// Package hostenv is a box's /etc/carlos/host.env as a parameter layout // (region HA spec 2 §5 step 2, §4). Every value a hosting box reads from // host.env is one parameter, named for its variable, under // /carlos///, or under /carlos//shared/ // for the values every region of the deployment holds alike. A box's // start-up assembles host.env from the two; an operator seeds them from a // box's existing file. // // Where the parameters live is an adapter's business (awsssm.go, AWS SSM // SecureStrings). This file names no provider (§25): host.env is a file, // and Params is all the core asks of a parameter store. package hostenv import ( "bufio" "bytes" "context" "errors" "fmt" "regexp" "slices" "sort" "strings" ) // Path is the file a box's units read their environment from. const Path = "/etc/carlos/host.env" // SharedScope is the scope of the values every region holds alike. const SharedScope = "shared" // Required are the variables a hosting box cannot run without (§5 step 2): // the buckets and their region, the release and secrets keys, the box's // region and channel, the alert topic, and the claim and region HA // switches. Assembling host.env refuses without any of them. var Required = []string{ "CARLOS_ALERT_SNS_TOPIC_ARN", "CARLOS_DEPLOYMENT_BUCKET", "CARLOS_HIBERNATE_BUCKET", "CARLOS_HIBERNATE_REGION", "CARLOS_HOST_CLAIM", "CARLOS_HOST_CLAIM_ORIGINS", "CARLOS_REGION", "CARLOS_REGION_HA", "CARLOS_RELEASE_PUBKEY", "CARLOS_SECRETS_KEY", "CARLOS_SYSTEM_CHANNEL", } // Optional are the other variables a box its region's group launched may // carry, from the agent's config surface (README's host.env list and the // variables the agent reads). Together with Required they are the // allowlist: any other name, under either scope, is refused by name, // never by value, by write (Assemble) and by seed. The list leaves out // the keys that select a box's authority (a fleet's or tunnel's identity, // an address override, a pool, the data, opt and deployment directories, // the relay's settings) and every token or API credential, except the // deployment's own required secrets, CARLOS_SECRETS_KEY and // CARLOS_RELEASE_PUBKEY. CARLOS_ALERT_WEBHOOK is left out on the same // rule: a webhook URL can carry a credential. So are the status // destination overrides, CARLOS_STATUS_BUCKET, CARLOS_STATUS_PREFIX and // CARLOS_STATUS_REGION: a group box reports to the deployment bucket, // unprefixed, where the console reads its guard-ready report, while it is // joining and once it is hosting. An endpoint override such as // CARLOS_S3_ENDPOINT stays, because it selects no box authority and only // operators can write the parameter paths. CARLOS_REGION and the group // marker, which do select, are cross-checked against the signed roster // where the agent uses them (internal/host's mode selection). Adding a // variable is a code change, reviewed. var Optional = []string{ "CARLOS_ACME_DELEGATION_ZONE", "CARLOS_ACME_DIRECTORY", "CARLOS_ALERT_CPU_PCT", "CARLOS_ALERT_DISK_PCT", "CARLOS_ALERT_MEM_PCT", "CARLOS_AWS_REGION", "CARLOS_BACKHAUL_MAX_INFLIGHT", "CARLOS_DNS_PROVIDER", "CARLOS_EDGE_GZIP", "CARLOS_HIBERNATE_IDLE", "CARLOS_HIBERNATE_PREFIX", "CARLOS_HOST_CLAIM_RETIRED_LOCATIONS", "CARLOS_HOST_METRICS", "CARLOS_LAYOUT_DEFAULT", "CARLOS_MOVES_PHASE1", "CARLOS_MOVE_READY_DELTA_BYTES", "CARLOS_PLAN_LIMITS", "CARLOS_REQUIRE_POINTER_DIGEST", "CARLOS_S3_ENDPOINT", "CARLOS_S3_QUIRKS", "CARLOS_STEERING_ZONE_ID", "CARLOS_SWAP", "CARLOS_UNIT_SETTLE", "CARLOS_VERSIONS_GRACE", "CARLOS_WILDCARD_DOMAINS", "STATUS_BOX_REGION", } // Allowed reports whether name is a variable host.env may carry from the // parameters: Required or Optional. func Allowed(name string) bool { return slices.Contains(Required, name) || slices.Contains(Optional, name) } // notAllowed lists, sorted, the names in env that are not Allowed. func notAllowed(env map[string]string) []string { var out []string for k := range env { if k != BoxReference && !Allowed(k) { out = append(out, k) } } sort.Strings(out) return out } // SharedKeys are the variables that are the same in every region of a // deployment, so they are kept once, under the shared scope. Everything // else is the region's own. var SharedKeys = []string{ "CARLOS_DEPLOYMENT_BUCKET", "CARLOS_HOST_CLAIM", "CARLOS_HOST_CLAIM_ORIGINS", "CARLOS_RELEASE_PUBKEY", "CARLOS_SECRETS_KEY", } // BoxReference and GroupReference are the one line host.env carries that // is not a parameter. write runs only in the start-up of a box its // region's group launched, so a host.env it wrote marks such a box, and // the box takes the reference its account and instance derive // (hostactors.GroupBoxReference) instead of a status label. Every other // box's host.env lacks it and keeps its label. const ( BoxReference = "CARLOS_BOX_REFERENCE" GroupReference = "group" ) // Params is a store of named, encrypted parameters. type Params interface { // ByPath returns every parameter directly under prefix, keyed by its // name after the prefix, decrypted. A read that fails or stops part // way is an error, never a partial map. ByPath(ctx context.Context, prefix string) (map[string]string, error) // Set writes one parameter, encrypted, replacing any value it had. Set(ctx context.Context, name, value string) error // SetNew writes one parameter, encrypted, only if it does not exist: // ErrExists when it does, and the value is not changed. SetNew(ctx context.Context, name, value string) error } var ( // ErrMissing is a required variable with no value. ErrMissing = errors.New("required values are missing") // ErrConflict is a variable held both by the region and shared, or a // shared value seeding would change. ErrConflict = errors.New("values conflict") // ErrValue is a name or value host.env cannot hold safely. ErrValue = errors.New("a name or value host.env cannot hold") // ErrExists is SetNew's answer for a parameter that is already set: // another seed wrote it first. ErrExists = errors.New("already set by another seed") ) var ( scopeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) keyName = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) safeValue = regexp.MustCompile(`^[A-Za-z0-9_./:,@+=%-]*$`) ) // Prefix is the parameter path of one scope: a region's placement code or // SharedScope. Both names are refused unless they are lower-case labels. func Prefix(deployment, scope string) (string, error) { if !scopeName.MatchString(deployment) || !scopeName.MatchString(scope) { return "", fmt.Errorf("%w: %q and %q must be lower-case labels", ErrValue, deployment, scope) } return "/carlos/" + deployment + "/" + scope + "/", nil } // ValidKey reports whether name is a variable host.env carries. Other // parameters under a scope (the bootstrap object's sha256, for one) are // not variables and are skipped. func ValidKey(name string) bool { return keyName.MatchString(name) } // Assemble merges the shared and the region's values. A variable in both // is a conflict, never resolved by precedence: one of the two is wrong. // Every Required variable must have a value. func Assemble(shared, regional map[string]string) (map[string]string, error) { env := map[string]string{} var both []string for _, src := range []map[string]string{shared, regional} { for k, v := range src { if !ValidKey(k) || k == BoxReference { continue } if _, dup := env[k]; dup { both = append(both, k) continue } env[k] = v } } if len(both) > 0 { sort.Strings(both) return nil, fmt.Errorf("%w: %s set both for the region and shared", ErrConflict, strings.Join(both, ", ")) } if bad := notAllowed(env); len(bad) > 0 { return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", ")) } if missing := Missing(env); len(missing) > 0 { return nil, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", ")) } return env, nil } // Missing lists the Required variables env has no value for, sorted. func Missing(env map[string]string) []string { var out []string for _, k := range Required { if strings.TrimSpace(env[k]) == "" { out = append(out, k) } } return out } // renderedHeader is the first line of a host.env Render writes. const renderedHeader = "# Written by carlos system host-env write from the deployment's parameters. Do not edit by hand." // Render is host.env's bytes: one KEY=value line per variable, sorted. // Values are written bare, so the file means the same to systemd's // EnvironmentFile and to a shell that sources it. A value outside the safe // set (letters, digits and _ . / : , @ + = % -) is refused rather than // quoted; the error names the variables, never their values, which may be // secrets. func Render(env map[string]string) ([]byte, error) { keys := make([]string, 0, len(env)) var bad []string for k, v := range env { if !ValidKey(k) || !safeValue.MatchString(v) { bad = append(bad, k) continue } keys = append(keys, k) } if len(bad) > 0 { sort.Strings(bad) return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", ")) } sort.Strings(keys) var b bytes.Buffer b.WriteString(renderedHeader + "\n") for _, k := range keys { b.WriteString(k + "=" + env[k] + "\n") } return b.Bytes(), nil } // Parse reads an existing host.env: KEY=value lines, with blank lines, // comments and a leading "export " ignored, and one layer of matching // quotes removed from a value. A variable set twice, or a line that is // not an assignment, is refused. func Parse(body []byte) (map[string]string, error) { env := map[string]string{} sc := bufio.NewScanner(bytes.NewReader(body)) for n := 1; sc.Scan(); n++ { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } line = strings.TrimPrefix(line, "export ") k, v, ok := strings.Cut(line, "=") k = strings.TrimSpace(k) if !ok || !ValidKey(k) { return nil, fmt.Errorf("%w: line %d is not KEY=value", ErrValue, n) } v = strings.TrimSpace(v) if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] { v = v[1 : len(v)-1] } if _, dup := env[k]; dup { return nil, fmt.Errorf("%w: %s is set twice", ErrValue, k) } env[k] = v } if err := sc.Err(); err != nil { return nil, err } return env, nil } // Split divides env into the shared variables and the region's own. func Split(env map[string]string) (shared, regional map[string]string) { shared, regional = map[string]string{}, map[string]string{} for k, v := range env { if slices.Contains(SharedKeys, k) { shared[k] = v } else { regional[k] = v } } return shared, regional } // Fetch assembles host.env for one region of a deployment from p, for a // box the region's group launched: it adds BoxReference. func Fetch(ctx context.Context, p Params, deployment, region string) ([]byte, error) { if region == SharedScope { return nil, fmt.Errorf("%w: %q is not a region", ErrValue, region) } sharedPrefix, err := Prefix(deployment, SharedScope) if err != nil { return nil, err } regionPrefix, err := Prefix(deployment, region) if err != nil { return nil, err } shared, err := p.ByPath(ctx, sharedPrefix) if err != nil { return nil, fmt.Errorf("parameters under %s: %w", sharedPrefix, err) } regional, err := p.ByPath(ctx, regionPrefix) if err != nil { return nil, fmt.Errorf("parameters under %s: %w", regionPrefix, err) } env, err := Assemble(shared, regional) if err != nil { return nil, err } env[BoxReference] = GroupReference return Render(env) } // Seeded is what Seed wrote: the parameters under each prefix. type Seeded struct { SharedPrefix, RegionPrefix string Shared, Regional int } // Seed writes env, a box's existing host.env, into the layout for region. // The region's variables are written over whatever is there. A shared // variable is written only when it is not yet set, and create-only // (SetNew): one region's file never changes a value every region reads. // A shared value that differs is a conflict, and nothing at all is // written. Two seeds racing for the same shared variable cannot both // win: the one SetNew refuses reads the value back and goes on only if // it is the value it would have written. env must hold every Required // variable, in a form Render accepts. BoxReference is never seeded: it // is write's. func Seed(ctx context.Context, p Params, deployment, region string, env map[string]string) (Seeded, error) { if region == SharedScope { return Seeded{}, fmt.Errorf("%w: %q is not a region", ErrValue, region) } sharedPrefix, err := Prefix(deployment, SharedScope) if err != nil { return Seeded{}, err } regionPrefix, err := Prefix(deployment, region) if err != nil { return Seeded{}, err } if missing := Missing(env); len(missing) > 0 { return Seeded{}, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", ")) } if bad := notAllowed(env); len(bad) > 0 { return Seeded{}, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", ")) } if _, err := Render(env); err != nil { return Seeded{}, err } shared, regional := Split(env) delete(regional, BoxReference) existing, err := p.ByPath(ctx, sharedPrefix) if err != nil { return Seeded{}, fmt.Errorf("parameters under %s: %w", sharedPrefix, err) } var differ []string for k, v := range shared { if have, ok := existing[k]; ok && have != v { differ = append(differ, k) } } if len(differ) > 0 { sort.Strings(differ) return Seeded{}, fmt.Errorf("%w: the shared %s already differ", ErrConflict, strings.Join(differ, ", ")) } out := Seeded{SharedPrefix: sharedPrefix, RegionPrefix: regionPrefix} for _, k := range sortedKeys(shared) { if _, ok := existing[k]; ok { continue } err := p.SetNew(ctx, sharedPrefix+k, shared[k]) if errors.Is(err, ErrExists) { // Another seed set it since the read above: go on only if it // set what this one would have. if err := sameShared(ctx, p, sharedPrefix, k, shared[k]); err != nil { return out, err } continue } if err != nil { return out, fmt.Errorf("%s%s: %w", sharedPrefix, k, err) } out.Shared++ } for _, k := range sortedKeys(regional) { if err := p.Set(ctx, regionPrefix+k, regional[k]); err != nil { return out, fmt.Errorf("%s%s: %w", regionPrefix, k, err) } out.Regional++ } return out, nil } // sameShared reads the shared variable k back after SetNew found it set, // and refuses unless it holds want. The error names the variable, never // either value. func sameShared(ctx context.Context, p Params, sharedPrefix, k, want string) error { now, err := p.ByPath(ctx, sharedPrefix) if err != nil { return fmt.Errorf("parameters under %s: %w", sharedPrefix, err) } have, ok := now[k] if !ok { return fmt.Errorf("%s%s: %w", sharedPrefix, k, ErrExists) } if have != want { return fmt.Errorf("%w: the shared %s already differ", ErrConflict, k) } return nil } func sortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys }