ssh-config: make the ssh <name>.vm shortcut opt-in
Before this change, every daemon.Open() wrote a Host *.vm stanza into
~/.ssh/config in a marker-fenced block. That's a real footgun for users
who manage their SSH config declaratively (chezmoi, dotfiles, NixOS):
banger was mutating host state outside its own directory on every
daemon start, easy to miss and hard to audit.
New contract: the daemon only ever writes its own ssh_config file at
~/.config/banger/ssh_config. ~/.ssh/config is untouched unless the user
opts in. `banger vm ssh <name>` still works out of the box — the
shortcut only matters for plain `ssh sandbox.vm` from any terminal.
The opt-in surface is `banger ssh-config`:
banger ssh-config # prints path + include-line +
# install/uninstall hints
banger ssh-config --install # adds `Include <bangerConfig>` to
# ~/.ssh/config inside a marker-fenced
# block; idempotent; migrates any
# legacy inline Host *.vm block from
# pre-opt-in builds
banger ssh-config --uninstall # removes the new Include block AND
# any legacy inline block
Doctor gains a gentle warn-level note when banger's ssh_config exists
but the user hasn't wired it in — not a fail, since the shortcut is
convenience and `banger vm ssh` covers the essential case.
Tests cover: daemon writes banger file and does NOT touch ~/.ssh/config,
Install adds the block, Install is idempotent, Install migrates the
legacy inline block cleanly (removing it, preserving unrelated
entries, adding the new Include block), Uninstall removes both marker
variants, Uninstall is a no-op when ~/.ssh/config is absent, and
UserSSHIncludeInstalled detects both marker shapes.
README reframes the feature as optional convenience.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
99d0811097
commit
108f7a0600
7 changed files with 509 additions and 82 deletions
24
README.md
24
README.md
|
|
@ -101,12 +101,28 @@ leaves the VM alive for `banger vm logs` inspection.
|
|||
## Hostnames: reaching `<vm>.vm`
|
||||
|
||||
banger's daemon runs a DNS server for the `.vm` zone. With host-side
|
||||
DNS routing you can `ssh root@sandbox.vm` or `curl
|
||||
http://sandbox.vm:3000` from anywhere on the host — no copy-pasting
|
||||
guest IPs. On systemd-resolved hosts this is auto-wired; everywhere
|
||||
else there's a short recipe. See
|
||||
DNS routing you can `curl http://sandbox.vm:3000` from anywhere on
|
||||
the host — no copy-pasting guest IPs. On systemd-resolved hosts this
|
||||
is auto-wired; everywhere else there's a short recipe. See
|
||||
[`docs/dns-routing.md`](docs/dns-routing.md).
|
||||
|
||||
### Optional: `ssh <name>.vm` shortcut
|
||||
|
||||
`banger vm ssh <name>` works out of the box. If you'd also like plain
|
||||
`ssh sandbox.vm` from any terminal (using banger's key + known_hosts),
|
||||
opt in:
|
||||
|
||||
```bash
|
||||
banger ssh-config --install # adds `Include ~/.config/banger/ssh_config`
|
||||
# to ~/.ssh/config in a marker-fenced block
|
||||
banger ssh-config --uninstall # reverse it
|
||||
banger ssh-config # show the include line to paste manually
|
||||
```
|
||||
|
||||
banger never touches `~/.ssh/config` on its own — the daemon keeps its
|
||||
file fresh at `~/.config/banger/ssh_config`; whether and how it's
|
||||
pulled into your SSH config is up to you.
|
||||
|
||||
## Image catalog
|
||||
|
||||
`banger image pull <name>` fetches a pre-built bundle from the
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ func (d *deps) newRootCommand() *cobra.Command {
|
|||
d.newImageCommand(),
|
||||
d.newInternalCommand(),
|
||||
d.newKernelCommand(),
|
||||
newSSHConfigCommand(),
|
||||
newVersionCommand(),
|
||||
d.newPSCommand(),
|
||||
d.newVMCommand(),
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
|
|||
for _, sub := range cmd.Commands() {
|
||||
names = append(names, sub.Name())
|
||||
}
|
||||
want := []string{"daemon", "doctor", "image", "internal", "kernel", "ps", "version", "vm"}
|
||||
want := []string{"daemon", "doctor", "image", "internal", "kernel", "ps", "ssh-config", "version", "vm"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("subcommands = %v, want %v", names, want)
|
||||
}
|
||||
|
|
|
|||
86
internal/cli/commands_ssh_config.go
Normal file
86
internal/cli/commands_ssh_config.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"banger/internal/daemon"
|
||||
"banger/internal/paths"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newSSHConfigCommand exposes the opt-in ergonomics for `ssh <name>.vm`.
|
||||
// Default mode prints current status + the exact Include line the user
|
||||
// can paste into ~/.ssh/config themselves. --install does the include
|
||||
// for them inside a marker-fenced block; --uninstall reverses it (also
|
||||
// cleans up any legacy inline block from pre-opt-in builds).
|
||||
func newSSHConfigCommand() *cobra.Command {
|
||||
var (
|
||||
install bool
|
||||
uninstall bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "ssh-config",
|
||||
Short: "Manage the optional `ssh <name>.vm` shortcut",
|
||||
Long: `Banger keeps a self-contained SSH client config under its own config
|
||||
directory (never touching ~/.ssh/config on its own). Opt in to the
|
||||
convenience shortcut that lets you run 'ssh <name>.vm' from any
|
||||
terminal, bypassing 'banger vm ssh':
|
||||
|
||||
banger ssh-config # print status + copy-paste snippet
|
||||
banger ssh-config --install # add an Include line to ~/.ssh/config
|
||||
banger ssh-config --uninstall # remove banger's Include from ~/.ssh/config
|
||||
`,
|
||||
Args: noArgsUsage("usage: banger ssh-config [--install|--uninstall]"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if install && uninstall {
|
||||
return fmt.Errorf("use only one of --install or --uninstall")
|
||||
}
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bangerConfig := daemon.BangerSSHConfigPath(layout)
|
||||
switch {
|
||||
case install:
|
||||
if err := daemon.InstallUserSSHInclude(layout); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"added Include %s to ~/.ssh/config — `ssh <name>.vm` will now route through banger\n",
|
||||
bangerConfig,
|
||||
)
|
||||
return err
|
||||
case uninstall:
|
||||
if err := daemon.UninstallUserSSHInclude(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), "removed banger's entries from ~/.ssh/config")
|
||||
return err
|
||||
default:
|
||||
installed, err := daemon.UserSSHIncludeInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintf(out, "banger ssh_config: %s\n", bangerConfig)
|
||||
if installed {
|
||||
fmt.Fprintln(out, "status: included from ~/.ssh/config")
|
||||
fmt.Fprintln(out, "")
|
||||
fmt.Fprintln(out, "`ssh <name>.vm` is enabled. Run `banger ssh-config --uninstall` to revert.")
|
||||
} else {
|
||||
fmt.Fprintln(out, "status: not included (opt-in)")
|
||||
fmt.Fprintln(out, "")
|
||||
fmt.Fprintln(out, "Enable `ssh <name>.vm` in two ways:")
|
||||
fmt.Fprintln(out, " banger ssh-config --install")
|
||||
fmt.Fprintln(out, "or add this line to ~/.ssh/config yourself:")
|
||||
fmt.Fprintf(out, " Include %s\n", bangerConfig)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&install, "install", false, "add an Include line to ~/.ssh/config")
|
||||
cmd.Flags().BoolVar(&uninstall, "uninstall", false, "remove banger's Include from ~/.ssh/config")
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package daemon
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
|
|
@ -55,11 +56,40 @@ func (d *Daemon) doctorReport(ctx context.Context, storeErr error) system.Report
|
|||
report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available")
|
||||
report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available")
|
||||
d.addVMDefaultsCheck(&report)
|
||||
d.addSSHShortcutCheck(&report)
|
||||
d.addCapabilityDoctorChecks(ctx, &report)
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
// addSSHShortcutCheck surfaces a gentle warning when banger maintains
|
||||
// an ssh_config file but the user hasn't wired it into ~/.ssh/config.
|
||||
// This is intentionally a warn, not a fail — the shortcut is opt-in
|
||||
// convenience and `banger vm ssh` works either way.
|
||||
func (d *Daemon) addSSHShortcutCheck(report *system.Report) {
|
||||
bangerConfig := BangerSSHConfigPath(d.layout)
|
||||
if strings.TrimSpace(bangerConfig) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(bangerConfig); err != nil {
|
||||
// No banger ssh_config rendered yet — nothing to include.
|
||||
return
|
||||
}
|
||||
installed, err := UserSSHIncludeInstalled()
|
||||
if err != nil {
|
||||
report.AddWarn("ssh shortcut", fmt.Sprintf("could not read ~/.ssh/config: %v", err))
|
||||
return
|
||||
}
|
||||
if installed {
|
||||
report.AddPass("ssh shortcut", "enabled — `ssh <name>.vm` routes through banger")
|
||||
return
|
||||
}
|
||||
report.AddWarn(
|
||||
"ssh shortcut",
|
||||
fmt.Sprintf("`ssh <name>.vm` not enabled (opt-in); run `banger ssh-config --install` or add `Include %s` to ~/.ssh/config", bangerConfig),
|
||||
)
|
||||
}
|
||||
|
||||
// addArchitectureCheck surfaces a hard-fail when banger is running on
|
||||
// a non-amd64 host. Companion binaries are pinned to amd64 in the
|
||||
// Makefile, the published kernel catalog ships only x86_64 images, and
|
||||
|
|
|
|||
|
|
@ -12,6 +12,23 @@ import (
|
|||
"banger/internal/paths"
|
||||
)
|
||||
|
||||
// Marker sentinels.
|
||||
//
|
||||
// vmSSHConfigIncludeBegin / vmSSHConfigIncludeEnd used to wrap the full
|
||||
// Host *.vm stanza when banger wrote directly into ~/.ssh/config.
|
||||
// We keep the sentinel strings only so uninstall can find and remove
|
||||
// legacy blocks on systems that upgraded from that behaviour.
|
||||
//
|
||||
// The new opt-in flow writes a short Include block with its own marker
|
||||
// pair; the daemon itself no longer touches ~/.ssh/config at all.
|
||||
const (
|
||||
vmSSHConfigIncludeBegin = "# BEGIN BANGER MANAGED VM SSH"
|
||||
vmSSHConfigIncludeEnd = "# END BANGER MANAGED VM SSH"
|
||||
|
||||
bangerSSHIncludeBegin = "# BEGIN BANGER SSH INCLUDE"
|
||||
bangerSSHIncludeEnd = "# END BANGER SSH INCLUDE"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
|
@ -37,10 +54,17 @@ func removeVMKnownHosts(knownHostsPath string, vm model.VMRecord, logger *slog.L
|
|||
}
|
||||
}
|
||||
|
||||
const (
|
||||
vmSSHConfigIncludeBegin = "# BEGIN BANGER MANAGED VM SSH"
|
||||
vmSSHConfigIncludeEnd = "# END BANGER MANAGED VM SSH"
|
||||
)
|
||||
// BangerSSHConfigPath is the file banger owns and keeps in sync with
|
||||
// the current default key + known_hosts locations. Users who want the
|
||||
// `ssh <name>.vm` shortcut opt in via `banger ssh-config --install`,
|
||||
// which adds an Include line to ~/.ssh/config pointing at this file.
|
||||
// The daemon never touches ~/.ssh/config on its own.
|
||||
func BangerSSHConfigPath(layout paths.Layout) string {
|
||||
if strings.TrimSpace(layout.ConfigDir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(layout.ConfigDir, "ssh_config")
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureVMSSHClientConfig() {
|
||||
if err := syncVMSSHClientConfig(d.layout, d.config.SSHKeyPath); err != nil && d.logger != nil {
|
||||
|
|
@ -48,53 +72,135 @@ func (d *Daemon) ensureVMSSHClientConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
// syncVMSSHClientConfig writes banger's own ssh_config file with the
|
||||
// current `Host *.vm` stanza. It does NOT touch ~/.ssh/config; that's
|
||||
// the job of `banger ssh-config --install` (user-initiated).
|
||||
//
|
||||
// The file lives in the banger config dir so users who manage their
|
||||
// SSH config declaratively can decide how (or whether) to pull it in.
|
||||
// We also keep a tiny migration step here: the pre-opt-in daemon
|
||||
// wrote a sibling file at $ConfigDir/ssh/ssh_config; remove it and
|
||||
// its dir if present.
|
||||
func syncVMSSHClientConfig(layout paths.Layout, keyPath string) error {
|
||||
keyPath = strings.TrimSpace(keyPath)
|
||||
if keyPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
target := BangerSSHConfigPath(layout)
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
userConfigPath := filepath.Join(sshDir, "config")
|
||||
userConfig, err := readTextFileIfExists(userConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := upsertManagedBlock(userConfig, vmSSHConfigIncludeBegin, vmSSHConfigIncludeEnd, renderManagedVMSSHBlock(keyPath, layout.KnownHostsPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeTextFileIfChanged(userConfigPath, updated, 0o644); err != nil {
|
||||
block := renderManagedVMSSHBlock(keyPath, layout.KnownHostsPath)
|
||||
if err := writeTextFileIfChanged(target, block, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
legacyManagedPath := filepath.Join(layout.ConfigDir, "ssh", "ssh_config")
|
||||
if err := os.Remove(legacyManagedPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
legacyDir := filepath.Join(layout.ConfigDir, "ssh")
|
||||
if _, err := os.Stat(legacyDir); err == nil {
|
||||
_ = os.RemoveAll(legacyDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// InstallUserSSHInclude adds an `Include <bangerSSHConfigPath>` line
|
||||
// to ~/.ssh/config inside a banger-owned marker block. Idempotent:
|
||||
// running it twice leaves a single block. Also strips any legacy
|
||||
// inline `Host *.vm` banger block left over from the pre-opt-in
|
||||
// era so the user ends up with the Include-only layout.
|
||||
func InstallUserSSHInclude(layout paths.Layout) error {
|
||||
bangerConfig := BangerSSHConfigPath(layout)
|
||||
if bangerConfig == "" {
|
||||
return fmt.Errorf("banger config dir is not configured")
|
||||
}
|
||||
userConfigPath, err := userSSHConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := readTextFileIfExists(userConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stripped, err := removeManagedBlock(existing, vmSSHConfigIncludeBegin, vmSSHConfigIncludeEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
block := renderBangerSSHIncludeBlock(bangerConfig)
|
||||
updated, err := upsertManagedBlock(stripped, bangerSSHIncludeBegin, bangerSSHIncludeEnd, block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeTextFileIfChanged(userConfigPath, updated, 0o600)
|
||||
}
|
||||
|
||||
// UninstallUserSSHInclude removes the Include block (and any legacy
|
||||
// inline Host *.vm block) from ~/.ssh/config. Idempotent: missing
|
||||
// file or missing block is a no-op.
|
||||
func UninstallUserSSHInclude() error {
|
||||
userConfigPath, err := userSSHConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := readTextFileIfExists(userConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == "" {
|
||||
return nil
|
||||
}
|
||||
updated, err := removeManagedBlock(existing, bangerSSHIncludeBegin, bangerSSHIncludeEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err = removeManagedBlock(updated, vmSSHConfigIncludeBegin, vmSSHConfigIncludeEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeTextFileIfChanged(userConfigPath, updated, 0o600)
|
||||
}
|
||||
|
||||
// UserSSHIncludeInstalled reports whether ~/.ssh/config contains
|
||||
// either the new Include block or a legacy inline banger block.
|
||||
// Used by `ssh-config` (status readout) and `doctor`.
|
||||
func UserSSHIncludeInstalled() (bool, error) {
|
||||
userConfigPath, err := userSSHConfigPath()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing, err := readTextFileIfExists(userConfigPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.Contains(existing, bangerSSHIncludeBegin) {
|
||||
return true, nil
|
||||
}
|
||||
if strings.Contains(existing, vmSSHConfigIncludeBegin) {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func userSSHConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".ssh", "config"), nil
|
||||
}
|
||||
|
||||
// renderManagedVMSSHBlock produces the body banger writes into its
|
||||
// own ssh_config file. Host-key verification uses the banger-owned
|
||||
// known_hosts — 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)
|
||||
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).",
|
||||
"# Generated by banger. Edits will be overwritten on daemon start.",
|
||||
"# Enable the `ssh <name>.vm` shortcut via `banger ssh-config --install`.",
|
||||
"Host *.vm",
|
||||
" User root",
|
||||
" IdentityFile " + keyPath,
|
||||
|
|
@ -114,14 +220,27 @@ func renderManagedVMSSHBlock(keyPath, knownHostsPath string) string {
|
|||
// closed rather than silently disable verification.
|
||||
lines = append(lines, " StrictHostKeyChecking yes")
|
||||
}
|
||||
lines = append(lines,
|
||||
" LogLevel ERROR",
|
||||
vmSSHConfigIncludeEnd,
|
||||
"",
|
||||
)
|
||||
lines = append(lines, " LogLevel ERROR", "")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderBangerSSHIncludeBlock returns the marker-fenced block that
|
||||
// `ssh-config --install` writes into ~/.ssh/config.
|
||||
func renderBangerSSHIncludeBlock(bangerConfigPath string) string {
|
||||
lines := []string{
|
||||
bangerSSHIncludeBegin,
|
||||
"# Added by `banger ssh-config --install`. Remove with",
|
||||
"# `banger ssh-config --uninstall`, or delete the whole block.",
|
||||
"Include " + bangerConfigPath,
|
||||
bangerSSHIncludeEnd,
|
||||
"",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// upsertManagedBlock replaces an existing marker-fenced block with
|
||||
// `block` (including the begin/end markers), or appends `block` if
|
||||
// no such block exists. `block` must contain the markers itself.
|
||||
func upsertManagedBlock(existing, beginMarker, endMarker, block string) (string, error) {
|
||||
existing = normalizeConfigText(existing)
|
||||
block = normalizeConfigText(block)
|
||||
|
|
@ -145,6 +264,27 @@ func upsertManagedBlock(existing, beginMarker, endMarker, block string) (string,
|
|||
return strings.TrimRight(existing, "\n") + "\n\n" + block, nil
|
||||
}
|
||||
|
||||
// removeManagedBlock strips a marker-fenced block from existing text
|
||||
// and returns the result (unchanged if no block is present). Missing
|
||||
// end marker with present begin marker is treated as corruption.
|
||||
func removeManagedBlock(existing, beginMarker, endMarker string) (string, error) {
|
||||
existing = normalizeConfigText(existing)
|
||||
start := strings.Index(existing, beginMarker)
|
||||
if start < 0 {
|
||||
return existing, nil
|
||||
}
|
||||
end := strings.Index(existing[start:], endMarker)
|
||||
if end < 0 {
|
||||
return "", fmt.Errorf("managed block %q is missing end marker %q", beginMarker, endMarker)
|
||||
}
|
||||
end += start + len(endMarker)
|
||||
for end < len(existing) && existing[end] == '\n' {
|
||||
end++
|
||||
}
|
||||
stripped := strings.TrimRight(existing[:start]+existing[end:], "\n")
|
||||
return normalizeConfigText(stripped), nil
|
||||
}
|
||||
|
||||
func normalizeConfigText(text string) string {
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.TrimRight(text, "\n")
|
||||
|
|
@ -174,5 +314,8 @@ func writeTextFileIfChanged(path, content string, mode os.FileMode) error {
|
|||
if existing == content {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), mode)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import (
|
|||
"banger/internal/paths"
|
||||
)
|
||||
|
||||
func TestSyncVMSSHClientConfigCreatesManagedBlock(t *testing.T) {
|
||||
// Under the opt-in contract the daemon writes its own ssh_config file
|
||||
// and never touches ~/.ssh/config on its own.
|
||||
func TestSyncVMSSHClientConfigWritesBangerFileOnly(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
|
|
@ -18,91 +20,240 @@ func TestSyncVMSSHClientConfigCreatesManagedBlock(t *testing.T) {
|
|||
ConfigDir: filepath.Join(homeDir, ".config", "banger"),
|
||||
KnownHostsPath: knownHostsPath,
|
||||
}
|
||||
keyPath := filepath.Join(layout.ConfigDir, "ssh", "id_ed25519")
|
||||
keyPath := filepath.Join(homeDir, ".config", "banger", "ssh", "id_ed25519")
|
||||
|
||||
if err := syncVMSSHClientConfig(layout, keyPath); err != nil {
|
||||
t.Fatalf("syncVMSSHClientConfig: %v", err)
|
||||
}
|
||||
|
||||
userConfigPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
userConfig, err := os.ReadFile(userConfigPath)
|
||||
// Banger's own ssh_config file has the `Host *.vm` stanza.
|
||||
bangerConfig, err := os.ReadFile(BangerSSHConfigPath(layout))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(user config): %v", err)
|
||||
}
|
||||
userContent := string(userConfig)
|
||||
if !strings.Contains(userContent, vmSSHConfigIncludeBegin) {
|
||||
t.Fatalf("user config = %q, want begin marker", userContent)
|
||||
t.Fatalf("ReadFile(banger ssh_config): %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Host *.vm",
|
||||
"User root",
|
||||
"IdentityFile " + keyPath,
|
||||
"IdentitiesOnly yes",
|
||||
"BatchMode yes",
|
||||
"PasswordAuthentication no",
|
||||
"UserKnownHostsFile " + knownHostsPath,
|
||||
"StrictHostKeyChecking accept-new",
|
||||
} {
|
||||
if !strings.Contains(userContent, want) {
|
||||
t.Fatalf("user config = %q, want %q", userContent, want)
|
||||
if !strings.Contains(string(bangerConfig), want) {
|
||||
t.Fatalf("banger ssh_config missing %q:\n%s", want, bangerConfig)
|
||||
}
|
||||
}
|
||||
// Regression: the legacy posture (StrictHostKeyChecking no +
|
||||
// UserKnownHostsFile /dev/null) must never reappear.
|
||||
|
||||
// ~/.ssh/config must NOT have been created or modified.
|
||||
if _, err := os.Stat(filepath.Join(homeDir, ".ssh", "config")); !os.IsNotExist(err) {
|
||||
t.Fatalf("~/.ssh/config should be untouched; stat err = %v", err)
|
||||
}
|
||||
|
||||
// Regression: the legacy posture (strict no + /dev/null) must not
|
||||
// reappear in the banger file.
|
||||
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)
|
||||
if strings.Contains(string(bangerConfig), must) {
|
||||
t.Fatalf("banger ssh_config leaked legacy posture %q:\n%s", must, bangerConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncVMSSHClientConfigReplacesManagedIncludeBlock(t *testing.T) {
|
||||
func TestInstallUserSSHIncludeAddsIncludeBlock(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
layout := paths.Layout{
|
||||
ConfigDir: filepath.Join(homeDir, ".config", "banger"),
|
||||
layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")}
|
||||
if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
// Write a fake banger ssh_config so Install has something to include.
|
||||
if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(banger ssh_config): %v", err)
|
||||
}
|
||||
|
||||
if err := InstallUserSSHInclude(layout); err != nil {
|
||||
t.Fatalf("InstallUserSSHInclude: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "config"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(~/.ssh/config): %v", err)
|
||||
}
|
||||
want := "Include " + BangerSSHConfigPath(layout)
|
||||
if !strings.Contains(string(got), want) {
|
||||
t.Fatalf("user config missing %q:\n%s", want, got)
|
||||
}
|
||||
if !strings.Contains(string(got), bangerSSHIncludeBegin) {
|
||||
t.Fatalf("user config missing begin marker:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUserSSHIncludeIsIdempotent(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")}
|
||||
if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := InstallUserSSHInclude(layout); err != nil {
|
||||
t.Fatalf("InstallUserSSHInclude (%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "config"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if n := strings.Count(string(got), bangerSSHIncludeBegin); n != 1 {
|
||||
t.Fatalf("begin markers = %d, want 1:\n%s", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUserSSHIncludeMigratesLegacyInlineBlock(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")}
|
||||
if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
keyPath := filepath.Join(layout.ConfigDir, "ssh", "id_ed25519")
|
||||
|
||||
sshDir := filepath.Join(homeDir, ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(.ssh): %v", err)
|
||||
}
|
||||
initial := strings.Join([]string{
|
||||
legacy := strings.Join([]string{
|
||||
"ServerAliveInterval 120",
|
||||
"",
|
||||
vmSSHConfigIncludeBegin,
|
||||
"Include /tmp/old-banger-config",
|
||||
"Host *.vm",
|
||||
" User root",
|
||||
" IdentityFile /some/old/key",
|
||||
vmSSHConfigIncludeEnd,
|
||||
"",
|
||||
"Host other",
|
||||
" HostName 192.0.2.5",
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(initial), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(user config): %v", err)
|
||||
if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(legacy), 0o600); err != nil {
|
||||
t.Fatalf("seed legacy config: %v", err)
|
||||
}
|
||||
|
||||
if err := syncVMSSHClientConfig(layout, keyPath); err != nil {
|
||||
t.Fatalf("syncVMSSHClientConfig: %v", err)
|
||||
if err := InstallUserSSHInclude(layout); err != nil {
|
||||
t.Fatalf("InstallUserSSHInclude: %v", err)
|
||||
}
|
||||
|
||||
userConfig, err := os.ReadFile(filepath.Join(sshDir, "config"))
|
||||
got, err := os.ReadFile(filepath.Join(sshDir, "config"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(user config): %v", err)
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
userContent := string(userConfig)
|
||||
if strings.Count(userContent, vmSSHConfigIncludeBegin) != 1 {
|
||||
t.Fatalf("user config = %q, want one managed block", userContent)
|
||||
gotStr := string(got)
|
||||
// Legacy inline block must be gone.
|
||||
if strings.Contains(gotStr, vmSSHConfigIncludeBegin) {
|
||||
t.Fatalf("legacy inline block survived:\n%s", gotStr)
|
||||
}
|
||||
if !strings.Contains(userContent, "ServerAliveInterval 120") || !strings.Contains(userContent, "Host other") {
|
||||
t.Fatalf("user config = %q, want existing entries preserved", userContent)
|
||||
// New Include block must be present.
|
||||
if !strings.Contains(gotStr, bangerSSHIncludeBegin) {
|
||||
t.Fatalf("new include block missing:\n%s", gotStr)
|
||||
}
|
||||
if !strings.Contains(userContent, "Host *.vm") || !strings.Contains(userContent, "IdentityFile "+keyPath) {
|
||||
t.Fatalf("user config = %q, want refreshed managed vm block", userContent)
|
||||
// Unrelated stanzas must be preserved.
|
||||
for _, want := range []string{"ServerAliveInterval 120", "Host other"} {
|
||||
if !strings.Contains(gotStr, want) {
|
||||
t.Fatalf("user config lost unrelated entry %q:\n%s", want, gotStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallUserSSHIncludeRemovesBothMarkerBlocks(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
sshDir := filepath.Join(homeDir, ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
seed := strings.Join([]string{
|
||||
"Host keep",
|
||||
" HostName 198.51.100.1",
|
||||
"",
|
||||
vmSSHConfigIncludeBegin,
|
||||
"Host *.vm",
|
||||
vmSSHConfigIncludeEnd,
|
||||
"",
|
||||
bangerSSHIncludeBegin,
|
||||
"Include /tmp/banger-ssh-config",
|
||||
bangerSSHIncludeEnd,
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := UninstallUserSSHInclude(); err != nil {
|
||||
t.Fatalf("UninstallUserSSHInclude: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(sshDir, "config"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
gotStr := string(got)
|
||||
for _, banned := range []string{vmSSHConfigIncludeBegin, bangerSSHIncludeBegin} {
|
||||
if strings.Contains(gotStr, banned) {
|
||||
t.Fatalf("residue of %q:\n%s", banned, gotStr)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(gotStr, "Host keep") {
|
||||
t.Fatalf("lost unrelated entry:\n%s", gotStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallUserSSHIncludeIsNoOpWhenMissing(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
if err := UninstallUserSSHInclude(); err != nil {
|
||||
t.Fatalf("UninstallUserSSHInclude on missing file: %v", err)
|
||||
}
|
||||
// Still no ~/.ssh/config.
|
||||
if _, err := os.Stat(filepath.Join(homeDir, ".ssh", "config")); !os.IsNotExist(err) {
|
||||
t.Fatalf("~/.ssh/config unexpectedly created; stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserSSHIncludeInstalledDetectsBothMarkers(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
seed string
|
||||
wantIn bool
|
||||
}{
|
||||
{"missing file", "", false},
|
||||
{"unrelated only", "Host other\n HostName 1.2.3.4\n", false},
|
||||
{"legacy marker", vmSSHConfigIncludeBegin + "\nHost *.vm\n" + vmSSHConfigIncludeEnd + "\n", true},
|
||||
{"new marker", bangerSSHIncludeBegin + "\nInclude /tmp/banger\n" + bangerSSHIncludeEnd + "\n", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
if tc.seed != "" {
|
||||
if err := os.MkdirAll(filepath.Join(homeDir, ".ssh"), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(homeDir, ".ssh", "config"), []byte(tc.seed), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
got, err := UserSSHIncludeInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("UserSSHIncludeInstalled: %v", err)
|
||||
}
|
||||
if got != tc.wantIn {
|
||||
t.Fatalf("got %v, want %v", got, tc.wantIn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue