Files
pages/cmd/pages-server/main.go
T
2026-08-15 07:13:00 +00:00

407 lines
13 KiB
Go

// 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)
})
}