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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user