diff --git a/CHANGELOG.md b/CHANGELOG.md index 7034889..ec56def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ## [Unreleased] +### Fixed + +Found provisioning a fresh OVH Debian 13 VPS as a non-root user with +`--network tailscale` (2026-10-25): + +- SSH now offers only the host-key algorithms `known_hosts` holds for a + known host. A box first trusted by stock `ssh` (ed25519) failed every + connect with a "host key mismatch" when Go negotiated ecdsa. +- Remote uploads apply the file mode after writing, not before. Read-only + modes (the 0440 sudoers audit drop-in) could not be written by a + non-root user, aborting `setup` hardening. +- Hardening and setup probes for `/usr/sbin` tools (`ufw`, `auditctl`, + `visudo`, `ufw status`) run through sudo for non-root users. Before, + they missed the tools, so ufw and auditd were reinstalled on every run, + the firewall stage reported "No active firewall", and `visudo` was not found. +- Tailscale and Headscale joins pass the key via `--auth-key=file:`. The + tailscale CLI ignores `TS_AUTHKEY`, so every join fell back to an + interactive login and timed out. The credential still never enters argv. +- The tailscale `BackendState` check parses the JSON; the pretty-printed + output never matched the compact substring. +- Re-running `setup --network tailscale|headscale` leaves an already-joined + node alone. It used to wipe `/var/lib/tailscale` every run and re-up with + the spent one-time key, logging the server out of the mesh. +- A failed mesh join reports the provider's output (e.g. `invalid key`) + instead of a bare wait timeout. +- `setup` installs cron (backup and autodeploy schedules need it; Debian 13 + cloud images ship without it) and `doctor` checks for it. +- The first-deploy DNS check accepts the server's own interface addresses, + so a server registered by its Tailscale IP no longer refuses a public + domain that points at its public IP. + ## [0.1.47] - 2026-10-21 ### Fixed diff --git a/internal/cli/deploy.go b/internal/cli/deploy.go index 6bf5cce..aebc85b 100644 --- a/internal/cli/deploy.go +++ b/internal/cli/deploy.go @@ -24,7 +24,6 @@ import ( "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/confighome" "github.com/useteploy/teploy/internal/deploy" - "github.com/useteploy/teploy/internal/dns" "github.com/useteploy/teploy/internal/docker" "github.com/useteploy/teploy/internal/env" "github.com/useteploy/teploy/internal/multideploy" @@ -630,7 +629,7 @@ func deployAppConfig(flags *Flags, appCfg *config.AppConfig, serverName, image, current, _ := state.Read(ctx, executor, appCfg.App) if current == nil { fmt.Println("Validating DNS...") - if err := dns.Validate(appCfg.Domain, host, nil); err != nil { + if err := validateDomainDNS(ctx, executor, appCfg.Domain, host); err != nil { return err } fmt.Println(" DNS validated") diff --git a/internal/cli/deployserver.go b/internal/cli/deployserver.go index e4579da..3da4bfa 100644 --- a/internal/cli/deployserver.go +++ b/internal/cli/deployserver.go @@ -14,7 +14,6 @@ import ( "github.com/useteploy/teploy/internal/build" "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/confighome" - "github.com/useteploy/teploy/internal/dns" "github.com/useteploy/teploy/internal/docker" "github.com/useteploy/teploy/internal/gitsource" "github.com/useteploy/teploy/internal/releasemeta" @@ -180,7 +179,7 @@ func deployServerConfigLocked(ctx context.Context, executor ssh.Executor, host, appCfg.Ingress != config.IngressHost && appCfg.Ingress != config.IngressExternal { if current, _ := state.Read(ctx, executor, app); current == nil { fmt.Println("Validating DNS...") - if err := dns.Validate(appCfg.Domain, host, nil); err != nil { + if err := validateDomainDNS(ctx, executor, appCfg.Domain, host); err != nil { return err } fmt.Println(" DNS validated") diff --git a/internal/cli/dnscheck.go b/internal/cli/dnscheck.go new file mode 100644 index 0000000..4262955 --- /dev/null +++ b/internal/cli/dnscheck.go @@ -0,0 +1,28 @@ +package cli + +import ( + "context" + "strings" + + "github.com/useteploy/teploy/internal/dns" + "github.com/useteploy/teploy/internal/ssh" +) + +// validateDomainDNS checks the domain points at the server. When the SSH +// host does not match (a server reached over its mesh IP), the server's +// own interface addresses are accepted too: a box registered by its +// Tailscale IP otherwise refused every first deploy of a public domain +// (2026-10-25 live finding, fresh OVH box set up with --network +// tailscale). A NAT'd server whose public IP is not on an interface +// still needs --skip-dns-check. +func validateDomainDNS(ctx context.Context, executor ssh.Executor, domain, host string) error { + err := dns.Validate(domain, host, nil) + if err == nil || executor == nil { + return err + } + out, runErr := executor.Run(ctx, "hostname -I") + if runErr == nil && dns.Resolves(domain, strings.Fields(out), nil) { + return nil + } + return err +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index f18e436..76c0554 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -395,7 +395,8 @@ func doctorDockerCheck(ctx context.Context, exec ssh.Executor) doctorCheck { // doctorServerToolsCheck verifies the server-side tools teploy shells out // to. python3 is required (fenced state writes, volume ownership, trigger // receipts), so its absence fails; git is a warning until a feature on the -// box needs it. `teploy setup` installs both. +// box needs it, as is cron (backup and autodeploy schedules). `teploy +// setup` installs all three. func doctorServerToolsCheck(ctx context.Context, exec ssh.Executor) doctorCheck { out, err := exec.Run(ctx, serverToolsProbe) if err != nil { diff --git a/internal/cli/setup.go b/internal/cli/setup.go index ec0f983..22a536d 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -312,9 +312,22 @@ func setupNetwork(ctx context.Context, exec ssh.Executor, w io.Writer, providerN return "", fmt.Errorf("installing %s: %w", providerName, err) } - // Reset Tailscale state if present — cloned VMs inherit the previous machine's identity - // which causes IP conflicts. Stop tailscaled, wipe state, restart. if providerName == "tailscale" || providerName == "headscale" { + // An already-joined node is left alone: the reset below used to run + // on every setup, so re-running `teploy setup --network` wiped a + // working node's identity and re-upped it with a (typically spent) + // one-time key — logging the server out of the mesh (2026-10-25 + // live finding). A clone that inherited another machine's identity + // needs `tailscale logout` on it first. + if out, err := exec.Run(ctx, sudo+"tailscale status --json 2>/dev/null"); err == nil && network.TailscaleRunning(out) { + if vpnIP, err := provider.GetIP(ctx, exec); err == nil && net.ParseIP(vpnIP) != nil { + fmt.Fprintf(w, " Already on the %s mesh (to re-enroll a cloned VM, run `tailscale logout` on it first)\n", providerName) + fmt.Fprintf(w, " VPN IP: %s\n", vpnIP) + return vpnIP, nil + } + } + // Not joined: reset any inherited state — cloned VMs carry the + // previous machine's identity, which causes IP conflicts. exec.Run(ctx, sudo+"systemctl stop tailscaled 2>/dev/null; "+sudo+"rm -rf /var/lib/tailscale; "+sudo+"systemctl start tailscaled 2>/dev/null; true") } @@ -336,7 +349,8 @@ func setupNetwork(ctx context.Context, exec ssh.Executor, w io.Writer, providerN // Tailscale/Headscale modifies iptables which can kill the SSH connection, // so detach the join and use the reconnecting authenticated executor to observe it. fmt.Fprintf(w, "Joining %s mesh...\n", providerName) - if err := joinVPNMesh(ctx, exec, sudo, providerName, cfg); err != nil { + joinLog, err := joinVPNMesh(ctx, exec, sudo, providerName, cfg) + if err != nil { return "", err } @@ -357,18 +371,22 @@ func setupNetwork(ctx context.Context, exec ssh.Executor, w io.Writer, providerN } select { case <-waitCtx.Done(): + if out, _ := exec.Run(ctx, "tail -n 3 "+joinLog+" 2>/dev/null"); strings.TrimSpace(out) != "" { + return "", fmt.Errorf("waiting for %s address: %w; join output: %s", providerName, waitCtx.Err(), strings.TrimSpace(out)) + } return "", fmt.Errorf("waiting for %s address: %w", providerName, waitCtx.Err()) case <-time.After(2 * time.Second): } } + _, _ = exec.Run(ctx, "rm -f -- "+joinLog) fmt.Fprintf(w, " VPN IP: %s\n", vpnIP) return vpnIP, nil } // vpnCredential resolves the provider's join credential and the env var -// its CLI documents for it (tailscale up reads TS_AUTHKEY, netbird up -// reads NB_SETUP_KEY). +// its CLI documents for it (netbird up reads NB_SETUP_KEY; the tailscale +// name is unused — its CLI ignores TS_AUTHKEY, see TailscaleJoinShell). func vpnCredential(providerName string, cfg network.Config) (envVar, value string, err error) { switch providerName { case "tailscale", "headscale": @@ -390,37 +408,46 @@ func vpnCredential(providerName string, cfg network.Config) (envVar, value strin // setup with the file path named (nothing was joined); a join failure // after that surfaces through the tailnet wait below, and the key file // is gone regardless — its only reader is the rm-ing shell itself. -func joinVPNMesh(ctx context.Context, exec ssh.Executor, sudo, providerName string, cfg network.Config) error { +func joinVPNMesh(ctx context.Context, exec ssh.Executor, sudo, providerName string, cfg network.Config) (string, error) { _, credential, err := vpnCredential(providerName, cfg) if err != nil { - return err + return "", err } if credential == "" { - return fmt.Errorf("%s join credential is empty", providerName) + return "", fmt.Errorf("%s join credential is empty", providerName) } keyPath, err := network.StageJoinCredential(ctx, exec, credential) if err != nil { - return err + return "", err } _, _ = exec.Run(ctx, vpnJoinCommand(providerName, cfg, sudo, keyPath)) - return nil + return vpnJoinLog(keyPath), nil } -// vpnJoinCommand renders the detached join: `cat` the key file into the -// provider's env var, remove the file, then exec the provider. The +// vpnJoinLog is where the detached join's output lands, so a rejected +// key surfaces as the provider's own error rather than a bare wait +// timeout. Per run (derived from the random key path): a fixed /tmp name +// owned by one user cannot be reopened by another under Debian's +// fs.protected_regular, which would fail the redirect and the join with +// it. It never holds the credential — the key is passed by file path. +func vpnJoinLog(keyPath string) string { return keyPath + ".log" } + +// vpnJoinCommand renders the detached join: tailscale/headscale read the +// key file via --auth-key=file: and remove it on exit; netbird `cat`s it +// into NB_SETUP_KEY, removes it, then execs. The // login-server URL (headscale) is not a secret and stays a plain flag. func vpnJoinCommand(providerName string, cfg network.Config, sudo, keyPath string) string { - envVar, _, _ := vpnCredential(providerName, cfg) - var tail string + var join string switch providerName { case "tailscale": - tail = "tailscale up --accept-routes" + join = network.TailscaleJoinShell(keyPath, "--accept-routes") case "headscale": - tail = "tailscale up --login-server=" + ssh.ShellQuote(cfg.Server) + " --accept-routes" + join = network.TailscaleJoinShell(keyPath, "--login-server="+ssh.ShellQuote(cfg.Server)+" --accept-routes") case "netbird": - tail = "netbird up" + envVar, _, _ := vpnCredential(providerName, cfg) + join = network.EnvVarJoinShell(envVar, keyPath, "netbird up") } - return sudo + "nohup " + network.EnvVarJoinShell(envVar, keyPath, tail) + " >/dev/null 2>&1 &" + return sudo + "nohup " + join + " >" + vpnJoinLog(keyPath) + " 2>&1 &" } // runLocal executes a command on the local machine and returns its output. @@ -522,9 +549,9 @@ type setupEnv struct { // serverTools are the packages teploy needs on every server beyond docker // and rsync; serverToolsProbe prints the missing ones, space separated. -var serverTools = []string{"git", "python3"} +var serverTools = []string{"git", "python3", "cron"} -const serverToolsProbe = "for t in git python3; do command -v $t >/dev/null 2>&1 || printf '%s ' $t; done; true" +const serverToolsProbe = "for t in git python3; do command -v $t >/dev/null 2>&1 || printf '%s ' $t; done; command -v crontab >/dev/null 2>&1 || printf 'cron '; true" // setupStages is the ordered provisioning plan. The ORDER is part of // the contract: docker before the network that needs it, the network @@ -604,17 +631,17 @@ func setupStages() []setupStage { }, }, { - name: "git and python3", - affects: "git and python3 packages (apt, if missing) — state writes need python3, server-side config and checkouts need git", + name: "git, python3 and cron", + affects: "git, python3 and cron packages (apt, if missing) — state writes need python3, server-side config and checkouts need git, backup and autodeploy schedules need cron", run: func(ctx context.Context, exec ssh.Executor, w io.Writer, env *setupEnv) error { - fmt.Fprintln(w, "Checking git and python3...") + fmt.Fprintln(w, "Checking git, python3 and cron...") out, err := exec.Run(ctx, serverToolsProbe) if err != nil { return fmt.Errorf("probing server tools: %w", err) } missing := strings.Fields(out) if len(missing) == 0 { - fmt.Fprintln(w, " git and python3 already installed") + fmt.Fprintln(w, " git, python3 and cron already installed") return nil } fmt.Fprintf(w, " Installing %s...\n", strings.Join(missing, ", ")) @@ -665,7 +692,7 @@ func setupStages() []setupStage { run: func(ctx context.Context, exec ssh.Executor, w io.Writer, env *setupEnv) error { // 3. Check firewall fmt.Fprintln(w, "Checking firewall...") - ufwOutput, ufwErr := exec.Run(ctx, "ufw status 2>/dev/null") + ufwOutput, ufwErr := exec.Run(ctx, env.sudo+"ufw status 2>/dev/null") if ufwErr == nil && strings.Contains(ufwOutput, "Status: active") { _, err1 := exec.Run(ctx, env.sudo+"ufw allow 80/tcp") _, err2 := exec.Run(ctx, env.sudo+"ufw allow 443/tcp") diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index a7b7989..7f430eb 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -529,7 +529,7 @@ func TestInstallSudoViaSu_WrongPassword(t *testing.T) { } // TestVPNJoinCommand_NoCredentialInArgv pins the join shape: the key -// file is read, removed, and fed via the provider env var — the literal +// file is passed by path and removed on exit — the literal // credential appears nowhere in the command. func TestVPNJoinCommand_NoCredentialInArgv(t *testing.T) { cfg := network.Config{Provider: "tailscale", AuthKey: "tskey-auth-secret123"} @@ -539,10 +539,8 @@ func TestVPNJoinCommand_NoCredentialInArgv(t *testing.T) { } for _, want := range []string{ "nohup sh -c ", - "k=$(cat /tmp/teploy-vpn-key-abc)", + "tailscale up --auth-key=file:/tmp/teploy-vpn-key-abc --accept-routes", "rm -f -- /tmp/teploy-vpn-key-abc", - `export TS_AUTHKEY="$k"`, - "exec tailscale up --accept-routes", } { if !strings.Contains(cmd, want) { t.Errorf("join command missing %q:\n%s", want, cmd) @@ -576,9 +574,13 @@ func TestJoinVPNMesh_StagesPrivateFileAndCleansUp(t *testing.T) { ssh.MockCommand{Match: "nohup sh -c", Output: ""}, ) cfg := network.Config{Provider: "tailscale", AuthKey: "tskey-auth-x"} - if err := joinVPNMesh(context.Background(), mock, "", "tailscale", cfg); err != nil { + joinLog, err := joinVPNMesh(context.Background(), mock, "", "tailscale", cfg) + if err != nil { t.Fatalf("joinVPNMesh: %v", err) } + if !strings.HasPrefix(joinLog, "/tmp/teploy-vpn-key-") || !strings.HasSuffix(joinLog, ".log") { + t.Errorf("join log must be per run, beside the staged key: %q", joinLog) + } uploads := 0 for _, call := range mock.Calls { if strings.HasPrefix(call, "UPLOAD:") { @@ -846,3 +848,24 @@ func TestInstallAuthorizedKey_GuardedAgainstDuplicateAppend(t *testing.T) { } } } + +// TestSetupNetwork_AlreadyJoinedTailscaleIsLeftAlone: a re-run of setup +// against a node already on the tailnet must not wipe its state or re-up +// it (the spent one-time key then logs the node out). +func TestSetupNetwork_AlreadyJoinedTailscaleIsLeftAlone(t *testing.T) { + m := ssh.NewMockExecutor("h", + ssh.MockCommand{Match: "whoami", Output: "debian"}, + ssh.MockCommand{Match: "which tailscale", Output: "/usr/bin/tailscale"}, + ssh.MockCommand{Match: "sudo tailscale status --json", Output: "{\n \"BackendState\": \"Running\"\n}"}, + ssh.MockCommand{Match: "sudo tailscale ip -4", Output: "100.118.234.58\n"}, + ) + got, err := setupNetwork(context.Background(), m, &bytes.Buffer{}, "tailscale", "tskey-auth-spent") + if err != nil || got != "100.118.234.58" { + t.Fatalf("got %q, %v", got, err) + } + for _, call := range m.Calls { + if strings.Contains(call, "rm -rf /var/lib/tailscale") || strings.Contains(call, "tailscale up") || strings.Contains(call, "teploy-vpn-key-") { + t.Fatalf("already-joined node was reset or re-joined: %s", call) + } + } +} diff --git a/internal/cli/validate.go b/internal/cli/validate.go index db39e43..67ccbbe 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -14,7 +14,6 @@ import ( "github.com/useteploy/teploy/internal/build" "github.com/useteploy/teploy/internal/caddy" "github.com/useteploy/teploy/internal/config" - "github.com/useteploy/teploy/internal/dns" "github.com/useteploy/teploy/internal/ssh" ) @@ -105,7 +104,7 @@ func runValidate(flags *Flags) error { // front (Cloudflare Tunnel / nginx / ALB) serves the domain, so // it legitimately resolves there, not to the server IP. if appCfg.Domain != "" && appCfg.Ingress != config.IngressExternal { - if err := dns.Validate(appCfg.Domain, host, nil); err != nil { + if err := validateDomainDNS(ctx, executor, appCfg.Domain, host); err != nil { result.Warns = append(result.Warns, fmt.Sprintf("DNS: %v", err)) } } diff --git a/internal/dns/resolves_test.go b/internal/dns/resolves_test.go new file mode 100644 index 0000000..b0e8196 --- /dev/null +++ b/internal/dns/resolves_test.go @@ -0,0 +1,13 @@ +package dns + +import "testing" + +func TestResolvesMatchesAnyServerAddress(t *testing.T) { + resolve := func(host string) ([]string, error) { return []string{"158.69.206.240"}, nil } + if !Resolves("example.com", []string{"100.67.126.38", "158.69.206.240", "172.17.0.1"}, resolve) { + t.Fatal("domain on the public interface must match") + } + if Resolves("example.com", []string{"100.67.126.38"}, resolve) { + t.Fatal("mesh-only addresses must not match a public A record") + } +} diff --git a/internal/dns/validate.go b/internal/dns/validate.go index 3ddccab..bb880cc 100644 --- a/internal/dns/validate.go +++ b/internal/dns/validate.go @@ -47,3 +47,26 @@ func Validate(domain, serverHost string, resolve Resolver) error { domain, domainIPs[0], serverIPs[0], serverIPs[0], ) } + +// Resolves reports whether the domain resolves to any of ips. It is the +// fallback when the server is addressed by a private or mesh IP (a +// servers.yml host set to its Tailscale address by `teploy setup +// --network`): public DNS points at the server's public interface, which +// Validate's comparison against the SSH host can never match. +func Resolves(domain string, ips []string, resolve Resolver) bool { + if resolve == nil { + resolve = net.LookupHost + } + domainIPs, err := resolve(domain) + if err != nil { + return false + } + for _, dip := range domainIPs { + for _, ip := range ips { + if dip == ip { + return true + } + } + } + return false +} diff --git a/internal/harden/audit_test.go b/internal/harden/audit_test.go index e489ac4..10a6ec0 100644 --- a/internal/harden/audit_test.go +++ b/internal/harden/audit_test.go @@ -11,7 +11,7 @@ import ( func TestEnableAuditInstallsRulesAndSudoers(t *testing.T) { mock := ssh.NewMockExecutor("h", - ssh.MockCommand{Match: "which auditctl", Output: "/usr/sbin/auditctl"}, + ssh.MockCommand{Match: "sh -c 'command -v auditctl'", Output: "/usr/sbin/auditctl"}, ssh.MockCommand{Match: "visudo -c", Output: "parsed OK"}, ssh.MockCommand{Match: "mv /tmp/teploy-audit.rules", Output: ""}, ssh.MockCommand{Match: "augenrules --load", Output: ""}, @@ -46,7 +46,7 @@ func TestEnableAuditInstallsRulesAndSudoers(t *testing.T) { func TestEnableAuditRejectsBadSudoers(t *testing.T) { mock := ssh.NewMockExecutor("h", - ssh.MockCommand{Match: "which auditctl", Output: "/usr/sbin/auditctl"}, + ssh.MockCommand{Match: "sh -c 'command -v auditctl'", Output: "/usr/sbin/auditctl"}, ssh.MockCommand{Match: "mv /tmp/teploy-audit.rules", Output: ""}, ssh.MockCommand{Match: "augenrules --load", Output: ""}, ssh.MockCommand{Match: "systemctl enable --now auditd", Output: ""}, diff --git a/internal/harden/harden.go b/internal/harden/harden.go index 2ab140c..65c9215 100644 --- a/internal/harden/harden.go +++ b/internal/harden/harden.go @@ -24,7 +24,7 @@ func detectSudo(ctx context.Context, exec ssh.Executor) string { func ConfigureUFW(ctx context.Context, exec ssh.Executor, w io.Writer, sudo string) error { fmt.Fprintln(w, "Configuring UFW...") - if _, err := exec.Run(ctx, "which ufw"); err != nil { + if _, err := exec.Run(ctx, sudo+"sh -c 'command -v ufw'"); err != nil { fmt.Fprintln(w, " Installing UFW...") if _, err := exec.Run(ctx, sudo+"DEBIAN_FRONTEND=noninteractive apt-get update -qq && "+sudo+"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ufw"); err != nil { return fmt.Errorf("installing ufw: %w", err) @@ -200,7 +200,7 @@ APT::Periodic::Unattended-Upgrade "1"; func EnableAudit(ctx context.Context, exec ssh.Executor, w io.Writer, sudo string) error { fmt.Fprintln(w, "Configuring host audit (auditd + sudo session logging)...") - if _, err := exec.Run(ctx, "which auditctl"); err != nil { + if _, err := exec.Run(ctx, sudo+"sh -c 'command -v auditctl'"); err != nil { if _, err := exec.Run(ctx, sudo+"DEBIAN_FRONTEND=noninteractive apt-get update -qq && "+sudo+"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq auditd"); err != nil { return fmt.Errorf("installing auditd: %w", err) } @@ -240,7 +240,7 @@ Defaults iolog_dir="/var/log/sudo-io/%{user}" } // visudo -c validates BEFORE install — a broken sudoers drop-in can // lock everyone out of sudo on the box. - if _, err := exec.Run(ctx, "visudo -c -f /tmp/teploy-audit-sudoers"); err != nil { + if _, err := exec.Run(ctx, sudo+"visudo -c -f /tmp/teploy-audit-sudoers"); err != nil { return fmt.Errorf("sudoers audit config failed validation: %w", err) } if _, err := exec.Run(ctx, sudo+"mv /tmp/teploy-audit-sudoers /etc/sudoers.d/teploy-audit && "+sudo+"chown root:root /etc/sudoers.d/teploy-audit && "+sudo+"chmod 0440 /etc/sudoers.d/teploy-audit"); err != nil { diff --git a/internal/harden/harden_test.go b/internal/harden/harden_test.go index 3771d23..455cbe2 100644 --- a/internal/harden/harden_test.go +++ b/internal/harden/harden_test.go @@ -18,7 +18,7 @@ func TestConfigureUFW_AlreadyInstalled(t *testing.T) { ssh.MockCommand{Match: "sudo ufw allow 22/tcp", Output: ""}, ssh.MockCommand{Match: "sh -c 'set -eu; backup=", Output: ""}, ssh.MockCommand{Match: "sudo sh -c 'set -eu; backup=", Output: ""}, - ssh.MockCommand{Match: "which ufw", Output: "/usr/sbin/ufw"}, + ssh.MockCommand{Match: "sh -c 'command -v ufw'", Output: "/usr/sbin/ufw"}, ssh.MockCommand{Match: "ufw default deny incoming && ufw default allow outgoing", Output: ""}, ssh.MockCommand{Match: "ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp", Output: ""}, ssh.MockCommand{Match: `sh -c 'echo "y" | ufw enable'`, Output: "Firewall is active"}, @@ -49,7 +49,7 @@ func TestConfigureUFW_FreshInstall(t *testing.T) { ssh.MockCommand{Match: "sudo ufw allow 22/tcp", Output: ""}, ssh.MockCommand{Match: "sh -c 'set -eu; backup=", Output: ""}, ssh.MockCommand{Match: "sudo sh -c 'set -eu; backup=", Output: ""}, - ssh.MockCommand{Match: "which ufw", Err: fmt.Errorf("not found")}, + ssh.MockCommand{Match: "sh -c 'command -v ufw'", Err: fmt.Errorf("not found")}, ssh.MockCommand{Match: "DEBIAN_FRONTEND=noninteractive apt-get update", Output: ""}, ssh.MockCommand{Match: "ufw default deny incoming", Output: ""}, ssh.MockCommand{Match: "ufw allow 22/tcp", Output: ""}, @@ -75,7 +75,7 @@ func TestConfigureUFW_WithSudo(t *testing.T) { ssh.MockCommand{Match: "sudo ufw allow 22/tcp", Output: ""}, ssh.MockCommand{Match: "sh -c 'set -eu; backup=", Output: ""}, ssh.MockCommand{Match: "sudo sh -c 'set -eu; backup=", Output: ""}, - ssh.MockCommand{Match: "which ufw", Err: fmt.Errorf("not found")}, + ssh.MockCommand{Match: "sudo sh -c 'command -v ufw'", Err: fmt.Errorf("not found")}, ssh.MockCommand{Match: "sudo DEBIAN_FRONTEND=noninteractive apt-get update", Output: ""}, ssh.MockCommand{Match: "sudo ufw default deny incoming", Output: ""}, ssh.MockCommand{Match: "sudo ufw allow 22/tcp", Output: ""}, @@ -280,7 +280,7 @@ func TestHarden_RunsAllSteps_AsRoot(t *testing.T) { // detectSudo ssh.MockCommand{Match: "whoami", Output: "root"}, // UFW - ssh.MockCommand{Match: "which ufw", Output: "/usr/sbin/ufw"}, + ssh.MockCommand{Match: "sh -c 'command -v ufw'", Output: "/usr/sbin/ufw"}, ssh.MockCommand{Match: "ufw default deny incoming", Output: ""}, ssh.MockCommand{Match: "ufw allow 22/tcp", Output: ""}, ssh.MockCommand{Match: `sh -c 'echo "y" | ufw enable'`, Output: ""}, @@ -299,7 +299,7 @@ func TestHarden_RunsAllSteps_AsRoot(t *testing.T) { ssh.MockCommand{Match: "sed -i", Output: ""}, ssh.MockCommand{Match: "systemctl restart sshd", Output: ""}, // audit - ssh.MockCommand{Match: "which auditctl", Output: "/usr/sbin/auditctl"}, + ssh.MockCommand{Match: "sh -c 'command -v auditctl'", Output: "/usr/sbin/auditctl"}, ssh.MockCommand{Match: "mv /tmp/teploy-audit.rules", Output: ""}, ssh.MockCommand{Match: "augenrules --load", Output: ""}, ssh.MockCommand{Match: "systemctl enable --now auditd", Output: ""}, @@ -335,7 +335,7 @@ func TestHarden_RunsAllSteps_WithSudo(t *testing.T) { // detectSudo ssh.MockCommand{Match: "whoami", Output: "tyler"}, // UFW - ssh.MockCommand{Match: "which ufw", Output: "/usr/sbin/ufw"}, + ssh.MockCommand{Match: "sudo sh -c 'command -v ufw'", Output: "/usr/sbin/ufw"}, ssh.MockCommand{Match: "sudo ufw default deny incoming", Output: ""}, ssh.MockCommand{Match: "sudo ufw allow 22/tcp", Output: ""}, ssh.MockCommand{Match: "sudo sh -c", Output: ""}, @@ -354,11 +354,11 @@ func TestHarden_RunsAllSteps_WithSudo(t *testing.T) { ssh.MockCommand{Match: "sudo sed -i", Output: ""}, ssh.MockCommand{Match: "sudo systemctl restart sshd", Output: ""}, // audit - ssh.MockCommand{Match: "which auditctl", Output: "/usr/sbin/auditctl"}, + ssh.MockCommand{Match: "sudo sh -c 'command -v auditctl'", Output: "/usr/sbin/auditctl"}, ssh.MockCommand{Match: "sudo mv /tmp/teploy-audit.rules", Output: ""}, ssh.MockCommand{Match: "sudo augenrules --load", Output: ""}, ssh.MockCommand{Match: "sudo systemctl enable --now auditd", Output: ""}, - ssh.MockCommand{Match: "visudo -c", Output: "parsed OK"}, + ssh.MockCommand{Match: "sudo visudo -c", Output: "parsed OK"}, ssh.MockCommand{Match: "sudo mv /tmp/teploy-audit-sudoers", Output: ""}, ) diff --git a/internal/network/network.go b/internal/network/network.go index f875236..8719cb8 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "io" "strings" @@ -55,9 +56,9 @@ func NewProvider(cfg Config) (Provider, error) { // key) to a private temp file on the server and returns its generated // path (C08): the credential must never enter a command string — the // session shell's argv sits in the server's process list for the life -// of the join. The consuming shell (EnvVarJoinShell) reads the file -// into the provider's env var, removes the file BEFORE exec'ing the -// provider, so the on-disk window is upload-to-join. An upload failure +// of the join. The consuming shell (EnvVarJoinShell for netbird, +// TailscaleJoinShell for tailscale/headscale) removes the file, so the +// on-disk window is upload-to-join. An upload failure // aborts before anything joins. func StageJoinCredential(ctx context.Context, exec ssh.Executor, credential string) (string, error) { var suffix [8]byte @@ -80,6 +81,30 @@ func EnvVarJoinShell(envVar, keyPath, tail string) string { return "sh -c " + ssh.ShellQuote(inner) } +// TailscaleJoinShell renders `sh -c 'tailscale up --auth-key=file:KEY +// flags; remove KEY'`, quoted for one outer shell level. The tailscale +// CLI does not read TS_AUTHKEY (verified on 1.104: with only the env var +// set, `tailscale up` falls back to an interactive login URL and the +// join never completes), so the key rides the CLI's file: form of +// --auth-key — the path, never the credential, is in the argv (C08). +// The file is removed when the CLI exits, success or not. keyPath must +// be a path StageJoinCredential generated. +func TailscaleJoinShell(keyPath, flags string) string { + inner := fmt.Sprintf(`tailscale up --auth-key=file:%s %s; rc=$?; rm -f -- %s; exit $rc`, keyPath, flags, keyPath) + return "sh -c " + ssh.ShellQuote(inner) +} + +// TailscaleRunning reports whether `tailscale status --json` output shows +// a running backend. The JSON is pretty-printed (`"BackendState": +// "Running"`, with a space), so a compact-form substring check never +// matched: an already-joined node looked unjoined and was re-upped with +// its spent one-time key, which logged it out of the tailnet (2026-10-25 +// live finding on a re-run of `teploy setup`). +func TailscaleRunning(out string) bool { + var st struct{ BackendState string } + return json.Unmarshal([]byte(out), &st) == nil && st.BackendState == "Running" +} + // --- Tailscale --- // TailscaleProvider manages Tailscale VPN on servers. @@ -105,7 +130,7 @@ func (t *TailscaleProvider) Install(ctx context.Context, exec ssh.Executor, w io func (t *TailscaleProvider) Join(ctx context.Context, exec ssh.Executor) error { out, err := exec.Run(ctx, t.Sudo+"tailscale status --json 2>/dev/null") - if err == nil && strings.Contains(out, `"BackendState":"Running"`) { + if err == nil && TailscaleRunning(out) { return nil // already connected } // Run tailscale up in the background. Tailscale modifies iptables which can kill @@ -115,13 +140,13 @@ func (t *TailscaleProvider) Join(ctx context.Context, exec ssh.Executor) error { if err != nil { return err } - cmd := t.Sudo + "nohup " + EnvVarJoinShell("TS_AUTHKEY", keyPath, "tailscale up --accept-routes") + + cmd := t.Sudo + "nohup " + TailscaleJoinShell(keyPath, "--accept-routes") + " >/dev/null 2>&1 & sleep 3 && " + t.Sudo + "tailscale status --json 2>/dev/null | grep -q Running" if _, err := exec.Run(ctx, cmd); err != nil { // SSH disconnect during tailscale up is expected — the iptables change can kill the connection. // Check if tailscale actually joined by trying status again. out, statusErr := exec.Run(ctx, t.Sudo+"tailscale status --json 2>/dev/null") - if statusErr == nil && strings.Contains(out, `"BackendState":"Running"`) { + if statusErr == nil && TailscaleRunning(out) { return nil // joined successfully despite SSH hiccup } return fmt.Errorf("joining tailscale mesh: %w", err) @@ -172,7 +197,7 @@ func (h *HeadscaleProvider) Install(ctx context.Context, exec ssh.Executor, w io func (h *HeadscaleProvider) Join(ctx context.Context, exec ssh.Executor) error { out, err := exec.Run(ctx, h.Sudo+"tailscale status --json 2>/dev/null") - if err == nil && strings.Contains(out, `"BackendState":"Running"`) { + if err == nil && TailscaleRunning(out) { return nil } // Same private-file credential transport as TailscaleProvider.Join @@ -181,12 +206,11 @@ func (h *HeadscaleProvider) Join(ctx context.Context, exec ssh.Executor) error { if err != nil { return err } - tail := "tailscale up --login-server=" + ssh.ShellQuote(h.Server) + " --accept-routes" - cmd := h.Sudo + "nohup " + EnvVarJoinShell("TS_AUTHKEY", keyPath, tail) + + cmd := h.Sudo + "nohup " + TailscaleJoinShell(keyPath, "--login-server="+ssh.ShellQuote(h.Server)+" --accept-routes") + " >/dev/null 2>&1 & sleep 3 && " + h.Sudo + "tailscale status --json 2>/dev/null | grep -q Running" if _, err := exec.Run(ctx, cmd); err != nil { out, statusErr := exec.Run(ctx, h.Sudo+"tailscale status --json 2>/dev/null") - if statusErr == nil && strings.Contains(out, `"BackendState":"Running"`) { + if statusErr == nil && TailscaleRunning(out) { return nil } return fmt.Errorf("joining headscale mesh: %w", err) diff --git a/internal/network/network_test.go b/internal/network/network_test.go index 112d45f..f91fce1 100644 --- a/internal/network/network_test.go +++ b/internal/network/network_test.go @@ -121,11 +121,8 @@ func TestTailscaleJoin_Fresh(t *testing.T) { if strings.Contains(call, "tskey-auth-xxx") || strings.Contains(call, "--authkey=") { t.Errorf("the auth key must never ride the argv (C08), got: %s", call) } - if !strings.Contains(call, `export TS_AUTHKEY="$k"`) { - t.Errorf("the key must ride TS_AUTHKEY from the staged file, got: %s", call) - } - if !strings.Contains(call, "exec tailscale up --accept-routes") { - t.Errorf("join must exec tailscale up with --accept-routes, got: %s", call) + if !strings.Contains(call, "tailscale up --auth-key=file:/tmp/teploy-vpn-key-") || !strings.Contains(call, "--accept-routes") { + t.Errorf("join must pass the staged file via --auth-key=file: with --accept-routes, got: %s", call) } } } @@ -203,8 +200,8 @@ func TestHeadscaleJoin_UsesLoginServer(t *testing.T) { if strings.Contains(call, "key123") || strings.Contains(call, "--authkey=") { t.Errorf("the auth key must never ride the argv (C08), got: %s", call) } - if !strings.Contains(call, `export TS_AUTHKEY="$k"`) { - t.Errorf("the key must ride TS_AUTHKEY from the staged file, got: %s", call) + if !strings.Contains(call, "--auth-key=file:/tmp/teploy-vpn-key-") { + t.Errorf("join must pass the staged file via --auth-key=file:, got: %s", call) } } } @@ -333,3 +330,19 @@ func TestNetbirdGetIP_NoIP(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +func TestTailscaleRunningParsesPrettyJSON(t *testing.T) { + for _, tc := range []struct { + out string + want bool + }{ + {"{\n \"Version\": \"1.104.2\",\n \"BackendState\": \"Running\"\n}", true}, + {`{"BackendState":"Running"}`, true}, + {"{\n \"BackendState\": \"NeedsLogin\"\n}", false}, + {"", false}, + } { + if got := TailscaleRunning(tc.out); got != tc.want { + t.Errorf("TailscaleRunning(%q) = %v, want %v", tc.out, got, tc.want) + } + } +} diff --git a/internal/ssh/hostkey.go b/internal/ssh/hostkey.go index 2391320..b41e94e 100644 --- a/internal/ssh/hostkey.go +++ b/internal/ssh/hostkey.go @@ -222,3 +222,53 @@ func hostsKnownUnder(knownHostsPath string, key gossh.PublicKey) []string { } return hosts } + +// knownHostKeyAlgorithms returns the host-key algorithms to offer for a +// host already on file, so negotiation picks a key known_hosts can +// verify. Without it the server's preference wins: a host first trusted +// by stock ssh(1) (which records ed25519) fails with a mismatch when Go +// negotiates ecdsa (2026-10-25 live finding, fresh OVH Debian 13 box). +// An unknown host, or an unreadable file, yields nil — Go's defaults. +func knownHostKeyAlgorithms(knownHostsPath, hostport string) []string { + cb, err := loadKnownHosts(knownHostsPath) + if err != nil || cb == nil { + return nil + } + remote := &net.TCPAddr{IP: net.IPv4zero} + if h, _, splitErr := net.SplitHostPort(hostport); splitErr == nil { + if ip := net.ParseIP(h); ip != nil { + remote.IP = ip + } + } + var keyErr *knownhosts.KeyError + if err := cb(hostport, remote, probeHostKey{}); !errors.As(err, &keyErr) || len(keyErr.Want) == 0 { + return nil + } + var algos []string + seen := map[string]bool{} + add := func(a string) { + if !seen[a] { + seen[a] = true + algos = append(algos, a) + } + } + for _, known := range keyErr.Want { + switch t := known.Key.Type(); t { + case gossh.KeyAlgoRSA: + add(gossh.KeyAlgoRSASHA512) + add(gossh.KeyAlgoRSASHA256) + add(gossh.KeyAlgoRSA) + default: + add(t) + } + } + return algos +} + +// probeHostKey is a key no known_hosts line can match; verifying it +// makes knownhosts report every key on file for the host. +type probeHostKey struct{} + +func (probeHostKey) Type() string { return "teploy-probe" } +func (probeHostKey) Marshal() []byte { return []byte("teploy-probe") } +func (probeHostKey) Verify([]byte, *gossh.Signature) error { return errors.New("probe key") } diff --git a/internal/ssh/hostkey_algos_test.go b/internal/ssh/hostkey_algos_test.go new file mode 100644 index 0000000..0b0577b --- /dev/null +++ b/internal/ssh/hostkey_algos_test.go @@ -0,0 +1,39 @@ +package ssh + +import ( + "crypto/ed25519" + "crypto/rand" + "os" + "path/filepath" + "reflect" + "testing" + + gossh "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +func TestKnownHostKeyAlgorithmsOffersOnlyKeysOnFile(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + key, err := gossh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "known_hosts") + line := knownhosts.Line([]string{knownhosts.Normalize("203.0.113.7:22")}, key) + if err := os.WriteFile(path, []byte(line+"\n"), 0600); err != nil { + t.Fatal(err) + } + + if got, want := knownHostKeyAlgorithms(path, "203.0.113.7:22"), []string{gossh.KeyAlgoED25519}; !reflect.DeepEqual(got, want) { + t.Fatalf("known host: got %v, want %v", got, want) + } + if got := knownHostKeyAlgorithms(path, "198.51.100.1:22"); got != nil { + t.Fatalf("unknown host: got %v, want nil (Go defaults)", got) + } + if got := knownHostKeyAlgorithms(filepath.Join(t.TempDir(), "missing"), "203.0.113.7:22"); got != nil { + t.Fatalf("missing file: got %v, want nil", got) + } +} diff --git a/internal/ssh/remote.go b/internal/ssh/remote.go index 436a28a..e5c5cc0 100644 --- a/internal/ssh/remote.go +++ b/internal/ssh/remote.go @@ -87,6 +87,10 @@ func Connect(ctx context.Context, cfg ConnectConfig) (*RemoteExecutor, error) { } var hostKeyCallback gossh.HostKeyCallback + var hostKeyAlgorithms []string + if home, homeErr := os.UserHomeDir(); homeErr == nil { + hostKeyAlgorithms = knownHostKeyAlgorithms(filepath.Join(home, ".ssh", "known_hosts"), host) + } if cfg.AcceptNewHost { home, err := os.UserHomeDir() if err != nil { @@ -102,9 +106,10 @@ func Connect(ctx context.Context, cfg ConnectConfig) (*RemoteExecutor, error) { } clientConfig := &gossh.ClientConfig{ - User: cfg.User, - Auth: authMethods, - HostKeyCallback: hostKeyCallback, + User: cfg.User, + Auth: authMethods, + HostKeyCallback: hostKeyCallback, + HostKeyAlgorithms: hostKeyAlgorithms, } client, err := dialWithContext(ctx, "tcp", host, clientConfig) @@ -281,9 +286,11 @@ func (e *RemoteExecutor) runDetailed(ctx context.Context, cmd string, stdin io.R // normal 022 umask a freshly created secret file was briefly world-readable, // an interrupted write left a half-written destination, and a pre-existing // symlink at the destination was followed. All three are closed here: -// mktemp(1) creates the temp 0600, umask 077 keeps it that way until the -// requested mode is applied BEFORE any content lands, cat streams stdin -// without buffering, and mv -f renames on the same filesystem (replacing a +// mktemp(1) creates the temp 0600, umask 077 keeps it that way while cat +// streams stdin in, the requested mode is applied only once the content is +// complete (applying it first broke read-only modes such as a 0440 sudoers +// drop-in for any non-root user, since the owner then cannot write its own +// temp; root bypassed the check, which is why it only failed off-root), and mv -f renames on the same filesystem (replacing a // destination symlink itself, not its target). Readers of remotePath see // either the old file or the complete new one, never a partial write. func (e *RemoteExecutor) Upload(ctx context.Context, content io.Reader, remotePath string, mode string) error { @@ -291,7 +298,7 @@ func (e *RemoteExecutor) Upload(ctx context.Context, content io.Reader, remotePa dir := path.Dir(remotePath) script := fmt.Sprintf( - `umask 077 && mkdir -p %s && tmp=$(mktemp %s) && trap 'rm -f -- "$tmp"' EXIT HUP INT TERM && chmod %s "$tmp" && cat > "$tmp" && mv -fT -- "$tmp" %s && trap - EXIT HUP INT TERM`, + `umask 077 && mkdir -p %s && tmp=$(mktemp %s) && trap 'rm -f -- "$tmp"' EXIT HUP INT TERM && cat > "$tmp" && chmod %s "$tmp" && mv -fT -- "$tmp" %s && trap - EXIT HUP INT TERM`, ShellQuote(dir), ShellQuote(dir+"/.teploy-upload.XXXXXXXX"), ShellQuote(mode),