feat(quota): per-container validity period with expiry lockdown

Port of dev-quota-expiry onto 1.8. The branch's other three commits are
already in main, so only this feature is new here; its migration is
renumbered to v16 (1.8 already uses v14/v15).

- users.expires_at (RFC3339 UTC, '' = permanent): the admin sets days at
  creation and extends from the users table (72h/30d/90d/180d/custom/forever;
  extend = max(now, current) + duration)
- the 60s loop force-stops expired containers (stop force:true) and disables
  boot.autostart; the account is locked to read-only — the user panel refuses
  every mutation (including the new snapshot-share/unshare routes) and the
  admin panel allows only extend + delete
- the user panel shows a live countdown and a locked banner; the admin users
  table dims expired rows and tags the name "(-Nd)"
- CLI: vps add --days N, vps quota --days N | --clear-expiry
- audit: quota.expiry (admin change) and quota.expire.<user> (auto stop)
- the container and its checkpoints are never deleted, so a checkpoint shared
  before expiry stays installable by others (cloning a stopped container's
  snapshot works — verified end to end)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-15 16:58:37 +00:00
1 parent 8cb5c7cb82
commit 76e6b0990c
22 files changed
+779 -97

No files matched your search

+11
View File
@@ -126,6 +126,17 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
(MiB), disk (GiB). Disk maps onto the storage driver's quota (ZFS space
accounting, btrfs qgroups, or the accepted-but-unenforced `dir` case) and
can only grow, never shrink.
- **Quota validity (expiry)**: each user can carry an optional absolute deadline
(`users.expires_at`, RFC3339 UTC, `''` = permanent). When it passes, the 60s
loop force-stops the container (`stop` with `force:true`) and disables
`boot.autostart`, and the account is locked to read-only for both the user and
the admin — the only operations left are the admin extending the deadline
(extend = `max(now, current) + duration`) or deleting the account. Extending
past the deadline lifts the lock; a manual start is then required (autostart
stays off). The user panel shows a countdown under the bandwidth bar; the admin
users table dims expired rows and tags the name `(-Nd)`. The container and its
snapshots are never deleted, so a checkpoint shared before expiry stays
installable by others.
- **Container swap**: Incus 7 on cgroup v2 writes `memory.swap.max=0` for every
container unless `limits.memory.swap` carries an explicit byte amount
(`true`/`false` both end up as 0), so without an explicit value containers
+29 -8
View File
@@ -65,8 +65,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/knowledge-save", s.requireAuth(s.requirePost(s.handleKnowledgeSave)))
mux.HandleFunc("/knowledge-del", s.requireAuth(s.requirePost(s.handleKnowledgeDel)))
mux.HandleFunc("/knowledge-preview", s.requireAuth(s.requirePost(s.handleKnowledgePreview)))
mux.HandleFunc("/domain-del", s.requireAuth(s.requirePost(s.handleDomainDel)))
mux.HandleFunc("/domain-update", s.requireAuth(s.requirePost(s.handleDomainUpdate)))
mux.HandleFunc("/domain-del", s.requireAuth(s.requirePost(s.requireTargetActive(s.handleDomainDel))))
mux.HandleFunc("/domain-update", s.requireAuth(s.requirePost(s.requireTargetActive(s.handleDomainUpdate))))
mux.HandleFunc("/blocked-domains", s.requireAuth(s.requirePost(s.handleBlockedDomains)))
mux.HandleFunc("/ipv6pool", s.requireAuth(s.handleIPv6Pool))
mux.HandleFunc("/ipv6pool-add", s.requireAuth(s.requirePost(s.handleIPv6PoolAdd)))
@@ -75,14 +75,15 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/audit/api", s.requireAuth(s.handleAuditAPI))
mux.HandleFunc("/user-add", s.requireAuth(s.requirePost(s.handleUserAdd)))
mux.HandleFunc("/user-del", s.requireAuth(s.requirePost(s.handleUserDel)))
mux.HandleFunc("/user-quota", s.requireAuth(s.requirePost(s.handleUserQuota)))
mux.HandleFunc("/user-color", s.requireAuth(s.requirePost(s.handleUserColor)))
mux.HandleFunc("/user-bandwidth-reset", s.requireAuth(s.requirePost(s.handleUserBandwidthReset)))
mux.HandleFunc("/power", s.requireAuth(s.requirePost(s.handlePower)))
mux.HandleFunc("/reset-panel-pass", s.requireAuth(s.requirePost(s.handleResetPanelPass)))
mux.HandleFunc("/user-quota", s.requireAuth(s.requirePost(s.requireTargetActive(s.handleUserQuota))))
mux.HandleFunc("/user-expiry", s.requireAuth(s.requirePost(s.handleUserExpiry)))
mux.HandleFunc("/user-color", s.requireAuth(s.requirePost(s.requireTargetActive(s.handleUserColor))))
mux.HandleFunc("/user-bandwidth-reset", s.requireAuth(s.requirePost(s.requireTargetActive(s.handleUserBandwidthReset))))
mux.HandleFunc("/power", s.requireAuth(s.requirePost(s.requireTargetActive(s.handlePower))))
mux.HandleFunc("/reset-panel-pass", s.requireAuth(s.requirePost(s.requireTargetActive(s.handleResetPanelPass))))
mux.HandleFunc("/admin-pass", s.requireAuth(s.requirePost(s.handleAdminPass)))
mux.HandleFunc("/keys", s.requireAuth(s.requirePost(s.handleAdminKeys)))
mux.HandleFunc("/login-as", s.requireAuth(s.requirePost(s.handleLoginAs)))
mux.HandleFunc("/login-as", s.requireAuth(s.requirePost(s.requireTargetActive(s.handleLoginAs))))
mux.HandleFunc("/flash", s.requireAuth(s.requirePost(s.handleFlash)))
prefix := s.prefix()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -231,6 +232,26 @@ func (s *Server) requirePost(next http.HandlerFunc) http.HandlerFunc {
}
}
// requireTargetActive rejects a mutation aimed at an expired user. An expired
// account is locked to read-only for everyone; the only operations left are the
// admin's extend (/user-expiry) and delete (/user-del), which are not wrapped.
// The target is the "name" form field, present on every per-user form.
func (s *Server) requireTargetActive(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
if name := r.FormValue("name"); name != "" {
if u, err := s.db.GetUserByName(name); err == nil && mgr.IsExpired(u.ExpiresAt, time.Now().UTC()) {
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_account_expired"))
return
}
}
next(w, r)
}
}
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: "vpsmgr_admin_session",
+63
View File
@@ -0,0 +1,63 @@
package admin
import (
"net/http"
"net/url"
"testing"
"time"
"vpsmgr/internal/db"
"vpsmgr/internal/mgr"
)
// TestAdminExpiredUserLocked: an expired account is read-only for the admin —
// a quota change is refused, while extending the deadline (and deleting) still
// works. This covers requireTargetActive and handleUserExpiry.
func TestAdminExpiredUserLocked(t *testing.T) {
srv, d := newTestServer(t)
setAdminPass(t, srv, "correct-horse-battery")
h := srv.Handler()
prefix := "/" + testAdminSecret
cookie := adminLogin(t, h, prefix, "correct-horse-battery")
u, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0,
db.StatusReady, "", "2000-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
// A quota change on the expired account is refused and does not apply.
rr := doReq(t, h, http.MethodPost, prefix+"/user-quota",
url.Values{"name": {"alice"}, "cpu": {"2"}, "mem": {"1024"}, "disk": {"10"}, "bandwidth": {"0"}}, cookie)
if rr.Code != http.StatusFound {
t.Fatalf("expired /user-quota = %d, want 302", rr.Code)
}
if got, _ := d.GetUserByName("alice"); got.CPU != u.CPU {
t.Fatalf("quota changed on an expired account: cpu=%d", got.CPU)
}
// Deleting is still allowed (handled by the delete route, not locked).
// Extending the deadline is the other allowed operation.
rr = doReq(t, h, http.MethodPost, prefix+"/user-expiry",
url.Values{"name": {"alice"}, "extend": {"30d"}}, cookie)
if rr.Code != http.StatusFound {
t.Fatalf("extend = %d, want 302", rr.Code)
}
got, err := d.GetUserByName("alice")
if err != nil {
t.Fatal(err)
}
if mgr.IsExpired(got.ExpiresAt, time.Now().UTC()) {
t.Fatalf("extend did not push the deadline into the future: %q", got.ExpiresAt)
}
// "forever" clears the deadline.
rr = doReq(t, h, http.MethodPost, prefix+"/user-expiry",
url.Values{"name": {"alice"}, "extend": {"forever"}}, cookie)
if rr.Code != http.StatusFound {
t.Fatalf("extend forever = %d, want 302", rr.Code)
}
if got, _ := d.GetUserByName("alice"); got.ExpiresAt != "" {
t.Fatalf("forever did not clear the deadline: %q", got.ExpiresAt)
}
}
+99 -21
View File
@@ -122,6 +122,13 @@ type userView struct {
IPv6 string
Procs int64 // live process count (0 when stopped)
ProcsLimit string // per-container pids.max cap, e.g. "4096"
// Quota validity: Expired locks the account to read-only (only the admin's
// extend/delete work). ExpiredDays is whole days past the deadline, floored
// (a same-day expiry shows 0). ExpiresShort is the UTC date for display.
ExpiresAt string
ExpiresShort string
Expired bool
ExpiredDays int
}
func (s *Server) buildPageData(msg, errMsg string) pageData {
@@ -216,29 +223,45 @@ func (s *Server) loadUsers(d *pageData) {
return
}
vs := make([]userView, 0, len(statuses))
now := time.Now().UTC()
for _, st := range statuses {
u := st.User
expired := mgr.IsExpired(u.ExpiresAt, now)
expDays := 0
if expired {
if rem := mgr.ExpiryRemaining(u.ExpiresAt, now); rem < 0 {
expDays = int((-rem).Hours() / 24)
}
}
expShort := u.ExpiresAt
if len(expShort) >= 10 {
expShort = expShort[:10]
}
vs = append(vs, userView{
Name: u.Name,
Color: u.Color,
State: st.State,
Status: u.Status,
Ports: mgr.UserPorts(u.StartPort, cfg.PortsPerUser),
PortsShort: mgr.UserPortsShort(u.StartPort),
SSHPort: strconv.Itoa(u.SSHPort),
QuotaCPU: mgr.FormatCPU(u.CPU),
QuotaMem: strconv.Itoa(u.MemMB) + " MiB",
QuotaDisk: strconv.Itoa(u.DiskGB) + " GiB",
BandwidthGB: u.BandwidthQuotaGB,
CPUUse: st.CPUUse,
MemUse: st.MemUse,
DiskUsed: st.DiskUsed,
UpGB: st.UpGB,
DownGB: st.DownGB,
BWTotal: st.BWTotal,
IPv6: st.IPv6,
Procs: st.Procs,
ProcsLimit: lx.DefaultProcessesLimit,
Name: u.Name,
Color: u.Color,
State: st.State,
Status: u.Status,
Ports: mgr.UserPorts(u.StartPort, cfg.PortsPerUser),
PortsShort: mgr.UserPortsShort(u.StartPort),
SSHPort: strconv.Itoa(u.SSHPort),
QuotaCPU: mgr.FormatCPU(u.CPU),
QuotaMem: strconv.Itoa(u.MemMB) + " MiB",
QuotaDisk: strconv.Itoa(u.DiskGB) + " GiB",
BandwidthGB: u.BandwidthQuotaGB,
CPUUse: st.CPUUse,
MemUse: st.MemUse,
DiskUsed: st.DiskUsed,
UpGB: st.UpGB,
DownGB: st.DownGB,
BWTotal: st.BWTotal,
IPv6: st.IPv6,
Procs: st.Procs,
ProcsLimit: lx.DefaultProcessesLimit,
ExpiresAt: u.ExpiresAt,
ExpiresShort: expShort,
Expired: expired,
ExpiredDays: expDays,
})
}
d.Users = vs
@@ -458,7 +481,12 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
if ipv6 == "auto" {
ipv6 = ""
}
res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6, AllowChild: true})
days, err := strconv.Atoi(strings.TrimSpace(r.FormValue("days")))
if err != nil || days < 0 {
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_days"))
return
}
res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6, AllowChild: true, Days: days})
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
@@ -530,6 +558,56 @@ func (s *Server) handleUserQuota(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), s.t(r, "quota_updated", name))
}
// handleUserExpiry extends (or clears) a user's quota validity. It is the one
// per-user mutation still allowed on an expired account, alongside delete, so
// it is deliberately NOT wrapped in requireTargetActive. The dropdown choices
// map to durations; extension is max(now, current) + duration.
func (s *Server) handleUserExpiry(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := r.FormValue("name")
switch choice := r.FormValue("extend"); choice {
case "", "none":
s.redirect(w, r, s.p(""), s.t(r, "quota_updated", name))
return
case "forever":
if _, err := s.mgr.SetExpiry(name, ""); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
default:
var d time.Duration
switch choice {
case "72h":
d = 72 * time.Hour
case "30d":
d = 30 * 24 * time.Hour
case "90d":
d = 90 * 24 * time.Hour
case "180d":
d = 180 * 24 * time.Hour
case "custom":
n, err := strconv.Atoi(strings.TrimSpace(r.FormValue("days")))
if err != nil || n <= 0 {
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_extend"))
return
}
d = time.Duration(n) * 24 * time.Hour
default:
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_extend"))
return
}
if _, err := s.mgr.ExtendExpiryFor(name, d); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
}
_ = s.db.AddAuditLog("000+"+name, "quota.expiry")
s.redirect(w, r, s.p(""), s.t(r, "quota_expiry_updated", name))
}
// handleUserBandwidthReset zeroes a user's monthly traffic counters without
// touching the resource quotas. If the user was over quota (and thus
// throttled), the reset also lifts the NIC rate limit immediately.
+4
View File
@@ -107,6 +107,10 @@ func tr(l, key string, args ...any) string {
"err_kb_title": {"标题不能为空", "the title is required"},
"err_kb_content": {"内容不能为空", "the content is required"},
"err_kb_too_large": {"内容过大(上限 256 KiB)", "the content is too large (256 KiB max)"},
"err_invalid_days": {"有效期天数必须是非负整数(0 = 无限期)", "validity days must be a non-negative integer (0 = permanent)"},
"err_invalid_extend": {"延期时长无效", "invalid extension duration"},
"quota_expiry_updated": {"已更新 %[1]v 的有效期", "validity updated for %[1]v"},
"err_account_expired": {"账户已到期,仅可延期或删除", "account expired — only extend or delete is allowed"},
}
pair, ok := m[key]
if !ok {
@@ -41,6 +41,11 @@ th.sort-asc .ind::after { content: "▲"; }
th.sort-desc .ind::after { content: "▼"; }
.up { color: #16a34a; font-weight: 600; }
.down { color: #9ca3af; }
/* expired row: dim the whole line and drop every accent color */
tr.exp td { background: #f3f4f6; color: #9ca3af; }
@media (prefers-color-scheme: dark) { tr.exp td { background: #24262b; color: #6b7280; } }
tr.exp .udot { display: none; }
tr.exp button.uname { color: #9ca3af !important; font-weight: 600; }
.ops { display: flex; gap: 6px; flex-wrap: wrap; }
button, .btn { padding: 4px 10px; font-size: 12px; border-radius: 6px; border: 1px solid #d1d5db; cursor: pointer; background: #fff; color: #1c1e21; text-decoration: none; }
@media (prefers-color-scheme: dark) { button, .btn { background: #2a2e34; border-color: #3a3f47; color: #e6e8eb; } }
@@ -76,7 +81,7 @@ button.swatch:hover { border-color: #9ca3af; transform: scale(1.06); }
form.inline { display: inline; }
form.inline[hidden] { display: none; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; align-items: start; }
#createForm { grid-template-columns: minmax(210px, 1.55fr) repeat(3, minmax(88px, .7fr)) minmax(130px, .9fr) minmax(220px, 1.35fr) 88px; align-items: end; padding-bottom: 1.25rem; }
#createForm { grid-template-columns: minmax(210px, 1.55fr) repeat(3, minmax(88px, .7fr)) minmax(130px, .9fr) minmax(88px, .7fr) minmax(220px, 1.35fr) 88px; align-items: end; padding-bottom: 1.25rem; }
#createForm .create-field { min-width: 0; }
#createForm .name-field { position: relative; padding-bottom: 2.6em; }
#createForm .name-field small { position: absolute; top: calc(100% - 2.6em); left: 0; right: 0; min-height: 2.6em; white-space: normal; overflow: visible; line-height: 1.3; }
@@ -85,7 +90,7 @@ form.inline[hidden] { display: none; }
#createForm .ipv6-field small { position: absolute; left: 0; right: 0; bottom: 0; min-height: 1.2em; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#createForm #cfIpv6 { min-width: 0; }
#createForm #cfBtn { align-self: end; min-height: 36px; padding-inline: 14px; }
@media (max-width: 1050px) { #createForm { grid-template-columns: minmax(210px, 1.5fr) repeat(3, minmax(86px, 1fr)) minmax(125px, 1fr) minmax(190px, 1.35fr) 82px; } }
@media (max-width: 1050px) { #createForm { grid-template-columns: minmax(210px, 1.5fr) repeat(3, minmax(86px, 1fr)) minmax(125px, 1fr) minmax(84px, 1fr) minmax(190px, 1.35fr) 82px; } }
@media (max-width: 760px) { #createForm { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-bottom: 1.25rem; } #createForm .name-field, #createForm .ipv6-field { grid-column: 1 / -1; } #createForm #cfBtn { justify-self: start; } }
.field label { display: block; font-size: 12px; color: #6b7280; margin-bottom: 4px; }
@media (prefers-color-scheme: dark) { .field label { color: #9aa0a8; } }
@@ -224,6 +229,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<div class="field create-field resource-field"><label>{{if eq .Lang "zh"}}内存 MiB{{else}}Memory MiB{{end}}</label><input name="mem" id="cfMem" value="1024" required></div>
<div class="field create-field resource-field"><label>{{if eq .Lang "zh"}}磁盘 GiB{{else}}Disk GiB{{end}}</label><input name="disk" id="cfDisk" type="number" min="1" value="10" required></div>
<div class="field create-field"><label>{{if eq .Lang "zh"}}流量配额 GiB{{else}}Bandwidth GiB{{end}}</label><input name="bandwidth" id="cfBandwidth" type="number" min="0" step="1" value="0" title="{{if eq .Lang "zh"}}每月流量上限(上传+下载),0 = 不限;超限后限速 1Mbps{{else}}monthly quota (upload+download), 0 = unlimited; throttled to 1Mbps when exceeded{{end}}"></div>
<div class="field create-field"><label>{{if eq .Lang "zh"}}有效期(天){{else}}Validity (days){{end}}</label><input name="days" id="cfDays" type="number" min="0" step="1" value="0" title="{{if eq .Lang "zh"}}从创建起计算的有效天数,0 = 无限期{{else}}validity in days from creation; 0 = permanent{{end}}"></div>
{{if .IPv6PoolMode}}
<div class="field create-field ipv6-field"><label>{{if eq .Lang "zh"}}IPv6 地址{{else}}IPv6 address{{end}}</label>
<select name="ipv6" id="cfIpv6" class="field-select">
@@ -259,11 +265,11 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
</thead>
<tbody>
{{range .Users}}
<tr>
<td data-sort="{{.Name}}"><span class="urow">{{if .Color}}<span class="udot" style="background:{{.Color}}"></span>{{end}}<button type="button" class="uname"{{if .Color}} style="color:{{.Color}}"{{end}} onclick="pickColor('{{.Name}}')">{{.Name}}</button></span></td>
<tr{{if .Expired}} class="exp"{{end}}>
<td data-sort="{{.Name}}"><span class="urow">{{if and .Color (not .Expired)}}<span class="udot" style="background:{{.Color}}"></span>{{end}}<button type="button" class="uname"{{if and .Color (not .Expired)}} style="color:{{.Color}}"{{end}} onclick="pickColor('{{.Name}}')">{{.Name}}{{if .Expired}}(-{{.ExpiredDays}}d){{end}}</button></span></td>
<td class="{{if eq .State "Running"}}up{{else}}down{{end}}">{{.State}}{{if and (ne .Status "ready") (ne .Status "")}} <span class="pill down">{{.Status}}</span>{{end}}</td>
<td>{{if $.V4Forward}}<span title="{{.Ports}}">{{.PortsShort}}</span><br><small>{{.SSHPort}}</small>{{else}}<span title="{{.Ports}}">{{.PortsShort}}</span><br><small>v4 off</small>{{end}}</td>
<td>{{.QuotaCPU}} / {{.QuotaMem}} / {{.QuotaDisk}}<br><small>{{if eq $.Lang "zh"}}流量{{else}}bandwidth{{end}}: {{if eq .BandwidthGB 0}}{{if eq $.Lang "zh"}}不限{{else}}unlimited{{end}}{{else}}{{.BandwidthGB}} GB{{end}}</small></td>
<td>{{.QuotaCPU}} / {{.QuotaMem}} / {{.QuotaDisk}}<br><small>{{if eq $.Lang "zh"}}流量{{else}}bandwidth{{end}}: {{if eq .BandwidthGB 0}}{{if eq $.Lang "zh"}}不限{{else}}unlimited{{end}}{{else}}{{.BandwidthGB}} GB{{end}}</small><br><small>{{if eq $.Lang "zh"}}有效期{{else}}validity{{end}}: {{if .ExpiresAt}}{{.ExpiresShort}}{{else}}{{if eq $.Lang "zh"}}无限期{{else}}permanent{{end}}{{end}}</small></td>
<td data-sort="{{.CPUUse}}">{{.CPUUse}}</td>
<td data-sort="{{.MemUse}}">{{.MemUse}}</td>
<td data-sort="{{if eq .State "Running"}}{{.Procs}}{{else}}-{{end}}">{{if eq .State "Running"}}{{.Procs}}{{else}}-{{end}}</td>
@@ -272,16 +278,21 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
{{if $.IPv6PoolMode}}<td>{{if .IPv6}}{{if eq $.Lang "zh"}}有{{else}}Yes{{end}}{{else}}<span class="down">{{if eq $.Lang "zh"}}无{{else}}No{{end}}</span>{{end}}</td>{{end}}
<td>
<div class="ops">
{{if .Expired}}
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}','{{.ExpiresAt}}',true)">{{if eq $.Lang "zh"}}延期{{else}}Extend{{end}}</button>
<button class="danger" onclick="del('{{.Name}}')">{{if eq $.Lang "zh"}}删除{{else}}Delete{{end}}</button>
{{else}}
{{if eq .State "Running"}}
<form action="{{$.Prefix}}/power" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="action" value="stop"><button class="warn">{{if eq $.Lang "zh"}}停止{{else}}Stop{{end}}</button></form>
<form action="{{$.Prefix}}/power" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="action" value="restart"><button>{{if eq $.Lang "zh"}}重启{{else}}Restart{{end}}</button></form>
{{else}}
<form action="{{$.Prefix}}/power" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="action" value="start"><button class="ok">{{if eq $.Lang "zh"}}启动{{else}}Start{{end}}</button></form>
{{end}}
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}')">{{if eq $.Lang "zh"}}配额{{else}}Quota{{end}}</button>
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}','{{.ExpiresAt}}',false)">{{if eq $.Lang "zh"}}配额{{else}}Quota{{end}}</button>
<form action="{{$.Prefix}}/login-as" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button class="primary neutral"{{if .Color}} style="--ubtn:{{.Color}};color:#fff"{{end}} title="{{if eq $.Lang "zh"}}以该用户身份登录用户面板{{else}}log in to this user's panel{{end}}">{{if eq $.Lang "zh"}}登录面板{{else}}Login{{end}}</button></form>
<form action="{{$.Prefix}}/reset-panel-pass" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button>{{if eq $.Lang "zh"}}重置密码{{else}}Reset pw{{end}}</button></form>
<button class="danger" onclick="del('{{.Name}}')">{{if eq $.Lang "zh"}}删除{{else}}Delete{{end}}</button>
{{end}}
</div>
</td>
</tr>
@@ -357,6 +368,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<div class="field"><label>{{if eq .Lang "zh"}}内存 MiB{{else}}Memory MiB{{end}}</label><input type="number" id="planMem" min="1" step="1" required></div>
<div class="field"><label>{{if eq .Lang "zh"}}磁盘 GiB{{else}}Disk GiB{{end}}</label><input type="number" id="planDisk" min="1" step="1" required></div>
<div class="field"><label>{{if eq .Lang "zh"}}流量 GiB /月{{else}}Bandwidth GiB/mo{{end}}</label><input type="number" id="planBw" min="0" step="1" required title="{{if eq .Lang "zh"}}每月流量上限(上传+下载),0 = 不限{{else}}monthly quota (up+down), 0 = unlimited{{end}}"></div>
<div class="field"><label>{{if eq .Lang "zh"}}有效期(天){{else}}Validity (days){{end}}</label><input type="number" id="planDays" min="0" step="1" value="0" title="{{if eq .Lang "zh"}}从创建起计算的有效天数,0 = 无限期{{else}}validity in days from creation; 0 = permanent{{end}}"></div>
<div class="foot" style="justify-content:flex-end">
<button type="button" onclick="closePlan()">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
<button type="submit" class="primary neutral">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
@@ -380,6 +392,25 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<button type="submit" class="primary" id="quotaSaveBtn">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
<button type="button" onclick="closeQuota()">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
</form>
<p id="quotaExpiredNote" style="display:none;color:#b91c1c;font-weight:600;margin:10px 0 0">{{if eq .Lang "zh"}}该账户已到期:仅可延期,其他配额与操作已锁定。{{else}}This account has expired: only extending is allowed; everything else is locked.{{end}}</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:14px 0;">
<form action="{{.Prefix}}/user-expiry" method="post" id="expiryForm" class="form-grid">
<input type="hidden" name="name" id="quotaExpiryName">
<div class="field"><label>{{if eq .Lang "zh"}}有效期 / 延期{{else}}Validity / extend{{end}}</label>
<select name="extend" id="quotaExtend" class="field-select">
<option value="none">{{if eq .Lang "zh"}}不改变{{else}}Keep current{{end}}</option>
<option value="72h">72 {{if eq .Lang "zh"}}小时{{else}}hours{{end}}</option>
<option value="30d">30 {{if eq .Lang "zh"}}天{{else}}days{{end}}</option>
<option value="90d">90 {{if eq .Lang "zh"}}天{{else}}days{{end}}</option>
<option value="180d">180 {{if eq .Lang "zh"}}天{{else}}days{{end}}</option>
<option value="custom">{{if eq .Lang "zh"}}自定义(天){{else}}Custom (days){{end}}</option>
<option value="forever">{{if eq .Lang "zh"}}永久(清除到期){{else}}Permanent (clear){{end}}</option>
</select>
</div>
<div class="field" id="quotaExtendDaysWrap" style="display:none"><label>{{if eq .Lang "zh"}}自定义天数{{else}}Custom days{{end}}</label><input name="days" id="quotaExtendDays" type="number" min="1" step="1" value="30"></div>
<button type="submit" class="primary neutral">{{if eq .Lang "zh"}}应用延期{{else}}Apply extension{{end}}</button>
<small style="color:#9aa0a8;display:block">{{if eq .Lang "zh"}}新到期时间 = max(现在, 当前到期) + 所选时长{{else}}new deadline = max(now, current) + chosen duration{{end}}</small>
</form>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:14px 0;">
<form action="{{.Prefix}}/user-bandwidth-reset" method="post">
<input type="hidden" name="name" id="quotaBwName">
@@ -441,7 +472,7 @@ var P = "{{.Prefix}}";
var COPY_OK = {{if eq .Lang "zh"}}"已复制 ✓"{{else}}"Copied ✓"{{end}};
var COPY_LBL = {{if eq .Lang "zh"}}"复制"{{else}}"Copy"{{end}};
var CREATE_ING = {{if eq .Lang "zh"}}"创建中…"{{else}}"Creating…"{{end}};
{{if eq .Lang "zh"}}var PLAN_LBL = {name:'名称', cpu:'CPU', mem:'内存', disk:'磁盘', bw:'流量', apply:'套用', edit:'编辑', save:'保存', cancel:'取消', editTitle:'编辑套餐预设'};{{else}}var PLAN_LBL = {name:'Name', cpu:'CPU', mem:'Memory', disk:'Disk', bw:'Bandwidth', apply:'Apply', edit:'Edit', save:'Save', cancel:'Cancel', editTitle:'Edit plan preset'};{{end}}
{{if eq .Lang "zh"}}var PLAN_LBL = {name:'名称', cpu:'CPU', mem:'内存', disk:'磁盘', bw:'流量', days:'有效期', apply:'套用', edit:'编辑', save:'保存', cancel:'取消', editTitle:'编辑套餐预设'};{{else}}var PLAN_LBL = {name:'Name', cpu:'CPU', mem:'Memory', disk:'Disk', bw:'Bandwidth', days:'Days', apply:'Apply', edit:'Edit', save:'Save', cancel:'Cancel', editTitle:'Edit plan preset'};{{end}}
function openModal(msg){ document.getElementById('modalBody').textContent = msg; document.getElementById('modal').classList.add('open'); }
function closeModal(){ document.getElementById('modal').classList.remove('open'); hideLoginAs(); }
// The "log in as this user" button appears only in the post-create modal.
@@ -457,18 +488,36 @@ function copyModal(){
if (navigator.clipboard) { navigator.clipboard.writeText(b.textContent).then(done, done); }
else { var ta=document.createElement('textarea'); ta.value=b.textContent; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); done(); }
}
function quota(name, cpu, mem, disk, bandwidth){
function quota(name, cpu, mem, disk, bandwidth, expires, expired){
document.getElementById('quotaName').textContent = name;
document.getElementById('quotaNameHidden').value = name;
document.getElementById('quotaBwName').value = name;
document.getElementById('quotaExpiryName').value = name;
document.getElementById('quotaCpu').value = parseFloat(cpu);
document.getElementById('quotaMem').value = parseInt(mem);
document.getElementById('quotaDisk').value = parseInt(disk);
document.getElementById('quotaBandwidth').value = parseInt(bandwidth || 0);
syncCpuStep(document.getElementById('quotaCpu'));
// Expired accounts are locked to extend-or-delete: disable the quota fields
// (and block the reset-traffic form) so only the extend form is usable.
var lock = !!expired;
['quotaCpu','quotaMem','quotaDisk','quotaBandwidth'].forEach(function(id){
var el = document.getElementById(id); if(el) el.disabled = lock;
});
var save = document.getElementById('quotaSaveBtn'); if(save) save.disabled = lock;
var note = document.getElementById('quotaExpiredNote'); if(note) note.style.display = lock ? 'block' : 'none';
document.getElementById('quotaExtend').value = 'none';
document.getElementById('quotaExtendDaysWrap').style.display = 'none';
document.getElementById('quotaModal').classList.add('open');
}
function closeQuota(){ document.getElementById('quotaModal').classList.remove('open'); }
// Show the custom-days field only when the "custom" extend option is chosen.
(function(){
var sel = document.getElementById('quotaExtend');
if(sel) sel.addEventListener('change', function(){
document.getElementById('quotaExtendDaysWrap').style.display = (sel.value === 'custom') ? 'block' : 'none';
});
})();
// Per-user color picker: opened by clicking the (bold) username. The modal
// posts straight to /user-color — one click on a swatch saves and reloads.
function pickColor(name){
@@ -516,10 +565,11 @@ function del(name){
function fmtMem(v){ return String(Math.round(v)); }
function fmtDisk(v){ return String(Math.round(v)); }
function fmtBw(v){ return String(Math.round(v)); }
function fmtDays(v){ return String(Math.round(v)); }
var DEFAULTS = [
{ key: 's', name: {{if eq .Lang "zh"}}'小号'{{else}}'Small'{{end}}, cpu: 1, mem: 1024, disk: 10, bw: 0 },
{ key: 'm', name: {{if eq .Lang "zh"}}'中号'{{else}}'Medium'{{end}}, cpu: 1, mem: 1024, disk: 10, bw: 0 },
{ key: 'l', name: {{if eq .Lang "zh"}}'大号'{{else}}'Large'{{end}}, cpu: 1, mem: 1024, disk: 10, bw: 0 }
{ key: 's', name: {{if eq .Lang "zh"}}'小号'{{else}}'Small'{{end}}, cpu: 1, mem: 1024, disk: 10, bw: 0, days: 0 },
{ key: 'm', name: {{if eq .Lang "zh"}}'中号'{{else}}'Medium'{{end}}, cpu: 1, mem: 1024, disk: 10, bw: 0, days: 0 },
{ key: 'l', name: {{if eq .Lang "zh"}}'大号'{{else}}'Large'{{end}}, cpu: 1, mem: 1024, disk: 10, bw: 0, days: 0 }
];
var plans;
try {
@@ -532,14 +582,16 @@ function del(name){
cpu: (s.cpu !== undefined) ? s.cpu : d.cpu,
mem: (s.mem !== undefined) ? s.mem : d.mem,
disk: (s.disk !== undefined) ? s.disk : d.disk,
bw: (s.bw !== undefined) ? s.bw : d.bw
bw: (s.bw !== undefined) ? s.bw : d.bw,
days: (s.days !== undefined) ? s.days : d.days
};
});
} catch (e) { plans = DEFAULTS.map(function(d){ return {key:d.key, name:d.name, cpu:d.cpu, mem:d.mem, disk:d.disk, bw:d.bw}; }); }
function save(){ try { var o = {}; plans.forEach(function(p){ o[p.key] = {name:p.name, cpu:p.cpu, mem:p.mem, disk:p.disk, bw:p.bw}; }); localStorage.setItem(KEY, JSON.stringify(o)); } catch (e) {} }
} catch (e) { plans = DEFAULTS.map(function(d){ return {key:d.key, name:d.name, cpu:d.cpu, mem:d.mem, disk:d.disk, bw:d.bw, days:d.days}; }); }
function save(){ try { var o = {}; plans.forEach(function(p){ o[p.key] = {name:p.name, cpu:p.cpu, mem:p.mem, disk:p.disk, bw:p.bw, days:p.days}; }); localStorage.setItem(KEY, JSON.stringify(o)); } catch (e) {} }
function fmtSpec(p){
return L.cpu + ' ' + fmtCpu(p.cpu) + ' · ' + L.mem + ' ' + fmtMem(p.mem) + ' · ' +
L.disk + ' ' + fmtDisk(p.disk) + ' · ' + L.bw + (p.bw === 0 ? ' ∞' : ' ' + fmtBw(p.bw));
L.disk + ' ' + fmtDisk(p.disk) + ' · ' + L.bw + (p.bw === 0 ? ' ∞' : ' ' + fmtBw(p.bw)) +
' · ' + L.days + (p.days > 0 ? ' ' + fmtDays(p.days) : ' ∞');
}
var last = null; // slot key of the last applied plan, for the highlight
function renderPlans(){
@@ -568,6 +620,7 @@ function del(name){
document.getElementById('planMem').value = p.mem;
document.getElementById('planDisk').value = p.disk;
document.getElementById('planBw').value = p.bw;
document.getElementById('planDays').value = p.days;
syncCpuStep(document.getElementById('planCpu'));
document.getElementById('planModal').classList.add('open');
document.getElementById('planName').focus();
@@ -577,6 +630,7 @@ function del(name){
last = p.key;
setVal('cfCpu', p.cpu, fmtCpu); setVal('cfMem', p.mem, fmtMem);
setVal('cfDisk', p.disk, fmtDisk); setVal('cfBandwidth', p.bw, fmtBw);
setVal('cfDays', p.days, fmtDays);
renderPlans();
}
function setVal(id, v, fmt){
@@ -595,6 +649,8 @@ function del(name){
p.mem = parseInt(document.getElementById('planMem').value, 10);
p.disk = parseInt(document.getElementById('planDisk').value, 10);
p.bw = parseInt(document.getElementById('planBw').value, 10);
p.days = parseInt(document.getElementById('planDays').value, 10) || 0;
if (p.days < 0) p.days = 0;
save(); renderPlans(); m.classList.remove('open');
});
if (m) m.addEventListener('click', function(e){ if (e.target === m) m.classList.remove('open'); });
@@ -609,7 +665,7 @@ function del(name){
// the field reports the naming rule and duplicate status before submit.
(function(){
var KEY = 'vpsmgr_admin_create';
var cpu = document.getElementById('cfCpu'), mem = document.getElementById('cfMem'), disk = document.getElementById('cfDisk'), bandwidth = document.getElementById('cfBandwidth'), btn = document.getElementById('cfBtn');
var cpu = document.getElementById('cfCpu'), mem = document.getElementById('cfMem'), disk = document.getElementById('cfDisk'), bandwidth = document.getElementById('cfBandwidth'), days = document.getElementById('cfDays'), btn = document.getElementById('cfBtn');
var nameIn = document.getElementById('cfName'), nameHint = document.getElementById('cfNameHint');
var existing = { {{range .Users}}"{{.Name}}":1, {{end}}};
var MSG = {{if eq .Lang "zh"}}{ok:'用户名可用', group:'将创建用户组:', groupAfter:'(已有 ', groupEnd:' 个账号,创建后 ', accounts:' 个)', dup:'该用户名已存在', invalid:'主用户必须以字母结尾;子用户格式为 主用户-数字,且不允许嵌套', hint:'自动转为小写'}{{else}}{ok:'Username available', group:'User group to create: ', groupAfter:' (', groupEnd:' accounts, ', accounts:' after creation)', dup:'Username already exists', invalid:'parent must end with a letter; child format is parent-number, nesting is not allowed', hint:'Folded to lowercase'}{{end}};
@@ -647,12 +703,12 @@ var child = low.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/);
if (nameIn) nameIn.addEventListener('input', nameStatus);
try {
var saved = JSON.parse(localStorage.getItem(KEY) || 'null');
if (saved) { if (saved.cpu) cpu.value = saved.cpu; if (saved.mem) mem.value = saved.mem; if (saved.disk) disk.value = saved.disk; if (saved.bandwidth !== undefined) bandwidth.value = saved.bandwidth; }
if (saved) { if (saved.cpu) cpu.value = saved.cpu; if (saved.mem) mem.value = saved.mem; if (saved.disk) disk.value = saved.disk; if (saved.bandwidth !== undefined) bandwidth.value = saved.bandwidth; if (saved.days !== undefined && days) days.value = saved.days; }
syncCpuStep(cpu);
} catch (e) {}
function persist(){ try { localStorage.setItem(KEY, JSON.stringify({cpu: cpu.value, mem: mem.value, disk: disk.value, bandwidth: bandwidth.value})); } catch (e) {} }
function persist(){ try { localStorage.setItem(KEY, JSON.stringify({cpu: cpu.value, mem: mem.value, disk: disk.value, bandwidth: bandwidth.value, days: days ? days.value : 0})); } catch (e) {} }
function onCpu(){ syncCpuStep(cpu); persist(); }
cpu.addEventListener('input', onCpu); mem.addEventListener('input', persist); disk.addEventListener('input', persist); bandwidth.addEventListener('input', persist);
cpu.addEventListener('input', onCpu); mem.addEventListener('input', persist); disk.addEventListener('input', persist); bandwidth.addEventListener('input', persist); if (days) days.addEventListener('input', persist);
document.getElementById('createForm').addEventListener('submit', function(e){
if (nameIn && !nameIn.checkValidity()) { e.preventDefault(); nameIn.reportValidity(); return; }
btn.disabled = true; btn.classList.add('busy'); btn.textContent = CREATE_ING;
+8 -1
View File
@@ -55,7 +55,7 @@ func (d *DB) Close() error { return d.sql.Close() }
// schemaVersion is the current schema version. Every migration in
// migrations must be applied in order; Open refuses to start on a database
// whose version is newer than this binary understands (downgrade protection).
const schemaVersion = 15
const schemaVersion = 16
// migrations are applied in order, each inside its own transaction. v1 is the
// original schema (baseline); later versions only add/alter, never drop.
@@ -266,6 +266,13 @@ var migrations = []struct {
updated_at TEXT NOT NULL
)`,
}},
// v16: optional quota validity deadline. RFC3339 UTC (same format as
// created_at); '' = permanent (no expiry). When the deadline passes the
// container is force-stopped and the account is locked to read-only until
// an admin extends it.
{16, []string{
`ALTER TABLE users ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''`,
}},
}
// sqliteStr quotes a string literal for SQLite by doubling any single quote.
+58
View File
@@ -0,0 +1,58 @@
package db
import "testing"
func TestUserExpiryRoundTrip(t *testing.T) {
d := openTestDB(t)
const exp = "2030-01-02T03:04:05Z"
u, err := d.CreateUserFull("alice", "h", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10, 0, StatusReady, "", exp)
if err != nil {
t.Fatal(err)
}
got, err := d.GetUserByName("alice")
if err != nil {
t.Fatal(err)
}
if got.ExpiresAt != exp {
t.Errorf("GetUserByName ExpiresAt = %q, want %q", got.ExpiresAt, exp)
}
// A permanent account (empty expiry) reads back empty.
if _, err := d.CreateUser("bob", "h", "10.115.0.3", 2, 30002, 10100, 1, 1024, 10); err != nil {
t.Fatal(err)
}
bob, err := d.GetUserByName("bob")
if err != nil {
t.Fatal(err)
}
if bob.ExpiresAt != "" {
t.Errorf("bob ExpiresAt = %q, want empty", bob.ExpiresAt)
}
// Clearing the deadline.
if err := d.UpdateUserExpiry(u.ID, ""); err != nil {
t.Fatal(err)
}
got, _ = d.GetUserByName("alice")
if got.ExpiresAt != "" {
t.Errorf("after clear ExpiresAt = %q, want empty", got.ExpiresAt)
}
// ListUsers carries the column too.
if err := d.UpdateUserExpiry(u.ID, exp); err != nil {
t.Fatal(err)
}
users, err := d.ListUsers()
if err != nil {
t.Fatal(err)
}
var seen bool
for _, x := range users {
if x.Name == "alice" && x.ExpiresAt == exp {
seen = true
}
}
if !seen {
t.Errorf("ListUsers did not return alice's expiry %q: %+v", exp, users)
}
}
+4 -4
View File
@@ -15,7 +15,7 @@ func TestIPv6AddressLifecycle(t *testing.T) {
defer d.Close()
addr := "2001:db8::9c4"
u, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, StatusReady, addr)
u, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, StatusReady, addr, "")
if err != nil {
t.Fatal(err)
}
@@ -24,7 +24,7 @@ func TestIPv6AddressLifecycle(t *testing.T) {
}
// Same address for a second user must be refused (UNIQUE index).
if _, err := d.CreateUserFull("bob", "h", "10.42.0.3", 2, 30002, 10100, 1, 1024, 10, 0, StatusReady, addr); err == nil {
if _, err := d.CreateUserFull("bob", "h", "10.42.0.3", 2, 30002, 10100, 1, 1024, 10, 0, StatusReady, addr, ""); err == nil {
t.Fatal("expected UNIQUE violation for duplicate address, got nil")
}
@@ -47,7 +47,7 @@ func TestIPv6AddressLifecycle(t *testing.T) {
}
// The address is now assignable again.
if _, err := d.CreateUserFull("carol", "h", "10.42.0.4", 3, 30003, 10200, 1, 1024, 10, 0, StatusReady, addr); err != nil {
if _, err := d.CreateUserFull("carol", "h", "10.42.0.4", 3, 30003, 10200, 1, 1024, 10, 0, StatusReady, addr, ""); err != nil {
t.Fatalf("reassign after delete: %v", err)
}
}
@@ -61,7 +61,7 @@ func TestIPv6AddressNullShared(t *testing.T) {
}
defer d.Close()
for i, name := range []string{"a", "b", "c"} {
if _, err := d.CreateUserFull(name, "h", "10.42.0."+string(rune('2'+i)), i+1, 30001+i, 10000+i*100, 1, 1024, 10, 0, StatusReady, ""); err != nil {
if _, err := d.CreateUserFull(name, "h", "10.42.0."+string(rune('2'+i)), i+1, 30001+i, 10000+i*100, 1, 1024, 10, 0, StatusReady, "", ""); err != nil {
t.Fatalf("user %s without address: %v", name, err)
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ func TestUserStatusRoundTrip(t *testing.T) {
t.Fatal(err)
}
defer d.Close()
u, err := d.CreateUserFull("carol", "h", "10.115.0.4", 3, 30003, 10200, 1, 1024, 10, 0, StatusCreating, "")
u, err := d.CreateUserFull("carol", "h", "10.115.0.4", 3, 30003, 10200, 1, 1024, 10, 0, StatusCreating, "", "")
if err != nil {
t.Fatal(err)
}
+23 -11
View File
@@ -33,6 +33,10 @@ type User struct {
// default). A hex string from the admin panel's fixed palette; the user
// panel tints its theme with it.
Color string
// ExpiresAt is the quota validity deadline in RFC3339 UTC ("" = permanent).
// Once it passes, the container is force-stopped and the account is locked
// to read-only until an admin extends or clears it.
ExpiresAt string
}
func (d *DB) CreateUser(name, passHash, ip string, idx, sshPort, startPort, cpu, memMB, diskGB int) (*User, error) {
@@ -56,8 +60,9 @@ func (d *DB) CreateUser(name, passHash, ip string, idx, sshPort, startPort, cpu,
// status is the initial lifecycle state (StatusCreating during Add, the
// default otherwise). ipv6Address is the pool-mode /128 assigned to the user
// ("" for none); written in the same transaction so the address reservation
// and the user row can never disagree.
func (d *DB) CreateUserFull(name, passHash, ip string, idx, sshPort, startPort, cpu, memMB, diskGB, bandwidthGB int, status, ipv6Address string) (*User, error) {
// and the user row can never disagree. expiresAt is the quota deadline
// (RFC3339 UTC, "" = permanent).
func (d *DB) CreateUserFull(name, passHash, ip string, idx, sshPort, startPort, cpu, memMB, diskGB, bandwidthGB int, status, ipv6Address, expiresAt string) (*User, error) {
if status == "" {
status = StatusReady
}
@@ -68,11 +73,11 @@ func (d *DB) CreateUserFull(name, passHash, ip string, idx, sshPort, startPort,
defer tx.Rollback()
u := &User{Name: name, PassHash: passHash, Idx: idx, IP: ip, SSHPort: sshPort, StartPort: startPort,
CPU: cpu, MemMB: memMB, DiskGB: diskGB, CreatedAt: now(), Status: status, IPv6Address: ipv6Address}
CPU: cpu, MemMB: memMB, DiskGB: diskGB, CreatedAt: now(), Status: status, IPv6Address: ipv6Address, ExpiresAt: expiresAt}
r, err := tx.Exec(
`INSERT INTO users(name, pass_hash, idx, ip, ssh_port, start_port, cpu, mem_mb, disk_gb, created_at, status, ipv6_address)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
u.Name, u.PassHash, u.Idx, u.IP, u.SSHPort, u.StartPort, u.CPU, u.MemMB, u.DiskGB, u.CreatedAt, u.Status, nullIfEmpty(u.IPv6Address))
`INSERT INTO users(name, pass_hash, idx, ip, ssh_port, start_port, cpu, mem_mb, disk_gb, created_at, status, ipv6_address, expires_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
u.Name, u.PassHash, u.Idx, u.IP, u.SSHPort, u.StartPort, u.CPU, u.MemMB, u.DiskGB, u.CreatedAt, u.Status, nullIfEmpty(u.IPv6Address), u.ExpiresAt)
if err != nil {
return nil, err
}
@@ -182,7 +187,7 @@ func scanUser(row *sql.Row) (*User, error) {
u := &User{}
var ipv6 sql.NullString
err := row.Scan(&u.ID, &u.Name, &u.PassHash, &u.Idx, &u.IP, &u.SSHPort, &u.StartPort,
&u.InitScript, &u.BandwidthQuotaGB, &u.CPU, &u.MemMB, &u.DiskGB, &u.CreatedAt, &u.Status, &ipv6, &u.Color)
&u.InitScript, &u.BandwidthQuotaGB, &u.CPU, &u.MemMB, &u.DiskGB, &u.CreatedAt, &u.Status, &ipv6, &u.Color, &u.ExpiresAt)
if err != nil {
return nil, err
}
@@ -192,19 +197,19 @@ func scanUser(row *sql.Row) (*User, error) {
func (d *DB) GetUserByName(name string) (*User, error) {
return scanUser(d.sql.QueryRow(
`SELECT id, name, pass_hash, idx, ip, ssh_port, start_port, init_script, bandwidth_quota_gb, cpu, mem_mb, disk_gb, created_at, status, ipv6_address, color
`SELECT id, name, pass_hash, idx, ip, ssh_port, start_port, init_script, bandwidth_quota_gb, cpu, mem_mb, disk_gb, created_at, status, ipv6_address, color, expires_at
FROM users WHERE name=?`, name))
}
func (d *DB) GetUserByID(id int64) (*User, error) {
return scanUser(d.sql.QueryRow(
`SELECT id, name, pass_hash, idx, ip, ssh_port, start_port, init_script, bandwidth_quota_gb, cpu, mem_mb, disk_gb, created_at, status, ipv6_address, color
`SELECT id, name, pass_hash, idx, ip, ssh_port, start_port, init_script, bandwidth_quota_gb, cpu, mem_mb, disk_gb, created_at, status, ipv6_address, color, expires_at
FROM users WHERE id=?`, id))
}
func (d *DB) ListUsers() ([]*User, error) {
rows, err := d.sql.Query(
`SELECT id, name, pass_hash, idx, ip, ssh_port, start_port, init_script, bandwidth_quota_gb, cpu, mem_mb, disk_gb, created_at, status, ipv6_address, color
`SELECT id, name, pass_hash, idx, ip, ssh_port, start_port, init_script, bandwidth_quota_gb, cpu, mem_mb, disk_gb, created_at, status, ipv6_address, color, expires_at
FROM users ORDER BY idx`)
if err != nil {
return nil, err
@@ -215,7 +220,7 @@ func (d *DB) ListUsers() ([]*User, error) {
u := &User{}
var ipv6 sql.NullString
if err := rows.Scan(&u.ID, &u.Name, &u.PassHash, &u.Idx, &u.IP, &u.SSHPort, &u.StartPort,
&u.InitScript, &u.BandwidthQuotaGB, &u.CPU, &u.MemMB, &u.DiskGB, &u.CreatedAt, &u.Status, &ipv6, &u.Color); err != nil {
&u.InitScript, &u.BandwidthQuotaGB, &u.CPU, &u.MemMB, &u.DiskGB, &u.CreatedAt, &u.Status, &ipv6, &u.Color, &u.ExpiresAt); err != nil {
return nil, err
}
u.IPv6Address = ipv6.String
@@ -264,6 +269,13 @@ func (d *DB) UpdateUserColor(id int64, color string) error {
return err
}
// UpdateUserExpiry sets a user's quota validity deadline (RFC3339 UTC, "" =
// permanent).
func (d *DB) UpdateUserExpiry(id int64, expiresAt string) error {
_, err := d.sql.Exec(`UPDATE users SET expires_at=? WHERE id=?`, expiresAt, id)
return err
}
// UsedIPv6Addresses returns the set of pool-mode IPv6 addresses currently
// assigned to users, so the manager can pick a free one and the UI can list
// the free remainder.
+8
View File
@@ -473,6 +473,14 @@ func (c *Client) Stop(name string) error {
stateAction{Action: "stop"}, 2*time.Minute)
}
// ForceStop stops a container immediately (force, no graceful shutdown
// timeout). Used by quota-expiry enforcement, where the guest may be
// uncooperative: the same body Delete uses before removing a running instance.
func (c *Client) ForceStop(name string) error {
return c.sendOp(http.MethodPut, "/1.0/instances/"+url.PathEscape(name)+"/state",
stateAction{Action: "stop", Force: true, Timeout: -1}, 2*time.Minute)
}
func (c *Client) Restart(name string) error {
if err := c.sendOp(http.MethodPut, "/1.0/instances/"+url.PathEscape(name)+"/state",
stateAction{Action: "restart"}, 2*time.Minute); err != nil {
+130
View File
@@ -0,0 +1,130 @@
package mgr
import (
"fmt"
"time"
"vpsmgr/internal/db"
)
// expiryLayout is the RFC3339 UTC format stored in users.expires_at.
const expiryLayout = time.RFC3339
// IsExpired reports whether an expiry timestamp (RFC3339 UTC, "" = permanent)
// has passed. Pure.
func IsExpired(expiresAt string, now time.Time) bool {
if expiresAt == "" {
return false
}
t, err := time.Parse(expiryLayout, expiresAt)
if err != nil {
return false
}
return now.After(t)
}
// ExpiryRemaining returns the time left until the deadline, or 0 for a
// permanent/unparseable value. Negative once past.
func ExpiryRemaining(expiresAt string, now time.Time) time.Duration {
if expiresAt == "" {
return 0
}
t, err := time.Parse(expiryLayout, expiresAt)
if err != nil {
return 0
}
return t.Sub(now)
}
// ExpiryFromDays converts a validity in days to an absolute RFC3339 UTC
// timestamp (0 or negative = permanent, i.e. "").
func ExpiryFromDays(days int) string {
if days <= 0 {
return ""
}
return time.Now().UTC().AddDate(0, 0, days).Format(expiryLayout)
}
// ExtendExpiry computes a new deadline by adding d to the later of now and the
// current deadline, so an already-expired account gets the full duration. A
// permanent account (empty current) gets a fresh deadline of now + d.
func ExtendExpiry(current string, now time.Time, d time.Duration) string {
base := now
if t, err := time.Parse(expiryLayout, current); err == nil && t.After(now) {
base = t
}
return base.Add(d).UTC().Format(expiryLayout)
}
// SetExpiry stores an absolute deadline ("" = permanent) and returns it.
func (m *Manager) SetExpiry(name, expiresAt string) (string, error) {
u, err := m.db.GetUserByName(name)
if err != nil {
return "", err
}
if err := m.db.UpdateUserExpiry(u.ID, expiresAt); err != nil {
return "", err
}
return expiresAt, nil
}
// ExtendExpiryFor extends a user's deadline by d (max(now, current) + d) and
// returns the new deadline.
func (m *Manager) ExtendExpiryFor(name string, d time.Duration) (string, error) {
u, err := m.db.GetUserByName(name)
if err != nil {
return "", err
}
next := ExtendExpiry(u.ExpiresAt, time.Now().UTC(), d)
if err := m.db.UpdateUserExpiry(u.ID, next); err != nil {
return "", err
}
return next, nil
}
// EnforceQuotaExpiry force-stops every container whose quota validity has
// passed. Called by the 60s sampler only (single goroutine). It acts only while
// a container is not stopped, so it is idempotent across passes: a container
// already down is left alone, and a manual start is re-stopped on the next
// pass. Autostart is disabled so the container cannot come back on its own.
//
// The container (and its snapshots) is never deleted: a shared checkpoint of an
// expired account therefore stays installable by others, and extending the
// deadline restores the machine (a manual start is then required).
func (m *Manager) EnforceQuotaExpiry() error {
users, err := m.db.ListUsers()
if err != nil {
return err
}
now := time.Now().UTC()
var firstErr error
for _, u := range users {
if u.Status != db.StatusReady || !IsExpired(u.ExpiresAt, now) {
continue
}
st, err := m.lx.State(u.Name)
if err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("expiry check %s: %w", u.Name, err)
}
continue
}
if st == "Stopped" {
continue
}
if err := m.lx.SetAutostart(u.Name, false); err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("expiry disable autostart %s: %w", u.Name, err)
}
continue
}
if err := m.lx.ForceStop(u.Name); err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("expiry force stop %s: %w", u.Name, err)
}
continue
}
_ = m.db.AddAuditLog("000", "quota.expire."+u.Name)
}
return firstErr
}
+74
View File
@@ -0,0 +1,74 @@
package mgr
import (
"testing"
"time"
)
func TestIsExpired(t *testing.T) {
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
cases := []struct {
v string
want bool
}{
{"", false}, // permanent
{"not-a-time", false},
{now.Add(time.Hour).Format(time.RFC3339), false},
{now.Add(-time.Minute).Format(time.RFC3339), true},
}
for _, c := range cases {
if got := IsExpired(c.v, now); got != c.want {
t.Errorf("IsExpired(%q) = %v, want %v", c.v, got, c.want)
}
}
}
func TestExpiryRemaining(t *testing.T) {
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
if got := ExpiryRemaining("", now); got != 0 {
t.Errorf("permanent remaining = %v, want 0", got)
}
if got := ExpiryRemaining("nonsense", now); got != 0 {
t.Errorf("unparseable remaining = %v, want 0", got)
}
if got := ExpiryRemaining(now.Add(90*time.Minute).Format(time.RFC3339), now); got != 90*time.Minute {
t.Errorf("remaining = %v, want 90m", got)
}
}
func TestExtendExpiry(t *testing.T) {
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
d := 30 * 24 * time.Hour
// Future deadline: extension stacks on top of it.
future := now.Add(10 * 24 * time.Hour)
if got, want := ExtendExpiry(future.Format(time.RFC3339), now, d), future.Add(d).Format(time.RFC3339); got != want {
t.Errorf("extend future = %q, want %q", got, want)
}
// Already expired: the full duration counts from now.
past := now.Add(-5 * 24 * time.Hour)
if got, want := ExtendExpiry(past.Format(time.RFC3339), now, d), now.Add(d).Format(time.RFC3339); got != want {
t.Errorf("extend past = %q, want %q", got, want)
}
// Permanent: extending creates a fresh deadline from now.
if got, want := ExtendExpiry("", now, d), now.Add(d).Format(time.RFC3339); got != want {
t.Errorf("extend permanent = %q, want %q", got, want)
}
}
func TestExpiryFromDays(t *testing.T) {
if got := ExpiryFromDays(0); got != "" {
t.Errorf("ExpiryFromDays(0) = %q, want empty", got)
}
if got := ExpiryFromDays(-3); got != "" {
t.Errorf("ExpiryFromDays(-3) = %q, want empty", got)
}
got := ExpiryFromDays(7)
ts, err := time.Parse(time.RFC3339, got)
if err != nil {
t.Fatalf("ExpiryFromDays(7) = %q: %v", got, err)
}
if want := time.Now().UTC().AddDate(0, 0, 7); ts.Sub(want).Abs() > time.Minute {
t.Errorf("ExpiryFromDays(7) = %v, want ~%v", ts, want)
}
}
+6 -6
View File
@@ -43,7 +43,7 @@ func TestPickPoolIPv6Auto(t *testing.T) {
func TestPickPoolIPv6ExplicitAndUsed(t *testing.T) {
m, d := poolTestManager(t, []string{"2001:db8::1", "2001:db8::2"})
// Reserve ::2 by creating a user with it.
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::2"); err != nil {
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::2", ""); err != nil {
t.Fatal(err)
}
// Explicitly picking a used address fails.
@@ -66,7 +66,7 @@ func TestPickPoolIPv6ExplicitAndUsed(t *testing.T) {
func TestPickPoolIPv6Exhausted(t *testing.T) {
m, d := poolTestManager(t, []string{"2001:db8::1"})
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::1"); err != nil {
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::1", ""); err != nil {
t.Fatal(err)
}
got, err := m.pickPoolIPv6("")
@@ -80,7 +80,7 @@ func TestPickPoolIPv6Exhausted(t *testing.T) {
func TestFreePoolIPv6List(t *testing.T) {
m, d := poolTestManager(t, []string{"2001:db8::1", "2001:db8::2", "2001:db8::3"})
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::2"); err != nil {
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::2", ""); err != nil {
t.Fatal(err)
}
free := m.FreePoolIPv6List()
@@ -101,7 +101,7 @@ func TestIPv6PoolUsage(t *testing.T) {
if total != 2 || used != 0 {
t.Errorf("empty pool usage = %d/%d, want 2/0", used, total)
}
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::1"); err != nil {
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::1", ""); err != nil {
t.Fatal(err)
}
total, used, _ = m.IPv6PoolUsage()
@@ -141,7 +141,7 @@ func TestAddPoolIPv6s(t *testing.T) {
func TestRemovePoolIPv6(t *testing.T) {
m, d := poolTestManager(t, []string{"2001:db8::1", "2001:db8::2"})
// Assigned address cannot be removed.
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::1"); err != nil {
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::1", ""); err != nil {
t.Fatal(err)
}
if err := m.RemovePoolIPv6("2001:db8::1"); err == nil {
@@ -163,7 +163,7 @@ func TestRemovePoolIPv6(t *testing.T) {
func TestPoolList(t *testing.T) {
m, d := poolTestManager(t, []string{"2001:db8::1", "2001:db8::2"})
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::1"); err != nil {
if _, err := d.CreateUserFull("alice", "h", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0, db.StatusReady, "2001:db8::1", ""); err != nil {
t.Fatal(err)
}
entries, err := m.PoolList()
+3 -1
View File
@@ -334,6 +334,8 @@ type AddOptions struct {
// Ignored unless the config is in pool mode (prefix mode always assigns
// the deterministic derived address).
IPv6Addr string
// Days is the initial quota validity in days (0 or negative = permanent).
Days int
}
func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
@@ -524,7 +526,7 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
return nil, fmt.Errorf("wire ipv6: %w", err)
}
}
u, err := m.db.CreateUserFull(name, hash, ip, idx, sshPort, startPort, opt.CPU, opt.MemMB, opt.DiskGB, opt.BandwidthGB, db.StatusCreating, poolAddr)
u, err := m.db.CreateUserFull(name, hash, ip, idx, sshPort, startPort, opt.CPU, opt.MemMB, opt.DiskGB, opt.BandwidthGB, db.StatusCreating, poolAddr, ExpiryFromDays(opt.Days))
if err != nil {
cleanup()
return nil, fmt.Errorf("db: %w", err)
+51
View File
@@ -0,0 +1,51 @@
package panel
import (
"net/http"
"net/url"
"strings"
"testing"
"vpsmgr/internal/db"
"vpsmgr/internal/pw"
)
// TestExpiredAccountLocked: an expired account is read-only in the user panel —
// mutations are refused with a clear reason, and the overview shows the lock
// instead of the action buttons.
func TestExpiredAccountLocked(t *testing.T) {
srv, d := newTestServer(t)
h := srv.Handler()
prefix := "/" + testSecret
hash, _ := pw.Hash("pw")
if _, err := d.CreateUserFull("alice", hash, "10.42.0.2", 1, 30001, 10000, 1, 1024, 10, 0,
db.StatusReady, "", "2000-01-01T00:00:00Z"); err != nil {
t.Fatal(err)
}
cookie := loginAndCookie(t, h, prefix, "alice", "pw")
// A mutation is blocked before it reaches the handler.
rr := doReq(t, h, http.MethodPost, prefix+"/power", url.Values{"action": {"start"}}, cookie)
if rr.Code != http.StatusFound {
t.Fatalf("expired POST /power = %d, want 302", rr.Code)
}
fr := doReq(t, h, http.MethodPost, prefix+"/flash", nil, cookie)
if !strings.Contains(strings.ToLower(fr.Body.String()), "expired") {
t.Fatalf("blocked mutation did not report the expiry: %s", fr.Body.String())
}
// The overview shows the banner + locked note and hides the action buttons.
rr = doReq(t, h, http.MethodGet, prefix, nil, cookie)
body := rr.Body.String()
if !strings.Contains(body, "expbanner") {
t.Error("expired overview missing the banner")
}
if !strings.Contains(body, "expiry-remaining") {
t.Error("expired overview missing the validity countdown")
}
for _, absent := range []string{`id="reBtn"`, `id="snapBtn"`, `id="kbBtn"`} {
if strings.Contains(body, absent) {
t.Errorf("expired overview still shows the action button %s", absent)
}
}
}
+28
View File
@@ -24,6 +24,9 @@ const (
// user"). When set, audit events are attributed "000+<user>" and the panel
// shows a "logged in as" banner.
impersonatedKey ctxKey = 1
// expiredKey marks a session whose quota validity has passed. An expired
// account is locked to read-only; only the admin can extend or delete it.
expiredKey ctxKey = 2
)
// loginDummyHash is compared against when the username is unknown so that a
@@ -74,10 +77,30 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
}
ctx := context.WithValue(r.Context(), userKey, u)
ctx = context.WithValue(ctx, impersonatedKey, imp)
ctx = context.WithValue(ctx, expiredKey, mgr.IsExpired(u.ExpiresAt, time.Now().UTC()))
next(w, r.WithContext(ctx))
}
}
// isExpired reports whether the current account's quota validity has passed.
func (s *Server) isExpired(r *http.Request) bool {
v, _ := r.Context().Value(expiredKey).(bool)
return v
}
// requireActive rejects state-changing requests from an expired account. An
// expired container is locked to read-only (the admin can still extend or
// delete it); read-only GET routes stay reachable so the user sees the reason.
func (s *Server) requireActive(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.isExpired(r) {
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_account_expired"))
return
}
next(w, r)
}
}
// isImpersonated reports whether the current session was created by the
// operator ("log in as user").
func (s *Server) isImpersonated(r *http.Request) bool {
@@ -673,6 +696,11 @@ func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
writeNotesJSON(w, true, "", data != "", data)
return
}
// Expired accounts are locked: reads stay open (above), writes do not.
if s.isExpired(r) {
writeNotesJSON(w, false, "account expired", true, "")
return
}
var req struct {
Data string `json:"data"`
}
+1
View File
@@ -91,6 +91,7 @@ func tr(l, key string, args ...any) string {
"snapshot_shared": {"分享码已生成(已移除 %[1]v 个更早的检查点)", "share code created (%[1]v older checkpoint(s) removed)"},
"snapshot_unshared": {"分享已撤销", "share revoked"},
"snapshot_restored": {"已从该检查点恢复", "restored from that checkpoint"},
"err_account_expired": {"账户已到期,机器已锁定,请联系管理员延期。", "Account expired — the machine is locked. Contact the administrator to extend it."},
"ssh_keys_apply_warn": {"机器当前不可达,密钥已保存,将在下次重装后写入。", "Machine unreachable — keys saved and will be applied on the next reinstall."},
}
pair, ok := m[key]
+26 -15
View File
@@ -168,6 +168,9 @@ type pageData struct {
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
@@ -219,24 +222,24 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/logout", s.requireAuth(s.requirePost(s.handleLogout)))
mux.HandleFunc("/switch-user", s.requireAuth(s.requirePost(s.handleSwitchUser)))
mux.HandleFunc("/", s.requireAuth(s.handleOverview))
mux.HandleFunc("/power", s.requireAuth(s.requirePost(s.handlePower)))
mux.HandleFunc("/reinstall", s.requireAuth(s.requirePost(s.handleReinstall)))
mux.HandleFunc("/password", s.requireAuth(s.requirePost(s.handlePanelPassword)))
mux.HandleFunc("/root-reset", s.requireAuth(s.requirePost(s.handleRootReset)))
mux.HandleFunc("/domain-add", s.requireAuth(s.requirePost(s.handleDomainAdd)))
mux.HandleFunc("/domain-del", s.requireAuth(s.requirePost(s.handleDomainDel)))
mux.HandleFunc("/domain-update", s.requireAuth(s.requirePost(s.handleDomainUpdate)))
mux.HandleFunc("/init-script", s.requireAuth(s.requirePost(s.handleInitScript)))
mux.HandleFunc("/power", s.requireAuth(s.requireActive(s.requirePost(s.handlePower))))
mux.HandleFunc("/reinstall", s.requireAuth(s.requireActive(s.requirePost(s.handleReinstall))))
mux.HandleFunc("/password", s.requireAuth(s.requireActive(s.requirePost(s.handlePanelPassword))))
mux.HandleFunc("/root-reset", s.requireAuth(s.requireActive(s.requirePost(s.handleRootReset))))
mux.HandleFunc("/domain-add", s.requireAuth(s.requireActive(s.requirePost(s.handleDomainAdd))))
mux.HandleFunc("/domain-del", s.requireAuth(s.requireActive(s.requirePost(s.handleDomainDel))))
mux.HandleFunc("/domain-update", s.requireAuth(s.requireActive(s.requirePost(s.handleDomainUpdate))))
mux.HandleFunc("/init-script", s.requireAuth(s.requireActive(s.requirePost(s.handleInitScript))))
mux.HandleFunc("/stats", s.requireAuth(s.handleStats))
mux.HandleFunc("/images", s.requireAuth(s.requirePost(s.handleImages)))
mux.HandleFunc("/snapshot", s.requireAuth(s.requirePost(s.handleSnapshot)))
mux.HandleFunc("/snapshot-del", s.requireAuth(s.requirePost(s.handleSnapshotDel)))
mux.HandleFunc("/snapshot-restore", s.requireAuth(s.requirePost(s.handleSnapshotRestore)))
mux.HandleFunc("/snapshot-share", s.requireAuth(s.requirePost(s.handleSnapshotShare)))
mux.HandleFunc("/snapshot-unshare", s.requireAuth(s.requirePost(s.handleSnapshotUnshare)))
mux.HandleFunc("/ssh-keys", s.requireAuth(s.requirePost(s.handleSSHKeys)))
mux.HandleFunc("/snapshot", s.requireAuth(s.requireActive(s.requirePost(s.handleSnapshot))))
mux.HandleFunc("/snapshot-del", s.requireAuth(s.requireActive(s.requirePost(s.handleSnapshotDel))))
mux.HandleFunc("/snapshot-restore", s.requireAuth(s.requireActive(s.requirePost(s.handleSnapshotRestore))))
mux.HandleFunc("/snapshot-share", s.requireAuth(s.requireActive(s.requirePost(s.handleSnapshotShare))))
mux.HandleFunc("/snapshot-unshare", s.requireAuth(s.requireActive(s.requirePost(s.handleSnapshotUnshare))))
mux.HandleFunc("/ssh-keys", s.requireAuth(s.requireActive(s.requirePost(s.handleSSHKeys))))
mux.HandleFunc("/notes", s.requireAuth(s.handleNotes))
mux.HandleFunc("/notes/reset", s.requireAuth(s.requirePost(s.handleNotesReset)))
mux.HandleFunc("/notes/reset", s.requireAuth(s.requireActive(s.requirePost(s.handleNotesReset))))
mux.HandleFunc("/flash", s.requireAuth(s.requirePost(s.handleFlash)))
prefix := s.prefix()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -392,9 +395,17 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData {
QuotaMem: itoa(u.MemMB) + " MiB",
QuotaDisk: itoa(u.DiskGB) + " GiB",
BandwidthResetDay: s.cfg.Panel.BandwidthResetDay,
ExpiresAt: u.ExpiresAt,
Msg: msg,
Err: errMsg,
}
// Quota validity: the client renders the live countdown from ExpiresAt; the
// server flags the expired/locked state (authoritative for enforcement).
{
now := time.Now().UTC()
d.Expired = mgr.IsExpired(u.ExpiresAt, now)
d.ExpiresSoon = u.ExpiresAt != "" && !d.Expired && mgr.ExpiryRemaining(u.ExpiresAt, now) <= 72*time.Hour
}
// Resource usage comes from the persisted sampler snapshot (five-minute
// CPU average plus latest memory/disk), never from a live Incus sample.
d.CPUUse, d.MemUse, d.DiskUsed = s.mgr.PanelResources(u.ID)
+40 -4
View File
@@ -78,6 +78,10 @@ label{font-size:.84rem;color:var(--muted)}
.groupbanner .group-switch{margin-left:auto;flex:none}
.container-index{display:inline-block;background:#dc2626;color:#fff;border:3px solid #7f1d1d;border-radius:6px;padding:.05rem .45rem;font-size:1.25rem;line-height:1.2;font-weight:900;box-shadow:2px 2px 0 #7f1d1d}
@media (prefers-color-scheme: dark){.groupbanner{background:#3b2f10;color:#fcd34d;border-color:#b45309}.container-index{background:#ef4444;color:#fff;border-color:#991b1b;box-shadow:2px 2px 0 #450a0a}}
.expbanner{background:#fee2e2;color:#991b1b;border:2px solid #ef4444;padding:.65rem .85rem;border-radius:8px;margin-bottom:.8rem;font-size:.9rem;font-weight:700}
@media (prefers-color-scheme: dark){.expbanner{background:#3b1414;color:#fca5a5;border-color:#b91c1c}}
.expiry-line{margin:.1rem 0 .7rem;font-size:.82rem}
.locked-note{padding:.5rem 0;font-size:.85rem}
a{color:var(--accent);text-decoration:none}
.domtable{width:100%;border-collapse:collapse;font-size:.85rem;margin:.2rem 0 .6rem}
.domtable th,.domtable td{text-align:left;padding:.3rem .4rem;border-bottom:1px solid var(--border);vertical-align:middle}
@@ -283,6 +287,7 @@ footer a{color:var(--muted);text-decoration:underline}
<div class="grid">
<div class="card">
<h2>{{if eq .Lang "zh"}}机器管理{{else}}Machine{{end}}{{if eq .State "Running"}}<span class="pill up" style="margin-left:.5rem">{{if eq .Lang "zh"}}运行中{{else}}Running{{end}}</span>{{else}}<span class="pill down" style="margin-left:.5rem">{{.State}}</span>{{end}}</h2>
{{if .Expired}}<div class="expbanner">⚠ {{if eq .Lang "zh"}}账户已到期,机器已强制关机并锁定;除管理员延期或删除外的一切操作均被禁用。{{else}}Account expired: the container is force-stopped and locked. Everything is disabled except an admin extending or deleting it.{{end}}</div>{{else if .ExpiresSoon}}<div class="groupbanner">⚠ {{if eq .Lang "zh"}}配额即将到期,请及时延期。{{else}}Quota expiring soon — extend it in time.{{end}}</div>{{end}}
<div class="qgrid">
<div class="qitem"><span class="qlabel">CPU</span><span class="qval">{{.QuotaCPU}}{{if .CPULimited}} <span class="pill down" id="cpuLimitPill" data-until="{{.CPULimitUntil}}" title="{{if eq .Lang "zh"}}受管理员 CPU 动态限制规则限制,到期自动恢复原配额{{else}}capped by the admin's dynamic CPU limit rule; the normal quota returns when the countdown ends{{end}}">{{if eq .Lang "zh"}}限制中{{else}}limited{{end}} <span id="cpuLimitCount"></span></span>{{end}}</span></div>
<div class="qitem"><span class="qlabel">{{if eq .Lang "zh"}}内存{{else}}Memory{{end}}</span><span class="qval">{{.QuotaMem}}</span></div>
@@ -300,6 +305,10 @@ footer a{color:var(--muted);text-decoration:underline}
<div class="bar"><div class="fill" style="width:{{.BandwidthPct}}%"></div></div>
</div>
{{end}}
{{if .ExpiresAt}}<div class="muted expiry-line"><span class="qlabel">{{if eq .Lang "zh"}}有效期{{else}}Validity{{end}}</span> <span class="expiry-remaining" data-expires="{{.ExpiresAt}}">{{.ExpiresAt}}</span></div>{{end}}
{{if .Expired}}
<div class="locked-note muted">{{if eq .Lang "zh"}}机器已锁定,请联系管理员延期后再操作。{{else}}Machine locked — ask the administrator to extend the quota.{{end}}</div>
{{else}}
<div class="btnrow">
<form action="{{.Prefix}}/power" method="post" class="inline"><input type="hidden" name="action" value="start"><button class="btn ok">{{if eq .Lang "zh"}}开机{{else}}Start{{end}}</button></form>
<form action="{{.Prefix}}/power" method="post" class="inline"><input type="hidden" name="action" value="stop"><button class="btn warn">{{if eq .Lang "zh"}}关机{{else}}Stop{{end}}</button></form>
@@ -313,6 +322,7 @@ footer a{color:var(--muted);text-decoration:underline}
<button class="btn" id="initBtn">{{if eq .Lang "zh"}}初始化脚本{{else}}Init script{{end}}</button>
<button class="btn danger" id="reBtn">{{if eq .Lang "zh"}}重装系统{{else}}Reinstall{{end}}</button>
</div>
{{end}}
</div>
<div class="card">
@@ -328,19 +338,19 @@ footer a{color:var(--muted);text-decoration:underline}
{{range .Domains}}
<tr>
<td><code>{{.Domain}}</code></td>
<td><input type="checkbox" name="proto" value="{{.Domain}}"{{if .ProxyProtocol}} checked{{end}}></td>
<td><button type="button" class="btn danger" onclick="delDomain('{{.Domain}}')">{{if eq $.Lang "zh"}}删除{{else}}Remove{{end}}</button></td>
<td><input type="checkbox" name="proto" value="{{.Domain}}"{{if .ProxyProtocol}} checked{{end}}{{if $.Expired}} disabled{{end}}></td>
<td><button type="button" class="btn danger" onclick="delDomain('{{.Domain}}')"{{if $.Expired}} disabled{{end}}>{{if eq $.Lang "zh"}}删除{{else}}Remove{{end}}</button></td>
</tr>
{{end}}
</tbody>
</table>
<button class="btn accent" type="submit" id="domSave" hidden>{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
<button class="btn accent" type="submit" id="domSave" hidden{{if .Expired}} disabled{{end}}>{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
</form>
{{else}}
<div class="muted">{{if eq .Lang "zh"}}未绑定域名{{else}}No domains bound{{end}}</div>
{{end}}
{{if and .V4Forward .TraefikEnabled}}<form action="{{.Prefix}}/domain-add" method="post">
<div class="frow"><label>{{if eq .Lang "zh"}}新域名{{else}}New domain{{end}}</label><input name="domain" placeholder="example.com"><label class="chk"><input type="checkbox" name="proxy_protocol" value="1"> PROXY v2 <span class="help" tabindex="0" role="button" aria-label="?">?<span class="tip">{{if eq $.Lang "zh"}}443 透传时向后端发送 PROXY 报文以汇报访客真实 IP;需后端支持,默认关闭。{{else}}443 passthrough sends the real client IP via PROXY; needs backend support, off by default.{{end}}</span></span></label><button class="btn ok" type="submit">{{if eq .Lang "zh"}}添加{{else}}Add{{end}}</button></div>
<div class="frow"><label>{{if eq .Lang "zh"}}新域名{{else}}New domain{{end}}</label><input name="domain" placeholder="example.com"{{if .Expired}} disabled{{end}}><label class="chk"><input type="checkbox" name="proxy_protocol" value="1"{{if .Expired}} disabled{{end}}> PROXY v2 <span class="help" tabindex="0" role="button" aria-label="?">?<span class="tip">{{if eq $.Lang "zh"}}443 透传时向后端发送 PROXY 报文以汇报访客真实 IP;需后端支持,默认关闭。{{else}}443 passthrough sends the real client IP via PROXY; needs backend support, off by default.{{end}}</span></span></label><button class="btn ok" type="submit"{{if .Expired}} disabled{{end}}>{{if eq .Lang "zh"}}添加{{else}}Add{{end}}</button></div>
</form>
{{else}}<div class="muted">{{if eq .Lang "zh"}}{{if .V4Forward}}Traefik 已关闭,无法添加域名。{{else}}IPv4 入站已关闭,无法添加域名。{{end}}{{else}}{{if .V4Forward}}Traefik is disabled — domains can't be added.{{else}}IPv4 inbound is off — domains can't be added.{{end}}{{end}}</div>{{end}}
</div>
@@ -1055,6 +1065,32 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
pre.appendChild(b);
});
})();
// Quota validity countdown: renders "剩余 N 天 M 小时" (or 已到期) from the
// server-provided absolute deadline, refreshed every minute. Read-only; the
// server remains authoritative for whether the account is locked.
(function(){
var els=document.querySelectorAll('.expiry-remaining');
if(!els.length)return;
var LANG='{{.Lang}}';
function label(ms){
if(ms<=0)return LANG==='zh'?'已到期':'expired';
var m=Math.floor(ms/60000), d=Math.floor(m/1440), h=Math.floor((m%1440)/60), mi=m%60;
if(LANG==='zh'){ return d>0?('剩余 '+d+' 天 '+h+' 小时'):('剩余 '+h+' 小时 '+mi+' 分'); }
return d>0?('left '+d+'d '+h+'h'):('left '+h+'h '+mi+'m');
}
function render(){
els.forEach(function(el){
var t=new Date(el.getAttribute('data-expires')).getTime();
if(isNaN(t))return;
var ms=t-Date.now();
el.textContent=label(ms);
el.title=new Date(t).toLocaleString();
el.style.color = ms<=0 ? '#dc2626' : (ms<=72*3600000 ? '#b45309' : '');
});
}
render();
setInterval(render,60000);
})();
// Resource usage charts (last 24h, per-minute). Vanilla canvas, no deps:
// shared pan/zoom view across the three charts, adaptive time ticks, stable
// Y axes (computed once from the full 24h window, not per zoom level).
+37 -6
View File
@@ -196,8 +196,8 @@ func usage() {
fmt.Print(`vps ` + ver.Version + `
usage:
vps list [name] all users, or one user's detail
vps add <name> [--cpu 1] [--mem 1G] [--disk 10G] [--bandwidth 100]
vps quota <name> [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 200]
vps add <name> [--cpu 1] [--mem 1G] [--disk 10G] [--bandwidth 100] [--days 30]
vps quota <name> [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 200] [--days 30] [--clear-expiry]
vps power <name> start|stop|restart
vps passwd <name> reissue user panel password (shown once)
vps admin-passwd reset admin panel password (shown once)
@@ -210,6 +210,8 @@ system:
cpu: whole cores >= 1 (e.g. --cpu 2), or a fraction of one core in 0.1..0.9
(e.g. --cpu 0.5 — the container is pinned to one core with a time slice)
bandwidth: monthly quota in GiB (upload + download combined); 0 or empty = unlimited
days: quota validity in days (add: from now; quota: extend by); empty = permanent,
use 'vps quota <name> --clear-expiry' to remove a deadline
`)
}
@@ -767,6 +769,9 @@ func sampleResourceLoop(m *mgr.Manager) {
if err := m.EnforceCPULimits(); err != nil {
log.Printf("cpu dynamic limit enforcement: %v", err)
}
if err := m.EnforceQuotaExpiry(); err != nil {
log.Printf("quota expiry enforcement: %v", err)
}
tick := time.NewTicker(mgr.BandwidthInterval)
defer tick.Stop()
for range tick.C {
@@ -780,6 +785,9 @@ func sampleResourceLoop(m *mgr.Manager) {
if err := m.EnforceCPULimits(); err != nil {
log.Printf("cpu dynamic limit enforcement: %v", err)
}
if err := m.EnforceQuotaExpiry(); err != nil {
log.Printf("quota expiry enforcement: %v", err)
}
}
}
@@ -1225,10 +1233,12 @@ func userAdd(args []string) error {
fs.SetOutput(io.Discard)
var cpuS string
var memS, diskS, bandwidthS string
var days int
fs.StringVar(&cpuS, "cpu", "", "")
fs.StringVar(&memS, "mem", "", "")
fs.StringVar(&diskS, "disk", "", "")
fs.StringVar(&bandwidthS, "bandwidth", "", "") // GiB/month, 0/empty = unlimited
fs.IntVar(&days, "days", 0, "") // validity in days, 0 = permanent
if err := fs.Parse(args[1:]); err != nil {
return err
}
@@ -1297,6 +1307,9 @@ func userAdd(args []string) error {
bandwidth, _ = mgr.ParseBandwidthGB(s)
}
}
if days < 0 {
return fmt.Errorf("--days must be >= 0 (0 = permanent)")
}
c, err := cfg.Load()
if err != nil {
@@ -1308,7 +1321,7 @@ func userAdd(args []string) error {
}
defer d.Close()
m := mgr.New(c, d)
res, err := m.Add(name, mgr.AddOptions{CPU: cpu, MemMB: mem, DiskGB: disk, BandwidthGB: bandwidth})
res, err := m.Add(name, mgr.AddOptions{CPU: cpu, MemMB: mem, DiskGB: disk, BandwidthGB: bandwidth, Days: days})
if err != nil {
return err
}
@@ -1336,17 +1349,21 @@ func userDel(name string) error {
func userQuota(args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: vps quota <name> [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 100]")
return fmt.Errorf("usage: vps quota <name> [--cpu 2] [--mem 2G] [--disk 20G] [--bandwidth 100] [--days 30] [--clear-expiry]")
}
name := args[0]
fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var cpuS string
var memS, diskS, bandwidthS string
var days int
var clearExpiry bool
fs.StringVar(&cpuS, "cpu", "", "")
fs.StringVar(&memS, "mem", "", "")
fs.StringVar(&diskS, "disk", "", "")
fs.StringVar(&bandwidthS, "bandwidth", "", "") // GiB/month, 0 = unlimited
fs.IntVar(&days, "days", 0, "") // extend validity by N days
fs.BoolVar(&clearExpiry, "clear-expiry", false, "")
if err := fs.Parse(args[1:]); err != nil {
return err
}
@@ -1430,12 +1447,14 @@ func userQuota(args []string) error {
}
bandwidthChanged := setBandwidth || bandwidthGB != u.BandwidthQuotaGB
if cpu == u.CPU && mem == u.MemMB && disk == u.DiskGB && !bandwidthChanged {
setDays, clearExpiry := provided["days"], provided["clear-expiry"]
expiryChanged := setDays || clearExpiry
if cpu == u.CPU && mem == u.MemMB && disk == u.DiskGB && !bandwidthChanged && !expiryChanged {
if inter.IsTTY() {
fmt.Println("no changes, exiting")
return nil
}
return fmt.Errorf("nothing to update: pass at least one of --cpu/--mem/--disk/--bandwidth")
return fmt.Errorf("nothing to update: pass at least one of --cpu/--mem/--disk/--bandwidth/--days/--clear-expiry")
}
if disk < u.DiskGB {
return fmt.Errorf("disk can only grow: current %d GiB, cannot shrink to %d GiB", u.DiskGB, disk)
@@ -1451,6 +1470,18 @@ func userQuota(args []string) error {
return err
}
}
if clearExpiry {
if _, err := m.SetExpiry(name, ""); err != nil {
return err
}
} else if setDays {
if days <= 0 {
return fmt.Errorf("--days must be positive (use --clear-expiry to remove the deadline)")
}
if _, err := m.ExtendExpiryFor(name, time.Duration(days)*24*time.Hour); err != nil {
return err
}
}
fresh, err := d.GetUserByName(name)
if err != nil {
return err