247 lines
7.3 KiB
Go
247 lines
7.3 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/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
|
|
}
|