Skip to content
Open
3 changes: 3 additions & 0 deletions constant/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)

Expand Down Expand Up @@ -142,6 +143,8 @@ func ProxyDisplayName(proxyType string) string {
return "DNSTT"
case TypeGooseRelay:
return "GooseRelay"
case TypeFirebaseTunnel:
return "Firebase Tunnel"
default:
return "Unknown"
}
Expand Down
18 changes: 18 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
2 changes: 2 additions & 0 deletions include/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -129,6 +130,7 @@ func EndpointRegistry() *endpoint.Registry {

tunnel.RegisterServerEndpoint(registry)
tunnel.RegisterClientEndpoint(registry)
firebasetunnel.RegisterEndpoint(registry)

registerWireGuardEndpoint(registry)
registerWarpEndpoint(registry)
Expand Down
68 changes: 68 additions & 0 deletions option/firebasetunnel.go
Original file line number Diff line number Diff line change
@@ -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=<secret> 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"`
}
41 changes: 41 additions & 0 deletions protocol/firebasetunnel/compress.go
Original file line number Diff line number Diff line change
@@ -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
}
32 changes: 32 additions & 0 deletions protocol/firebasetunnel/compress_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
122 changes: 122 additions & 0 deletions protocol/firebasetunnel/crypto.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions protocol/firebasetunnel/crypto_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading