a73x

internal/server/sshgate/gate.go

Ref:   Size: 19.0 KiB   History

// Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that
// admins reach with `ssh -J gate ubuntu@<tenant>.<vm>`. It authenticates users by short-
// lived certificates signed by the eitri user CA and permits exactly one thing —
// a `direct-tcpip` tunnel to `<vm>:22`, forwarded to the VM's host over the sync
// connection. Every other SSH surface is refused: no sessions/shells/exec (which
// would be code-exec on eitri-server), no `tcpip-forward`/`-R` (which would make
// the bastion an open ingress relay), no ports other than 22.
//
// Key material is never logged.
package sshgate

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"net"
	"strconv"
	"strings"
	"time"

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

// Resolver maps a bare VM name WITHIN tenant to its host and VM IDs. ok=false
// ⇒ unknown; the channel is rejected. Resolution is NOT an authorization
// boundary — authorize is; but it IS tenant-scoped, so names never resolve
// across tenants.
type Resolver func(tenant, name string) (hostID, vmID string, ok bool)

// Authorizer reports whether a connection belonging to tenant may reach vmID.
// The tenant is derived from the cert's signing CA at auth time (the ONLY
// authenticated identity on the connection — the cert principal is just the
// guest login user). Must fail closed.
//
// HONEST SCOPE (spec §Gate): with one CA every connection maps to the same
// tenant, so this check separates tenants logically, not cryptographically.
// The crypto boundary is the CA split — a recorded tenant-#2 blocker.
type Authorizer func(tenant, vmID string) bool

// Dialer opens a raw byte pipe to vmID:port on hostID (wired to syncsvc.OpenTCP).
type Dialer func(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error)

// tenantExt is the Permissions.Extensions key carrying the connection's
// tenant from auth to the channel handlers. Per-connection by construction —
// a startup-captured tenant would have the wrong lifetime (review finding).
const tenantExt = "tenant"

// directTCPIP is the wire payload of an SSH `direct-tcpip` channel-open.
type directTCPIP struct {
	HostToConnect  string
	PortToConnect  uint32
	OriginatorIP   string
	OriginatorPort uint32
}

// Gate is a hardened SSH bastion. Construct with New and run with Serve.
type Gate struct {
	cfg       *ssh.ServerConfig
	resolve   Resolver
	authorize Authorizer
	dial      Dialer
	audit     Audit
	// starting bounds how many connections may sit in the UNAUTHENTICATED
	// handshake at once — sshd's MaxStartups. See maxStartups.
	starting chan struct{}
}

// Revoker reports whether the user cert bearing serial has been revoked. It is
// consulted on every cert authentication and MUST fail closed: a nil Revoker is
// treated as "nothing revoked", but the wired implementation (main) returns true
// on a store error so a DB hiccup rejects the single connection rather than
// silently letting a possibly-revoked cert through.
//
// It is asked about a serial WITHIN a tenant: a certificate is revoked by the
// tenant whose CA signed it, and only for that tenant. Answering fleet-wide
// would let any tenant deny a serial it has no claim to, and nothing can
// attribute a bare serial back to its owner — CAs are BYO, so eitri never sees
// the certificates they mint.
//
// SCOPE: revocation is enforced at the GATE ONLY. VM guests trust the CA
// (TrustedUserCAKeys) with NO guest-side KRL, so a revoked cert would still be
// accepted by a VM's sshd if a client reached it directly. That is fine for
// single-user — VMs are reachable ONLY via this gate.
// Guest-side KRL distribution is a multi-user/rotation follow-up (the deferred
// CA-rotation-push problem).
type Revoker func(tenant string, serial uint64) bool

// UserCALookup resolves a cert signature key to the tenant that registered it.
// ok=false ⇒ the CA is not a registered tenant user CA ⇒ reject the cert.
type UserCALookup func(sig ssh.PublicKey) (tenant string, ok bool)

// Audit records one gate event. It takes the same (tenant, action, detail)
// shape the API's audit trail already uses, so gate rows read like every other
// row rather than like a second scheme.
//
// A tenant of "" means the event has no authenticated tenant yet — a refused
// login — and the caller files it under whatever scope it uses for unattributed
// events. The gate is a leaf and does not know that name.
//
// It MUST NOT block: it is called on the connection's own goroutine, between a
// client and its tunnel, and the store behind the real implementation serializes
// every write onto one connection shared with the rest of the plane. An
// implementation that writes synchronously will stall logins under exactly the
// load that makes the trail interesting. A nil Audit disables gate auditing.
type Audit func(tenant, action string, detail map[string]string)

// Extension keys carrying the authenticated cert's identity from the auth
// callback to the handlers that audit it. Per-connection by construction, for
// the same reason tenantExt is.
const (
	serialExt = "cert_serial"
	keyIDExt  = "cert_key_id"
)

// New builds a Gate that presents hostKey, trusts every certificate signed by a
// CA that userCAs resolves to a tenant, resolves VM names with resolve, gates
// them with authorize, rejects certs isRevoked flags, and tunnels through dial.
// A nil isRevoked disables revocation checks (nothing is revoked).
//
// The connection's tenant is stamped from WHICH registered CA signed the cert
// (userCAs), making the downstream cert.tenant == vm.tenant authz a real
// cryptographic boundary — a cert can only ever carry the tenant of the CA that
// signed it.
func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker, audit Audit) *Gate {
	checker := &ssh.CertChecker{
		IsUserAuthority: func(auth ssh.PublicKey) bool { _, ok := userCAs(auth); return ok },
		// `source-address` is the ONE critical option this gate accepts, and it
		// has to be named here because we call CheckCert directly rather than
		// going through CertChecker.Authenticate (see the principal argument
		// below for why). Authenticate is what exempts source-address on a
		// caller's behalf — it clones the checker and appends this same
		// constant — so a direct CheckCert caller that names nothing rejects
		// every source-address cert as an unsupported option.
		//
		// Naming it does NOT enforce it. CheckCert only decides whether an
		// option is understood; the enforcement happens in x/crypto's
		// serverAuthenticate, against the address the connection actually came
		// from, read from the Permissions returned below. Listing exactly this
		// one keeps the gate's promise intact: an option it cannot honour is
		// still a promise it must not accept (TestGateRejectsUnknownCriticalOption).
		SupportedCriticalOptions: []string{"source-address"},
	}
	// CheckCert consults IsRevoked during validation: a true result fails the
	// cert authentication outright, so a revoked cert cannot open the tunnel.
	//
	// The tenant asked about is the one that registered the signing CA — the same
	// derivation the connection's identity uses, so a certificate is only ever
	// measured against its own tenant's revocations. A signing key that resolves
	// to no tenant is treated as revoked: the auth below refuses it anyway, and
	// answering "not revoked" for a CA we do not know would be the wrong default
	// to leave lying around.
	if isRevoked != nil {
		checker.IsRevoked = func(cert *ssh.Certificate) bool {
			tenant, ok := userCAs(cert.SignatureKey)
			if !ok {
				return true
			}
			return isRevoked(tenant, cert.Serial)
		}
	}
	cfg := &ssh.ServerConfig{
		PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
			cert, ok := key.(*ssh.Certificate)
			if !ok {
				return nil, errors.New("sshgate: only certificate authentication is accepted")
			}
			if cert.CertType != ssh.UserCert {
				return nil, errors.New("sshgate: not a user certificate")
			}
			// The tenant is derived from which registered CA signed the cert. An
			// unregistered signing key resolves to ok=false and is rejected before
			// any further validation.
			tenant, ok := userCAs(cert.SignatureKey)
			if !ok {
				return nil, errors.New("sshgate: certificate not signed by a registered tenant CA")
			}
			// A cert with an empty principal set is, per CheckCert's rules, valid
			// for ANY principal — a wildcard. eitri always mints exactly one
			// principal (`ubuntu`), so an empty set is a malformed/over-broad cert:
			// reject it outright. This also removes the ValidPrincipals[0]
			// out-of-range panic below.
			if len(cert.ValidPrincipals) == 0 {
				return nil, errors.New("sshgate: certificate has no principals")
			}
			// Validate revocation / critical options / validity window / signature
			// WITHOUT binding the cert's principals to the outer SSH username. A jump
			// user connects as `ssh -J gate ubuntu@vm` with an arbitrary local
			// username on this outer hop, so CertChecker.Authenticate's
			// principal-against-conn.User() check would wrongly force `ssh -J
			// ubuntu@gate`. Instead we feed CheckCert one of the cert's own principals
			// (a tautology) so only CA-signature + validity gate authentication; the
			// verified principal is then captured for per-channel authz.
			if err := checker.CheckCert(cert.ValidPrincipals[0], cert); err != nil {
				return nil, err
			}
			return &ssh.Permissions{
				// The cert's own restrictions, carried back so the server layer
				// applies them. CheckCert above ACCEPTS `source-address` without
				// checking it (see SupportedCriticalOptions where the checker is
				// built); x/crypto's serverAuthenticate is what checks it,
				// against the address the connection actually came from, and it
				// reads it from the permissions returned here. The plane runs on
				// the host network, so that address is the client's, not a
				// proxy's. Every other critical option is still refused by
				// CheckCert, which supports only that one.
				CriticalOptions: cert.CriticalOptions,
				Extensions: map[string]string{
					// The tenant of the CA that signed this cert, resolved from the
					// registered tenant CA set. Downstream authz compares this against
					// the target VM's tenant, so the cert's signing CA cryptographically
					// bounds which tenant's VMs the connection may reach.
					tenantExt: tenant,
					// Carried so the connection can be audited by the certificate
					// that opened it: the serial is what a revocation names, which
					// makes a gate row answer "was this the cert we later revoked?".
					serialExt: strconv.FormatUint(cert.Serial, 10),
					keyIDExt:  cert.KeyId,
				},
			}, nil
		},
	}
	cfg.AddHostKey(hostKey)
	if audit == nil {
		audit = func(string, string, map[string]string) {}
	}
	return &Gate{cfg: cfg, resolve: resolve, authorize: authorize, dial: dial, audit: audit,
		starting: make(chan struct{}, maxStartups)}
}

// Serve accepts connections on l until it returns an error (e.g. l is closed).
func (g *Gate) Serve(l net.Listener) error {
	for {
		nConn, err := l.Accept()
		if err != nil {
			return err
		}
		go g.handleConn(nConn)
	}
}

// handshakeGrace bounds the unauthenticated SSH handshake, mirroring sshd's
// LoginGraceTime. Without it a client that connects and then stalls (no or
// partial banner) parks a goroutine + fd indefinitely, and enough such
// connections starve the bastion of legitimate admin logins. A var (not const)
// so tests can shrink it; not part of the public API.
var handshakeGrace = 30 * time.Second

// maxStartups bounds concurrent unauthenticated handshakes, mirroring sshd's
// MaxStartups. handshakeGrace already bounds how LONG one stalled client may
// hold a slot; this bounds HOW MANY, which is the half that stops a flood of
// merely-slow clients from crowding out every real admin login. A var (not
// const) so tests can shrink it; not part of the public API.
//
// The slot is released the moment the handshake finishes, NOT when the tunnel
// closes: the resource being rationed is the pre-auth window, and an
// authenticated session may legitimately last hours. Holding slots for the life
// of a tunnel would make a busy gate refuse logins it has every reason to take.
var maxStartups = 64

// handleConn runs the SSH handshake and dispatches channels for one connection.
func (g *Gate) handleConn(nConn net.Conn) {
	defer nConn.Close()
	remote := remoteHost(nConn)
	// Take a startup slot, or shed the connection now. Refusing immediately is
	// the honest answer under load: the alternative is queueing behind a full
	// grace window, which delays every real login instead of one attacker's.
	select {
	case g.starting <- struct{}{}:
	default:
		// Logged, deliberately NOT audited. This is the cheapest event on the
		// gate to provoke — it costs an attacker one TCP connection — and an
		// audit row costs a serialized database write and ninety days of
		// retention. Auditing here would turn the defence against a flood into
		// the flood's amplifier. sshd sends refusals to syslog for this reason.
		// The line above the auth boundary is the rule: what got far enough to
		// attempt authentication is audited; what was refused before that is
		// logged.
		slog.Warn("sshgate shed connection: too many starting", "remote", remote)
		return
	}
	released := false
	release := func() {
		if !released {
			released = true
			<-g.starting
		}
	}
	defer release()

	// Deadline covers only the pre-auth handshake; cleared once it completes so
	// it never applies to the long-lived tunnel that follows.
	_ = nConn.SetDeadline(time.Now().Add(handshakeGrace))
	sConn, chans, reqs, err := ssh.NewServerConn(nConn, g.cfg)
	if err != nil {
		// A refused login has no tenant to file under — the caller decides where
		// unattributed events go. The error is the reason as x/crypto phrased it;
		// it names no key material.
		g.audit("", "gate.auth.denied", map[string]string{"remote": remote, "reason": err.Error()})
		return // handshake, auth failure, or grace timeout — nothing to serve
	}
	_ = nConn.SetDeadline(time.Time{})
	// Authenticated: the pre-auth window is over, so give the slot back before
	// serving a tunnel that may outlast every other connection on the gate.
	release()
	defer sConn.Close()

	// Refuse EVERY out-of-band global request. tcpip-forward (`ssh -R`) would turn
	// the bastion into an open ingress relay; no other global request is
	// legitimate here. Draining the channel also keeps the transport unblocked.
	go rejectRequests(reqs)

	tenant, serial, keyID := "", "", ""
	if sConn.Permissions != nil {
		tenant = sConn.Permissions.Extensions[tenantExt]
		serial = sConn.Permissions.Extensions[serialExt]
		keyID = sConn.Permissions.Extensions[keyIDExt]
	}
	g.audit(tenant, "gate.auth", map[string]string{
		"remote": remote, "serial": serial, "key_id": keyID, "user": sConn.User(),
	})
	for newChan := range chans {
		// Only direct-tcpip is permitted; this rejects session/exec/shell/
		// subsystem/x11/auth-agent — any granted session is code-exec on the server.
		if newChan.ChannelType() != "direct-tcpip" {
			_ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted")
			continue
		}
		go g.handleDirectTCPIP(newChan, tenant, remote)
	}
}

// rejectRequests replies false to every global request that wants a reply and
// discards the rest.
func rejectRequests(reqs <-chan *ssh.Request) {
	for req := range reqs {
		if req.WantReply {
			_ = req.Reply(false, nil)
		}
	}
}

// handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM,
// and bridges the channel to the VM byte-for-byte.
func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant, remote string) {
	// deny records why a tunnel was refused. The requested name is
	// caller-controlled, so it is bounded before it reaches the trail.
	deny := func(target, reason string) {
		g.audit(tenant, "gate.tunnel.denied", map[string]string{
			"remote": remote, "target": truncate(target, 64), "reason": reason,
		})
	}
	var p directTCPIP
	if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil {
		deny("", "malformed request")
		_ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request")
		return
	}
	// Port policy: only 22, and reject others rather than silently rewriting, so
	// intent stays auditable.
	if p.PortToConnect != 22 {
		deny(p.HostToConnect, "port "+strconv.FormatUint(uint64(p.PortToConnect), 10)+" not permitted")
		_ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted")
		return
	}
	// Connect names take two forms, both resolving within the connection's
	// own tenant (the cert's signing CA already names the tenant):
	//   <name>          — a bare VM name in the connection's tenant.
	//   <tenant>.<name> — the explicit form; the prefix MUST equal the
	//                     connection's tenant. Naming another tenant is
	//                     rejected identically to a nonexistent VM, so
	//                     tenancy structure is not probeable from the gate.
	// VM names are RFC1123 labels (no dots) and tenant ids are dot-free, so
	// the FIRST dot splits the explicit form unambiguously; a name with no dot
	// is unambiguously bare.
	bare := p.HostToConnect
	if prefix, rest, found := strings.Cut(p.HostToConnect, "."); found {
		if prefix != tenant || rest == "" {
			deny(p.HostToConnect, "unknown VM")
			_ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
			return
		}
		bare = rest
	}
	if bare == "" {
		deny(p.HostToConnect, "unknown VM")
		_ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
		return
	}
	hostID, vmID, ok := g.resolve(tenant, bare)
	if !ok {
		deny(p.HostToConnect, "unknown VM")
		_ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
		return
	}
	if !g.authorize(tenant, vmID) {
		deny(p.HostToConnect, "not authorized for this VM")
		_ = newChan.Reject(ssh.Prohibited, "not authorized for this VM")
		return
	}
	rwc, err := g.dial(context.Background(), hostID, vmID, 22)
	if err != nil {
		deny(p.HostToConnect, "host unreachable")
		_ = newChan.Reject(ssh.ConnectionFailed, "cannot reach VM")
		return
	}
	ch, chReqs, err := newChan.Accept()
	if err != nil {
		_ = rwc.Close()
		return
	}
	go ssh.DiscardRequests(chReqs) // no channel requests (env/pty/exec) are honored
	g.audit(tenant, "gate.tunnel", map[string]string{
		"remote": remote, "vm_id": vmID, "host_id": hostID, "target": truncate(bare, 64),
	})

	// Two pumps, raw bytes (mirrors console.go): close both legs on either EOF.
	done := make(chan struct{}, 2)
	go func() { _, _ = io.Copy(rwc, ch); _ = rwc.Close(); done <- struct{}{} }()
	go func() { _, _ = io.Copy(ch, rwc); _ = ch.Close(); done <- struct{}{} }()
	<-done
	<-done
	slog.Debug("sshgate tunnel closed", "vm", vmID)
}

// remoteHost is the client's address without its port — the field an operator
// scans an audit trail by. An address that will not split is used whole.
func remoteHost(c net.Conn) string {
	host, _, err := net.SplitHostPort(c.RemoteAddr().String())
	if err != nil {
		return c.RemoteAddr().String()
	}
	return host
}

// truncate bounds a caller-controlled string before it reaches the audit trail.
func truncate(s string, n int) string {
	if len(s) > n {
		return s[:n]
	}
	return s
}