// The `carlos gate` verb family: one shared password in front of an // instance's hostname, for a static site or a prototype that should not be // world-readable (spec docs/superpowers/specs/2026-09-07-password-gate-design.md). // API mode only, like `carlos features` and for the same reason: setting a // gate is a console-authorized write with an audit row. package main import ( "bufio" "context" "errors" "flag" "fmt" "io" "log" "os" "strings" ) const gateUsage = `usage: carlos gate [flags] set -app [-host ] [-password

] put a password in front of the app's instances (or one host); without -password, one line is read from stdin clear -app [-host ] turn the password off list -app which hosts have a password set -account account (sqid or name; default "ops") -account-id account sqid, matched as a sqid ONLY -console console to act through (default: the only logged-in one) Signed-in only (carlos auth login). Visitors to a gated host see a plain page asking for the password and stay signed in for a week; changing or clearing the password signs everyone out at once. The box picks the change up within seconds of the next converge tick. ` func runGate(args []string) { if len(args) > 0 { switch args[0] { case "set": runGateSet(args[1:]) return case "clear": runGateClear(args[1:]) return case "list": runGateList(args[1:]) return } } log.Fatal(gateUsage) } func requireGateAPISession(consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal("carlos gate works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A password gate is a console write with an\n" + "audit row. Unset the store env and run: carlos auth login -console .") } return sess } // readGatePassword is the flag, else one line from stdin — so a password // can be piped or typed without landing in shell history or ps output. An // empty line is refused; the console's own length rule is applied there. func readGatePassword(flagged string, stdin io.Reader) (string, error) { if flagged != "" { return flagged, nil } line, err := bufio.NewReader(stdin).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return "", err } line = strings.TrimRight(line, "\r\n") if line == "" { return "", errors.New("no password given — pass -password or pipe one line on stdin") } return line, nil } // gateHosts resolves the hosts a set/clear applies to: -host alone, else // every instance record of the app. func gateHosts(sess *apiSession, acct apiAcct, app, host string) []string { if host != "" { return []string{host} } recs, _, _, err := sess.instanceList(context.Background(), acct, app, "", "") if err != nil { log.Fatalf("gate: list instances: %v", err) } if len(recs) == 0 { log.Fatalf("gate: %s has no instances — a gate is set per instance; create one first (carlos instances create)", app) } hosts := make([]string, 0, len(recs)) for _, r := range recs { hosts = append(hosts, r.Host) } return hosts } func gateFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, *string) { fs := flag.NewFlagSet("gate "+name, flag.ExitOnError) app := appFlag(fs, "app whose instances to gate") host := fs.String("host", "", "one instance host (default: every instance of the app)") consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") return fs, app, host, consoleFlag, acctRaw, acctIDRaw } func runGateSet(args []string) { fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("set") pw := fs.String("password", "", "the password (default: one line from stdin)") passwordGiven := false fs.Parse(args) fs.Visit(func(f *flag.Flag) { if f.Name == "password" { passwordGiven = true } }) 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) } printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, gateUsage) 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) sess := requireGateAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, gateUsage) failed := gateApply(sess, acct, app2, gateHosts(sess, acct, app2, strings.TrimSpace(*host)), "", stdout) if failed > 0 { os.Exit(1) } } func runGateList(args []string) { fs, app, _, consoleFlag, acctRaw, acctIDRaw := gateFlags("list") fs.Parse(args) sess := requireGateAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, gateUsage) recs, _, _, err := sess.instanceList(context.Background(), acct, app2, "", "") 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(out, "no instances") return } w := newTable(out) fmt.Fprintln(w, "HOST\tGATE") for _, r := range recs { 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) } w.Flush() }