1package app
2
3import (
4 "os"
5 "path/filepath"
6 "slices"
7 "strings"
8
9 "github.com/bnema/gordon/internal/adapters/out/secretfiles"
10 "github.com/bnema/gordon/internal/boundaries/out"
11 "github.com/bnema/gordon/internal/domain"
12)
13
14// protectedRuntimePaths lists the host paths no app bind may equal, lie
15// under, or contain: Gordon's data, config, and configured files, runtime
16// sockets and storage, Gordon's runtime credentials, and key stores.
17// Empty entries are skipped; each entry is made absolute and its
18// symlink-resolved form is added when it differs.
19func protectedRuntimePaths(dataDir string, gordonPaths []string, getenv func(string) string, home string) []string {
20 runtimeDir := getenv("XDG_RUNTIME_DIR")
21 configHome := getenv("XDG_CONFIG_HOME")
22 if configHome == "" {
23 configHome = subPath(home, ".config")
24 }
25 dockerHost, _ := strings.CutPrefix(getenv("DOCKER_HOST"), "unix://")
26 if strings.Contains(dockerHost, "://") {
27 dockerHost = ""
28 }
29 raw := append([]string{
30 dataDir,
31 "/var/run/docker.sock",
32 "/run/docker.sock",
33 "/run/podman/podman.sock",
34 subPath(runtimeDir, "docker.sock"),
35 subPath(runtimeDir, "podman", "podman.sock"),
36 subPath(runtimeDir, "gordon"),
37 subPath(home, ".gordon", "run"),
38 dockerHost,
39 subPath(home, ".local", "share", "containers"),
40 subPath(home, ".local", "share", "docker"),
41 subPath(configHome, "containers"),
42 subPath(home, ".docker"),
43 subPath(configHome, "systemd", "user"),
44 getenv("GORDON_CLOUDFLARE_API_TOKEN_FILE"),
45 getenv("GNUPGHOME"),
46 subPath(home, ".gnupg"),
47 getenv("PASSWORD_STORE_DIR"),
48 subPath(home, ".password-store"),
49 getenv("SOPS_AGE_KEY_FILE"),
50 subPath(configHome, "sops", "age"),
51 }, gordonPaths...)
52 return absWithResolved(raw)
53}
54
55// absWithResolved makes each non-empty path absolute, adds its
56// symlink-resolved form when it differs, and returns the sorted set.
57func absWithResolved(raw []string) []string {
58 var paths []string
59 for _, p := range raw {
60 if p == "" {
61 continue
62 }
63 // A relative data dir or config path is resolved against the
64 // working directory, as the daemon itself resolves it.
65 clean, err := filepath.Abs(p)
66 if err != nil {
67 continue
68 }
69 paths = append(paths, clean)
70 if resolved := resolveExisting(clean); resolved != clean {
71 paths = append(paths, resolved)
72 }
73 }
74 slices.Sort(paths)
75 return slices.Compact(paths)
76}
77
78// resolveExisting resolves symlinks in the longest existing ancestor of p
79// and re-appends the missing tail, so a path created after startup under
80// a symlinked parent (for example /home -> /var/home) is still protected
81// in its resolved form.
82func resolveExisting(p string) string {
83 tail := ""
84 for dir := p; ; dir = filepath.Dir(dir) {
85 if resolved, err := filepath.EvalSymlinks(dir); err == nil {
86 return filepath.Join(resolved, tail)
87 }
88 parent := filepath.Dir(dir)
89 if parent == dir {
90 return p
91 }
92 tail = filepath.Join(filepath.Base(dir), tail)
93 }
94}
95
96// subPath joins elems under base, or returns "" when base is empty.
97func subPath(base string, elems ...string) string {
98 if base == "" {
99 return ""
100 }
101 return filepath.Join(append([]string{base}, elems...)...)
102}
103
104// configuredGordonPaths returns the config file, the runtime socket in use,
105// and every private file or directory the config points Gordon at. Log
106// paths are left to gordonLogLocations. The public TLS certificate is left out
107// so its directory (often a shared CA bundle) stays bindable.
108func configuredGordonPaths(si *serviceInit) []string {
109 cfg := si.cfg
110 paths := []string{
111 cfg.Server.TLSKeyFile,
112 cfg.Backups.StorageDir,
113 cfg.Backups.Databases.StorageDir,
114 }
115 if si.v != nil {
116 paths = append(paths, si.v.ConfigFileUsed(), resolveRuntimeConfig(si.v.GetString("server.runtime")))
117 }
118 return paths
119}
120
121// logLocation is one enabled Gordon log: the directory a read-only bind
122// may expose and the exact file or directory Gordon writes.
123type logLocation struct {
124 dir string
125 path string
126}
127
128// readableLogDirs splits the log locations into dirs that may be bound
129// read-only and exact log paths that must stay protected instead. A dir
130// under the data dir is readable only at or under <data_dir>/logs; a dir
131// outside it only when it neither is, contains, nor lies under another
132// protected path. So a misconfigured log path never opens a read-only
133// exception on secrets, key stores, or runtime dirs, and a dropped log
134// stays write-protected.
135func readableLogDirs(locations []logLocation, protected []string, dataDir string) (readable, keepProtected []string) {
136 dataDirs := absWithResolved([]string{dataDir})
137 dataLogs := absWithResolved([]string{filepath.Join(dataDir, "logs")})
138 for _, loc := range locations {
139 if readableLogDir(loc.dir, protected, dataDirs, dataLogs) {
140 readable = append(readable, loc.dir)
141 } else {
142 keepProtected = append(keepProtected, loc.path)
143 }
144 }
145 return readable, keepProtected
146}
147
148func readableLogDir(dir string, protected, dataDirs, dataLogs []string) bool {
149 underData := slices.ContainsFunc(dataDirs, func(d string) bool { return pathWithin(d, dir) })
150 if underData && !slices.ContainsFunc(dataLogs, func(l string) bool { return pathWithin(l, dir) }) {
151 return false
152 }
153 for _, p := range protected {
154 if underData && slices.Contains(dataDirs, p) {
155 continue
156 }
157 if pathWithin(p, dir) || pathWithin(dir, p) {
158 return false
159 }
160 }
161 return true
162}
163
164// pathWithin reports whether target is root or lies under it.
165func pathWithin(root, target string) bool {
166 rel, err := filepath.Rel(root, target)
167 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
168}
169
170// gordonLogLocations returns the enabled file logs: Gordon's process log,
171// the access log, and the container logs dir, with paths made absolute.
172func gordonLogLocations(cfg Config, dataDir string) []logLocation {
173 var locs []logLocation
174 add := func(dir, path string) {
175 dir, errDir := filepath.Abs(dir)
176 path, errPath := filepath.Abs(path)
177 if errDir == nil && errPath == nil {
178 locs = append(locs, logLocation{dir: dir, path: path})
179 }
180 }
181 if cfg.Logging.File.Enabled {
182 p := cfg.Logging.File.Path
183 if p == "" {
184 p = filepath.Join(dataDir, "logs", "gordon.log")
185 }
186 add(filepath.Dir(p), p)
187 }
188 if cfg.Logging.AccessLog.Enabled && cfg.Logging.AccessLog.Output == "file" {
189 p := cfg.Logging.AccessLog.FilePath
190 if p == "" {
191 p = filepath.Join(dataDir, "logs", "access.log")
192 }
193 add(filepath.Dir(p), p)
194 }
195 if cfg.Logging.ContainerLogs.Enabled {
196 p := cfg.Logging.ContainerLogs.Dir
197 if p == "" {
198 p = filepath.Join(dataDir, "logs", "containers")
199 }
200 add(p, p)
201 }
202 return locs
203}
204
205// buildAppRuntimeSafety opens the host secret file store and builds the
206// runtime guard applied to every app container create. The store keeps one
207// directory handle open for the daemon's lifetime.
208func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) {
209 store, err := secretfiles.NewStore(dataDir)
210 if err != nil {
211 return nil, domain.RuntimeGuard{}, err
212 }
213 home, _ := os.UserHomeDir()
214 paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)
215 logDirs, logPaths := readableLogDirs(gordonLogLocations(si.cfg, dataDir), paths, dataDir)
216 paths = absWithResolved(append(paths, logPaths...))
217 return store, domain.RuntimeGuard{
218 ProtectedPaths: paths,
219 SecretFilesDir: store.Dir(),
220 LogDirs: absWithResolved(logDirs),
221 }, nil
222}