// 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" "sync" "time" "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 local source daemon address. DockerHost string // DockerHostSet reports that DockerHost was given explicitly on the command // line. It then wins over the source remembered from the last run. DockerHostSet bool // 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 source daemon, connection store and job manager to HTTP. type Server struct { cfg Config log *slog.Logger sources *store.Sources conns *store.Connections hosts *sshx.KnownHosts jobs *job.Manager mux *http.ServeMux // srcMu guards cur, the connection to the selected source. It is opened on // first use and replaced when the operator picks another source. srcMu sync.Mutex cur *sourceConn } // 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) } 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 } local := store.Source{Name: "this host", DockerHost: cfg.DockerHost} if local.DockerHost == "" { local.DockerHost = os.Getenv("DOCKER_HOST") } sources, err := store.NewSources(filepath.Join(cfg.DataDir, "sources.json"), local) if err != nil { return nil, err } // An explicit --docker-host is an instruction for this run, so it overrides // the source remembered from the last one. if cfg.DockerHostSet { if err := sources.Select(store.LocalSourceID); err != nil { return nil, err } } s := &Server{ cfg: cfg, log: cfg.Logger, sources: sources, conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(), } s.routes() return s, nil } // Close releases the connection to the current source. func (s *Server) Close() error { s.invalidateSource("") return nil } // 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/sources", s.handleListSources) m.HandleFunc("POST /api/sources", s.handleSaveSource) m.HandleFunc("DELETE /api/sources/{id}", s.handleDeleteSource) m.HandleFunc("POST /api/sources/{id}/select", s.handleSelectSource) m.HandleFunc("POST /api/sources/{id}/probe", s.handleSourceProbe) m.HandleFunc("POST /api/sources/{id}/trust", s.handleSourceTrust) 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 }