A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

tweaks related to did:plc, fix bluesky profile creation, update deploys to build locally then scp

evan.jarrett.net f340158a e3843db9

verified
+567 -250
+1 -1
CLAUDE.md
··· 232 232 **Hold DID recovery/migration (did:plc):** 233 233 1. Back up `rotation.key` and DID string (from `did.txt` or plc.directory) 234 234 2. Set `database.did_method: plc` and `database.did: "did:plc:..."` in config 235 - 3. Provide `rotation_key_path` — signing key auto-generates if missing 235 + 3. Provide `rotation_key` (multibase K-256 private key) — signing key auto-generates if missing 236 236 4. On boot: `LoadOrCreateDID()` adopts the DID, `EnsurePLCCurrent()` auto-updates PLC directory if keys/URL changed 237 237 5. Without rotation key: hold boots but logs warning about PLC mismatch 238 238
+1
cmd/hold/main.go
··· 77 77 rootCmd.AddCommand(serveCmd) 78 78 rootCmd.AddCommand(configCmd) 79 79 rootCmd.AddCommand(repoCmd) 80 + rootCmd.AddCommand(plcCmd) 80 81 } 81 82 82 83 func main() {
+164
cmd/hold/plc.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + 8 + "atcr.io/pkg/auth/oauth" 9 + "atcr.io/pkg/hold" 10 + "atcr.io/pkg/hold/pds" 11 + 12 + "github.com/bluesky-social/indigo/atproto/atcrypto" 13 + didplc "github.com/did-method-plc/go-didplc" 14 + "github.com/spf13/cobra" 15 + ) 16 + 17 + var plcCmd = &cobra.Command{ 18 + Use: "plc", 19 + Short: "PLC directory management commands", 20 + } 21 + 22 + var plcConfigFile string 23 + 24 + var plcAddRotationKeyCmd = &cobra.Command{ 25 + Use: "add-rotation-key <multibase-key>", 26 + Short: "Add a rotation key to this hold's PLC identity", 27 + Long: `Add an additional rotation key to the hold's did:plc document. 28 + The key must be a multibase-encoded private key (K-256 or P-256, starting with 'z'). 29 + The hold's configured rotation key is used to sign the PLC update. 30 + 31 + atcr-hold plc add-rotation-key --config config.yaml z...`, 32 + Args: cobra.ExactArgs(1), 33 + RunE: func(cmd *cobra.Command, args []string) error { 34 + cfg, err := hold.LoadConfig(plcConfigFile) 35 + if err != nil { 36 + return fmt.Errorf("failed to load config: %w", err) 37 + } 38 + 39 + if cfg.Database.DIDMethod != "plc" { 40 + return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod) 41 + } 42 + 43 + ctx := context.Background() 44 + 45 + // Resolve the hold's DID 46 + holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{ 47 + DID: cfg.Database.DID, 48 + DIDMethod: cfg.Database.DIDMethod, 49 + PublicURL: cfg.Server.PublicURL, 50 + DBPath: cfg.Database.Path, 51 + SigningKeyPath: cfg.Database.KeyPath, 52 + RotationKey: cfg.Database.RotationKey, 53 + PLCDirectoryURL: cfg.Database.PLCDirectoryURL, 54 + }) 55 + if err != nil { 56 + return fmt.Errorf("failed to resolve hold DID: %w", err) 57 + } 58 + 59 + // Parse the rotation key from config (required for signing PLC updates) 60 + if cfg.Database.RotationKey == "" { 61 + return fmt.Errorf("database.rotation_key must be set to sign PLC updates") 62 + } 63 + rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey) 64 + if err != nil { 65 + return fmt.Errorf("failed to parse rotation_key from config: %w", err) 66 + } 67 + 68 + // Parse the new key to add (K-256 or P-256) 69 + newKey, err := atcrypto.ParsePrivateMultibase(args[0]) 70 + if err != nil { 71 + return fmt.Errorf("failed to parse key argument: %w", err) 72 + } 73 + newKeyPub, err := newKey.PublicKey() 74 + if err != nil { 75 + return fmt.Errorf("failed to get public key from argument: %w", err) 76 + } 77 + newKeyDIDKey := newKeyPub.DIDKey() 78 + 79 + // Load signing key for verification methods 80 + keyPath := cfg.Database.KeyPath 81 + if keyPath == "" { 82 + keyPath = cfg.Database.Path + "/signing.key" 83 + } 84 + signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath) 85 + if err != nil { 86 + return fmt.Errorf("failed to load signing key: %w", err) 87 + } 88 + 89 + // Fetch current PLC state 90 + plcDirectoryURL := cfg.Database.PLCDirectoryURL 91 + if plcDirectoryURL == "" { 92 + plcDirectoryURL = "https://plc.directory" 93 + } 94 + client := &didplc.Client{DirectoryURL: plcDirectoryURL} 95 + 96 + opLog, err := client.OpLog(ctx, holdDID) 97 + if err != nil { 98 + return fmt.Errorf("failed to fetch PLC op log: %w", err) 99 + } 100 + if len(opLog) == 0 { 101 + return fmt.Errorf("empty op log for %s", holdDID) 102 + } 103 + 104 + lastEntry := opLog[len(opLog)-1] 105 + lastOp := lastEntry.Regular 106 + if lastOp == nil { 107 + return fmt.Errorf("last PLC operation is not a regular op") 108 + } 109 + 110 + // Check if key already present 111 + for _, k := range lastOp.RotationKeys { 112 + if k == newKeyDIDKey { 113 + fmt.Printf("Key %s is already a rotation key for %s\n", newKeyDIDKey, holdDID) 114 + return nil 115 + } 116 + } 117 + 118 + // Build updated rotation keys: keep existing, append new 119 + rotationKeys := make([]string, len(lastOp.RotationKeys)) 120 + copy(rotationKeys, lastOp.RotationKeys) 121 + rotationKeys = append(rotationKeys, newKeyDIDKey) 122 + 123 + // Build update: preserve everything else from current state 124 + sigPub, err := signingKey.PublicKey() 125 + if err != nil { 126 + return fmt.Errorf("failed to get signing public key: %w", err) 127 + } 128 + 129 + prevCID := lastEntry.AsOperation().CID().String() 130 + 131 + op := &didplc.RegularOp{ 132 + Type: "plc_operation", 133 + RotationKeys: rotationKeys, 134 + VerificationMethods: map[string]string{ 135 + "atproto": sigPub.DIDKey(), 136 + }, 137 + AlsoKnownAs: lastOp.AlsoKnownAs, 138 + Services: lastOp.Services, 139 + Prev: &prevCID, 140 + } 141 + 142 + if err := op.Sign(rotationKey); err != nil { 143 + return fmt.Errorf("failed to sign PLC update: %w", err) 144 + } 145 + 146 + if err := client.Submit(ctx, holdDID, op); err != nil { 147 + return fmt.Errorf("failed to submit PLC update: %w", err) 148 + } 149 + 150 + slog.Info("Added rotation key to PLC identity", 151 + "did", holdDID, 152 + "new_key", newKeyDIDKey, 153 + "total_rotation_keys", len(rotationKeys), 154 + ) 155 + fmt.Printf("Added rotation key %s to %s\n", newKeyDIDKey, holdDID) 156 + return nil 157 + }, 158 + } 159 + 160 + func init() { 161 + plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file") 162 + 163 + plcCmd.AddCommand(plcAddRotationKeyCmd) 164 + }
+1 -1
cmd/hold/repo.go
··· 111 111 PublicURL: cfg.Server.PublicURL, 112 112 DBPath: cfg.Database.Path, 113 113 SigningKeyPath: cfg.Database.KeyPath, 114 - RotationKeyPath: cfg.Database.RotationKeyPath, 114 + RotationKey: cfg.Database.RotationKey, 115 115 PLCDirectoryURL: cfg.Database.PLCDirectoryURL, 116 116 }) 117 117 if err != nil {
+6 -2
config-hold.example.yaml
··· 59 59 allow_all_crew: false 60 60 # URL to fetch avatar image from during bootstrap. 61 61 profile_avatar_url: https://atcr.io/web-app-manifest-192x192.png 62 + # Bluesky profile display name. Synced on every startup. 63 + profile_display_name: Cargo Hold 64 + # Bluesky profile description. Synced on every startup. 65 + profile_description: ahoy from the cargo hold 62 66 # Post to Bluesky when users push images. Synced to captain record on startup. 63 67 enable_bluesky_posts: false 64 68 # Deployment region, auto-detected from cloud metadata or S3 config. ··· 75 79 did: "" 76 80 # PLC directory URL. Only used when did_method is 'plc'. Default: https://plc.directory 77 81 plc_directory_url: https://plc.directory 78 - # Rotation key path for did:plc. Controls DID identity (separate from signing key). Defaults to {database.path}/rotation.key. 79 - rotation_key_path: "" 82 + # Rotation key for did:plc in multibase format (starting with 'z'). Generate with: goat key generate. Supports K-256 and P-256 curves. Controls DID identity (separate from signing key). 83 + rotation_key: "" 80 84 # libSQL sync URL (libsql://...). Works with Turso cloud, Bunny DB, or self-hosted libsql-server. Leave empty for local-only SQLite. 81 85 libsql_sync_url: "" 82 86 # Auth token for libSQL sync. Required if libsql_sync_url is set.
+9 -30
deploy/upcloud/cloudinit.go
··· 36 36 // values like client_name, owner_did, etc. are literal in the templates. 37 37 type ConfigValues struct { 38 38 // S3 / Object Storage 39 - S3Endpoint string 40 - S3Region string 41 - S3Bucket string 39 + S3Endpoint string 40 + S3Region string 41 + S3Bucket string 42 42 S3AccessKey string 43 43 S3SecretKey string 44 44 ··· 112 112 } 113 113 114 114 // generateAppviewCloudInit generates the cloud-init user-data script for the appview server. 115 - func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) { 115 + // Sets up the OS, directories, config, and systemd unit. Binaries are deployed separately via SCP. 116 + func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues) (string, error) { 116 117 naming := cfg.Naming() 117 118 118 119 configYAML, err := renderConfig(appviewConfigTmpl, vals) ··· 133 134 } 134 135 135 136 return generateCloudInit(cloudInitParams{ 136 - GoVersion: goVersion, 137 137 BinaryName: naming.Appview(), 138 - BuildCmd: "appview", 139 138 ServiceUnit: serviceUnit, 140 139 ConfigYAML: configYAML, 141 140 ConfigPath: naming.AppviewConfigPath(), 142 141 ServiceName: naming.Appview(), 143 142 DataDir: naming.BasePath(), 144 - RepoURL: cfg.RepoURL, 145 - RepoBranch: cfg.RepoBranch, 146 143 InstallDir: naming.InstallDir(), 147 144 SystemUser: naming.SystemUser(), 148 145 ConfigDir: naming.ConfigDir(), ··· 152 149 } 153 150 154 151 // generateHoldCloudInit generates the cloud-init user-data script for the hold server. 155 - // When withScanner is true, a second phase is appended that builds the scanner binary, 156 - // creates scanner data directories, and installs a scanner systemd service. 157 - func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string, withScanner bool) (string, error) { 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. 154 + func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, withScanner bool) (string, error) { 158 155 naming := cfg.Naming() 159 156 160 157 configYAML, err := renderConfig(holdConfigTmpl, vals) ··· 175 172 } 176 173 177 174 script, err := generateCloudInit(cloudInitParams{ 178 - GoVersion: goVersion, 179 175 BinaryName: naming.Hold(), 180 - BuildCmd: "hold", 181 176 ServiceUnit: serviceUnit, 182 177 ConfigYAML: configYAML, 183 178 ConfigPath: naming.HoldConfigPath(), 184 179 ServiceName: naming.Hold(), 185 180 DataDir: naming.BasePath(), 186 - RepoURL: cfg.RepoURL, 187 - RepoBranch: cfg.RepoBranch, 188 181 InstallDir: naming.InstallDir(), 189 182 SystemUser: naming.SystemUser(), 190 183 ConfigDir: naming.ConfigDir(), ··· 205 198 return "", fmt.Errorf("scanner config: %w", err) 206 199 } 207 200 208 - // Append scanner build and setup phase 201 + // Append scanner setup phase (no build — binary deployed via SCP) 209 202 scannerUnit, err := renderScannerServiceUnit(scannerServiceUnitParams{ 210 203 DisplayName: naming.DisplayName(), 211 204 User: naming.SystemUser(), ··· 225 218 226 219 scannerPhase := fmt.Sprintf(` 227 220 # === Scanner Setup === 228 - echo "Building scanner..." 229 - cd %s/scanner 230 - CGO_ENABLED=1 go build \ 231 - -ldflags="-s -w" \ 232 - -trimpath \ 233 - -o ../bin/%s ./cmd/scanner 234 - cd %s 235 221 236 222 # Scanner data dirs 237 223 mkdir -p %s/vulndb %s/tmp ··· 251 237 252 238 echo "=== Scanner setup complete ===" 253 239 `, 254 - naming.InstallDir(), 255 - naming.Scanner(), 256 - naming.InstallDir(), 257 240 naming.ScannerDataDir(), naming.ScannerDataDir(), 258 241 naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir(), 259 242 naming.ScannerConfigPath(), ··· 267 250 } 268 251 269 252 type cloudInitParams struct { 270 - GoVersion string 271 253 BinaryName string 272 - BuildCmd string 273 254 ServiceUnit string 274 255 ConfigYAML string 275 256 ConfigPath string 276 257 ServiceName string 277 258 DataDir string 278 - RepoURL string 279 - RepoBranch string 280 259 InstallDir string 281 260 SystemUser string 282 261 ConfigDir string
+3 -21
deploy/upcloud/configs/cloudinit.sh.tmpl
··· 19 19 apt-get update && apt-get upgrade -y 20 20 apt-get install -y git gcc make curl libsqlite3-dev nodejs npm htop 21 21 22 - # Swap (for builds on small instances) 22 + # Swap (for small instances) 23 23 if [ ! -f /swapfile ]; then 24 24 dd if=/dev/zero of=/swapfile bs=1M count=2048 25 25 chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile 26 26 echo '/swapfile none swap sw 0 0' >> /etc/fstab 27 27 fi 28 28 29 - # Go {{.GoVersion}} 30 - curl -fsSL https://go.dev/dl/go{{.GoVersion}}.linux-amd64.tar.gz | tar -C /usr/local -xz 31 - echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh 32 - export PATH=$PATH:/usr/local/go/bin 33 - export GOTMPDIR=/var/tmp 34 - 35 - # Clone & build 36 - if [ -d {{.InstallDir}} ]; then 37 - cd {{.InstallDir}} && git pull origin {{.RepoBranch}} 38 - else 39 - git clone -b {{.RepoBranch}} {{.RepoURL}} {{.InstallDir}} 40 - cd {{.InstallDir}} 41 - fi 42 - npm ci 43 - go generate ./... 44 - CGO_ENABLED=1 go build \ 45 - -ldflags="-s -w" \ 46 - -trimpath \ 47 - -o bin/{{.BinaryName}} ./cmd/{{.BuildCmd}} 29 + # Install directory (binaries deployed via SCP) 30 + mkdir -p {{.InstallDir}}/bin 48 31 49 32 # Service user & data dirs 50 33 useradd --system --no-create-home --shell /usr/sbin/nologin {{.SystemUser}} || true ··· 68 51 systemctl enable {{.ServiceName}} 69 52 70 53 echo "=== Setup complete at $(date -u) ===" 71 - echo "Edit {{.ConfigPath}} then: systemctl start {{.ServiceName}}"
+3 -1
deploy/upcloud/configs/hold.yaml.tmpl
··· 27 27 owner_did: "did:plc:pddp4xt5lgnv2qsegbzzs4xg" 28 28 allow_all_crew: true 29 29 profile_avatar_url: https://{{.HoldDomain}}/web-app-manifest-192x192.png 30 + profile_display_name: Cargo Hold 31 + profile_description: ahoy from the cargo hold 30 32 enable_bluesky_posts: false 31 33 region: "" 32 34 database: ··· 35 37 did_method: web 36 38 did: "" 37 39 plc_directory_url: https://plc.directory 38 - rotation_key_path: "" 40 + rotation_key: "" 39 41 libsql_sync_url: "" 40 42 libsql_auth_token: "" 41 43 libsql_sync_interval: 1m0s
deploy/upcloud/deploy

This is a binary file and will not be displayed.

+4 -26
deploy/upcloud/goversion.go
··· 1 1 package main 2 2 3 3 import ( 4 - "fmt" 5 - "os" 6 4 "path/filepath" 7 5 "runtime" 8 - "strings" 9 6 ) 10 7 11 - // requiredGoVersion reads the Go version from the root go.mod file. 12 - // Returns a version string like "1.25.7" for use in download URLs. 13 - func requiredGoVersion() (string, error) { 8 + // projectRoot returns the absolute path to the repository root, 9 + // derived from the compile-time source file location. 10 + func projectRoot() string { 14 11 _, thisFile, _, _ := runtime.Caller(0) 15 - rootMod := filepath.Join(filepath.Dir(thisFile), "..", "..", "go.mod") 16 - 17 - data, err := os.ReadFile(rootMod) 18 - if err != nil { 19 - return "", fmt.Errorf("read root go.mod: %w", err) 20 - } 21 - 22 - for _, line := range strings.Split(string(data), "\n") { 23 - line = strings.TrimSpace(line) 24 - if strings.HasPrefix(line, "go ") { 25 - version := strings.TrimPrefix(line, "go ") 26 - version = strings.TrimSpace(version) 27 - // Validate it looks like a version 28 - if len(version) > 0 && version[0] >= '1' && version[0] <= '9' { 29 - return version, nil 30 - } 31 - } 32 - } 33 - 34 - return "", fmt.Errorf("no 'go X.Y.Z' directive found in %s", rootMod) 12 + return filepath.Join(filepath.Dir(thisFile), "..", "..") 35 13 }
+109 -22
deploy/upcloud/provision.go
··· 9 9 "encoding/hex" 10 10 "fmt" 11 11 "os" 12 + "path/filepath" 12 13 "strings" 13 14 "time" 14 15 ··· 38 39 provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)") 39 40 provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)") 40 41 provisionCmd.Flags().Bool("with-scanner", false, "Deploy vulnerability scanner alongside hold") 41 - provisionCmd.MarkFlagRequired("ssh-key") 42 + _ = provisionCmd.MarkFlagRequired("ssh-key") 42 43 rootCmd.AddCommand(provisionCmd) 43 44 } 44 45 ··· 94 95 state.ScannerSecret = secret 95 96 fmt.Printf("Generated scanner shared secret\n") 96 97 } 97 - saveState(state) 98 - } 99 - 100 - goVersion, err := requiredGoVersion() 101 - if err != nil { 102 - return err 98 + _ = saveState(state) 103 99 } 104 100 105 101 fmt.Printf("Provisioning %s infrastructure in zone %s...\n", naming.DisplayName(), cfg.Zone) 106 - fmt.Printf("Go version: %s (from go.mod)\n", goVersion) 107 102 if needsServers { 108 103 fmt.Printf("Server plan: %s\n", cfg.Plan) 109 104 } ··· 130 125 if discovered.AccessKeyID != "" { 131 126 state.ObjectStorage.AccessKeyID = discovered.AccessKeyID 132 127 } 133 - saveState(state) 128 + _ = saveState(state) 134 129 } 135 130 } else { 136 131 fmt.Println("Creating object storage...") ··· 140 135 } 141 136 state.ObjectStorage = objState 142 137 s3SecretKey = secretKey 143 - saveState(state) 138 + _ = saveState(state) 144 139 fmt.Printf(" S3 Secret Key: %s\n", secretKey) 145 140 } 146 141 ··· 189 184 return fmt.Errorf("create network: %w", err) 190 185 } 191 186 state.Network = StateRef{UUID: network.UUID} 192 - saveState(state) 187 + _ = saveState(state) 193 188 fmt.Printf(" Network: %s (%s)\n", network.UUID, privateNetworkCIDR) 194 189 } 195 190 ··· 200 195 } 201 196 202 197 // 3. Appview server 198 + appviewCreated := false 203 199 if state.Appview.UUID != "" { 204 200 fmt.Printf("Appview: %s (exists)\n", state.Appview.UUID) 205 - appviewScript, err := generateAppviewCloudInit(cfg, vals, goVersion) 201 + appviewScript, err := generateAppviewCloudInit(cfg, vals) 206 202 if err != nil { 207 203 return err 208 204 } ··· 218 214 } 219 215 } else { 220 216 fmt.Println("Creating appview server...") 221 - appviewUserData, err := generateAppviewCloudInit(cfg, vals, goVersion) 217 + appviewUserData, err := generateAppviewCloudInit(cfg, vals) 222 218 if err != nil { 223 219 return err 224 220 } ··· 227 223 return fmt.Errorf("create appview: %w", err) 228 224 } 229 225 state.Appview = *appview 230 - saveState(state) 226 + _ = saveState(state) 227 + appviewCreated = true 231 228 fmt.Printf(" Appview: %s (public: %s, private: %s)\n", appview.UUID, appview.PublicIP, appview.PrivateIP) 232 229 } 233 230 234 231 // 4. Hold server 232 + holdCreated := false 235 233 if state.Hold.UUID != "" { 236 234 fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID) 237 - holdScript, err := generateHoldCloudInit(cfg, vals, goVersion, state.ScannerEnabled) 235 + holdScript, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled) 238 236 if err != nil { 239 237 return err 240 238 } ··· 259 257 } 260 258 } else { 261 259 fmt.Println("Creating hold server...") 262 - holdUserData, err := generateHoldCloudInit(cfg, vals, goVersion, state.ScannerEnabled) 260 + holdUserData, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled) 263 261 if err != nil { 264 262 return err 265 263 } ··· 268 266 return fmt.Errorf("create hold: %w", err) 269 267 } 270 268 state.Hold = *hold 271 - saveState(state) 269 + _ = saveState(state) 270 + holdCreated = true 272 271 fmt.Printf(" Hold: %s (public: %s, private: %s)\n", hold.UUID, hold.PublicIP, hold.PrivateIP) 273 272 } 274 273 ··· 296 295 return fmt.Errorf("create LB: %w", err) 297 296 } 298 297 state.LB = StateRef{UUID: lb.UUID} 299 - saveState(state) 298 + _ = saveState(state) 300 299 } 301 300 302 301 // Always reconcile forwarded headers rule (handles existing LBs) ··· 325 324 } 326 325 } 327 326 327 + // 7. Build locally and deploy binaries to new servers 328 + if appviewCreated || holdCreated { 329 + rootDir := projectRoot() 330 + 331 + fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...") 332 + if appviewCreated { 333 + outputPath := filepath.Join(rootDir, "bin", "atcr-appview") 334 + if err := buildLocal(rootDir, outputPath, "./cmd/appview"); err != nil { 335 + return fmt.Errorf("build appview: %w", err) 336 + } 337 + } 338 + if holdCreated { 339 + outputPath := filepath.Join(rootDir, "bin", "atcr-hold") 340 + if err := buildLocal(rootDir, outputPath, "./cmd/hold"); err != nil { 341 + return fmt.Errorf("build hold: %w", err) 342 + } 343 + if state.ScannerEnabled { 344 + outputPath := filepath.Join(rootDir, "bin", "atcr-scanner") 345 + if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil { 346 + return fmt.Errorf("build scanner: %w", err) 347 + } 348 + } 349 + } 350 + 351 + fmt.Println("\nWaiting for cloud-init to complete on new servers...") 352 + if appviewCreated { 353 + if err := waitForSetup(state.Appview.PublicIP, "appview"); err != nil { 354 + return err 355 + } 356 + } 357 + if holdCreated { 358 + if err := waitForSetup(state.Hold.PublicIP, "hold"); err != nil { 359 + return err 360 + } 361 + } 362 + 363 + fmt.Println("\nDeploying binaries...") 364 + if appviewCreated { 365 + localPath := filepath.Join(rootDir, "bin", "atcr-appview") 366 + remotePath := naming.InstallDir() + "/bin/" + naming.Appview() 367 + if err := scpFile(localPath, state.Appview.PublicIP, remotePath); err != nil { 368 + return fmt.Errorf("upload appview: %w", err) 369 + } 370 + } 371 + if holdCreated { 372 + localPath := filepath.Join(rootDir, "bin", "atcr-hold") 373 + remotePath := naming.InstallDir() + "/bin/" + naming.Hold() 374 + if err := scpFile(localPath, state.Hold.PublicIP, remotePath); err != nil { 375 + return fmt.Errorf("upload hold: %w", err) 376 + } 377 + if state.ScannerEnabled { 378 + scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner") 379 + scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner() 380 + if err := scpFile(scannerLocal, state.Hold.PublicIP, scannerRemote); err != nil { 381 + return fmt.Errorf("upload scanner: %w", err) 382 + } 383 + } 384 + } 385 + } 386 + 328 387 fmt.Println("\n=== Provisioning Complete ===") 329 388 fmt.Println() 330 389 fmt.Println("DNS records needed:") ··· 343 402 fmt.Printf(" ssh root@%s # hold\n", state.Hold.PublicIP) 344 403 fmt.Println() 345 404 fmt.Println("Next steps:") 346 - fmt.Println(" 1. Wait ~5 min for cloud-init to complete") 405 + if appviewCreated || holdCreated { 406 + fmt.Println(" 1. Edit configs if needed, then start services:") 407 + } else { 408 + fmt.Println(" 1. Start services:") 409 + } 347 410 if state.ScannerEnabled { 348 - fmt.Printf(" 2. systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner()) 411 + fmt.Printf(" systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner()) 349 412 } else { 350 - fmt.Printf(" 2. systemctl start %s / %s\n", naming.Appview(), naming.Hold()) 413 + fmt.Printf(" systemctl start %s / %s\n", naming.Appview(), naming.Hold()) 351 414 } 352 - fmt.Println(" 3. Configure DNS records above") 415 + fmt.Println(" 2. Configure DNS records above") 353 416 354 417 return nil 355 418 } ··· 984 1047 _, err := runSSH(ip, cmd, false) 985 1048 return err 986 1049 } 1050 + 1051 + // waitForSetup polls SSH availability on a newly created server, then waits 1052 + // for cloud-init to complete before returning. 1053 + func waitForSetup(ip, name string) error { 1054 + fmt.Printf(" %s (%s): waiting for SSH...\n", name, ip) 1055 + for i := 0; i < 30; i++ { 1056 + _, err := runSSH(ip, "echo ssh_ready", false) 1057 + if err == nil { 1058 + break 1059 + } 1060 + if i == 29 { 1061 + return fmt.Errorf("SSH not available after 5 minutes on %s (%s)", name, ip) 1062 + } 1063 + time.Sleep(10 * time.Second) 1064 + } 1065 + 1066 + fmt.Printf(" %s: waiting for cloud-init...\n", name) 1067 + _, err := runSSH(ip, "cloud-init status --wait 2>/dev/null || true", false) 1068 + if err != nil { 1069 + return fmt.Errorf("cloud-init wait on %s: %w", name, err) 1070 + } 1071 + fmt.Printf(" %s: ready\n", name) 1072 + return nil 1073 + }
+6 -6
deploy/upcloud/state.go
··· 10 10 11 11 // InfraState persists infrastructure resource UUIDs between commands. 12 12 type InfraState struct { 13 - Zone string `json:"zone"` 14 - ClientName string `json:"client_name,omitempty"` 15 - RepoBranch string `json:"repo_branch,omitempty"` 16 - Network StateRef `json:"network"` 17 - Appview ServerState `json:"appview"` 18 - Hold ServerState `json:"hold"` 13 + Zone string `json:"zone"` 14 + ClientName string `json:"client_name,omitempty"` 15 + RepoBranch string `json:"repo_branch,omitempty"` 16 + Network StateRef `json:"network"` 17 + Appview ServerState `json:"appview"` 18 + Hold ServerState `json:"hold"` 19 19 LB StateRef `json:"loadbalancer"` 20 20 ObjectStorage ObjectStorageState `json:"object_storage"` 21 21 ScannerEnabled bool `json:"scanner_enabled,omitempty"`
+1 -1
deploy/upcloud/teardown.go
··· 87 87 if err != nil { 88 88 fmt.Printf(" Warning (stop): %v\n", err) 89 89 } else { 90 - svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ 90 + _, _ = svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ 91 91 UUID: s.uuid, 92 92 DesiredState: "stopped", 93 93 })
+100 -57
deploy/upcloud/update.go
··· 6 6 "io" 7 7 "os" 8 8 "os/exec" 9 + "path/filepath" 9 10 "strings" 10 11 "time" 11 12 ··· 50 51 } 51 52 52 53 naming := state.Naming() 53 - branch := state.Branch() 54 - 55 - goVersion, err := requiredGoVersion() 56 - if err != nil { 57 - return err 58 - } 54 + rootDir := projectRoot() 59 55 60 56 // Enable scanner retroactively via --with-scanner on update 61 57 if withScanner && !state.ScannerEnabled { ··· 68 64 state.ScannerSecret = secret 69 65 fmt.Printf("Generated scanner shared secret\n") 70 66 } 71 - saveState(state) 67 + _ = saveState(state) 72 68 } 73 69 74 70 vals := configValsFromState(state) ··· 77 73 ip string 78 74 binaryName string 79 75 buildCmd string 76 + localBinary string 80 77 serviceName string 81 78 healthURL string 82 79 configTmpl string ··· 87 84 ip: state.Appview.PublicIP, 88 85 binaryName: naming.Appview(), 89 86 buildCmd: "appview", 87 + localBinary: "atcr-appview", 90 88 serviceName: naming.Appview(), 91 89 healthURL: "http://localhost:5000/health", 92 90 configTmpl: appviewConfigTmpl, ··· 97 95 ip: state.Hold.PublicIP, 98 96 binaryName: naming.Hold(), 99 97 buildCmd: "hold", 98 + localBinary: "atcr-hold", 100 99 serviceName: naming.Hold(), 101 100 healthURL: "http://localhost:8080/xrpc/_health", 102 101 configTmpl: holdConfigTmpl, ··· 115 114 return fmt.Errorf("unknown target: %s (use: all, appview, hold)", target) 116 115 } 117 116 117 + // Build all binaries locally before touching servers 118 + fmt.Println("Building locally (GOOS=linux GOARCH=amd64)...") 118 119 for _, name := range toUpdate { 119 120 t := targets[name] 120 - fmt.Printf("Updating %s (%s)...\n", name, t.ip) 121 + outputPath := filepath.Join(rootDir, "bin", t.localBinary) 122 + if err := buildLocal(rootDir, outputPath, "./cmd/"+t.buildCmd); err != nil { 123 + return fmt.Errorf("build %s: %w", name, err) 124 + } 125 + } 126 + 127 + // Build scanner locally if needed 128 + needScanner := false 129 + for _, name := range toUpdate { 130 + if name == "hold" && state.ScannerEnabled { 131 + needScanner = true 132 + break 133 + } 134 + } 135 + if needScanner { 136 + outputPath := filepath.Join(rootDir, "bin", "atcr-scanner") 137 + if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil { 138 + return fmt.Errorf("build scanner: %w", err) 139 + } 140 + } 141 + 142 + // Deploy each target 143 + for _, name := range toUpdate { 144 + t := targets[name] 145 + fmt.Printf("\nDeploying %s (%s)...\n", name, t.ip) 121 146 122 147 // Sync config keys (adds missing keys from template, never overwrites) 123 148 configYAML, err := renderConfig(t.configTmpl, vals) ··· 145 170 return fmt.Errorf("%s service unit sync: %w", name, err) 146 171 } 147 172 173 + // Upload binary 174 + localPath := filepath.Join(rootDir, "bin", t.localBinary) 175 + remotePath := naming.InstallDir() + "/bin/" + t.binaryName 176 + if err := scpFile(localPath, t.ip, remotePath); err != nil { 177 + return fmt.Errorf("upload %s: %w", name, err) 178 + } 179 + 148 180 daemonReload := "" 149 181 if unitChanged { 150 182 daemonReload = "systemctl daemon-reload" 151 183 } 152 184 153 185 // Scanner additions for hold server 154 - scannerBuild := "" 155 186 scannerRestart := "" 156 187 scannerHealthCheck := "" 157 188 if name == "hold" && state.ScannerEnabled { ··· 185 216 daemonReload = "systemctl daemon-reload" 186 217 } 187 218 188 - scannerBuild = fmt.Sprintf(` 189 - # Build scanner 190 - cd %s/scanner 191 - CGO_ENABLED=1 go build \ 192 - -ldflags="-s -w" \ 193 - -trimpath \ 194 - -o ../bin/%s ./cmd/scanner 195 - cd %s 219 + // Upload scanner binary 220 + scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner") 221 + scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner() 222 + if err := scpFile(scannerLocal, t.ip, scannerRemote); err != nil { 223 + return fmt.Errorf("upload scanner: %w", err) 224 + } 196 225 197 - # Ensure scanner data dirs exist 198 - mkdir -p %s/vulndb %s/tmp 199 - chown -R %s:%s %s 200 - `, naming.InstallDir(), naming.Scanner(), naming.InstallDir(), 226 + // Ensure scanner data dirs exist on server 227 + scannerSetup := fmt.Sprintf(`mkdir -p %s/vulndb %s/tmp 228 + chown -R %s:%s %s`, 201 229 naming.ScannerDataDir(), naming.ScannerDataDir(), 202 230 naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir()) 231 + if _, err := runSSH(t.ip, scannerSetup, false); err != nil { 232 + return fmt.Errorf("scanner dir setup: %w", err) 233 + } 203 234 204 235 scannerRestart = fmt.Sprintf("\nsystemctl restart %s", naming.Scanner()) 205 - scannerHealthCheck = fmt.Sprintf(` 236 + scannerHealthCheck = ` 206 237 sleep 2 207 238 curl -sf http://localhost:9090/healthz > /dev/null && echo "SCANNER_HEALTH_OK" || echo "SCANNER_HEALTH_FAIL" 208 - `) 239 + ` 209 240 } 210 241 211 - updateScript := fmt.Sprintf(`set -euo pipefail 212 - export PATH=$PATH:/usr/local/go/bin 213 - export GOTMPDIR=/var/tmp 214 - 215 - # Update Go if needed 216 - CURRENT_GO=$(go version 2>/dev/null | grep -oP 'go\K[0-9.]+' || echo "none") 217 - REQUIRED_GO="%s" 218 - if [ "$CURRENT_GO" != "$REQUIRED_GO" ]; then 219 - echo "Updating Go: $CURRENT_GO -> $REQUIRED_GO" 220 - rm -rf /usr/local/go 221 - curl -fsSL https://go.dev/dl/go${REQUIRED_GO}.linux-amd64.tar.gz | tar -C /usr/local -xz 222 - fi 223 - 224 - cd %s 225 - git pull origin %s 226 - npm ci 227 - go generate ./... 228 - CGO_ENABLED=1 go build \ 229 - -ldflags="-s -w -linkmode external -extldflags '-static'" \ 230 - -tags sqlite_omit_load_extension -trimpath \ 231 - -o bin/%s ./cmd/%s 242 + // Restart services and health check 243 + restartScript := fmt.Sprintf(`set -euo pipefail 232 244 %s 233 - %s 234 - systemctl restart %s 235 - %s 245 + systemctl restart %s%s 236 246 sleep 2 237 247 curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL" 238 - %s 239 - `, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd, 240 - scannerBuild, daemonReload, t.serviceName, scannerRestart, 241 - t.healthURL, scannerHealthCheck) 248 + %s`, daemonReload, t.serviceName, scannerRestart, t.healthURL, scannerHealthCheck) 242 249 243 - output, err := runSSH(t.ip, updateScript, true) 250 + output, err := runSSH(t.ip, restartScript, true) 244 251 if err != nil { 245 252 fmt.Printf(" ERROR: %v\n", err) 246 253 fmt.Printf(" Output: %s\n", output) 247 - return fmt.Errorf("update %s failed", name) 254 + return fmt.Errorf("restart %s failed", name) 248 255 } 249 256 250 257 if strings.Contains(output, "HEALTH_OK") { ··· 292 299 } 293 300 } 294 301 302 + // buildLocal compiles a Go binary locally with cross-compilation flags for linux/amd64. 303 + func buildLocal(dir, outputPath, buildPkg string) error { 304 + fmt.Printf(" building %s...\n", filepath.Base(outputPath)) 305 + cmd := exec.Command("go", "build", 306 + "-ldflags=-s -w", 307 + "-trimpath", 308 + "-o", outputPath, 309 + buildPkg, 310 + ) 311 + cmd.Dir = dir 312 + cmd.Env = append(os.Environ(), 313 + "GOOS=linux", 314 + "GOARCH=amd64", 315 + "CGO_ENABLED=1", 316 + ) 317 + cmd.Stdout = os.Stdout 318 + cmd.Stderr = os.Stderr 319 + return cmd.Run() 320 + } 321 + 322 + // scpFile uploads a local file to a remote server via SCP. 323 + // Removes the remote file first to avoid ETXTBSY when overwriting a running binary. 324 + func scpFile(localPath, ip, remotePath string) error { 325 + fmt.Printf(" uploading %s → %s:%s\n", filepath.Base(localPath), ip, remotePath) 326 + _, _ = runSSH(ip, fmt.Sprintf("rm -f %s", remotePath), false) 327 + cmd := exec.Command("scp", 328 + "-o", "StrictHostKeyChecking=accept-new", 329 + "-o", "ConnectTimeout=10", 330 + localPath, 331 + "root@"+ip+":"+remotePath, 332 + ) 333 + cmd.Stdout = os.Stdout 334 + cmd.Stderr = os.Stderr 335 + return cmd.Run() 336 + } 337 + 295 338 func cmdSSH(target string) error { 296 339 state, err := loadState() 297 340 if err != nil { ··· 337 380 cmd.Stderr = &buf 338 381 } 339 382 340 - // Give builds up to 10 minutes 383 + // Give deploys up to 5 minutes (SCP + restart, much faster than remote builds) 341 384 done := make(chan error, 1) 342 385 go func() { done <- cmd.Run() }() 343 386 344 387 select { 345 388 case err := <-done: 346 389 return buf.String(), err 347 - case <-time.After(10 * time.Minute): 348 - cmd.Process.Kill() 349 - return buf.String(), fmt.Errorf("SSH command timed out after 10 minutes") 390 + case <-time.After(5 * time.Minute): 391 + _ = cmd.Process.Kill() 392 + return buf.String(), fmt.Errorf("SSH command timed out after 5 minutes") 350 393 } 351 394 }
+4 -4
pkg/auth/holdlocal/holdlocal_test.go
··· 43 43 if err != nil { 44 44 panic(err) 45 45 } 46 - err = sharedPublicPDS.Bootstrap(ctx, nil, "did:plc:owner123", true, false, "", "") 46 + err = sharedPublicPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123", Public: true}) 47 47 if err != nil { 48 48 panic(err) 49 49 } ··· 54 54 if err != nil { 55 55 panic(err) 56 56 } 57 - err = sharedPrivatePDS.Bootstrap(ctx, nil, "did:plc:owner123", false, false, "", "") 57 + err = sharedPrivatePDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123"}) 58 58 if err != nil { 59 59 panic(err) 60 60 } ··· 65 65 if err != nil { 66 66 panic(err) 67 67 } 68 - err = sharedAllowCrewPDS.Bootstrap(ctx, nil, "did:plc:owner123", false, true, "", "") 68 + err = sharedAllowCrewPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123", AllowAllCrew: true}) 69 69 if err != nil { 70 70 panic(err) 71 71 } ··· 93 93 94 94 // Bootstrap with owner if provided 95 95 if ownerDID != "" { 96 - err = holdPDS.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "") 96 + err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: public, AllowAllCrew: allowAllCrew}) 97 97 if err != nil { 98 98 t.Fatalf("Failed to bootstrap HoldPDS: %v", err) 99 99 }
+11 -6
pkg/hold/config.go
··· 56 56 // URL to fetch avatar image from during bootstrap. 57 57 ProfileAvatarURL string `yaml:"profile_avatar_url" comment:"URL to fetch avatar image from during bootstrap."` 58 58 59 + // Bluesky profile display name. Synced on every startup. 60 + ProfileDisplayName string `yaml:"profile_display_name" comment:"Bluesky profile display name. Synced on every startup."` 61 + 62 + // Bluesky profile description. Synced on every startup. 63 + ProfileDescription string `yaml:"profile_description" comment:"Bluesky profile description. Synced on every startup."` 64 + 59 65 // Post to Bluesky when users push images. 60 66 EnableBlueskyPosts bool `yaml:"enable_bluesky_posts" comment:"Post to Bluesky when users push images. Synced to captain record on startup."` 61 67 ··· 152 158 // PLC directory URL. Only used when did_method is "plc". 153 159 PLCDirectoryURL string `yaml:"plc_directory_url" comment:"PLC directory URL. Only used when did_method is 'plc'. Default: https://plc.directory"` 154 160 155 - // Rotation key path for did:plc. Separate from signing key for recovery. 156 - RotationKeyPath string `yaml:"rotation_key_path" comment:"Rotation key path for did:plc. Controls DID identity (separate from signing key). Defaults to {database.path}/rotation.key."` 161 + // Rotation key for did:plc (multibase-encoded private key, K-256 or P-256). 162 + RotationKey string `yaml:"rotation_key" comment:"Rotation key for did:plc in multibase format (starting with 'z'). Generate with: goat key generate. Supports K-256 and P-256 curves. Controls DID identity (separate from signing key)."` 157 163 158 164 // libSQL sync URL for embedded replica mode. 159 165 LibsqlSyncURL string `yaml:"libsql_sync_url" comment:"libSQL sync URL (libsql://...). Works with Turso cloud, Bunny DB, or self-hosted libsql-server. Leave empty for local-only SQLite."` ··· 184 190 v.SetDefault("registration.owner_did", "") 185 191 v.SetDefault("registration.allow_all_crew", false) 186 192 v.SetDefault("registration.profile_avatar_url", "https://atcr.io/web-app-manifest-192x192.png") 193 + v.SetDefault("registration.profile_display_name", "Cargo Hold") 194 + v.SetDefault("registration.profile_description", "ahoy from the cargo hold") 187 195 v.SetDefault("registration.enable_bluesky_posts", false) 188 196 189 197 // Database defaults ··· 192 200 v.SetDefault("database.did_method", "web") 193 201 v.SetDefault("database.did", "") 194 202 v.SetDefault("database.plc_directory_url", "https://plc.directory") 195 - v.SetDefault("database.rotation_key_path", "") 203 + v.SetDefault("database.rotation_key", "") 196 204 v.SetDefault("database.libsql_sync_url", "") 197 205 v.SetDefault("database.libsql_auth_token", "") 198 206 v.SetDefault("database.libsql_sync_interval", "60s") ··· 286 294 // Post-load: derive key paths from database path if not set 287 295 if cfg.Database.KeyPath == "" && cfg.Database.Path != "" { 288 296 cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key") 289 - } 290 - if cfg.Database.RotationKeyPath == "" && cfg.Database.Path != "" { 291 - cfg.Database.RotationKeyPath = filepath.Join(cfg.Database.Path, "rotation.key") 292 297 } 293 298 294 299 // Validate DID method
+2 -2
pkg/hold/oci/xrpc_test.go
··· 106 106 r, w, _ := os.Pipe() 107 107 os.Stdout = w 108 108 109 - err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 109 + err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: true}) 110 110 111 111 // Restore stdout 112 112 w.Close() ··· 191 191 r, w, _ := os.Pipe() 192 192 os.Stdout = w 193 193 194 - err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 194 + err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: true}) 195 195 196 196 // Restore stdout 197 197 w.Close()
+1 -1
pkg/hold/pds/captain_test.go
··· 56 56 r, w, _ := os.Pipe() 57 57 os.Stdout = w 58 58 59 - err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "") 59 + err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: public, AllowAllCrew: allowAllCrew}) 60 60 61 61 w.Close() 62 62 os.Stdout = oldStdout
+30 -20
pkg/hold/pds/did.go
··· 118 118 PublicURL string 119 119 DBPath string 120 120 SigningKeyPath string 121 - RotationKeyPath string 121 + RotationKey string // Multibase-encoded private key, K-256 or P-256 (optional) 122 122 PLCDirectoryURL string 123 123 } 124 124 ··· 166 166 return "", fmt.Errorf("failed to load signing key: %w", err) 167 167 } 168 168 169 - // Try to load rotation key (optional — may be stored offline) 170 - rotationKey, _ := loadOptionalK256Key(cfg.RotationKeyPath) 169 + // Try to parse rotation key (optional — may not be configured) 170 + rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey) 171 171 172 172 if err := EnsurePLCCurrent(ctx, did, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL); err != nil { 173 173 return "", fmt.Errorf("failed to ensure PLC identity is current: %w", err) ··· 185 185 return "", fmt.Errorf("failed to load signing key: %w", err) 186 186 } 187 187 188 - // Load or generate rotation key 189 - rotationKey, err := oauth.GenerateOrLoadPDSKey(cfg.RotationKeyPath) 190 - if err != nil { 191 - return "", fmt.Errorf("failed to load rotation key: %w", err) 188 + // Parse or generate rotation key 189 + var rotationKey atcrypto.PrivateKeyExportable 190 + if cfg.RotationKey != "" { 191 + rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey) 192 + if err != nil { 193 + return "", fmt.Errorf("failed to parse rotation_key: %w", err) 194 + } 195 + } else { 196 + // Generate a new rotation key — user must save the multibase output 197 + rawKey, genErr := atcrypto.GeneratePrivateKeyK256() 198 + if genErr != nil { 199 + return "", fmt.Errorf("failed to generate rotation key: %w", genErr) 200 + } 201 + rotationKey = rawKey 202 + slog.Warn("Generated new rotation key — save this in your config as database.rotation_key", 203 + "rotation_key", rawKey.Multibase(), 204 + ) 192 205 } 193 206 194 207 did, err = CreatePLCIdentity(ctx, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL) ··· 208 221 "did", did, 209 222 "plc_directory", cfg.PLCDirectoryURL, 210 223 ) 211 - slog.Warn("Back up rotation.key and optionally remove it from the server. It is only needed for DID updates (URL changes, key rotation).", 212 - "rotation_key_path", cfg.RotationKeyPath, 213 - ) 224 + slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).") 214 225 215 226 return did, nil 216 227 } 217 228 218 - // loadOptionalK256Key attempts to load a K-256 private key from disk. 219 - // Returns nil if the file does not exist (key stored offline). 220 - func loadOptionalK256Key(path string) (*atcrypto.PrivateKeyK256, error) { 221 - data, err := os.ReadFile(path) 222 - if err != nil { 223 - return nil, err 229 + // parseOptionalMultibaseKey parses a multibase-encoded private key string (K-256 or P-256). 230 + // Returns nil, nil if the input is empty (key not configured). 231 + func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) { 232 + if encoded == "" { 233 + return nil, nil 224 234 } 225 - key, err := atcrypto.ParsePrivateBytesK256(data) 235 + key, err := atcrypto.ParsePrivateMultibase(encoded) 226 236 if err != nil { 227 - return nil, fmt.Errorf("failed to parse K-256 key from %s: %w", path, err) 237 + return nil, fmt.Errorf("failed to parse rotation key multibase string: %w", err) 228 238 } 229 239 return key, nil 230 240 } ··· 232 242 // EnsurePLCCurrent checks the PLC directory for the given DID and updates it 233 243 // if the local signing key or public URL doesn't match what's registered. 234 244 // If rotationKey is nil, mismatches are logged as warnings but not fatal. 235 - func EnsurePLCCurrent(ctx context.Context, did string, rotationKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error { 245 + func EnsurePLCCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error { 236 246 client := &didplc.Client{DirectoryURL: plcDirectoryURL} 237 247 238 248 // Fetch current op log ··· 340 350 341 351 // CreatePLCIdentity creates a new did:plc identity by building a genesis operation, 342 352 // signing it with the rotation key, and submitting it to the PLC directory. 343 - func CreatePLCIdentity(ctx context.Context, rotationKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) { 353 + func CreatePLCIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) { 344 354 rotPub, err := rotationKey.PublicKey() 345 355 if err != nil { 346 356 return "", fmt.Errorf("failed to get rotation public key: %w", err)
+1 -1
pkg/hold/pds/layer_test.go
··· 308 308 } 309 309 310 310 // Bootstrap with owner 311 - if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { 311 + if err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil { 312 312 t.Fatalf("Failed to bootstrap PDS: %v", err) 313 313 } 314 314
+10
pkg/hold/pds/profile.go
··· 148 148 return recordCID, nil 149 149 } 150 150 151 + // UpdateProfileRecord updates the existing app.bsky.actor.profile record. 152 + // Callers should GetProfileRecord first, modify fields, then pass the updated record. 153 + func (p *HoldPDS) UpdateProfileRecord(ctx context.Context, record *bsky.ActorProfile) (cid.Cid, error) { 154 + recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, ProfileCollection, ProfileRkey, record) 155 + if err != nil { 156 + return cid.Undef, fmt.Errorf("failed to update profile record: %w", err) 157 + } 158 + return recordCID, nil 159 + } 160 + 151 161 // GetProfileRecord retrieves the app.bsky.actor.profile record 152 162 func (p *HoldPDS) GetProfileRecord(ctx context.Context) (cid.Cid, *bsky.ActorProfile, error) { 153 163 // Use repomgr.GetRecord
+72 -28
pkg/hold/pds/server.go
··· 230 230 return recordCID, recBytes, nil 231 231 } 232 232 233 + // BootstrapConfig holds all configuration needed for Bootstrap. 234 + // Defined in the pds package to avoid circular imports with the hold package. 235 + type BootstrapConfig struct { 236 + OwnerDID string // DID of the hold captain 237 + Public bool // Allow unauthenticated blob reads 238 + AllowAllCrew bool // Create wildcard crew record 239 + ProfileAvatarURL string // URL to fetch avatar image from 240 + ProfileDisplayName string // Bluesky profile display name 241 + ProfileDescription string // Bluesky profile description 242 + Region string // Deployment region 243 + } 244 + 233 245 // Bootstrap initializes the hold with the captain record, owner as first crew member, and profile 234 - func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error { 235 - if ownerDID == "" { 246 + func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, cfg BootstrapConfig) error { 247 + if cfg.OwnerDID == "" { 236 248 return nil 237 249 } 238 250 ··· 244 256 // Captain record exists, skip captain/crew setup but still create profile if needed 245 257 slog.Info("Captain record exists, skipping captain/crew setup") 246 258 } else { 247 - slog.Info("Bootstrapping hold PDS", "owner", ownerDID) 259 + slog.Info("Bootstrapping hold PDS", "owner", cfg.OwnerDID) 248 260 } 249 261 250 262 if !captainExists { ··· 263 275 } 264 276 265 277 // Create captain record (hold ownership and settings) 266 - _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts, region) 278 + _, err = p.CreateCaptainRecord(ctx, cfg.OwnerDID, cfg.Public, cfg.AllowAllCrew, p.enableBlueskyPosts, cfg.Region) 267 279 if err != nil { 268 280 return fmt.Errorf("failed to create captain record: %w", err) 269 281 } 270 282 271 283 slog.Info("Created captain record", 272 - "public", public, 273 - "allowAllCrew", allowAllCrew, 284 + "public", cfg.Public, 285 + "allowAllCrew", cfg.AllowAllCrew, 274 286 "enableBlueskyPosts", p.enableBlueskyPosts, 275 - "region", region) 287 + "region", cfg.Region) 276 288 277 289 // Add hold owner as first crew member with admin role 278 - _, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}) 290 + _, err = p.AddCrewMember(ctx, cfg.OwnerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}) 279 291 if err != nil { 280 292 return fmt.Errorf("failed to add owner as crew member: %w", err) 281 293 } 282 294 283 - slog.Info("Added owner as hold admin", "did", ownerDID) 295 + slog.Info("Added owner as hold admin", "did", cfg.OwnerDID) 284 296 } else { 285 - // Captain record exists, check if we need to sync settings from env vars 297 + // Captain record exists, check if we need to sync settings from config 286 298 _, existingCaptain, err := p.GetCaptainRecord(ctx) 287 299 if err == nil { 288 300 // Check if any settings need updating 289 - needsUpdate := existingCaptain.Public != public || 290 - existingCaptain.AllowAllCrew != allowAllCrew || 301 + needsUpdate := existingCaptain.Public != cfg.Public || 302 + existingCaptain.AllowAllCrew != cfg.AllowAllCrew || 291 303 existingCaptain.EnableBlueskyPosts != p.enableBlueskyPosts 292 304 293 305 if needsUpdate { 294 - // Update captain record to match env vars (preserves other fields like Successor) 295 - existingCaptain.Public = public 296 - existingCaptain.AllowAllCrew = allowAllCrew 306 + // Update captain record to match config (preserves other fields like Successor) 307 + existingCaptain.Public = cfg.Public 308 + existingCaptain.AllowAllCrew = cfg.AllowAllCrew 297 309 existingCaptain.EnableBlueskyPosts = p.enableBlueskyPosts 298 310 _, err = p.UpdateCaptainRecord(ctx, existingCaptain) 299 311 if err != nil { 300 312 return fmt.Errorf("failed to update captain record: %w", err) 301 313 } 302 - slog.Info("Synced captain record with env vars", 303 - "public", public, 304 - "allowAllCrew", allowAllCrew, 314 + slog.Info("Synced captain record from config", 315 + "public", cfg.Public, 316 + "allowAllCrew", cfg.AllowAllCrew, 305 317 "enableBlueskyPosts", p.enableBlueskyPosts) 306 318 } 307 319 } ··· 315 327 slog.Info("Migrated crew records to hash-based rkeys", "count", migrated) 316 328 } 317 329 318 - // Create Bluesky profile record (idempotent - check if exists first) 330 + // Create or sync Bluesky profile record from config 319 331 // This runs even if captain exists (for existing holds being upgraded) 320 332 // Skip if no S3 service (e.g., in tests) 321 333 if s3svc != nil { 322 - _, _, err = p.GetProfileRecord(ctx) 323 - if err != nil { 324 - // Bluesky profile doesn't exist, create it 325 - displayName := "Cargo Hold" 326 - description := "ahoy from the cargo hold" 327 - 328 - _, err = p.CreateProfileRecord(ctx, s3svc, displayName, description, avatarURL) 334 + _, existingProfile, profileErr := p.GetProfileRecord(ctx) 335 + if profileErr != nil { 336 + // Profile doesn't exist, create it fresh 337 + _, err = p.CreateProfileRecord(ctx, s3svc, cfg.ProfileDisplayName, cfg.ProfileDescription, cfg.ProfileAvatarURL) 329 338 if err != nil { 330 339 return fmt.Errorf("failed to create bluesky profile record: %w", err) 331 340 } 332 - slog.Info("Created Bluesky profile record", "displayName", displayName) 341 + slog.Info("Created Bluesky profile record", "displayName", cfg.ProfileDisplayName) 333 342 } else { 334 - slog.Info("Bluesky profile record already exists, skipping") 343 + // Profile exists — sync fields from config (like captain record sync above) 344 + needsUpdate := false 345 + 346 + if cfg.ProfileDisplayName != "" && (existingProfile.DisplayName == nil || *existingProfile.DisplayName != cfg.ProfileDisplayName) { 347 + existingProfile.DisplayName = &cfg.ProfileDisplayName 348 + needsUpdate = true 349 + } 350 + if cfg.ProfileDescription != "" && (existingProfile.Description == nil || *existingProfile.Description != cfg.ProfileDescription) { 351 + existingProfile.Description = &cfg.ProfileDescription 352 + needsUpdate = true 353 + } 354 + if cfg.ProfileAvatarURL != "" && existingProfile.Avatar == nil { 355 + imageData, mimeType, dlErr := downloadImage(ctx, cfg.ProfileAvatarURL) 356 + if dlErr != nil { 357 + slog.Warn("Failed to download avatar for profile update", "error", dlErr) 358 + } else { 359 + avatarBlob, uploadErr := uploadBlobToStorage(ctx, s3svc, p.did, imageData, mimeType) 360 + if uploadErr != nil { 361 + slog.Warn("Failed to upload avatar for profile update", "error", uploadErr) 362 + } else { 363 + existingProfile.Avatar = avatarBlob 364 + needsUpdate = true 365 + } 366 + } 367 + } 368 + 369 + if needsUpdate { 370 + _, err = p.UpdateProfileRecord(ctx, existingProfile) 371 + if err != nil { 372 + return fmt.Errorf("failed to update bluesky profile record: %w", err) 373 + } 374 + slog.Info("Synced Bluesky profile record from config", 375 + "displayName", cfg.ProfileDisplayName) 376 + } else { 377 + slog.Info("Bluesky profile record already matches config, skipping") 378 + } 335 379 } 336 380 } 337 381
+12 -12
pkg/hold/pds/server_test.go
··· 69 69 70 70 // Bootstrap with a captain record 71 71 ownerDID := "did:plc:owner123" 72 - if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { 72 + if err := pds1.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil { 73 73 t.Fatalf("Bootstrap failed: %v", err) 74 74 } 75 75 ··· 129 129 publicAccess := true 130 130 allowAllCrew := false 131 131 132 - err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "") 132 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: publicAccess, AllowAllCrew: allowAllCrew}) 133 133 if err != nil { 134 134 t.Fatalf("Bootstrap failed: %v", err) 135 135 } ··· 204 204 ownerDID := "did:plc:alice123" 205 205 206 206 // First bootstrap 207 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 207 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 208 208 if err != nil { 209 209 t.Fatalf("First bootstrap failed: %v", err) 210 210 } ··· 223 223 crewCount1 := len(crew1) 224 224 225 225 // Second bootstrap (should be idempotent - skip creation) 226 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 226 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 227 227 if err != nil { 228 228 t.Fatalf("Second bootstrap failed: %v", err) 229 229 } ··· 268 268 defer pds.Close() 269 269 270 270 // Bootstrap with empty owner DID (should be no-op) 271 - err = pds.Bootstrap(ctx, nil, "", true, false, "", "") 271 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{Public: true}) 272 272 if err != nil { 273 273 t.Fatalf("Bootstrap with empty owner should not error: %v", err) 274 274 } ··· 302 302 303 303 // Bootstrap to create captain record 304 304 ownerDID := "did:plc:alice123" 305 - if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { 305 + if err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil { 306 306 t.Fatalf("Bootstrap failed: %v", err) 307 307 } 308 308 ··· 355 355 publicAccess := true 356 356 allowAllCrew := false 357 357 358 - err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "") 358 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: publicAccess, AllowAllCrew: allowAllCrew}) 359 359 if err != nil { 360 360 t.Fatalf("Bootstrap failed with did:web owner: %v", err) 361 361 } ··· 414 414 415 415 // Bootstrap with did:plc owner 416 416 plcOwner := "did:plc:alice123" 417 - err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "", "") 417 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: plcOwner, Public: true}) 418 418 if err != nil { 419 419 t.Fatalf("Bootstrap failed: %v", err) 420 420 } ··· 509 509 } 510 510 511 511 // Bootstrap should create captain record 512 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 512 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 513 513 if err != nil { 514 514 t.Fatalf("Bootstrap failed: %v", err) 515 515 } ··· 584 584 585 585 // Bootstrap should be idempotent but notice missing crew 586 586 // Currently Bootstrap skips if captain exists, so crew won't be added 587 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 587 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 588 588 if err != nil { 589 589 t.Fatalf("Bootstrap failed: %v", err) 590 590 } ··· 856 856 857 857 // Bootstrap to create some records in MST (captain + crew) 858 858 ownerDID := "did:plc:testowner" 859 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 859 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 860 860 if err != nil { 861 861 t.Fatalf("Bootstrap failed: %v", err) 862 862 } ··· 921 921 defer pds.Close() 922 922 923 923 // Bootstrap to create records 924 - err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "", "") 924 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: "did:plc:testowner", Public: true}) 925 925 if err != nil { 926 926 t.Fatalf("Bootstrap failed: %v", err) 927 927 }
+1 -1
pkg/hold/pds/status_test.go
··· 277 277 278 278 // Bootstrap once 279 279 ownerDID := "did:plc:testowner123" 280 - err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "", "") 280 + err = sharedPDS.Bootstrap(sharedCtx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 281 281 if err != nil { 282 282 panic(fmt.Sprintf("Failed to bootstrap shared PDS: %v", err)) 283 283 }
+5 -5
pkg/hold/pds/xrpc_test.go
··· 56 56 r, w, _ := os.Pipe() 57 57 os.Stdout = w 58 58 59 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 59 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 60 60 61 61 // Restore stdout 62 62 w.Close() ··· 114 114 r, w, _ := os.Pipe() 115 115 os.Stdout = w 116 116 117 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 117 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 118 118 119 119 // Restore stdout 120 120 w.Close() ··· 1987 1987 r, w, _ := os.Pipe() 1988 1988 os.Stdout = w 1989 1989 1990 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 1990 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 1991 1991 1992 1992 // Restore stdout 1993 1993 w.Close() ··· 2044 2044 r, w, _ := os.Pipe() 2045 2045 os.Stdout = w 2046 2046 2047 - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") 2047 + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) 2048 2048 2049 2049 // Restore stdout 2050 2050 w.Close() ··· 2619 2619 2620 2620 // Clean up - recreate captain record if it was deleted 2621 2621 if w.Code == http.StatusOK { 2622 - handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "", "") 2622 + handler.pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: "did:plc:testowner123", Public: true}) 2623 2623 } 2624 2624 } 2625 2625
+10 -2
pkg/hold/server.go
··· 79 79 PublicURL: cfg.Server.PublicURL, 80 80 DBPath: cfg.Database.Path, 81 81 SigningKeyPath: cfg.Database.KeyPath, 82 - RotationKeyPath: cfg.Database.RotationKeyPath, 82 + RotationKey: cfg.Database.RotationKey, 83 83 PLCDirectoryURL: cfg.Database.PLCDirectoryURL, 84 84 }) 85 85 if err != nil { ··· 124 124 } 125 125 126 126 // Bootstrap PDS with captain record, hold owner as first crew member, and profile 127 - if err := s.PDS.Bootstrap(ctx, s3Service, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil { 127 + if err := s.PDS.Bootstrap(ctx, s3Service, pds.BootstrapConfig{ 128 + OwnerDID: cfg.Registration.OwnerDID, 129 + Public: cfg.Server.Public, 130 + AllowAllCrew: cfg.Registration.AllowAllCrew, 131 + ProfileAvatarURL: cfg.Registration.ProfileAvatarURL, 132 + ProfileDisplayName: cfg.Registration.ProfileDisplayName, 133 + ProfileDescription: cfg.Registration.ProfileDescription, 134 + Region: cfg.Registration.Region, 135 + }); err != nil { 128 136 return nil, fmt.Errorf("failed to bootstrap PDS: %w", err) 129 137 } 130 138