Add SSH source support with dialstdio and UI components
- Implement SSH dial via stdio for remote connections - Add sources API and storage layer for managing connection sources - Add SourcePanel and SshFields web components for SSH configuration - Update app structure to support source-based connections - Update handlers and server for new sources endpoint Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+76
-14
@@ -21,23 +21,39 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*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 {
|
||||
|
||||
// Health is also what connects to the selected source on a fresh start, so
|
||||
// the UI learns straight away when the remembered source is unreachable.
|
||||
conn, release, err := s.source(ctx)
|
||||
if err != nil {
|
||||
selected, _ := s.sources.Get(s.sources.Selected())
|
||||
body["ok"] = false
|
||||
body["dockerError"] = err.Error()
|
||||
} else {
|
||||
body["dockerVersion"] = v
|
||||
endpoint := sourceEndpoint(selected)
|
||||
body["dockerHost"] = endpoint
|
||||
body["source"] = sourceStatus{
|
||||
ID: selected.ID, Name: selected.Name, Kind: selected.Kind,
|
||||
Endpoint: endpoint, Error: err.Error(),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
st := conn.status()
|
||||
body["dockerHost"] = st.Endpoint
|
||||
body["dockerVersion"] = st.DockerVersion
|
||||
body["source"] = st
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
@@ -47,7 +63,14 @@ 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)
|
||||
conn, release, err := s.source(ctx)
|
||||
if err != nil {
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
inv, err := conn.docker.Inventory(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
@@ -69,7 +92,14 @@ 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)
|
||||
conn, release, err := s.source(ctx)
|
||||
if err != nil {
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
sizes, err := conn.docker.VolumeSizes(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
@@ -209,7 +239,14 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
inv, err := s.docker.Inventory(ctx)
|
||||
conn, release, err := s.source(ctx)
|
||||
if err != nil {
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
inv, err := conn.docker.Inventory(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
@@ -299,12 +336,23 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The source is held for the whole job: picking another source in the UI
|
||||
// while this runs must not close the socket it is reading from.
|
||||
srcCtx, srcCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
src, release, err := s.source(srcCtx)
|
||||
srcCancel()
|
||||
if err != nil {
|
||||
s.writeDialError(w, 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)
|
||||
inv, err := src.docker.Inventory(invCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
release()
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
@@ -315,13 +363,14 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
|
||||
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
|
||||
dialCancel()
|
||||
if err != nil {
|
||||
release()
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
|
||||
title := fmt.Sprintf("%d container(s) from %s to %s", countIncluded(req.Plan), src.src.Name, cfg.Name)
|
||||
runner := &migrate.SSHRunner{
|
||||
Src: s.docker,
|
||||
Src: src.docker,
|
||||
Dst: sshx.NewRemoteDocker(client),
|
||||
Containers: inv.Containers,
|
||||
Volumes: inv.Volumes,
|
||||
@@ -331,8 +380,10 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
|
||||
func(ctx context.Context, j *job.Job) error {
|
||||
defer release()
|
||||
defer client.Close()
|
||||
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
|
||||
j.Logf(job.LevelInfo, "", "migrating from %s to %s@%s over ssh",
|
||||
src.docker.Endpoint, cfg.User, cfg.Host)
|
||||
return runner.Run(ctx, j)
|
||||
})
|
||||
|
||||
@@ -362,16 +413,25 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
srcCtx, srcCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
src, release, err := s.source(srcCtx)
|
||||
srcCancel()
|
||||
if err != nil {
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
inv, err := s.docker.Inventory(invCtx)
|
||||
inv, err := src.docker.Inventory(invCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
release()
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
packager := &migrate.Packager{
|
||||
Src: s.docker,
|
||||
Src: src.docker,
|
||||
Containers: inv.Containers,
|
||||
Volumes: inv.Volumes,
|
||||
Networks: inv.Networks,
|
||||
@@ -384,6 +444,8 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
defer release()
|
||||
j.Logf(job.LevelInfo, "", "reading from %s", src.docker.Endpoint)
|
||||
res, err := packager.Run(ctx, j)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+46
-17
@@ -11,9 +11,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
@@ -29,23 +29,31 @@ type Config struct {
|
||||
DataDir string
|
||||
// PackageDir is where migration packages are written.
|
||||
PackageDir string
|
||||
// DockerHost overrides the source daemon address.
|
||||
// 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 Docker client, connection store and job manager to HTTP.
|
||||
// Server ties the source daemon, 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
|
||||
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.
|
||||
@@ -60,10 +68,6 @@ func New(cfg Config) (*Server, error) {
|
||||
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
|
||||
@@ -72,17 +76,35 @@ func New(cfg Config) (*Server, error) {
|
||||
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, docker: docker,
|
||||
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 Docker connection.
|
||||
func (s *Server) Close() error { return s.docker.Close() }
|
||||
// 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 {
|
||||
@@ -96,6 +118,13 @@ func (s *Server) routes() {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/arescom/dockmv/internal/dkr"
|
||||
"github.com/arescom/dockmv/internal/sshx"
|
||||
"github.com/arescom/dockmv/internal/store"
|
||||
)
|
||||
|
||||
// sourceConn is a live connection to one source daemon.
|
||||
//
|
||||
// It is reference counted rather than closed eagerly: a migration holds its
|
||||
// source for as long as it runs, so switching source in the UI half way through
|
||||
// a transfer must not pull the socket out from under it. The connection is
|
||||
// closed once it is both replaced and unused.
|
||||
type sourceConn struct {
|
||||
src store.Source
|
||||
docker *dkr.Client
|
||||
ssh *sshx.Client
|
||||
version string
|
||||
|
||||
refs int
|
||||
stale bool
|
||||
}
|
||||
|
||||
func (c *sourceConn) close() {
|
||||
if c.docker != nil {
|
||||
_ = c.docker.Close()
|
||||
}
|
||||
if c.ssh != nil {
|
||||
_ = c.ssh.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// sourceStatus is what the UI shows about the source it is looking at.
|
||||
type sourceStatus struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind store.SourceKind `json:"kind"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
DockerVersion string `json:"dockerVersion,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *sourceConn) status() sourceStatus {
|
||||
return sourceStatus{
|
||||
ID: c.src.ID, Name: c.src.Name, Kind: c.src.Kind,
|
||||
Endpoint: c.docker.Endpoint, DockerVersion: c.version, Connected: true,
|
||||
}
|
||||
}
|
||||
|
||||
// source returns the current source, connecting to it on first use. The
|
||||
// returned release function must be called when the caller is done with it —
|
||||
// for a job, when the job finishes.
|
||||
func (s *Server) source(ctx context.Context) (*sourceConn, func(), error) {
|
||||
s.srcMu.Lock()
|
||||
if cur := s.cur; cur != nil {
|
||||
cur.refs++
|
||||
s.srcMu.Unlock()
|
||||
return cur, func() { s.releaseSource(cur) }, nil
|
||||
}
|
||||
id := s.sources.Selected()
|
||||
s.srcMu.Unlock()
|
||||
|
||||
conn, err := s.dialSource(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.srcMu.Lock()
|
||||
defer s.srcMu.Unlock()
|
||||
// Another request may have connected while this one was dialling; one
|
||||
// connection is enough, so the loser is dropped.
|
||||
if s.cur != nil {
|
||||
conn.close()
|
||||
conn = s.cur
|
||||
} else {
|
||||
s.cur = conn
|
||||
}
|
||||
conn.refs++
|
||||
return conn, func() { s.releaseSource(conn) }, nil
|
||||
}
|
||||
|
||||
func (s *Server) releaseSource(c *sourceConn) {
|
||||
s.srcMu.Lock()
|
||||
defer s.srcMu.Unlock()
|
||||
c.refs--
|
||||
if c.refs <= 0 && c.stale {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
// selectSource connects to a source and, once that worked, makes it the current
|
||||
// one and records the choice for the next run.
|
||||
func (s *Server) selectSource(ctx context.Context, id string) (sourceStatus, error) {
|
||||
conn, err := s.dialSource(ctx, id)
|
||||
if err != nil {
|
||||
return sourceStatus{}, err
|
||||
}
|
||||
st := conn.status()
|
||||
|
||||
s.srcMu.Lock()
|
||||
old := s.cur
|
||||
s.cur = conn
|
||||
if old != nil {
|
||||
old.stale = true
|
||||
if old.refs <= 0 {
|
||||
old.close()
|
||||
}
|
||||
}
|
||||
s.srcMu.Unlock()
|
||||
|
||||
if err := s.sources.Select(conn.src.ID); err != nil {
|
||||
return st, fmt.Errorf("remember the selected source: %w", err)
|
||||
}
|
||||
s.log.Info("source selected", "id", conn.src.ID, "endpoint", conn.docker.Endpoint)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// invalidateSource drops the cached connection when the source behind it has
|
||||
// been edited or removed. An empty id invalidates whatever is current.
|
||||
func (s *Server) invalidateSource(id string) {
|
||||
s.srcMu.Lock()
|
||||
defer s.srcMu.Unlock()
|
||||
if s.cur == nil {
|
||||
return
|
||||
}
|
||||
if id != "" && s.cur.src.ID != id {
|
||||
return
|
||||
}
|
||||
s.cur.stale = true
|
||||
if s.cur.refs <= 0 {
|
||||
s.cur.close()
|
||||
}
|
||||
s.cur = nil
|
||||
}
|
||||
|
||||
// dialSource opens a connection to one source and verifies the daemon answers.
|
||||
func (s *Server) dialSource(ctx context.Context, id string) (*sourceConn, error) {
|
||||
src, err := s.sources.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn := &sourceConn{src: src}
|
||||
switch src.Kind {
|
||||
case store.SourceLocal, store.SourceDocker:
|
||||
c, err := dkr.New(src.DockerHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.docker = c
|
||||
case store.SourceSSH:
|
||||
if src.SSH == nil {
|
||||
return nil, errors.New("source has no ssh configuration")
|
||||
}
|
||||
client, err := sshx.Dial(ctx, *src.SSH, s.hosts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rd := sshx.NewRemoteDocker(client)
|
||||
// The CLI is checked first: a missing binary or a user outside the
|
||||
// docker group is a readable error here, and an unexplained broken
|
||||
// socket if it is left to the tunnel.
|
||||
if _, err := rd.ProbeCLI(ctx); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
c, err := dkr.NewTunnel(sourceEndpoint(src), func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return rd.DialAPI(ctx)
|
||||
})
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
conn.ssh, conn.docker = client, c
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown source kind %q", src.Kind)
|
||||
}
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
version, err := conn.docker.Ping(pingCtx)
|
||||
if err != nil {
|
||||
conn.close()
|
||||
return nil, fmt.Errorf("connect to docker at %s: %w", conn.docker.Endpoint, err)
|
||||
}
|
||||
conn.version = version
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// sourceEndpoint describes where a source lives, before and after it is
|
||||
// connected to.
|
||||
func sourceEndpoint(src store.Source) string {
|
||||
if src.Kind == store.SourceSSH && src.SSH != nil {
|
||||
port := src.SSH.Port
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
return fmt.Sprintf("ssh://%s@%s", src.SSH.User, net.JoinHostPort(src.SSH.Host, strconv.Itoa(port)))
|
||||
}
|
||||
return src.DockerHost
|
||||
}
|
||||
|
||||
// handleListSources lists the sources without connecting to any of them; the
|
||||
// state of the current one comes from /api/health.
|
||||
func (s *Server) handleListSources(w http.ResponseWriter, r *http.Request) {
|
||||
s.srcMu.Lock()
|
||||
var current *sourceStatus
|
||||
if s.cur != nil {
|
||||
st := s.cur.status()
|
||||
current = &st
|
||||
}
|
||||
s.srcMu.Unlock()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"sources": s.sources.List(),
|
||||
"selected": s.sources.Selected(),
|
||||
"current": current,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveSource(w http.ResponseWriter, r *http.Request) {
|
||||
var src store.Source
|
||||
if err := decode(r, &src); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
saved, err := s.sources.Save(src)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
// Editing the source in use means the live connection describes the old
|
||||
// settings; drop it so the next call reconnects.
|
||||
s.invalidateSource(saved.ID)
|
||||
writeJSON(w, http.StatusOK, saved)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.sources.Delete(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
s.invalidateSource(id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSelectSource switches the source the whole UI works against.
|
||||
func (s *Server) handleSelectSource(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
st, err := s.selectSource(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// handleSourceProbe reads the SSH host key of a source host, so its fingerprint
|
||||
// can be approved the same way a target's is.
|
||||
func (s *Server) handleSourceProbe(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.sourceSSH(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)
|
||||
}
|
||||
|
||||
func (s *Server) handleSourceTrust(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.sourceSSH(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})
|
||||
}
|
||||
|
||||
// sourceSSH returns the SSH configuration of a source, refusing the ones that
|
||||
// are not reached over SSH.
|
||||
func (s *Server) sourceSSH(id string) (sshx.Config, error) {
|
||||
src, err := s.sources.Get(id)
|
||||
if err != nil {
|
||||
return sshx.Config{}, err
|
||||
}
|
||||
if src.Kind != store.SourceSSH || src.SSH == nil {
|
||||
return sshx.Config{}, fmt.Errorf("source %s is not reached over ssh", src.Name)
|
||||
}
|
||||
return *src.SSH, nil
|
||||
}
|
||||
Reference in New Issue
Block a user