Skip to content

Feat/ops hardening#25

Merged
AppsGanin merged 8 commits into
mainfrom
feat/ops-hardening
Jul 11, 2026
Merged

Feat/ops hardening#25
AppsGanin merged 8 commits into
mainfrom
feat/ops-hardening

Conversation

@AppsGanin

Copy link
Copy Markdown
Owner

No description provided.

- Track all user lifecycle events (create, rename, enable/disable, limit changes, plan changes, rotation, Telegram bind/unbind, deletion)
- Log payment events (created, paid, cancelled) with order details
- Record self-service actions and system events (expiry, quota exhaustion, device limit, auto-downgrade)
- Attribute every entry to an actor (admin, API key, Telegram bot, user, or system)
- Support per-user and global views with event-type and actor filters
- Retain history for 90 days with automatic purge; preserve records after user deletion
- Pass context through billing and bulk operations to carry actor identity
- Add `internal/actor` package for context-scoped action attribution
ActiveDeviceCounts filters connections by last_seen on the hot path (every
WorkingUsers read, driven by the access-log tap), but the table's only index was
the (user_id, ip) primary key — so the filter was a full scan over a table that
grows a row per source IP forever, with no retention sweep.

The index has to be covering and explicit. A bare index on last_seen is ignored:
SQLite prefers the PK autoindex to satisfy GROUP BY user_id without a sort and
scans everything anyway. Leading with last_seen and carrying user_id/ip makes the
range both searchable and covering, and INDEXED BY pins it — the planner never
runs ANALYZE, so it can't know the device window is only 120s of rows.

Also sweep connection rows past ConnectionRetentionDays (30) on the existing
retention timer, renamed from eventPurgeLoop since it now covers both tables.
…ness probe

The apiLimiter in front of /v1 caps request *rate* but never looks at whether a
request authenticated, so on its own it handed a source its full budget of key
guesses every minute, forever. Count failures per source IP instead: after 10
invalid keys the address is locked out for 15 minutes, and a valid key clears the
record immediately so a misconfigured integration recovers as soon as it's fixed.
A request with no credential at all is a bare probe, not a guess, and doesn't
spend the budget — otherwise an anonymous prober could lock out an address a real
integration shares with it.

GET /v1/healthz answers without a key so an uptime monitor doesn't have to carry a
credential that can delete users. It returns 503 when Xray is down: the panel may
be fine, but the node is carrying no VPN traffic, which is the thing worth being
paged about.

It lives under the API path rather than at the server root deliberately. A root
/healthz answering JSON would fingerprint the panel to any scanner and defeat the
decoy; the API segment is also stable across secret rotation, so the monitor URL
survives.
Scheduled backups only existed inside the Telegram service, so an operator who
never set up a bot had no automatic backups at all — even though internal/backup
could already produce them. A local scheduler now writes archives to
<dataDir>/backups on the same cron dialect and operator timezone, keeping the
newest N.

Two ways this could quietly destroy what it exists to protect, both now covered by
tests: the backups dir is excluded from the walk (otherwise every archive nests
the previous one and grows geometrically), and keep <= 0 means retain everything
rather than delete everything, so an unset config can't erase the lot.

The walk also skipped hand-rolled recovery copies by ".bak-" only, which let every
".bak.<date>" copy ride along — found on the live box, where seven full DB copies
were being carried inside each archive.

The cron is validated on save, not at fire time: the scheduler can only log an
unparseable expression and skip, which looks exactly like "nothing was due" — so a
typo would silently mean no backups until the day someone needed one.

The schedule picker is extracted to a shared CronPicker, so the Telegram and local
schedules can't drift apart in what they accept.
connguard.Ensure is best-effort and degrades to a no-op when nft is missing or the
panel isn't root — it only logs. So an operator who believes the box is protected
from connection floods could be running with no guard at all and never find out.
The health report now says so.

Both checks are live probes (nft list table, reading the sysctl) rather than a
remembered boot outcome: "we called Ensure" is not evidence the rules exist.
Missing rules while the guard is enabled is a warning; BBR is only informational,
since it's a throughput optimization and plenty of healthy kernels don't offer it.

connGuardWanted is recorded at boot so the report can tell "off on purpose"
(ROSPANEL_CONNLIMIT=off) from "on, but nftables refused it".
… vars

internal/datasec said secrets.key was excluded from backups; internal/backup says
it is included, and it is — the encrypted DB is useless without it, so a backup
that left it out would not restore on a fresh host. The datasec doc and the dead
KeyFileName helper were the ones lying, and an operator would have discovered that
at the worst possible moment. The consequence is now stated: a backup archive is
as sensitive as the key itself.

Also: document ROSPANEL_CONNLIMIT* in the README, add the undocumented
GET /v1/billing/providers to docs/api.md, note that RoutingOrder includes the opera
lane, and mark TLSMode as vestigial (always "acme"; don't branch new behaviour on
it) rather than ripping it out of the TLS paths for cosmetics.
staticcheck ST1022: the comment led with "UserEventRetention", dropping the Days
suffix, so it didn't match the identifier it documents.
@AppsGanin AppsGanin merged commit 8548ee2 into main Jul 11, 2026
4 checks passed
@AppsGanin AppsGanin deleted the feat/ops-hardening branch July 11, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant