From 1ac8af74d5fa68089cbfcf0418d86920e99635c3 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 3 May 2026 20:49:56 -0500 Subject: [PATCH] fix labeler deployment --- config-labeler.example.yaml | 14 +- deploy/upcloud/cloudinit.go | 38 ++- deploy/upcloud/config.go | 6 +- deploy/upcloud/configs/labeler.yaml.tmpl | 8 +- deploy/upcloud/provision.go | 323 ++++++++++++++++++----- deploy/upcloud/update.go | 2 + pkg/labeler/config.go | 77 ++---- pkg/labeler/config_test.go | 23 +- pkg/labeler/handlers.go | 4 +- pkg/labeler/identity.go | 2 +- pkg/labeler/takedown.go | 6 +- 11 files changed, 324 insertions(+), 179 deletions(-) diff --git a/config-labeler.example.yaml b/config-labeler.example.yaml index f3eec66..4b5e224 100644 --- a/config-labeler.example.yaml +++ b/config-labeler.example.yaml @@ -11,8 +11,12 @@ labeler: enabled: true # Listen address for labeler (e.g., :5002). addr: :5002 - # Externally reachable labeler URL. Empty = derive from server.base_url. - public_url: "" + # Externally reachable labeler URL (required, e.g. https://labeler.example.com). + public_url: https://labeler.example.com + # OAuth client display name (e.g., "ATCR Labeler"). + client_name: ATCR Labeler + # Short brand label used in UI copy (e.g., "ATCR"). + client_short_name: ATCR # DID of the labeler admin. Only this DID can log into the admin panel. owner_did: did:plc:your-did-here # Directory for labeler state (database, signing key, did.txt). @@ -33,12 +37,6 @@ labeler: libsql_auth_token: "" # Embedded-replica pull interval (e.g. 30s). 0 = manual sync only. libsql_sync_interval: 0s -# AppView server settings (shared config). -server: - base_url: https://atcr.io - client_name: AT Container Registry - client_short_name: ATCR - test_mode: false # Remote log shipping settings. log_shipper: # Log shipping backend: "victoria", "opensearch", or "loki". Empty disables shipping. diff --git a/deploy/upcloud/cloudinit.go b/deploy/upcloud/cloudinit.go index bf3e4f6..8cc0953 100644 --- a/deploy/upcloud/cloudinit.go +++ b/deploy/upcloud/cloudinit.go @@ -49,10 +49,11 @@ type ConfigValues struct { S3SecretKey string // Infrastructure (computed from zone + config) - Zone string // e.g. "us-chi1" - HoldDomain string // e.g. "us-chi1.cove.seamark.dev" - HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev" - BasePath string // e.g. "/var/lib/seamark" + Zone string // e.g. "us-chi1" + HoldDomain string // e.g. "us-chi1.cove.seamark.dev" + HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev" + LabelerDomain string // e.g. "labeler.seamark.dev" + BasePath string // e.g. "/var/lib/seamark" // Scanner (auto-generated shared secret) ScannerSecret string // hex-encoded 32-byte secret; empty disables scanning @@ -373,8 +374,10 @@ func generateCloudInit(p cloudInitParams) (string, error) { } // syncServiceUnit compares a rendered systemd service unit against what's on -// the server. If they differ, it writes the new unit file. Returns true if the -// unit was updated (caller should daemon-reload before restart). +// the server. If they differ, it writes the new unit file. If the unit is +// missing entirely, it installs it and runs `systemctl enable` so the service +// starts on boot. Returns true if the unit was created or updated (caller +// should daemon-reload before restart). func syncServiceUnit(name, ip, serviceName, renderedUnit string) (bool, error) { unitPath := "/etc/systemd/system/" + serviceName + ".service" @@ -387,8 +390,15 @@ func syncServiceUnit(name, ip, serviceName, renderedUnit string) (bool, error) { rendered := strings.TrimSpace(renderedUnit) if remote == "__MISSING__" { - fmt.Printf(" service unit: %s not found (cloud-init will handle it)\n", name) - return false, nil + // First-time install: write file, daemon-reload, and enable so the + // service comes up on boot. The caller's restart will start it. + script := fmt.Sprintf("cat > %s << 'SVCEOF'\n%s\nSVCEOF\nsystemctl daemon-reload\nsystemctl enable %s", + unitPath, rendered, serviceName) + if _, err := runSSH(ip, script, false); err != nil { + return false, fmt.Errorf("install service unit: %w", err) + } + fmt.Printf(" service unit: %s installed and enabled\n", name) + return true, nil } if remote == rendered { @@ -416,7 +426,17 @@ func syncConfigKeys(name, ip, configPath, templateYAML string) error { remote = strings.TrimSpace(remote) if remote == "__MISSING__" { - fmt.Printf(" config sync: %s not yet created (cloud-init will handle it)\n", name) + // First-time install: write the rendered template as-is. Subsequent + // runs use the merge-keys path below to preserve operator edits. + dir := configPath[:strings.LastIndex(configPath, "/")] + if _, err := runSSH(ip, fmt.Sprintf("mkdir -p %s", dir), false); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + script := fmt.Sprintf("cat > %s << 'CFGEOF'\n%s\nCFGEOF", configPath, strings.TrimRight(templateYAML, "\n")) + if _, err := runSSH(ip, script, false); err != nil { + return fmt.Errorf("write initial config: %w", err) + } + fmt.Printf(" config sync: %s installed\n", name) return nil } diff --git a/deploy/upcloud/config.go b/deploy/upcloud/config.go index 131b32a..f01a347 100644 --- a/deploy/upcloud/config.go +++ b/deploy/upcloud/config.go @@ -90,10 +90,12 @@ func extractFromAppviewTemplate() (clientName, baseDomain string, registryDomain return clientName, baseDomain, registryDomains, nil } -// readSSHPublicKey reads an SSH public key from a file path. +// readSSHPublicKey reads an SSH public key from a file path. An empty path +// returns an empty key without error — callers that need the key (e.g. when +// creating new servers) must check for empty before use. func readSSHPublicKey(path string) (string, error) { if path == "" { - return "", fmt.Errorf("--ssh-key is required (path to SSH public key file)") + return "", nil } data, err := os.ReadFile(path) if err != nil { diff --git a/deploy/upcloud/configs/labeler.yaml.tmpl b/deploy/upcloud/configs/labeler.yaml.tmpl index e4adc88..d4f8f0d 100644 --- a/deploy/upcloud/configs/labeler.yaml.tmpl +++ b/deploy/upcloud/configs/labeler.yaml.tmpl @@ -10,6 +10,9 @@ log_shipper: labeler: enabled: true addr: :5002 + public_url: "https://{{.LabelerDomain}}" + client_name: "Seamark Labeler" + client_short_name: Seamark owner_did: "" data_dir: "{{.BasePath}}/labeler" did_method: plc @@ -17,8 +20,3 @@ labeler: key_path: "" rotation_key: "" plc_directory_url: https://plc.directory -server: - base_url: "https://seamark.dev" - client_name: Seamark - client_short_name: Seamark - test_mode: false diff --git a/deploy/upcloud/provision.go b/deploy/upcloud/provision.go index 5c5fde8..f9b7a7f 100644 --- a/deploy/upcloud/provision.go +++ b/deploy/upcloud/provision.go @@ -37,11 +37,10 @@ var provisionCmd = &cobra.Command{ func init() { provisionCmd.Flags().String("zone", "", "UpCloud zone (interactive picker if omitted)") provisionCmd.Flags().String("plan", "", "Server plan (interactive picker if omitted)") - provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)") + provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required when creating new servers)") 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.Flags().Bool("with-labeler", false, "Deploy content moderation labeler alongside appview") - _ = provisionCmd.MarkFlagRequired("ssh-key") rootCmd.AddCommand(provisionCmd) } @@ -154,6 +153,8 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w // Hold domain is zone-based (e.g. us-chi1.cove.seamark.dev) holdDomain := cfg.Zone + ".cove." + cfg.BaseDomain + // Labeler domain is a fixed subdomain on the base domain (e.g. labeler.seamark.dev) + labelerDomain := "labeler." + cfg.BaseDomain // Build config template values vals := &ConfigValues{ @@ -165,6 +166,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w Zone: cfg.Zone, HoldDomain: holdDomain, HoldDid: "did:web:" + holdDomain, + LabelerDomain: labelerDomain, BasePath: naming.BasePath(), ScannerSecret: state.ScannerSecret, } @@ -307,7 +309,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w fmt.Printf("Load balancer: %s (exists)\n", state.LB.UUID) } else { fmt.Println("Creating load balancer (Essentials tier)...") - lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain) + lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain, labelerDomain, state.LabelerEnabled) if err != nil { return fmt.Errorf("create LB: %w", err) } @@ -325,6 +327,13 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w return fmt.Errorf("LB hold forwarded headers: %w", err) } + // Ensure labeler backend + route-labeler rule when labeler is enabled + if state.LabelerEnabled { + if err := ensureLBLabelerRoute(ctx, svc, state.LB.UUID, state.Appview.PrivateIP, labelerDomain); err != nil { + return fmt.Errorf("LB labeler route: %w", err) + } + } + // Always reconcile scanner block rule if err := ensureLBScannerBlock(ctx, svc, state.LB.UUID); err != nil { return fmt.Errorf("LB scanner block: %w", err) @@ -334,6 +343,9 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w tlsDomains := []string{cfg.BaseDomain} tlsDomains = append(tlsDomains, cfg.RegistryDomains...) tlsDomains = append(tlsDomains, holdDomain) + if state.LabelerEnabled { + tlsDomains = append(tlsDomains, labelerDomain) + } if err := ensureLBCertificates(ctx, svc, state.LB.UUID, tlsDomains); err != nil { return fmt.Errorf("LB certificates: %w", err) } @@ -428,6 +440,25 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w } } + // Labeler binary: build and upload when labeler is enabled but appview was + // not freshly created (the appviewCreated branch above already handled it). + if state.LabelerEnabled && !appviewCreated { + rootDir := projectRoot() + if err := runGenerate(rootDir); err != nil { + return fmt.Errorf("go generate: %w", err) + } + fmt.Println("\nBuilding labeler locally (GOOS=linux GOARCH=amd64)...") + labelerLocal := filepath.Join(rootDir, "bin", "atcr-labeler") + if err := buildLocal(rootDir, labelerLocal, "./cmd/labeler"); err != nil { + return fmt.Errorf("build labeler: %w", err) + } + labelerRemote := naming.InstallDir() + "/bin/" + naming.Labeler() + fmt.Println("Deploying labeler binary...") + if err := scpFile(labelerLocal, state.Appview.PublicIP, labelerRemote); err != nil { + return fmt.Errorf("upload labeler: %w", err) + } + } + fmt.Println("\n=== Provisioning Complete ===") fmt.Println() fmt.Println("DNS records needed:") @@ -437,6 +468,9 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w fmt.Printf(" CNAME %-24s → %s\n", rd, lbDNS) } fmt.Printf(" CNAME %-24s → %s\n", holdDomain, lbDNS) + if state.LabelerEnabled { + fmt.Printf(" CNAME %-24s → %s\n", labelerDomain, lbDNS) + } } else { fmt.Println(" (LB DNS name not yet available — check 'status' in a few minutes)") } @@ -574,6 +608,9 @@ func objectStorageRegion(zone string) string { } func createServer(ctx context.Context, svc *service.Service, cfg *InfraConfig, templateUUID, networkUUID, title, userData string) (*ServerState, error) { + if cfg.SSHPublicKey == "" { + return nil, fmt.Errorf("creating server %s requires --ssh-key (path to SSH public key file)", title) + } storageTier := "maxiops" if strings.HasPrefix(strings.ToUpper(cfg.Plan), "DEV-") { storageTier = "standard" @@ -709,7 +746,83 @@ func createFirewallRules(ctx context.Context, svc *service.Service, serverUUID, }) } -func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain string) (*upcloud.LoadBalancer, error) { +func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain, labelerDomain string, withLabeler bool) (*upcloud.LoadBalancer, error) { + frontendRules := []request.LoadBalancerFrontendRule{ + { + Name: "set-forwarded-headers", + Priority: 1, + Matchers: []upcloud.LoadBalancerMatcher{}, + Actions: []upcloud.LoadBalancerAction{ + request.NewLoadBalancerSetForwardedHeadersAction(), + }, + }, + { + Name: "route-hold", + Priority: 10, + Matchers: []upcloud.LoadBalancerMatcher{ + { + Type: upcloud.LoadBalancerMatcherTypeHost, + Host: &upcloud.LoadBalancerMatcherHost{ + Value: holdDomain, + }, + }, + }, + Actions: []upcloud.LoadBalancerAction{ + request.NewLoadBalancerSetForwardedHeadersAction(), + { + Type: upcloud.LoadBalancerActionTypeUseBackend, + UseBackend: &upcloud.LoadBalancerActionUseBackend{ + Backend: "hold", + }, + }, + }, + }, + } + + backends := []request.LoadBalancerBackend{ + { + Name: "appview", + Members: []request.LoadBalancerBackendMember{ + { + Name: "appview-1", + Type: upcloud.LoadBalancerBackendMemberTypeStatic, + IP: appviewIP, + Port: 5000, + Weight: 100, + MaxSessions: 1000, + Enabled: true, + }, + }, + Properties: &upcloud.LoadBalancerBackendProperties{ + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, + HealthCheckURL: "/health", + }, + }, + { + Name: "hold", + Members: []request.LoadBalancerBackendMember{ + { + Name: "hold-1", + Type: upcloud.LoadBalancerBackendMemberTypeStatic, + IP: holdIP, + Port: 8080, + Weight: 100, + MaxSessions: 1000, + Enabled: true, + }, + }, + Properties: &upcloud.LoadBalancerBackendProperties{ + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, + HealthCheckURL: "/xrpc/_health", + }, + }, + } + + if withLabeler { + frontendRules = append(frontendRules, labelerFrontendRule(labelerDomain)) + backends = append(backends, labelerBackend(appviewIP)) + } + lb, err := svc.CreateLoadBalancer(ctx, &request.CreateLoadBalancerRequest{ Name: naming.LBName(), Plan: "essentials", @@ -737,37 +850,7 @@ func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraCon Networks: []upcloud.LoadBalancerFrontendNetwork{ {Name: "public"}, }, - Rules: []request.LoadBalancerFrontendRule{ - { - Name: "set-forwarded-headers", - Priority: 1, - Matchers: []upcloud.LoadBalancerMatcher{}, - Actions: []upcloud.LoadBalancerAction{ - request.NewLoadBalancerSetForwardedHeadersAction(), - }, - }, - { - Name: "route-hold", - Priority: 10, - Matchers: []upcloud.LoadBalancerMatcher{ - { - Type: upcloud.LoadBalancerMatcherTypeHost, - Host: &upcloud.LoadBalancerMatcherHost{ - Value: holdDomain, - }, - }, - }, - Actions: []upcloud.LoadBalancerAction{ - request.NewLoadBalancerSetForwardedHeadersAction(), - { - Type: upcloud.LoadBalancerActionTypeUseBackend, - UseBackend: &upcloud.LoadBalancerActionUseBackend{ - Backend: "hold", - }, - }, - }, - }, - }, + Rules: frontendRules, }, { Name: "http-redirect", @@ -803,44 +886,7 @@ func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraCon }, }, Resolvers: []request.LoadBalancerResolver{}, - Backends: []request.LoadBalancerBackend{ - { - Name: "appview", - Members: []request.LoadBalancerBackendMember{ - { - Name: "appview-1", - Type: upcloud.LoadBalancerBackendMemberTypeStatic, - IP: appviewIP, - Port: 5000, - Weight: 100, - MaxSessions: 1000, - Enabled: true, - }, - }, - Properties: &upcloud.LoadBalancerBackendProperties{ - HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, - HealthCheckURL: "/health", - }, - }, - { - Name: "hold", - Members: []request.LoadBalancerBackendMember{ - { - Name: "hold-1", - Type: upcloud.LoadBalancerBackendMemberTypeStatic, - IP: holdIP, - Port: 8080, - Weight: 100, - MaxSessions: 1000, - Enabled: true, - }, - }, - Properties: &upcloud.LoadBalancerBackendProperties{ - HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, - HealthCheckURL: "/xrpc/_health", - }, - }, - }, + Backends: backends, }) if err != nil { return nil, err @@ -849,6 +895,55 @@ func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraCon return lb, nil } +// labelerBackend builds the labeler LB backend pointing at the appview server's +// private IP on the labeler listen port. +func labelerBackend(appviewIP string) request.LoadBalancerBackend { + return request.LoadBalancerBackend{ + Name: "labeler", + Members: []request.LoadBalancerBackendMember{ + { + Name: "labeler-1", + Type: upcloud.LoadBalancerBackendMemberTypeStatic, + IP: appviewIP, + Port: 5002, + Weight: 100, + MaxSessions: 1000, + Enabled: true, + }, + }, + Properties: &upcloud.LoadBalancerBackendProperties{ + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, + HealthCheckURL: "/.well-known/did.json", + }, + } +} + +// labelerFrontendRule returns a host-match rule routing labelerDomain to the +// labeler backend with forwarded headers. +func labelerFrontendRule(labelerDomain string) request.LoadBalancerFrontendRule { + return request.LoadBalancerFrontendRule{ + Name: "route-labeler", + Priority: 20, + Matchers: []upcloud.LoadBalancerMatcher{ + { + Type: upcloud.LoadBalancerMatcherTypeHost, + Host: &upcloud.LoadBalancerMatcherHost{ + Value: labelerDomain, + }, + }, + }, + Actions: []upcloud.LoadBalancerAction{ + request.NewLoadBalancerSetForwardedHeadersAction(), + { + Type: upcloud.LoadBalancerActionTypeUseBackend, + UseBackend: &upcloud.LoadBalancerActionUseBackend{ + Backend: "labeler", + }, + }, + }, + } +} + // ensureLBCertificates reconciles TLS certificate bundles on the load balancer. // It skips domains that already have a TLS config attached and creates missing ones. func ensureLBCertificates(ctx context.Context, svc *service.Service, lbUUID string, tlsDomains []string) error { @@ -1026,6 +1121,88 @@ func ensureLBHoldForwardedHeaders(ctx context.Context, svc *service.Service, lbU return nil } +// ensureLBLabelerRoute idempotently ensures the LB has a "labeler" backend +// pointing at the appview server's private IP and a "route-labeler" frontend +// rule matching labelerDomain. Used to add labeler routing to a pre-existing LB +// during a re-provision with --with-labeler. +func ensureLBLabelerRoute(ctx context.Context, svc *service.Service, lbUUID, appviewIP, labelerDomain string) error { + // 1. Ensure backend exists + backends, err := svc.GetLoadBalancerBackends(ctx, &request.GetLoadBalancerBackendsRequest{ServiceUUID: lbUUID}) + if err != nil { + return fmt.Errorf("get backends: %w", err) + } + hasBackend := false + for _, b := range backends { + if b.Name == "labeler" { + hasBackend = true + break + } + } + if !hasBackend { + _, err := svc.CreateLoadBalancerBackend(ctx, &request.CreateLoadBalancerBackendRequest{ + ServiceUUID: lbUUID, + Backend: labelerBackend(appviewIP), + }) + if err != nil { + return fmt.Errorf("create labeler backend: %w", err) + } + fmt.Println(" Labeler backend: created") + } else { + fmt.Println(" Labeler backend: exists") + } + + // 2. Ensure frontend rule exists with correct host matcher + rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + }) + if err != nil { + return fmt.Errorf("get frontend rules: %w", err) + } + for _, r := range rules { + if r.Name == "route-labeler" { + // Verify the host matcher and use_backend action are correct + hostOK := false + for _, m := range r.Matchers { + if m.Host != nil && m.Host.Value == labelerDomain { + hostOK = true + break + } + } + backendOK := false + for _, a := range r.Actions { + if a.UseBackend != nil && a.UseBackend.Backend == "labeler" { + backendOK = true + break + } + } + if hostOK && backendOK { + fmt.Println(" Route-labeler rule: exists and valid") + return nil + } + fmt.Println(" Route-labeler rule: exists but misconfigured, recreating") + if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Name: r.Name, + }); err != nil { + return fmt.Errorf("delete route-labeler rule: %w", err) + } + break + } + } + + if _, err := svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Rule: labelerFrontendRule(labelerDomain), + }); err != nil { + return fmt.Errorf("create route-labeler rule: %w", err) + } + fmt.Println(" Route-labeler rule: created") + return nil +} + // ensureLBScannerBlock ensures the "https" frontend has a rule that returns 403 // for common scanner paths (.php, .asp, .aspx, .jsp, .cgi, .env). func ensureLBScannerBlock(ctx context.Context, svc *service.Service, lbUUID string) error { diff --git a/deploy/upcloud/update.go b/deploy/upcloud/update.go index 815fc16..31a9a51 100644 --- a/deploy/upcloud/update.go +++ b/deploy/upcloud/update.go @@ -364,6 +364,7 @@ func configValsFromState(state *InfraState) *ConfigValues { naming := state.Naming() _, baseDomain, _, _ := extractFromAppviewTemplate() holdDomain := state.Zone + ".cove." + baseDomain + labelerDomain := "labeler." + baseDomain return &ConfigValues{ S3Endpoint: state.ObjectStorage.Endpoint, @@ -374,6 +375,7 @@ func configValsFromState(state *InfraState) *ConfigValues { Zone: state.Zone, HoldDomain: holdDomain, HoldDid: "did:web:" + holdDomain, + LabelerDomain: labelerDomain, BasePath: naming.BasePath(), ScannerSecret: state.ScannerSecret, } diff --git a/pkg/labeler/config.go b/pkg/labeler/config.go index 1335296..57386ef 100644 --- a/pkg/labeler/config.go +++ b/pkg/labeler/config.go @@ -4,7 +4,6 @@ package labeler import ( "fmt" - "net/url" "path/filepath" "strings" "time" @@ -14,13 +13,12 @@ import ( "atcr.io/pkg/config" ) -// Config represents the labeler service configuration. -// It reuses the appview config YAML structure, reading from the "labeler" section. +// Config represents the labeler service configuration. It is fully self-contained: +// no fields are inherited from or shared with the appview config. type Config struct { Version string `yaml:"version" comment:"Configuration format version."` LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` Labeler LabelerConfig `yaml:"labeler" comment:"Labeler service settings."` - Server AppviewServerConfig `yaml:"server" comment:"AppView server settings (shared config)."` LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` } @@ -32,10 +30,14 @@ type LabelerConfig struct { // Listen address for the labeler HTTP server. Addr string `yaml:"addr" comment:"Listen address for labeler (e.g., :5002)."` - // PublicURL is the externally reachable URL of the labeler. When empty the URL is - // derived from server.base_url by prefixing "labeler." (so https://atcr.io → - // https://labeler.atcr.io). Set explicitly for IP-based dev environments. - PublicURL string `yaml:"public_url" comment:"Externally reachable labeler URL. Empty = derive from server.base_url."` + // PublicURL is the externally reachable URL of the labeler. Required. + PublicURL string `yaml:"public_url" comment:"Externally reachable labeler URL (required, e.g. https://labeler.example.com)."` + + // ClientName is the OAuth client display name shown to PDS users on consent screens. + ClientName string `yaml:"client_name" comment:"OAuth client display name (e.g., \"ATCR Labeler\")."` + + // ClientShortName is a shorter brand label used in UI copy. + ClientShortName string `yaml:"client_short_name" comment:"Short brand label used in UI copy (e.g., \"ATCR\")."` // DID of the labeler admin. Only this DID can log into the admin panel. OwnerDID string `yaml:"owner_did" comment:"DID of the labeler admin. Only this DID can log into the admin panel."` @@ -69,27 +71,9 @@ type LabelerConfig struct { LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"Embedded-replica pull interval (e.g. 30s). 0 = manual sync only."` } -// AppviewServerConfig is a subset of the appview ServerConfig that the labeler needs. -type AppviewServerConfig struct { - BaseURL string `yaml:"base_url"` - ClientName string `yaml:"client_name"` - ClientShortName string `yaml:"client_short_name"` - TestMode bool `yaml:"test_mode"` -} - -// PublicURL returns the labeler's externally reachable URL. When labeler.public_url -// is set explicitly it wins; otherwise it's derived from server.base_url by prefixing -// "labeler." (so https://atcr.io → https://labeler.atcr.io). +// PublicURL returns the labeler's externally reachable URL. func (c *Config) PublicURL() string { - if c.Labeler.PublicURL != "" { - return c.Labeler.PublicURL - } - u, err := url.Parse(c.Server.BaseURL) - if err != nil { - return "" - } - u.Host = "labeler." + u.Host - return u.String() + return c.Labeler.PublicURL } // DBPath returns the path to the SQLite database file inside the data dir. @@ -121,6 +105,8 @@ func setDefaults(v *viper.Viper) { v.SetDefault("labeler.enabled", false) v.SetDefault("labeler.addr", ":5002") v.SetDefault("labeler.public_url", "") + v.SetDefault("labeler.client_name", "ATCR Labeler") + v.SetDefault("labeler.client_short_name", "ATCR") v.SetDefault("labeler.owner_did", "") v.SetDefault("labeler.data_dir", "/var/lib/atcr-labeler") v.SetDefault("labeler.did_method", "plc") @@ -131,15 +117,9 @@ func setDefaults(v *viper.Viper) { v.SetDefault("labeler.libsql_sync_url", "") v.SetDefault("labeler.libsql_auth_token", "") v.SetDefault("labeler.libsql_sync_interval", 0) - - // Server defaults (read from shared appview config) - v.SetDefault("server.base_url", "") - v.SetDefault("server.client_name", "AT Container Registry") - v.SetDefault("server.client_short_name", "ATCR") - v.SetDefault("server.test_mode", false) } -// LoadConfig loads the labeler configuration from the appview config YAML. +// LoadConfig loads the labeler configuration from a YAML file. func LoadConfig(yamlPath string) (*Config, error) { v := config.NewViper("LABELER", yamlPath) setDefaults(v) @@ -149,24 +129,9 @@ func LoadConfig(yamlPath string) (*Config, error) { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } - // Also try ATCR_ prefix for shared server config - atcrV := config.NewViper("ATCR", yamlPath) - if baseURL := atcrV.GetString("server.base_url"); baseURL != "" && cfg.Server.BaseURL == "" { - cfg.Server.BaseURL = baseURL - } - if clientName := atcrV.GetString("server.client_name"); clientName != "" && cfg.Server.ClientName == "" { - cfg.Server.ClientName = clientName - } - if clientShortName := atcrV.GetString("server.client_short_name"); clientShortName != "" && cfg.Server.ClientShortName == "" { - cfg.Server.ClientShortName = clientShortName - } - if atcrV.GetBool("server.test_mode") { - cfg.Server.TestMode = true - } - // Validation - if cfg.Server.BaseURL == "" { - return nil, fmt.Errorf("server.base_url is required") + if cfg.Labeler.PublicURL == "" { + return nil, fmt.Errorf("labeler.public_url is required") } if cfg.Labeler.OwnerDID == "" { return nil, fmt.Errorf("labeler.owner_did is required") @@ -188,14 +153,12 @@ func ExampleYAML() ([]byte, error) { cfg := &Config{ Version: "0.1", LogLevel: "info", - Server: AppviewServerConfig{ - BaseURL: "https://atcr.io", - ClientName: "AT Container Registry", - ClientShortName: "ATCR", - }, Labeler: LabelerConfig{ Enabled: true, Addr: ":5002", + PublicURL: "https://labeler.example.com", + ClientName: "ATCR Labeler", + ClientShortName: "ATCR", OwnerDID: "did:plc:your-did-here", DataDir: "/var/lib/atcr-labeler", DIDMethod: "plc", diff --git a/pkg/labeler/config_test.go b/pkg/labeler/config_test.go index 4c1fe15..30d410a 100644 --- a/pkg/labeler/config_test.go +++ b/pkg/labeler/config_test.go @@ -3,25 +3,10 @@ package labeler import "testing" func TestConfig_PublicURL(t *testing.T) { - tests := []struct { - name string - baseURL string - want string - }{ - {"standard", "https://atcr.io", "https://labeler.atcr.io"}, - {"with port", "https://atcr.io:8080", "https://labeler.atcr.io:8080"}, - {"localhost", "http://localhost:5000", "http://labeler.localhost:5000"}, + cfg := &Config{ + Labeler: LabelerConfig{PublicURL: "https://labeler.atcr.io"}, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Server: AppviewServerConfig{BaseURL: tt.baseURL}, - } - got := cfg.PublicURL() - if got != tt.want { - t.Errorf("PublicURL() = %q, want %q", got, tt.want) - } - }) + if got := cfg.PublicURL(); got != "https://labeler.atcr.io" { + t.Errorf("PublicURL() = %q, want %q", got, "https://labeler.atcr.io") } } diff --git a/pkg/labeler/handlers.go b/pkg/labeler/handlers.go index ee47236..a0dc27e 100644 --- a/pkg/labeler/handlers.go +++ b/pkg/labeler/handlers.go @@ -39,8 +39,8 @@ button{padding:10px 20px;cursor:pointer} `, - s.config.Server.ClientShortName, - s.config.Server.ClientShortName, + s.config.Labeler.ClientShortName, + s.config.Labeler.ClientShortName, func() string { if errorMsg != "" { return fmt.Sprintf(`
%s
`, template.HTMLEscapeString(errorMsg)) diff --git a/pkg/labeler/identity.go b/pkg/labeler/identity.go index 729785f..12728a6 100644 --- a/pkg/labeler/identity.go +++ b/pkg/labeler/identity.go @@ -58,7 +58,7 @@ func (s *Server) handleClientMetadata(w http.ResponseWriter, r *http.Request) { publicURL := s.config.PublicURL() metadata := map[string]any{ "client_id": publicURL + "/oauth-client-metadata.json", - "client_name": fmt.Sprintf("%s Labeler", s.config.Server.ClientShortName), + "client_name": s.config.Labeler.ClientName, "client_uri": publicURL, "redirect_uris": []string{publicURL + "/auth/oauth/callback"}, "scope": "atproto", diff --git a/pkg/labeler/takedown.go b/pkg/labeler/takedown.go index a91dc97..20d5b6b 100644 --- a/pkg/labeler/takedown.go +++ b/pkg/labeler/takedown.go @@ -462,8 +462,8 @@ code{background:#f4f4f5;padding:1px 4px;border-radius:3px} Logout

Active Takedowns (%d)

`, - s.config.Server.ClientShortName, - s.config.Server.ClientShortName, + s.config.Labeler.ClientShortName, + s.config.Labeler.ClientShortName, activeTotal, ) @@ -636,7 +636,7 @@ nav{display:flex;gap:16px;margin-bottom:24px} Dashboard New Takedown `, - s.config.Server.ClientShortName, + s.config.Labeler.ClientShortName, ) if msg != "" {