diff --git a/AGENTS.md b/AGENTS.md index 72b5516..7a7c2b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.en.md b/README.en.md index 71154fd..914fec3 100644 --- a/README.en.md +++ b/README.en.md @@ -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) diff --git a/README.md b/README.md index 4b02e8a..e2ee01c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/configs/config.yaml.example b/configs/config.yaml.example index c5e699a..534a9d4 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -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". diff --git a/configs/haproxy/domains.cfg b/configs/haproxy/domains.cfg new file mode 100644 index 0000000..c0dd387 --- /dev/null +++ b/configs/haproxy/domains.cfg @@ -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. diff --git a/configs/haproxy/haproxy.cfg b/configs/haproxy/haproxy.cfg new file mode 100644 index 0000000..57b9c03 --- /dev/null +++ b/configs/haproxy/haproxy.cfg @@ -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// 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 diff --git a/configs/haproxy/maps/http.map b/configs/haproxy/maps/http.map new file mode 100644 index 0000000..af1ef4f --- /dev/null +++ b/configs/haproxy/maps/http.map @@ -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 " " line per published domain. diff --git a/configs/haproxy/maps/sni.map b/configs/haproxy/maps/sni.map new file mode 100644 index 0000000..c840846 --- /dev/null +++ b/configs/haproxy/maps/sni.map @@ -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 " " line per published domain. diff --git a/configs/haproxy/proxy.cfg b/configs/haproxy/proxy.cfg new file mode 100644 index 0000000..6430236 --- /dev/null +++ b/configs/haproxy/proxy.cfg @@ -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// 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 diff --git a/configs/systemd/haproxy.service b/configs/systemd/haproxy.service new file mode 100644 index 0000000..d63dbc5 --- /dev/null +++ b/configs/systemd/haproxy.service @@ -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 diff --git a/configs/systemd/traefik.service b/configs/systemd/traefik.service deleted file mode 100644 index b3beb29..0000000 --- a/configs/systemd/traefik.service +++ /dev/null @@ -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 diff --git a/configs/traefik.yaml b/configs/traefik.yaml deleted file mode 100644 index 6ad7341..0000000 --- a/configs/traefik.yaml +++ /dev/null @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 777da69..991efc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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..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/.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// # the generation in service + releases// + 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 diff --git a/docs/configuration.md b/docs/configuration.md index 694f5f7..ae6561b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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..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-.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/.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//{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..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//` | 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 diff --git a/docs/development.md b/docs/development.md index 1fc9004..fe2368f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/install.sh b/install.sh index 5c2e740..f31765e 100755 --- a/install.sh +++ b/install.sh @@ -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 =====" diff --git a/oneclick.sh b/oneclick.sh index 02aedab..0b6ee36 100644 --- a/oneclick.sh +++ b/oneclick.sh @@ -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: diff --git a/scripts/00-check.sh b/scripts/00-check.sh index daefeac..3281d33 100755 --- a/scripts/00-check.sh +++ b/scripts/00-check.sh @@ -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" diff --git a/scripts/00-ip-ask.sh b/scripts/00-ip-ask.sh index 8b62981..e2dfb50 100644 --- a/scripts/00-ip-ask.sh +++ b/scripts/00-ip-ask.sh @@ -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 diff --git a/scripts/20-network.sh b/scripts/20-network.sh index 2298d8f..e63e1f6 100755 --- a/scripts/20-network.sh +++ b/scripts/20-network.sh @@ -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 <&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//ha) +# 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" diff --git a/scripts/30-traefik.sh b/scripts/30-traefik.sh deleted file mode 100755 index 62686cd..0000000 --- a/scripts/30-traefik.sh +++ /dev/null @@ -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" diff --git a/src/internal/admin/admin_test.go b/src/internal/admin/admin_test.go index 2a70050..90b1bb7 100644 --- a/src/internal/admin/admin_test.go +++ b/src/internal/admin/admin_test.go @@ -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 diff --git a/src/internal/admin/handlers.go b/src/internal/admin/handlers.go index 1208452..ce6f94e 100644 --- a/src/internal/admin/handlers.go +++ b/src/internal/admin/handlers.go @@ -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) diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go index 0f9edb7..4c7181c 100644 --- a/src/internal/cfg/cfg.go +++ b/src/internal/cfg/cfg.go @@ -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// one immutable generation (maps + dynamic + manifest) + // current -> releases/ + // 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 diff --git a/src/internal/cfg/cfg_test.go b/src/internal/cfg/cfg_test.go index bf370f4..7e322d5 100644 --- a/src/internal/cfg/cfg_test.go +++ b/src/internal/cfg/cfg_test.go @@ -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") } } diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index fcbc935..20cb5af 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -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)", diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go index 6cadac1..334958f 100644 --- a/src/internal/cfg/registry_test.go +++ b/src/internal/cfg/registry_test.go @@ -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", diff --git a/src/internal/db/db.go b/src/internal/db/db.go index a6be679..9d66d0e 100644 --- a/src/internal/db/db.go +++ b/src/internal/db/db.go @@ -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. diff --git a/src/internal/db/domain.go b/src/internal/db/domain.go index ab782f3..8152c53 100644 --- a/src/internal/db/domain.go +++ b/src/internal/db/domain.go @@ -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 diff --git a/src/internal/db/migrate_haproxy_test.go b/src/internal/db/migrate_haproxy_test.go new file mode 100644 index 0000000..83d63ee --- /dev/null +++ b/src/internal/db/migrate_haproxy_test.go @@ -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() + } +} diff --git a/src/internal/db/settings.go b/src/internal/db/settings.go index 8ee5525..b8a65fe 100644 --- a/src/internal/db/settings.go +++ b/src/internal/db/settings.go @@ -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 diff --git a/src/internal/hpx/hpx.go b/src/internal/hpx/hpx.go new file mode 100644 index 0000000..6ba0cf4 --- /dev/null +++ b/src/internal/hpx/hpx.go @@ -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// one immutable generation +// releases//domains.cfg one backend per domain +// releases//maps/http.map Host -> backend (port 80) +// releases//maps/sni.map SNI -> backend (port 443) +// releases//manifest provenance (generation, domain count, time) +// current -> releases/ 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 %[,map(/etc/haproxy/current/maps/.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 +` +} diff --git a/src/internal/hpx/hpx_test.go b/src/internal/hpx/hpx_test.go new file mode 100644 index 0000000..ecca022 --- /dev/null +++ b/src/internal/hpx/hpx_test.go @@ -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) + } +} diff --git a/src/internal/hpx/realbin_test.go b/src/internal/hpx/realbin_test.go new file mode 100644 index 0000000..bb10113 --- /dev/null +++ b/src/internal/hpx/realbin_test.go @@ -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 ` 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) + } +} diff --git a/src/internal/mgr/domain_test.go b/src/internal/mgr/domain_test.go index 470e5c9..194893d 100644 --- a/src/internal/mgr/domain_test.go +++ b/src/internal/mgr/domain_test.go @@ -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) } } diff --git a/src/internal/mgr/mgr.go b/src/internal/mgr/mgr.go index 9168662..48be83c 100644 --- a/src/internal/mgr/mgr.go +++ b/src/internal/mgr/mgr.go @@ -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 diff --git a/src/internal/mgr/v4_test.go b/src/internal/mgr/v4_test.go index 1635518..42eb4f4 100644 --- a/src/internal/mgr/v4_test.go +++ b/src/internal/mgr/v4_test.go @@ -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") } } diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index d3b44d3..afe545d 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -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, diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index f3ceaf0..7f4a41b 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -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) diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index 5982e74..ddaf948 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -352,10 +352,10 @@ footer a{color:var(--muted);text-decoration:underline} {{else}}
{{if eq .Lang "zh"}}未绑定域名{{else}}No domains bound{{end}}
{{end}} -{{if and .V4Forward .TraefikEnabled}}
+{{if and .V4Forward .ProxyEnabled}}
-{{else}}
{{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}}
{{end}} +{{else}}
{{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}}
{{end}} diff --git a/src/internal/su/su.go b/src/internal/su/su.go index 75e6d0f..df23bb7 100644 --- a/src/internal/su/su.go +++ b/src/internal/su/su.go @@ -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 diff --git a/src/internal/tfx/tfx.go b/src/internal/tfx/tfx.go deleted file mode 100644 index e3f68e0..0000000 --- a/src/internal/tfx/tfx.go +++ /dev/null @@ -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: .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 -} diff --git a/src/internal/tfx/tfx_test.go b/src/internal/tfx/tfx_test.go deleted file mode 100644 index 6a645ec..0000000 --- a/src/internal/tfx/tfx_test.go +++ /dev/null @@ -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) - } -} diff --git a/src/main.go b/src/main.go index 79ced5f..002e696 100644 --- a/src/main.go +++ b/src/main.go @@ -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 } diff --git a/uninstall.sh b/uninstall.sh index 40cd80d..45c3d31 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -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 diff --git a/upgrade-haproxy.sh b/upgrade-haproxy.sh new file mode 100644 index 0000000..d0ad215 --- /dev/null +++ b/upgrade-haproxy.sh @@ -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"