Add zapret tool implementation and associated tests

- Implemented the zapret tool in `tool_zapret.go`, including desynchronization modes, fooling strategies, and split position parsing.
- Added grammar definitions for zapret configurations.
- Created comprehensive tests in `tool_zapret_test.go` to validate the functionality of zapret options, ensuring all recognized options are reported and conflicts are handled correctly.
- Included tests for desync modes, fooling strategies, and the handling of UDP profiles.
This commit is contained in:
Daniel Lavrushin 2026-08-08 21:56:38 +02:00 committed by Daniel Lavrushin
parent 1ceaab7aa9
commit 37d05fb102
18 changed files with 2962 additions and 1286 deletions

View file

@ -118,6 +118,7 @@ func Analyze(input string, opts Options) (*Result, error) {
ProfileDomains: opts.ProfileDomains,
ProfileModel: spec.ProfileModel,
BreakKeys: spec.ProfileBreak,
Defaults: spec.Defaults,
})
noteUnaccounted(resolved, notes)

View file

@ -4,8 +4,6 @@ import (
"errors"
"strings"
"testing"
"github.com/daniellavrushin/b4/config"
)
func analyze(t *testing.T, line string) *Result {
@ -37,303 +35,6 @@ func hasField(n Note, field string) bool {
return false
}
func TestAnalyze_Splitting(t *testing.T) {
tests := []struct {
name string
line string
strategy string
middleSNI bool
sniPosition int
}{
{"sniStart", "-s1+s", "tcp", true, 0},
{"sniMiddle", "-s0+sm", "tcp", true, 0},
{"fixedPosition", "-s5", "tcp", false, 5},
{"firstByte", "-s1", "tcp", false, 1},
{"disorder", "-d0+sm", "disorder", true, 0},
{"splitAndDisorder", "-s1 -d0+sm", "combo", true, 0},
{"noSplit", "-t8", "none", true, 1},
{"negativeOffset", "-s-1", "tcp", true, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := analyze(t, tt.line)
set := res.Sets[0]
if set.Fragmentation.Strategy != tt.strategy {
t.Fatalf("strategy: got %q, want %q", set.Fragmentation.Strategy, tt.strategy)
}
if tt.strategy == config.ConfigNone {
return
}
if set.Fragmentation.MiddleSNI != tt.middleSNI {
t.Fatalf("middle_sni: got %v, want %v", set.Fragmentation.MiddleSNI, tt.middleSNI)
}
if set.Fragmentation.SNIPosition != tt.sniPosition {
t.Fatalf("sni_position: got %d, want %d", set.Fragmentation.SNIPosition, tt.sniPosition)
}
})
}
}
func TestAnalyze_OOBUsesByedpiDefaultByte(t *testing.T) {
res := analyze(t, "-o1")
set := res.Sets[0]
if set.Fragmentation.Strategy != "oob" {
t.Fatalf("strategy: got %q", set.Fragmentation.Strategy)
}
if set.Fragmentation.OOBPosition != 1 {
t.Fatalf("oob_position: got %d", set.Fragmentation.OOBPosition)
}
if set.Fragmentation.OOBChar != 'a' {
t.Fatalf("oob_char: got %d, want %d (byedpi default), b4 default is %d",
set.Fragmentation.OOBChar, 'a', config.DefaultSetConfig.Fragmentation.OOBChar)
}
}
func TestAnalyze_OOBByteOverride(t *testing.T) {
res := analyze(t, "-o1 -eb")
if got := res.Sets[0].Fragmentation.OOBChar; got != 'b' {
t.Fatalf("oob_char: got %d, want %d", got, 'b')
}
}
func TestAnalyze_TLSRecord(t *testing.T) {
res := analyze(t, "-r2")
set := res.Sets[0]
if set.Fragmentation.Strategy != "tls" || set.Fragmentation.TLSRecordPosition != 2 {
t.Fatalf("got strategy=%q pos=%d", set.Fragmentation.Strategy, set.Fragmentation.TLSRecordPosition)
}
}
func TestAnalyze_FakeDefaults(t *testing.T) {
res := analyze(t, "-f-1")
set := res.Sets[0]
if !set.Faking.SNI {
t.Fatal("expected faking.sni to be enabled")
}
if set.Faking.Strategy != "ttl" || !set.Faking.ApplyTTL {
t.Fatalf("got strategy=%q apply_ttl=%v", set.Faking.Strategy, set.Faking.ApplyTTL)
}
if set.Faking.TTL != byedpiDefaultFakeTTL {
t.Fatalf("ttl: got %d, want %d", set.Faking.TTL, byedpiDefaultFakeTTL)
}
}
func TestAnalyze_FakeSNIBecomesGeneratedPayload(t *testing.T) {
res := analyze(t, "-f-1 -Qr -n https://www.gosuslugi.ru/")
set := res.Sets[0]
if set.Faking.SNIType != config.FakePayloadDomain {
t.Fatalf("sni_type: got %d, want %d", set.Faking.SNIType, config.FakePayloadDomain)
}
if set.Faking.PayloadDomain != "www.gosuslugi.ru" {
t.Fatalf("payload_domain: got %q", set.Faking.PayloadDomain)
}
if len(set.Faking.TLSMod) != 1 || set.Faking.TLSMod[0] != "rnd" {
t.Fatalf("tls_mod: got %v", set.Faking.TLSMod)
}
}
func TestAnalyze_MD5SigWithoutFakeIsDegenerate(t *testing.T) {
res := analyze(t, "-d0+sm -S")
n := noteFor(t, res, "-S")
if n.Status != StatusDegenerate || n.Reason != "requiresFake" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_RepeatsWithoutSkipIsDegenerate(t *testing.T) {
res := analyze(t, "-d1:11+sm")
n := noteFor(t, res, "-d1:11+sm")
if n.Status != StatusDegenerate || n.Reason != "repeatsWithoutSkip" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_RepeatsWithSkipIsApproximated(t *testing.T) {
res := analyze(t, "-s1:3:5")
n := noteFor(t, res, "-s1:3:5")
if n.Status != StatusApproximated || n.Reason != "repeatsUnsupported" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_HostsInlineBecomeTargets(t *testing.T) {
res := analyze(t, "-H:youtube.com,googlevideo.com -s1+s")
set := res.Sets[0]
if len(set.Targets.SNIDomains) != 2 {
t.Fatalf("sni_domains: got %v", set.Targets.SNIDomains)
}
if !set.Enabled {
t.Fatal("a set with targets should be enabled")
}
}
func TestAnalyze_HostsFileIsUnresolved(t *testing.T) {
res := analyze(t, "-H /etc/byedpi/hosts.txt -s1+s")
if len(res.Unresolved) != 1 || res.Unresolved[0].Kind != "hostlist" {
t.Fatalf("unresolved: got %+v", res.Unresolved)
}
if res.Sets[0].Enabled {
t.Fatal("a set with no resolved targets must stay disabled")
}
}
func TestAnalyze_ProxyRuntimeIsNotApplicable(t *testing.T) {
res := analyze(t, "-i 0.0.0.0 -p 1080 -c 512 -s1+s")
for _, tok := range []string{"-i 0.0.0.0", "-p 1080", "-c 512"} {
n := noteFor(t, res, tok)
if n.Status != StatusNotApplicable {
t.Fatalf("%s: got %+v", tok, n)
}
}
if res.Fidelity.NotApplicable != 3 {
t.Fatalf("not_applicable: got %d", res.Fidelity.NotApplicable)
}
bare := analyze(t, "-s1+s")
if res.Fidelity.Score != bare.Fidelity.Score {
t.Fatalf("proxy plumbing must not change the score: got %d with, %d without",
res.Fidelity.Score, bare.Fidelity.Score)
}
}
func TestAnalyze_UnsupportedOptions(t *testing.T) {
tests := []struct {
token string
line string
reason string
}{
{"-Mh,d,r", "-f1 -Mh,d,r", "httpTamper"},
{"-O5", "-f1 -O5", "fakeOffsetUnsupported"},
{"-m3", "-f1 -m3", "noEquivalent"},
}
for _, tt := range tests {
t.Run(tt.token, func(t *testing.T) {
res := analyze(t, tt.line)
n := noteFor(t, res, tt.token)
if n.Status != StatusUnsupported || n.Reason != tt.reason {
t.Fatalf("got %+v", n)
}
})
}
}
func TestAnalyze_UDPProfile(t *testing.T) {
res := analyze(t, "-Ku -a1")
set := res.Sets[0]
if set.UDP.Mode != "fake" || set.UDP.FakeSeqLength != 1 || set.UDP.FilterQUIC != "all" {
t.Fatalf("udp: got mode=%q len=%d quic=%q", set.UDP.Mode, set.UDP.FakeSeqLength, set.UDP.FilterQUIC)
}
if set.Fragmentation.Strategy != config.ConfigNone || set.Faking.SNI {
t.Fatalf("a UDP-only profile must not carry TCP strategies: %q / %v",
set.Fragmentation.Strategy, set.Faking.SNI)
}
}
func TestAnalyze_ProtoFilterBecomesPortFilter(t *testing.T) {
res := analyze(t, "-Kt,h -H:example.com -s1+s")
if got := res.Sets[0].TCP.DPortFilter; got != "80,443" {
t.Fatalf("dport_filter: got %q", got)
}
}
func TestAnalyze_PortFilter(t *testing.T) {
res := analyze(t, "-V443-444 -H:example.com -s1+s")
if got := res.Sets[0].TCP.DPortFilter; got != "443-444" {
t.Fatalf("dport_filter: got %q", got)
}
}
func TestAnalyze_EscalationChain(t *testing.T) {
res := analyze(t, "-H:example.com -s1+s -At -d0+sm -At -f-1")
if len(res.Sets) != 3 {
t.Fatalf("expected 3 sets, got %d", len(res.Sets))
}
if res.Sets[0].Escalate.To != res.Sets[1].Id {
t.Fatalf("set 0 should escalate to set 1, got %q", res.Sets[0].Escalate.To)
}
if res.Sets[1].Escalate.To != res.Sets[2].Id {
t.Fatalf("set 1 should escalate to set 2, got %q", res.Sets[1].Escalate.To)
}
if res.Sets[2].Escalate.To != "" {
t.Fatalf("last set must not escalate, got %q", res.Sets[2].Escalate.To)
}
for i := 1; i < 3; i++ {
if !res.Sets[i].Enabled {
t.Fatalf("escalation target %d must be enabled", i)
}
if res.Sets[i].TCP.DPortFilter != "" {
t.Fatalf("escalation target %d must not match on ports alone", i)
}
}
}
func TestAnalyze_AutoNoneIsNotAnEscalation(t *testing.T) {
res := analyze(t, "-Ku -a1 -An -s1+s")
if res.Sets[0].Escalate.To != "" {
t.Fatalf("-An must not create an escalation link, got %q", res.Sets[0].Escalate.To)
}
n := noteFor(t, res, "-An")
if n.Status != StatusMapped || n.Reason != "autoNoneEntrySet" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_UDPOnlyProfileIsFoldedIntoTheEntrySet(t *testing.T) {
res := analyze(t, "-Ku -a1 -An -s1+s -At -d0+sm")
if len(res.Sets) != 2 {
t.Fatalf("the UDP profile should not become a set of its own, got %d sets", len(res.Sets))
}
entry := res.Sets[0]
if entry.Fragmentation.Strategy != "tcp" {
t.Fatalf("entry set lost its TCP strategy: %q", entry.Fragmentation.Strategy)
}
if entry.UDP.Mode != "fake" || entry.UDP.FakeSeqLength != 1 || entry.UDP.FilterQUIC != "all" {
t.Fatalf("entry set did not inherit the UDP handling: %+v", entry.UDP)
}
n := noteFor(t, res, "-Ku")
if n.Reason != "udpFoldedIntoSet" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_NoEntrySetIsShadowedByAnother(t *testing.T) {
res, err := Analyze("-Ku -a1 -An -s1+s -At -d0+sm", Options{Domains: []string{"youtube.com"}})
if err != nil {
t.Fatal(err)
}
claimed := map[string]int{}
for _, s := range res.Sets {
if !s.Enabled {
continue
}
for _, d := range s.Targets.SNIDomains {
claimed[d]++
}
}
for domain, n := range claimed {
if n > 1 {
t.Fatalf("%q is claimed by %d enabled sets; b4 applies only the first and ignores the rest", domain, n)
}
}
}
func TestAnalyze_UDPOnlyProfileSurvivesWithoutACarrier(t *testing.T) {
res := analyze(t, "-Ku -a1")
if len(res.Sets) != 1 {
t.Fatalf("expected the UDP profile to stay as its own set, got %d", len(res.Sets))
}
if res.Sets[0].UDP.FakeSeqLength != 1 {
t.Fatalf("udp: got %+v", res.Sets[0].UDP)
}
}
func TestAnalyze_UDPProfileWithOwnHostsIsNotFolded(t *testing.T) {
res := analyze(t, "-Ku -H:quic.example.com -a1 -An -H:www.example.com -s1+s")
if len(res.Sets) != 2 {
t.Fatalf("a UDP profile with its own host list is a separate set, got %d", len(res.Sets))
}
}
func TestAnalyze_PerProfileDomains(t *testing.T) {
res, err := Analyze("-An -s1+s -An -d0+sm", Options{
Domains: []string{"fallback.example"},
@ -366,19 +67,6 @@ func TestAnalyze_PlanDescribesRoles(t *testing.T) {
}
}
func TestAnalyze_ExplicitVersionOverride(t *testing.T) {
res, err := Analyze("-n example.com -f1", Options{Tool: "byedpi", Version: "0.13"})
if err != nil {
t.Fatal(err)
}
if res.Version != "0.13" {
t.Fatalf("version: got %q", res.Version)
}
if res.Sets[0].Faking.PayloadDomain != "example.com" {
t.Fatalf("payload_domain: got %q", res.Sets[0].Faking.PayloadDomain)
}
}
func TestAnalyze_DomainsOption(t *testing.T) {
res, err := Analyze("-s1+s -At -d0+sm", Options{Domains: []string{"youtube.com"}})
if err != nil {
@ -426,61 +114,3 @@ func TestAnalyze_ToolDetection(t *testing.T) {
})
}
}
func TestAnalyze_UserReportedLine(t *testing.T) {
line := "-Ku -a1 -An -o1 -At,r,s -f-1 -At,r,s -d1:11+sm -S -At,r,s " +
"-n https://www.gosuslugi.ru/ -Qr -f1 -d1:11+sm -s1:11+sm -S"
res := analyze(t, line)
if res.Tool != "byedpi" {
t.Fatalf("tool: got %q", res.Tool)
}
if res.Version != "0.17" {
t.Fatalf("version: got %q, want 0.17 (uses -Q and pos:repeats syntax)", res.Version)
}
if res.VersionInferred {
t.Fatal("version should be detected from markers, not guessed")
}
if len(res.Sets) != 4 {
t.Fatalf("expected 4 sets, got %d", len(res.Sets))
}
if res.Sets[0].UDP.FakeSeqLength != 1 {
t.Fatalf("set 0 should carry the folded UDP handling: got %d", res.Sets[0].UDP.FakeSeqLength)
}
if res.Sets[0].Fragmentation.Strategy != "oob" || res.Sets[0].Fragmentation.OOBPosition != 1 {
t.Fatalf("set 0: got %+v", res.Sets[0].Fragmentation)
}
if res.Sets[0].Escalate.To != res.Sets[1].Id ||
res.Sets[1].Escalate.To != res.Sets[2].Id ||
res.Sets[2].Escalate.To != res.Sets[3].Id {
t.Fatal("expected sets 0 -> 1 -> 2 -> 3 escalation chain")
}
if res.Sets[3].Faking.PayloadDomain != "www.gosuslugi.ru" || !res.Sets[3].Faking.MD5OnFake {
t.Fatalf("set 3 faking: got %+v", res.Sets[3].Faking)
}
if res.Sets[3].Fragmentation.Strategy != "combo" {
t.Fatalf("set 3 strategy: got %q", res.Sets[3].Fragmentation.Strategy)
}
if !res.Sets[3].Fragmentation.Combo.DecoyEnabled {
t.Fatal("a combo profile that also carries -f should enable the decoy")
}
n := noteFor(t, res, "-n https://www.gosuslugi.ru/")
if n.Status != StatusApproximated || n.Reason != "fakeSNINormalised" {
t.Fatalf("URL passed to --fake-sni should be flagged, got %+v", n)
}
if !hasField(n, "faking.payload_domain") {
t.Fatalf("note should name the field it set, got %+v", n)
}
var needsTargets bool
for _, w := range res.Warnings {
if w.Code == "needsTargets" {
needsTargets = true
}
}
if !needsTargets {
t.Fatal("a byedpi line with no host filter must warn that targets are required")
}
}

View file

@ -1,202 +0,0 @@
package convert
import (
"testing"
"github.com/daniellavrushin/b4/config"
)
var sharedConfigs = []struct {
name string
line string
}{
{
"gosuslugiEscalation",
"-Ku -a1 -An -o1 -At,r,s -f-1 -At,r,s -d1:11+sm -S -At,r,s " +
"-n https://www.gosuslugi.ru/ -Qr -f1 -d1:11+sm -s1:11+sm -S",
},
{
"vkLadderTwoProfiles",
"-Ku -a3 -An -Kt,h -n vk.com -d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s " +
"-d25+s -s30+s -d35+s -r1+s -S -Mh,d -As -Kt,h -n vk.com -d1 -d3+s -s6+s " +
"-d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -S -Mh,d",
},
{
"sevenProfileEscalation",
"-Ku -a1 -An -d1 -s0+s -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s " +
"-d35+s -At,r,s -s1 -q1 -At,r,s -s5 -o25000+s -At,r,s -o1 -d1 -r1+s -t10 " +
"-b1500 -s0+s -d3+s -At,r,s -f-1 -r1+s -At,r,s -s1 -o1+s -s-1",
},
{
"inlineFakePayloadUDP",
`-Ku -l':\x16\x03\x01\x02\x87\x01\x00\x02\x83\x03\x03\x5f\x15\x63\xcb\x06' ` +
`-a1 -An -s1 -q1 -Y -At -f-1 -r1+s -As`,
},
}
func TestCorpus_EveryRecognizedOptionIsReported(t *testing.T) {
for _, tc := range sharedConfigs {
t.Run(tc.name, func(t *testing.T) {
res := analyze(t, tc.line)
reported := map[string]bool{}
for _, n := range res.Notes {
reported[n.Token] = true
}
all, err := loadSpecs()
if err != nil {
t.Fatal(err)
}
table := all[res.Tool].tableFor(res.Version)
for _, tok := range getoptLong(res.Argv, table, false) {
if tok.Spec.Target == "_.ignore" {
continue
}
if !reported[tok.Raw] {
t.Fatalf("option %q produced no entry in the report", tok.Raw)
}
}
})
}
}
func TestCorpus_NoUnaccountedOptions(t *testing.T) {
for _, tc := range sharedConfigs {
t.Run(tc.name, func(t *testing.T) {
res := analyze(t, tc.line)
for _, n := range res.Notes {
if n.Reason == "unaccountedOption" {
t.Fatalf("%q fell through every emit rule", n.Token)
}
if n.Status == StatusUnknown || n.Status == StatusInvalid {
t.Fatalf("%q was not understood: %s/%s", n.Token, n.Status, n.Reason)
}
}
})
}
}
func TestCorpus_EscalationChainsAreAcyclic(t *testing.T) {
for _, tc := range sharedConfigs {
t.Run(tc.name, func(t *testing.T) {
res := analyze(t, tc.line)
byID := map[string]int{}
for i, s := range res.Sets {
byID[s.Id] = i
}
for i, s := range res.Sets {
if s.Escalate.To == "" {
continue
}
target, ok := byID[s.Escalate.To]
if !ok {
t.Fatalf("set %d escalates to an unknown id %q", i, s.Escalate.To)
}
if target <= i {
t.Fatalf("set %d escalates backwards to %d", i, target)
}
if !res.Sets[target].Enabled {
t.Fatalf("set %d escalates to a disabled set %d", i, target)
}
}
})
}
}
func TestAnalyze_UDPProfileStillReportsFakeOptions(t *testing.T) {
res := analyze(t, `-Ku -l':abc' -a1`)
n := noteFor(t, res, "-l:abc")
if n.Status != StatusDegenerate || n.Reason != "requiresFake" {
t.Fatalf("a fake payload in a UDP-only profile must be reported, got %+v", n)
}
}
func TestAnalyze_ComboHonoursFirstByteSplit(t *testing.T) {
res := analyze(t, "-d1 -s3+s")
set := res.Sets[0]
if set.Fragmentation.Strategy != "combo" {
t.Fatalf("strategy: got %q", set.Fragmentation.Strategy)
}
if !set.Fragmentation.Combo.FirstByteSplit {
t.Fatal("offset 1 should enable combo.first_byte_split")
}
n := noteFor(t, res, "-d1")
if n.Status != StatusMapped || n.Reason != "firstByteMapped" {
t.Fatalf("offset 1 is representable in combo, got %+v", n)
}
if !hasField(n, "fragmentation.combo.first_byte_split") {
t.Fatalf("note should name the field it set, got %+v", n)
}
}
func TestAnalyze_SplitLadderIsSummarised(t *testing.T) {
res := analyze(t, "-s1 -d3+s -s6+s -d9+s -s12+s -d15+s")
var found *Note
for i := range res.Notes {
if res.Notes[i].Reason == "splitPointsCollapsed" {
found = &res.Notes[i]
}
}
if found == nil {
t.Fatal("a ladder of split points should be summarised once for the profile")
}
if found.Params["count"] != 6 {
t.Fatalf("count: got %v, want 6", found.Params["count"])
}
}
func TestAnalyze_ProfileWithoutDesyncIsReported(t *testing.T) {
res := analyze(t, "-s1 -At -f-1 -As")
if len(res.Sets) != 3 {
t.Fatalf("expected 3 sets, got %d", len(res.Sets))
}
last := res.Sets[2]
if last.Fragmentation.Strategy != "none" || last.Faking.SNI {
t.Fatalf("a trailing -A with no options is a pass-through set, got %+v", last.Fragmentation)
}
var found bool
for _, n := range res.Notes {
if n.Reason == "profileWithoutDesync" && n.Profile == 2 {
found = true
}
}
if !found {
t.Fatal("an empty set must be explained rather than left as a mystery")
}
}
func TestAnalyze_CompetingFixedPositions(t *testing.T) {
res := analyze(t, "-s5 -s7")
if got := res.Sets[0].Fragmentation.SNIPosition; got != 5 {
t.Fatalf("sni_position: got %d, want 5", got)
}
if n := noteFor(t, res, "-s5"); n.Status != StatusMapped {
t.Fatalf("-s5: got %+v", n)
}
n := noteFor(t, res, "-s7")
if n.Status != StatusApproximated || n.Reason != "fixedPositionIgnored" {
t.Fatalf("a second fixed position cannot be kept, got %+v", n)
}
}
func TestAnalyze_PositionBeyondRangeIsClamped(t *testing.T) {
res := analyze(t, "-s25000")
if got := res.Sets[0].Fragmentation.SNIPosition; got != maxSNIPosition {
t.Fatalf("sni_position: got %d, want %d", got, maxSNIPosition)
}
n := noteFor(t, res, "-s25000")
if n.Status != StatusApproximated || n.Reason != "positionClamped" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_DroppedOOBDoesNotLeaveItsByteBehind(t *testing.T) {
res := analyze(t, "-s5 -o25000+s")
set := res.Sets[0]
if set.Fragmentation.Strategy == "oob" {
t.Fatal("a plain split should win over oob here")
}
if set.Fragmentation.OOBChar != config.DefaultSetConfig.Fragmentation.OOBChar {
t.Fatalf("oob_char should stay at the b4 default when oob was dropped, got %d",
set.Fragmentation.OOBChar)
}
}

View file

@ -5,8 +5,6 @@ import (
"strings"
)
const byedpiV013 = "0.13"
var posHintRe = regexp.MustCompile(`^-{1,2}[A-Za-z-]*=?[+-]?[0-9]+(:[0-9]+)+|\+[shn][emrs]`)
type usage struct {

View file

@ -10,11 +10,9 @@ import (
)
const (
byedpiDefaultFakeTTL = 8
byedpiDefaultOOBByte = 'a'
maxSNIPosition = 50
maxOOBPosition = 50
maxTLSRecPosition = 100
maxSNIPosition = 50
maxOOBPosition = 50
maxTLSRecPosition = 100
)
type emitOpts struct {
@ -23,6 +21,7 @@ type emitOpts struct {
ProfileDomains map[int][]string
ProfileModel string
BreakKeys []string
Defaults SpecDefaults
}
func noteBreakTokens(prof *Profile, ti tokenIndex, notes *noteSet, keys []string, model string) {
@ -95,8 +94,8 @@ func emit(prog *Program, tokens []Token, notes *noteSet, opts emitOpts) []config
emitFilters(&set, prof, ti, notes, udpOnly)
emitUDP(&set, prof, ti, notes, udpOnly)
if !udpOnly {
emitSplits(&set, prof, ti, notes)
emitFake(&set, prog, prof, ti, notes)
emitSplits(&set, prof, ti, notes, opts)
emitFake(&set, prog, prof, ti, notes, opts.Defaults)
} else {
set.Fragmentation.Strategy = config.ConfigNone
set.Faking.SNI = false
@ -111,8 +110,7 @@ func emit(prog *Program, tokens []Token, notes *noteSet, opts emitOpts) []config
})
}
emitMisc(&set, prog, prof, ti, notes)
emitZapretExtras(&set, prof, ti, notes)
noteDesyncModes(&set, prof, ti, notes)
runToolEmitter(prog.Tool, &set, prof, ti, notes)
noteBreakTokens(prof, ti, notes, opts.BreakKeys, opts.ProfileModel)
set.Targets.SNIDomains = append(set.Targets.SNIDomains, opts.domainsFor(prof)...)
@ -310,7 +308,7 @@ func emitUDPFake(prof *Profile, ti tokenIndex, notes *noteSet) {
}
}
func emitSplits(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet) {
func emitSplits(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet, opts emitOpts) {
var plain, disorder, oob, disoob, tlsrec, ipfrag []SplitOp
for _, s := range prof.Splits {
switch s.Kind {
@ -364,7 +362,9 @@ func emitSplits(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *note
set.Fragmentation.Strategy = "oob"
pos := clamp(absOffset(oob[0].Pos, 1), 1, maxOOBPosition)
set.Fragmentation.OOBPosition = pos
set.Fragmentation.OOBChar = byedpiDefaultOOBByte
if opts.Defaults.OOBByte > 0 {
set.Fragmentation.OOBChar = byte(opts.Defaults.OOBByte)
}
ti.each(prof.Index, "oob", func(t Token) {
notes.set(t, StatusMapped, "oobMapped",
"fragmentation.strategy=oob", "fragmentation.oob_position="+strconv.Itoa(pos))
@ -520,14 +520,14 @@ func describeSplitMapping(op SplitOp, strategy string, honoursFixed bool, set *c
return StatusMapped, "fixedPositionMapped", fields
}
func emitFake(set *config.SetConfig, prog *Program, prof *Profile, ti tokenIndex, notes *noteSet) {
func emitFake(set *config.SetConfig, prog *Program, prof *Profile, ti tokenIndex, notes *noteSet, defaults SpecDefaults) {
if !prof.Fake.Present {
set.Faking.SNI = false
noteFakeOptionsUnused(prof, ti, notes)
return
}
set.Faking.SNI = true
applyFooling(set, prof, ti, notes)
applyFooling(set, prof, ti, notes, defaults)
if prof.Fake.Repeats > 0 {
set.Faking.SNISeqLength = prof.Fake.Repeats
if tok, ok := ti.first(prof.Index, "repeats"); ok {
@ -649,124 +649,6 @@ func emitFake(set *config.SetConfig, prog *Program, prof *Profile, ti tokenIndex
}
}
var zapretDroppedModes = map[string]bool{
"udplen": true, "tamper": true, "hopbyhop": true, "destopt": true,
}
func noteDesyncModes(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet) {
tok, ok := ti.first(prof.Index, "desync")
if !ok || len(prof.DesyncModes) == 0 {
return
}
var fields, dropped []string
if set.Fragmentation.Strategy != config.ConfigNone {
fields = append(fields, "fragmentation.strategy="+set.Fragmentation.Strategy)
}
if set.Faking.SNI {
fields = append(fields, "faking.sni=true")
}
if set.TCP.Desync.Mode != config.ConfigOff {
fields = append(fields, "tcp.desync.mode="+set.TCP.Desync.Mode)
}
if set.TCP.SynFake {
fields = append(fields, "tcp.syn_fake=true")
}
if prof.UDP.Present {
fields = append(fields, "udp.mode="+set.UDP.Mode)
}
for _, m := range prof.DesyncModes {
if zapretDroppedModes[m] {
dropped = append(dropped, m)
}
}
if len(dropped) > 0 {
n := notes.set(tok, StatusUnsupported, "desyncModesDropped", fields...)
n.Params = map[string]any{"dropped": strings.Join(dropped, ", ")}
return
}
if len(fields) == 0 {
notes.set(tok, StatusDegenerate, "desyncModesEmpty")
return
}
notes.set(tok, StatusApproximated, "desyncModesMapped", fields...)
}
func emitZapretExtras(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet) {
if prof.Desync.Mode != "" {
set.TCP.Desync.Mode = prof.Desync.Mode
}
if prof.SynFake.Enabled {
set.TCP.SynFake = true
set.TCP.SynFakeLen = prof.SynFake.Len
}
if prof.Duplicate > 0 {
set.TCP.Duplicate.Enabled = true
set.TCP.Duplicate.Count = clamp(prof.Duplicate, 1, 10)
if tok, ok := ti.first(prof.Index, "dup"); ok {
notes.set(tok, StatusMapped, "duplicateMapped",
"tcp.duplicate.enabled=true", "tcp.duplicate.count="+strconv.Itoa(set.TCP.Duplicate.Count))
}
}
if prof.SeqOvl.Length > 0 {
set.Fragmentation.SeqOverlapLength = prof.SeqOvl.Length
set.Fragmentation.SeqOverlapPattern = seqOvlPattern(prof.SeqOvl.Pattern)
if tok, ok := ti.first(prof.Index, "seqovl"); ok {
notes.set(tok, StatusMapped, "seqOvlMapped",
"fragmentation.seq_overlap_length="+strconv.Itoa(prof.SeqOvl.Length))
}
if tok, ok := ti.first(prof.Index, "seqovl_pat"); ok {
notes.set(tok, StatusApproximated, "seqOvlPatternMapped", "fragmentation.seq_overlap_pattern")
}
}
if prof.WinSize > 0 {
set.TCP.Win.Mode = "zero"
if tok, ok := ti.first(prof.Index, "wssize"); ok {
notes.set(tok, StatusApproximated, "wsSizeApproximated", "tcp.win.mode=zero")
}
}
if len(prof.Filters.Excluded) > 0 {
if tok, ok := ti.first(prof.Index, "hostlist_excl_dom", "hostlist_exclude"); ok {
notes.set(tok, StatusUnsupported, "excludeListUnsupported")
}
}
if prof.Skip {
set.Enabled = false
if tok, ok := ti.first(prof.Index, "skip"); ok {
notes.set(tok, StatusMapped, "skipMapped", "enabled=false")
}
}
}
func onlyExtSplit(plain, disorder []SplitOp) bool {
ops := append(append([]SplitOp{}, plain...), disorder...)
if len(ops) != 1 {
return false
}
return ops[0].Pos.Anchor == AnchorSNIExt && ops[0].Pos.Offset == 0
}
func plainOrDisorder(plain, disorder []SplitOp) SplitOp {
if len(plain) > 0 {
return plain[0]
}
return disorder[0]
}
func seqOvlPattern(raw string) []string {
hex := strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X")
if hex == "" || len(hex)%2 != 0 {
return []string{"0x16", "0x03", "0x03", "0x00", "0x00"}
}
out := make([]string, 0, len(hex)/2)
for i := 0; i+1 < len(hex); i += 2 {
if !isHex(hex[i]) || !isHex(hex[i+1]) {
return []string{"0x16", "0x03", "0x03", "0x00", "0x00"}
}
out = append(out, "0x"+hex[i:i+2])
}
return out
}
func emitMisc(set *config.SetConfig, prog *Program, prof *Profile, ti tokenIndex, notes *noteSet) {
if prof.DropSACK {
set.TCP.DropSACK = true
@ -902,13 +784,15 @@ var foolingToStrategy = map[string]string{
"ts": "timestamp",
}
func applyFooling(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet) {
ttl := byedpiDefaultFakeTTL
if prof.Fake.TTLSet {
ttl = prof.Fake.TTL
func applyFooling(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet, defaults SpecDefaults) {
switch {
case prof.Fake.TTLSet:
set.Faking.TTL = uint8(clamp(prof.Fake.TTL, 1, 255))
set.Faking.ApplyTTL = true
case defaults.FakeTTL > 0:
set.Faking.TTL = uint8(clamp(defaults.FakeTTL, 1, 255))
set.Faking.ApplyTTL = defaults.FakeTTLForced
}
set.Faking.TTL = uint8(clamp(ttl, 1, 255))
set.Faking.ApplyTTL = true
set.Faking.Strategy = "ttl"
var chosen string

View file

@ -71,50 +71,3 @@ func TestGetoptLong_Errors(t *testing.T) {
})
}
}
func TestGetoptLong_VersionScopedOptions(t *testing.T) {
v13 := getoptLong([]string{"-Qr"}, testTable(t, "0.13"), false)
if v13[0].Err != "unknown" {
t.Fatalf("expected -Q to be unknown in 0.13, got %+v", v13[0])
}
v17 := getoptLong([]string{"-Qr"}, testTable(t, "0.17"), false)
if v17[0].Key != "fake_tls_mod" {
t.Fatalf("expected -Q to resolve in 0.17, got %+v", v17[0])
}
n13 := getoptLong([]string{"-n", "example.com"}, testTable(t, "0.13"), false)
if n13[0].Key != "tls_sni" {
t.Fatalf("expected -n to be tls_sni in 0.13, got %q", n13[0].Key)
}
n17 := getoptLong([]string{"-n", "example.com"}, testTable(t, "0.17"), false)
if n17[0].Key != "fake_sni" {
t.Fatalf("expected -n to be fake_sni in 0.17, got %q", n17[0].Key)
}
}
func TestDetectVersion_Markers(t *testing.T) {
all, err := loadSpecs()
if err != nil {
t.Fatal(err)
}
spec := all["byedpi"]
tests := []struct {
name string
argv []string
want string
detected bool
}{
{"fakeTLSMod", []string{"-Qr"}, "0.17", true},
{"ipOpt", []string{"-k"}, "0.13", true},
{"posRepeats", []string{"-d1:11+sm"}, "0.17", true},
{"ambiguousFallsBackToDefault", []string{"-s1", "-f-1"}, "0.17", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, detected := detectVersion(spec, tt.argv)
if got != tt.want || detected != tt.detected {
t.Fatalf("got (%s, %v), want (%s, %v)", got, detected, tt.want, tt.detected)
}
})
}
}

View file

@ -30,15 +30,10 @@ var grammars = map[string]grammarFn{
"int": gInt,
"str": gStr,
"float_sec": gFloatSec,
"cchar": gCChar,
"cdata": gCData,
"hostlist": gHostList,
"iplist": gHostList,
"portrange": gPortRange,
"range": gRange,
"csvfirstchar": gCSVFirstChar,
"csvkv": gCSVKeyValue,
"byedpi.pos": gByedpiPos,
}
func runGrammar(name, raw string, ctx grammarCtx) (Value, error) {
@ -141,47 +136,6 @@ func gCSVKeyValue(raw string, _ grammarCtx) (Value, error) {
return Value{List: out, Str: raw}, nil
}
func gCChar(raw string, _ grammarCtx) (Value, error) {
dec, err := parseCForm(raw)
if err != nil {
return Value{}, err
}
if len(dec) != 1 {
return Value{}, errors.New("expected exactly one byte")
}
return Value{Byte: dec[0], Str: raw}, nil
}
func gCData(raw string, _ grammarCtx) (Value, error) {
if strings.HasPrefix(raw, ":") {
dec, err := parseCForm(raw[1:])
if err != nil {
return Value{}, err
}
return Value{Str: string(dec)}, nil
}
return Value{Ref: raw}, nil
}
func gHostList(raw string, _ grammarCtx) (Value, error) {
if !strings.HasPrefix(raw, ":") {
return Value{Ref: raw}, nil
}
fields := strings.FieldsFunc(raw[1:], func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ',' || r == ';'
})
out := make([]string, 0, len(fields))
for _, f := range fields {
if f != "" {
out = append(out, f)
}
}
if len(out) == 0 {
return Value{}, errors.New("expected at least one entry")
}
return Value{List: out, Str: raw}, nil
}
func parseCForm(s string) ([]byte, error) {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
@ -247,15 +201,6 @@ func isHex(c byte) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func gByedpiPos(raw string, ctx grammarCtx) (Value, error) {
if ctx.Version == byedpiV013 {
p, err := parsePosV013(raw)
return Value{Pos: p, Str: raw}, err
}
p, err := parsePosV017(raw)
return Value{Pos: p, Str: raw}, err
}
func splitLeadingInt(s string) (int, string, error) {
i := 0
if i < len(s) && (s[i] == '+' || s[i] == '-') {
@ -276,86 +221,3 @@ func splitLeadingInt(s string) (int, string, error) {
}
return int(n), s[i:], nil
}
func parsePosV013(raw string) (Pos, error) {
p := Pos{Raw: raw, Anchor: AnchorAbs, Rel: RelStart}
n, rest, err := splitLeadingInt(raw)
if err != nil {
return p, err
}
p.Offset = n
if rest == "" {
return p, nil
}
if rest[0] != '+' || len(rest) != 2 {
return p, errors.New("expected <n>, <n>+s, <n>+h or <n>+e")
}
switch rest[1] {
case 's':
p.Anchor, p.Rel = AnchorSNI, RelStart
case 'h':
p.Anchor, p.Rel = AnchorHost, RelStart
case 'e':
p.Anchor, p.Rel = AnchorPacket, RelEnd
default:
return p, errors.New("expected +s, +h or +e")
}
return p, nil
}
func parsePosV017(raw string) (Pos, error) {
p := Pos{Raw: raw, Anchor: AnchorAbs, Rel: RelStart}
n, rest, err := splitLeadingInt(raw)
if err != nil {
return p, err
}
p.Offset = n
for len(rest) > 0 && rest[0] == ':' {
var v int
v, rest, err = splitLeadingInt(rest[1:])
if err != nil || v < 0 {
return p, errors.New("expected <offset>[:repeats[:skip]]")
}
if p.Repeats == 0 {
if v == 0 {
return p, errors.New("repeats must be greater than zero")
}
p.Repeats = v
} else {
p.Skip = v
break
}
}
if rest == "" {
return p, nil
}
if rest[0] != '+' || len(rest) < 2 {
return p, errors.New("expected +s, +h or +n after the offset")
}
switch rest[1] {
case 's':
p.Anchor = AnchorSNI
case 'h':
p.Anchor = AnchorHost
case 'n':
p.Anchor = AnchorPacket
default:
return p, errors.New("expected +s, +h or +n after the offset")
}
if len(rest) > 2 {
switch rest[2] {
case 'e':
p.Rel = RelEnd
case 'm':
p.Rel = RelMid
case 'r':
p.Rel = RelRand
case 's':
p.Rel = RelStart
}
}
if p.Anchor == AnchorPacket && p.Rel == RelStart {
p.Anchor = AnchorAbs
}
return p, nil
}

View file

@ -2,90 +2,6 @@ package convert
import "testing"
func TestParsePosV013_Valid(t *testing.T) {
tests := []struct {
name string
in string
offset int
anchor Anchor
rel Rel
}{
{"plain", "1", 1, AnchorAbs, RelStart},
{"negative", "-1", -1, AnchorAbs, RelStart},
{"hex", "0x10", 16, AnchorAbs, RelStart},
{"sni", "2+s", 2, AnchorSNI, RelStart},
{"host", "3+h", 3, AnchorHost, RelStart},
{"end", "4+e", 4, AnchorPacket, RelEnd},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, err := parsePosV013(tt.in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Offset != tt.offset || p.Anchor != tt.anchor || p.Rel != tt.rel {
t.Fatalf("got offset=%d anchor=%s rel=%s, want %d/%s/%s", p.Offset, p.Anchor, p.Rel, tt.offset, tt.anchor, tt.rel)
}
})
}
}
func TestParsePosV013_Rejects(t *testing.T) {
for _, in := range []string{"1:11+sm", "1+sm", "1+x", "abc", "1+", "1junk"} {
t.Run(in, func(t *testing.T) {
if _, err := parsePosV013(in); err == nil {
t.Fatalf("expected %q to be rejected", in)
}
})
}
}
func TestParsePosV017_Valid(t *testing.T) {
tests := []struct {
name string
in string
offset int
repeats int
skip int
anchor Anchor
rel Rel
}{
{"plain", "1", 1, 0, 0, AnchorAbs, RelStart},
{"negative", "-1", -1, 0, 0, AnchorAbs, RelStart},
{"sniMid", "1:11+sm", 1, 11, 0, AnchorSNI, RelMid},
{"repeatsSkip", "1:3:5", 1, 3, 5, AnchorAbs, RelStart},
{"sniStart", "0+s", 0, 0, 0, AnchorSNI, RelStart},
{"sniEnd", "0+se", 0, 0, 0, AnchorSNI, RelEnd},
{"sniRand", "0+sr", 0, 0, 0, AnchorSNI, RelRand},
{"hostMid", "2+hm", 2, 0, 0, AnchorHost, RelMid},
{"packetMid", "0+nm", 0, 0, 0, AnchorPacket, RelMid},
{"nullBase", "5+n", 5, 0, 0, AnchorAbs, RelStart},
{"unknownSecondCharIgnored", "5+sX", 5, 0, 0, AnchorSNI, RelStart},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, err := parsePosV017(tt.in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Offset != tt.offset || p.Repeats != tt.repeats || p.Skip != tt.skip || p.Anchor != tt.anchor || p.Rel != tt.rel {
t.Fatalf("got %+v, want offset=%d repeats=%d skip=%d anchor=%s rel=%s",
p, tt.offset, tt.repeats, tt.skip, tt.anchor, tt.rel)
}
})
}
}
func TestParsePosV017_Rejects(t *testing.T) {
for _, in := range []string{"1:0", "abc", "1+x", "1+"} {
t.Run(in, func(t *testing.T) {
if _, err := parsePosV017(in); err == nil {
t.Fatalf("expected %q to be rejected", in)
}
})
}
}
func TestParseCForm_Escapes(t *testing.T) {
tests := []struct {
name string
@ -138,23 +54,6 @@ func TestGCSVKeyValue_CapturesMSize(t *testing.T) {
}
}
func TestGHostList_InlineVsFile(t *testing.T) {
inline, err := gHostList(":a.com b.com,c.com", grammarCtx{})
if err != nil {
t.Fatal(err)
}
if len(inline.List) != 3 {
t.Fatalf("got %v", inline.List)
}
file, err := gHostList("/etc/byedpi/hosts.txt", grammarCtx{})
if err != nil {
t.Fatal(err)
}
if file.Ref != "/etc/byedpi/hosts.txt" {
t.Fatalf("got ref %q", file.Ref)
}
}
func TestSanitizeHost(t *testing.T) {
tests := []struct{ in, want string }{
{"https://www.gosuslugi.ru/", "www.gosuslugi.ru"},

View file

@ -1,74 +0,0 @@
package convert
var normalizers = map[string]func(*Program, []Token, *noteSet){
"zapret": normalizeZapret,
}
func runNormalizer(name string, prog *Program, tokens []Token, notes *noteSet) {
if fn, ok := normalizers[name]; ok {
fn(prog, tokens, notes)
}
}
func normalizeZapret(prog *Program, _ []Token, notes *noteSet) {
for _, prof := range prog.Profiles {
normalizeZapretProfile(prof, notes)
promoteUDPFake(prof)
}
}
func promoteUDPFake(prof *Profile) {
if !prof.UDPOnly() {
return
}
prof.UDP.Present = prof.Fake.Present
prof.UDP.Repeats = prof.Fake.Repeats
prof.UDP.QUICRef = prof.Fake.QUICRef
prof.UDP.TTL = prof.Fake.TTL
prof.UDP.TTLSet = prof.Fake.TTLSet
prof.UDP.Ports = append(prof.UDP.Ports, prof.Filters.UDPPorts...)
}
func normalizeZapretProfile(prof *Profile, notes *noteSet) {
positions := prof.SplitPositions
token := prof.SplitPosToken
if len(positions) == 0 {
positions = []Pos{{Raw: "1", Offset: 1, Anchor: AnchorAbs, Rel: RelStart}}
token = prof.DesyncToken
}
for _, mode := range prof.DesyncModes {
switch mode {
case "fake", "fakeknown":
prof.Fake.Present = true
case "rst", "rstack":
prof.Desync.Mode = "rst"
case "synack":
prof.SynFake.Enabled = true
case "syndata":
prof.SynFake.Enabled = true
prof.SynFake.Len = 1
case "multisplit":
appendSplits(prof, SplitPlain, positions, token)
case "multidisorder":
appendSplits(prof, SplitDisorder, positions, token)
case "fakedsplit":
prof.Fake.Present = true
appendSplits(prof, SplitPlain, positions[:1], token)
case "fakeddisorder":
prof.Fake.Present = true
appendSplits(prof, SplitDisorder, positions[:1], token)
case "hostfakesplit":
prof.Fake.Present = true
appendSplits(prof, SplitPlain, positions[:1], token)
case "ipfrag1", "ipfrag2":
appendSplits(prof, SplitIPFrag, positions[:1], token)
}
}
}
func appendSplits(prof *Profile, kind SplitKind, positions []Pos, token int) {
for _, p := range positions {
prof.Splits = append(prof.Splits, SplitOp{Kind: kind, Pos: p, Token: token})
}
}

View file

@ -3,101 +3,661 @@
"label": "byedpi",
"style": "getopt_long",
"homepage": "https://github.com/hufrea/byedpi",
"defaults": {
"fake_ttl": 8,
"fake_ttl_forced": true,
"oob_byte": 97
},
"detect": {
"markers": ["ciadpi", "byedpi"],
"signature": ["-K", "-A", "--proto", "--auto", "--disoob", "--tlsrec", "--oob-data"],
"reject": ["--dpi-desync", "--new", "--qnum", "--hostlist", "--filter-tcp"]
"markers": [
"ciadpi",
"byedpi"
],
"signature": [
"-K",
"-A",
"--proto",
"--auto",
"--disoob",
"--tlsrec",
"--oob-data"
],
"reject": [
"--dpi-desync",
"--new",
"--qnum",
"--hostlist",
"--filter-tcp"
]
},
"versions": [
{
"id": "0.13",
"label": "0.13",
"markers": ["-k", "--ip-opt"]
"markers": [
"-k",
"--ip-opt"
]
},
{
"id": "0.17",
"label": "0.15 - 0.17",
"markers": [
"-Q", "--fake-tls-mod",
"-L", "--auto-mode",
"-R", "--round",
"-j", "--ipset",
"-m", "--tlsminor",
"-B", "--copy",
"-C", "--connect-to",
"-y", "--cache-dump",
"-D", "--daemon",
"-E", "--transparent",
"-G", "--http-connect",
"-Z", "--wait-send"
"-Q",
"--fake-tls-mod",
"-L",
"--auto-mode",
"-R",
"--round",
"-j",
"--ipset",
"-m",
"--tlsminor",
"-B",
"--copy",
"-C",
"--connect-to",
"-y",
"--cache-dump",
"-D",
"--daemon",
"-E",
"--transparent",
"-G",
"--http-connect",
"-Z",
"--wait-send"
],
"pos_hint": true,
"default": true
}
],
"ambiguous": ["-w", "-W", "-n", "-O"],
"profile_break": ["auto"],
"ambiguous": [
"-w",
"-W",
"-n",
"-O"
],
"profile_break": [
"auto"
],
"options": [
{ "key": "no_domain", "short": "N", "long": "no-domain", "arg": "none", "scope": "global", "target": "_.na", "note": "resolverBehaviour" },
{ "key": "no_ipv6", "short": "X", "long": "no-ipv6", "arg": "none", "scope": "global", "target": "global.no_ipv6" },
{ "key": "no_udp", "short": "U", "long": "no-udp", "arg": "none", "scope": "global", "target": "global.no_udp" },
{ "key": "help", "short": "h", "long": "help", "arg": "none", "scope": "global", "target": "_.ignore" },
{ "key": "show_version", "short": "v", "long": "version", "arg": "none", "scope": "global", "target": "_.ignore" },
{ "key": "listen_ip", "short": "i", "long": "ip", "arg": "required", "scope": "global", "grammar": "str", "target": "_.na", "note": "proxyRuntime" },
{ "key": "listen_port", "short": "p", "long": "port", "arg": "required", "scope": "global", "grammar": "int", "target": "_.na", "note": "proxyRuntime" },
{ "key": "conn_ip", "short": "I", "long": "conn-ip", "arg": "required", "scope": "global", "grammar": "str", "target": "_.na", "note": "proxyRuntime" },
{ "key": "buf_size", "short": "b", "long": "buf-size", "arg": "required", "scope": "global", "grammar": "int", "target": "_.na", "note": "proxyRuntime" },
{ "key": "max_conn", "short": "c", "long": "max-conn", "arg": "required", "scope": "global", "grammar": "int", "target": "_.na", "note": "proxyRuntime" },
{ "key": "debug", "short": "x", "long": "debug", "arg": "required", "scope": "global", "grammar": "int", "target": "_.na", "note": "proxyRuntime" },
{ "key": "tfo", "short": "F", "long": "tfo", "arg": "none", "scope": "global", "target": "_.na", "note": "proxyRuntime" },
{ "key": "protect_path", "short": "P", "long": "protect-path", "arg": "required", "scope": "global", "grammar": "str", "target": "_.na", "note": "proxyRuntime" },
{ "key": "auto", "short": "A", "long": "auto", "arg": "required", "scope": "break", "grammar": "csvfirstchar", "target": "trigger" },
{ "key": "cache_ttl", "short": "u", "long": "cache-ttl", "arg": "required", "scope": "global", "grammar": "int", "target": "_.na", "note": "autoRetryTiming" },
{ "key": "timeout", "short": "T", "long": "timeout", "arg": "required", "scope": "global", "grammar": "float_sec", "target": "_.na", "note": "autoRetryTiming" },
{ "key": "def_ttl", "short": "g", "long": "def-ttl", "arg": "required", "scope": "global", "grammar": "int", "target": "_.na", "note": "globalTTL" },
{ "key": "proto", "short": "K", "long": "proto", "arg": "required", "scope": "profile", "grammar": "csvfirstchar", "target": "filters.proto" },
{ "key": "hosts", "short": "H", "long": "hosts", "arg": "required", "scope": "profile", "grammar": "hostlist", "target": "filters.hosts" },
{ "key": "pf", "short": "V", "long": "pf", "arg": "required", "scope": "profile", "grammar": "portrange", "target": "filters.ports" },
{ "key": "split", "short": "s", "long": "split", "arg": "required", "scope": "profile", "grammar": "byedpi.pos", "target": "splits[]", "const": { "kind": "split" } },
{ "key": "disorder", "short": "d", "long": "disorder", "arg": "required", "scope": "profile", "grammar": "byedpi.pos", "target": "splits[]", "const": { "kind": "disorder" } },
{ "key": "oob", "short": "o", "long": "oob", "arg": "required", "scope": "profile", "grammar": "byedpi.pos", "target": "splits[]", "const": { "kind": "oob" } },
{ "key": "disoob", "short": "q", "long": "disoob", "arg": "required", "scope": "profile", "grammar": "byedpi.pos", "target": "splits[]", "const": { "kind": "disoob" } },
{ "key": "fake", "short": "f", "long": "fake", "arg": "required", "scope": "profile", "grammar": "byedpi.pos", "target": "splits[]", "const": { "kind": "fake" } },
{ "key": "tlsrec", "short": "r", "long": "tlsrec", "arg": "required", "scope": "profile", "grammar": "byedpi.pos", "target": "splits[]", "const": { "kind": "tlsrec" } },
{ "key": "ttl", "short": "t", "long": "ttl", "arg": "required", "scope": "profile", "grammar": "int", "target": "fake.ttl" },
{ "key": "md5sig", "short": "S", "long": "md5sig", "arg": "none", "scope": "profile", "target": "fake.md5sig" },
{ "key": "fake_data", "short": "l", "long": "fake-data", "arg": "required", "scope": "profile", "grammar": "cdata", "target": "fake.data" },
{ "key": "oob_data", "short": "e", "long": "oob-data", "arg": "required", "scope": "profile", "grammar": "cchar", "target": "profile.oob_byte" },
{ "key": "mod_http", "short": "M", "long": "mod-http", "arg": "required", "scope": "profile", "grammar": "csvfirstchar", "target": "profile.http_mod" },
{ "key": "udp_fake", "short": "a", "long": "udp-fake", "arg": "required", "scope": "profile", "grammar": "int", "target": "profile.udp_fake_count" },
{ "key": "drop_sack", "short": "Y", "long": "drop-sack", "arg": "none", "scope": "profile", "target": "profile.drop_sack" },
{ "key": "ip_opt", "short": "k", "long": "ip-opt", "arg": "optional", "scope": "profile", "grammar": "cdata", "target": "fake.ip_opt", "versions": ["0.13"] },
{ "key": "tls_sni", "short": "n", "long": "tls-sni", "arg": "required", "scope": "global", "grammar": "str", "target": "global.fake_sni", "versions": ["0.13"] },
{ "key": "fake_offset", "short": "O", "long": "fake-offset", "arg": "required", "scope": "profile", "grammar": "int", "target": "fake.offset", "versions": ["0.13"] },
{ "key": "delay", "short": "w", "long": "delay", "arg": "required", "scope": "global", "grammar": "int", "target": "global.delay", "versions": ["0.13"] },
{ "key": "not_wait_send", "short": "W", "long": "not-wait-send", "arg": "none", "scope": "global", "target": "_.na", "note": "proxyRuntime", "versions": ["0.13"] },
{ "key": "fake_sni", "short": "n", "long": "fake-sni", "arg": "required", "scope": "profile", "grammar": "str", "target": "fake.sni[]", "versions": ["0.17"] },
{ "key": "fake_offset_pos", "short": "O", "long": "fake-offset", "arg": "required", "scope": "profile", "grammar": "byedpi.pos", "target": "fake.offset_pos", "versions": ["0.17"] },
{ "key": "fake_tls_mod", "short": "Q", "long": "fake-tls-mod", "arg": "required", "scope": "profile", "grammar": "csvkv", "target": "fake.tls_mod", "versions": ["0.17"] },
{ "key": "tls_minor", "short": "m", "long": "tlsminor", "arg": "required", "scope": "profile", "grammar": "int", "target": "profile.tls_minor","versions": ["0.17"] },
{ "key": "round", "short": "R", "long": "round", "arg": "required", "scope": "profile", "grammar": "range", "target": "profile.round", "versions": ["0.17"] },
{ "key": "ipset", "short": "j", "long": "ipset", "arg": "required", "scope": "profile", "grammar": "iplist", "target": "filters.ips", "versions": ["0.17"] },
{ "key": "connect_to", "short": "C", "long": "connect-to", "arg": "required", "scope": "profile", "grammar": "str", "target": "profile.unsupported", "note": "noEquivalent", "versions": ["0.17"] },
{ "key": "copy", "short": "B", "long": "copy", "arg": "required", "scope": "profile", "grammar": "str", "target": "profile.unsupported", "note": "profileCopy", "versions": ["0.17"] },
{ "key": "auto_mode", "short": "L", "long": "auto-mode", "arg": "required", "scope": "global", "grammar": "csvfirstchar", "target": "_.na", "note": "autoRetryTiming", "versions": ["0.17"] },
{ "key": "pidfile", "short": "w", "long": "pidfile", "arg": "required", "scope": "global", "grammar": "str", "target": "_.na", "note": "processControl", "versions": ["0.17"] },
{ "key": "await_int", "short": "W", "long": "await-int", "arg": "required", "scope": "global", "grammar": "int", "target": "_.na", "note": "proxyRuntime", "versions": ["0.17"] },
{ "key": "cache_dump", "short": "y", "long": "cache-dump", "arg": "required", "scope": "global", "grammar": "str", "target": "_.na", "note": "processControl", "versions": ["0.17"] },
{ "key": "daemon", "short": "D", "long": "daemon", "arg": "none", "scope": "global", "target": "_.na", "note": "processControl", "versions": ["0.17"] },
{ "key": "transparent", "short": "E", "long": "transparent", "arg": "none", "scope": "global", "target": "_.na", "note": "proxyRuntime", "versions": ["0.17"] },
{ "key": "http_connect", "short": "G", "long": "http-connect", "arg": "none", "scope": "global", "target": "_.na", "note": "proxyRuntime", "versions": ["0.17"] },
{ "key": "wait_send", "short": "Z", "long": "wait-send", "arg": "none", "scope": "global", "target": "_.na", "note": "proxyRuntime", "versions": ["0.17"] }
{
"key": "no_domain",
"short": "N",
"long": "no-domain",
"arg": "none",
"scope": "global",
"target": "_.na",
"note": "resolverBehaviour"
},
{
"key": "no_ipv6",
"short": "X",
"long": "no-ipv6",
"arg": "none",
"scope": "global",
"target": "global.no_ipv6"
},
{
"key": "no_udp",
"short": "U",
"long": "no-udp",
"arg": "none",
"scope": "global",
"target": "global.no_udp"
},
{
"key": "help",
"short": "h",
"long": "help",
"arg": "none",
"scope": "global",
"target": "_.ignore"
},
{
"key": "show_version",
"short": "v",
"long": "version",
"arg": "none",
"scope": "global",
"target": "_.ignore"
},
{
"key": "listen_ip",
"short": "i",
"long": "ip",
"arg": "required",
"scope": "global",
"grammar": "str",
"target": "_.na",
"note": "proxyRuntime"
},
{
"key": "listen_port",
"short": "p",
"long": "port",
"arg": "required",
"scope": "global",
"grammar": "int",
"target": "_.na",
"note": "proxyRuntime"
},
{
"key": "conn_ip",
"short": "I",
"long": "conn-ip",
"arg": "required",
"scope": "global",
"grammar": "str",
"target": "_.na",
"note": "proxyRuntime"
},
{
"key": "buf_size",
"short": "b",
"long": "buf-size",
"arg": "required",
"scope": "global",
"grammar": "int",
"target": "_.na",
"note": "proxyRuntime"
},
{
"key": "max_conn",
"short": "c",
"long": "max-conn",
"arg": "required",
"scope": "global",
"grammar": "int",
"target": "_.na",
"note": "proxyRuntime"
},
{
"key": "debug",
"short": "x",
"long": "debug",
"arg": "required",
"scope": "global",
"grammar": "int",
"target": "_.na",
"note": "proxyRuntime"
},
{
"key": "tfo",
"short": "F",
"long": "tfo",
"arg": "none",
"scope": "global",
"target": "_.na",
"note": "proxyRuntime"
},
{
"key": "protect_path",
"short": "P",
"long": "protect-path",
"arg": "required",
"scope": "global",
"grammar": "str",
"target": "_.na",
"note": "proxyRuntime"
},
{
"key": "auto",
"short": "A",
"long": "auto",
"arg": "required",
"scope": "break",
"grammar": "csvfirstchar",
"target": "trigger"
},
{
"key": "cache_ttl",
"short": "u",
"long": "cache-ttl",
"arg": "required",
"scope": "global",
"grammar": "int",
"target": "_.na",
"note": "autoRetryTiming"
},
{
"key": "timeout",
"short": "T",
"long": "timeout",
"arg": "required",
"scope": "global",
"grammar": "float_sec",
"target": "_.na",
"note": "autoRetryTiming"
},
{
"key": "def_ttl",
"short": "g",
"long": "def-ttl",
"arg": "required",
"scope": "global",
"grammar": "int",
"target": "_.na",
"note": "globalTTL"
},
{
"key": "proto",
"short": "K",
"long": "proto",
"arg": "required",
"scope": "profile",
"grammar": "csvfirstchar",
"target": "filters.proto"
},
{
"key": "hosts",
"short": "H",
"long": "hosts",
"arg": "required",
"scope": "profile",
"grammar": "hostlist",
"target": "filters.hosts"
},
{
"key": "pf",
"short": "V",
"long": "pf",
"arg": "required",
"scope": "profile",
"grammar": "portrange",
"target": "filters.ports"
},
{
"key": "split",
"short": "s",
"long": "split",
"arg": "required",
"scope": "profile",
"grammar": "byedpi.pos",
"target": "splits[]",
"const": {
"kind": "split"
}
},
{
"key": "disorder",
"short": "d",
"long": "disorder",
"arg": "required",
"scope": "profile",
"grammar": "byedpi.pos",
"target": "splits[]",
"const": {
"kind": "disorder"
}
},
{
"key": "oob",
"short": "o",
"long": "oob",
"arg": "required",
"scope": "profile",
"grammar": "byedpi.pos",
"target": "splits[]",
"const": {
"kind": "oob"
}
},
{
"key": "disoob",
"short": "q",
"long": "disoob",
"arg": "required",
"scope": "profile",
"grammar": "byedpi.pos",
"target": "splits[]",
"const": {
"kind": "disoob"
}
},
{
"key": "fake",
"short": "f",
"long": "fake",
"arg": "required",
"scope": "profile",
"grammar": "byedpi.pos",
"target": "splits[]",
"const": {
"kind": "fake"
}
},
{
"key": "tlsrec",
"short": "r",
"long": "tlsrec",
"arg": "required",
"scope": "profile",
"grammar": "byedpi.pos",
"target": "splits[]",
"const": {
"kind": "tlsrec"
}
},
{
"key": "ttl",
"short": "t",
"long": "ttl",
"arg": "required",
"scope": "profile",
"grammar": "int",
"target": "fake.ttl"
},
{
"key": "md5sig",
"short": "S",
"long": "md5sig",
"arg": "none",
"scope": "profile",
"target": "fake.md5sig"
},
{
"key": "fake_data",
"short": "l",
"long": "fake-data",
"arg": "required",
"scope": "profile",
"grammar": "cdata",
"target": "fake.data"
},
{
"key": "oob_data",
"short": "e",
"long": "oob-data",
"arg": "required",
"scope": "profile",
"grammar": "cchar",
"target": "profile.oob_byte"
},
{
"key": "mod_http",
"short": "M",
"long": "mod-http",
"arg": "required",
"scope": "profile",
"grammar": "csvfirstchar",
"target": "profile.http_mod"
},
{
"key": "udp_fake",
"short": "a",
"long": "udp-fake",
"arg": "required",
"scope": "profile",
"grammar": "int",
"target": "profile.udp_fake_count"
},
{
"key": "drop_sack",
"short": "Y",
"long": "drop-sack",
"arg": "none",
"scope": "profile",
"target": "profile.drop_sack"
},
{
"key": "ip_opt",
"short": "k",
"long": "ip-opt",
"arg": "optional",
"scope": "profile",
"grammar": "cdata",
"target": "fake.ip_opt",
"versions": [
"0.13"
]
},
{
"key": "tls_sni",
"short": "n",
"long": "tls-sni",
"arg": "required",
"scope": "global",
"grammar": "str",
"target": "global.fake_sni",
"versions": [
"0.13"
]
},
{
"key": "fake_offset",
"short": "O",
"long": "fake-offset",
"arg": "required",
"scope": "profile",
"grammar": "int",
"target": "fake.offset",
"versions": [
"0.13"
]
},
{
"key": "delay",
"short": "w",
"long": "delay",
"arg": "required",
"scope": "global",
"grammar": "int",
"target": "global.delay",
"versions": [
"0.13"
]
},
{
"key": "not_wait_send",
"short": "W",
"long": "not-wait-send",
"arg": "none",
"scope": "global",
"target": "_.na",
"note": "proxyRuntime",
"versions": [
"0.13"
]
},
{
"key": "fake_sni",
"short": "n",
"long": "fake-sni",
"arg": "required",
"scope": "profile",
"grammar": "str",
"target": "fake.sni[]",
"versions": [
"0.17"
]
},
{
"key": "fake_offset_pos",
"short": "O",
"long": "fake-offset",
"arg": "required",
"scope": "profile",
"grammar": "byedpi.pos",
"target": "fake.offset_pos",
"versions": [
"0.17"
]
},
{
"key": "fake_tls_mod",
"short": "Q",
"long": "fake-tls-mod",
"arg": "required",
"scope": "profile",
"grammar": "csvkv",
"target": "fake.tls_mod",
"versions": [
"0.17"
]
},
{
"key": "tls_minor",
"short": "m",
"long": "tlsminor",
"arg": "required",
"scope": "profile",
"grammar": "int",
"target": "profile.tls_minor",
"versions": [
"0.17"
]
},
{
"key": "round",
"short": "R",
"long": "round",
"arg": "required",
"scope": "profile",
"grammar": "range",
"target": "profile.round",
"versions": [
"0.17"
]
},
{
"key": "ipset",
"short": "j",
"long": "ipset",
"arg": "required",
"scope": "profile",
"grammar": "iplist",
"target": "filters.ips",
"versions": [
"0.17"
]
},
{
"key": "connect_to",
"short": "C",
"long": "connect-to",
"arg": "required",
"scope": "profile",
"grammar": "str",
"target": "profile.unsupported",
"note": "noEquivalent",
"versions": [
"0.17"
]
},
{
"key": "copy",
"short": "B",
"long": "copy",
"arg": "required",
"scope": "profile",
"grammar": "str",
"target": "profile.unsupported",
"note": "profileCopy",
"versions": [
"0.17"
]
},
{
"key": "auto_mode",
"short": "L",
"long": "auto-mode",
"arg": "required",
"scope": "global",
"grammar": "csvfirstchar",
"target": "_.na",
"note": "autoRetryTiming",
"versions": [
"0.17"
]
},
{
"key": "pidfile",
"short": "w",
"long": "pidfile",
"arg": "required",
"scope": "global",
"grammar": "str",
"target": "_.na",
"note": "processControl",
"versions": [
"0.17"
]
},
{
"key": "await_int",
"short": "W",
"long": "await-int",
"arg": "required",
"scope": "global",
"grammar": "int",
"target": "_.na",
"note": "proxyRuntime",
"versions": [
"0.17"
]
},
{
"key": "cache_dump",
"short": "y",
"long": "cache-dump",
"arg": "required",
"scope": "global",
"grammar": "str",
"target": "_.na",
"note": "processControl",
"versions": [
"0.17"
]
},
{
"key": "daemon",
"short": "D",
"long": "daemon",
"arg": "none",
"scope": "global",
"target": "_.na",
"note": "processControl",
"versions": [
"0.17"
]
},
{
"key": "transparent",
"short": "E",
"long": "transparent",
"arg": "none",
"scope": "global",
"target": "_.na",
"note": "proxyRuntime",
"versions": [
"0.17"
]
},
{
"key": "http_connect",
"short": "G",
"long": "http-connect",
"arg": "none",
"scope": "global",
"target": "_.na",
"note": "proxyRuntime",
"versions": [
"0.17"
]
},
{
"key": "wait_send",
"short": "Z",
"long": "wait-send",
"arg": "none",
"scope": "global",
"target": "_.na",
"note": "proxyRuntime",
"versions": [
"0.17"
]
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -71,11 +71,18 @@ type DetectSpec struct {
EnvVars []string `json:"env_vars"`
}
type SpecDefaults struct {
FakeTTL int `json:"fake_ttl"`
FakeTTLForced bool `json:"fake_ttl_forced"`
OOBByte int `json:"oob_byte"`
}
type Spec struct {
Tool string `json:"tool"`
Label string `json:"label"`
Style string `json:"style"`
Homepage string `json:"homepage"`
Defaults SpecDefaults `json:"defaults"`
Detect DetectSpec `json:"detect"`
Versions []VersionSpec `json:"versions"`
Ambiguous []string `json:"ambiguous"`

28
src/convert/tool.go Normal file
View file

@ -0,0 +1,28 @@
package convert
import "github.com/daniellavrushin/b4/config"
type normalizer func(*Program, []Token, *noteSet)
type toolEmitter func(*config.SetConfig, *Profile, tokenIndex, *noteSet)
var (
normalizers = map[string]normalizer{}
toolEmitters = map[string]toolEmitter{}
)
func registerNormalizer(name string, fn normalizer) { normalizers[name] = fn }
func registerToolEmitter(tool string, fn toolEmitter) { toolEmitters[tool] = fn }
func runNormalizer(name string, prog *Program, tokens []Token, notes *noteSet) {
if fn, ok := normalizers[name]; ok {
fn(prog, tokens, notes)
}
}
func runToolEmitter(tool string, set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet) {
if fn, ok := toolEmitters[tool]; ok {
fn(set, prof, ti, notes)
}
}

149
src/convert/tool_byedpi.go Normal file
View file

@ -0,0 +1,149 @@
package convert
import (
"errors"
"strings"
)
const byedpiV013 = "0.13"
func init() {
grammars["byedpi.pos"] = gByedpiPos
grammars["cchar"] = gCChar
grammars["cdata"] = gCData
grammars["hostlist"] = gHostList
grammars["iplist"] = gHostList
}
func gCChar(raw string, _ grammarCtx) (Value, error) {
dec, err := parseCForm(raw)
if err != nil {
return Value{}, err
}
if len(dec) != 1 {
return Value{}, errors.New("expected exactly one byte")
}
return Value{Byte: dec[0], Str: raw}, nil
}
func gCData(raw string, _ grammarCtx) (Value, error) {
if strings.HasPrefix(raw, ":") {
dec, err := parseCForm(raw[1:])
if err != nil {
return Value{}, err
}
return Value{Str: string(dec)}, nil
}
return Value{Ref: raw}, nil
}
func gHostList(raw string, _ grammarCtx) (Value, error) {
if !strings.HasPrefix(raw, ":") {
return Value{Ref: raw}, nil
}
fields := strings.FieldsFunc(raw[1:], func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ',' || r == ';'
})
out := make([]string, 0, len(fields))
for _, f := range fields {
if f != "" {
out = append(out, f)
}
}
if len(out) == 0 {
return Value{}, errors.New("expected at least one entry")
}
return Value{List: out, Str: raw}, nil
}
func gByedpiPos(raw string, ctx grammarCtx) (Value, error) {
if ctx.Version == byedpiV013 {
p, err := parsePosV013(raw)
return Value{Pos: p, Str: raw}, err
}
p, err := parsePosV017(raw)
return Value{Pos: p, Str: raw}, err
}
func parsePosV013(raw string) (Pos, error) {
p := Pos{Raw: raw, Anchor: AnchorAbs, Rel: RelStart}
n, rest, err := splitLeadingInt(raw)
if err != nil {
return p, err
}
p.Offset = n
if rest == "" {
return p, nil
}
if rest[0] != '+' || len(rest) != 2 {
return p, errors.New("expected <n>, <n>+s, <n>+h or <n>+e")
}
switch rest[1] {
case 's':
p.Anchor, p.Rel = AnchorSNI, RelStart
case 'h':
p.Anchor, p.Rel = AnchorHost, RelStart
case 'e':
p.Anchor, p.Rel = AnchorPacket, RelEnd
default:
return p, errors.New("expected +s, +h or +e")
}
return p, nil
}
func parsePosV017(raw string) (Pos, error) {
p := Pos{Raw: raw, Anchor: AnchorAbs, Rel: RelStart}
n, rest, err := splitLeadingInt(raw)
if err != nil {
return p, err
}
p.Offset = n
for len(rest) > 0 && rest[0] == ':' {
var v int
v, rest, err = splitLeadingInt(rest[1:])
if err != nil || v < 0 {
return p, errors.New("expected <offset>[:repeats[:skip]]")
}
if p.Repeats == 0 {
if v == 0 {
return p, errors.New("repeats must be greater than zero")
}
p.Repeats = v
} else {
p.Skip = v
break
}
}
if rest == "" {
return p, nil
}
if rest[0] != '+' || len(rest) < 2 {
return p, errors.New("expected +s, +h or +n after the offset")
}
switch rest[1] {
case 's':
p.Anchor = AnchorSNI
case 'h':
p.Anchor = AnchorHost
case 'n':
p.Anchor = AnchorPacket
default:
return p, errors.New("expected +s, +h or +n after the offset")
}
if len(rest) > 2 {
switch rest[2] {
case 'e':
p.Rel = RelEnd
case 'm':
p.Rel = RelMid
case 'r':
p.Rel = RelRand
case 's':
p.Rel = RelStart
}
}
if p.Anchor == AnchorPacket && p.Rel == RelStart {
p.Anchor = AnchorAbs
}
return p, nil
}

View file

@ -0,0 +1,738 @@
package convert
import (
"testing"
"github.com/daniellavrushin/b4/config"
)
func TestAnalyze_Splitting(t *testing.T) {
tests := []struct {
name string
line string
strategy string
middleSNI bool
sniPosition int
}{
{"sniStart", "-s1+s", "tcp", true, 0},
{"sniMiddle", "-s0+sm", "tcp", true, 0},
{"fixedPosition", "-s5", "tcp", false, 5},
{"firstByte", "-s1", "tcp", false, 1},
{"disorder", "-d0+sm", "disorder", true, 0},
{"splitAndDisorder", "-s1 -d0+sm", "combo", true, 0},
{"noSplit", "-t8", "none", true, 1},
{"negativeOffset", "-s-1", "tcp", true, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := analyze(t, tt.line)
set := res.Sets[0]
if set.Fragmentation.Strategy != tt.strategy {
t.Fatalf("strategy: got %q, want %q", set.Fragmentation.Strategy, tt.strategy)
}
if tt.strategy == config.ConfigNone {
return
}
if set.Fragmentation.MiddleSNI != tt.middleSNI {
t.Fatalf("middle_sni: got %v, want %v", set.Fragmentation.MiddleSNI, tt.middleSNI)
}
if set.Fragmentation.SNIPosition != tt.sniPosition {
t.Fatalf("sni_position: got %d, want %d", set.Fragmentation.SNIPosition, tt.sniPosition)
}
})
}
}
func TestAnalyze_OOBUsesByedpiDefaultByte(t *testing.T) {
res := analyze(t, "-o1")
set := res.Sets[0]
if set.Fragmentation.Strategy != "oob" {
t.Fatalf("strategy: got %q", set.Fragmentation.Strategy)
}
if set.Fragmentation.OOBPosition != 1 {
t.Fatalf("oob_position: got %d", set.Fragmentation.OOBPosition)
}
if set.Fragmentation.OOBChar != 'a' {
t.Fatalf("oob_char: got %d, want %d (byedpi default), b4 default is %d",
set.Fragmentation.OOBChar, 'a', config.DefaultSetConfig.Fragmentation.OOBChar)
}
}
func TestAnalyze_OOBByteOverride(t *testing.T) {
res := analyze(t, "-o1 -eb")
if got := res.Sets[0].Fragmentation.OOBChar; got != 'b' {
t.Fatalf("oob_char: got %d, want %d", got, 'b')
}
}
func TestAnalyze_TLSRecord(t *testing.T) {
res := analyze(t, "-r2")
set := res.Sets[0]
if set.Fragmentation.Strategy != "tls" || set.Fragmentation.TLSRecordPosition != 2 {
t.Fatalf("got strategy=%q pos=%d", set.Fragmentation.Strategy, set.Fragmentation.TLSRecordPosition)
}
}
func TestAnalyze_FakeDefaults(t *testing.T) {
res := analyze(t, "-f-1")
set := res.Sets[0]
if !set.Faking.SNI {
t.Fatal("expected faking.sni to be enabled")
}
if set.Faking.Strategy != "ttl" || !set.Faking.ApplyTTL {
t.Fatalf("got strategy=%q apply_ttl=%v", set.Faking.Strategy, set.Faking.ApplyTTL)
}
if set.Faking.TTL != byedpiFakeTTL(t) {
t.Fatalf("ttl: got %d, want %d", set.Faking.TTL, byedpiFakeTTL(t))
}
}
func TestAnalyze_FakeSNIBecomesGeneratedPayload(t *testing.T) {
res := analyze(t, "-f-1 -Qr -n https://www.gosuslugi.ru/")
set := res.Sets[0]
if set.Faking.SNIType != config.FakePayloadDomain {
t.Fatalf("sni_type: got %d, want %d", set.Faking.SNIType, config.FakePayloadDomain)
}
if set.Faking.PayloadDomain != "www.gosuslugi.ru" {
t.Fatalf("payload_domain: got %q", set.Faking.PayloadDomain)
}
if len(set.Faking.TLSMod) != 1 || set.Faking.TLSMod[0] != "rnd" {
t.Fatalf("tls_mod: got %v", set.Faking.TLSMod)
}
}
func TestAnalyze_MD5SigWithoutFakeIsDegenerate(t *testing.T) {
res := analyze(t, "-d0+sm -S")
n := noteFor(t, res, "-S")
if n.Status != StatusDegenerate || n.Reason != "requiresFake" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_RepeatsWithoutSkipIsDegenerate(t *testing.T) {
res := analyze(t, "-d1:11+sm")
n := noteFor(t, res, "-d1:11+sm")
if n.Status != StatusDegenerate || n.Reason != "repeatsWithoutSkip" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_RepeatsWithSkipIsApproximated(t *testing.T) {
res := analyze(t, "-s1:3:5")
n := noteFor(t, res, "-s1:3:5")
if n.Status != StatusApproximated || n.Reason != "repeatsUnsupported" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_HostsInlineBecomeTargets(t *testing.T) {
res := analyze(t, "-H:youtube.com,googlevideo.com -s1+s")
set := res.Sets[0]
if len(set.Targets.SNIDomains) != 2 {
t.Fatalf("sni_domains: got %v", set.Targets.SNIDomains)
}
if !set.Enabled {
t.Fatal("a set with targets should be enabled")
}
}
func TestAnalyze_HostsFileIsUnresolved(t *testing.T) {
res := analyze(t, "-H /etc/byedpi/hosts.txt -s1+s")
if len(res.Unresolved) != 1 || res.Unresolved[0].Kind != "hostlist" {
t.Fatalf("unresolved: got %+v", res.Unresolved)
}
if res.Sets[0].Enabled {
t.Fatal("a set with no resolved targets must stay disabled")
}
}
func TestAnalyze_ProxyRuntimeIsNotApplicable(t *testing.T) {
res := analyze(t, "-i 0.0.0.0 -p 1080 -c 512 -s1+s")
for _, tok := range []string{"-i 0.0.0.0", "-p 1080", "-c 512"} {
n := noteFor(t, res, tok)
if n.Status != StatusNotApplicable {
t.Fatalf("%s: got %+v", tok, n)
}
}
if res.Fidelity.NotApplicable != 3 {
t.Fatalf("not_applicable: got %d", res.Fidelity.NotApplicable)
}
bare := analyze(t, "-s1+s")
if res.Fidelity.Score != bare.Fidelity.Score {
t.Fatalf("proxy plumbing must not change the score: got %d with, %d without",
res.Fidelity.Score, bare.Fidelity.Score)
}
}
func TestAnalyze_UnsupportedOptions(t *testing.T) {
tests := []struct {
token string
line string
reason string
}{
{"-Mh,d,r", "-f1 -Mh,d,r", "httpTamper"},
{"-O5", "-f1 -O5", "fakeOffsetUnsupported"},
{"-m3", "-f1 -m3", "noEquivalent"},
}
for _, tt := range tests {
t.Run(tt.token, func(t *testing.T) {
res := analyze(t, tt.line)
n := noteFor(t, res, tt.token)
if n.Status != StatusUnsupported || n.Reason != tt.reason {
t.Fatalf("got %+v", n)
}
})
}
}
func TestAnalyze_UDPProfile(t *testing.T) {
res := analyze(t, "-Ku -a1")
set := res.Sets[0]
if set.UDP.Mode != "fake" || set.UDP.FakeSeqLength != 1 || set.UDP.FilterQUIC != "all" {
t.Fatalf("udp: got mode=%q len=%d quic=%q", set.UDP.Mode, set.UDP.FakeSeqLength, set.UDP.FilterQUIC)
}
if set.Fragmentation.Strategy != config.ConfigNone || set.Faking.SNI {
t.Fatalf("a UDP-only profile must not carry TCP strategies: %q / %v",
set.Fragmentation.Strategy, set.Faking.SNI)
}
}
func TestAnalyze_ProtoFilterBecomesPortFilter(t *testing.T) {
res := analyze(t, "-Kt,h -H:example.com -s1+s")
if got := res.Sets[0].TCP.DPortFilter; got != "80,443" {
t.Fatalf("dport_filter: got %q", got)
}
}
func TestAnalyze_PortFilter(t *testing.T) {
res := analyze(t, "-V443-444 -H:example.com -s1+s")
if got := res.Sets[0].TCP.DPortFilter; got != "443-444" {
t.Fatalf("dport_filter: got %q", got)
}
}
func TestAnalyze_EscalationChain(t *testing.T) {
res := analyze(t, "-H:example.com -s1+s -At -d0+sm -At -f-1")
if len(res.Sets) != 3 {
t.Fatalf("expected 3 sets, got %d", len(res.Sets))
}
if res.Sets[0].Escalate.To != res.Sets[1].Id {
t.Fatalf("set 0 should escalate to set 1, got %q", res.Sets[0].Escalate.To)
}
if res.Sets[1].Escalate.To != res.Sets[2].Id {
t.Fatalf("set 1 should escalate to set 2, got %q", res.Sets[1].Escalate.To)
}
if res.Sets[2].Escalate.To != "" {
t.Fatalf("last set must not escalate, got %q", res.Sets[2].Escalate.To)
}
for i := 1; i < 3; i++ {
if !res.Sets[i].Enabled {
t.Fatalf("escalation target %d must be enabled", i)
}
if res.Sets[i].TCP.DPortFilter != "" {
t.Fatalf("escalation target %d must not match on ports alone", i)
}
}
}
func TestAnalyze_AutoNoneIsNotAnEscalation(t *testing.T) {
res := analyze(t, "-Ku -a1 -An -s1+s")
if res.Sets[0].Escalate.To != "" {
t.Fatalf("-An must not create an escalation link, got %q", res.Sets[0].Escalate.To)
}
n := noteFor(t, res, "-An")
if n.Status != StatusMapped || n.Reason != "autoNoneEntrySet" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_UDPOnlyProfileIsFoldedIntoTheEntrySet(t *testing.T) {
res := analyze(t, "-Ku -a1 -An -s1+s -At -d0+sm")
if len(res.Sets) != 2 {
t.Fatalf("the UDP profile should not become a set of its own, got %d sets", len(res.Sets))
}
entry := res.Sets[0]
if entry.Fragmentation.Strategy != "tcp" {
t.Fatalf("entry set lost its TCP strategy: %q", entry.Fragmentation.Strategy)
}
if entry.UDP.Mode != "fake" || entry.UDP.FakeSeqLength != 1 || entry.UDP.FilterQUIC != "all" {
t.Fatalf("entry set did not inherit the UDP handling: %+v", entry.UDP)
}
n := noteFor(t, res, "-Ku")
if n.Reason != "udpFoldedIntoSet" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_NoEntrySetIsShadowedByAnother(t *testing.T) {
res, err := Analyze("-Ku -a1 -An -s1+s -At -d0+sm", Options{Domains: []string{"youtube.com"}})
if err != nil {
t.Fatal(err)
}
claimed := map[string]int{}
for _, s := range res.Sets {
if !s.Enabled {
continue
}
for _, d := range s.Targets.SNIDomains {
claimed[d]++
}
}
for domain, n := range claimed {
if n > 1 {
t.Fatalf("%q is claimed by %d enabled sets; b4 applies only the first and ignores the rest", domain, n)
}
}
}
func TestAnalyze_UDPOnlyProfileSurvivesWithoutACarrier(t *testing.T) {
res := analyze(t, "-Ku -a1")
if len(res.Sets) != 1 {
t.Fatalf("expected the UDP profile to stay as its own set, got %d", len(res.Sets))
}
if res.Sets[0].UDP.FakeSeqLength != 1 {
t.Fatalf("udp: got %+v", res.Sets[0].UDP)
}
}
func TestAnalyze_UDPProfileWithOwnHostsIsNotFolded(t *testing.T) {
res := analyze(t, "-Ku -H:quic.example.com -a1 -An -H:www.example.com -s1+s")
if len(res.Sets) != 2 {
t.Fatalf("a UDP profile with its own host list is a separate set, got %d", len(res.Sets))
}
}
func TestAnalyze_ExplicitVersionOverride(t *testing.T) {
res, err := Analyze("-n example.com -f1", Options{Tool: "byedpi", Version: "0.13"})
if err != nil {
t.Fatal(err)
}
if res.Version != "0.13" {
t.Fatalf("version: got %q", res.Version)
}
if res.Sets[0].Faking.PayloadDomain != "example.com" {
t.Fatalf("payload_domain: got %q", res.Sets[0].Faking.PayloadDomain)
}
}
func TestAnalyze_UserReportedLine(t *testing.T) {
line := "-Ku -a1 -An -o1 -At,r,s -f-1 -At,r,s -d1:11+sm -S -At,r,s " +
"-n https://www.gosuslugi.ru/ -Qr -f1 -d1:11+sm -s1:11+sm -S"
res := analyze(t, line)
if res.Tool != "byedpi" {
t.Fatalf("tool: got %q", res.Tool)
}
if res.Version != "0.17" {
t.Fatalf("version: got %q, want 0.17 (uses -Q and pos:repeats syntax)", res.Version)
}
if res.VersionInferred {
t.Fatal("version should be detected from markers, not guessed")
}
if len(res.Sets) != 4 {
t.Fatalf("expected 4 sets, got %d", len(res.Sets))
}
if res.Sets[0].UDP.FakeSeqLength != 1 {
t.Fatalf("set 0 should carry the folded UDP handling: got %d", res.Sets[0].UDP.FakeSeqLength)
}
if res.Sets[0].Fragmentation.Strategy != "oob" || res.Sets[0].Fragmentation.OOBPosition != 1 {
t.Fatalf("set 0: got %+v", res.Sets[0].Fragmentation)
}
if res.Sets[0].Escalate.To != res.Sets[1].Id ||
res.Sets[1].Escalate.To != res.Sets[2].Id ||
res.Sets[2].Escalate.To != res.Sets[3].Id {
t.Fatal("expected sets 0 -> 1 -> 2 -> 3 escalation chain")
}
if res.Sets[3].Faking.PayloadDomain != "www.gosuslugi.ru" || !res.Sets[3].Faking.MD5OnFake {
t.Fatalf("set 3 faking: got %+v", res.Sets[3].Faking)
}
if res.Sets[3].Fragmentation.Strategy != "combo" {
t.Fatalf("set 3 strategy: got %q", res.Sets[3].Fragmentation.Strategy)
}
if !res.Sets[3].Fragmentation.Combo.DecoyEnabled {
t.Fatal("a combo profile that also carries -f should enable the decoy")
}
n := noteFor(t, res, "-n https://www.gosuslugi.ru/")
if n.Status != StatusApproximated || n.Reason != "fakeSNINormalised" {
t.Fatalf("URL passed to --fake-sni should be flagged, got %+v", n)
}
if !hasField(n, "faking.payload_domain") {
t.Fatalf("note should name the field it set, got %+v", n)
}
var needsTargets bool
for _, w := range res.Warnings {
if w.Code == "needsTargets" {
needsTargets = true
}
}
if !needsTargets {
t.Fatal("a byedpi line with no host filter must warn that targets are required")
}
}
func TestParsePosV013_Valid(t *testing.T) {
tests := []struct {
name string
in string
offset int
anchor Anchor
rel Rel
}{
{"plain", "1", 1, AnchorAbs, RelStart},
{"negative", "-1", -1, AnchorAbs, RelStart},
{"hex", "0x10", 16, AnchorAbs, RelStart},
{"sni", "2+s", 2, AnchorSNI, RelStart},
{"host", "3+h", 3, AnchorHost, RelStart},
{"end", "4+e", 4, AnchorPacket, RelEnd},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, err := parsePosV013(tt.in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Offset != tt.offset || p.Anchor != tt.anchor || p.Rel != tt.rel {
t.Fatalf("got offset=%d anchor=%s rel=%s, want %d/%s/%s", p.Offset, p.Anchor, p.Rel, tt.offset, tt.anchor, tt.rel)
}
})
}
}
func TestParsePosV013_Rejects(t *testing.T) {
for _, in := range []string{"1:11+sm", "1+sm", "1+x", "abc", "1+", "1junk"} {
t.Run(in, func(t *testing.T) {
if _, err := parsePosV013(in); err == nil {
t.Fatalf("expected %q to be rejected", in)
}
})
}
}
func TestParsePosV017_Valid(t *testing.T) {
tests := []struct {
name string
in string
offset int
repeats int
skip int
anchor Anchor
rel Rel
}{
{"plain", "1", 1, 0, 0, AnchorAbs, RelStart},
{"negative", "-1", -1, 0, 0, AnchorAbs, RelStart},
{"sniMid", "1:11+sm", 1, 11, 0, AnchorSNI, RelMid},
{"repeatsSkip", "1:3:5", 1, 3, 5, AnchorAbs, RelStart},
{"sniStart", "0+s", 0, 0, 0, AnchorSNI, RelStart},
{"sniEnd", "0+se", 0, 0, 0, AnchorSNI, RelEnd},
{"sniRand", "0+sr", 0, 0, 0, AnchorSNI, RelRand},
{"hostMid", "2+hm", 2, 0, 0, AnchorHost, RelMid},
{"packetMid", "0+nm", 0, 0, 0, AnchorPacket, RelMid},
{"nullBase", "5+n", 5, 0, 0, AnchorAbs, RelStart},
{"unknownSecondCharIgnored", "5+sX", 5, 0, 0, AnchorSNI, RelStart},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, err := parsePosV017(tt.in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Offset != tt.offset || p.Repeats != tt.repeats || p.Skip != tt.skip || p.Anchor != tt.anchor || p.Rel != tt.rel {
t.Fatalf("got %+v, want offset=%d repeats=%d skip=%d anchor=%s rel=%s",
p, tt.offset, tt.repeats, tt.skip, tt.anchor, tt.rel)
}
})
}
}
func TestParsePosV017_Rejects(t *testing.T) {
for _, in := range []string{"1:0", "abc", "1+x", "1+"} {
t.Run(in, func(t *testing.T) {
if _, err := parsePosV017(in); err == nil {
t.Fatalf("expected %q to be rejected", in)
}
})
}
}
func TestGHostList_InlineVsFile(t *testing.T) {
inline, err := gHostList(":a.com b.com,c.com", grammarCtx{})
if err != nil {
t.Fatal(err)
}
if len(inline.List) != 3 {
t.Fatalf("got %v", inline.List)
}
file, err := gHostList("/etc/byedpi/hosts.txt", grammarCtx{})
if err != nil {
t.Fatal(err)
}
if file.Ref != "/etc/byedpi/hosts.txt" {
t.Fatalf("got ref %q", file.Ref)
}
}
func TestGetoptLong_VersionScopedOptions(t *testing.T) {
v13 := getoptLong([]string{"-Qr"}, testTable(t, "0.13"), false)
if v13[0].Err != "unknown" {
t.Fatalf("expected -Q to be unknown in 0.13, got %+v", v13[0])
}
v17 := getoptLong([]string{"-Qr"}, testTable(t, "0.17"), false)
if v17[0].Key != "fake_tls_mod" {
t.Fatalf("expected -Q to resolve in 0.17, got %+v", v17[0])
}
n13 := getoptLong([]string{"-n", "example.com"}, testTable(t, "0.13"), false)
if n13[0].Key != "tls_sni" {
t.Fatalf("expected -n to be tls_sni in 0.13, got %q", n13[0].Key)
}
n17 := getoptLong([]string{"-n", "example.com"}, testTable(t, "0.17"), false)
if n17[0].Key != "fake_sni" {
t.Fatalf("expected -n to be fake_sni in 0.17, got %q", n17[0].Key)
}
}
func TestDetectVersion_Markers(t *testing.T) {
all, err := loadSpecs()
if err != nil {
t.Fatal(err)
}
spec := all["byedpi"]
tests := []struct {
name string
argv []string
want string
detected bool
}{
{"fakeTLSMod", []string{"-Qr"}, "0.17", true},
{"ipOpt", []string{"-k"}, "0.13", true},
{"posRepeats", []string{"-d1:11+sm"}, "0.17", true},
{"ambiguousFallsBackToDefault", []string{"-s1", "-f-1"}, "0.17", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, detected := detectVersion(spec, tt.argv)
if got != tt.want || detected != tt.detected {
t.Fatalf("got (%s, %v), want (%s, %v)", got, detected, tt.want, tt.detected)
}
})
}
}
var sharedConfigs = []struct {
name string
line string
}{
{
"gosuslugiEscalation",
"-Ku -a1 -An -o1 -At,r,s -f-1 -At,r,s -d1:11+sm -S -At,r,s " +
"-n https://www.gosuslugi.ru/ -Qr -f1 -d1:11+sm -s1:11+sm -S",
},
{
"vkLadderTwoProfiles",
"-Ku -a3 -An -Kt,h -n vk.com -d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s " +
"-d25+s -s30+s -d35+s -r1+s -S -Mh,d -As -Kt,h -n vk.com -d1 -d3+s -s6+s " +
"-d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -S -Mh,d",
},
{
"sevenProfileEscalation",
"-Ku -a1 -An -d1 -s0+s -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s " +
"-d35+s -At,r,s -s1 -q1 -At,r,s -s5 -o25000+s -At,r,s -o1 -d1 -r1+s -t10 " +
"-b1500 -s0+s -d3+s -At,r,s -f-1 -r1+s -At,r,s -s1 -o1+s -s-1",
},
{
"inlineFakePayloadUDP",
`-Ku -l':\x16\x03\x01\x02\x87\x01\x00\x02\x83\x03\x03\x5f\x15\x63\xcb\x06' ` +
`-a1 -An -s1 -q1 -Y -At -f-1 -r1+s -As`,
},
}
func TestCorpus_EveryRecognizedOptionIsReported(t *testing.T) {
for _, tc := range sharedConfigs {
t.Run(tc.name, func(t *testing.T) {
res := analyze(t, tc.line)
reported := map[string]bool{}
for _, n := range res.Notes {
reported[n.Token] = true
}
all, err := loadSpecs()
if err != nil {
t.Fatal(err)
}
table := all[res.Tool].tableFor(res.Version)
for _, tok := range getoptLong(res.Argv, table, false) {
if tok.Spec.Target == "_.ignore" {
continue
}
if !reported[tok.Raw] {
t.Fatalf("option %q produced no entry in the report", tok.Raw)
}
}
})
}
}
func TestCorpus_NoUnaccountedOptions(t *testing.T) {
for _, tc := range sharedConfigs {
t.Run(tc.name, func(t *testing.T) {
res := analyze(t, tc.line)
for _, n := range res.Notes {
if n.Reason == "unaccountedOption" {
t.Fatalf("%q fell through every emit rule", n.Token)
}
if n.Status == StatusUnknown || n.Status == StatusInvalid {
t.Fatalf("%q was not understood: %s/%s", n.Token, n.Status, n.Reason)
}
}
})
}
}
func TestCorpus_EscalationChainsAreAcyclic(t *testing.T) {
for _, tc := range sharedConfigs {
t.Run(tc.name, func(t *testing.T) {
res := analyze(t, tc.line)
byID := map[string]int{}
for i, s := range res.Sets {
byID[s.Id] = i
}
for i, s := range res.Sets {
if s.Escalate.To == "" {
continue
}
target, ok := byID[s.Escalate.To]
if !ok {
t.Fatalf("set %d escalates to an unknown id %q", i, s.Escalate.To)
}
if target <= i {
t.Fatalf("set %d escalates backwards to %d", i, target)
}
if !res.Sets[target].Enabled {
t.Fatalf("set %d escalates to a disabled set %d", i, target)
}
}
})
}
}
func TestAnalyze_UDPProfileStillReportsFakeOptions(t *testing.T) {
res := analyze(t, `-Ku -l':abc' -a1`)
n := noteFor(t, res, "-l:abc")
if n.Status != StatusDegenerate || n.Reason != "requiresFake" {
t.Fatalf("a fake payload in a UDP-only profile must be reported, got %+v", n)
}
}
func TestAnalyze_ComboHonoursFirstByteSplit(t *testing.T) {
res := analyze(t, "-d1 -s3+s")
set := res.Sets[0]
if set.Fragmentation.Strategy != "combo" {
t.Fatalf("strategy: got %q", set.Fragmentation.Strategy)
}
if !set.Fragmentation.Combo.FirstByteSplit {
t.Fatal("offset 1 should enable combo.first_byte_split")
}
n := noteFor(t, res, "-d1")
if n.Status != StatusMapped || n.Reason != "firstByteMapped" {
t.Fatalf("offset 1 is representable in combo, got %+v", n)
}
if !hasField(n, "fragmentation.combo.first_byte_split") {
t.Fatalf("note should name the field it set, got %+v", n)
}
}
func TestAnalyze_SplitLadderIsSummarised(t *testing.T) {
res := analyze(t, "-s1 -d3+s -s6+s -d9+s -s12+s -d15+s")
var found *Note
for i := range res.Notes {
if res.Notes[i].Reason == "splitPointsCollapsed" {
found = &res.Notes[i]
}
}
if found == nil {
t.Fatal("a ladder of split points should be summarised once for the profile")
}
if found.Params["count"] != 6 {
t.Fatalf("count: got %v, want 6", found.Params["count"])
}
}
func TestAnalyze_ProfileWithoutDesyncIsReported(t *testing.T) {
res := analyze(t, "-s1 -At -f-1 -As")
if len(res.Sets) != 3 {
t.Fatalf("expected 3 sets, got %d", len(res.Sets))
}
last := res.Sets[2]
if last.Fragmentation.Strategy != "none" || last.Faking.SNI {
t.Fatalf("a trailing -A with no options is a pass-through set, got %+v", last.Fragmentation)
}
var found bool
for _, n := range res.Notes {
if n.Reason == "profileWithoutDesync" && n.Profile == 2 {
found = true
}
}
if !found {
t.Fatal("an empty set must be explained rather than left as a mystery")
}
}
func TestAnalyze_CompetingFixedPositions(t *testing.T) {
res := analyze(t, "-s5 -s7")
if got := res.Sets[0].Fragmentation.SNIPosition; got != 5 {
t.Fatalf("sni_position: got %d, want 5", got)
}
if n := noteFor(t, res, "-s5"); n.Status != StatusMapped {
t.Fatalf("-s5: got %+v", n)
}
n := noteFor(t, res, "-s7")
if n.Status != StatusApproximated || n.Reason != "fixedPositionIgnored" {
t.Fatalf("a second fixed position cannot be kept, got %+v", n)
}
}
func TestAnalyze_PositionBeyondRangeIsClamped(t *testing.T) {
res := analyze(t, "-s25000")
if got := res.Sets[0].Fragmentation.SNIPosition; got != maxSNIPosition {
t.Fatalf("sni_position: got %d, want %d", got, maxSNIPosition)
}
n := noteFor(t, res, "-s25000")
if n.Status != StatusApproximated || n.Reason != "positionClamped" {
t.Fatalf("got %+v", n)
}
}
func TestAnalyze_DroppedOOBDoesNotLeaveItsByteBehind(t *testing.T) {
res := analyze(t, "-s5 -o25000+s")
set := res.Sets[0]
if set.Fragmentation.Strategy == "oob" {
t.Fatal("a plain split should win over oob here")
}
if set.Fragmentation.OOBChar != config.DefaultSetConfig.Fragmentation.OOBChar {
t.Fatalf("oob_char should stay at the b4 default when oob was dropped, got %d",
set.Fragmentation.OOBChar)
}
}
func byedpiFakeTTL(t *testing.T) uint8 {
t.Helper()
all, err := loadSpecs()
if err != nil {
t.Fatal(err)
}
return uint8(all["byedpi"].Defaults.FakeTTL)
}
func TestByedpi_DefaultsComeFromTheRuleFile(t *testing.T) {
all, err := loadSpecs()
if err != nil {
t.Fatal(err)
}
d := all["byedpi"].Defaults
if d.FakeTTL != 8 || !d.FakeTTLForced || d.OOBByte != 'a' {
t.Fatalf("byedpi defaults: got %+v", d)
}
}

126
src/convert/tool_test.go Normal file
View file

@ -0,0 +1,126 @@
package convert
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
var coreFiles = []string{
"convert.go", "spec.go", "extract.go", "getopt.go",
"grammar.go", "ir.go", "parse.go", "emit.go", "tool.go", "detect.go",
}
func TestCore_MentionsNoToolByName(t *testing.T) {
tools, err := Tools()
if err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(tools))
for _, x := range tools {
names = append(names, x.Tool)
}
for _, f := range coreFiles {
raw, err := os.ReadFile(f)
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
body := string(raw)
for _, name := range names {
if f == "extract.go" {
continue
}
if strings.Contains(strings.ToLower(body), name) {
t.Errorf("%s mentions %q; per-tool behaviour belongs in tool_%s.go "+
"or in rules/%s.json", f, name, name, name)
}
}
}
}
func TestTools_EachHasItsOwnFileAndRules(t *testing.T) {
tools, err := Tools()
if err != nil {
t.Fatal(err)
}
if len(tools) < 2 {
t.Fatalf("expected at least two tools, got %d", len(tools))
}
for _, x := range tools {
t.Run(x.Tool, func(t *testing.T) {
for _, f := range []string{
"tool_" + x.Tool + ".go",
"tool_" + x.Tool + "_test.go",
filepath.Join("rules", x.Tool+".json"),
} {
if _, err := os.Stat(f); err != nil {
t.Errorf("missing %s", f)
}
}
if x.Label == "" {
t.Error("rule file has no label")
}
if len(x.Versions) == 0 {
t.Error("rule file declares no versions")
}
})
}
}
func TestTools_RegisteredGrammarsAreNamespaced(t *testing.T) {
tools, err := Tools()
if err != nil {
t.Fatal(err)
}
known := map[string]bool{}
for _, x := range tools {
known[x.Tool] = true
}
for name := range grammars {
tool, _, ok := strings.Cut(name, ".")
if !ok {
continue
}
if !known[tool] {
t.Errorf("grammar %q is namespaced under an unknown tool", name)
}
}
}
var grammarRefRe = regexp.MustCompile(`"grammar":\s*"([a-zA-Z0-9._]+)"`)
func TestRules_ReferenceOnlyRegisteredGrammars(t *testing.T) {
entries, err := os.ReadDir("rules")
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
raw, err := os.ReadFile(filepath.Join("rules", e.Name()))
if err != nil {
t.Fatal(err)
}
for _, m := range grammarRefRe.FindAllStringSubmatch(string(raw), -1) {
if _, ok := grammars[m[1]]; !ok {
t.Errorf("%s references unregistered grammar %q", e.Name(), m[1])
}
}
}
}
func TestRules_NormalizersAndEmittersResolve(t *testing.T) {
all, err := loadSpecs()
if err != nil {
t.Fatal(err)
}
for _, spec := range all {
if spec.Normalize != "" {
if _, ok := normalizers[spec.Normalize]; !ok {
t.Errorf("%s names normalizer %q, which is not registered",
spec.Tool, spec.Normalize)
}
}
}
}

View file

@ -4,6 +4,8 @@ import (
"errors"
"strconv"
"strings"
"github.com/daniellavrushin/b4/config"
)
var zapretDesyncModes = map[string]string{
@ -50,6 +52,8 @@ var zapretPosMarkers = map[string]struct {
}
func init() {
registerNormalizer("zapret", normalizeZapret)
registerToolEmitter("zapret", emitZapret)
grammars["zapret.desync"] = gZapretDesync
grammars["zapret.fooling"] = gZapretFooling
grammars["zapret.splitpos"] = gZapretSplitPos
@ -259,3 +263,186 @@ func gZapretWSize(raw string, _ grammarCtx) (Value, error) {
}
return Value{Int: n, Str: raw}, nil
}
var zapretDroppedModes = map[string]bool{
"udplen": true, "tamper": true, "hopbyhop": true, "destopt": true,
}
func emitZapretExtras(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet) {
if prof.Desync.Mode != "" {
set.TCP.Desync.Mode = prof.Desync.Mode
}
if prof.SynFake.Enabled {
set.TCP.SynFake = true
set.TCP.SynFakeLen = prof.SynFake.Len
}
if prof.Duplicate > 0 {
set.TCP.Duplicate.Enabled = true
set.TCP.Duplicate.Count = clamp(prof.Duplicate, 1, 10)
if tok, ok := ti.first(prof.Index, "dup"); ok {
notes.set(tok, StatusMapped, "duplicateMapped",
"tcp.duplicate.enabled=true", "tcp.duplicate.count="+strconv.Itoa(set.TCP.Duplicate.Count))
}
}
if prof.SeqOvl.Length > 0 {
set.Fragmentation.SeqOverlapLength = prof.SeqOvl.Length
set.Fragmentation.SeqOverlapPattern = seqOvlPattern(prof.SeqOvl.Pattern)
if tok, ok := ti.first(prof.Index, "seqovl"); ok {
notes.set(tok, StatusMapped, "seqOvlMapped",
"fragmentation.seq_overlap_length="+strconv.Itoa(prof.SeqOvl.Length))
}
if tok, ok := ti.first(prof.Index, "seqovl_pat"); ok {
notes.set(tok, StatusApproximated, "seqOvlPatternMapped", "fragmentation.seq_overlap_pattern")
}
}
if prof.WinSize > 0 {
set.TCP.Win.Mode = "zero"
if tok, ok := ti.first(prof.Index, "wssize"); ok {
notes.set(tok, StatusApproximated, "wsSizeApproximated", "tcp.win.mode=zero")
}
}
if len(prof.Filters.Excluded) > 0 {
if tok, ok := ti.first(prof.Index, "hostlist_excl_dom", "hostlist_exclude"); ok {
notes.set(tok, StatusUnsupported, "excludeListUnsupported")
}
}
if prof.Skip {
set.Enabled = false
if tok, ok := ti.first(prof.Index, "skip"); ok {
notes.set(tok, StatusMapped, "skipMapped", "enabled=false")
}
}
}
func noteDesyncModes(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet) {
tok, ok := ti.first(prof.Index, "desync")
if !ok || len(prof.DesyncModes) == 0 {
return
}
var fields, dropped []string
if set.Fragmentation.Strategy != config.ConfigNone {
fields = append(fields, "fragmentation.strategy="+set.Fragmentation.Strategy)
}
if set.Faking.SNI {
fields = append(fields, "faking.sni=true")
}
if set.TCP.Desync.Mode != config.ConfigOff {
fields = append(fields, "tcp.desync.mode="+set.TCP.Desync.Mode)
}
if set.TCP.SynFake {
fields = append(fields, "tcp.syn_fake=true")
}
if prof.UDP.Present {
fields = append(fields, "udp.mode="+set.UDP.Mode)
}
for _, m := range prof.DesyncModes {
if zapretDroppedModes[m] {
dropped = append(dropped, m)
}
}
if len(dropped) > 0 {
n := notes.set(tok, StatusUnsupported, "desyncModesDropped", fields...)
n.Params = map[string]any{"dropped": strings.Join(dropped, ", ")}
return
}
if len(fields) == 0 {
notes.set(tok, StatusDegenerate, "desyncModesEmpty")
return
}
notes.set(tok, StatusApproximated, "desyncModesMapped", fields...)
}
func seqOvlPattern(raw string) []string {
hex := strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X")
if hex == "" || len(hex)%2 != 0 {
return []string{"0x16", "0x03", "0x03", "0x00", "0x00"}
}
out := make([]string, 0, len(hex)/2)
for i := 0; i+1 < len(hex); i += 2 {
if !isHex(hex[i]) || !isHex(hex[i+1]) {
return []string{"0x16", "0x03", "0x03", "0x00", "0x00"}
}
out = append(out, "0x"+hex[i:i+2])
}
return out
}
func onlyExtSplit(plain, disorder []SplitOp) bool {
ops := append(append([]SplitOp{}, plain...), disorder...)
if len(ops) != 1 {
return false
}
return ops[0].Pos.Anchor == AnchorSNIExt && ops[0].Pos.Offset == 0
}
func plainOrDisorder(plain, disorder []SplitOp) SplitOp {
if len(plain) > 0 {
return plain[0]
}
return disorder[0]
}
func emitZapret(set *config.SetConfig, prof *Profile, ti tokenIndex, notes *noteSet) {
emitZapretExtras(set, prof, ti, notes)
noteDesyncModes(set, prof, ti, notes)
}
func normalizeZapret(prog *Program, _ []Token, notes *noteSet) {
for _, prof := range prog.Profiles {
normalizeZapretProfile(prof, notes)
promoteUDPFake(prof)
}
}
func promoteUDPFake(prof *Profile) {
if !prof.UDPOnly() {
return
}
prof.UDP.Present = prof.Fake.Present
prof.UDP.Repeats = prof.Fake.Repeats
prof.UDP.QUICRef = prof.Fake.QUICRef
prof.UDP.TTL = prof.Fake.TTL
prof.UDP.TTLSet = prof.Fake.TTLSet
prof.UDP.Ports = append(prof.UDP.Ports, prof.Filters.UDPPorts...)
}
func normalizeZapretProfile(prof *Profile, _ *noteSet) {
positions := prof.SplitPositions
token := prof.SplitPosToken
if len(positions) == 0 {
positions = []Pos{{Raw: "1", Offset: 1, Anchor: AnchorAbs, Rel: RelStart}}
token = prof.DesyncToken
}
for _, mode := range prof.DesyncModes {
switch mode {
case "fake", "fakeknown":
prof.Fake.Present = true
case "rst", "rstack":
prof.Desync.Mode = "rst"
case "synack":
prof.SynFake.Enabled = true
case "syndata":
prof.SynFake.Enabled = true
prof.SynFake.Len = 1
case "multisplit":
appendSplits(prof, SplitPlain, positions, token)
case "multidisorder":
appendSplits(prof, SplitDisorder, positions, token)
case "fakedsplit", "hostfakesplit":
prof.Fake.Present = true
appendSplits(prof, SplitPlain, positions[:1], token)
case "fakeddisorder":
prof.Fake.Present = true
appendSplits(prof, SplitDisorder, positions[:1], token)
case "ipfrag1", "ipfrag2":
appendSplits(prof, SplitIPFrag, positions[:1], token)
}
}
}
func appendSplits(prof *Profile, kind SplitKind, positions []Pos, token int) {
for _, p := range positions {
prof.Splits = append(prof.Splits, SplitOp{Kind: kind, Pos: p, Token: token})
}
}