eleven / messenger Public

server: hibernating env files hand ownership to the service user (#660)

Found on the production canary's first wake: add-instance (root) writes the
env file 0600 root-owned — fine for systemd, which reads EnvironmentFile as
root before dropping privileges, but the activator reads it as the router's
service user and got permission denied. matchOwner chowns the file to
/data's owner (the service user per the box hardening) for hibernating
registers and on hibernate --on flips of existing instances.

Also fixes the vet break on main (handlers_test.go: resp2 used before its
error check).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3877d72ad08e4993588b6e2a2522f6223939e84a parent 2648730
3 files changed, +57 −1
  • control.go +32 −0
  • control_test.go +21 −0
  • handlers_test.go +4 −1
diff --git a/control.go b/control.go
index 01a75eb..e1ed366 100644
--- a/control.go
+++ b/control.go
@@ -230,6 +230,16 @@ func materializeInstance(reg *Registry, route Route, token string, opts addInsta
if err := writeEnvFile(envPath, env); err != nil {
return nil, fmt.Errorf("write env file: %v", err)
}
+ if route.Hibernate {
+ // Systemd-managed instances never care who owns the env file — systemd
+ // reads EnvironmentFile as root before dropping privileges. A hibernating
+ // instance's env is read by the ROUTER at wake time, and the router runs
+ // as the service user, so root-run add-instance must hand the file over
+ // (found live: the canary's first wake died on permission denied).
+ if err := matchOwner(envPath, instanceDataDir); err != nil {
+ return nil, fmt.Errorf("env file ownership: %v", err)
+ }
+ }
// The instance's unit execs /opt/lchat/live/<host>; stage the running control
// binary and point that symlink at it, so ExecStart resolves on first start.
@@ -618,6 +628,12 @@ func runHibernate(args []string) {
// so the first wake's restore sees it as the warm cache and skips; replication
// under the fresh lease is what backfills S3.
func hibernateOn(reg *Registry, rt *Route, unitTmpl, routerUnit string) error {
+ // Existing instances' env files were written for the systemd path (root
+ // reads them); the router's activator reads them as the service user, so
+ // hand the file over first — a flip that can't wake is worse than no flip.
+ if err := matchOwner(filepath.Join(instanceDataDir, rt.Host+".env"), instanceDataDir); err != nil {
+ return fmt.Errorf("env file ownership: %v", err)
+ }
if err := reg.setHibernate(rt.Host, true); err != nil {
return fmt.Errorf("flag: %v", err)
}
@@ -1022,6 +1038,22 @@ var systemctl = func(args ...string) error {
return nil
}
+// matchOwner chowns path to whatever owns dir — the service user owns /data
+// (the box hardening), so this is "give the file to whoever runs the router"
+// without parsing systemd units for a user name. A no-op when both are
+// already the same (the single-user dev case).
+func matchOwner(path, dir string) error {
+ st, err := os.Stat(dir)
+ if err != nil {
+ return err
+ }
+ sys, ok := st.Sys().(*syscall.Stat_t)
+ if !ok {
+ return nil // non-unix stat: nothing to match, nothing to fix
+ }
+ return os.Chown(path, int(sys.Uid), int(sys.Gid))
+}
+
// writeEnvFile writes KEY=value lines (sorted for a stable file) at 0600 — it
// holds the admin token, so it's operator-secret, like the platform env it
// replaces.
diff --git a/control_test.go b/control_test.go
index 9d5791e..2fa6732 100644
--- a/control_test.go
+++ b/control_test.go
@@ -9,6 +9,7 @@ import (
"path/filepath"
"reflect"
"strings"
+ "syscall"
"testing"
"time"
)
@@ -657,3 +658,23 @@ func TestWaitSocketQuiet(t *testing.T) {
t.Error("live listener reported quiet — the flip-off would start a unit against a live child")
}
}
+
+// matchOwner hands a root-written env file to the service user (the owner of
+// /data) so the router's activator can read it at wake time — the bug the
+// production canary's first wake hit. Unprivileged test: same-owner is a
+// clean no-op and the owners really do match afterwards.
+func TestMatchOwner(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "x.env")
+ if err := os.WriteFile(path, []byte("A=b\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := matchOwner(path, dir); err != nil {
+ t.Fatalf("matchOwner: %v", err)
+ }
+ ds, _ := os.Stat(dir)
+ fs, _ := os.Stat(path)
+ if ds.Sys().(*syscall.Stat_t).Uid != fs.Sys().(*syscall.Stat_t).Uid {
+ t.Fatalf("owner mismatch after matchOwner")
+ }
+}
diff --git a/handlers_test.go b/handlers_test.go
index 2076728..1fe9f9e 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -2424,7 +2424,10 @@ func TestLevelGatedGroups(t *testing.T) {
if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_tier":"`+tid+`"}`); code != 200 {
t.Fatalf("keyless reopen: code=%d", code)
}
- resp2, _ := http.Get(ts.URL + "/api/threads/open?token=" + tokB)
+ resp2, err := http.Get(ts.URL + "/api/threads/open?token=" + tokB)
+ if err != nil {
+ t.Fatalf("open directory: %v", err)
+ }
defer resp2.Body.Close()
var dir2 struct {
Threads []OpenThread `json:"threads"`