From 2915d97898b13ab1e07b036f58e40a75314711ce Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 28 Oct 2026 17:14:05 -0400 Subject: [PATCH 1/5] fix(build): keep the traefik 0:0 default on rootless and userns-remapped Docker (D-0771) --- .github/wiki/Nginx-Generation.md | 2 +- internal/build/proxy_host_owner.go | 10 ++++-- internal/build/proxy_host_owner_test.go | 39 ++++++++++++++++++++++ internal/build/proxy_prepare.go | 2 +- internal/docker/security_options.go | 41 ++++++++++++++++++++++++ internal/docker/security_options_test.go | 22 +++++++++++++ 6 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 internal/docker/security_options.go create mode 100644 internal/docker/security_options_test.go 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/internal/build/proxy_host_owner.go b/internal/build/proxy_host_owner.go index fd87d8e81..02928dca6 100644 --- a/internal/build/proxy_host_owner.go +++ b/internal/build/proxy_host_owner.go @@ -8,22 +8,28 @@ package build // 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 -// issues the keys, so its own ids are their owner. +// issues the keys, so its own ids are their owner. A rootless or userns-remapped +// daemon maps a container uid to a sub-uid that cannot read them: 0:0 stays (D-0771). import ( "os" "runtime" "strconv" + "sync" "github.com/nself-org/cli/internal/config" + "github.com/nself-org/cli/internal/docker" ) +// daemonRemapsUIDs asks the daemon once per process. Tests replace it. +var daemonRemapsUIDs = sync.OnceValue(docker.RemapsUIDs) + // proxyHostOwner returns the ids Traefik runs as, or nil when nothing is exported. func proxyHostOwner(cfg *config.Config, workdir, pluginDir string) map[string]string { 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 || daemonRemapsUIDs() { 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..6e305cb01 100644 --- a/internal/build/proxy_host_owner_test.go +++ b/internal/build/proxy_host_owner_test.go @@ -9,6 +9,44 @@ import ( "testing" ) +// stubDaemonRemapsUIDs makes the daemon report remapped (or not) for one test. +func stubDaemonRemapsUIDs(t *testing.T, remapped bool) { + t.Helper() + prev := daemonRemapsUIDs + daemonRemapsUIDs = func() bool { return remapped } + t.Cleanup(func() { daemonRemapsUIDs = 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 +58,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/docker/security_options.go b/internal/docker/security_options.go new file mode 100644 index 000000000..cd2d56471 --- /dev/null +++ b/internal/docker/security_options.go @@ -0,0 +1,41 @@ +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" + "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) +} + +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) + } + } +} From a1dadd8ff9c0d5c890653c974a60ef1aa91f73b6 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 28 Oct 2026 17:14:05 -0400 Subject: [PATCH 2/5] fix(ssh): one injective host-key pin alias shared by deploy, admin and access (D-0463) --- .github/wiki/cmd-deploy.md | 15 +- internal/access/transport_ssh.go | 10 +- internal/admin/connect.go | 12 +- internal/admin/connect_multiplex_test.go | 2 +- internal/admin/connect_pin_test.go | 105 ++++++--- internal/deploy/argv_golden_test.go | 4 +- internal/deploy/pin_alias_test.go | 115 +++++++++ internal/deploy/secrets_test.go | 2 +- internal/deploy/ssh_alias_test.go | 65 ++++-- internal/deploy/ssh_hostkeys.go | 38 +-- internal/deploy/ssh_hostkeys_test.go | 11 +- internal/deploy/ssh_ships_test.go | 6 +- internal/hostpin/hostpin.go | 41 +--- internal/repoqa/testdata/layers.yaml | 2 +- sdk/go/CHANGELOG.md | 4 + sdk/go/remote/pinalias.go | 129 ++++++++++ sdk/go/remote/pinalias_test.go | 221 ++++++++++++++++++ sdk/go/remote/testdata/mutation-allow.txt | 2 +- .../cli/sdk/go/v2/remote/pinalias.go | 129 ++++++++++ 19 files changed, 766 insertions(+), 147 deletions(-) create mode 100644 internal/deploy/pin_alias_test.go create mode 100644 sdk/go/remote/pinalias.go create mode 100644 sdk/go/remote/pinalias_test.go create mode 100644 vendor/github.com/nself-org/cli/sdk/go/v2/remote/pinalias.go 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/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/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..e892497ea --- /dev/null +++ b/sdk/go/remote/pinalias.go @@ -0,0 +1,129 @@ +// 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. 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+" + +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) { + 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{"nself-" + 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..ff8abc688 --- /dev/null +++ b/sdk/go/remote/pinalias_test.go @@ -0,0 +1,221 @@ +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") + } +} 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/pinalias.go b/vendor/github.com/nself-org/cli/sdk/go/v2/remote/pinalias.go new file mode 100644 index 000000000..e892497ea --- /dev/null +++ b/vendor/github.com/nself-org/cli/sdk/go/v2/remote/pinalias.go @@ -0,0 +1,129 @@ +// 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. 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+" + +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) { + 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{"nself-" + 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 +} From 12b9c650eec39b33f3fc7bb2c586331813671362 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 09:07:18 -0400 Subject: [PATCH 3/5] fix(ssh): keep current pin aliases out of the legacy alias namespace (D-0463) A host named like a legacy alias (nself-a.test-2222) pinned under its bare name, and the legacy lookup then let the separate endpoint a.test:2222 inherit that pin. Names with the legacy prefix now use the synthetic form, so a fresh pin is never read as another endpoint's legacy pin. --- sdk/go/remote/pinalias.go | 13 +++++-- sdk/go/remote/pinalias_test.go | 38 +++++++++++++++++++ .../cli/sdk/go/v2/remote/pinalias.go | 13 +++++-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/sdk/go/remote/pinalias.go b/sdk/go/remote/pinalias.go index e892497ea..8b8d31161 100644 --- a/sdk/go/remote/pinalias.go +++ b/sdk/go/remote/pinalias.go @@ -11,7 +11,10 @@ // 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. Legacy aliases are only read: +// 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 @@ -29,6 +32,10 @@ const defaultSSHPort = 22 // 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 @@ -40,7 +47,7 @@ func effectivePort(spec HostSpec) int { func PinAlias(spec HostSpec) string { host := strings.ToLower(spec.Host) port := effectivePort(spec) - if port == defaultSSHPort && !isIPLiteral(spec.Host) { + if port == defaultSSHPort && !isIPLiteral(spec.Host) && !strings.HasPrefix(host, legacyAliasPrefix) { return host } return pinAliasPrefix + host + "+" + strconv.Itoa(port) @@ -55,7 +62,7 @@ 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{"nself-" + strings.ReplaceAll(strings.ToLower(spec.Host), ":", "-") + "-" + strconv.Itoa(effectivePort(spec))} + return []string{legacyAliasPrefix + strings.ReplaceAll(strings.ToLower(spec.Host), ":", "-") + "-" + strconv.Itoa(effectivePort(spec))} } // keyBlob is the " " form of a key line. diff --git a/sdk/go/remote/pinalias_test.go b/sdk/go/remote/pinalias_test.go index ff8abc688..77a126c52 100644 --- a/sdk/go/remote/pinalias_test.go +++ b/sdk/go/remote/pinalias_test.go @@ -219,3 +219,41 @@ func TestEffectivePinAlias(t *testing.T) { 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/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 index e892497ea..8b8d31161 100644 --- 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 @@ -11,7 +11,10 @@ // 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. Legacy aliases are only read: +// 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 @@ -29,6 +32,10 @@ const defaultSSHPort = 22 // 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 @@ -40,7 +47,7 @@ func effectivePort(spec HostSpec) int { func PinAlias(spec HostSpec) string { host := strings.ToLower(spec.Host) port := effectivePort(spec) - if port == defaultSSHPort && !isIPLiteral(spec.Host) { + if port == defaultSSHPort && !isIPLiteral(spec.Host) && !strings.HasPrefix(host, legacyAliasPrefix) { return host } return pinAliasPrefix + host + "+" + strconv.Itoa(port) @@ -55,7 +62,7 @@ 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{"nself-" + strings.ReplaceAll(strings.ToLower(spec.Host), ":", "-") + "-" + strconv.Itoa(effectivePort(spec))} + return []string{legacyAliasPrefix + strings.ReplaceAll(strings.ToLower(spec.Host), ":", "-") + "-" + strconv.Itoa(effectivePort(spec))} } // keyBlob is the " " form of a key line. From 70a8d26ec06bd9fe9be1d62b47aedcdc3dbeb63e Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 10:51:46 -0400 Subject: [PATCH 4/5] fix(build): keep internal/build under its package-size ceiling (D-0771) The once-per-process daemon query moves next to RemapsUIDs in internal/docker (RemapsUIDsOnce), so the build package no longer grows past its 13575 ceiling. --- internal/build/proxy_host_owner.go | 16 +++++----------- internal/build/proxy_host_owner_test.go | 8 +++++--- internal/docker/security_options.go | 4 ++++ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/build/proxy_host_owner.go b/internal/build/proxy_host_owner.go index 02928dca6..923f74d91 100644 --- a/internal/build/proxy_host_owner.go +++ b/internal/build/proxy_host_owner.go @@ -4,32 +4,26 @@ 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 -// issues the keys, so its own ids are their owner. A rootless or userns-remapped -// daemon maps a container uid to a sub-uid that cannot read them: 0:0 stays (D-0771). +// 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 ( "os" "runtime" "strconv" - "sync" "github.com/nself-org/cli/internal/config" "github.com/nself-org/cli/internal/docker" ) -// daemonRemapsUIDs asks the daemon once per process. Tests replace it. -var daemonRemapsUIDs = sync.OnceValue(docker.RemapsUIDs) - // proxyHostOwner returns the ids Traefik runs as, or nil when nothing is exported. func proxyHostOwner(cfg *config.Config, workdir, pluginDir string) map[string]string { if runtime.GOOS != "linux" { return nil } - if provider, err := ProxyProvider(cfg, workdir, pluginDir); err != nil || provider != ProviderTraefik || daemonRemapsUIDs() { + 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 6e305cb01..69cbbd17e 100644 --- a/internal/build/proxy_host_owner_test.go +++ b/internal/build/proxy_host_owner_test.go @@ -7,14 +7,16 @@ 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 := daemonRemapsUIDs - daemonRemapsUIDs = func() bool { return remapped } - t.Cleanup(func() { daemonRemapsUIDs = prev }) + prev := docker.RemapsUIDsOnce + docker.RemapsUIDsOnce = func() bool { return remapped } + t.Cleanup(func() { docker.RemapsUIDsOnce = prev }) } // TestProxyHostOwnerRemappedDaemon runs the whole build against a stubbed diff --git a/internal/docker/security_options.go b/internal/docker/security_options.go index cd2d56471..ac0e14843 100644 --- a/internal/docker/security_options.go +++ b/internal/docker/security_options.go @@ -11,6 +11,7 @@ import ( "encoding/json" "os/exec" "strings" + "sync" "time" ) @@ -29,6 +30,9 @@ func RemapsUIDs() bool { 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, ",") { From 716c03b1e4c45f776684cb7b5a88a24918f9e277 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 15:27:06 -0400 Subject: [PATCH 5/5] fix(remote): sync vendored copy.go and the scp argv check with the D4 scp drop set as SessionType, but left the vendored copy of copy.go and the checkScpArgv test helper on the old rule. main has failed go:vendor-sync and go:sdk-remote-mutation since. The helper now removes the same keys isD4ScpDrop removes, and vendor/ matches sdk/go again. --- sdk/go/remote/scp_argv_test.go | 14 ++++++++++---- .../nself-org/cli/sdk/go/v2/remote/copy.go | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) 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/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.