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:
2026-08-13 10:19:47 +02:00
co-authored by Claude Haiku 4.5
parent 05db8bfeb9
commit 9b354636bb
19 changed files with 1857 additions and 174 deletions
+170
View File
@@ -0,0 +1,170 @@
package sshx
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// DialAPI opens one connection to the remote daemon's API by running
// `docker system dial-stdio` over SSH and treating that session's stdin and
// stdout as a socket. It is the same mechanism `docker -H ssh://…` uses, so the
// remote host still needs nothing but sshd and the docker CLI.
//
// The returned connection is what dkr.NewTunnel dials through: from there on the
// whole Docker Engine API — inventory, archive streams, image save — is
// available on a remote source host exactly as it is on a local one.
func (r *RemoteDocker) DialAPI(ctx context.Context) (net.Conn, error) {
sess, err := r.c.conn.NewSession()
if err != nil {
return nil, fmt.Errorf("open ssh session: %w", err)
}
stdin, err := sess.StdinPipe()
if err != nil {
sess.Close()
return nil, fmt.Errorf("attach to remote stdin: %w", err)
}
stdout, err := sess.StdoutPipe()
if err != nil {
sess.Close()
return nil, fmt.Errorf("attach to remote stdout: %w", err)
}
errBuf := &syncBuffer{}
sess.Stderr = errBuf
cmd := r.Cmd("system", "dial-stdio")
if err := sess.Start(cmd); err != nil {
sess.Close()
return nil, fmt.Errorf("start %q: %w", cmd, err)
}
cfg := r.c.Config()
// The connection deliberately outlives ctx: the HTTP transport keeps it in
// its idle pool between API calls, and closes it itself when a request is
// cancelled or the client is closed.
return &apiConn{
sess: sess, stdin: stdin, stdout: stdout, stderr: errBuf,
remote: apiAddr(fmt.Sprintf("%s@%s", cfg.User, cfg.addr())),
}, nil
}
// ProbeCLI checks that the remote docker CLI is usable before the API is
// tunnelled through it, so a missing binary or a permission problem is reported
// as itself rather than as a broken socket. It returns the daemon version.
func (r *RemoteDocker) ProbeCLI(ctx context.Context) (string, error) {
out, ok, err := r.Try(ctx, "version", "--format", "{{.Server.Version}}")
if err != nil {
return "", err
}
if ok {
if v := strings.TrimSpace(out); v != "" {
return v, nil
}
}
res, err := r.c.Run(ctx, r.Cmd("version"))
if err != nil {
return "", err
}
msg := strings.TrimSpace(res.Stderr)
switch {
case strings.Contains(msg, "permission denied"):
return "", errors.New("the login user cannot talk to the docker daemon; " +
"add it to the docker group, or enable sudo -n for this source")
case msg != "":
return "", errors.New("docker is not usable on that host: " + firstLine(msg))
default:
return "", errors.New("docker is not installed or not on PATH on that host")
}
}
// apiConn adapts an SSH session to net.Conn.
type apiConn struct {
sess *ssh.Session
stdin io.WriteCloser
stdout io.Reader
stderr *syncBuffer
remote apiAddr
once sync.Once
err error
}
func (c *apiConn) Read(p []byte) (int, error) {
n, err := c.stdout.Read(p)
if err != nil {
return n, c.wrap(err)
}
return n, nil
}
func (c *apiConn) Write(p []byte) (int, error) {
n, err := c.stdin.Write(p)
if err != nil {
return n, c.wrap(err)
}
return n, nil
}
// wrap replaces the bare EOF a failed remote command produces with whatever it
// printed on stderr, which is the only place the reason appears.
func (c *apiConn) wrap(err error) error {
if msg := strings.TrimSpace(c.stderr.String()); msg != "" {
return fmt.Errorf("docker system dial-stdio on the remote host failed: %s", firstLine(msg))
}
return err
}
func (c *apiConn) Close() error {
c.once.Do(func() {
// Closing stdin lets the remote docker exit cleanly; the session is torn
// down straight after either way.
_ = c.stdin.Close()
c.err = c.sess.Close()
if errors.Is(c.err, io.EOF) {
c.err = nil
}
})
return c.err
}
func (c *apiConn) LocalAddr() net.Addr { return apiAddr("dockmv") }
func (c *apiConn) RemoteAddr() net.Addr { return c.remote }
// The deadline calls are no-ops: an SSH channel has no deadline of its own, and
// the Docker client relies on context cancellation rather than on these. This
// mirrors what the docker CLI's own ssh:// transport does.
func (c *apiConn) SetDeadline(time.Time) error { return nil }
func (c *apiConn) SetReadDeadline(time.Time) error { return nil }
func (c *apiConn) SetWriteDeadline(time.Time) error { return nil }
type apiAddr string
func (a apiAddr) Network() string { return "ssh" }
func (a apiAddr) String() string { return string(a) }
// syncBuffer collects remote stderr, which the ssh session writes from its own
// goroutine while the connection is being read.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
+79
View File
@@ -0,0 +1,79 @@
package sshx
import (
"errors"
"io"
"net"
"strings"
"testing"
"time"
)
// TestDialAPICommand pins the command the tunnel runs on the source host: it is
// the same one `docker -H ssh://…` uses, and the sudo / custom binary settings
// have to reach it.
func TestDialAPICommand(t *testing.T) {
for _, tc := range []struct {
name string
rd *RemoteDocker
want string
}{
{"plain", &RemoteDocker{binary: "docker"}, "docker system dial-stdio"},
{"sudo", &RemoteDocker{binary: "docker", sudo: true}, "sudo -n docker system dial-stdio"},
{"podman", &RemoteDocker{binary: "podman"}, "podman system dial-stdio"},
{"path with a space", &RemoteDocker{binary: "/opt/my docker/bin/docker"}, "'/opt/my docker/bin/docker' system dial-stdio"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := tc.rd.Cmd("system", "dial-stdio"); got != tc.want {
t.Fatalf("command = %q, want %q", got, tc.want)
}
})
}
}
// TestAPIConnSurfacesRemoteStderr covers the failure that would otherwise reach
// the Docker client as a bare EOF: the remote docker printing a reason and
// exiting.
func TestAPIConnSurfacesRemoteStderr(t *testing.T) {
errBuf := &syncBuffer{}
errBuf.Write([]byte("docker: 'system dial-stdio' is not a docker command\n"))
c := &apiConn{
stdout: strings.NewReader(""),
stdin: nopWriteCloser{io.Discard},
stderr: errBuf,
}
_, err := c.Read(make([]byte, 8))
if err == nil {
t.Fatal("a closed stream with remote stderr should be an error")
}
if !strings.Contains(err.Error(), "is not a docker command") {
t.Fatalf("error = %v, want the remote stderr in it", err)
}
// Without stderr the plain EOF must survive, or the HTTP transport cannot
// tell a finished response from a broken one.
quiet := &apiConn{stdout: strings.NewReader(""), stdin: nopWriteCloser{io.Discard}, stderr: &syncBuffer{}}
if _, err := quiet.Read(make([]byte, 8)); !errors.Is(err, io.EOF) {
t.Fatalf("error = %v, want io.EOF", err)
}
}
func TestAPIConnDeadlinesAreNoops(t *testing.T) {
var c net.Conn = &apiConn{stdout: strings.NewReader(""), stdin: nopWriteCloser{io.Discard}, stderr: &syncBuffer{}}
now := time.Now()
if err := c.SetDeadline(now); err != nil {
t.Fatalf("SetDeadline: %v", err)
}
if err := c.SetReadDeadline(now); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
if err := c.SetWriteDeadline(now); err != nil {
t.Fatalf("SetWriteDeadline: %v", err)
}
if c.RemoteAddr().Network() != "ssh" {
t.Fatalf("network = %q, want ssh", c.RemoteAddr().Network())
}
}
type nopWriteCloser struct{ io.Writer }
func (nopWriteCloser) Close() error { return nil }