VPN connection log: who connected and when
WireGuard has no connect events, only the last handshake and byte counters. awg-ctrl now checks them every 30 s and writes conn_log in users.db: connect at the first handshake, disconnect at the last activity (the last tick the counters moved), with the session length and traffic, plus peer_seen, the last time each peer was online. Open sessions are restored from the database, so a restart adds no duplicates; a down interface is not a disconnect; a peer removed mid-session is closed. Events keep the name, owner and VPN IP as of that moment and live 90 days. Client public IPs are not recorded. GET /api/connlog serves the events and the per-peer last-seen list. The panel fetches it from the selected server at /ui/connlog and fills in master key labels and device names for sen:// peers. A new "Подключения" tab shows the event feed with search and paging, and "who was online when", including those who never connected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
package ctrl
|
||||
|
||||
// Журнал VPN-подключений: кто, когда подключился и отключился, сколько длилась сессия и сколько
|
||||
// прошло трафика, и когда каждый пир был на связи последний раз (для «кто давно не подключался»).
|
||||
//
|
||||
// У WireGuard нет событий подключения — только время последнего handshake и счётчики байт в
|
||||
// `awg show dump`. Раз в 30 с сверяем их с открытыми сессиями:
|
||||
// - пир стал онлайн (handshake моложе 3 мин) — «подключился» со временем этого handshake;
|
||||
// - пир ушёл из онлайна или с интерфейса — «отключился» со временем последней активности
|
||||
// (последнего тика, когда менялись счётчики): handshake идёт раз в 2 мин, активность точнее.
|
||||
// Открытые сессии восстанавливаются из базы, поэтому перезапуск awg-ctrl не плодит дублей.
|
||||
// Внешний IP клиента не пишем: это уже слежка, а не журнал.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
connlogEvery = 30 * time.Second
|
||||
connlogKeepS = 90 * 24 * 3600 // столько дней храним события
|
||||
seenEveryS = 60 // last_seen пишем не чаще раза в минуту на пира
|
||||
connlogLimit = 500
|
||||
)
|
||||
|
||||
type connSession struct {
|
||||
since int64
|
||||
rx0, tx0 float64 // счётчики на начало сессии
|
||||
lastRx, lastTx float64
|
||||
active int64 // последний тик, когда менялись счётчики
|
||||
seenSaved int64
|
||||
}
|
||||
|
||||
type peerIdent struct{ name, owner, ip string }
|
||||
|
||||
type ConnLog struct {
|
||||
s *Server
|
||||
mu sync.Mutex
|
||||
open map[string]*connSession
|
||||
loaded bool
|
||||
pruned int64
|
||||
peersFn func() (map[string]peerStat, bool) // подменяется в тестах
|
||||
}
|
||||
|
||||
func newConnLog(s *Server) *ConnLog {
|
||||
l := &ConnLog{s: s, open: map[string]*connSession{}}
|
||||
l.peersFn = func() (map[string]peerStat, bool) {
|
||||
if !s.interfaceStatus().Up {
|
||||
return nil, false // интерфейс лежит — это не «все отключились», а пауза
|
||||
}
|
||||
return s.getPeersData(), true
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *ConnLog) Start() {
|
||||
go func() {
|
||||
for {
|
||||
l.tick(time.Now().Unix())
|
||||
time.Sleep(connlogEvery)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// idents — имя пользователя или владелец пира sen:// и внутренний IP по публичному ключу. Строки
|
||||
// читаем до конца до любых других запросов: соединение с SQLite одно (см. metrics_test.go).
|
||||
func (l *ConnLog) idents() map[string]peerIdent {
|
||||
out := map[string]peerIdent{}
|
||||
if rows, err := l.s.db.Query("SELECT pub_key, name, ip FROM users"); err == nil {
|
||||
for rows.Next() {
|
||||
var pub, name, ip string
|
||||
if rows.Scan(&pub, &name, &ip) == nil {
|
||||
out[pub] = peerIdent{name: name, ip: ip}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
if rows, err := l.s.db.Query("SELECT pub_key, owner, ip FROM peers"); err == nil {
|
||||
for rows.Next() {
|
||||
var pub, owner, ip string
|
||||
if rows.Scan(&pub, &owner, &ip) == nil {
|
||||
out[pub] = peerIdent{owner: owner, ip: ip}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// load — открытые сессии (последнее событие пира — «подключился») после перезапуска.
|
||||
func (l *ConnLog) load(now int64) {
|
||||
type row struct {
|
||||
pub string
|
||||
at, rx, tx int64
|
||||
seen int64
|
||||
}
|
||||
var open []row
|
||||
rows, err := l.s.db.Query(`SELECT c.pub_key, c.at, COALESCE(c.rx, 0), COALESCE(c.tx, 0), COALESCE(s.at, c.at)
|
||||
FROM conn_log c
|
||||
JOIN (SELECT pub_key, MAX(id) AS id FROM conn_log GROUP BY pub_key) last ON last.id = c.id
|
||||
LEFT JOIN peer_seen s ON s.pub_key = c.pub_key
|
||||
WHERE c.event = 'connect'`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if rows.Scan(&r.pub, &r.at, &r.rx, &r.tx, &r.seen) == nil {
|
||||
open = append(open, r)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, r := range open {
|
||||
l.open[r.pub] = &connSession{since: r.at, rx0: float64(r.rx), tx0: float64(r.tx),
|
||||
lastRx: float64(r.rx), lastTx: float64(r.tx), active: r.seen, seenSaved: r.seen}
|
||||
}
|
||||
l.loaded = true
|
||||
}
|
||||
|
||||
func (l *ConnLog) tick(now int64) {
|
||||
stats, ok := l.peersFn()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if !l.loaded {
|
||||
l.load(now)
|
||||
}
|
||||
who := l.idents()
|
||||
write := func(event, pub string, at int64, dur *int64, rx, tx float64) {
|
||||
id := who[pub]
|
||||
var d any
|
||||
if dur != nil {
|
||||
d = *dur
|
||||
}
|
||||
l.s.db.Exec("INSERT INTO conn_log (at, event, pub_key, name, owner, ip, duration, rx, tx) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
at, event, pub, id.name, id.owner, id.ip, d, int64(rx), int64(tx))
|
||||
}
|
||||
seen := func(pub string, at int64) {
|
||||
l.s.db.Exec("INSERT INTO peer_seen (pub_key, at) VALUES (?, ?) ON CONFLICT(pub_key) DO UPDATE SET at = excluded.at", pub, at)
|
||||
}
|
||||
closeSession := func(pub string, sess *connSession) {
|
||||
at := max(sess.active, sess.since)
|
||||
dur := at - sess.since
|
||||
// Счётчики сбрасываются при перезапуске интерфейса: тогда считаем от нуля.
|
||||
rx, tx := sess.lastRx-sess.rx0, sess.lastTx-sess.tx0
|
||||
if rx < 0 {
|
||||
rx = sess.lastRx
|
||||
}
|
||||
if tx < 0 {
|
||||
tx = sess.lastTx
|
||||
}
|
||||
write("disconnect", pub, at, &dur, rx, tx)
|
||||
seen(pub, at)
|
||||
delete(l.open, pub)
|
||||
}
|
||||
|
||||
for pub, st := range stats {
|
||||
sess := l.open[pub]
|
||||
switch {
|
||||
case st.Online && sess == nil:
|
||||
at := int64(st.LastHandshake)
|
||||
if at <= 0 || at > now {
|
||||
at = now
|
||||
}
|
||||
write("connect", pub, at, nil, st.Rx, st.Tx)
|
||||
l.open[pub] = &connSession{since: at, rx0: st.Rx, tx0: st.Tx, lastRx: st.Rx, lastTx: st.Tx, active: now, seenSaved: now}
|
||||
seen(pub, now)
|
||||
case st.Online:
|
||||
if st.Rx != sess.lastRx || st.Tx != sess.lastTx {
|
||||
sess.lastRx, sess.lastTx, sess.active = st.Rx, st.Tx, now
|
||||
}
|
||||
if now-sess.seenSaved >= seenEveryS {
|
||||
seen(pub, sess.active)
|
||||
sess.seenSaved = now
|
||||
}
|
||||
case sess != nil:
|
||||
if hs := int64(st.LastHandshake); hs > sess.active && hs <= now {
|
||||
sess.active = hs
|
||||
}
|
||||
if st.Rx > sess.lastRx || st.Tx > sess.lastTx {
|
||||
sess.lastRx, sess.lastTx = st.Rx, st.Tx
|
||||
}
|
||||
closeSession(pub, sess)
|
||||
}
|
||||
}
|
||||
// Пира сняли с интерфейса (удалили пользователя, перевыпустили ключ) посреди сессии.
|
||||
for pub, sess := range l.open {
|
||||
if _, ok := stats[pub]; !ok {
|
||||
closeSession(pub, sess)
|
||||
}
|
||||
}
|
||||
if now-l.pruned >= 3600 {
|
||||
l.s.db.Exec("DELETE FROM conn_log WHERE at < ?", now-connlogKeepS)
|
||||
l.pruned = now
|
||||
}
|
||||
}
|
||||
|
||||
// list — GET /api/connlog: события (новые сверху) и «когда был на связи» по каждому пиру.
|
||||
//
|
||||
// ?limit=N (до 500), ?before=<id> — следующая страница, ?q=<строка> — по имени, владельцу, IP.
|
||||
func (l *ConnLog) list(limit, before int64, q string) (ob, error) {
|
||||
if limit <= 0 || limit > connlogLimit {
|
||||
limit = connlogLimit
|
||||
}
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
if before > 0 {
|
||||
where, args = append(where, "id < ?"), append(args, before)
|
||||
}
|
||||
if q = strings.TrimSpace(q); q != "" {
|
||||
like := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(q) + "%"
|
||||
where = append(where, `(name LIKE ? ESCAPE '\' OR owner LIKE ? ESCAPE '\' OR ip LIKE ? ESCAPE '\')`)
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := l.s.db.Query("SELECT id, at, event, pub_key, name, owner, ip, duration, rx, tx FROM conn_log WHERE "+
|
||||
strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events := []any{}
|
||||
for rows.Next() {
|
||||
var id, at int64
|
||||
var event, pub, name, owner, ip string
|
||||
var dur, rx, tx *int64
|
||||
if err := rows.Scan(&id, &at, &event, &pub, &name, &owner, &ip, &dur, &rx, &tx); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
e := ob{{"id", id}, {"at", at}, {"event", event}, {"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip}}
|
||||
if event == "disconnect" {
|
||||
e = append(e, kv{"duration", deref(dur)}, kv{"rx", deref(rx)}, kv{"tx", deref(tx)})
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Все пиры сервера с последним временем на связи (null — с тех пор, как ведётся журнал, не было).
|
||||
stats := map[string]peerStat{}
|
||||
if st, ok := l.peersFn(); ok {
|
||||
stats = st
|
||||
}
|
||||
peers := []any{}
|
||||
rows, err = l.s.db.Query(`SELECT u.pub_key, u.name, '' AS owner, u.ip, s.at FROM users u LEFT JOIN peer_seen s ON s.pub_key = u.pub_key
|
||||
UNION ALL
|
||||
SELECT p.pub_key, '', p.owner, p.ip, s.at FROM peers p LEFT JOIN peer_seen s ON s.pub_key = p.pub_key`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var pub, name, owner, ip string
|
||||
var at *int64
|
||||
if err := rows.Scan(&pub, &name, &owner, &ip, &at); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
var last any
|
||||
if at != nil {
|
||||
last = *at
|
||||
}
|
||||
peers = append(peers, ob{{"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip},
|
||||
{"online", stats[pub].Online}, {"last_seen", last}})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
var since any
|
||||
var first int64
|
||||
if l.s.db.QueryRow("SELECT MIN(at) FROM conn_log").Scan(&first) == nil && first > 0 {
|
||||
since = first
|
||||
}
|
||||
return ob{{"events", events}, {"peers", peers}, {"since", since}, {"keep_days", connlogKeepS / 86400}}, nil
|
||||
}
|
||||
|
||||
func deref(p *int64) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func queryInt(c *rctx, k string) int64 {
|
||||
n, _ := strconv.ParseInt(c.r.URL.Query().Get(k), 10, 64)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package ctrl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
"github.com/maeneko/forgetting/internal/logx"
|
||||
)
|
||||
|
||||
func TestConnLog(t *testing.T) {
|
||||
db, err := openDB(filepath.Join(t.TempDir(), "users.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES ('ivan', '10.9.0.2', 'PK_IVAN', 'vpn://x')")
|
||||
db.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES ('PK_PHONE', '10.9.0.3', 'psk', 'p1/m1/d2', 1)")
|
||||
s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())}
|
||||
|
||||
stats := map[string]peerStat{}
|
||||
up := true
|
||||
newLog := func() *ConnLog {
|
||||
l := newConnLog(s)
|
||||
l.peersFn = func() (map[string]peerStat, bool) {
|
||||
cp := map[string]peerStat{}
|
||||
for k, v := range stats {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp, up
|
||||
}
|
||||
return l
|
||||
}
|
||||
l := newLog()
|
||||
peer := func(pub string, online bool, hs, rx, tx float64) {
|
||||
stats[pub] = peerStat{Online: online, LastHandshake: hs, Rx: rx, Tx: tx}
|
||||
}
|
||||
events := func() string {
|
||||
v, err := l.list(0, 0, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b []string
|
||||
for _, e := range v.Get2("events").([]any) {
|
||||
o := e.(jsjson.Object)
|
||||
s := o.Get2("event").(string) + " " + o.Get2("name").(string) + o.Get2("owner").(string) + " @" + string(jsjson.MustMarshal(o.Get2("at")))
|
||||
if o.Get2("event") == "disconnect" {
|
||||
s += " dur=" + string(jsjson.MustMarshal(o.Get2("duration"))) + " rx=" + string(jsjson.MustMarshal(o.Get2("rx")))
|
||||
}
|
||||
b = append([]string{s}, b...) // старые сверху — читать проще
|
||||
}
|
||||
return strings.Join(b, "; ")
|
||||
}
|
||||
|
||||
peer("PK_IVAN", false, 0, 0, 0)
|
||||
peer("PK_PHONE", false, 0, 0, 0)
|
||||
l.tick(1000)
|
||||
if got := events(); got != "" {
|
||||
t.Fatalf("без онлайна событий быть не должно: %s", got)
|
||||
}
|
||||
|
||||
peer("PK_IVAN", true, 1010, 100, 50) // подключился, handshake в 1010
|
||||
l.tick(1030)
|
||||
peer("PK_IVAN", true, 1010, 900, 300) // качает
|
||||
l.tick(1060)
|
||||
peer("PK_IVAN", true, 1130, 900, 300) // keepalive-handshake без трафика
|
||||
l.tick(1150)
|
||||
up = false // интерфейс лёг — это не отключение
|
||||
l.tick(1180)
|
||||
up = true
|
||||
peer("PK_IVAN", false, 1130, 900, 300)
|
||||
l.tick(1330)
|
||||
if got, want := events(), "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800"; got != want {
|
||||
t.Fatalf("сессия:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
|
||||
// Перезапуск awg-ctrl посреди сессии: новый ConnLog поднимает открытую сессию из базы.
|
||||
peer("PK_PHONE", true, 2000, 10, 10)
|
||||
l.tick(2010)
|
||||
l = newLog()
|
||||
peer("PK_PHONE", true, 2100, 70, 10)
|
||||
l.tick(2110)
|
||||
// Пира сняли с интерфейса (удалили устройство) — сессия закрывается.
|
||||
delete(stats, "PK_PHONE")
|
||||
l.tick(2140)
|
||||
want := "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800; connect p1/m1/d2 @2000; disconnect p1/m1/d2 @2110 dur=110 rx=60"
|
||||
if got := events(); got != want {
|
||||
t.Fatalf("после перезапуска:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
|
||||
// Счётчики сбросились (перезапуск интерфейса) — трафик сессии считается с нуля, не уходит в минус.
|
||||
peer("PK_IVAN", true, 3000, 5000, 5000)
|
||||
l.tick(3010)
|
||||
peer("PK_IVAN", true, 3100, 40, 40)
|
||||
l.tick(3110)
|
||||
peer("PK_IVAN", false, 3100, 40, 40)
|
||||
l.tick(3400)
|
||||
if got := events(); !strings.HasSuffix(got, "connect ivan @3000; disconnect ivan @3110 dur=110 rx=40") {
|
||||
t.Fatalf("сброс счётчиков: %s", got)
|
||||
}
|
||||
|
||||
v, _ := l.list(0, 0, "")
|
||||
seen := map[string]any{}
|
||||
for _, p := range v.Get2("peers").([]any) {
|
||||
o := p.(jsjson.Object)
|
||||
seen[o.Get2("pub_key").(string)] = o.Get2("last_seen")
|
||||
}
|
||||
if seen["PK_IVAN"] != float64(3110) && seen["PK_IVAN"] != int64(3110) {
|
||||
t.Errorf("last_seen ivan: %v", seen["PK_IVAN"])
|
||||
}
|
||||
if f, _ := l.list(0, 0, "ivan"); len(f.Get2("events").([]any)) != 4 {
|
||||
t.Errorf("поиск по имени: %d событий", len(f.Get2("events").([]any)))
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,26 @@ func initSchema(db *sql.DB) error {
|
||||
psk_key TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
// Журнал VPN-подключений (connlog.go). name/owner/ip — на момент события: пользователя
|
||||
// могут удалить, а запись о нём остаётся.
|
||||
`CREATE TABLE IF NOT EXISTS conn_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
pub_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
duration INTEGER,
|
||||
rx INTEGER,
|
||||
tx INTEGER
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_conn_log_at ON conn_log (at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_conn_log_pub ON conn_log (pub_key, id)",
|
||||
`CREATE TABLE IF NOT EXISTS peer_seen (
|
||||
pub_key TEXT PRIMARY KEY,
|
||||
at INTEGER NOT NULL
|
||||
)`,
|
||||
} {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
|
||||
@@ -37,6 +37,14 @@ func (s *Server) routes() []route {
|
||||
{"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil
|
||||
}),
|
||||
|
||||
R("GET", "/api/connlog", false, a, func(c *rctx) (result, error) {
|
||||
v, err := s.connlog.list(queryInt(c, "limit"), queryInt(c, "before"), c.r.URL.Query().Get("q"))
|
||||
if err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
return ok(v)
|
||||
}),
|
||||
|
||||
R("POST", "/api/users", true, an, func(c *rctx) (result, error) {
|
||||
name, _ := c.obj().Get2("name").(string)
|
||||
if u, err := s.userGet(name); err != nil {
|
||||
|
||||
@@ -56,6 +56,7 @@ type Server struct {
|
||||
|
||||
shaping *Shaping
|
||||
torrent *Torrent
|
||||
connlog *ConnLog
|
||||
metrics metricsCache
|
||||
monitor monitorJob
|
||||
cliPath string
|
||||
@@ -157,6 +158,7 @@ func New() (*Server, error) {
|
||||
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
|
||||
s.shaping = newShaping(s)
|
||||
s.torrent = newTorrent(s)
|
||||
s.connlog = newConnLog(s)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -185,5 +187,6 @@ func (s *Server) Run() error {
|
||||
go s.shaping.Apply()
|
||||
s.shaping.Start()
|
||||
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
|
||||
s.connlog.Start()
|
||||
return <-errc
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package ui
|
||||
|
||||
// Журнал VPN-подключений (вкладка «Подключения»): события ведёт awg-ctrl каждого сервера
|
||||
// (internal/ctrl/connlog.go), панель забирает их у выбранного сервера и подставляет имена, которых
|
||||
// awg-ctrl не знает: пир sen:// там — только владелец «<панель>/m<ключ>/d<устройство>».
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`)
|
||||
|
||||
// ownerNames — название мастер-ключа и имя устройства для владельца пира. foreign — пир другой
|
||||
// панели на том же awg-ctrl (у ноды может быть своя): её имён мы не знаем.
|
||||
func (s *Sub) ownerNames(owner string, cache map[string][3]any) (key, device any, foreign bool) {
|
||||
if v, ok := cache[owner]; ok {
|
||||
return v[0], v[1], v[2].(bool)
|
||||
}
|
||||
m := reOwner.FindStringSubmatch(owner)
|
||||
switch {
|
||||
case m == nil, m[1] != "" && m[1] != s.panelID: // не наш формат или чужая панель
|
||||
foreign = true
|
||||
default:
|
||||
mid, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
did, _ := strconv.ParseInt(m[3], 10, 64)
|
||||
var label, name string
|
||||
if s.a.db.QueryRow("SELECT label FROM master_keys WHERE id = ?", mid).Scan(&label) == nil {
|
||||
key = label
|
||||
}
|
||||
if s.a.db.QueryRow("SELECT device_name FROM devices WHERE id = ?", did).Scan(&name) == nil {
|
||||
device = name
|
||||
}
|
||||
}
|
||||
cache[owner] = [3]any{key, device, foreign}
|
||||
return key, device, foreign
|
||||
}
|
||||
|
||||
func (a *App) connlog(c *Ctx) bool {
|
||||
q := url.Values{}
|
||||
for _, k := range []string{"limit", "before", "q"} {
|
||||
if v := c.R.URL.Query().Get(k); v != "" {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
r, err := a.nodes.CtrlFor(a.serverIDOf(c))("GET", "/api/connlog?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return c.Error(502, "awg-ctrl недоступен")
|
||||
}
|
||||
if r.Status == 404 {
|
||||
return c.Error(501, "awg-ctrl на этом сервере старой версии и журнала не ведёт — обновите сервер")
|
||||
}
|
||||
data, ok := r.Data.(jsjson.Object)
|
||||
if r.Status != 200 || !ok {
|
||||
return c.JSON(r.Status, r.Data)
|
||||
}
|
||||
cache := map[string][3]any{}
|
||||
enrich := func(list any) {
|
||||
items, _ := list.([]any)
|
||||
for i, it := range items {
|
||||
o, ok := it.(jsjson.Object)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if owner, _ := o.Get2("owner").(string); owner != "" && a.sub != nil {
|
||||
key, dev, foreign := a.sub.ownerNames(owner, cache)
|
||||
o = append(o, jsjson.KV{Key: "key", Val: key}, jsjson.KV{Key: "device", Val: dev}, jsjson.KV{Key: "foreign", Val: foreign})
|
||||
}
|
||||
items[i] = o
|
||||
}
|
||||
}
|
||||
enrich(data.Get2("events"))
|
||||
enrich(data.Get2("peers"))
|
||||
return c.JSON(200, data)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
// Владелец пира sen:// в журнале → название мастер-ключа и устройства этой панели.
|
||||
func TestConnLogOwnerNames(t *testing.T) {
|
||||
a := testApp(t, t.TempDir(), "on")
|
||||
s := &Sub{a: a}
|
||||
if err := s.initSchema(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.panelID = "abcd1234" // initSchema заводит свой
|
||||
|
||||
a.db.Exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)")
|
||||
a.db.Exec("INSERT INTO devices (id, master_id, device_id, device_name, pub_key, auth_pub, created_at) VALUES (2, 1, 'd', 'iPhone Маши', 'W', 'A', 1)")
|
||||
cache := map[string][3]any{}
|
||||
for owner, want := range map[string][3]any{
|
||||
"abcd1234/m1/d2": {"Семья", "iPhone Маши", false},
|
||||
"m1/d2": {"Семья", "iPhone Маши", false}, // выдан до областей панели
|
||||
"abcd1234/m1/d9": {"Семья", nil, false}, // устройство удалено
|
||||
"abcd1234/m7/d9": {nil, nil, false}, // и ключ удалён
|
||||
"ffff0000/m1/d2": {nil, nil, true}, // другая панель на том же awg-ctrl
|
||||
"что-то/другое": {nil, nil, true},
|
||||
} {
|
||||
k, d, f := s.ownerNames(owner, cache)
|
||||
if k != want[0] || d != want[1] || f != want[2] {
|
||||
t.Errorf("%s: %v %v %v, ждали %v", owner, k, d, f, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -879,6 +879,7 @@ func (a *App) routes() *Router {
|
||||
app.Use("/ui/metrics-token", auth)
|
||||
app.Mount("/ui/metrics-token", a.metrics.router())
|
||||
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
|
||||
app.Get("/ui/connlog", auth, a.connlog)
|
||||
app.Use("/ui/monitor", auth)
|
||||
app.Mount("/ui/monitor", a.monitor.router())
|
||||
// Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса.
|
||||
|
||||
Reference in New Issue
Block a user