feat: прямой туннель без релея

This commit is contained in:
samosvalishe
2026-08-31 01:15:24 +03:00
parent fa9549e6e0
commit 2812ded2ed
18 changed files with 586 additions and 26 deletions
+48
View File
@@ -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<<EOF"
echo "${repo}:${version}"
# pre-release не двигает latest
case "${version}" in
*-*) ;;
*) echo "${repo}:latest" ;;
esac
echo "EOF"
} >> "$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
+5
View File
@@ -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:
+32
View File
@@ -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"]
+83
View File
@@ -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<NF;i++) if($i=="dev"){print $(i+1); exit}}')
if [ -n "$WAN" ] && [ -n "$NET" ]; then
iptables -A FORWARD -i "$IFACE" -j ACCEPT
iptables -A FORWARD -o "$IFACE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -t nat -A POSTROUTING -s "$NET" -o "$WAN" -j MASQUERADE
# -o WAN: ответный SYN-ACK из интернета несёт MSS 1460, режем по PMTU пути.
# -o IFACE: страховка от клиента, забывшего MTU.
iptables -t mangle -A FORWARD -o "$WAN" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
iptables -t mangle -A FORWARD -o "$IFACE" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss "$((MTU - 40))"
else
echo "ft-awg: WAN or subnet not detected (wan='$WAN' net='$NET'); NAT skipped" >&2
fi
echo "ft-awg: $IFACE up, addr $(echo "$ADDRS" | tr '\n' ' '), mtu $MTU, nat via ${WAN:-none}"
wait "$GO_PID"
+35
View File
@@ -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>`, а `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`):
+15 -6
View File
@@ -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
}
+44 -3
View File
@@ -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
+3 -3
View File
@@ -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 {
+5
View File
@@ -36,3 +36,8 @@ type Backend interface {
Down() error
Stats() (Stats, error)
}
// Rebinder реализует бэкенд с собственными сокетами
type Rebinder interface {
Rebind() error
}
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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} }},
+15 -5
View File
@@ -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)
}
+45 -3
View File
@@ -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)
}
}
}
+19
View File
@@ -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`:
+25 -2
View File
@@ -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)
}
}
}
+11
View File
@@ -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)
}
}
+91
View File
@@ -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
}
+106
View File
@@ -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)
}
}