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:
CyberMind-FR 2026-07-24 15:13:09 +02:00
parent fd4bcb5c45
commit a441fdb50c
3 changed files with 187 additions and 4 deletions

View File

@ -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

View 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)
}
}
}

View File

@ -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,