banger/internal/daemon/session_stream.go
Thales Maciel ae14b9499d
ssh: trust-on-first-use host key pinning everywhere
Guest host-key verification was off in all three SSH paths:

  * Go SSH (internal/guest/ssh.go) used ssh.InsecureIgnoreHostKey
  * `banger vm ssh` passed StrictHostKeyChecking=no
    + UserKnownHostsFile=/dev/null
  * `~/.ssh/config` Host *.vm shipped the same posture into the
    user's global config

Now each path verifies against a banger-owned known_hosts file at
`~/.local/state/banger/ssh/known_hosts` with TOFU semantics:

  * First dial to a VM pins the key.
  * Subsequent dials require an exact match. A mismatch fails with
    an explicit "possible MITM" error.
  * `vm delete` removes the entries so a future VM reusing the IP
    or name re-pins cleanly.
  * The user's `~/.ssh/known_hosts` is untouched.

Changes:

  internal/guest/known_hosts.go (new) — OpenSSH-compatible parser,
    TOFUHostKeyCallback, RemoveKnownHosts. Process-wide mutex
    around the file.
  internal/guest/ssh.go — Dial and WaitForSSH grew a knownHostsPath
    parameter threaded through the callback. Empty path keeps the
    insecure callback (tests + throwaway tools only; documented).
  internal/daemon/{guest_sessions,session_attach,session_lifecycle,
    session_stream}.go — call sites pass d.layout.KnownHostsPath.
  internal/daemon/ssh_client_config.go — the ~/.ssh/config Host *.vm
    block now points at banger's known_hosts and uses
    StrictHostKeyChecking=accept-new. Missing path → fail closed.
  internal/daemon/vm_lifecycle.go — deleteVMLocked drops known_hosts
    entries for the VM's IP and DNS name via removeVMKnownHosts.
  internal/cli/banger.go — sshCommandArgs swaps StrictHostKeyChecking
    no + /dev/null for banger's file + accept-new. Path resolution
    failure falls through to StrictHostKeyChecking=yes.
  internal/paths/paths.go — Layout gains SSHDir + KnownHostsPath;
    Ensure creates SSHDir at 0700.

Tests (internal/guest/known_hosts_test.go): pin on first use, accept
matching key on second dial, reject mismatch, empty path skips
checking, RemoveKnownHosts drops the entry, re-pin works after
remove. Existing daemon + cli tests updated to assert the new
posture and regression-guard against the old flags.

Live verified: vm run writes the pin to banger's known_hosts at 0600
inside a 0700 dir; banger vm ssh + ssh root@<vm>.vm both succeed
using the pin; vm delete clears it.
2026-04-19 16:46:03 -03:00

120 lines
4.1 KiB
Go

package daemon
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"path/filepath"
"strings"
"banger/internal/api"
sess "banger/internal/daemon/session"
"banger/internal/guest"
"banger/internal/model"
"banger/internal/system"
)
func (d *Daemon) GuestSessionLogs(ctx context.Context, params api.GuestSessionLogsParams) (api.GuestSessionLogsResult, error) {
vm, err := d.FindVM(ctx, params.VMIDOrName)
if err != nil {
return api.GuestSessionLogsResult{}, err
}
session, err := d.findGuestSession(ctx, vm.ID, params.SessionIDOrName)
if err != nil {
return api.GuestSessionLogsResult{}, err
}
streamName := strings.TrimSpace(params.Stream)
if streamName == "" {
streamName = "stdout"
}
tailLines := params.TailLines
if tailLines <= 0 {
tailLines = sess.LogTailLineDefault
}
path := session.StdoutLogPath
if streamName == "stderr" {
path = session.StderrLogPath
}
content, err := d.readGuestSessionLog(ctx, vm, session, streamName, tailLines)
if err != nil {
return api.GuestSessionLogsResult{}, err
}
return api.GuestSessionLogsResult{Session: session, Stream: streamName, Path: path, Content: content}, nil
}
func (d *Daemon) SendToGuestSession(ctx context.Context, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
vm, err := d.FindVM(ctx, params.VMIDOrName)
if err != nil {
return api.GuestSessionSendResult{}, err
}
session, err := d.findGuestSession(ctx, vm.ID, params.SessionIDOrName)
if err != nil {
return api.GuestSessionSendResult{}, err
}
if session.StdinMode != model.GuestSessionStdinPipe {
return api.GuestSessionSendResult{}, errors.New("session does not have a stdin pipe")
}
if session.Status != model.GuestSessionStatusRunning {
return api.GuestSessionSendResult{}, fmt.Errorf("session is not running (status=%s)", session.Status)
}
if !d.vmAlive(vm) {
return api.GuestSessionSendResult{}, fmt.Errorf("vm %q is not running", vm.Name)
}
if len(params.Payload) == 0 {
return api.GuestSessionSendResult{Session: session}, nil
}
client, err := d.dialGuest(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"))
if err != nil {
return api.GuestSessionSendResult{}, fmt.Errorf("dial guest: %w", err)
}
defer client.Close()
tmpPath := fmt.Sprintf("/tmp/banger-send-%s.bin", session.ID[:8])
var uploadLog bytes.Buffer
if err := client.UploadFile(ctx, tmpPath, 0o600, params.Payload, &uploadLog); err != nil {
return api.GuestSessionSendResult{}, fmt.Errorf("upload payload: %w", err)
}
sendScript := fmt.Sprintf(
"set -euo pipefail\ncat %s >> %s\nrm -f %s\n",
sess.ShellQuote(tmpPath),
sess.ShellQuote(sess.StdinPipePath(session.ID)),
sess.ShellQuote(tmpPath),
)
var sendLog bytes.Buffer
if err := client.RunScript(ctx, sendScript, &sendLog); err != nil {
return api.GuestSessionSendResult{}, fmt.Errorf("send to session: %w: %s", err, strings.TrimSpace(sendLog.String()))
}
return api.GuestSessionSendResult{Session: session, BytesWritten: len(params.Payload)}, nil
}
func (d *Daemon) readGuestSessionLog(ctx context.Context, vm model.VMRecord, session model.GuestSession, stream string, tailLines int) (string, error) {
if d.vmAlive(vm) {
client, err := guest.Dial(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"), d.config.SSHKeyPath, d.layout.KnownHostsPath)
if err != nil {
return "", err
}
defer client.Close()
path := session.StdoutLogPath
if stream == "stderr" {
path = session.StderrLogPath
}
var output bytes.Buffer
script := fmt.Sprintf("set -euo pipefail\nif [ -f %s ]; then tail -n %d %s; fi\n", sess.ShellQuote(path), tailLines, sess.ShellQuote(path))
if err := client.RunScript(ctx, script, &output); err != nil {
return "", sess.FormatStepError("read guest session log", err, output.String())
}
return output.String(), nil
}
runner := d.runner
if runner == nil {
runner = system.NewRunner()
}
workMount, cleanup, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
if err != nil {
return "", err
}
defer cleanup()
logPath := filepath.Join(workMount, sess.RelativeStateDir(session.ID), stream+".log")
return sess.TailFileContent(logPath, tailLines)
}