Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions constant/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package constant
const (
TypeAmnezia = "amnezia"
TypeALGeneva = "algeneva"
TypeMITMDF = "mitm-df"
TypeOutline = "outline"
TypeReflex = "reflex"
TypeSamizdat = "samizdat"
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb
github.com/gobwas/ws v1.4.0
github.com/pion/transport/v4 v4.0.1
github.com/refraction-networking/utls v1.8.2
github.com/refraction-networking/water v0.7.1-alpha
github.com/sagernet/sing v0.7.18
github.com/sagernet/sing-box v1.12.22
Expand Down Expand Up @@ -223,7 +224,6 @@ require (
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
Expand Down
106 changes: 106 additions & 0 deletions option/mitmdf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package option

import "github.com/sagernet/sing-box/option"

// MITMDFOutboundOptions configures a MITM-DomainFronting outbound.
//
// On dial, the outbound terminates the user's TLS handshake using a leaf
// certificate minted on the fly by a name-constrained local CA (see
// internal/mitmca), looks up the user's SNI against the configured fronts
// table, and dials the matching front through the embedded sing-box
// dialer. The egress connection is wrapped with uTLS using the configured
// fingerprint preset, the front's SNI, and the user's negotiated ALPN.
// Plaintext is bridged between the two TLS sides.
//
// Security properties — see docs/mitm-df/architecture.md and
// getlantern/engineering#3482 for the full rationale:
//
// - The CA carries an RFC 5280 NameConstraints extension restricting
// which DNS subtrees it can sign for. Even if the CA private key is
// extracted, a cert for chase.com cannot be forged.
// - The outbound never listens on a TCP port; the user side of every
// MITM session is an in-memory net.Pipe.
// - A deny list (DenyDomains) is enforced in the GetCertificate
// callback before any signature is produced — defense in depth for
// misrouted or maliciously-influenced flows.
// - Every mint is recorded to an audit log (AuditLogPath).
//
// The user device must trust the CA out-of-band for the user-side TLS
// handshake to succeed.
type MITMDFOutboundOptions struct {
option.DialerOptions
option.ServerOptions

// CA configures the name-constrained signing CA. On first run with
// missing files at CertPath/KeyPath, a fresh CA scoped to
// PermittedDomains is generated and persisted. On subsequent runs the
// existing CA is loaded and its PermittedDNSDomains is checked against
// the configured PermittedDomains (loads that don't cover the configured
// list are refused — downgrade protection).
CA MITMDFCAOptions `json:"ca"`

// Fronts maps user SNIs onto fronted destinations. The matcher is a
// suffix-aware exact match against FrontEntry.Names: an SNI matches an
// entry if it equals one of Names or has one of Names as a `.foo` DNS
// suffix. Entries are evaluated in order and the first match wins.
Fronts []MITMDFFrontEntry `json:"fronts"`

// DenyDomains are SNIs (exact or `.suffix` match, same semantics as
// Fronts.Names) that the outbound refuses to MITM regardless of any
// fronts match. Enforced in the GetCertificate callback before signing.
// Use for sensitive categories (banking, healthcare, government) that
// the route layer should never have sent here in the first place.
DenyDomains []string `json:"deny_domains,omitempty"`

// AuditLogPath is the path of an append-only JSONL audit log. Every
// MITM decision (allow, deny, no-match) is recorded with timestamp,
// SNI, fronted SNI, and decision. Empty disables auditing.
AuditLogPath string `json:"audit_log_path,omitempty"`

// Fingerprint selects the uTLS ClientHello preset for the egress
// handshake. One of: "chrome" (default), "firefox", "safari", "random".
Fingerprint string `json:"fingerprint,omitempty"`

// EgressHandshakeTimeout bounds both the user-side tls.Server handshake
// and the egress uTLS handshake, parseable by time.ParseDuration.
// Defaults to "10s".
EgressHandshakeTimeout string `json:"egress_handshake_timeout,omitempty"`
}

// MITMDFCAOptions configures the per-device signing CA. CertPath and
// KeyPath are the persistence locations; PermittedDomains is the
// NameConstraints subtree list cryptographically encoded into the cert.
type MITMDFCAOptions struct {
CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"`
PermittedDomains []string `json:"permitted_domains"`
// Validity, in time.ParseDuration form. Defaults to 30 days when empty.
// Only consulted at CA generation; ignored when reloading an existing CA.
Validity string `json:"validity,omitempty"`
}

// MITMDFFrontEntry maps a set of user-side SNIs to a single fronted
// destination on egress.
type MITMDFFrontEntry struct {
// Names is the SNI list this front handles. Matched as exact equality
// or as a `.suffix` DNS subtree of the user's SNI.
Names []string `json:"names"`

// FrontedSNI is the SNI presented to the CDN edge on the egress TLS
// handshake. Required.
FrontedSNI string `json:"fronted_sni"`

// VerifySAN, when non-empty, broadens egress cert validation to accept
// the peer leaf if it is valid for FrontedSNI OR any of these DNS
// names. Emulates Xray's verifyPeerCertByName. Use when the CDN serves
// a shared cert covering many names but the user's destination is one
// of those secondary names (Vercel / Fastly typical case).
VerifySAN []string `json:"verify_san,omitempty"`

// RedirectAddr is an optional "host:port" overriding the egress dial
// target. If empty, the outbound dials the destination handed to it by
// the router (the user's intended host as resolved). Typical use:
// dial the front's real backend (`nextjs.org:443`) while preserving the
// inner Host header.
RedirectAddr string `json:"redirect_addr,omitempty"`
}
79 changes: 79 additions & 0 deletions protocol/mitmdf/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package mitmdf

import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
)

// auditDecision is one of the fixed verdicts recorded in the audit log.
type auditDecision string

const (
auditAllow auditDecision = "allow" // a leaf was minted and the egress was attempted
auditDeny auditDecision = "deny" // deny list matched; no mint
auditNoMatch auditDecision = "no-match" // no fronts entry claimed the SNI; no mint
auditError auditDecision = "error" // mint or egress failed; recorded for forensics
)

// auditRecord is the JSONL line schema. Stable on-disk; new fields must be
// added with `omitempty` so old log readers don't reject them.
type auditRecord struct {
Timestamp time.Time `json:"ts"`
SNI string `json:"sni"`
FrontedSNI string `json:"fronted_sni,omitempty"`
Decision auditDecision `json:"decision"`
Reason string `json:"reason,omitempty"`
}

// auditLog is an append-only JSONL writer. nil receiver methods are safe
// no-ops so callers can use a single code path whether or not auditing is
// configured.
type auditLog struct {
mu sync.Mutex
f *os.File
}

// openAuditLog opens (or creates) path for append at mode 0600. Returns
// nil, nil if path is empty (caller may disable auditing by leaving the
// config field unset).
func openAuditLog(path string) (*auditLog, error) {
if path == "" {
return nil, nil
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("audit log open %s: %w", path, err)
}
return &auditLog{f: f}, nil
}

// record writes one JSONL line under the file lock. Write errors are
// dropped on the floor — the audit log is best-effort by design: a full
// disk or a slow fsync must not block real traffic.
func (a *auditLog) record(r auditRecord) {
if a == nil {
return
}
r.Timestamp = time.Now().UTC()
buf, err := json.Marshal(r)
if err != nil {
return
}
buf = append(buf, '\n')
a.mu.Lock()
defer a.mu.Unlock()
_, _ = a.f.Write(buf)
}

// close releases the underlying file. Safe on nil.
func (a *auditLog) close() error {
if a == nil {
return nil
}
a.mu.Lock()
defer a.mu.Unlock()
return a.f.Close()
}
104 changes: 104 additions & 0 deletions protocol/mitmdf/audit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package mitmdf

import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
)

func TestOpenAuditLog_DisabledWhenEmpty(t *testing.T) {
a, err := openAuditLog("")
if err != nil {
t.Fatalf("openAuditLog(\"\"): %v", err)
}
if a != nil {
t.Error("openAuditLog(\"\") returned a non-nil log; want nil for disabled")
}
// nil-safe ops.
a.record(auditRecord{SNI: "x"})
if err := a.close(); err != nil {
t.Errorf("nil.close: %v", err)
}
}

func TestAuditLog_WritesJSONL(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.log")
a, err := openAuditLog(path)
if err != nil {
t.Fatalf("openAuditLog: %v", err)
}
defer a.close()

a.record(auditRecord{SNI: "vercel.app", FrontedSNI: "nextjs.org", Decision: auditAllow})
a.record(auditRecord{SNI: "chase.com", Decision: auditDeny, Reason: "deny list"})

f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
var got []auditRecord
sc := bufio.NewScanner(f)
for sc.Scan() {
var r auditRecord
if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got = append(got, r)
}
if len(got) != 2 {
t.Fatalf("got %d records, want 2", len(got))
}
if got[0].SNI != "vercel.app" || got[0].FrontedSNI != "nextjs.org" || got[0].Decision != auditAllow {
t.Errorf("record[0] = %+v", got[0])
}
if got[1].Decision != auditDeny || got[1].Reason != "deny list" {
t.Errorf("record[1] = %+v", got[1])
}
for i, r := range got {
if r.Timestamp.IsZero() {
t.Errorf("record[%d] missing timestamp", i)
}
}
}

func TestAuditLog_ConcurrentWrites(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.log")
a, err := openAuditLog(path)
if err != nil {
t.Fatalf("openAuditLog: %v", err)
}
defer a.close()

const N = 50
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
a.record(auditRecord{SNI: "x.test", Decision: auditAllow})
}()
}
wg.Wait()

f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
count := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
var r auditRecord
if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
t.Fatalf("line %d unmarshal: %v", count+1, err)
}
count++
}
if count != N {
t.Errorf("got %d records, want %d", count, N)
}
}
Loading
Loading