Skip to content

Public Ingress

The public ingress feature extends bridge mode (internal/bridge) to expose mesh-internal services to public internet traffic through the bridge node. The bridge node accepts TCP connections on configured public ports and proxies them to target mesh peers. TLS can be terminated at the bridge or passed through transparently.

Data Flow

Public Internet Clients
(HTTP / HTTPS / TCP)
        |
        |  TCP connection
        v
+---------------------------------------------------------------+
|                        Bridge Node                              |
|                                                                 |
|  +-------------------+         +-------------------+           |
|  |  TCP Listeners    |  proxy  |  Mesh WireGuard   |           |
|  |  (per ingress     |-------->|  Interface        |           |
|  |   rule)           |         |  (plexd0)         |           |
|  |  :8080, :443, ... |         |                   |           |
|  +-------------------+         +---------+---------+           |
|         ^                                |                     |
|         |                                v                     |
|  +------+------------+            +------------------+           |
|  | IngressController |            |  Mesh Peers      |           |
|  | (TCP operations)  |            |  10.42.0.0/16    |           |
|  +---------+---------+            +------------------+           |
|            |                                                     |
|    TLS terminate:                                                |
|    tls.NewListener                                               |
|    TLS passthrough:                                              |
|    raw TCP forward                                               |
|                                                                 |
|  Control Plane --SSE--> bridge_config_updated --> reconcile      |
|                --Rec--> IngressReconcileHandler                   |
+-----------------------------------------------------------------+

Traffic from public internet clients arrives on per-rule TCP listeners, is proxied via bidirectional io.Copy relay to the target mesh peer address, and reaches mesh-internal services. For TLS terminate mode, the bridge decrypts TLS before forwarding plaintext. For passthrough mode, raw TCP bytes are forwarded without decryption.

Config

Ingress fields extend the existing bridge Config struct. Ingress requires bridge mode to be enabled (Enabled=true).

FieldTypeDefaultDescription
IngressEnabledboolfalseWhether public ingress is active
MaxIngressRulesint20Maximum number of concurrent ingress rules
IngressDialTimeouttime.Duration10sTimeout for dialing target mesh peers
go
cfg := bridge.Config{
    Enabled:        true,
    AccessInterface: "eth1",
    AccessSubnets:  []string{"10.0.0.0/24"},
    IngressEnabled: true,
}
cfg.ApplyDefaults() // sets MaxIngressRules, IngressDialTimeout
if err := cfg.Validate(); err != nil {
    log.Fatal(err)
}

Defaults

ApplyDefaults() sets zero-valued ingress fields:

FieldZero ValueDefault Applied
MaxIngressRules0DefaultMaxIngressRules (20)
IngressDialTimeout0DefaultIngressDialTimeout (10s)

Validation Rules

Ingress validation is skipped when IngressEnabled is false. When enabled:

FieldRuleError Message
IngressEnabledRequires Enabled=truebridge: config: ingress requires bridge mode to be enabled
MaxIngressRulesMust be > 0bridge: config: MaxIngressRules must be positive when ingress is enabled
IngressDialTimeoutMust be >= 1sbridge: config: IngressDialTimeout must be at least 1s

IngressController

Interface abstracting TCP listener operations for ingress testability. The production implementation is provided externally; this package defines and consumes the interface.

go
type IngressController interface {
    Listen(addr string, tlsCfg *tls.Config) (net.Listener, error)
    Close(listener net.Listener) error
}
MethodDescription
ListenCreates a TCP listener; wraps with tls.NewListener if tlsCfg is set
CloseCloses the given listener; idempotent

IngressManager

Central coordinator for public ingress lifecycle. Concurrent-safe via sync.Mutex — the reconcile handler and status readers may invoke methods concurrently. Active proxy connections are tracked via atomic.Int64 for lock-free counting.

Constructor

go
func NewIngressManager(ctrl IngressController, cfg Config, logger *slog.Logger, acme *ACMEManager) *IngressManager

Methods

MethodSignatureDescription
Setup() errorMarks manager active; no-op when disabled
Teardown() errorCloses all listeners, cancels connections; aggregates errors
AddRule(rule api.IngressRule) errorStarts listener, spawns accept loop; rejects duplicates/max
RemoveRule(ruleID string)Stops listener, waits for goroutine exit; no-op if not found
RuleIDs() []stringReturns IDs of all active rules
IngressStatus() *api.IngressInfoReturns status for heartbeat; nil when inactive
IngressCapabilities() map[string]stringReturns capability metadata for registration; nil when disabled

Lifecycle

go
mgr := bridge.NewIngressManager(ingressCtrl, cfg, logger)

// Setup — marks manager active
if err := mgr.Setup(); err != nil {
    log.Fatal(err)
}

// Add a rule (driven by the reconcile handler)
err := mgr.AddRule(api.IngressRule{
    RuleID:     "web-https",
    ListenPort: 443,
    TargetAddr: "10.42.0.5:8080",
    Mode:       "terminate",
    CertPEM:    certPEM,
    KeyPEM:     keyPEM,
})

// Remove a rule
mgr.RemoveRule("web-https")

// Report status in heartbeat
status := mgr.IngressStatus()

// Capabilities for registration
caps := mgr.IngressCapabilities()
// {"ingress": "true", "max_ingress_rules": "20"}

// Graceful shutdown
if err := mgr.Teardown(); err != nil {
    logger.Warn("teardown failed", "error", err)
}

Setup

When IngressEnabled is false, Setup is a no-op. When enabled, it marks the manager as active and logs the configuration.

Teardown

Teardown closes all active listeners and cancels proxy connections:

  1. Cancel all accept loop contexts
  2. Close all listeners via IngressController.Close
  3. Release the mutex
  4. Wait for all accept loop goroutines to exit (via done channels)

Errors are aggregated via errors.Join — cleanup continues even when individual operations fail. Calling Teardown when the manager is inactive is a no-op.

AddRule

  1. Rejects duplicate rule IDs (rule already exists)
  2. Rejects if MaxIngressRules limit is reached (max rules reached)
  3. For TLS terminate mode: parses CertPEM/KeyPEM via tls.X509KeyPair, builds tls.Config with MinVersion: tls.VersionTLS12
  4. Calls IngressController.Listen to create the TCP listener
  5. Spawns an acceptLoop goroutine with a cancellable context
  6. Tracks the rule in the internal activeRules map

RemoveRule

  1. If the rule ID is not tracked, returns immediately (no-op)
  2. Cancels the accept loop context
  3. Closes the listener via IngressController.Close
  4. Waits for the accept loop goroutine to exit (via done channel)

TCP Proxy

Each accepted connection spawns a proxyConnection goroutine:

  1. Increments the atomic connection counter
  2. Dials the target address with IngressDialTimeout
  3. Runs two io.Copy goroutines for bidirectional relay
  4. On context cancellation or either copy finishing, closes both connections
  5. Decrements the connection counter on exit

TLS Modes

ModeBehavior
passthroughRaw TCP bytes forwarded without decryption; no certificates required
terminateBridge performs TLS handshake with static tls.Config; plaintext forwarded to target
acmeAutomatic certificate via ACME (Let's Encrypt); requires Hostname on the rule

TLS terminate mode enforces minimum TLS 1.2 via tls.Config.MinVersion.

SSE Event Handling

There are no ingress-specific SSE handlers. The control plane emits a single bridge_config_updated event with an opaque payload; bridge.HandleBridgeConfigUpdated dispatches it to TriggerReconcile(), and the IngressReconcileHandler below applies the desired ingress subtree from the authoritative state snapshot.

go
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventBridgeConfigUpdated,
    bridge.HandleBridgeConfigUpdated(reconciler))

IngressReconcileHandler

go
func IngressReconcileHandler(mgr *IngressManager, logger *slog.Logger) reconcile.ReconcileHandler

Returns a reconcile.ReconcileHandler that synchronizes ingress rules to match the desired bridge ingress subtree. The handler is presence-aware: a null Bridge or null Ingress child means "not populated", so it reconciles against an empty desired set and tears down stale rules.

  1. Reads rules from desired.Bridge.Ingress.Rules (empty when Bridge or Ingress is nil)
  2. Builds a desired set keyed by RuleID
  3. Removes stale rules: current rule IDs not in the desired set
  4. Adds missing rules: desired rules not in the current set
  5. Aggregates AddRule errors via errors.Join

Registration

go
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(bridge.RelayReconcileHandler(bridgeMgr.Relay(), logger))
r.RegisterHandler(bridge.UserAccessReconcileHandler(accessMgr, logger))
r.RegisterHandler(bridge.IngressReconcileHandler(ingressMgr, logger))

API Types

IngressConfig

Pushed from the control plane in the snapshot bridge.ingress subtree (api.BridgeSnapshot.Ingress), present-but-nullable — a null value tears down active rules.

go
type IngressConfig struct {
    Enabled bool          `json:"enabled"`
    Rules   []IngressRule `json:"rules"`
}

IngressRule

Represents a single public ingress rule.

go
type IngressRule struct {
    RuleID     string `json:"rule_id"`
    ListenPort int    `json:"listen_port"`
    TargetAddr string `json:"target_addr"`
    Mode       string `json:"mode"`
    CertPEM    string `json:"cert_pem,omitempty"`
    KeyPEM     string `json:"key_pem,omitempty"`
    Hostname   string `json:"hostname,omitempty"`
}
FieldDescription
RuleIDUnique identifier for the rule
ListenPortPublic TCP port to listen on
TargetAddrMesh peer address to proxy traffic to (host:port)
ModeTLS mode: passthrough (raw TCP), terminate (static cert), or acme (automatic cert)
CertPEMPEM-encoded certificate for terminate mode (optional for passthrough/acme)
KeyPEMPEM-encoded private key for terminate mode (optional for passthrough/acme)
HostnameHostname for ACME mode certificate provisioning (optional for other modes)

IngressInfo

Reported in heartbeats via api.HeartbeatRequest.Ingress.

go
type IngressInfo struct {
    Enabled         bool `json:"enabled"`
    RuleCount       int  `json:"rule_count"`
    ConnectionCount int  `json:"connection_count"`
    ACMEEnabled     bool `json:"acme_enabled"`
}

SSE Event Constants

Ingress changes are delivered through the single bridge event constant; the fine-grained ingress_* constants have been removed.

ConstantValue
api.EventBridgeConfigUpdated"bridge_config_updated"

Error Prefixes

SourcePrefix
IngressManager.AddRule (dup)bridge: ingress: rule already exists:
IngressManager.AddRule (max)bridge: ingress: max rules reached (
IngressManager.AddRule (TLS)bridge: ingress: rule <id>: load TLS certificate:
IngressManager.AddRule (listen)bridge: ingress: rule <id>: listen on <addr>:
IngressManager.Teardown (close)bridge: ingress: close rule <id>:

Logging

All ingress log entries use component=bridge.

LevelEventKeys
InfoIngress manager startedmax_rules, dial_timeout
InfoIngress manager stopped(none)
InfoIngress rule addedrule_id, listen_port, target, mode
InfoIngress rule removedrule_id
ErrorDial target failedrule_id, target, error
ErrorClose rule failedrule_id, error
ErrorReconcile: add rule failedrule_id, error

Integration Points

Reconciliation Loop

The ingress reconcile handler plugs into internal/reconcile alongside existing handlers:

go
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(wireguard.ReconcileHandler(wgMgr))
r.RegisterHandler(policy.ReconcileHandler(enforcer, "plexd0"))
r.RegisterHandler(bridge.RelayReconcileHandler(bridgeMgr.Relay(), logger))
r.RegisterHandler(bridge.UserAccessReconcileHandler(accessMgr, logger))
r.RegisterHandler(bridge.IngressReconcileHandler(ingressMgr, logger))

SSE Real-Time Updates

The bridge_config_updated event triggers a full reconcile; the IngressReconcileHandler then applies the desired rule set from the state snapshot. There are no per-rule events.

Control Plane Types

TypePackageUsage
api.IngressConfiginternal/apiDesired ingress config from control plane
api.IngressRuleinternal/apiIndividual ingress rule definition
api.IngressInfointernal/apiIngress status in heartbeats
api.BridgeSnapshotinternal/apiSnapshot bridge subtree (contains Ingress)
api.HeartbeatRequestinternal/apiHeartbeat payload (contains IngressInfo)
api.Envelopeinternal/apiSSE event wrapper
api.EventBridgeConfigUpdatedinternal/apiEvent type "bridge_config_updated"

Heartbeat Reporting

go
heartbeat := api.HeartbeatRequest{
    Ingress: ingressMgr.IngressStatus(), // nil when inactive
}

Registration Capabilities

go
caps := ingressMgr.IngressCapabilities()
// {"ingress": "true", "max_ingress_rules": "20"}
// nil when ingress is disabled

Graceful Shutdown

go
<-ctx.Done()
if err := ingressMgr.Teardown(); err != nil {
    logger.Warn("ingress teardown failed", "error", err)
}

Full Lifecycle

go
cfg := bridge.Config{
    Enabled:        true,
    AccessInterface: "eth1",
    AccessSubnets:  []string{"10.0.0.0/24"},
    IngressEnabled: true,
}
cfg.ApplyDefaults()

ingressMgr := bridge.NewIngressManager(ingressCtrl, cfg, logger)

// Setup ingress manager
ingressMgr.Setup()

// Register the bridge SSE handler
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventBridgeConfigUpdated,
    bridge.HandleBridgeConfigUpdated(reconciler))

// Register reconcile handler
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(bridge.IngressReconcileHandler(ingressMgr, logger))

// Run reconciler
go r.Run(ctx, nodeID)

// Graceful shutdown
<-ctx.Done()
ingressMgr.Teardown()