docs(handler/tunnel): add architecture documentation and fix observeStats event loss

- Add package-level architecture doc in handler.go covering:
  - NAT traversal reverse proxy architecture
  - CmdBind/CmdConnect roles and data flow
  - Connector lifecycle and waitClose semantics
  - Entrypoint protocol dispatch (first-byte sniffing)
  - SD fallback behavior
- Add data-flow-oriented doc comments to all business files:
  - entrypoint.go: protocol dispatch, dial flow
  - connector.go: Connector/ConnectorPool semantics
  - dialer.go: two-phase dial strategy
  - tunnel.go: MaxWeight semantics, selection algorithm
  - bind.go: 6-step CmdBind flow
  - connect.go: CmdConnect flow with relay framing
  - ephttp.go, eptls.go, eprelay.go: per-protocol entrypoint flow
- Fix observeStats: after successful error retry, also flush new events
  instead of skipping the current tick (handler.go:261-271)
- Add test for observeStats retry-then-flush (handler_test.go)
This commit is contained in:
ginuerzh
2026-06-02 18:36:52 +08:00
parent e791ba47e2
commit 31878a72ec
12 changed files with 264 additions and 3 deletions
+56 -1
View File
@@ -1,3 +1,54 @@
// Package tunnel implements a reverse proxy tunnel handler for NAT traversal.
//
// Architecture overview
//
// The tunnel handler is deployed on the public-facing (server) side. It acts as a
// bridge between external clients and internal services behind NAT/firewall.
//
// There are two main roles:
//
// 1. Internal client (CmdBind) — connects to the tunnel handler and registers a
// multiplexed session (mux.Session via smux) as a Connector. Once bound, this
// client passively waits to receive streams from the public side.
//
// 2. Public entrypoints (CmdConnect + entrypoint) — accept incoming requests from
// the Internet and forward them through the tunnel to the internal client via
// mux streams (OpenStream).
//
// Data flow (normal direction: public → internal):
//
// Public request → tunnelHandler.Handle() / entrypoint.Handle()
// → Dialer.Dial()
// → ConnectorPool.Get() → Tunnel.GetConnector() → Connector.GetConn()
// → mux.Session.OpenStream() ← creates stream to internal side
// → Pipe(publicConn, muxStream)
//
// Internal client side:
//
// mux.Session.AcceptStream() ← receives the stream
// → processes request, sends response back through the same stream
//
// Connector lifecycle (CmdBind):
//
// Internal client sends CmdBind → handleBind() creates mux.ClientSession
// → NewConnector (stores session, starts waitClose goroutine)
// → ConnectorPool.Add() → Tunnel.AddConnector()
// → ingress rules + SD service registered
//
// The waitClose goroutine (Connector.waitClose) discards unexpected inbound
// streams on the Connector's mux session. This is a safety guard — normal
// request streams arrive via OpenStream from the public side and are handled
// by the internal client's Accept loop, NOT by waitClose.
//
// Entrypoint protocol dispatch (first-byte sniffing):
//
// relay.Version1 (0x52 'R') → handleConnect (relay protocol)
// dissector.Handshake (0x16) → handleTLS (TLS passthrough)
// otherwise → handleHTTP (HTTP forward proxy)
//
// SD fallback: when ConnectorPool.Get() returns nil (no local tunnel registered),
// Dialer queries service discovery for a remote node address and establishes a
// direct TCP connection, bypassing the mux session entirely.
package tunnel
import (
@@ -261,8 +312,12 @@ func (h *tunnelHandler) observeStats(ctx context.Context) {
if len(events) > 0 {
if err := h.options.Observer.Observe(ctx, events); err == nil {
events = nil
// Retry succeeded — also flush new events this tick.
// Fall through without break to reach the normal path.
} else {
// Retry still failing — skip new events, try again next tick.
break
}
break
}
evs := h.stats.Events()