internal/transport/tlsconf.go
Ref: Size: 5.4 KiB History
package transport
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"time"
"github.com/quic-go/quic-go"
)
// ALPN is the QUIC application-layer protocol token. Both ends MUST set it; the
// handshake fails without a match.
const ALPN = "eitri-sync/1"
// Application error codes sent via CloseWithError so the agent can classify why
// the server dropped it.
const (
CodeAuthRejected = 1 // permanent: bad/expired host credential — do not tight-loop
// CodeSuperseded closes the older of two sessions claiming one host: the
// newest Hello wins and the displaced agent is told so, rather than being
// left holding a connection nobody reads. It is transient by design — the
// displaced agent reconnects on its normal backoff, and only the auth code
// above earns the long one.
CodeSuperseded = 2
)
// Sync QUIC keepalive/idle timings. The agent dialer and the server listener
// MUST build their quic.Config from SyncQUICConfig: the effective idle timeout
// is the min of the two peers, so tuning one side alone silently shortens it.
// 10s keepalive under a 60s idle gives ~5 keepalive attempts before teardown,
// so a single missed PING or a brief GC/stall on either side does not kill a
// healthy control-plane session (the old 15s/30s allowed only one attempt and
// caused reconnect churn).
const (
SyncKeepAlivePeriod = 10 * time.Second
SyncMaxIdleTimeout = 60 * time.Second
)
// SyncQUICConfig returns the QUIC transport config shared by the eitri-agent
// dialer and the eitri-server listener. Colocating it with ALPN and the TLS
// builders is what keeps the two ends from drifting.
func SyncQUICConfig() *quic.Config {
return &quic.Config{
KeepAlivePeriod: SyncKeepAlivePeriod,
MaxIdleTimeout: SyncMaxIdleTimeout,
}
}
// GenerateServerCert returns a fresh self-signed ECDSA cert+key as PEM.
func GenerateServerCert() (certPEM, keyPEM []byte, err error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "eitri-server"},
NotBefore: time.Now().Add(-time.Hour),
// 2 years, not 10: the agent pin ignores expiry (an expired cert never
// breaks the fleet), so long validity only widens the forgery window
// if server.key leaks. CertRenewalDue warns 90 days out; rotation
// runbook: docs/cert-rotation.md.
NotAfter: time.Now().AddDate(2, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, nil, err
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, nil, err
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// CertFingerprint returns the hex SHA-256 of the cert's DER bytes.
func CertFingerprint(certPEM []byte) (string, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return "", fmt.Errorf("no PEM block in cert")
}
sum := sha256.Sum256(block.Bytes)
return hex.EncodeToString(sum[:]), nil
}
// renewalWindow is how long before NotAfter CertRenewalDue starts reporting
// true. 90 days gives the operator a comfortable rotation runway.
const renewalWindow = 90 * 24 * time.Hour
// CertRenewalDue reports the cert's NotAfter and whether rotation is due (now
// is within renewalWindow of expiry, or past it). An unparseable cert reports
// due — fail loud so the operator looks at it. Note: the agent pin
// (VerifyConnection) ignores expiry, so an expired cert never breaks the
// running fleet; this only drives the operator-facing warning cadence.
func CertRenewalDue(certPEM []byte, now time.Time) (notAfter time.Time, due bool) {
block, _ := pem.Decode(certPEM)
if block == nil {
return time.Time{}, true
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, true
}
return cert.NotAfter, now.After(cert.NotAfter.Add(-renewalWindow))
}
// ServerTLS builds the server's tls.Config from PEM cert+key with ALPN set.
func ServerTLS(certPEM, keyPEM []byte) (*tls.Config, error) {
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: []string{ALPN},
MinVersion: tls.VersionTLS13,
}, nil
}
// ClientTLS builds the agent's tls.Config pinned to wantFP (hex sha256 of the
// server cert DER). Uses VerifyConnection (called on resumed connections too,
// unlike VerifyPeerCertificate) and disables default CA verification since the
// cert is self-signed and pinned instead.
func ClientTLS(wantFP string) *tls.Config {
return &tls.Config{
NextProtos: []string{ALPN},
MinVersion: tls.VersionTLS13,
InsecureSkipVerify: true, // CA path disabled; pinning is the trust root
VerifyConnection: func(cs tls.ConnectionState) error {
if len(cs.PeerCertificates) == 0 {
return fmt.Errorf("server presented no certificate")
}
sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
if got := hex.EncodeToString(sum[:]); got != wantFP {
return fmt.Errorf("server cert fingerprint mismatch: got %s want %s", got, wantFP)
}
return nil
},
}
}