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:
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/maeneko/forgetting/internal/agent"
|
||||
"github.com/maeneko/forgetting/internal/ctrl"
|
||||
"github.com/maeneko/forgetting/internal/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -28,6 +29,16 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "ui":
|
||||
a, err := ui.New()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := a.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "agent":
|
||||
c, err := agent.ConfigFromEnv()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
package ui
|
||||
|
||||
// Аудит серверов (порт части awg-ui/sub.ts). Периодически core обходит все серверы и ищет
|
||||
// «мёртвых» пиров: живы на сервере, хотя панель про них не знает. Сообщает и — если включена
|
||||
// очистка или оператор нажал кнопку — снимает подтверждённых: мёртвых два прохода подряд.
|
||||
// ghosts — есть в ядре (awg show), но неизвестны awg-ctrl;
|
||||
// orphans — есть у awg-ctrl в области этой панели, но панели не нужны (sync их не снял);
|
||||
// key — мастер-ключ пира, если он отозван или удалён.
|
||||
// Пиры чужой области в отчёт не попадают — считаются в foreign.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
type auditPeer struct {
|
||||
PubKey string
|
||||
Owner any // только у orphans
|
||||
Key any // только у orphans
|
||||
Online any
|
||||
LastHandshake any
|
||||
SeenBefore *bool
|
||||
orphan bool
|
||||
}
|
||||
|
||||
func (p *auditPeer) json() jsjson.Object {
|
||||
o := jsjson.Object{{Key: "pub_key", Val: p.PubKey}}
|
||||
if p.orphan {
|
||||
o = append(o, jsjson.KV{Key: "owner", Val: p.Owner})
|
||||
}
|
||||
o = append(o, jsjson.KV{Key: "online", Val: p.Online}, jsjson.KV{Key: "lastHandshake", Val: p.LastHandshake})
|
||||
if p.orphan {
|
||||
o = append(o, jsjson.KV{Key: "key", Val: p.Key})
|
||||
}
|
||||
if p.SeenBefore != nil {
|
||||
o = append(o, jsjson.KV{Key: "seen_before", Val: *p.SeenBefore})
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
type auditEntry struct {
|
||||
ServerID int
|
||||
Status string // ok | offline | error
|
||||
GhostsChecked bool // false — awg-ctrl старше /api/audit, «призраков» не видно
|
||||
Ghosts []*auditPeer
|
||||
Orphans []*auditPeer
|
||||
Foreign int
|
||||
Blocked *int // очистка отказалась снимать столько пиров разом (предохранитель)
|
||||
}
|
||||
|
||||
func peersJSON(ps []*auditPeer) []any {
|
||||
out := []any{}
|
||||
for _, p := range ps {
|
||||
out = append(out, p.json())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *auditEntry) json() jsjson.Object {
|
||||
o := jsjson.Object{{Key: "server_id", Val: e.ServerID}, {Key: "status", Val: e.Status}, {Key: "ghosts_checked", Val: e.GhostsChecked},
|
||||
{Key: "ghosts", Val: peersJSON(e.Ghosts)}, {Key: "orphans", Val: peersJSON(e.Orphans)}, {Key: "foreign", Val: e.Foreign}}
|
||||
if e.Blocked != nil {
|
||||
o = append(o, jsjson.KV{Key: "blocked", Val: *e.Blocked})
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
type auditReport struct {
|
||||
At int64
|
||||
Servers []*auditEntry
|
||||
}
|
||||
|
||||
func (r *auditReport) json() any {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
list := []any{}
|
||||
for _, e := range r.Servers {
|
||||
list = append(list, e.json())
|
||||
}
|
||||
return jsjson.Object{{Key: "at", Val: r.At}, {Key: "servers", Val: list}}
|
||||
}
|
||||
|
||||
type cleanedPeer struct {
|
||||
At int64
|
||||
ServerID int
|
||||
PubKey string
|
||||
Kind string // ghost | orphan
|
||||
Key any // у ghost нет (undefined)
|
||||
HasKey bool
|
||||
Auto bool
|
||||
}
|
||||
|
||||
func (c cleanedPeer) json() jsjson.Object {
|
||||
o := jsjson.Object{{Key: "at", Val: c.At}, {Key: "server_id", Val: c.ServerID}, {Key: "pub_key", Val: c.PubKey}, {Key: "kind", Val: c.Kind}}
|
||||
if c.HasKey {
|
||||
o = append(o, jsjson.KV{Key: "key", Val: c.Key})
|
||||
}
|
||||
return append(o, jsjson.KV{Key: "auto", Val: c.Auto})
|
||||
}
|
||||
|
||||
// auditSettings — clean: снимать «мёртвых» сами; every_h — период; max — предохранитель.
|
||||
type auditSettings struct {
|
||||
Clean bool
|
||||
EveryH int
|
||||
Max int
|
||||
}
|
||||
|
||||
func (s auditSettings) json() jsjson.Object {
|
||||
return jsjson.Object{{Key: "clean", Val: s.Clean}, {Key: "every_h", Val: s.EveryH}, {Key: "max", Val: s.Max}}
|
||||
}
|
||||
|
||||
// ── sub_meta: JSON-значения ─────────────────────────────────────────────────
|
||||
|
||||
func (s *Sub) metaGet(key string) any {
|
||||
var raw string
|
||||
if s.a.db.QueryRow("SELECT value FROM sub_meta WHERE key = ?", key).Scan(&raw) != nil {
|
||||
return nil
|
||||
}
|
||||
v, err := jsjson.Decode([]byte(raw))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Sub) metaSet(key string, v any) {
|
||||
s.a.db.Exec("INSERT INTO sub_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, string(jsjson.MustMarshal(v)))
|
||||
}
|
||||
|
||||
func parsePeer(v any, orphan bool) *auditPeer {
|
||||
o, _ := v.(jsjson.Object)
|
||||
p := &auditPeer{PubKey: strOr(o.Get2("pub_key"), ""), Online: o.Get2("online"), LastHandshake: o.Get2("lastHandshake"), orphan: orphan}
|
||||
if orphan {
|
||||
p.Owner, p.Key = o.Get2("owner"), o.Get2("key")
|
||||
}
|
||||
if b, ok := o.Get2("seen_before").(bool); ok {
|
||||
p.SeenBefore = &b
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func parseReport(v any) *auditReport {
|
||||
o, ok := v.(jsjson.Object)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
r := &auditReport{At: int64(numOr(o.Get2("at"), 0))}
|
||||
for _, x := range arr(o.Get2("servers")) {
|
||||
e := &auditEntry{ServerID: int(numOr(field(x, "server_id"), 0)), Status: strOr(field(x, "status"), "error"),
|
||||
Foreign: int(numOr(field(x, "foreign"), 0))}
|
||||
e.GhostsChecked, _ = field(x, "ghosts_checked").(bool)
|
||||
for _, p := range arr(field(x, "ghosts")) {
|
||||
e.Ghosts = append(e.Ghosts, parsePeer(p, false))
|
||||
}
|
||||
for _, p := range arr(field(x, "orphans")) {
|
||||
e.Orphans = append(e.Orphans, parsePeer(p, true))
|
||||
}
|
||||
if b, ok := field(x, "blocked").(float64); ok {
|
||||
n := int(b)
|
||||
e.Blocked = &n
|
||||
}
|
||||
r.Servers = append(r.Servers, e)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Sub) loadAudit() {
|
||||
s.lastAudit = parseReport(s.metaGet("audit"))
|
||||
s.auditSettings = auditSettings{Clean: false, EveryH: 1, Max: 10}
|
||||
if o, ok := s.metaGet("audit_settings").(jsjson.Object); ok {
|
||||
if b, ok := o.Get2("clean").(bool); ok {
|
||||
s.auditSettings.Clean = b
|
||||
}
|
||||
if f, ok := o.Get2("every_h").(float64); ok {
|
||||
s.auditSettings.EveryH = int(f)
|
||||
}
|
||||
if f, ok := o.Get2("max").(float64); ok {
|
||||
s.auditSettings.Max = int(f)
|
||||
}
|
||||
}
|
||||
for _, x := range arr(s.metaGet("audit_cleaned")) {
|
||||
o, _ := x.(jsjson.Object)
|
||||
k, hasKey := o.Get("key")
|
||||
auto, _ := o.Get2("auto").(bool)
|
||||
s.cleanedLog = append(s.cleanedLog, cleanedPeer{At: int64(numOr(o.Get2("at"), 0)), ServerID: int(numOr(o.Get2("server_id"), 0)),
|
||||
PubKey: strOr(o.Get2("pub_key"), ""), Kind: strOr(o.Get2("kind"), ""), Key: k, HasKey: hasKey, Auto: auto})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sub) cleanedJSON() []any {
|
||||
out := []any{}
|
||||
for _, c := range s.cleanedLog {
|
||||
out = append(out, c.json())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Проход ──────────────────────────────────────────────────────────────────
|
||||
|
||||
var reOwnerMaster = regexp.MustCompile(`^[^/]+/m(\d+)/`)
|
||||
|
||||
func (s *Sub) auditNow(id int) (*auditEntry, error) {
|
||||
e := &auditEntry{ServerID: id, Status: "ok", GhostsChecked: true}
|
||||
if !s.a.nodes.Exists(id) || !s.a.nodes.IsOnline(id) {
|
||||
e.Status, e.GhostsChecked = "offline", false
|
||||
return e, nil
|
||||
}
|
||||
ctrl := s.a.nodes.CtrlFor(id)
|
||||
// Сначала обычное сведение: отчёт — про то, что не снялось само.
|
||||
if err := s.reconcileNow(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := ctrl("GET", "/api/audit", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := ctrl("GET", "/api/peers", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fail := func() (*auditEntry, error) {
|
||||
e.Status, e.GhostsChecked = "error", false
|
||||
e.Ghosts, e.Orphans = nil, nil
|
||||
return e, nil
|
||||
}
|
||||
if !ok2xx(p.Status) {
|
||||
return fail()
|
||||
}
|
||||
if ok2xx(a.Status) {
|
||||
for _, g := range arr(field(a.Data, "ghosts")) {
|
||||
e.Ghosts = append(e.Ghosts, &auditPeer{PubKey: strOr(field(g, "pub_key"), ""), Online: field(g, "online"), LastHandshake: field(g, "lastHandshake")})
|
||||
}
|
||||
} else if a.Status == 404 {
|
||||
e.GhostsChecked = false
|
||||
} else {
|
||||
return fail()
|
||||
}
|
||||
|
||||
keep := map[string]bool{}
|
||||
for _, k := range s.keepOf(id) {
|
||||
keep[k] = true
|
||||
}
|
||||
prefix := s.panelID + "/"
|
||||
for _, peer := range arr(field(p.Data, "peers")) {
|
||||
owner, ok := field(peer, "owner").(string)
|
||||
if !ok {
|
||||
return nil, errTypeError // peer.owner.startsWith бросил бы TypeError
|
||||
}
|
||||
pub := strOr(field(peer, "pub_key"), "")
|
||||
if !strings.HasPrefix(owner, prefix) {
|
||||
if strings.Contains(owner, "/") {
|
||||
e.Foreign++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if keep[pub] {
|
||||
continue
|
||||
}
|
||||
key := any("(нет в ui.db)")
|
||||
if m := reOwnerMaster.FindStringSubmatch(owner); m != nil {
|
||||
if mid, err := strconv.ParseInt(m[1], 10, 64); err == nil && mid <= 9007199254740991 {
|
||||
if row := s.masterRowByID(s.a.db, mid); row != nil {
|
||||
if row.RevokedAt.Valid {
|
||||
key = row.Label + " (удалён)"
|
||||
} else {
|
||||
key = row.Label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
e.Orphans = append(e.Orphans, &auditPeer{PubKey: pub, Owner: owner, Online: field(peer, "online"),
|
||||
LastHandshake: field(peer, "lastHandshake"), Key: key, orphan: true})
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
type jsTypeError struct{}
|
||||
|
||||
func (jsTypeError) Error() string { return "TypeError" }
|
||||
|
||||
var errTypeError = jsTypeError{}
|
||||
|
||||
// audit — под той же блокировкой, что и сведение: пир, которого прямо сейчас добавляет проход, не «призрак».
|
||||
func (s *Sub) audit(id int) *auditEntry {
|
||||
l := s.lock(id)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
e, err := func() (e *auditEntry, err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
e, err = nil, errTypeError
|
||||
}
|
||||
}()
|
||||
return s.auditNow(id)
|
||||
}()
|
||||
if err != nil || e == nil {
|
||||
return &auditEntry{ServerID: id, Status: "error"}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// cleanEntry — снять «мёртвых» сервера: только подтверждённых (seen_before), не больше max за проход.
|
||||
// «Призраки» — через awg-ctrl (он сам перепроверяет ключи), лишние — обычным DELETE.
|
||||
func (s *Sub) cleanEntry(e *auditEntry, auto bool) error {
|
||||
seen := func(ps []*auditPeer) []*auditPeer {
|
||||
var out []*auditPeer
|
||||
for _, p := range ps {
|
||||
if p.SeenBefore != nil && *p.SeenBefore {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
ghosts, orphans := seen(e.Ghosts), seen(e.Orphans)
|
||||
if len(ghosts)+len(orphans) == 0 || e.Status != "ok" {
|
||||
return nil
|
||||
}
|
||||
s.auditMu.Lock()
|
||||
max := s.auditSettings.Max
|
||||
s.auditMu.Unlock()
|
||||
if auto && len(ghosts)+len(orphans) > max {
|
||||
n := len(ghosts) + len(orphans)
|
||||
e.Blocked = &n
|
||||
warnf("sub: очистка сервера #%d пропущена: %d пиров больше предела %d", e.ServerID, n, max)
|
||||
return nil
|
||||
}
|
||||
ctrl := s.a.nodes.CtrlFor(e.ServerID)
|
||||
var done []cleanedPeer
|
||||
log := func(p *auditPeer, kind string) {
|
||||
done = append(done, cleanedPeer{At: now(), ServerID: e.ServerID, PubKey: p.PubKey, Kind: kind, Key: p.Key, HasKey: p.orphan, Auto: auto})
|
||||
}
|
||||
if len(ghosts) > 0 {
|
||||
keys := []any{}
|
||||
for _, g := range ghosts {
|
||||
keys = append(keys, g.PubKey)
|
||||
}
|
||||
r, err := ctrl("POST", "/api/audit/clean", jsjson.Object{{Key: "pub_keys", Val: keys}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok2xx(r.Status) {
|
||||
removed := map[string]bool{}
|
||||
for _, x := range arr(field(r.Data, "removed")) {
|
||||
if k, ok := x.(string); ok {
|
||||
removed[k] = true
|
||||
}
|
||||
}
|
||||
for _, g := range ghosts {
|
||||
if removed[g.PubKey] {
|
||||
log(g, "ghost")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range orphans {
|
||||
r, err := ctrl("DELETE", "/api/peers/"+pubParam(p.PubKey), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok2xx(r.Status) || r.Status == 404 {
|
||||
log(p, "orphan")
|
||||
} else if gone(r.Status) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(done) == 0 {
|
||||
return nil
|
||||
}
|
||||
cleaned := map[string]bool{}
|
||||
var short []string
|
||||
for _, d := range done {
|
||||
cleaned[d.PubKey] = true
|
||||
k := d.PubKey
|
||||
if len(k) > 8 {
|
||||
k = k[:8]
|
||||
}
|
||||
short = append(short, k)
|
||||
}
|
||||
drop := func(ps []*auditPeer) []*auditPeer {
|
||||
var out []*auditPeer
|
||||
for _, p := range ps {
|
||||
if !cleaned[p.PubKey] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
e.Ghosts, e.Orphans = drop(e.Ghosts), drop(e.Orphans)
|
||||
s.auditMu.Lock()
|
||||
s.cleanedLog = append(done, s.cleanedLog...)
|
||||
if len(s.cleanedLog) > 100 {
|
||||
s.cleanedLog = s.cleanedLog[:100]
|
||||
}
|
||||
s.metaSet("audit_cleaned", s.cleanedJSON())
|
||||
s.auditMu.Unlock()
|
||||
what := "очистка"
|
||||
if auto {
|
||||
what = "автоочистка"
|
||||
}
|
||||
warnf("sub: %s сервера #%d: снято %d (%s)", what, e.ServerID, len(done), strings.Join(short, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanLocked — очистка под блокировкой сервера, как и сам аудит.
|
||||
func (s *Sub) cleanLocked(e *auditEntry, auto bool) {
|
||||
l := s.lock(e.ServerID)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
if err := s.cleanEntry(e, auto); err != nil && auto {
|
||||
warnf("sub: очистка сервера #%d не удалась: %v", e.ServerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// auditAll — проход по всем серверам; одновременно идёт не больше одного (повторный вызов ждёт
|
||||
// текущий). auto — плановый: очищает сам, если включено. Ручной только смотрит.
|
||||
func (s *Sub) auditAll(auto bool) {
|
||||
s.auditMu.Lock()
|
||||
if ch := s.auditing; ch != nil {
|
||||
s.auditMu.Unlock()
|
||||
<-ch
|
||||
return
|
||||
}
|
||||
ch := make(chan struct{})
|
||||
s.auditing = ch
|
||||
prev := s.lastAudit
|
||||
s.auditMu.Unlock()
|
||||
defer func() {
|
||||
s.auditMu.Lock()
|
||||
s.auditing = nil
|
||||
s.auditMu.Unlock()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
ids := s.a.nodes.IDs()
|
||||
report := &auditReport{At: now(), Servers: make([]*auditEntry, len(ids))}
|
||||
done := make(chan struct{}, len(ids))
|
||||
for i, id := range ids {
|
||||
go func() { report.Servers[i] = s.audit(id); done <- struct{}{} }()
|
||||
}
|
||||
for range ids {
|
||||
<-done
|
||||
}
|
||||
s.auditMu.Lock()
|
||||
clean := s.auditSettings.Clean
|
||||
s.auditMu.Unlock()
|
||||
for _, e := range report.Servers {
|
||||
was := map[string]bool{}
|
||||
if prev != nil {
|
||||
for _, x := range prev.Servers {
|
||||
if x.ServerID == e.ServerID {
|
||||
for _, p := range append(append([]*auditPeer{}, x.Ghosts...), x.Orphans...) {
|
||||
was[p.PubKey] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range append(append([]*auditPeer{}, e.Ghosts...), e.Orphans...) {
|
||||
b := was[p.PubKey]
|
||||
p.SeenBefore = &b
|
||||
}
|
||||
if auto && clean {
|
||||
s.cleanLocked(e, true)
|
||||
}
|
||||
}
|
||||
s.auditMu.Lock()
|
||||
s.lastAudit = report
|
||||
s.metaSet("audit", report.json())
|
||||
s.auditMu.Unlock()
|
||||
for _, e := range report.Servers {
|
||||
if e.Status == "error" {
|
||||
warnf("sub: аудит сервера #%d не удался", e.ServerID)
|
||||
}
|
||||
if n := len(e.Ghosts) + len(e.Orphans); n > 0 {
|
||||
online := 0
|
||||
for _, p := range append(append([]*auditPeer{}, e.Ghosts...), e.Orphans...) {
|
||||
if truthy(p.Online) {
|
||||
online++
|
||||
}
|
||||
}
|
||||
warnf("sub: аудит сервера #%d: мёртвых пиров %d (в ядре без записи: %d, лишних у awg-ctrl: %d, онлайн: %d)",
|
||||
e.ServerID, n, len(e.Ghosts), len(e.Orphans), online)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanNow — ручная очистка подтверждённых «мёртвых» по последнему отчёту.
|
||||
func (s *Sub) cleanNow() {
|
||||
s.auditMu.Lock()
|
||||
rep := s.lastAudit
|
||||
s.auditMu.Unlock()
|
||||
if rep == nil {
|
||||
return
|
||||
}
|
||||
for _, e := range rep.Servers {
|
||||
s.cleanLocked(e, false)
|
||||
}
|
||||
s.auditMu.Lock()
|
||||
s.metaSet("audit", rep.json())
|
||||
s.auditMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Sub) auditState() jsjson.Object {
|
||||
s.auditMu.Lock()
|
||||
defer s.auditMu.Unlock()
|
||||
return jsjson.Object{{Key: "enabled", Val: s.auditOn}, {Key: "settings", Val: s.auditSettings.json()},
|
||||
{Key: "report", Val: s.lastAudit.json()}, {Key: "cleaned", Val: s.cleanedJSON()}}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// jsToNumber — Number(s) для строки: пробелы по краям, пусто → 0, 0x/0o/0b, Infinity;
|
||||
// всё прочее нечисловое — NaN.
|
||||
func jsToNumber(s string) float64 {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
return 0
|
||||
}
|
||||
switch t {
|
||||
case "Infinity", "+Infinity":
|
||||
return math.Inf(1)
|
||||
case "-Infinity":
|
||||
return math.Inf(-1)
|
||||
}
|
||||
if len(t) > 2 && t[0] == '0' {
|
||||
base := 0
|
||||
switch t[1] {
|
||||
case 'x', 'X':
|
||||
base = 16
|
||||
case 'o', 'O':
|
||||
base = 8
|
||||
case 'b', 'B':
|
||||
base = 2
|
||||
}
|
||||
if base != 0 {
|
||||
n, err := strconv.ParseUint(t[2:], base, 64)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
return float64(n)
|
||||
}
|
||||
}
|
||||
for _, r := range t {
|
||||
if !(r >= '0' && r <= '9' || r == '.' || r == 'e' || r == 'E' || r == '+' || r == '-') {
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
f, err := strconv.ParseFloat(t, 64)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// jsToNumberAny — Number(v) для значения из JSON.
|
||||
func jsToNumberAny(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case bool:
|
||||
if x {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case float64:
|
||||
return x
|
||||
case string:
|
||||
return jsToNumber(x)
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
// encodeURIComponent — как в JS: не трогает A-Z a-z 0-9 - _ . ! ~ * ' ( ).
|
||||
func encodeURIComponent(s string) string {
|
||||
const hexd = "0123456789ABCDEF"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || strings.IndexByte("-_.!~*'()", c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(hexd[c>>4])
|
||||
b.WriteByte(hexd[c&15])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func readAll(resp *http.Response) ([]byte, error) { return io.ReadAll(resp.Body) }
|
||||
|
||||
// utf16Len — длина строки в JS (кодовые единицы UTF-16).
|
||||
func utf16Len(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r >= 0x10000 {
|
||||
n += 2
|
||||
} else {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// lenOK — `{min,max}` в JS-регулярке над строкой целиком: длина в единицах UTF-16.
|
||||
func lenOK(s string, min, max int) bool {
|
||||
n := utf16Len(s)
|
||||
return n >= min && n <= max
|
||||
}
|
||||
|
||||
// sliceUTF16 — s.slice(0, n) в JS. Разрезанная суррогатная пара при записи в SQLite
|
||||
// превращается в U+FFFD — так же делаем и здесь.
|
||||
func sliceUTF16(s string, n int) string {
|
||||
units := utf16.Encode([]rune(s))
|
||||
if len(units) <= n {
|
||||
return s
|
||||
}
|
||||
return string(utf16.Decode(units[:n]))
|
||||
}
|
||||
|
||||
func quietLog() *log.Logger { return log.New(io.Discard, "", 0) }
|
||||
@@ -0,0 +1,468 @@
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package ui
|
||||
|
||||
// Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики.
|
||||
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
|
||||
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
|
||||
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove`
|
||||
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч
|
||||
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
type promRange struct{ seconds, step int64 }
|
||||
|
||||
// Шаг — чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
|
||||
var promRanges = map[string]promRange{"1h": {3600, 15}, "6h": {21600, 60}, "24h": {86400, 300}}
|
||||
|
||||
// Запросы по серверам. Трафик — со счётчиков интерфейса (монотонны), а не с сумм по пирам.
|
||||
var promQueries = []struct{ key, q string }{
|
||||
{"down", `sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8`},
|
||||
{"up", `sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8`},
|
||||
{"online", `sum by (server_id, server) (awg_peers_online)`},
|
||||
{"heavy", `sum by (server_id, server) (awg_shaping_heavy_devices)`},
|
||||
{"drops", `sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))`},
|
||||
{"congested", `max by (server_id, server) (awg_shaping_congested)`},
|
||||
{"scrape_ok", `max by (server_id, server) (awg_scrape_ok)`},
|
||||
{"cpu", `100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))`},
|
||||
{"mem", `100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))`},
|
||||
{"cores", `max by (server_id, server) (awg_host_cpu_cores)`},
|
||||
{"mem_total", `max by (server_id, server) (awg_host_memory_total_bytes)`},
|
||||
}
|
||||
|
||||
type point [2]float64
|
||||
|
||||
type seriesOne struct {
|
||||
name any
|
||||
points []point
|
||||
}
|
||||
|
||||
// series — server_id → ряд, в порядке ответа Prometheus (как Map в JS).
|
||||
type series struct {
|
||||
ids []int
|
||||
m map[int]*seriesOne
|
||||
}
|
||||
|
||||
func (s *series) get(id int) *seriesOne {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.m[id]
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
a *App
|
||||
promURL string
|
||||
localEnabled bool
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newMonitor(a *App) *Monitor {
|
||||
return &Monitor{
|
||||
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
|
||||
localEnabled: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off",
|
||||
http: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
||||
}
|
||||
}
|
||||
|
||||
var errProm = errors.New("Prometheus не отвечает")
|
||||
|
||||
func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, error) {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
q.Set("start", strconv.FormatInt(start, 10))
|
||||
q.Set("end", strconv.FormatInt(end, 10))
|
||||
q.Set("step", strconv.FormatInt(step, 10))
|
||||
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
|
||||
resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode())
|
||||
if err != nil {
|
||||
return nil, errProm
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, errProm
|
||||
}
|
||||
out := &series{m: map[int]*seriesOne{}}
|
||||
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
|
||||
metric := field(r, "metric")
|
||||
idf := jsToNumberAny(field(metric, "server_id"))
|
||||
if field(metric, "server_id") == nil {
|
||||
idf = math.NaN()
|
||||
}
|
||||
if idf != float64(int(idf)) {
|
||||
continue
|
||||
}
|
||||
id := int(idf)
|
||||
name := field(metric, "server")
|
||||
if name == nil {
|
||||
name = strconv.Itoa(id)
|
||||
}
|
||||
s := &seriesOne{name: name}
|
||||
for _, v := range arr(field(r, "values")) {
|
||||
pair := arr(v)
|
||||
if len(pair) < 2 {
|
||||
continue
|
||||
}
|
||||
t := jsToNumberAny(pair[0])
|
||||
val := jsToNumberAny(pair[1])
|
||||
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
|
||||
if math.IsNaN(val) || math.IsInf(val, 0) {
|
||||
continue
|
||||
}
|
||||
s.points = append(s.points, point{t, val})
|
||||
}
|
||||
if _, dup := out.m[id]; !dup {
|
||||
out.ids = append(out.ids, id)
|
||||
}
|
||||
out.m[id] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// total — сумма (или максимум) рядов по меткам времени.
|
||||
func total(s *series, max bool) []any {
|
||||
acc := map[float64]float64{}
|
||||
var order []float64
|
||||
for _, id := range s.ids {
|
||||
for _, p := range s.m[id].points {
|
||||
cur, seen := acc[p[0]]
|
||||
if !seen {
|
||||
order = append(order, p[0])
|
||||
}
|
||||
if max {
|
||||
acc[p[0]] = math.Max(cur, p[1])
|
||||
} else {
|
||||
acc[p[0]] = cur + p[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Float64s(order)
|
||||
out := []any{}
|
||||
for _, t := range order {
|
||||
out = append(out, []any{t, acc[t]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func last(s *seriesOne) any {
|
||||
if s == nil || len(s.points) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.points[len(s.points)-1][1]
|
||||
}
|
||||
|
||||
func (m *Monitor) promReady() bool {
|
||||
c := &http.Client{Transport: m.http.Transport, Timeout: 1500 * time.Millisecond}
|
||||
resp, err := c.Get(m.promURL + "/-/ready")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
type monitorJob struct {
|
||||
can bool
|
||||
action any
|
||||
running bool
|
||||
exit any
|
||||
log string
|
||||
}
|
||||
|
||||
// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут.
|
||||
func (m *Monitor) jobState() monitorJob {
|
||||
if !m.localEnabled {
|
||||
return monitorJob{}
|
||||
}
|
||||
r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil)
|
||||
if err != nil || r.Status != 200 {
|
||||
return monitorJob{}
|
||||
}
|
||||
j := monitorJob{can: true, action: field(r.Data, "action"), running: truthy(field(r.Data, "running")), exit: field(r.Data, "exit")}
|
||||
switch l := field(r.Data, "log").(type) {
|
||||
case nil:
|
||||
case string:
|
||||
j.log = l
|
||||
default:
|
||||
j.log, _ = jsString(l)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
func (m *Monitor) router() *Router {
|
||||
rt := newRouter()
|
||||
rt.Get("/", func(c *Ctx) bool {
|
||||
var ready bool
|
||||
var job monitorJob
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); ready = m.promReady() }()
|
||||
go func() { defer wg.Done(); job = m.jobState() }()
|
||||
wg.Wait()
|
||||
var jobOut any
|
||||
if job.can && truthy(job.action) {
|
||||
jobOut = jsjson.Object{{Key: "action", Val: job.action}, {Key: "running", Val: job.running}, {Key: "exit", Val: job.exit}, {Key: "log", Val: job.log}}
|
||||
}
|
||||
return c.JSON(200, jsjson.Object{{Key: "ready", Val: ready}, {Key: "can_install", Val: job.can}, {Key: "job", Val: jobOut},
|
||||
{Key: "command", Val: "awg-ctrl monitor install"}})
|
||||
})
|
||||
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
|
||||
ask := func(action string) handler {
|
||||
return func(c *Ctx) bool {
|
||||
if !m.localEnabled {
|
||||
return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине")
|
||||
}
|
||||
r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil)
|
||||
if err != nil {
|
||||
return c.Error(502, "awg-ctrl недоступен")
|
||||
}
|
||||
if r.Status == 404 {
|
||||
return c.Error(501, "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную")
|
||||
}
|
||||
return c.JSON(r.Status, r.Data)
|
||||
}
|
||||
}
|
||||
rt.Post("/install", ask("install"))
|
||||
rt.Post("/remove", ask("remove"))
|
||||
|
||||
rt.Get("/overview", func(c *Ctx) bool {
|
||||
qs := c.R.URL.Query()
|
||||
rangeKey := "1h"
|
||||
if _, ok := qs["range"]; ok {
|
||||
rangeKey = strings.Join(qs["range"], ",")
|
||||
}
|
||||
rg, ok := promRanges[rangeKey]
|
||||
if !ok {
|
||||
return c.Error(400, "range: 1h, 6h или 24h")
|
||||
}
|
||||
end := now()
|
||||
start := end - rg.seconds
|
||||
got := make([]*series, len(promQueries))
|
||||
errs := make([]error, len(promQueries))
|
||||
var wg sync.WaitGroup
|
||||
for i, pq := range promQueries {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(pq.q, start, end, rg.step) }()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return c.Error(502, err.Error())
|
||||
}
|
||||
}
|
||||
// Вкладка показывает один сервер — выбранный в сервер-баре (?server=<id>).
|
||||
if _, has := qs["server"]; has {
|
||||
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) {
|
||||
only := int(f)
|
||||
for i, s := range got {
|
||||
f := &series{m: map[int]*seriesOne{}}
|
||||
if x, ok := s.m[only]; ok {
|
||||
f.ids, f.m[only] = []int{only}, x
|
||||
}
|
||||
got[i] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
s := map[string]*series{}
|
||||
for i, pq := range promQueries {
|
||||
s[pq.key] = got[i]
|
||||
}
|
||||
ids := map[int]bool{}
|
||||
for _, x := range got {
|
||||
for _, id := range x.ids {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
var sorted []int
|
||||
for id := range ids {
|
||||
sorted = append(sorted, id)
|
||||
}
|
||||
sort.Ints(sorted)
|
||||
servers := []any{}
|
||||
for _, id := range sorted {
|
||||
var name any = strconv.Itoa(id)
|
||||
for _, k := range []string{"scrape_ok", "down", "online"} {
|
||||
if x := s[k].get(id); x != nil {
|
||||
name = x.name
|
||||
break
|
||||
}
|
||||
}
|
||||
servers = append(servers, jsjson.Object{
|
||||
{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "ok", Val: last(s["scrape_ok"].get(id)) == 1.0},
|
||||
{Key: "down", Val: last(s["down"].get(id))}, {Key: "up", Val: last(s["up"].get(id))},
|
||||
{Key: "online", Val: last(s["online"].get(id))}, {Key: "heavy", Val: last(s["heavy"].get(id))},
|
||||
{Key: "congested", Val: last(s["congested"].get(id)) == 1.0}, {Key: "cpu", Val: last(s["cpu"].get(id))},
|
||||
{Key: "mem", Val: last(s["mem"].get(id))}, {Key: "cores", Val: last(s["cores"].get(id))},
|
||||
{Key: "mem_total", Val: last(s["mem_total"].get(id))},
|
||||
})
|
||||
}
|
||||
var rangeOut any = "1h"
|
||||
if _, ok := qs["range"]; ok {
|
||||
rangeOut = rangeKey
|
||||
}
|
||||
return c.JSON(200, jsjson.Object{
|
||||
{Key: "range", Val: rangeOut}, {Key: "step", Val: rg.step}, {Key: "from", Val: start}, {Key: "to", Val: end},
|
||||
{Key: "servers", Val: servers},
|
||||
{Key: "total", Val: jsjson.Object{
|
||||
{Key: "down", Val: total(s["down"], false)}, {Key: "up", Val: total(s["up"], false)}, {Key: "online", Val: total(s["online"], false)},
|
||||
{Key: "heavy", Val: total(s["heavy"], false)}, {Key: "drops", Val: total(s["drops"], false)},
|
||||
{Key: "cpu", Val: total(s["cpu"], true)}, {Key: "mem", Val: total(s["mem"], true)},
|
||||
}},
|
||||
})
|
||||
})
|
||||
return rt
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
package ui
|
||||
|
||||
// Несколько серверов под одной панелью (порт awg-ui/nodes.ts): core (эта панель) и ноды —
|
||||
// VPN-серверы, которые сами открывают к core исходящее WSS-соединение (awg-control agent).
|
||||
// Реестр нод лежит в ui.db, RPC к ноде идёт по этому соединению, а нода исполняет запрос на
|
||||
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
|
||||
//
|
||||
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает
|
||||
// как чистая панель. Протокол и формат join-строки — docs/node-protocol.md.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
const (
|
||||
joinTTL = 3600 // секунд жизни одноразового секрета
|
||||
authTimeout = 10 * time.Second
|
||||
healthEvery = 15 * time.Second
|
||||
pingEvery = 20 * time.Second
|
||||
rpcTimeout = 15 * time.Second
|
||||
rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго
|
||||
hubMaxFrame = 4 * 1024 * 1024
|
||||
failWindow = 15 * time.Minute
|
||||
failMaxHits = 20
|
||||
)
|
||||
|
||||
var (
|
||||
reNodeName = regexp.MustCompile(`^[^\r\n<>]+$`) // длину — lenOK: в JS {1,40} считает UTF-16
|
||||
reHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`)
|
||||
reVersion = regexp.MustCompile(`^[\w.+-]{1,32}$`)
|
||||
)
|
||||
|
||||
// authMsg — сообщение, которое нода подписывает своим ключом. Дублируется в internal/agent.
|
||||
func authMsg(nonce string, nodeID int64) []byte {
|
||||
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.FormatInt(nodeID, 10))
|
||||
}
|
||||
|
||||
type nodeRow struct {
|
||||
ID int64
|
||||
Name string
|
||||
Pub sql.NullString
|
||||
JoinHash []byte
|
||||
JoinExpires sql.NullInt64
|
||||
CreatedAt int64
|
||||
LastSeen sql.NullInt64
|
||||
Version sql.NullString
|
||||
}
|
||||
|
||||
const nodeCols = "id, name, pub, join_hash, join_expires, created_at, last_seen, version"
|
||||
|
||||
func scanNode(row interface{ Scan(...any) error }) (*nodeRow, error) {
|
||||
n := &nodeRow{}
|
||||
if err := row.Scan(&n.ID, &n.Name, &n.Pub, &n.JoinHash, &n.JoinExpires, &n.CreatedAt, &n.LastSeen, &n.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// health — сводка /health сервера; ip — как пришло (`d.ip ?? ""`).
|
||||
type health struct {
|
||||
Server string
|
||||
IP any
|
||||
Gen any
|
||||
Peers any
|
||||
AwgUp bool
|
||||
Module any
|
||||
Tools any
|
||||
}
|
||||
|
||||
func (h *health) json() any {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return jsjson.Object{{Key: "server", Val: h.Server}, {Key: "ip", Val: h.IP}, {Key: "gen", Val: h.Gen}, {Key: "peers", Val: h.Peers},
|
||||
{Key: "awg_up", Val: h.AwgUp}, {Key: "module", Val: h.Module}, {Key: "tools", Val: h.Tools}}
|
||||
}
|
||||
|
||||
type pending struct {
|
||||
ch chan *CtrlResp
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
type nodeConn struct {
|
||||
ws *websocket.Conn
|
||||
mu sync.Mutex // seq, pending, alive
|
||||
seq int64
|
||||
alive bool
|
||||
pending map[int64]*pending
|
||||
closed bool
|
||||
}
|
||||
|
||||
type deleteVeto struct {
|
||||
Error string
|
||||
Forcible bool
|
||||
}
|
||||
|
||||
type Nodes struct {
|
||||
a *App
|
||||
port int
|
||||
local bool
|
||||
tls *tls.Config
|
||||
pin []byte
|
||||
mu sync.Mutex
|
||||
conns map[int64]*nodeConn
|
||||
health map[int64]*health
|
||||
failHits map[string]*rateEntry
|
||||
|
||||
hmu sync.Mutex
|
||||
onlineHandler []func(id int)
|
||||
beforeDelete []func(id int, force bool) *deleteVeto
|
||||
afterDelete []func(id int)
|
||||
}
|
||||
|
||||
var offline = &CtrlResp{502, errObj("Сервер недоступен")}
|
||||
|
||||
func newNodes(a *App) (*Nodes, error) {
|
||||
n := &Nodes{
|
||||
a: a, port: envNum("NODE_PORT"),
|
||||
local: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off",
|
||||
conns: map[int64]*nodeConn{}, health: map[int64]*health{}, failHits: map[string]*rateEntry{},
|
||||
}
|
||||
file := func(env, name string) string {
|
||||
if v := os.Getenv(env); v != "" {
|
||||
return v
|
||||
}
|
||||
return filepath.Join(a.baseDir, name)
|
||||
}
|
||||
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же,
|
||||
// сколько core: пересоздать = переподключить все ноды.
|
||||
if n.port != 0 {
|
||||
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
|
||||
if err := func() error {
|
||||
if !exists(keyF) && !exists(crtF) {
|
||||
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
|
||||
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Chmod(keyF, 0o600)
|
||||
os.Chmod(crtF, 0o600)
|
||||
logf("nodes: создан самоподписанный сертификат %s", crtF)
|
||||
}
|
||||
crt, err := os.ReadFile(crtF)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(crtF, keyF)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pin, err := spkiPin(crt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n.tls = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
n.pin = pin
|
||||
return nil
|
||||
}(); err != nil {
|
||||
warnf("nodes: сертификат хаба недоступен (%s) — приём нод отключён", crtF)
|
||||
n.tls, n.pin = nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := a.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
pub TEXT,
|
||||
join_hash BLOB,
|
||||
join_expires INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER
|
||||
);
|
||||
`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают).
|
||||
if !hasColumn(a.db, "nodes", "version") {
|
||||
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN version TEXT"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func exists(f string) bool {
|
||||
_, err := os.Stat(f)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func hasColumn(db queryer, table, col string) bool {
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, _ := rows.Columns()
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
for rows.Next() {
|
||||
rows.Scan(ptrs...)
|
||||
for i, c := range cols {
|
||||
if c == "name" && fmt.Sprint(vals[i]) == col {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (n *Nodes) row(id int64) *nodeRow {
|
||||
r, _ := scanNode(n.a.db.QueryRow("SELECT "+nodeCols+" FROM nodes WHERE id = ?", id))
|
||||
return r
|
||||
}
|
||||
|
||||
func (n *Nodes) all() []*nodeRow {
|
||||
rows, err := n.a.db.Query("SELECT " + nodeCols + " FROM nodes ORDER BY id")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*nodeRow
|
||||
for rows.Next() {
|
||||
if r, err := scanNode(rows); err == nil {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── RPC к нодам ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp {
|
||||
n.mu.Lock()
|
||||
c := n.conns[id]
|
||||
n.mu.Unlock()
|
||||
if c == nil {
|
||||
return offline
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return offline
|
||||
}
|
||||
c.seq++
|
||||
rid := c.seq
|
||||
p := &pending{ch: make(chan *CtrlResp, 1)}
|
||||
timeout := rpcTimeout
|
||||
if strings.HasPrefix(path, "/awg/") {
|
||||
timeout = rpcSlow
|
||||
}
|
||||
p.timer = time.AfterFunc(timeout, func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, rid)
|
||||
c.mu.Unlock()
|
||||
p.ch <- &CtrlResp{504, errObj("Сервер не ответил")}
|
||||
})
|
||||
c.pending[rid] = p
|
||||
c.mu.Unlock()
|
||||
|
||||
msg := jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: rid}, {Key: "method", Val: method}, {Key: "path", Val: path}}
|
||||
if body != nil {
|
||||
msg = append(msg, jsjson.KV{Key: "body", Val: body})
|
||||
}
|
||||
if b, err := jsjson.Marshal(msg); err == nil {
|
||||
go c.ws.Write(context.Background(), websocket.MessageText, b)
|
||||
}
|
||||
return <-p.ch
|
||||
}
|
||||
|
||||
// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS.
|
||||
func (n *Nodes) CtrlFor(id int) Ctrl {
|
||||
if id == 0 {
|
||||
if !n.local {
|
||||
return func(string, string, any) (*CtrlResp, error) {
|
||||
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
|
||||
}
|
||||
}
|
||||
return n.a.ctrlLocal
|
||||
}
|
||||
if n.row(int64(id)) == nil {
|
||||
return func(string, string, any) (*CtrlResp, error) {
|
||||
return &CtrlResp{404, errObj("Сервер не найден")}, nil
|
||||
}
|
||||
}
|
||||
return func(m, p string, b any) (*CtrlResp, error) { return n.rpc(int64(id), m, p, b), nil }
|
||||
}
|
||||
|
||||
func (n *Nodes) Exists(id int) bool {
|
||||
if id == 0 {
|
||||
return n.local
|
||||
}
|
||||
return n.row(int64(id)) != nil
|
||||
}
|
||||
|
||||
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
|
||||
func (n *Nodes) IsOnline(id int) bool {
|
||||
if id == 0 {
|
||||
return n.local
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return n.conns[int64(id)] != nil
|
||||
}
|
||||
|
||||
// IDs — все серверы реестра: локальный (если есть) и ноды.
|
||||
func (n *Nodes) IDs() []int {
|
||||
var ids []int
|
||||
if n.local {
|
||||
ids = append(ids, 0)
|
||||
}
|
||||
for _, r := range n.all() {
|
||||
ids = append(ids, int(r.ID))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// OnOnline — подписчики на «сервер снова на связи» (подписка догоняет пропущенное).
|
||||
func (n *Nodes) OnOnline(cb func(id int)) {
|
||||
n.hmu.Lock()
|
||||
n.onlineHandler = append(n.onlineHandler, cb)
|
||||
n.hmu.Unlock()
|
||||
}
|
||||
|
||||
// BeforeDelete — проверка перед удалением сервера: может запретить (forcible — запрет
|
||||
// снимается подтверждением оператора). AfterDelete — уборка ссылок на уже удалённый сервер.
|
||||
func (n *Nodes) BeforeDelete(cb func(id int, force bool) *deleteVeto) {
|
||||
n.hmu.Lock()
|
||||
n.beforeDelete = append(n.beforeDelete, cb)
|
||||
n.hmu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Nodes) AfterDelete(cb func(id int)) {
|
||||
n.hmu.Lock()
|
||||
n.afterDelete = append(n.afterDelete, cb)
|
||||
n.hmu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Nodes) fireOnline(id int) {
|
||||
n.hmu.Lock()
|
||||
hs := append([]func(int){}, n.onlineHandler...)
|
||||
n.hmu.Unlock()
|
||||
for _, cb := range hs {
|
||||
func() {
|
||||
defer func() { recover() }() // подписчик сам разберётся
|
||||
cb(id)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultID — сервер по умолчанию: локальный, а без него — первая нода.
|
||||
func (n *Nodes) DefaultID() int {
|
||||
if n.local {
|
||||
return 0
|
||||
}
|
||||
if all := n.all(); len(all) > 0 {
|
||||
return int(all[0].ID)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (n *Nodes) refreshHealth(id int64) {
|
||||
var r *CtrlResp
|
||||
if id == 0 {
|
||||
r, _ = n.a.ctrlLocal("GET", "/health", nil)
|
||||
} else {
|
||||
r = n.rpc(id, "GET", "/health", nil)
|
||||
}
|
||||
var d any
|
||||
if r != nil {
|
||||
d = r.Data
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if server, ok := field(d, "server").(string); ok {
|
||||
or := func(v any, def any) any {
|
||||
if v == nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
n.health[id] = &health{
|
||||
Server: server, IP: or(field(d, "ip"), ""), Gen: or(field(d, "gen"), "2"), Peers: or(field(d, "awg", "peers"), 0.0),
|
||||
AwgUp: field(d, "awg", "status") == "ok", Module: or(field(d, "awg", "module"), ""), Tools: or(field(d, "awg", "tools"), ""),
|
||||
}
|
||||
} else {
|
||||
delete(n.health, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Хаб: приём соединений от нод ────────────────────────────────────────────
|
||||
|
||||
func (n *Nodes) failed(ip string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
t := nowFn()
|
||||
e := n.failHits[ip]
|
||||
if e == nil || e.resetAt.Before(t) {
|
||||
n.failHits[ip] = &rateEntry{1, t.Add(failWindow)}
|
||||
} else {
|
||||
e.count++
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Nodes) blocked(ip string) bool {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
e := n.failHits[ip]
|
||||
return e != nil && e.resetAt.After(nowFn()) && e.count >= failMaxHits
|
||||
}
|
||||
|
||||
// dropConn — все ждущие запросы получают «недоступен»; из реестра соединений — только если
|
||||
// там всё ещё это соединение.
|
||||
func (n *Nodes) dropConn(id int64, c *nodeConn) {
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
ps := c.pending
|
||||
c.pending = map[int64]*pending{}
|
||||
c.mu.Unlock()
|
||||
for _, p := range ps {
|
||||
if p.timer.Stop() {
|
||||
p.ch <- offline
|
||||
}
|
||||
}
|
||||
n.mu.Lock()
|
||||
if n.conns[id] == c {
|
||||
delete(n.conns, id)
|
||||
delete(n.health, id)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func closeWS(ws *websocket.Conn, code int, reason string) {
|
||||
go ws.Close(websocket.StatusCode(code), reason)
|
||||
}
|
||||
|
||||
func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(hubMaxFrame)
|
||||
if n.blocked(ip) {
|
||||
ws.Close(4029, "rate_limited")
|
||||
return
|
||||
}
|
||||
nb := make([]byte, 16)
|
||||
rand.Read(nb)
|
||||
nonce := b64url.EncodeToString(nb)
|
||||
|
||||
var conn *nodeConn
|
||||
var nodeID int64
|
||||
authTimer := time.AfterFunc(authTimeout, func() { closeWS(ws, 4001, "auth_timeout") })
|
||||
defer authTimer.Stop()
|
||||
deny := func() { n.failed(ip); closeWS(ws, 4003, "unauthorized") }
|
||||
|
||||
authenticate := func(m jsjson.Object) {
|
||||
sig, sigOK := m.Get2("sig").(string)
|
||||
if m.Get2("t") != "auth" || !isInt(m.Get2("node_id")) || !sigOK {
|
||||
deny()
|
||||
return
|
||||
}
|
||||
row := n.row(int64(m.Get2("node_id").(float64)))
|
||||
if row == nil {
|
||||
deny()
|
||||
return
|
||||
}
|
||||
pubB64 := row.Pub.String
|
||||
joining := !row.Pub.Valid
|
||||
if joining {
|
||||
secret, sOK := field(m, "join", "secret").(string)
|
||||
pub, pOK := field(m, "join", "pub").(string)
|
||||
if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || !sOK || !pOK {
|
||||
deny()
|
||||
return
|
||||
}
|
||||
h := sha256.Sum256(fromB64url(secret))
|
||||
if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 {
|
||||
deny()
|
||||
return
|
||||
}
|
||||
pubB64 = pub
|
||||
}
|
||||
key := fromB64url(pubB64)
|
||||
if len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, authMsg(nonce, row.ID), fromB64url(sig)) {
|
||||
deny()
|
||||
return
|
||||
}
|
||||
authTimer.Stop()
|
||||
if joining {
|
||||
n.a.db.Exec("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?", pubB64, row.ID)
|
||||
}
|
||||
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
|
||||
var version any
|
||||
if v, ok := m.Get2("version").(string); ok && reVersion.MatchString(v) {
|
||||
version = v
|
||||
}
|
||||
n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID)
|
||||
|
||||
conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}}
|
||||
nodeID = row.ID
|
||||
n.mu.Lock()
|
||||
old := n.conns[row.ID]
|
||||
n.conns[row.ID] = conn
|
||||
n.mu.Unlock()
|
||||
if old != nil {
|
||||
n.dropConn(row.ID, old)
|
||||
closeWS(old.ws, 4000, "replaced")
|
||||
}
|
||||
ws.Write(context.Background(), websocket.MessageText, []byte(`{"t":"ready"}`))
|
||||
first := ""
|
||||
if joining {
|
||||
first = " (первое подключение)"
|
||||
}
|
||||
logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first)
|
||||
go n.refreshHealth(row.ID)
|
||||
go n.fireOnline(int(row.ID))
|
||||
}
|
||||
|
||||
hello, _ := jsjson.Marshal(jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: nonce}})
|
||||
ws.Write(r.Context(), websocket.MessageText, hello)
|
||||
|
||||
ctx := context.Background()
|
||||
for {
|
||||
_, raw, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
v, err := jsjson.Decode(raw)
|
||||
if err != nil {
|
||||
closeWS(ws, 4002, "bad_frame")
|
||||
continue
|
||||
}
|
||||
m, _ := v.(jsjson.Object)
|
||||
if conn == nil {
|
||||
authenticate(m)
|
||||
continue
|
||||
}
|
||||
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
|
||||
rid := int64(m.Get2("id").(float64))
|
||||
conn.mu.Lock()
|
||||
p := conn.pending[rid]
|
||||
delete(conn.pending, rid)
|
||||
conn.mu.Unlock()
|
||||
if p == nil || !p.timer.Stop() {
|
||||
continue
|
||||
}
|
||||
st := jsToNumberAny(m.Get2("status"))
|
||||
status := 502
|
||||
if st == st && st != 0 {
|
||||
status = int(st)
|
||||
}
|
||||
p.ch <- &CtrlResp{status, m.Get2("data")}
|
||||
}
|
||||
}
|
||||
if conn != nil {
|
||||
n.dropConn(nodeID, conn)
|
||||
logf("nodes: #%d отключена", nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Start поднимает хаб (если настроен) и периодические ping/health.
|
||||
func (n *Nodes) Start() {
|
||||
if n.local {
|
||||
go n.refreshHealth(0)
|
||||
go n.fireOnline(0)
|
||||
}
|
||||
if n.port == 0 || n.tls == nil {
|
||||
return
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/node/v1", n.serveWS)
|
||||
srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second,
|
||||
ErrorLog: quietLog()}
|
||||
go func() {
|
||||
ln, err := net.Listen("tcp", srv.Addr)
|
||||
if err != nil {
|
||||
warnf("nodes: хаб не запущен: %v", err)
|
||||
return
|
||||
}
|
||||
logf("nodes: hub on :%d (wss)", n.port)
|
||||
srv.ServeTLS(ln, "", "")
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for range time.Tick(pingEvery) {
|
||||
n.mu.Lock()
|
||||
conns := map[int64]*nodeConn{}
|
||||
for id, c := range n.conns {
|
||||
conns[id] = c
|
||||
}
|
||||
n.mu.Unlock()
|
||||
for id, c := range conns {
|
||||
c.mu.Lock()
|
||||
alive := c.alive
|
||||
c.alive = false
|
||||
c.mu.Unlock()
|
||||
if !alive {
|
||||
c.ws.CloseNow()
|
||||
n.dropConn(id, c)
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), pingEvery)
|
||||
defer cancel()
|
||||
if c.ws.Ping(ctx) == nil {
|
||||
c.mu.Lock()
|
||||
c.alive = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for range time.Tick(healthEvery) {
|
||||
n.mu.Lock()
|
||||
var ids []int64
|
||||
for id := range n.conns {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
go n.refreshHealth(id)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ── Роуты панели (/ui/nodes, за JWT) ────────────────────────────────────────
|
||||
|
||||
func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil }
|
||||
|
||||
func (n *Nodes) joinString(host string, id int64, secret []byte) string {
|
||||
b := jsjson.MustMarshal(jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: n.port},
|
||||
{Key: "pin", Val: b64url.EncodeToString(n.pin)}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}})
|
||||
return "awgjoin://" + b64url.EncodeToString(b)
|
||||
}
|
||||
|
||||
var rePortSuffix = regexp.MustCompile(`:\d+$`)
|
||||
|
||||
func (n *Nodes) hostOf(c *Ctx) string {
|
||||
if given, ok := c.Obj().Get2("host").(string); ok && reHost.MatchString(given) {
|
||||
return given
|
||||
}
|
||||
h := rePortSuffix.ReplaceAllString(c.R.Host, "")
|
||||
return strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
|
||||
}
|
||||
|
||||
func nullStr(s sql.NullString) any {
|
||||
if s.Valid {
|
||||
return s.String
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orNil(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// List — серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и метрик.
|
||||
func (n *Nodes) List() []serverInfo {
|
||||
var out []serverInfo
|
||||
n.mu.Lock()
|
||||
hs := map[int64]*health{}
|
||||
for k, v := range n.health {
|
||||
hs[k] = v
|
||||
}
|
||||
online := map[int64]bool{}
|
||||
for k := range n.conns {
|
||||
online[k] = true
|
||||
}
|
||||
n.mu.Unlock()
|
||||
if n.local {
|
||||
h := hs[0]
|
||||
name := "VPN"
|
||||
if h != nil {
|
||||
name = h.Server
|
||||
}
|
||||
out = append(out, serverInfo{ID: 0, Name: name, Local: true, Online: h != nil, Version: orNil(n.a.version), Health: h})
|
||||
}
|
||||
for _, r := range n.all() {
|
||||
out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid,
|
||||
LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type serverInfo struct {
|
||||
ID int
|
||||
Name string
|
||||
Local bool
|
||||
Online bool
|
||||
Pending bool
|
||||
LastSeen any
|
||||
Version any
|
||||
Health *health
|
||||
}
|
||||
|
||||
func (s serverInfo) json() jsjson.Object {
|
||||
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online},
|
||||
{Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}}
|
||||
}
|
||||
|
||||
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
|
||||
f := jsToNumber(c.Params["id"])
|
||||
return int64(f), f == float64(int64(f)) && f >= 1
|
||||
}
|
||||
|
||||
func (n *Nodes) router() *Router {
|
||||
rt := newRouter()
|
||||
rt.Get("/", func(c *Ctx) bool {
|
||||
if n.local {
|
||||
n.refreshHealth(0)
|
||||
}
|
||||
list := []any{}
|
||||
for _, s := range n.List() {
|
||||
list = append(list, s.json())
|
||||
}
|
||||
return c.JSON(200, jsjson.Object{{Key: "hub", Val: n.hubReady()}, {Key: "local", Val: n.local},
|
||||
{Key: "version", Val: orNil(n.a.version)}, {Key: "nodes", Val: list}})
|
||||
})
|
||||
rt.Post("/", func(c *Ctx) bool {
|
||||
if !n.hubReady() {
|
||||
return c.Error(503, "Приём нод не настроен (NODE_PORT / сертификат)")
|
||||
}
|
||||
name, ok := c.Obj().Get2("name").(string)
|
||||
if !ok || !reNodeName.MatchString(name) || !lenOK(name, 1, 40) {
|
||||
return c.Error(400, "Имя: до 40 символов, без переводов строк и <>")
|
||||
}
|
||||
host := n.hostOf(c)
|
||||
if !reHost.MatchString(host) {
|
||||
return c.Error(400, "Не удалось определить адрес core")
|
||||
}
|
||||
secret := make([]byte, 16)
|
||||
rand.Read(secret)
|
||||
h := sha256.Sum256(secret)
|
||||
res, err := n.a.db.Exec("INSERT INTO nodes (name, join_hash, join_expires, created_at) VALUES (?, ?, ?, ?)", name, h[:], now()+joinTTL, now())
|
||||
if err != nil {
|
||||
return c.Error(500, "Internal server error")
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return c.JSON(201, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "join", Val: n.joinString(host, id, secret)}, {Key: "expires_in", Val: joinTTL}})
|
||||
})
|
||||
// Новая join-строка для ноды, которая ещё не подключалась (истёк срок / потеряли).
|
||||
rt.Post("/:id/join", func(c *Ctx) bool {
|
||||
if !n.hubReady() {
|
||||
return c.Error(503, "Приём нод не настроен (NODE_PORT / сертификат)")
|
||||
}
|
||||
id, ok := n.idOf(c)
|
||||
var row *nodeRow
|
||||
if ok {
|
||||
row = n.row(id)
|
||||
}
|
||||
if row == nil {
|
||||
return c.Error(404, "Не найден")
|
||||
}
|
||||
if row.Pub.Valid {
|
||||
return c.Error(409, "Нода уже подключена")
|
||||
}
|
||||
host := n.hostOf(c)
|
||||
if !reHost.MatchString(host) {
|
||||
return c.Error(400, "Не удалось определить адрес core")
|
||||
}
|
||||
secret := make([]byte, 16)
|
||||
rand.Read(secret)
|
||||
h := sha256.Sum256(secret)
|
||||
n.a.db.Exec("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL", h[:], now()+joinTTL, id)
|
||||
return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: row.Name}, {Key: "join", Val: n.joinString(host, id, secret)}, {Key: "expires_in", Val: joinTTL}})
|
||||
})
|
||||
rt.Patch("/:id", func(c *Ctx) bool {
|
||||
id, ok := n.idOf(c)
|
||||
if !ok || n.row(id) == nil {
|
||||
return c.Error(404, "Не найден")
|
||||
}
|
||||
name, ok := c.Obj().Get2("name").(string)
|
||||
if !ok || !reNodeName.MatchString(name) || !lenOK(name, 1, 40) {
|
||||
return c.Error(400, "Неверное имя")
|
||||
}
|
||||
n.a.db.Exec("UPDATE nodes SET name = ? WHERE id = ?", name, id)
|
||||
return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name}})
|
||||
})
|
||||
rt.Delete("/:id", func(c *Ctx) bool {
|
||||
id, ok := n.idOf(c)
|
||||
if !ok || n.row(id) == nil {
|
||||
return c.Error(404, "Не найден")
|
||||
}
|
||||
// Ключи API и мастер-ключи проверяют их владельцы (BeforeDelete).
|
||||
force := c.R.URL.Query().Get("force") == "1"
|
||||
n.hmu.Lock()
|
||||
before := append([]func(int, bool) *deleteVeto{}, n.beforeDelete...)
|
||||
after := append([]func(int){}, n.afterDelete...)
|
||||
n.hmu.Unlock()
|
||||
for _, cb := range before {
|
||||
veto := func() (v *deleteVeto) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
v = &deleteVeto{"Не удалось подготовить сервер к удалению", true}
|
||||
}
|
||||
}()
|
||||
return cb(int(id), force)
|
||||
}()
|
||||
if veto != nil {
|
||||
return c.JSON(409, jsjson.Object{{Key: "error", Val: veto.Error}, {Key: "can_force", Val: veto.Forcible && !force}})
|
||||
}
|
||||
}
|
||||
if n.row(id) == nil {
|
||||
return c.Error(404, "Не найден")
|
||||
}
|
||||
n.mu.Lock()
|
||||
conn := n.conns[id]
|
||||
n.mu.Unlock()
|
||||
if conn != nil {
|
||||
n.dropConn(id, conn)
|
||||
closeWS(conn.ws, 4004, "removed")
|
||||
}
|
||||
n.a.db.Exec("DELETE FROM nodes WHERE id = ?", id)
|
||||
// Хвосты подписки для этого сервера больше некому доделать.
|
||||
for _, t := range []string{"peer_tombstones", "server_profiles"} {
|
||||
n.a.db.Exec("DELETE FROM "+t+" WHERE server_id = ?", id)
|
||||
}
|
||||
for _, cb := range after {
|
||||
func() {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
warnf("nodes: уборка после удаления #%d: %v", id, p)
|
||||
}
|
||||
}()
|
||||
cb(int(id))
|
||||
}()
|
||||
}
|
||||
return c.JSON(200, jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: id}})
|
||||
})
|
||||
return rt
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package ui
|
||||
|
||||
// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1 (порт awg-ui/sen.ts).
|
||||
// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент реализуется по
|
||||
// ней, поэтому любое изменение здесь — изменение протокола.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
const (
|
||||
senPrefix = "sen://"
|
||||
senVersion = 1
|
||||
flagTLS = 0x01
|
||||
|
||||
addrV4 = 0x04
|
||||
addrV6 = 0x06
|
||||
addrDomain = 0x44
|
||||
)
|
||||
|
||||
type senLinkError struct{ msg string }
|
||||
|
||||
func (e *senLinkError) Error() string { return e.msg }
|
||||
|
||||
func senErr(f string, a ...any) error { return &senLinkError{fmt.Sprintf(f, a...)} }
|
||||
|
||||
type SenAddr struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type SenLink struct {
|
||||
TLS bool
|
||||
Addrs []SenAddr
|
||||
Secret []byte // 16 байт
|
||||
SignPub []byte // 32 байта, Ed25519
|
||||
TLSPin []byte // 32 байта, только при TLS
|
||||
Name string
|
||||
}
|
||||
|
||||
// b64url — Buffer.toString("base64url"): без паддинга.
|
||||
var b64url = base64.RawURLEncoding
|
||||
|
||||
// fromB64url повторяет Buffer.from(s, "base64url") в Node: терпит паддинг и «+/», хвостовой
|
||||
// одиночный символ (неполный байт) молча отбрасывает, мусор после первого чужого символа — тоже.
|
||||
func fromB64url(s string) []byte {
|
||||
s = strings.NewReplacer("+", "-", "/", "_").Replace(s)
|
||||
if i := strings.IndexFunc(s, func(r rune) bool {
|
||||
return !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_')
|
||||
}); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if len(s)%4 == 1 {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
b, _ := b64url.DecodeString(s)
|
||||
return b
|
||||
}
|
||||
|
||||
// fromB64 — Buffer.from(s, "base64") (обычный алфавит, тоже нестрого).
|
||||
func fromB64(s string) []byte {
|
||||
return fromB64url(strings.TrimRight(s, "="))
|
||||
}
|
||||
|
||||
// ── Адреса ─────────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
reHex4 = regexp.MustCompile(`^[0-9a-fA-F]{1,4}$`)
|
||||
reIPv4 = regexp.MustCompile(`^\d{1,3}(\.\d{1,3}){3}$`)
|
||||
reDomain = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||
)
|
||||
|
||||
func ipv6ToBytes(host string) []byte {
|
||||
parts := strings.Split(host, "::")
|
||||
if len(parts) > 2 {
|
||||
return nil
|
||||
}
|
||||
var head, tail []string
|
||||
if parts[0] != "" {
|
||||
head = strings.Split(parts[0], ":")
|
||||
}
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
tail = strings.Split(parts[1], ":")
|
||||
}
|
||||
missing := 8 - len(head) - len(tail)
|
||||
if len(parts) == 1 && len(head) != 8 || len(parts) == 2 && missing < 1 {
|
||||
return nil
|
||||
}
|
||||
groups := append([]string{}, head...)
|
||||
if len(parts) == 2 {
|
||||
for range missing {
|
||||
groups = append(groups, "0")
|
||||
}
|
||||
}
|
||||
groups = append(groups, tail...)
|
||||
if len(groups) != 8 {
|
||||
return nil
|
||||
}
|
||||
out := make([]byte, 16)
|
||||
for i, g := range groups {
|
||||
if !reHex4.MatchString(g) {
|
||||
return nil
|
||||
}
|
||||
n, _ := strconv.ParseUint(g, 16, 16)
|
||||
binary.BigEndian.PutUint16(out[i*2:], uint16(n))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bytesToIPv6(b []byte) string {
|
||||
g := make([]string, 8)
|
||||
for i := range 8 {
|
||||
g[i] = strconv.FormatUint(uint64(binary.BigEndian.Uint16(b[i*2:])), 16)
|
||||
}
|
||||
// Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи.
|
||||
bestStart, bestLen := -1, 0
|
||||
for i := 0; i < 8; {
|
||||
if g[i] != "0" {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j < 8 && g[j] == "0" {
|
||||
j++
|
||||
}
|
||||
if j-i > bestLen {
|
||||
bestStart, bestLen = i, j-i
|
||||
}
|
||||
i = j
|
||||
}
|
||||
if bestLen < 2 {
|
||||
return strings.Join(g, ":")
|
||||
}
|
||||
return strings.Join(g[:bestStart], ":") + "::" + strings.Join(g[bestStart+bestLen:], ":")
|
||||
}
|
||||
|
||||
func encodeAddr(a SenAddr) ([]byte, error) {
|
||||
if a.Port < 1 || a.Port > 65535 {
|
||||
return nil, senErr("Неверный порт: %d", a.Port)
|
||||
}
|
||||
p := binary.BigEndian.AppendUint16(nil, uint16(a.Port))
|
||||
if reIPv4.MatchString(a.Host) {
|
||||
out := []byte{addrV4}
|
||||
for _, o := range strings.Split(a.Host, ".") {
|
||||
n, _ := strconv.Atoi(o)
|
||||
if n > 255 {
|
||||
return nil, senErr("Неверный IPv4: %s", a.Host)
|
||||
}
|
||||
out = append(out, byte(n))
|
||||
}
|
||||
return append(out, p...), nil
|
||||
}
|
||||
if strings.Contains(a.Host, ":") {
|
||||
b := ipv6ToBytes(a.Host)
|
||||
if b == nil {
|
||||
return nil, senErr("Неверный IPv6: %s", a.Host)
|
||||
}
|
||||
return append(append([]byte{addrV6}, b...), p...), nil
|
||||
}
|
||||
if len(a.Host) < 1 || len(a.Host) > 253 || !reDomain.MatchString(a.Host) {
|
||||
return nil, senErr("Неверный домен: %s", a.Host)
|
||||
}
|
||||
return append(append([]byte{addrDomain, byte(len(a.Host))}, a.Host...), p...), nil
|
||||
}
|
||||
|
||||
// ── Ссылка ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func EncodeSenLink(l SenLink) (string, error) {
|
||||
if len(l.Addrs) < 1 || len(l.Addrs) > 3 {
|
||||
return "", senErr("Адресов должно быть от 1 до 3")
|
||||
}
|
||||
if len(l.Secret) != 16 {
|
||||
return "", senErr("secret: 16 байт")
|
||||
}
|
||||
if len(l.SignPub) != 32 {
|
||||
return "", senErr("signPub: 32 байта")
|
||||
}
|
||||
if l.TLS && len(l.TLSPin) != 32 {
|
||||
return "", senErr("tlsPin: 32 байта при TLS")
|
||||
}
|
||||
if len(l.Name) > 64 {
|
||||
return "", senErr("Имя: до 64 байт")
|
||||
}
|
||||
var flags byte
|
||||
if l.TLS {
|
||||
flags = flagTLS
|
||||
}
|
||||
body := []byte{senVersion, flags, byte(len(l.Addrs))}
|
||||
for _, a := range l.Addrs {
|
||||
b, err := encodeAddr(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body = append(body, b...)
|
||||
}
|
||||
body = append(body, l.Secret...)
|
||||
body = append(body, l.SignPub...)
|
||||
if l.TLS {
|
||||
body = append(body, l.TLSPin...)
|
||||
}
|
||||
body = append(body, byte(len(l.Name)))
|
||||
body = append(body, l.Name...)
|
||||
body = binary.BigEndian.AppendUint32(body, crc32.ChecksumIEEE(body))
|
||||
return senPrefix + b64url.EncodeToString(body), nil
|
||||
}
|
||||
|
||||
var (
|
||||
reB64urlChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
reSpaces = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
func DecodeSenLink(link string) (SenLink, error) {
|
||||
var l SenLink
|
||||
t := strings.TrimSpace(link)
|
||||
if !strings.HasPrefix(strings.ToLower(t), senPrefix) {
|
||||
return l, senErr("Ссылка должна начинаться с sen://")
|
||||
}
|
||||
payload := reSpaces.ReplaceAllString(t[len(senPrefix):], "")
|
||||
if !reB64urlChars.MatchString(payload) {
|
||||
return l, senErr("Ссылка повреждена: недопустимые символы")
|
||||
}
|
||||
raw := fromB64url(payload)
|
||||
if len(raw) < 3+4 {
|
||||
return l, senErr("Ссылка слишком короткая")
|
||||
}
|
||||
body := raw[:len(raw)-4]
|
||||
if binary.BigEndian.Uint32(raw[len(raw)-4:]) != crc32.ChecksumIEEE(body) {
|
||||
return l, senErr("Ссылка повреждена: не сошлась контрольная сумма")
|
||||
}
|
||||
|
||||
off := 0
|
||||
errCut := senErr("Ссылка обрезана")
|
||||
take := func(n int) ([]byte, error) {
|
||||
if off+n > len(body) {
|
||||
return nil, errCut
|
||||
}
|
||||
s := body[off : off+n]
|
||||
off += n
|
||||
return s, nil
|
||||
}
|
||||
b1 := func() (byte, error) {
|
||||
b, err := take(1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return b[0], nil
|
||||
}
|
||||
|
||||
version, err := b1()
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
if version != senVersion {
|
||||
return l, senErr("Неизвестная версия ссылки: %d", version)
|
||||
}
|
||||
flags, err := b1()
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
if flags&^flagTLS != 0 {
|
||||
return l, senErr("Неизвестные флаги ссылки")
|
||||
}
|
||||
n, err := b1()
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
if n < 1 || n > 3 {
|
||||
return l, senErr("Неверное число адресов")
|
||||
}
|
||||
for range n {
|
||||
typ, err := b1()
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
var host string
|
||||
switch typ {
|
||||
case addrV4:
|
||||
b, err := take(4)
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
host = fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
|
||||
case addrV6:
|
||||
b, err := take(16)
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
host = bytesToIPv6(b)
|
||||
case addrDomain:
|
||||
ln, err := b1()
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
if ln < 1 {
|
||||
return l, senErr("Пустой домен")
|
||||
}
|
||||
b, err := take(int(ln))
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
host = jsUTF8(b)
|
||||
if !reDomain.MatchString(host) {
|
||||
return l, senErr("Неверный домен")
|
||||
}
|
||||
default:
|
||||
return l, senErr("Неизвестный тип адреса: %d", typ)
|
||||
}
|
||||
pb, err := take(2)
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
port := int(binary.BigEndian.Uint16(pb))
|
||||
if port == 0 {
|
||||
return l, senErr("Порт 0")
|
||||
}
|
||||
l.Addrs = append(l.Addrs, SenAddr{host, port})
|
||||
}
|
||||
if l.Secret, err = take(16); err != nil {
|
||||
return l, err
|
||||
}
|
||||
if l.SignPub, err = take(32); err != nil {
|
||||
return l, err
|
||||
}
|
||||
l.TLS = flags&flagTLS != 0
|
||||
if l.TLS {
|
||||
if l.TLSPin, err = take(32); err != nil {
|
||||
return l, err
|
||||
}
|
||||
}
|
||||
nameLen, err := b1()
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
if nameLen > 64 {
|
||||
return l, senErr("Имя длиннее 64 байт")
|
||||
}
|
||||
nb, err := take(int(nameLen))
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
l.Name = jsUTF8(nb)
|
||||
if off != len(body) {
|
||||
return l, senErr("Лишние данные в конце ссылки")
|
||||
}
|
||||
l.Secret, l.SignPub = bytes.Clone(l.Secret), bytes.Clone(l.SignPub)
|
||||
l.TLSPin = bytes.Clone(l.TLSPin)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// jsUTF8 — Buffer.toString("utf8"): битые последовательности заменяются на U+FFFD.
|
||||
func jsUTF8(b []byte) string {
|
||||
if utf8.Valid(b) {
|
||||
return string(b)
|
||||
}
|
||||
return strings.ToValidUTF8(string(b), "�")
|
||||
}
|
||||
|
||||
// ── Ключи Ed25519 ──────────────────────────────────────────────────────────
|
||||
|
||||
// loadEd25519Private читает PKCS#8 PEM (как crypto.createPrivateKey в Node).
|
||||
func loadEd25519Private(raw []byte) (ed25519.PrivateKey, error) {
|
||||
blk, _ := pem.Decode(raw)
|
||||
if blk == nil {
|
||||
return nil, errors.New("not a PEM key")
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(blk.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv, ok := k.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("not an Ed25519 key")
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
// spkiPin — SHA-256 от SPKI DER сертификата (первого в PEM) — то, что кладётся в ссылку как pin.
|
||||
func spkiPin(certPEM []byte) ([]byte, error) {
|
||||
blk, _ := pem.Decode(certPEM)
|
||||
if blk == nil {
|
||||
return nil, errors.New("not a PEM certificate")
|
||||
}
|
||||
c, err := x509.ParseCertificate(blk.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(c.RawSubjectPublicKeyInfo)
|
||||
return sum[:], nil
|
||||
}
|
||||
|
||||
// ── Подписи протокола ──────────────────────────────────────────────────────
|
||||
|
||||
// signedResponse — {"body": "<JSON-строка>", "sig": "<base64url>"}; sig — Ed25519 над
|
||||
// UTF-8 байтами строки body. body — именно строка, чтобы клиенту не нужна была
|
||||
// каноническая сериализация JSON.
|
||||
func signResponse(payload any, key ed25519.PrivateKey) jsjson.Object {
|
||||
body := string(jsjson.MustMarshal(payload))
|
||||
return jsjson.Object{{Key: "body", Val: body}, {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, []byte(body)))}}
|
||||
}
|
||||
|
||||
// requestSigString — METHOD \n /path \n ts \n hex(sha256(тело запроса)); path — без query.
|
||||
func requestSigString(method, path string, ts int64, body []byte) string {
|
||||
h := sha256.Sum256(body)
|
||||
return strings.ToUpper(method) + "\n" + path + "\n" + strconv.FormatInt(ts, 10) + "\n" + hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func verifyRequest(method, path string, ts int64, body []byte, sig string, authPub []byte) bool {
|
||||
if len(authPub) != ed25519.PublicKeySize {
|
||||
return false
|
||||
}
|
||||
return ed25519.Verify(authPub, []byte(requestSigString(method, path, ts, body)), fromB64url(sig))
|
||||
}
|
||||
|
||||
func crc32IEEE(b []byte) uint32 { return crc32.ChecksumIEEE(b) }
|
||||
@@ -0,0 +1,142 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
// Эталон — вывод самого awg-ui/sen.ts (tools/golden/sen.ts).
|
||||
type senFixture struct {
|
||||
SignSeed string `json:"sign_seed"`
|
||||
SignPub string `json:"sign_pub"`
|
||||
CRC32 []struct {
|
||||
In string `json:"in"`
|
||||
Out uint32 `json:"out"`
|
||||
} `json:"crc32"`
|
||||
Encode []struct {
|
||||
TLS bool `json:"tls"`
|
||||
Addrs []SenAddr `json:"addrs"`
|
||||
Secret string `json:"secret"`
|
||||
TLSPin string `json:"tlsPin"`
|
||||
Name string `json:"name"`
|
||||
Out string `json:"out"`
|
||||
Error string `json:"error"`
|
||||
} `json:"encode"`
|
||||
EncodeErrors []struct {
|
||||
TLS bool `json:"tls"`
|
||||
Addrs []SenAddr `json:"addrs"`
|
||||
Secret string `json:"secret"`
|
||||
Name string `json:"name"`
|
||||
Error string `json:"error"`
|
||||
} `json:"encode_errors"`
|
||||
Decode []struct {
|
||||
In string `json:"in"`
|
||||
Error string `json:"error"`
|
||||
TLS bool `json:"tls"`
|
||||
Addrs []SenAddr `json:"addrs"`
|
||||
Secret string `json:"secret"`
|
||||
SignPub string `json:"signPub"`
|
||||
TLSPin *string `json:"tlsPin"`
|
||||
Name string `json:"name"`
|
||||
} `json:"decode"`
|
||||
SigString []struct {
|
||||
Method, Path, Body, Out, Sig string
|
||||
TS int64 `json:"ts"`
|
||||
} `json:"sig_string"`
|
||||
SignResponse []struct {
|
||||
In json.RawMessage `json:"in"`
|
||||
Out struct{ Body, Sig string }
|
||||
} `json:"sign_response"`
|
||||
}
|
||||
|
||||
func unhex(t *testing.T, s string) []byte {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestSenAgainstNode(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../testdata/golden/sen.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f senFixture
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ed25519.NewKeyFromSeed(unhex(t, f.SignSeed))
|
||||
signPub := key.Public().(ed25519.PublicKey)
|
||||
if hex.EncodeToString(signPub) != f.SignPub {
|
||||
t.Fatal("sign key mismatch")
|
||||
}
|
||||
|
||||
for _, c := range f.CRC32 {
|
||||
if got := crc32IEEE([]byte(c.In)); got != c.Out {
|
||||
t.Errorf("crc32(%q) = %d, want %d", c.In, got, c.Out)
|
||||
}
|
||||
}
|
||||
for _, c := range f.Encode {
|
||||
l := SenLink{TLS: c.TLS, Addrs: c.Addrs, Secret: unhex(t, c.Secret), SignPub: signPub, Name: c.Name}
|
||||
if c.TLSPin != "" {
|
||||
l.TLSPin = unhex(t, c.TLSPin)
|
||||
}
|
||||
got, err := EncodeSenLink(l)
|
||||
if err != nil || got != c.Out {
|
||||
t.Errorf("encode %+v:\n got %s %v\nwant %s", c.Addrs, got, err, c.Out)
|
||||
}
|
||||
}
|
||||
for _, c := range f.EncodeErrors {
|
||||
_, err := EncodeSenLink(SenLink{TLS: c.TLS, Addrs: c.Addrs, Secret: unhex(t, c.Secret), SignPub: signPub, Name: c.Name})
|
||||
if err == nil || err.Error() != c.Error {
|
||||
t.Errorf("encode error %+v: got %v, want %s", c.Addrs, err, c.Error)
|
||||
}
|
||||
}
|
||||
for _, c := range f.Decode {
|
||||
l, err := DecodeSenLink(c.In)
|
||||
if c.Error != "" {
|
||||
if err == nil || err.Error() != c.Error {
|
||||
t.Errorf("decode %q: got %v, want error %s", c.In, err, c.Error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("decode %q: %v", c.In, err)
|
||||
continue
|
||||
}
|
||||
gotAddrs, _ := json.Marshal(l.Addrs)
|
||||
wantAddrs, _ := json.Marshal(c.Addrs)
|
||||
pin := ""
|
||||
if c.TLSPin != nil {
|
||||
pin = *c.TLSPin
|
||||
}
|
||||
if l.TLS != c.TLS || string(gotAddrs) != string(wantAddrs) || hex.EncodeToString(l.Secret) != c.Secret ||
|
||||
hex.EncodeToString(l.SignPub) != c.SignPub || hex.EncodeToString(l.TLSPin) != pin || l.Name != c.Name {
|
||||
t.Errorf("decode %q:\n got %+v %s", c.In, l, gotAddrs)
|
||||
}
|
||||
}
|
||||
for _, c := range f.SigString {
|
||||
if got := requestSigString(c.Method, c.Path, c.TS, []byte(c.Body)); got != c.Out {
|
||||
t.Errorf("sig string: got %q want %q", got, c.Out)
|
||||
}
|
||||
if !verifyRequest(c.Method, c.Path, c.TS, []byte(c.Body), c.Sig, signPub) {
|
||||
t.Errorf("Node signature for %s %s does not verify", c.Method, c.Path)
|
||||
}
|
||||
if verifyRequest(c.Method, c.Path, c.TS+1, []byte(c.Body), c.Sig, signPub) {
|
||||
t.Error("signature verifies for another ts")
|
||||
}
|
||||
}
|
||||
for _, c := range f.SignResponse {
|
||||
v, _ := jsjson.Decode(c.In)
|
||||
got := signResponse(v, key)
|
||||
if got.Get2("body") != c.Out.Body || got.Get2("sig") != c.Out.Sig {
|
||||
t.Errorf("signResponse: got %v want %+v", got, c.Out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+1265
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,804 @@
|
||||
package ui
|
||||
|
||||
// Мастер-ключи: общая логика панели и внешнего API (порт части awg-ui/sub.ts). Панель
|
||||
// (/ui/masterkeys, /ui/devices, за JWT) видит всё. Внешний API (/api/v1/masterkeys, ключ
|
||||
// awgk_) — только ключи, чьи серверы целиком входят в набор серверов API-ключа: иначе он мог бы
|
||||
// удалить или перенастроить раздачу на сервере, которого не видит. scope: nil — панель.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
type apiError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string { return e.msg }
|
||||
|
||||
func fail(status int, msg string) error { return &apiError{status, msg} }
|
||||
|
||||
// aw — apiError даёт свой статус; остальное (awg-ctrl не ответил посреди действия) — 502.
|
||||
func aw(fn func(c *Ctx) (int, any, error)) handler {
|
||||
return func(c *Ctx) bool {
|
||||
var (
|
||||
st int
|
||||
v any
|
||||
err error
|
||||
)
|
||||
func() {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
err = fmt.Errorf("%v", p)
|
||||
}
|
||||
}()
|
||||
st, v, err = fn(c)
|
||||
}()
|
||||
var ae *apiError
|
||||
if errors.As(err, &ae) {
|
||||
return c.Error(ae.status, ae.msg)
|
||||
}
|
||||
if err != nil {
|
||||
return c.Error(502, "awg-ctrl недоступен")
|
||||
}
|
||||
return c.JSON(st, v)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
reUUID = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
reLabel = regexp.MustCompile(`^[^\r\n<>]+$`) // длина — lenOK(1, 40): в JS {1,40} считает UTF-16
|
||||
)
|
||||
|
||||
func labelOK(v any) (string, bool) {
|
||||
s, ok := v.(string)
|
||||
return s, ok && reLabel.MatchString(s) && lenOK(s, 1, 40)
|
||||
}
|
||||
|
||||
// idOf — Number(v), если это безопасное целое, иначе -1 (не найдётся).
|
||||
func idOf(v string) int64 {
|
||||
f := jsToNumber(v)
|
||||
if !isSafeInt(f) {
|
||||
return -1
|
||||
}
|
||||
return int64(f)
|
||||
}
|
||||
|
||||
// masterIdOf — мастер-ключ в пути: числовой id или uuid.
|
||||
func (s *Sub) masterIDOf(v string) int64 {
|
||||
if reUUID.MatchString(v) {
|
||||
var id int64
|
||||
if s.a.db.QueryRow("SELECT id FROM master_keys WHERE uuid = ?", strings.ToLower(v)).Scan(&id) != nil {
|
||||
return -1
|
||||
}
|
||||
return id
|
||||
}
|
||||
return idOf(v)
|
||||
}
|
||||
|
||||
func limitOK(v any) (int64, bool) {
|
||||
f, ok := v.(float64)
|
||||
return int64(f), ok && f == float64(int64(f)) && f >= 1 && f <= 100
|
||||
}
|
||||
|
||||
func (s *Sub) visible(masterID int64, scope []int) bool {
|
||||
if scope == nil {
|
||||
return true
|
||||
}
|
||||
for _, id := range s.serverIDsOf(s.a.db, masterID) {
|
||||
if !containsInt(scope, id) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// masterFor — живой (не удаляемый) ключ в пределах области; чужой — как несуществующий.
|
||||
func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) {
|
||||
m := s.masterByID(s.a.db, id)
|
||||
if m == nil || !s.visible(m.ID, scope) {
|
||||
return nil, fail(404, "Не найден")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// deviceFor — устройство; у внешнего API — только вместе со своим мастер-ключом.
|
||||
func (s *Sub) deviceFor(id int64, master *masterRow) (*deviceRow, error) {
|
||||
d := s.deviceByID(s.a.db, id)
|
||||
if d == nil || master != nil && d.MasterID != master.ID {
|
||||
return nil, fail(404, "Не найдено")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// parseServers — набор серверов из тела: непустой, без повторов, только существующие и — для
|
||||
// внешнего API — только из области его ключа.
|
||||
func (s *Sub) parseServers(v any, scope []int) ([]int, error) {
|
||||
list, ok := v.([]any)
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, fail(400, "Нужен хотя бы один сервер")
|
||||
}
|
||||
if len(list) > maxServers {
|
||||
return nil, fail(400, fmt.Sprintf("Не больше %d серверов", maxServers))
|
||||
}
|
||||
ids, ok := s.a.serverList(v, s.a.nodes.Exists)
|
||||
if !ok {
|
||||
return nil, fail(400, "Такого сервера нет")
|
||||
}
|
||||
if scope != nil {
|
||||
for _, id := range ids {
|
||||
if !containsInt(scope, id) {
|
||||
return nil, fail(403, "Сервер вне области API-ключа")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// masterOut — deleting: ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
func (s *Sub) masterOut(m *masterRow) jsjson.Object {
|
||||
srv := intsJSON(s.serverIDsOf(s.a.db, m.ID))
|
||||
o := jsjson.Object{{Key: "id", Val: m.ID}, {Key: "uuid", Val: m.UUID}, {Key: "label", Val: m.Label}, {Key: "device_limit", Val: m.DeviceLimit},
|
||||
{Key: "devices", Val: m.Devices}, {Key: "servers", Val: srv}, {Key: "created_at", Val: m.CreatedAt}}
|
||||
if m.RevokedAt.Valid {
|
||||
o = append(o, jsjson.KV{Key: "deleting", Val: srv})
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func (s *Sub) masterOutByID(id int64) jsjson.Object { return s.masterOut(s.masterRowByID(s.a.db, id)) }
|
||||
|
||||
func (s *Sub) listMasters(scope []int) []any {
|
||||
rows, err := s.a.db.Query("SELECT " + masterCols + ", (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m ORDER BY id")
|
||||
if err != nil {
|
||||
return []any{}
|
||||
}
|
||||
var ms []*masterRow
|
||||
for rows.Next() {
|
||||
if m := scanMaster(rows, true); m != nil {
|
||||
ms = append(ms, m)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
out := []any{}
|
||||
for _, m := range ms {
|
||||
if s.visible(m.ID, scope) {
|
||||
out = append(out, s.masterOut(m))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// createMaster — серверы по умолчанию: у панели — сервер по умолчанию, у API-ключа — вся его область.
|
||||
func (s *Sub) createMaster(body any, scope []int) (jsjson.Object, error) {
|
||||
b, _ := body.(jsjson.Object)
|
||||
label, ok := labelOK(b.Get2("label"))
|
||||
if !ok {
|
||||
return nil, fail(400, "Метка: до 40 символов, без переводов строк и <>")
|
||||
}
|
||||
limitV, hasLimit := b.Get("device_limit")
|
||||
limit := int64(defaultLimit)
|
||||
if hasLimit {
|
||||
var ok bool
|
||||
if limit, ok = limitOK(limitV); !ok {
|
||||
return nil, fail(400, "Лимит устройств: от 1 до 100")
|
||||
}
|
||||
}
|
||||
wanted, hasWanted := b.Get("servers")
|
||||
src := wanted
|
||||
if src == nil {
|
||||
if scope != nil {
|
||||
list := []any{} // как пришло бы из JSON: числа — float64
|
||||
for _, id := range scope {
|
||||
list = append(list, float64(id))
|
||||
}
|
||||
src = list
|
||||
} else {
|
||||
src = []any{float64(s.a.nodes.DefaultID())}
|
||||
}
|
||||
}
|
||||
ids, err := s.parseServers(src, scope)
|
||||
if err != nil {
|
||||
if !hasWanted && scope == nil {
|
||||
return nil, fail(400, "Сначала добавьте сервер")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
secret := make([]byte, 16)
|
||||
rand.Read(secret)
|
||||
var id int64
|
||||
err = s.a.tx(func(tx *sql.Tx) error {
|
||||
r, err := tx.Exec("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
uuid.NewString(), label, secret, limit, ids[0], now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ = r.LastInsertId()
|
||||
for _, sid := range ids {
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", id, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.masterOutByID(id), nil
|
||||
}
|
||||
|
||||
func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object, error) {
|
||||
b, _ := body.(jsjson.Object)
|
||||
nlV := b.Get2("label")
|
||||
if nlV == nil {
|
||||
nlV = m.Label
|
||||
}
|
||||
ndV := b.Get2("device_limit")
|
||||
if ndV == nil {
|
||||
ndV = float64(m.DeviceLimit)
|
||||
}
|
||||
nl, ok := labelOK(nlV)
|
||||
if !ok {
|
||||
return nil, fail(400, "Неверная метка")
|
||||
}
|
||||
nd, ok := limitOK(ndV)
|
||||
if !ok {
|
||||
return nil, fail(400, "Лимит устройств: от 1 до 100")
|
||||
}
|
||||
|
||||
ids := s.serverIDsOf(s.a.db, m.ID)
|
||||
var affected []int
|
||||
if wanted, has := b.Get("servers"); has {
|
||||
next, err := s.parseServers(wanted, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var added, removed []int
|
||||
for _, x := range next {
|
||||
if !containsInt(ids, x) {
|
||||
added = append(added, x)
|
||||
}
|
||||
}
|
||||
for _, x := range ids {
|
||||
if !containsInt(next, x) {
|
||||
removed = append(removed, x)
|
||||
}
|
||||
}
|
||||
devices := s.devicesOf(s.a.db, m.ID)
|
||||
if err := s.a.tx(func(tx *sql.Tx) error {
|
||||
for _, sid := range removed {
|
||||
tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid)
|
||||
for _, d := range devices {
|
||||
for _, l := range s.linksOf(tx, d.ID) {
|
||||
if l.ServerID == sid && l.PeerPub.Valid {
|
||||
tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String)
|
||||
}
|
||||
}
|
||||
tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid)
|
||||
}
|
||||
}
|
||||
for _, sid := range added {
|
||||
tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid)
|
||||
for _, d := range devices {
|
||||
tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
affected = append(append(affected, added...), removed...)
|
||||
ids = next
|
||||
}
|
||||
s.a.db.Exec("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?", nl, nd, m.ID)
|
||||
// Лимит стал меньше, чем устройств: лишние отвязываются, начиная с подключившихся последними.
|
||||
unbound := []any{}
|
||||
devices := s.devicesOf(s.a.db, m.ID)
|
||||
if int64(len(devices)) > nd {
|
||||
newest := append([]*deviceRow{}, devices...)
|
||||
sort.SliceStable(newest, func(i, j int) bool {
|
||||
if newest[i].CreatedAt != newest[j].CreatedAt {
|
||||
return newest[i].CreatedAt > newest[j].CreatedAt
|
||||
}
|
||||
return newest[i].ID > newest[j].ID
|
||||
})
|
||||
for _, d := range newest[:int64(len(devices))-nd] {
|
||||
affected = append(affected, s.forgetDeviceTx(d)...)
|
||||
unbound = append(unbound, d.ID)
|
||||
}
|
||||
}
|
||||
s.reconcileAll(affected)
|
||||
return jsjson.Object{{Key: "id", Val: m.ID}, {Key: "label", Val: nl}, {Key: "device_limit", Val: nd},
|
||||
{Key: "servers", Val: intsJSON(ids)}, {Key: "unbound", Val: unbound}}, nil
|
||||
}
|
||||
|
||||
// rotateMaster — новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
|
||||
func (s *Sub) rotateMaster(m *masterRow) {
|
||||
secret := make([]byte, 16)
|
||||
rand.Read(secret)
|
||||
s.a.db.Exec("UPDATE master_keys SET secret = ? WHERE id = ?", secret, m.ID)
|
||||
}
|
||||
|
||||
func (s *Sub) rekeyMaster(m *masterRow) {
|
||||
s.a.db.Exec("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?", -1, m.ID)
|
||||
}
|
||||
|
||||
// deleteMaster — ключ сразу перестаёт работать, но строка остаётся отозванной, пока каждый его
|
||||
// сервер не подтвердит, что снял пиров.
|
||||
func (s *Sub) deleteMaster(m *masterRow) jsjson.Object {
|
||||
affected := s.serverIDsOf(s.a.db, m.ID)
|
||||
s.a.tx(func(tx *sql.Tx) error {
|
||||
for _, d := range s.devicesOf(tx, m.ID) {
|
||||
affected = append(affected, s.forgetDevice(tx, d)...)
|
||||
}
|
||||
_, err := tx.Exec("UPDATE master_keys SET revoked_at = ? WHERE id = ?", now(), m.ID)
|
||||
return err
|
||||
})
|
||||
s.reconcileAll(affected)
|
||||
return jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: m.ID}, {Key: "pending", Val: intsJSON(s.serverIDsOf(s.a.db, m.ID))}}
|
||||
}
|
||||
|
||||
// linkProblem — выдать ссылку можно, только если подписка настроена и известен адрес панели.
|
||||
func (s *Sub) linkProblem(c *Ctx) string {
|
||||
if s.key() == nil || s.signPub == nil || s.port == 0 {
|
||||
return "Подписка не настроена на сервере"
|
||||
}
|
||||
if s.subHost(c) == "" {
|
||||
return "Не удалось определить адрес панели — задайте SUB_HOST"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Sub) linkOf(m *masterRow, c *Ctx) (jsjson.Object, error) {
|
||||
if p := s.linkProblem(c); p != "" {
|
||||
return nil, fail(503, p)
|
||||
}
|
||||
link, err := EncodeSenLink(SenLink{TLS: s.tlsPin != nil, Addrs: []SenAddr{{s.subHost(c), s.port}},
|
||||
Secret: m.Secret, SignPub: s.signPub, TLSPin: s.tlsPin, Name: m.Label})
|
||||
if err != nil {
|
||||
return nil, err // как в Node: не ApiError — 502
|
||||
}
|
||||
return jsjson.Object{{Key: "link", Val: link}, {Key: "tls", Val: s.tlsPin != nil}}, nil
|
||||
}
|
||||
|
||||
type devStat struct {
|
||||
online bool
|
||||
lastHS float64
|
||||
rx, tx float64
|
||||
background bool
|
||||
}
|
||||
|
||||
// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий
|
||||
// handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ.
|
||||
func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
|
||||
stats := map[string]*devStat{}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var firstErr error
|
||||
for _, id := range s.serverIDsOf(s.a.db, m.ID) {
|
||||
if !s.a.nodes.IsOnline(id) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ok2xx(r.Status) {
|
||||
return
|
||||
}
|
||||
for _, p := range arr(field(r.Data, "peers")) {
|
||||
pub, _ := field(p, "pub_key").(string)
|
||||
st := stats[pub]
|
||||
if st == nil {
|
||||
st = &devStat{}
|
||||
stats[pub] = st
|
||||
}
|
||||
st.online = st.online || truthy(field(p, "online"))
|
||||
if h := numOr(field(p, "lastHandshake"), 0); h > st.lastHS {
|
||||
st.lastHS = h
|
||||
}
|
||||
st.rx += numOr(field(p, "rx"), 0)
|
||||
st.tx += numOr(field(p, "tx"), 0)
|
||||
// Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них.
|
||||
st.background = st.background || field(p, "priority") == "background"
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
out := []any{}
|
||||
for _, d := range s.devicesOf(s.a.db, m.ID) {
|
||||
links := s.linksOf(s.a.db, d.ID)
|
||||
st := stats[d.PubKey]
|
||||
if st == nil {
|
||||
st = &devStat{}
|
||||
}
|
||||
okN := 0
|
||||
for _, l := range links {
|
||||
if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" {
|
||||
okN++
|
||||
}
|
||||
}
|
||||
prio := "normal"
|
||||
if st.background {
|
||||
prio = "background"
|
||||
}
|
||||
out = append(out, jsjson.Object{
|
||||
{Key: "id", Val: d.ID}, {Key: "device_id", Val: d.DeviceID}, {Key: "device_name", Val: d.DeviceName}, {Key: "platform", Val: d.Platform},
|
||||
{Key: "version", Val: d.Version}, {Key: "created_at", Val: d.CreatedAt}, {Key: "last_seen", Val: nullInt(d.LastSeen)},
|
||||
{Key: "rekey_requested", Val: d.RekeyRequested == 1}, {Key: "online", Val: st.online}, {Key: "lastHandshake", Val: st.lastHS},
|
||||
{Key: "rx", Val: st.rx}, {Key: "tx", Val: st.tx}, {Key: "priority", Val: prio},
|
||||
{Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Sub) deleteDevice(d *deviceRow) jsjson.Object {
|
||||
s.reconcileAll(s.forgetDeviceTx(d))
|
||||
return jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: d.ID}}
|
||||
}
|
||||
|
||||
func (s *Sub) rekeyDevice(d *deviceRow) {
|
||||
s.a.db.Exec("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?", d.ID, -1)
|
||||
}
|
||||
|
||||
// rotatePsk — новый PSK на каждом сервере; недоступные получат его, когда появятся.
|
||||
func (s *Sub) rotatePsk(d *deviceRow) jsjson.Object {
|
||||
s.a.db.Exec("UPDATE device_peers SET psk_pending = 1 WHERE device_id = ? AND peer_pub IS NOT NULL", d.ID)
|
||||
var ids []int
|
||||
for _, l := range s.linksOf(s.a.db, d.ID) {
|
||||
ids = append(ids, l.ServerID)
|
||||
}
|
||||
s.reconcileAll(ids)
|
||||
pending := 0
|
||||
for _, l := range s.linksOf(s.a.db, d.ID) {
|
||||
if l.PSKPending == 1 {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
return jsjson.Object{{Key: "id", Val: d.ID}, {Key: "pending", Val: pending}}
|
||||
}
|
||||
|
||||
func idObj(id int64) jsjson.Object { return jsjson.Object{{Key: "id", Val: id}} }
|
||||
|
||||
func merge(a, b jsjson.Object) jsjson.Object {
|
||||
out := append(jsjson.Object{}, a...)
|
||||
for _, kv := range b {
|
||||
replaced := false
|
||||
for i := range out {
|
||||
if out[i].Key == kv.Key {
|
||||
out[i].Val, replaced = kv.Val, true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Роуты панели (/ui/masterkeys, /ui/devices) ──────────────────────────────
|
||||
|
||||
func (s *Sub) masterKeysRouter() *Router {
|
||||
rt := newRouter()
|
||||
auditOff := fail(409, "Аудит выключен на этой панели")
|
||||
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) }
|
||||
|
||||
rt.Get("/", func(c *Ctx) bool {
|
||||
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)}})
|
||||
})
|
||||
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
|
||||
rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) })
|
||||
rt.Post("/audit", aw(func(c *Ctx) (int, any, error) {
|
||||
if !s.auditOn {
|
||||
return 0, nil, auditOff
|
||||
}
|
||||
s.auditAll(false)
|
||||
return 200, s.auditState(), nil
|
||||
}))
|
||||
rt.Post("/audit/clean", aw(func(c *Ctx) (int, any, error) {
|
||||
if !s.auditOn {
|
||||
return 0, nil, auditOff
|
||||
}
|
||||
s.cleanNow()
|
||||
return 200, s.auditState(), nil
|
||||
}))
|
||||
rt.Put("/audit/settings", aw(func(c *Ctx) (int, any, error) {
|
||||
if !s.auditOn {
|
||||
return 0, nil, auditOff
|
||||
}
|
||||
b := c.Obj()
|
||||
s.auditMu.Lock()
|
||||
next := s.auditSettings
|
||||
s.auditMu.Unlock()
|
||||
if v, has := b.Get("clean"); has {
|
||||
bv, ok := v.(bool)
|
||||
if !ok {
|
||||
return 0, nil, fail(400, "clean: true или false")
|
||||
}
|
||||
next.Clean = bv
|
||||
}
|
||||
if v, has := b.Get("every_h"); has {
|
||||
if !isInt(v) || v.(float64) < 1 || v.(float64) > 168 {
|
||||
return 0, nil, fail(400, "Период: от 1 до 168 часов")
|
||||
}
|
||||
next.EveryH = int(v.(float64))
|
||||
}
|
||||
if v, has := b.Get("max"); has {
|
||||
if !isInt(v) || v.(float64) < 1 || v.(float64) > 1000 {
|
||||
return 0, nil, fail(400, "Предел: от 1 до 1000")
|
||||
}
|
||||
next.Max = int(v.(float64))
|
||||
}
|
||||
s.auditMu.Lock()
|
||||
s.auditSettings = next
|
||||
s.metaSet("audit_settings", next.json())
|
||||
s.auditMu.Unlock()
|
||||
return 200, s.auditState(), nil
|
||||
}))
|
||||
rt.Post("/", aw(func(c *Ctx) (int, any, error) {
|
||||
out, err := s.createMaster(c.Body, nil)
|
||||
return 201, out, err
|
||||
}))
|
||||
rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
out, err := s.updateMaster(m, c.Body, nil)
|
||||
return 200, out, err
|
||||
}))
|
||||
rt.Post("/:id/rotate", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
s.rotateMaster(m)
|
||||
return 200, idObj(m.ID), nil
|
||||
}))
|
||||
rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
s.rekeyMaster(m)
|
||||
return 200, idObj(m.ID), nil
|
||||
}))
|
||||
rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return 200, s.deleteMaster(m), nil
|
||||
}))
|
||||
rt.Get("/:id/link", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
out, err := s.linkOf(m, c)
|
||||
return 200, out, err
|
||||
}))
|
||||
rt.Get("/:id/devices", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
list, err := s.masterDevices(m)
|
||||
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
|
||||
}))
|
||||
return rt
|
||||
}
|
||||
|
||||
func (s *Sub) devicesRouter() *Router {
|
||||
rt := newRouter()
|
||||
dev := func(c *Ctx) (*deviceRow, error) { return s.deviceFor(idOf(c.Params["id"]), nil) }
|
||||
rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) {
|
||||
d, err := dev(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return 200, s.deleteDevice(d), nil
|
||||
}))
|
||||
rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) {
|
||||
d, err := dev(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
s.rekeyDevice(d)
|
||||
return 200, idObj(d.ID), nil
|
||||
}))
|
||||
rt.Post("/:id/psk", aw(func(c *Ctx) (int, any, error) {
|
||||
d, err := dev(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return 200, s.rotatePsk(d), nil
|
||||
}))
|
||||
return rt
|
||||
}
|
||||
|
||||
// ── Внешний API: /api/v1/masterkeys (ключ awgk_) ────────────────────────────
|
||||
// scopeOf — набор серверов API-ключа запроса. Устройства — вложенными путями, чтобы
|
||||
// область проверялась через их мастер-ключ.
|
||||
func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router {
|
||||
rt := newRouter()
|
||||
scope := func(c *Ctx) []int {
|
||||
if sc := scopeOf(c); sc != nil {
|
||||
return sc
|
||||
}
|
||||
return []int{}
|
||||
}
|
||||
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), scope(c)) }
|
||||
device := func(c *Ctx) (*deviceRow, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.deviceFor(idOf(c.Params["device"]), m)
|
||||
}
|
||||
withLink := func(c *Ctx, m *masterRow, base jsjson.Object) (jsjson.Object, error) {
|
||||
l, err := s.linkOf(m, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return merge(base, l), nil
|
||||
}
|
||||
|
||||
rt.Get("/", aw(func(c *Ctx) (int, any, error) {
|
||||
return 200, jsjson.Object{{Key: "keys", Val: s.listMasters(scope(c))}}, nil
|
||||
}))
|
||||
rt.Post("/", aw(func(c *Ctx) (int, any, error) {
|
||||
if p := s.linkProblem(c); p != "" { // без ссылки ключ внешней программе бесполезен
|
||||
return 0, nil, fail(503, p)
|
||||
}
|
||||
out, err := s.createMaster(c.Body, scope(c))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
m, err := s.masterFor(int64(out.Get2("id").(int64)), nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
res, err := withLink(c, m, out)
|
||||
return 201, res, err
|
||||
}))
|
||||
rt.Get("/:id", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
// devices — счётчик, как в списке; сами устройства — в device_list.
|
||||
out := s.masterOutByID(m.ID)
|
||||
if s.linkProblem(c) != "" {
|
||||
out = merge(out, jsjson.Object{{Key: "link", Val: nil}, {Key: "tls", Val: s.tlsPin != nil}})
|
||||
} else if out, err = withLink(c, m, out); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
list, err := s.masterDevices(m)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return 200, merge(out, jsjson.Object{{Key: "device_list", Val: list}}), nil
|
||||
}))
|
||||
rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
out, err := s.updateMaster(m, c.Body, scope(c))
|
||||
return 200, out, err
|
||||
}))
|
||||
rt.Post("/:id/rotate", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
s.rotateMaster(m)
|
||||
m2, err := s.masterFor(m.ID, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
out, err := withLink(c, m2, idObj(m.ID))
|
||||
return 200, out, err
|
||||
}))
|
||||
rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
s.rekeyMaster(m)
|
||||
return 200, idObj(m.ID), nil
|
||||
}))
|
||||
rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return 200, s.deleteMaster(m), nil
|
||||
}))
|
||||
rt.Get("/:id/devices", aw(func(c *Ctx) (int, any, error) {
|
||||
m, err := master(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
list, err := s.masterDevices(m)
|
||||
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
|
||||
}))
|
||||
rt.Delete("/:id/devices/:device", aw(func(c *Ctx) (int, any, error) {
|
||||
d, err := device(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return 200, s.deleteDevice(d), nil
|
||||
}))
|
||||
rt.Post("/:id/devices/:device/rekey", aw(func(c *Ctx) (int, any, error) {
|
||||
d, err := device(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
s.rekeyDevice(d)
|
||||
return 200, idObj(d.ID), nil
|
||||
}))
|
||||
rt.Post("/:id/devices/:device/psk", aw(func(c *Ctx) (int, any, error) {
|
||||
d, err := device(c)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return 200, s.rotatePsk(d), nil
|
||||
}))
|
||||
return rt
|
||||
}
|
||||
|
||||
// ── Удаление сервера из реестра ─────────────────────────────────────────────
|
||||
|
||||
// beforeServerDelete — пока сервером пользуется живой мастер-ключ — нельзя. Иначе сперва
|
||||
// сводим его (пиры панели снимутся) — после удаления до него уже не дотянуться. Не вышло
|
||||
// (нода не в сети) — удалять только с force.
|
||||
func (s *Sub) beforeServerDelete(id int, force bool) *deleteVeto {
|
||||
live := strs(s.a.db, "SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NULL", id)
|
||||
if len(live) > 0 {
|
||||
return &deleteVeto{Error: "Сервер в мастер-ключах: " + strings.Join(live, ", ") + " — сначала уберите его оттуда"}
|
||||
}
|
||||
if s.a.nodes.IsOnline(id) {
|
||||
s.reconcile(id)
|
||||
}
|
||||
waiting := strs(s.a.db, "SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NOT NULL", id)
|
||||
tombs := s.tombCount(id)
|
||||
if len(waiting) == 0 && tombs == 0 || force {
|
||||
return nil
|
||||
}
|
||||
msg := "Сервер не подтвердил, что снял пиров"
|
||||
if len(waiting) > 0 {
|
||||
msg += " удалённых мастер-ключей (" + strings.Join(waiting, ", ") + ")"
|
||||
}
|
||||
msg += ". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)"
|
||||
return &deleteVeto{Error: msg, Forcible: true}
|
||||
}
|
||||
|
||||
func (s *Sub) afterServerDelete(id int) {
|
||||
s.a.db.Exec("DELETE FROM master_servers WHERE server_id = ?", id)
|
||||
s.a.db.Exec("DELETE FROM master_keys WHERE revoked_at IS NOT NULL AND id NOT IN (SELECT master_id FROM master_servers)")
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package ui
|
||||
|
||||
// Минимальный роутер с поведением Express 4, на которое опирается фронтенд и внешние клиенты:
|
||||
// - тело разбирает express.json() до маршрутизации и для всех путей: без тела или не JSON —
|
||||
// пустой объект {}, битый JSON / не объект и не массив — HTML 400, больше лимита — HTML 413;
|
||||
// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется;
|
||||
// - use(prefix) совпадает по границе сегмента; первое подходящее объявление выигрывает;
|
||||
// - неизвестный путь — HTML 404 «Cannot METHOD /path», как finalhandler.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
type Ctx struct {
|
||||
W http.ResponseWriter
|
||||
R *http.Request
|
||||
Params map[string]string
|
||||
Body any // jsjson.Object, []any; без тела — пустой Object
|
||||
RawBody []byte // как пришло (для подписи /sub/v1)
|
||||
Path string // путь внутри смонтированного use(prefix), как req.path в под-роутере
|
||||
|
||||
// Внешний API: ключ запроса, его серверы и выбранный сервер.
|
||||
apiKey *apiKeyRow
|
||||
servers []int
|
||||
serverID int
|
||||
device *deviceRow
|
||||
}
|
||||
|
||||
// handler возвращает false, если ответ уже отправлен и цепочка дальше не идёт.
|
||||
type handler func(c *Ctx) bool
|
||||
|
||||
type route struct {
|
||||
method string // "" — любой (use)
|
||||
prefix bool // use(): совпадение по префиксу
|
||||
segs []string
|
||||
hs []handler
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
routes []route
|
||||
// notFound — ответ, если ничего не совпало (у /sub/v1 он подписанный).
|
||||
notFound func(c *Ctx)
|
||||
// badBody — ответ на неразобранное тело (у /sub/v1 подписанный 400).
|
||||
badBody func(w http.ResponseWriter, r *http.Request, status int, msg string)
|
||||
bodyLimit int64
|
||||
}
|
||||
|
||||
func newRouter() *Router { return &Router{bodyLimit: 100 * 1024} }
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func (rt *Router) add(method string, prefix bool, pattern string, hs ...handler) {
|
||||
rt.routes = append(rt.routes, route{method: method, prefix: prefix, segs: splitPath(pattern), hs: hs})
|
||||
}
|
||||
|
||||
func (rt *Router) Get(p string, hs ...handler) { rt.add("GET", false, p, hs...) }
|
||||
func (rt *Router) Post(p string, hs ...handler) { rt.add("POST", false, p, hs...) }
|
||||
func (rt *Router) Put(p string, hs ...handler) { rt.add("PUT", false, p, hs...) }
|
||||
func (rt *Router) Patch(p string, hs ...handler) { rt.add("PATCH", false, p, hs...) }
|
||||
func (rt *Router) Delete(p string, hs ...handler) { rt.add("DELETE", false, p, hs...) }
|
||||
func (rt *Router) Use(p string, hs ...handler) { rt.add("", true, p, hs...) }
|
||||
|
||||
// Mount — app.use(prefix, mw..., router): маршруты под-роутера с префиксом и общими mw.
|
||||
func (rt *Router) Mount(prefix string, sub *Router, mw ...handler) {
|
||||
pre := splitPath(prefix)
|
||||
for _, r := range sub.routes {
|
||||
segs := append(append([]string{}, pre...), r.segs...)
|
||||
rt.routes = append(rt.routes, route{method: r.method, prefix: r.prefix, segs: segs, hs: append(append([]handler{}, mw...), r.hs...)})
|
||||
}
|
||||
}
|
||||
|
||||
// errDecode — %-последовательность в параметре не декодируется (Express: 400 URIError).
|
||||
type errDecode struct{}
|
||||
|
||||
func match(r route, method string, segs []string) (map[string]string, int, error) {
|
||||
if r.method != "" && r.method != method && !(r.method == "GET" && method == "HEAD") {
|
||||
return nil, 0, nil
|
||||
}
|
||||
if len(segs) < len(r.segs) || !r.prefix && len(segs) != len(r.segs) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
params := map[string]string{}
|
||||
for i, p := range r.segs {
|
||||
if strings.HasPrefix(p, ":") {
|
||||
v, err := url.PathUnescape(segs[i])
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("URIError: Failed to decode param '%s'", segs[i])
|
||||
}
|
||||
params[p[1:]] = v
|
||||
} else if !strings.EqualFold(p, segs[i]) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
}
|
||||
return params, len(r.segs), nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
b, err := jsjson.Marshal(v)
|
||||
if err != nil {
|
||||
status, b = 500, []byte(`{"error":"Internal server error"}`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func writeHTMLError(w http.ResponseWriter, status int, text string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>%s</pre>\n</body>\n</html>\n", html.EscapeString(text))
|
||||
}
|
||||
|
||||
// readBody — express.json({limit}): только application/json, объект или массив.
|
||||
func readBody(r *http.Request, limit int64) (body any, raw []byte, status int, msg string) {
|
||||
empty := jsjson.Object{}
|
||||
mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if mt != "application/json" || r.Body == nil || r.Body == http.NoBody {
|
||||
return empty, nil, 0, ""
|
||||
}
|
||||
if r.ContentLength > limit {
|
||||
return nil, nil, 413, "PayloadTooLargeError: request entity too large"
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, nil, 400, "BadRequestError: request aborted"
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
return nil, nil, 413, "PayloadTooLargeError: request entity too large"
|
||||
}
|
||||
trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace)
|
||||
if trimmed == "" {
|
||||
return empty, raw, 0, ""
|
||||
}
|
||||
if trimmed[0] != '{' && trimmed[0] != '[' {
|
||||
return nil, nil, 400, "SyntaxError: Unexpected token in JSON"
|
||||
}
|
||||
v, err := jsjson.Decode(raw)
|
||||
if err != nil {
|
||||
return nil, nil, 400, "SyntaxError: " + err.Error()
|
||||
}
|
||||
return v, raw, 0, ""
|
||||
}
|
||||
|
||||
func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
body, raw, status, msg := readBody(r, rt.bodyLimit)
|
||||
if status != 0 {
|
||||
if rt.badBody != nil {
|
||||
rt.badBody(w, r, status, msg)
|
||||
} else {
|
||||
writeHTMLError(w, status, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
segs := splitPath(r.URL.EscapedPath())
|
||||
c := &Ctx{W: w, R: r, Body: body, RawBody: raw}
|
||||
for _, route := range rt.routes {
|
||||
params, n, err := match(route, r.Method, segs)
|
||||
if err != nil {
|
||||
writeHTMLError(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if params == nil {
|
||||
continue
|
||||
}
|
||||
c.Params, c.Path = params, "/"+strings.Join(segs[n:], "/")
|
||||
for _, h := range route.hs {
|
||||
if !h(c) {
|
||||
return // ответ отправлен
|
||||
}
|
||||
}
|
||||
// Все обработчики вызвали next() — ищем дальше, как Express.
|
||||
}
|
||||
if rt.notFound != nil {
|
||||
rt.notFound(c)
|
||||
return
|
||||
}
|
||||
writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.EscapedPath())
|
||||
}
|
||||
|
||||
// ── Ответы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *Ctx) JSON(status int, v any) bool {
|
||||
writeJSON(c.W, status, v)
|
||||
return false
|
||||
}
|
||||
|
||||
func errObj(msg string) jsjson.Object { return jsjson.Object{{Key: "error", Val: msg}} }
|
||||
|
||||
func (c *Ctx) Error(status int, msg string) bool { return c.JSON(status, errObj(msg)) }
|
||||
|
||||
func (c *Ctx) Obj() jsjson.Object {
|
||||
o, _ := c.Body.(jsjson.Object)
|
||||
return o
|
||||
}
|
||||
|
||||
// remoteIP — req.socket.remoteAddress.
|
||||
func (c *Ctx) remoteIP() string {
|
||||
host, _, err := net.SplitHostPort(c.R.RemoteAddr)
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках.
|
||||
func (c *Ctx) hostname() string {
|
||||
h := c.R.Host
|
||||
if strings.HasPrefix(h, "[") {
|
||||
if i := strings.Index(h, "]"); i >= 0 {
|
||||
return h[:i+1]
|
||||
}
|
||||
return h
|
||||
}
|
||||
if i := strings.Index(h, ":"); i >= 0 {
|
||||
return h[:i]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ── Значения из разобранного JSON (как обращение к полям в JS) ──────────────
|
||||
|
||||
// field — v.a.b.c; чего нет — nil (undefined).
|
||||
func field(v any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
o, ok := v.(jsjson.Object)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
v = o.Get2(k)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func has(o jsjson.Object, k string) bool {
|
||||
_, ok := o.Get(k)
|
||||
return ok
|
||||
}
|
||||
|
||||
func str(v any) (string, bool) {
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func num(v any) (float64, bool) {
|
||||
f, ok := v.(float64)
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// isInt — Number.isInteger.
|
||||
func isInt(v any) bool {
|
||||
f, ok := v.(float64)
|
||||
return ok && f == float64(int64(f)) && f >= -9007199254740991*1024 && f <= 9007199254740991*1024
|
||||
}
|
||||
|
||||
// isSafeInt — Number.isSafeInteger.
|
||||
func isSafeInt(f float64) bool {
|
||||
return f == float64(int64(f)) && f >= -9007199254740991 && f <= 9007199254740991
|
||||
}
|
||||
|
||||
// numOr — `x ?? def` для числового поля.
|
||||
func numOr(v any, def float64) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
if v == nil {
|
||||
return def
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func strOr(v any, def string) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func truthy(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return x
|
||||
case float64:
|
||||
return x != 0 && x == x
|
||||
case string:
|
||||
return x != ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func arr(v any) []any {
|
||||
a, _ := v.([]any)
|
||||
return a
|
||||
}
|
||||
Vendored
+323
@@ -0,0 +1,323 @@
|
||||
{
|
||||
"sign_seed": "0b6f395ca14ac202374d5cff678b71157d0bef7e00c3045e9b80aab4ffb85276",
|
||||
"sign_pub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
|
||||
"crc32": [
|
||||
{
|
||||
"in": "",
|
||||
"out": 0
|
||||
},
|
||||
{
|
||||
"in": "a",
|
||||
"out": 3904355907
|
||||
},
|
||||
{
|
||||
"in": "hello",
|
||||
"out": 907060870
|
||||
},
|
||||
{
|
||||
"in": "кириллица",
|
||||
"out": 2804376611
|
||||
}
|
||||
],
|
||||
"encode": [
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "203.0.113.7",
|
||||
"port": 8443
|
||||
}
|
||||
],
|
||||
"secret": "e8bc163c82eee18733288c7d4ac636db",
|
||||
"name": "Семья",
|
||||
"out": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg"
|
||||
},
|
||||
{
|
||||
"tls": true,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "vpn.example.com",
|
||||
"port": 443
|
||||
}
|
||||
],
|
||||
"secret": "ad328846aa18b32a335816374511cac1",
|
||||
"tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812",
|
||||
"name": "",
|
||||
"out": "sen://AQEBRA92cG4uZXhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk"
|
||||
},
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "2001:db8::1",
|
||||
"port": 1
|
||||
},
|
||||
{
|
||||
"host": "::",
|
||||
"port": 65535
|
||||
},
|
||||
{
|
||||
"host": "fe80:0:0:1:0:0:0:1",
|
||||
"port": 80
|
||||
}
|
||||
],
|
||||
"secret": "41242b9fae56fad4e6e77dfe33cb18d1",
|
||||
"name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"out": "sen://AQADBiABDbgAAAAAAAAAAAAAAAEAAQYAAAAAAAAAAAAAAAAAAAAA__8G_oAAAAAAAAEAAAAAAAAAAQBQQSQrn65W-tTm533-M8sY0auEMnNtF8YBXD8UTU7398FgRU4NbbHNni55pp4LqVXwQHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHhMjeF6"
|
||||
},
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "1:2:3:4:5:6:7:8",
|
||||
"port": 2
|
||||
}
|
||||
],
|
||||
"secret": "5b840157e7e86aef3b3fd0fc24f3add3",
|
||||
"name": "emoji 😀 & <>",
|
||||
"out": "sen://AQABBgABAAIAAwAEAAUABgAHAAgAAluEAVfn6GrvOz_Q_CTzrdOrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8A9lbW9qaSDwn5iAICYgPD6UZmVs"
|
||||
}
|
||||
],
|
||||
"encode_errors": [
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [],
|
||||
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
|
||||
"name": "",
|
||||
"error": "Адресов должно быть от 1 до 3"
|
||||
},
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "256.1.1.1",
|
||||
"port": 1
|
||||
}
|
||||
],
|
||||
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
|
||||
"name": "",
|
||||
"error": "Неверный IPv4: 256.1.1.1"
|
||||
},
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "1::2::3",
|
||||
"port": 1
|
||||
}
|
||||
],
|
||||
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
|
||||
"name": "",
|
||||
"error": "Неверный IPv6: 1::2::3"
|
||||
},
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "bad_host",
|
||||
"port": 1
|
||||
}
|
||||
],
|
||||
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
|
||||
"name": "",
|
||||
"error": "Неверный домен: bad_host"
|
||||
},
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "a.b",
|
||||
"port": 0
|
||||
}
|
||||
],
|
||||
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
|
||||
"name": "",
|
||||
"error": "Неверный порт: 0"
|
||||
},
|
||||
{
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "a.b",
|
||||
"port": 1
|
||||
}
|
||||
],
|
||||
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
|
||||
"name": "яяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяя",
|
||||
"error": "Имя: до 64 байт"
|
||||
},
|
||||
{
|
||||
"tls": true,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "a.b",
|
||||
"port": 1
|
||||
}
|
||||
],
|
||||
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
|
||||
"name": "",
|
||||
"error": "tlsPin: 32 байта при TLS"
|
||||
}
|
||||
],
|
||||
"decode": [
|
||||
{
|
||||
"in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg",
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "203.0.113.7",
|
||||
"port": 8443
|
||||
}
|
||||
],
|
||||
"secret": "e8bc163c82eee18733288c7d4ac636db",
|
||||
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
|
||||
"tlsPin": null,
|
||||
"name": "Семья"
|
||||
},
|
||||
{
|
||||
"in": "sen://AQEBRA92cG4uZXhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk",
|
||||
"tls": true,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "vpn.example.com",
|
||||
"port": 443
|
||||
}
|
||||
],
|
||||
"secret": "ad328846aa18b32a335816374511cac1",
|
||||
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
|
||||
"tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"in": "sen://AQADBiABDbgAAAAAAAAAAAAAAAEAAQYAAAAAAAAAAAAAAAAAAAAA__8G_oAAAAAAAAEAAAAAAAAAAQBQQSQrn65W-tTm533-M8sY0auEMnNtF8YBXD8UTU7398FgRU4NbbHNni55pp4LqVXwQHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHhMjeF6",
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "2001:db8::1",
|
||||
"port": 1
|
||||
},
|
||||
{
|
||||
"host": "::",
|
||||
"port": 65535
|
||||
},
|
||||
{
|
||||
"host": "fe80:0:0:1::1",
|
||||
"port": 80
|
||||
}
|
||||
],
|
||||
"secret": "41242b9fae56fad4e6e77dfe33cb18d1",
|
||||
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
|
||||
"tlsPin": null,
|
||||
"name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
},
|
||||
{
|
||||
"in": "sen://AQABBgABAAIAAwAEAAUABgAHAAgAAluEAVfn6GrvOz_Q_CTzrdOrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8A9lbW9qaSDwn5iAICYgPD6UZmVs",
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "1:2:3:4:5:6:7:8",
|
||||
"port": 2
|
||||
}
|
||||
],
|
||||
"secret": "5b840157e7e86aef3b3fd0fc24f3add3",
|
||||
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
|
||||
"tlsPin": null,
|
||||
"name": "emoji 😀 & <>"
|
||||
},
|
||||
{
|
||||
"in": " SEN://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg \n",
|
||||
"tls": false,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "203.0.113.7",
|
||||
"port": 8443
|
||||
}
|
||||
],
|
||||
"secret": "e8bc163c82eee18733288c7d4ac636db",
|
||||
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
|
||||
"tlsPin": null,
|
||||
"name": "Семья"
|
||||
},
|
||||
{
|
||||
"in": "sen://AQEBRA92cG4uZX\nhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk",
|
||||
"tls": true,
|
||||
"addrs": [
|
||||
{
|
||||
"host": "vpn.example.com",
|
||||
"port": 443
|
||||
}
|
||||
],
|
||||
"secret": "ad328846aa18b32a335816374511cac1",
|
||||
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
|
||||
"tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"in": "http://x",
|
||||
"error": "Ссылка должна начинаться с sen://"
|
||||
},
|
||||
{
|
||||
"in": "sen://",
|
||||
"error": "Ссылка повреждена: недопустимые символы"
|
||||
},
|
||||
{
|
||||
"in": "sen://AAAA",
|
||||
"error": "Ссылка слишком короткая"
|
||||
},
|
||||
{
|
||||
"in": "sen://a+b",
|
||||
"error": "Ссылка повреждена: недопустимые символы"
|
||||
},
|
||||
{
|
||||
"in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdg",
|
||||
"error": "Ссылка повреждена: не сошлась контрольная сумма"
|
||||
},
|
||||
{
|
||||
"in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwA",
|
||||
"error": "Ссылка повреждена: не сошлась контрольная сумма"
|
||||
}
|
||||
],
|
||||
"sig_string": [
|
||||
{
|
||||
"method": "post",
|
||||
"path": "/sub/v1/register",
|
||||
"ts": 1700000000,
|
||||
"body": "{\"a\":1}",
|
||||
"out": "POST\n/sub/v1/register\n1700000000\n015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862",
|
||||
"sig": "CHqkcC2BwATFKxopbQlDvSDNKCRzAxKaynJ0SDWuj-ht2L5tFcDmIsjFL2vnKiaDuNSCr-ABbWR_uzgIj7mfCw"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/sub/v1/config",
|
||||
"ts": 1,
|
||||
"body": "",
|
||||
"out": "GET\n/sub/v1/config\n1\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"sig": "9p4dzFWv-JeWYVzxxdhjeEMIz1uH_XDVAea94YwQmYTE3j9-9hGRR6mVRs9T4szHvBUExnyXXiApOXEDkd3zCQ"
|
||||
}
|
||||
],
|
||||
"sign_response": [
|
||||
{
|
||||
"in": {
|
||||
"config": {
|
||||
"rev": "ab",
|
||||
"servers": []
|
||||
},
|
||||
"device": 3,
|
||||
"ts": 5
|
||||
},
|
||||
"out": {
|
||||
"body": "{\"config\":{\"rev\":\"ab\",\"servers\":[]},\"device\":3,\"ts\":5}",
|
||||
"sig": "jcH9kAuxkvyBKJFLR-uSbaQgEh6dede75PLezg0v7yATUQojATafYKbQ8-aJAjvlHbv4G04j1eMD5owW9tQZDw"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": {
|
||||
"error": "revoked"
|
||||
},
|
||||
"out": {
|
||||
"body": "{\"error\":\"revoked\"}",
|
||||
"sig": "Eb9F2GY-lyqk7ZxxKYHxSTl-goq7oUCYxBwguLpSw1oQmrXVFpUehQdd6_02UfjtDX1Rtth4-uf8-t4XFuciAA"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Состояние после сценария awg-ui: строки ui.db и пиры/пользователи awg-ctrl.
|
||||
import fs from "fs";
|
||||
import { execSync } from "child_process";
|
||||
const out = process.argv[2];
|
||||
const t = JSON.parse(fs.readFileSync(out, "utf8"));
|
||||
const q = (db, sql) => JSON.parse(execSync(`sqlite3 -json ${db} "${sql}"`).toString() || "[]");
|
||||
const UI = "/run/bench/ui.db", CT = "/etc/amnezia/amneziawg/users.db";
|
||||
const tables = ["api_keys", "api_key_servers", "nodes", "master_keys", "master_servers", "devices", "device_peers",
|
||||
"peer_tombstones", "server_profiles", "sub_meta", "metrics_token"];
|
||||
t.state = {
|
||||
ui: Object.fromEntries(tables.map(x => [x, q(UI, `select * from ${x} order by rowid`)])),
|
||||
uiSchema: execSync(`sqlite3 ${UI} .schema`).toString(),
|
||||
ctrlUsers: q(CT, "select name, ip, key_gen from users order by rowid"),
|
||||
ctrlPeers: q(CT, "select pub_key, ip, owner from peers order by rowid"),
|
||||
};
|
||||
fs.writeFileSync(out, JSON.stringify(t, null, 1));
|
||||
console.log("collect-ui: done");
|
||||
@@ -0,0 +1,61 @@
|
||||
// compare-ui.mjs <ts.json> <go.json>: расхождения двух прогонов scenario-ui. Случайное
|
||||
// маскируется (токены, ключи, uuid, ссылки, подписи, время, id панели); подписанные ответы
|
||||
// /sub/v1 раскрываются: сравнивается body, а подпись — только что она есть.
|
||||
import fs from "fs";
|
||||
const [a, b] = process.argv.slice(2).map(f => JSON.parse(fs.readFileSync(f, "utf8")));
|
||||
const panelOf = r => r.state.ui.sub_meta.find(x => x.key === "panel_id")?.value ?? "";
|
||||
const TIME_KEYS = /^(created_at|last_used|last_seen|at|ts|updated_at|join_expires|revoked_at|expires_in_at)$/;
|
||||
function mask(s, panel) {
|
||||
if (panel) s = s.split(panel).join("<panel>");
|
||||
return s
|
||||
.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<jwt>")
|
||||
.replace(/awg[km]_[A-Za-z0-9_-]{8}(…|[A-Za-z0-9_-]{24})/g, "<token>")
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
|
||||
.replace(/sen:\/\/[A-Za-z0-9_-]+/g, "<sen>")
|
||||
.replace(/awgjoin:\/\/[A-Za-z0-9_-]+/g, "<join>")
|
||||
.replace(/awg_scrape_duration_seconds [0-9.e-]+/g, "awg_scrape_duration_seconds <d>")
|
||||
.replace(/(awg_host_[a-z_]+\{[^}]*\}) [0-9.e+-]+/g, "$1 <n>");
|
||||
}
|
||||
function norm(v, panel, key = "") {
|
||||
if (typeof v === "string") {
|
||||
if (key === "sig") return "<sig>";
|
||||
if (key === "body" && v.startsWith("{")) { try { return norm(JSON.parse(v), panel); } catch {} }
|
||||
return mask(v, panel);
|
||||
}
|
||||
if (typeof v === "number" && TIME_KEYS.test(key)) return "<t>";
|
||||
if (Array.isArray(v)) return v.map(x => norm(x, panel));
|
||||
if (v && typeof v === "object") {
|
||||
const o = {};
|
||||
for (const [k, x] of Object.entries(v)) {
|
||||
if (["key_hash", "secret", "join_hash", "token_hash", "pub", "prefix"].includes(k) && x !== null) { o[k] = "<rnd>"; continue; }
|
||||
if (k === "value" && typeof x === "string" && /^[[{]/.test(x)) { o[k] = norm(JSON.parse(x), panel); continue; }
|
||||
o[k] = norm(x, panel, k);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
const pa = panelOf(a), pb = panelOf(b);
|
||||
let diffs = 0;
|
||||
const show = (label, x, y) => {
|
||||
const X = JSON.stringify(norm(x, pa)), Y = JSON.stringify(norm(y, pb));
|
||||
if (X === Y) return;
|
||||
diffs++;
|
||||
let i = 0; while (X[i] === Y[i]) i++;
|
||||
const from = Math.max(0, i - 120);
|
||||
console.log(`### ${label} @${i}\n TS: …${X.slice(from, i + 300)}\n GO: …${Y.slice(from, i + 300)}`);
|
||||
};
|
||||
if (a.error || b.error) console.log("scenario error:", a.error, "|", b.error);
|
||||
const n = Math.max(a.steps.length, b.steps.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = a.steps[i] ?? {}, t = b.steps[i] ?? {};
|
||||
// text не сравниваем: порядок ключей виден и в разобранном body (Object.entries его хранит).
|
||||
// Стек Express в dev-режиме (NODE_ENV не задан) у Go не воспроизводится — сравниваем тип ошибки.
|
||||
const html = x => typeof x === "string" ? x.replace(/<pre>([A-Za-z]+Error)[^<]*(<br>[\s\S]*)?<\/pre>/, "<pre>$1</pre>") : x;
|
||||
show(`${i} ${s.name ?? t.name} ${s.method} ${s.path}`, { st: s.status, ct: s.ctype, body: html(s.body) }, { st: t.status, ct: t.ctype, body: html(t.body) });
|
||||
}
|
||||
for (const k of Object.keys(a.state.ui)) show("ui." + k, a.state.ui[k], b.state.ui[k]);
|
||||
show("uiSchema", a.state.uiSchema.replace(/\s+/g, " "), b.state.uiSchema.replace(/\s+/g, " "));
|
||||
show("ctrlUsers", a.state.ctrlUsers, b.state.ctrlUsers);
|
||||
show("ctrlPeers", a.state.ctrlPeers, b.state.ctrlPeers);
|
||||
console.log(diffs ? `${diffs} differences` : "IDENTICAL");
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# local-ui.sh <ts|go> <имя-прогона> — дифференциальный прогон awg-ui без Docker: awg-ctrl (Go,
|
||||
# на заглушках, сверен с TS отдельно) + панель нужной реализации + нода из Go-агента на тот же
|
||||
# awg-ctrl. Как и local.sh — только для одноразовой машины: пересоздаёт /etc/amnezia.
|
||||
# Нужны node 20+, sqlite3, openssl, go и `npm install` в awg-ui/ (и собранный awg-ui/dist).
|
||||
set -e
|
||||
IMPL="$1"; NAME="$2"
|
||||
B=$(cd "$(dirname "$0")" && pwd); ROOT=$(cd "$B/../.." && pwd)
|
||||
BENCH=/var/lib/uibench
|
||||
mkdir -p "$B/bin/stubs" "$B/out" "$BENCH"
|
||||
for c in awg awg-quick ip tc nft modinfo; do
|
||||
printf '#!/bin/sh\nexec node %s %s "$@"\n' "$B/stubs/stub.mjs" "$c" > "$B/bin/stubs/$c"; chmod +x "$B/bin/stubs/$c"
|
||||
done
|
||||
export PATH="$B/bin/stubs:$PATH"
|
||||
(cd "$ROOT" && go build ${RACE:+-race} -o "$B/bin/awg-control" ./cmd/awg-control) # RACE=1 — с детектором гонок
|
||||
# Ключи стенда — одни на все прогоны: подпись sen://, сертификат хаба нод.
|
||||
[ -f "$BENCH/sub_sign.key" ] || openssl genpkey -algorithm ed25519 -out "$BENCH/sub_sign.key" 2>/dev/null
|
||||
[ -f "$BENCH/node_tls.key" ] || openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
|
||||
-keyout "$BENCH/node_tls.key" -out "$BENCH/node_tls.crt" -days 3650 -subj /CN=awg-node 2>/dev/null
|
||||
sh "$B/setup.sh"
|
||||
|
||||
PORT=3005 SERVER_IP=203.0.113.7 SERVER_PORT=47619 SERVER_NAME=VPN \
|
||||
INTERNAL_AUTH_PUB_FILE=/etc/amnezia/amneziawg/internal_auth_public.key SHAPE_SAMPLE_S=3600 AWG_CLI=/nonexistent/awg-ctrl \
|
||||
sh -c 'cd /tmp && exec "$0" ctrl' "$B/bin/awg-control" > /run/bench/ctrl.log 2>&1 &
|
||||
CTRL=$!
|
||||
i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $i -gt 150 ] && { cat /run/bench/ctrl.log; kill $CTRL; exit 1; }; sleep 0.1; done
|
||||
|
||||
export PORT=8080 UI_USER=admin UI_PASS=benchpass JWT_SECRET=benchsecret UI_DB_FILE=/run/bench/ui.db AWGCTRL_PORT=3005 \
|
||||
INTERNAL_AUTH_KEY_FILE=/etc/amnezia/amneziawg/internal_auth_private.key \
|
||||
SUB_PORT=8443 SUB_TLS=off SUB_SIGN_KEY_FILE="$BENCH/sub_sign.key" SUB_HOST=sub.example.com \
|
||||
NODE_PORT=8444 NODE_TLS_KEY_FILE="$BENCH/node_tls.key" NODE_TLS_CERT_FILE="$BENCH/node_tls.crt" \
|
||||
BRAND=Bench CHANNEL=Beta VERSION=0.0.0-bench MONITOR_HASH_FILE="$BENCH/none" PROM_URL=http://127.0.0.1:9 \
|
||||
METRICS_PEERS=on AUDIT=on LOCAL_NODE=on
|
||||
cd "$ROOT/awg-ui"
|
||||
if [ "$IMPL" = "ts" ]; then node node_modules/.bin/tsx server.ts > /run/bench/ui.log 2>&1 &
|
||||
else "$B/bin/awg-control" ui > /run/bench/ui.log 2>&1 &
|
||||
fi
|
||||
UIPID=$!
|
||||
i=0; until curl -s -o /dev/null http://127.0.0.1:8080/ui/brand; do i=$((i+1)); [ $i -gt 200 ] && { cat /run/bench/ui.log; kill $UIPID $CTRL; exit 1; }; sleep 0.1; done
|
||||
sleep 1
|
||||
|
||||
AGENT_BIN="$B/bin/awg-control" BENCH_DIR="$BENCH" "$ROOT/awg-ui/node_modules/.bin/tsx" "$B/scenario-ui.ts" "$B/out/$NAME.json" || true
|
||||
sleep 2
|
||||
kill $UIPID $CTRL 2>/dev/null || true; wait $UIPID $CTRL 2>/dev/null || true
|
||||
node "$B/collect-ui.mjs" "$B/out/$NAME.json"
|
||||
cp /run/bench/ui.log "$B/out/$NAME.ui.log"; cp /run/bench/ctrl.log "$B/out/$NAME.ctrl.log"
|
||||
@@ -0,0 +1,301 @@
|
||||
// Сценарий для awg-ui: одинаковая последовательность запросов к TS- и Go-версии панели.
|
||||
// Запуск: awg-ui/node_modules/.bin/tsx tools/diffbench/scenario-ui.ts <out.json>
|
||||
// Окружение: UI (http://127.0.0.1:8080), SUB (http://127.0.0.1:8443), AGENT_BIN — бинарник
|
||||
// awg-control для ноды (join по строке из панели), BENCH_DIR — каталог с ключами стенда.
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import { spawn, execSync, type ChildProcess } from "child_process";
|
||||
import { decodeSenLink, signRequest } from "../../awg-ui/sen";
|
||||
|
||||
const UI = process.env.UI ?? "http://127.0.0.1:8080";
|
||||
const SUB = process.env.SUB ?? "http://127.0.0.1:8443";
|
||||
const out = process.argv[2];
|
||||
const BENCH = process.env.BENCH_DIR ?? "/var/lib/uibench";
|
||||
|
||||
type Step = { name: string; method: string; path: string; status: number; ctype: string; body: unknown; text?: string };
|
||||
const steps: Step[] = [];
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
async function call(name: string, method: string, path: string, body?: unknown,
|
||||
o: { token?: string | null; key?: string; raw?: string; headers?: Record<string, string>; base?: string } = {}) {
|
||||
const h: Record<string, string> = { ...(o.headers ?? {}) };
|
||||
if (o.token) h.authorization = `Bearer ${o.token}`;
|
||||
if (o.key) h["x-api-key"] = o.key;
|
||||
let data: string | undefined;
|
||||
if (o.raw !== undefined) { h["content-type"] = "application/json"; data = o.raw; }
|
||||
else if (body !== undefined) { h["content-type"] = "application/json"; data = JSON.stringify(body); }
|
||||
const r = await fetch((o.base ?? UI) + path, { method, headers: h, body: data });
|
||||
const ctype = (r.headers.get("content-type") ?? "").split(";")[0];
|
||||
const text = await r.text();
|
||||
let parsed: unknown = text;
|
||||
if (ctype === "application/json") { try { parsed = JSON.parse(text); } catch { /* как есть */ } }
|
||||
if (ctype === "text/html" && text.length > 2000) parsed = text.slice(0, 200); // index.html
|
||||
steps.push({ name, method, path, status: r.status, ctype, body: parsed, text: ctype === "application/json" ? text : undefined });
|
||||
return parsed as any;
|
||||
}
|
||||
|
||||
// ── Подписанные запросы устройства (/sub/v1) ────────────────────────────────
|
||||
const seedKey = (s: string) => crypto.createPrivateKey({
|
||||
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), crypto.createHash("sha256").update(s).digest()]),
|
||||
format: "der", type: "pkcs8",
|
||||
});
|
||||
const rawPub = (k: crypto.KeyObject) => (crypto.createPublicKey(k).export({ type: "spki", format: "der" }) as Buffer).subarray(12);
|
||||
const wgPub = (s: string) => crypto.createHash("sha256").update("wg:" + s).digest("base64");
|
||||
|
||||
let tsBump = 0;
|
||||
async function subCall(name: string, method: string, path: string, body: unknown, auth: crypto.KeyObject | null, device?: number, extra: Record<string, string> = {}) {
|
||||
const raw = body === undefined ? "" : JSON.stringify(body);
|
||||
const ts = Math.floor(Date.now() / 1000) + (tsBump++ % 2); // разные подписи для одинаковых запросов
|
||||
const h: Record<string, string> = { ...extra };
|
||||
if (auth) { h["x-sen-ts"] = String(ts); h["x-sen-sig"] = signRequest(method, path, ts, raw, auth); }
|
||||
if (device !== undefined) h["x-sen-device"] = String(device);
|
||||
return call(name, method, path, undefined, { raw: body === undefined ? undefined : raw, headers: h, base: SUB });
|
||||
}
|
||||
|
||||
// ── Сценарий ────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
let agent: ChildProcess | null = null;
|
||||
try {
|
||||
await call("brand", "GET", "/ui/brand");
|
||||
await call("login bad", "POST", "/login", { user: "admin", pass: "nope" });
|
||||
await call("login no body", "POST", "/login");
|
||||
await call("login broken json", "POST", "/login", undefined, { raw: "{" });
|
||||
const { token } = await call("login", "POST", "/login", { user: "admin", pass: "benchpass" });
|
||||
const T = { token };
|
||||
|
||||
await call("apikeys no auth", "GET", "/ui/apikeys");
|
||||
await call("apikeys bad token", "GET", "/ui/apikeys", undefined, { token: "a.b.c" });
|
||||
await call("nodes no auth", "GET", "/ui/nodes");
|
||||
await call("proxy no auth", "GET", "/api/users");
|
||||
|
||||
// прокси к awg-ctrl
|
||||
await call("proxy health", "GET", "/health", undefined, T);
|
||||
await call("proxy users", "GET", "/api/users", undefined, T);
|
||||
await call("proxy add alice", "POST", "/api/users", { name: "alice" }, T);
|
||||
await call("proxy add bad", "POST", "/api/users", { name: "a b" }, T);
|
||||
await call("proxy unknown", "GET", "/api/nope?x=1", undefined, T);
|
||||
await call("proxy server 5", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": "5" } });
|
||||
await call("proxy server junk", "GET", "/api/users/stats", undefined, { ...T, headers: { "x-server-id": "junk" } });
|
||||
|
||||
// ключи API
|
||||
await call("apikeys empty", "GET", "/ui/apikeys", undefined, T);
|
||||
await call("apikey bad label", "POST", "/ui/apikeys", { label: "bad!" }, T);
|
||||
await call("apikey no label", "POST", "/ui/apikeys", {}, T);
|
||||
await call("apikey bad servers", "POST", "/ui/apikeys", { label: "k", servers: [9] }, T);
|
||||
await call("apikey empty servers", "POST", "/ui/apikeys", { label: "k", servers: [] }, T);
|
||||
const k1 = await call("apikey create", "POST", "/ui/apikeys", { label: "main key" }, T);
|
||||
const k2 = await call("apikey create sid", "POST", "/ui/apikeys", { label: "second", server_id: 0, servers: [0, 0] }, T);
|
||||
await call("apikeys list", "GET", "/ui/apikeys", undefined, T);
|
||||
await call("apikey patch bad", "PATCH", "/ui/apikeys/" + k2.id, { servers: [] }, T);
|
||||
await call("apikey patch missing", "PATCH", "/ui/apikeys/999", { servers: [0] }, T);
|
||||
await call("apikey patch", "PATCH", "/ui/apikeys/" + k2.id, { servers: [0] }, T);
|
||||
await call("apikey delete bad", "DELETE", "/ui/apikeys/x", undefined, T);
|
||||
await call("apikey delete", "DELETE", "/ui/apikeys/" + k2.id, undefined, T);
|
||||
|
||||
// внешний API: пользователи
|
||||
const K = { key: k1.key as string };
|
||||
await call("v1 no key", "GET", "/api/v1/users");
|
||||
await call("v1 bad key", "GET", "/api/v1/users", undefined, { key: "awgk_nope" });
|
||||
await call("v1 users", "GET", "/api/v1/users", undefined, K);
|
||||
await call("v1 add bob", "POST", "/api/v1/users", { name: "bob" }, K);
|
||||
await call("v1 add bob again", "POST", "/api/v1/users", { name: "bob" }, K);
|
||||
await call("v1 add no name", "POST", "/api/v1/users", {}, K);
|
||||
await call("v1 get bob", "GET", "/api/v1/users/bob", undefined, K);
|
||||
await call("v1 get nobody", "GET", "/api/v1/users/nobody", undefined, K);
|
||||
await call("v1 get escaped", "GET", "/api/v1/users/b%20b", undefined, K);
|
||||
await call("v1 server outside", "GET", "/api/v1/users", undefined, { ...K, headers: { "x-server-id": "3" } });
|
||||
await call("v1 unknown with key", "GET", "/api/v1/zzz", undefined, K);
|
||||
await call("v1 delete bob", "DELETE", "/api/v1/users/bob", undefined, K);
|
||||
|
||||
// ноды: заводим и подключаем агента к тому же awg-ctrl
|
||||
await call("nodes list", "GET", "/ui/nodes", undefined, T);
|
||||
await call("node bad name", "POST", "/ui/nodes", { name: "" }, T);
|
||||
const n1 = await call("node create", "POST", "/ui/nodes", { name: "edge-1", host: "127.0.0.1" }, T);
|
||||
await call("node rename bad", "PATCH", "/ui/nodes/" + n1.id, { name: "a<b" }, T);
|
||||
await call("node rename", "PATCH", "/ui/nodes/" + n1.id, { name: "edge-one" }, T);
|
||||
await call("node rejoin", "POST", `/ui/nodes/${n1.id}/join`, { host: "127.0.0.1" }, T).then(async r => {
|
||||
const j = JSON.parse(Buffer.from(String(r.join).slice("awgjoin://".length), "base64url").toString());
|
||||
fs.rmSync(`${BENCH}/agent-node.key`, { force: true });
|
||||
agent = spawn(process.env.AGENT_BIN!, ["agent"], {
|
||||
env: { ...process.env, CORE_HOST: j.h, CORE_PORT: String(j.p), CORE_PIN: j.pin, NODE_ID: String(j.n), JOIN_SECRET: j.s,
|
||||
AWGCTRL_PORT: "3005", NODE_KEY_FILE: `${BENCH}/agent-node.key`, VERSION: "0.9.9",
|
||||
INTERNAL_AUTH_KEY_FILE: "/etc/amnezia/amneziawg/internal_auth_private.key", CLI_ENV_FILE: "" },
|
||||
stdio: "ignore",
|
||||
});
|
||||
});
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const r = await fetch(UI + "/ui/nodes", { headers: { authorization: `Bearer ${token}` } }).then(x => x.json()) as any;
|
||||
if (r.nodes.some((n: any) => n.id === n1.id && n.online && n.health)) break;
|
||||
await sleep(100);
|
||||
}
|
||||
await sleep(500);
|
||||
await call("nodes online", "GET", "/ui/nodes", undefined, T);
|
||||
await call("node join bound", "POST", `/ui/nodes/${n1.id}/join`, {}, T);
|
||||
await call("proxy via node", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
|
||||
await call("proxy via node awg", "GET", "/awg/status", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
|
||||
|
||||
// мастер-ключи
|
||||
await call("mk list empty", "GET", "/ui/masterkeys", undefined, T);
|
||||
await call("mk bad label", "POST", "/ui/masterkeys", { label: "a\nb" }, T);
|
||||
await call("mk bad limit", "POST", "/ui/masterkeys", { label: "x", device_limit: 0 }, T);
|
||||
await call("mk bad servers", "POST", "/ui/masterkeys", { label: "x", servers: [42] }, T);
|
||||
const m1 = await call("mk create", "POST", "/ui/masterkeys", { label: "Семья", device_limit: 2 }, T);
|
||||
const m2 = await call("mk create multi", "POST", "/ui/masterkeys", { label: "Multi", servers: [n1.id, 0] }, T);
|
||||
await call("mk list", "GET", "/ui/masterkeys", undefined, T);
|
||||
const link = await call("mk link", "GET", `/ui/masterkeys/${m1.id}/link`, undefined, T);
|
||||
await call("mk link by uuid", "GET", `/ui/masterkeys/${m1.uuid.toUpperCase()}/link`, undefined, T);
|
||||
await call("mk link missing", "GET", "/ui/masterkeys/999/link", undefined, T);
|
||||
const sen = decodeSenLink(link.link);
|
||||
const secret = sen.secret.toString("base64url");
|
||||
|
||||
// /sub/v1: регистрация и опрос устройства
|
||||
const d1 = seedKey("dev1"), d2 = seedKey("dev2"), d3 = seedKey("dev3");
|
||||
const reg = (dev: string, k: crypto.KeyObject, sec: string, extra: object = {}) => ({
|
||||
sub: sec, device_id: dev, device_name: "Phone " + dev, platform: "android", pub_key: wgPub(dev),
|
||||
auth_pub: rawPub(k).toString("base64url"), version: "1.2.3", ...extra,
|
||||
});
|
||||
await subCall("sub unknown path", "GET", "/sub/v1/nope", undefined, null);
|
||||
await subCall("sub broken json", "POST", "/sub/v1/peek", undefined, null).catch(() => {});
|
||||
await call("sub broken json raw", "POST", "/sub/v1/peek", undefined, { raw: "{", base: SUB });
|
||||
await subCall("sub peek bad", "POST", "/sub/v1/peek", { sub: "x" }, null);
|
||||
await subCall("sub peek missing", "POST", "/sub/v1/peek", { sub: "A".repeat(22) }, null);
|
||||
await subCall("sub peek", "POST", "/sub/v1/peek", { sub: secret }, null);
|
||||
await subCall("sub register bad", "POST", "/sub/v1/register", { sub: secret }, d1);
|
||||
await subCall("sub register unsigned", "POST", "/sub/v1/register", reg("d1", d1, secret), null);
|
||||
const r1 = await subCall("sub register d1", "POST", "/sub/v1/register", reg("d1", d1, secret), d1, { "x-sen-device": "77" });
|
||||
const dev1 = JSON.parse(r1.body).device;
|
||||
const r2 = await subCall("sub register d2", "POST", "/sub/v1/register", reg("d2", d2, secret), d2);
|
||||
const dev2 = JSON.parse(r2.body).device;
|
||||
await subCall("sub register over limit", "POST", "/sub/v1/register", reg("d3", d3, secret), d3);
|
||||
await subCall("sub config", "GET", "/sub/v1/config", undefined, d1, dev1, { "x-sen-version": "1.2.4" });
|
||||
await subCall("sub config wrong key", "GET", "/sub/v1/config", undefined, d2, dev1);
|
||||
await subCall("sub config no sig", "GET", "/sub/v1/config", undefined, null, dev1);
|
||||
await subCall("sub config gone", "GET", "/sub/v1/config", undefined, d1, 9999);
|
||||
await subCall("sub devices", "GET", "/sub/v1/devices", undefined, d2, dev2);
|
||||
await subCall("sub rekey bad", "POST", "/sub/v1/rekey", { pub_key: "x" }, d1, dev1);
|
||||
await subCall("sub rekey same", "POST", "/sub/v1/rekey", { pub_key: wgPub("d1") }, d1, dev1);
|
||||
await subCall("sub rekey taken", "POST", "/sub/v1/rekey", { pub_key: wgPub("d2") }, d1, dev1);
|
||||
await subCall("sub rekey", "POST", "/sub/v1/rekey", { pub_key: wgPub("d1-new") }, d1, dev1);
|
||||
// повтор той же подписи — replay
|
||||
{
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const sig = signRequest("GET", "/sub/v1/config", ts, "", d2);
|
||||
const h = { "x-sen-ts": String(ts), "x-sen-sig": sig, "x-sen-device": String(dev2) };
|
||||
await call("sub config sig once", "GET", "/sub/v1/config", undefined, { headers: h, base: SUB });
|
||||
await call("sub config replay", "GET", "/sub/v1/config", undefined, { headers: h, base: SUB });
|
||||
}
|
||||
// устройство на ключе с двумя серверами (нода — тот же awg-ctrl)
|
||||
const link2 = await call("mk2 link", "GET", `/ui/masterkeys/${m2.id}/link`, undefined, T);
|
||||
const secret2 = decodeSenLink(link2.link).secret.toString("base64url");
|
||||
const r3 = await subCall("sub register multi", "POST", "/sub/v1/register", reg("d3", d3, secret2), d3);
|
||||
const dev3 = JSON.parse(r3.body).device;
|
||||
await subCall("sub config multi", "GET", "/sub/v1/config", undefined, d3, dev3);
|
||||
|
||||
// панель: устройства и мастер-ключи
|
||||
await call("mk devices", "GET", `/ui/masterkeys/${m1.id}/devices`, undefined, T);
|
||||
await call("mk2 devices", "GET", `/ui/masterkeys/${m2.id}/devices`, undefined, T);
|
||||
await call("dev rekey", "POST", `/ui/devices/${dev2}/rekey`, undefined, T);
|
||||
await call("dev psk", "POST", `/ui/devices/${dev2}/psk`, undefined, T);
|
||||
await call("dev missing", "POST", "/ui/devices/999/psk", undefined, T);
|
||||
await call("mk rekey", "POST", `/ui/masterkeys/${m1.id}/rekey`, undefined, T);
|
||||
await subCall("sub config after rekey flag", "GET", "/sub/v1/config", undefined, d2, dev2);
|
||||
await call("mk patch bad", "PATCH", `/ui/masterkeys/${m1.id}`, { device_limit: 101 }, T);
|
||||
await call("mk patch limit", "PATCH", `/ui/masterkeys/${m1.id}`, { label: "Семья 2", device_limit: 1 }, T);
|
||||
await subCall("sub config unbound", "GET", "/sub/v1/config", undefined, d2, dev2);
|
||||
await call("mk2 patch servers", "PATCH", `/ui/masterkeys/${m2.id}`, { servers: [0] }, T);
|
||||
await call("mk rotate", "POST", `/ui/masterkeys/${m1.id}/rotate`, undefined, T);
|
||||
await subCall("sub peek old secret", "POST", "/sub/v1/peek", { sub: secret }, null);
|
||||
await call("dev delete", "DELETE", `/ui/devices/${dev1}`, undefined, T);
|
||||
await subCall("sub config deleted", "GET", "/sub/v1/config", undefined, d1, dev1);
|
||||
await subCall("sub delete self", "DELETE", "/sub/v1/device", undefined, d3, dev3);
|
||||
|
||||
// внешний API: мастер-ключи
|
||||
await call("v1 mk list", "GET", "/api/v1/masterkeys", undefined, K);
|
||||
await call("v1 mk create outside", "POST", "/api/v1/masterkeys", { label: "ext", servers: [n1.id] }, K);
|
||||
const em = await call("v1 mk create", "POST", "/api/v1/masterkeys", { label: "ext" }, K);
|
||||
await call("v1 mk get", "GET", `/api/v1/masterkeys/${em.id}`, undefined, K);
|
||||
await call("v1 mk get by uuid", "GET", `/api/v1/masterkeys/${em.uuid}`, undefined, K);
|
||||
await call("v1 mk get invisible", "GET", `/api/v1/masterkeys/${m2.id}`, undefined, K);
|
||||
await call("v1 mk patch", "PATCH", `/api/v1/masterkeys/${em.id}`, { device_limit: 5 }, K);
|
||||
await call("v1 mk rotate", "POST", `/api/v1/masterkeys/${em.id}/rotate`, undefined, K);
|
||||
await call("v1 mk rekey", "POST", `/api/v1/masterkeys/${em.id}/rekey`, undefined, K);
|
||||
await call("v1 mk devices", "GET", `/api/v1/masterkeys/${em.id}/devices`, undefined, K);
|
||||
await call("v1 mk device missing", "DELETE", `/api/v1/masterkeys/${em.id}/devices/${dev2}`, undefined, K);
|
||||
await call("v1 mk delete", "DELETE", `/api/v1/masterkeys/${em.id}`, undefined, K);
|
||||
|
||||
// аудит
|
||||
await call("audit get", "GET", "/ui/masterkeys/audit", undefined, T);
|
||||
await call("audit settings bad", "PUT", "/ui/masterkeys/audit/settings", { every_h: 0 }, T);
|
||||
await call("audit settings", "PUT", "/ui/masterkeys/audit/settings", { clean: false, every_h: 2, max: 5 }, T);
|
||||
await call("audit run", "POST", "/ui/masterkeys/audit", undefined, T);
|
||||
await call("audit clean", "POST", "/ui/masterkeys/audit/clean", undefined, T);
|
||||
|
||||
// метрики
|
||||
await call("metrics no token", "GET", "/metrics");
|
||||
await call("metrics token none", "GET", "/ui/metrics-token", undefined, T);
|
||||
const mt = await call("metrics token create", "POST", "/ui/metrics-token", undefined, T);
|
||||
await call("metrics", "GET", "/metrics", undefined, { token: mt.token });
|
||||
await call("metrics token view", "GET", "/ui/metrics-token", undefined, T);
|
||||
await call("metrics token delete", "DELETE", "/ui/metrics-token", undefined, T);
|
||||
await call("metrics after delete", "GET", "/metrics", undefined, { token: mt.token });
|
||||
|
||||
// монитор (Prometheus не запущен)
|
||||
await call("monitor", "GET", "/ui/monitor", undefined, T);
|
||||
await call("monitor overview bad", "GET", "/ui/monitor/overview?range=2h", undefined, T);
|
||||
await call("monitor overview", "GET", "/ui/monitor/overview?range=6h", undefined, T);
|
||||
|
||||
// аудит с «мёртвыми»: лишний пир панели у awg-ctrl и пир в ядре без записи
|
||||
{
|
||||
const panel = fs.readFileSync(__dirname + "/../../awg-ui/panel.id", "utf8").trim();
|
||||
await call("orphan add", "POST", "/api/peers", { pub_key: wgPub("orphan"), owner: `${panel}/m1/d77` }, T);
|
||||
await call("foreign add", "POST", "/api/peers", { pub_key: wgPub("foreign"), owner: "ffffffffffff/m1/d1" }, T);
|
||||
execSync(`awg set awg1 peer ${wgPub("ghost")} allowed-ips 10.9.9.9/32`);
|
||||
await call("audit run 1", "POST", "/ui/masterkeys/audit", undefined, T);
|
||||
await call("audit clean unconfirmed", "POST", "/ui/masterkeys/audit/clean", undefined, T);
|
||||
await call("audit run 2", "POST", "/ui/masterkeys/audit", undefined, T);
|
||||
await call("audit clean confirmed", "POST", "/ui/masterkeys/audit/clean", undefined, T);
|
||||
await call("audit after clean", "GET", "/ui/masterkeys/audit", undefined, T);
|
||||
}
|
||||
|
||||
// ключ API на ноде мешает её удалить; потом нода уходит офлайн
|
||||
const k3 = await call("apikey on node", "POST", "/ui/apikeys", { label: "node only", servers: [n1.id] }, T);
|
||||
await call("node delete key veto", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
|
||||
await call("apikey to both", "PATCH", "/ui/apikeys/" + k3.id, { servers: [0, n1.id] }, T);
|
||||
await call("v1 users on node", "GET", "/api/v1/users", undefined, { key: k3.key, headers: { "x-server-id": String(n1.id) } });
|
||||
if (agent) { (agent as ChildProcess).kill(); agent = null; }
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const r = await fetch(UI + "/ui/nodes", { headers: { authorization: `Bearer ${token}` } }).then(x => x.json()) as any;
|
||||
if (r.nodes.some((n: any) => n.id === n1.id && !n.online)) break;
|
||||
await sleep(100);
|
||||
}
|
||||
await call("nodes offline", "GET", "/ui/nodes", undefined, T);
|
||||
await call("proxy via offline node", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
|
||||
await call("mk2 back to node", "PATCH", `/ui/masterkeys/${m2.id}`, { servers: [0, n1.id] }, T);
|
||||
await call("mk2 devices offline", "GET", `/ui/masterkeys/${m2.id}/devices`, undefined, T);
|
||||
|
||||
// удаление ноды
|
||||
await call("node delete in use", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
|
||||
await call("mk2 delete", "DELETE", `/ui/masterkeys/${m2.id}`, undefined, T);
|
||||
await call("node delete pending", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
|
||||
await call("node delete", "DELETE", `/ui/nodes/${n1.id}?force=1`, undefined, T);
|
||||
await call("apikeys after node delete", "GET", "/ui/apikeys", undefined, T);
|
||||
await call("node delete again", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
|
||||
await call("mk list final", "GET", "/ui/masterkeys", undefined, T);
|
||||
|
||||
// статика и общие ответы
|
||||
await call("static index", "GET", "/");
|
||||
await call("static spa", "GET", "/some/route");
|
||||
await call("static ui fallback", "GET", "/ui/unknown", undefined, T);
|
||||
await call("post unknown", "POST", "/nope");
|
||||
await call("wrong method", "PUT", "/ui/apikeys", undefined, T);
|
||||
await call("logout", "POST", "/logout", undefined, T);
|
||||
await call("after logout", "GET", "/ui/apikeys", undefined, T);
|
||||
} finally {
|
||||
if (agent) (agent as ChildProcess).kill();
|
||||
}
|
||||
|
||||
fs.writeFileSync(out, JSON.stringify({ steps }, null, 1));
|
||||
console.log(`scenario-ui: ${steps.length} steps → ${out}`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); fs.writeFileSync(out, JSON.stringify({ steps, error: String(e) }, null, 1)); process.exit(1); });
|
||||
@@ -0,0 +1,63 @@
|
||||
// Эталон для internal/ui (sen.go): ссылки sen:// и подписи протокола, как их делает awg-ui/sen.ts.
|
||||
// Запуск: awg-ui/node_modules/.bin/tsx tools/golden/sen.ts > testdata/golden/sen.json
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
encodeSenLink, decodeSenLink, crc32, requestSigString, signResponse, signRequest, rawPublicKey,
|
||||
type SenLink,
|
||||
} from "../../awg-ui/sen";
|
||||
|
||||
const seed = (s: string) => crypto.createHash("sha256").update(s).digest();
|
||||
// Ed25519 из фиксированного seed: PKCS#8 = префикс + 32 байта seed.
|
||||
const keyFromSeed = (s: Buffer) => crypto.createPrivateKey({
|
||||
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), s]), format: "der", type: "pkcs8",
|
||||
});
|
||||
const signKey = keyFromSeed(seed("sign"));
|
||||
const signPub = rawPublicKey(crypto.createPublicKey(signKey));
|
||||
const hex = (b?: Buffer) => b?.toString("hex");
|
||||
|
||||
const links: SenLink[] = [
|
||||
{ tls: false, addrs: [{ host: "203.0.113.7", port: 8443 }], secret: seed("s1").subarray(0, 16), signPub, name: "Семья" },
|
||||
{ tls: true, addrs: [{ host: "vpn.example.com", port: 443 }], secret: seed("s2").subarray(0, 16), signPub, tlsPin: seed("pin"), name: "" },
|
||||
{ tls: false, addrs: [{ host: "2001:db8::1", port: 1 }, { host: "::", port: 65535 }, { host: "fe80:0:0:1:0:0:0:1", port: 80 }],
|
||||
secret: seed("s3").subarray(0, 16), signPub, name: "x".repeat(64) },
|
||||
{ tls: false, addrs: [{ host: "1:2:3:4:5:6:7:8", port: 2 }], secret: seed("s4").subarray(0, 16), signPub, name: "emoji 😀 & <>" },
|
||||
];
|
||||
const badLinks = [
|
||||
{ tls: false, addrs: [], secret: seed("a").subarray(0, 16), signPub, name: "" },
|
||||
{ tls: false, addrs: [{ host: "256.1.1.1", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
|
||||
{ tls: false, addrs: [{ host: "1::2::3", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
|
||||
{ tls: false, addrs: [{ host: "bad_host", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
|
||||
{ tls: false, addrs: [{ host: "a.b", port: 0 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
|
||||
{ tls: false, addrs: [{ host: "a.b", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "я".repeat(33) },
|
||||
{ tls: true, addrs: [{ host: "a.b", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
|
||||
];
|
||||
const good = links.map(encodeSenLink);
|
||||
const decodeCases = [
|
||||
...good, " " + good[0].toUpperCase().slice(0, 6) + good[0].slice(6) + " \n", good[1].slice(0, 20) + "\n" + good[1].slice(20),
|
||||
"http://x", "sen://", "sen://AAAA", "sen://a+b", good[0].slice(0, -2), good[0].slice(0, -1) + (good[0].endsWith("A") ? "B" : "A"),
|
||||
];
|
||||
const err = (f: () => unknown) => { try { return { ok: f() }; } catch (e) { return { error: (e as Error).message }; } };
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
sign_seed: hex(seed("sign")),
|
||||
sign_pub: hex(signPub),
|
||||
crc32: ["", "a", "hello", "кириллица"].map(s => ({ in: s, out: crc32(Buffer.from(s)) })),
|
||||
encode: links.map((l, i) => ({
|
||||
tls: l.tls, addrs: l.addrs, secret: hex(l.secret), tlsPin: hex(l.tlsPin), name: l.name, out: good[i],
|
||||
})),
|
||||
encode_errors: badLinks.map(l => ({
|
||||
tls: l.tls, addrs: l.addrs, secret: hex(l.secret), name: l.name, error: err(() => encodeSenLink(l as SenLink)).error,
|
||||
})),
|
||||
decode: decodeCases.map(s => {
|
||||
const r = err(() => decodeSenLink(s));
|
||||
if ("error" in r) return { in: s, error: r.error };
|
||||
const d = r.ok as SenLink;
|
||||
return { in: s, tls: d.tls, addrs: d.addrs, secret: hex(d.secret), signPub: hex(d.signPub), tlsPin: hex(d.tlsPin) ?? null, name: d.name };
|
||||
}),
|
||||
sig_string: [
|
||||
{ method: "post", path: "/sub/v1/register", ts: 1700000000, body: '{"a":1}' },
|
||||
{ method: "GET", path: "/sub/v1/config", ts: 1, body: "" },
|
||||
].map(c => ({ ...c, out: requestSigString(c.method, c.path, c.ts, c.body),
|
||||
sig: signRequest(c.method, c.path, c.ts, c.body, signKey) })),
|
||||
sign_response: [{ config: { rev: "ab", servers: [] }, device: 3, ts: 5 }, { error: "revoked" }].map(p => ({ in: p, out: signResponse(p, signKey) })),
|
||||
}, null, 1));
|
||||
Reference in New Issue
Block a user