init
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user