Files
shater/shater/engine/httpclient.go
T
omarandClaude Opus 5 4078334d85 fix(stats,alert,panel): put a ceiling on everything that only grew
Four maps had no bound on a box with 512 MB that runs for months. The health
board only ever inserted — the delete exists but no path in this fork calls it —
and it lives on the engine context, so it outlives every generation. Its keys are
node tags, and providers rename nodes on each subscription refresh: about 440k
keys a year, some 88 MB. Alert dedup keyed on MAC with no delete at all. The
stats aggregator's server and outbound counters were the only ones with no cap,
no prune and no top-N, and one of them was handed to the panel whole on every
poll.

They are bounded now, evicting least-recently-seen, with numbers argued from this
box rather than round: the board holds 4096 against a live generation of about
1200 tags, so a rename day cannot evict a tag still in use. Nothing is dropped
silently — the same rule the log sink already follows — and a new Dropped section
in the snapshot reports all six bounded aggregates, including the three that had
been evicting without saying so.

Snapshot did O(devices × domains) under the aggregator lock, sorting five
thousand entries to show fifteen, and could read the DHCP lease file from inside
it. Meanwhile the event subscribers have 64-slot buffers that drop without a
counter, so an open Overview page cost the query log real rows. Selection is
top-K now — proven byte-identical to the old sort over 200 random trials — and
both the lease read and the row ordering happen outside the lock.

The panel server had one timeout, on headers. An unauthenticated client could
hold a goroutine, a socket and a descriptor forever by sending its body one byte
at a time; a stopped reader on the log stream held the handler, the pipe and a
child process that outlived the request. Every phase is bounded now, with the
unauthenticated route on a tighter budget than the rest, and the log stream
renewing its deadline per chunk so a slow-but-reading client is never truncated.

And the last of the detour transports: each call built a fresh one, and the alert
delivery path dropped it, pinning keep-alive sessions through the engine's own
outbounds for 90 seconds — eighteen times the budget a retiring generation gets.

The race skip is gone from the gate. The test it existed for raced in its own
clock, not in the product; that is fixed, so nothing is excluded under -race any
more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:40:21 +03:00

145 lines
5.7 KiB
Go

package engine
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/shater/netplane"
M "github.com/sagernet/sing/common/metadata"
)
// ErrOutboundUnknown is returned by HTTPClient when the resolved outbound tag is
// not present in the running box (e.g. a `via` naming a group/node/egress that
// does not exist in the applied config). The panel API maps it to a 4xx.
var ErrOutboundUnknown = errors.New("unknown outbound tag")
// detourHTTPTimeout bounds a whole detour request (connect through the tunnel +
// TLS + redirects + body). Generous because the request travels through a proxy
// hop; subscription feeds and Telegram/webhook posts are small.
const detourHTTPTimeout = 30 * time.Second
// ViaToTag maps a shater `via` selector to the box-internal outbound tag it names.
//
// Accepted forms (case-insensitive prefixes):
//
// "" -> "direct" (the always-present direct outbound)
// "direct" -> "direct"
// "group:<X>" -> "<X>" (a group's tag is its name; generate/outbound)
// "node:<X>" -> "<X>" (a node's tag is its name)
// "egress:<X>" -> "egress-<X>" (netplane.EgressOutboundTag)
// "chain:<X>" -> "<X>" (a chain's entry tag is its name; niche)
// "<X>" -> "<X>" (bare: treated as a node/group tag verbatim)
//
// It never fails — an unresolvable tag is surfaced later by HTTPClient when the
// OutboundManager has no such tag.
func ViaToTag(via string) string {
via = strings.TrimSpace(via)
if via == "" || strings.EqualFold(via, "direct") {
return "direct"
}
if rest, ok := cutPrefixFold(via, "group:"); ok {
return strings.TrimSpace(rest)
}
if rest, ok := cutPrefixFold(via, "node:"); ok {
return strings.TrimSpace(rest)
}
if rest, ok := cutPrefixFold(via, "egress:"); ok {
return netplane.EgressOutboundTag(strings.TrimSpace(rest))
}
if rest, ok := cutPrefixFold(via, "chain:"); ok {
return strings.TrimSpace(rest)
}
// Bare value: treat as a node/group tag as-is.
return via
}
// cutPrefixFold is strings.CutPrefix with a case-insensitive prefix match.
func cutPrefixFold(s, prefix string) (string, bool) {
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
return s[len(prefix):], true
}
return "", false
}
// HTTPClient returns an *http.Client whose TCP dials are routed THROUGH the running
// engine's outbound named by `via` — i.e. requests egress via that tunnel. The
// transport still performs TLS/HTTP normally over the dialed connection, so callers
// use it like any http.Client.
//
// `via` is resolved by ViaToTag; "" / "direct" dials through the box's direct
// outbound (still in-tunnel-process, but egressing directly to the internet).
//
// Errors:
// - ErrEngineStopped when no box is running (nil instance / no OutboundManager).
// - ErrOutboundUnknown when the resolved tag is not present in the running box.
//
// The returned client honors a 30s overall timeout and per-dial context deadlines.
func (e *Engine) HTTPClient(via string) (*http.Client, error) {
inst := e.Instance()
if inst == nil {
return nil, ErrEngineStopped
}
om := inst.Outbound()
if om == nil {
return nil, ErrEngineStopped
}
tag := ViaToTag(via)
ob, ok := om.Outbound(tag)
if !ok {
return nil, fmt.Errorf("%w: %q (from via %q)", ErrOutboundUnknown, tag, via)
}
return httpClientVia(ob, detourHTTPTimeout), nil
}
// httpClientVia builds an http.Client whose TCP dials go through the given outbound
// OBJECT, with the given overall timeout.
//
// Taking the outbound rather than a tag is what lets a caller that has already
// resolved one (the group test, grouptest.go) guarantee the request cannot end up
// anywhere else — in particular not on the direct outbound, which would report the
// ISP's address as the tunnel's exit address.
//
// # Every caller MUST call CloseIdleConnections on the returned client
//
// A fresh http.Transport is built per call and belongs to that call alone. Its idle
// connections are not ordinary sockets: each is a live proxying session through an
// engine outbound, with a read loop and a write loop of its own, and the DialContext
// closure above captures the outbound OBJECT — so an idle connection keeps a whole
// retired engine generation reachable long after Apply swapped it out and its 5s
// close budget expired. Dropping the client without closing it therefore leaks far
// more than a socket.
//
// grouptest.go and shater/generate/ruleset.go get this right; copy them.
func httpClientVia(ob adapter.Outbound, timeout time.Duration) *http.Client {
transport := &http.Transport{
// Dial the underlying TCP connection through the selected outbound. The
// http.Transport layers TLS/HTTP on top of the returned net.Conn, so TLS is
// handled normally end-to-end (the outbound only carries bytes).
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return ob.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
ForceAttemptHTTP2: true,
MaxIdleConns: 8,
// The backstop for a caller that forgets to close, not the intended
// mechanism. 90s (the net/http default this used to carry) is eighteen times
// the engine's 5s close budget, so a single forgotten client could pin a dead
// generation through more than a minute and a half of it. 15s is still ample
// for the reuse this actually buys — a redirect chain or the second request of
// a subscription fetch, both within seconds — while bounding the damage of a
// leak to about one apply cycle.
IdleConnTimeout: 15 * time.Second,
TLSHandshakeTimeout: 15 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Timeout: timeout,
Transport: transport,
}
}