Port Management
Status: Current
Last reviewed: 2026-05-15
Route: /nodes/ports
Port Management shows Nexus-owned listener ports, the L4/L3 services that request each listener, and the agent-reported runtime state for those listeners.
It does not create upstream targets. Targets remain managed by L4 Upstreams. It does not create forwarding chains. Traffic rules still come from L4 Chains and L3 services. This page manages the listener namespace: node, transport, bind address, port, associated services, intended routes, and runtime listener state.
Core Model
| Area | Owns | Example |
|---|---|---|
| L4 Upstreams | Reusable target endpoints and inbound SNI match names | 10.0.0.10:443, service.internal:8443 |
| L4 Chains | Deployable forwarding rules | TCP TLS chain, UDP Noise chain, QUIC REALITY hop |
| Port Management | Listener ownership, service list, route visibility, and runtime state | node-a tcp :::443, L4/L3 services |
A port is not a service. A port is a listener namespace. Services come from L4 Chains or L3 routing features. Port Management makes every service on the same namespace visible and lets operators compare control-plane intent with the agent's current runtime listener table.
The backend still stores listener priority policies for API compatibility, but the current Port Management UI does not expose a save action for those policies. The agent does not blindly drop every non-winning service. It first builds a same-port route table whenever the protocols have reliable early demultiplexing signals, and only drops impossible duplicates such as a second RAW fallback on the same listener.
Same-protocol port reuse is only supported when the protocol has a reliable early demultiplexing signal:
- TCP REALITY is TLS-like on the wire: it routes by TLS SNI first and can keep multiple REALITY routes with the same SNI when their short IDs differ.
- TCP TLS can route by TLS SNI.
- UDP Noise can route by the local X25519 key that decrypts the first datagram.
- QUIC REALITY can share a UDP socket with UDP RAW or UDP Noise; the agent dispatches QUIC-looking packets to the embedded QUIC endpoint and lets normal UDP traffic continue through the UDP router.
- RAW has no discriminator and cannot safely host multiple independent RAW services on the same listener.
Control-Plane Flow
- An operator creates or updates L4 Chains.
- The backend stores chain definitions in
tunnel_chains. - An operator deploys chain or service changes.
- The backend stores optional listener policy in
node_port_policiesfor compatibility and API-managed deployments. - On chain deploy or policy update, the backend replays all enabled L4 chains for the node.
- The backend sends one
PingoraConfigcontaining upstreams, TCP rules, QUIC rules, UDP rules, TLS config, andlistener_policies. - The agent validates local bind availability, merges same-port route tables, drops only non-demultiplexable conflicts, and reconciles runtime listeners.
- Agent heartbeats report the resulting runtime listener table back to the backend.
The agent makes the final decision because only the agent can know local socket state. The backend stores intent; the agent applies it safely.
Listener Identity
The same numeric port can appear more than once because TCP and UDP are separate kernel namespaces.
| Field | Meaning |
|---|---|
node_id | Agent node that owns the listener. |
transport | tcp or udp; QUIC belongs to the UDP namespace. |
listen_addr | Bind address. L4 chain deployment currently emits ::. |
listen_port | Numeric port, 1 to 65535. |
Examples:
| Scenario | Same listener? | Notes |
|---|---|---|
tcp :::443 and udp :::443 | No | TCP TLS and UDP Noise can use the same numeric port because TCP and UDP bind separately. |
udp :::443 UDP RAW and udp :::443 QUIC REALITY | Yes | QUIC uses UDP, but QUIC REALITY can be embedded into the selected UDP socket so RAW fallback still receives non-QUIC traffic. |
tcp :::443 TCP TLS and tcp :::443 TCP REALITY | Yes | Both use TCP. The agent builds a TLS/SNI-family listener and routes TLS, REALITY, and RAW fallback traffic by the available handshake signals. |
Compatibility Policy
Default order:
REALITY > TLS > Noise > RAWAPI and database values use lowercase labels:
["reality", "tls", "noise", "raw"]The priority order is a compatibility policy for cases that still cannot be merged into one route table. Only protocol classes actually present on that listener participate. If a saved policy omits a valid label, the backend and agent append missing labels in default order.
The current frontend no longer exposes the policy save icon. API clients can still save a policy; saving a policy triggers L4 config replay for that node. The policy itself does not create traffic rules. Without matching L4 Chain rules, the row is only a policy-only listener.
Conflict Resolution
Agent behavior, simplified:
for each listener namespace:
collect TCP rules by tcp listen address
collect UDP rules and QUIC rules by udp listen address
if TCP has REALITY, TLS, or SSLPREREAD routes:
build one TLS/SNI-family listener
add all matching REALITY routes, including same-SNI routes with different short IDs
add TLS/SSLPREREAD routes by SNI
keep at most one TCP RAW fallback for non-TLS or unmatched-SNI traffic
else:
keep the single selected TCP route class
if UDP has RAW or Noise routes:
build one UDP listener
add Noise routes by local X25519 key
keep at most one UDP RAW fallback
embed same-address QUIC REALITY routes into the same UDP socket
else:
build a standalone QUIC listener when only QUIC routes request the namespaceDetailed behavior:
| Winning class | Multiplexing behavior |
|---|---|
| TCP REALITY | Supports multiple REALITY routes on one TCP listener. REALITY is treated as TLS-like: the listener uses SNI as the first split key and then tries all SNI-matching REALITY routes by short ID. |
| TCP TLS | Supports multiple TLS routes on one TCP listener by pre-reading TLS ClientHello and routing by SNI. Can also keep one TCP RAW route as the default fallback for non-TLS or unmatched-SNI traffic. |
| UDP Noise | Supports multiple UDP Noise routes on one UDP listener by trying candidate local X25519 keys on the first datagram, then pinning the UDP session to the selected route. |
| QUIC REALITY | QUIC is in the UDP namespace. QUIC REALITY routes can be embedded into the same UDP socket used by UDP RAW or UDP Noise, while QUIC's own REALITY routing can still split by server name and short ID. |
| RAW | No discriminator. At most one RAW route can own a selected listener. Duplicate RAW routes on the same listener are not meaningful. |
TCP RAW Fallback
For TCP, a TLS/SNI-family listener can retain one RAW route as an explicit default fallback. The TLS/SNI router first tries to read a TLS ClientHello:
- if SNI matches a configured TLS or SSLPREREAD route, traffic uses that route;
- if SNI matches REALITY routes, the agent tries each candidate REALITY route until short ID authentication succeeds;
- if the first bytes are not TLS, or no SNI route matches, traffic falls back to the RAW route;
- if more than one RAW rule exists on the same listener, only one can be kept because RAW has no discriminator.
This is what allows a TCP TLS/SNI control listener and an SSH RAW listener to share the same TCP port. REALITY can coexist in the same listener because it is recognized from the TLS-like ClientHello before REALITY short ID checks run.
UDP Shared Socket
For UDP, one socket can host UDP RAW fallback, UDP Noise routes, and embedded QUIC REALITY routes when they request the same udp address:port.
Dispatch rules:
- existing QUIC peer state continues to Quinn for QUIC packets;
- an unknown peer is dispatched to Quinn only when the packet looks like a QUIC long-header Initial;
- Noise candidates try to decrypt the first datagram with configured local keys;
- if no QUIC or Noise route accepts the datagram, the single UDP RAW fallback receives it.
This keeps ordinary UDP RAW traffic from being swallowed by the QUIC endpoint while still allowing QUIC REALITY to live on the same UDP listener.
Protocol Split Matrix
| Listener namespace | Protocol class | Split key | Same-port routes | Notes |
|---|---|---|---|---|
| TCP | REALITY | TLS-like ClientHello SNI, then REALITY short ID | Many | Multiple same-SNI REALITY routes are valid when short IDs differ. |
| TCP | TLS | TLS ClientHello SNI | Many, one per SNI | Empty cert_domain cannot be used for SNI routing. Can retain one RAW fallback. |
| TCP | RAW | None | One | No protocol-level discriminator. Can act as the default fallback under a TLS/SNI listener. |
| UDP | QUIC REALITY | QUIC Initial detection, then REALITY route material | Many internally | Can be embedded into the shared UDP socket with UDP RAW and UDP Noise. |
| UDP | Noise | First datagram decrypt with local X25519 key | Many, one per local key | Session is pinned after first datagram. |
| UDP | RAW | None | One | Can act as fallback in the shared UDP socket; two RAW UDP services still cannot share one listener. |
Noise And TLS On The Same Port
Noise and TLS can share the same numeric port if one is in the UDP namespace and the other is in the TCP namespace.
For example, TCP TLS on tcp :::443 and UDP Noise on udp :::443 can coexist. They are not the same listener and do not participate in the same priority decision.
QUIC REALITY and UDP Noise both live in the UDP namespace, but QUIC REALITY can be embedded into the selected UDP socket executor, so QUIC REALITY, UDP Noise, and one UDP RAW fallback can coexist on the same udp address:port. TCP TLS and TCP REALITY both live in the TCP namespace and can coexist inside the same TLS/SNI-family listener when their SNI and REALITY short ID material are unambiguous.
Validator Risk
Validator risk here means semantic drift between control-plane validation, frontend display, and actual agent runtime behavior.
It can happen when:
- the backend accepts a rule set, but the agent drops part of it due to system bind occupancy or non-demultiplexable duplicates such as multiple RAW fallbacks;
- the frontend displays control-plane intent while the agent runtime has not yet reported the reconciled listener table;
- default priority exists only as hidden agent logic and is not represented in backend or frontend state.
Current mitigations:
| Mitigation | Description |
|---|---|
| Shared policy storage | node_port_policies remains the compatibility store for API-managed per-node listener priority. |
| Proto propagation | PingoraConfig.listener_policies carries policy to the agent. |
| Agent-side final decision | The agent still makes the final bind and conflict decision because only it knows local socket state. |
| Runtime heartbeat state | HeartbeatRequest.runtime_listeners reports the listener state that the agent actually applied. |
| Runtime preview | The frontend preview action loads /api/nodes/ports/runtime instead of relying on local draft computation. |
| Human-readable logs | Agent logs selected priority, dropped priority, listener address, cluster, and rule fingerprint. |
| Focused tests | Tests cover TCP SNI-family coexistence, UDP Noise key routing, UDP RAW fallback, and QUIC REALITY sharing a UDP socket with ordinary UDP routes. |
System-Bound Ports
If an address:port is already bound by a non-Nexus process, the agent does not auto-allocate another port. The config apply is rejected and reported through ConfigApplyReport instead of silently skipping the listener.
For TCP, the agent checks the TCP address:port. For UDP and QUIC, the agent checks the UDP address:port namespace. An existing Nexus-managed listener can be hot-reloaded and is not treated as system occupancy.
Frontend Workflow
- Open Nodes > Port Management.
- Select a node from the dropdown, or keep All nodes.
- Review each row:
- node name and id prefix;
- transport, such as
tcporudp; - listener namespace, such as
:::443; - L4 or L3 service sources requesting that listener;
- intended route count and runtime status when available.
- Click the service/order action to open the draggable service list. The dialog lists each L4 or L3 service that must be considered for that listener; the current UI does not save a priority policy from this dialog.
- Click the runtime preview action to fetch the agent-reported listener table. The dialog shows a loading state while it calls
/api/nodes/ports/runtime. - Compare the runtime route list with the intended service list. Missing runtime data means the agent has not connected, has not heartbeated with runtime listener state, or the latest config apply was rejected.
- Watch agent logs if the port is already occupied or a non-demultiplexable duplicate was dropped.
The listener table keeps one row per node_id + transport + listen_addr + listen_port. TCP and UDP can share the same numeric port and still appear as separate rows.
REST API
List Ports
GET /api/nodes/ports
GET /api/nodes/ports?node_id=<uuid>Returns all node options and listener items. node_id filters listener items but the node dropdown still receives all nodes.
Response shape:
{
"success": true,
"data": {
"nodes": [
{ "id": "node-uuid", "name": "hk-edge-1", "status": "online" }
],
"items": [
{
"nodeId": "node-uuid",
"nodeName": "hk-edge-1",
"transport": "tcp",
"listenAddr": "::",
"listenPort": 443,
"priorityOrder": ["reality", "tls", "noise", "raw"],
"hasPolicy": true,
"policyId": "policy-uuid",
"routes": [
{
"chainId": "chain-uuid",
"chainName": "tls-edge",
"source": "l4_chain",
"sourceLabel": "L4 Chain",
"payload": "tcp",
"outer": "tcp",
"security": "tls",
"matchKind": "sni",
"matchValue": "example.com",
"credentialId": "credential-uuid",
"nextHop": "target.example.com:443"
},
{
"chainId": "chain-uuid",
"chainName": "tls-edge",
"source": "l4_upstream",
"sourceLabel": "api-origin",
"payload": "tcp",
"outer": "tcp",
"security": "tls",
"matchKind": "sni",
"matchValue": "api.example.com",
"credentialId": null,
"nextHop": "10.0.0.5:443"
}
]
}
],
"defaultPriorityOrder": ["reality", "tls", "noise", "raw"]
},
"error": null
}The /api/nodes/ports response is control-plane intent. It is built from chains, upstreams, policies, and service definitions. Use the runtime endpoint below when the question is "what is the agent actually listening on right now?"
List Runtime L4 Ports
GET /api/nodes/ports/runtime
GET /api/nodes/ports/runtime?node_id=<uuid>&transport=tcp&listen_addr=::&listen_port=443Returns the runtime listener table most recently reported by agents through heartbeat. Query parameters are optional filters.
Response shape:
{
"success": true,
"data": {
"nodes": [
{
"nodeId": "node-uuid",
"nodeName": "hk-edge-1",
"status": "online",
"reportedAt": "2026-05-15T10:20:30Z",
"listeners": [
{
"transport": "tcp",
"listenAddr": "::",
"listenPort": 443,
"state": "running",
"routes": [
{
"service": "154.88.64.117:55443",
"payload": "tcp",
"outer": "tcp",
"security": "reality",
"matchKind": "reality",
"matchValue": "SNI img.alicdn.com / short_id 0123456789abcdef",
"nextHop": "154.88.64.117:55443"
},
{
"service": "127.0.0.1:22",
"payload": "tcp",
"outer": "tcp",
"security": "raw",
"matchKind": "default",
"matchValue": "default",
"nextHop": "127.0.0.1:22"
}
],
"bytesIn": 0,
"bytesOut": 0,
"activeSessions": 0,
"totalSessions": 0,
"errorSessions": 0
}
]
}
]
},
"error": null
}The runtime preview dialog uses this endpoint. It must show loading and error states because the data depends on agent connectivity and heartbeat freshness.
Save Listener Policy
PUT /api/nodes/ports/policiesRequired role: admin or operator.
This endpoint remains for API compatibility and operator automation. The current frontend Port Management page does not expose a save icon for this policy.
Request:
{
"nodeId": "node-uuid",
"transport": "udp",
"listenAddr": "::",
"listenPort": 443,
"priorityOrder": ["raw", "reality", "noise", "tls"]
}Behavior:
| Field | Rule |
|---|---|
listenPort | Must be between 1 and 65535. |
transport | Must be tcp or udp. |
priorityOrder | Can only contain reality, tls, noise, and raw. |
The backend deduplicates, normalizes, and appends missing protocol labels. After saving, it replays L4 chain config for that node.
Runtime preview no longer applies a frontend-only draft ordering. It displays the agent-reported route table from /api/nodes/ports/runtime.
Database
The policy table is node_port_policies.
| Column | Meaning |
|---|---|
id | Policy id. |
node_id | Owning node, references nodes(id). |
transport | tcp or udp. |
listen_addr | Listener bind address. |
listen_port | Listener port, 1 to 65535. |
priority_order | JSONB array of protocol labels. |
enabled | Whether the policy is active. |
created_at, updated_at | Timestamps. |
Uniqueness:
UNIQUE (node_id, transport, listen_addr, listen_port)This means one node can have separate policies for tcp :::443 and udp :::443.
Agent Control Protocol
PingoraConfig carries listener policies:
message PingoraConfig {
repeated UpstreamCluster upstreams = 1;
repeated TcpProxyRule tcp_rules = 2;
repeated HttpProxyRule http_rules = 3;
TlsConfig tls_config = 4;
repeated QuicRule quic_rules = 5;
repeated UdpRule udp_rules = 6;
repeated ListenerPolicy listener_policies = 7;
}
message ListenerPolicy {
string transport = 1;
string listen_addr = 2;
int32 listen_port = 3;
repeated string priority_order = 4;
}The backend and agent keep separate copies of common/proto/agent.proto; both copies must stay identical.
Agent heartbeat also carries the current runtime listener table:
message HeartbeatRequest {
string node_id = 1;
uint64 sequence = 2;
google.protobuf.Timestamp timestamp = 3;
double cpu_usage = 4;
double memory_usage = 5;
uint64 uptime_seconds = 6;
repeated LinkMetric link_metrics = 7;
repeated RuntimeListener runtime_listeners = 8;
}
message RuntimeListener {
string transport = 1;
string listen_addr = 2;
int32 listen_port = 3;
string state = 4;
repeated RuntimeRoute routes = 5;
uint64 bytes_in = 6;
uint64 bytes_out = 7;
uint64 active_sessions = 8;
uint64 total_sessions = 9;
uint64 error_sessions = 10;
}
message RuntimeRoute {
string service = 1;
string payload = 2;
string outer = 3;
string security = 4;
string match_kind = 5;
string match_value = 6;
string next_hop = 7;
}Logging And Troubleshooting
Useful agent log patterns:
| Log meaning | What to check |
|---|---|
Gateway config validated | Compare requested vs selected TCP/QUIC/UDP rule counts. |
TCP SNI-family port reuse enabled | TCP REALITY, TLS/SSLPREREAD, and RAW fallback were merged into one listener. |
UDP same-port route table enabled | UDP RAW, UDP Noise, or embedded QUIC REALITY routes were merged into one UDP listener. |
Embedding QUIC REALITY route into the selected UDP socket executor | QUIC REALITY is sharing the selected UDP socket instead of opening a standalone QUIC socket. |
Dropping duplicate TCP RAW fallback because another RAW route already owns this listener fallback | Only one TCP RAW fallback can exist under one TCP SNI-family listener. |
Dropping duplicate UDP RAW fallback because another RAW route already owns this listener fallback | Only one UDP RAW fallback can exist under one UDP listener. |
already bound outside this manager | Another process owns the port; the snapshot is rejected. Stop that process or choose another port. |
Troubleshooting checklist:
- Confirm the L4 chain is enabled.
- Confirm the chain was deployed after the latest edit.
- Open Port Management and check the listener row exists.
- Open runtime preview and check whether the agent reports the listener and the expected routes.
- Check whether the port is occupied by a non-Nexus process on the agent host.
- Check whether a credential is missing or mismatched:
- TLS requires a TLS certificate with an explicit domain.
- REALITY requires server names and short IDs.
- UDP Noise requires valid X25519 key material.
- Inspect agent logs for dropped rules.
Performance Notes
Most listener-merge cost happens during config apply: group by listener, validate whether routes can be demultiplexed, keep a single fallback where needed, and build the runtime route table. Runtime overhead depends on the listener composition:
- TCP TLS SNI routing pre-reads the first TLS ClientHello record, then feeds that record back into rustls for normal accept.
- TCP REALITY selection uses SNI first and may try multiple SNI-matching REALITY routes until short ID authentication succeeds.
- UDP Noise routing tries candidate keys only on the first datagram of a new UDP session. After selection, the session is pinned to the route.
- QUIC sharing on a UDP socket dispatches unknown peers to Quinn only for QUIC long-header Initial packets; known QUIC peers can continue with fixed-bit QUIC packets. Non-QUIC UDP falls through to Noise or RAW.
- Runtime preview freshness is bounded by the agent heartbeat cadence.
- REALITY, TLS, Noise, and QUIC same-port reuse are not literally free, but the overhead is small for normal route counts.
If one port carries many SNI, REALITY, or Noise key routes, watch first-packet/handshake latency and route counts in logs.
Design Rules
| Do | Avoid |
|---|---|
| Use TCP TLS SNI when many TLS services need one TCP port. | Do not put two RAW services on the same listener and expect routing. |
| Use UDP Noise key routing when many UDP Noise services need one UDP port. | Do not assume TCP and UDP priorities interact. They are separate namespaces. |
| Use QUIC REALITY shared UDP dispatch when QUIC and ordinary UDP must use one UDP port. | Do not assume every QUIC mode can share a UDP socket; this behavior is for QUIC REALITY route material handled by the shared listener. |
| Compare control-plane intent with runtime preview after deployment. | Do not rely on automatic fallback port allocation. It is intentionally disabled. |
| Keep listener policy and chain deployment together in change review when using the policy API. | Do not treat policy-only listeners as active traffic listeners. |