diff --git a/backend/cmd/agent/identity.go b/backend/cmd/agent/identity.go index 4476471..57af4c6 100644 --- a/backend/cmd/agent/identity.go +++ b/backend/cmd/agent/identity.go @@ -16,6 +16,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" ) @@ -109,6 +110,14 @@ func register(cfg config, p paths) (*identity, error) { if resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) + // An HTML body (or a bare 405) means we reached a web server, not the + // agent API: WGPANEL_PANEL_ADDR points at the panel's WEB port instead of + // NODE_AGENT_PORT. Say so - the raw nginx/SPA error page explains nothing. + if resp.StatusCode == http.StatusMethodNotAllowed || + strings.Contains(resp.Header.Get("Content-Type"), "text/html") || + bytes.Contains(bytes.ToLower(body), []byte(" Nodes -> Add Node): " JOIN_TOKEN - read -rp "A name for this node (e.g. de-frankfurt-1): " NODE_NAME - read -rp "WireGuard listen port [51820]: " WG_PORT - WG_PORT=${WG_PORT:-51820} + # The agent dials https:///agent/* - that's the panel's NODE_AGENT_PORT + # (48443 by default), NOT the panel web UI port and NOT WireGuard's UDP port. + # Getting this wrong is the most common install mistake, so probe it over TCP + # before accepting the answer. + while true; do + read_required "Control plane address (host:port - the panel's NODE_AGENT_PORT, e.g. panel.example.com:48443): " PANEL_ADDR + if [[ "$PANEL_ADDR" != *:* ]]; then + warn "Expected host:port, e.g. panel.example.com:48443." + continue + fi + if ! timeout 5 bash -c ": /dev/null; then + warn "Cannot reach ${PANEL_ADDR} over TCP. Check the address: the port must be the" + warn "panel's node-agent port (NODE_AGENT_PORT in the panel's .env, 48443 by default)," + warn "not the WireGuard port, and it must be open in the panel server's firewall." + read -rp "Use ${PANEL_ADDR} anyway? [y/N]: " CONFIRM + [[ "${CONFIRM,,}" == y* ]] && break + continue + fi + # Reachable is not enough: the panel's WEB port (443) also answers TCP. The real + # agent endpoint replies with plain text/JSON, never HTML - an HTML answer means + # this is the web UI and registration would die with an nginx "405 Not Allowed". + if curl -skm 5 "https://${PANEL_ADDR}/agent/register" 2>/dev/null | grep -qiE ' Nodes -> Add Node): " JOIN_TOKEN + read_required "A name for this node (e.g. de-frankfurt-1): " NODE_NAME + + while true; do + read -rp "WireGuard listen port [51820]: " WG_PORT + WG_PORT="${WG_PORT//$'\r'/}" + WG_PORT=${WG_PORT:-51820} + [[ "$WG_PORT" =~ ^[0-9]+$ ]] && break + warn "The port must be a number." + done + read -rp "WireGuard interface name [wg0]: " WG_IFACE + WG_IFACE="${WG_IFACE//$'\r'/}" WG_IFACE=${WG_IFACE:-wg0} - read -rp "This node's own WireGuard interface address, with prefix (the .1 of the subnet you set in the panel, e.g. 10.66.0.1/24): " WG_IFACE_ADDR + + while true; do + read_required "This node's own WireGuard interface address, with prefix (the .1 of the subnet you set in the panel, e.g. 10.66.0.1/24): " WG_IFACE_ADDR + [[ "$WG_IFACE_ADDR" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] && break + warn "Expected an IPv4 address with a prefix length, e.g. 10.66.0.1/24." + done cat > "$ENV_FILE" </dev/null 2>&1 || true - ufw allow "${WG_PORT}/udp" + # A broken ufw/iptables ("ERROR: problem running iptables/ufw-init" - classically a + # kernel upgraded without a reboot, leaving the running kernel unable to load + # iptables modules) must not abort the install this late. The node works without + # the rule; the port just has to be opened once ufw is healthy again. + if ! ufw allow "${WG_PORT}/udp"; then + warn "ufw could not add the ${WG_PORT}/udp rule (see the error above) - continuing anyway." + warn "Clients can't connect until UDP ${WG_PORT} is open. If the error mentions iptables," + warn "a reboot usually fixes it (pending kernel upgrade); then run: ufw allow ${WG_PORT}/udp" + fi # ufw's default FORWARD policy is DROP, which also drops the traffic Docker forwards # for the node container's clients (container bridge -> host egress). Set it to ACCEPT # so Docker's own specific per-bridge FORWARD rules govern forwarding - without this, diff --git a/deploy/install.sh b/deploy/install.sh index 3934a61..fc95fdb 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -190,13 +190,21 @@ setup_files() { } setup_firewall() { + local rule log "Configuring firewall (ufw)..." ufw allow OpenSSH >/dev/null 2>&1 || true - ufw allow 80/tcp - ufw allow 443/tcp - # Node agents connect back to the control plane on this port over the public internet. + # Node agents connect back to the control plane on NODE_AGENT_PORT over the public + # internet. A broken ufw/iptables ("ERROR: problem running iptables/ufw-init" - + # classically a kernel upgraded without a reboot) must not abort the install this + # late; warn with the exact rule to add by hand once ufw is healthy again. NODE_AGENT_PORT="$(grep '^NODE_AGENT_PORT=' "$ENV_FILE" | cut -d= -f2)" - ufw allow "${NODE_AGENT_PORT}/tcp" + for rule in 80/tcp 443/tcp "${NODE_AGENT_PORT}/tcp"; do + if ! ufw allow "$rule"; then + warn "ufw could not add the ${rule} rule (see the error above) - continuing anyway." + warn "If the error mentions iptables, a reboot usually fixes it (pending kernel" + warn "upgrade); then run: ufw allow ${rule}" + fi + done # ufw's default FORWARD policy is DROP, which also drops the traffic Docker forwards # for the self-node container's clients. Setting it to ACCEPT lets Docker's own # per-bridge FORWARD rules govern forwarding (they're specific, not blanket), which diff --git a/deploy/wgpanel b/deploy/wgpanel index bd01b7b..6ed3f0f 100755 --- a/deploy/wgpanel +++ b/deploy/wgpanel @@ -311,8 +311,32 @@ cmd_show_bootstrap_admin() { # ---- destructive ---- cmd_uninstall() { - read -rp "This stops containers and DELETES all data volumes. Type 'yes' to confirm: " c - [[ "$c" == "yes" ]] && docker compose down -v + echo "This removes WGPanel from this server completely:" + echo " - stops the panel stack and DELETES its data volumes (database, TLS certs, node CA)" + if [[ -f "$NODE_DIR/docker-compose.yml" ]]; then + echo " - stops the self-node stack and deletes its volumes (WireGuard state)" + fi + echo " - deletes ${INSTALL_DIR} (including .env and ALL backups) and ${NODE_DIR}" + echo " - removes this CLI (/usr/local/bin/wgpanel)" + read -rp "Type 'yes' to confirm: " c + if [[ "$c" != "yes" ]]; then + log "Aborted - nothing was removed." + return 0 + fi + # Tear the self-node down FIRST: its agent would otherwise keep hammering the + # just-removed control plane with re-registration attempts until reboot. + if [[ -f "$NODE_DIR/docker-compose.yml" ]]; then + log "Removing the self-node stack..." + (cd "$NODE_DIR" && docker compose down -v --remove-orphans) \ + || warn "Self-node teardown failed - finish it manually: cd ${NODE_DIR} && docker compose down -v" + fi + log "Removing the panel stack..." + docker compose down -v --remove-orphans \ + || warn "Panel teardown failed - finish it manually: cd ${INSTALL_DIR} && docker compose down -v" + cd / + rm -rf "$INSTALL_DIR" "$NODE_DIR" + rm -f /usr/local/bin/wgpanel + log "WGPanel uninstalled. Pulled images were kept - reclaim the space with: docker image prune -a" } usage() { @@ -344,7 +368,8 @@ Admin accounts: if still present in the API's log history Other: - uninstall Stop and delete all data volumes (destructive, asks for confirmation) + uninstall Remove WGPanel completely: panel + self-node stacks, volumes, + /opt/wgpanel*, and this CLI (destructive, asks for confirmation) EOF } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4b0e58c..5cf6c98 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -478,7 +478,16 @@ paths: type: object properties: token: { type: string } - expires_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time, nullable: true } + unlimited: { type: boolean } + panel_addr: + type: string + nullable: true + description: >- + The exact host:port to give install-node.sh as the control-plane + address (panel domain + NODE_AGENT_PORT). Null when no panel + domain is configured. This is NOT the panel's web/HTTPS port. + example: panel.example.com:48443 "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } @@ -951,9 +960,13 @@ paths: schema: { type: string } responses: "200": - description: wg-quick config text (Content-Disposition attachment). + description: >- + wg-quick config text (Content-Disposition attachment). Served as + `application/octet-stream` rather than `text/plain` so Android + Chrome keeps the `.conf` extension instead of renaming the + download to `.conf.txt`, which the WireGuard app can't import. content: - text/plain: + application/octet-stream: schema: { type: string } "403": description: "`account_suspended`" diff --git a/frontend/src/pages/AccountsPage.tsx b/frontend/src/pages/AccountsPage.tsx index 185416f..02b3b84 100644 --- a/frontend/src/pages/AccountsPage.tsx +++ b/frontend/src/pages/AccountsPage.tsx @@ -789,7 +789,11 @@ function AccountDetailDialog({ + {token.panel_addr && ( +
+

+ When the installer asks for the control plane address, + enter exactly this — it is the panel's node-agent port, not the web UI address: +

+
+ + {token.panel_addr} + + +
+
+ )} )}