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.
This commit is contained in:
parent
a59958d4f5
commit
ae14b9499d
14 changed files with 634 additions and 47 deletions
|
|
@ -31,14 +31,14 @@ func (d *Daemon) waitForGuestSSH(ctx context.Context, address string, interval t
|
|||
if d != nil && d.guestWaitForSSH != nil {
|
||||
return d.guestWaitForSSH(ctx, address, d.config.SSHKeyPath, interval)
|
||||
}
|
||||
return guest.WaitForSSH(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)
|
||||
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) {
|
||||
|
|
@ -86,7 +86,7 @@ func (d *Daemon) refreshGuestSession(ctx context.Context, vm model.VMRecord, s m
|
|||
|
||||
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)
|
||||
client, err := guest.Dial(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"), d.config.SSHKeyPath, d.layout.KnownHostsPath)
|
||||
if err != nil {
|
||||
return session.StateSnapshot{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ func (d *Daemon) attachGuestSessionBridge(session model.GuestSession, controller
|
|||
}
|
||||
|
||||
func (d *Daemon) openGuestSessionAttachStream(address, command string) (*guest.StreamSession, error) {
|
||||
client, err := guest.Dial(context.Background(), address, d.config.SSHKeyPath)
|
||||
client, err := guest.Dial(context.Background(), address, d.config.SSHKeyPath, d.layout.KnownHostsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ func (d *Daemon) signalGuestSession(ctx context.Context, params api.GuestSession
|
|||
}
|
||||
return session, nil
|
||||
}
|
||||
client, err := guest.Dial(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"), d.config.SSHKeyPath)
|
||||
client, err := guest.Dial(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"), d.config.SSHKeyPath, d.layout.KnownHostsPath)
|
||||
if err != nil {
|
||||
return model.GuestSession{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ func (d *Daemon) SendToGuestSession(ctx context.Context, params api.GuestSession
|
|||
|
||||
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)
|
||||
client, err := guest.Dial(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"), d.config.SSHKeyPath, d.layout.KnownHostsPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,41 @@ package daemon
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"banger/internal/guest"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
)
|
||||
|
||||
// removeVMKnownHosts drops every host-key pin for vm from the
|
||||
// banger-owned known_hosts. Best-effort — a failure here only
|
||||
// matters if the same IP/name is reused by a fresh VM before the
|
||||
// next daemon restart, and even then it just causes a
|
||||
// TOFU-mismatch error that the user can clear manually. Logged at
|
||||
// warn so it shows up if it ever actually breaks things.
|
||||
func removeVMKnownHosts(knownHostsPath string, vm model.VMRecord, logger *slog.Logger) {
|
||||
if strings.TrimSpace(knownHostsPath) == "" {
|
||||
return
|
||||
}
|
||||
var hosts []string
|
||||
if ip := strings.TrimSpace(vm.Runtime.GuestIP); ip != "" {
|
||||
hosts = append(hosts, ip)
|
||||
}
|
||||
if dns := strings.TrimSpace(vm.Runtime.DNSName); dns != "" {
|
||||
hosts = append(hosts, dns)
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return
|
||||
}
|
||||
if err := guest.RemoveKnownHosts(knownHostsPath, hosts...); err != nil && logger != nil {
|
||||
logger.Warn("remove known_hosts entries", "vm_id", vm.ID, "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
vmSSHConfigIncludeBegin = "# BEGIN BANGER MANAGED VM SSH"
|
||||
vmSSHConfigIncludeEnd = "# END BANGER MANAGED VM SSH"
|
||||
|
|
@ -39,7 +67,7 @@ func syncVMSSHClientConfig(layout paths.Layout, keyPath string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := upsertManagedBlock(userConfig, vmSSHConfigIncludeBegin, vmSSHConfigIncludeEnd, renderManagedVMSSHBlock(keyPath))
|
||||
updated, err := upsertManagedBlock(userConfig, vmSSHConfigIncludeBegin, vmSSHConfigIncludeEnd, renderManagedVMSSHBlock(keyPath, layout.KnownHostsPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -54,11 +82,19 @@ func syncVMSSHClientConfig(layout paths.Layout, keyPath string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func renderManagedVMSSHBlock(keyPath string) string {
|
||||
// renderManagedVMSSHBlock produces the `Host *.vm` stanza banger
|
||||
// writes into the user's ~/.ssh/config. Host-key verification uses
|
||||
// the banger-owned known_hosts file at knownHostsPath — NOT the
|
||||
// user's ~/.ssh/known_hosts, and NOT /dev/null. `accept-new` means
|
||||
// first contact pins the key; any later mismatch fails the connect.
|
||||
func renderManagedVMSSHBlock(keyPath, knownHostsPath string) string {
|
||||
keyPath = strings.TrimSpace(keyPath)
|
||||
return strings.Join([]string{
|
||||
knownHostsPath = strings.TrimSpace(knownHostsPath)
|
||||
lines := []string{
|
||||
vmSSHConfigIncludeBegin,
|
||||
"# Generated by banger for direct SSH access to VM DNS names.",
|
||||
"# Host keys are pinned on first use into a banger-owned",
|
||||
"# known_hosts file (not ~/.ssh/known_hosts).",
|
||||
"Host *.vm",
|
||||
" User root",
|
||||
" IdentityFile " + keyPath,
|
||||
|
|
@ -67,12 +103,23 @@ func renderManagedVMSSHBlock(keyPath string) string {
|
|||
" PreferredAuthentications publickey",
|
||||
" PasswordAuthentication no",
|
||||
" KbdInteractiveAuthentication no",
|
||||
" StrictHostKeyChecking no",
|
||||
" UserKnownHostsFile /dev/null",
|
||||
}
|
||||
if knownHostsPath != "" {
|
||||
lines = append(lines,
|
||||
" UserKnownHostsFile "+knownHostsPath,
|
||||
" StrictHostKeyChecking accept-new",
|
||||
)
|
||||
} else {
|
||||
// Missing known_hosts path is a configuration anomaly — fail
|
||||
// closed rather than silently disable verification.
|
||||
lines = append(lines, " StrictHostKeyChecking yes")
|
||||
}
|
||||
lines = append(lines,
|
||||
" LogLevel ERROR",
|
||||
vmSSHConfigIncludeEnd,
|
||||
"",
|
||||
}, "\n")
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func upsertManagedBlock(existing, beginMarker, endMarker, block string) (string, error) {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ func TestSyncVMSSHClientConfigCreatesManagedBlock(t *testing.T) {
|
|||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
knownHostsPath := filepath.Join(homeDir, ".local", "state", "banger", "ssh", "known_hosts")
|
||||
layout := paths.Layout{
|
||||
ConfigDir: filepath.Join(homeDir, ".config", "banger"),
|
||||
ConfigDir: filepath.Join(homeDir, ".config", "banger"),
|
||||
KnownHostsPath: knownHostsPath,
|
||||
}
|
||||
keyPath := filepath.Join(layout.ConfigDir, "ssh", "id_ed25519")
|
||||
|
||||
|
|
@ -38,12 +40,23 @@ func TestSyncVMSSHClientConfigCreatesManagedBlock(t *testing.T) {
|
|||
"IdentitiesOnly yes",
|
||||
"BatchMode yes",
|
||||
"PasswordAuthentication no",
|
||||
"UserKnownHostsFile /dev/null",
|
||||
"UserKnownHostsFile " + knownHostsPath,
|
||||
"StrictHostKeyChecking accept-new",
|
||||
} {
|
||||
if !strings.Contains(userContent, want) {
|
||||
t.Fatalf("user config = %q, want %q", userContent, want)
|
||||
}
|
||||
}
|
||||
// Regression: the legacy posture (StrictHostKeyChecking no +
|
||||
// UserKnownHostsFile /dev/null) must never reappear.
|
||||
for _, must := range []string{
|
||||
"StrictHostKeyChecking no",
|
||||
"UserKnownHostsFile /dev/null",
|
||||
} {
|
||||
if strings.Contains(userContent, must) {
|
||||
t.Fatalf("user config leaked legacy posture %q:\n%s", must, userContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncVMSSHClientConfigReplacesManagedIncludeBlock(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -411,5 +411,9 @@ func (d *Daemon) deleteVMLocked(ctx context.Context, current model.VMRecord) (vm
|
|||
return model.VMRecord{}, err
|
||||
}
|
||||
}
|
||||
// Drop any host-key pins. A future VM reusing this IP or name
|
||||
// would otherwise trip the TOFU mismatch branch in
|
||||
// TOFUHostKeyCallback and fail to connect.
|
||||
removeVMKnownHosts(d.layout.KnownHostsPath, vm, d.logger)
|
||||
return vm, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue