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).
| Field | Type | Default | Description |
|---|---|---|---|
IngressEnabled | bool | false | Whether public ingress is active |
MaxIngressRules | int | 20 | Maximum number of concurrent ingress rules |
IngressDialTimeout | time.Duration | 10s | Timeout for dialing target mesh peers |
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:
| Field | Zero Value | Default Applied |
|---|---|---|
MaxIngressRules | 0 | DefaultMaxIngressRules (20) |
IngressDialTimeout | 0 | DefaultIngressDialTimeout (10s) |
Validation Rules
Ingress validation is skipped when IngressEnabled is false. When enabled:
| Field | Rule | Error Message |
|---|---|---|
IngressEnabled | Requires Enabled=true | bridge: config: ingress requires bridge mode to be enabled |
MaxIngressRules | Must be > 0 | bridge: config: MaxIngressRules must be positive when ingress is enabled |
IngressDialTimeout | Must be >= 1s | bridge: 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.
type IngressController interface {
Listen(addr string, tlsCfg *tls.Config) (net.Listener, error)
Close(listener net.Listener) error
}| Method | Description |
|---|---|
Listen | Creates a TCP listener; wraps with tls.NewListener if tlsCfg is set |
Close | Closes 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
func NewIngressManager(ctrl IngressController, cfg Config, logger *slog.Logger, acme *ACMEManager) *IngressManagerMethods
| Method | Signature | Description |
|---|---|---|
Setup | () error | Marks manager active; no-op when disabled |
Teardown | () error | Closes all listeners, cancels connections; aggregates errors |
AddRule | (rule api.IngressRule) error | Starts listener, spawns accept loop; rejects duplicates/max |
RemoveRule | (ruleID string) | Stops listener, waits for goroutine exit; no-op if not found |
RuleIDs | () []string | Returns IDs of all active rules |
IngressStatus | () *api.IngressInfo | Returns status for heartbeat; nil when inactive |
IngressCapabilities | () map[string]string | Returns capability metadata for registration; nil when disabled |
Lifecycle
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:
- Cancel all accept loop contexts
- Close all listeners via
IngressController.Close - Release the mutex
- Wait for all accept loop goroutines to exit (via
donechannels)
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
- Rejects duplicate rule IDs (
rule already exists) - Rejects if
MaxIngressRuleslimit is reached (max rules reached) - For TLS terminate mode: parses
CertPEM/KeyPEMviatls.X509KeyPair, buildstls.ConfigwithMinVersion: tls.VersionTLS12 - Calls
IngressController.Listento create the TCP listener - Spawns an
acceptLoopgoroutine with a cancellable context - Tracks the rule in the internal
activeRulesmap
RemoveRule
- If the rule ID is not tracked, returns immediately (no-op)
- Cancels the accept loop context
- Closes the listener via
IngressController.Close - Waits for the accept loop goroutine to exit (via
donechannel)
TCP Proxy
Each accepted connection spawns a proxyConnection goroutine:
- Increments the atomic connection counter
- Dials the target address with
IngressDialTimeout - Runs two
io.Copygoroutines for bidirectional relay - On context cancellation or either copy finishing, closes both connections
- Decrements the connection counter on exit
TLS Modes
| Mode | Behavior |
|---|---|
passthrough | Raw TCP bytes forwarded without decryption; no certificates required |
terminate | Bridge performs TLS handshake with static tls.Config; plaintext forwarded to target |
acme | Automatic 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.
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventBridgeConfigUpdated,
bridge.HandleBridgeConfigUpdated(reconciler))IngressReconcileHandler
func IngressReconcileHandler(mgr *IngressManager, logger *slog.Logger) reconcile.ReconcileHandlerReturns 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.
- Reads rules from
desired.Bridge.Ingress.Rules(empty whenBridgeorIngressis nil) - Builds a desired set keyed by
RuleID - Removes stale rules: current rule IDs not in the desired set
- Adds missing rules: desired rules not in the current set
- Aggregates
AddRuleerrors viaerrors.Join
Registration
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.
type IngressConfig struct {
Enabled bool `json:"enabled"`
Rules []IngressRule `json:"rules"`
}IngressRule
Represents a single public ingress rule.
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"`
}| Field | Description |
|---|---|
RuleID | Unique identifier for the rule |
ListenPort | Public TCP port to listen on |
TargetAddr | Mesh peer address to proxy traffic to (host:port) |
Mode | TLS mode: passthrough (raw TCP), terminate (static cert), or acme (automatic cert) |
CertPEM | PEM-encoded certificate for terminate mode (optional for passthrough/acme) |
KeyPEM | PEM-encoded private key for terminate mode (optional for passthrough/acme) |
Hostname | Hostname for ACME mode certificate provisioning (optional for other modes) |
IngressInfo
Reported in heartbeats via api.HeartbeatRequest.Ingress.
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.
| Constant | Value |
|---|---|
api.EventBridgeConfigUpdated | "bridge_config_updated" |
Error Prefixes
| Source | Prefix |
|---|---|
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.
| Level | Event | Keys |
|---|---|---|
Info | Ingress manager started | max_rules, dial_timeout |
Info | Ingress manager stopped | (none) |
Info | Ingress rule added | rule_id, listen_port, target, mode |
Info | Ingress rule removed | rule_id |
Error | Dial target failed | rule_id, target, error |
Error | Close rule failed | rule_id, error |
Error | Reconcile: add rule failed | rule_id, error |
Integration Points
Reconciliation Loop
The ingress reconcile handler plugs into internal/reconcile alongside existing handlers:
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
| Type | Package | Usage |
|---|---|---|
api.IngressConfig | internal/api | Desired ingress config from control plane |
api.IngressRule | internal/api | Individual ingress rule definition |
api.IngressInfo | internal/api | Ingress status in heartbeats |
api.BridgeSnapshot | internal/api | Snapshot bridge subtree (contains Ingress) |
api.HeartbeatRequest | internal/api | Heartbeat payload (contains IngressInfo) |
api.Envelope | internal/api | SSE event wrapper |
api.EventBridgeConfigUpdated | internal/api | Event type "bridge_config_updated" |
Heartbeat Reporting
heartbeat := api.HeartbeatRequest{
Ingress: ingressMgr.IngressStatus(), // nil when inactive
}Registration Capabilities
caps := ingressMgr.IngressCapabilities()
// {"ingress": "true", "max_ingress_rules": "20"}
// nil when ingress is disabledGraceful Shutdown
<-ctx.Done()
if err := ingressMgr.Teardown(); err != nil {
logger.Warn("ingress teardown failed", "error", err)
}Full Lifecycle
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()