IRC support #5
@@ -23,6 +23,7 @@
|
||||
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||
<link rel="stylesheet" href="/src/css/github.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" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -334,6 +335,7 @@
|
||||
<script src="/src/js/github.js"></script>
|
||||
<script src="/src/js/lib/novnc.min.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/commands.js"></script>
|
||||
<script src="/src/js/secrets.js"></script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,9 @@
|
||||
.sidebar-item .protocol-indicator.proto-vnc::before {
|
||||
content: "⊞";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-irc::before {
|
||||
content: "#";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-local::before {
|
||||
content: "›_";
|
||||
}
|
||||
@@ -328,6 +331,10 @@
|
||||
background: rgba(232, 93, 74, 0.18);
|
||||
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 {
|
||||
background: rgba(139, 195, 74, 0.18);
|
||||
color: var(--protocol-local);
|
||||
|
||||
@@ -118,6 +118,10 @@
|
||||
background: rgba(232, 93, 74, 0.15);
|
||||
color: var(--protocol-vnc);
|
||||
}
|
||||
.tab-badge.badge-irc {
|
||||
background: rgba(216, 178, 76, 0.15);
|
||||
color: var(--protocol-irc);
|
||||
}
|
||||
.tab-badge.badge-local {
|
||||
background: rgba(139, 195, 74, 0.15);
|
||||
color: var(--protocol-local);
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
--protocol-raw: #ab47bc;
|
||||
--protocol-github: #7c8aff;
|
||||
--protocol-vnc: #e85d4a;
|
||||
--protocol-irc: #d8b24c;
|
||||
--protocol-local: #8bc34a;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
|
||||
+50
-3
@@ -35,6 +35,7 @@
|
||||
this.terminals = new TerminalManager(this);
|
||||
this.profileEditor = new ProfileEditor(this);
|
||||
this.rawViewer = new RawViewer(this);
|
||||
window.NexusTerminal.IRCViewer?.setApp(this);
|
||||
this.settings = new Settings(this);
|
||||
this.commandPalette = new CommandPalette(this);
|
||||
this.secretPrompt = new SecretPrompt(this);
|
||||
@@ -239,6 +240,7 @@
|
||||
setTimeout(fit, 50);
|
||||
setTimeout(fit, 300);
|
||||
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
|
||||
window.runtime.EventsOn("app:open-about", () => {
|
||||
this.settings.openTab("about");
|
||||
@@ -801,7 +809,12 @@
|
||||
const decision = await this._confirmReuseSession(profile, preferred);
|
||||
if (decision === "existing") {
|
||||
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);
|
||||
}
|
||||
return;
|
||||
@@ -845,6 +858,15 @@
|
||||
profile,
|
||||
);
|
||||
}
|
||||
} else if (profile.protocol === "irc") {
|
||||
const container = document.getElementById(panelId);
|
||||
if (container) {
|
||||
window.NexusTerminal.IRCViewer.createViewer(
|
||||
sessionInfo.id,
|
||||
container,
|
||||
profile,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
||||
}
|
||||
@@ -901,6 +923,7 @@
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="telnet">Telnet</option>
|
||||
<option value="raw">RAW TCP</option>
|
||||
<option value="irc">IRC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -915,6 +938,10 @@
|
||||
<label class="form-label">Username</label>
|
||||
<input class="form-input qc-username" type="text" placeholder="root" />
|
||||
</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 class="modal-footer">
|
||||
<button class="btn btn-sm qc-cancel">Cancel</button>
|
||||
@@ -931,14 +958,17 @@
|
||||
const protocolSelect = modal.querySelector(".qc-protocol");
|
||||
const portInput = modal.querySelector(".qc-port");
|
||||
const usernameGroup = modal.querySelector(".qc-username-group");
|
||||
const nickGroup = modal.querySelector(".qc-nick-group");
|
||||
const hostInput = modal.querySelector(".qc-host");
|
||||
|
||||
// Update port default and username visibility when protocol changes
|
||||
protocolSelect.addEventListener("change", () => {
|
||||
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;
|
||||
usernameGroup.style.display = proto === "ssh" ? "" : "none";
|
||||
usernameGroup.style.display =
|
||||
proto === "ssh" || proto === "irc" ? "" : "none";
|
||||
nickGroup.style.display = proto === "irc" ? "" : "none";
|
||||
});
|
||||
|
||||
const close = (value) => {
|
||||
@@ -965,6 +995,7 @@
|
||||
host,
|
||||
port: parseInt(portInput.value, 10) || 22,
|
||||
username: modal.querySelector(".qc-username").value.trim(),
|
||||
nick: modal.querySelector(".qc-nick").value.trim(),
|
||||
});
|
||||
});
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
@@ -989,6 +1020,13 @@
|
||||
displayName: result.host,
|
||||
tags: [],
|
||||
};
|
||||
if (result.protocol === "irc") {
|
||||
profile.irc = {
|
||||
nick: result.nick || "nexus",
|
||||
realName: "Nexus Terminal",
|
||||
autoJoinRooms: [],
|
||||
};
|
||||
}
|
||||
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
|
||||
this.state.addSession(sessionInfo);
|
||||
this.tabs.addTab(sessionInfo);
|
||||
@@ -999,6 +1037,15 @@
|
||||
protocol: "raw",
|
||||
raw: {},
|
||||
});
|
||||
} else if (result.protocol === "irc") {
|
||||
const container = document.getElementById(panelId);
|
||||
if (container) {
|
||||
window.NexusTerminal.IRCViewer.createViewer(
|
||||
sessionInfo.id,
|
||||
container,
|
||||
profile,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.terminals.createTerminal(sessionInfo.id, panelId, {
|
||||
protocol: result.protocol,
|
||||
|
||||
@@ -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
@@ -86,6 +86,31 @@
|
||||
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 {
|
||||
@@ -162,10 +187,11 @@
|
||||
drawer.appendChild(header);
|
||||
|
||||
// ── 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");
|
||||
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(
|
||||
"button",
|
||||
`drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`,
|
||||
@@ -208,6 +234,10 @@
|
||||
case "vnc":
|
||||
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
|
||||
break;
|
||||
case "irc":
|
||||
this._renderIRCSections(profile).forEach((s) => body.appendChild(s));
|
||||
body.appendChild(sshKeyDatalist);
|
||||
break;
|
||||
}
|
||||
|
||||
// Terminal section (SSH and Telnet only)
|
||||
@@ -1075,6 +1105,185 @@
|
||||
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 */
|
||||
_renderTerminalSection(profile) {
|
||||
return this._renderSection("Terminal", [
|
||||
@@ -2275,6 +2484,34 @@
|
||||
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -2300,6 +2537,25 @@
|
||||
if (!profile.port || profile.port < 1 || profile.port > 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;
|
||||
}
|
||||
|
||||
@@ -2476,6 +2732,7 @@
|
||||
raw: null,
|
||||
github: null,
|
||||
vnc: null,
|
||||
irc: null,
|
||||
};
|
||||
|
||||
const defaults = DEFAULTS[protocol] || {};
|
||||
@@ -2494,6 +2751,17 @@
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.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 || {};
|
||||
|
||||
@@ -926,6 +926,8 @@
|
||||
return "TELNET";
|
||||
case "raw":
|
||||
return "RAW";
|
||||
case "irc":
|
||||
return "IRC";
|
||||
default:
|
||||
return (protocol || "").toUpperCase() || "UNKNOWN";
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
this.app.terminals.destroyTerminal(sessionId);
|
||||
this.app.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.IRCViewer.destroyViewer(sessionId);
|
||||
this.app.leviathan?.destroyPanel(sessionId);
|
||||
this.removeTab(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
return "proto-github";
|
||||
case "vnc":
|
||||
return "proto-vnc";
|
||||
case "irc":
|
||||
return "proto-irc";
|
||||
case "local":
|
||||
return "proto-local";
|
||||
default:
|
||||
|
||||
Vendored
+12
@@ -80,6 +80,18 @@ export function GetVNCWebSocketURL(arg1:string):Promise<string>;
|
||||
|
||||
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 ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
@@ -154,6 +154,30 @@ export function 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) {
|
||||
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,54 @@ export namespace models {
|
||||
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 {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -520,6 +568,7 @@ export namespace models {
|
||||
raw?: RAWConfig;
|
||||
github?: GitHubConfig;
|
||||
vnc?: VNCConfig;
|
||||
irc?: IRCConfig;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Profile(source);
|
||||
@@ -553,6 +602,7 @@ export namespace models {
|
||||
this.raw = this.convertValues(source["raw"], RAWConfig);
|
||||
this.github = this.convertValues(source["github"], GitHubConfig);
|
||||
this.vnc = this.convertValues(source["vnc"], VNCConfig);
|
||||
this.irc = this.convertValues(source["irc"], IRCConfig);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hajimehoshi/go-mp3 v0.3.4
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/lrstanley/girc v1.1.1
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/pquerna/otp v1.5.0
|
||||
|
||||
@@ -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/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
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.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
|
||||
@@ -393,6 +393,7 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
|
||||
if profile.ID == "" {
|
||||
profile.ID = uuid.New().String()
|
||||
}
|
||||
normaliseProfileForSave(&profile)
|
||||
sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
|
||||
if err != nil {
|
||||
return models.SessionInfo{}, err
|
||||
@@ -527,6 +528,52 @@ func (s *Service) SendBreak(sessionID string) error {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,8 @@ const (
|
||||
DefaultRAWPort = 23
|
||||
DefaultGitHubPort = 443
|
||||
DefaultVNCPort = 5900
|
||||
DefaultIRCPort = 6667
|
||||
DefaultIRCTLSPort = 6697
|
||||
)
|
||||
|
||||
// Default timeout values (in seconds).
|
||||
@@ -122,6 +124,19 @@ func NewProfile(protocol Protocol) Profile {
|
||||
SSHAuthMethod: "publickey",
|
||||
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
|
||||
@@ -186,6 +201,8 @@ func NormalizeProfileDefaults(p *Profile) {
|
||||
normalizeGitHubConfig(p, defaults.GitHub)
|
||||
case ProtocolVNC:
|
||||
normalizeVNCConfig(p, defaults.VNC)
|
||||
case ProtocolIRC:
|
||||
normalizeIRCConfig(p, defaults.IRC)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +218,8 @@ func defaultProfileName(p *Profile) string {
|
||||
return "GitHub"
|
||||
case ProtocolVNC:
|
||||
return "VNC"
|
||||
case ProtocolIRC:
|
||||
return "IRC"
|
||||
case ProtocolRAW:
|
||||
return "RAW TCP"
|
||||
case ProtocolTelnet:
|
||||
@@ -340,3 +359,31 @@ func normalizeVNCConfig(p *Profile, defaults *VNCConfig) {
|
||||
}
|
||||
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
@@ -13,6 +13,7 @@ const (
|
||||
ProtocolRAW Protocol = "raw"
|
||||
ProtocolGitHub Protocol = "github"
|
||||
ProtocolVNC Protocol = "vnc"
|
||||
ProtocolIRC Protocol = "irc"
|
||||
ProtocolLocal Protocol = "local"
|
||||
)
|
||||
|
||||
@@ -85,6 +86,9 @@ type Profile struct {
|
||||
|
||||
// VNC-specific configuration
|
||||
VNC *VNCConfig `json:"vnc,omitempty"`
|
||||
|
||||
// IRC-specific configuration
|
||||
IRC *IRCConfig `json:"irc,omitempty"`
|
||||
}
|
||||
|
||||
// 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
|
||||
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
||||
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
|
||||
ProxyType string `json:"proxyType"` // "none", "socks5", "http"
|
||||
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
||||
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
|
||||
ProxyType string `json:"proxyType"` // "none", "socks5", "http"
|
||||
ProxyHost string `json:"proxyHost"`
|
||||
ProxyPort int `json:"proxyPort"`
|
||||
ProxyUsername string `json:"proxyUsername"`
|
||||
@@ -144,12 +148,12 @@ const (
|
||||
type PortForward struct {
|
||||
ID string `json:"id"`
|
||||
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"`
|
||||
DestHost string `json:"destHost"` // target address to forward to
|
||||
DestHost string `json:"destHost"` // target address to forward to
|
||||
DestPort int `json:"destPort"`
|
||||
Enabled bool `json:"enabled"` // whether to activate on connect
|
||||
Label string `json:"label"` // optional descriptive label
|
||||
Enabled bool `json:"enabled"` // whether to activate on connect
|
||||
Label string `json:"label"` // optional descriptive label
|
||||
}
|
||||
|
||||
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
||||
@@ -162,7 +166,7 @@ type TelnetConfig struct {
|
||||
LineMode bool `json:"lineMode"`
|
||||
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||
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.
|
||||
@@ -221,33 +225,71 @@ type VNCConfig struct {
|
||||
SharedSession bool `json:"sharedSession"`
|
||||
|
||||
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
type GitHubRepo struct {
|
||||
FullName string `json:"fullName"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"htmlURL"`
|
||||
DefaultBranch string `json:"defaultBranch"`
|
||||
FullName string `json:"fullName"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"htmlURL"`
|
||||
DefaultBranch string `json:"defaultBranch"`
|
||||
LatestCommitter string `json:"latestCommitter"`
|
||||
LatestCommitAt string `json:"latestCommitAt"`
|
||||
Private bool `json:"private"`
|
||||
Fork bool `json:"fork"`
|
||||
Archived bool `json:"archived"`
|
||||
Language string `json:"language"`
|
||||
Stars int `json:"stars"`
|
||||
PushedAt string `json:"pushedAt"` // ISO 8601
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Private bool `json:"private"`
|
||||
Fork bool `json:"fork"`
|
||||
Archived bool `json:"archived"`
|
||||
Language string `json:"language"`
|
||||
Stars int `json:"stars"`
|
||||
PushedAt string `json:"pushedAt"` // ISO 8601
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SessionState represents the lifecycle state of a connection session.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/githubclient"
|
||||
"github.com/andydixon/nexus/internal/ircclient"
|
||||
"github.com/andydixon/nexus/internal/localshell"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/rawclient"
|
||||
@@ -32,7 +33,7 @@ import (
|
||||
"github.com/andydixon/nexus/internal/vncclient"
|
||||
)
|
||||
|
||||
const maxVNCTunnelPrivateKeyBytes = 1 << 20
|
||||
const maxTunnelPrivateKeyBytes = 1 << 20
|
||||
|
||||
// Session wraps a protocol client with metadata and state.
|
||||
type Session struct {
|
||||
@@ -43,6 +44,7 @@ type Session struct {
|
||||
githubClient *githubclient.Client
|
||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||
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
|
||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||
profile *models.Profile
|
||||
@@ -139,6 +141,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
||||
err = m.connectGitHub(sess, getSecret)
|
||||
case models.ProtocolVNC:
|
||||
err = m.connectVNC(sess, getSecret)
|
||||
case models.ProtocolIRC:
|
||||
err = m.connectIRC(sess, getSecret)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
||||
}
|
||||
@@ -539,6 +543,74 @@ func (m *Manager) SendBreak(sessionID string) error {
|
||||
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.
|
||||
func (m *Manager) WriteSessionText(sessionID string, text string) error {
|
||||
m.mu.RLock()
|
||||
@@ -1040,8 +1112,8 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("SSH key path is a directory")
|
||||
}
|
||||
if info.Size() > maxVNCTunnelPrivateKeyBytes {
|
||||
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxVNCTunnelPrivateKeyBytes)
|
||||
if info.Size() > maxTunnelPrivateKeyBytes {
|
||||
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
|
||||
}
|
||||
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
|
||||
if err != nil {
|
||||
@@ -1091,6 +1163,139 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
|
||||
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.
|
||||
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
|
||||
m.mu.RLock()
|
||||
@@ -1728,6 +1933,10 @@ func (m *Manager) closeSession(sess *Session) {
|
||||
sess.vncClient.Close()
|
||||
sess.vncClient = nil
|
||||
}
|
||||
if sess.ircClient != nil {
|
||||
sess.ircClient.Close()
|
||||
sess.ircClient = nil
|
||||
}
|
||||
if sess.localClient != nil {
|
||||
sess.localClient.Close()
|
||||
sess.localClient = nil
|
||||
@@ -1879,6 +2088,10 @@ func (m *Manager) shouldAutoReconnect(sess *Session) bool {
|
||||
if sess.profile.RAW != nil {
|
||||
return sess.profile.RAW.AutoReconnect
|
||||
}
|
||||
case models.ProtocolIRC:
|
||||
if sess.profile.IRC != nil {
|
||||
return sess.profile.IRC.AutoReconnect
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1895,6 +2108,14 @@ func (m *Manager) maxReconnectAttempts(sess *Session) int {
|
||||
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
|
||||
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
|
||||
}
|
||||
@@ -1959,6 +2180,7 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
||||
sess.sshClient = nil
|
||||
sess.telnetClient = nil
|
||||
sess.rawClient = nil
|
||||
sess.ircClient = nil
|
||||
sess.sftpClient = nil
|
||||
sess.Info.State = models.SessionConnecting
|
||||
m.mu.Unlock()
|
||||
@@ -1976,6 +2198,8 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
||||
err = m.connectTelnet(sess)
|
||||
case models.ProtocolRAW:
|
||||
err = m.connectRAW(sess)
|
||||
case models.ProtocolIRC:
|
||||
err = m.connectIRC(sess, m.buildInteractiveSecretCallback(sessionID))
|
||||
default:
|
||||
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
@@ -111,15 +111,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
githubJSON, _ := json.Marshal(p.GitHub)
|
||||
vncJSON, _ := json.Marshal(p.VNC)
|
||||
ircJSON, _ := json.Marshal(p.IRC)
|
||||
|
||||
_, err := s.db.Exec(`INSERT INTO profiles (
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
||||
p.Host, p.Port, p.Username,
|
||||
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.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
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),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -143,7 +144,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE id = ?`, id)
|
||||
|
||||
@@ -165,13 +166,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
githubJSON, _ := json.Marshal(p.GitHub)
|
||||
vncJSON, _ := json.Marshal(p.VNC)
|
||||
ircJSON, _ := json.Marshal(p.IRC)
|
||||
|
||||
res, err := s.db.Exec(`UPDATE profiles SET
|
||||
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=?,
|
||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
p.DisplayName, p.Description, string(p.Protocol),
|
||||
@@ -181,7 +183,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
p.ConnectTimeout, p.ReadTimeout,
|
||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
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.ID,
|
||||
)
|
||||
@@ -215,7 +217,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles ORDER BY display_name`)
|
||||
if err != nil {
|
||||
@@ -234,7 +236,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles
|
||||
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,
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
||||
if err != nil {
|
||||
@@ -271,7 +273,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
||||
if err != nil {
|
||||
@@ -288,7 +290,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
||||
if err != nil {
|
||||
@@ -305,7 +307,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
||||
if err != nil {
|
||||
@@ -579,6 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||
ircJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
@@ -591,7 +594,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -634,6 +637,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
p.VNC = &models.VNCConfig{}
|
||||
_ = 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
|
||||
}
|
||||
@@ -646,6 +653,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||
ircJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
@@ -658,7 +666,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -701,6 +709,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
p.VNC = &models.VNCConfig{}
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/lrstanley/girc"
|
||||
)
|
||||
|
||||
// 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...)
|
||||
}
|
||||
}
|
||||
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:
|
||||
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
||||
}
|
||||
@@ -403,6 +413,101 @@ func ValidateVNCConfig(profileHost string, cfg *models.VNCConfig) error {
|
||||
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 {
|
||||
h := strings.ToLower(strings.TrimSpace(host))
|
||||
if h == "localhost" {
|
||||
|
||||
Reference in New Issue
Block a user