fix labeler deployment

This commit is contained in:
Evan Jarrett
2026-05-03 20:49:56 -05:00
parent c2250c8787
commit 1ac8af74d5
11 changed files with 324 additions and 179 deletions
+6 -8
View File
@@ -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.
+29 -9
View File
@@ -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
}
+4 -2
View File
@@ -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 {
+3 -5
View File
@@ -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
+250 -73
View File
@@ -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 {
+2
View File
@@ -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,
}
+20 -57
View File
@@ -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",
+4 -19
View File
@@ -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")
}
}
+2 -2
View File
@@ -39,8 +39,8 @@ button{padding:10px 20px;cursor:pointer}</style>
<button type="submit">Sign In</button>
</form>
</body></html>`,
s.config.Server.ClientShortName,
s.config.Server.ClientShortName,
s.config.Labeler.ClientShortName,
s.config.Labeler.ClientShortName,
func() string {
if errorMsg != "" {
return fmt.Sprintf(`<div class="error">%s</div>`, template.HTMLEscapeString(errorMsg))
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -462,8 +462,8 @@ code{background:#f4f4f5;padding:1px 4px;border-radius:3px}
<a href="/auth/logout">Logout</a>
</nav>
<h2>Active Takedowns (%d)</h2>`,
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}
<a href="/" class="nav-btn">Dashboard</a>
<a href="/takedown" class="nav-btn">New Takedown</a>
</nav>`,
s.config.Server.ClientShortName,
s.config.Labeler.ClientShortName,
)
if msg != "" {