Add net.ipv6_pin_gateway (off by default): make the HOST's upstream
link-local default gateway a permanent neighbour entry, so the kernel stops
probing it.
Why: on a host whose provider router answers neighbour solicitations
unreliably, the host's neighbour for its default nexthop drops to INCOMPLETE,
and the host then answers every forwarded packet — every container's IPv6
included — with "address unreachable" until it recovers. The container keeps
its address and can ping the bridge, but nothing off-link works,
intermittently; the host's own IPv6 drops at the same moments. Captured
on-bridge: the host (the bridge's global address) returning ICMP6
destination-unreachable for the container's packets, correlated with the
gateway neighbour going REACHABLE -> DELAY -> PROBE -> INCOMPLETE.
Applied by RewireAllIPv6, so `vps ipv6-reapply` (the boot unit) and
`vps install` re-apply it, and `vps config set net.ipv6_pin_gateway true`
applies it immediately. It only touches the host's own link-local default
gateway, and only while that gateway is resolved: the MAC is read from the
live neighbour table by the new `vps ip6 neigh-pin` root helper
(`neigh-unpin` drops the entry again when the key is turned off). Off by
default because a permanent entry is never re-resolved — a gateway that
later moves to another MAC would be missed.
Also drop the bridge-MAC pin from 02d55d2 (and lx.NetworkGet with it):
measured on a live host, Incus keeps a managed bridge's MAC stable across
two reboots even with bridge.hwaddr unset and no stored MAC, so that pin
guarded a failure that does not occur.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
24 KiB
Configuration
The config file is at /etc/vpsmgr/config.yaml (auto-generated at install,
root-only read/write). The VPSMGR_CONFIG env var points the vps binary at
another path; the shell install/uninstall scripts always use
/etc/vpsmgr/config.yaml.
The installer is intended for a fresh, dedicated host unless existing services,
ports, bridges, UFW, and firewall rules have been checked. For a host that must
keep other public services and only needs IPv6 inbound access, install with
./install.sh --disable-v4forward; this requires an explicit confirmation,
skips vpsmgr's reserved-port check, writes net.v4_forward=false, and leaves
HAProxy installed but stopped.
Editing config.yaml by hand is discouraged. The sanctioned interface is
vps config list / vps config set / vps config help, which validate every
change and refuse immutable fields. A raw YAML edit still works but is not
checked — get the rules from vps config help.
Field matrix (kind + how a change applies)
Every field is classified exactly one way — there is no ambiguity about who
may change it or when the change takes effect. vps config help prints the
same table; vps config list shows the live values with this annotation.
| Key | Kind | Change applies | Notes |
|---|---|---|---|
panel.listen |
operator | systemctl restart vps |
a FRESH install picks a random free port in 2000-9999 |
panel.cert |
operator | restart panel | TLS certificate path |
panel.key |
operator | restart panel | TLS private key path |
panel.db |
operator | re-run vps install |
SQLite database path |
panel.public_ip |
operator | re-run vps install |
NIC IPv4 used by firewall/routing; cert is regenerated |
panel.display_ip |
operator | restart panel | address shown to users (panel URL / SSH hints); any string without spaces (IP or domain), or empty = fall back to public_ip |
panel.title |
operator | restart panel | name on the login page and in the panel header; empty = VPS Manager |
panel.session_days |
operator | restart panel | login session lifetime (days) |
panel.url_path |
fixed at install | — | secret prefix of the user panel; settable only while empty (re-enable) |
panel.admin_url_path |
operator | restart panel | secret prefix of the admin panel; an empty value disables the admin panel (shown as disabled) |
panel.show_footer |
operator | restart panel | user panel footer with project link and installed/build version; false hides the entire footer |
panel.web_ssh |
operator | restart panel | browser terminal in the user panel; default true. When false the Web SSH button is not rendered and the panel refuses the terminal page and connection outright |
panel.bandwidth_reset_day |
operator | restart panel — discards stored state | day of month the monthly bandwidth quota resets, 1-28 (28 keeps February safe); default 1. Changing it wipes every user's accumulated traffic totals — see below |
panel.admin_pass_hash |
managed elsewhere | — | bcrypt hash of the admin password; stored in the DB, set via vps admin-passwd / web UI |
net.subnet |
fixed at install | — | container subnet 10.<n>.0.0/24; changing breaks existing containers |
net.gateway |
fixed at install | — | bridge gateway (derived from subnet) |
net.user_ports |
operator | next vps add / reinstall |
user-port ranges a new container's 100-port block is drawn from; comma-separated inclusive ranges (e.g. 10000-29999 = 200 containers, or 10000-20000, 25000-30000); auto-aligned to whole hundreds; affects new containers only — existing ones keep their ports |
net.v4_forward |
runtime policy | applied immediately | true = direct IPv4 SSH/port DNAT; false = IPv4 inbound fully off; web-only = public IPv4 and optional HAProxy 80/443 domains, with direct IPv4 ports off; NAT4 outbound always remains |
net.haproxy |
runtime toggle | applied immediately | false = stop and disable HAProxy; existing domains are retained, but new domains cannot be added. Renamed from net.traefik — same values; an old key is read and rewritten under the new name |
net.ext_if |
operator | re-run vps install |
external NIC (auto-detected from default route) |
net.ipv6_subnet |
operator | re-run vps install |
global IPv6 prefix for pass-through, e.g. 2602:fada:6::/64; empty = disabled (does not remove IPv6 state already applied, see note below) |
net.ipv6_mode |
fixed at install | — | IPv6 allocation mode: none / prefix (/112 blocks) / pool (per-container address) |
net.ipv6_pool |
operator | vps config set needs --apply; admin-panel changes apply immediately |
pool-mode address list (bare global addresses or /128); editable via the admin panel's IPv6 Pool page |
net.ipv6_extra_prefix |
operator | applied immediately (rewires host IPv6 + restarts the panel); admin-panel changes apply immediately too | optional extra prefix (prefix mode alongside /112, or IPv4-only none mode as standalone routed /64) that a container can be given a whole /64 out of, e.g. 2001:1c00:b1b:7f0::/60; empty = feature off (the default, including on every upgrade). Accepted as-is — the /64 this host itself uses is never handed out |
net.ipv6_pin_gateway |
operator | applied immediately (host IPv6 rewired; no panel restart) | pin the host's upstream link-local default gateway as a permanent neighbour, working around a provider router that answers neighbour solicitations unreliably (the host's default route otherwise drops to INCOMPLETE and every container loses IPv6 with it). Off by default: a permanent entry is never re-resolved, so a gateway that moves to another MAC is then missed |
incus.image |
operator | next vps add / reinstall |
container image alias |
incus.image_fallback |
operator | next vps add / reinstall |
fallback remote image |
incus.pool |
fixed at install | — | storage pool (backend selected at install via VPSMGR_STORAGE=zfs|btrfs|dir) |
incus.bridge |
fixed at install | — | managed bridge |
incus.socket |
operator | restart panel | Incus daemon Unix socket |
incus.swap_ratio |
operator | applied immediately | swap granted to each container as a multiple of its memory limit (limits.memory.swap = limits.memory × ratio); 0 disables container swap. Setting it re-applies the allowance to all existing containers (no restart) |
snapshots.limit |
operator | restart panel | max checkpoints a user may keep per container (0 = disable new snapshots). Restoring to an older checkpoint auto-deletes the ones created after it (see below) |
snapshots.share |
operator | applied immediately | allow users to share a checkpoint behind a code and install from it. Off by default; CLI-only: vps config set snapshots.share true. Stored codes survive while it is off |
Settings that are NOT in this file
The dynamic CPU limit rule is edited on the admin overview and stored in the
DB: it has no config.yaml key and no vps config key, because it is tuned
repeatedly and must apply immediately. Saving applies it on the spot (turning it
off restores every capped container), and vps install never resets it.
How "fixed at install" is enforced
Fields marked fixed at install (net.subnet, net.gateway, incus.pool,
incus.bridge, panel.url_path, net.ipv6_mode) are snapshotted into the DB settings table on
the first vps install. Every later vps install and vps serve compares the
live config against that snapshot and refuses to run if any of them drifted
— vps config set also refuses them up front. The user panel path
(panel.url_path) is immutable on purpose: moving it strands every user who
bookmarked it. The admin path is NOT immutable — it can be rotated and emptied
to disable the admin panel.
Managed files
Files generated by the panel carry a Managed by vpsmgr — generated, do not edit by hand banner and are overwritten on the next write:
| File | Written by |
|---|---|
/etc/vpsmgr/nftables.conf |
vps install |
/etc/vpsmgr/nftables.d/user-<name>.nft |
vps add / quota / firewall updates |
/etc/haproxy/haproxy.cfg |
install (from configs/haproxy/haproxy.cfg); static entry config, never rewritten by the panel |
/etc/haproxy/releases/<gen>/{domains.cfg,maps/*.map,manifest} |
domain add/update/delete (a new immutable generation) |
/etc/haproxy/current |
domain add/update/delete (symlink to the generation in service) |
/etc/ndppd.conf |
IPv6 pass-through updates |
/etc/sysctl.d/99-vpsmgr.conf |
vps install |
systemd units (vps/vps-nft/vps-ipv6, haproxy) |
vps install |
Example
panel:
listen: ":5231" # panel listen address — a FRESH install picks a random free port in 2000-9999
cert: /etc/vpsmgr/panel.crt # HTTPS certificate
key: /etc/vpsmgr/panel.key # private key
db: /etc/vpsmgr/vpsmgr.db # SQLite database
public_ip: AUTO # NIC IPv4 used by the firewall / routing; on NAT-ing clouds (AWS/Alibaba) this is a private address
display_ip: AUTO # address shown to users (panel URL, SSH hints); any string without spaces (IP or domain); auto-fetched from ipv4.ip.sb when public_ip is private; empty = fall back to public_ip
session_days: 3 # login session lifetime (days)
bandwidth_reset_day: 1 # day of month the monthly bandwidth quota resets (1-28).
# CHANGING IT WIPES every user's accumulated traffic totals.
# `vps config set` asks for confirmation.
url_path: AUTO # random secret path, the only panel entrance; do not change after first install
admin_url_path: AUTO # random secret path of the admin panel; do not change after first install
admin_pass_hash: AUTO # bcrypt hash of the admin password — stored in the DB, not in this file
net:
subnet: "10.115.0.0/24" # container subnet 10.<n>.0.0/24 — only the second octet is settable, at install
gateway: "10.115.0.1"
v4_forward: true # true = direct IPv4 SSH/ports; false = fully off; web-only = 80/443 domains only
haproxy: true # domain reverse proxy (false = stopped and not enabled at boot);
# renamed from `traefik` — a pre-rename key is adopted as-is
ext_if: AUTO # external NIC, auto-detected from the default route
ipv6_mode: "" # IPv6 allocation mode: none / prefix / pool (fixed at install)
ipv6_subnet: "" # optional: global prefix for IPv6 pass-through, e.g. "2602:fada:6::/64"
# (/64 or shorter uses the first /64; provider /80 slices like
# "2406:da14:1dd2:a807:753a::/80"); empty = disabled (default),
# but does not remove IPv6 state already applied
ipv6_pool: [] # optional (pool mode): the /128 addresses containers are
# assigned from, e.g. "2001:db8:1::9c4" (bare global
# addresses, no prefix length)
ipv6_extra_prefix: "" # optional: an extra prefix a container can be given
# a whole /64 out of, e.g. "2001:1c00:b1b:7f0::/60"
# (/48, /56, /60 ...). Works in prefix mode or IPv4-only
# (none) mode. Empty = off (the default). The /64 this host
# uses itself is never handed out. See docs/ipv6.md
ipv6_pin_gateway: false # optional: pin the host's upstream link-local default
# gateway as a PERMANENT neighbour, working around a
# provider router that answers NDP unreliably. Off by
# default — a permanent entry is never re-resolved.
# See docs/ipv6.md
incus:
image: "vpsmgr/debian-sshd"
image_fallback: "images:debian/13"
pool: vpsmgr
bridge: incusbr0
socket: "/var/lib/incus/unix.socket" # Incus daemon Unix socket (REST API)
swap_ratio: 0.5 # swap per container as a multiple of its
# memory limit (0 = no swap; 0.5 = a 1 GiB
# container may use 512 MiB host swap);
# applied to all containers immediately on set
snapshots:
limit: 1 # 0 disables new snapshots
share: false # allow checkpoint share codes (opt-in:
# install from someone's checkpoint)
Port scheme
The port layout per container is fixed at install and not individually tunable,
but the span new containers may use follows net.user_ports:
- Panel port: random free port in
2000-9999, chosen on a fresh install and stored inpanel.listen. Change it in the config only if you know why. - SSH port: each container gets one random port in
30000-31999(TCP, DNAT to container:22). Independent of the user-port ranges. Not reserved at install — each port is probed atvps addtime, and a candidate already listened on by another process is skipped for another. Shown asssh -p <port>. - User ports: each container owns a whole-hundred block of 100 ports, drawn
at create time from the configured
net.user_portsranges, DNAT to the container (TCP and UDP). Displayed compactly as e.g.107xx.
User-port ranges (net.user_ports)
net.user_ports names the port ranges a new container's 100-port block may
be drawn from, e.g. 10000-29999 (full) or 10000-20000, 25000-30000 (leave a
gap for the host). Set it at install (00-ip-ask.sh asks right after the
subnet octet) or change it later:
vps config set net.user_ports 10000-20000, 25000-30000
Rules:
- Comma-separated inclusive ranges
A-B(integers,A <= B). Ends may extend outside10000-29999— only the overlap with the usable domain counts, and a fully-outside range contributes nothing. - Values are auto-aligned to whole hundreds: the low end rounds up to a
block start and the high end rounds down to a block end (
...99 <= B), so the effective range always ends in...99, never...00, and never spills past the requested upper bound. For example10001-29998becomes10100-29899, and10000-15000becomes10000-14999. - At least one whole 100-port block must remain (the value is rejected otherwise) — a range that cannot host even one container is an error.
- Capacity follows the ranges:
10000-29999allows 200 containers;10000-20000, 25000-30000allows 150; and so on. The interactive prompt echoes the computed capacity for confirmation. - A narrowed set reserves far fewer host user ports, so an install's
port-occupancy check scans only the configured ranges — not the whole
10000-29999.80/443is reserved regardless; SSH ports (30000-31999) are not checked at install — each is probed atvps addtime and the allocator reassigns on conflict (a live listener on the candidate is skipped). - It affects new containers only: editing the ranges never renumbers or removes an existing container (one whose block falls outside the current ranges keeps it). The admin panel's capacity readout reflects the ranges.
Legacy: configs written before
net.user_portsusednet.slot_range(a range of v4 last octets, e.g.2-201). On upgrade the installer converts it once to the equivalentuser_portsvalue and writes the new key; the old key stays in the file but is ignored and is no longer an editable config key.
IPv4 inbound policy (v4_forward)
net.v4_forward controls the IPv4 inbound policy. It is enabled by default and
accepts three values:
true(default): containers get direct SSH and user-port-block DNAT, and HAProxy domains are available whennet.haproxy=true.web-only: direct IPv4 SSH/port DNAT is removed, but the public IPv4 remains visible in the user panel and HAProxy can forward domains on host ports 80/443 whennet.haproxy=true. Domain add/update/delete remains available. The user panel still shows IPv4 SSH as unsupported.false: IPv4 inbound is fully off. HAProxy is stopped (domains are kept but not served), the public IPv4 is hidden, and adding a domain is rejected. Containers still reach IPv4 outbound through the NAT4 masquerade.
Change it at runtime with
vps config set net.v4_forward true|false|web-only. Direct DNAT files are
refreshed immediately; HAProxy boot autostart follows the effective policy.
Stored SSH/user ports remain in SQLite, so changing back to true restores the
same allocations.
web-only is a vpsmgr application policy: it removes vpsmgr's per-user direct
DNAT while continuing to use HAProxy for 80/443. Kernel IPv4 forwarding, the
Incus bridge, NAT4 masquerade, and Docker compatibility rules are unchanged;
this setting does not install a host-wide packet allowlist.
Domain proxy (net.haproxy)
net.haproxy independently controls the HAProxy domain reverse proxy. It is
enabled by default. Set it with vps config set net.haproxy false to stop
HAProxy and disable its boot autostart. Existing domain records and the
published configuration are kept, but users cannot add new domains while it is
disabled. Re-enabling the setting starts HAProxy, restores autostart, and
republishes the configuration from the database.
Upgrading from a Traefik-era install. This key used to be called
net.traefik. The values are identical (true= running and enabled at boot,false= stopped and disabled), and the rename is handled automatically:
- a
config.yamlcarryingtraefik:is read asnet.haproxy(the new key wins if both are somehow present), and the next save rewrites only the new key;- the DB runtime mirror (
settings.traefik) is moved tosettings.haproxyby schema migration v18, keeping the stored value;- the installer still honors
VPSMGR_TRAEFIKas a fallback forVPSMGR_HAPROXY(an older checkout exports the old name);install.shremoves the Traefik program, unit and service account and republishes the domains onto HAProxy, keeping only/etc/traefik/traefik.yaml(inert).There is no rollback to Traefik: HAProxy is the only proxy the panel knows.
Install-time auto-off: if port 80 or 443 is already bound by a
non-vpsmgr process during install.sh, the installer does not fail. Instead
it proceeds with HAProxy installed but net.haproxy: false (not started, no
boot autostart), so a host that already serves 80/443 stays usable. The
00-check.sh port-occupancy scan reports it and installs the proxy disabled;
re-enable later with vps config set net.haproxy true.
HAProxy layout and version
The proxy itself is HAProxy 3.4 LTS, installed from the official HAProxy
performance packages (haproxy-awslc) for the host's distribution. The panel
pins the branch (3.4), not the patch: install.sh and
upgrade-haproxy.sh keep the newest patch of that branch, and
install.sh --update moves the panel and the proxy together. A branch change
is a deliberate migration — update HAPROXY_BRANCH / HAPROXY_REPO_SLUG /
HAPROXY_BRANCH_RE in scripts/30-haproxy.sh and upgrade-haproxy.sh, plus
cfg.HaproxyBranch, then re-run install.sh.
| Path | Written by |
|---|---|
/etc/haproxy/haproxy.cfg |
install (static entry config; the unit loads it first and current/domains.cfg second) |
/etc/haproxy/current |
panel — symlink to the generation in service |
/etc/haproxy/releases/<gen>/ |
panel — one immutable generation (three kept) |
/etc/haproxy/generation |
panel — counter of the last published generation |
/etc/systemd/system/haproxy.service |
install (overrides the distribution's unit) |
Monthly bandwidth reset day (panel.bandwidth_reset_day)
The quota period runs from the configured day of one month to that day of the
next (default 1 = calendar months). The user panel shows which reset applies
to the period in force, including which month it falls in — with the reset day
set to the 22nd and today the 16th, the period ends on the 22nd of this
month, because it started on the 22nd of the previous one.
Changing this setting discards every user's accumulated bandwidth totals. The accumulated counters are keyed by an accounting period derived from this day, so moving the day moves the period key, and the next sampler pass sees "a new period" and starts from zero. Any over-quota throttle is lifted as a side effect, and past totals are not recoverable — setting the day back does not bring them back.
This is why the key is classified as destructive:
vps config set panel.bandwidth_reset_day <n>asks for an explicit confirmation describing the loss (and repeats it when the change is saved with--no-apply, since then it lands at the next panel restart instead). To reset ONE user's traffic on purpose, use the admin panel's "重置流量统计" button instead — that is the supported, per-user operation.
Port 25 (SMTP) is always blocked
The host nftables ruleset drops port 25 for all forwarded traffic, both directions, TCP and UDP (containers ⇄ internet, container ⇄ container). This is a permanent anti-spam measure: there is no user toggle, and the rule only goes away when the panel is uninstalled.
Field notes
panel.listen,panel.public_ip,net.ext_ifonly affect display/on how the panel listens.url_path,net.subnet,incus.pooletc. are bound to existing data — do not change them after first install.net.ipv6_subnetmust be a global (non-ULA) IPv6 CIDR with an explicit prefix length — a bare address is rejected, never silently assumed/64. The validator accepts a global prefix of/80or shorter;/48../80are the documented operational range (see ipv6.md).net.ipv6_modeis fixed at install and immutable (likenet.subnet):none(pure IPv4),prefix(classic /112 blocks),pool(per-container address fromnet.ipv6_pool). Switching modes would renumber containers.net.ipv6_extra_prefixis optional and off by default — an upgraded host keeps running exactly as before until an operator fills it in, and nothing in the installer or the panel prompts for it. It works inprefixmode (alongside the /112) and innonemode (a standalone routed /64), is accepted as the operator enters it (no ownership check: they assert the prefix is theirs), and never renumbers an existing container. Changing it takes effect immediately: host IPv6 plumbing (forwarding,proxy_ndp, NDP rules, block routes) is rewired, the boot unit is refreshed and the panel is restarted so the create form updates. A value that holds no whole /64 (a /64 or longer) simply leaves the feature inert.net.ipv6_pin_gatewayis optional and off by default. It makes the host's own upstream default gateway — only when that gateway is a link-local address — a permanent neighbour entry, so the kernel stops probing it. That works around providers whose router answers neighbour solicitations unreliably: the host's default nexthop otherwise falls toINCOMPLETE, and every forwarded packet, every container's IPv6 included, is answered with address unreachable until it recovers. It is re-applied byvps ipv6-reapplyandvps install, touches only that one gateway, and does nothing while the gateway is unresolved. Turning the key off drops the permanent entry again. The trade-off is why the default is off: a permanent entry is never re-resolved, so a gateway that later moves to a different MAC is not picked up.- Clearing
net.ipv6_subnet(empty) only stops vpsmgr from applying IPv6 on the nextvps install; it does not remove IPv6 already in place (bridge address, ndppd, routes). Full IPv6 cleanup happens inuninstall.sh.