feat(snapshot): share a checkpoint to install from it

- a user can publish one checkpoint behind a random v4-UUID code; another
  user enters that code in the reinstall dialog and gets a container cloned
  copy-on-write from the snapshot (no image is built, so no extra disk until
  it diverges), re-provisioned as their own: own IP/ports/disk quota and
  network, a fresh hostname/root password/machine-id, the source's
  authorized_keys cleared, and no init script run
- publishing deletes the older checkpoints (a clone pins its origin, so they
  could never be restored to again); the panel warns with the exact count.
  One share per container; deleting the checkpoint/container or reinstalling
  revokes it, and a code for the container itself is handled as a restore
- clone via POST /1.0/instances with source.type=copy, then delete the old
  container and rename the clone into place; new lx.CloneFromSnapshot /
  lx.RenameInstance and a shared instanceSpec() so a clone is configured
  exactly like a fresh container
- new snapshot_shares table (schema v14) and /snapshot-share /
  /snapshot-unshare panel routes plus the time-machine and reinstall UI
- also fix a refused restore (a checkpoint pinned by a clone) so the
  container is always left running, and recognise the "subsequent internal
  snapshot(s)" refusal message alongside the existing one
- also give new containers a HARD disk limit: the pool carries
  volume.zfs.use_refquota so a volume's usage counts the blocks it
  references (including inherited image/snapshot blocks); existing
  containers are untouched

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-15 15:57:30 +00:00
1 parent 0e4528ad85
commit 2363b89743
15 files changed
+1033 -66

No files matched your search

+31 -1
View File
@@ -239,6 +239,28 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
container deletes every checkpoint, which the reinstall dialog warns about
with the exact count.
- **Snapshot sharing (install from someone's checkpoint)**: a user can publish
one checkpoint behind a random v4-UUID code (`snapshot_shares`, one row per
user). Another user enters that code in the reinstall dialog and gets a
container cloned **copy-on-write** from the checkpoint — no image is built, so
it costs no real disk until it diverges. The clone is then re-provisioned as
the new owner's container: own IP/ports/quota/network, a fresh random
hostname, a new root password, a fresh machine-id, and the source's
`authorized_keys` cleared before the new owner's keys are written. The source
container and its checkpoint are untouched, so one code can serve many
installs, and the init script is deliberately not run (the clone already
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.
### User groups (multi-container users)
One login can manage several containers by grouping panel accounts under a
@@ -316,7 +338,15 @@ no driver branch in the panel code, and the single driver-aware helper
80% by default, 90% when ≥ 20 GiB free. New installations never scan, select,
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.
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.
- **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,
+19 -1
View File
@@ -249,6 +249,16 @@ if [[ $LOOP_BACKED -eq 1 ]]; then
SIZE_LINE=" size: \"${POOL_SIZE_MB}MiB\""
fi
# ZFS volumes created from here on use refquota: the disk limit counts all the
# blocks a container references (a hard cap), instead of the default `quota`
# that only charges a CoW clone's own delta and leaves inherited snapshot data
# unaccounted. Only new volumes are affected — existing containers keep their
# accounting. No-op for non-ZFS drivers.
REFQUOTA_LINE=""
if [[ "$DRIVER" == "zfs" ]]; then
REFQUOTA_LINE=" volume.zfs.use_refquota: \"true\""
fi
# --- incus admin init (preseed) ---
# IPv6 pass-through: when VPSMGR_IPV6_SUBNET is set, incusbr0 carries the global
# prefix (no NAT) and containers SLAAC global addresses from it. The bridge
@@ -289,6 +299,7 @@ storage_pools:
- config:
$SRC_LINE
$SIZE_LINE
$REFQUOTA_LINE
description: ""
name: $POOL
driver: $DRIVER
@@ -318,7 +329,7 @@ EOF
case "$STORAGE" in
zfs)
[[ -n "$POOL_SIZE_MB" ]] || die "no loop-file size decided for the ZFS pool"
incus storage create "$POOL" zfs size="${POOL_SIZE_MB}MiB" || die "zfs pool creation (loop file) failed"
incus storage create "$POOL" zfs size="${POOL_SIZE_MB}MiB" volume.zfs.use_refquota=true || die "zfs pool creation (loop file) failed"
;;
btrfs)
if [[ -n "$BTRFS_SOURCE" ]]; then
@@ -355,6 +366,13 @@ incus profile device set default eth0 parent incusbr0 2>/dev/null || true
# that let any tenant enumerate every other user's username with a subnet scan.
incus network set incusbr0 dns.mode=none 2>/dev/null || true
# ZFS hard-quota default: re-assert on EVERY run (fresh install and upgrade
# alike) so a pool created before this setting still gives NEW containers a
# refquota hard limit. Existing volumes are left as they are.
if [[ "$(incus storage show "$POOL" 2>/dev/null | awk -F': ' '/driver:/{print $2}')" == "zfs" ]]; then
incus storage set "$POOL" volume.zfs.use_refquota=true 2>/dev/null || true
fi
DRIVER_NOW=$(incus storage show "$POOL" | awk -F': ' '/driver:/{print $2}')
log "storage backend: $DRIVER_NOW"
case "$STORAGE" in
+13 -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 = 13
const schemaVersion = 14
// migrations are applied in order, each inside its own transaction. v1 is the
// original schema (baseline); later versions only add/alter, never drop.
@@ -243,6 +243,18 @@ var migrations = []struct {
{13, []string{
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_start_port ON users(start_port)`,
}},
// v14: snapshot shares. A user may publish one checkpoint behind a random
// code; anyone can install a new container by cloning that snapshot. One
// active share per container (UNIQUE(user_id)) — publishing a new one
// replaces the old. Cascades away with the user.
{14, []string{
`CREATE TABLE IF NOT EXISTS snapshot_shares(
code TEXT PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
snapshot TEXT NOT NULL,
created_at TEXT NOT NULL
)`,
}},
}
// sqliteStr quotes a string literal for SQLite by doubling any single quote.
+77
View File
@@ -0,0 +1,77 @@
package db
import (
"database/sql"
"errors"
)
// SnapshotShare is one published checkpoint: a random code that anyone can
// present to clone the owner's snapshot into a fresh container. A user has at
// most one share at a time (see the UNIQUE(user_id) index).
type SnapshotShare struct {
Code string
UserID int64
Snapshot string
CreatedAt string
}
// SetSnapshotShare publishes a share for the user, replacing any existing one.
func (d *DB) SetSnapshotShare(userID int64, code, snapshot string) error {
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM snapshot_shares WHERE user_id=?`, userID); err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO snapshot_shares(code, user_id, snapshot, created_at) VALUES(?,?,?,?)`,
code, userID, snapshot, now()); err != nil {
return err
}
return tx.Commit()
}
// GetSnapshotShare returns the user's current share, or (nil, nil) when none.
func (d *DB) GetSnapshotShare(userID int64) (*SnapshotShare, error) {
var s SnapshotShare
switch err := d.sql.QueryRow(
`SELECT code, user_id, snapshot, created_at FROM snapshot_shares WHERE user_id=?`, userID).
Scan(&s.Code, &s.UserID, &s.Snapshot, &s.CreatedAt); {
case err == nil:
return &s, nil
case errors.Is(err, sql.ErrNoRows):
return nil, nil
default:
return nil, err
}
}
// GetSnapshotShareByCode resolves a share code, or (nil, nil) when unknown.
func (d *DB) GetSnapshotShareByCode(code string) (*SnapshotShare, error) {
var s SnapshotShare
switch err := d.sql.QueryRow(
`SELECT code, user_id, snapshot, created_at FROM snapshot_shares WHERE code=?`, code).
Scan(&s.Code, &s.UserID, &s.Snapshot, &s.CreatedAt); {
case err == nil:
return &s, nil
case errors.Is(err, sql.ErrNoRows):
return nil, nil
default:
return nil, err
}
}
// DeleteSnapshotShare removes the user's share (no-op when there is none).
func (d *DB) DeleteSnapshotShare(userID int64) error {
_, err := d.sql.Exec(`DELETE FROM snapshot_shares WHERE user_id=?`, userID)
return err
}
// DeleteSnapshotShareBySnapshot removes the user's share only when it points at
// the given snapshot, so deleting an unrelated checkpoint does not revoke it.
func (d *DB) DeleteSnapshotShareBySnapshot(userID int64, snapshot string) error {
_, err := d.sql.Exec(`DELETE FROM snapshot_shares WHERE user_id=? AND snapshot=?`, userID, snapshot)
return err
}
+81
View File
@@ -0,0 +1,81 @@
package db
import "testing"
func TestSnapshotShareStore(t *testing.T) {
d, err := Open(t.TempDir() + "/share.db")
if err != nil {
t.Fatal(err)
}
defer d.Close()
alice, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 10, 1024, 10)
if err != nil {
t.Fatal(err)
}
// No share yet.
if s, err := d.GetSnapshotShare(alice.ID); err != nil || s != nil {
t.Fatalf("unset share = %+v, err=%v, want nil", s, err)
}
if err := d.SetSnapshotShare(alice.ID, "code-1", "snap-a"); err != nil {
t.Fatal(err)
}
s, err := d.GetSnapshotShare(alice.ID)
if err != nil || s == nil || s.Code != "code-1" || s.Snapshot != "snap-a" {
t.Fatalf("share = %+v, err=%v", s, err)
}
if byCode, err := d.GetSnapshotShareByCode("code-1"); err != nil || byCode == nil || byCode.UserID != alice.ID {
t.Fatalf("by code = %+v, err=%v", byCode, err)
}
if byCode, err := d.GetSnapshotShareByCode("nope"); err != nil || byCode != nil {
t.Fatalf("unknown code = %+v, err=%v, want nil", byCode, err)
}
// Publishing again replaces the previous share (one per user).
if err := d.SetSnapshotShare(alice.ID, "code-2", "snap-b"); err != nil {
t.Fatal(err)
}
if byCode, _ := d.GetSnapshotShareByCode("code-1"); byCode != nil {
t.Error("old code still resolves after republish")
}
if s, _ := d.GetSnapshotShare(alice.ID); s == nil || s.Code != "code-2" {
t.Fatalf("share not replaced: %+v", s)
}
// Deleting a different snapshot leaves the share alone; the matching one
// clears it.
if err := d.DeleteSnapshotShareBySnapshot(alice.ID, "snap-x"); err != nil {
t.Fatal(err)
}
if s, _ := d.GetSnapshotShare(alice.ID); s == nil {
t.Fatal("share cleared by an unrelated snapshot")
}
if err := d.DeleteSnapshotShareBySnapshot(alice.ID, "snap-b"); err != nil {
t.Fatal(err)
}
if s, _ := d.GetSnapshotShare(alice.ID); s != nil {
t.Fatal("share not cleared for its own snapshot")
}
}
func TestSnapshotShareCascadeOnUserDelete(t *testing.T) {
d, err := Open(t.TempDir() + "/share-cascade.db")
if err != nil {
t.Fatal(err)
}
defer d.Close()
u, err := d.CreateUser("bob", "h", "10.115.0.3", 2, 30002, 10100, 10, 1024, 10)
if err != nil {
t.Fatal(err)
}
if err := d.SetSnapshotShare(u.ID, "code-3", "snap-c"); err != nil {
t.Fatal(err)
}
if err := d.DeleteUser(u.ID); err != nil {
t.Fatal(err)
}
if s, err := d.GetSnapshotShareByCode("code-3"); err != nil || s != nil {
t.Fatalf("share survived user deletion: %+v, err=%v", s, err)
}
}
+78 -14
View File
@@ -878,14 +878,12 @@ var (
}
)
// Launch creates a container with limits, static IPv4 (and optional static
// IPv6 primary address + routed /112 block), root size and autostart enabled,
// then starts it and waits until it is ready. security.nesting allows running
// Docker / nested containers inside.
// pool and bridge name the storage pool and managed bridge (from config).
// cpu is a quota in tenths of a core (see cpuLimitConfig).
// Everything is submitted in ONE create request — the config, the eth0 static
// addresses and the root size — so no follow-up device overrides are needed.
// instanceSpec builds the create-time config and device map for a container:
// the root disk (pool + size), the NIC layout (a single bridged eth0, or a
// routed eth0 + bridged eth1 in pool mode), the CPU/memory limits and the
// hardening options. Shared by Launch (source = image) and CloneFromSnapshot
// (source = a snapshot) so a clone is configured exactly like a fresh container
// — own IP, own quotas — no matter what the source had.
//
// poolIPv6 ("" in prefix mode) switches the NIC layout: the container gets
// TWO NICs — eth0 as a `routed` NIC on the EXTERNAL interface carrying the
@@ -893,12 +891,7 @@ var (
// and eth1 as a `bridged` NIC on the Incus bridge carrying the private IPv4.
// This is the layout that makes "one discrete public /128 per container" work
// (the address is NOT bound on the host's eth0 — see docs/ipv6.md).
func (c *Client) Launch(pool, bridge, name, image, ip, ipv6, block, poolIPv6, extIF string, cpu, memMB, diskGB int) error {
// The create source takes a plain local alias; strip a "remote:" prefix
// (the image is ensured to be cached locally before Launch is called).
if _, local, found := strings.Cut(image, ":"); found {
image = local
}
func (c *Client) instanceSpec(pool, bridge, ip, ipv6, block, poolIPv6, extIF string, cpu, memMB, diskGB int) (map[string]string, map[string]device) {
devices := map[string]device{
"root": {"type": "disk", "path": "/", "pool": pool, "size": strconv.Itoa(diskGB) + "GiB"},
}
@@ -954,6 +947,24 @@ func (c *Client) Launch(pool, bridge, name, image, ip, ipv6, block, poolIPv6, ex
config["limits.processes"] = DefaultProcessesLimit
config["boot.autostart"] = "true"
config["security.nesting"] = "true"
return config, devices
}
// Launch creates a container with limits, static IPv4 (and optional static
// IPv6 primary address + routed /112 block), root size and autostart enabled,
// then starts it and waits until it is ready. security.nesting allows running
// Docker / nested containers inside.
// pool and bridge name the storage pool and managed bridge (from config).
// cpu is a quota in tenths of a core (see cpuLimitConfig).
// Everything is submitted in ONE create request — the config, the eth0 static
// addresses and the root size — so no follow-up device overrides are needed.
func (c *Client) Launch(pool, bridge, name, image, ip, ipv6, block, poolIPv6, extIF string, cpu, memMB, diskGB int) error {
// The create source takes a plain local alias; strip a "remote:" prefix
// (the image is ensured to be cached locally before Launch is called).
if _, local, found := strings.Cut(image, ":"); found {
image = local
}
config, devices := c.instanceSpec(pool, bridge, ip, ipv6, block, poolIPv6, extIF, cpu, memMB, diskGB)
req := createReq{
Name: name,
Source: map[string]string{"type": "image", "alias": image},
@@ -971,6 +982,59 @@ func (c *Client) Launch(pool, bridge, name, image, ip, ipv6, block, poolIPv6, ex
return c.WaitReady(name, 120*time.Second)
}
// CloneFromSnapshot creates newName as a copy-on-write clone of a snapshot of
// srcContainer, configured for its own user (network + quota) exactly like a
// fresh Launch. The clone is left Stopped: the caller renames it into place and
// provisions it (hostname, root password, keys), then starts it. The snapshot
// itself is untouched and can serve further clones.
func (c *Client) CloneFromSnapshot(srcContainer, snapshot, newName, pool, bridge, ip, ipv6, block, poolIPv6, extIF string, cpu, memMB, diskGB int) error {
config, devices := c.instanceSpec(pool, bridge, ip, ipv6, block, poolIPv6, extIF, cpu, memMB, diskGB)
req := createReq{
Name: newName,
Source: map[string]string{"type": "copy", "source": srcContainer + "/" + snapshot},
Config: config,
Devices: devices,
Profiles: []string{"default"},
}
return c.sendOp(http.MethodPost, "/1.0/instances", req, 5*time.Minute)
}
// RenameInstance renames an instance in place. Used to move a clone into the
// name of the container it replaces (the old one is deleted first, so the name
// is free and its snapshots — gone with it — are not needed).
func (c *Client) RenameInstance(oldName, newName string) error {
return c.sendOp(http.MethodPost, "/1.0/instances/"+url.PathEscape(oldName),
map[string]string{"name": newName}, 2*time.Minute)
}
// EnsurePoolRefQuota switches the pool's ZFS volume default to refquota, so
// every NEW container's disk limit counts the blocks it references (a real hard
// cap), instead of the default `quota`, which only charges a CoW clone's own
// delta and lets inherited snapshot data go unaccounted. Existing volumes are
// untouched — pool defaults apply at creation only. No-op on non-ZFS pools
// (btrfs quotas are already a hard limit; `dir` has none). Idempotent.
func (c *Client) EnsurePoolRefQuota(pool string) error {
var p struct {
Driver string `json:"driver"`
Config map[string]string `json:"config"`
}
if err := c.get("/1.0/storage-pools/"+url.PathEscape(pool), &p); err != nil {
return err
}
if p.Driver != "zfs" {
return nil
}
if p.Config["volume.zfs.use_refquota"] == "true" {
return nil
}
if p.Config == nil {
p.Config = map[string]string{}
}
p.Config["volume.zfs.use_refquota"] = "true"
return c.put("/1.0/storage-pools/"+url.PathEscape(pool),
map[string]any{"config": p.Config})
}
// WaitReady waits until the container is running and accepts exec.
func (c *Client) WaitReady(name string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
+51 -8
View File
@@ -1064,7 +1064,12 @@ func (m *Manager) SnapshotDelete(name, snapName string) error {
if !ValidSnapName(snapName) {
return errors.New("invalid snapshot name")
}
return m.lx.SnapshotDelete(u.Name, snapName)
if err := m.lx.SnapshotDelete(u.Name, snapName); err != nil {
return err
}
// A share pointing at this checkpoint is now dead (the snapshot is gone).
_ = m.db.DeleteSnapshotShareBySnapshot(u.ID, snapName)
return nil
}
// SnapshotRestore restores the container disk from a snapshot, keeping the
@@ -1094,6 +1099,14 @@ func (m *Manager) SnapshotRestore(name, snapName string) error {
if !ValidSnapName(snapName) {
return errors.New("invalid snapshot name")
}
return m.snapshotRestoreLocked(u, snapName)
}
// snapshotRestoreLocked restores an already-resolved user's disk from a
// snapshot. The caller holds opMu. On ANY failure the container is returned to
// its original running state (restarted when it was running), so a rejected
// restore never strands the user's container stopped.
func (m *Manager) snapshotRestoreLocked(u *db.User, snapName string) error {
st, err := m.lx.State(u.Name)
if err != nil {
return err
@@ -1104,18 +1117,22 @@ func (m *Manager) SnapshotRestore(name, snapName string) error {
return err
}
}
if err := m.lx.SnapshotRestore(u.Name, snapName); err != nil {
restore := func() error { return m.lx.SnapshotRestore(u.Name, snapName) }
err = restore()
if err != nil && isSubsequentSnapshotErr(err) {
// Restore to an older checkpoint is refused while later ones exist and
// the volume lacks zfs.remove_snapshots. Delete them and retry once.
if !strings.Contains(err.Error(), "subsequent snapshot") {
return err
}
if derr := m.discardNewerSnapshots(u.Name, snapName); derr != nil {
return derr
err = derr
} else {
err = restore()
}
if err := m.lx.SnapshotRestore(u.Name, snapName); err != nil {
return err
}
if err != nil {
if wasRunning {
_ = m.lx.Start(u.Name)
}
return err
}
if wasRunning {
return m.lx.Start(u.Name)
@@ -1123,6 +1140,18 @@ func (m *Manager) SnapshotRestore(name, snapName string) error {
return nil
}
// isSubsequentSnapshotErr reports whether a restore was refused because newer
// snapshots exist. Incus emits two variants: "subsequent snapshot(s)" for plain
// later checkpoints, and "subsequent internal snapshot(s) (from a copy)" when a
// later checkpoint is the origin of a clone (a shared snapshot). The second is
// not recoverable by deleting snapshots — those internal ones are not listed
// and cannot be removed while a clone depends on them — but recognising it
// keeps the failure path identical (restart the container, surface the error).
func isSubsequentSnapshotErr(err error) bool {
s := err.Error()
return strings.Contains(s, "subsequent snapshot") || strings.Contains(s, "subsequent internal snapshot")
}
// ensureZfsRollbackVolume sets zfs.remove_snapshots on the container's root
// volume so a single restore auto-discards the newer snapshots (see
// lx.EnsureZFSRemoveSnapshots). Called on Add and Reinstall.
@@ -1295,6 +1324,9 @@ func (m *Manager) Reinstall(name, image string) (string, error) {
m.db.UpdateUserStatus(u.ID, db.StatusFailed)
return "", fmt.Errorf("delete container: %w", err)
}
// The container (and its snapshots) is gone, so any share pointing at one
// of them is dead.
_ = m.db.DeleteSnapshotShare(u.ID)
// Default image (or empty): resolve to the configured alias / fallback and
// pull the fallback if it is remote-qualified. A user-picked non-default
// image must already exist locally — never auto-fetch a surprise image.
@@ -1596,6 +1628,17 @@ func (m *Manager) ApplyZFSRemoveSnapshotsToAll() error {
return firstErr
}
// EnsurePoolRefQuota switches the storage pool's ZFS volume default to
// refquota, so every container created after this runs gets a HARD disk limit
// that counts all the blocks it references — including data inherited from a
// cloned snapshot — instead of the default `quota`, which only charges the
// clone's own delta. Existing containers are deliberately untouched (pool
// defaults apply at creation only). No-op on non-ZFS pools. Idempotent; run on
// every `vps install` so an --update upgrade also picks it up.
func (m *Manager) EnsurePoolRefQuota() error {
return m.lx.EnsurePoolRefQuota(m.cfg.Incus.Pool)
}
// EnsureBlockRoutes adds the deterministic /112 block (ipv6.routes) to every
// existing container's eth0, so an upgrade to the /112 scheme routes each
// container's whole block. Idempotent; a container without IPv6 (or not in
+341
View File
@@ -0,0 +1,341 @@
package mgr
import (
"crypto/rand"
"errors"
"fmt"
"strings"
"time"
"vpsmgr/internal/cfg"
"vpsmgr/internal/db"
"vpsmgr/internal/lx"
"vpsmgr/internal/pw"
)
// snapshot sharing
//
// A user may publish one checkpoint behind a random code. Anyone can install a
// container by presenting that code: the snapshot is cloned copy-on-write
// (no image is built, so it costs no real disk space until it diverges) and the
// clone is re-provisioned as the target user's own container — own network,
// quota, hostname, root password and SSH keys. The source container and its
// snapshot are untouched, so one share can serve many installs.
//
// Because a clone pins its origin snapshot, a checkpoint that has been cloned
// can no longer be rolled back past. Publishing therefore deletes the OLDER
// checkpoints (they could never be restored to again anyway) and the panel
// warns about it first.
// shareCodeLen is the number of random UUID bits presented to users. 128 bits
// (a v4 UUID) is unguessable.
func newShareCode() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// olderSnapshotNames returns the names of snapshots created strictly before
// target, preserving list order. A snapshot with an unparseable timestamp is
// left alone (safe: it is never deleted).
func olderSnapshotNames(snaps []lx.SnapshotInfo, target string) []string {
var targetTime time.Time
found := false
for _, s := range snaps {
if s.Name == target {
targetTime = snapshotCreateTime(s)
found = !targetTime.IsZero()
break
}
}
if !found {
return nil
}
var older []string
for _, s := range snaps {
if s.Name == target {
continue
}
if t := snapshotCreateTime(s); !t.IsZero() && t.Before(targetTime) {
older = append(older, s.Name)
}
}
return older
}
// ShareSnapshot publishes a checkpoint: it deletes the older checkpoints (which
// become unreachable once the clone exists) and stores a fresh code, replacing
// any previous share. It returns the code and the names of the removed
// checkpoints so the caller can tell the user what happened.
func (m *Manager) ShareSnapshot(name, snapName string) (string, []string, error) {
m.opMu.Lock()
defer m.opMu.Unlock()
u, err := m.db.GetUserByName(name)
if err != nil {
return "", nil, err
}
if m.SnapshotLimit() == 0 {
return "", nil, errors.New("snapshots are disabled")
}
if !ValidSnapName(snapName) {
return "", nil, errors.New("invalid snapshot name")
}
snaps, err := m.lx.SnapshotList(u.Name)
if err != nil {
return "", nil, err
}
found := false
for _, s := range snaps {
if s.Name == snapName {
found = true
break
}
}
if !found {
return "", nil, errors.New("snapshot not found")
}
removed := olderSnapshotNames(snaps, snapName)
for _, s := range removed {
if err := m.lx.SnapshotDelete(u.Name, s); err != nil {
return "", nil, fmt.Errorf("delete older checkpoint %s: %w", s, err)
}
_ = m.db.DeleteSnapshotShareBySnapshot(u.ID, s)
}
code, err := newShareCode()
if err != nil {
return "", nil, err
}
if err := m.db.SetSnapshotShare(u.ID, code, snapName); err != nil {
return "", nil, err
}
return code, removed, nil
}
// SnapshotShareInfo returns the user's current share code and the checkpoint it
// points at (ok=false when the user has no share).
func (m *Manager) SnapshotShareInfo(name string) (code, snap string, ok bool) {
u, err := m.db.GetUserByName(name)
if err != nil {
return "", "", false
}
s, err := m.db.GetSnapshotShare(u.ID)
if err != nil || s == nil {
return "", "", false
}
return s.Code, s.Snapshot, true
}
// RevokeSnapshotShare drops the user's share code.
func (m *Manager) RevokeSnapshotShare(name string) error {
u, err := m.db.GetUserByName(name)
if err != nil {
return err
}
return m.db.DeleteSnapshotShare(u.ID)
}
// resolveShare turns a code into its owner and snapshot, verifying the
// checkpoint still exists (the owner may have deleted it, reinstalled or
// removed the container since publishing).
func (m *Manager) resolveShare(code string) (*db.User, string, error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, "", errors.New("no share code given")
}
share, err := m.db.GetSnapshotShareByCode(code)
if err != nil {
return nil, "", err
}
if share == nil {
return nil, "", errors.New("invalid or expired share code")
}
owner, err := m.db.GetUserByID(share.UserID)
if err != nil {
return nil, "", errors.New("invalid or expired share code")
}
snaps, err := m.lx.SnapshotList(owner.Name)
if err != nil {
return nil, "", err
}
for _, s := range snaps {
if s.Name == share.Snapshot {
return owner, share.Snapshot, nil
}
}
return nil, "", errors.New("the shared checkpoint no longer exists (the owner deleted it)")
}
// ReinstallFromShare rebuilds the user's container from a shared checkpoint.
// When the code points at a checkpoint of the very container being reinstalled,
// the operation is a plain rollback (the snapshot would be destroyed if the
// container were deleted first). Otherwise the snapshot is cloned to a
// temporary container, the old container is deleted, and the clone is renamed
// into place — so a clone failure never leaves the user with nothing.
func (m *Manager) ReinstallFromShare(name, code string) (string, error) {
m.opMu.Lock()
defer m.opMu.Unlock()
u, err := m.db.GetUserByName(name)
if err != nil {
return "", err
}
owner, snap, err := m.resolveShare(code)
if err != nil {
return "", err
}
// Own checkpoint: a time-machine restore of the same container is exactly
// "reinstall from this checkpoint", and avoids deleting the source of the
// clone. Disk-only keeps the current network/quotas.
if owner.ID == u.ID {
if err := m.snapshotRestoreLocked(u, snap); err != nil {
return "", err
}
m.applyUserKeys(u.Name)
return "", nil
}
// Cross-container clone.
if err := m.db.UpdateUserStatus(u.ID, db.StatusReinstalling); err != nil {
return "", fmt.Errorf("mark reinstalling: %w", err)
}
tmp, err := tempCloneName(u.Name)
if err != nil {
m.db.UpdateUserStatus(u.ID, db.StatusFailed)
return "", err
}
ipv6, blockStr := "", ""
if m.cfg.IPv6ModeEffective() != cfg.IPv6ModePool {
ipv6, _ = m.IPv6Addr(u.Name)
if block, _ := m.IPv6Block(u.Name); block != nil {
blockStr = block.String()
}
}
if err := m.lx.CloneFromSnapshot(owner.Name, snap, tmp,
m.cfg.Incus.Pool, m.cfg.Incus.Bridge, u.IP, ipv6, blockStr, u.IPv6Address,
m.cfg.Net.ExtIF, u.CPU, u.MemMB, u.DiskGB); err != nil {
m.db.UpdateUserStatus(u.ID, db.StatusFailed)
return "", fmt.Errorf("clone shared checkpoint: %w", err)
}
rollback := func() error {
_ = m.UnwireIPv6(tmp)
_ = m.lx.Delete(tmp)
_ = m.lx.Delete(u.Name)
_ = m.db.UpdateUserStatus(u.ID, db.StatusFailed)
return nil
}
if err := m.lx.Delete(u.Name); err != nil {
rollback()
return "", fmt.Errorf("delete old container: %w", err)
}
// The old container's snapshots (and any share over them) are gone with it.
_ = m.db.DeleteSnapshotShare(u.ID)
if err := m.lx.RenameInstance(tmp, u.Name); err != nil {
rollback()
return "", fmt.Errorf("rename clone into place: %w", err)
}
if err := m.ensureZfsRollbackVolume(u.Name); err != nil {
rollback()
return "", fmt.Errorf("zfs snapshot rollback setup: %w", err)
}
if err := m.lx.Start(u.Name); err != nil {
rollback()
return "", fmt.Errorf("start container: %w", err)
}
if err := m.lx.WaitReady(u.Name, 120*time.Second); err != nil {
rollback()
return "", fmt.Errorf("wait for container: %w", err)
}
pass := pw.Generate(20)
// image is only used to pick the provisioning path; a clone always comes
// from a vpsmgr image, so pass the managed alias.
if err := m.Provision(u.Name, m.cfg.Incus.Image, pass); err != nil {
rollback()
return "", fmt.Errorf("provision container: %w", err)
}
// The clone carries the source's machine-id; a duplicate DUID makes dnsmasq
// drop DHCPv6 leases, so give it a fresh one before the network settles.
if err := m.regenerateMachineID(u.Name); err != nil {
rollback()
return "", fmt.Errorf("regenerate machine-id: %w", err)
}
// The clone also carries the source's authorized_keys — clear them before
// writing this user's own keys, or the source could log in.
m.clearAuthorizedKeys(u.Name)
if m.cfg.IPv6ModeEffective() == cfg.IPv6ModePool {
if u.IPv6Address != "" {
if err := m.ConfigureContainerIPv6(u.Name, u.IPv6Address); err != nil {
rollback()
return "", fmt.Errorf("config container ipv6: %w", err)
}
if err := m.WireIPv6Pool(u.Name, u.IPv6Address); err != nil {
rollback()
return "", fmt.Errorf("wire ipv6 pool: %w", err)
}
}
} else {
if err := m.ConfigureContainerIPv6(u.Name, ""); err != nil {
rollback()
return "", fmt.Errorf("config container ipv6: %w", err)
}
if err := m.WireIPv6(u.Name); err != nil {
rollback()
return "", fmt.Errorf("wire ipv6: %w", err)
}
}
m.applyUserKeys(u.Name)
// The init script is deliberately NOT run: the clone already carries the
// shared checkpoint's state.
if err := m.db.UpdateUserStatus(u.ID, db.StatusReady); err != nil {
m.db.UpdateUserStatus(u.ID, db.StatusFailed)
return "", fmt.Errorf("db: mark user ready: %w", err)
}
m.limitMu.Lock()
delete(m.throttled, u.Name)
m.limitMu.Unlock()
return pass, nil
}
// tempCloneName is a transient container name for the clone-in-place dance. It
// carries no trailing digits after a hyphen, so it is never parsed as a child
// account.
func tempCloneName(name string) (string, error) {
b := make([]byte, 2)
if _, err := rand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%s-rt-%04x", name, b), nil
}
// regenerateMachineID gives a cloned container a fresh machine-id.
func (m *Manager) regenerateMachineID(name string) error {
_, err := m.lx.ExecSH(name, "rm -f /etc/machine-id /var/lib/dbus/machine-id && systemd-machine-id-setup >/dev/null 2>&1")
return err
}
// clearAuthorizedKeys drops the container's authorized_keys (a clone inherits
// the source's). The next ApplySSHKeys rebuilds it from the DB.
func (m *Manager) clearAuthorizedKeys(name string) {
_, _ = m.lx.ExecSH(name, "rm -f /root/.ssh/authorized_keys")
}
// applyUserKeys best-effort re-applies the user's active (and granted admin)
// keys. Keys persist in the DB, so a failure is not fatal.
func (m *Manager) applyUserKeys(name string) {
active, err := m.ActiveKeys(name)
if err != nil {
return
}
admin, _ := m.GrantedAdminKeys(name)
if len(active) == 0 && len(admin) == 0 {
return
}
if err := m.ApplySSHKeys(name, active, admin); err != nil {
fmt.Printf(" ! warn: ssh keys: %v\n", err)
}
}
+147
View File
@@ -0,0 +1,147 @@
package mgr
import (
"path/filepath"
"strings"
"testing"
"vpsmgr/internal/cfg"
"vpsmgr/internal/db"
"vpsmgr/internal/lx"
)
func TestNewShareCode(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
code, err := newShareCode()
if err != nil {
t.Fatal(err)
}
if len(code) != 36 {
t.Fatalf("code %q has length %d, want 36 (UUID)", code, len(code))
}
if code[14] != '4' {
t.Errorf("code %q is not a v4 UUID (version nibble %q)", code, code[14])
}
if v := code[19]; v != '8' && v != '9' && v != 'a' && v != 'b' {
t.Errorf("code %q has a bad variant nibble %q", code, v)
}
if seen[code] {
t.Fatalf("duplicate share code %q", code)
}
seen[code] = true
}
}
func TestOlderSnapshotNames(t *testing.T) {
snaps := []lx.SnapshotInfo{
{Name: "old", CreatedAt: "2026-08-01T10:00:00Z"},
{Name: "mid", CreatedAt: "2026-08-02T10:00:00Z"},
{Name: "new", CreatedAt: "2026-08-03T10:00:00Z"},
{Name: "bad", CreatedAt: "not-a-time"},
}
got := olderSnapshotNames(snaps, "mid")
if len(got) != 1 || got[0] != "old" {
t.Fatalf("olderSnapshotNames(mid) = %v, want [old]", got)
}
if got := olderSnapshotNames(snaps, "old"); len(got) != 0 {
t.Fatalf("olderSnapshotNames(old) = %v, want none", got)
}
// An unknown target deletes nothing (safe).
if got := olderSnapshotNames(snaps, "ghost"); got != nil {
t.Fatalf("olderSnapshotNames(ghost) = %v, want nil", got)
}
}
func TestIsSubsequentSnapshotErr(t *testing.T) {
yes := []string{
`Snapshot "s1" cannot be restored due to subsequent snapshot(s). Set zfs.remove_snapshots to override`,
`Snapshot "s1" cannot be restored due to subsequent internal snapshot(s) (from a copy)`,
}
for _, m := range yes {
if !isSubsequentSnapshotErr(errString(m)) {
t.Errorf("not matched as subsequent-snapshot error: %q", m)
}
}
if isSubsequentSnapshotErr(errString("some other failure")) {
t.Error("unrelated error matched as subsequent-snapshot")
}
}
type errString string
func (e errString) Error() string { return string(e) }
func TestShareSnapshotRejectsBadInput(t *testing.T) {
c := cfg.Default()
c.Incus.Socket = "/nonexistent/vpsmgr-test.sock"
d, err := db.Open(filepath.Join(t.TempDir(), "share.db"))
if err != nil {
t.Fatal(err)
}
defer d.Close()
if _, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 10, 1024, 10); err != nil {
t.Fatal(err)
}
m := New(c, d)
// Invalid snapshot name is rejected before any Incus call.
if _, _, err := m.ShareSnapshot("alice", "../evil"); err == nil {
t.Fatal("invalid snapshot name accepted")
}
// A valid name reaches Incus, which is unreachable here → error.
if _, _, err := m.ShareSnapshot("alice", "snap-x"); err == nil {
t.Fatal("expected an error: Incus is unreachable")
}
}
func TestSnapshotShareInfoAndRevoke(t *testing.T) {
c := cfg.Default()
c.Incus.Socket = "/nonexistent/vpsmgr-test.sock"
d, err := db.Open(filepath.Join(t.TempDir(), "share2.db"))
if err != nil {
t.Fatal(err)
}
defer d.Close()
u, err := d.CreateUser("bob", "h", "10.115.0.3", 2, 30002, 10100, 10, 1024, 10)
if err != nil {
t.Fatal(err)
}
m := New(c, d)
if _, _, ok := m.SnapshotShareInfo("bob"); ok {
t.Fatal("reported a share before one exists")
}
if err := d.SetSnapshotShare(u.ID, "code-x", "snap-1"); err != nil {
t.Fatal(err)
}
code, snap, ok := m.SnapshotShareInfo("bob")
if !ok || code != "code-x" || snap != "snap-1" {
t.Fatalf("SnapshotShareInfo = %q,%q,%v", code, snap, ok)
}
if err := m.RevokeSnapshotShare("bob"); err != nil {
t.Fatal(err)
}
if _, _, ok := m.SnapshotShareInfo("bob"); ok {
t.Fatal("share still reported after revoke")
}
}
func TestReinstallFromShareUnknownCode(t *testing.T) {
c := cfg.Default()
c.Incus.Socket = "/nonexistent/vpsmgr-test.sock"
d, err := db.Open(filepath.Join(t.TempDir(), "share3.db"))
if err != nil {
t.Fatal(err)
}
defer d.Close()
if _, err := d.CreateUser("carol", "h", "10.115.0.4", 3, 30003, 10200, 10, 1024, 10); err != nil {
t.Fatal(err)
}
m := New(c, d)
if _, err := m.ReinstallFromShare("carol", "not-a-real-code"); err == nil ||
!strings.Contains(err.Error(), "invalid or expired") {
t.Fatalf("unknown code error = %v, want invalid/expired", err)
}
}
+48
View File
@@ -276,6 +276,23 @@ func (s *Server) handleReinstall(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "error: please confirm reinstall")
return
}
// "Install from a shared checkpoint": the code replaces the image choice.
// A code pointing at this very container is handled as a restore and yields
// no new root password.
if share := strings.TrimSpace(r.FormValue("share")); share != "" {
pass, err := s.mgr.ReinstallFromShare(u.Name, share)
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "reinstall.share")
if pass == "" {
s.redirect(w, r, s.p(""), s.t(r, "snapshot_restored"))
return
}
s.redirectModal(w, r, s.p(""), s.t(r, "reinstall_done", pass))
return
}
pass, err := s.mgr.Reinstall(u.Name, r.FormValue("image"))
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
@@ -548,6 +565,37 @@ func (s *Server) handleSnapshotRestore(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), msg)
}
// handleSnapshotShare publishes a checkpoint behind a random code so other
// users can install from it. Older checkpoints are deleted in the process: once
// the snapshot is cloned it can no longer be rolled back past, so they would
// only linger as dead entries. The panel warns before submitting.
func (s *Server) handleSnapshotShare(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := r.FormValue("name")
_, removed, err := s.mgr.ShareSnapshot(u.Name, name)
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.share")
s.redirect(w, r, s.p(""), s.t(r, "snapshot_shared", len(removed)))
}
// handleSnapshotUnshare revokes the user's share code.
func (s *Server) handleSnapshotUnshare(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := s.mgr.RevokeSnapshotShare(u.Name); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.unshare")
s.redirect(w, r, s.p(""), s.t(r, "snapshot_unshared"))
}
// handleSSHKeys reconciles the user's SSH-key set from the management panel
// AND which of the operator's admin keys the user has activated. The body is a
// JSON object: `keys` is the array of user key rows (ID > 0 updates, ID == 0
+3
View File
@@ -88,6 +88,9 @@ func tr(l, key string, args ...any) string {
"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"},
"ssh_keys_apply_warn": {"机器当前不可达,密钥已保存,将在下次重装后写入。", "Machine unreachable — keys saved and will be applied on the next reinstall."},
}
pair, ok := m[key]
+58 -41
View File
@@ -118,6 +118,7 @@ type snapshotRow struct {
Name string
CreatedAt string // UTC RFC3339; rendered in the browser's timezone
Size string // human-readable disk usage, e.g. "184 MiB"; may be empty
Shared bool // this checkpoint is published behind the share code
}
// sshKeyRow is one public key shown in the SSH-key management panel.
@@ -137,46 +138,50 @@ type groupMemberRow struct {
}
type pageData struct {
Title string
User *db.User
GroupUsers []groupMemberRow
GroupIndex string
GroupCount int
State string
IP string
SSHPort int
StartPort int
Ports string // full user-port block, e.g. 10700-10799
PortsPrefix string // whole-hundred block number, e.g. 107 → "10700-10799"
SSH string
V4Forward bool // false = IPv6-only box: v4 ssh/ports not offered
TraefikEnabled bool // false = domain proxy disabled; domains cannot be added
InitScript string // custom init script, run after a reinstall
BandwidthQuotaGB int // monthly bandwidth quota GiB, 0 = unlimited
BandwidthUsedGB string // used this month (GB, 1 decimal) — only set when limited
BandwidthPct int // used/quota * 100, clamped to 100
BandwidthResetDay int // day of month the bandwidth period resets (1-28)
Throttled bool // over quota: NIC limited to 1Mbps
CPULimited bool // under the admin's dynamic CPU limit
CPULimitUntil int64 // unix seconds the dynamic CPU limit expires
Domains []domainRow
QuotaCPU string
QuotaMem string
QuotaDisk string
CPUUse string // 5-minute CPU average ("12%" or "-")
MemUse string // latest sampled memory usage ("345 MiB" or "-")
DiskUsed string // latest sampled disk usage ("184 MiB" or "-")
Msg string
Err string
PublicIP string
Prefix string
Lang string
UpGB string
DownGB string
IPv6 string // primary global address (the one to connect to)
IPv6Block string // the /112 block the container owns (informational)
Snapshots []snapshotRow
SnapshotLimit int // configured per-container snapshot cap (for display)
Title string
User *db.User
GroupUsers []groupMemberRow
GroupIndex string
GroupCount int
State string
IP string
SSHPort int
StartPort int
Ports string // full user-port block, e.g. 10700-10799
PortsPrefix string // whole-hundred block number, e.g. 107 → "10700-10799"
SSH string
V4Forward bool // false = IPv6-only box: v4 ssh/ports not offered
TraefikEnabled bool // false = domain proxy disabled; domains cannot be added
InitScript string // custom init script, run after a reinstall
BandwidthQuotaGB int // monthly bandwidth quota GiB, 0 = unlimited
BandwidthUsedGB string // used this month (GB, 1 decimal) — only set when limited
BandwidthPct int // used/quota * 100, clamped to 100
BandwidthResetDay int // day of month the bandwidth period resets (1-28)
Throttled bool // over quota: NIC limited to 1Mbps
CPULimited bool // under the admin's dynamic CPU limit
CPULimitUntil int64 // unix seconds the dynamic CPU limit expires
Domains []domainRow
QuotaCPU string
QuotaMem string
QuotaDisk string
CPUUse string // 5-minute CPU average ("12%" or "-")
MemUse string // latest sampled memory usage ("345 MiB" or "-")
DiskUsed string // latest sampled disk usage ("184 MiB" or "-")
Msg string
Err string
PublicIP string
Prefix string
Lang string
UpGB string
DownGB string
IPv6 string // primary global address (the one to connect to)
IPv6Block string // the /112 block the container owns (informational)
Snapshots []snapshotRow
SnapshotLimit int // configured per-container snapshot cap (for display)
// Snapshot share: the code others can use to install from a checkpoint, and
// the checkpoint it points at ("" when the user has no active share).
ShareCode string
SharedSnapshot string
SSHKeys []sshKeyRow
AdminSSHKeys []sshKeyRow // operator's own public keys, shown read-only
AdminKeysAnyActive bool // at least one admin key is granted: expand the disclosure by default
@@ -210,6 +215,8 @@ func (s *Server) Handler() http.Handler {
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("/notes", s.requireAuth(s.handleNotes))
mux.HandleFunc("/notes/reset", s.requireAuth(s.requirePost(s.handleNotesReset)))
@@ -427,9 +434,19 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData {
}
// Snapshots come from Incus (one list call). A failure is non-fatal: the
// page still renders, and the snapshot modal shows the empty state.
shareCode, shareSnap, hasShare := s.mgr.SnapshotShareInfo(u.Name)
if hasShare {
d.ShareCode = shareCode
d.SharedSnapshot = shareSnap
}
if snaps, err := s.mgr.SnapshotList(u.Name); err == nil {
for _, sn := range snaps {
d.Snapshots = append(d.Snapshots, snapshotRow{Name: sn.Name, CreatedAt: sn.CreatedAt, Size: mgr.HumanBytes(sn.Size)})
d.Snapshots = append(d.Snapshots, snapshotRow{
Name: sn.Name,
CreatedAt: sn.CreatedAt,
Size: mgr.HumanBytes(sn.Size),
Shared: hasShare && sn.Name == shareSnap,
})
}
}
d.SnapshotLimit = s.mgr.SnapshotLimit()
+43
View File
@@ -1128,6 +1128,11 @@ func TestSnapshotRoutesRegistered(t *testing.T) {
if rr.Code != http.StatusFound {
t.Fatalf("POST /snapshot-restore = %d, want 302", rr.Code)
}
// POST /snapshot-share: registered; fails on the unreachable Incus.
rr = doReq(t, h, http.MethodPost, prefix+"/snapshot-share", url.Values{"name": {"snap-x"}}, cookie)
if rr.Code != http.StatusFound {
t.Fatalf("POST /snapshot-share = %d, want 302", rr.Code)
}
// No audit rows for the snapshot ops: every snapshot call failed before
// any successful action (the login row is expected).
@@ -1140,6 +1145,13 @@ func TestSnapshotRoutesRegistered(t *testing.T) {
t.Errorf("failed snapshot op wrote an audit row: %+v", r)
}
}
// POST /snapshot-unshare: registered; revoking nothing succeeds (so it is
// checked after the "no audit row on failure" assertion above).
rr = doReq(t, h, http.MethodPost, prefix+"/snapshot-unshare", url.Values{}, cookie)
if rr.Code != http.StatusFound {
t.Fatalf("POST /snapshot-unshare = %d, want 302", rr.Code)
}
}
// TestSnapshotLimitSemantics checks the config-driven snapshot cap: default is
@@ -1219,6 +1231,37 @@ func TestOverviewSnapshotModal(t *testing.T) {
}
}
// TestOverviewSnapshotShareUI verifies the share UI: a published checkpoint
// shows its code with a revoke button, an unpublished one shows a Share button,
// and the reinstall modal carries the share-code field.
func TestOverviewSnapshotShareUI(t *testing.T) {
srv, _ := newTestServer(t)
body := srv.renderToString(t, "overview.html", pageData{
User: &db.User{Name: "alice"},
Prefix: "/" + testSecret,
ShareCode: "11111111-2222-4333-8444-555555555555",
SharedSnapshot: "snap-shared",
Snapshots: []snapshotRow{
{Name: "snap-shared", CreatedAt: "2026-08-01T00:00:00Z", Size: "10 MiB", Shared: true},
{Name: "snap-other", CreatedAt: "2026-08-02T00:00:00Z", Size: "11 MiB"},
},
})
for _, want := range []string{
`data-copy="11111111-2222-4333-8444-555555555555"`,
`onclick="unshareSnap()"`,
`onclick="shareSnap('snap-other','2026-08-02T00:00:00Z')"`,
`name="share"`,
} {
if !strings.Contains(body, want) {
t.Errorf("overview share UI missing %q", want)
}
}
// The published checkpoint must not also offer a Share button.
if strings.Contains(body, `shareSnap('snap-shared'`) {
t.Error("published checkpoint still offers a Share button")
}
}
// TestOverviewGroupSwitcherLabels verifies the multi-container switcher shows
// "<label>-<cpu>c<mem>g<disk>g" with the group-name prefix omitted and the base
// account first, and that the sticky-notes title tags the machine only when the
@@ -464,6 +464,13 @@ footer a{color:var(--muted);text-decoration:underline}
<td><span class="snapts" data-ts="{{.CreatedAt}}">{{.CreatedAt}}</span></td>
<td><span class="muted">{{.Size}}</span></td>
<td>
{{if .Shared}}
<div class="muted" style="font-size:.72rem">{{if eq $.Lang "zh"}}分享码{{else}}share code{{end}}</div>
<code class="copyable" data-copy="{{$.ShareCode}}">{{$.ShareCode}}</code>
<button type="button" class="btn danger" onclick="unshareSnap()">{{if eq $.Lang "zh"}}撤销分享{{else}}Revoke{{end}}</button>
{{else}}
<button type="button" class="btn" onclick="shareSnap('{{.Name}}','{{.CreatedAt}}')">{{if eq $.Lang "zh"}}分享{{else}}Share{{end}}</button>
{{end}}
<button type="button" class="btn accent" onclick="restoreSnap('{{.Name}}')">{{if eq $.Lang "zh"}}恢复{{else}}Restore{{end}}</button>
<button type="button" class="btn danger" onclick="delSnap('{{.Name}}')">{{if eq $.Lang "zh"}}删除{{else}}Delete{{end}}</button>
</td>
@@ -533,6 +540,10 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
<input type="hidden" name="confirm" value="1">
<div class="imglist" id="reImgs"></div>
<div id="reDesc"></div>
<div class="field" style="margin-top:.6rem">
<label class="muted" style="font-size:.82rem">{{if eq .Lang "zh"}}或:从分享的检查点安装(填入分享码;填了则忽略上面的系统选择){{else}}Or: install from a shared checkpoint (enter a code; the system choice above is then ignored){{end}}</label>
<input name="share" id="reShare" type="text" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" autocomplete="off" spellcheck="false" style="width:100%">
</div>
<div class="actions">
<button class="btn" type="button" id="reCancel">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
<button class="btn danger" type="submit" id="reConfirm">{{if eq .Lang "zh"}}确认重装(数据全部丢弃){{else}}Confirm reinstall (all data lost){{end}}</button>
@@ -899,6 +910,30 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
:('Restoring to checkpoint '+n+' loses the current data and all later checkpoints. Confirm?');
if(confirm(msg)) submitSnap('/snapshot-restore',{name:n});
};
// Publish a checkpoint. The warning is explicit: the checkpoint (and
// everything inside the container, credentials included) becomes available to
// whoever holds the code, and the OLDER checkpoints become unreachable once a
// clone exists — so they are removed now.
window.shareSnap=function(n,ts){
var older=0;
document.querySelectorAll('#snapModal .snapts').forEach(function(el){
var t=el.getAttribute('data-ts');
if(t&&ts&&t<ts) older++;
});
var msg;
if(older>0){
msg=(LANG==='zh')?('分享会把该检查点(包含容器内的全部数据与凭据)暴露给持有分享码的人。一旦有人据此克隆,更早的 '+older+' 个检查点将无法再恢复,因此会被删除。确认继续?')
:('Sharing exposes this checkpoint (including everything inside the container, credentials included) to whoever holds the code. Once someone clones it, the '+older+' older checkpoint(s) can no longer be restored to and will be deleted. Continue?');
}else{
msg=(LANG==='zh')?('分享会把该检查点(包含容器内的全部数据与凭据)暴露给持有分享码的人。确认继续?')
:('Sharing exposes this checkpoint (including everything inside the container, credentials included) to whoever holds the code. Continue?');
}
if(confirm(msg)) submitSnap('/snapshot-share',{name:n});
};
window.unshareSnap=function(){
var msg=(LANG==='zh')?'撤销分享?已据此安装的容器不受影响。':'Revoke the share? Containers already installed from it are unaffected.';
if(confirm(msg)) submitSnap('/snapshot-unshare',{});
};
// Create form and all destructive ops: keep the hash so the modal reopens
// after the reload that follows the POST.
function submitSnap(path, fields){
+8
View File
@@ -349,6 +349,14 @@ func cmdInstall() error {
d.Close()
return fmt.Errorf("apply zfs remove_snapshots: %w", err)
}
// Hard disk quota for new containers: make the pool create new volumes with
// refquota, so a user's disk limit counts the data it holds (including a
// cloned checkpoint's inherited blocks), not just its own delta. Existing
// containers keep their current accounting (pool defaults apply at create).
if err := m.EnsurePoolRefQuota(); err != nil {
d.Close()
return fmt.Errorf("apply pool refquota: %w", err)
}
// Route inter-container IPv6 through the host (no L2 discovery / MITM),
// so a container can reach a peer whose address it knows. A user-controlled
// container may use an unsupported guest network stack; do not let that one