carlos / platform Public

carlos gate: say "unknown" when the console never mentioned gates, and test the loop

`gate list` decoded the console's "gated" key into a plain bool, so a
console that predates the field — which sends no key at all — printed OPEN
for every host. That is the one wrong answer this column can give: the
person reading it is checking whether something private is exposed, and it
told them what they hoped to hear on no evidence. Gated is now *bool, and
nil prints "unknown — console predates gates". No other reader of the field
exists.

The set/clear loop moves into gateApply, which both verbs call: it PATCHes
every host whichever way one of them fails, and hands back the count the
caller turns into the exit code. The list printer comes out as
printGateList. Both are now covered, along with instanceSetGate's three
answers (the record, the older-console sentence, and every other refusal
passed through verbatim) and gateHosts' fan-out — including that -host
never calls the list door.

`gate set` also resolves the session BEFORE it reads stdin. It used to sit
there swallowing a typed password and only then say it had no console to
send it to, which leaves someone having typed a secret at a prompt that was
never going to work. And when stdin is a terminal it now prints "Password: "
to stderr first, so the waiting cursor says what it wants.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 328da0134532c3eb6eb03c373186ed6248f2f2c6 parent 283e7c5
3 files changed, +289 −25
  • apimode.go +8 −2
  • gate_cli.go +56 −23
  • gate_cli_test.go +225 −0
diff --git a/apimode.go b/apimode.go
index 9446ccf8d..f8ba1c827 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2535,8 +2535,14 @@ type apiInstanceRecord struct {
Environment string `json:"environment"`
// Gated says whether the host has a password gate (spec
// 2026-09-07-password-gate-design §5.4). The hash itself never leaves
- // the console. False from a console that predates the field.
- Gated bool `json:"gated"`
+ // the console. A POINTER because absent and false are different facts
+ // here: nil means the console predates the field and has told us
+ // nothing, while false is its word that the host is open. Decoding the
+ // silence as "open" would have `carlos gate list` print a reassuring
+ // OPEN for hosts it knows nothing about — the one wrong answer this
+ // column can give, since the person reading it is checking whether
+ // something private is exposed.
+ Gated *bool `json:"gated"`
}
// apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status)
diff --git a/gate_cli.go b/gate_cli.go
index da4ea56e3..612baed23 100644
--- a/gate_cli.go
+++ b/gate_cli.go
@@ -127,29 +127,57 @@ func runGateSet(args []string) {
if passwordGiven && *pw == "" {
log.Fatal("gate set: -password is empty — give a password of 8 to 72 characters")
}
+ // The session is resolved BEFORE the password is read. Reading first
+ // meant a shell with no console sat there swallowing a typed password
+ // and only then said it could not use it — and the person has now typed
+ // a secret at a prompt that was never going to work, with no way to
+ // know where it went.
+ sess := requireGateAPISession(*consoleFlag)
+ // A pipe gets no prompt (nothing is there to read it, and it would land
+ // in whatever the caller is capturing); a person typing gets told what
+ // the waiting cursor is for, on stderr so a redirected stdout stays
+ // clean. isTerminal is color.go's dependency-free isatty — no x/term,
+ // and no echo suppression: the gate's plaintext is a shared site
+ // password that -password already puts in full view.
+ if !passwordGiven && isTerminal(os.Stdin) {
+ fmt.Fprint(os.Stderr, "Password: ")
+ }
password, err := readGatePassword(*pw, os.Stdin)
if err != nil {
log.Fatalf("gate set: %v", err)
}
- sess := requireGateAPISession(*consoleFlag)
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, gateUsage)
- failed := 0
- for _, h := range gateHosts(sess, acct, app2, strings.TrimSpace(*host)) {
- if _, err := sess.instanceSetGate(context.Background(), acct, app2, h, password); err != nil {
- failed++
- fmt.Fprintf(stdout, "%s %s: %v\n", colorFor(stdout).bad("gate not set on"), h, err)
- continue
- }
- fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("password set on"), h)
- }
+ failed := gateApply(sess, acct, app2, gateHosts(sess, acct, app2, strings.TrimSpace(*host)), password, stdout)
mutedf(stdout, " the box picks it up within seconds of the next converge tick; visitors already signed in are signed out")
if failed > 0 {
os.Exit(1)
}
}
+// gateApply is the set/clear loop both verbs share: one PATCH per host, one
+// line each, and the number that failed — which the callers turn into the
+// exit code. A failure on one host does NOT stop the others: with no -host
+// the fan-out is every instance of the app, and stopping at the first
+// refusal would leave the operator guessing which of the rest took the
+// password. password == "" is the clear.
+func gateApply(sess *apiSession, acct apiAcct, app string, hosts []string, password string, w io.Writer) (failed int) {
+ bad, ok := "gate not set on", "password set on"
+ if password == "" {
+ bad, ok = "gate not cleared on", "password turned off on"
+ }
+ for _, h := range hosts {
+ if _, err := sess.instanceSetGate(context.Background(), acct, app, h, password); err != nil {
+ failed++
+ fmt.Fprintf(w, "%s %s: %v\n", colorFor(w).bad(bad), h, err)
+ continue
+ }
+ fmt.Fprintf(w, "%s %s\n", colorFor(w).ok(ok), h)
+ }
+ return failed
+}
+
func runGateClear(args []string) {
fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("clear")
fs.Parse(args)
@@ -157,15 +185,7 @@ func runGateClear(args []string) {
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, gateUsage)
- failed := 0
- for _, h := range gateHosts(sess, acct, app2, strings.TrimSpace(*host)) {
- if _, err := sess.instanceSetGate(context.Background(), acct, app2, h, ""); err != nil {
- failed++
- fmt.Fprintf(stdout, "%s %s: %v\n", colorFor(stdout).bad("gate not cleared on"), h, err)
- continue
- }
- fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("password turned off on"), h)
- }
+ failed := gateApply(sess, acct, app2, gateHosts(sess, acct, app2, strings.TrimSpace(*host)), "", stdout)
if failed > 0 {
os.Exit(1)
}
@@ -182,16 +202,29 @@ func runGateList(args []string) {
if err != nil {
log.Fatalf("gate list: %v", err)
}
+ printGateList(stdout, recs)
+}
+
+// printGateList renders the GATE column's three states. The third one is
+// the honest half: a console that predates the field sends no "gated" key,
+// which decodes to nil, and printing "open" for that would tell someone
+// checking whether a host is exposed exactly what they hoped to hear on no
+// evidence at all.
+func printGateList(out io.Writer, recs []apiInstanceRecord) {
if len(recs) == 0 {
- fmt.Fprintln(stdout, "no instances")
+ fmt.Fprintln(out, "no instances")
return
}
- w := newTable(stdout)
+ w := newTable(out)
fmt.Fprintln(w, "HOST\tGATE")
for _, r := range recs {
- state := "open"
- if r.Gated {
+ state := "unknown — console predates gates"
+ switch {
+ case r.Gated == nil:
+ case *r.Gated:
state = "password set"
+ default:
+ state = "open"
}
fmt.Fprintf(w, "%s\t%s\n", r.Host, state)
}
diff --git a/gate_cli_test.go b/gate_cli_test.go
index 048bca8a8..ca0debaf0 100644
--- a/gate_cli_test.go
+++ b/gate_cli_test.go
@@ -1,6 +1,11 @@
package main
import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
"os/exec"
"strings"
"testing"
@@ -46,3 +51,223 @@ func TestReadGatePassword(t *testing.T) {
t.Fatal("empty stdin line accepted")
}
}
+
+// A `gate set` in a shell with no console must refuse BEFORE it reads
+// stdin. Reading first meant the CLI sat there swallowing a typed password
+// and only then said it could not use it — the person has now typed a
+// secret at a prompt that was never going to work, with no idea where it
+// went. Stdin is empty here, which is what separates the two orders: read
+// first and the CLI complains about the missing password, when the real
+// problem is that it had nowhere to send one.
+func TestGateSetResolvesTheSessionBeforeReadingThePassword(t *testing.T) {
+ bin := buildCarlos(t)
+ cmd := exec.Command(bin, "gate", "set", "-app", "site")
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = t.TempDir()
+ cmd.Stdin = strings.NewReader("")
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("gate set with no console exited 0: %s", out)
+ }
+ if strings.Contains(string(out), "no password given") {
+ t.Fatalf("the password was read before the session was resolved: %s", out)
+ }
+ if !strings.Contains(string(out), "auth login") {
+ t.Fatalf("want the sign-in refusal, got: %s", out)
+ }
+}
+
+// gateConsole is a fake console for the gate client: it answers the two
+// doors `carlos gate` uses (GET the instance list, PATCH one instance) with
+// whatever the test hands it, and records every request so a test can prove
+// a door was NOT knocked on.
+type gateConsole struct {
+ ts *httptest.Server
+ seen []string
+ list string // body for GET .../instances
+ patch func(host string) (int, string) // status and body for PATCH .../instances/<host>
+}
+
+func newGateConsole(t *testing.T) *gateConsole {
+ t.Helper()
+ fc := &gateConsole{}
+ fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fc.seen = append(fc.seen, r.Method+" "+r.URL.Path)
+ if r.Method == http.MethodPatch {
+ host := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
+ status, body := 200, `{"host":"`+host+`","gated":true}`
+ if fc.patch != nil {
+ status, body = fc.patch(host)
+ }
+ w.WriteHeader(status)
+ w.Write([]byte(body))
+ return
+ }
+ w.Write([]byte(fc.list))
+ }))
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// instanceSetGate sends the plaintext for the console to hash, and decodes
+// the console's word about the host.
+func TestInstanceSetGate(t *testing.T) {
+ var gotBody, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ w.Write([]byte(`{"host":"h","gated":true}`))
+ }))
+ defer ts.Close()
+
+ rec, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "correct horse")
+ if err != nil {
+ t.Fatalf("instanceSetGate: %v", err)
+ }
+ if gotMethod != http.MethodPatch {
+ t.Errorf("method = %s, want PATCH", gotMethod)
+ }
+ if !strings.Contains(gotBody, `"gate":"correct horse"`) {
+ t.Errorf("request body = %s, want the plaintext for the console to hash", gotBody)
+ }
+ if rec.Gated == nil || !*rec.Gated {
+ t.Errorf("decoded gated = %v, want the console's true", rec.Gated)
+ }
+}
+
+// The one refusal an operator cannot otherwise read: a PATCH carrying only
+// `gate` looks like an empty update to a door that has never heard of the
+// field, and the door's own "nothing to update" reads like a bug in the CLI.
+func TestInstanceSetGateAgainstAnOldConsole(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"error":"nothing to update — send addrs, probe, steering"}`))
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "correct horse")
+ if err == nil || !strings.Contains(err.Error(), "does not know about password gates") {
+ t.Fatalf("err = %v, want the older-console sentence", err)
+ }
+}
+
+// Every other refusal is the console's own, passed through verbatim:
+// guessing at what a 403 or a 409 meant would put words in its mouth.
+func TestInstanceSetGatePassesOtherRefusalsThrough(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ w.Write([]byte(`{"error":"the record moved under you — try again"}`))
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).instanceSetGate(context.Background(), apiAcct{Seg: "bah"}, "app", "h", "")
+ if err == nil || err.Error() != "the record moved under you — try again" {
+ t.Fatalf("err = %v, want the console's own message", err)
+ }
+}
+
+// The fan-out: no -host means every instance of the app, and -host means
+// exactly that one — without asking the console for a list it does not need.
+func TestGateHostsFanOut(t *testing.T) {
+ fc := newGateConsole(t)
+ fc.list = `{"instances":[{"host":"a.example.com"},{"host":"b.example.com"}],"total":2}`
+ sess := newTestSession(fc.ts.URL)
+
+ got := gateHosts(sess, apiAcct{Seg: "bah"}, "app", "")
+ if len(got) != 2 || got[0] != "a.example.com" || got[1] != "b.example.com" {
+ t.Fatalf("hosts = %v, want both instances", got)
+ }
+
+ fc.seen = nil
+ if got := gateHosts(sess, apiAcct{Seg: "bah"}, "app", "one.example.com"); len(got) != 1 || got[0] != "one.example.com" {
+ t.Fatalf("hosts = %v, want just the named one", got)
+ }
+ if len(fc.seen) != 0 {
+ t.Fatalf("-host still called the console: %v", fc.seen)
+ }
+}
+
+// gateApply keeps going after a refusal and counts it. With no -host the
+// fan-out is every instance of the app, so stopping at the first failure
+// would leave the operator not knowing which of the rest took the password
+// — and the exit code must still say something went wrong.
+func TestGateApplyReportsEveryHostAndCountsFailures(t *testing.T) {
+ fc := newGateConsole(t)
+ fc.patch = func(host string) (int, string) {
+ if host == "b.example.com" {
+ return http.StatusConflict, `{"error":"the record moved under you"}`
+ }
+ return 200, `{"host":"` + host + `","gated":true}`
+ }
+ var out bytes.Buffer
+ failed := gateApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app",
+ []string{"a.example.com", "b.example.com", "c.example.com"}, "correct horse", &out)
+
+ if failed != 1 {
+ t.Errorf("failed = %d, want 1 — the caller turns this into the exit code", failed)
+ }
+ s := out.String()
+ for _, want := range []string{
+ "password set on a.example.com",
+ "gate not set on b.example.com: the record moved under you",
+ "password set on c.example.com",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("output missing %q:\n%s", want, s)
+ }
+ }
+ if n := len(fc.seen); n != 3 {
+ t.Errorf("%d PATCHes, want 3 — the loop stopped at the refusal: %v", n, fc.seen)
+ }
+}
+
+// Clearing says so. Same loop, and the words an operator reads are the only
+// thing that tells them which way it went.
+func TestGateApplyWordsTheClear(t *testing.T) {
+ fc := newGateConsole(t)
+ fc.patch = func(host string) (int, string) { return 200, `{"host":"` + host + `","gated":false}` }
+ var out bytes.Buffer
+ if failed := gateApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app",
+ []string{"a.example.com"}, "", &out); failed != 0 {
+ t.Fatalf("failed = %d, want 0", failed)
+ }
+ if !strings.Contains(out.String(), "password turned off on a.example.com") {
+ t.Errorf("output = %q, want the clear's own wording", out.String())
+ }
+}
+
+// All three states of the GATE column. The third is the one that matters:
+// a console that predates the field sends no "gated" key, and calling that
+// "open" would tell someone checking whether a host is exposed exactly what
+// they hoped to hear on no evidence at all.
+func TestPrintGateList(t *testing.T) {
+ yes, no := true, false
+ var out bytes.Buffer
+ printGateList(&out, []apiInstanceRecord{
+ {Host: "set.example.com", Gated: &yes},
+ {Host: "open.example.com", Gated: &no},
+ {Host: "old.example.com"},
+ })
+ s := out.String()
+ for _, want := range []string{
+ "set.example.com", "password set",
+ "open.example.com", "open",
+ "old.example.com", "unknown — console predates gates",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("table missing %q:\n%s", want, s)
+ }
+ }
+ for _, line := range strings.Split(s, "\n") {
+ if strings.HasPrefix(line, "old.example.com") && !strings.Contains(line, "unknown") {
+ t.Errorf("a console that said nothing was reported as %q", line)
+ }
+ }
+
+ out.Reset()
+ printGateList(&out, nil)
+ if strings.TrimSpace(out.String()) != "no instances" {
+ t.Errorf("empty list = %q, want \"no instances\"", out.String())
+ }
+}