Files
x/handler/tunnel/tunnel.go
T
ginuerzh 31878a72ec 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)
2026-06-02 18:36:52 +08:00

187 lines
3.7 KiB
Go

package tunnel
import (
"context"
"sync"
"time"
"github.com/go-gost/core/sd"
"github.com/go-gost/relay"
"github.com/go-gost/x/selector"
)
// MaxWeight is the exclusive takeover weight (255). A connector at MaxWeight
// triggers rw.Reset() in GetConnector, clearing all previously added connectors
// so that only this one is selected. If that MaxWeight connector later dies
// (IsClosed), GetConnector returns nil until Tunnel.clean() removes it.
const (
MaxWeight uint8 = 0xff
)
// Tunnel groups Connectors that share the same tunnel ID.
//
// Connector selection (GetConnector):
// - Single connector: fast path, no weighted random.
// - Multiple connectors: weighted random selection by network type (tcp/udp).
// - MaxWeight (255): exclusive — triggers Reset() and only the MaxWeight
// connector is added. If the MaxWeight connector closes, selection returns
// nil until clean() removes it.
//
// Tunnel lifecycle:
// - NewTunnel starts a clean() goroutine that runs every TTL (default 15s).
// Each tick removes closed connectors and renews SD registrations.
// A tunnel with 0 connectors gets cleaned up.
// - CloseOnIdle closes the tunnel if it has 0 connectors (called by
// ConnectorPool.closeIdles on a 15-minute ticker).
// - Close() closes all connectors and signals shutdown.
type Tunnel struct {
node string
id relay.TunnelID
connectors []*Connector
t time.Time
close chan struct{}
mu sync.RWMutex
ttl time.Duration
}
func NewTunnel(node string, tid relay.TunnelID, ttl time.Duration) *Tunnel {
t := &Tunnel{
node: node,
id: tid,
t: time.Now(),
close: make(chan struct{}),
ttl: ttl,
}
if t.ttl <= 0 {
t.ttl = defaultTTL
}
go t.clean()
return t
}
func (t *Tunnel) ID() relay.TunnelID {
return t.id
}
func (t *Tunnel) AddConnector(c *Connector) {
if c == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.connectors = append(t.connectors, c)
}
func (t *Tunnel) GetConnector(network string) *Connector {
t.mu.RLock()
defer t.mu.RUnlock()
if len(t.connectors) == 1 {
if t.connectors[0].IsClosed() {
return nil
}
return t.connectors[0]
}
rw := selector.NewRandomWeighted[*Connector]()
found := false
for _, c := range t.connectors {
if c.IsClosed() {
continue
}
weight := c.ID().Weight()
if weight == 0 {
weight = 1
}
if network == "udp" && c.id.IsUDP() ||
network != "udp" && !c.id.IsUDP() {
if weight == MaxWeight && !found {
rw.Reset()
found = true
}
if weight == MaxWeight || !found {
rw.Add(c, int(weight))
}
}
}
return rw.Next()
}
func (t *Tunnel) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
select {
case <-t.close:
default:
for _, c := range t.connectors {
c.Close()
}
close(t.close)
}
return nil
}
func (t *Tunnel) CloseOnIdle() bool {
t.mu.Lock()
defer t.mu.Unlock()
select {
case <-t.close:
default:
if len(t.connectors) == 0 {
close(t.close)
return true
}
}
return false
}
func (t *Tunnel) clean() {
ticker := time.NewTicker(t.ttl)
defer ticker.Stop()
for {
select {
case <-ticker.C:
t.mu.Lock()
if len(t.connectors) == 0 {
t.mu.Unlock()
break
}
var connectors []*Connector
for _, c := range t.connectors {
if c.IsClosed() {
c.log.Debugf("remove connector: %s %s", t.id, c.id)
continue
}
connectors = append(connectors, c)
if c.opts.sd != nil {
c.opts.sd.Renew(context.Background(), &sd.Service{
ID: c.id.String(),
Name: t.id.String(),
Node: t.node,
})
}
}
if len(connectors) != len(t.connectors) {
t.connectors = connectors
}
t.mu.Unlock()
case <-t.close:
return
}
}
}