diff --git a/CLAUDE.md b/CLAUDE.md index 1a93f04..2f8802a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,7 +232,7 @@ See `config-appview.example.yaml` and `config-hold.example.yaml` for all options **Hold DID recovery/migration (did:plc):** 1. Back up `rotation.key` and DID string (from `did.txt` or plc.directory) 2. Set `database.did_method: plc` and `database.did: "did:plc:..."` in config -3. Provide `rotation_key_path` — signing key auto-generates if missing +3. Provide `rotation_key` (multibase K-256 private key) — signing key auto-generates if missing 4. On boot: `LoadOrCreateDID()` adopts the DID, `EnsurePLCCurrent()` auto-updates PLC directory if keys/URL changed 5. Without rotation key: hold boots but logs warning about PLC mismatch diff --git a/cmd/hold/main.go b/cmd/hold/main.go index 636dfdb..d75c883 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -77,6 +77,7 @@ func init() { rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(configCmd) rootCmd.AddCommand(repoCmd) + rootCmd.AddCommand(plcCmd) } func main() { diff --git a/cmd/hold/plc.go b/cmd/hold/plc.go new file mode 100644 index 0000000..753cad4 --- /dev/null +++ b/cmd/hold/plc.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + + "atcr.io/pkg/auth/oauth" + "atcr.io/pkg/hold" + "atcr.io/pkg/hold/pds" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + didplc "github.com/did-method-plc/go-didplc" + "github.com/spf13/cobra" +) + +var plcCmd = &cobra.Command{ + Use: "plc", + Short: "PLC directory management commands", +} + +var plcConfigFile string + +var plcAddRotationKeyCmd = &cobra.Command{ + Use: "add-rotation-key ", + Short: "Add a rotation key to this hold's PLC identity", + Long: `Add an additional rotation key to the hold's did:plc document. +The key must be a multibase-encoded private key (K-256 or P-256, starting with 'z'). +The hold's configured rotation key is used to sign the PLC update. + + atcr-hold plc add-rotation-key --config config.yaml z...`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := hold.LoadConfig(plcConfigFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if cfg.Database.DIDMethod != "plc" { + return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod) + } + + ctx := context.Background() + + // Resolve the hold's DID + holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{ + DID: cfg.Database.DID, + DIDMethod: cfg.Database.DIDMethod, + PublicURL: cfg.Server.PublicURL, + DBPath: cfg.Database.Path, + SigningKeyPath: cfg.Database.KeyPath, + RotationKey: cfg.Database.RotationKey, + PLCDirectoryURL: cfg.Database.PLCDirectoryURL, + }) + if err != nil { + return fmt.Errorf("failed to resolve hold DID: %w", err) + } + + // Parse the rotation key from config (required for signing PLC updates) + if cfg.Database.RotationKey == "" { + return fmt.Errorf("database.rotation_key must be set to sign PLC updates") + } + rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey) + if err != nil { + return fmt.Errorf("failed to parse rotation_key from config: %w", err) + } + + // Parse the new key to add (K-256 or P-256) + newKey, err := atcrypto.ParsePrivateMultibase(args[0]) + if err != nil { + return fmt.Errorf("failed to parse key argument: %w", err) + } + newKeyPub, err := newKey.PublicKey() + if err != nil { + return fmt.Errorf("failed to get public key from argument: %w", err) + } + newKeyDIDKey := newKeyPub.DIDKey() + + // Load signing key for verification methods + keyPath := cfg.Database.KeyPath + if keyPath == "" { + keyPath = cfg.Database.Path + "/signing.key" + } + signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath) + if err != nil { + return fmt.Errorf("failed to load signing key: %w", err) + } + + // Fetch current PLC state + plcDirectoryURL := cfg.Database.PLCDirectoryURL + if plcDirectoryURL == "" { + plcDirectoryURL = "https://plc.directory" + } + client := &didplc.Client{DirectoryURL: plcDirectoryURL} + + opLog, err := client.OpLog(ctx, holdDID) + if err != nil { + return fmt.Errorf("failed to fetch PLC op log: %w", err) + } + if len(opLog) == 0 { + return fmt.Errorf("empty op log for %s", holdDID) + } + + lastEntry := opLog[len(opLog)-1] + lastOp := lastEntry.Regular + if lastOp == nil { + return fmt.Errorf("last PLC operation is not a regular op") + } + + // Check if key already present + for _, k := range lastOp.RotationKeys { + if k == newKeyDIDKey { + fmt.Printf("Key %s is already a rotation key for %s\n", newKeyDIDKey, holdDID) + return nil + } + } + + // Build updated rotation keys: keep existing, append new + rotationKeys := make([]string, len(lastOp.RotationKeys)) + copy(rotationKeys, lastOp.RotationKeys) + rotationKeys = append(rotationKeys, newKeyDIDKey) + + // Build update: preserve everything else from current state + sigPub, err := signingKey.PublicKey() + if err != nil { + return fmt.Errorf("failed to get signing public key: %w", err) + } + + prevCID := lastEntry.AsOperation().CID().String() + + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: rotationKeys, + VerificationMethods: map[string]string{ + "atproto": sigPub.DIDKey(), + }, + AlsoKnownAs: lastOp.AlsoKnownAs, + Services: lastOp.Services, + Prev: &prevCID, + } + + if err := op.Sign(rotationKey); err != nil { + return fmt.Errorf("failed to sign PLC update: %w", err) + } + + if err := client.Submit(ctx, holdDID, op); err != nil { + return fmt.Errorf("failed to submit PLC update: %w", err) + } + + slog.Info("Added rotation key to PLC identity", + "did", holdDID, + "new_key", newKeyDIDKey, + "total_rotation_keys", len(rotationKeys), + ) + fmt.Printf("Added rotation key %s to %s\n", newKeyDIDKey, holdDID) + return nil + }, +} + +func init() { + plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file") + + plcCmd.AddCommand(plcAddRotationKeyCmd) +} diff --git a/cmd/hold/repo.go b/cmd/hold/repo.go index 5875420..e1bf978 100644 --- a/cmd/hold/repo.go +++ b/cmd/hold/repo.go @@ -111,7 +111,7 @@ func openHoldPDS(ctx context.Context, cfg *hold.Config) (*pds.HoldPDS, func(), e PublicURL: cfg.Server.PublicURL, DBPath: cfg.Database.Path, SigningKeyPath: cfg.Database.KeyPath, - RotationKeyPath: cfg.Database.RotationKeyPath, + RotationKey: cfg.Database.RotationKey, PLCDirectoryURL: cfg.Database.PLCDirectoryURL, }) if err != nil { diff --git a/config-hold.example.yaml b/config-hold.example.yaml index 5df63c2..5ac6575 100644 --- a/config-hold.example.yaml +++ b/config-hold.example.yaml @@ -59,6 +59,10 @@ registration: allow_all_crew: false # URL to fetch avatar image from during bootstrap. profile_avatar_url: https://atcr.io/web-app-manifest-192x192.png + # Bluesky profile display name. Synced on every startup. + profile_display_name: Cargo Hold + # Bluesky profile description. Synced on every startup. + profile_description: ahoy from the cargo hold # Post to Bluesky when users push images. Synced to captain record on startup. enable_bluesky_posts: false # Deployment region, auto-detected from cloud metadata or S3 config. @@ -75,8 +79,8 @@ database: did: "" # PLC directory URL. Only used when did_method is 'plc'. Default: https://plc.directory plc_directory_url: https://plc.directory - # Rotation key path for did:plc. Controls DID identity (separate from signing key). Defaults to {database.path}/rotation.key. - rotation_key_path: "" + # 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). + rotation_key: "" # libSQL sync URL (libsql://...). Works with Turso cloud, Bunny DB, or self-hosted libsql-server. Leave empty for local-only SQLite. libsql_sync_url: "" # Auth token for libSQL sync. Required if libsql_sync_url is set. diff --git a/deploy/upcloud/cloudinit.go b/deploy/upcloud/cloudinit.go index cfef47f..16bf6bd 100644 --- a/deploy/upcloud/cloudinit.go +++ b/deploy/upcloud/cloudinit.go @@ -36,9 +36,9 @@ var cloudInitTmpl string // values like client_name, owner_did, etc. are literal in the templates. type ConfigValues struct { // S3 / Object Storage - S3Endpoint string - S3Region string - S3Bucket string + S3Endpoint string + S3Region string + S3Bucket string S3AccessKey string S3SecretKey string @@ -112,7 +112,8 @@ func renderScannerServiceUnit(p scannerServiceUnitParams) (string, error) { } // generateAppviewCloudInit generates the cloud-init user-data script for the appview server. -func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) { +// Sets up the OS, directories, config, and systemd unit. Binaries are deployed separately via SCP. +func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues) (string, error) { naming := cfg.Naming() configYAML, err := renderConfig(appviewConfigTmpl, vals) @@ -133,16 +134,12 @@ func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion st } return generateCloudInit(cloudInitParams{ - GoVersion: goVersion, BinaryName: naming.Appview(), - BuildCmd: "appview", ServiceUnit: serviceUnit, ConfigYAML: configYAML, ConfigPath: naming.AppviewConfigPath(), ServiceName: naming.Appview(), DataDir: naming.BasePath(), - RepoURL: cfg.RepoURL, - RepoBranch: cfg.RepoBranch, InstallDir: naming.InstallDir(), SystemUser: naming.SystemUser(), ConfigDir: naming.ConfigDir(), @@ -152,9 +149,9 @@ func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion st } // generateHoldCloudInit generates the cloud-init user-data script for the hold server. -// When withScanner is true, a second phase is appended that builds the scanner binary, -// creates scanner data directories, and installs a scanner systemd service. -func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string, withScanner bool) (string, error) { +// When withScanner is true, a second phase is appended that creates scanner data +// directories and installs a scanner systemd service. Binaries are deployed separately via SCP. +func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, withScanner bool) (string, error) { naming := cfg.Naming() configYAML, err := renderConfig(holdConfigTmpl, vals) @@ -175,16 +172,12 @@ func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion strin } script, err := generateCloudInit(cloudInitParams{ - GoVersion: goVersion, BinaryName: naming.Hold(), - BuildCmd: "hold", ServiceUnit: serviceUnit, ConfigYAML: configYAML, ConfigPath: naming.HoldConfigPath(), ServiceName: naming.Hold(), DataDir: naming.BasePath(), - RepoURL: cfg.RepoURL, - RepoBranch: cfg.RepoBranch, InstallDir: naming.InstallDir(), SystemUser: naming.SystemUser(), ConfigDir: naming.ConfigDir(), @@ -205,7 +198,7 @@ func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion strin return "", fmt.Errorf("scanner config: %w", err) } - // Append scanner build and setup phase + // Append scanner setup phase (no build — binary deployed via SCP) scannerUnit, err := renderScannerServiceUnit(scannerServiceUnitParams{ DisplayName: naming.DisplayName(), User: naming.SystemUser(), @@ -225,13 +218,6 @@ func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion strin scannerPhase := fmt.Sprintf(` # === Scanner Setup === -echo "Building scanner..." -cd %s/scanner -CGO_ENABLED=1 go build \ - -ldflags="-s -w" \ - -trimpath \ - -o ../bin/%s ./cmd/scanner -cd %s # Scanner data dirs mkdir -p %s/vulndb %s/tmp @@ -251,9 +237,6 @@ systemctl enable %s echo "=== Scanner setup complete ===" `, - naming.InstallDir(), - naming.Scanner(), - naming.InstallDir(), naming.ScannerDataDir(), naming.ScannerDataDir(), naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir(), naming.ScannerConfigPath(), @@ -267,16 +250,12 @@ echo "=== Scanner setup complete ===" } type cloudInitParams struct { - GoVersion string BinaryName string - BuildCmd string ServiceUnit string ConfigYAML string ConfigPath string ServiceName string DataDir string - RepoURL string - RepoBranch string InstallDir string SystemUser string ConfigDir string diff --git a/deploy/upcloud/configs/cloudinit.sh.tmpl b/deploy/upcloud/configs/cloudinit.sh.tmpl index e519fa1..4b571db 100644 --- a/deploy/upcloud/configs/cloudinit.sh.tmpl +++ b/deploy/upcloud/configs/cloudinit.sh.tmpl @@ -19,32 +19,15 @@ export DEBIAN_FRONTEND=noninteractive apt-get update && apt-get upgrade -y apt-get install -y git gcc make curl libsqlite3-dev nodejs npm htop -# Swap (for builds on small instances) +# Swap (for small instances) if [ ! -f /swapfile ]; then dd if=/dev/zero of=/swapfile bs=1M count=2048 chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile echo '/swapfile none swap sw 0 0' >> /etc/fstab fi -# Go {{.GoVersion}} -curl -fsSL https://go.dev/dl/go{{.GoVersion}}.linux-amd64.tar.gz | tar -C /usr/local -xz -echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh -export PATH=$PATH:/usr/local/go/bin -export GOTMPDIR=/var/tmp - -# Clone & build -if [ -d {{.InstallDir}} ]; then - cd {{.InstallDir}} && git pull origin {{.RepoBranch}} -else - git clone -b {{.RepoBranch}} {{.RepoURL}} {{.InstallDir}} - cd {{.InstallDir}} -fi -npm ci -go generate ./... -CGO_ENABLED=1 go build \ - -ldflags="-s -w" \ - -trimpath \ - -o bin/{{.BinaryName}} ./cmd/{{.BuildCmd}} +# Install directory (binaries deployed via SCP) +mkdir -p {{.InstallDir}}/bin # Service user & data dirs useradd --system --no-create-home --shell /usr/sbin/nologin {{.SystemUser}} || true @@ -68,4 +51,3 @@ systemctl daemon-reload systemctl enable {{.ServiceName}} echo "=== Setup complete at $(date -u) ===" -echo "Edit {{.ConfigPath}} then: systemctl start {{.ServiceName}}" diff --git a/deploy/upcloud/configs/hold.yaml.tmpl b/deploy/upcloud/configs/hold.yaml.tmpl index 8ebe5cb..24d3c0a 100644 --- a/deploy/upcloud/configs/hold.yaml.tmpl +++ b/deploy/upcloud/configs/hold.yaml.tmpl @@ -27,6 +27,8 @@ registration: owner_did: "did:plc:pddp4xt5lgnv2qsegbzzs4xg" allow_all_crew: true profile_avatar_url: https://{{.HoldDomain}}/web-app-manifest-192x192.png + profile_display_name: Cargo Hold + profile_description: ahoy from the cargo hold enable_bluesky_posts: false region: "" database: @@ -35,7 +37,7 @@ database: did_method: web did: "" plc_directory_url: https://plc.directory - rotation_key_path: "" + rotation_key: "" libsql_sync_url: "" libsql_auth_token: "" libsql_sync_interval: 1m0s diff --git a/deploy/upcloud/deploy b/deploy/upcloud/deploy deleted file mode 100755 index 591c929..0000000 Binary files a/deploy/upcloud/deploy and /dev/null differ diff --git a/deploy/upcloud/goversion.go b/deploy/upcloud/goversion.go index 4e160ba..503632e 100644 --- a/deploy/upcloud/goversion.go +++ b/deploy/upcloud/goversion.go @@ -1,35 +1,13 @@ package main import ( - "fmt" - "os" "path/filepath" "runtime" - "strings" ) -// requiredGoVersion reads the Go version from the root go.mod file. -// Returns a version string like "1.25.7" for use in download URLs. -func requiredGoVersion() (string, error) { +// projectRoot returns the absolute path to the repository root, +// derived from the compile-time source file location. +func projectRoot() string { _, thisFile, _, _ := runtime.Caller(0) - rootMod := filepath.Join(filepath.Dir(thisFile), "..", "..", "go.mod") - - data, err := os.ReadFile(rootMod) - if err != nil { - return "", fmt.Errorf("read root go.mod: %w", err) - } - - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "go ") { - version := strings.TrimPrefix(line, "go ") - version = strings.TrimSpace(version) - // Validate it looks like a version - if len(version) > 0 && version[0] >= '1' && version[0] <= '9' { - return version, nil - } - } - } - - return "", fmt.Errorf("no 'go X.Y.Z' directive found in %s", rootMod) + return filepath.Join(filepath.Dir(thisFile), "..", "..") } diff --git a/deploy/upcloud/provision.go b/deploy/upcloud/provision.go index 716c861..d33a0a1 100644 --- a/deploy/upcloud/provision.go +++ b/deploy/upcloud/provision.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "fmt" "os" + "path/filepath" "strings" "time" @@ -38,7 +39,7 @@ func init() { provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)") provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)") provisionCmd.Flags().Bool("with-scanner", false, "Deploy vulnerability scanner alongside hold") - provisionCmd.MarkFlagRequired("ssh-key") + _ = provisionCmd.MarkFlagRequired("ssh-key") rootCmd.AddCommand(provisionCmd) } @@ -94,16 +95,10 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo state.ScannerSecret = secret fmt.Printf("Generated scanner shared secret\n") } - saveState(state) - } - - goVersion, err := requiredGoVersion() - if err != nil { - return err + _ = saveState(state) } fmt.Printf("Provisioning %s infrastructure in zone %s...\n", naming.DisplayName(), cfg.Zone) - fmt.Printf("Go version: %s (from go.mod)\n", goVersion) if needsServers { fmt.Printf("Server plan: %s\n", cfg.Plan) } @@ -130,7 +125,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo if discovered.AccessKeyID != "" { state.ObjectStorage.AccessKeyID = discovered.AccessKeyID } - saveState(state) + _ = saveState(state) } } else { fmt.Println("Creating object storage...") @@ -140,7 +135,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo } state.ObjectStorage = objState s3SecretKey = secretKey - saveState(state) + _ = saveState(state) fmt.Printf(" S3 Secret Key: %s\n", secretKey) } @@ -189,7 +184,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo return fmt.Errorf("create network: %w", err) } state.Network = StateRef{UUID: network.UUID} - saveState(state) + _ = saveState(state) fmt.Printf(" Network: %s (%s)\n", network.UUID, privateNetworkCIDR) } @@ -200,9 +195,10 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo } // 3. Appview server + appviewCreated := false if state.Appview.UUID != "" { fmt.Printf("Appview: %s (exists)\n", state.Appview.UUID) - appviewScript, err := generateAppviewCloudInit(cfg, vals, goVersion) + appviewScript, err := generateAppviewCloudInit(cfg, vals) if err != nil { return err } @@ -218,7 +214,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo } } else { fmt.Println("Creating appview server...") - appviewUserData, err := generateAppviewCloudInit(cfg, vals, goVersion) + appviewUserData, err := generateAppviewCloudInit(cfg, vals) if err != nil { return err } @@ -227,14 +223,16 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo return fmt.Errorf("create appview: %w", err) } state.Appview = *appview - saveState(state) + _ = saveState(state) + appviewCreated = true fmt.Printf(" Appview: %s (public: %s, private: %s)\n", appview.UUID, appview.PublicIP, appview.PrivateIP) } // 4. Hold server + holdCreated := false if state.Hold.UUID != "" { fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID) - holdScript, err := generateHoldCloudInit(cfg, vals, goVersion, state.ScannerEnabled) + holdScript, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled) if err != nil { return err } @@ -259,7 +257,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo } } else { fmt.Println("Creating hold server...") - holdUserData, err := generateHoldCloudInit(cfg, vals, goVersion, state.ScannerEnabled) + holdUserData, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled) if err != nil { return err } @@ -268,7 +266,8 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo return fmt.Errorf("create hold: %w", err) } state.Hold = *hold - saveState(state) + _ = saveState(state) + holdCreated = true fmt.Printf(" Hold: %s (public: %s, private: %s)\n", hold.UUID, hold.PublicIP, hold.PrivateIP) } @@ -296,7 +295,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo return fmt.Errorf("create LB: %w", err) } state.LB = StateRef{UUID: lb.UUID} - saveState(state) + _ = saveState(state) } // Always reconcile forwarded headers rule (handles existing LBs) @@ -325,6 +324,66 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo } } + // 7. Build locally and deploy binaries to new servers + if appviewCreated || holdCreated { + rootDir := projectRoot() + + fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...") + if appviewCreated { + outputPath := filepath.Join(rootDir, "bin", "atcr-appview") + if err := buildLocal(rootDir, outputPath, "./cmd/appview"); err != nil { + return fmt.Errorf("build appview: %w", err) + } + } + if holdCreated { + outputPath := filepath.Join(rootDir, "bin", "atcr-hold") + if err := buildLocal(rootDir, outputPath, "./cmd/hold"); err != nil { + return fmt.Errorf("build hold: %w", err) + } + if state.ScannerEnabled { + outputPath := filepath.Join(rootDir, "bin", "atcr-scanner") + if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil { + return fmt.Errorf("build scanner: %w", err) + } + } + } + + fmt.Println("\nWaiting for cloud-init to complete on new servers...") + if appviewCreated { + if err := waitForSetup(state.Appview.PublicIP, "appview"); err != nil { + return err + } + } + if holdCreated { + if err := waitForSetup(state.Hold.PublicIP, "hold"); err != nil { + return err + } + } + + fmt.Println("\nDeploying binaries...") + if appviewCreated { + localPath := filepath.Join(rootDir, "bin", "atcr-appview") + remotePath := naming.InstallDir() + "/bin/" + naming.Appview() + if err := scpFile(localPath, state.Appview.PublicIP, remotePath); err != nil { + return fmt.Errorf("upload appview: %w", err) + } + } + if holdCreated { + localPath := filepath.Join(rootDir, "bin", "atcr-hold") + remotePath := naming.InstallDir() + "/bin/" + naming.Hold() + if err := scpFile(localPath, state.Hold.PublicIP, remotePath); err != nil { + return fmt.Errorf("upload hold: %w", err) + } + if state.ScannerEnabled { + scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner") + scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner() + if err := scpFile(scannerLocal, state.Hold.PublicIP, scannerRemote); err != nil { + return fmt.Errorf("upload scanner: %w", err) + } + } + } + } + fmt.Println("\n=== Provisioning Complete ===") fmt.Println() fmt.Println("DNS records needed:") @@ -343,13 +402,17 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo fmt.Printf(" ssh root@%s # hold\n", state.Hold.PublicIP) fmt.Println() fmt.Println("Next steps:") - fmt.Println(" 1. Wait ~5 min for cloud-init to complete") - if state.ScannerEnabled { - fmt.Printf(" 2. systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner()) + if appviewCreated || holdCreated { + fmt.Println(" 1. Edit configs if needed, then start services:") } else { - fmt.Printf(" 2. systemctl start %s / %s\n", naming.Appview(), naming.Hold()) + fmt.Println(" 1. Start services:") } - fmt.Println(" 3. Configure DNS records above") + if state.ScannerEnabled { + fmt.Printf(" systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner()) + } else { + fmt.Printf(" systemctl start %s / %s\n", naming.Appview(), naming.Hold()) + } + fmt.Println(" 2. Configure DNS records above") return nil } @@ -984,3 +1047,27 @@ func writeRemoteCloudInit(ip, script string) error { _, err := runSSH(ip, cmd, false) return err } + +// waitForSetup polls SSH availability on a newly created server, then waits +// for cloud-init to complete before returning. +func waitForSetup(ip, name string) error { + fmt.Printf(" %s (%s): waiting for SSH...\n", name, ip) + for i := 0; i < 30; i++ { + _, err := runSSH(ip, "echo ssh_ready", false) + if err == nil { + break + } + if i == 29 { + return fmt.Errorf("SSH not available after 5 minutes on %s (%s)", name, ip) + } + time.Sleep(10 * time.Second) + } + + fmt.Printf(" %s: waiting for cloud-init...\n", name) + _, err := runSSH(ip, "cloud-init status --wait 2>/dev/null || true", false) + if err != nil { + return fmt.Errorf("cloud-init wait on %s: %w", name, err) + } + fmt.Printf(" %s: ready\n", name) + return nil +} diff --git a/deploy/upcloud/state.go b/deploy/upcloud/state.go index 3b39a27..27428f4 100644 --- a/deploy/upcloud/state.go +++ b/deploy/upcloud/state.go @@ -10,12 +10,12 @@ import ( // InfraState persists infrastructure resource UUIDs between commands. type InfraState struct { - Zone string `json:"zone"` - ClientName string `json:"client_name,omitempty"` - RepoBranch string `json:"repo_branch,omitempty"` - Network StateRef `json:"network"` - Appview ServerState `json:"appview"` - Hold ServerState `json:"hold"` + Zone string `json:"zone"` + ClientName string `json:"client_name,omitempty"` + RepoBranch string `json:"repo_branch,omitempty"` + Network StateRef `json:"network"` + Appview ServerState `json:"appview"` + Hold ServerState `json:"hold"` LB StateRef `json:"loadbalancer"` ObjectStorage ObjectStorageState `json:"object_storage"` ScannerEnabled bool `json:"scanner_enabled,omitempty"` diff --git a/deploy/upcloud/teardown.go b/deploy/upcloud/teardown.go index bfe5ef7..7339602 100644 --- a/deploy/upcloud/teardown.go +++ b/deploy/upcloud/teardown.go @@ -87,7 +87,7 @@ func cmdTeardown(token string) error { if err != nil { fmt.Printf(" Warning (stop): %v\n", err) } else { - svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ + _, _ = svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ UUID: s.uuid, DesiredState: "stopped", }) diff --git a/deploy/upcloud/update.go b/deploy/upcloud/update.go index 14d6c9c..0a957e7 100644 --- a/deploy/upcloud/update.go +++ b/deploy/upcloud/update.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" "time" @@ -50,12 +51,7 @@ func cmdUpdate(target string, withScanner bool) error { } naming := state.Naming() - branch := state.Branch() - - goVersion, err := requiredGoVersion() - if err != nil { - return err - } + rootDir := projectRoot() // Enable scanner retroactively via --with-scanner on update if withScanner && !state.ScannerEnabled { @@ -68,7 +64,7 @@ func cmdUpdate(target string, withScanner bool) error { state.ScannerSecret = secret fmt.Printf("Generated scanner shared secret\n") } - saveState(state) + _ = saveState(state) } vals := configValsFromState(state) @@ -77,6 +73,7 @@ func cmdUpdate(target string, withScanner bool) error { ip string binaryName string buildCmd string + localBinary string serviceName string healthURL string configTmpl string @@ -87,6 +84,7 @@ func cmdUpdate(target string, withScanner bool) error { ip: state.Appview.PublicIP, binaryName: naming.Appview(), buildCmd: "appview", + localBinary: "atcr-appview", serviceName: naming.Appview(), healthURL: "http://localhost:5000/health", configTmpl: appviewConfigTmpl, @@ -97,6 +95,7 @@ func cmdUpdate(target string, withScanner bool) error { ip: state.Hold.PublicIP, binaryName: naming.Hold(), buildCmd: "hold", + localBinary: "atcr-hold", serviceName: naming.Hold(), healthURL: "http://localhost:8080/xrpc/_health", configTmpl: holdConfigTmpl, @@ -115,9 +114,35 @@ func cmdUpdate(target string, withScanner bool) error { return fmt.Errorf("unknown target: %s (use: all, appview, hold)", target) } + // Build all binaries locally before touching servers + fmt.Println("Building locally (GOOS=linux GOARCH=amd64)...") for _, name := range toUpdate { t := targets[name] - fmt.Printf("Updating %s (%s)...\n", name, t.ip) + outputPath := filepath.Join(rootDir, "bin", t.localBinary) + if err := buildLocal(rootDir, outputPath, "./cmd/"+t.buildCmd); err != nil { + return fmt.Errorf("build %s: %w", name, err) + } + } + + // Build scanner locally if needed + needScanner := false + for _, name := range toUpdate { + if name == "hold" && state.ScannerEnabled { + needScanner = true + break + } + } + if needScanner { + outputPath := filepath.Join(rootDir, "bin", "atcr-scanner") + if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil { + return fmt.Errorf("build scanner: %w", err) + } + } + + // Deploy each target + for _, name := range toUpdate { + t := targets[name] + fmt.Printf("\nDeploying %s (%s)...\n", name, t.ip) // Sync config keys (adds missing keys from template, never overwrites) configYAML, err := renderConfig(t.configTmpl, vals) @@ -145,13 +170,19 @@ func cmdUpdate(target string, withScanner bool) error { return fmt.Errorf("%s service unit sync: %w", name, err) } + // Upload binary + localPath := filepath.Join(rootDir, "bin", t.localBinary) + remotePath := naming.InstallDir() + "/bin/" + t.binaryName + if err := scpFile(localPath, t.ip, remotePath); err != nil { + return fmt.Errorf("upload %s: %w", name, err) + } + daemonReload := "" if unitChanged { daemonReload = "systemctl daemon-reload" } // Scanner additions for hold server - scannerBuild := "" scannerRestart := "" scannerHealthCheck := "" if name == "hold" && state.ScannerEnabled { @@ -185,66 +216,42 @@ func cmdUpdate(target string, withScanner bool) error { daemonReload = "systemctl daemon-reload" } - scannerBuild = fmt.Sprintf(` -# Build scanner -cd %s/scanner -CGO_ENABLED=1 go build \ - -ldflags="-s -w" \ - -trimpath \ - -o ../bin/%s ./cmd/scanner -cd %s + // Upload scanner binary + scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner") + scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner() + if err := scpFile(scannerLocal, t.ip, scannerRemote); err != nil { + return fmt.Errorf("upload scanner: %w", err) + } -# Ensure scanner data dirs exist -mkdir -p %s/vulndb %s/tmp -chown -R %s:%s %s -`, naming.InstallDir(), naming.Scanner(), naming.InstallDir(), + // Ensure scanner data dirs exist on server + scannerSetup := fmt.Sprintf(`mkdir -p %s/vulndb %s/tmp +chown -R %s:%s %s`, naming.ScannerDataDir(), naming.ScannerDataDir(), naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir()) + if _, err := runSSH(t.ip, scannerSetup, false); err != nil { + return fmt.Errorf("scanner dir setup: %w", err) + } scannerRestart = fmt.Sprintf("\nsystemctl restart %s", naming.Scanner()) - scannerHealthCheck = fmt.Sprintf(` + scannerHealthCheck = ` sleep 2 curl -sf http://localhost:9090/healthz > /dev/null && echo "SCANNER_HEALTH_OK" || echo "SCANNER_HEALTH_FAIL" -`) +` } - updateScript := fmt.Sprintf(`set -euo pipefail -export PATH=$PATH:/usr/local/go/bin -export GOTMPDIR=/var/tmp - -# Update Go if needed -CURRENT_GO=$(go version 2>/dev/null | grep -oP 'go\K[0-9.]+' || echo "none") -REQUIRED_GO="%s" -if [ "$CURRENT_GO" != "$REQUIRED_GO" ]; then - echo "Updating Go: $CURRENT_GO -> $REQUIRED_GO" - rm -rf /usr/local/go - curl -fsSL https://go.dev/dl/go${REQUIRED_GO}.linux-amd64.tar.gz | tar -C /usr/local -xz -fi - -cd %s -git pull origin %s -npm ci -go generate ./... -CGO_ENABLED=1 go build \ - -ldflags="-s -w -linkmode external -extldflags '-static'" \ - -tags sqlite_omit_load_extension -trimpath \ - -o bin/%s ./cmd/%s -%s -%s -systemctl restart %s + // Restart services and health check + restartScript := fmt.Sprintf(`set -euo pipefail %s +systemctl restart %s%s sleep 2 curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL" -%s -`, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd, - scannerBuild, daemonReload, t.serviceName, scannerRestart, - t.healthURL, scannerHealthCheck) +%s`, daemonReload, t.serviceName, scannerRestart, t.healthURL, scannerHealthCheck) - output, err := runSSH(t.ip, updateScript, true) + output, err := runSSH(t.ip, restartScript, true) if err != nil { fmt.Printf(" ERROR: %v\n", err) fmt.Printf(" Output: %s\n", output) - return fmt.Errorf("update %s failed", name) + return fmt.Errorf("restart %s failed", name) } if strings.Contains(output, "HEALTH_OK") { @@ -292,6 +299,42 @@ func configValsFromState(state *InfraState) *ConfigValues { } } +// buildLocal compiles a Go binary locally with cross-compilation flags for linux/amd64. +func buildLocal(dir, outputPath, buildPkg string) error { + fmt.Printf(" building %s...\n", filepath.Base(outputPath)) + cmd := exec.Command("go", "build", + "-ldflags=-s -w", + "-trimpath", + "-o", outputPath, + buildPkg, + ) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GOOS=linux", + "GOARCH=amd64", + "CGO_ENABLED=1", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// scpFile uploads a local file to a remote server via SCP. +// Removes the remote file first to avoid ETXTBSY when overwriting a running binary. +func scpFile(localPath, ip, remotePath string) error { + fmt.Printf(" uploading %s → %s:%s\n", filepath.Base(localPath), ip, remotePath) + _, _ = runSSH(ip, fmt.Sprintf("rm -f %s", remotePath), false) + cmd := exec.Command("scp", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=10", + localPath, + "root@"+ip+":"+remotePath, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + func cmdSSH(target string) error { state, err := loadState() if err != nil { @@ -337,15 +380,15 @@ func runSSH(ip, script string, stream bool) (string, error) { cmd.Stderr = &buf } - // Give builds up to 10 minutes + // Give deploys up to 5 minutes (SCP + restart, much faster than remote builds) done := make(chan error, 1) go func() { done <- cmd.Run() }() select { case err := <-done: return buf.String(), err - case <-time.After(10 * time.Minute): - cmd.Process.Kill() - return buf.String(), fmt.Errorf("SSH command timed out after 10 minutes") + case <-time.After(5 * time.Minute): + _ = cmd.Process.Kill() + return buf.String(), fmt.Errorf("SSH command timed out after 5 minutes") } } diff --git a/pkg/auth/holdlocal/holdlocal_test.go b/pkg/auth/holdlocal/holdlocal_test.go index 78bdcce..3ec4a99 100644 --- a/pkg/auth/holdlocal/holdlocal_test.go +++ b/pkg/auth/holdlocal/holdlocal_test.go @@ -43,7 +43,7 @@ func TestMain(m *testing.M) { if err != nil { panic(err) } - err = sharedPublicPDS.Bootstrap(ctx, nil, "did:plc:owner123", true, false, "", "") + err = sharedPublicPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123", Public: true}) if err != nil { panic(err) } @@ -54,7 +54,7 @@ func TestMain(m *testing.M) { if err != nil { panic(err) } - err = sharedPrivatePDS.Bootstrap(ctx, nil, "did:plc:owner123", false, false, "", "") + err = sharedPrivatePDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123"}) if err != nil { panic(err) } @@ -65,7 +65,7 @@ func TestMain(m *testing.M) { if err != nil { panic(err) } - err = sharedAllowCrewPDS.Bootstrap(ctx, nil, "did:plc:owner123", false, true, "", "") + err = sharedAllowCrewPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: "did:plc:owner123", AllowAllCrew: true}) if err != nil { panic(err) } @@ -93,7 +93,7 @@ func createTestHoldPDS(t *testing.T, ownerDID string, public bool, allowAllCrew // Bootstrap with owner if provided if ownerDID != "" { - err = holdPDS.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "") + err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: public, AllowAllCrew: allowAllCrew}) if err != nil { t.Fatalf("Failed to bootstrap HoldPDS: %v", err) } diff --git a/pkg/hold/config.go b/pkg/hold/config.go index b20ebdd..04eaf33 100644 --- a/pkg/hold/config.go +++ b/pkg/hold/config.go @@ -56,6 +56,12 @@ type RegistrationConfig struct { // URL to fetch avatar image from during bootstrap. ProfileAvatarURL string `yaml:"profile_avatar_url" comment:"URL to fetch avatar image from during bootstrap."` + // Bluesky profile display name. Synced on every startup. + ProfileDisplayName string `yaml:"profile_display_name" comment:"Bluesky profile display name. Synced on every startup."` + + // Bluesky profile description. Synced on every startup. + ProfileDescription string `yaml:"profile_description" comment:"Bluesky profile description. Synced on every startup."` + // Post to Bluesky when users push images. EnableBlueskyPosts bool `yaml:"enable_bluesky_posts" comment:"Post to Bluesky when users push images. Synced to captain record on startup."` @@ -152,8 +158,8 @@ type DatabaseConfig struct { // PLC directory URL. Only used when did_method is "plc". PLCDirectoryURL string `yaml:"plc_directory_url" comment:"PLC directory URL. Only used when did_method is 'plc'. Default: https://plc.directory"` - // Rotation key path for did:plc. Separate from signing key for recovery. - 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."` + // Rotation key for did:plc (multibase-encoded private key, K-256 or P-256). + 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)."` // libSQL sync URL for embedded replica mode. 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,6 +190,8 @@ func setHoldDefaults(v *viper.Viper) { v.SetDefault("registration.owner_did", "") v.SetDefault("registration.allow_all_crew", false) v.SetDefault("registration.profile_avatar_url", "https://atcr.io/web-app-manifest-192x192.png") + v.SetDefault("registration.profile_display_name", "Cargo Hold") + v.SetDefault("registration.profile_description", "ahoy from the cargo hold") v.SetDefault("registration.enable_bluesky_posts", false) // Database defaults @@ -192,7 +200,7 @@ func setHoldDefaults(v *viper.Viper) { v.SetDefault("database.did_method", "web") v.SetDefault("database.did", "") v.SetDefault("database.plc_directory_url", "https://plc.directory") - v.SetDefault("database.rotation_key_path", "") + v.SetDefault("database.rotation_key", "") v.SetDefault("database.libsql_sync_url", "") v.SetDefault("database.libsql_auth_token", "") v.SetDefault("database.libsql_sync_interval", "60s") @@ -287,9 +295,6 @@ func LoadConfig(yamlPath string) (*Config, error) { if cfg.Database.KeyPath == "" && cfg.Database.Path != "" { cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key") } - if cfg.Database.RotationKeyPath == "" && cfg.Database.Path != "" { - cfg.Database.RotationKeyPath = filepath.Join(cfg.Database.Path, "rotation.key") - } // Validate DID method if cfg.Database.DIDMethod != "" && cfg.Database.DIDMethod != "web" && cfg.Database.DIDMethod != "plc" { diff --git a/pkg/hold/oci/xrpc_test.go b/pkg/hold/oci/xrpc_test.go index 6ba7f33..9035508 100644 --- a/pkg/hold/oci/xrpc_test.go +++ b/pkg/hold/oci/xrpc_test.go @@ -106,7 +106,7 @@ func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client r, w, _ := os.Pipe() os.Stdout = w - err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: true}) // Restore stdout w.Close() @@ -191,7 +191,7 @@ func setupTestOCIHandlerWithS3(t *testing.T) (*XRPCHandler, bool) { r, w, _ := os.Pipe() os.Stdout = w - err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = holdPDS.Bootstrap(ctx, nil, pds.BootstrapConfig{OwnerDID: ownerDID, Public: true}) // Restore stdout w.Close() diff --git a/pkg/hold/pds/captain_test.go b/pkg/hold/pds/captain_test.go index 99bd916..78696aa 100644 --- a/pkg/hold/pds/captain_test.go +++ b/pkg/hold/pds/captain_test.go @@ -56,7 +56,7 @@ func setupTestPDSWithBootstrap(t *testing.T, ownerDID string, public, allowAllCr r, w, _ := os.Pipe() os.Stdout = w - err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "") + err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: public, AllowAllCrew: allowAllCrew}) w.Close() os.Stdout = oldStdout diff --git a/pkg/hold/pds/did.go b/pkg/hold/pds/did.go index e539f6e..13f98dc 100644 --- a/pkg/hold/pds/did.go +++ b/pkg/hold/pds/did.go @@ -118,7 +118,7 @@ type DIDConfig struct { PublicURL string DBPath string SigningKeyPath string - RotationKeyPath string + RotationKey string // Multibase-encoded private key, K-256 or P-256 (optional) PLCDirectoryURL string } @@ -166,8 +166,8 @@ func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) { return "", fmt.Errorf("failed to load signing key: %w", err) } - // Try to load rotation key (optional — may be stored offline) - rotationKey, _ := loadOptionalK256Key(cfg.RotationKeyPath) + // Try to parse rotation key (optional — may not be configured) + rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey) if err := EnsurePLCCurrent(ctx, did, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL); err != nil { return "", fmt.Errorf("failed to ensure PLC identity is current: %w", err) @@ -185,10 +185,23 @@ func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) { return "", fmt.Errorf("failed to load signing key: %w", err) } - // Load or generate rotation key - rotationKey, err := oauth.GenerateOrLoadPDSKey(cfg.RotationKeyPath) - if err != nil { - return "", fmt.Errorf("failed to load rotation key: %w", err) + // Parse or generate rotation key + var rotationKey atcrypto.PrivateKeyExportable + if cfg.RotationKey != "" { + rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey) + if err != nil { + return "", fmt.Errorf("failed to parse rotation_key: %w", err) + } + } else { + // Generate a new rotation key — user must save the multibase output + rawKey, genErr := atcrypto.GeneratePrivateKeyK256() + if genErr != nil { + return "", fmt.Errorf("failed to generate rotation key: %w", genErr) + } + rotationKey = rawKey + slog.Warn("Generated new rotation key — save this in your config as database.rotation_key", + "rotation_key", rawKey.Multibase(), + ) } did, err = CreatePLCIdentity(ctx, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL) @@ -208,23 +221,20 @@ func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) { "did", did, "plc_directory", cfg.PLCDirectoryURL, ) - slog.Warn("Back up rotation.key and optionally remove it from the server. It is only needed for DID updates (URL changes, key rotation).", - "rotation_key_path", cfg.RotationKeyPath, - ) + slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).") return did, nil } -// loadOptionalK256Key attempts to load a K-256 private key from disk. -// Returns nil if the file does not exist (key stored offline). -func loadOptionalK256Key(path string) (*atcrypto.PrivateKeyK256, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err +// parseOptionalMultibaseKey parses a multibase-encoded private key string (K-256 or P-256). +// Returns nil, nil if the input is empty (key not configured). +func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) { + if encoded == "" { + return nil, nil } - key, err := atcrypto.ParsePrivateBytesK256(data) + key, err := atcrypto.ParsePrivateMultibase(encoded) if err != nil { - return nil, fmt.Errorf("failed to parse K-256 key from %s: %w", path, err) + return nil, fmt.Errorf("failed to parse rotation key multibase string: %w", err) } return key, nil } @@ -232,7 +242,7 @@ func loadOptionalK256Key(path string) (*atcrypto.PrivateKeyK256, error) { // EnsurePLCCurrent checks the PLC directory for the given DID and updates it // if the local signing key or public URL doesn't match what's registered. // If rotationKey is nil, mismatches are logged as warnings but not fatal. -func EnsurePLCCurrent(ctx context.Context, did string, rotationKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error { +func EnsurePLCCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error { client := &didplc.Client{DirectoryURL: plcDirectoryURL} // Fetch current op log @@ -340,7 +350,7 @@ func EnsurePLCCurrent(ctx context.Context, did string, rotationKey, signingKey * // CreatePLCIdentity creates a new did:plc identity by building a genesis operation, // signing it with the rotation key, and submitting it to the PLC directory. -func CreatePLCIdentity(ctx context.Context, rotationKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) { +func CreatePLCIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) { rotPub, err := rotationKey.PublicKey() if err != nil { return "", fmt.Errorf("failed to get rotation public key: %w", err) diff --git a/pkg/hold/pds/layer_test.go b/pkg/hold/pds/layer_test.go index f0d6c79..1ce1b50 100644 --- a/pkg/hold/pds/layer_test.go +++ b/pkg/hold/pds/layer_test.go @@ -308,7 +308,7 @@ func setupTestPDSWithIndex(t *testing.T, ownerDID string) *HoldPDS { } // Bootstrap with owner - if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { + if err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } diff --git a/pkg/hold/pds/profile.go b/pkg/hold/pds/profile.go index aada7f1..db69d66 100644 --- a/pkg/hold/pds/profile.go +++ b/pkg/hold/pds/profile.go @@ -148,6 +148,16 @@ func (p *HoldPDS) CreateProfileRecord(ctx context.Context, s3svc *s3.S3Service, return recordCID, nil } +// UpdateProfileRecord updates the existing app.bsky.actor.profile record. +// Callers should GetProfileRecord first, modify fields, then pass the updated record. +func (p *HoldPDS) UpdateProfileRecord(ctx context.Context, record *bsky.ActorProfile) (cid.Cid, error) { + recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, ProfileCollection, ProfileRkey, record) + if err != nil { + return cid.Undef, fmt.Errorf("failed to update profile record: %w", err) + } + return recordCID, nil +} + // GetProfileRecord retrieves the app.bsky.actor.profile record func (p *HoldPDS) GetProfileRecord(ctx context.Context) (cid.Cid, *bsky.ActorProfile, error) { // Use repomgr.GetRecord diff --git a/pkg/hold/pds/server.go b/pkg/hold/pds/server.go index 6b4c543..64945b7 100644 --- a/pkg/hold/pds/server.go +++ b/pkg/hold/pds/server.go @@ -230,9 +230,21 @@ func (p *HoldPDS) GetRecordBytes(ctx context.Context, recordPath string) (cid.Ci return recordCID, recBytes, nil } +// BootstrapConfig holds all configuration needed for Bootstrap. +// Defined in the pds package to avoid circular imports with the hold package. +type BootstrapConfig struct { + OwnerDID string // DID of the hold captain + Public bool // Allow unauthenticated blob reads + AllowAllCrew bool // Create wildcard crew record + ProfileAvatarURL string // URL to fetch avatar image from + ProfileDisplayName string // Bluesky profile display name + ProfileDescription string // Bluesky profile description + Region string // Deployment region +} + // Bootstrap initializes the hold with the captain record, owner as first crew member, and profile -func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error { - if ownerDID == "" { +func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, cfg BootstrapConfig) error { + if cfg.OwnerDID == "" { return nil } @@ -244,7 +256,7 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID s // Captain record exists, skip captain/crew setup but still create profile if needed slog.Info("Captain record exists, skipping captain/crew setup") } else { - slog.Info("Bootstrapping hold PDS", "owner", ownerDID) + slog.Info("Bootstrapping hold PDS", "owner", cfg.OwnerDID) } if !captainExists { @@ -263,45 +275,45 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID s } // Create captain record (hold ownership and settings) - _, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts, region) + _, err = p.CreateCaptainRecord(ctx, cfg.OwnerDID, cfg.Public, cfg.AllowAllCrew, p.enableBlueskyPosts, cfg.Region) if err != nil { return fmt.Errorf("failed to create captain record: %w", err) } slog.Info("Created captain record", - "public", public, - "allowAllCrew", allowAllCrew, + "public", cfg.Public, + "allowAllCrew", cfg.AllowAllCrew, "enableBlueskyPosts", p.enableBlueskyPosts, - "region", region) + "region", cfg.Region) // Add hold owner as first crew member with admin role - _, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}) + _, err = p.AddCrewMember(ctx, cfg.OwnerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}) if err != nil { return fmt.Errorf("failed to add owner as crew member: %w", err) } - slog.Info("Added owner as hold admin", "did", ownerDID) + slog.Info("Added owner as hold admin", "did", cfg.OwnerDID) } else { - // Captain record exists, check if we need to sync settings from env vars + // Captain record exists, check if we need to sync settings from config _, existingCaptain, err := p.GetCaptainRecord(ctx) if err == nil { // Check if any settings need updating - needsUpdate := existingCaptain.Public != public || - existingCaptain.AllowAllCrew != allowAllCrew || + needsUpdate := existingCaptain.Public != cfg.Public || + existingCaptain.AllowAllCrew != cfg.AllowAllCrew || existingCaptain.EnableBlueskyPosts != p.enableBlueskyPosts if needsUpdate { - // Update captain record to match env vars (preserves other fields like Successor) - existingCaptain.Public = public - existingCaptain.AllowAllCrew = allowAllCrew + // Update captain record to match config (preserves other fields like Successor) + existingCaptain.Public = cfg.Public + existingCaptain.AllowAllCrew = cfg.AllowAllCrew existingCaptain.EnableBlueskyPosts = p.enableBlueskyPosts _, err = p.UpdateCaptainRecord(ctx, existingCaptain) if err != nil { return fmt.Errorf("failed to update captain record: %w", err) } - slog.Info("Synced captain record with env vars", - "public", public, - "allowAllCrew", allowAllCrew, + slog.Info("Synced captain record from config", + "public", cfg.Public, + "allowAllCrew", cfg.AllowAllCrew, "enableBlueskyPosts", p.enableBlueskyPosts) } } @@ -315,23 +327,55 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID s slog.Info("Migrated crew records to hash-based rkeys", "count", migrated) } - // Create Bluesky profile record (idempotent - check if exists first) + // Create or sync Bluesky profile record from config // This runs even if captain exists (for existing holds being upgraded) // Skip if no S3 service (e.g., in tests) if s3svc != nil { - _, _, err = p.GetProfileRecord(ctx) - if err != nil { - // Bluesky profile doesn't exist, create it - displayName := "Cargo Hold" - description := "ahoy from the cargo hold" - - _, err = p.CreateProfileRecord(ctx, s3svc, displayName, description, avatarURL) + _, existingProfile, profileErr := p.GetProfileRecord(ctx) + if profileErr != nil { + // Profile doesn't exist, create it fresh + _, err = p.CreateProfileRecord(ctx, s3svc, cfg.ProfileDisplayName, cfg.ProfileDescription, cfg.ProfileAvatarURL) if err != nil { return fmt.Errorf("failed to create bluesky profile record: %w", err) } - slog.Info("Created Bluesky profile record", "displayName", displayName) + slog.Info("Created Bluesky profile record", "displayName", cfg.ProfileDisplayName) } else { - slog.Info("Bluesky profile record already exists, skipping") + // Profile exists — sync fields from config (like captain record sync above) + needsUpdate := false + + if cfg.ProfileDisplayName != "" && (existingProfile.DisplayName == nil || *existingProfile.DisplayName != cfg.ProfileDisplayName) { + existingProfile.DisplayName = &cfg.ProfileDisplayName + needsUpdate = true + } + if cfg.ProfileDescription != "" && (existingProfile.Description == nil || *existingProfile.Description != cfg.ProfileDescription) { + existingProfile.Description = &cfg.ProfileDescription + needsUpdate = true + } + if cfg.ProfileAvatarURL != "" && existingProfile.Avatar == nil { + imageData, mimeType, dlErr := downloadImage(ctx, cfg.ProfileAvatarURL) + if dlErr != nil { + slog.Warn("Failed to download avatar for profile update", "error", dlErr) + } else { + avatarBlob, uploadErr := uploadBlobToStorage(ctx, s3svc, p.did, imageData, mimeType) + if uploadErr != nil { + slog.Warn("Failed to upload avatar for profile update", "error", uploadErr) + } else { + existingProfile.Avatar = avatarBlob + needsUpdate = true + } + } + } + + if needsUpdate { + _, err = p.UpdateProfileRecord(ctx, existingProfile) + if err != nil { + return fmt.Errorf("failed to update bluesky profile record: %w", err) + } + slog.Info("Synced Bluesky profile record from config", + "displayName", cfg.ProfileDisplayName) + } else { + slog.Info("Bluesky profile record already matches config, skipping") + } } } diff --git a/pkg/hold/pds/server_test.go b/pkg/hold/pds/server_test.go index 593204b..e74552e 100644 --- a/pkg/hold/pds/server_test.go +++ b/pkg/hold/pds/server_test.go @@ -69,7 +69,7 @@ func TestNewHoldPDS_ExistingRepo(t *testing.T) { // Bootstrap with a captain record ownerDID := "did:plc:owner123" - if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { + if err := pds1.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -129,7 +129,7 @@ func TestBootstrap_NewRepo(t *testing.T) { publicAccess := true allowAllCrew := false - err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: publicAccess, AllowAllCrew: allowAllCrew}) if err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -204,7 +204,7 @@ func TestBootstrap_Idempotent(t *testing.T) { ownerDID := "did:plc:alice123" // First bootstrap - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) if err != nil { t.Fatalf("First bootstrap failed: %v", err) } @@ -223,7 +223,7 @@ func TestBootstrap_Idempotent(t *testing.T) { crewCount1 := len(crew1) // Second bootstrap (should be idempotent - skip creation) - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) if err != nil { t.Fatalf("Second bootstrap failed: %v", err) } @@ -268,7 +268,7 @@ func TestBootstrap_EmptyOwner(t *testing.T) { defer pds.Close() // Bootstrap with empty owner DID (should be no-op) - err = pds.Bootstrap(ctx, nil, "", true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{Public: true}) if err != nil { t.Fatalf("Bootstrap with empty owner should not error: %v", err) } @@ -302,7 +302,7 @@ func TestLexiconTypeRegistration(t *testing.T) { // Bootstrap to create captain record ownerDID := "did:plc:alice123" - if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil { + if err := pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}); err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -355,7 +355,7 @@ func TestBootstrap_DidWebOwner(t *testing.T) { publicAccess := true allowAllCrew := false - err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: publicAccess, AllowAllCrew: allowAllCrew}) if err != nil { t.Fatalf("Bootstrap failed with did:web owner: %v", err) } @@ -414,7 +414,7 @@ func TestBootstrap_MixedDIDs(t *testing.T) { // Bootstrap with did:plc owner plcOwner := "did:plc:alice123" - err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: plcOwner, Public: true}) if err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -509,7 +509,7 @@ func TestBootstrap_CrewWithoutCaptain(t *testing.T) { } // Bootstrap should create captain record - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) if err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -584,7 +584,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) { // Bootstrap should be idempotent but notice missing crew // Currently Bootstrap skips if captain exists, so crew won't be added - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) if err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -856,7 +856,7 @@ func TestHoldPDS_BackfillRecordsIndex(t *testing.T) { // Bootstrap to create some records in MST (captain + crew) ownerDID := "did:plc:testowner" - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) if err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -921,7 +921,7 @@ func TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced(t *testing.T) { defer pds.Close() // Bootstrap to create records - err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: "did:plc:testowner", Public: true}) if err != nil { t.Fatalf("Bootstrap failed: %v", err) } diff --git a/pkg/hold/pds/status_test.go b/pkg/hold/pds/status_test.go index 7c0e9be..ecaa4de 100644 --- a/pkg/hold/pds/status_test.go +++ b/pkg/hold/pds/status_test.go @@ -277,7 +277,7 @@ func TestMain(m *testing.M) { // Bootstrap once ownerDID := "did:plc:testowner123" - err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "", "") + err = sharedPDS.Bootstrap(sharedCtx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) if err != nil { panic(fmt.Sprintf("Failed to bootstrap shared PDS: %v", err)) } diff --git a/pkg/hold/pds/xrpc_test.go b/pkg/hold/pds/xrpc_test.go index 819e270..6da962b 100644 --- a/pkg/hold/pds/xrpc_test.go +++ b/pkg/hold/pds/xrpc_test.go @@ -56,7 +56,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) { r, w, _ := os.Pipe() os.Stdout = w - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) // Restore stdout w.Close() @@ -114,7 +114,7 @@ func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context) r, w, _ := os.Pipe() os.Stdout = w - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) // Restore stdout w.Close() @@ -1987,7 +1987,7 @@ func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Clien r, w, _ := os.Pipe() os.Stdout = w - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) // Restore stdout w.Close() @@ -2044,7 +2044,7 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *s3.MockS3Client r, w, _ := os.Pipe() os.Stdout = w - err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "") + err = pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: ownerDID, Public: true}) // Restore stdout w.Close() @@ -2619,7 +2619,7 @@ func TestRequireOwnerOrCrewAdmin_Authorized(t *testing.T) { // Clean up - recreate captain record if it was deleted if w.Code == http.StatusOK { - handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "", "") + handler.pds.Bootstrap(ctx, nil, BootstrapConfig{OwnerDID: "did:plc:testowner123", Public: true}) } } diff --git a/pkg/hold/server.go b/pkg/hold/server.go index 4c0559c..848f404 100644 --- a/pkg/hold/server.go +++ b/pkg/hold/server.go @@ -79,7 +79,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) { PublicURL: cfg.Server.PublicURL, DBPath: cfg.Database.Path, SigningKeyPath: cfg.Database.KeyPath, - RotationKeyPath: cfg.Database.RotationKeyPath, + RotationKey: cfg.Database.RotationKey, PLCDirectoryURL: cfg.Database.PLCDirectoryURL, }) if err != nil { @@ -124,7 +124,15 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) { } // Bootstrap PDS with captain record, hold owner as first crew member, and profile - if err := s.PDS.Bootstrap(ctx, s3Service, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil { + if err := s.PDS.Bootstrap(ctx, s3Service, pds.BootstrapConfig{ + OwnerDID: cfg.Registration.OwnerDID, + Public: cfg.Server.Public, + AllowAllCrew: cfg.Registration.AllowAllCrew, + ProfileAvatarURL: cfg.Registration.ProfileAvatarURL, + ProfileDisplayName: cfg.Registration.ProfileDisplayName, + ProfileDescription: cfg.Registration.ProfileDescription, + Region: cfg.Registration.Region, + }); err != nil { return nil, fmt.Errorf("failed to bootstrap PDS: %w", err) }