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.
469 lines
18 KiB
Go
469 lines
18 KiB
Go
package ui
|
|
|
|
// Prometheus: GET /metrics на ядре (порт awg-ui/metrics.ts). Метрики ядра и всех нод
|
|
// собираются здесь же — у нод через уже открытый WSS. Доступ — отдельный токен «только чтение
|
|
// метрик»; он не открывает ни /api/v1, ни панель.
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"math"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/maeneko/forgetting/internal/jsjson"
|
|
)
|
|
|
|
const (
|
|
scrapeDeadline = 5 * time.Second
|
|
touchEveryS = 60 // last_used пишем не чаще раза в минуту, а не на каждый скрейп
|
|
)
|
|
|
|
func hashToken(t string) []byte {
|
|
s := sha256.Sum256([]byte(t))
|
|
return s[:]
|
|
}
|
|
|
|
type Metrics struct {
|
|
a *App
|
|
perPeer bool
|
|
localFile string
|
|
|
|
mu sync.Mutex
|
|
localMod time.Time
|
|
localSeen bool
|
|
localHash []byte
|
|
}
|
|
|
|
func newMetrics(a *App) (*Metrics, error) {
|
|
m := &Metrics{
|
|
a: a,
|
|
// Пер-пировые серии (peer = внутренний IP, name = имя пользователя) выключены: это и
|
|
// приватность, и кардинальность. Включается явно.
|
|
perPeer: strings.ToLower(envOr("METRICS_PEERS", "off")) == "on",
|
|
localFile: envOr("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"),
|
|
}
|
|
_, err := a.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS metrics_token (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
token_hash BLOB NOT NULL,
|
|
prefix TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
last_used INTEGER
|
|
)
|
|
`)
|
|
return m, err
|
|
}
|
|
|
|
var reHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
|
|
// localTokenHash — токен встроенного Prometheus (`awg-ctrl monitor install`): здесь его хэш.
|
|
// Файл перечитывается по mtime: установка не требует перезапуска панели.
|
|
func (m *Metrics) localTokenHash() []byte {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
st, err := os.Stat(m.localFile)
|
|
if err != nil {
|
|
m.localSeen, m.localHash = false, nil
|
|
return nil
|
|
}
|
|
if !m.localSeen || !st.ModTime().Equal(m.localMod) {
|
|
raw, err := os.ReadFile(m.localFile)
|
|
if err != nil {
|
|
m.localSeen, m.localHash = false, nil
|
|
return nil
|
|
}
|
|
m.localSeen, m.localMod, m.localHash = true, st.ModTime(), nil
|
|
if h := strings.TrimSpace(string(raw)); reHex64.MatchString(h) {
|
|
m.localHash, _ = hex.DecodeString(h)
|
|
}
|
|
}
|
|
return m.localHash
|
|
}
|
|
|
|
type tokenRow struct {
|
|
hash []byte
|
|
prefix string
|
|
createdAt int64
|
|
lastUsed sql.NullInt64
|
|
}
|
|
|
|
func (m *Metrics) token() *tokenRow {
|
|
t := &tokenRow{}
|
|
if m.a.db.QueryRow("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&t.hash, &t.prefix, &t.createdAt, &t.lastUsed) != nil {
|
|
return nil
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (m *Metrics) view() jsjson.Object {
|
|
if t := m.token(); t != nil {
|
|
return jsjson.Object{{Key: "exists", Val: true}, {Key: "prefix", Val: t.prefix}, {Key: "created_at", Val: t.createdAt},
|
|
{Key: "last_used", Val: nullInt(t.lastUsed)}, {Key: "per_peer", Val: m.perPeer}}
|
|
}
|
|
return jsjson.Object{{Key: "exists", Val: false}, {Key: "per_peer", Val: m.perPeer}}
|
|
}
|
|
|
|
// router — управление токеном (роуты панели, за JWT). Выпуск и перевыпуск — одно и то же:
|
|
// старый токен перестаёт работать. Значение отдаётся один раз.
|
|
func (m *Metrics) router() *Router {
|
|
rt := newRouter()
|
|
rt.Get("/", func(c *Ctx) bool { return c.JSON(200, m.view()) })
|
|
rt.Post("/", func(c *Ctx) bool {
|
|
b := make([]byte, 24)
|
|
rand.Read(b)
|
|
tok := "awgm_" + b64url.EncodeToString(b)
|
|
m.a.db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)",
|
|
hashToken(tok), tok[:13]+"…", now())
|
|
return c.JSON(201, append(m.view(), jsjson.KV{Key: "token", Val: tok}))
|
|
})
|
|
rt.Delete("/", func(c *Ctx) bool {
|
|
m.a.db.Exec("DELETE FROM metrics_token WHERE id = 1")
|
|
return c.JSON(200, m.view())
|
|
})
|
|
return rt
|
|
}
|
|
|
|
func writeText(c *Ctx, status int, ctype, body string) bool {
|
|
c.W.Header().Set("Content-Type", ctype)
|
|
c.W.WriteHeader(status)
|
|
c.W.Write([]byte(body))
|
|
return false
|
|
}
|
|
|
|
// requireToken — нет токена или он неверный — одинаковый 401: не подсказываем, выключены
|
|
// метрики или токен не тот.
|
|
func (m *Metrics) requireToken(c *Ctx) bool {
|
|
given := bearer(c.R)
|
|
row := m.token()
|
|
var gh []byte
|
|
if strings.HasPrefix(given, "awgm_") {
|
|
gh = hashToken(given)
|
|
}
|
|
panelOK := row != nil && gh != nil && len(row.hash) == len(gh) && subtle.ConstantTimeCompare(gh, row.hash) == 1
|
|
local := m.localTokenHash()
|
|
localOK := gh != nil && local != nil && subtle.ConstantTimeCompare(gh, local) == 1
|
|
if !panelOK && !localOK {
|
|
c.W.Header().Set("WWW-Authenticate", "Bearer")
|
|
return writeText(c, 401, "text/plain; charset=utf-8", "unauthorized\n")
|
|
}
|
|
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
|
|
if t := now(); panelOK && (!row.lastUsed.Valid || row.lastUsed.Int64 == 0 || t-row.lastUsed.Int64 >= touchEveryS) {
|
|
m.a.db.Exec("UPDATE metrics_token SET last_used = ? WHERE id = 1", t)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ── Экспозиция ──────────────────────────────────────────────────────────────
|
|
|
|
type label struct {
|
|
k string
|
|
v any
|
|
}
|
|
|
|
type sample struct {
|
|
labels []label
|
|
v any // число; не число и не конечное — 0
|
|
}
|
|
|
|
type exposition struct {
|
|
b bytes.Buffer
|
|
seen map[string]bool
|
|
}
|
|
|
|
var (
|
|
escLabel = strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
|
|
escHelp = strings.NewReplacer(`\`, `\\`, "\n", `\n`)
|
|
)
|
|
|
|
// labelString — String(v) для значения метки.
|
|
func labelString(v any) string {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x
|
|
case int:
|
|
return jsjson.Number(float64(x))
|
|
case int64:
|
|
return jsjson.Number(float64(x))
|
|
case float64:
|
|
return jsjson.Number(x)
|
|
case bool:
|
|
if x {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case nil:
|
|
return "undefined"
|
|
}
|
|
return "[object Object]"
|
|
}
|
|
|
|
func (e *exposition) add(name, typ, help string, samples []sample) {
|
|
if len(samples) == 0 {
|
|
return
|
|
}
|
|
if !e.seen[name] {
|
|
e.seen[name] = true
|
|
e.b.WriteString("# HELP " + name + " " + escHelp.Replace(help) + "\n# TYPE " + name + " " + typ + "\n")
|
|
}
|
|
for _, s := range samples {
|
|
e.b.WriteString(name)
|
|
if len(s.labels) > 0 {
|
|
parts := make([]string, len(s.labels))
|
|
for i, l := range s.labels {
|
|
parts[i] = l.k + `="` + escLabel.Replace(labelString(l.v)) + `"`
|
|
}
|
|
e.b.WriteString("{" + strings.Join(parts, ",") + "}")
|
|
}
|
|
v := 0.0
|
|
switch x := s.v.(type) {
|
|
case float64:
|
|
v = x
|
|
case int:
|
|
v = float64(x)
|
|
case int64:
|
|
v = float64(x)
|
|
}
|
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
|
v = 0
|
|
}
|
|
e.b.WriteString(" " + jsjson.Number(v) + "\n")
|
|
}
|
|
}
|
|
|
|
func b2f(b bool) float64 {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// scrapeServer — один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
|
|
func (m *Metrics) scrapeServer(s serverInfo) any {
|
|
if s.Pending || !m.a.nodes.IsOnline(s.ID) {
|
|
return nil
|
|
}
|
|
ch := make(chan any, 1)
|
|
go func() {
|
|
r, err := m.a.nodes.CtrlFor(s.ID)("GET", "/api/metrics", nil)
|
|
if err != nil || r.Status != 200 {
|
|
ch <- nil
|
|
return
|
|
}
|
|
ch <- r.Data
|
|
}()
|
|
select {
|
|
case d := <-ch:
|
|
return d
|
|
case <-time.After(scrapeDeadline):
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (m *Metrics) render() string {
|
|
t0 := time.Now()
|
|
list := m.a.nodes.List()
|
|
data := make([]any, len(list))
|
|
var wg sync.WaitGroup
|
|
for i, s := range list {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); data[i] = m.scrapeServer(s) }()
|
|
}
|
|
wg.Wait()
|
|
out := &exposition{seen: map[string]bool{}}
|
|
L := func(s serverInfo, extra ...label) []label {
|
|
return append([]label{{"server", s.Name}, {"server_id", s.ID}}, extra...)
|
|
}
|
|
// Серии только тех серверов, чьи метрики получены.
|
|
per := func(fn func(s serverInfo, d any) []sample) []sample {
|
|
var res []sample
|
|
for i, s := range list {
|
|
if truthy(data[i]) {
|
|
res = append(res, fn(s, data[i])...)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
one := func(fn func(d any) any) func(serverInfo, any) []sample {
|
|
return func(s serverInfo, d any) []sample { return []sample{{L(s), fn(d)}} }
|
|
}
|
|
or0 := func(v any) any {
|
|
if v == nil {
|
|
return 0.0
|
|
}
|
|
return v
|
|
}
|
|
|
|
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", []sample{{[]label{{"version", m.a.version}}, 1.0}})
|
|
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", []sample{{nil, float64(len(list))}})
|
|
var online, scrapeOK, info []sample
|
|
for i, s := range list {
|
|
online = append(online, sample{L(s), b2f(s.Online)})
|
|
scrapeOK = append(scrapeOK, sample{L(s), b2f(truthy(data[i]))})
|
|
if !s.Pending {
|
|
gen, module, tools := any(""), any(""), any("")
|
|
if s.Health != nil {
|
|
gen, module, tools = s.Health.Gen, s.Health.Module, s.Health.Tools
|
|
}
|
|
ver := s.Version
|
|
if ver == nil {
|
|
ver = ""
|
|
}
|
|
info = append(info, sample{L(s, label{"version", ver}, label{"gen", gen}, label{"module", module}, label{"tools", tools}), 1.0})
|
|
}
|
|
}
|
|
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", online)
|
|
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", scrapeOK)
|
|
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.", info)
|
|
|
|
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per(one(func(d any) any { return b2f(truthy(field(d, "up"))) })))
|
|
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per(one(func(d any) any { return or0(field(d, "peers", "total")) })))
|
|
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per(one(func(d any) any { return or0(field(d, "peers", "online")) })))
|
|
// Сумма по пирам — gauge, а не counter: при удалении пира сумма уменьшается.
|
|
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "rx_bytes")) })))
|
|
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "tx_bytes")) })))
|
|
for _, x := range [][3]string{
|
|
{"rx_bytes", "receive_bytes", "байт"}, {"tx_bytes", "transmit_bytes", "байт"},
|
|
{"rx_packets", "receive_packets", "пакетов"}, {"tx_packets", "transmit_packets", "пакетов"},
|
|
{"rx_dropped", "receive_dropped", "потерянных пакетов"}, {"tx_dropped", "transmit_dropped", "потерянных пакетов"},
|
|
{"rx_errors", "receive_errors", "ошибок"}, {"tx_errors", "transmit_errors", "ошибок"},
|
|
} {
|
|
out.add("awg_link_"+x[1]+"_total", "counter", "Интерфейс awg1: "+x[2]+" ("+x[0]+").", per(one(func(d any) any { return or0(field(d, "link", x[0])) })))
|
|
}
|
|
|
|
// Нагрузка хоста. CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
|
|
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
|
|
per(func(s serverInfo, d any) []sample {
|
|
cpu, _ := field(d, "host", "cpu").(jsjson.Object)
|
|
var res []sample
|
|
for _, kv := range cpu {
|
|
res = append(res, sample{L(s, label{"mode", kv.Key}), jsToNumberAny(kv.Val)})
|
|
}
|
|
return res
|
|
}))
|
|
hostOne := func(fn func(h any) any) func(serverInfo, any) []sample {
|
|
return func(s serverInfo, d any) []sample {
|
|
h := field(d, "host")
|
|
if !truthy(h) {
|
|
return nil
|
|
}
|
|
return []sample{{L(s), fn(h)}}
|
|
}
|
|
}
|
|
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per(hostOne(func(h any) any { return or0(field(h, "cores")) })))
|
|
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "total")) })))
|
|
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "available")) })))
|
|
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).", per(func(s serverInfo, d any) []sample {
|
|
h := field(d, "host")
|
|
if !truthy(h) {
|
|
return nil
|
|
}
|
|
load := arr(field(h, "load"))
|
|
var res []sample
|
|
for i, p := range []string{"1m", "5m", "15m"} {
|
|
var v any = 0.0
|
|
if i < len(load) && load[i] != nil {
|
|
v = load[i]
|
|
}
|
|
res = append(res, sample{L(s, label{"period", p}), v})
|
|
}
|
|
return res
|
|
}))
|
|
|
|
// Деление канала (CAKE)
|
|
sh := func(fn func(x any) any) []sample {
|
|
return per(func(s serverInfo, d any) []sample {
|
|
x := field(d, "shaping")
|
|
if !truthy(x) {
|
|
return nil
|
|
}
|
|
return []sample{{L(s), fn(x)}}
|
|
})
|
|
}
|
|
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "settings", "enabled"))) }))
|
|
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "active"))) }))
|
|
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "congested"))) }))
|
|
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(func(x any) any {
|
|
if h, ok := field(x, "heavy").([]any); ok {
|
|
return float64(len(h))
|
|
}
|
|
return 0.0
|
|
}))
|
|
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(func(x any) any { return or0(field(x, "devices")) }))
|
|
// dirs — значение nil (undefined) серию не даёт.
|
|
dirs := func(fn func(x any, dir string) any) []sample {
|
|
return per(func(s serverInfo, d any) []sample {
|
|
x := field(d, "shaping")
|
|
if !truthy(x) {
|
|
return nil
|
|
}
|
|
var res []sample
|
|
for _, dir := range []string{"down", "up"} {
|
|
if v := fn(x, dir); v != nil {
|
|
res = append(res, sample{L(s, label{"direction", dir}), v})
|
|
}
|
|
}
|
|
return res
|
|
})
|
|
}
|
|
mbit := func(v any) any {
|
|
if v == nil {
|
|
return math.NaN() // undefined * 1e6
|
|
}
|
|
return jsToNumberAny(v) * 1e6
|
|
}
|
|
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs(func(x any, dir string) any {
|
|
if dir == "down" {
|
|
return mbit(field(x, "settings", "downMbit"))
|
|
}
|
|
return mbit(field(x, "settings", "upMbit"))
|
|
}))
|
|
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs(func(x any, dir string) any {
|
|
if dir == "down" {
|
|
return mbit(field(x, "rateDown"))
|
|
}
|
|
return mbit(field(x, "rateUp"))
|
|
}))
|
|
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "drops") }))
|
|
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "backlog") }))
|
|
|
|
if m.perPeer {
|
|
peers := func(fn func(p any) any) []sample {
|
|
return per(func(s serverInfo, d any) []sample {
|
|
var res []sample
|
|
for _, p := range arr(field(d, "list")) {
|
|
name := field(p, "name")
|
|
if name == nil {
|
|
name = ""
|
|
}
|
|
res = append(res, sample{L(s, label{"peer", field(p, "ip")}, label{"name", name}, label{"kind", field(p, "kind")}), fn(p)})
|
|
}
|
|
return res
|
|
})
|
|
}
|
|
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "online"))) }))
|
|
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(func(p any) any { return b2f(field(p, "priority") == "background") }))
|
|
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(func(p any) any { return field(p, "rx") }))
|
|
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(func(p any) any { return field(p, "tx") }))
|
|
}
|
|
|
|
var masters, devices int64
|
|
m.a.db.QueryRow("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").Scan(&masters)
|
|
m.a.db.QueryRow("SELECT COUNT(*) AS n FROM devices").Scan(&devices)
|
|
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", []sample{{nil, masters}})
|
|
out.add("awg_devices", "gauge", "Устройств sen://.", []sample{{nil, devices}})
|
|
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", []sample{{nil, time.Since(t0).Seconds()}})
|
|
return out.b.String()
|
|
}
|
|
|
|
func (m *Metrics) handler(c *Ctx) bool {
|
|
return writeText(c, 200, "text/plain; version=0.0.4; charset=utf-8", m.render())
|
|
}
|