package main import ( "context" "crypto/subtle" "encoding/base64" "log/slog" "net" "net/http" "strings" "time" "github.com/bluesky-social/indigo/cmd/relay/relay" "github.com/bluesky-social/indigo/util/svcutil" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" ) type Service struct { logger *slog.Logger relay *relay.Relay config ServiceConfig siblingClient http.Client } type ServiceConfig struct { // list of hosts which get forwarded admin state changes (takedowns, etc) SiblingRelayHosts []string // verified against Basic admin auth. multiple passwords are allowed server-side, to make secret rotations operationally easier AdminPasswords []string // how long to wait for the requested server socket to become available for use ListenerBootTimeout time.Duration // if true, don't process public (unauthenticated) requestCrawl DisableRequestCrawl bool // if true, allows non-SSL hosts to be added via public requestCrawl AllowInsecureHosts bool } func DefaultServiceConfig() *ServiceConfig { return &ServiceConfig{ ListenerBootTimeout: 5 * time.Second, } } func NewService(r *relay.Relay, config *ServiceConfig) (*Service, error) { if config == nil { config = DefaultServiceConfig() } svc := &Service{ logger: slog.Default().With("system", "relay"), relay: r, config: *config, siblingClient: http.Client{ Timeout: 10 * time.Second, }, } return svc, nil } func (svc *Service) StartMetrics(listen string) error { http.Handle("/metrics", promhttp.Handler()) return http.ListenAndServe(listen, nil) } func (svc *Service) StartAPI(bind string) error { var lc net.ListenConfig ctx, cancel := context.WithTimeout(context.Background(), svc.config.ListenerBootTimeout) defer cancel() li, err := lc.Listen(ctx, "tcp", bind) if err != nil { return err } return svc.startWithListener(li) } func (svc *Service) startWithListener(listen net.Listener) error { e := echo.New() e.HideBanner = true e.HidePort = true e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"*"}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, })) e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { c.Response().Header().Set(echo.HeaderServer, svc.relay.Config.UserAgent) return next(c) } }) e.Use(middleware.LoggerWithConfig(middleware.DefaultLoggerConfig)) // React uses a virtual router, so we need to serve the index.html for all // routes that aren't otherwise handled or in the /assets directory. e.File("/dash", "public/index.html") e.File("/dash/*", "public/index.html") e.Static("/assets", "public/assets") e.Use(svcutil.MetricsMiddleware) e.HTTPErrorHandler = func(err error, c echo.Context) { if c.Response().Committed { return } switch err := err.(type) { case *echo.HTTPError: if err2 := c.JSON(err.Code, map[string]any{ "error": err.Message, }); err2 != nil { svc.logger.Error("Failed to write http error", "err", err2) } default: sendHeader := true if c.Path() == "/xrpc/com.atproto.sync.subscribeRepos" { sendHeader = false } svc.logger.Error("API handler error", "path", c.Path(), "err", err) if strings.HasPrefix(c.Path(), "/admin/") { _ = c.JSON(http.StatusInternalServerError, map[string]any{ "error": err.Error(), }) return } if sendHeader { c.Response().WriteHeader(http.StatusInternalServerError) } } } // TODO: this API is temporary until we formalize what we want here e.GET("/", svc.HandleHomeMessage) e.GET("/_health", svc.HandleHealthCheck) e.GET("/xrpc/_health", svc.HandleHealthCheck) e.GET("/xrpc/com.atproto.sync.subscribeRepos", svc.HandleComAtprotoSyncSubscribeRepos) e.POST("/xrpc/com.atproto.sync.requestCrawl", svc.HandleComAtprotoSyncRequestCrawl) e.GET("/xrpc/com.atproto.sync.listHosts", svc.HandleComAtprotoSyncListHosts) e.GET("/xrpc/com.atproto.sync.getHostStatus", svc.HandleComAtprotoSyncGetHostStatus) e.GET("/xrpc/com.atproto.sync.listRepos", svc.HandleComAtprotoSyncListRepos) e.GET("/xrpc/com.atproto.sync.getRepo", svc.HandleComAtprotoSyncGetRepo) // just returns 3xx redirect to source PDS e.GET("/xrpc/com.atproto.sync.getRepoStatus", svc.HandleComAtprotoSyncGetRepoStatus) e.GET("/xrpc/com.atproto.sync.getLatestCommit", svc.HandleComAtprotoSyncGetLatestCommit) admin := e.Group("/admin", svc.checkAdminAuth) // Slurper-related Admin API admin.GET("/subs/getUpstreamConns", svc.handleAdminGetUpstreamConns) admin.POST("/subs/killUpstream", svc.handleAdminKillUpstreamConn) admin.GET("/subs/getEnabled", svc.handleAdminGetSubsEnabled) admin.POST("/subs/setEnabled", svc.handleAdminSetSubsEnabled) admin.GET("/subs/perDayLimit", svc.handleAdminGetNewHostPerDayRateLimit) admin.POST("/subs/setPerDayLimit", svc.handleAdminSetNewHostPerDayRateLimit) // Domain-related Admin API admin.GET("/subs/listDomainBans", svc.handleAdminListDomainBans) admin.POST("/subs/banDomain", svc.handleAdminBanDomain) admin.POST("/subs/unbanDomain", svc.handleAdminUnbanDomain) // Repo-related Admin API admin.GET("/repo/takedowns", svc.handleAdminListRepoTakeDowns) // NOTE: unused admin.POST("/repo/takeDown", svc.handleAdminTakeDownRepo) admin.POST("/repo/reverseTakedown", svc.handleAdminReverseTakedown) // Host-related Admin API admin.GET("/pds/list", svc.handleListHosts) admin.POST("/pds/requestCrawl", svc.handleAdminRequestCrawl) admin.POST("/pds/changeLimits", svc.handleAdminChangeHostRateLimits) admin.POST("/pds/block", svc.handleBlockHost) admin.POST("/pds/unblock", svc.handleUnblockHost) // removed: admin.POST("/pds/addTrustedDomain", svc.handleAdminAddTrustedDomain) // Consumer-related Admin API admin.GET("/consumers/list", svc.handleAdminListConsumers) // In order to support booting on random ports in tests, we need to tell the // Echo instance it's already got a port, and then use its StartServer // method to re-use that listener. e.Listener = listen svc.logger.Info("starting web server", "listen", e.Listener.Addr().String()) srv := &http.Server{} // TODO: attach echo to Service, for shutdown? return e.StartServer(srv) } func (svc *Service) Shutdown() []error { var errs []error if err := svc.relay.Slurper.Shutdown(); err != nil { errs = append(errs, err) } if err := svc.relay.Events.Shutdown(context.TODO()); err != nil { errs = append(errs, err) } return errs } func (svc *Service) checkAdminAuth(next echo.HandlerFunc) echo.HandlerFunc { // pre-compute valid HTTP auth headers based on the set of validAuthHeaders := []string{} for _, pw := range svc.config.AdminPasswords { hdr := "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:"+pw)) validAuthHeaders = append(validAuthHeaders, hdr) } // for paths that this middleware is applied to, enforce that the auth header must exist and match one of the known passwords return func(c echo.Context) error { hdr := c.Request().Header.Get("Authorization") if hdr == "" { c.Response().Header().Set("WWW-Authenticate", "Basic") return echo.ErrUnauthorized } for _, val := range validAuthHeaders { if subtle.ConstantTimeCompare([]byte(hdr), []byte(val)) == 1 { return next(c) } } c.Response().Header().Set("WWW-Authenticate", "Basic") return echo.ErrUnauthorized } }