Initial push

This commit is contained in:
2026-08-11 09:00:01 +02:00
commit fe8b354adc
54 changed files with 12640 additions and 0 deletions
+678
View File
@@ -0,0 +1,678 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/migrate"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/arescom/docker-migrate/internal/sshx"
"github.com/arescom/docker-migrate/internal/store"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
body := map[string]any{
"ok": true,
"dockerHost": s.docker.Endpoint,
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
"authRequired": s.cfg.Token != "",
}
if v, err := s.docker.Ping(ctx); err != nil {
body["ok"] = false
body["dockerError"] = err.Error()
} else {
body["dockerVersion"] = v
}
writeJSON(w, http.StatusOK, body)
}
// handleSource returns the full inventory of the source daemon, plus the
// default selection for every container so the UI can render immediately.
func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
defaults := make(map[string]spec.ItemSelection, len(inv.Containers))
for i := range inv.Containers {
defaults[inv.Containers[i].ID] = spec.DefaultSelection(&inv.Containers[i])
}
writeJSON(w, http.StatusOK, map[string]any{
"inventory": inv,
"defaults": defaults,
"options": spec.DefaultOptions(),
})
}
// handleSourceSizes measures volume sizes, which is slow enough that the UI
// asks for it separately.
func (s *Server) handleSourceSizes(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
sizes, err := s.docker.VolumeSizes(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"volumes": sizes})
}
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.conns.List())
}
func (s *Server) handleSaveConnection(w http.ResponseWriter, r *http.Request) {
var cfg sshx.Config
if err := decode(r, &cfg); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
saved, err := s.conns.Save(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
if err := s.conns.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleProbe reads the target's host key so the operator can compare the
// fingerprint before trusting it.
func (s *Server) handleProbe(w http.ResponseWriter, r *http.Request) {
cfg, err := s.conns.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
info, err := sshx.Probe(ctx, cfg, s.hosts)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, info)
}
// handleTrust records the host key. The fingerprint the operator approved is
// echoed back and re-checked, so approving one key cannot trust another.
func (s *Server) handleTrust(w http.ResponseWriter, r *http.Request) {
cfg, err := s.conns.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
var body struct {
Fingerprint string `json:"fingerprint"`
}
if err := decode(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := sshx.TrustFromProbe(ctx, cfg, s.hosts, body.Fingerprint); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"trusted": true})
}
func (s *Server) handleTestConnection(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
if err != nil {
s.writeDialError(w, err)
return
}
defer closeFn()
pre, err := rd.Preflight(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, pre)
}
func (s *Server) handleTargetInventory(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
if err != nil {
s.writeDialError(w, err)
return
}
defer closeFn()
inv, err := rd.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, inv)
}
// PreviewItem is what the UI shows when the operator asks "what will this do?".
type PreviewItem struct {
ContainerID string `json:"containerId"`
Name string `json:"name"`
TargetName string `json:"targetName"`
Image string `json:"image"`
Commands []string `json:"commands"`
Transfers []string `json:"transfers"`
Notes []string `json:"notes"`
Warnings []string `json:"warnings"`
TotalBytes int64 `json:"totalBytes"`
}
// handlePreview renders the exact docker commands a plan would run, without
// touching either host.
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
var plan spec.Plan
if err := decode(r, &plan); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
byID := map[string]*spec.Container{}
for i := range inv.Containers {
byID[inv.Containers[i].ID] = &inv.Containers[i]
}
out := []PreviewItem{}
netSeen := map[string]bool{}
var netCmds []string
for _, sel := range plan.Items {
if !sel.Include {
continue
}
p, err := migrate.Prepare(byID[sel.ContainerID], sel, inv.Volumes, inv.Networks)
if err != nil {
writeError(w, http.StatusBadRequest, "container %s: %v", sel.ContainerID, err)
return
}
item := PreviewItem{
ContainerID: p.Source.ID,
Name: p.Source.Name,
TargetName: p.ContainerName(),
Image: p.Target.Image,
Notes: p.Notes,
Warnings: p.Source.Warnings,
}
for _, n := range p.Networks {
if !netSeen[n.Name] {
netSeen[n.Name] = true
netCmds = append(netCmds, "docker "+spec.ShellQuoteAll(n.CreateArgs()))
}
}
for _, v := range p.Volumes {
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(v.CreateArgs()))
}
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(p.Target.CreateArgs(p.Render)))
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(args))
}
for _, t := range p.Transfers {
item.Transfers = append(item.Transfers, t.Label)
item.Commands = append(item.Commands,
fmt.Sprintf("docker cp -a - %s:%s # contents of %s", p.ContainerName(), t.RestoreInto, t.SourcePath))
if t.SizeBytes > 0 {
item.TotalBytes += t.SizeBytes
}
}
if p.Selection.StartAfter {
item.Commands = append(item.Commands, "docker start "+spec.ShellQuote(p.ContainerName()))
}
out = append(out, item)
}
writeJSON(w, http.StatusOK, map[string]any{
"networkCommands": netCmds,
"items": out,
})
}
type sshMigrateRequest struct {
ConnectionID string `json:"connectionId"`
Plan spec.Plan `json:"plan"`
}
func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
var req sshMigrateRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if req.ConnectionID == "" {
writeError(w, http.StatusBadRequest, "connectionId is required")
return
}
if countIncluded(req.Plan) == 0 {
writeError(w, http.StatusBadRequest, "no containers selected")
return
}
cfg, err := s.conns.Get(req.ConnectionID)
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
// The inventory is re-read now so the plan is applied to current state
// rather than to whatever the browser last loaded.
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
cancel()
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
// Dial before the job starts so a bad credential is an immediate error in
// the UI instead of a failed job.
dialCtx, dialCancel := context.WithTimeout(r.Context(), 45*time.Second)
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
dialCancel()
if err != nil {
s.writeDialError(w, err)
return
}
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
runner := &migrate.SSHRunner{
Src: s.docker,
Dst: sshx.NewRemoteDocker(client),
Containers: inv.Containers,
Volumes: inv.Volumes,
Networks: inv.Networks,
Plan: req.Plan,
}
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
defer client.Close()
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
return runner.Run(ctx, j)
})
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
type packageRequest struct {
Plan spec.Plan `json:"plan"`
Format migrate.PackageFormat `json:"format"`
}
func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
var req packageRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if countIncluded(req.Plan) == 0 {
writeError(w, http.StatusBadRequest, "no containers selected")
return
}
if req.Format == "" {
req.Format = migrate.FormatTar
}
if req.Format != migrate.FormatTar && req.Format != migrate.FormatDir {
writeError(w, http.StatusBadRequest, "format must be %q or %q", migrate.FormatTar, migrate.FormatDir)
return
}
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
cancel()
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
packager := &migrate.Packager{
Src: s.docker,
Containers: inv.Containers,
Volumes: inv.Volumes,
Networks: inv.Networks,
Plan: req.Plan,
OutputDir: s.cfg.PackageDir,
Format: req.Format,
SourceHost: inv.Host,
}
title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
res, err := packager.Run(ctx, j)
if err != nil {
return err
}
j.Logf(job.LevelInfo, "", "package ready: %s (%s)", res.Name, humanBytes(res.Bytes))
return nil
})
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.jobs.List())
}
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
writeJSON(w, http.StatusOK, j.Snapshot())
}
// handleJobEvents streams job snapshots over server-sent events, coalescing
// rapid updates so a fast transfer does not saturate the browser.
func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
changes, unsubscribe := j.Subscribe()
defer unsubscribe()
send := func() bool {
b, err := json.Marshal(j.Snapshot())
if err != nil {
return false
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
return false
}
flusher.Flush()
return true
}
if !send() {
return
}
// Updates are batched: at most one frame every 250ms while work is busy.
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
keepalive := time.NewTicker(20 * time.Second)
defer keepalive.Stop()
dirty := false
for {
select {
case <-r.Context().Done():
return
case _, open := <-changes:
if !open {
return
}
dirty = true
case <-ticker.C:
if !dirty {
continue
}
dirty = false
if !send() {
return
}
if j.Snapshot().State.Terminal() {
fmt.Fprint(w, "event: done\ndata: {}\n\n")
flusher.Flush()
return
}
case <-keepalive.C:
fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
case <-j.Done():
// Drain one last snapshot so the client sees the final state.
send()
fmt.Fprint(w, "event: done\ndata: {}\n\n")
flusher.Flush()
return
}
}
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
j.Cancel()
writeJSON(w, http.StatusOK, map[string]any{"canceled": true})
}
func (s *Server) handleDeleteJob(w http.ResponseWriter, r *http.Request) {
if err := s.jobs.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// PackageInfo describes one built package on disk.
type PackageInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
IsDir bool `json:"isDir"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Server) handleListPackages(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.cfg.PackageDir)
if err != nil {
writeError(w, http.StatusInternalServerError, "read package directory: %v", err)
return
}
out := []PackageInfo{}
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
p := PackageInfo{
Name: e.Name(),
Path: filepath.Join(s.cfg.PackageDir, e.Name()),
IsDir: e.IsDir(),
CreatedAt: info.ModTime(),
}
if e.IsDir() {
p.Bytes, _ = dirBytes(p.Path)
} else {
p.Bytes = info.Size()
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
writeJSON(w, http.StatusOK, out)
}
// handleDownloadPackage streams a package file. Directory packages are not
// downloadable as-is; the operator picks the tar format for that.
func (s *Server) handleDownloadPackage(w http.ResponseWriter, r *http.Request) {
path, err := s.packagePath(r.PathValue("name"))
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
info, err := os.Stat(path)
if err != nil {
writeError(w, http.StatusNotFound, "package not found")
return
}
if info.IsDir() {
writeError(w, http.StatusBadRequest,
"this package is a directory; copy it from %s, or rebuild with the tar format", path)
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/x-tar")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(path)))
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
func (s *Server) handleDeletePackage(w http.ResponseWriter, r *http.Request) {
path, err := s.packagePath(r.PathValue("name"))
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if err := os.RemoveAll(path); err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// packagePath resolves a package name against the package directory, refusing
// anything that would escape it.
func (s *Server) packagePath(name string) (string, error) {
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return "", errors.New("invalid package name")
}
base, err := filepath.Abs(s.cfg.PackageDir)
if err != nil {
return "", err
}
full := filepath.Join(base, name)
if !strings.HasPrefix(full, base+string(os.PathSeparator)) {
return "", errors.New("invalid package name")
}
return full, nil
}
// dialTarget opens a short-lived connection for an interactive request.
func (s *Server) dialTarget(ctx context.Context, id string) (*sshx.RemoteDocker, func(), error) {
cfg, err := s.conns.Get(id)
if err != nil {
return nil, nil, err
}
client, err := sshx.Dial(ctx, cfg, s.hosts)
if err != nil {
return nil, nil, err
}
return sshx.NewRemoteDocker(client), func() { client.Close() }, nil
}
// writeDialError turns an untrusted host key into a structured response the UI
// can turn into a trust prompt.
func (s *Server) writeDialError(w http.ResponseWriter, err error) {
var hk *sshx.HostKeyError
if errors.As(err, &hk) {
writeJSON(w, http.StatusPreconditionRequired, map[string]any{
"error": err.Error(),
"hostKey": hk.Fingerprint,
"keyType": hk.KeyType,
"changed": hk.Changed,
"needsTrust": true,
"host": hk.Host,
})
return
}
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "%v", err)
return
}
writeError(w, http.StatusBadGateway, "%v", err)
}
func countIncluded(p spec.Plan) int {
n := 0
for _, i := range p.Items {
if i.Include {
n++
}
}
return n
}
func dirBytes(dir string) (int64, error) {
var total int64
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total, err
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
var _ = dkr.RestorePath
+246
View File
@@ -0,0 +1,246 @@
// Package api exposes the migration tool over HTTP and serves the web UI.
package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/sshx"
"github.com/arescom/docker-migrate/internal/store"
)
// Config configures the HTTP server.
type Config struct {
// Addr is the listen address, e.g. 127.0.0.1:8080.
Addr string
// Token, when set, must be presented on every API request.
Token string
// DataDir holds connections and the known-hosts file.
DataDir string
// PackageDir is where migration packages are written.
PackageDir string
// DockerHost overrides the source daemon address.
DockerHost string
// UI is the embedded web app; nil disables the UI.
UI fs.FS
// Logger receives request and error logs.
Logger *slog.Logger
}
// Server ties the Docker client, connection store and job manager to HTTP.
type Server struct {
cfg Config
log *slog.Logger
docker *dkr.Client
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
mux *http.ServeMux
}
// New builds the server and everything it owns.
func New(cfg Config) (*Server, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
if cfg.PackageDir == "" {
cfg.PackageDir = filepath.Join(cfg.DataDir, "packages")
}
if err := os.MkdirAll(cfg.PackageDir, 0o755); err != nil {
return nil, fmt.Errorf("create package directory: %w", err)
}
docker, err := dkr.New(cfg.DockerHost)
if err != nil {
return nil, err
}
conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
if err != nil {
return nil, err
}
hosts, err := sshx.NewKnownHosts(filepath.Join(cfg.DataDir, "known_hosts"))
if err != nil {
return nil, err
}
s := &Server{
cfg: cfg, log: cfg.Logger, docker: docker,
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
}
s.routes()
return s, nil
}
// Close releases the Docker connection.
func (s *Server) Close() error { return s.docker.Close() }
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.logging(s.auth(s.mux)))
}
func (s *Server) routes() {
m := s.mux
m.HandleFunc("GET /api/health", s.handleHealth)
m.HandleFunc("GET /api/source", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
m.HandleFunc("GET /api/connections", s.handleListConnections)
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
m.HandleFunc("POST /api/connections/{id}/probe", s.handleProbe)
m.HandleFunc("POST /api/connections/{id}/trust", s.handleTrust)
m.HandleFunc("POST /api/connections/{id}/test", s.handleTestConnection)
m.HandleFunc("GET /api/connections/{id}/inventory", s.handleTargetInventory)
m.HandleFunc("POST /api/plan/preview", s.handlePreview)
m.HandleFunc("POST /api/migrate/ssh", s.handleMigrateSSH)
m.HandleFunc("POST /api/migrate/package", s.handleBuildPackage)
m.HandleFunc("GET /api/jobs", s.handleListJobs)
m.HandleFunc("GET /api/jobs/{id}", s.handleGetJob)
m.HandleFunc("GET /api/jobs/{id}/events", s.handleJobEvents)
m.HandleFunc("POST /api/jobs/{id}/cancel", s.handleCancelJob)
m.HandleFunc("DELETE /api/jobs/{id}", s.handleDeleteJob)
m.HandleFunc("GET /api/packages", s.handleListPackages)
m.HandleFunc("GET /api/packages/{name}/download", s.handleDownloadPackage)
m.HandleFunc("DELETE /api/packages/{name}", s.handleDeletePackage)
if s.cfg.UI != nil {
m.Handle("/", s.spaHandler())
}
}
// auth enforces the shared token on the API. The token may also be passed as a
// query parameter, because EventSource cannot set headers and neither can a
// download link.
//
// Static assets are deliberately served without it. A browser opening
// /?token=… does not carry the query string over to /assets/app.js, so gating
// the shell would leave the UI unable to boot. Nothing sensitive lives in the
// bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
if s.cfg.Token == "" {
return next
}
want := []byte(s.cfg.Token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-Auth-Token")
if got == "" {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got = strings.TrimPrefix(h, "Bearer ")
}
}
if got == "" {
got = r.URL.Query().Get("token")
}
if subtle.ConstantTimeCompare([]byte(got), want) != 1 {
writeError(w, http.StatusUnauthorized, "invalid or missing token")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
if strings.HasPrefix(r.URL.Path, "/api/") {
s.log.Debug("request", "method", r.Method, "path", r.URL.Path,
"status", sw.status, "duration", time.Since(start).Round(time.Millisecond))
}
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic serving request", "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
// Flush forwards to the wrapped writer so server-sent events keep streaming.
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// spaHandler serves the built web app, falling back to index.html so client
// side routing works on a hard refresh.
func (s *Server) spaHandler() http.Handler {
files := http.FileServer(http.FS(s.cfg.UI))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(s.cfg.UI, p); err != nil {
r = r.Clone(r.Context())
r.URL.Path = "/"
w.Header().Set("Cache-Control", "no-store")
} else if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
files.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
// The response is already partially written; nothing useful is left to do.
return
}
}
type errorBody struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, code int, format string, args ...any) {
writeJSON(w, code, errorBody{Error: fmt.Sprintf(format, args...)})
}
func decode(r *http.Request, v any) error {
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 8<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}