diff --git a/docs/configuration.md b/docs/configuration.md index ae6561b..3496235 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,7 +35,7 @@ same table; `vps config list` shows the live values with this annotation. | `panel.url_path` | **fixed at install** | — | secret prefix of the user panel; settable only while empty (re-enable) | | `panel.admin_url_path` | operator | restart panel | secret prefix of the admin panel; an **empty value disables the admin panel** (shown as `disabled`) | | `panel.show_footer` | operator | restart panel | user panel footer with project link and installed/build version; `false` hides the entire footer | -| `panel.bandwidth_reset_day` | operator | restart panel | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1` | +| `panel.bandwidth_reset_day` | operator | restart panel — **discards stored state** | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1`. **Changing it wipes every user's accumulated traffic totals** — see below | | `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI | | `net.subnet` | **fixed at install** | — | container subnet `10..0.0/24`; changing breaks existing containers | | `net.gateway` | **fixed at install** | — | bridge gateway (derived from subnet) | @@ -100,7 +100,9 @@ panel: public_ip: AUTO # NIC IPv4 used by the firewall / routing; on NAT-ing clouds (AWS/Alibaba) this is a private address display_ip: AUTO # address shown to users (panel URL, SSH hints); any string without spaces (IP or domain); auto-fetched from ipv4.ip.sb when public_ip is private; empty = fall back to public_ip session_days: 3 # login session lifetime (days) - bandwidth_reset_day: 1 # day of month the monthly bandwidth quota resets (1-28) + bandwidth_reset_day: 1 # day of month the monthly bandwidth quota resets (1-28). + # CHANGING IT WIPES every user's accumulated traffic totals. + # `vps config set` asks for confirmation. url_path: AUTO # random secret path, the only panel entrance; do not change after first install admin_url_path: AUTO # random secret path of the admin panel; do not change after first install admin_pass_hash: AUTO # bcrypt hash of the admin password — stored in the DB, not in this file @@ -265,6 +267,28 @@ is a deliberate migration — update `HAPROXY_BRANCH` / `HAPROXY_REPO_SLUG` / | `/etc/haproxy/generation` | panel — counter of the last published generation | | `/etc/systemd/system/haproxy.service` | install (overrides the distribution's unit) | +## Monthly bandwidth reset day (`panel.bandwidth_reset_day`) + +The quota period runs from the configured day of one month to that day of the +next (default `1` = calendar months). The user panel shows which reset applies +to the period in force, including which month it falls in — with the reset day +set to the 22nd and today the 16th, the period ends on the **22nd of this +month**, because it started on the 22nd of the previous one. + +> **Changing this setting discards every user's accumulated bandwidth totals.** +> The accumulated counters are keyed by an accounting period derived from this +> day, so moving the day moves the period key, and the next sampler pass sees +> "a new period" and starts from zero. Any over-quota throttle is lifted as a +> side effect, and past totals are not recoverable — setting the day back does +> not bring them back. +> +> This is why the key is classified as destructive: `vps config set +> panel.bandwidth_reset_day ` asks for an explicit confirmation describing +> the loss (and repeats it when the change is saved with `--no-apply`, since +> then it lands at the next panel restart instead). To reset ONE user's traffic +> on purpose, use the admin panel's "重置流量统计" button instead — that is +> the supported, per-user operation. + ## Port 25 (SMTP) is always blocked The host nftables ruleset drops **port 25 for all forwarded traffic, both diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index 20cb5af..075fbcc 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -53,11 +53,12 @@ func (k FieldKind) String() string { type Apply int const ( - ApplyRestart Apply = iota // panel is restarted automatically by `vps config set` - ApplyInstall // re-run `vps install` (or `vps config set --apply`) - ApplyImmediate // applied by vps config set right now - ApplyNextAdd // used on the next vps add / reinstall - ApplyNone // not settable + ApplyRestart Apply = iota // panel is restarted automatically by `vps config set` + ApplyInstall // re-run `vps install` (or `vps config set --apply`) + ApplyImmediate // applied by vps config set right now + ApplyNextAdd // used on the next vps add / reinstall + ApplyDestructive // applied by a panel restart, and applying it DISCARDS stored state + ApplyNone // not settable ) func (a Apply) String() string { @@ -70,6 +71,8 @@ func (a Apply) String() string { return "applied immediately" case ApplyNextAdd: return "next add / reinstall" + case ApplyDestructive: + return "restart panel (auto), discards stored state" case ApplyNone: return "-" } @@ -227,8 +230,8 @@ var Fields = []Field{ c.Panel.ShowFooter = b return nil }}, - {"panel.bandwidth_reset_day", KindOperator, ApplyRestart, - "day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe)", + {"panel.bandwidth_reset_day", KindOperator, ApplyDestructive, + "day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe). WARNING: changing it discards EVERY user's accumulated bandwidth totals — the accounting period key moves, so the next sampler pass starts a fresh period", "1", getStr(func(c *Config) string { return strconv.Itoa(c.Panel.BandwidthResetDay) }), func(c *Config, v string) error { diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go index 334958f..51f9d2d 100644 --- a/src/internal/cfg/registry_test.go +++ b/src/internal/cfg/registry_test.go @@ -370,3 +370,38 @@ func TestSwapRatioValidator(t *testing.T) { } } } + +// TestBandwidthResetDayIsDestructive pins the classification that makes +// `vps config set panel.bandwidth_reset_day` ask for confirmation: changing +// the day moves the accounting period key, which discards every user's +// accumulated bandwidth totals (see TestApplyBandwidthPeriodChangeDiscardsTotals +// in internal/db). If this ever drops back to a plain ApplyRestart the change +// would happen silently again. +func TestBandwidthResetDayIsDestructive(t *testing.T) { + f := FieldFor("panel.bandwidth_reset_day") + if f == nil { + t.Fatal("panel.bandwidth_reset_day is not registered") + } + if f.Apply != ApplyDestructive { + t.Errorf("Apply = %v, want ApplyDestructive", f.Apply) + } + // The warning has to name the consequence, since that is all the operator + // sees in `vps config help`. + for _, want := range []string{"discards", "bandwidth"} { + if !strings.Contains(f.Desc, want) { + t.Errorf("description does not mention %q: %s", want, f.Desc) + } + } + // And the value range is unchanged. + c := Default() + for _, bad := range []string{"0", "29", "abc"} { + if err := f.Assign(c, bad); err == nil { + t.Errorf("invalid %q accepted", bad) + } + } + for _, ok := range []string{"1", "22", "28"} { + if err := f.Assign(c, ok); err != nil { + t.Errorf("valid %q rejected: %v", ok, err) + } + } +} diff --git a/src/internal/db/bandwidth_test.go b/src/internal/db/bandwidth_test.go index 0ae6d3d..c5f853f 100644 --- a/src/internal/db/bandwidth_test.go +++ b/src/internal/db/bandwidth_test.go @@ -177,6 +177,54 @@ func TestApplyBandwidthResetNewPid(t *testing.T) { wantBandwidth(t, tr, "2026-08", 100, 300, 300, 100, 555) } +// TestApplyBandwidthPeriodChangeDiscardsTotals pins the behaviour that +// `panel.bandwidth_reset_day` has to warn about: the monthly totals are reset +// whenever the PERIOD KEY changes, and moving the configured reset day moves +// the key. Changing it from 1 to 22 on the 16th therefore looks like a +// rollover to the sampler, which zeroes every user's accumulated traffic on +// its next pass — not because anything resets it explicitly, but because the +// period the totals belonged to no longer exists. +// +// Fixing the label (mgr.BandwidthNextReset) does NOT change this: it is why +// the config key is marked destructive and confirmed at `vps config set`. +func TestApplyBandwidthPeriodChangeDiscardsTotals(t *testing.T) { + d := openTestDB(t) + u := mkUser(t, d, "erin", 5) + + // Accumulate under the day-1 period key, as a default install does. + if err := d.ApplyBandwidth(u.ID, "2026-09", 1000, 500, 777); err != nil { + t.Fatal(err) + } + if err := d.ApplyBandwidth(u.ID, "2026-09", 4000, 2500, 777); err != nil { + t.Fatal(err) + } + tr, err := d.GetBandwidth(u.ID) + if err != nil { + t.Fatal(err) + } + wantBandwidth(t, tr, "2026-09", 2000, 3000, 4000, 2500, 777) + + // The operator moves the reset day to the 22nd on the 16th, so the key + // becomes the previous month's. + if err := d.ApplyBandwidth(u.ID, "2026-08", 4000, 2500, 777); err != nil { + t.Fatal(err) + } + tr, err = d.GetBandwidth(u.ID) + if err != nil { + t.Fatal(err) + } + // Totals discarded; the counter baselines are preserved (unchanged + // counters add nothing), so counting restarts from here. + wantBandwidth(t, tr, "2026-08", 0, 0, 4000, 2500, 777) + + // Traffic after the change is counted again normally. + if err := d.ApplyBandwidth(u.ID, "2026-08", 4500, 3000, 777); err != nil { + t.Fatal(err) + } + tr, _ = d.GetBandwidth(u.ID) + wantBandwidth(t, tr, "2026-08", 500, 500, 4500, 3000, 777) +} + func TestBandwidthCascadeDelete(t *testing.T) { d := openTestDB(t) u := mkUser(t, d, "bob", 2) diff --git a/src/internal/mgr/bandwidth.go b/src/internal/mgr/bandwidth.go index 0206292..127ebba 100644 --- a/src/internal/mgr/bandwidth.go +++ b/src/internal/mgr/bandwidth.go @@ -59,6 +59,39 @@ func BandwidthPeriod(t time.Time, resetDay int) string { return firstOfMonth.AddDate(0, -1, 0).Format("2006-01") } +// BandwidthNextReset returns the moment the quota period in force ends, i.e. +// when the current period's counters roll over. +// +// It is the configured day of the month AFTER the current period's start +// month — not "the configured day next month". The distinction matters right +// after the reset day has passed: with resetDay=1 and today mid-month, the next +// reset is the 1st of the FOLLOWING month, while with resetDay=22 and today the +// 16th it is the 22nd of THIS month (the period still started last month). +// +// The caller renders it; ThisNextMonth reports whether that lands in the +// current calendar month or the next one. resetDay is clamped exactly like +// BandwidthPeriod (1..28) so the two can never disagree about the boundary. +func BandwidthNextReset(t time.Time, resetDay int) time.Time { + if resetDay < 1 || resetDay > 28 { + resetDay = 1 + } + t = t.UTC() + // Same period arithmetic as BandwidthPeriod: on/after resetDay we are in + // this month's period, before it we are still in last month's. + year, month := t.Year(), t.Month() + if t.Day() < resetDay { + month-- + if month < time.January { + month = time.December + year-- + } + } + // The period ends on the reset day of the following month. + firstOfPeriod := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + end := firstOfPeriod.AddDate(0, 1, 0) + return time.Date(end.Year(), end.Month(), resetDay, 0, 0, 0, 0, time.UTC) +} + // ParseBandwidthGB parses a bandwidth quota in GiB: empty or "0" = unlimited, // otherwise a non-negative integer. func ParseBandwidthGB(s string) (int, error) { diff --git a/src/internal/mgr/bandwidth_test.go b/src/internal/mgr/bandwidth_test.go index 0253d72..01348ac 100644 --- a/src/internal/mgr/bandwidth_test.go +++ b/src/internal/mgr/bandwidth_test.go @@ -83,6 +83,85 @@ func TestBandwidthPeriod(t *testing.T) { } } +// TestBandwidthNextReset pins the date the quota label shows. The next reset +// is the configured day of the month AFTER the current PERIOD started — not +// "the configured day next month" — which is exactly the distinction that made +// the label wrong before: with resetDay=22 on the 16th the period ends on the +// 22nd of THIS month. +func TestBandwidthNextReset(t *testing.T) { + day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } + cases := []struct { + name string + t time.Time + reset int + want time.Time + thisMon bool // does the reset land in the same calendar month as t? + }{ + // The reported case: today is the 16th, the reset day is the 22nd, so + // the period in force started on the 22nd of LAST month and ends on the + // 22nd of THIS one. + {"16th with reset 22 -> this month", day(2026, 9, 16), 22, day(2026, 9, 22), true}, + // Once that day has passed, the next one is next month's. + {"23rd with reset 22 -> next month", day(2026, 9, 23), 22, day(2026, 10, 22), false}, + {"on the reset day -> next month", day(2026, 9, 22), 22, day(2026, 10, 22), false}, + // Reset day 1: mid-month the next reset is always the 1st of next month. + {"16th with reset 1 -> next month", day(2026, 9, 16), 1, day(2026, 10, 1), false}, + // ... except on the last day of a month, where it is the very next day + // (still a different calendar month, so the label says "next month"). + {"last day with reset 1 -> September", day(2026, 8, 31), 1, day(2026, 9, 1), false}, + {"just after new year with reset 1", day(2026, 1, 1), 1, day(2026, 2, 1), false}, + // Year rollover, both directions. + {"before reset in January", day(2026, 1, 10), 15, day(2026, 1, 15), true}, + {"December past the reset day", day(2026, 12, 20), 15, day(2027, 1, 15), false}, + // February is why the day is capped at 28. + {"Feb with reset 28", day(2026, 2, 27), 28, day(2026, 2, 28), true}, + {"Feb with reset 28 past", day(2026, 2, 28), 28, day(2026, 3, 28), false}, + // Out of range clamps to 1, exactly like BandwidthPeriod. + {"out of range clamps to 1", day(2026, 9, 16), 0, day(2026, 10, 1), false}, + {"out of range clamps to 1 (high)", day(2026, 9, 16), 40, day(2026, 10, 1), false}, + } + for _, c := range cases { + got := BandwidthNextReset(c.t, c.reset) + if !got.Equal(c.want) { + t.Errorf("%s: BandwidthNextReset(%s, %d) = %s, want %s", + c.name, c.t.Format("2006-01-02"), c.reset, got.Format("2006-01-02"), c.want.Format("2006-01-02")) + } + if wantThisMon := got.Month() == c.t.Month() && got.Year() == c.t.Year(); wantThisMon != c.thisMon { + t.Errorf("%s: same-month flag = %v, want %v", c.name, wantThisMon, c.thisMon) + } + } +} + +// TestBandwidthNextResetAgreesWithPeriod keeps the two helpers from drifting: +// the period key flips exactly AT the returned reset moment, so a label built +// from one and a rollover driven by the other can never disagree about when +// the counters reset. +func TestBandwidthNextResetAgreesWithPeriod(t *testing.T) { + for _, reset := range []int{1, 5, 15, 22, 28} { + for d := 1; d <= 28; d++ { + now := time.Date(2026, 9, d, 12, 0, 0, 0, time.UTC) + next := BandwidthNextReset(now, reset) + if !next.After(now) { + t.Errorf("reset=%d day=%d: next reset %s is not in the future of %s", + reset, d, next.Format("2006-01-02"), now.Format("2006-01-02")) + } + // Just before the boundary the OLD period is still in force; at the + // boundary itself the key has already advanced. + before := BandwidthPeriod(next.Add(-time.Second), reset) + at := BandwidthPeriod(next, reset) + if before == at { + t.Errorf("reset=%d day=%d: period did not advance at %s (%s -> %s)", + reset, d, next.Format("2006-01-02"), before, at) + } + // And the period in force right now must be the one this reset ends. + if got := BandwidthPeriod(now, reset); got != before { + t.Errorf("reset=%d day=%d: period now = %s, but the reset at %s ends %s", + reset, d, got, next.Format("2006-01-02"), before) + } + } + } +} + func TestParseBandwidthGB(t *testing.T) { for s, want := range map[string]int{"": 0, "0": 0, "100": 100, " 50 ": 50} { got, err := ParseBandwidthGB(s) diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index afe545d..bbab2b3 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -167,30 +167,35 @@ type pageData struct { BandwidthUsedGB string // used this month (GB, 1 decimal) — only set when limited BandwidthPct int // used/quota * 100, clamped to 100 BandwidthResetDay int // day of month the bandwidth period resets (1-28) - Throttled bool // over quota: NIC limited to 1Mbps - ExpiresAt string // quota validity deadline (RFC3339 UTC), "" = permanent - Expired bool // deadline passed: account locked to read-only - ExpiresSoon bool // within 72h of the deadline - CPULimited bool // under the admin's dynamic CPU limit - CPULimitUntil int64 // unix seconds the dynamic CPU limit expires - Domains []domainRow - QuotaCPU string - QuotaMem string - QuotaDisk string - CPUUse string // 5-minute CPU average ("12%" or "-") - MemUse string // latest sampled memory usage ("345 MiB" or "-") - DiskUsed string // latest sampled disk usage ("184 MiB" or "-") - Msg string - Err string - PublicIP string - Prefix string - Lang string - UpGB string - DownGB string - IPv6 string // primary global address (the one to connect to) - IPv6Block string // the /112 block the container owns (informational) - Snapshots []snapshotRow - SnapshotLimit int // configured per-container snapshot cap (for display) + // BandwidthResetThisMonth reports whether the NEXT reset falls in the + // calendar month we are in. The label has to say which month: right after + // the reset day has passed it is still this month (reset day 22 on the 16th + // ends the period on the 22nd of THIS month). + BandwidthResetThisMonth bool + Throttled bool // over quota: NIC limited to 1Mbps + ExpiresAt string // quota validity deadline (RFC3339 UTC), "" = permanent + Expired bool // deadline passed: account locked to read-only + ExpiresSoon bool // within 72h of the deadline + CPULimited bool // under the admin's dynamic CPU limit + CPULimitUntil int64 // unix seconds the dynamic CPU limit expires + Domains []domainRow + QuotaCPU string + QuotaMem string + QuotaDisk string + CPUUse string // 5-minute CPU average ("12%" or "-") + MemUse string // latest sampled memory usage ("345 MiB" or "-") + DiskUsed string // latest sampled disk usage ("184 MiB" or "-") + Msg string + Err string + PublicIP string + Prefix string + Lang string + UpGB string + DownGB string + IPv6 string // primary global address (the one to connect to) + IPv6Block string // the /112 block the container owns (informational) + Snapshots []snapshotRow + SnapshotLimit int // configured per-container snapshot cap (for display) // Snapshot share: the code others can use to install from a checkpoint, and // the checkpoint it points at ("" when the user has no active share). ShareCode string @@ -402,6 +407,14 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData { Msg: msg, Err: errMsg, } + // The quota label has to name the month the next reset lands in: it is + // derived from the configured day, not hardcoded to "next month" — with + // resetDay=22 on the 16th the period ends on the 22nd of THIS month. + { + now := time.Now().UTC() + next := mgr.BandwidthNextReset(now, s.cfg.Panel.BandwidthResetDay) + d.BandwidthResetThisMonth = next.Month() == now.Month() && next.Year() == now.Year() + } // Quota validity: the client renders the live countdown from ExpiresAt; the // server flags the expired/locked state (authoritative for enforcement). { diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index 7f4a41b..ac4344a 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -1973,16 +1973,67 @@ func TestOverviewImpersonationBanner(t *testing.T) { } } -// TestOverviewBandwidthResetDayLabel verifies the quota label shows the -// configured monthly reset day ("至次月 N 号"). +// TestOverviewBandwidthResetDayLabel verifies the quota label names the right +// MONTH for the next reset. The label used to hardcode "次月", which is wrong +// whenever the reset day is still ahead of today: the period in force started +// on that day of the previous month, so it ends on that day of THIS one. func TestOverviewBandwidthResetDayLabel(t *testing.T) { srv, _ := newTestServer(t) - html := srv.renderToString(t, "overview.html", pageData{ - User: &db.User{Name: "alice"}, Prefix: "/" + testSecret, Lang: langZh, - BandwidthQuotaGB: 100, BandwidthUsedGB: "1.0", BandwidthResetDay: 5, - }) - if !strings.Contains(html, "流量配额(至次月5号)") { - t.Error("overview missing the bandwidth reset-day label") + cases := []struct { + name string + day int + thisMonth bool + wantZh string + wantEn string + }{ + {"reset still ahead -> this month", 22, true, "流量配额(至本月22号)", "Bandwidth quota (until day 22 this month)"}, + {"reset day passed -> next month", 5, false, "流量配额(至次月5号)", "Bandwidth quota (until day 5 next month)"}, + } + for _, c := range cases { + zh := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, Prefix: "/" + testSecret, Lang: langZh, + BandwidthQuotaGB: 100, BandwidthUsedGB: "1.0", + BandwidthResetDay: c.day, BandwidthResetThisMonth: c.thisMonth, + }) + if !strings.Contains(zh, c.wantZh) { + t.Errorf("%s: zh label missing %q", c.name, c.wantZh) + } + en := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, Prefix: "/" + testSecret, Lang: "en", + BandwidthQuotaGB: 100, BandwidthUsedGB: "1.0", + BandwidthResetDay: c.day, BandwidthResetThisMonth: c.thisMonth, + }) + if !strings.Contains(en, c.wantEn) { + t.Errorf("%s: en label missing %q", c.name, c.wantEn) + } + } +} + +// TestOverviewBandwidthResetMonthIsDerived checks the page data itself: the +// month flag must come from the reset day and today, not be assumed. The +// assertion is expressed against the same helper the page uses, so it holds +// whenever the suite runs (the reset day is pinned relative to today). +func TestOverviewBandwidthResetMonthIsDerived(t *testing.T) { + now := time.Now().UTC() + // A day that is always still ahead of today within the same month: today's + // day + 1 capped at 28, falling back to "reset day 1" mid-month on the 28th. + ahead := now.Day() + 1 + if ahead > 28 { + t.Skip("today is late in the month; the same-month case needs a reset day still ahead") + } + next := mgr.BandwidthNextReset(now, ahead) + if next.Month() != now.Month() { + t.Fatalf("reset day %d on the %dth should fall in this month, got %s", ahead, now.Day(), next.Format("2006-01-02")) + } + // A day just passed (today's day, capped) belongs to next month unless it + // is exactly today. + past := now.Day() - 1 + if past < 1 { + t.Skip("today is the 1st; no earlier day to test") + } + next = mgr.BandwidthNextReset(now, past) + if next.Month() == now.Month() { + t.Fatalf("reset day %d on the %dth should fall next month, got %s", past, now.Day(), next.Format("2006-01-02")) } } diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index ddaf948..ec4f4c1 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -304,7 +304,7 @@ footer a{color:var(--muted);text-decoration:underline}
{{if eq .Lang "zh"}}本月流量{{else}}Bandwidth{{end}} ↑ {{.UpGB}} / ↓ {{.DownGB}} GB{{if not .BandwidthQuotaGB}} · {{if eq .Lang "zh"}}无限制{{else}}unlimited{{end}}{{end}}
{{if .BandwidthQuotaGB}}
-
{{if eq .Lang "zh"}}流量配额(至次月{{.BandwidthResetDay}}号){{else}}Bandwidth quota (until day {{.BandwidthResetDay}} next month){{end}}{{.BandwidthUsedGB}} / {{.BandwidthQuotaGB}} GB{{if .Throttled}} · {{if eq .Lang "zh"}}限速中 1Mbps{{else}}limited to 1Mbps{{end}}{{end}}
+
{{if eq .Lang "zh"}}流量配额(至{{if .BandwidthResetThisMonth}}本月{{else}}次月{{end}}{{.BandwidthResetDay}}号){{else}}Bandwidth quota (until day {{.BandwidthResetDay}} {{if .BandwidthResetThisMonth}}this month{{else}}next month{{end}}){{end}}{{.BandwidthUsedGB}} / {{.BandwidthQuotaGB}} GB{{if .Throttled}} · {{if eq .Lang "zh"}}限速中 1Mbps{{else}}limited to 1Mbps{{end}}{{end}}
{{end}} diff --git a/src/main.go b/src/main.go index 002e696..5d72b96 100644 --- a/src/main.go +++ b/src/main.go @@ -1000,12 +1000,23 @@ func configSet(args []string) error { return fmt.Errorf("save config: %w", err) } if noApply { + if f.Apply == cfg.ApplyDestructive { + // --no-apply skips the confirmation above, so say it here instead: + // the discarded totals land at the NEXT panel restart, which would + // otherwise make the loss look like a separate event. Setting the + // value back before that restart avoids it entirely. + fmt.Printf("WARNING: %s is a destructive setting — applying it at the next panel restart discards every user's accumulated bandwidth totals (already-lost totals are not restored by setting it back).\n", key) + } fmt.Printf("%s saved (not applied).\n", key) return nil } switch f.Apply { - case cfg.ApplyRestart: + case cfg.ApplyRestart, cfg.ApplyDestructive: + // ApplyDestructive carries the same "restart the panel" mechanics; the + // difference is that the operator was warned in confirmApply that the + // change discards stored state (only panel.bandwidth_reset_day today), + // and it is stated again below so it cannot be missed. switch key { case "panel.listen": if !listenFree(c.Panel.Listen) { @@ -1019,6 +1030,8 @@ func configSet(args []string) error { if _, err := os.Stat(c.Panel.Key); err != nil { fmt.Printf("warning: key file %s does not exist — panel may fail to start\n", c.Panel.Key) } + case "panel.bandwidth_reset_day": + fmt.Printf("note: the monthly bandwidth period now starts on day %d — every user's accumulated traffic totals are discarded and counting restarts from the next sample.\n", c.Panel.BandwidthResetDay) } if err := restartPanel(); err != nil { fmt.Printf("%s saved, but the panel was NOT restarted: %v\n", key, err) @@ -1138,10 +1151,12 @@ func promptConfigValue(c *cfg.Config, f *cfg.Field) (value string, changed bool, // dangerousApply reports whether applying a field change is disruptive enough // to warrant a second confirmation: re-running `vps install` (regenerates -// firewall/routing/container wiring) or a live runtime toggle (net.v4_forward -// changes SSH/port/domain reachability of every container). +// firewall/routing/container wiring), a live runtime toggle (net.v4_forward +// changes SSH/port/domain reachability of every container), or a change that +// DISCARDS stored state (panel.bandwidth_reset_day zeroes every user's +// accumulated traffic totals). func dangerousApply(f *cfg.Field) bool { - return f.Apply == cfg.ApplyInstall || f.Apply == cfg.ApplyImmediate + return f.Apply == cfg.ApplyInstall || f.Apply == cfg.ApplyImmediate || f.Apply == cfg.ApplyDestructive } // confirmApply asks for a y/N confirmation before saving a change whose apply @@ -1151,6 +1166,15 @@ func confirmApply(c *cfg.Config, f *cfg.Field, key string) (bool, error) { switch f.Apply { case cfg.ApplyInstall: what = "re-runs `vps install`, regenerating the firewall / routing / container wiring" + case cfg.ApplyDestructive: + switch key { + case "panel.bandwidth_reset_day": + what = "restarts the panel AND DISCARDS every user's accumulated bandwidth totals — " + + "the monthly accounting period is derived from this day, so moving it starts a fresh period " + + "(any over-quota throttle is lifted as a result). This cannot be undone" + default: + what = "restarts the panel and discards stored state" + } case cfg.ApplyImmediate: switch key { case "net.v4_forward":