diff --git a/constant/proxy.go b/constant/proxy.go index 0eb790f496..d674af09b7 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -50,6 +50,7 @@ const ( 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) ) @@ -142,6 +143,8 @@ func ProxyDisplayName(proxyType string) string { return "DNSTT" case TypeGooseRelay: return "GooseRelay" + case TypeFirebaseTunnel: + return "Firebase Tunnel" default: return "Unknown" } diff --git a/docs/changelog.md b/docs/changelog.md index e1ac041f75..f370c934e1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,24 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.27 + +* Add Firebase Tunnel endpoint **1** +* Fixes and improvements + +**1**: + +Adds `firebasetunnel_client` and `firebasetunnel_server` endpoints, which +relay TCP connections through Firebase Realtime Database for use when +direct connectivity is blocked but Firebase's REST API is reachable. +Supports multi-user accounting (wired into the existing SSM traffic stats +used for Shadowsocks usage), optional per-user PSK payload encryption, and +an optional Firebase Auth token alternative to the legacy database secret. +See [Firebase Tunnel docs](https://github.com/hiddify/hiddify-sing-box/tree/testing-merge-base/protocol/firebasetunnel/docs) +for configuration, security considerations, and operational guidance. +Adapted from [Hiddify2/Firebase-Tunnel](https://github.com/Hiddify2/Firebase-Tunnel); +see the provenance doc for what was reused vs. rewritten. + #### 1.14.0-alpha.26 * Add gecko obfs for Hysteria2 **1** diff --git a/include/registry.go b/include/registry.go index 1b9643bec7..5c73467b09 100644 --- a/include/registry.go +++ b/include/registry.go @@ -43,6 +43,7 @@ import ( "github.com/sagernet/sing-box/protocol/tor" "github.com/sagernet/sing-box/protocol/trojan" "github.com/sagernet/sing-box/protocol/tun" + "github.com/sagernet/sing-box/protocol/firebasetunnel" "github.com/sagernet/sing-box/protocol/tunnel" "github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vmess" @@ -129,6 +130,7 @@ func EndpointRegistry() *endpoint.Registry { tunnel.RegisterServerEndpoint(registry) tunnel.RegisterClientEndpoint(registry) + firebasetunnel.RegisterEndpoint(registry) registerWireGuardEndpoint(registry) registerWarpEndpoint(registry) 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/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/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.go b/protocol/firebasetunnel/crypto.go new file mode 100644 index 0000000000..a44e5c2973 --- /dev/null +++ b/protocol/firebasetunnel/crypto.go @@ -0,0 +1,122 @@ +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). +func deriveKey(psk string) [32]byte { + return sha256.Sum256([]byte(psk)) +} + +// 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 + } + 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, aad), nil +} + +// 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 + } + 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, 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/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/docs/README.md b/protocol/firebasetunnel/docs/README.md new file mode 100644 index 0000000000..70769ba244 --- /dev/null +++ b/protocol/firebasetunnel/docs/README.md @@ -0,0 +1,19 @@ +# Firebase Tunnel + +A sing-box endpoint pair (`firebasetunnel_client` / `firebasetunnel_server`) that relays TCP byte streams through Firebase Realtime Database, used when direct connectivity between client and server is blocked but Firebase's REST API is reachable. + +Adapted from [github.com/Hiddify2/Firebase-Tunnel](https://github.com/Hiddify2/Firebase-Tunnel) (a single-star proof-of-concept with no LICENSE — the logic below was rewritten for sing-box rather than vendored; see [provenance.md](./provenance.md)). + +## Documents + +- [architecture.md](./architecture.md) — how the relay works, wire format, Firebase database layout +- [configuration.md](./configuration.md) — client/server config reference +- [security.md](./security.md) — threat model, auth options, encryption +- [operations.md](./operations.md) — rate limits, session caps, GC, rollout/rollback +- [provenance.md](./provenance.md) — relationship to the upstream PoC, what was reused vs. rewritten + +## Known limitations + +- Added latency: Firebase RTDB polling/SSE imposes a realistic floor of ~200ms+ round-trip on top of normal network latency. +- Firebase rate limits apply per project; high-throughput use needs read/write quota headroom (see [operations.md](./operations.md)). +- Default auth is a single shared Firebase Database Secret — coarse-grained (see [security.md](./security.md) for the recommended token/PSK alternatives). diff --git a/protocol/firebasetunnel/docs/architecture.md b/protocol/firebasetunnel/docs/architecture.md new file mode 100644 index 0000000000..3141b6ab31 --- /dev/null +++ b/protocol/firebasetunnel/docs/architecture.md @@ -0,0 +1,71 @@ +# Architecture + +## Endpoint, not outbound+inbound + +`firebasetunnel_client` and `firebasetunnel_server` are both registered as sing-box **Endpoints** (`adapter.Endpoint`), the same shape used by the existing `tunnel_client`/`tunnel_server` pair in `protocol/tunnel/`. An Endpoint can both dial out (`DialContext`) and run its own background listener (`Start`) — exactly the dual nature needed here: the client dials *through* Firebase, the server listens for sessions arriving *via* Firebase and dials the real target. + +``` +sing-box (client box) sing-box (server box) +┌─────────────────────────┐ ┌─────────────────────────┐ +│ inbound (mixed/socks/…) │ │ firebasetunnel_server │ +│ │ │ │ │ │ +│ ▼ │ │ ▼ │ +│ firebasetunnel_client │◄──── Firebase Realtime Database ──►│ router.RouteConnectionEx│ +│ .DialContext() │ (sessions/{id}/...) │ │ │ +└─────────────────────────┘ │ ▼ │ + │ direct/other outbound │ + └─────────────────────────┘ +``` + +## Session lifecycle + +1. **Client** generates a UUIDv4 `session_id`, writes `sessions/{id}/metadata` with `state=pending`, `target_host`/`target_port` (the dial destination), and its configured `user` label. +2. **Server** discovers the new pending session (via a Firebase SSE `Listen` stream on `sessions/`, with a coarse poll fallback), validates `user` against its configured user list, enforces session caps/rate limits, then flips `state=active`. +3. **Client** observes `state=active` and begins relaying. +4. Both sides exchange `net.Pipe()`-backed connections internally: the client hands the caller of `DialContext` one end of a pipe and relays the other end to Firebase; the server does the same and routes its end through `router.RouteConnectionEx` so normal sing-box routing/DNS/sniffing rules apply to the final egress. +5. Bytes are batched (default: every 50ms or 32KiB, whichever first), optionally zstd-compressed, optionally AES-256-GCM encrypted (see [security.md](./security.md)), base64-encoded, and written as sequential `chunk` records. +6. The reading side reassembles chunks in order (buffering any that arrive out of order), writes reassembled bytes to its local pipe end, then records an ack pointer and deletes acknowledged chunks to bound database growth. +7. Either side closing triggers `state=closing` then `state=closed`; the session's Firebase node is deleted on completion. A background GC sweep also removes abandoned sessions (see [operations.md](./operations.md)). + +## Firebase database layout + +``` +sessions/ + {session_id}/ + metadata - sessionMetadata: session_id, version, target_host, target_port, + created_at, state (pending|active|closing|closed), user + c2s/ + {seq}/ - chunk: client→server data, keyed by monotonically increasing seq + s2c/ + {seq}/ - chunk: server→client data + acks/ + c2s_ack - uint64, highest consecutive c2s seq the server has processed + s2c_ack - uint64, highest consecutive s2c seq the client has processed +``` + +A `chunk` record: + +```json +{ + "seq": 42, + "timestamp": 1719500000000, + "compressed": true, + "encrypted": false, + "data": "" +} +``` + +`encrypted` is only set when the session's user has a PSK configured (see [security.md](./security.md)); `data` is then AES-256-GCM ciphertext (nonce-prefixed) rather than the raw/compressed payload. + +## Code layout (`protocol/firebasetunnel/`) + +| File | Purpose | +|---|---| +| `protocol.go` | Wire types (`sessionMetadata`, `chunk`), Firebase path helpers | +| `firebase.go` | REST client: Get/Put/Delete with retry, SSE `Listen` with jittered reconnect | +| `session.go` | `chunkSender` (batch/compress/encrypt + backpressure cap), `chunkReceiver` (reorder/decrypt/decompress), ack/cleanup | +| `crypto.go` | Optional AES-256-GCM payload encryption keyed by a PSK | +| `compress.go` | zstd wrappers | +| `util.go` | Jittered backoff, token bucket rate limiter | +| `client.go` | `ClientEndpoint`: session creation, `net.Pipe` dial adapter, c2s/s2c relay | +| `server.go` | `ServerEndpoint`: session discovery, user/limit enforcement, relay, GC sweep, SSM tracker hookup | diff --git a/protocol/firebasetunnel/docs/configuration.md b/protocol/firebasetunnel/docs/configuration.md new file mode 100644 index 0000000000..8c4a575b43 --- /dev/null +++ b/protocol/firebasetunnel/docs/configuration.md @@ -0,0 +1,72 @@ +# Configuration reference + +## `firebasetunnel_client` + +```json +{ + "type": "firebasetunnel_client", + "tag": "fb-client", + "firebase_urls": ["https://your-project-default-rtdb.firebaseio.com"], + "firebase_secret": "YOUR_DATABASE_SECRET", + "user": "alice", + "psk": "optional-shared-secret-for-alice", + "batch_interval": "50ms", + "batch_max_bytes": 32768, + "retry_limit": 5, + "activation_timeout": "30s" +} +``` + +| Field | Required | Default | Notes | +|---|---|---|---| +| `firebase_urls` | yes | — | First URL is used; list exists for future multi-backend failover. | +| `firebase_secret` | one of secret/token | — | Legacy Firebase Database Secret. Coarse: grants full read/write on the whole project. | +| `firebase_auth_token` | one of secret/token | — | Short-lived Firebase Auth/service-account token; preferred over `firebase_secret`. | +| `user` | yes | — | Self-reported accounting label sent in session metadata. | +| `psk` | no | — | If set, encrypts this client's relayed bytes and lets the server verify the `user` label cryptographically. Must match the server's per-user PSK for this `user`. | +| `batch_interval` | no | `50ms` | How long to accumulate bytes before an early flush timer fires. | +| `batch_max_bytes` | no | `32768` | Flush early once buffered bytes reach this size. | +| `retry_limit` | no | `5` | Firebase REST retry attempts before failing a request. | +| `activation_timeout` | no | `30s` | How long `DialContext` waits for the server to mark a session active before failing (lets outbound groups like `urltest`/`selector` fail over). | + +Use it as the target of another outbound's chain, or directly as a `detour`/`outbound` reference, the same way `tunnel_client` is used. + +## `firebasetunnel_server` + +```json +{ + "type": "firebasetunnel_server", + "tag": "fb-server", + "firebase_urls": ["https://your-project-default-rtdb.firebaseio.com"], + "firebase_secret": "YOUR_DATABASE_SECRET", + "users": [ + { "name": "alice", "psk": "optional-shared-secret-for-alice" }, + { "name": "bob" } + ], + "poll_interval": "200ms", + "session_timeout": "300s", + "retry_limit": 5, + "max_sessions": 1000, + "max_sessions_per_user": 50, + "max_sessions_per_second_per_user": 5 +} +``` + +| Field | Required | Default | Notes | +|---|---|---|---| +| `firebase_urls` | yes | — | Must match the client(s)' project. | +| `firebase_secret` / `firebase_auth_token` | one required | — | Same semantics as client. | +| `users` | yes, non-empty | — | Sessions declaring an unlisted `user` are rejected. | +| `users[].psk` | no | — | If set, sessions from this user must encrypt/decrypt correctly under this key or are rejected — turns the `user` label into a verified credential. | +| `poll_interval` | no | `200ms` | Fallback poll cadence alongside the SSE listen stream; also the c2s/s2c chunk poll interval during an active relay. | +| `session_timeout` | no | `300s` | Inactivity timeout for an active session; also drives the GC sweep interval (half this value) and the grace period for abandoned-session cleanup. | +| `retry_limit` | no | `5` | Firebase REST retry attempts. | +| `max_sessions` | no | `1000` | Global concurrent session cap. | +| `max_sessions_per_user` | no | `50` | Per-user concurrent session cap. | +| `max_sessions_per_second_per_user` | no | `5` | Token-bucket rate limit on new session creation per user. | + +The server endpoint dials real targets via the router (`router.RouteConnectionEx`), so normal `route` rules, DNS, and sniffing in the server's sing-box config apply to traffic exiting through this tunnel — it is not a raw unrestricted relay. + +## Traffic accounting + +Per-user upload/download bytes are tracked through the same SSM (`service/ssmapi`) traffic manager used by the Shadowsocks multi-user inbound, so Hiddify Manager can read `firebasetunnel_server` usage the same way it already reads Shadowsocks usage — wire a tracker via `ServerEndpoint.SetTracker(...)` at box-construction time. diff --git a/protocol/firebasetunnel/docs/operations.md b/protocol/firebasetunnel/docs/operations.md new file mode 100644 index 0000000000..64c481cdfa --- /dev/null +++ b/protocol/firebasetunnel/docs/operations.md @@ -0,0 +1,46 @@ +# Operations + +## Expected latency and throughput + +Polling/SSE against Firebase Realtime Database imposes a realistic floor of **~200ms+ added round-trip latency** beyond normal network latency — this is inherent to the relay mechanism (inherited from the upstream PoC's own measurements), not something this implementation eliminates. Treat this protocol as a fallback transport for blocked-direct-connectivity scenarios, not a low-latency primary path. + +Firebase Realtime Database enforces project-level read/write quotas; high session counts or throughput can hit these limits. See caps below for how this implementation bounds its own usage; operators should also monitor Firebase's own quota dashboards. + +## Resource limits (server-side) + +| Limit | Config field | Default | Effect when exceeded | +|---|---|---|---| +| Global concurrent sessions | `max_sessions` | 1000 | New session rejected (`state=closed`), logged at Warn. | +| Per-user concurrent sessions | `max_sessions_per_user` | 50 | Same — bounds one user's worst-case resource use. | +| Per-user session creation rate | `max_sessions_per_second_per_user` | 5/s (token bucket) | New session rejected if the user's bucket is empty — protects against a compromised/buggy client spamming session creation. | +| Sender pending-bytes (client and server) | — (internal, `maxPendingBytes`) | 8 MiB | `feed()` returns an error rather than buffering unbounded memory; caller should treat this as backpressure. | + +Rejections are logged with the session ID at `Warn` (never with secrets/PSKs) so operators can spot misconfigured clients or abuse attempts. + +## Session garbage collection + +Two timeout-driven cleanups exist: + +1. **In-relay timeout**: an *active* session with no chunk activity for `session_timeout` (default 300s) is torn down by the relay loop itself. +2. **GC sweep**: a background loop (interval = `session_timeout`/2) scans `sessions/` and deletes: + - sessions stuck in `pending` (never reached `active`) older than `session_timeout` — covers a client that crashed after writing metadata but before the server ever responded, + - sessions stuck in `closing`/`closed` for more than `session_timeout` + 10s grace — covers a peer that died mid-shutdown handshake. + +This prevents unbounded Firebase node growth from abandoned sessions without relying on either side cleanly exiting. + +## Failure isolation + +- The server's poll loop and GC loop each run under panic recovery with a 5s restart backoff (`runWithRecovery`) — a bug in either does not crash the rest of the sing-box process or other endpoints/inbounds sharing it. +- A sustained Firebase outage degrades the server to "no new sessions accepted, existing sessions time out via `session_timeout`" rather than a stuck goroutine. +- On the client side, `DialContext` fails within `activation_timeout` (default 30s) if the server never marks a session active — bounded rather than indefinite, so outbound groups (`urltest`/`selector`) can fail over to an alternative outbound in reasonable time. +- SSE reconnects use jittered exponential backoff (`jitteredBackoff`, capped at 30s) to avoid thundering-herd reconnects when many sessions/instances share one Firebase project. + +## Rollout / rollback + +This protocol ships as ordinary registered endpoint types (`firebasetunnel_client`/`firebasetunnel_server`) — there is currently no build-tag or experimental-flag gate. Adopting it for a fleet should be done config-by-config (enable on a small subset first, monitor logs and SSM stats, then widen) rather than relying on a kill switch built into the binary. Disabling it for a given deployment means removing the endpoint from that config; the server endpoint's `Close()` cancels its poll/GC loops but does not forcibly kill in-flight sessions — they drain naturally via the timeout/GC mechanisms above. + +## What to monitor + +- Server logs at `Warn`/`Error` for: rejected sessions (unknown user, limit/rate exceeded), ack/ingest failures (potential decrypt/auth failures if PSKs are in use), GC deletions (volume spikes may indicate misbehaving clients). +- SSM traffic stats (if a tracker is wired via `SetTracker`) for per-user byte counts, the same surface used for Shadowsocks usage today. +- Firebase project's own console/quota metrics for read/write volume against plan limits. diff --git a/protocol/firebasetunnel/docs/provenance.md b/protocol/firebasetunnel/docs/provenance.md new file mode 100644 index 0000000000..2b69129eda --- /dev/null +++ b/protocol/firebasetunnel/docs/provenance.md @@ -0,0 +1,29 @@ +# Provenance + +This protocol is inspired by [github.com/Hiddify2/Firebase-Tunnel](https://github.com/Hiddify2/Firebase-Tunnel), a small (single-star, no LICENSE file) proof-of-concept that relays a SOCKS5 connection through Firebase Realtime Database REST calls. Because the upstream repository carries no license, **no code was copied or vendored** — the mechanism (batched/compressed/base64 chunks written to sequential database keys, ack-and-delete cleanup, SSE-based session discovery) was studied and reimplemented from scratch for sing-box's architecture. + +## What's conceptually the same as the PoC + +- Firebase REST `GET`/`PUT`/`DELETE` for session metadata and chunk queues, plus SSE `Listen` for push-based session discovery. +- Chunk batching (time-or-size triggered flush), zstd compression, base64 encoding, sequence-numbered ordering with out-of-order buffering. +- Ack-pointer-then-delete cleanup pattern to bound database growth. +- Session state machine: `pending` → `active` → `closing` → `closed`. + +## What's different + +| Aspect | Upstream PoC | This implementation | +|---|---|---| +| Client transport entry point | Local SOCKS5 listener | sing-box's own inbound chain (mixed/socks/etc.) feeds connections in; this protocol is purely the outbound leg, exposed as a `net.Conn` via `net.Pipe()` from `DialContext`. | +| Server egress | Raw `net.Dial` to the declared target | `router.RouteConnectionEx`, so the server's own sing-box `route` rules, DNS, and sniffing apply — this cannot be used to bypass routing restrictions configured elsewhere in the same sing-box instance. | +| Multi-tenancy | Single-tenant (no user concept) | `user` label per session, validated against a configured user list; optional per-user PSK turns the label into a verified credential. | +| Traffic accounting | None | Wired into the existing SSM (`service/ssmapi`) traffic manager via `ServerEndpoint.SetTracker`, the same mechanism used for Shadowsocks per-user usage. | +| Auth | Legacy Database Secret only | Legacy secret retained as the simple default, plus an optional Firebase Auth/service-account token path. | +| Payload confidentiality | None (cleartext to anyone who can read the Firebase project) | Optional AES-256-GCM encryption keyed by a per-user PSK. | +| Resource limits | None | Global/per-user session caps, per-user session-creation rate limiting, sender backpressure byte cap. | +| Abandoned-session cleanup | In-session inactivity timeout only | Same, plus a background GC sweep for sessions that never reached `active` or that stalled mid-shutdown. | +| Failure isolation | N/A (single-purpose binary) | Poll/GC loops run under panic recovery with restart backoff so a bug can't take down the rest of the sing-box process. | +| Reconnect behavior | Fixed 2s SSE reconnect delay | Jittered exponential backoff, to avoid thundering-herd reconnects across many sessions/instances sharing a project. | + +## Known shared limitation + +Both the upstream PoC and this implementation share the same fundamental latency floor (~200ms+ added round-trip) and Firebase rate-limit exposure, since both rely on the same underlying Firebase RTDB polling/SSE mechanism — this is an inherent property of the transport choice, not an implementation gap. See [operations.md](./operations.md). diff --git a/protocol/firebasetunnel/docs/security.md b/protocol/firebasetunnel/docs/security.md new file mode 100644 index 0000000000..bda93aeac9 --- /dev/null +++ b/protocol/firebasetunnel/docs/security.md @@ -0,0 +1,41 @@ +# Security / threat model + +## Who can see what + +By default (no `psk` configured), relayed application bytes are **cleartext to anyone who can read the Firebase project** — the project owner, anyone holding the `firebase_secret` or a valid auth token, and anyone with read access to the Realtime Database console. Transport security (HTTPS to Firebase) protects bytes in transit between this process and Firebase, but not from Firebase-side observers. + +Even with the optional PSK encryption enabled (below), **metadata is never hidden**: `target_host`/`target_port` (the dial destination), session timing, and chunk sizes are visible to anyone with project read access. This protocol does not protect against traffic analysis — an observer who can read the Firebase project can infer what's being accessed and roughly how much data flows, even if the payload itself is encrypted. + +## Auth options + +| Option | Field | Strength | Notes | +|---|---|---|---| +| Legacy Database Secret | `firebase_secret` | Weak | Single shared secret grants full read/write to the *entire* Firebase project, not just this tunnel's data. Matches the upstream PoC's default; simplest to set up. | +| Firebase Auth / service-account token | `firebase_auth_token` | Better | Short-lived, can be scoped via Firebase security rules. Recommended for anything beyond personal/test use. | + +Neither option authenticates *which client* is connecting — that's what the `user` label and optional per-user PSK are for. + +## User-label verification (optional, per-user PSK) + +Without a PSK, `user` is a **self-reported claim** — the server uses it only as an accounting label, not a credential. Any client holding the project's `firebase_secret`/token could claim to be any user. + +Setting `psk` on a `FirebaseTunnelUser` (server) and the matching `psk` on the client turns the label into a verified credential: the server only accepts a session's declared `user` if its chunks decrypt successfully under that user's derived key (AES-256-GCM, key derived from the PSK via SHA-256). A mismatched or missing PSK causes chunk ingestion to fail, and the session is treated as an abuse signal (logged, counted against that user's failure rate) rather than silently relayed. + +## Payload encryption + +When a PSK is configured for a user, every chunk's payload is encrypted with AES-256-GCM before base64 encoding (`chunk.encrypted = true`). This is **opt-in** — off by default, matching the upstream PoC's simplest-path behavior — because: +- it requires coordinating a PSK between client and server config (extra operational step), +- the metadata-visibility limitation above means it's a partial mitigation, not full traffic confidentiality. + +Strongly recommended for anything beyond personal/throwaway test use. + +## What this protocol does *not* protect against + +- A malicious or compromised Firebase project administrator (full project access bypasses everything above). +- Traffic analysis via session/chunk timing and sizes, even with payload encryption enabled. +- Firebase-side logging/retention outside this protocol's control. +- Denial of service from a party who obtains the shared secret/token (mitigated, not eliminated, by the per-user rate limits and session caps described in [operations.md](./operations.md)). + +## Rotation + +Secrets and PSKs are currently single-value per config (`firebase_secret`, `users[].psk`) — rotating either requires a coordinated config update on both client and server with a brief overlap window where old sessions using the previous value may fail. Multi-value (list-based) rotation support is a known follow-up, not yet implemented. 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 new file mode 100644 index 0000000000..4823dd08e3 --- /dev/null +++ b/protocol/firebasetunnel/endpoint_test.go @@ -0,0 +1,143 @@ +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) + s := &Endpoint{ + fb: fb, + srv: &serverState{pollInterval: 20 * time.Millisecond, sessionTimeout: 10 * time.Second}, + } + 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 := &Endpoint{ + fb: fb, + clientKey: nil, + hmacKey: hmacKey, + batchInterval: 20 * time.Millisecond, + batchMaxBytes: defaultBatchMaxBytes, + } + 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") + + 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 +} + +// 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 := &Endpoint{ + fb: fb, + srv: &serverState{pollInterval: 20 * time.Millisecond, sessionTimeout: 10 * time.Second}, + } + s.runRelay(ctx, sessionID, serverRemote, &key, nil) + }() + + clientLocal, clientRemote := net.Pipe() + clientDone := make(chan struct{}) + go func() { + defer close(clientDone) + c := &Endpoint{ + fb: fb, + clientKey: &key, + hmacKey: nil, + batchInterval: 20 * time.Millisecond, + batchMaxBytes: defaultBatchMaxBytes, + } + 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/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/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/protocol.go b/protocol/firebasetunnel/protocol.go new file mode 100644 index 0000000000..7a71ce5174 --- /dev/null +++ b/protocol/firebasetunnel/protocol.go @@ -0,0 +1,97 @@ +// 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, 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 { + 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..acf3649898 --- /dev/null +++ b/protocol/firebasetunnel/session.go @@ -0,0 +1,378 @@ +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 + +// 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 { + 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 +} + +// 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, sessionID, direction, fb, batchInterval, batchMaxBytes, key, hmacKey, 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, 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) + defer ticker.Stop() + + flush := func(flushCtx context.Context) { + if len(buffer) == 0 { + return + } + n := len(buffer) + 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) + } + + 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, 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] + + var payload []byte + compressed := false + if len(raw) >= compressMinBytes { + payload = compressBytes(raw) + compressed = true + } else { + payload = raw + } + + encrypted := false + hasHMAC := false + if key != nil { + 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{ + Seq: *seq, + Timestamp: nowMillis(), + Compressed: compressed, + Encrypted: encrypted, + HasHMAC: hasHMAC, + 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 + hmacKey []byte + sessionID string + direction string +} + +// 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, + 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/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) { + r.mu.Lock() + defer r.mu.Unlock() + + if c.Seq < r.nextSeq { + return nil, nil + } + + payload, err := decodeChunkPayload(c, r.key, r.hmacKey, r.sessionID, r.direction) + 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, 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) + } + if c.Encrypted { + if key == nil { + return nil, fmt.Errorf("firebasetunnel: chunk seq=%d is encrypted but no key configured", c.Seq) + } + 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) + 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/session_test.go b/protocol/firebasetunnel/session_test.go new file mode 100644 index 0000000000..b5f31c8cbb --- /dev/null +++ b/protocol/firebasetunnel/session_test.go @@ -0,0 +1,382 @@ +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 + listeners []chan struct{} +} + +func newFakeFirebaseServer() *httptest.Server { + f := &fakeFirebaseServer{data: make(map[string]json.RawMessage)} + 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() + + 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) + f.notifyListeners() + w.Write([]byte("{}")) + case http.MethodDelete: + delete(f.data, path) + w.Write([]byte("null")) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// 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() + + fb := newFirebaseClient(srv.URL, "test-secret", "", 0, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + 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 { + 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, nil, sessionID, "c2s") + 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() + + sessionID := "test-session-2" + key := deriveKey("shared-psk") + 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 { + 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, 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, nil, sessionID, "c2s") + 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 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, nil, "sid", "c2s") + + 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, nil, "sid", "c2s") + + 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", "x", "c2s", fb, time.Hour, 1<<30, nil, 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 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) +} 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 +}