feat(tunnel): параметры обфускации awg 3+

This commit is contained in:
samosvalishe
2026-08-25 11:42:23 +03:00
parent c6fc903c25
commit 77fbdec707
6 changed files with 285 additions and 0 deletions
+70
View File
@@ -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
}
+16
View File
@@ -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")
}
}
+94
View File
@@ -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")
}
}
+39
View File
@@ -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":
+53
View File
@@ -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)
}
}
}
+13
View File
@@ -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