| 1 | // The `carlos instances door-key` verb family (instance-moves P7, #175): the |
| 2 | // CLI front over the console's three door-key doors |
| 3 | // (internal/console/apidoorkeys.go) — mint, list and revoke the app-scoped |
| 4 | // credential that authenticates an app's own processes on a box to the |
| 5 | // edge-local door (a unix socket), whatever box the instance lives on. API |
| 6 | // mode only, the same as `carlos instances create/list/delete`: a door key |
| 7 | // is a console-authorized write, the console's database is the authority, |
| 8 | // and there is no store-mode equivalent. Modelled on runInstancesDelete |
| 9 | // (instances_cli.go) for the verb shape and mail_cli.go's emailDeclare/ |
| 10 | // emailStatus for the wire calls. |
| 11 | package main |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "encoding/json" |
| 16 | "flag" |
| 17 | "fmt" |
| 18 | "io" |
| 19 | "log" |
| 20 | "net/http" |
| 21 | "strings" |
| 22 | "time" |
| 23 | ) |
| 24 | |
| 25 | const doorKeyUsage = `usage: carlos instances door-key create --app <a> [--label <text>] |
| 26 | carlos instances door-key list --app <a> |
| 27 | carlos instances door-key revoke --app <a> <id> |
| 28 | |
| 29 | --account <a> account (sqid or name; default: your only account, |
| 30 | or the configured/chosen one) |
| 31 | --account-id <id> account sqid, matched as a sqid ONLY — supersedes |
| 32 | --account |
| 33 | --console <origin> console to act through (default: the only |
| 34 | logged-in one) |
| 35 | |
| 36 | Signed in only (carlos auth login). A door key is written through a console, which keeps it in its own database. There is no direct-bucket form. |
| 37 | |
| 38 | create mints a new key for the app and prints it once. Store it now: it cannot be shown again, and list only ever shows its id and label. |
| 39 | |
| 40 | list shows the app's live keys: id, label and when each was created. |
| 41 | |
| 42 | revoke takes an id from list and stops that key working. |
| 43 | ` |
| 44 | |
| 45 | // ---- wire types ------------------------------------------------------------- |
| 46 | |
| 47 | // apiDoorKeyMint is handleAPIDoorKeyMint's 201 reply |
| 48 | // (internal/console/apidoorkeys.go): the raw key, shown here and nowhere |
| 49 | // else — the console keeps only its digest from this point on. |
| 50 | type apiDoorKeyMint struct { |
| 51 | Key string `json:"key"` |
| 52 | ID string `json:"id"` |
| 53 | Account string `json:"account"` |
| 54 | App string `json:"app"` |
| 55 | } |
| 56 | |
| 57 | // apiDoorKeyEntry is one row of handleAPIDoorKeyList's reply — never the key |
| 58 | // itself, which the console does not keep in a recoverable form. |
| 59 | type apiDoorKeyEntry struct { |
| 60 | ID string `json:"id"` |
| 61 | Label string `json:"label"` |
| 62 | CreatedAt time.Time `json:"created_at"` |
| 63 | } |
| 64 | |
| 65 | // apiDoorKeyRevokeReply is handleAPIDoorKeyRevoke's 200 reply. |
| 66 | type apiDoorKeyRevokeReply struct { |
| 67 | Revoked string `json:"revoked"` |
| 68 | } |
| 69 | |
| 70 | // ---- apiSession wire calls --------------------------------------------------- |
| 71 | |
| 72 | func (s *apiSession) doorKeyMint(ctx context.Context, a apiAcct, app, label string) (apiDoorKeyMint, error) { |
| 73 | in := struct { |
| 74 | Label string `json:"label,omitempty"` |
| 75 | }{label} |
| 76 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/door-keys"), in) |
| 77 | if err != nil { |
| 78 | return apiDoorKeyMint{}, err |
| 79 | } |
| 80 | if status != http.StatusCreated { |
| 81 | return apiDoorKeyMint{}, apiErrorFromBody(status, body) |
| 82 | } |
| 83 | var out apiDoorKeyMint |
| 84 | if err := json.Unmarshal(body, &out); err != nil { |
| 85 | return apiDoorKeyMint{}, fmt.Errorf("door-key create: unreadable response: %w", err) |
| 86 | } |
| 87 | return out, nil |
| 88 | } |
| 89 | |
| 90 | func (s *apiSession) doorKeyList(ctx context.Context, a apiAcct, app string) ([]apiDoorKeyEntry, error) { |
| 91 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/door-keys")) |
| 92 | if err != nil { |
| 93 | return nil, err |
| 94 | } |
| 95 | if status != http.StatusOK { |
| 96 | return nil, apiErrorFromBody(status, body) |
| 97 | } |
| 98 | var res struct { |
| 99 | Keys []apiDoorKeyEntry `json:"keys"` |
| 100 | } |
| 101 | if err := json.Unmarshal(body, &res); err != nil { |
| 102 | return nil, fmt.Errorf("door-key list: unreadable response: %w", err) |
| 103 | } |
| 104 | return res.Keys, nil |
| 105 | } |
| 106 | |
| 107 | func (s *apiSession) doorKeyRevoke(ctx context.Context, a apiAcct, app, id string) (string, error) { |
| 108 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/door-keys/"+pathEscape(id)+"/revoke"), struct{}{}) |
| 109 | if err != nil { |
| 110 | return "", err |
| 111 | } |
| 112 | if status != http.StatusOK { |
| 113 | return "", apiErrorFromBody(status, body) |
| 114 | } |
| 115 | var out apiDoorKeyRevokeReply |
| 116 | if err := json.Unmarshal(body, &out); err != nil { |
| 117 | return "", fmt.Errorf("door-key revoke: unreadable response: %w", err) |
| 118 | } |
| 119 | return out.Revoked, nil |
| 120 | } |
| 121 | |
| 122 | // ---- verbs -------------------------------------------------------------- |
| 123 | |
| 124 | // runInstancesDoorKey dispatches the door-key sub-verbs — create/list/revoke |
| 125 | // are positional words, not flags, and must never reach fs.Parse, the same |
| 126 | // shape runInstances and runDomains use for their own sub-verbs. |
| 127 | func runInstancesDoorKey(args []string) { |
| 128 | if len(args) > 0 { |
| 129 | switch args[0] { |
| 130 | case "create": |
| 131 | runInstancesDoorKeyCreate(args[1:]) |
| 132 | return |
| 133 | case "list": |
| 134 | runInstancesDoorKeyList(args[1:]) |
| 135 | return |
| 136 | case "revoke": |
| 137 | runInstancesDoorKeyRevoke(args[1:]) |
| 138 | return |
| 139 | } |
| 140 | } |
| 141 | log.Fatal(doorKeyUsage) |
| 142 | } |
| 143 | |
| 144 | func runInstancesDoorKeyCreate(args []string) { |
| 145 | fs := flag.NewFlagSet("instances door-key create", flag.ExitOnError) |
| 146 | app := appFlag(fs, "app to mint a door key for") |
| 147 | label := fs.String("label", "", "short note stored with the key and shown by list (default: none)") |
| 148 | consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)") |
| 149 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 150 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 151 | fs.Usage = flagUsage(fs, doorKeyUsage) |
| 152 | fs.Parse(args) |
| 153 | sess := requireAPISession("door-key create", *consoleFlag) |
| 154 | printUsing(stdout, sess) |
| 155 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 156 | app2 := resolvedApp(sess, acct, *app, doorKeyUsage) |
| 157 | res, err := sess.doorKeyMint(context.Background(), acct, app2, strings.TrimSpace(*label)) |
| 158 | if err != nil { |
| 159 | log.Fatalf("instances door-key create: %v", err) |
| 160 | } |
| 161 | printDoorKeyCreate(stdout, res) |
| 162 | } |
| 163 | |
| 164 | // printDoorKeyCreate prints the raw key on its own line, so it is the one |
| 165 | // thing a script capturing this command's stdout has to parse out, then a |
| 166 | // warning that this is the only time it will ever be shown — the console |
| 167 | // keeps only its digest from here on, the same rule email credentials |
| 168 | // create follows for a standalone credential's password. |
| 169 | func printDoorKeyCreate(w io.Writer, res apiDoorKeyMint) { |
| 170 | fmt.Fprintln(w, res.Key) |
| 171 | warnf(w, "shown once. store it now: it cannot be shown again") |
| 172 | } |
| 173 | |
| 174 | func runInstancesDoorKeyList(args []string) { |
| 175 | fs := flag.NewFlagSet("instances door-key list", flag.ExitOnError) |
| 176 | app := appFlag(fs, "app whose door keys to list") |
| 177 | consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 178 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 179 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 180 | fs.Usage = flagUsage(fs, doorKeyUsage) |
| 181 | fs.Parse(args) |
| 182 | sess := requireAPISession("door-key list", *consoleFlag) |
| 183 | printUsing(stdout, sess) |
| 184 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 185 | app2 := resolvedApp(sess, acct, *app, doorKeyUsage) |
| 186 | keys, err := sess.doorKeyList(context.Background(), acct, app2) |
| 187 | if err != nil { |
| 188 | log.Fatalf("instances door-key list: %v", err) |
| 189 | } |
| 190 | printDoorKeyList(stdout, keys) |
| 191 | } |
| 192 | |
| 193 | // printDoorKeyList never prints a key — the list reply this reads from does |
| 194 | // not carry one (apiDoorKeyEntry has no such field at all), so there is |
| 195 | // nothing here to leak by construction. |
| 196 | func printDoorKeyList(w io.Writer, keys []apiDoorKeyEntry) { |
| 197 | if len(keys) == 0 { |
| 198 | mutedf(w, "no door keys") |
| 199 | return |
| 200 | } |
| 201 | t := newTable(w) |
| 202 | fmt.Fprintf(t, "ID\tLABEL\tCREATED\n") |
| 203 | for _, k := range keys { |
| 204 | label := k.Label |
| 205 | if label == "" { |
| 206 | label = "-" |
| 207 | } |
| 208 | fmt.Fprintf(t, "%s\t%s\t%s\n", k.ID, label, k.CreatedAt.Format(time.RFC3339)) |
| 209 | } |
| 210 | t.Flush() |
| 211 | } |
| 212 | |
| 213 | func runInstancesDoorKeyRevoke(args []string) { |
| 214 | fs := flag.NewFlagSet("instances door-key revoke", flag.ExitOnError) |
| 215 | app := appFlag(fs, "app the door key belongs to") |
| 216 | consoleFlag := consoleOption(fs, "console to revoke through (default: the only logged-in one)") |
| 217 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 218 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 219 | fs.Usage = flagUsage(fs, doorKeyUsage) |
| 220 | fs.Parse(args) |
| 221 | id := strings.TrimSpace(fs.Arg(0)) |
| 222 | if id == "" || fs.NArg() > 1 { |
| 223 | log.Fatal(doorKeyUsage) |
| 224 | } |
| 225 | sess := requireAPISession("door-key revoke", *consoleFlag) |
| 226 | printUsing(stdout, sess) |
| 227 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 228 | app2 := resolvedApp(sess, acct, *app, doorKeyUsage) |
| 229 | revoked, err := sess.doorKeyRevoke(context.Background(), acct, app2, id) |
| 230 | if err != nil { |
| 231 | log.Fatalf("instances door-key revoke: %v", err) |
| 232 | } |
| 233 | fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("revoked"), revoked) |
| 234 | } |
| 235 | |