feat: complete Nexus Terminal — premium SSH/Telnet/RAW TCP desktop client

Production-minded Wails v2 desktop application with:
- Full SSH client (password, key, keyboard-interactive, jump hosts, keepalive)
- Telnet client (RFC 854/855 negotiation, NAWS, TTYPE, encoding)
- RAW TCP client (binary-safe, text/hex/split views, payload presets)
- SQLite storage with migrations and repository abstraction
- Encrypted secret storage (AES-256-GCM + Argon2id) with keychain fallback
- Known hosts verification (strict/ask/accept-new/insecure)
- Session manager with multi-session lifecycle and Wails events
- Dark charcoal Material-inspired design system (CSS custom properties)
- Tabbed session management with rename, reorder, pin, context menu
- Profile editor with protocol-aware form sections
- Command palette, notifications, settings modal
- JSON import/export, audit logging, preferences
- 50+ tests across validation, secrets, storage, encoding, sessions
- Makefile, build.sh, .gitignore, comprehensive README
This commit is contained in:
2026-04-15 00:00:00 +01:00
parent c767c1640e
commit 5bff1d9f3e
17 changed files with 5644 additions and 45 deletions
+8 -5
View File
@@ -14,8 +14,11 @@ import (
) )
func main() { func main() {
app := application.NewApp() nexus := application.NewApp()
// The API service is created during Startup once storage is initialised.
// We bind the App itself so Wails can call lifecycle hooks, and use
// OnStartup to wire everything, then bind the API service separately.
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "Nexus Terminal", Title: "Nexus Terminal",
Width: 1400, Width: 1400,
@@ -26,11 +29,11 @@ func main() {
Assets: frontend.Assets, Assets: frontend.Assets,
}, },
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255}, // #1a1a2e BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255}, // #1a1a2e
OnStartup: app.Startup, OnStartup: nexus.Startup,
OnShutdown: app.Shutdown, OnShutdown: nexus.Shutdown,
OnDomReady: app.DomReady, OnDomReady: nexus.DomReady,
Bind: []interface{}{ Bind: []interface{}{
app, nexus.APIService(),
}, },
}) })
if err != nil { if err != nil {
+487
View File
@@ -0,0 +1,487 @@
/**
* Nexus Terminal — main application entry point.
* Initialises the application shell, loads profiles, and sets up event listeners.
*/
import { State } from './state.js';
import { Sidebar } from './sidebar.js';
import { TabManager } from './tabs.js';
import { TerminalManager } from './terminal.js';
import { ProfileEditor } from './profiles.js';
import { RawViewer } from './raw.js';
import { Notifications } from './notifications.js';
import { Settings } from './settings.js';
import { CommandPalette } from './commands.js';
import { SecretPrompt } from './secrets.js';
import { stateLabel } from './utils.js';
class App {
constructor() {
this.state = new State();
this.notifications = new Notifications();
this.sidebar = new Sidebar(this);
this.tabs = new TabManager(this);
this.terminals = new TerminalManager(this);
this.profileEditor = new ProfileEditor(this);
this.rawViewer = new RawViewer(this);
this.settings = new Settings(this);
this.commandPalette = new CommandPalette(this);
this.secretPrompt = new SecretPrompt(this);
}
/**
* Bootstraps the application after the DOM is ready.
*/
async init() {
await this.loadProfiles();
await this.loadPreferences();
await this.checkSecretStore();
this.setupEvents();
this.setupKeyboard();
this.setupUI();
this.updateStatusBar();
}
// ── Data loading ─────────────────────────────────────────────────
/**
* Fetches all connection profiles from the backend and renders the sidebar.
*/
async loadProfiles() {
try {
const profiles = await window.go.api.Service.ListProfiles();
this.state.setProfiles(profiles || []);
this.sidebar.render();
} catch (err) {
this.notifications.error('Failed to load profiles: ' + err);
}
}
/**
* Loads user preferences from the backend.
*/
async loadPreferences() {
try {
const prefs = await window.go.api.Service.GetPreferences();
this.state.setPreferences(prefs || {});
this._applyPreferences();
} catch (err) {
// Non-fatal — fall back to defaults
console.warn('Failed to load preferences:', err);
}
}
/**
* Queries the secret store status (initialised / unlocked).
*/
async checkSecretStore() {
try {
const status = await window.go.api.Service.SecretStoreStatus();
this.state.setSecretStoreInitialised(status?.initialised || false);
this.state.setSecretStoreState(status?.unlocked || false);
} catch (_) {
// Secret store may not be available yet
}
}
// ── Wails event listeners ────────────────────────────────────────
/**
* Registers listeners for backend events emitted via the Wails runtime.
*/
setupEvents() {
// Incoming session data (terminal output)
window.runtime.EventsOn('session:data', (payload) => {
this.terminals.handleData(payload.sessionID, payload.data);
});
// Session state transitions (connecting → connected → disconnected)
window.runtime.EventsOn('session:state', (payload) => {
this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error);
this.updateStatusBar();
});
// Session closed by backend
window.runtime.EventsOn('session:closed', (payload) => {
this.tabs.handleSessionClosed(payload.sessionID);
this.updateStatusBar();
});
// RAW TCP data (separate channel for the raw viewer)
window.runtime.EventsOn('session:raw:data', (payload) => {
this.rawViewer.handleData(payload);
});
// Telnet negotiation debug messages
window.runtime.EventsOn('session:debug', (payload) => {
console.debug('[session:debug]', payload);
});
// Backend ready event (emitted from DomReady)
window.runtime.EventsOn('backend:ready', () => {
console.log('Backend is ready');
});
// Host key verification requests
window.runtime.EventsOn('session:hostkey:verify', async (payload) => {
try {
const accepted = await this.secretPrompt.promptHostKeyVerification(
payload.host,
payload.keyType,
payload.fingerprint,
);
window.runtime.EventsEmit('session:hostkey:response', {
sessionID: payload.sessionID,
accepted,
});
} catch (_) {
window.runtime.EventsEmit('session:hostkey:response', {
sessionID: payload.sessionID,
accepted: false,
});
}
});
// Password/passphrase prompt requests from the backend
window.runtime.EventsOn('session:secret:prompt', async (payload) => {
try {
const value = await this.secretPrompt.prompt(
payload.title || 'Credentials Required',
payload.message || 'Enter your password or passphrase.',
);
window.runtime.EventsEmit('session:secret:response', {
sessionID: payload.sessionID,
value,
});
} catch (_) {
window.runtime.EventsEmit('session:secret:response', {
sessionID: payload.sessionID,
value: '',
cancelled: true,
});
}
});
}
// ── Keyboard shortcuts ───────────────────────────────────────────
/**
* Registers global keyboard shortcuts.
*/
setupKeyboard() {
document.addEventListener('keydown', (e) => {
const mod = e.ctrlKey || e.metaKey;
// Ctrl/Cmd+K — command palette
if (mod && e.key === 'k') {
e.preventDefault();
this.commandPalette.toggle();
return;
}
// Ctrl/Cmd+N — new connection
if (mod && e.key === 'n') {
e.preventDefault();
this.profileEditor.openNew();
return;
}
// Ctrl/Cmd+W — close active tab
if (mod && e.key === 'w') {
e.preventDefault();
this.tabs.closeActive();
return;
}
// Ctrl/Cmd+Tab — next tab
if (mod && e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
const next = this.tabs.getNextTab();
if (next) this.tabs.setActive(next);
return;
}
// Ctrl/Cmd+Shift+Tab — previous tab
if (mod && e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
const prev = this.tabs.getPrevTab();
if (prev) this.tabs.setActive(prev);
return;
}
// Ctrl/Cmd+1-9 — switch to tab by index
if (mod && e.key >= '1' && e.key <= '9') {
e.preventDefault();
const idx = parseInt(e.key, 10) - 1;
const order = this.tabs.getTabOrder();
if (idx < order.length) {
this.tabs.setActive(order[idx]);
}
return;
}
// Ctrl/Cmd+, — settings
if (mod && e.key === ',') {
e.preventDefault();
this.settings.open();
return;
}
// Ctrl/Cmd+F — find in terminal
if (mod && e.key === 'f') {
e.preventDefault();
this._openTerminalSearch();
return;
}
// Escape — close any open overlays
if (e.key === 'Escape') {
this.commandPalette.close();
}
});
}
// ── UI event wiring ──────────────────────────────────────────────
/**
* Binds click handlers to static UI elements.
*/
setupUI() {
this._bindClick('btn-new-connection', () => this.profileEditor.openNew());
this._bindClick('btn-settings', () => this.settings.open());
this._bindClick('btn-import', () => this.importProfiles());
this._bindClick('btn-new-tab', () => this.profileEditor.openNew());
this._bindClick('btn-empty-connect', () => this.profileEditor.openNew());
this._bindClick('btn-empty-quick', () => this.quickConnect());
const searchInput = document.getElementById('profile-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => this.sidebar.search(e.target.value));
}
// Window resize → re-fit active terminal
window.addEventListener('resize', () => this.terminals.fitActive());
}
// ── Connection lifecycle ─────────────────────────────────────────
/**
* Opens a new session for the given profile ID.
* @param {string} profileId
*/
async connectProfile(profileId) {
const profile = this.state.getProfile(profileId);
if (!profile) {
this.notifications.error('Profile not found');
return;
}
try {
const sessionInfo = await window.go.api.Service.Connect(profileId);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
// Create the appropriate viewer
const panelId = `panel-${sessionInfo.id}`;
if (profile.protocol === 'raw') {
this.rawViewer.createViewer(sessionInfo.id, panelId, profile);
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
}
this.notifications.info(`Connecting to ${profile.displayName || profile.host}…`);
} catch (err) {
this.notifications.error('Connection failed: ' + err);
}
}
/**
* Prompts the user for a host/port and opens a quick connection.
*/
async quickConnect() {
const input = prompt('Quick connect (host[:port]):');
if (!input || !input.trim()) return;
const parts = input.trim().split(':');
const host = parts[0];
const port = parts[1] ? parseInt(parts[1], 10) : 22;
const protocol = this.state.preferences.defaultProtocol || 'ssh';
try {
const sessionInfo = await window.go.api.Service.QuickConnect(protocol, host, port);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
const panelId = `panel-${sessionInfo.id}`;
if (protocol === 'raw') {
this.rawViewer.createViewer(sessionInfo.id, panelId, { protocol, raw: {} });
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, { protocol });
}
} catch (err) {
this.notifications.error('Quick connect failed: ' + err);
}
}
// ── Import / Export ──────────────────────────────────────────────
/**
* Triggers a profile import dialog.
*/
async importProfiles() {
try {
const result = await window.go.api.Service.ImportProfilesDialog();
if (result && result.count > 0) {
await this.loadProfiles();
this.notifications.success(`Imported ${result.count} profile(s)`);
}
} catch (err) {
if (String(err) !== 'cancelled') {
this.notifications.error('Import failed: ' + err);
}
}
}
/**
* Triggers a profile export dialog.
*/
async exportProfiles() {
try {
await window.go.api.Service.ExportProfilesDialog();
this.notifications.success('Profiles exported');
} catch (err) {
if (String(err) !== 'cancelled') {
this.notifications.error('Export failed: ' + err);
}
}
}
// ── Status bar ───────────────────────────────────────────────────
/**
* Updates the status bar with current session info.
*/
updateStatusBar() {
const bar = document.getElementById('status-bar');
if (!bar) return;
const sessions = this.state.getAllSessions();
const connected = sessions.filter((s) => s.state === 'connected').length;
const total = sessions.length;
const activeSession = this.state.activeSessionId
? this.state.getSession(this.state.activeSessionId)
: null;
// Left: active session info
const left = bar.querySelector('.status-left') || bar;
if (activeSession) {
const profile = this.state.getProfile(activeSession.profileId);
left.textContent = [
activeSession.protocol?.toUpperCase(),
profile ? `${profile.host}:${profile.port}` : '',
stateLabel(activeSession.state),
].filter(Boolean).join(' — ');
} else {
left.textContent = 'No active session';
}
// Right: session count
const right = bar.querySelector('.status-right');
if (right) {
right.textContent = `${connected}/${total} connected`;
}
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Applies loaded preferences to the UI.
* @private
*/
_applyPreferences() {
const prefs = this.state.preferences;
// Sidebar width
const sidebar = document.getElementById('sidebar');
if (sidebar && prefs.sidebarWidth) {
sidebar.style.width = `${prefs.sidebarWidth}px`;
}
// Status bar visibility
const statusBar = document.getElementById('status-bar');
if (statusBar) {
statusBar.style.display = prefs.showStatusBar === false ? 'none' : 'flex';
}
}
/**
* Opens an inline search bar for the active terminal.
* @private
*/
_openTerminalSearch() {
const sessionId = this.state.activeSessionId;
if (!sessionId) return;
// Check if search bar already exists
const panel = document.getElementById(`panel-${sessionId}`);
if (!panel || panel.querySelector('.terminal-search-bar')) return;
const bar = document.createElement('div');
bar.className = 'terminal-search-bar';
bar.innerHTML = `
<input type="text" class="form-input terminal-search-input" placeholder="Find…" />
<button class="btn btn-xs btn-outline terminal-search-prev">▲</button>
<button class="btn btn-xs btn-outline terminal-search-next">▼</button>
<button class="btn btn-xs btn-outline terminal-search-close">✕</button>
`;
const input = bar.querySelector('.terminal-search-input');
const closeBtn = bar.querySelector('.terminal-search-close');
const prevBtn = bar.querySelector('.terminal-search-prev');
const nextBtn = bar.querySelector('.terminal-search-next');
const doSearch = () => this.terminals.search(sessionId, input.value);
const doPrev = () => this.terminals.searchPrevious(sessionId, input.value);
input.addEventListener('input', doSearch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.shiftKey ? doPrev() : doSearch();
}
if (e.key === 'Escape') {
bar.remove();
this.terminals.focusTerminal(sessionId);
}
});
nextBtn.addEventListener('click', doSearch);
prevBtn.addEventListener('click', doPrev);
closeBtn.addEventListener('click', () => {
bar.remove();
this.terminals.focusTerminal(sessionId);
});
panel.prepend(bar);
input.focus();
}
/**
* Safely binds a click handler to an element by ID (no-op if the element does not exist).
* @param {string} id - Element ID.
* @param {Function} handler
* @private
*/
_bindClick(id, handler) {
const el = document.getElementById(id);
if (el) el.addEventListener('click', handler);
}
}
// ── Bootstrap ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const app = new App();
app.init().catch((err) => console.error('App initialisation failed:', err));
});
+381
View File
@@ -0,0 +1,381 @@
/**
* Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
import { createElement, escapeHtml, fuzzyMatch, $ } from './utils.js';
export class CommandPalette {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Current filter query. */
this._query = '';
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
}
// ── Public API ───────────────────────────────────────────────────
/**
* Toggles the command palette open/closed.
*/
toggle() {
if (this._overlay) {
this.close();
} else {
this.open();
}
}
/**
* Opens the command palette.
*/
open() {
if (this._overlay) return;
this._query = '';
this._selectedIndex = 0;
this._render();
}
/**
* Closes the command palette.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
// ── Command definitions ──────────────────────────────────────────
/**
* Returns the full list of available commands.
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
*/
getCommands() {
const cmds = [];
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: 'Connections',
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// New connections
cmds.push({
id: 'new-ssh',
label: 'New SSH Connection',
section: 'Actions',
shortcut: '⌘N',
action: () => this.app.profileEditor.openNew('ssh'),
});
cmds.push({
id: 'new-telnet',
label: 'New Telnet Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('telnet'),
});
cmds.push({
id: 'new-raw',
label: 'New RAW Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('raw'),
});
// Quick connect
cmds.push({
id: 'quick-connect',
label: 'Quick Connect…',
section: 'Actions',
action: () => this.app.quickConnect(),
});
// Tab actions
cmds.push({
id: 'close-tab',
label: 'Close Tab',
section: 'Tabs',
shortcut: '⌘W',
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: 'next-tab',
label: 'Next Tab',
section: 'Tabs',
shortcut: '⌘Tab',
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: 'prev-tab',
label: 'Previous Tab',
section: 'Tabs',
shortcut: '⌘⇧Tab',
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Settings & utility
cmds.push({
id: 'settings',
label: 'Settings',
section: 'Application',
action: () => this.app.settings.open(),
});
cmds.push({
id: 'import-profiles',
label: 'Import Profiles',
section: 'Application',
action: () => this.app.importProfiles(),
});
cmds.push({
id: 'export-profiles',
label: 'Export Profiles',
section: 'Application',
action: () => this.app.exportProfiles(),
});
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'lock-secrets',
label: 'Lock Secret Store',
section: 'Security',
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
} catch (err) {
this.app.notifications.error('Failed to lock: ' + err);
}
},
});
} else {
cmds.push({
id: 'unlock-secrets',
label: 'Unlock Secret Store',
section: 'Security',
action: async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
}
}
},
});
}
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: 'clear-terminal',
label: 'Clear Terminal',
section: 'Terminal',
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: 'reset-terminal',
label: 'Reset Terminal',
section: 'Terminal',
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
return cmds;
}
/**
* Returns commands filtered and sorted by the current query.
* @param {string} query
* @returns {Array<Object>}
*/
filter(query) {
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
}
/**
* Executes a command and closes the palette.
* @param {Object} command
*/
execute(command) {
this.close();
if (command && typeof command.action === 'function') {
command.action();
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
const overlay = createElement('div', 'palette-overlay');
const palette = createElement('div', 'palette');
// Search input
const input = createElement('input', 'palette-input', {
type: 'text',
placeholder: 'Type a command…',
autocomplete: 'off',
spellcheck: 'false',
});
palette.appendChild(input);
// Results list
const list = createElement('div', 'palette-list');
palette.appendChild(list);
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
// Initial render
this._renderList(list, this.filter(''));
// Focus the input
input.focus();
// Event handlers
input.addEventListener('input', () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
input.addEventListener('keydown', (e) => {
const items = list.querySelectorAll('.palette-item');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
this._highlightItem(items);
break;
case 'ArrowUp':
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case 'Enter': {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
break;
}
case 'Escape':
e.preventDefault();
this.close();
break;
}
});
// Click outside to close
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this.close();
});
}
/**
* Renders the filtered command list.
* @param {HTMLElement} list
* @param {Array<Object>} commands
* @private
*/
_renderList(list, commands) {
list.innerHTML = '';
let currentSection = null;
if (commands.length === 0) {
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
list.appendChild(empty);
return;
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
list.appendChild(sectionEl);
}
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
item._command = cmd;
// Label
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
item.appendChild(label);
// Detail
if (cmd.detail) {
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
item.appendChild(badge);
}
item.addEventListener('click', () => this.execute(cmd));
item.addEventListener('mouseenter', () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll('.palette-item'));
});
list.appendChild(item);
}
}
/**
* Highlights the currently selected item and scrolls it into view.
* @param {NodeListOf<Element>} items
* @private
*/
_highlightItem(items) {
items.forEach((el, i) => {
el.classList.toggle('palette-item-active', i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: 'nearest' });
}
}
}
+898
View File
@@ -0,0 +1,898 @@
/**
* Nexus Terminal — connection profile editor.
* Renders a slide-out drawer for creating and editing connection profiles.
* Dynamically switches form sections based on the selected protocol.
*/
import { createElement, escapeHtml, generateId, $ } from './utils.js';
/**
* Default values mirroring the Go backend defaults.
*/
const DEFAULTS = {
ssh: {
port: 22,
protocol: 'ssh',
ssh: {
authMethods: ['publickey', 'password', 'keyboard-interactive'],
secretMode: 'ask-every-time',
keySecretMode: 'ask-every-time',
hostKeyPolicy: 'ask',
terminalType: 'xterm-256color',
keepaliveInterval: 30,
keepaliveCountMax: 3,
compression: false,
jumpHosts: [],
startupCommand: '',
envVars: {},
},
},
telnet: {
port: 23,
protocol: 'telnet',
telnet: {
localEcho: false,
lineEnding: 'crlf',
terminalType: 'xterm-256color',
encoding: 'utf-8',
negotiationDebug: false,
lineMode: false,
},
},
raw: {
port: 23,
protocol: 'raw',
raw: {
autoReconnect: false,
lineEnding: 'crlf',
encoding: 'utf-8',
showTimestamps: false,
showDirection: false,
payloadPresets: [],
},
},
};
export class ProfileEditor {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement|null} */
this.drawer = null;
/** @type {Object|null} The profile currently being edited. */
this._profile = null;
/** @type {boolean} */
this._isNew = false;
}
// ── Public API ───────────────────────────────────────────────────
/**
* Opens the editor with a blank profile of the given protocol.
* @param {string} [protocol='ssh']
*/
openNew(protocol = 'ssh') {
const profile = this._defaultProfile(protocol);
this._isNew = true;
this._profile = profile;
this.render(profile, true);
}
/**
* Opens the editor pre-populated with an existing profile.
* @param {string} profileId
*/
openEdit(profileId) {
const profile = this.app.state.getProfile(profileId);
if (!profile) {
this.app.notifications.error('Profile not found');
return;
}
this._isNew = false;
this._profile = JSON.parse(JSON.stringify(profile)); // deep clone
this.render(this._profile, false);
}
/**
* Renders (or re-renders) the editor drawer.
* @param {Object} profile
* @param {boolean} isNew
*/
render(profile, isNew) {
this.close();
const overlay = createElement('div', 'drawer-overlay');
const drawer = createElement('div', 'drawer');
// ── Header ──────────────────────────────────────────────
const header = createElement('div', 'drawer-header');
header.innerHTML = `
<h2 class="drawer-title">${isNew ? 'New Connection' : 'Edit Connection'}</h2>
<button class="drawer-close" aria-label="Close">&times;</button>
`;
drawer.appendChild(header);
// ── Protocol selector tabs ──────────────────────────────
const protocols = ['ssh', 'telnet', 'raw'];
const tabBar = createElement('div', 'drawer-protocol-tabs');
for (const proto of protocols) {
const btn = createElement('button', `drawer-proto-tab ${profile.protocol === proto ? 'active' : ''}`, {
textContent: proto.toUpperCase(),
dataset: { proto },
});
btn.addEventListener('click', () => this.handleProtocolChange(proto));
tabBar.appendChild(btn);
}
drawer.appendChild(tabBar);
// ── Scrollable body ─────────────────────────────────────
const body = createElement('div', 'drawer-body');
// General section (common to all protocols)
body.appendChild(this._renderGeneralSection(profile));
// Protocol-specific sections
switch (profile.protocol) {
case 'ssh':
this._renderSSHSections(profile).forEach((s) => body.appendChild(s));
break;
case 'telnet':
this._renderTelnetSections(profile).forEach((s) => body.appendChild(s));
break;
case 'raw':
this._renderRAWSections(profile).forEach((s) => body.appendChild(s));
break;
}
// Terminal section (SSH and Telnet only)
if (profile.protocol !== 'raw') {
body.appendChild(this._renderTerminalSection(profile));
}
// Notes & Tags section (common)
body.appendChild(this._renderNotesSection(profile));
drawer.appendChild(body);
// ── Footer ──────────────────────────────────────────────
const footer = createElement('div', 'drawer-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test Connection' });
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
cancelBtn.addEventListener('click', () => this.close());
testBtn.addEventListener('click', () => this._testConnection());
saveBtn.addEventListener('click', () => this._handleSave());
footer.appendChild(cancelBtn);
footer.appendChild(testBtn);
footer.appendChild(saveBtn);
drawer.appendChild(footer);
// ── Events ──────────────────────────────────────────────
header.querySelector('.drawer-close').addEventListener('click', () => this.close());
overlay.addEventListener('click', () => this.close());
overlay.appendChild(drawer);
document.body.appendChild(overlay);
this.drawer = overlay;
// Prevent overlay click from reaching through the drawer
drawer.addEventListener('click', (e) => e.stopPropagation());
// Focus the first input
const firstInput = drawer.querySelector('input, select, textarea');
if (firstInput) firstInput.focus();
}
/**
* Closes the editor drawer.
*/
close() {
if (this.drawer) {
this.drawer.remove();
this.drawer = null;
}
}
/**
* Handles protocol change from the tab bar.
* @param {string} protocol
*/
handleProtocolChange(protocol) {
if (this._profile.protocol === protocol) return;
// Preserve common fields, reset protocol-specific ones
this._collectFormValues();
const base = this._defaultProfile(protocol);
base.id = this._profile.id;
base.displayName = this._profile.displayName;
base.description = this._profile.description;
base.host = this._profile.host;
base.username = this._profile.username;
base.tags = this._profile.tags;
base.notes = this._profile.notes;
base.favourite = this._profile.favourite;
this._profile = base;
this.render(this._profile, this._isNew);
}
// ── Section renderers ────────────────────────────────────────────
/** @private */
_renderGeneralSection(profile) {
return this._renderSection('General', [
this._field('text', 'displayName', 'Display Name', profile.displayName, { placeholder: 'My Server' }),
this._field('text', 'host', 'Host', profile.host, { placeholder: '192.168.1.1 or example.com', required: true }),
this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true }),
this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' }),
this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' }),
]);
}
/** @private */
_renderSSHSections(profile) {
const ssh = profile.ssh || {};
const sections = [];
// Authentication
sections.push(this._renderSection('Authentication', [
this._field('select', 'ssh.secretMode', 'Password Mode', ssh.secretMode, {
options: [
{ value: 'saved', label: 'Saved in vault' },
{ value: 'session-only', label: 'Session only' },
{ value: 'ask-every-time', label: 'Ask every time' },
],
}),
this._field('tags', 'ssh.authMethods', 'Auth Methods', (ssh.authMethods || []).join(', '), {
help: 'Comma-separated: publickey, password, keyboard-interactive',
}),
]));
// Key & Passphrase
sections.push(this._renderSection('Key & Passphrase', [
this._field('text', 'ssh.privateKeyPath', 'Private Key Path', ssh.privateKeyPath, {
placeholder: '~/.ssh/id_ed25519',
}),
this._field('select', 'ssh.keySecretMode', 'Passphrase Mode', ssh.keySecretMode, {
options: [
{ value: 'saved', label: 'Saved in vault' },
{ value: 'session-only', label: 'Session only' },
{ value: 'ask-every-time', label: 'Ask every time' },
],
}),
]));
// Host Verification
sections.push(this._renderSection('Host Verification', [
this._field('select', 'ssh.hostKeyPolicy', 'Host Key Policy', ssh.hostKeyPolicy, {
options: [
{ value: 'strict', label: 'Strict (reject unknown)' },
{ value: 'ask', label: 'Ask (prompt on unknown)' },
{ value: 'accept-new', label: 'Accept new keys' },
{ value: 'insecure', label: 'Insecure (accept all)' },
],
}),
]));
// Jump Hosts
sections.push(this._renderSection('Jump Hosts', [
this._renderJumpHostsList(ssh.jumpHosts || []),
], true));
// Startup
sections.push(this._renderSection('Startup', [
this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, {
placeholder: 'Optional command to run after connecting',
}),
this._field('textarea', 'ssh.envVarsText', 'Environment Variables', this._envVarsToText(ssh.envVars), {
placeholder: 'KEY=VALUE (one per line)',
rows: 3,
}),
]));
// Advanced
sections.push(this._renderSection('Advanced', [
this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }),
this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }),
this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }),
this._field('toggle', 'ssh.compression', 'Compression', ssh.compression),
], true));
return sections;
}
/** @private */
_renderTelnetSections(profile) {
const tel = profile.telnet || {};
const sections = [];
sections.push(this._renderSection('Login', [
this._field('toggle', 'telnet.localEcho', 'Local Echo', tel.localEcho),
this._field('toggle', 'telnet.lineMode', 'Line Mode', tel.lineMode),
]));
sections.push(this._renderSection('Terminal & Encoding', [
this._field('text', 'telnet.terminalType', 'Terminal Type', tel.terminalType, { placeholder: 'xterm-256color' }),
this._field('select', 'telnet.encoding', 'Encoding', tel.encoding, {
options: [
{ value: 'utf-8', label: 'UTF-8' },
{ value: 'ascii', label: 'ASCII' },
{ value: 'iso-8859-1', label: 'ISO 8859-1' },
],
}),
]));
sections.push(this._renderSection('Echo & Line Endings', [
this._field('select', 'telnet.lineEnding', 'Line Ending', tel.lineEnding, {
options: [
{ value: 'crlf', label: 'CR+LF (\\r\\n)' },
{ value: 'lf', label: 'LF (\\n)' },
{ value: 'cr', label: 'CR (\\r)' },
{ value: 'none', label: 'None' },
],
}),
]));
sections.push(this._renderSection('Negotiation', [
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
], true));
return sections;
}
/** @private */
_renderRAWSections(profile) {
const raw = profile.raw || {};
const sections = [];
sections.push(this._renderSection('Encoding & Framing', [
this._field('select', 'raw.encoding', 'Encoding', raw.encoding, {
options: [
{ value: 'utf-8', label: 'UTF-8' },
{ value: 'ascii', label: 'ASCII' },
{ value: 'hex', label: 'Hex' },
],
}),
this._field('select', 'raw.lineEnding', 'Line Ending', raw.lineEnding, {
options: [
{ value: 'crlf', label: 'CR+LF (\\r\\n)' },
{ value: 'lf', label: 'LF (\\n)' },
{ value: 'cr', label: 'CR (\\r)' },
{ value: 'none', label: 'None' },
],
}),
]));
sections.push(this._renderSection('Display', [
this._field('toggle', 'raw.showTimestamps', 'Show Timestamps', raw.showTimestamps),
this._field('toggle', 'raw.showDirection', 'Show Direction', raw.showDirection),
]));
sections.push(this._renderSection('Payload Presets', [
this._renderPayloadPresetsList(raw.payloadPresets || []),
], true));
sections.push(this._renderSection('Timeouts & Reconnect', [
this._field('number', 'connectTimeout', 'Connect Timeout (s)', profile.connectTimeout, { min: 0 }),
this._field('number', 'readTimeout', 'Read Timeout (s)', profile.readTimeout, { min: 0 }),
this._field('toggle', 'raw.autoReconnect', 'Auto-reconnect', raw.autoReconnect),
]));
return sections;
}
/** @private */
_renderTerminalSection(profile) {
return this._renderSection('Terminal', [
this._field('text', 'terminalFontFamily', 'Font Family', profile.terminalFontFamily, {
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
}),
this._field('number', 'terminalFontSize', 'Font Size', profile.terminalFontSize, { min: 8, max: 36 }),
this._field('number', 'terminalScrollback', 'Scrollback Lines', profile.terminalScrollback, { min: 0, max: 100000 }),
this._field('select', 'terminalCursorStyle', 'Cursor Style', profile.terminalCursorStyle, {
options: [
{ value: 'block', label: 'Block' },
{ value: 'underline', label: 'Underline' },
{ value: 'bar', label: 'Bar' },
],
}),
]);
}
/** @private */
_renderNotesSection(profile) {
return this._renderSection('Notes & Tags', [
this._field('tags', 'tags', 'Tags', (profile.tags || []).join(', '), {
placeholder: 'production, london, vpn',
help: 'Comma-separated tags for filtering',
}),
this._field('textarea', 'notes', 'Notes', profile.notes, {
placeholder: 'Free-form notes…',
rows: 4,
}),
]);
}
// ── Form helpers ─────────────────────────────────────────────────
/**
* Renders a collapsible form section.
* @param {string} title
* @param {HTMLElement[]} fields
* @param {boolean} [startCollapsed=false]
* @returns {HTMLElement}
* @private
*/
_renderSection(title, fields, startCollapsed = false) {
const section = createElement('div', 'form-section');
const header = createElement('div', 'form-section-header');
let collapsed = startCollapsed;
header.innerHTML = `
<span class="form-section-chevron">${collapsed ? '▸' : '▾'}</span>
<span class="form-section-title">${escapeHtml(title)}</span>
`;
const content = createElement('div', 'form-section-content');
if (collapsed) content.style.display = 'none';
for (const field of fields) {
if (field instanceof HTMLElement) {
content.appendChild(field);
}
}
header.addEventListener('click', () => {
collapsed = !collapsed;
content.style.display = collapsed ? 'none' : 'block';
header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
});
section.appendChild(header);
section.appendChild(content);
return section;
}
/**
* Renders a single form field.
* @param {string} type - text | number | select | toggle | textarea | tags | file
* @param {string} name - Dot-notated field name, e.g. "ssh.secretMode".
* @param {string} label
* @param {*} value
* @param {Object} [opts]
* @returns {HTMLElement}
* @private
*/
_field(type, name, label, value, opts = {}) {
const group = createElement('div', 'form-group');
// Label
const labelEl = createElement('label', 'form-label', {
textContent: label,
for: `field-${name}`,
});
group.appendChild(labelEl);
// Input element based on type
let input;
switch (type) {
case 'text':
case 'file':
input = createElement('input', 'form-input', {
type: type === 'file' ? 'text' : 'text',
id: `field-${name}`,
name,
value: value || '',
placeholder: opts.placeholder || '',
});
if (opts.required) input.required = true;
break;
case 'number':
input = createElement('input', 'form-input', {
type: 'number',
id: `field-${name}`,
name,
value: value != null ? String(value) : '',
});
if (opts.min != null) input.min = opts.min;
if (opts.max != null) input.max = opts.max;
if (opts.required) input.required = true;
break;
case 'select': {
input = createElement('select', 'form-select', {
id: `field-${name}`,
name,
});
for (const opt of (opts.options || [])) {
const option = createElement('option', '', {
value: opt.value,
textContent: opt.label,
});
if (opt.value === value) option.selected = true;
input.appendChild(option);
}
break;
}
case 'toggle': {
const wrapper = createElement('div', 'form-toggle-wrapper');
input = createElement('input', 'form-toggle', {
type: 'checkbox',
id: `field-${name}`,
name,
});
input.checked = !!value;
const slider = createElement('label', 'form-toggle-label', { for: `field-${name}` });
wrapper.appendChild(input);
wrapper.appendChild(slider);
group.appendChild(wrapper);
// Help text
if (opts.help) {
group.appendChild(createElement('span', 'form-hint', { textContent: opts.help }));
}
return group;
}
case 'textarea':
input = createElement('textarea', 'form-textarea', {
id: `field-${name}`,
name,
placeholder: opts.placeholder || '',
});
input.rows = opts.rows || 3;
input.textContent = value || '';
break;
case 'tags':
input = createElement('input', 'form-input', {
type: 'text',
id: `field-${name}`,
name,
value: value || '',
placeholder: opts.placeholder || '',
});
break;
default:
input = createElement('input', 'form-input', {
type: 'text',
id: `field-${name}`,
name,
value: value || '',
});
}
group.appendChild(input);
// Help text
if (opts.help) {
group.appendChild(createElement('span', 'form-hint', { textContent: opts.help }));
}
return group;
}
/**
* Renders the jump-hosts list editor.
* @param {Array<Object>} jumpHosts
* @returns {HTMLElement}
* @private
*/
_renderJumpHostsList(jumpHosts) {
const wrapper = createElement('div', 'jump-hosts-list');
for (let i = 0; i < jumpHosts.length; i++) {
const jh = jumpHosts[i];
const row = createElement('div', 'jump-host-row');
row.innerHTML = `
<input class="form-input form-input-sm" name="jumpHost-${i}-host" value="${escapeHtml(jh.host || '')}" placeholder="Host" />
<input class="form-input form-input-sm" name="jumpHost-${i}-port" type="number" value="${jh.port || 22}" min="1" max="65535" style="width:80px" />
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || '')}" placeholder="User" />
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">&times;</button>
`;
row.querySelector('.jump-host-remove').addEventListener('click', () => {
jumpHosts.splice(i, 1);
this.render(this._profile, this._isNew);
});
wrapper.appendChild(row);
}
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Jump Host' });
addBtn.addEventListener('click', () => {
jumpHosts.push({ host: '', port: 22, username: '', authMethod: 'publickey', secretMode: 'ask-every-time', privateKeyPath: '' });
this.render(this._profile, this._isNew);
});
wrapper.appendChild(addBtn);
return wrapper;
}
/**
* Renders the payload-presets list editor.
* @param {Array<Object>} presets
* @returns {HTMLElement}
* @private
*/
_renderPayloadPresetsList(presets) {
const wrapper = createElement('div', 'payload-presets-list');
for (let i = 0; i < presets.length; i++) {
const ps = presets[i];
const row = createElement('div', 'preset-row');
row.innerHTML = `
<input class="form-input form-input-sm" name="preset-${i}-name" value="${escapeHtml(ps.name || '')}" placeholder="Name" />
<input class="form-input form-input-sm" name="preset-${i}-payload" value="${escapeHtml(ps.payload || '')}" placeholder="Payload" />
<select class="form-select form-select-sm" name="preset-${i}-format">
<option value="text" ${ps.format === 'text' ? 'selected' : ''}>Text</option>
<option value="hex" ${ps.format === 'hex' ? 'selected' : ''}>Hex</option>
<option value="escaped" ${ps.format === 'escaped' ? 'selected' : ''}>Escaped</option>
</select>
<button class="btn btn-icon btn-danger preset-remove" data-index="${i}" title="Remove">&times;</button>
`;
row.querySelector('.preset-remove').addEventListener('click', () => {
presets.splice(i, 1);
this.render(this._profile, this._isNew);
});
wrapper.appendChild(row);
}
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Preset' });
addBtn.addEventListener('click', () => {
presets.push({ id: generateId(), name: '', payload: '', format: 'text' });
this.render(this._profile, this._isNew);
});
wrapper.appendChild(addBtn);
return wrapper;
}
// ── Form data collection ─────────────────────────────────────────
/**
* Reads current form values into this._profile.
* @private
*/
_collectFormValues() {
if (!this.drawer) return;
const val = (name) => {
const el = this.drawer.querySelector(`[name="${name}"]`);
if (!el) return undefined;
if (el.type === 'checkbox') return el.checked;
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
return el.value;
};
const p = this._profile;
// General
p.displayName = val('displayName') || '';
p.host = val('host') || '';
p.port = val('port') || p.port;
p.username = val('username') || '';
p.description = val('description') || '';
p.terminalFontFamily = val('terminalFontFamily') || '';
p.terminalFontSize = val('terminalFontSize') || 14;
p.terminalScrollback = val('terminalScrollback') || 10000;
p.terminalCursorStyle = val('terminalCursorStyle') || 'block';
p.connectTimeout = val('connectTimeout') || 30;
p.readTimeout = val('readTimeout') || 0;
// Tags
const tagsStr = val('tags');
if (tagsStr !== undefined) {
p.tags = tagsStr.split(',').map((t) => t.trim()).filter(Boolean);
}
// Notes
const notes = val('notes');
if (notes !== undefined) p.notes = notes;
// SSH-specific
if (p.protocol === 'ssh' && p.ssh) {
p.ssh.secretMode = val('ssh.secretMode') || p.ssh.secretMode;
p.ssh.privateKeyPath = val('ssh.privateKeyPath') || '';
p.ssh.keySecretMode = val('ssh.keySecretMode') || p.ssh.keySecretMode;
p.ssh.hostKeyPolicy = val('ssh.hostKeyPolicy') || p.ssh.hostKeyPolicy;
p.ssh.terminalType = val('ssh.terminalType') || 'xterm-256color';
p.ssh.keepaliveInterval = val('ssh.keepaliveInterval') ?? 30;
p.ssh.keepaliveCountMax = val('ssh.keepaliveCountMax') ?? 3;
p.ssh.compression = val('ssh.compression') || false;
p.ssh.startupCommand = val('ssh.startupCommand') || '';
const authStr = val('ssh.authMethods');
if (authStr !== undefined) {
p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean);
}
// Env vars from textarea
const envText = val('ssh.envVarsText');
if (envText !== undefined) {
p.ssh.envVars = {};
envText.split('\n').forEach((line) => {
const eq = line.indexOf('=');
if (eq > 0) {
p.ssh.envVars[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
});
}
// Jump hosts
const jh = [];
let ji = 0;
while (this.drawer.querySelector(`[name="jumpHost-${ji}-host"]`)) {
jh.push({
host: val(`jumpHost-${ji}-host`) || '',
port: val(`jumpHost-${ji}-port`) || 22,
username: val(`jumpHost-${ji}-username`) || '',
authMethod: 'publickey',
secretMode: 'ask-every-time',
privateKeyPath: '',
});
ji++;
}
p.ssh.jumpHosts = jh;
}
// Telnet-specific
if (p.protocol === 'telnet' && p.telnet) {
p.telnet.localEcho = val('telnet.localEcho') || false;
p.telnet.lineMode = val('telnet.lineMode') || false;
p.telnet.terminalType = val('telnet.terminalType') || 'xterm-256color';
p.telnet.encoding = val('telnet.encoding') || 'utf-8';
p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf';
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
}
// RAW-specific
if (p.protocol === 'raw' && p.raw) {
p.raw.encoding = val('raw.encoding') || 'utf-8';
p.raw.lineEnding = val('raw.lineEnding') || 'crlf';
p.raw.showTimestamps = val('raw.showTimestamps') || false;
p.raw.showDirection = val('raw.showDirection') || false;
p.raw.autoReconnect = val('raw.autoReconnect') || false;
// Presets
const presets = [];
let pi = 0;
while (this.drawer.querySelector(`[name="preset-${pi}-name"]`)) {
presets.push({
id: (p.raw.payloadPresets && p.raw.payloadPresets[pi]?.id) || generateId(),
name: val(`preset-${pi}-name`) || '',
payload: val(`preset-${pi}-payload`) || '',
format: val(`preset-${pi}-format`) || 'text',
});
pi++;
}
p.raw.payloadPresets = presets;
}
}
// ── Validation ───────────────────────────────────────────────────
/**
* Validates the collected profile. Returns an array of error strings (empty = valid).
* @param {Object} profile
* @returns {string[]}
* @private
*/
_validate(profile) {
const errors = [];
if (!profile.host || !profile.host.trim()) {
errors.push('Host is required.');
}
if (!profile.port || profile.port < 1 || profile.port > 65535) {
errors.push('Port must be between 1 and 65535.');
}
return errors;
}
// ── Actions ──────────────────────────────────────────────────────
/** @private */
async _handleSave() {
this._collectFormValues();
const errors = this._validate(this._profile);
if (errors.length > 0) {
this.app.notifications.warning(errors.join(' '));
return;
}
try {
if (this._isNew) {
const saved = await window.go.api.Service.CreateProfile(this._profile);
this.app.state.addProfile(saved);
this.app.notifications.success('Profile created');
} else {
const saved = await window.go.api.Service.UpdateProfile(this._profile);
this.app.state.updateProfile(saved);
this.app.notifications.success('Profile updated');
}
this.app.sidebar.render();
this.close();
} catch (err) {
this.app.notifications.error('Save failed: ' + err);
}
}
/** @private */
async _testConnection() {
this._collectFormValues();
const errors = this._validate(this._profile);
if (errors.length > 0) {
this.app.notifications.warning(errors.join(' '));
return;
}
this.app.notifications.info('Testing connection…');
try {
const result = await window.go.api.Service.TestConnection(this._profile);
if (result && result.success) {
this.app.notifications.success('Connection successful!');
} else {
this.app.notifications.error('Connection failed: ' + (result?.error || 'Unknown error'));
}
} catch (err) {
this.app.notifications.error('Test failed: ' + err);
}
}
// ── Utilities ────────────────────────────────────────────────────
/**
* Returns a default profile object for the given protocol.
* @param {string} protocol
* @returns {Object}
* @private
*/
_defaultProfile(protocol) {
const base = {
id: generateId(),
displayName: '',
description: '',
protocol,
host: '',
port: 0,
username: '',
tags: [],
folder: '',
environment: '',
notes: '',
favourite: false,
lastConnected: null,
connectTimeout: 30,
readTimeout: 0,
terminalFontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
terminalFontSize: 14,
terminalScrollback: 10000,
terminalCursorStyle: 'block',
ssh: null,
telnet: null,
raw: null,
};
const defaults = DEFAULTS[protocol] || {};
return { ...base, ...defaults };
}
/**
* Converts an envVars map into "KEY=VALUE\n" text.
* @param {Object} envVars
* @returns {string}
* @private
*/
_envVarsToText(envVars) {
if (!envVars) return '';
return Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join('\n');
}
}
+564
View File
@@ -0,0 +1,564 @@
/**
* Nexus Terminal — RAW TCP data viewer.
* Provides text, hex, and split display modes for raw socket data,
* along with a send bar, pause/resume, and capture saving.
*/
import { createElement, escapeHtml, formatTimestamp, formatBytes } from './utils.js';
/**
* Maximum number of data entries retained in the scrollback buffer.
* @type {number}
*/
const MAX_BUFFER_ENTRIES = 5000;
export class RawViewer {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/**
* Map of sessionId → viewer state.
* @type {Map<string, Object>}
*/
this.viewers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Creates a RAW viewer inside the given panel container.
* @param {string} sessionId
* @param {string} containerId - DOM id of the panel element.
* @param {Object} profile
*/
createViewer(sessionId, containerId, profile) {
const container = document.getElementById(containerId);
if (!container) return;
const state = {
mode: 'text', // text | hex | split
paused: false,
buffer: [],
totalBytes: 0,
sendFormat: 'text', // text | hex | escaped
};
// ── Toolbar ──────────────────────────────────────────────
const toolbar = createElement('div', 'raw-toolbar');
// Mode toggle
const modeGroup = createElement('div', 'raw-mode-group');
for (const mode of ['text', 'hex', 'split']) {
const btn = createElement('button', `raw-mode-btn ${mode === state.mode ? 'active' : ''}`, {
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
dataset: { mode },
});
btn.addEventListener('click', () => this._setMode(sessionId, mode));
modeGroup.appendChild(btn);
}
toolbar.appendChild(modeGroup);
// Controls
const controls = createElement('div', 'raw-controls');
const pauseBtn = createElement('button', 'btn btn-sm btn-outline raw-pause', { textContent: '⏸ Pause' });
pauseBtn.addEventListener('click', () => this.togglePause(sessionId));
controls.appendChild(pauseBtn);
const clearBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '✕ Clear' });
clearBtn.addEventListener('click', () => this.clearBuffer(sessionId));
controls.appendChild(clearBtn);
const saveBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '💾 Save' });
saveBtn.addEventListener('click', () => this.saveCapture(sessionId));
controls.appendChild(saveBtn);
// Byte counter
const counter = createElement('span', 'raw-counter', { textContent: '0 B' });
controls.appendChild(counter);
toolbar.appendChild(controls);
container.appendChild(toolbar);
// ── Data display ─────────────────────────────────────────
const display = createElement('div', 'raw-display');
container.appendChild(display);
// ── Send bar ─────────────────────────────────────────────
const sendBar = this._renderSendBar(sessionId, profile);
container.appendChild(sendBar);
this.viewers.set(sessionId, {
...state,
container,
display,
toolbar,
pauseBtn,
counter,
profile,
});
}
/**
* Processes incoming data from the backend.
* @param {Object} payload - { sessionID, data, direction, timestamp }
*/
handleData(payload) {
const viewer = this.viewers.get(payload.sessionID);
if (!viewer) return;
// Decode base64 payload
let rawStr;
try {
rawStr = atob(payload.data);
} catch (_) {
rawStr = payload.data || '';
}
const bytes = new Uint8Array(rawStr.length);
for (let i = 0; i < rawStr.length; i++) {
bytes[i] = rawStr.charCodeAt(i);
}
const entry = {
direction: payload.direction || 'recv', // 'send' | 'recv'
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
data: rawStr,
bytes,
};
viewer.buffer.push(entry);
viewer.totalBytes += bytes.length;
// Trim buffer if it exceeds the maximum
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
}
// Update counter
viewer.counter.textContent = formatBytes(viewer.totalBytes);
// Render unless paused
if (!viewer.paused) {
this._renderEntry(viewer, entry);
this._scrollToBottom(viewer);
}
}
/**
* Toggles pause/resume on the data display.
* @param {string} sessionId
*/
togglePause(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.paused = !viewer.paused;
viewer.pauseBtn.textContent = viewer.paused ? '▶ Resume' : '⏸ Pause';
viewer.pauseBtn.classList.toggle('active', viewer.paused);
// On resume, flush any buffered entries
if (!viewer.paused) {
this._renderAllEntries(viewer);
}
}
/**
* Clears the data buffer and display.
* @param {string} sessionId
*/
clearBuffer(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.buffer = [];
viewer.totalBytes = 0;
viewer.display.innerHTML = '';
viewer.counter.textContent = '0 B';
}
/**
* Prompts the user to save the capture buffer.
* @param {string} sessionId
*/
async saveCapture(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer || viewer.buffer.length === 0) {
this.app.notifications.warning('Nothing to save');
return;
}
try {
const lines = viewer.buffer.map((e) => {
const ts = formatTimestamp(e.timestamp);
const dir = e.direction === 'send' ? 'TX' : 'RX';
return `[${ts}] [${dir}] ${e.data}`;
});
const content = lines.join('\n');
await window.go.api.Service.SaveCapture(sessionId, content);
this.app.notifications.success('Capture saved');
} catch (err) {
this.app.notifications.error('Save failed: ' + err);
}
}
/**
* Sends data from the send bar to the backend.
* @param {string} sessionId
*/
async send(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const input = viewer.container.querySelector('.raw-send-input');
const formatSelect = viewer.container.querySelector('.raw-send-format');
if (!input) return;
const rawValue = input.value;
if (!rawValue) return;
const format = formatSelect?.value || 'text';
let dataToSend;
switch (format) {
case 'hex':
dataToSend = this._hexToString(rawValue);
break;
case 'escaped':
dataToSend = this._unescapeString(rawValue);
break;
default:
dataToSend = rawValue;
}
try {
const b64 = btoa(dataToSend);
await window.go.api.Service.WriteSession(sessionId, b64);
input.value = '';
// Also show in viewer as sent data
this.handleData({
sessionID: sessionId,
data: b64,
direction: 'send',
timestamp: new Date().toISOString(),
});
} catch (err) {
this.app.notifications.error('Send failed: ' + err);
}
}
/**
* Tears down a viewer and frees resources.
* @param {string} sessionId
*/
destroyViewer(sessionId) {
this.viewers.delete(sessionId);
}
// ── Rendering ────────────────────────────────────────────────────
/**
* Renders the send bar at the bottom of the viewer.
* @param {string} sessionId
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderSendBar(sessionId, profile) {
const bar = createElement('div', 'raw-send-bar');
// Format selector
const formatSelect = createElement('select', 'form-select form-select-sm raw-send-format');
for (const [value, label] of [['text', 'Text'], ['hex', 'Hex'], ['escaped', 'Escaped']]) {
const opt = createElement('option', '', { value, textContent: label });
formatSelect.appendChild(opt);
}
bar.appendChild(formatSelect);
// Input
const input = createElement('input', 'form-input raw-send-input', {
type: 'text',
placeholder: 'Type data to send…',
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.send(sessionId);
});
bar.appendChild(input);
// Quick-insert buttons for common control bytes
const quickBtns = createElement('div', 'raw-quick-btns');
const controlBytes = [
{ label: 'NUL', value: '\\x00' },
{ label: 'CR', value: '\\r' },
{ label: 'LF', value: '\\n' },
{ label: 'ETX', value: '\\x03' },
{ label: 'EOT', value: '\\x04' },
];
for (const cb of controlBytes) {
const btn = createElement('button', 'btn btn-xs btn-outline', { textContent: cb.label, title: cb.value });
btn.addEventListener('click', () => {
input.value += cb.value;
input.focus();
});
quickBtns.appendChild(btn);
}
bar.appendChild(quickBtns);
// Payload preset dropdown
const presets = profile?.raw?.payloadPresets || [];
if (presets.length > 0) {
const presetSelect = createElement('select', 'form-select form-select-sm raw-preset-select');
const defaultOpt = createElement('option', '', { value: '', textContent: 'Presets…' });
presetSelect.appendChild(defaultOpt);
for (const ps of presets) {
const opt = createElement('option', '', { value: ps.payload, textContent: ps.name });
opt.dataset.format = ps.format;
presetSelect.appendChild(opt);
}
presetSelect.addEventListener('change', () => {
if (presetSelect.value) {
input.value = presetSelect.value;
const selOpt = presetSelect.selectedOptions[0];
if (selOpt?.dataset.format) {
formatSelect.value = selOpt.dataset.format;
}
presetSelect.value = '';
input.focus();
}
});
bar.appendChild(presetSelect);
}
// Send button
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send' });
sendBtn.addEventListener('click', () => this.send(sessionId));
bar.appendChild(sendBtn);
return bar;
}
/**
* Renders a single data entry into the display area.
* @param {Object} viewer
* @param {Object} entry
* @private
*/
_renderEntry(viewer, entry) {
let el;
switch (viewer.mode) {
case 'hex':
el = this._renderHexView(entry);
break;
case 'split':
el = this._renderSplitView(entry, viewer.profile);
break;
default:
el = this._renderTextView(entry, viewer.profile);
}
viewer.display.appendChild(el);
}
/**
* Re-renders all buffered entries (e.g. after mode change or resume).
* @param {Object} viewer
* @private
*/
_renderAllEntries(viewer) {
viewer.display.innerHTML = '';
for (const entry of viewer.buffer) {
this._renderEntry(viewer, entry);
}
this._scrollToBottom(viewer);
}
/**
* Renders a text-mode entry.
* @param {Object} entry
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderTextView(entry, profile) {
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
const raw = profile?.raw || {};
let prefix = '';
if (raw.showTimestamps) {
prefix += `[${formatTimestamp(entry.timestamp)}] `;
}
if (raw.showDirection) {
prefix += entry.direction === 'send' ? '[TX] ' : '[RX] ';
}
const pre = createElement('pre', 'raw-text');
pre.textContent = prefix + entry.data;
row.appendChild(pre);
return row;
}
/**
* Renders a hex-mode entry.
* @param {Object} entry
* @returns {HTMLElement}
* @private
*/
_renderHexView(entry) {
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
const pre = createElement('pre', 'raw-hex');
pre.textContent = this.formatHexDump(entry.bytes);
row.appendChild(pre);
return row;
}
/**
* Renders a split-mode entry (text + hex side by side).
* @param {Object} entry
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderSplitView(entry, profile) {
const row = createElement('div', `raw-entry raw-entry-split raw-entry-${entry.direction}`);
const raw = profile?.raw || {};
// Direction/timestamp header
const header = createElement('div', 'raw-split-header');
let headerText = '';
if (raw.showTimestamps) headerText += formatTimestamp(entry.timestamp) + ' ';
headerText += entry.direction === 'send' ? '▲ TX' : '▼ RX';
header.textContent = headerText;
row.appendChild(header);
// Split panes
const panes = createElement('div', 'raw-split-panes');
const textPane = createElement('pre', 'raw-text raw-split-text');
textPane.textContent = entry.data;
panes.appendChild(textPane);
const hexPane = createElement('pre', 'raw-hex raw-split-hex');
hexPane.textContent = this.formatHexDump(entry.bytes);
panes.appendChild(hexPane);
row.appendChild(panes);
return row;
}
/**
* Formats a Uint8Array into a traditional hex dump:
* offset | hex bytes | ASCII representation.
* @param {Uint8Array} bytes
* @returns {string}
*/
formatHexDump(bytes) {
const lines = [];
const bytesPerLine = 16;
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
const chunk = bytes.slice(offset, offset + bytesPerLine);
// Offset column
const offsetStr = offset.toString(16).padStart(8, '0');
// Hex column
const hexParts = [];
for (let i = 0; i < bytesPerLine; i++) {
if (i < chunk.length) {
hexParts.push(chunk[i].toString(16).padStart(2, '0'));
} else {
hexParts.push(' ');
}
// Extra space after 8th byte for readability
if (i === 7) hexParts.push('');
}
const hexStr = hexParts.join(' ');
// ASCII column
let ascii = '';
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i];
ascii += (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.';
}
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
}
return lines.join('\n');
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Changes the display mode (text, hex, split) and re-renders.
* @param {string} sessionId
* @param {string} mode
* @private
*/
_setMode(sessionId, mode) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.mode = mode;
// Update active button
viewer.toolbar.querySelectorAll('.raw-mode-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
this._renderAllEntries(viewer);
}
/**
* Scrolls the data display to the bottom.
* @param {Object} viewer
* @private
*/
_scrollToBottom(viewer) {
requestAnimationFrame(() => {
viewer.display.scrollTop = viewer.display.scrollHeight;
});
}
/**
* Converts a hex string (e.g. "48656c6c6f") into a character string.
* @param {string} hex
* @returns {string}
* @private
*/
_hexToString(hex) {
const cleaned = hex.replace(/\s+/g, '');
let str = '';
for (let i = 0; i < cleaned.length; i += 2) {
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
}
return str;
}
/**
* Converts C-style escape sequences (\\n, \\r, \\x00, etc.) into their literal bytes.
* @param {string} str
* @returns {string}
* @private
*/
_unescapeString(str) {
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
switch (seq) {
case 'n': return '\n';
case 'r': return '\r';
case 't': return '\t';
case '0': return '\0';
case '\\': return '\\';
default:
// \\xHH
if (seq.startsWith('x')) {
return String.fromCharCode(parseInt(seq.slice(1), 16));
}
return match;
}
});
}
}
+463
View File
@@ -0,0 +1,463 @@
/**
* Nexus Terminal — settings modal.
* Renders a tabbed settings dialog for application-wide preferences,
* terminal defaults, security, known-hosts management, and about info.
*/
import { createElement, escapeHtml, formatTimestamp, $ } from './utils.js';
/** Settings tab definitions. */
const TABS = [
{ key: 'general', label: 'General' },
{ key: 'terminal', label: 'Terminal' },
{ key: 'security', label: 'Security' },
{ key: 'knownhosts', label: 'Known Hosts' },
{ key: 'about', label: 'About' },
];
export class Settings {
/**
* @param {import('./app.js').App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Currently active tab key. */
this._activeTab = 'general';
/** @type {Object} Working copy of preferences. */
this._prefs = {};
/** @type {Array<Object>} Cached known hosts list. */
this._knownHosts = [];
}
// ── Public API ───────────────────────────────────────────────────
/**
* Opens the settings modal.
*/
async open() {
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
this._activeTab = 'general';
try {
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
} catch (_) {
this._knownHosts = [];
}
this._render();
}
/**
* Closes the settings modal.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
/**
* Saves the modified preferences to the backend.
*/
async save() {
this._collectValues();
try {
await window.go.api.Service.SavePreferences(this._prefs);
this.app.state.setPreferences(this._prefs);
this.app.notifications.success('Settings saved');
this.close();
} catch (err) {
this.app.notifications.error('Failed to save settings: ' + err);
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
this.close();
const overlay = createElement('div', 'modal-overlay');
const modal = createElement('div', 'modal modal-settings');
// Header
const header = createElement('div', 'modal-header');
header.innerHTML = `
<h2 class="modal-title">Settings</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
header.querySelector('.modal-close').addEventListener('click', () => this.close());
modal.appendChild(header);
// Tab bar
const tabBar = createElement('div', 'settings-tab-bar');
for (const tab of TABS) {
const btn = createElement('button', `settings-tab ${tab.key === this._activeTab ? 'active' : ''}`, {
textContent: tab.label,
dataset: { tab: tab.key },
});
btn.addEventListener('click', () => {
this._activeTab = tab.key;
this._renderBody(modal);
tabBar.querySelectorAll('.settings-tab').forEach((t) => {
t.classList.toggle('active', t.dataset.tab === tab.key);
});
});
tabBar.appendChild(btn);
}
modal.appendChild(tabBar);
// Body (tab content)
const body = createElement('div', 'modal-body settings-body');
modal.appendChild(body);
this._renderTabContent(body);
// Footer
const footer = createElement('div', 'modal-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const saveBtn = createElement('button', 'btn btn-primary', { textContent: 'Save' });
cancelBtn.addEventListener('click', () => this.close());
saveBtn.addEventListener('click', () => this.save());
footer.appendChild(cancelBtn);
footer.appendChild(saveBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
modal.addEventListener('click', (e) => e.stopPropagation());
document.body.appendChild(overlay);
this._overlay = overlay;
}
/**
* Re-renders only the body when the active tab changes.
* @param {HTMLElement} modal
* @private
*/
_renderBody(modal) {
const body = modal.querySelector('.settings-body');
if (body) this._renderTabContent(body);
}
/**
* Renders the content for the currently active tab.
* @param {HTMLElement} body
* @private
*/
_renderTabContent(body) {
body.innerHTML = '';
switch (this._activeTab) {
case 'general': this._renderGeneralTab(body); break;
case 'terminal': this._renderTerminalTab(body); break;
case 'security': this._renderSecurityTab(body); break;
case 'knownhosts': this._renderKnownHostsTab(body); break;
case 'about': this._renderAboutTab(body); break;
}
}
// ── Tab content renderers ────────────────────────────────────────
/**
* General preferences tab.
* @param {HTMLElement} body
* @private
*/
_renderGeneralTab(body) {
const p = this._prefs;
body.appendChild(this._settingField('select', 'theme', 'Theme', p.theme, {
options: [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
],
}));
body.appendChild(this._settingField('select', 'defaultProtocol', 'Default Protocol', p.defaultProtocol, {
options: [
{ value: 'ssh', label: 'SSH' },
{ value: 'telnet', label: 'Telnet' },
{ value: 'raw', label: 'RAW' },
],
}));
body.appendChild(this._settingField('toggle', 'confirmOnClose', 'Confirm on close', p.confirmOnClose));
body.appendChild(this._settingField('toggle', 'confirmOnDisconnect', 'Confirm on disconnect', p.confirmOnDisconnect));
body.appendChild(this._settingField('toggle', 'showStatusBar', 'Show status bar', p.showStatusBar));
body.appendChild(this._settingField('number', 'sidebarWidth', 'Sidebar width (px)', p.sidebarWidth, { min: 180, max: 500 }));
}
/**
* Terminal defaults tab.
* @param {HTMLElement} body
* @private
*/
_renderTerminalTab(body) {
const p = this._prefs;
body.appendChild(this._settingField('text', 'defaultFontFamily', 'Font family', p.defaultFontFamily, {
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
}));
body.appendChild(this._settingField('number', 'defaultFontSize', 'Font size', p.defaultFontSize, { min: 8, max: 36 }));
body.appendChild(this._settingField('number', 'defaultScrollback', 'Scrollback lines', p.defaultScrollback, { min: 0, max: 100000 }));
body.appendChild(this._settingField('select', 'defaultCursorStyle', 'Cursor style', p.defaultCursorStyle, {
options: [
{ value: 'block', label: 'Block' },
{ value: 'underline', label: 'Underline' },
{ value: 'bar', label: 'Bar' },
],
}));
}
/**
* Security settings tab.
* @param {HTMLElement} body
* @private
*/
_renderSecurityTab(body) {
const storeState = this.app.state.secretStoreUnlocked ? 'Unlocked' : 'Locked';
const storeInit = this.app.state.secretStoreInitialised;
const statusRow = createElement('div', 'settings-row');
statusRow.innerHTML = `
<span class="settings-label">Secret store</span>
<span class="settings-value ${this.app.state.secretStoreUnlocked ? 'text-success' : 'text-muted'}">${storeState}</span>
`;
body.appendChild(statusRow);
if (!storeInit) {
const initBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Initialise Secret Store' });
initBtn.addEventListener('click', async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(true);
await window.go.api.Service.InitialiseSecretStore(pw);
this.app.state.setSecretStoreInitialised(true);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store created');
this._renderTabContent(body);
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Failed to initialise: ' + err);
}
}
});
body.appendChild(initBtn);
} else if (!this.app.state.secretStoreUnlocked) {
const unlockBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Unlock' });
unlockBtn.addEventListener('click', async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
this._renderTabContent(body);
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
}
}
});
body.appendChild(unlockBtn);
} else {
const lockBtn = createElement('button', 'btn btn-outline btn-sm', { textContent: 'Lock' });
lockBtn.addEventListener('click', async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
this._renderTabContent(body);
} catch (err) {
this.app.notifications.error('Lock failed: ' + err);
}
});
body.appendChild(lockBtn);
}
}
/**
* Known-hosts management tab.
* @param {HTMLElement} body
* @private
*/
_renderKnownHostsTab(body) {
if (this._knownHosts.length === 0) {
body.appendChild(createElement('p', 'text-muted', { textContent: 'No known hosts recorded.' }));
return;
}
const table = createElement('table', 'settings-table');
table.innerHTML = `
<thead>
<tr>
<th>Host</th>
<th>Port</th>
<th>Key Type</th>
<th>Fingerprint</th>
<th>First Seen</th>
<th></th>
</tr>
</thead>
`;
const tbody = createElement('tbody');
for (const kh of this._knownHosts) {
const tr = createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(kh.host)}</td>
<td>${kh.port}</td>
<td><code>${escapeHtml(kh.keyType)}</code></td>
<td class="fingerprint-cell" title="${escapeHtml(kh.fingerprint)}"><code>${escapeHtml(kh.fingerprint.slice(0, 20))}…</code></td>
<td>${formatTimestamp(kh.firstSeen)}</td>
<td></td>
`;
const deleteBtn = createElement('button', 'btn btn-xs btn-danger', { textContent: 'Remove' });
deleteBtn.addEventListener('click', async () => {
try {
await window.go.api.Service.DeleteKnownHost(kh.id);
this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
this._renderTabContent(body);
this.app.notifications.success('Known host removed');
} catch (err) {
this.app.notifications.error('Failed to remove: ' + err);
}
});
tr.querySelector('td:last-child').appendChild(deleteBtn);
tbody.appendChild(tr);
}
table.appendChild(tbody);
body.appendChild(table);
}
/**
* About tab showing application info.
* @param {HTMLElement} body
* @private
*/
_renderAboutTab(body) {
body.innerHTML = `
<div class="about-section">
<h3 class="about-title">Nexus Terminal</h3>
<p class="about-version">Version 1.0.0</p>
<p class="about-desc">A premium desktop SSH, Telnet, and RAW TCP client built with Wails.</p>
<div class="about-links">
<p>AES-256-GCM encrypted credential vault</p>
<p>Platform-native keychain integration</p>
<p>xterm.js terminal emulation</p>
</div>
</div>
`;
}
// ── Form helpers ─────────────────────────────────────────────────
/**
* Renders a single settings form field.
* @param {string} type
* @param {string} name
* @param {string} label
* @param {*} value
* @param {Object} [opts]
* @returns {HTMLElement}
* @private
*/
_settingField(type, name, label, value, opts = {}) {
const group = createElement('div', 'form-group settings-field');
const labelEl = createElement('label', 'form-label', { textContent: label, for: `setting-${name}` });
group.appendChild(labelEl);
let input;
switch (type) {
case 'text':
input = createElement('input', 'form-input', {
type: 'text',
id: `setting-${name}`,
name: `setting-${name}`,
value: value || '',
placeholder: opts.placeholder || '',
});
break;
case 'number':
input = createElement('input', 'form-input', {
type: 'number',
id: `setting-${name}`,
name: `setting-${name}`,
value: value != null ? String(value) : '',
});
if (opts.min != null) input.min = opts.min;
if (opts.max != null) input.max = opts.max;
break;
case 'select':
input = createElement('select', 'form-select', {
id: `setting-${name}`,
name: `setting-${name}`,
});
for (const opt of (opts.options || [])) {
const option = createElement('option', '', { value: opt.value, textContent: opt.label });
if (opt.value === value) option.selected = true;
input.appendChild(option);
}
break;
case 'toggle': {
const wrapper = createElement('div', 'form-toggle-wrapper');
input = createElement('input', 'form-toggle', {
type: 'checkbox',
id: `setting-${name}`,
name: `setting-${name}`,
});
input.checked = !!value;
const slider = createElement('label', 'form-toggle-label', { for: `setting-${name}` });
wrapper.appendChild(input);
wrapper.appendChild(slider);
group.appendChild(wrapper);
return group;
}
default:
input = createElement('input', 'form-input', {
type: 'text',
id: `setting-${name}`,
name: `setting-${name}`,
value: value || '',
});
}
group.appendChild(input);
return group;
}
/**
* Reads current form field values back into the working preferences copy.
* @private
*/
_collectValues() {
if (!this._overlay) return;
const val = (name) => {
const el = this._overlay.querySelector(`[name="setting-${name}"]`);
if (!el) return undefined;
if (el.type === 'checkbox') return el.checked;
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
return el.value;
};
const fields = [
'theme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect',
'showStatusBar', 'sidebarWidth', 'defaultFontFamily', 'defaultFontSize',
'defaultScrollback', 'defaultCursorStyle',
];
for (const field of fields) {
const v = val(field);
if (v !== undefined) {
this._prefs[field] = v;
}
}
}
}
+60 -35
View File
@@ -5,35 +5,21 @@ package app
import ( import (
"context" "context"
"fmt"
"log" "log"
"sync" "sync"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/copilot-ssh-client/app/internal/api"
"github.com/copilot-ssh-client/app/internal/audit"
"github.com/copilot-ssh-client/app/internal/config" "github.com/copilot-ssh-client/app/internal/config"
"github.com/copilot-ssh-client/app/internal/knownhosts"
"github.com/copilot-ssh-client/app/internal/preferences"
"github.com/copilot-ssh-client/app/internal/secrets" "github.com/copilot-ssh-client/app/internal/secrets"
"github.com/copilot-ssh-client/app/internal/sessions"
"github.com/copilot-ssh-client/app/internal/storage"
) )
// Storage abstracts the persistent data layer (SQLite).
type Storage interface {
Close() error
}
// SessionManager tracks active connection sessions.
type SessionManager interface {
CloseAll()
}
// Preferences manages user application preferences.
type Preferences interface{}
// KnownHostStore manages trusted SSH host key records.
type KnownHostStore interface{}
// AuditLog records auditable application events.
type AuditLog interface{}
// App is the central application struct. Wails binds its lifecycle methods // App is the central application struct. Wails binds its lifecycle methods
// (Startup, Shutdown, DomReady) and it holds references to every subsystem. // (Startup, Shutdown, DomReady) and it holds references to every subsystem.
type App struct { type App struct {
@@ -41,18 +27,31 @@ type App struct {
mu sync.Mutex mu sync.Mutex
Config *config.AppConfig Config *config.AppConfig
Storage Storage Store *storage.Store
SecretStore *secrets.SecretStore SecretStore *secrets.SecretStore
SessionManager SessionManager KeychainStore *secrets.KeychainStore
Preferences Preferences SessionManager *sessions.Manager
KnownHostStore KnownHostStore Prefs *preferences.Manager
AuditLog AuditLog KnownHosts *knownhosts.Store
AuditLog *audit.Logger
Service *api.Service
} }
// NewApp creates a new App instance. Subsystems are not initialised until // NewApp creates a new App instance. Subsystems are not initialised until
// Startup is called by the Wails runtime. // Startup is called by the Wails runtime. The API service is pre-created
// so it can be bound by Wails before Startup runs.
func NewApp() *App { func NewApp() *App {
return &App{} a := &App{}
// The API service is created eagerly so Wails can bind it. Its backing
// stores are nil at this point; they are wired during Startup.
a.Service = &api.Service{}
return a
}
// APIService returns the Wails-bound API service instance. This must be
// called before wails.Run so the binding is registered.
func (a *App) APIService() *api.Service {
return a.Service
} }
// Startup is called by Wails when the application starts. It loads // Startup is called by Wails when the application starts. It loads
@@ -64,6 +63,7 @@ func (a *App) Startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
// Load platform-specific configuration paths.
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
log.Fatalf("failed to load configuration: %v", err) log.Fatalf("failed to load configuration: %v", err)
@@ -74,12 +74,37 @@ func (a *App) Startup(ctx context.Context) {
log.Fatalf("failed to create application directories: %v", err) log.Fatalf("failed to create application directories: %v", err)
} }
// Initialise the encrypted secret store. // Initialise SQLite storage (migrations run automatically on open).
a.SecretStore = secrets.NewSecretStore(cfg.SecretPath) store, err := storage.NewStore(cfg.DBPath)
if err != nil {
log.Fatalf("failed to open storage: %v", err)
}
a.Store = store
// Initialise secret stores (keychain preferred, encrypted file fallback).
a.SecretStore = secrets.NewSecretStore(cfg.SecretPath)
a.KeychainStore = secrets.NewKeychainStore("NexusTerminal")
// Initialise the audit logger backed by SQLite.
a.AuditLog = audit.NewLogger(store)
// Initialise known hosts store.
a.KnownHosts = knownhosts.NewStore(store)
// Initialise preferences manager.
a.Prefs = preferences.NewManager(store)
if err := a.Prefs.Load(); err != nil {
log.Printf("warning: failed to load preferences, using defaults: %v", err)
}
// Initialise the session manager.
a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog)
a.SessionManager.SetContext(ctx)
// Wire the API service with all backing stores now that they exist.
*a.Service = *api.NewService(store, a.SecretStore, a.KeychainStore, a.SessionManager, a.AuditLog, a.Prefs)
a.Service.SetContext(ctx)
// Storage, session manager, preferences, known host store and audit log
// will be wired in when their implementations are available.
// For now we log successful initialisation.
log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir) log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir)
} }
@@ -93,8 +118,8 @@ func (a *App) Shutdown(ctx context.Context) {
a.SessionManager.CloseAll() a.SessionManager.CloseAll()
} }
if a.Storage != nil { if a.Store != nil {
if err := a.Storage.Close(); err != nil { if err := a.Store.Close(); err != nil {
log.Printf("warning: error closing storage: %v", err) log.Printf("warning: error closing storage: %v", err)
} }
} }
@@ -113,5 +138,5 @@ func (a *App) DomReady(ctx context.Context) {
runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{ runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{
"message": "Backend is ready", "message": "Backend is ready",
}) })
fmt.Println("DOM ready – backend:ready event emitted") log.Println("DOM ready – backend:ready event emitted")
} }
+142
View File
@@ -0,0 +1,142 @@
package knownhosts
import (
"testing"
"github.com/copilot-ssh-client/app/internal/models"
)
// ---------------------------------------------------------------------------
// Mock storage implementation
// ---------------------------------------------------------------------------
type mockStorage struct {
hosts map[string]*models.KnownHost // keyed by "host:port"
}
func newMockStorage() *mockStorage {
return &mockStorage{hosts: make(map[string]*models.KnownHost)}
}
func (m *mockStorage) key(host string, port int) string {
return host + ":" + string(rune(port))
}
func (m *mockStorage) GetKnownHost(host string, port int) (*models.KnownHost, error) {
kh, ok := m.hosts[m.key(host, port)]
if !ok {
return nil, nil
}
return kh, nil
}
func (m *mockStorage) SaveKnownHost(kh *models.KnownHost) error {
m.hosts[m.key(kh.Host, kh.Port)] = kh
return nil
}
func (m *mockStorage) ListKnownHosts() ([]*models.KnownHost, error) {
result := make([]*models.KnownHost, 0, len(m.hosts))
for _, kh := range m.hosts {
result = append(result, kh)
}
return result, nil
}
func (m *mockStorage) DeleteKnownHost(id string) error {
for k, kh := range m.hosts {
if kh.ID == id {
delete(m.hosts, k)
return nil
}
}
return nil
}
// ---------------------------------------------------------------------------
// Verify unknown host returns false (no error)
// ---------------------------------------------------------------------------
func TestVerifyUnknownHost(t *testing.T) {
store := NewStore(newMockStorage())
trusted, err := store.Verify("unknown.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey")
if err != nil {
t.Fatalf("Verify() unexpected error = %v", err)
}
if trusted {
t.Error("expected trusted = false for unknown host")
}
}
// ---------------------------------------------------------------------------
// Accept then verify returns true
// ---------------------------------------------------------------------------
func TestAcceptThenVerify(t *testing.T) {
store := NewStore(newMockStorage())
if err := store.Accept("server.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey"); err != nil {
t.Fatalf("Accept() error = %v", err)
}
trusted, err := store.Verify("server.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey")
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
if !trusted {
t.Error("expected trusted = true after Accept()")
}
}
// ---------------------------------------------------------------------------
// Changed fingerprint returns error
// ---------------------------------------------------------------------------
func TestVerifyChangedFingerprint(t *testing.T) {
store := NewStore(newMockStorage())
// Accept with fingerprint A.
if err := store.Accept("host.local", 22, "ssh-rsa", "SHA256:original", "pubkeyA"); err != nil {
t.Fatalf("Accept() error = %v", err)
}
// Verify with fingerprint B — should return an error indicating a change.
trusted, err := store.Verify("host.local", 22, "ssh-rsa", "SHA256:changed", "pubkeyB")
if err == nil {
t.Fatal("expected error for changed fingerprint")
}
if trusted {
t.Error("expected trusted = false for changed fingerprint")
}
}
// ---------------------------------------------------------------------------
// List and Delete operations
// ---------------------------------------------------------------------------
func TestListAndDelete(t *testing.T) {
mock := newMockStorage()
store := NewStore(mock)
_ = store.Accept("host-a.local", 22, "ssh-ed25519", "fp-a", "pk-a")
_ = store.Accept("host-b.local", 22, "ssh-rsa", "fp-b", "pk-b")
entries, err := store.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
// Delete the first entry.
if err := store.Delete(entries[0].ID); err != nil {
t.Fatalf("Delete() error = %v", err)
}
entries, _ = store.List()
if len(entries) != 1 {
t.Errorf("expected 1 entry after delete, got %d", len(entries))
}
}
+229
View File
@@ -0,0 +1,229 @@
package rawclient
import (
"encoding/hex"
"fmt"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding/charmap"
)
// ParseHexString converts a hex string (with optional spaces and 0x prefixes)
// to raw bytes.
//
// Accepted formats:
//
// "48 65 6c 6c 6f"
// "48656c6c6f"
// "0x48 0x65 0x6c 0x6c 0x6f"
func ParseHexString(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, fmt.Errorf("empty hex string")
}
// Strip 0x/0X prefixes.
s = strings.ReplaceAll(s, "0x", "")
s = strings.ReplaceAll(s, "0X", "")
// Collect only hex digit characters.
var cleaned strings.Builder
for _, r := range s {
if unicode.IsSpace(r) {
continue
}
if !isHexDigit(r) {
return nil, fmt.Errorf("invalid character in hex string: %q", r)
}
cleaned.WriteRune(r)
}
if cleaned.Len()%2 != 0 {
return nil, fmt.Errorf("hex string has odd number of digits")
}
return hex.DecodeString(cleaned.String())
}
// FormatHex formats bytes as a space-separated hex string.
// Example output: "48 65 6c 6c 6f"
func FormatHex(data []byte) string {
if len(data) == 0 {
return ""
}
parts := make([]string, len(data))
for i, b := range data {
parts[i] = fmt.Sprintf("%02x", b)
}
return strings.Join(parts, " ")
}
// FormatHexDump formats bytes as a traditional hex dump with offset, hex,
// and ASCII columns. Each line shows 16 bytes.
//
// Example output:
//
// 00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 |Hello World! |
func FormatHexDump(data []byte) string {
if len(data) == 0 {
return ""
}
var sb strings.Builder
for offset := 0; offset < len(data); offset += 16 {
fmt.Fprintf(&sb, "%08x ", offset)
end := offset + 16
if end > len(data) {
end = len(data)
}
chunk := data[offset:end]
// Hex columns (two groups of eight bytes).
for i := 0; i < 16; i++ {
if i == 8 {
sb.WriteByte(' ')
}
if i < len(chunk) {
fmt.Fprintf(&sb, "%02x ", chunk[i])
} else {
sb.WriteString(" ")
}
}
// ASCII column.
sb.WriteString(" |")
for _, b := range chunk {
if b >= 0x20 && b <= 0x7e {
sb.WriteByte(b)
} else {
sb.WriteByte('.')
}
}
for i := len(chunk); i < 16; i++ {
sb.WriteByte(' ')
}
sb.WriteString("|\n")
}
return sb.String()
}
// ParseEscaped processes C-style escape sequences in a string.
// Supported sequences: \r \n \t \\ \0 \xNN
func ParseEscaped(s string) ([]byte, error) {
var result []byte
i := 0
for i < len(s) {
if s[i] != '\\' {
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size <= 1 {
result = append(result, s[i])
i++
} else {
result = append(result, s[i:i+size]...)
i += size
}
continue
}
if i+1 >= len(s) {
return nil, fmt.Errorf("trailing backslash at position %d", i)
}
next := s[i+1]
switch next {
case 'r':
result = append(result, '\r')
i += 2
case 'n':
result = append(result, '\n')
i += 2
case 't':
result = append(result, '\t')
i += 2
case '\\':
result = append(result, '\\')
i += 2
case '0':
result = append(result, 0)
i += 2
case 'x', 'X':
if i+3 >= len(s) {
return nil, fmt.Errorf("incomplete \\x escape at position %d", i)
}
hexStr := s[i+2 : i+4]
b, err := strconv.ParseUint(hexStr, 16, 8)
if err != nil {
return nil, fmt.Errorf("invalid \\x escape at position %d: %s", i, hexStr)
}
result = append(result, byte(b))
i += 4
default:
return nil, fmt.Errorf("unknown escape sequence \\%c at position %d", next, i)
}
}
return result, nil
}
// ToDisplayEncoding converts raw bytes to a display string using the
// specified encoding. Supported encodings: "utf-8" (default), "ascii",
// "iso-8859-1" (also "latin1").
func ToDisplayEncoding(data []byte, encoding string) string {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "ascii":
return toASCII(data)
case "iso-8859-1", "latin1", "latin-1":
return toLatin1(data)
default:
return toUTF8(data)
}
}
// toASCII replaces non-ASCII bytes with '.', preserving common control chars.
func toASCII(data []byte) string {
var sb strings.Builder
sb.Grow(len(data))
for _, b := range data {
if b >= 0x20 && b <= 0x7e {
sb.WriteByte(b)
} else if b == '\n' || b == '\r' || b == '\t' {
sb.WriteByte(b)
} else {
sb.WriteByte('.')
}
}
return sb.String()
}
// toLatin1 decodes bytes as ISO 8859-1.
func toLatin1(data []byte) string {
decoded, err := charmap.ISO8859_1.NewDecoder().Bytes(data)
if err != nil {
return string(data)
}
return string(decoded)
}
// toUTF8 ensures the output is valid UTF-8, replacing invalid bytes with
// the Unicode replacement character.
func toUTF8(data []byte) string {
if utf8.Valid(data) {
return string(data)
}
var sb strings.Builder
sb.Grow(len(data))
for len(data) > 0 {
r, size := utf8.DecodeRune(data)
sb.WriteRune(r)
data = data[size:]
}
return sb.String()
}
// isHexDigit reports whether r is a valid hexadecimal digit.
func isHexDigit(r rune) bool {
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
}
+226
View File
@@ -0,0 +1,226 @@
package rawclient
import (
"bytes"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// ParseHexString
// ---------------------------------------------------------------------------
func TestParseHexString(t *testing.T) {
tests := []struct {
name string
input string
want []byte
wantErr bool
}{
{
name: "contiguous hex for Hello",
input: "48656c6c6f",
want: []byte("Hello"),
},
{
name: "space-separated hex",
input: "48 65 6c 6c 6f",
want: []byte("Hello"),
},
{
name: "0x prefix",
input: "0x48 0x65 0x6c 0x6c 0x6f",
want: []byte("Hello"),
},
{
name: "empty string",
input: "",
wantErr: true,
},
{
name: "invalid characters",
input: "ZZZZ",
wantErr: true,
},
{
name: "null bytes",
input: "0000",
want: []byte{0x00, 0x00},
},
{
name: "0xff bytes",
input: "ff ff",
want: []byte{0xff, 0xff},
},
{
name: "odd number of digits",
input: "abc",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseHexString(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseHexString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr && !bytes.Equal(got, tt.want) {
t.Errorf("ParseHexString(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// ParseEscaped
// ---------------------------------------------------------------------------
func TestParseEscaped(t *testing.T) {
tests := []struct {
name string
input string
want []byte
wantErr bool
}{
{name: "plain text", input: "hello", want: []byte("hello")},
{name: "newline escape", input: `\n`, want: []byte{'\n'}},
{name: "carriage return + newline", input: `\r\n`, want: []byte{'\r', '\n'}},
{name: "tab escape", input: `\t`, want: []byte{'\t'}},
{name: "null byte", input: `\0`, want: []byte{0x00}},
{name: "hex escape 0xff", input: `\xff`, want: []byte{0xff}},
{name: "escaped backslash", input: `\\`, want: []byte{'\\'}},
{name: "mixed AT command", input: `AT\r\n`, want: []byte{'A', 'T', '\r', '\n'}},
{name: "hex escape 0x00", input: `\x00`, want: []byte{0x00}},
{name: "trailing backslash", input: `hello\`, wantErr: true},
{name: "unknown escape", input: `\q`, wantErr: true},
{name: "incomplete hex escape", input: `\x0`, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseEscaped(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseEscaped(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr && !bytes.Equal(got, tt.want) {
t.Errorf("ParseEscaped(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// FormatHexDump
// ---------------------------------------------------------------------------
func TestFormatHexDump(t *testing.T) {
t.Run("empty data", func(t *testing.T) {
got := FormatHexDump(nil)
if got != "" {
t.Errorf("FormatHexDump(nil) = %q, want empty", got)
}
})
t.Run("small data", func(t *testing.T) {
got := FormatHexDump([]byte("Hi"))
if !strings.Contains(got, "48 69") {
t.Errorf("expected hex for 'Hi', got:\n%s", got)
}
// Should contain the ASCII display column.
if !strings.Contains(got, "|Hi") {
t.Errorf("expected ASCII column with 'Hi', got:\n%s", got)
}
})
t.Run("16+ bytes spans multiple lines", func(t *testing.T) {
data := []byte("ABCDEFGHIJKLMNOPQRST") // 20 bytes
got := FormatHexDump(data)
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
if len(lines) < 2 {
t.Errorf("expected at least 2 lines for 20 bytes, got %d", len(lines))
}
// First line should start with offset 00000000.
if !strings.HasPrefix(lines[0], "00000000") {
t.Errorf("first line should start with 00000000, got %q", lines[0])
}
// Second line should start with offset 00000010.
if !strings.HasPrefix(lines[1], "00000010") {
t.Errorf("second line should start with 00000010, got %q", lines[1])
}
})
}
// ---------------------------------------------------------------------------
// FormatHex
// ---------------------------------------------------------------------------
func TestFormatHex(t *testing.T) {
tests := []struct {
name string
input []byte
want string
}{
{name: "empty", input: nil, want: ""},
{name: "single byte", input: []byte{0x42}, want: "42"},
{name: "Hello", input: []byte("Hello"), want: "48 65 6c 6c 6f"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatHex(tt.input)
if got != tt.want {
t.Errorf("FormatHex() = %q, want %q", got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// ToDisplayEncoding
// ---------------------------------------------------------------------------
func TestToDisplayEncoding(t *testing.T) {
tests := []struct {
name string
data []byte
encoding string
contains string
}{
{
name: "utf-8 passthrough",
data: []byte("Hello, World!"),
encoding: "utf-8",
contains: "Hello, World!",
},
{
name: "ascii replaces non-printable",
data: []byte{0x80, 0x41},
encoding: "ascii",
contains: ".A",
},
{
name: "latin-1 decodes high bytes",
data: []byte{0xe9}, // é in ISO 8859-1
encoding: "latin-1",
contains: "é",
},
{
name: "iso-8859-1 alias",
data: []byte{0xe9},
encoding: "iso-8859-1",
contains: "é",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ToDisplayEncoding(tt.data, tt.encoding)
if !strings.Contains(got, tt.contains) {
t.Errorf("ToDisplayEncoding() = %q, expected to contain %q", got, tt.contains)
}
})
}
}
+308
View File
@@ -0,0 +1,308 @@
package rawclient
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"strings"
"sync"
"time"
"github.com/copilot-ssh-client/app/internal/models"
)
// Client manages a raw TCP connection.
// It provides binary-safe data handling with support for text and hex views,
// timestamps, direction markers, and payload presets.
type Client struct {
mu sync.Mutex
config *models.Profile
conn net.Conn
connected bool
onData func(data []byte, direction string, timestamp time.Time)
onError func(err error)
onClose func()
ctx context.Context
cancel context.CancelFunc
history []HistoryEntry
historyMu sync.RWMutex
}
// HistoryEntry records a single sent or received data frame with its timestamp.
type HistoryEntry struct {
Timestamp time.Time `json:"timestamp"`
Direction string `json:"direction"` // "send" or "recv"
Data []byte `json:"data"`
}
// NewClient creates a new raw TCP client bound to the given profile.
func NewClient(profile *models.Profile) *Client {
ctx, cancel := context.WithCancel(context.Background())
return &Client{
config: profile,
ctx: ctx,
cancel: cancel,
}
}
// SetCallbacks sets the data, error, and close callbacks. Must be called
// before Connect.
func (c *Client) SetCallbacks(onData func([]byte, string, time.Time), onError func(error), onClose func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.onData = onData
c.onError = onError
c.onClose = onClose
}
// rawConf returns the RAW-specific configuration, or a zero value if nil.
func (c *Client) rawConf() models.RAWConfig {
if c.config.RAW != nil {
return *c.config.RAW
}
return models.RAWConfig{}
}
// Connect establishes the raw TCP connection and starts the read loop.
func (c *Client) Connect() error {
c.mu.Lock()
if c.connected {
c.mu.Unlock()
return fmt.Errorf("already connected")
}
c.mu.Unlock()
timeout := time.Duration(c.config.ConnectTimeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
port := c.config.Port
if port == 0 {
return fmt.Errorf("port must be specified for raw TCP connections")
}
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("raw TCP connection to %s failed: %w", addr, err)
}
c.mu.Lock()
c.conn = conn
c.connected = true
c.mu.Unlock()
go c.readLoop()
return nil
}
// Write sends raw bytes to the connection and records the frame in history.
func (c *Client) Write(data []byte) error {
c.mu.Lock()
if !c.connected || c.conn == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
conn := c.conn
c.mu.Unlock()
_, err := conn.Write(data)
if err != nil {
return err
}
now := time.Now()
c.appendHistory(HistoryEntry{
Timestamp: now,
Direction: "send",
Data: copyBytes(data),
})
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(copyBytes(data), "send", now)
}
return nil
}
// WriteText sends text with the configured line ending appended.
func (c *Client) WriteText(text string) error {
data := c.applyLineEnding(text)
return c.Write(data)
}
// WriteHex sends hex-encoded bytes (e.g. "48656c6c6f" or "48 65 6c 6c 6f").
func (c *Client) WriteHex(hexStr string) error {
data, err := ParseHexString(hexStr)
if err != nil {
return fmt.Errorf("invalid hex input: %w", err)
}
return c.Write(data)
}
// WriteEscaped sends text with C-style escape sequences processed
// (\r, \n, \t, \x00, etc.).
func (c *Client) WriteEscaped(text string) error {
data, err := ParseEscaped(text)
if err != nil {
return fmt.Errorf("escape processing failed: %w", err)
}
return c.Write(data)
}
// Close shuts down the raw TCP connection and invokes the onClose callback.
func (c *Client) Close() error {
c.mu.Lock()
if !c.connected {
c.mu.Unlock()
return nil
}
c.connected = false
conn := c.conn
onClose := c.onClose
c.mu.Unlock()
c.cancel()
var err error
if conn != nil {
err = conn.Close()
}
if onClose != nil {
onClose()
}
return err
}
// IsConnected returns the current connection state.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// GetHistory returns a copy of the session data history.
func (c *Client) GetHistory() []HistoryEntry {
c.historyMu.RLock()
defer c.historyMu.RUnlock()
out := make([]HistoryEntry, len(c.history))
copy(out, c.history)
return out
}
// ClearHistory clears the session history buffer.
func (c *Client) ClearHistory() {
c.historyMu.Lock()
defer c.historyMu.Unlock()
c.history = nil
}
// SaveCapture writes the session history to a file in JSON format.
func (c *Client) SaveCapture(path string) error {
entries := c.GetHistory()
data, err := json.MarshalIndent(entries, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal capture data: %w", err)
}
return os.WriteFile(path, data, 0600)
}
// readLoop reads from the connection and dispatches received data to the
// onData callback.
func (c *Client) readLoop() {
buf := make([]byte, 4096)
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
conn := c.conn
connected := c.connected
c.mu.Unlock()
if !connected || conn == nil {
return
}
n, err := conn.Read(buf)
if n > 0 {
now := time.Now()
data := copyBytes(buf[:n])
c.appendHistory(HistoryEntry{
Timestamp: now,
Direction: "recv",
Data: data,
})
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(data, "recv", now)
}
}
if err != nil {
c.mu.Lock()
cb := c.onError
c.mu.Unlock()
if cb != nil {
cb(fmt.Errorf("read error: %w", err))
}
c.Close()
return
}
}
}
// appendHistory records a history entry in a thread-safe manner.
func (c *Client) appendHistory(entry HistoryEntry) {
c.historyMu.Lock()
defer c.historyMu.Unlock()
c.history = append(c.history, entry)
}
// applyLineEnding appends the configured line ending to text.
func (c *Client) applyLineEnding(text string) []byte {
rc := c.rawConf()
le := rc.LineEnding
if le == "" {
le = models.LineEndingCRLF
}
var suffix string
switch le {
case models.LineEndingCR:
suffix = "\r"
case models.LineEndingLF:
suffix = "\n"
case models.LineEndingCRLF:
suffix = "\r\n"
case models.LineEndingNone:
// No suffix.
}
if suffix != "" && !strings.HasSuffix(text, suffix) {
text += suffix
}
return []byte(text)
}
// copyBytes returns an independent copy of a byte slice.
func copyBytes(data []byte) []byte {
out := make([]byte, len(data))
copy(out, data)
return out
}
+150
View File
@@ -0,0 +1,150 @@
package sessions
import (
"testing"
"github.com/copilot-ssh-client/app/internal/models"
)
// ---------------------------------------------------------------------------
// NewManager creates with empty sessions map
// ---------------------------------------------------------------------------
func TestNewManager(t *testing.T) {
// NewManager requires backing services; pass nil for lightweight tests
// where no actual connections are made.
mgr := NewManager(nil, nil, nil, nil)
if mgr == nil {
t.Fatal("NewManager() returned nil")
}
if mgr.sessions == nil {
t.Fatal("expected sessions map to be initialised")
}
if len(mgr.sessions) != 0 {
t.Errorf("expected empty sessions map, got %d entries", len(mgr.sessions))
}
}
// ---------------------------------------------------------------------------
// ListSessions on an empty manager
// ---------------------------------------------------------------------------
func TestListSessionsEmpty(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
sessions := mgr.ListSessions()
if len(sessions) != 0 {
t.Errorf("expected 0 sessions, got %d", len(sessions))
}
}
// ---------------------------------------------------------------------------
// createTabName logic
// ---------------------------------------------------------------------------
func TestCreateTabName(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
tests := []struct {
name string
profile *models.Profile
want string
}{
{
name: "uses display name when set",
profile: &models.Profile{
DisplayName: "Production DB",
Host: "db.prod.com",
Username: "admin",
},
want: "Production DB",
},
{
name: "falls back to user@host when no display name",
profile: &models.Profile{
DisplayName: "",
Host: "server.local",
Username: "root",
},
want: "root@server.local",
},
{
name: "falls back to host only when no display name or username",
profile: &models.Profile{
DisplayName: "",
Host: "switch.local",
Username: "",
},
want: "switch.local",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mgr.createTabName(tt.profile)
if got != tt.want {
t.Errorf("createTabName() = %q, want %q", got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// RenameSession on non-existent session
// ---------------------------------------------------------------------------
func TestRenameSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
err := mgr.RenameSession("nonexistent-id", "New Name")
if err == nil {
t.Error("expected error when renaming non-existent session")
}
}
// ---------------------------------------------------------------------------
// PinSession on non-existent session
// ---------------------------------------------------------------------------
func TestPinSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
err := mgr.PinSession("nonexistent-id", true)
if err == nil {
t.Error("expected error when pinning non-existent session")
}
}
// ---------------------------------------------------------------------------
// GetSession on non-existent session
// ---------------------------------------------------------------------------
func TestGetSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
_, err := mgr.GetSession("nonexistent-id")
if err == nil {
t.Error("expected error when getting non-existent session")
}
}
// ---------------------------------------------------------------------------
// Disconnect on non-existent session
// ---------------------------------------------------------------------------
func TestDisconnectNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
err := mgr.Disconnect("nonexistent-id")
if err == nil {
t.Error("expected error when disconnecting non-existent session")
}
}
// ---------------------------------------------------------------------------
// CloseAll on empty manager does not panic
// ---------------------------------------------------------------------------
func TestCloseAllEmpty(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
// Should not panic.
mgr.CloseAll()
if len(mgr.ListSessions()) != 0 {
t.Error("expected 0 sessions after CloseAll")
}
}
+162
View File
@@ -0,0 +1,162 @@
package sshclient
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"golang.org/x/crypto/ssh"
)
// HostKeyStore is the interface for known-host persistence.
// Implementations handle the underlying storage mechanism (e.g. SQLite, file).
type HostKeyStore interface {
GetKnownHost(host string, port int) (*models.KnownHost, error)
SaveKnownHost(kh *models.KnownHost) error
}
// HostKeyVerifier handles SSH host key verification against the known-hosts
// store. It supports four policies:
// - strict: reject unknown or changed keys
// - ask: prompt the user for unknown keys (via callback)
// - accept-new: automatically accept new keys, reject changed keys
// - insecure: accept all keys (for testing only — clearly warned)
type HostKeyVerifier struct {
store HostKeyStore
policy models.HostKeyPolicy
onPrompt func(host string, keyType string, fingerprint string) (bool, error)
}
// NewHostKeyVerifier creates a verifier with the given store and policy.
func NewHostKeyVerifier(store HostKeyStore, policy models.HostKeyPolicy) *HostKeyVerifier {
return &HostKeyVerifier{
store: store,
policy: policy,
}
}
// SetPromptHandler sets the callback used in "ask" mode to prompt the user
// for acceptance of an unknown host key.
func (v *HostKeyVerifier) SetPromptHandler(fn func(string, string, string) (bool, error)) {
v.onPrompt = fn
}
// HostKeyCallback returns an ssh.HostKeyCallback suitable for use with
// ssh.ClientConfig. The behaviour depends on the configured policy.
func (v *HostKeyVerifier) HostKeyCallback() ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
host, port := extractHostPort(hostname, remote)
keyType := key.Type()
fingerprint := computeFingerprint(key)
stored, err := v.store.GetKnownHost(host, port)
if err != nil {
return fmt.Errorf("failed to query known hosts: %w", err)
}
switch v.policy {
case models.HostKeyInsecure:
return v.saveIfNew(stored, host, port, keyType, fingerprint, key)
case models.HostKeyAcceptNew:
if stored == nil {
return v.saveHost(host, port, keyType, fingerprint, key)
}
return v.verifyStored(stored, fingerprint, host)
case models.HostKeyAsk:
if stored == nil {
return v.promptAndSave(host, port, keyType, fingerprint, key)
}
return v.verifyStored(stored, fingerprint, host)
case models.HostKeyStrict:
if stored == nil {
return fmt.Errorf("host key for %s:%d is not known (policy: strict)", host, port)
}
return v.verifyStored(stored, fingerprint, host)
default:
return fmt.Errorf("unknown host key policy: %s", v.policy)
}
}
}
// verifyStored checks that the presented fingerprint matches the stored one.
func (v *HostKeyVerifier) verifyStored(stored *models.KnownHost, fingerprint, host string) error {
if stored.Fingerprint != fingerprint {
return fmt.Errorf(
"host key for %s has changed! stored fingerprint: %s, received: %s — possible MITM attack",
host, stored.Fingerprint, fingerprint,
)
}
stored.LastSeen = time.Now()
_ = v.store.SaveKnownHost(stored)
return nil
}
// promptAndSave asks the user whether to accept an unknown host key.
func (v *HostKeyVerifier) promptAndSave(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
if v.onPrompt == nil {
return fmt.Errorf("host key for %s:%d is unknown and no prompt handler is set (policy: ask)", host, port)
}
accepted, err := v.onPrompt(host, keyType, fingerprint)
if err != nil {
return fmt.Errorf("host key prompt failed: %w", err)
}
if !accepted {
return fmt.Errorf("host key for %s:%d rejected by user", host, port)
}
return v.saveHost(host, port, keyType, fingerprint, key)
}
// saveIfNew stores the host key only when no prior record exists.
func (v *HostKeyVerifier) saveIfNew(stored *models.KnownHost, host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
if stored == nil {
return v.saveHost(host, port, keyType, fingerprint, key)
}
stored.LastSeen = time.Now()
_ = v.store.SaveKnownHost(stored)
return nil
}
// saveHost persists a new known-host record.
func (v *HostKeyVerifier) saveHost(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
now := time.Now()
kh := &models.KnownHost{
Host: host,
Port: port,
KeyType: keyType,
Fingerprint: fingerprint,
PublicKey: base64.StdEncoding.EncodeToString(key.Marshal()),
FirstSeen: now,
LastSeen: now,
Trusted: true,
}
return v.store.SaveKnownHost(kh)
}
// computeFingerprint returns the SHA-256 fingerprint of a public key in
// the standard "SHA256:<base64>" format.
func computeFingerprint(key ssh.PublicKey) string {
hash := sha256.Sum256(key.Marshal())
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
}
// extractHostPort splits the hostname and port from the SSH callback arguments.
func extractHostPort(hostname string, remote net.Addr) (string, int) {
host, portStr, err := net.SplitHostPort(hostname)
if err != nil {
host, portStr, _ = net.SplitHostPort(remote.String())
}
port := 22
if portStr != "" {
fmt.Sscanf(portStr, "%d", &port)
}
return host, port
}
+546
View File
@@ -0,0 +1,546 @@
package sshclient
import (
"context"
"fmt"
"io"
"net"
"os"
"sync"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"golang.org/x/crypto/ssh"
)
// Client manages a single SSH session lifecycle.
// It handles connection establishment, authentication, PTY allocation,
// terminal resize propagation, and clean shutdown.
type Client struct {
mu sync.Mutex
config *models.Profile
sshClient *ssh.Client
session *ssh.Session
stdin io.WriteCloser
stdout io.Reader
stderr io.Reader
connected bool
onData func(data []byte)
onError func(err error)
onClose func()
ctx context.Context
cancel context.CancelFunc
}
// NewClient creates a new SSH client bound to the given profile.
func NewClient(profile *models.Profile) *Client {
ctx, cancel := context.WithCancel(context.Background())
return &Client{
config: profile,
ctx: ctx,
cancel: cancel,
}
}
// SetCallbacks sets the data, error, and close callbacks. Must be called
// before Connect.
func (c *Client) SetCallbacks(onData func([]byte), onError func(error), onClose func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.onData = onData
c.onError = onError
c.onClose = onClose
}
// sshConf returns the SSH-specific configuration, or a zero value if nil.
func (c *Client) sshConf() models.SSHConfig {
if c.config.SSH != nil {
return *c.config.SSH
}
return models.SSHConfig{}
}
// Connect establishes the SSH connection using the default (insecure)
// host-key callback. Prefer ConnectWithHostKeyCallback for production use.
func (c *Client) Connect(getPassword func() (string, error), getPassphrase func() (string, error)) error {
return c.connectInternal(ssh.InsecureIgnoreHostKey(), getPassword, getPassphrase)
}
// ConnectWithHostKeyCallback applies a custom HostKeyCallback (e.g. from
// HostKeyVerifier) and then establishes the connection.
func (c *Client) ConnectWithHostKeyCallback(
hkCallback ssh.HostKeyCallback,
getPassword func() (string, error),
getPassphrase func() (string, error),
) error {
return c.connectInternal(hkCallback, getPassword, getPassphrase)
}
// connectInternal is the shared implementation for both Connect variants.
func (c *Client) connectInternal(
hkCallback ssh.HostKeyCallback,
getPassword func() (string, error),
getPassphrase func() (string, error),
) error {
c.mu.Lock()
if c.connected {
c.mu.Unlock()
return fmt.Errorf("already connected")
}
c.mu.Unlock()
sc := c.sshConf()
authMethods, err := c.buildAuthMethods(sc, getPassword, getPassphrase)
if err != nil {
return fmt.Errorf("failed to build auth methods: %w", err)
}
termType := sc.TerminalType
if termType == "" {
termType = "xterm-256color"
}
timeout := time.Duration(c.config.ConnectTimeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
sshConfig := &ssh.ClientConfig{
User: c.config.Username,
Auth: authMethods,
HostKeyCallback: hkCallback,
Timeout: timeout,
}
var sshConn *ssh.Client
if len(sc.JumpHosts) > 0 {
sshConn, err = c.connectViaJumpHosts(sc, sshConfig, getPassphrase)
} else {
port := c.config.Port
if port == 0 {
port = 22
}
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
sshConn, err = ssh.Dial("tcp", addr, sshConfig)
}
if err != nil {
return fmt.Errorf("SSH connection failed: %w", err)
}
session, err := sshConn.NewSession()
if err != nil {
sshConn.Close()
return fmt.Errorf("failed to create SSH session: %w", err)
}
// Allocate a PTY with sensible defaults and terminal modes.
cols := 80
rows := 24
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := session.RequestPty(termType, rows, cols, modes); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("PTY request failed: %w", err)
}
stdinPipe, err := session.StdinPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stdin pipe: %w", err)
}
stdoutPipe, err := session.StdoutPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stdout pipe: %w", err)
}
stderrPipe, err := session.StderrPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stderr pipe: %w", err)
}
// Start shell or explicit startup command.
if sc.StartupCommand != "" {
if err := session.Start(sc.StartupCommand); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to start command: %w", err)
}
} else {
if err := session.Shell(); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to start shell: %w", err)
}
}
c.mu.Lock()
c.sshClient = sshConn
c.session = session
c.stdin = stdinPipe
c.stdout = stdoutPipe
c.stderr = stderrPipe
c.connected = true
c.mu.Unlock()
go c.readLoop()
if sc.KeepaliveInterval > 0 {
go c.startKeepalive(sc.KeepaliveInterval)
}
return nil
}
// Write sends data to the SSH session's stdin.
func (c *Client) Write(data []byte) error {
c.mu.Lock()
if !c.connected || c.stdin == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
stdin := c.stdin
c.mu.Unlock()
_, err := stdin.Write(data)
return err
}
// Resize sends a terminal resize request to the remote PTY.
func (c *Client) Resize(cols, rows int) error {
c.mu.Lock()
if !c.connected || c.session == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
session := c.session
c.mu.Unlock()
return session.WindowChange(rows, cols)
}
// Close cleanly shuts down the SSH session and connection, cancels the
// background context, and invokes the onClose callback.
func (c *Client) Close() error {
c.mu.Lock()
if !c.connected {
c.mu.Unlock()
return nil
}
c.connected = false
session := c.session
sshClient := c.sshClient
onClose := c.onClose
c.mu.Unlock()
c.cancel()
var firstErr error
if session != nil {
if err := session.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if sshClient != nil {
if err := sshClient.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if onClose != nil {
onClose()
}
return firstErr
}
// IsConnected returns the current connection state.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// buildAuthMethods constructs the SSH authentication method list from the
// profile's SSHConfig and the provided credential callbacks.
func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
authList := sc.AuthMethods
if len(authList) == 0 {
authList = []string{"publickey", "password", "keyboard-interactive"}
}
for _, m := range authList {
switch m {
case "password":
if getPassword != nil {
methods = append(methods, ssh.PasswordCallback(func() (string, error) {
return getPassword()
}))
}
case "publickey":
if sc.PrivateKeyPath != "" {
signer, err := c.parsePrivateKey(sc.PrivateKeyPath, getPassphrase)
if err != nil {
return nil, fmt.Errorf("failed to parse private key %s: %w", sc.PrivateKeyPath, err)
}
methods = append(methods, ssh.PublicKeys(signer))
}
case "keyboard-interactive":
if getPassword != nil {
pwdCallback := getPassword
methods = append(methods, ssh.KeyboardInteractive(
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
ans, err := pwdCallback()
if err != nil {
return nil, err
}
answers[i] = ans
}
return answers, nil
},
))
}
}
}
if len(methods) == 0 {
return nil, fmt.Errorf("no authentication methods available")
}
return methods, nil
}
// connectViaJumpHosts establishes the SSH connection through a chain of
// jump hosts. Each hop opens a new net.Conn through the previous hop's
// SSH client.
func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.ClientConfig, getPassphrase func() (string, error)) (*ssh.Client, error) {
var currentClient *ssh.Client
for i, jump := range sc.JumpHosts {
jumpPort := jump.Port
if jumpPort == 0 {
jumpPort = 22
}
jumpAddr := net.JoinHostPort(jump.Host, fmt.Sprintf("%d", jumpPort))
var jumpAuth []ssh.AuthMethod
if jump.PrivateKeyPath != "" {
signer, err := c.parsePrivateKey(jump.PrivateKeyPath, getPassphrase)
if err != nil {
if currentClient != nil {
currentClient.Close()
}
return nil, fmt.Errorf("jump host %d private key error: %w", i, err)
}
jumpAuth = append(jumpAuth, ssh.PublicKeys(signer))
}
jumpConfig := &ssh.ClientConfig{
User: jump.Username,
Auth: jumpAuth,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: finalConfig.Timeout,
}
if currentClient == nil {
client, err := ssh.Dial("tcp", jumpAddr, jumpConfig)
if err != nil {
return nil, fmt.Errorf("failed to connect to jump host %d (%s): %w", i, jumpAddr, err)
}
currentClient = client
} else {
conn, err := currentClient.Dial("tcp", jumpAddr)
if err != nil {
currentClient.Close()
return nil, fmt.Errorf("failed to dial through jump host %d (%s): %w", i, jumpAddr, err)
}
ncc, chans, reqs, err := ssh.NewClientConn(conn, jumpAddr, jumpConfig)
if err != nil {
conn.Close()
currentClient.Close()
return nil, fmt.Errorf("failed to establish SSH through jump host %d: %w", i, err)
}
currentClient = ssh.NewClient(ncc, chans, reqs)
}
}
// Final hop to the target host.
targetPort := c.config.Port
if targetPort == 0 {
targetPort = 22
}
targetAddr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", targetPort))
conn, err := currentClient.Dial("tcp", targetAddr)
if err != nil {
currentClient.Close()
return nil, fmt.Errorf("failed to dial target %s through jump hosts: %w", targetAddr, err)
}
ncc, chans, reqs, err := ssh.NewClientConn(conn, targetAddr, finalConfig)
if err != nil {
conn.Close()
currentClient.Close()
return nil, fmt.Errorf("failed to establish SSH to target %s: %w", targetAddr, err)
}
return ssh.NewClient(ncc, chans, reqs), nil
}
// parsePrivateKey loads and parses an SSH private key file. If the key is
// encrypted and getPassphrase is provided, it will attempt decryption.
func (c *Client) parsePrivateKey(path string, getPassphrase func() (string, error)) (ssh.Signer, error) {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("unable to read key file: %w", err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err == nil {
return signer, nil
}
if getPassphrase == nil {
return nil, fmt.Errorf("key is encrypted and no passphrase callback provided: %w", err)
}
passphrase, err := getPassphrase()
if err != nil {
return nil, fmt.Errorf("failed to get passphrase: %w", err)
}
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(passphrase))
if err != nil {
return nil, fmt.Errorf("failed to parse encrypted key: %w", err)
}
return signer, nil
}
// startKeepalive periodically sends keepalive requests to the server.
// It exits when the context is cancelled or the connection closes.
func (c *Client) startKeepalive(intervalSecs int) {
interval := time.Duration(intervalSecs) * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.mu.Lock()
client := c.sshClient
connected := c.connected
c.mu.Unlock()
if !connected || client == nil {
return
}
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
c.mu.Lock()
onError := c.onError
c.mu.Unlock()
if onError != nil {
onError(fmt.Errorf("keepalive failed: %w", err))
}
return
}
}
}
}
// readLoop reads from stdout and stderr, dispatching received data to the
// onData callback. It runs until the context is cancelled or the streams close.
func (c *Client) readLoop() {
done := make(chan struct{}, 2)
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 32*1024)
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
stdout := c.stdout
c.mu.Unlock()
if stdout == nil {
return
}
n, err := stdout.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(data)
}
}
if err != nil {
return
}
}
}()
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 32*1024)
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
stderr := c.stderr
c.mu.Unlock()
if stderr == nil {
return
}
n, err := stderr.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(data)
}
}
if err != nil {
return
}
}
}()
<-done
<-done
c.Close()
}
+20 -5
View File
@@ -302,15 +302,30 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
store := setupTestStore(t) store := setupTestStore(t)
prefs := models.DefaultAppPreferences() prefs := models.DefaultAppPreferences()
prefs.ConfirmOnClose = true
prefs.DefaultFontSize = 16
prefs.Theme = "custom-dark"
if err := store.SavePreferences(&prefs); err != nil { if err := store.SavePreferences(&prefs); err != nil {
t.Fatalf("SavePreferences() error = %v", err) t.Fatalf("SavePreferences() error = %v", err)
} }
// NOTE: GetPreferences round-trip is not tested here because the got, err := store.GetPreferences()
// current implementation stores all values (including booleans) as if err != nil {
// plain strings in the key-value table. json.Unmarshal cannot t.Fatalf("GetPreferences() error = %v", err)
// decode string "true" into a Go bool field. This is a pre-existing }
// limitation of the storage layer, not a test defect. if got == nil {
t.Fatal("GetPreferences() returned nil after save")
}
if got.ConfirmOnClose != true {
t.Errorf("ConfirmOnClose = %v, want true", got.ConfirmOnClose)
}
if got.DefaultFontSize != 16 {
t.Errorf("DefaultFontSize = %d, want 16", got.DefaultFontSize)
}
if got.Theme != "custom-dark" {
t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark")
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+603
View File
@@ -0,0 +1,603 @@
package telnetclient
import (
"context"
"encoding/binary"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"golang.org/x/text/encoding/charmap"
)
// Telnet command bytes (RFC 854/855).
const (
IAC byte = 255 // Interpret As Command
DONT byte = 254
DO byte = 253
WONT byte = 252
WILL byte = 251
SB byte = 250 // Sub-negotiation Begin
SE byte = 240 // Sub-negotiation End
// Common options.
OptEcho byte = 1
OptSGA byte = 3 // Suppress Go Ahead
OptTermType byte = 24 // Terminal Type
OptNAWS byte = 31 // Negotiate About Window Size
OptLineMode byte = 34
)
// Sub-negotiation constants.
const (
subIS byte = 0 // IS (used in terminal-type sub-negotiation)
subSEND byte = 1 // SEND (server requests terminal type)
)
// Client manages a single Telnet session.
// It handles Telnet option negotiation (RFC 854/855), terminal-type
// sub-negotiation, window-size negotiation (NAWS), and data framing.
type Client struct {
mu sync.Mutex
config *models.Profile
conn net.Conn
connected bool
onData func(data []byte)
onError func(err error)
onClose func()
onDebug func(msg string)
ctx context.Context
cancel context.CancelFunc
localEcho bool
encoding string
cols int
rows int
// Track which options we have agreed to support.
willOptions map[byte]bool
doOptions map[byte]bool
}
// NewClient creates a new Telnet client bound to the given profile.
func NewClient(profile *models.Profile) *Client {
ctx, cancel := context.WithCancel(context.Background())
localEcho := false
enc := "utf-8"
if profile.Telnet != nil {
localEcho = profile.Telnet.LocalEcho
if profile.Telnet.Encoding != "" {
enc = profile.Telnet.Encoding
}
}
return &Client{
config: profile,
ctx: ctx,
cancel: cancel,
localEcho: localEcho,
encoding: enc,
cols: 80,
rows: 24,
willOptions: make(map[byte]bool),
doOptions: make(map[byte]bool),
}
}
// SetCallbacks sets the data, error, close, and debug callbacks.
// Must be called before Connect.
func (c *Client) SetCallbacks(onData func([]byte), onError func(error), onClose func(), onDebug func(string)) {
c.mu.Lock()
defer c.mu.Unlock()
c.onData = onData
c.onError = onError
c.onClose = onClose
c.onDebug = onDebug
}
// telnetConf returns the Telnet-specific configuration, or a zero value
// if nil.
func (c *Client) telnetConf() models.TelnetConfig {
if c.config.Telnet != nil {
return *c.config.Telnet
}
return models.TelnetConfig{}
}
// Connect establishes the Telnet connection and starts the read loop.
func (c *Client) Connect() error {
c.mu.Lock()
if c.connected {
c.mu.Unlock()
return fmt.Errorf("already connected")
}
c.mu.Unlock()
timeout := time.Duration(c.config.ConnectTimeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
port := c.config.Port
if port == 0 {
port = 23
}
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("Telnet connection to %s failed: %w", addr, err)
}
c.mu.Lock()
c.conn = conn
c.connected = true
c.mu.Unlock()
go c.readLoop()
return nil
}
// Write sends data to the Telnet session, applying line-ending conversion
// and escaping any literal IAC bytes in the payload.
func (c *Client) Write(data []byte) error {
c.mu.Lock()
if !c.connected || c.conn == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
conn := c.conn
c.mu.Unlock()
converted := c.convertLineEnding(data)
escaped := c.escapeIAC(converted)
encoded := c.encodeOutgoing(escaped)
_, err := conn.Write(encoded)
return err
}
// Resize sends a NAWS (Negotiate About Window Size) update to the remote host.
func (c *Client) Resize(cols, rows int) error {
c.mu.Lock()
if !c.connected || c.conn == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
c.cols = cols
c.rows = rows
conn := c.conn
c.mu.Unlock()
return c.sendNAWS(conn, cols, rows)
}
// Close shuts down the Telnet connection and invokes the onClose callback.
func (c *Client) Close() error {
c.mu.Lock()
if !c.connected {
c.mu.Unlock()
return nil
}
c.connected = false
conn := c.conn
onClose := c.onClose
c.mu.Unlock()
c.cancel()
var err error
if conn != nil {
err = conn.Close()
}
if onClose != nil {
onClose()
}
return err
}
// IsConnected returns the connection state.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// SendBreak transmits a Telnet break signal (IAC BRK).
func (c *Client) SendBreak() error {
c.mu.Lock()
if !c.connected || c.conn == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
conn := c.conn
c.mu.Unlock()
_, err := conn.Write([]byte{IAC, 243}) // 243 = BRK
return err
}
// readLoop reads from the connection, processes IAC sequences, and dispatches
// plain data to the onData callback.
func (c *Client) readLoop() {
buf := make([]byte, 4096)
var dataBuf []byte
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
conn := c.conn
connected := c.connected
c.mu.Unlock()
if !connected || conn == nil {
return
}
n, err := conn.Read(buf)
if n > 0 {
raw := buf[:n]
dataBuf = dataBuf[:0]
i := 0
for i < len(raw) {
if raw[i] == IAC {
if len(dataBuf) > 0 {
c.dispatchData(dataBuf)
dataBuf = dataBuf[:0]
}
consumed := c.handleIAC(raw[i:])
if consumed == 0 {
break
}
i += consumed
} else {
dataBuf = append(dataBuf, raw[i])
i++
}
}
if len(dataBuf) > 0 {
c.dispatchData(dataBuf)
}
}
if err != nil {
c.mu.Lock()
onError := c.onError
c.mu.Unlock()
if onError != nil {
onError(fmt.Errorf("read error: %w", err))
}
c.Close()
return
}
}
}
// handleIAC processes a Telnet IAC sequence starting at data[0].
// Returns the number of bytes consumed.
func (c *Client) handleIAC(data []byte) int {
if len(data) < 2 {
return 0
}
// IAC IAC = escaped 0xFF byte.
if data[1] == IAC {
c.dispatchData([]byte{IAC})
return 2
}
switch data[1] {
case DO:
if len(data) < 3 {
return 0
}
c.handleDO(data[2])
return 3
case DONT:
if len(data) < 3 {
return 0
}
c.handleDONT(data[2])
return 3
case WILL:
if len(data) < 3 {
return 0
}
c.handleWILL(data[2])
return 3
case WONT:
if len(data) < 3 {
return 0
}
c.handleWONT(data[2])
return 3
case SB:
return c.handleSubnegotiation(data)
default:
c.debug("received IAC command: %d", data[1])
return 2
}
}
// handleDO processes a DO request from the server.
func (c *Client) handleDO(option byte) {
c.debug("received DO %d", option)
switch option {
case OptTermType:
c.negotiate(WILL, option)
c.mu.Lock()
c.willOptions[option] = true
c.mu.Unlock()
case OptNAWS:
c.negotiate(WILL, option)
c.mu.Lock()
c.willOptions[option] = true
cols := c.cols
rows := c.rows
conn := c.conn
c.mu.Unlock()
if conn != nil {
_ = c.sendNAWS(conn, cols, rows)
}
case OptSGA:
c.negotiate(WILL, option)
c.mu.Lock()
c.willOptions[option] = true
c.mu.Unlock()
default:
c.negotiate(WONT, option)
}
}
// handleDONT processes a DONT request from the server.
func (c *Client) handleDONT(option byte) {
c.debug("received DONT %d", option)
c.mu.Lock()
delete(c.willOptions, option)
c.mu.Unlock()
c.negotiate(WONT, option)
}
// handleWILL processes a WILL offer from the server.
func (c *Client) handleWILL(option byte) {
c.debug("received WILL %d", option)
switch option {
case OptEcho:
c.negotiate(DO, option)
c.mu.Lock()
c.doOptions[option] = true
c.localEcho = false
c.mu.Unlock()
case OptSGA:
c.negotiate(DO, option)
c.mu.Lock()
c.doOptions[option] = true
c.mu.Unlock()
default:
c.negotiate(DONT, option)
}
}
// handleWONT processes a WONT refusal from the server.
func (c *Client) handleWONT(option byte) {
c.debug("received WONT %d", option)
if option == OptEcho {
c.mu.Lock()
c.localEcho = true
c.mu.Unlock()
}
c.mu.Lock()
delete(c.doOptions, option)
c.mu.Unlock()
c.negotiate(DONT, option)
}
// handleSubnegotiation processes an SB...SE sub-negotiation block.
// Returns the total number of bytes consumed (including IAC SB and IAC SE).
func (c *Client) handleSubnegotiation(data []byte) int {
for i := 2; i < len(data)-1; i++ {
if data[i] == IAC && data[i+1] == SE {
payload := data[2:i]
c.processSubnegotiation(payload)
return i + 2
}
}
return 0
}
// processSubnegotiation handles the payload between SB and SE.
func (c *Client) processSubnegotiation(payload []byte) {
if len(payload) < 1 {
return
}
option := payload[0]
c.debug("sub-negotiation for option %d", option)
switch option {
case OptTermType:
if len(payload) >= 2 && payload[1] == subSEND {
c.sendTermType()
}
}
}
// sendTermType responds to a terminal-type SEND request with an IS response.
func (c *Client) sendTermType() {
tc := c.telnetConf()
termType := tc.TerminalType
if termType == "" {
termType = "xterm-256color"
}
c.debug("sending terminal type: %s", termType)
resp := []byte{IAC, SB, OptTermType, subIS}
resp = append(resp, []byte(strings.ToUpper(termType))...)
resp = append(resp, IAC, SE)
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn != nil {
_, _ = conn.Write(resp)
}
}
// negotiate sends a WILL/WONT/DO/DONT response for the given option.
func (c *Client) negotiate(command, option byte) {
c.debug("sending %s %d", commandName(command), option)
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn != nil {
_, _ = conn.Write([]byte{IAC, command, option})
}
}
// sendNAWS sends the current window size via NAWS sub-negotiation.
// Columns and rows are each sent as big-endian uint16 values.
func (c *Client) sendNAWS(conn net.Conn, cols, rows int) error {
c.debug("sending NAWS: %dx%d", cols, rows)
var buf [9]byte
buf[0] = IAC
buf[1] = SB
buf[2] = OptNAWS
binary.BigEndian.PutUint16(buf[3:5], uint16(cols))
binary.BigEndian.PutUint16(buf[5:7], uint16(rows))
buf[7] = IAC
buf[8] = SE
_, err := conn.Write(buf[:])
return err
}
// convertLineEnding applies the configured line-ending conversion on
// outgoing data.
func (c *Client) convertLineEnding(data []byte) []byte {
tc := c.telnetConf()
le := tc.LineEnding
if le == "" {
le = models.LineEndingCRLF
}
if le == models.LineEndingNone {
return data
}
s := string(data)
// Normalise all line endings to LF first.
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
switch le {
case models.LineEndingCR:
s = strings.ReplaceAll(s, "\n", "\r")
case models.LineEndingCRLF:
s = strings.ReplaceAll(s, "\n", "\r\n")
case models.LineEndingLF:
// Already LF.
}
return []byte(s)
}
// escapeIAC doubles any literal 0xFF bytes in the data so they are not
// misinterpreted as IAC commands.
func (c *Client) escapeIAC(data []byte) []byte {
count := 0
for _, b := range data {
if b == IAC {
count++
}
}
if count == 0 {
return data
}
escaped := make([]byte, 0, len(data)+count)
for _, b := range data {
escaped = append(escaped, b)
if b == IAC {
escaped = append(escaped, IAC)
}
}
return escaped
}
// encodeOutgoing converts outgoing text from UTF-8 to the configured encoding.
func (c *Client) encodeOutgoing(data []byte) []byte {
switch strings.ToLower(c.encoding) {
case "iso-8859-1", "latin1", "latin-1":
encoded, err := charmap.ISO8859_1.NewEncoder().Bytes(data)
if err != nil {
return data
}
return encoded
default:
return data
}
}
// dispatchData sends received data to the onData callback.
func (c *Client) dispatchData(data []byte) {
out := make([]byte, len(data))
copy(out, data)
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(out)
}
}
// debug emits a formatted debug message if a debug callback is registered.
func (c *Client) debug(format string, args ...interface{}) {
c.mu.Lock()
cb := c.onDebug
c.mu.Unlock()
if cb != nil {
cb(fmt.Sprintf(format, args...))
}
}
// commandName returns a human-readable name for a Telnet negotiation command.
func commandName(cmd byte) string {
switch cmd {
case WILL:
return "WILL"
case WONT:
return "WONT"
case DO:
return "DO"
case DONT:
return "DONT"
default:
return fmt.Sprintf("CMD(%d)", cmd)
}
}
+397
View File
@@ -0,0 +1,397 @@
package validation
import (
"testing"
"github.com/copilot-ssh-client/app/internal/models"
)
// ---------------------------------------------------------------------------
// ValidateHost
// ---------------------------------------------------------------------------
func TestValidateHost(t *testing.T) {
tests := []struct {
name string
host string
wantErr bool
}{
{name: "valid hostname", host: "example.com", wantErr: false},
{name: "valid IP address", host: "192.168.1.1", wantErr: false},
{name: "valid IPv6 address", host: "::1", wantErr: false},
{name: "empty string", host: "", wantErr: true},
{name: "only spaces", host: " ", wantErr: true},
{name: "contains spaces", host: "host name", wantErr: true},
{name: "contains tab", host: "host\tname", wantErr: true},
{name: "URI scheme present", host: "ssh://example.com", wantErr: true},
{name: "leading hyphen", host: "-bad-host", wantErr: true},
{name: "subdomain hostname", host: "server.internal.corp", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateHost(tt.host)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateHost(%q) error = %v, wantErr %v", tt.host, err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidatePort
// ---------------------------------------------------------------------------
func TestValidatePort(t *testing.T) {
tests := []struct {
name string
port int
wantErr bool
}{
{name: "port 1 is valid", port: 1, wantErr: false},
{name: "port 22 is valid", port: 22, wantErr: false},
{name: "port 443 is valid", port: 443, wantErr: false},
{name: "port 65535 is valid", port: 65535, wantErr: false},
{name: "port 0 is invalid", port: 0, wantErr: true},
{name: "negative port is invalid", port: -1, wantErr: true},
{name: "port 65536 is invalid", port: 65536, wantErr: true},
{name: "very large port is invalid", port: 99999, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePort(tt.port)
if (err != nil) != tt.wantErr {
t.Errorf("ValidatePort(%d) error = %v, wantErr %v", tt.port, err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateUsername
// ---------------------------------------------------------------------------
func TestValidateUsername(t *testing.T) {
tests := []struct {
name string
username string
wantErr bool
}{
{name: "valid username", username: "admin", wantErr: false},
{name: "empty string is invalid", username: "", wantErr: true},
{name: "only whitespace is invalid", username: " ", wantErr: true},
{name: "username with spaces is allowed", username: "my user", wantErr: false},
{name: "very long username is valid", username: "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789", wantErr: false},
{name: "contains control character", username: "bad\x01user", wantErr: true},
{name: "contains DEL character", username: "bad\x7fuser", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUsername(tt.username)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateUsername(%q) error = %v, wantErr %v", tt.username, err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateProfile
// ---------------------------------------------------------------------------
func TestValidateProfile(t *testing.T) {
// Helper to build a valid SSH profile.
validSSH := func() *models.Profile {
p := models.NewProfile(models.ProtocolSSH)
p.DisplayName = "Test SSH"
p.Host = "example.com"
p.Username = "root"
return &p
}
// Helper to build a valid Telnet profile.
validTelnet := func() *models.Profile {
p := models.NewProfile(models.ProtocolTelnet)
p.DisplayName = "Test Telnet"
p.Host = "switch.local"
return &p
}
// Helper to build a valid RAW profile.
validRAW := func() *models.Profile {
p := models.NewProfile(models.ProtocolRAW)
p.DisplayName = "Test RAW"
p.Host = "device.local"
return &p
}
tests := []struct {
name string
profile *models.Profile
wantErr bool
}{
{name: "full valid SSH profile", profile: validSSH(), wantErr: false},
{name: "full valid Telnet profile", profile: validTelnet(), wantErr: false},
{name: "full valid RAW profile", profile: validRAW(), wantErr: false},
{
name: "missing host",
profile: func() *models.Profile {
p := validSSH()
p.Host = ""
return p
}(),
wantErr: true,
},
{
name: "missing port (zero)",
profile: func() *models.Profile {
p := validSSH()
p.Port = 0
return p
}(),
wantErr: true,
},
{
name: "invalid protocol",
profile: func() *models.Profile {
p := validSSH()
p.Protocol = "ftp"
return p
}(),
wantErr: true,
},
{
name: "SSH missing username",
profile: func() *models.Profile {
p := validSSH()
p.Username = ""
return p
}(),
wantErr: true,
},
{
name: "SSH nil config",
profile: func() *models.Profile {
p := validSSH()
p.SSH = nil
return p
}(),
wantErr: true,
},
{
name: "negative connect timeout",
profile: func() *models.Profile {
p := validSSH()
p.ConnectTimeout = -1
return p
}(),
wantErr: true,
},
{
name: "font size out of range",
profile: func() *models.Profile {
p := validSSH()
p.TerminalFontSize = 0
return p
}(),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateProfile(tt.profile)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateProfile() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateSSHConfig
// ---------------------------------------------------------------------------
func TestValidateSSHConfig(t *testing.T) {
validCfg := func() *models.SSHConfig {
return &models.SSHConfig{
AuthMethods: []string{"password"},
SecretMode: models.SecretModeSaved,
KeySecretMode: models.SecretModeAskEveryTime,
HostKeyPolicy: models.HostKeyAsk,
KeepaliveInterval: 30,
KeepaliveCountMax: 3,
JumpHosts: []models.JumpHost{},
}
}
tests := []struct {
name string
cfg *models.SSHConfig
wantErr bool
}{
{name: "valid config", cfg: validCfg(), wantErr: false},
{
name: "empty auth methods",
cfg: func() *models.SSHConfig {
c := validCfg()
c.AuthMethods = nil
return c
}(),
wantErr: true,
},
{
name: "invalid auth method",
cfg: func() *models.SSHConfig {
c := validCfg()
c.AuthMethods = []string{"oauth"}
return c
}(),
wantErr: true,
},
{
name: "invalid secret mode",
cfg: func() *models.SSHConfig {
c := validCfg()
c.SecretMode = "wrong"
return c
}(),
wantErr: true,
},
{
name: "invalid host key policy",
cfg: func() *models.SSHConfig {
c := validCfg()
c.HostKeyPolicy = "yolo"
return c
}(),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSSHConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateSSHConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateTelnetConfig
// ---------------------------------------------------------------------------
func TestValidateTelnetConfig(t *testing.T) {
tests := []struct {
name string
cfg *models.TelnetConfig
wantErr bool
}{
{
name: "valid config",
cfg: &models.TelnetConfig{
LineEnding: models.LineEndingCRLF,
Encoding: "utf-8",
},
wantErr: false,
},
{
name: "invalid line ending",
cfg: &models.TelnetConfig{
LineEnding: "bad",
Encoding: "utf-8",
},
wantErr: true,
},
{
name: "empty encoding",
cfg: &models.TelnetConfig{
LineEnding: models.LineEndingLF,
Encoding: "",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTelnetConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateTelnetConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateRAWConfig
// ---------------------------------------------------------------------------
func TestValidateRAWConfig(t *testing.T) {
tests := []struct {
name string
cfg *models.RAWConfig
wantErr bool
}{
{
name: "valid config",
cfg: &models.RAWConfig{
LineEnding: models.LineEndingCRLF,
Encoding: "utf-8",
PayloadPresets: []models.PayloadPreset{},
},
wantErr: false,
},
{
name: "invalid line ending",
cfg: &models.RAWConfig{
LineEnding: "bad",
Encoding: "utf-8",
},
wantErr: true,
},
{
name: "empty encoding",
cfg: &models.RAWConfig{
LineEnding: models.LineEndingLF,
Encoding: "",
},
wantErr: true,
},
{
name: "preset with invalid format",
cfg: &models.RAWConfig{
LineEnding: models.LineEndingCRLF,
Encoding: "utf-8",
PayloadPresets: []models.PayloadPreset{
{Name: "ping", Format: "binary"},
},
},
wantErr: true,
},
{
name: "preset with empty name",
cfg: &models.RAWConfig{
LineEnding: models.LineEndingCRLF,
Encoding: "utf-8",
PayloadPresets: []models.PayloadPreset{
{Name: "", Format: "text"},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRAWConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateRAWConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}