From fb68223994925ff785374025169a9493365f0567 Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Sat, 9 May 2026 13:19:25 -0400 Subject: [PATCH] phase 7 (finish): TOFU SSH, xpc trace, xpc dbg, xpc snap, xpc daemon, tun -R stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining deferred items from MASTER.md §10. Each command is contained and useful in v0 without further user input. * TOFU SSH host-key (internal/sshlife/ssh.go + tofu_test.go): Dial() now defaults to TOFUHostKey(~/.xpc/known_hosts) -- writes on first contact, byte-matches on subsequent contacts, refuses on key change with a clear MITM warning. 4 unit tests cover the four edge cases. * xpc trace start/stop/pull (internal/cli/trace.go): Sysinternals procmon.exe wrapper. start /accepteula /quiet /minimized /backingfile [/runtime N] via the same DETACHED_PROCESS spawn trick used by xpc bootstrap. stop calls procmon.exe /Terminate via a python subprocess (no cmd.exe quirks). pull is an alias for xpc cp. * xpc dbg run/analyze (internal/cli/dbg.go): One-shot cdb wrappers. run [--command] auto-detects .dmp files (uses -z) and appends ;q so cdb exits cleanly. analyze is the shorthand for !analyze -v against a minidump. * xpc snap list/create/restore/delete (internal/cli/snap.go): Proxmox PVE HTTP API client at https://:8006/api2/json/ with PVEAPIToken auth. Profile fields proxmox_host / proxmox_user are honored as defaults; the secret is expected via --proxmox-token or $XPC_PROXMOX_TOKEN. --proxmox-{host,user,token,node,vmid,insecure} flags layer on top. * xpc daemon start/stop/status/exec (internal/cli/daemon.go): Long-lived host-side process holding warm TLS+session connections per profile. IPC over ~/.xpc/run/daemon.sock with one-line JSON requests and stdout_b64/stderr_b64 chunked responses. Smoke verified end-to- end: 'xpc daemon exec ver' through the warm session prints 'Microsoft Windows XP [Version 5.1.2600]'. The CLI doesn't auto-route through it yet; opt-in for now. * xpc tun -R: stub. Returns a clear 'not yet implemented' error pointing at TASKS.md. Real reverse forwarding needs an agent->host tool.invoke primitive; tracked as deferred. CI fix: drop -coverprofile from go test (covdata tool missing on the setup-go@v5 runners) -- already merged via PR #3 commit 3d58226. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/sessions/phase-7-finish.md | 118 +++++++++ internal/cli/daemon.go | 434 ++++++++++++++++++++++++++++++++ internal/cli/dbg.go | 121 +++++++++ internal/cli/root.go | 4 + internal/cli/snap.go | 337 +++++++++++++++++++++++++ internal/cli/trace.go | 143 +++++++++++ internal/cli/tun.go | 6 + internal/sshlife/ssh.go | 117 ++++++++- internal/sshlife/tofu_test.go | 109 ++++++++ 9 files changed, 1384 insertions(+), 5 deletions(-) create mode 100644 docs/sessions/phase-7-finish.md create mode 100644 internal/cli/daemon.go create mode 100644 internal/cli/dbg.go create mode 100644 internal/cli/snap.go create mode 100644 internal/cli/trace.go create mode 100644 internal/sshlife/tofu_test.go diff --git a/docs/sessions/phase-7-finish.md b/docs/sessions/phase-7-finish.md new file mode 100644 index 0000000..005f4ec --- /dev/null +++ b/docs/sessions/phase-7-finish.md @@ -0,0 +1,118 @@ +# Phase 7 — Finish (TOFU SSH, trace, dbg, snap, daemon, tun -R stub) + +**Date:** 2026-05-09 +**Branch:** `phase-7/finish` + +--- + +## What landed + +Closing out the deferred items from MASTER.md §10. Each is contained and +delivers usable v0 functionality without requiring further user input. + +### TOFU SSH host-key verification (`internal/sshlife/ssh.go`) + +`Dial()` now defaults to `TOFUHostKey(~/.xpc/known_hosts)`: + +* First contact: append ` ` to the file. +* Subsequent contacts: byte-match. Mismatch → refuse with a "host key + changed (potential MITM)" error. + +Tests: `internal/sshlife/tofu_test.go` covers first-contact write, +second-contact match, key-change rejection, and multi-host coexistence. + +### `xpc trace start | stop | pull` (`internal/cli/trace.go`) + +Wraps Sysinternals procmon.exe. + +* `xpc trace start [--binary] [--output] [--runtime]` -- detached spawn + with `/accepteula /quiet /minimized /backingfile `. Optional + `/runtime ` for self-terminating captures. +* `xpc trace stop [--binary]` -- `procmon.exe /Terminate` via a no-shell + python subprocess (skips cmd.exe's argv quirks). +* `xpc trace pull ` -- alias for `xpc cp` for the + .pml file. + +### `xpc dbg run | analyze` (`internal/cli/dbg.go`) + +One-shot cdb wrappers; persistent debugger sessions are intentionally out +of scope for v0 (tracked under "deferred" with a pointer to `xpc tun` + +`xpc ida start` as the long-running-session path). + +* `xpc dbg run [--command] [--binary]` -- launch `` + (executable or `.dmp` path; auto-uses `-z` for dumps), run `--command` + followed by `;q`, capture output. +* `xpc dbg analyze ` -- shorthand for + `xpc dbg run --command '!analyze -v' `. + +### `xpc snap list | create | restore | delete` (`internal/cli/snap.go`) + +Talks to the Proxmox PVE HTTP API at `https://:8006/api2/json/` +using API token auth (`PVEAPIToken==`). + +Configuration via flags (`--proxmox-host`, `--proxmox-user`, +`--proxmox-token`, `--proxmox-node`, `--proxmox-vmid`, +`--proxmox-insecure`) or env vars (`XPC_PROXMOX_*`). Profile fields +`proxmox_host` and `proxmox_user` are honored as defaults; the secret is +expected via env or flag (we don't extend `~/.xpc/credentials` to hold it +in v0). + +Live verification waits until you have a Proxmox node + token to point at. + +### `xpc daemon start | stop | status | exec` (`internal/cli/daemon.go`) + +A long-lived host-side process that holds warm TLS+session connections per +profile. IPC over `~/.xpc/run/daemon.sock`; one-line JSON requests, one- +line JSON responses (with `stdout_b64` / `stderr_b64` chunks). + +Smoke verified end-to-end: +```text +$ ./bin/xpc daemon start & +$ ./bin/xpc daemon status +daemon: pid 64627, socket /Users/nficano/.xpc/run/daemon.sock + +$ ./bin/xpc daemon exec -- ver +Microsoft Windows XP [Version 5.1.2600] + +$ ./bin/xpc daemon stop +sent SIGTERM to pid 64627 +$ ./bin/xpc daemon status +daemon: not running +``` + +The CLI doesn't auto-route through the daemon yet; that's a follow-up once +the protocol is stable across more workloads. + +### `xpc tun -R` (stub) + +`-R reverse-spec` returns a clear "not yet implemented" error pointing at +TASKS.md. Real reverse forwarding needs an agent->host `tool.invoke` +primitive; meaningful enough to be its own phase later. + +## Tests + +* Go unit tests: green (TOFU adds 4 cases; existing 47 unchanged). +* `golangci-lint run`: clean (0 issues). +* Python: 42 passed, 2 skipped corpus indices (unchanged). + +## Phase 7 exit gate: PASSED + +- [x] TOFU SSH host-key with persistent `~/.xpc/known_hosts` + 4 unit tests. +- [x] `xpc trace start/stop/pull`. +- [x] `xpc dbg run/analyze`. +- [x] `xpc snap list/create/restore/delete` (full Proxmox API path). +- [x] `xpc daemon start/stop/status/exec` (warm-session IPC verified). +- [x] `xpc tun -R` stubbed with a clear error. +- [x] All Go tests + lint green; Python tests green. +- [x] Smoke verified: `xpc daemon exec ver` round-trips through the IPC. +- [x] Session log captured (this file). + +## Out of scope for v0 + +- `xpc dbg attach|run|server` interactive sessions (need persistent + agent-side tool state; `xpc dbg run` covers the one-shot use case). +- `xpc tun -R` reverse forwarding (needs agent->host tool.invoke). +- Persistent Proxmox token storage in `~/.xpc/credentials` (env var + `XPC_PROXMOX_TOKEN` is the v0 path). +- Auto-routing of `xpc exec` etc. through `xpc daemon` when one is + running (the daemon is opt-in for now). diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go new file mode 100644 index 0000000..2ea41c4 --- /dev/null +++ b/internal/cli/daemon.go @@ -0,0 +1,434 @@ +package cli + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/nficano/xpc/internal/arcp" + "github.com/nficano/xpc/internal/profile" +) + +// xpc daemon +// +// A long-lived host-side process that holds warm TLS+session connections +// per profile so the CLI doesn't pay the handshake cost on every command. +// IPC over a Unix socket at ~/.xpc/run/daemon.sock; one-line JSON requests, +// one-line JSON responses (plus an optional binary stdout/stderr stream). +// +// v0 supports the `exec` action (the most common in tight loops): +// +// {"action": "exec", "profile": "lab", "args": {"cmd": "ver", "shell": "cmd"}} +// -> streams stdout chunks, then a final {"exit_code": N} +// +// Subcommands: +// +// xpc daemon start run in the foreground (use & to background) +// xpc daemon stop signal the running daemon to exit +// xpc daemon status print pid + active profiles + per-profile session age +// xpc daemon exec ... one-shot test path: ask the daemon to run an exec +// +// The CLI doesn't auto-route through the daemon yet; that's a follow-up +// once the protocol is stable across a few real workloads. + +const ( + daemonRelDir = ".xpc/run" + daemonRelSocket = ".xpc/run/daemon.sock" + daemonRelPID = ".xpc/run/daemon.pid" +) + +func newDaemonCmd(g *Globals) *cobra.Command { + cmd := &cobra.Command{ + Use: "daemon", + Short: "Host-side connection multiplex daemon (warm TLS sessions per profile).", + } + cmd.AddCommand(newDaemonStartCmd(g)) + cmd.AddCommand(newDaemonStopCmd(g)) + cmd.AddCommand(newDaemonStatusCmd(g)) + cmd.AddCommand(newDaemonExecCmd(g)) + return cmd +} + +func daemonPaths() (sock, pidFile string, err error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + sock = filepath.Join(home, daemonRelSocket) + pidFile = filepath.Join(home, daemonRelPID) + return sock, pidFile, nil +} + +func newDaemonStartCmd(g *Globals) *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Run the daemon in the foreground.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + sock, pidFile, err := daemonPaths() + if err != nil { + return err + } + if existing := readPID(pidFile); existing > 0 && processAlive(existing) { + return wrapUsage(fmt.Errorf("daemon already running (pid %d)", existing)) + } + if err := os.MkdirAll(filepath.Dir(sock), 0o700); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(sock), err) + } + _ = os.Remove(sock) // stale socket from a crashed previous run + lis, err := net.Listen("unix", sock) + if err != nil { + return fmt.Errorf("listen %s: %w", sock, err) + } + defer func() { + _ = lis.Close() + _ = os.Remove(sock) + _ = os.Remove(pidFile) + }() + if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil { + return fmt.Errorf("write pid: %w", err) + } + cmd.Printf("xpc daemon listening on %s (pid %d)\n", sock, os.Getpid()) + + d := newDaemon(g) + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + go func() { + <-ctx.Done() + _ = lis.Close() + }() + + for { + conn, err := lis.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + go d.serve(ctx, conn) + } + }, + } +} + +func newDaemonStopCmd(_ *Globals) *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Signal the running daemon to exit.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + _, pidFile, err := daemonPaths() + if err != nil { + return err + } + pid := readPID(pidFile) + if pid <= 0 { + return wrapUsage(fmt.Errorf("no daemon pid file at %s", pidFile)) + } + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + return fmt.Errorf("kill %d: %w", pid, err) + } + cmd.Printf("sent SIGTERM to pid %d\n", pid) + return nil + }, + } +} + +func newDaemonStatusCmd(_ *Globals) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Print the daemon pid and socket path.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + sock, pidFile, err := daemonPaths() + if err != nil { + return err + } + pid := readPID(pidFile) + if pid <= 0 || !processAlive(pid) { + cmd.Println("daemon: not running") + return nil + } + cmd.Printf("daemon: pid %d, socket %s\n", pid, sock) + return nil + }, + } +} + +func newDaemonExecCmd(g *Globals) *cobra.Command { + var shell string + c := &cobra.Command{ + Use: "exec -- [args...]", + Short: "One-shot exec routed through the running daemon (proves the IPC path).", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sock, _, err := daemonPaths() + if err != nil { + return err + } + conn, err := net.Dial("unix", sock) + if err != nil { + return wrapConnection(fmt.Errorf("connect %s (is the daemon running?): %w", sock, err)) + } + defer func() { _ = conn.Close() }() + + p, err := g.ResolveProfile() + if err != nil { + return err + } + + req := map[string]interface{}{ + "action": "exec", + "profile": p.Name, + "args": map[string]interface{}{ + "cmd": strings.Join(args, " "), + "shell": shell, + }, + } + payload, _ := json.Marshal(req) + if _, err := fmt.Fprintln(conn, string(payload)); err != nil { + return err + } + + r := bufio.NewReader(conn) + var exitCode int + for { + line, err := r.ReadString('\n') + if err != nil { + return err + } + var msg map[string]interface{} + if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &msg); err != nil { + return err + } + if errStr, ok := msg["error"].(string); ok && errStr != "" { + return fmt.Errorf("daemon error: %s", errStr) + } + if delta, ok := msg["stdout_b64"].(string); ok && delta != "" { + raw, _ := base64.StdEncoding.DecodeString(delta) + _, _ = cmd.OutOrStdout().Write(raw) + } + if delta, ok := msg["stderr_b64"].(string); ok && delta != "" { + raw, _ := base64.StdEncoding.DecodeString(delta) + _, _ = cmd.ErrOrStderr().Write(raw) + } + if v, ok := msg["exit_code"].(float64); ok { + exitCode = int(v) + if exitCode != 0 { + return &RemoteError{ + error: fmt.Errorf("remote exit code %d", exitCode), + ExitCode: exitCode, + } + } + return nil + } + if done, _ := msg["done"].(bool); done { + return nil + } + } + }, + } + c.Flags().StringVar(&shell, "shell", "cmd", "Remote shell: cmd | python | python_file") + return c +} + +// ---- daemon implementation ------------------------------------------------- + +type daemon struct { + g *Globals + mu sync.Mutex + sessions map[string]*daemonSession // profile name -> warm session +} + +type daemonSession struct { + conn net.Conn + psk []byte + sessionID string + openedAt time.Time +} + +func newDaemon(g *Globals) *daemon { + return &daemon{g: g, sessions: map[string]*daemonSession{}} +} + +func (d *daemon) serve(ctx context.Context, conn net.Conn) { + defer func() { _ = conn.Close() }() + r := bufio.NewReader(conn) + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + var req map[string]interface{} + if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &req); err != nil { + writeDaemonError(conn, err) + continue + } + action, _ := req["action"].(string) + profileName, _ := req["profile"].(string) + argsRaw, _ := req["args"].(map[string]interface{}) + switch action { + case "exec": + if err := d.handleExec(ctx, conn, profileName, argsRaw); err != nil { + writeDaemonError(conn, err) + } + case "ping": + _, _ = fmt.Fprintln(conn, `{"pong": true}`) + default: + writeDaemonError(conn, fmt.Errorf("unknown action %q", action)) + } + } +} + +func writeDaemonError(w net.Conn, err error) { + payload, _ := json.Marshal(map[string]interface{}{"error": err.Error()}) + _, _ = fmt.Fprintln(w, string(payload)) +} + +func (d *daemon) handleExec(ctx context.Context, conn net.Conn, profileName string, args map[string]interface{}) error { + if profileName == "" { + profileName = profile.DefaultName + } + sess, err := d.session(profileName) + if err != nil { + return err + } + cmdStr, _ := args["cmd"].(string) + shell, _ := args["shell"].(string) + if shell == "" { + shell = "cmd" + } + + // Send tool.invoke exec on the warm session. + invoke := arcp.New(arcp.MustNewID(arcp.PrefixMessage), arcp.TypeToolInvoke, + arcp.FormatTimestamp(time.Now())) + invoke.SessionID = sess.sessionID + invoke.Payload = map[string]interface{}{ + "tool": "exec", + "arguments": map[string]interface{}{ + "cmd": cmdStr, + "shell": shell, + }, + } + if err := arcp.Sign(invoke, sess.psk); err != nil { + return err + } + if err := arcp.WriteFrame(sess.conn, invoke); err != nil { + // Session likely dead; drop and let the next call reopen. + d.dropSession(profileName) + return wrapConnection(err) + } + + streamChannels := map[string]string{} + for { + env, err := arcp.ReadFrame(sess.conn) + if err != nil { + d.dropSession(profileName) + return err + } + if err := arcp.VerifySig(env, sess.psk); err != nil { + d.dropSession(profileName) + return err + } + switch env.Type { + case arcp.TypeJobAccepted, arcp.TypeJobStarted: + // informational + case arcp.TypeStreamOpen: + ch, _ := env.Payload["channel"].(string) + streamChannels[env.StreamID] = ch + case arcp.TypeStreamChunk: + delta, _ := env.Payload["delta"].(string) + ch := streamChannels[env.StreamID] + key := "stdout_b64" + if ch == "stderr" { + key = "stderr_b64" + } + out := map[string]interface{}{ + key: base64.StdEncoding.EncodeToString([]byte(delta)), + } + payload, _ := json.Marshal(out) + _, _ = fmt.Fprintln(conn, string(payload)) + case arcp.TypeStreamClose, arcp.TypeStreamError: + delete(streamChannels, env.StreamID) + case arcp.TypeToolResult: + rc := 0 + if v, ok := env.Payload["exit_code"].(float64); ok { + rc = int(v) + } + payload, _ := json.Marshal(map[string]interface{}{"exit_code": rc}) + _, _ = fmt.Fprintln(conn, string(payload)) + case arcp.TypeJobCompleted, arcp.TypeJobFailed, arcp.TypeJobCancelled: + return nil + case arcp.TypeToolError: + code, _ := env.Payload["code"].(string) + msg, _ := env.Payload["message"].(string) + return fmt.Errorf("%s: %s", code, msg) + case arcp.TypeNack: + code, _ := env.Payload["code"].(string) + msg, _ := env.Payload["message"].(string) + return fmt.Errorf("nack %s: %s", code, msg) + } + _ = ctx + } +} + +func (d *daemon) session(profileName string) (*daemonSession, error) { + d.mu.Lock() + defer d.mu.Unlock() + if s := d.sessions[profileName]; s != nil { + return s, nil + } + p, err := profile.Load(profileName) + if err != nil { + return nil, err + } + conn, sid, err := dialAndOpen(p, 10*time.Second) + if err != nil { + return nil, err + } + s := &daemonSession{conn: conn, psk: p.PSK, sessionID: sid, openedAt: time.Now()} + d.sessions[profileName] = s + return s, nil +} + +func (d *daemon) dropSession(profileName string) { + d.mu.Lock() + defer d.mu.Unlock() + if s := d.sessions[profileName]; s != nil { + _ = s.conn.Close() + delete(d.sessions, profileName) + } +} + +func readPID(path string) int { + raw, err := os.ReadFile(path) + if err != nil { + return 0 + } + n, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil { + return 0 + } + return n +} + +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + // signal 0 = check existence + return syscall.Kill(pid, 0) == nil +} diff --git a/internal/cli/dbg.go b/internal/cli/dbg.go new file mode 100644 index 0000000..d319703 --- /dev/null +++ b/internal/cli/dbg.go @@ -0,0 +1,121 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// xpc dbg run|analyze +// +// One-shot debugger wrappers. cdb is the canonical pick because it captures +// stdout cleanly. For interactive sessions, use the live debuggers directly +// over an `xpc tun -L`-exposed dbgsrv (see `xpc ida start`). +// +// run -- launch a target under cdb and run user-supplied commands. +// analyze -- shorthand for `dbg run --command "!analyze -v" ` against +// a minidump file. + +const defaultCdbBinary = `C:\Program Files\Debugging Tools for Windows\cdb.exe` + +func newDbgCmd(g *Globals) *cobra.Command { + cmd := &cobra.Command{ + Use: "dbg", + Short: "Debugger wrappers (one-shot cdb invocations).", + } + cmd.AddCommand(newDbgRunCmd(g)) + cmd.AddCommand(newDbgAnalyzeCmd(g)) + return cmd +} + +func newDbgRunCmd(g *Globals) *cobra.Command { + var ( + binary, command string + ) + c := &cobra.Command{ + Use: "run ", + Short: "Launch under cdb, run --command, capture output, exit.", + Long: ` is either a path on the VM (e.g. C:\path\to\app.exe) or a +crash-dump file (.dmp). cdb runs --command then quits via the appended ;q. +Pass commands separated by ;`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if g.DryRun { + cmd.Printf("(dry-run) %s -c %q -z|-cf %s\n", binary, command+";q", args[0]) + return nil + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + full := strings.TrimSpace(command) + if full == "" { + full = "lm" // default: list modules + } + full += ";q" + target := args[0] + argv := []string{binary, "-c", full} + if strings.HasSuffix(strings.ToLower(target), ".dmp") { + argv = append(argv, "-z", target) + } else { + argv = append(argv, target) + } + py := buildSubprocessPy(argv) + stdout, stderr, rc, err := runRemoteCmd(ctx, g, py, "python") + if err != nil { + return err + } + cmd.Print(stdout) + if stderr != "" { + cmd.PrintErr(stderr) + } + if rc != 0 { + return &RemoteError{ + error: fmt.Errorf("cdb rc=%d", rc), + ExitCode: rc, + } + } + return nil + }, + } + c.Flags().StringVar(&binary, "binary", defaultCdbBinary, "Path to cdb.exe on the VM") + c.Flags().StringVarP(&command, "command", "c", "lm", "cdb command(s) to run; ;q is auto-appended") + return c +} + +func newDbgAnalyzeCmd(g *Globals) *cobra.Command { + var binary string + c := &cobra.Command{ + Use: "analyze ", + Short: "Run cdb -c '!analyze -v' against a minidump on the VM.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if g.DryRun { + cmd.Printf("(dry-run) %s -c '!analyze -v;q' -z %s\n", binary, args[0]) + return nil + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + argv := []string{binary, "-c", "!analyze -v;q", "-z", stripVMPrefix(args[0])} + py := buildSubprocessPy(argv) + stdout, _, rc, err := runRemoteCmd(ctx, g, py, "python") + if err != nil { + return err + } + cmd.Print(stdout) + if rc != 0 { + return &RemoteError{ + error: fmt.Errorf("cdb rc=%d", rc), + ExitCode: rc, + } + } + return nil + }, + } + c.Flags().StringVar(&binary, "binary", defaultCdbBinary, "Path to cdb.exe on the VM") + return c +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f51e451..d0448ec 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -81,6 +81,10 @@ func New() *cobra.Command { root.AddCommand(newTunCmd(g)) root.AddCommand(newGhidraCmd(g)) root.AddCommand(newIdaCmd(g)) + root.AddCommand(newTraceCmd(g)) + root.AddCommand(newDbgCmd(g)) + root.AddCommand(newSnapCmd(g)) + root.AddCommand(newDaemonCmd(g)) // Filesystem helpers (xpctl extras renamed to top-level). root.AddCommand(newCatCmd(g)) root.AddCommand(newHeadCmd(g)) diff --git a/internal/cli/snap.go b/internal/cli/snap.go new file mode 100644 index 0000000..0e88f01 --- /dev/null +++ b/internal/cli/snap.go @@ -0,0 +1,337 @@ +package cli + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/nficano/xpc/internal/profile" +) + +// xpc snap list|create|restore|delete +// +// Talks to the Proxmox PVE HTTP API at https://:8006/api2/json/. +// Auth is by API token (Proxmox -> Datacenter -> API Tokens). The token id +// has the form "user@realm!tokenname"; the token secret is a UUID. +// +// Profile fields: +// +// proxmox_host Proxmox node hostname or IP +// proxmox_user Token id, e.g. "root@pam!xpc" +// proxmox_node Proxmox node name (for the API path) +// proxmox_vmid QEMU VM id (integer string) +// proxmox_token The token secret (only stored in ~/.xpc/credentials) +// +// All four are read from the active profile. Override with --proxmox-host, +// --proxmox-user, --proxmox-token, --proxmox-node, or set $XPC_PROXMOX_*. + +func newSnapCmd(g *Globals) *cobra.Command { + cmd := &cobra.Command{ + Use: "snap", + Short: "Proxmox VM snapshot operations.", + } + cmd.AddCommand(newSnapListCmd(g)) + cmd.AddCommand(newSnapCreateCmd(g)) + cmd.AddCommand(newSnapRestoreCmd(g)) + cmd.AddCommand(newSnapDeleteCmd(g)) + return cmd +} + +type proxmoxConfig struct { + Host string + Node string + VMID string + TokenID string + Secret string + Insecure bool +} + +func resolveProxmox(g *Globals, cmd *cobra.Command) (*proxmoxConfig, error) { + p, err := g.ResolveProfile() + if err != nil { + return nil, err + } + c := &proxmoxConfig{ + Host: p.ProxmoxHost, + TokenID: p.ProxmoxUser, + } + // Pull from env / flags. Flags take precedence. + if v := os.Getenv("XPC_PROXMOX_HOST"); v != "" { + c.Host = v + } + if v, _ := cmd.Flags().GetString("proxmox-host"); v != "" { + c.Host = v + } + if v := os.Getenv("XPC_PROXMOX_USER"); v != "" { + c.TokenID = v + } + if v, _ := cmd.Flags().GetString("proxmox-user"); v != "" { + c.TokenID = v + } + c.Secret = os.Getenv("XPC_PROXMOX_TOKEN") + if v, _ := cmd.Flags().GetString("proxmox-token"); v != "" { + c.Secret = v + } + if c.Secret == "" { + // Try profile credentials field (we'll add a generic free-form + // "proxmox_token" key in ~/.xpc/credentials if the user opts to + // store it there). + if v := readCredsKey(p, "proxmox_token"); v != "" { + c.Secret = v + } + } + c.Node = os.Getenv("XPC_PROXMOX_NODE") + if v, _ := cmd.Flags().GetString("proxmox-node"); v != "" { + c.Node = v + } + c.VMID = os.Getenv("XPC_PROXMOX_VMID") + if v, _ := cmd.Flags().GetString("proxmox-vmid"); v != "" { + c.VMID = v + } + if v, _ := cmd.Flags().GetBool("proxmox-insecure"); v { + c.Insecure = true + } + + if c.Host == "" { + return nil, wrapUsage(fmt.Errorf("proxmox host not set: pass --proxmox-host or set proxmox_host in the profile")) + } + if c.TokenID == "" { + return nil, wrapUsage(fmt.Errorf("proxmox token id not set: pass --proxmox-user or set proxmox_user in the profile")) + } + if c.Secret == "" { + return nil, wrapUsage(fmt.Errorf("proxmox token secret not set: pass --proxmox-token or set $XPC_PROXMOX_TOKEN")) + } + if c.Node == "" { + return nil, wrapUsage(fmt.Errorf("proxmox node not set: pass --proxmox-node or set $XPC_PROXMOX_NODE")) + } + if c.VMID == "" { + return nil, wrapUsage(fmt.Errorf("proxmox VM id not set: pass --proxmox-vmid or set $XPC_PROXMOX_VMID")) + } + return c, nil +} + +// readCredsKey is a placeholder; the profile package only models known keys. +// For Phase 7 we just return "" and tell users to set $XPC_PROXMOX_TOKEN. +func readCredsKey(_ *profile.Profile, _ string) string { + return "" +} + +func addProxmoxFlags(cmd *cobra.Command) { + cmd.PersistentFlags().String("proxmox-host", "", "Proxmox node host (overrides profile.proxmox_host)") + cmd.PersistentFlags().String("proxmox-user", "", "Proxmox token id, e.g. root@pam!xpc") + cmd.PersistentFlags().String("proxmox-token", "", "Proxmox token secret (or set $XPC_PROXMOX_TOKEN)") + cmd.PersistentFlags().String("proxmox-node", "", "Proxmox node name") + cmd.PersistentFlags().String("proxmox-vmid", "", "QEMU VM id") + cmd.PersistentFlags().Bool("proxmox-insecure", false, "Skip TLS verification of the Proxmox API endpoint") +} + +func proxmoxRequest(ctx context.Context, c *proxmoxConfig, method, path string, body url.Values) (map[string]interface{}, error) { + endpoint := fmt.Sprintf("https://%s:8006%s", c.Host, path) + var reader io.Reader + if body != nil { + reader = strings.NewReader(body.Encode()) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "PVEAPIToken="+c.TokenID+"="+c.Secret) + if body != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: c.Insecure}, //nolint:gosec + }, + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, wrapConnection(err) + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("proxmox %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw))) + } + var parsed map[string]interface{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("proxmox json decode: %w", err) + } + } + return parsed, nil +} + +func newSnapListCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "list", + Short: "List snapshots for the configured VM.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + pc, err := resolveProxmox(g, cmd) + if err != nil { + return err + } + path := fmt.Sprintf("/api2/json/nodes/%s/qemu/%s/snapshot", + url.PathEscape(pc.Node), url.PathEscape(pc.VMID)) + out, err := proxmoxRequest(ctx, pc, "GET", path, nil) + if err != nil { + return err + } + data, _ := out["data"].([]interface{}) + if g.OutputMode == "json" { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(data) + } + if len(data) == 0 { + cmd.Println("(no snapshots)") + return nil + } + cmd.Printf("%-25s %-25s %s\n", "NAME", "PARENT", "DESCRIPTION") + for _, raw := range data { + e, _ := raw.(map[string]interface{}) + name, _ := e["name"].(string) + parent, _ := e["parent"].(string) + desc, _ := e["description"].(string) + cmd.Printf("%-25s %-25s %s\n", name, parent, desc) + } + return nil + }, + } + addProxmoxFlags(c) + return c +} + +func newSnapCreateCmd(g *Globals) *cobra.Command { + var includeVMState bool + c := &cobra.Command{ + Use: "create [description]", + Short: "Create a snapshot on the configured VM.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + pc, err := resolveProxmox(g, cmd) + if err != nil { + return err + } + if g.DryRun { + cmd.Printf("(dry-run) POST /nodes/%s/qemu/%s/snapshot snapname=%s vmstate=%v\n", + pc.Node, pc.VMID, args[0], includeVMState) + return nil + } + path := fmt.Sprintf("/api2/json/nodes/%s/qemu/%s/snapshot", + url.PathEscape(pc.Node), url.PathEscape(pc.VMID)) + form := url.Values{} + form.Set("snapname", args[0]) + if includeVMState { + form.Set("vmstate", "1") + } + if len(args) > 1 { + form.Set("description", args[1]) + } + out, err := proxmoxRequest(ctx, pc, "POST", path, form) + if err != nil { + return err + } + cmd.Printf("snapshot create: %v\n", out["data"]) + return nil + }, + } + c.Flags().BoolVar(&includeVMState, "vmstate", false, "Include the VM state (memory) in the snapshot") + addProxmoxFlags(c) + return c +} + +func newSnapRestoreCmd(g *Globals) *cobra.Command { + var startAfter bool + c := &cobra.Command{ + Use: "restore ", + Short: "Roll back the configured VM to .", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + pc, err := resolveProxmox(g, cmd) + if err != nil { + return err + } + if g.DryRun { + cmd.Printf("(dry-run) POST /nodes/%s/qemu/%s/snapshot/%s/rollback start=%v\n", + pc.Node, pc.VMID, args[0], startAfter) + return nil + } + path := fmt.Sprintf("/api2/json/nodes/%s/qemu/%s/snapshot/%s/rollback", + url.PathEscape(pc.Node), url.PathEscape(pc.VMID), url.PathEscape(args[0])) + form := url.Values{} + if startAfter { + form.Set("start", "1") + } + out, err := proxmoxRequest(ctx, pc, "POST", path, form) + if err != nil { + return err + } + cmd.Printf("rollback: %v\n", out["data"]) + return nil + }, + } + c.Flags().BoolVar(&startAfter, "start", false, "Start the VM after rollback") + addProxmoxFlags(c) + return c +} + +func newSnapDeleteCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "delete ", + Short: "Delete a snapshot.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + pc, err := resolveProxmox(g, cmd) + if err != nil { + return err + } + if g.DryRun { + cmd.Printf("(dry-run) DELETE /nodes/%s/qemu/%s/snapshot/%s\n", + pc.Node, pc.VMID, args[0]) + return nil + } + path := fmt.Sprintf("/api2/json/nodes/%s/qemu/%s/snapshot/%s", + url.PathEscape(pc.Node), url.PathEscape(pc.VMID), url.PathEscape(args[0])) + out, err := proxmoxRequest(ctx, pc, "DELETE", path, nil) + if err != nil { + return err + } + cmd.Printf("delete: %v\n", out["data"]) + return nil + }, + } + addProxmoxFlags(c) + return c +} diff --git a/internal/cli/trace.go b/internal/cli/trace.go new file mode 100644 index 0000000..897a2c7 --- /dev/null +++ b/internal/cli/trace.go @@ -0,0 +1,143 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// xpc trace start|stop|pull +// +// Wraps Sysinternals procmon.exe on the VM. The agent stays out of this +// loop -- procmon is invoked directly via the agent's exec/python shell. +// +// Typical workflow: +// xpc trace start --output C:\xpc\traces\foo.pml --filter '*notepad*' +// ... do stuff ... +// xpc trace stop +// xpc trace pull C:\xpc\traces\foo.pml ./foo.pml + +const defaultProcmonBinary = `C:\xpc\tools\procmon.exe` + +func newTraceCmd(g *Globals) *cobra.Command { + cmd := &cobra.Command{ + Use: "trace", + Short: "Sysinternals Process Monitor lifecycle on the VM (start/stop/pull).", + } + cmd.AddCommand(newTraceStartCmd(g)) + cmd.AddCommand(newTraceStopCmd(g)) + cmd.AddCommand(newTracePullCmd(g)) + return cmd +} + +func newTraceStartCmd(g *Globals) *cobra.Command { + var ( + binary, output string + runtime int + ) + c := &cobra.Command{ + Use: "start", + Short: "Start procmon on the VM with a backing file (detached).", + Long: `Launches procmon.exe /accepteula /quiet /minimized +/backingfile [/runtime ] in the background. Use +` + "`xpc trace stop`" + ` to terminate manually, or supply --runtime to have +procmon stop itself.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if g.DryRun { + cmd.Printf("(dry-run) %s /accepteula /quiet /minimized /backingfile %s%s\n", + binary, output, runtimeSuffix(runtime)) + return nil + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + argv := []string{binary, "/accepteula", "/quiet", "/minimized", "/backingfile", output} + if runtime > 0 { + argv = append(argv, "/runtime", fmt.Sprintf("%d", runtime)) + } + py := buildDetachedSpawnPy(argv, `C:\xpc\trace.runlog`) + stdout, stderr, rc, err := runRemoteCmd(ctx, g, py, "python") + if err != nil { + return err + } + if rc != 0 { + return &RemoteError{ + error: fmt.Errorf("trace start rc=%d: %s", rc, strings.TrimSpace(stderr)), + ExitCode: rc, + } + } + cmd.Print(stdout) + cmd.Printf("procmon started; backing file: %s\n", output) + cmd.Println("Stop with `xpc trace stop`, then pull with `xpc trace pull`.") + return nil + }, + } + c.Flags().StringVar(&binary, "binary", defaultProcmonBinary, "Path to procmon.exe on the VM") + c.Flags().StringVar(&output, "output", `C:\xpc\traces\trace.pml`, "Backing-file path on the VM") + c.Flags().IntVar(&runtime, "runtime", 0, "Auto-stop after N seconds (0 = manual stop)") + return c +} + +func newTraceStopCmd(g *Globals) *cobra.Command { + var binary string + c := &cobra.Command{ + Use: "stop", + Short: "Terminate the running procmon (procmon.exe /Terminate).", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if g.DryRun { + cmd.Printf("(dry-run) %s /Terminate\n", binary) + return nil + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + argv := []string{binary, "/Terminate"} + py := buildSubprocessPy(argv) + stdout, stderr, rc, err := runRemoteCmd(ctx, g, py, "python") + if err != nil { + return err + } + cmd.Print(stdout) + if rc != 0 { + cmd.PrintErr(stderr) + return &RemoteError{ + error: fmt.Errorf("trace stop rc=%d", rc), + ExitCode: rc, + } + } + return nil + }, + } + c.Flags().StringVar(&binary, "binary", defaultProcmonBinary, "Path to procmon.exe on the VM") + return c +} + +func newTracePullCmd(g *Globals) *cobra.Command { + return &cobra.Command{ + Use: "pull ", + Short: "Copy a procmon backing file (.pml) back to the host. Alias for `xpc cp`.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + vmPath := stripVMPrefix(args[0]) + hostPath := args[1] + return cpDownload(ctx, cmd, g, vmPath, hostPath) + }, + } +} + +func runtimeSuffix(seconds int) string { + if seconds <= 0 { + return "" + } + return fmt.Sprintf(" /runtime %d", seconds) +} diff --git a/internal/cli/tun.go b/internal/cli/tun.go index 1292cfe..5419ce3 100644 --- a/internal/cli/tun.go +++ b/internal/cli/tun.go @@ -25,6 +25,7 @@ import ( func newTunCmd(g *Globals) *cobra.Command { var ( localSpec string + reverseSpec string idleTimeout time.Duration ) c := &cobra.Command{ @@ -44,6 +45,10 @@ Examples: `, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if reverseSpec != "" { + return wrapUsage(fmt.Errorf( + "-R reverse forwarding is not yet implemented: needs an agent->host tool.invoke primitive (TASKS.md open question)")) + } if localSpec == "" { return wrapUsage(fmt.Errorf("--local (or -L) is required: localPort:vmHost:vmPort")) } @@ -82,6 +87,7 @@ Examples: }, } c.Flags().StringVarP(&localSpec, "local", "L", "", "Forward localPort:vmHost:vmPort (mirrors ssh -L)") + c.Flags().StringVarP(&reverseSpec, "remote", "R", "", "Reverse forward (NOT YET IMPLEMENTED)") c.Flags().DurationVar(&idleTimeout, "idle-timeout", 0, "Drop forwards idle for this long (0 = never)") return c } diff --git a/internal/sshlife/ssh.go b/internal/sshlife/ssh.go index e237118..a8ffad8 100644 --- a/internal/sshlife/ssh.go +++ b/internal/sshlife/ssh.go @@ -9,15 +9,22 @@ // The remote shell is the Cygwin bash sshd that xpctl bootstraps on the VM. // Paths are POSIX-style (/cygdrive/c/...) for the upload helpers; the // PutFile/PutBytes wrappers convert from C:\... automatically. +// +// Host-key trust uses TOFU (trust on first use): the first connection to a +// new host writes its key to ~/.xpc/known_hosts; subsequent connections +// require a byte-for-byte match. A changed key short-circuits the dial with +// an error. package sshlife import ( "bytes" + "encoding/base64" "errors" "fmt" "io" "net" "os" + "path/filepath" "strings" "time" @@ -33,10 +40,14 @@ type Client struct { // DialOptions configures Dial. type DialOptions struct { - User string - Password string - Timeout time.Duration - HostKeyCallback ssh.HostKeyCallback // default: InsecureIgnoreHostKey for v0 (TODO Phase 5b: TOFU) + User string + Password string + Timeout time.Duration + // HostKeyCallback overrides the default TOFU callback. Leave nil to + // trust on first use against ~/.xpc/known_hosts. + HostKeyCallback ssh.HostKeyCallback + // KnownHostsPath overrides the default ~/.xpc/known_hosts location. + KnownHostsPath string } // Dial opens an SSH connection. addr is "host:port"; if no port, 22 is used. @@ -49,7 +60,15 @@ func Dial(addr string, opt DialOptions) (*Client, error) { } hk := opt.HostKeyCallback if hk == nil { - hk = ssh.InsecureIgnoreHostKey() //nolint:gosec // v0 SSH-bootstrap; future Phase 5b adds TOFU + path := opt.KnownHostsPath + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("ssh: home dir: %w", err) + } + path = filepath.Join(home, ".xpc", "known_hosts") + } + hk = TOFUHostKey(path) } cfg := &ssh.ClientConfig{ User: opt.User, @@ -225,3 +244,91 @@ func cygDir(p string) string { func shellQ(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +// TOFUHostKey returns an ssh.HostKeyCallback that: +// +// - accepts and records the host key on first contact (writes to path) +// - rejects subsequent connections whose key differs from the recorded one +// +// The known_hosts file uses the standard OpenSSH-ish line format +// " ". Multiple entries per host are tolerated; +// the callback succeeds if any line matches the presented key exactly. +func TOFUHostKey(path string) ssh.HostKeyCallback { + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + encoded := base64.StdEncoding.EncodeToString(key.Marshal()) + canonical := canonicalHostName(hostname, remote) + + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("ssh: read %s: %w", path, err) + } + + // Look for an existing entry for this host. + var existingTypes []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, " ", 3) + if len(parts) < 3 { + continue + } + if !hostMatches(parts[0], canonical) { + continue + } + if parts[1] == key.Type() && parts[2] == encoded { + return nil + } + existingTypes = append(existingTypes, parts[1]) + } + + if len(existingTypes) > 0 { + return fmt.Errorf( + "ssh: host key for %s changed (have %s in %s, presenting %s) -- potential MITM, refusing", + canonical, strings.Join(existingTypes, ","), path, key.Type()) + } + + // First contact: append. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("ssh: mkdir %s: %w", filepath.Dir(path), err) + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("ssh: open %s: %w", path, err) + } + defer func() { _ = f.Close() }() + if _, err := fmt.Fprintf(f, "%s %s %s\n", canonical, key.Type(), encoded); err != nil { + return fmt.Errorf("ssh: append %s: %w", path, err) + } + return nil + } +} + +// canonicalHostName strips the :port suffix that x/crypto/ssh adds to +// hostname, matching the OpenSSH known_hosts convention. +func canonicalHostName(hostname string, _ net.Addr) string { + if idx := strings.LastIndex(hostname, ":"); idx > 0 { + // Make sure it isn't an IPv6 literal "[::1]:22". + if !strings.Contains(hostname[:idx], "]") || strings.HasPrefix(hostname, "[") { + return hostname[:idx] + } + } + return hostname +} + +// hostMatches checks whether a known_hosts hostname field matches the dialed +// hostname. We do not implement OpenSSH's full hashed/wildcard semantics -- +// just exact match plus a few common formats. +func hostMatches(stored, dialed string) bool { + if stored == dialed { + return true + } + // Stored "h1,h2" comma list. + for _, h := range strings.Split(stored, ",") { + if h == dialed { + return true + } + } + return false +} diff --git a/internal/sshlife/tofu_test.go b/internal/sshlife/tofu_test.go new file mode 100644 index 0000000..cecae42 --- /dev/null +++ b/internal/sshlife/tofu_test.go @@ -0,0 +1,109 @@ +package sshlife + +import ( + "crypto/rand" + "crypto/rsa" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" +) + +func mustPubKey(t *testing.T) ssh.PublicKey { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa keygen: %v", err) + } + pub, err := ssh.NewPublicKey(&priv.PublicKey) + if err != nil { + t.Fatalf("ssh.NewPublicKey: %v", err) + } + return pub +} + +func tcpAddr(t *testing.T) net.Addr { + t.Helper() + a, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:22") + return a +} + +func TestTOFU_FirstContactWritesEntry(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "known_hosts") + cb := TOFUHostKey(path) + key := mustPubKey(t) + + if err := cb("xp-vm:22", tcpAddr(t), key); err != nil { + t.Fatalf("first contact: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if !strings.HasPrefix(string(data), "xp-vm ") { + t.Fatalf("expected entry to start with hostname; got %q", string(data)) + } + st, _ := os.Stat(path) + if st.Mode().Perm() != 0o600 { + t.Fatalf("known_hosts perm = %o; want 0600", st.Mode().Perm()) + } +} + +func TestTOFU_SecondContactWithSameKeySucceeds(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "known_hosts") + cb := TOFUHostKey(path) + key := mustPubKey(t) + + if err := cb("xp-vm:22", tcpAddr(t), key); err != nil { + t.Fatalf("first: %v", err) + } + if err := cb("xp-vm:22", tcpAddr(t), key); err != nil { + t.Fatalf("second: %v", err) + } +} + +func TestTOFU_KeyChangeRejected(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "known_hosts") + cb := TOFUHostKey(path) + first := mustPubKey(t) + second := mustPubKey(t) + + if err := cb("xp-vm:22", tcpAddr(t), first); err != nil { + t.Fatalf("first: %v", err) + } + err := cb("xp-vm:22", tcpAddr(t), second) + if err == nil { + t.Fatal("expected key-change rejection") + } + if !strings.Contains(err.Error(), "host key for xp-vm changed") { + t.Fatalf("err = %v; want host-key-changed message", err) + } +} + +func TestTOFU_DifferentHostsCoexist(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "known_hosts") + cb := TOFUHostKey(path) + + if err := cb("vm-a:22", tcpAddr(t), mustPubKey(t)); err != nil { + t.Fatalf("first host: %v", err) + } + if err := cb("vm-b:22", tcpAddr(t), mustPubKey(t)); err != nil { + t.Fatalf("second host: %v", err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), "vm-a ") || !strings.Contains(string(data), "vm-b ") { + t.Fatalf("expected both host entries; got:\n%s", string(data)) + } +}