Files
at-container-registry/pkg/labeler/server.go
T
Evan JarrettandClaude Fable 5.1 a01b08b924 atproto: gate local indigo behavior behind a testmode build tag
indigo's identity directory refuses HTTP and IP-hosted did:web, and its
OAuth client is growing an SSRF-guarded transport that refuses loopback
and private addresses. Local development and the test suites need both,
and the workarounds were scattered: two did:web fallbacks in the
resolver, a hand-rolled appview key fetch on the hold, and the OAuth
client left on indigo's defaults so any test driving it against an
httptest server depended on the transport staying permissive.

Move every departure from indigo's defaults into one file pair in
pkg/atproto: indigo_prod.go (!testmode) returns indigo's directory and
OAuth client unchanged; indigo_local.go (testmode) wraps the directory
so a did:web naming an IP, localhost, or a host with a port resolves
over plain HTTP, and gives the OAuth client plain HTTP clients. All six
identity and OAuth constructor call sites go through NewDirectory and
NewOAuthClientApp. The resolver fallbacks, DIDWebToURL, and the hold's
scheme-guessing key fetch are gone; the hold resolves the appview key
through the directory, preferring #appview, and purges and retries once
on a signature failure so a re-keyed appview is not masked by the
24-hour cache.

There is no runtime switch for this: a production binary cannot be
configured to resolve local DIDs. The runtime test_mode flag still
gates the remaining behavioral branches only.

Tests, the harness, make dev, Air, Dockerfile.dev, and docker-compose
build with the tag; fixtures that need loopback did:web fail fast
naming it. Test hold servers now serve a did.json via pkg/testpds so
they resolve as real holds under the tag.

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

190 lines
4.8 KiB
Go

package labeler
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/atcrypto"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/go-chi/chi/v5"
)
// Server is the labeler HTTP server.
type Server struct {
config *Config
storage *LabelerDB
db *sql.DB
router chi.Router
clientApp *indigooauth.ClientApp
auth *Auth
did string
signingKey *atcrypto.PrivateKeyK256
hub *Hub
}
// NewServer creates a new labeler server.
func NewServer(cfg *Config) (*Server, error) {
storage, err := OpenDB(cfg.DBPath(), LibsqlSync{
SyncURL: cfg.Labeler.LibsqlSyncURL,
AuthToken: cfg.Labeler.LibsqlAuthToken,
SyncInterval: cfg.Labeler.LibsqlSyncInterval,
})
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
ctx := context.Background()
did, signingKey, err := LoadIdentity(ctx, cfg)
if err != nil {
_ = storage.Close()
return nil, err
}
publicURL := cfg.PublicURL()
// Set up OAuth client for admin login
oauthStore := indigooauth.NewMemStore()
scopes := []string{"atproto"}
var oauthConfig indigooauth.ClientConfig
var redirectURI string
u, err := url.Parse(publicURL)
if err != nil {
return nil, fmt.Errorf("invalid public URL: %w", err)
}
host := u.Hostname()
if isLocalhost(host) {
port := u.Port()
if port == "" {
port = "5002"
}
oauthBaseURL := "http://127.0.0.1:" + port
redirectURI = oauthBaseURL + "/auth/oauth/callback"
oauthConfig = indigooauth.NewLocalhostConfig(redirectURI, scopes)
} else {
clientID := publicURL + "/oauth-client-metadata.json"
redirectURI = publicURL + "/auth/oauth/callback"
oauthConfig = indigooauth.NewPublicConfig(clientID, redirectURI, scopes)
}
clientApp := atproto.NewOAuthClientApp(&oauthConfig, oauthStore)
auth := NewAuth(cfg.Labeler.OwnerDID)
s := &Server{
config: cfg,
storage: storage,
db: storage.DB,
clientApp: clientApp,
auth: auth,
did: did,
signingKey: signingKey,
hub: NewHub(),
}
s.setupRoutes()
return s, nil
}
func (s *Server) setupRoutes() {
r := chi.NewRouter()
// DID document
r.Get("/.well-known/did.json", s.handleDIDDocument)
// OAuth client metadata
r.Get("/oauth-client-metadata.json", s.handleClientMetadata)
// Auth routes (public)
r.Get("/auth/login", s.handleLogin)
r.Get("/auth/oauth/authorize", s.handleAuthorize)
r.Get("/auth/oauth/callback", s.handleCallback)
r.Get("/auth/logout", s.handleLogout)
// XRPC endpoints (public)
r.Get("/xrpc/com.atproto.label.subscribeLabels", s.handleSubscribeLabels)
r.Get("/xrpc/com.atproto.label.queryLabels", s.handleQueryLabels)
// Protected routes (require owner). CSRF is enforced for state-mutating
// methods inside the same group, so it sees the session on the context.
r.Group(func(r chi.Router) {
r.Use(s.auth.RequireOwner)
r.Use(s.auth.RequireCSRF)
r.Get("/", s.handleDashboard)
r.Get("/takedown", s.handleTakedownForm)
r.Post("/takedown", s.handleTakedownSubmit)
r.Post("/reverse", s.handleReverse)
})
s.router = r
}
// Serve starts the HTTP server with graceful shutdown.
func (s *Server) Serve() error {
slog.Info("Starting labeler service",
"addr", s.config.Labeler.Addr,
"public_url", s.config.PublicURL(),
"did", s.did,
"owner", s.config.Labeler.OwnerDID,
)
srv := &http.Server{
Addr: s.config.Labeler.Addr,
Handler: s.router,
}
// Graceful shutdown
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
if err != http.ErrServerClosed {
return err
}
case <-ctx.Done():
slog.Info("Shutting down labeler service")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown error: %w", err)
}
}
if err := s.storage.Close(); err != nil {
slog.Warn("Error closing labeler database", "error", err)
}
return nil
}
// isLocalhost returns true when the host is reachable only from the local machine /
// docker host — anything that an external PDS can't reach. Matches the hold's policy:
// any IP literal counts (covers 127.0.0.1, 192.168.*, 172.16-31.*, 10.*, ::1, etc.) plus
// the literal "localhost". When this is true, OAuth uses indigo's `NewLocalhostConfig`
// which sets a `http://localhost`-form client_id that PDSes accept under the loopback
// exception — so the PDS never has to fetch the client metadata URL we publish.
func isLocalhost(host string) bool {
if host == "localhost" {
return true
}
return net.ParseIP(host) != nil
}