- awg-ctrl: optional nft table awg_torrent (GET/PUT /api/torrent, off by default). A connection is judged by its first packets, like Xray's sniffing: DHT, UDP trackers, uTP SYN, and with @ih support the plain TCP handshake and HTTP announce. A match marks the connection in conntrack and drops it whole; without @ih only the UDP part is used - devices that tried are kept in a 10-minute set and flagged as "torrent" in user and device stats; with shaping on they sit in the background class while the flag lasts - metrics: awg_torrent_block_enabled/active, awg_torrent_blocked_total, awg_torrent_devices and per-peer awg_peer_torrent - panel: "Торренты" section in server settings, "торренты" chip on users and devices; Save sends only what changed
190 lines
6.3 KiB
Go
190 lines
6.3 KiB
Go
// Package ctrl — awg-ctrl: привилегированный REST API, который управляет интерфейсом
|
|
// AmneziaWG (`awg`, `awg-quick`, `ip`, `tc`, `nft`) и базой пользователей users.db.
|
|
// Слушает только 127.0.0.1; снаружи до него добирается лишь awg-ui (и агент ноды, и CLI)
|
|
// с подписанным Ed25519-токеном.
|
|
package ctrl
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"database/sql"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/maeneko/forgetting/internal/iauth"
|
|
"github.com/maeneko/forgetting/internal/logx"
|
|
)
|
|
|
|
// Config — то, что в Node-версии лежало в CONFIG. Параметры и поколение меняются при
|
|
// restartAwg/переходе на 3.1 — читать через Server.cfg().
|
|
type Config struct {
|
|
ServerIP string
|
|
ServerPort int
|
|
ServerName string
|
|
DNS1, DNS2 string
|
|
MTU int
|
|
Keepalive string
|
|
Subnet string
|
|
Params Params
|
|
Gen string
|
|
AwgModule string
|
|
AwgTools string
|
|
}
|
|
|
|
type Server struct {
|
|
iface string
|
|
confDir string
|
|
dbPath string
|
|
serverPrivKeyFile string
|
|
port int
|
|
|
|
db *sql.DB
|
|
log *logx.Logger
|
|
pub ed25519.PublicKey
|
|
|
|
// mu делает последовательности «БД + awg + awg1.conf» неделимыми. В Node весь код между
|
|
// await атомарен (один поток); в Go без этого два запроса перемешали бы шаги.
|
|
mu sync.Mutex
|
|
cfgMu sync.RWMutex
|
|
c Config
|
|
|
|
shaping *Shaping
|
|
torrent *Torrent
|
|
metrics metricsCache
|
|
monitor monitorJob
|
|
cliPath string
|
|
cliLog string
|
|
}
|
|
|
|
func (s *Server) cfg() Config {
|
|
s.cfgMu.RLock()
|
|
defer s.cfgMu.RUnlock()
|
|
return s.c
|
|
}
|
|
|
|
func (s *Server) keyEnv() keyEnv {
|
|
c := s.cfg()
|
|
return keyEnv{P: c.Params, ServerIP: c.ServerIP, ServerPort: c.ServerPort, ServerName: c.ServerName,
|
|
DNS1: c.DNS1, DNS2: c.DNS2, MTU: c.MTU, Keepalive: c.Keepalive, Gen: c.Gen, Subnet: c.Subnet}
|
|
}
|
|
|
|
func envOr(key, def string) string {
|
|
if v, ok := os.LookupEnv(key); ok {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func getLocalIP() string {
|
|
r := runCmd("ip", "route", "show", "default")
|
|
m := regexp.MustCompile(`dev\s+(\S+)`).FindStringSubmatch(r.Stdout)
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
a := runCmd("ip", "addr", "show", m[1])
|
|
if m := regexp.MustCompile(`inet\s+([\d.]+)`).FindStringSubmatch(a.Stdout); m != nil {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// New открывает базу и ключи, читает параметры из awg1.conf. Не слушает порт.
|
|
func New() (*Server, error) {
|
|
s := &Server{
|
|
iface: "awg1",
|
|
confDir: envOr("AWG_CONF_DIR", "/etc/amnezia/amneziawg"),
|
|
serverPrivKeyFile: envOr("AWG_SERVER_PRIV_KEY_FILE", "/etc/amnezia/server_private.key"),
|
|
cliPath: envOr("AWG_CLI", "/usr/local/bin/awg-ctrl"),
|
|
}
|
|
s.dbPath = envOr("AWG_DB_PATH", filepath.Join(s.confDir, "users.db"))
|
|
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
|
if port == 0 {
|
|
port = 3005
|
|
}
|
|
s.port = port
|
|
cwd, _ := os.Getwd()
|
|
s.log = logx.New(os.Stdout, filepath.Join(cwd, "logs"))
|
|
s.cliLog = envOr("MONITOR_LOG", filepath.Join(filepath.Dir(cwd), "logs", "monitor-job.log"))
|
|
|
|
pubFile := envOr("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")
|
|
pub, err := iauth.LoadPublic(pubFile)
|
|
if err != nil {
|
|
s.log.Error("FATAL: публичный ключ внутренней авторизации не найден: " + pubFile)
|
|
return nil, fmt.Errorf("FATAL: публичный ключ внутренней авторизации не найден: %s", pubFile)
|
|
}
|
|
s.pub = pub
|
|
|
|
db, err := openDB(s.dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.db = db
|
|
|
|
// Адрес/порт/имя: БД, затем env, затем (для адреса) маршрут по умолчанию. Как в Node,
|
|
// запасной адрес вычисляется всегда, даже если в БД уже есть значение.
|
|
fallbackIP := ""
|
|
if v, ok := os.LookupEnv("SERVER_IP"); ok {
|
|
fallbackIP = v
|
|
} else {
|
|
fallbackIP = getLocalIP()
|
|
}
|
|
ip := s.getCfg("serverIp", fallbackIP)
|
|
portStr := s.getCfg("serverPort", envOr("SERVER_PORT", "51820"))
|
|
name := s.getCfg("serverName", envOr("SERVER_NAME", "VPN"))
|
|
if ip == "" {
|
|
return nil, fmt.Errorf("SERVER_IP не задан — передай через env при первом запуске")
|
|
}
|
|
s.setCfg("serverIp", ip)
|
|
s.setCfg("serverPort", portStr)
|
|
s.setCfg("serverName", name)
|
|
sp, _ := strconv.Atoi(portStr)
|
|
|
|
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
|
|
gen := params.Gen()
|
|
module, tools := readVersions()
|
|
s.c = Config{
|
|
ServerIP: ip, ServerPort: sp, ServerName: name,
|
|
DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: 1376, Subnet: "10.9",
|
|
Keepalive: keepaliveByGen[gen], Params: params, Gen: gen,
|
|
AwgModule: module, AwgTools: tools,
|
|
}
|
|
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
|
|
s.shaping = newShaping(s)
|
|
s.torrent = newTorrent(s)
|
|
return s, nil
|
|
}
|
|
|
|
// Run слушает 127.0.0.1:PORT и выполняет стартовую последовательность Node-версии:
|
|
// listen → ensureInterfaceUp → syncPeers → шейпинг. Нет интерфейса — завершаемся с ошибкой:
|
|
// health-gate CLI опирается на это (без поднятого awg1 awg-ctrl не нужен).
|
|
func (s *Server) Run() error {
|
|
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 15 * time.Second}
|
|
errc := make(chan error, 1)
|
|
go func() { errc <- srv.Serve(ln) }()
|
|
c := s.cfg()
|
|
s.log.Info("Server started", "port", s.port, "host", "127.0.0.1", "serverName", c.ServerName, "serverIp", c.ServerIP)
|
|
|
|
if err := s.ensureInterfaceUp(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.syncPeers(); err != nil {
|
|
return err
|
|
}
|
|
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
|
|
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
|
|
go s.shaping.Apply()
|
|
s.shaping.Start()
|
|
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
|
|
return <-errc
|
|
}
|