package host import ( "bufio" "bytes" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "github.com/carlosframework/platform/internal/hostwrite" ) const claimControlUnit = "carlos-instance-control.service" var claimBootstrapHostEnv = "/etc/carlos/host.env" var readClaimUnitFile = os.ReadFile const claimControlEnvironment = "EnvironmentFile=-/etc/carlos/host.env\n" type claimUnitSettings struct { enabled bool rootRetry bool data, opt string } func readClaimUnitSettings(path string) (claimUnitSettings, error) { values := map[string]string{"CARLOS_HOST_CLAIM": "", "CARLOS_DATA_DIR": "", "CARLOS_OPT_DIR": ""} raw, err := readClaimUnitFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return claimUnitSettings{}, err } if err == nil { // The installed EnvironmentFile is authoritative, including absent keys. // Never execute it as shell code. Refuse unsupported syntax for these // three settings rather than render permissions for a different value. for key := range values { values[key] = "" } scan := bufio.NewScanner(bytes.NewReader(raw)) for scan.Scan() { line := strings.TrimSpace(scan.Text()) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } key, v, ok := strings.Cut(line, "=") key = strings.TrimSpace(key) v = strings.TrimSpace(v) if !ok { continue } if strings.HasSuffix(v, `\`) || (len(v) > 0 && (v[0] == '"' || v[0] == '\'') && (len(v) < 2 || v[len(v)-1] != v[0])) { return claimUnitSettings{}, errors.New("unsupported multiline host unit setting") } if _, known := values[key]; !known { continue } v = strings.TrimSpace(v) if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { v = v[1 : len(v)-1] } if strings.ContainsAny(v, "\\\"'\x00\r\n") { return claimUnitSettings{}, fmt.Errorf("unsupported host unit setting %s", key) } values[key] = v } if err := scan.Err(); err != nil { return claimUnitSettings{}, err } } cfg := claimUnitSettings{enabled: hostwrite.Enabled(func(k string) string { return values[k] }), data: values["CARLOS_DATA_DIR"], opt: values["CARLOS_OPT_DIR"]} if cfg.data == "" { cfg.data = "/data" } if cfg.opt == "" { cfg.opt = "/opt/carlos" } if cfg.enabled { for _, p := range []string{cfg.data, cfg.opt} { if !filepath.IsAbs(p) || filepath.Clean(p) != p || p == "/" || strings.ContainsAny(p, "\x00\r\n") { return claimUnitSettings{}, errors.New("invalid claim unit root") } } } if cfg.enabled { cfg.rootRetry, err = prepareRootRetryUnitDirectory() if err != nil { return claimUnitSettings{}, err } } return cfg, nil } func claimUnitContent(name string, content []byte, cfg claimUnitSettings) []byte { if name != claimControlUnit || !cfg.enabled { return content } quote := func(p string) string { if !strings.ContainsAny(p, " \t%\\\"") { return p } return strconv.Quote(strings.ReplaceAll(p, "%", "%%")) } roots := []string{"/run/carlos-control", "/run/carlos", "/var/lib/carlos-claims", rootReleasePrivate, "/run/systemd/system", cfg.data, cfg.opt, "/etc/systemd/system", "/sys/fs/cgroup"} if cfg.rootRetry { roots = append(roots, rootRetryProjection) } for i, p := range roots { roots[i] = quote(p) } content = bytes.Replace(content, []byte("[Service]\n"), []byte("[Service]\nStateDirectory=carlos-claims\nStateDirectoryMode=0700\n"+claimControlEnvironment), 1) content = bytes.Replace(content, []byte("ReadWritePaths=/run/carlos-control /run/systemd/system\n"), []byte("ReadWritePaths="+strings.Join(roots, " ")+"\n"), 1) content = bytes.Replace(content, []byte("RestrictAddressFamilies=AF_UNIX\n"), []byte("RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\n"), 1) // Claim mode adds the root journal service, the offline legacy migration // (up to 16 MiB of decoded, validated and re-marshalled rows) and the AWS // fence pass to this process. 64M leaves no headroom over the Go runtime // and SQLite caches; GOMEMLIMIT makes the collector work before the cap. return bytes.Replace(content, []byte("MemoryMax=64M\n"), []byte("MemoryMax=256M\nEnvironment=GOMEMLIMIT=192MiB\n"), 1) } func claimControlChanged(before, after []byte) bool { return !bytes.Equal(before, after) && (bytes.Contains(before, []byte(claimControlEnvironment)) || bytes.Contains(after, []byte(claimControlEnvironment))) } func restartClaimControl(path string, before, after []byte, systemctl func(...string) (string, error)) error { if !claimControlChanged(before, after) { return nil } if _, err := systemctl("try-restart", claimControlUnit); err != nil { // Restore the old bytes so the next repair still sees a change. var restore error if before == nil { restore = os.Remove(path) } else { restore = writeFileDurable(path, before, 0644) } _, reload := systemctl("daemon-reload") return errors.Join(err, restore, reload) } return nil }