diff --git a/docs/architecture.md b/docs/architecture.md index 634093a..396400d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,17 +128,13 @@ The panel daemon runs as the dedicated unprivileged `vps` system user 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`), deleting the account, and either - side changing the **panel login password** (the user via the panel, the admin - via reset-password), which stays available so the account never becomes - unreachable. 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. + loop force-stops the container and disables `boot.autostart`, and the account + goes read-only for both sides. The admin can still extend it + (`max(now, current) + duration`), delete the account, and reset its panel + password; the user can still change their own panel password. Extending lifts + the lock but leaves autostart off, so the container needs a manual start. 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 @@ -265,26 +261,20 @@ The panel daemon runs as the dedicated unprivileged `vps` system user carries the checkpoint's state). Cloning from a checkpoint **pins** it: ZFS cannot roll the source back past a - snapshot that a clone depends on. Publishing therefore deletes the older - checkpoints (the panel warns with the exact count first — they could never be - restored to again once a clone exists) and keeps only the published one. On - deleting the snapshot, reinstalling or deleting the container, the share is - revoked with it (`ReinstallFromShare` reports a clear error rather than - building a half-container). A code that points at the **same** container being - reinstalled is treated as a plain restore, since cloning-then-deleting would - destroy the snapshot first. + snapshot a clone depends on. Publishing therefore deletes the older + checkpoints, keeping only the published one; the panel warns with the exact + count first. Deleting the checkpoint, reinstalling or deleting the container + revokes the share with it. A code pointing at the **same** container being + reinstalled is a plain restore (cloning first would destroy the snapshot). - The whole feature is **opt-in and off by default** (new installs and upgrades - alike): enable it with `vps config set snapshots.share true`. It is a CLI-only - setting — there is no admin-panel UI for it. While off, the user-side - share/import entry points are hidden and the manager refuses new shares and - installs, but the stored codes are kept and every container and checkpoint is - left alone, so switching it back on restores the codes for as long as their - checkpoint still exists. The code→checkpoint mapping is only ever removed when - the checkpoint itself is deleted (from the panel) or the container goes away, - and a code whose checkpoint has vanished is reported as invalid instead of - half-building a container. Installs are audited as `reinstall.share.`, - sharing as `snapshot.share.`. + The feature is **off by default** and has no panel UI: enable it with `vps + config set snapshots.share true`. While off, the user-side entry points are + hidden and new shares and installs are refused; stored codes and every + container and checkpoint are left alone, so re-enabling restores the codes as + long as their checkpoint exists. A code is dropped only when its checkpoint is + deleted or its container goes away, and a code whose checkpoint has vanished + fails with an error instead of half-building a container. Installs are audited + as `reinstall.share.`, shares as `snapshot.share.`. ### User groups (multi-container users) @@ -364,14 +354,12 @@ no driver branch in the panel code, and the single driver-aware helper format, or modify secondary disks. The loop file only allocates blocks as the pool actually fills. On very small hosts, cap the ZFS ARC (`zfs.arc_max`) so container memory keeps priority over the pool's cache. New containers get a - **hard** disk limit: the pool carries `volume.zfs.use_refquota=true` (set by - `10-incus.sh` on creation and re-asserted on every run, plus an - `EnsurePoolRefQuota` pass in `vps install`), so a volume's disk usage counts - every block it references — including blocks inherited from the image or a - shared checkpoint through CoW clones. Without it, ZFS's default `quota` - charges a clone only for its own delta, so a limit would not match what the - user sees inside the container. Existing volumes are untouched: pool defaults - apply to new volumes only. + **hard** disk limit: the pool sets `volume.zfs.use_refquota=true` + (`10-incus.sh` plus an `EnsurePoolRefQuota` pass in `vps install`), so a + volume's usage counts every block it references, including blocks shared + through CoW clones. ZFS's default `quota` charges a clone only for its own + delta, which would stop matching what the user sees inside the container. + Existing volumes are untouched — pool defaults apply to new ones only. - **btrfs (beta)**. Selecting `VPSMGR_STORAGE=btrfs` is flagged as beta and `install.sh` asks for an explicit confirmation up front (default yes — on a btrfs root there is no ZFS fallback). When `/` is itself a btrfs filesystem, @@ -537,16 +525,13 @@ into the kernel. bare, headerless 404 (no fingerprint, no auth cost). - Mutating actions are POST-only; sessions are 3-day HttpOnly+Secure+ SameSite=Lax cookies; a per-IP login rate limiter. -- Sessions are **persisted in the DB** (only the SHA-256 of the token is stored, - so a copy of the database cannot be replayed): user sessions in `sessions` - (→ `users`), admin sessions in `admin_sessions` (password-only auth, no user - row). A panel restart — upgrade, crash, `systemctl restart vps` — therefore no - longer logs either side out. Rotation still invalidates the other sessions: an - admin password change drops every admin session but the one that made it. -- The **dynamic CPU limit rule** is panel-owned: it lives in the DB settings - table and is edited on the admin overview, not in `config.yaml` (no - `vps config` key either). Saving applies it immediately; `vps install` leaves - it alone so an upgrade never resets it. +- Sessions are persisted in the DB (hashed tokens, so a copy of the database + cannot be replayed): user sessions in `sessions`, admin sessions in + `admin_sessions`. A panel restart does not log either side out. An admin + password change still drops every other admin session. +- The dynamic CPU limit rule is panel-owned: a DB settings row edited on the + admin overview, with no `config.yaml` key. Saving applies it at once, and + `vps install` never resets it. - The panel daemon is **unprivileged** (see "Unprivileged panel" above): Incus access via group membership, only whitelisted commands via sudo. - Containers are Incus-unprivileged with `security.nesting=true`. @@ -556,12 +541,11 @@ into the kernel. The operator can author Markdown articles in the admin panel (`/knowledge`); users open them read-only from a **知识库 / Knowledge base** button on the machine card, which opens a large overlay (nearly fullscreen on phones). Content -is stored verbatim in the `knowledge` table and rendered **server-side** by -`internal/markdown` — a hand-written, dependency-free renderer for a basic -subset (headings, fenced code, inline code, bold/italic, links, lists, -blockquotes, rules). It escapes HTML first and only allows http/https/mailto/#/ -links, so article content can never inject markup. The rendered HTML is embedded -in the panel page; a small script adds a copy button to each code block. +lives in the `knowledge` table and is rendered **server-side** by +`internal/markdown`, a dependency-free renderer for a basic subset (headings, +fenced code, inline code, bold/italic, links, lists, quotes, rules). It escapes +HTML first and allows only http/https/mailto/#/ links, so articles cannot inject +markup. A small script adds a copy button to each code block. ## Bandwidth accounting diff --git a/docs/configuration.md b/docs/configuration.md index bfa79b7..694f5f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,18 +53,14 @@ same table; `vps config list` shows the live values with this annotation. | `incus.socket` | operator | restart panel | Incus daemon Unix socket | | `incus.swap_ratio` | operator | **applied immediately** | swap granted to each container as a multiple of its memory limit (`limits.memory.swap = limits.memory × ratio`); `0` disables container swap. Setting it re-applies the allowance to **all existing containers** (no restart) | | `snapshots.limit` | operator | restart panel | max checkpoints a user may keep per container (`0` = disable new snapshots). Restoring to an older checkpoint auto-deletes the ones created after it (see below) | -| `snapshots.share` | operator | **applied immediately** | allow users to share a checkpoint behind a code and install from it. **Off by default** (new installs and upgrades alike) and CLI-only — no panel UI: `vps config set snapshots.share true`. Stored codes are kept while it is off | +| `snapshots.share` | operator | **applied immediately** | allow users to share a checkpoint behind a code and install from it. **Off by default**; CLI-only: `vps config set snapshots.share true`. Stored codes survive while it is off | ### Settings that are NOT in this file -The **dynamic CPU limit rule** (cap a container to Z cores for N after it stays -over Y% of its own quota for X minutes in a row) is edited in the **admin -panel** (the "Dynamic CPU limit rule" card on the overview) and stored in the -DB — it is deliberately not a `config.yaml` option and has no `vps config` key, -because it is tuned repeatedly and must apply immediately. Saving it applies the -new rule on the spot (any container already over the threshold is capped, and -turning the rule off restores every capped container), and `vps install` never -resets it. +The **dynamic CPU limit rule** is edited on the admin overview and stored in the +DB: it has no `config.yaml` key and no `vps config` key, because it is tuned +repeatedly and must apply immediately. Saving applies it on the spot (turning it +off restores every capped container), and `vps install` never resets it. ### How "fixed at install" is enforced diff --git a/src/internal/admin/cpu_limit_test.go b/src/internal/admin/cpu_limit_test.go index ecb699c..356379b 100644 --- a/src/internal/admin/cpu_limit_test.go +++ b/src/internal/admin/cpu_limit_test.go @@ -97,10 +97,13 @@ func TestCPULimitCardSave(t *testing.T) { if rr.Code != http.StatusFound { t.Fatalf("disable save = %d, want 302", rr.Code) } - // The message must not claim the rule is in effect when it is off. - if msg := flashMsg(t, h, prefix, cookie); !strings.Contains(msg, "未启用") && - !strings.Contains(msg, "no container is capped") { - t.Errorf("disabled save flash = %q, want it to say the rule is off", msg) + // The message must match what happened: off means nothing was applied. + offMsg := flashMsg(t, h, prefix, cookie) + if !strings.Contains(offMsg, "未启用") && !strings.Contains(offMsg, "disabled") { + t.Errorf("disabled save flash = %q, want it to say the rule is off", offMsg) + } + if strings.Contains(offMsg, "立即生效") || strings.Contains(offMsg, "applied now") { + t.Errorf("disabled save claims the rule is in effect: %q", offMsg) } off := want off.Enabled = false diff --git a/src/internal/admin/i18n.go b/src/internal/admin/i18n.go index 673cd0e..20a6863 100644 --- a/src/internal/admin/i18n.go +++ b/src/internal/admin/i18n.go @@ -106,13 +106,13 @@ func tr(l, key string, args ...any) string { "knowledge_deleted": {"知识库文章已删除", "knowledge-base article deleted"}, "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_kb_too_large": {"内容超过 256 KiB", "the content exceeds 256 KiB"}, + "err_invalid_days": {"有效期天数必须是非负整数", "validity days must be a non-negative integer"}, "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"}, - "cpu_limit_saved": {"CPU 动态限制规则已保存并立即生效", "dynamic CPU limit rule saved and applied now"}, - "cpu_limit_saved_off": {"CPU 动态限制规则已保存:当前未启用,不限制任何容器", "dynamic CPU limit rule saved — it is disabled, so no container is capped"}, + "cpu_limit_saved": {"规则已保存并立即生效", "rule saved and applied now"}, + "cpu_limit_saved_off": {"规则已保存,当前未启用", "rule saved; it is disabled"}, } pair, ok := m[key] if !ok { diff --git a/src/internal/admin/templates/admin_knowledge.html b/src/internal/admin/templates/admin_knowledge.html index 600d445..c7a0c68 100644 --- a/src/internal/admin/templates/admin_knowledge.html +++ b/src/internal/admin/templates/admin_knowledge.html @@ -110,7 +110,7 @@ label { display: block; font-size: 12px; color: #6b7280; margin: 10px 0 4px; } - +
@@ -120,7 +120,7 @@ label { display: block; font-size: 12px; color: #6b7280; margin: 10px 0 4px; }
{{if eq .Lang "zh"}}预览{{else}}Preview{{end}}
-
{{if eq .Lang "zh"}}点击“预览”查看渲染结果。{{else}}Click “Preview” to render.{{end}}
+
{{if eq .Lang "zh"}}—{{else}}—{{end}}
diff --git a/src/internal/admin/templates/admin_overview.html b/src/internal/admin/templates/admin_overview.html index c8445ec..9fca39d 100644 --- a/src/internal/admin/templates/admin_overview.html +++ b/src/internal/admin/templates/admin_overview.html @@ -232,7 +232,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
-
+
{{if .IPv6PoolMode}}
{{if eq .Lang "zh"}}启用动态限制{{else}}Enable the dynamic limit{{end}} -

{{if eq .Lang "zh"}}当前未启用:所有容器按各自配额运行,不做任何限制。{{else}}Currently disabled: every container runs on its own quota, nothing is capped.{{end}}

-

{{if eq .Lang "zh"}}当前已启用,保存后立即生效(判定基于每分钟采样的 CPU 使用率,最多延迟 1 分钟触发)。{{else}}Currently enabled; saving applies it at once (judged from the per-minute CPU samples, so it can take up to a minute to trigger).{{end}}

+

{{if eq .Lang "zh"}}未启用:不限制任何容器。{{else}}Disabled: no container is capped.{{end}}

+

{{if eq .Lang "zh"}}已启用,保存后立即生效,最多延迟 1 分钟触发。{{else}}Enabled: saving applies it at once; it can take a minute to trigger.{{end}}

@@ -357,7 +357,6 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
- {{if eq .Lang "zh"}}保存后的效果取决于上方的勾选状态。{{else}}What saving does depends on the checkbox above.{{end}}
@@ -402,7 +401,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
-
+
@@ -426,7 +425,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; } - +
@@ -438,12 +437,12 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; } - +
- {{if eq .Lang "zh"}}新到期时间 = max(现在, 当前到期) + 所选时长{{else}}new deadline = max(now, current) + chosen duration{{end}} +
diff --git a/src/internal/panel/handlers.go b/src/internal/panel/handlers.go index b472f09..77e601f 100644 --- a/src/internal/panel/handlers.go +++ b/src/internal/panel/handlers.go @@ -611,7 +611,11 @@ func (s *Server) handleSnapshotShare(w http.ResponseWriter, r *http.Request) { return } _ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.share."+name) - s.redirect(w, r, s.p(""), s.t(r, "snapshot_shared", len(removed))) + msg := "snapshot_shared" + if len(removed) > 0 { + msg = "snapshot_shared_removed" + } + s.redirect(w, r, s.p(""), s.t(r, msg, len(removed))) } // handleSnapshotUnshare revokes the user's share code. diff --git a/src/internal/panel/i18n.go b/src/internal/panel/i18n.go index 0953a78..662ab81 100644 --- a/src/internal/panel/i18n.go +++ b/src/internal/panel/i18n.go @@ -81,18 +81,19 @@ func (s *Server) t(r *http.Request, key string, args ...any) string { // zh and one en variant toggled by {{if eq .Lang "zh"}}. func tr(l, key string, args ...any) string { m := map[string][2]string{ // key -> [zh, en] - "err_bad_login": {"用户名或密码错误", "invalid credentials"}, - "err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"}, - "err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"}, - "err_pass_short": {"密码长度不得低于 10 位", "password must be at least 10 characters"}, - "err_pass_weak": {"密码需同时包含大写字母、小写字母和数字", "password must contain uppercase letters, lowercase letters and digits"}, - "new_root_password": {"新的 root 密码:\n%[1]v", "New root password:\n%[1]v"}, - "reinstall_done": {"重装完成,新的 root 密码:\n%[1]v", "Reinstall complete. Root password:\n%[1]v"}, - "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."}, + "err_bad_login": {"用户名或密码错误", "invalid credentials"}, + "err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"}, + "err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"}, + "err_pass_short": {"密码长度不得低于 10 位", "password must be at least 10 characters"}, + "err_pass_weak": {"密码需同时包含大写字母、小写字母和数字", "password must contain uppercase letters, lowercase letters and digits"}, + "new_root_password": {"新的 root 密码:\n%[1]v", "New root password:\n%[1]v"}, + "reinstall_done": {"重装完成,新的 root 密码:\n%[1]v", "Reinstall complete. Root password:\n%[1]v"}, + "snapshot_shared": {"分享码已生成", "share code created"}, + "snapshot_shared_removed": {"分享码已生成,已删除 %[1]v 个更早的检查点", "share code created — %[1]v older checkpoint(s) deleted"}, + "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] if !ok { diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index df9e49d..5982e74 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -290,7 +290,7 @@ footer a{color:var(--muted);text-decoration:underline}

{{if eq .Lang "zh"}}机器管理{{else}}Machine{{end}}{{if eq .State "Running"}}{{if eq .Lang "zh"}}运行中{{else}}Running{{end}}{{else}}{{.State}}{{end}}

-{{if .Expired}}
⚠ {{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}}
{{else if .ExpiresSoon}}
⚠ {{if eq .Lang "zh"}}配额即将到期,请及时延期。{{else}}Quota expiring soon — extend it in time.{{end}}
{{end}} +{{if .Expired}}
⚠ {{if eq .Lang "zh"}}账户已到期,机器已关机并锁定。{{else}}Account expired: the container is stopped and locked.{{end}}
{{else if .ExpiresSoon}}
⚠ {{if eq .Lang "zh"}}配额即将到期,请及时延期。{{else}}Quota expiring soon — extend it in time.{{end}}
{{end}}
CPU{{.QuotaCPU}}{{if .CPULimited}} {{if eq .Lang "zh"}}限制中{{else}}limited{{end}} {{end}}
{{if eq .Lang "zh"}}内存{{else}}Memory{{end}}{{.QuotaMem}}
@@ -626,10 +626,10 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}
{{if .ShareEnabled}}
- +
- + {{end}}
@@ -1065,8 +1065,8 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}} }; window.restoreSnap=function(n){ ask(T('恢复检查点','Restore checkpoint'), - T('恢复到检查点 '+n+' 将丢失当前数据以及此后的所有检查点,确认?', - 'Restoring to checkpoint '+n+' loses the current data and all later checkpoints. Confirm?'), + T('恢复到 '+n+' 会丢失当前数据和此后的检查点。', + 'Restoring to '+n+' loses the current data and every later checkpoint.'), T('恢复','Restore')).then(function(ok){if(ok)submitSnap('/snapshot-restore',{name:n});}); }; // Publish a checkpoint. Publishing also DELETES the older checkpoints (a clone @@ -1078,24 +1078,24 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}} var t=el.getAttribute('data-ts'); if(t&&ts&&t0){ - m1+='\n'+T('一旦有人据此克隆,更早的 '+older+' 个检查点将永久无法再恢复,现在就会被删除。', - 'Once someone clones it, the '+older+' older checkpoint(s) can never be restored to and will be DELETED now.'); + m1+='\n'+T('分享后会删除更早的 '+older+' 个检查点。', + 'Sharing also deletes the '+older+' older checkpoint(s).'); } ask(T('分享检查点','Share checkpoint'),m1,T('继续','Continue')).then(function(ok){ if(!ok)return false; if(older<=0)return true; - return ask(T('再次确认','Confirm again'), - T('这是第二步确认:更早的 '+older+' 个检查点现在会被删除且无法恢复,确定继续分享吗?', - 'Second confirmation: the '+older+' older checkpoint(s) will be deleted now and cannot be recovered. Share anyway?'), - T('确定分享并删除','Share and delete')); + return ask(T('确认删除','Confirm deletion'), + T('更早的 '+older+' 个检查点会被删除,无法恢复。', + 'The '+older+' older checkpoint(s) will be deleted and cannot be recovered.'), + T('分享并删除','Share and delete')); }).then(function(ok){if(ok)submitSnap('/snapshot-share',{name:n});}); }; window.unshareSnap=function(){ ask(T('撤销分享','Revoke share'), - T('撤销分享?已据此安装的容器不受影响。','Revoke the share? Containers already installed from it are unaffected.'), + T('已据此安装的容器不受影响。','Containers already installed from it are unaffected.'), T('撤销','Revoke')).then(function(ok){if(ok)submitSnap('/snapshot-unshare',{});}); }; // Create form and all destructive ops: keep the hash so the modal reopens @@ -1918,7 +1918,7 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}} }); document.getElementById('notesForgotBtn').addEventListener('click',function(){ ask(T('重置便利贴','Reset sticky notes'), - T('是否重置所有便利贴内容?(重置后无法恢复)','Reset all sticky notes? This cannot be undone.'), + T('所有便利贴内容会被清空。','Every sticky note will be cleared.'), T('重置','Reset')).then(function(ok){ if(!ok)return; fetch(P+'/notes/reset',{method:'POST',credentials:'same-origin'}).then(function(r){return r.json();}).then(function(d){