From 2812ded2ed4a6c2d9c0f44bf0e79e218a5a801dc Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Mon, 31 Aug 2026 01:15:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=82=D1=83=D0=BD=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BB=D0=B5=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 48 ++++++++++++ Taskfile.yml | 5 ++ docker/awg/Dockerfile | 32 ++++++++ docker/awg/start.sh | 83 ++++++++++++++++++++ docs/deploy.md | 35 +++++++++ internal/netctl/netctl.go | 21 +++-- internal/tunnel/awg/awg.go | 47 +++++++++++- internal/tunnel/config.go | 6 +- internal/tunnel/tunnel.go | 5 ++ internal/tunnel/uapi.go | 4 +- internal/tunnel/uapi_test.go | 4 +- internal/tunnel/wgconf/wgconf.go | 20 +++-- internal/tunnel/wgconf/wgconf_test.go | 48 +++++++++++- mobile/README.md | 19 +++++ mobile/api.go | 27 ++++++- mobile/protect.go | 11 +++ mobile/tunnel.go | 91 ++++++++++++++++++++++ mobile/tunnel_test.go | 106 ++++++++++++++++++++++++++ 18 files changed, 586 insertions(+), 26 deletions(-) create mode 100644 docker/awg/Dockerfile create mode 100644 docker/awg/start.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b36ee15..f478562 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,54 @@ jobs: dist/freeturn-sources.jar retention-days: 7 + awg-image: + name: AWG image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: docker/setup-qemu-action@v4.0.0 + + - uses: docker/setup-buildx-action@v4.1.0 + + - name: Log in to GHCR + uses: docker/login-action@v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve image tags + id: tags + run: | + version="${GITHUB_REF_NAME#v}" + repo="ghcr.io/${{ github.repository_owner }}/freeturn-awg" + { + echo "version=${version}" + echo "list<> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v6.20.0 + with: + context: . + file: docker/awg/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.tags.outputs.list }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ steps.tags.outputs.version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + goreleaser: name: GoReleaser runs-on: ubuntu-latest diff --git a/Taskfile.yml b/Taskfile.yml index e360161..d9f2e85 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -60,6 +60,11 @@ tasks: powershell -NoProfile -Command '$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:PATH = "$env:JAVA_HOME\bin;$env:PATH"; gomobile bind {{.GOMOBILE_FLAGS}} -o dist/freeturn.aar ./mobile' platforms: [windows] + docker:awg: + desc: Build the AWG service image locally (docker/awg) + cmds: + - docker build -f docker/awg/Dockerfile -t freeturn-awg:dev . + test: desc: Run unit tests with race detector cmds: diff --git a/docker/awg/Dockerfile b/docker/awg/Dockerfile new file mode 100644 index 0000000..197fdc0 --- /dev/null +++ b/docker/awg/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 +# Образ AWG-сервиса: amneziawg-go в форграунде + amneziawg-tools (awg, awg-quick). +ARG GO_VERSION=1.26.7 +ARG ALPINE_VERSION=3.23 +ARG AWG_GO_VERSION=v3.1.20260814 +ARG AWG_TOOLS_VERSION=v3.1.20260812 + +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS go +ARG AWG_GO_VERSION +ARG TARGETARCH +RUN apk add --no-cache git +ENV CGO_ENABLED=0 GOOS=linux + +RUN GOARCH=$TARGETARCH go install "github.com/amnezia-vpn/amneziawg-go/v3@${AWG_GO_VERSION}" \ + && install -D "$(find /go/bin -type f -name amneziawg-go | head -n1)" /out/amneziawg-go + +FROM alpine:${ALPINE_VERSION} AS tools +ARG AWG_TOOLS_VERSION +RUN apk add --no-cache git build-base linux-headers \ + && git clone --depth 1 --branch "${AWG_TOOLS_VERSION}" https://github.com/amnezia-vpn/amneziawg-tools.git /src \ + && make -C /src/src + +FROM alpine:${ALPINE_VERSION} +RUN apk add --no-cache bash iproute2 iptables +COPY --from=go /out/amneziawg-go /usr/bin/amneziawg-go +COPY --from=tools /src/src/wg /usr/bin/awg +COPY --from=tools /src/src/wg-quick/linux.bash /usr/bin/awg-quick +COPY docker/awg/start.sh /usr/local/bin/ft-awg-start +RUN sed -i 's/\r$//' /usr/local/bin/ft-awg-start \ + && chmod +x /usr/bin/amneziawg-go /usr/bin/awg /usr/bin/awg-quick /usr/local/bin/ft-awg-start + +ENTRYPOINT ["/usr/local/bin/ft-awg-start"] diff --git a/docker/awg/start.sh b/docker/awg/start.sh new file mode 100644 index 0000000..04b209d --- /dev/null +++ b/docker/awg/start.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# PID 1 контейнера AWG: демон в форграунде, чтобы его смерть роняла контейнер. +# `ft-awg-start sync` - применить правки conf к живому интерфейсу (docker exec с хоста). +set -euo pipefail + +CONF="${AWG_CONF:-/etc/awg/awg0.conf}" +IFACE="${AWG_IFACE:-awg0}" + +# Значения ключа из [Interface] (до первого [Peer]), по одному на строку. +_iface_vals() { + sed -n "/^[[:space:]]*\[Peer\]/q; s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$CONF" \ + | sed 's/[#;].*//' | tr -d ' \r' | tr ',' '\n' | grep -v '^$' || true +} + +_iface_val() { + _iface_vals "$1" | head -n1 +} + +_sync() { + awg-quick strip "$CONF" | awg syncconf "$IFACE" /dev/stdin +} + +if [ "${1:-}" = "sync" ]; then + _sync + exit 0 +fi + +[ -f "$CONF" ] || { echo "ft-awg: $CONF not found" >&2; exit 1; } + +ADDRS=$(_iface_vals Address) +MTU=$(_iface_val MTU) +[ -n "$ADDRS" ] || { echo "ft-awg: no Address in $CONF" >&2; exit 1; } +: "${MTU:=1280}" + +# В namespace контейнера форвардинг задаётся флагом docker --sysctl; здесь - страховка. +sysctl -qw net.ipv4.ip_forward=1 >/dev/null 2>&1 || true + +export WG_PROCESS_FOREGROUND=1 +# amneziawg-go знает только verbose/debug/error/silent, прочее для него = error. +export LOG_LEVEL="${AWG_LOG_LEVEL:-error}" +amneziawg-go -f "$IFACE" & +GO_PID=$! + +_shutdown() { + kill "$GO_PID" 2>/dev/null || true + wait "$GO_PID" 2>/dev/null || true + exit 0 +} +trap _shutdown TERM INT + +# UAPI-сокет появляется позже процесса - до него setconf не с кем говорить. +for _ in $(seq 1 100); do + [ -S "/var/run/amneziawg/$IFACE.sock" ] && break + kill -0 "$GO_PID" 2>/dev/null || { echo "ft-awg: daemon died on startup" >&2; wait "$GO_PID"; exit 1; } + sleep 0.1 +done + +_sync +# Без -4: конфиг может нести и v6. +for addr in $ADDRS; do + ip address add "$addr" dev "$IFACE" +done +ip link set mtu "$MTU" up dev "$IFACE" + +# Подсеть берём из connected-роута, а не из парсинга Address - маска любая. +# NAT только v4: masquerade для v6 требует ip6tables, его в образе нет. +NET=$(ip -4 route show dev "$IFACE" proto kernel scope link | awk '{print $1; exit}') +WAN=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i&2 +fi + +echo "ft-awg: $IFACE up, addr $(echo "$ADDRS" | tr '\n' ' '), mtu $MTU, nat via ${WAN:-none}" +wait "$GO_PID" diff --git a/docs/deploy.md b/docs/deploy.md index da3d9c8..3e169f6 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -101,6 +101,41 @@ openssl rand -hex 32 --- +## AWG-бэкенд в Docker + +Образ `freeturn-awg` - VPN-бэкенд для прокси: поднимает интерфейс AmneziaWG из конфига и раздаёт NAT наружу. + +1. Положите конфиг `awg-quick` в `/opt/awg/awg0.conf`. +2. Включите форвардинг на хосте: `sudo sysctl -w net.ipv4.ip_forward=1` +3. Добавьте службу в `docker-compose.yml`: + ```yaml + services: + awg: + image: ghcr.io/samosvalishe/freeturn-awg:latest + container_name: freeturn-awg + network_mode: "host" + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun + restart: unless-stopped + volumes: + - /opt/awg/awg0.conf:/etc/awg/awg0.conf:ro + ``` +4. В прокси укажите `CONNECT_ADDR=127.0.0.1:`, а `ListenPort/udp` откройте в файрволе. + +Применить правки конфига без перезапуска: `docker exec freeturn-awg ft-awg-start sync`. + +| Переменная | По умолчанию | Описание | +| --- | --- | --- | +| `AWG_CONF` | `/etc/awg/awg0.conf` | Путь к конфигу в контейнере | +| `AWG_IFACE` | `awg0` | Имя интерфейса | +| `AWG_LOG_LEVEL` | `error` | Логи демона: `error` \| `verbose` \| `silent` | + +NAT настраивается только для IPv4. + +--- + ## Настройка Файрвола Откройте внешний порт (сервер слушает по **UDP**, даже если `MODE=tcp`): diff --git a/internal/netctl/netctl.go b/internal/netctl/netctl.go index 01231a8..4bb8c76 100644 --- a/internal/netctl/netctl.go +++ b/internal/netctl/netctl.go @@ -1,19 +1,28 @@ // Package netctl предоставляет глобальный Control-хук для сокетов (VpnService.protect). package netctl -import "syscall" +import ( + "sync/atomic" + "syscall" +) -var control func(network, address string, c syscall.RawConn) error +type ControlFunc func(network, address string, c syscall.RawConn) error + +var control atomic.Pointer[ControlFunc] // SetControl регистрирует функцию защиты сокетов хоста (nil - no-op). -func SetControl(fn func(network, address string, c syscall.RawConn) error) { - control = fn +func SetControl(fn ControlFunc) { + if fn == nil { + control.Store(nil) + return + } + control.Store(&fn) } // Apply вызывается из net.Dialer и net.ListenConfig для защиты создаваемых сокетов. func Apply(network, address string, c syscall.RawConn) error { - if control != nil { - return control(network, address, c) + if fn := control.Load(); fn != nil { + return (*fn)(network, address, c) } return nil } diff --git a/internal/tunnel/awg/awg.go b/internal/tunnel/awg/awg.go index d2600bb..c39466d 100644 --- a/internal/tunnel/awg/awg.go +++ b/internal/tunnel/awg/awg.go @@ -3,6 +3,7 @@ package awg import ( "errors" + "fmt" "strconv" "strings" "sync" @@ -18,9 +19,10 @@ import ( type Deps struct { Bind conn.Bind Log logx.Logger + // Protect исключает сокеты bind из туннеля (VpnService.protect); nil - бэкенд поверх пайпа релея. + Protect func(fd int) } -// Backend управляет userspace WireGuard/AmneziaWG устройством. type Backend struct { deps Deps @@ -69,6 +71,8 @@ func (b *Backend) Up(cfg *tunnel.Config, tunFD int) error { return err } + b.protectBind(dev) + b.dev = dev mode := "wg" if cfg.Amnezia.Enabled() { @@ -78,7 +82,45 @@ func (b *Backend) Up(cfg *tunnel.Config, tunFD int) error { return nil } -// Down останавливает устройство и закрывает tun-дескриптор. +func (b *Backend) Rebind() error { + b.mu.Lock() + dev := b.dev + b.mu.Unlock() + + if dev == nil { + return nil + } + if err := dev.BindUpdate(); err != nil { + return fmt.Errorf("awg: bind update: %w", err) + } + b.protectBind(dev) + b.deps.Log.Infof("tunnel: rebound") + return nil +} + +func (b *Backend) protectBind(dev *device.Device) { + if b.deps.Protect == nil { + return + } + peek, ok := dev.Bind().(conn.PeekLookAtSocketFd) + if !ok { + b.deps.Log.Warnf("tunnel: bind does not expose socket fd, protect skipped") + return + } + protected := 0 + if fd, err := peek.PeekLookAtSocketFd4(); err == nil { + b.deps.Protect(fd) + protected++ + } + if fd, err := peek.PeekLookAtSocketFd6(); err == nil { + b.deps.Protect(fd) + protected++ + } + if protected == 0 { + b.deps.Log.Warnf("tunnel: no bind socket to protect") + } +} + func (b *Backend) Down() error { b.mu.Lock() dev := b.dev @@ -93,7 +135,6 @@ func (b *Backend) Down() error { return nil } -// Stats возвращает текущие счетчики трафика туннеля. func (b *Backend) Stats() (tunnel.Stats, error) { b.mu.Lock() dev := b.dev diff --git a/internal/tunnel/config.go b/internal/tunnel/config.go index 5837591..b119687 100644 --- a/internal/tunnel/config.go +++ b/internal/tunnel/config.go @@ -24,7 +24,7 @@ type Peer struct { PresharedKey Key AllowedIPs []netip.Prefix Endpoint string - Keepalive int + Keepalive string } type Config struct { @@ -95,8 +95,8 @@ func (c *Config) Validate() error { if len(p.AllowedIPs) == 0 { return fmt.Errorf("tunnel: peer[%d]: AllowedIPs is required", i) } - if p.Keepalive < 0 { - return fmt.Errorf("tunnel: peer[%d]: negative keepalive", i) + if err := ValidateRange(p.Keepalive); err != nil { + return fmt.Errorf("tunnel: peer[%d]: keepalive: %w", i, err) } } if c.MTU <= 0 { diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 4eb9509..b49e2ad 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -36,3 +36,8 @@ type Backend interface { Down() error Stats() (Stats, error) } + +// Rebinder реализует бэкенд с собственными сокетами +type Rebinder interface { + Rebind() error +} diff --git a/internal/tunnel/uapi.go b/internal/tunnel/uapi.go index 086c26f..ead775b 100644 --- a/internal/tunnel/uapi.go +++ b/internal/tunnel/uapi.go @@ -36,8 +36,8 @@ func UAPI(c *Config) (string, error) { } else { line("endpoint", EndpointSinglePeer) } - if p.Keepalive > 0 { - line("persistent_keepalive_interval", strconv.Itoa(p.Keepalive)) + if p.Keepalive != "" && p.Keepalive != "0" { + line("persistent_keepalive_interval", p.Keepalive) } line("replace_allowed_ips", "true") for _, ip := range p.AllowedIPs { diff --git a/internal/tunnel/uapi_test.go b/internal/tunnel/uapi_test.go index 1ee398b..43911ac 100644 --- a/internal/tunnel/uapi_test.go +++ b/internal/tunnel/uapi_test.go @@ -92,7 +92,7 @@ func TestUAPIFullPeer(t *testing.T) { cfg := minimalConfig() cfg.Peers[0].PresharedKey = testKey(0x03) cfg.Peers[0].Endpoint = "1.2.3.4:51820" - cfg.Peers[0].Keepalive = 25 + cfg.Peers[0].Keepalive = "25" cfg.Peers[0].AllowedIPs = []netip.Prefix{ netip.MustParsePrefix("10.0.0.0/24"), netip.MustParsePrefix("::/0"), @@ -144,7 +144,7 @@ func TestUAPIRejectsInvalidConfig(t *testing.T) { {"no public key", func(c *Config) { c.Peers[0].PublicKey = Key{} }}, {"no allowed ips", func(c *Config) { c.Peers[0].AllowedIPs = nil }}, {"zero mtu", func(c *Config) { c.MTU = 0 }}, - {"negative keepalive", func(c *Config) { c.Peers[0].Keepalive = -1 }}, + {"bad keepalive range", func(c *Config) { c.Peers[0].Keepalive = "35-25" }}, {"partial junk", func(c *Config) { c.Amnezia = AmneziaParams{Jc: 4} }}, {"junk range", func(c *Config) { c.Amnezia = AmneziaParams{Jc: 4, Jmin: 70, Jmax: 40} }}, {"negative padding", func(c *Config) { c.Amnezia = AmneziaParams{S1: -1} }}, diff --git a/internal/tunnel/wgconf/wgconf.go b/internal/tunnel/wgconf/wgconf.go index 1fb800e..66847a1 100644 --- a/internal/tunnel/wgconf/wgconf.go +++ b/internal/tunnel/wgconf/wgconf.go @@ -136,7 +136,7 @@ func applyInterface(cfg *tunnel.Config, key, value string) error { } setAmneziaRange(&cfg.Amnezia, key, value) case "randomtrailers", "disablecookies": - b, err := strconv.ParseBool(value) + b, err := parseBool(value) if err != nil { return fmt.Errorf("%s: %w", key, err) } @@ -151,6 +151,17 @@ func applyInterface(cfg *tunnel.Config, key, value string) error { return nil } +// AmneziaWG пишет булевы как on/off и yes/no. +func parseBool(value string) (bool, error) { + switch strings.ToLower(value) { + case "on", "yes": + return true, nil + case "off", "no": + return false, nil + } + return strconv.ParseBool(value) +} + func setAmneziaRange(p *tunnel.AmneziaParams, key, value string) { switch key { case "contentpaddingaddition": @@ -211,14 +222,13 @@ func applyPeer(peer *tunnel.Peer, key, value string) error { peer.Endpoint = value case "persistentkeepalive": if strings.EqualFold(value, "off") { - peer.Keepalive = 0 + peer.Keepalive = "" return nil } - seconds, err := strconv.Atoi(value) - if err != nil { + if err := tunnel.ValidateRange(value); err != nil { return fmt.Errorf("persistentkeepalive: %w", err) } - peer.Keepalive = seconds + peer.Keepalive = value default: return fmt.Errorf("unknown [Peer] key %q", key) } diff --git a/internal/tunnel/wgconf/wgconf_test.go b/internal/tunnel/wgconf/wgconf_test.go index 63ec5af..8682d72 100644 --- a/internal/tunnel/wgconf/wgconf_test.go +++ b/internal/tunnel/wgconf/wgconf_test.go @@ -78,7 +78,7 @@ PersistentKeepalive = 25 t.Errorf("DNS = %v, want two addresses", cfg.DNS) } peer := cfg.Peers[0] - if peer.PresharedKey.IsZero() || peer.Endpoint != "1.2.3.4:51820" || peer.Keepalive != 25 { + if peer.PresharedKey.IsZero() || peer.Endpoint != "1.2.3.4:51820" || peer.Keepalive != "25" { t.Errorf("peer = %+v", peer) } if len(peer.AllowedIPs) != 2 { @@ -155,8 +155,8 @@ func TestParseKeepaliveOff(t *testing.T) { if err != nil { t.Fatalf("Parse() error = %v", err) } - if cfg.Peers[0].Keepalive != 0 { - t.Errorf("Keepalive = %d, want 0", cfg.Peers[0].Keepalive) + if cfg.Peers[0].Keepalive != "" { + t.Errorf("Keepalive = %q, want empty", cfg.Peers[0].Keepalive) } } @@ -239,6 +239,36 @@ func TestParseAWG3Params(t *testing.T) { } } +// Конфиг в том виде, в каком его выдаёт AmneziaVPN: булевы on/off, keepalive диапазоном. +func TestParseAmneziaClientConf(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" + + "RandomTrailers = on\nDisableCookies = off\n\n" + + "[Peer]\nPublicKey = " + pubKey + "\nAllowedIPs = 0.0.0.0/0\n" + + "Endpoint = 1.2.3.4:55424\nPersistentKeepalive = 25-35\n" + + cfg, err := Parse(text) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if !cfg.Amnezia.RandomTrailers || cfg.Amnezia.DisableCookies { + t.Errorf("bool flags = %v/%v, want true/false", + cfg.Amnezia.RandomTrailers, cfg.Amnezia.DisableCookies) + } + if cfg.Peers[0].Keepalive != "25-35" { + t.Errorf("Keepalive = %q, want 25-35", cfg.Peers[0].Keepalive) + } + uapi, err := tunnel.UAPI(cfg) + if err != nil { + t.Fatalf("UAPI() error = %v", err) + } + if !strings.Contains(uapi, "persistent_keepalive_interval=25-35\n") { + t.Errorf("UAPI() = %s, want keepalive range passed through", uapi) + } +} + 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" @@ -255,3 +285,15 @@ func TestParseAWG3Errors(t *testing.T) { } } } + +func TestParseKeepaliveErrors(t *testing.T) { + for name, value := range map[string]string{ + "non-numeric": "soon", + "reversed": "35-25", + "broken bounds": "25-", + } { + if _, err := Parse(minimalConf() + "PersistentKeepalive = " + value + "\n"); err == nil { + t.Errorf("%s: Parse() = nil, want error", name) + } + } +} diff --git a/mobile/README.md b/mobile/README.md index a785d5c..573f324 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -23,6 +23,7 @@ task build:android ```go func Start(configJSON string) error // Запуск в режиме прокси func StartTunnel(configJSON string, tunFD int) error // Запуск прокси + WireGuard/AmneziaWG +func StartDirectTunnel(wgText string, mtu, tunFD int) error // WireGuard/AmneziaWG напрямую, без релея func Restart(configJSON string, tunFD int) error // Перезапуск с ожиданием остановки (tunFD 0 - без туннеля) func Stop() // Остановка сессии func GetState() *Snapshot // Метрики сессии (State, Rates, Streams) @@ -118,6 +119,24 @@ Mobile.startTunnel(configJson, pfd.dup().detachFd().toLong()) WireGuard общается с релеем через in-memory `netconn.PacketPipe` напрямую. Петля `127.0.0.1:9000` не используется, поэтому исключать приложение из VPN-маршрутов не требуется. Защите через `SetProtect` подлежат только сокеты релея. +### Прямой туннель (без релея) + +`StartDirectTunnel(wgText, mtu, tunFD)` поднимает тот же WireGuard/AmneziaWG, но пакеты идут прямо на сервер, минуя TURN. Конфиг - текст wg-quick, а не JSON: полей релея тут нет. + +```kotlin +pfd = builder.establish()!! +Mobile.startDirectTunnel(wgConf, 1376, pfd.dup().detachFd().toLong()) +``` + +Отличия от `StartTunnel`: + +* каждый `[Peer]` обязан нести `Endpoint` в виде `ip:port` - bind устройства не резолвит имена; +* `SetProtect` обязателен: сокеты открывает bind устройства, без защиты трафик заворачивается обратно в туннель; +* `GetState()` держит `connected` с первого handshake и уходит в `connecting`, только если handshake старше 180 с при идущем tx; `Streams`/`Total` всегда 1; +* `Reconnect()` пересоздаёт и заново защищает сокеты - этого хватает при смене сети, перезапуск не нужен; +* `Restart` работает только с JSON-конфигурацией: для смены параметров прямого туннеля нужен `Stop` и новый `StartDirectTunnel`; +* `mtu` идёт только в валидацию и логи: tun создаёт платформа, MTU для неё даёт `ParseTunnelConfig`. + ### Обработка событий Хост-приложение должно реализовать интерфейс `EventSink`: diff --git a/mobile/api.go b/mobile/api.go index a90884d..985a5a3 100644 --- a/mobile/api.go +++ b/mobile/api.go @@ -54,8 +54,15 @@ type live struct { done chan struct{} total int tunnel *tunnelParts + + rateMu sync.Mutex + prevTx int64 + prevRx int64 + prevAt time.Time } +func (l *live) direct() bool { return l.sess == nil } + type final struct { state string errMsg string @@ -82,6 +89,9 @@ type Snapshot struct { func GetState() *Snapshot { if l := current.Load(); l != nil { + if l.direct() { + return directSnapshot(l) + } s := l.sess.Snapshot() return &Snapshot{ State: string(s.Phase), @@ -156,15 +166,28 @@ func Stop() { // Wake форсирует пересоздание TURN-аллокаций при пробуждении устройства. func Wake() { - if l := current.Load(); l != nil { + if l := current.Load(); l != nil && !l.direct() { l.sess.Wake() } } // Reconnect пересоздаёт TURN-аллокации, не трогая туннель и tun-дескриптор. +// В прямом режиме вместо них пересоздаются сокеты bind. func Reconnect() { - if l := current.Load(); l != nil { + mu.Lock() + defer mu.Unlock() + l := current.Load() + if l == nil { + return + } + if !l.direct() { l.sess.Reconnect() + return + } + if r, ok := l.tunnel.backend.(tunnel.Rebinder); ok { + if err := r.Rebind(); err != nil { + coreLog().Warnf("%v", err) + } } } diff --git a/mobile/protect.go b/mobile/protect.go index 38d243a..ceddcb6 100644 --- a/mobile/protect.go +++ b/mobile/protect.go @@ -1,6 +1,7 @@ package mobile import ( + "sync/atomic" "syscall" "github.com/samosvalishe/free-turn-proxy/internal/netctl" @@ -12,13 +13,23 @@ type Protector interface { Protect(fd int) bool } +var protector atomic.Pointer[Protector] + // SetProtect устанавливает обработчик защиты сокетов хоста (nil - no-op). func SetProtect(p Protector) { if p == nil { + protector.Store(nil) netctl.SetControl(nil) return } + protector.Store(&p) netctl.SetControl(func(_, _ string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { p.Protect(int(fd)) }) }) } + +func protectFD(fd int) { + if p := protector.Load(); p != nil { + (*p).Protect(fd) + } +} diff --git a/mobile/tunnel.go b/mobile/tunnel.go index 223363f..40cfd78 100644 --- a/mobile/tunnel.go +++ b/mobile/tunnel.go @@ -175,3 +175,94 @@ func checkTunFD(fd int) error { } return nil } + +const directHandshakeStale = 180 * time.Second + +func StartDirectTunnel(wgText string, mtu int, tunFD int) error { + if err := checkTunFD(tunFD); err != nil { + return err + } + mu.Lock() + defer mu.Unlock() + if current.Load() != nil { + awg.CloseTUNFD(tunFD) + return errors.New("already running") + } + + cfg, err := directConfig(wgText, mtu) + if err != nil { + awg.CloseTUNFD(tunFD) + return err + } + + if protector.Load() == nil { + coreLog().Warnf("tunnel: SetProtect не задан - сокеты прямого туннеля не защищены") + } + + backend := awg.New(awg.Deps{Log: coreLog(), Protect: protectFD}) + + if err := backend.Up(cfg, tunFD); err != nil { + return fmt.Errorf("start direct tunnel: %w", err) + } + + done := make(chan struct{}) + close(done) + current.Store(&live{ + cancel: func() {}, + done: done, + tunnel: &tunnelParts{backend: backend}, + }) + finishing.Store(nil) + lastStop.Store(nil) + return nil +} + +func directConfig(wgText string, mtu int) (*tunnel.Config, error) { + cfg, err := wgconf.Parse(wgText) + if err != nil { + return nil, fmt.Errorf("parse tunnel config: %w", err) + } + if mtu > 0 { + cfg.MTU = mtu + } + for i := range cfg.Peers { + if _, err := netip.ParseAddrPort(cfg.Peers[i].Endpoint); err != nil { + return nil, fmt.Errorf("peer[%d]: Endpoint must be ip:port for a direct tunnel, got %q", i, cfg.Peers[i].Endpoint) + } + } + return cfg, nil +} + +func directSnapshot(l *live) *Snapshot { + st, err := l.tunnel.backend.Stats() + if err != nil { + return &Snapshot{State: StateError, ErrMsg: err.Error(), Streams: 1, Total: 1} + } + snap := &Snapshot{ + State: StateConnecting, + Streams: 1, + Total: 1, + TxTotal: st.TxBytes, + RxTotal: st.RxBytes, + } + if !st.LastHandshake.IsZero() { + snap.State = StateConnected + } + l.fillRates(snap) + if snap.State == StateConnected && snap.TxRate > 0 && time.Since(st.LastHandshake) > directHandshakeStale { + snap.State = StateConnecting + } + return snap +} + +func (l *live) fillRates(s *Snapshot) { + l.rateMu.Lock() + defer l.rateMu.Unlock() + + now := time.Now() + if dt := now.Sub(l.prevAt).Seconds(); !l.prevAt.IsZero() && dt > 0 { + s.TxRate = int64(float64(s.TxTotal-l.prevTx) / dt) + s.RxRate = int64(float64(s.RxTotal-l.prevRx) / dt) + } + l.prevTx, l.prevRx, l.prevAt = s.TxTotal, s.RxTotal, now +} diff --git a/mobile/tunnel_test.go b/mobile/tunnel_test.go index eab7529..ea1566b 100644 --- a/mobile/tunnel_test.go +++ b/mobile/tunnel_test.go @@ -8,6 +8,7 @@ import ( "net" "strings" "testing" + "time" "github.com/samosvalishe/free-turn-proxy/internal/config" "github.com/samosvalishe/free-turn-proxy/internal/logx" @@ -228,3 +229,108 @@ func TestStartTunnelRejectsTCPMode(t *testing.T) { t.Fatalf("StartTunnel() error = %v, want ErrTCPModeRequiresStart", err) } } + +func TestDirectConfigRequiresEndpoint(t *testing.T) { + noEndpoint := awgConf() + hostname := strings.Replace(wgConf(), "1.2.3.4:51820", "vpn.example.com:51820", 1) + for _, conf := range []string{noEndpoint, hostname} { + if _, err := directConfig(conf, 0); err == nil { + t.Fatal("directConfig() error = nil, want ip:port endpoint") + } + } + cfg, err := directConfig(wgConf(), 1376) + if err != nil { + t.Fatalf("directConfig() error = %v", err) + } + if cfg.MTU != 1376 { + t.Errorf("MTU = %d, want 1376", cfg.MTU) + } + if cfg.Peers[0].Endpoint != "1.2.3.4:51820" { + t.Errorf("Endpoint = %q, want kept", cfg.Peers[0].Endpoint) + } +} + +func TestStartDirectTunnelRejectsBadFD(t *testing.T) { + if err := StartDirectTunnel(wgConf(), 0, 0); err == nil { + t.Fatal("StartDirectTunnel() error = nil for fd 0") + } +} + +// stubBackend подменяет устройство: реальный tun в тестах не поднять. +type stubBackend struct { + stats tunnel.Stats + err error +} + +func (stubBackend) Up(*tunnel.Config, int) error { return nil } +func (stubBackend) Down() error { return nil } +func (s stubBackend) Stats() (tunnel.Stats, error) { return s.stats, s.err } + +func TestDirectSnapshotPhase(t *testing.T) { + stale := time.Now().Add(-2 * directHandshakeStale) + cases := []struct { + name string + backend stubBackend + prep func(*live) + want string + }{ + {"no handshake", stubBackend{}, nil, StateConnecting}, + {"fresh handshake", stubBackend{stats: tunnel.Stats{LastHandshake: time.Now()}}, nil, StateConnected}, + {"stale handshake idle", stubBackend{stats: tunnel.Stats{LastHandshake: stale}}, nil, StateConnected}, + { + "stale handshake with tx", + stubBackend{stats: tunnel.Stats{LastHandshake: stale, TxBytes: 2000}}, + func(l *live) { l.prevAt = time.Now().Add(-time.Second) }, + StateConnecting, + }, + {"stats error", stubBackend{err: errors.New("boom")}, nil, StateError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + l := &live{tunnel: &tunnelParts{backend: tc.backend}} + if tc.prep != nil { + tc.prep(l) + } + if got := directSnapshot(l).State; got != tc.want { + t.Errorf("State = %q, want %q", got, tc.want) + } + }) + } +} + +func TestDirectSnapshotErrorKeepsStreams(t *testing.T) { + l := &live{tunnel: &tunnelParts{backend: stubBackend{err: errors.New("boom")}}} + + s := directSnapshot(l) + + if s.Streams != 1 || s.Total != 1 { + t.Errorf("streams = %d/%d, want 1/1", s.Streams, s.Total) + } +} + +// Скорость берётся из дельты между опросами хоста. +func TestFillRatesUsesDelta(t *testing.T) { + l := &live{prevTx: 1000, prevRx: 500, prevAt: time.Now().Add(-2 * time.Second)} + s := &Snapshot{TxTotal: 3000, RxTotal: 1500} + + l.fillRates(s) + + if s.TxRate != 1000 || s.RxRate != 500 { + t.Errorf("rates = %d/%d, want 1000/500", s.TxRate, s.RxRate) + } + if l.prevTx != 3000 || l.prevRx != 1500 { + t.Errorf("prev = %d/%d, want 3000/1500", l.prevTx, l.prevRx) + } +} + +// Первый опрос не с чем сравнивать - скорость нулевая, а не мусорная. +func TestFillRatesFirstCall(t *testing.T) { + l := &live{} + s := &Snapshot{TxTotal: 3000, RxTotal: 1500} + + l.fillRates(s) + + if s.TxRate != 0 || s.RxRate != 0 { + t.Errorf("rates = %d/%d, want 0/0", s.TxRate, s.RxRate) + } +}