407 lines
13 KiB
Go
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)
|
|
})
|
|
}
|