# 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..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-.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//{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 ```yaml 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..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 in `panel.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 at `vps add` time, and a candidate already listened on by another process is skipped for another. Shown as `ssh -p `. - **User ports**: each container owns a whole-hundred block of 100 ports, drawn at create time from the configured `net.user_ports` ranges, 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: ```sh vps config set net.user_ports 10000-20000, 25000-30000 ``` Rules: - Comma-separated inclusive ranges `A-B` (integers, `A <= B`). Ends may extend outside `10000-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 example `10001-29998` becomes `10100-29899`, and `10000-15000` becomes `10000-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-29999` allows **200** containers; `10000-20000, 25000-30000` allows **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/443` is reserved **regardless**; SSH ports (`30000-31999`) are **not** checked at install — each is probed at `vps add` time 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_ports` used `net.slot_range` (a > range of v4 last octets, e.g. `2-201`). On upgrade the installer converts it > once to the equivalent `user_ports` value 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 when `net.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 when `net.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.yaml` carrying `traefik:` is read as `net.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 to `settings.haproxy` > by schema migration v18, keeping the stored value; > - the installer still honors `VPSMGR_TRAEFIK` as a fallback for > `VPSMGR_HAPROXY` (an older checkout exports the old name); > - `install.sh` removes 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//` | 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 ` 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_if` only affect display/on how the panel listens. - `url_path`, `net.subnet`, `incus.pool` etc. are bound to existing data — do not change them after first install. - `net.ipv6_subnet` must 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 `/80` or shorter; `/48`..`/80` are the documented operational range (see [ipv6.md](ipv6.md)). - `net.ipv6_mode` is fixed at install and immutable (like `net.subnet`): `none` (pure IPv4), `prefix` (classic /112 blocks), `pool` (per-container address from `net.ipv6_pool`). Switching modes would renumber containers. - `net.ipv6_extra_prefix` is **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 in `prefix` mode (alongside the /112) and in `none` mode (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_gateway` is **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 to `INCOMPLETE`, and every forwarded packet, every container's IPv6 included, is answered with *address unreachable* until it recovers. It is re-applied by `vps ipv6-reapply` and `vps 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 next `vps install`; it does not remove IPv6 already in place (bridge address, ndppd, routes). Full IPv6 cleanup happens in `uninstall.sh`.