Skip to content

Commit 8548ee2

Browse files
authored
Merge pull request #25 from AppsGanin/feat/ops-hardening
Feat/ops hardening
2 parents ea6bffb + 6b8c784 commit 8548ee2

62 files changed

Lines changed: 3267 additions & 360 deletions

Some content is hidden

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

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,24 @@
8585
это **один проход и одна перезагрузка** конфига Xray вместо операции на каждого
8686
- **Дашборд**: CPU / RAM / swap / диск / сеть / VPN-трафик в реальном времени, аптайм
8787

88+
#### 📋 Журнал действий (аудит)
89+
90+
- Пишется **всё, что происходило с пользователем**, и кто это сделал: создание,
91+
переименование, включение/отключение, смена лимитов и тарифа, сброс трафика,
92+
ротация ссылки, привязка/отвязка Telegram, удаление
93+
- **Оплаты**: заказ создан / оплачен / отменён — с номером заказа, тарифом, суммой и способом
94+
- **Самообслуживание**: саморегистрация в боте, отмена подписки, оплата со страницы подписки
95+
- **Системные события**: истёк срок, исчерпан трафик, превышен лимит устройств,
96+
автосброс квоты, перевод на бесплатный тариф после истечения
97+
- У каждой записи есть **автор**: админ (по логину), API-ключ (по названию),
98+
Telegram-бот (по @username), сам пользователь или система (крон, вебхук провайдера)
99+
- Смотреть можно **по одному пользователю** — кнопка «Журнал действий» в его карточке —
100+
или **сквозняком** на вкладке «Журнал», с фильтрами по типу события и по автору
101+
- Журнал **переживает удаление пользователя**: запись «удалён» и вся его история
102+
остаются (имя сохраняется в самой записи)
103+
- Групповые операции пишут запись **каждому** затронутому пользователю с пометкой «массово»
104+
- Хранение — **90 дней**, старые записи чистятся автоматически
105+
88106
#### 📲 Подписки
89107

90108
- `/<путь>/<токен>` — base64-список + страница с QR и deep-links
@@ -319,6 +337,7 @@ internal/
319337
model/ доменные типы (User, Settings, RoutingConfig, …)
320338
store/ SQLite + миграции
321339
auth/ argon2id, секреты/токены, сессии
340+
actor/ кто выполняет действие (едет в context) — для журнала аудита
322341
datasec/ шифрование секретов в БД (AES-GCM, ключ secrets.key)
323342
netguard/ SSRF-safe HTTP (валидация исходящих URL)
324343
tlsmgr/ tlsutil/ ACME (Let's Encrypt / ZeroSSL) + self-signed
@@ -386,6 +405,20 @@ go build -o rospanel ./cmd/rospanel
386405
`ROSPANEL_ADMIN_ADDR` (loopback-адрес панели, по умолчанию `127.0.0.1:8080`),
387406
`XRAY_BIN`, `ROSPANEL_HOST`, `ROSPANEL_ACME_EMAIL`.
388407

408+
Защита от флуда (нафтабл-лимиты на публичных TCP-портах, см. «Эксплуатация») тоже
409+
настраивается через окружение — пригодится, если за одним IP сидит целый офис или
410+
CGNAT-оператор и клиенты упираются в дефолты:
411+
412+
| Переменная | Что делает |
413+
| --- | --- |
414+
| `ROSPANEL_CONNLIMIT=off` | Полностью выключает лимиты (правила nftables сносятся) |
415+
| `ROSPANEL_CONNLIMIT_MAX` | Максимум одновременных TCP-соединений с одного IP |
416+
| `ROSPANEL_CONNLIMIT_RATE` | Максимум новых соединений с одного IP в секунду |
417+
418+
Текущее состояние защиты видно в **Дашборд → Управление → Диагностика**: если
419+
nftables не установлен или панель работает не от root, правила молча не применятся —
420+
диагностика об этом скажет.
421+
389422
PR и issue приветствуются. Коммиты — в стиле [Conventional Commits](https://www.conventionalcommits.org/):
390423
на их основе release-please собирает релиз и публикует бинарь + Docker-образ в GHCR.
391424

cmd/rospanel/service.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"github.com/AppsGanin/rospanel/internal/auth"
18+
"github.com/AppsGanin/rospanel/internal/autobackup"
1819
"github.com/AppsGanin/rospanel/internal/backup"
1920
"github.com/AppsGanin/rospanel/internal/connguard"
2021
"github.com/AppsGanin/rospanel/internal/core"
@@ -115,7 +116,8 @@ func runServer(dataDir string) {
115116
// DoS the per-user quota/device model never sees, since it happens before auth).
116117
// Tunable / disable-able at runtime via ROSPANEL_CONNLIMIT* (no redeploy needed
117118
// if a busy CGNAT egress trips the defaults).
118-
if strings.EqualFold(env("ROSPANEL_CONNLIMIT", "on"), "off") {
119+
connGuardWanted := !strings.EqualFold(env("ROSPANEL_CONNLIMIT", "on"), "off")
120+
if !connGuardWanted {
119121
log.Printf("connguard: disabled via ROSPANEL_CONNLIMIT=off")
120122
_ = connguard.Ensure(nil, connguard.DefaultLimits()) // tear down any stale table
121123
} else {
@@ -154,6 +156,8 @@ func runServer(dataDir string) {
154156
filepath.Join(dataDir, "opera"))
155157
sup.SetOnAccess(mgr.RecordAccess) // track online status + connection IPs
156158
mgr.StartSysstat(dataDir) // host metrics for the dashboard
159+
// The health report needs to tell "off on purpose" from "on, but nft refused it".
160+
mgr.SetConnGuardWanted(connGuardWanted)
157161

158162
// Load the proxy pool synchronously before the first reconcile so Xray starts
159163
// once with the proxies already in place — instead of starting empty and being
@@ -172,6 +176,11 @@ func runServer(dataDir string) {
172176
// Payment polling fallback: reconciles pending provider orders in case a webhook
173177
// was missed. Idles cheaply when there are no pending orders.
174178
go paymentPollLoop(mgr)
179+
// Audit-log + connection-row retention: drops rows past their windows.
180+
go retentionLoop(mgr)
181+
// Scheduled local backups. Independent of Telegram, so an operator with no bot
182+
// still gets automatic backups; idles until a cron is set in Settings.
183+
go autobackup.New(mgr, st, dataDir).Run(context.Background())
175184
// Telegram admin bot: view/add/remove users + scheduled backups. It idles until
176185
// enabled with a token in Settings → Telegram, re-reading config each cycle.
177186
go telegram.New(mgr, st, dataDir).Run(context.Background())
@@ -314,6 +323,22 @@ func paymentPollLoop(mgr *core.Manager) {
314323
}
315324
}
316325

326+
// retentionLoop drops audit rows and stale connection rows past their retention
327+
// windows. Both cutoffs move by the day, so a slow cadence is plenty — this only
328+
// keeps the tables from growing forever.
329+
func retentionLoop(mgr *core.Manager) {
330+
sweep := func() {
331+
mgr.PurgeOldEvents()
332+
mgr.PurgeOldConnections()
333+
}
334+
sweep() // sweep once at boot, then on the timer
335+
t := time.NewTicker(6 * time.Hour)
336+
defer t.Stop()
337+
for range t.C {
338+
safeTick("retention sweep", sweep)
339+
}
340+
}
341+
317342
// tlsLoop obtains the cert if missing and renews it before expiry, reloading
318343
// Xray whenever the cert changes. It retries quickly while there's no usable
319344
// cert (e.g. ACME wasn't reachable at boot) and settles into a slow renew

docs/api.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,24 @@ Base URL below is written as `$BASE` (e.g. `https://vpn.example.com/ab12cd34/v1`
7575
GET $BASE/health → { "data": { "status": "ok" } }
7676
```
7777

78+
#### Liveness probe (no API key)
79+
80+
`GET $BASE/healthz` is the one endpoint that needs no key — point an uptime monitor
81+
or a load balancer at it. It answers **503** (not 200) when Xray isn't running: the
82+
panel may be fine, but the node is carrying no VPN traffic, which is what you want
83+
to be paged about.
84+
85+
```
86+
GET $BASE/healthz
87+
200 → { "data": { "status": "ok", "xray": "running", "xray_started_at": 1752230400 } }
88+
503 → { "data": { "status": "degraded", "xray": "down", "xray_started_at": 0 } }
89+
```
90+
91+
It lives under the API path rather than at the server root on purpose: an
92+
unauthenticated `/healthz` on the root would answer JSON to any scanner and give the
93+
panel away, defeating the decoy. The API path is stable across secret rotation, so a
94+
monitor pointed here keeps working.
95+
7896
### Users
7997

8098
| Method | Path | Description |
@@ -137,6 +155,7 @@ required only for `extend`). Response: `{ "data": { "affected": 3 } }`.
137155

138156
| Method | Path | Description |
139157
| --- | --- | --- |
158+
| `GET` | `/v1/billing/providers` | List the enabled payment methods (what a client can pay with). |
140159
| `GET` | `/v1/billing/plans?include_disabled=true` | List tariff plans. |
141160
| `POST` | `/v1/billing/plans` | Create (no `id`) or update (`id` set) a plan. |
142161
| `DELETE` | `/v1/billing/plans/{id}` | Delete a plan. |

internal/actor/actor.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Package actor carries WHO is performing an action across layer boundaries.
2+
//
3+
// The audit log needs to attribute every mutation, but core.Manager sits below the
4+
// HTTP and bot layers and can't reach a session cookie or a Telegram update. So the
5+
// actor rides the request context: each entry point (panel session, API key,
6+
// Telegram bot, subscription page) stamps it once, and the Manager reads it back
7+
// when it writes its audit row.
8+
//
9+
// This lives in its own leaf package rather than in core so the bots can stamp an
10+
// actor without importing core — the telegram package deliberately depends on core
11+
// only through its own narrow Panel interface.
12+
package actor
13+
14+
import (
15+
"context"
16+
17+
"github.com/AppsGanin/rospanel/internal/model"
18+
)
19+
20+
// Actor is who is performing an action: a kind (model.Actor*) and a display name.
21+
type Actor struct {
22+
Kind string
23+
Name string
24+
}
25+
26+
type ctxKey struct{}
27+
28+
// System is the fallback for anything the panel does on its own initiative — the
29+
// background poller, a provider webhook. A context with no actor means exactly this.
30+
var System = Actor{Kind: model.ActorSystem}
31+
32+
// Admin / APIKey / Telegram / UserSelf name the four external entry points, so
33+
// callers don't hand-roll the kind strings.
34+
func Admin(username string) Actor { return Actor{Kind: model.ActorAdmin, Name: username} }
35+
func APIKey(name string) Actor { return Actor{Kind: model.ActorAPIKey, Name: name} }
36+
func Telegram(name string) Actor { return Actor{Kind: model.ActorTelegram, Name: name} }
37+
func UserSelf(name string) Actor { return Actor{Kind: model.ActorUser, Name: name} }
38+
39+
// With stamps the actor onto ctx for the mutating calls made under it.
40+
func With(ctx context.Context, a Actor) context.Context {
41+
return context.WithValue(ctx, ctxKey{}, a)
42+
}
43+
44+
// From returns the actor stamped on ctx, or System when none is.
45+
func From(ctx context.Context) Actor {
46+
if a, ok := ctx.Value(ctxKey{}).(Actor); ok && a.Kind != "" {
47+
return a
48+
}
49+
return System
50+
}

internal/autobackup/autobackup.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Package autobackup runs scheduled local backups of the data directory.
2+
//
3+
// Scheduled backups previously existed only inside the Telegram service, so an
4+
// operator who never configured a bot had no automatic backups at all. This runs the
5+
// same schedule against local disk instead, independent of Telegram (both can be on
6+
// — they're separate schedules and neither knows about the other).
7+
package autobackup
8+
9+
import (
10+
"context"
11+
"log/slog"
12+
"strings"
13+
"time"
14+
15+
"github.com/AppsGanin/rospanel/internal/backup"
16+
"github.com/AppsGanin/rospanel/internal/cron"
17+
"github.com/AppsGanin/rospanel/internal/store"
18+
)
19+
20+
// Panel is the slice of core.Manager this needs.
21+
type Panel interface {
22+
BackupManifest() backup.Manifest
23+
Location() *time.Location // operator timezone; the cron is evaluated in it
24+
}
25+
26+
type Service struct {
27+
panel Panel
28+
store *store.Store
29+
dataDir string
30+
31+
// lastFired is the minute a backup last ran, seeded to the startup minute so a
32+
// restart can't re-fire a schedule that already matched the minute we came up.
33+
// Only touched from the single loop goroutine.
34+
lastFired time.Time
35+
}
36+
37+
func New(panel Panel, st *store.Store, dataDir string) *Service {
38+
return &Service{
39+
panel: panel,
40+
store: st,
41+
dataDir: dataDir,
42+
lastFired: time.Now().In(panel.Location()).Truncate(time.Minute),
43+
}
44+
}
45+
46+
// Run wakes every minute and lets maybeBackup decide whether the schedule is due.
47+
// A minute tick is the finest granularity a 5-field cron can express.
48+
func (s *Service) Run(ctx context.Context) {
49+
t := time.NewTicker(time.Minute)
50+
defer t.Stop()
51+
for {
52+
select {
53+
case <-ctx.Done():
54+
return
55+
case <-t.C:
56+
s.maybeBackup()
57+
}
58+
}
59+
}
60+
61+
// maybeBackup writes a backup when the operator's cron matches the current minute
62+
// (in the operator timezone), then prunes old archives.
63+
func (s *Service) maybeBackup() {
64+
set, err := s.store.GetSettings()
65+
if err != nil {
66+
return
67+
}
68+
expr := strings.TrimSpace(set.LocalBackupCron)
69+
if expr == "" {
70+
return
71+
}
72+
sched, err := cron.Parse(expr)
73+
if err != nil {
74+
slog.Warn("autobackup: bad cron expression", "cron", expr, "err", err)
75+
return
76+
}
77+
now := time.Now().In(s.panel.Location())
78+
if !sched.Match(now) {
79+
return
80+
}
81+
minute := now.Truncate(time.Minute)
82+
if minute.Equal(s.lastFired) {
83+
return
84+
}
85+
s.lastFired = minute
86+
87+
if _, err := s.RunOnce(now, set.LocalBackupKeep); err != nil {
88+
slog.Error("autobackup: scheduled backup failed", "err", err)
89+
}
90+
}
91+
92+
// RunOnce writes one archive and rotates the directory down to keep. Exported so the
93+
// panel can offer a "back up now" action against the same code path the timer uses.
94+
func (s *Service) RunOnce(now time.Time, keep int) (string, error) {
95+
path, err := backup.WriteLocal(s.dataDir, s.panel.BackupManifest(), s.store.Checkpoint, now)
96+
if err != nil {
97+
return "", err
98+
}
99+
slog.Info("autobackup: backup written", "path", path)
100+
101+
// A rotation failure doesn't invalidate the backup we just took, so it's logged
102+
// rather than returned — the archive on disk is the thing that matters.
103+
if removed, rerr := backup.Rotate(s.dataDir, keep); rerr != nil {
104+
slog.Warn("autobackup: rotation failed", "err", rerr)
105+
} else if removed > 0 {
106+
slog.Info("autobackup: old archives removed", "count", removed, "keep", keep)
107+
}
108+
return path, nil
109+
}

internal/backup/backup.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,15 @@ func WriteWithManifest(dataDir string, m Manifest, w io.Writer) error {
6666
// the opera-proxy helper in opera/ — all re-fetched on demand), transient
6767
// restore staging, and logs/ (operational + actively appended, which would
6868
// otherwise grow mid-archive and trip "write too long").
69+
//
70+
// LocalBackupDir is skipped for a sharper reason: it holds previous archives,
71+
// so including it would nest every backup inside the next one and blow the
72+
// size up geometrically.
6973
if info.IsDir() && (path == filepath.Join(dataDir, "bin") ||
7074
path == filepath.Join(dataDir, "geo") ||
7175
path == filepath.Join(dataDir, "opera") ||
7276
path == filepath.Join(dataDir, "logs") ||
77+
path == filepath.Join(dataDir, LocalBackupDir) ||
7378
path == filepath.Join(dataDir, stagingDir)) {
7479
return filepath.SkipDir
7580
}
@@ -80,8 +85,13 @@ func WriteWithManifest(dataDir string, m Manifest, w io.Writer) error {
8085
if strings.HasSuffix(path, ".db-wal") || strings.HasSuffix(path, ".db-shm") {
8186
return nil
8287
}
83-
// Skip recovery artifacts (.bak, .bak-20060102-150405, .new).
84-
if strings.HasSuffix(base, ".bak") || strings.Contains(base, ".bak-") || strings.HasSuffix(base, ".new") {
88+
// Skip recovery artifacts: rospanel.db.bak, .bak-20060102-150405, .bak.20060102,
89+
// config.json.new. These are copies of files already in the archive, so carrying
90+
// them just bloats it — and a full DB copy is not small. Both separators matter:
91+
// operators hand-roll these names, and a rule that only knew ".bak-" quietly let
92+
// every ".bak.<date>" copy ride along in every backup.
93+
if strings.HasSuffix(base, ".bak") || strings.Contains(base, ".bak-") ||
94+
strings.Contains(base, ".bak.") || strings.HasSuffix(base, ".new") {
8595
return nil
8696
}
8797
rel, err := filepath.Rel(dataDir, path)

0 commit comments

Comments
 (0)