- 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>
171 lines
4.8 KiB
Go
171 lines
4.8 KiB
Go
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()
|
|
}
|