Merge pull request #5 from andydixon/irc
IRC support
This commit was merged in pull request #5.
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
<link rel="stylesheet" href="/src/css/modals.css" />
|
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||||
<link rel="stylesheet" href="/src/css/github.css" />
|
<link rel="stylesheet" href="/src/css/github.css" />
|
||||||
<link rel="stylesheet" href="/src/css/vnc.css" />
|
<link rel="stylesheet" href="/src/css/vnc.css" />
|
||||||
|
<link rel="stylesheet" href="/src/css/irc.css" />
|
||||||
<link rel="stylesheet" href="/src/css/leviathan.css" />
|
<link rel="stylesheet" href="/src/css/leviathan.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -334,6 +335,7 @@
|
|||||||
<script src="/src/js/github.js"></script>
|
<script src="/src/js/github.js"></script>
|
||||||
<script src="/src/js/lib/novnc.min.js"></script>
|
<script src="/src/js/lib/novnc.min.js"></script>
|
||||||
<script src="/src/js/vnc.js"></script>
|
<script src="/src/js/vnc.js"></script>
|
||||||
|
<script src="/src/js/irc.js"></script>
|
||||||
<script src="/src/js/settings.js"></script>
|
<script src="/src/js/settings.js"></script>
|
||||||
<script src="/src/js/commands.js"></script>
|
<script src="/src/js/commands.js"></script>
|
||||||
<script src="/src/js/secrets.js"></script>
|
<script src="/src/js/secrets.js"></script>
|
||||||
|
|||||||
@@ -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 {
|
.sidebar-item .protocol-indicator.proto-vnc::before {
|
||||||
content: "⊞";
|
content: "⊞";
|
||||||
}
|
}
|
||||||
|
.sidebar-item .protocol-indicator.proto-irc::before {
|
||||||
|
content: "#";
|
||||||
|
}
|
||||||
.sidebar-item .protocol-indicator.proto-local::before {
|
.sidebar-item .protocol-indicator.proto-local::before {
|
||||||
content: "›_";
|
content: "›_";
|
||||||
}
|
}
|
||||||
@@ -328,6 +331,10 @@
|
|||||||
background: rgba(232, 93, 74, 0.18);
|
background: rgba(232, 93, 74, 0.18);
|
||||||
color: var(--protocol-vnc);
|
color: var(--protocol-vnc);
|
||||||
}
|
}
|
||||||
|
.sidebar-item .protocol-indicator.proto-irc {
|
||||||
|
background: rgba(216, 178, 76, 0.18);
|
||||||
|
color: var(--protocol-irc);
|
||||||
|
}
|
||||||
.sidebar-item .protocol-indicator.proto-local {
|
.sidebar-item .protocol-indicator.proto-local {
|
||||||
background: rgba(139, 195, 74, 0.18);
|
background: rgba(139, 195, 74, 0.18);
|
||||||
color: var(--protocol-local);
|
color: var(--protocol-local);
|
||||||
|
|||||||
@@ -118,6 +118,10 @@
|
|||||||
background: rgba(232, 93, 74, 0.15);
|
background: rgba(232, 93, 74, 0.15);
|
||||||
color: var(--protocol-vnc);
|
color: var(--protocol-vnc);
|
||||||
}
|
}
|
||||||
|
.tab-badge.badge-irc {
|
||||||
|
background: rgba(216, 178, 76, 0.15);
|
||||||
|
color: var(--protocol-irc);
|
||||||
|
}
|
||||||
.tab-badge.badge-local {
|
.tab-badge.badge-local {
|
||||||
background: rgba(139, 195, 74, 0.15);
|
background: rgba(139, 195, 74, 0.15);
|
||||||
color: var(--protocol-local);
|
color: var(--protocol-local);
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
--protocol-raw: #ab47bc;
|
--protocol-raw: #ab47bc;
|
||||||
--protocol-github: #7c8aff;
|
--protocol-github: #7c8aff;
|
||||||
--protocol-vnc: #e85d4a;
|
--protocol-vnc: #e85d4a;
|
||||||
|
--protocol-irc: #d8b24c;
|
||||||
--protocol-local: #8bc34a;
|
--protocol-local: #8bc34a;
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------
|
/* -----------------------------------------------------------------------
|
||||||
|
|||||||
+50
-3
@@ -35,6 +35,7 @@
|
|||||||
this.terminals = new TerminalManager(this);
|
this.terminals = new TerminalManager(this);
|
||||||
this.profileEditor = new ProfileEditor(this);
|
this.profileEditor = new ProfileEditor(this);
|
||||||
this.rawViewer = new RawViewer(this);
|
this.rawViewer = new RawViewer(this);
|
||||||
|
window.NexusTerminal.IRCViewer?.setApp(this);
|
||||||
this.settings = new Settings(this);
|
this.settings = new Settings(this);
|
||||||
this.commandPalette = new CommandPalette(this);
|
this.commandPalette = new CommandPalette(this);
|
||||||
this.secretPrompt = new SecretPrompt(this);
|
this.secretPrompt = new SecretPrompt(this);
|
||||||
@@ -239,6 +240,7 @@
|
|||||||
setTimeout(fit, 50);
|
setTimeout(fit, 50);
|
||||||
setTimeout(fit, 300);
|
setTimeout(fit, 300);
|
||||||
setTimeout(fit, 800);
|
setTimeout(fit, 800);
|
||||||
|
setTimeout(() => window.NexusTerminal.IRCViewer?.fit(payload.sessionID), 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,6 +290,12 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IRC events
|
||||||
|
window.runtime.EventsOn("session:irc:event", (payload) => {
|
||||||
|
window.NexusTerminal.IRCViewer.handleEvent(payload);
|
||||||
|
this.tabs.markActivity(payload.sessionID);
|
||||||
|
});
|
||||||
|
|
||||||
// App-wide commands from the native menu
|
// App-wide commands from the native menu
|
||||||
window.runtime.EventsOn("app:open-about", () => {
|
window.runtime.EventsOn("app:open-about", () => {
|
||||||
this.settings.openTab("about");
|
this.settings.openTab("about");
|
||||||
@@ -801,7 +809,12 @@
|
|||||||
const decision = await this._confirmReuseSession(profile, preferred);
|
const decision = await this._confirmReuseSession(profile, preferred);
|
||||||
if (decision === "existing") {
|
if (decision === "existing") {
|
||||||
this.tabs.setActive(preferred.id);
|
this.tabs.setActive(preferred.id);
|
||||||
if (preferred.protocol !== "raw" && preferred.protocol !== "github") {
|
if (
|
||||||
|
preferred.protocol !== "raw" &&
|
||||||
|
preferred.protocol !== "github" &&
|
||||||
|
preferred.protocol !== "vnc" &&
|
||||||
|
preferred.protocol !== "irc"
|
||||||
|
) {
|
||||||
this.terminals.focusTerminal(preferred.id);
|
this.terminals.focusTerminal(preferred.id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -845,6 +858,15 @@
|
|||||||
profile,
|
profile,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (profile.protocol === "irc") {
|
||||||
|
const container = document.getElementById(panelId);
|
||||||
|
if (container) {
|
||||||
|
window.NexusTerminal.IRCViewer.createViewer(
|
||||||
|
sessionInfo.id,
|
||||||
|
container,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
||||||
}
|
}
|
||||||
@@ -901,6 +923,7 @@
|
|||||||
<option value="ssh">SSH</option>
|
<option value="ssh">SSH</option>
|
||||||
<option value="telnet">Telnet</option>
|
<option value="telnet">Telnet</option>
|
||||||
<option value="raw">RAW TCP</option>
|
<option value="raw">RAW TCP</option>
|
||||||
|
<option value="irc">IRC</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -915,6 +938,10 @@
|
|||||||
<label class="form-label">Username</label>
|
<label class="form-label">Username</label>
|
||||||
<input class="form-input qc-username" type="text" placeholder="root" />
|
<input class="form-input qc-username" type="text" placeholder="root" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group qc-nick-group" style="display:none">
|
||||||
|
<label class="form-label">Nick</label>
|
||||||
|
<input class="form-input qc-nick" type="text" value="nexus" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-sm qc-cancel">Cancel</button>
|
<button class="btn btn-sm qc-cancel">Cancel</button>
|
||||||
@@ -931,14 +958,17 @@
|
|||||||
const protocolSelect = modal.querySelector(".qc-protocol");
|
const protocolSelect = modal.querySelector(".qc-protocol");
|
||||||
const portInput = modal.querySelector(".qc-port");
|
const portInput = modal.querySelector(".qc-port");
|
||||||
const usernameGroup = modal.querySelector(".qc-username-group");
|
const usernameGroup = modal.querySelector(".qc-username-group");
|
||||||
|
const nickGroup = modal.querySelector(".qc-nick-group");
|
||||||
const hostInput = modal.querySelector(".qc-host");
|
const hostInput = modal.querySelector(".qc-host");
|
||||||
|
|
||||||
// Update port default and username visibility when protocol changes
|
// Update port default and username visibility when protocol changes
|
||||||
protocolSelect.addEventListener("change", () => {
|
protocolSelect.addEventListener("change", () => {
|
||||||
const proto = protocolSelect.value;
|
const proto = protocolSelect.value;
|
||||||
const defaultPorts = { ssh: 22, telnet: 23, raw: 23 };
|
const defaultPorts = { ssh: 22, telnet: 23, raw: 23, irc: 6667 };
|
||||||
portInput.value = defaultPorts[proto] || 22;
|
portInput.value = defaultPorts[proto] || 22;
|
||||||
usernameGroup.style.display = proto === "ssh" ? "" : "none";
|
usernameGroup.style.display =
|
||||||
|
proto === "ssh" || proto === "irc" ? "" : "none";
|
||||||
|
nickGroup.style.display = proto === "irc" ? "" : "none";
|
||||||
});
|
});
|
||||||
|
|
||||||
const close = (value) => {
|
const close = (value) => {
|
||||||
@@ -965,6 +995,7 @@
|
|||||||
host,
|
host,
|
||||||
port: parseInt(portInput.value, 10) || 22,
|
port: parseInt(portInput.value, 10) || 22,
|
||||||
username: modal.querySelector(".qc-username").value.trim(),
|
username: modal.querySelector(".qc-username").value.trim(),
|
||||||
|
nick: modal.querySelector(".qc-nick").value.trim(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
backdrop.addEventListener("click", (e) => {
|
backdrop.addEventListener("click", (e) => {
|
||||||
@@ -989,6 +1020,13 @@
|
|||||||
displayName: result.host,
|
displayName: result.host,
|
||||||
tags: [],
|
tags: [],
|
||||||
};
|
};
|
||||||
|
if (result.protocol === "irc") {
|
||||||
|
profile.irc = {
|
||||||
|
nick: result.nick || "nexus",
|
||||||
|
realName: "Nexus Terminal",
|
||||||
|
autoJoinRooms: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
|
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
|
||||||
this.state.addSession(sessionInfo);
|
this.state.addSession(sessionInfo);
|
||||||
this.tabs.addTab(sessionInfo);
|
this.tabs.addTab(sessionInfo);
|
||||||
@@ -999,6 +1037,15 @@
|
|||||||
protocol: "raw",
|
protocol: "raw",
|
||||||
raw: {},
|
raw: {},
|
||||||
});
|
});
|
||||||
|
} else if (result.protocol === "irc") {
|
||||||
|
const container = document.getElementById(panelId);
|
||||||
|
if (container) {
|
||||||
|
window.NexusTerminal.IRCViewer.createViewer(
|
||||||
|
sessionInfo.id,
|
||||||
|
container,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.terminals.createTerminal(sessionInfo.id, panelId, {
|
this.terminals.createTerminal(sessionInfo.id, panelId, {
|
||||||
protocol: result.protocol,
|
protocol: result.protocol,
|
||||||
|
|||||||
@@ -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,
|
sshAgentAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
irc: {
|
||||||
|
port: 6667,
|
||||||
|
protocol: "irc",
|
||||||
|
irc: {
|
||||||
|
nick: "nexus",
|
||||||
|
realName: "Nexus Terminal",
|
||||||
|
useTLS: false,
|
||||||
|
serverPasswordRequired: false,
|
||||||
|
serverPasswordMode: "ask-every-time",
|
||||||
|
nickServIdentify: false,
|
||||||
|
nickServAccount: "",
|
||||||
|
nickServMode: "saved",
|
||||||
|
autoJoinRooms: [],
|
||||||
|
sshTunnel: false,
|
||||||
|
sshHost: "",
|
||||||
|
sshPort: 22,
|
||||||
|
sshUsername: "",
|
||||||
|
sshAuthMethod: "publickey",
|
||||||
|
sshSecretMode: "saved",
|
||||||
|
sshPrivateKey: "",
|
||||||
|
sshAgentAuth: false,
|
||||||
|
autoReconnect: true,
|
||||||
|
maxReconnectTries: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
class ProfileEditor {
|
class ProfileEditor {
|
||||||
@@ -162,10 +187,11 @@
|
|||||||
drawer.appendChild(header);
|
drawer.appendChild(header);
|
||||||
|
|
||||||
// ── Protocol selector tabs ──────────────────────────────
|
// ── Protocol selector tabs ──────────────────────────────
|
||||||
const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
|
const protocols = ["ssh", "telnet", "raw", "github", "vnc", "irc"];
|
||||||
const tabBar = createElement("div", "drawer-protocol-tabs");
|
const tabBar = createElement("div", "drawer-protocol-tabs");
|
||||||
for (const proto of protocols) {
|
for (const proto of protocols) {
|
||||||
const label = proto === "github" ? "GitHub" : proto.toUpperCase();
|
const label =
|
||||||
|
proto === "github" ? "GitHub" : proto === "irc" ? "IRC" : proto.toUpperCase();
|
||||||
const btn = createElement(
|
const btn = createElement(
|
||||||
"button",
|
"button",
|
||||||
`drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`,
|
`drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`,
|
||||||
@@ -208,6 +234,10 @@
|
|||||||
case "vnc":
|
case "vnc":
|
||||||
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
|
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
|
||||||
break;
|
break;
|
||||||
|
case "irc":
|
||||||
|
this._renderIRCSections(profile).forEach((s) => body.appendChild(s));
|
||||||
|
body.appendChild(sshKeyDatalist);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal section (SSH and Telnet only)
|
// Terminal section (SSH and Telnet only)
|
||||||
@@ -1075,6 +1105,185 @@
|
|||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderIRCSections(profile) {
|
||||||
|
const sections = [];
|
||||||
|
const irc = profile.irc || DEFAULTS.irc.irc;
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("IRC", [
|
||||||
|
this._field("text", "irc.nick", "Nick", irc.nick, {
|
||||||
|
placeholder: "yourNick",
|
||||||
|
}),
|
||||||
|
this._field("text", "irc.realName", "Real Name", irc.realName, {
|
||||||
|
placeholder: "Shown in WHOIS",
|
||||||
|
}),
|
||||||
|
this._field("toggle", "irc.useTLS", "Use TLS", irc.useTLS, {
|
||||||
|
help: "Use port 6697 for most TLS IRC networks.",
|
||||||
|
}),
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.serverPasswordRequired",
|
||||||
|
"Server Password",
|
||||||
|
irc.serverPasswordRequired,
|
||||||
|
{
|
||||||
|
help: "Prompt for an IRC PASS secret before registration.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"select",
|
||||||
|
"irc.serverPasswordMode",
|
||||||
|
"Server Password Storage",
|
||||||
|
irc.serverPasswordMode,
|
||||||
|
{
|
||||||
|
options: [
|
||||||
|
{ value: "saved", label: "Save to keychain" },
|
||||||
|
{ value: "session-only", label: "Remember for session" },
|
||||||
|
{ value: "ask-every-time", label: "Ask every time" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("NickServ", [
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.nickServIdentify",
|
||||||
|
"Identify on Connect",
|
||||||
|
irc.nickServIdentify,
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"text",
|
||||||
|
"irc.nickServAccount",
|
||||||
|
"Account",
|
||||||
|
irc.nickServAccount,
|
||||||
|
{
|
||||||
|
placeholder: "Optional if it matches your nick",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"select",
|
||||||
|
"irc.nickServMode",
|
||||||
|
"Password Storage",
|
||||||
|
irc.nickServMode,
|
||||||
|
{
|
||||||
|
options: [
|
||||||
|
{ value: "saved", label: "Save to keychain" },
|
||||||
|
{ value: "session-only", label: "Remember for session" },
|
||||||
|
{ value: "ask-every-time", label: "Ask every time" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("Rooms", [
|
||||||
|
this._field(
|
||||||
|
"textarea",
|
||||||
|
"irc.autoJoinRoomsText",
|
||||||
|
"Auto-Join Rooms",
|
||||||
|
this._roomsToText(irc.autoJoinRooms),
|
||||||
|
{
|
||||||
|
placeholder: "#general\n#ops",
|
||||||
|
rows: 4,
|
||||||
|
help: "One room per line or comma-separated.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("SSH Tunnel", [
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.sshTunnel",
|
||||||
|
"Enable SSH Tunnel",
|
||||||
|
irc.sshTunnel,
|
||||||
|
),
|
||||||
|
this._field("text", "irc.sshHost", "SSH Host", irc.sshHost, {
|
||||||
|
placeholder: "jump.example.com",
|
||||||
|
}),
|
||||||
|
this._field("number", "irc.sshPort", "SSH Port", irc.sshPort, {
|
||||||
|
min: 1,
|
||||||
|
max: 65535,
|
||||||
|
}),
|
||||||
|
this._field(
|
||||||
|
"text",
|
||||||
|
"irc.sshUsername",
|
||||||
|
"SSH Username",
|
||||||
|
irc.sshUsername,
|
||||||
|
{
|
||||||
|
placeholder: "admin",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"select",
|
||||||
|
"irc.sshAuthMethod",
|
||||||
|
"SSH Auth Method",
|
||||||
|
irc.sshAuthMethod,
|
||||||
|
{
|
||||||
|
options: [
|
||||||
|
{ value: "publickey", label: "Public Key" },
|
||||||
|
{ value: "password", label: "Password" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"text",
|
||||||
|
"irc.sshPrivateKey",
|
||||||
|
"SSH Private Key Path",
|
||||||
|
irc.sshPrivateKey,
|
||||||
|
{
|
||||||
|
placeholder: "~/.ssh/id_ed25519",
|
||||||
|
datalistId: "ssh-key-path-options",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.sshAgentAuth",
|
||||||
|
"Use SSH Agent",
|
||||||
|
irc.sshAgentAuth,
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"select",
|
||||||
|
"irc.sshSecretMode",
|
||||||
|
"SSH Secret Storage",
|
||||||
|
irc.sshSecretMode,
|
||||||
|
{
|
||||||
|
options: [
|
||||||
|
{ value: "saved", label: "Save to keychain" },
|
||||||
|
{ value: "session-only", label: "Remember for session" },
|
||||||
|
{ value: "ask-every-time", label: "Ask every time" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("Reconnection", [
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.autoReconnect",
|
||||||
|
"Auto-Reconnect",
|
||||||
|
irc.autoReconnect,
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"number",
|
||||||
|
"irc.maxReconnectTries",
|
||||||
|
"Max Reconnect Attempts",
|
||||||
|
irc.maxReconnectTries || 5,
|
||||||
|
{ min: 1, max: 20 },
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
_renderTerminalSection(profile) {
|
_renderTerminalSection(profile) {
|
||||||
return this._renderSection("Terminal", [
|
return this._renderSection("Terminal", [
|
||||||
@@ -2275,6 +2484,34 @@
|
|||||||
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
|
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
|
||||||
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
|
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRC-specific
|
||||||
|
if (p.protocol === "irc") {
|
||||||
|
if (!p.irc) p.irc = {};
|
||||||
|
p.irc.nick = val("irc.nick") || "nexus";
|
||||||
|
p.irc.realName = val("irc.realName") || "Nexus Terminal";
|
||||||
|
p.irc.useTLS = val("irc.useTLS") || false;
|
||||||
|
p.irc.serverPasswordRequired =
|
||||||
|
val("irc.serverPasswordRequired") || false;
|
||||||
|
p.irc.serverPasswordMode =
|
||||||
|
val("irc.serverPasswordMode") || "ask-every-time";
|
||||||
|
p.irc.nickServIdentify = val("irc.nickServIdentify") || false;
|
||||||
|
p.irc.nickServAccount = val("irc.nickServAccount") || "";
|
||||||
|
p.irc.nickServMode = val("irc.nickServMode") || "saved";
|
||||||
|
p.irc.autoJoinRooms = this._parseRoomList(
|
||||||
|
val("irc.autoJoinRoomsText") || "",
|
||||||
|
);
|
||||||
|
p.irc.sshTunnel = val("irc.sshTunnel") || false;
|
||||||
|
p.irc.sshHost = val("irc.sshHost") || "";
|
||||||
|
p.irc.sshPort = val("irc.sshPort") || 22;
|
||||||
|
p.irc.sshUsername = val("irc.sshUsername") || "";
|
||||||
|
p.irc.sshAuthMethod = val("irc.sshAuthMethod") || "publickey";
|
||||||
|
p.irc.sshPrivateKey = val("irc.sshPrivateKey") || "";
|
||||||
|
p.irc.sshAgentAuth = val("irc.sshAgentAuth") || false;
|
||||||
|
p.irc.sshSecretMode = val("irc.sshSecretMode") || "saved";
|
||||||
|
p.irc.autoReconnect = val("irc.autoReconnect") !== false;
|
||||||
|
p.irc.maxReconnectTries = val("irc.maxReconnectTries") || 5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Validation ───────────────────────────────────────────────────
|
// ── Validation ───────────────────────────────────────────────────
|
||||||
@@ -2300,6 +2537,25 @@
|
|||||||
if (!profile.port || profile.port < 1 || profile.port > 65535) {
|
if (!profile.port || profile.port < 1 || profile.port > 65535) {
|
||||||
errors.push("Port must be between 1 and 65535.");
|
errors.push("Port must be between 1 and 65535.");
|
||||||
}
|
}
|
||||||
|
if (profile.protocol === "irc") {
|
||||||
|
const nick = profile.irc?.nick || "";
|
||||||
|
if (!nick.trim()) {
|
||||||
|
errors.push("IRC nick is required.");
|
||||||
|
}
|
||||||
|
for (const room of profile.irc?.autoJoinRooms || []) {
|
||||||
|
if (!/^[#&+!][^\s,]+$/.test(room)) {
|
||||||
|
errors.push(`Invalid IRC room "${room}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (profile.irc?.sshTunnel) {
|
||||||
|
if (!profile.irc.sshHost?.trim()) {
|
||||||
|
errors.push("SSH Host is required when tunnelling IRC.");
|
||||||
|
}
|
||||||
|
if (!profile.irc.sshUsername?.trim()) {
|
||||||
|
errors.push("SSH Username is required when tunnelling IRC.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2476,6 +2732,7 @@
|
|||||||
raw: null,
|
raw: null,
|
||||||
github: null,
|
github: null,
|
||||||
vnc: null,
|
vnc: null,
|
||||||
|
irc: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaults = DEFAULTS[protocol] || {};
|
const defaults = DEFAULTS[protocol] || {};
|
||||||
@@ -2494,6 +2751,17 @@
|
|||||||
.map(([k, v]) => `${k}=${v}`)
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_roomsToText(rooms) {
|
||||||
|
return Array.isArray(rooms) ? rooms.join("\n") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseRoomList(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((room) => room.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.NexusTerminal = window.NexusTerminal || {};
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
|
|||||||
@@ -926,6 +926,8 @@
|
|||||||
return "TELNET";
|
return "TELNET";
|
||||||
case "raw":
|
case "raw":
|
||||||
return "RAW";
|
return "RAW";
|
||||||
|
case "irc":
|
||||||
|
return "IRC";
|
||||||
default:
|
default:
|
||||||
return (protocol || "").toUpperCase() || "UNKNOWN";
|
return (protocol || "").toUpperCase() || "UNKNOWN";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,7 @@
|
|||||||
this.app.terminals.destroyTerminal(sessionId);
|
this.app.terminals.destroyTerminal(sessionId);
|
||||||
this.app.rawViewer.destroyViewer(sessionId);
|
this.app.rawViewer.destroyViewer(sessionId);
|
||||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||||
|
window.NexusTerminal.IRCViewer.destroyViewer(sessionId);
|
||||||
this.app.leviathan?.destroyPanel(sessionId);
|
this.app.leviathan?.destroyPanel(sessionId);
|
||||||
this.removeTab(sessionId);
|
this.removeTab(sessionId);
|
||||||
this.app.state.removeSession(sessionId);
|
this.app.state.removeSession(sessionId);
|
||||||
|
|||||||
@@ -290,6 +290,8 @@
|
|||||||
return "proto-github";
|
return "proto-github";
|
||||||
case "vnc":
|
case "vnc":
|
||||||
return "proto-vnc";
|
return "proto-vnc";
|
||||||
|
case "irc":
|
||||||
|
return "proto-irc";
|
||||||
case "local":
|
case "local":
|
||||||
return "proto-local";
|
return "proto-local";
|
||||||
default:
|
default:
|
||||||
|
|||||||
Vendored
+12
@@ -80,6 +80,18 @@ export function GetVNCWebSocketURL(arg1:string):Promise<string>;
|
|||||||
|
|
||||||
export function HasTOTPSecret(arg1:string):Promise<boolean>;
|
export function HasTOTPSecret(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function IRCHandleInput(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IRCJoin(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IRCListRooms(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IRCPart(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IRCRooms(arg1:string):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
|
export function IRCUsers(arg1:string,arg2:string):Promise<Array<string>>;
|
||||||
|
|
||||||
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|||||||
@@ -154,6 +154,30 @@ export function HasTOTPSecret(arg1) {
|
|||||||
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
|
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IRCHandleInput(arg1, arg2, arg3) {
|
||||||
|
return window['go']['api']['Service']['IRCHandleInput'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCJoin(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['IRCJoin'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCListRooms(arg1) {
|
||||||
|
return window['go']['api']['Service']['IRCListRooms'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCPart(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['IRCPart'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCRooms(arg1) {
|
||||||
|
return window['go']['api']['Service']['IRCRooms'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCUsers(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['IRCUsers'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportProfiles(arg1, arg2) {
|
export function ImportProfiles(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
|
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,54 @@ export namespace models {
|
|||||||
this.updatedAt = source["updatedAt"];
|
this.updatedAt = source["updatedAt"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class IRCConfig {
|
||||||
|
nick: string;
|
||||||
|
realName: string;
|
||||||
|
useTLS: boolean;
|
||||||
|
serverPasswordRequired: boolean;
|
||||||
|
serverPasswordMode: string;
|
||||||
|
nickServIdentify: boolean;
|
||||||
|
nickServAccount: string;
|
||||||
|
nickServMode: string;
|
||||||
|
autoJoinRooms: string[];
|
||||||
|
sshTunnel: boolean;
|
||||||
|
sshHost: string;
|
||||||
|
sshPort: number;
|
||||||
|
sshUsername: string;
|
||||||
|
sshAuthMethod: string;
|
||||||
|
sshSecretMode: string;
|
||||||
|
sshPrivateKeyPath: string;
|
||||||
|
sshAgentAuth: boolean;
|
||||||
|
autoReconnect: boolean;
|
||||||
|
maxReconnectTries: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new IRCConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.nick = source["nick"];
|
||||||
|
this.realName = source["realName"];
|
||||||
|
this.useTLS = source["useTLS"];
|
||||||
|
this.serverPasswordRequired = source["serverPasswordRequired"];
|
||||||
|
this.serverPasswordMode = source["serverPasswordMode"];
|
||||||
|
this.nickServIdentify = source["nickServIdentify"];
|
||||||
|
this.nickServAccount = source["nickServAccount"];
|
||||||
|
this.nickServMode = source["nickServMode"];
|
||||||
|
this.autoJoinRooms = source["autoJoinRooms"];
|
||||||
|
this.sshTunnel = source["sshTunnel"];
|
||||||
|
this.sshHost = source["sshHost"];
|
||||||
|
this.sshPort = source["sshPort"];
|
||||||
|
this.sshUsername = source["sshUsername"];
|
||||||
|
this.sshAuthMethod = source["sshAuthMethod"];
|
||||||
|
this.sshSecretMode = source["sshSecretMode"];
|
||||||
|
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
|
||||||
|
this.sshAgentAuth = source["sshAgentAuth"];
|
||||||
|
this.autoReconnect = source["autoReconnect"];
|
||||||
|
this.maxReconnectTries = source["maxReconnectTries"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class JumpHost {
|
export class JumpHost {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -520,6 +568,7 @@ export namespace models {
|
|||||||
raw?: RAWConfig;
|
raw?: RAWConfig;
|
||||||
github?: GitHubConfig;
|
github?: GitHubConfig;
|
||||||
vnc?: VNCConfig;
|
vnc?: VNCConfig;
|
||||||
|
irc?: IRCConfig;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Profile(source);
|
return new Profile(source);
|
||||||
@@ -553,6 +602,7 @@ export namespace models {
|
|||||||
this.raw = this.convertValues(source["raw"], RAWConfig);
|
this.raw = this.convertValues(source["raw"], RAWConfig);
|
||||||
this.github = this.convertValues(source["github"], GitHubConfig);
|
this.github = this.convertValues(source["github"], GitHubConfig);
|
||||||
this.vnc = this.convertValues(source["vnc"], VNCConfig);
|
this.vnc = this.convertValues(source["vnc"], VNCConfig);
|
||||||
|
this.irc = this.convertValues(source["irc"], IRCConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4
|
github.com/hajimehoshi/go-mp3 v0.3.4
|
||||||
github.com/klauspost/compress v1.17.11
|
github.com/klauspost/compress v1.17.11
|
||||||
|
github.com/lrstanley/girc v1.1.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.44
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
github.com/pkg/sftp v1.13.10
|
github.com/pkg/sftp v1.13.10
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
|||||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
|
||||||
|
github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
|
|||||||
if profile.ID == "" {
|
if profile.ID == "" {
|
||||||
profile.ID = uuid.New().String()
|
profile.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
normaliseProfileForSave(&profile)
|
||||||
sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
|
sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.SessionInfo{}, err
|
return models.SessionInfo{}, err
|
||||||
@@ -527,6 +528,52 @@ func (s *Service) SendBreak(sessionID string) error {
|
|||||||
return s.sessions.SendBreak(sessionID)
|
return s.sessions.SendBreak(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IRC-specific
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// IRCHandleInput sends an IRC composer line, including slash commands.
|
||||||
|
func (s *Service) IRCHandleInput(sessionID string, target string, input string) error {
|
||||||
|
return s.sessions.IRCHandleInput(sessionID, target, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCJoin joins an IRC room.
|
||||||
|
func (s *Service) IRCJoin(sessionID string, room string) error {
|
||||||
|
return s.sessions.IRCJoin(sessionID, room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCPart leaves an IRC room.
|
||||||
|
func (s *Service) IRCPart(sessionID string, room string) error {
|
||||||
|
return s.sessions.IRCPart(sessionID, room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCListRooms requests the server room list. Results are emitted as events.
|
||||||
|
func (s *Service) IRCListRooms(sessionID string) error {
|
||||||
|
return s.sessions.IRCListRooms(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCRooms returns currently joined IRC rooms.
|
||||||
|
func (s *Service) IRCRooms(sessionID string) ([]map[string]interface{}, error) {
|
||||||
|
rooms, err := s.sessions.IRCRooms(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]map[string]interface{}, len(rooms))
|
||||||
|
for i, room := range rooms {
|
||||||
|
result[i] = map[string]interface{}{
|
||||||
|
"name": room.Name,
|
||||||
|
"topic": room.Topic,
|
||||||
|
"users": room.Users,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCUsers returns the known users in a joined IRC room.
|
||||||
|
func (s *Service) IRCUsers(sessionID string, room string) ([]string, error) {
|
||||||
|
return s.sessions.IRCUsers(sessionID, room)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Secret store
|
// Secret store
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
DefaultRAWPort = 23
|
||||||
DefaultGitHubPort = 443
|
DefaultGitHubPort = 443
|
||||||
DefaultVNCPort = 5900
|
DefaultVNCPort = 5900
|
||||||
|
DefaultIRCPort = 6667
|
||||||
|
DefaultIRCTLSPort = 6697
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default timeout values (in seconds).
|
// Default timeout values (in seconds).
|
||||||
@@ -122,6 +124,19 @@ func NewProfile(protocol Protocol) Profile {
|
|||||||
SSHAuthMethod: "publickey",
|
SSHAuthMethod: "publickey",
|
||||||
SSHSecretMode: SecretModeSaved,
|
SSHSecretMode: SecretModeSaved,
|
||||||
}
|
}
|
||||||
|
case ProtocolIRC:
|
||||||
|
p.Port = DefaultIRCPort
|
||||||
|
p.IRC = &IRCConfig{
|
||||||
|
Nick: "nexus",
|
||||||
|
RealName: "Nexus Terminal",
|
||||||
|
ServerPasswordMode: SecretModeAskEveryTime,
|
||||||
|
NickServMode: SecretModeSaved,
|
||||||
|
AutoJoinRooms: []string{},
|
||||||
|
SSHPort: DefaultSSHPort,
|
||||||
|
SSHAuthMethod: "publickey",
|
||||||
|
SSHSecretMode: SecretModeSaved,
|
||||||
|
AutoReconnect: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
@@ -186,6 +201,8 @@ func NormalizeProfileDefaults(p *Profile) {
|
|||||||
normalizeGitHubConfig(p, defaults.GitHub)
|
normalizeGitHubConfig(p, defaults.GitHub)
|
||||||
case ProtocolVNC:
|
case ProtocolVNC:
|
||||||
normalizeVNCConfig(p, defaults.VNC)
|
normalizeVNCConfig(p, defaults.VNC)
|
||||||
|
case ProtocolIRC:
|
||||||
|
normalizeIRCConfig(p, defaults.IRC)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +218,8 @@ func defaultProfileName(p *Profile) string {
|
|||||||
return "GitHub"
|
return "GitHub"
|
||||||
case ProtocolVNC:
|
case ProtocolVNC:
|
||||||
return "VNC"
|
return "VNC"
|
||||||
|
case ProtocolIRC:
|
||||||
|
return "IRC"
|
||||||
case ProtocolRAW:
|
case ProtocolRAW:
|
||||||
return "RAW TCP"
|
return "RAW TCP"
|
||||||
case ProtocolTelnet:
|
case ProtocolTelnet:
|
||||||
@@ -340,3 +359,31 @@ func normalizeVNCConfig(p *Profile, defaults *VNCConfig) {
|
|||||||
}
|
}
|
||||||
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
|
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeIRCConfig(p *Profile, defaults *IRCConfig) {
|
||||||
|
if p.IRC == nil {
|
||||||
|
cfg := *defaults
|
||||||
|
cfg.AutoJoinRooms = append([]string{}, defaults.AutoJoinRooms...)
|
||||||
|
p.IRC = &cfg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := p.IRC
|
||||||
|
if cfg.Nick == "" {
|
||||||
|
cfg.Nick = defaults.Nick
|
||||||
|
}
|
||||||
|
if cfg.RealName == "" {
|
||||||
|
cfg.RealName = defaults.RealName
|
||||||
|
}
|
||||||
|
cfg.ServerPasswordMode = normalizeSecretMode(cfg.ServerPasswordMode, defaults.ServerPasswordMode)
|
||||||
|
cfg.NickServMode = normalizeSecretMode(cfg.NickServMode, defaults.NickServMode)
|
||||||
|
if cfg.AutoJoinRooms == nil {
|
||||||
|
cfg.AutoJoinRooms = []string{}
|
||||||
|
}
|
||||||
|
if cfg.SSHPort == 0 {
|
||||||
|
cfg.SSHPort = defaults.SSHPort
|
||||||
|
}
|
||||||
|
if cfg.SSHAuthMethod == "" {
|
||||||
|
cfg.SSHAuthMethod = defaults.SSHAuthMethod
|
||||||
|
}
|
||||||
|
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
ProtocolRAW Protocol = "raw"
|
ProtocolRAW Protocol = "raw"
|
||||||
ProtocolGitHub Protocol = "github"
|
ProtocolGitHub Protocol = "github"
|
||||||
ProtocolVNC Protocol = "vnc"
|
ProtocolVNC Protocol = "vnc"
|
||||||
|
ProtocolIRC Protocol = "irc"
|
||||||
ProtocolLocal Protocol = "local"
|
ProtocolLocal Protocol = "local"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +86,9 @@ type Profile struct {
|
|||||||
|
|
||||||
// VNC-specific configuration
|
// VNC-specific configuration
|
||||||
VNC *VNCConfig `json:"vnc,omitempty"`
|
VNC *VNCConfig `json:"vnc,omitempty"`
|
||||||
|
|
||||||
|
// IRC-specific configuration
|
||||||
|
IRC *IRCConfig `json:"irc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHConfig holds SSH-specific settings for a connection profile.
|
// SSHConfig holds SSH-specific settings for a connection profile.
|
||||||
@@ -231,6 +235,44 @@ type VNCConfig struct {
|
|||||||
SSHAgentAuth bool `json:"sshAgentAuth"`
|
SSHAgentAuth bool `json:"sshAgentAuth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRCConfig holds IRC-specific settings for a connection profile.
|
||||||
|
type IRCConfig struct {
|
||||||
|
// Nick is the primary nickname used when connecting to the IRC network.
|
||||||
|
Nick string `json:"nick"`
|
||||||
|
|
||||||
|
// RealName is the user-facing realname/gecos sent during registration.
|
||||||
|
RealName string `json:"realName"`
|
||||||
|
|
||||||
|
// UseTLS enables TLS for the IRC server connection.
|
||||||
|
UseTLS bool `json:"useTLS"`
|
||||||
|
|
||||||
|
// ServerPasswordRequired controls whether to prompt for an IRC server PASS secret.
|
||||||
|
ServerPasswordRequired bool `json:"serverPasswordRequired"`
|
||||||
|
ServerPasswordMode SecretMode `json:"serverPasswordMode"`
|
||||||
|
|
||||||
|
// NickServ settings. The password is resolved through the secret store.
|
||||||
|
NickServIdentify bool `json:"nickServIdentify"`
|
||||||
|
NickServAccount string `json:"nickServAccount"`
|
||||||
|
NickServMode SecretMode `json:"nickServMode"`
|
||||||
|
|
||||||
|
// AutoJoinRooms are joined automatically after registration.
|
||||||
|
AutoJoinRooms []string `json:"autoJoinRooms"`
|
||||||
|
|
||||||
|
// SSH tunnel settings — if enabled, IRC traffic is tunnelled through SSH.
|
||||||
|
SSHTunnel bool `json:"sshTunnel"`
|
||||||
|
SSHHost string `json:"sshHost"`
|
||||||
|
SSHPort int `json:"sshPort"`
|
||||||
|
SSHUsername string `json:"sshUsername"`
|
||||||
|
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
|
||||||
|
SSHSecretMode SecretMode `json:"sshSecretMode"`
|
||||||
|
SSHPrivateKey string `json:"sshPrivateKeyPath"`
|
||||||
|
SSHAgentAuth bool `json:"sshAgentAuth"`
|
||||||
|
|
||||||
|
// Reconnect policy for unexpected disconnects.
|
||||||
|
AutoReconnect bool `json:"autoReconnect"`
|
||||||
|
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||||
|
}
|
||||||
|
|
||||||
// GitHubRepo is a single repository returned to the frontend for display.
|
// GitHubRepo is a single repository returned to the frontend for display.
|
||||||
type GitHubRepo struct {
|
type GitHubRepo struct {
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"github.com/andydixon/nexus/internal/audit"
|
"github.com/andydixon/nexus/internal/audit"
|
||||||
"github.com/andydixon/nexus/internal/githubclient"
|
"github.com/andydixon/nexus/internal/githubclient"
|
||||||
|
"github.com/andydixon/nexus/internal/ircclient"
|
||||||
"github.com/andydixon/nexus/internal/localshell"
|
"github.com/andydixon/nexus/internal/localshell"
|
||||||
"github.com/andydixon/nexus/internal/models"
|
"github.com/andydixon/nexus/internal/models"
|
||||||
"github.com/andydixon/nexus/internal/rawclient"
|
"github.com/andydixon/nexus/internal/rawclient"
|
||||||
@@ -32,7 +33,7 @@ import (
|
|||||||
"github.com/andydixon/nexus/internal/vncclient"
|
"github.com/andydixon/nexus/internal/vncclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxVNCTunnelPrivateKeyBytes = 1 << 20
|
const maxTunnelPrivateKeyBytes = 1 << 20
|
||||||
|
|
||||||
// Session wraps a protocol client with metadata and state.
|
// Session wraps a protocol client with metadata and state.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
@@ -43,6 +44,7 @@ type Session struct {
|
|||||||
githubClient *githubclient.Client
|
githubClient *githubclient.Client
|
||||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||||
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
||||||
|
ircClient *ircclient.Client // IRC client, nil for non-IRC sessions
|
||||||
localClient *localshell.Client // local shell PTY, nil for non-local sessions
|
localClient *localshell.Client // local shell PTY, nil for non-local sessions
|
||||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||||
profile *models.Profile
|
profile *models.Profile
|
||||||
@@ -139,6 +141,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
|||||||
err = m.connectGitHub(sess, getSecret)
|
err = m.connectGitHub(sess, getSecret)
|
||||||
case models.ProtocolVNC:
|
case models.ProtocolVNC:
|
||||||
err = m.connectVNC(sess, getSecret)
|
err = m.connectVNC(sess, getSecret)
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
err = m.connectIRC(sess, getSecret)
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
||||||
}
|
}
|
||||||
@@ -539,6 +543,74 @@ func (m *Manager) SendBreak(sessionID string) error {
|
|||||||
return sess.telnetClient.SendBreak()
|
return sess.telnetClient.SendBreak()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRCHandleInput sends a user-entered composer line to an IRC session.
|
||||||
|
func (m *Manager) IRCHandleInput(sessionID string, target string, input string) error {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.HandleInput(target, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCJoin joins an IRC room.
|
||||||
|
func (m *Manager) IRCJoin(sessionID string, room string) error {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Join(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCPart leaves an IRC room.
|
||||||
|
func (m *Manager) IRCPart(sessionID string, room string) error {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Part(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCListRooms requests a server-side IRC room list.
|
||||||
|
func (m *Manager) IRCListRooms(sessionID string) error {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.ListRooms()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCRooms returns currently joined IRC rooms.
|
||||||
|
func (m *Manager) IRCRooms(sessionID string) ([]ircclient.RoomInfo, error) {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.Rooms(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCUsers returns known users in an IRC room.
|
||||||
|
func (m *Manager) IRCUsers(sessionID string, room string) ([]string, error) {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.Users(room), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getIRC(sessionID string) (*ircclient.Client, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
sess, ok := m.sessions[sessionID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
||||||
|
}
|
||||||
|
if sess.ircClient == nil {
|
||||||
|
return nil, fmt.Errorf("sessions: session %q is not an IRC connection", sessionID)
|
||||||
|
}
|
||||||
|
return sess.ircClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
// WriteSessionText sends text with line ending via the RAW client.
|
// WriteSessionText sends text with line ending via the RAW client.
|
||||||
func (m *Manager) WriteSessionText(sessionID string, text string) error {
|
func (m *Manager) WriteSessionText(sessionID string, text string) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -1040,8 +1112,8 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
|
|||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
return fmt.Errorf("SSH key path is a directory")
|
return fmt.Errorf("SSH key path is a directory")
|
||||||
}
|
}
|
||||||
if info.Size() > maxVNCTunnelPrivateKeyBytes {
|
if info.Size() > maxTunnelPrivateKeyBytes {
|
||||||
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxVNCTunnelPrivateKeyBytes)
|
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
|
||||||
}
|
}
|
||||||
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
|
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1091,6 +1163,139 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// connectIRC establishes an IRC connection, optionally through an SSH tunnel,
|
||||||
|
// and forwards typed IRC events to the frontend.
|
||||||
|
func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (string, error)) error {
|
||||||
|
profile := sess.profile
|
||||||
|
ircConf := profile.IRC
|
||||||
|
if ircConf == nil {
|
||||||
|
return fmt.Errorf("IRC configuration missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPass := ""
|
||||||
|
if ircConf.ServerPasswordRequired {
|
||||||
|
getPass := m.buildSecretCallback(profile.ID, "irc-server-password", ircConf.ServerPasswordMode, getSecret)
|
||||||
|
password, err := getPass()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("IRC server password: %w", err)
|
||||||
|
}
|
||||||
|
serverPass = password
|
||||||
|
}
|
||||||
|
|
||||||
|
nickServPass := ""
|
||||||
|
if ircConf.NickServIdentify {
|
||||||
|
getPass := m.buildSecretCallback(profile.ID, "irc-nickserv-password", ircConf.NickServMode, getSecret)
|
||||||
|
password, err := getPass()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("NickServ password: %w", err)
|
||||||
|
}
|
||||||
|
nickServPass = password
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := ircclient.Config{
|
||||||
|
Server: profile.Host,
|
||||||
|
Port: profile.Port,
|
||||||
|
Nick: ircConf.Nick,
|
||||||
|
User: profile.Username,
|
||||||
|
RealName: ircConf.RealName,
|
||||||
|
UseTLS: ircConf.UseTLS,
|
||||||
|
ServerPass: serverPass,
|
||||||
|
AutoJoinRooms: append([]string{}, ircConf.AutoJoinRooms...),
|
||||||
|
NickServIdentify: ircConf.NickServIdentify,
|
||||||
|
NickServAccount: ircConf.NickServAccount,
|
||||||
|
NickServPassword: nickServPass,
|
||||||
|
ConnectTimeout: time.Duration(profile.ConnectTimeout) * time.Second,
|
||||||
|
OnEvent: func(event ircclient.Event) { m.emitIRCEvent(sess.Info.ID, event) },
|
||||||
|
OnError: func(err error) { m.markSessionError(sess, err) },
|
||||||
|
OnClose: func() { m.handleSessionClosed(sess.Info.ID) },
|
||||||
|
SSHTunnel: ircConf.SSHTunnel,
|
||||||
|
SSHHost: ircConf.SSHHost,
|
||||||
|
SSHPort: ircConf.SSHPort,
|
||||||
|
SSHUsername: ircConf.SSHUsername,
|
||||||
|
SSHAuthMethod: ircConf.SSHAuthMethod,
|
||||||
|
SSHAgentAuth: ircConf.SSHAgentAuth,
|
||||||
|
SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.User == "" {
|
||||||
|
cfg.User = ircConf.Nick
|
||||||
|
}
|
||||||
|
|
||||||
|
if ircConf.SSHTunnel {
|
||||||
|
switch ircConf.SSHAuthMethod {
|
||||||
|
case "password":
|
||||||
|
getPass := m.buildSecretCallback(profile.ID, "irc-ssh-password", ircConf.SSHSecretMode, getSecret)
|
||||||
|
password, err := getPass()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("IRC SSH password: %w", err)
|
||||||
|
}
|
||||||
|
cfg.SSHPassword = password
|
||||||
|
case "publickey":
|
||||||
|
if ircConf.SSHPrivateKey != "" {
|
||||||
|
info, err := os.Stat(ircConf.SSHPrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading IRC SSH key metadata: %w", err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return fmt.Errorf("IRC SSH key path is a directory")
|
||||||
|
}
|
||||||
|
if info.Size() > maxTunnelPrivateKeyBytes {
|
||||||
|
return fmt.Errorf("IRC SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
|
||||||
|
}
|
||||||
|
keyData, err := os.ReadFile(ircConf.SSHPrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading IRC SSH key: %w", err)
|
||||||
|
}
|
||||||
|
cfg.SSHPrivateKey = keyData
|
||||||
|
getPassphrase := m.buildSecretCallback(profile.ID, "irc-ssh-passphrase", ircConf.SSHSecretMode, getSecret)
|
||||||
|
passphrase, _ := getPassphrase()
|
||||||
|
cfg.SSHPassphrase = passphrase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ircConf.SSHAgentAuth {
|
||||||
|
socketPath := os.Getenv("SSH_AUTH_SOCK")
|
||||||
|
if socketPath != "" {
|
||||||
|
agentConn, err := net.Dial("unix", socketPath)
|
||||||
|
if err == nil {
|
||||||
|
cfg.SSHAgentConn = agentConn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ircclient.New(cfg)
|
||||||
|
if err := client.Connect(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.ircClient = client
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) {
|
||||||
|
m.emitEvent("session:irc:event", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"event": event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) markSessionError(sess *Session, err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.Info.State = models.SessionError
|
||||||
|
sess.Info.Error = err.Error()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
|
"sessionID": sess.Info.ID,
|
||||||
|
"state": string(models.SessionError),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
|
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
|
||||||
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
|
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -1728,6 +1933,10 @@ func (m *Manager) closeSession(sess *Session) {
|
|||||||
sess.vncClient.Close()
|
sess.vncClient.Close()
|
||||||
sess.vncClient = nil
|
sess.vncClient = nil
|
||||||
}
|
}
|
||||||
|
if sess.ircClient != nil {
|
||||||
|
sess.ircClient.Close()
|
||||||
|
sess.ircClient = nil
|
||||||
|
}
|
||||||
if sess.localClient != nil {
|
if sess.localClient != nil {
|
||||||
sess.localClient.Close()
|
sess.localClient.Close()
|
||||||
sess.localClient = nil
|
sess.localClient = nil
|
||||||
@@ -1879,6 +2088,10 @@ func (m *Manager) shouldAutoReconnect(sess *Session) bool {
|
|||||||
if sess.profile.RAW != nil {
|
if sess.profile.RAW != nil {
|
||||||
return sess.profile.RAW.AutoReconnect
|
return sess.profile.RAW.AutoReconnect
|
||||||
}
|
}
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
if sess.profile.IRC != nil {
|
||||||
|
return sess.profile.IRC.AutoReconnect
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1895,6 +2108,14 @@ func (m *Manager) maxReconnectAttempts(sess *Session) int {
|
|||||||
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
|
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
|
||||||
maxTries = sess.profile.Telnet.MaxReconnectTries
|
maxTries = sess.profile.Telnet.MaxReconnectTries
|
||||||
}
|
}
|
||||||
|
case models.ProtocolRAW:
|
||||||
|
if sess.profile.RAW != nil {
|
||||||
|
maxTries = 5
|
||||||
|
}
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
if sess.profile.IRC != nil && sess.profile.IRC.MaxReconnectTries > 0 {
|
||||||
|
maxTries = sess.profile.IRC.MaxReconnectTries
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return maxTries
|
return maxTries
|
||||||
}
|
}
|
||||||
@@ -1959,6 +2180,7 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
|||||||
sess.sshClient = nil
|
sess.sshClient = nil
|
||||||
sess.telnetClient = nil
|
sess.telnetClient = nil
|
||||||
sess.rawClient = nil
|
sess.rawClient = nil
|
||||||
|
sess.ircClient = nil
|
||||||
sess.sftpClient = nil
|
sess.sftpClient = nil
|
||||||
sess.Info.State = models.SessionConnecting
|
sess.Info.State = models.SessionConnecting
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -1976,6 +2198,8 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
|||||||
err = m.connectTelnet(sess)
|
err = m.connectTelnet(sess)
|
||||||
case models.ProtocolRAW:
|
case models.ProtocolRAW:
|
||||||
err = m.connectRAW(sess)
|
err = m.connectRAW(sess)
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
err = m.connectIRC(sess, m.buildInteractiveSecretCallback(sessionID))
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
|
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- Migration 005: Add IRC protocol support
|
||||||
|
-- Adds the 'irc' value to the protocol CHECK constraint and an irc_config column.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
protocol TEXT NOT NULL CHECK(
|
||||||
|
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc', 'irc')
|
||||||
|
),
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
username TEXT DEFAULT '',
|
||||||
|
tags TEXT DEFAULT '[]',
|
||||||
|
folder TEXT DEFAULT '',
|
||||||
|
environment TEXT DEFAULT '',
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
favourite INTEGER DEFAULT 0,
|
||||||
|
last_connected TEXT,
|
||||||
|
connect_timeout INTEGER DEFAULT 30,
|
||||||
|
read_timeout INTEGER DEFAULT 0,
|
||||||
|
terminal_font_family TEXT DEFAULT '',
|
||||||
|
terminal_font_size INTEGER DEFAULT 14,
|
||||||
|
terminal_scrollback INTEGER DEFAULT 10000,
|
||||||
|
terminal_cursor_style TEXT DEFAULT 'block',
|
||||||
|
ssh_config TEXT,
|
||||||
|
telnet_config TEXT,
|
||||||
|
raw_config TEXT,
|
||||||
|
github_config TEXT,
|
||||||
|
vnc_config TEXT,
|
||||||
|
irc_config TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT
|
||||||
|
OR IGNORE INTO profiles_new (
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
protocol,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
tags,
|
||||||
|
folder,
|
||||||
|
environment,
|
||||||
|
notes,
|
||||||
|
favourite,
|
||||||
|
last_connected,
|
||||||
|
connect_timeout,
|
||||||
|
read_timeout,
|
||||||
|
terminal_font_family,
|
||||||
|
terminal_font_size,
|
||||||
|
terminal_scrollback,
|
||||||
|
terminal_cursor_style,
|
||||||
|
ssh_config,
|
||||||
|
telnet_config,
|
||||||
|
raw_config,
|
||||||
|
github_config,
|
||||||
|
vnc_config,
|
||||||
|
irc_config,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
protocol,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
tags,
|
||||||
|
folder,
|
||||||
|
environment,
|
||||||
|
notes,
|
||||||
|
favourite,
|
||||||
|
last_connected,
|
||||||
|
connect_timeout,
|
||||||
|
read_timeout,
|
||||||
|
terminal_font_family,
|
||||||
|
terminal_font_size,
|
||||||
|
terminal_scrollback,
|
||||||
|
terminal_cursor_style,
|
||||||
|
ssh_config,
|
||||||
|
telnet_config,
|
||||||
|
raw_config,
|
||||||
|
github_config,
|
||||||
|
vnc_config,
|
||||||
|
NULL,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
profiles;
|
||||||
|
|
||||||
|
DROP TABLE profiles;
|
||||||
|
|
||||||
|
ALTER TABLE
|
||||||
|
profiles_new RENAME TO profiles;
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
schema_version (version, applied_at)
|
||||||
|
VALUES
|
||||||
|
(5, datetime('now'));
|
||||||
+26
-14
@@ -111,15 +111,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
|
|||||||
rawJSON, _ := json.Marshal(p.RAW)
|
rawJSON, _ := json.Marshal(p.RAW)
|
||||||
githubJSON, _ := json.Marshal(p.GitHub)
|
githubJSON, _ := json.Marshal(p.GitHub)
|
||||||
vncJSON, _ := json.Marshal(p.VNC)
|
vncJSON, _ := json.Marshal(p.VNC)
|
||||||
|
ircJSON, _ := json.Marshal(p.IRC)
|
||||||
|
|
||||||
_, err := s.db.Exec(`INSERT INTO profiles (
|
_, err := s.db.Exec(`INSERT INTO profiles (
|
||||||
id, display_name, description, protocol, host, port, username,
|
id, display_name, description, protocol, host, port, username,
|
||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
||||||
p.Host, p.Port, p.Username,
|
p.Host, p.Port, p.Username,
|
||||||
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
||||||
@@ -127,7 +128,7 @@ func (s *Store) CreateProfile(p *models.Profile) error {
|
|||||||
p.ConnectTimeout, p.ReadTimeout,
|
p.ConnectTimeout, p.ReadTimeout,
|
||||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||||
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
|
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON),
|
||||||
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
|
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,7 +144,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE id = ?`, id)
|
FROM profiles WHERE id = ?`, id)
|
||||||
|
|
||||||
@@ -165,13 +166,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
|||||||
rawJSON, _ := json.Marshal(p.RAW)
|
rawJSON, _ := json.Marshal(p.RAW)
|
||||||
githubJSON, _ := json.Marshal(p.GitHub)
|
githubJSON, _ := json.Marshal(p.GitHub)
|
||||||
vncJSON, _ := json.Marshal(p.VNC)
|
vncJSON, _ := json.Marshal(p.VNC)
|
||||||
|
ircJSON, _ := json.Marshal(p.IRC)
|
||||||
|
|
||||||
res, err := s.db.Exec(`UPDATE profiles SET
|
res, err := s.db.Exec(`UPDATE profiles SET
|
||||||
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
||||||
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
||||||
connect_timeout=?, read_timeout=?,
|
connect_timeout=?, read_timeout=?,
|
||||||
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
||||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?,
|
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
p.DisplayName, p.Description, string(p.Protocol),
|
p.DisplayName, p.Description, string(p.Protocol),
|
||||||
@@ -181,7 +183,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
|||||||
p.ConnectTimeout, p.ReadTimeout,
|
p.ConnectTimeout, p.ReadTimeout,
|
||||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||||
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
|
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON),
|
||||||
p.UpdatedAt.Format(time.RFC3339),
|
p.UpdatedAt.Format(time.RFC3339),
|
||||||
p.ID,
|
p.ID,
|
||||||
)
|
)
|
||||||
@@ -215,7 +217,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles ORDER BY display_name`)
|
FROM profiles ORDER BY display_name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -234,7 +236,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles
|
FROM profiles
|
||||||
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
|
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
|
||||||
@@ -253,7 +255,7 @@ func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, erro
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -271,7 +273,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -288,7 +290,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,7 +307,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -579,6 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
|||||||
var (
|
var (
|
||||||
protocol, tagsJSON string
|
protocol, tagsJSON string
|
||||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||||
|
ircJSON sql.NullString
|
||||||
lastConn sql.NullString
|
lastConn sql.NullString
|
||||||
favourite int
|
favourite int
|
||||||
createdAt, updatedAt string
|
createdAt, updatedAt string
|
||||||
@@ -591,7 +594,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
|||||||
&favourite, &lastConn,
|
&favourite, &lastConn,
|
||||||
&p.ConnectTimeout, &p.ReadTimeout,
|
&p.ConnectTimeout, &p.ReadTimeout,
|
||||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
|
||||||
&createdAt, &updatedAt,
|
&createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -634,6 +637,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
|||||||
p.VNC = &models.VNCConfig{}
|
p.VNC = &models.VNCConfig{}
|
||||||
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
||||||
}
|
}
|
||||||
|
if ircJSON.Valid && ircJSON.String != "" {
|
||||||
|
p.IRC = &models.IRCConfig{}
|
||||||
|
_ = json.Unmarshal([]byte(ircJSON.String), p.IRC)
|
||||||
|
}
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
@@ -646,6 +653,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
|||||||
var (
|
var (
|
||||||
protocol, tagsJSON string
|
protocol, tagsJSON string
|
||||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||||
|
ircJSON sql.NullString
|
||||||
lastConn sql.NullString
|
lastConn sql.NullString
|
||||||
favourite int
|
favourite int
|
||||||
createdAt, updatedAt string
|
createdAt, updatedAt string
|
||||||
@@ -658,7 +666,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
|||||||
&favourite, &lastConn,
|
&favourite, &lastConn,
|
||||||
&p.ConnectTimeout, &p.ReadTimeout,
|
&p.ConnectTimeout, &p.ReadTimeout,
|
||||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
|
||||||
&createdAt, &updatedAt,
|
&createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -701,6 +709,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
|||||||
p.VNC = &models.VNCConfig{}
|
p.VNC = &models.VNCConfig{}
|
||||||
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
||||||
}
|
}
|
||||||
|
if ircJSON.Valid && ircJSON.String != "" {
|
||||||
|
p.IRC = &models.IRCConfig{}
|
||||||
|
_ = json.Unmarshal([]byte(ircJSON.String), p.IRC)
|
||||||
|
}
|
||||||
|
|
||||||
result = append(result, p)
|
result = append(result, p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/andydixon/nexus/internal/models"
|
"github.com/andydixon/nexus/internal/models"
|
||||||
|
"github.com/lrstanley/girc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidationError describes a single field-level validation failure.
|
// ValidationError describes a single field-level validation failure.
|
||||||
@@ -111,6 +112,15 @@ func ValidateProfile(p *models.Profile) error {
|
|||||||
errs.Errors = append(errs.Errors, ve.Errors...)
|
errs.Errors = append(errs.Errors, ve.Errors...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
validateProfileHostPort(errs, p.Host, p.Port)
|
||||||
|
if p.IRC == nil {
|
||||||
|
errs.Add("irc", "IRC configuration is required for IRC protocol")
|
||||||
|
} else if err := ValidateIRCConfig(p.IRC); err != nil {
|
||||||
|
if ve, ok := err.(*ValidationErrors); ok {
|
||||||
|
errs.Errors = append(errs.Errors, ve.Errors...)
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
||||||
}
|
}
|
||||||
@@ -403,6 +413,101 @@ func ValidateVNCConfig(profileHost string, cfg *models.VNCConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateIRCConfig validates IRC connection settings.
|
||||||
|
func ValidateIRCConfig(cfg *models.IRCConfig) error {
|
||||||
|
errs := &ValidationErrors{}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
return &ValidationError{Field: "irc", Message: "IRC configuration must not be nil"}
|
||||||
|
}
|
||||||
|
|
||||||
|
nick := strings.TrimSpace(cfg.Nick)
|
||||||
|
if nick == "" {
|
||||||
|
errs.Add("irc.nick", "nickname is required")
|
||||||
|
} else if !girc.IsValidNick(nick) {
|
||||||
|
errs.Add("irc.nick", "nickname is not valid for IRC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ContainsAny(cfg.RealName, "\r\n\x00") {
|
||||||
|
errs.Add("irc.realName", "real name must not contain control characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidSecretMode(cfg.ServerPasswordMode) {
|
||||||
|
errs.Add("irc.serverPasswordMode", fmt.Sprintf("unsupported secret mode %q", cfg.ServerPasswordMode))
|
||||||
|
}
|
||||||
|
if !isValidSecretMode(cfg.NickServMode) {
|
||||||
|
errs.Add("irc.nickServMode", fmt.Sprintf("unsupported secret mode %q", cfg.NickServMode))
|
||||||
|
}
|
||||||
|
if cfg.NickServIdentify && !isValidNickServAccount(cfg.NickServAccount) {
|
||||||
|
errs.Add("irc.nickServAccount", "NickServ account must not contain control characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
seenRooms := map[string]bool{}
|
||||||
|
for i, room := range cfg.AutoJoinRooms {
|
||||||
|
field := fmt.Sprintf("irc.autoJoinRooms[%d]", i)
|
||||||
|
name := strings.TrimSpace(room)
|
||||||
|
if name == "" {
|
||||||
|
errs.Add(field, "room name must not be empty")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !girc.IsValidChannel(name) {
|
||||||
|
errs.Add(field, "room name must start with a valid IRC channel prefix")
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if seenRooms[key] {
|
||||||
|
errs.Add(field, "room is listed more than once")
|
||||||
|
}
|
||||||
|
seenRooms[key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SSHTunnel {
|
||||||
|
if err := ValidateHost(cfg.SSHHost); err != nil {
|
||||||
|
errs.Add("irc.sshHost", err.(*ValidationError).Message)
|
||||||
|
}
|
||||||
|
if err := ValidatePort(cfg.SSHPort); err != nil {
|
||||||
|
errs.Add("irc.sshPort", err.(*ValidationError).Message)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.SSHUsername) == "" {
|
||||||
|
errs.Add("irc.sshUsername", "SSH username is required when tunnelling")
|
||||||
|
} else if err := ValidateUsername(cfg.SSHUsername); err != nil {
|
||||||
|
errs.Add("irc.sshUsername", err.(*ValidationError).Message)
|
||||||
|
}
|
||||||
|
if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" {
|
||||||
|
errs.Add("irc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod))
|
||||||
|
}
|
||||||
|
if !isValidSecretMode(cfg.SSHSecretMode) {
|
||||||
|
errs.Add("irc.sshSecretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SSHSecretMode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxReconnectTries < 0 {
|
||||||
|
errs.Add("irc.maxReconnectTries", "max reconnect attempts must not be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs.HasErrors() {
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidSecretMode(mode models.SecretMode) bool {
|
||||||
|
switch mode {
|
||||||
|
case models.SecretModeSaved, models.SecretModeSessionOnly, models.SecretModeAskEveryTime:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidNickServAccount(account string) bool {
|
||||||
|
for _, r := range account {
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func isLoopbackHost(host string) bool {
|
func isLoopbackHost(host string) bool {
|
||||||
h := strings.ToLower(strings.TrimSpace(host))
|
h := strings.ToLower(strings.TrimSpace(host))
|
||||||
if h == "localhost" {
|
if h == "localhost" {
|
||||||
|
|||||||
Reference in New Issue
Block a user