// The `carlos domains` verb family (Task 10, issue #151): attach a // customer's own hostname to an app's route, detach it, or list what an app // currently holds — the CLI front over internal/console/apidomains.go, the // same instance-gated console door titogo's router speaks with its // CARLOS_INSTANCE_TOKEN. API mode only, the same as `carlos instances // create/list/delete`: a claim is a console-authorized write, and there is // no store-mode equivalent. package main import ( "context" "flag" "fmt" "log" "os" "sort" "strings" "time" ) const domainsUsage = `usage: carlos domains [flags] attach --app [--route ] [--wildcard] [--catchall] [--platform] [flags] claim a customer's hostname for the app, pointed at --route (default: the app's single instance record, if it has exactly one — otherwise --route is required). --wildcard/--catchall are capability flips: naming either one changes it, an unset flag leaves it as it was. --platform attaches a hostname in this deployment's own domains. Platform operators only. Where the deployment requires proof of control, the hostname is not served until you add the TXT record attach prints. attach --app '*.' an instance catch-all: each of the app's instances also answers at '*.'. verify --app [--channel ] [flags] check the TXT record and, once it is there, make the hostname live. --channel is for a hostname that will be the app's first route, when the app follows several. detach --app [flags] release a hostname claim list --app [flags] this app's hostname claims, joined to the fleet's own readings sweep (DNS/CERT-EXPIRES/WILDCARD/CATCHALL/DELEGATION) paths --app [flags] the leading path segments this hostname's traffic is split by, as PUBLISHED. Read-only: the platform that owns the hostname republishes the whole map, so there is no verb here that writes one. --account account (sqid or name; default "ops") --account-id account sqid, matched as a sqid ONLY --console console to act through (default: the only logged-in one) Signed-in only (carlos auth login): a claim is a console-authorized write — the console checks the hostname against the deploy's deny list, resolves the route, and writes it. The hostname comes live on the box that owns the route at its next domains pass. ` const domainsAttachUsage = `usage: carlos domains attach --app [--route ] [--wildcard] [--catchall] [--platform] [--account ] [--account-id ] [--console ] ` const domainsVerifyUsage = `usage: carlos domains verify --app [--channel ] [--account ] [--account-id ] [--console ] ` const domainsDetachUsage = `usage: carlos domains detach --app [--account ] [--account-id ] [--console ] ` const domainsListUsage = `usage: carlos domains list --app [--account ] [--account-id ] [--console ] ` const domainsPathsUsage = `usage: carlos domains paths --app [--account ] [--account-id ] [--console ] ` // runDomains dispatches the domains sub-verbs. attach/detach/list are // positional words, not flags, and must never reach fs.Parse — the same // shape runInstances uses for its own sub-verbs. func runDomains(args []string) { if len(args) > 0 { switch args[0] { case "attach": runDomainsAttach(args[1:]) return case "verify": runDomainsVerify(args[1:]) return case "detach": runDomainsDetach(args[1:]) return case "list": runDomainsList(args[1:]) return case "paths": runDomainsPaths(args[1:]) return } } log.Fatal(domainsUsage) } func runDomainsAttach(args []string) { fs := flag.NewFlagSet("domains attach", flag.ExitOnError) app := appFlag(fs, "app the hostname belongs to") route := fs.String("route", "", "route hostname to attach to") wildcard := fs.Bool("wildcard", false, "enable a delegated wildcard cert for this hostname (needs a CNAME — see the 422 message if refused)") catchall := fs.Bool("catchall", false, "derive a *. catch-all alias to the same route (needs --wildcard)") platform := fs.Bool("platform", false, "attach a hostname in this deployment's own domains (platform operators only)") consoleFlag := consoleOption(fs, "console to attach 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") fs.Usage = flagUsage(fs, domainsAttachUsage) fs.Parse(args) host := strings.TrimSpace(fs.Arg(0)) if host == "" || fs.NArg() > 1 { log.Fatal(domainsAttachUsage) } // --wildcard/--catchall are tri-state, the same reason apps place's // --target is: an omitted flag must leave the claim's current capability // untouched, and the bool value alone cannot distinguish "not named" // from "named, false" (an explicit --catchall=false is a real request to // turn it off). fs.Visit reports only flags actually present on the // command line. var wildcardArg, catchallArg *bool fs.Visit(func(f *flag.Flag) { switch f.Name { case "wildcard": v := *wildcard wildcardArg = &v case "catchall": v := *catchall catchallArg = &v } }) sess := requireAPISession("attach", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, domainsAttachUsage) ctx := context.Background() res, err := sess.postDomainAttachFull(ctx, acct, app2, domainAttachIn{ Host: host, Route: strings.TrimSpace(*route), Wildcard: wildcardArg, Catchall: catchallArg, Platform: *platform, }) if err != nil { log.Fatalf("domains attach: %v", err) } created, resolvedRoute := res.Created, res.Route if !res.Verified { // Stored, not served (#215). Said first, before any "attached" // wording a member would read as "it's live". fmt.Fprintf(stdout, "%s %s -> %s, waiting for you to prove you control it\n", colorFor(stdout).warn("reserved"), host, resolvedRoute) if !printDomainDNSLine(ctx, sess, acct, app2, host) { printDomainVerifyHint(res.Verify, app2, host) return } printDomainVerifyHint(nil, app2, host) return } if strings.HasPrefix(host, "*.") { // An instance catch-all: no route of its own to report. resolvedRoute = "