| 1 | // `lchat env set` — amend an instance's /data/<host>.env in place, the |
| 2 | // operator-safe way. On a hardened box operators can't read or edit /data |
| 3 | // directly, and lchat-env-install replaces a file wholesale (right for |
| 4 | // service envs sourced from a vault note; wrong for instance envs, whose |
| 5 | // admin token must survive). This verb does the read-modify-write as root |
| 6 | // via the allowlisted binary, prints KEY NAMES ONLY (env values are tokens — |
| 7 | // they must never reach a transcript), and restarts what it changed. |
| 8 | // |
| 9 | // Born for the fetch-service flip (LCHAT_FETCH_URL=… across the fleet, |
| 10 | // docs/fetch.md § The client flip) but deliberately generic. |
| 11 | package main |
| 12 | |
| 13 | import ( |
| 14 | "flag" |
| 15 | "fmt" |
| 16 | "path/filepath" |
| 17 | "regexp" |
| 18 | "sort" |
| 19 | "strings" |
| 20 | ) |
| 21 | |
| 22 | var envKeyRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) |
| 23 | |
| 24 | // runEnvCmd dispatches `lchat env <command>`, mirroring runLinksCmd's shape. |
| 25 | func runEnvCmd(args []string) { |
| 26 | if len(args) < 1 { |
| 27 | die(`usage: lchat env set <host>|--all KEY=VALUE [KEY=VALUE…] |
| 28 | |
| 29 | set upsert KEY=VALUE lines in /data/<host>.env and restart the instance |
| 30 | (--all: every registry instance except canaries; hibernating |
| 31 | instances get the file now and pick it up on their next wake)`) |
| 32 | } |
| 33 | switch args[0] { |
| 34 | case "set": |
| 35 | runEnvSet(args[1:]) |
| 36 | default: |
| 37 | die("env: unknown command %q", args[0]) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func runEnvSet(args []string) { |
| 42 | fs := flag.NewFlagSet("env set", flag.ExitOnError) |
| 43 | registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") |
| 44 | all := fs.Bool("all", false, "apply to every instance in the registry (canaries excluded — they belong to their sessions)") |
| 45 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 46 | |
| 47 | host := "" |
| 48 | pairs := map[string]string{} |
| 49 | for _, a := range fs.Args() { |
| 50 | if k, v, ok := strings.Cut(a, "="); ok { |
| 51 | if !envKeyRe.MatchString(k) { |
| 52 | die("env set: %q is not an env KEY (A-Z, digits, underscores)", k) |
| 53 | } |
| 54 | pairs[k] = v |
| 55 | continue |
| 56 | } |
| 57 | if host != "" { |
| 58 | die("env set: one host (or --all), then KEY=VALUE pairs — got both %q and %q", host, a) |
| 59 | } |
| 60 | host = strings.ToLower(strings.TrimSpace(a)) |
| 61 | } |
| 62 | if len(pairs) == 0 { |
| 63 | die("env set: no KEY=VALUE pairs given") |
| 64 | } |
| 65 | if (*all && host != "") || (!*all && host == "") { |
| 66 | die("env set: name one host, or --all — not both, not neither") |
| 67 | } |
| 68 | |
| 69 | keys := make([]string, 0, len(pairs)) |
| 70 | for k := range pairs { |
| 71 | keys = append(keys, k) |
| 72 | } |
| 73 | sort.Strings(keys) |
| 74 | |
| 75 | var hosts []string |
| 76 | if *all { |
| 77 | reg, err := openRegistry(*registryPath) |
| 78 | if err != nil { |
| 79 | die("env set: open registry: %v", err) |
| 80 | } |
| 81 | routes, err := reg.list() |
| 82 | if err != nil { |
| 83 | die("env set: %v", err) |
| 84 | } |
| 85 | for _, rt := range routes { |
| 86 | // Services keep their own env contracts (vault-note wholesale via |
| 87 | // lchat-env-install); canaries belong to their sessions. |
| 88 | if rt.Kind != "instance" || rt.Canary { |
| 89 | continue |
| 90 | } |
| 91 | hosts = append(hosts, rt.Host) |
| 92 | } |
| 93 | sort.Strings(hosts) |
| 94 | } else { |
| 95 | if !validHost(host) { |
| 96 | die("env set: invalid host %q", host) |
| 97 | } |
| 98 | hosts = []string{host} |
| 99 | } |
| 100 | |
| 101 | for _, h := range hosts { |
| 102 | if err := envSetOne(h, pairs); err != nil { |
| 103 | // --all keeps going: one broken instance must not strand the rest |
| 104 | // of a fleet rollout half-applied with no report of who's left. |
| 105 | fmt.Printf(" %s: FAILED: %v\n", h, err) |
| 106 | continue |
| 107 | } |
| 108 | unit := "lchat@" + h |
| 109 | switch { |
| 110 | case unitActive(unit): |
| 111 | if err := systemctl("restart", unit); err != nil { |
| 112 | fmt.Printf(" %s: env updated (%s) but restart failed: %v\n", h, strings.Join(keys, ", "), err) |
| 113 | continue |
| 114 | } |
| 115 | fmt.Printf(" %s: set %s (restarted)\n", h, strings.Join(keys, ", ")) |
| 116 | default: |
| 117 | // Hibernating (or simply stopped): the activator layers the env |
| 118 | // file over each spawn, so the change rides the next wake. |
| 119 | fmt.Printf(" %s: set %s (not running — applies on next start/wake)\n", h, strings.Join(keys, ", ")) |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // envSetOne upserts pairs into /data/<host>.env, preserving everything else |
| 125 | // (notably LCHAT_ADMIN_TOKEN — the whole reason this isn't a wholesale write) |
| 126 | // and the lchat service user's ownership. |
| 127 | func envSetOne(host string, pairs map[string]string) error { |
| 128 | path := filepath.Join(instanceDataDir, host+".env") |
| 129 | env, err := readEnvFile(path) |
| 130 | if err != nil { |
| 131 | return err |
| 132 | } |
| 133 | for k, v := range pairs { |
| 134 | env[k] = v |
| 135 | } |
| 136 | if err := writeEnvFile(path, env); err != nil { |
| 137 | return err |
| 138 | } |
| 139 | return matchOwner(path, instanceDataDir) |
| 140 | } |
| 141 | |