carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
6package main
7
8import (
9 "bufio"
10 "context"
11 "errors"
12 "flag"
13 "fmt"
14 "io"
15 "log"
16 "os"
17 "strings"
18)
19
20const 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
33Signed-in only (carlos auth login). Visitors to a gated host see a plain
34page asking for the password and stay signed in for a week; changing or
35clearing the password signs everyone out at once. The box picks the change
36up within seconds of the next converge tick.
37`
38
39func 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
56func 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.
72func 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.
89func 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
107func 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
117func 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.
165func 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
181func 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
194func 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.
213func 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