A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
1package main
2
3import (
4 "bytes"
5 _ "embed"
6 "fmt"
7 "strings"
8 "text/template"
9
10 "go.yaml.in/yaml/v3"
11)
12
13//go:embed systemd/appview.service.tmpl
14var appviewServiceTmpl string
15
16//go:embed systemd/hold.service.tmpl
17var holdServiceTmpl string
18
19//go:embed systemd/scanner.service.tmpl
20var scannerServiceTmpl string
21
22//go:embed configs/appview.yaml.tmpl
23var appviewConfigTmpl string
24
25//go:embed configs/hold.yaml.tmpl
26var holdConfigTmpl string
27
28//go:embed configs/scanner.yaml.tmpl
29var scannerConfigTmpl string
30
31//go:embed configs/cloudinit.sh.tmpl
32var cloudInitTmpl string
33
34// ConfigValues holds values injected into config YAML templates.
35// Only truly dynamic/computed values belong here — deployment-specific
36// values like client_name, owner_did, etc. are literal in the templates.
37type ConfigValues struct {
38 // S3 / Object Storage
39 S3Endpoint string
40 S3Region string
41 S3Bucket string
42 S3AccessKey string
43 S3SecretKey string
44
45 // Infrastructure (computed from zone + config)
46 Zone string // e.g. "us-chi1"
47 HoldDomain string // e.g. "us-chi1.cove.seamark.dev"
48 HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev"
49 BasePath string // e.g. "/var/lib/seamark"
50
51 // Scanner (auto-generated shared secret)
52 ScannerSecret string // hex-encoded 32-byte secret; empty disables scanning
53}
54
55// renderConfig executes a Go template with the given values.
56func renderConfig(tmplStr string, vals *ConfigValues) (string, error) {
57 t, err := template.New("config").Parse(tmplStr)
58 if err != nil {
59 return "", fmt.Errorf("parse config template: %w", err)
60 }
61 var buf bytes.Buffer
62 if err := t.Execute(&buf, vals); err != nil {
63 return "", fmt.Errorf("render config template: %w", err)
64 }
65 return buf.String(), nil
66}
67
68// serviceUnitParams holds values for rendering systemd service unit templates.
69type serviceUnitParams struct {
70 DisplayName string // e.g. "Seamark"
71 User string // e.g. "seamark"
72 BinaryPath string // e.g. "/opt/seamark/bin/seamark-appview"
73 ConfigPath string // e.g. "/etc/seamark/appview.yaml"
74 DataDir string // e.g. "/var/lib/seamark"
75 ServiceName string // e.g. "seamark-appview"
76}
77
78func renderServiceUnit(tmplStr string, p serviceUnitParams) (string, error) {
79 t, err := template.New("service").Parse(tmplStr)
80 if err != nil {
81 return "", fmt.Errorf("parse service template: %w", err)
82 }
83 var buf bytes.Buffer
84 if err := t.Execute(&buf, p); err != nil {
85 return "", fmt.Errorf("render service template: %w", err)
86 }
87 return buf.String(), nil
88}
89
90// scannerServiceUnitParams holds values for rendering the scanner systemd unit.
91// Extends the standard fields with HoldServiceName for the After= dependency.
92type scannerServiceUnitParams struct {
93 DisplayName string // e.g. "Seamark"
94 User string // e.g. "seamark"
95 BinaryPath string // e.g. "/opt/seamark/bin/seamark-scanner"
96 ConfigPath string // e.g. "/etc/seamark/scanner.yaml"
97 DataDir string // e.g. "/var/lib/seamark"
98 ServiceName string // e.g. "seamark-scanner"
99 HoldServiceName string // e.g. "seamark-hold" (After= dependency)
100}
101
102func renderScannerServiceUnit(p scannerServiceUnitParams) (string, error) {
103 t, err := template.New("scanner-service").Parse(scannerServiceTmpl)
104 if err != nil {
105 return "", fmt.Errorf("parse scanner service template: %w", err)
106 }
107 var buf bytes.Buffer
108 if err := t.Execute(&buf, p); err != nil {
109 return "", fmt.Errorf("render scanner service template: %w", err)
110 }
111 return buf.String(), nil
112}
113
114// generateAppviewCloudInit generates the cloud-init user-data script for the appview server.
115// Sets up the OS, directories, config, and systemd unit. Binaries are deployed separately via SCP.
116func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues) (string, error) {
117 naming := cfg.Naming()
118
119 configYAML, err := renderConfig(appviewConfigTmpl, vals)
120 if err != nil {
121 return "", fmt.Errorf("appview config: %w", err)
122 }
123
124 serviceUnit, err := renderServiceUnit(appviewServiceTmpl, serviceUnitParams{
125 DisplayName: naming.DisplayName(),
126 User: naming.SystemUser(),
127 BinaryPath: naming.InstallDir() + "/bin/" + naming.Appview(),
128 ConfigPath: naming.AppviewConfigPath(),
129 DataDir: naming.BasePath(),
130 ServiceName: naming.Appview(),
131 })
132 if err != nil {
133 return "", fmt.Errorf("appview service unit: %w", err)
134 }
135
136 return generateCloudInit(cloudInitParams{
137 BinaryName: naming.Appview(),
138 ServiceUnit: serviceUnit,
139 ConfigYAML: configYAML,
140 ConfigPath: naming.AppviewConfigPath(),
141 ServiceName: naming.Appview(),
142 DataDir: naming.BasePath(),
143 InstallDir: naming.InstallDir(),
144 SystemUser: naming.SystemUser(),
145 ConfigDir: naming.ConfigDir(),
146 LogFile: naming.LogFile(),
147 DisplayName: naming.DisplayName(),
148 })
149}
150
151// generateHoldCloudInit generates the cloud-init user-data script for the hold server.
152// When withScanner is true, a second phase is appended that creates scanner data
153// directories and installs a scanner systemd service. Binaries are deployed separately via SCP.
154func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, withScanner bool) (string, error) {
155 naming := cfg.Naming()
156
157 configYAML, err := renderConfig(holdConfigTmpl, vals)
158 if err != nil {
159 return "", fmt.Errorf("hold config: %w", err)
160 }
161
162 serviceUnit, err := renderServiceUnit(holdServiceTmpl, serviceUnitParams{
163 DisplayName: naming.DisplayName(),
164 User: naming.SystemUser(),
165 BinaryPath: naming.InstallDir() + "/bin/" + naming.Hold(),
166 ConfigPath: naming.HoldConfigPath(),
167 DataDir: naming.BasePath(),
168 ServiceName: naming.Hold(),
169 })
170 if err != nil {
171 return "", fmt.Errorf("hold service unit: %w", err)
172 }
173
174 script, err := generateCloudInit(cloudInitParams{
175 BinaryName: naming.Hold(),
176 ServiceUnit: serviceUnit,
177 ConfigYAML: configYAML,
178 ConfigPath: naming.HoldConfigPath(),
179 ServiceName: naming.Hold(),
180 DataDir: naming.BasePath(),
181 InstallDir: naming.InstallDir(),
182 SystemUser: naming.SystemUser(),
183 ConfigDir: naming.ConfigDir(),
184 LogFile: naming.LogFile(),
185 DisplayName: naming.DisplayName(),
186 })
187 if err != nil {
188 return "", err
189 }
190
191 if !withScanner {
192 return script, nil
193 }
194
195 // Render scanner config YAML
196 scannerConfigYAML, err := renderConfig(scannerConfigTmpl, vals)
197 if err != nil {
198 return "", fmt.Errorf("scanner config: %w", err)
199 }
200
201 // Append scanner setup phase (no build — binary deployed via SCP)
202 scannerUnit, err := renderScannerServiceUnit(scannerServiceUnitParams{
203 DisplayName: naming.DisplayName(),
204 User: naming.SystemUser(),
205 BinaryPath: naming.InstallDir() + "/bin/" + naming.Scanner(),
206 ConfigPath: naming.ScannerConfigPath(),
207 DataDir: naming.BasePath(),
208 ServiceName: naming.Scanner(),
209 HoldServiceName: naming.Hold(),
210 })
211 if err != nil {
212 return "", fmt.Errorf("scanner service unit: %w", err)
213 }
214
215 // Escape single quotes for heredoc embedding
216 scannerUnit = strings.ReplaceAll(scannerUnit, "'", "'\\''")
217 scannerConfigYAML = strings.ReplaceAll(scannerConfigYAML, "'", "'\\''")
218
219 scannerPhase := fmt.Sprintf(`
220# === Scanner Setup ===
221
222# Scanner data dirs
223mkdir -p %s/vulndb %s/tmp
224chown -R %s:%s %s
225
226# Scanner config
227cat > %s << 'CFGEOF'
228%s
229CFGEOF
230
231# Scanner systemd service
232cat > /etc/systemd/system/%s.service << 'SVCEOF'
233%s
234SVCEOF
235systemctl daemon-reload
236systemctl enable %s
237
238echo "=== Scanner setup complete ==="
239`,
240 naming.ScannerDataDir(), naming.ScannerDataDir(),
241 naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir(),
242 naming.ScannerConfigPath(),
243 scannerConfigYAML,
244 naming.Scanner(),
245 scannerUnit,
246 naming.Scanner(),
247 )
248
249 return script + scannerPhase, nil
250}
251
252type cloudInitParams struct {
253 BinaryName string
254 ServiceUnit string
255 ConfigYAML string
256 ConfigPath string
257 ServiceName string
258 DataDir string
259 InstallDir string
260 SystemUser string
261 ConfigDir string
262 LogFile string
263 DisplayName string
264}
265
266func generateCloudInit(p cloudInitParams) (string, error) {
267 // Escape single quotes in embedded content for heredoc safety
268 p.ServiceUnit = strings.ReplaceAll(p.ServiceUnit, "'", "'\\''")
269 p.ConfigYAML = strings.ReplaceAll(p.ConfigYAML, "'", "'\\''")
270
271 t, err := template.New("cloudinit").Parse(cloudInitTmpl)
272 if err != nil {
273 return "", fmt.Errorf("parse cloudinit template: %w", err)
274 }
275 var buf bytes.Buffer
276 if err := t.Execute(&buf, p); err != nil {
277 return "", fmt.Errorf("render cloudinit template: %w", err)
278 }
279 return buf.String(), nil
280}
281
282// syncServiceUnit compares a rendered systemd service unit against what's on
283// the server. If they differ, it writes the new unit file. Returns true if the
284// unit was updated (caller should daemon-reload before restart).
285func syncServiceUnit(name, ip, serviceName, renderedUnit string) (bool, error) {
286 unitPath := "/etc/systemd/system/" + serviceName + ".service"
287
288 remote, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", unitPath), false)
289 if err != nil {
290 fmt.Printf(" service unit sync: could not reach %s (%v)\n", name, err)
291 return false, nil
292 }
293 remote = strings.TrimSpace(remote)
294 rendered := strings.TrimSpace(renderedUnit)
295
296 if remote == "__MISSING__" {
297 fmt.Printf(" service unit: %s not found (cloud-init will handle it)\n", name)
298 return false, nil
299 }
300
301 if remote == rendered {
302 fmt.Printf(" service unit: %s up to date\n", name)
303 return false, nil
304 }
305
306 // Write the updated unit file
307 script := fmt.Sprintf("cat > %s << 'SVCEOF'\n%s\nSVCEOF", unitPath, rendered)
308 if _, err := runSSH(ip, script, false); err != nil {
309 return false, fmt.Errorf("write service unit: %w", err)
310 }
311 fmt.Printf(" service unit: %s updated\n", name)
312 return true, nil
313}
314
315// syncConfigKeys fetches the existing config from a server and merges in any
316// missing keys from the rendered template. Existing values are never overwritten.
317func syncConfigKeys(name, ip, configPath, templateYAML string) error {
318 remote, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", configPath), false)
319 if err != nil {
320 fmt.Printf(" config sync: could not reach %s (%v)\n", name, err)
321 return nil
322 }
323 remote = strings.TrimSpace(remote)
324
325 if remote == "__MISSING__" {
326 fmt.Printf(" config sync: %s not yet created (cloud-init will handle it)\n", name)
327 return nil
328 }
329
330 // Parse both into yaml.Node trees
331 var templateDoc yaml.Node
332 if err := yaml.Unmarshal([]byte(templateYAML), &templateDoc); err != nil {
333 return fmt.Errorf("parse template yaml: %w", err)
334 }
335 var existingDoc yaml.Node
336 if err := yaml.Unmarshal([]byte(remote), &existingDoc); err != nil {
337 return fmt.Errorf("parse remote yaml: %w", err)
338 }
339
340 // Unwrap document nodes to get the root mapping
341 templateRoot := unwrapDocNode(&templateDoc)
342 existingRoot := unwrapDocNode(&existingDoc)
343 if templateRoot == nil || existingRoot == nil {
344 fmt.Printf(" config sync: %s skipped (unexpected YAML structure)\n", name)
345 return nil
346 }
347
348 added := mergeYAMLNodes(templateRoot, existingRoot)
349 if !added {
350 fmt.Printf(" config sync: %s up to date\n", name)
351 return nil
352 }
353
354 // Marshal the modified tree back
355 merged, err := yaml.Marshal(&existingDoc)
356 if err != nil {
357 return fmt.Errorf("marshal merged yaml: %w", err)
358 }
359
360 // Write back to server
361 script := fmt.Sprintf("cat > %s << 'CFGEOF'\n%sCFGEOF", configPath, string(merged))
362 if _, err := runSSH(ip, script, false); err != nil {
363 return fmt.Errorf("write merged config: %w", err)
364 }
365 fmt.Printf(" config sync: %s updated with new keys\n", name)
366 return nil
367}
368
369// unwrapDocNode returns the root mapping node, unwrapping a DocumentNode wrapper if present.
370func unwrapDocNode(n *yaml.Node) *yaml.Node {
371 if n.Kind == yaml.DocumentNode && len(n.Content) > 0 {
372 return n.Content[0]
373 }
374 if n.Kind == yaml.MappingNode {
375 return n
376 }
377 return nil
378}
379
380// mergeYAMLNodes recursively adds keys from base into existing that are not
381// already present. Existing values are never overwritten. Returns true if any
382// new keys were added.
383func mergeYAMLNodes(base, existing *yaml.Node) bool {
384 if base.Kind != yaml.MappingNode || existing.Kind != yaml.MappingNode {
385 return false
386 }
387
388 added := false
389 for i := 0; i+1 < len(base.Content); i += 2 {
390 baseKey := base.Content[i]
391 baseVal := base.Content[i+1]
392
393 // Look for this key in existing
394 found := false
395 for j := 0; j+1 < len(existing.Content); j += 2 {
396 if existing.Content[j].Value == baseKey.Value {
397 found = true
398 // If both are mappings, recurse to merge sub-keys
399 if baseVal.Kind == yaml.MappingNode && existing.Content[j+1].Kind == yaml.MappingNode {
400 if mergeYAMLNodes(baseVal, existing.Content[j+1]) {
401 added = true
402 }
403 }
404 break
405 }
406 }
407
408 if !found {
409 // Append the missing key+value pair
410 existing.Content = append(existing.Content, baseKey, baseVal)
411 added = true
412 }
413 }
414
415 return added
416}