1 Commits

Author SHA1 Message Date
Privatae 081763a675 bastion 0.57.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m30s
ci / fuzz (push) Successful in 3m25s
2026-10-28 20:35:45 +00:00
26 changed files with 2118 additions and 37 deletions
+70
View File
@@ -23,6 +23,76 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once
that is settled. The newest entries may not be published yet; the manifest that is settled. The newest entries may not be published yet; the manifest
says which version is. Older entries are not marked. says which version is. Older entries are not marked.
## 0.57.0
- **Pairing with a Cloud Host is one pasted line.** A Host's "Set up a
bastion" card now gives a pairing code, `skp_…`, that carries the token,
the Host's enroll address and the SHA-256 of the Host's CA. `pair <code>`
(or the code on standard input after `pair -`) takes the CA from the enroll
port's own TLS chain, keeps it only under that fingerprint and only when the
port's certificate verifies under it for `host.cseries.internal`, then pairs
exactly as `--redeem-url` with `--host-ca` does. There is no certificate
file to save, so the step people skipped ("open host-ca.pem: The system
cannot find the file specified") is gone. `--host-ca` still works as before.
- **The installers pair and start in one go.** `install.sh` with
`PRIVATAE_PAIR_CODE='<code>'` in its environment (the card's line is
`curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh`)
and `install.ps1 -Pair '<code>'` install or update the bastion as before,
hand the code to `pair -` on standard input, then start the bastion, or restart it when it
was running, and end with one plain sentence and `status`. On Windows that
is the logon task (every running copy is stopped first, so the new one gets
its control port), or the service on a networked machine; on a machine whose
bastion is already the Windows service, `-Pair` pairs that service from an
Administrator PowerShell instead of installing a second bastion. On Linux as
yourself it is a systemd user unit, with lingering asked for and the outcome
said; on macOS a LaunchAgent; as root, the system service. Without the code
both installers behave as they did.
- **The code is in no process's arguments.** On macOS and Linux it comes
only from the environment: an argument would sit in the installer shell's
command line for the whole install, where any user of the machine can read
it with `ps`, and one at the same address could redeem it first. A
process's environment is readable only by its owner and root. `install.sh`
reads the variable before it starts any program and unsets it, so nothing
it starts inherits it. An empty or partial code is refused before the
download. `install.sh` now names no argument it refuses, so a code given in
the wrong place is never printed back. On Windows the code stays a
parameter of the script, inside the person's own PowerShell.
- **On Linux, as yourself, the copy that serves is the one the installer
started.** Before the user unit starts, every copy of your bastion running
outside it (left in a terminal, or from another path) is stopped, as
install.ps1 does on Windows. Otherwise the unit's copy only waits behind the
old one's run lock, and the old copy never reads the new pairing. The
installer then checks for a few seconds that the unit is running and keeps
the same process. A unit that keeps restarting is reported as not running,
with its log command, and the install fails.
- **A local bastion can serve a second Host, when you say so at the machine.**
A local bastion used to serve one Host and refuse any other, so a person
with two Hosts of their own (a Cloud machine beside a platform Host) could
not pair the second at all. `pair` now asks on this machine's terminal,
`/dev/tty` or the Windows console, never standard input, so a code piped
in by an installer still needs a person to answer: "This computer already
serves your Host … Add … as well? [y/N]". Only y or yes adds it; the first
pairing is untouched; with no terminal it is refused. Other callers
(`claim-anchor`, the pairing op) keep the refusal. On a local machine that
serves two Hosts, each changes only the servers it built and stops only the
coder runs it started. The name in the question is the token's, so anything
in it but a plain host name is shown quoted and escaped.
- **`pair` exits 3 when the Host is already paired here.** Nothing changes, and
the installers report it as already connected. Over TCP, with a pairing
code or `--host-ca`, a Host of the same name under another CA (Cloud names
repeat) is a plain refusal, not exit 3, before the redeem and after it. A
pairing code that names a field twice is refused.
- **An enroll port that does not answer says why within 10 seconds.** A Cloud
Host opens its pairing port only to the address the browser used when the
card was opened, for 15 minutes; from anywhere else the port does not
answer, and `pair` used to wait for the system's connect timeout. It now
stops after 10 seconds with that reason and what to do.
- **The local-mode refusal names this system's way to a networked bastion**
(`install.ps1 -Networked` from an Administrator PowerShell on Windows,
`install.sh --networked` as root elsewhere) and says that the Host already
paired would have to be paired again with it. It used to name the Linux
script on Windows.
## 0.56.0 ## 0.56.0
- **New: a Host with no platform can use the model servers on this - **New: a Host with no platform can use the model servers on this
+8 -2
View File
@@ -49,6 +49,11 @@ privatae-bastion run
A Windows server installs from an Administrator PowerShell with `-Networked`. A Windows server installs from an Administrator PowerShell with `-Networked`.
See [Install](#install). See [Install](#install).
A Cloud Host's **Set up a bastion** card gives one line that installs the
bastion, pairs it and starts it, so it runs now and after a restart:
`curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh`, or
on Windows `& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…'`.
**AI readers and reviewers:** [docs/README.md](docs/README.md) lists every reference page, one line each, and [docs/claims.md](docs/claims.md) is the full table of security claims and the test behind each. **AI readers and reviewers:** [docs/README.md](docs/README.md) lists every reference page, one line each, and [docs/claims.md](docs/claims.md) is the full table of security claims and the test behind each.
**Contents:** [Why a bastion](#why-a-bastion) · [Install](#install) · **Contents:** [Why a bastion](#why-a-bastion) · [Install](#install) ·
@@ -106,7 +111,8 @@ privatae-bastion stepup-enroll
``` ```
**On your own computer** (run the installer as yourself). The bastion runs **On your own computer** (run the installer as yourself). The bastion runs
as your user in **local** mode and serves one host. The pairing's as your user in **local** mode and serves the host you pair it with, and
another only when you answer yes on this computer. The pairing's
capabilities are the authorization, and approvals work once you enrol a capabilities are the authorization, and approvals work once you enrol a
second factor. second factor.
@@ -166,7 +172,7 @@ share on the host with your code.
| Runs as | your user is fine | the system's service: root under systemd or launchd, LocalSystem on Windows | | Runs as | your user is fine | the system's service: root under systemd or launchd, LocalSystem on Windows |
| A terminal the host opens | opens on the pairing's capabilities | waits for your approval, for that one session | | A terminal the host opens | opens on the pairing's capabilities | waits for your approval, for that one session |
| A write (an op, a ship step) | runs on the pairing's capabilities | waits for your approval, bound to that op and its arguments | | A write (an op, a ship step) | runs on the pairing's capabilities | waits for your approval, bound to that op and its arguments |
| Second pairing | refused | waits for the **anchor** host's approval | | Second pairing | asked on this computer's terminal, added only on a yes | waits for the **anchor** host's approval |
| The anchor | none | the first host paired, or `claim-anchor` | | The anchor | none | the first host paired, or `claim-anchor` |
In networked mode one host is the **anchor**: the one host that approves every In networked mode one host is the **anchor**: the one host that approves every
+5 -4
View File
@@ -574,9 +574,10 @@ var allCaps = []string{"dcn", "egress", "harness", "inference", "models", "obser
// writes a pairing PENDING when an anchor exists (pair.go), and state "" reads // writes a pairing PENDING when an anchor exists (pair.go), and state "" reads
// active (config.go), so B comes straight up with full lanes. // active (config.go), so B comes straight up with full lanes.
// //
// LOCAL is exempt and must stay so: a local bastion serves one host and cannot // LOCAL is exempt and must stay so: a local bastion serves the one host it was
// be anchored at all (cmdClaimAnchor refuses outside networked mode), so the // paired with, and a second only on a yes typed at the machine (pair_terminal.go),
// pairing IS the authorization there. Most bastions are local. // and it cannot be anchored at all (cmdClaimAnchor refuses outside networked
// mode), so the pairing IS the authorization there. Most bastions are local.
// //
// self-update is deliberately INSIDE the refusal. Directing a binary swap is the // self-update is deliberately INSIDE the refusal. Directing a binary swap is the
// most dangerous thing on the list, and an unanchored networked box cannot say // most dangerous thing on the list, and an unanchored networked box cannot say
@@ -1033,7 +1034,7 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
return err return err
} }
if boxMode() != modeNetworked { if boxMode() != modeNetworked {
return fmt.Errorf("claim-anchor needs networked mode — a local bastion serves one host and needs no anchor (%s, then `privatae-bastion set-mode networked`)", adminHowTo()) return fmt.Errorf("claim-anchor needs networked mode — a local bastion serves only the Hosts its person said yes to and needs no anchor (%s, then `privatae-bastion set-mode networked`)", adminHowTo())
} }
cfgs, err := loadPairings() cfgs, err := loadPairings()
if err != nil { if err != nil {
+7
View File
@@ -1229,10 +1229,17 @@ func coderContainerGone(rt, cname string) bool {
// anchor pairing on an anchored box (serviceOwnerRefusal's rule). // anchor pairing on an anchored box (serviceOwnerRefusal's rule).
// An unknown owner proves nothing about self, so it gets the anchor-only rule // An unknown owner proves nothing about self, so it gets the anchor-only rule
// too: every run this bastion starts records and labels its owner. // too: every run this bastion starts records and labels its owner.
//
// A local machine has no anchor, so anchorOnly lets every pairing through
// there; once it serves several Hosts (localServesSeveralHosts), each stops
// only the runs it started, as with the servers each built.
func coderOwnerRefusal(self, owner string) string { func coderOwnerRefusal(self, owner string) string {
if owner != "" && owner == self { if owner != "" && owner == self {
return "" return ""
} }
if localServesSeveralHosts() {
return "refused: that run was started by " + orUnknown(owner) + "; on a local machine that serves several Hosts each stops only the runs it started"
}
if why := anchorOnly(self, "stopping a run another host started"); why != "" { if why := anchorOnly(self, "stopping a run another host started"); why != "" {
return "refused: that run was started by " + orUnknown(owner) + "; " + why return "refused: that run was started by " + orUnknown(owner) + "; " + why
} }
+1 -1
View File
@@ -615,7 +615,7 @@ func removeStalePairingFile(hostName string) error {
func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, error) { func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, error) {
existing := pairingFor(cfgs, handle) existing := pairingFor(cfgs, handle)
if existing != nil && !replace { if existing != nil && !replace {
return nil, fmt.Errorf("already paired with %s — this daemon serves multiple hosts, so pairing never overwrites; to rotate that host's credentials run `privatae-bastion pair --replace <token>`", existing.HostName) return nil, &alreadyPairedError{host: existing.HostName, msg: fmt.Sprintf("already paired with %s — this daemon serves multiple hosts, so pairing never overwrites; to rotate that host's credentials run `privatae-bastion pair --replace <token>`", existing.HostName)}
} }
if existing == nil && replace { if existing == nil && replace {
return nil, fmt.Errorf("pair --replace: no existing pairing matches '%s' — pair it normally first", handle) return nil, fmt.Errorf("pair --replace: no existing pairing matches '%s' — pair it normally first", handle)
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// install.ps1 has no Windows runner here, so its -Pair shape is read from the
// file: the code is checked before anything is downloaded, reaches `pair -`
// on standard input and never an argument, exit 3 is success, and the
// install with no flag still ends the way it did.
func TestInstallPs1PairShape(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.ps1"))
if err != nil {
t.Fatal(err)
}
s := string(raw)
for _, want := range []string{
"param([switch]$Networked, [string]$Pair)",
"$Pair | & $exe pair -",
"elseif ($rc -eq 3)",
"Start-ScheduledTask -TaskName 'PrivataeBastion'",
"Restart-Service -Name 'PrivataeBastion'",
`Write-Host "Open a NEW terminal, then: privatae-bastion pair <token>"`,
} {
if !strings.Contains(s, want) {
t.Errorf("install.ps1 lacks %q", want)
}
}
for _, bad := range []string{"pair $Pair", "pair -- $Pair", "-ArgumentList $Pair", "Write-Host $Pair", "$Pair\"", "'$Pair'"} {
if strings.Contains(s, bad) {
t.Errorf("install.ps1 must never put the code in an argument or the output: %q", bad)
}
}
check, fetch := strings.Index(s, "StartsWith('skp_')"), strings.Index(s, `Write-Host "Fetching manifest..."`)
if check < 0 || fetch < 0 || check > fetch {
t.Error("the pairing code must be checked before the manifest is fetched")
}
}
// The -Pair block itself, run by pwsh against a stand-in bastion
// (testdata/install_ps1_pair.ps1). Skipped where pwsh is not installed.
func TestInstallPs1PairBlockRunsUnderPwsh(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
t.Skip("pwsh is not installed: install.ps1's -Pair block is checked only by TestInstallPs1PairShape here")
}
out, err := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File",
filepath.Join("testdata", "install_ps1_pair.ps1"), filepath.Join("..", "deploy", "install.ps1")).CombinedOutput()
if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "ok:") {
t.Fatalf("%v\n%s", err, out)
}
}
+358
View File
@@ -3,10 +3,13 @@
package main package main
import ( import (
"crypto/sha256"
"encoding/hex"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
"testing" "testing"
) )
@@ -74,3 +77,358 @@ func TestInstallShKeepsAPairedLocalMachineLocal(t *testing.T) {
t.Fatal("a machine with no pairing gets the networked default") t.Fatal("a machine with no pairing gets the networked default")
} }
} }
// installShPrivatePIDs is what a test that reaches install.sh's
// start_bastion runs under. start_bastion stops every copy of this user's
// bastion it can see in /proc, and on a machine that runs a real bastion as
// this user (root on a server) that would be the real one. So the test must
// see only its own processes: nil when it already runs in a PID namespace of
// its own (CI's container), the unshare prefix that makes one when it can,
// and a skip otherwise.
//
// The machine's own PID namespace is the kernel's initial one, whose number
// is fixed (PROC_PID_INIT_INO, 0xEFFFFFFC); any other is a namespace of its
// own. NSpid in /proc/self/status cannot tell: inside a container /proc is
// the container's, so it shows one level there too.
func installShPrivatePIDs(t *testing.T) []string {
t.Helper()
ns, err := os.Readlink("/proc/self/ns/pid")
if err == nil && ns != "pid:[4026531836]" {
return nil
}
if err == nil && os.Geteuid() == 0 {
if u, err := exec.LookPath("unshare"); err == nil {
wrap := []string{u, "--pid", "--fork", "--mount-proc"}
if exec.Command(wrap[0], append(wrap[1:], "true")...).Run() == nil {
return wrap
}
}
}
t.Skip("install.sh's start_bastion stops every copy of this user's bastion; run this test in a PID namespace of its own (unshare --pid --fork --mount-proc, as root)")
return nil
}
// pairShSetup is a stand-in machine for install.sh: a log directory, a
// home, and a PATH directory of stand-ins for systemctl, loginctl and sleep,
// each logging what it was given. unit is how the stand-in systemd reports
// the user unit: "steady" (one process, running) or "flapping" (a new
// process on every look, auto-restarting).
type pairShSetup struct {
dir, logs, bin, path, home string
}
func newPairShSetup(t *testing.T) *pairShSetup {
t.Helper()
d := t.TempDir()
s := &pairShSetup{dir: d, logs: filepath.Join(d, "log"), bin: filepath.Join(d, "bin"), path: filepath.Join(d, "path"), home: filepath.Join(d, "home")}
for _, p := range []string{s.logs, s.bin, s.path, s.home} {
if err := os.MkdirAll(p, 0o700); err != nil {
t.Fatal(err)
}
}
s.write(t, filepath.Join(s.path, "systemctl"), `#!/bin/sh
echo "$*" >> "$LOGS/systemctl"
case "$*" in
*"show -p MainPID --value"*)
if [ "$UNIT" = flapping ]; then n=$(cat "$LOGS/n" 2>/dev/null || echo 100); n=$((n + 1)); echo "$n" > "$LOGS/n"; echo "$n"; else echo 4242; fi ;;
*"show -p ActiveState --value"*) if [ "$UNIT" = flapping ]; then echo activating; else echo active; fi ;;
*"show -p SubState --value"*) if [ "$UNIT" = flapping ]; then echo auto-restart; else echo running; fi ;;
esac
exit 0
`)
s.write(t, filepath.Join(s.path, "loginctl"), "#!/bin/sh\necho \"$*\" >> \"$LOGS/loginctl\"\nexit \"$LINGER_RC\"\n")
s.write(t, filepath.Join(s.path, "sleep"), "#!/bin/sh\nexit 0\n")
return s
}
func (s *pairShSetup) write(t *testing.T, p, body string) {
t.Helper()
if err := os.WriteFile(p, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
}
func (s *pairShSetup) env(extra ...string) []string {
return append([]string{"PATH=" + s.path + ":/usr/bin:/bin", "HOME=" + s.home, "XDG_CONFIG_HOME=" + filepath.Join(s.home, ".config"),
"LOGS=" + s.logs, "TMPDIR=" + s.dir}, extra...)
}
// standInCopy puts a copy of this test binary at dir/privatae-bastion: run
// with installShStandInRun it is a bastion that waits to be stopped, under
// the program name install.sh looks for.
func standInCopy(t *testing.T, dir string) string {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(self)
if err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "privatae-bastion")
if err := os.WriteFile(p, b, 0o755); err != nil {
t.Fatal(err)
}
return p
}
// pairShHarness runs install.sh's pairing functions against a stand-in
// bastion, systemctl and loginctl. It returns the run's output, the log
// directory and its error. With copyRunning, a copy of the bastion from another
// path is running when the functions start, and log/copy says whether it
// still was when they ended.
func pairShHarness(t *testing.T, root, pairRC, lingerRC string) (string, string, error) {
t.Helper()
return pairShHarnessWith(t, root, pairRC, lingerRC, "steady", false)
}
func pairShHarnessWith(t *testing.T, root, pairRC, lingerRC, unit string, copyRunning bool) (string, string, error) {
t.Helper()
wrap := installShPrivatePIDs(t)
s := newPairShSetup(t)
fake := filepath.Join(s.bin, "privatae-bastion")
s.write(t, fake, `#!/bin/sh
echo "HOME=$HOME $*" >> "$LOGS/argv"
case "$1" in
pair) cat > "$LOGS/stdin"; exit "$PAIR_RC" ;;
status) echo "status: paired" ;;
esac
exit 0
`)
copyPath := ""
if copyRunning {
other := filepath.Join(s.dir, "other-install")
if err := os.MkdirAll(other, 0o700); err != nil {
t.Fatal(err)
}
copyPath = standInCopy(t, other)
}
fns := installShFunc(t, "pair_as") + installShFunc(t, "bastion_copy") + installShFunc(t, "stop_user_copies") +
installShFunc(t, "user_unit_stays_up") + installShFunc(t, "start_bastion") + installShFunc(t, "pair_and_start")
script := fns + `
if [ -n "$COPY" ]; then
"$COPY" ` + installShStandInRun + ` >/dev/null 2>&1 &
copy=$!
i=0
while ! bastion_copy "$copy" && [ "$i" -lt 100 ]; do i=$((i + 1)); /bin/sleep 0.05; done
bastion_copy "$copy" || { echo "the stand-in copy never started" >&2; exit 97; }
fi
rc=0
pair_and_start "$1" "$2" "$3" linux || rc=$?
if [ -n "$COPY" ]; then
if bastion_copy "$copy"; then echo alive > "$LOGS/copy"; kill -9 "$copy"; else echo gone > "$LOGS/copy"; fi
fi
exit "$rc"
`
args := append(append([]string{}, wrap...), "sh", "-c", script, "sh", fake, "skp_INVENTEDCODE", root)
cmd := exec.Command(args[0], args[1:]...)
cmd.Env = s.env("PAIR_RC="+pairRC, "LINGER_RC="+lingerRC, "UNIT="+unit, "COPY="+copyPath)
out, err := cmd.CombinedOutput()
return string(out), s.logs, err
}
func readPairShLog(t *testing.T, dir, name string) string {
t.Helper()
b, _ := os.ReadFile(filepath.Join(dir, name))
return string(b)
}
// The pairing functions hand the code to `pair -` on standard input, never
// as an argument, then write and start a systemd user unit, ask for
// lingering, and end with one plain sentence. Exit 3 (already paired with
// this Host) is success.
func TestInstallShPairsOverStdinAndStartsAUserUnit(t *testing.T) {
out, logs, err := pairShHarness(t, "0", "0", "0")
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
argv := readPairShLog(t, logs, "argv")
if !strings.Contains(argv, " pair -\n") || strings.Contains(argv, "skp_") {
t.Fatalf("the code must reach `pair -` on stdin and never an argument: %q", argv)
}
if got := readPairShLog(t, logs, "stdin"); got != "skp_INVENTEDCODE\n" {
t.Fatalf("pair's standard input: %q", got)
}
home := strings.TrimPrefix(strings.Fields(argv)[0], "HOME=")
unit, err := os.ReadFile(filepath.Join(home, ".config", "systemd", "user", "privatae-bastion.service"))
if err != nil || !regexp.MustCompile(`(?m)^ExecStart=\S+/privatae-bastion run$`).Match(unit) || !strings.Contains(string(unit), "WantedBy=default.target") {
t.Fatalf("the user unit: %v\n%s", err, unit)
}
sc := readPairShLog(t, logs, "systemctl")
for _, want := range []string{"--user daemon-reload", "--user enable privatae-bastion", "--user restart privatae-bastion"} {
if !strings.Contains(sc, want+"\n") {
t.Fatalf("systemctl was not asked to %q: %q", want, sc)
}
}
if !strings.Contains(readPairShLog(t, logs, "loginctl"), "enable-linger ") {
t.Fatal("lingering was not asked for")
}
if !strings.Contains(out, "status: paired") || !strings.HasSuffix(strings.TrimSpace(out),
"This machine is paired. The bastion runs as your systemd user service privatae-bastion, and starts at boot.") {
t.Fatalf("the ending: %s", out)
}
out, _, err = pairShHarness(t, "0", "3", "1")
if err != nil || !strings.Contains(out, "already paired with this Host") || !strings.Contains(out, "sudo loginctl enable-linger") {
t.Fatalf("exit 3 is success, and a refused linger says how to get it: %v\n%s", err, out)
}
out, logs, err = pairShHarness(t, "0", "1", "0")
if err == nil || strings.Contains(readPairShLog(t, logs, "systemctl"), "restart") || !strings.Contains(out, "pairing did not finish") {
t.Fatalf("a failed pair must stop before starting anything: %v\n%s", err, out)
}
out, logs, err = pairShHarness(t, "1", "0", "0")
if err != nil || !strings.Contains(readPairShLog(t, logs, "argv"), "HOME=/root ") ||
readPairShLog(t, logs, "systemctl") != "restart privatae-bastion\n" || strings.Contains(out, "user service") {
t.Fatalf("as root it pairs as the service's state and restarts the system service: %v\n%s\n%s", err, out, readPairShLog(t, logs, "systemctl"))
}
}
// Linux, as yourself: a copy of the bastion already running outside the
// user unit (from a terminal, or another path) is stopped before the unit
// starts, so the unit's copy is the one that serves; and a unit that does not
// stay up is said plainly, with a failure, never reported as running.
func TestInstallShStopsAnOutsideCopyAndChecksTheUnitStaysUp(t *testing.T) {
out, logs, err := pairShHarnessWith(t, "0", "0", "0", "steady", true)
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if got := strings.TrimSpace(readPairShLog(t, logs, "copy")); got != "gone" {
t.Fatalf("the copy running outside the unit must be stopped, it is %q\n%s", got, out)
}
if !strings.Contains(out, "Stopped 1 copy(ies) of the bastion running outside its service") {
t.Fatalf("the stop is said: %s", out)
}
sc := readPairShLog(t, logs, "systemctl")
if i, j := strings.Index(sc, "--user stop privatae-bastion\n"), strings.Index(sc, "--user restart privatae-bastion\n"); i < 0 || j < i {
t.Fatalf("the unit is stopped before the copies and started after: %q", sc)
}
if !strings.HasSuffix(strings.TrimSpace(out), "This machine is paired. The bastion runs as your systemd user service privatae-bastion, and starts at boot.") {
t.Fatalf("the ending: %s", out)
}
out, _, err = pairShHarnessWith(t, "0", "0", "0", "flapping", false)
if err == nil || !strings.Contains(out, "is not staying up") || !strings.Contains(out, "activating/auto-restart") ||
!strings.Contains(out, "journalctl --user -u privatae-bastion") || strings.Contains(out, "The bastion runs as your systemd user service") {
t.Fatalf("a unit that restarts must be reported as not running, and fail: %v\n%s", err, out)
}
}
// The whole of install.sh, the card's way: the code in PRIVATAE_PAIR_CODE
// and no argument. Every program it starts (stand-ins for uname, id, curl,
// printf, systemctl, loginctl and sleep, and the downloaded bastion) records
// its own arguments, the command line of the shell that started it, and
// whether the code is in its environment. The code must reach `pair -` on
// standard input and appear in none of those. Nothing leaves this machine:
// curl is a stand-in serving a manifest and a stand-in bastion.
func TestInstallShTakesTheCodeOnlyFromTheEnvironment(t *testing.T) {
wrap := installShPrivatePIDs(t)
s := newPairShSetup(t)
const code = "skp_eyJ2IjoxLCJ0IjoiSU5WRU5URUQtRU5WLUNPREUifQ"
record := `echo "$(basename "$0") argv=[$*] parent=[$(tr '\0' ' ' < /proc/$PPID/cmdline)] env=[${PRIVATAE_PAIR_CODE+set}] envcode=[$(env | grep -c "` + code + `")]" >> "$LOGS/procs"
`
bastion := "#!/bin/sh\n" + record + `case "$1" in
pair) cat > "$LOGS/stdin" ;;
status) echo "status: paired" ;;
esac
exit 0
`
s.write(t, filepath.Join(s.dir, "bastion.bin"), bastion)
sum := sha256.Sum256([]byte(bastion))
s.write(t, filepath.Join(s.dir, "version.json"), "{\n \"linux-amd64\": {\n \"url\": \"https://dl.privatae.ai/dl/privatae-bastion-linux-amd64?v=0.0.0\",\n \"sha256\": \""+hex.EncodeToString(sum[:])+"\"\n }\n}\n")
stand := map[string]string{
"uname": `case "$1" in -s) echo Linux ;; -m) echo x86_64 ;; *) echo Linux ;; esac`,
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
"curl": `for a; do o=$a; done; case "$2" in */version.json) cp "$STAND/version.json" "$o" ;; *) cp "$STAND/bastion.bin" "$o" ;; esac`,
"printf": `exec /usr/bin/printf "$@"`,
}
for name, body := range stand {
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+record+body+"\n")
}
for _, name := range []string{"systemctl", "loginctl", "sleep"} {
p := filepath.Join(s.path, name)
b, _ := os.ReadFile(p)
s.write(t, p, strings.Replace(string(b), "#!/bin/sh\n", "#!/bin/sh\n"+record, 1))
}
sh, err := filepath.Abs(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
args := append(append([]string{}, wrap...), "sh", sh)
cmd := exec.Command(args[0], args[1:]...)
cmd.Env = s.env("PRIVATAE_PAIR_CODE="+code, "STAND="+s.dir, "LINGER_RC=0", "UNIT=steady")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if got := readPairShLog(t, s.logs, "stdin"); got != code+"\n" {
t.Fatalf("pair's standard input: %q\n%s", got, out)
}
procs := readPairShLog(t, s.logs, "procs")
for _, who := range []string{"uname argv=[-s]", "curl argv=[", "privatae-bastion argv=[pair -]", "privatae-bastion argv=[status]", "systemctl argv=[--user restart privatae-bastion]"} {
if !strings.Contains(procs, who) {
t.Fatalf("no record of %q: the stand-ins did not run as expected\n%s\n%s", who, procs, out)
}
}
if strings.Contains(procs, code) || strings.Contains(procs, "INVENTED-ENV-CODE") {
t.Fatalf("the code is in a program's arguments or its shell's command line:\n%s", procs)
}
if strings.Contains(procs, "env=[set]") || !strings.Contains(procs, "envcode=[0]") || strings.Contains(procs, "envcode=[1]") {
t.Fatalf("a program the installer started inherited the code in its environment:\n%s", procs)
}
if strings.Contains(string(out), code) {
t.Fatalf("the installer printed the code:\n%s", out)
}
if !strings.HasSuffix(strings.TrimSpace(string(out)), "https://privatae.ai/bastion/LICENSE.txt") || !strings.Contains(string(out), "This machine is paired.") {
t.Fatalf("the ending: %s", out)
}
}
// A PRIVATAE_PAIR_CODE that is empty or not a whole pairing code is refused
// before anything is downloaded, and never echoed; so is any argument, a
// code given as one (the removed --pair, or the code alone) included.
//
// The script runs with stand-ins for id (not root) and curl (records that it
// was reached, fetches nothing), so an installer whose refusal broke can
// neither reach the network nor install anything as root on the machine
// running the test: it fails here instead.
func TestInstallShRefusesABadPairCodeBeforeDownloading(t *testing.T) {
const code = "skp_eyJ2IjoxfQ"
stand := t.TempDir()
for name, body := range map[string]string{
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
"curl": `echo "REACHED-THE-DOWNLOAD" >&2; exit 1`,
} {
if err := os.WriteFile(filepath.Join(stand, name), []byte("#!/bin/sh\n"+body+"\n"), 0o700); err != nil {
t.Fatal(err)
}
}
for _, c := range []struct {
env []string
args []string
}{
{[]string{"PRIVATAE_PAIR_CODE="}, nil},
{[]string{"PRIVATAE_PAIR_CODE=skp_"}, nil},
{[]string{"PRIVATAE_PAIR_CODE=skb_aG9tZS5zZWNyZXQteA"}, nil},
{[]string{"PRIVATAE_PAIR_CODE=" + code + " trailing"}, nil},
{[]string{"PRIVATAE_PAIR_CODE=" + code + "%3D"}, nil},
{nil, []string{"--pair", code}},
{nil, []string{"--pair=" + code}},
{nil, []string{code}},
{nil, []string{"--networkd"}},
} {
home := t.TempDir()
cmd := exec.Command("sh", append([]string{filepath.Join("..", "deploy", "install.sh")}, c.args...)...)
cmd.Env = append([]string{"PATH=" + stand + ":/usr/bin:/bin", "HOME=" + home, "TMPDIR=" + home}, c.env...)
out, err := cmd.CombinedOutput()
ee, ok := err.(*exec.ExitError)
if !ok || ee.ExitCode() != 2 || strings.Contains(string(out), "Fetching manifest") || strings.Contains(string(out), "REACHED-THE-DOWNLOAD") {
t.Fatalf("%v %v: want exit 2 before the download, got %v\n%s", c.env, c.args, err, out)
}
if strings.Contains(string(out), code) || strings.Contains(string(out), "skb_") || strings.Contains(string(out), "networkd") {
t.Fatalf("%v %v: the refusal echoes what it was given: %s", c.env, c.args, out)
}
}
}
+19 -1
View File
@@ -37,6 +37,13 @@ Usage:
that CA alone (name host.cseries.internal), the lanes go to that CA alone (name host.cseries.internal), the lanes go to
the lane port the Host names, and the CA is pinned for life. the lane port the Host names, and the CA is pinned for life.
No coder runs over this pairing No coder runs over this pairing
privatae-bastion pair <pairing code skp_…>
the same pairing from the one code a Cloud Host's card gives:
it carries the enroll address and the CA's fingerprint, and
the CA is taken from the enroll port only under that
fingerprint. On a local machine that already serves a Host,
it asks here whether to add this one as well. Exit 3: already
paired with this Host, nothing changed
privatae-bastion run bring up the tunnel + lanes for EVERY paired host privatae-bastion run bring up the tunnel + lanes for EVERY paired host
(a loopback control port per host for the verbs below: (a loopback control port per host for the verbs below:
127.0.0.1:7878, then 7879, …) 127.0.0.1:7878, then 7879, …)
@@ -188,7 +195,9 @@ func takeHostFlag(args []string) (string, []string) {
} }
const pairUsage = "usage: privatae-bastion pair [--replace] [--redeem-url unix:///<socket> | --redeem-url https://<host>:<port>/<path> --host-ca <pem file>] <token> | -\n" + const pairUsage = "usage: privatae-bastion pair [--replace] [--redeem-url unix:///<socket> | --redeem-url https://<host>:<port>/<path> --host-ca <pem file>] <token> | -\n" +
" - reads the token from standard input, so it stays out of the process list and shell history" " privatae-bastion pair <pairing code skp_…> | -\n" +
" - reads the token or pairing code from standard input, so it stays out of the process list and shell history\n" +
" exit status: 0 paired, 3 already paired with this Host (nothing changed), anything else a failure"
// parsePairArgs reads `pair`'s flags in any order: --replace, --redeem-url // parsePairArgs reads `pair`'s flags in any order: --replace, --redeem-url
// for a Host with no platform (pair_unix.go, pair_tcp.go) and --host-ca for // for a Host with no platform (pair_unix.go, pair_tcp.go) and --host-ca for
@@ -258,6 +267,15 @@ func main() {
os.Exit(2) os.Exit(2)
} }
err = cmdPair(token, redeemURL, hostCA, replace) err = cmdPair(token, redeemURL, hostCA, replace)
// Already paired with this Host, nothing changed: its own exit status,
// so the installers (install.ps1 -Pair, install.sh with
// PRIVATAE_PAIR_CODE) report "already connected".
var already *alreadyPairedError
if errors.As(err, &already) {
auditFlush(auditFlushOnExit)
fmt.Fprintln(os.Stderr, already)
os.Exit(pairExitAlreadyPaired)
}
case "run": case "run":
err = cmdRun() err = cmdRun()
case "observe:baseline": case "observe:baseline":
+12
View File
@@ -36,8 +36,13 @@ import (
"os" "os"
"strings" "strings"
"testing" "testing"
"time"
) )
// installShStandInRun is the argument that makes this test binary stand in
// for a running bastion (TestMain).
const installShStandInRun = "install-sh-stand-in-run"
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
// The op sandbox re-execs its own binary (ops_exec_linux.go); under // The op sandbox re-execs its own binary (ops_exec_linux.go); under
// `go test` that binary is this one, so the child must be answered here // `go test` that binary is this one, so the child must be answered here
@@ -62,6 +67,13 @@ func TestMain(m *testing.M) {
if len(os.Args) >= 3 && os.Args[1] == rootExecConnectSubcommand { if len(os.Args) >= 3 && os.Args[1] == rootExecConnectSubcommand {
os.Exit(rootExecConnectMain(os.Args[2:])) os.Exit(rootExecConnectMain(os.Args[2:]))
} }
// install_sh_test.go runs a copy of this binary named privatae-bastion as
// a bastion left running outside its service: it only waits to be
// stopped, and never starts the suite.
if len(os.Args) >= 2 && os.Args[1] == installShStandInRun {
time.Sleep(2 * time.Minute)
os.Exit(0)
}
// Likewise when git runs this binary as the bastion's credential helper // Likewise when git runs this binary as the bastion's credential helper
// (claims_git_test.go): answer as the helper, with the config dir the // (claims_git_test.go): answer as the helper, with the config dir the
// test named, and never start the suite. // test named, and never start the suite.
+5 -3
View File
@@ -88,11 +88,13 @@ func setBoxMode(mode string) error {
} }
} }
if mode == modeLocal { if mode == modeLocal {
// Going back to local must not strand hosts: a local box serves ONE host, // Going back to local must not strand hosts: a local box serves a second
// so with several pairings the box would be lying about its own rule. // host only on a yes typed at this machine (pair_terminal.go), and these
// were paired under the networked rules, so with several pairings the box
// would serve hosts nobody here said yes to.
cfgs, err := loadPairings() cfgs, err := loadPairings()
if err == nil && len(cfgs) > 1 { if err == nil && len(cfgs) > 1 {
return fmt.Errorf("cannot switch to local mode: %d hosts are paired and a local bastion serves one — remove the others first", len(cfgs)) return fmt.Errorf("cannot switch to local mode: %d hosts are paired, and a local bastion adds a second host only on a yes typed at this machine — remove the others first, then pair them again here", len(cfgs))
} }
if currentAnchor() != nil { if currentAnchor() != nil {
return errors.New("cannot switch to local mode: this box is anchored (anchor.json) — anchoring is a networked-mode concept") return errors.New("cannot switch to local mode: this box is anchored (anchor.json) — anchoring is a networked-mode concept")
+149 -14
View File
@@ -163,10 +163,12 @@ type redeemResp struct {
// mode rule, and the anchoring rule. Returns the existing pairing on a // mode rule, and the anchoring rule. Returns the existing pairing on a
// --replace and the state the new pairing file must carry. // --replace and the state the new pairing file must carry.
// //
// - LOCAL mode (mode.go): one host, full stop. A second `pair` is refused with // - LOCAL mode (mode.go): one host unless the person says otherwise. A
// a plain message; only --replace of the one host is allowed. This is the // second `pair` is refused here with localSecondHostError; `pair` itself
// entire protection for a single-user machine — a second host appearing // turns that into a question asked on this machine's terminal
// means someone else got a token onto the box. // (pair_terminal.go), and every other caller keeps the refusal. On a
// single-user machine a second host appearing unasked means someone else
// got a token onto the box, so only a yes typed here adds one.
// - NETWORKED + anchored (anchor.go): every pairing after the anchor is written // - NETWORKED + anchored (anchor.go): every pairing after the anchor is written
// PENDING. It gets nothing until the anchor host's grant arrives. // PENDING. It gets nothing until the anchor host's grant arrives.
// - --replace keeps the pairing's current state: rotating credentials is not // - --replace keeps the pairing's current state: rotating credentials is not
@@ -191,11 +193,14 @@ func pairPrecheck(cfgs []*Config, handle string, replace, forceActive bool) (exi
return existing, existing.State, nil return existing, existing.State, nil
} }
if boxMode() == modeLocal && len(cfgs) > 0 { if boxMode() == modeLocal && len(cfgs) > 0 {
return nil, "", fmt.Errorf("this bastion is in local mode and already serves %s — a local bastion serves one host. "+ return nil, "", &localSecondHostError{serving: cfgs[0].HostName}
"To rotate that host's credentials: `privatae-bastion pair --replace <token>`. "+
"To serve several hosts, reinstall as a networked bastion (install.sh --networked, root) and anchor it",
cfgs[0].HostName)
} }
return pairPrecheckState(forceActive)
}
// pairPrecheckState is the state a new pairing gets once pairPrecheck's walls
// are passed: active for claim-anchor, pending behind an anchor, else "".
func pairPrecheckState(forceActive bool) (*Config, string, error) {
if forceActive { if forceActive {
return nil, pairingActive, nil return nil, pairingActive, nil
} }
@@ -205,8 +210,73 @@ func pairPrecheck(cfgs []*Config, handle string, replace, forceActive bool) (exi
return nil, "", nil return nil, "", nil
} }
// localSecondHostError is pairPrecheck's refusal on a local machine that
// already serves a host. cmdPair asks the person instead (confirmAddHost).
type localSecondHostError struct{ serving string }
func (e *localSecondHostError) Error() string { return localOneHostRefusal(e.serving) }
// localOneHostRefusal says why a second host was not added to a local
// bastion and what the person can do, in this system's own words. A networked
// reinstall starts a bastion with its own state, so it says plainly that the
// host already here would have to be paired again.
func localOneHostRefusal(serving string) string {
networked := "reinstall it as root with install.sh --networked"
if runtime.GOOS == "windows" {
networked = "reinstall it from an Administrator PowerShell with install.ps1 -Networked"
}
return fmt.Sprintf("this bastion is in local mode and already serves %s, and it adds another Host only when you "+
"answer yes at this computer's terminal. To rotate %s's credentials instead: `privatae-bastion pair --replace <token>`. "+
"A machine that other people's Hosts pair to is a networked bastion: %s. That makes a new bastion with "+
"state of its own, so %s has to be paired with it again.", serving, serving, networked, serving)
}
// alreadyPairedError: the host a pair names is already paired here, and
// nothing was changed. `pair` exits pairExitAlreadyPaired on it, so an
// installer can tell "already connected" from a failure.
type alreadyPairedError struct{ host, msg string }
func (e *alreadyPairedError) Error() string { return e.msg }
// pairExitAlreadyPaired is `pair`'s exit status for alreadyPairedError.
const pairExitAlreadyPaired = 3
// sameNameOtherHost refuses, as a plain failure and never as
// alreadyPairedError, a pair whose Host has the name of prev but trusts
// another CA (wantCA, its fingerprint). A Cloud Host's name can repeat; its CA
// cannot, so that is another Host and never "already connected". nil when
// prev is nil or holds that same CA.
func sameNameOtherHost(prev *Config, wantCA string) error {
if prev == nil || caSHA256(prev.CACert) == wantCA {
return nil
}
return fmt.Errorf("this computer already serves a different Host named %s (its certificate is not this one's), "+
"and one bastion cannot serve two Hosts of the same name yet; nothing was paired", prev.HostName)
}
func alreadyPairedRefusal(host string) error {
return &alreadyPairedError{host: host, msg: fmt.Sprintf("already paired with %s — run `privatae-bastion pair --replace <token>` to rotate its credentials", host)}
}
// pairOpts are what `pair` brings beyond the token: where to redeem, the
// Host CA as a file or as a pairing code's fingerprint, and the person to ask
// before a local machine adds a second host (nil: refuse, as every caller but
// `pair` does).
type pairOpts struct {
redeemURL string
hostCAFile string
caSHA256 string
replace bool
forceActive bool
askSecond func(serving, newHost string) (bool, error)
}
func cmdPair(token, redeemURL, hostCAFile string, replace bool) error { func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
cfg, all, err := pairWithTokenVia(token, redeemURL, hostCAFile, replace, false) token, opts, err := pairOptsFor(token, redeemURL, hostCAFile, replace)
if err != nil {
return err
}
cfg, all, err := pairWith(token, opts)
if err != nil { if err != nil {
return err return err
} }
@@ -236,6 +306,26 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
return nil return nil
} }
// pairOptsFor is what `pair`'s arguments come to: the token to redeem and
// the options for it, the person at this machine to ask about a second host
// among them. A pairing code (pair_code.go) carries the redeem address and the
// Host CA's fingerprint itself, so it takes neither flag.
func pairOptsFor(token, redeemURL, hostCAFile string, replace bool) (string, pairOpts, error) {
opts := pairOpts{redeemURL: redeemURL, hostCAFile: hostCAFile, replace: replace, askSecond: confirmAddHost}
if !isPairCode(token) {
return token, opts, nil
}
if redeemURL != "" || hostCAFile != "" {
return "", opts, errors.New("a pairing code (skp_…) carries its own address and certificate; give it alone, without --redeem-url or --host-ca")
}
pc, err := decodePairCode(token)
if err != nil {
return "", opts, err
}
opts.redeemURL, opts.caSHA256 = pc.RedeemURL, pc.CASHA256
return pc.Token, opts, nil
}
// pairReport is what `pair` prints once the pairing stands. Whatever the form // pairReport is what `pair` prints once the pairing stands. Whatever the form
// (anchored just now, over TCP, over a unix socket, pending, or over the // (anchored just now, over TCP, over a unix socket, pending, or over the
// tunnel), it ends by saying to enrol a second factor when this pairing has // tunnel), it ends by saying to enrol a second factor when this pairing has
@@ -338,6 +428,14 @@ func pairWithTokenAt(token, redeemURL string, replace, forceActive bool) (*Confi
// redeem URL needs (pair_tcp.go): the Host's enroll port, trusted through // redeem URL needs (pair_tcp.go): the Host's enroll port, trusted through
// that CA alone. A unix:// or platform redeem takes no --host-ca. // that CA alone. A unix:// or platform redeem takes no --host-ca.
func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive bool) (*Config, []*Config, error) { func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive bool) (*Config, []*Config, error) {
return pairWith(token, pairOpts{redeemURL: redeemURL, hostCAFile: hostCAFile, replace: replace, forceActive: forceActive})
}
// pairWith is every pairing's one path. opts.caSHA256 set (a pairing code)
// takes the Host CA from the enroll port once the walls below are passed,
// kept only under that fingerprint (fetchPinnedHostCA).
func pairWith(token string, opts pairOpts) (*Config, []*Config, error) {
redeemURL, hostCAFile, replace, forceActive := opts.redeemURL, opts.hostCAFile, opts.replace, opts.forceActive
// ONE BASTION PER MACHINE, and this is the check that matters most. // ONE BASTION PER MACHINE, and this is the check that matters most.
// //
// It is FIRST on purpose. loadPairings below reaches pairingsDir -> MkdirAll, // It is FIRST on purpose. loadPairings below reaches pairingsDir -> MkdirAll,
@@ -367,12 +465,17 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if hostCAFile == "" { switch {
case opts.caSHA256 != "":
// A pairing code: the CA comes from the enroll port after the
// walls, under the code's fingerprint.
case hostCAFile == "":
return nil, nil, errors.New("--redeem-url https://… needs --host-ca <the Host's CA, PEM>: it is the only certificate the redeem trusts") return nil, nil, errors.New("--redeem-url https://… needs --host-ca <the Host's CA, PEM>: it is the only certificate the redeem trusts")
} default:
if hostCA, err = loadHostCA(hostCAFile); err != nil { if hostCA, err = loadHostCA(hostCAFile); err != nil {
return nil, nil, err return nil, nil, err
} }
}
tcpRedeem, transport = u, transportTCP tcpRedeem, transport = u, transportTCP
default: default:
s, err := parseUnixRedeemURL(redeemURL) s, err := parseUnixRedeemURL(redeemURL)
@@ -397,12 +500,41 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive
return nil, nil, err return nil, nil, err
} }
existing, state, err := pairPrecheck(cfgs, handle, replace, forceActive) existing, state, err := pairPrecheck(cfgs, handle, replace, forceActive)
var second *localSecondHostError
if errors.As(err, &second) && opts.askSecond != nil {
yes, aerr := opts.askSecond(second.serving, handle)
if aerr != nil {
return nil, nil, aerr
}
if !yes {
return nil, nil, fmt.Errorf("nothing was paired: %s stays the only Host this computer serves", second.serving)
}
existing, state, err = pairPrecheckState(forceActive)
}
// The CA this pair trusts, as a fingerprint: the pairing code's, or that
// of the --host-ca file. "" for a platform or unix-socket pairing, whose
// CA comes back in the answer.
wantCA := opts.caSHA256
if wantCA == "" && hostCA != "" {
wantCA = caSHA256(hostCA)
}
var already *alreadyPairedError
if errors.As(err, &already) && wantCA != "" {
if refusal := sameNameOtherHost(pairingFor(cfgs, handle), wantCA); refusal != nil {
return nil, nil, refusal
}
}
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if err := replaceKeepsTransport(existing, transport); err != nil { if err := replaceKeepsTransport(existing, transport); err != nil {
return nil, nil, err return nil, nil, err
} }
if tcpRedeem != nil && hostCA == "" {
if hostCA, err = fetchPinnedHostCA(tcpRedeem, opts.caSHA256); err != nil {
return nil, nil, err
}
}
if tcpRedeem != nil { if tcpRedeem != nil {
if err := tcpPinRefusal(existing, hostCA); err != nil { if err := tcpPinRefusal(existing, hostCA); err != nil {
return nil, nil, err return nil, nil, err
@@ -438,7 +570,7 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive
if prev := pairingFor(cfgs, rr.hostName()); prev != nil && validHostName(rr.hostName()) { if prev := pairingFor(cfgs, rr.hostName()); prev != nil && validHostName(rr.hostName()) {
existing = prev existing = prev
if !replace { if !replace {
return nil, nil, fmt.Errorf("already paired with %s — run `privatae-bastion pair --replace <token>` to rotate its credentials", prev.HostName) return nil, nil, alreadyPairedRefusal(prev.HostName)
} }
if err := replaceKeepsTransport(existing, transportUnix); err != nil { if err := replaceKeepsTransport(existing, transportUnix); err != nil {
return nil, nil, err return nil, nil, err
@@ -463,7 +595,10 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive
if prev := pairingFor(cfgs, rr.hostName()); prev != nil && validHostName(rr.hostName()) { if prev := pairingFor(cfgs, rr.hostName()); prev != nil && validHostName(rr.hostName()) {
existing = prev existing = prev
if !replace { if !replace {
return nil, nil, fmt.Errorf("already paired with %s — run `privatae-bastion pair --replace <token>` to rotate its credentials", prev.HostName) if refusal := sameNameOtherHost(prev, caSHA256(hostCA)); refusal != nil {
return nil, nil, refusal
}
return nil, nil, alreadyPairedRefusal(prev.HostName)
} }
if err := replaceKeepsTransport(existing, transportTCP); err != nil { if err := replaceKeepsTransport(existing, transportTCP); err != nil {
return nil, nil, err return nil, nil, err
@@ -531,7 +666,7 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive
if prev := pairingFor(cfgs, rr.HostName); prev != nil { if prev := pairingFor(cfgs, rr.HostName); prev != nil {
existing = prev existing = prev
if !replace { if !replace {
return nil, nil, fmt.Errorf("already paired with %s — run `privatae-bastion pair --replace <token>` to rotate its credentials", prev.HostName) return nil, nil, alreadyPairedRefusal(prev.HostName)
} }
} }
opSecret, err := genSecret(24) opSecret, err := genSecret(24)
+254
View File
@@ -0,0 +1,254 @@
package main
// pair_code.go: the pairing code a Host's "Set up a bastion" card hands out,
// so pairing a machine is one pasted line.
//
// privatae-bastion pair skp_… or … | privatae-bastion pair -
//
// The code is the single-use token plus the two things the person used to copy
// by hand: the Host's enroll address and its CA. The CA does not travel; its
// fingerprint does. The bastion takes the CA from the enroll port's own TLS
// chain and keeps it only when its SHA-256 is the one in the code, so the trust
// is the same as `--host-ca`: the fingerprint comes from the owner's own
// signed-in card, as the PEM did. From there the pairing is exactly
// `pair --redeem-url <u> --host-ca <that CA> <t>`.
//
// No error here repeats the code, for the reason pair_stdin.go gives: a log of
// a failed install must not hold the token.
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
)
// pairCodePrefix marks a pairing code; a bare token is skb_….
const pairCodePrefix = "skp_"
// pairCodeVersion is the one layout this bastion reads.
const pairCodeVersion = 1
// pairCode is a decoded pairing code: where to redeem, the Host CA's
// fingerprint, and the token.
type pairCode struct {
RedeemURL string // "u": the Host's enroll port, https://<host>:<port>/<path>
CASHA256 string // "ca": SHA-256 of the Host CA certificate's DER, lowercase hex
Token string // "t": the skb_ token
}
func isPairCode(s string) bool { return strings.HasPrefix(s, pairCodePrefix) }
// decodePairCode reads skp_<base64url(JSON)>: exactly the keys v, u, ca and t,
// v the one version this bastion knows, u an enroll URL parseTCPRedeemURL
// takes, ca 64 lowercase hex digits and t a token decodeToken takes.
func decodePairCode(code string) (pairCode, error) {
var pc pairCode
bad := func(why string) (pairCode, error) {
return pairCode{}, fmt.Errorf("the pairing code is not one this bastion can read (%s); copy the whole line from the card again", why)
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(code, pairCodePrefix))
if err != nil {
return bad("it is not base64url")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return bad("it holds no JSON object")
}
// A map keeps the last of two same-named keys, so "exactly these four"
// is counted on the object as written: a key given twice is an extra key.
if n, err := jsonObjectKeyCount(raw); err != nil || n != len(fields) {
return bad("it names a field twice")
}
if len(fields) != 4 {
return bad(fmt.Sprintf("it has %d fields, and a pairing code has 4", len(fields)))
}
var v int
if err := json.Unmarshal(fields["v"], &v); err != nil {
return bad("its version is missing")
}
if v != pairCodeVersion {
return pairCode{}, fmt.Errorf("the pairing code is version %d, and this bastion reads version %d: update the bastion and pair again", v, pairCodeVersion)
}
for k, dst := range map[string]*string{"u": &pc.RedeemURL, "ca": &pc.CASHA256, "t": &pc.Token} {
if err := json.Unmarshal(fields[k], dst); err != nil || *dst == "" {
return bad("its " + k + " field is missing")
}
}
if _, err := parseTCPRedeemURL(pc.RedeemURL); err != nil {
return bad("its address is not an https enroll address")
}
if len(pc.CASHA256) != 64 || strings.ToLower(pc.CASHA256) != pc.CASHA256 {
return bad("its certificate fingerprint is not 64 lowercase hex digits")
}
if _, err := hex.DecodeString(pc.CASHA256); err != nil {
return bad("its certificate fingerprint is not hex")
}
if !strings.HasPrefix(pc.Token, "skb_") {
return bad("it carries no pairing token")
}
if _, _, err := decodeToken(pc.Token); err != nil {
return bad("its pairing token is malformed")
}
return pc, nil
}
// jsonObjectKeyCount counts the keys of the JSON object raw as written,
// repeats included. raw has already decoded as an object.
func jsonObjectKeyCount(raw []byte) (int, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
if _, err := dec.Token(); err != nil { // the opening brace
return 0, err
}
n := 0
for dec.More() {
if _, err := dec.Token(); err != nil { // the key
return 0, err
}
var v json.RawMessage
if err := dec.Decode(&v); err != nil {
return 0, err
}
n++
}
return n, nil
}
// pairAnswerTimeout is how long the enroll port has to answer the first
// connection. A Cloud Host's pairing port drops every address but the one the
// card opened it to, so a pair from anywhere else gets no answer at all, and
// without this it would wait for the system's own connect timeout, minutes.
// A variable so a test can shorten it; nothing else assigns it.
var pairAnswerTimeout = 10 * time.Second
// pairDial dials the enroll port. A variable so a test can stand in a port
// that never answers; nothing else assigns it.
var pairDial = (&net.Dialer{}).DialContext
// pairWindowError is what a pair says when the Host's enroll port did not
// answer: the plain reason, which is nearly always the pairing window. A type
// of its own so the redeem's HTTP client, which wraps every dial error in the
// request's URL, can hand it on as it is.
type pairWindowError struct{ host string }
func (e *pairWindowError) Error() string {
return fmt.Sprintf("%s's pairing port didn't answer within %s. A Cloud Host opens it only to the address "+
"your browser used when you opened \"Set up a bastion\", for 15 minutes. Pair from that network, or "+
"on the card choose \"Pairing a machine on another network?\" and give this machine's address. Nothing was paired.",
e.host, pairAnswerTimeout)
}
// dialEnrollPort opens a TCP connection to the redeem URL's host and port
// within pairAnswerTimeout, or says why not in plain words.
func dialEnrollPort(ctx context.Context, u *url.URL) (net.Conn, error) {
addr := u.Host
if u.Port() == "" {
addr = net.JoinHostPort(u.Hostname(), "443")
}
ctx, cancel := context.WithTimeout(ctx, pairAnswerTimeout)
defer cancel()
c, err := pairDial(ctx, "tcp", addr)
if err == nil {
return c, nil
}
var ne net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout()) {
return nil, &pairWindowError{host: u.Hostname()}
}
return nil, fmt.Errorf("could not reach %s's pairing port: %v; nothing was paired", u.Hostname(), err)
}
// fetchPinnedHostCA takes the Host CA from the enroll port's TLS chain and
// returns it as PEM, only when it is a CA whose SHA-256 over its DER is
// wantSHA and the port's own certificate verifies under it alone for
// tcpServerName, the check hostCATLS makes on every later connection. No
// system roots are consulted at any point.
func fetchPinnedHostCA(u *url.URL, wantSHA string) (string, error) {
conn, err := dialEnrollPort(context.Background(), u)
if err != nil {
return "", err
}
defer conn.Close()
var caPEM string
tc := &tls.Config{
ServerName: tcpServerName, MinVersion: tls.VersionTLS12,
// The chain is checked by verifyPinnedChain below, against the pinned
// CA alone; the system roots must play no part, so the default
// verification is the thing turned off here.
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
p, err := verifyPinnedChain(cs.PeerCertificates, wantSHA)
caPEM = p
return err
},
}
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
tlsConn := tls.Client(conn, tc)
if err := tlsConn.Handshake(); err != nil {
return "", fmt.Errorf("%s's pairing port: %v; nothing was paired", u.Hostname(), err)
}
_ = tlsConn.Close()
return caPEM, nil
}
// verifyPinnedChain picks the pinned CA out of a presented chain and checks
// the leaf against it. The leaf is chain[0]; the CA is any later certificate
// whose SHA-256 is wantSHA.
func verifyPinnedChain(chain []*x509.Certificate, wantSHA string) (string, error) {
if len(chain) == 0 {
return "", errors.New("the Host's pairing port presented no certificate")
}
var ca *x509.Certificate
sawCA := false
for _, c := range chain[1:] {
if !c.IsCA {
continue
}
sawCA = true
sum := sha256.Sum256(c.Raw)
if hex.EncodeToString(sum[:]) == wantSHA {
ca = c
break
}
}
if ca == nil {
if sawCA {
return "", errors.New("the Host's pairing port presented a certificate authority other than the one this pairing code names — refusing")
}
return "", errors.New("the Host's pairing port did not present its certificate authority, so this pairing code cannot be checked — refusing (the Host needs a newer release, or pair with --host-ca)")
}
roots := x509.NewCertPool()
roots.AddCert(ca)
inter := x509.NewCertPool()
for _, c := range chain[1:] {
if c != ca {
inter.AddCert(c)
}
}
if _, err := chain[0].Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: tcpServerName}); err != nil {
return "", fmt.Errorf("the Host's pairing port certificate is not signed by the certificate authority this pairing code names: %v — refusing", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw})), nil
}
// caSHA256 is the fingerprint a pairing code carries for a PEM CA: SHA-256 of
// the first certificate's DER, lowercase hex. "" when the PEM holds none.
func caSHA256(caPEM string) string {
certs := pemCerts(caPEM)
if len(certs) == 0 {
return ""
}
sum := sha256.Sum256(certs[0].Raw)
return hex.EncodeToString(sum[:])
}
+626
View File
@@ -0,0 +1,626 @@
//go:build linux
// The pairing code a Cloud Host's card hands out (pair_code.go): one string
// carrying the token, the enroll address and the Host CA's fingerprint, and
// the question a local machine asks before it serves a second Host
// (pair_terminal.go).
//
// Uses the fixtures of local_host_linux_test.go and local_host_tcp_linux_test.go
// (newTestCA, pairingBox, startTCPEnrollHost, homeToken), so it carries the same
// build tag.
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
// makePairCode builds a code the way a Host's card does: skp_ and base64url,
// no padding, of the JSON object fields.
func makePairCode(t *testing.T, fields map[string]any) string {
t.Helper()
raw, err := json.Marshal(fields)
if err != nil {
t.Fatal(err)
}
return pairCodePrefix + base64.RawURLEncoding.EncodeToString(raw)
}
func pairCodeFingerprint(ca *testCA) string {
sum := sha256.Sum256(ca.cert.Raw)
return hex.EncodeToString(sum[:])
}
// chainWithCA is the enroll port's certificate as a Host from this release
// serves it: the leaf, then the CA.
func chainWithCA(t *testing.T, ca *testCA) tls.Certificate {
t.Helper()
c := ca.serverCert(t, tcpServerName)
c.Certificate = append(c.Certificate, ca.cert.Raw)
return c
}
func goodCodeFields(url, fp string) map[string]any {
return map[string]any{"v": 1, "u": url, "ca": fp, "t": homeToken()}
}
// ── decoding ────────────────────────────────────────────────────────────────
func TestAPairingCodeDecodes(t *testing.T) {
fp := strings.Repeat("ab", 32)
code := makePairCode(t, goodCodeFields("https://maple.example-id.cloud.example:8810/v1/local/redeem", fp))
pc, err := decodePairCode(code)
if err != nil {
t.Fatal(err)
}
if pc.RedeemURL != "https://maple.example-id.cloud.example:8810/v1/local/redeem" || pc.CASHA256 != fp || pc.Token != homeToken() {
t.Fatalf("decoded: %+v", pc)
}
token, opts, err := pairOptsFor(code, "", "", false)
if err != nil || token != homeToken() || opts.redeemURL != pc.RedeemURL || opts.caSHA256 != fp || opts.hostCAFile != "" || opts.askSecond == nil {
t.Fatalf("pair's options from a code: %q %+v %v", token, opts, err)
}
if _, _, err := pairOptsFor(code, "", "host-ca.pem", false); err == nil {
t.Fatal("a code carries its own certificate: --host-ca beside it must be refused")
}
if tok, opts, err := pairOptsFor(homeToken(), "", "", false); err != nil || tok != homeToken() || opts.caSHA256 != "" {
t.Fatalf("a bare token passes through as it was: %q %+v %v", tok, opts, err)
}
}
// A code this bastion cannot read is refused with what was wrong, never
// with its content: a log of a failed install must not hold the token.
func TestAPairingCodeIsReadStrictly(t *testing.T) {
fp := strings.Repeat("0f", 32)
u := "https://maple.example-id.cloud.example:8810/v1/local/redeem"
with := func(edit func(m map[string]any)) string {
m := goodCodeFields(u, fp)
edit(m)
return makePairCode(t, m)
}
for name, code := range map[string]string{
"garbled": "skp_%%%not-base64%%%",
"not JSON": pairCodePrefix + base64.RawURLEncoding.EncodeToString([]byte("secret-x home")),
"wrong version": with(func(m map[string]any) { m["v"] = 2 }),
"version as text": with(func(m map[string]any) { m["v"] = "1" }),
"extra key": with(func(m map[string]any) { m["x"] = "y" }),
"missing key": with(func(m map[string]any) { delete(m, "ca") }),
"another key": with(func(m map[string]any) { delete(m, "t"); m["token"] = homeToken() }),
"http address": with(func(m map[string]any) { m["u"] = "http://maple.example-id.cloud.example:8810/v1/local/redeem" }),
"address with user": with(func(m map[string]any) { m["u"] = "https://a@maple.example-id.cloud.example/v1/local/redeem" }),
"short fingerprint": with(func(m map[string]any) { m["ca"] = fp[:63] }),
"upper fingerprint": with(func(m map[string]any) { m["ca"] = strings.ToUpper(fp) }),
"not hex": with(func(m map[string]any) { m["ca"] = strings.Repeat("zz", 32) }),
"no skb_ token": with(func(m map[string]any) { m["t"] = "secret-x" }),
"malformed token": with(func(m map[string]any) { m["t"] = "skb_bm9kb3Q" }), // base64url("nodot")
} {
_, err := decodePairCode(code)
if err == nil {
t.Errorf("%s: refused nothing", name)
continue
}
if strings.Contains(err.Error(), "secret-x") || strings.Contains(err.Error(), homeToken()) {
t.Errorf("%s: the refusal repeats the token: %v", name, err)
}
}
_, err := decodePairCode(with(func(m map[string]any) { m["v"] = 2 }))
if err == nil || !strings.Contains(err.Error(), "update the bastion") {
t.Fatalf("a newer code says to update the bastion: %v", err)
}
}
// ── the pinned CA ───────────────────────────────────────────────────────────
// SECURITY: the CA is taken from the enroll port's chain only when its
// SHA-256 is the code's, and the port's own certificate must verify under it
// for tcpServerName. A chain without the CA, another CA, or a leaf another CA
// signed are refused before the token is spent, and nothing is saved.
func TestAPairingCodePinsTheHostCAByFingerprint(t *testing.T) {
ca := newTestCA(t, "maple host CA")
impostor := newTestCA(t, "impostor")
leafByImpostor := impostor.serverCert(t, tcpServerName)
leafByImpostor.Certificate = append(leafByImpostor.Certificate, ca.cert.Raw)
for name, c := range map[string]struct {
serve tls.Certificate
fp string
want string
}{
"no CA in the chain": {ca.serverCert(t, tcpServerName), pairCodeFingerprint(ca), "did not present"},
"another CA in the chain": {chainWithCA(t, impostor), pairCodeFingerprint(ca), "other than"},
"another fingerprint": {chainWithCA(t, ca), pairCodeFingerprint(impostor), "other than"},
"a leaf another CA signed": {leafByImpostor, pairCodeFingerprint(ca), "not signed by"},
"the CA, under another name": {func() tls.Certificate {
c := ca.serverCert(t, "maple.example")
c.Certificate = append(c.Certificate, ca.cert.Raw)
return c
}(), pairCodeFingerprint(ca), "not signed by"},
} {
t.Run(name, func(t *testing.T) {
pairingBox(t)
h := startTCPEnrollHost(t, ca, c.serve, "127.0.0.1:18821")
token, opts, err := pairOptsFor(makePairCode(t, goodCodeFields(h.url, c.fp)), "", "", false)
if err != nil {
t.Fatal(err)
}
_, _, err = pairWith(token, opts)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("want a refusal naming %q, got %v", c.want, err)
}
if h.hits.Load() != 0 {
t.Fatal("the token went to an enroll port whose CA was never checked")
}
if cfgs, _ := loadPairings(); len(cfgs) != 0 {
t.Fatalf("a refused pairing saved %d pairings", len(cfgs))
}
})
}
}
// A good code pairs exactly as --redeem-url u --host-ca <that CA> t does: the
// same TCP pairing, the same CA pinned.
func TestAPairingCodePairsAsTheHostCAFileDoes(t *testing.T) {
pairingBox(t)
ca := newTestCA(t, "maple host CA")
h := startTCPEnrollHost(t, ca, chainWithCA(t, ca), "127.0.0.1:18822")
token, opts, err := pairOptsFor(makePairCode(t, goodCodeFields(h.url, pairCodeFingerprint(ca))), "", "", false)
if err != nil {
t.Fatal(err)
}
cfg, _, err := pairWith(token, opts)
if err != nil {
t.Fatal(err)
}
if !cfg.tcpTransport() || !sameCAs(cfg.CACert, ca.pem) || cfg.LaneAddr != "127.0.0.1:18822" || cfg.HostName != "home.example.test" {
t.Fatalf("the pairing: transport %q lane %q host %q", cfg.Transport, cfg.LaneAddr, cfg.HostName)
}
h.mu.Lock()
req, sni := h.last, h.sni
h.mu.Unlock()
if req.Secret != "secret-x" || req.Handle != "home" || sni != tcpServerName {
t.Fatalf("the redeem is today's: secret %q handle %q sni %q", req.Secret, req.Handle, sni)
}
// The same code again: already paired with this Host, nothing changed,
// and the error is the one `pair` exits 3 on.
before, _ := os.ReadFile(filepath.Join(mustPairingsDir(t), "home.example.test.json"))
token, opts, _ = pairOptsFor(makePairCode(t, goodCodeFields(h.url, pairCodeFingerprint(ca))), "", "", false)
_, _, err = pairWith(token, opts)
var already *alreadyPairedError
if !errors.As(err, &already) {
t.Fatalf("pairing the same Host again must be alreadyPairedError (exit %d), got %v", pairExitAlreadyPaired, err)
}
after, _ := os.ReadFile(filepath.Join(mustPairingsDir(t), "home.example.test.json"))
if string(before) != string(after) || h.hits.Load() != 1 {
t.Fatal("an already-paired Host must change nothing and spend no token")
}
// Another Host of the same name (Cloud names repeat): a different CA, so
// never "already connected".
other := newTestCA(t, "another maple")
h2 := startTCPEnrollHost(t, other, chainWithCA(t, other), "127.0.0.1:18823")
token, opts, _ = pairOptsFor(makePairCode(t, goodCodeFields(h2.url, pairCodeFingerprint(other))), "", "", false)
_, _, err = pairWith(token, opts)
if err == nil || errors.As(err, &already) || !strings.Contains(err.Error(), "different Host named") {
t.Fatalf("a same-named Host under another CA is a plain refusal, not exit 3: %v", err)
}
}
func mustPairingsDir(t *testing.T) string {
t.Helper()
d, err := pairingsDir()
if err != nil {
t.Fatal(err)
}
return d
}
// ── an enroll port that does not answer ─────────────────────────────────────
// A Cloud Host's pairing port drops every address but the one the card opened
// it to. A pair from elsewhere says so within pairAnswerTimeout, in plain
// words, instead of waiting for the system's connect timeout. Both the code
// and --host-ca paths.
func TestAnEnrollPortThatDoesNotAnswerNamesThePairingWindow(t *testing.T) {
pairingBox(t)
oldTimeout, oldDial := pairAnswerTimeout, pairDial
t.Cleanup(func() { pairAnswerTimeout, pairDial = oldTimeout, oldDial })
pairAnswerTimeout = 150 * time.Millisecond
pairDial = func(ctx context.Context, _, _ string) (net.Conn, error) {
<-ctx.Done()
return nil, ctx.Err()
}
u := "https://maple.example-id.cloud.example:8810/v1/local/redeem"
ca := newTestCA(t, "maple host CA")
check := func(name string, err error, took time.Duration) {
t.Helper()
var we *pairWindowError
if !errors.As(err, &we) {
t.Fatalf("%s: want the pairing-window message, got %v", name, err)
}
msg := err.Error()
if !strings.Contains(msg, "maple.example-id.cloud.example's pairing port didn't answer") ||
!strings.Contains(msg, "15 minutes") || !strings.Contains(msg, "another network") || !strings.Contains(msg, "Nothing was paired") {
t.Fatalf("%s: the message: %s", name, msg)
}
if took > 2*time.Second {
t.Fatalf("%s: took %s, the timeout is %s", name, took, pairAnswerTimeout)
}
}
start := time.Now()
token, opts, _ := pairOptsFor(makePairCode(t, goodCodeFields(u, pairCodeFingerprint(ca))), "", "", false)
_, _, err := pairWith(token, opts)
check("pairing code", err, time.Since(start))
start = time.Now()
_, _, err = pairWithTokenVia(homeToken(), u, writeCA(t, ca), false, false)
check("--host-ca", err, time.Since(start))
}
// ── a local machine that already serves a Host ──────────────────────────────
// fakeTerminal stands in a person at this machine: their answer, and what
// the question said. answer "" with noTerm set is a run with no terminal.
type fakeTerminal struct {
answer string
noTerm bool
asked strings.Builder
opened int
}
func (f *fakeTerminal) use(t *testing.T) {
t.Helper()
old := openTerminal
t.Cleanup(func() { openTerminal = old })
openTerminal = func() (io.Reader, io.Writer, func(), error) {
f.opened++
if f.noTerm {
return nil, nil, nil, errNoTerminal
}
return strings.NewReader(f.answer), &f.asked, func() {}, nil
}
}
func otherToken() string { return "skb_b3RoZXIuc2VjcmV0LXk" } // base64url("other.secret-y")
// SECURITY: a local machine that serves a Host adds another only on a yes
// typed at this machine's terminal. No terminal, or any answer but y or yes,
// refuses before the token is spent, and the first pairing is untouched.
// Every caller but `pair` (claim-anchor, the pairing op) keeps the old
// refusal: it has no person to ask.
func TestALocalMachineAddsASecondHostOnlyOnAYesTypedHere(t *testing.T) {
pairingBox(t)
ca := newTestCA(t, "home host CA")
home := startTCPEnrollHost(t, ca, ca.serverCert(t, tcpServerName), "127.0.0.1:18831")
if _, _, err := pairWithTokenVia(homeToken(), home.url, writeCA(t, ca), false, false); err != nil {
t.Fatal(err)
}
first, _ := os.ReadFile(filepath.Join(mustPairingsDir(t), "home.example.test.json"))
otherCA := newTestCA(t, "other host CA")
other := startTCPEnrollHost(t, otherCA, chainWithCA(t, otherCA), "127.0.0.1:18832")
other.set(func(h *tcpEnrollHost) {
h.edit = func(ans map[string]any) { ans["host_handle"], ans["host_name"] = "other", "other.example.test" }
})
code := makePairCode(t, map[string]any{"v": 1, "u": other.url, "ca": pairCodeFingerprint(otherCA), "t": otherToken()})
pairOther := func() error {
token, opts, err := pairOptsFor(code, "", "", false)
if err != nil {
t.Fatal(err)
}
_, _, err = pairWith(token, opts)
return err
}
untouched := func(why string) {
t.Helper()
now, _ := os.ReadFile(filepath.Join(mustPairingsDir(t), "home.example.test.json"))
cfgs, _ := loadPairings()
if string(now) != string(first) {
t.Fatalf("%s: the first pairing changed", why)
}
if why != "yes" && (len(cfgs) != 1 || other.hits.Load() != 0) {
t.Fatalf("%s: %d pairings, %d redeems", why, len(cfgs), other.hits.Load())
}
}
// No terminal: refused, and it says where to run it instead.
ft := &fakeTerminal{noTerm: true}
ft.use(t)
if err := pairOther(); err == nil || !strings.Contains(err.Error(), "terminal window on this computer") {
t.Fatalf("no terminal must refuse in plain words: %v", err)
}
untouched("no terminal")
// Anything but y or yes.
for _, a := range []string{"\n", "n\n", "no\n", "yess\n", "sure\n", ""} {
ft := &fakeTerminal{answer: a}
ft.use(t)
if err := pairOther(); err == nil || !strings.Contains(err.Error(), "stays the only Host") {
t.Fatalf("answer %q must refuse: %v", a, err)
}
if !strings.Contains(ft.asked.String(), "already serves your Host home.example.test. Add other as well?") {
t.Fatalf("the question: %q", ft.asked.String())
}
untouched("answer " + a)
}
// The callers with no person to ask keep the refusal, and ask nobody.
ft = &fakeTerminal{answer: "y\n"}
ft.use(t)
var second *localSecondHostError
if _, _, err := pairWithTokenVia(otherToken(), other.url, writeCA(t, otherCA), false, false); !errors.As(err, &second) {
t.Fatalf("pairWithTokenVia (no person to ask) must keep the refusal: %v", err)
}
if ft.opened != 0 {
t.Fatal("a caller with no person to ask opened the terminal")
}
untouched("no asker")
// A typed yes adds it beside the first.
ft = &fakeTerminal{answer: "Yes\n"}
ft.use(t)
if err := pairOther(); err != nil {
t.Fatal(err)
}
cfgs, _ := loadPairings()
if len(cfgs) != 2 || pairingFor(cfgs, "other.example.test") == nil {
t.Fatalf("after yes: %d pairings", len(cfgs))
}
untouched("yes")
}
// secondHostStdinChild set to 1 makes this test binary the child of
// TestTheSecondHostAnswerIsNeverReadFromStandardInput.
const secondHostStdinChild = "PRIVATAE_TEST_SECOND_HOST_STDIN_CHILD"
// SECURITY: the answer to the second-Host question comes from the controlling
// terminal, never from standard input. The installers hand the pairing code to
// `pair -` on standard input, so a "y" piped in after the code must not add a
// Host. The real openTerminal runs in a child with no controlling terminal (a
// session of its own) and "y" waiting on its standard input: it must find no
// terminal, and leave standard input unread.
func TestTheSecondHostAnswerIsNeverReadFromStandardInput(t *testing.T) {
if os.Getenv(secondHostStdinChild) == "1" {
yes, err := confirmAddHost("home.example.test", "other")
rest, _ := io.ReadAll(os.Stdin)
fmt.Printf("child: yes=%v refused=%v stdin-left=%q\n", yes, err != nil && strings.Contains(err.Error(), "no terminal"), rest)
os.Exit(0)
}
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(self, "-test.run=^TestTheSecondHostAnswerIsNeverReadFromStandardInput$")
cmd.Env = append(os.Environ(), secondHostStdinChild+"=1")
cmd.Stdin = strings.NewReader("y\n")
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("the child: %v\n%s", err, out)
}
if !strings.Contains(string(out), `child: yes=false refused=true stdin-left="y\n"`) {
t.Fatalf("a y on standard input must neither answer the question nor be read: %s", out)
}
}
// The refusal names this system's own way to a networked bastion, and says
// that the Host already here would have to be paired again.
func TestTheLocalRefusalSaysWhatANetworkedReinstallCosts(t *testing.T) {
msg := localOneHostRefusal("home.example.test")
if !strings.Contains(msg, "install.sh --networked") || strings.Contains(msg, "install.ps1") {
t.Fatalf("on linux it names install.sh: %s", msg)
}
if !strings.Contains(msg, "home.example.test has to be paired with it again") || !strings.Contains(msg, "answer yes at this computer's terminal") {
t.Fatalf("the refusal: %s", msg)
}
}
// ── what the Host's card actually makes ─────────────────────────────────────
// Made by the C-Series Host's own pairing_code (bastion_enroll.py) from the CA
// below and an invented token, for an invented address: compact JSON with the
// keys v, u, ca and t in base64url without padding. The bastion must read
// exactly that, and the fingerprint must be the one caSHA256 computes from
// the same CA, or a real code would never pin.
const hostMadePairCode = "skp_eyJ2IjoxLCJ1IjoiaHR0cHM6Ly9tYXBsZS5leGFtcGxlLWlkLmNsb3VkLmV4YW1wbGU6ODgxMC92MS9sb2NhbC9yZWRlZW0iLCJjYSI6IjcyMzNkOWU4ODU5MzIyZjE1YTJiNmMzYWIyMmZiNjMxNGI4M2I0Njg2ZmNhYmZiYjRmNGU1NDE5NDA1MTFkMDciLCJ0Ijoic2tiX2JXRndiR1V1YzJWamNtVjBMV2x1ZG1WdWRHVmtMVEF3TURFIn0"
const hostMadePairCodeCA = `-----BEGIN CERTIFICATE-----
MIIBTTCB9KADAgECAgIQkjAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhleGFtcGxl
IGludmVudGVkIGhvc3QgQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNTEyMDgwMDAw
MDBaMCMxITAfBgNVBAMMGGV4YW1wbGUgaW52ZW50ZWQgaG9zdCBDQTBZMBMGByqG
SM49AgEGCCqGSM49AwEHA0IABA32JMrh4sMMLvNNoSBq5FgXFgHd/2n71Qk9ayje
Kj9rdt+Q3CFYWgtCqFHc6ZLUfXexKkdWvnET3px2hGqZWbKjFjAUMBIGA1UdEwEB
/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwIDSAAwRQIhAIQ6sKtiqxcAQTPq7+toiEl0
qrWb4CKKB2UtvcQoC9YwAiBYGKbmvG9ZszE8X7uKUGexjP9x6lDOwqfmnLoruuVg
Kg==
-----END CERTIFICATE-----
`
func TestACodeTheHostMadeDecodesAndPins(t *testing.T) {
pc, err := decodePairCode(hostMadePairCode)
if err != nil {
t.Fatal(err)
}
if pc.RedeemURL != "https://maple.example-id.cloud.example:8810/v1/local/redeem" ||
pc.Token != "skb_bWFwbGUuc2VjcmV0LWludmVudGVkLTAwMDE" { // base64url("maple.secret-invented-0001")
t.Fatalf("decoded: %+v", pc)
}
if got := caSHA256(hostMadePairCodeCA); got != pc.CASHA256 {
t.Fatalf("the Host's fingerprint %s is not caSHA256 of its CA (%s)", pc.CASHA256, got)
}
if h, _, err := decodeToken(pc.Token); err != nil || h != "maple" {
t.Fatalf("the token's handle: %q %v", h, err)
}
}
// SECURITY: a key given twice is an extra key. A map would keep only the
// last, so two codes that differ in which token they carry could both read as
// "exactly v, u, ca and t".
func TestAPairingCodeWithAKeyGivenTwiceIsRefused(t *testing.T) {
fp := strings.Repeat("0f", 32)
u := "https://maple.example-id.cloud.example:8810/v1/local/redeem"
raw := `{"v":1,"u":"` + u + `","ca":"` + fp + `","t":"` + otherToken() + `","t":"` + homeToken() + `"}`
if _, err := decodePairCode(pairCodePrefix + base64.RawURLEncoding.EncodeToString([]byte(raw))); err == nil {
t.Fatal("a code naming t twice was read")
}
once := `{"v":1,"u":"` + u + `","ca":"` + fp + `","t":"` + homeToken() + `"}`
if _, err := decodePairCode(pairCodePrefix + base64.RawURLEncoding.EncodeToString([]byte(once))); err != nil {
t.Fatalf("the same code with t once: %v", err)
}
}
// pinnedChainCert is a certificate for the pinned-chain cases: signed by
// parent (self-signed when parent is nil), a CA or not, valid from nb to na.
func pinnedChainCert(t *testing.T, cn string, isCA bool, nb, na time.Time, parent *x509.Certificate, parentKey *ecdsa.PrivateKey, dns ...string) (*x509.Certificate, *ecdsa.PrivateKey) {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: cn},
NotBefore: nb, NotAfter: na, DNSNames: dns, BasicConstraintsValid: true, IsCA: isCA}
if isCA {
tmpl.KeyUsage = x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature
} else {
tmpl.KeyUsage, tmpl.ExtKeyUsage = x509.KeyUsageDigitalSignature, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
}
signer, signKey := tmpl, key
if parent != nil {
signer, signKey = parent, parentKey
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, signer, &key.PublicKey, signKey)
if err != nil {
t.Fatal(err)
}
c, _ := x509.ParseCertificate(der)
return c, key
}
// SECURITY: the cases of the pinned chain the TLS tests do not reach. A
// certificate with the code's fingerprint that is not a CA, and a CA that has
// expired, are refused; the pinned CA anywhere after the leaf, beside other
// certificates, is the one returned.
func TestThePinnedChainTakesOnlyAValidCAWithTheFingerprint(t *testing.T) {
now := time.Now()
fpOf := func(c *x509.Certificate) string { s := sha256.Sum256(c.Raw); return hex.EncodeToString(s[:]) }
ca, caKey := pinnedChainCert(t, "maple host CA", true, now.Add(-time.Hour), now.Add(24*time.Hour), nil, nil)
leaf, _ := pinnedChainCert(t, "leaf", false, now.Add(-time.Hour), now.Add(time.Hour), ca, caKey, tcpServerName)
other, _ := pinnedChainCert(t, "another CA", true, now.Add(-time.Hour), now.Add(24*time.Hour), nil, nil)
got, err := verifyPinnedChain([]*x509.Certificate{leaf, other, ca}, fpOf(ca))
if err != nil || caSHA256(got) != fpOf(ca) {
t.Fatalf("the pinned CA after another certificate: %v", err)
}
// A self-signed certificate that is not a CA, signing the leaf.
notCA, notCAKey := pinnedChainCert(t, "not a CA", false, now.Add(-time.Hour), now.Add(24*time.Hour), nil, nil)
leaf2, _ := pinnedChainCert(t, "leaf", false, now.Add(-time.Hour), now.Add(time.Hour), notCA, notCAKey, tcpServerName)
if _, err := verifyPinnedChain([]*x509.Certificate{leaf2, notCA}, fpOf(notCA)); err == nil {
t.Fatal("a certificate with the fingerprint that is not a CA was pinned")
}
// An expired CA, with a leaf it signed that is still in its dates.
old, oldKey := pinnedChainCert(t, "expired host CA", true, now.Add(-48*time.Hour), now.Add(-time.Hour), nil, nil)
leaf3, _ := pinnedChainCert(t, "leaf", false, now.Add(-2*time.Hour), now.Add(time.Hour), old, oldKey, tcpServerName)
if _, err := verifyPinnedChain([]*x509.Certificate{leaf3, old}, fpOf(old)); err == nil {
t.Fatal("an expired CA was pinned")
}
if _, err := verifyPinnedChain([]*x509.Certificate{leaf}, fpOf(ca)); err == nil {
t.Fatal("a chain of the leaf alone was accepted")
}
}
// Exit 3 means the same Host, so the same CA, on every TCP pairing: a
// --host-ca pair to a Host of a name already paired here under another CA is
// a plain failure, as the pairing code's is.
func TestHostCAFileWithASameNamedHostUnderAnotherCAIsNotAlreadyPaired(t *testing.T) {
pairingBox(t)
ca := newTestCA(t, "home host CA")
h := startTCPEnrollHost(t, ca, ca.serverCert(t, tcpServerName), "127.0.0.1:18841")
if _, _, err := pairWithTokenVia(homeToken(), h.url, writeCA(t, ca), false, false); err != nil {
t.Fatal(err)
}
var already *alreadyPairedError
if _, _, err := pairWithTokenVia(homeToken(), h.url, writeCA(t, ca), false, false); !errors.As(err, &already) {
t.Fatalf("the same Host under its own CA is already paired (exit 3): %v", err)
}
other := newTestCA(t, "another home")
h2 := startTCPEnrollHost(t, other, other.serverCert(t, tcpServerName), "127.0.0.1:18842")
_, _, err := pairWithTokenVia(homeToken(), h2.url, writeCA(t, other), false, false)
if err == nil || errors.As(err, &already) || !strings.Contains(err.Error(), "different Host named") {
t.Fatalf("a same-named Host under another CA is a plain refusal, not exit 3: %v", err)
}
if h2.hits.Load() != 0 {
t.Fatal("the refusal spent the other Host's token")
}
}
// SECURITY: the name in the question is the token's handle, which whoever
// made the token chose. A control character or escape sequence in it reaches
// the person's screen escaped, so it cannot rewrite the question.
func TestTheSecondHostQuestionEscapesTheTokensName(t *testing.T) {
ft := &fakeTerminal{answer: "n\n"}
ft.use(t)
if _, err := confirmAddHost("home.example.test", "evil\r\x1b[2K\x1b[1Ainnocent"); err != nil {
t.Fatal(err)
}
q := ft.asked.String()
if strings.ContainsAny(q, "\x1b\r") {
t.Fatalf("the question carries the handle's control bytes raw: %q", q)
}
if !strings.Contains(q, `Add "evil\r\x1b[2K\x1b[1Ainnocent" as well?`) {
t.Fatalf("the question: %q", q)
}
ft = &fakeTerminal{answer: "n\n"}
ft.use(t)
if _, err := confirmAddHost("home.example.test", "maple"); err != nil || !strings.Contains(ft.asked.String(), "Add maple as well?") {
t.Fatalf("a plain name is shown as it is: %q %v", ft.asked.String(), err)
}
}
// The name the redeem answers with is checked again after the redeem (the
// token's handle can differ from it). There too, the same name under another
// CA is a plain failure, never exit 3.
func TestARedeemAnsweringAPairedNameUnderAnotherCAIsNotAlreadyPaired(t *testing.T) {
pairingBox(t)
ca := newTestCA(t, "home host CA")
h := startTCPEnrollHost(t, ca, ca.serverCert(t, tcpServerName), "127.0.0.1:18843")
if _, _, err := pairWithTokenVia(homeToken(), h.url, writeCA(t, ca), false, false); err != nil {
t.Fatal(err)
}
other := newTestCA(t, "another home")
h2 := startTCPEnrollHost(t, other, chainWithCA(t, other), "127.0.0.1:18844")
// otherToken's handle is "other"; the Host answers home.example.test.
token, opts, err := pairOptsFor(makePairCode(t, map[string]any{"v": 1, "u": h2.url, "ca": pairCodeFingerprint(other), "t": otherToken()}), "", "", false)
if err != nil {
t.Fatal(err)
}
opts.askSecond = func(string, string) (bool, error) { return true, nil }
_, _, err = pairWith(token, opts)
var already *alreadyPairedError
if err == nil || errors.As(err, &already) || !strings.Contains(err.Error(), "different Host named home.example.test") {
t.Fatalf("a redeem naming a paired Host under another CA is a plain refusal, not exit 3: %v", err)
}
if cfgs, _ := loadPairings(); len(cfgs) != 1 {
t.Fatalf("%d pairings after the refusal", len(cfgs))
}
}
+8
View File
@@ -149,11 +149,19 @@ func redeemOverTCP(u *url.URL, caPEM string, body []byte) (tcpRedeemResp, error)
if err != nil { if err != nil {
return rr, fmt.Errorf("--host-ca: %w", err) return rr, fmt.Errorf("--host-ca: %w", err)
} }
// The dial is dialEnrollPort's: an enroll port that does not answer in
// pairAnswerTimeout gets the pairing window's plain message, not a wait
// for the system's connect timeout.
httpc := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{ httpc := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{
Proxy: nil, TLSClientConfig: tc, ForceAttemptHTTP2: false, Proxy: nil, TLSClientConfig: tc, ForceAttemptHTTP2: false,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dialEnrollPort(ctx, u) },
}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} }, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := httpc.Post(u.String(), "application/json", bytes.NewReader(body)) resp, err := httpc.Post(u.String(), "application/json", bytes.NewReader(body))
if err != nil { if err != nil {
var we *pairWindowError
if errors.As(err, &we) {
return rr, we
}
return rr, fmt.Errorf("redeem: %w", err) return rr, fmt.Errorf("redeem: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
+66
View File
@@ -0,0 +1,66 @@
package main
// pair_terminal.go: the one question `pair` asks a person, on a local machine
// that already serves a Host: whether to add another.
//
// A local bastion used to serve one Host, full stop, because on a single-user
// machine a second Host appearing meant someone else had got a token onto the
// box. A person who uses two Hosts (a Cloud machine beside the platform one)
// could not pair the second at all. Now the second is added when the person at
// this machine says so, and only then: the answer is read from the controlling
// terminal, never from standard input, so a token piped in by a script or an
// installer (which feeds the pairing code through standard input) cannot add a
// Host by itself. With no terminal there is nobody to ask, and the pair is
// refused.
import (
"bufio"
"errors"
"fmt"
"io"
"strconv"
"strings"
)
// errNoTerminal: this process has no controlling terminal to ask on.
var errNoTerminal = errors.New("no terminal")
// openTerminal opens the controlling terminal for one question: a reader for
// the answer, a writer for the question, and a close. A variable so a test can
// stand in a person; nothing else assigns it.
var openTerminal = openControllingTerminal
// terminalSafeName is a Host name as the question shows it. newHost is the
// handle the token carries, which nothing has checked yet: whoever made the
// token chose it, and a control character or an escape sequence in it could
// rewrite the question on the person's screen. A plain host name is shown as
// it is; anything else is quoted, every such byte escaped.
func terminalSafeName(name string) string {
if validHostName(name) {
return name
}
return strconv.QuoteToASCII(name)
}
// confirmAddHost asks whether to add newHost beside serving, the Host this
// local machine already serves. True only for a typed y or yes.
func confirmAddHost(serving, newHost string) (bool, error) {
in, out, closeTerm, err := openTerminal()
if err != nil {
return false, fmt.Errorf("this computer already serves your Host %s, and adding another needs a yes typed "+
"at this computer, but this run has no terminal to ask in. Run the same command in a terminal "+
"window on this computer; nothing was paired", serving)
}
defer closeTerm()
fmt.Fprintf(out, "This computer already serves your Host %s. Add %s as well? It gets a pairing of its own,\n"+
"and `privatae-bastion ui` shows what each Host may do here. [y/N] ", terminalSafeName(serving), terminalSafeName(newHost))
line, err := bufio.NewReader(io.LimitReader(in, 256)).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return false, fmt.Errorf("could not read the answer (%v); nothing was paired", err)
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true, nil
}
return false, nil
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package main
import (
"io"
"os"
)
// openControllingTerminal opens /dev/tty, this process's controlling terminal
// whatever its standard input is. It fails with no terminal (a service, a
// pipe from `ssh host cmd` without -t, cron).
func openControllingTerminal() (io.Reader, io.Writer, func(), error) {
f, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return nil, nil, nil, errNoTerminal
}
return f, f, func() { _ = f.Close() }, nil
}
+26
View File
@@ -0,0 +1,26 @@
//go:build windows
package main
import (
"io"
"os"
)
// openControllingTerminal opens the console this process is attached to,
// CONIN$ for the answer and CONOUT$ for the question, whatever its standard
// input is: install.ps1 pipes the pairing code into `pair -`, and the person
// still answers in the PowerShell window. It fails with no console (a
// service, a scheduled task with no window).
func openControllingTerminal() (io.Reader, io.Writer, func(), error) {
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
if err != nil {
return nil, nil, nil, errNoTerminal
}
out, err := os.OpenFile("CONOUT$", os.O_RDWR, 0)
if err != nil {
_ = in.Close()
return nil, nil, nil, errNoTerminal
}
return in, out, func() { _ = in.Close(); _ = out.Close() }, nil
}
+24 -1
View File
@@ -288,17 +288,40 @@ func serviceArgs(spec harnessSpec) []string {
// serviceOwnerRefusal: on an anchored box a member pairing may not replace or // serviceOwnerRefusal: on an anchored box a member pairing may not replace or
// stop a slot another host built. The anchor pairing may (its user is the // stop a slot another host built. The anchor pairing may (its user is the
// machine's admin), and an unanchored local box serves one host. // machine's admin). A local box has no anchor to be that admin, and serves a
// second host only on its person's yes (pair_terminal.go); once it serves two,
// each host changes only the slots it built. With one host there is nobody to
// keep apart (a host renamed since it built a slot still owns it), and
// pairings that cannot be read are treated as two.
func serviceOwnerRefusal(self string, r *serviceRecord) string { func serviceOwnerRefusal(self string, r *serviceRecord) string {
if r == nil || r.Owner == "" || r.Owner == self { if r == nil || r.Owner == "" || r.Owner == self {
return "" return ""
} }
if boxMode() == modeLocal {
if !localServesSeveralHosts() {
return ""
}
return "refused: slot '" + r.Name + "' was built by " + r.Owner + "; on a local machine that serves several Hosts each changes only the servers it built"
}
if why := anchorOnly(self, "changing a server another host built"); why != "" { if why := anchorOnly(self, "changing a server another host built"); why != "" {
return "refused: slot '" + r.Name + "' was built by " + r.Owner + "; " + why return "refused: slot '" + r.Name + "' was built by " + r.Owner + "; " + why
} }
return "" return ""
} }
// localServesSeveralHosts: this is a local machine that serves more than one
// Host (the second added on its person's yes, pair_terminal.go), so what one
// Host built or started here is kept from the others. Pairings that cannot be
// read count as several. False on a networked machine, whose anchor rule
// (anchorOnly) keeps its Hosts apart.
func localServesSeveralHosts() bool {
if boxMode() != modeLocal {
return false
}
cfgs, err := loadPairings()
return err != nil || len(cfgs) > 1
}
func serviceFail(res map[string]interface{}, msg string) ([]byte, int) { func serviceFail(res map[string]interface{}, msg string) ([]byte, int) {
res["ok"], res["error"] = false, msg res["ok"], res["error"] = false, msg
b, _ := json.Marshal(res) b, _ := json.Marshal(res)
+41 -2
View File
@@ -495,10 +495,24 @@ func TestServiceOwnerRuleOnAnAnchoredBox(t *testing.T) {
if why := serviceOwnerRefusal(tMember, &serviceRecord{Name: "forge", Owner: tMember}); why != "" { if why := serviceOwnerRefusal(tMember, &serviceRecord{Name: "forge", Owner: tMember}); why != "" {
t.Fatalf("the builder may change its own server: %s", why) t.Fatalf("the builder may change its own server: %s", why)
} }
// Local box: one host, no owner rule to apply. // Local box with one host: nobody to keep apart, as before.
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := (&Config{HostName: tMember, ListenAddr: "127.0.0.1:7878"}).save(); err != nil {
t.Fatal(err)
}
if why := serviceOwnerRefusal(tMember, r); why != "" { if why := serviceOwnerRefusal(tMember, r); why != "" {
t.Fatalf("a local bastion serves one host: %s", why) t.Fatalf("a local machine with one host applies no owner rule: %s", why)
}
// Local box serving a second host (added on its person's yes): each host
// changes only the servers it built.
if err := (&Config{HostName: tAnchor, ListenAddr: "127.0.0.1:7879"}).save(); err != nil {
t.Fatal(err)
}
if why := serviceOwnerRefusal(tMember, r); why == "" || !strings.Contains(why, "built by "+tAnchor) {
t.Fatalf("on a local machine serving two hosts one must not change the other's server: %q", why)
}
if why := serviceOwnerRefusal(tMember, &serviceRecord{Name: "forge", Owner: tMember}); why != "" {
t.Fatalf("on a local machine a host changes its own server: %s", why)
} }
} }
@@ -852,3 +866,28 @@ func TestSpliceUnixCarriesBothWays(t *testing.T) {
} }
_ = a.Close() _ = a.Close()
} }
// A coder run on a local machine that serves two Hosts is stopped only by
// the Host that started it, as a server is changed only by the Host that
// built it. With one Host there is nobody to keep apart.
func TestCoderRunOwnerRuleOnALocalBoxServingTwoHosts(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := (&Config{HostName: tMember, ListenAddr: "127.0.0.1:7878"}).save(); err != nil {
t.Fatal(err)
}
if why := coderOwnerRefusal(tMember, tAnchor); why != "" {
t.Fatalf("a local machine with one host applies no owner rule: %s", why)
}
if err := (&Config{HostName: tAnchor, ListenAddr: "127.0.0.1:7879"}).save(); err != nil {
t.Fatal(err)
}
if why := coderOwnerRefusal(tMember, tAnchor); why == "" || !strings.Contains(why, "started by "+tAnchor) {
t.Fatalf("on a local machine serving two hosts one must not stop the other's run: %q", why)
}
if why := coderOwnerRefusal(tMember, ""); why == "" {
t.Fatal("on a local machine serving two hosts a run of unknown owner proves nothing about self")
}
if why := coderOwnerRefusal(tMember, tMember); why != "" {
t.Fatalf("a host stops its own run: %s", why)
}
}
+58
View File
@@ -0,0 +1,58 @@
# Runs install.ps1's -Pair block against a stand-in bastion, with the Windows
# cmdlets it calls stood in by functions that log their call. pwsh on Linux:
# pwsh -NoProfile -File testdata/install_ps1_pair.ps1 ../deploy/install.ps1
# Exits 0 when every case holds, and prints the first that does not.
param([string]$Script)
$ErrorActionPreference = 'Stop'
$src = Get-Content -Raw $Script
$m = [regex]::Match($src, '(?ms)^if \(\$Pair\) \{\r?\n.*?^\}\r?\n')
if (-not $m.Success) { Write-Output 'FAIL: install.ps1 has no top-level if ($Pair) { … } block'; exit 1 }
$block = [scriptblock]::Create($m.Value)
$dir = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $dir | Out-Null
$log = Join-Path $dir 'log'
$exe = Join-Path $dir 'privatae-bastion'
Set-Content -Path $exe -Value ("#!/bin/sh`necho ""ARGV `$*"" >> '$log'`n" +
"if [ ""`$1"" = pair ]; then sed 's/^/STDIN /' >> '$log'; exit `$FAKE_RC; fi`necho status-ok`n")
chmod 755 $exe
function Stop-ScheduledTask { $script:calls += 'stop-task' }
function Start-ScheduledTask { $script:calls += 'start-task' }
function Restart-Service { $script:calls += 'restart-service' }
function Start-Process { $script:calls += 'start-process' }
function Stop-Process { $script:calls += 'stop-process' }
function Get-Process { @() }
function Start-Sleep { }
function Write-Host { param($Object) $script:out += "$Object`n" }
function Run-Case([string]$rc, [bool]$networked) {
$script:calls = @(); $script:out = ''
if (Test-Path $log) { Remove-Item $log }
$env:FAKE_RC = $rc
$Pair = 'skp_INVENTEDCODE'; $Networked = $networked
$threw = $null
try { . $block } catch { $threw = $_.Exception.Message }
$l = if (Test-Path $log) { Get-Content -Raw $log } else { '' }
return @{ log = $l; calls = ($script:calls -join ','); out = $script:out; threw = $threw }
}
function Fail([string]$why) { Write-Output "FAIL: $why"; exit 1 }
$r = Run-Case '0' $false
if ($r.threw) { Fail "rc 0 threw: $($r.threw)" }
if ($r.log -notmatch '(?m)^ARGV pair -$' -or $r.log -match 'ARGV .*skp_') { Fail "the code must reach 'pair -' on stdin, never argv: $($r.log)" }
if ($r.log -notmatch '(?m)^STDIN skp_INVENTEDCODE\r?$') { Fail "pair's stdin: $($r.log)" }
if ($r.calls -ne 'stop-task,start-task') { Fail "per-user: stop, then start the logon task: $($r.calls)" }
if ($r.out -notmatch 'This computer is paired\. The bastion is running, and starts again each time you sign in to Windows\.') { Fail "the ending: $($r.out)" }
$r = Run-Case '3' $false
if ($r.threw -or $r.out -notmatch 'already paired with this Host') { Fail "exit 3 is success: $($r.threw) $($r.out)" }
$r = Run-Case '1' $false
if (-not $r.threw -or $r.calls -ne '') { Fail "a failed pair must throw before starting anything: $($r.threw) $($r.calls)" }
$r = Run-Case '0' $true
if ($r.threw -or $r.calls -ne 'restart-service' -or $r.out -notmatch 'Windows service PrivataeBastion') { Fail "networked restarts the service: $($r.threw) $($r.calls)" }
Remove-Item -Recurse -Force $dir
Write-Output 'ok: install.ps1 -Pair pairs over stdin, treats exit 3 as success, stops on a failure, restarts task or service'
+1 -1
View File
@@ -888,7 +888,7 @@ func (s *uiSetup) anchorChoose(w http.ResponseWriter, r *http.Request) {
Class: "bad", Class: "bad",
Command: setModeCommandHint(), Command: setModeCommandHint(),
How: adminHowTo() + ", then claim the anchor.", How: adminHowTo() + ", then claim the anchor.",
Why: "This machine is in local mode, where it serves one host and needs no anchor. " + Why: "This machine is in local mode, where it serves only the Hosts you said yes to here and needs no anchor. " +
"An anchor only means something on a networked bastion.", "An anchor only means something on a networked bastion.",
}) })
return return
+1 -1
View File
@@ -1 +1 @@
0.56.0 0.57.0
+68 -3
View File
@@ -21,7 +21,17 @@
# OWN account keeps the same profile — but "Run as different user" does not, and # OWN account keeps the same profile — but "Run as different user" does not, and
# the bastion would then read an empty directory and refuse to see its pairings. # the bastion would then read an empty directory and refuse to see its pairings.
# This is the Windows shape of the same trap `sudo -H` exists for on Unix. # This is the Windows shape of the same trap `sudo -H` exists for on Unix.
param([switch]$Networked) #
# -Pair '<code>': pair as well, with the code from your Host's "Set up a
# bastion" card, and start the bastion so it serves now and at every sign-in.
# The one line the card gives is
# & ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…'
# The code reaches the bastion on standard input, never as an argument, so it
# is not in the process list. On a machine whose bastion is already the
# Windows service (installed with -Networked, or by `npx cseries`), -Pair pairs
# that service from an Administrator PowerShell instead of installing a second
# bastion beside it.
param([switch]$Networked, [string]$Pair)
# #
# Brought into the repo (this file previously existed ONLY as bytes in the # Brought into the repo (this file previously existed ONLY as bytes in the
# edge webroot, so what customers piped to `iex` had no review history) and # edge webroot, so what customers piped to `iex` had no review history) and
@@ -35,6 +45,18 @@ $base = 'https://dl.privatae.ai'
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') {'arm64'} else {'amd64'} $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') {'arm64'} else {'amd64'}
$key = "windows-$arch.exe" $key = "windows-$arch.exe"
# Checked before anything is downloaded. Never echoed: it holds the token.
if ($Pair -and -not $Pair.StartsWith('skp_')) {
throw "-Pair takes the pairing code from your Host's card, which starts skp_. Nothing was installed."
}
# A machine whose bastion already runs as the Windows service is paired
# through that service. A per-user install beside it would be a second
# bastion, which the service's own guard refuses to pair anyway.
if ($Pair -and -not $Networked -and (Get-Service -Name 'PrivataeBastion' -ErrorAction SilentlyContinue)) {
$Networked = $true
Write-Host "This computer's bastion runs as the Windows service PrivataeBastion; pairing that service."
}
if ($Networked) { if ($Networked) {
# Checked before anything is downloaded, so a refusal leaves nothing behind # Checked before anything is downloaded, so a refusal leaves nothing behind
# and is one clear sentence at the moment it can be acted on. # and is one clear sentence at the moment it can be acted on.
@@ -43,7 +65,7 @@ if ($Networked) {
if (-not $isAdmin) { if (-not $isAdmin) {
throw ("-Networked needs an elevated terminal. A networked bastion runs as a Windows service, " + throw ("-Networked needs an elevated terminal. A networked bastion runs as a Windows service, " +
"and whoever administers the machine is what stands behind it.`n" + "and whoever administers the machine is what stands behind it.`n" +
"Close this window, right-click your terminal, choose 'Run as administrator', and run it again.") "Close this window, right-click your terminal, choose 'Run as administrator', and run the same line again.")
} }
} }
@@ -223,11 +245,54 @@ if ($Networked) {
Write-Host "" Write-Host ""
Write-Host "The bastion runs as the Windows service 'PrivataeBastion' (LocalSystem). It starts" Write-Host "The bastion runs as the Windows service 'PrivataeBastion' (LocalSystem). It starts"
Write-Host "serving as soon as this machine is paired." Write-Host "serving as soon as this machine is paired."
if (-not $Pair) {
Write-Host "Next, anchor this machine to the Host that should govern it:" Write-Host "Next, anchor this machine to the Host that should govern it:"
Write-Host " privatae-bastion claim-anchor" Write-Host " privatae-bastion claim-anchor"
Write-Host " (add --host <name> if this machine already serves several Hosts)" Write-Host " (add --host <name> if this machine already serves several Hosts)"
Write-Host "Every host paired after the anchor waits, pending, until that Host approves it." Write-Host "Every host paired after the anchor waits, pending, until that Host approves it."
} else { }
} elseif (-not $Pair) {
Write-Host "Open a NEW terminal, then: privatae-bastion pair <token>" Write-Host "Open a NEW terminal, then: privatae-bastion pair <token>"
} }
if ($Pair) {
Write-Host ""
# The full path, never the name on PATH, which this window may not have
# picked up yet. The code goes on standard input: `pair -` reads its first
# line. `pair` exits 3 when this computer is already paired with that Host,
# which is success here too. On a computer that already serves another
# Host, `pair` asks in this window whether to add this one.
$Pair | & $exe pair -
$rc = $LASTEXITCODE
if ($rc -eq 0) { $done = 'This computer is paired.' }
elseif ($rc -eq 3) { $done = 'This computer was already paired with this Host; nothing changed there.' }
else { throw "Pairing did not finish (the reason is above). The bastion is installed; nothing else changed." }
# A running bastion reads a new pairing at its next start, so it is
# restarted, not just started.
if ($Networked) {
Restart-Service -Name 'PrivataeBastion'
$how = 'The bastion runs as the Windows service PrivataeBastion, and starts with Windows.'
} else {
# Every copy is stopped first, from wherever it was started (a terminal,
# an older install, a detached copy after a self-update): each would
# hold this machine's first control port, 127.0.0.1:7878, and the new
# one would fail to start beside it.
Stop-ScheduledTask -TaskName 'PrivataeBastion' -ErrorAction SilentlyContinue
Get-Process -Name 'privatae-bastion' -ErrorAction SilentlyContinue | ForEach-Object {
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
}
try {
Start-ScheduledTask -TaskName 'PrivataeBastion'
$how = 'The bastion is running, and starts again each time you sign in to Windows.'
} catch {
Start-Process -FilePath $exe -ArgumentList 'run' -WindowStyle Hidden
$how = "The bastion is running until you sign out (its sign-in task could not be started: $($_.Exception.Message))."
}
}
Start-Sleep -Seconds 3
& $exe status
Write-Host ""
Write-Host "$done $how"
}
Write-Host "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt" Write-Host "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt"
+213 -2
View File
@@ -6,7 +6,33 @@
# sudo -H privatae-bastion pair <token> # sudo -H privatae-bastion pair <token>
# Run as your own user (a desktop), it installs the LOCAL bastion for that user. # Run as your own user (a desktop), it installs the LOCAL bastion for that user.
# #
# PRIVATAE_PAIR_CODE='<code>' pairs as well, with the code from your Host's "Set
# up a bastion" card, and starts the bastion so it serves now and after a
# restart. The one line the card gives is
# curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh
#
set -e set -e
# The pairing code comes from the environment, never from an argument: an
# argument sits in this shell's command line for the whole install, where any
# user of this machine can read it with ps, and one at the same address could
# redeem it first. A process's environment is readable only by its owner and
# root. It is read here, before this script starts any program, and unset at
# once, so no program it starts inherits it. Never echoed: it holds the token.
pair_code=${PRIVATAE_PAIR_CODE-}
pair_given=${PRIVATAE_PAIR_CODE+1}
unset PRIVATAE_PAIR_CODE
if [ -n "$pair_given" ]; then
# skp_ and base64url: anything else is a line that lost part of the code.
case "$pair_code" in
skp_*[!A-Za-z0-9_-]* | skp_) pair_code="" ;;
skp_?*) ;;
*) pair_code="" ;;
esac
if [ -z "$pair_code" ]; then
echo "ERROR: PRIVATAE_PAIR_CODE is not a pairing code. Copy the whole line from your Host's card again (the code starts skp_). Nothing was installed." >&2
exit 2
fi
fi
# dl.privatae.ai, not privatae.ai/bastion: the download host is served from each # dl.privatae.ai, not privatae.ai/bastion: the download host is served from each
# edge's OWN local disk, so installs survive losing the platform datacentre, and # edge's OWN local disk, so installs survive losing the platform datacentre, and
# DNS points it at the edge with the bandwidth for an install wave (~15MB a # DNS points it at the edge with the bandwidth for an install wave (~15MB a
@@ -33,7 +59,9 @@ for a in "$@"; do
case "$a" in case "$a" in
--networked) networked=1 ;; --networked) networked=1 ;;
--local) local=1 ;; --local) local=1 ;;
*) echo "unknown option: $a" >&2; exit 2 ;; # An unknown argument is never echoed: it may be a pairing code given in
# the wrong place, and a log of the failed install must not hold it.
*) echo "ERROR: unknown option. This installer takes --networked or --local; a pairing code goes in PRIVATAE_PAIR_CODE, as your Host's card shows. Nothing was installed." >&2; exit 2 ;;
esac esac
done done
if [ "$networked" = 1 ] && [ "$local" = 1 ]; then if [ "$networked" = 1 ] && [ "$local" = 1 ]; then
@@ -54,6 +82,179 @@ paired_local() {
return 1 return 1
} }
# pair_as ROOT OS BIN ARGS...: run the bastion as the state it serves. As
# root that is the service's HOME (the unit's /root, launchd's /var/root),
# whatever HOME `sudo` left behind; as yourself it is your own.
pair_as() {
pa_root=$1; pa_os=$2; shift 2
if [ "$pa_root" = 1 ]; then
if [ "$pa_os" = darwin ]; then HOME=/var/root "$@"; else HOME=/root "$@"; fi
else
"$@"
fi
}
# bastion_copy PID: 0 when PID is a process of this user running a program
# named privatae-bastion, from any path, including one replaced on disk since
# it started. A process that has exited, or another user's, is not.
bastion_copy() {
[ -O "/proc/$1" ] || return 1
bc_exe=$(readlink "/proc/$1/exe" 2>/dev/null) || return 1
case "${bc_exe##*/}" in
"privatae-bastion" | "privatae-bastion (deleted)") return 0 ;;
esac
return 1
}
# stop_user_copies: stop every copy of this user's bastion (Linux), as
# install.ps1 does on Windows: one left running in a terminal, from an older
# install or another path. `run` holds the machine's run lock, so the copy the
# service starts would only wait behind it, and that copy never reads the new
# pairing; an older build would fail on 127.0.0.1:7878 and restart for ever.
# Asked to stop, then killed after 10 seconds. Says on stderr how many.
stop_user_copies() {
suc_pids=""
for suc_d in /proc/[0-9]*; do
suc_pid=${suc_d#/proc/}
bastion_copy "$suc_pid" || continue
kill "$suc_pid" 2>/dev/null && suc_pids="$suc_pids $suc_pid"
done
[ -n "$suc_pids" ] || return 0
set -- $suc_pids
echo "Stopped $# copy(ies) of the bastion running outside its service (a terminal, or another install)." >&2
for suc_i in 1 2 3 4 5 6 7 8 9 10; do
suc_left=""
for suc_pid in $suc_pids; do bastion_copy "$suc_pid" && suc_left="$suc_left $suc_pid"; done
[ -n "$suc_left" ] || return 0
sleep 1
done
for suc_pid in $suc_left; do kill -9 "$suc_pid" 2>/dev/null || true; done
}
# user_unit_stays_up: 0 when the user unit privatae-bastion is running and
# has kept the same process across a few seconds, longer than its
# RestartSec, so a copy that fails at start and is restarted is caught.
# Otherwise says what systemd shows, on stderr.
user_unit_stays_up() {
sleep 1
uu_first=$(systemctl --user show -p MainPID --value privatae-bastion 2>/dev/null) || uu_first=""
sleep 4
uu_pid=$(systemctl --user show -p MainPID --value privatae-bastion 2>/dev/null) || uu_pid=""
uu_state=$(systemctl --user show -p ActiveState --value privatae-bastion 2>/dev/null) || uu_state=""
uu_sub=$(systemctl --user show -p SubState --value privatae-bastion 2>/dev/null) || uu_sub=""
if [ "$uu_state" = active ] && [ "$uu_sub" = running ] && [ -n "$uu_pid" ] && [ "$uu_pid" != 0 ] && [ "$uu_pid" = "$uu_first" ]; then
return 0
fi
echo "The bastion's user service privatae-bastion is not staying up (systemd shows it ${uu_state:-unknown}/${uu_sub:-unknown}, and it has restarted)." >&2
echo "Its log says why: journalctl --user -u privatae-bastion" >&2
return 1
}
# start_bastion BIN ROOT OS: start, or restart, the bastion so it serves every
# pairing now and starts again by itself. A running bastion reads a new
# pairing at its next start, so a restart is what a fresh pairing needs.
# Prints one plain sentence saying how it will run.
start_bastion() {
sb_bin=$1; sb_root=$2; sb_os=$3
if [ "$sb_root" = 1 ] && [ "$sb_os" = linux ] && command -v systemctl >/dev/null 2>&1; then
systemctl restart privatae-bastion || return 1
echo "The bastion runs as the system service privatae-bastion, and starts at boot."
return 0
fi
if [ "$sb_root" = 1 ] && [ "$sb_os" = darwin ] && [ -f /Library/LaunchDaemons/ai.privatae.bastion.plist ]; then
/bin/launchctl kickstart -k system/ai.privatae.bastion || return 1
echo "The bastion runs as the system service ai.privatae.bastion, and starts at boot."
return 0
fi
if [ "$sb_root" != 1 ] && [ "$sb_os" = linux ] && command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
# A systemd user unit: yours, no root. It starts when you sign in; with
# lingering on, at boot and after you sign out as well.
sb_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$sb_dir"
cat > "$sb_dir/privatae-bastion.service" <<UNIT
[Unit]
Description=Privatae bastion - serves this machine to your Hosts (tunnel + lanes)
[Service]
Type=simple
ExecStart=$sb_bin run
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
UNIT
systemctl --user daemon-reload && systemctl --user enable privatae-bastion >/dev/null 2>&1 || return 1
# Its own copy first, then every other: what is left running after this
# is only what the unit starts.
systemctl --user stop privatae-bastion >/dev/null 2>&1 || true
stop_user_copies
systemctl --user restart privatae-bastion || return 1
user_unit_stays_up || return 1
if loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
echo "The bastion runs as your systemd user service privatae-bastion, and starts at boot."
else
echo "The bastion runs as your systemd user service privatae-bastion while you are signed in."
echo "To keep it running at boot and after you sign out: sudo loginctl enable-linger $(id -un)"
fi
return 0
fi
if [ "$sb_root" != 1 ] && [ "$sb_os" = darwin ]; then
# A LaunchAgent: yours, no root, started each time you log in.
sb_plist="$HOME/Library/LaunchAgents/ai.privatae.bastion.user.plist"
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$sb_plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>ai.privatae.bastion.user</string>
<key>ProgramArguments</key><array><string>$sb_bin</string><string>run</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict>
</plist>
PLIST
/bin/launchctl bootout "gui/$(id -u)/ai.privatae.bastion.user" >/dev/null 2>&1 || true
/bin/launchctl bootstrap "gui/$(id -u)" "$sb_plist" || return 1
echo "The bastion runs as your LaunchAgent ai.privatae.bastion.user, and starts each time you log in."
return 0
fi
# No service manager this script can use: run it now, detached, once every
# other copy of it has stopped (stop_user_copies says why).
if [ "$sb_os" = linux ] && [ "$sb_root" != 1 ]; then stop_user_copies; fi
nohup "$sb_bin" run >/dev/null 2>&1 &
sb_pid=$!
sleep 3
if [ "$sb_os" = linux ] && ! bastion_copy "$sb_pid"; then
echo "The bastion stopped as soon as it started. Run it in a terminal to see why: $sb_bin run" >&2
return 1
fi
echo "The bastion is running, until this machine restarts. Start it again with: $sb_bin run"
}
# pair_and_start BIN CODE ROOT OS: pair with CODE, handed to `pair -` on
# standard input and never as an argument, where the process list would show
# it. Then start the bastion and show its status. `pair` exits 3 when this
# machine is already paired with that Host, which is success here too.
pair_and_start() {
ps_bin=$1; ps_code=$2; ps_root=$3; ps_os=$4
ps_rc=0
printf '%s\n' "$ps_code" | pair_as "$ps_root" "$ps_os" "$ps_bin" pair - || ps_rc=$?
case "$ps_rc" in
0) ps_done="This machine is paired." ;;
3) ps_done="This machine was already paired with this Host; nothing changed there." ;;
*) echo "ERROR: pairing did not finish (the reason is above). The bastion is installed; nothing else changed." >&2
return 1 ;;
esac
ps_how=$(start_bastion "$ps_bin" "$ps_root" "$ps_os") || {
echo "ERROR: $ps_done But the bastion did not start. Start it with: $ps_bin run" >&2
return 1
}
sleep 2
pair_as "$ps_root" "$ps_os" "$ps_bin" status || true
echo ""
echo "$ps_done $ps_how"
}
if [ "$root" = 1 ] && [ "$local" != 1 ]; then if [ "$root" = 1 ] && [ "$local" != 1 ]; then
case "$(uname -s)" in case "$(uname -s)" in
Linux) if command -v systemctl >/dev/null 2>&1; then networked=1; fi ;; Linux) if command -v systemctl >/dev/null 2>&1; then networked=1; fi ;;
@@ -199,7 +400,9 @@ UNIT
systemctl enable --now privatae-bastion >/dev/null 2>&1 || true systemctl enable --now privatae-bastion >/dev/null 2>&1 || true
echo "Service installed + enabled (it restarts until you pair, then connects automatically)." echo "Service installed + enabled (it restarts until you pair, then connects automatically)."
echo "" echo ""
if [ "$networked" = 1 ]; then if [ -n "$pair_code" ]; then
:
elif [ "$networked" = 1 ]; then
echo "Networked bastion. Pair it with YOUR host; the first host paired becomes its anchor:" echo "Networked bastion. Pair it with YOUR host; the first host paired becomes its anchor:"
echo " sudo -H privatae-bastion pair <token>" echo " sudo -H privatae-bastion pair <token>"
echo "Then enrol your second factor, so you can approve here:" echo "Then enrol your second factor, so you can approve here:"
@@ -224,6 +427,7 @@ elif [ "$darwin_service" = 1 ]; then
HOME=/var/root "$bin" set-mode networked || die "could not set networked mode" HOME=/var/root "$bin" set-mode networked || die "could not set networked mode"
"$bin" service install || die "could not register the system service" "$bin" service install || die "could not register the system service"
echo "" echo ""
if [ -z "$pair_code" ]; then
echo "Networked bastion, running as a system service (launchd, as root)." echo "Networked bastion, running as a system service (launchd, as root)."
echo "Pair it with YOUR host; the first host paired becomes its anchor:" echo "Pair it with YOUR host; the first host paired becomes its anchor:"
# The full path, not the name on PATH: /usr/local/bin may belong to the # The full path, not the name on PATH: /usr/local/bin may belong to the
@@ -234,6 +438,7 @@ elif [ "$darwin_service" = 1 ]; then
echo " sudo -H '$bin' stepup-enroll" echo " sudo -H '$bin' stepup-enroll"
echo "Every terminal and write waits for your approval, and every later host for your host's." echo "Every terminal and write waits for your approval, and every later host for your host's."
echo "(Already paired before this install? Anchor with: sudo -H '$bin' claim-anchor)" echo "(Already paired before this install? Anchor with: sudo -H '$bin' claim-anchor)"
fi
elif [ "$networked" = 1 ]; then elif [ "$networked" = 1 ]; then
die "--networked needs a system service manager: systemd on Linux, launchd on macOS" die "--networked needs a system service manager: systemd on Linux, launchd on macOS"
else else
@@ -243,7 +448,13 @@ else
echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local." echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local."
echo "To make it a networked server instead, run the installer again with --networked." echo "To make it a networked server instead, run the installer again with --networked."
fi fi
if [ -z "$pair_code" ]; then
echo "Now pair: privatae-bastion pair <token>" echo "Now pair: privatae-bastion pair <token>"
echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)" echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)"
fi fi
fi
if [ -n "$pair_code" ]; then
echo ""
pair_and_start "$bin" "$pair_code" "$root" "$os" || exit 1
fi
echo "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt" echo "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt"
+18
View File
@@ -174,6 +174,24 @@ says so. Those parts are enforced only by the code as written.
| A networked machine is anchored to its first host | `pair_anchor_test.go`: `TestPairAnchorsANetworkedMachineToItsFirstHost` | On a networked machine with no anchor, the first pairing made with machine authority anchors it and is active; a local machine, an anchored one (a new host stays pending), a second pairing, and a caller without machine authority are left as they were. `claim-anchor` afterwards succeeds and changes nothing. Called directly, not through `pair`'s redeem. | | A networked machine is anchored to its first host | `pair_anchor_test.go`: `TestPairAnchorsANetworkedMachineToItsFirstHost` | On a networked machine with no anchor, the first pairing made with machine authority anchors it and is active; a local machine, an anchored one (a new host stays pending), a second pairing, and a caller without machine authority are left as they were. `claim-anchor` afterwards succeeds and changes nothing. Called directly, not through `pair`'s redeem. |
| | `anchor_test.go`: `TestClaimAnchorWithNoToken` | `claim-anchor` with no token on a machine anchored to that host succeeds and changes nothing, the host named in full, by handle or not at all; when the anchor's pairing is pending or revoked it is refused and the state is left as it was; another host is refused without `--rotate`. | | | `anchor_test.go`: `TestClaimAnchorWithNoToken` | `claim-anchor` with no token on a machine anchored to that host succeeds and changes nothing, the host named in full, by handle or not at all; when the anchor's pairing is pending or revoked it is refused and the state is left as it was; another host is refused without `--rotate`. |
| A root reinstall keeps a paired local machine local | `install_sh_test.go`: `TestInstallShKeepsAPairedLocalMachineLocal` | `install.sh`'s check finds a pairing in a folder whose mode is local (or unset), in either folder it looks in; a networked folder, or one with no pairing, gets the networked default. The rest of the installer is not run by a test. | | A root reinstall keeps a paired local machine local | `install_sh_test.go`: `TestInstallShKeepsAPairedLocalMachineLocal` | `install.sh`'s check finds a pairing in a folder whose mode is local (or unset), in either folder it looks in; a networked folder, or one with no pairing, gets the networked default. The rest of the installer is not run by a test. |
| A pairing code pins the Host's CA by its fingerprint | `pair_code_linux_test.go`: `TestAPairingCodePinsTheHostCAByFingerprint` | A pairing code whose enroll port presents no CA, another CA, a CA under another fingerprint, a leaf another CA signed, or a leaf without the name `host.cseries.internal` is refused before the token is spent, and nothing is saved. |
| | `pair_code_linux_test.go`: `TestAPairingCodePairsAsTheHostCAFileDoes` | A good code makes the same TCP pairing `--redeem-url` and `--host-ca` make, with that CA pinned; the same code again is "already paired" (exit 3) and changes nothing; a Host of the same name under another CA is a plain refusal. |
| | `pair_code_linux_test.go`: `TestAPairingCodeIsReadStrictly` | A code that is garbled, another version, or has an extra, missing or malformed field is refused, and no refusal repeats the token. |
| | `pair_code_linux_test.go`: `TestACodeTheHostMadeDecodesAndPins` | A code made by the C-Series Host's own `pairing_code` decodes to its address and token, and its fingerprint is the one `caSHA256` computes from the same CA. |
| | `pair_code_linux_test.go`: `TestAPairingCodeWithAKeyGivenTwiceIsRefused` | A code that names a field twice is refused. |
| | `pair_code_linux_test.go`: `TestThePinnedChainTakesOnlyAValidCAWithTheFingerprint` | A certificate with the fingerprint that is not a CA, an expired CA, or a chain of the leaf alone is refused; the pinned CA after other certificates is the one kept. Called directly, not through TLS. |
| | `pair_code_linux_test.go`: `TestHostCAFileWithASameNamedHostUnderAnotherCAIsNotAlreadyPaired` | With `--host-ca` too, a Host of the same name under another CA is a plain refusal, not exit 3, and its token is not spent; the same Host under its own CA is exit 3. |
| | `pair_code_linux_test.go`: `TestARedeemAnsweringAPairedNameUnderAnotherCAIsNotAlreadyPaired` | When the redeem answers with a paired name the token's handle did not show, a Host of that name under another CA is a plain refusal, not exit 3, and nothing is saved. |
| A local machine adds a second host only on a yes typed there | `pair_code_linux_test.go`: `TestALocalMachineAddsASecondHostOnlyOnAYesTypedHere` | With no terminal, or any answer but y or yes, the second host is refused before its token is spent and the first pairing is unchanged; callers with no person to ask keep the refusal and open no terminal; a typed yes adds it beside the first. |
| | `pair_code_linux_test.go`: `TestTheSecondHostAnswerIsNeverReadFromStandardInput` | The answer is read from the controlling terminal, never from standard input: with no terminal and a y waiting on standard input, the question finds no terminal, refuses, and leaves standard input unread. |
| | `service_test.go`: `TestServiceOwnerRuleOnAnAnchoredBox` | On a local machine that serves two hosts, a host may not replace or stop a server the other built, and may change its own; with one host no owner rule applies. |
| | `service_test.go`: `TestCoderRunOwnerRuleOnALocalBoxServingTwoHosts` | On a local machine that serves two hosts, a host may not stop a coder run the other started, or one of unknown owner, and may stop its own; with one host nothing changes. |
| | `pair_code_linux_test.go`: `TestTheSecondHostQuestionEscapesTheTokensName` | The second-host question shows a token's name that is not a plain host name quoted, with its control bytes escaped. |
| The installers keep the pairing code out of every process's arguments | `install_sh_test.go`: `TestInstallShTakesTheCodeOnlyFromTheEnvironment` | `install.sh` takes the code only from `PRIVATAE_PAIR_CODE` and unsets it before it starts any program: no program it starts, the downloaded bastion included, has the code in its arguments, its shell's command line or its environment, and the code reaches `pair -` on standard input. Run on stand-in programs; nothing is downloaded. |
| | `install_sh_test.go`: `TestInstallShRefusesABadPairCodeBeforeDownloading` | An empty or partial `PRIVATAE_PAIR_CODE`, and any unknown argument (the removed `--pair` and a bare code included), are refused before the download and never echoed. |
| | `install_sh_test.go`: `TestInstallShPairsOverStdinAndStartsAUserUnit` | The pairing functions hand the code to `pair -` on standard input, then start the bastion; a failed pair starts nothing. Run on stand-in programs, not on a real systemd. |
| | `install_sh_test.go`: `TestInstallShStopsAnOutsideCopyAndChecksTheUnitStaysUp` | On Linux as yourself, a copy of the bastion running outside the user unit is stopped before the unit starts, and a unit that keeps restarting is reported as not running, with a failure. Run on a stand-in systemd, in a PID namespace of its own. |
| | `install_ps1_test.go`: `TestInstallPs1PairShape` | `install.ps1` pipes the code to `pair -`, never into an argument or the output, and checks it before downloading. Read from the file; `TestInstallPs1PairBlockRunsUnderPwsh` runs the block where pwsh is installed. Not run on Windows. |
| A terminal is checked here and logged | `session_test.go`: `TestSessionOpensWithoutAGrantOnALocalBox` | On a local machine a terminal with no grant and no approval opens and is audited. The same test calls `sessionPinOfferedKey` directly: the first key offered is pinned and a different one is refused. | | A terminal is checked here and logged | `session_test.go`: `TestSessionOpensWithoutAGrantOnALocalBox` | On a local machine a terminal with no grant and no approval opens and is audited. The same test calls `sessionPinOfferedKey` directly: the first key offered is pinned and a different one is refused. |
| | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | A grant that is sent is verified: a bad signature, an op not listed, or an op the grant denies is refused and audited. | | | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | A grant that is sent is verified: a bad signature, an op not listed, or an op the grant denies is refused and audited. |
| | `fuzz_grants_test.go`: `FuzzEachOpIsAllowedOnlyByItsOwnFamily` | On an anchored machine the family check (`capsAllow`) lets a host other than the anchor run an op, the session ops included, only if its pairing grant names that op's family. Called directly, not through the terminal handler. | | | `fuzz_grants_test.go`: `FuzzEachOpIsAllowedOnlyByItsOwnFamily` | On an anchored machine the family check (`capsAllow`) lets a host other than the anchor run an op, the session ops included, only if its pairing grant names that op's family. Called directly, not through the terminal handler. |
+3
View File
@@ -7,6 +7,9 @@
| `pair --replace <token>` | Re-pair an existing host: rotates its credentials, keeps its port, context store and DCN state. | | `pair --replace <token>` | Re-pair an existing host: rotates its credentials, keeps its port, context store and DCN state. |
| `pair [--replace] --redeem-url unix:///<socket> <token>` | Pair with a Host on this machine over its enroll socket. Its CA is pinned for life. See [A Host on this machine](host-on-this-machine.md). | | `pair [--replace] --redeem-url unix:///<socket> <token>` | Pair with a Host on this machine over its enroll socket. Its CA is pinned for life. See [A Host on this machine](host-on-this-machine.md). |
| `pair [--replace] --redeem-url https://<host>:<port>/<path> --host-ca <pem> <token>` | Pair with a Host in a VM on this machine over its enroll port, trusting that CA alone. Its CA is pinned for life. See [A Host in a VM on this machine](host-on-this-machine.md#a-host-in-a-vm-on-this-machine). | | `pair [--replace] --redeem-url https://<host>:<port>/<path> --host-ca <pem> <token>` | Pair with a Host in a VM on this machine over its enroll port, trusting that CA alone. Its CA is pinned for life. See [A Host in a VM on this machine](host-on-this-machine.md#a-host-in-a-vm-on-this-machine). |
| `pair <pairing code>` | Pair with the one code (`skp_…`) a Cloud Host's "Set up a bastion" card gives: it carries the enroll address and the fingerprint of the Host's CA, and the CA is taken from the enroll port only under that fingerprint. On a local machine that already serves a Host it asks, on this machine's terminal, whether to add this one; only a yes adds it. |
| | The card's one line installs the bastion, pairs it with that code and starts it: `curl -fsSL https://privatae.ai/bastion/install.sh \| PRIVATAE_PAIR_CODE='skp_…' sh` on macOS and Linux (the code goes in the environment, never in an argument, which the process list would show), and `& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…'` on Windows. Either hands the code to `pair -` on standard input. |
| | `pair` exits 0 when it paired, 3 when this machine is already paired with that Host (nothing changed), and anything else on a failure. |
| `run` | Bring up the tunnel and lanes for every paired host. | | `run` | Bring up the tunnel and lanes for every paired host. |
| `status` | Show every pairing and its tunnel status. | | `status` | Show every pairing and its tunnel status. |
| `ui` | Open the local console in your browser. | | `ui` | Open the local console in your browser. |