# TCP Forwarding (https://portunus.bybee.dev/en/docs/features/tcp-forwarding)



TCP forwarding is the core primitive. Every other feature (UDP, port
ranges, SNI routing, rate limiting) layers on top of it.

## How it works [#how-it-works]

Each TCP rule runs a single `accept` loop on the client. For every
accepted connection the client opens one upstream `TcpStream::connect`
to the configured target and runs a bidirectional byte copy until either
side closes.

There is **no userspace buffering** beyond the kernel's send/recv
windows. The forwarder is a pure L4 byte passthrough — TLS sessions,
HTTP requests, and arbitrary protocols pass through unmodified.

On Linux, connections without a bandwidth cap take the `splice(2)`
fast path (zero-copy through the kernel). Rate-limited connections fall
back to a userspace copy loop. The listener binds with `SO_REUSEADDR`,
so a fast process restart (e.g. `docker restart`) rebinds immediately
instead of failing with `port_in_use` while a prior socket sits in
`TIME_WAIT`.

## Push a rule [#push-a-rule]

```sh
portunus-server push-rule <client_name> <listen_port> <target_host>:<target_port>
```

Example:

```sh
portunus-server push-rule edge-01 8080 example.com:80
# → rule_id=1
```

Within \~1 second (the ack budget) the rule transitions
`Pending → Active` and the client starts accepting on port 8080.

## Inspect [#inspect]

```sh
# Operator CLI
portunus-server list-rules --client edge-01
portunus-server rule-stats 1

# Operator HTTP API
curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
     http://127.0.0.1:7080/v1/rules

# Prometheus
curl -s http://127.0.0.1:7081/metrics | grep '^portunus_rule'
```

## Remove [#remove]

```sh
portunus-server remove-rule <rule_id>
```

In-flight connections drain up to `shutdown_drain_timeout_secs`
(default 30 s). The listener stops accepting immediately.

## Failure modes [#failure-modes]

| Condition                            | Outcome                                                          |
| ------------------------------------ | ---------------------------------------------------------------- |
| Listen port already in use on client | Rule state `Failed(port_in_use)`; remove + retry on a free port  |
| Target connect refused / timed out   | Connection-level error; rule stays `Active`, no metric scrubbing |
| Client disconnected when rule pushed | HTTP 4xx with `client_not_connected`                             |
| Client reconnects                    | Rules persist server-side and re-push to the same client         |

## Metrics [#metrics]

Per-rule collectors (one row per live rule, labels `{client, rule, owner}`):

* `portunus_rule_bytes_in_total`
* `portunus_rule_bytes_out_total`
* `portunus_rule_active_connections`

Byte counters update incrementally (every 64 KiB) even on the `splice`
fast path, so long-lived connections (SSH, gRPC streams, WebSockets)
report live throughput instead of staying frozen until the connection
closes.

See [Metrics](/en/docs/observability/metrics) for the full collector list.

## See also [#see-also]

* [UDP Forwarding](/en/docs/features/udp-forwarding) — same model for UDP
* [Multi-target Failover](/en/docs/features/multi-target-failover) — push 1..=8 targets per rule
* [DNS-name Targets](/en/docs/features/dns-targets) — use a hostname instead of an IP
