phase 1: HTTP server + first-run bootstrap
P1-01 chi router, slog request log, graceful shutdown via signal
context. Health endpoint, /api/auth/login, /api/auth/logout,
/api/bootstrap. Background sweeper for expired sessions and
enrollment tokens (15 min cadence).
P1-04 (sessions half) HttpOnly Secure-when-TLS cookie carrying a
base64url token; server stores SHA-256(token) so a stolen DB
doesn't yield credentials. Unknown user and bad password collapse
to the same 401 response code so a probe can't enumerate names.
P1-05 first-run admin bootstrap. On a fresh DB the server mints a
one-time token and prints it to stderr inside a banner. The
/api/bootstrap handler accepts {token, username, password},
creates the first admin, then becomes a 409 forever.
P1-07 (partial) audit hooks fire on auth.login and auth.bootstrap.
Full middleware-driven coverage lands with the rest of the API.
internal/server/config: env > YAML > defaults. RM_LISTEN /
RM_DATA_DIR / RM_BASE_URL / RM_TLS_CERT / RM_TLS_KEY /
RM_SECRET_KEY_FILE / RM_TRUSTED_PROXY (CIDR list, validated).
End-to-end smoke test passes: server boots on a fresh dir,
prints the bootstrap token, POST /api/bootstrap creates the admin,
POST /api/auth/login returns 200 with a session cookie.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+125
-4
@@ -2,32 +2,153 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
|
||||
rmhttp "gitea.dcglab.co.uk/steve/restic-manager/internal/server/http"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("server fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", "", "path to YAML config (optional; env vars win regardless)")
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("restic-manager-server", version)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
slog.Info("config resolved", "listen", cfg.Listen, "data_dir", cfg.DataDir,
|
||||
"tls", cfg.TLSEnabled(), "trusted_proxies", cfg.TrustedProxies)
|
||||
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
return fmt.Errorf("ensure data dir: %w", err)
|
||||
}
|
||||
|
||||
// Mint or load the encryption key.
|
||||
if _, err := os.Stat(cfg.SecretKeyFile); errors.Is(err, os.ErrNotExist) {
|
||||
slog.Warn("no secret key found; generating a new one — back this up before continuing",
|
||||
"path", cfg.SecretKeyFile)
|
||||
if err := crypto.GenerateKeyFile(cfg.SecretKeyFile); err != nil {
|
||||
return fmt.Errorf("generate secret key: %w", err)
|
||||
}
|
||||
}
|
||||
keyBytes, err := crypto.LoadKeyFromFile(cfg.SecretKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load secret key: %w", err)
|
||||
}
|
||||
aead, err := crypto.NewAEAD(keyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init AEAD: %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(cfg.DataDir, "restic-manager.db")
|
||||
st, err := store.Open(context.Background(), dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
deps := rmhttp.Deps{
|
||||
Cfg: cfg,
|
||||
Store: st,
|
||||
AEAD: aead,
|
||||
}
|
||||
|
||||
// First-run bootstrap: if the users table is empty, mint a one-time
|
||||
// token and print it. /api/bootstrap accepts it to create the first
|
||||
// admin user, then becomes a no-op.
|
||||
n, err := st.CountUsers(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mint bootstrap token: %w", err)
|
||||
}
|
||||
deps.BootstrapToken = token
|
||||
// Stable, easy-to-grep marker so an operator finds this in
|
||||
// scrolling logs without spelunking. Token is shown in plain
|
||||
// text exactly once; we hash it into BootstrapToken on the
|
||||
// server-side handler.
|
||||
fmt.Fprintln(os.Stderr, "================================================================")
|
||||
fmt.Fprintln(os.Stderr, " FIRST RUN — bootstrap token (use within 1 hour, then it's gone):")
|
||||
fmt.Fprintln(os.Stderr, " "+token)
|
||||
fmt.Fprintln(os.Stderr, " POST it to /api/bootstrap with {token, username, password}.")
|
||||
fmt.Fprintln(os.Stderr, "================================================================")
|
||||
}
|
||||
|
||||
srv := rmhttp.New(deps)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
slog.Info("restic-manager server starting", "version", version)
|
||||
<-ctx.Done()
|
||||
slog.Info("shutting down")
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("server listening", "addr", cfg.Listen, "version", version)
|
||||
errCh <- srv.Start()
|
||||
}()
|
||||
|
||||
// Background sweeper for expired sessions and enrollment tokens.
|
||||
tick := time.NewTicker(15 * time.Minute)
|
||||
defer tick.Stop()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
if n, err := st.PurgeExpiredSessions(ctx); err == nil && n > 0 {
|
||||
slog.Info("purged expired sessions", "n", n)
|
||||
}
|
||||
if n, err := st.PurgeExpiredEnrollmentTokens(ctx); err == nil && n > 0 {
|
||||
slog.Info("purged expired enrollment tokens", "n", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
slog.Info("shutting down")
|
||||
}
|
||||
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutCtx); err != nil {
|
||||
return fmt.Errorf("shutdown: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user