Commit a5bc0f8
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
- cmd/rospanel
- internal
- core
- datasec
- model
- server
- store
- migrations
- telegram
- xray
- web/src
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
256 | 262 | | |
257 | 263 | | |
258 | 264 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
203 | 203 | | |
204 | 204 | | |
205 | 205 | | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
206 | 209 | | |
207 | 210 | | |
208 | 211 | | |
| |||
217 | 220 | | |
218 | 221 | | |
219 | 222 | | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
220 | 230 | | |
221 | 231 | | |
222 | 232 | | |
| |||
265 | 275 | | |
266 | 276 | | |
267 | 277 | | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
268 | 285 | | |
269 | 286 | | |
270 | 287 | | |
271 | | - | |
272 | 288 | | |
273 | 289 | | |
274 | 290 | | |
| |||
344 | 360 | | |
345 | 361 | | |
346 | 362 | | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
347 | 378 | | |
348 | 379 | | |
349 | 380 | | |
| |||
353 | 384 | | |
354 | 385 | | |
355 | 386 | | |
356 | | - | |
357 | | - | |
358 | | - | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
359 | 390 | | |
360 | 391 | | |
361 | 392 | | |
362 | 393 | | |
363 | 394 | | |
| 395 | + | |
364 | 396 | | |
365 | 397 | | |
366 | 398 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
41 | 45 | | |
42 | 46 | | |
43 | 47 | | |
| |||
53 | 57 | | |
54 | 58 | | |
55 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
56 | 64 | | |
57 | 65 | | |
58 | 66 | | |
| |||
81 | 89 | | |
82 | 90 | | |
83 | 91 | | |
84 | | - | |
85 | | - | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
86 | 97 | | |
87 | 98 | | |
88 | 99 | | |
| |||
176 | 187 | | |
177 | 188 | | |
178 | 189 | | |
| 190 | + | |
179 | 191 | | |
180 | 192 | | |
181 | 193 | | |
| |||
205 | 217 | | |
206 | 218 | | |
207 | 219 | | |
208 | | - | |
| 220 | + | |
| 221 | + | |
209 | 222 | | |
210 | 223 | | |
211 | 224 | | |
| |||
248 | 261 | | |
249 | 262 | | |
250 | 263 | | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
251 | 270 | | |
252 | | - | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
253 | 276 | | |
254 | 277 | | |
255 | 278 | | |
| |||
261 | 284 | | |
262 | 285 | | |
263 | 286 | | |
| 287 | + | |
264 | 288 | | |
265 | | - | |
266 | 289 | | |
267 | 290 | | |
268 | 291 | | |
| |||
273 | 296 | | |
274 | 297 | | |
275 | 298 | | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
276 | 334 | | |
277 | | - | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
278 | 357 | | |
279 | 358 | | |
280 | 359 | | |
281 | 360 | | |
282 | 361 | | |
283 | | - | |
| 362 | + | |
284 | 363 | | |
285 | 364 | | |
286 | 365 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
0 commit comments