- zfsutils-linux (and the zfs-dkms it Recommends) live in Debian's
'contrib' component — ZFS's CDDL license is not DFSG-free — but a
minimal Debian install enables only 'main' (+ 'non-free-firmware').
Without contrib, apt reports "Package 'zfsutils-linux' has no
installation candidate", zfs-dkms is never installed and the DKMS
rebuild then dies with "no ZFS DKMS version found". Enable contrib
idempotently before installing, in both the deb822 (*.sources) and the
classic one-line formats, touching only the deb.debian.org archive so a
third-party repo is never given a contrib it lacks. Mirrors the
existing Ubuntu 'universe' handling.
- Strip the trailing colon from 'dkms status' version fields: a module
whose postinst ran before the headers existed registers as
"zfs/<ver>: added" (no kernel), and 'dkms build zfs/<ver>:' is invalid,
so the fallback rebuild failed even once contrib was enabled.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- add the rule to the conventions with the documentation ranges to use
(RFC 5737 for IPv4, RFC 3849 for IPv6) and note that fixtures, docs and
commit messages are all covered
- record why: a real prefix names an operator's live allocation, the block
arithmetic is length-driven so a doc-range prefix substitutes unchanged, and
scrubbing a leak later means rewriting published history (force-pushed tags,
broken forks/clones). Host addresses, credentials and provider/ASN names are
called out as the same class of leak
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- test 2 sent a Globalping "ping" with a port, which is still ICMP (its
rawOutput is literally `PING ... icmp_seq=... 100% packet loss`), so it only
repeated the ICMP result and reported NOT VERIFIED on a provider that filters
inbound ICMPv6 while routing the prefix fine
- use the "tcp" measurement type and teach the summarizer that a finished probe
with timings is a 0-loss success
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- buildData filled the IPv6 fields only inside `if IPv6Enabled()`, false in none
mode, so the user panel showed IPv4 connection info only even though the
container had a routed /64
- read the extra block from the account row (a live DB value) and, in none
mode, derive the connect address (block::1) via a new mgr helper
ExtraBlockAddr
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- the key was ApplyNextAdd: the CLI rewrote config.yaml while the running panel
kept its startup snapshot, so the admin's "assign a whole /64" checkbox only
appeared after a manual restart
- it is now ApplyImmediate: the change rewires host IPv6, (re)installs the
vps-ipv6 boot unit and restarts the panel, so the create form updates at once
- factor the boot-unit selection out of cmdInstall into syncIPv6BootUnit so
install and the runtime apply share one implementation
- document the new apply semantics
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- RewireAllIPv6() began with `if !IPv6Enabled() { return nil }`, so in none
mode — the mode that carries an extra prefix — it did nothing at all: the
boot unit and `vps ipv6-reapply` never set net.ipv6.conf.all.forwarding,
all.proxy_ndp or <ext_if>.proxy_ndp, and without them Incus could not install
the routed NIC's proxy_ndp entry, so every create failed
- give that branch its plumbing: forwarding, kernel proxy_ndp, the NDP rules
and the whole-/64 routes
- ensure forwarding + proxy_ndp at create time, before the routed NIC exists,
so a create no longer depends on a prior install / ipv6-reapply
- a clone or launch failure now calls cleanup() and removes the partial
instance; the leftover used to hold the name, so every retry was refused with
"already exists (orphan?)"
- the installer persists the name-free sysctls (all.proxy_ndp always,
all.forwarding when vpsmgr relays container v6) and uninstall resets
all.proxy_ndp again
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- the login page heading and the panel header (and their <title>) now use the
configured name instead of the hard-coded "VPS Manager"
- blank falls back to "VPS Manager", so existing config files are unchanged;
clearing the key resets it
- registry validation keeps it a single line of at most 60 characters; applied
on panel restart like the other panel.* display settings
- documented in the config example and the configuration reference
- a batch clones every user from a shared snapshot code, and handleUserBatch
already refuses when snapshots.share is off; the entry point now follows, so
the admin is not offered a form that can only be rejected
- carry snapshot-sharing state into the admin page data (ShareEnabled)
- correct the snapshots.share help text, which still claimed "default true"
although sharing is an explicit opt-in and defaults to false
- dl_content no longer runs with no timeout at all: every attempt carries
--connect-timeout, and a content transfer that connects then crawls below
1 KiB/s for 30s is aborted so the next try can take over (a slow but moving
download is still never cut off by a total cap)
- verify the release binary by looking up its SHA256SUMS entry explicitly
instead of `sha256sum -c --ignore-missing`, which quietly succeeded when the
manifest held no line for the arch
- copy the local vps-prebuild instead of moving it, so a later failure cannot
destroy the only snapshot along with the temp dir
- fail loudly when lib-download.sh is missing instead of degrading silently
- drop the unused dl_handshake helper, accept binary-mode `*asset` lines, and
lower-case v4_forward in 30-haproxy so True/TRUE match like 00-ip-ask
- drop the four on-screen arrow buttons: a physical keyboard already sends
them, and the app-cursor mode that made them awkward is handled by keySequence
- add a Paste button that reads the clipboard and hands the text to the shell
the same way a browser paste does, bracketed paste included
- report a refusal (no secure context or permission) instead of looking dead,
and fall back to Ctrl+V
- stop a mouse-down from stealing focus into the hidden input, which
collapsed the selection a drag was building; focus comes back on a plain
click, and after a real selection it is left where the copy shortcuts need it
- preserve a selection across repaints: note it as a row/character offset and
put it back after a row is rewritten, so output no longer wipes it, and hold
a frame while a drag is in progress
- let Ctrl/Cmd+C copy a selection instead of collapsing it first; it still
interrupts when nothing is selected
- make the mount non-focusable, tint the selection, and document the behaviour
- Fresh install (no /usr/local/bin/vps): prefer local vps-prebuild
when its SHA-256 matches the release manifest, otherwise download.
Checksum verification is mandatory whenever the manifest is fetched.
If the local snapshot is used, mv it into the temp dir and chmod 755.
- Update (--update): always re-download the latest release; local
vps-prebuild is never reused for updates (existing behavior).
- Already installed: skip entirely, ignoring any local vps-prebuild.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Previously the local vps-prebuild check only ran inside
install_prebuilt (fresh install path). An already-installed vps was
never replaced, so placing vps-prebuild next to the installer had
no effect on upgrades. Now the "already installed" branch also
checks the snapshot against the release manifest and swaps the
binary when it matches.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The checksum-manifest fetch previously had to succeed first; on any
transient GitHub failure the local prebuild was skipped entirely and
the installer fell through to dl_content. Now the local file is
attempted immediately when present, only hitting GitHub for the
manifest if needed.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Revert the oneclick.sh changes: keep the plain curl|tar download so it works
under `bash <(curl …)` without locating sibling scripts. The retry library
is not used by this bootstrap by design.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
When piped through /dev/fd the script's $0 is not a filesystem path, so
sourcing scripts/lib-download.sh failed ("... /dev/fd/scripts/lib-download.sh:
No such file or directory"). Inline dl_content() instead so the gitless
bootstrap can still retry the repo tarball download.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- allow net.ipv6_extra_prefix when net.ipv6_mode is none (IPv4-only)
- deliver standalone /64 to containers via a routed NIC (eth0) alongside bridged private IPv4 (eth1)
- bind <block>::1/64 on-link inside guest with default route fe80::1 (no local route, enabling SLAAC and internal subnet delegation)
- in-tree NDP responder (vps-ipv6.service) covers the extra /64 blocks on ext_if in none mode
- admin overview, user creation, batch creation, quota dialog, and IPv6 management page support none mode
- add unit tests covering none mode allocation, middle slice (/56 in /48), and admin UI
Docker sets iptables FORWARD policy to DROP, which blocks forwarded traffic
from incusbr0 (both IPv4, and IPv6 when Docker's experimental/ipv6 is enabled).
This caused container apt and outbound traffic to time out during image build
and container runtime on hosts where Docker is installed.
- allow incusbr0 traffic in DOCKER-USER for iptables and ip6tables
- install docker.service.d/vpsmgr.conf drop-in so rules persist across Docker restarts
- clean up the drop-in and rules on uninstall
- document Docker compatibility in docs/architecture.md
The previous normalization logic aligned the upper bound using (b // 100) * 100 + 99,
which rounded down to a block start and then extended to +99. For any input where
the upper bound did not already end in 99 (such as 10000-15000 or 10000-20000),
this spilled over by (99 - (b % 100)) ports past the user's requested range
(e.g. 10000-15000 became 10000-15099).
Ports outside the user's input may be in use or unavailable. Align the upper
bound strictly inward so each allocated block is a subset of the input:
- calculate hi as ((b + 1) / PortsPerUser) * PortsPerUser - 1 (ending in ...99 <= b)
- mirror the calculation in scripts/00-ip-ask.sh normalize_user_ports
- update unit tests in internal/cfg to verify strict inward alignment
- update docs/configuration.md examples and container capacity readouts
On Ubuntu systems with Ubuntu Pro / ESM Apps enabled, Ubuntu's ESM repository
pins its own packages to Pin-Priority: 510. Ubuntu 24.04 ships Incus 6.0 in
universe/ESM, so APT prefers the older 6.0 incus-client over Zabbly's 7.0
incus-client (default priority 500). When installing incus-base, this causes
unmet dependencies and breaks the installer with held broken packages.
- write /etc/apt/preferences.d/zabbly-incus with Pin-Priority: 600 in 10-incus.sh
- purge the preference file in uninstall.sh --purge
A container that owns a whole /64 had only its primary address reachable: the
guest makes the whole /112 local, but the host could no longer hand packets for
any other address in it to the container, so every suffix but ::1 went silent.
The cause was the shape v1.11.0 chose to get the /64 routed. Incus programs a
declared route as `via <ipv6.address>`, and the kernel refuses such a route when
the gateway is itself reached by a gateway route — which the account's own /112
route is ("RTNETLINK answers: No route to host"). Dropping ipv6.address made
both routes direct (`dev`), and a direct route means the host resolves every
address by neighbour discovery on the bridge, where the guest answers only for
the addresses it has bound.
Keep ipv6.address and order the list instead: `<block>/64,<account /112>`. Incus
installs a NIC's routes in the order declared, so the /64 is in the table before
the /112 route exists and both end up as via-address routes. The /112 reaches
the container's MAC again (every address in it answers, as it did before this
feature), and the whole /64 lands on the container's MAC too — which is what
lets a customer route sub-prefixes out of it.
- blockRoutes puts the /64 first, with the reasoning and a test pinning the order
- applyExtraRoutes declares the address again instead of removing it
- EnsureBlockRoutes no longer truncates a block owner's declaration to the /112
- share/transfer build instances with the address again (they had lost it too)
- lx.EnsureDeviceOptionsDrop is gone: nothing needs to remove a device key now
- docs/ipv6.md describes the shape and why each part of it is load-bearing
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The script's copy of the dispatcher had drifted (transfer and ipv6-proxy were
missing) and there was no flag completion at all for add/quota, so the new
--extra64 never showed up. Both are fixed:
- top-level list now matches main.go's dispatcher (minus the hidden
`completions` helper the script itself calls), and `vps transfer <TAB>`
offers send/receive
- add/quota complete their flags at any depth, so `vps quota <name> --<TAB>`
finds --extra64; `vps config set <key> <TAB>` keeps offering
--apply/--no-apply
- the config-key completion is untouched: it still reads the live registry and
therefore already offers net.ipv6_extra_prefix
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The dialog already shows the assigned block read-only next to its label; the
extra sentence explaining that it cannot be taken back was noise.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- a container without a whole /64 now gets exactly the NIC it has always had
(ipv6.address + the /112 in ipv6.routes, the /112 route via that address).
Only a /64 owner uses the routes-only form, so an upgraded host is
unchanged for everything that does not touch the new key.
- drop the `vps extra64` command: `vps quota <name> --extra64` assigns a block
instead, next to the quotas it belongs with (the panel's quota dialog is the
other entry point). Nothing else about quota changed.
- docs updated for the merged flag.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- admin → IPv6 (prefix mode): the extra-prefix editor, a capacity readout
(total / kept for this host / assigned / free) and the assigned blocks with
their owners — only the assigned ones, a /48 has 65536.
- create and batch forms carry a ticked-by-default "Assign a whole /64 (N
left)" checkbox, disabled once the pool is empty; The quota dialog can hand
one to an existing container and shows an existing block read-only, since it
can never be taken back there (only deleting the account releases it).
- the user panel lists the block next to the IPv6 address, plainly — nothing
advertises it as something to ask for.
- docs: the configuration.md key row/example/notes, a new ipv6.md section on
what a container gets and the two wiring traps above, and an architecture.md
pointer.
- check-ipv6-support.sh now probes a random address anywhere inside the
candidate prefix instead of a host part of its first /64, so a delegation
shorter than /64 is actually verified (the first /64 is usually the one the
host already holds an address in, and proves nothing).
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- net.ipv6_extra_prefix (prefix mode only, OFF by default and off on every
upgrade) lets a container own a whole /64 in addition to its /112 primary.
A /112 has 16 host bits, so nothing inside the container can use SLAAC or be
delegated further; a /64 is the smallest block that can.
- the /64 the host itself uses (the one holding net.ipv6_subnet) and any /64
it holds a global address in are never handed out. That is the only check:
the operator may point the key at a prefix that contains the host's own /64.
- blocks are stored per account as the CIDR actually given out
(users.ipv6_extra_block, migration v20), handed out lowest-first, and belong
to the account for life — only deleting it releases the block. An exhausted
pool is not an error: the container is simply created without one.
- delivery: the container's own eth0 declares `ipv6.routes = <account /112>,
<block>/64` and DROPS ipv6.address. Both halves are load-bearing, found the
hard way on a real /60 host:
* Incus builds security.ipv6_filtering from the declared addresses and
routes, so an undeclared block is dropped by the bridge — the container
binds its addresses and answers the host, but everything it sources from
the block dies at the veth;
* Incus programs a declared route as `via <ipv6.address>`, and the kernel
refuses that form once the gateway is itself covered by a gateway route
(the account's own /112 route): "no route to host". Omitting the address
makes the routes direct dev routes, which install fine and which Incus
restores on every container start.
- the guest binds <block>::1/64, with its real length and NOT as a local
route, so the prefix is on-link inside and the customer can carve
sub-prefixes out of it; one ndppd rule covers the block like the /112s.
- vps add --extra64 / vps extra64 <name> (CLI) mirror the panel entry points;
EnsureExtraBlockRoutes (vps install, the boot unit, ipv6-reapply) and a
power start re-apply the wiring idempotently.
- tests: pool arithmetic, reserved blocks, the /63 membership trap (a /63's
two /64s differ in the LAST bit of the fourth hextet), and the guest script.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- host sysctl (20-network.sh): tcp_rmem/tcp_wmem max, rmem_max and
wmem_max all go from 4194304 to 8388608
- image builds (50/60/70/80/90 + eol-debian11): tcp_rmem/tcp_wmem max
bumped the same way
- autotune defaults (262144) and the min values are untouched
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The send half printed a UUID-laden path, a standalone checksum and a paragraph
of hand-off instructions; the receive half printed the URL twice and explained
the outcome in three lines. Same information, fewer lines: one line to say what
is being produced and how big it may get, the version and result on one line,
one receive command with two lines about what it brings, and on the receiving
side one line that confirms the checksum and says which build made the archive.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The sender now offers exactly two, so a transfer cannot end up three-quarters
optimized:
default the storage-driver native stream, written as the pool stores it.
Nothing is decompressed and recompressed at any point.
--portable a tar of the files, zstd-compressed: slower and larger, but any
pool driver can restore it and an older receiving build
understands it.
--compression is gone from the sender: it was the knob that let a native stream
be wrapped in a second compression pass, which cost CPU to save almost nothing
over blocks the pool had already compressed. --optimized stays accepted and is
simply what happens without flags, so commands written against 1.10.x keep
working. The output is one line per fact now instead of three overlapping ones.
The receiving half keeps doing the adapting, because the sender may be any
version:
- it reads the sending pool's driver, the compression and the sending build
from the URL fragment and refuses an optimized stream only when it KNOWS
the driver differs — a stream that arrives without a stated driver (an
older sender, or a hand-written URL) is given the benefit of the doubt and
left to Incus, which reports the mismatch itself;
- it prints what it was handed, and which build produced it, before importing.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
A transfer used to describe itself only by its two digests, so the receiving
host found out what it had when Incus either accepted it or did not. The
receiver now knows three more things before it fetches anything, carried in the
URL fragment alongside the checksums (a fragment is never sent to the server,
so the listener still learns nothing about what it serves):
- compression and whether the archive is an --optimized storage-driver stream;
- the sending pool's driver, because an optimized stream is driver-specific
and says nothing about itself: a zfs send stream cannot be unpacked into a
btrfs or dir pool. That check now runs before the metadata fetch, so a
mismatch is refused in a sentence instead of failing an import halfway
through;
- the sending build's version, printed at both ends so an operator can see
which build produced the archive.
parseTransferURL returns one struct rather than ten values, and the
compatibility decision is a pure function with its own tests: a command from an
older build has none of the new fields (zero value = not stated) and one from a
newer build carries extras an older receiver ignores, so plain tar transfers
keep working in both directions across versions. An --optimized archive does
need a receiver that understands it: older builds would try to untar a zfs
stream.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
--optimized is Incus's native backup stream (zfs send / btrfs
subvol), not "compressed". It is the fastest path — 190 MiB in
2 s vs 468 MiB in 10 s for the same 175 MiB container — but the
file is pool-driver-specific and unreadable by any other host, so
it must stay opt-in and not be the default.
The default remains a portable zstd-compressed tar, which is small
enough (104 MiB for that same container) and still 2.5x faster than
the old gzip default. gzip is dropped from the CLI: it was the
slowest option, and none of the three paths (tar/gzip, tar/zstd,
optimized) uses it. The error message and usage text are updated.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The quota dialog has grown a second and a third form, and the plain Cancel
button sitting inside the first form's grid was easy to miss — and it is the
only way out. It now lives in the dialog's own footer at the bottom right,
uses the danger styling, and says what it does: 取消且不保存 / Cancel without
saving.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
gzip is the slowest link in an export: on a single weak vCPU it streams at
~25-40 MB/s. Same container, same host: gzip 116 MiB in 27 s, zstd 104 MiB in
11 s — smaller and 2.5x faster. --compression none remains for incompressible
data or fast LAN links.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- the transfer URL is built from panel.display_ip (falling back to
public_ip), and display_ip may legitimately be a hostname — a host with one
could not send at all ("cannot build a transfer address from ...").
- the listener only embeds the value in a URL and the peer pins the
certificate by fingerprint, so an IP literal was never a real requirement;
the check now rejects only values that cannot survive a URL, and the error
points at both settings.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
- IPv6Block treats a stored index of 0 as "no block": 0 is the block the
bridge gateway lives in, so serving it would put the gateway's own address
on the container. Pool-mode and V4-only accounts store 0 there.
- the v19 migration gets a test that fails its Go step on purpose and asserts
the schema rolls back whole — no column, no index, no version row — and that
the next start re-runs it, plus a second legacy account so the backfill is
proven over more than one row.
- pickIPv6Index is asserted never to hand out the gateway's block, even on an
empty database.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
A prefix-mode container's /112 block index was sha256(username)[0:4], so the
suffix of its address was a global constant for a given name: "alice" got the
same block on every host running this panel. An address therefore identified
its owner to anyone who knew the scheme and could enumerate names — the
opposite of what the random container hostname is for.
The index now lives in users.ipv6_index (schema v19) and is random:
- accounts that predate the column are seeded with the value they have been
using, so no address moves and no container is touched;
- vps add picks a free index and retries internally, with the UNIQUE index on
the column as the backstop, so a name can no longer be refused for a reason
as opaque as "your address is taken" (the old checkIPv6BlockCollision). A
block containing the bridge gateway is skipped the same way.
- deleting an account and recreating the same name yields a fresh random index,
and so does a transfer: the value is per-host and stays out of the metadata.
Add launches a container before creating its account row, so within that window
the block can no longer be looked up by name: WireIPv6 and
ConfigureContainerIPv6 now take the block/address the caller already picked.
Blocks are also only meaningful in prefix mode, which the accessors now say.
Verified on a live prefix-mode host: a user created before the change kept her
address (panel and inside the container), a new user came up on a random block
end to end (guest address, ndppd rule, Incus route), deleting and recreating a
name produced a new random block, and a V4-only host still creates users with
no index at all.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
A container moving to a host with no IPv6 has to lose the address it carried,
and on RHEL-family guests that means telling NetworkManager, not editing a file.
NetworkManager will not accept the change in the obvious order — a method of
"manual" requires an address, and an address is not allowed under a method of
"disabled" — so the guest still came up with the address from the host it left.
Setting the whole final state in one request is what it accepts.
Verified with an AlmaLinux 9 guest in both directions against a prefix-mode and
a pool-mode host: moved to the prefix host it came back up with one bridged NIC
carrying this host's IPv4 and IPv6 (which required re-enabling the IPv4 that the
pool-mode layout had disabled on that NIC); moved to the pool host it came up
pure IPv4 with ipv6.method disabled and no global address at all.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Unifies what a transfer means on the receiving host, and fixes the gap that
made the rule awkward to live with.
Receiving is now decided entirely by the receiving host's own semantics and
nothing else — the sending host's setup is irrelevant, and the metadata carries
no trace of it. Prefix-mode and IPv4-only hosts give the container the address
they give any other container of theirs. A pool-mode host gives it none: a pool
address is a per-container choice its operator makes, so an imported container
arrives as pure IPv4, on the single bridged NIC that layout uses, consuming
nothing from the pool.
That left a real hole in the panel, which is also fixed here: there was no way
to give a pool address to a container that did not have one, even with the pool
sitting empty, so a container imported into a pool-mode host could never get
IPv6 at all. The quota form now offers the free pool addresses for exactly the
containers that have none. Giving one back is deliberately not offered — an
address handed to a customer stays theirs until the account is deleted — and
the assignment reshapes the container (the IPv4 moves to a bridged eth1, the
address goes on a routed eth0) and wires it on the host, rolling the whole thing
back if any step fails.
Incus decides an update device by device against the set it is replacing, so
that reshape goes in two requests: adding eth1 while it already carries the IPv4
fails with "already defined on another NIC", because the eth0 being changed
still holds it. The NIC is added bare first, and the address arrives as a change
to a device that already exists.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
A prefix-mode host and a pool-mode host lay a container out differently —
prefix mode puts the private IPv4 and the routed IPv6 on one bridged eth0, pool
mode puts the public /128 on a routed eth0 and the private IPv4 on a bridged
eth1 — so moving between them needs both halves of the layout to be rebuilt,
not just refilled.
On the host side, the import override now also names every NIC a vpsmgr
container can have and clears every device option this host's own spec does not
decide. It has to: the override can set device keys but never remove a device,
so an eth1 the archive brings and this host has no use for would otherwise be
validated against a network it does not belong to, and an option whose meaning
depends on the NIC's kind ("security.ipv4_filtering" on a routed NIC) fails
outright. Cleared options are the ones this host's spec sets anyway whenever the
mode matches, so the list only bites when the kind of NIC has actually changed.
The spare NIC disappears for good when the configuration is replaced wholesale
a moment later.
Inside the container, one more step was needed in the pool-to-prefix direction:
there the container's eth0 has no DHCP client at all, because on the host it
came from the private address lived on eth1. Without one it never asks for the
address reserved for it and comes up with no IPv4, so every port forward points
at nothing. Its bridged NIC is given back the ordinary setup (the RHEL path
re-enables the NetworkManager profile instead, as that is where its addresses
live).
Verified in both directions between the two test hosts, one installed in prefix
mode and one reinstalled in pool mode: a prefix-mode container moved to the pool
host came up with a routed eth0 carrying an address from that host's pool and a
bridged eth1 carrying its private IPv4, and a pool-mode container moved to the
prefix host came up with a single bridged eth0 carrying both, its old eth1 gone.
In both cases the data was intact, the addresses were the receiving host's own,
and the published SSH port answered through the host's DNAT rules.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The owner should notice nothing but their address, and that is now mostly
true: the quota, the deadline, their SSH keys, their sticky notes and their
init script travel with the archive in a small JSON file served next to it and
checked against its own digest, so nothing has to be retyped on a command line
or remembered from the old machine. The init script is written to the database
and deliberately not run: it belongs to a reinstall, and a migration must not
re-run whatever it did the first time.
The root password is no longer reset either. It lives on the disk, its owner
already knows it, and leaving it alone is the point of a migration that should
feel like nothing happened — verified by logging into the moved container with
the password set on the sending host before it moved.
An account whose deadline has passed is refused outright, with the command to
extend it: starting a container the panel has already locked and stopped is not
something a transfer should do, and an expiry in the past would otherwise be
recreated as a live account. The deadline travels as the absolute date it was,
so moving neither gains nor loses time.
The receiving host checks it has room before fetching anything: the metadata is
small and comes first, and the disk figure in it is the sending host's own, the
same number the export sized its temp file from.
The account's settings are no longer passed as flags. --cpu/--mem/--disk/
--bandwidth/--days on `receive` are gone, along with the code that could have
been used to shrink a disk on the way — quotas may exceed what the target has
room for (the pool is over-committed by design, as it is for `vps add`), but a
migration never reduces one.
Two more things the real daemon taught us, both about the same DHCP handshake:
- A container is handed its static IPv4 through a reservation keyed to its
hardware address, written when the instance is created. Changing that address
afterwards — which is what dropping the source's MAC did — leaves the
reservation naming the old one, and the container then comes up on a dynamic
address while every rule and record points at the static one. The fresh
address is therefore set as part of the import, via X-Incus-config, where
Incus will write the reservation around it. Confirmed on the wire: the
configured, configured-in-the-guest and reserved addresses now agree, and the
lease follows.
- That address arrives over DHCP a moment after the container is up, later than
Readiness means, so it is waited for rather than assumed, with one restart if
the server is still holding the address of the container that was replaced.
Verified on two hosts (Ubuntu 24.04 and 26.04, Incus 7.0.1, zfs): an account
with a key, notes, an init script and a deadline moved with all four intact,
the init script not run, the old root password still working, the transferred
quota in place, its assigned static IPv4 taken and reachable over its published
SSH port; an expired account refused on the sending side with nothing created;
and a failed import rolled back to no account, no container and no files.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Everything below comes from running the feature against two real hosts, where
the assumptions that read fine in the source turned out not to hold.
The receiving side now creates the account instead of replacing one. That is
both simpler and safer: the name must be free, the account is built by the
same code `vps add` uses — same IP, port block, IPv6 assignment, firewall
rules and panel row — and the disk of the container that produced is then
replaced by the transferred one. Nothing that already exists is touched, so
every failure is undone by deleting what was created, and the host ends up
exactly as it was. It also drops the whole staging/rename dance the previous
shape needed.
What the real daemon taught us:
- A direct-stream export (Accept: application/octet-stream) refuses a backup
name: no backup object is created, so the field must not be sent at all.
- Incus validates device configuration while it CREATES the instance, so the
archive's own devices — the source host's IPv6 — are rejected before any
later fix-up can run ("not within network incusbr0 subnet"). This host's
devices therefore go in as part of the import, via X-Incus-devices. Without
them the source's disk quota comes along too: an 8 GiB account arrived on a
host that had allotted 5.
- A static IPv4 is handed out through a DHCP reservation held by the current
lease, and Incus counts a static address as taken by ANY instance that
declares it, stopped ones included. The container being replaced therefore
has to be gone before the replacement starts, or the replacement is given a
dynamic address from the pool — and then nothing can reach it, because the
panel's records and the host's DNAT rules all name the static one.
- The re-key step appends this host's IPv6 address, routed block and gateway
neighbour to the container's networkd file, checking only for its own
values. A container that arrived from another host kept that host's address
as a second global one, so the stanza is now cleared first.
- An archive carries the source's volatile.<nic>.hwaddr, so importing the same
archive twice on one host failed outright with "MAC address ... already
defined on another NIC". Per-instance volatile keys are now dropped, like
the machine-id and SSH host keys before them.
Also from the field: the re-keyed container is checked to actually hold its
assigned IPv4, the quota travels in the printed receive command (--cpu/--mem/
--disk/--bandwidth/--days), and the receiving command's <user> must be free —
receiving onto a taken name is refused before the download rather than after.
Verified on two hosts (Ubuntu 24.04 and 26.04, Incus 7.0.1, zfs): a 316 MiB
container moved and took its data (files, setuid bits, modes, a custom user,
a 200 MiB file), a fresh hostname/machine-id/SSH host key, cleared
authorized_keys, the transferred quota, its assigned static IPv4 and only this
host's IPv6; reachable over its published SSH port. The same archive imported
twice into two accounts; --optimized (zfs-native) likewise; a wrong
certificate pin refused in under a second with nothing created; a mid-import
failure rolled back to no account, no container and no files; idle exit, SIGHUP
and a killed CLI all left no archive behind.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Two vpsmgr hosts that share nothing but a network can now hand a container's
root disk over: `vps transfer send <user>` exports the stopped container to a
single archive and serves it from a temporary listener, and
`vps transfer receive <url> <user>` fetches it, verifies it, and rebuilds the
container from it. It is what an operator needs to move a customer between two
machines in different datacenters, where the existing snapshot sharing cannot
reach: that feature is a copy-on-write clone inside one pool, so no byte ever
leaves the host.
Only the disk travels. Domains, SSH keys, quotas, expiry, bandwidth and
resource history are configuration this host owns and deliberately keeps to
itself -- the two hosts do not share admin keys -- so the receiving side
re-homes the container onto its own address, ports and quota instead of
adopting the sender's.
Decisions worth recording:
- The archive is an instance backup streamed with Accept:
application/octet-stream, so Incus sends the tarball straight down the
socket and nothing is staged in the storage pool first. Publishing an image
would have been the cleaner artefact but it materialises the whole rootfs
inside the pool, which fails on exactly the nearly-full hosts a migration
tends to involve.
- The transfer runs as root from the CLI and has no panel entry. It puts a
user's disk on the network; no panel request should be able to do that.
- The certificate pin and the archive checksum both ride in the URL fragment,
which HTTP clients never transmit, so the pasted command carries the whole
trust decision and the listener never learns either value.
- An import parks the container it replaces under a temporary name and brings
the new one into place only after it starts and provisions. A failure at any
point puts the original back, rather than leaving the user with nothing.
- The listener stops after five idle minutes and the archive is deleted on
every exit path, including SIGINT/SIGHUP during the export itself.
- lx: a deadline-free HTTP client for the transfer (the 60s cap would abort
any real transfer), plus BackupExport/BackupImport/ReplaceConfig/DiskUsage
- lx: ReplaceConfig replaces an imported container's configuration wholesale
while carrying the daemon's volatile.* keys through unchanged
- mgr: the export/import orchestration, including fresh machine-id and SSH
host keys (a copied rootfs otherwise makes two hosts claim one identity)
- mgr: a temporary HTTPS listener with one path, Range support for resuming a
dropped download, and a stalled-connection guard
- cli: `vps transfer send|receive`, live progress on both sides, space
checked against the container's actual size before a long export starts
- tests: pinning, checksum, resume, unknown-token, URL parsing, idle exit
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
A host whose RTC came up eight hours fast at boot -- systemd-timesyncd
corrected it seconds later -- took its very first resource sample before that
correction, so the row was written with a sample_minute eight hours in the
future. The sampler kept recording normally, but that single row won
MAX(sample_minute) forever, so every reader served the stale boot-time
snapshot: both panels showed all containers Stopped with no CPU or memory until
wall-clock caught up. Restarting does not help, because the row lives in the
database.
Reads cannot tell a mis-dated row from a legitimate one -- a clock jumped
forward and a machine powered off for hours look identical from inside -- so
the guard belongs on the read side, not the write side:
- LatestResourceSamples picks the newest sample at or before now rather than
the newest overall, so one future-dated row can no longer freeze the panels
- ResourceHistory, behind the user panel's 24h chart, skips future-dated
samples for the same reason
- the row is still stored, and the real sample overwrites it in place once
wall-clock reaches that minute, so the series keeps no gap or duplicate
- tested: the future sample is persisted but never served by either reader
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>