Port awg-ui to Go: awg-control ui

internal/ui reimplements the awg-ui backend with the same HTTP surface; the
React frontend is unchanged and served from awg-ui/dist.

- web.go: Express-like router (express.json semantics, mounts, :params,
  HTML 400/404/413 pages, SPA fallback)
- server.go: JWT login with rate limit, API keys and their server sets,
  awg-ctrl client and proxy, /api/v1/users, delete hooks
- nodes.go: WSS hub for agents (pin, challenge-response, join strings),
  RPC with timeouts, health, /ui/nodes
- sub.go, audit.go, sub_admin.go: sen:// master keys and devices,
  per-server reconcile with tombstones and /api/peers/sync, signed /sub/v1
  listener, audit with two-pass confirmation and cleaning, panel and
  /api/v1/masterkeys routers
- metrics.go, monitor.go: /metrics exposition with tokens, /ui/monitor
- sen.go: link codec and protocol signatures, tested byte for byte against
  fixtures produced by awg-ui/sen.ts (tools/golden/sen.ts)

Differential bench (tools/diffbench/local-ui.sh, scenario-ui.ts,
compare-ui.mjs): 158 steps over every route group, including signed device
requests and a Go agent joining as a node, plus the resulting ui.db and
awg-ctrl state. TS and Go are identical after masking random values, across
repeated runs and with the race detector on.
This commit is contained in:
2026-10-14 13:27:24 +00:00
parent c2e9636f31
commit 70bb587216
18 changed files with 7017 additions and 0 deletions
+949
View File
@@ -0,0 +1,949 @@
// Package ui — awg-ui на Go (порт awg-ui/server.ts и модулей): веб-панель с JWT, прокси к
// awg-ctrl, публичный API /api/v1 по ключам, хаб нод (WSS), подписка sen:// и /metrics.
// Фронтенд тот же (React, собранный в dist/) — сервер его только раздаёт.
package ui
import (
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
// ── Журнал: как console.log / console.warn у Node-версии (CLI пишет оба потока в один файл) ──
func logf(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
func warnf(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
var nowFn = time.Now
func now() int64 { return nowFn().Unix() }
func envOr(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
// envNum — Number(process.env.X) || 0.
func envNum(key string) int {
f := jsToNumber(os.Getenv(key))
if f != f || f == 0 || f != float64(int(f)) {
return 0
}
return int(f)
}
// envFromFile — значение KEY=… из .env (первая строка, обрезанная).
func envFromFile(file, key string) string {
raw, err := os.ReadFile(file)
if err != nil {
return ""
}
m := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(.*)$`).FindSubmatch(raw)
if m == nil {
return ""
}
return strings.TrimSpace(string(m[1]))
}
// ── Клиент awg-ctrl ─────────────────────────────────────────────────────────
type CtrlResp struct {
Status int
Data any // разобранный JSON, иначе строка (как axios)
}
// Ctrl — запрос к awg-ctrl сервера: локальному по HTTP или к ноде по WSS. Ошибка — только
// когда ответа нет вовсе (сеть, нет ключа); статусы ≥ 400 приходят как есть.
type Ctrl func(method, path string, body any) (*CtrlResp, error)
// decodeLikeAxios — тело ответа так, как его отдал бы axios: JSON — значением, иначе строкой.
func decodeLikeAxios(raw []byte) any {
if len(raw) == 0 {
return ""
}
if v, err := jsjson.Decode(raw); err == nil {
return v
}
return string(raw)
}
type App struct {
port int
uiUser string
uiPass string
jwtSecret []byte
brand string
channel string
version string
ctrlURL string
baseDir string
internalKey ed25519.PrivateKey
httpLocal *http.Client
db *sql.DB
mu sync.Mutex
revoked map[string]bool
loginAttempts map[string]*rateEntry
nodes *Nodes
sub *Sub
metrics *Metrics
monitor *Monitor
}
// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса
// (CLI запускает сервис с cwd = <root>/awg-ui): там dist/, panel.id и ключи по умолчанию.
func New() (*App, error) {
cwd, _ := os.Getwd()
rootEnv := filepath.Join(cwd, "..", ".env")
pick := func(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
if v := envFromFile(rootEnv, key); v != "" {
return v
}
return def
}
a := &App{
port: envNum("PORT"),
uiUser: envOr("UI_USER", "admin"),
uiPass: os.Getenv("UI_PASS"),
brand: pick("BRAND", "Forgetting"),
channel: pick("CHANNEL", "Beta"),
version: pick("VERSION", ""),
ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"),
baseDir: cwd,
// awg-ctrl на loopback: системный прокси сюда не нужен.
httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}},
revoked: map[string]bool{},
loginAttempts: map[string]*rateEntry{},
}
if a.port == 0 {
a.port = 8080
}
// Как `process.env.JWT_SECRET ?? random` в Node: заданный (даже пустой) секрет берётся как есть.
if s, ok := os.LookupEnv("JWT_SECRET"); ok {
a.jwtSecret = []byte(s)
} else {
b := make([]byte, 32)
rand.Read(b)
a.jwtSecret = []byte(hex.EncodeToString(b))
}
keyFile := envOr("INTERNAL_AUTH_KEY_FILE", filepath.Join(cwd, "internal_auth_private.key"))
if k, err := iauth.LoadPrivate(keyFile); err == nil {
a.internalKey = k
} else {
warnf("ui: приватный ключ внутренней авторизации не найден: %s — запросы к awg-ctrl будут отклоняться", keyFile)
}
dbFile := os.Getenv("UI_DB_FILE")
if dbFile == "" {
dbFile = filepath.Join(cwd, "ui.db")
}
db, err := openUIDB(dbFile)
if err != nil {
return nil, err
}
a.db = db
if err := a.initAPIKeys(); err != nil {
return nil, err
}
if a.nodes, err = newNodes(a); err != nil {
return nil, err
}
if a.sub, err = newSub(a); err != nil {
return nil, err
}
if a.metrics, err = newMetrics(a); err != nil {
return nil, err
}
a.monitor = newMonitor(a)
a.nodes.BeforeDelete(a.apiKeysBeforeDelete)
a.nodes.AfterDelete(a.apiKeysAfterDelete)
return a, nil
}
func openUIDB(path string) (*sql.DB, error) {
dsn := "file:" + (&url.URL{Path: path}).EscapedPath() +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// Одно соединение: запросы и транзакции идут по очереди, как в однопоточном Node.
// Внутри tx() нельзя обращаться к a.db — только к переданному tx, иначе взаимоблокировка.
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// tx — uidb.transaction(fn)(): всё или ничего.
func (a *App) tx(fn func(tx *sql.Tx) error) error {
t, err := a.db.Begin()
if err != nil {
return err
}
if err := fn(t); err != nil {
t.Rollback()
return err
}
return t.Commit()
}
// ctrlLocal — запрос к awg-ctrl этой машины с внутренним токеном.
func (a *App) ctrlLocal(method, path string, body any) (*CtrlResp, error) {
if a.internalKey == nil {
return nil, fmt.Errorf("приватный ключ внутренней авторизации не загружен")
}
var rd *strings.Reader
if body != nil {
b, err := jsjson.Marshal(body)
if err != nil {
return nil, err
}
rd = strings.NewReader(string(b))
}
var req *http.Request
var err error
if rd != nil {
req, err = http.NewRequest(method, a.ctrlURL+path, rd)
} else {
req, err = http.NewRequest(method, a.ctrlURL+path, nil)
}
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-ui", nowFn()))
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpLocal.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := readAll(resp)
if err != nil {
return nil, err
}
return &CtrlResp{resp.StatusCode, decodeLikeAxios(raw)}, nil
}
// ── JWT панели (HS256) ──────────────────────────────────────────────────────
func tokenSig(token string) string {
if p := strings.Split(token, "."); len(p) > 2 {
return p[2]
}
return token
}
func (a *App) sign() string {
h := b64url.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
p := b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{{Key: "exp", Val: now() + 86400}}))
m := hmac.New(sha256.New, a.jwtSecret)
m.Write([]byte(h + "." + p))
return h + "." + p + "." + b64url.EncodeToString(m.Sum(nil))
}
func (a *App) verify(token string) bool {
a.mu.Lock()
rev := a.revoked[tokenSig(token)]
a.mu.Unlock()
if rev {
return false
}
parts := strings.Split(token, ".")
if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return false
}
m := hmac.New(sha256.New, a.jwtSecret)
m.Write([]byte(parts[0] + "." + parts[1]))
if !hmac.Equal(fromB64url(parts[2]), m.Sum(nil)) {
return false
}
v, err := jsjson.Decode(fromB64url(parts[1]))
if err != nil {
return false
}
exp, ok := field(v, "exp").(float64)
return ok && exp > float64(now())
}
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return h[7:]
}
return ""
}
func (a *App) requireAuth(c *Ctx) bool {
tok := bearer(c.R)
if tok == "" || !a.verify(tok) {
return c.Error(401, "Unauthorized")
}
return true
}
// ── Лимит входа ─────────────────────────────────────────────────────────────
const (
rateLimit = 5
rateWindow = 15 * time.Minute
)
type rateEntry struct {
count int
resetAt time.Time
}
func (a *App) checkRate(ip string) (bool, int) {
a.mu.Lock()
defer a.mu.Unlock()
t := nowFn()
e := a.loginAttempts[ip]
if e == nil || e.resetAt.Before(t) {
a.loginAttempts[ip] = &rateEntry{1, t.Add(rateWindow)}
return true, 0
}
if e.count >= rateLimit {
ms := e.resetAt.Sub(t).Milliseconds()
return false, int((ms + 999) / 1000)
}
e.count++
return true, 0
}
// ── Ключи внешнего API (/api/v1) ────────────────────────────────────────────
// Живут в собственной БД awg-ui: awg-ctrl про них ничего не знает. Открытое значение
// ключа не хранится — только SHA-256 и префикс для показа в панели.
type apiKeyRow struct {
ID int64
Label string
KeyHash string
Prefix string
ServerID int
CreatedAt int64
LastUsed sql.NullInt64
}
func (a *App) initAPIKeys() error {
_, err := a.db.Exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_used INTEGER
);
-- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах
-- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users,
-- всегда входит в набор.
CREATE TABLE IF NOT EXISTS api_key_servers (
key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
PRIMARY KEY (key_id, server_id)
);
-- Ключи со времён одного сервера: набор — их server_id.
INSERT OR IGNORE INTO api_key_servers (key_id, server_id)
SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers);
`)
return err
}
func scanKey(row interface{ Scan(...any) error }) (*apiKeyRow, error) {
k := &apiKeyRow{}
if err := row.Scan(&k.ID, &k.Label, &k.KeyHash, &k.Prefix, &k.ServerID, &k.CreatedAt, &k.LastUsed); err != nil {
return nil, err
}
return k, nil
}
const keyCols = "id, label, key_hash, prefix, server_id, created_at, last_used"
func (a *App) keyByID(q queryer, id int64) *apiKeyRow {
k, _ := scanKey(q.QueryRow("SELECT "+keyCols+" FROM api_keys WHERE id = ?", id))
return k
}
type queryer interface {
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
Exec(query string, args ...any) (sql.Result, error)
}
// ints — первая колонка всех строк.
func ints(q queryer, query string, args ...any) []int {
rows, err := q.Query(query, args...)
if err != nil {
return nil
}
defer rows.Close()
out := []int{}
for rows.Next() {
var n int
if rows.Scan(&n) == nil {
out = append(out, n)
}
}
return out
}
func (a *App) keyServers(q queryer, id int64) []int {
return ints(q, "SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id", id)
}
// setKeyServers — набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в
// наборе, иначе — наименьший из набора.
func (a *App) setKeyServers(id int64, ids []int) error {
row := a.keyByID(a.db, id)
if row == nil {
return nil
}
return a.tx(func(tx *sql.Tx) error {
if _, err := tx.Exec("DELETE FROM api_key_servers WHERE key_id = ?", id); err != nil {
return err
}
for _, sid := range ids {
if _, err := tx.Exec("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)", id, sid); err != nil {
return err
}
}
if !containsInt(ids, row.ServerID) {
_, err := tx.Exec("UPDATE api_keys SET server_id = ? WHERE id = ?", ids[0], id)
return err
}
return nil
})
}
func containsInt(s []int, v int) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// uniqueInts — [...new Set(v)] для массива из JSON: все элементы — целые серверы из реестра.
// Иначе — ok=false.
func (a *App) serverList(v any, exists func(int) bool) ([]int, bool) {
list := arr(v)
seen := map[float64]bool{}
var ids []int
for _, x := range list {
f, isNum := x.(float64)
if !isNum || !isInt(x) || !exists(int(f)) {
return nil, false
}
if !seen[f] {
seen[f] = true
ids = append(ids, int(f))
}
}
sort.Ints(ids)
return ids, true
}
// parseKeyServers — набор серверов из тела: непустой, без повторов, только существующие.
func (a *App) parseKeyServers(v any) ([]int, string) {
if l, ok := v.([]any); !ok || len(l) == 0 {
return nil, "Нужен хотя бы один сервер"
}
ids, ok := a.serverList(v, a.nodes.Exists)
if !ok {
return nil, "Такого сервера нет"
}
return ids, ""
}
func hashKey(key string) string {
s := sha256.Sum256([]byte(key))
return hex.EncodeToString(s[:])
}
func genAPIKey() (key, hash, prefix string) {
b := make([]byte, 24)
rand.Read(b)
key = "awgk_" + b64url.EncodeToString(b)
return key, hashKey(key), key[:13] + "…"
}
var reKeyLabel = regexp.MustCompile(`^[\w \-]{1,40}$`)
func intsJSON(s []int) []any {
out := make([]any, len(s))
for i, v := range s {
out[i] = v
}
return out
}
func nullInt(n sql.NullInt64) any {
if n.Valid {
return n.Int64
}
return nil
}
func (a *App) apiKeysList(c *Ctx) bool {
rows, err := a.db.Query("SELECT " + keyCols + " FROM api_keys ORDER BY id")
if err != nil {
return c.Error(500, "Internal server error")
}
var keys []*apiKeyRow
for rows.Next() {
if k, err := scanKey(rows); err == nil {
keys = append(keys, k)
}
}
rows.Close()
out := []any{}
for _, k := range keys {
out = append(out, jsjson.Object{
{Key: "id", Val: k.ID}, {Key: "label", Val: k.Label}, {Key: "prefix", Val: k.Prefix},
{Key: "server_id", Val: k.ServerID}, {Key: "created_at", Val: k.CreatedAt}, {Key: "last_used", Val: nullInt(k.LastUsed)},
{Key: "servers", Val: intsJSON(a.keyServers(a.db, k.ID))},
})
}
return c.JSON(200, jsjson.Object{{Key: "keys", Val: out}})
}
// jsString — String(v) для меток: строка, число или логическое значение. Остальное — не метка.
func jsString(v any) (string, bool) {
switch x := v.(type) {
case string:
return x, true
case float64:
return jsjson.Number(x), true
case bool:
return strconv.FormatBool(x), true
}
return "", false
}
// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию.
func (a *App) apiKeysCreate(c *Ctx) bool {
b := c.Obj()
label, labelOK := jsString(b.Get2("label"))
if !truthy(b.Get2("label")) || !labelOK || !reKeyLabel.MatchString(label) {
return c.Error(400, "Метка: буквы, цифры, пробел, _ и -, до 40 символов")
}
// server_id === undefined в JSON — только отсутствие ключа; null — это Number(null) = 0.
sidRaw, sidPresent := b.Get("server_id")
var serversV any
if s := b.Get2("servers"); s != nil {
serversV = s
} else if !sidPresent {
serversV = []any{float64(a.nodes.DefaultID())}
} else {
serversV = []any{jsToNumberAny(sidRaw)}
}
ids, msg := a.parseKeyServers(serversV)
if msg != "" {
return c.Error(400, msg)
}
sid := ids[0]
if sidPresent {
if f := jsToNumberAny(sidRaw); f == float64(int(f)) && containsInt(ids, int(f)) {
sid = int(f)
}
}
key, hash, prefix := genAPIKey()
var id int64
err := a.tx(func(tx *sql.Tx) error {
r, err := tx.Exec("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)",
label, hash, prefix, sid, now())
if err != nil {
return err
}
id, _ = r.LastInsertId()
for _, s := range ids {
if _, err := tx.Exec("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)", id, s); err != nil {
return err
}
}
return nil
})
if err != nil {
return c.Error(500, "Internal server error")
}
// Открытое значение отдаётся один раз — дальше в БД только хэш.
return c.JSON(201, jsjson.Object{{Key: "id", Val: id}, {Key: "label", Val: b.Get2("label")}, {Key: "prefix", Val: prefix},
{Key: "key", Val: key}, {Key: "server_id", Val: sid}, {Key: "servers", Val: intsJSON(ids)}})
}
// paramID — Number(req.params.id): ok, если целое.
func paramID(s string) (int64, bool) {
f := jsToNumber(s)
if f != f || f != float64(int64(f)) {
return 0, false
}
return int64(f), true
}
func (a *App) apiKeysPatch(c *Ctx) bool {
id, ok := paramID(c.Params["id"])
if !ok || a.keyByID(a.db, id) == nil {
return c.Error(404, "Не найден")
}
ids, msg := a.parseKeyServers(c.Obj().Get2("servers"))
if msg != "" {
return c.Error(400, msg)
}
if err := a.setKeyServers(id, ids); err != nil {
return c.Error(500, "Internal server error")
}
row := a.keyByID(a.db, id)
return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "server_id", Val: row.ServerID}, {Key: "servers", Val: intsJSON(ids)}})
}
func (a *App) apiKeysDelete(c *Ctx) bool {
id, ok := paramID(c.Params["id"])
if !ok {
return c.Error(400, "Неверный id")
}
a.tx(func(tx *sql.Tx) error {
tx.Exec("DELETE FROM api_key_servers WHERE key_id = ?", id)
_, err := tx.Exec("DELETE FROM api_keys WHERE id = ?", id)
return err
})
return c.JSON(200, jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: id}})
}
// Сервер удаляют из реестра: ключ API, для которого он единственный, — повод отказать
// (ключ остался бы без серверов); у остальных он убирается из набора, когда сервер удалён.
type keyOnServer struct {
id int64
label string
serverID int
n int
}
func (a *App) keysOnServer(id int) []keyOnServer {
rows, err := a.db.Query("SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?", id)
if err != nil {
return nil
}
defer rows.Close()
var out []keyOnServer
for rows.Next() {
var k keyOnServer
if rows.Scan(&k.id, &k.label, &k.serverID, &k.n) == nil {
out = append(out, k)
}
}
return out
}
func (a *App) apiKeysBeforeDelete(id int, _ bool) *deleteVeto {
var only []string
for _, k := range a.keysOnServer(id) {
if k.n == 1 {
only = append(only, k.label)
}
}
if len(only) > 0 {
return &deleteVeto{Error: "Это единственный сервер ключей API: " + strings.Join(only, ", ") + " — сначала удалите их или добавьте им другой сервер"}
}
return nil
}
func (a *App) apiKeysAfterDelete(id int) {
for _, k := range a.keysOnServer(id) {
var rest []int
for _, s := range a.keyServers(a.db, k.id) {
if s != id {
rest = append(rest, s)
}
}
a.setKeyServers(k.id, rest)
}
}
// ── Внешний API: ключ и сервер ──────────────────────────────────────────────
func (a *App) requireAPIKey(c *Ctx) bool {
key := c.R.Header.Get("X-Api-Key")
if !strings.HasPrefix(key, "awgk_") {
return c.Error(401, "API key required")
}
row, err := scanKey(a.db.QueryRow("SELECT "+keyCols+" FROM api_keys WHERE key_hash = ?", hashKey(key)))
if err != nil {
return c.Error(401, "Invalid API key")
}
a.db.Exec("UPDATE api_keys SET last_used = ? WHERE id = ?", now(), row.ID)
c.apiKey = row
c.servers = a.keyServers(a.db, row.ID)
return true
}
// pickKeyServer — сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка —
// сервер ключа по умолчанию. Сервер вне набора — как несуществующий.
func (a *App) pickKeyServer(c *Ctx) bool {
id := c.apiKey.ServerID
if raw, ok := c.R.Header["X-Server-Id"]; ok {
f := jsToNumber(strings.Join(raw, ", "))
if f != float64(int(f)) {
return c.Error(404, "Сервер не найден")
}
id = int(f)
}
if !containsInt(c.servers, id) {
return c.Error(404, "Сервер не найден")
}
c.serverID = id
return true
}
// serverIDOf — сервер запроса панели: X-Server-Id (0 — локальный awg-ctrl), без него — по умолчанию.
func (a *App) serverIDOf(c *Ctx) int {
raw, ok := c.R.Header["X-Server-Id"]
if !ok {
return a.nodes.DefaultID()
}
f := jsToNumber(strings.Join(raw, ", "))
if f == float64(int(f)) && f >= 0 {
return int(f)
}
return a.nodes.DefaultID()
}
func (a *App) proxy(c *Ctx) bool {
r, err := a.nodes.CtrlFor(a.serverIDOf(c))(c.R.Method, c.R.RequestURI, c.Body)
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
return c.JSON(r.Status, r.Data)
}
// extUser — внешний контракт: наружу только name/ip/gen/vpn_key, psk_key и pub_key остаются внутри.
func extUser(d any) jsjson.Object {
o := jsjson.Object{}
for _, k := range [][2]string{{"name", "name"}, {"ip", "ip"}, {"gen", "key_gen"}, {"vpn_key", "vpn_key"}} {
if hasField(d, k[1]) { // undefined JSON.stringify опускает
o = append(o, jsjson.KV{Key: k[0], Val: field(d, k[1])})
}
}
return o
}
func hasField(v any, k string) bool {
o, ok := v.(jsjson.Object)
return ok && has(o, k)
}
func (a *App) extCtrl(c *Ctx) Ctrl { return a.nodes.CtrlFor(c.serverID) }
// unavailable — ответ, когда awg-ctrl не ответил (как wrap() в Node-версии).
func unavailable(c *Ctx) bool { return c.Error(502, "awg-ctrl недоступен") }
func (a *App) extUsersCreate(c *Ctx) bool {
body := jsjson.Object{}
if v, ok := c.Obj().Get("name"); ok {
body = append(body, jsjson.KV{Key: "name", Val: v})
}
r, err := a.extCtrl(c)("POST", "/api/users", body)
if err != nil {
return unavailable(c)
}
if r.Status >= 400 {
return c.JSON(r.Status, r.Data)
}
return c.JSON(201, extUser(r.Data))
}
func (a *App) extUsersList(c *Ctx) bool {
r, err := a.extCtrl(c)("GET", "/api/users/stats", nil)
if err != nil {
return unavailable(c)
}
return c.JSON(r.Status, r.Data)
}
func (a *App) extUsersGet(c *Ctx) bool {
r, err := a.extCtrl(c)("POST", "/api/users/"+encodeURIComponent(c.Params["name"]), nil)
if err != nil {
return unavailable(c)
}
if r.Status >= 400 {
return c.JSON(r.Status, r.Data)
}
return c.JSON(200, extUser(r.Data))
}
func (a *App) extUsersDelete(c *Ctx) bool {
r, err := a.extCtrl(c)("DELETE", "/api/users/"+encodeURIComponent(c.Params["name"]), nil)
if err != nil {
return unavailable(c)
}
return c.JSON(r.Status, r.Data)
}
// ── Маршруты ────────────────────────────────────────────────────────────────
func (a *App) login(c *Ctx) bool {
ip := c.remoteIP()
if ok, retry := a.checkRate(ip); !ok {
return c.Error(429, fmt.Sprintf("Слишком много попыток. Повтори через %d сек.", retry))
}
b := c.Obj()
user, uok := b.Get2("user").(string)
pass, pok := b.Get2("pass").(string)
if a.uiPass == "" || !uok || !pok || user != a.uiUser || pass != a.uiPass {
return c.Error(401, "Неверный логин или пароль")
}
a.mu.Lock()
delete(a.loginAttempts, ip)
a.mu.Unlock()
return c.JSON(200, jsjson.Object{{Key: "token", Val: a.sign()}})
}
func (a *App) logout(c *Ctx) bool {
a.mu.Lock()
a.revoked[tokenSig(bearer(c.R))] = true
a.mu.Unlock()
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}})
}
func (a *App) routes() *Router {
app := newRouter()
auth := a.requireAuth
app.Post("/login", a.login)
app.Post("/logout", auth, a.logout)
// Единственный роут без авторизации: вордмарк нужен уже на экране логина.
app.Get("/ui/brand", func(c *Ctx) bool {
return c.JSON(200, jsjson.Object{{Key: "brand", Val: a.brand}, {Key: "channel", Val: a.channel}, {Key: "version", Val: a.version}})
})
app.Get("/ui/apikeys", auth, a.apiKeysList)
app.Post("/ui/apikeys", auth, a.apiKeysCreate)
app.Patch("/ui/apikeys/:id", auth, a.apiKeysPatch)
app.Delete("/ui/apikeys/:id", auth, a.apiKeysDelete)
// ВАЖНО: /api/v1 — до JWT-прокси на /api, иначе прокси потребует JWT вместо ключа.
app.Use("/api/v1", a.requireAPIKey)
app.Use("/api/v1/users", a.pickKeyServer)
app.Post("/api/v1/users", a.extUsersCreate)
app.Get("/api/v1/users", a.extUsersList)
app.Get("/api/v1/users/:name", a.extUsersGet)
app.Delete("/api/v1/users/:name", a.extUsersDelete)
app.Mount("/api/v1/masterkeys", a.sub.apiRouter(func(c *Ctx) []int { return c.servers }))
app.Use("/ui/nodes", auth)
app.Mount("/ui/nodes", a.nodes.router())
app.Use("/ui/masterkeys", auth)
app.Mount("/ui/masterkeys", a.sub.masterKeysRouter())
app.Use("/ui/devices", auth)
app.Mount("/ui/devices", a.sub.devicesRouter())
// Prometheus: /metrics за отдельным токеном «только метрики» (не JWT и не ключ API).
app.Use("/ui/metrics-token", auth)
app.Mount("/ui/metrics-token", a.metrics.router())
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
app.Use("/ui/monitor", auth)
app.Mount("/ui/monitor", a.monitor.router())
for _, p := range []string{"/api", "/health", "/awg"} {
app.Use(p, auth, a.proxy)
}
app.Use("/", a.static)
return app
}
// static — express.static(dist) и SPA-фолбэк app.get("*") → dist/index.html.
func (a *App) static(c *Ctx) bool {
if c.R.Method != "GET" && c.R.Method != "HEAD" {
return true
}
dist := filepath.Join(a.baseDir, "dist")
p, err := url.PathUnescape(c.R.URL.EscapedPath())
if err == nil && !strings.Contains(p, "\x00") {
clean := filepath.Clean("/" + p)
hidden := false
for _, s := range strings.Split(clean, "/") {
if strings.HasPrefix(s, ".") {
hidden = true
}
}
f := filepath.Join(dist, clean)
if st, err := os.Stat(f); err == nil && !hidden {
if st.IsDir() {
if !strings.HasSuffix(c.R.URL.Path, "/") {
http.Redirect(c.W, c.R, c.R.URL.Path+"/", http.StatusMovedPermanently)
return false
}
f = filepath.Join(f, "index.html")
}
if st, err := os.Stat(f); err == nil && !st.IsDir() {
serveFile(c.W, c.R, f)
return false
}
}
}
index := filepath.Join(dist, "index.html")
if _, err := os.Stat(index); err != nil {
writeHTMLError(c.W, 404, "Error: ENOENT: no such file or directory, stat '"+index+"'")
return false
}
serveFile(c.W, c.R, index)
return false
}
func serveFile(w http.ResponseWriter, r *http.Request, f string) {
fh, err := os.Open(f)
if err != nil {
writeHTMLError(w, 404, "Not Found")
return
}
defer fh.Close()
st, _ := fh.Stat()
w.Header().Set("Cache-Control", "public, max-age=0")
http.ServeContent(w, r, st.Name(), st.ModTime(), fh)
}
// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку).
func (a *App) Run() error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port))
if err != nil {
return err
}
srv := &http.Server{Handler: a.routes(), ReadHeaderTimeout: 30 * time.Second}
logf("ui: listening on :%d", a.port)
a.nodes.Start()
a.sub.Start()
return srv.Serve(ln)
}