gateway

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 12, 2026 License: MIT Imports: 27 Imported by: 0

README

goakt-gateway

Cluster-aware ingress tier for GoAkt: a WebSocket/SSE connection registry with two-tier delivery, and cluster-shared TLS termination (Cloudflare Origin CA, Authenticated Origin Pulls).

What it is

goakt-gateway turns a GoAkt cluster into its own ingress tier - the role an nginx/caddy front-tier normally plays in front of a stateless HTTP fleet - for two things specifically: giving long-lived WebSocket/SSE connections an addressable identity so any node can deliver a message to them, and terminating TLS for domains shared cluster-wide from a certificate arbitrated so that, as long as issuance completes within the configured lock TTL, at most one process issues it (see Cluster-shared TLS for the lock-TTL caveat).

Why it lives outside the actor core

This is a satellite library, not part of GoAkt itself, on purpose. A WebSocket gateway is an opinionated shape on top of the actor system - a particular registry design, a particular delivery model, a particular certificate-issuance workflow - and GoAkt's core has no opinion on any of that. Keeping it as a separate module means:

  • GoAkt's dependency footprint does not grow for users who never touch this;
  • this library can iterate and version independently of GoAkt's release cadence;
  • it is held to the same bar as any third-party consumer of GoAkt: it depends on GoAkt exclusively through the public actor API (Spawn, ActorOf, TopicActor, Subscribe/Unsubscribe, ScheduleWithCron, ...) - nothing here reaches into a GoAkt internal package, which is both proof that the public API is sufficient for this kind of integration and a guarantee that upgrading GoAkt does not silently break this library through an internal it was never supposed to depend on.

Two design boundaries drive every type in this package:

  • Plain HTTP requests are ordinary net/http handlers. Nothing in this package puts an actor in the path of a request/response cycle that lives for a few milliseconds - that would be pure overhead.
  • Only long-lived WebSocket/SSE connections get an addressable identity. Each accepted connection is registered in a local Registry and backed by a lightweight ephemeral actor (relocation disabled, long-lived passivation) whose only job is to relay a delivery to the socket it owns when another node needs to reach it.

Install

go get github.com/StringKe/goakt-gateway

Requires Go 1.26 and github.com/tochemey/goakt/v4 v4.3.1 or later.

Quickstart

A complete, runnable WebSocket echo server plus an HTTP handler that pushes a message into a registered connection - the "HTTP handler talks to a websocket connection" pattern the whole package exists for. A longer version of this lives in examples/echo.

package main

import (
	"context"
	"log"
	"net/http"

	"github.com/tochemey/goakt/v4/actor"
	golog "github.com/tochemey/goakt/v4/log"

	gateway "github.com/StringKe/goakt-gateway"
)

func main() {
	ctx := context.Background()

	system, err := actor.NewActorSystem("gateway-echo", actor.WithLogger(golog.DiscardLogger))
	if err != nil {
		log.Fatal(err)
	}
	if err := system.Start(ctx); err != nil {
		log.Fatal(err)
	}
	defer func() { _ = system.Stop(ctx) }()

	registry := gateway.NewRegistry(system, golog.DiscardLogger)

	mux := http.NewServeMux()

	mux.Handle("/ws", gateway.NewWSHandler(registry,
		gateway.WithWSIDFunc(func(r *http.Request) string { return r.URL.Query().Get("id") }),
		gateway.WithWSOnMessage(func(ctx context.Context, id string, payload []byte) {
			// The connection is always local to this process here, so
			// SendToConnection takes the direct-write fast path: no actor, no
			// cluster lookup.
			_ = registry.SendToConnection(ctx, id, payload)
		}),
	))

	mux.HandleFunc("/send", func(w http.ResponseWriter, r *http.Request) {
		id := r.URL.Query().Get("id")
		if err := registry.SendToConnection(r.Context(), id, []byte(r.URL.Query().Get("msg"))); err != nil {
			http.Error(w, err.Error(), http.StatusNotFound)
			return
		}
		w.WriteHeader(http.StatusOK)
	})

	server, err := gateway.NewServer("127.0.0.1:8080", mux)
	if err != nil {
		log.Fatal(err)
	}
	log.Fatal(server.ListenAndServe(ctx))
}

Run it, connect a WebSocket client to ws://127.0.0.1:8080/ws?id=alice, then from another terminal:

curl "http://127.0.0.1:8080/send?id=alice&msg=hello-from-http"

The message shows up on the WebSocket connection - delivered by an ordinary HTTP handler, with no actor or cluster machinery involved because the connection is local.

Registry and two-tier delivery

Registry.SendToConnection and Registry.Broadcast both follow the same shape:

  1. Local hit: write directly. If the target connection (or, for broadcast, a topic member) is registered on this node, the payload is written straight to the socket.
  2. Local miss: cluster fallback. Only when the connection is not held locally does Registry resolve it through the cluster-aware actor directory (ActorSystem.ActorOf) and deliver remotely. For Broadcast, remote topic members are reached through a small pub/sub bridge (see below) built on GoAkt's public actor.Subscribe/actor.Unsubscribe messaging.

Cross-node messaging - the only scenario that needs any cluster machinery at all - is opt-in by construction: a single-node deployment never touches ActorSystem.ActorOf or the topic actor.

The pub/sub bridge

GoAkt's fork-only ActorSystem.SubscribeTopic convenience is not part of the public API, so Registry's topic fan-out is built from scratch on primitives that are: it spawns its own small bridge actor per topic (actor.ActorSystem.Spawn), sends the public actor.Subscribe message to system.TopicActor(), and forwards whatever the topic actor delivers back to Registry's local topic members. The bridge is torn down with actor.Unsubscribe once the last local member leaves the topic. See bridge.go.

WebSocket and SSE listeners

NewWSHandler and NewSSEHandler return ordinary http.Handler values:

mux.Handle("/ws", gateway.NewWSHandler(registry,
    gateway.WithWSIDFunc(func(r *http.Request) string { return r.URL.Query().Get("user_id") }),
    gateway.WithWSAuthFunc(verifyBearerToken),
    gateway.WithWSTopics(func(r *http.Request) []string { return []string{roomOf(r)} }),
    gateway.WithWSOnMessage(handleInbound),
))
mux.Handle("/events", gateway.NewSSEHandler(registry,
    gateway.WithSSEIDFunc(func(r *http.Request) string { return r.URL.Query().Get("user_id") }),
))
Concern How it's covered
Auth WithWSAuthFunc/WithSSEAuthFunc run during the handshake/request; a non-nil error rejects the connection (403).
Backpressure Each connection gets a fixed-size outbound buffer (default 256). A full buffer returns ErrBackpressure from SendToConnection/Broadcast instead of blocking the caller.
Clean shutdown The connection is always unregistered (and its ephemeral actor stopped) when the socket closes.
Topic grouping WithWSTopics/WithSSETopics join the connection to Registry topics at registration time, for use with Broadcast.

SSE is one-way (server to client); pair it with an ordinary HTTP endpoint for inbound data.

Cluster-shared TLS

Manager lets every process terminate TLS for any hosted domain from one certificate, with issuance arbitrated across every process that shares its Coordinator so that, as long as your CertIssuer completes within the configured issuance lock TTL, at most one process calls it:

manager := gateway.NewManager(actorSystem, logger,
    gateway.WithCertIssuer(issuer),
    gateway.WithCoordinator(coordinator),      // optional; defaults to an in-memory, process-local one
    gateway.WithCertStore(myPersistentStore),  // optional; defaults to in-memory
    gateway.WithAllowedDomains("chat.example.com", "api.example.com"),
    gateway.WithRenewBefore(30*24*time.Hour), // default
)

if err := manager.Start(ctx); err != nil { // registers the renewal schedule
    log.Fatal(err)
}
defer manager.Stop(ctx)

tlsConfig := manager.TLSConfig() // GetCertificate wired for SNI lookup
  • Issuance arbitration. The first request for a domain (or the renewal schedule finding one close to expiry) races Coordinator.TryLock for that domain; only the winner calls the configured CertIssuer. Losers poll the Coordinator until the winner publishes, or give up with ErrIssuanceTimeout.
  • Issuance lock TTL caveat. The lock is not renewed while the CertIssuer call is in flight (Coordinator has no lock-extension method). If issuance takes longer than WithIssuanceLockTTL (default 2 minutes), the lock can expire mid-issuance and a second process can acquire it and also call the issuer. Manager detects this after the fact and returns ErrIssuanceLockExpired instead of caching the result, but it does not prevent the extra CertIssuer call. Set WithIssuanceLockTTL comfortably longer than your CertIssuer's worst-case latency.
  • Distribution. The issued certificate is written to the Coordinator (so every process sharing it can serve it immediately) and to the configured CertStore (so a single-process restart doesn't need the Coordinator at all).
  • Renewal. Driven by GoAkt's cron scheduler (ActorSystem.ScheduleWithCron, default: hourly, configurable via WithRenewInterval). In a GoAkt cluster, cron schedules fire once cluster-wide by scheduler design, so exactly one node re-checks/re-issues per tick regardless of cluster size.
  • SNI lookup. Manager.GetCertificate implements the tls.Config.GetCertificate signature directly.
Coordinator

GoAkt's cluster KV store is not part of the public API this library depends on, and its underlying storage documents its own lock as "recommended for efficiency, not correctness" - the wrong substrate for an issuance lock, since a duplicated issuance burns CA rate limit. Coordinator is a small interface this library owns instead:

type Coordinator interface {
    Get(ctx context.Context, key string) (value []byte, ok bool, err error)
    Put(ctx context.Context, key string, value []byte, ttl time.Duration) error
    TryLock(ctx context.Context, key string, ttl time.Duration) (unlock func(context.Context) error, err error)
}

Two implementations ship:

  • NewMemoryCoordinator (the default) is process-local - correct for a single process, local development, and tests, but does not coordinate issuance across processes.
  • coordinator/redis.New is backed by Redis (SET NX PX for the lock, a Lua compare-and-delete for release - a real mutual exclusion, not a best-effort one) and coordinates issuance across every process pointed at the same Redis instance. It is a separate package specifically so importing the root module never pulls in github.com/redis/go-redis/v9 for applications that do not want it:
import (
	"github.com/redis/go-redis/v9"

	gateway "github.com/StringKe/goakt-gateway"
	gatewayredis "github.com/StringKe/goakt-gateway/coordinator/redis"
)

client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
manager := gateway.NewManager(actorSystem, logger,
	gateway.WithCoordinator(gatewayredis.New(client)),
	gateway.WithCertIssuer(issuer),
)

Bring your own implementation (backed by etcd, Consul, a database - anything that can do a real TTL'd mutex) by implementing the three-method interface directly.

Certificate issuers
type CertIssuer interface {
    Issue(ctx context.Context, domain string) (*Certificate, error)
}
  • StaticIssuer serves a fixed, pre-provisioned certificate.
  • CloudflareOriginCAIssuer calls the Cloudflare Origin CA API over plain net/http - no Cloudflare SDK dependency:
issuer := &gateway.CloudflareOriginCAIssuer{
    APIToken:           os.Getenv("CF_ORIGIN_CA_KEY"),
    RequestCertificate: gateway.NewRSACertificateRequest(2048),
}

Authenticated Origin Pulls

If traffic only ever reaches your gateway through Cloudflare, AuthenticatedOriginPulls adds mTLS verification that inbound connections present Cloudflare's origin-pull client certificate. CAPEM is supplied by the caller rather than embedded, so deployments track Cloudflare's currently published CA bundle instead of trusting a copy vendored into this library:

pulls := &gateway.AuthenticatedOriginPulls{CAPEM: cloudflareOriginPullCA}

server, err := gateway.NewServer(":8443", mux,
    gateway.WithTLSManager(manager),
    gateway.WithAuthenticatedOriginPulls(pulls),
)

Server and graceful shutdown

Server is an optional, thin wrapper around *http.Server. http.Server.Shutdown cannot evict long-lived connections on its own - hijacked WebSocket sockets are ignored entirely, and open SSE streams are waited on until the shutdown context expires - so register handlers with WithDrainOnShutdown:

wsHandler := gateway.NewWSHandler(registry)
sseHandler := gateway.NewSSEHandler(registry)

server, err := gateway.NewServer(":8443", mux,
    gateway.WithTLSManager(manager),
    gateway.WithDrainOnShutdown(wsHandler, sseHandler),
)

Drain closes every socket, the per-connection actors unregister, and reconnecting clients land on the replicas that stay up.

What this library does and does not guarantee

Does:

  • A locally registered connection is always written to directly, with no actor mailbox or cluster round-trip on the hot path.
  • SendToConnection/Broadcast for a connection registered anywhere in a GoAkt cluster resolve and deliver correctly, given the cluster's own consistency model for actor directory propagation (not instantaneous - see the multi-node tests for the propagation delay this library's own test suite budgets for).
  • Certificate issuance is deduplicated within a process (singleflight) and, with a shared Coordinator, arbitrated across every process sharing it so at most one process calls the configured CertIssuer for a given domain at a time, provided issuance completes within the configured issuance lock TTL (see the lock TTL caveat below).
  • MemoryCoordinator.TryLock and coordinator/redis.Coordinator.TryLock are both real mutual exclusion: an unlock can never release a lock a later caller has since acquired. This is a shared conformance suite (coordinator/conformance), run against both implementations, so a regression to e.g. a bare DEL in either would fail tests.

Does not:

  • Guarantee message delivery. SendToConnection/Broadcast are best-effort, same as any actor Tell: no acknowledgement, no retry, no persistence. Build your own ack/retry layer on top if you need it.
  • Guarantee ordering across Broadcast calls to different topics, or between a local direct write and a remote delivery of the same broadcast racing each other.
  • Provide its own certificate validation, ACME support, or CA. CertIssuer is a seam; bring your own (an ACME implementation is a natural third one, deliberately not shipped here to keep the dependency tree minimal).
  • Renew the issuance lock while a CertIssuer call is in flight. Coordinator has no lock-extension method, so if issuance takes longer than WithIssuanceLockTTL, the lock can expire mid-issuance and a second process can acquire it and also call the issuer; Manager returns ErrIssuanceLockExpired after the fact rather than caching the result, but does not prevent the extra call. Set the TTL comfortably longer than your CertIssuer's worst-case latency.
  • Drain in-flight application state on shutdown beyond closing the socket/stream - Drain terminates connections, it does not flush or persist anything for you.

Development

go build ./...
go vet ./...
go test -race ./...

Redis-backed Coordinator conformance tests are skipped unless TEST_REDIS_ADDR is set:

TEST_REDIS_ADDR=localhost:6379 go test ./coordinator/redis/...

See also

  • GoAkt - the actor system this library sits on top of.
  • examples/echo - a complete runnable sample.

Documentation

Overview

Package gateway turns a GoAkt cluster into a single, cluster-aware ingress tier for HTTP, WebSocket, and Server-Sent Events (SSE) traffic - the role an nginx/caddy front-tier normally plays in front of a stateless HTTP fleet.

Two design boundaries drive every type in this package:

  • Plain HTTP requests are served by ordinary net/http handlers with zero actor involvement. Actorizing a request/response cycle that lives for a few milliseconds is pure overhead; nothing in this package puts one in the way of a short-lived HTTP handler.
  • Only long-lived WebSocket/SSE connections get an addressable identity. Each accepted connection is registered in a local Registry and, so that it can be addressed from any node in the cluster, backed by a lightweight ephemeral actor (relocation disabled, long-lived passivation) whose sole job is to relay a delivery to the socket it owns.

Message delivery to a connection is two-tier: Registry.SendToConnection checks the local connection table first and, on a hit, writes the socket directly with no actor or cluster machinery involved. Only on a miss does it fall back to a cluster-wide actor lookup (actor.ActorSystem.ActorOf) and a remote Tell to the node that owns the connection. Registry.Broadcast follows the same shape for topic fan-out: local subscribers are written directly, and remote nodes are reached through a small pub/sub bridge built entirely on GoAkt's public actor.Subscribe/actor.Unsubscribe API (see bridge.go) - this package has no dependency on any GoAkt internal package.

The TLS side of the package (CertIssuer, CertStore, Manager) lets every node in the cluster terminate TLS for any hosted domain from one certificate, issued exactly once cluster-wide: issuance is arbitrated through a storage-agnostic Coordinator this library owns (see coordinator.go), the resulting certificate is distributed to every node through the same Coordinator, and renewal is driven by GoAkt's cluster-single-fire cron schedule (actor.ActorSystem.ScheduleWithCron) so only one node re-issues per renewal window. See Manager, CertIssuer, CertStore, and Coordinator.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrConnectionNotFound is returned by Registry.SendToConnection when the target
	// connection id is not registered locally and cannot be located anywhere else in
	// the cluster either.
	ErrConnectionNotFound = errors.New("gateway: connection not found")

	// ErrConnectionExists is returned by Registry.Register when the given connection id
	// is already registered.
	ErrConnectionExists = errors.New("gateway: connection already registered")

	// ErrConnectionClosed is returned when writing to a connection that has already
	// been unregistered/closed.
	ErrConnectionClosed = errors.New("gateway: connection closed")

	// ErrBackpressure is returned when a connection's outbound buffer is full and the
	// write could not be queued without blocking.
	ErrBackpressure = errors.New("gateway: connection send buffer full")

	// ErrUnauthorized is returned by the WebSocket/SSE upgrade path when the configured
	// AuthFunc rejects the incoming request.
	ErrUnauthorized = errors.New("gateway: unauthorized")

	// ErrPubSubUnavailable is returned when a topic bridge is requested but the actor
	// system was not started with pub/sub enabled (see actor.ActorSystem.TopicActor).
	ErrPubSubUnavailable = errors.New("gateway: pub/sub is not enabled on this actor system")

	// ErrDomainNotAllowed is returned by Manager.GetCertificate and EnsureCertificate
	// when the requested SNI/domain is not covered by the configured allow list.
	ErrDomainNotAllowed = errors.New("gateway: domain not allowed")

	// ErrNoIssuer is returned by Manager when certificate issuance is required but no
	// CertIssuer was configured.
	ErrNoIssuer = errors.New("gateway: no certificate issuer configured")

	// ErrIssuanceTimeout is returned when this node lost the coordinator-arbitrated
	// issuance race for a domain and the winning node did not publish a certificate
	// before the wait deadline elapsed.
	ErrIssuanceTimeout = errors.New("gateway: timed out waiting for coordinated certificate issuance")

	// ErrCertificateNotFound is returned by CertStore.Get when no certificate is stored
	// for the given domain.
	ErrCertificateNotFound = errors.New("gateway: certificate not found")

	// ErrOriginPullVerificationFailed is returned when Authenticated Origin Pulls is
	// enabled and the inbound client certificate does not chain to the configured CA.
	ErrOriginPullVerificationFailed = errors.New("gateway: origin pull client certificate verification failed")

	// ErrLockNotAcquired is returned by Coordinator.TryLock when the lock is already
	// held by someone else.
	ErrLockNotAcquired = errors.New("gateway: lock not acquired")

	// ErrIssuanceLockExpired is returned by Manager when a CertIssuer call outlives the
	// configured issuance lock TTL (see WithIssuanceLockTTL). The Coordinator lock has no
	// renewal/heartbeat, so a slow issuer can let the lock expire and a second process
	// acquire it while the first is still mid-issuance; returning this error instead of
	// silently caching the result makes that window visible rather than quietly risking a
	// duplicate CertIssuer call. Configure a WithIssuanceLockTTL comfortably longer than
	// your CertIssuer's worst-case latency to avoid it.
	ErrIssuanceLockExpired = errors.New("gateway: certificate issuance took longer than the issuance lock ttl")
)

Functions

func NewRSACertificateRequest

func NewRSACertificateRequest(bits int) func(domain string) (csrPEM, keyPEM []byte, err error)

NewRSACertificateRequest builds a PEM-encoded PKCS#10 certificate signing request and matching RSA private key for domain, suitable for use as CloudflareOriginCAIssuer.RequestCertificate. It uses only the standard library, so it adds no dependency beyond what go.mod already carries.

Types

type AuthenticatedOriginPulls

type AuthenticatedOriginPulls struct {
	// CAPEM is the PEM-encoded CA certificate (or bundle) that inbound client
	// certificates must chain to.
	CAPEM []byte
}

AuthenticatedOriginPulls configures mutual TLS verification of inbound connections against a trusted CA - the shape Cloudflare's "Authenticated Origin Pulls" feature requires of an origin server: only connections presenting a client certificate that chains to Cloudflare's published origin-pull CA are accepted.

The CA is supplied by the caller (CAPEM) rather than embedded, since a real deployment should track Cloudflare's currently published origin-pull CA bundle rather than trust a copy vendored into this library.

func (*AuthenticatedOriginPulls) Apply

func (a *AuthenticatedOriginPulls) Apply(cfg *tls.Config) error

Apply overlays mTLS verification onto cfg: it sets ClientAuth to tls.RequireAndVerifyClientCert and ClientCAs to a pool built from CAPEM. It returns an error if CAPEM does not contain a valid PEM-encoded certificate.

func (*AuthenticatedOriginPulls) Verify

Verify checks that the peer certificates presented in state chain to the configured CA. tls.Config.ClientAuth = tls.RequireAndVerifyClientCert already performs this verification during the handshake itself; Verify exists for callers that want to re-check an already-established connection's state explicitly (e.g. in an HTTP handler, via *http.Request.TLS) rather than solely trust the handshake outcome.

type CertIssuer

type CertIssuer interface {
	// Issue returns a new Certificate for domain. It is called by Manager both for the
	// first issuance of a domain and for renewal ahead of expiry.
	Issue(ctx context.Context, domain string) (*Certificate, error)
}

CertIssuer obtains a certificate for a domain. Manager arbitrates calls to Issue so that, when using a cluster-shared Coordinator, at most one node calls it for a given domain at a time (see Manager.EnsureCertificate).

Implementations must be safe for concurrent use.

type CertStore

type CertStore interface {
	// Get returns the certificate stored for domain. It returns ErrCertificateNotFound
	// if none is stored.
	Get(ctx context.Context, domain string) (*Certificate, error)
	// Put stores cert, overwriting any previous certificate for the same domain.
	Put(ctx context.Context, cert *Certificate) error
	// Delete removes the certificate stored for domain, if any.
	Delete(ctx context.Context, domain string) error
}

CertStore persists issued certificates so that a cold start does not have to re-issue one for every hosted domain. Manager always keeps a copy in the configured Coordinator for fast, shared SNI lookups; a CertStore is the layer behind that - by default an in-memory MemoryCertStore, but applications facing a full restart of every node should plug in a persistent implementation (e.g. backed by a database or object storage) to avoid depending on the issuer being reachable/willing to re-issue on every cold start.

Implementations must be safe for concurrent use.

type Certificate

type Certificate struct {
	// Domain is the domain the certificate was issued for.
	Domain string
	// CertPEM is the PEM-encoded leaf certificate (and, where applicable, intermediate
	// chain) as returned by the issuer.
	CertPEM []byte
	// KeyPEM is the PEM-encoded private key matching CertPEM.
	KeyPEM []byte
	// NotAfter is the certificate's expiry, used by Manager to decide when to renew.
	NotAfter time.Time
}

Certificate is the PEM-encoded issuance result CertIssuer.Issue returns. It is what Manager stores in a CertStore and hands to tls.Config.GetCertificate after parsing.

type CloudflareOriginCAIssuer

type CloudflareOriginCAIssuer struct {
	// APIToken authenticates against the Origin CA API. Origin CA calls use an API
	// Token/Key dedicated to Origin CA rather than a general Cloudflare API token; see
	// the Cloudflare documentation for how to obtain one.
	APIToken string
	// BaseURL overrides the Cloudflare API base URL. Defaults to
	// "https://api.cloudflare.com/client/v4" when empty; tests point this at a local
	// httptest.Server.
	BaseURL string
	// RequestedValidityDays is the requested certificate lifetime in days, one of the
	// values Cloudflare's API accepts (7, 30, 90, 365, 730, 1095, or 5475). Defaults to
	// 365 when zero.
	RequestedValidityDays int
	// RequestCertificate builds the PEM-encoded PKCS#10 certificate signing request and
	// matching private key for domain. It is a function rather than a fixed
	// implementation so callers can plug in their preferred key type/size; see
	// NewRSACertificateRequest for a ready-made RSA implementation.
	RequestCertificate func(domain string) (csrPEM, keyPEM []byte, err error)
	// HTTPClient performs the API request. Defaults to http.DefaultClient when nil.
	HTTPClient *http.Client
}

CloudflareOriginCAIssuer issues certificates through the Cloudflare Origin CA REST API (https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/), using only net/http - no Cloudflare SDK dependency. It signs a certificate request created locally (CertRequestPEM) and returns whatever Cloudflare hands back.

Cloudflare-issued origin certificates are trusted by Cloudflare's edge but not by public clients, which is the intended shape for a service that only ever receives traffic proxied through Cloudflare.

func (*CloudflareOriginCAIssuer) Issue

func (c *CloudflareOriginCAIssuer) Issue(ctx context.Context, domain string) (*Certificate, error)

Issue implements CertIssuer by calling the Cloudflare Origin CA "create certificate" endpoint (POST /certificates).

type Coordinator

type Coordinator interface {
	// Get returns the value stored for key. ok is false if key is absent or has
	// expired; a missing key is not an error.
	Get(ctx context.Context, key string) (value []byte, ok bool, err error)
	// Put stores value under key. A ttl of zero or less means the value never expires
	// on its own (it is overwritten or explicitly replaced instead).
	Put(ctx context.Context, key string, value []byte, ttl time.Duration) error
	// TryLock attempts to acquire an exclusive, TTL-bounded lock on key. On success it
	// returns an unlock function that releases the lock; unlock is safe to call more
	// than once and safe to call after ttl has already elapsed (a no-op in that case,
	// since the lock is already gone). On failure it returns ErrLockNotAcquired.
	//
	// The lock is a real mutual exclusion primitive: implementations must guarantee
	// that at most one caller holds a given key at a time, and that a caller's unlock
	// never releases a lock some other caller has since acquired (e.g. after the
	// original holder's TTL expired).
	TryLock(ctx context.Context, key string, ttl time.Duration) (unlock func(context.Context) error, err error)
}

Coordinator is the storage-agnostic backend Manager uses to arbitrate certificate issuance and distribute the result across every process that shares it. It is owned by this library rather than by GoAkt: GoAkt's cluster KV store is PA/EC with last-write-wins semantics and documents its own lock as "recommended for efficiency, not correctness", which is the wrong substrate for a certificate issuance lock - a duplicated issuance burns CA rate limit.

A Coordinator does not have to be cluster-shared: NewMemoryCoordinator (the default) is process-local and gives Manager the same single-flight-only behavior a non-clustered deployment needs. Share a Coordinator implementation across processes (e.g. coordinator/redis) to get cluster-wide arbitration and distribution.

Implementations must be safe for concurrent use.

type Drainer

type Drainer interface {
	Drain()
}

Drainer is implemented by connection handlers (WSHandler, SSEHandler) that hold long-lived connections http.Server.Shutdown cannot evict on its own: Shutdown ignores hijacked WebSocket sockets entirely and would wait on open SSE streams until its context expired.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager terminates TLS for one or more domains from certificates shared across every process that shares its Coordinator: issuance for a given domain is arbitrated through Coordinator.TryLock so at most one caller invokes the configured CertIssuer, the result is distributed through Coordinator.Put/Get, and a cron renewal schedule (single-fire cluster-wide by GoAkt scheduler design when running in a GoAkt cluster) drives renewal ahead of expiry.

The issuance lock is held for at most WithIssuanceLockTTL and is not renewed/extended while a CertIssuer call is outstanding: if issuance takes longer than lockTTL, the lock can expire mid-issuance, letting a second process acquire it and also call the issuer. doEnsure detects this after the fact and returns ErrIssuanceLockExpired instead of caching the result, but the extra CertIssuer call itself is not prevented. Set WithIssuanceLockTTL comfortably longer than your CertIssuer's worst-case latency.

Without an explicit WithCoordinator, Manager defaults to a process-local MemoryCoordinator: issuance is still deduplicated (so concurrent handshakes for a cold domain only call the issuer once per process) but nothing is shared across processes. Share a cluster-backed Coordinator (e.g. coordinator/redis) to get cluster-wide arbitration and distribution.

A Manager is safe for concurrent use. Use NewManager to construct one.

func NewManager

func NewManager(system actor.ActorSystem, logger log.Logger, opts ...ManagerOption) *Manager

NewManager creates a Manager backed by system. Call Start before serving traffic so the renewal schedule is registered, and Stop during shutdown to release it.

func (*Manager) EnsureCertificate

func (m *Manager) EnsureCertificate(ctx context.Context, domain string) (*tls.Certificate, error)

EnsureCertificate returns a ready-to-serve certificate for domain, issuing (or fetching from another caller's issuance) one if necessary. Concurrent calls for the same domain on this process are deduplicated; concurrent calls across every process that shares this Manager's Coordinator are arbitrated so only one of them calls the configured CertIssuer (see Manager documentation).

It returns ErrDomainNotAllowed if WithAllowedDomains was set and domain is not in it, and ErrNoIssuer if issuance is required but no CertIssuer was configured.

func (*Manager) GetCertificate

func (m *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)

GetCertificate implements the tls.Config.GetCertificate signature for SNI-based dynamic certificate lookup: assign it directly, e.g. tls.Config{GetCertificate: manager.GetCertificate}.

func (*Manager) Start

func (m *Manager) Start(ctx context.Context) error

Start registers the renewal schedule (cluster-single-fire when system is running in a GoAkt cluster). It is a no-op if renewal was disabled via WithRenewInterval("").

func (*Manager) Stop

func (m *Manager) Stop(ctx context.Context) error

Stop cancels the renewal schedule and stops its backing actor. It is a no-op if Start was never called or renewal was disabled.

func (*Manager) TLSConfig

func (m *Manager) TLSConfig() *tls.Config

TLSConfig returns a *tls.Config wired to serve certificates dynamically by SNI through this Manager.

type ManagerOption

type ManagerOption func(*Manager)

ManagerOption configures a Manager created with NewManager.

func WithAllowedDomains

func WithAllowedDomains(domains ...string) ManagerOption

WithAllowedDomains restricts issuance/serving to the given domains. Without this option, any domain requested via GetCertificate/EnsureCertificate is allowed.

func WithCertIssuer

func WithCertIssuer(issuer CertIssuer) ManagerOption

WithCertIssuer sets the CertIssuer used to obtain certificates that are not already cached or stored. Without one, Manager can only serve certificates that a CertStore already has (e.g. pre-populated via CertStore.Put).

func WithCertStore

func WithCertStore(store CertStore) ManagerOption

WithCertStore overrides the persistent CertStore layer. Defaults to a MemoryCertStore.

func WithCoordinator

func WithCoordinator(coordinator Coordinator) ManagerOption

WithCoordinator overrides the Coordinator Manager uses to arbitrate issuance and distribute certificates. Defaults to a process-local NewMemoryCoordinator; pass a cluster-shared implementation (e.g. coordinator/redis) to coordinate issuance across every process that shares it.

func WithIssuanceLockTTL

func WithIssuanceLockTTL(d time.Duration) ManagerOption

WithIssuanceLockTTL sets how long the coordinated issuance lock for a domain is held for. Defaults to 2 minutes. The lock is not renewed while the CertIssuer call is in flight, so set this comfortably longer than your CertIssuer's worst-case latency: if issuance overruns it, doEnsure returns ErrIssuanceLockExpired instead of silently caching a result whose single-issuer guarantee is no longer certain.

func WithRenewBefore

func WithRenewBefore(d time.Duration) ManagerOption

WithRenewBefore sets how far ahead of a certificate's expiry Manager renews it. Defaults to 30 days.

func WithRenewInterval

func WithRenewInterval(cronExpression string) ManagerOption

WithRenewInterval sets the cron expression (go-quartz syntax, 6 fields with seconds first) the renewal schedule uses to check for expiring certificates. Defaults to hourly ("0 0 * * * *"). Pass an empty string to disable automatic renewal entirely (EnsureCertificate/GetCertificate will still re-issue on-demand once a served certificate has actually expired).

type MemoryCertStore

type MemoryCertStore struct {
	// contains filtered or unexported fields
}

MemoryCertStore is the default, in-process CertStore. It does not survive a process restart, which is why Manager also always keeps a copy in the configured Coordinator: on a single node restart the certificate is fetched back from there, and on a full cold start (no Coordinator, or a fresh one) it is re-issued.

func NewMemoryCertStore

func NewMemoryCertStore() *MemoryCertStore

NewMemoryCertStore creates an empty MemoryCertStore.

func (*MemoryCertStore) Delete

func (m *MemoryCertStore) Delete(_ context.Context, domain string) error

Delete implements CertStore.

func (*MemoryCertStore) Get

func (m *MemoryCertStore) Get(_ context.Context, domain string) (*Certificate, error)

Get implements CertStore.

func (*MemoryCertStore) Put

func (m *MemoryCertStore) Put(_ context.Context, cert *Certificate) error

Put implements CertStore.

type MemoryCoordinator

type MemoryCoordinator struct {
	// contains filtered or unexported fields
}

MemoryCoordinator is the default, in-process Coordinator. It is appropriate for a single-node deployment, local development, and tests: TryLock/Put/Get only coordinate callers within this process, so it gives Manager exactly the "issue once per process" behavior a non-clustered gateway needs. Share a cluster-backed Coordinator (e.g. coordinator/redis) across processes to get cluster-wide arbitration.

func NewMemoryCoordinator

func NewMemoryCoordinator() *MemoryCoordinator

NewMemoryCoordinator creates an empty MemoryCoordinator.

func (*MemoryCoordinator) Get

func (m *MemoryCoordinator) Get(_ context.Context, key string) ([]byte, bool, error)

Get implements Coordinator.

func (*MemoryCoordinator) Put

func (m *MemoryCoordinator) Put(_ context.Context, key string, value []byte, ttl time.Duration) error

Put implements Coordinator.

func (*MemoryCoordinator) TryLock

func (m *MemoryCoordinator) TryLock(_ context.Context, key string, ttl time.Duration) (func(context.Context) error, error)

TryLock implements Coordinator. Ownership is tracked with a per-acquisition token so that an unlock call can never release a lock some other caller has since acquired after this one's ttl elapsed.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is the local, per-node table of registered WebSocket/SSE connections. It is the "local connection table" the two-tier delivery model described in the gateway package documentation is built around: SendToConnection and Broadcast always prefer a direct write to a locally held connection over any actor/cluster machinery, and only fall back to cluster addressing when the target is not held by this node.

A Registry is safe for concurrent use.

func NewRegistry

func NewRegistry(system actor.ActorSystem, logger log.Logger) *Registry

NewRegistry creates a Registry backed by system. system is used to spawn the per-connection ephemeral actors (relocation disabled, long-lived passivation) that make registered connections addressable from other nodes, and to bridge topic broadcasts across the cluster (see bridge.go).

func (*Registry) Broadcast

func (r *Registry) Broadcast(ctx context.Context, topic string, payload []byte) error

Broadcast delivers payload to every connection joined to topic across the cluster. Local members are written to directly; remote members are reached through the topic bridge (see bridge.go).

func (*Registry) Has

func (r *Registry) Has(id string) bool

Has reports whether id is registered on this node.

func (*Registry) Join

func (r *Registry) Join(_ context.Context, id, topic string) error

Join adds an already-registered connection to a topic's local membership, creating the cluster fan-out bridge for that topic on first use. It returns ErrConnectionNotFound if id is not registered.

func (*Registry) Leave

func (r *Registry) Leave(_ context.Context, id, topic string) error

Leave removes an already-registered connection from a topic's local membership, tearing down the cluster fan-out bridge for that topic once its last local member leaves.

func (*Registry) Len

func (r *Registry) Len() int

Len returns the number of connections registered on this node.

func (*Registry) Register

func (r *Registry) Register(ctx context.Context, id string, send func([]byte) error, topics ...string) error

Register adds a new connection to the local table and makes it addressable cluster-wide under connActorName(id). send is invoked (from any goroutine, including remote-delivery ones) whenever a payload must be written to the underlying socket; it must be non-blocking or perform its own internal buffering, since Registry never queues on the caller's behalf.

The id is reserved in the table before the backing actor is spawned, so a concurrent Register for the same id fails immediately with ErrConnectionExists rather than racing past the reservation. If an Unregister for id arrives while the actor is still spawning, Register rolls back the spawn and returns ErrConnectionClosed instead of resurrecting a connection the Unregister caller already believes is gone.

It returns ErrConnectionExists if id is already registered.

func (*Registry) SendToConnection

func (r *Registry) SendToConnection(ctx context.Context, id string, payload []byte) error

SendToConnection delivers payload to the connection identified by id anywhere in the cluster. If id is registered on this node, payload is written directly to the socket with no actor or cluster machinery involved. Otherwise, SendToConnection resolves the connection's owning node through the cluster-aware actor directory (ActorSystem.ActorOf) and delivers payload there.

It returns ErrConnectionNotFound if id is not registered anywhere in the cluster.

func (*Registry) Unregister

func (r *Registry) Unregister(ctx context.Context, id string) error

Unregister removes a connection from the local table, leaves every topic it had joined, and shuts down its backing actor. It is a no-op if id is not registered.

If id is currently reserved by an in-flight Register (its actor is still spawning), Unregister marks the reservation dead and returns immediately: the in-flight Register observes this and rolls back the spawn itself, since it is the only side that can safely stop the actor it just created.

type SSEHandler

type SSEHandler struct {
	// contains filtered or unexported fields
}

SSEHandler opens a Server-Sent Events stream for every incoming request and registers each one in its Registry for the lifetime of the connection. SSE is one-way (server to client); inbound application data, if any, belongs in an ordinary HTTP endpoint the client posts to separately.

func NewSSEHandler

func NewSSEHandler(registry *Registry, opts ...SSEHandlerOption) *SSEHandler

NewSSEHandler returns an SSEHandler bound to registry. The returned handler is an http.Handler; wire its Drain method into Server via WithDrainOnShutdown (or call it from your own shutdown path) so open streams terminate promptly on shutdown.

func (*SSEHandler) Drain

func (h *SSEHandler) Drain()

Drain terminates every open SSE stream and makes new requests fail fast with 503, so a graceful server shutdown is not held hostage by long-lived streams. Safe to call more than once.

func (*SSEHandler) ServeHTTP

func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type SSEHandlerOption

type SSEHandlerOption func(*SSEHandler)

SSEHandlerOption configures an http.Handler created with NewSSEHandler.

func WithSSEAuthFunc

func WithSSEAuthFunc(f func(*http.Request) error) SSEHandlerOption

WithSSEAuthFunc sets the auth hook run before a connection is accepted and registered. A non-nil error rejects the request with HTTP 403 Forbidden.

func WithSSEIDFunc

func WithSSEIDFunc(f func(*http.Request) string) SSEHandlerOption

WithSSEIDFunc sets the function used to derive a connection's id from the request. Without one, a random UUID is generated per connection.

func WithSSEKeepAlive

func WithSSEKeepAlive(d time.Duration) SSEHandlerOption

WithSSEKeepAlive sets the interval at which a comment-only keepalive event is sent to detect dead connections and prevent idle-timing-out intermediate proxies. Defaults to 15 seconds.

func WithSSELogger

func WithSSELogger(logger log.Logger) SSEHandlerOption

WithSSELogger sets the logger used to report connection-handling errors. Defaults to log.DiscardLogger.

func WithSSEOnConnect

func WithSSEOnConnect(f func(ctx context.Context, id string, r *http.Request)) SSEHandlerOption

WithSSEOnConnect sets the callback invoked once a connection has been registered and the response stream has been opened.

func WithSSEOnDisconnect

func WithSSEOnDisconnect(f func(id string)) SSEHandlerOption

WithSSEOnDisconnect sets the callback invoked once a connection has been unregistered.

func WithSSESendBuffer

func WithSSESendBuffer(size int) SSEHandlerOption

WithSSESendBuffer sets the size of each connection's outbound buffer. When full, Registry.SendToConnection/Broadcast deliveries to that connection return ErrBackpressure instead of blocking. Defaults to 256.

func WithSSETopics

func WithSSETopics(f func(*http.Request) []string) SSEHandlerOption

WithSSETopics sets the function used to derive the topics a connection should be joined to at registration time.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a thin convenience wrapper around http.Server that wires a Manager's cluster-shared, SNI-dynamic TLS certificates (and, optionally, Authenticated Origin Pulls mTLS verification) into the standard library's TLS listener. Plain HTTP handlers, WebSocket upgrades (NewWSHandler), and SSE streams (NewSSEHandler) are all just http.Handler values mounted on Server like on any other *http.Server - Server adds nothing actor- or cluster-related to the request path itself.

Using Server is entirely optional: any application already managing its own *http.Server can instead set TLSConfig: manager.TLSConfig() directly.

func NewServer

func NewServer(addr string, handler http.Handler, opts ...ServerOption) (*Server, error)

NewServer creates a Server listening on addr and dispatching to handler.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(ctx context.Context) error

ListenAndServe starts the Manager's renewal schedule (if any) and serves traffic, terminating TLS through it when WithTLSManager was set, or serving plain HTTP otherwise. It blocks until the server stops (see Shutdown) and returns http.ErrServerClosed on a clean shutdown.

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully stops the server: it first drains handlers registered via WithDrainOnShutdown (evicting long-lived WebSocket/SSE connections that http.Server.Shutdown cannot terminate itself), then stops the Manager's renewal schedule if any, and finally shuts the HTTP listener down.

type ServerOption

type ServerOption func(*Server)

ServerOption configures a Server created with NewServer.

func WithAuthenticatedOriginPulls

func WithAuthenticatedOriginPulls(pulls *AuthenticatedOriginPulls) ServerOption

WithAuthenticatedOriginPulls enables mTLS verification of inbound connections against pulls's configured CA (see AuthenticatedOriginPulls), rejecting any connection that does not present a valid client certificate. Requires WithTLSManager.

func WithDrainOnShutdown

func WithDrainOnShutdown(drainers ...Drainer) ServerOption

WithDrainOnShutdown registers connection handlers whose Drain method Server.Shutdown invokes before shutting the HTTP listener down, so long-lived WebSocket/SSE connections are evicted promptly and clients reconnect to surviving replicas during rolling deploys.

func WithReadHeaderTimeout

func WithReadHeaderTimeout(d time.Duration) ServerOption

WithReadHeaderTimeout sets the underlying http.Server's ReadHeaderTimeout.

func WithTLSManager

func WithTLSManager(manager *Manager) ServerOption

WithTLSManager configures Server to terminate TLS using manager's cluster-shared, SNI-dynamic certificates. Without this option, Server serves plain HTTP.

type StaticIssuer

type StaticIssuer struct {
	// contains filtered or unexported fields
}

StaticIssuer is a CertIssuer that serves a fixed, pre-provisioned certificate for one or more domains, e.g. loaded from files at startup. It never renews: Certificate.NotAfter reflects whatever was supplied, and Manager's renewal schedule will simply keep re-"issuing" (returning) the same static material.

func NewStaticIssuer

func NewStaticIssuer(cert *Certificate, domains ...string) *StaticIssuer

NewStaticIssuer creates a StaticIssuer that serves cert for every domain in domains.

func (*StaticIssuer) Issue

func (s *StaticIssuer) Issue(_ context.Context, domain string) (*Certificate, error)

Issue implements CertIssuer.

type WSHandler

type WSHandler struct {
	// contains filtered or unexported fields
}

WSHandler upgrades incoming requests to WebSocket connections and registers each one in its Registry for the lifetime of the socket. It implements http.Handler; mount it only on the path(s) meant to accept WebSocket upgrades - plain (non-upgrade) HTTP handling is entirely out of scope here by design.

func NewWSHandler

func NewWSHandler(registry *Registry, opts ...WSHandlerOption) *WSHandler

NewWSHandler returns a WSHandler bound to registry. The returned handler is an http.Handler; wire its Drain method into Server via WithDrainOnShutdown (or call it from your own shutdown path) so established sockets are evicted on shutdown.

func (*WSHandler) Drain

func (h *WSHandler) Drain()

Drain closes every established WebSocket connection and rejects connections that are mid-upgrade, so a graceful server shutdown evicts long-lived sockets instead of leaving them until the peer disconnects. Safe to call more than once.

func (*WSHandler) ServeHTTP

func (h *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type WSHandlerOption

type WSHandlerOption func(*WSHandler)

WSHandlerOption configures a WebSocket http.Handler created with NewWSHandler.

func WithWSAuthFunc

func WithWSAuthFunc(f func(*http.Request) error) WSHandlerOption

WithWSAuthFunc sets the auth hook run during the WebSocket handshake, before the connection is accepted or registered. A non-nil error rejects the upgrade with HTTP 403 Forbidden.

func WithWSIDFunc

func WithWSIDFunc(f func(*http.Request) string) WSHandlerOption

WithWSIDFunc sets the function used to derive a connection's id from the upgrade request. Without one, a random UUID is generated per connection.

func WithWSLogger

func WithWSLogger(logger log.Logger) WSHandlerOption

WithWSLogger sets the logger used to report connection-handling errors. Defaults to log.DiscardLogger.

func WithWSOnConnect

func WithWSOnConnect(f func(ctx context.Context, id string, r *http.Request)) WSHandlerOption

WithWSOnConnect sets the callback invoked once a connection has been registered and is ready to receive deliveries.

func WithWSOnDisconnect

func WithWSOnDisconnect(f func(id string)) WSHandlerOption

WithWSOnDisconnect sets the callback invoked once a connection has been unregistered.

func WithWSOnMessage

func WithWSOnMessage(f func(ctx context.Context, id string, payload []byte)) WSHandlerOption

WithWSOnMessage sets the callback invoked for every text/binary message received from the client.

func WithWSSendBuffer

func WithWSSendBuffer(size int) WSHandlerOption

WithWSSendBuffer sets the size of each connection's outbound buffer. When full, Registry.SendToConnection/Broadcast deliveries to that connection return ErrBackpressure instead of blocking. Defaults to 256.

func WithWSTopics

func WithWSTopics(f func(*http.Request) []string) WSHandlerOption

WithWSTopics sets the function used to derive the topics a connection should be joined to at registration time, from the upgrade request (e.g. a room/channel query parameter or claim in an auth token).

Directories

Path Synopsis
coordinator
conformance
Package conformance is a shared test suite every gateway.Coordinator implementation must pass, so gateway.MemoryCoordinator and coordinator/redis.Coordinator (and any third-party implementation) are held to the exact same contract.
Package conformance is a shared test suite every gateway.Coordinator implementation must pass, so gateway.MemoryCoordinator and coordinator/redis.Coordinator (and any third-party implementation) are held to the exact same contract.
redis
Package redis provides a Redis-backed gateway.Coordinator, for sharing certificate issuance arbitration and distribution across every process in a deployment.
Package redis provides a Redis-backed gateway.Coordinator, for sharing certificate issuance arbitration and distribution across every process in a deployment.
examples
echo command
Command echo is a minimal, runnable demonstration of the gateway package:
Command echo is a minimal, runnable demonstration of the gateway package:

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL