bastion 0.57.0
This commit is contained in:
@@ -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
|
||||
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
|
||||
|
||||
- **New: a Host with no platform can use the model servers on this
|
||||
|
||||
@@ -49,6 +49,11 @@ privatae-bastion run
|
||||
A Windows server installs from an Administrator PowerShell with `-Networked`.
|
||||
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.
|
||||
|
||||
**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
|
||||
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
|
||||
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 |
|
||||
| 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 |
|
||||
| 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` |
|
||||
|
||||
In networked mode one host is the **anchor**: the one host that approves every
|
||||
|
||||
+5
-4
@@ -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
|
||||
// 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
|
||||
// be anchored at all (cmdClaimAnchor refuses outside networked mode), so the
|
||||
// pairing IS the authorization there. Most bastions are local.
|
||||
// LOCAL is exempt and must stay so: a local bastion serves the one host it was
|
||||
// paired with, and a second only on a yes typed at the machine (pair_terminal.go),
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
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()
|
||||
if err != nil {
|
||||
|
||||
@@ -1229,10 +1229,17 @@ func coderContainerGone(rt, cname string) bool {
|
||||
// anchor pairing on an anchored box (serviceOwnerRefusal's 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.
|
||||
//
|
||||
// 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 {
|
||||
if owner != "" && owner == self {
|
||||
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 != "" {
|
||||
return "refused: that run was started by " + orUnknown(owner) + "; " + why
|
||||
}
|
||||
|
||||
+1
-1
@@ -615,7 +615,7 @@ func removeStalePairingFile(hostName string) error {
|
||||
func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, error) {
|
||||
existing := pairingFor(cfgs, handle)
|
||||
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 {
|
||||
return nil, fmt.Errorf("pair --replace: no existing pairing matches '%s' — pair it normally first", handle)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -74,3 +77,358 @@ func TestInstallShKeepsAPairedLocalMachineLocal(t *testing.T) {
|
||||
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
@@ -37,6 +37,13 @@ Usage:
|
||||
that CA alone (name host.cseries.internal), the lanes go to
|
||||
the lane port the Host names, and the CA is pinned for life.
|
||||
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
|
||||
(a loopback control port per host for the verbs below:
|
||||
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" +
|
||||
" - 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
|
||||
// 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)
|
||||
}
|
||||
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":
|
||||
err = cmdRun()
|
||||
case "observe:baseline":
|
||||
|
||||
@@ -36,8 +36,13 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"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) {
|
||||
// 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
|
||||
@@ -62,6 +67,13 @@ func TestMain(m *testing.M) {
|
||||
if len(os.Args) >= 3 && os.Args[1] == rootExecConnectSubcommand {
|
||||
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
|
||||
// (claims_git_test.go): answer as the helper, with the config dir the
|
||||
// test named, and never start the suite.
|
||||
|
||||
+5
-3
@@ -88,11 +88,13 @@ func setBoxMode(mode string) error {
|
||||
}
|
||||
}
|
||||
if mode == modeLocal {
|
||||
// Going back to local must not strand hosts: a local box serves ONE host,
|
||||
// so with several pairings the box would be lying about its own rule.
|
||||
// Going back to local must not strand hosts: a local box serves a second
|
||||
// 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()
|
||||
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 {
|
||||
return errors.New("cannot switch to local mode: this box is anchored (anchor.json) — anchoring is a networked-mode concept")
|
||||
|
||||
+149
-14
@@ -163,10 +163,12 @@ type redeemResp struct {
|
||||
// mode rule, and the anchoring rule. Returns the existing pairing on a
|
||||
// --replace and the state the new pairing file must carry.
|
||||
//
|
||||
// - LOCAL mode (mode.go): one host, full stop. A second `pair` is refused with
|
||||
// a plain message; only --replace of the one host is allowed. This is the
|
||||
// entire protection for a single-user machine — a second host appearing
|
||||
// means someone else got a token onto the box.
|
||||
// - LOCAL mode (mode.go): one host unless the person says otherwise. A
|
||||
// second `pair` is refused here with localSecondHostError; `pair` itself
|
||||
// turns that into a question asked on this machine's terminal
|
||||
// (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
|
||||
// PENDING. It gets nothing until the anchor host's grant arrives.
|
||||
// - --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
|
||||
}
|
||||
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. "+
|
||||
"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 nil, "", &localSecondHostError{serving: 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 {
|
||||
return nil, pairingActive, nil
|
||||
}
|
||||
@@ -205,8 +210,73 @@ func pairPrecheck(cfgs []*Config, handle string, replace, forceActive bool) (exi
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -236,6 +306,26 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
|
||||
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
|
||||
// (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
|
||||
@@ -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
|
||||
// that CA alone. A unix:// or platform redeem takes no --host-ca.
|
||||
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.
|
||||
//
|
||||
// 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 {
|
||||
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")
|
||||
}
|
||||
default:
|
||||
if hostCA, err = loadHostCA(hostCAFile); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
tcpRedeem, transport = u, transportTCP
|
||||
default:
|
||||
s, err := parseUnixRedeemURL(redeemURL)
|
||||
@@ -397,12 +500,41 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive
|
||||
return nil, nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := replaceKeepsTransport(existing, transport); err != nil {
|
||||
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 err := tcpPinRefusal(existing, hostCA); err != nil {
|
||||
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()) {
|
||||
existing = prev
|
||||
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 {
|
||||
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()) {
|
||||
existing = prev
|
||||
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 {
|
||||
return nil, nil, err
|
||||
@@ -531,7 +666,7 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive
|
||||
if prev := pairingFor(cfgs, rr.HostName); prev != nil {
|
||||
existing = prev
|
||||
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)
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -149,11 +149,19 @@ func redeemOverTCP(u *url.URL, caPEM string, body []byte) (tcpRedeemResp, error)
|
||||
if err != nil {
|
||||
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{
|
||||
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 }}
|
||||
resp, err := httpc.Post(u.String(), "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
var we *pairWindowError
|
||||
if errors.As(err, &we) {
|
||||
return rr, we
|
||||
}
|
||||
return rr, fmt.Errorf("redeem: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -288,17 +288,40 @@ func serviceArgs(spec harnessSpec) []string {
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
if r == nil || r.Owner == "" || r.Owner == self {
|
||||
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 != "" {
|
||||
return "refused: slot '" + r.Name + "' was built by " + r.Owner + "; " + why
|
||||
}
|
||||
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) {
|
||||
res["ok"], res["error"] = false, msg
|
||||
b, _ := json.Marshal(res)
|
||||
|
||||
@@ -495,10 +495,24 @@ func TestServiceOwnerRuleOnAnAnchoredBox(t *testing.T) {
|
||||
if why := serviceOwnerRefusal(tMember, &serviceRecord{Name: "forge", Owner: tMember}); 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())
|
||||
if err := (&Config{HostName: tMember, ListenAddr: "127.0.0.1:7878"}).save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 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
@@ -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'
|
||||
@@ -888,7 +888,7 @@ func (s *uiSetup) anchorChoose(w http.ResponseWriter, r *http.Request) {
|
||||
Class: "bad",
|
||||
Command: setModeCommandHint(),
|
||||
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.",
|
||||
})
|
||||
return
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.56.0
|
||||
0.57.0
|
||||
|
||||
+68
-3
@@ -21,7 +21,17 @@
|
||||
# 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.
|
||||
# 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
|
||||
# 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'}
|
||||
$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) {
|
||||
# Checked before anything is downloaded, so a refusal leaves nothing behind
|
||||
# and is one clear sentence at the moment it can be acted on.
|
||||
@@ -43,7 +65,7 @@ if ($Networked) {
|
||||
if (-not $isAdmin) {
|
||||
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" +
|
||||
"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 "The bastion runs as the Windows service 'PrivataeBastion' (LocalSystem). It starts"
|
||||
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 " privatae-bastion claim-anchor"
|
||||
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."
|
||||
} else {
|
||||
}
|
||||
} elseif (-not $Pair) {
|
||||
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"
|
||||
|
||||
+213
-2
@@ -6,7 +6,33 @@
|
||||
# sudo -H privatae-bastion pair <token>
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
@@ -33,7 +59,9 @@ for a in "$@"; do
|
||||
case "$a" in
|
||||
--networked) networked=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
|
||||
done
|
||||
if [ "$networked" = 1 ] && [ "$local" = 1 ]; then
|
||||
@@ -54,6 +82,179 @@ paired_local() {
|
||||
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
|
||||
case "$(uname -s)" in
|
||||
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
|
||||
echo "Service installed + enabled (it restarts until you pair, then connects automatically)."
|
||||
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 " sudo -H privatae-bastion pair <token>"
|
||||
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"
|
||||
"$bin" service install || die "could not register the system service"
|
||||
echo ""
|
||||
if [ -z "$pair_code" ]; then
|
||||
echo "Networked bastion, running as a system service (launchd, as root)."
|
||||
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
|
||||
@@ -234,6 +438,7 @@ elif [ "$darwin_service" = 1 ]; then
|
||||
echo " sudo -H '$bin' stepup-enroll"
|
||||
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)"
|
||||
fi
|
||||
elif [ "$networked" = 1 ]; then
|
||||
die "--networked needs a system service manager: systemd on Linux, launchd on macOS"
|
||||
else
|
||||
@@ -243,7 +448,13 @@ else
|
||||
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."
|
||||
fi
|
||||
if [ -z "$pair_code" ]; then
|
||||
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)"
|
||||
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"
|
||||
|
||||
@@ -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. |
|
||||
| | `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 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. |
|
||||
| | `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. |
|
||||
|
||||
@@ -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] --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 <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. |
|
||||
| `status` | Show every pairing and its tunnel status. |
|
||||
| `ui` | Open the local console in your browser. |
|
||||
|
||||
Reference in New Issue
Block a user