Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/cmd/agent/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
)

Expand Down Expand Up @@ -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("<html")) {
Comment on lines +116 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Content-Type header value can be mixed-case (e.g., Text/HTML or text/HTML). Performing a case-sensitive strings.Contains check on the raw header value might fail to detect HTML responses from some web servers. It is safer to convert the header value to lowercase before checking.

Suggested change
if resp.StatusCode == http.StatusMethodNotAllowed ||
strings.Contains(resp.Header.Get("Content-Type"), "text/html") ||
bytes.Contains(bytes.ToLower(body), []byte("<html")) {
if resp.StatusCode == http.StatusMethodNotAllowed ||
strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") ||
bytes.Contains(bytes.ToLower(body), []byte("<html")) {

return nil, fmt.Errorf("register rejected: %s - this address answers like the panel's web UI, not the node-agent API; WGPANEL_PANEL_ADDR must use the panel's NODE_AGENT_PORT (48443 by default, see the panel server's .env), not the web/HTTPS port", resp.Status)
}
return nil, fmt.Errorf("register rejected: %s: %s", resp.Status, string(body))
}

Expand Down
1 change: 1 addition & 0 deletions backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func run(logger *slog.Logger) error {
AdminACLEmail: cfg.AdminACLEmail,
BootPanelDomain: cfg.PanelDomain,
CADataDir: caDataDir,
NodeAgentPort: cfg.NodeAgentPort,
}

httpServer := &http.Server{
Expand Down
23 changes: 23 additions & 0 deletions backend/internal/httpapi/nodes.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package httpapi

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
Expand Down Expand Up @@ -206,6 +207,27 @@ type joinTokenResponse struct {
Token string `json:"token"`
ExpiresAt *string `json:"expires_at"` // null for an unlimited token - it never expires
Unlimited bool `json:"unlimited"`
// PanelAddr is the exact host:port install-node.sh must be given as the
// "control plane address" (panel domain + NODE_AGENT_PORT). Null when the
// deployment has no domain configured. Shown next to the token because
// pointing the agent at the panel's WEB port instead is the most common
// node-onboarding mistake - the agent then gets the SPA's HTML back.
PanelAddr *string `json:"panel_addr"`
}

// controlPlaneAddr assembles the host:port a node agent must dial: the panel
// domain (live setting, falling back to the boot-time PANEL_DOMAIN) plus the
// agent listener's NODE_AGENT_PORT. Nil when either half is unknown.
func (s *Server) controlPlaneAddr(ctx context.Context) *string {
host := s.BootPanelDomain
if settings, err := s.Store.GetSettings(ctx); err == nil && settings.PanelDomain != nil && *settings.PanelDomain != "" {
host = *settings.PanelDomain
}
if host == "" || s.NodeAgentPort == "" {
return nil
}
addr := host + ":" + s.NodeAgentPort
return &addr
}

type generateJoinTokenRequest struct {
Expand Down Expand Up @@ -278,6 +300,7 @@ func (s *Server) handleGenerateJoinToken(w http.ResponseWriter, r *http.Request)
Token: rawToken,
ExpiresAt: expiresAt,
Unlimited: req.Unlimited,
PanelAddr: s.controlPlaneAddr(ctx),
})
}

Expand Down
4 changes: 4 additions & 0 deletions backend/internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ type Server struct {
// caDataDir) so backup can include it and restore can replace it. Empty
// disables the CA portion of backup/restore.
CADataDir string
// NodeAgentPort is the NODE_AGENT_PORT this API's agent listener is published
// on - surfaced in the join-token response so the panel can show the exact
// control-plane address install-node.sh needs (see controlPlaneAddr).
NodeAgentPort string
}

// Routes builds the full handler tree: public routes (proxied by Caddy), the
Expand Down
6 changes: 5 additions & 1 deletion backend/internal/httpapi/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ func (s *Server) handleSubscriptionConfig(w http.ResponseWriter, r *http.Request
return
}

w.Header().Set("Content-Type", "text/plain; charset=utf-8")
// application/octet-stream, NOT text/plain: this endpoint is downloaded straight
// from mobile browsers, and Android Chrome renames text/plain attachments whose
// extension it doesn't associate with that type - "x.conf" would land in Downloads
// as "x.conf.txt", which the WireGuard app refuses to import.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", confFilename(account.Label)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(config))
Expand Down
80 changes: 73 additions & 7 deletions deploy/install-node.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,75 @@ install_docker() {
systemctl enable --now docker
}

# read_required re-prompts until the answer is non-empty. Without this, a stray
# blank line in a paste (very easy to hit when copy-pasting the join token with a
# trailing newline) silently answers the NEXT prompt, and required settings land
# empty in .env - the node then fails in ways that only surface much later.
# Pasted CRs (CRLF clipboards) are stripped too.
read_required() {
local prompt="$1" var="$2" val=""
while [[ -z "$val" ]]; do
read -rp "$prompt" val
val="${val//$'\r'/}"
done
printf -v "$var" '%s' "$val"
}

prompt_config() {
mkdir -p "$NODE_DIR"

read -rp "Control plane address (host:port, e.g. panel.example.com:48443): " PANEL_ADDR
read -rp "Join token (from admin panel -> 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://<addr>/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/tcp/${PANEL_ADDR%:*}/${PANEL_ADDR##*:}" 2>/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 '<html|<!doctype'; then
warn "${PANEL_ADDR} answers like the panel's WEB UI, not the node-agent API."
warn "Enter the panel's node-agent port instead: NODE_AGENT_PORT in the panel"
warn "server's /opt/wgpanel/.env (48443 by default) - e.g. ${PANEL_ADDR%:*}:48443."
read -rp "Use ${PANEL_ADDR} anyway? [y/N]: " CONFIRM
[[ "${CONFIRM,,}" == y* ]] && break
continue
fi
Comment on lines +101 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Debian (and by extension, some Ubuntu configurations) disables the /dev/tcp built-in redirection in bash at compile-time for security reasons. When /dev/tcp is disabled, the command bash -c ": </dev/tcp/..." will always fail, causing the script to incorrectly warn that the control plane is unreachable even when it is perfectly healthy.

Since curl is already installed and used in the subsequent check, we can use curl to check both connectivity and the response content in a single request, which is more portable, robust, and efficient.

Suggested change
if ! timeout 5 bash -c ": </dev/tcp/${PANEL_ADDR%:*}/${PANEL_ADDR##*:}" 2>/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 '<html|<!doctype'; then
warn "${PANEL_ADDR} answers like the panel's WEB UI, not the node-agent API."
warn "Enter the panel's node-agent port instead: NODE_AGENT_PORT in the panel"
warn "server's /opt/wgpanel/.env (48443 by default) - e.g. ${PANEL_ADDR%:*}:48443."
read -rp "Use ${PANEL_ADDR} anyway? [y/N]: " CONFIRM
[[ "${CONFIRM,,}" == y* ]] && break
continue
fi
local response
if ! response=$(curl -skm 5 "https://${PANEL_ADDR}/agent/register" 2>/dev/null); then
warn "Cannot reach ${PANEL_ADDR} over HTTPS. 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 echo "$response" | grep -qiE '<html|<!doctype'; then
warn "${PANEL_ADDR} answers like the panel's WEB UI, not the node-agent API."
warn "Enter the panel's node-agent port instead: NODE_AGENT_PORT in the panel"
warn "server's /opt/wgpanel/.env (48443 by default) - e.g. ${PANEL_ADDR%:*}:48443."
read -rp "Use ${PANEL_ADDR} anyway? [y/N]: " CONFIRM
[[ "${CONFIRM,,}" == y* ]] && break
continue
fi

break
done

read_required "Join token (from admin panel -> 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" <<EOF
NODE_IMAGE=${NODE_IMAGE}
Expand Down Expand Up @@ -166,7 +224,15 @@ build_image_from_source() {
setup_firewall() {
log "Configuring firewall (ufw)..."
ufw allow OpenSSH >/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,
Expand Down
16 changes: 12 additions & 4 deletions deploy/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions deploy/wgpanel
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +334 to +335

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since wgpanel is installed globally as /usr/local/bin/wgpanel, users can run the uninstall command from any directory. Running docker compose down without changing to the $INSTALL_DIR directory will fail because Docker Compose won't be able to find the docker-compose.yml file.

  (cd "$INSTALL_DIR" && 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() {
Expand Down Expand Up @@ -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
}

Expand Down
19 changes: 16 additions & 3 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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`"
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,11 @@ function AccountDetailDialog({
<Button
variant="secondary"
onClick={() => {
const blob = new Blob([configText], { type: 'text/plain' })
// application/octet-stream, NOT text/plain: Android Chrome renames
// text/plain downloads whose extension it doesn't associate with
// that type, so "x.conf" lands as "x.conf.txt" - which the WireGuard
// app then refuses to import.
const blob = new Blob([configText], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/pages/NodesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ interface JoinToken {
token: string
expires_at: string | null
unlimited: boolean
// The exact host:port to give install-node.sh as the control-plane address
// (panel domain + NODE_AGENT_PORT) - null if no panel domain is configured.
panel_addr: string | null
}

interface MetricsSample {
Expand Down Expand Up @@ -317,6 +320,30 @@ function JoinTokenDialog({
<Copy className="h-4 w-4" />
Copy token
</Button>
{token.panel_addr && (
<div className="border-t border-edge pt-3">
<p className="text-sm leading-relaxed text-muted">
When the installer asks for the <span className="font-medium text-fg">control plane address</span>,
enter exactly this — it is the panel's node-agent port, <em>not</em> the web UI address:
</p>
<div className="mt-2 flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg border border-edge bg-inset px-3 py-2 font-mono text-xs leading-5 text-fg">
{token.panel_addr}
</code>
<Button
variant="secondary"
size="icon"
title="Copy control plane address"
onClick={() => {
navigator.clipboard.writeText(token.panel_addr!)
push('success', 'Control plane address copied')
}}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</div>
)}
</Dialog>
Expand Down
Loading