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

Configure Feed

Select the types of activity you want to include in your feed.

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

+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