A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
at main 416 lines 13 kB view raw
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}