Files
at-container-registry/pkg/hold/config.go
T
Evan JarrettandClaude Fable 5.1 0080957a21 remove the runtime test_mode switch; the testmode build tag is the only one
server.test_mode survived the build-tag refactor only to feed five
behavioral branches: the registry's fall-back to the default hold when
the user's hold is unreachable, backfill warning suppression for
external holds, the appview listener close on shutdown, the hold's
relay-crawl skip, and the hold's appview-issuer tolerance. Every one of
them is a "this is a local development build" decision, which is what
the tag already says, and local development has to build with the tag
or nothing resolves. So they read atproto.TestModeBuild now, and the
flag, SetTestMode, IsTestMode, the middleware option, the backfill
constructor parameter, the never-read field on RemoteHoldAuthorizer,
the example and template YAML lines, and the docker-compose env vars
are gone. The registry keeps the fallback as a field seeded from the
constant so the production-path tests can pin it off under the tag.

The 24 SetTestMode calls in tests were dead already: stripping them and
running the affected packages tagged changed nothing.

Tests that resolve a loopback did:web used to t.Fatal naming the tag,
which left a bare `go test ./...` permanently red in five packages.
They now live under `//go:build testmode`: whole-file constraints where
every test needs it, and sibling *_testmode_test.go files holding the
moved tests plus their fixtures where a file mixed. The harness carries
the constraint too, with its package doc in an untagged doc.go so the
package still exists without it. An untagged run compiles those tests
out and passes; make test keeps the tag and runs everything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 11:09:44 -05:00

404 lines
18 KiB
Go

// Package hold implements the ATCR hold service, which provides BYOS
// (Bring Your Own Storage) functionality. It includes an embedded PDS for
// storing captain and crew records, generates presigned URLs for blob storage,
// and handles authorization based on crew membership. Configuration is loaded
// via Viper with YAML file support and environment variable overrides.
package hold
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/spf13/viper"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/config"
"atcr.io/pkg/hold/gc"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/hold/quota"
)
// URLFromDIDWeb converts a did:web identifier to an HTTPS URL.
// This is the inverse of the did:web spec encoding:
//
// "did:web:atcr.io" → "https://atcr.io"
// "did:web:localhost%3A8080" → "https://localhost:8080"
//
// Returns empty string for non-did:web identifiers.
func URLFromDIDWeb(did string) string {
if !strings.HasPrefix(did, "did:web:") {
return ""
}
host := strings.TrimPrefix(did, "did:web:")
// Per did:web spec, %3A encodes port colon
host = strings.ReplaceAll(host, "%3A", ":")
return "https://" + host
}
// Config represents the hold service configuration
type Config struct {
Version string `yaml:"version" comment:"Configuration format version."`
LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."`
LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."`
Storage StorageConfig `yaml:"storage" comment:"S3-compatible blob storage settings."`
Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."`
Registration RegistrationConfig `yaml:"registration" comment:"Auto-registration and bootstrap settings."`
Database DatabaseConfig `yaml:"database" comment:"Embedded PDS database settings."`
Admin AdminConfig `yaml:"admin" comment:"Admin panel settings."`
GC gc.Config `yaml:"gc" comment:"Garbage collection settings."`
Quota quota.Config `yaml:"quota" comment:"Storage quota tiers. Empty disables quota enforcement."`
Scanner ScannerConfig `yaml:"scanner" comment:"Vulnerability scanner settings. Empty disables scanning."`
Labeler LabelerConfig `yaml:"labeler" comment:"Labeler subscription settings. When configured, the hold consumes takedown labels from the named labeler and purges affected records on receipt; GC consults the cache to gate blob cleanup. Empty subscribe_url disables."`
configPath string `yaml:"-"` // internal: path to YAML file for subsystem config loading
}
// ConfigPath returns the path to the YAML configuration file used to load this config.
// Subsystems (e.g. billing) use this to re-read the same file for extended fields.
func (c *Config) ConfigPath() string { return c.configPath }
// DIDConfig builds the did.Config used to load or create the hold's identity.
// The verification key fragment and service set are hold-specific (atproto PDS +
// AtcrHoldService), so they're filled in here rather than at every callsite.
func (c *Config) DIDConfig() did.Config {
return did.Config{
DID: c.Database.DID,
Method: c.Database.DIDMethod,
PublicURL: c.Server.PublicURL,
DBPath: c.Database.Path,
SigningKeyPath: c.Database.KeyPath,
RotationKey: c.Database.RotationKey,
PLCDirectoryURL: c.Database.PLCDirectoryURL,
VerificationKeyName: "atproto",
Services: pds.HoldServices(c.Server.PublicURL),
}
}
// AdminConfig defines admin panel settings
type AdminConfig struct {
// Enable the web-based admin panel.
Enabled bool `yaml:"enabled" comment:"Enable the web-based admin panel for crew and storage management."`
}
// RegistrationConfig defines auto-registration settings
type RegistrationConfig struct {
// DID of the hold captain.
OwnerDID string `yaml:"owner_did" comment:"DID of the hold captain. If set, auto-creates captain and profile records on startup."`
// Allow any authenticated user to join as crew.
AllowAllCrew bool `yaml:"allow_all_crew" comment:"Create a wildcard crew record allowing any authenticated user to join."`
// 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."`
// Deployment region, auto-detected from cloud metadata or S3 config.
Region string `yaml:"region" comment:"Deployment region, auto-detected from cloud metadata or S3 config."`
}
// StorageConfig holds S3 storage credentials.
type StorageConfig struct {
// S3-compatible access key.
AccessKey string `yaml:"access_key" comment:"S3-compatible access key (AWS, Storj, Minio, UpCloud)."`
// S3-compatible secret key.
SecretKey string `yaml:"secret_key" comment:"S3-compatible secret key."`
// S3 region.
Region string `yaml:"region" comment:"S3 region, e.g. \"us-east-1\". Used for request signing."`
// S3 bucket name.
Bucket string `yaml:"bucket" comment:"S3 bucket for blob storage (REQUIRED). Must already exist."`
// Custom S3 endpoint for non-AWS providers.
Endpoint string `yaml:"endpoint" comment:"Custom S3 endpoint for non-AWS providers (e.g. \"https://gateway.storjshare.io\")."`
// CDN pull zone URL for presigned download URLs.
PullZone string `yaml:"pull_zone" comment:"CDN pull zone URL for downloads. When set, presigned GET/HEAD URLs use this host instead of the S3 endpoint. Uploads and API calls still use the S3 endpoint."`
}
// S3Params returns a params map suitable for s3.NewS3Service.
func (s StorageConfig) S3Params() map[string]any {
params := map[string]any{
"accesskey": s.AccessKey,
"secretkey": s.SecretKey,
"region": s.Region,
"bucket": s.Bucket,
}
if s.Endpoint != "" {
params["regionendpoint"] = s.Endpoint
params["forcepathstyle"] = true
}
if s.PullZone != "" {
params["pullzone"] = s.PullZone
}
return params
}
// ServerConfig defines server settings
type ServerConfig struct {
// Listen address for the HTTP server.
Addr string `yaml:"addr" comment:"Listen address, e.g. \":8080\" or \"0.0.0.0:8080\"."`
// Externally reachable URL used for did:web identity.
PublicURL string `yaml:"public_url" comment:"Externally reachable URL used for did:web identity (REQUIRED), e.g. \"https://hold.example.com\"."`
// Allow unauthenticated blob reads.
Public bool `yaml:"public" comment:"Allow unauthenticated blob reads. If false, readers need crew membership."`
// DID of successor hold for migration.
Successor string `yaml:"successor" comment:"DID of successor hold for migration. Appview redirects all requests to the successor."`
// Relay endpoints used primarily for proactive scan discovery via
// com.atproto.sync.listReposByCollection. Endpoints listed here MUST
// support listReposByCollection. They are also sent requestCrawl on
// startup (in addition to the built-in known-relay list); endpoints that
// don't implement requestCrawl just 404 silently.
RelayEndpoints []string `yaml:"relay_endpoints" comment:"Endpoints used for proactive scan discovery. MUST support com.atproto.sync.listReposByCollection. Also sent requestCrawl on startup (best-effort, in addition to built-in known relays)."`
// DID of the appview this hold is managed by. Resolved via did:web for URL and public key discovery.
AppviewDID string `yaml:"appview_did" comment:"DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key."`
// ReadTimeout for HTTP requests.
ReadTimeout time.Duration `yaml:"read_timeout" comment:"Read timeout for HTTP requests."`
// WriteTimeout for HTTP requests.
WriteTimeout time.Duration `yaml:"write_timeout" comment:"Write timeout for HTTP requests."`
}
// AppviewURL derives the appview base URL from AppviewDID.
func (s ServerConfig) AppviewURL() string {
return URLFromDIDWeb(s.AppviewDID)
}
// LabelerConfig defines labeler subscription settings.
//
// When DID is set, the hold opens a websocket to the labeler's
// com.atproto.label.subscribeLabels endpoint and only honors labels whose
// Src matches the same DID. Active takedowns are cached locally with their
// Cts timestamp; on receipt of a !takedown label the hold immediately purges
// layer/scan/image-config records for the labeled manifest (or all manifests
// by the labeled DID for user-level takedowns). Negations drop the cache
// entry. The GC consults the cache when computing referenced sets so blobs
// survive a configurable grace window before being collected, preserving
// reversibility.
type LabelerConfig struct {
// DID of the labeler service (did:plc:... or did:web:...). The HTTP
// endpoint is resolved at runtime via the labeler's #atproto_labeler
// service entry in its DID document. Empty disables labeler integration.
DID string `yaml:"did" comment:"DID of the ATProto labeler (did:plc:... or did:web:...). Empty disables labeler integration."`
// Grace window for reversibility. Until a takedown is older than this,
// the GC keeps blobs referenced even though their layer records were
// purged. After this window blobs become eligible for collection.
GraceWindow time.Duration `yaml:"grace_window" comment:"Reversibility window for takedowns. Blobs survive this long after a takedown so the action can be reversed. After this window the GC reclaims them. Default: 720h (30 days)."`
}
// ScannerConfig defines vulnerability scanner settings
type ScannerConfig struct {
// Shared secret for scanner WebSocket authentication. Empty disables scanning.
Secret string `yaml:"secret" comment:"Shared secret for scanner WebSocket auth. Empty disables scanning."`
// Minimum interval between re-scans of the same manifest. 0 disables proactive scanning.
RescanInterval time.Duration `yaml:"rescan_interval" comment:"Minimum interval between re-scans of the same manifest. When set, the hold proactively scans manifests when the scanner is idle. Default: 168h (7 days). Set to 0 to disable."`
}
// DatabaseConfig defines embedded PDS database settings
type DatabaseConfig struct {
// Directory for the embedded PDS database.
Path string `yaml:"path" comment:"Directory for the embedded PDS database (carstore + SQLite)."`
// PDS signing key path.
KeyPath string `yaml:"key_path" comment:"PDS signing key path. Defaults to {database.path}/signing.key."`
// DID method for hold identity: "web" (default) or "plc".
DIDMethod string `yaml:"did_method" comment:"DID method: 'web' (default, derived from public_url) or 'plc' (registered with PLC directory)."`
// Explicit DID for this hold. Used for recovery/migration with did:plc.
DID string `yaml:"did" comment:"Explicit DID for this hold. If set with did_method 'plc', adopts this identity instead of creating new. Use for recovery/migration."`
// 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 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."`
// Auth token for libSQL sync.
LibsqlAuthToken string `yaml:"libsql_auth_token" comment:"Auth token for libSQL sync. Required if libsql_sync_url is set."`
// How often to sync with remote libSQL server.
LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"How often to sync with remote libSQL server. Default: 60s."`
}
// setHoldDefaults registers all default values on the given Viper instance.
func setHoldDefaults(v *viper.Viper) {
v.SetDefault("version", "0.1")
v.SetDefault("log_level", "info")
// Server defaults
v.SetDefault("server.addr", ":8080")
v.SetDefault("server.public_url", "")
v.SetDefault("server.public", false)
v.SetDefault("server.successor", "")
v.SetDefault("server.relay_endpoints", []string{
"https://relay1.us-east.bsky.network",
"https://relay1.us-west.bsky.network",
})
v.SetDefault("server.appview_did", "did:web:atcr.io")
v.SetDefault("server.read_timeout", "5m")
v.SetDefault("server.write_timeout", "5m")
// Registration defaults
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
v.SetDefault("database.path", "/var/lib/atcr-hold")
v.SetDefault("database.key_path", "")
v.SetDefault("database.did_method", "web")
v.SetDefault("database.did", "")
v.SetDefault("database.plc_directory_url", "https://plc.directory")
v.SetDefault("database.rotation_key", "")
v.SetDefault("database.libsql_sync_url", "")
v.SetDefault("database.libsql_auth_token", "")
v.SetDefault("database.libsql_sync_interval", "60s")
// Admin defaults
v.SetDefault("admin.enabled", true)
// Storage defaults
v.SetDefault("storage.access_key", "")
v.SetDefault("storage.secret_key", "")
v.SetDefault("storage.region", "us-east-1")
v.SetDefault("storage.bucket", "")
v.SetDefault("storage.endpoint", "")
v.SetDefault("storage.pull_zone", "")
// GC defaults
v.SetDefault("gc.enabled", false)
// Scanner defaults
v.SetDefault("scanner.secret", "")
v.SetDefault("scanner.rescan_interval", "168h") // 7 days
// Labeler defaults
v.SetDefault("labeler.did", "")
v.SetDefault("labeler.grace_window", "720h") // 30 days
// Log shipper defaults
v.SetDefault("log_shipper.batch_size", 100)
v.SetDefault("log_shipper.flush_interval", "5s")
}
// DefaultConfig returns a Config populated with all default values (no validation).
func DefaultConfig() *Config {
v := config.NewViper("HOLD", "")
setHoldDefaults(v)
cfg := &Config{}
_ = v.Unmarshal(cfg, config.UnmarshalOption())
return cfg
}
// ExampleYAML returns a fully-commented YAML configuration with default values.
// Includes example quota tiers for documentation (defaults have quotas disabled).
func ExampleYAML() ([]byte, error) {
cfg := DefaultConfig()
// Populate example quota tiers so operators see the structure
cfg.Quota = quota.Config{
Tiers: []quota.TierConfig{
{Name: "deckhand", Quota: "5GB"},
{Name: "bosun", Quota: "50GB", ScanOnPush: true},
{Name: "quartermaster", Quota: "100GB", ScanOnPush: true},
},
Defaults: quota.DefaultsConfig{
NewCrewTier: "deckhand",
},
}
return config.MarshalCommentedYAML("ATCR Hold Service Configuration", cfg)
}
// LoadConfig builds a complete configuration using Viper layered loading:
// defaults -> YAML file -> environment variables.
// yamlPath is optional; empty string means env-only (backward compatible).
func LoadConfig(yamlPath string) (*Config, error) {
v := config.NewViper("HOLD", yamlPath)
// Set defaults
setHoldDefaults(v)
// Bind standard AWS/S3 env vars to storage fields
_ = v.BindEnv("storage.access_key", "AWS_ACCESS_KEY_ID")
_ = v.BindEnv("storage.secret_key", "AWS_SECRET_ACCESS_KEY")
_ = v.BindEnv("storage.region", "AWS_REGION")
_ = v.BindEnv("storage.bucket", "S3_BUCKET")
_ = v.BindEnv("storage.endpoint", "S3_ENDPOINT")
_ = v.BindEnv("storage.pull_zone", "S3_PULL_ZONE")
// Bind legacy GC env vars (backward compat)
_ = v.BindEnv("gc.enabled", "GC_ENABLED")
// Unmarshal into config struct
cfg := &Config{}
if err := v.Unmarshal(cfg, config.UnmarshalOption()); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Validation
if cfg.Server.PublicURL == "" {
return nil, fmt.Errorf("server.public_url is required (env: HOLD_SERVER_PUBLIC_URL)")
}
if cfg.Storage.Bucket == "" {
return nil, fmt.Errorf("storage.bucket is required (env: S3_BUCKET) - S3 is the only supported storage backend")
}
// Post-load: derive key paths from database path if not set
if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
}
// Validate DID method
if cfg.Database.DIDMethod != "" && cfg.Database.DIDMethod != "web" && cfg.Database.DIDMethod != "plc" {
return nil, fmt.Errorf("database.did_method must be 'web' or 'plc', got %q", cfg.Database.DIDMethod)
}
// Validate labeler DID
if cfg.Labeler.DID != "" && !strings.HasPrefix(cfg.Labeler.DID, "did:") {
return nil, fmt.Errorf("labeler.did must be a DID (did:plc:... or did:web:...), got %q", cfg.Labeler.DID)
}
// Store config path for subsystem config loading (e.g. billing)
cfg.configPath = yamlPath
// Detect region from cloud metadata or S3 config
if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil {
cfg.Registration.Region = meta.Region
slog.Info("Detected cloud metadata", "region", meta.Region)
} else {
cfg.Registration.Region = cfg.Storage.Region
slog.Info("Using S3 region", "region", cfg.Registration.Region)
}
return cfg, nil
}