| 1 | // The `carlos gate` verb family: one shared password in front of an |
| 2 | // instance's hostname, for a static site or a prototype that should not be |
| 3 | // world-readable (spec docs/superpowers/specs/2026-09-07-password-gate-design.md). |
| 4 | // API mode only, like `carlos features` and for the same reason: setting a |
| 5 | // gate is a console-authorized write with an audit row. |
| 6 | package main |
| 7 | |
| 8 | import ( |
| 9 | "bufio" |
| 10 | "context" |
| 11 | "errors" |
| 12 | "flag" |
| 13 | "fmt" |
| 14 | "io" |
| 15 | "log" |
| 16 | "os" |
| 17 | "strings" |
| 18 | ) |
| 19 | |
| 20 | const gateUsage = `usage: carlos gate <set|clear|list> [flags] |
| 21 | |
| 22 | set -app <a> [-host <h>] [-password <p>] |
| 23 | put a password in front of the app's instances (or one host); |
| 24 | without -password, one line is read from stdin |
| 25 | clear -app <a> [-host <h>] |
| 26 | turn the password off |
| 27 | list -app <a> which hosts have a password set |
| 28 | |
| 29 | -account <a> account (sqid or name; default "ops") |
| 30 | -account-id <id> account sqid, matched as a sqid ONLY |
| 31 | -console <origin> console to act through (default: the only logged-in one) |
| 32 | |
| 33 | Signed-in only (carlos auth login). Visitors to a gated host see a plain |
| 34 | page asking for the password and stay signed in for a week; changing or |
| 35 | clearing the password signs everyone out at once. The box picks the change |
| 36 | up within seconds of the next converge tick. |
| 37 | ` |
| 38 | |
| 39 | func runGate(args []string) { |
| 40 | if len(args) > 0 { |
| 41 | switch args[0] { |
| 42 | case "set": |
| 43 | runGateSet(args[1:]) |
| 44 | return |
| 45 | case "clear": |
| 46 | runGateClear(args[1:]) |
| 47 | return |
| 48 | case "list": |
| 49 | runGateList(args[1:]) |
| 50 | return |
| 51 | } |
| 52 | } |
| 53 | log.Fatal(gateUsage) |
| 54 | } |
| 55 | |
| 56 | func requireGateAPISession(consoleFlag string) *apiSession { |
| 57 | sess, err := resolveMode(consoleFlag) |
| 58 | if err != nil { |
| 59 | log.Fatal(err) |
| 60 | } |
| 61 | if sess == nil { |
| 62 | log.Fatal("carlos gate works through a console, and this shell is in direct bucket mode\n" + |
| 63 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A password gate is a console write with an\n" + |
| 64 | "audit row. Unset the store env and run: carlos auth login -console <url>.") |
| 65 | } |
| 66 | return sess |
| 67 | } |
| 68 | |
| 69 | // readGatePassword is the flag, else one line from stdin — so a password |
| 70 | // can be piped or typed without landing in shell history or ps output. An |
| 71 | // empty line is refused; the console's own length rule is applied there. |
| 72 | func readGatePassword(flagged string, stdin io.Reader) (string, error) { |
| 73 | if flagged != "" { |
| 74 | return flagged, nil |
| 75 | } |
| 76 | line, err := bufio.NewReader(stdin).ReadString('\n') |
| 77 | if err != nil && !errors.Is(err, io.EOF) { |
| 78 | return "", err |
| 79 | } |
| 80 | line = strings.TrimRight(line, "\r\n") |
| 81 | if line == "" { |
| 82 | return "", errors.New("no password given — pass -password or pipe one line on stdin") |
| 83 | } |
| 84 | return line, nil |
| 85 | } |
| 86 | |
| 87 | // gateHosts resolves the hosts a set/clear applies to: -host alone, else |
| 88 | // every instance record of the app. |
| 89 | func gateHosts(sess *apiSession, acct apiAcct, app, host string) []string { |
| 90 | if host != "" { |
| 91 | return []string{host} |
| 92 | } |
| 93 | recs, _, _, err := sess.instanceList(context.Background(), acct, app, "", "") |
| 94 | if err != nil { |
| 95 | log.Fatalf("gate: list instances: %v", err) |
| 96 | } |
| 97 | if len(recs) == 0 { |
| 98 | log.Fatalf("gate: %s has no instances — a gate is set per instance; create one first (carlos instances create)", app) |
| 99 | } |
| 100 | hosts := make([]string, 0, len(recs)) |
| 101 | for _, r := range recs { |
| 102 | hosts = append(hosts, r.Host) |
| 103 | } |
| 104 | return hosts |
| 105 | } |
| 106 | |
| 107 | func gateFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, *string) { |
| 108 | fs := flag.NewFlagSet("gate "+name, flag.ExitOnError) |
| 109 | app := appFlag(fs, "app whose instances to gate") |
| 110 | host := fs.String("host", "", "one instance host (default: every instance of the app)") |
| 111 | consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| 112 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 113 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| 114 | return fs, app, host, consoleFlag, acctRaw, acctIDRaw |
| 115 | } |
| 116 | |
| 117 | func runGateSet(args []string) { |
| 118 | fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("set") |
| 119 | pw := fs.String("password", "", "the password (default: one line from stdin)") |
| 120 | passwordGiven := false |
| 121 | fs.Parse(args) |
| 122 | fs.Visit(func(f *flag.Flag) { |
| 123 | if f.Name == "password" { |
| 124 | passwordGiven = true |
| 125 | } |
| 126 | }) |
| 127 | if passwordGiven && *pw == "" { |
| 128 | log.Fatal("gate set: -password is empty — give a password of 8 to 72 characters") |
| 129 | } |
| 130 | // The session is resolved BEFORE the password is read. Reading first |
| 131 | // meant a shell with no console sat there swallowing a typed password |
| 132 | // and only then said it could not use it — and the person has now typed |
| 133 | // a secret at a prompt that was never going to work, with no way to |
| 134 | // know where it went. |
| 135 | sess := requireGateAPISession(*consoleFlag) |
| 136 | // A pipe gets no prompt (nothing is there to read it, and it would land |
| 137 | // in whatever the caller is capturing); a person typing gets told what |
| 138 | // the waiting cursor is for, on stderr so a redirected stdout stays |
| 139 | // clean. isTerminal is color.go's dependency-free isatty — no x/term, |
| 140 | // and no echo suppression: the gate's plaintext is a shared site |
| 141 | // password that -password already puts in full view. |
| 142 | if !passwordGiven && isTerminal(os.Stdin) { |
| 143 | fmt.Fprint(os.Stderr, "Password: ") |
| 144 | } |
| 145 | password, err := readGatePassword(*pw, os.Stdin) |
| 146 | if err != nil { |
| 147 | log.Fatalf("gate set: %v", err) |
| 148 | } |
| 149 | printUsing(stdout, sess) |
| 150 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 151 | app2 := resolvedApp(sess, acct, *app, gateUsage) |
| 152 | failed := gateApply(sess, acct, app2, gateHosts(sess, acct, app2, strings.TrimSpace(*host)), password, stdout) |
| 153 | mutedf(stdout, " the box picks it up within seconds of the next converge tick; visitors already signed in are signed out") |
| 154 | if failed > 0 { |
| 155 | os.Exit(1) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // gateApply is the set/clear loop both verbs share: one PATCH per host, one |
| 160 | // line each, and the number that failed — which the callers turn into the |
| 161 | // exit code. A failure on one host does NOT stop the others: with no -host |
| 162 | // the fan-out is every instance of the app, and stopping at the first |
| 163 | // refusal would leave the operator guessing which of the rest took the |
| 164 | // password. password == "" is the clear. |
| 165 | func gateApply(sess *apiSession, acct apiAcct, app string, hosts []string, password string, w io.Writer) (failed int) { |
| 166 | bad, ok := "gate not set on", "password set on" |
| 167 | if password == "" { |
| 168 | bad, ok = "gate not cleared on", "password turned off on" |
| 169 | } |
| 170 | for _, h := range hosts { |
| 171 | if _, err := sess.instanceSetGate(context.Background(), acct, app, h, password); err != nil { |
| 172 | failed++ |
| 173 | fmt.Fprintf(w, "%s %s: %v\n", colorFor(w).bad(bad), h, err) |
| 174 | continue |
| 175 | } |
| 176 | fmt.Fprintf(w, "%s %s\n", colorFor(w).ok(ok), h) |
| 177 | } |
| 178 | return failed |
| 179 | } |
| 180 | |
| 181 | func runGateClear(args []string) { |
| 182 | fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("clear") |
| 183 | fs.Parse(args) |
| 184 | sess := requireGateAPISession(*consoleFlag) |
| 185 | printUsing(stdout, sess) |
| 186 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 187 | app2 := resolvedApp(sess, acct, *app, gateUsage) |
| 188 | failed := gateApply(sess, acct, app2, gateHosts(sess, acct, app2, strings.TrimSpace(*host)), "", stdout) |
| 189 | if failed > 0 { |
| 190 | os.Exit(1) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func runGateList(args []string) { |
| 195 | fs, app, _, consoleFlag, acctRaw, acctIDRaw := gateFlags("list") |
| 196 | fs.Parse(args) |
| 197 | sess := requireGateAPISession(*consoleFlag) |
| 198 | printUsing(stdout, sess) |
| 199 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 200 | app2 := resolvedApp(sess, acct, *app, gateUsage) |
| 201 | recs, _, _, err := sess.instanceList(context.Background(), acct, app2, "", "") |
| 202 | if err != nil { |
| 203 | log.Fatalf("gate list: %v", err) |
| 204 | } |
| 205 | printGateList(stdout, recs) |
| 206 | } |
| 207 | |
| 208 | // printGateList renders the GATE column's three states. The third one is |
| 209 | // the honest half: a console that predates the field sends no "gated" key, |
| 210 | // which decodes to nil, and printing "open" for that would tell someone |
| 211 | // checking whether a host is exposed exactly what they hoped to hear on no |
| 212 | // evidence at all. |
| 213 | func printGateList(out io.Writer, recs []apiInstanceRecord) { |
| 214 | if len(recs) == 0 { |
| 215 | fmt.Fprintln(out, "no instances") |
| 216 | return |
| 217 | } |
| 218 | w := newTable(out) |
| 219 | fmt.Fprintln(w, "HOST\tGATE") |
| 220 | for _, r := range recs { |
| 221 | state := "unknown — console predates gates" |
| 222 | switch { |
| 223 | case r.Gated == nil: |
| 224 | case *r.Gated: |
| 225 | state = "password set" |
| 226 | default: |
| 227 | state = "open" |
| 228 | } |
| 229 | fmt.Fprintf(w, "%s\t%s\n", r.Host, state) |
| 230 | } |
| 231 | w.Flush() |
| 232 | } |
| 233 | |