Skip to content

Commit a5bc0f8

Browse files
authored
feat(telegram): support relay, broadcasts, and the scaling work that came out of it (#34)
# Telegram support & broadcasts, and the scaling work that came out of it Closes #29. Two things landed on this branch. The first is the feature the issue asked for; the second is what fell out of asking "how many users can this actually hold?" while testing it. ## Support relay, broadcasts, user notifications A third bot relays user support into a forum group, one topic per person, so operators answer from Telegram instead of the panel. Broadcasts have a per-recipient table, so a delivery that dies halfway resumes rather than restarts — and nobody gets messaged twice. Users now hear about their own account (expiry, quota, payment), not just admins. ## Scaling and durability The panel's SQLite pool is a single connection with `synchronous=FULL`, so every statement is its own commit and its own fsync. Measured on the 1-core test box: **223 writes/sec, exactly the disk's fsync rate.** The hot paths wrote row by row, so the ceiling scaled with users — ~450 online was a hard wall, and six seconds of every sixty-second poll cycle went to writing while holding the one connection the whole panel shares. Fixed by batching, not by relaxing `synchronous` — durability is kept. | path | before | after | |---|---|---| | stats poll (500 users) | 6.075s | 70ms (**87×**) | | access-log tap (500 sightings) | 2.217s | 53ms (**42×**) | | dashboard summary, per tick per tab | 8.1ms | 3.4ms (**2.4×**) | `RecordAccess` runs per access-log line, so it now only buffers in memory; a 5s loop writes the batch (which also moved a full `WorkingUsers` query from per-sighting to per-flush). `CountUsers` replaces loading every user row — and decrypting every stored password — to compute four numbers. `statusFeed` computes the dashboard payload once for all viewers instead of once per open tab, and idles when nobody is watching. Write load is now roughly constant rather than linear in users. ## The bugs batching exposed Wrapping these paths in transactions surfaced a class of bug they had been hiding: **a claim committing separately from the thing it pays for.** - **Payment confirmation** wrote five autocommits with the terminal status *first* and the plan *last*. Every retry path selects `status = 'pending'`, which the claim had already cleared — so an ordinary restart in between took the money and left nothing in the codebase able to notice. Claim and grant now commit together. - **Node traffic ingest** had the same shape: the watermark committed before the traffic it covered, so a failure meant the node's resend was rejected as a duplicate and that batch was gone for good. And two consequences of batching that row-by-row code used to shrug off: - A **foreign-key violation now rolls back the whole batch**, so one deleted user could void everyone else's traffic — and wedge a node into resending the same poison batch forever. Both inserts guard on `EXISTS`. - The **abandoned-order sweep** cancelled by age *before* asking the provider, so an outage longer than a day cancelled orders that had in fact been paid. It asks first now. ## Also fixed (from review) - `traffic_daily` had no retention sweep and grew forever — capped at a year, with covering indexes for every query that reads it (all five now plan as `COVERING INDEX`). - Designating an existing **paid** plan as the free/trial one left its subscribers on paid terms: they expired, and then nothing could rescue or renew them. Their rows are now rewritten to the plan's new terms. - One plan could be chosen for **both** the free and trial roles, which stranded every self-registered user when their trial ended. Refused, in the UI and on the server. - Xray exiting during shutdown was reported as a **crash**, so an ordinary `systemctl stop` paged the operator with an alert no all-clear ever followed (`KillMode=mixed` + the supervisor treats any exit while closing as intentional). - Tariff editor: a designated free/trial plan no longer shows price, sort order or an "Активен" toggle — it is never offered for sale, and the toggle was quietly gating whether trials happened at all. ## Panel fixes found by using it - A node running the pinned Xray was reported as **outdated forever**: the health check compared against `PinnedVersion` with `==`, and that constant carries a leading "v" while `xray version` output does not. `VersionMatchesPinned` already existed and the Nodes tab already used it — so the two screens disagreed about the same node while the operator kept "updating" it. - **Traffic is now split by the server that carried it**, under the chart on both the stats page and a user's card. The data was always there (`traffic_daily` carries `node_id`) but nothing read it — `StatsSeriesNode` sat in the store with no caller. Numbers rather than more lines: the chart already draws two, and a line per server would be 2×N. - Dropped two dashboard cards that answered nothing. "Общий объём трафика" summed `users.used_up/used_down`, which the quota reset zeroes per user — so it added up a different period for everybody while reading as a lifetime figure. "Сеть сервера" showed whole-host NIC throughput directly above the VPN number it never matched. ## Migrations Everything is folded into `0031_telegram_support_broadcast.sql`, except `0032_drop_billing_trial_days.sql`. **The index DDL deliberately lives in 0032, not 0031.** The migration runner keys off the filename with no checksum, so anything appended to an already-applied file silently never runs — a fresh install would get it and an upgraded one would not, with no error either way. This was confirmed on the test box, whose `schema_migrations` still holds both the pre-squash and post-squash series. ## Verification `go build`, the full `go test ./... -race`, and `golangci-lint v2.12.2` are clean; `tsc --noEmit` and `vite build` pass. Deployed to the test box and checked live: migrations applied, both covering indexes in use on the real database, service healthy (0 restarts, no panic), data intact. The riskiest changes are pinned by tests that were **verified to fail against the old code**: the payment-atomicity test reproduces the historical "order paid, plan never granted" symptom, the FK test reproduces the wedged node, and the shutdown test reproduces the false crash alert. ## Hardening (from a security pass) - The public **user bot had no per-chat limit** while the support bot did. Its poll loop is one goroutine answering synchronously, and every reply waits on the outbound one-second-per-chat slot — so one chat could stall registration, menus and payments for everyone, writing a subscriber row per message before any gate. Both bots now share one `chatLimiter`, applied ahead of that write. - **Invite codes** get a tighter budget of their own (5 per 10 minutes, per chat). The comparison was already constant-time, but that only closes a timing oracle — nothing bounded how many codes a chat could try, and a hit mints a real account. - `dbHasEncryptedSecrets` did not know about `tg_support_bot_token`. That guard is what tells "fresh install" apart from "the key is gone", so an install running only the support bot looked fresh: boot would mint a new key and orphan the ciphertext, silently. Every encrypted column is listed now, the nodes table included, and the column was missing from the re-encrypt list too.
1 parent 170a95d commit a5bc0f8

87 files changed

Lines changed: 10467 additions & 476 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/rospanel/cli.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ func runInstall() {
253253
"ExecStart=" + installBinPath + "\n" +
254254
"Restart=always\n" +
255255
"RestartSec=3\n" +
256+
// Signal only the panel, not everything in the cgroup. The default
257+
// (control-group) SIGTERMs the Xray child too, so it would die on its own
258+
// before the panel could mark the stop intentional — reported as a crash on
259+
// every restart. The panel stops Xray itself; KillMode=mixed still SIGKILLs
260+
// the whole group if the timeout expires, so nothing can be left behind.
261+
"KillMode=mixed\n" +
256262
// The panel still runs as root (it execs Xray, runs iptables/nft for the
257263
// brute-guard + Hysteria port-hopping, writes net.* sysctls for BBR, and
258264
// self-updates its own binary in /usr/local/bin). Rather than drop the user,

cmd/rospanel/service.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,9 @@ func runServer(dataDir string) {
203203
go tlsLoop(mgr)
204204
// Periodic traffic accounting + quota/expiry enforcement.
205205
go statsPollLoop(mgr)
206+
// Writes the buffered access-log sightings. RecordAccess only buffers, so this
207+
// is what actually persists who connected from where.
208+
go accessFlushLoop(mgr)
206209
// Payment polling fallback: reconciles pending provider orders in case a webhook
207210
// was missed. Idles cheaply when there are no pending orders.
208211
go paymentPollLoop(mgr)
@@ -217,6 +220,13 @@ func runServer(dataDir string) {
217220
// Telegram user bot: public self-service for VPN clients (registration,
218221
// subscription, stats). Idles until enabled with its own token in Settings.
219222
go telegram.NewUser(mgr, st).Run(context.Background())
223+
// Telegram support bot: relays messages between a user's private chat and a
224+
// per-user topic in the operator's forum supergroup. Idles until enabled with its
225+
// own token and a group in Settings → Telegram.
226+
go telegram.NewSupport(mgr, st).Run(context.Background())
227+
// Broadcast delivery. Polls the store rather than holding a queue, so a restart
228+
// mid-run resumes from the remaining recipients instead of losing or repeating.
229+
go telegram.NewBroadcast(st, dataDir).Run(context.Background())
220230

221231
handler, err := server.New(mgr, secret, set.DecoyTemplate, dataDir)
222232
if err != nil {
@@ -265,10 +275,16 @@ func runServer(dataDir string) {
265275
<-stop
266276
log.Print("shutting down")
267277

278+
// Stop Xray FIRST. Draining HTTP can take the full timeout below, and until the
279+
// supervisor is marked closed an Xray exit still reads as a crash — which is
280+
// exactly what happens under systemd's default KillMode, where Xray receives its
281+
// own SIGTERM at the same moment we do. Marking the shutdown before we wait on
282+
// anything is what keeps an ordinary restart from paging the operator.
283+
sup.Stop()
284+
268285
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
269286
defer cancel()
270287
_ = httpSrv.Shutdown(ctx)
271-
sup.Stop()
272288
}
273289

274290
// bootstrapTLS configures host/SNI and resolves a cert via ACME, falling back to
@@ -344,6 +360,21 @@ func statsPollLoop(mgr *core.Manager) {
344360
}
345361
}
346362

363+
// accessFlushInterval bounds how long a connection sighting sits in memory before
364+
// it is written, and so how quickly a newly-appeared device can trip the device
365+
// cap. Short enough to stay prompt, long enough that a busy server folds many
366+
// sightings into each commit.
367+
const accessFlushInterval = 5 * time.Second
368+
369+
// accessFlushLoop persists buffered access-log sightings.
370+
func accessFlushLoop(mgr *core.Manager) {
371+
t := time.NewTicker(accessFlushInterval)
372+
defer t.Stop()
373+
for range t.C {
374+
safeTick("access flush", mgr.FlushAccess)
375+
}
376+
}
377+
347378
// paymentPollLoop reconciles pending provider orders (webhook fallback) every 25s.
348379
func paymentPollLoop(mgr *core.Manager) {
349380
t := time.NewTicker(25 * time.Second)
@@ -353,14 +384,15 @@ func paymentPollLoop(mgr *core.Manager) {
353384
}
354385
}
355386

356-
// retentionLoop drops audit rows and stale connection rows past their retention
357-
// windows. Both cutoffs move by the day, so a slow cadence is plenty — this only
358-
// keeps the tables from growing forever.
387+
// retentionLoop drops audit rows, stale connection rows and old traffic history
388+
// past their retention windows. Every cutoff moves by the day, so a slow cadence is
389+
// plenty — this only keeps the tables from growing forever.
359390
func retentionLoop(mgr *core.Manager) {
360391
sweep := func() {
361392
mgr.PurgeOldEvents()
362393
mgr.PurgeOldAdminAudit()
363394
mgr.PurgeOldConnections()
395+
mgr.PurgeOldTraffic() // per-day traffic history past a year
364396
mgr.PurgeExpiredUsers() // no-op unless the operator set a grace period
365397
mgr.PurgeDeletedNodes() // reclaim node tombstones past their grace window
366398
}

internal/core/manager.go

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const reconcileDebounce = 800 * time.Millisecond
3838
const (
3939
accLastMax = 4096
4040
accLastTTL = int64(time.Hour / time.Second)
41+
// accPendingMax bounds the unflushed sighting buffer. Sized above accLastMax so
42+
// the throttle, not this cap, is what normally limits it — this only catches the
43+
// pathological case where flushes keep failing and the buffer stops draining.
44+
accPendingMax = 8192
4145
)
4246

4347
// Manager is the application service layer.
@@ -53,6 +57,10 @@ type Manager struct {
5357

5458
accMu sync.Mutex
5559
accLast map[string]int64 // throttle key "uN|ip" → last recorded unix
60+
// accPending buffers sightings between flushes, so the access-log reader never
61+
// touches the database on the hot path. Bounded by the throttle above: one entry
62+
// per user+IP per flush interval, not per log line.
63+
accPending map[accPendingKey]store.ConnectionHit
5664

5765
// applyMu serializes config application (Reconcile + the live user-sync) so a
5866
// direct Reconcile (e.g. from tlsLoop on cert renewal) can't interleave with the
@@ -81,8 +89,11 @@ type Manager struct {
8189

8290
// notifyThrottle bounds the rate of repeatable system alerts (Xray crash loop,
8391
// cert renewal errors) so a stuck condition can't flood the admin chats.
84-
throttleMu sync.Mutex
85-
lastCrashNotify time.Time
92+
throttleMu sync.Mutex
93+
lastCrashNotify time.Time
94+
// crashAlerted records that admins were actually told about the current outage,
95+
// so the all-clear is only sent for an alarm they saw.
96+
crashAlerted bool
8697
lastCertErrNotify time.Time
8798

8899
// applyPlanMu serializes ApplyPlanToUser so the read-modify-write of expire_at
@@ -176,6 +187,7 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
176187
tls: tls,
177188
reconcileCh: make(chan struct{}, 1),
178189
accLast: make(map[string]int64),
190+
accPending: make(map[accPendingKey]store.ConnectionHit),
179191
applied: make(map[int64]struct{}),
180192
tz: time.Local,
181193
guard: newBruteGuard(),
@@ -205,7 +217,8 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
205217
go func() { _ = m.syncOpera(true, set.OperaCountryOr(), set.OperaPortOr()) }()
206218
}
207219
}
208-
m.sup.SetOnCrash(m.onXrayCrash) // alert admins when Xray exits unexpectedly
220+
m.sup.SetOnCrash(m.onXrayCrash) // alert admins when Xray exits unexpectedly
221+
m.sup.SetOnRecover(m.onXrayRecover) // ...and tell them when it is back
209222
go m.reconcileLoop()
210223
go m.proxyLoop()
211224
go m.geoLoop() // auto-refresh geo databases on the operator's cadence
@@ -248,8 +261,18 @@ func (m *Manager) loc() *time.Location {
248261
// Location exposes the operator timezone for handlers that compute date ranges.
249262
func (m *Manager) Location() *time.Location { return m.loc() }
250263

264+
// accPendingKey identifies one buffered sighting.
265+
type accPendingKey struct {
266+
userID int64
267+
ip string
268+
}
269+
251270
// RecordAccess notes a connection from an Xray access-log line (email "uN" +
252-
// source IP). Throttled to one DB write per user+IP per 10s to absorb bursts.
271+
// source IP). Throttled to one recorded sighting per user+IP per 10s to absorb
272+
// bursts, then buffered — FlushAccess writes them.
273+
//
274+
// This is called from the access-log reader for every line Xray emits, so it does
275+
// no I/O at all: it takes a lock, updates two maps, and returns.
253276
func (m *Manager) RecordAccess(email, ip string) {
254277
if !strings.HasPrefix(email, "u") {
255278
return
@@ -261,8 +284,8 @@ func (m *Manager) RecordAccess(email, ip string) {
261284
now := time.Now().Unix()
262285
key := email + "|" + ip
263286
m.accMu.Lock()
287+
defer m.accMu.Unlock()
264288
if now-m.accLast[key] < 10 {
265-
m.accMu.Unlock()
266289
return
267290
}
268291
m.accLast[key] = now
@@ -273,14 +296,70 @@ func (m *Manager) RecordAccess(email, ip string) {
273296
}
274297
}
275298
}
299+
pk := accPendingKey{userID: id, ip: ip}
300+
h, buffered := m.accPending[pk]
301+
// Bound the buffer. It normally drains every few seconds, but a persistent write
302+
// failure (a full disk, say) makes FlushAccess requeue instead — and the throttle
303+
// above stops protecting us as soon as accLast evicts a key, since that reopens
304+
// the pair for buffering. Dropping the newest sighting for a pair we are not
305+
// already tracking costs a last_seen update; growing without limit costs the
306+
// process.
307+
if !buffered && len(m.accPending) >= accPendingMax {
308+
return
309+
}
310+
h.UserID, h.IP, h.Hits = id, ip, h.Hits+1
311+
if now > h.SeenAt {
312+
h.SeenAt = now
313+
}
314+
m.accPending[pk] = h
315+
}
316+
317+
// FlushAccess writes the buffered access sightings in one transaction and, if the
318+
// new devices changed who should be online, syncs Xray.
319+
//
320+
// The device-cap re-check used to run per sighting — a full WorkingUsers query for
321+
// every user+IP every 10s. It belongs here: it only has to happen when something
322+
// was actually recorded, and once per batch answers the same question.
323+
func (m *Manager) FlushAccess() {
324+
m.accMu.Lock()
325+
if len(m.accPending) == 0 {
326+
m.accMu.Unlock()
327+
return
328+
}
329+
hits := make([]store.ConnectionHit, 0, len(m.accPending))
330+
for _, h := range m.accPending {
331+
hits = append(hits, h)
332+
}
333+
clear(m.accPending)
276334
m.accMu.Unlock()
277-
if err := m.store.AddConnection(id, ip, now); err != nil {
335+
336+
if err := m.store.AddConnections(hits); err != nil {
337+
// Put them back rather than drop them: the buffer was already drained, so
338+
// returning here would silently lose the sightings, and stale last_seen /
339+
// undercounted devices feed straight into the device cap. Merging (rather than
340+
// overwriting) keeps whatever arrived while the write was in flight.
341+
m.accMu.Lock()
342+
for _, h := range hits {
343+
pk := accPendingKey{userID: h.UserID, ip: h.IP}
344+
cur, buffered := m.accPending[pk]
345+
if !buffered && len(m.accPending) >= accPendingMax {
346+
continue // same bound as RecordAccess: shed rather than grow forever
347+
}
348+
cur.UserID, cur.IP = h.UserID, h.IP
349+
cur.Hits += h.Hits
350+
if h.SeenAt > cur.SeenAt {
351+
cur.SeenAt = h.SeenAt
352+
}
353+
m.accPending[pk] = cur
354+
}
355+
m.accMu.Unlock()
356+
logErr("access: flush failed, sightings requeued", "sightings", len(hits), "err", err)
278357
return
279358
}
280359
// A new device (source IP) may push the user over their device cap — re-check
281360
// the working set and sync promptly so the over-limit user drops out, instead
282361
// of waiting for the next periodic reconcile.
283-
if working, err := m.store.WorkingUsers(now); err == nil && m.workingChanged(working) {
362+
if working, err := m.store.WorkingUsers(time.Now().Unix()); err == nil && m.workingChanged(working) {
284363
m.TriggerUserSync()
285364
}
286365
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package core
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/AppsGanin/rospanel/internal/store"
9+
)
10+
11+
func accessTestManager(t *testing.T) (*Manager, *store.Store) {
12+
t.Helper()
13+
st, err := store.Open(filepath.Join(t.TempDir(), "acc.db"))
14+
if err != nil {
15+
t.Fatalf("open store: %v", err)
16+
}
17+
t.Cleanup(func() { st.Close() })
18+
return &Manager{
19+
store: st,
20+
accLast: make(map[string]int64),
21+
accPending: make(map[accPendingKey]store.ConnectionHit),
22+
}, st
23+
}
24+
25+
// TestRecordAccessDoesNoIO pins the hot path. RecordAccess runs for every line the
26+
// Xray access log emits; it must only touch memory, leaving the write to the
27+
// flusher. Before this, each admitted sighting did two statements plus a full
28+
// WorkingUsers query — the panel's single busiest write source.
29+
func TestRecordAccessDoesNoIO(t *testing.T) {
30+
m, st := accessTestManager(t)
31+
u, err := st.CreateUser("u1", "uuid-1", "pw", "tok", 0, 0, 0)
32+
if err != nil {
33+
t.Fatalf("create user: %v", err)
34+
}
35+
email := fmt.Sprintf("u%d", u.ID)
36+
37+
for range 50 {
38+
m.RecordAccess(email, "1.1.1.1")
39+
m.RecordAccess(email, "2.2.2.2")
40+
}
41+
42+
conns, err := st.RecentConnections(u.ID, 10)
43+
if err != nil {
44+
t.Fatalf("connections: %v", err)
45+
}
46+
if len(conns) != 0 {
47+
t.Fatalf("RecordAccess wrote %d rows before the flush — the hot path is still doing I/O", len(conns))
48+
}
49+
// The 10s throttle collapses the burst: two IPs, one buffered sighting each.
50+
if got := len(m.accPending); got != 2 {
51+
t.Fatalf("buffered %d sightings from 100 calls across 2 IPs, want 2", got)
52+
}
53+
}
54+
55+
// TestFlushAccessWritesBatch: the buffered sightings land, and flushing an empty
56+
// buffer is free.
57+
func TestFlushAccessWritesBatch(t *testing.T) {
58+
m, st := accessTestManager(t)
59+
u, err := st.CreateUser("u1", "uuid-1", "pw", "tok", 0, 0, 0)
60+
if err != nil {
61+
t.Fatalf("create user: %v", err)
62+
}
63+
email := fmt.Sprintf("u%d", u.ID)
64+
for _, ip := range []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"} {
65+
m.RecordAccess(email, ip)
66+
}
67+
68+
m.FlushAccess()
69+
conns, err := st.RecentConnections(u.ID, 10)
70+
if err != nil {
71+
t.Fatalf("connections: %v", err)
72+
}
73+
if len(conns) != 3 {
74+
t.Fatalf("flushed %d connection rows, want 3", len(conns))
75+
}
76+
cur, err := st.GetUser(u.ID)
77+
if err != nil {
78+
t.Fatalf("get user: %v", err)
79+
}
80+
if cur.LastSeen == 0 {
81+
t.Error("flush did not stamp last_seen")
82+
}
83+
84+
// Buffer is drained, so a second flush writes nothing new.
85+
if len(m.accPending) != 0 {
86+
t.Fatalf("buffer still holds %d sightings after a flush", len(m.accPending))
87+
}
88+
m.FlushAccess()
89+
conns, _ = st.RecentConnections(u.ID, 10)
90+
if len(conns) != 3 {
91+
t.Fatalf("second flush changed the row count to %d", len(conns))
92+
}
93+
}
94+
95+
// TestRecordAccessIgnoresJunk: the access log is parsed text, so non-user emails
96+
// must not create buffer entries.
97+
func TestRecordAccessIgnoresJunk(t *testing.T) {
98+
m, _ := accessTestManager(t)
99+
for _, email := range []string{"", "admin", "unotanumber", "12", "u"} {
100+
m.RecordAccess(email, "1.1.1.1")
101+
}
102+
if len(m.accPending) != 0 {
103+
t.Fatalf("buffered %d sightings from junk emails", len(m.accPending))
104+
}
105+
}

0 commit comments

Comments
 (0)