Files
forgetting/internal/ui/server.go
T
maeneko 70bb587216 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.
2026-10-14 13:27:24 +00:00

950 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}