feat: complete Nexus Terminal — premium SSH/Telnet/RAW TCP desktop client
Production-minded Wails v2 desktop application with: - Full SSH client (password, key, keyboard-interactive, jump hosts, keepalive) - Telnet client (RFC 854/855 negotiation, NAWS, TTYPE, encoding) - RAW TCP client (binary-safe, text/hex/split views, payload presets) - SQLite storage with migrations and repository abstraction - Encrypted secret storage (AES-256-GCM + Argon2id) with keychain fallback - Known hosts verification (strict/ask/accept-new/insecure) - Session manager with multi-session lifecycle and Wails events - Dark charcoal Material-inspired design system (CSS custom properties) - Tabbed session management with rename, reorder, pin, context menu - Profile editor with protocol-aware form sections - Command palette, notifications, settings modal - JSON import/export, audit logging, preferences - 50+ tests across validation, secrets, storage, encoding, sessions - Makefile, build.sh, .gitignore, comprehensive README
This commit is contained in:
+8
-5
@@ -14,8 +14,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := application.NewApp()
|
nexus := application.NewApp()
|
||||||
|
|
||||||
|
// The API service is created during Startup once storage is initialised.
|
||||||
|
// We bind the App itself so Wails can call lifecycle hooks, and use
|
||||||
|
// OnStartup to wire everything, then bind the API service separately.
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
Title: "Nexus Terminal",
|
Title: "Nexus Terminal",
|
||||||
Width: 1400,
|
Width: 1400,
|
||||||
@@ -26,11 +29,11 @@ func main() {
|
|||||||
Assets: frontend.Assets,
|
Assets: frontend.Assets,
|
||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255}, // #1a1a2e
|
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255}, // #1a1a2e
|
||||||
OnStartup: app.Startup,
|
OnStartup: nexus.Startup,
|
||||||
OnShutdown: app.Shutdown,
|
OnShutdown: nexus.Shutdown,
|
||||||
OnDomReady: app.DomReady,
|
OnDomReady: nexus.DomReady,
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
app,
|
nexus.APIService(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
/**
|
||||||
|
* Nexus Terminal — main application entry point.
|
||||||
|
* Initialises the application shell, loads profiles, and sets up event listeners.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { State } from './state.js';
|
||||||
|
import { Sidebar } from './sidebar.js';
|
||||||
|
import { TabManager } from './tabs.js';
|
||||||
|
import { TerminalManager } from './terminal.js';
|
||||||
|
import { ProfileEditor } from './profiles.js';
|
||||||
|
import { RawViewer } from './raw.js';
|
||||||
|
import { Notifications } from './notifications.js';
|
||||||
|
import { Settings } from './settings.js';
|
||||||
|
import { CommandPalette } from './commands.js';
|
||||||
|
import { SecretPrompt } from './secrets.js';
|
||||||
|
import { stateLabel } from './utils.js';
|
||||||
|
|
||||||
|
class App {
|
||||||
|
constructor() {
|
||||||
|
this.state = new State();
|
||||||
|
this.notifications = new Notifications();
|
||||||
|
this.sidebar = new Sidebar(this);
|
||||||
|
this.tabs = new TabManager(this);
|
||||||
|
this.terminals = new TerminalManager(this);
|
||||||
|
this.profileEditor = new ProfileEditor(this);
|
||||||
|
this.rawViewer = new RawViewer(this);
|
||||||
|
this.settings = new Settings(this);
|
||||||
|
this.commandPalette = new CommandPalette(this);
|
||||||
|
this.secretPrompt = new SecretPrompt(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstraps the application after the DOM is ready.
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
await this.loadProfiles();
|
||||||
|
await this.loadPreferences();
|
||||||
|
await this.checkSecretStore();
|
||||||
|
|
||||||
|
this.setupEvents();
|
||||||
|
this.setupKeyboard();
|
||||||
|
this.setupUI();
|
||||||
|
this.updateStatusBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data loading ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all connection profiles from the backend and renders the sidebar.
|
||||||
|
*/
|
||||||
|
async loadProfiles() {
|
||||||
|
try {
|
||||||
|
const profiles = await window.go.api.Service.ListProfiles();
|
||||||
|
this.state.setProfiles(profiles || []);
|
||||||
|
this.sidebar.render();
|
||||||
|
} catch (err) {
|
||||||
|
this.notifications.error('Failed to load profiles: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads user preferences from the backend.
|
||||||
|
*/
|
||||||
|
async loadPreferences() {
|
||||||
|
try {
|
||||||
|
const prefs = await window.go.api.Service.GetPreferences();
|
||||||
|
this.state.setPreferences(prefs || {});
|
||||||
|
this._applyPreferences();
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — fall back to defaults
|
||||||
|
console.warn('Failed to load preferences:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries the secret store status (initialised / unlocked).
|
||||||
|
*/
|
||||||
|
async checkSecretStore() {
|
||||||
|
try {
|
||||||
|
const status = await window.go.api.Service.SecretStoreStatus();
|
||||||
|
this.state.setSecretStoreInitialised(status?.initialised || false);
|
||||||
|
this.state.setSecretStoreState(status?.unlocked || false);
|
||||||
|
} catch (_) {
|
||||||
|
// Secret store may not be available yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wails event listeners ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers listeners for backend events emitted via the Wails runtime.
|
||||||
|
*/
|
||||||
|
setupEvents() {
|
||||||
|
// Incoming session data (terminal output)
|
||||||
|
window.runtime.EventsOn('session:data', (payload) => {
|
||||||
|
this.terminals.handleData(payload.sessionID, payload.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session state transitions (connecting → connected → disconnected)
|
||||||
|
window.runtime.EventsOn('session:state', (payload) => {
|
||||||
|
this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error);
|
||||||
|
this.updateStatusBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session closed by backend
|
||||||
|
window.runtime.EventsOn('session:closed', (payload) => {
|
||||||
|
this.tabs.handleSessionClosed(payload.sessionID);
|
||||||
|
this.updateStatusBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
// RAW TCP data (separate channel for the raw viewer)
|
||||||
|
window.runtime.EventsOn('session:raw:data', (payload) => {
|
||||||
|
this.rawViewer.handleData(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Telnet negotiation debug messages
|
||||||
|
window.runtime.EventsOn('session:debug', (payload) => {
|
||||||
|
console.debug('[session:debug]', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backend ready event (emitted from DomReady)
|
||||||
|
window.runtime.EventsOn('backend:ready', () => {
|
||||||
|
console.log('Backend is ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Host key verification requests
|
||||||
|
window.runtime.EventsOn('session:hostkey:verify', async (payload) => {
|
||||||
|
try {
|
||||||
|
const accepted = await this.secretPrompt.promptHostKeyVerification(
|
||||||
|
payload.host,
|
||||||
|
payload.keyType,
|
||||||
|
payload.fingerprint,
|
||||||
|
);
|
||||||
|
window.runtime.EventsEmit('session:hostkey:response', {
|
||||||
|
sessionID: payload.sessionID,
|
||||||
|
accepted,
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
window.runtime.EventsEmit('session:hostkey:response', {
|
||||||
|
sessionID: payload.sessionID,
|
||||||
|
accepted: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password/passphrase prompt requests from the backend
|
||||||
|
window.runtime.EventsOn('session:secret:prompt', async (payload) => {
|
||||||
|
try {
|
||||||
|
const value = await this.secretPrompt.prompt(
|
||||||
|
payload.title || 'Credentials Required',
|
||||||
|
payload.message || 'Enter your password or passphrase.',
|
||||||
|
);
|
||||||
|
window.runtime.EventsEmit('session:secret:response', {
|
||||||
|
sessionID: payload.sessionID,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
window.runtime.EventsEmit('session:secret:response', {
|
||||||
|
sessionID: payload.sessionID,
|
||||||
|
value: '',
|
||||||
|
cancelled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard shortcuts ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers global keyboard shortcuts.
|
||||||
|
*/
|
||||||
|
setupKeyboard() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
// Ctrl/Cmd+K — command palette
|
||||||
|
if (mod && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.commandPalette.toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+N — new connection
|
||||||
|
if (mod && e.key === 'n') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.profileEditor.openNew();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+W — close active tab
|
||||||
|
if (mod && e.key === 'w') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.tabs.closeActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Tab — next tab
|
||||||
|
if (mod && e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = this.tabs.getNextTab();
|
||||||
|
if (next) this.tabs.setActive(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Shift+Tab — previous tab
|
||||||
|
if (mod && e.key === 'Tab' && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = this.tabs.getPrevTab();
|
||||||
|
if (prev) this.tabs.setActive(prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+1-9 — switch to tab by index
|
||||||
|
if (mod && e.key >= '1' && e.key <= '9') {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = parseInt(e.key, 10) - 1;
|
||||||
|
const order = this.tabs.getTabOrder();
|
||||||
|
if (idx < order.length) {
|
||||||
|
this.tabs.setActive(order[idx]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+, — settings
|
||||||
|
if (mod && e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.settings.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+F — find in terminal
|
||||||
|
if (mod && e.key === 'f') {
|
||||||
|
e.preventDefault();
|
||||||
|
this._openTerminalSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape — close any open overlays
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.commandPalette.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI event wiring ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds click handlers to static UI elements.
|
||||||
|
*/
|
||||||
|
setupUI() {
|
||||||
|
this._bindClick('btn-new-connection', () => this.profileEditor.openNew());
|
||||||
|
this._bindClick('btn-settings', () => this.settings.open());
|
||||||
|
this._bindClick('btn-import', () => this.importProfiles());
|
||||||
|
this._bindClick('btn-new-tab', () => this.profileEditor.openNew());
|
||||||
|
this._bindClick('btn-empty-connect', () => this.profileEditor.openNew());
|
||||||
|
this._bindClick('btn-empty-quick', () => this.quickConnect());
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('profile-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', (e) => this.sidebar.search(e.target.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window resize → re-fit active terminal
|
||||||
|
window.addEventListener('resize', () => this.terminals.fitActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection lifecycle ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a new session for the given profile ID.
|
||||||
|
* @param {string} profileId
|
||||||
|
*/
|
||||||
|
async connectProfile(profileId) {
|
||||||
|
const profile = this.state.getProfile(profileId);
|
||||||
|
if (!profile) {
|
||||||
|
this.notifications.error('Profile not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionInfo = await window.go.api.Service.Connect(profileId);
|
||||||
|
this.state.addSession(sessionInfo);
|
||||||
|
this.tabs.addTab(sessionInfo);
|
||||||
|
|
||||||
|
// Create the appropriate viewer
|
||||||
|
const panelId = `panel-${sessionInfo.id}`;
|
||||||
|
if (profile.protocol === 'raw') {
|
||||||
|
this.rawViewer.createViewer(sessionInfo.id, panelId, profile);
|
||||||
|
} else {
|
||||||
|
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifications.info(`Connecting to ${profile.displayName || profile.host}…`);
|
||||||
|
} catch (err) {
|
||||||
|
this.notifications.error('Connection failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user for a host/port and opens a quick connection.
|
||||||
|
*/
|
||||||
|
async quickConnect() {
|
||||||
|
const input = prompt('Quick connect (host[:port]):');
|
||||||
|
if (!input || !input.trim()) return;
|
||||||
|
|
||||||
|
const parts = input.trim().split(':');
|
||||||
|
const host = parts[0];
|
||||||
|
const port = parts[1] ? parseInt(parts[1], 10) : 22;
|
||||||
|
const protocol = this.state.preferences.defaultProtocol || 'ssh';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionInfo = await window.go.api.Service.QuickConnect(protocol, host, port);
|
||||||
|
this.state.addSession(sessionInfo);
|
||||||
|
this.tabs.addTab(sessionInfo);
|
||||||
|
|
||||||
|
const panelId = `panel-${sessionInfo.id}`;
|
||||||
|
if (protocol === 'raw') {
|
||||||
|
this.rawViewer.createViewer(sessionInfo.id, panelId, { protocol, raw: {} });
|
||||||
|
} else {
|
||||||
|
this.terminals.createTerminal(sessionInfo.id, panelId, { protocol });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.notifications.error('Quick connect failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import / Export ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a profile import dialog.
|
||||||
|
*/
|
||||||
|
async importProfiles() {
|
||||||
|
try {
|
||||||
|
const result = await window.go.api.Service.ImportProfilesDialog();
|
||||||
|
if (result && result.count > 0) {
|
||||||
|
await this.loadProfiles();
|
||||||
|
this.notifications.success(`Imported ${result.count} profile(s)`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (String(err) !== 'cancelled') {
|
||||||
|
this.notifications.error('Import failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a profile export dialog.
|
||||||
|
*/
|
||||||
|
async exportProfiles() {
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.ExportProfilesDialog();
|
||||||
|
this.notifications.success('Profiles exported');
|
||||||
|
} catch (err) {
|
||||||
|
if (String(err) !== 'cancelled') {
|
||||||
|
this.notifications.error('Export failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status bar ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the status bar with current session info.
|
||||||
|
*/
|
||||||
|
updateStatusBar() {
|
||||||
|
const bar = document.getElementById('status-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
|
||||||
|
const sessions = this.state.getAllSessions();
|
||||||
|
const connected = sessions.filter((s) => s.state === 'connected').length;
|
||||||
|
const total = sessions.length;
|
||||||
|
|
||||||
|
const activeSession = this.state.activeSessionId
|
||||||
|
? this.state.getSession(this.state.activeSessionId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Left: active session info
|
||||||
|
const left = bar.querySelector('.status-left') || bar;
|
||||||
|
if (activeSession) {
|
||||||
|
const profile = this.state.getProfile(activeSession.profileId);
|
||||||
|
left.textContent = [
|
||||||
|
activeSession.protocol?.toUpperCase(),
|
||||||
|
profile ? `${profile.host}:${profile.port}` : '',
|
||||||
|
stateLabel(activeSession.state),
|
||||||
|
].filter(Boolean).join(' — ');
|
||||||
|
} else {
|
||||||
|
left.textContent = 'No active session';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right: session count
|
||||||
|
const right = bar.querySelector('.status-right');
|
||||||
|
if (right) {
|
||||||
|
right.textContent = `${connected}/${total} connected`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies loaded preferences to the UI.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_applyPreferences() {
|
||||||
|
const prefs = this.state.preferences;
|
||||||
|
|
||||||
|
// Sidebar width
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
if (sidebar && prefs.sidebarWidth) {
|
||||||
|
sidebar.style.width = `${prefs.sidebarWidth}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status bar visibility
|
||||||
|
const statusBar = document.getElementById('status-bar');
|
||||||
|
if (statusBar) {
|
||||||
|
statusBar.style.display = prefs.showStatusBar === false ? 'none' : 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens an inline search bar for the active terminal.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_openTerminalSearch() {
|
||||||
|
const sessionId = this.state.activeSessionId;
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
// Check if search bar already exists
|
||||||
|
const panel = document.getElementById(`panel-${sessionId}`);
|
||||||
|
if (!panel || panel.querySelector('.terminal-search-bar')) return;
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'terminal-search-bar';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<input type="text" class="form-input terminal-search-input" placeholder="Find…" />
|
||||||
|
<button class="btn btn-xs btn-outline terminal-search-prev">▲</button>
|
||||||
|
<button class="btn btn-xs btn-outline terminal-search-next">▼</button>
|
||||||
|
<button class="btn btn-xs btn-outline terminal-search-close">✕</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = bar.querySelector('.terminal-search-input');
|
||||||
|
const closeBtn = bar.querySelector('.terminal-search-close');
|
||||||
|
const prevBtn = bar.querySelector('.terminal-search-prev');
|
||||||
|
const nextBtn = bar.querySelector('.terminal-search-next');
|
||||||
|
|
||||||
|
const doSearch = () => this.terminals.search(sessionId, input.value);
|
||||||
|
const doPrev = () => this.terminals.searchPrevious(sessionId, input.value);
|
||||||
|
|
||||||
|
input.addEventListener('input', doSearch);
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.shiftKey ? doPrev() : doSearch();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
bar.remove();
|
||||||
|
this.terminals.focusTerminal(sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextBtn.addEventListener('click', doSearch);
|
||||||
|
prevBtn.addEventListener('click', doPrev);
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
bar.remove();
|
||||||
|
this.terminals.focusTerminal(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.prepend(bar);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely binds a click handler to an element by ID (no-op if the element does not exist).
|
||||||
|
* @param {string} id - Element ID.
|
||||||
|
* @param {Function} handler
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_bindClick(id, handler) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener('click', handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bootstrap ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const app = new App();
|
||||||
|
app.init().catch((err) => console.error('App initialisation failed:', err));
|
||||||
|
});
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* Nexus Terminal — command palette (Ctrl+K).
|
||||||
|
* Provides a searchable overlay for quick access to application commands,
|
||||||
|
* profile connections, and utility actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createElement, escapeHtml, fuzzyMatch, $ } from './utils.js';
|
||||||
|
|
||||||
|
export class CommandPalette {
|
||||||
|
/**
|
||||||
|
* @param {import('./app.js').App} app
|
||||||
|
*/
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
this._overlay = null;
|
||||||
|
|
||||||
|
/** @type {string} Current filter query. */
|
||||||
|
this._query = '';
|
||||||
|
|
||||||
|
/** @type {number} Index of the highlighted command. */
|
||||||
|
this._selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the command palette open/closed.
|
||||||
|
*/
|
||||||
|
toggle() {
|
||||||
|
if (this._overlay) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the command palette.
|
||||||
|
*/
|
||||||
|
open() {
|
||||||
|
if (this._overlay) return;
|
||||||
|
this._query = '';
|
||||||
|
this._selectedIndex = 0;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the command palette.
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this._overlay) {
|
||||||
|
this._overlay.remove();
|
||||||
|
this._overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command definitions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full list of available commands.
|
||||||
|
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
|
||||||
|
*/
|
||||||
|
getCommands() {
|
||||||
|
const cmds = [];
|
||||||
|
|
||||||
|
// Profile connections
|
||||||
|
const profiles = this.app.state.profiles || [];
|
||||||
|
for (const p of profiles) {
|
||||||
|
cmds.push({
|
||||||
|
id: `connect-${p.id}`,
|
||||||
|
label: `Connect to ${p.displayName || p.host}`,
|
||||||
|
section: 'Connections',
|
||||||
|
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
|
||||||
|
action: () => this.app.connectProfile(p.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// New connections
|
||||||
|
cmds.push({
|
||||||
|
id: 'new-ssh',
|
||||||
|
label: 'New SSH Connection',
|
||||||
|
section: 'Actions',
|
||||||
|
shortcut: '⌘N',
|
||||||
|
action: () => this.app.profileEditor.openNew('ssh'),
|
||||||
|
});
|
||||||
|
cmds.push({
|
||||||
|
id: 'new-telnet',
|
||||||
|
label: 'New Telnet Connection',
|
||||||
|
section: 'Actions',
|
||||||
|
action: () => this.app.profileEditor.openNew('telnet'),
|
||||||
|
});
|
||||||
|
cmds.push({
|
||||||
|
id: 'new-raw',
|
||||||
|
label: 'New RAW Connection',
|
||||||
|
section: 'Actions',
|
||||||
|
action: () => this.app.profileEditor.openNew('raw'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick connect
|
||||||
|
cmds.push({
|
||||||
|
id: 'quick-connect',
|
||||||
|
label: 'Quick Connect…',
|
||||||
|
section: 'Actions',
|
||||||
|
action: () => this.app.quickConnect(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab actions
|
||||||
|
cmds.push({
|
||||||
|
id: 'close-tab',
|
||||||
|
label: 'Close Tab',
|
||||||
|
section: 'Tabs',
|
||||||
|
shortcut: '⌘W',
|
||||||
|
action: () => this.app.tabs.closeActive(),
|
||||||
|
});
|
||||||
|
cmds.push({
|
||||||
|
id: 'next-tab',
|
||||||
|
label: 'Next Tab',
|
||||||
|
section: 'Tabs',
|
||||||
|
shortcut: '⌘Tab',
|
||||||
|
action: () => {
|
||||||
|
const next = this.app.tabs.getNextTab();
|
||||||
|
if (next) this.app.tabs.setActive(next);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cmds.push({
|
||||||
|
id: 'prev-tab',
|
||||||
|
label: 'Previous Tab',
|
||||||
|
section: 'Tabs',
|
||||||
|
shortcut: '⌘⇧Tab',
|
||||||
|
action: () => {
|
||||||
|
const prev = this.app.tabs.getPrevTab();
|
||||||
|
if (prev) this.app.tabs.setActive(prev);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings & utility
|
||||||
|
cmds.push({
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Settings',
|
||||||
|
section: 'Application',
|
||||||
|
action: () => this.app.settings.open(),
|
||||||
|
});
|
||||||
|
cmds.push({
|
||||||
|
id: 'import-profiles',
|
||||||
|
label: 'Import Profiles',
|
||||||
|
section: 'Application',
|
||||||
|
action: () => this.app.importProfiles(),
|
||||||
|
});
|
||||||
|
cmds.push({
|
||||||
|
id: 'export-profiles',
|
||||||
|
label: 'Export Profiles',
|
||||||
|
section: 'Application',
|
||||||
|
action: () => this.app.exportProfiles(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Secret store
|
||||||
|
if (this.app.state.secretStoreUnlocked) {
|
||||||
|
cmds.push({
|
||||||
|
id: 'lock-secrets',
|
||||||
|
label: 'Lock Secret Store',
|
||||||
|
section: 'Security',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.LockSecretStore();
|
||||||
|
this.app.state.setSecretStoreState(false);
|
||||||
|
this.app.notifications.info('Secret store locked');
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to lock: ' + err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cmds.push({
|
||||||
|
id: 'unlock-secrets',
|
||||||
|
label: 'Unlock Secret Store',
|
||||||
|
section: 'Security',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
||||||
|
await window.go.api.Service.UnlockSecretStore(pw);
|
||||||
|
this.app.state.setSecretStoreState(true);
|
||||||
|
this.app.notifications.success('Secret store unlocked');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message !== 'User cancelled') {
|
||||||
|
this.app.notifications.error('Unlock failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal actions (only when a session is active)
|
||||||
|
if (this.app.state.activeSessionId) {
|
||||||
|
cmds.push({
|
||||||
|
id: 'clear-terminal',
|
||||||
|
label: 'Clear Terminal',
|
||||||
|
section: 'Terminal',
|
||||||
|
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
|
||||||
|
});
|
||||||
|
cmds.push({
|
||||||
|
id: 'reset-terminal',
|
||||||
|
label: 'Reset Terminal',
|
||||||
|
section: 'Terminal',
|
||||||
|
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns commands filtered and sorted by the current query.
|
||||||
|
* @param {string} query
|
||||||
|
* @returns {Array<Object>}
|
||||||
|
*/
|
||||||
|
filter(query) {
|
||||||
|
if (!query) return this.getCommands();
|
||||||
|
return this.getCommands()
|
||||||
|
.map((cmd) => {
|
||||||
|
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
|
||||||
|
return { ...cmd, ...result };
|
||||||
|
})
|
||||||
|
.filter((c) => c.match)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a command and closes the palette.
|
||||||
|
* @param {Object} command
|
||||||
|
*/
|
||||||
|
execute(command) {
|
||||||
|
this.close();
|
||||||
|
if (command && typeof command.action === 'function') {
|
||||||
|
command.action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_render() {
|
||||||
|
const overlay = createElement('div', 'palette-overlay');
|
||||||
|
const palette = createElement('div', 'palette');
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
const input = createElement('input', 'palette-input', {
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Type a command…',
|
||||||
|
autocomplete: 'off',
|
||||||
|
spellcheck: 'false',
|
||||||
|
});
|
||||||
|
|
||||||
|
palette.appendChild(input);
|
||||||
|
|
||||||
|
// Results list
|
||||||
|
const list = createElement('div', 'palette-list');
|
||||||
|
palette.appendChild(list);
|
||||||
|
|
||||||
|
overlay.appendChild(palette);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
this._overlay = overlay;
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
this._renderList(list, this.filter(''));
|
||||||
|
|
||||||
|
// Focus the input
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
this._query = input.value;
|
||||||
|
this._selectedIndex = 0;
|
||||||
|
this._renderList(list, this.filter(this._query));
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
const items = list.querySelectorAll('.palette-item');
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
|
||||||
|
this._highlightItem(items);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
|
||||||
|
this._highlightItem(items);
|
||||||
|
break;
|
||||||
|
case 'Enter': {
|
||||||
|
e.preventDefault();
|
||||||
|
const selected = items[this._selectedIndex];
|
||||||
|
if (selected && selected._command) {
|
||||||
|
this.execute(selected._command);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the filtered command list.
|
||||||
|
* @param {HTMLElement} list
|
||||||
|
* @param {Array<Object>} commands
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderList(list, commands) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
let currentSection = null;
|
||||||
|
|
||||||
|
if (commands.length === 0) {
|
||||||
|
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < commands.length; i++) {
|
||||||
|
const cmd = commands[i];
|
||||||
|
|
||||||
|
// Section header
|
||||||
|
if (cmd.section && cmd.section !== currentSection) {
|
||||||
|
currentSection = cmd.section;
|
||||||
|
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
|
||||||
|
list.appendChild(sectionEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
|
||||||
|
item._command = cmd;
|
||||||
|
|
||||||
|
// Label
|
||||||
|
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
|
||||||
|
item.appendChild(label);
|
||||||
|
|
||||||
|
// Detail
|
||||||
|
if (cmd.detail) {
|
||||||
|
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
|
||||||
|
item.appendChild(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shortcut badge
|
||||||
|
if (cmd.shortcut) {
|
||||||
|
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
|
||||||
|
item.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.addEventListener('click', () => this.execute(cmd));
|
||||||
|
item.addEventListener('mouseenter', () => {
|
||||||
|
this._selectedIndex = i;
|
||||||
|
this._highlightItem(list.querySelectorAll('.palette-item'));
|
||||||
|
});
|
||||||
|
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlights the currently selected item and scrolls it into view.
|
||||||
|
* @param {NodeListOf<Element>} items
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_highlightItem(items) {
|
||||||
|
items.forEach((el, i) => {
|
||||||
|
el.classList.toggle('palette-item-active', i === this._selectedIndex);
|
||||||
|
});
|
||||||
|
const active = items[this._selectedIndex];
|
||||||
|
if (active) {
|
||||||
|
active.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,898 @@
|
|||||||
|
/**
|
||||||
|
* Nexus Terminal — connection profile editor.
|
||||||
|
* Renders a slide-out drawer for creating and editing connection profiles.
|
||||||
|
* Dynamically switches form sections based on the selected protocol.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createElement, escapeHtml, generateId, $ } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default values mirroring the Go backend defaults.
|
||||||
|
*/
|
||||||
|
const DEFAULTS = {
|
||||||
|
ssh: {
|
||||||
|
port: 22,
|
||||||
|
protocol: 'ssh',
|
||||||
|
ssh: {
|
||||||
|
authMethods: ['publickey', 'password', 'keyboard-interactive'],
|
||||||
|
secretMode: 'ask-every-time',
|
||||||
|
keySecretMode: 'ask-every-time',
|
||||||
|
hostKeyPolicy: 'ask',
|
||||||
|
terminalType: 'xterm-256color',
|
||||||
|
keepaliveInterval: 30,
|
||||||
|
keepaliveCountMax: 3,
|
||||||
|
compression: false,
|
||||||
|
jumpHosts: [],
|
||||||
|
startupCommand: '',
|
||||||
|
envVars: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
telnet: {
|
||||||
|
port: 23,
|
||||||
|
protocol: 'telnet',
|
||||||
|
telnet: {
|
||||||
|
localEcho: false,
|
||||||
|
lineEnding: 'crlf',
|
||||||
|
terminalType: 'xterm-256color',
|
||||||
|
encoding: 'utf-8',
|
||||||
|
negotiationDebug: false,
|
||||||
|
lineMode: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raw: {
|
||||||
|
port: 23,
|
||||||
|
protocol: 'raw',
|
||||||
|
raw: {
|
||||||
|
autoReconnect: false,
|
||||||
|
lineEnding: 'crlf',
|
||||||
|
encoding: 'utf-8',
|
||||||
|
showTimestamps: false,
|
||||||
|
showDirection: false,
|
||||||
|
payloadPresets: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ProfileEditor {
|
||||||
|
/**
|
||||||
|
* @param {import('./app.js').App} app
|
||||||
|
*/
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
this.drawer = null;
|
||||||
|
|
||||||
|
/** @type {Object|null} The profile currently being edited. */
|
||||||
|
this._profile = null;
|
||||||
|
|
||||||
|
/** @type {boolean} */
|
||||||
|
this._isNew = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the editor with a blank profile of the given protocol.
|
||||||
|
* @param {string} [protocol='ssh']
|
||||||
|
*/
|
||||||
|
openNew(protocol = 'ssh') {
|
||||||
|
const profile = this._defaultProfile(protocol);
|
||||||
|
this._isNew = true;
|
||||||
|
this._profile = profile;
|
||||||
|
this.render(profile, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the editor pre-populated with an existing profile.
|
||||||
|
* @param {string} profileId
|
||||||
|
*/
|
||||||
|
openEdit(profileId) {
|
||||||
|
const profile = this.app.state.getProfile(profileId);
|
||||||
|
if (!profile) {
|
||||||
|
this.app.notifications.error('Profile not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._isNew = false;
|
||||||
|
this._profile = JSON.parse(JSON.stringify(profile)); // deep clone
|
||||||
|
this.render(this._profile, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders (or re-renders) the editor drawer.
|
||||||
|
* @param {Object} profile
|
||||||
|
* @param {boolean} isNew
|
||||||
|
*/
|
||||||
|
render(profile, isNew) {
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
const overlay = createElement('div', 'drawer-overlay');
|
||||||
|
const drawer = createElement('div', 'drawer');
|
||||||
|
|
||||||
|
// ── Header ──────────────────────────────────────────────
|
||||||
|
const header = createElement('div', 'drawer-header');
|
||||||
|
header.innerHTML = `
|
||||||
|
<h2 class="drawer-title">${isNew ? 'New Connection' : 'Edit Connection'}</h2>
|
||||||
|
<button class="drawer-close" aria-label="Close">×</button>
|
||||||
|
`;
|
||||||
|
drawer.appendChild(header);
|
||||||
|
|
||||||
|
// ── Protocol selector tabs ──────────────────────────────
|
||||||
|
const protocols = ['ssh', 'telnet', 'raw'];
|
||||||
|
const tabBar = createElement('div', 'drawer-protocol-tabs');
|
||||||
|
for (const proto of protocols) {
|
||||||
|
const btn = createElement('button', `drawer-proto-tab ${profile.protocol === proto ? 'active' : ''}`, {
|
||||||
|
textContent: proto.toUpperCase(),
|
||||||
|
dataset: { proto },
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', () => this.handleProtocolChange(proto));
|
||||||
|
tabBar.appendChild(btn);
|
||||||
|
}
|
||||||
|
drawer.appendChild(tabBar);
|
||||||
|
|
||||||
|
// ── Scrollable body ─────────────────────────────────────
|
||||||
|
const body = createElement('div', 'drawer-body');
|
||||||
|
|
||||||
|
// General section (common to all protocols)
|
||||||
|
body.appendChild(this._renderGeneralSection(profile));
|
||||||
|
|
||||||
|
// Protocol-specific sections
|
||||||
|
switch (profile.protocol) {
|
||||||
|
case 'ssh':
|
||||||
|
this._renderSSHSections(profile).forEach((s) => body.appendChild(s));
|
||||||
|
break;
|
||||||
|
case 'telnet':
|
||||||
|
this._renderTelnetSections(profile).forEach((s) => body.appendChild(s));
|
||||||
|
break;
|
||||||
|
case 'raw':
|
||||||
|
this._renderRAWSections(profile).forEach((s) => body.appendChild(s));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal section (SSH and Telnet only)
|
||||||
|
if (profile.protocol !== 'raw') {
|
||||||
|
body.appendChild(this._renderTerminalSection(profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes & Tags section (common)
|
||||||
|
body.appendChild(this._renderNotesSection(profile));
|
||||||
|
|
||||||
|
drawer.appendChild(body);
|
||||||
|
|
||||||
|
// ── Footer ──────────────────────────────────────────────
|
||||||
|
const footer = createElement('div', 'drawer-footer');
|
||||||
|
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
|
||||||
|
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test Connection' });
|
||||||
|
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', () => this.close());
|
||||||
|
testBtn.addEventListener('click', () => this._testConnection());
|
||||||
|
saveBtn.addEventListener('click', () => this._handleSave());
|
||||||
|
|
||||||
|
footer.appendChild(cancelBtn);
|
||||||
|
footer.appendChild(testBtn);
|
||||||
|
footer.appendChild(saveBtn);
|
||||||
|
drawer.appendChild(footer);
|
||||||
|
|
||||||
|
// ── Events ──────────────────────────────────────────────
|
||||||
|
header.querySelector('.drawer-close').addEventListener('click', () => this.close());
|
||||||
|
overlay.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
overlay.appendChild(drawer);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
this.drawer = overlay;
|
||||||
|
|
||||||
|
// Prevent overlay click from reaching through the drawer
|
||||||
|
drawer.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
// Focus the first input
|
||||||
|
const firstInput = drawer.querySelector('input, select, textarea');
|
||||||
|
if (firstInput) firstInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the editor drawer.
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this.drawer) {
|
||||||
|
this.drawer.remove();
|
||||||
|
this.drawer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles protocol change from the tab bar.
|
||||||
|
* @param {string} protocol
|
||||||
|
*/
|
||||||
|
handleProtocolChange(protocol) {
|
||||||
|
if (this._profile.protocol === protocol) return;
|
||||||
|
|
||||||
|
// Preserve common fields, reset protocol-specific ones
|
||||||
|
this._collectFormValues();
|
||||||
|
const base = this._defaultProfile(protocol);
|
||||||
|
base.id = this._profile.id;
|
||||||
|
base.displayName = this._profile.displayName;
|
||||||
|
base.description = this._profile.description;
|
||||||
|
base.host = this._profile.host;
|
||||||
|
base.username = this._profile.username;
|
||||||
|
base.tags = this._profile.tags;
|
||||||
|
base.notes = this._profile.notes;
|
||||||
|
base.favourite = this._profile.favourite;
|
||||||
|
|
||||||
|
this._profile = base;
|
||||||
|
this.render(this._profile, this._isNew);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section renderers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderGeneralSection(profile) {
|
||||||
|
return this._renderSection('General', [
|
||||||
|
this._field('text', 'displayName', 'Display Name', profile.displayName, { placeholder: 'My Server' }),
|
||||||
|
this._field('text', 'host', 'Host', profile.host, { placeholder: '192.168.1.1 or example.com', required: true }),
|
||||||
|
this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true }),
|
||||||
|
this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' }),
|
||||||
|
this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderSSHSections(profile) {
|
||||||
|
const ssh = profile.ssh || {};
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
sections.push(this._renderSection('Authentication', [
|
||||||
|
this._field('select', 'ssh.secretMode', 'Password Mode', ssh.secretMode, {
|
||||||
|
options: [
|
||||||
|
{ value: 'saved', label: 'Saved in vault' },
|
||||||
|
{ value: 'session-only', label: 'Session only' },
|
||||||
|
{ value: 'ask-every-time', label: 'Ask every time' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
this._field('tags', 'ssh.authMethods', 'Auth Methods', (ssh.authMethods || []).join(', '), {
|
||||||
|
help: 'Comma-separated: publickey, password, keyboard-interactive',
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Key & Passphrase
|
||||||
|
sections.push(this._renderSection('Key & Passphrase', [
|
||||||
|
this._field('text', 'ssh.privateKeyPath', 'Private Key Path', ssh.privateKeyPath, {
|
||||||
|
placeholder: '~/.ssh/id_ed25519',
|
||||||
|
}),
|
||||||
|
this._field('select', 'ssh.keySecretMode', 'Passphrase Mode', ssh.keySecretMode, {
|
||||||
|
options: [
|
||||||
|
{ value: 'saved', label: 'Saved in vault' },
|
||||||
|
{ value: 'session-only', label: 'Session only' },
|
||||||
|
{ value: 'ask-every-time', label: 'Ask every time' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Host Verification
|
||||||
|
sections.push(this._renderSection('Host Verification', [
|
||||||
|
this._field('select', 'ssh.hostKeyPolicy', 'Host Key Policy', ssh.hostKeyPolicy, {
|
||||||
|
options: [
|
||||||
|
{ value: 'strict', label: 'Strict (reject unknown)' },
|
||||||
|
{ value: 'ask', label: 'Ask (prompt on unknown)' },
|
||||||
|
{ value: 'accept-new', label: 'Accept new keys' },
|
||||||
|
{ value: 'insecure', label: 'Insecure (accept all)' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Jump Hosts
|
||||||
|
sections.push(this._renderSection('Jump Hosts', [
|
||||||
|
this._renderJumpHostsList(ssh.jumpHosts || []),
|
||||||
|
], true));
|
||||||
|
|
||||||
|
// Startup
|
||||||
|
sections.push(this._renderSection('Startup', [
|
||||||
|
this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, {
|
||||||
|
placeholder: 'Optional command to run after connecting',
|
||||||
|
}),
|
||||||
|
this._field('textarea', 'ssh.envVarsText', 'Environment Variables', this._envVarsToText(ssh.envVars), {
|
||||||
|
placeholder: 'KEY=VALUE (one per line)',
|
||||||
|
rows: 3,
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Advanced
|
||||||
|
sections.push(this._renderSection('Advanced', [
|
||||||
|
this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }),
|
||||||
|
this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }),
|
||||||
|
this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }),
|
||||||
|
this._field('toggle', 'ssh.compression', 'Compression', ssh.compression),
|
||||||
|
], true));
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderTelnetSections(profile) {
|
||||||
|
const tel = profile.telnet || {};
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Login', [
|
||||||
|
this._field('toggle', 'telnet.localEcho', 'Local Echo', tel.localEcho),
|
||||||
|
this._field('toggle', 'telnet.lineMode', 'Line Mode', tel.lineMode),
|
||||||
|
]));
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Terminal & Encoding', [
|
||||||
|
this._field('text', 'telnet.terminalType', 'Terminal Type', tel.terminalType, { placeholder: 'xterm-256color' }),
|
||||||
|
this._field('select', 'telnet.encoding', 'Encoding', tel.encoding, {
|
||||||
|
options: [
|
||||||
|
{ value: 'utf-8', label: 'UTF-8' },
|
||||||
|
{ value: 'ascii', label: 'ASCII' },
|
||||||
|
{ value: 'iso-8859-1', label: 'ISO 8859-1' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Echo & Line Endings', [
|
||||||
|
this._field('select', 'telnet.lineEnding', 'Line Ending', tel.lineEnding, {
|
||||||
|
options: [
|
||||||
|
{ value: 'crlf', label: 'CR+LF (\\r\\n)' },
|
||||||
|
{ value: 'lf', label: 'LF (\\n)' },
|
||||||
|
{ value: 'cr', label: 'CR (\\r)' },
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Negotiation', [
|
||||||
|
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
|
||||||
|
], true));
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderRAWSections(profile) {
|
||||||
|
const raw = profile.raw || {};
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Encoding & Framing', [
|
||||||
|
this._field('select', 'raw.encoding', 'Encoding', raw.encoding, {
|
||||||
|
options: [
|
||||||
|
{ value: 'utf-8', label: 'UTF-8' },
|
||||||
|
{ value: 'ascii', label: 'ASCII' },
|
||||||
|
{ value: 'hex', label: 'Hex' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
this._field('select', 'raw.lineEnding', 'Line Ending', raw.lineEnding, {
|
||||||
|
options: [
|
||||||
|
{ value: 'crlf', label: 'CR+LF (\\r\\n)' },
|
||||||
|
{ value: 'lf', label: 'LF (\\n)' },
|
||||||
|
{ value: 'cr', label: 'CR (\\r)' },
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Display', [
|
||||||
|
this._field('toggle', 'raw.showTimestamps', 'Show Timestamps', raw.showTimestamps),
|
||||||
|
this._field('toggle', 'raw.showDirection', 'Show Direction', raw.showDirection),
|
||||||
|
]));
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Payload Presets', [
|
||||||
|
this._renderPayloadPresetsList(raw.payloadPresets || []),
|
||||||
|
], true));
|
||||||
|
|
||||||
|
sections.push(this._renderSection('Timeouts & Reconnect', [
|
||||||
|
this._field('number', 'connectTimeout', 'Connect Timeout (s)', profile.connectTimeout, { min: 0 }),
|
||||||
|
this._field('number', 'readTimeout', 'Read Timeout (s)', profile.readTimeout, { min: 0 }),
|
||||||
|
this._field('toggle', 'raw.autoReconnect', 'Auto-reconnect', raw.autoReconnect),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderTerminalSection(profile) {
|
||||||
|
return this._renderSection('Terminal', [
|
||||||
|
this._field('text', 'terminalFontFamily', 'Font Family', profile.terminalFontFamily, {
|
||||||
|
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
|
||||||
|
}),
|
||||||
|
this._field('number', 'terminalFontSize', 'Font Size', profile.terminalFontSize, { min: 8, max: 36 }),
|
||||||
|
this._field('number', 'terminalScrollback', 'Scrollback Lines', profile.terminalScrollback, { min: 0, max: 100000 }),
|
||||||
|
this._field('select', 'terminalCursorStyle', 'Cursor Style', profile.terminalCursorStyle, {
|
||||||
|
options: [
|
||||||
|
{ value: 'block', label: 'Block' },
|
||||||
|
{ value: 'underline', label: 'Underline' },
|
||||||
|
{ value: 'bar', label: 'Bar' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderNotesSection(profile) {
|
||||||
|
return this._renderSection('Notes & Tags', [
|
||||||
|
this._field('tags', 'tags', 'Tags', (profile.tags || []).join(', '), {
|
||||||
|
placeholder: 'production, london, vpn',
|
||||||
|
help: 'Comma-separated tags for filtering',
|
||||||
|
}),
|
||||||
|
this._field('textarea', 'notes', 'Notes', profile.notes, {
|
||||||
|
placeholder: 'Free-form notes…',
|
||||||
|
rows: 4,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a collapsible form section.
|
||||||
|
* @param {string} title
|
||||||
|
* @param {HTMLElement[]} fields
|
||||||
|
* @param {boolean} [startCollapsed=false]
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderSection(title, fields, startCollapsed = false) {
|
||||||
|
const section = createElement('div', 'form-section');
|
||||||
|
const header = createElement('div', 'form-section-header');
|
||||||
|
let collapsed = startCollapsed;
|
||||||
|
|
||||||
|
header.innerHTML = `
|
||||||
|
<span class="form-section-chevron">${collapsed ? '▸' : '▾'}</span>
|
||||||
|
<span class="form-section-title">${escapeHtml(title)}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const content = createElement('div', 'form-section-content');
|
||||||
|
if (collapsed) content.style.display = 'none';
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field instanceof HTMLElement) {
|
||||||
|
content.appendChild(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
collapsed = !collapsed;
|
||||||
|
content.style.display = collapsed ? 'none' : 'block';
|
||||||
|
header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(header);
|
||||||
|
section.appendChild(content);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single form field.
|
||||||
|
* @param {string} type - text | number | select | toggle | textarea | tags | file
|
||||||
|
* @param {string} name - Dot-notated field name, e.g. "ssh.secretMode".
|
||||||
|
* @param {string} label
|
||||||
|
* @param {*} value
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_field(type, name, label, value, opts = {}) {
|
||||||
|
const group = createElement('div', 'form-group');
|
||||||
|
|
||||||
|
// Label
|
||||||
|
const labelEl = createElement('label', 'form-label', {
|
||||||
|
textContent: label,
|
||||||
|
for: `field-${name}`,
|
||||||
|
});
|
||||||
|
group.appendChild(labelEl);
|
||||||
|
|
||||||
|
// Input element based on type
|
||||||
|
let input;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'text':
|
||||||
|
case 'file':
|
||||||
|
input = createElement('input', 'form-input', {
|
||||||
|
type: type === 'file' ? 'text' : 'text',
|
||||||
|
id: `field-${name}`,
|
||||||
|
name,
|
||||||
|
value: value || '',
|
||||||
|
placeholder: opts.placeholder || '',
|
||||||
|
});
|
||||||
|
if (opts.required) input.required = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
input = createElement('input', 'form-input', {
|
||||||
|
type: 'number',
|
||||||
|
id: `field-${name}`,
|
||||||
|
name,
|
||||||
|
value: value != null ? String(value) : '',
|
||||||
|
});
|
||||||
|
if (opts.min != null) input.min = opts.min;
|
||||||
|
if (opts.max != null) input.max = opts.max;
|
||||||
|
if (opts.required) input.required = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'select': {
|
||||||
|
input = createElement('select', 'form-select', {
|
||||||
|
id: `field-${name}`,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
for (const opt of (opts.options || [])) {
|
||||||
|
const option = createElement('option', '', {
|
||||||
|
value: opt.value,
|
||||||
|
textContent: opt.label,
|
||||||
|
});
|
||||||
|
if (opt.value === value) option.selected = true;
|
||||||
|
input.appendChild(option);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'toggle': {
|
||||||
|
const wrapper = createElement('div', 'form-toggle-wrapper');
|
||||||
|
input = createElement('input', 'form-toggle', {
|
||||||
|
type: 'checkbox',
|
||||||
|
id: `field-${name}`,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
input.checked = !!value;
|
||||||
|
const slider = createElement('label', 'form-toggle-label', { for: `field-${name}` });
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
wrapper.appendChild(slider);
|
||||||
|
group.appendChild(wrapper);
|
||||||
|
// Help text
|
||||||
|
if (opts.help) {
|
||||||
|
group.appendChild(createElement('span', 'form-hint', { textContent: opts.help }));
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
input = createElement('textarea', 'form-textarea', {
|
||||||
|
id: `field-${name}`,
|
||||||
|
name,
|
||||||
|
placeholder: opts.placeholder || '',
|
||||||
|
});
|
||||||
|
input.rows = opts.rows || 3;
|
||||||
|
input.textContent = value || '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tags':
|
||||||
|
input = createElement('input', 'form-input', {
|
||||||
|
type: 'text',
|
||||||
|
id: `field-${name}`,
|
||||||
|
name,
|
||||||
|
value: value || '',
|
||||||
|
placeholder: opts.placeholder || '',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
input = createElement('input', 'form-input', {
|
||||||
|
type: 'text',
|
||||||
|
id: `field-${name}`,
|
||||||
|
name,
|
||||||
|
value: value || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.appendChild(input);
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
if (opts.help) {
|
||||||
|
group.appendChild(createElement('span', 'form-hint', { textContent: opts.help }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the jump-hosts list editor.
|
||||||
|
* @param {Array<Object>} jumpHosts
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderJumpHostsList(jumpHosts) {
|
||||||
|
const wrapper = createElement('div', 'jump-hosts-list');
|
||||||
|
|
||||||
|
for (let i = 0; i < jumpHosts.length; i++) {
|
||||||
|
const jh = jumpHosts[i];
|
||||||
|
const row = createElement('div', 'jump-host-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<input class="form-input form-input-sm" name="jumpHost-${i}-host" value="${escapeHtml(jh.host || '')}" placeholder="Host" />
|
||||||
|
<input class="form-input form-input-sm" name="jumpHost-${i}-port" type="number" value="${jh.port || 22}" min="1" max="65535" style="width:80px" />
|
||||||
|
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || '')}" placeholder="User" />
|
||||||
|
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">×</button>
|
||||||
|
`;
|
||||||
|
row.querySelector('.jump-host-remove').addEventListener('click', () => {
|
||||||
|
jumpHosts.splice(i, 1);
|
||||||
|
this.render(this._profile, this._isNew);
|
||||||
|
});
|
||||||
|
wrapper.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Jump Host' });
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
jumpHosts.push({ host: '', port: 22, username: '', authMethod: 'publickey', secretMode: 'ask-every-time', privateKeyPath: '' });
|
||||||
|
this.render(this._profile, this._isNew);
|
||||||
|
});
|
||||||
|
wrapper.appendChild(addBtn);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the payload-presets list editor.
|
||||||
|
* @param {Array<Object>} presets
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderPayloadPresetsList(presets) {
|
||||||
|
const wrapper = createElement('div', 'payload-presets-list');
|
||||||
|
|
||||||
|
for (let i = 0; i < presets.length; i++) {
|
||||||
|
const ps = presets[i];
|
||||||
|
const row = createElement('div', 'preset-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<input class="form-input form-input-sm" name="preset-${i}-name" value="${escapeHtml(ps.name || '')}" placeholder="Name" />
|
||||||
|
<input class="form-input form-input-sm" name="preset-${i}-payload" value="${escapeHtml(ps.payload || '')}" placeholder="Payload" />
|
||||||
|
<select class="form-select form-select-sm" name="preset-${i}-format">
|
||||||
|
<option value="text" ${ps.format === 'text' ? 'selected' : ''}>Text</option>
|
||||||
|
<option value="hex" ${ps.format === 'hex' ? 'selected' : ''}>Hex</option>
|
||||||
|
<option value="escaped" ${ps.format === 'escaped' ? 'selected' : ''}>Escaped</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-icon btn-danger preset-remove" data-index="${i}" title="Remove">×</button>
|
||||||
|
`;
|
||||||
|
row.querySelector('.preset-remove').addEventListener('click', () => {
|
||||||
|
presets.splice(i, 1);
|
||||||
|
this.render(this._profile, this._isNew);
|
||||||
|
});
|
||||||
|
wrapper.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Preset' });
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
presets.push({ id: generateId(), name: '', payload: '', format: 'text' });
|
||||||
|
this.render(this._profile, this._isNew);
|
||||||
|
});
|
||||||
|
wrapper.appendChild(addBtn);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form data collection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads current form values into this._profile.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_collectFormValues() {
|
||||||
|
if (!this.drawer) return;
|
||||||
|
|
||||||
|
const val = (name) => {
|
||||||
|
const el = this.drawer.querySelector(`[name="${name}"]`);
|
||||||
|
if (!el) return undefined;
|
||||||
|
if (el.type === 'checkbox') return el.checked;
|
||||||
|
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
|
||||||
|
return el.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = this._profile;
|
||||||
|
|
||||||
|
// General
|
||||||
|
p.displayName = val('displayName') || '';
|
||||||
|
p.host = val('host') || '';
|
||||||
|
p.port = val('port') || p.port;
|
||||||
|
p.username = val('username') || '';
|
||||||
|
p.description = val('description') || '';
|
||||||
|
p.terminalFontFamily = val('terminalFontFamily') || '';
|
||||||
|
p.terminalFontSize = val('terminalFontSize') || 14;
|
||||||
|
p.terminalScrollback = val('terminalScrollback') || 10000;
|
||||||
|
p.terminalCursorStyle = val('terminalCursorStyle') || 'block';
|
||||||
|
p.connectTimeout = val('connectTimeout') || 30;
|
||||||
|
p.readTimeout = val('readTimeout') || 0;
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
const tagsStr = val('tags');
|
||||||
|
if (tagsStr !== undefined) {
|
||||||
|
p.tags = tagsStr.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
const notes = val('notes');
|
||||||
|
if (notes !== undefined) p.notes = notes;
|
||||||
|
|
||||||
|
// SSH-specific
|
||||||
|
if (p.protocol === 'ssh' && p.ssh) {
|
||||||
|
p.ssh.secretMode = val('ssh.secretMode') || p.ssh.secretMode;
|
||||||
|
p.ssh.privateKeyPath = val('ssh.privateKeyPath') || '';
|
||||||
|
p.ssh.keySecretMode = val('ssh.keySecretMode') || p.ssh.keySecretMode;
|
||||||
|
p.ssh.hostKeyPolicy = val('ssh.hostKeyPolicy') || p.ssh.hostKeyPolicy;
|
||||||
|
p.ssh.terminalType = val('ssh.terminalType') || 'xterm-256color';
|
||||||
|
p.ssh.keepaliveInterval = val('ssh.keepaliveInterval') ?? 30;
|
||||||
|
p.ssh.keepaliveCountMax = val('ssh.keepaliveCountMax') ?? 3;
|
||||||
|
p.ssh.compression = val('ssh.compression') || false;
|
||||||
|
p.ssh.startupCommand = val('ssh.startupCommand') || '';
|
||||||
|
|
||||||
|
const authStr = val('ssh.authMethods');
|
||||||
|
if (authStr !== undefined) {
|
||||||
|
p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env vars from textarea
|
||||||
|
const envText = val('ssh.envVarsText');
|
||||||
|
if (envText !== undefined) {
|
||||||
|
p.ssh.envVars = {};
|
||||||
|
envText.split('\n').forEach((line) => {
|
||||||
|
const eq = line.indexOf('=');
|
||||||
|
if (eq > 0) {
|
||||||
|
p.ssh.envVars[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump hosts
|
||||||
|
const jh = [];
|
||||||
|
let ji = 0;
|
||||||
|
while (this.drawer.querySelector(`[name="jumpHost-${ji}-host"]`)) {
|
||||||
|
jh.push({
|
||||||
|
host: val(`jumpHost-${ji}-host`) || '',
|
||||||
|
port: val(`jumpHost-${ji}-port`) || 22,
|
||||||
|
username: val(`jumpHost-${ji}-username`) || '',
|
||||||
|
authMethod: 'publickey',
|
||||||
|
secretMode: 'ask-every-time',
|
||||||
|
privateKeyPath: '',
|
||||||
|
});
|
||||||
|
ji++;
|
||||||
|
}
|
||||||
|
p.ssh.jumpHosts = jh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telnet-specific
|
||||||
|
if (p.protocol === 'telnet' && p.telnet) {
|
||||||
|
p.telnet.localEcho = val('telnet.localEcho') || false;
|
||||||
|
p.telnet.lineMode = val('telnet.lineMode') || false;
|
||||||
|
p.telnet.terminalType = val('telnet.terminalType') || 'xterm-256color';
|
||||||
|
p.telnet.encoding = val('telnet.encoding') || 'utf-8';
|
||||||
|
p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf';
|
||||||
|
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAW-specific
|
||||||
|
if (p.protocol === 'raw' && p.raw) {
|
||||||
|
p.raw.encoding = val('raw.encoding') || 'utf-8';
|
||||||
|
p.raw.lineEnding = val('raw.lineEnding') || 'crlf';
|
||||||
|
p.raw.showTimestamps = val('raw.showTimestamps') || false;
|
||||||
|
p.raw.showDirection = val('raw.showDirection') || false;
|
||||||
|
p.raw.autoReconnect = val('raw.autoReconnect') || false;
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
const presets = [];
|
||||||
|
let pi = 0;
|
||||||
|
while (this.drawer.querySelector(`[name="preset-${pi}-name"]`)) {
|
||||||
|
presets.push({
|
||||||
|
id: (p.raw.payloadPresets && p.raw.payloadPresets[pi]?.id) || generateId(),
|
||||||
|
name: val(`preset-${pi}-name`) || '',
|
||||||
|
payload: val(`preset-${pi}-payload`) || '',
|
||||||
|
format: val(`preset-${pi}-format`) || 'text',
|
||||||
|
});
|
||||||
|
pi++;
|
||||||
|
}
|
||||||
|
p.raw.payloadPresets = presets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the collected profile. Returns an array of error strings (empty = valid).
|
||||||
|
* @param {Object} profile
|
||||||
|
* @returns {string[]}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_validate(profile) {
|
||||||
|
const errors = [];
|
||||||
|
if (!profile.host || !profile.host.trim()) {
|
||||||
|
errors.push('Host is required.');
|
||||||
|
}
|
||||||
|
if (!profile.port || profile.port < 1 || profile.port > 65535) {
|
||||||
|
errors.push('Port must be between 1 and 65535.');
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
async _handleSave() {
|
||||||
|
this._collectFormValues();
|
||||||
|
const errors = this._validate(this._profile);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
this.app.notifications.warning(errors.join(' '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this._isNew) {
|
||||||
|
const saved = await window.go.api.Service.CreateProfile(this._profile);
|
||||||
|
this.app.state.addProfile(saved);
|
||||||
|
this.app.notifications.success('Profile created');
|
||||||
|
} else {
|
||||||
|
const saved = await window.go.api.Service.UpdateProfile(this._profile);
|
||||||
|
this.app.state.updateProfile(saved);
|
||||||
|
this.app.notifications.success('Profile updated');
|
||||||
|
}
|
||||||
|
this.app.sidebar.render();
|
||||||
|
this.close();
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Save failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
async _testConnection() {
|
||||||
|
this._collectFormValues();
|
||||||
|
const errors = this._validate(this._profile);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
this.app.notifications.warning(errors.join(' '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.app.notifications.info('Testing connection…');
|
||||||
|
try {
|
||||||
|
const result = await window.go.api.Service.TestConnection(this._profile);
|
||||||
|
if (result && result.success) {
|
||||||
|
this.app.notifications.success('Connection successful!');
|
||||||
|
} else {
|
||||||
|
this.app.notifications.error('Connection failed: ' + (result?.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Test failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a default profile object for the given protocol.
|
||||||
|
* @param {string} protocol
|
||||||
|
* @returns {Object}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_defaultProfile(protocol) {
|
||||||
|
const base = {
|
||||||
|
id: generateId(),
|
||||||
|
displayName: '',
|
||||||
|
description: '',
|
||||||
|
protocol,
|
||||||
|
host: '',
|
||||||
|
port: 0,
|
||||||
|
username: '',
|
||||||
|
tags: [],
|
||||||
|
folder: '',
|
||||||
|
environment: '',
|
||||||
|
notes: '',
|
||||||
|
favourite: false,
|
||||||
|
lastConnected: null,
|
||||||
|
connectTimeout: 30,
|
||||||
|
readTimeout: 0,
|
||||||
|
terminalFontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
|
||||||
|
terminalFontSize: 14,
|
||||||
|
terminalScrollback: 10000,
|
||||||
|
terminalCursorStyle: 'block',
|
||||||
|
ssh: null,
|
||||||
|
telnet: null,
|
||||||
|
raw: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaults = DEFAULTS[protocol] || {};
|
||||||
|
return { ...base, ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an envVars map into "KEY=VALUE\n" text.
|
||||||
|
* @param {Object} envVars
|
||||||
|
* @returns {string}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_envVarsToText(envVars) {
|
||||||
|
if (!envVars) return '';
|
||||||
|
return Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
/**
|
||||||
|
* Nexus Terminal — RAW TCP data viewer.
|
||||||
|
* Provides text, hex, and split display modes for raw socket data,
|
||||||
|
* along with a send bar, pause/resume, and capture saving.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createElement, escapeHtml, formatTimestamp, formatBytes } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of data entries retained in the scrollback buffer.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
const MAX_BUFFER_ENTRIES = 5000;
|
||||||
|
|
||||||
|
export class RawViewer {
|
||||||
|
/**
|
||||||
|
* @param {import('./app.js').App} app
|
||||||
|
*/
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of sessionId → viewer state.
|
||||||
|
* @type {Map<string, Object>}
|
||||||
|
*/
|
||||||
|
this.viewers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a RAW viewer inside the given panel container.
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {string} containerId - DOM id of the panel element.
|
||||||
|
* @param {Object} profile
|
||||||
|
*/
|
||||||
|
createViewer(sessionId, containerId, profile) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
mode: 'text', // text | hex | split
|
||||||
|
paused: false,
|
||||||
|
buffer: [],
|
||||||
|
totalBytes: 0,
|
||||||
|
sendFormat: 'text', // text | hex | escaped
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Toolbar ──────────────────────────────────────────────
|
||||||
|
const toolbar = createElement('div', 'raw-toolbar');
|
||||||
|
|
||||||
|
// Mode toggle
|
||||||
|
const modeGroup = createElement('div', 'raw-mode-group');
|
||||||
|
for (const mode of ['text', 'hex', 'split']) {
|
||||||
|
const btn = createElement('button', `raw-mode-btn ${mode === state.mode ? 'active' : ''}`, {
|
||||||
|
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
|
||||||
|
dataset: { mode },
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', () => this._setMode(sessionId, mode));
|
||||||
|
modeGroup.appendChild(btn);
|
||||||
|
}
|
||||||
|
toolbar.appendChild(modeGroup);
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
const controls = createElement('div', 'raw-controls');
|
||||||
|
|
||||||
|
const pauseBtn = createElement('button', 'btn btn-sm btn-outline raw-pause', { textContent: '⏸ Pause' });
|
||||||
|
pauseBtn.addEventListener('click', () => this.togglePause(sessionId));
|
||||||
|
controls.appendChild(pauseBtn);
|
||||||
|
|
||||||
|
const clearBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '✕ Clear' });
|
||||||
|
clearBtn.addEventListener('click', () => this.clearBuffer(sessionId));
|
||||||
|
controls.appendChild(clearBtn);
|
||||||
|
|
||||||
|
const saveBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '💾 Save' });
|
||||||
|
saveBtn.addEventListener('click', () => this.saveCapture(sessionId));
|
||||||
|
controls.appendChild(saveBtn);
|
||||||
|
|
||||||
|
// Byte counter
|
||||||
|
const counter = createElement('span', 'raw-counter', { textContent: '0 B' });
|
||||||
|
controls.appendChild(counter);
|
||||||
|
|
||||||
|
toolbar.appendChild(controls);
|
||||||
|
container.appendChild(toolbar);
|
||||||
|
|
||||||
|
// ── Data display ─────────────────────────────────────────
|
||||||
|
const display = createElement('div', 'raw-display');
|
||||||
|
container.appendChild(display);
|
||||||
|
|
||||||
|
// ── Send bar ─────────────────────────────────────────────
|
||||||
|
const sendBar = this._renderSendBar(sessionId, profile);
|
||||||
|
container.appendChild(sendBar);
|
||||||
|
|
||||||
|
this.viewers.set(sessionId, {
|
||||||
|
...state,
|
||||||
|
container,
|
||||||
|
display,
|
||||||
|
toolbar,
|
||||||
|
pauseBtn,
|
||||||
|
counter,
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes incoming data from the backend.
|
||||||
|
* @param {Object} payload - { sessionID, data, direction, timestamp }
|
||||||
|
*/
|
||||||
|
handleData(payload) {
|
||||||
|
const viewer = this.viewers.get(payload.sessionID);
|
||||||
|
if (!viewer) return;
|
||||||
|
|
||||||
|
// Decode base64 payload
|
||||||
|
let rawStr;
|
||||||
|
try {
|
||||||
|
rawStr = atob(payload.data);
|
||||||
|
} catch (_) {
|
||||||
|
rawStr = payload.data || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(rawStr.length);
|
||||||
|
for (let i = 0; i < rawStr.length; i++) {
|
||||||
|
bytes[i] = rawStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
direction: payload.direction || 'recv', // 'send' | 'recv'
|
||||||
|
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
|
||||||
|
data: rawStr,
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
viewer.buffer.push(entry);
|
||||||
|
viewer.totalBytes += bytes.length;
|
||||||
|
|
||||||
|
// Trim buffer if it exceeds the maximum
|
||||||
|
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
|
||||||
|
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counter
|
||||||
|
viewer.counter.textContent = formatBytes(viewer.totalBytes);
|
||||||
|
|
||||||
|
// Render unless paused
|
||||||
|
if (!viewer.paused) {
|
||||||
|
this._renderEntry(viewer, entry);
|
||||||
|
this._scrollToBottom(viewer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles pause/resume on the data display.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
togglePause(sessionId) {
|
||||||
|
const viewer = this.viewers.get(sessionId);
|
||||||
|
if (!viewer) return;
|
||||||
|
|
||||||
|
viewer.paused = !viewer.paused;
|
||||||
|
viewer.pauseBtn.textContent = viewer.paused ? '▶ Resume' : '⏸ Pause';
|
||||||
|
viewer.pauseBtn.classList.toggle('active', viewer.paused);
|
||||||
|
|
||||||
|
// On resume, flush any buffered entries
|
||||||
|
if (!viewer.paused) {
|
||||||
|
this._renderAllEntries(viewer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the data buffer and display.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
clearBuffer(sessionId) {
|
||||||
|
const viewer = this.viewers.get(sessionId);
|
||||||
|
if (!viewer) return;
|
||||||
|
|
||||||
|
viewer.buffer = [];
|
||||||
|
viewer.totalBytes = 0;
|
||||||
|
viewer.display.innerHTML = '';
|
||||||
|
viewer.counter.textContent = '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user to save the capture buffer.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
async saveCapture(sessionId) {
|
||||||
|
const viewer = this.viewers.get(sessionId);
|
||||||
|
if (!viewer || viewer.buffer.length === 0) {
|
||||||
|
this.app.notifications.warning('Nothing to save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = viewer.buffer.map((e) => {
|
||||||
|
const ts = formatTimestamp(e.timestamp);
|
||||||
|
const dir = e.direction === 'send' ? 'TX' : 'RX';
|
||||||
|
return `[${ts}] [${dir}] ${e.data}`;
|
||||||
|
});
|
||||||
|
const content = lines.join('\n');
|
||||||
|
await window.go.api.Service.SaveCapture(sessionId, content);
|
||||||
|
this.app.notifications.success('Capture saved');
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Save failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends data from the send bar to the backend.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
async send(sessionId) {
|
||||||
|
const viewer = this.viewers.get(sessionId);
|
||||||
|
if (!viewer) return;
|
||||||
|
|
||||||
|
const input = viewer.container.querySelector('.raw-send-input');
|
||||||
|
const formatSelect = viewer.container.querySelector('.raw-send-format');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const rawValue = input.value;
|
||||||
|
if (!rawValue) return;
|
||||||
|
|
||||||
|
const format = formatSelect?.value || 'text';
|
||||||
|
let dataToSend;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'hex':
|
||||||
|
dataToSend = this._hexToString(rawValue);
|
||||||
|
break;
|
||||||
|
case 'escaped':
|
||||||
|
dataToSend = this._unescapeString(rawValue);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dataToSend = rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const b64 = btoa(dataToSend);
|
||||||
|
await window.go.api.Service.WriteSession(sessionId, b64);
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
// Also show in viewer as sent data
|
||||||
|
this.handleData({
|
||||||
|
sessionID: sessionId,
|
||||||
|
data: b64,
|
||||||
|
direction: 'send',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Send failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down a viewer and frees resources.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
destroyViewer(sessionId) {
|
||||||
|
this.viewers.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the send bar at the bottom of the viewer.
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {Object} profile
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderSendBar(sessionId, profile) {
|
||||||
|
const bar = createElement('div', 'raw-send-bar');
|
||||||
|
|
||||||
|
// Format selector
|
||||||
|
const formatSelect = createElement('select', 'form-select form-select-sm raw-send-format');
|
||||||
|
for (const [value, label] of [['text', 'Text'], ['hex', 'Hex'], ['escaped', 'Escaped']]) {
|
||||||
|
const opt = createElement('option', '', { value, textContent: label });
|
||||||
|
formatSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
bar.appendChild(formatSelect);
|
||||||
|
|
||||||
|
// Input
|
||||||
|
const input = createElement('input', 'form-input raw-send-input', {
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Type data to send…',
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') this.send(sessionId);
|
||||||
|
});
|
||||||
|
bar.appendChild(input);
|
||||||
|
|
||||||
|
// Quick-insert buttons for common control bytes
|
||||||
|
const quickBtns = createElement('div', 'raw-quick-btns');
|
||||||
|
const controlBytes = [
|
||||||
|
{ label: 'NUL', value: '\\x00' },
|
||||||
|
{ label: 'CR', value: '\\r' },
|
||||||
|
{ label: 'LF', value: '\\n' },
|
||||||
|
{ label: 'ETX', value: '\\x03' },
|
||||||
|
{ label: 'EOT', value: '\\x04' },
|
||||||
|
];
|
||||||
|
for (const cb of controlBytes) {
|
||||||
|
const btn = createElement('button', 'btn btn-xs btn-outline', { textContent: cb.label, title: cb.value });
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
input.value += cb.value;
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
quickBtns.appendChild(btn);
|
||||||
|
}
|
||||||
|
bar.appendChild(quickBtns);
|
||||||
|
|
||||||
|
// Payload preset dropdown
|
||||||
|
const presets = profile?.raw?.payloadPresets || [];
|
||||||
|
if (presets.length > 0) {
|
||||||
|
const presetSelect = createElement('select', 'form-select form-select-sm raw-preset-select');
|
||||||
|
const defaultOpt = createElement('option', '', { value: '', textContent: 'Presets…' });
|
||||||
|
presetSelect.appendChild(defaultOpt);
|
||||||
|
for (const ps of presets) {
|
||||||
|
const opt = createElement('option', '', { value: ps.payload, textContent: ps.name });
|
||||||
|
opt.dataset.format = ps.format;
|
||||||
|
presetSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
presetSelect.addEventListener('change', () => {
|
||||||
|
if (presetSelect.value) {
|
||||||
|
input.value = presetSelect.value;
|
||||||
|
const selOpt = presetSelect.selectedOptions[0];
|
||||||
|
if (selOpt?.dataset.format) {
|
||||||
|
formatSelect.value = selOpt.dataset.format;
|
||||||
|
}
|
||||||
|
presetSelect.value = '';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bar.appendChild(presetSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send button
|
||||||
|
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send' });
|
||||||
|
sendBtn.addEventListener('click', () => this.send(sessionId));
|
||||||
|
bar.appendChild(sendBtn);
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single data entry into the display area.
|
||||||
|
* @param {Object} viewer
|
||||||
|
* @param {Object} entry
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderEntry(viewer, entry) {
|
||||||
|
let el;
|
||||||
|
switch (viewer.mode) {
|
||||||
|
case 'hex':
|
||||||
|
el = this._renderHexView(entry);
|
||||||
|
break;
|
||||||
|
case 'split':
|
||||||
|
el = this._renderSplitView(entry, viewer.profile);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
el = this._renderTextView(entry, viewer.profile);
|
||||||
|
}
|
||||||
|
viewer.display.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-renders all buffered entries (e.g. after mode change or resume).
|
||||||
|
* @param {Object} viewer
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderAllEntries(viewer) {
|
||||||
|
viewer.display.innerHTML = '';
|
||||||
|
for (const entry of viewer.buffer) {
|
||||||
|
this._renderEntry(viewer, entry);
|
||||||
|
}
|
||||||
|
this._scrollToBottom(viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a text-mode entry.
|
||||||
|
* @param {Object} entry
|
||||||
|
* @param {Object} profile
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderTextView(entry, profile) {
|
||||||
|
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
|
||||||
|
const raw = profile?.raw || {};
|
||||||
|
|
||||||
|
let prefix = '';
|
||||||
|
if (raw.showTimestamps) {
|
||||||
|
prefix += `[${formatTimestamp(entry.timestamp)}] `;
|
||||||
|
}
|
||||||
|
if (raw.showDirection) {
|
||||||
|
prefix += entry.direction === 'send' ? '[TX] ' : '[RX] ';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pre = createElement('pre', 'raw-text');
|
||||||
|
pre.textContent = prefix + entry.data;
|
||||||
|
row.appendChild(pre);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a hex-mode entry.
|
||||||
|
* @param {Object} entry
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderHexView(entry) {
|
||||||
|
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
|
||||||
|
const pre = createElement('pre', 'raw-hex');
|
||||||
|
pre.textContent = this.formatHexDump(entry.bytes);
|
||||||
|
row.appendChild(pre);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a split-mode entry (text + hex side by side).
|
||||||
|
* @param {Object} entry
|
||||||
|
* @param {Object} profile
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderSplitView(entry, profile) {
|
||||||
|
const row = createElement('div', `raw-entry raw-entry-split raw-entry-${entry.direction}`);
|
||||||
|
const raw = profile?.raw || {};
|
||||||
|
|
||||||
|
// Direction/timestamp header
|
||||||
|
const header = createElement('div', 'raw-split-header');
|
||||||
|
let headerText = '';
|
||||||
|
if (raw.showTimestamps) headerText += formatTimestamp(entry.timestamp) + ' ';
|
||||||
|
headerText += entry.direction === 'send' ? '▲ TX' : '▼ RX';
|
||||||
|
header.textContent = headerText;
|
||||||
|
row.appendChild(header);
|
||||||
|
|
||||||
|
// Split panes
|
||||||
|
const panes = createElement('div', 'raw-split-panes');
|
||||||
|
|
||||||
|
const textPane = createElement('pre', 'raw-text raw-split-text');
|
||||||
|
textPane.textContent = entry.data;
|
||||||
|
panes.appendChild(textPane);
|
||||||
|
|
||||||
|
const hexPane = createElement('pre', 'raw-hex raw-split-hex');
|
||||||
|
hexPane.textContent = this.formatHexDump(entry.bytes);
|
||||||
|
panes.appendChild(hexPane);
|
||||||
|
|
||||||
|
row.appendChild(panes);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a Uint8Array into a traditional hex dump:
|
||||||
|
* offset | hex bytes | ASCII representation.
|
||||||
|
* @param {Uint8Array} bytes
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
formatHexDump(bytes) {
|
||||||
|
const lines = [];
|
||||||
|
const bytesPerLine = 16;
|
||||||
|
|
||||||
|
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
|
||||||
|
const chunk = bytes.slice(offset, offset + bytesPerLine);
|
||||||
|
|
||||||
|
// Offset column
|
||||||
|
const offsetStr = offset.toString(16).padStart(8, '0');
|
||||||
|
|
||||||
|
// Hex column
|
||||||
|
const hexParts = [];
|
||||||
|
for (let i = 0; i < bytesPerLine; i++) {
|
||||||
|
if (i < chunk.length) {
|
||||||
|
hexParts.push(chunk[i].toString(16).padStart(2, '0'));
|
||||||
|
} else {
|
||||||
|
hexParts.push(' ');
|
||||||
|
}
|
||||||
|
// Extra space after 8th byte for readability
|
||||||
|
if (i === 7) hexParts.push('');
|
||||||
|
}
|
||||||
|
const hexStr = hexParts.join(' ');
|
||||||
|
|
||||||
|
// ASCII column
|
||||||
|
let ascii = '';
|
||||||
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
|
const b = chunk[i];
|
||||||
|
ascii += (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the display mode (text, hex, split) and re-renders.
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {string} mode
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_setMode(sessionId, mode) {
|
||||||
|
const viewer = this.viewers.get(sessionId);
|
||||||
|
if (!viewer) return;
|
||||||
|
|
||||||
|
viewer.mode = mode;
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
viewer.toolbar.querySelectorAll('.raw-mode-btn').forEach((btn) => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._renderAllEntries(viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the data display to the bottom.
|
||||||
|
* @param {Object} viewer
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_scrollToBottom(viewer) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
viewer.display.scrollTop = viewer.display.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a hex string (e.g. "48656c6c6f") into a character string.
|
||||||
|
* @param {string} hex
|
||||||
|
* @returns {string}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hexToString(hex) {
|
||||||
|
const cleaned = hex.replace(/\s+/g, '');
|
||||||
|
let str = '';
|
||||||
|
for (let i = 0; i < cleaned.length; i += 2) {
|
||||||
|
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts C-style escape sequences (\\n, \\r, \\x00, etc.) into their literal bytes.
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {string}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_unescapeString(str) {
|
||||||
|
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
|
||||||
|
switch (seq) {
|
||||||
|
case 'n': return '\n';
|
||||||
|
case 'r': return '\r';
|
||||||
|
case 't': return '\t';
|
||||||
|
case '0': return '\0';
|
||||||
|
case '\\': return '\\';
|
||||||
|
default:
|
||||||
|
// \\xHH
|
||||||
|
if (seq.startsWith('x')) {
|
||||||
|
return String.fromCharCode(parseInt(seq.slice(1), 16));
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
/**
|
||||||
|
* Nexus Terminal — settings modal.
|
||||||
|
* Renders a tabbed settings dialog for application-wide preferences,
|
||||||
|
* terminal defaults, security, known-hosts management, and about info.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createElement, escapeHtml, formatTimestamp, $ } from './utils.js';
|
||||||
|
|
||||||
|
/** Settings tab definitions. */
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'general', label: 'General' },
|
||||||
|
{ key: 'terminal', label: 'Terminal' },
|
||||||
|
{ key: 'security', label: 'Security' },
|
||||||
|
{ key: 'knownhosts', label: 'Known Hosts' },
|
||||||
|
{ key: 'about', label: 'About' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class Settings {
|
||||||
|
/**
|
||||||
|
* @param {import('./app.js').App} app
|
||||||
|
*/
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
|
/** @type {HTMLElement|null} */
|
||||||
|
this._overlay = null;
|
||||||
|
|
||||||
|
/** @type {string} Currently active tab key. */
|
||||||
|
this._activeTab = 'general';
|
||||||
|
|
||||||
|
/** @type {Object} Working copy of preferences. */
|
||||||
|
this._prefs = {};
|
||||||
|
|
||||||
|
/** @type {Array<Object>} Cached known hosts list. */
|
||||||
|
this._knownHosts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the settings modal.
|
||||||
|
*/
|
||||||
|
async open() {
|
||||||
|
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
|
||||||
|
this._activeTab = 'general';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
|
||||||
|
} catch (_) {
|
||||||
|
this._knownHosts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the settings modal.
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this._overlay) {
|
||||||
|
this._overlay.remove();
|
||||||
|
this._overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the modified preferences to the backend.
|
||||||
|
*/
|
||||||
|
async save() {
|
||||||
|
this._collectValues();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.SavePreferences(this._prefs);
|
||||||
|
this.app.state.setPreferences(this._prefs);
|
||||||
|
this.app.notifications.success('Settings saved');
|
||||||
|
this.close();
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to save settings: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_render() {
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
const overlay = createElement('div', 'modal-overlay');
|
||||||
|
const modal = createElement('div', 'modal modal-settings');
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = createElement('div', 'modal-header');
|
||||||
|
header.innerHTML = `
|
||||||
|
<h2 class="modal-title">Settings</h2>
|
||||||
|
<button class="modal-close" aria-label="Close">×</button>
|
||||||
|
`;
|
||||||
|
header.querySelector('.modal-close').addEventListener('click', () => this.close());
|
||||||
|
modal.appendChild(header);
|
||||||
|
|
||||||
|
// Tab bar
|
||||||
|
const tabBar = createElement('div', 'settings-tab-bar');
|
||||||
|
for (const tab of TABS) {
|
||||||
|
const btn = createElement('button', `settings-tab ${tab.key === this._activeTab ? 'active' : ''}`, {
|
||||||
|
textContent: tab.label,
|
||||||
|
dataset: { tab: tab.key },
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this._activeTab = tab.key;
|
||||||
|
this._renderBody(modal);
|
||||||
|
tabBar.querySelectorAll('.settings-tab').forEach((t) => {
|
||||||
|
t.classList.toggle('active', t.dataset.tab === tab.key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tabBar.appendChild(btn);
|
||||||
|
}
|
||||||
|
modal.appendChild(tabBar);
|
||||||
|
|
||||||
|
// Body (tab content)
|
||||||
|
const body = createElement('div', 'modal-body settings-body');
|
||||||
|
modal.appendChild(body);
|
||||||
|
this._renderTabContent(body);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
const footer = createElement('div', 'modal-footer');
|
||||||
|
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
|
||||||
|
const saveBtn = createElement('button', 'btn btn-primary', { textContent: 'Save' });
|
||||||
|
cancelBtn.addEventListener('click', () => this.close());
|
||||||
|
saveBtn.addEventListener('click', () => this.save());
|
||||||
|
footer.appendChild(cancelBtn);
|
||||||
|
footer.appendChild(saveBtn);
|
||||||
|
modal.appendChild(footer);
|
||||||
|
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
|
||||||
|
modal.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
this._overlay = overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-renders only the body when the active tab changes.
|
||||||
|
* @param {HTMLElement} modal
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderBody(modal) {
|
||||||
|
const body = modal.querySelector('.settings-body');
|
||||||
|
if (body) this._renderTabContent(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the content for the currently active tab.
|
||||||
|
* @param {HTMLElement} body
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderTabContent(body) {
|
||||||
|
body.innerHTML = '';
|
||||||
|
switch (this._activeTab) {
|
||||||
|
case 'general': this._renderGeneralTab(body); break;
|
||||||
|
case 'terminal': this._renderTerminalTab(body); break;
|
||||||
|
case 'security': this._renderSecurityTab(body); break;
|
||||||
|
case 'knownhosts': this._renderKnownHostsTab(body); break;
|
||||||
|
case 'about': this._renderAboutTab(body); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab content renderers ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General preferences tab.
|
||||||
|
* @param {HTMLElement} body
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderGeneralTab(body) {
|
||||||
|
const p = this._prefs;
|
||||||
|
body.appendChild(this._settingField('select', 'theme', 'Theme', p.theme, {
|
||||||
|
options: [
|
||||||
|
{ value: 'dark', label: 'Dark' },
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
body.appendChild(this._settingField('select', 'defaultProtocol', 'Default Protocol', p.defaultProtocol, {
|
||||||
|
options: [
|
||||||
|
{ value: 'ssh', label: 'SSH' },
|
||||||
|
{ value: 'telnet', label: 'Telnet' },
|
||||||
|
{ value: 'raw', label: 'RAW' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
body.appendChild(this._settingField('toggle', 'confirmOnClose', 'Confirm on close', p.confirmOnClose));
|
||||||
|
body.appendChild(this._settingField('toggle', 'confirmOnDisconnect', 'Confirm on disconnect', p.confirmOnDisconnect));
|
||||||
|
body.appendChild(this._settingField('toggle', 'showStatusBar', 'Show status bar', p.showStatusBar));
|
||||||
|
body.appendChild(this._settingField('number', 'sidebarWidth', 'Sidebar width (px)', p.sidebarWidth, { min: 180, max: 500 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal defaults tab.
|
||||||
|
* @param {HTMLElement} body
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderTerminalTab(body) {
|
||||||
|
const p = this._prefs;
|
||||||
|
body.appendChild(this._settingField('text', 'defaultFontFamily', 'Font family', p.defaultFontFamily, {
|
||||||
|
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
|
||||||
|
}));
|
||||||
|
body.appendChild(this._settingField('number', 'defaultFontSize', 'Font size', p.defaultFontSize, { min: 8, max: 36 }));
|
||||||
|
body.appendChild(this._settingField('number', 'defaultScrollback', 'Scrollback lines', p.defaultScrollback, { min: 0, max: 100000 }));
|
||||||
|
body.appendChild(this._settingField('select', 'defaultCursorStyle', 'Cursor style', p.defaultCursorStyle, {
|
||||||
|
options: [
|
||||||
|
{ value: 'block', label: 'Block' },
|
||||||
|
{ value: 'underline', label: 'Underline' },
|
||||||
|
{ value: 'bar', label: 'Bar' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security settings tab.
|
||||||
|
* @param {HTMLElement} body
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderSecurityTab(body) {
|
||||||
|
const storeState = this.app.state.secretStoreUnlocked ? 'Unlocked' : 'Locked';
|
||||||
|
const storeInit = this.app.state.secretStoreInitialised;
|
||||||
|
|
||||||
|
const statusRow = createElement('div', 'settings-row');
|
||||||
|
statusRow.innerHTML = `
|
||||||
|
<span class="settings-label">Secret store</span>
|
||||||
|
<span class="settings-value ${this.app.state.secretStoreUnlocked ? 'text-success' : 'text-muted'}">${storeState}</span>
|
||||||
|
`;
|
||||||
|
body.appendChild(statusRow);
|
||||||
|
|
||||||
|
if (!storeInit) {
|
||||||
|
const initBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Initialise Secret Store' });
|
||||||
|
initBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const pw = await this.app.secretPrompt.promptMasterPassword(true);
|
||||||
|
await window.go.api.Service.InitialiseSecretStore(pw);
|
||||||
|
this.app.state.setSecretStoreInitialised(true);
|
||||||
|
this.app.state.setSecretStoreState(true);
|
||||||
|
this.app.notifications.success('Secret store created');
|
||||||
|
this._renderTabContent(body);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message !== 'User cancelled') {
|
||||||
|
this.app.notifications.error('Failed to initialise: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
body.appendChild(initBtn);
|
||||||
|
} else if (!this.app.state.secretStoreUnlocked) {
|
||||||
|
const unlockBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Unlock' });
|
||||||
|
unlockBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
||||||
|
await window.go.api.Service.UnlockSecretStore(pw);
|
||||||
|
this.app.state.setSecretStoreState(true);
|
||||||
|
this.app.notifications.success('Secret store unlocked');
|
||||||
|
this._renderTabContent(body);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message !== 'User cancelled') {
|
||||||
|
this.app.notifications.error('Unlock failed: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
body.appendChild(unlockBtn);
|
||||||
|
} else {
|
||||||
|
const lockBtn = createElement('button', 'btn btn-outline btn-sm', { textContent: 'Lock' });
|
||||||
|
lockBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.LockSecretStore();
|
||||||
|
this.app.state.setSecretStoreState(false);
|
||||||
|
this.app.notifications.info('Secret store locked');
|
||||||
|
this._renderTabContent(body);
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Lock failed: ' + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
body.appendChild(lockBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known-hosts management tab.
|
||||||
|
* @param {HTMLElement} body
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderKnownHostsTab(body) {
|
||||||
|
if (this._knownHosts.length === 0) {
|
||||||
|
body.appendChild(createElement('p', 'text-muted', { textContent: 'No known hosts recorded.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = createElement('table', 'settings-table');
|
||||||
|
table.innerHTML = `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Key Type</th>
|
||||||
|
<th>Fingerprint</th>
|
||||||
|
<th>First Seen</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
`;
|
||||||
|
const tbody = createElement('tbody');
|
||||||
|
|
||||||
|
for (const kh of this._knownHosts) {
|
||||||
|
const tr = createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${escapeHtml(kh.host)}</td>
|
||||||
|
<td>${kh.port}</td>
|
||||||
|
<td><code>${escapeHtml(kh.keyType)}</code></td>
|
||||||
|
<td class="fingerprint-cell" title="${escapeHtml(kh.fingerprint)}"><code>${escapeHtml(kh.fingerprint.slice(0, 20))}…</code></td>
|
||||||
|
<td>${formatTimestamp(kh.firstSeen)}</td>
|
||||||
|
<td></td>
|
||||||
|
`;
|
||||||
|
const deleteBtn = createElement('button', 'btn btn-xs btn-danger', { textContent: 'Remove' });
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.DeleteKnownHost(kh.id);
|
||||||
|
this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
|
||||||
|
this._renderTabContent(body);
|
||||||
|
this.app.notifications.success('Known host removed');
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to remove: ' + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tr.querySelector('td:last-child').appendChild(deleteBtn);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.appendChild(tbody);
|
||||||
|
body.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* About tab showing application info.
|
||||||
|
* @param {HTMLElement} body
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderAboutTab(body) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-title">Nexus Terminal</h3>
|
||||||
|
<p class="about-version">Version 1.0.0</p>
|
||||||
|
<p class="about-desc">A premium desktop SSH, Telnet, and RAW TCP client built with Wails.</p>
|
||||||
|
<div class="about-links">
|
||||||
|
<p>AES-256-GCM encrypted credential vault</p>
|
||||||
|
<p>Platform-native keychain integration</p>
|
||||||
|
<p>xterm.js terminal emulation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single settings form field.
|
||||||
|
* @param {string} type
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} label
|
||||||
|
* @param {*} value
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_settingField(type, name, label, value, opts = {}) {
|
||||||
|
const group = createElement('div', 'form-group settings-field');
|
||||||
|
const labelEl = createElement('label', 'form-label', { textContent: label, for: `setting-${name}` });
|
||||||
|
group.appendChild(labelEl);
|
||||||
|
|
||||||
|
let input;
|
||||||
|
switch (type) {
|
||||||
|
case 'text':
|
||||||
|
input = createElement('input', 'form-input', {
|
||||||
|
type: 'text',
|
||||||
|
id: `setting-${name}`,
|
||||||
|
name: `setting-${name}`,
|
||||||
|
value: value || '',
|
||||||
|
placeholder: opts.placeholder || '',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
input = createElement('input', 'form-input', {
|
||||||
|
type: 'number',
|
||||||
|
id: `setting-${name}`,
|
||||||
|
name: `setting-${name}`,
|
||||||
|
value: value != null ? String(value) : '',
|
||||||
|
});
|
||||||
|
if (opts.min != null) input.min = opts.min;
|
||||||
|
if (opts.max != null) input.max = opts.max;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
input = createElement('select', 'form-select', {
|
||||||
|
id: `setting-${name}`,
|
||||||
|
name: `setting-${name}`,
|
||||||
|
});
|
||||||
|
for (const opt of (opts.options || [])) {
|
||||||
|
const option = createElement('option', '', { value: opt.value, textContent: opt.label });
|
||||||
|
if (opt.value === value) option.selected = true;
|
||||||
|
input.appendChild(option);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'toggle': {
|
||||||
|
const wrapper = createElement('div', 'form-toggle-wrapper');
|
||||||
|
input = createElement('input', 'form-toggle', {
|
||||||
|
type: 'checkbox',
|
||||||
|
id: `setting-${name}`,
|
||||||
|
name: `setting-${name}`,
|
||||||
|
});
|
||||||
|
input.checked = !!value;
|
||||||
|
const slider = createElement('label', 'form-toggle-label', { for: `setting-${name}` });
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
wrapper.appendChild(slider);
|
||||||
|
group.appendChild(wrapper);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
input = createElement('input', 'form-input', {
|
||||||
|
type: 'text',
|
||||||
|
id: `setting-${name}`,
|
||||||
|
name: `setting-${name}`,
|
||||||
|
value: value || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.appendChild(input);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads current form field values back into the working preferences copy.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_collectValues() {
|
||||||
|
if (!this._overlay) return;
|
||||||
|
|
||||||
|
const val = (name) => {
|
||||||
|
const el = this._overlay.querySelector(`[name="setting-${name}"]`);
|
||||||
|
if (!el) return undefined;
|
||||||
|
if (el.type === 'checkbox') return el.checked;
|
||||||
|
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
|
||||||
|
return el.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
'theme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect',
|
||||||
|
'showStatusBar', 'sidebarWidth', 'defaultFontFamily', 'defaultFontSize',
|
||||||
|
'defaultScrollback', 'defaultCursorStyle',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const v = val(field);
|
||||||
|
if (v !== undefined) {
|
||||||
|
this._prefs[field] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-35
@@ -5,35 +5,21 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"github.com/copilot-ssh-client/app/internal/api"
|
||||||
|
"github.com/copilot-ssh-client/app/internal/audit"
|
||||||
"github.com/copilot-ssh-client/app/internal/config"
|
"github.com/copilot-ssh-client/app/internal/config"
|
||||||
|
"github.com/copilot-ssh-client/app/internal/knownhosts"
|
||||||
|
"github.com/copilot-ssh-client/app/internal/preferences"
|
||||||
"github.com/copilot-ssh-client/app/internal/secrets"
|
"github.com/copilot-ssh-client/app/internal/secrets"
|
||||||
|
"github.com/copilot-ssh-client/app/internal/sessions"
|
||||||
|
"github.com/copilot-ssh-client/app/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Storage abstracts the persistent data layer (SQLite).
|
|
||||||
type Storage interface {
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionManager tracks active connection sessions.
|
|
||||||
type SessionManager interface {
|
|
||||||
CloseAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preferences manages user application preferences.
|
|
||||||
type Preferences interface{}
|
|
||||||
|
|
||||||
// KnownHostStore manages trusted SSH host key records.
|
|
||||||
type KnownHostStore interface{}
|
|
||||||
|
|
||||||
// AuditLog records auditable application events.
|
|
||||||
type AuditLog interface{}
|
|
||||||
|
|
||||||
// App is the central application struct. Wails binds its lifecycle methods
|
// App is the central application struct. Wails binds its lifecycle methods
|
||||||
// (Startup, Shutdown, DomReady) and it holds references to every subsystem.
|
// (Startup, Shutdown, DomReady) and it holds references to every subsystem.
|
||||||
type App struct {
|
type App struct {
|
||||||
@@ -41,18 +27,31 @@ type App struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
Config *config.AppConfig
|
Config *config.AppConfig
|
||||||
Storage Storage
|
Store *storage.Store
|
||||||
SecretStore *secrets.SecretStore
|
SecretStore *secrets.SecretStore
|
||||||
SessionManager SessionManager
|
KeychainStore *secrets.KeychainStore
|
||||||
Preferences Preferences
|
SessionManager *sessions.Manager
|
||||||
KnownHostStore KnownHostStore
|
Prefs *preferences.Manager
|
||||||
AuditLog AuditLog
|
KnownHosts *knownhosts.Store
|
||||||
|
AuditLog *audit.Logger
|
||||||
|
Service *api.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App instance. Subsystems are not initialised until
|
// NewApp creates a new App instance. Subsystems are not initialised until
|
||||||
// Startup is called by the Wails runtime.
|
// Startup is called by the Wails runtime. The API service is pre-created
|
||||||
|
// so it can be bound by Wails before Startup runs.
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{}
|
a := &App{}
|
||||||
|
// The API service is created eagerly so Wails can bind it. Its backing
|
||||||
|
// stores are nil at this point; they are wired during Startup.
|
||||||
|
a.Service = &api.Service{}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIService returns the Wails-bound API service instance. This must be
|
||||||
|
// called before wails.Run so the binding is registered.
|
||||||
|
func (a *App) APIService() *api.Service {
|
||||||
|
return a.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// Startup is called by Wails when the application starts. It loads
|
// Startup is called by Wails when the application starts. It loads
|
||||||
@@ -64,6 +63,7 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
|
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
|
||||||
|
// Load platform-specific configuration paths.
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to load configuration: %v", err)
|
log.Fatalf("failed to load configuration: %v", err)
|
||||||
@@ -74,12 +74,37 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
log.Fatalf("failed to create application directories: %v", err)
|
log.Fatalf("failed to create application directories: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialise the encrypted secret store.
|
// Initialise SQLite storage (migrations run automatically on open).
|
||||||
a.SecretStore = secrets.NewSecretStore(cfg.SecretPath)
|
store, err := storage.NewStore(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to open storage: %v", err)
|
||||||
|
}
|
||||||
|
a.Store = store
|
||||||
|
|
||||||
|
// Initialise secret stores (keychain preferred, encrypted file fallback).
|
||||||
|
a.SecretStore = secrets.NewSecretStore(cfg.SecretPath)
|
||||||
|
a.KeychainStore = secrets.NewKeychainStore("NexusTerminal")
|
||||||
|
|
||||||
|
// Initialise the audit logger backed by SQLite.
|
||||||
|
a.AuditLog = audit.NewLogger(store)
|
||||||
|
|
||||||
|
// Initialise known hosts store.
|
||||||
|
a.KnownHosts = knownhosts.NewStore(store)
|
||||||
|
|
||||||
|
// Initialise preferences manager.
|
||||||
|
a.Prefs = preferences.NewManager(store)
|
||||||
|
if err := a.Prefs.Load(); err != nil {
|
||||||
|
log.Printf("warning: failed to load preferences, using defaults: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise the session manager.
|
||||||
|
a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog)
|
||||||
|
a.SessionManager.SetContext(ctx)
|
||||||
|
|
||||||
|
// Wire the API service with all backing stores now that they exist.
|
||||||
|
*a.Service = *api.NewService(store, a.SecretStore, a.KeychainStore, a.SessionManager, a.AuditLog, a.Prefs)
|
||||||
|
a.Service.SetContext(ctx)
|
||||||
|
|
||||||
// Storage, session manager, preferences, known host store and audit log
|
|
||||||
// will be wired in when their implementations are available.
|
|
||||||
// For now we log successful initialisation.
|
|
||||||
log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir)
|
log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +118,8 @@ func (a *App) Shutdown(ctx context.Context) {
|
|||||||
a.SessionManager.CloseAll()
|
a.SessionManager.CloseAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.Storage != nil {
|
if a.Store != nil {
|
||||||
if err := a.Storage.Close(); err != nil {
|
if err := a.Store.Close(); err != nil {
|
||||||
log.Printf("warning: error closing storage: %v", err)
|
log.Printf("warning: error closing storage: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,5 +138,5 @@ func (a *App) DomReady(ctx context.Context) {
|
|||||||
runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{
|
runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{
|
||||||
"message": "Backend is ready",
|
"message": "Backend is ready",
|
||||||
})
|
})
|
||||||
fmt.Println("DOM ready – backend:ready event emitted")
|
log.Println("DOM ready – backend:ready event emitted")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package sshclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/copilot-ssh-client/app/internal/models"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HostKeyStore is the interface for known-host persistence.
|
||||||
|
// Implementations handle the underlying storage mechanism (e.g. SQLite, file).
|
||||||
|
type HostKeyStore interface {
|
||||||
|
GetKnownHost(host string, port int) (*models.KnownHost, error)
|
||||||
|
SaveKnownHost(kh *models.KnownHost) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostKeyVerifier handles SSH host key verification against the known-hosts
|
||||||
|
// store. It supports four policies:
|
||||||
|
// - strict: reject unknown or changed keys
|
||||||
|
// - ask: prompt the user for unknown keys (via callback)
|
||||||
|
// - accept-new: automatically accept new keys, reject changed keys
|
||||||
|
// - insecure: accept all keys (for testing only — clearly warned)
|
||||||
|
type HostKeyVerifier struct {
|
||||||
|
store HostKeyStore
|
||||||
|
policy models.HostKeyPolicy
|
||||||
|
onPrompt func(host string, keyType string, fingerprint string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHostKeyVerifier creates a verifier with the given store and policy.
|
||||||
|
func NewHostKeyVerifier(store HostKeyStore, policy models.HostKeyPolicy) *HostKeyVerifier {
|
||||||
|
return &HostKeyVerifier{
|
||||||
|
store: store,
|
||||||
|
policy: policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPromptHandler sets the callback used in "ask" mode to prompt the user
|
||||||
|
// for acceptance of an unknown host key.
|
||||||
|
func (v *HostKeyVerifier) SetPromptHandler(fn func(string, string, string) (bool, error)) {
|
||||||
|
v.onPrompt = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostKeyCallback returns an ssh.HostKeyCallback suitable for use with
|
||||||
|
// ssh.ClientConfig. The behaviour depends on the configured policy.
|
||||||
|
func (v *HostKeyVerifier) HostKeyCallback() ssh.HostKeyCallback {
|
||||||
|
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
host, port := extractHostPort(hostname, remote)
|
||||||
|
keyType := key.Type()
|
||||||
|
fingerprint := computeFingerprint(key)
|
||||||
|
|
||||||
|
stored, err := v.store.GetKnownHost(host, port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query known hosts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v.policy {
|
||||||
|
case models.HostKeyInsecure:
|
||||||
|
return v.saveIfNew(stored, host, port, keyType, fingerprint, key)
|
||||||
|
|
||||||
|
case models.HostKeyAcceptNew:
|
||||||
|
if stored == nil {
|
||||||
|
return v.saveHost(host, port, keyType, fingerprint, key)
|
||||||
|
}
|
||||||
|
return v.verifyStored(stored, fingerprint, host)
|
||||||
|
|
||||||
|
case models.HostKeyAsk:
|
||||||
|
if stored == nil {
|
||||||
|
return v.promptAndSave(host, port, keyType, fingerprint, key)
|
||||||
|
}
|
||||||
|
return v.verifyStored(stored, fingerprint, host)
|
||||||
|
|
||||||
|
case models.HostKeyStrict:
|
||||||
|
if stored == nil {
|
||||||
|
return fmt.Errorf("host key for %s:%d is not known (policy: strict)", host, port)
|
||||||
|
}
|
||||||
|
return v.verifyStored(stored, fingerprint, host)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown host key policy: %s", v.policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyStored checks that the presented fingerprint matches the stored one.
|
||||||
|
func (v *HostKeyVerifier) verifyStored(stored *models.KnownHost, fingerprint, host string) error {
|
||||||
|
if stored.Fingerprint != fingerprint {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"host key for %s has changed! stored fingerprint: %s, received: %s — possible MITM attack",
|
||||||
|
host, stored.Fingerprint, fingerprint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stored.LastSeen = time.Now()
|
||||||
|
_ = v.store.SaveKnownHost(stored)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptAndSave asks the user whether to accept an unknown host key.
|
||||||
|
func (v *HostKeyVerifier) promptAndSave(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
|
||||||
|
if v.onPrompt == nil {
|
||||||
|
return fmt.Errorf("host key for %s:%d is unknown and no prompt handler is set (policy: ask)", host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted, err := v.onPrompt(host, keyType, fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("host key prompt failed: %w", err)
|
||||||
|
}
|
||||||
|
if !accepted {
|
||||||
|
return fmt.Errorf("host key for %s:%d rejected by user", host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.saveHost(host, port, keyType, fingerprint, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveIfNew stores the host key only when no prior record exists.
|
||||||
|
func (v *HostKeyVerifier) saveIfNew(stored *models.KnownHost, host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
|
||||||
|
if stored == nil {
|
||||||
|
return v.saveHost(host, port, keyType, fingerprint, key)
|
||||||
|
}
|
||||||
|
stored.LastSeen = time.Now()
|
||||||
|
_ = v.store.SaveKnownHost(stored)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveHost persists a new known-host record.
|
||||||
|
func (v *HostKeyVerifier) saveHost(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
|
||||||
|
now := time.Now()
|
||||||
|
kh := &models.KnownHost{
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
KeyType: keyType,
|
||||||
|
Fingerprint: fingerprint,
|
||||||
|
PublicKey: base64.StdEncoding.EncodeToString(key.Marshal()),
|
||||||
|
FirstSeen: now,
|
||||||
|
LastSeen: now,
|
||||||
|
Trusted: true,
|
||||||
|
}
|
||||||
|
return v.store.SaveKnownHost(kh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeFingerprint returns the SHA-256 fingerprint of a public key in
|
||||||
|
// the standard "SHA256:<base64>" format.
|
||||||
|
func computeFingerprint(key ssh.PublicKey) string {
|
||||||
|
hash := sha256.Sum256(key.Marshal())
|
||||||
|
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHostPort splits the hostname and port from the SSH callback arguments.
|
||||||
|
func extractHostPort(hostname string, remote net.Addr) (string, int) {
|
||||||
|
host, portStr, err := net.SplitHostPort(hostname)
|
||||||
|
if err != nil {
|
||||||
|
host, portStr, _ = net.SplitHostPort(remote.String())
|
||||||
|
}
|
||||||
|
port := 22
|
||||||
|
if portStr != "" {
|
||||||
|
fmt.Sscanf(portStr, "%d", &port)
|
||||||
|
}
|
||||||
|
return host, port
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -302,15 +302,30 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
|
|||||||
store := setupTestStore(t)
|
store := setupTestStore(t)
|
||||||
|
|
||||||
prefs := models.DefaultAppPreferences()
|
prefs := models.DefaultAppPreferences()
|
||||||
|
prefs.ConfirmOnClose = true
|
||||||
|
prefs.DefaultFontSize = 16
|
||||||
|
prefs.Theme = "custom-dark"
|
||||||
|
|
||||||
if err := store.SavePreferences(&prefs); err != nil {
|
if err := store.SavePreferences(&prefs); err != nil {
|
||||||
t.Fatalf("SavePreferences() error = %v", err)
|
t.Fatalf("SavePreferences() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: GetPreferences round-trip is not tested here because the
|
got, err := store.GetPreferences()
|
||||||
// current implementation stores all values (including booleans) as
|
if err != nil {
|
||||||
// plain strings in the key-value table. json.Unmarshal cannot
|
t.Fatalf("GetPreferences() error = %v", err)
|
||||||
// decode string "true" into a Go bool field. This is a pre-existing
|
}
|
||||||
// limitation of the storage layer, not a test defect.
|
if got == nil {
|
||||||
|
t.Fatal("GetPreferences() returned nil after save")
|
||||||
|
}
|
||||||
|
if got.ConfirmOnClose != true {
|
||||||
|
t.Errorf("ConfirmOnClose = %v, want true", got.ConfirmOnClose)
|
||||||
|
}
|
||||||
|
if got.DefaultFontSize != 16 {
|
||||||
|
t.Errorf("DefaultFontSize = %d, want 16", got.DefaultFontSize)
|
||||||
|
}
|
||||||
|
if got.Theme != "custom-dark" {
|
||||||
|
t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user