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
79 lines
2.8 KiB
Go
79 lines
2.8 KiB
Go
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)
|
|
}
|