From 77fbdec70703fd7222d58867cf033b371ab41849 Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Tue, 25 Aug 2026 11:42:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(tunnel):=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D1=8B=20=D0=BE=D0=B1=D1=84=D1=83=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20awg=203+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tunnel/config.go | 70 ++++++++++++++++++++ internal/tunnel/uapi.go | 16 +++++ internal/tunnel/uapi_test.go | 94 +++++++++++++++++++++++++++ internal/tunnel/wgconf/wgconf.go | 39 +++++++++++ internal/tunnel/wgconf/wgconf_test.go | 53 +++++++++++++++ mobile/README.md | 13 ++++ 6 files changed, 285 insertions(+) diff --git a/internal/tunnel/config.go b/internal/tunnel/config.go index e8d9ac7..5837591 100644 --- a/internal/tunnel/config.go +++ b/internal/tunnel/config.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "net/netip" + "strconv" + "strings" ) // DefaultMTU - безопасный MTU для WireGuard поверх TURN без фрагментации. @@ -34,7 +36,11 @@ type Config struct { Amnezia AmneziaParams } +// MinHeaderProtectionPadding - минимум S1-S4, из которого AWG берёт nonce для защиты заголовка. +const MinHeaderProtectionPadding = 12 + // AmneziaParams содержит параметры обфускации протокола AmneziaWG. +// Range-поля пустые либо в формате "N" / "LO-HI". type AmneziaParams struct { Jc int Jmin int @@ -51,6 +57,17 @@ type AmneziaParams struct { H4 string I [5]string + + // AWG 3+. + HeaderProtectionKey Key + ContentPaddingAddition string + RekeyAfterTime string + RekeyTimeout string + RejectAfterTime string + KeepaliveTimeout string + MaxHandshakeAttempts string + RandomTrailers bool + DisableCookies bool } func (p AmneziaParams) Enabled() bool { return p != AmneziaParams{} } @@ -111,5 +128,58 @@ func (p AmneziaParams) validate() error { return errors.New("tunnel: amnezia padding must not be negative") } } + return p.validateAWG3() +} + +func (p AmneziaParams) validateAWG3() error { + ranges := []struct { + name string + val string + }{ + {"H1", p.H1}, {"H2", p.H2}, {"H3", p.H3}, {"H4", p.H4}, + {"ContentPaddingAddition", p.ContentPaddingAddition}, + {"RekeyAfterTime", p.RekeyAfterTime}, + {"RekeyTimeout", p.RekeyTimeout}, + {"RejectAfterTime", p.RejectAfterTime}, + {"KeepaliveTimeout", p.KeepaliveTimeout}, + {"MaxHandshakeAttempts", p.MaxHandshakeAttempts}, + } + for _, r := range ranges { + if err := ValidateRange(r.val); err != nil { + return fmt.Errorf("tunnel: amnezia %s: %w", r.name, err) + } + } + if p.HeaderProtectionKey.IsZero() { + return nil + } + // Защита заголовка берёт nonce из crypto-паддинга, короткого ей не хватает. + for _, v := range []int{p.S1, p.S2, p.S3, p.S4} { + if v < MinHeaderProtectionPadding { + return fmt.Errorf("tunnel: amnezia HeaderProtectionKey requires S1-S4 >= %d", MinHeaderProtectionPadding) + } + } + return nil +} + +// ValidateRange проверяет формат диапазона AWG ("N" или "LO-HI"); пустая строка - не задан. +func ValidateRange(s string) error { + if s == "" { + return nil + } + lo, hi, hasHi := strings.Cut(s, "-") + low, err := strconv.ParseUint(lo, 10, 32) + if err != nil { + return fmt.Errorf("bad lower bound %q", lo) + } + if !hasHi { + return nil + } + high, err := strconv.ParseUint(hi, 10, 32) + if err != nil { + return fmt.Errorf("bad upper bound %q", hi) + } + if high < low { + return fmt.Errorf("upper bound %d below lower %d", high, low) + } return nil } diff --git a/internal/tunnel/uapi.go b/internal/tunnel/uapi.go index 75cac18..086c26f 100644 --- a/internal/tunnel/uapi.go +++ b/internal/tunnel/uapi.go @@ -69,6 +69,12 @@ func writeAmnezia(line func(k, v string), p AmneziaParams) { val string }{ {"h1", p.H1}, {"h2", p.H2}, {"h3", p.H3}, {"h4", p.H4}, + {"content_padding_addition", p.ContentPaddingAddition}, + {"rekey_after_time", p.RekeyAfterTime}, + {"rekey_timeout", p.RekeyTimeout}, + {"reject_after_time", p.RejectAfterTime}, + {"keepalive_timeout", p.KeepaliveTimeout}, + {"max_handshake_attempts", p.MaxHandshakeAttempts}, } for _, kv := range strKeys { if kv.val != "" { @@ -80,4 +86,14 @@ func writeAmnezia(line func(k, v string), p AmneziaParams) { line("i"+strconv.Itoa(i+1), spec) } } + if !p.HeaderProtectionKey.IsZero() { + line("header_protection_key", hex.EncodeToString(p.HeaderProtectionKey[:])) + } + // Булевы шлём только во включённом виде - device и так стартует с false. + if p.RandomTrailers { + line("random_trailers", "true") + } + if p.DisableCookies { + line("disable_cookies", "true") + } } diff --git a/internal/tunnel/uapi_test.go b/internal/tunnel/uapi_test.go index 13d6fe3..1ee398b 100644 --- a/internal/tunnel/uapi_test.go +++ b/internal/tunnel/uapi_test.go @@ -176,3 +176,97 @@ func TestDefaultsMTU(t *testing.T) { t.Errorf("Defaults().MTU = %d, want %d", got, DefaultMTU) } } + +func TestUAPIAWG3Params(t *testing.T) { + cfg := minimalConfig() + cfg.Amnezia = AmneziaParams{ + S1: 12, S2: 12, S3: 12, S4: 12, + HeaderProtectionKey: testKey(0x04), + ContentPaddingAddition: "10-40", + RekeyAfterTime: "90-140", + RekeyTimeout: "5", + RejectAfterTime: "180", + KeepaliveTimeout: "10-15", + MaxHandshakeAttempts: "18", + RandomTrailers: true, + DisableCookies: true, + } + + got, err := UAPI(cfg) + if err != nil { + t.Fatalf("UAPI() error = %v", err) + } + for _, want := range []string{ + "header_protection_key=" + strings.Repeat("04", KeyLen), + "content_padding_addition=10-40", + "rekey_after_time=90-140", + "rekey_timeout=5", + "reject_after_time=180", + "keepalive_timeout=10-15", + "max_handshake_attempts=18", + "random_trailers=true", + "disable_cookies=true", + } { + if !strings.Contains(got, want+"\n") { + t.Errorf("UAPI() missing %q:\n%s", want, got) + } + } +} + +func TestUAPIOmitsAWG3Defaults(t *testing.T) { + cfg := minimalConfig() + cfg.Amnezia = AmneziaParams{Jc: 4, Jmin: 40, Jmax: 70} + + got, err := UAPI(cfg) + if err != nil { + t.Fatalf("UAPI() error = %v", err) + } + for _, unwanted := range []string{ + "header_protection_key=", "content_padding_addition=", + "rekey_after_time=", "rekey_timeout=", "reject_after_time=", + "keepalive_timeout=", "max_handshake_attempts=", + "random_trailers=", "disable_cookies=", + } { + if strings.Contains(got, unwanted) { + t.Errorf("UAPI() contains %q for zero value:\n%s", unwanted, got) + } + } +} + +func TestValidateRange(t *testing.T) { + for _, ok := range []string{"", "0", "25", "10-40", "7-7"} { + if err := ValidateRange(ok); err != nil { + t.Errorf("ValidateRange(%q) = %v, want nil", ok, err) + } + } + for _, bad := range []string{"40-10", "a", "-5", "1-", "1-2-3", "off"} { + if err := ValidateRange(bad); err == nil { + t.Errorf("ValidateRange(%q) = nil, want error", bad) + } + } +} + +func TestValidateHeaderProtectionNeedsPadding(t *testing.T) { + cfg := minimalConfig() + cfg.Amnezia = AmneziaParams{ + S1: MinHeaderProtectionPadding, S2: MinHeaderProtectionPadding, + S3: MinHeaderProtectionPadding, S4: MinHeaderProtectionPadding - 1, + HeaderProtectionKey: testKey(0x04), + } + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() = nil, want error for short S4") + } + + cfg.Amnezia.S4 = MinHeaderProtectionPadding + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } +} + +func TestValidateRejectsBadRange(t *testing.T) { + cfg := minimalConfig() + cfg.Amnezia = AmneziaParams{RekeyTimeout: "40-10"} + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() = nil, want error for inverted range") + } +} diff --git a/internal/tunnel/wgconf/wgconf.go b/internal/tunnel/wgconf/wgconf.go index e0e7080..1fb800e 100644 --- a/internal/tunnel/wgconf/wgconf.go +++ b/internal/tunnel/wgconf/wgconf.go @@ -123,12 +123,51 @@ func applyInterface(cfg *tunnel.Config, key, value string) error { case "i1", "i2", "i3", "i4", "i5": idx := int(key[1] - '1') cfg.Amnezia.I[idx] = value + case "headerprotectionkey": + k, err := parseKey(value) + if err != nil { + return fmt.Errorf("headerprotectionkey: %w", err) + } + cfg.Amnezia.HeaderProtectionKey = k + case "contentpaddingaddition", "rekeyaftertime", "rekeytimeout", + "rejectaftertime", "keepalivetimeout", "maxhandshakeattempts": + if err := tunnel.ValidateRange(value); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + setAmneziaRange(&cfg.Amnezia, key, value) + case "randomtrailers", "disablecookies": + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("%s: %w", key, err) + } + if key == "randomtrailers" { + cfg.Amnezia.RandomTrailers = b + } else { + cfg.Amnezia.DisableCookies = b + } default: return fmt.Errorf("unknown [Interface] key %q", key) } return nil } +func setAmneziaRange(p *tunnel.AmneziaParams, key, value string) { + switch key { + case "contentpaddingaddition": + p.ContentPaddingAddition = value + case "rekeyaftertime": + p.RekeyAfterTime = value + case "rekeytimeout": + p.RekeyTimeout = value + case "rejectaftertime": + p.RejectAfterTime = value + case "keepalivetimeout": + p.KeepaliveTimeout = value + case "maxhandshakeattempts": + p.MaxHandshakeAttempts = value + } +} + func setAmneziaInt(p *tunnel.AmneziaParams, key string, n int) { switch key { case "jc": diff --git a/internal/tunnel/wgconf/wgconf_test.go b/internal/tunnel/wgconf/wgconf_test.go index 777f627..63ec5af 100644 --- a/internal/tunnel/wgconf/wgconf_test.go +++ b/internal/tunnel/wgconf/wgconf_test.go @@ -202,3 +202,56 @@ func TestParseFeedsUAPI(t *testing.T) { t.Errorf("UAPI() = %s", uapi) } } + +func TestParseAWG3Params(t *testing.T) { + hpKey := base64.StdEncoding.EncodeToString(bytesOf(0x04)) + text := "[Interface]\nPrivateKey = " + privKey + "\nAddress = 10.8.0.2/32\n" + + "S1 = 12\nS2 = 12\nS3 = 12\nS4 = 12\n" + + "HeaderProtectionKey = " + hpKey + "\n" + + "ContentPaddingAddition = 10-40\n" + + "RekeyAfterTime = 90-140\nRekeyTimeout = 5\nRejectAfterTime = 180\n" + + "KeepaliveTimeout = 10-15\nMaxHandshakeAttempts = 18\n" + + "RandomTrailers = true\nDisableCookies = 1\n\n" + + "[Peer]\nPublicKey = " + pubKey + "\nAllowedIPs = 0.0.0.0/0\n" + + cfg, err := Parse(text) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + p := cfg.Amnezia + if p.HeaderProtectionKey != tunnel.Key(bytesOf(0x04)) { + t.Error("HeaderProtectionKey not parsed") + } + for _, kv := range []struct{ got, want string }{ + {p.ContentPaddingAddition, "10-40"}, + {p.RekeyAfterTime, "90-140"}, + {p.RekeyTimeout, "5"}, + {p.RejectAfterTime, "180"}, + {p.KeepaliveTimeout, "10-15"}, + {p.MaxHandshakeAttempts, "18"}, + } { + if kv.got != kv.want { + t.Errorf("range = %q, want %q", kv.got, kv.want) + } + } + if !p.RandomTrailers || !p.DisableCookies { + t.Errorf("bool flags = %v/%v, want true/true", p.RandomTrailers, p.DisableCookies) + } +} + +func TestParseAWG3Errors(t *testing.T) { + base := "[Interface]\nPrivateKey = " + privKey + "\nAddress = 10.8.0.2/32\n" + peer := "\n[Peer]\nPublicKey = " + pubKey + "\nAllowedIPs = 0.0.0.0/0\n" + + for name, line := range map[string]string{ + "bad range": "RekeyTimeout = 40-10\n", + "non-numeric": "KeepaliveTimeout = soon\n", + "bad bool": "RandomTrailers = maybe\n", + "short hp key": "HeaderProtectionKey = " + base64.StdEncoding.EncodeToString([]byte("short")) + "\n", + "hp without pad": "HeaderProtectionKey = " + base64.StdEncoding.EncodeToString(bytesOf(0x04)) + "\n", + } { + if _, err := Parse(base + line + peer); err == nil { + t.Errorf("%s: Parse() = nil, want error", name) + } + } +} diff --git a/mobile/README.md b/mobile/README.md index f7fcfc9..a785d5c 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -84,6 +84,19 @@ JSON парсится с `DisallowUnknownFields`, поэтому конфиг с Текст WireGuard-конфигурации (`wg-quick`) передается в `tunnel.config`. В режиме `wg` параметры обфускации AmneziaWG игнорируются. Поле `Endpoint` из конфига не используется, так как трафик идет через TURN-релей. +В секции `[Interface]` понимаются параметры AmneziaWG: `Jc`/`Jmin`/`Jmax`, `S1`-`S4`, `H1`-`H4`, `I1`-`I5`, а также AWG 3+: + +| Ключ | Значение | Должен совпадать с сервером | +|---|---|---| +| `HeaderProtectionKey` | base64-ключ 32 байта | да | +| `ContentPaddingAddition` | `N` или `LO-HI` | нет | +| `RekeyAfterTime`, `RekeyTimeout`, `RejectAfterTime`, `KeepaliveTimeout` | `N` или `LO-HI`, секунды | нет | +| `MaxHandshakeAttempts` | `N` или `LO-HI`, попытки | нет | +| `RandomTrailers` | bool | да | +| `DisableCookies` | bool | нет | + +`HeaderProtectionKey` берёт nonce из crypto-паддинга, поэтому требует `S1`-`S4` не меньше 12 - иначе конфиг отклоняется. + Адреса, DNS, маршруты и MTU tun-интерфейса ядро не применяет - это работа платформы. Их отдаёт `ParseTunnelConfig` из того же текста, что уходит в `tunnel.config` (аргумент `mtu` - то же значение, что в `tunnel.mtu`; `0` - взять из конфига): ```kotlin