feat(haproxy): replace Traefik with a pinned HAProxy domain proxy

The panel now fronts user domains with HAProxy 3.4 LTS (official performance
packages) instead of Traefik: about 100 MB of RSS and 200 MB of disk saved for
a feature set that never used more than Host routing on :80 and SNI passthrough
on :443.

- hpx: publish the proxy configuration as immutable GENERATIONS. Every domain
  mutation renders the whole set from the DB, validates it with the pinned
  `haproxy -c`, and only then repoints the `current` symlink, so a rejected
  candidate leaves the running proxy untouched and a stale domain can never
  survive into a later generation. Routing is carried by two map files
  (Host -> backend, SNI -> backend): per-domain frontend rules cannot work from
  a second file because HAProxy rejects duplicate section names since 3.3 and
  attaches bare directives to the section opened last.
- mgr: republish and reload (SIGUSR2, i.e. `systemctl reload`) after every
  domain change, so a change takes effect without dropping established HTTP or
  TLS connections. Rollback semantics (DB first, then publish) are unchanged.
- cfg,db: `net.traefik` is renamed to `net.haproxy` with identical values. A
  pre-rename config.yaml key and the legacy VPSMGR_TRAEFIK env var are still
  read (new name wins), and schema migration v18 moves the settings mirror from
  `traefik` to `haproxy`, keeping a newer value when both rows exist.
- 30-haproxy.sh: install the pinned branch from the vendor repository and
  migrate a Traefik host in one run — install and validate HAProxy first, then
  remove Traefik's binary, unit, service account and dynamic rules (only its
  config file is kept, inert). No fallback keeps both proxies alive.
- upgrade-haproxy.sh: follow the pinned branch to its newest patch, with a
  configuration check and a graceful reload; `install.sh --update` runs it.
- tests: generation publish/rollback, map rendering, config-key and settings
  migrations, plus real-binary tests (skipped unless
  VPSMGR_TEST_HAPROXY_BIN is set) that run the generated configuration through
  `haproxy -c` and start it in a network namespace.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-16 12:59:13 +00:00
1 parent f6e0727ca4
commit 4c93ae4be3
47 files changed
+2736 -649

No files matched your search

+4 -1
View File
@@ -71,7 +71,10 @@ Keep the top-level READMEs concise; technical detail belongs in `docs/`
## Key invariants — do not break
- `uninstall.sh` **without** `--purge` must keep `/etc/vpsmgr` (config/db)
and `/etc/traefik` so reinstall adopts them; only `--purge` deletes them.
and `/etc/haproxy` so reinstall adopts them; only `--purge` deletes them.
A pre-HAProxy install's `/etc/traefik` is kept by the same rule (its config
file is inert; the installer removes Traefik's binary/unit/account and the
per-domain dynamic directory).
- IPv6 bridge prefix is clamped to ≥ /64 (`bridgePrefixLen`): Incus's dnsmasq
only serves /64 networks. Container addresses always live in the first /64
of the configured prefix.
+1 -1
View File
@@ -40,7 +40,7 @@ If the host already runs other public services and you only need IPv6 inbound ac
sudo ./install.sh --disable-v4forward
```
This option asks for confirmation at the beginning. Once confirmed, it writes `net.v4_forward=false`, skips vpsmgr's reserved-port check, and only needs one randomly selected panel entry port. Traefik is still installed but remains stopped. IPv4 inbound forwarding can later be restored with `vps config set net.v4_forward true`.
This option asks for confirmation at the beginning. Once confirmed, it writes `net.v4_forward=false`, skips vpsmgr's reserved-port check, and only needs one randomly selected panel entry port. HAProxy is still installed but remains stopped. IPv4 inbound forwarding can later be restored with `vps config set net.v4_forward true`. The domain proxy can also be disabled on its own with `vps config set net.haproxy false` (renamed from `net.traefik`; the values are unchanged and a pre-rename config is adopted as-is).
ZFS is the default storage backend. `btrfs` is supported as an alternative
(on a btrfs host the pool becomes a native subvolume; elsewhere a loop file)
+1 -1
View File
@@ -45,7 +45,7 @@ sudo ./install.sh --local-build
sudo ./install.sh --disable-v4forward
```
该参数会在安装开始时要求确认;确认后写入 `net.v4_forward=false`,跳过 vpsmgr 保留端口检查,只随机选择一个面板入口端口。Traefik 仍会安装但不会启动。以后可用 `vps config set net.v4_forward true` 恢复 IPv4 入站转发。Traefik 也可通过 `vps config set net.traefik false` 独立关闭;关闭后不会运行或自启,且不能添加新域名。
该参数会在安装开始时要求确认;确认后写入 `net.v4_forward=false`,跳过 vpsmgr 保留端口检查,只随机选择一个面板入口端口。HAProxy 仍会安装但不会启动。以后可用 `vps config set net.v4_forward true` 恢复 IPv4 入站转发。HAProxy 也可通过 `vps config set net.haproxy false` 独立关闭;关闭后不会运行或自启,且不能添加新域名。(该开关原名为 `net.traefik`,取值语义不变,旧配置会被自动读取并使用。)
### IPv6
+8 -1
View File
@@ -33,8 +33,15 @@ net:
user_ports: "10000-29999"
v4_forward: true # IPv4 inbound policy. false (only meaningful with
# ipv6_subnet set) = containers are IPv6-only: no
# SSH/port-block DNAT and traefik/domains disabled
# SSH/port-block DNAT and haproxy/domains disabled
# (NAT4 outbound kept). Toggle at runtime: vps config set net.v4_forward true|false
haproxy: true # domain proxy (HAProxy). true = running and enabled
# at boot; false = stopped and disabled at boot, with
# existing domain records kept but no new domains
# accepted. Renamed from `traefik` — same values; a
# Traefik-era config carrying `traefik:` is read as-is
# and rewritten under the new name. Toggle at runtime:
# vps config set net.haproxy true|false
ext_if: AUTO # detected from default route
ipv6_mode: none # fixed at install: none, prefix, or pool
ipv6_subnet: "" # optional: global prefix for IPv6 pass-through, e.g. "2602:fada:6::/64".
+6
View File
@@ -0,0 +1,6 @@
# Managed by vpsmgr — generated file, do not edit by hand.
# Changes are overwritten on the next write; use the panel / `vps` CLI instead.
#
# BOOTSTRAP COPY ONLY: the first generation has no domains yet. The panel's
# publisher (internal/hpx) writes the real file, containing one HTTP backend,
# one TCP (443) backend and the routing rules per domain.
+94
View File
@@ -0,0 +1,94 @@
# Managed by vpsmgr — installed file, do not edit by hand.
# Changes are overwritten on the next install.
#
# This is the STATIC entry configuration: it is installed once by
# scripts/30-haproxy.sh and never rewritten by the panel. The unit loads it
# FIRST and the generation in service SECOND:
#
# /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg \
# -f /etc/haproxy/current/domains.cfg
#
# Layout of the published side (all written by the panel, atomically):
#
# current -> releases/<NNNNNN>/ the generation in service
# domains.cfg one backend per domain
# maps/http.map Host -> backend (port 80)
# maps/sni.map SNI -> backend (port 443)
# manifest generation, domain count, timestamp
#
# Routing uses those maps so that adding or removing a domain never changes a
# frontend: a map miss yields an empty backend name, which HAProxy treats as
# "no rule matched" and falls through to default_backend (the reject backend).
# Keep this file free of anything domain-specific — it is the one part of the
# layout a reload cannot change.
global
log /dev/log local0
log /dev/log local1 notice
maxconn 4096
# Started as root by systemd (to bind 80/443), then dropped to 'haproxy'.
user haproxy
group haproxy
# Master-worker is selected with -Ws on the command line, which systemd
# needs for Type=notify; the old `master-worker` directive is deprecated.
#
# A reload keeps the old worker alive until its connections finish. This
# bounds how long a stuck worker may linger (5 minutes) so repeated reloads
# cannot accumulate processes. It applies to the DRAIN of established
# connections, not to new ones, so a long-lived SSH/WebSocket tunnel may in
# principle be cut short — raise it if that ever matters more than memory.
hard-stop-after 5m
# Local admin socket: used by `vps` diagnostics and for graceful reloads.
stats socket /run/haproxy/admin.sock mode 660 level admin
defaults
log global
option dontlognull
retries 2
timeout connect 5s
# Deliberately generous: SSH tunnels and WebSockets live for hours and the
# 443 path is a raw TCP passthrough, so there is nothing to time out early.
timeout client 1h
timeout server 1h
timeout tunnel 1h
# ---------------------------------------------------------------------------
# :80 - HTTP host routing to the containers
# ---------------------------------------------------------------------------
frontend fe_http
mode http
bind :80
option httplog
# Normalize before the map lookup: lowercase, then drop a ":port" suffix
# that a client may have sent.
http-request set-var(txn.host) req.hdr(host),lower,regsub(:[0-9]+$,)
http-request set-header X-Forwarded-Proto http
http-request set-header X-Forwarded-Port 80
# The client address reaches the container as X-Forwarded-For, added by the
# backends (option forwardfor). PROXY protocol is never used on :80.
use_backend %[var(txn.host),map(/etc/haproxy/current/maps/http.map)]
default_backend be_http_reject
backend be_http_reject
mode http
http-request deny deny_status 404
# ---------------------------------------------------------------------------
# :443 - TLS SNI routing (PASSTHROUGH: HAProxy never terminates TLS here)
# ---------------------------------------------------------------------------
frontend fe_tls
mode tcp
bind :443
option tcplog
# The SNI is only available once the ClientHello has arrived; without an
# inspect-delay the routing decision would be taken before it is known.
tcp-request inspect-delay 2s
tcp-request content accept if { req.ssl_hello_type 1 }
use_backend %[req.ssl_sni,lower,map(/etc/haproxy/current/maps/sni.map)]
default_backend be_tls_reject
backend be_tls_reject
mode tcp
# No SNI, an unregistered host, or anything that is not a TLS ClientHello:
# close immediately instead of forwarding.
tcp-request content reject
+7
View File
@@ -0,0 +1,7 @@
# Managed by vpsmgr — generated file, do not edit by hand.
# Changes are overwritten on the next write; use the panel / `vps` CLI instead.
#
# BOOTSTRAP COPY ONLY: the first generation has no domains yet, so this map is
# empty and every request falls through to the reject backend in
# /etc/haproxy/haproxy.cfg. The panel's publisher (internal/hpx) writes the real
# file, containing one "<domain> <backend>" line per published domain.
+7
View File
@@ -0,0 +1,7 @@
# Managed by vpsmgr — generated file, do not edit by hand.
# Changes are overwritten on the next write; use the panel / `vps` CLI instead.
#
# BOOTSTRAP COPY ONLY: the first generation has no domains yet, so this map is
# empty and every TLS connection falls through to the reject backend in
# /etc/haproxy/haproxy.cfg. The panel's publisher (internal/hpx) writes the real
# file, containing one "<domain> <backend>" line per published domain.
+97
View File
@@ -0,0 +1,97 @@
# Managed by vpsmgr — generated file, do not edit by hand.
# Changes are overwritten on the next write; use the panel / `vps` CLI instead.
#
# THIS IS THE STATIC ENTRY CONFIG, not a generation file. scripts/30-haproxy.sh
# installs it as /etc/haproxy/haproxy.cfg (it is byte-identical to what
# internal/hpx.renderMainConfig produces, and
# TestMainConfigTemplateMatchesBootstrap fails if the two drift).
#
# The unit loads it FIRST and the generation in service SECOND:
#
# /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg \
# -f /etc/haproxy/current/domains.cfg
#
# Layout of the published side (all written by the panel, atomically):
#
# current -> releases/<NNNNNN>/ the generation in service
# domains.cfg one backend per domain
# maps/http.map Host -> backend (port 80)
# maps/sni.map SNI -> backend (port 443)
# manifest generation, domain count, timestamp
#
# Routing uses those maps so that adding or removing a domain never changes a
# frontend: a map miss yields an empty backend name, which HAProxy treats as
# "no rule matched" and falls through to default_backend (the reject backend).
# Keep this file free of anything domain-specific — it is the one part of the
# layout a reload cannot change.
global
log /dev/log local0
log /dev/log local1 notice
maxconn 4096
# Started as root by systemd (to bind 80/443), then dropped to 'haproxy'.
user haproxy
group haproxy
# Master-worker is selected with -Ws on the command line, which systemd
# needs for Type=notify; the old `master-worker` directive is deprecated.
#
# A reload keeps the old worker alive until its connections finish. This
# bounds how long a stuck worker may linger (5 minutes) so repeated reloads
# cannot accumulate processes. It applies to the DRAIN of established
# connections, not to new ones, so a long-lived SSH/WebSocket tunnel may in
# principle be cut short — raise it if that ever matters more than memory.
hard-stop-after 5m
# Local admin socket: used by `vps` diagnostics and for graceful reloads.
stats socket /run/haproxy/admin.sock mode 660 level admin
defaults
log global
option dontlognull
retries 2
timeout connect 5s
# Deliberately generous: SSH tunnels and WebSockets live for hours and the
# 443 path is a raw TCP passthrough, so there is nothing to time out early.
timeout client 1h
timeout server 1h
timeout tunnel 1h
# ---------------------------------------------------------------------------
# :80 - HTTP host routing to the containers
# ---------------------------------------------------------------------------
frontend fe_http
mode http
bind :80
option httplog
# Normalize before the map lookup: lowercase, then drop a ":port" suffix
# that a client may have sent.
http-request set-var(txn.host) req.hdr(host),lower,regsub(:[0-9]+$,)
http-request set-header X-Forwarded-Proto http
http-request set-header X-Forwarded-Port 80
# The client address reaches the container as X-Forwarded-For, added by the
# backends (option forwardfor). PROXY protocol is never used on :80.
use_backend %[var(txn.host),map(/etc/haproxy/current/maps/http.map)]
default_backend be_http_reject
backend be_http_reject
mode http
http-request deny deny_status 404
# ---------------------------------------------------------------------------
# :443 - TLS SNI routing (PASSTHROUGH: HAProxy never terminates TLS here)
# ---------------------------------------------------------------------------
frontend fe_tls
mode tcp
bind :443
option tcplog
# The SNI is only available once the ClientHello has arrived; without an
# inspect-delay the routing decision would be taken before it is known.
tcp-request inspect-delay 2s
tcp-request content accept if { req.ssl_hello_type 1 }
use_backend %[req.ssl_sni,lower,map(/etc/haproxy/current/maps/sni.map)]
default_backend be_tls_reject
backend be_tls_reject
mode tcp
# No SNI, an unregistered host, or anything that is not a TLS ClientHello:
# close immediately instead of forwarding.
tcp-request content reject
+41
View File
@@ -0,0 +1,41 @@
# Managed by vpsmgr — installed file, do not edit by hand.
# Changes are overwritten on the next install.
#
# Installed to /etc/systemd/system/haproxy.service, which takes precedence over
# the unit the distribution's haproxy package ships in /lib/systemd/system.
# Exactly one unit may own 80/443.
[Unit]
Description=HAProxy domain proxy (vpsmgr)
Documentation=https://docs.haproxy.org/3.4/management.html
Wants=network-online.target
After=network-online.target
# The panel publishes generations under /etc/haproxy and reloads the service
# after a successful validation, so start after it if both are enabled.
After=vps.service
[Service]
# -Ws = master-worker mode speaking the systemd notify protocol:
# * the master process stays alive and owns the workers,
# * a reload (SIGUSR2 to the master) starts a new worker with the re-read
# configuration and lets the old one DRAIN established connections, which
# is why a published generation never drops a live HTTP or TLS connection,
# * `systemctl reload` therefore never has to restart the process.
# The configuration is the stable entry file, which includes
# /etc/haproxy/current/proxy.cfg — the generation the panel last published.
Type=notify
ExecStart=/usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -p /run/haproxy/haproxy.pid
ExecReload=/bin/kill -USR2 $MAINPID
# Send SIGUSR1 (graceful stop) to the master on `systemctl stop`: established
# connections are allowed to finish; hard-stop-after (see haproxy.cfg) is the
# upper bound. KillMode=mixed keeps systemd from SIGKILLing the workers while
# they drain.
KillSignal=SIGUSR1
KillMode=mixed
Restart=on-failure
RestartSec=3
RuntimeDirectory=haproxy
RuntimeDirectoryMode=0750
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
-30
View File
@@ -1,30 +0,0 @@
# Managed by vpsmgr — installed file, do not edit by hand.
# Changes are overwritten on the next install.
[Unit]
Description=Traefik reverse proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Unprivileged: Traefik only needs to read /etc/traefik and bind 80/443.
User=traefik
Group=traefik
ExecStart=/usr/local/bin/traefik --configfile=/etc/traefik/traefik.yaml
Restart=always
RestartSec=3
# Bind the privileged ports without any other capability.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
# Config is read-only for the service; keep the rest of the FS out of reach.
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.target
-17
View File
@@ -1,17 +0,0 @@
# Managed by vpsmgr — installed file, do not edit by hand.
# Changes are overwritten on the next install; use the panel / `vps` CLI instead.
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
file:
directory: /etc/traefik/dynamic
watch: true
log:
level: INFO
accessLog: false
+69 -27
View File
@@ -2,7 +2,7 @@
vpsmgr is a lightweight LXC hosting panel: one Debian 13 container per user
account, managed from a web panel and a CLI. Everything ships as a single Go
binary (`vps` = CLI + embedded web panel); Incus, nftables and Traefik provide
binary (`vps` = CLI + embedded web panel); Incus, nftables and HAProxy provide
the plumbing.
## Design goals
@@ -11,7 +11,7 @@ vpsmgr is a toy for small machines (≤ 4 GB RAM, small VPS). Storage and memory
are treated as scarce, which drives every choice in this document:
- the panel is a single static Go binary — the only new service vpsmgr adds
besides Incus and Traefik;
besides Incus and HAProxy;
- the storage pool is sparse (a loop file that only grows as it fills) and the
published image is slimmed and the base image deleted, so disk is only
consumed by what containers actually use, and clones share the image's
@@ -22,15 +22,15 @@ are treated as scarce, which drives every choice in this document:
containers.
"Lightweight" refers to the panel and this storage/memory discipline, not to a
zero-overhead platform: Incus, nftables and Traefik are the minimal plumbing
zero-overhead platform: Incus, nftables and HAProxy are the minimal plumbing
that makes the panel possible.
## Components
```
install.sh / uninstall.sh / build.sh # lifecycle + local build
scripts/ 00-check 10-incus 20-network 30-traefik 40-panel 50-image
configs/ reference configs (traefik / systemd / sudoers)
scripts/ 00-check 10-incus 20-network 30-haproxy 40-panel 50-image
configs/ reference configs (haproxy / systemd / sudoers)
src/ Go source (single binary: CLI + panel)
```
@@ -65,8 +65,11 @@ src/ Go source (single binary: CLI + panel)
ufw allow in on incusbr0 to any port 53 # DNS
ufw route allow in on incusbr0 from 10.<n>.0.0/24 # container forwarding
```
- **Traefik** — file provider, hot-reloads `/etc/traefik/dynamic`. Port 80
proxies per domain; 443 SNI passthrough (TLS is managed inside the container).
- **HAProxy** — the domain front, pinned to the 3.4 LTS branch (official
performance packages). Port 80 reverse-proxies per `Host`; 443 routes by SNI
with **TLS passthrough** (the certificate lives in the container, HAProxy
never terminates TLS). Runtime configuration is published as immutable
*generations* under `/etc/haproxy`; see “Domain proxy” below.
- **SQLite** — users, domains, sessions, bandwidth counters and seven-day
minute resource history. Located at
`/etc/vpsmgr/vpsmgr.db`. Migrations run in order on open; the recorded
@@ -83,7 +86,7 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
full management API is therefore available without any privilege elevation.
- **The only root commands** the panel may run are pinned in a sudoers
whitelist (`/etc/sudoers.d/vps`, validated with `visudo -c` before
install): `nft` reloads, `systemctl` control of traefik / the panel itself,
install): `nft` reloads, `systemctl` control of haproxy / the panel itself,
IPv6 route/neighbor/addr changes, the IPv6-forwarding sysctl and ndppd
control. `internal/su` runs them via `sudo -n`; anything else fails instead
of prompting.
@@ -108,7 +111,7 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
cross-process backstop).
- **IPv4 inbound policy (`v4_forward`)**: the SSH/port-block DNAT above only
exists while `net.v4_forward` is true. When false (IPv6-only box), no DNAT
rules are written and traefik is stopped (domains kept but not served); the
rules are written and HAProxy is stopped (domains kept but not served); the
NAT4 masquerade stays so containers still reach IPv4 outbound. Toggle:
`vps config set net.v4_forward true|false` (applied immediately). The SSH/port
values stay recorded in the DB.
@@ -158,21 +161,53 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
(htb qdisc on the host veth) — no container restart — and the manager keeps
an in-memory throttle state so Incus is only touched on state changes.
- **Domains**: the `domains` table (owner via `user_id`, PROXY flag, UTC
created/updated timestamps) is the single source of truth; the traefik
dynamic directory holds **one self-contained YAML per domain**
(`/etc/traefik/dynamic/<domain>.yaml`), so toggling PROXY protocol or
deleting a domain only rewrites/removes that one file. PROXY protocol v2 is a
**TCP-service** feature: a flagged domain's TLS-passthrough service carries
`proxyProtocol.version: 2` (HTTP/80 cannot — traefik injects
`X-Forwarded-For` there). Router/service names are `sanitizeDomain(domain)`
(dots → underscores), which is collision-free across the `[a-z0-9.-]` domain
charset and globally unique. **DB and YAML are updated sequentially, not
atomically**: every domain mutation writes the DB first, then the file, and
rolls the DB back if the file write fails; a crash or full disk between the
two can leave them out of sync. `SyncAllDomains` (run on `vps install` and
`vps config set net.v4_forward true`) regenerates every file from the DB and
deletes orphans, repairing any drift. All timestamps are stored as UTC; the admin
domain page renders them in the browser's timezone.
created/updated timestamps) is the single source of truth. The published
HAProxy configuration is a **derived artifact** and is never edited in place:
```
/etc/haproxy/
haproxy.cfg # static entry config (frontends + reject backends)
current -> releases/<gen>/ # the generation in service
releases/<gen>/
maps/http.map # Host -> backend (port 80)
maps/sni.map # SNI -> backend (port 443)
domains.cfg # one backend per domain
manifest # generation, domain count, timestamp
generation # counter of the last published generation
```
Every mutation builds a **complete new generation** from the DB
(`internal/hpx.PublishAll`), validates it with `haproxy -c`, and only then
atomically repoints the `current` symlink. Because a generation is rendered
from scratch, a stale domain can never survive into it, and a rejected
candidate leaves the running proxy untouched — the previous generation keeps
serving. The three most recent generations are retained for rollback and
debugging.
Routing lives in the generation's two MAP files (`Host -> backend` for port
80, `SNI -> backend` for 443). Maps rather than frontend ACLs because HAProxy
refuses duplicate proxy-section names since 3.3 and attaches bare directives
to the section opened last — per-domain rules emitted from a second file
would either clash or land in the wrong section. A map miss yields an empty
backend name, which HAProxy treats as "no rule matched" and falls through to
the frontend's reject backend, so an unregistered host or SNI is never
forwarded anywhere.
PROXY protocol v2 is a **TCP-only** feature: a flagged domain's 443 backend
carries `send-proxy-v2` (the container's TLS listener must support it), while
port 80 never does — there the client address travels in
`X-Forwarded-For`, which HAProxy adds via `option forwardfor`.
Backend names are `sanitizeDomain(domain)` (dots → underscores), which is
collision-free across the `[a-z0-9.-]` domain charset.
**DB and configuration are updated sequentially, not atomically**: a domain
mutation writes the DB first, then republishes AND reloads (`systemctl reload
haproxy.service`), and rolls the DB back if either fails; a crash or full
disk between the steps can leave them out of sync. `SyncAllDomains` (run on `vps install`, on `vps config set
net.v4_forward true`, on `vps config set net.haproxy true` and at every panel
start) rebuilds the generation from the DB, repairing any drift.
A reload (SIGUSR2 to the master) starts a new worker on the new generation
and lets the old one **drain** its established connections, so publishing a
domain never drops a live HTTP or TLS connection; `hard-stop-after` bounds
how long a draining worker may linger. All timestamps are stored as UTC; the
admin domain page renders them in the browser's timezone.
- **Blocked domains**: the admin domain panel keeps a blocked-domains list (a
`settings` key, `blocked_domains`, one domain per line, managed via a
textarea). `mgr.AddDomain` — the single add path for the user panel — refuses
@@ -583,13 +618,20 @@ returns an error when any container could not be repaired.
## Install / uninstall lifecycle
- `uninstall.sh` without `--purge` removes the software but **keeps**
`/etc/vpsmgr` (config/db/certs) and `/etc/traefik`, so a reinstall adopts
the previous users/domains/settings. `--purge` removes those plus
containers, the storage pool and the Incus package.
`/etc/vpsmgr` (config/db/certs) and `/etc/haproxy` (the static entry config
plus every published generation), so a reinstall adopts the previous
users/domains/settings. `--purge` removes those plus containers, the storage
pool, the HAProxy package and the Incus package.
- `install.sh` detects an existing `/etc/vpsmgr/config.yaml` and adopts it
(users/domains survive). `00-ip-ask.sh` reuses a previously configured
`ipv6_subnet` / `subnet` / `user_ports` instead of re-asking; a legacy
`slot_range` is converted to `user_ports` once and the new key persisted.
- A **pre-HAProxy install is migrated in place** by the same `install.sh` run:
`30-haproxy.sh` installs and validates HAProxy, stops and removes the Traefik
unit/binary/service account (keeping only its config file), republishes the
domains from the database and removes the old per-domain YAML directory.
`net.traefik` in an adopted config is read as `net.haproxy` and rewritten
under the new name (see `docs/configuration.md`).
- `install.sh --local-build` prints the current git branch and waits 10 s
(Ctrl-C to abort) before starting, and always rebuilds rather than reusing
an installed stable binary. `install.sh --update` explicitly re-downloads
+59 -19
View File
@@ -10,7 +10,7 @@ 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
Traefik installed but stopped.
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
@@ -40,8 +40,8 @@ same table; `vps config list` shows the live values with this annotation.
| `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 toggle | **applied immediately** | false = IPv6-only containers (no SSH/port DNAT, traefik disabled, NAT4 outbound kept) |
| `net.traefik` | runtime toggle | **applied immediately** | false = stop and disable Traefik; existing domains are retained, but new domains cannot be added |
| `net.v4_forward` | runtime toggle | **applied immediately** | false = IPv6-only containers (no SSH/port DNAT, domain proxy disabled, NAT4 outbound kept) |
| `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) |
@@ -82,11 +82,12 @@ edit by hand` banner and are **overwritten on the next write**:
|---|---|
| `/etc/vpsmgr/nftables.conf` | `vps install` |
| `/etc/vpsmgr/nftables.d/user-<name>.nft` | `vps add` / quota / firewall updates |
| `/etc/traefik/traefik.yaml` | install (from `configs/traefik.yaml`); only written when absent — existing installs keep theirs |
| `/etc/traefik/dynamic/<domain>.yaml` | domain add/update/delete |
| `/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`, `traefik`) | `vps install` |
| systemd units (`vps`/`vps-nft`/`vps-ipv6`, `haproxy`) | `vps install` |
## Example
@@ -108,7 +109,8 @@ 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 # IPv4 inbound policy (false = IPv6-only containers)
traefik: true # domain reverse proxy (false = stopped and not enabled at boot)
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"
@@ -196,34 +198,72 @@ Always enabled by default — the installer does not ask (with IPv6 off, IPv4
forwarding is mandatory, as containers would otherwise be unreachable).
- `true` (default): containers get the random SSH port + user port block (DNAT),
and the domain proxy (traefik) is available.
and the domain proxy (HAProxy) is available.
- `false`: containers are **IPv6-only**. No SSH DNAT, no port-block DNAT, and
traefik is stopped (domains are kept but not served; adding a domain is
HAProxy is stopped (domains are kept but not served; adding a domain is
rejected until re-enabled). Containers still reach IPv4 outbound via the NAT4
masquerade.
Toggle at runtime with `vps config set net.v4_forward true|false` — the rules
are refreshed and traefik started/stopped immediately (its boot autostart is
are refreshed and HAProxy started/stopped immediately (its boot autostart is
disabled along with it, so it cannot come back on reboot). The SSH/user ports
stay recorded in the DB, so turning it back on restores everything. The user
panel hides IPv4 inbound info and shows "v4 SSH unavailable" while off, and
**domain-add is blocked while off** — the add form is hidden and the handler
rejects it (the panel reads the toggle live from the DB, no restart needed).
## Traefik (`net.traefik`)
## Domain proxy (`net.haproxy`)
`net.traefik` independently controls the Traefik domain reverse proxy. It is
enabled by default. Set it with `vps config set net.traefik false` to stop
Traefik and disable its boot autostart. Existing domain records and files are
kept, but users cannot add new domains while it is disabled. Re-enabling the
setting starts Traefik, restores autostart, and synchronizes the domain files.
`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 Traefik installed but `net.traefik: false` (not started, no
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 Traefik disabled;
re-enable later with `vps config set net.traefik true`.
`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) |
## Port 25 (SMTP) is always blocked
+31 -2
View File
@@ -81,8 +81,37 @@ auto-generated "Full changelog" compare link below the notes.
```
install.sh / uninstall.sh / build.sh # lifecycle + local build
scripts/ 00-check 10-incus 20-network 30-traefik 40-panel 50-image 60-rhel 70-opensuse 80-debian-dev 90-arch
configs/ reference configs (traefik / systemd / sudoers)
upgrade-haproxy.sh # follow the pinned HAProxy branch (patches)
scripts/ 00-check 10-incus 20-network 30-haproxy 40-panel 50-image 60-rhel 70-opensuse 80-debian-dev 90-arch
configs/ reference configs (haproxy / systemd / sudoers)
docs/ this documentation
src/ Go source (single binary: CLI + panel)
```
## Domain proxy (HAProxy) — pinned branch
The proxy is HAProxy **3.4 LTS** from the vendors' performance packages
(`haproxy-awslc`). The project pins the BRANCH and follows its newest patch.
The branch appears in exactly three places — change all of them together:
| Where | Constant |
|---|---|
| `scripts/30-haproxy.sh` | `HAPROXY_BRANCH` / `HAPROXY_BRANCH_RE` / `HAPROXY_REPO_SLUG` |
| `upgrade-haproxy.sh` | the same three |
| `src/internal/cfg/cfg.go` | `HaproxyBranch` |
`install.sh --update` upgrades the panel binary and then runs
`upgrade-haproxy.sh`, which validates the candidate configuration with
`haproxy -c` before reloading (`systemctl reload`, never `restart`, so
established connections drain instead of dropping).
Runtime configuration is published as immutable **generations**
(`internal/hpx`): render the whole set from the DB, validate, then atomically
repoint `/etc/haproxy/current`. Never edit a generation in place, and never
write domain-specific settings into `/etc/haproxy/haproxy.cfg` (the static
entry config a reload cannot change).
The panel's `internal/hpx` renderer and `configs/haproxy/proxy.cfg` (the
bootstrap generation used before the first sync) are two copies of the same
template — keep them in step.
+21 -8
View File
@@ -115,7 +115,7 @@ fi
if [[ "$DISABLE_V4FORWARD" == "1" ]]; then
echo
echo "!! --disable-v4forward: install with IPv4 inbound forwarding disabled !!"
echo " Containers will have no IPv4 SSH/port DNAT; Traefik will be installed but stopped."
echo " Containers will have no IPv4 SSH/port DNAT; HAProxy will be installed but stopped."
echo " Reserved vpsmgr port checks will be skipped. You can re-enable later with:"
echo " vps config set net.v4_forward true"
if [[ ! -t 0 ]]; then
@@ -291,7 +291,7 @@ export VPSMGR_IPV4_SUBNET="${VPSMGR_IPV4_SUBNET:-}"
export VPSMGR_IPV6_MODE="${VPSMGR_IPV6_MODE:-}"
export VPSMGR_IPV6_POOL="${VPSMGR_IPV6_POOL:-}"
for step in 00-check 10-incus 20-network 30-traefik 40-panel 50-image; do
for step in 00-check 10-incus 20-network 30-haproxy 40-panel 50-image; do
# Advanced setup: incus.image is a custom (non-default) alias — no Debian 13
# sshd image build. Skipped the same way on install and upgrade.
if [[ "$step" == "50-image" && "${VPSMGR_SKIP_IMAGE:-0}" == "1" ]]; then
@@ -301,15 +301,28 @@ for step in 00-check 10-incus 20-network 30-traefik 40-panel 50-image; do
echo
echo "===== $step ====="
bash "$ROOT/scripts/$step.sh"
# 80/443 already in use (detected by 00-check): force net.traefik false for
# the rest of the install — 30-traefik keeps the binary installed but stops
# it, and `vps install` writes net.traefik: false. The marker is cleared at
# the start of the next 00-check run.
# 80/443 already in use (detected by 00-check): force the domain proxy false
# for the rest of the install — 30-haproxy keeps the binary installed but
# stops it, and `vps install` writes net.haproxy: false. The marker name is
# the PRE-RENAME one on purpose: an operator may still be running an older
# checkout of this installer, and both spellings must agree. The marker is
# cleared at the start of the next 00-check run and deleted by 30-haproxy
# once the decision has been recorded in the config.
if [[ -f /etc/vpsmgr/.install-traefik-off ]]; then
export VPSMGR_TRAEFIK=0
export VPSMGR_HAPROXY=0
fi
done
export VPSMGR_TRAEFIK="${VPSMGR_TRAEFIK:-}"
export VPSMGR_HAPROXY="${VPSMGR_HAPROXY:-}"
# --update follows the pinned HAProxy branch to its newest patch as well, so a
# single command keeps the panel AND the domain proxy current. 30-haproxy.sh
# already installed/refreshed the package; this adds the validation + graceful
# reload so an upgrade in place never drops established connections.
if [[ "$BUILD_MODE" == "update" && -x "$ROOT/upgrade-haproxy.sh" ]]; then
echo
echo "===== upgrade-haproxy ====="
bash "$ROOT/upgrade-haproxy.sh" || echo "[install] warn: HAProxy patch upgrade failed — the installed version is unchanged"
fi
echo
echo "===== cleaning apt cache ====="
+3 -1
View File
@@ -8,7 +8,9 @@
# bash <(curl -fsSL https://raw.githubusercontent.com/bluebluesoda/vpsmgr/refs/heads/main/oneclick.sh) --update
#
# Supported args (passed straight through to install.sh):
# --update re-download the latest prebuilt release binary over an existing one
# --update re-download the latest prebuilt release binary over an
# existing one, and follow the pinned HAProxy branch to its
# newest patch (the panel and the domain proxy move together)
# --local-build force local Go compilation of the panel binary
# --disable-v4forward install with IPv4 inbound forwarding disabled
# Env:
+34 -20
View File
@@ -82,15 +82,16 @@ log "storage backend: $STORAGE"
# that is actually running. Ubuntu ships the ZFS module PREBUILT inside
# the kernel (linux-modules), so it needs none of the DKMS toolchain — no
# compile time at all.
# - ca-certificates: without it every curl HTTPS call (Zabbly key, traefik
# download, image pulls) fails with a certificate error
# - ca-certificates: without it every curl HTTPS call (Zabbly key, the
# HAProxy repository, image pulls) fails with a certificate error
# - python3: used by 00-ip-ask.sh (prefix/subnet validation) and the
# check-ipv6-support.sh probe
# - tar/xz-utils: traefik tarball extraction; curl: downloads; gpg: Zabbly
# key verification; nftables/zstd: firewall + Incus image compression
# - tar/xz-utils: archive extraction; curl: downloads; gpg: Zabbly key and
# HAProxy repository key verification; nftables/zstd: firewall + Incus image
# compression
# - sudo: hard requirement — the panel runs unprivileged and escalates ONLY
# the sudoers whitelist commands via `sudo -n`. Without it every
# privileged operation (nft reload, traefik/systemctl, IPv6 wiring, ndppd)
# privileged operation (nft reload, haproxy/systemctl, IPv6 wiring, ndppd)
# fails at runtime. visudo ships with the sudo package.
KERNEL_REL=$(uname -r)
BASE_DEPS="sudo ca-certificates python3 tar xz-utils nftables zstd curl gpg"
@@ -228,15 +229,16 @@ fi
# --- port occupancy ---
# Ports reserved for vpsmgr must be free on a fresh install. On adoption the
# panel and traefik are already running and owned by vpsmgr — those listeners
# panel and the domain proxy are already running and owned by vpsmgr — those
# listeners
# are excluded by process name. Checks TCP and UDP (the user port block is
# DNAT-ed for both).
#
# 80/443 are handled specially: if either is already taken, the install does
# NOT fail — it proceeds with Traefik installed but DISABLED (net.traefik
# NOT fail — it proceeds with HAProxy installed but DISABLED (net.haproxy
# false, not started/autostarted), so a host that already serves 80/443 is
# still usable; the operator can re-enable later via `vps config set
# net.traefik true`. The user-port ranges (from net.user_ports, exported by
# net.haproxy true`. The user-port ranges (from net.user_ports, exported by
# 00-ip-ask.sh as VPSMGR_USER_PORTS; default 10000-29999) stay hard failures.
#
# SSH ports (30000-31999) are deliberately NOT checked here: they are allocated
@@ -248,7 +250,11 @@ fi
#
# 00-check runs as a separate bash process, so its env cannot reach the rest of
# the installer. The decision therefore goes through a root-only marker file
# that install.sh turns into an env var for 30-traefik.sh and the panel.
# that install.sh turns into an env var for 30-haproxy.sh and the panel.
#
# The marker keeps its historical name (.install-traefik-off) even though the
# proxy is HAProxy now: an operator may run an older installer checkout that
# reads and writes exactly this path, and the new script honors it as well.
# Clear any stale marker from a previous run so a reinstall re-detects.
rm -f /etc/vpsmgr/.install-traefik-off 2>/dev/null || true
if [[ "${VPSMGR_DISABLE_V4FORWARD:-0}" == "1" ]]; then
@@ -293,11 +299,15 @@ while IFS= read -r line; do
[[ "$PORT" =~ ^[0-9]+$ ]] || continue
PROC=$(sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line")
case "${PROC##*/}" in
vpsmgr|traefik) continue ;;
# A listener we own is never a conflict: on adoption/upgrade the domain
# proxy is already running. 'haproxy' is the current proxy, 'traefik' the
# pre-HAProxy one (a host that has not been upgraded yet), 'vpsmgr' the
# panel itself.
vpsmgr|traefik|haproxy) continue ;;
esac
if [[ "$PORT" == "80" || "$PORT" == "443" ]]; then
# 80/443 occupied by a non-vpsmgr process: not a hard failure — force
# Traefik OFF instead (still installed, kept stopped).
# 80/443 occupied by a non-vpsmgr process: not a hard failure — force the
# domain proxy OFF instead (still installed, kept stopped).
PORT_80_443_TAKEN=1
PORT_80_443_PROC="${PROC:-unknown process}"
continue
@@ -313,19 +323,23 @@ $CONFLICTS
Free these ports (or remove the programs above) and re-run install."
fi
if [[ "$PORT_80_443_TAKEN" == "1" ]]; then
# Belt-and-suspenders for update/adoption: the scan already skips traefik's
# Belt-and-suspenders for update/adoption: the scan already skips our proxy's
# own listeners by process name, but if `ss` could not attribute the listener
# (PROC empty) while OUR traefik is already active, that active traefik is
# what holds 80/443 — do not misread it as a third-party conflict and turn
# our own proxy off.
if systemctl is-active --quiet traefik.service 2>/dev/null; then
log "80/443 held by the running vpsmgr traefik — keeping traefik enabled (no force-off)"
# (PROC empty) while OUR proxy is already active, that active proxy is what
# holds 80/443 — do not misread it as a third-party conflict and turn our own
# domain proxy off. Both service names are checked: 'haproxy' now, 'traefik'
# on a host that has not run the upgraded installer yet.
if systemctl is-active --quiet haproxy.service 2>/dev/null \
|| systemctl is-active --quiet traefik.service 2>/dev/null; then
log "80/443 held by the running vpsmgr domain proxy — keeping it enabled (no force-off)"
else
mkdir -p /etc/vpsmgr
# The marker keeps its historical name: an older installer checkout still
# writes and reads this exact path, and the new one honors it too.
touch /etc/vpsmgr/.install-traefik-off
log "port 80 and/or 443 is in use (${PORT_80_443_PROC}) — will NOT fail;"
log "traefik will be installed but DISABLED (net.traefik false, not started/autostarted)."
log "re-enable later with: vps config set net.traefik true"
log "the domain proxy will be installed but DISABLED (net.haproxy false, not started/autostarted)."
log "re-enable later with: vps config set net.haproxy true"
fi
else
log "reserved ports are free"
+1 -1
View File
@@ -437,7 +437,7 @@ PY
# --- IPv4 inbound forwarding policy ------------------------------------------
# Always ON by default, never asked. A VPSMGR_V4_FORWARD env var (e.g. for a
# scripted IPv6-only box) is honored; on adoption the recorded config value is
# re-exported so 30-traefik.sh keeps the existing policy. A fresh install
# re-exported so 30-haproxy.sh keeps the existing policy. A fresh install
# leaves it unset → the config default (enabled) applies everywhere.
if [[ -n "${VPSMGR_V4_FORWARD:-}" ]]; then
case "$VPSMGR_V4_FORWARD" in
+1 -1
View File
@@ -9,7 +9,7 @@ log(){ echo "[20] $*"; }
# host's own root services) create io_uring; container tenants — even container
# root, which only has userns-scoped caps — get EPERM, closing the biggest
# kernel LPE attack surface for tenants. Value 1, not 2: the host stack (Incus,
# ZFS, Go panel/traefik) is untouched. Matches RHEL 9.3+'s shipped default.
# ZFS, Go panel/HAProxy) is untouched. Matches RHEL 9.3+'s shipped default.
cat > /etc/sysctl.d/99-vpsmgr.conf <<EOF
# Managed by vpsmgr — generated file, do not edit by hand.
# Changes are overwritten on the next install.
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env bash
# 30-haproxy.sh — install the HAProxy domain proxy, migrate away from Traefik.
#
# WHAT THIS SCRIPT OWNS
# 1. The HAProxy binary, pinned to the release BRANCH below (official HAProxy
# performance packages; the branch is the only thing a future major
# upgrade has to change).
# 2. The static entry configuration /etc/haproxy/haproxy.cfg. The panel
# publishes per-generation configs and repoints /etc/haproxy/current at
# them; this file only includes the current one.
# 3. The systemd unit (overriding any unit the distribution package ships).
# 4. The one-way migration OFF Traefik (see "TRAEFIK REMOVAL" below).
#
# ---------------------------------------------------------------------------
# UPGRADING FROM A TRAEFIK-ERA INSTALL (read before touching this script)
# ---------------------------------------------------------------------------
# vpsmgr used to front user domains with Traefik. An operator may run an old
# version for a long time before upgrading, so this script must converge a
# Traefik host onto HAProxy in ONE run, and never leave both proxies on the box:
#
# * net.traefik (the config key) was RENAMED to net.haproxy. Values are
# unchanged. The Go side reads BOTH (see cfg.Load / cfg.FillAuto) so an old
# config.yaml or an old installer exporting VPSMGR_TRAEFIK still works.
# * Traefik's program, unit and service account are REMOVED here. Only its
# config FILE /etc/traefik/traefik.yaml is kept (harmless, no longer read).
# * The old /etc/traefik/dynamic directory is REMOVED: it holds per-domain
# YAML that nothing reads any more, and keeping it would invite confusion
# about which proxy is live.
# * The pre-rename marker /etc/vpsmgr/.install-traefik-off is still honored
# (it records "80/443 was taken, so keep the proxy installed but down"),
# and is deleted once the new state has been recorded.
# * Ordering is deliberate: install + validate the new proxy FIRST, stop
# Traefik, start HAProxy, and only then delete the Traefik files. A failure
# before the start leaves the box on Traefik and aborts the install loudly.
#
# Do NOT add a "keep Traefik as a fallback" branch: a fallback would mean two
# proxies fighting for 80/443 after a reboot.
# ---------------------------------------------------------------------------
set -uo pipefail
log(){ echo "[30] $*"; }
die(){ echo "[30] error: $*" >&2; exit 1; }
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# ---------------------------------------------------------------------------
# 1. Pinned version: BRANCH is the contract, the patch level floats within it.
# ---------------------------------------------------------------------------
# Changing HAPROXY_BRANCH is the ONLY edit needed for a future major upgrade.
# It must stay in sync with cfg.HaproxyBranch in the panel (the panel refuses
# to manage a binary from a different branch) and with the two places below:
# HAPROXY_REPO_SLUG -> the apt source path (…/performance/<distro>/ha<XY>)
# HAPROXY_BRANCH_RE -> the version assertion in verify_version
# A wrong slug makes `apt-get update` 404 and the install fails loudly; a wrong
# regex makes verify_version fail. Neither can silently install the wrong proxy.
HAPROXY_BRANCH="3.4"
HAPROXY_BRANCH_RE='^3\.4\.'
HAPROXY_REPO_SLUG="ha34"
HAPROXY_KEY_URL="https://pks.haproxy.com/linux/community/RPM-GPG-KEY-HAProxy"
HAPROXY_KEY="/usr/share/keyrings/HAPROXY-key-community.asc"
HAPROXY_PKG="haproxy-awslc"
# The published layout root. Must match cfg.DefaultProxyDir.
HAPROXY_DIR="/etc/haproxy"
ENTRY_CFG="$HAPROXY_DIR/haproxy.cfg"
CURRENT_LINK="$HAPROXY_DIR/current"
# ---------------------------------------------------------------------------
# 2. Distribution keys for the apt source. The vendor repo is per-distribution
# AND per-branch, so both have to be resolved before writing the source.
# ---------------------------------------------------------------------------
OS_ID=""
OS_CODENAME=""
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"
OS_CODENAME="${VERSION_CODENAME:-}"
fi
case "$OS_ID" in
debian|ubuntu) ;;
*) die "unsupported distribution '${OS_ID:-unknown}' for the HAProxy package repository (need debian or ubuntu)" ;;
esac
[[ -n "$OS_CODENAME" ]] || die "could not determine VERSION_CODENAME from /etc/os-release"
# Surface any previous HAProxy install early. An OLDER branch (e.g. 3.2) is
# upgraded in place by the branch switch below, which is expected; the version
# assertion at the end is what proves the switch worked.
if [[ -x /usr/sbin/haproxy ]]; then
log "existing haproxy: $(/usr/sbin/haproxy -v 2>/dev/null | head -1)"
fi
# ---------------------------------------------------------------------------
# 3. Install the pinned branch from the official vendor repository.
# ---------------------------------------------------------------------------
install_repo(){
# gpg + curl + ca-certificates are needed for the key and the repo; the
# installer's 00-check step normally provides them, this covers a standalone
# run of this script on a bare host.
for p in curl ca-certificates gpg; do
dpkg -s "$p" >/dev/null 2>&1 || {
log "installing $p (needed for the HAProxy repository)"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p"
}
done
if [[ ! -s "$HAPROXY_KEY" ]]; then
# download_keyring: the vendor key endpoint is plain HTTPS (no TLS client
# auth), so a fetch failure is fatal — never continue without the key, or
# apt would trust an unverified repository.
log "fetching the HAProxy repository key"
mkdir -p /usr/share/keyrings
curl -fsSL --max-time 60 -o "$HAPROXY_KEY.tmp" "$HAPROXY_KEY_URL" \
|| die "could not download the HAProxy repository key"
mv "$HAPROXY_KEY.tmp" "$HAPROXY_KEY"
chmod 0644 "$HAPROXY_KEY"
fi
local src="/etc/apt/sources.list.d/haproxy.list"
local want="deb [signed-by=$HAPROXY_KEY] https://www.haproxy.com/download/haproxy/performance/$OS_ID/$HAPROXY_REPO_SLUG $OS_CODENAME main"
if [[ ! -f "$src" ]] || [[ "$(cat "$src" 2>/dev/null)" != "$want" ]]; then
# Any older source line (a previous branch) is replaced wholesale: leaving
# both would let apt pick whichever branch has the higher version.
printf '%s\n' "$want" > "$src"
chmod 0644 "$src"
log "configured the HAProxy $HAPROXY_BRANCH repository for $OS_ID/$OS_CODENAME"
# A stale package list from the previous branch must not be reused.
rm -f /var/lib/apt/lists/*haproxy* 2>/dev/null || true
fi
apt-get update -qq || die "apt-get update failed (check the HAProxy repository)"
}
verify_version(){
local bin="${1:-/usr/sbin/haproxy}"
[[ -x "$bin" ]] || die "HAProxy binary not found at $bin"
local out
out="$("$bin" -v 2>/dev/null | head -1)"
local ver
ver="$(awk '{print $3}' <<<"$out")"
# The vendor package reports "HAProxy version 3.4.4-0+ha34+deb13u1 2026/08/27".
if [[ ! "$ver" =~ $HAPROXY_BRANCH_RE ]]; then
die "installed HAProxy is '$ver', which is not on the pinned branch $HAPROXY_BRANCH ($out)
a newer/older branch source is still configured — fix /etc/apt/sources.list.d/haproxy.list"
fi
log "haproxy $ver (branch $HAPROXY_BRANCH)"
}
install_repo
if ! dpkg -s "$HAPROXY_PKG" >/dev/null 2>&1; then
log "installing $HAPROXY_PKG (HAProxy $HAPROXY_BRANCH, AWS-LC build)"
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$HAPROXY_PKG" \
|| die "could not install $HAPROXY_PKG"
else
# Already present: make sure it is the newest patch of the pinned branch.
# `--only-upgrade` never installs anything new, so a no-op stays a no-op.
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade "$HAPROXY_PKG" >/dev/null 2>&1 || true
fi
verify_version /usr/sbin/haproxy
# ---------------------------------------------------------------------------
# 4. Directories. The panel (user 'vps') owns the layout root and publishes
# generations into it; HAProxy only READS them, so the dirs stay 0755.
# ---------------------------------------------------------------------------
mkdir -p "$HAPROXY_DIR" /run/haproxy
# Keep any configuration an operator already had before we overwrite it. This
# is a one-time courtesy for the very first install of this script on a host
# that ran the distribution's haproxy package.
if [[ -f "$ENTRY_CFG" ]] && ! grep -q "Managed by vpsmgr" "$ENTRY_CFG" 2>/dev/null; then
cp -a "$ENTRY_CFG" "$ENTRY_CFG.before-vpsmgr" 2>/dev/null || true
log "kept the previous $ENTRY_CFG as $ENTRY_CFG.before-vpsmgr"
fi
cp "$ROOT/configs/haproxy/haproxy.cfg" "$ENTRY_CFG"
chmod 0644 "$ENTRY_CFG"
# A generation must exist before the unit is ever started, because the entry
# config resolves its map paths through "$CURRENT_LINK/maps/" and HAProxy
# refuses to start when a map cannot be opened. The panel republishes this from
# the DB at startup; this is only the bootstrap (empty maps = every request
# falls through to the reject backends).
if [[ ! -f "$CURRENT_LINK/maps/http.map" ]]; then
log "bootstrapping an empty HAProxy generation"
gen="$HAPROXY_DIR/releases/000001"
mkdir -p "$gen/maps"
cp "$ROOT/configs/haproxy/domains.cfg" "$gen/domains.cfg"
cp "$ROOT/configs/haproxy/maps/http.map" "$gen/maps/http.map"
cp "$ROOT/configs/haproxy/maps/sni.map" "$gen/maps/sni.map"
printf 'generation: 1\ndomains: 0\n' > "$gen/manifest"
printf '1\n' > "$HAPROXY_DIR/generation"
# Atomic switch: a temp link renamed over the real one, so a reader (or a
# concurrent panel publish) never observes a missing link.
ln -sfn "releases/000001" "$CURRENT_LINK.tmp" && mv -Tf "$CURRENT_LINK.tmp" "$CURRENT_LINK"
fi
# ---------------------------------------------------------------------------
# 5. The unit. Debian's haproxy package ships /lib/systemd/system/haproxy.service
# plus an /etc/default/haproxy conffile; ours lives in /etc/systemd/system,
# which takes precedence, so exactly one process can ever own 80/443.
# ---------------------------------------------------------------------------
if [[ -f /lib/systemd/system/haproxy.service ]] || [[ -f /usr/lib/systemd/system/haproxy.service ]]; then
log "distribution haproxy unit present — overridden by /etc/systemd/system/haproxy.service"
fi
cp "$ROOT/configs/systemd/haproxy.service" /etc/systemd/system/haproxy.service
chmod 0644 /etc/systemd/system/haproxy.service
systemctl daemon-reload
# The haproxy account is created by the package's maintainer scripts. If a
# future package drops it (or we ever switch to a hand-built binary), create it
# here; the config's "user haproxy" would otherwise fail to start.
if ! id -u haproxy >/dev/null 2>&1; then
useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin haproxy
log "created the 'haproxy' service account"
fi
# ---------------------------------------------------------------------------
# 6. Effective enablement: v4_forward AND the domain-proxy switch, mirroring
# the semantics of the old Traefik-era script exactly.
# ---------------------------------------------------------------------------
V4ON=1
case "${VPSMGR_V4_FORWARD:-1}" in
1|true|True) V4ON=1 ;;
*) V4ON=0 ;;
esac
# Proxy switch: installer override first (VPSMGR_HAPROXY, or the legacy
# VPSMGR_TRAEFIK an old installer exported), then an adopted config file
# (net.haproxy, or the pre-rename net.traefik key), then the default (enabled).
PROXY_CFG=""
pick_bool(){
case "${1,,}" in
1|true) PROXY_CFG=true ;;
0|false) PROXY_CFG=false ;;
*) die "$2 must be 1/0/true/false (got '$1')" ;;
esac
}
if [[ -n "${VPSMGR_HAPROXY:-}" ]]; then
pick_bool "$VPSMGR_HAPROXY" VPSMGR_HAPROXY
elif [[ -n "${VPSMGR_TRAEFIK:-}" ]]; then
pick_bool "$VPSMGR_TRAEFIK" VPSMGR_TRAEFIK
elif [[ -f /etc/vpsmgr/config.yaml ]]; then
# Read the new key when present, else the legacy one. Both are plain "true"/
# "false" values under the net: section.
v="$(grep -E '^\s+haproxy:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')"
if [[ -z "$v" ]]; then
v="$(grep -E '^\s+traefik:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')"
fi
[[ -n "$v" ]] && pick_bool "$v" config
fi
# ---------------------------------------------------------------------------
# 7. Port check BEFORE touching Traefik, so a conflict can never leave the box
# with no proxy at all. A Traefik/HAProxy listener we already own is not a
# conflict (that is the normal upgrade path).
# ---------------------------------------------------------------------------
ports_busy(){
local port="$1" proc
while read -r line; do
[[ -z "$line" ]] && continue
proc="$(sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line")"
case "${proc##*/}" in
haproxy|traefik|vpsmgr) continue ;; # ours
esac
return 0
done < <(ss -H -tlnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$"')
return 1
}
# ---------------------------------------------------------------------------
# 8. TRAEFIK REMOVAL — one-way, after the new proxy is proven startable.
# ---------------------------------------------------------------------------
traefik_present(){
[[ -x /usr/local/bin/traefik ]] \
|| [[ -f /etc/systemd/system/traefik.service ]] \
|| [[ -d /etc/traefik ]] \
|| id -u traefik >/dev/null 2>&1
}
remove_traefik(){
traefik_present || return 0
log "removing the Traefik era install (one-way: HAProxy is the only proxy now)"
# Stop and disable FIRST: a disabled unit must not come back on reboot and
# race HAProxy for 80/443.
systemctl disable --now traefik.service >/dev/null 2>&1 || true
rm -f /etc/systemd/system/traefik.service
systemctl daemon-reload >/dev/null 2>&1 || true
# Program and service account.
rm -f /usr/local/bin/traefik
if id -u traefik >/dev/null 2>&1; then
userdel traefik >/dev/null 2>&1 || true
fi
# userdel leaves the same-named primary group behind on some systems; a
# stale group makes a later `useradd traefik` fail with exit 9.
if getent group traefik >/dev/null 2>&1; then
groupdel traefik >/dev/null 2>&1 || true
fi
# Per-domain YAML from the old proxy. Nothing reads it now; leaving it would
# only confuse an operator debugging "which proxy is serving this domain".
rm -rf /etc/traefik/dynamic
# The config FILE is kept deliberately: it documents the previous setup and
# removing it would discard host-specific provider settings. It is inert.
[[ -f /etc/traefik/traefik.yaml ]] && log "kept /etc/traefik/traefik.yaml (no longer read)"
log "traefik removed: binary, unit, service account and dynamic rules"
}
# The marker records "80/443 was already taken when this host was installed, so
# keep the domain proxy installed but down". It predates the rename; honor it
# and then retire it (the state it describes now lives in net.haproxy).
if [[ -f /etc/vpsmgr/.install-traefik-off ]]; then
log "found .install-traefik-off — keeping the domain proxy installed but disabled"
PROXY_CFG=false
fi
[[ -n "$PROXY_CFG" ]] || PROXY_CFG=true
CONFLICT=""
if ports_busy 80; then CONFLICT="80"; fi
if ports_busy 443; then CONFLICT="${CONFLICT:+$CONFLICT and }443"; fi
want_start=1
if [[ "$V4ON" -eq 0 ]]; then
want_start=0
log "v4 forwarding off — HAProxy installed but disabled (domains kept)"
elif [[ "$PROXY_CFG" != "true" ]]; then
want_start=0
log "net.haproxy is false — HAProxy installed but disabled (not started/autostarted; domains kept)"
elif [[ -n "$CONFLICT" ]]; then
want_start=0
log "port $CONFLICT is in use by another process — HAProxy installed but disabled"
log "free the port, then run: vps config set net.haproxy true"
fi
# The candidate configuration must be valid BEFORE we touch the running proxy:
# a broken config would otherwise take the domain service down with it.
/usr/sbin/haproxy -c -q -f "$ENTRY_CFG" 2>/dev/null \
|| { /usr/sbin/haproxy -c -f "$ENTRY_CFG"; die "the generated HAProxy configuration is invalid"; }
log "configuration check passed"
if [[ "$want_start" -eq 1 ]]; then
systemctl enable --now haproxy.service >/dev/null 2>&1 || true
sleep 1
if ! systemctl is-active --quiet haproxy.service; then
# Only NOW is a message about the still-running Traefik accurate.
if systemctl is-active --quiet traefik.service 2>/dev/null; then
systemctl status haproxy.service --no-pager | tail -5
die "HAProxy failed to start while Traefik is still running — free 80/443 or disable it, then re-run the installer"
fi
systemctl status haproxy.service --no-pager | tail -5
die "HAProxy failed to start"
fi
log "HAProxy running"
# Only after HAProxy is confirmed active do we tear Traefik down, so a failed
# start leaves the host exactly as it was.
remove_traefik
else
# Not starting: disable boot autostart so a reboot cannot bring up a proxy
# the operator asked to keep off.
systemctl disable --now haproxy.service >/dev/null 2>&1 || true
# Traefik must still go: it must never be the process serving domains after
# this script runs, even when HAProxy stays down.
remove_traefik
fi
rm -f /etc/vpsmgr/.install-traefik-off 2>/dev/null || true
log "domain proxy: $( [[ "$want_start" -eq 1 ]] && echo "running (HAProxy $HAPROXY_BRANCH)" || echo "installed but disabled" )"
echo "[30] haproxy ready"
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env bash
# 30-traefik.sh — install Traefik binary + static config + systemd service.
set -uo pipefail
log(){ echo "[30] $*"; }
die(){ echo "[30] error: $*" >&2; exit 1; }
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# Private scratch dir (root-only, unique): never write downloads to a fixed
# /tmp path another process could pre-create or race (review P2-9).
SCRATCH="$(mktemp -d /tmp/vpsmgr-traefik.XXXXXX)"
trap 'rm -rf "$SCRATCH"' EXIT
# fixed version + per-arch download URLs (no bundled binaries in the repo)
TRAEFIK_VERSION=3.7.12
ARCH=$(uname -m)
case "$ARCH" in
x86_64) TARCH=amd64 ;;
aarch64) TARCH=arm64 ;;
*) die "unsupported architecture: $ARCH" ;;
esac
TRAEFIK_URL="https://github.com/traefik/traefik/releases/download/v${TRAEFIK_VERSION}/traefik_v${TRAEFIK_VERSION}_linux_${TARCH}.tar.gz"
# Defensive dependency check (00-check.sh already installs these on a full
# install; this covers running 30-traefik.sh standalone on a bare Debian).
for p in curl tar ca-certificates; do
if ! dpkg -s "$p" >/dev/null 2>&1; then
log "installing $p (needed for the traefik download)"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p"
fi
done
if [[ ! -x /usr/local/bin/traefik ]]; then
log "downloading traefik ${TRAEFIK_VERSION} (${TARCH})"
log " ${TRAEFIK_URL}"
curl -fsSL -o "$SCRATCH/traefik.tar.gz" "$TRAEFIK_URL" || die "traefik download failed"
tar -xzf "$SCRATCH/traefik.tar.gz" -C "$SCRATCH" traefik
cp "$SCRATCH/traefik" /usr/local/bin/traefik
chmod 755 /usr/local/bin/traefik
log "installed /usr/local/bin/traefik"
fi
log "traefik version: $(/usr/local/bin/traefik version 2>/dev/null | awk '/Version:/{print $2; exit}')"
mkdir -p /etc/traefik/dynamic
if [[ ! -f /etc/traefik/traefik.yaml ]]; then
cp "$ROOT/configs/traefik.yaml" /etc/traefik/traefik.yaml
log "wrote /etc/traefik/traefik.yaml"
fi
if [[ ! -f /etc/systemd/system/traefik.service ]]; then
# Fresh install: run Traefik as a dedicated unprivileged user that can only
# read /etc/traefik (config + dynamic rules) and bind ports 80/443. The
# dynamic dir is world-readable (traefik reads it as any other user); the
# panel installer assigns its ownership after creating the vps user.
if ! id -u traefik >/dev/null 2>&1; then
useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin traefik
log "created unprivileged user 'traefik'"
fi
chown -R root:traefik /etc/traefik
chmod 755 /etc/traefik
chmod 755 /etc/traefik/dynamic
chmod 640 /etc/traefik/traefik.yaml
cp "$ROOT/configs/systemd/traefik.service" /etc/systemd/system/traefik.service
systemctl daemon-reload
log "installed traefik.service (unprivileged)"
fi
# Effective net.traefik toggle: forced by the installer (VPSMGR_TRAEFIK=0 when
# 80/443 is already taken, exported by install.sh from the 00-check marker),
# else adopted from an existing config, else default (enabled).
TRAEFIK_CFG=
if [[ -n "${VPSMGR_TRAEFIK:-}" ]]; then
case "${VPSMGR_TRAEFIK,,}" in
1|true) TRAEFIK_CFG=true ;;
0|false) TRAEFIK_CFG=false ;;
*) die "VPSMGR_TRAEFIK must be 1/0/true/false (got '$VPSMGR_TRAEFIK')" ;;
esac
elif [[ -f /etc/vpsmgr/config.yaml ]]; then
TRAEFIK_CFG=$(grep -E '^\s+traefik:' /etc/vpsmgr/config.yaml 2>/dev/null | awk -F': ' '{print $2}' | tr -d '"')
fi
# Effective v4 forwarding for this install. On a fresh install nothing forces
# it off (default: enabled); on adoption 00-ip-ask.sh re-exports the recorded
# config value so we keep the existing policy.
V4ON=1
case "${VPSMGR_V4_FORWARD:-1}" in
1|true|True) V4ON=1 ;;
*) V4ON=0 ;;
esac
# Traefik (the domain proxy) only ever runs while BOTH IPv4 ingress is on AND
# net.traefik is on. With v4 forwarding OFF (an IPv6-only box) there is no
# SSH/port DNAT and domains are not served, so traefik must be installed but
# DISABLED (not started, no boot autostart) even if net.traefik is kept true —
# re-enabling v4 later (`vps config set net.v4_forward true`) restores it.
# The same disabling applies when net.traefik is false on its own (forced off
# by a port 80/443 conflict, or an adopted config). Domain files are kept in
# both cases so a later re-enable restores them.
if [[ "$V4ON" -eq 0 ]]; then
systemctl disable --now traefik >/dev/null 2>&1 || true
log "v4 forwarding off — traefik installed but disabled (domains kept)"
elif [[ "$TRAEFIK_CFG" == "false" || "$TRAEFIK_CFG" == "0" ]]; then
systemctl disable --now traefik >/dev/null 2>&1 || true
log "net.traefik false — traefik installed but disabled (not started/autostarted; domains kept)"
else
systemctl enable --now traefik >/dev/null 2>&1 || die "cannot start traefik"
sleep 1
if systemctl is-active traefik >/dev/null 2>&1; then
log "traefik running"
else
systemctl status traefik --no-pager | tail -5
die "traefik failed to start"
fi
fi
echo "[30] traefik ready"
+10 -1
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -29,7 +31,14 @@ func newTestServer(t *testing.T) (*Server, *db.DB) {
t.Helper()
cfgPath := t.TempDir() + "/config.yaml"
t.Setenv("VPSMGR_CONFIG", cfgPath)
t.Setenv("VPSMGR_TRAEFIK_DIR", t.TempDir())
// Keep domain publishing out of /etc/haproxy and stub the config check
// (the real /usr/sbin/haproxy is not installed on a test box).
t.Setenv("VPSMGR_HAPROXY_DIR", t.TempDir())
bin := filepath.Join(t.TempDir(), "haproxy")
if err := os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("VPSMGR_HAPROXY_BIN", bin)
c := cfg.Default()
c.Panel.URLPath = "UserSecRet99"
c.Panel.AdminPath = testAdminSecret
+1 -1
View File
@@ -1085,7 +1085,7 @@ func (s *Server) handleKnowledgePreview(w http.ResponseWriter, r *http.Request)
}
// handleDomainDel deletes a domain (admin path). It finds the owning user and
// removes the domain + its traefik file atomically.
// removes the domain and its published route atomically.
func (s *Server) handleDomainDel(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
+84 -27
View File
@@ -21,18 +21,42 @@ import (
)
const (
DefaultDataDir = "/etc/vpsmgr"
DefaultNftDir = "/etc/vpsmgr/nftables.d"
DefaultNftMain = "/etc/vpsmgr/nftables.conf"
DefaultTraefikDir = "/etc/traefik/dynamic"
DefaultDB = "/etc/vpsmgr/vpsmgr.db"
DefaultListen = ":8443"
DefaultSubnet = "10.115.0.0/24"
DefaultGateway = "10.115.0.1"
DefaultBridge = "incusbr0"
DefaultPool = "vpsmgr"
DefaultImage = "vpsmgr/debian-sshd"
DefaultImageFB = "images:debian/13"
DefaultDataDir = "/etc/vpsmgr"
DefaultNftDir = "/etc/vpsmgr/nftables.d"
DefaultNftMain = "/etc/vpsmgr/nftables.conf"
DefaultDB = "/etc/vpsmgr/vpsmgr.db"
// DefaultProxyDir is the HAProxy layout root the panel publishes domain
// configs into. Layout (all under here):
// haproxy.cfg stable entry config (static, installed once)
// maps/ Host/SNI maps of the CURRENT generation
// dynamic/ per-domain backend fragments of the CURRENT generation
// releases/<gen>/ one immutable generation (maps + dynamic + manifest)
// current -> releases/<gen>
// VPSMGR_HAPROXY_DIR overrides it (tests only).
DefaultProxyDir = "/etc/haproxy"
// DefaultHaproxyBin is the absolute path of the HAProxy binary used both to
// run the proxy and to validate a candidate generation. It is always
// absolute: the validation must never pick up an unrelated build from PATH.
DefaultHaproxyBin = "/usr/sbin/haproxy"
// DefaultHaproxyService is the systemd unit the panel starts/stops through
// the sudoers whitelist (replaces traefik.service).
DefaultHaproxyService = "haproxy.service"
// HaproxyBranch is the HAProxy release branch the installer pins. It is the
// ONLY thing that has to change for a future major upgrade: the apt source
// path (…/performance/debian/ha34) and the version assertion are derived
// from it. Kept in Go as well as in the install script so the running panel
// can report/reject a mismatched binary.
HaproxyBranch = "3.4"
DefaultListen = ":8443"
DefaultSubnet = "10.115.0.0/24"
DefaultGateway = "10.115.0.1"
DefaultBridge = "incusbr0"
DefaultPool = "vpsmgr"
DefaultImage = "vpsmgr/debian-sshd"
DefaultImageFB = "images:debian/13"
// DefaultSocket is the Incus daemon's Unix socket; the REST client
// talks to it directly (no `incus` process spawn per call).
DefaultSocket = "/var/lib/incus/unix.socket"
@@ -140,16 +164,30 @@ type NetCfg struct {
ExtIF string `yaml:"ext_if"`
// V4Forward controls IPv4 inbound forwarding to containers. When false
// (only meaningful with IPv6 pass-through enabled) containers become
// IPv6-only: no SSH DNAT, no user-port-block DNAT, and traefik (domains)
// IPv6-only: no SSH DNAT, no user-port-block DNAT, and the domain proxy
// is disabled — containers still reach IPv4 out via the NAT4 masquerade.
// Set once at install, changeable at runtime with `vps config set
// net.v4_forward true|false`.
// Deliberately NOT omitempty: false must round-trip through the config.
V4Forward bool `yaml:"v4_forward"`
// Traefik controls the optional domain reverse proxy independently of
// IPv4 forwarding. When false, Traefik is stopped and not enabled at boot;
// existing domain records are retained but new domains cannot be added.
Traefik bool `yaml:"traefik"`
// Haproxy controls the optional domain reverse proxy (HAProxy, the SNI
// passthrough / HTTP host router) independently of IPv4 forwarding. When
// false, haproxy.service is stopped and not enabled at boot; existing
// domain records are retained but new domains cannot be added.
//
// Renamed from `traefik` when vpsmgr moved off the Traefik proxy. The
// VALUE SEMANTICS ARE UNCHANGED. A config written by a Traefik-era
// install carries `traefik:` instead; Load() picks it up (see the
// LegacyTraefik field) so an upgrade keeps the previous on/off state.
// Save() always writes the new `haproxy:` key, so the old one disappears
// on the next write.
Haproxy bool `yaml:"haproxy"`
// LegacyTraefik receives the pre-rename `traefik:` key from a Traefik-era
// config file so Load() can honor it. It MUST stay exported (yaml.v3
// cannot fill unexported fields) and carry omitempty, and Load() clears it
// to nil so it is never written back — Save() therefore emits only the new
// `haproxy:` key. Do not read this field outside Load()/migration tests.
LegacyTraefik *bool `yaml:"traefik,omitempty"`
// UserPorts is the comma-separated set of inclusive user-port ranges a
// NEW container's whole-hundred block may be drawn from (e.g.
// "10000-29999", or "10000-20000, 25000-30000" for discontiguous spans).
@@ -252,7 +290,7 @@ type SnapshotsCfg struct {
func Default() *Config {
c := &Config{}
c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, BandwidthResetDay: 1}
c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Traefik: true, UserPorts: DefaultUserPorts}
c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Haproxy: true, UserPorts: DefaultUserPorts}
c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio}
c.Snapshots = SnapshotsCfg{Limit: 1, Share: false}
return c
@@ -269,13 +307,14 @@ func (c *Config) DataDir() string { return DefaultDataDir }
func (c *Config) NftDir() string { return DefaultNftDir }
func (c *Config) NftMain() string { return DefaultNftMain }
// TraefikDir is where per-domain dynamic files are written. VPSMGR_TRAEFIK_DIR
// overrides it (used by tests to keep writes out of /etc/traefik).
func (c *Config) TraefikDir() string {
if p := os.Getenv("VPSMGR_TRAEFIK_DIR"); p != "" {
// ProxyDir is the HAProxy layout root where per-domain configs, maps and the
// published generations live. VPSMGR_HAPROXY_DIR overrides it (used by tests
// to keep writes out of /etc/haproxy).
func (c *Config) ProxyDir() string {
if p := os.Getenv("VPSMGR_HAPROXY_DIR"); p != "" {
return p
}
return DefaultTraefikDir
return DefaultProxyDir
}
// SubnetIP returns the IP portion of the subnet CIDR.
@@ -469,6 +508,17 @@ func Load() (*Config, error) {
c.Net.UserPorts = ports
}
}
// Legacy migration: a Traefik-era config carries `traefik:` instead of
// `haproxy:`. The value semantics are identical, so honor it verbatim.
// `haproxy:` wins when BOTH keys are present (a partially migrated file, or
// a hand edit that added the new key before removing the old one). Clear
// the legacy field afterwards so Save() cannot write it back.
if c.Net.LegacyTraefik != nil {
if !hasYAMLKey(b, "haproxy") {
c.Net.Haproxy = *c.Net.LegacyTraefik
}
c.Net.LegacyTraefik = nil
}
if err := c.FillAuto(); err != nil {
return nil, err
}
@@ -559,12 +609,19 @@ func (c *Config) FillAuto() error {
if v := os.Getenv("VPSMGR_V4_FORWARD"); v != "" {
c.Net.V4Forward = v == "1" || strings.EqualFold(v, "true")
}
// VPSMGR_TRAEFIK (1/0/true/false) forces the Traefik domain-proxy toggle at
// VPSMGR_HAPROXY (1/0/true/false) forces the domain-proxy toggle at
// install: 00-check.sh sets it to 0 when 80/443 is already taken so the
// install continues with Traefik installed but DISABLED (net.traefik
// install continues with the proxy installed but DISABLED (net.haproxy
// false) instead of failing or fighting the occupant.
if v := os.Getenv("VPSMGR_TRAEFIK"); v != "" {
c.Net.Traefik = v == "1" || strings.EqualFold(v, "true")
// VPSMGR_TRAEFIK is the LEGACY spelling of the same knob, set by
// pre-rename installer versions. It is honored only as a fallback so an
// operator upgrading from a Traefik-era checkout with the old env var
// exported still gets the intended state. New code must set
// VPSMGR_HAPROXY; nothing in this tree exports VPSMGR_TRAEFIK anymore.
if v := os.Getenv("VPSMGR_HAPROXY"); v != "" {
c.Net.Haproxy = v == "1" || strings.EqualFold(v, "true")
} else if v := os.Getenv("VPSMGR_TRAEFIK"); v != "" {
c.Net.Haproxy = v == "1" || strings.EqualFold(v, "true")
}
// Legacy migration happens in Load (see hasYAMLKey), NOT here: Default() has
// already pre-filled user_ports with the full range by the time FillAuto
+119 -9
View File
@@ -407,25 +407,135 @@ net:
}
}
func TestFillAutoTraefikEnv(t *testing.T) {
func TestFillAutoHaproxyEnv(t *testing.T) {
c := Default()
c.Net.ExtIF = "eth0"
c.Panel.PublicIP = "203.0.113.10"
// Install-time force-off (80/443 conflict) must write net.haproxy false.
t.Setenv("VPSMGR_HAPROXY", "0")
if err := c.FillAuto(); err != nil {
t.Fatalf("FillAuto: %v", err)
}
if c.Net.Haproxy {
t.Error("net.haproxy should be false when VPSMGR_HAPROXY=0")
}
// VPSMGR_HAPROXY=1 forces it on.
c.Net.Haproxy = false
t.Setenv("VPSMGR_HAPROXY", "true")
if err := c.FillAuto(); err != nil {
t.Fatalf("FillAuto: %v", err)
}
if !c.Net.Haproxy {
t.Error("net.haproxy should be true when VPSMGR_HAPROXY=true")
}
}
// TestFillAutoLegacyTraefikEnv covers the upgrade path from an old installer:
// a pre-rename checkout exported VPSMGR_TRAEFIK for the very same knob. It is
// honored only as a fallback, so an operator running an old script against the
// new binary still gets the state they asked for.
func TestFillAutoLegacyTraefikEnv(t *testing.T) {
c := Default()
c.Net.ExtIF = "eth0"
c.Panel.PublicIP = "203.0.113.10"
// Install-time force-off (80/443 conflict) must write net.traefik false.
t.Setenv("VPSMGR_TRAEFIK", "0")
if err := c.FillAuto(); err != nil {
t.Fatalf("FillAuto: %v", err)
}
if c.Net.Traefik {
t.Error("net.traefik should be false when VPSMGR_TRAEFIK=0")
if c.Net.Haproxy {
t.Error("legacy VPSMGR_TRAEFIK=0 should still disable the domain proxy")
}
// VPSMGR_TRAEFIK=1 forces it on.
c.Net.Traefik = false
t.Setenv("VPSMGR_TRAEFIK", "true")
// The new spelling wins when both are present.
c.Net.Haproxy = false
t.Setenv("VPSMGR_HAPROXY", "1")
if err := c.FillAuto(); err != nil {
t.Fatalf("FillAuto: %v", err)
}
if !c.Net.Traefik {
t.Error("net.traefik should be true when VPSMGR_TRAEFIK=true")
if !c.Net.Haproxy {
t.Error("VPSMGR_HAPROXY must take precedence over the legacy VPSMGR_TRAEFIK")
}
}
// TestLoadLegacyTraefikKey covers a config file written by a Traefik-era
// install: it carries `traefik:` where the new tree writes `haproxy:`. The
// value semantics are identical, so the old value must be honored verbatim and
// then dropped from the file on the next save.
func TestLoadLegacyTraefikKey(t *testing.T) {
write := func(t *testing.T, body string) string {
t.Helper()
p := t.TempDir() + "/config.yaml"
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("VPSMGR_CONFIG", p)
t.Setenv("VPSMGR_V4_FORWARD", "1")
return p
}
// 1. Only the legacy key: its value is used, and a save rewrites the file
// with the new key only.
p := write(t, `panel:
listen: ":8443"
net:
subnet: "10.115.0.0/24"
v4_forward: true
traefik: false
`)
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Net.Haproxy {
t.Error("legacy traefik: false must be honored as net.haproxy=false")
}
if c.Net.LegacyTraefik != nil {
t.Error("legacy key must be cleared after Load so it cannot be written back")
}
if err := Save(c); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "traefik:") {
t.Errorf("saved config still carries the legacy key:\n%s", b)
}
if !strings.Contains(string(b), "haproxy: false") {
t.Errorf("saved config missing the new key:\n%s", b)
}
// 2. Only the new key: honored as-is.
write(t, `panel:
listen: ":8443"
net:
subnet: "10.115.0.0/24"
v4_forward: true
haproxy: false
`)
c, err = Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Net.Haproxy {
t.Error("haproxy: false must be honored")
}
// 3. Both keys (a half-migrated file, or a hand edit): the NEW key wins.
write(t, `panel:
listen: ":8443"
net:
subnet: "10.115.0.0/24"
v4_forward: true
haproxy: true
traefik: false
`)
c, err = Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.Net.Haproxy {
t.Error("haproxy: true must win over a stale traefik: false")
}
}
+7 -7
View File
@@ -24,7 +24,7 @@ const (
// takes effect (panel restart, `vps install`, next add, ...).
KindOperator
// KindRuntime is a live toggle applied immediately (net.v4_forward,
// net.traefik).
// net.haproxy).
KindRuntime
// KindAuto is written by the panel/installer only; never user-set.
KindAuto
@@ -261,7 +261,7 @@ var Fields = []Field{
return fmt.Errorf("net.gateway is fixed at install (derived from net.subnet)")
}},
{"net.v4_forward", KindRuntime, ApplyImmediate,
"IPv4 inbound policy: true = SSH/port-block DNAT + traefik, false = IPv6-only containers",
"IPv4 inbound policy: true = SSH/port-block DNAT + the domain proxy, false = IPv6-only containers",
"true or false",
getStr(func(c *Config) string { return strconv.FormatBool(c.Net.V4Forward) }),
func(c *Config, v string) error {
@@ -288,16 +288,16 @@ var Fields = []Field{
c.Net.UserPorts = strings.Join(parts, ", ")
return nil
}},
{"net.traefik", KindRuntime, ApplyImmediate,
"Traefik domain proxy: true = running and enabled at boot, false = stopped and disabled at boot",
{"net.haproxy", KindRuntime, ApplyImmediate,
"HAProxy domain proxy: true = running and enabled at boot, false = stopped and disabled at boot (renamed from net.traefik; same values)",
"true or false",
getStr(func(c *Config) string { return strconv.FormatBool(c.Net.Traefik) }),
getStr(func(c *Config) string { return strconv.FormatBool(c.Net.Haproxy) }),
func(c *Config, v string) error {
b, ok := parseBool(v)
if !ok {
return fmt.Errorf("net.traefik must be true/false or 1/0")
return fmt.Errorf("net.haproxy must be true/false or 1/0")
}
c.Net.Traefik = b
c.Net.Haproxy = b
return nil
}},
{"net.ext_if", KindOperator, ApplyInstall, "external NIC (auto-detected from default route)",
+7 -7
View File
@@ -39,8 +39,8 @@ func TestFieldValueReadsConfig(t *testing.T) {
if v := FieldValue(c, "net.v4_forward"); v != "true" {
t.Errorf("net.v4_forward default = %q", v)
}
if v := FieldValue(c, "net.traefik"); v != "true" {
t.Errorf("net.traefik default = %q", v)
if v := FieldValue(c, "net.haproxy"); v != "true" {
t.Errorf("net.haproxy default = %q", v)
}
if v := FieldValue(c, "panel.show_footer"); v != "true" {
t.Errorf("panel.show_footer default = %q", v)
@@ -101,12 +101,12 @@ func TestAssignValidators(t *testing.T) {
t.Error("v4_forward=maybe accepted")
}
for _, v := range []string{"true", "1", "on", "false", "0", "off"} {
if err := FieldFor("net.traefik").Assign(c, v); err != nil {
t.Errorf("traefik=%q: %v", v, err)
if err := FieldFor("net.haproxy").Assign(c, v); err != nil {
t.Errorf("net.haproxy=%q: %v", v, err)
}
}
if err := FieldFor("net.traefik").Assign(c, "maybe"); err == nil {
t.Error("traefik=maybe accepted")
if err := FieldFor("net.haproxy").Assign(c, "maybe"); err == nil {
t.Error("net.haproxy=maybe accepted")
}
for _, v := range []string{"true", "1", "on", "false", "0", "off"} {
if err := FieldFor("panel.show_footer").Assign(c, v); err != nil {
@@ -199,7 +199,7 @@ func TestEditableClassification(t *testing.T) {
"panel.listen": "yes",
"panel.session_days": "yes",
"net.v4_forward": "yes",
"net.traefik": "yes",
"net.haproxy": "yes",
"net.ipv6_subnet": "yes",
"panel.url_path": "only-when-empty",
"panel.admin_url_path": "yes",
+23 -1
View File
@@ -56,7 +56,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 = 17
const schemaVersion = 18
// migrations are applied in order, each inside its own transaction. v1 is the
// original schema (baseline); later versions only add/alter, never drop.
@@ -285,6 +285,28 @@ var migrations = []struct {
expires_at INTEGER NOT NULL
)`,
}},
// v18: the domain-proxy runtime mirror moves from the legacy "traefik" key
// to "haproxy" (the config field net.traefik was renamed to net.haproxy
// when vpsmgr replaced Traefik with HAProxy). The stored value is a plain
// "true"/"false" string in both spellings, so it is carried over verbatim.
//
// Both statements are unconditional and idempotent, which is what makes
// this safe for every upgrade path:
// - installing over a Traefik-era DB: only "traefik" exists, the INSERT
// copies it to "haproxy", the DELETE drops the old row.
// - a DB that already carries "haproxy" (fresh install, or a re-run
// after a failure): the INSERT hits ON CONFLICT and keeps the NEW
// value, the DELETE still cleans up a stale "traefik" row if present.
// - a DB with neither row: both statements are no-ops.
// Migration v18 runs exactly once (recorded in schema_migrations), which
// is why the "keep the new value" rule is expressed in SQL rather than in
// Go — there is no second chance to get it wrong.
{18, []string{
`INSERT INTO settings(key, value)
SELECT 'haproxy', value FROM settings WHERE key = 'traefik'
ON CONFLICT(key) DO NOTHING`,
`DELETE FROM settings WHERE key = 'traefik'`,
}},
}
// sqliteStr quotes a string literal for SQLite by doubling any single quote.
+2 -2
View File
@@ -67,7 +67,7 @@ func (d *DB) GetDomainByDomain(domain string) (*Domain, error) {
// SetDomainProtocol updates a domain's PROXY protocol flag and bumps
// updated_at. Used both for the real change and the rollback on a failed
// traefik write.
// proxy publish.
func (d *DB) SetDomainProtocol(id int64, on bool) error {
_, err := d.sql.Exec(`UPDATE domains SET proxy_protocol=?, updated_at=? WHERE id=?`, b2i(on), now(), id)
return err
@@ -94,7 +94,7 @@ func (d *DB) ListDomains(userID int64) ([]*Domain, error) {
// ListAllDomains returns every domain with its owner's username and container
// IP, newest modification first. Used by the admin domain panel and the
// reconciliation that regenerates the traefik dynamic files from the DB.
// reconciliation that republishes the HAProxy configuration from the DB.
func (d *DB) ListAllDomains() ([]*DomainView, error) {
rows, err := d.sql.Query(
`SELECT d.id, d.user_id, u.name, u.ip, d.domain, d.proxy_protocol, d.created_at, d.updated_at
+122
View File
@@ -0,0 +1,122 @@
package db
import (
"path/filepath"
"testing"
)
// rewindToV17 opens a fresh database, removes every v18+ migration marker and
// the two settings keys this migration deals with, and returns the path so the
// caller can seed the v17-era rows and then re-open (which applies v18).
func rewindToV17(t *testing.T, rows map[string]string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "pre-v18.db")
d, err := Open(path)
if err != nil {
t.Fatal(err)
}
if _, err := d.sql.Exec(`DELETE FROM schema_migrations WHERE version >= 18`); err != nil {
t.Fatal(err)
}
if _, err := d.sql.Exec(`DELETE FROM settings WHERE key IN ('traefik','haproxy')`); err != nil {
t.Fatal(err)
}
for k, v := range rows {
if _, err := d.sql.Exec(`INSERT INTO settings(key, value) VALUES(?, ?)`, k, v); err != nil {
t.Fatal(err)
}
}
d.Close()
return path
}
// TestMigrateV18RenamesTraefikSetting: a Traefik-era DB carries the
// domain-proxy mirror under "traefik". Migration v18 must carry the value over
// to "haproxy" verbatim and delete the old row.
func TestMigrateV18RenamesTraefikSetting(t *testing.T) {
path := rewindToV17(t, map[string]string{"traefik": "false"})
d, err := Open(path) // applies v18
if err != nil {
t.Fatal(err)
}
defer d.Close()
v, ok, err := d.GetSetting(SettingHaproxy)
if err != nil || !ok {
t.Fatalf("haproxy setting missing after migration (ok=%v err=%v)", ok, err)
}
if v != "false" {
t.Errorf("haproxy = %q, want the legacy value \"false\"", v)
}
if _, ok, _ := d.GetSetting("traefik"); ok {
t.Error("legacy traefik row survived the migration")
}
}
// TestMigrateV18KeepsExistingHaproxyValue: when the new key already exists
// (fresh install, or a re-run after a partial migration), the migration must
// not clobber it with a stale legacy value — but it must still drop the old row.
func TestMigrateV18KeepsExistingHaproxyValue(t *testing.T) {
path := rewindToV17(t, map[string]string{"traefik": "false", "haproxy": "true"})
d, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer d.Close()
v, ok, err := d.GetSetting(SettingHaproxy)
if err != nil || !ok {
t.Fatalf("haproxy setting missing (ok=%v err=%v)", ok, err)
}
if v != "true" {
t.Errorf("haproxy = %q, want the NEW value \"true\" to win", v)
}
if _, ok, _ := d.GetSetting("traefik"); ok {
t.Error("legacy traefik row survived the migration")
}
}
// TestMigrateV18NoRows: a DB with neither key must stay that way — the
// migration must not invent a row (an absent key means "fall back to the
// config file", which is a meaningful state).
func TestMigrateV18NoRows(t *testing.T) {
path := rewindToV17(t, nil)
d, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer d.Close()
if _, ok, _ := d.GetSetting(SettingHaproxy); ok {
t.Error("migration created a haproxy row out of nothing")
}
}
// TestMigrateV18IsIdempotent: re-opening an already migrated DB must be a no-op
// (the migration is recorded in schema_migrations and never re-runs).
func TestMigrateV18IsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "v18.db")
d, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := d.SetSetting(SettingHaproxy, "true"); err != nil {
t.Fatal(err)
}
d.Close()
for i := 0; i < 3; i++ {
d, err := Open(path)
if err != nil {
t.Fatalf("reopen %d: %v", i, err)
}
v, ok, err := d.GetSetting(SettingHaproxy)
if err != nil || !ok || v != "true" {
t.Fatalf("reopen %d: haproxy=(%q, %v, %v)", i, v, ok, err)
}
d.Close()
}
}
+4 -3
View File
@@ -30,9 +30,10 @@ const (
// be added.
SettingV4Forward = "v4_forward"
// SettingTraefik mirrors net.traefik so the long-running panel sees a
// runtime toggle without a panel restart.
SettingTraefik = "traefik"
// SettingHaproxy mirrors net.haproxy so the long-running panel sees a
// runtime toggle without a panel restart. Migration v18 renames the
// pre-HAProxy "traefik" row into this one (same "true"/"false" values).
SettingHaproxy = "haproxy"
// SettingBlockedDomains is the admin blocked-domains list, stored as
// newline-separated normalized domains. A blocked domain and all its
+572
View File
@@ -0,0 +1,572 @@
// Package hpx renders and publishes the HAProxy configuration that fronts
// every user's domain.
//
// The panel never edits the live configuration in place. Instead it builds a
// complete, self-contained "generation" directory, validates it with the
// pinned HAProxy binary, and only then atomically repoints a symlink at it.
// A rejected generation leaves the running proxy untouched.
//
// Layout rooted at cfg.Config.ProxyDir() (default /etc/haproxy):
//
// haproxy.cfg static entry config (installed once, never rewritten)
// releases/<gen>/ one immutable generation
// releases/<gen>/domains.cfg one backend per domain
// releases/<gen>/maps/http.map Host -> backend (port 80)
// releases/<gen>/maps/sni.map SNI -> backend (port 443)
// releases/<gen>/manifest provenance (generation, domain count, time)
// current -> releases/<gen> the generation HAProxy actually loads
// generation monotonic counter of the last published generation
//
// The service loads the entry config first and the current generation second
// (two -f arguments), which is why the frontends live in the entry config and
// the per-domain backends in the generation.
//
// A release is published in three steps: write the new release directory,
// fsync, then atomically replace the "current" symlink. HAProxy only ever
// reads through the symlink, and a reload re-reads it, so an interrupted
// publish leaves the previous generation serving traffic.
package hpx
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"vpsmgr/internal/cfg"
)
// Proxy is the HAProxy configuration publisher.
type Proxy struct {
dir string
// bin is the HAProxy binary used for the configuration check. It is always
// an absolute path so the check can never pick up a different build from
// PATH (see cfg.HaproxyBin).
bin string
// check overrides the validation step (tests inject a stub). nil = run
// bin -c on the candidate release.
check func(releaseCfg string) error
}
// Domain is one published mapping: a normalized domain routed to a container
// IPv4. ProxyProtocol enables PROXY protocol v2 towards the 443 backend only.
type Domain struct {
Domain string
IP string
ProxyProtocol bool
}
// New returns a publisher for the configured proxy directory.
//
// VPSMGR_HAPROXY_BIN overrides the binary used for the configuration check
// (tests substitute a stub; the real one is /usr/sbin/haproxy).
func New(c *cfg.Config) *Proxy {
bin := cfg.DefaultHaproxyBin
if p := os.Getenv("VPSMGR_HAPROXY_BIN"); p != "" {
bin = p
}
return &Proxy{dir: c.ProxyDir(), bin: bin}
}
// SetCheck overrides the configuration-validation step. Tests only.
func (p *Proxy) SetCheck(f func(releaseCfg string) error) { p.check = f }
// Dir reports the layout root (tests and diagnostics).
func (p *Proxy) Dir() string { return p.dir }
func (p *Proxy) entryPath() string { return filepath.Join(p.dir, "haproxy.cfg") }
func (p *Proxy) releasesDir() string { return filepath.Join(p.dir, "releases") }
func (p *Proxy) currentLink() string { return filepath.Join(p.dir, "current") }
func (p *Proxy) generationPath() string { return filepath.Join(p.dir, "generation") }
func (p *Proxy) releaseDir(gen int) string {
return filepath.Join(p.releasesDir(), fmt.Sprintf("%06d", gen))
}
// sanitizeDomain maps a normalized domain to a collision-free identifier
// usable in HAProxy object names: dots become underscores, hyphens and
// alphanumerics stay. Domains are restricted to [a-z0-9.-] before they reach
// this layer, so the mapping is injective and two different domains can never
// produce the same backend name (example.com -> example_com versus
// example-com -> example-com).
func sanitizeDomain(d string) string {
var b strings.Builder
for _, r := range d {
if r == '.' {
b.WriteByte('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// domainID is the stable per-domain suffix used in backend names. A short hash
// keeps names unique even if two domains sanitize differently but collide by
// case (defensive: the domain set is already validated and unique).
func domainID(domain string) string {
sum := sha256.Sum256([]byte(domain))
return hex.EncodeToString(sum[:4])
}
func httpBackendName(domain string) string { return "be_h_" + sanitizeDomain(domain) }
func tlsBackendName(domain string) string { return "be_t_" + sanitizeDomain(domain) }
// WriteDomain publishes a single-domain set. It exists for callers that have
// exactly one domain (and for tests); the manager always republishes the full
// set, because a generation must describe every domain at once.
func (p *Proxy) WriteDomain(domain, ip string, proxyProtocol bool) error {
return p.PublishAll([]Domain{{Domain: domain, IP: ip, ProxyProtocol: proxyProtocol}})
}
// PublishAll renders every domain into a fresh generation, validates it, and
// atomically switches the "current" symlink to it.
//
// Callers pass the COMPLETE desired set (from the database). Rendering the
// whole set every time is what makes the result independent of any previous
// state: a stale file can never survive into a later generation.
func (p *Proxy) PublishAll(domains []Domain) error {
if err := os.MkdirAll(p.releasesDir(), 0o755); err != nil {
return fmt.Errorf("hpx: create releases dir: %w", err)
}
gen := p.nextGeneration()
dir := p.releaseDir(gen)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("hpx: create release dir: %w", err)
}
// Any failure below must not leave a half-written release behind that a
// future generation number could reuse in a confusing way; the directory is
// uniquely numbered, so simply removing it is safe.
ok := false
defer func() {
if !ok {
// The generation never became valid; drop its directory but leave
// the "current" link exactly as it was (validateRelease already
// restored it on failure).
os.RemoveAll(dir)
}
}()
domainsPath := filepath.Join(dir, "domains.cfg")
if err := writeFileSync(domainsPath, []byte(renderDomains(domains)), 0o644); err != nil {
return fmt.Errorf("hpx: write domains: %w", err)
}
mapsDir := filepath.Join(dir, "maps")
if err := os.MkdirAll(mapsDir, 0o755); err != nil {
return fmt.Errorf("hpx: create maps dir: %w", err)
}
// The maps are what the frontends actually consult. They must be written
// BEFORE validation, which loads them.
if err := writeFileSync(filepath.Join(mapsDir, "http.map"), []byte(renderMap(domains, httpBackendName)), 0o644); err != nil {
return fmt.Errorf("hpx: write http map: %w", err)
}
if err := writeFileSync(filepath.Join(mapsDir, "sni.map"), []byte(renderMap(domains, tlsBackendName)), 0o644); err != nil {
return fmt.Errorf("hpx: write sni map: %w", err)
}
if err := writeFileSync(filepath.Join(dir, "manifest"), []byte(renderManifest(gen, domains)), 0o644); err != nil {
return fmt.Errorf("hpx: write manifest: %w", err)
}
if err := fsyncDir(dir); err != nil {
return fmt.Errorf("hpx: sync release dir: %w", err)
}
// Validate BEFORE the change is made permanent. The entry config resolves
// its map paths through "current", so the candidate must be visible there
// for the check to see the NEW maps rather than the old ones. The previous
// target is kept and restored if the check fails, so a rejected generation
// leaves the layout exactly as it was (and a running proxy, which already
// loaded the old files, is unaffected either way).
prev, hadPrev := p.currentTarget()
if err := p.switchCurrent(dir); err != nil {
return err
}
if err := p.validateRelease(dir); err != nil {
if hadPrev {
_ = p.switchCurrent(prev)
} else {
os.Remove(p.currentLink())
}
return err
}
if err := os.WriteFile(p.generationPath(), []byte(strconv.Itoa(gen)+"\n"), 0o644); err != nil {
return fmt.Errorf("hpx: record generation: %w", err)
}
ok = true
p.pruneOldReleases(gen)
return nil
}
// validateRelease checks a release directory that "current" does not point at
// yet, by loading the static entry config together with that directory's own
// domains file — exactly the command line the service uses.
func (p *Proxy) validateRelease(dir string) error {
return p.checkFiles(dir)
}
// validate checks a release directory (used by diagnostics and tests).
func (p *Proxy) validate(dir string) error {
return p.checkFiles(dir)
}
// checkFiles runs the configuration check over the two files the service
// loads. The injected stub (tests, and any caller that cannot run a real
// binary) receives the generation's domains.cfg path.
func (p *Proxy) checkFiles(dir string) error {
domainsCfg := filepath.Join(dir, "domains.cfg")
if p.check != nil {
return p.check(domainsCfg)
}
if _, err := os.Stat(p.bin); err != nil {
return fmt.Errorf("hpx: proxy binary %s is missing: %w", p.bin, err)
}
out, err := exec.Command(p.bin, "-c", "-f", p.entryPath(), "-f", domainsCfg).CombinedOutput()
if err != nil {
return fmt.Errorf("hpx: proxy config check failed: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// WriteEntryConfig writes the layout's static entry config from the renderer's
// template, with the map paths pointing at THIS layout. Production installs
// the vendored copy (configs/haproxy/haproxy.cfg, identical content) with the
// host paths; tests call this so a real `haproxy -c` resolves every include.
func (p *Proxy) WriteEntryConfig() error {
body := strings.ReplaceAll(renderMainConfig(), cfg.DefaultProxyDir, p.dir)
return writeFileSync(p.entryPath(), []byte(body), 0o644)
}
// switchCurrent atomically repoints "current" at dir. The link is created
// under a temporary name and renamed, which is atomic on POSIX: a reader sees
// either the old or the new generation, never a missing link.
func (p *Proxy) switchCurrent(dir string) error {
rel, err := filepath.Rel(p.dir, dir)
if err != nil {
return fmt.Errorf("hpx: relative release path: %w", err)
}
tmp := p.currentLink() + ".tmp"
os.Remove(tmp)
if err := os.Symlink(rel, tmp); err != nil {
return fmt.Errorf("hpx: create current link: %w", err)
}
if err := os.Rename(tmp, p.currentLink()); err != nil {
os.Remove(tmp)
return fmt.Errorf("hpx: switch current link: %w", err)
}
return fsyncDir(p.dir)
}
// nextGeneration returns the generation number to build: one past the recorded
// counter, or 1 when nothing has been published yet. The counter is only a
// monotonic label — correctness comes from building a complete release every
// time, so a lost counter can at worst reuse a directory name that prune()
// has already removed.
func (p *Proxy) nextGeneration() int {
b, err := os.ReadFile(p.generationPath())
if err != nil {
return 1
}
n, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil || n < 0 {
return 1
}
return n + 1
}
// Generation reports the generation currently pointed at by "current" (0 when
// nothing has been published). Used for diagnostics and tests.
func (p *Proxy) Generation() int {
prev, ok := p.currentTarget()
if !ok {
return 0
}
// The target is a zero-padded directory name ("releases/000004").
n, err := strconv.Atoi(filepath.Base(prev))
if err != nil {
return 0
}
return n
}
// currentTarget resolves the "current" symlink to a release directory. ok is
// false when nothing has been published yet.
func (p *Proxy) currentTarget() (string, bool) {
target, err := os.Readlink(p.currentLink())
if err != nil {
return "", false
}
if !filepath.IsAbs(target) {
target = filepath.Join(p.dir, target)
}
return target, true
}
// keepReleases is how many generations are retained for rollback/debugging.
// The running HAProxy keeps an open reference to files in its generation, so
// deleting the directory of a still-draining worker would only matter for
// files it has not read yet — maps and configs are read at load time, so a
// small retention window is enough.
const keepReleases = 3
func (p *Proxy) pruneOldReleases(current int) {
ents, err := os.ReadDir(p.releasesDir())
if err != nil {
return
}
type rel struct {
gen int
dir string
}
var all []rel
for _, e := range ents {
if !e.IsDir() {
continue
}
n, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
all = append(all, rel{n, filepath.Join(p.releasesDir(), e.Name())})
}
sort.Slice(all, func(i, j int) bool { return all[i].gen > all[j].gen })
kept := 0
for _, r := range all {
if r.gen == current {
kept++
continue
}
kept++
if kept > keepReleases {
os.RemoveAll(r.dir)
}
}
}
// writeFileSync writes data to path and fsyncs the file, so the content is on
// disk before the generation is validated and published.
func writeFileSync(path string, data []byte, mode os.FileMode) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// fsyncDir flushes a directory entry so a rename/create is durable.
func fsyncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
// renderManifest records what the generation contains, so an operator can tell
// which set of domains a running process loaded.
func renderManifest(gen int, domains []Domain) string {
var b strings.Builder
fmt.Fprintf(&b, "generation: %d\n", gen)
fmt.Fprintf(&b, "domains: %d\n", len(domains))
fmt.Fprintf(&b, "created: %s\n", time.Now().UTC().Format(time.RFC3339))
names := make([]string, 0, len(domains))
for _, d := range domains {
names = append(names, d.Domain)
}
sort.Strings(names)
for _, n := range names {
fmt.Fprintf(&b, "domain: %s\n", n)
}
return b.String()
}
// renderDomains emits the per-domain backends.
//
// Routing lives in the two MAP files of the same generation, not here, and the
// frontends live in the static entry config. That split is forced by HAProxy's
// parsing model:
//
// - `use_backend` is a FRONTEND directive, and a bare directive belongs to
// whichever proxy section was opened last. Emitting per-domain
// `use_backend` lines from a file loaded after the entry config would
// therefore attach them to the wrong proxy (and re-opening a frontend by
// name is rejected: duplicate names are an error since HAProxy 3.3).
// - expanding N domains into N frontend rules would also mean the frontends
// change on every domain add/remove, i.e. a much larger reload surface.
//
// With maps the frontends stay constant and only two small files change:
//
// host -> backend (http.map) SNI -> backend (sni.map)
//
// A map miss yields the empty backend name, which HAProxy treats as "no rule
// matched" and falls through to the frontend's default_backend (the reject
// backend), so an unregistered host/SNI is never forwarded anywhere. An EMPTY
// map is valid too — that is the state before the first domain is added.
//
// This file therefore only declares the backends. They are reachable solely
// through the maps, which is why every one of them looks "unused" to a reader
// of the entry config.
func renderDomains(domains []Domain) string {
var b bytes.Buffer
b.WriteString(cfg.GeneratedBanner)
b.WriteString("# One HTTP backend (container :80) and one TCP backend (container :443,\n")
b.WriteString("# TLS passthrough) per published domain. The frontends select them through\n")
b.WriteString("# the maps/http.map and maps/sni.map files of this same generation.\n\n")
// Deterministic ordering keeps generations diffable.
sorted := make([]Domain, len(domains))
copy(sorted, domains)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Domain < sorted[j].Domain })
for _, d := range sorted {
hb := httpBackendName(d.Domain)
tb := tlsBackendName(d.Domain)
fmt.Fprintf(&b, "# ---- %s -> %s ----\n", d.Domain, d.IP)
fmt.Fprintf(&b, "backend %s\n", hb)
b.WriteString(" mode http\n")
b.WriteString(" option forwardfor\n")
fmt.Fprintf(&b, " server s %s:80 check\n\n", d.IP)
fmt.Fprintf(&b, "backend %s\n", tb)
b.WriteString(" mode tcp\n")
if d.ProxyProtocol {
// PROXY protocol v2 is a TCP-only feature and is deliberately NOT
// sent on :80 (where X-Forwarded-For carries the client address).
// The container's TLS listener must support it, or the handshake
// fails — this mirrors the per-domain toggle the panel exposes.
fmt.Fprintf(&b, " server s %s:443 check send-proxy-v2\n\n", d.IP)
} else {
fmt.Fprintf(&b, " server s %s:443 check\n\n", d.IP)
}
}
return b.String()
}
// renderMap renders one "key backend" map file. Keys are the normalized
// domains; values are the backend names declared by renderDomains. The file is
// rewritten (never patched) with each generation, and HAProxy re-reads it on
// reload, because a map is a plain file loaded at startup.
func renderMap(domains []Domain, backend func(string) string) string {
var b bytes.Buffer
b.WriteString(cfg.GeneratedBanner)
sorted := make([]Domain, len(domains))
copy(sorted, domains)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Domain < sorted[j].Domain })
for _, d := range sorted {
fmt.Fprintf(&b, "%s %s\n", d.Domain, backend(d.Domain))
}
return b.String()
}
// renderMainConfig emits the STATIC entry configuration: it declares the
// frontends, the reject backends and the timeouts, and it is intentionally
// independent of the domain set. The installed copy lives at
// /etc/haproxy/haproxy.cfg and is never rewritten by the panel; the per-domain
// backends come from the generation, loaded with a second -f.
//
// Routing is expressed with MAP files so that adding or removing a domain
// never has to touch a frontend:
//
// use_backend %[<key>,map(/etc/haproxy/current/maps/<name>.map)]
//
// A map hit yields the backend name; a miss yields an empty value, which
// HAProxy treats as "no rule matched" and falls through to default_backend
// (the reject backend). The map paths point INSIDE the current generation, so
// a graceful reload picks up the new ones.
//
// configs/haproxy/proxy.cfg holds the same content for the installer's
// bootstrap generation; TestMainConfigTemplateMatchesBootstrap keeps the two
// from drifting.
func renderMainConfig() string {
return cfg.GeneratedBanner + `# HAProxy front for vpsmgr containers.
#
# :80 HTTP -> container :80 (reverse proxy, X-Forwarded-For added)
# :443 TLS -> container :443 (SNI routing, TLS PASSTHROUGH: HAProxy never
# terminates TLS, the container serves its own
# certificate)
#
# Every 80/443 listener is owned by this process alone; unknown hosts/SNI are
# answered by the reject backends below rather than forwarded anywhere.
#
# The per-domain backends live in the current generation, loaded by the service
# as a second -f (see /etc/systemd/system/haproxy.service).
global
log /dev/log local0
log /dev/log local1 notice
maxconn 4096
# Drop to the unprivileged account after binding 80/443 as root.
user haproxy
group haproxy
# Long enough that a full drain of established connections is possible,
# short enough that a stuck worker cannot accumulate across many reloads.
hard-stop-after 5m
stats socket /run/haproxy/admin.sock mode 660 level admin
defaults
log global
option dontlognull
retries 2
timeout connect 5s
# Deliberately generous: SSH-style tunnels and WebSockets live for hours.
timeout client 1h
timeout server 1h
timeout tunnel 1h
# ---------------------------------------------------------------------------
# :80 - HTTP host routing
# ---------------------------------------------------------------------------
frontend fe_http
mode http
bind :80
option httplog
# Normalize before the map lookup: lowercase, then drop a trailing ":port"
# (req.hdr(host) keeps the port a client sent).
http-request set-var(txn.host) req.hdr(host),lower,regsub(:[0-9]+$,)
http-request set-header X-Forwarded-Proto http
http-request set-header X-Forwarded-Port 80
# The client address reaches the container as X-Forwarded-For, added by the
# backends (option forwardfor). PROXY protocol is never used on :80.
use_backend %[var(txn.host),map(/etc/haproxy/current/maps/http.map)]
default_backend be_http_reject
backend be_http_reject
mode http
http-request deny deny_status 404
# ---------------------------------------------------------------------------
# :443 - TLS SNI routing (passthrough)
# ---------------------------------------------------------------------------
frontend fe_tls
mode tcp
bind :443
option tcplog
# Give the client a moment to send its ClientHello so the SNI can be read;
# without this the SNI is not yet known when the backend is chosen.
tcp-request inspect-delay 2s
tcp-request content accept if { req.ssl_hello_type 1 }
use_backend %[req.ssl_sni,lower,map(/etc/haproxy/current/maps/sni.map)]
default_backend be_tls_reject
backend be_tls_reject
mode tcp
# No SNI, a host that is not registered, or anything that is not a TLS
# ClientHello: close immediately instead of forwarding.
tcp-request content reject
`
}
+215
View File
@@ -0,0 +1,215 @@
package hpx
import (
"os"
"path/filepath"
"strings"
"testing"
"vpsmgr/internal/cfg"
)
// newTestProxy builds a publisher over a temp layout with a stubbed
// configuration check (the real one shells out to /usr/sbin/haproxy).
func newTestProxy(t *testing.T) *Proxy {
t.Helper()
t.Setenv("VPSMGR_HAPROXY_DIR", t.TempDir())
c := cfg.Default()
p := New(c)
p.SetCheck(func(string) error { return nil })
return p
}
// newRealProxy is like newTestProxy but with the real configuration check, so
// Validate() shells out to the binary. Skips when none is available.
func newRealProxy(t *testing.T) *Proxy {
t.Helper()
bin := os.Getenv("VPSMGR_TEST_HAPROXY_BIN")
if bin == "" {
t.Skip("VPSMGR_TEST_HAPROXY_BIN not set")
}
t.Setenv("VPSMGR_HAPROXY_DIR", t.TempDir())
t.Setenv("VPSMGR_HAPROXY_BIN", bin)
p := New(cfg.Default())
// The entry config only exists on an installed host; render it for the
// temp layout so haproxy resolves every include.
if err := p.WriteEntryConfig(); err != nil {
t.Fatal(err)
}
return p
}
func readCurrent(t *testing.T, p *Proxy, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(p.Dir(), "current", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return string(b)
}
func TestSanitizeDomainCollisionFree(t *testing.T) {
// Two domains that differ only by separator must not collide.
if sanitizeDomain("example.com") == sanitizeDomain("example-com") {
t.Fatal("example.com and example-com collide")
}
if got := sanitizeDomain("api.example.com"); got != "api_example_com" {
t.Fatalf("sanitizeDomain = %q", got)
}
}
func TestPublishRendersHTTPAndTLSBackends(t *testing.T) {
p := newTestProxy(t)
err := p.PublishAll([]Domain{
{Domain: "api.example.com", IP: "10.115.0.2", ProxyProtocol: true},
{Domain: "example.com", IP: "10.115.0.3"},
})
if err != nil {
t.Fatal(err)
}
got := readCurrent(t, p, "domains.cfg")
// HTTP backend: plain reverse proxy to :80, no PROXY protocol.
if !strings.Contains(got, "backend be_h_api_example_com\n mode http\n option forwardfor\n server s 10.115.0.2:80 check") {
t.Errorf("missing HTTP backend:\n%s", got)
}
// TLS backend with PROXY protocol v2 enabled.
if !strings.Contains(got, "server s 10.115.0.2:443 check send-proxy-v2") {
t.Errorf("missing send-proxy-v2 on the 443 backend:\n%s", got)
}
// TLS backend without it.
if !strings.Contains(got, "server s 10.115.0.3:443 check\n") {
t.Errorf("missing plain 443 backend:\n%s", got)
}
// Routing lives in the generation's maps: host -> backend, SNI -> backend.
httpMap := readCurrent(t, p, "maps/http.map")
if !strings.Contains(httpMap, "api.example.com be_h_api_example_com") ||
!strings.Contains(httpMap, "example.com be_h_example_com") {
t.Errorf("unexpected http map:\n%s", httpMap)
}
sniMap := readCurrent(t, p, "maps/sni.map")
if !strings.Contains(sniMap, "api.example.com be_t_api_example_com") {
t.Errorf("unexpected sni map:\n%s", sniMap)
}
}
// A generation must describe the DOMAIN SET, not a delta: publishing a subset
// drops everything else, which is what makes "rebuild from the DB" safe.
func TestPublishReplacesWholeGeneration(t *testing.T) {
p := newTestProxy(t)
if err := p.PublishAll([]Domain{{Domain: "a.example.com", IP: "10.115.0.2"}}); err != nil {
t.Fatal(err)
}
if err := p.PublishAll([]Domain{{Domain: "b.example.com", IP: "10.115.0.3"}}); err != nil {
t.Fatal(err)
}
got := readCurrent(t, p, "domains.cfg")
if strings.Contains(got, "a.example.com") {
t.Fatalf("previous generation's domain survived:\n%s", got)
}
if !strings.Contains(got, "b.example.com") {
t.Fatalf("new domain missing:\n%s", got)
}
if p.Generation() != 2 {
t.Fatalf("generation = %d, want 2", p.Generation())
}
}
// A failed validation must NOT move "current": the previously published
// generation has to keep serving traffic.
func TestValidationFailureKeepsCurrent(t *testing.T) {
p := newTestProxy(t)
if err := p.PublishAll([]Domain{{Domain: "good.example.com", IP: "10.115.0.2"}}); err != nil {
t.Fatal(err)
}
genBefore := p.Generation()
p.SetCheck(func(string) error { return os.ErrInvalid })
if err := p.PublishAll([]Domain{{Domain: "bad.example.com", IP: "10.115.0.3"}}); err == nil {
t.Fatal("publish should fail when the configuration check fails")
}
if p.Generation() != genBefore {
t.Fatalf("generation moved to %d after a failed check", p.Generation())
}
got := readCurrent(t, p, "domains.cfg")
if !strings.Contains(got, "good.example.com") || strings.Contains(got, "bad.example.com") {
t.Fatalf("failed generation leaked into current:\n%s", got)
}
// The rejected release directory must not be left behind.
ents, err := os.ReadDir(filepath.Join(p.Dir(), "releases"))
if err != nil {
t.Fatal(err)
}
for _, e := range ents {
if e.Name() != filepath.Base(mustReadlink(t, filepath.Join(p.Dir(), "current"))) {
t.Fatalf("stale release %s left behind", e.Name())
}
}
}
func mustReadlink(t *testing.T, path string) string {
t.Helper()
target, err := os.Readlink(path)
if err != nil {
t.Fatal(err)
}
return filepath.Join(filepath.Dir(path), target)
}
// Publishing an empty set is valid: every domain was unbound, so the proxy
// keeps running with only the reject backends.
func TestPublishEmptySet(t *testing.T) {
p := newTestProxy(t)
if err := p.PublishAll(nil); err != nil {
t.Fatal(err)
}
// No backends and, crucially, empty maps: every request falls through to
// the reject backends, which is the correct state before the first domain.
if got := readCurrent(t, p, "domains.cfg"); strings.Contains(got, "backend be_h_") || strings.Contains(got, "backend be_t_") {
t.Fatalf("empty set still declares backends:\n%s", got)
}
for _, name := range []string{"maps/http.map", "maps/sni.map"} {
if got := readCurrent(t, p, name); strings.Contains(got, "be_") {
t.Fatalf("%s still routes domains:\n%s", name, got)
}
}
}
// The entry config's include target must exist for the generation HAProxy is
// asked to load, and the manifest must record the domain count.
func TestManifestAndLayout(t *testing.T) {
p := newTestProxy(t)
if err := p.PublishAll([]Domain{{Domain: "example.com", IP: "10.115.0.2"}}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(p.Dir(), "current", "manifest")); err != nil {
t.Fatalf("manifest missing: %v", err)
}
b, err := os.ReadFile(filepath.Join(p.Dir(), "generation"))
if err != nil {
t.Fatalf("generation counter missing: %v", err)
}
if strings.TrimSpace(string(b)) != "1" {
t.Fatalf("generation counter = %q", b)
}
}
// Generations are pruned, but the live one is never removed.
func TestPruneKeepsCurrentGeneration(t *testing.T) {
p := newTestProxy(t)
for i := 0; i < 6; i++ {
if err := p.PublishAll([]Domain{{Domain: "example.com", IP: "10.115.0.2"}}); err != nil {
t.Fatal(err)
}
}
ents, err := os.ReadDir(filepath.Join(p.Dir(), "releases"))
if err != nil {
t.Fatal(err)
}
if len(ents) > keepReleases {
t.Fatalf("kept %d releases, want at most %d", len(ents), keepReleases)
}
if _, err := os.Stat(filepath.Join(p.Dir(), "current", "domains.cfg")); err != nil {
t.Fatalf("current generation was pruned: %v", err)
}
}
+173
View File
@@ -0,0 +1,173 @@
package hpx
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"vpsmgr/internal/cfg"
)
// TestGeneratedConfigIsAcceptedByRealHAProxy runs the configuration the panel
// actually renders (main config + per-domain backends and routing rules)
// through a REAL `haproxy -c`, and then STARTS it to prove haproxy not only
// parses the file but binds and runs with it.
//
// Skipped unless a real binary is provided, so the default `go test ./...`
// stays hermetic:
//
// VPSMGR_TEST_HAPROXY_BIN=/usr/sbin/haproxy CGO_ENABLED=0 \
// go test ./internal/hpx/ -run RealHAProxy -v
func TestGeneratedConfigIsAcceptedByRealHAProxy(t *testing.T) {
bin := os.Getenv("VPSMGR_TEST_HAPROXY_BIN")
if bin == "" {
t.Skip("VPSMGR_TEST_HAPROXY_BIN not set; skipping the real-binary validation")
}
if _, err := os.Stat(bin); err != nil {
t.Fatalf("VPSMGR_TEST_HAPROXY_BIN=%s: %v", bin, err)
}
dir := t.TempDir()
t.Setenv("VPSMGR_HAPROXY_DIR", dir)
t.Setenv("VPSMGR_HAPROXY_BIN", bin)
p := New(cfg.Default())
// The installed entry config is not in the temp layout, and it is loaded
// FIRST by the service; render it here so the check resolves every map.
if err := p.WriteEntryConfig(); err != nil {
t.Fatal(err)
}
// A realistic mix: several domains, one with PROXY protocol enabled, a
// hyphenated label and a subdomain, plus a long one.
domains := []Domain{
{Domain: "example.com", IP: "10.115.0.2"},
{Domain: "api.example.com", IP: "10.115.0.3", ProxyProtocol: true},
{Domain: "my-site.example.com", IP: "10.115.0.4"},
{Domain: "a-very-long-subdomain-label.example.com", IP: "10.115.0.5", ProxyProtocol: true},
}
// PublishAll calls validate() itself when no stub is installed — that is
// exactly the real `haproxy -c -f <generation>` we want to exercise.
if err := p.PublishAll(domains); err != nil {
t.Fatalf("real haproxy rejected the generated configuration: %v", err)
}
domainsCfg := filepath.Join(dir, "current", "domains.cfg")
t.Logf("haproxy accepted generation %d (%s)", p.Generation(), domainsCfg)
// Start it for real. The template binds :80/:443, which a busy test host
// may already own, so run it in a throwaway network namespace: there it is
// the only listener, and binding privileged ports as root works. When the
// namespace cannot be created the parse result above still stands.
if out, err := exec.Command("unshare", "-n", "true").CombinedOutput(); err != nil {
t.Skipf("cannot create a network namespace (%v: %s); skipping the start test", err, strings.TrimSpace(string(out)))
}
cmd := exec.Command("unshare", "-n", bin, "-f", p.entryPath(), "-f", domainsCfg)
cmd.Dir = dir
if err := cmd.Start(); err != nil {
t.Fatalf("could not start haproxy: %v", err)
}
defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
t.Fatalf("haproxy exited immediately — the generated configuration is not loadable: %v", err)
case <-time.After(1500 * time.Millisecond):
// Still running with no bind error: a live process accepted it.
}
}
// TestBootstrapConfigIsAcceptedByRealHAProxy validates the vendored bootstrap
// files that scripts/30-haproxy.sh installs before the panel ever runs: the
// static entry config, which includes an empty first generation.
func TestBootstrapConfigIsAcceptedByRealHAProxy(t *testing.T) {
bin := os.Getenv("VPSMGR_TEST_HAPROXY_BIN")
if bin == "" {
t.Skip("VPSMGR_TEST_HAPROXY_BIN not set")
}
// The repository's configs live three levels above src/.
root := filepath.Join("..", "..", "..", "configs", "haproxy")
entry, err := filepath.Abs(filepath.Join(root, "haproxy.cfg"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(entry); err != nil {
t.Skipf("vendored config not found at %s: %v", entry, err)
}
// Reproduce the installed layout in a temp dir: the entry config includes
// an ABSOLUTE path to the generation, so point that at the temp copy.
dir := t.TempDir()
gen := filepath.Join(dir, "releases", "000001")
if err := os.MkdirAll(gen, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(gen, "maps"), 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"domains.cfg", filepath.Join("maps", "http.map"), filepath.Join("maps", "sni.map")} {
b, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gen, name), b, 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(filepath.Join("releases", "000001"), filepath.Join(dir, "current")); err != nil {
t.Fatal(err)
}
// The entry config is installed at the layout root; render it for this
// temp layout (same content, paths redirected).
p := &Proxy{dir: dir, bin: bin}
if err := p.WriteEntryConfig(); err != nil {
t.Fatal(err)
}
if err := p.validate(gen); err != nil {
t.Fatalf("real haproxy rejected the vendored bootstrap configuration: %v", err)
}
// The vendored bootstrap and the renderer must agree, name for name.
vendored, err := os.ReadFile(entry)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"fe_http", "fe_tls", "be_http_reject", "be_tls_reject", "inspect-delay"} {
if !strings.Contains(string(vendored), want) {
t.Errorf("vendored haproxy.cfg is missing %q", want)
}
}
}
// TestMainConfigTemplateMatchesBootstrap guards the one thing a duplicated
// template can break silently: the renderer and the vendored bootstrap copy
// must declare the same proxy sections, or a published generation would not
// line up with the frontends and reject backends the entry config expects.
func TestMainConfigTemplateMatchesBootstrap(t *testing.T) {
b, err := os.ReadFile(filepath.Join("..", "..", "..", "configs", "haproxy", "proxy.cfg"))
if err != nil {
t.Skipf("vendored bootstrap config not readable: %v", err)
}
// Compare the meaningful lines only: the comments differ between the two
// copies on purpose (one explains the host layout, the other the panel).
norm := func(s string) string {
var out []string
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
want := norm(renderMainConfig())
got := norm(string(b))
if want != got {
t.Errorf("configs/haproxy/proxy.cfg has drifted from renderMainConfig():\n--- renderer ---\n%s\n--- bootstrap ---\n%s", want, got)
}
}
+172 -36
View File
@@ -8,16 +8,24 @@ import (
"vpsmgr/internal/cfg"
"vpsmgr/internal/db"
"vpsmgr/internal/hpx"
)
func setupDomainTest(t *testing.T, traefikDir string) (*Manager, *db.DB, string) {
// setupDomainTest builds a Manager whose proxy publisher writes into a temp
// directory and whose configuration check is stubbed to succeed (the real one
// runs /usr/sbin/haproxy -c, which a test box does not have).
//
// The returned dir is the publisher root; call currentDomains to read back what
// the running proxy would load.
func setupDomainTest(t *testing.T, dir string) (*Manager, *db.DB, string) {
t.Helper()
c := cfg.Default()
c.Net.V4Forward = true
if traefikDir != "" {
os.Setenv("VPSMGR_TRAEFIK_DIR", traefikDir)
t.Cleanup(func() { os.Unsetenv("VPSMGR_TRAEFIK_DIR") })
c.Net.Haproxy = true
if dir == "" {
dir = t.TempDir()
}
t.Setenv("VPSMGR_HAPROXY_DIR", dir)
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
@@ -26,10 +34,59 @@ func setupDomainTest(t *testing.T, traefikDir string) (*Manager, *db.DB, string)
if _, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
return New(c, d), d, "alice"
m := New(c, d)
p := hpx.New(c)
p.SetCheck(func(string) error { return nil })
m.SetProxy(p)
return m, d, "alice"
}
func TestAddDomainWritesDBAndYAML(t *testing.T) {
// setupFailingPublish builds a manager whose publisher FAILS validation, to
// exercise the DB rollback paths.
func setupFailingPublish(t *testing.T) (*Manager, *db.DB, string) {
t.Helper()
c := cfg.Default()
c.Net.V4Forward = true
c.Net.Haproxy = true
t.Setenv("VPSMGR_HAPROXY_DIR", t.TempDir())
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
if _, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
m := New(c, d)
p := hpx.New(c)
p.SetCheck(func(string) error { return os.ErrInvalid })
m.SetProxy(p)
return m, d, "alice"
}
// currentDomains returns the text of the domain fragment in the generation
// "current" points at — exactly what a reload would load.
func currentDomains(t *testing.T, dir string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, "current", "domains.cfg"))
if err != nil {
t.Fatalf("read published domains: %v", err)
}
return string(b)
}
// currentMap returns one of the generation's routing maps. Routing lives there
// (host -> backend, SNI -> backend), not in the domain fragment.
func currentMap(t *testing.T, dir, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, "current", "maps", name))
if err != nil {
t.Fatalf("read published %s: %v", name, err)
}
return string(b)
}
func TestAddDomainPublishesRoute(t *testing.T) {
dir := t.TempDir()
m, d, name := setupDomainTest(t, dir)
if err := m.AddDomain(name, "api.example.com", true); err != nil {
@@ -43,50 +100,73 @@ func TestAddDomainWritesDBAndYAML(t *testing.T) {
if !dmn.ProxyProtocol {
t.Error("proxy_protocol not persisted")
}
// YAML file written with the proxyProtocol block.
b, err := os.ReadFile(filepath.Join(dir, "api.example.com.yaml"))
if err != nil {
t.Fatalf("yaml file missing: %v", err)
got := currentDomains(t, dir)
// 443 backend carries PROXY protocol v2; 80 backend never does.
if !strings.Contains(got, "backend be_t_api_example_com") {
t.Errorf("missing TCP backend:\n%s", got)
}
got := string(b)
if !strings.Contains(got, "proxyProtocol:\n version: 2") || !strings.Contains(got, "u-api_example_com") {
t.Errorf("unexpected yaml:\n%s", got)
if !strings.Contains(got, "server s 10.115.0.2:443 check send-proxy-v2") {
t.Errorf("missing send-proxy-v2 on the 443 backend:\n%s", got)
}
if strings.Contains(got, "10.115.0.2:80 check send-proxy-v2") {
t.Errorf("PROXY protocol must never be sent on :80:\n%s", got)
}
if !strings.Contains(currentMap(t, dir, "http.map"), "api.example.com be_h_api_example_com") {
t.Errorf("missing HTTP routing entry:\n%s", currentMap(t, dir, "http.map"))
}
}
func TestAddDomainRollsBackDBOnTraefikFailure(t *testing.T) {
// The traefik dir points at a nonexistent path, so WriteDomain fails.
m, d, name := setupDomainTest(t, filepath.Join(t.TempDir(), "no", "such", "dir"))
if err := m.AddDomain(name, "example.com", false); err == nil {
t.Fatal("AddDomain should fail when the traefik write fails")
}
if _, err := d.GetDomainByDomain("example.com"); err == nil {
t.Fatal("DB row not rolled back after a traefik write failure")
}
}
func TestSetDomainProtocolRollsBackOnTraefikFailure(t *testing.T) {
m, d, name := setupDomainTest(t, t.TempDir())
func TestAddDomainWithoutProxyProtocol(t *testing.T) {
dir := t.TempDir()
m, _, name := setupDomainTest(t, dir)
if err := m.AddDomain(name, "example.com", false); err != nil {
t.Fatal(err)
}
// A manager whose traefik dir fails on write (captured at New).
os.Setenv("VPSMGR_TRAEFIK_DIR", filepath.Join(t.TempDir(), "no", "such", "dir"))
t.Cleanup(func() { os.Unsetenv("VPSMGR_TRAEFIK_DIR") })
m2 := New(cfg.Default(), d)
if err := m2.SetDomainProtocol(name, "example.com", true); err == nil {
t.Fatal("SetDomainProtocol should fail when the traefik write fails")
got := currentDomains(t, dir)
if strings.Contains(got, "send-proxy-v2") {
t.Errorf("send-proxy-v2 present although the domain has PROXY off:\n%s", got)
}
if !strings.Contains(currentMap(t, dir, "sni.map"), "example.com be_t_example_com") {
t.Errorf("missing SNI routing entry:\n%s", currentMap(t, dir, "sni.map"))
}
}
// A crash between the DB write and the publish must not leave a domain that the
// proxy serves but the panel does not know about.
func TestAddDomainRollsBackDBOnPublishFailure(t *testing.T) {
m, d, name := setupFailingPublish(t)
if err := m.AddDomain(name, "example.com", false); err == nil {
t.Fatal("AddDomain should fail when the publish fails")
}
if _, err := d.GetDomainByDomain("example.com"); err == nil {
t.Fatal("DB row not rolled back after a publish failure")
}
}
func TestSetDomainProtocolRollsBackOnPublishFailure(t *testing.T) {
dir := t.TempDir()
m, d, name := setupDomainTest(t, dir)
if err := m.AddDomain(name, "example.com", false); err != nil {
t.Fatal(err)
}
// Swap in a failing publisher AFTER the domain exists, so only the toggle
// is exercised.
bad := hpx.New(cfg.Default())
bad.SetCheck(func(string) error { return os.ErrInvalid })
m.SetProxy(bad)
if err := m.SetDomainProtocol(name, "example.com", true); err == nil {
t.Fatal("SetDomainProtocol should fail when the publish fails")
}
dmn, err := d.GetDomainByDomain("example.com")
if err != nil {
t.Fatal(err)
}
if dmn.ProxyProtocol {
t.Fatal("proxy_protocol not rolled back after a traefik write failure")
t.Fatal("proxy_protocol not rolled back after a publish failure")
}
}
func TestDelDomainRemovesYAMLAndDB(t *testing.T) {
func TestDelDomainRemovesRouteAndDB(t *testing.T) {
dir := t.TempDir()
m, d, name := setupDomainTest(t, dir)
if err := m.AddDomain(name, "example.com", false); err != nil {
@@ -98,7 +178,63 @@ func TestDelDomainRemovesYAMLAndDB(t *testing.T) {
if _, err := d.GetDomainByDomain("example.com"); err == nil {
t.Fatal("db row still present after delete")
}
if _, err := os.Stat(filepath.Join(dir, "example.com.yaml")); !os.IsNotExist(err) {
t.Fatalf("yaml file still present: %v", err)
// The republished generation must no longer route the domain.
got := currentDomains(t, dir)
if strings.Contains(got, "be_h_example_com") || strings.Contains(got, "be_t_example_com") {
t.Fatalf("domain still routed after delete:\n%s", got)
}
}
// SyncAllDomains must be able to reconstruct everything from the DB alone: a
// wiped proxy directory is repaired on the next sync.
func TestSyncAllDomainsRebuildsFromDB(t *testing.T) {
dir := t.TempDir()
m, d, name := setupDomainTest(t, dir)
if err := m.AddDomain(name, "example.com", false); err != nil {
t.Fatal(err)
}
// Simulate total loss of the published layout.
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
p := hpx.New(cfg.Default())
p.SetCheck(func(string) error { return nil })
m.SetProxy(p)
if err := m.SyncAllDomains(); err != nil {
t.Fatalf("sync after wipe: %v", err)
}
got := currentDomains(t, dir)
if !strings.Contains(got, "backend be_h_example_com") {
t.Fatalf("domain backend not rebuilt from the DB:\n%s", got)
}
if !strings.Contains(currentMap(t, dir, "http.map"), "example.com be_h_example_com") {
t.Fatalf("domain route not rebuilt from the DB:\n%s", currentMap(t, dir, "http.map"))
}
// And the DB is still the only source of truth for it.
if _, err := d.GetDomainByDomain("example.com"); err != nil {
t.Fatal(err)
}
}
// A domain deleted directly from the DB (an out-of-band edit) must disappear
// from the next published generation: generations are rebuilt, never patched.
func TestSyncAllDomainsDropsUnknownDomains(t *testing.T) {
dir := t.TempDir()
m, d, name := setupDomainTest(t, dir)
if err := m.AddDomain(name, "example.com", false); err != nil {
t.Fatal(err)
}
u, err := d.GetUserByName(name)
if err != nil {
t.Fatal(err)
}
if err := d.DeleteDomain(u.ID, "example.com"); err != nil {
t.Fatal(err)
}
if err := m.SyncAllDomains(); err != nil {
t.Fatal(err)
}
if got := currentDomains(t, dir); strings.Contains(got, "be_h_example_com") {
t.Fatalf("a domain no longer in the DB is still routed:\n%s", got)
}
}
+129 -74
View File
@@ -15,10 +15,10 @@ import (
"vpsmgr/internal/cfg"
"vpsmgr/internal/db"
"vpsmgr/internal/fw"
"vpsmgr/internal/hpx"
"vpsmgr/internal/lx"
"vpsmgr/internal/pw"
"vpsmgr/internal/su"
"vpsmgr/internal/tfx"
)
// nameRe is the strict rule for NEW usernames: lowercase letters, digits and
@@ -38,7 +38,9 @@ type Manager struct {
db *db.DB
lx *lx.Client
fw *fw.Firewall
tfx *tfx.Traefik
// proxy renders and publishes the HAProxy configuration that fronts every
// user domain (HTTP host routing on :80, TLS SNI passthrough on :443).
proxy *hpx.Proxy
// opMu serializes Add/Del/Reinstall within this process (the panels share
// one Manager), so two simultaneous creates cannot race for the same
@@ -67,9 +69,9 @@ type Manager struct {
// domainMu serializes domain mutations (AddDomain/DelDomain/
// SetDomainProtocol + SyncAllDomains) so two panel requests cannot race on
// the same domain's DB row and YAML file (review P2-12). SyncAllDomains is
// deliberately NOT held while Del removes domain files — Del already holds
// opMu, and holding both would risk lock-order deadlock with AddDomain.
// the same domain's DB row and published generation (review P2-12).
// Callers that already hold domainMu must use syncAllDomainsLocked: the
// mutex is not reentrant.
domainMu sync.Mutex
// cpuLimit is the container-quota writer the dynamic CPU limit uses. It is
@@ -86,7 +88,7 @@ type Manager struct {
}
func New(c *cfg.Config, d *db.DB) *Manager {
m := &Manager{cfg: c, db: d, lx: lx.New(c.Incus.Socket, c.Incus.SwapRatio), fw: fw.New(c), tfx: tfx.New(c)}
m := &Manager{cfg: c, db: d, lx: lx.New(c.Incus.Socket, c.Incus.SwapRatio), fw: fw.New(c), proxy: hpx.New(c)}
m.cpuLimit = m.lx
m.RefreshHostStats()
return m
@@ -678,15 +680,22 @@ func (m *Manager) Del(name string) error {
if err != nil {
return err
}
// Domain configs are removed FIRST, before the container and DB row. A
// leftover traefik YAML keeps proxying to the (about to be deleted) IP;
// if that IP is later reused by a new user, the old domain would silently
// point at the new tenant (cross-tenant hijack, review P1-7). So a failed
// domain-file removal aborts the whole delete; the retry re-runs it.
// The user's domain ROUTING is withdrawn FIRST, before the container and
// the DB row. A leftover route would keep proxying to the (about to be
// deleted) IP; if that IP is later reused by a new user, the old domain
// would silently point at the new tenant (cross-tenant hijack, review
// P1-7). So a failed republish aborts the whole delete; the retry re-runs
// it. Deleting the DB rows first is what actually removes the routes: the
// published generation is derived from the domains table.
domains, _ := m.db.ListDomains(u.ID)
for _, d := range domains {
if err := m.tfx.RemoveDomain(d.Domain); err != nil {
return fmt.Errorf("remove traefik config for %s: %w (retry after fixing)", d.Domain, err)
if len(domains) > 0 {
for _, d := range domains {
if err := m.db.DeleteDomain(u.ID, d.Domain); err != nil {
return fmt.Errorf("withdraw domain %s: %w (retry after fixing)", d.Domain, err)
}
}
if err := m.syncAllDomainsLocked(); err != nil {
return fmt.Errorf("withdraw domain routing: %w (retry after fixing)", err)
}
}
// If the container cannot actually be removed, keep the DB record and let
@@ -727,7 +736,7 @@ func (m *Manager) Del(name string) error {
// ApplyV4State enforces the current v4_forward policy: it rewrites (when on)
// or removes (when off) every user's DNAT rules, reloads the ruleset, and
// applies the related Traefik state. Called by
// applies the related HAProxy state. Called by
// `vps config set net.v4_forward` and at the end of `vps install`. It also
// records the effective policy in the DB settings so the long-running panel
// process reflects the toggle without a restart.
@@ -753,7 +762,7 @@ func (m *Manager) ApplyV4State() error {
if err := m.db.SetSetting(db.SettingV4Forward, strconv.FormatBool(m.cfg.Net.V4Forward)); err != nil {
return fmt.Errorf("record v4_forward: %w", err)
}
return m.ApplyTraefikState()
return m.ApplyHaproxyState()
}
// V4ForwardLive reports whether IPv4 inbound is currently enabled, preferring
@@ -768,41 +777,71 @@ func (m *Manager) V4ForwardLive() bool {
return v == "1" || strings.EqualFold(v, "true")
}
// TraefikLive reports the effective domain-proxy toggle, including changes
// HaproxyLive reports the effective domain-proxy toggle, including changes
// made by `vps config set` while this panel process remains running.
func (m *Manager) TraefikLive() bool {
v, ok, err := m.db.GetSetting(db.SettingTraefik)
func (m *Manager) HaproxyLive() bool {
v, ok, err := m.db.GetSetting(db.SettingHaproxy)
if err != nil || !ok {
return m.cfg.Net.Traefik
return m.cfg.Net.Haproxy
}
return v == "1" || strings.EqualFold(v, "true")
}
// ApplyTraefikState starts/stops the traefik service to match v4_forward and
// net.traefik: with either disabled, the domain proxy is not offered, so
// traefik is stopped and
// its BOOT AUTOSTART is disabled (systemctl disable --now) — it must not come
// back on the next reboot. Domain config files are KEPT, so re-enabling
// restores them; a full re-sync runs when enabling. systemctl errors are
// surfaced, not swallowed.
func (m *Manager) ApplyTraefikState() error {
if err := m.db.SetSetting(db.SettingTraefik, strconv.FormatBool(m.cfg.Net.Traefik)); err != nil {
return fmt.Errorf("record traefik: %w", err)
// ApplyHaproxyState starts/stops haproxy.service to match v4_forward and
// net.haproxy: with either disabled, the domain proxy is not offered, so the
// service is stopped and its BOOT AUTOSTART is disabled (systemctl disable
// --now) — it must not come back on the next reboot. Published domain
// configurations are KEPT, so re-enabling restores them; a full re-sync runs
// when enabling. systemctl errors are surfaced, not swallowed.
func (m *Manager) ApplyHaproxyState() error {
if err := m.db.SetSetting(db.SettingHaproxy, strconv.FormatBool(m.cfg.Net.Haproxy)); err != nil {
return fmt.Errorf("record haproxy: %w", err)
}
if m.cfg.Net.V4Forward && m.cfg.Net.Traefik {
if err := systemctl("enable", "--now", "traefik.service"); err != nil {
return fmt.Errorf("start traefik: %w", err)
if m.cfg.Net.V4Forward && m.cfg.Net.Haproxy {
if err := systemctl("enable", "--now", cfg.DefaultHaproxyService); err != nil {
return fmt.Errorf("start %s: %w", cfg.DefaultHaproxyService, err)
}
return m.SyncAllDomains()
}
if err := systemctl("disable", "--now", "traefik.service"); err != nil {
return fmt.Errorf("stop/disable traefik: %w", err)
if err := systemctl("disable", "--now", cfg.DefaultHaproxyService); err != nil {
return fmt.Errorf("stop/disable %s: %w", cfg.DefaultHaproxyService, err)
}
return nil
}
// reloadProxy asks HAProxy to load the generation just published.
//
// A reload (SIGUSR2 to the master, which is what the unit's ExecReload sends)
// starts a new worker on the new files and lets the old one drain its
// established connections — the whole point of publishing to a new generation
// instead of editing files in place. Without this call a change would only
// take effect at the next service restart.
//
// A reload is a no-op for correctness when the service is not running (the
// next start loads the same files), so a "not active" failure is not fatal.
func (m *Manager) reloadProxy() error {
if err := systemctl("reload", cfg.DefaultHaproxyService); err != nil {
// `systemctl reload` fails when the unit is inactive. The published
// generation is on disk either way, so only report it if the proxy is
// supposed to be up.
if active, aerr := m.proxyActive(); aerr == nil && !active {
return nil
}
return fmt.Errorf("reload %s: %w", cfg.DefaultHaproxyService, err)
}
return nil
}
// proxyActive reports whether haproxy.service is currently running.
func (m *Manager) proxyActive() (bool, error) {
if _, err := su.Run("/usr/bin/systemctl", "is-active", "--quiet", cfg.DefaultHaproxyService); err != nil {
return false, nil // is-active exits non-zero when inactive
}
return true, nil
}
// systemctl runs systemctl and returns its stderr on failure. The panel
// daemon is unprivileged, so traefik (and self) control goes through the
// daemon is unprivileged, so haproxy (and self) control goes through the
// sudoers whitelist.
func systemctl(args ...string) error {
if _, err := su.Run(append([]string{"/usr/bin/systemctl"}, args...)...); err != nil {
@@ -811,39 +850,54 @@ func systemctl(args ...string) error {
return nil
}
// SyncAllDomains reconciles the traefik dynamic directory against the DB (the
// single source of truth): it regenerates every domain's YAML and removes any
// leftover file that no longer matches a domain. This is the safety net that
// fixes any drift left by a crash between a DB write and a file write.
// SyncAllDomains republishes the HAProxy configuration from the DB (the single
// source of truth): every domain in the database becomes a backend plus a
// routing rule in a fresh, validated generation, and anything the database no
// longer knows about is simply absent from that generation.
//
// Unlike the old per-domain file layout there is no orphan sweep to perform:
// each generation is built from scratch, so a leftover file can never be read
// by the running proxy. This is the safety net that repairs drift left by a
// crash between a DB write and a publish.
func (m *Manager) SyncAllDomains() error {
m.domainMu.Lock()
defer m.domainMu.Unlock()
return m.syncAllDomainsLocked()
}
// syncAllDomainsLocked is SyncAllDomains for callers that ALREADY hold
// domainMu (AddDomain/DelDomain/SetDomainProtocol). domainMu is not
// reentrant, so those must not call the exported wrapper.
//
// Publishing is not enough on its own: HAProxy reads maps and backends at load
// time, so the new generation only takes effect after a reload. That reload
// happens here, once per mutation, and is what makes a domain add/remove
// visible without dropping established connections.
func (m *Manager) syncAllDomainsLocked() error {
domains, err := m.db.ListAllDomains()
if err != nil {
return err
}
known := make(map[string]bool, len(domains))
var firstErr error
for _, d := range domains {
known[d.Domain] = true
if err := m.tfx.WriteDomain(d.Domain, d.IP, d.ProxyProtocol); err != nil && firstErr == nil {
firstErr = err
}
}
files, err := m.tfx.ListFiles()
if err != nil {
if err := m.proxy.PublishAll(toProxyDomains(domains)); err != nil {
return err
}
for _, f := range files {
if !known[f] {
if err := m.tfx.RemoveDomain(f); err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
return m.reloadProxy()
}
// toProxyDomains adapts the DB view rows to the publisher's input type.
func toProxyDomains(rows []*db.DomainView) []hpx.Domain {
out := make([]hpx.Domain, 0, len(rows))
for _, d := range rows {
out = append(out, hpx.Domain{Domain: d.Domain, IP: d.IP, ProxyProtocol: d.ProxyProtocol})
}
return out
}
// SetProxy replaces the HAProxy publisher. Tests use it to inject a stub
// validation step: the real one shells out to /usr/sbin/haproxy, which is not
// installed on a test box.
func (m *Manager) SetProxy(p *hpx.Proxy) { m.proxy = p }
func (m *Manager) List() ([]*Result, error) {
users, err := m.db.ListUsers()
if err != nil {
@@ -1440,17 +1494,18 @@ func (m *Manager) Reinstall(name, image string) (string, error) {
return pass, nil
}
// AddDomain binds a domain to a user. DB and traefik YAML are kept in sync
// atomically: insert the DB row, write the domain's YAML file, and if the file
// write fails roll the DB row back so the two never disagree.
// AddDomain binds a domain to a user. The DB row is the source of truth and
// the proxy configuration is derived from it: insert the row, republish the
// generation, and if publishing fails roll the DB row back so the two never
// disagree.
func (m *Manager) AddDomain(name, domain string, proxyProtocol bool) error {
m.domainMu.Lock()
defer m.domainMu.Unlock()
if !m.V4ForwardLive() {
return errors.New("v4 forwarding is disabled (v4_forward: false) — domains are not available; re-enable with `vps config set net.v4_forward true`")
}
if !m.TraefikLive() {
return errors.New("Traefik is disabled (net.traefik: false) — domains are not available; re-enable with `vps config set net.traefik true`")
if !m.HaproxyLive() {
return errors.New("HAProxy is disabled (net.haproxy: false) — domains are not available; re-enable with `vps config set net.haproxy true`")
}
u, err := m.db.GetUserByName(name)
if err != nil {
@@ -1476,15 +1531,15 @@ func (m *Manager) AddDomain(name, domain string, proxyProtocol bool) error {
if _, err := m.db.AddDomain(u.ID, domain, proxyProtocol); err != nil {
return err
}
if err := m.tfx.WriteDomain(domain, u.IP, proxyProtocol); err != nil {
if err := m.syncAllDomainsLocked(); err != nil {
_ = m.db.DeleteDomain(u.ID, domain) // roll back the DB row
return fmt.Errorf("write traefik config: %w", err)
return fmt.Errorf("publish haproxy config: %w", err)
}
return nil
}
// DelDomain unbinds a domain, atomically: delete the DB row, remove the YAML
// file, and if the file removal fails re-insert the row.
// DelDomain unbinds a domain, atomically: delete the DB row, republish the
// generation, and if publishing fails re-insert the row.
func (m *Manager) DelDomain(name, domain string) error {
m.domainMu.Lock()
defer m.domainMu.Unlock()
@@ -1503,9 +1558,9 @@ func (m *Manager) DelDomain(name, domain string) error {
if err := m.db.DeleteDomain(u.ID, domain); err != nil {
return err
}
if err := m.tfx.RemoveDomain(domain); err != nil {
if err := m.syncAllDomainsLocked(); err != nil {
if _, rerr := m.db.AddDomain(u.ID, domain, dmn.ProxyProtocol); rerr == nil {
return fmt.Errorf("remove traefik config: %w", err)
return fmt.Errorf("publish haproxy config: %w", err)
}
return err
}
@@ -1513,7 +1568,7 @@ func (m *Manager) DelDomain(name, domain string) error {
}
// SetDomainProtocol toggles a user's domain PROXY protocol flag, atomically:
// update the DB, rewrite the YAML, and on failure restore the old flag.
// update the DB, republish the generation, and on failure restore the old flag.
func (m *Manager) SetDomainProtocol(name, domain string, on bool) error {
m.domainMu.Lock()
defer m.domainMu.Unlock()
@@ -1535,9 +1590,9 @@ func (m *Manager) SetDomainProtocol(name, domain string, on bool) error {
if err := m.db.SetDomainProtocol(dmn.ID, on); err != nil {
return err
}
if err := m.tfx.WriteDomain(domain, u.IP, on); err != nil {
if err := m.syncAllDomainsLocked(); err != nil {
_ = m.db.SetDomainProtocol(dmn.ID, dmn.ProxyProtocol) // restore old flag
return fmt.Errorf("write traefik config: %w", err)
return fmt.Errorf("publish haproxy config: %w", err)
}
return nil
}
@@ -1673,8 +1728,8 @@ func (m *Manager) EnsureBlockRoutes() error {
return firstErr
}
// normalizeDomain validates and normalizes a domain for use in Traefik
// dynamic YAML. Rules:
// normalizeDomain validates and normalizes a domain before it is stored and
// rendered into the proxy configuration. Rules:
// 1. lowercase, strip surrounding whitespace and ALL trailing dots
// 2. keep only [a-z0-9.-] — any other character is a hard error (never
// silently stripped), so pasting "https://example.com" or a path is
+10 -10
View File
@@ -9,7 +9,7 @@ import (
)
// TestAddDomainRejectedWhenV4Off: with v4_forward=false the domain proxy is not
// offered, so adding a domain must be rejected before any traefik write.
// offered, so adding a domain must be rejected before anything is published.
func TestAddDomainRejectedWhenV4Off(t *testing.T) {
c := cfg.Default()
c.Net.V4Forward = false
@@ -27,9 +27,9 @@ func TestAddDomainRejectedWhenV4Off(t *testing.T) {
}
}
func TestAddDomainRejectedWhenTraefikOff(t *testing.T) {
func TestAddDomainRejectedWhenProxyOff(t *testing.T) {
c := cfg.Default()
c.Net.Traefik = false
c.Net.Haproxy = false
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
@@ -40,11 +40,11 @@ func TestAddDomainRejectedWhenTraefikOff(t *testing.T) {
}
m := New(c, d)
if err := m.AddDomain("alice", "example.com", false); err == nil {
t.Fatal("AddDomain should be rejected when Traefik is disabled")
t.Fatal("AddDomain should be rejected when the domain proxy is disabled")
}
}
func TestTraefikLive(t *testing.T) {
func TestHaproxyLive(t *testing.T) {
c := cfg.Default()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
@@ -52,14 +52,14 @@ func TestTraefikLive(t *testing.T) {
}
defer d.Close()
m := New(c, d)
if !m.TraefikLive() {
t.Fatal("TraefikLive should fall back to the config default")
if !m.HaproxyLive() {
t.Fatal("HaproxyLive should fall back to the config default")
}
if err := d.SetSetting(db.SettingTraefik, "false"); err != nil {
if err := d.SetSetting(db.SettingHaproxy, "false"); err != nil {
t.Fatal(err)
}
if m.TraefikLive() {
t.Fatal("TraefikLive should read false from the DB setting")
if m.HaproxyLive() {
t.Fatal("HaproxyLive should read false from the DB setting")
}
}
+2 -2
View File
@@ -161,7 +161,7 @@ type pageData struct {
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
ProxyEnabled bool // false = domain proxy (HAProxy) 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
@@ -389,7 +389,7 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData {
PortsPrefix: itoa(u.StartPort / 100),
SSH: "ssh -p " + itoa(u.SSHPort) + " root@" + s.cfg.DisplayIP(),
V4Forward: s.mgr.V4ForwardLive(),
TraefikEnabled: s.mgr.TraefikLive(),
ProxyEnabled: s.mgr.HaproxyLive(),
ShowFooter: s.cfg.Panel.ShowFooter,
Version: ver.Version,
InitScript: u.InitScript,
+18 -2
View File
@@ -7,6 +7,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -19,14 +21,28 @@ import (
const testSecret = "Ab1_cdE-9x"
// useTempProxyLayout points the HAProxy publisher at a temp directory and at a
// stub binary that always reports the configuration as valid. Domain tests
// must not touch /etc/haproxy, and the real /usr/sbin/haproxy is not installed
// on a test box.
func useTempProxyLayout(t *testing.T) {
t.Helper()
t.Setenv("VPSMGR_HAPROXY_DIR", t.TempDir())
bin := filepath.Join(t.TempDir(), "haproxy")
if err := os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("VPSMGR_HAPROXY_BIN", bin)
}
func newTestServer(t *testing.T) (*Server, *db.DB) {
t.Helper()
c := cfg.Default()
c.Panel.URLPath = testSecret
c.Panel.PublicIP = "127.0.0.1"
c.Panel.SessionDays = 3
// Keep per-domain traefik writes out of /etc/traefik.
t.Setenv("VPSMGR_TRAEFIK_DIR", t.TempDir())
// Keep per-domain proxy writes out of /etc/haproxy.
useTempProxyLayout(t)
d, err := db.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
+2 -2
View File
@@ -352,10 +352,10 @@ footer a{color:var(--muted);text-decoration:underline}
{{else}}
<div class="muted">{{if eq .Lang "zh"}}未绑定域名{{else}}No domains bound{{end}}</div>
{{end}}
{{if and .V4Forward .TraefikEnabled}}<form action="{{.Prefix}}/domain-add" method="post">
{{if and .V4Forward .ProxyEnabled}}<form action="{{.Prefix}}/domain-add" method="post">
<div class="frow"><label>{{if eq .Lang "zh"}}新域名{{else}}New domain{{end}}</label><input name="domain" placeholder="example.com"{{if .Expired}} disabled{{end}}><label class="chk"><input type="checkbox" name="proxy_protocol" value="1"{{if .Expired}} disabled{{end}}> PROXY v2 <span class="help" tabindex="0" role="button" aria-label="?">?<span class="tip">{{if eq $.Lang "zh"}}443 透传时向后端发送 PROXY 报文以汇报访客真实 IP;需后端支持,默认关闭。{{else}}443 passthrough sends the real client IP via PROXY; needs backend support, off by default.{{end}}</span></span></label><button class="btn ok" type="submit"{{if .Expired}} disabled{{end}}>{{if eq .Lang "zh"}}添加{{else}}Add{{end}}</button></div>
</form>
{{else}}<div class="muted">{{if eq .Lang "zh"}}{{if .V4Forward}}Traefik 已关闭,无法添加域名。{{else}}IPv4 入站已关闭,无法添加域名。{{end}}{{else}}{{if .V4Forward}}Traefik is disabled — domains can't be added.{{else}}IPv4 inbound is off — domains can't be added.{{end}}{{end}}</div>{{end}}
{{else}}<div class="muted">{{if eq .Lang "zh"}}{{if .V4Forward}}HAProxy 已关闭,无法添加域名。{{else}}IPv4 入站已关闭,无法添加域名。{{end}}{{else}}{{if .V4Forward}}HAProxy is disabled — domains can't be added.{{else}}IPv4 inbound is off — domains can't be added.{{end}}{{end}}</div>{{end}}
</div>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
// The panel daemon (vps serve) runs as the unprivileged 'vps' user. A small
// sudoers whitelist (installed to /etc/sudoers.d/vps at install time) allows
// that user to run ONLY the exact commands vpsmgr needs as root — nftables
// reloads, traefik service control, IPv6 route/neighbor/addr changes, sysctl
// reloads, haproxy service control, IPv6 route/neighbor/addr changes, sysctl
// and the ndppd NDP proxy. Everything else is denied.
//
// Run always passes -n (non-interactive): if sudo would prompt for a password
-100
View File
@@ -1,100 +0,0 @@
package tfx
import (
"fmt"
"os"
"path/filepath"
"strings"
"vpsmgr/internal/cfg"
)
type Traefik struct {
dir string
}
func New(c *cfg.Config) *Traefik { return &Traefik{dir: c.TraefikDir()} }
// sanitizeDomain maps a normalized domain to a traefik element name that is
// collision-free: dots become underscores, hyphens and alphanumerics stay.
// Because domains only contain [a-z0-9.-], the mapping is injective, so two
// different domains can never produce the same router/service name (e.g.
// example.com -> example_com versus example-com -> example-com). Traefik
// element names must be globally unique across all dynamic files.
func sanitizeDomain(d string) string {
var b strings.Builder
for _, r := range d {
if r == '.' {
b.WriteByte('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// filePath is the per-domain dynamic file: <domain>.yaml. Domain names are
// normalized to [a-z0-9.-], so this is a safe filename (no slashes, no dots
// colliding with the extension logic).
func (t *Traefik) filePath(domain string) string {
return filepath.Join(t.dir, domain+".yaml")
}
// WriteDomain writes one domain's complete dynamic config: an HTTP router +
// service (port 80) and a TLS-passthrough TCP router + service (port 443).
// When proxyProtocol is enabled the TCP service sends PROXY protocol v2 to the
// container before the TLS bytes (the backend must support it; HTTP/80 has no
// proxy protocol - traefik injects X-Forwarded-For headers there). The direct
// `loadBalancer.proxyProtocol` form matches traefik 3.3.5 (the pinned version);
// traefik >= 3.5.2 deprecated it in favour of a `serversTransport` - revisit
// when upgrading. Atomic: written to a temp file and renamed.
func (t *Traefik) WriteDomain(domain, ip string, proxyProtocol bool) error {
san := sanitizeDomain(domain)
var b strings.Builder
b.WriteString(cfg.GeneratedBanner)
fmt.Fprintf(&b, "http:\n routers:\n u-%s:\n rule: \"Host(`%s`)\"\n entryPoints: [web]\n service: s-%s\n", san, domain, san)
fmt.Fprintf(&b, " services:\n s-%s:\n loadBalancer:\n servers: [{ url: \"http://%s:80\" }]\n", san, ip)
fmt.Fprintf(&b, "tcp:\n routers:\n t-%s:\n rule: \"HostSNI(`%s`)\"\n entryPoints: [websecure]\n tls: { passthrough: true }\n service: t-%s\n", san, domain, san)
fmt.Fprintf(&b, " services:\n t-%s:\n loadBalancer:\n servers: [{ address: \"%s:443\" }]\n", san, ip)
if proxyProtocol {
fmt.Fprintf(&b, " proxyProtocol:\n version: 2\n")
}
tmp := t.filePath(domain) + fmt.Sprintf(".tmp.%d", os.Getpid())
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
return err
}
return os.Rename(tmp, t.filePath(domain))
}
// RemoveDomain removes a domain's dynamic file. Already-gone files are treated
// as success.
func (t *Traefik) RemoveDomain(domain string) error {
err := os.Remove(t.filePath(domain))
if os.IsNotExist(err) {
return nil
}
return err
}
// ListFiles returns the base domain names of every *.yaml file in the dynamic
// directory (the part before ".yaml"), for orphan cleanup during
// reconciliation.
func (t *Traefik) ListFiles() ([]string, error) {
ents, err := os.ReadDir(t.dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, e := range ents {
if e.IsDir() {
continue
}
if name := e.Name(); strings.HasSuffix(name, ".yaml") {
out = append(out, strings.TrimSuffix(name, ".yaml"))
}
}
return out, nil
}
-74
View File
@@ -1,74 +0,0 @@
package tfx
import (
"os"
"path/filepath"
"strings"
"testing"
"vpsmgr/internal/cfg"
)
func TestSanitizeDomainCollisionFree(t *testing.T) {
cases := map[string]string{
"example.com": "example_com",
"example-com": "example-com",
"api.example.com": "api_example_com",
}
for in, want := range cases {
if got := sanitizeDomain(in); got != want {
t.Errorf("sanitizeDomain(%q) = %q, want %q", in, got, want)
}
}
// example.com and example-com must never collide.
if sanitizeDomain("example.com") == sanitizeDomain("example-com") {
t.Fatal("example.com and example-com collide")
}
}
// newTfx builds a Traefik pointing at a temp dir (via the env override).
func newTfx(t *testing.T) *Traefik {
t.Helper()
dir := t.TempDir()
os.Setenv("VPSMGR_TRAEFIK_DIR", dir)
t.Cleanup(func() { os.Unsetenv("VPSMGR_TRAEFIK_DIR") })
return New(&cfg.Config{})
}
func TestWriteDomainYAML(t *testing.T) {
tfx := newTfx(t)
if err := tfx.WriteDomain("api.example.com", "10.115.0.2", true); err != nil {
t.Fatal(err)
}
if err := tfx.WriteDomain("example.com", "10.115.0.2", false); err != nil {
t.Fatal(err)
}
api, err := os.ReadFile(filepath.Join(tfx.dir, "api.example.com.yaml"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(api), "proxyProtocol:\n version: 2") {
t.Errorf("api yaml missing proxyProtocol:\n%s", api)
}
if !strings.Contains(string(api), "u-api_example_com") || !strings.Contains(string(api), "t-api_example_com") {
t.Errorf("api yaml missing per-domain router/service names:\n%s", api)
}
plain, err := os.ReadFile(filepath.Join(tfx.dir, "example.com.yaml"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(plain), "proxyProtocol") {
t.Errorf("plain domain must not have proxyProtocol:\n%s", plain)
}
// Remove + list.
if err := tfx.RemoveDomain("example.com"); err != nil {
t.Fatal(err)
}
files, err := tfx.ListFiles()
if err != nil {
t.Fatal(err)
}
if len(files) != 1 || files[0] != "api.example.com" {
t.Errorf("ListFiles = %v", files)
}
}
+42 -35
View File
@@ -47,9 +47,10 @@ Group=vps
ExecStart=/usr/local/bin/vps serve
Restart=always
RestartSec=3
# The panel writes /etc/vpsmgr (config/db/certs) and /etc/traefik/dynamic.
# /etc/vpsmgr is chowned to vps at install; ProtectSystem is off so those
# writes work while the rest of the host stays untouched by policy.
# The panel writes /etc/vpsmgr (config/db/certs) and /etc/haproxy (the
# published proxy generations). Both are chowned to vps at install;
# ProtectSystem is off so those writes work while the rest of the host stays
# untouched by policy.
NoNewPrivileges=false
[Install]
WantedBy=multi-user.target
@@ -374,7 +375,7 @@ func cmdInstall() error {
}
d.Close()
// Unprivileged panel: create the 'vps' service user (if missing), hand it
// the writable dirs (/etc/vpsmgr, /etc/traefik/dynamic) and the sudoers
// the writable dirs (/etc/vpsmgr, /etc/haproxy) and the sudoers
// whitelist, and add it to incus-admin so the socket API is fully usable
// without root. Idempotent on adoption. Hard failure: a panel without its
// sudoers whitelist would look healthy but silently fail every privileged
@@ -448,8 +449,8 @@ func cmdInstall() error {
if out, err := exec.Command("systemctl", "enable", "--now", "vps.service").CombinedOutput(); err != nil {
return fmt.Errorf("enable vps: %s", strings.TrimSpace(string(out)))
}
// Enforce the IPv4 inbound policy and the independent Traefik state.
// (v4_forward off = IPv6-only; net.traefik off = domain proxy disabled.)
// Enforce the IPv4 inbound policy and the independent HAProxy state.
// (v4_forward off = IPv6-only; net.haproxy off = domain proxy disabled.)
// Idempotent.
d2, err := db.Open(c.Panel.DB)
if err != nil {
@@ -587,14 +588,14 @@ func cmdIPv6Proxy() error {
// ensureVPSUser sets up the unprivileged 'vps' account the panel daemon runs
// as. Idempotent, called by `vps install`:
// - creates the system user if missing
// - chowns /etc/vpsmgr and /etc/traefik/dynamic so the panel can write its
// config, db, certs and domain files without root. /etc/traefik and the
// dynamic dir are 0755 (world-readable), so the traefik service reads the
// dynamic YAMLs as its own unprivileged user — no group cross-link needed.
// - chowns /etc/vpsmgr and the HAProxy layout root so the panel can write its
// config, db, certs and published proxy generations without root. The
// proxy root is 0755 so the haproxy service can read the generations it
// serves (it never needs to write there).
// - adds vps to the incus-admin group so the Incus Unix-socket API is fully
// usable (the socket is group-rw by incus-admin)
// - installs the sudoers whitelist granting ONLY the exact privileged
// commands the panel needs (nft reload, traefik/self systemctl, IPv6
// commands the panel needs (nft reload, haproxy/self systemctl, IPv6
// route/neigh/addr, sysctl forwarding, ndppd control)
func ensureVPSUser(c *cfg.Config) error {
// 1. System user. A reinstall can leave the 'vps' group behind after
@@ -615,11 +616,11 @@ func ensureVPSUser(c *cfg.Config) error {
if err := exec.Command("chown", "-R", "vps:vps", c.DataDir()).Run(); err != nil {
return fmt.Errorf("chown %s: %w", c.DataDir(), err)
}
if err := os.MkdirAll(c.TraefikDir(), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", c.TraefikDir(), err)
if err := os.MkdirAll(c.ProxyDir(), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", c.ProxyDir(), err)
}
if err := exec.Command("chown", "-R", "vps:vps", c.TraefikDir()).Run(); err != nil {
return fmt.Errorf("chown %s: %w", c.TraefikDir(), err)
if err := exec.Command("chown", "-R", "vps:vps", c.ProxyDir()).Run(); err != nil {
return fmt.Errorf("chown %s: %w", c.ProxyDir(), err)
}
// 3. incus-admin group membership (the Incus socket's owning group).
if err := exec.Command("usermod", "-aG", "incus-admin", "vps").Run(); err != nil {
@@ -628,7 +629,7 @@ func ensureVPSUser(c *cfg.Config) error {
// 3b. ndppd.conf: the panel renders this file itself inside /etc/vpsmgr
// (its own writable dir) — no root-zone file to create or chown here.
// 4. sudoers whitelist — a hard requirement: without it every privileged
// operation (nft reload, traefik/systemctl, IPv6 wiring) fails at runtime.
// operation (nft reload, haproxy/systemctl, IPv6 wiring) fails at runtime.
if err := ensureSudoers(); err != nil {
return fmt.Errorf("sudoers whitelist: %w", err)
}
@@ -650,8 +651,14 @@ func ensureSudoers() error {
vps ALL=(root) NOPASSWD: /usr/sbin/nft add table inet vpsmgr
vps ALL=(root) NOPASSWD: /usr/sbin/nft -c -f /etc/vpsmgr/nftables.conf
vps ALL=(root) NOPASSWD: /usr/sbin/nft -f /etc/vpsmgr/nftables.conf
vps ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now traefik.service
vps ALL=(root) NOPASSWD: /usr/bin/systemctl disable --now traefik.service
vps ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now haproxy.service
vps ALL=(root) NOPASSWD: /usr/bin/systemctl disable --now haproxy.service
# A domain change publishes a new generation and then reloads: SIGUSR2 to the
# master starts a worker on the new configuration while the old one drains its
# established connections. A restart is deliberately NOT whitelisted — it would
# drop every live HTTP/TLS connection.
vps ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy.service
vps ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet haproxy.service
vps ALL=(root) NOPASSWD: /usr/bin/systemctl restart vps.service
vps ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet vps.service
# IPv6 route/neigh/addr changes go through the vps ip6 root helper, which
@@ -713,15 +720,15 @@ func cmdServe() error {
if err := m.SetSnapshotShareEnabled(c.Snapshots.Share); err != nil {
log.Printf("warn: sync snapshot share mirror: %v", err)
}
// Reconcile the traefik dynamic directory against the DB at every panel
// start (review P1-7/P2-11): a crash between a DB write and a file write
// leaves the two divergent, and the domain YAMLs are the only thing a
// proxy actually reads. Regenerating from the DB (and dropping orphan
// files) makes the panel self-heal after any crash. Only meaningful when
// v4 forwarding is on.
// Reconcile the published HAProxy configuration against the DB at every
// panel start (review P1-7/P2-11): a crash between a DB write and a publish
// leaves the two divergent, and the published generation is the only thing
// the proxy reads. Regenerating from the DB (and dropping every domain the
// DB no longer knows) makes the panel self-heal after any crash. Only
// meaningful when v4 forwarding is on.
if m.V4ForwardLive() {
if err := m.SyncAllDomains(); err != nil {
log.Printf("warn: traefik reconciliation at startup: %v", err)
log.Printf("warn: haproxy reconciliation at startup: %v", err)
}
}
userPath := panelPath(c)
@@ -1067,22 +1074,22 @@ func configSet(args []string) error {
fmt.Printf("%s updated and applied to all containers.\n", key)
return nil
}
if key == "net.traefik" {
if key == "net.haproxy" {
d, err := db.Open(c.Panel.DB)
if err != nil {
return fmt.Errorf("config saved, but applying Traefik state failed: %w", err)
return fmt.Errorf("config saved, but applying HAProxy state failed: %w", err)
}
defer d.Close()
if err := mgr.New(c, d).ApplyTraefikState(); err != nil {
return fmt.Errorf("config saved, but applying Traefik state failed: %w", err)
if err := mgr.New(c, d).ApplyHaproxyState(); err != nil {
return fmt.Errorf("config saved, but applying HAProxy state failed: %w", err)
}
fmt.Printf("%s updated and applied (Traefik state refreshed).\n", key)
fmt.Printf("%s updated and applied (HAProxy state refreshed).\n", key)
return nil
}
if err := applyV4State(c); err != nil {
return err
}
fmt.Printf("%s updated and applied (firewall/traefik state refreshed).\n", key)
fmt.Printf("%s updated and applied (firewall/haproxy state refreshed).\n", key)
case cfg.ApplyNone:
return fmt.Errorf("%s is not settable", key)
}
@@ -1148,8 +1155,8 @@ func confirmApply(c *cfg.Config, f *cfg.Field, key string) (bool, error) {
switch key {
case "net.v4_forward":
what = "toggles container IPv4 inbound immediately — SSH / port / domain reachability of every container changes"
case "net.traefik":
what = "starts/stops Traefik immediately and changes whether new domains may be added"
case "net.haproxy":
what = "starts/stops HAProxy immediately and changes whether new domains may be added"
case "incus.swap_ratio":
what = "re-applies the swap allowance of every existing container (no restart)"
default:
@@ -1190,7 +1197,7 @@ func listenFree(addr string) bool {
}
// applyV4State enforces the current net.v4_forward policy (firewall plus the
// related Traefik state).
// related HAProxy state).
func applyV4State(c *cfg.Config) error {
d, err := db.Open(c.Panel.DB)
if err != nil {
@@ -1353,7 +1360,7 @@ func userDel(name string) error {
if err := m.Del(name); err != nil {
return err
}
fmt.Printf("user %s deleted (container, nft rules, traefik config, records)\n", name)
fmt.Printf("user %s deleted (container, nft rules, domain routes, records)\n", name)
return nil
}
+25 -7
View File
@@ -9,7 +9,10 @@ if [[ "${1:-}" == "--purge" ]]; then PURGE=1; fi
log(){ echo "[un] $*"; }
log "stopping services..."
for svc in vps vps-nft vps-ipv6 traefik; do
# 'traefik' is listed for historical installs: a host that never ran the
# HAProxy-era installer still has that unit, and uninstall must converge from
# any version.
for svc in vps vps-nft vps-ipv6 haproxy traefik; do
systemctl disable --now "$svc.service" >/dev/null 2>&1 || true
done
systemctl daemon-reload >/dev/null 2>&1 || true
@@ -63,16 +66,24 @@ if [[ -n "$V6SUBNET" ]]; then
fi
log "removing files..."
# Both proxy binaries are removed: /usr/local/bin/traefik for a host that never
# ran the HAProxy-era installer, and the HAProxy package itself is purged in
# the --purge branch below (see the apt handling there).
rm -f /usr/local/bin/vps /usr/local/bin/traefik
rm -f /etc/systemd/system/vps.service /etc/systemd/system/vps-nft.service /etc/systemd/system/vps-ipv6.service /etc/systemd/system/traefik.service
rm -f /etc/systemd/system/vps.service /etc/systemd/system/vps-nft.service /etc/systemd/system/vps-ipv6.service
rm -f /etc/systemd/system/haproxy.service /etc/systemd/system/traefik.service
# Restore the host-wide io_uring clamp to the kernel default before dropping
# 99-vpsmgr.conf (which sets it to 1 at install time).
sysctl -w kernel.io_uring_disabled=0 >/dev/null 2>&1 || true
rm -f /etc/sysctl.d/99-vpsmgr.conf
nft delete table inet vpsmgr 2>/dev/null || true
# /etc/vpsmgr (config/db/certs) and /etc/traefik are deliberately KEPT here:
# without --purge, a reinstall should adopt the existing users/domains/settings.
log " kept /etc/vpsmgr and /etc/traefik (reinstall will adopt them)"
# /etc/vpsmgr (config/db/certs), /etc/haproxy (the published proxy generations
# and the static entry config) and /etc/traefik (a pre-HAProxy install's config)
# are deliberately KEPT here: without --purge a reinstall should adopt the
# existing users/domains/settings without republishing everything by hand.
# The HAProxy PACKAGE is kept too on a plain uninstall, so a reinstall needs no
# network round trip; --purge removes it below.
log " kept /etc/vpsmgr, /etc/haproxy and /etc/traefik (reinstall will adopt them)"
# Remove the panel's privilege surface even on a plain uninstall (review
# P2-10): a leftover /etc/sudoers.d/vps grants the vps user root-level nft /
@@ -98,8 +109,15 @@ if getent group traefik >/dev/null 2>&1; then
fi
if [[ $PURGE -eq 1 ]]; then
log "purging vpsmgr config/db/certs and traefik config..."
rm -rf /etc/vpsmgr /etc/traefik
log "purging vpsmgr config/db/certs, the HAProxy layout and any traefik config..."
rm -rf /etc/vpsmgr /etc/haproxy /etc/traefik
# The HAProxy package is removed here (not on a plain uninstall): purging the
# apt source and key as well leaves no vendor repository behind.
if dpkg -s haproxy-awslc >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq haproxy-awslc >/dev/null 2>&1 || true
fi
rm -f /etc/apt/sources.list.d/haproxy.list /usr/share/keyrings/HAPROXY-key-community.asc
apt-get update -qq >/dev/null 2>&1 || true
if command -v incus >/dev/null 2>&1; then
log "purging Incus instances..."
for c in $(incus list --format=csv -c n 2>/dev/null); do
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# upgrade-haproxy.sh — follow the pinned HAProxy branch to its newest patch.
#
# Usage:
# ./upgrade-haproxy.sh # upgrade if a newer patch exists
# ./upgrade-haproxy.sh --check # report the current/available versions, change nothing
#
# WHY A SEPARATE SCRIPT
# ---------------------------------------------------------------------------
# vpsmgr pins the HAProxy MAJOR BRANCH (3.4 today) but deliberately follows the
# newest PATCH within it: patch releases only carry fixes, never behaviour
# changes, and staying current keeps security fixes flowing without touching
# the configuration semantics the panel generates.
#
# `install.sh --update` upgrades the panel binary AND calls this script, so a
# normal upgrade keeps both in step. Run it on its own to pick up an HAProxy
# patch without touching the panel.
#
# UPGRADING ACROSS BRANCHES IS A DELIBERATE, MANUAL EDIT
# ---------------------------------------------------------------------------
# The branch lives in ONE place: HAPROXY_BRANCH below (mirrored by
# 30-haproxy.sh and cfg.HaproxyBranch in the panel — change all three). This
# script never moves to another branch on its own; it only moves within the
# pinned one, and refuses to continue if the installed binary is not on it.
# ---------------------------------------------------------------------------
set -euo pipefail
log(){ echo "[upgrade] $*"; }
die(){ echo "[upgrade] error: $*" >&2; exit 1; }
CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
# --- pinned branch (see the header) ---------------------------------------
HAPROXY_BRANCH="3.4"
HAPROXY_BRANCH_RE='^3\.4\.'
HAPROXY_REPO_SLUG="ha34"
HAPROXY_PKG="haproxy-awslc"
HAPROXY_DIR="/etc/haproxy"
ENTRY_CFG="$HAPROXY_DIR/haproxy.cfg"
BACKUP_DIR="/etc/vpsmgr/haproxy-backup"
[[ $EUID -eq 0 ]] || die "must run as root"
# --- resolve the distribution keys of the vendor repository ---------------
OS_ID="${ID:-}"; OS_CODENAME="${VERSION_CODENAME:-}"
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"; OS_CODENAME="${VERSION_CODENAME:-}"
fi
[[ -n "$OS_CODENAME" ]] || die "could not determine VERSION_CODENAME"
installed_version(){
# "HAProxy version 3.4.4-0+ha34+deb13u1 2026/08/27" -> 3.4.4-0+ha34+deb13u1
[[ -x /usr/sbin/haproxy ]] || { echo ""; return; }
/usr/sbin/haproxy -v 2>/dev/null | awk 'NR==1{print $3}'
}
current="$(installed_version)"
[[ -n "$current" ]] || die "HAProxy is not installed at /usr/sbin/haproxy (run ./install.sh first)"
# An install still on the pre-HAProxy branch is a BRANCH change, not a patch
# upgrade: refuse rather than silently migrating the domain proxy's version.
if [[ ! "$current" =~ $HAPROXY_BRANCH_RE ]]; then
die "installed HAProxy '$current' is not on the pinned branch $HAPROXY_BRANCH.
A branch change is a deliberate migration: update HAPROXY_BRANCH /
HAPROXY_BRANCH_RE / HAPROXY_REPO_SLUG here, in scripts/30-haproxy.sh and
in src/internal/cfg (HaproxyBranch), then re-run ./install.sh."
fi
# --- make sure the apt source points at the pinned branch ------------------
src="/etc/apt/sources.list.d/haproxy.list"
want="deb [signed-by=/usr/share/keyrings/HAPROXY-key-community.asc] https://www.haproxy.com/download/haproxy/performance/$OS_ID/$HAPROXY_REPO_SLUG $OS_CODENAME main"
if [[ ! -f "$src" ]] || [[ "$(cat "$src")" != "$want" ]]; then
log "fixing the HAProxy apt source (it did not point at branch $HAPROXY_BRANCH)"
# Leftover lists from another branch must not be merged into this one.
rm -f /var/lib/apt/lists/*haproxy* 2>/dev/null || true
printf '%s\n' "$want" > "$src"
chmod 0644 "$src"
fi
apt-get update -qq || die "apt-get update failed (check the HAProxy repository)"
candidate="$(apt-cache policy "$HAPROXY_PKG" 2>/dev/null | awk '/Candidate:/{print $2}')"
[[ -n "$candidate" ]] || die "no $HAPROXY_PKG candidate in the configured repository"
log "installed: $current"
log "candidate: $candidate"
if [[ "$CHECK_ONLY" == "1" ]]; then
exit 0
fi
# Nothing to do when the candidate is not newer (or is unknown to dpkg, which
# happens when the installed build came from another source).
if dpkg --compare-versions "$candidate" le "$current" 2>/dev/null; then
log "already on the newest $HAPROXY_BRANCH patch — nothing to do"
exit 0
fi
# --- back up before changing anything -------------------------------------
# The panel can rebuild every generation from the database, but the STATIC
# entry config and the unit are vpsmgr-owned files that a package upgrade could
# touch, so keep a copy to diff against if something goes wrong.
mkdir -p "$BACKUP_DIR"
cp -a "$ENTRY_CFG" "$BACKUP_DIR/haproxy.cfg.$current" 2>/dev/null || true
cp -a /etc/systemd/system/haproxy.service "$BACKUP_DIR/haproxy.service.$current" 2>/dev/null || true
log "backup: $BACKUP_DIR (entry config + unit as of $current)"
# --- upgrade, then prove the result --------------------------------------
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade "$HAPROXY_PKG" \
|| die "package upgrade failed — the previous version is still installed and untouched"
new="$(installed_version)"
if [[ ! "$new" =~ $HAPROXY_BRANCH_RE ]]; then
die "upgraded binary reports '$new', which is not on branch $HAPROXY_BRANCH"
fi
log "upgraded: $current -> $new"
# The candidate configuration must still be valid under the new binary before
# it is allowed to serve traffic.
if ! /usr/sbin/haproxy -c -q -f "$ENTRY_CFG"; then
log "configuration check FAILED under $new — rolling the package back to $current"
apt-get install -y -qq --allow-downgrades "$HAPROXY_PKG=$current" || log "warn: rollback failed, fix by hand"
die "HAProxy upgrade rolled back (the new binary rejected the configuration)"
fi
log "configuration check passed under $new"
# --- graceful reload: existing connections are NOT dropped ----------------
# SIGUSR2 tells the master to load the new binary + configuration into a fresh
# worker; the old worker drains its established connections and exits. A
# `systemctl restart` would kill them, so it is never used here.
if systemctl is-active --quiet haproxy.service; then
systemctl reload haproxy.service || die "graceful reload failed — service left as it was"
sleep 1
systemctl is-active --quiet haproxy.service || die "service is not active after the reload"
log "haproxy reloaded without dropping established connections"
else
# Installed but intentionally disabled (net.haproxy false, or 80/443 taken):
# do not start it behind the operator's back.
log "haproxy is not running — binary upgraded, service left stopped"
fi
log "done: HAProxy $new on branch $HAPROXY_BRANCH"