eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
11package main
12
13import (
14 "flag"
15 "fmt"
16 "path/filepath"
17 "regexp"
18 "sort"
19 "strings"
20)
21
22var envKeyRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
23
24// runEnvCmd dispatches `lchat env <command>`, mirroring runLinksCmd's shape.
25func 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
41func 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.
127func 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