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.
142 lines
4.6 KiB
Go
142 lines
4.6 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"banger/internal/daemon/session"
|
|
"banger/internal/guest"
|
|
"banger/internal/model"
|
|
"banger/internal/system"
|
|
)
|
|
|
|
type guestSSHClient interface {
|
|
Close() error
|
|
RunScript(context.Context, string, io.Writer) error
|
|
RunScriptOutput(context.Context, string) ([]byte, error)
|
|
UploadFile(context.Context, string, os.FileMode, []byte, io.Writer) error
|
|
StreamTar(context.Context, string, string, io.Writer) error
|
|
StreamTarEntries(context.Context, string, []string, string, io.Writer) error
|
|
}
|
|
|
|
func (d *Daemon) waitForGuestSSH(ctx context.Context, address string, interval time.Duration) error {
|
|
if d != nil && d.guestWaitForSSH != nil {
|
|
return d.guestWaitForSSH(ctx, address, d.config.SSHKeyPath, interval)
|
|
}
|
|
return guest.WaitForSSH(ctx, address, d.config.SSHKeyPath, d.layout.KnownHostsPath, interval)
|
|
}
|
|
|
|
func (d *Daemon) dialGuest(ctx context.Context, address string) (guestSSHClient, error) {
|
|
if d != nil && d.guestDial != nil {
|
|
return d.guestDial(ctx, address, d.config.SSHKeyPath)
|
|
}
|
|
return guest.Dial(ctx, address, d.config.SSHKeyPath, d.layout.KnownHostsPath)
|
|
}
|
|
|
|
func (d *Daemon) waitForGuestSessionReadyHook(ctx context.Context, vm model.VMRecord, s model.GuestSession) (model.GuestSession, error) {
|
|
if d != nil && d.waitForGuestSessionReady != nil {
|
|
return d.waitForGuestSessionReady(ctx, vm, s)
|
|
}
|
|
return d.waitForGuestSessionReadyDefault(ctx, vm, s)
|
|
}
|
|
|
|
func (d *Daemon) waitForGuestSessionReadyDefault(ctx context.Context, vm model.VMRecord, s model.GuestSession) (model.GuestSession, error) {
|
|
for {
|
|
updated, err := d.refreshGuestSession(ctx, vm, s)
|
|
if err == nil {
|
|
s = updated
|
|
if s.GuestPID != 0 || s.ExitCode != nil || s.Status == model.GuestSessionStatusRunning || s.Status == model.GuestSessionStatusFailed || s.Status == model.GuestSessionStatusExited {
|
|
return s, nil
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return s, ctx.Err()
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *Daemon) refreshGuestSession(ctx context.Context, vm model.VMRecord, s model.GuestSession) (model.GuestSession, error) {
|
|
if s.Status != model.GuestSessionStatusStarting && s.Status != model.GuestSessionStatusRunning && s.Status != model.GuestSessionStatusStopping {
|
|
return s, nil
|
|
}
|
|
snapshot, err := d.inspectGuestSessionState(ctx, vm, s)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
original := s
|
|
session.ApplyStateSnapshot(&s, snapshot, d.vmAlive(vm))
|
|
if session.StateChanged(original, s) {
|
|
s.UpdatedAt = model.Now()
|
|
if err := d.store.UpsertGuestSession(ctx, s); err != nil {
|
|
return s, err
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (d *Daemon) inspectGuestSessionState(ctx context.Context, vm model.VMRecord, s model.GuestSession) (session.StateSnapshot, 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 session.StateSnapshot{}, err
|
|
}
|
|
defer client.Close()
|
|
var output bytes.Buffer
|
|
if err := client.RunScript(ctx, session.InspectScript(s.ID), &output); err != nil {
|
|
return session.StateSnapshot{}, session.FormatStepError("inspect guest session state", err, output.String())
|
|
}
|
|
return session.ParseState(output.String())
|
|
}
|
|
return d.inspectGuestSessionStateFromWorkDisk(ctx, vm, s.ID)
|
|
}
|
|
|
|
func (d *Daemon) inspectGuestSessionStateFromWorkDisk(ctx context.Context, vm model.VMRecord, sessionID string) (session.StateSnapshot, error) {
|
|
runner := d.runner
|
|
if runner == nil {
|
|
runner = system.NewRunner()
|
|
}
|
|
workMount, cleanup, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
|
|
if err != nil {
|
|
return session.StateSnapshot{}, err
|
|
}
|
|
defer cleanup()
|
|
stateDir := filepath.Join(workMount, session.RelativeStateDir(sessionID))
|
|
return session.InspectStateFromDir(stateDir)
|
|
}
|
|
|
|
func (d *Daemon) findGuestSession(ctx context.Context, vmID, idOrName string) (model.GuestSession, error) {
|
|
if strings.TrimSpace(idOrName) == "" {
|
|
return model.GuestSession{}, errors.New("session id or name is required")
|
|
}
|
|
if s, err := d.store.GetGuestSession(ctx, vmID, idOrName); err == nil {
|
|
return s, nil
|
|
}
|
|
sessions, err := d.store.ListGuestSessionsByVM(ctx, vmID)
|
|
if err != nil {
|
|
return model.GuestSession{}, err
|
|
}
|
|
matches := make([]model.GuestSession, 0, 1)
|
|
for _, s := range sessions {
|
|
if strings.HasPrefix(s.ID, idOrName) || strings.HasPrefix(s.Name, idOrName) {
|
|
matches = append(matches, s)
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
return model.GuestSession{}, fmt.Errorf("session %q not found", idOrName)
|
|
case 1:
|
|
return matches[0], nil
|
|
default:
|
|
return model.GuestSession{}, fmt.Errorf("multiple sessions match %q", idOrName)
|
|
}
|
|
}
|