Files
DockMV/internal/api/server.go
T
kawaandClaude Sonnet 5 34e18987a0 Rename product to dockmv (container, image, module, env vars)
Container/image/service name, Go module path, CLI binary name, and
DOCKER_MIGRATE_* env vars still used the old working name; the project
is branded DockMV everywhere else (README, logo, Gitea repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:56:26 +02:00

247 lines
7.2 KiB
Go

// 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/dockmv/internal/dkr"
"github.com/arescom/dockmv/internal/job"
"github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/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
}