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
+16
View File
@@ -314,6 +314,22 @@ base64url-декодировать, отбросить первые 4 байта
| `DELETE /ui/devices/:id` | Отвязать устройство |
| `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства |
## Журнал подключений
Вкладка «Подключения» показывает по выбранному серверу:
- **События** — кто подключился к VPN и отключился, когда, сколько длилась сессия и сколько
скачал и отдал. Поиск — по имени пользователя, мастер-ключу, устройству или внутреннему IP.
- **Кто когда был** — все пользователи и устройства sen:// сервера: онлайн сейчас, когда были на
связи последний раз и кто не подключался ни разу с начала журнала.
Журнал ведёт awg-ctrl каждого сервера (`conn_log` в `users.db`, 90 дней). У WireGuard нет событий
подключения, поэтому раз в 30 с он сверяет время handshake и счётчики трафика: «подключился» —
время первого handshake, «отключился» — последняя активность (точность около 30 с). Перезапуск
awg-ctrl сессии не рвёт, падение интерфейса отключением не считается. Внешний IP клиентов не
записывается. Тот же журнал по API: `GET /api/connlog?limit=&before=&q=`. Серверы на прежней
версии журнала не ведут — вкладка попросит их обновить.
## Управление
```bash
+7
View File
@@ -108,10 +108,17 @@ export const IcoMove = () => (
</svg>
);
export const IcoHistory = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>
</svg>
);
export const ICONS: Record<string, () => JSX.Element> = {
users: IcoUsers,
key: IcoKey,
shield: IcoShield,
pulse: IcoPulse,
move: IcoMove,
history: IcoHistory,
};
+46
View File
@@ -0,0 +1,46 @@
/* Вкладка «Подключения»: лента событий и список «кто когда был». Строки — гибкие, без таблицы:
на телефоне колонки сами переносятся. */
.cl { display: flex; flex-direction: column; gap: 12px; }
.cl-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.cl-views { display: flex; gap: 8px; }
.cl-view {
min-height: 44px; padding: 0 18px;
font: inherit; font-size: 14px; color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill);
cursor: pointer;
}
.cl-view:hover { background: var(--state-primary); }
.cl-view--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.cl-search { flex: 1; min-width: 200px; width: auto; }
.cl-hint { margin: 0; font-size: 13px; color: var(--neutral-text); }
.cl-err { text-align: left; }
.cl-list {
list-style: none; margin: 0; padding: 0;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-card);
overflow: hidden;
}
.cl-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px;
padding: 12px 16px;
border-top: 1px solid var(--outline-variant);
}
.cl-row:first-child { border-top: none; }
.cl-time { flex: 0 0 110px; font-size: 13px; color: var(--neutral-text); font-variant-numeric: tabular-nums; }
.cl-chip { flex: none; }
.cl-who { flex: 1 1 200px; display: flex; flex-direction: column; min-width: 0; }
.cl-who b { font-weight: 500; overflow-wrap: anywhere; }
.cl-sub { font-size: 12px; color: var(--neutral-text); overflow-wrap: anywhere; }
.cl-session, .cl-seen { font-size: 13px; color: var(--on-surface); font-variant-numeric: tabular-nums; }
.cl-empty { padding: 24px 16px; text-align: center; color: var(--neutral-text); }
.cl-more { display: flex; justify-content: center; padding: 12px; border-top: 1px solid var(--outline-variant); }
@media (max-width: 640px) {
.cl-time { flex-basis: auto; }
.cl-views { width: 100%; }
.cl-view { flex: 1; }
}
+183
View File
@@ -0,0 +1,183 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useMemo } from 'react';
import { apiFetch, bytes, timeAgo, type PageProps } from '../../lib/shared';
import { IcoRefresh } from '../../components/icons';
import './connlog.css';
// Вкладка «Подключения»: журнал VPN выбранного сервера. Ведёт его awg-ctrl (internal/ctrl/connlog.go)
// по handshake и счётчикам WireGuard раз в 30 с, панель подставляет имена ключей и устройств sen://
// (/ui/connlog). Время отключения — последняя активность, точность около 30 с.
interface Who { pub_key: string; name: string; owner: string; ip: string; key?: string | null; device?: string | null; foreign?: boolean }
interface Ev extends Who { id: number; at: number; event: 'connect' | 'disconnect'; duration?: number | null; rx?: number | null; tx?: number | null }
interface Peer extends Who { online: boolean; last_seen: number | null }
interface Log { events: Ev[]; peers: Peer[]; since: number | null; keep_days: number }
const errText = (e: any): string => e?.response?.data?.error ?? 'Не удалось получить журнал';
// Кто это: пользователь — по имени, устройство sen:// — ключ и устройство.
function who(w: Who): { title: string; sub: string } {
if (w.name) return { title: w.name, sub: 'пользователь' };
if (w.foreign) return { title: 'Устройство другой панели', sub: w.owner };
if (w.owner) {
return { title: w.device || 'Удалённое устройство', sub: w.key ? `мастер-ключ «${w.key}»` : 'мастер-ключ удалён' };
}
return { title: `ключ ${w.pub_key.slice(0, 10)}…`, sub: 'не из панели' };
}
const pad = (n: number) => String(n).padStart(2, '0');
function when(ts: number): string {
const d = new Date(ts * 1000), now = new Date();
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (d.toDateString() === now.toDateString()) return `сегодня ${time}`;
const y = new Date(now); y.setDate(now.getDate() - 1);
if (d.toDateString() === y.toDateString()) return `вчера ${time}`;
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}${d.getFullYear() === now.getFullYear() ? '' : '.' + d.getFullYear()} ${time}`;
}
function duration(s: number): string {
if (s < 60) return `${s} сек`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} мин`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} ч ${m % 60} мин`;
return `${Math.floor(h / 24)} дн ${h % 24} ч`;
}
export default function ConnLogPage({ token, showMsg }: PageProps) {
const [view, setView] = useState<'events' | 'peers'>('events');
const [log, setLog] = useState<Log | null>(null);
const [more, setMore] = useState<Ev[]>([]); // подгруженные страницы старее первой
const [done, setDone] = useState(false); // старее событий нет
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState('');
// Первая страница: обновляем раз в 30 с — журнал пишется с тем же шагом.
const load = useCallback(async () => {
try {
const l = await apiFetch('GET', '/ui/connlog?limit=200', token) as Log;
setLog(l); setErr('');
if (l.events.length < 200) setDone(true);
} catch (e) { setErr(errText(e)); }
}, [token]);
useEffect(() => {
void load();
const t = setInterval(() => { void load(); }, 30000);
return () => clearInterval(t);
}, [load]);
const all = useMemo(() => {
const first = log?.events ?? [];
const seen = new Set(first.map(e => e.id));
return [...first, ...more.filter(e => !seen.has(e.id))];
}, [log, more]);
const loadMore = async () => {
const last = all[all.length - 1];
if (!last) return;
setBusy(true);
try {
const l = await apiFetch('GET', `/ui/connlog?limit=200&before=${last.id}`, token) as Log;
setMore(m => [...m, ...l.events]);
if (l.events.length < 200) setDone(true);
} catch (e) { showMsg(errText(e)); }
finally { setBusy(false); }
};
const q = query.trim().toLowerCase();
const match = (w: Who) => !q || [w.name, w.owner, w.ip, w.key ?? '', w.device ?? '', w.pub_key]
.some(s => s.toLowerCase().includes(q));
const events = all.filter(match);
const peers = (log?.peers ?? []).filter(match).sort((a, b) =>
Number(b.online) - Number(a.online) || (b.last_seen ?? 0) - (a.last_seen ?? 0));
const onlineNow = (log?.peers ?? []).filter(p => p.online).length;
return (
<div className="cl">
<div className="cl-toolbar">
<div className="cl-views" role="group" aria-label="Вид">
<button type="button" className={`cl-view${view === 'events' ? ' cl-view--on' : ''}`} aria-pressed={view === 'events'}
onClick={() => setView('events')}>События</button>
<button type="button" className={`cl-view${view === 'peers' ? ' cl-view--on' : ''}`} aria-pressed={view === 'peers'}
onClick={() => setView('peers')}>Кто когда был</button>
</div>
<input className="field cl-search" type="search" placeholder="Имя, ключ, устройство или IP"
aria-label="Поиск" value={query} onChange={e => setQuery(e.target.value)} />
<button className="btn btn--tonal" onClick={() => void load()} aria-label="Обновить"><IcoRefresh /> Обновить</button>
</div>
{err && <p className="login-error cl-err">{err}</p>}
{!log && !err && <p className="cl-hint">Загрузка…</p>}
{log && (
<p className="cl-hint">
Сейчас онлайн: <b>{onlineNow}</b> из {log.peers.length}.
{' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней.</> : 'Журнал пуст: события появятся при первом подключении.'}
{' '}Время отключения — последняя активность, точность около 30 секунд.
</p>
)}
{log && view === 'events' && (
<ul className="cl-list">
{events.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'Событий пока нет'}</li>}
{events.map(e => {
const w = who(e);
const on = e.event === 'connect';
return (
<li className="cl-row" key={e.id}>
<span className="cl-time">{when(e.at)}</span>
<span className={`chip chip--${on ? 'online' : 'offline'} cl-chip`}>
<span className="chip-dot" />{on ? 'подключился' : 'отключился'}
</span>
<span className="cl-who">
<b>{w.title}</b>
<span className="cl-sub">{w.sub}{e.ip && <> · {e.ip}</>}</span>
</span>
{!on && (
<span className="cl-session">
{e.duration != null && <>сессия {duration(e.duration)}</>}
{/* rx/tx — со стороны сервера: то, что сервер принял, клиент отправил */}
{(e.tx || e.rx) ? <> · ↓ {bytes(e.tx ?? 0)} ↑ {bytes(e.rx ?? 0)}</> : null}
</span>
)}
</li>
);
})}
{!done && !q && all.length > 0 && (
<li className="cl-more">
<button className="btn btn--tonal" onClick={() => void loadMore()} disabled={busy}>
{busy ? 'Загрузка…' : 'Показать раньше'}
</button>
</li>
)}
</ul>
)}
{log && view === 'peers' && (
<ul className="cl-list">
{peers.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'На сервере нет пользователей'}</li>}
{peers.map(p => {
const w = who(p);
return (
<li className="cl-row" key={p.pub_key}>
<span className="cl-who">
<b>{w.title}</b>
<span className="cl-sub">{w.sub}{p.ip && <> · {p.ip}</>}</span>
</span>
{p.online ? (
<span className="chip chip--online cl-chip"><span className="chip-dot" />онлайн</span>
) : p.last_seen ? (
<span className="cl-seen">был {when(p.last_seen)} <span className="cl-sub">({timeAgo(p.last_seen)} назад)</span></span>
) : (
<span className="chip chip--offline cl-chip"><span className="chip-dot" />не подключался{log.since ? ` с ${when(log.since)}` : ''}</span>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "connlog",
"label": "Подключения",
"icon": "history",
"group": "main",
"order": 1.5
}
+287
View File
@@ -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
}
+116
View File
@@ -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)))
}
}
+20
View File
@@ -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 {
+8
View File
@@ -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 {
+3
View File
@@ -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
}
+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, его пускает только ключ из кода переноса.