| 1 | // The `carlos auth` verb: CLI identity. |
| 2 | // |
| 3 | // Before this, the CLI's authority was "whoever holds the release key or can |
| 4 | // reach the box", and `carlos accounts create` had nobody to name as an |
| 5 | // account's owner — which produced accounts their own creator could not open |
| 6 | // in the console. `carlos auth login` gives the command line an identity, and |
| 7 | // that identity is what owns the accounts it creates. |
| 8 | // |
| 9 | // The flow, and why it is shaped this way: |
| 10 | // |
| 11 | // carlos auth login --console https://platform.carlosframework.com |
| 12 | // → POST /auth/cli/start the console mints a short typed code |
| 13 | // for the human and a long device code |
| 14 | // for this process |
| 15 | // → print the URL and the code the human opens it in ANY browser they |
| 16 | // are signed in to and approves |
| 17 | // → POST /auth/cli/poll (loop) the console hands the token back down |
| 18 | // this channel, once |
| 19 | // → ~/.carlos/credentials mode 0600, one entry per console origin |
| 20 | // |
| 21 | // There is deliberately no local callback listener. A browser-redirect flow |
| 22 | // needs the browser to reach a port on the machine running the CLI, and the |
| 23 | // machine running the CLI is very often an SSH session on a box or a CI |
| 24 | // shell where that is simply false. A code you can retype from a phone works |
| 25 | // everywhere, which is the whole point. |
| 26 | // |
| 27 | // The console — not Keymail — is the authority here. It is already a |
| 28 | // registered Keymail OAuth client and already knows your accounts and roles, |
| 29 | // so the CLI trusts the deployment the operator actually runs. |
| 30 | package main |
| 31 | |
| 32 | import ( |
| 33 | "bytes" |
| 34 | "context" |
| 35 | "encoding/json" |
| 36 | "errors" |
| 37 | "flag" |
| 38 | "fmt" |
| 39 | "io" |
| 40 | "mime/multipart" |
| 41 | "net" |
| 42 | "net/http" |
| 43 | "net/url" |
| 44 | "os" |
| 45 | "os/user" |
| 46 | "path/filepath" |
| 47 | "strings" |
| 48 | "time" |
| 49 | ) |
| 50 | |
| 51 | const authUsage = `usage: carlos auth <login|whoami|logout|default> |
| 52 | |
| 53 | login [--console <url>] log this terminal in to a deployment's console |
| 54 | (default: the configured default console, else |
| 55 | https://platform.carlosframework.com). Prints a |
| 56 | code; you approve it in a browser that is already |
| 57 | signed in to that console. No browser on this |
| 58 | machine is needed — the code travels. |
| 59 | [--label <name>] what to call this terminal on the console's |
| 60 | command-line access page (default: user@host) |
| 61 | whoami [--console <url>] ask the console who this terminal is |
| 62 | logout [--console <url>] revoke this terminal's token and forget it |
| 63 | default [--project] [-d <destination>] [<url>] |
| 64 | show the default console and where it is set, or |
| 65 | set it: writes ~/.carlos/config — or, with |
| 66 | --project, ./.carlos/config, which overrides the |
| 67 | global and is meant to be committed so a checkout |
| 68 | pins its own deployment. -d reads or writes that |
| 69 | destination's console ("carlos config |
| 70 | destinations" manages the rest) |
| 71 | |
| 72 | The token lands in ~/.carlos/credentials (mode 0600), one entry per console |
| 73 | origin — a laptop can hold credentials for the flagship and a self-hosted |
| 74 | deployment at once. --console may be omitted when there is exactly one, or |
| 75 | when a default is set. |
| 76 | |
| 77 | HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human, |
| 78 | and a long-lived token on it is a standing credential nobody is watching. |
| 79 | Pass --owner <address> to "carlos accounts create" instead — that is the |
| 80 | documented path, and it needs no identity of its own. |
| 81 | ` |
| 82 | |
| 83 | // RunAuth dispatches the auth sub-verbs. |
| 84 | func RunAuth(args []string) { |
| 85 | if len(args) == 0 { |
| 86 | fmt.Fprint(os.Stderr, authUsage) |
| 87 | os.Exit(1) |
| 88 | } |
| 89 | var err error |
| 90 | switch args[0] { |
| 91 | case "login": |
| 92 | err = authLogin(os.Stdout, args[1:]) |
| 93 | case "whoami": |
| 94 | err = authWhoami(os.Stdout, args[1:]) |
| 95 | case "logout": |
| 96 | err = authLogout(os.Stdout, args[1:]) |
| 97 | case "default": |
| 98 | err = authDefault(os.Stdout, args[1:]) |
| 99 | default: |
| 100 | fmt.Fprint(os.Stderr, authUsage) |
| 101 | os.Exit(1) |
| 102 | } |
| 103 | if err != nil { |
| 104 | fmt.Fprintf(os.Stderr, "%s %v\n", colorFor(os.Stderr).bad("carlos auth "+args[0]+":"), err) |
| 105 | os.Exit(1) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // ---- the credentials file --------------------------------------------------- |
| 110 | |
| 111 | // credential is one console's token. The token is the whole secret: it is |
| 112 | // never printed, never logged, and the file it lives in is 0600. |
| 113 | type credential struct { |
| 114 | Console string `json:"console"` |
| 115 | Address string `json:"address"` |
| 116 | Token string `json:"token"` |
| 117 | Label string `json:"label,omitempty"` |
| 118 | CreatedAt time.Time `json:"created_at"` |
| 119 | ExpiresAt time.Time `json:"expires_at"` |
| 120 | } |
| 121 | |
| 122 | // Expired reports whether this credential is past its console-declared life. |
| 123 | // A zero ExpiresAt is treated as live: a console that declined to say is not |
| 124 | // the same as one that said "now". |
| 125 | func (c credential) Expired(now time.Time) bool { |
| 126 | return !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt) |
| 127 | } |
| 128 | |
| 129 | // credentialFile is the whole file. A list rather than a map so the on-disk |
| 130 | // order is stable and a human can read it. |
| 131 | type credentialFile struct { |
| 132 | Version int `json:"version"` |
| 133 | Credentials []credential `json:"credentials"` |
| 134 | } |
| 135 | |
| 136 | // credentialsPath is CARLOS_CREDENTIALS, else ~/.carlos/credentials. The env |
| 137 | // override exists for tests and for anyone keeping their credential somewhere |
| 138 | // other than $HOME (a per-project shell, a tmpfs). |
| 139 | func credentialsPath() string { |
| 140 | if p := os.Getenv("CARLOS_CREDENTIALS"); p != "" { |
| 141 | return p |
| 142 | } |
| 143 | home, err := os.UserHomeDir() |
| 144 | if err != nil { |
| 145 | return ".carlos/credentials" |
| 146 | } |
| 147 | return filepath.Join(home, ".carlos", "credentials") |
| 148 | } |
| 149 | |
| 150 | // loadCredentials reads the file. A missing file is an empty one, not an |
| 151 | // error: "you are not logged in" is a state, and every caller has to handle |
| 152 | // it anyway. |
| 153 | func loadCredentials() (*credentialFile, error) { |
| 154 | f := &credentialFile{Version: 1} |
| 155 | raw, err := os.ReadFile(credentialsPath()) |
| 156 | if errors.Is(err, os.ErrNotExist) { |
| 157 | return f, nil |
| 158 | } |
| 159 | if err != nil { |
| 160 | return nil, err |
| 161 | } |
| 162 | if len(bytes.TrimSpace(raw)) == 0 { |
| 163 | return f, nil |
| 164 | } |
| 165 | if err := json.Unmarshal(raw, f); err != nil { |
| 166 | return nil, fmt.Errorf("%s is not readable as credentials: %w", credentialsPath(), err) |
| 167 | } |
| 168 | f.Version = 1 |
| 169 | return f, nil |
| 170 | } |
| 171 | |
| 172 | // save writes the file 0600, through a temp file in the same directory so a |
| 173 | // crash mid-write cannot leave a truncated credential store. |
| 174 | func (f *credentialFile) save() error { |
| 175 | path := credentialsPath() |
| 176 | dir := filepath.Dir(path) |
| 177 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 178 | return err |
| 179 | } |
| 180 | f.Version = 1 |
| 181 | body, err := json.MarshalIndent(f, "", " ") |
| 182 | if err != nil { |
| 183 | return err |
| 184 | } |
| 185 | body = append(body, '\n') |
| 186 | tmp, err := os.CreateTemp(dir, ".credentials-*") |
| 187 | if err != nil { |
| 188 | return err |
| 189 | } |
| 190 | defer os.Remove(tmp.Name()) |
| 191 | // Chmod before the write, not after: the secret must never exist on disk, |
| 192 | // even for a moment, at the temp file's default permissions. |
| 193 | if err := tmp.Chmod(0o600); err != nil { |
| 194 | tmp.Close() |
| 195 | return err |
| 196 | } |
| 197 | if _, err := tmp.Write(body); err != nil { |
| 198 | tmp.Close() |
| 199 | return err |
| 200 | } |
| 201 | if err := tmp.Close(); err != nil { |
| 202 | return err |
| 203 | } |
| 204 | return os.Rename(tmp.Name(), path) |
| 205 | } |
| 206 | |
| 207 | // find returns the credential for a console origin, or nil. |
| 208 | func (f *credentialFile) find(console string) *credential { |
| 209 | want, err := normalizeConsole(console) |
| 210 | if err != nil { |
| 211 | return nil |
| 212 | } |
| 213 | for i := range f.Credentials { |
| 214 | if f.Credentials[i].Console == want { |
| 215 | return &f.Credentials[i] |
| 216 | } |
| 217 | } |
| 218 | return nil |
| 219 | } |
| 220 | |
| 221 | // put stores a credential, replacing any existing entry for that console. |
| 222 | // One entry per origin: logging in again to one deployment must never disturb |
| 223 | // another. |
| 224 | func (f *credentialFile) put(c credential) { |
| 225 | if n, err := normalizeConsole(c.Console); err == nil { |
| 226 | c.Console = n |
| 227 | } |
| 228 | for i := range f.Credentials { |
| 229 | if f.Credentials[i].Console == c.Console { |
| 230 | f.Credentials[i] = c |
| 231 | return |
| 232 | } |
| 233 | } |
| 234 | f.Credentials = append(f.Credentials, c) |
| 235 | } |
| 236 | |
| 237 | // remove drops a console's credential, reporting whether there was one. |
| 238 | func (f *credentialFile) remove(console string) bool { |
| 239 | want, err := normalizeConsole(console) |
| 240 | if err != nil { |
| 241 | return false |
| 242 | } |
| 243 | for i := range f.Credentials { |
| 244 | if f.Credentials[i].Console == want { |
| 245 | f.Credentials = append(f.Credentials[:i], f.Credentials[i+1:]...) |
| 246 | return true |
| 247 | } |
| 248 | } |
| 249 | return false |
| 250 | } |
| 251 | |
| 252 | // pick resolves which credential a verb should use: an explicit --console, |
| 253 | // else CARLOS_CONSOLE, else the configured default (.carlos/config walking |
| 254 | // up from cwd, then ~/.carlos/config — config.go), else the only live |
| 255 | // credential. Several with no default is a refusal rather than a guess — |
| 256 | // shipping to the wrong deployment because the CLI picked one for you is |
| 257 | // not a mistake worth being convenient about. |
| 258 | // |
| 259 | // Any explicitly named console — flag, env, or config — is authoritative: |
| 260 | // it resolves against credentials for THAT console or errors. It never |
| 261 | // falls back to "the only credential", because inside a checkout pinned to |
| 262 | // one stack, quietly using a credential for a different stack is the wrong |
| 263 | // deployment with extra steps. |
| 264 | func (f *credentialFile) pick(console string) (*credential, error) { |
| 265 | if console == "" { |
| 266 | console = os.Getenv("CARLOS_CONSOLE") |
| 267 | } |
| 268 | from := "" |
| 269 | if console == "" { |
| 270 | var src string |
| 271 | var err error |
| 272 | console, src, err = configDefault() |
| 273 | if err != nil { |
| 274 | return nil, err |
| 275 | } |
| 276 | if console != "" { |
| 277 | // Only a config-sourced console gets the clause: whoever typed |
| 278 | // --console or exported CARLOS_CONSOLE knows where it came from, |
| 279 | // but a default set weeks ago in a file needs naming. |
| 280 | from = fmt.Sprintf(" (the default from %s)", src) |
| 281 | } |
| 282 | } |
| 283 | if console != "" { |
| 284 | want, err := normalizeConsole(console) |
| 285 | if err != nil { |
| 286 | return nil, err |
| 287 | } |
| 288 | c := f.find(want) |
| 289 | if c == nil { |
| 290 | return nil, ¬LoggedInError{Console: want, |
| 291 | msg: fmt.Sprintf("not logged in to %s%s — run: carlos auth login --console %s", want, from, want)} |
| 292 | } |
| 293 | if c.Expired(time.Now()) { |
| 294 | return nil, ¬LoggedInError{Console: want, Expired: true, |
| 295 | msg: fmt.Sprintf("the credential for %s%s expired %s — run: carlos auth login --console %s", |
| 296 | want, from, c.ExpiresAt.Format(time.RFC3339), want)} |
| 297 | } |
| 298 | return c, nil |
| 299 | } |
| 300 | var live []*credential |
| 301 | for i := range f.Credentials { |
| 302 | if !f.Credentials[i].Expired(time.Now()) { |
| 303 | live = append(live, &f.Credentials[i]) |
| 304 | } |
| 305 | } |
| 306 | switch len(live) { |
| 307 | case 1: |
| 308 | return live[0], nil |
| 309 | case 0: |
| 310 | // EXACT wording — apimode.go's noCredentialsMsg matches this string. |
| 311 | return nil, errors.New("not logged in — run: carlos auth login --console <url>") |
| 312 | default: |
| 313 | var names []string |
| 314 | for _, c := range live { |
| 315 | names = append(names, c.Console) |
| 316 | } |
| 317 | return nil, fmt.Errorf("logged in to several consoles — say which with --console, "+ |
| 318 | "or set a default with `carlos auth default <url>`: %s", |
| 319 | strings.Join(names, ", ")) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // notLoggedInError is pick's refusal when a NAMED console has no live |
| 324 | // credential — typed so resolveMode's interactive path (#162) can see |
| 325 | // which console to offer a login for, while Error() stays byte-identical |
| 326 | // to the strings scripts have matched since the credential file existed. |
| 327 | type notLoggedInError struct { |
| 328 | Console string |
| 329 | Expired bool |
| 330 | msg string |
| 331 | } |
| 332 | |
| 333 | func (e *notLoggedInError) Error() string { return e.msg } |
| 334 | |
| 335 | // credentialPermWarning reports a credentials file other users can read. |
| 336 | // A warning rather than a refusal: locking someone out of their own tooling |
| 337 | // over a mode bit helps nobody, but saying nothing lets a world-readable |
| 338 | // bearer token sit there unnoticed. |
| 339 | func credentialPermWarning() string { |
| 340 | path := credentialsPath() |
| 341 | fi, err := os.Stat(path) |
| 342 | if err != nil { |
| 343 | return "" |
| 344 | } |
| 345 | if fi.Mode().Perm()&0o077 == 0 { |
| 346 | return "" |
| 347 | } |
| 348 | return fmt.Sprintf("warning: %s is mode %04o — it holds a bearer token. Fix with: chmod 600 %s", |
| 349 | path, fi.Mode().Perm(), path) |
| 350 | } |
| 351 | |
| 352 | // normalizeConsole turns whatever was typed into the origin the console calls |
| 353 | // itself (its OAuth client_id): scheme + host, no trailing slash, no path. |
| 354 | func normalizeConsole(in string) (string, error) { |
| 355 | in = strings.TrimSpace(in) |
| 356 | if in == "" { |
| 357 | return "", errors.New("no console URL") |
| 358 | } |
| 359 | u, err := url.Parse(in) |
| 360 | if err != nil { |
| 361 | return "", fmt.Errorf("%q is not a URL: %w", in, err) |
| 362 | } |
| 363 | if u.Scheme != "https" && u.Scheme != "http" { |
| 364 | return "", fmt.Errorf("%q needs an https:// (or http:// for a local console) URL", in) |
| 365 | } |
| 366 | if u.Host == "" { |
| 367 | return "", fmt.Errorf("%q has no host", in) |
| 368 | } |
| 369 | // http is for a console on this machine and nowhere else. Over http the |
| 370 | // login flow hands a 90-day bearer token to anyone on the path, and a |
| 371 | // typo'd or attacker-supplied http:// origin is a credential posted in |
| 372 | // clear text to a stranger. |
| 373 | if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { |
| 374 | return "", fmt.Errorf("%q is http:// but not local — a login token would cross the network in clear text. Use https://", in) |
| 375 | } |
| 376 | return u.Scheme + "://" + u.Host, nil |
| 377 | } |
| 378 | |
| 379 | // isLoopbackHost reports whether a URL host is this machine. |
| 380 | func isLoopbackHost(host string) bool { |
| 381 | if host == "localhost" { |
| 382 | return true |
| 383 | } |
| 384 | ip := net.ParseIP(host) |
| 385 | return ip != nil && ip.IsLoopback() |
| 386 | } |
| 387 | |
| 388 | // ---- talking to the console ------------------------------------------------- |
| 389 | |
| 390 | // consoleClient is the CLI's half of the console's JSON API. |
| 391 | type consoleClient struct { |
| 392 | base string |
| 393 | token string |
| 394 | httpc *http.Client |
| 395 | } |
| 396 | |
| 397 | func newConsoleClient(base, token string) *consoleClient { |
| 398 | // Redirects are NEVER followed (#160): Go's default policy follows a |
| 399 | // cross-host GET with Authorization stripped (a mystery 401 from the |
| 400 | // new host) and surfaces unreplayable POSTs as raw 3xx — both wrong. |
| 401 | // Refusing uniformly lets every method see the 3xx + Location, which |
| 402 | // consoleMoved (apimode.go) turns into an explanation and an offer. |
| 403 | return &consoleClient{base: base, token: token, httpc: &http.Client{ |
| 404 | Timeout: 30 * time.Second, |
| 405 | CheckRedirect: neverFollow, |
| 406 | }} |
| 407 | } |
| 408 | |
| 409 | // do runs one JSON round-trip. A non-200 comes back carrying the console's |
| 410 | // own {"error": …} text, because that text is written for the person at the |
| 411 | // terminal. |
| 412 | func (c *consoleClient) do(ctx context.Context, method, path string, in, out any) (int, error) { |
| 413 | var body io.Reader |
| 414 | if in != nil { |
| 415 | raw, err := json.Marshal(in) |
| 416 | if err != nil { |
| 417 | return 0, err |
| 418 | } |
| 419 | body = bytes.NewReader(raw) |
| 420 | } |
| 421 | req, err := http.NewRequestWithContext(ctx, method, c.base+path, body) |
| 422 | if err != nil { |
| 423 | return 0, err |
| 424 | } |
| 425 | if in != nil { |
| 426 | req.Header.Set("Content-Type", "application/json") |
| 427 | } |
| 428 | if c.token != "" { |
| 429 | req.Header.Set("Authorization", "Bearer "+c.token) |
| 430 | } |
| 431 | res, err := c.httpc.Do(req) |
| 432 | if err != nil { |
| 433 | return 0, err |
| 434 | } |
| 435 | defer res.Body.Close() |
| 436 | if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { |
| 437 | return res.StatusCode, offerConsoleMoved(merr) |
| 438 | } |
| 439 | raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| 440 | if err != nil { |
| 441 | return res.StatusCode, err |
| 442 | } |
| 443 | if res.StatusCode != http.StatusOK { |
| 444 | var e struct { |
| 445 | Error string `json:"error"` |
| 446 | } |
| 447 | msg := res.Status |
| 448 | if json.Unmarshal(raw, &e) == nil && e.Error != "" { |
| 449 | msg = e.Error |
| 450 | } |
| 451 | return res.StatusCode, fmt.Errorf("%s", msg) |
| 452 | } |
| 453 | if out != nil { |
| 454 | if err := json.Unmarshal(raw, out); err != nil { |
| 455 | return res.StatusCode, fmt.Errorf("%s %s: unreadable response: %w", method, path, err) |
| 456 | } |
| 457 | } |
| 458 | return res.StatusCode, nil |
| 459 | } |
| 460 | |
| 461 | // shipHTTPClientTimeout is the ABSOLUTE per-request Timeout doMultipart's own |
| 462 | // client carries — zero (the production default) means none: a ship relies |
| 463 | // solely on its caller's context deadline (apimode.go's ship gives itself a |
| 464 | // generous 15 minutes), because the console's own budget for a big upload — |
| 465 | // up to 10 minutes to read the body, up to 2 more to store it |
| 466 | // (internal/console/apicli.go) — already exceeds the 30s absolute Timeout |
| 467 | // c.httpc carries for the JSON verbs. Reusing that client here would abort a |
| 468 | // large, still-healthy upload CLIENT-side while the server was still working |
| 469 | // on it, reporting a live ship as failed. |
| 470 | // |
| 471 | // A var only so a test can shrink it far below what any real upload takes — |
| 472 | // set it small against a deliberately slow server and the request fails |
| 473 | // fast, proving the failure mode the zero-Timeout client is built to avoid, |
| 474 | // without an actual multi-minute sleep to demonstrate it. |
| 475 | var shipHTTPClientTimeout time.Duration |
| 476 | |
| 477 | // doMultipart runs one multipart/form-data POST, streaming the body through |
| 478 | // an io.Pipe rather than buffering it whole: write feeds the multipart.Writer |
| 479 | // from a goroutine while http.Client reads the other end of the pipe as it |
| 480 | // sends the request, so a 30MiB artifact is never held twice over (once in |
| 481 | // caller memory, once again in an http.Client-owned buffer). |
| 482 | // |
| 483 | // Unlike do(), a non-200 status here is NOT turned into an error — the raw |
| 484 | // body comes back alongside the status so a caller that needs more than a |
| 485 | // bare {"error"} string (a stable-refusal's console_url, ship()'s per- |
| 486 | // artifact result) can decode it itself. |
| 487 | func (c *consoleClient) doMultipart(ctx context.Context, path string, write func(*multipart.Writer) error) (int, []byte, error) { |
| 488 | return c.doMultipartMethod(ctx, http.MethodPost, path, write) |
| 489 | } |
| 490 | |
| 491 | // doMultipartMethod is doMultipart over any method: `carlos system update`'s |
| 492 | // local-binary upload goes to a PUT door (PUT /api/cli/system/upload), and |
| 493 | // Go's ServeMux matches the method as part of the pattern, so a POST there |
| 494 | // is a 405 rather than an upload. Everything else — the pipe, the client and |
| 495 | // the raw non-2xx body — is doMultipart's, unchanged and not a second copy. |
| 496 | func (c *consoleClient) doMultipartMethod(ctx context.Context, method, path string, write func(*multipart.Writer) error) (int, []byte, error) { |
| 497 | return c.doMultipartHeaders(ctx, method, path, nil, write) |
| 498 | } |
| 499 | |
| 500 | // doMultipartHeaders is doMultipartMethod with extra request headers, such |
| 501 | // as a run's Idempotency-Key. They are set before the content type and the |
| 502 | // bearer, which they cannot override. |
| 503 | func (c *consoleClient) doMultipartHeaders(ctx context.Context, method, path string, headers http.Header, write func(*multipart.Writer) error) (int, []byte, error) { |
| 504 | pr, pw := io.Pipe() |
| 505 | mw := multipart.NewWriter(pw) |
| 506 | go func() { |
| 507 | err := write(mw) |
| 508 | if err == nil { |
| 509 | err = mw.Close() |
| 510 | } |
| 511 | pw.CloseWithError(err) |
| 512 | }() |
| 513 | req, err := http.NewRequestWithContext(ctx, method, c.base+path, pr) |
| 514 | if err != nil { |
| 515 | // The write goroutine above is already running (or about to run) and |
| 516 | // will otherwise block forever trying to write to a pipe nobody ever |
| 517 | // reads: closing our end unblocks it with io.ErrClosedPipe instead of |
| 518 | // leaking it. |
| 519 | pr.Close() |
| 520 | return 0, nil, err |
| 521 | } |
| 522 | for k, vs := range headers { |
| 523 | req.Header[k] = vs |
| 524 | } |
| 525 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 526 | if c.token != "" { |
| 527 | req.Header.Set("Authorization", "Bearer "+c.token) |
| 528 | } |
| 529 | // A client of its own — see shipHTTPClientTimeout above — never c.httpc, |
| 530 | // whose 30s absolute Timeout is sized for the JSON verbs, not a large |
| 531 | // upload. Same no-redirect rule as c.httpc, same reason. |
| 532 | client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow} |
| 533 | res, err := client.Do(req) |
| 534 | if err != nil { |
| 535 | return 0, nil, err |
| 536 | } |
| 537 | defer res.Body.Close() |
| 538 | if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { |
| 539 | return res.StatusCode, nil, offerConsoleMoved(merr) |
| 540 | } |
| 541 | body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| 542 | if err != nil { |
| 543 | return res.StatusCode, nil, err |
| 544 | } |
| 545 | return res.StatusCode, body, nil |
| 546 | } |
| 547 | |
| 548 | // doStream runs one request whose response body is a STREAM — `carlos db` |
| 549 | // reading a replica file down — and hands the response back unread, so the |
| 550 | // caller copies the body where it is going without holding it whole. |
| 551 | // header carries the caller's extras (a Range) on top of the bearer. |
| 552 | // |
| 553 | // Same client shape as doMultipart, for the same reason: no absolute |
| 554 | // Timeout (the caller's context is the budget, and a large file over a slow |
| 555 | // link outlives the JSON client's 30s), and no redirects. A non-2xx status |
| 556 | // is NOT an error here: the caller reads the body itself. The caller closes |
| 557 | // the response body. |
| 558 | func (c *consoleClient) doStream(ctx context.Context, method, path string, body io.Reader, contentLength int64, header http.Header) (*http.Response, error) { |
| 559 | req, err := http.NewRequestWithContext(ctx, method, c.base+path, body) |
| 560 | if err != nil { |
| 561 | return nil, err |
| 562 | } |
| 563 | if body != nil && contentLength >= 0 { |
| 564 | req.ContentLength = contentLength |
| 565 | } |
| 566 | for k, vs := range header { |
| 567 | for _, v := range vs { |
| 568 | req.Header.Add(k, v) |
| 569 | } |
| 570 | } |
| 571 | if c.token != "" { |
| 572 | req.Header.Set("Authorization", "Bearer "+c.token) |
| 573 | } |
| 574 | client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow} |
| 575 | res, err := client.Do(req) |
| 576 | if err != nil { |
| 577 | return nil, err |
| 578 | } |
| 579 | if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { |
| 580 | res.Body.Close() |
| 581 | return nil, offerConsoleMoved(merr) |
| 582 | } |
| 583 | return res, nil |
| 584 | } |
| 585 | |
| 586 | // neverFollow is the one redirect policy every console client uses. |
| 587 | func neverFollow(*http.Request, []*http.Request) error { |
| 588 | return http.ErrUseLastResponse |
| 589 | } |
| 590 | |
| 591 | type startResponse struct { |
| 592 | UserCode string `json:"user_code"` |
| 593 | DeviceCode string `json:"device_code"` |
| 594 | VerifyURI string `json:"verification_uri"` |
| 595 | VerifyURIFull string `json:"verification_uri_complete"` |
| 596 | Interval int `json:"interval"` |
| 597 | ExpiresIn int `json:"expires_in"` |
| 598 | } |
| 599 | |
| 600 | type pollResponse struct { |
| 601 | Status string `json:"status"` |
| 602 | Token string `json:"token"` |
| 603 | Address string `json:"address"` |
| 604 | ExpiresAt string `json:"expires_at"` |
| 605 | Interval int `json:"interval"` |
| 606 | } |
| 607 | |
| 608 | type whoamiResponse struct { |
| 609 | Address string `json:"address"` |
| 610 | Console string `json:"console"` |
| 611 | Label string `json:"label"` |
| 612 | ExpiresAt string `json:"expires_at"` |
| 613 | } |
| 614 | |
| 615 | // ---- verbs ------------------------------------------------------------------ |
| 616 | |
| 617 | // defaultLabel is what this terminal calls itself on the console's |
| 618 | // command-line access page: user@host, so a person with three machines can |
| 619 | // tell which token to revoke. |
| 620 | func defaultLabel() string { |
| 621 | name := "someone" |
| 622 | if u, err := user.Current(); err == nil && u.Username != "" { |
| 623 | name = u.Username |
| 624 | } |
| 625 | host, err := os.Hostname() |
| 626 | if err != nil || host == "" { |
| 627 | host = "unknown-host" |
| 628 | } |
| 629 | return name + "@" + host |
| 630 | } |
| 631 | |
| 632 | // Bounds on what a console can talk this process into doing. It is a console |
| 633 | // we are about to trust with our identity, so this is not a threat model so |
| 634 | // much as a refusal to turn two JSON fields into a CPU burner: a zero interval |
| 635 | // is a hot loop, and an enormous expires_in is a hot loop that never ends. |
| 636 | const ( |
| 637 | minPollWait = time.Second |
| 638 | maxPollWait = 30 * time.Second |
| 639 | maxLoginWait = 15 * time.Minute |
| 640 | ) |
| 641 | |
| 642 | // pollWait bounds the sleep between polls, floor included: interval 0 must |
| 643 | // never mean "spin". |
| 644 | func pollWait(seconds int) time.Duration { |
| 645 | d := time.Duration(seconds) * time.Second |
| 646 | if d < minPollWait { |
| 647 | return minPollWait |
| 648 | } |
| 649 | if d > maxPollWait { |
| 650 | return maxPollWait |
| 651 | } |
| 652 | return d |
| 653 | } |
| 654 | |
| 655 | // loginDeadline bounds how long the CLI waits overall, whatever expires_in says. |
| 656 | func loginDeadline(now time.Time, expiresIn int) time.Time { |
| 657 | d := time.Duration(expiresIn) * time.Second |
| 658 | if d <= 0 || d > maxLoginWait { |
| 659 | d = maxLoginWait |
| 660 | } |
| 661 | return now.Add(d) |
| 662 | } |
| 663 | |
| 664 | // loginConsole is login's resolution chain: flag → CARLOS_CONSOLE → the |
| 665 | // configured default → the flagship. Login alone gets the built-in landing |
| 666 | // place: there may be no credential yet to pick from, and the flagship is |
| 667 | // the product answer. Every other verb resolves through pick(), which |
| 668 | // assumes nothing. |
| 669 | func loginConsole(flagValue string) (string, error) { |
| 670 | base := flagValue |
| 671 | if base == "" { |
| 672 | base = os.Getenv("CARLOS_CONSOLE") |
| 673 | } |
| 674 | if base == "" { |
| 675 | var err error |
| 676 | if base, _, err = configDefault(); err != nil { |
| 677 | return "", err |
| 678 | } |
| 679 | } |
| 680 | if base == "" { |
| 681 | base = defaultConsole |
| 682 | } |
| 683 | return normalizeConsole(base) |
| 684 | } |
| 685 | |
| 686 | func authLogin(out io.Writer, args []string) error { |
| 687 | fs := flag.NewFlagSet("auth login", flag.ContinueOnError) |
| 688 | fs.SetOutput(out) |
| 689 | console := consoleOption(fs, "the console to log in to (default: the configured default, else "+defaultConsole+")") |
| 690 | label := fs.String("label", "", "what to call this terminal on the console (default: user@host)") |
| 691 | fs.Usage = flagUsage(fs, "") |
| 692 | if err := fs.Parse(args); err != nil { |
| 693 | return err |
| 694 | } |
| 695 | base, err := loginConsole(*console) |
| 696 | if err != nil { |
| 697 | return err |
| 698 | } |
| 699 | if *label == "" { |
| 700 | *label = defaultLabel() |
| 701 | } |
| 702 | ctx := context.Background() |
| 703 | c := newConsoleClient(base, "") |
| 704 | var start startResponse |
| 705 | if _, err := c.do(ctx, http.MethodPost, "/auth/cli/start", map[string]string{"label": *label}, &start); err != nil { |
| 706 | return fmt.Errorf("starting the login at %s: %w", base, err) |
| 707 | } |
| 708 | if start.DeviceCode == "" || start.UserCode == "" { |
| 709 | return fmt.Errorf("%s did not return a login code — is that a CARLOS console?", base) |
| 710 | } |
| 711 | // The BARE verification URI plus the code, never the ready-made |
| 712 | // ?code=… link, even though the console offers one. |
| 713 | // |
| 714 | // A link that lands one click from an Approve button is the whole |
| 715 | // device-code phishing attack: an attacker starts this flow, sends you |
| 716 | // their link, and one click hands them a 90-day credential that acts as |
| 717 | // you. The console refuses to approve from a prefilled code for that |
| 718 | // reason, and printing the complete URL here would teach people that |
| 719 | // following such a link is the normal way to do this — which is exactly |
| 720 | // the habit the attack needs. Type the code. |
| 721 | link := start.VerifyURI |
| 722 | if link == "" { |
| 723 | link = base + "/auth/cli" |
| 724 | } |
| 725 | // Printed, never opened: this frequently runs over SSH, where opening a |
| 726 | // browser would open it on the wrong machine. |
| 727 | pal := colorFor(out) |
| 728 | fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, pal.link(link)) |
| 729 | // The code is the one thing here a person has to read off the screen and |
| 730 | // type somewhere else, so it is the one thing given weight. |
| 731 | fmt.Fprintf(out, "and type this code:\n\n %s\n\n", pal.head(start.UserCode)) |
| 732 | mutedf(out, "Waiting for approval (this code expires in %d minutes)…", start.ExpiresIn/60) |
| 733 | |
| 734 | deadline := loginDeadline(time.Now(), start.ExpiresIn) |
| 735 | wait := pollWait(start.Interval) |
| 736 | for time.Now().Before(deadline) { |
| 737 | time.Sleep(wait) |
| 738 | var poll pollResponse |
| 739 | if _, err := c.do(ctx, http.MethodPost, "/auth/cli/poll", |
| 740 | map[string]string{"device_code": start.DeviceCode}, &poll); err != nil { |
| 741 | // A hiccup mid-poll is not a failed login: keep waiting, the |
| 742 | // human is mid-approval. |
| 743 | continue |
| 744 | } |
| 745 | if poll.Interval > 0 { |
| 746 | wait = pollWait(poll.Interval) |
| 747 | } |
| 748 | switch poll.Status { |
| 749 | case "pending": |
| 750 | continue |
| 751 | case "denied": |
| 752 | return errors.New("the console says that login was denied — nothing was issued") |
| 753 | case "expired": |
| 754 | return errors.New("that login code expired before it was approved — run this again") |
| 755 | case "unknown": |
| 756 | return errors.New("the console does not recognise this login — run this again") |
| 757 | case "approved": |
| 758 | if poll.Token == "" { |
| 759 | return errors.New("the console approved the login but returned no token") |
| 760 | } |
| 761 | now := time.Now().UTC() |
| 762 | expires := now.Add(90 * 24 * time.Hour) |
| 763 | if poll.ExpiresAt != "" { |
| 764 | if t, err := time.Parse(time.RFC3339, poll.ExpiresAt); err == nil { |
| 765 | expires = t |
| 766 | } |
| 767 | } |
| 768 | f, err := loadCredentials() |
| 769 | if err != nil { |
| 770 | return err |
| 771 | } |
| 772 | f.put(credential{Console: base, Address: poll.Address, Token: poll.Token, |
| 773 | Label: *label, CreatedAt: now, ExpiresAt: expires}) |
| 774 | if err := f.save(); err != nil { |
| 775 | return fmt.Errorf("storing the credential: %w", err) |
| 776 | } |
| 777 | // Checked here as well as in whoami: this is the moment a bearer |
| 778 | // token first exists on this machine, and a pre-existing file with |
| 779 | // loose permissions is exactly when to say so. |
| 780 | if warn := credentialPermWarning(); warn != "" { |
| 781 | warnf(out, "%s", warn) |
| 782 | } |
| 783 | fmt.Fprintf(out, "\n%s to %s as %s.\n", pal.ok("Logged in"), base, poll.Address) |
| 784 | mutedf(out, "Token stored in %s (mode 0600); it expires %s.", |
| 785 | credentialsPath(), expires.Format("2006-01-02")) |
| 786 | mutedf(out, "Revoke it any time at %s/auth/cli/tokens", base) |
| 787 | return nil |
| 788 | default: |
| 789 | return fmt.Errorf("the console answered with an unknown status %q", poll.Status) |
| 790 | } |
| 791 | } |
| 792 | return errors.New("gave up waiting for approval — run this again when you're at a browser") |
| 793 | } |
| 794 | |
| 795 | func authWhoami(out io.Writer, args []string) error { |
| 796 | fs := flag.NewFlagSet("auth whoami", flag.ContinueOnError) |
| 797 | fs.SetOutput(out) |
| 798 | console := consoleOption(fs, "which console (only needed with several)") |
| 799 | fs.Usage = flagUsage(fs, "") |
| 800 | if err := fs.Parse(args); err != nil { |
| 801 | return err |
| 802 | } |
| 803 | f, err := loadCredentials() |
| 804 | if err != nil { |
| 805 | return err |
| 806 | } |
| 807 | c, err := f.pick(*console) |
| 808 | if err != nil { |
| 809 | return err |
| 810 | } |
| 811 | if warn := credentialPermWarning(); warn != "" { |
| 812 | warnf(out, "%s", warn) |
| 813 | } |
| 814 | var who whoamiResponse |
| 815 | if _, err := newConsoleClient(c.Console, c.Token). |
| 816 | do(context.Background(), http.MethodGet, "/auth/cli/whoami", nil, &who); err != nil { |
| 817 | // Deliberately shows the STORED identity as well: "the console says |
| 818 | // no" and "I could not reach the console" are different problems, and |
| 819 | // a person debugging one needs to know which they have. |
| 820 | return fmt.Errorf("%s says: %v (this machine holds a credential for %s)", c.Console, err, c.Address) |
| 821 | } |
| 822 | fmt.Fprintf(out, "%s at %s\n", who.Address, c.Console) |
| 823 | if who.Label != "" { |
| 824 | mutedf(out, " terminal: %s", who.Label) |
| 825 | } |
| 826 | if who.ExpiresAt != "" { |
| 827 | mutedf(out, " expires: %s", who.ExpiresAt) |
| 828 | } |
| 829 | // Memberships are a bonus line, not part of whoami's contract: identity |
| 830 | // already printed, and a console from before GET /api/cli/apps (or a |
| 831 | // blip fetching it) must not make whoami look broken — so ANY error |
| 832 | // here degrades to silence rather than a message, and the moved-console |
| 833 | // OFFER is suppressed too: a bonus line must never turn into a prompt. |
| 834 | quietConsoleOffers = true |
| 835 | defer func() { quietConsoleOffers = false }() |
| 836 | var acc struct { |
| 837 | Accounts []apiAccountEntry `json:"accounts"` |
| 838 | } |
| 839 | if _, err := newConsoleClient(c.Console, c.Token). |
| 840 | do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc); err == nil && len(acc.Accounts) > 0 { |
| 841 | rows := make([]string, 0, len(acc.Accounts)) |
| 842 | for _, a := range acc.Accounts { |
| 843 | rows = append(rows, fmt.Sprintf("%s (%s)", a.Name, a.Sqid)) |
| 844 | } |
| 845 | mutedf(out, " accounts: %s", strings.Join(rows, ", ")) |
| 846 | } |
| 847 | return nil |
| 848 | } |
| 849 | |
| 850 | func authLogout(out io.Writer, args []string) error { |
| 851 | fs := flag.NewFlagSet("auth logout", flag.ContinueOnError) |
| 852 | fs.SetOutput(out) |
| 853 | console := consoleOption(fs, "which console (only needed with several)") |
| 854 | fs.Usage = flagUsage(fs, "") |
| 855 | if err := fs.Parse(args); err != nil { |
| 856 | return err |
| 857 | } |
| 858 | f, err := loadCredentials() |
| 859 | if err != nil { |
| 860 | return err |
| 861 | } |
| 862 | c, err := f.pick(*console) |
| 863 | if err != nil { |
| 864 | return err |
| 865 | } |
| 866 | origin := c.Console |
| 867 | // Revoke first, then forget. If the console cannot be reached the local |
| 868 | // copy still goes — but say so, because a token this machine has stopped |
| 869 | // tracking is still a live credential until it expires, and the person |
| 870 | // needs to know to kill it from the console. |
| 871 | revokeErr := func() error { |
| 872 | _, err := newConsoleClient(origin, c.Token). |
| 873 | do(context.Background(), http.MethodPost, "/auth/cli/revoke", map[string]string{}, nil) |
| 874 | return err |
| 875 | }() |
| 876 | f.remove(origin) |
| 877 | if err := f.save(); err != nil { |
| 878 | return err |
| 879 | } |
| 880 | if revokeErr != nil { |
| 881 | warnf(out, "Forgot the credential for %s locally, but could not revoke it there: %v", origin, revokeErr) |
| 882 | warnf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens", origin) |
| 883 | return nil |
| 884 | } |
| 885 | fmt.Fprintf(out, "%s of %s — the token is revoked.\n", colorFor(out).ok("Logged out"), origin) |
| 886 | return nil |
| 887 | } |
| 888 | |
| 889 | // authDefault shows or sets the default console. Bare: report the effective |
| 890 | // configured default and which file said so (flag and env are not consulted |
| 891 | // — this verb reports CONFIG, and someone with CARLOS_CONSOLE exported can |
| 892 | // see their own shell). With a URL: write ~/.carlos/config, or |
| 893 | // ./.carlos/config under --project — relative to the working directory, |
| 894 | // deliberately not "the repo root": the CLI has no VCS awareness, and |
| 895 | // "run it where you want the file" beats guessing. |
| 896 | func authDefault(out io.Writer, args []string) error { |
| 897 | fs := flag.NewFlagSet("auth default", flag.ContinueOnError) |
| 898 | fs.SetOutput(out) |
| 899 | project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config") |
| 900 | destinationOption(fs) // reads, and writes into, that destination's block |
| 901 | fs.Usage = flagUsage(fs, "") |
| 902 | if err := fs.Parse(args); err != nil { |
| 903 | return err |
| 904 | } |
| 905 | switch fs.NArg() { |
| 906 | case 0: |
| 907 | if *project { |
| 908 | return errors.New("--project sets a default: carlos auth default --project <url>") |
| 909 | } |
| 910 | console, src, err := configDefault() |
| 911 | if err != nil { |
| 912 | return err |
| 913 | } |
| 914 | if console == "" { |
| 915 | fmt.Fprintf(out, "no default console configured.\n") |
| 916 | fmt.Fprintf(out, "`carlos auth login` defaults to %s; other commands use the\n", defaultConsole) |
| 917 | fmt.Fprintf(out, "only stored credential, or ask when there are several.\n") |
| 918 | fmt.Fprintf(out, "Set one with: carlos auth default <url> (--project pins this directory)\n") |
| 919 | return nil |
| 920 | } |
| 921 | if d := configDestination(console); d != "" { |
| 922 | src = "the " + d + " destination in " + src |
| 923 | } |
| 924 | fmt.Fprintf(out, "%s (from %s)\n", console, src) |
| 925 | return nil |
| 926 | case 1: |
| 927 | path, err := globalConfigPath() |
| 928 | perm := os.FileMode(0o700) // ~/.carlos also holds credentials |
| 929 | if *project { |
| 930 | path, err = filepath.Join(".carlos", "config"), nil |
| 931 | perm = 0o755 // meant to be committed; nothing secret inside |
| 932 | } |
| 933 | if err != nil { |
| 934 | return err |
| 935 | } |
| 936 | if err := saveConfigFile(path, fs.Arg(0), perm); err != nil { |
| 937 | return err |
| 938 | } |
| 939 | // Echo the NORMALIZED value: what was saved, not what was typed. |
| 940 | console, err := normalizeConsole(fs.Arg(0)) |
| 941 | if err != nil { |
| 942 | return err |
| 943 | } |
| 944 | if d := selectedDestination(); d != "" { |
| 945 | path = "the " + d + " destination in " + path |
| 946 | } |
| 947 | fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path) |
| 948 | return nil |
| 949 | default: |
| 950 | return errors.New("usage: carlos auth default [--project] [-d <destination>] [<url>]") |
| 951 | } |
| 952 | } |
| 953 | |
| 954 | // ---- what other verbs use --------------------------------------------------- |
| 955 | |
| 956 | // loggedInCredential is the seam every other verb reaches through: the |
| 957 | // credential for a console, or nil with the reason. Never fatal — the callers |
| 958 | // have their own answer for "not logged in" (usually: name --owner). |
| 959 | func loggedInCredential(console string) (*credential, error) { |
| 960 | f, err := loadCredentials() |
| 961 | if err != nil { |
| 962 | return nil, err |
| 963 | } |
| 964 | return f.pick(console) |
| 965 | } |
| 966 | |