diff --git a/.github/wiki/Nginx-Generation.md b/.github/wiki/Nginx-Generation.md index 3f7fc2810..3369b7174 100644 --- a/.github/wiki/Nginx-Generation.md +++ b/.github/wiki/Nginx-Generation.md @@ -52,7 +52,7 @@ Before persisting secrets or changing file modes, the build checks the real comp Fronted mode is nginx-only. E482 refuses traefik when `NGINX_FRONTED_BY` is set. It also refuses a fronting stack whose generated `docker-compose.yml` has no nginx service or whose `.nself/compose-files.txt` lists a traefik fragment. Keep nginx in both projects, or unset `NGINX_FRONTED_BY` and build this project with its own proxy. -On Linux, with traefik selected, the build writes `NSELF_HOST_UID` and `NSELF_HOST_GID` (the ids of the user running the build, who owns the generated 0600 private keys) to `.nself/compose.env`. The traefik fragment runs as that user with every capability dropped except `NET_BIND_SERVICE`, so it can read the keys. The build also creates `.nself/generated/traefik` and `.nself/generated/traefik-static` as that user before Docker can create them root-owned, and the one-shot `traefik-config` renderer runs as the same user, so swapping back to nginx removes both without `sudo`. Other hosts and nginx projects get no such keys, and the fragment falls back to `0:0`. +On Linux, with traefik selected, the build writes `NSELF_HOST_UID` and `NSELF_HOST_GID` (the ids of the user running the build, who owns the generated 0600 private keys) to `.nself/compose.env`. The traefik fragment runs as that user with every capability dropped except `NET_BIND_SERVICE`, so it can read the keys. The build also creates `.nself/generated/traefik` and `.nself/generated/traefik-static` as that user before Docker can create them root-owned, and the one-shot `traefik-config` renderer runs as the same user, so swapping back to nginx removes both without `sudo`. Other hosts and nginx projects get no such keys, and the fragment falls back to `0:0`. So does a Linux host whose Docker daemon is rootless or runs with a user-namespace remap (`docker info` lists `name=rootless` or `name=userns` under SecurityOptions): there a container uid maps to a sub-uid that does not own the host key, and the `0:0` default is the one that works. If `docker info` cannot be run, the build exports the ids as before. E481 lists every item that prevents a complete route swap: diff --git a/.github/wiki/cmd-deploy.md b/.github/wiki/cmd-deploy.md index 45a55de0d..ae395dc54 100644 --- a/.github/wiki/cmd-deploy.md +++ b/.github/wiki/cmd-deploy.md @@ -507,13 +507,14 @@ Compare the fingerprint with the one your provider shows (console or `ssh-keygen `--trust-host-key`, or ssh to the host once and accept the key. Before P7-DEPL-28 and P7-DEPL-29 the v1.4 secrets push and env-shipping deploys accepted any key on first contact (`accept-new`). -IP-address hosts are pinned under the alias `nself++` (for example -`nself+203.0.113.10+22`) and ssh gets a matching `HostKeyAlias`; hosts with an explicit port use -`nself++`, preserving IPv6 colons. These aliases cannot equal a real hostname. -Existing `nself--` pins remain readable when no new alias pin exists; -lookup never rewrites them. Re-enroll verified keys with `--trust-host-key` to use -the new namespace. Ordinary `nself-*` hostnames remain valid. -A pin file holding a line the CLI did not write (a wildcard, +Deploy, `nself admin` and `nself access` share one pin alias. A host name on port 22 (or no port) is pinned +under the bare name. An IP address, or any host on another port, is pinned under `nself++` +(for example `nself+203.0.113.10+22` or `nself+host.example+2222`) and ssh gets a matching `HostKeyAlias`. +`+` cannot appear in a host name, so two endpoints never share a pin, and `host:22` is the same pin as +`host`. Ordinary `nself-*` hostnames remain valid; they are pinned under `nself++` so a new pin +never lands in the old alias namespace, and a bare `nself-*` pin from an older release needs one re-enrolment +with `--trust-host-key`. Pins an older release wrote as `nself--` are copied to the new alias the +first time the presented key matches; the old line is never rewritten and can be deleted by hand. A pin file holding a line the CLI did not write (a wildcard, `@cert-authority` or hashed entry) or with group- or world-writable permissions is refused with `E487`; fix or remove that line and pin again. diff --git a/internal/access/transport_ssh.go b/internal/access/transport_ssh.go index 7df7f82a0..1ddbfc79d 100644 --- a/internal/access/transport_ssh.go +++ b/internal/access/transport_ssh.go @@ -17,7 +17,6 @@ import ( "context" "fmt" "github.com/nself-org/cli/internal/compat" - "github.com/nself-org/cli/internal/hostpin" "github.com/nself-org/cli/sdk/go/v2/remote" "os" "os/exec" @@ -91,8 +90,13 @@ func (t *SSHTransport) sshArgsContext(ctx context.Context) ([]string, error) { return nil, err } args = append(args, "-o", "StrictHostKeyChecking=yes", "-o", option) - if spec.Port != 0 { - alias := hostpin.PinAlias(ctx, spec.Host, spec.Port, remote.Fingerprint) + // Read-only: a pin an older release made under its own alias still counts + // until deploy or admin connect moves it (remote.MigrateLegacyPin). + alias, err := remote.PinnedHostKeys{Path: filepath.Join(home, ".config", "nself", "deploy_known_hosts")}.EffectivePinAlias(spec) + if err != nil { + return nil, err + } + if alias != strings.ToLower(spec.Host) { args = append(args, "-o", "HostKeyAlias="+alias) } } else { diff --git a/internal/admin/connect.go b/internal/admin/connect.go index 8f57e8414..863b26ef3 100644 --- a/internal/admin/connect.go +++ b/internal/admin/connect.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "net" "net/http" "os" "os/exec" @@ -19,7 +18,6 @@ import ( "github.com/nself-org/cli/internal/compat" "github.com/nself-org/cli/internal/errs" - "github.com/nself-org/cli/internal/hostpin" "github.com/nself-org/cli/sdk/go/v2/remote" ) @@ -167,9 +165,11 @@ func adminHostKeyOptions(ctx context.Context, spec remote.HostSpec) ([]string, e if resolved.HostKeyAlias != "" && resolved.HostKeyAlias != "none" { operatorAlias = resolved.HostKeyAlias } - pinAlias := spec.Host - if (spec.Port != 0 && spec.Port != 22) || net.ParseIP(spec.Host) != nil { - pinAlias = hostpin.PinAlias(ctx, spec.Host, spec.Port, remote.Fingerprint) + // The port stays as given: remote.PinAlias applies the one port-22 rule. + pinAlias := remote.PinAlias(spec) + // A pin an older release made under its own alias moves to the shared one, once. + if err := (remote.PinnedHostKeys{Path: pinFile}).MigrateLegacyPin(spec, keys); err != nil { + return nil, errs.New("E487", fmt.Sprintf("deploy pin file %s refused: %v", pinFile, err)) } operator := adminTrustedFingerprints(ctx, operatorAlias, filepath.Join(home, ".ssh", "known_hosts")) pinned := adminTrustedFingerprints(ctx, pinAlias, pinFile) @@ -181,7 +181,7 @@ func adminHostKeyOptions(ctx context.Context, spec remote.HostSpec) ([]string, e "-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", pinOption, } - if !operator[key.Fingerprint] && (spec.Port != 0 && spec.Port != 22 || net.ParseIP(spec.Host) != nil) { + if pinAlias != strings.ToLower(spec.Host) && !operator[key.Fingerprint] { opts = append(opts, "-o", "HostKeyAlias="+pinAlias) } return opts, nil diff --git a/internal/admin/connect_multiplex_test.go b/internal/admin/connect_multiplex_test.go index aacb0ead5..243d5ddcf 100644 --- a/internal/admin/connect_multiplex_test.go +++ b/internal/admin/connect_multiplex_test.go @@ -15,7 +15,7 @@ func TestAdminStrictPolicyDisablesMultiplexing(t *testing.T) { {"operator-default", "host.test", 22, false}, {"operator-port", "[host.test]:2222", 2222, false}, {"pin-default", "host.test", 22, true}, - {"pin-port", "nself-host.test-2222", 2222, true}, + {"pin-port", "nself+host.test+2222", 2222, true}, } { for _, entry := range entryNames { t.Run(trust.name+"/"+entry, func(t *testing.T) { diff --git a/internal/admin/connect_pin_test.go b/internal/admin/connect_pin_test.go index bd5013acc..fc9eb303d 100644 --- a/internal/admin/connect_pin_test.go +++ b/internal/admin/connect_pin_test.go @@ -1,6 +1,7 @@ package admin import ( + "os" "path/filepath" "reflect" "runtime" @@ -26,13 +27,13 @@ func v15Verify(pin, alias string) []string { } // TestAdminSSHV15PinOnlyExact pins the pin-file-only path: known_hosts is -// absent, the key lives in the nSelf pin file under the nself-- -// alias, and the argv adds HostKeyAlias with the quoted pin file. Covers a DNS -// name and a bracketed IPv6 literal (colons become dashes in the alias). +// absent, the key lives in the nSelf pin file under the nself++ +// alias (PinAlias), and the argv adds HostKeyAlias with the quoted pin file. Covers a DNS +// name and a bracketed IPv6 literal (its colons stay in the alias). func TestAdminSSHV15PinOnlyExact(t *testing.T) { cases := []struct{ host, alias, dest string }{ - {"host.test", "nself-host.test-2222", "u@host.test"}, - {"[2001:db8::1]", "nself-2001-db8--1-2222", "u@2001:db8::1"}, + {"host.test", "nself+host.test+2222", "u@host.test"}, + {"[2001:db8::1]", "nself+2001:db8::1+2222", "u@2001:db8::1"}, } for _, tc := range cases { t.Run(tc.host, func(t *testing.T) { @@ -149,35 +150,39 @@ func TestAdminPinFileOption(t *testing.T) { } } -// TestAdminSSHV15IPPort22PinOnly verifies that an IP literal on port 22 with a -// pin enrolled under the nself--22 alias emits HostKeyAlias so ssh checks -// the pin alias rather than the bare IP. Without HostKeyAlias, strict checking -// would fail because the pin file has no entry for the plain IP. +// TestAdminSSHV15IPPort22PinOnly verifies that an IP literal on port 22 emits +// HostKeyAlias=nself++22 so ssh checks the pin alias rather than the bare IP +// (the pin file refuses an IP as an alias). A pin under the shared alias and an +// older release's nself--22 pin (migrated once) both reach that alias. func TestAdminSSHV15IPPort22PinOnly(t *testing.T) { - compattest.Set(t, true) - e := newArgvEnv(t, "", &hostKey{alias: "nself-203.0.113.7-22", pin: true}) - if err := callEntry("verify", "u", "203.0.113.7", 22); err != nil { - t.Fatal(err) - } - got := e.argv(t) - found := false - for i, w := range got { - if w == "HostKeyAlias=nself-203.0.113.7-22" { - found = true - if i == 0 || got[i-1] != "-o" { - t.Fatalf("HostKeyAlias not preceded by -o: %q", got) + for _, alias := range []string{"nself+203.0.113.7+22", "nself-203.0.113.7-22"} { + t.Run(alias, func(t *testing.T) { + compattest.Set(t, true) + e := newArgvEnv(t, "", &hostKey{alias: alias, pin: true}) + if err := callEntry("verify", "u", "203.0.113.7", 22); err != nil { + t.Fatal(err) } - break - } - } - if !found { - t.Fatalf("missing HostKeyAlias for IP port 22 pin-only: %q", got) + got := e.argv(t) + found := false + for i, w := range got { + if w == "HostKeyAlias=nself+203.0.113.7+22" { + found = true + if i == 0 || got[i-1] != "-o" { + t.Fatalf("HostKeyAlias not preceded by -o: %q", got) + } + break + } + } + if !found { + t.Fatalf("missing HostKeyAlias for IP port 22 pin-only: %q", got) + } + }) } } // TestAdminSSHV15Non22PinAliasFallback verifies that for a non-22 port, the -// admin connect path finds a pin under either the new alias or the legacy -// v1.4.13 alias. This covers the deploy.PinAlias fallback at the admin layer. +// admin connect path finds a pin under either the shared alias or the legacy +// v1.4.13 alias. func TestAdminSSHV15Non22PinAliasFallback(t *testing.T) { cases := []struct{ name, alias string }{ {"new-alias", "nself+host.test+2222"}, @@ -190,16 +195,42 @@ func TestAdminSSHV15Non22PinAliasFallback(t *testing.T) { if err := callEntry("verify", "u", "host.test", 2222); err != nil { t.Fatal(err) } - got := e.argv(t) - found := false - for _, w := range got { - if strings.HasPrefix(w, "HostKeyAlias=") { - found = true - break - } + if joined := strings.Join(e.argv(t), " "); !strings.Contains(joined, "HostKeyAlias=nself+host.test+2222") { + t.Fatalf("missing HostKeyAlias for %s: %s", tc.name, joined) } - if !found { - t.Fatalf("missing HostKeyAlias for %s: %q", tc.name, got) + }) + } +} + +// TestAdminSSHV15LegacyPinMigrates is D-0463: a key pinned by an older release +// under its own alias (deploy's explicit host:22 form, the host:port form) is +// found, moved to the shared remote.PinAlias once, and the legacy line stays. +func TestAdminSSHV15LegacyPinMigrates(t *testing.T) { + cases := []struct { + name, legacy, want string + port int + wantAlias bool + }{ + {"explicit 22 from deploy", "nself-host.test-22", "host.test", 22, false}, + {"custom port", "nself-host.test-2222", "nself+host.test+2222", 2222, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + compattest.Set(t, true) + e := newArgvEnv(t, "", &hostKey{alias: tc.legacy, pin: true}) + if err := callEntry("verify", "u", "host.test", tc.port); err != nil { + t.Fatalf("legacy pin was refused: %v", err) + } + joined := strings.Join(e.argv(t), " ") + if got := strings.Contains(joined, "HostKeyAlias="+tc.want); got != tc.wantAlias { + t.Errorf("HostKeyAlias=%s present = %v, want %v: %s", tc.want, got, tc.wantAlias, joined) + } + pins, err := os.ReadFile(e.pinFile()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(pins), tc.want+" ") || !strings.Contains(string(pins), tc.legacy+" ") { + t.Errorf("pin file lacks the new alias %q or lost the legacy one:\n%s", tc.want, pins) } }) } diff --git a/internal/build/proxy_host_owner.go b/internal/build/proxy_host_owner.go index fd87d8e81..923f74d91 100644 --- a/internal/build/proxy_host_owner.go +++ b/internal/build/proxy_host_owner.go @@ -4,10 +4,9 @@ package build // build issues (P7-DEPL-03, DEPL EPIC ruling 2026-10-12, plugins b3f4cc1f). // Inputs: the build's loaded config, workdir and plugin dir. // Outputs: NSELF_HOST_UID and NSELF_HOST_GID for .nself/compose.env, or nil. -// Constraints: only when the provider is Traefik and the host is Linux, where -// bind-mounted keys keep their host owner and a root container with every -// capability dropped cannot read them. Elsewhere the fragment's 0:0 default -// stays, so an nginx project's compose.env is unchanged. The build process +// Constraints: only for Traefik on Linux, where bind-mounted keys keep their host +// owner and a capability-dropped root container cannot read them; a rootless or +// userns-remapped daemon maps uids to sub-uids, so 0:0 stays (D-0771). The build // issues the keys, so its own ids are their owner. import ( @@ -16,6 +15,7 @@ import ( "strconv" "github.com/nself-org/cli/internal/config" + "github.com/nself-org/cli/internal/docker" ) // proxyHostOwner returns the ids Traefik runs as, or nil when nothing is exported. @@ -23,7 +23,7 @@ func proxyHostOwner(cfg *config.Config, workdir, pluginDir string) map[string]st if runtime.GOOS != "linux" { return nil } - if provider, err := ProxyProvider(cfg, workdir, pluginDir); err != nil || provider != ProviderTraefik { + if provider, err := ProxyProvider(cfg, workdir, pluginDir); err != nil || provider != ProviderTraefik || docker.RemapsUIDsOnce() { return nil } return map[string]string{ diff --git a/internal/build/proxy_host_owner_test.go b/internal/build/proxy_host_owner_test.go index bf71764cd..69cbbd17e 100644 --- a/internal/build/proxy_host_owner_test.go +++ b/internal/build/proxy_host_owner_test.go @@ -7,8 +7,48 @@ import ( "strconv" "strings" "testing" + + "github.com/nself-org/cli/internal/docker" ) +// stubDaemonRemapsUIDs makes the daemon report remapped (or not) for one test. +func stubDaemonRemapsUIDs(t *testing.T, remapped bool) { + t.Helper() + prev := docker.RemapsUIDsOnce + docker.RemapsUIDsOnce = func() bool { return remapped } + t.Cleanup(func() { docker.RemapsUIDsOnce = prev }) +} + +// TestProxyHostOwnerRemappedDaemon runs the whole build against a stubbed +// rootless daemon: no owner ids reach compose.env and no directory is pre-created. +func TestProxyHostOwnerRemappedDaemon(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the export is Linux only; internal/docker tests the daemon rule") + } + for _, name := range []string{"rootless", "userns"} { + t.Run(name, func(t *testing.T) { + stubDaemonRemapsUIDs(t, true) + f := freshProxyFixture(t) + installProxy(t, f.plugins) + if _, err := Build(f.workdir, BuildOptions{Force: true}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(f.workdir, ".nself", "compose.env")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "NSELF_HOST_") { + t.Fatalf("%s daemon still got a host owner:\n%s", name, data) + } + for _, name := range proxyOutputNames { + if exists(proxyOutputPath(f.workdir, name)) { + t.Fatalf("%s pre-created for a daemon that remaps uids", name) + } + } + }) + } +} + // TestProxyHostOwner covers the NSELF_HOST_UID/GID export the Traefik fragment reads. func TestProxyHostOwner(t *testing.T) { read := func(t *testing.T, f *planFixture) string { @@ -20,6 +60,7 @@ func TestProxyHostOwner(t *testing.T) { return string(data) } t.Run("traefik exports the build user on linux only", func(t *testing.T) { + stubDaemonRemapsUIDs(t, false) f := freshProxyFixture(t) installProxy(t, f.plugins) if _, err := Build(f.workdir, BuildOptions{Force: true}); err != nil { diff --git a/internal/build/proxy_prepare.go b/internal/build/proxy_prepare.go index e60c57949..11b403c41 100644 --- a/internal/build/proxy_prepare.go +++ b/internal/build/proxy_prepare.go @@ -4,7 +4,7 @@ package build // build user (D-0782) so Docker never creates them root-owned on Linux, which // would stop an unprivileged build from removing them after a swap to nginx. // Inputs: the build sink, the project workdir and the host owner map from -// proxyHostOwner (non-nil only for Traefik on Linux). +// proxyHostOwner (non-nil only for Traefik on Linux with a daemon that keeps uids). // Outputs: .nself/generated/traefik and .nself/generated/traefik-static, empty, // mode 0755, through the sink (plan mode only records them). // Constraints: a path that exists and is not a plain directory is left alone; diff --git a/internal/deploy/argv_golden_test.go b/internal/deploy/argv_golden_test.go index ce7152825..b73bc4180 100644 --- a/internal/deploy/argv_golden_test.go +++ b/internal/deploy/argv_golden_test.go @@ -161,7 +161,7 @@ func TestGoldenArgv_PushSecrets(t *testing.T) { t.Setenv("HOME", pinHome(t)) read := installFakeSSHTools(t) installFakeKeyscan(t) - if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself-203.0.113.7-22", "ssh-ed25519 AQID"); err != nil { + if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself+203.0.113.7+22", "ssh-ed25519 AQID"); err != nil { t.Fatalf("pin: %v", err) } dir := t.TempDir() @@ -183,7 +183,7 @@ func TestGoldenArgv_PushSecrets(t *testing.T) { "-o", "StrictHostKeyChecking=yes", "-o", `GlobalKnownHostsFile="` + filepath.ToSlash(DeployKnownHostsPath()) + `"`, "-o", "ControlMaster=no", "-o", "ControlPath=none", - "-o", "HostKeyAlias=nself-203.0.113.7-22", + "-o", "HostKeyAlias=nself+203.0.113.7+22", "-o", "ForwardAgent=no", "--", envFile, "deploy@203.0.113.7:/opt/app/.env", }, diff --git a/internal/deploy/pin_alias_test.go b/internal/deploy/pin_alias_test.go new file mode 100644 index 000000000..7367a6fcb --- /dev/null +++ b/internal/deploy/pin_alias_test.go @@ -0,0 +1,115 @@ +package deploy + +// Purpose: D-0463. Deploy pins under remote.PinAlias, so a key trusted for +// host:22 is the key admin and access find for host, and a pin an older release +// wrote under "nself--" moves to the shared alias once. + +import ( + "context" + "strings" + "testing" + + "github.com/nself-org/cli/sdk/go/v2/remote" +) + +const aqidKey = "ssh-ed25519 AQID" + +func TestTrustHostKeyExplicitPort22IsTheBareHostPin(t *testing.T) { + requireKeygen(t) + t.Setenv("NSELF_V15", "1") + t.Setenv("HOME", pinHome(t)) + installFakeKeyscan(t) + fp, err := remote.Fingerprint("h " + aqidKey) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := TrustHostKey(ctx, "u@host.example:22", fp); err != nil { + t.Fatal(err) + } + pin := remote.PinnedHostKeys{Path: DeployKnownHostsPath()} + if keys, err := pin.Lookup("host.example"); err != nil || len(keys) != 1 { + t.Fatalf("host:22 pin under the bare host = %v, %v", keys, err) + } + if keys, _ := pin.Lookup("nself-host.example-22"); len(keys) != 0 { + t.Fatalf("a legacy alias was written: %v", keys) + } + // The same endpoint spelled without the port is trusted by that pin. + opts, err := HostKeyOptions(ctx, "u@host.example", "live", "web", true) + if err != nil { + t.Fatalf("host without port: %v", err) + } + if strings.Contains(strings.Join(opts, " "), "HostKeyAlias") { + t.Errorf("a bare-host pin needs no HostKeyAlias: %q", opts) + } +} + +func TestLegacyDeployPinMigratesOnce(t *testing.T) { + requireKeygen(t) + for host, legacy := range map[string]string{ + "u@host.example:22": "nself-host.example-22", + "u@host.example:2222": "nself-host.example-2222", + "u@203.0.113.7": "nself-203.0.113.7-22", + } { + host, legacy := host, legacy + t.Run(host, func(t *testing.T) { + t.Setenv("NSELF_V15", "1") + t.Setenv("HOME", pinHome(t)) + installFakeKeyscan(t) + pin := remote.PinnedHostKeys{Path: DeployKnownHostsPath()} + if err := pin.Add(legacy, aqidKey); err != nil { + t.Fatal(err) + } + spec := mustSpec(t, host) + if _, err := HostKeyOptions(context.Background(), host, "live", "web", true); err != nil { + t.Fatalf("a legacy pin of the presented key was refused: %v", err) + } + if keys, err := pin.Lookup(remote.PinAlias(spec)); err != nil || len(keys) != 1 { + t.Fatalf("the pin was not moved to %s: %v, %v", remote.PinAlias(spec), keys, err) + } + if keys, _ := pin.Lookup(legacy); len(keys) != 1 { + t.Fatalf("the legacy line must stay: %v", keys) + } + }) + } +} + +func TestLegacyDeployPinOfAnotherKeyStaysRefused(t *testing.T) { + requireKeygen(t) + t.Setenv("NSELF_V15", "1") + t.Setenv("HOME", pinHome(t)) + installFakeKeyscan(t) + pin := remote.PinnedHostKeys{Path: DeployKnownHostsPath()} + other := "ssh-ed25519 AQIDBA==" // not what the scan presents + if err := pin.Add("nself-host.example-2222", other); err != nil { + t.Fatal(err) + } + _, err := HostKeyOptions(context.Background(), "u@host.example:2222", "live", "web", true) + if err == nil || !strings.Contains(err.Error(), "E487") { + t.Fatalf("a legacy pin of a different key was accepted: %v", err) + } + if keys, _ := pin.Lookup("nself+host.example+2222"); len(keys) != 0 { + t.Fatalf("a non-matching legacy key was migrated: %v", keys) + } +} + +// TestCollidingEndpointsKeepSeparatePins is the collision from the debt row: an +// IPv6 literal on 2222 and a host named like its folded form used one pin. +func TestCollidingEndpointsKeepSeparatePins(t *testing.T) { + requireKeygen(t) + t.Setenv("NSELF_V15", "1") + t.Setenv("HOME", pinHome(t)) + installFakeKeyscan(t) + fp, err := remote.Fingerprint("h " + aqidKey) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := TrustHostKey(ctx, "u@[2001:db8::1]:2222", fp); err != nil { + t.Fatal(err) + } + _, err = HostKeyOptions(ctx, "u@2001-db8--1:2222", "live", "web", true) + if err == nil || !strings.Contains(err.Error(), "E487") { + t.Fatalf("trusting the IPv6 endpoint trusted a different host: %v", err) + } +} diff --git a/internal/deploy/secrets_test.go b/internal/deploy/secrets_test.go index d49272539..b1e954793 100644 --- a/internal/deploy/secrets_test.go +++ b/internal/deploy/secrets_test.go @@ -174,7 +174,7 @@ func TestPushSecretsLocalOperand(t *testing.T) { t.Setenv("HOME", pinHome(t)) read := installFakeSSHTools(t) installFakeKeyscan(t) - if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself-203.0.113.7-22", "ssh-ed25519 AQID"); err != nil { + if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself+203.0.113.7+22", "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } dir := t.TempDir() diff --git a/internal/deploy/ssh_alias_test.go b/internal/deploy/ssh_alias_test.go index 0e47b0c1d..1508fb5bb 100644 --- a/internal/deploy/ssh_alias_test.go +++ b/internal/deploy/ssh_alias_test.go @@ -44,20 +44,23 @@ func TestDeployPinAliasIsolation(t *testing.T) { } } -func TestDeployLegacyAliasReadOnly(t *testing.T) { +// TestDeployLegacyAliasNeverRewritten: a pin an older release wrote under its +// own alias keeps working, ssh checks the shared remote.PinAlias, and no line +// already in the pin file is rewritten or removed (D-0696, D-0463). +func TestDeployLegacyAliasNeverRewritten(t *testing.T) { requireKeygen(t) - for _, host := range []string{"203.0.113.7", "x:22", "[1::2]:22", "nself-x-22"} { + for _, host := range []string{"203.0.113.7", "x:22", "[1::2]:22"} { t.Run(host, func(t *testing.T) { t.Setenv("NSELF_V15", "1") t.Setenv("HOME", pinHome(t)) installFakeKeyscan(t) spec := mustSpec(t, host) - alias := spec.Host - if spec.Port != 0 || strings.Contains(host, "203.0.113.7") { - alias = "nself-" + strings.ReplaceAll(spec.Host, ":", "-") + "-22" + legacy := remote.LegacyPinAliases(spec) + if len(legacy) != 1 { + t.Fatalf("legacy aliases for %s: %v", host, legacy) } pin := remote.PinnedHostKeys{Path: DeployKnownHostsPath()} - if err := pin.Add(alias, "ssh-ed25519 AQID"); err != nil { + if err := pin.Add(legacy[0], "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } before, err := os.ReadFile(pin.Path) @@ -68,15 +71,36 @@ func TestDeployLegacyAliasReadOnly(t *testing.T) { if err != nil { t.Fatal(err) } - if alias != spec.Host && !strings.Contains(strings.Join(opts, " "), "HostKeyAlias="+alias) { - t.Fatalf("legacy alias missing: %v", opts) + alias := remote.PinAlias(spec) + if alias != strings.ToLower(spec.Host) && !strings.Contains(strings.Join(opts, " "), "HostKeyAlias="+alias) { + t.Fatalf("shared alias missing: %v", opts) } after, err := os.ReadFile(pin.Path) - if err != nil || string(before) != string(after) { - t.Fatalf("compat lookup changed pin file: %v", err) + if err != nil || !strings.HasPrefix(string(after), string(before)) { + t.Fatalf("legacy pin lines rewritten: %v\nbefore:\n%s\nafter:\n%s", err, before, after) } }) } + // Ordinary nself-* hostnames stay valid: a fresh enrolment pins and passes. + t.Run("nself-x-22", func(t *testing.T) { + t.Setenv("NSELF_V15", "1") + t.Setenv("HOME", pinHome(t)) + installFakeKeyscan(t) + fp, err := remote.Fingerprint("h ssh-ed25519 AQID") + if err != nil { + t.Fatal(err) + } + if err := TrustHostKey(context.Background(), "nself-x-22", fp); err != nil { + t.Fatal(err) + } + opts, err := HostKeyPolicy(context.Background(), "nself-x-22", "live", "web", true, true) + if err != nil { + t.Fatalf("enrolled nself-* host refused: %v", err) + } + if alias := remote.PinAlias(mustSpec(t, "nself-x-22")); alias != "nself-x-22" && !strings.Contains(strings.Join(opts, " "), "HostKeyAlias="+alias) { + t.Fatalf("nself-* host not checked under its pin alias %s: %v", alias, opts) + } + }) } func TestDeployNewAliasOverridesLegacy(t *testing.T) { @@ -98,27 +122,36 @@ func TestDeployNewAliasOverridesLegacy(t *testing.T) { } } +// TestPinAliasLegacyFallbackForOtherCallers: access reads the deploy pin file +// through EffectivePinAlias, which falls back to the legacy alias read-only. func TestPinAliasLegacyFallbackForOtherCallers(t *testing.T) { requireKeygen(t) t.Setenv("HOME", pinHome(t)) - spec := mustSpec(t, "u@example.test:2222") - if got := PinAlias(context.Background(), spec); got != "nself+example.test+2222" { + pin := remote.PinnedHostKeys{Path: DeployKnownHostsPath()} + effective := func(host string) string { + t.Helper() + got, err := pin.EffectivePinAlias(mustSpec(t, host)) + if err != nil { + t.Fatal(err) + } + return got + } + if got := effective("u@example.test:2222"); got != "nself+example.test+2222" { t.Fatalf("no pins: alias %q", got) } - pin := remote.PinnedHostKeys{Path: DeployKnownHostsPath()} if err := pin.Add("nself-example.test-2222", "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } - if got := PinAlias(context.Background(), spec); got != "nself-example.test-2222" { + if got := effective("u@example.test:2222"); got != "nself-example.test-2222" { t.Fatalf("legacy-only pin: alias %q", got) } if err := pin.Add("nself+example.test+2222", "ssh-ed25519 AQIE"); err != nil { t.Fatal(err) } - if got := PinAlias(context.Background(), spec); got != "nself+example.test+2222" { + if got := effective("u@example.test:2222"); got != "nself+example.test+2222" { t.Fatalf("new pin must win: alias %q", got) } - if got := PinAlias(context.Background(), mustSpec(t, "example.test")); got != "example.test" { + if got := effective("example.test"); got != "example.test" { t.Fatalf("plain host alias %q", got) } } diff --git a/internal/deploy/ssh_hostkeys.go b/internal/deploy/ssh_hostkeys.go index 56203743b..c66a98606 100644 --- a/internal/deploy/ssh_hostkeys.go +++ b/internal/deploy/ssh_hostkeys.go @@ -34,9 +34,7 @@ func knownHostsOption() (string, error) { return remote.GlobalKnownHostsFileOption(DeployKnownHostsPath()) } -func deployHostPort(spec remote.HostSpec) int { return hostpin.HostPort(spec.Port) } -func deployKeyAlias(spec remote.HostSpec) string { return hostpin.KeyAlias(spec.Host, spec.Port) } -func legacyKeyAlias(spec remote.HostSpec) string { return hostpin.LegacyKeyAlias(spec.Host, spec.Port) } +func deployHostPort(spec remote.HostSpec) int { return hostpin.HostPort(spec.Port) } // TrustHostKey scans live keys and pins only the key with the offered fingerprint. func TrustHostKey(ctx context.Context, host, offered string) error { @@ -53,7 +51,7 @@ func TrustHostKey(ctx context.Context, host, offered string) error { } for _, k := range keys { if k.Fingerprint == offered { - return (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add(deployKeyAlias(spec), k.Line) + return (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add(remote.PinAlias(spec), k.Line) } } return errs.New("E487", fmt.Sprintf("host %s presented no key matching %s", spec.Host, offered)) @@ -76,25 +74,6 @@ func pinnedFingerprints(ctx context.Context, alias string) (map[string]bool, err return hostpin.TrustedFingerprints(ctx, alias, DeployKnownHostsPath(), remote.Fingerprint), nil } -// deployPinnedFingerprints reads legacy IP/port pins only when no new pin -// exists. It returns the alias SSH must check and never rewrites the pin file. -func deployPinnedFingerprints(ctx context.Context, spec remote.HostSpec) (string, map[string]bool, error) { - alias := deployKeyAlias(spec) - pinned, err := pinnedFingerprints(ctx, alias) - if err != nil || len(pinned) != 0 || alias == spec.Host { - return alias, pinned, err - } - legacy := legacyKeyAlias(spec) - pinned, err = pinnedFingerprints(ctx, legacy) - return legacy, pinned, err -} - -// PinAlias delegates to hostpin.PinAlias; kept here so existing deploy callers -// keep compiling without importing hostpin directly. -func PinAlias(ctx context.Context, spec remote.HostSpec) string { - return hostpin.PinAlias(ctx, spec.Host, spec.Port, remote.Fingerprint) -} - // hostKeyPolicy is the unexported spelling of HostKeyPolicy, kept for the // callers in this package that predate the export. func hostKeyPolicy(ctx context.Context, host, env, server string, strict, shipsSecrets bool) ([]string, error) { @@ -123,11 +102,16 @@ func HostKeyPolicy(ctx context.Context, host, env, server string, strict, shipsS } home, _ := os.UserHomeDir() operatorAlias := spec.Host - if spec.Port != 0 { + if spec.Port != 0 && spec.Port != 22 { // ssh stores port 22 under the bare name, never [host]:22 operatorAlias = "[" + spec.Host + "]:" + strconv.Itoa(spec.Port) } - operator := hostpin.TrustedFingerprints(ctx, operatorAlias, filepath.Join(home, ".ssh", "known_hosts"), remote.Fingerprint) - alias, pinned, err := deployPinnedFingerprints(ctx, spec) + alias := remote.PinAlias(spec) + // A pin an older release made under its own alias moves to the shared one, once. + if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).MigrateLegacyPin(spec, keys); err != nil { + return nil, errs.New("E487", fmt.Sprintf("deploy pin file %s refused: %v", DeployKnownHostsPath(), err)) + } + operator := deployTrustedFingerprints(ctx, operatorAlias, filepath.Join(home, ".ssh", "known_hosts")) + pinned, err := pinnedFingerprints(ctx, alias) if err != nil { return nil, err } @@ -137,7 +121,7 @@ func HostKeyPolicy(ctx context.Context, host, env, server string, strict, shipsS // check, so a strict connection never shares one (an accept-new probe // could have opened it): ControlMaster=no, ControlPath=none. opts := []string{"-o", "StrictHostKeyChecking=yes", "-o", pinOption, "-o", "ControlMaster=no", "-o", "ControlPath=none"} - if alias != spec.Host && !operator[k.Fingerprint] { + if alias != strings.ToLower(spec.Host) && !operator[k.Fingerprint] { opts = append(opts, "-o", "HostKeyAlias="+alias) } return opts, nil diff --git a/internal/deploy/ssh_hostkeys_test.go b/internal/deploy/ssh_hostkeys_test.go index f955dfeba..8c8ae5dfd 100644 --- a/internal/deploy/ssh_hostkeys_test.go +++ b/internal/deploy/ssh_hostkeys_test.go @@ -111,7 +111,7 @@ func TestSecretShippingPinsHostKeyV14(t *testing.T) { if n := len(read()); n != 0 { t.Fatalf("%d process(es) ran before the host-key refusal", n) } - alias := deployKeyAlias(mustSpec(t, cfg.Host)) + alias := remote.PinAlias(mustSpec(t, cfg.Host)) if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add(alias, "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } @@ -140,7 +140,7 @@ func TestSecretShippingPinsHostKeyV14(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "E487") || len(read()) != 0 { t.Fatalf("v1.4 unknown host: %v", err) } - if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself-203.0.113.7-22", "ssh-ed25519 AQID"); err != nil { + if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself+203.0.113.7+22", "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } if err := DeployViaSsh(context.Background(), cfg, "/work/compose.yml"); err != nil { @@ -164,7 +164,7 @@ func TestSecretShippingPinsHostKeyV14(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "E487") || len(read()) != 0 { t.Fatalf("v1.5 unknown host: %v", err) } - if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself-203.0.113.7-22", "ssh-ed25519 AQID"); err != nil { + if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself+203.0.113.7+22", "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } if err := DeployViaSsh(context.Background(), cfg, "/work/compose.yml"); err != nil { @@ -223,10 +223,11 @@ func TestDeployKeyAliasIPHost(t *testing.T) { "u@203.0.113.7": "nself+203.0.113.7+22", "u@[2001:db8::1]:2222": "nself+2001:db8::1+2222", "u@host.example:2222": "nself+host.example+2222", + "u@host.example:22": "host.example", "u@host.example": "host.example", } { - if got := deployKeyAlias(mustSpec(t, in)); got != want { - t.Errorf("deployKeyAlias(%q) = %q, want %q", in, got, want) + if got := remote.PinAlias(mustSpec(t, in)); got != want { + t.Errorf("PinAlias(%q) = %q, want %q", in, got, want) } } t.Setenv("HOME", pinHome(t)) diff --git a/internal/deploy/ssh_ships_test.go b/internal/deploy/ssh_ships_test.go index 040712082..5ba6ef481 100644 --- a/internal/deploy/ssh_ships_test.go +++ b/internal/deploy/ssh_ships_test.go @@ -86,7 +86,7 @@ func TestDeployViaSshEnvFileStrictV14(t *testing.T) { if n := len(read()); n != 0 { t.Fatalf("%d process(es) ran before the host-key refusal", n) } - if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself-203.0.113.7-22", "ssh-ed25519 AQID"); err != nil { + if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself+203.0.113.7+22", "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } if err := DeployViaSsh(context.Background(), cfg, "/work/compose.yml"); err != nil { @@ -143,7 +143,7 @@ func TestHostKeyPolicyShipsSecrets(t *testing.T) { t.Fatalf("v1.4 without shipsSecrets: %q, %v", got, err) } } - if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself-203.0.113.7-22", "ssh-ed25519 AQID"); err != nil { + if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself+203.0.113.7+22", "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } got, err := HostKeyPolicy(ctx, "u@203.0.113.7", "live", "web", false, true) @@ -334,7 +334,7 @@ func TestStrictPolicyNeverSharesConnections(t *testing.T) { t.Setenv("NSELF_V15", v15) t.Setenv("HOME", pinHome(t)) installFakeKeyscan(t) - if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself-203.0.113.7-22", "ssh-ed25519 AQID"); err != nil { + if err := (remote.PinnedHostKeys{Path: DeployKnownHostsPath()}).Add("nself+203.0.113.7+22", "ssh-ed25519 AQID"); err != nil { t.Fatal(err) } opts, err := HostKeyPolicy(context.Background(), "u@203.0.113.7", "qa", "web", true, true) diff --git a/internal/docker/security_options.go b/internal/docker/security_options.go new file mode 100644 index 000000000..ac0e14843 --- /dev/null +++ b/internal/docker/security_options.go @@ -0,0 +1,45 @@ +package docker + +// Purpose: report whether the Docker daemon remaps container uids (D-0771). +// Inputs: the daemon `docker info` reaches through the usual DOCKER_HOST rules. +// Outputs: the SecurityOptions entries, and whether they name a rootless or +// user-namespace-remapped daemon. +// Constraints: one read-only `docker info`, ten second timeout. + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + "sync" + "time" +) + +// RemapsUIDs reports whether the daemon is rootless or runs with a +// user-namespace remap: a container uid then maps to a sub-uid that does not +// own the host's files. A daemon that cannot be asked counts as not remapping, +// the behaviour before it was consulted. +func RemapsUIDs() bool { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{json .SecurityOptions}}").Output() + var opts []string + if err != nil || json.Unmarshal(out, &opts) != nil { + return false + } + return remappedOptions(opts) +} + +// RemapsUIDsOnce asks the daemon once per process. Tests replace it. +var RemapsUIDsOnce = sync.OnceValue(RemapsUIDs) + +func remappedOptions(opts []string) bool { + for _, opt := range opts { + for _, field := range strings.Split(opt, ",") { + if field == "name=rootless" || field == "name=userns" { + return true + } + } + } + return false +} diff --git a/internal/docker/security_options_test.go b/internal/docker/security_options_test.go new file mode 100644 index 000000000..23ba00ca3 --- /dev/null +++ b/internal/docker/security_options_test.go @@ -0,0 +1,22 @@ +package docker + +import "testing" + +func TestRemappedOptions(t *testing.T) { + cases := []struct { + opts []string + want bool + }{ + {nil, false}, + {[]string{"name=seccomp,profile=builtin", "name=cgroupns"}, false}, + {[]string{"name=seccomp,profile=builtin", "name=rootless"}, true}, + {[]string{"name=userns"}, true}, + {[]string{"name=rootless,foo=bar"}, true}, + {[]string{"name=rootlessish", "name=usernsx"}, false}, + } + for _, tc := range cases { + if got := remappedOptions(tc.opts); got != tc.want { + t.Errorf("remappedOptions(%v) = %v, want %v", tc.opts, got, tc.want) + } + } +} diff --git a/internal/hostpin/hostpin.go b/internal/hostpin/hostpin.go index 5282ea892..c89016bb3 100644 --- a/internal/hostpin/hostpin.go +++ b/internal/hostpin/hostpin.go @@ -1,16 +1,14 @@ -// Package hostpin provides SSH host-key pin alias resolution for callers that -// do their own pin lookup (admin connect, access). It lives at L0 (stdlib -// only, the sdk fingerprint parser is injected) so L1 packages can resolve a pin -// alias without importing internal/deploy (L2). +// Package hostpin holds the deploy pin file location and the ssh-keygen pin +// lookup shared by deploy callers. It lives at L0 (stdlib only, the sdk +// fingerprint parser is injected). The pin alias itself is remote.PinAlias, the +// one scheme deploy, admin connect and access share (D-0463). package hostpin import ( "context" - "net" "os" "os/exec" "path/filepath" - "strconv" "strings" ) @@ -29,24 +27,6 @@ func HostPort(port int) int { return port } -// KeyAlias is the pin alias for host and port. A port or an IP literal needs a -// synthetic alias: the sdk refuses an IP literal as a pin alias. '+' is -// allowed in pin aliases but forbidden in host names, keeping the namespaces -// disjoint. -func KeyAlias(host string, port int) string { - if port != 0 || net.ParseIP(host) != nil { - return "nself+" + host + "+" + strconv.Itoa(HostPort(port)) - } - return host -} - -// LegacyKeyAlias is the v1.4.13 spelling of the IP/port pin alias. It can -// equal a real host name, so it is only ever read, and only when no pin exists -// under the new alias (D-0696). -func LegacyKeyAlias(host string, port int) string { - return "nself-" + strings.ReplaceAll(host, ":", "-") + "-" + strconv.Itoa(HostPort(port)) -} - // TrustedFingerprints returns the fingerprints ssh-keygen reports for alias in // path, each line parsed by fp (the sdk remote.Fingerprint; injected so this // package stays at L0). Errors are swallowed: the caller treats an empty set as @@ -64,16 +44,3 @@ func TrustedFingerprints(ctx context.Context, alias, path string, fp func(string } return seen } - -// PinAlias returns the HostKeyAlias to give ssh for a host with an explicit -// port or an IP literal. It is the new alias unless only a v1.4.13 pin exists. -func PinAlias(ctx context.Context, host string, port int, fp func(string) (string, error)) string { - alias := KeyAlias(host, port) - if alias == host || len(TrustedFingerprints(ctx, alias, KnownHostsPath(), fp)) != 0 { - return alias - } - if legacy := LegacyKeyAlias(host, port); len(TrustedFingerprints(ctx, legacy, KnownHostsPath(), fp)) != 0 { - return legacy - } - return alias -} diff --git a/internal/repoqa/testdata/layers.yaml b/internal/repoqa/testdata/layers.yaml index 6452d2747..4c1c6004c 100644 --- a/internal/repoqa/testdata/layers.yaml +++ b/internal/repoqa/testdata/layers.yaml @@ -58,7 +58,7 @@ rules: - {match: "internal/output/**", layer: L0} - {match: "internal/canon/**", layer: L0} - {match: "internal/cmdregistry/**", layer: L0} - - {match: "internal/hostpin/**", layer: L0} # SSH pin alias resolution: stdlib only; sdk fingerprint parser injected + - {match: "internal/hostpin/**", layer: L0} # deploy pin file path and ssh-keygen lookup: stdlib only; sdk fingerprint parser injected - {match: "internal/sysexec/**", layer: L0} # command runner + headless check for system-changing setup (trust, ssl, dns) # L2: domain orchestrators plus the planned ones - {match: "internal/upgrade/stack/**", layer: L2} diff --git a/sdk/go/CHANGELOG.md b/sdk/go/CHANGELOG.md index 3a6ab922c..2b89c407c 100644 --- a/sdk/go/CHANGELOG.md +++ b/sdk/go/CHANGELOG.md @@ -11,6 +11,10 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `remote.PinAlias(HostSpec)`, `remote.LegacyPinAliases`, `PinnedHostKeys.MigrateLegacyPin` and + `PinnedHostKeys.EffectivePinAlias` (D-0463): one pin-file alias per endpoint, `nself++` + (a host name cannot hold `+`), with port 22 and no port the same endpoint, and a one-time copy of a + pin held under the old `nself--` alias. - Runtime entitlement APIs in `licensing` (P7-PLUG-24): `Guard(ctx, slug, next, ...GuardOption) *Middleware`, `VerifyEntitlement(slug, now) error`, `ErrEntitlementUnconfigured`, diff --git a/sdk/go/remote/pinalias.go b/sdk/go/remote/pinalias.go new file mode 100644 index 000000000..8b8d31161 --- /dev/null +++ b/sdk/go/remote/pinalias.go @@ -0,0 +1,136 @@ +// Purpose: the one pin-alias scheme for nSelf's own host-key pin file, shared +// by deploy, admin connect and access. +// Inputs: a HostSpec (host, optional port). +// Outputs: the alias under which that endpoint's keys are pinned and passed to +// ssh as HostKeyAlias, the aliases older releases used for it, and a +// one-time copy of a legacy pin to the new alias. +// Constraints: the alias is a function of (lowercased host, effective port) +// alone and is injective: a name host never contains '+' or ':', +// so "nself++" cannot be produced by any host name +// and two endpoints never share an alias. Port 22 and no port are +// the same endpoint (ssh's default), one rule. A name host on +// port 22 keeps its bare name (the operator's known_hosts form); +// an IP literal always gets the synthetic form because the pin +// file refuses an IP as an alias. A name starting "nself-" also +// gets the synthetic form: that bare prefix is the legacy alias +// namespace, so a pin written by this scheme is never read as +// another endpoint's legacy pin. Legacy aliases are only read: +// nothing is deleted from the pin file. + +package remote + +import ( + "fmt" + "strconv" + "strings" +) + +// defaultSSHPort is the port ssh uses when none is given. +const defaultSSHPort = 22 + +// pinAliasPrefix starts every synthetic alias. '+' is outside what a host name +// can hold, so no host name collides with it. +const pinAliasPrefix = "nself+" + +// legacyAliasPrefix starts every legacy alias. PinAlias never returns a bare +// name with this prefix, so a current pin cannot be mistaken for a legacy one. +const legacyAliasPrefix = "nself-" + +func effectivePort(spec HostSpec) int { + if spec.Port == 0 { + return defaultSSHPort + } + return spec.Port +} + +// PinAlias returns the alias spec's host keys are pinned under. +func PinAlias(spec HostSpec) string { + host := strings.ToLower(spec.Host) + port := effectivePort(spec) + if port == defaultSSHPort && !isIPLiteral(spec.Host) && !strings.HasPrefix(host, legacyAliasPrefix) { + return host + } + return pinAliasPrefix + host + "+" + strconv.Itoa(port) +} + +// LegacyPinAliases returns the aliases releases before PinAlias pinned spec +// under, other than PinAlias(spec) itself. They were +// "nself--" for any host with a port and for any IP +// literal (port 22 when none was given), which collided across endpoints and +// made deploy's "host:22" pin invisible to admin. +func LegacyPinAliases(spec HostSpec) []string { + if spec.Port == 0 && !isIPLiteral(spec.Host) { + return nil // the bare name, which is also the current alias + } + return []string{legacyAliasPrefix + strings.ReplaceAll(strings.ToLower(spec.Host), ":", "-") + "-" + strconv.Itoa(effectivePort(spec))} +} + +// keyBlob is the " " form of a key line. +func keyBlob(line string) (string, error) { + typ, b64, err := parseKeyLine(line) + if err != nil { + return "", err + } + return typ + " " + b64, nil +} + +// MigrateLegacyPin copies the keys pinned for spec under a legacy alias to +// PinAlias(spec), but only those the endpoint presents now (live): a legacy +// pin that names a different key is left alone and the caller's normal check +// refuses it. A key already pinned, or a different key of a type already +// pinned at the new alias, copies nothing. The legacy lines stay in the file. +func (p PinnedHostKeys) MigrateLegacyPin(spec HostSpec, live []HostKey) error { + legacy := LegacyPinAliases(spec) + if len(legacy) == 0 { + return nil + } + alias := PinAlias(spec) + current, err := p.Lookup(alias) + if err != nil { + return err + } + pinnedTypes := map[string]bool{} + for _, k := range current { + pinnedTypes[strings.Fields(k)[0]] = true + } + for _, old := range legacy { + keys, err := p.Lookup(old) + if err != nil { + return err + } + for _, k := range keys { + for _, l := range live { + blob, err := keyBlob(l.Line) + if err != nil || blob != k || pinnedTypes[l.Type] { + continue + } + if err := p.Add(alias, l.Line); err != nil { + return fmt.Errorf("migrating the pin for %s: %w", alias, err) + } + pinnedTypes[l.Type] = true + } + } + } + return nil +} + +// EffectivePinAlias is PinAlias(spec), or the legacy alias when only that one +// holds pins: the read-only form for a caller that has no live key to migrate +// with. Nothing is written. +func (p PinnedHostKeys) EffectivePinAlias(spec HostSpec) (string, error) { + alias := PinAlias(spec) + keys, err := p.Lookup(alias) + if err != nil || len(keys) > 0 { + return alias, err + } + for _, old := range LegacyPinAliases(spec) { + keys, err := p.Lookup(old) + if err != nil { + return alias, err + } + if len(keys) > 0 { + return old, nil + } + } + return alias, nil +} diff --git a/sdk/go/remote/pinalias_test.go b/sdk/go/remote/pinalias_test.go new file mode 100644 index 000000000..77a126c52 --- /dev/null +++ b/sdk/go/remote/pinalias_test.go @@ -0,0 +1,259 @@ +package remote + +import ( + "os" + "path/filepath" + "testing" +) + +func mustSpec(t *testing.T, in string) HostSpec { + t.Helper() + spec, err := ParseHostSpec(in) + if err != nil { + t.Fatalf("ParseHostSpec(%q): %v", in, err) + } + return spec +} + +func TestPinAlias(t *testing.T) { + cases := []struct{ in, want string }{ + {"a.test", "a.test"}, + {"A.Test", "a.test"}, + {"a.test:22", "a.test"}, // one port-22 rule: explicit 22 is the default endpoint + {"a.test:2222", "nself+a.test+2222"}, + {"203.0.113.9", "nself+203.0.113.9+22"}, + {"203.0.113.9:22", "nself+203.0.113.9+22"}, + {"203.0.113.9:2222", "nself+203.0.113.9+2222"}, + {"[2001:db8::1]", "nself+2001:db8::1+22"}, + {"[2001:DB8::1]:2222", "nself+2001:db8::1+2222"}, + {"u@a.test:2222", "nself+a.test+2222"}, // the user is not part of the endpoint + } + for _, tc := range cases { + if got := PinAlias(mustSpec(t, tc.in)); got != tc.want { + t.Errorf("PinAlias(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestPinAliasNoCollision is D-0463: the old scheme mapped these pairs to one alias. +func TestPinAliasNoCollision(t *testing.T) { + pairs := [][2]string{ + {"[2001:db8::1]:2222", "2001-db8--1:2222"}, // ':' folded to '-' + {"nself-a.test-2222", "a.test:2222"}, // a host named like an alias, on 22 + {"nself-a.test-2222:22", "a.test:2222"}, // same, explicit 22 + {"a.test-2222", "a.test:2222"}, // port folded into the name + {"a.test:2222", "a.test:22222"}, // port digits run into the host + {"203.0.113.9:2222", "203.0.113.9:222"}, + {"[2001:db8::1]", "[2001:db8::1]:2222"}, + } + for _, p := range pairs { + a, b := PinAlias(mustSpec(t, p[0])), PinAlias(mustSpec(t, p[1])) + if a == b { + t.Errorf("%q and %q share the pin alias %q", p[0], p[1], a) + } + } + // No host name can spell a synthetic alias: ParseHostSpec refuses '+'. + if _, err := ParseHostSpec("nself+a.test+2222"); err == nil { + t.Error("a host name holding '+' parsed, so a name could collide with a synthetic alias") + } +} + +func TestPinAliasIsAcceptedByThePinFile(t *testing.T) { + for _, in := range []string{"a.test", "a.test:2222", "203.0.113.9", "[2001:db8::1]:2222"} { + if _, err := checkPinAlias(PinAlias(mustSpec(t, in))); err != nil { + t.Errorf("pin alias for %q refused: %v", in, err) + } + } +} + +func TestLegacyPinAliases(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"a.test", nil}, + {"a.test:22", []string{"nself-a.test-22"}}, // deploy pinned explicit 22 under this + {"a.test:2222", []string{"nself-a.test-2222"}}, + {"203.0.113.9", []string{"nself-203.0.113.9-22"}}, + {"[2001:db8::1]:2222", []string{"nself-2001-db8--1-2222"}}, + } + for _, tc := range cases { + got := LegacyPinAliases(mustSpec(t, tc.in)) + if len(got) != len(tc.want) || (len(got) == 1 && got[0] != tc.want[0]) { + t.Errorf("LegacyPinAliases(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func liveKey(t *testing.T, line string) HostKey { + t.Helper() + typ, _, err := parseKeyLine(line) + if err != nil { + t.Fatal(err) + } + fp, err := Fingerprint(line) + if err != nil { + t.Fatal(err) + } + return HostKey{Line: line, Type: typ, Fingerprint: fp} +} + +func newPins(t *testing.T) PinnedHostKeys { + t.Helper() + return PinnedHostKeys{Path: filepath.Join(safeTempDir(t), "deploy_known_hosts")} +} + +func lookup(t *testing.T, p PinnedHostKeys, alias string) []string { + t.Helper() + keys, err := p.Lookup(alias) + if err != nil { + t.Fatal(err) + } + return keys +} + +func TestMigrateLegacyPinCopiesTheMatchingKey(t *testing.T) { + cases := []struct{ host, legacy string }{ + {"a.test:22", "nself-a.test-22"}, // the explicit-22 pin admin could not see + {"a.test:2222", "nself-a.test-2222"}, + {"203.0.113.9", "nself-203.0.113.9-22"}, + } + for _, tc := range cases { + t.Run(tc.host, func(t *testing.T) { + p := newPins(t) + spec := mustSpec(t, tc.host) + if err := p.Add(tc.legacy, keyA); err != nil { + t.Fatal(err) + } + if err := p.MigrateLegacyPin(spec, []HostKey{liveKey(t, keyA)}); err != nil { + t.Fatal(err) + } + if got := lookup(t, p, PinAlias(spec)); len(got) != 1 || got[0] != keyA { + t.Fatalf("new alias holds %v, want [%s]", got, keyA) + } + if got := lookup(t, p, tc.legacy); len(got) != 1 { + t.Fatalf("the legacy pin was removed: %v", got) + } + // A second run is a no-op. + if err := p.MigrateLegacyPin(spec, []HostKey{liveKey(t, keyA)}); err != nil { + t.Fatal(err) + } + if got := lookup(t, p, PinAlias(spec)); len(got) != 1 { + t.Fatalf("second migration changed the pin: %v", got) + } + }) + } +} + +func TestMigrateLegacyPinRefusals(t *testing.T) { + spec := mustSpec(t, "a.test:2222") + t.Run("a live key that is not the legacy pin copies nothing", func(t *testing.T) { + p := newPins(t) + if err := p.Add("nself-a.test-2222", keyA); err != nil { + t.Fatal(err) + } + if err := p.MigrateLegacyPin(spec, []HostKey{liveKey(t, keyB)}); err != nil { + t.Fatal(err) + } + if got := lookup(t, p, PinAlias(spec)); len(got) != 0 { + t.Fatalf("an unpresented legacy key was migrated: %v", got) + } + }) + t.Run("a different key already pinned at the new alias is not replaced", func(t *testing.T) { + p := newPins(t) + if err := p.Add("nself-a.test-2222", keyA); err != nil { + t.Fatal(err) + } + if err := p.Add(PinAlias(spec), keyB); err != nil { + t.Fatal(err) + } + if err := p.MigrateLegacyPin(spec, []HostKey{liveKey(t, keyA)}); err != nil { + t.Fatal(err) + } + if got := lookup(t, p, PinAlias(spec)); len(got) != 1 || got[0] != keyB { + t.Fatalf("new alias holds %v, want only %s", got, keyB) + } + }) + t.Run("no legacy alias is a no-op that creates no file", func(t *testing.T) { + p := newPins(t) + if err := p.MigrateLegacyPin(mustSpec(t, "a.test"), []HostKey{liveKey(t, keyA)}); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(p.Path); !os.IsNotExist(err) { + t.Fatalf("pin file created: %v", err) + } + }) + t.Run("a refused pin file is an error", func(t *testing.T) { + p := newPins(t) + writePin(t, p, "*.test "+keyA+"\n") + if err := p.MigrateLegacyPin(spec, []HostKey{liveKey(t, keyA)}); err == nil { + t.Fatal("a foreign wildcard line did not fail closed") + } + }) +} + +func TestEffectivePinAlias(t *testing.T) { + spec := mustSpec(t, "a.test:22") + p := newPins(t) + if got, err := p.EffectivePinAlias(spec); err != nil || got != "a.test" { + t.Fatalf("empty file: %q, %v", got, err) + } + if err := p.Add("nself-a.test-22", keyA); err != nil { + t.Fatal(err) + } + if got, err := p.EffectivePinAlias(spec); err != nil || got != "nself-a.test-22" { + t.Fatalf("only the legacy alias pinned: %q, %v", got, err) + } + if err := p.Add("a.test", keyA); err != nil { + t.Fatal(err) + } + if got, err := p.EffectivePinAlias(spec); err != nil || got != "a.test" { + t.Fatalf("the current alias wins: %q, %v", got, err) + } + if got, err := p.EffectivePinAlias(mustSpec(t, "b.test")); err != nil || got != "b.test" { + t.Fatalf("no legacy form: %q, %v", got, err) + } + bad := newPins(t) + writePin(t, bad, "*.test "+keyA+"\n") + if _, err := bad.EffectivePinAlias(spec); err == nil { + t.Fatal("a refused pin file did not fail closed") + } +} + +// TestFreshPinIsNotAnotherEndpointsLegacyPin is the cross-endpoint collision: +// a pin written now for the host named "nself-a.test-2222" must not +// authenticate the separate endpoint a.test:2222 through the legacy lookup. +func TestFreshPinIsNotAnotherEndpointsLegacyPin(t *testing.T) { + named := mustSpec(t, "nself-a.test-2222") + other := mustSpec(t, "a.test:2222") + p := newPins(t) + if err := p.Add(PinAlias(named), keyA); err != nil { + t.Fatal(err) + } + if err := p.MigrateLegacyPin(other, []HostKey{liveKey(t, keyA)}); err != nil { + t.Fatal(err) + } + if got := lookup(t, p, PinAlias(other)); len(got) != 0 { + t.Fatalf("a.test:2222 inherited the pin of nself-a.test-2222: %v", got) + } + if got, err := p.EffectivePinAlias(other); err != nil || got != PinAlias(other) { + t.Fatalf("access fallback read the other endpoint's pin: %q, %v", got, err) + } +} + +// TestPinAliasStaysOutOfTheLegacyNamespace: no current alias is a legacy one. +func TestPinAliasStaysOutOfTheLegacyNamespace(t *testing.T) { + for _, in := range []string{"nself-a.test-2222", "NSELF-a.test-2222:22", "nself-x", "a.test", "a.test:2222", "203.0.113.9"} { + alias := PinAlias(mustSpec(t, in)) + for _, other := range []string{"a.test:22", "a.test:2222", "203.0.113.9", "[2001:db8::1]:2222"} { + for _, legacy := range LegacyPinAliases(mustSpec(t, other)) { + if alias == legacy { + t.Errorf("PinAlias(%q) = %q is the legacy alias of %q", in, alias, other) + } + } + } + } + if got := PinAlias(mustSpec(t, "Nself-A.test-2222")); got != "nself+nself-a.test-2222+22" { + t.Errorf("PinAlias = %q, want the synthetic form", got) + } +} diff --git a/sdk/go/remote/scp_argv_test.go b/sdk/go/remote/scp_argv_test.go index da832bd2e..e62407a2f 100644 --- a/sdk/go/remote/scp_argv_test.go +++ b/sdk/go/remote/scp_argv_test.go @@ -55,11 +55,17 @@ func simulateScp(args []string) (end int, operands []string, eaten []int, unknow // ssh-only flag is present and the D4 block comes first. func checkScpArgv(t testing.TB, c captured, d4 []string, src, dst string) { t.Helper() - // SCP chooses its own session type; the rest of the D4 block stays first. - d4 = append([]string(nil), d4...) - if i := indexOf(d4, "SessionType=default"); i >= 1 { - d4 = append(d4[:i-1], d4[i+1:]...) + // SCP chooses its own session type, so StdinNull, SessionType and + // ForkAfterAuthentication are dropped; the rest of the D4 block stays first. + kept := make([]string, 0, len(d4)) + for i := 0; i < len(d4); i++ { + if d4[i] == "-o" && i+1 < len(d4) && isD4ScpDrop(d4[i+1]) { + i++ + continue + } + kept = append(kept, d4[i]) } + d4 = kept n := len(c.args) if n < len(d4)+3 || c.args[n-3] != "--" || c.args[n-2] != src || c.args[n-1] != dst { t.Fatalf("scp argv %q: tail is not -- src dst", c.args) diff --git a/sdk/go/remote/testdata/mutation-allow.txt b/sdk/go/remote/testdata/mutation-allow.txt index 0fa972784..59ab97334 100644 --- a/sdk/go/remote/testdata/mutation-allow.txt +++ b/sdk/go/remote/testdata/mutation-allow.txt @@ -1,4 +1,4 @@ -# Equivalent mutants for: bash scripts/mutation.sh sdk/go/remote --only copy.go,doc.go,exec.go,hostspec.go,knownhosts.go,knownhosts_check.go,knownhosts_compat.go,lock_unix.go,options.go,pin_option.go,resolve.go,rsync_flags.go,run.go,scan.go,ssh_allow.go,validate.go --allow remote/testdata/mutation-allow.txt +# Equivalent mutants for: bash scripts/mutation.sh sdk/go/remote --only copy.go,doc.go,exec.go,hostspec.go,knownhosts.go,knownhosts_check.go,knownhosts_compat.go,lock_unix.go,options.go,pin_option.go,pinalias.go,resolve.go,rsync_flags.go,run.go,scan.go,ssh_allow.go,validate.go --allow remote/testdata/mutation-allow.txt # The gate covers every non-test .go file except lock_other.go: it carries a !unix build tag (no-op stubs) and is not compiled on the linux CI host. doc.go holds no code; it is listed only so the set is exhaustive. # Format: MUTATOR at file.go:line:col | reason. Line numbers are those of the current source; a moved line needs its entry moved (D-0253). ARITHMETIC_BASE at knownhosts_check.go:30:22 | unmeasurable, not equivalent: package var initialiser: Go emits no coverage block for it, so gremlins can only report NOT COVERED; TestPinLockWaitDefaultIsThirtySeconds pins the value diff --git a/vendor/github.com/nself-org/cli/sdk/go/v2/remote/copy.go b/vendor/github.com/nself-org/cli/sdk/go/v2/remote/copy.go index 0947db35b..1839e5a4e 100644 --- a/vendor/github.com/nself-org/cli/sdk/go/v2/remote/copy.go +++ b/vendor/github.com/nself-org/cli/sdk/go/v2/remote/copy.go @@ -54,8 +54,9 @@ func CopyTo(ctx context.Context, t Target, local, remote string) error { // option parsing. -4, -6, -q, -v, -i X, -o V and -oV mean the same in scp. Any // other element is refused, so scp only ever sees an option scp defines, with // its value attached or directly after it. SessionType=default is dropped: -// scp selects its own session type (SFTP uses ssh -s), and an explicit -// SessionType conflicts with that selection before ssh reads its config. +// scp selects its own session type (SFTP uses ssh -s), and StdinNull, +// SessionType and ForkAfterAuthentication from the D4 8.7 set all conflict +// with that internal -s on newer OpenSSH clients. func scpOptions(opts []string) ([]string, error) { out := make([]string, 0, len(opts)) for i := 0; i < len(opts); i++ { @@ -74,12 +75,13 @@ func scpOptions(opts []string) ([]string, error) { if a == "-p" { a = "-P" } - if a == "-o" && opts[i] == "SessionType=default" { + if a == "-o" && isD4ScpDrop(opts[i]) { continue } out = append(out, a, opts[i]) } else if strings.HasPrefix(a, "-o") { - if a != "-oSessionType=default" { + v := strings.TrimPrefix(a, "-o") + if !isD4ScpDrop(v) { out = append(out, a) } } else { @@ -89,6 +91,14 @@ func scpOptions(opts []string) ([]string, error) { return out, nil } +// isD4ScpDrop reports whether kv is one of the D4 8.7 options that must not +// reach scp: StdinNull, SessionType and ForkAfterAuthentication all conflict +// with scp's internal -s (SFTP subsystem) on OpenSSH 9.x and later. +func isD4ScpDrop(kv string) bool { + k, _, _ := strings.Cut(kv, "=") + return k == "StdinNull" || k == "SessionType" || k == "ForkAfterAuthentication" +} + // checkLocalOperand refuses a local path scp or rsync would read as a remote // "host:path" (a colon before the first slash), that starts with '-', or that // holds a newline/NUL. diff --git a/vendor/github.com/nself-org/cli/sdk/go/v2/remote/pinalias.go b/vendor/github.com/nself-org/cli/sdk/go/v2/remote/pinalias.go new file mode 100644 index 000000000..8b8d31161 --- /dev/null +++ b/vendor/github.com/nself-org/cli/sdk/go/v2/remote/pinalias.go @@ -0,0 +1,136 @@ +// Purpose: the one pin-alias scheme for nSelf's own host-key pin file, shared +// by deploy, admin connect and access. +// Inputs: a HostSpec (host, optional port). +// Outputs: the alias under which that endpoint's keys are pinned and passed to +// ssh as HostKeyAlias, the aliases older releases used for it, and a +// one-time copy of a legacy pin to the new alias. +// Constraints: the alias is a function of (lowercased host, effective port) +// alone and is injective: a name host never contains '+' or ':', +// so "nself++" cannot be produced by any host name +// and two endpoints never share an alias. Port 22 and no port are +// the same endpoint (ssh's default), one rule. A name host on +// port 22 keeps its bare name (the operator's known_hosts form); +// an IP literal always gets the synthetic form because the pin +// file refuses an IP as an alias. A name starting "nself-" also +// gets the synthetic form: that bare prefix is the legacy alias +// namespace, so a pin written by this scheme is never read as +// another endpoint's legacy pin. Legacy aliases are only read: +// nothing is deleted from the pin file. + +package remote + +import ( + "fmt" + "strconv" + "strings" +) + +// defaultSSHPort is the port ssh uses when none is given. +const defaultSSHPort = 22 + +// pinAliasPrefix starts every synthetic alias. '+' is outside what a host name +// can hold, so no host name collides with it. +const pinAliasPrefix = "nself+" + +// legacyAliasPrefix starts every legacy alias. PinAlias never returns a bare +// name with this prefix, so a current pin cannot be mistaken for a legacy one. +const legacyAliasPrefix = "nself-" + +func effectivePort(spec HostSpec) int { + if spec.Port == 0 { + return defaultSSHPort + } + return spec.Port +} + +// PinAlias returns the alias spec's host keys are pinned under. +func PinAlias(spec HostSpec) string { + host := strings.ToLower(spec.Host) + port := effectivePort(spec) + if port == defaultSSHPort && !isIPLiteral(spec.Host) && !strings.HasPrefix(host, legacyAliasPrefix) { + return host + } + return pinAliasPrefix + host + "+" + strconv.Itoa(port) +} + +// LegacyPinAliases returns the aliases releases before PinAlias pinned spec +// under, other than PinAlias(spec) itself. They were +// "nself--" for any host with a port and for any IP +// literal (port 22 when none was given), which collided across endpoints and +// made deploy's "host:22" pin invisible to admin. +func LegacyPinAliases(spec HostSpec) []string { + if spec.Port == 0 && !isIPLiteral(spec.Host) { + return nil // the bare name, which is also the current alias + } + return []string{legacyAliasPrefix + strings.ReplaceAll(strings.ToLower(spec.Host), ":", "-") + "-" + strconv.Itoa(effectivePort(spec))} +} + +// keyBlob is the " " form of a key line. +func keyBlob(line string) (string, error) { + typ, b64, err := parseKeyLine(line) + if err != nil { + return "", err + } + return typ + " " + b64, nil +} + +// MigrateLegacyPin copies the keys pinned for spec under a legacy alias to +// PinAlias(spec), but only those the endpoint presents now (live): a legacy +// pin that names a different key is left alone and the caller's normal check +// refuses it. A key already pinned, or a different key of a type already +// pinned at the new alias, copies nothing. The legacy lines stay in the file. +func (p PinnedHostKeys) MigrateLegacyPin(spec HostSpec, live []HostKey) error { + legacy := LegacyPinAliases(spec) + if len(legacy) == 0 { + return nil + } + alias := PinAlias(spec) + current, err := p.Lookup(alias) + if err != nil { + return err + } + pinnedTypes := map[string]bool{} + for _, k := range current { + pinnedTypes[strings.Fields(k)[0]] = true + } + for _, old := range legacy { + keys, err := p.Lookup(old) + if err != nil { + return err + } + for _, k := range keys { + for _, l := range live { + blob, err := keyBlob(l.Line) + if err != nil || blob != k || pinnedTypes[l.Type] { + continue + } + if err := p.Add(alias, l.Line); err != nil { + return fmt.Errorf("migrating the pin for %s: %w", alias, err) + } + pinnedTypes[l.Type] = true + } + } + } + return nil +} + +// EffectivePinAlias is PinAlias(spec), or the legacy alias when only that one +// holds pins: the read-only form for a caller that has no live key to migrate +// with. Nothing is written. +func (p PinnedHostKeys) EffectivePinAlias(spec HostSpec) (string, error) { + alias := PinAlias(spec) + keys, err := p.Lookup(alias) + if err != nil || len(keys) > 0 { + return alias, err + } + for _, old := range LegacyPinAliases(spec) { + keys, err := p.Lookup(old) + if err != nil { + return alias, err + } + if len(keys) > 0 { + return old, nil + } + } + return alias, nil +}