mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
feat: прямой туннель без релея
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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`):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -36,3 +36,8 @@ type Backend interface {
|
||||
Down() error
|
||||
Stats() (Stats, error)
|
||||
}
|
||||
|
||||
// Rebinder реализует бэкенд с собственными сокетами
|
||||
type Rebinder interface {
|
||||
Rebind() error
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} }},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user