diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index 122f135..666fa92 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -149,6 +149,24 @@ export default function App() { if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false); }, []); + // Токен протух (24 ч) или отозван, пока панель открыта: запросы вкладок падали с 401, а экран + // оставался прежним. Выходим только по 401 самой панели («Unauthorized» — её JWT не принят): + // 401 от awg-ctrl ноды (внутренняя авторизация) пересылается как есть и сессию не рвёт. + useEffect(() => { + const id = axios.interceptors.response.use(undefined, e => { + if (axios.isAxiosError(e) && e.response?.status === 401 && e.response.data?.error === 'Unauthorized') { + localStorage.removeItem(TOKEN_KEY); + setToken(''); + setNodes([]); + setServerId(null); + setServerIdState(null); + showMsg('Сессия истекла — войдите снова'); + } + return Promise.reject(e); + }); + return () => axios.interceptors.response.eject(id); + }, [showMsg]); + // Восстановить сессию из сохранённого токена useEffect(() => { const saved = localStorage.getItem(TOKEN_KEY); diff --git a/internal/ctrl/metrics.go b/internal/ctrl/metrics.go index 31d728f..b375de5 100644 --- a/internal/ctrl/metrics.go +++ b/internal/ctrl/metrics.go @@ -129,6 +129,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) { if st.Up { stats = s.getPeersData() } + torrent := s.torrent.SeenSet() // до запроса: внутри цикла по строкам — ни базы, ни nft rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''") if err != nil { return nil, err @@ -141,7 +142,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) { return nil, err } ps := stats[pub] - b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip), s.torrent.Seen(ip)}) + b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip), torrent[ip]}) } rows.Close() devs, err := s.peersAll() @@ -150,7 +151,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) { } for _, p := range devs { ps := stats[p.PubKey] - b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP), s.torrent.Seen(p.IP)}) + b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP), torrent[p.IP]}) } b.Peers.Total = st.Peers for _, ps := range stats { diff --git a/internal/ctrl/metrics_test.go b/internal/ctrl/metrics_test.go new file mode 100644 index 0000000..ae9944a --- /dev/null +++ b/internal/ctrl/metrics_test.go @@ -0,0 +1,37 @@ +package ctrl + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/maeneko/forgetting/internal/logx" +) + +// SQLite открыт с одним соединением: любой запрос в базу изнутри цикла по строкам users ждал +// бы его вечно и намертво вешал awg-ctrl (так было в 0.4.2 с меткой торрента). Скрейп метрик +// идёт раз в 15 с, поэтому зависание проявлялось сразу. +func TestCollectMetricsNoDeadlock(t *testing.T) { + db, err := openDB(filepath.Join(t.TempDir(), "users.db")) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 5; i++ { + db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES (?, ?, ?, 'vpn://x')", fmt.Sprintf("u%d", i), fmt.Sprintf("10.9.0.%d", i+2), fmt.Sprintf("pk%d", i)) + } + s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())} + s.shaping = newShaping(s) + s.torrent = newTorrent(s) + done := make(chan error, 1) + go func() { _, err := s.collectMetrics(); done <- err }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("collectMetrics завис: запрос в базу изнутри цикла по строкам") + } +} diff --git a/internal/ctrl/routes.go b/internal/ctrl/routes.go index 44da853..5370b0c 100644 --- a/internal/ctrl/routes.go +++ b/internal/ctrl/routes.go @@ -73,6 +73,7 @@ func (s *Server) routes() []route { }), R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) { + torrent := s.torrent.SeenSet() rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''") if err != nil { return result{}, err @@ -88,7 +89,7 @@ func (s *Server) routes() []route { ps := stats[pub] list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)}, - {"torrent", s.torrent.Seen(ip)}}) + {"torrent", torrent[ip]}}) } rows.Close() return ok(ob{{"users", list}}) @@ -138,6 +139,7 @@ func (s *Server) routes() []route { }), R("GET", "/api/peers", true, a, func(c *rctx) (result, error) { + torrent := s.torrent.SeenSet() stats := s.getPeersData() peers, err := s.peersAll() if err != nil { @@ -148,7 +150,7 @@ func (s *Server) routes() []route { ps := stats[p.PubKey] list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner}, {"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, - {"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", s.torrent.Seen(p.IP)}}) + {"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", torrent[p.IP]}}) } return ok(ob{{"peers", list}}) }), diff --git a/internal/ctrl/torrent.go b/internal/ctrl/torrent.go index 9d305d5..8b85df2 100644 --- a/internal/ctrl/torrent.go +++ b/internal/ctrl/torrent.go @@ -71,7 +71,9 @@ type Torrent struct { opMu sync.Mutex // сериализует nft - mu sync.Mutex + mu sync.Mutex + enabled bool // копия настройки в памяти: cached() зовут внутри циклов по строкам users, а + // соединение с SQLite одно — запрос в базу оттуда ждал бы его вечно tcp bool lastError string cacheAt time.Time @@ -79,7 +81,11 @@ type Torrent struct { cacheHits float64 } -func newTorrent(s *Server) *Torrent { return &Torrent{s: s} } +func newTorrent(s *Server) *Torrent { + t := &Torrent{s: s} + t.enabled = t.Settings().Enabled + return t +} func (t *Torrent) Settings() TorrentSettings { return TorrentSettings{Enabled: t.s.getCfg("torrentBlock", "0") == "1"} @@ -163,6 +169,9 @@ func (t *Torrent) Update(st TorrentSettings) error { v = "1" } t.s.setCfg("torrentBlock", v) + t.mu.Lock() + t.enabled, t.cache = st.Enabled, nil + t.mu.Unlock() t.opMu.Lock() err := t.applyNow() t.opMu.Unlock() @@ -228,11 +237,13 @@ func (t *Torrent) read() (seen map[string]bool, hits float64, ok bool) { return seen, hits, true } +// cached не трогает базу (только память и nft) — его можно звать где угодно. func (t *Torrent) cached() (map[string]bool, float64, bool) { - if !t.Settings().Enabled { + t.mu.Lock() + if !t.enabled { + t.mu.Unlock() return nil, 0, false } - t.mu.Lock() if t.cache != nil && time.Since(t.cacheAt) < torrentCacheS { c, h := t.cache, t.cacheHits t.mu.Unlock() @@ -249,10 +260,15 @@ func (t *Torrent) cached() (map[string]bool, float64, bool) { return seen, hits, true } -// Seen — пытался ли этот IP открыть торрент-соединение за последние torrentSeenTTL. -func (t *Torrent) Seen(ip string) bool { +// SeenSet — кто пытался открыть торрент-соединение за последние torrentSeenTTL. Берите его до +// запроса к базе, а не внутри цикла по строкам: так на открытом курсоре не висит вызов nft. +func (t *Torrent) SeenSet() map[string]bool { seen, _, _ := t.cached() - return seen[ip] + out := make(map[string]bool, len(seen)) + for ip := range seen { + out[ip] = true + } + return out } // SeenList — IP с меткой «торренты», по порядку; шейпинг держит их в фоновом классе.