// Command pages-server serves static sites deployed through the pages CLI and // exposes the management API used to drive those deployments. // // It binds two listeners: a public one that only ever serves site content, and a // management one (loopback by default) that only ever serves the API. Keeping // them apart means the management surface never shares an origin with content // that projects control. package main import ( "context" "errors" "flag" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/iceBear67/simplepages/internal/adminapi" "github.com/iceBear67/simplepages/internal/auth" "github.com/iceBear67/simplepages/internal/cas" "github.com/iceBear67/simplepages/internal/config" "github.com/iceBear67/simplepages/internal/deploy" "github.com/iceBear67/simplepages/internal/httpx" "github.com/iceBear67/simplepages/internal/site" "github.com/iceBear67/simplepages/internal/store" "github.com/iceBear67/simplepages/internal/version" "github.com/iceBear67/simplepages/internal/webroot" ) // Background worker cadences. The auth flusher batches last_used_at updates; // writing one per request would funnel every authenticated read through the // single write connection. const ( touchFlushInterval = 60 * time.Second // failedAuthBurst is how many failed authentications one client address may // make before it is throttled, and failedAuthPeriod is how long a full // budget takes to refill. Successful requests cost nothing, so a busy CI // fleet never meets these numbers. failedAuthBurst = 10 failedAuthPeriod = time.Minute failedAuthClients = 10000 ) func main() { if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { if errors.Is(err, flag.ErrHelp) { os.Exit(2) } fmt.Fprintf(os.Stderr, "pages-server: %v\n", err) os.Exit(1) } } func run(args []string, stdout, stderr io.Writer) error { opts, err := config.Load(args, stderr) if err != nil { return err } if opts.ShowVersion { fmt.Fprintf(stdout, "pages-server %s\n", version.String()) return nil } if opts.CheckOnly { fmt.Fprintln(stdout, "configuration ok") return nil } cfg := opts.Config log := cfg.Logger(stderr) log.Info("starting", "version", version.Short(), "data_dir", cfg.DataDir, "webroot", cfg.Webroot, "assemble_mode", string(cfg.AssembleMode)) if err := cfg.EnsureDirs(); err != nil { return err } // The first signal starts a graceful shutdown; a second one gives up on the // in-flight requests, which is what an operator means by pressing Ctrl-C // twice. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { <-ctx.Done() hard := make(chan os.Signal, 1) signal.Notify(hard, os.Interrupt, syscall.SIGTERM) <-hard fmt.Fprintln(stderr, "pages-server: second signal, exiting immediately") os.Exit(130) }() app, err := newApp(ctx, cfg, log) if err != nil { return err } defer app.close() // Background workers get their own cancellable context so they are stopped // *after* the listeners have drained: the auth flusher's final write should // include the last requests the server handled. workerCtx, stopWorkers := context.WithCancel(context.Background()) var workers sync.WaitGroup // Registered after the app's own cleanup so it runs before it: the workers // must be finished with the database before anything closes it. defer func() { stopWorkers() workers.Wait() }() for _, worker := range []func(context.Context){ func(ctx context.Context) { app.verifier.RunFlusher(ctx, touchFlushInterval) }, func(ctx context.Context) { app.deploy.RunReconciler(ctx, cfg.ReconcileInterval.D()) }, func(ctx context.Context) { app.deploy.RunCollector(ctx, cfg.GCInterval.D()) }, } { workers.Add(1) go func() { defer workers.Done() worker(workerCtx) }() } siteSrv, err := httpx.Listen("site", cfg.Listen, app.siteHandler(), httpx.Timeouts{ ReadHeader: cfg.ReadHeaderTimeout.D(), Read: cfg.ReadTimeout.D(), Idle: cfg.IdleTimeout.D(), // No Write timeout: see httpx.Timeouts. }, log) if err != nil { return fmt.Errorf("listen %s: %w", cfg.Listen, err) } apiSrv, err := httpx.Listen("api", cfg.APIListen, app.apiHandler(), httpx.Timeouts{ ReadHeader: cfg.ReadHeaderTimeout.D(), Read: cfg.ReadTimeout.D(), Idle: cfg.IdleTimeout.D(), }, log) if err != nil { return fmt.Errorf("listen %s: %w", cfg.APIListen, err) } app.ready.Store(true) g := &httpx.Group{ Servers: []*httpx.Server{siteSrv, apiSrv}, Grace: cfg.ShutdownGrace.D(), Log: log, } if err := g.Run(ctx); err != nil { return err } log.Info("stopped") return nil } // app holds the process-wide state shared by both listeners. type app struct { cfg config.Config log *slog.Logger started time.Time db *store.DB cas *cas.Store sites *site.Registry webroot *webroot.Webroot deploy *deploy.Service verifier *auth.Verifier authmw *auth.Middleware admin *adminapi.Server // ready gates /readyz: the process may be accepting connections before it // can actually answer for content, and a load balancer needs to know. ready atomic.Bool } func newApp(ctx context.Context, cfg config.Config, log *slog.Logger) (*app, error) { db, err := store.Open(ctx, cfg.DBPath(), log) if err != nil { return nil, fmt.Errorf("open store: %w", err) } if _, err := auth.EnsureAdminKey(ctx, db, cfg.BootstrapTokenPath(), log); err != nil { db.Close() return nil, err } // The probe directory must be the one trees are assembled in: hardlinks // cannot cross filesystems, so probing anywhere else answers a different // question. With assemble_mode=none nothing is assembled and the mode is // irrelevant, so ask for copy rather than probe for something unused. casOpts := cas.Options{ProbeDir: cfg.DeploymentsDir(), Log: log} switch cfg.AssembleMode { case config.AssembleHardlink: casOpts.Mode = cas.LinkHard case config.AssembleCopy, config.AssembleNone: casOpts.Mode = cas.LinkCopy } cs, err := cas.Open(cfg.CASDir(), casOpts) if err != nil { db.Close() return nil, err } verifier := auth.NewVerifier(db, log, auth.DefaultCacheTTL) // An empty Dir is how the service is told to skip on-disk assembly. deployDir := cfg.DeploymentsDir() if cfg.AssembleMode == config.AssembleNone { deployDir = "" } // With nothing assembled on disk there is nothing for a symlink to point at, // so assemble_mode=none leaves the webroot unmanaged. Failing to open it // otherwise is a permissions or layout problem the operator asked for and // should hear about now, rather than as a warning on every activation. var wr *webroot.Webroot if deployDir != "" && cfg.Webroot != "" { wr, err = webroot.Open(cfg.Webroot, deployDir) if err != nil { cs.Close() db.Close() return nil, err } } a := &app{ cfg: cfg, log: log, started: time.Now(), db: db, cas: cs, sites: site.NewRegistry(), webroot: wr, verifier: verifier, authmw: &auth.Middleware{ V: verifier, Limiter: auth.NewLimiter(failedAuthBurst, failedAuthPeriod, failedAuthClients), Trusted: cfg.TrustedProxies(), Log: log, }, } a.deploy = &deploy.Service{ DB: db, CAS: cs, Log: log, Dir: deployDir, Sites: a.sites, Webroot: wr, } a.admin = &adminapi.Server{ DB: db, Auth: a.authmw, Deploy: a.deploy, Log: log, Limits: cfg.Limits, // The registry answers ownership checks and "what is this project // serving" from memory. Both are only correct once LoadSites below has // finished, which is why it runs before any listener exists. Resolver: a.sites, Sites: a.sites, BaseURL: cfg.SiteURL, LinkMode: string(cs.LinkMode()), Started: a.started, } a.admin.Hooks = adminapi.Hooks{ ProjectChanged: func(_ context.Context, p *store.Project) { // A new project starts with nothing activated, so it resolves and // then answers 503 until something is deployed to it. A changed one // keeps serving what it was serving, with new settings. a.sites.Put(p) }, ProjectDeleted: func(ctx context.Context, p *store.Project) { a.sites.Delete(p.Name) if wr != nil { if err := wr.Unpoint(p.Name); err != nil { log.WarnContext(ctx, "could not remove the webroot symlink", "project", p.Name, "err", err) } } // The rows are already gone and their blobs are already // unreferenced; this is only the assembled trees, which nothing // would otherwise account for. A failure here is not worth failing // the request over — the startup sweep removes them as orphans. if err := a.deploy.RemoveProjectTrees(p.ID); err != nil { log.WarnContext(ctx, "could not remove the project's deployment trees", "project", p.Name, "err", err) } }, } // Recovery runs before the registry is built, and both run before any // listener exists. That order is what lets recovery assume it is alone with // the data directory, and it means the first request is answered from state // that has already been reconciled rather than from whatever the last crash // left behind. if err := a.deploy.Recover(ctx); err != nil { a.close() return nil, fmt.Errorf("recover: %w", err) } if err := a.deploy.LoadSites(ctx); err != nil { a.close() return nil, fmt.Errorf("load sites: %w", err) } return a, nil } // pingDB checks that the read pool can still reach the database, with a // deadline of its own so a wedged store cannot hold a probe open indefinitely. func (a *app) pingDB(ctx context.Context) error { if a.db == nil { return errors.New("store not open") } ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() return a.db.Reader().PingContext(ctx) } func (a *app) close() { if a.cas != nil { if err := a.cas.Close(); err != nil { a.log.Error("closing content store", "err", err) } } if a.db != nil { if err := a.db.Close(); err != nil { a.log.Error("closing store", "err", err) } } } // middleware is the chain shared by both listeners, outermost first. // // The order is load-bearing. Recover must sit *inside* AccessLog: a panic that // unwound past AccessLog would skip its logging entirely, so the one request // that most needs a log line would be the one without one. With Recover // innermost the panic becomes an ordinary 500 return that AccessLog then records // normally, and Recover can see the shared request id and the recorder that // tells it whether a response has already begun. Panics in the middleware // itself are left to net/http, which closes the connection. func (a *app) middleware() []httpx.Middleware { return []httpx.Middleware{ httpx.WithRequestID(a.cfg.TrustedProxies()), httpx.AccessLog(a.log, a.cfg.TrustedProxies()), httpx.Recover(a.log), } } func (a *app) siteHandler() http.Handler { mux := http.NewServeMux() a.registerHealth(mux) // Site routing is hand-parsed rather than expressed as a ServeMux pattern: // "/~{project}/{path...}" is rejected by net/http, whose wildcards must start // at the beginning of a path segment. Registering "/" still gets us the mux's // built-in ".."/"//" normalisation redirects. // // Those redirects are a convenience, not a defence: a percent-encoded // "/~a/%2e%2e/%2e%2e/etc/passwd" reaches the handler with r.URL.Path already // decoded to "/~a/../../etc/passwd" and no redirect issued (verified against // this server). The resolver therefore does its own path.Clean and validation // rather than assume the mux normalised anything. mux.Handle("/", &site.Handler{Registry: a.sites, CAS: a.cas, Log: a.log}) return httpx.Chain(mux, a.middleware()...) } func (a *app) apiHandler() http.Handler { mux := http.NewServeMux() a.registerHealth(mux) a.admin.Register(mux) return httpx.Chain(mux, a.middleware()...) } // registerHealth adds the probe endpoints to a mux. They live on both listeners // so a probe can target whichever one the deployment exposes. func (a *app) registerHealth(mux *http.ServeMux) { // Liveness: answers as long as the process can schedule a goroutine. It must // never touch the database, or a slow query would get the process killed. mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") fmt.Fprintln(w, "ok") }) // Readiness: reports whether this process can serve real traffic yet. mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { status := http.StatusOK body := map[string]any{ "status": "ready", "version": version.Short(), "uptime_s": int64(time.Since(a.started).Seconds()), } if !a.ready.Load() { status = http.StatusServiceUnavailable body["status"] = "starting" } else if err := a.pingDB(r.Context()); err != nil { // Unlike /healthz this may touch the database: a process that // cannot read its own store should be taken out of rotation, not // restarted. a.log.WarnContext(r.Context(), "readiness probe: store unreachable", "err", err) status = http.StatusServiceUnavailable body["status"] = "degraded" } w.Header().Set("Cache-Control", "no-store") httpx.WriteJSON(w, status, body) }) }