| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostwrite" |
| 14 | ) |
| 15 | |
| 16 | const claimControlUnit = "carlos-instance-control.service" |
| 17 | |
| 18 | var claimBootstrapHostEnv = "/etc/carlos/host.env" |
| 19 | var readClaimUnitFile = os.ReadFile |
| 20 | |
| 21 | const claimControlEnvironment = "EnvironmentFile=-/etc/carlos/host.env\n" |
| 22 | |
| 23 | type claimUnitSettings struct { |
| 24 | enabled bool |
| 25 | rootRetry bool |
| 26 | data, opt string |
| 27 | } |
| 28 | |
| 29 | func readClaimUnitSettings(path string) (claimUnitSettings, error) { |
| 30 | values := map[string]string{"CARLOS_HOST_CLAIM": "", "CARLOS_DATA_DIR": "", "CARLOS_OPT_DIR": ""} |
| 31 | raw, err := readClaimUnitFile(path) |
| 32 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 33 | return claimUnitSettings{}, err |
| 34 | } |
| 35 | if err == nil { |
| 36 | // The installed EnvironmentFile is authoritative, including absent keys. |
| 37 | // Never execute it as shell code. Refuse unsupported syntax for these |
| 38 | // three settings rather than render permissions for a different value. |
| 39 | for key := range values { |
| 40 | values[key] = "" |
| 41 | } |
| 42 | scan := bufio.NewScanner(bytes.NewReader(raw)) |
| 43 | for scan.Scan() { |
| 44 | line := strings.TrimSpace(scan.Text()) |
| 45 | if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { |
| 46 | continue |
| 47 | } |
| 48 | key, v, ok := strings.Cut(line, "=") |
| 49 | key = strings.TrimSpace(key) |
| 50 | v = strings.TrimSpace(v) |
| 51 | if !ok { |
| 52 | continue |
| 53 | } |
| 54 | if strings.HasSuffix(v, `\`) || (len(v) > 0 && (v[0] == '"' || v[0] == '\'') && (len(v) < 2 || v[len(v)-1] != v[0])) { |
| 55 | return claimUnitSettings{}, errors.New("unsupported multiline host unit setting") |
| 56 | } |
| 57 | if _, known := values[key]; !known { |
| 58 | continue |
| 59 | } |
| 60 | v = strings.TrimSpace(v) |
| 61 | if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { |
| 62 | v = v[1 : len(v)-1] |
| 63 | } |
| 64 | if strings.ContainsAny(v, "\\\"'\x00\r\n") { |
| 65 | return claimUnitSettings{}, fmt.Errorf("unsupported host unit setting %s", key) |
| 66 | } |
| 67 | values[key] = v |
| 68 | } |
| 69 | if err := scan.Err(); err != nil { |
| 70 | return claimUnitSettings{}, err |
| 71 | } |
| 72 | } |
| 73 | cfg := claimUnitSettings{enabled: hostwrite.Enabled(func(k string) string { return values[k] }), data: values["CARLOS_DATA_DIR"], opt: values["CARLOS_OPT_DIR"]} |
| 74 | if cfg.data == "" { |
| 75 | cfg.data = "/data" |
| 76 | } |
| 77 | if cfg.opt == "" { |
| 78 | cfg.opt = "/opt/carlos" |
| 79 | } |
| 80 | if cfg.enabled { |
| 81 | for _, p := range []string{cfg.data, cfg.opt} { |
| 82 | if !filepath.IsAbs(p) || filepath.Clean(p) != p || p == "/" || strings.ContainsAny(p, "\x00\r\n") { |
| 83 | return claimUnitSettings{}, errors.New("invalid claim unit root") |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | if cfg.enabled { |
| 88 | cfg.rootRetry, err = prepareRootRetryUnitDirectory() |
| 89 | if err != nil { |
| 90 | return claimUnitSettings{}, err |
| 91 | } |
| 92 | } |
| 93 | return cfg, nil |
| 94 | } |
| 95 | |
| 96 | func claimUnitContent(name string, content []byte, cfg claimUnitSettings) []byte { |
| 97 | if name != claimControlUnit || !cfg.enabled { |
| 98 | return content |
| 99 | } |
| 100 | quote := func(p string) string { |
| 101 | if !strings.ContainsAny(p, " \t%\\\"") { |
| 102 | return p |
| 103 | } |
| 104 | return strconv.Quote(strings.ReplaceAll(p, "%", "%%")) |
| 105 | } |
| 106 | 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"} |
| 107 | if cfg.rootRetry { |
| 108 | roots = append(roots, rootRetryProjection) |
| 109 | } |
| 110 | for i, p := range roots { |
| 111 | roots[i] = quote(p) |
| 112 | } |
| 113 | content = bytes.Replace(content, []byte("[Service]\n"), []byte("[Service]\nStateDirectory=carlos-claims\nStateDirectoryMode=0700\n"+claimControlEnvironment), 1) |
| 114 | content = bytes.Replace(content, []byte("ReadWritePaths=/run/carlos-control /run/systemd/system\n"), []byte("ReadWritePaths="+strings.Join(roots, " ")+"\n"), 1) |
| 115 | content = bytes.Replace(content, []byte("RestrictAddressFamilies=AF_UNIX\n"), []byte("RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\n"), 1) |
| 116 | // Claim mode adds the root journal service, the offline legacy migration |
| 117 | // (up to 16 MiB of decoded, validated and re-marshalled rows) and the AWS |
| 118 | // fence pass to this process. 64M leaves no headroom over the Go runtime |
| 119 | // and SQLite caches; GOMEMLIMIT makes the collector work before the cap. |
| 120 | return bytes.Replace(content, []byte("MemoryMax=64M\n"), []byte("MemoryMax=256M\nEnvironment=GOMEMLIMIT=192MiB\n"), 1) |
| 121 | } |
| 122 | |
| 123 | func claimControlChanged(before, after []byte) bool { |
| 124 | return !bytes.Equal(before, after) && (bytes.Contains(before, []byte(claimControlEnvironment)) || bytes.Contains(after, []byte(claimControlEnvironment))) |
| 125 | } |
| 126 | |
| 127 | func restartClaimControl(path string, before, after []byte, systemctl func(...string) (string, error)) error { |
| 128 | if !claimControlChanged(before, after) { |
| 129 | return nil |
| 130 | } |
| 131 | if _, err := systemctl("try-restart", claimControlUnit); err != nil { |
| 132 | // Restore the old bytes so the next repair still sees a change. |
| 133 | var restore error |
| 134 | if before == nil { |
| 135 | restore = os.Remove(path) |
| 136 | } else { |
| 137 | restore = writeFileDurable(path, before, 0644) |
| 138 | } |
| 139 | _, reload := systemctl("daemon-reload") |
| 140 | return errors.Join(err, restore, reload) |
| 141 | } |
| 142 | return nil |
| 143 | } |
| 144 | |