Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/wiki/Nginx-Generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
15 changes: 8 additions & 7 deletions .github/wiki/cmd-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+<ip>+<port>` (for example
`nself+203.0.113.10+22`) and ssh gets a matching `HostKeyAlias`; hosts with an explicit port use
`nself+<host>+<port>`, preserving IPv6 colons. These aliases cannot equal a real hostname.
Existing `nself-<host>-<port>` 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+<host>+<port>`
(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+<host>+<port>` 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-<host>-<port>` 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.

Expand Down
10 changes: 7 additions & 3 deletions internal/access/transport_ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions internal/admin/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/admin/connect_multiplex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
105 changes: 68 additions & 37 deletions internal/admin/connect_pin_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package admin

import (
"os"
"path/filepath"
"reflect"
"runtime"
Expand All @@ -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-<host>-<port>
// 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+<host>+<port>
// 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) {
Expand Down Expand Up @@ -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-<ip>-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+<ip>+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-<ip>-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"},
Expand All @@ -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)
}
})
}
Expand Down
10 changes: 5 additions & 5 deletions internal/build/proxy_host_owner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -16,14 +15,15 @@ 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.
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 || docker.RemapsUIDsOnce() {
return nil
}
return map[string]string{
Expand Down
41 changes: 41 additions & 0 deletions internal/build/proxy_host_owner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/build/proxy_prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions internal/deploy/argv_golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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",
},
Expand Down
Loading
Loading