-اول خانوادهی ترنسپورت (TCP / UDP / WebSocket) و بعد نوعش را انتخاب کن، پورت تونل
-و پورتهای expose را بده، **توکن ۶۴ کاراکتری** پیشنهادی را با Enter بپذیر، و یک
-پریست انتخاب کن — **Turbo** پیشنهاد پیشفرض است. توکن را کپی کن (برای کلاینت لازم است).
+اول خانواده و سپس نوع دقیق ترنسپورت را انتخاب کن. قبل از سؤال پورتها، روش اتصال
+را انتخاب میکنی: **Direct** یعنی ایران به خارج متصل شود؛ **Reverse** یعنی روش قدیمی
+خارج به ایران. پورت تونل و پورتهای expose را بده، **توکن ۶۴ کاراکتری** پیشنهادی را
+با Enter بپذیر و یک پریست انتخاب کن — **Turbo** پیشنهاد پیشفرض است.
### ۲) روی سرور خارج — ساخت تونل Client
@@ -181,14 +181,15 @@ sudo backpack → 2. Setup Client
-**IP سرور ایران**، پورت تونل، و **همان توکن** را وارد کن. تمام.
+همان ترنسپورت و mode را انتخاب کن. در Direct پورت listen سرور خارج را بده؛ در
+Reverse، IP و پورت سرور ایران را وارد کن. در هر دو حالت **همان توکن** را وارد کن.
---
## امکانات
-**ترنسپورتها** — TCP، TCP Mux، TCP + Stealth، UDP، UDP + KCP، WS، WS Mux،
-WSS و WSS Mux، با Connection Pool.
+**ترنسپورتها** — TCP، TCP Mux، TCP + Stealth، UDP، UDP + KCP، UDP + QUIC، WS،
+WS Mux، WSS و WSS Mux، با Connection Pool در موارد قابل استفاده.
- **TCP + Stealth** — یک تونل TCP در لایهی Noise با **بدون fingerprint**؛ روی سیم
شبیه بایت تصادفی است، پس چیزی برای تطبیق DPI نیست. برای جایی که فیلترینگ سنگین
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 14c1a3f..db5b380 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -73,7 +73,7 @@ func runEngine(cfg *config.Config, ctx context.Context, configPath string, apply
if err != nil {
return err
}
- if cfg.EffectiveEngine() == config.EngineReverse {
+ if cfg.EffectiveEngine() == config.EngineReverse || cfg.EffectiveEngine() == config.EngineForward {
if cfg.HasServer() {
// Apply temporary TCP optimizations at startup
if applyTuning && !cfg.Server.SkipOptz {
diff --git a/cmd/defaults.go b/cmd/defaults.go
index 39b3cdd..364e64f 100644
--- a/cmd/defaults.go
+++ b/cmd/defaults.go
@@ -41,7 +41,7 @@ const ( // Default values
)
func applyDefaults(cfg *config.Config) {
- if cfg.EffectiveEngine() != config.EngineReverse {
+ if cfg.EffectiveEngine() != config.EngineReverse && cfg.EffectiveEngine() != config.EngineForward {
return
}
// Token
diff --git a/config/config.go b/config/config.go
index 3d5f9cb..2aa97f8 100644
--- a/config/config.go
+++ b/config/config.go
@@ -14,7 +14,12 @@ import (
type EngineType string
const (
- EngineReverse EngineType = "reverse"
+ EngineReverse EngineType = "reverse"
+ // EngineForward keeps Backpack's application-level tunnel and selected
+ // transport, but reverses who establishes it: the Iran edge dials the
+ // Kharej origin. It is intentionally distinct from EngineIPTables, which
+ // is a kernel-only DNAT engine and does not carry a Backpack transport.
+ EngineForward EngineType = "forward"
EngineIPTables EngineType = "iptables"
)
@@ -294,6 +299,10 @@ type ServerConfig struct {
// ClientConfig represents the configuration for the client.
type ClientConfig struct {
RemoteAddr string `toml:"remote_addr"`
+ // Ports is used only by the forward engine. In that mode the dialling
+ // client is the Iran edge, so it also owns the public ingress listeners.
+ // Reverse client configs omit it and retain their historical meaning.
+ Ports []string `toml:"ports"`
// FallbackAddrs are additional server addresses tried in order whenever the
// primary cannot be reached (a filtered IP, a blocked port, a CDN edge).
FallbackAddrs []string `toml:"fallback_addrs"`
@@ -366,6 +375,12 @@ type ClientConfig struct {
// plain `tcp` transport on Linux, and only when the tunnel has no bandwidth
// limit — anything else quietly keeps the buffered path.
ZeroCopy bool `toml:"zero_copy"`
+ // The following ingress controls are meaningful on the dialling side only
+ // for EngineForward. They mirror the long-standing reverse server knobs.
+ AcceptUDP bool `toml:"accept_udp"`
+ ProxyProtocol bool `toml:"proxy_protocol"`
+ MaxConnections int `toml:"max_connections"`
+ BandwidthMbps int `toml:"bandwidth_mbps"`
Preset string `toml:"preset"`
// LoadBalance spreads the pool's data connections over every configured
@@ -445,6 +460,23 @@ func (c *Config) ValidateStructure() error {
if hasForward || hasServer == hasClient {
return fmt.Errorf("engine %q requires exactly one of [server] or [client]", EngineReverse)
}
+ case EngineForward:
+ if hasForward || hasServer == hasClient {
+ return fmt.Errorf("engine %q requires exactly one of [server] or [client]", EngineForward)
+ }
+ // Operational roles are deliberately used here: [client] is the Iran
+ // dialler and therefore owns ingress ports; [server] is the Kharej
+ // listener and never exposes ports itself.
+ if hasClient {
+ if strings.TrimSpace(c.Client.RemoteAddr) == "" {
+ return fmt.Errorf("engine %q [client] requires remote_addr", EngineForward)
+ }
+ if len(c.Client.Ports) == 0 {
+ return fmt.Errorf("engine %q Iran [client] requires at least one ingress port mapping", EngineForward)
+ }
+ } else if strings.TrimSpace(c.Server.BindAddr) == "" {
+ return fmt.Errorf("engine %q Kharej [server] requires bind_addr", EngineForward)
+ }
case EngineIPTables:
if !hasForward || hasServer || hasClient {
return fmt.Errorf("engine %q requires [forward] and no reverse section", EngineIPTables)
diff --git a/config/engine_test.go b/config/engine_test.go
index 61fba28..6ce1e86 100644
--- a/config/engine_test.go
+++ b/config/engine_test.go
@@ -60,6 +60,30 @@ func TestExplicitReverseAndLegacyClientRemainValid(t *testing.T) {
}
}
+func TestApplicationForwardUsesOperationalClientAndServerSections(t *testing.T) {
+ edge := "engine='forward'\n[client]\nremote_addr='192.0.2.10:443'\ntransport='tcp'\nports=['8443=127.0.0.1:8443']\n"
+ origin := "engine='forward'\n[server]\nbind_addr=':443'\ntransport='tcp'\n"
+ for _, body := range []string{edge, origin} {
+ cfg, err := LoadFile(writeConfig(t, body))
+ if err != nil {
+ t.Fatalf("valid application forward config rejected: %v\n%s", err, body)
+ }
+ if cfg.EffectiveEngine() != EngineForward || cfg.HasForward() {
+ t.Fatalf("application forward confused with iptables forwarding: %#v", cfg)
+ }
+ }
+
+ for _, body := range []string{
+ "engine='forward'\n[client]\nremote_addr='192.0.2.10:443'\ntransport='tcp'\n",
+ "engine='forward'\n[server]\ntransport='tcp'\n",
+ "engine='forward'\n[forward]\n",
+ } {
+ if _, err := LoadFile(writeConfig(t, body)); err == nil {
+ t.Fatalf("invalid application forward config accepted:\n%s", body)
+ }
+ }
+}
+
func TestForwardValidation(t *testing.T) {
body := "engine='iptables'\n[forward]\n[[forward.mappings]]\nlisten_address='0.0.0.0'\nlisten_ports='1000-1002'\ntarget_address='192.0.2.10'\ntarget_ports='2000-2002'\nprotocols=['tcp','udp']\n"
if _, err := LoadFile(writeConfig(t, body)); err != nil {
diff --git a/docs/direct-forward.md b/docs/direct-forward.md
index f548cae..3e69360 100644
--- a/docs/direct-forward.md
+++ b/docs/direct-forward.md
@@ -1,82 +1,62 @@
-# Direct forwarding (iptables)
+# Direct connection mode
-Backpack can forward TCP and UDP traffic directly through the Linux kernel in
-addition to its existing reverse-tunnel mode. Each direct-forward config is a
-long-running systemd instance: it installs and watches its own rules, records
-traffic counters, and removes only its owned rules when stopped.
+Backpack supports two directions for its application tunnel. Both directions
+use the transport selected by the user (TCP, TCP Mux, Stealth, UDP, KCP, QUIC,
+WS, WSS, WS Mux or WSS Mux):
-Create one from **Setup Direct Forward** in the CLI, or use this TOML form:
+- **Direct:** the Iran server initiates the selected tunnel toward Kharej. The
+ public user ports remain on Iran and the backend service remains on Kharej.
+- **Reverse:** the legacy behaviour; Kharej initiates the selected tunnel
+ toward Iran.
-```toml
-engine = "iptables"
+Run `sudo backpack`, choose **Setup Server** on Iran or **Setup Client** on
+Kharej, select the transport family and concrete transport, then select the
+connection mode. The mode question appears after every concrete transport and
+before the port questions. Both sides must use the same mode, transport,
+tunnel port and token.
-[forward]
+Direct application configs use `engine = "forward"`. Iran is operationally the
+dialling `[client]` and owns `ports`; Kharej is operationally the listening
+`[server]`. The CLI hides that implementation detail and continues to call the
+machines Server (Iran) and Client (Kharej).
-[[forward.mappings]]
-listen_address = "0.0.0.0"
-listen_ports = "443"
-target_address = "203.0.113.10"
-target_ports = "8443"
-protocols = ["tcp", "udp"]
+```toml
+# Iran
+engine = "forward"
+
+[client]
+remote_addr = "KHAREJ_IP:8443"
+transport = "tcpmux"
+token = "SAME_LONG_TOKEN"
+ports = ["443=127.0.0.1:443"]
```
-`0.0.0.0` listens on every local IPv4 address and `::` does the same for IPv6.
-A specific listen address must exist on a local interface when the instance
-starts. The target must be an explicit, same-family unicast IP address; host
-names, loopback, multicast, unspecified addresses and IPv4/IPv6 translation
-are intentionally rejected.
-
-Ports can be single values or equal-length ranges. Range mapping preserves the
-offset, for example `10000-10009` to `20000-20009`. A mapping is limited to
-1,024 expanded ports and an instance to 4,096. Only `tcp` and `udp` are valid.
-
-## Behaviour and requirements
-
-- Linux and root privileges are required. The installer provides the iptables
- suite. Both `iptables-nft` and `iptables-legacy` are supported, but the
- command, save and restore tools must use the same backend.
-- IPv4-only configs do not require IPv6 tools. IPv6 tools are checked only
- when an IPv6 mapping is used.
-- Backpack enables `net.ipv4.ip_forward` and, when needed, IPv6 forwarding on
- every start. Stop and uninstall do not turn these shared host settings off.
-- Direct forwarding applies only to ingress traffic. Backpack creates no
- general `OUTPUT` rule, so locally generated host traffic is not redirected.
-- DNAT connections receive an instance-specific connection mark. FORWARD and
- MASQUERADE rules require that mark and the exact configured tuple.
-- Rule changes are prepared in detached chains. Hooks are activated last and
- failures roll back the new generation. Rules carry a structured ownership
- comment, so cleanup never flushes a table or deletes an unrelated rule.
-
-Backpack refuses to start if a mapping overlaps another Backpack config, a
-local TCP/UDP listener, or an existing DNAT rule. If an nftables, multiport or
-ipset expression may overlap but cannot be interpreted safely, validation
-fails closed and reports the rule that needs review.
-
-## Health and counters
-
-Direct health represents local desired state: service, forwarding sysctls,
-backend, chains, hooks and the desired rule hash. Target reachability does not
-make the service unhealthy. **Diagnose** reports routes and an optional TCP
-probe separately; UDP reachability remains unknown because a connected UDP
-socket is not proof of a reachable service.
-
-RX/TX packets and bytes are counted by dedicated FORWARD accounting rules.
-Cumulative values are persisted under `/etc/backpack/forward-state` before a
-generation is removed, so restart, reconcile and reboot do not reduce the
-Prometheus totals.
-
-Legacy configs with no `engine` field remain reverse tunnels and need no
-migration. `mode` is display-only metadata and must not be written to TOML.
-
-## Integration test
-
-On a disposable Linux CI runner with root and network namespaces enabled, run:
+```toml
+# Kharej
+engine = "forward"
-```bash
-sudo BACKPACK_NETNS_TEST=1 go test ./internal/engine -run TestDirectNetNSAcceptance -v
+[server]
+bind_addr = "0.0.0.0:8443"
+transport = "tcpmux"
+token = "SAME_LONG_TOKEN"
```
-The test builds isolated client, ingress and target namespaces and covers IPv4
-and IPv6, TCP and UDP, single-port and range remapping, MASQUERADE source
-visibility, stop cleanup and restart recovery. It is skipped during ordinary
-unprivileged unit-test runs.
+A bare mapping such as `443` exposes port 443 on Iran and connects it to
+`127.0.0.1:443` on Kharej. Ranges preserve offsets, for example
+`10000-10009=127.0.0.1:20000-20009`. IPv6 endpoints must use brackets. A pipe
+separates multiple backends and Direct load-balances over available members:
+`443=127.0.0.1:8443|127.0.0.1:9443`. An instance may expand at most 4096
+ingress ports, with at most 1024 ports from any one mapping.
+
+Legacy configs without `engine` remain Reverse and require no migration.
+`mode` is display metadata and is never written to TOML.
+
+## Advanced iptables engine
+
+The separate `engine = "iptables"` provider remains available for operators
+who intentionally want kernel DNAT/MASQUERADE without an application tunnel.
+It is not the Direct/Reverse choice in the normal setup wizard and must be
+configured explicitly with `[forward]`. It supports IPv4/IPv6 TCP/UDP mappings,
+owned generation chains, rollback, conflict detection and persistent counters.
+See the example config and engine tests under `internal/engine` for that
+advanced deployment path.
diff --git a/img/architecture.svg b/img/architecture.svg
index eefd64a..86f0965 100644
--- a/img/architecture.svg
+++ b/img/architecture.svg
@@ -19,7 +19,7 @@
IRAN SERVER
-
role: SERVER · exposes ports
+
role: SERVER · public ingress
Forwarded ports · :443 :8443
Backpack engine
@@ -28,7 +28,7 @@
KHAREJ / ABROAD
-
role: CLIENT · dials out
+
role: CLIENT · real-service side
Backpack engine
forward to the real service
@@ -43,8 +43,8 @@
encrypted tunnel · one transport
-
the client dials the server (kharej → Iran)
+
Direct: Iran → Kharej · Reverse: Kharej → Iran
-
Transports: TCP · TCP Mux · TCP + Stealth · UDP · UDP + KCP · WS · WS Mux · WSS · WSS Mux
+
Transports: TCP · TCP Mux · Stealth · UDP · KCP · QUIC · WS · WS Mux · WSS · WSS Mux
diff --git a/internal/client/client.go b/internal/client/client.go
index e737645..0800b0f 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -20,10 +20,11 @@ import (
// Client encapsulates the client configuration and state
type Client struct {
- config *config.ClientConfig
- ctx context.Context
- cancel context.CancelFunc
- logger *logrus.Logger
+ config *config.ClientConfig
+ forward bool
+ ctx context.Context
+ cancel context.CancelFunc
+ logger *logrus.Logger
}
func NewClient(cfg *config.ClientConfig, parentCtx context.Context) *Client {
@@ -41,6 +42,14 @@ func NewClient(cfg *config.ClientConfig, parentCtx context.Context) *Client {
}
}
+// NewForwardEdge builds the dialling Iran half. Unlike a reverse client it
+// also owns the public ingress listeners declared in ClientConfig.Ports.
+func NewForwardEdge(cfg *config.ClientConfig, parentCtx context.Context) *Client {
+ c := NewClient(cfg, parentCtx)
+ c.forward = true
+ return c
+}
+
// Run starts the client and begins dialing the tunnel server
func (c *Client) Start() {
// Profiling endpoint, off unless explicitly enabled in the config. Bound to
@@ -105,7 +114,13 @@ func (c *Client) Start() {
Outbound: outbound,
// Stealth is the TCP transport with a Noise record layer over every
// tunnel connection; everything else about it is identical.
- Stealth: c.config.Transport == config.STEALTH,
+ Stealth: c.config.Transport == config.STEALTH,
+ Forward: c.forward,
+ Ports: append([]string(nil), c.config.Ports...),
+ AcceptUDP: c.config.AcceptUDP,
+ ProxyProtocol: c.config.ProxyProtocol,
+ MaxConnections: c.config.MaxConnections,
+ BandwidthMbps: c.config.BandwidthMbps,
}
tcpClient := transport.NewTCPClient(c.ctx, tcpConfig, c.logger)
go tcpClient.Start()
@@ -132,6 +147,11 @@ func (c *Client) Start() {
SO_RCVBUF: c.config.SO_RCVBUF,
SO_SNDBUF: c.config.SO_SNDBUF,
Outbound: outbound,
+ Forward: c.forward,
+ Ports: append([]string(nil), c.config.Ports...),
+ ProxyProtocol: c.config.ProxyProtocol,
+ MaxConnections: c.config.MaxConnections,
+ BandwidthMbps: c.config.BandwidthMbps,
}
tcpMuxClient := transport.NewMuxClient(c.ctx, tcpMuxConfig, c.logger)
go tcpMuxClient.Start()
@@ -177,6 +197,11 @@ func (c *Client) Start() {
SpoofSrcPool: c.config.SpoofSrcPool,
SpoofPeerIP: c.config.SpoofPeerIP,
SpoofInterface: c.config.SpoofInterface,
+ Forward: c.forward,
+ Ports: append([]string(nil), c.config.Ports...),
+ ProxyProtocol: c.config.ProxyProtocol,
+ MaxConnections: c.config.MaxConnections,
+ BandwidthMbps: c.config.BandwidthMbps,
}
kcpClient := transport.NewKcpClient(c.ctx, kcpConfig, c.logger)
go kcpClient.Start()
@@ -196,6 +221,11 @@ func (c *Client) Start() {
AggressivePool: c.config.AggressivePool,
SO_RCVBUF: c.config.SO_RCVBUF,
SO_SNDBUF: c.config.SO_SNDBUF,
+ Forward: c.forward,
+ Ports: append([]string(nil), c.config.Ports...),
+ ProxyProtocol: c.config.ProxyProtocol,
+ MaxConnections: c.config.MaxConnections,
+ BandwidthMbps: c.config.BandwidthMbps,
}
quicClient := transport.NewQuicClient(c.ctx, quicConfig, c.logger)
go quicClient.Start()
@@ -218,6 +248,10 @@ func (c *Client) Start() {
AggressivePool: c.config.AggressivePool,
EdgeIP: c.config.EdgeIP,
Outbound: outbound,
+ Forward: c.forward,
+ Ports: append([]string(nil), c.config.Ports...),
+ MaxConnections: c.config.MaxConnections,
+ BandwidthMbps: c.config.BandwidthMbps,
}
WsClient := transport.NewWSClient(c.ctx, WsConfig, c.logger)
go WsClient.Start()
@@ -244,6 +278,11 @@ func (c *Client) Start() {
AggressivePool: c.config.AggressivePool,
EdgeIP: c.config.EdgeIP,
Outbound: outbound,
+ Forward: c.forward,
+ Ports: append([]string(nil), c.config.Ports...),
+ ProxyProtocol: c.config.ProxyProtocol,
+ MaxConnections: c.config.MaxConnections,
+ BandwidthMbps: c.config.BandwidthMbps,
}
wsMuxClient := transport.NewWSMuxClient(c.ctx, wsMuxConfig, c.logger)
go wsMuxClient.Start()
@@ -262,6 +301,10 @@ func (c *Client) Start() {
AggressivePool: c.config.AggressivePool,
SO_RCVBUF: c.config.SO_RCVBUF,
SO_SNDBUF: c.config.SO_SNDBUF,
+ Forward: c.forward,
+ Ports: append([]string(nil), c.config.Ports...),
+ MaxConnections: c.config.MaxConnections,
+ BandwidthMbps: c.config.BandwidthMbps,
}
udpClient := transport.NewUDPClient(c.ctx, udpConfig, c.logger)
go udpClient.Start()
diff --git a/internal/client/transport/forward_common.go b/internal/client/transport/forward_common.go
new file mode 100644
index 0000000..1c40b3b
--- /dev/null
+++ b/internal/client/transport/forward_common.go
@@ -0,0 +1,87 @@
+package transport
+
+import (
+ "context"
+ "net"
+ "sync/atomic"
+
+ "github.com/backpack/backpack/internal/metrics"
+ "github.com/backpack/backpack/internal/utils/handlers"
+ "github.com/backpack/backpack/internal/web"
+ "github.com/sirupsen/logrus"
+)
+
+type forwardStreamOpener func(target string) (net.Conn, error)
+
+// startForwardIngress is the Iran-side TCP ingress shared by all stream
+// carriers. Carrier implementations provide only how a new logical stream is
+// opened; mapping, limits, PROXY protocol and relay semantics stay identical.
+func startForwardIngress(ctx context.Context, specs []string, maxConnections, bandwidthMbps int, proxyProtocol bool, logger *logrus.Logger, usage *web.Usage, sniffer bool, active *int32, open forwardStreamOpener, restart func()) {
+ mappings, err := expandForwardTCPMappings(specs)
+ if err != nil {
+ logger.Errorf("invalid forward ingress mappings: %v", err)
+ restart()
+ return
+ }
+ bandwidth := newForwardBandwidth(bandwidthMbps)
+ for _, mapping := range mappings {
+ mapping := mapping
+ go func() {
+ listener, err := net.Listen("tcp", mapping.listen)
+ if err != nil {
+ logger.Errorf("failed to listen on forward ingress %s: %v", mapping.listen, err)
+ restart()
+ return
+ }
+ defer listener.Close()
+ go func() { <-ctx.Done(); _ = listener.Close() }()
+ logger.Infof("forward ingress listening on %s -> Kharej %s", listener.Addr(), mapping.target)
+ for {
+ local, err := listener.Accept()
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ logger.Warnf("forward ingress accept on %s failed: %v", mapping.listen, err)
+ continue
+ }
+ if !acquireForwardConnection(active, maxConnections) {
+ logger.Warnf("forward connection limit reached, refusing %s", local.RemoteAddr())
+ local.Close()
+ continue
+ }
+ go func(local net.Conn) {
+ defer atomic.AddInt32(active, -1)
+ local = bandwidth.wrap(local)
+ stream, err := open(mapping.target)
+ if err != nil {
+ logger.Warnf("could not open forward channel for %s: %v", mapping.target, err)
+ local.Close()
+ return
+ }
+ port := 0
+ if addr, ok := local.LocalAddr().(*net.TCPAddr); ok {
+ port = addr.Port
+ }
+ handlers.TCPConnectionHandler(ctx, proxyProtocol, local, metrics.CountedConn(stream), logger, usage, port, sniffer)
+ }(local)
+ }
+ }()
+ }
+}
+
+func acquireForwardConnection(active *int32, limit int) bool {
+ if limit <= 0 {
+ atomic.AddInt32(active, 1)
+ return true
+ }
+ for {
+ current := atomic.LoadInt32(active)
+ if int(current) >= limit {
+ return false
+ }
+ if atomic.CompareAndSwapInt32(active, current, current+1) {
+ return true
+ }
+ }
+}
diff --git a/internal/client/transport/forward_limits.go b/internal/client/transport/forward_limits.go
new file mode 100644
index 0000000..37d07f4
--- /dev/null
+++ b/internal/client/transport/forward_limits.go
@@ -0,0 +1,58 @@
+package transport
+
+import (
+ "context"
+ "net"
+
+ "golang.org/x/time/rate"
+)
+
+// forwardBandwidth is shared by every ingress flow of one transport instance,
+// so bandwidth_mbps is a tunnel-wide cap rather than a per-user multiplier.
+type forwardBandwidth struct{ bucket *rate.Limiter }
+
+func newForwardBandwidth(mbps int) *forwardBandwidth {
+ if mbps <= 0 {
+ return nil
+ }
+ bytesPerSecond := float64(mbps) * 1_000_000 / 8
+ return &forwardBandwidth{bucket: rate.NewLimiter(rate.Limit(bytesPerSecond), int(bytesPerSecond))}
+}
+
+func (l *forwardBandwidth) wrap(conn net.Conn) net.Conn {
+ if l == nil {
+ return conn
+ }
+ return &forwardLimitedConn{Conn: conn, limit: l}
+}
+
+func (l *forwardBandwidth) wait(n int) {
+ if l == nil || n <= 0 {
+ return
+ }
+ burst := l.bucket.Burst()
+ for n > 0 {
+ chunk := n
+ if chunk > burst {
+ chunk = burst
+ }
+ _ = l.bucket.WaitN(context.Background(), chunk)
+ n -= chunk
+ }
+}
+
+type forwardLimitedConn struct {
+ net.Conn
+ limit *forwardBandwidth
+}
+
+func (c *forwardLimitedConn) Read(p []byte) (int, error) {
+ n, err := c.Conn.Read(p)
+ c.limit.wait(n)
+ return n, err
+}
+
+func (c *forwardLimitedConn) Write(p []byte) (int, error) {
+ c.limit.wait(len(p))
+ return c.Conn.Write(p)
+}
diff --git a/internal/client/transport/forward_tcp.go b/internal/client/transport/forward_tcp.go
new file mode 100644
index 0000000..6428b1c
--- /dev/null
+++ b/internal/client/transport/forward_tcp.go
@@ -0,0 +1,108 @@
+package transport
+
+import (
+ "net"
+ "sync/atomic"
+
+ "github.com/backpack/backpack/internal/forwardmap"
+ "github.com/backpack/backpack/internal/metrics"
+ "github.com/backpack/backpack/internal/utils/handlers"
+)
+
+type forwardTCPMapping struct {
+ listen string
+ target string
+}
+
+// expandForwardTCPMappings normalises the long-standing Backpack port syntax
+// for the dialling Iran edge. Ranges preserve their offset when both sides are
+// ranges; a single target intentionally fans the whole listen range into that
+// one backend, matching the historical server-side behaviour.
+func expandForwardTCPMappings(specs []string) ([]forwardTCPMapping, error) {
+ expanded, err := forwardmap.Expand(specs)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]forwardTCPMapping, len(expanded))
+ for i, mapping := range expanded {
+ out[i] = forwardTCPMapping{listen: mapping.Listen, target: mapping.Target}
+ }
+ return out, nil
+}
+
+func (c *TcpTransport) startForwardTCPIngress() {
+ mappings, err := expandForwardTCPMappings(c.config.Ports)
+ if err != nil {
+ c.logger.Errorf("invalid forward ingress mappings: %v", err)
+ go c.Restart()
+ return
+ }
+ for _, mapping := range mappings {
+ mapping := mapping
+ go c.runForwardTCPListener(mapping)
+ }
+}
+
+func (c *TcpTransport) runForwardTCPListener(mapping forwardTCPMapping) {
+ listener, err := net.Listen("tcp", mapping.listen)
+ if err != nil {
+ c.logger.Errorf("failed to listen on forward ingress %s: %v", mapping.listen, err)
+ go c.Restart()
+ return
+ }
+ defer listener.Close()
+ go func() {
+ <-c.state.Ctx().Done()
+ _ = listener.Close()
+ }()
+ c.logger.Infof("forward ingress listening on %s -> Kharej %s", listener.Addr(), mapping.target)
+
+ for {
+ local, err := listener.Accept()
+ if err != nil {
+ if c.state.Ctx().Err() != nil {
+ return
+ }
+ c.logger.Warnf("forward ingress accept on %s failed: %v", mapping.listen, err)
+ continue
+ }
+ if !c.acquireForwardSlot() {
+ c.logger.Warnf("forward connection limit reached, refusing %s", local.RemoteAddr())
+ local.Close()
+ continue
+ }
+ go c.handleForwardTCPIngress(local, mapping.target)
+ }
+}
+
+func (c *TcpTransport) acquireForwardSlot() bool {
+ if c.config.MaxConnections <= 0 {
+ atomic.AddInt32(&c.loadConnections, 1)
+ return true
+ }
+ for {
+ current := atomic.LoadInt32(&c.loadConnections)
+ if int(current) >= c.config.MaxConnections {
+ return false
+ }
+ if atomic.CompareAndSwapInt32(&c.loadConnections, current, current+1) {
+ return true
+ }
+ }
+}
+
+func (c *TcpTransport) handleForwardTCPIngress(local net.Conn, target string) {
+ defer atomic.AddInt32(&c.loadConnections, -1)
+ local = c.forwardBandwidth.wrap(local)
+ tunnel, err := c.openForwardTCP(target)
+ if err != nil {
+ c.logger.Warnf("could not open forward channel for %s: %v", target, err)
+ local.Close()
+ return
+ }
+ port := 0
+ if tcpAddr, ok := local.LocalAddr().(*net.TCPAddr); ok {
+ port = tcpAddr.Port
+ }
+ handlers.TCPConnectionHandler(c.state.Ctx(), c.config.ProxyProtocol, local, metrics.CountedConn(tunnel), c.logger, c.state.Usage(), port, c.config.Sniffer)
+}
diff --git a/internal/client/transport/forward_udp.go b/internal/client/transport/forward_udp.go
new file mode 100644
index 0000000..d52c306
--- /dev/null
+++ b/internal/client/transport/forward_udp.go
@@ -0,0 +1,153 @@
+package transport
+
+import (
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/backpack/backpack/internal/metrics"
+ "github.com/backpack/backpack/internal/utils"
+)
+
+type clientForwardUDPFlow struct {
+ client *net.UDPAddr
+ payload chan []byte
+}
+
+func (c *UdpTransport) startForwardUDPIngress() {
+ mappings, err := expandForwardTCPMappings(c.config.Ports)
+ if err != nil {
+ c.logger.Errorf("invalid forward UDP mappings: %v", err)
+ go c.Restart()
+ return
+ }
+ for _, mapping := range mappings {
+ mapping := mapping
+ go c.runForwardUDPListener(mapping)
+ }
+}
+
+func (c *UdpTransport) runForwardUDPListener(mapping forwardTCPMapping) {
+ addr, err := net.ResolveUDPAddr("udp", mapping.listen)
+ if err != nil {
+ c.logger.Errorf("invalid UDP ingress %s: %v", mapping.listen, err)
+ return
+ }
+ listener, err := net.ListenUDP("udp", addr)
+ if err != nil {
+ c.logger.Errorf("failed to listen on UDP ingress %s: %v", mapping.listen, err)
+ go c.Restart()
+ return
+ }
+ c.applyBuffers(listener)
+ defer listener.Close()
+ go func() { <-c.state.Ctx().Done(); listener.Close() }()
+ c.logger.Infof("forward UDP ingress listening on %s -> Kharej %s", listener.LocalAddr(), mapping.target)
+
+ flows := map[string]*clientForwardUDPFlow{}
+ var mu sync.Mutex
+ buf := make([]byte, 64*1024)
+ for {
+ n, clientAddr, err := listener.ReadFromUDP(buf)
+ if err != nil {
+ if c.state.Ctx().Err() != nil {
+ return
+ }
+ continue
+ }
+ key := clientAddr.String()
+ mu.Lock()
+ flow := flows[key]
+ if flow == nil {
+ if !acquireForwardConnection(&c.forwardActive, c.config.MaxConnections) {
+ mu.Unlock()
+ continue
+ }
+ flow = &clientForwardUDPFlow{client: clientAddr, payload: make(chan []byte, 1024)}
+ flows[key] = flow
+ go c.runForwardUDPFlow(listener, mapping.target, key, flow, &mu, flows)
+ }
+ select {
+ case flow.payload <- append([]byte(nil), buf[:n]...):
+ default:
+ c.logger.Warnf("forward UDP flow %s queue is full; dropping packet", key)
+ }
+ mu.Unlock()
+ }
+}
+
+func (c *UdpTransport) runForwardUDPFlow(listener *net.UDPConn, target, key string, flow *clientForwardUDPFlow, mu *sync.Mutex, flows map[string]*clientForwardUDPFlow) {
+ defer func() {
+ atomic.AddInt32(&c.forwardActive, -1)
+ mu.Lock()
+ if flows[key] == flow {
+ delete(flows, key)
+ close(flow.payload)
+ }
+ mu.Unlock()
+ }()
+ remote, err := net.ResolveUDPAddr("udp", c.config.Endpoints.Next())
+ if err != nil {
+ return
+ }
+ tunnel, err := net.DialUDP("udp", nil, remote)
+ if err != nil {
+ return
+ }
+ c.applyBuffers(tunnel)
+ defer tunnel.Close()
+ announcement, err := utils.EncodeForwardUDP(c.config.Token, target)
+ if err != nil {
+ return
+ }
+ if _, err := tunnel.Write(announcement); err != nil {
+ return
+ }
+ _ = tunnel.SetReadDeadline(time.Now().Add(c.config.DialTimeOut))
+ ack := []byte{0}
+ if _, err := tunnel.Read(ack); err != nil || ack[0] != utils.SG_ForwardOK {
+ return
+ }
+ _ = tunnel.SetReadDeadline(time.Time{})
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for {
+ select {
+ case <-c.state.Ctx().Done():
+ return
+ case payload, ok := <-flow.payload:
+ if !ok {
+ return
+ }
+ c.forwardBandwidth.wait(len(payload))
+ _ = tunnel.SetWriteDeadline(time.Now().Add(60 * time.Second))
+ if _, err := tunnel.Write(payload); err != nil {
+ return
+ }
+ metrics.AddBytes(0, uint64(len(payload)))
+ }
+ }
+ }()
+
+ buf := make([]byte, 64*1024)
+ for {
+ _ = tunnel.SetReadDeadline(time.Now().Add(60 * time.Second))
+ n, err := tunnel.Read(buf)
+ if err != nil {
+ return
+ }
+ c.forwardBandwidth.wait(n)
+ if _, err := listener.WriteToUDP(buf[:n], flow.client); err != nil {
+ return
+ }
+ metrics.AddBytes(uint64(n), 0)
+ select {
+ case <-done:
+ return
+ default:
+ }
+ }
+}
diff --git a/internal/client/transport/kcp.go b/internal/client/transport/kcp.go
index 2c58647..8fdd6d7 100644
--- a/internal/client/transport/kcp.go
+++ b/internal/client/transport/kcp.go
@@ -34,6 +34,8 @@ type KcpTransport struct {
poolConnections int32
loadConnections int32
controlFlow chan struct{}
+ forwardSessions chan *smux.Session
+ forwardActive int32
}
type KcpConfig struct {
@@ -83,6 +85,11 @@ type KcpConfig struct {
SpoofSrcPool []string
SpoofPeerIP string
SpoofInterface string
+ Forward bool
+ Ports []string
+ ProxyProtocol bool
+ MaxConnections int
+ BandwidthMbps int
}
// transportLabel is what the panel and logs call this transport — XDI over ICMP
@@ -97,6 +104,10 @@ func (c *KcpTransport) transportLabel() string {
return "KCP"
}
+func (c *KcpTransport) rawForward() bool {
+ return c.config.Forward && (c.config.UseICMP || c.config.UseSpoof)
+}
+
func (c *KcpConfig) settings() network.KCPSettings {
s := network.KCPSettings{
MTU: c.MTU,
@@ -147,6 +158,7 @@ func NewKcpClient(parentCtx context.Context, config *KcpConfig, logger *logrus.L
poolConnections: 0,
loadConnections: 0,
controlFlow: make(chan struct{}, 100),
+ forwardSessions: make(chan *smux.Session, max(1, config.ConnPoolSize)),
}
// Seed the first generation through the same path a restart uses, so
// there is only one way this state is ever published.
@@ -180,6 +192,7 @@ func (c *KcpTransport) Restart() {
if c.state.Cancel() != nil {
c.state.Cancel()()
}
+ metrics.ClearPeer()
c.state.CloseConn()
@@ -211,6 +224,16 @@ func (c *KcpTransport) Restart() {
// connection that is gone until the new run's first tick replaced them.
metrics.ClearPool()
drain(c.controlFlow)
+ for {
+ select {
+ case session := <-c.forwardSessions:
+ _ = session.Close()
+ default:
+ goto sessionsDrained
+ }
+ }
+sessionsDrained:
+ atomic.StoreInt32(&c.forwardActive, 0)
c.logger.SetLevel(level)
@@ -301,6 +324,19 @@ func (c *KcpTransport) channelDialer() {
bo.Wait(c.state.Ctx())
continue
}
+ if c.rawForward() {
+ // Raw ICMP/spoof carriers cannot safely open several PacketConns
+ // with the same tunnel identity: the kernel may deliver a reply to
+ // the wrong socket. Reuse the authenticated control KCP session as
+ // the one long-lived SMUX carrier for every Direct stream.
+ c.state.SetConn(tunnelConn)
+ metrics.ReportPeer(tunnelConn.RemoteAddr().String())
+ c.config.TunnelStatus = "Connected (" + c.transportLabel() + ")"
+ atomic.AddInt32(&c.poolConnections, 1)
+ go c.handleSession(tunnelConn)
+ go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardKCPStream, func() { go c.Restart() })
+ return
+ }
c.state.SetConn(tunnelConn)
c.logger.Info("control channel established successfully")
@@ -309,6 +345,9 @@ func (c *KcpTransport) channelDialer() {
go c.poolMaintainer()
go c.channelHandler()
+ if c.config.Forward {
+ go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardKCPStream, func() { go c.Restart() })
+ }
return
}
@@ -487,7 +526,11 @@ func (c *KcpTransport) tunnelDialer() {
// materialises a session once it receives a packet from this socket. So
// every pool connection announces itself with the token, which both wakes
// the listener and authenticates the session before any data flows.
- if err := utils.SendBinaryTransportString(tunnelConn, c.config.Token, utils.SG_TCP); err != nil {
+ signal := utils.SG_TCP
+ if c.config.Forward {
+ signal = utils.SG_ForwardTCP
+ }
+ if err := utils.SendBinaryTransportString(tunnelConn, c.config.Token, signal); err != nil {
c.logger.Errorf("failed to announce tunnel connection: %v", err)
tunnelConn.Close()
return
@@ -501,6 +544,12 @@ func (c *KcpTransport) tunnelDialer() {
func (c *KcpTransport) handleSession(tunnelConn net.Conn) {
defer func() {
atomic.AddInt32(&c.poolConnections, -1)
+ if c.rawForward() {
+ metrics.ClearPeer()
+ if c.state.Ctx().Err() == nil {
+ go c.Restart()
+ }
+ }
}()
// SMUX server
@@ -510,6 +559,17 @@ func (c *KcpTransport) handleSession(tunnelConn net.Conn) {
tunnelConn.Close()
return
}
+ if c.config.Forward {
+ select {
+ case c.forwardSessions <- session:
+ case <-c.state.Ctx().Done():
+ session.Close()
+ return
+ }
+ <-c.state.Ctx().Done()
+ session.Close()
+ return
+ }
for {
select {
@@ -535,6 +595,49 @@ func (c *KcpTransport) handleSession(tunnelConn net.Conn) {
}
}
+func (c *KcpTransport) openForwardKCPStream(target string) (net.Conn, error) {
+ timer := time.NewTimer(c.config.DialTimeOut)
+ defer timer.Stop()
+ for {
+ select {
+ case <-c.state.Ctx().Done():
+ return nil, c.state.Ctx().Err()
+ case <-timer.C:
+ return nil, fmt.Errorf("no forward KCP session became ready")
+ case session := <-c.forwardSessions:
+ stream, err := session.OpenStream()
+ if err != nil {
+ session.Close()
+ if c.rawForward() {
+ go c.Restart()
+ } else {
+ go c.tunnelDialer()
+ }
+ continue
+ }
+ select {
+ case c.forwardSessions <- session:
+ default:
+ }
+ if err := utils.SendBinaryString(stream, target); err != nil {
+ stream.Close()
+ return nil, err
+ }
+ _ = stream.SetReadDeadline(time.Now().Add(c.config.DialTimeOut))
+ status, err := utils.ReceiveBinaryByte(stream)
+ _ = stream.SetReadDeadline(time.Time{})
+ if err != nil || status != utils.SG_ForwardOK {
+ stream.Close()
+ if err != nil {
+ return nil, err
+ }
+ return nil, fmt.Errorf("Kharej backend %q is unavailable or invalid", target)
+ }
+ return stream, nil
+ }
+ }
+}
+
func (c *KcpTransport) localDialer(stream *smux.Stream, remoteAddr string) {
port, resolvedAddr, err := network.ResolveRemoteAddr(remoteAddr)
if err != nil {
diff --git a/internal/client/transport/quic.go b/internal/client/transport/quic.go
index 93b4e2d..5490a49 100644
--- a/internal/client/transport/quic.go
+++ b/internal/client/transport/quic.go
@@ -38,8 +38,9 @@ type QuicTransport struct {
// connMu guards quicConn, the connection this run opens its streams on. It is
// replaced on every restart, so a data stream is always opened on the current
// connection rather than a torn-down one.
- connMu sync.Mutex
- quicConn *quic.Conn
+ connMu sync.Mutex
+ quicConn *quic.Conn
+ forwardActive int32
}
type QuicConfig struct {
@@ -59,6 +60,11 @@ type QuicConfig struct {
AggressivePool bool
SO_RCVBUF int
SO_SNDBUF int
+ Forward bool
+ Ports []string
+ ProxyProtocol bool
+ MaxConnections int
+ BandwidthMbps int
}
func (c *QuicConfig) settings() network.QUICSettings {
@@ -158,6 +164,7 @@ func (c *QuicTransport) Restart() {
c.config.TunnelStatus = ""
atomic.StoreInt32(&c.poolConnections, 0)
atomic.StoreInt32(&c.loadConnections, 0)
+ atomic.StoreInt32(&c.forwardActive, 0)
metrics.ClearPool()
drain(c.controlFlow)
@@ -247,14 +254,47 @@ func (c *QuicTransport) channelDialer() {
c.config.TunnelStatus = "Connected (QUIC)"
- go c.poolMaintainer()
go c.channelHandler()
+ if c.config.Forward {
+ go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardQUICStream, func() { go c.Restart() })
+ } else {
+ go c.poolMaintainer()
+ }
return
}
}
}
+func (c *QuicTransport) openForwardQUICStream(target string) (net.Conn, error) {
+ qc := c.getQUICConn()
+ if qc == nil {
+ return nil, fmt.Errorf("forward QUIC connection is not ready")
+ }
+ stream, err := qc.OpenStreamSync(c.state.Ctx())
+ if err != nil {
+ return nil, err
+ }
+ data := network.NewQUICStreamConn(stream, qc)
+ fail := func(err error) (net.Conn, error) { data.Close(); return nil, err }
+ if err := utils.SendBinaryTransportString(data, c.config.Token, utils.SG_ForwardTCP); err != nil {
+ return fail(err)
+ }
+ if err := utils.SendBinaryString(data, target); err != nil {
+ return fail(err)
+ }
+ _ = data.SetReadDeadline(time.Now().Add(c.config.DialTimeOut))
+ status, err := utils.ReceiveBinaryByte(data)
+ _ = data.SetReadDeadline(time.Time{})
+ if err != nil {
+ return fail(err)
+ }
+ if status != utils.SG_ForwardOK {
+ return fail(fmt.Errorf("Kharej backend %q is unavailable or invalid", target))
+ }
+ return data, nil
+}
+
func (c *QuicTransport) poolMaintainer() {
for i := 0; i < c.config.ConnPoolSize; i++ { // initial pool filling
go c.tunnelDialer()
diff --git a/internal/client/transport/tcp.go b/internal/client/transport/tcp.go
index 2371060..5c98faf 100644
--- a/internal/client/transport/tcp.go
+++ b/internal/client/transport/tcp.go
@@ -35,7 +35,8 @@ type TcpTransport struct {
// handshake, so later attempts skip straight to the old one instead of
// spending a connection discovering it again. It is not reset on restart:
// an upgraded server means an upgraded binary, which means a new process.
- legacyServer atomic.Bool
+ legacyServer atomic.Bool
+ forwardBandwidth *forwardBandwidth
}
type TcpConfig struct {
RemoteAddr string
@@ -64,6 +65,15 @@ type TcpConfig struct {
// Stealth wraps every tunnel-carrying connection in the Noise record layer,
// so the stream has no fingerprint for deep packet inspection to match.
Stealth bool
+ // Forward turns this dialler into the Iran edge: it keeps the ordinary
+ // authenticated control channel, owns the public ingress listeners, and
+ // opens one authenticated data connection per accepted user connection.
+ Forward bool
+ Ports []string
+ AcceptUDP bool
+ ProxyProtocol bool
+ MaxConnections int
+ BandwidthMbps int
}
// wrapStealth upgrades a freshly dialled tunnel connection to the Noise record
@@ -83,12 +93,13 @@ func NewTCPClient(parentCtx context.Context, config *TcpConfig, logger *logrus.L
// Initialize the TcpTransport struct
client := &TcpTransport{
- config: config,
- parentctx: parentCtx,
- logger: logger,
- poolConnections: 0,
- loadConnections: 0,
- controlFlow: make(chan struct{}, 100),
+ config: config,
+ parentctx: parentCtx,
+ logger: logger,
+ poolConnections: 0,
+ loadConnections: 0,
+ controlFlow: make(chan struct{}, 100),
+ forwardBandwidth: newForwardBandwidth(config.BandwidthMbps),
}
// Seed the first generation through the same path a restart uses, so
@@ -248,8 +259,18 @@ func (c *TcpTransport) channelDialer() {
c.logger.Info("control channel established successfully")
c.config.TunnelStatus = "Connected (TCP)"
- go c.poolMaintainer()
go c.channelHandler()
+ if c.config.Forward {
+ if nonce == "" {
+ c.logger.Error("forward mode requires the authenticated v2 control handshake; peer is too old")
+ tunnelTCPConn.Close()
+ bo.Wait(c.state.Ctx())
+ continue
+ }
+ go c.startForwardTCPIngress()
+ return
+ }
+ go c.poolMaintainer()
return
@@ -263,6 +284,47 @@ func (c *TcpTransport) channelDialer() {
}
}
+// openForwardTCP creates the data connection proactively from the Iran edge.
+// It returns only after the Kharej origin has successfully dialled the target,
+// so an unavailable backend becomes a prompt close instead of a black hole.
+func (c *TcpTransport) openForwardTCP(target string) (net.Conn, error) {
+ nonce := c.poolNonce.Get()
+ if nonce == "" || c.state.Conn() == nil {
+ return nil, fmt.Errorf("forward control channel is not ready")
+ }
+ raw, err := network.TcpDialerVia(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 3, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS)
+ if err != nil {
+ return nil, fmt.Errorf("dial forward origin: %w", err)
+ }
+ conn, err := c.wrapStealth(raw)
+ if err != nil {
+ raw.Close()
+ return nil, fmt.Errorf("forward stealth handshake: %w", err)
+ }
+ fail := func(e error) (net.Conn, error) {
+ conn.Close()
+ return nil, e
+ }
+ if err := utils.SendBinaryTransportString(conn, nonce, utils.SG_ForwardTCP); err != nil {
+ return fail(fmt.Errorf("announce forward data connection: %w", err))
+ }
+ if err := utils.SendBinaryString(conn, target); err != nil {
+ return fail(fmt.Errorf("send forward target: %w", err))
+ }
+ if err := conn.SetReadDeadline(time.Now().Add(c.config.DialTimeOut)); err != nil {
+ return fail(err)
+ }
+ status, err := utils.ReceiveBinaryByte(conn)
+ _ = conn.SetReadDeadline(time.Time{})
+ if err != nil {
+ return fail(fmt.Errorf("receive forward-open result: %w", err))
+ }
+ if status != utils.SG_ForwardOK {
+ return fail(fmt.Errorf("Kharej backend %q is unavailable or invalid", target))
+ }
+ return conn, nil
+}
+
func (c *TcpTransport) poolMaintainer() {
for i := 0; i < c.config.ConnPoolSize; i++ { //initial pool filling
go c.tunnelDialer()
diff --git a/internal/client/transport/tcpmux.go b/internal/client/transport/tcpmux.go
index 56ae5e4..3101e79 100644
--- a/internal/client/transport/tcpmux.go
+++ b/internal/client/transport/tcpmux.go
@@ -41,7 +41,9 @@ type TcpMuxTransport struct {
// handshake, so later attempts skip straight to the old one instead of
// spending a connection discovering it again. It is not reset on restart:
// an upgraded server means an upgraded binary, which means a new process.
- legacyServer atomic.Bool
+ legacyServer atomic.Bool
+ forwardSessions chan *smux.Session
+ forwardActive int32
}
type TcpMuxConfig struct {
@@ -71,7 +73,12 @@ type TcpMuxConfig struct {
// this machine: through a proxy, from a chosen source address or
// interface, under a routing mark. Nil dials directly. None of it is ever
// applied to the dial to the local backend — see network/outbound.go.
- Outbound *network.Outbound
+ Outbound *network.Outbound
+ Forward bool
+ Ports []string
+ ProxyProtocol bool
+ MaxConnections int
+ BandwidthMbps int
}
func NewMuxClient(parentCtx context.Context, config *TcpMuxConfig, logger *logrus.Logger) *TcpMuxTransport {
@@ -93,6 +100,7 @@ func NewMuxClient(parentCtx context.Context, config *TcpMuxConfig, logger *logru
poolConnections: 0,
loadConnections: 0,
controlFlow: make(chan struct{}, 100),
+ forwardSessions: make(chan *smux.Session, max(1, config.ConnPoolSize)),
}
// Seed the first generation through the same path a restart uses, so
@@ -163,6 +171,16 @@ func (c *TcpMuxTransport) Restart() {
// connection that is gone until the new run's first tick replaced them.
metrics.ClearPool()
drain(c.controlFlow)
+ for {
+ select {
+ case session := <-c.forwardSessions:
+ _ = session.Close()
+ default:
+ goto sessionsDrained
+ }
+ }
+sessionsDrained:
+ atomic.StoreInt32(&c.forwardActive, 0)
// set the log level again
c.logger.SetLevel(level)
@@ -244,9 +262,18 @@ func (c *TcpMuxTransport) channelDialer() {
c.logger.Infof("control channel established successfully (mux version %d)", c.muxVersion.Load())
c.config.TunnelStatus = "Connected (TCPMux)"
+ if c.config.Forward && nonce == "" {
+ c.logger.Error("forward mode requires the authenticated v2 control handshake; peer is too old")
+ tunnelConn.Close()
+ bo.Wait(c.state.Ctx())
+ continue
+ }
go c.poolMaintainer()
go c.channelHandler()
+ if c.config.Forward {
+ go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardMuxStream, func() { go c.Restart() })
+ }
return
} else {
@@ -415,7 +442,11 @@ func (c *TcpMuxTransport) tunnelDialer() {
// Say what this connection is, so the server admits it on the nonce rather
// than on the address it happened to dial out from.
- if err := announcePoolConn(tunnelConn, c.poolNonce.Get()); err != nil {
+ signal := utils.SG_Pool
+ if c.config.Forward {
+ signal = utils.SG_ForwardTCP
+ }
+ if err := utils.SendBinaryTransportString(tunnelConn, c.poolNonce.Get(), signal); err != nil {
c.logger.Debugf("tunnel dialer: failed to announce the pool connection: %v", err)
tunnelConn.Close()
return
@@ -438,6 +469,17 @@ func (c *TcpMuxTransport) handleSession(tunnelConn net.Conn) {
c.logger.Errorf("failed to create mux session: %v", err)
return
}
+ if c.config.Forward {
+ select {
+ case c.forwardSessions <- session:
+ case <-c.state.Ctx().Done():
+ session.Close()
+ return
+ }
+ <-c.state.Ctx().Done()
+ session.Close()
+ return
+ }
for {
select {
@@ -463,6 +505,45 @@ func (c *TcpMuxTransport) handleSession(tunnelConn net.Conn) {
}
}
+func (c *TcpMuxTransport) openForwardMuxStream(target string) (net.Conn, error) {
+ deadline := time.NewTimer(c.config.DialTimeOut)
+ defer deadline.Stop()
+ for {
+ select {
+ case <-c.state.Ctx().Done():
+ return nil, c.state.Ctx().Err()
+ case <-deadline.C:
+ return nil, fmt.Errorf("no forward mux session became ready")
+ case session := <-c.forwardSessions:
+ stream, err := session.OpenStream()
+ if err != nil {
+ session.Close()
+ go c.tunnelDialer()
+ continue
+ }
+ select {
+ case c.forwardSessions <- session:
+ default:
+ }
+ if err := utils.SendBinaryString(stream, target); err != nil {
+ stream.Close()
+ return nil, err
+ }
+ _ = stream.SetReadDeadline(time.Now().Add(c.config.DialTimeOut))
+ status, err := utils.ReceiveBinaryByte(stream)
+ _ = stream.SetReadDeadline(time.Time{})
+ if err != nil || status != utils.SG_ForwardOK {
+ stream.Close()
+ if err != nil {
+ return nil, err
+ }
+ return nil, fmt.Errorf("Kharej backend %q is unavailable or invalid", target)
+ }
+ return stream, nil
+ }
+ }
+}
+
func (c *TcpMuxTransport) localDialer(stream *smux.Stream, remoteAddr string) {
// Extract the port from the received address
port, resolvedAddr, err := network.ResolveRemoteAddr(remoteAddr)
diff --git a/internal/client/transport/udp.go b/internal/client/transport/udp.go
index 42cf282..4812ba0 100644
--- a/internal/client/transport/udp.go
+++ b/internal/client/transport/udp.go
@@ -15,14 +15,16 @@ import (
)
type UdpTransport struct {
- config *UdpConfig
- parentctx context.Context
- state clientState
- logger *logrus.Logger
- restartMutex sync.Mutex
- poolConnections int32
- loadConnections int32
- controlFlow chan struct{}
+ config *UdpConfig
+ parentctx context.Context
+ state clientState
+ logger *logrus.Logger
+ restartMutex sync.Mutex
+ poolConnections int32
+ loadConnections int32
+ controlFlow chan struct{}
+ forwardActive int32
+ forwardBandwidth *forwardBandwidth
}
type UdpConfig struct {
RemoteAddr string
@@ -42,8 +44,12 @@ type UdpConfig struct {
// to the server and to the local backend. The kernel default is small enough
// that a datagram flood overruns it and drops packets before they are read;
// the preset's several MB is what carries a speed test without stalling.
- SO_RCVBUF int
- SO_SNDBUF int
+ SO_RCVBUF int
+ SO_SNDBUF int
+ Forward bool
+ Ports []string
+ MaxConnections int
+ BandwidthMbps int
}
func NewUDPClient(parentCtx context.Context, config *UdpConfig, logger *logrus.Logger) *UdpTransport {
@@ -52,12 +58,13 @@ func NewUDPClient(parentCtx context.Context, config *UdpConfig, logger *logrus.L
// Initialize the TcpTransport struct
client := &UdpTransport{
- config: config,
- parentctx: parentCtx,
- logger: logger,
- poolConnections: 0,
- loadConnections: 0,
- controlFlow: make(chan struct{}, 100),
+ config: config,
+ parentctx: parentCtx,
+ logger: logger,
+ poolConnections: 0,
+ loadConnections: 0,
+ controlFlow: make(chan struct{}, 100),
+ forwardBandwidth: newForwardBandwidth(config.BandwidthMbps),
}
// Seed the first generation through the same path a restart uses, so
@@ -191,8 +198,12 @@ func (c *UdpTransport) channelDialer() {
c.config.TunnelStatus = "Connected (UDP)"
- go c.poolMaintainer()
go c.channelHandler()
+ if c.config.Forward {
+ go c.startForwardUDPIngress()
+ } else {
+ go c.poolMaintainer()
+ }
return
diff --git a/internal/client/transport/ws.go b/internal/client/transport/ws.go
index 0699b1c..f69889c 100644
--- a/internal/client/transport/ws.go
+++ b/internal/client/transport/ws.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
+ "net"
"strings"
"sync"
"sync/atomic"
@@ -20,14 +21,16 @@ import (
)
type WsTransport struct {
- config *WsConfig
- parentctx context.Context
- state clientState
- logger *logrus.Logger
- restartMutex sync.Mutex
- poolConnections int32
- loadConnections int32
- controlFlow chan struct{}
+ config *WsConfig
+ parentctx context.Context
+ state clientState
+ logger *logrus.Logger
+ restartMutex sync.Mutex
+ poolConnections int32
+ loadConnections int32
+ controlFlow chan struct{}
+ forwardActive int32
+ forwardBandwidth *forwardBandwidth
}
type WsConfig struct {
RemoteAddr string
@@ -52,7 +55,11 @@ type WsConfig struct {
// this machine: through a proxy, from a chosen source address or
// interface, under a routing mark. Nil dials directly. None of it is ever
// applied to the dial to the local backend — see network/outbound.go.
- Outbound *network.Outbound
+ Outbound *network.Outbound
+ Forward bool
+ Ports []string
+ MaxConnections int
+ BandwidthMbps int
}
func NewWSClient(parentCtx context.Context, config *WsConfig, logger *logrus.Logger) *WsTransport {
@@ -61,12 +68,13 @@ func NewWSClient(parentCtx context.Context, config *WsConfig, logger *logrus.Log
// Initialize the TcpTransport struct
client := &WsTransport{
- config: config,
- parentctx: parentCtx,
- logger: logger,
- poolConnections: 0,
- loadConnections: 0,
- controlFlow: make(chan struct{}, 100),
+ config: config,
+ parentctx: parentCtx,
+ logger: logger,
+ poolConnections: 0,
+ loadConnections: 0,
+ controlFlow: make(chan struct{}, 100),
+ forwardBandwidth: newForwardBandwidth(config.BandwidthMbps),
}
// Seed the first generation through the same path a restart uses, so
@@ -129,6 +137,7 @@ func (c *WsTransport) Restart() {
c.config.TunnelStatus = ""
atomic.StoreInt32(&c.poolConnections, 0)
atomic.StoreInt32(&c.loadConnections, 0)
+ atomic.StoreInt32(&c.forwardActive, 0)
drain(c.controlFlow)
// set the log level again
@@ -166,14 +175,82 @@ func (c *WsTransport) channelDialer() {
c.config.TunnelStatus = fmt.Sprintf("Connected (%s)", c.config.Mode)
- go c.poolMaintainer()
go c.channelHandler()
+ if c.config.Forward {
+ go c.startForwardWSIngress()
+ } else {
+ go c.poolMaintainer()
+ }
return
}
}
}
+func (c *WsTransport) startForwardWSIngress() {
+ mappings, err := expandForwardTCPMappings(c.config.Ports)
+ if err != nil {
+ c.logger.Errorf("invalid forward ingress mappings: %v", err)
+ go c.Restart()
+ return
+ }
+ for _, mapping := range mappings {
+ mapping := mapping
+ go func() {
+ listener, err := net.Listen("tcp", mapping.listen)
+ if err != nil {
+ c.logger.Errorf("failed to listen on forward ingress %s: %v", mapping.listen, err)
+ go c.Restart()
+ return
+ }
+ defer listener.Close()
+ go func() { <-c.state.Ctx().Done(); _ = listener.Close() }()
+ c.logger.Infof("forward ingress listening on %s -> Kharej %s", listener.Addr(), mapping.target)
+ for {
+ local, err := listener.Accept()
+ if err != nil {
+ if c.state.Ctx().Err() != nil {
+ return
+ }
+ continue
+ }
+ if !acquireForwardConnection(&c.forwardActive, c.config.MaxConnections) {
+ local.Close()
+ continue
+ }
+ go c.handleForwardWSIngress(local, mapping.target)
+ }
+ }()
+ }
+}
+
+func (c *WsTransport) handleForwardWSIngress(local net.Conn, target string) {
+ defer atomic.AddInt32(&c.forwardActive, -1)
+ local = c.forwardBandwidth.wrap(local)
+ wsConn, err := network.WebSocketDialer(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Next(), c.config.EdgeIP, "/tunnel", c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, c.config.Token, c.config.Mode, c.config.SimpleAuth, 3, 1024*1024, 1024*1024)
+ if err != nil {
+ local.Close()
+ return
+ }
+ fail := func() { wsConn.Close(); local.Close() }
+ if err := wsConn.WriteMessage(websocket.TextMessage, []byte(target)); err != nil {
+ fail()
+ return
+ }
+ _ = wsConn.SetReadDeadline(time.Now().Add(c.config.DialTimeOut))
+ _, ack, err := wsConn.ReadMessage()
+ _ = wsConn.SetReadDeadline(time.Time{})
+ if err != nil || len(ack) != 1 || ack[0] != utils.SG_ForwardOK {
+ fail()
+ return
+ }
+ port := 0
+ if addr, ok := local.LocalAddr().(*net.TCPAddr); ok {
+ port = addr.Port
+ }
+ handlers.WSConnectionHandler(c.state.Ctx(), wsConn, local, c.logger, c.state.Usage(), port, c.config.Sniffer)
+}
+
func (c *WsTransport) poolMaintainer() {
for i := 0; i < c.config.ConnPoolSize; i++ { //initial pool filling
go c.tunnelDialer()
diff --git a/internal/client/transport/wsmux.go b/internal/client/transport/wsmux.go
index 0d970e3..2a4eb2a 100644
--- a/internal/client/transport/wsmux.go
+++ b/internal/client/transport/wsmux.go
@@ -3,6 +3,7 @@ package transport
import (
"context"
"fmt"
+ "net"
"strings"
"sync"
"sync/atomic"
@@ -30,6 +31,8 @@ type WsMuxTransport struct {
poolConnections int32
loadConnections int32
controlFlow chan struct{}
+ forwardSessions chan *smux.Session
+ forwardActive int32
}
type WsMuxConfig struct {
RemoteAddr string
@@ -58,7 +61,12 @@ type WsMuxConfig struct {
// this machine: through a proxy, from a chosen source address or
// interface, under a routing mark. Nil dials directly. None of it is ever
// applied to the dial to the local backend — see network/outbound.go.
- Outbound *network.Outbound
+ Outbound *network.Outbound
+ Forward bool
+ Ports []string
+ ProxyProtocol bool
+ MaxConnections int
+ BandwidthMbps int
}
func NewWSMuxClient(parentCtx context.Context, config *WsMuxConfig, logger *logrus.Logger) *WsMuxTransport {
@@ -81,6 +89,7 @@ func NewWSMuxClient(parentCtx context.Context, config *WsMuxConfig, logger *logr
poolConnections: 0,
loadConnections: 0,
controlFlow: make(chan struct{}, 100),
+ forwardSessions: make(chan *smux.Session, max(1, config.ConnPoolSize)),
}
// Seed the first generation through the same path a restart uses, so
@@ -147,6 +156,16 @@ func (c *WsMuxTransport) Restart() {
// connection that is gone until the new run's first tick replaced them.
metrics.ClearPool()
drain(c.controlFlow)
+ for {
+ select {
+ case session := <-c.forwardSessions:
+ _ = session.Close()
+ default:
+ goto sessionsDrained
+ }
+ }
+sessionsDrained:
+ atomic.StoreInt32(&c.forwardActive, 0)
// set the log level again
c.logger.SetLevel(level)
@@ -186,6 +205,9 @@ func (c *WsMuxTransport) channelDialer() {
go c.poolMaintainer()
go c.channelHandler()
+ if c.config.Forward {
+ go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardWSMuxStream, func() { go c.Restart() })
+ }
return
}
@@ -368,6 +390,17 @@ func (c *WsMuxTransport) handleSession(tunnelConn *websocket.Conn) {
c.logger.Errorf("failed to create mux session: %v", err)
return
}
+ if c.config.Forward {
+ select {
+ case c.forwardSessions <- session:
+ case <-c.state.Ctx().Done():
+ session.Close()
+ return
+ }
+ <-c.state.Ctx().Done()
+ session.Close()
+ return
+ }
for {
select {
@@ -393,6 +426,45 @@ func (c *WsMuxTransport) handleSession(tunnelConn *websocket.Conn) {
}
}
+func (c *WsMuxTransport) openForwardWSMuxStream(target string) (net.Conn, error) {
+ timer := time.NewTimer(c.config.DialTimeOut)
+ defer timer.Stop()
+ for {
+ select {
+ case <-c.state.Ctx().Done():
+ return nil, c.state.Ctx().Err()
+ case <-timer.C:
+ return nil, fmt.Errorf("no forward websocket mux session became ready")
+ case session := <-c.forwardSessions:
+ stream, err := session.OpenStream()
+ if err != nil {
+ session.Close()
+ go c.tunnelDialer()
+ continue
+ }
+ select {
+ case c.forwardSessions <- session:
+ default:
+ }
+ if err := utils.SendBinaryString(stream, target); err != nil {
+ stream.Close()
+ return nil, err
+ }
+ _ = stream.SetReadDeadline(time.Now().Add(c.config.DialTimeOut))
+ status, err := utils.ReceiveBinaryByte(stream)
+ _ = stream.SetReadDeadline(time.Time{})
+ if err != nil || status != utils.SG_ForwardOK {
+ stream.Close()
+ if err != nil {
+ return nil, err
+ }
+ return nil, fmt.Errorf("Kharej backend %q is unavailable or invalid", target)
+ }
+ return stream, nil
+ }
+ }
+}
+
func (c *WsMuxTransport) localDialer(stream *smux.Stream, remoteAddr string) {
// Extract the port from the received address
port, resolvedAddr, err := network.ResolveRemoteAddr(remoteAddr)
diff --git a/internal/e2e/forward_tcp_test.go b/internal/e2e/forward_tcp_test.go
new file mode 100644
index 0000000..65632ae
--- /dev/null
+++ b/internal/e2e/forward_tcp_test.go
@@ -0,0 +1,90 @@
+package e2e
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/backpack/backpack/internal/client"
+ "github.com/backpack/backpack/internal/server"
+)
+
+// TestForwardTCP proves the corrected direct semantics end to end: the Iran
+// process is the dialler and owns the user-facing port, while the listening
+// Kharej process owns and dials the backend.
+func TestForwardStreamTransports(t *testing.T) {
+ for _, transport := range []string{"tcp", "stealth", "tcpmux", "kcp", "quic", "ws", "wss", "wsmux", "wssmux"} {
+ t.Run(transport, func(t *testing.T) { testForwardStreamTransport(t, transport) })
+ }
+}
+
+func testForwardStreamTransport(t *testing.T, transport string) {
+ backend := startEchoBackend(t)
+ tunnelPort := freePort(t)
+ entryPort := freePort(t)
+ token := "forward-e2e-token-0123456789abcdef"
+
+ origin := baseServerConfig(transport, tunnelPort, 1, backend.addr, token)
+ if transport == "wss" || transport == "wssmux" {
+ origin.TLSCertFile, origin.TLSKeyFile = testCert(t)
+ }
+ origin.Ports = nil // Kharej must not expose the user's ingress port.
+ edge := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil)
+ edge.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backend.addr)}
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var wg sync.WaitGroup
+ srv := server.NewForwardOrigin(origin, ctx)
+ wg.Add(1)
+ go func() { defer wg.Done(); srv.Start() }()
+ time.Sleep(300 * time.Millisecond)
+ cli := client.NewForwardEdge(edge, ctx)
+ wg.Add(1)
+ go func() { defer wg.Done(); cli.Start() }()
+
+ tun := &tunnel{Entry: fmt.Sprintf("127.0.0.1:%d", entryPort), TunnelPort: tunnelPort, cancel: cancel, wg: &wg}
+ t.Cleanup(tun.Stop)
+ if err := tun.waitReady(tunnelReadyTimeout); err != nil {
+ t.Fatalf("forward %s tunnel never carried traffic: %v", transport, err)
+ }
+ if err := tun.roundTrip(randomPayload(t, 512*1024)); err != nil {
+ t.Fatalf("forward %s payload failed: %v", transport, err)
+ }
+}
+
+func TestForwardUDP(t *testing.T) {
+ backendAddr := startUDPEchoBackend(t)
+ tunnelPort, entryPort := freePort(t), freePort(t)
+ token := "forward-udp-token-0123456789abcdef"
+ origin := baseServerConfig("udp", tunnelPort, 1, backendAddr, token)
+ origin.Ports = nil
+ edge := baseClientConfig("udp", fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil)
+ edge.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backendAddr)}
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var wg sync.WaitGroup
+ t.Cleanup(func() { cancel(); wg.Wait() })
+ srv := server.NewForwardOrigin(origin, ctx)
+ wg.Add(1)
+ go func() { defer wg.Done(); srv.Start() }()
+ time.Sleep(300 * time.Millisecond)
+ cli := client.NewForwardEdge(edge, ctx)
+ wg.Add(1)
+ go func() { defer wg.Done(); cli.Start() }()
+
+ entry := fmt.Sprintf("127.0.0.1:%d", entryPort)
+ payload := []byte("forward-udp-datagram")
+ deadline := time.Now().Add(tunnelReadyTimeout)
+ var lastErr error
+ for time.Now().Before(deadline) {
+ if err := udpRoundTrip(entry, payload); err == nil {
+ return
+ } else {
+ lastErr = err
+ }
+ time.Sleep(250 * time.Millisecond)
+ }
+ t.Fatalf("forward UDP tunnel never carried a datagram: %v", lastErr)
+}
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index 0677814..c83fdde 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -19,6 +19,9 @@ type Metadata struct {
type Request struct {
ConfigPath string
Config *config.Config
+ // Replacing means validation is evaluating a candidate for an instance
+ // whose current process may still own its existing listen sockets.
+ Replacing bool
}
type Health struct {
diff --git a/internal/engine/forward.go b/internal/engine/forward.go
new file mode 100644
index 0000000..588c871
--- /dev/null
+++ b/internal/engine/forward.go
@@ -0,0 +1,122 @@
+package engine
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "path/filepath"
+ "strings"
+
+ "github.com/backpack/backpack/config"
+ "github.com/backpack/backpack/internal/client"
+ "github.com/backpack/backpack/internal/forwardmap"
+ "github.com/backpack/backpack/internal/metrics"
+ "github.com/backpack/backpack/internal/server"
+)
+
+// forwardProvider is Backpack's application-level direct mode: the Iran edge
+// dials Kharej using a normal Backpack transport, while user-facing ports stay
+// on Iran and backends stay on Kharej. It is not the iptables DNAT provider.
+type forwardProvider struct{}
+
+func init() { Register(config.EngineForward, forwardProvider{}) }
+
+func (forwardProvider) Metadata() Metadata { return Metadata{Name: "forward", Mode: "direct"} }
+
+func (forwardProvider) Validate(_ context.Context, r Request) error {
+ if r.Config == nil {
+ return fmt.Errorf("nil forward tunnel configuration")
+ }
+ if err := r.Config.ValidateStructure(); err != nil {
+ return err
+ }
+ var transport config.TransportType
+ if r.Config.HasClient() {
+ transport = r.Config.Client.Transport
+ mappings, err := forwardmap.Expand(r.Config.Client.Ports)
+ if err != nil {
+ return fmt.Errorf("invalid forward ingress mappings: %w", err)
+ }
+ if !r.Replacing {
+ network := "tcp"
+ if transport == config.UDP {
+ network = "udp"
+ }
+ for _, mapping := range mappings {
+ if err := probeForwardListen(network, mapping.Listen); err != nil {
+ return fmt.Errorf("forward ingress %s/%s is unavailable: %w", network, mapping.Listen, err)
+ }
+ }
+ }
+ } else {
+ transport = r.Config.Server.Transport
+ if !r.Replacing && transport != config.XDI && transport != config.SPOOF {
+ network := "tcp"
+ if transport == config.UDP || transport == config.KCP || transport == config.QUIC {
+ network = "udp"
+ }
+ if err := probeForwardListen(network, r.Config.Server.BindAddr); err != nil {
+ return fmt.Errorf("forward tunnel listener %s/%s is unavailable: %w", network, r.Config.Server.BindAddr, err)
+ }
+ }
+ }
+ // Keep incomplete carriers impossible to start while their direction-aware
+ // stream adapters are being added. This guard is widened only together with
+ // an end-to-end test for that carrier.
+ switch transport {
+ case config.TCP, config.STEALTH, config.TCPMUX, config.KCP, config.XDI, config.SPOOF, config.QUIC, config.WS, config.WSS, config.WSMUX, config.WSSMUX, config.UDP:
+ return nil
+ default:
+ return fmt.Errorf("forward direction for transport %q is not implemented in this build", transport)
+ }
+}
+
+func probeForwardListen(network, addr string) error {
+ if network == "udp" {
+ pc, err := net.ListenPacket("udp", addr)
+ if err != nil {
+ return err
+ }
+ return pc.Close()
+ }
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ return err
+ }
+ return ln.Close()
+}
+
+func (p forwardProvider) Run(ctx context.Context, r Request) error {
+ if err := p.Validate(ctx, r); err != nil {
+ return err
+ }
+ if r.Config.HasClient() {
+ waitMetrics := reverseMetrics(ctx, r, string(r.Config.Client.Transport), "iran-edge")
+ c := client.NewForwardEdge(&r.Config.Client, ctx)
+ go c.Start()
+ <-ctx.Done()
+ c.Stop()
+ waitMetrics()
+ return nil
+ }
+ waitMetrics := reverseMetrics(ctx, r, string(r.Config.Server.Transport), "kharej-origin")
+ s := server.NewForwardOrigin(&r.Config.Server, ctx)
+ go s.Start()
+ <-ctx.Done()
+ s.Stop()
+ waitMetrics()
+ return nil
+}
+
+func (forwardProvider) Health(context.Context, Request) (Health, error) {
+ return Health{Ready: true, Detail: "forward transport process is running"}, nil
+}
+func (forwardProvider) Counters(_ context.Context, r Request) (Counters, error) {
+ name := strings.TrimSuffix(filepath.Base(r.ConfigPath), filepath.Ext(r.ConfigPath))
+ snap, err := metrics.Read(filepath.Dir(r.ConfigPath), name)
+ if err != nil {
+ return Counters{}, err
+ }
+ return Counters{RXBytes: snap.BytesIn, TXBytes: snap.BytesOut, RXPackets: snap.PacketsIn, TXPackets: snap.PacketsOut}, nil
+}
+func (forwardProvider) Cleanup(context.Context, Request) error { return nil }
diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go
index 66ed44d..d1be278 100644
--- a/internal/engine/reverse.go
+++ b/internal/engine/reverse.go
@@ -29,12 +29,19 @@ func reverseName(path string) string {
return strings.TrimSuffix(base, filepath.Ext(base))
}
-func reverseMetrics(ctx context.Context, r Request, transport, role string) {
+// reverseMetrics starts the process-wide collector and returns a waiter for its
+// final atomic snapshot. Providers must call the waiter after ctx is cancelled;
+// otherwise systemd can let the process exit while the final rename is still
+// pending, losing the last interval of cumulative counters.
+func reverseMetrics(ctx context.Context, r Request, transport, role string) func() {
c := metrics.NewCollector(filepath.Dir(r.ConfigPath), reverseName(r.ConfigPath), transport, role, nil, nil)
- done := make(chan struct{})
- go func() { <-ctx.Done(); close(done) }()
_ = c.Write()
- go c.Run(done, 30*time.Second)
+ finished := make(chan struct{})
+ go func() {
+ defer close(finished)
+ c.Run(ctx.Done(), 30*time.Second)
+ }()
+ return func() { <-finished }
}
func (reverseProvider) Run(ctx context.Context, r Request) error {
@@ -42,18 +49,20 @@ func (reverseProvider) Run(ctx context.Context, r Request) error {
return err
}
if r.Config.HasServer() {
- reverseMetrics(ctx, r, string(r.Config.Server.Transport), "server")
+ waitMetrics := reverseMetrics(ctx, r, string(r.Config.Server.Transport), "server")
s := server.NewServer(&r.Config.Server, ctx)
go s.Start()
<-ctx.Done()
s.Stop()
+ waitMetrics()
return nil
}
- reverseMetrics(ctx, r, string(r.Config.Client.Transport), "client")
+ waitMetrics := reverseMetrics(ctx, r, string(r.Config.Client.Transport), "client")
c := client.NewClient(&r.Config.Client, ctx)
go c.Start()
<-ctx.Done()
c.Stop()
+ waitMetrics()
return nil
}
@@ -62,5 +71,11 @@ func (reverseProvider) Run(ctx context.Context, r Request) error {
func (reverseProvider) Health(context.Context, Request) (Health, error) {
return Health{Ready: true, Detail: "reverse health is connection-based"}, nil
}
-func (reverseProvider) Counters(context.Context, Request) (Counters, error) { return Counters{}, nil }
-func (reverseProvider) Cleanup(context.Context, Request) error { return nil }
+func (reverseProvider) Counters(_ context.Context, r Request) (Counters, error) {
+ snap, err := metrics.Read(filepath.Dir(r.ConfigPath), reverseName(r.ConfigPath))
+ if err != nil {
+ return Counters{}, err
+ }
+ return Counters{RXBytes: snap.BytesIn, TXBytes: snap.BytesOut, RXPackets: snap.PacketsIn, TXPackets: snap.PacketsOut}, nil
+}
+func (reverseProvider) Cleanup(context.Context, Request) error { return nil }
diff --git a/internal/engine/reverse_metrics_test.go b/internal/engine/reverse_metrics_test.go
new file mode 100644
index 0000000..cac60ef
--- /dev/null
+++ b/internal/engine/reverse_metrics_test.go
@@ -0,0 +1,28 @@
+package engine
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/backpack/backpack/internal/metrics"
+)
+
+func TestMetricsShutdownWaitsForFinalSnapshot(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "sigterm.toml")
+ ctx, cancel := context.WithCancel(context.Background())
+ wait := reverseMetrics(ctx, Request{ConfigPath: path}, "tcp", "client")
+
+ metrics.AddBytes(17, 29)
+ cancel()
+ wait()
+
+ snapshot, err := metrics.Read(dir, "sigterm")
+ if err != nil {
+ t.Fatalf("read final metrics snapshot: %v", err)
+ }
+ if snapshot.BytesIn != 17 || snapshot.BytesOut != 29 {
+ t.Fatalf("final counters = %d/%d, want 17/29", snapshot.BytesIn, snapshot.BytesOut)
+ }
+}
diff --git a/internal/forwardmap/forwardmap.go b/internal/forwardmap/forwardmap.go
new file mode 100644
index 0000000..21e1431
--- /dev/null
+++ b/internal/forwardmap/forwardmap.go
@@ -0,0 +1,178 @@
+package forwardmap
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+)
+
+const (
+ MaxPortsPerMapping = 1024
+ MaxExpandedPorts = 4096
+)
+
+// Mapping is one concrete Iran listen socket and its Kharej backend target.
+type Mapping struct {
+ Listen string
+ Target string
+}
+
+type endpointRange struct {
+ host string
+ lo, hi int
+ explicitHost bool
+}
+
+func parseEndpointRange(raw string) (endpointRange, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return endpointRange{}, fmt.Errorf("empty endpoint")
+ }
+ host, portText := "", raw
+ if strings.Contains(raw, ":") {
+ var err error
+ host, portText, err = net.SplitHostPort(raw)
+ if err != nil {
+ return endpointRange{}, fmt.Errorf("invalid host:port %q (IPv6 addresses must be bracketed): %w", raw, err)
+ }
+ }
+ parts := strings.Split(portText, "-")
+ if len(parts) < 1 || len(parts) > 2 {
+ return endpointRange{}, fmt.Errorf("invalid port range %q", portText)
+ }
+ parsePort := func(v string) (int, error) {
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil || n < 1 || n > 65535 {
+ return 0, fmt.Errorf("invalid port %q", v)
+ }
+ return n, nil
+ }
+ lo, err := parsePort(parts[0])
+ if err != nil {
+ return endpointRange{}, err
+ }
+ hi := lo
+ if len(parts) == 2 {
+ hi, err = parsePort(parts[1])
+ if err != nil {
+ return endpointRange{}, err
+ }
+ if hi < lo {
+ return endpointRange{}, fmt.Errorf("port range %q ends before it starts", portText)
+ }
+ }
+ return endpointRange{host: host, lo: lo, hi: hi, explicitHost: strings.Contains(raw, ":")}, nil
+}
+
+func (e endpointRange) len() int { return e.hi - e.lo + 1 }
+
+func endpoint(e endpointRange, port int, listen bool) string {
+ if e.explicitHost {
+ return net.JoinHostPort(e.host, strconv.Itoa(port))
+ }
+ if listen {
+ return ":" + strconv.Itoa(port)
+ }
+ return strconv.Itoa(port)
+}
+
+// Expand parses Backpack's existing mapping syntax. Ranges preserve offsets
+// when the target is also a range. A single target intentionally fans a listen
+// range into one backend, matching the historical reverse behaviour.
+func Expand(specs []string) ([]Mapping, error) {
+ var out []Mapping
+ var listens []string
+ for _, raw := range specs {
+ left, right, hasTarget := strings.Cut(strings.TrimSpace(raw), "=")
+ listenRange, err := parseEndpointRange(left)
+ if err != nil {
+ return nil, fmt.Errorf("mapping %q listen: %w", raw, err)
+ }
+ if listenRange.len() > MaxPortsPerMapping {
+ return nil, fmt.Errorf("mapping %q expands beyond the %d-port mapping limit", raw, MaxPortsPerMapping)
+ }
+ if len(out)+listenRange.len() > MaxExpandedPorts {
+ return nil, fmt.Errorf("mappings expand beyond the %d-port instance limit", MaxExpandedPorts)
+ }
+
+ var targets []endpointRange
+ if !hasTarget {
+ targets = []endpointRange{{lo: listenRange.lo, hi: listenRange.hi}}
+ } else {
+ for _, targetRaw := range strings.Split(right, "|") {
+ target, err := parseEndpointRange(targetRaw)
+ if err != nil {
+ return nil, fmt.Errorf("mapping %q target: %w", raw, err)
+ }
+ if target.len() != 1 && target.len() != listenRange.len() {
+ return nil, fmt.Errorf("mapping %q listen and target ranges must have equal length", raw)
+ }
+ targets = append(targets, target)
+ }
+ }
+ if len(targets) == 0 {
+ return nil, fmt.Errorf("mapping %q has no target", raw)
+ }
+ for offset := 0; offset < listenRange.len(); offset++ {
+ targetParts := make([]string, 0, len(targets))
+ for _, target := range targets {
+ port := target.lo
+ if target.len() > 1 {
+ port += offset
+ }
+ targetParts = append(targetParts, endpoint(target, port, false))
+ }
+ listen := endpoint(listenRange, listenRange.lo+offset, true)
+ for _, old := range listens {
+ if listenOverlap(listen, old) {
+ return nil, fmt.Errorf("listen endpoint %s overlaps %s", listen, old)
+ }
+ }
+ listens = append(listens, listen)
+ out = append(out, Mapping{
+ Listen: listen,
+ Target: strings.Join(targetParts, "|"),
+ })
+ }
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("at least one ingress port mapping is required")
+ }
+ return out, nil
+}
+
+func listenOverlap(a, b string) bool {
+ ah, ap, aerr := net.SplitHostPort(a)
+ bh, bp, berr := net.SplitHostPort(b)
+ if aerr != nil || berr != nil || ap != bp {
+ return false
+ }
+ normalize := func(h string) string { return strings.Trim(strings.TrimSpace(h), "[]") }
+ wild := func(h string) bool {
+ switch normalize(h) {
+ case "", "0.0.0.0", "::", "*":
+ return true
+ }
+ return false
+ }
+ family := func(h string) int {
+ h = normalize(h)
+ if h == "" || h == "*" {
+ return 0 // an unspecified Go listen address may claim both families
+ }
+ ip := net.ParseIP(h)
+ if ip == nil {
+ return 0 // unresolved names are conservatively treated as either family
+ }
+ if ip.To4() != nil {
+ return 4
+ }
+ return 6
+ }
+ af, bf := family(ah), family(bh)
+ if af != 0 && bf != 0 && af != bf {
+ return false
+ }
+ return wild(ah) || wild(bh) || strings.EqualFold(ah, bh)
+}
diff --git a/internal/forwardmap/forwardmap_test.go b/internal/forwardmap/forwardmap_test.go
new file mode 100644
index 0000000..6bd76fc
--- /dev/null
+++ b/internal/forwardmap/forwardmap_test.go
@@ -0,0 +1,48 @@
+package forwardmap
+
+import "testing"
+
+func TestExpandOffsetRangesAndIPv6(t *testing.T) {
+ got, err := Expand([]string{"[::1]:1000-1002=[2001:db8::5]:2000-2002"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 3 || got[0].Listen != "[::1]:1000" || got[2].Target != "[2001:db8::5]:2002" {
+ t.Fatalf("unexpected expansion: %#v", got)
+ }
+}
+
+func TestExpandMultipleBackends(t *testing.T) {
+ got, err := Expand([]string{"100-101=127.0.0.1:200-201|[::1]:300-301"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got[1].Target != "127.0.0.1:201|[::1]:301" {
+ t.Fatalf("unexpected target: %q", got[1].Target)
+ }
+}
+
+func TestExpandRejectsUnequalRangeAndLimit(t *testing.T) {
+ if _, err := Expand([]string{"100-102=200-201"}); err == nil {
+ t.Fatal("unequal ranges must fail")
+ }
+ if _, err := Expand([]string{"1-4097"}); err == nil {
+ t.Fatal("oversized expansion must fail")
+ }
+}
+
+func TestExpandRejectsPerMappingExpansionLimit(t *testing.T) {
+ if _, err := Expand([]string{"1000-2024=3000-4024"}); err == nil {
+ t.Fatal("a mapping expanding beyond 1024 ports must be rejected")
+ }
+}
+
+func TestExpandKeepsExplicitIPv4AndIPv6FamiliesIndependent(t *testing.T) {
+ mappings, err := Expand([]string{"0.0.0.0:443=8443", "[::]:443=8443"})
+ if err != nil {
+ t.Fatalf("explicit IPv4 and IPv6 listeners should not overlap: %v", err)
+ }
+ if len(mappings) != 2 {
+ t.Fatalf("got %d mappings, want 2", len(mappings))
+ }
+}
diff --git a/internal/manage/backup.go b/internal/manage/backup.go
index ee6a867..f449a81 100644
--- a/internal/manage/backup.go
+++ b/internal/manage/backup.go
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
+ "net"
"os"
"path/filepath"
"sort"
@@ -170,11 +171,11 @@ func configsInDir(dir string) (map[string]*config.Config, error) {
return result, nil
}
-func validateRestoreSet(ctx context.Context, dir string, configs map[string]*config.Config) error {
+func validateRestoreSet(ctx context.Context, dir string, configs map[string]*config.Config, replacing bool) error {
for name, cfg := range configs {
provider, err := engine.Resolve(cfg)
if err == nil {
- err = provider.Validate(ctx, engine.Request{ConfigPath: filepath.Join(dir, name+".toml"), Config: cfg})
+ err = provider.Validate(ctx, engine.Request{ConfigPath: filepath.Join(dir, name+".toml"), Config: cfg, Replacing: replacing})
}
if err != nil {
return fmt.Errorf("restored instance %s is invalid or conflicts with this host: %w", name, err)
@@ -183,6 +184,76 @@ func validateRestoreSet(ctx context.Context, dir string, configs map[string]*con
return nil
}
+func restoreTunnel(name string, cfg *config.Config) Tunnel {
+ t := Tunnel{Name: name, Engine: string(cfg.EffectiveEngine())}
+ switch {
+ case cfg.EffectiveEngine() == config.EngineIPTables:
+ t.Mappings = append([]config.ForwardMapping(nil), cfg.Forward.Mappings...)
+ case cfg.HasServer():
+ t.Role, t.Transport, t.Addr = "server", string(cfg.Server.Transport), cfg.Server.BindAddr
+ t.Ports = append([]string(nil), cfg.Server.Ports...)
+ case cfg.HasClient():
+ t.Role, t.Transport, t.Addr = "client", string(cfg.Client.Transport), cfg.Client.RemoteAddr
+ if cfg.EffectiveEngine() == config.EngineForward {
+ t.Ports = append([]string(nil), cfg.Client.Ports...)
+ }
+ }
+ return t
+}
+
+// validateRestoreClaims compares the whole staged set with itself. Binding
+// each candidate and immediately closing it cannot discover that two staged
+// instances both intend to claim the same socket, so that comparison must be
+// explicit before any live service is stopped.
+func validateRestoreClaims(configs map[string]*config.Config) error {
+ type ownedClaim struct {
+ name string
+ listenClaim
+ }
+ var claims []ownedClaim
+ for name, cfg := range configs {
+ for _, claim := range tunnelClaims(restoreTunnel(name, cfg)) {
+ claims = append(claims, ownedClaim{name: name, listenClaim: claim})
+ }
+ }
+ for i := range claims {
+ for j := 0; j < i; j++ {
+ if claimsOverlap(claims[i].listenClaim, claims[j].listenClaim) {
+ return fmt.Errorf("restored instances %s and %s both claim %s %s", claims[i].name, claims[j].name, claims[i].network, claims[i].addr)
+ }
+ }
+ }
+ return nil
+}
+
+// probeRestoreClaims runs only after old Backpack services have been
+// quiesced, so a bind failure now identifies an external listener rather than
+// the instance that the candidate is about to replace.
+func probeRestoreClaims(configs map[string]*config.Config) error {
+ for name, cfg := range configs {
+ t := restoreTunnel(name, cfg)
+ if t.KernelDirect() {
+ continue // the iptables provider performs netfilter conflict analysis
+ }
+ for _, claim := range tunnelClaims(t) {
+ if claim.network == "udp" {
+ pc, err := net.ListenPacket("udp", claim.addr)
+ if err != nil {
+ return fmt.Errorf("restored instance %s cannot claim UDP %s: %w", name, claim.addr, err)
+ }
+ _ = pc.Close()
+ continue
+ }
+ ln, err := net.Listen("tcp", claim.addr)
+ if err != nil {
+ return fmt.Errorf("restored instance %s cannot claim TCP %s: %w", name, claim.addr, err)
+ }
+ _ = ln.Close()
+ }
+ }
+ return nil
+}
+
// Restore reads a backup archive produced by WriteBackup, extracts it into the
// config directory (overwriting matching files, leaving others untouched), then
// re-registers a systemd service for every tunnel it finds and starts them. It
@@ -302,7 +373,10 @@ func Restore(r io.Reader) (RestoreResult, error) {
if err != nil {
return res, err
}
- if err := validateRestoreSet(context.Background(), stage, candidates); err != nil {
+ if err := validateRestoreSet(context.Background(), stage, candidates, true); err != nil {
+ return res, err
+ }
+ if err := validateRestoreClaims(candidates); err != nil {
return res, err
}
@@ -325,6 +399,14 @@ func Restore(r io.Reader) (RestoreResult, error) {
}
}
}
+ if err := validateRestoreSet(context.Background(), stage, candidates, false); err != nil {
+ resumeOld()
+ return res, err
+ }
+ if err := probeRestoreClaims(candidates); err != nil {
+ resumeOld()
+ return res, err
+ }
rollbackDir, err := os.MkdirTemp(parent, ".backpack-rollback-")
if err != nil {
@@ -391,9 +473,13 @@ func Restore(r io.Reader) (RestoreResult, error) {
}
for _, name := range names {
service := app.ServiceName(name)
- if err := StartService(service); err != nil {
+ // A restored name may already have a running process from before the
+ // atomic directory swap. Restart is required so it actually reads the
+ // restored config and cannot overwrite restored cumulative metrics with
+ // its old in-memory snapshot. systemd restart also starts an inactive unit.
+ if err := RestartService(service); err != nil {
res.Failed++
- return res, rollback(fmt.Errorf("start restored instance %s: %w", name, err))
+ return res, rollback(fmt.Errorf("restart restored instance %s: %w", name, err))
}
if !WaitServiceActive(service, 12*time.Second) {
res.Failed++
diff --git a/internal/manage/backup_restore_claims_test.go b/internal/manage/backup_restore_claims_test.go
new file mode 100644
index 0000000..1df945b
--- /dev/null
+++ b/internal/manage/backup_restore_claims_test.go
@@ -0,0 +1,42 @@
+package manage
+
+import (
+ "net"
+ "testing"
+
+ "github.com/backpack/backpack/config"
+)
+
+func TestRestoreSetRejectsCandidateSocketConflict(t *testing.T) {
+ configs := map[string]*config.Config{
+ "one": {Server: config.ServerConfig{BindAddr: "0.0.0.0:24443", Transport: config.TCP}},
+ "two": {Server: config.ServerConfig{BindAddr: "127.0.0.1:24443", Transport: config.TCP}},
+ }
+ if err := validateRestoreClaims(configs); err == nil {
+ t.Fatal("overlapping candidate listeners must be rejected before restore")
+ }
+}
+
+func TestRestoreSetKeepsExplicitFamiliesIndependent(t *testing.T) {
+ configs := map[string]*config.Config{
+ "v4": {Server: config.ServerConfig{BindAddr: "0.0.0.0:24443", Transport: config.TCP}},
+ "v6": {Server: config.ServerConfig{BindAddr: "[::]:24443", Transport: config.TCP}},
+ }
+ if err := validateRestoreClaims(configs); err != nil {
+ t.Fatalf("explicit IPv4 and IPv6 candidates should be independent: %v", err)
+ }
+}
+
+func TestRestoreProbeRejectsExternalListener(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ configs := map[string]*config.Config{
+ "candidate": {Server: config.ServerConfig{BindAddr: listener.Addr().String(), Transport: config.TCP}},
+ }
+ if err := probeRestoreClaims(configs); err == nil {
+ t.Fatal("an external listener must block restore activation")
+ }
+}
diff --git a/internal/manage/benchmarkmenu.go b/internal/manage/benchmarkmenu.go
index e403fb5..e9025a3 100644
--- a/internal/manage/benchmarkmenu.go
+++ b/internal/manage/benchmarkmenu.go
@@ -19,9 +19,9 @@ func LinkTest() {
clients := clientTunnels()
if len(clients) == 0 {
- tui.Info("No client tunnels found on this server.")
- tui.Warn("Run this on the abroad (kharej) side — it is the side that dials")
- tui.Warn("out, so it is the side that can measure the link.")
+ tui.Info("No dialling tunnels found on this server.")
+ tui.Warn("Run this on the side that initiates the tunnel: Iran for Direct,")
+ tui.Warn("or Kharej for Reverse.")
tui.PressEnter()
return
}
diff --git a/internal/manage/config.go b/internal/manage/config.go
index 2117e90..4a0dfd2 100644
--- a/internal/manage/config.go
+++ b/internal/manage/config.go
@@ -1,11 +1,14 @@
package manage
import (
+ "context"
"fmt"
"os"
"strings"
+ "github.com/backpack/backpack/config"
"github.com/backpack/backpack/internal/app"
+ "github.com/backpack/backpack/internal/engine"
)
// TunnelSpec is the full description of a tunnel used to render a TOML config.
@@ -13,6 +16,10 @@ type TunnelSpec struct {
Name string
Role string // "server" (Iran/edge that exposes ports) or "client" (kharej/origin)
Transport string // tcp, tcpmux, udp, kcp, ws, wss, wsmux, wssmux
+ // Engine is empty for legacy/reverse output. Forward retains the geographic
+ // UI role above while swapping the operational TOML section: Iran is the
+ // dialling [client], Kharej is the listening [server].
+ Engine config.EngineType
// Preset is the performance profile every tuning field was filled from:
// balance, turbo or aggressive. Empty means the values were set by hand or
@@ -125,6 +132,48 @@ type TunnelSpec struct {
ZeroCopy bool
}
+func (s TunnelSpec) operationalServer() bool {
+ server := s.Role == "server"
+ if s.Engine == config.EngineForward {
+ return !server
+ }
+ return server
+}
+
+// Validate renders and decodes the exact candidate that would be installed,
+// then runs the selected engine's side-effect-free validation.
+func (s TunnelSpec) Validate() error {
+ if err := validateForwardConflicts(s); err != nil {
+ return err
+ }
+ if err := os.MkdirAll(app.ConfigDir, 0755); err != nil {
+ return err
+ }
+ f, err := os.CreateTemp(app.ConfigDir, ".candidate-*")
+ if err != nil {
+ return err
+ }
+ path := f.Name()
+ defer os.Remove(path)
+ if _, err = f.WriteString(s.Render()); err != nil {
+ f.Close()
+ return err
+ }
+ if err = f.Close(); err != nil {
+ return err
+ }
+ cfg, err := config.LoadFile(path)
+ if err != nil {
+ return err
+ }
+ provider, err := engine.Resolve(cfg)
+ if err != nil {
+ return err
+ }
+ _, statErr := os.Stat(app.ConfigPath(s.Name))
+ return provider.Validate(context.Background(), engine.Request{ConfigPath: app.ConfigPath(s.Name), Config: cfg, Replacing: statErr == nil})
+}
+
// writeTuning emits the throughput/latency knobs shared by server and client.
func (s TunnelSpec) writeTuning(p func(string, ...any)) {
if s.MSS > 0 {
@@ -221,13 +270,15 @@ func isDatagram(t string) bool {
return t == "udp" || t == "kcp" || t == "xdi" || t == "quic" || t == "spoof"
}
+func isRawDatagram(t string) bool { return t == "xdi" || t == "spoof" }
+
// supportsProxyProtocol reports whether a transport can prepend the PROXY
// protocol header. The plain websocket and raw UDP transports cannot: one has
// no place to put it in its framing, the other carries datagrams with no
// connection to describe.
func supportsProxyProtocol(t string) bool {
switch t {
- case "tcp", "tcpmux", "kcp", "wsmux", "wssmux", "stealth", "quic", "spoof":
+ case "tcp", "tcpmux", "kcp", "wsmux", "wssmux", "stealth", "quic", "xdi", "spoof":
return true
}
return false
@@ -260,8 +311,12 @@ func (s TunnelSpec) Render() string {
b.WriteString("# Generated by backpack — do not edit while the service is running.\n")
p("# name = \"%s\"\n\n", s.Name)
+ if s.Engine != "" && s.Engine != config.EngineReverse {
+ p("engine = %q\n\n", s.Engine)
+ }
- if s.Role == "server" {
+ serverSection := s.operationalServer()
+ if serverSection {
b.WriteString("[server]\n")
p("bind_addr = %q\n", s.BindAddr)
p("transport = %q\n", s.Transport)
@@ -325,11 +380,13 @@ func (s TunnelSpec) Render() string {
if s.WebPort > 0 {
p("web_port = %d\n", s.WebPort)
}
- b.WriteString("ports = [\n")
- for _, port := range s.Ports {
- p(" %q,\n", port)
+ if s.Engine != config.EngineForward {
+ b.WriteString("ports = [\n")
+ for _, port := range s.Ports {
+ p(" %q,\n", port)
+ }
+ b.WriteString("]\n")
}
- b.WriteString("]\n")
return b.String()
}
@@ -385,6 +442,20 @@ func (s TunnelSpec) Render() string {
if s.ZeroCopy {
p("zero_copy = true\n")
}
+ if s.Engine == config.EngineForward {
+ if s.Transport == "tcp" {
+ p("accept_udp = %t\n", s.AcceptUDP)
+ }
+ if supportsProxyProtocol(s.Transport) {
+ p("proxy_protocol = %t\n", s.ProxyProtocol)
+ }
+ if s.MaxConnections > 0 {
+ p("max_connections = %d\n", s.MaxConnections)
+ }
+ if s.BandwidthMbps > 0 {
+ p("bandwidth_mbps = %d\n", s.BandwidthMbps)
+ }
+ }
if isMux(s.Transport) {
p("mux_session = %d\n", s.MuxCon)
p("mux_version = %d\n", s.MuxVersion)
@@ -396,6 +467,13 @@ func (s TunnelSpec) Render() string {
if s.WebPort > 0 {
p("web_port = %d\n", s.WebPort)
}
+ if s.Engine == config.EngineForward {
+ b.WriteString("ports = [\n")
+ for _, port := range s.Ports {
+ p(" %q,\n", port)
+ }
+ b.WriteString("]\n")
+ }
return b.String()
}
@@ -405,17 +483,35 @@ func (s TunnelSpec) Save() (string, error) {
if err := os.MkdirAll(app.ConfigDir, 0755); err != nil {
return "", err
}
- if err := os.WriteFile(app.ConfigPath(s.Name), []byte(s.Render()), 0644); err != nil {
+ if err := s.Validate(); err != nil {
+ return "", fmt.Errorf("invalid tunnel configuration: %w", err)
+ }
+ path := app.ConfigPath(s.Name)
+ previous, previousErr := os.ReadFile(path)
+ hadPrevious := previousErr == nil
+ rollback := func() {
+ if hadPrevious {
+ _ = app.WriteFileAtomic(path, previous, 0644)
+ } else {
+ _ = os.Remove(path)
+ removeUnit(s.Name)
+ }
+ _ = DaemonReload()
+ }
+ if err := app.WriteFileAtomic(path, []byte(s.Render()), 0644); err != nil {
return "", err
}
if err := writeUnit(s.Name); err != nil {
+ rollback()
return "", err
}
if err := DaemonReload(); err != nil {
+ rollback()
return "", err
}
service := app.ServiceName(s.Name)
if err := StartService(service); err != nil {
+ rollback()
return service, err
}
return service, nil
diff --git a/internal/manage/diagnose.go b/internal/manage/diagnose.go
index 1a29b58..c6421f6 100644
--- a/internal/manage/diagnose.go
+++ b/internal/manage/diagnose.go
@@ -274,7 +274,7 @@ func tunnelChecks() []Check {
// tunnelChecksFor is one tunnel's section of the report.
func tunnelChecksFor(t Tunnel, pairs [][2]string) []Check {
- if t.Mode == "direct" {
+ if t.KernelDirect() {
return directChecksFor(t)
}
var out []Check
@@ -285,7 +285,7 @@ func tunnelChecksFor(t Tunnel, pairs [][2]string) []Check {
switch h.State {
case "online":
out = append(out, Check{Group: g, Name: "State", Level: CheckOK,
- Detail: fmt.Sprintf("online (%s %s)", t.Role, t.Transport)})
+ Detail: fmt.Sprintf("online (%s %s)", t.DisplayRole(), t.Transport)})
case "offline":
fix := "check the other side is running and reachable"
if t.Role == "client" {
diff --git a/internal/manage/direct.go b/internal/manage/direct.go
index 1a64d84..155da1e 100644
--- a/internal/manage/direct.go
+++ b/internal/manage/direct.go
@@ -151,7 +151,7 @@ func promptDirectMapping(existing *config.ForwardMapping) (config.ForwardMapping
func SetupDirect() {
tui.Clear()
- tui.Title("Setup Direct Forward")
+ tui.Title("Advanced Kernel Direct Forward")
tui.Warn("Incoming TCP/UDP is DNATed directly to one fixed target with iptables.")
tui.Warn("MASQUERADE means the target sees this ingress server as the source.")
fmt.Println()
diff --git a/internal/manage/edit.go b/internal/manage/edit.go
index ddd1b6a..10a1ae2 100644
--- a/internal/manage/edit.go
+++ b/internal/manage/edit.go
@@ -8,7 +8,6 @@ import (
"strings"
"time"
- "github.com/BurntSushi/toml"
"github.com/backpack/backpack/config"
"github.com/backpack/backpack/internal/app"
)
@@ -16,16 +15,25 @@ import (
// loadServerSpec reconstructs a server tunnel's spec from its config file so it
// can be modified and re-saved without losing settings.
func loadServerSpec(name string) (TunnelSpec, error) {
- var cfg config.Config
- if _, err := toml.DecodeFile(app.ConfigPath(name), &cfg); err != nil {
+ cfg, err := config.LoadFile(app.ConfigPath(name))
+ if err != nil {
return TunnelSpec{}, err
}
+ return serverSpecFromConfig(name, cfg)
+}
+
+func serverSpecFromConfig(name string, cfg *config.Config) (TunnelSpec, error) {
sc := cfg.Server
if sc.BindAddr == "" {
return TunnelSpec{}, fmt.Errorf("%q is not a server tunnel", name)
}
+ role := "server"
+ if cfg.EffectiveEngine() == config.EngineForward {
+ role = "client" // geographic Kharej role; [server] is operational
+ }
return TunnelSpec{
- Role: "server",
+ Role: role,
+ Engine: cfg.Engine,
Name: name,
Transport: string(sc.Transport),
BindAddr: sc.BindAddr,
@@ -82,21 +90,31 @@ func loadServerSpec(name string) (TunnelSpec, error) {
// loadClientSpec reconstructs a client tunnel's spec from its config file so it
// can be modified and re-saved without losing settings.
func loadClientSpec(name string) (TunnelSpec, error) {
- var cfg config.Config
- if _, err := toml.DecodeFile(app.ConfigPath(name), &cfg); err != nil {
+ cfg, err := config.LoadFile(app.ConfigPath(name))
+ if err != nil {
return TunnelSpec{}, err
}
+ return clientSpecFromConfig(name, cfg)
+}
+
+func clientSpecFromConfig(name string, cfg *config.Config) (TunnelSpec, error) {
cc := cfg.Client
if cc.RemoteAddr == "" {
return TunnelSpec{}, fmt.Errorf("%q is not a client tunnel", name)
}
+ role := "client"
+ if cfg.EffectiveEngine() == config.EngineForward {
+ role = "server" // geographic Iran role; [client] is operational
+ }
return TunnelSpec{
- Role: "client",
+ Role: role,
+ Engine: cfg.Engine,
Name: name,
Transport: string(cc.Transport),
RemoteAddr: cc.RemoteAddr,
FallbackAddrs: cc.FallbackAddrs,
Token: cc.Token,
+ Ports: append([]string(nil), cc.Ports...),
ConnectionPool: cc.ConnectionPool,
AggressivePool: cc.AggressivePool,
KeepAlive: cc.Keepalive,
@@ -113,6 +131,10 @@ func loadClientSpec(name string) (TunnelSpec, error) {
Interface: cc.Interface,
SOMark: cc.SOMark,
ZeroCopy: cc.ZeroCopy,
+ AcceptUDP: cc.AcceptUDP,
+ ProxyProtocol: cc.ProxyProtocol,
+ MaxConnections: cc.MaxConnections,
+ BandwidthMbps: cc.BandwidthMbps,
MuxCon: cc.MuxSession,
MuxVersion: cc.MuxVersion,
MuxFrameSize: cc.MaxFrameSize,
@@ -221,7 +243,7 @@ func EditTunnel(name, host, tunnelPort string, ports []string) error {
if tunnelPort != "" && !validPort(tunnelPort) {
return fmt.Errorf("invalid tunnel port %q", tunnelPort)
}
- if s.Role == "server" {
+ if s.operationalServer() {
if host != "" {
return fmt.Errorf("the address can only be changed on client tunnels")
}
@@ -259,6 +281,11 @@ func EditTunnel(name, host, tunnelPort string, ports []string) error {
if err := validatePortSpecs(clean); err != nil {
return err
}
+ if s.Engine == config.EngineForward {
+ if err := validateForwardPortSpecs(clean); err != nil {
+ return err
+ }
+ }
// Keep the hidden Telegram/SOCKS relay mapping the user never sees.
for _, p := range s.Ports {
if isBotRelayPort(p, s.Token) {
@@ -287,7 +314,7 @@ func SetFallbackAddrs(name string, addrs []string) error {
if err != nil {
return err
}
- if s.Role != "client" {
+ if s.operationalServer() {
return fmt.Errorf("fallback addresses apply to client tunnels only")
}
@@ -364,7 +391,7 @@ func ChangeTransport(name, transport string) error {
s.MuxStreamBuffer = 65536
}
// TLS transports need a certificate on the server side.
- if s.Role == "server" && needsTLS(transport) && (s.TLSCert == "" || !fileExists(s.TLSCert)) {
+ if s.operationalServer() && needsTLS(transport) && (s.TLSCert == "" || !fileExists(s.TLSCert)) {
cert, key, err := EnsureSelfSignedCert(s.Name, "")
if err != nil {
return fmt.Errorf("could not generate a TLS certificate: %w", err)
@@ -395,7 +422,7 @@ func SetLoadBalance(name string, on bool) error {
if err != nil {
return err
}
- if s.Role != "client" {
+ if s.operationalServer() {
return fmt.Errorf("load balancing is a client-side setting")
}
if on && len(s.FallbackAddrs) == 0 {
@@ -474,12 +501,21 @@ func applySpec(s TunnelSpec) error {
}
wasActive := IsActive(service)
- if _, err := s.Save(); err != nil {
- // Save failed — put the original file back untouched.
- _ = os.WriteFile(path, prev, 0644)
+ if err := s.Validate(); err != nil {
+ return fmt.Errorf("invalid candidate configuration: %w", err)
+ }
+ if err := app.WriteFileAtomic(path, []byte(s.Render()), 0644); err != nil {
+ return fmt.Errorf("could not atomically install candidate config: %w", err)
+ }
+ if err := writeUnit(s.Name); err != nil {
+ _ = app.WriteFileAtomic(path, prev, 0644)
return err
}
- // Save alone won't reload an already-running unit — restart explicitly.
+ if err := DaemonReload(); err != nil {
+ _ = app.WriteFileAtomic(path, prev, 0644)
+ return err
+ }
+ // Restart explicitly so an existing process cannot keep the old config.
if err := RestartService(service); err != nil {
revertSpec(path, prev, service, wasActive)
return fmt.Errorf("the tunnel failed to restart with the new settings — reverted: %w", err)
@@ -501,7 +537,7 @@ func applySpec(s TunnelSpec) error {
// revertSpec restores a previous config file and brings the service back to the
// state it was in before the edit.
func revertSpec(path string, prev []byte, service string, wasActive bool) {
- _ = os.WriteFile(path, prev, 0644)
+ _ = app.WriteFileAtomic(path, prev, 0644)
if wasActive {
_ = RestartService(service)
} else {
@@ -542,7 +578,7 @@ func SetCertificate(name, domain, email string) error {
if err != nil {
return err
}
- if s.Role != "server" {
+ if !s.operationalServer() {
return fmt.Errorf("the certificate is a server-side setting — the client does not present one")
}
if !needsTLS(s.Transport) {
diff --git a/internal/manage/forward_config_test.go b/internal/manage/forward_config_test.go
new file mode 100644
index 0000000..d72a9e4
--- /dev/null
+++ b/internal/manage/forward_config_test.go
@@ -0,0 +1,105 @@
+package manage
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/backpack/backpack/config"
+)
+
+func loadRenderedForward(t *testing.T, s TunnelSpec) *config.Config {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "forward.toml")
+ if err := os.WriteFile(path, []byte(s.Render()), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := config.LoadFile(path)
+ if err != nil {
+ t.Fatalf("rendered forward config is invalid: %v\n%s", err, s.Render())
+ }
+ return cfg
+}
+
+func TestForwardIranRoleRendersDiallingClientWithIngress(t *testing.T) {
+ s := TunnelSpec{
+ Name: "iran", Role: "server", Engine: config.EngineForward,
+ Transport: "tcp", RemoteAddr: "192.0.2.10:443", Token: "secret",
+ Ports: []string{"8443=127.0.0.1:8443"}, KeepAlive: 75,
+ }
+ cfg := loadRenderedForward(t, s)
+ if !cfg.HasClient() || cfg.HasServer() || len(cfg.Client.Ports) != 1 {
+ t.Fatalf("Iran forward role rendered with wrong operational section: %#v", cfg)
+ }
+ if strings.Contains(s.Render(), "[forward]") || !strings.Contains(s.Render(), "engine = \"forward\"") {
+ t.Fatalf("application forward was confused with iptables config:\n%s", s.Render())
+ }
+}
+
+func TestForwardKharejRoleRendersListeningServerWithoutIngress(t *testing.T) {
+ s := TunnelSpec{
+ Name: "kharej", Role: "client", Engine: config.EngineForward,
+ Transport: "tcp", BindAddr: "0.0.0.0:443", Token: "secret",
+ ChannelSize: 2048, KeepAlive: 75, Heartbeat: 40,
+ }
+ cfg := loadRenderedForward(t, s)
+ if !cfg.HasServer() || cfg.HasClient() || len(cfg.Server.Ports) != 0 {
+ t.Fatalf("Kharej forward role rendered with wrong operational section: %#v", cfg)
+ }
+}
+
+func TestForwardConfigReloadPreservesGeographicRolesAndIngress(t *testing.T) {
+ iranRendered := TunnelSpec{
+ Name: "iran", Role: "server", Engine: config.EngineForward,
+ Transport: "tcpmux", RemoteAddr: "192.0.2.10:443", Token: "secret",
+ Ports: []string{"8443=127.0.0.1:9443"}, MaxConnections: 50, BandwidthMbps: 25,
+ }.Render()
+ iranPath := filepath.Join(t.TempDir(), "iran.toml")
+ if err := os.WriteFile(iranPath, []byte(iranRendered), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ iranCfg, err := config.LoadFile(iranPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ iran, err := clientSpecFromConfig("iran", iranCfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if iran.Role != "server" || iran.Engine != config.EngineForward || len(iran.Ports) != 1 || iran.MaxConnections != 50 || iran.BandwidthMbps != 25 {
+ t.Fatalf("Iran Direct spec lost settings on reload: %#v", iran)
+ }
+ if !strings.Contains(iran.Render(), "[client]") || strings.Contains(iran.Render(), "[server]") {
+ t.Fatalf("editing Iran Direct would flip its operational role:\n%s", iran.Render())
+ }
+
+ kharejRendered := TunnelSpec{
+ Name: "kharej", Role: "client", Engine: config.EngineForward,
+ Transport: "wss", BindAddr: "0.0.0.0:443", Token: "secret",
+ TLSCert: "/tmp/cert", TLSKey: "/tmp/key",
+ }.Render()
+ kharejPath := filepath.Join(t.TempDir(), "kharej.toml")
+ if err := os.WriteFile(kharejPath, []byte(kharejRendered), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ kharejCfg, err := config.LoadFile(kharejPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ kharej, err := serverSpecFromConfig("kharej", kharejCfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if kharej.Role != "client" || !kharej.operationalServer() || kharej.TLSCert != "/tmp/cert" {
+ t.Fatalf("Kharej Direct spec lost its operational server identity: %#v", kharej)
+ }
+}
+
+func TestForwardTunnelDisplayRoleStaysGeographic(t *testing.T) {
+ iran := Tunnel{Engine: string(config.EngineForward), Role: "client"}
+ kharej := Tunnel{Engine: string(config.EngineForward), Role: "server"}
+ if iran.DisplayRole() != "server" || kharej.DisplayRole() != "client" {
+ t.Fatalf("display roles changed sides: Iran=%q Kharej=%q", iran.DisplayRole(), kharej.DisplayRole())
+ }
+}
diff --git a/internal/manage/forward_conflict.go b/internal/manage/forward_conflict.go
new file mode 100644
index 0000000..5636db9
--- /dev/null
+++ b/internal/manage/forward_conflict.go
@@ -0,0 +1,185 @@
+package manage
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "strings"
+
+ "github.com/backpack/backpack/config"
+ "github.com/backpack/backpack/internal/app"
+ "github.com/backpack/backpack/internal/forwardmap"
+)
+
+type listenClaim struct {
+ network string // tcp or udp
+ addr string
+ purpose string
+}
+
+func transportNetwork(transport string) string {
+ if isDatagram(transport) {
+ return "udp"
+ }
+ return "tcp"
+}
+
+func appForwardClaims(s TunnelSpec) ([]listenClaim, error) {
+ var claims []listenClaim
+ if s.Role == "server" { // geographic Iran edge owns user ingress
+ mappings, err := forwardmap.Expand(s.Ports)
+ if err != nil {
+ return nil, err
+ }
+ network := "tcp"
+ if s.Transport == "udp" {
+ network = "udp"
+ }
+ for _, mapping := range mappings {
+ claims = append(claims, listenClaim{network: network, addr: mapping.Listen, purpose: "Direct ingress"})
+ }
+ }
+ if s.operationalServer() && !isRawDatagram(s.Transport) {
+ claims = append(claims, listenClaim{network: transportNetwork(s.Transport), addr: s.BindAddr, purpose: "tunnel listener"})
+ }
+ return claims, nil
+}
+
+func tunnelClaims(t Tunnel) []listenClaim {
+ var claims []listenClaim
+ if t.Role == "server" && !isRawDatagram(t.Transport) && strings.TrimSpace(t.Addr) != "" {
+ claims = append(claims, listenClaim{network: transportNetwork(t.Transport), addr: t.Addr, purpose: t.Name + " tunnel listener"})
+ }
+ if len(t.Ports) > 0 {
+ if mappings, err := forwardmap.Expand(t.Ports); err == nil {
+ network := "tcp"
+ if t.Transport == "udp" {
+ network = "udp"
+ }
+ for _, mapping := range mappings {
+ claims = append(claims, listenClaim{network: network, addr: mapping.Listen, purpose: t.Name + " exposed port"})
+ }
+ }
+ }
+ for _, mapping := range t.Mappings { // advanced iptables engine
+ lr, _, err := mapping.Ranges()
+ if err != nil {
+ continue
+ }
+ for _, proto := range mapping.Protocols {
+ for p := int(lr.Start); p <= int(lr.End); p++ {
+ claims = append(claims, listenClaim{network: strings.ToLower(proto), addr: net.JoinHostPort(mapping.ListenAddress, fmt.Sprint(p)), purpose: t.Name + " iptables mapping"})
+ }
+ }
+ }
+ return claims
+}
+
+func splitClaim(addr string) (host, port string, ok bool) {
+ host, port, err := net.SplitHostPort(addr)
+ return host, port, err == nil
+}
+
+func wildcardHost(host string) bool {
+ switch strings.Trim(strings.TrimSpace(host), "[]") {
+ case "", "0.0.0.0", "::", "*":
+ return true
+ }
+ return false
+}
+
+func claimFamily(host string) int {
+ host = strings.Trim(strings.TrimSpace(host), "[]")
+ if host == "" || host == "*" {
+ return 0
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return 0
+ }
+ if ip.To4() != nil {
+ return 4
+ }
+ return 6
+}
+
+func claimsOverlap(a, b listenClaim) bool {
+ if a.network != b.network {
+ return false
+ }
+ ah, ap, aok := splitClaim(a.addr)
+ bh, bp, bok := splitClaim(b.addr)
+ if !aok || !bok || ap != bp {
+ return false
+ }
+ af, bf := claimFamily(ah), claimFamily(bh)
+ if af != 0 && bf != 0 && af != bf {
+ return false
+ }
+ return wildcardHost(ah) || wildcardHost(bh) || strings.EqualFold(ah, bh)
+}
+
+// validateForwardConflicts rejects mistakes before config replacement or
+// optimization. Existing local sockets are probed for new instances; edits
+// rely on config claims so the instance being replaced is not mistaken for a
+// second owner of its own ports.
+func validateForwardConflicts(s TunnelSpec) error {
+ if s.Engine != config.EngineForward {
+ return nil
+ }
+ claims, err := appForwardClaims(s)
+ if err != nil {
+ return err
+ }
+ for i := range claims {
+ for j := 0; j < i; j++ {
+ if claimsOverlap(claims[i], claims[j]) {
+ return fmt.Errorf("%s %s overlaps %s %s", claims[i].network, claims[i].addr, claims[j].purpose, claims[j].addr)
+ }
+ }
+ }
+ for _, other := range List() {
+ if other.Name == s.Name {
+ continue
+ }
+ for _, existing := range tunnelClaims(other) {
+ for _, candidate := range claims {
+ if claimsOverlap(candidate, existing) {
+ return fmt.Errorf("%s %s conflicts with %s (%s)", candidate.network, candidate.addr, existing.purpose, existing.addr)
+ }
+ }
+ }
+ }
+ var oldClaims []listenClaim
+ if _, err := os.Stat(app.ConfigPath(s.Name)); err == nil {
+ if current, ok := Find(s.Name); ok {
+ oldClaims = tunnelClaims(current)
+ }
+ }
+ for _, claim := range claims {
+ ownedByCurrent := false
+ for _, old := range oldClaims {
+ if claimsOverlap(claim, old) {
+ ownedByCurrent = true
+ break
+ }
+ }
+ if ownedByCurrent {
+ continue
+ }
+ if claim.network == "udp" {
+ pc, err := net.ListenPacket("udp", claim.addr)
+ if err != nil {
+ return fmt.Errorf("%s %s is already in use: %w", claim.purpose, claim.addr, err)
+ }
+ pc.Close()
+ } else {
+ ln, err := net.Listen("tcp", claim.addr)
+ if err != nil {
+ return fmt.Errorf("%s %s is already in use: %w", claim.purpose, claim.addr, err)
+ }
+ ln.Close()
+ }
+ }
+ return nil
+}
diff --git a/internal/manage/forward_conflict_test.go b/internal/manage/forward_conflict_test.go
new file mode 100644
index 0000000..4375ffc
--- /dev/null
+++ b/internal/manage/forward_conflict_test.go
@@ -0,0 +1,46 @@
+package manage
+
+import (
+ "fmt"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/backpack/backpack/config"
+)
+
+func TestForwardConflictRejectsDuplicateWildcardAndSpecific(t *testing.T) {
+ s := TunnelSpec{
+ Name: "conflict-unit", Role: "server", Engine: config.EngineForward,
+ Transport: "tcp", RemoteAddr: "192.0.2.1:443",
+ Ports: []string{"0.0.0.0:18080=18080", "127.0.0.1:18080=18081"},
+ }
+ if err := validateForwardConflicts(s); err == nil {
+ t.Fatal("wildcard and specific listeners on the same port must overlap")
+ }
+}
+
+func TestForwardConflictRejectsExistingLocalListener(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ln.Close()
+ port := ln.Addr().(*net.TCPAddr).Port
+ s := TunnelSpec{
+ Name: fmt.Sprintf("conflict-%d", time.Now().UnixNano()),
+ Role: "server", Engine: config.EngineForward, Transport: "tcp",
+ RemoteAddr: "192.0.2.1:443", Ports: []string{fmt.Sprintf("127.0.0.1:%d=18081", port)},
+ }
+ if err := validateForwardConflicts(s); err == nil {
+ t.Fatal("an unrelated local listener must be rejected before config write")
+ }
+}
+
+func TestForwardConflictTreatsExplicitFamiliesIndependently(t *testing.T) {
+ a := listenClaim{network: "tcp", addr: "0.0.0.0:443"}
+ b := listenClaim{network: "tcp", addr: "[::]:443"}
+ if claimsOverlap(a, b) {
+ t.Fatal("explicit IPv4 and IPv6 listeners must be checked independently")
+ }
+}
diff --git a/internal/manage/health.go b/internal/manage/health.go
index 11779ba..caf7a66 100644
--- a/internal/manage/health.go
+++ b/internal/manage/health.go
@@ -105,6 +105,13 @@ func tunnelHealthWith(t Tunnel, pairs [][2]string) Health {
h.Connected = connected
}
}
+ if t.AppForward() && isRawDatagram(t.Transport) && t.Role == "client" {
+ if connected, known := datagramServerPeer(app.ConfigDir, t.Name); known {
+ h.Connected = connected
+ } else {
+ h.Connected = false
+ }
+ }
if h.Connected {
h.State = "online"
h.Detail = "peer connected"
diff --git a/internal/manage/menu.go b/internal/manage/menu.go
index 879ea78..bf54bb2 100644
--- a/internal/manage/menu.go
+++ b/internal/manage/menu.go
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
+ "github.com/backpack/backpack/config"
"github.com/backpack/backpack/internal/app"
"github.com/backpack/backpack/internal/tui"
)
@@ -29,9 +30,11 @@ func ManageTunnels() {
tui.Clear()
opts := make([]tui.Option, len(tunnels))
for i, t := range tunnels {
- desc := fmt.Sprintf("%s %s — %s", t.Role, t.Transport, plainState(t.Service))
- if t.Mode == "direct" {
+ desc := fmt.Sprintf("%s %s — %s", t.DisplayRole(), t.Transport, plainState(t.Service))
+ if t.KernelDirect() {
desc = fmt.Sprintf("direct %s — %d mapping(s) — %s", t.Engine, len(t.Mappings), plainState(t.Service))
+ } else if t.AppForward() {
+ desc = fmt.Sprintf("direct %s over %s — %s", t.DisplayRole(), t.Transport, plainState(t.Service))
}
opts[i] = tui.Option{
Title: t.Name,
@@ -60,14 +63,16 @@ func manageOne(t Tunnel) {
for {
tui.Clear()
tui.Title(fmt.Sprintf("Instance: %s", t.Name))
- label := strings.TrimSpace(t.Role + " " + t.Transport)
- if t.Mode == "direct" {
+ label := strings.TrimSpace(t.DisplayRole() + " " + t.Transport)
+ if t.KernelDirect() {
label = "direct " + t.Engine
+ } else if t.AppForward() {
+ label = "direct " + label
}
fmt.Printf(" %s%s%s %s\n\n", tui.Gray, label, tui.Reset, stateLabel(t.Service))
editDesc := "change tunnel port & forwarded ports"
- if t.Mode == "direct" {
+ if t.KernelDirect() {
editDesc = "add, edit, or remove direct mappings"
}
idx := tui.ChooseOpt("Choose an action:", []tui.Option{
@@ -80,7 +85,7 @@ func manageOne(t Tunnel) {
})
switch idx {
case 0:
- if t.Mode == "direct" {
+ if t.KernelDirect() {
editDirectMenu(t.Name)
} else {
editPortsMenu(t.Name)
@@ -134,6 +139,12 @@ func editPortsMenu(name string) {
tui.Clear()
tui.Title("Edit — " + name)
fmt.Println()
+ if spec.Engine == config.EngineForward {
+ if editForwardSpec(name, spec) {
+ continue
+ }
+ return
+ }
if spec.Role == "server" {
tui.Info("Tunnel (control) port : " + addrPort(spec.BindAddr))
@@ -218,10 +229,95 @@ func editPortsMenu(name string) {
}
}
+// editForwardSpec renders Direct using the geographic roles shown by setup,
+// while applying settings to the operationally reversed TOML sections.
+// It returns true after an action so the caller redraws fresh values.
+func editForwardSpec(name string, spec TunnelSpec) bool {
+ if spec.Role == "server" { // Iran edge: operational [client]
+ tui.Info("Kharej address : " + spec.RemoteAddr)
+ tui.Info("Exposed ports : " + strings.Join(VisiblePorts(spec.Ports, spec.Token), ", "))
+ tui.Info("Transport : " + transportLabel(spec.Transport))
+ tui.Info("Performance preset : " + presetLabel(spec.Preset))
+ tui.Info("Limits : " + limitsSummary(spec))
+ fmt.Println()
+ opts := []tui.Option{
+ {Title: "Change tunnel port", Desc: "the port Iran dials on Kharej"},
+ {Title: "Change Kharej address", Desc: "IP or domain of the Direct origin"},
+ {Title: "Change exposed ports", Desc: "public Iran listen ports and Kharej targets"},
+ {Title: "Change transport", Desc: "switch carrier; change the other side too"},
+ {Title: "Change performance preset", Desc: "Balance, Turbo or Aggressive"},
+ {Title: "Limits", Desc: "cap ingress connections and bandwidth"},
+ {Title: "Backup Kharej addresses", Desc: "fail over if the primary path is blocked"},
+ {Title: "Load balancing", Desc: "spread over all Kharej addresses"},
+ }
+ if supportsProxyProtocol(spec.Transport) {
+ opts = append(opts, tui.Option{Title: "Real client IP", Desc: "send PROXY protocol to the Kharej backend"})
+ }
+ switch tui.ChooseOpt("Choose:", opts) {
+ case 0:
+ changeTunnelPort(name, spec)
+ case 1:
+ changeClientHost(name, spec)
+ case 2:
+ changeForwardedPorts(name, spec)
+ case 3:
+ changeTunnelTransport(name, spec)
+ case 4:
+ changeTunnelPreset(name, spec)
+ case 5:
+ editLimits(name, spec)
+ case 6:
+ changeFallbackAddrs(name, spec)
+ case 7:
+ toggleLoadBalance(name, spec)
+ case 8:
+ if supportsProxyProtocol(spec.Transport) {
+ toggleProxyProtocol(name, spec)
+ }
+ default:
+ return false
+ }
+ return true
+ }
+
+ // Kharej origin: operational [server]. It owns the TLS certificate and
+ // tunnel listener, but never exposes the public user ports.
+ tui.Info("Tunnel listen address : " + spec.BindAddr)
+ tui.Info("Transport : " + transportLabel(spec.Transport))
+ tui.Info("Performance preset : " + presetLabel(spec.Preset))
+ if needsTLS(spec.Transport) {
+ tui.Info("Certificate : " + certSummary(spec))
+ }
+ fmt.Println()
+ opts := []tui.Option{
+ {Title: "Change tunnel port", Desc: "the Direct port Iran dials"},
+ {Title: "Change transport", Desc: "switch carrier; change the other side too"},
+ {Title: "Change performance preset", Desc: "Balance, Turbo or Aggressive"},
+ }
+ if needsTLS(spec.Transport) {
+ opts = append(opts, tui.Option{Title: "Certificate", Desc: "self-signed or Let's Encrypt"})
+ }
+ switch tui.ChooseOpt("Choose:", opts) {
+ case 0:
+ changeTunnelPort(name, spec)
+ case 1:
+ changeTunnelTransport(name, spec)
+ case 2:
+ changeTunnelPreset(name, spec)
+ case 3:
+ if needsTLS(spec.Transport) {
+ editCertificate(name, spec)
+ }
+ default:
+ return false
+ }
+ return true
+}
+
// changeTunnelPort prompts for and applies a new tunnel (control) port.
func changeTunnelPort(name string, spec TunnelSpec) {
cur := addrPort(spec.BindAddr)
- if spec.Role == "client" {
+ if !spec.operationalServer() {
cur = addrPort(spec.RemoteAddr)
}
fmt.Println()
@@ -236,7 +332,7 @@ func changeTunnelPort(name string, spec TunnelSpec) {
}
// Check the protocol the transport actually binds: a UDP-based tunnel is
// unaffected by whatever holds the same TCP port, and vice versa.
- if spec.Role == "server" && TunnelPortInUse(spec.Transport, port) {
+ if spec.operationalServer() && TunnelPortInUse(spec.Transport, port) {
tui.Error(fmt.Sprintf("Port %s is already in use on this machine.", port))
tui.PressEnter()
return
@@ -247,7 +343,7 @@ func changeTunnelPort(name string, spec TunnelSpec) {
return
}
tui.Success(fmt.Sprintf("Tunnel port changed to %s and the tunnel was restarted.", port))
- if spec.Role == "server" {
+ if spec.operationalServer() {
tui.Warn("Update the CLIENT side to the same port, or it will not reconnect.")
}
tui.PressEnter()
@@ -299,7 +395,7 @@ func changeTunnelTransport(name string, spec TunnelSpec) {
tui.PressEnter()
return
}
- if spec.Role == "server" && needsTLS(newTransport) {
+ if spec.operationalServer() && needsTLS(newTransport) {
tui.Info("A self-signed TLS certificate will be generated automatically if needed.")
}
if !tui.Confirm(fmt.Sprintf("Switch %q to %s now", name, transportLabel(newTransport)), true) {
diff --git a/internal/manage/setup.go b/internal/manage/setup.go
index 5bcdb15..ba8455e 100644
--- a/internal/manage/setup.go
+++ b/internal/manage/setup.go
@@ -5,6 +5,7 @@ import (
"net"
"strings"
+ "github.com/backpack/backpack/config"
"github.com/backpack/backpack/internal/app"
"github.com/backpack/backpack/internal/optimize"
"github.com/backpack/backpack/internal/tui"
@@ -84,6 +85,38 @@ func chooseTransport() string {
}
}
+// chooseConnectionMode is deliberately shown after the concrete transport
+// choice. Both modes use that exact transport; only the side that initiates
+// the tunnel changes.
+func chooseConnectionMode(transport string) string {
+ idx := tui.ChooseOpt("Connection mode:", []tui.Option{
+ {
+ Title: "Direct",
+ Desc: "Iran initiates the selected " + strings.ToUpper(transport) + " tunnel toward Kharej",
+ },
+ {
+ Title: "Reverse",
+ Desc: "Kharej initiates the selected " + strings.ToUpper(transport) + " tunnel toward Iran (legacy)",
+ },
+ })
+ switch idx {
+ case 0:
+ return "direct"
+ case 1:
+ return "reverse"
+ default:
+ return ""
+ }
+}
+
+func forwardTransportReady(transport string) bool {
+ switch transport {
+ case "tcp", "stealth", "tcpmux", "kcp", "xdi", "spoof", "quic", "ws", "wss", "wsmux", "wssmux", "udp":
+ return true
+ }
+ return false
+}
+
// choosePreset asks for the performance profile. Turbo is preselected because
// it reproduces exactly what earlier versions called "Best Performance".
func choosePreset() string {
@@ -113,7 +146,7 @@ func applyManualTuning(s *TunnelSpec) {
} else {
s.LogFormat = ""
}
- if s.Role == "server" {
+ if s.operationalServer() {
s.ChannelSize = tui.PromptInt("Channel size", s.ChannelSize)
if s.Transport == "tcp" {
s.AcceptUDP = tui.Confirm("Accept UDP traffic over the TCP transport (accept_udp)", s.AcceptUDP)
@@ -319,7 +352,7 @@ func askSpoof(s *TunnelSpec) {
// The server cannot learn the client's real address from the forged packets,
// so it must be told it. The client already knows the server's real address
// from the tunnel address it dialled.
- if s.Role == "server" {
+ if s.operationalServer() {
tui.Info("The client forges its source, so the server cannot see where to send")
tui.Info("replies. Enter the client's REAL public IPv4 address.")
for {
@@ -431,15 +464,30 @@ func uniqueName(name string) string {
func SetupServer() {
tui.Clear()
tui.Title("Setup Server")
- tui.Warn("Iran side — reverse tunnel that exposes ports on this machine.")
+ tui.Warn("Iran side — exposes ports with either direct forwarding or a reverse tunnel.")
fmt.Println()
transport := chooseTransport()
if transport == "" {
return
}
-
+ mode := chooseConnectionMode(transport)
+ if mode == "" {
+ return
+ }
s := TunnelSpec{Role: "server", Transport: transport}
+ if mode == "direct" {
+ s.Engine = config.EngineForward
+ // Forward adapters are enabled only after their carrier-specific E2E
+ // test exists. This avoids writing a service that can start but cannot
+ // carry the user's traffic while the remaining adapters are developed.
+ if !forwardTransportReady(transport) {
+ tui.Warn("Direct direction for " + strings.ToUpper(transport) + " is still being integrated and is not enabled in this build.")
+ tui.Warn("No configuration was written. Reverse remains available and unchanged.")
+ tui.PressEnter()
+ return
+ }
+ }
port := tui.Prompt("Tunnel (control) port: ")
if !validPort(port) {
@@ -447,13 +495,23 @@ func SetupServer() {
tui.PressEnter()
return
}
- // Binding the IPv6 wildcard accepts IPv4 as well on a normal dual-stack
- // host, so this is "IPv6 too" rather than "IPv6 instead".
- bind := "0.0.0.0"
- if tui.Confirm("Listen on IPv6 as well", false) {
- bind = "::"
+ if mode == "direct" {
+ remoteHost := strings.TrimSpace(tui.Prompt("Kharej server address (IP or domain): "))
+ if remoteHost == "" {
+ tui.Error("Kharej server address is required.")
+ tui.PressEnter()
+ return
+ }
+ s.RemoteAddr = net.JoinHostPort(strings.Trim(remoteHost, "[]"), port)
+ } else {
+ // Binding the IPv6 wildcard accepts IPv4 as well on a normal dual-stack
+ // host, so this is "IPv6 too" rather than "IPv6 instead".
+ bind := "0.0.0.0"
+ if tui.Confirm("Listen on IPv6 as well", false) {
+ bind = "::"
+ }
+ s.BindAddr = net.JoinHostPort(bind, port)
}
- s.BindAddr = net.JoinHostPort(bind, port)
defaultName := "server-" + port
s.Name = uniqueName(tui.PromptDefault("Tunnel name", defaultName))
@@ -487,9 +545,16 @@ func SetupServer() {
tui.PressEnter()
return
}
+ if mode == "direct" {
+ if err := validateForwardPortSpecs(s.Ports); err != nil {
+ tui.Error(err.Error())
+ tui.PressEnter()
+ return
+ }
+ }
showForwardTargets(s.Ports)
- if needsTLS(transport) && !setupServerTLS(&s) {
+ if mode == "reverse" && needsTLS(transport) && !setupServerTLS(&s) {
return
}
askSimpleAuth(&s, transport)
@@ -510,13 +575,21 @@ func SetupServer() {
func SetupClient() {
tui.Clear()
tui.Title("Setup Client")
- tui.Warn("Kharej side — reverse tunnel that dials out to the Iran server.")
+ tui.Warn("Kharej side — listens for Direct, or dials Iran for Reverse.")
fmt.Println()
transport := chooseTransport()
if transport == "" {
return
}
+ mode := chooseConnectionMode(transport)
+ if mode == "" {
+ return
+ }
+ if mode == "direct" {
+ setupForwardOrigin(transport)
+ return
+ }
s := TunnelSpec{Role: "client", Transport: transport}
@@ -636,9 +709,55 @@ func SetupClient() {
finishSetup(s)
}
+// setupForwardOrigin configures the Kharej listener for Direct mode. The
+// geographic name remains "Client" in the UI, but engine=forward makes its
+// operational TOML role [server]: it accepts the selected transport and dials
+// the local backend named by each Iran-side ingress connection.
+func setupForwardOrigin(transport string) {
+ if !forwardTransportReady(transport) {
+ tui.Warn("Direct direction for " + strings.ToUpper(transport) + " is still being integrated and is not enabled in this build.")
+ tui.Warn("No configuration was written. Reverse remains available and unchanged.")
+ tui.PressEnter()
+ return
+ }
+
+ s := TunnelSpec{Role: "client", Transport: transport, Engine: config.EngineForward}
+ port := tui.Prompt("Tunnel (control) port to listen on: ")
+ if !validPort(port) {
+ tui.Error("Invalid port.")
+ tui.PressEnter()
+ return
+ }
+ bind := "0.0.0.0"
+ if tui.Confirm("Listen on IPv6 as well", false) {
+ bind = "::"
+ }
+ s.BindAddr = net.JoinHostPort(bind, port)
+ s.Name = uniqueName(tui.PromptDefault("Tunnel name", "client-"+port))
+ tui.Info("Enter the SAME token configured on the Iran server.")
+ s.Token = tui.PromptDefault("Security token", "backpack")
+
+ if needsTLS(transport) && !setupServerTLS(&s) {
+ return
+ }
+ askSimpleAuth(&s, transport)
+ askSpoof(&s)
+ ApplyPreset(&s, choosePreset())
+ if tui.Confirm("Fine-tune the advanced settings by hand", false) {
+ applyManualTuning(&s)
+ }
+ finishSetup(s)
+}
+
// finishSetup persists the tunnel, applies system-level tuning, and reports
// the result.
func finishSetup(s TunnelSpec) {
+ if err := s.Validate(); err != nil {
+ tui.Error("Configuration is invalid: " + err.Error())
+ tui.Warn("Nothing was installed or changed on the system.")
+ tui.PressEnter()
+ return
+ }
tui.Info("Applying system network optimizations...")
optimize.ApplyQuiet()
diff --git a/internal/manage/setup_flow_test.go b/internal/manage/setup_flow_test.go
new file mode 100644
index 0000000..a357116
--- /dev/null
+++ b/internal/manage/setup_flow_test.go
@@ -0,0 +1,64 @@
+package manage
+
+import (
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+const setupFlowHelperEnv = "BACKPACK_SETUP_FLOW_HELPER"
+
+// The helper runs in a child process so tui's package-level stdin reader is
+// attached to the pipe from process start, exactly like a real terminal run.
+func TestSetupFlowHelper(t *testing.T) {
+ if os.Getenv(setupFlowHelperEnv) != "1" {
+ return
+ }
+ SetupServer()
+}
+
+func runSetupFlow(t *testing.T, input string) string {
+ t.Helper()
+ cmd := exec.Command(os.Args[0], "-test.run=^TestSetupFlowHelper$")
+ cmd.Env = append(os.Environ(), setupFlowHelperEnv+"=1")
+ cmd.Stdin = strings.NewReader(input)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("setup helper failed: %v\n%s", err, out)
+ }
+ return string(out)
+}
+
+func TestEveryConcreteTransportShowsConnectionMode(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ family, child int
+ }{
+ {"tcp", 1, 1}, {"tcpmux", 1, 2}, {"stealth", 1, 3},
+ {"udp", 2, 1}, {"kcp", 2, 2}, {"quic", 2, 3},
+ {"ws", 3, 1}, {"wsmux", 3, 2}, {"wss", 3, 3}, {"wssmux", 3, 4},
+ {"xdi", 4, 1}, {"spoof", 4, 2},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ out := runSetupFlow(t, strings.Join([]string{
+ string(rune('0' + tc.family)), string(rune('0' + tc.child)), "0", "",
+ }, "\n"))
+ if !strings.Contains(out, "Connection mode:") {
+ t.Fatalf("mode prompt missing after %s selection:\n%s", tc.name, out)
+ }
+ })
+ }
+}
+
+func TestDirectModePromptPrecedesPortQuestions(t *testing.T) {
+ out := runSetupFlow(t, "1\n1\n1\nnot-a-port\n\n")
+ modeAt := strings.Index(out, "Connection mode:")
+ portAt := strings.Index(out, "Tunnel (control) port:")
+ if modeAt < 0 || portAt < 0 || modeAt >= portAt {
+ t.Fatalf("Direct/Reverse must be asked after transport and before ports:\n%s", out)
+ }
+ if strings.Contains(out, "Kharej server address") {
+ t.Fatal("an invalid tunnel port must stop the novice flow before later questions")
+ }
+}
diff --git a/internal/manage/status.go b/internal/manage/status.go
index 5a5f937..802363e 100644
--- a/internal/manage/status.go
+++ b/internal/manage/status.go
@@ -67,14 +67,14 @@ func printStatusTable(tunnels []Tunnel) {
state := colorPad(tui.Color(color, plainState), plainState, 8)
detail := t.Addr
- if t.Mode == "direct" {
+ if t.KernelDirect() {
var maps []string
for _, m := range t.Mappings {
maps = append(maps, fmt.Sprintf("%s:%s->%s:%s", m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts))
}
detail = strings.Join(maps, ",")
}
- if t.Role == "server" && len(t.Ports) > 0 {
+ if (t.Role == "server" || (t.AppForward() && t.Role == "client")) && len(t.Ports) > 0 {
detail = strings.Join(t.Ports, ",")
}
fmt.Printf("%-16s %-8s %-10s %s %s\n",
diff --git a/internal/manage/tunnel.go b/internal/manage/tunnel.go
index 9f2009f..4f5d394 100644
--- a/internal/manage/tunnel.go
+++ b/internal/manage/tunnel.go
@@ -27,6 +27,30 @@ type Tunnel struct {
Service string
}
+// KernelDirect reports the standalone netfilter engine. Application-forward
+// instances also have mode=direct, but still have a role, transport and peer;
+// treating every direct mode as iptables breaks their management/UI paths.
+func (t Tunnel) KernelDirect() bool { return t.Engine == string(config.EngineIPTables) }
+
+// AppForward reports the application tunnel whose dial direction is reversed.
+func (t Tunnel) AppForward() bool { return t.Engine == string(config.EngineForward) }
+
+// DisplayRole preserves the geographic Server/Client language used by setup:
+// Iran is Server and Kharej is Client. EngineForward deliberately swaps the
+// operational TOML sections, but exposing that implementation detail in the
+// panel and management menu makes the same machine appear to change roles.
+func (t Tunnel) DisplayRole() string {
+ if t.AppForward() {
+ switch t.Role {
+ case "client":
+ return "server"
+ case "server":
+ return "client"
+ }
+ }
+ return t.Role
+}
+
// List scans the config directory and returns all tunnels, sorted by name.
func List() []Tunnel {
var tunnels []Tunnel
@@ -54,6 +78,9 @@ func List() []Tunnel {
t.Role = "client"
t.Transport = string(cfg.Client.Transport)
t.Addr = cfg.Client.RemoteAddr
+ if cfg.EffectiveEngine() == config.EngineForward {
+ t.Ports = append([]string(nil), cfg.Client.Ports...)
+ }
default:
continue
}
diff --git a/internal/manage/validate.go b/internal/manage/validate.go
index 9e030a0..e637714 100644
--- a/internal/manage/validate.go
+++ b/internal/manage/validate.go
@@ -6,6 +6,8 @@ import (
"regexp"
"strconv"
"strings"
+
+ "github.com/backpack/backpack/internal/forwardmap"
)
// validPort reports whether s is a valid TCP/UDP port number.
@@ -85,3 +87,10 @@ func validatePortSpecs(ports []string) error {
}
return nil
}
+
+func validateForwardPortSpecs(ports []string) error {
+ if _, err := forwardmap.Expand(ports); err != nil {
+ return fmt.Errorf("invalid Direct mapping: %w", err)
+ }
+ return nil
+}
diff --git a/internal/manage/watchdog.go b/internal/manage/watchdog.go
index 674bcc5..10d1c1b 100644
--- a/internal/manage/watchdog.go
+++ b/internal/manage/watchdog.go
@@ -111,6 +111,12 @@ func directDesiredStateHealthy(ctx context.Context, t Tunnel) bool {
// tunnelHealthy reports whether a running tunnel currently has its connection up,
// based on the established TCP sockets in `pairs` ([local, peer] address pairs).
func tunnelHealthy(t Tunnel, pairs [][2]string) bool {
+ if t.AppForward() && isRawDatagram(t.Transport) && t.Role == "client" {
+ if connected, known := datagramServerPeer(app.ConfigDir, t.Name); known {
+ return connected
+ }
+ return true // not observable yet: avoid a startup restart loop
+ }
// UDP-based transports (udp, kcp) hold no TCP sockets at all, so the TCP
// table says nothing about them.
//
diff --git a/internal/menu/menu.go b/internal/menu/menu.go
index 037f523..8ae156b 100644
--- a/internal/menu/menu.go
+++ b/internal/menu/menu.go
@@ -65,22 +65,20 @@ func Run() {
case "2":
manage.SetupClient()
case "3":
- manage.SetupDirect()
- case "4":
manageMenu()
- case "5":
+ case "4":
backupMenu()
- case "6":
+ case "5":
webPanelMenu()
- case "7":
+ case "6":
optimizeMenu()
- case "8":
+ case "7":
telegramMenu()
- case "9":
+ case "8":
updateMenu()
- case "10":
+ case "9":
uninstallMenu()
- case "11", "0":
+ case "10", "11", "0":
tui.Info("Goodbye!")
return
default:
@@ -98,28 +96,27 @@ func printUpdateBanner() {
if !ok {
return
}
- fmt.Printf(" %s⬆ %s is available%s %s— option 9 to update safely%s\n",
+ fmt.Printf(" %s⬆ %s is available%s %s— option 8 to update safely%s\n",
tui.Bold+tui.Red, tag, tui.Reset, tui.Gray, tui.Reset)
}
// printMenu renders the main menu: red numbers, white titles, gray descriptions.
func printMenu() {
fmt.Println()
- menuItem(1, "Setup Server", "Iran side — exposes ports to users")
- menuItem(2, "Setup Client", "Kharej side — dials out to the Iran server")
- menuItem(3, "Setup Direct", "iptables direct forward — IPv4/IPv6, TCP/UDP")
- menuItem(4, "Manage", "instances, ports, transport, status, health check")
- menuItem(5, "Backup & Restore", "save or restore the full configuration")
- menuItem(6, "Web Panel", "monitoring web UI — link, login code, port")
- menuItem(7, "Optimize", "kernel & network tuning — BBR, buffers, limits")
- menuItem(8, "Telegram Bot", "status reports, relayed through a tunnel")
+ menuItem(1, "Setup Server", "Iran side — exposes ports; Direct dials Kharej")
+ menuItem(2, "Setup Client", "Kharej side — Direct accepts, Reverse dials Iran")
+ menuItem(3, "Manage", "instances, ports, transport, status, health check")
+ menuItem(4, "Backup & Restore", "save or restore the full configuration")
+ menuItem(5, "Web Panel", "monitoring web UI — link, login code, port")
+ menuItem(6, "Optimize", "kernel & network tuning — BBR, buffers, limits")
+ menuItem(7, "Telegram Bot", "status reports, relayed through a tunnel")
updateDesc := "safe update with automatic rollback"
if tag, ok := manage.UpdateAvailable(); ok {
updateDesc = tag + " is out — safe update with automatic rollback"
}
- menuItem(9, "Update", updateDesc)
- menuItem(10, "Uninstall", "remove everything")
- menuItem(11, "Exit", "")
+ menuItem(8, "Update", updateDesc)
+ menuItem(9, "Uninstall", "remove everything")
+ menuItem(10, "Exit", "")
fmt.Println()
}
@@ -1002,7 +999,7 @@ func restorePointMenu() {
tui.PressEnter()
}
-// uninstallMenu is main-menu item 10.
+// uninstallMenu is main-menu item 9.
func uninstallMenu() {
tui.Clear()
tui.Title("Uninstall Backpack")
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index 7e6bb6b..8bfb0fc 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -133,7 +133,10 @@ func Path(dir, name string) string {
type Collector struct {
// baseIn/baseOut are the totals this tunnel had already accumulated before
// this process started, read from the last written snapshot.
- baseIn, baseOut uint64
+ baseIn, baseOut uint64
+ basePacketsIn, basePacketsOut uint64
+ startIn, startOut uint64
+ startPacketsIn, startPacketsOut uint64
dir string
name string
@@ -159,6 +162,8 @@ func NewCollector(dir, name, transport, role string, bytesIn, bytesOut func() ui
bytesIn: bytesIn,
bytesOut: bytesOut,
}
+ c.startIn, c.startOut = Traffic()
+ c.startPacketsIn, c.startPacketsOut = TrafficPackets()
// Carry on from whatever this tunnel had already moved.
//
// The live counters only know about this process, so without a baseline the
@@ -172,6 +177,7 @@ func NewCollector(dir, name, transport, role string, bytesIn, bytesOut func() ui
// again on the new machine.
if prev, err := Read(dir, name); err == nil {
c.baseIn, c.baseOut = prev.BytesIn, prev.BytesOut
+ c.basePacketsIn, c.basePacketsOut = prev.PacketsIn, prev.PacketsOut
}
return c
}
@@ -188,7 +194,10 @@ func (c *Collector) Snapshot() Snapshot {
}
// The persisted baseline plus what this process has carried.
liveIn, liveOut := Traffic()
- s.BytesIn, s.BytesOut = c.baseIn+liveIn, c.baseOut+liveOut
+ s.BytesIn, s.BytesOut = c.baseIn+(liveIn-c.startIn), c.baseOut+(liveOut-c.startOut)
+ livePacketsIn, livePacketsOut := TrafficPackets()
+ s.PacketsIn = c.basePacketsIn + (livePacketsIn - c.startPacketsIn)
+ s.PacketsOut = c.basePacketsOut + (livePacketsOut - c.startPacketsOut)
if c.bytesIn != nil {
s.BytesIn = c.baseIn + c.bytesIn()
}
@@ -276,8 +285,10 @@ func Read(dir, name string) (Snapshot, error) {
// whichever side of the tunnel this process is. One tunnel runs per process, so
// package-level totals describe exactly this tunnel.
var (
- bytesIn atomic.Uint64
- bytesOut atomic.Uint64
+ bytesIn atomic.Uint64
+ bytesOut atomic.Uint64
+ packetsIn atomic.Uint64
+ packetsOut atomic.Uint64
)
// CountedConn wraps a tunnel connection so its traffic is recorded.
@@ -293,6 +304,7 @@ func (c *countedConn) Read(b []byte) (int, error) {
n, err := c.Conn.Read(b)
if n > 0 {
bytesIn.Add(uint64(n))
+ packetsIn.Add(1)
}
return n, err
}
@@ -301,6 +313,7 @@ func (c *countedConn) Write(b []byte) (int, error) {
n, err := c.Conn.Write(b)
if n > 0 {
bytesOut.Add(uint64(n))
+ packetsOut.Add(1)
}
return n, err
}
@@ -326,11 +339,18 @@ func Uncount(c net.Conn) (net.Conn, bool) {
func AddBytes(in, out uint64) {
if in > 0 {
bytesIn.Add(in)
+ packetsIn.Add(1)
}
if out > 0 {
bytesOut.Add(out)
+ packetsOut.Add(1)
}
}
// Traffic returns the bytes carried over the tunnel so far.
func Traffic() (in, out uint64) { return bytesIn.Load(), bytesOut.Load() }
+
+// TrafficPackets returns transport-level read/write units. For datagrams and
+// websocket messages these are real messages; for streams they are successful
+// relay reads/writes (kernel packet counts are not exposed to the process).
+func TrafficPackets() (in, out uint64) { return packetsIn.Load(), packetsOut.Load() }
diff --git a/internal/metrics/persist_test.go b/internal/metrics/persist_test.go
index 129d79d..4529f98 100644
--- a/internal/metrics/persist_test.go
+++ b/internal/metrics/persist_test.go
@@ -15,12 +15,38 @@ func resetCounters(t *testing.T) {
t.Helper()
bytesIn.Store(0)
bytesOut.Store(0)
+ packetsIn.Store(0)
+ packetsOut.Store(0)
t.Cleanup(func() {
bytesIn.Store(0)
bytesOut.Store(0)
+ packetsIn.Store(0)
+ packetsOut.Store(0)
})
}
+func TestCollectorReloadInSameProcessDoesNotDoubleCount(t *testing.T) {
+ resetCounters(t)
+ dir := t.TempDir()
+ first := NewCollector(dir, "t", "tcp", "server", nil, nil)
+ AddBytes(1000, 500)
+ if err := first.Write(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Config reloads happen inside the same process, so the package counters do
+ // not reset. The new collector must begin at their current offset.
+ second := NewCollector(dir, "t", "tcp", "server", nil, nil)
+ if got := second.Snapshot(); got.BytesIn != 1000 || got.BytesOut != 500 {
+ t.Fatalf("same-process reload doubled totals: %#v", got)
+ }
+ AddBytes(200, 100)
+ got := second.Snapshot()
+ if got.BytesIn != 1200 || got.BytesOut != 600 || got.PacketsIn != 2 || got.PacketsOut != 2 {
+ t.Fatalf("reload totals did not continue monotonically: %#v", got)
+ }
+}
+
func TestTrafficResumesAfterRestart(t *testing.T) {
resetCounters(t)
dir := t.TempDir()
diff --git a/internal/server/server.go b/internal/server/server.go
index 2bc9fc0..597b2b2 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -22,10 +22,11 @@ import (
const acmeCacheDir = "/etc/backpack/acme"
type Server struct {
- config *config.ServerConfig
- ctx context.Context
- cancel context.CancelFunc
- logger *logrus.Logger
+ config *config.ServerConfig
+ forward bool
+ ctx context.Context
+ cancel context.CancelFunc
+ logger *logrus.Logger
}
func NewServer(cfg *config.ServerConfig, parentCtx context.Context) *Server {
@@ -43,6 +44,16 @@ func NewServer(cfg *config.ServerConfig, parentCtx context.Context) *Server {
}
}
+// NewForwardOrigin builds the listening Kharej half of an application-level
+// forward tunnel. It uses the same transport listener and authentication as a
+// reverse server, but accepted data channels are dialled into local backends
+// instead of being paired with public ingress sockets.
+func NewForwardOrigin(cfg *config.ServerConfig, parentCtx context.Context) *Server {
+ s := NewServer(cfg, parentCtx)
+ s.forward = true
+ return s
+}
+
func (s *Server) Start() {
// Profiling endpoint, off unless explicitly enabled in the config.
//
@@ -82,6 +93,7 @@ func (s *Server) Start() {
// Stealth is the TCP transport with a Noise record layer over every
// tunnel connection; everything else about it is identical.
Stealth: s.config.Transport == config.STEALTH,
+ Forward: s.forward,
}
tcpServer := transport.NewTCPServer(s.ctx, tcpConfig, s.logger)
@@ -129,6 +141,7 @@ func (s *Server) Start() {
SpoofSrcPool: s.config.SpoofSrcPool,
SpoofPeerIP: s.config.SpoofPeerIP,
SpoofInterface: s.config.SpoofInterface,
+ Forward: s.forward,
}
kcpServer := transport.NewKcpServer(s.ctx, kcpConfig, s.logger)
@@ -150,6 +163,7 @@ func (s *Server) Start() {
ProxyProtocol: s.config.ProxyProtocol,
MaxConnections: s.config.MaxConnections,
BandwidthMbps: s.config.BandwidthMbps,
+ Forward: s.forward,
}
quicServer := transport.NewQuicServer(s.ctx, quicConfig, s.logger)
@@ -178,6 +192,7 @@ func (s *Server) Start() {
ProxyProtocol: s.config.ProxyProtocol,
MaxConnections: s.config.MaxConnections,
BandwidthMbps: s.config.BandwidthMbps,
+ Forward: s.forward,
}
tcpMuxServer := transport.NewTcpMuxServer(s.ctx, tcpMuxConfig, s.logger)
@@ -205,6 +220,7 @@ func (s *Server) Start() {
MaxConnections: s.config.MaxConnections,
BandwidthMbps: s.config.BandwidthMbps,
+ Forward: s.forward,
}
wsServer := transport.NewWSServer(s.ctx, wsConfig, s.logger)
@@ -237,6 +253,7 @@ func (s *Server) Start() {
ProxyProtocol: s.config.ProxyProtocol,
MaxConnections: s.config.MaxConnections,
BandwidthMbps: s.config.BandwidthMbps,
+ Forward: s.forward,
}
wsMuxServer := transport.NewWSMuxServer(s.ctx, wsMuxConfig, s.logger)
@@ -254,6 +271,7 @@ func (s *Server) Start() {
SnifferLog: s.config.SnifferLog,
SO_RCVBUF: s.config.SO_RCVBUF,
SO_SNDBUF: s.config.SO_SNDBUF,
+ Forward: s.forward,
}
udpServer := transport.NewUDPServer(s.ctx, udpConfig, s.logger)
diff --git a/internal/server/transport/forward.go b/internal/server/transport/forward.go
new file mode 100644
index 0000000..dc62595
--- /dev/null
+++ b/internal/server/transport/forward.go
@@ -0,0 +1,80 @@
+package transport
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/backpack/backpack/internal/metrics"
+ "github.com/backpack/backpack/internal/utils"
+ "github.com/backpack/backpack/internal/utils/handlers"
+ "github.com/backpack/backpack/internal/utils/network"
+ "github.com/backpack/backpack/internal/web"
+ "github.com/sirupsen/logrus"
+)
+
+var forwardBackendCursor sync.Map // map[canonical backend list]*atomic.Uint64
+
+// dialForwardTCPBackend load-balances across the same pipe-separated backend
+// syntax supported by reverse tunnels. A failed member is skipped immediately;
+// the next user connection starts at the next member, so healthy backends share
+// load without a dead member black-holing the ingress.
+func dialForwardTCPBackend(ctx context.Context, target string, keepAlive time.Duration) (net.Conn, int, string, error) {
+ firstPort, resolved, err := network.ResolveRemoteAddr(target)
+ if err != nil {
+ return nil, 0, "", err
+ }
+ parts := strings.Split(resolved, "|")
+ cursorAny, _ := forwardBackendCursor.LoadOrStore(resolved, &atomic.Uint64{})
+ start := int(cursorAny.(*atomic.Uint64).Add(1)-1) % len(parts)
+ var lastErr error
+ for i := 0; i < len(parts); i++ {
+ candidate := strings.TrimSpace(parts[(start+i)%len(parts)])
+ port, _, err := network.ResolveRemoteAddr(candidate)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ dialer := net.Dialer{Timeout: 10 * time.Second, KeepAlive: keepAlive}
+ backend, err := dialer.DialContext(ctx, "tcp", candidate)
+ if err == nil {
+ return backend, port, candidate, nil
+ }
+ lastErr = err
+ }
+ if lastErr == nil {
+ lastErr = fmt.Errorf("no backend candidates")
+ }
+ return nil, firstPort, "", lastErr
+}
+
+// handleForwardStream is shared by every stream-oriented carrier. The carrier
+// has already authenticated the data connection/session; this function reads
+// the requested Kharej backend, dials it, acknowledges readiness, and relays.
+func handleForwardStream(ctx context.Context, stream net.Conn, keepAlive time.Duration, logger *logrus.Logger, usage *web.Usage, sniffer bool) {
+ target, err := utils.ReceiveBinaryString(stream)
+ if err != nil {
+ logger.Warnf("invalid forward target from %s: %v", stream.RemoteAddr(), err)
+ _ = utils.SendBinaryByte(stream, utils.SG_ForwardError)
+ stream.Close()
+ return
+ }
+ backend, port, resolved, err := dialForwardTCPBackend(ctx, target, keepAlive)
+ if err != nil {
+ logger.Warnf("invalid forward backend %q: %v", target, err)
+ _ = utils.SendBinaryByte(stream, utils.SG_ForwardError)
+ stream.Close()
+ return
+ }
+ if err := utils.SendBinaryByte(stream, utils.SG_ForwardOK); err != nil {
+ backend.Close()
+ stream.Close()
+ return
+ }
+ logger.Debugf("forward data channel connected to backend %s", resolved)
+ handlers.TCPConnectionHandler(ctx, false, metrics.CountedConn(stream), backend, logger, usage, port, sniffer)
+}
diff --git a/internal/server/transport/kcp.go b/internal/server/transport/kcp.go
index f297c1c..9dc3482 100644
--- a/internal/server/transport/kcp.go
+++ b/internal/server/transport/kcp.go
@@ -33,6 +33,7 @@ type kcpGen struct {
localChannel chan LocalTCPConn
reqNewConnChan chan struct{}
usageMonitor *web.Usage
+ forwardReady chan struct{}
}
// KcpTransport is the server side of the KCP transport: a reliable,
@@ -44,23 +45,26 @@ type kcpGen struct {
// or a path where the return route is asymmetric. Forward error correction
// repairs losses without waiting a full round trip for a retransmit.
type KcpTransport struct {
- config *KcpConfig
- smuxConfig *smux.Config
- kcpSettings network.KCPSettings
- parentctx context.Context
- ctx context.Context
- cancel context.CancelFunc
- logger *logrus.Logger
- tunnelChannel chan *smux.Session
- handshakeChannel chan net.Conn
- localChannel chan LocalTCPConn
- reqNewConnChan chan struct{}
- controlChannel netControl
- usageMonitor *web.Usage
- restartMutex sync.Mutex
- streamCounter int32
- sessionCounter int32
- limits *limiter
+ config *KcpConfig
+ smuxConfig *smux.Config
+ kcpSettings network.KCPSettings
+ parentctx context.Context
+ ctx context.Context
+ cancel context.CancelFunc
+ logger *logrus.Logger
+ tunnelChannel chan *smux.Session
+ handshakeChannel chan net.Conn
+ localChannel chan LocalTCPConn
+ reqNewConnChan chan struct{}
+ controlChannel netControl
+ usageMonitor *web.Usage
+ restartMutex sync.Mutex
+ streamCounter int32
+ sessionCounter int32
+ limits *limiter
+ forwardReady chan struct{}
+ forwardMu sync.Mutex
+ forwardRawSession *smux.Session
}
type KcpConfig struct {
@@ -110,6 +114,7 @@ type KcpConfig struct {
SpoofSrcPool []string
SpoofPeerIP string
SpoofInterface string
+ Forward bool
}
// transportLabel is what the panel and logs call this transport — XDI when it
@@ -125,6 +130,10 @@ func (s *KcpTransport) transportLabel() string {
return "KCP"
}
+func (s *KcpTransport) rawForward() bool {
+ return s.config.Forward && (s.config.UseICMP || s.config.UseSpoof)
+}
+
func (c *KcpConfig) settings() network.KCPSettings {
s := network.KCPSettings{
MTU: c.MTU,
@@ -180,6 +189,7 @@ func NewKcpServer(parentCtx context.Context, config *KcpConfig, logger *logrus.L
reqNewConnChan: make(chan struct{}, config.ChannelSize),
usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger),
limits: newLimiter(Limits{MaxConnections: config.MaxConnections, BandwidthMbps: config.BandwidthMbps}),
+ forwardReady: make(chan struct{}, 1),
}
}
@@ -195,6 +205,7 @@ func (s *KcpTransport) Start() {
localChannel: s.localChannel,
reqNewConnChan: s.reqNewConnChan,
usageMonitor: s.usageMonitor,
+ forwardReady: s.forwardReady,
}
if s.config.WebPort > 0 {
@@ -203,11 +214,23 @@ func (s *KcpTransport) Start() {
s.config.TunnelStatus = "Disconnected (" + s.transportLabel() + ")"
go s.tunnelListener(g)
+ if s.rawForward() {
+ select {
+ case <-g.forwardReady:
+ s.config.TunnelStatus = "Connected (" + s.transportLabel() + ")"
+ case <-g.ctx.Done():
+ }
+ return
+ }
s.channelHandshake(g)
if s.controlChannel.IsSet() {
s.config.TunnelStatus = "Connected (" + s.transportLabel() + ")"
+ go s.channelHandler(g)
+ if s.config.Forward {
+ return
+ }
numCPU := runtime.NumCPU()
if numCPU > 4 {
@@ -215,8 +238,6 @@ func (s *KcpTransport) Start() {
}
go s.parsePortMappings(g)
- go s.channelHandler(g)
-
s.logger.Infof("starting %d handle loops on each CPU thread", numCPU)
for i := 0; i < numCPU; i++ {
@@ -244,6 +265,12 @@ func (s *KcpTransport) Restart() {
if s.controlChannel.IsSet() {
s.controlChannel.Close()
}
+ s.forwardMu.Lock()
+ if s.forwardRawSession != nil {
+ _ = s.forwardRawSession.Close()
+ s.forwardRawSession = nil
+ }
+ s.forwardMu.Unlock()
time.Sleep(2 * time.Second)
@@ -268,6 +295,7 @@ func (s *KcpTransport) Restart() {
s.tunnelChannel = make(chan *smux.Session, s.config.ChannelSize)
s.localChannel = make(chan LocalTCPConn, s.config.ChannelSize)
s.reqNewConnChan = make(chan struct{}, s.config.ChannelSize)
+ s.forwardReady = make(chan struct{}, 1)
s.handshakeChannel = make(chan net.Conn)
s.controlChannel.Clear()
// The peer is gone until a new control channel arrives; a stale address
@@ -452,6 +480,20 @@ func (s *KcpTransport) acceptSession(g *kcpGen, session *kcp.UDPSession) {
}
// The control channel carries small, latency-critical signals.
session.SetACKNoDelay(true)
+ if s.rawForward() {
+ muxSession, err := smux.Client(session, s.smuxConfig)
+ if err != nil {
+ session.Close()
+ return
+ }
+ metrics.ReportPeer(session.RemoteAddr().String())
+ select {
+ case g.forwardReady <- struct{}{}:
+ default:
+ }
+ s.adoptRawForwardSession(g, muxSession)
+ return
+ }
// A control claim while one is already established means the client
// restarted on its own and re-dialed, while this run never noticed
@@ -498,12 +540,54 @@ func (s *KcpTransport) acceptSession(g *kcpGen, session *kcp.UDPSession) {
muxSession.Close()
}
+ case utils.SG_ForwardTCP:
+ if !s.config.Forward || !s.controlChannel.IsSet() {
+ s.logger.Warnf("invalid forward KCP session from %s", session.RemoteAddr())
+ session.Close()
+ return
+ }
+ muxSession, err := smux.Client(session, s.smuxConfig)
+ if err != nil {
+ session.Close()
+ return
+ }
+ go s.handleForwardKCPSession(g, muxSession)
+
default:
s.logger.Warnf("unexpected announcement signal %v from %s", signal, session.RemoteAddr())
session.Close()
}
}
+func (s *KcpTransport) handleForwardKCPSession(g *kcpGen, session *smux.Session) {
+ defer session.Close()
+ for {
+ stream, err := session.AcceptStream()
+ if err != nil {
+ return
+ }
+ go handleForwardStream(g.ctx, stream, 75*time.Second, s.logger, g.usageMonitor, s.config.Sniffer)
+ }
+}
+
+func (s *KcpTransport) adoptRawForwardSession(g *kcpGen, session *smux.Session) {
+ s.forwardMu.Lock()
+ old := s.forwardRawSession
+ s.forwardRawSession = session
+ s.forwardMu.Unlock()
+ if old != nil && old != session {
+ _ = old.Close()
+ }
+ go func() {
+ s.handleForwardKCPSession(g, session)
+ s.forwardMu.Lock()
+ if s.forwardRawSession == session {
+ s.forwardRawSession = nil
+ }
+ s.forwardMu.Unlock()
+ }()
+}
+
func (s *KcpTransport) parsePortMappings(g *kcpGen) {
for _, portMapping := range s.config.Ports {
parts := strings.Split(portMapping, "=")
diff --git a/internal/server/transport/quic.go b/internal/server/transport/quic.go
index 46cd16f..22f40f4 100644
--- a/internal/server/transport/quic.go
+++ b/internal/server/transport/quic.go
@@ -71,6 +71,7 @@ type QuicConfig struct {
MaxConnections int
// BandwidthMbps caps total tunnel throughput (0 = unlimited).
BandwidthMbps int
+ Forward bool
}
func (c *QuicConfig) settings() network.QUICSettings {
@@ -143,6 +144,10 @@ func (s *QuicTransport) Start() {
}
s.config.TunnelStatus = "Connected (QUIC)"
+ go s.channelHandler(g)
+ if s.config.Forward {
+ return
+ }
numCPU := runtime.NumCPU()
if numCPU > 4 {
@@ -150,8 +155,6 @@ func (s *QuicTransport) Start() {
}
go s.parsePortMappings(g)
- go s.channelHandler(g)
-
s.logger.Infof("starting %d handle loops on each CPU thread", numCPU)
for i := 0; i < numCPU; i++ {
go s.handleLoop(g)
@@ -326,6 +329,14 @@ func (s *QuicTransport) acceptStream(g *quicGen, conn *quic.Conn, stream *quic.S
stream.Close()
}
+ case utils.SG_ForwardTCP:
+ if !s.config.Forward || !s.controlChannel.IsSet() {
+ s.logger.Warnf("invalid forward QUIC stream from %s", conn.RemoteAddr())
+ stream.Close()
+ return
+ }
+ go handleForwardStream(g.ctx, wrapped, s.config.KeepAlive, s.logger, g.usageMonitor, s.config.Sniffer)
+
default:
s.logger.Warnf("unexpected announcement signal %v from %s", signal, conn.RemoteAddr())
stream.Close()
diff --git a/internal/server/transport/tcp.go b/internal/server/transport/tcp.go
index acc9ba4..54d5c22 100644
--- a/internal/server/transport/tcp.go
+++ b/internal/server/transport/tcp.go
@@ -78,6 +78,10 @@ type TcpConfig struct {
// Stealth wraps every accepted tunnel connection in the Noise record layer,
// so the stream has no fingerprint for deep packet inspection to match.
Stealth bool
+ // Forward makes this listener the Kharej origin of a forward tunnel. The
+ // control channel is unchanged; data connections are opened by the Iran
+ // peer and carry their backend target immediately.
+ Forward bool
}
func NewTCPServer(parentCtx context.Context, config *TcpConfig, logger *logrus.Logger) *TcpTransport {
@@ -132,6 +136,14 @@ func (s *TcpTransport) Start() {
if s.controlChannel.IsSet() {
s.config.TunnelStatus = "Connected (TCP)"
+ go s.channelHandler(g)
+
+ if s.config.Forward {
+ // The forward origin owns no public ingress ports and never asks the
+ // dialler for pool connections. Each Iran-side ingress opens and
+ // authenticates its own data connection when needed.
+ return
+ }
numCPU := runtime.NumCPU()
if numCPU > 4 {
@@ -139,8 +151,6 @@ func (s *TcpTransport) Start() {
}
go s.parsePortMappings(g)
- go s.channelHandler(g)
-
s.logger.Infof("starting %d handle loops on each CPU thread", numCPU)
for i := 0; i < numCPU; i++ {
@@ -444,12 +454,33 @@ func (s *TcpTransport) admitTunnelConn(g *tcpGen, raw net.Conn) {
}
s.deliverTunnelConn(g, conn)
+ case ann.signal == utils.SG_ForwardTCP:
+ if !s.config.Forward {
+ s.logger.Warnf("forward data connection sent to a reverse instance from %s", conn.RemoteAddr())
+ conn.Close()
+ return
+ }
+ if !s.controlChannel.IsSet() || !s.poolNonce.Verify(ann.payload) {
+ s.logger.Warnf("forward data connection from %s presented an invalid run nonce", conn.RemoteAddr())
+ conn.Close()
+ return
+ }
+ go s.handleForwardTCP(g, conn)
+
default:
s.logger.Warnf("unexpected announcement %d from %s, discarding", ann.signal, conn.RemoteAddr())
conn.Close()
}
}
+// handleForwardTCP completes the forward-open handshake, dials the backend on
+// this Kharej machine, and only then acknowledges the Iran-side ingress. The
+// target is bounded by the binary framing and is resolved with the same rules
+// as reverse mode (a bare port means 127.0.0.1:
).
+func (s *TcpTransport) handleForwardTCP(g *tcpGen, tunnelConn net.Conn) {
+ handleForwardStream(g.ctx, tunnelConn, s.config.KeepAlive, s.logger, g.usageMonitor, s.config.Sniffer)
+}
+
// admitControlChannel verifies a peer claiming the control channel, answers it,
// and offers it as the candidate for channelHandshake to publish.
func (s *TcpTransport) admitControlChannel(g *tcpGen, conn net.Conn, ann announcement) {
diff --git a/internal/server/transport/tcpmux.go b/internal/server/transport/tcpmux.go
index 34de84c..43e6d8a 100644
--- a/internal/server/transport/tcpmux.go
+++ b/internal/server/transport/tcpmux.go
@@ -87,6 +87,7 @@ type TcpMuxConfig struct {
MaxConnections int
// BandwidthMbps caps total tunnel throughput (0 = unlimited).
BandwidthMbps int
+ Forward bool
}
// setMuxVersion records the version this run agreed on. A legacy client cannot
@@ -169,6 +170,10 @@ func (s *TcpMuxTransport) Start() {
if s.controlChannel.IsSet() {
s.config.TunnelStatus = "Connected (TCPMux)"
+ go s.channelHandler(g)
+ if s.config.Forward {
+ return
+ }
numCPU := runtime.NumCPU()
if numCPU > 4 {
@@ -176,8 +181,6 @@ func (s *TcpMuxTransport) Start() {
}
go s.parsePortMappings(g)
- go s.channelHandler(g)
-
s.logger.Infof("starting %d handle loops on each CPU thread", numCPU)
for i := 0; i < numCPU; i++ {
@@ -462,12 +465,36 @@ func (s *TcpMuxTransport) admitTunnelConn(g *tcpMuxGen, conn net.Conn) {
}
s.deliverTunnelConn(g, conn)
+ case ann.signal == utils.SG_ForwardTCP:
+ if !s.config.Forward || !s.controlChannel.IsSet() || !s.poolNonce.Verify(ann.payload) {
+ s.logger.Warnf("invalid forward mux session from %s", conn.RemoteAddr())
+ conn.Close()
+ return
+ }
+ go s.handleForwardMuxConn(g, conn)
+
default:
s.logger.Warnf("unexpected announcement %d from %s, discarding", ann.signal, conn.RemoteAddr())
conn.Close()
}
}
+func (s *TcpMuxTransport) handleForwardMuxConn(g *tcpMuxGen, conn net.Conn) {
+ session, err := smux.Client(conn, s.smuxCfg())
+ if err != nil {
+ conn.Close()
+ return
+ }
+ defer session.Close()
+ for {
+ stream, err := session.AcceptStream()
+ if err != nil {
+ return
+ }
+ go handleForwardStream(g.ctx, stream, s.config.KeepAlive, s.logger, g.usageMonitor, s.config.Sniffer)
+ }
+}
+
// admitControlChannel verifies a peer claiming the control channel, answers it,
// and offers it as the candidate for channelHandshake to publish.
func (s *TcpMuxTransport) admitControlChannel(g *tcpMuxGen, conn net.Conn, ann announcement) {
diff --git a/internal/server/transport/udp.go b/internal/server/transport/udp.go
index 3f791e8..f7c4489 100644
--- a/internal/server/transport/udp.go
+++ b/internal/server/transport/udp.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/backpack/backpack/internal/utils"
+ "github.com/backpack/backpack/internal/utils/network"
"github.com/backpack/backpack/internal/web"
"github.com/sirupsen/logrus"
)
@@ -58,6 +59,7 @@ type UdpConfig struct {
// keeps the tunnel carrying traffic under load instead of stalling.
SO_RCVBUF int
SO_SNDBUF int
+ Forward bool
}
func NewUDPServer(parentCtx context.Context, config *UdpConfig, logger *logrus.Logger) *UdpTransport {
@@ -222,7 +224,9 @@ func (s *UdpTransport) channelHandshake(g *udpGen) {
established = true
go s.tunnelListener(g)
- go s.parsePortMappings(g)
+ if !s.config.Forward {
+ go s.parsePortMappings(g)
+ }
go s.channelHandler(g)
}
}
@@ -422,7 +426,15 @@ func (s *UdpTransport) acceptTunnelConn(g *udpGen, listener *net.UDPConn) {
s.activeMu.Unlock()
- if string(buf[:n]) != s.config.Token { // For new connections, validate the token
+ forwardTarget := ""
+ if s.config.Forward {
+ token, target, err := utils.DecodeForwardUDP(buf[:n])
+ if err != nil || !tokenMatches(token, s.config.Token) {
+ s.logger.Errorf("invalid forward UDP announcement from %s", addr.String())
+ continue
+ }
+ forwardTarget = target
+ } else if string(buf[:n]) != s.config.Token { // For new reverse connections, validate the token
s.logger.Errorf("invalid token received from %s", addr.String())
continue
}
@@ -445,7 +457,12 @@ func (s *UdpTransport) acceptTunnelConn(g *udpGen, listener *net.UDPConn) {
s.activeConnections[key] = &tunnelConn
s.activeMu.Unlock()
- // Send the new tunnel connection to the tunnel channel
+ if s.config.Forward {
+ go s.handleForwardUDP(g, &tunnelConn, forwardTarget)
+ continue
+ }
+
+ // Send the new reverse tunnel connection to the tunnel channel
select {
case g.tunnelChannel <- &tunnelConn:
go s.keepAlive(g, &tunnelConn)
@@ -460,6 +477,76 @@ func (s *UdpTransport) acceptTunnelConn(g *udpGen, listener *net.UDPConn) {
}
}
+func (s *UdpTransport) handleForwardUDP(g *udpGen, tunnel *TunnelUDPConn, target string) {
+ defer func() {
+ s.activeMu.Lock()
+ key := tunnel.addr.String()
+ if s.activeConnections[key] == tunnel {
+ delete(s.activeConnections, key)
+ close(tunnel.payload)
+ }
+ s.activeMu.Unlock()
+ }()
+ _, resolved, err := network.ResolveRemoteAddr(target)
+ if err != nil {
+ _, _ = tunnel.listener.WriteToUDP([]byte{utils.SG_ForwardError}, tunnel.addr)
+ return
+ }
+ // UDP has no connection probe that proves an application is healthy. Match
+ // reverse mode's documented behaviour and use the first configured backend.
+ resolved = strings.TrimSpace(strings.Split(resolved, "|")[0])
+ backendAddr, err := net.ResolveUDPAddr("udp", resolved)
+ if err != nil {
+ _, _ = tunnel.listener.WriteToUDP([]byte{utils.SG_ForwardError}, tunnel.addr)
+ return
+ }
+ backend, err := net.DialUDP("udp", nil, backendAddr)
+ if err != nil {
+ _, _ = tunnel.listener.WriteToUDP([]byte{utils.SG_ForwardError}, tunnel.addr)
+ return
+ }
+ s.applyBuffers(backend)
+ defer backend.Close()
+ if _, err := tunnel.listener.WriteToUDP([]byte{utils.SG_ForwardOK}, tunnel.addr); err != nil {
+ return
+ }
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for {
+ select {
+ case <-g.ctx.Done():
+ return
+ case payload, ok := <-tunnel.payload:
+ if !ok {
+ return
+ }
+ _ = backend.SetWriteDeadline(time.Now().Add(60 * time.Second))
+ if _, err := backend.Write(payload); err != nil {
+ return
+ }
+ }
+ }
+ }()
+ buf := make([]byte, 64*1024)
+ for {
+ _ = backend.SetReadDeadline(time.Now().Add(60 * time.Second))
+ n, err := backend.Read(buf)
+ if err != nil {
+ return
+ }
+ if _, err := tunnel.listener.WriteToUDP(buf[:n], tunnel.addr); err != nil {
+ return
+ }
+ select {
+ case <-done:
+ return
+ default:
+ }
+ }
+}
+
func (s *UdpTransport) parsePortMappings(g *udpGen) {
for _, portMapping := range s.config.Ports {
parts := strings.Split(portMapping, "=")
diff --git a/internal/server/transport/ws.go b/internal/server/transport/ws.go
index ec403e4..3edfa4b 100644
--- a/internal/server/transport/ws.go
+++ b/internal/server/transport/ws.go
@@ -72,6 +72,7 @@ type WsConfig struct {
MaxConnections int
// BandwidthMbps caps total tunnel throughput (0 = unlimited).
BandwidthMbps int
+ Forward bool
}
func NewWSServer(parentCtx context.Context, config *WsConfig, logger *logrus.Logger) *WsTransport {
@@ -299,17 +300,29 @@ func (s *WsTransport) tunnelListener(g *wsGen) {
}
go s.channelHandler(g)
- go s.parsePortMappings(g)
+ if !s.config.Forward {
+ go s.parsePortMappings(g)
+ }
s.logger.Infof("starting %d handle loops on each CPU thread", numCPU)
- for i := 0; i < numCPU; i++ {
- go s.handleLoop(g)
+ if !s.config.Forward {
+ for i := 0; i < numCPU; i++ {
+ go s.handleLoop(g)
+ }
}
s.config.TunnelStatus = fmt.Sprintf("Connected (%s)", s.config.Mode)
} else if strings.HasPrefix(r.URL.Path, "/tunnel") {
+ if s.config.Forward {
+ if !s.controlChannel.IsSet() {
+ conn.Close()
+ return
+ }
+ go s.handleForwardWS(g, conn)
+ return
+ }
wsConn := TunnelChannel{
conn: conn,
ping: make(chan struct{}),
@@ -375,6 +388,29 @@ func (s *WsTransport) tunnelListener(g *wsGen) {
}
+func (s *WsTransport) handleForwardWS(g *wsGen, conn *websocket.Conn) {
+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
+ _, payload, err := conn.ReadMessage()
+ _ = conn.SetReadDeadline(time.Time{})
+ if err != nil {
+ conn.Close()
+ return
+ }
+ target := string(payload)
+ backend, port, _, err := dialForwardTCPBackend(g.ctx, target, s.config.KeepAlive)
+ if err != nil {
+ _ = conn.WriteMessage(websocket.BinaryMessage, []byte{utils.SG_ForwardError})
+ conn.Close()
+ return
+ }
+ if err := conn.WriteMessage(websocket.BinaryMessage, []byte{utils.SG_ForwardOK}); err != nil {
+ backend.Close()
+ conn.Close()
+ return
+ }
+ handlers.WSConnectionHandler(g.ctx, conn, backend, s.logger, g.usageMonitor, port, s.config.Sniffer)
+}
+
func (s *WsTransport) parsePortMappings(g *wsGen) {
for _, portMapping := range s.config.Ports {
parts := strings.Split(portMapping, "=")
diff --git a/internal/server/transport/wsmux.go b/internal/server/transport/wsmux.go
index df96226..c4ed889 100644
--- a/internal/server/transport/wsmux.go
+++ b/internal/server/transport/wsmux.go
@@ -83,6 +83,7 @@ type WsMuxConfig struct {
MaxConnections int
// BandwidthMbps caps total tunnel throughput (0 = unlimited).
BandwidthMbps int
+ Forward bool
}
func NewWSMuxServer(parentCtx context.Context, config *WsMuxConfig, logger *logrus.Logger) *WsMuxTransport {
@@ -327,12 +328,16 @@ func (s *WsMuxTransport) tunnelListener(g *wsMuxGen) {
}
go s.channelHandler(g)
- go s.parsePortMappings(g)
+ if !s.config.Forward {
+ go s.parsePortMappings(g)
+ }
s.logger.Infof("starting %d handle loops on each CPU thread", numCPU)
- for i := 0; i < numCPU; i++ {
- go s.handleLoop(g)
+ if !s.config.Forward {
+ for i := 0; i < numCPU; i++ {
+ go s.handleLoop(g)
+ }
}
s.config.TunnelStatus = fmt.Sprintf("Connected (%s)", s.config.Mode)
@@ -344,6 +349,14 @@ func (s *WsMuxTransport) tunnelListener(g *wsMuxGen) {
conn.Close()
return
}
+ if s.config.Forward {
+ if !s.controlChannel.IsSet() {
+ session.Close()
+ return
+ }
+ go s.handleForwardWSMuxSession(g, session)
+ return
+ }
select {
case g.tunnelChannel <- session: // ok
default:
@@ -400,6 +413,17 @@ func (s *WsMuxTransport) tunnelListener(g *wsMuxGen) {
}
}
+func (s *WsMuxTransport) handleForwardWSMuxSession(g *wsMuxGen, session *smux.Session) {
+ defer session.Close()
+ for {
+ stream, err := session.AcceptStream()
+ if err != nil {
+ return
+ }
+ go handleForwardStream(g.ctx, stream, s.config.KeepAlive, s.logger, g.usageMonitor, s.config.Sniffer)
+ }
+}
+
func (s *WsMuxTransport) parsePortMappings(g *wsMuxGen) {
for _, portMapping := range s.config.Ports {
parts := strings.Split(portMapping, "=")
diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go
index 9d810cf..f89ed39 100644
--- a/internal/telegram/telegram.go
+++ b/internal/telegram/telegram.go
@@ -158,8 +158,10 @@ func tunnelBlock(lang string, t manage.Tunnel, h manage.Health) string {
fmt.Fprintf(&b, "%s ", f)
}
label := strings.ToUpper(t.Transport)
- if t.Mode == "direct" {
+ if t.KernelDirect() {
label = "DIRECT/" + strings.ToUpper(t.Engine)
+ } else if t.AppForward() {
+ label = "DIRECT/" + strings.ToUpper(t.Transport)
}
fmt.Fprintf(&b, "%s [ %s ]", t.Name, label)
if p := manage.PresetLabel(t.Name); p != "" {
@@ -167,10 +169,15 @@ func tunnelBlock(lang string, t manage.Tunnel, h manage.Health) string {
}
b.WriteString("\n")
- if t.Mode == "direct" {
+ if t.KernelDirect() {
for _, m := range t.Mappings {
fmt.Fprintf(&b, "%s %s:%s -> %s:%s\n", strings.ToUpper(strings.Join(m.Protocols, "+")), m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts)
}
+ } else if t.AppForward() && t.Role == "client" {
+ fmt.Fprintf(&b, "Kharej : %s\n", t.Addr)
+ if ports := manage.VisiblePorts(t.Ports, manage.TunnelToken(t.Name)); len(ports) > 0 {
+ fmt.Fprintf(&b, tr(lang, "Forwarded Port")+" : %s\n", strings.Join(ports, ", "))
+ }
} else if t.Role == "server" {
fmt.Fprintf(&b, tr(lang, "Tunnel Port")+" : %s\n", portOf(t.Addr))
if ports := manage.VisiblePorts(t.Ports, manage.TunnelToken(t.Name)); len(ports) > 0 {
diff --git a/internal/utils/forward_udp.go b/internal/utils/forward_udp.go
new file mode 100644
index 0000000..bf5d16a
--- /dev/null
+++ b/internal/utils/forward_udp.go
@@ -0,0 +1,34 @@
+package utils
+
+import (
+ "encoding/binary"
+ "fmt"
+)
+
+// EncodeForwardUDP builds the first datagram of a direct UDP flow. Lengths are
+// explicit because both the token and target are user-controlled strings and
+// no separator byte is safe. The packet stays well below the UDP maximum.
+func EncodeForwardUDP(token, target string) ([]byte, error) {
+ if len(token) > 65535 || len(target) > 65535 || len(token)+len(target)+5 > 65507 {
+ return nil, fmt.Errorf("forward UDP announcement is too large")
+ }
+ b := make([]byte, 5+len(token)+len(target))
+ b[0] = SG_ForwardUDP
+ binary.BigEndian.PutUint16(b[1:3], uint16(len(token)))
+ binary.BigEndian.PutUint16(b[3:5], uint16(len(target)))
+ copy(b[5:], token)
+ copy(b[5+len(token):], target)
+ return b, nil
+}
+
+func DecodeForwardUDP(b []byte) (token, target string, err error) {
+ if len(b) < 5 || b[0] != SG_ForwardUDP {
+ return "", "", fmt.Errorf("not a forward UDP announcement")
+ }
+ tokenLen := int(binary.BigEndian.Uint16(b[1:3]))
+ targetLen := int(binary.BigEndian.Uint16(b[3:5]))
+ if tokenLen == 0 || targetLen == 0 || 5+tokenLen+targetLen != len(b) {
+ return "", "", fmt.Errorf("malformed forward UDP announcement")
+ }
+ return string(b[5 : 5+tokenLen]), string(b[5+tokenLen:]), nil
+}
diff --git a/internal/utils/forward_udp_test.go b/internal/utils/forward_udp_test.go
new file mode 100644
index 0000000..7606ddd
--- /dev/null
+++ b/internal/utils/forward_udp_test.go
@@ -0,0 +1,19 @@
+package utils
+
+import "testing"
+
+func TestForwardUDPRoundTripAndMalformedPackets(t *testing.T) {
+ p, err := EncodeForwardUDP("tok:en\x00", "[2001:db8::1]:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ token, target, err := DecodeForwardUDP(p)
+ if err != nil || token != "tok:en\x00" || target != "[2001:db8::1]:443" {
+ t.Fatalf("decoded %q %q err=%v", token, target, err)
+ }
+ for _, bad := range [][]byte{nil, {SG_ForwardUDP}, {SG_ForwardUDP, 0, 1, 0, 1, 'x'}} {
+ if _, _, err := DecodeForwardUDP(bad); err == nil {
+ t.Fatalf("accepted malformed packet %v", bad)
+ }
+ }
+}
diff --git a/internal/utils/network/resolver.go b/internal/utils/network/resolver.go
index 4a23d17..45ea41c 100644
--- a/internal/utils/network/resolver.go
+++ b/internal/utils/network/resolver.go
@@ -2,6 +2,7 @@ package network
import (
"fmt"
+ "net"
"strconv"
"strings"
)
@@ -33,26 +34,32 @@ func ResolveRemoteAddr(remoteAddr string) (int, string, error) {
return firstPort, strings.Join(resolved, "|"), nil
}
- // Split the address into host and port
- parts := strings.Split(remoteAddr, ":")
- var port int
- var err error
-
- // Handle cases where only the port is sent or host:port format
- if len(parts) < 2 {
- port, err = strconv.Atoi(parts[0])
+ remoteAddr = strings.TrimSpace(remoteAddr)
+ // A bare number means the historical loopback shorthand. Everything else
+ // must be a valid host:port; net.SplitHostPort is required here because a
+ // strings.Split on ':' corrupts bracketed IPv6 addresses.
+ if !strings.Contains(remoteAddr, ":") {
+ port, err := strconv.Atoi(remoteAddr)
if err != nil {
return 0, "", fmt.Errorf("invalid port format: %v", err)
}
+ if port < 1 || port > 65535 {
+ return 0, "", fmt.Errorf("invalid port %d", port)
+ }
// Default to localhost if only the port is provided
return port, fmt.Sprintf("127.0.0.1:%d", port), nil
}
-
- // If both host and port are provided
- port, err = strconv.Atoi(parts[1])
+ host, portText, err := net.SplitHostPort(remoteAddr)
+ if err != nil || strings.TrimSpace(host) == "" {
+ return 0, "", fmt.Errorf("invalid remote address %q: %w", remoteAddr, err)
+ }
+ port, err := strconv.Atoi(portText)
if err != nil {
return 0, "", fmt.Errorf("invalid port format: %v", err)
}
+ if port < 1 || port > 65535 {
+ return 0, "", fmt.Errorf("invalid port %d", port)
+ }
// Return the full resolved address
return port, remoteAddr, nil
diff --git a/internal/utils/network/resolver_test.go b/internal/utils/network/resolver_test.go
index f3f42ab..ad69685 100644
--- a/internal/utils/network/resolver_test.go
+++ b/internal/utils/network/resolver_test.go
@@ -14,6 +14,7 @@ func TestResolveRemoteAddrSingle(t *testing.T) {
{"443", 443, "127.0.0.1:443"},
{"127.0.0.1:2096", 2096, "127.0.0.1:2096"},
{"10.0.0.5:8443", 8443, "10.0.0.5:8443"},
+ {"[2001:db8::10]:8443", 8443, "[2001:db8::10]:8443"},
} {
port, addr, err := ResolveRemoteAddr(c.in)
if err != nil {
diff --git a/internal/utils/signals.go b/internal/utils/signals.go
index a2ece62..e5a4078 100644
--- a/internal/utils/signals.go
+++ b/internal/utils/signals.go
@@ -18,4 +18,18 @@ const (
// SG_Pool announces a pool connection, carrying the nonce the server handed
// out when the control channel was established.
SG_Pool
+
+ // SG_ForwardTCP announces a data connection opened by the dialling Iran
+ // edge. The payload is the current control-channel nonce; the next framed
+ // string is the backend target on the Kharej origin. Older binaries reject
+ // the unknown signal without changing legacy reverse behaviour.
+ SG_ForwardTCP
+ // SG_ForwardUDP is the datagram equivalent. It is reserved separately so a
+ // receiver can never interpret UDP framing as a TCP byte stream.
+ SG_ForwardUDP
+ // The origin answers a forward-open only after its backend dial succeeds.
+ // This keeps an accepted Iran-side user socket from hanging against a dead
+ // or invalid backend.
+ SG_ForwardOK
+ SG_ForwardError
)
diff --git a/internal/webui/assets/dashboard.html b/internal/webui/assets/dashboard.html
index ee0fe6d..242f928 100644
--- a/internal/webui/assets/dashboard.html
+++ b/internal/webui/assets/dashboard.html
@@ -1420,6 +1420,11 @@ Logs
"Disk %":"دیسک ٪",
"Tunnel Port":"پورت تونل",
"Server":"سرور",
+ "Engine":"موتور",
+ "Mappings":"نگاشتها",
+ "Kharej Server":"سرور خارج",
+ "Direction":"جهت اتصال",
+ "Iran → Kharej":"ایران → خارج",
"Ping":"پینگ",
"Forwarded Ports":"پورتهای فورواردشده",
"Remote":"مقصد",
@@ -1845,6 +1850,7 @@ Logs
let cardMap={}, lastTunnels=[];
function buildCard(t){
const n=esc(t.name);
+ const kernelDirect=t.engine==='iptables', appDirect=t.engine==='forward';
const el=document.createElement('div');
el.className='tun';
el.dataset.name=t.name;
@@ -1857,9 +1863,9 @@ Logs
${ICON.bot}
-
${t.mode==='direct'?'Engine':(t.role==='server'?'Tunnel Port':'Server')}
+
${kernelDirect?'Engine':(appDirect?(t.role==='server'?'Kharej Server':'Tunnel Port'):(t.role==='server'?'Tunnel Port':'Server'))}
-
${t.mode==='direct'?'Mappings':(t.role==='server'?'Forwarded Ports':'Remote')}
+
${kernelDirect?'Mappings':(appDirect?(t.role==='server'?'Forwarded Ports':'Direction'):(t.role==='server'?'Forwarded Ports':'Remote'))}
@@ -1871,6 +1877,7 @@
Logs
return el;
}
function updateCard(el,t){
+ const kernelDirect=t.engine==='iptables', appDirect=t.engine==='forward';
const st=t.state||'stopped';
if(el.classList.contains(st)===false){ el.className='tun '+st; }
// The dot is the whole status display now, so it carries the label as a
@@ -1885,7 +1892,7 @@
Logs
const fl=el.querySelector('.flag'), fe=cc?flagEmoji(cc):'';
if(fl.textContent!==fe){ fl.textContent=fe; fl.title=cc?countryName(cc):''; }
- setText(el.querySelector('.badge.tr'),((t.mode==='direct'?t.engine:t.transport)||'').toUpperCase());
+ setText(el.querySelector('.badge.tr'),(kernelDirect?(t.engine||'iptables'):(appDirect?'DIRECT/'+(t.transport||''):t.transport||'')).toUpperCase());
el.querySelector('.badge.bot').style.display=t.botRelay?'':'none';
const pe=el.querySelector('.ping'), pc='v ping '+pingClass(t.ping);
@@ -1893,10 +1900,12 @@
Logs
setText(pe,pingText(t.ping));
// A server card shows the port clients dial; the full bind address is noise.
- setText(el.querySelector('.addrlabel'),t.mode==='direct'?'Engine':(t.role==='server'?'Tunnel Port':'Server'));
- setText(el.querySelector('.portslabel'),t.mode==='direct'?'Mappings':(t.role==='server'?'Forwarded Ports':'Remote'));
- setText(el.querySelector('.addr'), t.mode==='direct' ? (t.engine||'iptables') : (t.role==='server' ? (t.tunnelPort||'—') : (t.addr||'—')));
- setText(el.querySelector('.ports'),t.ports||t.addr||'—');
+ const addrLabel=kernelDirect?'Engine':(appDirect?(t.role==='server'?'Kharej Server':'Tunnel Port'):(t.role==='server'?'Tunnel Port':'Server'));
+ const portsLabel=kernelDirect?'Mappings':(appDirect?(t.role==='server'?'Forwarded Ports':'Direction'):(t.role==='server'?'Forwarded Ports':'Remote'));
+ setText(el.querySelector('.addrlabel'),T(addrLabel));
+ setText(el.querySelector('.portslabel'),T(portsLabel));
+ setText(el.querySelector('.addr'),kernelDirect?(t.engine||'iptables'):(appDirect?(t.role==='server'?(t.addr||'—'):(t.tunnelPort||'—')):(t.role==='server'?(t.tunnelPort||'—'):(t.addr||'—'))));
+ setText(el.querySelector('.ports'),appDirect&&t.role!=='server'?T('Iran → Kharej'):(t.ports||t.addr||'—'));
// Traffic comes from the tunnel's own metrics snapshot; a tunnel that has
// never run has none, and the row stays hidden rather than showing a
@@ -2175,7 +2184,8 @@
Logs
function renderLinkTestBox(t){
const box=$('ltwrap');
// Measured from the side that dials out; a TCP probe can't see a UDP port.
- if(t.role!=='client'||['udp','kcp'].includes(t.transport)){ box.innerHTML=''; return; }
+ const dialler=t.engine==='forward'?t.role==='server':t.role==='client';
+ if(!dialler||['udp','kcp','quic','xdi','spoof'].includes(t.transport)){ box.innerHTML=''; return; }
box.innerHTML=dSec('Link Test')+
'
Latency, jitter and loss to '+esc(t.addr)+
', with a transport recommendation.'+
diff --git a/internal/webui/direct_api_test.go b/internal/webui/direct_api_test.go
index c025aaf..b7d79b4 100644
--- a/internal/webui/direct_api_test.go
+++ b/internal/webui/direct_api_test.go
@@ -2,6 +2,7 @@ package webui
import (
"encoding/json"
+ "strings"
"testing"
"github.com/backpack/backpack/config"
@@ -10,7 +11,8 @@ import (
func TestTunnelInfoAdditiveEngineShapeIsStable(t *testing.T) {
for _, info := range []TunnelInfo{
{Name: "legacy", Mode: "reverse", Engine: "reverse", Role: "server", Transport: "tcp", Mappings: []config.ForwardMapping{}},
- {Name: "direct", Mode: "direct", Engine: "iptables", Role: "", Transport: "", Mappings: []config.ForwardMapping{}},
+ {Name: "kernel-direct", Mode: "direct", Engine: "iptables", Role: "", Transport: "", Mappings: []config.ForwardMapping{}},
+ {Name: "app-direct", Mode: "direct", Engine: "forward", Role: "server", Transport: "tcpmux", Mappings: []config.ForwardMapping{}},
} {
b, err := json.Marshal(info)
if err != nil {
@@ -28,8 +30,28 @@ func TestTunnelInfoAdditiveEngineShapeIsStable(t *testing.T) {
if mappings, ok := got["mappings"].([]any); !ok || len(mappings) != 0 {
t.Errorf("%s mappings must be an empty JSON array, got %#v", info.Name, got["mappings"])
}
- if info.Mode == "direct" && (got["role"] != "" || got["transport"] != "") {
- t.Errorf("direct reverse-only fields must be stable empty strings: %s", b)
+ if info.Engine == "iptables" && (got["role"] != "" || got["transport"] != "") {
+ t.Errorf("kernel-direct reverse-only fields must be stable empty strings: %s", b)
}
+ if info.Engine == "forward" && (got["role"] == "" || got["transport"] == "") {
+ t.Errorf("application Direct must retain its selected transport: %s", b)
+ }
+ }
+}
+
+func TestDashboardSeparatesApplicationDirectFromKernelDirect(t *testing.T) {
+ body := string(dashboardHTML)
+ for _, marker := range []string{
+ "kernelDirect=t.engine==='iptables'",
+ "appDirect=t.engine==='forward'",
+ "appDirect?'DIRECT/'+(t.transport||'')",
+ "appDirect&&t.role!=='server'?T('Iran → Kharej')",
+ } {
+ if !strings.Contains(body, marker) {
+ t.Fatalf("dashboard is missing Direct-mode distinction %q", marker)
+ }
+ }
+ if strings.Contains(body, "t.mode==='direct'?'Engine'") {
+ t.Fatal("dashboard still renders every Direct instance as the iptables engine")
}
}
diff --git a/internal/webui/handlers_monitoring.go b/internal/webui/handlers_monitoring.go
index 326679c..2861735 100644
--- a/internal/webui/handlers_monitoring.go
+++ b/internal/webui/handlers_monitoring.go
@@ -102,7 +102,7 @@ func (s *server) handleLinkTest(w http.ResponseWriter, r *http.Request) {
// has no address to probe, and probing a datagram tunnel's port over
// TCP would report a working tunnel as dead.
if t.Role != "client" {
- http.Error(w, "the link test runs on the client (kharej) side — it is the side that dials out", http.StatusBadRequest)
+ http.Error(w, "the link test runs on the side that dials the tunnel (Iran for Direct, Kharej for Reverse)", http.StatusBadRequest)
return
}
if manage.IsDatagram(t.Transport) {
diff --git a/internal/webui/stats.go b/internal/webui/stats.go
index 5b4c26b..b56c0bf 100644
--- a/internal/webui/stats.go
+++ b/internal/webui/stats.go
@@ -434,7 +434,7 @@ func GatherTunnels() []TunnelInfo {
Name: t.Name,
Mode: t.Mode,
Engine: t.Engine,
- Role: t.Role,
+ Role: t.DisplayRole(),
Transport: t.Transport,
Mappings: append([]config.ForwardMapping{}, t.Mappings...),
Addr: t.Addr,
@@ -449,7 +449,7 @@ func GatherTunnels() []TunnelInfo {
Country: manage.TunnelCountry(t.Name),
Ping: -1,
}
- if t.Mode == "direct" {
+ if t.KernelDirect() {
info.State = health[t.Name].State
var rendered []string
for _, m := range t.Mappings {
@@ -781,7 +781,7 @@ func fillConfig(info *TunnelInfo, t manage.Tunnel) {
if err != nil {
return
}
- if t.Mode == "direct" {
+ if t.KernelDirect() {
return
}
if t.Role == "server" {
@@ -814,6 +814,11 @@ func fillConfig(info *TunnelInfo, t manage.Tunnel) {
info.Preset = manage.PresetValueLabel(cc.Preset)
info.LoadBalance = cc.LoadBalance
info.FallbackAddrs = cc.FallbackAddrs
+ if t.AppForward() {
+ info.MaxConnections = cc.MaxConnections
+ info.BandwidthMbps = cc.BandwidthMbps
+ info.ProxyProtocol = cc.ProxyProtocol
+ }
}
}
From 7389db7b7d5622765c62bfcc2455915ac0451c27 Mon Sep 17 00:00:00 2001
From: etm
Date: Fri, 7 Aug 2026 03:00:34 +0330
Subject: [PATCH 4/4] fix: harden direct TCP reconnects under load
---
.../client/transport/forward_pool_test.go | 104 +++++++++++
internal/client/transport/forward_tcp.go | 104 +++++++++--
internal/client/transport/state.go | 23 +++
.../client/transport/state_generation_test.go | 30 ++++
internal/client/transport/tcp.go | 133 +++++++++-----
internal/e2e/forward_tcp_test.go | 168 ++++++++++++++++++
internal/utils/handlers/tcp_handler.go | 15 ++
internal/utils/handlers/tcp_handler_test.go | 20 +++
8 files changed, 538 insertions(+), 59 deletions(-)
create mode 100644 internal/client/transport/forward_pool_test.go
create mode 100644 internal/client/transport/state_generation_test.go
diff --git a/internal/client/transport/forward_pool_test.go b/internal/client/transport/forward_pool_test.go
new file mode 100644
index 0000000..5822d25
--- /dev/null
+++ b/internal/client/transport/forward_pool_test.go
@@ -0,0 +1,104 @@
+package transport
+
+import (
+ "context"
+ "net"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestForwardTCPPoolBoundsConcurrentDialsAndRefills(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ const workers = 3
+ gate := make(chan struct{}, workers+1)
+ var calls atomic.Int32
+ var peersMu sync.Mutex
+ var peers []net.Conn
+ dial := func(ctx context.Context) (net.Conn, error) {
+ calls.Add(1)
+ select {
+ case <-gate:
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ client, peer := net.Pipe()
+ peersMu.Lock()
+ peers = append(peers, peer)
+ peersMu.Unlock()
+ return client, nil
+ }
+
+ pool := newForwardTCPPool(ctx, workers, dial)
+ pool.Start()
+ t.Cleanup(func() {
+ pool.Close()
+ peersMu.Lock()
+ defer peersMu.Unlock()
+ for _, peer := range peers {
+ peer.Close()
+ }
+ })
+
+ waitAtomic(t, &calls, workers)
+ time.Sleep(50 * time.Millisecond)
+ if got := calls.Load(); got != workers {
+ t.Fatalf("pool launched %d concurrent dials, want exactly %d", got, workers)
+ }
+
+ for range workers {
+ gate <- struct{}{}
+ }
+ conn, err := pool.Get(ctx, time.Second)
+ if err != nil {
+ t.Fatalf("get pre-warmed connection: %v", err)
+ }
+ conn.Close()
+
+ // Consuming one slot makes exactly that worker refill it.
+ waitAtomic(t, &calls, workers+1)
+ time.Sleep(50 * time.Millisecond)
+ if got := calls.Load(); got != workers+1 {
+ t.Fatalf("one checkout triggered %d total dials, want %d", got, workers+1)
+ }
+}
+
+func TestForwardTCPPoolCancellationUnblocksWaiter(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ pool := newForwardTCPPool(ctx, 1, func(ctx context.Context) (net.Conn, error) {
+ <-ctx.Done()
+ return nil, ctx.Err()
+ })
+ pool.Start()
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := pool.Get(ctx, time.Minute)
+ done <- err
+ }()
+ cancel()
+
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("Get returned nil after generation cancellation")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Get remained blocked after generation cancellation")
+ }
+}
+
+func waitAtomic(t *testing.T, value *atomic.Int32, want int32) {
+ t.Helper()
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ if value.Load() >= want {
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatalf("counter reached %d, want at least %d", value.Load(), want)
+}
diff --git a/internal/client/transport/forward_tcp.go b/internal/client/transport/forward_tcp.go
index 6428b1c..2f6f6d7 100644
--- a/internal/client/transport/forward_tcp.go
+++ b/internal/client/transport/forward_tcp.go
@@ -1,12 +1,17 @@
package transport
import (
+ "context"
+ "fmt"
"net"
+ "sync"
"sync/atomic"
+ "time"
"github.com/backpack/backpack/internal/forwardmap"
"github.com/backpack/backpack/internal/metrics"
"github.com/backpack/backpack/internal/utils/handlers"
+ "github.com/backpack/backpack/internal/web"
)
type forwardTCPMapping struct {
@@ -30,29 +35,106 @@ func expandForwardTCPMappings(specs []string) ([]forwardTCPMapping, error) {
return out, nil
}
-func (c *TcpTransport) startForwardTCPIngress() {
+type forwardTCPPool struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ size int
+ dial func(context.Context) (net.Conn, error)
+ ready chan net.Conn
+ closed chan struct{}
+ once sync.Once
+}
+
+func newForwardTCPPool(parent context.Context, size int, dial func(context.Context) (net.Conn, error)) *forwardTCPPool {
+ ctx, cancel := context.WithCancel(parent)
+ return &forwardTCPPool{ctx: ctx, cancel: cancel, size: max(1, size), dial: dial, ready: make(chan net.Conn), closed: make(chan struct{})}
+}
+
+func (p *forwardTCPPool) Start() {
+ for i := 0; i < p.size; i++ {
+ go p.worker()
+ }
+}
+
+func (p *forwardTCPPool) worker() {
+ bo := newBackoff(100 * time.Millisecond)
+ for p.ctx.Err() == nil {
+ conn, err := p.dial(p.ctx)
+ if err != nil {
+ if !bo.Wait(p.ctx) {
+ return
+ }
+ continue
+ }
+ // A successful connection means the outage is over. A later failure is
+ // a new outage and should again recover quickly instead of inheriting a
+ // 30-second backoff from an old one.
+ bo = newBackoff(100 * time.Millisecond)
+ select {
+ case p.ready <- conn:
+ // The connection was consumed. Refill this worker's one slot.
+ case <-p.ctx.Done():
+ conn.Close()
+ return
+ }
+ }
+}
+
+func (p *forwardTCPPool) Get(ctx context.Context, timeout time.Duration) (net.Conn, error) {
+ if err := p.ctx.Err(); err != nil {
+ return nil, err
+ }
+ if timeout <= 0 {
+ timeout = 15 * time.Second
+ }
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ select {
+ case conn := <-p.ready:
+ if err := p.ctx.Err(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return conn, nil
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-p.closed:
+ return nil, fmt.Errorf("forward connection pool is closed")
+ case <-timer.C:
+ return nil, fmt.Errorf("timed out waiting for a forward data connection")
+ }
+}
+
+func (p *forwardTCPPool) Close() {
+ p.once.Do(func() {
+ p.cancel()
+ close(p.closed)
+ })
+}
+
+func (c *TcpTransport) startForwardTCPIngress(ctx context.Context, usage *web.Usage, pool *forwardTCPPool) {
mappings, err := expandForwardTCPMappings(c.config.Ports)
if err != nil {
c.logger.Errorf("invalid forward ingress mappings: %v", err)
- go c.Restart()
+ go c.restartGeneration(ctx)
return
}
for _, mapping := range mappings {
mapping := mapping
- go c.runForwardTCPListener(mapping)
+ go c.runForwardTCPListener(ctx, usage, pool, mapping)
}
}
-func (c *TcpTransport) runForwardTCPListener(mapping forwardTCPMapping) {
+func (c *TcpTransport) runForwardTCPListener(ctx context.Context, usage *web.Usage, pool *forwardTCPPool, mapping forwardTCPMapping) {
listener, err := net.Listen("tcp", mapping.listen)
if err != nil {
c.logger.Errorf("failed to listen on forward ingress %s: %v", mapping.listen, err)
- go c.Restart()
+ go c.restartGeneration(ctx)
return
}
defer listener.Close()
go func() {
- <-c.state.Ctx().Done()
+ <-ctx.Done()
_ = listener.Close()
}()
c.logger.Infof("forward ingress listening on %s -> Kharej %s", listener.Addr(), mapping.target)
@@ -60,7 +142,7 @@ func (c *TcpTransport) runForwardTCPListener(mapping forwardTCPMapping) {
for {
local, err := listener.Accept()
if err != nil {
- if c.state.Ctx().Err() != nil {
+ if ctx.Err() != nil {
return
}
c.logger.Warnf("forward ingress accept on %s failed: %v", mapping.listen, err)
@@ -71,7 +153,7 @@ func (c *TcpTransport) runForwardTCPListener(mapping forwardTCPMapping) {
local.Close()
continue
}
- go c.handleForwardTCPIngress(local, mapping.target)
+ go c.handleForwardTCPIngress(ctx, usage, pool, local, mapping.target)
}
}
@@ -91,10 +173,10 @@ func (c *TcpTransport) acquireForwardSlot() bool {
}
}
-func (c *TcpTransport) handleForwardTCPIngress(local net.Conn, target string) {
+func (c *TcpTransport) handleForwardTCPIngress(ctx context.Context, usage *web.Usage, pool *forwardTCPPool, local net.Conn, target string) {
defer atomic.AddInt32(&c.loadConnections, -1)
local = c.forwardBandwidth.wrap(local)
- tunnel, err := c.openForwardTCP(target)
+ tunnel, err := c.openForwardTCP(ctx, pool, target)
if err != nil {
c.logger.Warnf("could not open forward channel for %s: %v", target, err)
local.Close()
@@ -104,5 +186,5 @@ func (c *TcpTransport) handleForwardTCPIngress(local net.Conn, target string) {
if tcpAddr, ok := local.LocalAddr().(*net.TCPAddr); ok {
port = tcpAddr.Port
}
- handlers.TCPConnectionHandler(c.state.Ctx(), c.config.ProxyProtocol, local, metrics.CountedConn(tunnel), c.logger, c.state.Usage(), port, c.config.Sniffer)
+ handlers.TCPConnectionHandler(ctx, c.config.ProxyProtocol, local, metrics.CountedConn(tunnel), c.logger, usage, port, c.config.Sniffer)
}
diff --git a/internal/client/transport/state.go b/internal/client/transport/state.go
index d6a19bd..a48e0d0 100644
--- a/internal/client/transport/state.go
+++ b/internal/client/transport/state.go
@@ -71,6 +71,29 @@ func (s *clientState) SetConn(c net.Conn) {
s.conn = c
}
+// SetConnFor publishes a control connection only when ctx still identifies
+// the current generation. A dial can finish after Restart has already
+// installed the next generation; publishing that late result would otherwise
+// let an old goroutine replace the new run's control channel.
+func (s *clientState) SetConnFor(ctx context.Context, c net.Conn) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.ctx != ctx {
+ return false
+ }
+ s.conn = c
+ return true
+}
+
+// IsCurrent reports whether ctx belongs to the currently published
+// generation. It is used before a failing old worker requests a restart, so a
+// late error cannot tear down a healthy replacement generation.
+func (s *clientState) IsCurrent(ctx context.Context) bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.ctx == ctx
+}
+
func (s *clientState) WSConn() *websocket.Conn {
s.mu.RLock()
defer s.mu.RUnlock()
diff --git a/internal/client/transport/state_generation_test.go b/internal/client/transport/state_generation_test.go
new file mode 100644
index 0000000..d0a3faf
--- /dev/null
+++ b/internal/client/transport/state_generation_test.go
@@ -0,0 +1,30 @@
+package transport
+
+import (
+ "context"
+ "net"
+ "testing"
+)
+
+func TestClientStateRejectsRetiredGenerationConnection(t *testing.T) {
+ oldCtx, oldCancel := context.WithCancel(context.Background())
+ var state clientState
+ state.Reset(oldCtx, oldCancel, nil)
+
+ newCtx, newCancel := context.WithCancel(context.Background())
+ defer newCancel()
+ state.Reset(newCtx, newCancel, nil)
+
+ late, peer := net.Pipe()
+ defer late.Close()
+ defer peer.Close()
+ if state.SetConnFor(oldCtx, late) {
+ t.Fatal("retired generation published a late control connection")
+ }
+ if state.Conn() != nil {
+ t.Fatal("late retired connection replaced the current control connection")
+ }
+ if state.IsCurrent(oldCtx) || !state.IsCurrent(newCtx) {
+ t.Fatal("generation identity check returned the wrong result")
+ }
+}
diff --git a/internal/client/transport/tcp.go b/internal/client/transport/tcp.go
index 5c98faf..0fd7403 100644
--- a/internal/client/transport/tcp.go
+++ b/internal/client/transport/tcp.go
@@ -67,7 +67,8 @@ type TcpConfig struct {
Stealth bool
// Forward turns this dialler into the Iran edge: it keeps the ordinary
// authenticated control channel, owns the public ingress listeners, and
- // opens one authenticated data connection per accepted user connection.
+ // checks out one authenticated, pre-warmed data connection per accepted
+ // user connection.
Forward bool
Ports []string
AcceptUDP bool
@@ -109,20 +110,33 @@ func NewTCPClient(parentCtx context.Context, config *TcpConfig, logger *logrus.L
}
func (c *TcpTransport) Start() {
+ ctx := c.state.Ctx()
+ usage := c.state.Usage()
if c.config.WebPort > 0 {
- go c.state.Usage().Monitor()
+ go usage.Monitor()
}
c.config.TunnelStatus = "Disconnected (TCP)"
- go c.channelDialer()
+ go c.channelDialer(ctx, usage)
}
func (c *TcpTransport) Restart() {
+ c.restartGeneration(nil)
+}
+
+// restartGeneration replaces only the generation that actually failed. An
+// error from a goroutine belonging to a previous run is expected while that
+// run drains and must never cancel the healthy run that replaced it.
+func (c *TcpTransport) restartGeneration(failed context.Context) {
if !c.restartMutex.TryLock() {
c.logger.Warn("client is already restarting")
return
}
defer c.restartMutex.Unlock()
+ if failed != nil && !c.state.IsCurrent(failed) {
+ c.logger.Debug("ignoring restart request from a retired client generation")
+ return
+ }
c.logger.Info("restarting client...")
@@ -175,7 +189,7 @@ func (c *TcpTransport) Restart() {
go c.Start()
}
-func (c *TcpTransport) channelDialer() {
+func (c *TcpTransport) channelDialer(ctx context.Context, usage *web.Usage) {
c.logger.Info("attempting to establish a new control channel connection...")
// One backoff for this reconnect loop: retries start at the configured
@@ -184,11 +198,11 @@ func (c *TcpTransport) channelDialer() {
for {
select {
- case <-c.state.Ctx().Done():
+ case <-ctx.Done():
return
default:
//set default behaviour of control channel to nodelay, also using default buffer parameters
- rawConn, err := network.TcpDialerVia(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Current(), c.config.DialTimeOut, c.config.KeepAlive, true, 3, 0, 0, 0)
+ rawConn, err := network.TcpDialerVia(ctx, c.config.Outbound, c.config.Endpoints.Current(), c.config.DialTimeOut, c.config.KeepAlive, true, 3, 0, 0, 0)
if err != nil {
c.logger.Errorf("channel dialer: %v", err)
// The current endpoint did not answer — move to the next one so a
@@ -196,7 +210,7 @@ func (c *TcpTransport) channelDialer() {
if next := c.config.Endpoints.Rotate(); c.config.Endpoints.Len() > 1 {
c.logger.Infof("trying next server endpoint: %s", next)
}
- bo.Wait(c.state.Ctx())
+ bo.Wait(ctx)
continue
}
@@ -207,7 +221,7 @@ func (c *TcpTransport) channelDialer() {
if err != nil {
c.logger.Errorf("channel dialer: stealth handshake failed: %v", err)
rawConn.Close()
- bo.Wait(c.state.Ctx())
+ bo.Wait(ctx)
continue
}
@@ -219,7 +233,7 @@ func (c *TcpTransport) channelDialer() {
if err != nil {
c.logger.Errorf("failed to send security token: %v", err)
tunnelTCPConn.Close()
- bo.Wait(c.state.Ctx())
+ bo.Wait(ctx)
continue
}
@@ -227,7 +241,7 @@ func (c *TcpTransport) channelDialer() {
if err := tunnelTCPConn.SetReadDeadline(time.Now().Add(controlAckTimeout)); err != nil {
c.logger.Errorf("failed to set read deadline: %v", err)
tunnelTCPConn.Close()
- bo.Wait(c.state.Ctx())
+ bo.Wait(ctx)
continue
}
@@ -241,7 +255,7 @@ func (c *TcpTransport) channelDialer() {
noteLegacyServer(c.logger, &c.legacyServer, signal)
}
tunnelTCPConn.Close() // Close connection on error or timeout
- bo.Wait(c.state.Ctx())
+ bo.Wait(ctx)
continue
}
// Resetting the deadline (removes any existing deadline)
@@ -255,44 +269,51 @@ func (c *TcpTransport) channelDialer() {
// Before the control channel is published, so the pool
// connections poolMaintainer starts below already have it.
c.poolNonce.Set(nonce)
- c.state.SetConn(tunnelTCPConn)
+ if !c.state.SetConnFor(ctx, tunnelTCPConn) {
+ tunnelTCPConn.Close()
+ return
+ }
c.logger.Info("control channel established successfully")
c.config.TunnelStatus = "Connected (TCP)"
- go c.channelHandler()
+ go c.channelHandler(ctx, usage, tunnelTCPConn, nonce)
if c.config.Forward {
if nonce == "" {
c.logger.Error("forward mode requires the authenticated v2 control handshake; peer is too old")
tunnelTCPConn.Close()
- bo.Wait(c.state.Ctx())
+ bo.Wait(ctx)
continue
}
- go c.startForwardTCPIngress()
+ generation := ctx
+ pool := newForwardTCPPool(generation, max(1, c.config.ConnPoolSize), func(poolCtx context.Context) (net.Conn, error) {
+ return c.newForwardDataConn(poolCtx, generation, nonce)
+ })
+ pool.Start()
+ go c.startForwardTCPIngress(ctx, usage, pool)
return
}
- go c.poolMaintainer()
+ go c.poolMaintainer(ctx, usage, nonce)
return
} else {
c.logger.Errorf("invalid token received (does not match the server's token). Retrying...")
tunnelTCPConn.Close() // Close connection if the token is invalid
- bo.Wait(c.state.Ctx())
+ bo.Wait(ctx)
continue
}
}
}
}
-// openForwardTCP creates the data connection proactively from the Iran edge.
-// It returns only after the Kharej origin has successfully dialled the target,
-// so an unavailable backend becomes a prompt close instead of a black hole.
-func (c *TcpTransport) openForwardTCP(target string) (net.Conn, error) {
- nonce := c.poolNonce.Get()
- if nonce == "" || c.state.Conn() == nil {
+// newForwardDataConn creates and authenticates one pre-warmed connection from
+// the Iran edge. The target is deliberately sent only when a user checks it
+// out, so one bounded pool can serve every configured mapping.
+func (c *TcpTransport) newForwardDataConn(ctx, generation context.Context, nonce string) (net.Conn, error) {
+ if nonce == "" || !c.state.IsCurrent(generation) {
return nil, fmt.Errorf("forward control channel is not ready")
}
- raw, err := network.TcpDialerVia(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 3, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS)
+ raw, err := network.TcpDialerVia(ctx, c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 1, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS)
if err != nil {
return nil, fmt.Errorf("dial forward origin: %w", err)
}
@@ -308,6 +329,22 @@ func (c *TcpTransport) openForwardTCP(target string) (net.Conn, error) {
if err := utils.SendBinaryTransportString(conn, nonce, utils.SG_ForwardTCP); err != nil {
return fail(fmt.Errorf("announce forward data connection: %w", err))
}
+ return conn, nil
+}
+
+// openForwardTCP checks out one authenticated, pre-warmed data connection and
+// completes the target handshake. The bounded worker pool absorbs connection
+// bursts without launching one expensive dial/Noise handshake per user at the
+// same instant.
+func (c *TcpTransport) openForwardTCP(ctx context.Context, pool *forwardTCPPool, target string) (net.Conn, error) {
+ conn, err := pool.Get(ctx, c.config.DialTimeOut)
+ if err != nil {
+ return nil, err
+ }
+ fail := func(e error) (net.Conn, error) {
+ conn.Close()
+ return nil, e
+ }
if err := utils.SendBinaryString(conn, target); err != nil {
return fail(fmt.Errorf("send forward target: %w", err))
}
@@ -325,9 +362,9 @@ func (c *TcpTransport) openForwardTCP(target string) (net.Conn, error) {
return conn, nil
}
-func (c *TcpTransport) poolMaintainer() {
+func (c *TcpTransport) poolMaintainer(ctx context.Context, usage *web.Usage, nonce string) {
for i := 0; i < c.config.ConnPoolSize; i++ { //initial pool filling
- go c.tunnelDialer()
+ go c.tunnelDialer(ctx, usage, nonce)
}
// factors
@@ -356,7 +393,7 @@ func (c *TcpTransport) poolMaintainer() {
for {
select {
- case <-c.state.Ctx().Done():
+ case <-ctx.Done():
return
case <-tickerPool.C:
@@ -390,7 +427,7 @@ func (c *TcpTransport) poolMaintainer() {
newPoolSize++
// Add a new connection to the pool
- go c.tunnelDialer()
+ go c.tunnelDialer(ctx, usage, nonce)
} else if float64(loadConnections+x) < float64(poolConnectionsAvg)*y && newPoolSize > c.config.ConnPoolSize {
c.logger.Debugf("decreasing pool size: %d -> %d, avg pool conn: %d, avg load conn: %d", newPoolSize, newPoolSize-1, poolConnectionsAvg, loadConnections)
newPoolSize--
@@ -403,21 +440,21 @@ func (c *TcpTransport) poolMaintainer() {
}
-func (c *TcpTransport) channelHandler() {
+func (c *TcpTransport) channelHandler(ctx context.Context, usage *web.Usage, control net.Conn, nonce string) {
msgChan := make(chan byte, 1000)
// Goroutine to handle the blocking ReceiveBinaryString
go func() {
for {
select {
- case <-c.state.Ctx().Done():
+ case <-ctx.Done():
return
default:
- msg, err := utils.ReceiveBinaryByte(c.state.Conn())
+ msg, err := utils.ReceiveBinaryByte(control)
if err != nil {
- if c.state.Cancel() != nil {
+ if ctx.Err() == nil {
c.logger.Error("failed to read from control channel. ", err)
- go c.Restart()
+ go c.restartGeneration(ctx)
}
return
}
@@ -429,8 +466,8 @@ func (c *TcpTransport) channelHandler() {
// Main loop to listen for context cancellation or received messages
for {
select {
- case <-c.state.Ctx().Done():
- _ = utils.SendBinaryByte(c.state.Conn(), utils.SG_Closed)
+ case <-ctx.Done():
+ _ = utils.SendBinaryByte(control, utils.SG_Closed)
return
case msg := <-msgChan:
@@ -443,7 +480,7 @@ func (c *TcpTransport) channelHandler() {
default:
c.logger.Debug("channel signal received, initiating tunnel dialer")
- go c.tunnelDialer()
+ go c.tunnelDialer(ctx, usage, nonce)
}
case utils.SG_HB:
@@ -451,20 +488,20 @@ func (c *TcpTransport) channelHandler() {
case utils.SG_Closed:
c.logger.Warn("control channel has been closed by the server")
- go c.Restart()
+ go c.restartGeneration(ctx)
return
case utils.SG_RTT:
- err := utils.SendBinaryByte(c.state.Conn(), utils.SG_RTT)
+ err := utils.SendBinaryByte(control, utils.SG_RTT)
if err != nil {
c.logger.Error("failed to send RTT signal, restarting client: ", err)
- go c.Restart()
+ go c.restartGeneration(ctx)
return
}
default:
c.logger.Errorf("unexpected response from channel: %v.", msg)
- go c.Restart()
+ go c.restartGeneration(ctx)
return
}
}
@@ -472,14 +509,14 @@ func (c *TcpTransport) channelHandler() {
}
// Dialing to the tunnel server, chained functions, without retry
-func (c *TcpTransport) tunnelDialer() {
+func (c *TcpTransport) tunnelDialer(ctx context.Context, usage *web.Usage, nonce string) {
c.logger.Debugf("initiating new connection to tunnel server at %s", c.config.RemoteAddr)
// Dial to the tunnel server
// Next() rather than Current(): with load balancing enabled the pool
// spreads its connections over every configured endpoint, so one
// congested route only slows its own share of the traffic.
- rawConn, err := network.TcpDialerVia(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 3, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS)
+ rawConn, err := network.TcpDialerVia(ctx, c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 3, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS)
if err != nil {
c.logger.Error("tunnel server dialer: ", err)
@@ -497,7 +534,7 @@ func (c *TcpTransport) tunnelDialer() {
// Say what this connection is, so the server admits it on the nonce rather
// than on the address it happened to dial out from.
- if err := announcePoolConn(tcpConn, c.poolNonce.Get()); err != nil {
+ if err := announcePoolConn(tcpConn, nonce); err != nil {
c.logger.Debugf("tunnel dialer: failed to announce the pool connection: %v", err)
tcpConn.Close()
return
@@ -529,10 +566,10 @@ func (c *TcpTransport) tunnelDialer() {
switch transport {
case utils.SG_TCP:
// Dial local server using the received address
- c.localDialer(tcpConn, resolvedAddr, port)
+ c.localDialer(ctx, usage, tcpConn, resolvedAddr, port)
case utils.SG_UDP:
- UDPDialer(tcpConn, resolvedAddr, c.logger, c.state.Usage(), port, c.config.Sniffer)
+ UDPDialer(tcpConn, resolvedAddr, c.logger, usage, port, c.config.Sniffer)
default:
c.logger.Error("undefined transport. close the connection.")
@@ -540,7 +577,7 @@ func (c *TcpTransport) tunnelDialer() {
}
}
-func (c *TcpTransport) localDialer(tcpConn net.Conn, resolvedAddr string, port int) {
+func (c *TcpTransport) localDialer(ctx context.Context, usage *web.Usage, tcpConn net.Conn, resolvedAddr string, port int) {
// Pick a healthy backend when several are configured; a single backend is
// returned unchanged, so ordinary tunnels are untouched.
resolvedAddr = backends.pick(resolvedAddr)
@@ -556,7 +593,7 @@ func (c *TcpTransport) localDialer(tcpConn net.Conn, resolvedAddr string, port i
recvBuf = c.config.SO_RCVBUF
}
- localConnection, err := network.TcpDialer(c.state.Ctx(), resolvedAddr, c.config.DialTimeOut, c.config.KeepAlive, true, 1, recvBuf, sendBuf, c.config.MSS)
+ localConnection, err := network.TcpDialer(ctx, resolvedAddr, c.config.DialTimeOut, c.config.KeepAlive, true, 1, recvBuf, sendBuf, c.config.MSS)
if err != nil {
localDial.Report(c.logger, resolvedAddr, err)
tcpConn.Close()
@@ -565,5 +602,5 @@ func (c *TcpTransport) localDialer(tcpConn net.Conn, resolvedAddr string, port i
c.logger.Debugf("connected to local address %s successfully", resolvedAddr)
- handlers.TCPConnectionHandler(c.state.Ctx(), false, metrics.CountedConn(tcpConn), localConnection, c.logger, c.state.Usage(), port, c.config.Sniffer)
+ handlers.TCPConnectionHandler(ctx, false, metrics.CountedConn(tcpConn), localConnection, c.logger, usage, port, c.config.Sniffer)
}
diff --git a/internal/e2e/forward_tcp_test.go b/internal/e2e/forward_tcp_test.go
index 65632ae..1bfab59 100644
--- a/internal/e2e/forward_tcp_test.go
+++ b/internal/e2e/forward_tcp_test.go
@@ -4,9 +4,11 @@ import (
"context"
"fmt"
"sync"
+ "sync/atomic"
"testing"
"time"
+ "github.com/backpack/backpack/config"
"github.com/backpack/backpack/internal/client"
"github.com/backpack/backpack/internal/server"
)
@@ -20,6 +22,172 @@ func TestForwardStreamTransports(t *testing.T) {
}
}
+// A browser, game gateway or proxy can open hundreds of connections at once.
+// Direct TCP/Stealth must queue the expensive outer handshakes behind the
+// configured pool instead of turning that burst into an outbound dial storm.
+func TestForwardTCPBurst(t *testing.T) {
+ for _, transport := range []string{"tcp", "stealth"} {
+ t.Run(transport, func(t *testing.T) {
+ backend := startEchoBackend(t)
+ tunnelPort, entryPort := freePort(t), freePort(t)
+ token := "forward-burst-token-0123456789abcdef"
+ origin := baseServerConfig(transport, tunnelPort, 1, backend.addr, token)
+ origin.Ports = nil
+ edge := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil)
+ edge.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backend.addr)}
+ edge.ConnectionPool = 4
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var wg sync.WaitGroup
+ srv := server.NewForwardOrigin(origin, ctx)
+ wg.Add(1)
+ go func() { defer wg.Done(); srv.Start() }()
+ time.Sleep(300 * time.Millisecond)
+ cli := client.NewForwardEdge(edge, ctx)
+ wg.Add(1)
+ go func() { defer wg.Done(); cli.Start() }()
+ tun := &tunnel{Entry: fmt.Sprintf("127.0.0.1:%d", entryPort), TunnelPort: tunnelPort, cancel: cancel, wg: &wg}
+ t.Cleanup(tun.Stop)
+ if err := tun.waitReady(tunnelReadyTimeout); err != nil {
+ t.Fatalf("forward tunnel never became ready: %v", err)
+ }
+
+ payload := randomPayload(t, 128*1024)
+ const users = 64
+ var failures atomic.Int32
+ var burst sync.WaitGroup
+ burst.Add(users)
+ for range users {
+ go func() {
+ defer burst.Done()
+ if err := tun.roundTrip(payload); err != nil {
+ failures.Add(1)
+ }
+ }()
+ }
+ burst.Wait()
+ if got := failures.Load(); got != 0 {
+ t.Fatalf("%d of %d simultaneous forward users failed", got, users)
+ }
+ })
+ }
+}
+
+func TestLargePayloadAcrossTCPDirections(t *testing.T) {
+ for _, transport := range []string{"tcp", "stealth"} {
+ t.Run("direct/"+transport, func(t *testing.T) {
+ backend := startEchoBackend(t)
+ tunnelPort, entryPort := freePort(t), freePort(t)
+ token := "forward-large-token-0123456789abcdef"
+ origin := baseServerConfig(transport, tunnelPort, 1, backend.addr, token)
+ origin.Ports = nil
+ edge := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil)
+ edge.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backend.addr)}
+ tun := runForwardPair(t, origin, edge, entryPort, tunnelPort)
+ if err := tun.waitReady(tunnelReadyTimeout); err != nil {
+ t.Fatal(err)
+ }
+ if err := tun.roundTrip(randomPayload(t, 16*1024*1024)); err != nil {
+ t.Fatalf("large direct payload: %v", err)
+ }
+ })
+
+ t.Run("reverse/"+transport, func(t *testing.T) {
+ backend := startEchoBackend(t)
+ tunnelPort, entryPort := freePort(t), freePort(t)
+ token := "reverse-large-token-0123456789abcdef"
+ srvCfg := baseServerConfig(transport, tunnelPort, entryPort, backend.addr, token)
+ cliCfg := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil)
+ tun := runPair(t, srvCfg, cliCfg, entryPort, tunnelPort)
+ if err := tun.waitReady(tunnelReadyTimeout); err != nil {
+ t.Fatal(err)
+ }
+ if err := tun.roundTrip(randomPayload(t, 16*1024*1024)); err != nil {
+ t.Fatalf("large reverse payload: %v", err)
+ }
+ })
+ }
+}
+
+// Reconnecting the control channel creates a new client generation. The old
+// generation must release the public ingress listener before the replacement
+// binds it; this is the regression test for the production "address already
+// in use" restart loop.
+func TestForwardTCPRecoversAfterOriginRestart(t *testing.T) {
+ for _, transport := range []string{"tcp", "stealth"} {
+ t.Run(transport, func(t *testing.T) {
+ backend := startEchoBackend(t)
+ tunnelPort, entryPort := freePort(t), freePort(t)
+ token := "forward-restart-token-0123456789abcdef"
+ originCfg := baseServerConfig(transport, tunnelPort, 1, backend.addr, token)
+ originCfg.Ports = nil
+ edgeCfg := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil)
+ edgeCfg.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backend.addr)}
+
+ edgeCtx, stopEdge := context.WithCancel(context.Background())
+ edge := client.NewForwardEdge(edgeCfg, edgeCtx)
+
+ startOrigin := func() (context.CancelFunc, <-chan struct{}) {
+ originCtx, stopOrigin := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ origin := server.NewForwardOrigin(originCfg, originCtx)
+ go func() {
+ defer close(done)
+ origin.Start()
+ }()
+ return stopOrigin, done
+ }
+
+ stopOrigin, originDone := startOrigin()
+ time.Sleep(300 * time.Millisecond)
+ edgeDone := make(chan struct{})
+ go func() { defer close(edgeDone); edge.Start() }()
+ tun := &tunnel{Entry: fmt.Sprintf("127.0.0.1:%d", entryPort)}
+ t.Cleanup(func() {
+ stopEdge()
+ stopOrigin()
+ for name, done := range map[string]<-chan struct{}{"origin": originDone, "edge": edgeDone} {
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Errorf("%s did not stop during cleanup", name)
+ }
+ }
+ })
+ if err := tun.waitReady(tunnelReadyTimeout); err != nil {
+ t.Fatalf("initial forward generation never became ready: %v", err)
+ }
+
+ stopOrigin()
+ select {
+ case <-originDone:
+ case <-time.After(5 * time.Second):
+ t.Fatal("old origin did not stop")
+ }
+ stopOrigin, originDone = startOrigin()
+ if err := tun.waitReady(tunnelReadyTimeout); err != nil {
+ t.Fatalf("forward tunnel did not recover after origin restart: %v", err)
+ }
+ })
+ }
+}
+
+func runForwardPair(t *testing.T, originCfg *config.ServerConfig, edgeCfg *config.ClientConfig, entryPort, tunnelPort int) *tunnel {
+ t.Helper()
+ ctx, cancel := context.WithCancel(context.Background())
+ var wg sync.WaitGroup
+ origin := server.NewForwardOrigin(originCfg, ctx)
+ wg.Add(1)
+ go func() { defer wg.Done(); origin.Start() }()
+ time.Sleep(300 * time.Millisecond)
+ edge := client.NewForwardEdge(edgeCfg, ctx)
+ wg.Add(1)
+ go func() { defer wg.Done(); edge.Start() }()
+ tun := &tunnel{Entry: fmt.Sprintf("127.0.0.1:%d", entryPort), TunnelPort: tunnelPort, cancel: cancel, wg: &wg}
+ t.Cleanup(tun.Stop)
+ return tun
+}
+
func testForwardStreamTransport(t *testing.T, transport string) {
backend := startEchoBackend(t)
tunnelPort := freePort(t)
diff --git a/internal/utils/handlers/tcp_handler.go b/internal/utils/handlers/tcp_handler.go
index 2dc95fa..0af8b3a 100644
--- a/internal/utils/handlers/tcp_handler.go
+++ b/internal/utils/handlers/tcp_handler.go
@@ -13,6 +13,21 @@ import (
func TCPConnectionHandler(ctx context.Context, proxyProtocol bool, from net.Conn, to net.Conn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) {
done := make(chan struct{})
+ stopWatch := make(chan struct{})
+ defer close(stopWatch)
+
+ // Relay reads are intentionally blocking, so merely selecting on ctx after
+ // one direction finishes cannot stop an idle connection. Close both sockets
+ // as soon as the generation ends; that wakes both copy loops and guarantees
+ // reconnect/reload does not retain old file descriptors indefinitely.
+ go func() {
+ select {
+ case <-ctx.Done():
+ from.Close()
+ to.Close()
+ case <-stopWatch:
+ }
+ }()
// Write Proxy Protocol V2 Header
if proxyProtocol {
diff --git a/internal/utils/handlers/tcp_handler_test.go b/internal/utils/handlers/tcp_handler_test.go
index 3fb06a5..221a4cd 100644
--- a/internal/utils/handlers/tcp_handler_test.go
+++ b/internal/utils/handlers/tcp_handler_test.go
@@ -99,6 +99,26 @@ func TestRelayCarriesBothDirections(t *testing.T) {
}
}
+func TestRelayCancellationClosesIdleConnections(t *testing.T) {
+ client, from := tcpPair(t)
+ to, backend := tcpPair(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ TCPConnectionHandler(ctx, false, from, to, quietLogger(), &web.Usage{}, 8080, false)
+ }()
+
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("idle relay did not stop when its generation was cancelled")
+ }
+ client.Close()
+ backend.Close()
+}
+
// When either side goes away the handler closes both connections, so a forwarded
// connection can never be left half open holding a socket open forever.
func TestRelayClosesBothEnds(t *testing.T) {