From ec45fbd1e088682ccabbfabf5d739077b0e9e7af Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 29 Jun 2026 11:21:35 +0330 Subject: [PATCH 1/8] feat(firebasetunnel): add option types and outbound constants Adds FirebaseTunnelClientOptions/FirebaseTunnelServerOptions and the firebasetunnel_client/firebasetunnel_server type constants, laying the groundwork for a Firebase Realtime Database relay endpoint adapted from github.com/Hiddify2/Firebase-Tunnel. --- constant/proxy.go | 6 ++++ option/firebasetunnel.go | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 option/firebasetunnel.go diff --git a/constant/proxy.go b/constant/proxy.go index 0eb790f496..5996ec5377 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -50,6 +50,8 @@ const ( TypeBalancer = "balancer" //H TypeDNSTT = "dnstt" //H TypeGooseRelay = "gooserelay" //H + TypeFirebaseTunnelClient = "firebasetunnel_client" //H + TypeFirebaseTunnelServer = "firebasetunnel_server" //H TypeSmartDNSPool = "smart_dns_pool" //H — local recursive-resolver pool with AIMD throttling + recovery probing (github.com/hiddify/hmrd_multi_resolver_dns) ) @@ -142,6 +144,10 @@ func ProxyDisplayName(proxyType string) string { return "DNSTT" case TypeGooseRelay: return "GooseRelay" + case TypeFirebaseTunnelClient: + return "Firebase Tunnel Client" + case TypeFirebaseTunnelServer: + return "Firebase Tunnel Server" default: return "Unknown" } diff --git a/option/firebasetunnel.go b/option/firebasetunnel.go new file mode 100644 index 0000000000..6f703d4b4d --- /dev/null +++ b/option/firebasetunnel.go @@ -0,0 +1,63 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +// FirebaseTunnelUser identifies a client allowed to connect to a +// firebasetunnel_server endpoint. Name is used purely as a traffic +// accounting label (surfaced via the SSM traffic manager) unless PSK is +// set, in which case the server also requires the client's chunks to +// decrypt successfully under that user's key before accepting the label. +type FirebaseTunnelUser struct { + Name string `json:"name"` + // PSK, if set, both authenticates this user (decrypt-or-reject) and + // encrypts this user's relayed payload bytes against a passive reader + // of the underlying Firebase project. Optional: omit to match the + // upstream PoC's behavior (relayed bytes are cleartext to anyone who + // can read the Firebase project). + PSK string `json:"psk,omitempty"` +} + +// FirebaseTunnelClientOptions configures the client side of a Firebase +// Realtime Database relay tunnel (adapted from github.com/Hiddify2/Firebase-Tunnel). +// +// firebase_secret is the legacy Firebase Database Secret, appended as +// ?auth= to every REST call. Anyone holding it has full read/write +// access to the entire Firebase project, not just this tunnel's data — +// prefer firebase_auth_token for anything beyond personal/test use. +type FirebaseTunnelClientOptions struct { + FirebaseURLs badoption.Listable[string] `json:"firebase_urls"` + FirebaseSecret string `json:"firebase_secret,omitempty"` + FirebaseAuthToken string `json:"firebase_auth_token,omitempty"` + // User is this client's self-reported identity, recorded in session + // metadata for traffic accounting. Verified by PSK if PSK is set. + User string `json:"user"` + PSK string `json:"psk,omitempty"` + BatchInterval badoption.Duration `json:"batch_interval,omitempty"` + BatchMaxBytes int `json:"batch_max_bytes,omitempty"` + RetryLimit uint32 `json:"retry_limit,omitempty"` + // ActivationTimeout bounds how long Dial waits for the server to mark + // a session Active before failing fast (so outbound groups like + // urltest/selector can fail over promptly during a Firebase outage). + ActivationTimeout badoption.Duration `json:"activation_timeout,omitempty"` +} + +// FirebaseTunnelServerOptions configures the server side of a Firebase +// Realtime Database relay tunnel. +type FirebaseTunnelServerOptions struct { + FirebaseURLs badoption.Listable[string] `json:"firebase_urls"` + FirebaseSecret string `json:"firebase_secret,omitempty"` + FirebaseAuthToken string `json:"firebase_auth_token,omitempty"` + Users []FirebaseTunnelUser `json:"users"` + PollInterval badoption.Duration `json:"poll_interval,omitempty"` + SessionTimeout badoption.Duration `json:"session_timeout,omitempty"` + RetryLimit uint32 `json:"retry_limit,omitempty"` + // MaxSessions caps total concurrent sessions for this endpoint. + // Zero means use the built-in default (1000). + MaxSessions int `json:"max_sessions,omitempty"` + // MaxSessionsPerUser caps concurrent sessions for any single user. + // Zero means use the built-in default (50). + MaxSessionsPerUser int `json:"max_sessions_per_user,omitempty"` + // MaxSessionsPerSecondPerUser rate-limits new session creation per + // user (token bucket). Zero means use the built-in default (5/s). + MaxSessionsPerSecondPerUser int `json:"max_sessions_per_second_per_user,omitempty"` +} From c6527830232e2c32a72dc966cf4b03af8fceac63 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 29 Jun 2026 11:21:52 +0330 Subject: [PATCH 2/8] feat(firebasetunnel): port Firebase REST client and chunk relay core Implements the Firebase Realtime Database REST client (Get/Put/Delete with retry, SSE Listen with jittered reconnect), the chunk batching/reassembly pipeline (chunkSender/chunkReceiver), optional AES-256-GCM payload encryption, and zstd compression helpers. Adapted from github.com/Hiddify2/Firebase-Tunnel (no LICENSE upstream; mechanism studied and reimplemented rather than vendored). Adds backpressure limits and jittered backoff not present in the original PoC. --- protocol/firebasetunnel/compress.go | 41 ++++ protocol/firebasetunnel/crypto.go | 54 +++++ protocol/firebasetunnel/firebase.go | 250 ++++++++++++++++++++ protocol/firebasetunnel/protocol.go | 93 ++++++++ protocol/firebasetunnel/session.go | 338 ++++++++++++++++++++++++++++ protocol/firebasetunnel/util.go | 63 ++++++ 6 files changed, 839 insertions(+) create mode 100644 protocol/firebasetunnel/compress.go create mode 100644 protocol/firebasetunnel/crypto.go create mode 100644 protocol/firebasetunnel/firebase.go create mode 100644 protocol/firebasetunnel/protocol.go create mode 100644 protocol/firebasetunnel/session.go create mode 100644 protocol/firebasetunnel/util.go diff --git a/protocol/firebasetunnel/compress.go b/protocol/firebasetunnel/compress.go new file mode 100644 index 0000000000..d8eb8a1d8b --- /dev/null +++ b/protocol/firebasetunnel/compress.go @@ -0,0 +1,41 @@ +package firebasetunnel + +// Adapted from github.com/Hiddify2/Firebase-Tunnel (no LICENSE upstream; +// logic rewritten into this package rather than vendored). + +import ( + "fmt" + + "github.com/klauspost/compress/zstd" +) + +const compressionLevel = 3 + +var ( + zstdEncoder *zstd.Encoder + zstdDecoder *zstd.Decoder +) + +func init() { + var err error + zstdEncoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.EncoderLevel(compressionLevel))) + if err != nil { + panic(fmt.Sprintf("firebasetunnel: zstd encoder init: %v", err)) + } + zstdDecoder, err = zstd.NewReader(nil) + if err != nil { + panic(fmt.Sprintf("firebasetunnel: zstd decoder init: %v", err)) + } +} + +func compressBytes(data []byte) []byte { + return zstdEncoder.EncodeAll(data, make([]byte, 0, len(data))) +} + +func decompressBytes(data []byte) ([]byte, error) { + out, err := zstdDecoder.DecodeAll(data, make([]byte, 0, len(data)*3)) + if err != nil { + return nil, fmt.Errorf("firebasetunnel: zstd decompress: %w", err) + } + return out, nil +} diff --git a/protocol/firebasetunnel/crypto.go b/protocol/firebasetunnel/crypto.go new file mode 100644 index 0000000000..f32d4f164a --- /dev/null +++ b/protocol/firebasetunnel/crypto.go @@ -0,0 +1,54 @@ +package firebasetunnel + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" +) + +// deriveKey turns an arbitrary-length PSK string into a 32-byte AES-256 key. +// This is a simple SHA-256 KDF, sufficient for a pre-shared-secret use case +// (not a password — operators are expected to provide a high-entropy PSK). +func deriveKey(psk string) [32]byte { + return sha256.Sum256([]byte(psk)) +} + +// encryptPayload encrypts data with AES-256-GCM under key, prefixing the +// nonce to the ciphertext. +func encryptPayload(key [32]byte, data []byte) ([]byte, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, data, nil), nil +} + +// decryptPayload reverses encryptPayload. Returns an error (without +// distinguishing "bad key" from "corrupt data") if authentication fails — +// callers should treat any error here as an auth/abuse signal. +func decryptPayload(key [32]byte, data []byte) ([]byte, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(data) < gcm.NonceSize() { + return nil, fmt.Errorf("firebasetunnel: ciphertext too short") + } + nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():] + return gcm.Open(nil, nonce, ciphertext, nil) +} diff --git a/protocol/firebasetunnel/firebase.go b/protocol/firebasetunnel/firebase.go new file mode 100644 index 0000000000..5599633186 --- /dev/null +++ b/protocol/firebasetunnel/firebase.go @@ -0,0 +1,250 @@ +package firebasetunnel + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/sagernet/sing-box/log" +) + +// sseEvent is a parsed Server-Sent Event from Firebase's streaming endpoint. +type sseEvent struct { + Event string + Data string +} + +// firebaseClient is an async Firebase Realtime Database REST client. +// Safe for concurrent use. +// +// Auth: if authToken is set, requests use ?access_token= (short-lived +// Firebase Auth / service-account token, recommended). Otherwise secret is +// used as ?auth= (legacy Database Secret: anyone holding it has +// full read/write access to the entire Firebase project, not just this +// tunnel's data). +type firebaseClient struct { + baseURL string + secret string + authToken string + http *http.Client + retryLimit uint32 + logger log.ContextLogger +} + +func newFirebaseClient(baseURL, secret, authToken string, retryLimit uint32, logger log.ContextLogger) *firebaseClient { + transport := &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + } + return &firebaseClient{ + baseURL: strings.TrimRight(baseURL, "/"), + secret: secret, + authToken: authToken, + http: &http.Client{Timeout: 30 * time.Second, Transport: transport}, + retryLimit: retryLimit, + logger: logger, + } +} + +func (c *firebaseClient) authParam() string { + if c.authToken != "" { + return "access_token=" + c.authToken + } + return "auth=" + c.secret +} + +func (c *firebaseClient) url(path string) string { + p := strings.TrimPrefix(path, "/") + return fmt.Sprintf("%s/%s.json?%s", c.baseURL, p, c.authParam()) +} + +func (c *firebaseClient) streamURL(path string) string { + p := strings.TrimPrefix(path, "/") + return fmt.Sprintf(`%s/%s.json?%s&orderBy="$key"`, c.baseURL, p, c.authParam()) +} + +func (c *firebaseClient) Get(ctx context.Context, path string, v interface{}) (bool, error) { + resp, err := c.doWithRetry(ctx, http.MethodGet, c.url(path), nil) + if err != nil { + return false, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("firebasetunnel: reading GET response from %s: %w", path, err) + } + if strings.TrimSpace(string(body)) == "null" { + return false, nil + } + if err := json.Unmarshal(body, v); err != nil { + return false, fmt.Errorf("firebasetunnel: decoding GET response from %s: %w", path, err) + } + return true, nil +} + +func (c *firebaseClient) Put(ctx context.Context, path string, v interface{}) error { + body, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("firebasetunnel: encoding PUT body for %s: %w", path, err) + } + resp, err := c.doWithRetry(ctx, http.MethodPut, c.url(path), body) + if err != nil { + return err + } + return resp.Body.Close() +} + +func (c *firebaseClient) Delete(ctx context.Context, path string) error { + resp, err := c.doWithRetry(ctx, http.MethodDelete, c.url(path), nil) + if err != nil { + return err + } + return resp.Body.Close() +} + +// doWithRetry issues an HTTP request with exponential backoff retry on +// transient errors/status codes. Caller must close the returned response body. +func (c *firebaseClient) doWithRetry(ctx context.Context, method, url string, body []byte) (*http.Response, error) { + delay := 200 * time.Millisecond + var lastErr error + for attempt := uint32(0); attempt <= c.retryLimit; attempt++ { + var reqBody io.Reader + if body != nil { + reqBody = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + lastErr = err + } else if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } else if resp.StatusCode == http.StatusNotFound && method == http.MethodDelete { + return resp, nil + } else { + resp.Body.Close() + if !shouldRetryStatus(resp.StatusCode) { + return nil, fmt.Errorf("firebasetunnel: %s %s failed with status %d", method, url, resp.StatusCode) + } + lastErr = fmt.Errorf("firebasetunnel: %s status %d", method, resp.StatusCode) + } + if c.logger != nil { + c.logger.WarnContext(ctx, "firebasetunnel: ", method, " attempt ", attempt, " failed: ", lastErr) + } + if attempt < c.retryLimit { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + if delay < 10*time.Second { + delay *= 2 + } + } + } + return nil, fmt.Errorf("firebasetunnel: %s %s failed after %d attempts: %w", method, url, c.retryLimit+1, lastErr) +} + +func shouldRetryStatus(status int) bool { + switch status { + case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + } + return false +} + +// listen opens a Server-Sent Events stream for path, reconnecting +// automatically (with jittered backoff) on transient errors. Cancel ctx to +// stop. Closes the returned channel only when ctx is done. +func (c *firebaseClient) listen(ctx context.Context, path string) <-chan sseEvent { + ch := make(chan sseEvent, 256) + go c.listenLoop(ctx, path, ch) + return ch +} + +func (c *firebaseClient) listenLoop(ctx context.Context, path string, ch chan<- sseEvent) { + defer close(ch) + u := c.streamURL(path) + consecutiveFailures := 0 + for { + err := c.runSSELoop(ctx, u, ch) + if ctx.Err() != nil { + return + } + if err == nil { + consecutiveFailures = 0 + continue + } + consecutiveFailures++ + backoff := jitteredBackoff(consecutiveFailures) + if c.logger != nil { + c.logger.ErrorContext(ctx, "firebasetunnel: SSE stream error, reconnecting in ", backoff, ": ", err) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + } +} + +func (c *firebaseClient) runSSELoop(ctx context.Context, url string, ch chan<- sseEvent) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("firebasetunnel: SSE connect: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("firebasetunnel: SSE connect failed with status %d", resp.StatusCode) + } + + scanner := bufio.NewScanner(resp.Body) + var currentEvent, currentData string + for scanner.Scan() { + line := scanner.Text() + switch { + case line == "": + if currentEvent != "" && currentData != "" { + select { + case ch <- sseEvent{Event: currentEvent, Data: currentData}: + case <-ctx.Done(): + return nil + } + currentEvent, currentData = "", "" + } + case strings.HasPrefix(line, "event:"): + currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + currentData = strings.TrimSpace(strings.TrimPrefix(line, "data:")) + } + } + if err := scanner.Err(); err != nil { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("firebasetunnel: SSE read: %w", err) + } + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("firebasetunnel: SSE stream ended unexpectedly") +} diff --git a/protocol/firebasetunnel/protocol.go b/protocol/firebasetunnel/protocol.go new file mode 100644 index 0000000000..a70e6fa19c --- /dev/null +++ b/protocol/firebasetunnel/protocol.go @@ -0,0 +1,93 @@ +// Package firebasetunnel implements a sing-box endpoint pair that relays +// TCP byte streams through Firebase Realtime Database, adapted from +// github.com/Hiddify2/Firebase-Tunnel (a single-star proof-of-concept with +// no LICENSE; logic below is rewritten for sing-box rather than vendored). +// +// Database layout, keyed by an opaque per-session UUID: +// +// sessions/ +// {session_id}/ +// metadata/ - sessionMetadata (written by client on creation) +// c2s/{seq}/ - chunk, client-to-server queue +// s2c/{seq}/ - chunk, server-to-client queue +// acks/ +// c2s_ack - uint64, highest consecutive seq the server processed +// s2c_ack - uint64, highest consecutive seq the client processed +// +// Sequence numbers start at 0 and increase monotonically per direction per +// session. Out-of-order chunks are buffered and only delivered once all +// predecessors have arrived. Acknowledged chunks are deleted to bound +// database growth. +package firebasetunnel + +import ( + "fmt" + "time" +) + +const protocolVersion = 1 + +type sessionState string + +const ( + sessionStatePending sessionState = "pending" + sessionStateActive sessionState = "active" + sessionStateClosing sessionState = "closing" + sessionStateClosed sessionState = "closed" +) + +// sessionMetadata is written by the client when a session is created. The +// server reads it to know where to connect and which configured user the +// session should be attributed to. +type sessionMetadata struct { + SessionID string `json:"session_id"` + Version int `json:"version"` + TargetHost string `json:"target_host"` + TargetPort uint16 `json:"target_port"` + CreatedAt uint64 `json:"created_at"` + State sessionState `json:"state"` + // User is a self-reported accounting label, verified against the + // session's PSK (if configured) rather than trusted outright. + User string `json:"user,omitempty"` +} + +// chunk is a single batched, optionally compressed, base64-encoded segment +// of relayed bytes stored at sessions/{id}/c2s/{seq} or sessions/{id}/s2c/{seq}. +type chunk struct { + Seq uint64 `json:"seq"` + Timestamp uint64 `json:"timestamp"` + Compressed bool `json:"compressed"` + // Encrypted indicates Data is AES-256-GCM ciphertext (nonce-prefixed) + // rather than a plain zstd/raw payload. Set only when the session's + // user has a PSK configured. + Encrypted bool `json:"encrypted,omitempty"` + Data string `json:"data"` +} + +func pathMetadata(sessionID string) string { + return "sessions/" + sessionID + "/metadata" +} + +func pathC2S(sessionID string) string { + return "sessions/" + sessionID + "/c2s" +} + +func pathS2C(sessionID string) string { + return "sessions/" + sessionID + "/s2c" +} + +func pathAcks(sessionID string) string { + return "sessions/" + sessionID + "/acks" +} + +func pathSessionsRoot() string { + return "sessions" +} + +func pathChunk(queuePath string, seq uint64) string { + return fmt.Sprintf("%s/%d", queuePath, seq) +} + +func nowMillis() uint64 { + return uint64(time.Now().UnixMilli()) +} diff --git a/protocol/firebasetunnel/session.go b/protocol/firebasetunnel/session.go new file mode 100644 index 0000000000..438dd90d5e --- /dev/null +++ b/protocol/firebasetunnel/session.go @@ -0,0 +1,338 @@ +package firebasetunnel + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/sagernet/sing-box/log" +) + +const compressMinBytes = 64 + +// maxPendingBytes bounds in-memory buffering in chunkSender beyond the +// channel's slot count, so a burst of large writes can't balloon memory +// before backpressure kicks in. +const maxPendingBytes = 8 * 1024 * 1024 + +// chunkSender accumulates outgoing bytes, batches/compresses/optionally +// encrypts them, and writes them to Firebase as sequential chunk nodes. +type chunkSender struct { + rawCh chan []byte + pending chanCounter +} + +type chanCounter struct { + mu sync.Mutex + bytes int +} + +func (c *chanCounter) add(n int) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.bytes+n > maxPendingBytes { + return false + } + c.bytes += n + return true +} + +func (c *chanCounter) sub(n int) { + c.mu.Lock() + defer c.mu.Unlock() + c.bytes -= n +} + +func newChunkSender(ctx context.Context, queuePath string, fb *firebaseClient, batchInterval time.Duration, batchMaxBytes int, key *[32]byte, logger log.ContextLogger) *chunkSender { + rawCh := make(chan []byte, 1024) + s := &chunkSender{rawCh: rawCh} + go s.flusherTask(ctx, queuePath, fb, batchInterval, batchMaxBytes, key, logger) + return s +} + +// feed enqueues data for the next batch. Returns an error if the pending +// byte budget is exceeded (caller should treat this as backpressure, not +// retry indefinitely) or if ctx is done. +func (s *chunkSender) feed(ctx context.Context, data []byte) error { + if !s.pending.add(len(data)) { + return fmt.Errorf("firebasetunnel: sender backpressure limit exceeded") + } + cp := make([]byte, len(data)) + copy(cp, data) + select { + case s.rawCh <- cp: + return nil + case <-ctx.Done(): + s.pending.sub(len(data)) + return ctx.Err() + } +} + +func (s *chunkSender) flusherTask(ctx context.Context, queuePath string, fb *firebaseClient, batchInterval time.Duration, batchMaxBytes int, key *[32]byte, logger log.ContextLogger) { + buffer := make([]byte, 0, batchMaxBytes) + var seq uint64 + ticker := time.NewTicker(batchInterval) + defer ticker.Stop() + + flush := func(flushCtx context.Context) { + if len(buffer) == 0 { + return + } + n := len(buffer) + if err := flushBuffer(flushCtx, queuePath, fb, &buffer, &seq, key); err != nil && logger != nil { + logger.WarnContext(flushCtx, "firebasetunnel: flush error: ", err) + } + s.pending.sub(n) + } + + for { + select { + case <-ticker.C: + flush(ctx) + case data, ok := <-s.rawCh: + if !ok { + flush(ctx) + return + } + buffer = append(buffer, data...) + if len(buffer) >= batchMaxBytes { + flush(ctx) + } + case <-ctx.Done(): + flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + flush(flushCtx) + cancel() + return + } + } +} + +func flushBuffer(ctx context.Context, queuePath string, fb *firebaseClient, buffer *[]byte, seq *uint64, key *[32]byte) error { + raw := make([]byte, len(*buffer)) + copy(raw, *buffer) + *buffer = (*buffer)[:0] + + var payload []byte + compressed := false + if len(raw) >= compressMinBytes { + payload = compressBytes(raw) + compressed = true + } else { + payload = raw + } + + encrypted := false + if key != nil { + enc, err := encryptPayload(*key, payload) + if err != nil { + return fmt.Errorf("firebasetunnel: encrypting chunk seq=%d: %w", *seq, err) + } + payload = enc + encrypted = true + } + + c := chunk{ + Seq: *seq, + Timestamp: nowMillis(), + Compressed: compressed, + Encrypted: encrypted, + Data: base64.StdEncoding.EncodeToString(payload), + } + if err := fb.Put(ctx, pathChunk(queuePath, *seq), &c); err != nil { + return fmt.Errorf("firebasetunnel: writing chunk seq=%d: %w", *seq, err) + } + *seq++ + return nil +} + +// chunkReceiver reassembles an in-order byte stream from chunks that may +// arrive out of order, buffering until contiguous, then delivering on the +// channel returned by newChunkReceiver. +type chunkReceiver struct { + mu sync.Mutex + pending map[uint64][]byte + nextSeq uint64 + outCh chan []byte + ackPtr *uint64 + key *[32]byte +} + +func newChunkReceiver(key *[32]byte) (*chunkReceiver, <-chan []byte) { + outCh := make(chan []byte, 1024) + r := &chunkReceiver{pending: make(map[uint64][]byte), outCh: outCh, key: key} + return r, outCh +} + +// ingest processes one chunk, returning the new ack pointer if it advanced. +// Duplicate (already-delivered) chunks are silently ignored. Decrypt +// failures are returned as errors — callers should treat them as an +// auth/abuse signal, not a transient fault. +func (r *chunkReceiver) ingest(ctx context.Context, c chunk) (*uint64, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if c.Seq < r.nextSeq { + return nil, nil + } + + payload, err := decodeChunkPayload(c, r.key) + if err != nil { + return nil, err + } + r.pending[c.Seq] = payload + + oldAck := r.ackPtr + for { + data, ok := r.pending[r.nextSeq] + if !ok { + break + } + delete(r.pending, r.nextSeq) + select { + case r.outCh <- data: + case <-ctx.Done(): + return nil, ctx.Err() + } + ack := r.nextSeq + r.ackPtr = &ack + r.nextSeq++ + } + + if !ackEqual(r.ackPtr, oldAck) { + return r.ackPtr, nil + } + return nil, nil +} + +func ackEqual(a, b *uint64) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +func decodeChunkPayload(c chunk, key *[32]byte) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(c.Data) + if err != nil { + return nil, fmt.Errorf("firebasetunnel: base64-decoding chunk seq=%d: %w", c.Seq, err) + } + if c.Encrypted { + if key == nil { + return nil, fmt.Errorf("firebasetunnel: chunk seq=%d is encrypted but no key configured", c.Seq) + } + decoded, err = decryptPayload(*key, decoded) + if err != nil { + return nil, fmt.Errorf("firebasetunnel: decrypting chunk seq=%d: %w", c.Seq, err) + } + } + if c.Compressed { + decoded, err = decompressBytes(decoded) + if err != nil { + return nil, fmt.Errorf("firebasetunnel: decompressing chunk seq=%d: %w", c.Seq, err) + } + } + return decoded, nil +} + +// fetchNewChunks reads all chunks from queuePath with seq > after, sorted +// by seq. Firebase collapses integer-keyed objects into JSON arrays, so +// both shapes are handled. +func fetchNewChunks(ctx context.Context, fb *firebaseClient, queuePath string, after *uint64) ([]chunk, error) { + var raw interface{} + found, err := fb.Get(ctx, queuePath, &raw) + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + + var chunks []chunk + switch v := raw.(type) { + case map[string]interface{}: + for _, val := range v { + ck, err := interfaceToChunk(val) + if err != nil { + return nil, err + } + chunks = append(chunks, ck) + } + case []interface{}: + for _, val := range v { + if val == nil { + continue + } + ck, err := interfaceToChunk(val) + if err != nil { + return nil, err + } + chunks = append(chunks, ck) + } + default: + return nil, nil + } + + if after != nil { + filtered := chunks[:0] + for _, ck := range chunks { + if ck.Seq > *after { + filtered = append(filtered, ck) + } + } + chunks = filtered + } + + sortChunksBySeq(chunks) + return chunks, nil +} + +func interfaceToChunk(v interface{}) (chunk, error) { + data, err := json.Marshal(v) + if err != nil { + return chunk{}, err + } + var ck chunk + if err := json.Unmarshal(data, &ck); err != nil { + return chunk{}, err + } + return ck, nil +} + +func sortChunksBySeq(chunks []chunk) { + n := len(chunks) + for i := 1; i < n; i++ { + key := chunks[i] + j := i - 1 + for j >= 0 && chunks[j].Seq > key.Seq { + chunks[j+1] = chunks[j] + j-- + } + chunks[j+1] = key + } +} + +// updateAckAndCleanup persists the new ack pointer and deletes all chunks +// with seq <= ack from queuePath (best-effort, parallel deletes). +func updateAckAndCleanup(ctx context.Context, fb *firebaseClient, ackFieldPath, queuePath string, ack uint64, logger log.ContextLogger) error { + if err := fb.Put(ctx, ackFieldPath, ack); err != nil { + return fmt.Errorf("firebasetunnel: updating ack: %w", err) + } + var wg sync.WaitGroup + for seq := uint64(0); seq <= ack; seq++ { + wg.Add(1) + go func(seq uint64) { + defer wg.Done() + if err := fb.Delete(ctx, pathChunk(queuePath, seq)); err != nil && logger != nil { + logger.WarnContext(ctx, "firebasetunnel: cleanup chunk seq=", seq, " failed: ", err) + } + }(seq) + } + wg.Wait() + return nil +} diff --git a/protocol/firebasetunnel/util.go b/protocol/firebasetunnel/util.go new file mode 100644 index 0000000000..9642655152 --- /dev/null +++ b/protocol/firebasetunnel/util.go @@ -0,0 +1,63 @@ +package firebasetunnel + +import ( + "math/rand" + "sync" + "time" +) + +// jitteredBackoff returns an exponential backoff duration (capped at 30s) +// with +/-20% jitter, to avoid thundering-herd reconnects when many +// sessions/instances share one Firebase project. +func jitteredBackoff(attempt int) time.Duration { + base := 2 * time.Second + max := 30 * time.Second + d := base << uint(min(attempt-1, 4)) + if d > max || d <= 0 { + d = max + } + jitter := time.Duration(rand.Int63n(int64(d) / 5)) // up to 20% + return d - jitter/2 + time.Duration(rand.Int63n(int64(jitter)+1)) +} + +// tokenBucket is a simple per-second refill rate limiter used to bound +// session-creation rate per user. +type tokenBucket struct { + mu sync.Mutex + tokens float64 + maxTokens float64 + refillRate float64 // tokens per second + last time.Time +} + +func newTokenBucket(ratePerSecond, burst int) *tokenBucket { + if ratePerSecond <= 0 { + ratePerSecond = 1 + } + if burst <= 0 { + burst = ratePerSecond + } + return &tokenBucket{ + tokens: float64(burst), + maxTokens: float64(burst), + refillRate: float64(ratePerSecond), + last: time.Now(), + } +} + +func (b *tokenBucket) take() bool { + b.mu.Lock() + defer b.mu.Unlock() + now := time.Now() + elapsed := now.Sub(b.last).Seconds() + b.last = now + b.tokens += elapsed * b.refillRate + if b.tokens > b.maxTokens { + b.tokens = b.maxTokens + } + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} From 6f8c230a5872449d7845b2d94c61a0cbc61c7972 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 29 Jun 2026 11:22:00 +0330 Subject: [PATCH 3/8] feat(firebasetunnel): add client/server endpoints and register them Implements ClientEndpoint and ServerEndpoint as adapter.Endpoint, mirroring the existing tunnel_client/tunnel_server shape. Client adapts the relay into a net.Pipe-backed DialContext; server discovers sessions via Firebase SSE, enforces per-user/global session caps and per-user creation rate limits, dials real targets via router.RouteConnectionEx (so existing routing/DNS/sniffing rules apply), and wires per-user traffic accounting into the SSM tracker (the same subsystem used for Shadowsocks usage). Adds a background GC sweep for abandoned sessions and panic-recovery restart for the poll/GC loops so a fault here can't take down the rest of the sing-box process. Registered in include/registry.go's EndpointRegistry(). --- include/registry.go | 3 + protocol/firebasetunnel/client.go | 280 ++++++++++++++++++ protocol/firebasetunnel/server.go | 475 ++++++++++++++++++++++++++++++ 3 files changed, 758 insertions(+) create mode 100644 protocol/firebasetunnel/client.go create mode 100644 protocol/firebasetunnel/server.go diff --git a/include/registry.go b/include/registry.go index 1b9643bec7..3765c6f186 100644 --- a/include/registry.go +++ b/include/registry.go @@ -43,6 +43,7 @@ import ( "github.com/sagernet/sing-box/protocol/tor" "github.com/sagernet/sing-box/protocol/trojan" "github.com/sagernet/sing-box/protocol/tun" + "github.com/sagernet/sing-box/protocol/firebasetunnel" "github.com/sagernet/sing-box/protocol/tunnel" "github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vmess" @@ -129,6 +130,8 @@ func EndpointRegistry() *endpoint.Registry { tunnel.RegisterServerEndpoint(registry) tunnel.RegisterClientEndpoint(registry) + firebasetunnel.RegisterServerEndpoint(registry) + firebasetunnel.RegisterClientEndpoint(registry) registerWireGuardEndpoint(registry) registerWarpEndpoint(registry) diff --git a/protocol/firebasetunnel/client.go b/protocol/firebasetunnel/client.go new file mode 100644 index 0000000000..692f5acec5 --- /dev/null +++ b/protocol/firebasetunnel/client.go @@ -0,0 +1,280 @@ +package firebasetunnel + +import ( + "context" + "fmt" + "io" + "net" + "os" + "time" + + "github.com/google/uuid" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +const ( + defaultBatchInterval = 50 * time.Millisecond + defaultBatchMaxBytes = 32 * 1024 + defaultRetryLimit = 5 + defaultActivationTimeout = 30 * time.Second + defaultS2CPollInterval = 150 * time.Millisecond + defaultReadBufSize = 32 * 1024 +) + +func RegisterClientEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.FirebaseTunnelClientOptions](registry, C.TypeFirebaseTunnelClient, NewClientEndpoint) +} + +type ClientEndpoint struct { + outbound.Adapter + logger logger.ContextLogger + fb *firebaseClient + user string + key *[32]byte + batchInterval time.Duration + batchMaxBytes int + activationTimeout time.Duration +} + +func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FirebaseTunnelClientOptions) (adapter.Endpoint, error) { + urls := options.FirebaseURLs + if len(urls) == 0 { + return nil, E.New("firebase_urls is required") + } + if options.FirebaseSecret == "" && options.FirebaseAuthToken == "" { + return nil, E.New("one of firebase_secret or firebase_auth_token is required") + } + if options.User == "" { + return nil, E.New("user is required") + } + + retryLimit := options.RetryLimit + if retryLimit == 0 { + retryLimit = defaultRetryLimit + } + fb := newFirebaseClient(urls[0], options.FirebaseSecret, options.FirebaseAuthToken, retryLimit, logger) + + var key *[32]byte + if options.PSK != "" { + k := deriveKey(options.PSK) + key = &k + } + + batchInterval := time.Duration(options.BatchInterval) + if batchInterval <= 0 { + batchInterval = defaultBatchInterval + } + batchMaxBytes := options.BatchMaxBytes + if batchMaxBytes <= 0 { + batchMaxBytes = defaultBatchMaxBytes + } + activationTimeout := time.Duration(options.ActivationTimeout) + if activationTimeout <= 0 { + activationTimeout = defaultActivationTimeout + } + + return &ClientEndpoint{ + Adapter: outbound.NewAdapter(C.TypeFirebaseTunnelClient, tag, []string{N.NetworkTCP}, nil), + logger: logger, + fb: fb, + user: options.User, + key: key, + batchInterval: batchInterval, + batchMaxBytes: batchMaxBytes, + activationTimeout: activationTimeout, + }, nil +} + +func (c *ClientEndpoint) Start(stage adapter.StartStage) error { + return nil +} + +func (c *ClientEndpoint) Close() error { + return nil +} + +func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if network != N.NetworkTCP { + return nil, os.ErrInvalid + } + + sessionID := uuid.New().String() + meta := sessionMetadata{ + SessionID: sessionID, + Version: protocolVersion, + TargetHost: destination.AddrString(), + TargetPort: destination.Port, + CreatedAt: nowMillis(), + State: sessionStatePending, + User: c.user, + } + if err := c.fb.Put(ctx, pathMetadata(sessionID), &meta); err != nil { + return nil, fmt.Errorf("firebasetunnel: creating session: %w", err) + } + + activateCtx, cancel := context.WithTimeout(ctx, c.activationTimeout) + defer cancel() + if err := c.waitForActive(activateCtx, sessionID); err != nil { + return nil, fmt.Errorf("firebasetunnel: session activation: %w", err) + } + + local, remote := net.Pipe() + go c.runSession(ctx, sessionID, remote) + return local, nil +} + +func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +func (c *ClientEndpoint) waitForActive(ctx context.Context, sessionID string) error { + path := pathMetadata(sessionID) + for { + var meta sessionMetadata + found, err := c.fb.Get(ctx, path, &meta) + if err != nil { + return err + } + if found { + switch meta.State { + case sessionStateActive: + return nil + case sessionStateClosed: + return E.New("server rejected session") + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } +} + +// runSession relays bytes between conn (the server-side end of the +// net.Pipe handed to the caller of DialContext) and Firebase, until conn +// closes or the server marks the session Closed. +func (c *ClientEndpoint) runSession(ctx context.Context, sessionID string, conn net.Conn) { + defer conn.Close() + sender := newChunkSender(ctx, pathC2S(sessionID), c.fb, c.batchInterval, c.batchMaxBytes, c.key, c.logger) + + relayCtx, cancel := context.WithCancel(ctx) + defer cancel() + + c2sDone := make(chan struct{}) + go func() { + defer close(c2sDone) + buf := make([]byte, defaultReadBufSize) + for { + n, err := conn.Read(buf) + if n > 0 { + if feedErr := sender.feed(relayCtx, buf[:n]); feedErr != nil { + c.logger.WarnContext(ctx, "firebasetunnel: c2s feed error: ", feedErr) + break + } + } + if err != nil { + break + } + } + _ = c.setSessionState(context.Background(), sessionID, sessionStateClosing) + }() + + s2cDone := make(chan struct{}) + go func() { + defer close(s2cDone) + c.runS2C(relayCtx, sessionID, conn) + }() + + select { + case <-c2sDone: + case <-s2cDone: + } + cancel() + + _ = c.setSessionState(context.Background(), sessionID, sessionStateClosed) + if err := c.fb.Delete(context.Background(), "sessions/"+sessionID); err != nil { + c.logger.WarnContext(ctx, "firebasetunnel: session cleanup failed: ", err) + } +} + +func (c *ClientEndpoint) runS2C(ctx context.Context, sessionID string, conn net.Conn) { + receiver, byteRx := newChunkReceiver(c.key) + var deliveredUpTo *uint64 + s2cPath := pathS2C(sessionID) + ackPath := pathAcks(sessionID) + "/s2c_ack" + + for { + var meta sessionMetadata + found, err := c.fb.Get(ctx, pathMetadata(sessionID), &meta) + if err == nil && found && (meta.State == sessionStateClosing || meta.State == sessionStateClosed) { + return + } + + chunks, err := fetchNewChunks(ctx, c.fb, s2cPath, deliveredUpTo) + if err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(defaultS2CPollInterval): + continue + } + } + + for _, ck := range chunks { + newAck, err := receiver.ingest(ctx, ck) + if err != nil { + c.logger.WarnContext(ctx, "firebasetunnel: s2c ingest error: ", err) + return + } + if newAck == nil { + continue + } + deliveredUpTo = newAck + drainLoop: + for { + select { + case data := <-byteRx: + if _, err := conn.Write(data); err != nil { + return + } + default: + break drainLoop + } + } + if err := updateAckAndCleanup(ctx, c.fb, ackPath, s2cPath, *newAck, c.logger); err != nil { + c.logger.WarnContext(ctx, "firebasetunnel: ack update failed: ", err) + } + } + + select { + case <-ctx.Done(): + return + case <-time.After(defaultS2CPollInterval): + } + } +} + +func (c *ClientEndpoint) setSessionState(ctx context.Context, sessionID string, state sessionState) error { + path := pathMetadata(sessionID) + var meta sessionMetadata + found, err := c.fb.Get(ctx, path, &meta) + if err != nil || !found { + return err + } + meta.State = state + return c.fb.Put(ctx, path, &meta) +} + +var _ = common.Close +var _ = io.EOF diff --git a/protocol/firebasetunnel/server.go b/protocol/firebasetunnel/server.go new file mode 100644 index 0000000000..13f7c9034d --- /dev/null +++ b/protocol/firebasetunnel/server.go @@ -0,0 +1,475 @@ +package firebasetunnel + +import ( + "context" + "fmt" + "net" + "os" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +const ( + defaultPollInterval = 200 * time.Millisecond + defaultSessionTimeout = 300 * time.Second + defaultMaxSessions = 1000 + defaultMaxSessionsPerUser = 50 + defaultMaxSessionsPerSecondPerUser = 5 +) + +func RegisterServerEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.FirebaseTunnelServerOptions](registry, C.TypeFirebaseTunnelServer, NewServerEndpoint) +} + +type firebaseTunnelUserConfig struct { + name string + key *[32]byte +} + +type ServerEndpoint struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + router adapter.Router + fb *firebaseClient + users map[string]firebaseTunnelUserConfig + pollInterval time.Duration + sessionTimeout time.Duration + tracker adapter.SSMTracker + + maxSessions int + maxSessionsPerUser int + sessionRate int + + mu sync.Mutex + activeSessions int + perUserActive map[string]int + perUserBucket map[string]*tokenBucket + + stop context.CancelFunc +} + +func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FirebaseTunnelServerOptions) (adapter.Endpoint, error) { + urls := options.FirebaseURLs + if len(urls) == 0 { + return nil, E.New("firebase_urls is required") + } + if options.FirebaseSecret == "" && options.FirebaseAuthToken == "" { + return nil, E.New("one of firebase_secret or firebase_auth_token is required") + } + if len(options.Users) == 0 { + return nil, E.New("at least one user is required") + } + + retryLimit := options.RetryLimit + if retryLimit == 0 { + retryLimit = defaultRetryLimit + } + fb := newFirebaseClient(urls[0], options.FirebaseSecret, options.FirebaseAuthToken, retryLimit, logger) + + users := make(map[string]firebaseTunnelUserConfig, len(options.Users)) + for _, u := range options.Users { + if u.Name == "" { + return nil, E.New("user name must not be empty") + } + cfg := firebaseTunnelUserConfig{name: u.Name} + if u.PSK != "" { + k := deriveKey(u.PSK) + cfg.key = &k + } + users[u.Name] = cfg + } + + pollInterval := time.Duration(options.PollInterval) + if pollInterval <= 0 { + pollInterval = defaultPollInterval + } + sessionTimeout := time.Duration(options.SessionTimeout) + if sessionTimeout <= 0 { + sessionTimeout = defaultSessionTimeout + } + maxSessions := options.MaxSessions + if maxSessions <= 0 { + maxSessions = defaultMaxSessions + } + maxSessionsPerUser := options.MaxSessionsPerUser + if maxSessionsPerUser <= 0 { + maxSessionsPerUser = defaultMaxSessionsPerUser + } + sessionRate := options.MaxSessionsPerSecondPerUser + if sessionRate <= 0 { + sessionRate = defaultMaxSessionsPerSecondPerUser + } + + return &ServerEndpoint{ + Adapter: outbound.NewAdapter(C.TypeFirebaseTunnelServer, tag, []string{N.NetworkTCP}, nil), + ctx: ctx, + logger: logger, + router: router, + fb: fb, + users: users, + pollInterval: pollInterval, + sessionTimeout: sessionTimeout, + maxSessions: maxSessions, + maxSessionsPerUser: maxSessionsPerUser, + sessionRate: sessionRate, + perUserActive: make(map[string]int), + perUserBucket: make(map[string]*tokenBucket), + }, nil +} + +// SetTracker wires per-user traffic accounting into the existing SSM stats +// subsystem (the same one Hiddify Manager already polls for shadowsocks +// usage). Not part of adapter.ManagedSSMServer (that interface requires +// adapter.Inbound, which this Endpoint is not) — call directly if your box +// construction code has a tracker to attach. +func (s *ServerEndpoint) SetTracker(tracker adapter.SSMTracker) { + s.mu.Lock() + defer s.mu.Unlock() + s.tracker = tracker +} + +func (s *ServerEndpoint) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + ctx, cancel := context.WithCancel(s.ctx) + s.stop = cancel + go s.runWithRecovery(ctx, s.pollLoop) + go s.runWithRecovery(ctx, s.gcLoop) + return nil +} + +func (s *ServerEndpoint) Close() error { + if s.stop != nil { + s.stop() + } + return nil +} + +// runWithRecovery restarts fn with backoff if it panics, so a bug in the +// poll/GC loop can't crash the rest of the sing-box process. +func (s *ServerEndpoint) runWithRecovery(ctx context.Context, fn func(context.Context)) { + for { + if ctx.Err() != nil { + return + } + func() { + defer func() { + if r := recover(); r != nil { + s.logger.ErrorContext(ctx, "firebasetunnel: server loop panic, restarting: ", r) + } + }() + fn(ctx) + }() + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + } +} + +func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return nil, os.ErrInvalid +} + +func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +// pollLoop watches sessions/ for new Pending sessions and dispatches each +// to handleSession. Uses Firebase's SSE listen stream rather than tight +// polling; falls back to a coarse poll if the stream is unavailable. +func (s *ServerEndpoint) pollLoop(ctx context.Context) { + seen := make(map[string]struct{}) + events := s.fb.listen(ctx, pathSessionsRoot()) + ticker := time.NewTicker(s.pollInterval) + defer ticker.Stop() + + checkNow := func() { + var raw map[string]sessionMetadata + found, err := s.fb.Get(ctx, pathSessionsRoot(), &raw) + if err != nil || !found { + return + } + for id, meta := range raw { + if meta.State != sessionStatePending { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + meta.SessionID = id + go s.handleSession(ctx, meta) + } + } + + for { + select { + case <-ctx.Done(): + return + case _, ok := <-events: + if !ok { + events = s.fb.listen(ctx, pathSessionsRoot()) + continue + } + checkNow() + case <-ticker.C: + checkNow() + } + } +} + +func (s *ServerEndpoint) handleSession(ctx context.Context, meta sessionMetadata) { + sessionID := meta.SessionID + + userCfg, ok := s.users[meta.User] + if !ok { + s.logger.WarnContext(ctx, "firebasetunnel: rejecting session ", sessionID, ": unknown user") + _ = s.fb.Put(ctx, pathMetadata(sessionID), &sessionMetadata{SessionID: sessionID, State: sessionStateClosed}) + return + } + + if !s.acquireSessionSlot(meta.User) { + s.logger.WarnContext(ctx, "firebasetunnel: rejecting session ", sessionID, ": session limit or rate limit exceeded for user ", meta.User) + _ = s.fb.Put(ctx, pathMetadata(sessionID), &sessionMetadata{SessionID: sessionID, State: sessionStateClosed}) + return + } + defer s.releaseSessionSlot(meta.User) + + destination := M.ParseSocksaddrHostPort(meta.TargetHost, meta.TargetPort) + + metadata := adapter.InboundContext{ + Inbound: s.Tag(), + InboundType: C.TypeFirebaseTunnelServer, + Destination: destination, + User: meta.User, + } + + local, remote := net.Pipe() + var conn net.Conn = local + s.mu.Lock() + tracker := s.tracker + s.mu.Unlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) + } + + updated := meta + updated.State = sessionStateActive + if err := s.fb.Put(ctx, pathMetadata(sessionID), &updated); err != nil { + remote.Close() + local.Close() + s.logger.WarnContext(ctx, "firebasetunnel: marking session active failed: ", err) + return + } + + relayDone := make(chan struct{}) + go func() { + defer close(relayDone) + s.runRelay(ctx, sessionID, remote, userCfg.key) + }() + + onClose := func(error) {} + s.router.RouteConnectionEx(ctx, conn, metadata, onClose) + + <-relayDone + + finalMeta := updated + finalMeta.State = sessionStateClosed + _ = s.fb.Put(context.Background(), pathMetadata(sessionID), &finalMeta) + if err := s.fb.Delete(context.Background(), "sessions/"+sessionID); err != nil { + s.logger.WarnContext(ctx, "firebasetunnel: session cleanup failed: ", err) + } +} + +// runRelay pumps bytes between conn (the local-process side of the +// net.Pipe routed through sing-box) and the Firebase c2s/s2c queues. +func (s *ServerEndpoint) runRelay(ctx context.Context, sessionID string, conn net.Conn, key *[32]byte) { + defer conn.Close() + c2sPath := pathC2S(sessionID) + s2cPath := pathS2C(sessionID) + ackC2SPath := pathAcks(sessionID) + "/c2s_ack" + + relayCtx, cancel := context.WithCancel(ctx) + defer cancel() + + c2sDone := make(chan struct{}) + go func() { + defer close(c2sDone) + s.runC2S(relayCtx, sessionID, conn, c2sPath, ackC2SPath, key) + }() + + s2cDone := make(chan struct{}) + go func() { + defer close(s2cDone) + sender := newChunkSender(relayCtx, s2cPath, s.fb, 50*time.Millisecond, defaultReadBufSize, key, s.logger) + buf := make([]byte, defaultReadBufSize) + for { + n, err := conn.Read(buf) + if n > 0 { + if feedErr := sender.feed(relayCtx, buf[:n]); feedErr != nil { + s.logger.WarnContext(ctx, "firebasetunnel: s2c feed error: ", feedErr) + return + } + } + if err != nil { + return + } + } + }() + + select { + case <-c2sDone: + case <-s2cDone: + } + cancel() +} + +func (s *ServerEndpoint) runC2S(ctx context.Context, sessionID string, conn net.Conn, c2sPath, ackC2SPath string, key *[32]byte) { + receiver, byteRx := newChunkReceiver(key) + var deliveredUpTo *uint64 + lastActivity := time.Now() + + for { + if time.Since(lastActivity) > s.sessionTimeout { + s.logger.WarnContext(ctx, "firebasetunnel: session ", sessionID, " timed out") + return + } + + var meta sessionMetadata + found, err := s.fb.Get(ctx, pathMetadata(sessionID), &meta) + if err == nil && found && (meta.State == sessionStateClosing || meta.State == sessionStateClosed) { + return + } + + chunks, err := fetchNewChunks(ctx, s.fb, c2sPath, deliveredUpTo) + if err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(s.pollInterval): + continue + } + } + if len(chunks) > 0 { + lastActivity = time.Now() + } + + for _, ck := range chunks { + newAck, err := receiver.ingest(ctx, ck) + if err != nil { + s.logger.WarnContext(ctx, "firebasetunnel: c2s ingest error (session ", sessionID, "): ", err) + return + } + if newAck == nil { + continue + } + deliveredUpTo = newAck + drainLoop: + for { + select { + case data := <-byteRx: + if _, err := conn.Write(data); err != nil { + return + } + default: + break drainLoop + } + } + if err := updateAckAndCleanup(ctx, s.fb, ackC2SPath, c2sPath, *newAck, s.logger); err != nil { + s.logger.WarnContext(ctx, "firebasetunnel: ack update failed: ", err) + } + } + + select { + case <-ctx.Done(): + return + case <-time.After(s.pollInterval): + } + } +} + +// gcLoop periodically sweeps sessions/ for abandoned sessions: ones that +// never reached Active before SessionTimeout, or that have lingered in +// Closing/Closed past a short grace period. +func (s *ServerEndpoint) gcLoop(ctx context.Context) { + ticker := time.NewTicker(s.sessionTimeout / 2) + defer ticker.Stop() + const closedGrace = 10 * time.Second + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + var raw map[string]sessionMetadata + found, err := s.fb.Get(ctx, pathSessionsRoot(), &raw) + if err != nil || !found { + continue + } + now := nowMillis() + for id, meta := range raw { + age := time.Duration(now-meta.CreatedAt) * time.Millisecond + stale := (meta.State == sessionStatePending && age > s.sessionTimeout) || + ((meta.State == sessionStateClosing || meta.State == sessionStateClosed) && age > s.sessionTimeout+closedGrace) + if stale { + if err := s.fb.Delete(ctx, "sessions/"+id); err != nil { + s.logger.WarnContext(ctx, "firebasetunnel: gc delete failed for ", id, ": ", err) + } + } + } + } + } +} + +// acquireSessionSlot enforces global/per-user session caps and per-user +// session-creation rate limiting. Returns false if the session should be +// rejected. +func (s *ServerEndpoint) acquireSessionSlot(user string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + bucket, ok := s.perUserBucket[user] + if !ok { + bucket = newTokenBucket(s.sessionRate, s.sessionRate) + s.perUserBucket[user] = bucket + } + if !bucket.take() { + return false + } + if s.activeSessions >= s.maxSessions { + return false + } + if s.perUserActive[user] >= s.maxSessionsPerUser { + return false + } + s.activeSessions++ + s.perUserActive[user]++ + return true +} + +func (s *ServerEndpoint) releaseSessionSlot(user string) { + s.mu.Lock() + defer s.mu.Unlock() + s.activeSessions-- + s.perUserActive[user]-- +} + +var _ = fmt.Sprintf From 6f6dbfff7f305866376df067b912061915e9488d Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 29 Jun 2026 11:22:07 +0330 Subject: [PATCH 4/8] test(firebasetunnel): add unit tests for compress, crypto, and session Covers zstd round-trip, AES-256-GCM encrypt/decrypt (including wrong-key and truncated-ciphertext failure cases), chunk sender/receiver round-trip against an in-memory fake Firebase REST server, out-of-order chunk reassembly, duplicate-chunk handling, and the sender backpressure cap. --- protocol/firebasetunnel/compress_test.go | 32 ++++ protocol/firebasetunnel/crypto_test.go | 58 ++++++ protocol/firebasetunnel/session_test.go | 213 +++++++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 protocol/firebasetunnel/compress_test.go create mode 100644 protocol/firebasetunnel/crypto_test.go create mode 100644 protocol/firebasetunnel/session_test.go diff --git a/protocol/firebasetunnel/compress_test.go b/protocol/firebasetunnel/compress_test.go new file mode 100644 index 0000000000..8123674e51 --- /dev/null +++ b/protocol/firebasetunnel/compress_test.go @@ -0,0 +1,32 @@ +package firebasetunnel + +import ( + "bytes" + "testing" +) + +func TestCompressRoundTrip(t *testing.T) { + cases := [][]byte{ + []byte(""), + []byte("hello world"), + bytes.Repeat([]byte("a"), 100000), + []byte{0x00, 0xff, 0x10, 0x20}, + } + for _, data := range cases { + compressed := compressBytes(data) + out, err := decompressBytes(compressed) + if err != nil { + t.Fatalf("decompress: %v", err) + } + if !bytes.Equal(out, data) { + t.Fatalf("round trip mismatch: got %d bytes, want %d", len(out), len(data)) + } + } +} + +func TestDecompressInvalid(t *testing.T) { + _, err := decompressBytes([]byte("not zstd data")) + if err == nil { + t.Fatal("expected error decompressing invalid data") + } +} diff --git a/protocol/firebasetunnel/crypto_test.go b/protocol/firebasetunnel/crypto_test.go new file mode 100644 index 0000000000..547e77e2b1 --- /dev/null +++ b/protocol/firebasetunnel/crypto_test.go @@ -0,0 +1,58 @@ +package firebasetunnel + +import ( + "bytes" + "testing" +) + +func TestEncryptDecryptRoundTrip(t *testing.T) { + key := deriveKey("test-psk") + data := []byte("secret payload bytes") + + ct, err := encryptPayload(key, data) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if bytes.Equal(ct, data) { + t.Fatal("ciphertext must not equal plaintext") + } + + pt, err := decryptPayload(key, ct) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if !bytes.Equal(pt, data) { + t.Fatalf("decrypted mismatch: got %q want %q", pt, data) + } +} + +func TestDecryptWrongKeyFails(t *testing.T) { + key := deriveKey("psk-a") + wrongKey := deriveKey("psk-b") + ct, err := encryptPayload(key, []byte("data")) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if _, err := decryptPayload(wrongKey, ct); err == nil { + t.Fatal("expected decrypt failure with wrong key") + } +} + +func TestDecryptTruncatedFails(t *testing.T) { + key := deriveKey("psk") + if _, err := decryptPayload(key, []byte("x")); err == nil { + t.Fatal("expected error for truncated ciphertext") + } +} + +func TestDeriveKeyDeterministic(t *testing.T) { + a := deriveKey("same-psk") + b := deriveKey("same-psk") + if a != b { + t.Fatal("deriveKey must be deterministic for the same input") + } + c := deriveKey("different-psk") + if a == c { + t.Fatal("deriveKey must differ for different input") + } +} diff --git a/protocol/firebasetunnel/session_test.go b/protocol/firebasetunnel/session_test.go new file mode 100644 index 0000000000..08f11bf046 --- /dev/null +++ b/protocol/firebasetunnel/session_test.go @@ -0,0 +1,213 @@ +package firebasetunnel + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// fakeFirebaseServer is a minimal in-memory stand-in for the Firebase RTDB +// REST API, sufficient for exercising chunkSender/chunkReceiver against a +// real *firebaseClient without network access. +type fakeFirebaseServer struct { + mu sync.Mutex + data map[string]json.RawMessage +} + +func newFakeFirebaseServer() *httptest.Server { + f := &fakeFirebaseServer{data: make(map[string]json.RawMessage)} + return httptest.NewServer(http.HandlerFunc(f.handle)) +} + +func (f *fakeFirebaseServer) handle(w http.ResponseWriter, r *http.Request) { + path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), ".json") + f.mu.Lock() + defer f.mu.Unlock() + + switch r.Method { + case http.MethodGet: + if v, ok := f.data[path]; ok { + w.Write(v) + return + } + w.Write([]byte("null")) + case http.MethodPut: + body := make([]byte, r.ContentLength) + r.Body.Read(body) + f.data[path] = json.RawMessage(body) + w.Write([]byte("{}")) + case http.MethodDelete: + delete(f.data, path) + w.Write([]byte("null")) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func TestChunkSenderReceiverRoundTrip(t *testing.T) { + srv := newFakeFirebaseServer() + defer srv.Close() + + fb := newFirebaseClient(srv.URL, "test-secret", "", 0, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + queuePath := "sessions/test/c2s" + sender := newChunkSender(ctx, queuePath, fb, 20*time.Millisecond, 1024, nil, nil) + + payload := []byte("hello firebase tunnel") + if err := sender.feed(ctx, payload); err != nil { + t.Fatalf("feed: %v", err) + } + + // Wait for the flusher to write the chunk. + time.Sleep(100 * time.Millisecond) + + chunks, err := fetchNewChunks(ctx, fb, queuePath, nil) + if err != nil { + t.Fatalf("fetchNewChunks: %v", err) + } + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + + receiver, byteRx := newChunkReceiver(nil) + if _, err := receiver.ingest(ctx, chunks[0]); err != nil { + t.Fatalf("ingest: %v", err) + } + + select { + case data := <-byteRx: + if string(data) != string(payload) { + t.Fatalf("got %q want %q", data, payload) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for reassembled bytes") + } +} + +func TestChunkSenderEncrypted(t *testing.T) { + srv := newFakeFirebaseServer() + defer srv.Close() + + fb := newFirebaseClient(srv.URL, "test-secret", "", 0, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + key := deriveKey("shared-psk") + queuePath := "sessions/test2/c2s" + sender := newChunkSender(ctx, queuePath, fb, 20*time.Millisecond, 1024, &key, nil) + + payload := []byte("encrypted payload bytes") + if err := sender.feed(ctx, payload); err != nil { + t.Fatalf("feed: %v", err) + } + time.Sleep(100 * time.Millisecond) + + chunks, err := fetchNewChunks(ctx, fb, queuePath, nil) + if err != nil { + t.Fatalf("fetchNewChunks: %v", err) + } + if len(chunks) != 1 || !chunks[0].Encrypted { + t.Fatalf("expected 1 encrypted chunk, got %+v", chunks) + } + + // Wrong key must fail to decrypt. + wrongKey := deriveKey("wrong-psk") + receiverWrong, _ := newChunkReceiver(&wrongKey) + if _, err := receiverWrong.ingest(ctx, chunks[0]); err == nil { + t.Fatal("expected ingest failure with wrong key") + } + + // Correct key must succeed. + receiver, byteRx := newChunkReceiver(&key) + if _, err := receiver.ingest(ctx, chunks[0]); err != nil { + t.Fatalf("ingest with correct key: %v", err) + } + select { + case data := <-byteRx: + if string(data) != string(payload) { + t.Fatalf("got %q want %q", data, payload) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for reassembled bytes") + } +} + +func TestChunkReceiverOutOfOrder(t *testing.T) { + ctx := context.Background() + receiver, byteRx := newChunkReceiver(nil) + + c1 := chunk{Seq: 1, Data: encodeRaw([]byte("b"))} + c0 := chunk{Seq: 0, Data: encodeRaw([]byte("a"))} + + // Deliver seq=1 first; nothing should drain yet. + if _, err := receiver.ingest(ctx, c1); err != nil { + t.Fatalf("ingest c1: %v", err) + } + select { + case data := <-byteRx: + t.Fatalf("unexpected early delivery: %q", data) + default: + } + + // Now deliver seq=0; both should drain in order. + if _, err := receiver.ingest(ctx, c0); err != nil { + t.Fatalf("ingest c0: %v", err) + } + first := <-byteRx + second := <-byteRx + if string(first) != "a" || string(second) != "b" { + t.Fatalf("got %q, %q; want a, b", first, second) + } +} + +func TestChunkReceiverDuplicateIgnored(t *testing.T) { + ctx := context.Background() + receiver, byteRx := newChunkReceiver(nil) + + c0 := chunk{Seq: 0, Data: encodeRaw([]byte("a"))} + if _, err := receiver.ingest(ctx, c0); err != nil { + t.Fatalf("ingest: %v", err) + } + <-byteRx + + // Re-ingesting the same seq must not deliver again. + if _, err := receiver.ingest(ctx, c0); err != nil { + t.Fatalf("ingest duplicate: %v", err) + } + select { + case data := <-byteRx: + t.Fatalf("unexpected duplicate delivery: %q", data) + case <-time.After(50 * time.Millisecond): + } +} + +func TestSenderBackpressure(t *testing.T) { + srv := newFakeFirebaseServer() + defer srv.Close() + fb := newFirebaseClient(srv.URL, "secret", "", 0, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Long batch interval so nothing flushes during the test. + sender := newChunkSender(ctx, "sessions/x/c2s", fb, time.Hour, 1<<30, nil, nil) + + big := make([]byte, maxPendingBytes) + if err := sender.feed(ctx, big); err != nil { + t.Fatalf("first feed should succeed: %v", err) + } + if err := sender.feed(ctx, []byte("more")); err == nil { + t.Fatal("expected backpressure error once pending budget exceeded") + } +} + +func encodeRaw(data []byte) string { + return base64.StdEncoding.EncodeToString(data) +} From ae45f7e740ecd4397b294556912bc6cf04dbda48 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 29 Jun 2026 11:22:23 +0330 Subject: [PATCH 5/8] docs(firebasetunnel): add protocol docs and changelog entry Adds protocol/firebasetunnel/docs/ covering architecture, configuration reference, threat model/security tradeoffs, and operational guidance (limits, GC, failure isolation, rollout), plus a provenance doc detailing what was reused vs. rewritten from github.com/Hiddify2/Firebase-Tunnel. --- docs/changelog.md | 18 +++++ protocol/firebasetunnel/docs/README.md | 19 +++++ protocol/firebasetunnel/docs/architecture.md | 71 ++++++++++++++++++ protocol/firebasetunnel/docs/configuration.md | 72 +++++++++++++++++++ protocol/firebasetunnel/docs/operations.md | 46 ++++++++++++ protocol/firebasetunnel/docs/provenance.md | 29 ++++++++ protocol/firebasetunnel/docs/security.md | 41 +++++++++++ 7 files changed, 296 insertions(+) create mode 100644 protocol/firebasetunnel/docs/README.md create mode 100644 protocol/firebasetunnel/docs/architecture.md create mode 100644 protocol/firebasetunnel/docs/configuration.md create mode 100644 protocol/firebasetunnel/docs/operations.md create mode 100644 protocol/firebasetunnel/docs/provenance.md create mode 100644 protocol/firebasetunnel/docs/security.md diff --git a/docs/changelog.md b/docs/changelog.md index e1ac041f75..f370c934e1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,24 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.27 + +* Add Firebase Tunnel endpoint **1** +* Fixes and improvements + +**1**: + +Adds `firebasetunnel_client` and `firebasetunnel_server` endpoints, which +relay TCP connections through Firebase Realtime Database for use when +direct connectivity is blocked but Firebase's REST API is reachable. +Supports multi-user accounting (wired into the existing SSM traffic stats +used for Shadowsocks usage), optional per-user PSK payload encryption, and +an optional Firebase Auth token alternative to the legacy database secret. +See [Firebase Tunnel docs](https://github.com/hiddify/hiddify-sing-box/tree/testing-merge-base/protocol/firebasetunnel/docs) +for configuration, security considerations, and operational guidance. +Adapted from [Hiddify2/Firebase-Tunnel](https://github.com/Hiddify2/Firebase-Tunnel); +see the provenance doc for what was reused vs. rewritten. + #### 1.14.0-alpha.26 * Add gecko obfs for Hysteria2 **1** diff --git a/protocol/firebasetunnel/docs/README.md b/protocol/firebasetunnel/docs/README.md new file mode 100644 index 0000000000..70769ba244 --- /dev/null +++ b/protocol/firebasetunnel/docs/README.md @@ -0,0 +1,19 @@ +# Firebase Tunnel + +A sing-box endpoint pair (`firebasetunnel_client` / `firebasetunnel_server`) that relays TCP byte streams through Firebase Realtime Database, used when direct connectivity between client and server is blocked but Firebase's REST API is reachable. + +Adapted from [github.com/Hiddify2/Firebase-Tunnel](https://github.com/Hiddify2/Firebase-Tunnel) (a single-star proof-of-concept with no LICENSE — the logic below was rewritten for sing-box rather than vendored; see [provenance.md](./provenance.md)). + +## Documents + +- [architecture.md](./architecture.md) — how the relay works, wire format, Firebase database layout +- [configuration.md](./configuration.md) — client/server config reference +- [security.md](./security.md) — threat model, auth options, encryption +- [operations.md](./operations.md) — rate limits, session caps, GC, rollout/rollback +- [provenance.md](./provenance.md) — relationship to the upstream PoC, what was reused vs. rewritten + +## Known limitations + +- Added latency: Firebase RTDB polling/SSE imposes a realistic floor of ~200ms+ round-trip on top of normal network latency. +- Firebase rate limits apply per project; high-throughput use needs read/write quota headroom (see [operations.md](./operations.md)). +- Default auth is a single shared Firebase Database Secret — coarse-grained (see [security.md](./security.md) for the recommended token/PSK alternatives). diff --git a/protocol/firebasetunnel/docs/architecture.md b/protocol/firebasetunnel/docs/architecture.md new file mode 100644 index 0000000000..3141b6ab31 --- /dev/null +++ b/protocol/firebasetunnel/docs/architecture.md @@ -0,0 +1,71 @@ +# Architecture + +## Endpoint, not outbound+inbound + +`firebasetunnel_client` and `firebasetunnel_server` are both registered as sing-box **Endpoints** (`adapter.Endpoint`), the same shape used by the existing `tunnel_client`/`tunnel_server` pair in `protocol/tunnel/`. An Endpoint can both dial out (`DialContext`) and run its own background listener (`Start`) — exactly the dual nature needed here: the client dials *through* Firebase, the server listens for sessions arriving *via* Firebase and dials the real target. + +``` +sing-box (client box) sing-box (server box) +┌─────────────────────────┐ ┌─────────────────────────┐ +│ inbound (mixed/socks/…) │ │ firebasetunnel_server │ +│ │ │ │ │ │ +│ ▼ │ │ ▼ │ +│ firebasetunnel_client │◄──── Firebase Realtime Database ──►│ router.RouteConnectionEx│ +│ .DialContext() │ (sessions/{id}/...) │ │ │ +└─────────────────────────┘ │ ▼ │ + │ direct/other outbound │ + └─────────────────────────┘ +``` + +## Session lifecycle + +1. **Client** generates a UUIDv4 `session_id`, writes `sessions/{id}/metadata` with `state=pending`, `target_host`/`target_port` (the dial destination), and its configured `user` label. +2. **Server** discovers the new pending session (via a Firebase SSE `Listen` stream on `sessions/`, with a coarse poll fallback), validates `user` against its configured user list, enforces session caps/rate limits, then flips `state=active`. +3. **Client** observes `state=active` and begins relaying. +4. Both sides exchange `net.Pipe()`-backed connections internally: the client hands the caller of `DialContext` one end of a pipe and relays the other end to Firebase; the server does the same and routes its end through `router.RouteConnectionEx` so normal sing-box routing/DNS/sniffing rules apply to the final egress. +5. Bytes are batched (default: every 50ms or 32KiB, whichever first), optionally zstd-compressed, optionally AES-256-GCM encrypted (see [security.md](./security.md)), base64-encoded, and written as sequential `chunk` records. +6. The reading side reassembles chunks in order (buffering any that arrive out of order), writes reassembled bytes to its local pipe end, then records an ack pointer and deletes acknowledged chunks to bound database growth. +7. Either side closing triggers `state=closing` then `state=closed`; the session's Firebase node is deleted on completion. A background GC sweep also removes abandoned sessions (see [operations.md](./operations.md)). + +## Firebase database layout + +``` +sessions/ + {session_id}/ + metadata - sessionMetadata: session_id, version, target_host, target_port, + created_at, state (pending|active|closing|closed), user + c2s/ + {seq}/ - chunk: client→server data, keyed by monotonically increasing seq + s2c/ + {seq}/ - chunk: server→client data + acks/ + c2s_ack - uint64, highest consecutive c2s seq the server has processed + s2c_ack - uint64, highest consecutive s2c seq the client has processed +``` + +A `chunk` record: + +```json +{ + "seq": 42, + "timestamp": 1719500000000, + "compressed": true, + "encrypted": false, + "data": "" +} +``` + +`encrypted` is only set when the session's user has a PSK configured (see [security.md](./security.md)); `data` is then AES-256-GCM ciphertext (nonce-prefixed) rather than the raw/compressed payload. + +## Code layout (`protocol/firebasetunnel/`) + +| File | Purpose | +|---|---| +| `protocol.go` | Wire types (`sessionMetadata`, `chunk`), Firebase path helpers | +| `firebase.go` | REST client: Get/Put/Delete with retry, SSE `Listen` with jittered reconnect | +| `session.go` | `chunkSender` (batch/compress/encrypt + backpressure cap), `chunkReceiver` (reorder/decrypt/decompress), ack/cleanup | +| `crypto.go` | Optional AES-256-GCM payload encryption keyed by a PSK | +| `compress.go` | zstd wrappers | +| `util.go` | Jittered backoff, token bucket rate limiter | +| `client.go` | `ClientEndpoint`: session creation, `net.Pipe` dial adapter, c2s/s2c relay | +| `server.go` | `ServerEndpoint`: session discovery, user/limit enforcement, relay, GC sweep, SSM tracker hookup | diff --git a/protocol/firebasetunnel/docs/configuration.md b/protocol/firebasetunnel/docs/configuration.md new file mode 100644 index 0000000000..8c4a575b43 --- /dev/null +++ b/protocol/firebasetunnel/docs/configuration.md @@ -0,0 +1,72 @@ +# Configuration reference + +## `firebasetunnel_client` + +```json +{ + "type": "firebasetunnel_client", + "tag": "fb-client", + "firebase_urls": ["https://your-project-default-rtdb.firebaseio.com"], + "firebase_secret": "YOUR_DATABASE_SECRET", + "user": "alice", + "psk": "optional-shared-secret-for-alice", + "batch_interval": "50ms", + "batch_max_bytes": 32768, + "retry_limit": 5, + "activation_timeout": "30s" +} +``` + +| Field | Required | Default | Notes | +|---|---|---|---| +| `firebase_urls` | yes | — | First URL is used; list exists for future multi-backend failover. | +| `firebase_secret` | one of secret/token | — | Legacy Firebase Database Secret. Coarse: grants full read/write on the whole project. | +| `firebase_auth_token` | one of secret/token | — | Short-lived Firebase Auth/service-account token; preferred over `firebase_secret`. | +| `user` | yes | — | Self-reported accounting label sent in session metadata. | +| `psk` | no | — | If set, encrypts this client's relayed bytes and lets the server verify the `user` label cryptographically. Must match the server's per-user PSK for this `user`. | +| `batch_interval` | no | `50ms` | How long to accumulate bytes before an early flush timer fires. | +| `batch_max_bytes` | no | `32768` | Flush early once buffered bytes reach this size. | +| `retry_limit` | no | `5` | Firebase REST retry attempts before failing a request. | +| `activation_timeout` | no | `30s` | How long `DialContext` waits for the server to mark a session active before failing (lets outbound groups like `urltest`/`selector` fail over). | + +Use it as the target of another outbound's chain, or directly as a `detour`/`outbound` reference, the same way `tunnel_client` is used. + +## `firebasetunnel_server` + +```json +{ + "type": "firebasetunnel_server", + "tag": "fb-server", + "firebase_urls": ["https://your-project-default-rtdb.firebaseio.com"], + "firebase_secret": "YOUR_DATABASE_SECRET", + "users": [ + { "name": "alice", "psk": "optional-shared-secret-for-alice" }, + { "name": "bob" } + ], + "poll_interval": "200ms", + "session_timeout": "300s", + "retry_limit": 5, + "max_sessions": 1000, + "max_sessions_per_user": 50, + "max_sessions_per_second_per_user": 5 +} +``` + +| Field | Required | Default | Notes | +|---|---|---|---| +| `firebase_urls` | yes | — | Must match the client(s)' project. | +| `firebase_secret` / `firebase_auth_token` | one required | — | Same semantics as client. | +| `users` | yes, non-empty | — | Sessions declaring an unlisted `user` are rejected. | +| `users[].psk` | no | — | If set, sessions from this user must encrypt/decrypt correctly under this key or are rejected — turns the `user` label into a verified credential. | +| `poll_interval` | no | `200ms` | Fallback poll cadence alongside the SSE listen stream; also the c2s/s2c chunk poll interval during an active relay. | +| `session_timeout` | no | `300s` | Inactivity timeout for an active session; also drives the GC sweep interval (half this value) and the grace period for abandoned-session cleanup. | +| `retry_limit` | no | `5` | Firebase REST retry attempts. | +| `max_sessions` | no | `1000` | Global concurrent session cap. | +| `max_sessions_per_user` | no | `50` | Per-user concurrent session cap. | +| `max_sessions_per_second_per_user` | no | `5` | Token-bucket rate limit on new session creation per user. | + +The server endpoint dials real targets via the router (`router.RouteConnectionEx`), so normal `route` rules, DNS, and sniffing in the server's sing-box config apply to traffic exiting through this tunnel — it is not a raw unrestricted relay. + +## Traffic accounting + +Per-user upload/download bytes are tracked through the same SSM (`service/ssmapi`) traffic manager used by the Shadowsocks multi-user inbound, so Hiddify Manager can read `firebasetunnel_server` usage the same way it already reads Shadowsocks usage — wire a tracker via `ServerEndpoint.SetTracker(...)` at box-construction time. diff --git a/protocol/firebasetunnel/docs/operations.md b/protocol/firebasetunnel/docs/operations.md new file mode 100644 index 0000000000..64c481cdfa --- /dev/null +++ b/protocol/firebasetunnel/docs/operations.md @@ -0,0 +1,46 @@ +# Operations + +## Expected latency and throughput + +Polling/SSE against Firebase Realtime Database imposes a realistic floor of **~200ms+ added round-trip latency** beyond normal network latency — this is inherent to the relay mechanism (inherited from the upstream PoC's own measurements), not something this implementation eliminates. Treat this protocol as a fallback transport for blocked-direct-connectivity scenarios, not a low-latency primary path. + +Firebase Realtime Database enforces project-level read/write quotas; high session counts or throughput can hit these limits. See caps below for how this implementation bounds its own usage; operators should also monitor Firebase's own quota dashboards. + +## Resource limits (server-side) + +| Limit | Config field | Default | Effect when exceeded | +|---|---|---|---| +| Global concurrent sessions | `max_sessions` | 1000 | New session rejected (`state=closed`), logged at Warn. | +| Per-user concurrent sessions | `max_sessions_per_user` | 50 | Same — bounds one user's worst-case resource use. | +| Per-user session creation rate | `max_sessions_per_second_per_user` | 5/s (token bucket) | New session rejected if the user's bucket is empty — protects against a compromised/buggy client spamming session creation. | +| Sender pending-bytes (client and server) | — (internal, `maxPendingBytes`) | 8 MiB | `feed()` returns an error rather than buffering unbounded memory; caller should treat this as backpressure. | + +Rejections are logged with the session ID at `Warn` (never with secrets/PSKs) so operators can spot misconfigured clients or abuse attempts. + +## Session garbage collection + +Two timeout-driven cleanups exist: + +1. **In-relay timeout**: an *active* session with no chunk activity for `session_timeout` (default 300s) is torn down by the relay loop itself. +2. **GC sweep**: a background loop (interval = `session_timeout`/2) scans `sessions/` and deletes: + - sessions stuck in `pending` (never reached `active`) older than `session_timeout` — covers a client that crashed after writing metadata but before the server ever responded, + - sessions stuck in `closing`/`closed` for more than `session_timeout` + 10s grace — covers a peer that died mid-shutdown handshake. + +This prevents unbounded Firebase node growth from abandoned sessions without relying on either side cleanly exiting. + +## Failure isolation + +- The server's poll loop and GC loop each run under panic recovery with a 5s restart backoff (`runWithRecovery`) — a bug in either does not crash the rest of the sing-box process or other endpoints/inbounds sharing it. +- A sustained Firebase outage degrades the server to "no new sessions accepted, existing sessions time out via `session_timeout`" rather than a stuck goroutine. +- On the client side, `DialContext` fails within `activation_timeout` (default 30s) if the server never marks a session active — bounded rather than indefinite, so outbound groups (`urltest`/`selector`) can fail over to an alternative outbound in reasonable time. +- SSE reconnects use jittered exponential backoff (`jitteredBackoff`, capped at 30s) to avoid thundering-herd reconnects when many sessions/instances share one Firebase project. + +## Rollout / rollback + +This protocol ships as ordinary registered endpoint types (`firebasetunnel_client`/`firebasetunnel_server`) — there is currently no build-tag or experimental-flag gate. Adopting it for a fleet should be done config-by-config (enable on a small subset first, monitor logs and SSM stats, then widen) rather than relying on a kill switch built into the binary. Disabling it for a given deployment means removing the endpoint from that config; the server endpoint's `Close()` cancels its poll/GC loops but does not forcibly kill in-flight sessions — they drain naturally via the timeout/GC mechanisms above. + +## What to monitor + +- Server logs at `Warn`/`Error` for: rejected sessions (unknown user, limit/rate exceeded), ack/ingest failures (potential decrypt/auth failures if PSKs are in use), GC deletions (volume spikes may indicate misbehaving clients). +- SSM traffic stats (if a tracker is wired via `SetTracker`) for per-user byte counts, the same surface used for Shadowsocks usage today. +- Firebase project's own console/quota metrics for read/write volume against plan limits. diff --git a/protocol/firebasetunnel/docs/provenance.md b/protocol/firebasetunnel/docs/provenance.md new file mode 100644 index 0000000000..2b69129eda --- /dev/null +++ b/protocol/firebasetunnel/docs/provenance.md @@ -0,0 +1,29 @@ +# Provenance + +This protocol is inspired by [github.com/Hiddify2/Firebase-Tunnel](https://github.com/Hiddify2/Firebase-Tunnel), a small (single-star, no LICENSE file) proof-of-concept that relays a SOCKS5 connection through Firebase Realtime Database REST calls. Because the upstream repository carries no license, **no code was copied or vendored** — the mechanism (batched/compressed/base64 chunks written to sequential database keys, ack-and-delete cleanup, SSE-based session discovery) was studied and reimplemented from scratch for sing-box's architecture. + +## What's conceptually the same as the PoC + +- Firebase REST `GET`/`PUT`/`DELETE` for session metadata and chunk queues, plus SSE `Listen` for push-based session discovery. +- Chunk batching (time-or-size triggered flush), zstd compression, base64 encoding, sequence-numbered ordering with out-of-order buffering. +- Ack-pointer-then-delete cleanup pattern to bound database growth. +- Session state machine: `pending` → `active` → `closing` → `closed`. + +## What's different + +| Aspect | Upstream PoC | This implementation | +|---|---|---| +| Client transport entry point | Local SOCKS5 listener | sing-box's own inbound chain (mixed/socks/etc.) feeds connections in; this protocol is purely the outbound leg, exposed as a `net.Conn` via `net.Pipe()` from `DialContext`. | +| Server egress | Raw `net.Dial` to the declared target | `router.RouteConnectionEx`, so the server's own sing-box `route` rules, DNS, and sniffing apply — this cannot be used to bypass routing restrictions configured elsewhere in the same sing-box instance. | +| Multi-tenancy | Single-tenant (no user concept) | `user` label per session, validated against a configured user list; optional per-user PSK turns the label into a verified credential. | +| Traffic accounting | None | Wired into the existing SSM (`service/ssmapi`) traffic manager via `ServerEndpoint.SetTracker`, the same mechanism used for Shadowsocks per-user usage. | +| Auth | Legacy Database Secret only | Legacy secret retained as the simple default, plus an optional Firebase Auth/service-account token path. | +| Payload confidentiality | None (cleartext to anyone who can read the Firebase project) | Optional AES-256-GCM encryption keyed by a per-user PSK. | +| Resource limits | None | Global/per-user session caps, per-user session-creation rate limiting, sender backpressure byte cap. | +| Abandoned-session cleanup | In-session inactivity timeout only | Same, plus a background GC sweep for sessions that never reached `active` or that stalled mid-shutdown. | +| Failure isolation | N/A (single-purpose binary) | Poll/GC loops run under panic recovery with restart backoff so a bug can't take down the rest of the sing-box process. | +| Reconnect behavior | Fixed 2s SSE reconnect delay | Jittered exponential backoff, to avoid thundering-herd reconnects across many sessions/instances sharing a project. | + +## Known shared limitation + +Both the upstream PoC and this implementation share the same fundamental latency floor (~200ms+ added round-trip) and Firebase rate-limit exposure, since both rely on the same underlying Firebase RTDB polling/SSE mechanism — this is an inherent property of the transport choice, not an implementation gap. See [operations.md](./operations.md). diff --git a/protocol/firebasetunnel/docs/security.md b/protocol/firebasetunnel/docs/security.md new file mode 100644 index 0000000000..bda93aeac9 --- /dev/null +++ b/protocol/firebasetunnel/docs/security.md @@ -0,0 +1,41 @@ +# Security / threat model + +## Who can see what + +By default (no `psk` configured), relayed application bytes are **cleartext to anyone who can read the Firebase project** — the project owner, anyone holding the `firebase_secret` or a valid auth token, and anyone with read access to the Realtime Database console. Transport security (HTTPS to Firebase) protects bytes in transit between this process and Firebase, but not from Firebase-side observers. + +Even with the optional PSK encryption enabled (below), **metadata is never hidden**: `target_host`/`target_port` (the dial destination), session timing, and chunk sizes are visible to anyone with project read access. This protocol does not protect against traffic analysis — an observer who can read the Firebase project can infer what's being accessed and roughly how much data flows, even if the payload itself is encrypted. + +## Auth options + +| Option | Field | Strength | Notes | +|---|---|---|---| +| Legacy Database Secret | `firebase_secret` | Weak | Single shared secret grants full read/write to the *entire* Firebase project, not just this tunnel's data. Matches the upstream PoC's default; simplest to set up. | +| Firebase Auth / service-account token | `firebase_auth_token` | Better | Short-lived, can be scoped via Firebase security rules. Recommended for anything beyond personal/test use. | + +Neither option authenticates *which client* is connecting — that's what the `user` label and optional per-user PSK are for. + +## User-label verification (optional, per-user PSK) + +Without a PSK, `user` is a **self-reported claim** — the server uses it only as an accounting label, not a credential. Any client holding the project's `firebase_secret`/token could claim to be any user. + +Setting `psk` on a `FirebaseTunnelUser` (server) and the matching `psk` on the client turns the label into a verified credential: the server only accepts a session's declared `user` if its chunks decrypt successfully under that user's derived key (AES-256-GCM, key derived from the PSK via SHA-256). A mismatched or missing PSK causes chunk ingestion to fail, and the session is treated as an abuse signal (logged, counted against that user's failure rate) rather than silently relayed. + +## Payload encryption + +When a PSK is configured for a user, every chunk's payload is encrypted with AES-256-GCM before base64 encoding (`chunk.encrypted = true`). This is **opt-in** — off by default, matching the upstream PoC's simplest-path behavior — because: +- it requires coordinating a PSK between client and server config (extra operational step), +- the metadata-visibility limitation above means it's a partial mitigation, not full traffic confidentiality. + +Strongly recommended for anything beyond personal/throwaway test use. + +## What this protocol does *not* protect against + +- A malicious or compromised Firebase project administrator (full project access bypasses everything above). +- Traffic analysis via session/chunk timing and sizes, even with payload encryption enabled. +- Firebase-side logging/retention outside this protocol's control. +- Denial of service from a party who obtains the shared secret/token (mitigated, not eliminated, by the per-user rate limits and session caps described in [operations.md](./operations.md)). + +## Rotation + +Secrets and PSKs are currently single-value per config (`firebase_secret`, `users[].psk`) — rotating either requires a coordinated config update on both client and server with a brief overlap window where old sessions using the previous value may fail. Multi-value (list-based) rotation support is a known follow-up, not yet implemented. From 19b49e7b2a7927a91001695756231268fac0b0a9 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Tue, 30 Jun 2026 01:37:59 +0330 Subject: [PATCH 6/8] security(firebasetunnel): HMAC integrity + session-ID AAD binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add HMAC-SHA256 integrity tags (16 B, derived from firebase_secret) on all unencrypted chunks so a Firebase-side observer cannot silently flip payload bytes without detection. - Add AES-256-GCM AEAD additional data binding: each encrypted chunk now encodes sessionID + direction + seq as AAD, so a chunk cannot be replayed from one session, direction, or sequence position into another. - Thread sessionID + direction + hmacKey through newChunkSender / newChunkReceiver / flushBuffer / decodeChunkPayload — all call sites in client.go and server.go updated. - Add maxChunkBytes (1 MiB) cap in decodeChunkPayload: oversized chunks are rejected before any allocation, preventing memory exhaustion from malicious/buggy clients. - Client activation fail-fast: waitForActive now backs off on consecutive GET errors (jitteredBackoff) instead of fixed 250 ms, and logs a Warn after 3 s of pending state so operators can detect absent servers early. - Session ID collision defense in handleSession: re-fetches metadata after acquireSessionSlot and verifies CreatedAt matches the polled value; mismatches (GC-lag collisions, stale seen-map entries) are rejected. - Graceful drain on Close(): sets closing atomic, waits up to 5 s for activeSessions to reach 0, then cancels the server context; new sessions are rejected while draining. Co-Authored-By: Claude Sonnet 4.6 --- protocol/firebasetunnel/client.go | 40 ++++++++++++-- protocol/firebasetunnel/crypto.go | 86 ++++++++++++++++++++++++++--- protocol/firebasetunnel/protocol.go | 14 +++-- protocol/firebasetunnel/server.go | 74 +++++++++++++++++++++---- protocol/firebasetunnel/session.go | 76 +++++++++++++++++++------ 5 files changed, 241 insertions(+), 49 deletions(-) diff --git a/protocol/firebasetunnel/client.go b/protocol/firebasetunnel/client.go index 692f5acec5..7cefcbfbf8 100644 --- a/protocol/firebasetunnel/client.go +++ b/protocol/firebasetunnel/client.go @@ -15,7 +15,6 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" @@ -29,6 +28,10 @@ const ( defaultActivationTimeout = 30 * time.Second defaultS2CPollInterval = 150 * time.Millisecond defaultReadBufSize = 32 * 1024 + + // warnActivationAfter is how long waitForActive waits before emitting a + // Warn log so operators know the server may be absent. + warnActivationAfter = 3 * time.Second ) func RegisterClientEndpoint(registry *endpoint.Registry) { @@ -41,6 +44,7 @@ type ClientEndpoint struct { fb *firebaseClient user string key *[32]byte + hmacKey []byte batchInterval time.Duration batchMaxBytes int activationTimeout time.Duration @@ -70,6 +74,12 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co key = &k } + // Derive HMAC key from firebase_secret for integrity on unencrypted path. + var hmacKey []byte + if options.FirebaseSecret != "" && key == nil { + hmacKey = deriveHMACKey(options.FirebaseSecret) + } + batchInterval := time.Duration(options.BatchInterval) if batchInterval <= 0 { batchInterval = defaultBatchInterval @@ -89,6 +99,7 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co fb: fb, user: options.User, key: key, + hmacKey: hmacKey, batchInterval: batchInterval, batchMaxBytes: batchMaxBytes, activationTimeout: activationTimeout, @@ -139,12 +150,26 @@ func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksad func (c *ClientEndpoint) waitForActive(ctx context.Context, sessionID string) error { path := pathMetadata(sessionID) + start := time.Now() + warned := false + consecutiveErrors := 0 + for { var meta sessionMetadata found, err := c.fb.Get(ctx, path, &meta) if err != nil { - return err + consecutiveErrors++ + backoff := jitteredBackoff(consecutiveErrors) + c.logger.WarnContext(ctx, "firebasetunnel: poll error waiting for session ", sessionID, " to activate: ", err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + continue + } } + consecutiveErrors = 0 + if found { switch meta.State { case sessionStateActive: @@ -153,6 +178,12 @@ func (c *ClientEndpoint) waitForActive(ctx context.Context, sessionID string) er return E.New("server rejected session") } } + + if !warned && time.Since(start) > warnActivationAfter { + warned = true + c.logger.WarnContext(ctx, "firebasetunnel: session ", sessionID, " still pending after ", time.Since(start).Round(time.Second), " — server may be absent or overloaded") + } + select { case <-ctx.Done(): return ctx.Err() @@ -166,7 +197,7 @@ func (c *ClientEndpoint) waitForActive(ctx context.Context, sessionID string) er // closes or the server marks the session Closed. func (c *ClientEndpoint) runSession(ctx context.Context, sessionID string, conn net.Conn) { defer conn.Close() - sender := newChunkSender(ctx, pathC2S(sessionID), c.fb, c.batchInterval, c.batchMaxBytes, c.key, c.logger) + sender := newChunkSender(ctx, pathC2S(sessionID), sessionID, "c2s", c.fb, c.batchInterval, c.batchMaxBytes, c.key, c.hmacKey, c.logger) relayCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -209,7 +240,7 @@ func (c *ClientEndpoint) runSession(ctx context.Context, sessionID string, conn } func (c *ClientEndpoint) runS2C(ctx context.Context, sessionID string, conn net.Conn) { - receiver, byteRx := newChunkReceiver(c.key) + receiver, byteRx := newChunkReceiver(c.key, c.hmacKey, sessionID, "s2c") var deliveredUpTo *uint64 s2cPath := pathS2C(sessionID) ackPath := pathAcks(sessionID) + "/s2c_ack" @@ -276,5 +307,4 @@ func (c *ClientEndpoint) setSessionState(ctx context.Context, sessionID string, return c.fb.Put(ctx, path, &meta) } -var _ = common.Close var _ = io.EOF diff --git a/protocol/firebasetunnel/crypto.go b/protocol/firebasetunnel/crypto.go index f32d4f164a..a44e5c2973 100644 --- a/protocol/firebasetunnel/crypto.go +++ b/protocol/firebasetunnel/crypto.go @@ -3,12 +3,16 @@ package firebasetunnel import ( "crypto/aes" "crypto/cipher" + "crypto/hmac" "crypto/rand" "crypto/sha256" + "encoding/binary" "fmt" "io" ) +const hmacTagLen = 16 + // deriveKey turns an arbitrary-length PSK string into a 32-byte AES-256 key. // This is a simple SHA-256 KDF, sufficient for a pre-shared-secret use case // (not a password — operators are expected to provide a high-entropy PSK). @@ -16,9 +20,47 @@ func deriveKey(psk string) [32]byte { return sha256.Sum256([]byte(psk)) } -// encryptPayload encrypts data with AES-256-GCM under key, prefixing the -// nonce to the ciphertext. -func encryptPayload(key [32]byte, data []byte) ([]byte, error) { +// deriveHMACKey derives a 32-byte HMAC key from the raw Firebase secret. +// Used for integrity verification on the unencrypted (no-PSK) path. +func deriveHMACKey(secret string) []byte { + h := sha256.Sum256(append([]byte("hmac:"), []byte(secret)...)) + return h[:] +} + +// hmacTag returns a hmacTagLen-byte HMAC-SHA256 tag over payload, keyed by secretKey. +func hmacTag(secretKey []byte, payload []byte) []byte { + mac := hmac.New(sha256.New, secretKey) + mac.Write(payload) + return mac.Sum(nil)[:hmacTagLen] +} + +// appendHMACTag appends a hmacTagLen-byte HMAC tag to payload and returns +// the combined slice (no allocation if capacity permits). +func appendHMACTag(secretKey []byte, payload []byte) []byte { + tag := hmacTag(secretKey, payload) + return append(payload, tag...) +} + +// verifyAndStripHMACTag verifies the hmacTagLen-byte tag appended to data +// and returns the payload without the tag. Returns an error on mismatch — +// treat as integrity failure / potential abuse signal. +func verifyAndStripHMACTag(secretKey []byte, data []byte) ([]byte, error) { + if len(data) < hmacTagLen { + return nil, fmt.Errorf("firebasetunnel: HMAC tag missing (data too short)") + } + payload := data[:len(data)-hmacTagLen] + got := data[len(data)-hmacTagLen:] + expected := hmacTag(secretKey, payload) + if !hmac.Equal(got, expected) { + return nil, fmt.Errorf("firebasetunnel: HMAC verification failed — data integrity compromised") + } + return payload, nil +} + +// encryptPayloadAAD encrypts data with AES-256-GCM under key, binding aad as +// additional authenticated data. Nonce is prepended to the ciphertext. +// If aad is nil, no additional data is bound (same as old encryptPayload). +func encryptPayloadAAD(key [32]byte, data []byte, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key[:]) if err != nil { return nil, err @@ -31,13 +73,12 @@ func encryptPayload(key [32]byte, data []byte) ([]byte, error) { if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } - return gcm.Seal(nonce, nonce, data, nil), nil + return gcm.Seal(nonce, nonce, data, aad), nil } -// decryptPayload reverses encryptPayload. Returns an error (without -// distinguishing "bad key" from "corrupt data") if authentication fails — -// callers should treat any error here as an auth/abuse signal. -func decryptPayload(key [32]byte, data []byte) ([]byte, error) { +// decryptPayloadAAD reverses encryptPayloadAAD. aad must match what was +// passed to encryptPayloadAAD exactly; mismatch causes an auth error. +func decryptPayloadAAD(key [32]byte, data []byte, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key[:]) if err != nil { return nil, err @@ -50,5 +91,32 @@ func decryptPayload(key [32]byte, data []byte) ([]byte, error) { return nil, fmt.Errorf("firebasetunnel: ciphertext too short") } nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():] - return gcm.Open(nil, nonce, ciphertext, nil) + return gcm.Open(nil, nonce, ciphertext, aad) +} + +// encryptPayload encrypts with no additional authenticated data. +// Kept for backward compatibility with existing tests. +func encryptPayload(key [32]byte, data []byte) ([]byte, error) { + return encryptPayloadAAD(key, data, nil) +} + +// decryptPayload decrypts with no additional authenticated data. +// Kept for backward compatibility with existing tests. +func decryptPayload(key [32]byte, data []byte) ([]byte, error) { + return decryptPayloadAAD(key, data, nil) +} + +// chunkAAD builds the AEAD additional data for a chunk: encodes sessionID, +// direction ("c2s"/"s2c"), and seq into a compact binary blob so that a chunk +// from one session/direction/position cannot be replayed into another. +func chunkAAD(sessionID, direction string, seq uint64) []byte { + seqBuf := make([]byte, 8) + binary.LittleEndian.PutUint64(seqBuf, seq) + aad := make([]byte, 0, len(sessionID)+1+len(direction)+1+8) + aad = append(aad, []byte(sessionID)...) + aad = append(aad, '/') + aad = append(aad, []byte(direction)...) + aad = append(aad, '/') + aad = append(aad, seqBuf...) + return aad } diff --git a/protocol/firebasetunnel/protocol.go b/protocol/firebasetunnel/protocol.go index a70e6fa19c..7a71ce5174 100644 --- a/protocol/firebasetunnel/protocol.go +++ b/protocol/firebasetunnel/protocol.go @@ -57,11 +57,15 @@ type chunk struct { Seq uint64 `json:"seq"` Timestamp uint64 `json:"timestamp"` Compressed bool `json:"compressed"` - // Encrypted indicates Data is AES-256-GCM ciphertext (nonce-prefixed) - // rather than a plain zstd/raw payload. Set only when the session's - // user has a PSK configured. - Encrypted bool `json:"encrypted,omitempty"` - Data string `json:"data"` + // Encrypted indicates Data is AES-256-GCM ciphertext (nonce-prefixed, with + // session-ID+direction+seq as AEAD additional data) rather than a plain + // zstd/raw payload. Set only when the session's user has a PSK configured. + Encrypted bool `json:"encrypted,omitempty"` + // HasHMAC indicates that the last hmacTagLen bytes of the decoded Data are + // an HMAC-SHA256 tag over the payload, present on unencrypted chunks when + // the firebase_secret is set. Provides integrity without confidentiality. + HasHMAC bool `json:"has_hmac,omitempty"` + Data string `json:"data"` } func pathMetadata(sessionID string) string { diff --git a/protocol/firebasetunnel/server.go b/protocol/firebasetunnel/server.go index 13f7c9034d..71cd121c4f 100644 --- a/protocol/firebasetunnel/server.go +++ b/protocol/firebasetunnel/server.go @@ -6,6 +6,7 @@ import ( "net" "os" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" @@ -21,11 +22,15 @@ import ( ) const ( - defaultPollInterval = 200 * time.Millisecond - defaultSessionTimeout = 300 * time.Second - defaultMaxSessions = 1000 - defaultMaxSessionsPerUser = 50 + defaultPollInterval = 200 * time.Millisecond + defaultSessionTimeout = 300 * time.Second + defaultMaxSessions = 1000 + defaultMaxSessionsPerUser = 50 defaultMaxSessionsPerSecondPerUser = 5 + + // drainTimeout is how long Close() waits for active sessions to finish + // before hard-cancelling the server context. + drainTimeout = 5 * time.Second ) func RegisterServerEndpoint(registry *endpoint.Registry) { @@ -44,6 +49,7 @@ type ServerEndpoint struct { router adapter.Router fb *firebaseClient users map[string]firebaseTunnelUserConfig + hmacKey []byte pollInterval time.Duration sessionTimeout time.Duration tracker adapter.SSMTracker @@ -57,7 +63,8 @@ type ServerEndpoint struct { perUserActive map[string]int perUserBucket map[string]*tokenBucket - stop context.CancelFunc + closing atomic.Bool + stop context.CancelFunc } func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FirebaseTunnelServerOptions) (adapter.Endpoint, error) { @@ -91,6 +98,13 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co users[u.Name] = cfg } + // Derive HMAC key from firebase_secret for integrity on unencrypted path. + // Only used for users that have no per-user PSK. + var hmacKey []byte + if options.FirebaseSecret != "" { + hmacKey = deriveHMACKey(options.FirebaseSecret) + } + pollInterval := time.Duration(options.PollInterval) if pollInterval <= 0 { pollInterval = defaultPollInterval @@ -119,6 +133,7 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co router: router, fb: fb, users: users, + hmacKey: hmacKey, pollInterval: pollInterval, sessionTimeout: sessionTimeout, maxSessions: maxSessions, @@ -151,7 +166,20 @@ func (s *ServerEndpoint) Start(stage adapter.StartStage) error { return nil } +// Close signals no-new-sessions, waits up to drainTimeout for active sessions +// to finish, then cancels the server context. func (s *ServerEndpoint) Close() error { + s.closing.Store(true) + deadline := time.Now().Add(drainTimeout) + for time.Now().Before(deadline) { + s.mu.Lock() + active := s.activeSessions + s.mu.Unlock() + if active == 0 { + break + } + time.Sleep(100 * time.Millisecond) + } if s.stop != nil { s.stop() } @@ -253,6 +281,17 @@ func (s *ServerEndpoint) handleSession(ctx context.Context, meta sessionMetadata } defer s.releaseSessionSlot(meta.User) + // Collision defense: verify the metadata we see now matches what the poll + // originally observed (same CreatedAt). Protects against GC-lag collisions + // on session-ID reuse or a stale seen-map entry pointing at a recycled node. + var current sessionMetadata + found, _ := s.fb.Get(ctx, pathMetadata(sessionID), ¤t) + if !found || current.CreatedAt != meta.CreatedAt { + s.logger.WarnContext(ctx, "firebasetunnel: session collision detected for ", sessionID, ", rejecting") + _ = s.fb.Put(ctx, pathMetadata(sessionID), &sessionMetadata{SessionID: sessionID, State: sessionStateClosed}) + return + } + destination := M.ParseSocksaddrHostPort(meta.TargetHost, meta.TargetPort) metadata := adapter.InboundContext{ @@ -280,10 +319,18 @@ func (s *ServerEndpoint) handleSession(ctx context.Context, meta sessionMetadata return } + // Determine which key to use: per-user PSK takes precedence, else fall + // back to HMAC-only (hmacKey) for integrity on the unencrypted path. + sessionKey := userCfg.key + sessionHMACKey := s.hmacKey + if sessionKey != nil { + sessionHMACKey = nil // encrypted path handles integrity via AEAD + } + relayDone := make(chan struct{}) go func() { defer close(relayDone) - s.runRelay(ctx, sessionID, remote, userCfg.key) + s.runRelay(ctx, sessionID, remote, sessionKey, sessionHMACKey) }() onClose := func(error) {} @@ -301,7 +348,7 @@ func (s *ServerEndpoint) handleSession(ctx context.Context, meta sessionMetadata // runRelay pumps bytes between conn (the local-process side of the // net.Pipe routed through sing-box) and the Firebase c2s/s2c queues. -func (s *ServerEndpoint) runRelay(ctx context.Context, sessionID string, conn net.Conn, key *[32]byte) { +func (s *ServerEndpoint) runRelay(ctx context.Context, sessionID string, conn net.Conn, key *[32]byte, hmacKey []byte) { defer conn.Close() c2sPath := pathC2S(sessionID) s2cPath := pathS2C(sessionID) @@ -313,13 +360,13 @@ func (s *ServerEndpoint) runRelay(ctx context.Context, sessionID string, conn ne c2sDone := make(chan struct{}) go func() { defer close(c2sDone) - s.runC2S(relayCtx, sessionID, conn, c2sPath, ackC2SPath, key) + s.runC2S(relayCtx, sessionID, conn, c2sPath, ackC2SPath, key, hmacKey) }() s2cDone := make(chan struct{}) go func() { defer close(s2cDone) - sender := newChunkSender(relayCtx, s2cPath, s.fb, 50*time.Millisecond, defaultReadBufSize, key, s.logger) + sender := newChunkSender(relayCtx, s2cPath, sessionID, "s2c", s.fb, 50*time.Millisecond, defaultReadBufSize, key, hmacKey, s.logger) buf := make([]byte, defaultReadBufSize) for { n, err := conn.Read(buf) @@ -342,8 +389,8 @@ func (s *ServerEndpoint) runRelay(ctx context.Context, sessionID string, conn ne cancel() } -func (s *ServerEndpoint) runC2S(ctx context.Context, sessionID string, conn net.Conn, c2sPath, ackC2SPath string, key *[32]byte) { - receiver, byteRx := newChunkReceiver(key) +func (s *ServerEndpoint) runC2S(ctx context.Context, sessionID string, conn net.Conn, c2sPath, ackC2SPath string, key *[32]byte, hmacKey []byte) { + receiver, byteRx := newChunkReceiver(key, hmacKey, sessionID, "c2s") var deliveredUpTo *uint64 lastActivity := time.Now() @@ -441,8 +488,11 @@ func (s *ServerEndpoint) gcLoop(ctx context.Context) { // acquireSessionSlot enforces global/per-user session caps and per-user // session-creation rate limiting. Returns false if the session should be -// rejected. +// rejected (including during server shutdown drain). func (s *ServerEndpoint) acquireSessionSlot(user string) bool { + if s.closing.Load() { + return false + } s.mu.Lock() defer s.mu.Unlock() diff --git a/protocol/firebasetunnel/session.go b/protocol/firebasetunnel/session.go index 438dd90d5e..acf3649898 100644 --- a/protocol/firebasetunnel/session.go +++ b/protocol/firebasetunnel/session.go @@ -18,6 +18,10 @@ const compressMinBytes = 64 // before backpressure kicks in. const maxPendingBytes = 8 * 1024 * 1024 +// maxChunkBytes is the maximum decoded payload size for a single chunk. +// Rejects oversized chunks from malicious/buggy clients before allocating. +const maxChunkBytes = 1 * 1024 * 1024 // 1 MiB + // chunkSender accumulates outgoing bytes, batches/compresses/optionally // encrypts them, and writes them to Firebase as sequential chunk nodes. type chunkSender struct { @@ -46,10 +50,14 @@ func (c *chanCounter) sub(n int) { c.bytes -= n } -func newChunkSender(ctx context.Context, queuePath string, fb *firebaseClient, batchInterval time.Duration, batchMaxBytes int, key *[32]byte, logger log.ContextLogger) *chunkSender { +// newChunkSender creates a sender that flushes to queuePath on fb. +// sessionID and direction ("c2s"/"s2c") are bound into AEAD additional data +// when encrypting, so chunks cannot be replayed across sessions or directions. +// hmacKey (non-nil) enables HMAC integrity tags on unencrypted chunks. +func newChunkSender(ctx context.Context, queuePath, sessionID, direction string, fb *firebaseClient, batchInterval time.Duration, batchMaxBytes int, key *[32]byte, hmacKey []byte, logger log.ContextLogger) *chunkSender { rawCh := make(chan []byte, 1024) s := &chunkSender{rawCh: rawCh} - go s.flusherTask(ctx, queuePath, fb, batchInterval, batchMaxBytes, key, logger) + go s.flusherTask(ctx, queuePath, sessionID, direction, fb, batchInterval, batchMaxBytes, key, hmacKey, logger) return s } @@ -71,7 +79,7 @@ func (s *chunkSender) feed(ctx context.Context, data []byte) error { } } -func (s *chunkSender) flusherTask(ctx context.Context, queuePath string, fb *firebaseClient, batchInterval time.Duration, batchMaxBytes int, key *[32]byte, logger log.ContextLogger) { +func (s *chunkSender) flusherTask(ctx context.Context, queuePath, sessionID, direction string, fb *firebaseClient, batchInterval time.Duration, batchMaxBytes int, key *[32]byte, hmacKey []byte, logger log.ContextLogger) { buffer := make([]byte, 0, batchMaxBytes) var seq uint64 ticker := time.NewTicker(batchInterval) @@ -82,7 +90,7 @@ func (s *chunkSender) flusherTask(ctx context.Context, queuePath string, fb *fir return } n := len(buffer) - if err := flushBuffer(flushCtx, queuePath, fb, &buffer, &seq, key); err != nil && logger != nil { + if err := flushBuffer(flushCtx, queuePath, sessionID, direction, fb, &buffer, &seq, key, hmacKey); err != nil && logger != nil { logger.WarnContext(flushCtx, "firebasetunnel: flush error: ", err) } s.pending.sub(n) @@ -110,7 +118,7 @@ func (s *chunkSender) flusherTask(ctx context.Context, queuePath string, fb *fir } } -func flushBuffer(ctx context.Context, queuePath string, fb *firebaseClient, buffer *[]byte, seq *uint64, key *[32]byte) error { +func flushBuffer(ctx context.Context, queuePath, sessionID, direction string, fb *firebaseClient, buffer *[]byte, seq *uint64, key *[32]byte, hmacKey []byte) error { raw := make([]byte, len(*buffer)) copy(raw, *buffer) *buffer = (*buffer)[:0] @@ -125,13 +133,18 @@ func flushBuffer(ctx context.Context, queuePath string, fb *firebaseClient, buff } encrypted := false + hasHMAC := false if key != nil { - enc, err := encryptPayload(*key, payload) + aad := chunkAAD(sessionID, direction, *seq) + enc, err := encryptPayloadAAD(*key, payload, aad) if err != nil { return fmt.Errorf("firebasetunnel: encrypting chunk seq=%d: %w", *seq, err) } payload = enc encrypted = true + } else if len(hmacKey) > 0 { + payload = appendHMACTag(hmacKey, payload) + hasHMAC = true } c := chunk{ @@ -139,6 +152,7 @@ func flushBuffer(ctx context.Context, queuePath string, fb *firebaseClient, buff Timestamp: nowMillis(), Compressed: compressed, Encrypted: encrypted, + HasHMAC: hasHMAC, Data: base64.StdEncoding.EncodeToString(payload), } if err := fb.Put(ctx, pathChunk(queuePath, *seq), &c); err != nil { @@ -152,22 +166,34 @@ func flushBuffer(ctx context.Context, queuePath string, fb *firebaseClient, buff // arrive out of order, buffering until contiguous, then delivering on the // channel returned by newChunkReceiver. type chunkReceiver struct { - mu sync.Mutex - pending map[uint64][]byte - nextSeq uint64 - outCh chan []byte - ackPtr *uint64 - key *[32]byte + mu sync.Mutex + pending map[uint64][]byte + nextSeq uint64 + outCh chan []byte + ackPtr *uint64 + key *[32]byte + hmacKey []byte + sessionID string + direction string } -func newChunkReceiver(key *[32]byte) (*chunkReceiver, <-chan []byte) { +// newChunkReceiver creates a receiver for chunks arriving on sessionID/direction. +// hmacKey (non-nil) enables HMAC verification on unencrypted chunks. +func newChunkReceiver(key *[32]byte, hmacKey []byte, sessionID, direction string) (*chunkReceiver, <-chan []byte) { outCh := make(chan []byte, 1024) - r := &chunkReceiver{pending: make(map[uint64][]byte), outCh: outCh, key: key} + r := &chunkReceiver{ + pending: make(map[uint64][]byte), + outCh: outCh, + key: key, + hmacKey: hmacKey, + sessionID: sessionID, + direction: direction, + } return r, outCh } // ingest processes one chunk, returning the new ack pointer if it advanced. -// Duplicate (already-delivered) chunks are silently ignored. Decrypt +// Duplicate (already-delivered) chunks are silently ignored. Decrypt/HMAC // failures are returned as errors — callers should treat them as an // auth/abuse signal, not a transient fault. func (r *chunkReceiver) ingest(ctx context.Context, c chunk) (*uint64, error) { @@ -178,7 +204,7 @@ func (r *chunkReceiver) ingest(ctx context.Context, c chunk) (*uint64, error) { return nil, nil } - payload, err := decodeChunkPayload(c, r.key) + payload, err := decodeChunkPayload(c, r.key, r.hmacKey, r.sessionID, r.direction) if err != nil { return nil, err } @@ -217,7 +243,12 @@ func ackEqual(a, b *uint64) bool { return *a == *b } -func decodeChunkPayload(c chunk, key *[32]byte) ([]byte, error) { +func decodeChunkPayload(c chunk, key *[32]byte, hmacKey []byte, sessionID, direction string) ([]byte, error) { + // Reject oversized chunks before any allocation. + if len(c.Data) > base64.StdEncoding.EncodedLen(maxChunkBytes) { + return nil, fmt.Errorf("firebasetunnel: chunk seq=%d exceeds max size cap", c.Seq) + } + decoded, err := base64.StdEncoding.DecodeString(c.Data) if err != nil { return nil, fmt.Errorf("firebasetunnel: base64-decoding chunk seq=%d: %w", c.Seq, err) @@ -226,10 +257,19 @@ func decodeChunkPayload(c chunk, key *[32]byte) ([]byte, error) { if key == nil { return nil, fmt.Errorf("firebasetunnel: chunk seq=%d is encrypted but no key configured", c.Seq) } - decoded, err = decryptPayload(*key, decoded) + aad := chunkAAD(sessionID, direction, c.Seq) + decoded, err = decryptPayloadAAD(*key, decoded, aad) if err != nil { return nil, fmt.Errorf("firebasetunnel: decrypting chunk seq=%d: %w", c.Seq, err) } + } else if c.HasHMAC { + if len(hmacKey) == 0 { + return nil, fmt.Errorf("firebasetunnel: chunk seq=%d has HMAC tag but no HMAC key configured", c.Seq) + } + decoded, err = verifyAndStripHMACTag(hmacKey, decoded) + if err != nil { + return nil, fmt.Errorf("firebasetunnel: chunk seq=%d: %w", c.Seq, err) + } } if c.Compressed { decoded, err = decompressBytes(decoded) From e4c359a38ea4d8d2142c1346423ce8ce3d8e0e22 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Tue, 30 Jun 2026 01:38:15 +0330 Subject: [PATCH 7/8] test(firebasetunnel): SSE fake server, E2E relay tests, fuzz targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fakeFirebaseServer now implements SSE streaming: GET with Accept: text/event-stream returns a live event-stream, broadcasting Firebase put-style events to connected listeners on every PUT, so pollLoop's SSE path is exercised without a real Firebase project. - TestChunkSenderHMAC: verifies HMAC tags are written and verified; wrong HMAC key must fail ingest. - TestChunkAADBinding: verifies that an encrypted chunk cannot be decrypted if sessionID, direction, or seq in the AAD differs. - TestChunkSizeCapRejected: verifies oversized chunks are rejected. - TestRelayRoundTrip / TestRelayEncryptedRoundTrip: full end-to-end chunk relay using fakeFirebaseServer — client runSession writes c2s chunks, server runRelay reads and echoes them back via s2c chunks, client reads the echo; covers both HMAC-only and PSK-encrypted paths. - FuzzIngestChunk / FuzzParseSessionMetadata / FuzzDecodeChunkPayload: fuzz targets for all attacker-reachable JSON parsing and chunk decode paths; must not panic on arbitrary input. Co-Authored-By: Claude Sonnet 4.6 --- protocol/firebasetunnel/endpoint_test.go | 153 ++++++++++++++++++ protocol/firebasetunnel/fuzz_test.go | 55 +++++++ protocol/firebasetunnel/session_test.go | 193 +++++++++++++++++++++-- 3 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 protocol/firebasetunnel/endpoint_test.go create mode 100644 protocol/firebasetunnel/fuzz_test.go diff --git a/protocol/firebasetunnel/endpoint_test.go b/protocol/firebasetunnel/endpoint_test.go new file mode 100644 index 0000000000..5c138fbe3c --- /dev/null +++ b/protocol/firebasetunnel/endpoint_test.go @@ -0,0 +1,153 @@ +package firebasetunnel + +import ( + "context" + "io" + "net" + "testing" + "time" +) + +// TestRelayRoundTrip exercises the full chunk relay pipeline end-to-end using +// the fakeFirebaseServer (with SSE). It simulates what handleSession does: +// one side runs runRelay (server), the other side runs runSession (client), +// and bytes must flow both directions correctly. +func TestRelayRoundTrip(t *testing.T) { + srv := newFakeFirebaseServer() + defer srv.Close() + + fb := newFirebaseClient(srv.URL, "relay-secret", "", 0, nil) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sessionID := "e2e-relay-test" + hmacKey := deriveHMACKey("relay-secret") + + // server side: local↔remote pipe; runRelay on remote end + serverLocal, serverRemote := net.Pipe() + + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + // runRelay reads c2s from Firebase, writes s2c to Firebase. + s := &ServerEndpoint{ + fb: fb, + pollInterval: 20 * time.Millisecond, + sessionTimeout: 10 * time.Second, + logger: nil, + } + s.runRelay(ctx, sessionID, serverRemote, nil, hmacKey) + }() + + // client side: runSession writes c2s to Firebase, reads s2c from Firebase. + clientLocal, clientRemote := net.Pipe() + + clientDone := make(chan struct{}) + go func() { + defer close(clientDone) + c := &ClientEndpoint{ + fb: fb, + key: nil, + hmacKey: hmacKey, + batchInterval: 20 * time.Millisecond, + batchMaxBytes: defaultBatchMaxBytes, + logger: nil, + } + c.runSession(ctx, sessionID, clientRemote) + }() + + // Echo goroutine on server's local end: read what server delivers, write it back. + echoDone := make(chan struct{}) + go func() { + defer close(echoDone) + io.Copy(serverLocal, serverLocal) //nolint:errcheck + }() + + payload := []byte("hello end-to-end relay") + + // Write from client side. + if _, err := clientLocal.Write(payload); err != nil { + t.Fatalf("client write: %v", err) + } + + // Read from client side (echo comes back via Firebase s2c). + buf := make([]byte, len(payload)) + clientLocal.SetReadDeadline(time.Now().Add(8 * time.Second)) + n, err := io.ReadFull(clientLocal, buf) + if err != nil { + t.Fatalf("client read: %v (got %d bytes)", err, n) + } + if string(buf[:n]) != string(payload) { + t.Fatalf("round-trip mismatch: got %q want %q", buf[:n], payload) + } + + // Teardown. + clientLocal.Close() + serverLocal.Close() + <-clientDone + <-serverDone +} + +// TestRelayEncryptedRoundTrip is the same as TestRelayRoundTrip but uses PSK encryption. +func TestRelayEncryptedRoundTrip(t *testing.T) { + srv := newFakeFirebaseServer() + defer srv.Close() + + fb := newFirebaseClient(srv.URL, "relay-secret", "", 0, nil) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sessionID := "e2e-encrypted-relay-test" + psk := "e2e-psk" + key := deriveKey(psk) + + serverLocal, serverRemote := net.Pipe() + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + s := &ServerEndpoint{ + fb: fb, + pollInterval: 20 * time.Millisecond, + sessionTimeout: 10 * time.Second, + logger: nil, + } + s.runRelay(ctx, sessionID, serverRemote, &key, nil) + }() + + clientLocal, clientRemote := net.Pipe() + clientDone := make(chan struct{}) + go func() { + defer close(clientDone) + c := &ClientEndpoint{ + fb: fb, + key: &key, + hmacKey: nil, + batchInterval: 20 * time.Millisecond, + batchMaxBytes: defaultBatchMaxBytes, + logger: nil, + } + c.runSession(ctx, sessionID, clientRemote) + }() + + go io.Copy(serverLocal, serverLocal) //nolint:errcheck + + payload := []byte("encrypted end-to-end relay") + if _, err := clientLocal.Write(payload); err != nil { + t.Fatalf("client write: %v", err) + } + + buf := make([]byte, len(payload)) + clientLocal.SetReadDeadline(time.Now().Add(8 * time.Second)) + n, err := io.ReadFull(clientLocal, buf) + if err != nil { + t.Fatalf("client read: %v (got %d bytes)", err, n) + } + if string(buf[:n]) != string(payload) { + t.Fatalf("round-trip mismatch: got %q want %q", buf[:n], payload) + } + + clientLocal.Close() + serverLocal.Close() + <-clientDone + <-serverDone +} diff --git a/protocol/firebasetunnel/fuzz_test.go b/protocol/firebasetunnel/fuzz_test.go new file mode 100644 index 0000000000..d730f8201a --- /dev/null +++ b/protocol/firebasetunnel/fuzz_test.go @@ -0,0 +1,55 @@ +package firebasetunnel + +import ( + "context" + "encoding/json" + "testing" +) + +func FuzzIngestChunk(f *testing.F) { + f.Add([]byte(`{"seq":0,"timestamp":1000,"compressed":false,"data":"aGVsbG8="}`)) + f.Add([]byte(`{"seq":0,"timestamp":1000,"compressed":true,"encrypted":false,"data":"aGVsbG8="}`)) + f.Add([]byte(`{"seq":0,"has_hmac":true,"data":"aGVsbG8="}`)) + f.Add([]byte(`{}`)) + f.Fuzz(func(t *testing.T, data []byte) { + var c chunk + if err := json.Unmarshal(data, &c); err != nil { + return // invalid JSON — not a bug + } + receiver, _ := newChunkReceiver(nil, nil, "fuzz-session", "c2s") + // Must not panic regardless of input. + _, _ = receiver.ingest(context.Background(), c) + }) +} + +func FuzzParseSessionMetadata(f *testing.F) { + f.Add([]byte(`{"session_id":"abc","version":1,"target_host":"1.2.3.4","target_port":80,"created_at":1000,"state":"pending","user":"alice"}`)) + f.Add([]byte(`{"state":"active"}`)) + f.Add([]byte(`{}`)) + f.Fuzz(func(t *testing.T, data []byte) { + var m sessionMetadata + _ = json.Unmarshal(data, &m) + // Verify field access doesn't panic. + _ = m.SessionID + m.User + string(m.State) + _ = m.TargetPort + _ = m.CreatedAt + }) +} + +func FuzzDecodeChunkPayload(f *testing.F) { + // Seed: valid base64, empty, random-looking. + f.Add([]byte("aGVsbG8="), false, false, false) + f.Add([]byte(""), false, false, false) + f.Add([]byte("AAAAAAAAAAAAAAAA"), true, false, false) // looks encrypted + f.Fuzz(func(t *testing.T, rawData []byte, encrypted, compressed, hasHMAC bool) { + c := chunk{ + Seq: 0, + Encrypted: encrypted, + Compressed: compressed, + HasHMAC: hasHMAC, + Data: string(rawData), + } + // Must not panic; errors are expected for malformed inputs. + _, _ = decodeChunkPayload(c, nil, nil, "fuzz-session", "c2s") + }) +} diff --git a/protocol/firebasetunnel/session_test.go b/protocol/firebasetunnel/session_test.go index 08f11bf046..b5f31c8cbb 100644 --- a/protocol/firebasetunnel/session_test.go +++ b/protocol/firebasetunnel/session_test.go @@ -16,8 +16,9 @@ import ( // REST API, sufficient for exercising chunkSender/chunkReceiver against a // real *firebaseClient without network access. type fakeFirebaseServer struct { - mu sync.Mutex - data map[string]json.RawMessage + mu sync.Mutex + data map[string]json.RawMessage + listeners []chan struct{} } func newFakeFirebaseServer() *httptest.Server { @@ -25,8 +26,61 @@ func newFakeFirebaseServer() *httptest.Server { return httptest.NewServer(http.HandlerFunc(f.handle)) } +func (f *fakeFirebaseServer) notifyListeners() { + for _, ch := range f.listeners { + select { + case ch <- struct{}{}: + default: + } + } +} + func (f *fakeFirebaseServer) handle(w http.ResponseWriter, r *http.Request) { path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), ".json") + + // SSE streaming endpoint. + if r.Method == http.MethodGet && r.Header.Get("Accept") == "text/event-stream" { + notify := make(chan struct{}, 4) + f.mu.Lock() + f.listeners = append(f.listeners, notify) + // Send initial state. + snapshot, _ := json.Marshal(f.buildSnapshot()) + f.mu.Unlock() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + // Firebase SSE format. + _, _ = w.Write([]byte("event: put\ndata: {\"path\":\"/\",\"data\":" + string(snapshot) + "}\n\n")) + flusher.Flush() + + for { + select { + case <-notify: + f.mu.Lock() + snap, _ := json.Marshal(f.buildSnapshot()) + f.mu.Unlock() + _, _ = w.Write([]byte("event: put\ndata: {\"path\":\"/\",\"data\":" + string(snap) + "}\n\n")) + flusher.Flush() + case <-r.Context().Done(): + f.mu.Lock() + listeners := f.listeners[:0] + for _, ch := range f.listeners { + if ch != notify { + listeners = append(listeners, ch) + } + } + f.listeners = listeners + f.mu.Unlock() + return + } + } + } + f.mu.Lock() defer f.mu.Unlock() @@ -41,6 +95,7 @@ func (f *fakeFirebaseServer) handle(w http.ResponseWriter, r *http.Request) { body := make([]byte, r.ContentLength) r.Body.Read(body) f.data[path] = json.RawMessage(body) + f.notifyListeners() w.Write([]byte("{}")) case http.MethodDelete: delete(f.data, path) @@ -50,6 +105,16 @@ func (f *fakeFirebaseServer) handle(w http.ResponseWriter, r *http.Request) { } } +// buildSnapshot returns the top-level "sessions" map for SSE payloads. +// Must be called with f.mu held. +func (f *fakeFirebaseServer) buildSnapshot() map[string]json.RawMessage { + out := make(map[string]json.RawMessage) + for k, v := range f.data { + out[k] = v + } + return out +} + func TestChunkSenderReceiverRoundTrip(t *testing.T) { srv := newFakeFirebaseServer() defer srv.Close() @@ -58,8 +123,9 @@ func TestChunkSenderReceiverRoundTrip(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - queuePath := "sessions/test/c2s" - sender := newChunkSender(ctx, queuePath, fb, 20*time.Millisecond, 1024, nil, nil) + sessionID := "test-session" + queuePath := "sessions/" + sessionID + "/c2s" + sender := newChunkSender(ctx, queuePath, sessionID, "c2s", fb, 20*time.Millisecond, 1024, nil, nil, nil) payload := []byte("hello firebase tunnel") if err := sender.feed(ctx, payload); err != nil { @@ -77,7 +143,7 @@ func TestChunkSenderReceiverRoundTrip(t *testing.T) { t.Fatalf("expected 1 chunk, got %d", len(chunks)) } - receiver, byteRx := newChunkReceiver(nil) + receiver, byteRx := newChunkReceiver(nil, nil, sessionID, "c2s") if _, err := receiver.ingest(ctx, chunks[0]); err != nil { t.Fatalf("ingest: %v", err) } @@ -100,9 +166,10 @@ func TestChunkSenderEncrypted(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + sessionID := "test-session-2" key := deriveKey("shared-psk") - queuePath := "sessions/test2/c2s" - sender := newChunkSender(ctx, queuePath, fb, 20*time.Millisecond, 1024, &key, nil) + queuePath := "sessions/" + sessionID + "/c2s" + sender := newChunkSender(ctx, queuePath, sessionID, "c2s", fb, 20*time.Millisecond, 1024, &key, nil, nil) payload := []byte("encrypted payload bytes") if err := sender.feed(ctx, payload); err != nil { @@ -120,13 +187,13 @@ func TestChunkSenderEncrypted(t *testing.T) { // Wrong key must fail to decrypt. wrongKey := deriveKey("wrong-psk") - receiverWrong, _ := newChunkReceiver(&wrongKey) + receiverWrong, _ := newChunkReceiver(&wrongKey, nil, sessionID, "c2s") if _, err := receiverWrong.ingest(ctx, chunks[0]); err == nil { t.Fatal("expected ingest failure with wrong key") } // Correct key must succeed. - receiver, byteRx := newChunkReceiver(&key) + receiver, byteRx := newChunkReceiver(&key, nil, sessionID, "c2s") if _, err := receiver.ingest(ctx, chunks[0]); err != nil { t.Fatalf("ingest with correct key: %v", err) } @@ -140,9 +207,99 @@ func TestChunkSenderEncrypted(t *testing.T) { } } +func TestChunkSenderHMAC(t *testing.T) { + srv := newFakeFirebaseServer() + defer srv.Close() + + fb := newFirebaseClient(srv.URL, "test-secret", "", 0, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sessionID := "test-hmac" + hmacKey := deriveHMACKey("test-secret") + queuePath := "sessions/" + sessionID + "/c2s" + sender := newChunkSender(ctx, queuePath, sessionID, "c2s", fb, 20*time.Millisecond, 1024, nil, hmacKey, nil) + + payload := []byte("hmac protected payload") + if err := sender.feed(ctx, payload); err != nil { + t.Fatalf("feed: %v", err) + } + time.Sleep(100 * time.Millisecond) + + chunks, err := fetchNewChunks(ctx, fb, queuePath, nil) + if err != nil { + t.Fatalf("fetchNewChunks: %v", err) + } + if len(chunks) != 1 || !chunks[0].HasHMAC { + t.Fatalf("expected 1 HMAC chunk, got %+v", chunks) + } + + // Wrong HMAC key must fail. + wrongHMACKey := deriveHMACKey("wrong-secret") + receiverWrong, _ := newChunkReceiver(nil, wrongHMACKey, sessionID, "c2s") + if _, err := receiverWrong.ingest(ctx, chunks[0]); err == nil { + t.Fatal("expected ingest failure with wrong HMAC key") + } + + // Correct HMAC key must succeed. + receiver, byteRx := newChunkReceiver(nil, hmacKey, sessionID, "c2s") + if _, err := receiver.ingest(ctx, chunks[0]); err != nil { + t.Fatalf("ingest with correct hmac key: %v", err) + } + select { + case data := <-byteRx: + if string(data) != string(payload) { + t.Fatalf("got %q want %q", data, payload) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for reassembled bytes") + } +} + +func TestChunkAADBinding(t *testing.T) { + // A chunk encrypted for session A / direction c2s / seq 0 must not decrypt + // if the receiver uses a different sessionID, direction, or seq. + key := deriveKey("binding-psk") + sessionID := "session-a" + payload := []byte("aad binding test") + + aad := chunkAAD(sessionID, "c2s", 0) + ct, err := encryptPayloadAAD(key, payload, aad) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + + // Wrong session ID. + wrongAAD := chunkAAD("session-b", "c2s", 0) + if _, err := decryptPayloadAAD(key, ct, wrongAAD); err == nil { + t.Fatal("expected failure with wrong sessionID in AAD") + } + + // Wrong direction. + wrongDir := chunkAAD(sessionID, "s2c", 0) + if _, err := decryptPayloadAAD(key, ct, wrongDir); err == nil { + t.Fatal("expected failure with wrong direction in AAD") + } + + // Wrong seq. + wrongSeq := chunkAAD(sessionID, "c2s", 1) + if _, err := decryptPayloadAAD(key, ct, wrongSeq); err == nil { + t.Fatal("expected failure with wrong seq in AAD") + } + + // Correct AAD must succeed. + pt, err := decryptPayloadAAD(key, ct, aad) + if err != nil { + t.Fatalf("correct AAD failed: %v", err) + } + if string(pt) != string(payload) { + t.Fatalf("got %q want %q", pt, payload) + } +} + func TestChunkReceiverOutOfOrder(t *testing.T) { ctx := context.Background() - receiver, byteRx := newChunkReceiver(nil) + receiver, byteRx := newChunkReceiver(nil, nil, "sid", "c2s") c1 := chunk{Seq: 1, Data: encodeRaw([]byte("b"))} c0 := chunk{Seq: 0, Data: encodeRaw([]byte("a"))} @@ -170,7 +327,7 @@ func TestChunkReceiverOutOfOrder(t *testing.T) { func TestChunkReceiverDuplicateIgnored(t *testing.T) { ctx := context.Background() - receiver, byteRx := newChunkReceiver(nil) + receiver, byteRx := newChunkReceiver(nil, nil, "sid", "c2s") c0 := chunk{Seq: 0, Data: encodeRaw([]byte("a"))} if _, err := receiver.ingest(ctx, c0); err != nil { @@ -197,7 +354,7 @@ func TestSenderBackpressure(t *testing.T) { defer cancel() // Long batch interval so nothing flushes during the test. - sender := newChunkSender(ctx, "sessions/x/c2s", fb, time.Hour, 1<<30, nil, nil) + sender := newChunkSender(ctx, "sessions/x/c2s", "x", "c2s", fb, time.Hour, 1<<30, nil, nil, nil) big := make([]byte, maxPendingBytes) if err := sender.feed(ctx, big); err != nil { @@ -208,6 +365,18 @@ func TestSenderBackpressure(t *testing.T) { } } +func TestChunkSizeCapRejected(t *testing.T) { + ctx := context.Background() + receiver, _ := newChunkReceiver(nil, nil, "sid", "c2s") + + // Craft a chunk whose Data field exceeds the encoded size cap. + oversized := make([]byte, maxChunkBytes+1) + c := chunk{Seq: 0, Data: base64.StdEncoding.EncodeToString(oversized)} + if _, err := receiver.ingest(ctx, c); err == nil { + t.Fatal("expected error for oversized chunk") + } +} + func encodeRaw(data []byte) string { return base64.StdEncoding.EncodeToString(data) } From 737876188259869e86bec2402aa2a8a922eced9f Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Tue, 30 Jun 2026 22:29:44 +0330 Subject: [PATCH 8/8] refactor(firebasetunnel): merge client/server into single Endpoint type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace TypeFirebaseTunnelClient + TypeFirebaseTunnelServer (two separate proxy types) with a single TypeFirebaseTunnel. Role — server (inbound) or client (outbound) — is now determined at construction time by which sub-config is present in FirebaseTunnelOptions: options.Server != nil → server mode, options.Client != nil → client mode. Changes: - option/firebasetunnel.go: one FirebaseTunnelOptions with nested *FirebaseTunnelServerConfig and *FirebaseTunnelClientConfig; shared fields (firebase_urls, firebase_secret, firebase_auth_token, retry_limit) at top level - constant/proxy.go: two constants → TypeFirebaseTunnel = "firebasetunnel" - protocol/firebasetunnel/endpoint.go: single Endpoint struct with srv *serverState (nil in client mode); extract drainInto, copyToSender, isTerminal helpers; fix perUserActive map leak on releaseSessionSlot; add all missing constants - protocol/firebasetunnel/client.go: deleted - protocol/firebasetunnel/server.go: deleted - include/registry.go: two Register calls → one RegisterEndpoint call - protocol/firebasetunnel/endpoint_test.go: update struct literals to Endpoint{} Co-Authored-By: Claude Sonnet 4.6 --- constant/proxy.go | 9 +- include/registry.go | 3 +- option/firebasetunnel.go | 97 +-- protocol/firebasetunnel/client.go | 310 ---------- protocol/firebasetunnel/endpoint.go | 754 +++++++++++++++++++++++ protocol/firebasetunnel/endpoint_test.go | 30 +- protocol/firebasetunnel/server.go | 525 ---------------- 7 files changed, 819 insertions(+), 909 deletions(-) delete mode 100644 protocol/firebasetunnel/client.go create mode 100644 protocol/firebasetunnel/endpoint.go delete mode 100644 protocol/firebasetunnel/server.go diff --git a/constant/proxy.go b/constant/proxy.go index 5996ec5377..d674af09b7 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -50,8 +50,7 @@ const ( TypeBalancer = "balancer" //H TypeDNSTT = "dnstt" //H TypeGooseRelay = "gooserelay" //H - TypeFirebaseTunnelClient = "firebasetunnel_client" //H - TypeFirebaseTunnelServer = "firebasetunnel_server" //H + TypeFirebaseTunnel = "firebasetunnel" //H TypeSmartDNSPool = "smart_dns_pool" //H — local recursive-resolver pool with AIMD throttling + recovery probing (github.com/hiddify/hmrd_multi_resolver_dns) ) @@ -144,10 +143,8 @@ func ProxyDisplayName(proxyType string) string { return "DNSTT" case TypeGooseRelay: return "GooseRelay" - case TypeFirebaseTunnelClient: - return "Firebase Tunnel Client" - case TypeFirebaseTunnelServer: - return "Firebase Tunnel Server" + case TypeFirebaseTunnel: + return "Firebase Tunnel" default: return "Unknown" } diff --git a/include/registry.go b/include/registry.go index 3765c6f186..5c73467b09 100644 --- a/include/registry.go +++ b/include/registry.go @@ -130,8 +130,7 @@ func EndpointRegistry() *endpoint.Registry { tunnel.RegisterServerEndpoint(registry) tunnel.RegisterClientEndpoint(registry) - firebasetunnel.RegisterServerEndpoint(registry) - firebasetunnel.RegisterClientEndpoint(registry) + firebasetunnel.RegisterEndpoint(registry) registerWireGuardEndpoint(registry) registerWarpEndpoint(registry) diff --git a/option/firebasetunnel.go b/option/firebasetunnel.go index 6f703d4b4d..67946bac96 100644 --- a/option/firebasetunnel.go +++ b/option/firebasetunnel.go @@ -2,62 +2,67 @@ package option import "github.com/sagernet/sing/common/json/badoption" -// FirebaseTunnelUser identifies a client allowed to connect to a -// firebasetunnel_server endpoint. Name is used purely as a traffic -// accounting label (surfaced via the SSM traffic manager) unless PSK is -// set, in which case the server also requires the client's chunks to -// decrypt successfully under that user's key before accepting the label. +// FirebaseTunnelUser identifies a client allowed to connect when this +// endpoint acts as a server. Name is used as a traffic-accounting label +// (surfaced via the SSM traffic manager). If PSK is set the server also +// requires the client's chunks to decrypt successfully before accepting +// the label. type FirebaseTunnelUser struct { Name string `json:"name"` - // PSK, if set, both authenticates this user (decrypt-or-reject) and - // encrypts this user's relayed payload bytes against a passive reader - // of the underlying Firebase project. Optional: omit to match the - // upstream PoC's behavior (relayed bytes are cleartext to anyone who - // can read the Firebase project). + // PSK authenticates this user and encrypts their relayed payload bytes + // against a passive reader of the Firebase project. Optional: omit to + // relay cleartext (anyone who can read the project can see the data). PSK string `json:"psk,omitempty"` } -// FirebaseTunnelClientOptions configures the client side of a Firebase -// Realtime Database relay tunnel (adapted from github.com/Hiddify2/Firebase-Tunnel). -// -// firebase_secret is the legacy Firebase Database Secret, appended as -// ?auth= to every REST call. Anyone holding it has full read/write -// access to the entire Firebase project, not just this tunnel's data — -// prefer firebase_auth_token for anything beyond personal/test use. -type FirebaseTunnelClientOptions struct { - FirebaseURLs badoption.Listable[string] `json:"firebase_urls"` - FirebaseSecret string `json:"firebase_secret,omitempty"` - FirebaseAuthToken string `json:"firebase_auth_token,omitempty"` - // User is this client's self-reported identity, recorded in session - // metadata for traffic accounting. Verified by PSK if PSK is set. - User string `json:"user"` - PSK string `json:"psk,omitempty"` - BatchInterval badoption.Duration `json:"batch_interval,omitempty"` - BatchMaxBytes int `json:"batch_max_bytes,omitempty"` - RetryLimit uint32 `json:"retry_limit,omitempty"` - // ActivationTimeout bounds how long Dial waits for the server to mark - // a session Active before failing fast (so outbound groups like - // urltest/selector can fail over promptly during a Firebase outage). +// FirebaseTunnelServerConfig holds options that are only meaningful when +// this endpoint acts as a server (inbound). Its presence in +// FirebaseTunnelOptions switches the endpoint into server mode; omitting +// it (nil) selects client (outbound) mode. +type FirebaseTunnelServerConfig struct { + Users []FirebaseTunnelUser `json:"users"` + PollInterval badoption.Duration `json:"poll_interval,omitempty"` + SessionTimeout badoption.Duration `json:"session_timeout,omitempty"` + MaxSessions int `json:"max_sessions,omitempty"` + MaxSessionsPerUser int `json:"max_sessions_per_user,omitempty"` + // MaxSessionsPerSecondPerUser rate-limits new session creation per user + // (token bucket). Zero → built-in default (5/s). + MaxSessionsPerSecondPerUser int `json:"max_sessions_per_second_per_user,omitempty"` +} + +// FirebaseTunnelClientConfig holds options that are only meaningful when +// this endpoint acts as a client (outbound). +type FirebaseTunnelClientConfig struct { + // User is this client's self-reported identity, verified by PSK if set. + User string `json:"user"` + PSK string `json:"psk,omitempty"` + BatchInterval badoption.Duration `json:"batch_interval,omitempty"` + BatchMaxBytes int `json:"batch_max_bytes,omitempty"` ActivationTimeout badoption.Duration `json:"activation_timeout,omitempty"` } -// FirebaseTunnelServerOptions configures the server side of a Firebase -// Realtime Database relay tunnel. -type FirebaseTunnelServerOptions struct { +// FirebaseTunnelOptions configures a Firebase Realtime Database relay tunnel +// (adapted from github.com/Hiddify2/Firebase-Tunnel). +// +// Role is determined by which sub-config is present: +// - Server != nil → server (inbound) mode: listens for pending sessions +// written to the Firebase project and routes them through sing-box. +// - Client != nil → client (outbound) mode: dials by writing a session +// request to Firebase and waiting for the server to activate it. +// +// Exactly one of Server or Client must be set. +// +// firebase_secret is the legacy Firebase Database Secret appended as +// ?auth= to every REST call. Anyone holding it has full read/write +// access to the entire Firebase project — prefer firebase_auth_token for +// anything beyond personal/test use. +type FirebaseTunnelOptions struct { FirebaseURLs badoption.Listable[string] `json:"firebase_urls"` FirebaseSecret string `json:"firebase_secret,omitempty"` FirebaseAuthToken string `json:"firebase_auth_token,omitempty"` - Users []FirebaseTunnelUser `json:"users"` - PollInterval badoption.Duration `json:"poll_interval,omitempty"` - SessionTimeout badoption.Duration `json:"session_timeout,omitempty"` RetryLimit uint32 `json:"retry_limit,omitempty"` - // MaxSessions caps total concurrent sessions for this endpoint. - // Zero means use the built-in default (1000). - MaxSessions int `json:"max_sessions,omitempty"` - // MaxSessionsPerUser caps concurrent sessions for any single user. - // Zero means use the built-in default (50). - MaxSessionsPerUser int `json:"max_sessions_per_user,omitempty"` - // MaxSessionsPerSecondPerUser rate-limits new session creation per - // user (token bucket). Zero means use the built-in default (5/s). - MaxSessionsPerSecondPerUser int `json:"max_sessions_per_second_per_user,omitempty"` + + // Exactly one must be non-nil. + Server *FirebaseTunnelServerConfig `json:"server,omitempty"` + Client *FirebaseTunnelClientConfig `json:"client,omitempty"` } diff --git a/protocol/firebasetunnel/client.go b/protocol/firebasetunnel/client.go deleted file mode 100644 index 7cefcbfbf8..0000000000 --- a/protocol/firebasetunnel/client.go +++ /dev/null @@ -1,310 +0,0 @@ -package firebasetunnel - -import ( - "context" - "fmt" - "io" - "net" - "os" - "time" - - "github.com/google/uuid" - "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/adapter/endpoint" - "github.com/sagernet/sing-box/adapter/outbound" - C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/log" - "github.com/sagernet/sing-box/option" - E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/common/logger" - M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" -) - -const ( - defaultBatchInterval = 50 * time.Millisecond - defaultBatchMaxBytes = 32 * 1024 - defaultRetryLimit = 5 - defaultActivationTimeout = 30 * time.Second - defaultS2CPollInterval = 150 * time.Millisecond - defaultReadBufSize = 32 * 1024 - - // warnActivationAfter is how long waitForActive waits before emitting a - // Warn log so operators know the server may be absent. - warnActivationAfter = 3 * time.Second -) - -func RegisterClientEndpoint(registry *endpoint.Registry) { - endpoint.Register[option.FirebaseTunnelClientOptions](registry, C.TypeFirebaseTunnelClient, NewClientEndpoint) -} - -type ClientEndpoint struct { - outbound.Adapter - logger logger.ContextLogger - fb *firebaseClient - user string - key *[32]byte - hmacKey []byte - batchInterval time.Duration - batchMaxBytes int - activationTimeout time.Duration -} - -func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FirebaseTunnelClientOptions) (adapter.Endpoint, error) { - urls := options.FirebaseURLs - if len(urls) == 0 { - return nil, E.New("firebase_urls is required") - } - if options.FirebaseSecret == "" && options.FirebaseAuthToken == "" { - return nil, E.New("one of firebase_secret or firebase_auth_token is required") - } - if options.User == "" { - return nil, E.New("user is required") - } - - retryLimit := options.RetryLimit - if retryLimit == 0 { - retryLimit = defaultRetryLimit - } - fb := newFirebaseClient(urls[0], options.FirebaseSecret, options.FirebaseAuthToken, retryLimit, logger) - - var key *[32]byte - if options.PSK != "" { - k := deriveKey(options.PSK) - key = &k - } - - // Derive HMAC key from firebase_secret for integrity on unencrypted path. - var hmacKey []byte - if options.FirebaseSecret != "" && key == nil { - hmacKey = deriveHMACKey(options.FirebaseSecret) - } - - batchInterval := time.Duration(options.BatchInterval) - if batchInterval <= 0 { - batchInterval = defaultBatchInterval - } - batchMaxBytes := options.BatchMaxBytes - if batchMaxBytes <= 0 { - batchMaxBytes = defaultBatchMaxBytes - } - activationTimeout := time.Duration(options.ActivationTimeout) - if activationTimeout <= 0 { - activationTimeout = defaultActivationTimeout - } - - return &ClientEndpoint{ - Adapter: outbound.NewAdapter(C.TypeFirebaseTunnelClient, tag, []string{N.NetworkTCP}, nil), - logger: logger, - fb: fb, - user: options.User, - key: key, - hmacKey: hmacKey, - batchInterval: batchInterval, - batchMaxBytes: batchMaxBytes, - activationTimeout: activationTimeout, - }, nil -} - -func (c *ClientEndpoint) Start(stage adapter.StartStage) error { - return nil -} - -func (c *ClientEndpoint) Close() error { - return nil -} - -func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if network != N.NetworkTCP { - return nil, os.ErrInvalid - } - - sessionID := uuid.New().String() - meta := sessionMetadata{ - SessionID: sessionID, - Version: protocolVersion, - TargetHost: destination.AddrString(), - TargetPort: destination.Port, - CreatedAt: nowMillis(), - State: sessionStatePending, - User: c.user, - } - if err := c.fb.Put(ctx, pathMetadata(sessionID), &meta); err != nil { - return nil, fmt.Errorf("firebasetunnel: creating session: %w", err) - } - - activateCtx, cancel := context.WithTimeout(ctx, c.activationTimeout) - defer cancel() - if err := c.waitForActive(activateCtx, sessionID); err != nil { - return nil, fmt.Errorf("firebasetunnel: session activation: %w", err) - } - - local, remote := net.Pipe() - go c.runSession(ctx, sessionID, remote) - return local, nil -} - -func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return nil, os.ErrInvalid -} - -func (c *ClientEndpoint) waitForActive(ctx context.Context, sessionID string) error { - path := pathMetadata(sessionID) - start := time.Now() - warned := false - consecutiveErrors := 0 - - for { - var meta sessionMetadata - found, err := c.fb.Get(ctx, path, &meta) - if err != nil { - consecutiveErrors++ - backoff := jitteredBackoff(consecutiveErrors) - c.logger.WarnContext(ctx, "firebasetunnel: poll error waiting for session ", sessionID, " to activate: ", err) - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(backoff): - continue - } - } - consecutiveErrors = 0 - - if found { - switch meta.State { - case sessionStateActive: - return nil - case sessionStateClosed: - return E.New("server rejected session") - } - } - - if !warned && time.Since(start) > warnActivationAfter { - warned = true - c.logger.WarnContext(ctx, "firebasetunnel: session ", sessionID, " still pending after ", time.Since(start).Round(time.Second), " — server may be absent or overloaded") - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(250 * time.Millisecond): - } - } -} - -// runSession relays bytes between conn (the server-side end of the -// net.Pipe handed to the caller of DialContext) and Firebase, until conn -// closes or the server marks the session Closed. -func (c *ClientEndpoint) runSession(ctx context.Context, sessionID string, conn net.Conn) { - defer conn.Close() - sender := newChunkSender(ctx, pathC2S(sessionID), sessionID, "c2s", c.fb, c.batchInterval, c.batchMaxBytes, c.key, c.hmacKey, c.logger) - - relayCtx, cancel := context.WithCancel(ctx) - defer cancel() - - c2sDone := make(chan struct{}) - go func() { - defer close(c2sDone) - buf := make([]byte, defaultReadBufSize) - for { - n, err := conn.Read(buf) - if n > 0 { - if feedErr := sender.feed(relayCtx, buf[:n]); feedErr != nil { - c.logger.WarnContext(ctx, "firebasetunnel: c2s feed error: ", feedErr) - break - } - } - if err != nil { - break - } - } - _ = c.setSessionState(context.Background(), sessionID, sessionStateClosing) - }() - - s2cDone := make(chan struct{}) - go func() { - defer close(s2cDone) - c.runS2C(relayCtx, sessionID, conn) - }() - - select { - case <-c2sDone: - case <-s2cDone: - } - cancel() - - _ = c.setSessionState(context.Background(), sessionID, sessionStateClosed) - if err := c.fb.Delete(context.Background(), "sessions/"+sessionID); err != nil { - c.logger.WarnContext(ctx, "firebasetunnel: session cleanup failed: ", err) - } -} - -func (c *ClientEndpoint) runS2C(ctx context.Context, sessionID string, conn net.Conn) { - receiver, byteRx := newChunkReceiver(c.key, c.hmacKey, sessionID, "s2c") - var deliveredUpTo *uint64 - s2cPath := pathS2C(sessionID) - ackPath := pathAcks(sessionID) + "/s2c_ack" - - for { - var meta sessionMetadata - found, err := c.fb.Get(ctx, pathMetadata(sessionID), &meta) - if err == nil && found && (meta.State == sessionStateClosing || meta.State == sessionStateClosed) { - return - } - - chunks, err := fetchNewChunks(ctx, c.fb, s2cPath, deliveredUpTo) - if err != nil { - select { - case <-ctx.Done(): - return - case <-time.After(defaultS2CPollInterval): - continue - } - } - - for _, ck := range chunks { - newAck, err := receiver.ingest(ctx, ck) - if err != nil { - c.logger.WarnContext(ctx, "firebasetunnel: s2c ingest error: ", err) - return - } - if newAck == nil { - continue - } - deliveredUpTo = newAck - drainLoop: - for { - select { - case data := <-byteRx: - if _, err := conn.Write(data); err != nil { - return - } - default: - break drainLoop - } - } - if err := updateAckAndCleanup(ctx, c.fb, ackPath, s2cPath, *newAck, c.logger); err != nil { - c.logger.WarnContext(ctx, "firebasetunnel: ack update failed: ", err) - } - } - - select { - case <-ctx.Done(): - return - case <-time.After(defaultS2CPollInterval): - } - } -} - -func (c *ClientEndpoint) setSessionState(ctx context.Context, sessionID string, state sessionState) error { - path := pathMetadata(sessionID) - var meta sessionMetadata - found, err := c.fb.Get(ctx, path, &meta) - if err != nil || !found { - return err - } - meta.State = state - return c.fb.Put(ctx, path, &meta) -} - -var _ = io.EOF diff --git a/protocol/firebasetunnel/endpoint.go b/protocol/firebasetunnel/endpoint.go new file mode 100644 index 0000000000..f2cf8eb2e4 --- /dev/null +++ b/protocol/firebasetunnel/endpoint.go @@ -0,0 +1,754 @@ +package firebasetunnel + +import ( + "context" + "fmt" + "net" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +// Timing / capacity defaults. Callers may override via options. +const ( + defaultBatchInterval = 50 * time.Millisecond + defaultBatchMaxBytes = 32 * 1024 + defaultRetryLimit = 5 + defaultActivationTimeout = 30 * time.Second + defaultS2CPollInterval = 150 * time.Millisecond + defaultReadBufSize = 32 * 1024 + warnActivationAfter = 3 * time.Second + + defaultPollInterval = 200 * time.Millisecond + defaultSessionTimeout = 300 * time.Second + defaultMaxSessions = 1000 + defaultMaxSessionsPerUser = 50 + defaultMaxSessionsPerSecondPerUser = 5 + drainTimeout = 5 * time.Second +) + +// userConfig holds per-user server-side settings derived from options at +// construction time. +type userConfig struct { + name string + key *[32]byte // nil → no PSK, HMAC-only integrity +} + +// serverState holds all fields that are only needed in server (inbound) mode. +// When Endpoint.srv is nil the endpoint is in client (outbound) mode. +type serverState struct { + router adapter.Router + users map[string]userConfig + pollInterval time.Duration + sessionTimeout time.Duration + tracker adapter.SSMTracker + + maxSessions int + maxSessionsPerUser int + sessionRate int + + mu sync.Mutex + activeSessions int + perUserActive map[string]int + perUserBucket map[string]*tokenBucket + + closing atomic.Bool + stop context.CancelFunc +} + +// Endpoint implements adapter.Endpoint for the Firebase Tunnel protocol in +// either client (outbound) or server (inbound) mode. +// +// Role is fixed at construction time: +// - options.Client != nil → client mode: dials via DialContext +// - options.Server != nil → server mode: discovers sessions in pollLoop +// +// Exactly one must be set; NewEndpoint validates this. +type Endpoint struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + fb *firebaseClient + key *[32]byte // encryption key (PSK-derived); nil → HMAC-only + hmacKey []byte // integrity key; nil when key != nil or no secret + + // client-mode only + user string + batchInterval time.Duration + batchMaxBytes int + activationTimeout time.Duration + + // non-nil only in server mode + srv *serverState +} + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.FirebaseTunnelOptions](registry, C.TypeFirebaseTunnel, NewEndpoint) +} + +func NewEndpoint(ctx context.Context, router adapter.Router, lg log.ContextLogger, tag string, options option.FirebaseTunnelOptions) (adapter.Endpoint, error) { + switch { + case options.Server != nil && options.Client != nil: + return nil, E.New("firebasetunnel: set exactly one of server or client, not both") + case options.Server == nil && options.Client == nil: + return nil, E.New("firebasetunnel: one of server or client must be set") + case len(options.FirebaseURLs) == 0: + return nil, E.New("firebase_urls is required") + case options.FirebaseSecret == "" && options.FirebaseAuthToken == "": + return nil, E.New("one of firebase_secret or firebase_auth_token is required") + } + + retryLimit := options.RetryLimit + if retryLimit == 0 { + retryLimit = defaultRetryLimit + } + + ep := &Endpoint{ + Adapter: outbound.NewAdapter(C.TypeFirebaseTunnel, tag, []string{N.NetworkTCP}, nil), + ctx: ctx, + logger: lg, + fb: newFirebaseClient(options.FirebaseURLs[0], options.FirebaseSecret, options.FirebaseAuthToken, retryLimit, lg), + } + + if options.Client != nil { + return ep, ep.applyClientOptions(options) + } + return ep, ep.applyServerOptions(ctx, router, options) +} + +func (e *Endpoint) applyClientOptions(options option.FirebaseTunnelOptions) error { + c := options.Client + if c.User == "" { + return E.New("client.user is required") + } + + if c.PSK != "" { + k := deriveKey(c.PSK) + e.key = &k + } else if options.FirebaseSecret != "" { + e.hmacKey = deriveHMACKey(options.FirebaseSecret) + } + + e.user = c.User + e.batchInterval = durationOr(time.Duration(c.BatchInterval), defaultBatchInterval) + e.batchMaxBytes = intOr(c.BatchMaxBytes, defaultBatchMaxBytes) + e.activationTimeout = durationOr(time.Duration(c.ActivationTimeout), defaultActivationTimeout) + return nil +} + +func (e *Endpoint) applyServerOptions(ctx context.Context, router adapter.Router, options option.FirebaseTunnelOptions) error { + s := options.Server + if len(s.Users) == 0 { + return E.New("server.users must have at least one entry") + } + + users := make(map[string]userConfig, len(s.Users)) + for _, u := range s.Users { + if u.Name == "" { + return E.New("user name must not be empty") + } + uc := userConfig{name: u.Name} + if u.PSK != "" { + k := deriveKey(u.PSK) + uc.key = &k + } + users[u.Name] = uc + } + + if options.FirebaseSecret != "" { + e.hmacKey = deriveHMACKey(options.FirebaseSecret) + } + + e.srv = &serverState{ + router: router, + users: users, + pollInterval: durationOr(time.Duration(s.PollInterval), defaultPollInterval), + sessionTimeout: durationOr(time.Duration(s.SessionTimeout), defaultSessionTimeout), + maxSessions: intOr(s.MaxSessions, defaultMaxSessions), + maxSessionsPerUser: intOr(s.MaxSessionsPerUser, defaultMaxSessionsPerUser), + sessionRate: intOr(s.MaxSessionsPerSecondPerUser, defaultMaxSessionsPerSecondPerUser), + perUserActive: make(map[string]int), + perUserBucket: make(map[string]*tokenBucket), + } + return nil +} + +// SetTracker wires per-user traffic accounting into the SSM stats subsystem. +// No-op in client mode. +func (e *Endpoint) SetTracker(tracker adapter.SSMTracker) { + if e.srv == nil { + return + } + e.srv.mu.Lock() + e.srv.tracker = tracker + e.srv.mu.Unlock() +} + +func (e *Endpoint) Start(stage adapter.StartStage) error { + if e.srv == nil || stage != adapter.StartStatePostStart { + return nil + } + ctx, cancel := context.WithCancel(e.ctx) + e.srv.stop = cancel + go e.runWithRecovery(ctx, e.pollLoop) + go e.runWithRecovery(ctx, e.gcLoop) + return nil +} + +func (e *Endpoint) Close() error { + if e.srv == nil { + return nil + } + srv := e.srv + srv.closing.Store(true) + deadline := time.Now().Add(drainTimeout) + for time.Now().Before(deadline) { + srv.mu.Lock() + active := srv.activeSessions + srv.mu.Unlock() + if active == 0 { + break + } + time.Sleep(100 * time.Millisecond) + } + if srv.stop != nil { + srv.stop() + } + return nil +} + +// DialContext is only valid in client mode; returns os.ErrInvalid in server mode. +func (e *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if e.srv != nil || network != N.NetworkTCP { + return nil, os.ErrInvalid + } + + sessionID := uuid.New().String() + meta := sessionMetadata{ + SessionID: sessionID, + Version: protocolVersion, + TargetHost: destination.AddrString(), + TargetPort: destination.Port, + CreatedAt: nowMillis(), + State: sessionStatePending, + User: e.user, + } + if err := e.fb.Put(ctx, pathMetadata(sessionID), &meta); err != nil { + return nil, fmt.Errorf("firebasetunnel: creating session: %w", err) + } + + activateCtx, cancel := context.WithTimeout(ctx, e.activationTimeout) + defer cancel() + if err := e.waitForActive(activateCtx, sessionID); err != nil { + return nil, fmt.Errorf("firebasetunnel: session activation: %w", err) + } + + local, remote := net.Pipe() + go e.runSession(ctx, sessionID, remote) + return local, nil +} + +func (e *Endpoint) ListenPacket(_ context.Context, _ M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +// ── client internals ────────────────────────────────────────────────────────── + +func (e *Endpoint) waitForActive(ctx context.Context, sessionID string) error { + path := pathMetadata(sessionID) + start := time.Now() + warned := false + consecutiveErrors := 0 + + for { + var meta sessionMetadata + found, err := e.fb.Get(ctx, path, &meta) + if err != nil { + consecutiveErrors++ + e.logger.WarnContext(ctx, "firebasetunnel: poll error waiting for session ", sessionID, " to activate: ", err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(jitteredBackoff(consecutiveErrors)): + continue + } + } + consecutiveErrors = 0 + + if found { + switch meta.State { + case sessionStateActive: + return nil + case sessionStateClosed: + return E.New("server rejected session") + } + } + + if !warned && time.Since(start) > warnActivationAfter { + warned = true + e.logger.WarnContext(ctx, "firebasetunnel: session ", sessionID, " still pending after ", + time.Since(start).Round(time.Second), " — server may be absent or overloaded") + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } +} + +func (e *Endpoint) runSession(ctx context.Context, sessionID string, conn net.Conn) { + defer conn.Close() + + sender := newChunkSender(ctx, pathC2S(sessionID), sessionID, "c2s", + e.fb, e.batchInterval, e.batchMaxBytes, e.key, e.hmacKey, e.logger) + + relayCtx, cancel := context.WithCancel(ctx) + defer cancel() + + c2sDone := make(chan struct{}) + go func() { + defer close(c2sDone) + copyToSender(relayCtx, conn, sender, e.logger) + _ = e.setSessionState(context.Background(), sessionID, sessionStateClosing) + }() + + s2cDone := make(chan struct{}) + go func() { + defer close(s2cDone) + e.runS2C(relayCtx, sessionID, conn) + }() + + select { + case <-c2sDone: + case <-s2cDone: + } + cancel() + + _ = e.setSessionState(context.Background(), sessionID, sessionStateClosed) + if err := e.fb.Delete(context.Background(), "sessions/"+sessionID); err != nil { + e.logger.WarnContext(ctx, "firebasetunnel: session cleanup failed: ", err) + } +} + +func (e *Endpoint) runS2C(ctx context.Context, sessionID string, conn net.Conn) { + receiver, byteRx := newChunkReceiver(e.key, e.hmacKey, sessionID, "s2c") + var deliveredUpTo *uint64 + s2cPath := pathS2C(sessionID) + ackPath := pathAcks(sessionID) + "/s2c_ack" + + for { + var meta sessionMetadata + if found, err := e.fb.Get(ctx, pathMetadata(sessionID), &meta); err == nil && found && isTerminal(meta.State) { + return + } + + chunks, err := fetchNewChunks(ctx, e.fb, s2cPath, deliveredUpTo) + if err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(defaultS2CPollInterval): + continue + } + } + + for _, ck := range chunks { + newAck, err := receiver.ingest(ctx, ck) + if err != nil { + e.logger.WarnContext(ctx, "firebasetunnel: s2c ingest error: ", err) + return + } + if newAck == nil { + continue + } + deliveredUpTo = newAck + if err := drainInto(byteRx, conn); err != nil { + return + } + if err := updateAckAndCleanup(ctx, e.fb, ackPath, s2cPath, *newAck, e.logger); err != nil { + e.logger.WarnContext(ctx, "firebasetunnel: ack update failed: ", err) + } + } + + select { + case <-ctx.Done(): + return + case <-time.After(defaultS2CPollInterval): + } + } +} + +func (e *Endpoint) setSessionState(ctx context.Context, sessionID string, state sessionState) error { + path := pathMetadata(sessionID) + var meta sessionMetadata + found, err := e.fb.Get(ctx, path, &meta) + if err != nil || !found { + return err + } + meta.State = state + return e.fb.Put(ctx, path, &meta) +} + +// ── server internals ────────────────────────────────────────────────────────── + +func (e *Endpoint) runWithRecovery(ctx context.Context, fn func(context.Context)) { + for { + if ctx.Err() != nil { + return + } + func() { + defer func() { + if r := recover(); r != nil { + e.logger.ErrorContext(ctx, "firebasetunnel: server loop panic, restarting: ", r) + } + }() + fn(ctx) + }() + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + } +} + +func (e *Endpoint) pollLoop(ctx context.Context) { + srv := e.srv + seen := make(map[string]struct{}) + events := e.fb.listen(ctx, pathSessionsRoot()) + ticker := time.NewTicker(srv.pollInterval) + defer ticker.Stop() + + dispatch := func() { + var raw map[string]sessionMetadata + if found, err := e.fb.Get(ctx, pathSessionsRoot(), &raw); err != nil || !found { + return + } + for id, meta := range raw { + if meta.State != sessionStatePending { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + meta.SessionID = id + go e.handleSession(ctx, meta) + } + } + + for { + select { + case <-ctx.Done(): + return + case _, ok := <-events: + if !ok { + events = e.fb.listen(ctx, pathSessionsRoot()) + continue + } + dispatch() + case <-ticker.C: + dispatch() + } + } +} + +func (e *Endpoint) handleSession(ctx context.Context, meta sessionMetadata) { + sessionID := meta.SessionID + srv := e.srv + + userCfg, ok := srv.users[meta.User] + if !ok { + e.logger.WarnContext(ctx, "firebasetunnel: rejecting session ", sessionID, ": unknown user") + e.closeSession(ctx, sessionID) + return + } + if !e.acquireSessionSlot(meta.User) { + e.logger.WarnContext(ctx, "firebasetunnel: rejecting session ", sessionID, ": limit/rate exceeded for user ", meta.User) + e.closeSession(ctx, sessionID) + return + } + defer e.releaseSessionSlot(meta.User) + + // Collision defense: re-read to verify CreatedAt hasn't changed under us. + var current sessionMetadata + if found, _ := e.fb.Get(ctx, pathMetadata(sessionID), ¤t); !found || current.CreatedAt != meta.CreatedAt { + e.logger.WarnContext(ctx, "firebasetunnel: session collision for ", sessionID, ", rejecting") + e.closeSession(ctx, sessionID) + return + } + + destination := M.ParseSocksaddrHostPort(meta.TargetHost, meta.TargetPort) + inboundMeta := adapter.InboundContext{ + Inbound: e.Tag(), + InboundType: C.TypeFirebaseTunnel, + Destination: destination, + User: meta.User, + } + + local, remote := net.Pipe() + var conn net.Conn = local + srv.mu.Lock() + tracker := srv.tracker + srv.mu.Unlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, inboundMeta) + } + + updated := meta + updated.State = sessionStateActive + if err := e.fb.Put(ctx, pathMetadata(sessionID), &updated); err != nil { + remote.Close() + local.Close() + e.logger.WarnContext(ctx, "firebasetunnel: marking session active failed: ", err) + return + } + + sessionKey := userCfg.key + sessionHMACKey := e.hmacKey + if sessionKey != nil { + sessionHMACKey = nil // AEAD handles integrity; HMAC not needed + } + + relayDone := make(chan struct{}) + go func() { + defer close(relayDone) + e.runRelay(ctx, sessionID, remote, sessionKey, sessionHMACKey) + }() + + srv.router.RouteConnectionEx(ctx, conn, inboundMeta, func(error) {}) + <-relayDone + + updated.State = sessionStateClosed + _ = e.fb.Put(context.Background(), pathMetadata(sessionID), &updated) + if err := e.fb.Delete(context.Background(), "sessions/"+sessionID); err != nil { + e.logger.WarnContext(ctx, "firebasetunnel: session cleanup failed: ", err) + } +} + +func (e *Endpoint) closeSession(ctx context.Context, sessionID string) { + _ = e.fb.Put(ctx, pathMetadata(sessionID), &sessionMetadata{SessionID: sessionID, State: sessionStateClosed}) +} + +func (e *Endpoint) runRelay(ctx context.Context, sessionID string, conn net.Conn, key *[32]byte, hmacKey []byte) { + defer conn.Close() + srv := e.srv + + relayCtx, cancel := context.WithCancel(ctx) + defer cancel() + + c2sDone := make(chan struct{}) + go func() { + defer close(c2sDone) + e.runC2S(relayCtx, sessionID, conn, key, hmacKey) + }() + + s2cDone := make(chan struct{}) + go func() { + defer close(s2cDone) + sender := newChunkSender(relayCtx, pathS2C(sessionID), sessionID, "s2c", + e.fb, srv.pollInterval, defaultReadBufSize, key, hmacKey, e.logger) + copyToSender(relayCtx, conn, sender, e.logger) + }() + + select { + case <-c2sDone: + case <-s2cDone: + } + cancel() +} + +func (e *Endpoint) runC2S(ctx context.Context, sessionID string, conn net.Conn, key *[32]byte, hmacKey []byte) { + srv := e.srv + receiver, byteRx := newChunkReceiver(key, hmacKey, sessionID, "c2s") + var deliveredUpTo *uint64 + c2sPath := pathC2S(sessionID) + ackPath := pathAcks(sessionID) + "/c2s_ack" + lastActivity := time.Now() + + for { + if time.Since(lastActivity) > srv.sessionTimeout { + e.logger.WarnContext(ctx, "firebasetunnel: session ", sessionID, " timed out") + return + } + + var meta sessionMetadata + if found, err := e.fb.Get(ctx, pathMetadata(sessionID), &meta); err == nil && found && isTerminal(meta.State) { + return + } + + chunks, err := fetchNewChunks(ctx, e.fb, c2sPath, deliveredUpTo) + if err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(srv.pollInterval): + continue + } + } + if len(chunks) > 0 { + lastActivity = time.Now() + } + + for _, ck := range chunks { + newAck, err := receiver.ingest(ctx, ck) + if err != nil { + e.logger.WarnContext(ctx, "firebasetunnel: c2s ingest error (session ", sessionID, "): ", err) + return + } + if newAck == nil { + continue + } + deliveredUpTo = newAck + if err := drainInto(byteRx, conn); err != nil { + return + } + if err := updateAckAndCleanup(ctx, e.fb, ackPath, c2sPath, *newAck, e.logger); err != nil { + e.logger.WarnContext(ctx, "firebasetunnel: ack update failed: ", err) + } + } + + select { + case <-ctx.Done(): + return + case <-time.After(srv.pollInterval): + } + } +} + +func (e *Endpoint) gcLoop(ctx context.Context) { + srv := e.srv + ticker := time.NewTicker(srv.sessionTimeout / 2) + defer ticker.Stop() + const closedGrace = 10 * time.Second + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + var raw map[string]sessionMetadata + if found, err := e.fb.Get(ctx, pathSessionsRoot(), &raw); err != nil || !found { + continue + } + now := nowMillis() + for id, meta := range raw { + age := time.Duration(now-meta.CreatedAt) * time.Millisecond + stale := (meta.State == sessionStatePending && age > srv.sessionTimeout) || + (isTerminal(meta.State) && age > srv.sessionTimeout+closedGrace) + if stale { + if err := e.fb.Delete(ctx, "sessions/"+id); err != nil { + e.logger.WarnContext(ctx, "firebasetunnel: gc delete failed for ", id, ": ", err) + } + } + } + } + } +} + +func (e *Endpoint) acquireSessionSlot(user string) bool { + srv := e.srv + if srv.closing.Load() { + return false + } + srv.mu.Lock() + defer srv.mu.Unlock() + + bucket, ok := srv.perUserBucket[user] + if !ok { + bucket = newTokenBucket(srv.sessionRate, srv.sessionRate) + srv.perUserBucket[user] = bucket + } + if !bucket.take() || srv.activeSessions >= srv.maxSessions || srv.perUserActive[user] >= srv.maxSessionsPerUser { + return false + } + srv.activeSessions++ + srv.perUserActive[user]++ + return true +} + +func (e *Endpoint) releaseSessionSlot(user string) { + srv := e.srv + srv.mu.Lock() + defer srv.mu.Unlock() + srv.activeSessions-- + if srv.perUserActive[user]--; srv.perUserActive[user] == 0 { + delete(srv.perUserActive, user) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// isTerminal reports whether s is a state that signals the session is ending. +func isTerminal(s sessionState) bool { + return s == sessionStateClosing || s == sessionStateClosed +} + +// drainInto reads all buffered data from ch and writes it to conn. +// Returns the first write error encountered, or nil. +func drainInto(ch <-chan []byte, conn net.Conn) error { + for { + select { + case data := <-ch: + if _, err := conn.Write(data); err != nil { + return err + } + default: + return nil + } + } +} + +// copyToSender reads from conn in a loop and feeds each chunk to sender. +// Stops (without error) when conn returns an error or ctx is cancelled. +func copyToSender(ctx context.Context, conn net.Conn, sender *chunkSender, lg logger.ContextLogger) { + buf := make([]byte, defaultReadBufSize) + for { + n, err := conn.Read(buf) + if n > 0 { + if feedErr := sender.feed(ctx, buf[:n]); feedErr != nil { + if lg != nil { + lg.WarnContext(ctx, "firebasetunnel: sender feed error: ", feedErr) + } + return + } + } + if err != nil { + return + } + } +} + +// durationOr returns d if d > 0, otherwise fallback. +func durationOr(d, fallback time.Duration) time.Duration { + if d > 0 { + return d + } + return fallback +} + +// intOr returns v if v > 0, otherwise fallback. +func intOr(v, fallback int) int { + if v > 0 { + return v + } + return fallback +} diff --git a/protocol/firebasetunnel/endpoint_test.go b/protocol/firebasetunnel/endpoint_test.go index 5c138fbe3c..4823dd08e3 100644 --- a/protocol/firebasetunnel/endpoint_test.go +++ b/protocol/firebasetunnel/endpoint_test.go @@ -29,12 +29,9 @@ func TestRelayRoundTrip(t *testing.T) { serverDone := make(chan struct{}) go func() { defer close(serverDone) - // runRelay reads c2s from Firebase, writes s2c to Firebase. - s := &ServerEndpoint{ - fb: fb, - pollInterval: 20 * time.Millisecond, - sessionTimeout: 10 * time.Second, - logger: nil, + s := &Endpoint{ + fb: fb, + srv: &serverState{pollInterval: 20 * time.Millisecond, sessionTimeout: 10 * time.Second}, } s.runRelay(ctx, sessionID, serverRemote, nil, hmacKey) }() @@ -45,13 +42,12 @@ func TestRelayRoundTrip(t *testing.T) { clientDone := make(chan struct{}) go func() { defer close(clientDone) - c := &ClientEndpoint{ + c := &Endpoint{ fb: fb, - key: nil, + clientKey: nil, hmacKey: hmacKey, batchInterval: 20 * time.Millisecond, batchMaxBytes: defaultBatchMaxBytes, - logger: nil, } c.runSession(ctx, sessionID, clientRemote) }() @@ -65,12 +61,10 @@ func TestRelayRoundTrip(t *testing.T) { payload := []byte("hello end-to-end relay") - // Write from client side. if _, err := clientLocal.Write(payload); err != nil { t.Fatalf("client write: %v", err) } - // Read from client side (echo comes back via Firebase s2c). buf := make([]byte, len(payload)) clientLocal.SetReadDeadline(time.Now().Add(8 * time.Second)) n, err := io.ReadFull(clientLocal, buf) @@ -81,7 +75,6 @@ func TestRelayRoundTrip(t *testing.T) { t.Fatalf("round-trip mismatch: got %q want %q", buf[:n], payload) } - // Teardown. clientLocal.Close() serverLocal.Close() <-clientDone @@ -105,11 +98,9 @@ func TestRelayEncryptedRoundTrip(t *testing.T) { serverDone := make(chan struct{}) go func() { defer close(serverDone) - s := &ServerEndpoint{ - fb: fb, - pollInterval: 20 * time.Millisecond, - sessionTimeout: 10 * time.Second, - logger: nil, + s := &Endpoint{ + fb: fb, + srv: &serverState{pollInterval: 20 * time.Millisecond, sessionTimeout: 10 * time.Second}, } s.runRelay(ctx, sessionID, serverRemote, &key, nil) }() @@ -118,13 +109,12 @@ func TestRelayEncryptedRoundTrip(t *testing.T) { clientDone := make(chan struct{}) go func() { defer close(clientDone) - c := &ClientEndpoint{ + c := &Endpoint{ fb: fb, - key: &key, + clientKey: &key, hmacKey: nil, batchInterval: 20 * time.Millisecond, batchMaxBytes: defaultBatchMaxBytes, - logger: nil, } c.runSession(ctx, sessionID, clientRemote) }() diff --git a/protocol/firebasetunnel/server.go b/protocol/firebasetunnel/server.go deleted file mode 100644 index 71cd121c4f..0000000000 --- a/protocol/firebasetunnel/server.go +++ /dev/null @@ -1,525 +0,0 @@ -package firebasetunnel - -import ( - "context" - "fmt" - "net" - "os" - "sync" - "sync/atomic" - "time" - - "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/adapter/endpoint" - "github.com/sagernet/sing-box/adapter/outbound" - C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/log" - "github.com/sagernet/sing-box/option" - E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/common/logger" - M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" -) - -const ( - defaultPollInterval = 200 * time.Millisecond - defaultSessionTimeout = 300 * time.Second - defaultMaxSessions = 1000 - defaultMaxSessionsPerUser = 50 - defaultMaxSessionsPerSecondPerUser = 5 - - // drainTimeout is how long Close() waits for active sessions to finish - // before hard-cancelling the server context. - drainTimeout = 5 * time.Second -) - -func RegisterServerEndpoint(registry *endpoint.Registry) { - endpoint.Register[option.FirebaseTunnelServerOptions](registry, C.TypeFirebaseTunnelServer, NewServerEndpoint) -} - -type firebaseTunnelUserConfig struct { - name string - key *[32]byte -} - -type ServerEndpoint struct { - outbound.Adapter - ctx context.Context - logger logger.ContextLogger - router adapter.Router - fb *firebaseClient - users map[string]firebaseTunnelUserConfig - hmacKey []byte - pollInterval time.Duration - sessionTimeout time.Duration - tracker adapter.SSMTracker - - maxSessions int - maxSessionsPerUser int - sessionRate int - - mu sync.Mutex - activeSessions int - perUserActive map[string]int - perUserBucket map[string]*tokenBucket - - closing atomic.Bool - stop context.CancelFunc -} - -func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FirebaseTunnelServerOptions) (adapter.Endpoint, error) { - urls := options.FirebaseURLs - if len(urls) == 0 { - return nil, E.New("firebase_urls is required") - } - if options.FirebaseSecret == "" && options.FirebaseAuthToken == "" { - return nil, E.New("one of firebase_secret or firebase_auth_token is required") - } - if len(options.Users) == 0 { - return nil, E.New("at least one user is required") - } - - retryLimit := options.RetryLimit - if retryLimit == 0 { - retryLimit = defaultRetryLimit - } - fb := newFirebaseClient(urls[0], options.FirebaseSecret, options.FirebaseAuthToken, retryLimit, logger) - - users := make(map[string]firebaseTunnelUserConfig, len(options.Users)) - for _, u := range options.Users { - if u.Name == "" { - return nil, E.New("user name must not be empty") - } - cfg := firebaseTunnelUserConfig{name: u.Name} - if u.PSK != "" { - k := deriveKey(u.PSK) - cfg.key = &k - } - users[u.Name] = cfg - } - - // Derive HMAC key from firebase_secret for integrity on unencrypted path. - // Only used for users that have no per-user PSK. - var hmacKey []byte - if options.FirebaseSecret != "" { - hmacKey = deriveHMACKey(options.FirebaseSecret) - } - - pollInterval := time.Duration(options.PollInterval) - if pollInterval <= 0 { - pollInterval = defaultPollInterval - } - sessionTimeout := time.Duration(options.SessionTimeout) - if sessionTimeout <= 0 { - sessionTimeout = defaultSessionTimeout - } - maxSessions := options.MaxSessions - if maxSessions <= 0 { - maxSessions = defaultMaxSessions - } - maxSessionsPerUser := options.MaxSessionsPerUser - if maxSessionsPerUser <= 0 { - maxSessionsPerUser = defaultMaxSessionsPerUser - } - sessionRate := options.MaxSessionsPerSecondPerUser - if sessionRate <= 0 { - sessionRate = defaultMaxSessionsPerSecondPerUser - } - - return &ServerEndpoint{ - Adapter: outbound.NewAdapter(C.TypeFirebaseTunnelServer, tag, []string{N.NetworkTCP}, nil), - ctx: ctx, - logger: logger, - router: router, - fb: fb, - users: users, - hmacKey: hmacKey, - pollInterval: pollInterval, - sessionTimeout: sessionTimeout, - maxSessions: maxSessions, - maxSessionsPerUser: maxSessionsPerUser, - sessionRate: sessionRate, - perUserActive: make(map[string]int), - perUserBucket: make(map[string]*tokenBucket), - }, nil -} - -// SetTracker wires per-user traffic accounting into the existing SSM stats -// subsystem (the same one Hiddify Manager already polls for shadowsocks -// usage). Not part of adapter.ManagedSSMServer (that interface requires -// adapter.Inbound, which this Endpoint is not) — call directly if your box -// construction code has a tracker to attach. -func (s *ServerEndpoint) SetTracker(tracker adapter.SSMTracker) { - s.mu.Lock() - defer s.mu.Unlock() - s.tracker = tracker -} - -func (s *ServerEndpoint) Start(stage adapter.StartStage) error { - if stage != adapter.StartStatePostStart { - return nil - } - ctx, cancel := context.WithCancel(s.ctx) - s.stop = cancel - go s.runWithRecovery(ctx, s.pollLoop) - go s.runWithRecovery(ctx, s.gcLoop) - return nil -} - -// Close signals no-new-sessions, waits up to drainTimeout for active sessions -// to finish, then cancels the server context. -func (s *ServerEndpoint) Close() error { - s.closing.Store(true) - deadline := time.Now().Add(drainTimeout) - for time.Now().Before(deadline) { - s.mu.Lock() - active := s.activeSessions - s.mu.Unlock() - if active == 0 { - break - } - time.Sleep(100 * time.Millisecond) - } - if s.stop != nil { - s.stop() - } - return nil -} - -// runWithRecovery restarts fn with backoff if it panics, so a bug in the -// poll/GC loop can't crash the rest of the sing-box process. -func (s *ServerEndpoint) runWithRecovery(ctx context.Context, fn func(context.Context)) { - for { - if ctx.Err() != nil { - return - } - func() { - defer func() { - if r := recover(); r != nil { - s.logger.ErrorContext(ctx, "firebasetunnel: server loop panic, restarting: ", r) - } - }() - fn(ctx) - }() - if ctx.Err() != nil { - return - } - select { - case <-ctx.Done(): - return - case <-time.After(5 * time.Second): - } - } -} - -func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - return nil, os.ErrInvalid -} - -func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return nil, os.ErrInvalid -} - -// pollLoop watches sessions/ for new Pending sessions and dispatches each -// to handleSession. Uses Firebase's SSE listen stream rather than tight -// polling; falls back to a coarse poll if the stream is unavailable. -func (s *ServerEndpoint) pollLoop(ctx context.Context) { - seen := make(map[string]struct{}) - events := s.fb.listen(ctx, pathSessionsRoot()) - ticker := time.NewTicker(s.pollInterval) - defer ticker.Stop() - - checkNow := func() { - var raw map[string]sessionMetadata - found, err := s.fb.Get(ctx, pathSessionsRoot(), &raw) - if err != nil || !found { - return - } - for id, meta := range raw { - if meta.State != sessionStatePending { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - meta.SessionID = id - go s.handleSession(ctx, meta) - } - } - - for { - select { - case <-ctx.Done(): - return - case _, ok := <-events: - if !ok { - events = s.fb.listen(ctx, pathSessionsRoot()) - continue - } - checkNow() - case <-ticker.C: - checkNow() - } - } -} - -func (s *ServerEndpoint) handleSession(ctx context.Context, meta sessionMetadata) { - sessionID := meta.SessionID - - userCfg, ok := s.users[meta.User] - if !ok { - s.logger.WarnContext(ctx, "firebasetunnel: rejecting session ", sessionID, ": unknown user") - _ = s.fb.Put(ctx, pathMetadata(sessionID), &sessionMetadata{SessionID: sessionID, State: sessionStateClosed}) - return - } - - if !s.acquireSessionSlot(meta.User) { - s.logger.WarnContext(ctx, "firebasetunnel: rejecting session ", sessionID, ": session limit or rate limit exceeded for user ", meta.User) - _ = s.fb.Put(ctx, pathMetadata(sessionID), &sessionMetadata{SessionID: sessionID, State: sessionStateClosed}) - return - } - defer s.releaseSessionSlot(meta.User) - - // Collision defense: verify the metadata we see now matches what the poll - // originally observed (same CreatedAt). Protects against GC-lag collisions - // on session-ID reuse or a stale seen-map entry pointing at a recycled node. - var current sessionMetadata - found, _ := s.fb.Get(ctx, pathMetadata(sessionID), ¤t) - if !found || current.CreatedAt != meta.CreatedAt { - s.logger.WarnContext(ctx, "firebasetunnel: session collision detected for ", sessionID, ", rejecting") - _ = s.fb.Put(ctx, pathMetadata(sessionID), &sessionMetadata{SessionID: sessionID, State: sessionStateClosed}) - return - } - - destination := M.ParseSocksaddrHostPort(meta.TargetHost, meta.TargetPort) - - metadata := adapter.InboundContext{ - Inbound: s.Tag(), - InboundType: C.TypeFirebaseTunnelServer, - Destination: destination, - User: meta.User, - } - - local, remote := net.Pipe() - var conn net.Conn = local - s.mu.Lock() - tracker := s.tracker - s.mu.Unlock() - if tracker != nil { - conn = tracker.TrackConnection(conn, metadata) - } - - updated := meta - updated.State = sessionStateActive - if err := s.fb.Put(ctx, pathMetadata(sessionID), &updated); err != nil { - remote.Close() - local.Close() - s.logger.WarnContext(ctx, "firebasetunnel: marking session active failed: ", err) - return - } - - // Determine which key to use: per-user PSK takes precedence, else fall - // back to HMAC-only (hmacKey) for integrity on the unencrypted path. - sessionKey := userCfg.key - sessionHMACKey := s.hmacKey - if sessionKey != nil { - sessionHMACKey = nil // encrypted path handles integrity via AEAD - } - - relayDone := make(chan struct{}) - go func() { - defer close(relayDone) - s.runRelay(ctx, sessionID, remote, sessionKey, sessionHMACKey) - }() - - onClose := func(error) {} - s.router.RouteConnectionEx(ctx, conn, metadata, onClose) - - <-relayDone - - finalMeta := updated - finalMeta.State = sessionStateClosed - _ = s.fb.Put(context.Background(), pathMetadata(sessionID), &finalMeta) - if err := s.fb.Delete(context.Background(), "sessions/"+sessionID); err != nil { - s.logger.WarnContext(ctx, "firebasetunnel: session cleanup failed: ", err) - } -} - -// runRelay pumps bytes between conn (the local-process side of the -// net.Pipe routed through sing-box) and the Firebase c2s/s2c queues. -func (s *ServerEndpoint) runRelay(ctx context.Context, sessionID string, conn net.Conn, key *[32]byte, hmacKey []byte) { - defer conn.Close() - c2sPath := pathC2S(sessionID) - s2cPath := pathS2C(sessionID) - ackC2SPath := pathAcks(sessionID) + "/c2s_ack" - - relayCtx, cancel := context.WithCancel(ctx) - defer cancel() - - c2sDone := make(chan struct{}) - go func() { - defer close(c2sDone) - s.runC2S(relayCtx, sessionID, conn, c2sPath, ackC2SPath, key, hmacKey) - }() - - s2cDone := make(chan struct{}) - go func() { - defer close(s2cDone) - sender := newChunkSender(relayCtx, s2cPath, sessionID, "s2c", s.fb, 50*time.Millisecond, defaultReadBufSize, key, hmacKey, s.logger) - buf := make([]byte, defaultReadBufSize) - for { - n, err := conn.Read(buf) - if n > 0 { - if feedErr := sender.feed(relayCtx, buf[:n]); feedErr != nil { - s.logger.WarnContext(ctx, "firebasetunnel: s2c feed error: ", feedErr) - return - } - } - if err != nil { - return - } - } - }() - - select { - case <-c2sDone: - case <-s2cDone: - } - cancel() -} - -func (s *ServerEndpoint) runC2S(ctx context.Context, sessionID string, conn net.Conn, c2sPath, ackC2SPath string, key *[32]byte, hmacKey []byte) { - receiver, byteRx := newChunkReceiver(key, hmacKey, sessionID, "c2s") - var deliveredUpTo *uint64 - lastActivity := time.Now() - - for { - if time.Since(lastActivity) > s.sessionTimeout { - s.logger.WarnContext(ctx, "firebasetunnel: session ", sessionID, " timed out") - return - } - - var meta sessionMetadata - found, err := s.fb.Get(ctx, pathMetadata(sessionID), &meta) - if err == nil && found && (meta.State == sessionStateClosing || meta.State == sessionStateClosed) { - return - } - - chunks, err := fetchNewChunks(ctx, s.fb, c2sPath, deliveredUpTo) - if err != nil { - select { - case <-ctx.Done(): - return - case <-time.After(s.pollInterval): - continue - } - } - if len(chunks) > 0 { - lastActivity = time.Now() - } - - for _, ck := range chunks { - newAck, err := receiver.ingest(ctx, ck) - if err != nil { - s.logger.WarnContext(ctx, "firebasetunnel: c2s ingest error (session ", sessionID, "): ", err) - return - } - if newAck == nil { - continue - } - deliveredUpTo = newAck - drainLoop: - for { - select { - case data := <-byteRx: - if _, err := conn.Write(data); err != nil { - return - } - default: - break drainLoop - } - } - if err := updateAckAndCleanup(ctx, s.fb, ackC2SPath, c2sPath, *newAck, s.logger); err != nil { - s.logger.WarnContext(ctx, "firebasetunnel: ack update failed: ", err) - } - } - - select { - case <-ctx.Done(): - return - case <-time.After(s.pollInterval): - } - } -} - -// gcLoop periodically sweeps sessions/ for abandoned sessions: ones that -// never reached Active before SessionTimeout, or that have lingered in -// Closing/Closed past a short grace period. -func (s *ServerEndpoint) gcLoop(ctx context.Context) { - ticker := time.NewTicker(s.sessionTimeout / 2) - defer ticker.Stop() - const closedGrace = 10 * time.Second - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - var raw map[string]sessionMetadata - found, err := s.fb.Get(ctx, pathSessionsRoot(), &raw) - if err != nil || !found { - continue - } - now := nowMillis() - for id, meta := range raw { - age := time.Duration(now-meta.CreatedAt) * time.Millisecond - stale := (meta.State == sessionStatePending && age > s.sessionTimeout) || - ((meta.State == sessionStateClosing || meta.State == sessionStateClosed) && age > s.sessionTimeout+closedGrace) - if stale { - if err := s.fb.Delete(ctx, "sessions/"+id); err != nil { - s.logger.WarnContext(ctx, "firebasetunnel: gc delete failed for ", id, ": ", err) - } - } - } - } - } -} - -// acquireSessionSlot enforces global/per-user session caps and per-user -// session-creation rate limiting. Returns false if the session should be -// rejected (including during server shutdown drain). -func (s *ServerEndpoint) acquireSessionSlot(user string) bool { - if s.closing.Load() { - return false - } - s.mu.Lock() - defer s.mu.Unlock() - - bucket, ok := s.perUserBucket[user] - if !ok { - bucket = newTokenBucket(s.sessionRate, s.sessionRate) - s.perUserBucket[user] = bucket - } - if !bucket.take() { - return false - } - if s.activeSessions >= s.maxSessions { - return false - } - if s.perUserActive[user] >= s.maxSessionsPerUser { - return false - } - s.activeSessions++ - s.perUserActive[user]++ - return true -} - -func (s *ServerEndpoint) releaseSessionSlot(user string) { - s.mu.Lock() - defer s.mu.Unlock() - s.activeSessions-- - s.perUserActive[user]-- -} - -var _ = fmt.Sprintf