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:
Claude
2026-10-28 12:06:40 +00:00
parent fd5e2cc761
commit 1a54d05cca
13 changed files with 802 additions and 0 deletions
+78
View File
@@ -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)
}
+30
View File
@@ -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)
}
}
}
+1
View File
@@ -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, его пускает только ключ из кода переноса.