From 1d6736dca6e9ddd42531b33d5f2950825a1e506a Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 29 Jun 2026 11:21:52 +0330 Subject: [PATCH 1/7] 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 c026ae094dbda178fe409743938bf5a431f61bac Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 29 Jun 2026 11:22:00 +0330 Subject: [PATCH 2/7] 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 | 203 +++++++++++++ protocol/firebasetunnel/client.go | 280 ++++++++++++++++++ protocol/firebasetunnel/server.go | 475 ++++++++++++++++++++++++++++++ 3 files changed, 958 insertions(+) create mode 100644 include/registry.go create mode 100644 protocol/firebasetunnel/client.go create mode 100644 protocol/firebasetunnel/server.go diff --git a/include/registry.go b/include/registry.go new file mode 100644 index 0000000000..5c73467b09 --- /dev/null +++ b/include/registry.go @@ -0,0 +1,203 @@ +package include + +import ( + "context" + + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/dns/transport/fakeip" + "github.com/sagernet/sing-box/dns/transport/hosts" + "github.com/sagernet/sing-box/dns/transport/local" + "github.com/sagernet/sing-box/dns/transport/multi" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/anytls" + "github.com/sagernet/sing-box/protocol/block" + "github.com/sagernet/sing-box/protocol/direct" + "github.com/sagernet/sing-box/protocol/group" + "github.com/sagernet/sing-box/protocol/group/balancer" + "github.com/sagernet/sing-box/protocol/hiddify/dnstt" + "github.com/sagernet/sing-box/protocol/hiddify/gooserelay" + "github.com/sagernet/sing-box/protocol/hiddify/hinvalid" + + "github.com/sagernet/sing-box/protocol/hiddify/xray" + "github.com/sagernet/sing-box/protocol/http" + "github.com/sagernet/sing-box/protocol/mieru" + "github.com/sagernet/sing-box/protocol/mixed" + "github.com/sagernet/sing-box/protocol/naive" + "github.com/sagernet/sing-box/protocol/psiphon" + "github.com/sagernet/sing-box/protocol/redirect" + "github.com/sagernet/sing-box/protocol/shadowsocks" + "github.com/sagernet/sing-box/protocol/shadowtls" + snellprotocol "github.com/sagernet/sing-box/protocol/snell" + "github.com/sagernet/sing-box/protocol/socks" + "github.com/sagernet/sing-box/protocol/ssh" + "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" + originca "github.com/sagernet/sing-box/service/origin_ca" + "github.com/sagernet/sing-box/service/resolved" + smartdnspool "github.com/sagernet/sing-box/service/smart_dns_pool" + "github.com/sagernet/sing-box/service/ssmapi" + E "github.com/sagernet/sing/common/exceptions" +) + +func Context(ctx context.Context) context.Context { + return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry(), CertificateProviderRegistry()) +} + +func InboundRegistry() *inbound.Registry { + registry := inbound.NewRegistry() + + tun.RegisterInbound(registry) + redirect.RegisterRedirect(registry) + redirect.RegisterTProxy(registry) + direct.RegisterInbound(registry) + + socks.RegisterInbound(registry) + http.RegisterInbound(registry) + mixed.RegisterInbound(registry) + + shadowsocks.RegisterInbound(registry) + vmess.RegisterInbound(registry) + trojan.RegisterInbound(registry) + naive.RegisterInbound(registry) + shadowtls.RegisterInbound(registry) + vless.RegisterInbound(registry) + anytls.RegisterInbound(registry) + mieru.RegisterInbound(registry) + ssh.RegisterInbound(registry) + snellprotocol.RegisterInbound(registry) + + registerQUICInbounds(registry) + registerCloudflaredInbound(registry) + registerStubForRemovedInbounds(registry) + + return registry +} + +func OutboundRegistry() *outbound.Registry { + registry := outbound.NewRegistry() + + direct.RegisterOutbound(registry) + + block.RegisterOutbound(registry) + + group.RegisterSelector(registry) + group.RegisterURLTest(registry) + + socks.RegisterOutbound(registry) + http.RegisterOutbound(registry) + shadowsocks.RegisterOutbound(registry) + vmess.RegisterOutbound(registry) + trojan.RegisterOutbound(registry) + registerNaiveOutbound(registry) + tor.RegisterOutbound(registry) + ssh.RegisterOutbound(registry) + shadowtls.RegisterOutbound(registry) + vless.RegisterOutbound(registry) + psiphon.RegisterOutbound(registry) + mieru.RegisterOutbound(registry) + anytls.RegisterOutbound(registry) + hinvalid.RegisterOutbound(registry) + xray.RegisterOutbound(registry) + dnstt.RegisterOutbound(registry) + gooserelay.RegisterOutbound(registry) + balancer.RegisterLoadBalance(registry) + snellprotocol.RegisterOutbound(registry) + + registerMASQUEOutbound(registry) + registerQUICOutbounds(registry) + registerStubForRemovedOutbounds(registry) + + return registry +} + +func EndpointRegistry() *endpoint.Registry { + registry := endpoint.NewRegistry() + + tunnel.RegisterServerEndpoint(registry) + tunnel.RegisterClientEndpoint(registry) + firebasetunnel.RegisterEndpoint(registry) + + registerWireGuardEndpoint(registry) + registerWarpEndpoint(registry) + registerTailscaleEndpoint(registry) + registerAwgEndpoint(registry) + + return registry +} + +func DNSTransportRegistry() *dns.TransportRegistry { + registry := dns.NewTransportRegistry() + + transport.RegisterTCP(registry) + transport.RegisterUDP(registry) + transport.RegisterTLS(registry) + transport.RegisterHTTPS(registry) + transport.RegisterSDNS(registry) + + multi.RegisterTransport(registry) //H + hosts.RegisterTransport(registry) + local.RegisterTransport(registry) + fakeip.RegisterTransport(registry) + resolved.RegisterTransport(registry) + + registerQUICTransports(registry) + registerDHCPTransport(registry) + registerTailscaleTransport(registry) + + return registry +} + +func ServiceRegistry() *service.Registry { + registry := service.NewRegistry() + + resolved.RegisterService(registry) + ssmapi.RegisterService(registry) + smartdnspool.RegisterService(registry) //H + + registerDERPService(registry) + registerCCMService(registry) + registerOCMService(registry) + registerOOMKillerService(registry) + + return registry +} + +func CertificateProviderRegistry() *certificate.Registry { + registry := certificate.NewRegistry() + + registerACMECertificateProvider(registry) + registerTailscaleCertificateProvider(registry) + originca.RegisterCertificateProvider(registry) + + return registry +} + +func registerStubForRemovedInbounds(registry *inbound.Registry) { + inbound.Register[option.ShadowsocksInboundOptions](registry, C.TypeShadowsocksR, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (adapter.Inbound, error) { + return nil, E.New("ShadowsocksR is deprecated and removed in sing-box 1.6.0") + }) +} + +func registerStubForRemovedOutbounds(registry *outbound.Registry) { + outbound.Register[option.ShadowsocksROutboundOptions](registry, C.TypeShadowsocksR, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksROutboundOptions) (adapter.Outbound, error) { + return nil, E.New("ShadowsocksR is deprecated and removed in sing-box 1.6.0") + }) + outbound.Register[option.StubOptions](registry, C.TypeWireGuard, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.StubOptions) (adapter.Outbound, error) { + return nil, E.New("WireGuard outbound is deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, use WireGuard endpoint instead") + }) +} 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 29c304eab4211a17d58959e0e270ffe1c73f2e22 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 29 Jun 2026 11:22:07 +0330 Subject: [PATCH 3/7] 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 743d74e48224c334b0a8c117ee62d359b05db70c Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Tue, 30 Jun 2026 01:37:59 +0330 Subject: [PATCH 4/7] 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 25dd12cc9a6b2ab7bbe2d315c8c84b173aeb0096 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Tue, 30 Jun 2026 01:38:15 +0330 Subject: [PATCH 5/7] 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 fb52c6b5780827b61dab0ba389a3c366c8bea344 Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Tue, 30 Jun 2026 22:29:44 +0330 Subject: [PATCH 6/7] 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 | 122 +++- option/firebasetunnel.go | 68 ++ protocol/firebasetunnel/client.go | 310 ---------- protocol/firebasetunnel/endpoint.go | 754 +++++++++++++++++++++++ protocol/firebasetunnel/endpoint_test.go | 30 +- protocol/firebasetunnel/server.go | 525 ---------------- 6 files changed, 929 insertions(+), 880 deletions(-) create mode 100644 option/firebasetunnel.go 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 3d10519e05..0c0ee50e54 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -1,31 +1,57 @@ package constant const ( - TypeTun = "tun" - TypeRedirect = "redirect" - TypeTProxy = "tproxy" - TypeDirect = "direct" - TypeBlock = "block" - TypeDNS = "dns" - TypeSOCKS = "socks" - TypeHTTP = "http" - TypeMixed = "mixed" - TypeShadowsocks = "shadowsocks" - TypeVMess = "vmess" - TypeTrojan = "trojan" - TypeNaive = "naive" - TypeWireGuard = "wireguard" - TypeHysteria = "hysteria" - TypeTor = "tor" - TypeSSH = "ssh" - TypeShadowTLS = "shadowtls" - TypeShadowsocksR = "shadowsocksr" - TypeVLESS = "vless" - TypeTUIC = "tuic" - TypeHysteria2 = "hysteria2" - TypeCustom = "custom" - TypeXray = "xray" - TypeInvalidConfig = "invalid" + TypeTun = "tun" + TypeRedirect = "redirect" + TypeTProxy = "tproxy" + TypeDirect = "direct" + TypeBlock = "block" + TypeDNS = "dns" + TypeSOCKS = "socks" + TypeHTTP = "http" + TypeMixed = "mixed" + TypeShadowsocks = "shadowsocks" + TypeVMess = "vmess" + TypeTrojan = "trojan" + TypeNaive = "naive" + TypeWireGuard = "wireguard" + TypeWARP = "warp" + TypeHysteria = "hysteria" + TypeTor = "tor" + TypeSSH = "ssh" + TypeShadowTLS = "shadowtls" + TypeMieru = "mieru" + TypeAnyTLS = "anytls" + TypeSnell = "snell" + TypeMASQUE = "masque" + TypeShadowsocksR = "shadowsocksr" + TypeVLESS = "vless" + TypeTUIC = "tuic" + TypeHysteria2 = "hysteria2" + TypeHysteriaRealm = "hysteria-realm" + TypePsiphon = "psiphon" + TypeTunnelClient = "tunnel_client" + TypeTunnelServer = "tunnel_server" + TypeTailscale = "tailscale" + TypeCloudflared = "cloudflared" + TypeDERP = "derp" + TypeResolved = "resolved" + TypeSSMAPI = "ssm-api" + TypeCCM = "ccm" + TypeOCM = "ocm" + TypeOOMKiller = "oom-killer" + TypeACME = "acme" + TypeCloudflareOriginCA = "cloudflare-origin-ca" + + TypeHInvalidConfig = "hinvalid" //H + TypeXray = "xray" //H + TypeCustom = "custom" //H + TypeAwg = "awg" //H + TypeBalancer = "balancer" //H + TypeDNSTT = "dnstt" //H + TypeGooseRelay = "gooserelay" //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) ) const ( @@ -35,6 +61,12 @@ const ( func ProxyDisplayName(proxyType string) string { switch proxyType { + case TypeTun: + return "TUN" + case TypeRedirect: + return "Redirect" + case TypeTProxy: + return "TProxy" case TypeDirect: return "Direct" case TypeBlock: @@ -45,6 +77,8 @@ func ProxyDisplayName(proxyType string) string { return "SOCKS" case TypeHTTP: return "HTTP" + case TypeMixed: + return "Mixed" case TypeShadowsocks: return "Shadowsocks" case TypeVMess: @@ -55,6 +89,8 @@ func ProxyDisplayName(proxyType string) string { return "Naive" case TypeWireGuard: return "WireGuard" + case TypeWARP: + return "WARP" case TypeHysteria: return "Hysteria" case TypeTor: @@ -71,11 +107,47 @@ func ProxyDisplayName(proxyType string) string { return "TUIC" case TypeHysteria2: return "Hysteria2" + case TypeMieru: + return "Mieru" + case TypeAnyTLS: + return "AnyTLS" + case TypeSnell: + return "Snell" + case TypeMASQUE: + return "MASQUE" + case TypePsiphon: + return "Psiphon" + case TypeTailscale: + return "Tailscale" + case TypeCloudflared: + return "Cloudflared" case TypeSelector: return "Selector" case TypeURLTest: return "URLTest" + case TypeHInvalidConfig: + return "Invalid" + case TypeXray: + return "xray" + case TypeCustom: + return "custom" + case TypeTunnelClient: + return "Tunnel Client" + case TypeTunnelServer: + return "Tunnel Server" + case TypeAwg: + return "Awg" + case TypeBalancer: + return "Balancer" + case TypeDNSTT: + return "DNSTT" + case TypeGooseRelay: + return "GooseRelay" + case TypeFirebaseTunnel: + return "Firebase Tunnel" default: return "Unknown" } } + +const DefaultBrowserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36" diff --git a/option/firebasetunnel.go b/option/firebasetunnel.go new file mode 100644 index 0000000000..67946bac96 --- /dev/null +++ b/option/firebasetunnel.go @@ -0,0 +1,68 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +// 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 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"` +} + +// 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"` +} + +// 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"` + RetryLimit uint32 `json:"retry_limit,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 From ee659b1741a2191022d3a45208380c5d77e5f0cd Mon Sep 17 00:00:00 2001 From: hiddifydeveloper Date: Mon, 6 Jul 2026 11:59:21 +0330 Subject: [PATCH 7/7] fix(firebasetunnel): fix endpoint_test field name after Endpoint refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clientKey → key to match merged Endpoint struct field name. Co-Authored-By: Claude Sonnet 4.6 --- protocol/firebasetunnel/endpoint_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol/firebasetunnel/endpoint_test.go b/protocol/firebasetunnel/endpoint_test.go index 4823dd08e3..ec951abbbc 100644 --- a/protocol/firebasetunnel/endpoint_test.go +++ b/protocol/firebasetunnel/endpoint_test.go @@ -44,7 +44,7 @@ func TestRelayRoundTrip(t *testing.T) { defer close(clientDone) c := &Endpoint{ fb: fb, - clientKey: nil, + key: nil, hmacKey: hmacKey, batchInterval: 20 * time.Millisecond, batchMaxBytes: defaultBatchMaxBytes, @@ -111,7 +111,7 @@ func TestRelayEncryptedRoundTrip(t *testing.T) { defer close(clientDone) c := &Endpoint{ fb: fb, - clientKey: &key, + key: &key, hmacKey: nil, batchInterval: 20 * time.Millisecond, batchMaxBytes: defaultBatchMaxBytes,