- 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
409 lines
14 KiB
Go
409 lines
14 KiB
Go
package ctrl
|
|
|
|
import (
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/maeneko/forgetting/internal/jsjson"
|
|
)
|
|
|
|
func nowFn() time.Time { return time.Now() }
|
|
|
|
type ob = jsjson.Object
|
|
type kv = jsjson.KV
|
|
|
|
func ok(v any) (result, error) { return result{200, v}, nil }
|
|
func created(v any) (result, error) { return result{201, v}, nil }
|
|
func (s *Server) routes() []route {
|
|
a := []func(*rctx) error{s.auth}
|
|
an := []func(*rctx) error{s.auth, validateName}
|
|
R := func(method, path string, lock bool, mw []func(*rctx) error, h func(*rctx) (result, error)) route {
|
|
segs := strings.Split(strings.Trim(path, "/"), "/")
|
|
return route{method: method, segs: segs, mw: mw, lock: lock, h: h}
|
|
}
|
|
|
|
return []route{
|
|
R("GET", "/health", false, nil, func(c *rctx) (result, error) {
|
|
st := s.interfaceStatus()
|
|
cfg := s.cfg()
|
|
status, word := 200, "ok"
|
|
awgSt := "ok"
|
|
if !st.Up {
|
|
status, word, awgSt = 503, "degraded", "down"
|
|
}
|
|
return result{status, ob{{"status", word}, {"server", cfg.ServerName}, {"ip", cfg.ServerIP}, {"gen", cfg.Gen},
|
|
{"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil
|
|
}),
|
|
|
|
R("POST", "/api/users", true, an, func(c *rctx) (result, error) {
|
|
name, _ := c.obj().Get2("name").(string)
|
|
if u, err := s.userGet(name); err != nil {
|
|
return result{}, err
|
|
} else if u != nil {
|
|
return result{}, httpErr(409, "Пользователь уже существует")
|
|
}
|
|
u, err := s.addUser(name)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return created(u)
|
|
}),
|
|
|
|
R("GET", "/api/users", true, a, func(c *rctx) (result, error) {
|
|
rows, err := s.db.Query("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''")
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
stats := s.getPeersData()
|
|
list := []any{}
|
|
for rows.Next() {
|
|
var n, ip, pub, vk, kg string
|
|
if err := rows.Scan(&n, &ip, &pub, &vk, &kg); err != nil {
|
|
rows.Close()
|
|
return result{}, err
|
|
}
|
|
ps := stats[pub]
|
|
list = append(list, ob{{"name", n}, {"ip", ip}, {"pub_key", pub}, {"vpn_key", vk}, {"key_gen", kg},
|
|
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}})
|
|
}
|
|
rows.Close()
|
|
return ok(ob{{"users", list}})
|
|
}),
|
|
|
|
R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) {
|
|
rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''")
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
stats := s.getPeersData()
|
|
list := []any{}
|
|
for rows.Next() {
|
|
var n, ip, pub, kg string
|
|
if err := rows.Scan(&n, &ip, &pub, &kg); err != nil {
|
|
rows.Close()
|
|
return result{}, err
|
|
}
|
|
ps := stats[pub]
|
|
list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online},
|
|
{"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)},
|
|
{"torrent", s.torrent.Seen(ip)}})
|
|
}
|
|
rows.Close()
|
|
return ok(ob{{"users", list}})
|
|
}),
|
|
|
|
// ⚠️ До «/api/users/:name», иначе тот примет «reissue» за имя пользователя.
|
|
R("POST", "/api/users/reissue", true, a, func(c *rctx) (result, error) {
|
|
r, err := s.reissueAll()
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(r)
|
|
}),
|
|
|
|
R("POST", "/api/users/:name", true, an, func(c *rctx) (result, error) {
|
|
u, err := s.userGet(c.params["name"])
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
if u == nil {
|
|
return result{}, httpErr(404, "Пользователь не найден")
|
|
}
|
|
return ok(u)
|
|
}),
|
|
|
|
R("DELETE", "/api/users/:name", true, an, func(c *rctx) (result, error) {
|
|
if err := s.removeUser(c.params["name"]); err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(ob{{"success", true}, {"name", c.params["name"]}})
|
|
}),
|
|
|
|
R("POST", "/api/peers", true, a, func(c *rctx) (result, error) {
|
|
owner, isStr := c.obj().Get2("owner").(string)
|
|
if !isStr || !reOwner.MatchString(owner) {
|
|
return result{}, httpErr(400, "Неверный owner")
|
|
}
|
|
pub, err := checkPubKeyAny(c.obj().Get2("pub_key"))
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
r, err := s.addPeer(pub, owner)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return created(r)
|
|
}),
|
|
|
|
R("GET", "/api/peers", true, a, func(c *rctx) (result, error) {
|
|
stats := s.getPeersData()
|
|
peers, err := s.peersAll()
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
list := []any{}
|
|
for _, p := range peers {
|
|
ps := stats[p.PubKey]
|
|
list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner},
|
|
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx},
|
|
{"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", s.torrent.Seen(p.IP)}})
|
|
}
|
|
return ok(ob{{"peers", list}})
|
|
}),
|
|
|
|
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять
|
|
// их после сбоя `awg set … remove` некому. Только чтение — панель сама решает, что делать.
|
|
R("GET", "/api/audit", true, a, func(c *rctx) (result, error) {
|
|
r := runCmd("awg", "show", s.iface, "dump")
|
|
if !r.ok() {
|
|
return result{}, httpErr(503, "awg show не ответил")
|
|
}
|
|
known, err := s.knownPubKeys()
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
now := float64(time.Now().Unix())
|
|
ghosts := []any{}
|
|
lines := strings.Split(r.Stdout, "\n")
|
|
for _, l := range lines[1:] {
|
|
f := strings.Split(l, "\t")
|
|
if f[0] == "" || known[f[0]] {
|
|
continue
|
|
}
|
|
num := func(i int) float64 {
|
|
if i < len(f) {
|
|
return orZero(jsNumber(f[i]))
|
|
}
|
|
return 0
|
|
}
|
|
hs := num(4)
|
|
ghosts = append(ghosts, ob{{"pub_key", f[0]}, {"lastHandshake", hs}, {"online", hs > 0 && now-hs < 180}, {"rx", num(5)}, {"tx", num(6)}})
|
|
}
|
|
return ok(ob{{"ghosts", ghosts}})
|
|
}),
|
|
|
|
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется:
|
|
// пир, который успел появиться в users/peers, не трогаем. Строк в базе у «призраков»
|
|
// нет — снимается только ядро.
|
|
R("POST", "/api/audit/clean", true, a, func(c *rctx) (result, error) {
|
|
arr, isArr := c.obj().Get2("pub_keys").([]any)
|
|
if !isArr || len(arr) > syncMax {
|
|
return result{}, httpErr(400, "Неверный список ключей")
|
|
}
|
|
keys, err := checkPubKeys(arr)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
removed, skipped := []string{}, []string{}
|
|
for _, k := range keys {
|
|
known, err := s.isKnownPub(k)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
skip := known
|
|
if !skip {
|
|
sp, err := s.getServerPublicKey()
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
kp := s.kernelPeers()
|
|
skip = k == sp || kp == nil || !kp[k]
|
|
}
|
|
if skip {
|
|
skipped = append(skipped, k)
|
|
} else if s.dropFromKernel(k) {
|
|
removed = append(removed, k)
|
|
} else {
|
|
skipped = append(skipped, k)
|
|
}
|
|
}
|
|
if len(removed) > 0 {
|
|
s.log.Warn("ghost peers removed from kernel", "count", len(removed), "removed", removed)
|
|
}
|
|
return ok(ob{{"removed", removed}, {"skipped", skipped}})
|
|
}),
|
|
|
|
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
|
|
R("PUT", "/api/peers/sync", true, a, func(c *rctx) (result, error) {
|
|
owner, isStr := c.obj().Get2("owner").(string)
|
|
if !isStr || !rePanelID.MatchString(owner) {
|
|
return result{}, httpErr(400, "Неверный owner")
|
|
}
|
|
arr, isArr := c.obj().Get2("keep").([]any)
|
|
if !isArr || len(arr) > syncMax {
|
|
return result{}, httpErr(400, "Неверный список ключей")
|
|
}
|
|
keep, err := checkPubKeys(arr)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
r, err := s.syncOwnedPeers(owner, keep)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(r)
|
|
}),
|
|
|
|
R("PUT", "/api/peers/:pub", true, a, func(c *rctx) (result, error) {
|
|
oldPub, err := pubKeyFromParam(c.params["pub"])
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
newPub, err := checkPubKeyAny(c.obj().Get2("pub_key"))
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
r, err := s.replacePeer(oldPub, newPub)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(r)
|
|
}),
|
|
|
|
R("POST", "/api/peers/:pub/psk", true, a, func(c *rctx) (result, error) {
|
|
pub, err := pubKeyFromParam(c.params["pub"])
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
r, err := s.rotatePeerPSK(pub)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(r)
|
|
}),
|
|
|
|
R("DELETE", "/api/peers/:pub", true, a, func(c *rctx) (result, error) {
|
|
pub, err := pubKeyFromParam(c.params["pub"])
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
if err := s.removePeer(pub); err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(ob{{"success", true}})
|
|
}),
|
|
|
|
// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf;
|
|
// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf.
|
|
R("GET", "/api/profile", true, a, func(c *rctx) (result, error) {
|
|
sp, err := s.getServerPublicKey()
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
cfg := s.cfg()
|
|
return ok(ob{{"name", cfg.ServerName}, {"endpoint", cfg.ServerIP + ":" + strconv.Itoa(cfg.ServerPort)},
|
|
{"server_pub", sp}, {"gen", cfg.Gen}, {"dns", []string{cfg.DNS1, cfg.DNS2}},
|
|
{"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}})
|
|
}),
|
|
|
|
R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) {
|
|
if err := s.restartAwg(); err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(ob{{"success", true}})
|
|
}),
|
|
|
|
// Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей. Ошибку отдаём
|
|
// текстом: панель показывает её оператору, а причины отказа (старый модуль, старое ядро,
|
|
// отказ ядра на подъёме) требуют разных действий.
|
|
R("POST", "/awg/upgrade", true, a, func(c *rctx) (result, error) {
|
|
if s.cfg().Gen == "3.1" {
|
|
return result{}, httpErr(409, "Сервер уже работает на AmneziaWG 3.1")
|
|
}
|
|
if blocker := s.checkGen31Support(); blocker != "" {
|
|
return result{}, httpErr(409, blocker)
|
|
}
|
|
r, err := s.upgradeToGen31()
|
|
if err != nil {
|
|
return result{}, httpErr(500, err.Error())
|
|
}
|
|
return ok(r)
|
|
}),
|
|
|
|
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не
|
|
// пропустит (и без PATCH: агент отвечает на него 400).
|
|
R("GET", "/api/shaping", false, a, func(c *rctx) (result, error) { return ok(s.shaping.Status()) }),
|
|
|
|
R("PUT", "/api/shaping", false, a, func(c *rctx) (result, error) {
|
|
b := c.obj()
|
|
enabled, e1 := b.Get2("enabled").(bool)
|
|
auto, e2 := b.Get2("auto").(bool)
|
|
if !e1 || !e2 {
|
|
return result{}, httpErr(400, "enabled и auto должны быть true/false")
|
|
}
|
|
mbit := func(v any, what string) (int, error) {
|
|
n := jsToNumber(v)
|
|
if math.IsNaN(n) || n != math.Trunc(n) || n < 0 || n > 100_000 {
|
|
return 0, httpErr(400, what+": целое число Мбит/с от 1 до 100000")
|
|
}
|
|
return int(n), nil
|
|
}
|
|
down, err := mbit(b.Get2("downMbit"), "Загрузка")
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
up, err := mbit(b.Get2("upMbit"), "Отдача")
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
if enabled && (down < 1 || up < 1) {
|
|
return result{}, httpErr(400, "Для включения укажите ширину канала в обе стороны")
|
|
}
|
|
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
|
|
if err := s.shaping.Update(ShapingSettings{Enabled: enabled, DownMbit: down, UpMbit: up, Auto: auto}); err != nil {
|
|
return result{}, httpErr(500, err.Error())
|
|
}
|
|
return ok(s.shaping.Status())
|
|
}),
|
|
|
|
// Блокировка BitTorrent. Тоже под /api/ и через PUT — чтобы проходила через awg-agent на ноде.
|
|
R("GET", "/api/torrent", false, a, func(c *rctx) (result, error) { return ok(s.torrent.Status()) }),
|
|
|
|
R("PUT", "/api/torrent", false, a, func(c *rctx) (result, error) {
|
|
enabled, isBool := c.obj().Get2("enabled").(bool)
|
|
if !isBool {
|
|
return result{}, httpErr(400, "enabled должен быть true/false")
|
|
}
|
|
// nft не нашёлся или ядро отказало: оператору нужна причина, а не 500.
|
|
if err := s.torrent.Update(TorrentSettings{Enabled: enabled}); err != nil {
|
|
return result{}, httpErr(500, err.Error())
|
|
}
|
|
return ok(s.torrent.Status())
|
|
}),
|
|
|
|
R("GET", "/api/metrics", false, a, func(c *rctx) (result, error) {
|
|
d, err := s.metricsCached()
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(d)
|
|
}),
|
|
|
|
R("POST", "/api/monitor/install", false, a, func(c *rctx) (result, error) {
|
|
if err := s.startMonitorJob("install"); err != nil {
|
|
return result{}, err
|
|
}
|
|
return result{202, ob{{"running", true}, {"action", "install"}}}, nil
|
|
}),
|
|
R("POST", "/api/monitor/remove", false, a, func(c *rctx) (result, error) {
|
|
if err := s.startMonitorJob("remove"); err != nil {
|
|
return result{}, err
|
|
}
|
|
return result{202, ob{{"running", true}, {"action", "remove"}}}, nil
|
|
}),
|
|
R("GET", "/api/monitor/job", false, a, func(c *rctx) (result, error) { return ok(s.monitorView()) }),
|
|
|
|
R("GET", "/awg/status", true, a, func(c *rctx) (result, error) { return ok(s.interfaceStatus()) }),
|
|
|
|
R("POST", "/awg/start", true, a, func(c *rctx) (result, error) {
|
|
st, err := s.startInterface()
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
return ok(st)
|
|
}),
|
|
}
|
|
}
|