carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
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
16const claimControlUnit = "carlos-instance-control.service"
17
18var claimBootstrapHostEnv = "/etc/carlos/host.env"
19var readClaimUnitFile = os.ReadFile
20
21const claimControlEnvironment = "EnvironmentFile=-/etc/carlos/host.env\n"
22
23type claimUnitSettings struct {
24 enabled bool
25 rootRetry bool
26 data, opt string
27}
28
29func 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
96func 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
123func claimControlChanged(before, after []byte) bool {
124 return !bytes.Equal(before, after) && (bytes.Contains(before, []byte(claimControlEnvironment)) || bytes.Contains(after, []byte(claimControlEnvironment)))
125}
126
127func 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