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
43 changes: 31 additions & 12 deletions .github/wiki/cmd-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,13 @@ the working-tree inventory and the inventory committed on the default branch of
committed copy cannot be read and the ci plugin is installed, the promotion
refuses with `E499`. Git runs without `GIT_DIR`/`GIT_CONFIG_*` and replacement
objects; anyone who can edit the project's `.git/config` can already run code as
the operator, so by default the project's own `origin` is trusted. To pin it,
put the expected URL in `~/.nself/ci/trust/origin.url` (first non-comment line):
a different effective origin (local config or `insteadOf`) then refuses with
the operator, so until the origin is pinned the project's own `origin` is trusted.
The first release that verifies pins it (trust on first use): `promote` writes the
effective origin URL to `~/.nself/ci/trust/origin.url` (mode 600, never replacing an
existing file) and prints the pinned URL. A URL that carries a token or password is
not written; a warning says so. To pin another URL, or before the first release, put
it in that file yourself (first non-comment line); delete the file to unpin. Once
pinned, a different effective origin (local config or `insteadOf`) then refuses with
`E493` before any trust file is read, and so does a project whose `origin` was
removed. A project with no `origin`, or with no committed
`.nself/control-plane.yaml`, has no committed policy to consult and promotes
Expand Down Expand Up @@ -443,17 +447,24 @@ SSH key defaults to `~/.ssh/id_ed25519`. Override with `NSELF_DEPLOY_SSH_KEY`.

Agent forwarding is always disabled (`-o ForwardAgent=no`). ssh, scp and rsync get their options as
separate arguments from one builder; no option, key path or remote path is ever parsed by a shell.
Before any process starts, the CLI refuses a key path outside `[a-zA-Z0-9/_.~-]` or starting with
`-`, a remote path with shell metacharacters, a leading `-` or a `..` segment, and any ssh option
holding whitespace or a quote (rsync re-splits its `-e` string on spaces).
Before a connection starts, the CLI refuses a key path outside `[a-zA-Z0-9/_.~-]` or starting with
`-`; absolute Windows drive paths such as `C:/Users/Jane/.ssh/id_ed25519` and backslash separators
are also accepted. Key paths must have no spaces. Remote paths cannot contain shell metacharacters,
a leading `-` or a `..` segment. SSH option tokens refuse whitespace and quotes except for the
canonical quoted pin-file option, which supports home directories containing spaces.

On Windows, rsync requires the matching MSYS/Cygwin `cygpath` on `PATH`. The CLI uses `cygpath -u`
to map local sources and file lists to that runtime's mount paths, preserving directory trailing
slashes. A missing or failing converter stops the copy with an error. Native scp retains Windows paths.

**Host keys.** Which policy a deploy uses depends on what it sends:

| Deploy path | v1.4 (default) | v1.5 (`NSELF_V15=1`) |
|---|---|---|
| Secrets push (`.env.<target>` copied with scp) | strict | strict |
| Deploy that ships an env file (inventory deploy, `nself deploy <target>` push) | strict, every connection of the run | strict |
| Compose file only | `StrictHostKeyChecking=accept-new` | strict |
| Compose file only, non-production | `StrictHostKeyChecking=accept-new` | `accept-new` (P7-DEPL-29) |
| Compose file only, production through control-plane | `StrictHostKeyChecking=accept-new` | strict |
| Read-only commands (probes outside a shipping run, `logs`, `health`) | `StrictHostKeyChecking=accept-new` | `accept-new` with nself's own key file (below) |

Strict means the CLI scans the host's live key and continues only when that key is already trusted,
Expand Down Expand Up @@ -496,9 +507,13 @@ 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 on a non-default port use
`nself-<host>-<port>`. A pin file holding a line the CLI did not write (a wildcard,
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,
`@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 Expand Up @@ -674,8 +689,12 @@ file. v1.4 mode keeps the push described above, byte for byte. One flow runs per
`--env-file` list, and the sync only creates it (empty, mode 600) when the host has none. The
host keeps `.nself/deploy-shipped.txt`, the list of what nSelf shipped. Before the sync the host
removes only files that list names, the new set drops, and that carry the `generated by nself`
marker. A marker-carrying conf nSelf never shipped here (another project's), an unmarked file or
anything under `conf.d*/` stays. The generated `nginx/conf.d/<name>.conf` files (today
marker. A plugin fragment under `.nself/plugins/<slug>/` is a copy of your own file and has no
marker: one that the previous deploy shipped (your local record and the host list both name it)
and the new set drops is removed without the marker, behind the same symlink and realpath guard.
A marker-carrying conf nSelf never shipped here (another project's), an unmarked file outside
`.nself/plugins/<slug>/` or anything under `conf.d*/` stays. The first v1.5 deploy to a v1.4 host
has no shipped list and no record yet, so it prunes nothing. The generated `nginx/conf.d/<name>.conf` files (today
`default.conf`) ship, and only that one directory: never `conf.d-<env>/`, never a nested path, and
a conf.d file is never pruned. The host decides, from the file alone: a file at that path whose
first 5 lines have none starting exactly with `# Generated by nself build` (case-sensitive; a
Expand Down
4 changes: 2 additions & 2 deletions cmd/commands/deploy_apply_v15.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@ func remoteDeployV15(ctx context.Context, workdir, host, cascade string, jsonOut
ctx, stop := deploy.WithInterrupt(ctx)
defer stop()
key := sshKeyPath()
if key != "" && !sshKeyRe.MatchString(key) {
return fmt.Errorf("NSELF_DEPLOY_SSH_KEY contains unsafe characters (got %q): only [a-zA-Z0-9/_.~-] allowed", key)
if err := deploy.CheckKeyPath(key); err != nil {
return err
}
spec, err := remote.ParseHostSpec(host)
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions cmd/commands/deploy_remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ func remoteDeployPush(ctx context.Context, workdir, host, target string, jsonOut
// sshKey goes into the rsync ssh command string, which rsync passes
// through a shell. Reject any key path containing shell metacharacters to
// prevent command injection via NSELF_DEPLOY_SSH_KEY.
if sshKey != "" && !sshKeyRe.MatchString(sshKey) {
return fmt.Errorf("NSELF_DEPLOY_SSH_KEY contains unsafe characters (got %q): only [a-zA-Z0-9/_.~-] allowed", sshKey)
if err := deploy.CheckKeyPath(sshKey); err != nil {
return err
}

spec, err := remote.ParseHostSpec(host)
Expand Down
3 changes: 2 additions & 1 deletion cmd/commands/deploy_scratch.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package commands
import (
"context"
"fmt"
"github.com/nself-org/cli/internal/deploy/pathmap"
"io"
"os"
"path/filepath"
Expand Down Expand Up @@ -166,7 +167,7 @@ func stageScratch(p v15Params, scratch string) (deploy.ArtifactSet, error) {
return deploy.ArtifactSet{}, err
}
in := deploy.StageInput{
Map: deploy.PathMap{ScratchRoot: scratch, OperatorRoot: p.workdir, PluginDir: build.DefaultPluginDir(), Home: deploy.DefaultHome(), RemotePath: p.srv.RemotePath},
Map: pathmap.PathMap{ScratchRoot: scratch, OperatorRoot: p.workdir, PluginDir: build.DefaultPluginDir(), Home: pathmap.DefaultHome(), RemotePath: p.srv.RemotePath},
StageDir: stage, Compose: files, EnvFiles: envs, EnvSnapshot: envSnap, EnvName: cascade, Nginx: policy,
}
if frontedBy != "" {
Expand Down
3 changes: 2 additions & 1 deletion cmd/commands/deploy_scratch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"github.com/nself-org/cli/internal/deploy/pathmap"
"io/fs"
"os"
"path/filepath"
Expand Down Expand Up @@ -155,7 +156,7 @@ func TestDeployScratchUntouched(t *testing.T) {
t.Fatal(err)
}
set, err := deploy.Stage(deploy.StageInput{
Map: deploy.PathMap{ScratchRoot: scratch, OperatorRoot: proj, PluginDir: filepath.Join(t.TempDir(), "plugins"), Home: filepath.Join(t.TempDir(), "home"), RemotePath: "/opt/nself"},
Map: pathmap.PathMap{ScratchRoot: scratch, OperatorRoot: proj, PluginDir: filepath.Join(t.TempDir(), "plugins"), Home: filepath.Join(t.TempDir(), "home"), RemotePath: "/opt/nself"},
StageDir: stage, Compose: []string{filepath.Join(scratch, "docker-compose.yml")}, EnvFiles: []string{filepath.Join(scratch, ".env")}, EnvName: "prod", Nginx: policy,
})
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions cmd/commands/release_manifest_flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ func wirePromoteRelease(cmd *cobra.Command, workdir string, eng *cpromote.Engine
Git: verify.GitCLI{Root: workdir},
Evidence: verify.ExecEvidence(binary, resolvePluginDir(), workdir),
Warn: func(msg string) { _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "warning:", msg) },
// D-1216: trust on first use for the trust origin.
PinOnFirstUse: true,
Notice: func(msg string) { _, _ = fmt.Fprintln(cmd.ErrOrStderr(), msg) },
}
eng.Verify = func(ctx context.Context, s *cpromote.State) error {
m, err := verify.Verify(ctx, in)
Expand Down
4 changes: 2 additions & 2 deletions internal/access/transport_ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ 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"
"path"
"path/filepath"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -92,7 +92,7 @@ func (t *SSHTransport) sshArgsContext(ctx context.Context) ([]string, error) {
}
args = append(args, "-o", "StrictHostKeyChecking=yes", "-o", option)
if spec.Port != 0 {
alias := "nself-" + strings.ReplaceAll(spec.Host, ":", "-") + "-" + strconv.Itoa(spec.Port)
alias := hostpin.PinAlias(ctx, spec.Host, spec.Port, remote.Fingerprint)
args = append(args, "-o", "HostKeyAlias="+alias)
}
} else {
Expand Down
2 changes: 1 addition & 1 deletion internal/access/transport_ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestSSHTransportHostSpecPolicy(t *testing.T) {
name, v15, host, want, reject string
}{
{"legacy", "0", "u@example.test:2222", "StrictHostKeyChecking=accept-new", "HostKeyAlias="},
{"strict-port", "1", "u@example.test:2222", "HostKeyAlias=nself-example.test-2222", "StrictHostKeyChecking=accept-new"},
{"strict-port", "1", "u@example.test:2222", "HostKeyAlias=nself+example.test+2222", "StrictHostKeyChecking=accept-new"},
{"strict-default", "1", "u@example.test", "StrictHostKeyChecking=yes", "HostKeyAlias="},
} {
t.Run(tc.name, func(t *testing.T) {
Expand Down
8 changes: 5 additions & 3 deletions internal/admin/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
Expand All @@ -18,6 +19,7 @@ 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 @@ -166,8 +168,8 @@ func adminHostKeyOptions(ctx context.Context, spec remote.HostSpec) ([]string, e
operatorAlias = resolved.HostKeyAlias
}
pinAlias := spec.Host
if spec.Port != 0 && spec.Port != 22 {
pinAlias = "nself-" + strings.ReplaceAll(spec.Host, ":", "-") + "-" + strconv.Itoa(spec.Port)
if (spec.Port != 0 && spec.Port != 22) || net.ParseIP(spec.Host) != nil {
pinAlias = hostpin.PinAlias(ctx, spec.Host, spec.Port, remote.Fingerprint)
}
operator := adminTrustedFingerprints(ctx, operatorAlias, filepath.Join(home, ".ssh", "known_hosts"))
pinned := adminTrustedFingerprints(ctx, pinAlias, pinFile)
Expand All @@ -179,7 +181,7 @@ func adminHostKeyOptions(ctx context.Context, spec remote.HostSpec) ([]string, e
"-o", "ControlMaster=no", "-o", "ControlPath=none",
"-o", pinOption,
}
if spec.Port != 0 && spec.Port != 22 && !operator[key.Fingerprint] {
if !operator[key.Fingerprint] && (spec.Port != 0 && spec.Port != 22 || net.ParseIP(spec.Host) != nil) {
opts = append(opts, "-o", "HostKeyAlias="+pinAlias)
}
return opts, nil
Expand Down
56 changes: 56 additions & 0 deletions internal/admin/connect_pin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,59 @@ 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.
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)
}
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.
func TestAdminSSHV15Non22PinAliasFallback(t *testing.T) {
cases := []struct{ name, alias string }{
{"new-alias", "nself+host.test+2222"},
{"legacy-alias", "nself-host.test-2222"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
compattest.Set(t, true)
e := newArgvEnv(t, "", &hostKey{alias: tc.alias, pin: true})
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 !found {
t.Fatalf("missing HostKeyAlias for %s: %q", tc.name, got)
}
})
}
}
91 changes: 91 additions & 0 deletions internal/controlplane/promote/verify/trust_pin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package verify

// Purpose: trust on first use for the trust origin (D-1216). After the first
// release that verifies, the project's effective origin URL is written
// to ~/.nself/ci/trust/origin.url, so every later verification, and the
// committed-inventory lookup, holds to that origin.
// Inputs: the Git view, the operator home.
// Outputs: the pinned URL, or why nothing was pinned.
// Constraints: never replaces an existing pin file (hard link from a temp file,
// which fails when the target exists). A URL that carries a
// password or token is not written: the file is plain text and the
// URL is printed. Only the operator home is written.

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)

// PinOriginOnFirstUse pins the project's origin when the operator has no pin.
// pinned is false, with a nil error, when there is nothing to do (a pin exists,
// or the project has no origin). A non-nil error means the origin was left
// unpinned and says why; it never means the release failed to verify.
func PinOriginOnFirstUse(ctx context.Context, g Git, home string) (url string, pinned bool, err error) {
if g == nil || !filepath.IsAbs(home) {
return "", false, nil
}
if _, has, err := loadOriginPin(home); has || err != nil {
return "", false, err
}
ok, err := g.HasOrigin(ctx)
if err != nil || !ok {
return "", false, err
}
got, err := g.OriginURL(ctx)
if err != nil {
return "", false, fmt.Errorf("cannot read the origin URL to pin it: %w", err)
}
got = strings.TrimSpace(got)
if !pinnableURL(got) {
return "", false, errors.New("the origin URL is empty or carries credentials, so it was not written to " + originPinName + "; put the URL without credentials there to pin it")
}
dir := filepath.Join(home, ".nself", "ci", "trust")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", false, err
}
tmp, err := os.CreateTemp(dir, ".origin-*")
if err != nil {
return "", false, err
}
defer func() { _ = os.Remove(tmp.Name()) }()
_, werr := tmp.WriteString("# pinned by nself after the first verified release; edit or delete this file to change it\n" + got + "\n")
if cerr := tmp.Close(); werr == nil {
werr = cerr
}
if werr != nil {
return "", false, werr
}
if err := os.Link(tmp.Name(), filepath.Join(dir, originPinName)); err != nil {
if errors.Is(err, os.ErrExist) {
return "", false, nil // pinned by someone else meanwhile: theirs stands
}
return "", false, err
}
return got, true, nil
}

// pinnableURL reports whether u is a single-line URL with no userinfo secret.
// scp-like and ssh:// forms may name a user (git@host) but never a password.
func pinnableURL(u string) bool {
if u == "" || strings.ContainsFunc(u, func(r rune) bool { return r < ' ' || r == 0x7f || r == ' ' }) {
return false
}
authority := u
if _, rest, ok := strings.Cut(u, "://"); ok {
scheme, _, _ := strings.Cut(u, "://")
authority, _, _ = strings.Cut(rest, "/")
if i := strings.LastIndex(authority, "@"); i >= 0 && scheme != "ssh" {
return false // https://token@host: the userinfo is a credential
}
} else {
authority, _, _ = strings.Cut(u, ":")
}
if i := strings.LastIndex(authority, "@"); i >= 0 && strings.Contains(authority[:i], ":") {
return false
}
return true
}
Loading
Loading