Merge pull request #5 from andydixon/irc

IRC support
This commit was merged in pull request #5.
This commit is contained in:
Andy Dixon
2026-06-23 14:49:21 +01:00
committed by GitHub
24 changed files with 2729 additions and 51 deletions
+2
View File
@@ -23,6 +23,7 @@
<link rel="stylesheet" href="/src/css/modals.css" /> <link rel="stylesheet" href="/src/css/modals.css" />
<link rel="stylesheet" href="/src/css/github.css" /> <link rel="stylesheet" href="/src/css/github.css" />
<link rel="stylesheet" href="/src/css/vnc.css" /> <link rel="stylesheet" href="/src/css/vnc.css" />
<link rel="stylesheet" href="/src/css/irc.css" />
<link rel="stylesheet" href="/src/css/leviathan.css" /> <link rel="stylesheet" href="/src/css/leviathan.css" />
</head> </head>
<body> <body>
@@ -334,6 +335,7 @@
<script src="/src/js/github.js"></script> <script src="/src/js/github.js"></script>
<script src="/src/js/lib/novnc.min.js"></script> <script src="/src/js/lib/novnc.min.js"></script>
<script src="/src/js/vnc.js"></script> <script src="/src/js/vnc.js"></script>
<script src="/src/js/irc.js"></script>
<script src="/src/js/settings.js"></script> <script src="/src/js/settings.js"></script>
<script src="/src/js/commands.js"></script> <script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script> <script src="/src/js/secrets.js"></script>
+289
View File
@@ -0,0 +1,289 @@
/* ==========================================================================
Nexus Terminal - IRC viewer
========================================================================== */
.irc-viewer-panel {
background: var(--bg-base);
}
.irc-viewer {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.irc-main {
display: grid;
grid-template-rows: 34px minmax(0, 1fr) 42px;
min-width: 0;
min-height: 0;
}
.irc-room-tabs {
display: flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
padding: var(--space-1) var(--space-2);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
}
.irc-room-tab {
height: 24px;
max-width: 180px;
min-width: 44px;
padding: 0 var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.irc-room-tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.irc-room-tab.active {
background: var(--bg-active);
border-color: var(--border-default);
color: var(--text-primary);
}
.irc-room-tab.has-unread::after {
content: "";
display: inline-block;
width: 5px;
height: 5px;
margin-left: var(--space-1);
border-radius: 50%;
background: var(--protocol-irc);
vertical-align: middle;
}
.irc-terminal {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: var(--space-2);
}
.irc-terminal .xterm {
height: 100%;
}
.irc-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
padding: var(--space-2);
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
}
.irc-input {
min-width: 0;
height: 26px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.irc-input:focus,
.irc-join-input:focus,
.irc-filter-input:focus {
outline: none;
border-color: var(--border-focus);
}
.irc-send {
height: 26px;
}
.irc-sidebar {
display: grid;
grid-template-rows: minmax(88px, auto) minmax(120px, 1fr) minmax(120px, 1fr);
min-width: 0;
min-height: 0;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
}
.irc-sidebar-section {
min-height: 0;
padding: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
overflow: hidden;
}
.irc-users-section {
border-bottom: 0;
}
.irc-sidebar-title,
.irc-sidebar-title-row {
min-height: 24px;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.irc-sidebar-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.irc-join-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.irc-join-input,
.irc-filter-input {
min-width: 0;
height: 24px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-size: var(--text-xs);
}
.irc-filter-input {
width: 100%;
margin-bottom: var(--space-2);
}
.irc-sidebar-list,
.irc-room-list,
.irc-user-list {
min-height: 0;
max-height: 100%;
overflow-y: auto;
}
.irc-room-row,
.irc-user-row,
.irc-listed-room-main {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
}
.irc-room-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
height: 26px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
}
.irc-room-row:hover,
.irc-user-row:hover,
.irc-listed-room:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.irc-room-row.active {
background: var(--bg-active);
color: var(--text-primary);
}
.irc-room-name,
.irc-listed-room-name,
.irc-listed-room-topic {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.irc-room-count,
.irc-listed-room-count {
color: var(--text-tertiary);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.irc-listed-room {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: 34px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
.irc-listed-room-main {
display: grid;
gap: 1px;
}
.irc-listed-room-name {
color: var(--text-primary);
font-size: var(--text-xs);
}
.irc-listed-room-topic {
color: var(--text-tertiary);
font-size: 10px;
}
.irc-user-row {
display: block;
height: 24px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.irc-empty {
padding: var(--space-2);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
@media (max-width: 900px) {
.irc-viewer {
grid-template-columns: minmax(0, 1fr);
}
.irc-sidebar {
display: none;
}
}
+7
View File
@@ -304,6 +304,9 @@
.sidebar-item .protocol-indicator.proto-vnc::before { .sidebar-item .protocol-indicator.proto-vnc::before {
content: "⊞"; content: "⊞";
} }
.sidebar-item .protocol-indicator.proto-irc::before {
content: "#";
}
.sidebar-item .protocol-indicator.proto-local::before { .sidebar-item .protocol-indicator.proto-local::before {
content: "›_"; content: "›_";
} }
@@ -328,6 +331,10 @@
background: rgba(232, 93, 74, 0.18); background: rgba(232, 93, 74, 0.18);
color: var(--protocol-vnc); color: var(--protocol-vnc);
} }
.sidebar-item .protocol-indicator.proto-irc {
background: rgba(216, 178, 76, 0.18);
color: var(--protocol-irc);
}
.sidebar-item .protocol-indicator.proto-local { .sidebar-item .protocol-indicator.proto-local {
background: rgba(139, 195, 74, 0.18); background: rgba(139, 195, 74, 0.18);
color: var(--protocol-local); color: var(--protocol-local);
+4
View File
@@ -118,6 +118,10 @@
background: rgba(232, 93, 74, 0.15); background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc); color: var(--protocol-vnc);
} }
.tab-badge.badge-irc {
background: rgba(216, 178, 76, 0.15);
color: var(--protocol-irc);
}
.tab-badge.badge-local { .tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15); background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local); color: var(--protocol-local);
+1
View File
@@ -58,6 +58,7 @@
--protocol-raw: #ab47bc; --protocol-raw: #ab47bc;
--protocol-github: #7c8aff; --protocol-github: #7c8aff;
--protocol-vnc: #e85d4a; --protocol-vnc: #e85d4a;
--protocol-irc: #d8b24c;
--protocol-local: #8bc34a; --protocol-local: #8bc34a;
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
+50 -3
View File
@@ -35,6 +35,7 @@
this.terminals = new TerminalManager(this); this.terminals = new TerminalManager(this);
this.profileEditor = new ProfileEditor(this); this.profileEditor = new ProfileEditor(this);
this.rawViewer = new RawViewer(this); this.rawViewer = new RawViewer(this);
window.NexusTerminal.IRCViewer?.setApp(this);
this.settings = new Settings(this); this.settings = new Settings(this);
this.commandPalette = new CommandPalette(this); this.commandPalette = new CommandPalette(this);
this.secretPrompt = new SecretPrompt(this); this.secretPrompt = new SecretPrompt(this);
@@ -239,6 +240,7 @@
setTimeout(fit, 50); setTimeout(fit, 50);
setTimeout(fit, 300); setTimeout(fit, 300);
setTimeout(fit, 800); setTimeout(fit, 800);
setTimeout(() => window.NexusTerminal.IRCViewer?.fit(payload.sessionID), 50);
} }
}); });
@@ -288,6 +290,12 @@
); );
}); });
// IRC events
window.runtime.EventsOn("session:irc:event", (payload) => {
window.NexusTerminal.IRCViewer.handleEvent(payload);
this.tabs.markActivity(payload.sessionID);
});
// App-wide commands from the native menu // App-wide commands from the native menu
window.runtime.EventsOn("app:open-about", () => { window.runtime.EventsOn("app:open-about", () => {
this.settings.openTab("about"); this.settings.openTab("about");
@@ -801,7 +809,12 @@
const decision = await this._confirmReuseSession(profile, preferred); const decision = await this._confirmReuseSession(profile, preferred);
if (decision === "existing") { if (decision === "existing") {
this.tabs.setActive(preferred.id); this.tabs.setActive(preferred.id);
if (preferred.protocol !== "raw" && preferred.protocol !== "github") { if (
preferred.protocol !== "raw" &&
preferred.protocol !== "github" &&
preferred.protocol !== "vnc" &&
preferred.protocol !== "irc"
) {
this.terminals.focusTerminal(preferred.id); this.terminals.focusTerminal(preferred.id);
} }
return; return;
@@ -845,6 +858,15 @@
profile, profile,
); );
} }
} else if (profile.protocol === "irc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.IRCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else { } else {
this.terminals.createTerminal(sessionInfo.id, panelId, profile); this.terminals.createTerminal(sessionInfo.id, panelId, profile);
} }
@@ -901,6 +923,7 @@
<option value="ssh">SSH</option> <option value="ssh">SSH</option>
<option value="telnet">Telnet</option> <option value="telnet">Telnet</option>
<option value="raw">RAW TCP</option> <option value="raw">RAW TCP</option>
<option value="irc">IRC</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -915,6 +938,10 @@
<label class="form-label">Username</label> <label class="form-label">Username</label>
<input class="form-input qc-username" type="text" placeholder="root" /> <input class="form-input qc-username" type="text" placeholder="root" />
</div> </div>
<div class="form-group qc-nick-group" style="display:none">
<label class="form-label">Nick</label>
<input class="form-input qc-nick" type="text" value="nexus" />
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-sm qc-cancel">Cancel</button> <button class="btn btn-sm qc-cancel">Cancel</button>
@@ -931,14 +958,17 @@
const protocolSelect = modal.querySelector(".qc-protocol"); const protocolSelect = modal.querySelector(".qc-protocol");
const portInput = modal.querySelector(".qc-port"); const portInput = modal.querySelector(".qc-port");
const usernameGroup = modal.querySelector(".qc-username-group"); const usernameGroup = modal.querySelector(".qc-username-group");
const nickGroup = modal.querySelector(".qc-nick-group");
const hostInput = modal.querySelector(".qc-host"); const hostInput = modal.querySelector(".qc-host");
// Update port default and username visibility when protocol changes // Update port default and username visibility when protocol changes
protocolSelect.addEventListener("change", () => { protocolSelect.addEventListener("change", () => {
const proto = protocolSelect.value; const proto = protocolSelect.value;
const defaultPorts = { ssh: 22, telnet: 23, raw: 23 }; const defaultPorts = { ssh: 22, telnet: 23, raw: 23, irc: 6667 };
portInput.value = defaultPorts[proto] || 22; portInput.value = defaultPorts[proto] || 22;
usernameGroup.style.display = proto === "ssh" ? "" : "none"; usernameGroup.style.display =
proto === "ssh" || proto === "irc" ? "" : "none";
nickGroup.style.display = proto === "irc" ? "" : "none";
}); });
const close = (value) => { const close = (value) => {
@@ -965,6 +995,7 @@
host, host,
port: parseInt(portInput.value, 10) || 22, port: parseInt(portInput.value, 10) || 22,
username: modal.querySelector(".qc-username").value.trim(), username: modal.querySelector(".qc-username").value.trim(),
nick: modal.querySelector(".qc-nick").value.trim(),
}); });
}); });
backdrop.addEventListener("click", (e) => { backdrop.addEventListener("click", (e) => {
@@ -989,6 +1020,13 @@
displayName: result.host, displayName: result.host,
tags: [], tags: [],
}; };
if (result.protocol === "irc") {
profile.irc = {
nick: result.nick || "nexus",
realName: "Nexus Terminal",
autoJoinRooms: [],
};
}
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile); const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
this.state.addSession(sessionInfo); this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo); this.tabs.addTab(sessionInfo);
@@ -999,6 +1037,15 @@
protocol: "raw", protocol: "raw",
raw: {}, raw: {},
}); });
} else if (result.protocol === "irc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.IRCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else { } else {
this.terminals.createTerminal(sessionInfo.id, panelId, { this.terminals.createTerminal(sessionInfo.id, panelId, {
protocol: result.protocol, protocol: result.protocol,
+617
View File
@@ -0,0 +1,617 @@
(function () {
/**
* Nexus Terminal - IRC viewer.
* Uses xterm.js as the chat stream, with room subtabs, a composer, and
* room/user sidebars.
*/
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */
const { createElement } = window.NexusTerminal;
const SERVER_TARGET = "server";
const MAX_LINES_PER_TARGET = 2000;
const MAX_LISTED_ROOMS = 200;
class IRCViewer {
constructor() {
this._viewers = new Map();
this.app = null;
}
setApp(app) {
this.app = app;
}
createViewer(sessionID, container, profile) {
if (this._viewers.has(sessionID) || !container) return;
const viewer = new IRCViewerInstance(sessionID, container, profile, this.app);
this._viewers.set(sessionID, viewer);
viewer.render();
}
destroyViewer(sessionID) {
const viewer = this._viewers.get(sessionID);
if (!viewer) return;
viewer.destroy();
this._viewers.delete(sessionID);
}
handleEvent(payload) {
const sessionID = payload?.sessionID;
const event = payload?.event || payload;
const viewer = this._viewers.get(sessionID);
if (viewer) viewer.handleEvent(event);
}
fit(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) viewer.fit();
}
}
class IRCViewerInstance {
constructor(sessionID, container, profile, app) {
this.sessionID = sessionID;
this.container = container;
this.profile = profile || {};
this.app = app || null;
this.term = null;
this.fitAddon = null;
this.searchAddon = null;
this.observer = null;
this.activeTarget = SERVER_TARGET;
this.targets = new Map();
this.users = new Map();
this.joinedRooms = [];
this.roomList = [];
this.roomFilter = "";
this.unreadTargets = new Set();
this.tabsEl = null;
this.termHost = null;
this.inputEl = null;
this.roomsEl = null;
this.usersEl = null;
this.roomListEl = null;
this.roomFilterEl = null;
this._ensureTarget(SERVER_TARGET, "Server");
}
render() {
this.container.innerHTML = "";
this.container.classList.add("irc-viewer-panel");
const wrapper = createElement("div", "irc-viewer");
const main = createElement("div", "irc-main");
this.tabsEl = createElement("div", "irc-room-tabs");
main.appendChild(this.tabsEl);
this.termHost = createElement("div", "irc-terminal");
main.appendChild(this.termHost);
const composer = createElement("form", "irc-composer");
this.inputEl = createElement("input", "irc-input", {
type: "text",
placeholder: "Message or /command",
autocomplete: "off",
});
const sendBtn = createElement("button", "btn btn-sm btn-primary irc-send", {
type: "submit",
textContent: "Send",
});
composer.appendChild(this.inputEl);
composer.appendChild(sendBtn);
composer.addEventListener("submit", (event) => {
event.preventDefault();
this._sendComposer();
});
this.inputEl.addEventListener("keydown", (event) => {
if (event.key === "Tab" && this._completeMention()) {
event.preventDefault();
}
});
main.appendChild(composer);
const sidebar = this._buildSidebar();
wrapper.appendChild(main);
wrapper.appendChild(sidebar);
this.container.appendChild(wrapper);
this._openTerminal();
this._renderTabs();
this._renderRooms();
this._renderUsers();
this._renderRoomList();
this._appendLine(SERVER_TARGET, {
type: "system",
text: "Connecting...",
timestamp: Date.now(),
});
}
handleEvent(event) {
if (!event || !event.type) return;
if (event.type === "rooms") {
this.joinedRooms = Array.isArray(event.rooms) ? event.rooms : [];
for (const room of this.joinedRooms) {
this._ensureTarget(room.name, room.name);
}
this._renderTabs();
this._renderRooms();
return;
}
if (event.type === "users") {
const target = event.target;
if (target) {
this.users.set(target, Array.isArray(event.users) ? event.users : []);
if (target === this.activeTarget) this._renderUsers();
}
return;
}
if (event.type === "room-list") {
this.roomList = Array.isArray(event.rooms) ? event.rooms : [];
this._renderRoomList();
return;
}
const target = this._eventTarget(event);
this._ensureTarget(target, this._targetLabel(target));
this._appendLine(target, event);
if (target !== this.activeTarget) {
this.unreadTargets.add(target);
this._renderTabs();
}
if (["join", "part", "topic"].includes(event.type)) {
this._renderRooms();
}
}
fit() {
if (!this.fitAddon) return;
requestAnimationFrame(() => {
try {
this.fitAddon.fit();
} catch (_) {
/* noop */
}
});
}
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.term) {
this.term.dispose();
this.term = null;
}
this.container.classList.remove("irc-viewer-panel");
this.container.innerHTML = "";
}
_buildSidebar() {
const sidebar = createElement("aside", "irc-sidebar");
const joinSection = createElement("div", "irc-sidebar-section");
joinSection.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Rooms" }),
);
const joinRow = createElement("form", "irc-join-row");
const joinInput = createElement("input", "irc-join-input", {
type: "text",
placeholder: "#room",
autocomplete: "off",
});
const joinBtn = createElement("button", "btn btn-xs btn-secondary", {
type: "submit",
textContent: "Join",
});
joinRow.appendChild(joinInput);
joinRow.appendChild(joinBtn);
joinRow.addEventListener("submit", async (event) => {
event.preventDefault();
const room = joinInput.value.trim();
if (!room) return;
try {
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
joinInput.value = "";
} catch (err) {
this._notifyError("Join failed: " + err);
}
});
joinSection.appendChild(joinRow);
this.roomsEl = createElement("div", "irc-sidebar-list");
joinSection.appendChild(this.roomsEl);
sidebar.appendChild(joinSection);
const listSection = createElement("div", "irc-sidebar-section");
const listTitleRow = createElement("div", "irc-sidebar-title-row");
listTitleRow.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Room List" }),
);
const listBtn = createElement("button", "btn btn-xs btn-outline", {
type: "button",
textContent: "List",
});
listBtn.addEventListener("click", async () => {
this.roomList = [];
this._renderRoomList("Loading rooms...");
try {
await window.NexusTerminal.wailsService.IRCListRooms(this.sessionID);
} catch (err) {
this._renderRoomList("Room list failed");
this._notifyError("Room list failed: " + err);
}
});
listTitleRow.appendChild(listBtn);
listSection.appendChild(listTitleRow);
this.roomFilterEl = createElement("input", "irc-filter-input", {
type: "text",
placeholder: "Filter rooms",
autocomplete: "off",
});
this.roomFilterEl.addEventListener("input", () => {
this.roomFilter = this.roomFilterEl.value.toLowerCase();
this._renderRoomList();
});
listSection.appendChild(this.roomFilterEl);
this.roomListEl = createElement("div", "irc-room-list");
listSection.appendChild(this.roomListEl);
sidebar.appendChild(listSection);
const usersSection = createElement("div", "irc-sidebar-section irc-users-section");
usersSection.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Users" }),
);
this.usersEl = createElement("div", "irc-user-list");
usersSection.appendChild(this.usersEl);
sidebar.appendChild(usersSection);
return sidebar;
}
_openTerminal() {
const themeName =
(this.app?.state?.preferences || {}).terminalTheme || "charcoal";
this.term = new Terminal({
theme: window.NexusTerminal.getTerminalTheme(themeName),
fontFamily:
this.profile.terminalFontFamily ||
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
fontSize: this.profile.terminalFontSize || 14,
scrollback: this.profile.terminalScrollback || 10000,
cursorBlink: false,
convertEol: true,
disableStdin: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.searchAddon = new SearchAddon.SearchAddon();
this.term.loadAddon(this.fitAddon);
this.term.loadAddon(this.searchAddon);
try {
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_event, uri) => {
window.runtime.BrowserOpenURL(uri);
}));
} catch (_) {
/* optional */
}
this.term.open(this.termHost);
this.fit();
this.observer = new ResizeObserver(() => this.fit());
this.observer.observe(this.termHost);
}
_renderTabs() {
if (!this.tabsEl) return;
this.tabsEl.innerHTML = "";
for (const [target, state] of this.targets) {
const btn = createElement(
"button",
`irc-room-tab ${target === this.activeTarget ? "active" : ""} ${
this.unreadTargets.has(target) ? "has-unread" : ""
}`,
{
type: "button",
textContent: state.label,
title: state.label,
},
);
btn.addEventListener("click", () => this._setActiveTarget(target));
this.tabsEl.appendChild(btn);
}
}
_renderRooms() {
if (!this.roomsEl) return;
this.roomsEl.innerHTML = "";
if (this.joinedRooms.length === 0) {
this.roomsEl.appendChild(
createElement("div", "irc-empty", { textContent: "No joined rooms" }),
);
return;
}
for (const room of this.joinedRooms) {
const row = createElement("button", "irc-room-row", {
type: "button",
title: room.topic || room.name,
});
row.classList.toggle("active", room.name === this.activeTarget);
row.appendChild(createElement("span", "irc-room-name", { textContent: room.name }));
row.appendChild(
createElement("span", "irc-room-count", {
textContent: String(room.users || 0),
}),
);
row.addEventListener("click", () => this._setActiveTarget(room.name));
this.roomsEl.appendChild(row);
}
}
_renderUsers() {
if (!this.usersEl) return;
this.usersEl.innerHTML = "";
const users = this.users.get(this.activeTarget) || [];
if (!this._isRoom(this.activeTarget)) {
this.usersEl.appendChild(
createElement("div", "irc-empty", { textContent: "No room selected" }),
);
return;
}
if (users.length === 0) {
this.usersEl.appendChild(
createElement("div", "irc-empty", { textContent: "No users yet" }),
);
return;
}
for (const user of users) {
const row = createElement("button", "irc-user-row", {
type: "button",
textContent: this._displayNick(user),
title: user,
});
row.addEventListener("click", () => {
this._insertMention(this._displayNick(user));
});
this.usersEl.appendChild(row);
}
}
_renderRoomList(message = "") {
if (!this.roomListEl) return;
this.roomListEl.innerHTML = "";
if (message) {
this.roomListEl.appendChild(
createElement("div", "irc-empty", { textContent: message }),
);
return;
}
let rooms = this.roomList;
if (this.roomFilter) {
rooms = rooms.filter((room) => {
const haystack = `${room.name || ""} ${room.topic || ""}`.toLowerCase();
return haystack.includes(this.roomFilter);
});
}
rooms = rooms.slice(0, MAX_LISTED_ROOMS);
if (rooms.length === 0) {
this.roomListEl.appendChild(
createElement("div", "irc-empty", { textContent: "No rooms loaded" }),
);
return;
}
for (const room of rooms) {
const row = createElement("div", "irc-listed-room");
const meta = createElement("button", "irc-listed-room-main", {
type: "button",
title: room.topic || room.name,
});
meta.appendChild(
createElement("span", "irc-listed-room-name", {
textContent: room.name || "",
}),
);
meta.appendChild(
createElement("span", "irc-listed-room-topic", {
textContent: room.topic || "",
}),
);
meta.addEventListener("click", () => this._joinRoom(room.name));
const count = createElement("span", "irc-listed-room-count", {
textContent: String(room.users || 0),
});
row.appendChild(meta);
row.appendChild(count);
this.roomListEl.appendChild(row);
}
}
_setActiveTarget(target) {
this._ensureTarget(target, this._targetLabel(target));
this.activeTarget = target;
this.unreadTargets.delete(target);
this._renderTabs();
this._renderRooms();
this._renderUsers();
this._replayActiveTarget();
if (this.inputEl) this.inputEl.focus();
}
_appendLine(target, event) {
const state = this._ensureTarget(target, this._targetLabel(target));
const line = this._formatEvent(event);
if (!line) return;
state.lines.push(line);
if (state.lines.length > MAX_LINES_PER_TARGET) {
state.lines.splice(0, state.lines.length - MAX_LINES_PER_TARGET);
}
if (target === this.activeTarget && this.term) {
this.term.writeln(line);
this.term.scrollToBottom();
}
}
_replayActiveTarget() {
if (!this.term) return;
this.term.clear();
const state = this.targets.get(this.activeTarget);
for (const line of state?.lines || []) {
this.term.writeln(line);
}
this.term.scrollToBottom();
this.fit();
}
async _sendComposer() {
const text = this.inputEl.value;
if (!text.trim()) return;
this.inputEl.value = "";
try {
await window.NexusTerminal.wailsService.IRCHandleInput(
this.sessionID,
this.activeTarget,
text,
);
} catch (err) {
this._notifyError(String(err));
this._appendLine(this.activeTarget, {
type: "error",
text: String(err),
timestamp: Date.now(),
});
}
}
async _joinRoom(room) {
if (!room) return;
try {
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
} catch (err) {
this._notifyError("Join failed: " + err);
}
}
_completeMention() {
if (!this.inputEl || !this._isRoom(this.activeTarget)) return false;
const start = this.inputEl.selectionStart || 0;
const before = this.inputEl.value.slice(0, start);
const match = before.match(/(^|\s)@([^\s@]*)$/);
if (!match) return false;
const prefix = match[2].toLowerCase();
const users = this.users.get(this.activeTarget) || [];
const nick = users
.map((user) => this._displayNick(user))
.find((user) => user.toLowerCase().startsWith(prefix));
if (!nick) return false;
const replacement = `${match[1]}@${nick} `;
const nextValue =
this.inputEl.value.slice(0, start - match[0].length) +
replacement +
this.inputEl.value.slice(start);
const caret = start - match[0].length + replacement.length;
this.inputEl.value = nextValue;
this.inputEl.setSelectionRange(caret, caret);
return true;
}
_insertMention(nick) {
if (!this.inputEl) return;
const current = this.inputEl.value;
const spacer = current && !/\s$/.test(current) ? " " : "";
this.inputEl.value = `${current}${spacer}@${nick} `;
this.inputEl.focus();
}
_ensureTarget(target, label) {
const safeTarget = target || SERVER_TARGET;
if (!this.targets.has(safeTarget)) {
this.targets.set(safeTarget, {
label: label || this._targetLabel(safeTarget),
lines: [],
});
}
return this.targets.get(safeTarget);
}
_eventTarget(event) {
if (!event) return SERVER_TARGET;
if (event.target) return event.target;
return SERVER_TARGET;
}
_targetLabel(target) {
return target === SERVER_TARGET ? "Server" : target;
}
_formatEvent(event) {
const time = this._time(event.timestamp);
const from = this._sanitize(event.from || "");
const text = this._sanitize(event.text || "");
switch (event.type) {
case "message":
return `[${time}] <${from}> ${text}`;
case "action":
return `[${time}] * ${from} ${text}`;
case "notice":
return `[${time}] -${from || "notice"}- ${text}`;
case "join":
return `[${time}] --> ${from || "user"} joined`;
case "part":
return `[${time}] <-- ${from || "user"} left${text ? ` (${text})` : ""}`;
case "quit":
return `[${time}] <-- ${from || "user"} quit${text ? ` (${text})` : ""}`;
case "nick":
return `[${time}] ${from || "user"} is now known as ${text}`;
case "topic":
return `[${time}] topic: ${text}`;
case "error":
return `[${time}] error: ${text}`;
case "system":
default:
return `[${time}] ${text}`;
}
}
_time(value) {
const date = value ? new Date(value) : new Date();
const hours = String(date.getHours()).padStart(2, "0");
const mins = String(date.getMinutes()).padStart(2, "0");
const secs = String(date.getSeconds()).padStart(2, "0");
return `${hours}:${mins}:${secs}`;
}
_sanitize(value) {
return String(value || "")
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "")
.replace(/[\x02\x0f\x16\x1d\x1f]/g, "");
}
_displayNick(user) {
return String(user || "").replace(/^[~&@%+]+/, "");
}
_isRoom(target) {
return /^[#&+!]/.test(target || "");
}
_notifyError(message) {
if (this.app?.notifications) {
this.app.notifications.error(message);
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.IRCViewer = new IRCViewer();
})();
+270 -2
View File
@@ -86,6 +86,31 @@
sshAgentAuth: false, sshAgentAuth: false,
}, },
}, },
irc: {
port: 6667,
protocol: "irc",
irc: {
nick: "nexus",
realName: "Nexus Terminal",
useTLS: false,
serverPasswordRequired: false,
serverPasswordMode: "ask-every-time",
nickServIdentify: false,
nickServAccount: "",
nickServMode: "saved",
autoJoinRooms: [],
sshTunnel: false,
sshHost: "",
sshPort: 22,
sshUsername: "",
sshAuthMethod: "publickey",
sshSecretMode: "saved",
sshPrivateKey: "",
sshAgentAuth: false,
autoReconnect: true,
maxReconnectTries: 5,
},
},
}; };
class ProfileEditor { class ProfileEditor {
@@ -162,10 +187,11 @@
drawer.appendChild(header); drawer.appendChild(header);
// ── Protocol selector tabs ────────────────────────────── // ── Protocol selector tabs ──────────────────────────────
const protocols = ["ssh", "telnet", "raw", "github", "vnc"]; const protocols = ["ssh", "telnet", "raw", "github", "vnc", "irc"];
const tabBar = createElement("div", "drawer-protocol-tabs"); const tabBar = createElement("div", "drawer-protocol-tabs");
for (const proto of protocols) { for (const proto of protocols) {
const label = proto === "github" ? "GitHub" : proto.toUpperCase(); const label =
proto === "github" ? "GitHub" : proto === "irc" ? "IRC" : proto.toUpperCase();
const btn = createElement( const btn = createElement(
"button", "button",
`drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`, `drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`,
@@ -208,6 +234,10 @@
case "vnc": case "vnc":
this._renderVNCSections(profile).forEach((s) => body.appendChild(s)); this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
break; break;
case "irc":
this._renderIRCSections(profile).forEach((s) => body.appendChild(s));
body.appendChild(sshKeyDatalist);
break;
} }
// Terminal section (SSH and Telnet only) // Terminal section (SSH and Telnet only)
@@ -1075,6 +1105,185 @@
return sections; return sections;
} }
/** @private */
_renderIRCSections(profile) {
const sections = [];
const irc = profile.irc || DEFAULTS.irc.irc;
sections.push(
this._renderSection("IRC", [
this._field("text", "irc.nick", "Nick", irc.nick, {
placeholder: "yourNick",
}),
this._field("text", "irc.realName", "Real Name", irc.realName, {
placeholder: "Shown in WHOIS",
}),
this._field("toggle", "irc.useTLS", "Use TLS", irc.useTLS, {
help: "Use port 6697 for most TLS IRC networks.",
}),
this._field(
"toggle",
"irc.serverPasswordRequired",
"Server Password",
irc.serverPasswordRequired,
{
help: "Prompt for an IRC PASS secret before registration.",
},
),
this._field(
"select",
"irc.serverPasswordMode",
"Server Password Storage",
irc.serverPasswordMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("NickServ", [
this._field(
"toggle",
"irc.nickServIdentify",
"Identify on Connect",
irc.nickServIdentify,
),
this._field(
"text",
"irc.nickServAccount",
"Account",
irc.nickServAccount,
{
placeholder: "Optional if it matches your nick",
},
),
this._field(
"select",
"irc.nickServMode",
"Password Storage",
irc.nickServMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("Rooms", [
this._field(
"textarea",
"irc.autoJoinRoomsText",
"Auto-Join Rooms",
this._roomsToText(irc.autoJoinRooms),
{
placeholder: "#general\n#ops",
rows: 4,
help: "One room per line or comma-separated.",
},
),
]),
);
sections.push(
this._renderSection("SSH Tunnel", [
this._field(
"toggle",
"irc.sshTunnel",
"Enable SSH Tunnel",
irc.sshTunnel,
),
this._field("text", "irc.sshHost", "SSH Host", irc.sshHost, {
placeholder: "jump.example.com",
}),
this._field("number", "irc.sshPort", "SSH Port", irc.sshPort, {
min: 1,
max: 65535,
}),
this._field(
"text",
"irc.sshUsername",
"SSH Username",
irc.sshUsername,
{
placeholder: "admin",
},
),
this._field(
"select",
"irc.sshAuthMethod",
"SSH Auth Method",
irc.sshAuthMethod,
{
options: [
{ value: "publickey", label: "Public Key" },
{ value: "password", label: "Password" },
],
},
),
this._field(
"text",
"irc.sshPrivateKey",
"SSH Private Key Path",
irc.sshPrivateKey,
{
placeholder: "~/.ssh/id_ed25519",
datalistId: "ssh-key-path-options",
},
),
this._field(
"toggle",
"irc.sshAgentAuth",
"Use SSH Agent",
irc.sshAgentAuth,
),
this._field(
"select",
"irc.sshSecretMode",
"SSH Secret Storage",
irc.sshSecretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("Reconnection", [
this._field(
"toggle",
"irc.autoReconnect",
"Auto-Reconnect",
irc.autoReconnect,
),
this._field(
"number",
"irc.maxReconnectTries",
"Max Reconnect Attempts",
irc.maxReconnectTries || 5,
{ min: 1, max: 20 },
),
]),
);
return sections;
}
/** @private */ /** @private */
_renderTerminalSection(profile) { _renderTerminalSection(profile) {
return this._renderSection("Terminal", [ return this._renderSection("Terminal", [
@@ -2275,6 +2484,34 @@
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false; p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved"; p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
} }
// IRC-specific
if (p.protocol === "irc") {
if (!p.irc) p.irc = {};
p.irc.nick = val("irc.nick") || "nexus";
p.irc.realName = val("irc.realName") || "Nexus Terminal";
p.irc.useTLS = val("irc.useTLS") || false;
p.irc.serverPasswordRequired =
val("irc.serverPasswordRequired") || false;
p.irc.serverPasswordMode =
val("irc.serverPasswordMode") || "ask-every-time";
p.irc.nickServIdentify = val("irc.nickServIdentify") || false;
p.irc.nickServAccount = val("irc.nickServAccount") || "";
p.irc.nickServMode = val("irc.nickServMode") || "saved";
p.irc.autoJoinRooms = this._parseRoomList(
val("irc.autoJoinRoomsText") || "",
);
p.irc.sshTunnel = val("irc.sshTunnel") || false;
p.irc.sshHost = val("irc.sshHost") || "";
p.irc.sshPort = val("irc.sshPort") || 22;
p.irc.sshUsername = val("irc.sshUsername") || "";
p.irc.sshAuthMethod = val("irc.sshAuthMethod") || "publickey";
p.irc.sshPrivateKey = val("irc.sshPrivateKey") || "";
p.irc.sshAgentAuth = val("irc.sshAgentAuth") || false;
p.irc.sshSecretMode = val("irc.sshSecretMode") || "saved";
p.irc.autoReconnect = val("irc.autoReconnect") !== false;
p.irc.maxReconnectTries = val("irc.maxReconnectTries") || 5;
}
} }
// ── Validation ─────────────────────────────────────────────────── // ── Validation ───────────────────────────────────────────────────
@@ -2300,6 +2537,25 @@
if (!profile.port || profile.port < 1 || profile.port > 65535) { if (!profile.port || profile.port < 1 || profile.port > 65535) {
errors.push("Port must be between 1 and 65535."); errors.push("Port must be between 1 and 65535.");
} }
if (profile.protocol === "irc") {
const nick = profile.irc?.nick || "";
if (!nick.trim()) {
errors.push("IRC nick is required.");
}
for (const room of profile.irc?.autoJoinRooms || []) {
if (!/^[#&+!][^\s,]+$/.test(room)) {
errors.push(`Invalid IRC room "${room}".`);
}
}
if (profile.irc?.sshTunnel) {
if (!profile.irc.sshHost?.trim()) {
errors.push("SSH Host is required when tunnelling IRC.");
}
if (!profile.irc.sshUsername?.trim()) {
errors.push("SSH Username is required when tunnelling IRC.");
}
}
}
return errors; return errors;
} }
@@ -2476,6 +2732,7 @@
raw: null, raw: null,
github: null, github: null,
vnc: null, vnc: null,
irc: null,
}; };
const defaults = DEFAULTS[protocol] || {}; const defaults = DEFAULTS[protocol] || {};
@@ -2494,6 +2751,17 @@
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join("\n"); .join("\n");
} }
_roomsToText(rooms) {
return Array.isArray(rooms) ? rooms.join("\n") : "";
}
_parseRoomList(value) {
return String(value || "")
.split(/[\n,]/)
.map((room) => room.trim())
.filter(Boolean);
}
} }
window.NexusTerminal = window.NexusTerminal || {}; window.NexusTerminal = window.NexusTerminal || {};
+2
View File
@@ -926,6 +926,8 @@
return "TELNET"; return "TELNET";
case "raw": case "raw":
return "RAW"; return "RAW";
case "irc":
return "IRC";
default: default:
return (protocol || "").toUpperCase() || "UNKNOWN"; return (protocol || "").toUpperCase() || "UNKNOWN";
} }
+1
View File
@@ -191,6 +191,7 @@
this.app.terminals.destroyTerminal(sessionId); this.app.terminals.destroyTerminal(sessionId);
this.app.rawViewer.destroyViewer(sessionId); this.app.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId); window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
window.NexusTerminal.IRCViewer.destroyViewer(sessionId);
this.app.leviathan?.destroyPanel(sessionId); this.app.leviathan?.destroyPanel(sessionId);
this.removeTab(sessionId); this.removeTab(sessionId);
this.app.state.removeSession(sessionId); this.app.state.removeSession(sessionId);
+2
View File
@@ -290,6 +290,8 @@
return "proto-github"; return "proto-github";
case "vnc": case "vnc":
return "proto-vnc"; return "proto-vnc";
case "irc":
return "proto-irc";
case "local": case "local":
return "proto-local"; return "proto-local";
default: default:
+12
View File
@@ -80,6 +80,18 @@ export function GetVNCWebSocketURL(arg1:string):Promise<string>;
export function HasTOTPSecret(arg1:string):Promise<boolean>; export function HasTOTPSecret(arg1:string):Promise<boolean>;
export function IRCHandleInput(arg1:string,arg2:string,arg3:string):Promise<void>;
export function IRCJoin(arg1:string,arg2:string):Promise<void>;
export function IRCListRooms(arg1:string):Promise<void>;
export function IRCPart(arg1:string,arg2:string):Promise<void>;
export function IRCRooms(arg1:string):Promise<Array<Record<string, any>>>;
export function IRCUsers(arg1:string,arg2:string):Promise<Array<string>>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>; export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>; export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
+24
View File
@@ -154,6 +154,30 @@ export function HasTOTPSecret(arg1) {
return window['go']['api']['Service']['HasTOTPSecret'](arg1); return window['go']['api']['Service']['HasTOTPSecret'](arg1);
} }
export function IRCHandleInput(arg1, arg2, arg3) {
return window['go']['api']['Service']['IRCHandleInput'](arg1, arg2, arg3);
}
export function IRCJoin(arg1, arg2) {
return window['go']['api']['Service']['IRCJoin'](arg1, arg2);
}
export function IRCListRooms(arg1) {
return window['go']['api']['Service']['IRCListRooms'](arg1);
}
export function IRCPart(arg1, arg2) {
return window['go']['api']['Service']['IRCPart'](arg1, arg2);
}
export function IRCRooms(arg1) {
return window['go']['api']['Service']['IRCRooms'](arg1);
}
export function IRCUsers(arg1, arg2) {
return window['go']['api']['Service']['IRCUsers'](arg1, arg2);
}
export function ImportProfiles(arg1, arg2) { export function ImportProfiles(arg1, arg2) {
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2); return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
} }
+50
View File
@@ -181,6 +181,54 @@ export namespace models {
this.updatedAt = source["updatedAt"]; this.updatedAt = source["updatedAt"];
} }
} }
export class IRCConfig {
nick: string;
realName: string;
useTLS: boolean;
serverPasswordRequired: boolean;
serverPasswordMode: string;
nickServIdentify: boolean;
nickServAccount: string;
nickServMode: string;
autoJoinRooms: string[];
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
static createFrom(source: any = {}) {
return new IRCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.nick = source["nick"];
this.realName = source["realName"];
this.useTLS = source["useTLS"];
this.serverPasswordRequired = source["serverPasswordRequired"];
this.serverPasswordMode = source["serverPasswordMode"];
this.nickServIdentify = source["nickServIdentify"];
this.nickServAccount = source["nickServAccount"];
this.nickServMode = source["nickServMode"];
this.autoJoinRooms = source["autoJoinRooms"];
this.sshTunnel = source["sshTunnel"];
this.sshHost = source["sshHost"];
this.sshPort = source["sshPort"];
this.sshUsername = source["sshUsername"];
this.sshAuthMethod = source["sshAuthMethod"];
this.sshSecretMode = source["sshSecretMode"];
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
this.sshAgentAuth = source["sshAgentAuth"];
this.autoReconnect = source["autoReconnect"];
this.maxReconnectTries = source["maxReconnectTries"];
}
}
export class JumpHost { export class JumpHost {
host: string; host: string;
port: number; port: number;
@@ -520,6 +568,7 @@ export namespace models {
raw?: RAWConfig; raw?: RAWConfig;
github?: GitHubConfig; github?: GitHubConfig;
vnc?: VNCConfig; vnc?: VNCConfig;
irc?: IRCConfig;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Profile(source); return new Profile(source);
@@ -553,6 +602,7 @@ export namespace models {
this.raw = this.convertValues(source["raw"], RAWConfig); this.raw = this.convertValues(source["raw"], RAWConfig);
this.github = this.convertValues(source["github"], GitHubConfig); this.github = this.convertValues(source["github"], GitHubConfig);
this.vnc = this.convertValues(source["vnc"], VNCConfig); this.vnc = this.convertValues(source["vnc"], VNCConfig);
this.irc = this.convertValues(source["irc"], IRCConfig);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
+1
View File
@@ -8,6 +8,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4 github.com/hajimehoshi/go-mp3 v0.3.4
github.com/klauspost/compress v1.17.11 github.com/klauspost/compress v1.17.11
github.com/lrstanley/girc v1.1.1
github.com/mattn/go-sqlite3 v1.14.44 github.com/mattn/go-sqlite3 v1.14.44
github.com/pkg/sftp v1.13.10 github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
+2
View File
@@ -43,6 +43,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+47
View File
@@ -393,6 +393,7 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
if profile.ID == "" { if profile.ID == "" {
profile.ID = uuid.New().String() profile.ID = uuid.New().String()
} }
normaliseProfileForSave(&profile)
sessionID, err := s.sessions.Connect(&profile, s.promptSecret) sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
if err != nil { if err != nil {
return models.SessionInfo{}, err return models.SessionInfo{}, err
@@ -527,6 +528,52 @@ func (s *Service) SendBreak(sessionID string) error {
return s.sessions.SendBreak(sessionID) return s.sessions.SendBreak(sessionID)
} }
// ---------------------------------------------------------------------------
// IRC-specific
// ---------------------------------------------------------------------------
// IRCHandleInput sends an IRC composer line, including slash commands.
func (s *Service) IRCHandleInput(sessionID string, target string, input string) error {
return s.sessions.IRCHandleInput(sessionID, target, input)
}
// IRCJoin joins an IRC room.
func (s *Service) IRCJoin(sessionID string, room string) error {
return s.sessions.IRCJoin(sessionID, room)
}
// IRCPart leaves an IRC room.
func (s *Service) IRCPart(sessionID string, room string) error {
return s.sessions.IRCPart(sessionID, room)
}
// IRCListRooms requests the server room list. Results are emitted as events.
func (s *Service) IRCListRooms(sessionID string) error {
return s.sessions.IRCListRooms(sessionID)
}
// IRCRooms returns currently joined IRC rooms.
func (s *Service) IRCRooms(sessionID string) ([]map[string]interface{}, error) {
rooms, err := s.sessions.IRCRooms(sessionID)
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(rooms))
for i, room := range rooms {
result[i] = map[string]interface{}{
"name": room.Name,
"topic": room.Topic,
"users": room.Users,
}
}
return result, nil
}
// IRCUsers returns the known users in a joined IRC room.
func (s *Service) IRCUsers(sessionID string, room string) ([]string, error) {
return s.sessions.IRCUsers(sessionID, room)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Secret store // Secret store
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+767
View File
@@ -0,0 +1,767 @@
// Package ircclient wraps a full IRC client with Nexus-specific event and
// command handling, including optional SSH tunnelling.
package ircclient
import (
"crypto/tls"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/lrstanley/girc"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
const ServerTarget = "server"
const maxRoomListEntries = 2000
// Config describes an IRC connection.
type Config struct {
Server string
Port int
Nick string
User string
RealName string
UseTLS bool
ServerPass string
AutoJoinRooms []string
NickServIdentify bool
NickServAccount string
NickServPassword string
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
SSHHostKeyCallback ssh.HostKeyCallback
ConnectTimeout time.Duration
OnEvent func(Event)
OnError func(error)
OnClose func()
}
// Event is a typed IRC event suitable for JSON delivery to the frontend.
type Event struct {
Type string `json:"type"`
Target string `json:"target,omitempty"`
From string `json:"from,omitempty"`
Text string `json:"text,omitempty"`
Command string `json:"command,omitempty"`
Timestamp int64 `json:"timestamp"`
Rooms []RoomInfo `json:"rooms,omitempty"`
Users []string `json:"users,omitempty"`
Topic string `json:"topic,omitempty"`
}
// RoomInfo describes a joined or listed IRC room.
type RoomInfo struct {
Name string `json:"name"`
Topic string `json:"topic,omitempty"`
Users int `json:"users,omitempty"`
}
// Client owns a girc client and optional SSH tunnel.
type Client struct {
cfg Config
client *girc.Client
sshClient *ssh.Client
agentConn net.Conn
mu sync.Mutex
connected bool
closing bool
listRooms []RoomInfo
}
// New creates an IRC client wrapper.
func New(cfg Config) *Client {
if cfg.ConnectTimeout <= 0 {
cfg.ConnectTimeout = 30 * time.Second
}
if cfg.User == "" {
cfg.User = cfg.Nick
}
if cfg.RealName == "" {
cfg.RealName = cfg.Nick
}
c := &Client{cfg: cfg}
c.client = girc.New(girc.Config{
Server: cfg.Server,
ServerPass: cfg.ServerPass,
Port: cfg.Port,
Nick: cfg.Nick,
User: cfg.User,
Name: cfg.RealName,
SSL: cfg.UseTLS && !cfg.SSHTunnel,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: cfg.Server,
},
AllowFlood: false,
})
c.registerHandlers()
return c
}
// Connect starts the IRC connection and returns once registration succeeds.
func (c *Client) Connect() error {
connectedCh := make(chan struct{}, 1)
errCh := make(chan error, 1)
var connectedOnce sync.Once
c.client.Handlers.Add(girc.CONNECTED, func(gc *girc.Client, e girc.Event) {
c.mu.Lock()
c.connected = true
c.mu.Unlock()
c.emit(Event{
Type: "system",
Target: ServerTarget,
Text: fmt.Sprintf("Connected to %s", gc.Server()),
Timestamp: eventTime(e),
})
c.identifyWithNickServ(gc)
if len(c.cfg.AutoJoinRooms) > 0 {
gc.Cmd.Join(c.cfg.AutoJoinRooms...)
}
c.emitRooms()
connectedOnce.Do(func() {
select {
case connectedCh <- struct{}{}:
default:
}
})
})
go func() {
var err error
if c.cfg.SSHTunnel {
dialer, dialerErr := c.newSSHDialer()
if dialerErr != nil {
select {
case errCh <- dialerErr:
default:
}
return
}
err = c.client.DialerConnect(dialer)
} else {
err = c.client.Connect()
}
c.mu.Lock()
wasClosing := c.closing
wasConnected := c.connected
c.connected = false
c.mu.Unlock()
if err != nil && !wasClosing {
c.emit(Event{
Type: "error",
Target: ServerTarget,
Text: err.Error(),
Timestamp: time.Now().UnixMilli(),
})
if c.cfg.OnError != nil {
c.cfg.OnError(err)
}
select {
case errCh <- err:
default:
}
}
if wasConnected && !wasClosing && c.cfg.OnClose != nil {
c.cfg.OnClose()
}
}()
select {
case <-connectedCh:
return nil
case err := <-errCh:
return err
case <-time.After(c.cfg.ConnectTimeout):
c.Close()
return fmt.Errorf("IRC connection timed out after %s", c.cfg.ConnectTimeout)
}
}
// Close disconnects from IRC and closes any SSH tunnel resources.
func (c *Client) Close() {
c.mu.Lock()
if c.closing {
c.mu.Unlock()
return
}
c.closing = true
c.mu.Unlock()
if c.client != nil {
c.client.Quit("Nexus Terminal disconnected")
c.client.Close()
}
if c.sshClient != nil {
_ = c.sshClient.Close()
c.sshClient = nil
}
if c.agentConn != nil {
_ = c.agentConn.Close()
c.agentConn = nil
}
}
// HandleInput parses a user-entered IRC composer line.
func (c *Client) HandleInput(target string, input string) error {
text := strings.TrimSpace(input)
if text == "" {
return nil
}
if target == "" {
target = ServerTarget
}
if !strings.HasPrefix(text, "/") || text == "/" {
if target == ServerTarget {
return fmt.Errorf("select a room or use /msg <nick> <message>")
}
c.client.Cmd.Message(target, text)
c.emitLocalMessage("message", target, text)
return nil
}
commandLine := strings.TrimSpace(strings.TrimPrefix(text, "/"))
if commandLine == "" {
return nil
}
fields := strings.Fields(commandLine)
cmd := strings.ToLower(fields[0])
args := strings.TrimSpace(commandLine[len(fields[0]):])
switch cmd {
case "me", "action":
if target == ServerTarget {
return fmt.Errorf("/me requires an active room or private tab")
}
if args == "" {
return fmt.Errorf("/me requires an action")
}
c.client.Cmd.Action(target, args)
c.emitLocalMessage("action", target, args)
case "msg", "query":
recipient, body, ok := splitFirstArg(args)
if !ok || body == "" {
return fmt.Errorf("/msg requires a target and message")
}
c.client.Cmd.Message(recipient, body)
c.emitLocalMessage("message", recipient, body)
case "notice":
recipient, body, ok := splitFirstArg(args)
if !ok || body == "" {
return fmt.Errorf("/notice requires a target and message")
}
c.client.Cmd.Notice(recipient, body)
c.emitLocalMessage("notice", recipient, body)
case "join", "j":
room, key, ok := splitFirstArg(args)
if !ok {
return fmt.Errorf("/join requires a room")
}
if key != "" {
c.client.Cmd.JoinKey(room, key)
} else {
c.client.Cmd.Join(room)
}
case "part", "leave":
room, msg, _ := splitFirstArg(args)
if room == "" {
room = target
}
if room == "" || room == ServerTarget {
return fmt.Errorf("/part requires a room")
}
if msg != "" {
c.client.Cmd.PartMessage(room, msg)
} else {
c.client.Cmd.Part(room)
}
case "quit":
if args == "" {
args = "Nexus Terminal disconnected"
}
c.client.Quit(args)
c.client.Close()
case "nick":
if args == "" {
return fmt.Errorf("/nick requires a nickname")
}
c.client.Cmd.Nick(strings.Fields(args)[0])
case "topic":
room, topic, _ := splitFirstArg(args)
if room == "" {
room = target
}
if room == "" || room == ServerTarget {
return fmt.Errorf("/topic requires a room")
}
c.client.Cmd.Topic(room, topic)
case "list":
if args == "" {
c.client.Cmd.List()
} else {
c.client.Cmd.List(strings.Fields(args)...)
}
case "names":
room := args
if room == "" && target != ServerTarget {
room = target
}
if room == "" {
return c.client.Cmd.SendRaw("NAMES")
}
return c.client.Cmd.SendRaw("NAMES " + room)
case "whois":
if args == "" {
return fmt.Errorf("/whois requires a nickname")
}
c.client.Cmd.Whois(strings.Fields(args)...)
case "who":
if args == "" {
if target == ServerTarget {
return fmt.Errorf("/who requires a target")
}
args = target
}
c.client.Cmd.Who(strings.Fields(args)...)
case "raw", "quote":
if args == "" {
return fmt.Errorf("/raw requires an IRC command")
}
return c.client.Cmd.SendRawNoSplit(args)
case "help":
c.emit(Event{
Type: "system",
Target: target,
Text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit",
Timestamp: time.Now().UnixMilli(),
})
default:
raw := strings.ToUpper(fields[0])
if args != "" {
raw += " " + args
}
return c.client.Cmd.SendRawNoSplit(raw)
}
return nil
}
// Join requests a room join.
func (c *Client) Join(room string) error {
room = strings.TrimSpace(room)
if room == "" {
return fmt.Errorf("room is required")
}
c.client.Cmd.Join(room)
return nil
}
// Part leaves a room.
func (c *Client) Part(room string) error {
room = strings.TrimSpace(room)
if room == "" {
return fmt.Errorf("room is required")
}
c.client.Cmd.Part(room)
return nil
}
// ListRooms requests a server-side room list. Results arrive as events.
func (c *Client) ListRooms() error {
c.mu.Lock()
c.listRooms = nil
c.mu.Unlock()
c.client.Cmd.List()
return nil
}
// Rooms returns currently joined rooms.
func (c *Client) Rooms() []RoomInfo {
if c.client == nil {
return nil
}
channels := c.client.Channels()
rooms := make([]RoomInfo, 0, len(channels))
for _, ch := range channels {
if ch == nil {
continue
}
rooms = append(rooms, RoomInfo{
Name: ch.Name,
Topic: ch.Topic,
Users: len(ch.UserList),
})
}
sort.Slice(rooms, func(i, j int) bool {
return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name)
})
return rooms
}
// Users returns known users in a room.
func (c *Client) Users(room string) []string {
if c.client == nil {
return nil
}
ch := c.client.LookupChannel(room)
if ch == nil {
return []string{}
}
users := append([]string{}, ch.UserList...)
sort.Slice(users, func(i, j int) bool {
return strings.TrimLeft(users[i], "~&@%+") < strings.TrimLeft(users[j], "~&@%+")
})
return users
}
func (c *Client) registerHandlers() {
c.client.Handlers.Add(girc.PRIVMSG, c.handleMessage)
c.client.Handlers.Add(girc.NOTICE, c.handleNotice)
c.client.Handlers.Add(girc.JOIN, c.handleJoin)
c.client.Handlers.Add(girc.PART, c.handlePart)
c.client.Handlers.Add(girc.QUIT, c.handleQuit)
c.client.Handlers.Add(girc.NICK, c.handleNick)
c.client.Handlers.Add(girc.TOPIC, c.handleTopic)
c.client.Handlers.Add(girc.RPL_TOPIC, c.handleTopic)
c.client.Handlers.Add(girc.RPL_NAMREPLY, c.handleNames)
c.client.Handlers.Add(girc.RPL_ENDOFNAMES, c.handleEndOfNames)
c.client.Handlers.Add(girc.RPL_LIST, c.handleList)
c.client.Handlers.Add(girc.RPL_LISTEND, c.handleListEnd)
c.client.Handlers.Add(girc.ERROR, func(_ *girc.Client, e girc.Event) {
c.emit(Event{Type: "error", Target: ServerTarget, Text: e.Last(), Timestamp: eventTime(e)})
})
}
func (c *Client) handleMessage(gc *girc.Client, e girc.Event) {
if len(e.Params) < 2 || e.Source == nil {
return
}
target := e.Params[0]
if strings.EqualFold(target, gc.GetNick()) {
target = e.Source.Name
}
eventType := "message"
text := e.Last()
if e.IsAction() {
eventType = "action"
text = e.StripAction()
}
c.emit(Event{Type: eventType, Target: target, From: e.Source.Name, Text: text, Timestamp: eventTime(e)})
}
func (c *Client) handleNotice(gc *girc.Client, e girc.Event) {
target := ServerTarget
if len(e.Params) > 0 {
target = e.Params[0]
if target == "*" {
target = ServerTarget
}
if strings.EqualFold(target, gc.GetNick()) && e.Source != nil {
target = e.Source.Name
}
}
from := ""
if e.Source != nil {
from = e.Source.Name
}
c.emit(Event{Type: "notice", Target: target, From: from, Text: e.Last(), Timestamp: eventTime(e)})
}
func (c *Client) handleJoin(gc *girc.Client, e girc.Event) {
room := eventRoom(e)
if room == "" {
return
}
from := sourceName(e)
c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)})
c.emitRooms()
c.emitUsers(room)
}
func (c *Client) handlePart(gc *girc.Client, e girc.Event) {
room := eventRoom(e)
if room == "" {
return
}
from := sourceName(e)
message := ""
if len(e.Params) > 1 {
message = strings.TrimSpace(e.Last())
}
c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)})
c.emitRooms()
c.emitUsers(room)
}
func (c *Client) handleQuit(gc *girc.Client, e girc.Event) {
from := sourceName(e)
c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)})
c.emitUsersForAll()
}
func (c *Client) handleNick(gc *girc.Client, e girc.Event) {
from := sourceName(e)
newNick := e.Last()
c.emit(Event{Type: "nick", Target: ServerTarget, From: from, Text: newNick, Timestamp: eventTime(e)})
c.emitUsersForAll()
}
func (c *Client) handleTopic(gc *girc.Client, e girc.Event) {
room := ""
topic := e.Last()
if e.Command == girc.RPL_TOPIC && len(e.Params) >= 3 {
room = e.Params[1]
topic = e.Params[2]
} else if len(e.Params) >= 2 {
room = e.Params[0]
topic = e.Params[1]
}
if room == "" {
return
}
c.emit(Event{Type: "topic", Target: room, From: sourceName(e), Text: topic, Topic: topic, Timestamp: eventTime(e)})
c.emitRooms()
}
func (c *Client) handleNames(gc *girc.Client, e girc.Event) {
if len(e.Params) >= 4 {
c.emit(Event{Type: "users", Target: e.Params[2], Users: splitNames(e.Params[3]), Timestamp: eventTime(e)})
}
}
func (c *Client) handleEndOfNames(gc *girc.Client, e girc.Event) {
if len(e.Params) >= 2 {
c.emitUsers(e.Params[1])
}
}
func (c *Client) handleList(gc *girc.Client, e girc.Event) {
if len(e.Params) < 4 {
return
}
users, _ := strconv.Atoi(e.Params[2])
room := RoomInfo{Name: e.Params[1], Users: users, Topic: e.Params[3]}
c.mu.Lock()
if len(c.listRooms) < maxRoomListEntries {
c.listRooms = append(c.listRooms, room)
}
c.mu.Unlock()
}
func (c *Client) handleListEnd(gc *girc.Client, e girc.Event) {
c.mu.Lock()
rooms := append([]RoomInfo{}, c.listRooms...)
c.listRooms = nil
c.mu.Unlock()
sort.Slice(rooms, func(i, j int) bool {
if rooms[i].Users == rooms[j].Users {
return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name)
}
return rooms[i].Users > rooms[j].Users
})
c.emit(Event{Type: "room-list", Target: ServerTarget, Rooms: rooms, Timestamp: eventTime(e)})
}
func (c *Client) identifyWithNickServ(gc *girc.Client) {
if !c.cfg.NickServIdentify || c.cfg.NickServPassword == "" {
return
}
if c.cfg.NickServAccount != "" {
gc.Cmd.Message("NickServ", fmt.Sprintf("IDENTIFY %s %s", c.cfg.NickServAccount, c.cfg.NickServPassword))
} else {
gc.Cmd.Message("NickServ", "IDENTIFY "+c.cfg.NickServPassword)
}
c.emit(Event{Type: "system", Target: ServerTarget, Text: "Sent NickServ identify request", Timestamp: time.Now().UnixMilli()})
}
func (c *Client) newSSHDialer() (*sshDialer, error) {
if c.cfg.SSHHostKeyCallback == nil {
return nil, fmt.Errorf("SSH host key callback is required")
}
authMethods, err := c.sshAuthMethods()
if err != nil {
return nil, err
}
sshAddr := fmt.Sprintf("%s:%d", c.cfg.SSHHost, c.cfg.SSHPort)
sshConfig := &ssh.ClientConfig{
User: c.cfg.SSHUsername,
Auth: authMethods,
HostKeyCallback: c.cfg.SSHHostKeyCallback,
Timeout: c.cfg.ConnectTimeout,
}
sshClient, err := ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return nil, fmt.Errorf("SSH connection failed: %w", err)
}
c.sshClient = sshClient
return &sshDialer{
client: sshClient,
useTLS: c.cfg.UseTLS,
serverName: c.cfg.Server,
timeout: c.cfg.ConnectTimeout,
}, nil
}
func (c *Client) sshAuthMethods() ([]ssh.AuthMethod, error) {
var authMethods []ssh.AuthMethod
if c.cfg.SSHAgentAuth && c.cfg.SSHAgentConn != nil {
c.agentConn = c.cfg.SSHAgentConn
ag := agent.NewClient(c.cfg.SSHAgentConn)
signers, err := ag.Signers()
if err == nil && len(signers) > 0 {
authMethods = append(authMethods, ssh.PublicKeys(signers...))
}
}
if len(c.cfg.SSHPrivateKey) > 0 {
var signer ssh.Signer
var err error
if c.cfg.SSHPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(c.cfg.SSHPrivateKey, []byte(c.cfg.SSHPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(c.cfg.SSHPrivateKey)
}
if err != nil {
return nil, fmt.Errorf("parsing SSH private key: %w", err)
}
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
if c.cfg.SSHPassword != "" {
authMethods = append(authMethods, ssh.Password(c.cfg.SSHPassword))
}
if len(authMethods) == 0 {
return nil, fmt.Errorf("no SSH authentication method available")
}
return authMethods, nil
}
func (c *Client) emitRooms() {
c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: c.Rooms(), Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emitUsers(room string) {
if room == "" {
return
}
c.emit(Event{Type: "users", Target: room, Users: c.Users(room), Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emitUsersForAll() {
for _, room := range c.Rooms() {
c.emitUsers(room.Name)
}
}
func (c *Client) emitLocalMessage(eventType string, target string, text string) {
c.emit(Event{Type: eventType, Target: target, From: c.client.GetNick(), Text: text, Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emit(event Event) {
if event.Timestamp == 0 {
event.Timestamp = time.Now().UnixMilli()
}
if event.Target == "" {
event.Target = ServerTarget
}
if c.cfg.OnEvent != nil {
c.cfg.OnEvent(event)
}
}
type sshDialer struct {
client *ssh.Client
useTLS bool
serverName string
timeout time.Duration
}
func (d *sshDialer) Dial(network, address string) (net.Conn, error) {
conn, err := d.client.Dial(network, address)
if err != nil {
return nil, err
}
if !d.useTLS {
return conn, nil
}
tlsConn := tls.Client(conn, &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: d.serverName,
})
if d.timeout > 0 {
_ = tlsConn.SetDeadline(time.Now().Add(d.timeout))
}
if err := tlsConn.Handshake(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("IRC TLS handshake failed: %w", err)
}
_ = tlsConn.SetDeadline(time.Time{})
return tlsConn, nil
}
func eventTime(e girc.Event) int64 {
if e.Timestamp.IsZero() {
return time.Now().UnixMilli()
}
return e.Timestamp.UnixMilli()
}
func eventRoom(e girc.Event) string {
if len(e.Params) == 0 {
return ""
}
return e.Params[0]
}
func sourceName(e girc.Event) string {
if e.Source == nil {
return ""
}
return e.Source.Name
}
func splitFirstArg(input string) (first string, rest string, ok bool) {
fields := strings.Fields(input)
if len(fields) == 0 {
return "", "", false
}
first = fields[0]
rest = strings.TrimSpace(input[len(first):])
return first, rest, true
}
func splitNames(input string) []string {
fields := strings.Fields(input)
names := make([]string, 0, len(fields))
for _, name := range fields {
name = strings.TrimSpace(name)
if name != "" {
names = append(names, name)
}
}
return names
}
+47
View File
@@ -13,6 +13,8 @@ const (
DefaultRAWPort = 23 DefaultRAWPort = 23
DefaultGitHubPort = 443 DefaultGitHubPort = 443
DefaultVNCPort = 5900 DefaultVNCPort = 5900
DefaultIRCPort = 6667
DefaultIRCTLSPort = 6697
) )
// Default timeout values (in seconds). // Default timeout values (in seconds).
@@ -122,6 +124,19 @@ func NewProfile(protocol Protocol) Profile {
SSHAuthMethod: "publickey", SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved, SSHSecretMode: SecretModeSaved,
} }
case ProtocolIRC:
p.Port = DefaultIRCPort
p.IRC = &IRCConfig{
Nick: "nexus",
RealName: "Nexus Terminal",
ServerPasswordMode: SecretModeAskEveryTime,
NickServMode: SecretModeSaved,
AutoJoinRooms: []string{},
SSHPort: DefaultSSHPort,
SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved,
AutoReconnect: true,
}
} }
return p return p
@@ -186,6 +201,8 @@ func NormalizeProfileDefaults(p *Profile) {
normalizeGitHubConfig(p, defaults.GitHub) normalizeGitHubConfig(p, defaults.GitHub)
case ProtocolVNC: case ProtocolVNC:
normalizeVNCConfig(p, defaults.VNC) normalizeVNCConfig(p, defaults.VNC)
case ProtocolIRC:
normalizeIRCConfig(p, defaults.IRC)
} }
} }
@@ -201,6 +218,8 @@ func defaultProfileName(p *Profile) string {
return "GitHub" return "GitHub"
case ProtocolVNC: case ProtocolVNC:
return "VNC" return "VNC"
case ProtocolIRC:
return "IRC"
case ProtocolRAW: case ProtocolRAW:
return "RAW TCP" return "RAW TCP"
case ProtocolTelnet: case ProtocolTelnet:
@@ -340,3 +359,31 @@ func normalizeVNCConfig(p *Profile, defaults *VNCConfig) {
} }
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode) cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
} }
func normalizeIRCConfig(p *Profile, defaults *IRCConfig) {
if p.IRC == nil {
cfg := *defaults
cfg.AutoJoinRooms = append([]string{}, defaults.AutoJoinRooms...)
p.IRC = &cfg
return
}
cfg := p.IRC
if cfg.Nick == "" {
cfg.Nick = defaults.Nick
}
if cfg.RealName == "" {
cfg.RealName = defaults.RealName
}
cfg.ServerPasswordMode = normalizeSecretMode(cfg.ServerPasswordMode, defaults.ServerPasswordMode)
cfg.NickServMode = normalizeSecretMode(cfg.NickServMode, defaults.NickServMode)
if cfg.AutoJoinRooms == nil {
cfg.AutoJoinRooms = []string{}
}
if cfg.SSHPort == 0 {
cfg.SSHPort = defaults.SSHPort
}
if cfg.SSHAuthMethod == "" {
cfg.SSHAuthMethod = defaults.SSHAuthMethod
}
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
}
+71 -29
View File
@@ -13,6 +13,7 @@ const (
ProtocolRAW Protocol = "raw" ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github" ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc" ProtocolVNC Protocol = "vnc"
ProtocolIRC Protocol = "irc"
ProtocolLocal Protocol = "local" ProtocolLocal Protocol = "local"
) )
@@ -85,6 +86,9 @@ type Profile struct {
// VNC-specific configuration // VNC-specific configuration
VNC *VNCConfig `json:"vnc,omitempty"` VNC *VNCConfig `json:"vnc,omitempty"`
// IRC-specific configuration
IRC *IRCConfig `json:"irc,omitempty"`
} }
// SSHConfig holds SSH-specific settings for a connection profile. // SSHConfig holds SSH-specific settings for a connection profile.
@@ -102,9 +106,9 @@ type SSHConfig struct {
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted) TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
ProxyType string `json:"proxyType"` // "none", "socks5", "http" ProxyType string `json:"proxyType"` // "none", "socks5", "http"
ProxyHost string `json:"proxyHost"` ProxyHost string `json:"proxyHost"`
ProxyPort int `json:"proxyPort"` ProxyPort int `json:"proxyPort"`
ProxyUsername string `json:"proxyUsername"` ProxyUsername string `json:"proxyUsername"`
@@ -144,12 +148,12 @@ const (
type PortForward struct { type PortForward struct {
ID string `json:"id"` ID string `json:"id"`
Direction PortForwardDirection `json:"direction"` Direction PortForwardDirection `json:"direction"`
BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote) BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote)
BindPort int `json:"bindPort"` BindPort int `json:"bindPort"`
DestHost string `json:"destHost"` // target address to forward to DestHost string `json:"destHost"` // target address to forward to
DestPort int `json:"destPort"` DestPort int `json:"destPort"`
Enabled bool `json:"enabled"` // whether to activate on connect Enabled bool `json:"enabled"` // whether to activate on connect
Label string `json:"label"` // optional descriptive label Label string `json:"label"` // optional descriptive label
} }
// TelnetConfig holds Telnet-specific settings for a connection profile. // TelnetConfig holds Telnet-specific settings for a connection profile.
@@ -162,7 +166,7 @@ type TelnetConfig struct {
LineMode bool `json:"lineMode"` LineMode bool `json:"lineMode"`
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
} }
// RAWConfig holds RAW TCP-specific settings for a connection profile. // RAWConfig holds RAW TCP-specific settings for a connection profile.
@@ -221,33 +225,71 @@ type VNCConfig struct {
SharedSession bool `json:"sharedSession"` SharedSession bool `json:"sharedSession"`
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection. // SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
SSHTunnel bool `json:"sshTunnel"` SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"` SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"` SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"` SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"` SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"` SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"` SSHAgentAuth bool `json:"sshAgentAuth"`
}
// IRCConfig holds IRC-specific settings for a connection profile.
type IRCConfig struct {
// Nick is the primary nickname used when connecting to the IRC network.
Nick string `json:"nick"`
// RealName is the user-facing realname/gecos sent during registration.
RealName string `json:"realName"`
// UseTLS enables TLS for the IRC server connection.
UseTLS bool `json:"useTLS"`
// ServerPasswordRequired controls whether to prompt for an IRC server PASS secret.
ServerPasswordRequired bool `json:"serverPasswordRequired"`
ServerPasswordMode SecretMode `json:"serverPasswordMode"`
// NickServ settings. The password is resolved through the secret store.
NickServIdentify bool `json:"nickServIdentify"`
NickServAccount string `json:"nickServAccount"`
NickServMode SecretMode `json:"nickServMode"`
// AutoJoinRooms are joined automatically after registration.
AutoJoinRooms []string `json:"autoJoinRooms"`
// SSH tunnel settings — if enabled, IRC traffic is tunnelled through SSH.
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
// Reconnect policy for unexpected disconnects.
AutoReconnect bool `json:"autoReconnect"`
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
} }
// GitHubRepo is a single repository returned to the frontend for display. // GitHubRepo is a single repository returned to the frontend for display.
type GitHubRepo struct { type GitHubRepo struct {
FullName string `json:"fullName"` FullName string `json:"fullName"`
Name string `json:"name"` Name string `json:"name"`
Owner string `json:"owner"` Owner string `json:"owner"`
Description string `json:"description"` Description string `json:"description"`
HTMLURL string `json:"htmlURL"` HTMLURL string `json:"htmlURL"`
DefaultBranch string `json:"defaultBranch"` DefaultBranch string `json:"defaultBranch"`
LatestCommitter string `json:"latestCommitter"` LatestCommitter string `json:"latestCommitter"`
LatestCommitAt string `json:"latestCommitAt"` LatestCommitAt string `json:"latestCommitAt"`
Private bool `json:"private"` Private bool `json:"private"`
Fork bool `json:"fork"` Fork bool `json:"fork"`
Archived bool `json:"archived"` Archived bool `json:"archived"`
Language string `json:"language"` Language string `json:"language"`
Stars int `json:"stars"` Stars int `json:"stars"`
PushedAt string `json:"pushedAt"` // ISO 8601 PushedAt string `json:"pushedAt"` // ISO 8601
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
} }
// SessionState represents the lifecycle state of a connection session. // SessionState represents the lifecycle state of a connection session.
+227 -3
View File
@@ -20,6 +20,7 @@ import (
"github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/githubclient" "github.com/andydixon/nexus/internal/githubclient"
"github.com/andydixon/nexus/internal/ircclient"
"github.com/andydixon/nexus/internal/localshell" "github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/rawclient" "github.com/andydixon/nexus/internal/rawclient"
@@ -32,7 +33,7 @@ import (
"github.com/andydixon/nexus/internal/vncclient" "github.com/andydixon/nexus/internal/vncclient"
) )
const maxVNCTunnelPrivateKeyBytes = 1 << 20 const maxTunnelPrivateKeyBytes = 1 << 20
// Session wraps a protocol client with metadata and state. // Session wraps a protocol client with metadata and state.
type Session struct { type Session struct {
@@ -43,6 +44,7 @@ type Session struct {
githubClient *githubclient.Client githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine githubCancel context.CancelFunc // stops the auto-refresh goroutine
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
ircClient *ircclient.Client // IRC client, nil for non-IRC sessions
localClient *localshell.Client // local shell PTY, nil for non-local sessions localClient *localshell.Client // local shell PTY, nil for non-local sessions
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile profile *models.Profile
@@ -139,6 +141,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
err = m.connectGitHub(sess, getSecret) err = m.connectGitHub(sess, getSecret)
case models.ProtocolVNC: case models.ProtocolVNC:
err = m.connectVNC(sess, getSecret) err = m.connectVNC(sess, getSecret)
case models.ProtocolIRC:
err = m.connectIRC(sess, getSecret)
default: default:
err = fmt.Errorf("unsupported protocol %q", profile.Protocol) err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
} }
@@ -539,6 +543,74 @@ func (m *Manager) SendBreak(sessionID string) error {
return sess.telnetClient.SendBreak() return sess.telnetClient.SendBreak()
} }
// IRCHandleInput sends a user-entered composer line to an IRC session.
func (m *Manager) IRCHandleInput(sessionID string, target string, input string) error {
client, err := m.getIRC(sessionID)
if err != nil {
return err
}
return client.HandleInput(target, input)
}
// IRCJoin joins an IRC room.
func (m *Manager) IRCJoin(sessionID string, room string) error {
client, err := m.getIRC(sessionID)
if err != nil {
return err
}
return client.Join(room)
}
// IRCPart leaves an IRC room.
func (m *Manager) IRCPart(sessionID string, room string) error {
client, err := m.getIRC(sessionID)
if err != nil {
return err
}
return client.Part(room)
}
// IRCListRooms requests a server-side IRC room list.
func (m *Manager) IRCListRooms(sessionID string) error {
client, err := m.getIRC(sessionID)
if err != nil {
return err
}
return client.ListRooms()
}
// IRCRooms returns currently joined IRC rooms.
func (m *Manager) IRCRooms(sessionID string) ([]ircclient.RoomInfo, error) {
client, err := m.getIRC(sessionID)
if err != nil {
return nil, err
}
return client.Rooms(), nil
}
// IRCUsers returns known users in an IRC room.
func (m *Manager) IRCUsers(sessionID string, room string) ([]string, error) {
client, err := m.getIRC(sessionID)
if err != nil {
return nil, err
}
return client.Users(room), nil
}
func (m *Manager) getIRC(sessionID string) (*ircclient.Client, error) {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.ircClient == nil {
return nil, fmt.Errorf("sessions: session %q is not an IRC connection", sessionID)
}
return sess.ircClient, nil
}
// WriteSessionText sends text with line ending via the RAW client. // WriteSessionText sends text with line ending via the RAW client.
func (m *Manager) WriteSessionText(sessionID string, text string) error { func (m *Manager) WriteSessionText(sessionID string, text string) error {
m.mu.RLock() m.mu.RLock()
@@ -1040,8 +1112,8 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
if info.IsDir() { if info.IsDir() {
return fmt.Errorf("SSH key path is a directory") return fmt.Errorf("SSH key path is a directory")
} }
if info.Size() > maxVNCTunnelPrivateKeyBytes { if info.Size() > maxTunnelPrivateKeyBytes {
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxVNCTunnelPrivateKeyBytes) return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
} }
keyData, err := os.ReadFile(vncConf.SSHPrivateKey) keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
if err != nil { if err != nil {
@@ -1091,6 +1163,139 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
return nil return nil
} }
// connectIRC establishes an IRC connection, optionally through an SSH tunnel,
// and forwards typed IRC events to the frontend.
func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (string, error)) error {
profile := sess.profile
ircConf := profile.IRC
if ircConf == nil {
return fmt.Errorf("IRC configuration missing")
}
serverPass := ""
if ircConf.ServerPasswordRequired {
getPass := m.buildSecretCallback(profile.ID, "irc-server-password", ircConf.ServerPasswordMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("IRC server password: %w", err)
}
serverPass = password
}
nickServPass := ""
if ircConf.NickServIdentify {
getPass := m.buildSecretCallback(profile.ID, "irc-nickserv-password", ircConf.NickServMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("NickServ password: %w", err)
}
nickServPass = password
}
cfg := ircclient.Config{
Server: profile.Host,
Port: profile.Port,
Nick: ircConf.Nick,
User: profile.Username,
RealName: ircConf.RealName,
UseTLS: ircConf.UseTLS,
ServerPass: serverPass,
AutoJoinRooms: append([]string{}, ircConf.AutoJoinRooms...),
NickServIdentify: ircConf.NickServIdentify,
NickServAccount: ircConf.NickServAccount,
NickServPassword: nickServPass,
ConnectTimeout: time.Duration(profile.ConnectTimeout) * time.Second,
OnEvent: func(event ircclient.Event) { m.emitIRCEvent(sess.Info.ID, event) },
OnError: func(err error) { m.markSessionError(sess, err) },
OnClose: func() { m.handleSessionClosed(sess.Info.ID) },
SSHTunnel: ircConf.SSHTunnel,
SSHHost: ircConf.SSHHost,
SSHPort: ircConf.SSHPort,
SSHUsername: ircConf.SSHUsername,
SSHAuthMethod: ircConf.SSHAuthMethod,
SSHAgentAuth: ircConf.SSHAgentAuth,
SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk),
}
if cfg.User == "" {
cfg.User = ircConf.Nick
}
if ircConf.SSHTunnel {
switch ircConf.SSHAuthMethod {
case "password":
getPass := m.buildSecretCallback(profile.ID, "irc-ssh-password", ircConf.SSHSecretMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("IRC SSH password: %w", err)
}
cfg.SSHPassword = password
case "publickey":
if ircConf.SSHPrivateKey != "" {
info, err := os.Stat(ircConf.SSHPrivateKey)
if err != nil {
return fmt.Errorf("reading IRC SSH key metadata: %w", err)
}
if info.IsDir() {
return fmt.Errorf("IRC SSH key path is a directory")
}
if info.Size() > maxTunnelPrivateKeyBytes {
return fmt.Errorf("IRC SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
}
keyData, err := os.ReadFile(ircConf.SSHPrivateKey)
if err != nil {
return fmt.Errorf("reading IRC SSH key: %w", err)
}
cfg.SSHPrivateKey = keyData
getPassphrase := m.buildSecretCallback(profile.ID, "irc-ssh-passphrase", ircConf.SSHSecretMode, getSecret)
passphrase, _ := getPassphrase()
cfg.SSHPassphrase = passphrase
}
}
if ircConf.SSHAgentAuth {
socketPath := os.Getenv("SSH_AUTH_SOCK")
if socketPath != "" {
agentConn, err := net.Dial("unix", socketPath)
if err == nil {
cfg.SSHAgentConn = agentConn
}
}
}
}
client := ircclient.New(cfg)
if err := client.Connect(); err != nil {
return err
}
sess.ircClient = client
return nil
}
func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) {
m.emitEvent("session:irc:event", map[string]interface{}{
"sessionID": sessionID,
"event": event,
})
}
func (m *Manager) markSessionError(sess *Session, err error) {
if err == nil {
return
}
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sess.Info.ID,
"state": string(models.SessionError),
"error": err.Error(),
})
}
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session. // GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) { func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
m.mu.RLock() m.mu.RLock()
@@ -1728,6 +1933,10 @@ func (m *Manager) closeSession(sess *Session) {
sess.vncClient.Close() sess.vncClient.Close()
sess.vncClient = nil sess.vncClient = nil
} }
if sess.ircClient != nil {
sess.ircClient.Close()
sess.ircClient = nil
}
if sess.localClient != nil { if sess.localClient != nil {
sess.localClient.Close() sess.localClient.Close()
sess.localClient = nil sess.localClient = nil
@@ -1879,6 +2088,10 @@ func (m *Manager) shouldAutoReconnect(sess *Session) bool {
if sess.profile.RAW != nil { if sess.profile.RAW != nil {
return sess.profile.RAW.AutoReconnect return sess.profile.RAW.AutoReconnect
} }
case models.ProtocolIRC:
if sess.profile.IRC != nil {
return sess.profile.IRC.AutoReconnect
}
} }
return false return false
} }
@@ -1895,6 +2108,14 @@ func (m *Manager) maxReconnectAttempts(sess *Session) int {
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 { if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
maxTries = sess.profile.Telnet.MaxReconnectTries maxTries = sess.profile.Telnet.MaxReconnectTries
} }
case models.ProtocolRAW:
if sess.profile.RAW != nil {
maxTries = 5
}
case models.ProtocolIRC:
if sess.profile.IRC != nil && sess.profile.IRC.MaxReconnectTries > 0 {
maxTries = sess.profile.IRC.MaxReconnectTries
}
} }
return maxTries return maxTries
} }
@@ -1959,6 +2180,7 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
sess.sshClient = nil sess.sshClient = nil
sess.telnetClient = nil sess.telnetClient = nil
sess.rawClient = nil sess.rawClient = nil
sess.ircClient = nil
sess.sftpClient = nil sess.sftpClient = nil
sess.Info.State = models.SessionConnecting sess.Info.State = models.SessionConnecting
m.mu.Unlock() m.mu.Unlock()
@@ -1976,6 +2198,8 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
err = m.connectTelnet(sess) err = m.connectTelnet(sess)
case models.ProtocolRAW: case models.ProtocolRAW:
err = m.connectRAW(sess) err = m.connectRAW(sess)
case models.ProtocolIRC:
err = m.connectIRC(sess, m.buildInteractiveSecretCallback(sessionID))
default: default:
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol) err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
} }
@@ -0,0 +1,105 @@
-- Migration 005: Add IRC protocol support
-- Adds the 'irc' value to the protocol CHECK constraint and an irc_config column.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc', 'irc')
),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
irc_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT
OR IGNORE INTO profiles_new (
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
irc_config,
created_at,
updated_at
)
SELECT
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
NULL,
created_at,
updated_at
FROM
profiles;
DROP TABLE profiles;
ALTER TABLE
profiles_new RENAME TO profiles;
INSERT INTO
schema_version (version, applied_at)
VALUES
(5, datetime('now'));
+26 -14
View File
@@ -111,15 +111,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
rawJSON, _ := json.Marshal(p.RAW) rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub) githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC) vncJSON, _ := json.Marshal(p.VNC)
ircJSON, _ := json.Marshal(p.IRC)
_, err := s.db.Exec(`INSERT INTO profiles ( _, err := s.db.Exec(`INSERT INTO profiles (
id, display_name, description, protocol, host, port, username, id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.DisplayName, p.Description, string(p.Protocol), p.ID, p.DisplayName, p.Description, string(p.Protocol),
p.Host, p.Port, p.Username, p.Host, p.Port, p.Username,
string(tagsJSON), p.Folder, p.Environment, p.Notes, string(tagsJSON), p.Folder, p.Environment, p.Notes,
@@ -127,7 +128,7 @@ func (s *Store) CreateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout, p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON),
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339), p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
) )
if err != nil { if err != nil {
@@ -143,7 +144,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE id = ?`, id) FROM profiles WHERE id = ?`, id)
@@ -165,13 +166,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
rawJSON, _ := json.Marshal(p.RAW) rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub) githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC) vncJSON, _ := json.Marshal(p.VNC)
ircJSON, _ := json.Marshal(p.IRC)
res, err := s.db.Exec(`UPDATE profiles SET res, err := s.db.Exec(`UPDATE profiles SET
display_name=?, description=?, protocol=?, host=?, port=?, username=?, display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?, tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
connect_timeout=?, read_timeout=?, connect_timeout=?, read_timeout=?,
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?, terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?,
updated_at=? updated_at=?
WHERE id=?`, WHERE id=?`,
p.DisplayName, p.Description, string(p.Protocol), p.DisplayName, p.Description, string(p.Protocol),
@@ -181,7 +183,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout, p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON),
p.UpdatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
p.ID, p.ID,
) )
@@ -215,7 +217,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at created_at, updated_at
FROM profiles ORDER BY display_name`) FROM profiles ORDER BY display_name`)
if err != nil { if err != nil {
@@ -234,7 +236,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at created_at, updated_at
FROM profiles FROM profiles
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ? WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
@@ -253,7 +255,7 @@ func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, erro
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol) FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
if err != nil { if err != nil {
@@ -271,7 +273,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q) FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
if err != nil { if err != nil {
@@ -288,7 +290,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE folder = ? ORDER BY display_name`, folder) FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
if err != nil { if err != nil {
@@ -305,7 +307,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config, ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE favourite = 1 ORDER BY display_name`) FROM profiles WHERE favourite = 1 ORDER BY display_name`)
if err != nil { if err != nil {
@@ -579,6 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
var ( var (
protocol, tagsJSON string protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
ircJSON sql.NullString
lastConn sql.NullString lastConn sql.NullString
favourite int favourite int
createdAt, updatedAt string createdAt, updatedAt string
@@ -591,7 +594,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
&favourite, &lastConn, &favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout, &p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
&createdAt, &updatedAt, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -634,6 +637,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
p.VNC = &models.VNCConfig{} p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC) _ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
} }
if ircJSON.Valid && ircJSON.String != "" {
p.IRC = &models.IRCConfig{}
_ = json.Unmarshal([]byte(ircJSON.String), p.IRC)
}
return p, nil return p, nil
} }
@@ -646,6 +653,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
var ( var (
protocol, tagsJSON string protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
ircJSON sql.NullString
lastConn sql.NullString lastConn sql.NullString
favourite int favourite int
createdAt, updatedAt string createdAt, updatedAt string
@@ -658,7 +666,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
&favourite, &lastConn, &favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout, &p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
&createdAt, &updatedAt, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -701,6 +709,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
p.VNC = &models.VNCConfig{} p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC) _ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
} }
if ircJSON.Valid && ircJSON.String != "" {
p.IRC = &models.IRCConfig{}
_ = json.Unmarshal([]byte(ircJSON.String), p.IRC)
}
result = append(result, p) result = append(result, p)
} }
+105
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/models"
"github.com/lrstanley/girc"
) )
// ValidationError describes a single field-level validation failure. // ValidationError describes a single field-level validation failure.
@@ -111,6 +112,15 @@ func ValidateProfile(p *models.Profile) error {
errs.Errors = append(errs.Errors, ve.Errors...) errs.Errors = append(errs.Errors, ve.Errors...)
} }
} }
case models.ProtocolIRC:
validateProfileHostPort(errs, p.Host, p.Port)
if p.IRC == nil {
errs.Add("irc", "IRC configuration is required for IRC protocol")
} else if err := ValidateIRCConfig(p.IRC); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
default: default:
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol)) errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
} }
@@ -403,6 +413,101 @@ func ValidateVNCConfig(profileHost string, cfg *models.VNCConfig) error {
return nil return nil
} }
// ValidateIRCConfig validates IRC connection settings.
func ValidateIRCConfig(cfg *models.IRCConfig) error {
errs := &ValidationErrors{}
if cfg == nil {
return &ValidationError{Field: "irc", Message: "IRC configuration must not be nil"}
}
nick := strings.TrimSpace(cfg.Nick)
if nick == "" {
errs.Add("irc.nick", "nickname is required")
} else if !girc.IsValidNick(nick) {
errs.Add("irc.nick", "nickname is not valid for IRC")
}
if strings.ContainsAny(cfg.RealName, "\r\n\x00") {
errs.Add("irc.realName", "real name must not contain control characters")
}
if !isValidSecretMode(cfg.ServerPasswordMode) {
errs.Add("irc.serverPasswordMode", fmt.Sprintf("unsupported secret mode %q", cfg.ServerPasswordMode))
}
if !isValidSecretMode(cfg.NickServMode) {
errs.Add("irc.nickServMode", fmt.Sprintf("unsupported secret mode %q", cfg.NickServMode))
}
if cfg.NickServIdentify && !isValidNickServAccount(cfg.NickServAccount) {
errs.Add("irc.nickServAccount", "NickServ account must not contain control characters")
}
seenRooms := map[string]bool{}
for i, room := range cfg.AutoJoinRooms {
field := fmt.Sprintf("irc.autoJoinRooms[%d]", i)
name := strings.TrimSpace(room)
if name == "" {
errs.Add(field, "room name must not be empty")
continue
}
if !girc.IsValidChannel(name) {
errs.Add(field, "room name must start with a valid IRC channel prefix")
}
key := strings.ToLower(name)
if seenRooms[key] {
errs.Add(field, "room is listed more than once")
}
seenRooms[key] = true
}
if cfg.SSHTunnel {
if err := ValidateHost(cfg.SSHHost); err != nil {
errs.Add("irc.sshHost", err.(*ValidationError).Message)
}
if err := ValidatePort(cfg.SSHPort); err != nil {
errs.Add("irc.sshPort", err.(*ValidationError).Message)
}
if strings.TrimSpace(cfg.SSHUsername) == "" {
errs.Add("irc.sshUsername", "SSH username is required when tunnelling")
} else if err := ValidateUsername(cfg.SSHUsername); err != nil {
errs.Add("irc.sshUsername", err.(*ValidationError).Message)
}
if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" {
errs.Add("irc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod))
}
if !isValidSecretMode(cfg.SSHSecretMode) {
errs.Add("irc.sshSecretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SSHSecretMode))
}
}
if cfg.MaxReconnectTries < 0 {
errs.Add("irc.maxReconnectTries", "max reconnect attempts must not be negative")
}
if errs.HasErrors() {
return errs
}
return nil
}
func isValidSecretMode(mode models.SecretMode) bool {
switch mode {
case models.SecretModeSaved, models.SecretModeSessionOnly, models.SecretModeAskEveryTime:
return true
default:
return false
}
}
func isValidNickServAccount(account string) bool {
for _, r := range account {
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func isLoopbackHost(host string) bool { func isLoopbackHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host)) h := strings.ToLower(strings.TrimSpace(host))
if h == "localhost" { if h == "localhost" {