// `lchat env set` — amend an instance's /data/.env in place, the // operator-safe way. On a hardened box operators can't read or edit /data // directly, and lchat-env-install replaces a file wholesale (right for // service envs sourced from a vault note; wrong for instance envs, whose // admin token must survive). This verb does the read-modify-write as root // via the allowlisted binary, prints KEY NAMES ONLY (env values are tokens — // they must never reach a transcript), and restarts what it changed. // // Born for the fetch-service flip (LCHAT_FETCH_URL=… across the fleet, // docs/fetch.md § The client flip) but deliberately generic. package main import ( "flag" "fmt" "path/filepath" "regexp" "sort" "strings" ) var envKeyRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) // runEnvCmd dispatches `lchat env `, mirroring runLinksCmd's shape. func runEnvCmd(args []string) { if len(args) < 1 { die(`usage: lchat env set |--all KEY=VALUE [KEY=VALUE…] set upsert KEY=VALUE lines in /data/.env and restart the instance (--all: every registry instance except canaries; hibernating instances get the file now and pick it up on their next wake)`) } switch args[0] { case "set": runEnvSet(args[1:]) default: die("env: unknown command %q", args[0]) } } func runEnvSet(args []string) { fs := flag.NewFlagSet("env set", flag.ExitOnError) registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") all := fs.Bool("all", false, "apply to every instance in the registry (canaries excluded — they belong to their sessions)") _ = fs.Parse(reorderFlagsFirst(fs, args)) host := "" pairs := map[string]string{} for _, a := range fs.Args() { if k, v, ok := strings.Cut(a, "="); ok { if !envKeyRe.MatchString(k) { die("env set: %q is not an env KEY (A-Z, digits, underscores)", k) } pairs[k] = v continue } if host != "" { die("env set: one host (or --all), then KEY=VALUE pairs — got both %q and %q", host, a) } host = strings.ToLower(strings.TrimSpace(a)) } if len(pairs) == 0 { die("env set: no KEY=VALUE pairs given") } if (*all && host != "") || (!*all && host == "") { die("env set: name one host, or --all — not both, not neither") } keys := make([]string, 0, len(pairs)) for k := range pairs { keys = append(keys, k) } sort.Strings(keys) var hosts []string if *all { reg, err := openRegistry(*registryPath) if err != nil { die("env set: open registry: %v", err) } routes, err := reg.list() if err != nil { die("env set: %v", err) } for _, rt := range routes { // Services keep their own env contracts (vault-note wholesale via // lchat-env-install); canaries belong to their sessions. if rt.Kind != "instance" || rt.Canary { continue } hosts = append(hosts, rt.Host) } sort.Strings(hosts) } else { if !validHost(host) { die("env set: invalid host %q", host) } hosts = []string{host} } for _, h := range hosts { if err := envSetOne(h, pairs); err != nil { // --all keeps going: one broken instance must not strand the rest // of a fleet rollout half-applied with no report of who's left. fmt.Printf(" %s: FAILED: %v\n", h, err) continue } unit := "lchat@" + h switch { case unitActive(unit): if err := systemctl("restart", unit); err != nil { fmt.Printf(" %s: env updated (%s) but restart failed: %v\n", h, strings.Join(keys, ", "), err) continue } fmt.Printf(" %s: set %s (restarted)\n", h, strings.Join(keys, ", ")) default: // Hibernating (or simply stopped): the activator layers the env // file over each spawn, so the change rides the next wake. fmt.Printf(" %s: set %s (not running — applies on next start/wake)\n", h, strings.Join(keys, ", ")) } } } // envSetOne upserts pairs into /data/.env, preserving everything else // (notably LCHAT_ADMIN_TOKEN — the whole reason this isn't a wholesale write) // and the lchat service user's ownership. func envSetOne(host string, pairs map[string]string) error { path := filepath.Join(instanceDataDir, host+".env") env, err := readEnvFile(path) if err != nil { return err } for k, v := range pairs { env[k] = v } if err := writeEnvFile(path, env); err != nil { return err } return matchOwner(path, instanceDataDir) }