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:
1 parent
f6e0727ca4
commit
4c93ae4be3
47 files changed
+2736
-649
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user