diff --git a/netsim/censor/ip.go b/netsim/censor/ip.go new file mode 100644 index 0000000..5d21d9c --- /dev/null +++ b/netsim/censor/ip.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package censor + +import ( + "bytes" + "net/netip" + "sync" + "time" + + "github.com/rbmk-project/x/netsim/packet" +) + +// Blackholer implements connection blackholing with optional pattern matching +// and connection tracking. Once a connection is blackholed, all packets matching +// its five-tuple will be dropped for the configured duration. +type Blackholer struct { + // target specifies an optional specific endpoint to filter + // if zero, applies to all connections. + target netip.AddrPort + + // pattern is an optional byte pattern to match in payload + // if nil, only considers the target (if set). + pattern []byte + + // duration specifies how long to maintain blackholing state, if set. + duration time.Duration + + // mu protects access to blocked. + mu sync.Mutex + + // blocked tracks blackholed connections using five-tuple. + blocked map[fiveTuple]time.Time +} + +// fiveTuple is the five-tuple identifying a connection. +type fiveTuple struct { + proto packet.IPProtocol + srcAddr netip.Addr + srcPort uint16 + dstAddr netip.Addr + dstPort uint16 +} + +// NewBlackholer creates a new [*Blackholer] instance. +// +// The duration parameter controls how long connections remain blackholed. +// +// If target is zero, it applies to all connections. +// +// If pattern is nil, it doesn't perform payload matching. +func NewBlackholer(duration time.Duration, target netip.AddrPort, pattern []byte) *Blackholer { + return &Blackholer{ + target: target, + pattern: pattern, + duration: duration, + mu: sync.Mutex{}, + blocked: make(map[fiveTuple]time.Time), + } +} + +// Filter implements [packet.Filter]. +func (t *Blackholer) Filter(pkt *packet.Packet) (packet.Target, []*packet.Packet) { + // Check if this connection is already blocked + tuple := fiveTuple{ + proto: pkt.IPProtocol, + srcAddr: pkt.SrcAddr, + srcPort: pkt.SrcPort, + dstAddr: pkt.DstAddr, + dstPort: pkt.DstPort, + } + now := time.Now() + t.mu.Lock() + deadline, ok := t.blocked[tuple] + blocked := ok && now.Before(deadline) + if ok && !blocked { + delete(t.blocked, tuple) + } + t.mu.Unlock() + if blocked { + return packet.DROP, nil + } + + // Check if we need to filter specific endpoint + if t.target.IsValid() { + if pkt.DstAddr != t.target.Addr() || pkt.DstPort != t.target.Port() { + return packet.ACCEPT, nil + } + } + + // If we have a pattern, check payload + if t.pattern != nil { + if len(pkt.Payload) <= 0 || !bytes.Contains(pkt.Payload, t.pattern) { + return packet.ACCEPT, nil + } + } + + // Block this connection + t.mu.Lock() + t.blocked[tuple] = now.Add(t.duration) + t.mu.Unlock() + + return packet.DROP, nil +} diff --git a/netsim/example_censor_tls_test.go b/netsim/example_censor_tls_test.go index 4b960a8..bb6d8a1 100644 --- a/netsim/example_censor_tls_test.go +++ b/netsim/example_censor_tls_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/netip" + "time" "github.com/rbmk-project/x/netsim" "github.com/rbmk-project/x/netsim/censor" @@ -53,3 +54,42 @@ func Example_tlsRSTInjection() { // Output: // err: Get "https://dns.google/": connection reset by peer } + +// This example shows how to use [netsim] to simulate SNI-based TLS blocking +// using connection blackholing. +func Example_tlsBlackholing() { + // Create a new scenario using the given directory to cache + // the certificates used by the simulated PKI + scenario := netsim.NewScenario("testdata") + defer scenario.Close() + + // Create server stack emulating dns.google. + scenario.Attach(scenario.MustNewGoogleDNSStack()) + + // Configure blackholing on the scenario router targeting + // connections where the SNI matches "dns.google" + scenario.Router().AddFilter(censor.NewBlackholer( + 300*time.Second, // residual censorship duration + netip.AddrPort{}, // match any endpoint + []byte("dns.google"), // match SNI + )) + + // Create and attach the client stack. + clientStack := scenario.MustNewClientStack() + scenario.Attach(clientStack) + + // Create the HTTP client with a short timeout + clientTxp := scenario.NewHTTPTransport(clientStack) + defer clientTxp.CloseIdleConnections() + clientHTTP := &http.Client{ + Transport: clientTxp, + Timeout: 200 * time.Millisecond, // short timeout for testing + } + + // Attempt the HTTPS request, which should time out + _, err := clientHTTP.Get("https://dns.google/") + fmt.Printf("err: %v\n", err) + + // Output: + // err: Get "https://dns.google/": context deadline exceeded (Client.Timeout exceeded while awaiting headers) +}