Skip to content

Commit 346f174

Browse files
committed
feat: subscription announce, expired-user auto-delete, DB integrity recovery
Three operational features plus the HTTP endpoint + UI for the self-test. - Announce: a short in-client message (Happ, v2RayTun) delivered via the subscription Announce header (base64, 200-char cap) — a channel to users who aren't in Telegram. Verified live: base64 Cyrillic round-trips, header absent when empty. - Auto-delete expired users after a configurable grace period (default: never). Keys off the expiry date, never touches users with no expiry or renewed ones, and records each deletion in the journal. - DB integrity check at boot (PRAGMA quick_check): a corrupt database (hard reboot, full disk) recovers from the newest local backup instead of crash-looping; the damaged file is quarantined, not deleted. - Wire up POST /api/health/selftest (operator role) and the Диагностика UI.
1 parent 8fd1a4e commit 346f174

19 files changed

Lines changed: 652 additions & 41 deletions

cmd/rospanel/dbrecover.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"log"
7+
"os"
8+
"path/filepath"
9+
"time"
10+
11+
"github.com/AppsGanin/rospanel/internal/backup"
12+
"github.com/AppsGanin/rospanel/internal/store"
13+
)
14+
15+
// ensureHealthyDB gates the boot on a readable database, and recovers from the one
16+
// failure that otherwise ends the install: SQLite reporting the file as corrupt.
17+
//
18+
// A hard reboot or a full disk can tear a page and leave "file is not a database"
19+
// behind. Without this the panel crash-loops forever on a file it will never be
20+
// able to read, and the operator's only clue is a stack trace. So: quarantine the
21+
// damaged file (never delete it — it's the only forensic copy, and it may still be
22+
// partially recoverable by hand) and extract the newest local backup in its place.
23+
//
24+
// Recovery is deliberately restricted to store.ErrCorrupt. A locked file, bad
25+
// permissions or a full disk are all transient or operator-fixable, and restoring
26+
// over them would destroy good data to "fix" a problem that isn't there.
27+
//
28+
// Runs before datasec.Init, because a backup carries its own secrets.key: pulling
29+
// the archive's DB and key in as a pair keeps encrypted columns decryptable, while
30+
// restoring only the DB after the key was already loaded would not.
31+
func ensureHealthyDB(dbPath, dataDir string) error {
32+
err := store.Check(dbPath)
33+
if err == nil {
34+
return nil
35+
}
36+
if !errors.Is(err, store.ErrCorrupt) {
37+
return err
38+
}
39+
40+
log.Printf("[ALERT] database: %v", err)
41+
log.Printf("[ALERT] database: the file is damaged — attempting recovery from the newest local backup")
42+
43+
archives, lerr := backup.ListLocal(dataDir) // newest first
44+
if lerr != nil {
45+
return fmt.Errorf("database is corrupt and the backup directory is unreadable (%v) — "+
46+
"restore a backup by hand: rospanel restore <file>", lerr)
47+
}
48+
if len(archives) == 0 {
49+
return fmt.Errorf("database is corrupt and there is no local backup to restore from. "+
50+
"The damaged file is left at %s. Restore an off-box backup with `rospanel restore <file>`, "+
51+
"or wipe and start fresh with `rospanel reset`. "+
52+
"Turn on scheduled local backups (Настройки → Бэкапы) so this is recoverable next time", dbPath)
53+
}
54+
55+
quarantine, qerr := quarantineDB(dbPath)
56+
if qerr != nil {
57+
return fmt.Errorf("database is corrupt and could not be set aside for recovery: %w", qerr)
58+
}
59+
60+
newest := filepath.Join(dataDir, backup.LocalBackupDir, archives[0])
61+
if rerr := backup.Restore(newest, dataDir); rerr != nil {
62+
return fmt.Errorf("database is corrupt and restoring %s failed: %w "+
63+
"(the damaged database is preserved at %s)", archives[0], rerr, quarantine)
64+
}
65+
66+
// The archive could itself be damaged or truncated. If what we just restored is
67+
// also unreadable, stop: a boot loop that keeps unpacking a broken archive over
68+
// the data dir is worse than a clean failure.
69+
if cerr := store.Check(dbPath); cerr != nil {
70+
return fmt.Errorf("restored %s but the database is still unusable: %w "+
71+
"(the original damaged database is preserved at %s)", archives[0], cerr, quarantine)
72+
}
73+
74+
log.Printf("[ALERT] database: recovered from backup %s — changes made after that backup are LOST", archives[0])
75+
log.Printf("[ALERT] database: the damaged file is preserved at %s", quarantine)
76+
return nil
77+
}
78+
79+
// quarantineDB moves the damaged database aside (with its WAL and shared-memory
80+
// sidecars, which belong to it and would otherwise be replayed onto the restored
81+
// file) and returns the path it was moved to.
82+
func quarantineDB(dbPath string) (string, error) {
83+
dst := fmt.Sprintf("%s.corrupt-%s", dbPath, time.Now().Format("20060102-150405"))
84+
if err := os.Rename(dbPath, dst); err != nil {
85+
return "", err
86+
}
87+
for _, suffix := range []string{"-wal", "-shm"} {
88+
if err := os.Rename(dbPath+suffix, dst+suffix); err != nil && !os.IsNotExist(err) {
89+
return "", err
90+
}
91+
}
92+
return dst, nil
93+
}

cmd/rospanel/service.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ func runServer(dataDir string) {
7070
log.Print("restore: staged backup applied")
7171
}
7272

73+
// Gate the boot on a readable database, recovering from a corrupt one before any
74+
// secret or cert is loaded — a recovery swaps in the backup's secrets.key too, so
75+
// it has to happen before datasec.Init pins a key in memory.
76+
startupStage("checking database integrity")
77+
if err := ensureHealthyDB(dbPath, dataDir); err != nil {
78+
log.Fatalf("database: %v", err)
79+
}
80+
7381
// After any staged restore has put the real secrets.key in place, load it —
7482
// doing this before ApplyPending would pin a freshly-generated key in memory
7583
// that the restore then overwrites on disk, breaking decryption.
@@ -331,6 +339,7 @@ func retentionLoop(mgr *core.Manager) {
331339
mgr.PurgeOldEvents()
332340
mgr.PurgeOldAdminAudit()
333341
mgr.PurgeOldConnections()
342+
mgr.PurgeExpiredUsers() // no-op unless the operator set a grace period
334343
}
335344
sweep() // sweep once at boot, then on the timer
336345
t := time.NewTicker(6 * time.Hour)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/AppsGanin/rospanel/internal/actor"
8+
"github.com/AppsGanin/rospanel/internal/model"
9+
)
10+
11+
// autoDeleteMaxDays bounds the grace period an operator can set. A year is already
12+
// far past any plausible "keep them around in case they come back", and the cap
13+
// keeps a typo (3650) from being read as "effectively never" — that's what 0 is for.
14+
const autoDeleteMaxDays = 365
15+
16+
// SetUserAutoDelete configures how many days an expired user is kept before being
17+
// deleted. 0 disables deletion entirely. The admin-audit row for the change is
18+
// written by the HTTP layer (see server/audit.go), like every other setting.
19+
func (m *Manager) SetUserAutoDelete(days int) error {
20+
if days < 0 || days > autoDeleteMaxDays {
21+
return invalid("срок хранения истёкших: от 0 (не удалять) до %d дней", autoDeleteMaxDays)
22+
}
23+
return m.store.SetUserAutoDeleteDays(days)
24+
}
25+
26+
// PurgeExpiredUsers deletes users whose expiry date is further in the past than the
27+
// configured grace period. Called from the retention sweep; a no-op when the setting
28+
// is 0 (the default), so an operator who never opts in never loses a user.
29+
//
30+
// Deletion is irreversible, so it is deliberately conservative:
31+
// - it keys off expire_at, not the derived `status` (see store.ExpiredUsersBefore) —
32+
// a user whose plan was renewed has a future expiry and is never a candidate;
33+
// - users with no expiry date at all are never touched;
34+
// - every deletion is written to the user journal (which outlives the user) and
35+
// pushed as a webhook, so «где мой пользователь?» always has an answer.
36+
func (m *Manager) PurgeExpiredUsers() {
37+
set, err := m.store.GetSettings()
38+
if err != nil || set == nil || set.UserAutoDeleteDays <= 0 {
39+
return
40+
}
41+
cutoff := time.Now().AddDate(0, 0, -set.UserAutoDeleteDays).Unix()
42+
doomed, err := m.store.ExpiredUsersBefore(cutoff)
43+
if err != nil {
44+
logErr("autodelete: listing expired users failed", "err", err)
45+
return
46+
}
47+
if len(doomed) == 0 {
48+
return
49+
}
50+
51+
ids := make([]int64, 0, len(doomed))
52+
for _, u := range doomed {
53+
ids = append(ids, u.ID)
54+
}
55+
n, err := m.store.DeleteUsers(ids)
56+
if err != nil {
57+
logErr("autodelete: deleting expired users failed", "count", len(ids), "err", err)
58+
return
59+
}
60+
61+
// One sync for the whole batch, not one per user: the deleted users have to leave
62+
// the running Xray config, but bouncing it N times to do so would be gratuitous.
63+
m.TriggerUserSync()
64+
65+
ctx := actor.With(context.Background(), actor.System)
66+
for _, u := range doomed {
67+
m.auditNamed(ctx, u.ID, u.Name, model.EventUserDeleted, map[string]any{
68+
"reason": "autodelete",
69+
"expire_at": u.ExpireAt,
70+
"after_days": set.UserAutoDeleteDays,
71+
})
72+
m.EmitWebhook(model.WebhookUserDeleted, userEventData(u))
73+
}
74+
logInfo("autodelete: expired users removed", "count", n, "after_days", set.UserAutoDeleteDays)
75+
}

internal/core/manager_settings.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,12 @@ func joinInts(xs []int) string {
264264
// subPathRe validates the public subscription path prefix: URL-path-safe, 1–32 chars.
265265
var subPathRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`)
266266

267+
// announceMaxRunes is the cap VPN clients themselves impose on the announcement
268+
// they display (Happ documents 200; Remnawave validates the same number). Anything
269+
// past it is cut off client-side, so the panel refuses it rather than let an
270+
// operator send half a sentence.
271+
const announceMaxRunes = 200
272+
267273
// reservedSubPaths are first-segment names the subscription prefix must not use:
268274
// they belong to the panel/system surface (the panel mux serves these under the
269275
// secret, and "well-known" is conventionally reserved for ACME), so allowing a
@@ -287,6 +293,14 @@ func (m *Manager) SaveSubSettings(st *model.Settings) error {
287293
if !subPathRe.MatchString(st.SubPath) {
288294
return invalid("путь подписки: латиница, цифры, «-» и «_», 1–32 символа")
289295
}
296+
st.SubAnnounce = strings.TrimSpace(st.SubAnnounce)
297+
// Clients render at most 200 characters of the announcement and silently cut the
298+
// rest, so a longer text is a message the operator thinks they sent and nobody
299+
// ever read. Reject it here instead. Runes, not bytes: the text is Cyrillic.
300+
if n := utf8.RuneCountInString(st.SubAnnounce); n > announceMaxRunes {
301+
return invalid("объявление: не длиннее %d символов (сейчас %d) — клиенты обрежут остальное",
302+
announceMaxRunes, n)
303+
}
290304
if reservedSubPaths[strings.ToLower(st.SubPath)] {
291305
return invalid("путь подписки «%s» зарезервирован панелью — выберите другой", st.SubPath)
292306
}

internal/model/model.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,15 @@ type Settings struct {
359359
SubRoutingIncy string `json:"-"` // INCY routing config URL
360360
SubRoutingMihomo string `json:"-"` // Mihomo (Clash Meta) routing config URL
361361
SubUpdateInterval int `json:"-"` // subscription auto-update interval (hours)
362+
// SubAnnounce is a short broadcast shown inside the VPN client itself (Happ,
363+
// v2RayTun) via the subscription's Announce header. Empty ⇒ no announcement.
364+
// Clients only render the first 200 characters; the panel enforces that limit.
365+
SubAnnounce string `json:"-"`
366+
367+
// UserAutoDeleteDays deletes an expired user this many days after their expiry
368+
// date. 0 ⇒ never (default): expired users pile up but nothing is ever destroyed
369+
// behind the operator's back.
370+
UserAutoDeleteDays int `json:"-"`
362371

363372
XrayDNS string `json:"-"` // upstream DNS servers for Xray (newline/comma separated)
364373

internal/server/audit.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ var auditActions = map[string]auditRoute{
6565
"POST /api/settings/dns": set("DNS"),
6666
"POST /api/settings/proxy-mode": set("Режим прокси"),
6767
"POST /api/settings/local-backup": set("Локальные бэкапы"),
68+
"POST /api/settings/autodelete": set("Автоудаление истёкших"),
6869
"POST /api/settings/api-path": set("Адрес API"),
6970
"POST /api/setup/timezone": set("Часовой пояс"),
7071
"POST /api/setup/finish": set("Первичная настройка"),
@@ -100,13 +101,14 @@ var auditActions = map[string]auditRoute{
100101

101102
// The panel itself. The backup download is a GET, but it hands over a file
102103
// containing every secret the panel holds — that is worth a row.
103-
"GET /api/backup": act(model.AuditBackupTaken),
104-
"POST /api/backup/inspect": skip, // read-only: inspects an uploaded file, changes nothing
105-
"POST /api/restore": act(model.AuditRestored),
106-
"POST /api/reset": act(model.AuditFactoryReset),
107-
"POST /api/update": act(model.AuditUpdated),
108-
"POST /api/xray/restart": act(model.AuditXrayRestarted),
109-
"POST /api/stats/reset": act(model.AuditStatsReset),
104+
"GET /api/backup": act(model.AuditBackupTaken),
105+
"POST /api/backup/inspect": skip, // read-only: inspects an uploaded file, changes nothing
106+
"POST /api/restore": act(model.AuditRestored),
107+
"POST /api/reset": act(model.AuditFactoryReset),
108+
"POST /api/update": act(model.AuditUpdated),
109+
"POST /api/xray/restart": act(model.AuditXrayRestarted),
110+
"POST /api/stats/reset": act(model.AuditStatsReset),
111+
"POST /api/health/selftest": skip, // a read-only probe: spawns a throwaway client, changes nothing
110112

111113
// End users: audited in the user journal instead, per user, with details this
112114
// trail could not carry. Listed explicitly so the exhaustiveness test sees a

internal/server/panel.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,15 @@ func (rt *Router) panelMux() http.Handler {
186186
authed("POST /api/settings/dns", rt.setXrayDNS)
187187
authed("POST /api/settings/proxy-mode", rt.setProxyMode)
188188
authed("POST /api/settings/local-backup", rt.setLocalBackup)
189+
authed("POST /api/settings/autodelete", rt.setUserAutoDelete)
189190
authed("GET /api/geo/categories", rt.geoCategories)
190191
authed("GET /api/geo", rt.geoStatus)
191192
authed("POST /api/geo/update", rt.updateGeo)
192193
authed("GET /api/routing", rt.getRouting)
193194
authed("POST /api/routing", rt.saveRouting)
194195
authedOp("GET /api/system/stream", rt.systemStream)
195196
authedOp("GET /api/health", rt.health)
197+
authedOp("POST /api/health/selftest", rt.selfTest)
196198
authed("GET /api/xray/config", rt.xrayConfig)
197199
authed("GET /api/xray/status", rt.xrayStatus)
198200
authed("POST /api/xray/restart", rt.xrayRestart)

internal/server/panel_health.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,33 @@
11
package server
22

3-
import "net/http"
3+
import (
4+
"context"
5+
"net/http"
6+
"time"
7+
)
48

59
// health returns the panel self-diagnostics for the Health page (Xray, config,
610
// TLS, disk/RAM, geo databases, egress lanes).
711
func (rt *Router) health(w http.ResponseWriter, _ *http.Request) {
812
writeJSON(w, http.StatusOK, rt.mgr.Health())
913
}
14+
15+
// selfTestBudget caps the whole run: it spawns a throwaway Xray per protocol and
16+
// dials the internet through each. Comfortably covers four cold handshakes while
17+
// still bounding a stuck request.
18+
const selfTestBudget = 90 * time.Second
19+
20+
// selfTest connects to each enabled protocol as a real client and reports whether
21+
// traffic flows end-to-end. It's a POST because it does real work (spawns a client,
22+
// sends traffic), not because it changes state — it changes nothing.
23+
func (rt *Router) selfTest(w http.ResponseWriter, r *http.Request) {
24+
ctx, cancel := context.WithTimeout(r.Context(), selfTestBudget)
25+
defer cancel()
26+
27+
results, err := rt.mgr.SelfTest(ctx)
28+
if err != nil {
29+
writeManagerErr(w, err)
30+
return
31+
}
32+
writeJSON(w, http.StatusOK, map[string]any{"results": results})
33+
}

0 commit comments

Comments
 (0)