diff --git a/README.md b/README.md index 44b99a0..af93a1d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/awg-ui/src/components/icons.tsx b/awg-ui/src/components/icons.tsx index 1882371..8a663f8 100644 --- a/awg-ui/src/components/icons.tsx +++ b/awg-ui/src/components/icons.tsx @@ -108,10 +108,17 @@ export const IcoMove = () => ( ); +export const IcoHistory = () => ( + + + +); + export const ICONS: Record JSX.Element> = { users: IcoUsers, key: IcoKey, shield: IcoShield, pulse: IcoPulse, move: IcoMove, + history: IcoHistory, }; \ No newline at end of file diff --git a/awg-ui/src/tabs/connlog/connlog.css b/awg-ui/src/tabs/connlog/connlog.css new file mode 100644 index 0000000..03e1425 --- /dev/null +++ b/awg-ui/src/tabs/connlog/connlog.css @@ -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; } +} diff --git a/awg-ui/src/tabs/connlog/index.tsx b/awg-ui/src/tabs/connlog/index.tsx new file mode 100644 index 0000000..f94232f --- /dev/null +++ b/awg-ui/src/tabs/connlog/index.tsx @@ -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(null); + const [more, setMore] = useState([]); // подгруженные страницы старее первой + 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 ( +
+
+
+ + +
+ setQuery(e.target.value)} /> + +
+ + {err &&

{err}

} + {!log && !err &&

Загрузка…

} + + {log && ( +

+ Сейчас онлайн: {onlineNow} из {log.peers.length}. + {' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней. : 'Журнал пуст: события появятся при первом подключении.'} + {' '}Время отключения — последняя активность, точность около 30 секунд. +

+ )} + + {log && view === 'events' && ( +
    + {events.length === 0 &&
  • {q ? 'Ничего не найдено' : 'Событий пока нет'}
  • } + {events.map(e => { + const w = who(e); + const on = e.event === 'connect'; + return ( +
  • + {when(e.at)} + + {on ? 'подключился' : 'отключился'} + + + {w.title} + {w.sub}{e.ip && <> · {e.ip}} + + {!on && ( + + {e.duration != null && <>сессия {duration(e.duration)}} + {/* rx/tx — со стороны сервера: то, что сервер принял, клиент отправил */} + {(e.tx || e.rx) ? <> · ↓ {bytes(e.tx ?? 0)} ↑ {bytes(e.rx ?? 0)} : null} + + )} +
  • + ); + })} + {!done && !q && all.length > 0 && ( +
  • + +
  • + )} +
+ )} + + {log && view === 'peers' && ( +
    + {peers.length === 0 &&
  • {q ? 'Ничего не найдено' : 'На сервере нет пользователей'}
  • } + {peers.map(p => { + const w = who(p); + return ( +
  • + + {w.title} + {w.sub}{p.ip && <> · {p.ip}} + + {p.online ? ( + онлайн + ) : p.last_seen ? ( + был {when(p.last_seen)} ({timeAgo(p.last_seen)} назад) + ) : ( + не подключался{log.since ? ` с ${when(log.since)}` : ''} + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/awg-ui/src/tabs/connlog/metadata.json b/awg-ui/src/tabs/connlog/metadata.json new file mode 100644 index 0000000..cf7bad5 --- /dev/null +++ b/awg-ui/src/tabs/connlog/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "connlog", + "label": "Подключения", + "icon": "history", + "group": "main", + "order": 1.5 +} diff --git a/internal/ctrl/connlog.go b/internal/ctrl/connlog.go new file mode 100644 index 0000000..cadba57 --- /dev/null +++ b/internal/ctrl/connlog.go @@ -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= — следующая страница, ?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 +} diff --git a/internal/ctrl/connlog_test.go b/internal/ctrl/connlog_test.go new file mode 100644 index 0000000..4c507ef --- /dev/null +++ b/internal/ctrl/connlog_test.go @@ -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))) + } +} diff --git a/internal/ctrl/db.go b/internal/ctrl/db.go index 064364a..5860fae 100644 --- a/internal/ctrl/db.go +++ b/internal/ctrl/db.go @@ -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 { diff --git a/internal/ctrl/routes.go b/internal/ctrl/routes.go index 5370b0c..88de669 100644 --- a/internal/ctrl/routes.go +++ b/internal/ctrl/routes.go @@ -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 { diff --git a/internal/ctrl/server.go b/internal/ctrl/server.go index 212c07e..76fdc6b 100644 --- a/internal/ctrl/server.go +++ b/internal/ctrl/server.go @@ -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 } diff --git a/internal/ui/connlog.go b/internal/ui/connlog.go new file mode 100644 index 0000000..3f65f17 --- /dev/null +++ b/internal/ui/connlog.go @@ -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) +} diff --git a/internal/ui/connlog_test.go b/internal/ui/connlog_test.go new file mode 100644 index 0000000..fc4acdc --- /dev/null +++ b/internal/ui/connlog_test.go @@ -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) + } + } +} diff --git a/internal/ui/server.go b/internal/ui/server.go index 462aa89..9aa19f7 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -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, его пускает только ключ из кода переноса.