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
}