| new file mode 100644 |
| index 000000000..da4ea56e3 |
| --- /dev/null |
| +++ b/gate_cli.go |
| @@ -0,0 +1,199 @@ |
| +// 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 <set|clear|list> [flags] |
| + |
| + set -app <a> [-host <h>] [-password <p>] |
| + put a password in front of the app's instances (or one host); |
| + without -password, one line is read from stdin |
| + clear -app <a> [-host <h>] |
| + turn the password off |
| + list -app <a> which hosts have a password set |
| + |
| + -account <a> account (sqid or name; default "ops") |
| + -account-id <id> account sqid, matched as a sqid ONLY |
| + -console <origin> 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 <url>.") |
| + } |
| + 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 := fs.String("console", "", "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") |
| + } |
| + 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) |
| + } |
| + 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) |
| + } |
| +} |
| + |
| +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 := 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) |
| + } |
| + 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) |
| + } |
| + if len(recs) == 0 { |
| + fmt.Fprintln(stdout, "no instances") |
| + return |
| + } |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "HOST\tGATE") |
| + for _, r := range recs { |
| + state := "open" |
| + if r.Gated { |
| + state = "password set" |
| + } |
| + fmt.Fprintf(w, "%s\t%s\n", r.Host, state) |
| + } |
| + w.Flush() |
| +} |