diff --git a/cmd/app/main.go b/cmd/app/main.go index 490dbbb..0b2bc27 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -14,8 +14,11 @@ import ( ) 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{ Title: "Nexus Terminal", Width: 1400, @@ -26,11 +29,11 @@ func main() { Assets: frontend.Assets, }, BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255}, // #1a1a2e - OnStartup: app.Startup, - OnShutdown: app.Shutdown, - OnDomReady: app.DomReady, + OnStartup: nexus.Startup, + OnShutdown: nexus.Shutdown, + OnDomReady: nexus.DomReady, Bind: []interface{}{ - app, + nexus.APIService(), }, }) if err != nil { diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js new file mode 100644 index 0000000..5e40a19 --- /dev/null +++ b/frontend/src/js/app.js @@ -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 = ` + + + + + `; + + 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)); +}); diff --git a/frontend/src/js/commands.js b/frontend/src/js/commands.js new file mode 100644 index 0000000..677401d --- /dev/null +++ b/frontend/src/js/commands.js @@ -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} + */ + 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} 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} 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' }); + } + } +} diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js new file mode 100644 index 0000000..df5002f --- /dev/null +++ b/frontend/src/js/profiles.js @@ -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 = ` +

${isNew ? 'New Connection' : 'Edit Connection'}

+ + `; + 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 = ` + ${collapsed ? '▸' : '▾'} + ${escapeHtml(title)} + `; + + 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} 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 = ` + + + + + `; + 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} 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 = ` + + + + + `; + 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'); + } +} diff --git a/frontend/src/js/raw.js b/frontend/src/js/raw.js new file mode 100644 index 0000000..d9b5d0d --- /dev/null +++ b/frontend/src/js/raw.js @@ -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} + */ + 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; + } + }); + } +} diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js new file mode 100644 index 0000000..3e9aba2 --- /dev/null +++ b/frontend/src/js/settings.js @@ -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} 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 = ` + + + `; + 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 = ` + Secret store + ${storeState} + `; + 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 = ` + + + Host + Port + Key Type + Fingerprint + First Seen + + + + `; + const tbody = createElement('tbody'); + + for (const kh of this._knownHosts) { + const tr = createElement('tr'); + tr.innerHTML = ` + ${escapeHtml(kh.host)} + ${kh.port} + ${escapeHtml(kh.keyType)} + ${escapeHtml(kh.fingerprint.slice(0, 20))}… + ${formatTimestamp(kh.firstSeen)} + + `; + 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 = ` +
+

Nexus Terminal

+

Version 1.0.0

+

A premium desktop SSH, Telnet, and RAW TCP client built with Wails.

+ +
+ `; + } + + // ── 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; + } + } + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 9e738d8..f4e5825 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,35 +5,21 @@ package app import ( "context" - "fmt" "log" "sync" "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/knownhosts" + "github.com/copilot-ssh-client/app/internal/preferences" "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 // (Startup, Shutdown, DomReady) and it holds references to every subsystem. type App struct { @@ -41,18 +27,31 @@ type App struct { mu sync.Mutex Config *config.AppConfig - Storage Storage + Store *storage.Store SecretStore *secrets.SecretStore - SessionManager SessionManager - Preferences Preferences - KnownHostStore KnownHostStore - AuditLog AuditLog + KeychainStore *secrets.KeychainStore + SessionManager *sessions.Manager + Prefs *preferences.Manager + KnownHosts *knownhosts.Store + AuditLog *audit.Logger + Service *api.Service } // 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 { - 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 @@ -64,6 +63,7 @@ func (a *App) Startup(ctx context.Context) { a.ctx = ctx + // Load platform-specific configuration paths. cfg, err := config.Load() if err != nil { 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) } - // Initialise the encrypted secret store. - a.SecretStore = secrets.NewSecretStore(cfg.SecretPath) + // Initialise SQLite storage (migrations run automatically on open). + 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) } @@ -93,8 +118,8 @@ func (a *App) Shutdown(ctx context.Context) { a.SessionManager.CloseAll() } - if a.Storage != nil { - if err := a.Storage.Close(); err != nil { + if a.Store != nil { + if err := a.Store.Close(); err != nil { 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{}{ "message": "Backend is ready", }) - fmt.Println("DOM ready – backend:ready event emitted") + log.Println("DOM ready – backend:ready event emitted") } diff --git a/internal/knownhosts/knownhosts_test.go b/internal/knownhosts/knownhosts_test.go new file mode 100644 index 0000000..b04e67a --- /dev/null +++ b/internal/knownhosts/knownhosts_test.go @@ -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)) + } +} diff --git a/internal/rawclient/encoding.go b/internal/rawclient/encoding.go new file mode 100644 index 0000000..a7acc2b --- /dev/null +++ b/internal/rawclient/encoding.go @@ -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') +} diff --git a/internal/rawclient/encoding_test.go b/internal/rawclient/encoding_test.go new file mode 100644 index 0000000..949ef20 --- /dev/null +++ b/internal/rawclient/encoding_test.go @@ -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) + } + }) + } +} diff --git a/internal/rawclient/rawclient.go b/internal/rawclient/rawclient.go new file mode 100644 index 0000000..d026256 --- /dev/null +++ b/internal/rawclient/rawclient.go @@ -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 +} diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go new file mode 100644 index 0000000..39103dc --- /dev/null +++ b/internal/sessions/manager_test.go @@ -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") + } +} diff --git a/internal/sshclient/hostkey.go b/internal/sshclient/hostkey.go new file mode 100644 index 0000000..3f74dfd --- /dev/null +++ b/internal/sshclient/hostkey.go @@ -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:" 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 +} diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go new file mode 100644 index 0000000..a4b6789 --- /dev/null +++ b/internal/sshclient/sshclient.go @@ -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() +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 8ddd719..2c42f78 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -302,15 +302,30 @@ func TestSavePreferencesDoesNotError(t *testing.T) { store := setupTestStore(t) prefs := models.DefaultAppPreferences() + prefs.ConfirmOnClose = true + prefs.DefaultFontSize = 16 + prefs.Theme = "custom-dark" + if err := store.SavePreferences(&prefs); err != nil { t.Fatalf("SavePreferences() error = %v", err) } - // NOTE: GetPreferences round-trip is not tested here because the - // current implementation stores all values (including booleans) as - // plain strings in the key-value table. json.Unmarshal cannot - // decode string "true" into a Go bool field. This is a pre-existing - // limitation of the storage layer, not a test defect. + got, err := store.GetPreferences() + if err != nil { + t.Fatalf("GetPreferences() error = %v", err) + } + 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") + } } // --------------------------------------------------------------------------- diff --git a/internal/telnetclient/telnetclient.go b/internal/telnetclient/telnetclient.go new file mode 100644 index 0000000..17e2fb9 --- /dev/null +++ b/internal/telnetclient/telnetclient.go @@ -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) + } +} diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go new file mode 100644 index 0000000..83822b2 --- /dev/null +++ b/internal/validation/validation_test.go @@ -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) + } + }) + } +}