mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
feat(toolbox-ng): per-peer clamp wired into Decide (decideForPeer, both accept paths)
Both sbxmitm accept paths (CONNECT/handleConnect and transparent/ handleTransparent) now go through a single decideForPeer(clientIP, host, sni) helper: it calls the existing px.pol.Decide, then clamps the verdict to the calling peer's R-level via px.rlevel.ModeForIP + clampVerdict when a PeerPolicy is wired in. Factoring both call-sites onto the same helper means they can never drift on how the clamp is applied. Proxy gains an rlevel *PeerPolicy field, loaded in main() from SECUBOX_PEER_RLEVEL / SECUBOX_WG_PEERS (env-overridable, falling back to peer-rlevel.json and machash.go's wgPeersPath). A nil rlevel is a total no-op — every Proxy built without it (existing suite, CONNECT PoC) keeps today's exact px.pol.Decide behavior. Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
fd4bcb5c45
commit
a441fdb50c
|
|
@ -177,6 +177,28 @@ type Proxy struct {
|
|||
// rest to the async sbx-sentinel analyzer. nil-safe and fail-open — a
|
||||
// disabled/erroring hook is a transparent passthrough. See sentinel.go.
|
||||
sentinel *sentinelHook
|
||||
|
||||
// rlevel (#rlevel-per-peer, Task 3) is the per-peer R-level clamp: it
|
||||
// ceilings the verdict px.pol.Decide already computed to what the calling
|
||||
// peer's mode allows (see decideForPeer, rlevel.go's clampVerdict). nil is
|
||||
// a TOTAL NO-OP — every existing test/PoC that builds a Proxy{} without
|
||||
// setting this field keeps today's exact behavior (raw px.pol.Decide).
|
||||
rlevel *PeerPolicy
|
||||
}
|
||||
|
||||
// decideForPeer resolves the policy verdict for (host, sni) and, if a
|
||||
// PeerPolicy is wired in (px.rlevel != nil), clamps it to the calling client's
|
||||
// R-level (clientIP). Both accept paths (CONNECT/handleConnect and
|
||||
// transparent/handleTransparent) call this SAME helper so they can never
|
||||
// drift on how the clamp is applied. px.rlevel == nil is a total no-op:
|
||||
// the result is exactly px.pol.Decide(host, sni), preserving current
|
||||
// behavior for callers/tests that never set rlevel.
|
||||
func (px *Proxy) decideForPeer(clientIP, host, sni string) string {
|
||||
v := px.pol.Decide(host, sni)
|
||||
if px.rlevel != nil {
|
||||
v = clampVerdict(px.rlevel.ModeForIP(clientIP), v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// recordAdBlock forwards a 204'd ad/tracker block to the engine's metrics
|
||||
|
|
@ -292,9 +314,10 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|||
defer client.Close()
|
||||
io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
|
||||
// Decide once on (host, sni). For the CONNECT PoC the SNI is the CONNECT
|
||||
// host; the transparent engine will splice on the real ClientHello SNI.
|
||||
verdict := px.pol.Decide(host, host)
|
||||
// Decide once on (host, sni), clamped to the calling peer's R-level (#rlevel
|
||||
// -per-peer). For the CONNECT PoC the SNI is the CONNECT host; the
|
||||
// transparent engine will splice on the real ClientHello SNI.
|
||||
verdict := px.decideForPeer(peerIP(client), host, host)
|
||||
|
||||
if verdict == "splice" {
|
||||
// passthrough: raw TCP to upstream, no TLS interception (tls_splice).
|
||||
|
|
@ -779,6 +802,20 @@ func main() {
|
|||
if *poison && len(jarKey) == 0 {
|
||||
log.Printf("poison requested but jar key %s absent/empty → poison OFF", *jarKeyPath)
|
||||
}
|
||||
// #rlevel-per-peer Task 3 — per-peer R-level clamp. LoadPeerPolicy is
|
||||
// best-effort (like LoadPolicy above): a missing/unreadable/corrupt store
|
||||
// degrades to its own Passive fail-safe rather than erroring, so in
|
||||
// practice this branch is defensive only. On the rare non-nil error we
|
||||
// choose rlevel = nil (total no-op via decideForPeer) over a policy we
|
||||
// have no confidence in, rather than risk fail-closed-passive silently
|
||||
// blanket-splicing every peer.
|
||||
peerRlevelPath := envOr("SECUBOX_PEER_RLEVEL", "/var/lib/secubox/toolbox/peer-rlevel.json")
|
||||
peerWgPeersPath := envOr("SECUBOX_WG_PEERS", wgPeersPath)
|
||||
rlevelPol, err := LoadPeerPolicy(peerRlevelPath, peerWgPeersPath)
|
||||
if err != nil {
|
||||
log.Printf("peer rlevel policy load failed (per-peer clamp disabled): %v", err)
|
||||
rlevelPol = nil
|
||||
}
|
||||
px := &Proxy{
|
||||
ca: ca,
|
||||
pol: pol,
|
||||
|
|
@ -803,6 +840,8 @@ func main() {
|
|||
// SENTINEL_MIRROR_SOCK); unset/false or a failed pack load yields a
|
||||
// disabled no-op hook, so the default build is byte-identical to today.
|
||||
sentinel: newSentinelHook(),
|
||||
// #rlevel-per-peer Task 3 — per-peer R-level clamp (see decideForPeer).
|
||||
rlevel: rlevelPol,
|
||||
}
|
||||
// #812 Task 6 — apply the retention/size-ceil overrides on top of
|
||||
// NewMediaBuffer's defaults. Same-package unexported field access (see
|
||||
|
|
|
|||
142
packages/secubox-toolbox-ng/cmd/sbxmitm/rlevel_wire_test.go
Normal file
142
packages/secubox-toolbox-ng/cmd/sbxmitm/rlevel_wire_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
//
|
||||
// SecuBox-Deb :: toolbox-ng :: R-level per-peer wiring (#rlevel-per-peer Task 3)
|
||||
//
|
||||
// Proves decideForPeer — the single helper both accept paths (CONNECT/
|
||||
// handleConnect and transparent/handleTransparent) call — actually clamps
|
||||
// px.pol.Decide's verdict to the calling peer's R-level, and that a Proxy
|
||||
// built without a PeerPolicy (px.rlevel == nil, every pre-existing test/PoC)
|
||||
// keeps today's exact behavior.
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// wireTestPeerRlevelJSON pins three peers to fixed, explicit modes via
|
||||
// "forced" so the test is independent of defaults/floor clamping semantics
|
||||
// (already covered by TestPeerPolicyModeForIP in rlevel_test.go).
|
||||
const wireTestPeerRlevelJSON = `{
|
||||
"defaults": {"mode": "passive", "floor": "passive"},
|
||||
"peers": {
|
||||
"PKPASSIVE": {"forced": "passive"},
|
||||
"PKACTIVE": {"forced": "active"},
|
||||
"PKREEL": {"forced": "reel"}
|
||||
}
|
||||
}`
|
||||
|
||||
const wireTestWgPeersJSON = `{
|
||||
"peers": {
|
||||
"PKPASSIVE": {"ip": "10.60.0.1"},
|
||||
"PKACTIVE": {"ip": "10.60.0.2"},
|
||||
"PKREEL": {"ip": "10.60.0.3"}
|
||||
}
|
||||
}`
|
||||
|
||||
const (
|
||||
wirePassiveIP = "10.60.0.1"
|
||||
wireActiveIP = "10.60.0.2"
|
||||
wireReelIP = "10.60.0.3"
|
||||
wireUnknownIP = "10.60.0.99"
|
||||
)
|
||||
|
||||
// newWireTestPolicy builds a real Policy (LoadPolicy, like policy_test.go/
|
||||
// reload_test.go) with two hosts of known, deterministic verdicts:
|
||||
// - mitmHost : not in any list → Decide == "mitm" (the engine's default
|
||||
// for an unmatched host, per TestMaybeReloadPicksUpAppendedLearnedTracker).
|
||||
// - blockHost : appended to learned-trackers.txt → Decide == "block".
|
||||
func newWireTestPolicy(t *testing.T) (pol *Policy, mitmHost, blockHost string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
mitmHost = "mitm-would-be.example"
|
||||
blockHost = "block-would-be.example"
|
||||
|
||||
learned := filepath.Join(dir, "learned-trackers.txt")
|
||||
allow := filepath.Join(dir, "ad-allowlist.txt")
|
||||
writeFile(t, learned, blockHost+"\n")
|
||||
writeFile(t, allow, "")
|
||||
|
||||
p, err := LoadPolicy(PolicyOpts{
|
||||
LearnedPath: learned,
|
||||
AllowPath: allow,
|
||||
SpliceSeedPath: filepath.Join(dir, "splice-seed.conf"),
|
||||
SpliceLearnPath: filepath.Join(dir, "splice-learned.txt"),
|
||||
PureTrackersPath: filepath.Join(dir, "pure-trackers.txt"),
|
||||
SelfDomains: []string{"secubox.in"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPolicy: %v", err)
|
||||
}
|
||||
return p, mitmHost, blockHost
|
||||
}
|
||||
|
||||
// newWireTestPeerPolicy loads the three-peer fixture (passive/active/reel).
|
||||
func newWireTestPeerPolicy(t *testing.T) *PeerPolicy {
|
||||
t.Helper()
|
||||
rlevelPath, wgPath := writePeerFixtures(t, wireTestPeerRlevelJSON, wireTestWgPeersJSON)
|
||||
pp, err := LoadPeerPolicy(rlevelPath, wgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPeerPolicy: %v", err)
|
||||
}
|
||||
return pp
|
||||
}
|
||||
|
||||
// TestDecideForPeerPassiveClampsToSplice: a passive IP always gets "splice",
|
||||
// even for a host whose bare policy verdict would be "mitm".
|
||||
func TestDecideForPeerPassiveClampsToSplice(t *testing.T) {
|
||||
pol, mitmHost, _ := newWireTestPolicy(t)
|
||||
// sanity: prove the unclamped verdict really is "mitm" first.
|
||||
if got := pol.Decide(mitmHost, mitmHost); got != "mitm" {
|
||||
t.Fatalf("sanity: pol.Decide(%q) = %q, want mitm", mitmHost, got)
|
||||
}
|
||||
px := &Proxy{pol: pol, rlevel: newWireTestPeerPolicy(t)}
|
||||
|
||||
if got := px.decideForPeer(wirePassiveIP, mitmHost, mitmHost); got != "splice" {
|
||||
t.Fatalf("passive peer: decideForPeer(%q) = %q, want splice (would-be mitm host)", mitmHost, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideForPeerActiveDowngradesBlockToMitm: an active IP gets "mitm" for
|
||||
// a host whose bare policy verdict would be "block" (visibility, no enforce).
|
||||
func TestDecideForPeerActiveDowngradesBlockToMitm(t *testing.T) {
|
||||
pol, _, blockHost := newWireTestPolicy(t)
|
||||
if got := pol.Decide(blockHost, blockHost); got != "block" {
|
||||
t.Fatalf("sanity: pol.Decide(%q) = %q, want block", blockHost, got)
|
||||
}
|
||||
px := &Proxy{pol: pol, rlevel: newWireTestPeerPolicy(t)}
|
||||
|
||||
if got := px.decideForPeer(wireActiveIP, blockHost, blockHost); got != "mitm" {
|
||||
t.Fatalf("active peer: decideForPeer(%q) = %q, want mitm (would-be block host)", blockHost, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideForPeerReelPreservesBlock: a reel (full enforcement) IP keeps the
|
||||
// underlying "block" verdict unchanged.
|
||||
func TestDecideForPeerReelPreservesBlock(t *testing.T) {
|
||||
pol, _, blockHost := newWireTestPolicy(t)
|
||||
px := &Proxy{pol: pol, rlevel: newWireTestPeerPolicy(t)}
|
||||
|
||||
if got := px.decideForPeer(wireReelIP, blockHost, blockHost); got != "block" {
|
||||
t.Fatalf("reel peer: decideForPeer(%q) = %q, want block (preserved)", blockHost, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideForPeerNilRlevelIsNoop: px.rlevel == nil (every Proxy built by the
|
||||
// existing suite / the CONNECT PoC before this task) must behave EXACTLY like
|
||||
// calling px.pol.Decide directly — the wiring must never change behavior for
|
||||
// a Proxy that doesn't opt in.
|
||||
func TestDecideForPeerNilRlevelIsNoop(t *testing.T) {
|
||||
pol, mitmHost, blockHost := newWireTestPolicy(t)
|
||||
px := &Proxy{pol: pol} // rlevel left at its zero value: nil
|
||||
|
||||
for _, host := range []string{mitmHost, blockHost} {
|
||||
want := pol.Decide(host, host)
|
||||
// Any clientIP — including one that would resolve to Passive/Active in
|
||||
// the fixture used above — must have zero effect when rlevel is nil.
|
||||
if got := px.decideForPeer(wirePassiveIP, host, host); got != want {
|
||||
t.Fatalf("nil rlevel: decideForPeer(%q) = %q, want %q (== pol.Decide, no-op)", host, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -366,7 +366,9 @@ func (px *Proxy) handleTransparent(client net.Conn) {
|
|||
decisionHost = dstHost // no SNI → fall back to the captured dst IP
|
||||
}
|
||||
|
||||
verdict := px.pol.Decide(decisionHost, sni)
|
||||
// Clamped to the calling peer's R-level (#rlevel-per-peer) via the SAME
|
||||
// helper handleConnect uses, so the two accept paths never drift.
|
||||
verdict := px.decideForPeer(peerIP(client), decisionHost, sni)
|
||||
|
||||
if verdict == "splice" {
|
||||
// Passthrough: raw TCP to the REAL captured destination, never the SNI,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user