The /healthz route was registered inside httpapi.Server.Routes() but the root mux only attached that handler at /auth/ and /api/, so any request to /healthz fell through to the static file server and got 404'd. Caddy's reverse-proxy and the deploy workflow's curl-based health check both hit the public origin, so the in-container healthcheck reported 'unhealthy' and CI never marked the deploy as verified. Mount /healthz on the root mux explicitly. Add a serve_test.go that asserts the same routing topology so the regression cannot return silently.
224 lines
6.2 KiB
Go
224 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"embed"
|
|
"encoding/hex"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.librete.ch/public/librenotes/internal/auth"
|
|
"git.librete.ch/public/librenotes/internal/httpapi"
|
|
"git.librete.ch/public/librenotes/internal/storage"
|
|
"git.librete.ch/public/librenotes/internal/tenant"
|
|
)
|
|
|
|
//go:embed all:web/public
|
|
var publicFS embed.FS
|
|
|
|
type serveConfig struct {
|
|
addr string
|
|
dataDir string
|
|
dbPath string
|
|
baseURL string
|
|
jwtSecret string
|
|
smtpHost string
|
|
smtpPort string
|
|
smtpUser string
|
|
smtpPass string
|
|
smtpFrom string
|
|
}
|
|
|
|
func loadConfig(args []string) (serveConfig, error) {
|
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
|
c := serveConfig{}
|
|
fs.StringVar(&c.addr, "addr", envOr("LIBRENOTES_ADDR", ":8080"), "listen address")
|
|
fs.StringVar(&c.dataDir, "data-dir", envOr("LIBRENOTES_DATA_DIR", "./data"), "directory for per-tenant note storage")
|
|
fs.StringVar(&c.dbPath, "db", envOr("LIBRENOTES_DB", "./librenotes.db"), "SQLite database path")
|
|
fs.StringVar(&c.baseURL, "base-url", envOr("LIBRENOTES_BASE_URL", "http://localhost:8080"), "public origin used in magic links")
|
|
fs.StringVar(&c.jwtSecret, "jwt-secret", os.Getenv("LIBRENOTES_JWT_SECRET"), "HMAC secret for session JWTs (>=32 bytes)")
|
|
fs.StringVar(&c.smtpHost, "smtp-host", os.Getenv("LIBRENOTES_SMTP_HOST"), "SMTP host (empty = log to stdout)")
|
|
fs.StringVar(&c.smtpPort, "smtp-port", envOr("LIBRENOTES_SMTP_PORT", "587"), "SMTP port")
|
|
fs.StringVar(&c.smtpUser, "smtp-user", os.Getenv("LIBRENOTES_SMTP_USER"), "SMTP username")
|
|
fs.StringVar(&c.smtpPass, "smtp-pass", os.Getenv("LIBRENOTES_SMTP_PASS"), "SMTP password")
|
|
fs.StringVar(&c.smtpFrom, "smtp-from", os.Getenv("LIBRENOTES_SMTP_FROM"), "envelope From address")
|
|
if err := fs.Parse(args); err != nil {
|
|
return c, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func envOr(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func runServe(args []string) error {
|
|
c, err := loadConfig(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
logger := log.New(os.Stderr, "librenotes ", log.LstdFlags|log.Lmsgprefix)
|
|
|
|
if c.jwtSecret == "" {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return fmt.Errorf("generate jwt secret: %w", err)
|
|
}
|
|
c.jwtSecret = hex.EncodeToString(buf)
|
|
logger.Printf("warning: no LIBRENOTES_JWT_SECRET set; generated ephemeral secret. Sessions will not survive restart.")
|
|
}
|
|
if len(c.jwtSecret) < 32 {
|
|
return fmt.Errorf("jwt secret must be at least 32 bytes")
|
|
}
|
|
if err := os.MkdirAll(c.dataDir, 0o700); err != nil {
|
|
return fmt.Errorf("mkdir data-dir: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(c.dbPath), 0o700); err != nil {
|
|
return fmt.Errorf("mkdir db dir: %w", err)
|
|
}
|
|
|
|
db, err := storage.Open(c.dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
users := storage.NewUserStore(db)
|
|
tokens := auth.NewTokenStore(db)
|
|
limiter := auth.NewRateLimiter(db, 15*time.Minute, 5)
|
|
signer := auth.NewSigner([]byte(c.jwtSecret))
|
|
|
|
var mailer auth.Mailer
|
|
if c.smtpHost == "" {
|
|
logger.Printf("SMTP not configured; magic links will be logged to stdout")
|
|
mailer = auth.LogMailer{W: os.Stdout}
|
|
} else {
|
|
mailer = auth.SMTPMailer{
|
|
Host: c.smtpHost, Port: c.smtpPort,
|
|
Username: c.smtpUser, Password: c.smtpPass,
|
|
From: c.smtpFrom,
|
|
}
|
|
}
|
|
|
|
authSvc, err := auth.NewService(auth.Config{
|
|
Users: users, Tokens: tokens, Limiter: limiter,
|
|
Mailer: mailer, Signer: signer,
|
|
BaseURL: c.baseURL, DataDir: c.dataDir,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Background: purge expired magic tokens every 10 minutes.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go purgeLoop(ctx, tokens, logger)
|
|
|
|
tenants := newTenantPool(c.dataDir)
|
|
defer tenants.Close()
|
|
|
|
api := &httpapi.Server{
|
|
Auth: auth.Handlers{Service: authSvc},
|
|
Signer: signer,
|
|
Logger: logger,
|
|
Notes: httpapi.NotesHandler{FSFor: tenants.FSFor},
|
|
}
|
|
|
|
root := http.NewServeMux()
|
|
apiHandler := api.Routes()
|
|
root.Handle("/auth/", apiHandler)
|
|
root.Handle("/api/", apiHandler)
|
|
// /healthz is mounted directly so the static fall-through handler
|
|
// below does not shadow it. The api.Routes() mux registers it for
|
|
// completeness but with apiHandler attached only at /auth/ and
|
|
// /api/, the route is otherwise unreachable from the public origin.
|
|
root.Handle("/healthz", apiHandler)
|
|
|
|
pub, err := fs.Sub(publicFS, "web/public")
|
|
if err != nil {
|
|
return fmt.Errorf("public fs: %w", err)
|
|
}
|
|
root.Handle("/", http.FileServer(http.FS(pub)))
|
|
|
|
srv := &http.Server{
|
|
Addr: c.addr,
|
|
Handler: withSecurityHeaders(root),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
logger.Printf("listening on %s, base URL %s, data dir %s", c.addr, c.baseURL, c.dataDir)
|
|
return srv.ListenAndServe()
|
|
}
|
|
|
|
func purgeLoop(ctx context.Context, tokens *auth.TokenStore, logger *log.Logger) {
|
|
t := time.NewTicker(10 * time.Minute)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if err := tokens.PurgeExpired(ctx, 24*time.Hour); err != nil {
|
|
logger.Printf("token purge: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// tenantPool memoises tenant.FS handles per user ID. We open the
|
|
// root once and reuse it; closing happens on shutdown.
|
|
type tenantPool struct {
|
|
dataDir string
|
|
mu sync.Mutex
|
|
by map[string]*tenant.FS
|
|
}
|
|
|
|
func newTenantPool(dataDir string) *tenantPool {
|
|
return &tenantPool{dataDir: dataDir, by: map[string]*tenant.FS{}}
|
|
}
|
|
|
|
func (p *tenantPool) FSFor(userID string) (*tenant.FS, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if fs, ok := p.by[userID]; ok {
|
|
return fs, nil
|
|
}
|
|
fs, err := tenant.Open(filepath.Join(p.dataDir, userID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.by[userID] = fs
|
|
return fs, nil
|
|
}
|
|
|
|
func (p *tenantPool) Close() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
for _, fs := range p.by {
|
|
_ = fs.Close()
|
|
}
|
|
p.by = nil
|
|
}
|
|
|
|
func withSecurityHeaders(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Content-Security-Policy",
|
|
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'")
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|