Files
forgetting/internal/ctrl/connlog_test.go
T
Claude 1a54d05cca 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
2026-10-28 12:06:40 +00:00

117 lines
4.0 KiB
Go

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)))
}
}