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:
1 parent
0e4528ad85
commit
2363b89743
15 files changed
+1033
-66
No files matched your search
+31
-1
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user