mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-24 03:04:16 +00:00
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
404 lines
18 KiB
Go
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
|
|
}
|