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
72 changes: 72 additions & 0 deletions .github/workflows/build-node.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: build-node

# Publishes the production WireGuard node image (deploy/node.Dockerfile) to GHCR, so
# install-node.sh and install.sh's self-node can pull it instead of building on the
# host. The build context is the repo root because node.Dockerfile compiles the agent
# from backend/ and copies deploy/node-entrypoint.sh.
on:
push:
branches: [main]
tags: ["v*"]
paths:
- "backend/**"
- "deploy/node.Dockerfile"
- "deploy/node-entrypoint.sh"
- ".github/workflows/build-node.yml"
pull_request:
paths:
- "backend/**"
- "deploy/node.Dockerfile"
- "deploy/node-entrypoint.sh"

permissions:
contents: read
packages: write

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
- run: go vet ./...
- run: go test ./...

build-and-push:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v5
with:
# Matches NODE_IMAGE in deploy/.env.example - update both together if the org/repo changes.
images: ghcr.io/${{ github.repository_owner }}/wgpanel-node
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=tag
type=sha,format=short

- uses: docker/build-push-action@v6
with:
# Repo root - node.Dockerfile reads backend/ and deploy/node-entrypoint.sh.
context: .
file: deploy/node.Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
96 changes: 94 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,36 @@ WGPanel is a self-hosted admin panel for running your own WireGuard VPN service.

- **Multi-node by default** — create an account once and it's usable across every server in your fleet, not pinned to a single node.
- **Live monitoring** — per-account traffic charts, node CPU/RAM history, and online/offline status for every connection.
- **Service tiers** — per-account bandwidth limits (enforced on every node via tc), data quotas, expiry, and device limits with optional auto-suspend when a config is shared across too many devices.
- **Subscription URLs** — every account gets a stable link that always serves its latest config, picking the best node automatically (load/health/region-aware steering); rotate the link anytime without re-issuing keys.
- **Self-service domain & TLS** — change the panel's domain from the Settings page; Caddy provisions the certificate automatically, no restart needed.
- **Role-based admin accounts** — super admin, operator, and read-only support roles.
- **A scoped API** for bots/resellers to provision accounts programmatically (e.g. from a Telegram sales bot), kept completely separate from WireGuard/infrastructure logic.

## Screenshots

The dashboard gives an at-a-glance view of the fleet — nodes online, accounts, data transferred, and live node capacity. Light and dark themes are both built in (switchable per-user):

| Light | Dark |
| --- | --- |
| ![Dashboard, light theme](docs/screenshots/dashboard.png) | ![Dashboard, dark theme](docs/screenshots/dashboard-dark.png) |

**Accounts** — search, per-account usage with quota bars, and live connection status:

![Accounts list](docs/screenshots/accounts.png)

**Account detail** — a tabbed view for overview, devices, usage charts, and inline editing (quota, device limit, bandwidth, expiry), plus the subscription URL and per-node config/QR:

![Account detail](docs/screenshots/account-detail.png)

**Nodes** — every WireGuard server in the fleet, with status, region, endpoint, and capacity. Adding a node offers a curated list of peer subnets (already-used ranges are disabled) so you never collide with another node or a client's home LAN:

| Nodes | New node |
| --- | --- |
| ![Nodes list](docs/screenshots/nodes.png) | ![New node dialog with subnet picker](docs/screenshots/new-node.png) |

<p align="center"><img src="docs/screenshots/login.png" alt="Sign-in screen" width="420"></p>

## Requirements

- A Linux server (Ubuntu/Debian) with a public IP, for the panel itself.
Expand Down Expand Up @@ -68,12 +94,78 @@ Run `wgpanel` with no arguments for the full command list.

## Using the panel

- **Accounts** — create a WireGuard account, set a data quota/device limit/expiry, and download the `.conf` or scan the QR code from any device it has a peer on.
- **Nodes** — see live status, edit capacity/endpoint, and view CPU/RAM history for each server.
- **Accounts** — create a WireGuard account, set a data quota/device limit/bandwidth limit/expiry, and download the `.conf` or scan the QR code from any device it has a peer on. Each account's detail view also shows its subscription URL and the devices recently seen using it.
- **Nodes** — see live status, edit capacity/endpoint/region, and view CPU/RAM history for each server.
- **Dashboard** — fleet-wide stats: nodes online, accounts online, active/suspended counts, total data transferred.
- **Settings** — panel defaults (quota, device limit, node capacity) and live domain/TLS management.
- **API Keys** — issue scoped, HMAC-signed keys for bots/resellers to create and manage accounts via the API.

## For developers

Architecture notes, the full API reference, and design docs for each feature area live in [`docs/`](docs) — start with [`docs/openapi.yaml`](docs/openapi.yaml) for the API surface.

### Repo layout

| Path | What it is |
| --- | --- |
| `backend/` | Go 1.25 control plane. `cmd/api` (the panel API + node-agent server) and `cmd/agent` (the WireGuard node agent). SQL migrations in `internal/store/migrations`. |
| `frontend/` | React 19 + TypeScript + Vite + Tailwind admin SPA. |
| `deploy/` | Docker Compose, Caddy, the `install.sh` / `install-node.sh` installers, and the `wgpanel` management CLI. |
| `docs/` | Product/architecture docs and `openapi.yaml`. |

### Local development

Everything runs from `deploy/` with Docker Compose. Compose auto-merges `docker-compose.yml` (production) with `docker-compose.override.yml` (dev-only) whenever you run it from this directory, so a local `up` builds the images from source and makes the in-Docker WireGuard test node available — none of which ships to production.

```bash
cd deploy
cp .env.example .env # then set the CHANGE_ME secrets; for local use, point the
# image vars at local tags, e.g. API_IMAGE=wgpanel-api:local
# and FRONTEND_IMAGE=wgpanel-frontend:local
docker compose up -d --build # build + start db, redis, api, frontend, caddy
docker compose logs -f api # the one-time bootstrap admin password is printed here
```

Optionally run a WireGuard node in Docker (handy on macOS, where `install-node.sh` can't run natively). Generate a join token in the panel (**Nodes → New node → Join token**), put it in `.env` as `NODE_JOIN_TOKEN`, then:

```bash
docker compose --profile node up -d wg-node
```

**Working on the frontend alone** — Vite's dev server proxies `/api` to the backend (default `http://127.0.0.1:8090`, override with `VITE_BACKEND_URL`), so you get HMR against the running API stack:

```bash
cd frontend
npm ci
npm run dev # http://localhost:5173
npm run lint # oxlint
npm run build # type-check (tsc -b) + production build
```

**Working on the backend alone:**

```bash
cd backend
go test ./...
go vet ./...
gofmt -l . # must print nothing
```

There's a browser-driven smoke test that exercises the whole panel against a running stack: `node frontend/e2e-smoke.mjs` (see the file header for the env vars it takes).

### Production vs. dev Compose

- **`docker-compose.yml`** is production-only: five services (db, redis, api, frontend, caddy), pinned image digests, bounded logs, per-service memory limits, `no-new-privileges`, and healthchecks. It pulls `API_IMAGE` / `FRONTEND_IMAGE` and never builds. `install.sh` copies just this file to the server.
- **`docker-compose.override.yml`** adds the local `build:` directives and the dev `wg-node`. It lives only in the repo and is never deployed.

Per-container memory ceilings are tunable via `.env` (`DB_MEM_LIMIT`, `REDIS_MEM_LIMIT`, `API_MEM_LIMIT`, …) — see the comments in `.env.example`.

### Images & CI

Pushes to `main` build and publish three images to GHCR via `.github/workflows/build-{api,frontend,node}.yml`:

- `wgpanel-api` — the control plane (also contains the agent binary).
- `wgpanel-frontend` — the SPA served by nginx.
- `wgpanel-node` — the production WireGuard node (`deploy/node.Dockerfile`), run as a container by both installers.

Both installers deploy nodes as **production Docker containers** (bridge networking, the container NATs client traffic in its own namespace). If the published `wgpanel-node` image can't be pulled (e.g. before CI has run, or a private package), they fall back to building it from source on the host. If you fork the repo, update `API_IMAGE` / `FRONTEND_IMAGE` / `NODE_IMAGE` in `.env.example` (and make the GHCR packages public).
18 changes: 18 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Keep the build context small and free of host-only cruft. The Dockerfile does
# `COPY . .`, so without this the whole .git history, editor files, and any local
# build output would be shipped into the build layer.
.git
.gitignore
.dockerignore
Dockerfile
*.md
# Local build artifacts / binaries (the image builds its own inside the container).
/wgpanel-api
/wgpanel-agent
*.test
*.out
coverage*
# Editor / OS noise
.idea
.vscode
.DS_Store
4 changes: 3 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wgpanel-agent ./cm
# (65532:65532, distroless's fixed UID/GID) rather than being created/chowned later.
RUN mkdir -p /out/data && chown 65532:65532 /out/data

FROM gcr.io/distroless/static-debian12:nonroot
# Runtime base pinned to a digest (resolved from :nonroot) so the shipped image is
# reproducible and can't drift under an unattended CI rebuild - bump deliberately.
FROM gcr.io/distroless/static-debian12:nonroot@sha256:aef9602f8710ec12bde19d593fed1f76c708531bb7aba205110f1029786ead7b
COPY --from=build /out/wgpanel-api /wgpanel-api
COPY --from=build /out/wgpanel-agent /wgpanel-agent
COPY --from=build --chown=65532:65532 /out/data /data
Expand Down
27 changes: 26 additions & 1 deletion backend/cmd/agent/heartbeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net"
"net/http"
"sort"
"strconv"
"strings"
"time"

Expand All @@ -33,6 +34,10 @@ type heartbeatPeerTraffic struct {
ReceiveBytes int64 `json:"rx_bytes"`
TransmitBytes int64 `json:"tx_bytes"`
LastHandshake *time.Time `json:"last_handshake,omitempty"`
// Endpoint is the peer's current client source "ip:port" from the same kernel
// snapshot as the counters - the control plane's device-tracking input
// (PRD-account-management.md §6.4). Omitted when the peer has never connected.
Endpoint string `json:"endpoint,omitempty"`
}

type heartbeatRequest struct {
Expand All @@ -47,6 +52,9 @@ type heartbeatRequest struct {
type heartbeatPeer struct {
PublicKey string `json:"public_key"`
AllowedIPs []string `json:"allowed_ips"`
// BandwidthLimitMbps: per-account rate limit the control plane wants enforced on
// this peer via tc (see tc.go); nil = unshaped.
BandwidthLimitMbps *int `json:"bandwidth_limit_mbps"`
}

type heartbeatResponse struct {
Expand Down Expand Up @@ -115,6 +123,9 @@ func (s *heartbeatState) tick(ctx context.Context, cfg config, client *http.Clie
hs := p.LastHandshakeTime
t.LastHandshake = &hs
}
if p.Endpoint != nil {
t.Endpoint = p.Endpoint.String()
}
traffic = append(traffic, t)
}
}
Expand Down Expand Up @@ -145,6 +156,13 @@ func (s *heartbeatState) tick(ctx context.Context, cfg config, client *http.Clie
logger.Warn("reconcile_peers_failed", "error", err)
return
}
// Shaping failure (e.g. no tc binary on an old bare-metal install) must not hold
// the signature back: that would force reconcilePeers to reapply ReplacePeers
// every 10s - exactly the handshake-resetting behavior peerSignature exists to
// prevent. Peers stay correct, just without rate enforcement, and the warn says so.
if err := applyShaping(cfg.WGInterface, resp.Peers, logger); err != nil {
logger.Warn("apply_shaping_failed", "error", err)
}
s.lastAppliedPeerSignature = sig
}

Expand All @@ -170,7 +188,14 @@ func peerSignature(peers []heartbeatPeer) string {
for _, p := range peers {
allowedIPs := append([]string(nil), p.AllowedIPs...)
sort.Strings(allowedIPs)
entries = append(entries, p.PublicKey+"|"+strings.Join(allowedIPs, ","))
// The bandwidth limit is part of the signature so a limit change alone (same
// peers, new rate) still triggers a reconcile pass - tc shaping is applied in
// the same signature-gated step as ConfigureDevice.
limit := "-"
if p.BandwidthLimitMbps != nil {
limit = strconv.Itoa(*p.BandwidthLimitMbps)
}
entries = append(entries, p.PublicKey+"|"+strings.Join(allowedIPs, ",")+"|"+limit)
}
sort.Strings(entries)
return strings.Join(entries, "\n")
Expand Down
Loading
Loading