a73x

internal/gateclient/dial.go

Ref:   Size: 4.3 KiB   History

package gateclient

import (
	"context"
	"fmt"
	"net"
	"time"

	"golang.org/x/crypto/ssh"
)

// Credentials provides the cert-backed client signer, the CA host-key
// verifier, and the gate connect name a dialer authenticates with. *GateAuth
// satisfies it.
type Credentials interface {
	Signer(ctx context.Context) (ssh.Signer, error)
	HostKeyCallback() ssh.HostKeyCallback
	// ConnectName maps a bare VM name to its <tenant>.<name> gate connect name.
	ConnectName(ctx context.Context, vmName string) (string, error)
	// LoginUser is the guest account to log in as; it is also the principal the
	// signer's cert carries, so Dial takes the login user from here (not a
	// separate field) and the two cannot disagree.
	LoginUser() string
}

// DialConfig configures SSH access to VMs through the eitri SSH-CA jump gate.
type DialConfig struct {
	Gate string      // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host)
	Auth Credentials // minted user-cert signer + CA host verifier; also supplies the login user
}

// Dial reaches the VM named vmName through the eitri SSH-CA gate: a client
// handshake with the gate (CA-verified host cert, CA-signed user cert), a
// direct-tcpip tunnel to <tenant>.<vmName>:22 (the only port the gate permits),
// then a second handshake directly with the VM's sshd over that tunnel. Error
// messages distinguish gate-unreachable/gate-handshake from
// VM-unreachable/VM-handshake. The caller must Close the returned *ssh.Client.
func Dial(ctx context.Context, cfg DialConfig, vmName string) (*ssh.Client, error) {
	signer, err := cfg.Auth.Signer(ctx)
	if err != nil {
		return nil, fmt.Errorf("minting gate credentials: %w", err)
	}
	hostCB := cfg.Auth.HostKeyCallback()
	// The gate resolves <tenant>.<name> and each VM's host-cert principal is that
	// same namespaced name, so both the tunnel target and the VM host-cert
	// verification address use it.
	target, err := cfg.Auth.ConnectName(ctx, vmName)
	if err != nil {
		return nil, fmt.Errorf("building gate connect name: %w", err)
	}

	// Both hops share the same client config: the same CA-signed user cert
	// authenticates to the gate and to the VM, and the same callback verifies
	// both host certs against the eitri CA. The gate ignores the outer username,
	// so using the VM login user throughout is harmless. The login user comes
	// from the credentials, which is the same value the cert's principal carries.
	clientConf := &ssh.ClientConfig{
		User:            cfg.Auth.LoginUser(),
		Auth:            []ssh.AuthMethod{ssh.PublicKeys(signer)},
		HostKeyCallback: hostCB,
		Timeout:         15 * time.Second,
	}

	// Gate hop. Dial the gate verbatim and verify its host cert under the SAME
	// address: the operator sets Gate to "<gate-domain>:<port>", and the gate's
	// host cert principal is that domain, so ssh.CertChecker (in hostCB) matches.
	dialer := net.Dialer{Timeout: 15 * time.Second}
	conn, err := dialer.DialContext(ctx, "tcp", cfg.Gate)
	if err != nil {
		return nil, fmt.Errorf("gate %s unreachable: %w", cfg.Gate, err)
	}
	gnc, gchans, greqs, err := ssh.NewClientConn(conn, cfg.Gate, clientConf)
	if err != nil {
		_ = conn.Close()
		return nil, fmt.Errorf("gate %s ssh handshake: %w", cfg.Gate, err)
	}
	gateClient := ssh.NewClient(gnc, gchans, greqs)

	// VM hop. Open the direct-tcpip tunnel to <tenant>.<vmName>:22 through the
	// gate. A connection failure here is expected during the vm_create pre-sshd
	// boot window (the guest hasn't started sshd yet), and the caller retries.
	vmAddr := target + ":22"
	vmConn, err := gateClient.DialContext(ctx, "tcp", vmAddr)
	if err != nil {
		gateClient.Close()
		return nil, fmt.Errorf("vm %s unreachable through the gate: %w", vmName, err)
	}
	// Verify the VM's host cert under <tenant>.<vmName>:22: its host-cert
	// principal is that namespaced name, so ssh.CertChecker matches on the host
	// portion of this address.
	nc, chans, reqs, err := ssh.NewClientConn(vmConn, vmAddr, clientConf)
	if err != nil {
		gateClient.Close()
		return nil, fmt.Errorf("vm %s ssh handshake: %w", vmName, err)
	}
	client := ssh.NewClient(nc, chans, reqs)
	// Tie the gate client's lifetime to the VM client's: when the VM client
	// closes (or the VM drops), tear down the tunnel and the gate connection.
	go func() { _ = client.Wait(); gateClient.Close() }()
	return client, nil
}