ZapretKVN-android/audit/fallback_strategy_test.go
loop-uh 3896b96346
Some checks failed
Android CI / build (push) Failing after 7m18s
AWG 3.0: ядро 2.6.5, официальный движок amneziawg-go v3, импорт всех полей
Три согласованных изменения, вместе дающие рабочий AmneziaWG 3.0:

1. Ядро поднято до sing-box extended v1.13.18-extended-2.6.5 — первой
   версии со схемой amnezia третьего поколения (header_protection_key,
   content_padding_addition, rekey_*, keepalive_timeout,
   max_handshake_attempts) и её трансляцией в UAPI движка.

2. Android-патч данных: AWG-ветка раздельного data-plane переведена с
   metacubex/amneziawg-go (только AWG <= 2.0, AWG3-ключи UAPI неизвестны)
   на официальный amnezia-vpn/amneziawg-go v3.1.20260814 — ту же линию
   3.1, что и на серверном флоте. Обычный WireGuard остаётся на
   metacubex/wireguard-go. Общая bind-логика через защищённый диалер
   вынесена в engine-agnostic ядро с двумя тонкими типами-обёртками
   (Go не позволяет одному типу реализовать структурно одинаковые
   интерфейсы Bind двух пакетов). Известное сужение: официальный движок
   не принимает j1-j3/itime (AWG 1.5-надстройки metacubex); наш импортёр
   их и раньше не выпускал.

3. Kotlin-импортёр .conf переносит все семь полей AWG 3.0 в объект
   amnezia (snake_case схемы ядра) со строгой проверкой: ключ — base64
   ровно 32 байт, диапазоны — число или A-B в пределах uint32. Профиль
   получает имя протокола "AmneziaWG 3.0".

Аудит-гейт ядра переписан под честно изменившуюся семантику fallback
2.6.x: контекст попытки теперь производный с per-attempt-дедлайном
(значения наследуются, общий дедлайн не превышается), SERVFAIL/REFUSED
стали поводом для fallback, финальны только NOERROR и NXDOMAIN;
CreateStrategy требует логгер и таймаут.

Проверено: полный pinned-билд ядра (libbox.aar arm/arm64/amd64, все
go-тесты патчсета), компиляция приложения с новым libbox, юнит-тесты
app и wireguard-import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:22:39 +03:00

214 lines
8 KiB
Go

package fallback
import (
"context"
"errors"
"testing"
"time"
"github.com/miekg/dns"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/logger"
)
// This file is copied into the exact pinned checkout only for `go test` and is
// removed immediately afterwards. It deliberately tests the unexported
// strategy implementation without patching the libbox binary we ship.
//
// Core 2.6.x semantics under audit (changed from 2.5.x):
// - every attempt runs in a context DERIVED from the caller's one (values
// inherited, deadline split per attempt) instead of the identical object;
// - SERVFAIL/REFUSED are transport failures and fall back to the next
// server; only NOERROR and NXDOMAIN are final results;
// - the whole sequential pass never outlives the caller's deadline.
type zapretAuditKey struct{}
type zapretAuditTransport struct {
tag string
calls int
exchange func(context.Context, *dns.Msg) (*dns.Msg, error)
}
func (t *zapretAuditTransport) Start(adapter.StartStage) error { return nil }
func (t *zapretAuditTransport) Close() error { return nil }
func (t *zapretAuditTransport) Type() string { return "audit" }
func (t *zapretAuditTransport) Tag() string { return t.tag }
func (t *zapretAuditTransport) Dependencies() []string { return nil }
func (t *zapretAuditTransport) Reset() {}
func (t *zapretAuditTransport) Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error) {
t.calls++
return t.exchange(ctx, message)
}
func zapretRequireInherited(t *testing.T, ctx context.Context, who string) {
t.Helper()
if ctx.Value(zapretAuditKey{}) != "shared" {
t.Fatalf("%s transport lost the caller's context values", who)
}
if _, ok := ctx.Deadline(); !ok {
t.Fatalf("%s transport received a context without a deadline", who)
}
}
func TestZapretSequentialSuccessStopsAtFirstTransportAndKeepsContext(t *testing.T) {
shared := context.WithValue(context.Background(), zapretAuditKey{}, "shared")
query := new(dns.Msg).SetQuestion("example.com.", dns.TypeA)
want := new(dns.Msg).SetReply(query)
first := &zapretAuditTransport{tag: "first"}
first.exchange = func(ctx context.Context, _ *dns.Msg) (*dns.Msg, error) {
zapretRequireInherited(t, ctx, "first")
return want, nil
}
second := &zapretAuditTransport{tag: "second"}
second.exchange = func(context.Context, *dns.Msg) (*dns.Msg, error) {
t.Fatal("second transport ran after first success")
return nil, nil
}
strategy, err := CreateStrategy("sequential", []adapter.DNSTransport{first, second}, logger.NOP(), 0)
if err != nil {
t.Fatal(err)
}
response, err := strategy(shared, query)
if err != nil || response != want {
t.Fatalf("unexpected result: response=%p error=%v", response, err)
}
if first.calls != 1 || second.calls != 0 {
t.Fatalf("unexpected call counts: first=%d second=%d", first.calls, second.calls)
}
}
func TestZapretSequentialTransportErrorFallsBackWithInheritedContext(t *testing.T) {
shared := context.WithValue(context.Background(), zapretAuditKey{}, "shared")
query := new(dns.Msg).SetQuestion("example.com.", dns.TypeA)
want := new(dns.Msg).SetReply(query)
first := &zapretAuditTransport{tag: "first"}
first.exchange = func(ctx context.Context, _ *dns.Msg) (*dns.Msg, error) {
zapretRequireInherited(t, ctx, "first")
return nil, errors.New("transport failed")
}
second := &zapretAuditTransport{tag: "second"}
second.exchange = func(ctx context.Context, _ *dns.Msg) (*dns.Msg, error) {
zapretRequireInherited(t, ctx, "fallback")
return want, nil
}
strategy, err := CreateStrategy("", []adapter.DNSTransport{first, second}, logger.NOP(), 0)
if err != nil {
t.Fatal(err)
}
response, err := strategy(shared, query)
if err != nil || response != want {
t.Fatalf("unexpected result: response=%p error=%v", response, err)
}
if first.calls != 1 || second.calls != 1 {
t.Fatalf("unexpected call counts: first=%d second=%d", first.calls, second.calls)
}
}
func TestZapretSequentialHangNeverOutlivesSharedDeadline(t *testing.T) {
const budget = 80 * time.Millisecond
sharedTimeout, cancel := context.WithTimeout(context.Background(), budget)
defer cancel()
shared := context.WithValue(sharedTimeout, zapretAuditKey{}, "shared")
sharedDeadline, _ := sharedTimeout.Deadline()
query := new(dns.Msg).SetQuestion("example.com.", dns.TypeA)
hang := func(who string) func(context.Context, *dns.Msg) (*dns.Msg, error) {
return func(ctx context.Context, _ *dns.Msg) (*dns.Msg, error) {
zapretRequireInherited(t, ctx, who)
deadline, _ := ctx.Deadline()
if deadline.After(sharedDeadline) {
t.Fatalf("%s transport got a deadline past the caller's one", who)
}
<-ctx.Done()
return nil, ctx.Err()
}
}
first := &zapretAuditTransport{tag: "first"}
first.exchange = hang("first")
second := &zapretAuditTransport{tag: "second"}
second.exchange = hang("fallback")
strategy, err := CreateStrategy("sequential", []adapter.DNSTransport{first, second}, logger.NOP(), 0)
if err != nil {
t.Fatal(err)
}
started := time.Now()
response, err := strategy(shared, query)
if response != nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("unexpected result: response=%p error=%v", response, err)
}
if elapsed := time.Since(started); elapsed > budget+40*time.Millisecond {
t.Fatalf("sequential pass outlived the caller's deadline: %v", elapsed)
}
if first.calls != 1 || second.calls != 1 {
t.Fatalf("unexpected call counts: first=%d second=%d", first.calls, second.calls)
}
}
func TestZapretSequentialFinalRcodesStopWithoutFallback(t *testing.T) {
for _, rcode := range []int{dns.RcodeSuccess, dns.RcodeNameError} {
t.Run(dns.RcodeToString[rcode], func(t *testing.T) {
shared := context.WithValue(context.Background(), zapretAuditKey{}, "shared")
query := new(dns.Msg).SetQuestion("example.com.", dns.TypeA)
want := new(dns.Msg).SetReply(query)
want.Rcode = rcode
first := &zapretAuditTransport{tag: "first"}
first.exchange = func(ctx context.Context, _ *dns.Msg) (*dns.Msg, error) {
zapretRequireInherited(t, ctx, "first")
return want, nil
}
second := &zapretAuditTransport{tag: "second"}
second.exchange = func(context.Context, *dns.Msg) (*dns.Msg, error) {
t.Fatal("second transport ran after a final RCODE response")
return nil, nil
}
strategy, err := CreateStrategy("sequential", []adapter.DNSTransport{first, second}, logger.NOP(), 0)
if err != nil {
t.Fatal(err)
}
response, err := strategy(shared, query)
if err != nil || response == nil || response.Rcode != rcode {
t.Fatalf("unexpected result: response=%v error=%v", response, err)
}
if first.calls != 1 || second.calls != 0 {
t.Fatalf("unexpected call counts: first=%d second=%d", first.calls, second.calls)
}
})
}
}
func TestZapretSequentialServerFailureRcodesFallBack(t *testing.T) {
for _, rcode := range []int{dns.RcodeServerFailure, dns.RcodeRefused} {
t.Run(dns.RcodeToString[rcode], func(t *testing.T) {
shared := context.WithValue(context.Background(), zapretAuditKey{}, "shared")
query := new(dns.Msg).SetQuestion("example.com.", dns.TypeA)
bad := new(dns.Msg).SetReply(query)
bad.Rcode = rcode
want := new(dns.Msg).SetReply(query)
first := &zapretAuditTransport{tag: "first"}
first.exchange = func(ctx context.Context, _ *dns.Msg) (*dns.Msg, error) {
zapretRequireInherited(t, ctx, "first")
return bad, nil
}
second := &zapretAuditTransport{tag: "second"}
second.exchange = func(ctx context.Context, _ *dns.Msg) (*dns.Msg, error) {
zapretRequireInherited(t, ctx, "fallback")
return want, nil
}
strategy, err := CreateStrategy("sequential", []adapter.DNSTransport{first, second}, logger.NOP(), 0)
if err != nil {
t.Fatal(err)
}
response, err := strategy(shared, query)
if err != nil || response != want {
t.Fatalf("unexpected result: response=%v error=%v", response, err)
}
if first.calls != 1 || second.calls != 1 {
t.Fatalf("unexpected call counts: first=%d second=%d", first.calls, second.calls)
}
})
}
}