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
}