From 8572e6e7ea73b7408b12dfb9d16426a8facc6145 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 10:49:21 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20wire=20SSH/SFTP/terminal=20to=20real=20G?= =?UTF-8?q?o=20backend=20=E2=80=94=20kill=20all=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical path wired end-to-end: - ConnectSSH on WraithApp resolves credentials from vault, builds auth methods - SSH output handler emits Wails events (base64) to frontend - useTerminal.ts forwards keystrokes to SSHService.Write, resize to SSHService.Resize - useTerminal.ts listens for ssh:data:{sessionId} events and writes to xterm.js - session.store.ts connect() calls real Go ConnectSSH, not mock - useSftp.ts calls real SFTPService.List instead of hardcoded mock data - SFTP client auto-registered on SSH connection via pkg/sftp - DisconnectSession cleans up both SSH and SFTP Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/composables/useSftp.ts | 49 +++------- frontend/src/composables/useTerminal.ts | 56 +++++++---- frontend/src/stores/session.store.ts | 75 ++++++++------ internal/app/app.go | 125 ++++++++++++++++++++++-- internal/credentials/service.go | 5 + main.go | 3 + 6 files changed, 226 insertions(+), 87 deletions(-) diff --git a/frontend/src/composables/useSftp.ts b/frontend/src/composables/useSftp.ts index 16df99f..4e12b91 100644 --- a/frontend/src/composables/useSftp.ts +++ b/frontend/src/composables/useSftp.ts @@ -1,4 +1,7 @@ import { ref, type Ref } from "vue"; +import { Call } from "@wailsio/runtime"; + +const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService"; export interface FileEntry { name: string; @@ -19,46 +22,24 @@ export interface UseSftpReturn { refresh: () => Promise; } -/** Mock directory listings used until Wails SFTP bindings are connected. */ -const mockDirectories: Record = { - "/home/user": [ - { name: "docs", path: "/home/user/docs", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-17" }, - { name: "projects", path: "/home/user/projects", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" }, - { name: ".ssh", path: "/home/user/.ssh", size: 0, isDir: true, permissions: "drwx------", modTime: "2026-03-10" }, - { name: ".bashrc", path: "/home/user/.bashrc", size: 3771, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-15" }, - { name: "deploy.sh", path: "/home/user/deploy.sh", size: 1024, isDir: false, permissions: "-rwxr-xr-x", modTime: "2026-03-16" }, - { name: ".profile", path: "/home/user/.profile", size: 807, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-10" }, - ], - "/home/user/docs": [ - { name: "readme.md", path: "/home/user/docs/readme.md", size: 2048, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-17" }, - { name: "notes.txt", path: "/home/user/docs/notes.txt", size: 512, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-14" }, - ], - "/home/user/projects": [ - { name: "app", path: "/home/user/projects/app", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" }, - { name: "Makefile", path: "/home/user/projects/Makefile", size: 256, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-16" }, - ], - "/home/user/.ssh": [ - { name: "authorized_keys", path: "/home/user/.ssh/authorized_keys", size: 743, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" }, - { name: "config", path: "/home/user/.ssh/config", size: 128, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" }, - ], -}; - /** * Composable that manages SFTP file browsing state. - * - * Uses mock data until Wails SFTPService bindings are connected. + * Calls the real Go SFTPService via Wails bindings. */ -export function useSftp(_sessionId: string): UseSftpReturn { - const currentPath = ref("/home/user"); +export function useSftp(sessionId: string): UseSftpReturn { + const currentPath = ref("/"); const entries = ref([]); const isLoading = ref(false); const followTerminal = ref(false); async function listDirectory(path: string): Promise { - // TODO: Replace with Wails binding call — SFTPService.List(sessionId, path) - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 150)); - return mockDirectories[path] ?? []; + try { + const result = await Call.ByName(`${SFTP}.List`, sessionId, path) as FileEntry[]; + return result ?? []; + } catch (err) { + console.error("SFTP list error:", err); + return []; + } } async function navigateTo(path: string): Promise { @@ -85,8 +66,8 @@ export function useSftp(_sessionId: string): UseSftpReturn { await navigateTo(currentPath.value); } - // Load initial directory - navigateTo(currentPath.value); + // Load home directory on init + navigateTo("/home"); return { currentPath, diff --git a/frontend/src/composables/useTerminal.ts b/frontend/src/composables/useTerminal.ts index 7644b43..03a0736 100644 --- a/frontend/src/composables/useTerminal.ts +++ b/frontend/src/composables/useTerminal.ts @@ -1,10 +1,13 @@ -import { ref, onBeforeUnmount } from "vue"; +import { onBeforeUnmount } from "vue"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; +import { Call, Events } from "@wailsio/runtime"; import "@xterm/xterm/css/xterm.css"; +const SSH = "github.com/vstockwell/wraith/internal/ssh.SSHService"; + /** MobaXTerm Classic–inspired terminal theme colors. */ const defaultTheme = { background: "#0d1117", @@ -43,8 +46,10 @@ export interface UseTerminalReturn { /** * Composable that manages an xterm.js Terminal lifecycle. * - * Creates the terminal with fit, search, and web-links addons. - * Data input and resize events are wired as TODOs for Wails bindings. + * Wires bidirectional I/O: + * - User keystrokes → SSHService.Write (via Wails Call) + * - SSH stdout → xterm.js (via Wails Events, base64 encoded) + * - Terminal resize → SSHService.Resize (via Wails Call) */ export function useTerminal(sessionId: string): UseTerminalReturn { const fitAddon = new FitAddon(); @@ -67,39 +72,54 @@ export function useTerminal(sessionId: string): UseTerminalReturn { terminal.loadAddon(searchAddon); terminal.loadAddon(webLinksAddon); - // Capture typed data and forward to the SSH backend - terminal.onData((_data: string) => { - // TODO: Replace with Wails binding call — SSHService.Write(sessionId, data) - // For now, echo typed data back to the terminal for visual feedback - void sessionId; + // Forward typed data to the SSH backend + terminal.onData((data: string) => { + Call.ByName(`${SSH}.Write`, sessionId, data).catch((err: unknown) => { + console.error("SSH write error:", err); + }); }); - // Handle terminal resize events - terminal.onResize((_size: { cols: number; rows: number }) => { - // TODO: Replace with Wails binding call — SSHService.Resize(sessionId, cols, rows) - void sessionId; + // Forward resize events to the SSH backend + terminal.onResize((size: { cols: number; rows: number }) => { + Call.ByName(`${SSH}.Resize`, sessionId, size.cols, size.rows).catch((err: unknown) => { + console.error("SSH resize error:", err); + }); }); + // Listen for SSH output events from the Go backend (base64 encoded) + let cleanupEvent: (() => void) | null = null; + let resizeObserver: ResizeObserver | null = null; function mount(container: HTMLElement): void { terminal.open(container); fitAddon.fit(); + // Subscribe to SSH output events for this session + cleanupEvent = Events.On(`ssh:data:${sessionId}`, (...args: unknown[]) => { + // Wails v3 events pass data as arguments + const b64data = typeof args[0] === "string" ? args[0] : String(args[0] ?? ""); + try { + const decoded = atob(b64data); + terminal.write(decoded); + } catch { + // Fallback: write raw if not valid base64 + terminal.write(b64data); + } + }); + // Auto-fit when the container resizes resizeObserver = new ResizeObserver(() => { fitAddon.fit(); }); resizeObserver.observe(container); - - // Write a placeholder welcome message (mock — replaced by real SSH output) - terminal.writeln("\x1b[1;34m Wraith Terminal\x1b[0m"); - terminal.writeln("\x1b[90m Session: " + sessionId + "\x1b[0m"); - terminal.writeln("\x1b[90m Waiting for SSH connection...\x1b[0m"); - terminal.writeln(""); } function destroy(): void { + if (cleanupEvent) { + cleanupEvent(); + cleanupEvent = null; + } if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; diff --git a/frontend/src/stores/session.store.ts b/frontend/src/stores/session.store.ts index 6c64b2d..c9a5408 100644 --- a/frontend/src/stores/session.store.ts +++ b/frontend/src/stores/session.store.ts @@ -1,7 +1,10 @@ import { defineStore } from "pinia"; import { ref, computed } from "vue"; +import { Call } from "@wailsio/runtime"; import { useConnectionStore } from "@/stores/connection.store"; +const APP = "github.com/vstockwell/wraith/internal/app.WraithApp"; + export interface Session { id: string; connectionId: number; @@ -10,17 +13,10 @@ export interface Session { active: boolean; } -/** - * Session store. - * Manages active sessions and tab order. - * - * Sessions are populated by the Go SessionManager once plugins are wired up. - * For now, mock sessions are used to render the tab bar. - */ export const useSessionStore = defineStore("session", () => { const sessions = ref([]); - const activeSessionId = ref(null); + const connecting = ref(false); const activeSession = computed(() => sessions.value.find((s) => s.id === activeSessionId.value) ?? null, @@ -28,19 +24,25 @@ export const useSessionStore = defineStore("session", () => { const sessionCount = computed(() => sessions.value.length); - /** Switch to a session tab. */ function activateSession(id: string): void { activeSessionId.value = id; } - /** Close a session tab. */ - function closeSession(id: string): void { + async function closeSession(id: string): Promise { const idx = sessions.value.findIndex((s) => s.id === id); if (idx === -1) return; + const session = sessions.value[idx]; + + // Disconnect the backend session + try { + await Call.ByName(`${APP}.DisconnectSession`, session.id); + } catch (err) { + console.error("Failed to disconnect session:", err); + } + sessions.value.splice(idx, 1); - // If we closed the active session, activate an adjacent one if (activeSessionId.value === id) { if (sessions.value.length === 0) { activeSessionId.value = null; @@ -51,21 +53,11 @@ export const useSessionStore = defineStore("session", () => { } } - /** Add a new session (placeholder — will be called from connection double-click). */ - function addSession(connectionId: number, name: string, protocol: "ssh" | "rdp"): void { - const id = `s${Date.now()}`; - sessions.value.push({ id, connectionId, name, protocol, active: false }); - activeSessionId.value = id; - } - /** * Connect to a server by connection ID. - * Creates a new session tab and sets it active. - * - * TODO: Replace with Wails binding call — SSHService.Connect(hostname, port, ...) - * For now, creates a mock session using the connection's name. + * Calls the real Go backend to establish an SSH or RDP session. */ - function connect(connectionId: number): void { + async function connect(connectionId: number): Promise { const connectionStore = useConnectionStore(); const conn = connectionStore.connections.find((c) => c.id === connectionId); if (!conn) return; @@ -77,10 +69,35 @@ export const useSessionStore = defineStore("session", () => { return; } - // TODO: Replace with Wails binding call: - // const sessionId = await SSHService.Connect(conn.hostname, conn.port, username, authMethods, cols, rows) - // For now, create a mock session - addSession(connectionId, conn.name, conn.protocol); + connecting.value = true; + try { + if (conn.protocol === "ssh") { + // Call Go backend — resolves credentials, builds auth, returns sessionID + const sessionId = await Call.ByName( + `${APP}.ConnectSSH`, + connectionId, + 120, // cols (will be resized by xterm.js fit addon) + 40, // rows + ) as string; + + sessions.value.push({ + id: sessionId, + connectionId, + name: conn.name, + protocol: "ssh", + active: true, + }); + activeSessionId.value = sessionId; + } else if (conn.protocol === "rdp") { + // TODO: Wire RDP connect when ready + console.warn("RDP connections not yet wired"); + } + } catch (err) { + console.error("Connection failed:", err); + // TODO: Show error toast in UI + } finally { + connecting.value = false; + } } return { @@ -88,9 +105,9 @@ export const useSessionStore = defineStore("session", () => { activeSessionId, activeSession, sessionCount, + connecting, activateSession, closeSession, - addSession, connect, }; }); diff --git a/internal/app/app.go b/internal/app/app.go index b5136c5..3d31ca8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" + "encoding/base64" + "github.com/vstockwell/wraith/internal/ai" "github.com/vstockwell/wraith/internal/connections" "github.com/vstockwell/wraith/internal/credentials" @@ -22,6 +24,9 @@ import ( "github.com/vstockwell/wraith/internal/theme" "github.com/vstockwell/wraith/internal/updater" "github.com/vstockwell/wraith/internal/vault" + sftplib "github.com/pkg/sftp" + gossh "golang.org/x/crypto/ssh" + "github.com/wailsapp/wails/v3/pkg/application" ) // WraithApp is the main application struct that wires together all services @@ -41,9 +46,16 @@ type WraithApp struct { AI *ai.AIService Updater *updater.UpdateService oauthMgr *ai.OAuthManager + wailsApp *application.App unlocked bool } +// SetWailsApp stores the Wails application reference for event emission. +// Must be called after application.New() and before app.Run(). +func (a *WraithApp) SetWailsApp(app *application.App) { + a.wailsApp = app +} + // New creates and initializes the WraithApp, opening the database, running // migrations, creating all services, and seeding built-in themes. // The version string is the build-time semver (e.g. "0.2.0") used by the @@ -68,11 +80,16 @@ func New(version string) (*WraithApp, error) { sessionMgr := session.NewManager() pluginReg := plugin.NewRegistry() - // No-op output handler — Wails event emission will be wired at runtime. + // SSH output handler — emits Wails events to the frontend. + // The closure captures `app` (the WraithApp being built). The wailsApp + // field is set after application.New() in main.go, but SSH sessions only + // start after app.Run(), so wailsApp is always valid at call time. + var app *WraithApp sshSvc := ssh.NewSSHService(database, func(sessionID string, data []byte) { - // TODO: Emit Wails event "ssh:output" with sessionID + data - _ = sessionID - _ = data + if app != nil && app.wailsApp != nil { + // Base64 encode binary data for safe transport over Wails events + app.wailsApp.Event.Emit("ssh:data:"+sessionID, base64.StdEncoding.EncodeToString(data)) + } }) sftpSvc := sftp.NewSFTPService() @@ -102,7 +119,7 @@ func New(version string) (*WraithApp, error) { updaterSvc := updater.NewUpdateService(version) - return &WraithApp{ + app = &WraithApp{ db: database, Settings: settingsSvc, Connections: connSvc, @@ -115,7 +132,8 @@ func New(version string) (*WraithApp, error) { AI: aiSvc, Updater: updaterSvc, oauthMgr: oauthMgr, - }, nil + } + return app, nil } // dataDirectory returns the path where Wraith stores its data. @@ -230,6 +248,101 @@ func (a *WraithApp) initCredentials() { } } +// ConnectSSH opens an SSH session to the given connection ID. +// It resolves credentials from the vault, builds auth methods, and returns a session ID. +// The frontend calls this instead of SSHService.Connect directly (which takes Go-only types). +func (a *WraithApp) ConnectSSH(connectionID int64, cols, rows int) (string, error) { + conn, err := a.Connections.GetConnection(connectionID) + if err != nil { + return "", fmt.Errorf("connection not found: %w", err) + } + + // Build SSH auth methods from the connection's credential + var authMethods []gossh.AuthMethod + username := "root" // default + + if conn.CredentialID != nil && a.Credentials != nil { + cred, err := a.Credentials.GetCredential(*conn.CredentialID) + if err != nil { + slog.Warn("failed to load credential", "id", *conn.CredentialID, "error", err) + } else { + if cred.Username != "" { + username = cred.Username + } + + switch cred.Type { + case "password": + pw, err := a.Credentials.DecryptPassword(cred.ID) + if err != nil { + slog.Warn("failed to decrypt password", "error", err) + } else { + authMethods = append(authMethods, gossh.Password(pw)) + } + case "ssh_key": + if cred.SSHKeyID != nil { + keyPEM, passphrase, err := a.Credentials.DecryptSSHKey(*cred.SSHKeyID) + if err != nil { + slog.Warn("failed to decrypt SSH key", "error", err) + } else { + var signer gossh.Signer + if passphrase != "" { + signer, err = gossh.ParsePrivateKeyWithPassphrase(keyPEM, []byte(passphrase)) + } else { + signer, err = gossh.ParsePrivateKey(keyPEM) + } + if err != nil { + slog.Warn("failed to parse SSH key", "error", err) + } else { + authMethods = append(authMethods, gossh.PublicKeys(signer)) + } + } + } + } + } + } + + // Fallback: keyboard-interactive (prompts for password) + if len(authMethods) == 0 { + slog.Info("no stored credentials, SSH will attempt keyboard-interactive auth") + authMethods = append(authMethods, gossh.KeyboardInteractive( + func(name, instruction string, questions []string, echos []bool) ([]string, error) { + // No interactive prompt available yet — return empty + return make([]string, len(questions)), nil + }, + )) + } + + sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows) + if err != nil { + return "", fmt.Errorf("SSH connect failed: %w", err) + } + + // Register SFTP client on the same SSH connection + if sess, ok := a.SSH.GetSession(sessionID); ok && sess != nil { + sftpClient, err := sftplib.NewClient(sess.Client) + if err != nil { + slog.Warn("failed to create SFTP client", "error", err) + // Non-fatal — SSH still works without SFTP + } else { + a.SFTP.RegisterClient(sessionID, sftpClient) + } + } + + // Update last_connected timestamp + if _, err := a.db.Exec("UPDATE connections SET last_connected = CURRENT_TIMESTAMP WHERE id = ?", connectionID); err != nil { + slog.Warn("failed to update last_connected", "error", err) + } + + slog.Info("SSH session started", "sessionID", sessionID, "host", conn.Hostname, "user", username) + return sessionID, nil +} + +// DisconnectSession closes an active SSH session and its SFTP client. +func (a *WraithApp) DisconnectSession(sessionID string) error { + a.SFTP.RemoveClient(sessionID) + return a.SSH.Disconnect(sessionID) +} + // ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents // (groups, connections, host keys) into the database. func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) { diff --git a/internal/credentials/service.go b/internal/credentials/service.go index 617de89..90c69e2 100644 --- a/internal/credentials/service.go +++ b/internal/credentials/service.go @@ -341,6 +341,11 @@ func classifyPublicKey(pub ssh.PublicKey) string { } // getCredential retrieves a single credential by ID. +// GetCredential retrieves a single credential by ID. +func (s *CredentialService) GetCredential(id int64) (*Credential, error) { + return s.getCredential(id) +} + func (s *CredentialService) getCredential(id int64) (*Credential, error) { var c Credential var username, domain sql.NullString diff --git a/main.go b/main.go index 4ed045e..d9a8caa 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,9 @@ func main() { }, }) + // Wire Wails app reference for event emission (SSH output → frontend) + wraith.SetWailsApp(app) + app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "Wraith", Width: 1400,