Compare commits
No commits in common. "e240f071d527bbf0216ff63969e290d8547eecf4" and "73c96cab56c1e38d7a2e01fd6771cde2af19fe49" have entirely different histories.
e240f071d5
...
73c96cab56
21 changed files with 221 additions and 1461 deletions
115
.env.example
115
.env.example
|
|
@ -1,96 +1,49 @@
|
||||||
# Filling this file in by hand? Run `ops/deploy.sh` instead — it prompts
|
# Which cert/tenancy strategy to run: "single" or "shared".
|
||||||
# for the values below, generates the tokens, and renders
|
# See traefik/static/traefik.single.yml vs traefik.shared.yml.
|
||||||
# traefik/dynamic/control-plane.yml from its tracked template. Read the
|
|
||||||
# README's Prerequisites checklist first either way.
|
|
||||||
|
|
||||||
# Tenancy switch only — "single" (one operator, the common case) or
|
|
||||||
# "shared" (multi-tenant, out of scope for most deployments). Has no
|
|
||||||
# effect on ports, certs, or infrastructure — that's all your own
|
|
||||||
# Traefik/wg-easy now, configured below.
|
|
||||||
GATEWAY_MODE=single
|
GATEWAY_MODE=single
|
||||||
|
|
||||||
# YOUR wg-easy's admin API — reachable at whatever host:port makes sense
|
# The hostname (or IP, for a first local smoke test) wg-easy advertises to
|
||||||
# for your setup. If it's a sibling container that joined the same docker
|
# peers as the WireGuard endpoint. Must be reachable on udp/51820.
|
||||||
# network as this control-plane (see GATEWAY_TRAEFIK_NETWORK below), this
|
GATEWAY_PUBLIC_HOST=vpn.example.com
|
||||||
# is usually http://<its-container-name>:51821.
|
|
||||||
WG_EASY_URL=http://wg-easy:51821
|
|
||||||
|
|
||||||
# "14" or "15" — these are genuinely different, incompatible wg-easy APIs
|
# bcrypt hash of the wg-easy admin password. Generate with:
|
||||||
# (see control_plane/wgeasy.py vs wgeasy_v15.py's module docstrings, each
|
# docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \
|
||||||
# independently verified against a real running container). Check your
|
# "console.log(require('bcryptjs').hashSync(process.argv[1], 10))" 'your-password'
|
||||||
# wg-easy image's actual tag.
|
#
|
||||||
WG_EASY_API_VERSION=14
|
# IMPORTANT: bcrypt hashes contain literal `$` characters (e.g.
|
||||||
|
# "$2a$10$..."), and Docker Compose's own .env-file parser treats `$word`
|
||||||
|
# as a variable reference to substitute — confirmed by actually hitting
|
||||||
|
# this: an unescaped hash silently got truncated to "$2a$10" with
|
||||||
|
# everything after dropped. Every `$` in the value below MUST be doubled
|
||||||
|
# as `$$`, e.g. WG_EASY_PASSWORD_HASH=$$2a$$10$$abc123...
|
||||||
|
WG_EASY_PASSWORD_HASH=
|
||||||
|
|
||||||
# v15 only: the admin user's username. v14 has no username concept — see
|
# PLAINTEXT password matching the hash above. wg-easy's admin API (this
|
||||||
# WG_EASY_ADMIN_PASSWORD below, used alone in that case.
|
# specific pinned image, ghcr.io/wg-easy/wg-easy:14 — confirmed by reading
|
||||||
WG_EASY_ADMIN_USERNAME=
|
# its actual source, which turned out to differ from what wg-easy's current
|
||||||
|
# docs describe) has no login call or session cookie at all: every request
|
||||||
# PLAINTEXT password for wg-easy's admin API — never a hash, on either
|
# just carries this password as a plain `Authorization` header, checked
|
||||||
# version. v14 sends this as a bare `Authorization` header (no username,
|
# with bcrypt against WG_EASY_PASSWORD_HASH. No username concept exists.
|
||||||
# no session — confirmed by reading the actual pinned image's source,
|
|
||||||
# which differs from what wg-easy's current docs describe). v15 sends it
|
|
||||||
# as HTTP Basic auth alongside WG_EASY_ADMIN_USERNAME.
|
|
||||||
WG_EASY_ADMIN_PASSWORD=
|
WG_EASY_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# v15 only: restricts new WireGuard clients to this subnet instead of
|
# GATEWAY_MODE=single only: the one box token a single-tenant deployment
|
||||||
# v15's full-tunnel default (0.0.0.0/0, ::/0) — see wgeasy_v15.py's
|
# accepts at /v1/boxes/register, skipping full account/registration-token
|
||||||
# restrict_default_allowed_ips(), applied on every control-plane startup.
|
# issuance. Generate with: openssl rand -hex 32
|
||||||
# Should match your wg-easy's actual WireGuard peer subnet.
|
GATEWAY_BOX_TOKEN=
|
||||||
GATEWAY_WG_ALLOWED_IPS=10.8.0.0/24
|
|
||||||
|
|
||||||
# Host directory YOUR Traefik's file provider already watches (or a new
|
|
||||||
# one you add as an *additional* providers.file directory in its static
|
|
||||||
# config — additive, doesn't touch your existing dynamic config). This
|
|
||||||
# gets bind-mounted into the control-plane container.
|
|
||||||
GATEWAY_DYNAMIC_DIR_HOST=./dynamic
|
|
||||||
|
|
||||||
# Your Traefik's certResolver name — mirrored into every route
|
|
||||||
# furtka-gateway publishes. Blank means no explicit resolver (`tls: {}`),
|
|
||||||
# trusting your Traefik to apply its own default cert automatically (e.g.
|
|
||||||
# a wildcard defaultGeneratedCert). Set this if your Traefik doesn't have
|
|
||||||
# a default and needs an explicit resolver named per-route.
|
|
||||||
GATEWAY_CERT_RESOLVER=
|
|
||||||
|
|
||||||
# Hostname a Furtka box actually reaches the control-plane API on — needs
|
# Hostname a Furtka box actually reaches the control-plane API on — needs
|
||||||
# its own A/AAAA record pointing at wherever your Traefik is reachable.
|
# its own A/AAAA record pointing at this VPS. Not read by docker-compose
|
||||||
# Not read by docker-compose itself; it's what ops/deploy.sh substitutes
|
# itself; it's a reminder of what to substitute into
|
||||||
# into traefik/dynamic/control-plane.yml.
|
# traefik/dynamic/control-plane.yml (copied from the .example file — see
|
||||||
|
# that file for why this route has to exist at all).
|
||||||
GATEWAY_CONTROL_PLANE_HOST=gw.example.com
|
GATEWAY_CONTROL_PLANE_HOST=gw.example.com
|
||||||
|
|
||||||
# Where the control-plane's own API is bound (docker-compose.yaml's port
|
|
||||||
# publish) and where your Traefik reaches it, respectively. Same value
|
|
||||||
# for the recommended same-docker-network setup (ops/deploy.sh sets both
|
|
||||||
# to furtka-gateway-control-plane:8090 automatically, with the bind
|
|
||||||
# staying loopback-only — see docker-compose.yaml's comment on why this
|
|
||||||
# is more robust than the compose *service* name "control-plane" once the
|
|
||||||
# network is joined after the fact via `docker network connect`).
|
|
||||||
# Different values only for a separate-host setup, where
|
|
||||||
# GATEWAY_CONTROL_PLANE_BIND must be widened beyond 127.0.0.1 — put real
|
|
||||||
# access control around that bind address, this file carries a WireGuard
|
|
||||||
# private key in its registration responses.
|
|
||||||
GATEWAY_CONTROL_PLANE_BIND=127.0.0.1:8090
|
|
||||||
GATEWAY_CONTROL_PLANE_TARGET=furtka-gateway-control-plane:8090
|
|
||||||
|
|
||||||
# Same-docker-network setup only (the recommended path — see the README's
|
|
||||||
# Prerequisites checklist): your Traefik's docker network name
|
|
||||||
# (`docker network ls` / `docker inspect <your-traefik-container>`).
|
|
||||||
# ops/deploy.sh runs `docker network connect` with this after bringing the
|
|
||||||
# stack up. Leave blank for a separate-host setup.
|
|
||||||
GATEWAY_TRAEFIK_NETWORK=
|
|
||||||
|
|
||||||
# The one box token a single-tenant deployment accepts at
|
|
||||||
# /v1/boxes/register, skipping full account/registration-token issuance.
|
|
||||||
# Generate with: openssl rand -hex 32
|
|
||||||
GATEWAY_BOX_TOKEN=
|
|
||||||
|
|
||||||
# Bearer token required to create accounts via POST /v1/accounts.
|
# Bearer token required to create accounts via POST /v1/accounts.
|
||||||
# Only meaningful once account endpoints exist (Phase 2+); harmless to set
|
# Only meaningful once account endpoints exist (Phase 2+); harmless to set
|
||||||
# now. Generate with: openssl rand -hex 32
|
# now. Generate with: openssl rand -hex 32
|
||||||
GATEWAY_ADMIN_TOKEN=
|
GATEWAY_ADMIN_TOKEN=
|
||||||
|
|
||||||
# Optional: override the control-plane image `docker stack deploy` pulls
|
# GATEWAY_MODE=shared only: base domain subdomains are issued under, and the
|
||||||
# (Swarm ignores docker-compose.yaml's `build:` key — see README's Swarm
|
# DNS provider Traefik's DNS-01 challenge should use for the wildcard cert.
|
||||||
# caveats). Defaults to the `latest` tag CI publishes on every push to
|
# See traefik/static/traefik.shared.yml.
|
||||||
# master; pin to a specific commit-sha tag for a reproducible deploy.
|
GATEWAY_BASE_DOMAIN=boxes.example.com
|
||||||
# Not read by plain `docker compose up`, which always builds locally.
|
|
||||||
GATEWAY_CONTROL_PLANE_IMAGE=
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
name: Build control-plane image
|
|
||||||
|
|
||||||
# Publishes control_plane as a versioned image to this Forgejo instance's
|
|
||||||
# own container registry, so `docker stack deploy` (which never reads
|
|
||||||
# `build:`, see README's Swarm caveats) doesn't need an operator to build
|
|
||||||
# and tag it by hand first.
|
|
||||||
#
|
|
||||||
# Self-hosted runner, plain `docker build`/`docker push` rather than the
|
|
||||||
# docker/build-push-action marketplace action: unconfirmed whether that
|
|
||||||
# action is mirrored on this instance's action runner (furtka's own
|
|
||||||
# ci.yml hit exactly this gap with lycheeverse/lychee-action and had to
|
|
||||||
# fall back to a fully-qualified github.com URL), and raw docker commands
|
|
||||||
# sidestep the question entirely — same reasoning as release.yml/
|
|
||||||
# build-iso.yml using plain shell for their heavy lifting.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
tags: ['**']
|
|
||||||
paths:
|
|
||||||
- 'control_plane/**'
|
|
||||||
- '.forgejo/workflows/build-image.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: build-image-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-image:
|
|
||||||
runs-on: self-hosted
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to this instance's container registry
|
|
||||||
# GITHUB_TOKEN here is Forgejo's own auto-issued Actions token,
|
|
||||||
# scoped to this repo with package read/write — not a GitHub
|
|
||||||
# token. Standard Forgejo/Gitea Actions pattern.
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login "${{ vars.FORGEJO_HOST || 'forgejo.sourcegate.online' }}" \
|
|
||||||
-u "${{ github.actor }}" --password-stdin
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
run: |
|
|
||||||
docker build -t control-plane:build ./control_plane
|
|
||||||
|
|
||||||
- name: Tag and push
|
|
||||||
env:
|
|
||||||
REGISTRY: ${{ vars.FORGEJO_HOST || 'forgejo.sourcegate.online' }}
|
|
||||||
IMAGE: furtka-gateway-control-plane
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
sha_tag="${GITHUB_SHA::12}"
|
|
||||||
docker tag control-plane:build "$REGISTRY/${{ github.repository_owner }}/$IMAGE:$sha_tag"
|
|
||||||
docker push "$REGISTRY/${{ github.repository_owner }}/$IMAGE:$sha_tag"
|
|
||||||
|
|
||||||
# `latest` tracks master; a version-tag push (e.g. from a future
|
|
||||||
# CalVer release process, see furtka/RELEASING.md for the
|
|
||||||
# pattern this would mirror) additionally publishes under that
|
|
||||||
# tag's own name.
|
|
||||||
if [ "${{ github.ref_type }}" = "branch" ]; then
|
|
||||||
docker tag control-plane:build "$REGISTRY/${{ github.repository_owner }}/$IMAGE:latest"
|
|
||||||
docker push "$REGISTRY/${{ github.repository_owner }}/$IMAGE:latest"
|
|
||||||
else
|
|
||||||
docker tag control-plane:build "$REGISTRY/${{ github.repository_owner }}/$IMAGE:${{ github.ref_name }}"
|
|
||||||
docker push "$REGISTRY/${{ github.repository_owner }}/$IMAGE:${{ github.ref_name }}"
|
|
||||||
fi
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,4 +9,3 @@ acme.json
|
||||||
traefik/dynamic/route-*.yml
|
traefik/dynamic/route-*.yml
|
||||||
traefik/dynamic/control-plane.yml
|
traefik/dynamic/control-plane.yml
|
||||||
traefik/dynamic/default-cert.yml
|
traefik/dynamic/default-cert.yml
|
||||||
traefik/static/traefik.active.yml
|
|
||||||
|
|
|
||||||
145
README.md
145
README.md
|
|
@ -4,34 +4,21 @@ WireGuard (wg-easy) + Traefik reverse-proxy that lets a Furtka box — typically
|
||||||
sitting behind NAT/CGNAT with no public IP — expose individual apps to the
|
sitting behind NAT/CGNAT with no public IP — expose individual apps to the
|
||||||
internet under a real domain with automatic TLS.
|
internet under a real domain with automatic TLS.
|
||||||
|
|
||||||
A Furtka box becomes a WireGuard peer of this gateway. Your Traefik
|
A Furtka box becomes a WireGuard peer of this gateway. The gateway's Traefik
|
||||||
reverse-proxies public HTTPS traffic over that tunnel to whichever apps the
|
reverse-proxies public HTTPS traffic over that tunnel to whichever apps the
|
||||||
box owner has explicitly published. Only apps whose Furtka `manifest.json`
|
box owner has explicitly published. Only apps whose Furtka `manifest.json`
|
||||||
declares `internet.viable: true` can be published at all, and publishing is
|
declares `internet.viable: true` can be published at all, and publishing is
|
||||||
always an explicit per-app opt-in on the box side — the gateway never exposes
|
always an explicit per-app opt-in on the box side — the gateway never exposes
|
||||||
anything on its own.
|
anything on its own.
|
||||||
|
|
||||||
## What this actually is
|
Supports two deployment modes from the same codebase:
|
||||||
|
|
||||||
The control-plane (`control_plane/`, one small stdlib-only Docker image) is
|
- **`single`** — the common case: one person/operator runs this on their own
|
||||||
all this repo ships. It does two things: talks to wg-easy's admin API to
|
VPS with their own domain's A/AAAA records pointed at it, for their own
|
||||||
provision WireGuard peers for boxes, and writes plain Traefik file-provider
|
Furtka box(es).
|
||||||
YAML for the routes those boxes publish. It does not own or bundle a Traefik
|
- **`shared`** — one larger, multi-tenant instance (operated by the Furtka
|
||||||
or wg-easy of its own — **you plug it into a Traefik and wg-easy you already
|
project) for people who don't want to run their own. Same schema, same
|
||||||
run** (a very common self-hosting pair), the same way you'd plug any other
|
code; `single` is just the one-account case.
|
||||||
self-hosted app into your existing reverse proxy. This also means your
|
|
||||||
Traefik's own cert resolver (whatever you already use) just works for these
|
|
||||||
new routes too — furtka-gateway doesn't set up its own ACME/DNS story at all.
|
|
||||||
|
|
||||||
Don't already run Traefik or wg-easy? See
|
|
||||||
[`examples/starter-stack/`](examples/starter-stack/) — a zero-infra on-ramp
|
|
||||||
that bundles both.
|
|
||||||
|
|
||||||
`GATEWAY_MODE` is purely a tenancy switch: `single` (one operator, the
|
|
||||||
common case) or `shared` (one larger, multi-tenant instance — same schema,
|
|
||||||
same code, out of scope for the walkthrough below). It has no effect on
|
|
||||||
ports, certs, or which Traefik/wg-easy you use — that's all just your own
|
|
||||||
infrastructure now.
|
|
||||||
|
|
||||||
Status: **Phase 2** — the control plane (accounts/boxes/routes, wg-easy
|
Status: **Phase 2** — the control plane (accounts/boxes/routes, wg-easy
|
||||||
peer provisioning, Traefik dynamic-config generation) is built and tested
|
peer provisioning, Traefik dynamic-config generation) is built and tested
|
||||||
|
|
@ -39,91 +26,69 @@ for single-tenant mode. The Furtka-core side (the box-facing "connect to a
|
||||||
gateway" flow and per-app "expose to internet" toggle) is still just a
|
gateway" flow and per-app "expose to internet" toggle) is still just a
|
||||||
plan, not yet implemented — see the plan doc for the phased delivery.
|
plan, not yet implemented — see the plan doc for the phased delivery.
|
||||||
|
|
||||||
## Prerequisites checklist
|
## Deploying on a VPS
|
||||||
|
|
||||||
Read this before running `ops/deploy.sh` — a couple of these can't be
|
1. Get a VPS with a static public IP, point a domain's A/AAAA record at it
|
||||||
verified from the script itself, and a deploy that skips them fails in
|
(e.g. `example.com`), and open `80/tcp`, `443/tcp`, `51820/udp` in
|
||||||
confusing ways later.
|
whatever firewall/security group sits in front of it.
|
||||||
|
2. `cp .env.example .env` and fill it in — in particular `GATEWAY_PUBLIC_HOST`
|
||||||
- **A running Traefik**, with a file-provider directory you can point
|
(the WireGuard endpoint hostname), a wg-easy admin password
|
||||||
`GATEWAY_DYNAMIC_DIR_HOST` at (either one it already watches, or a new
|
(`WG_EASY_PASSWORD_HASH` + the matching plaintext `WG_EASY_ADMIN_PASSWORD`),
|
||||||
one you add as an *additional* `providers.file` directory in its static
|
`GATEWAY_BOX_TOKEN` (`openssl rand -hex 32`), and
|
||||||
config — additive, doesn't touch your existing dynamic config).
|
`GATEWAY_CONTROL_PLANE_HOST` (its own A/AAAA record — see step 5).
|
||||||
- **A running wg-easy.** Know its version — `ghcr.io/wg-easy/wg-easy:14`
|
**Double every `$` in `WG_EASY_PASSWORD_HASH` as `$$`** — see that
|
||||||
and `:15` are confirmed supported (`control_plane/wgeasy.py` and
|
variable's comment in `.env.example` for why; getting this wrong
|
||||||
`wgeasy_v15.py` respectively — these are genuinely different,
|
silently truncates the hash instead of erroring, confirmed by hitting
|
||||||
incompatible APIs, not just version numbers; each module's own docstring
|
it directly.
|
||||||
documents exactly what was verified, live, against a real container).
|
3. Run `sudo ops/host-sysctls.sh` on the VPS *before* bringing the stack up.
|
||||||
Anything else is unverified.
|
wg-easy needs `net.ipv4.ip_forward=1` (so the host actually routes
|
||||||
- **Traefik must have an actual routed network path to wg-easy's WireGuard
|
between the docker bridge and the WireGuard interface — see the
|
||||||
peer subnet** (default `10.8.0.0/24`). This is the one thing that can't
|
`network_mode: host` comment on the `wg-easy` service) and
|
||||||
be automated or checked from `ops/deploy.sh` — it depends entirely on
|
`net.ipv4.conf.all.src_valid_mark`, and — confirmed by actually trying
|
||||||
how you've deployed the two. A plain bridge-networked wg-easy container
|
it, not assumed — Docker's own `sysctls:` key can't set either one once
|
||||||
(Docker's default) will **not** work: `wg0`'s route only exists inside
|
a service is host-networked; runc refuses to even start the container.
|
||||||
wg-easy's own network namespace unless wg-easy runs with
|
4. Bring the stack up: `docker compose up -d` (or `docker stack deploy` under
|
||||||
`network_mode: host`, a macvlan, or explicit host routing that bridges
|
Swarm — see the caveats below, `build:` doesn't work there yet).
|
||||||
its WireGuard interface out to wherever Traefik is. Verify this
|
5. Copy `traefik/dynamic/control-plane.yml.example` to
|
||||||
yourself before running `ops/deploy.sh`: register a test box (or use
|
`traefik/dynamic/control-plane.yml`, substituting your real
|
||||||
any existing WireGuard peer's address), then from Traefik's container,
|
`GATEWAY_CONTROL_PLANE_HOST`. Without this, the registration API is only
|
||||||
`wget -qO- http://10.8.0.x:<some-port>` (or `ping 10.8.0.x`) should
|
reachable at `127.0.0.1:8090` on the VPS itself — a remote Furtka box has
|
||||||
succeed. If it doesn't, fix your wg-easy/Traefik network setup first —
|
no way to reach it, and the call needs TLS anyway since it carries a
|
||||||
nothing in this repo can route around it.
|
WireGuard private key (see that file's comments, and
|
||||||
- **How Traefik reaches the control-plane container itself.** The
|
`control_plane/wgeasy.py`'s module docstring, for why).
|
||||||
recommended path is joining your Traefik's own docker network (
|
6. Run `sudo ops/firewall.sh` on the VPS. wg-easy's admin API is no longer
|
||||||
`ops/deploy.sh` does this for you via `docker network connect` after
|
kept off the public interface by Docker the way every other service
|
||||||
`docker compose up`); a separate-host setup is also supported but needs
|
here is (a direct consequence of `network_mode: host` — see step 3).
|
||||||
its own access control — see the script's prompts.
|
This script closes that with an iptables rule. **Do this before
|
||||||
|
pointing DNS at the box for real** — read the script's own caveats
|
||||||
## Deploying
|
first, it hasn't been validated against a real multi-interface VPS yet.
|
||||||
|
7. `curl http://127.0.0.1:8090/healthz` from the VPS to confirm the stack
|
||||||
**`ops/deploy.sh`** prompts for all of the above, generates
|
is actually up.
|
||||||
`GATEWAY_BOX_TOKEN`/`GATEWAY_ADMIN_TOKEN`, writes `.env`, renders
|
|
||||||
`traefik/dynamic/control-plane.yml` from its tracked template, brings the
|
|
||||||
control-plane up, joins your Traefik's network (if that's your choice), and
|
|
||||||
health-checks it. Safe to re-run — an existing `.env`'s values become the
|
|
||||||
defaults for each prompt.
|
|
||||||
|
|
||||||
1. Work through the Prerequisites checklist above.
|
|
||||||
2. Run `ops/deploy.sh` and answer its prompts.
|
|
||||||
3. Add `GATEWAY_DYNAMIC_DIR_HOST` (whatever path you gave the script) as a
|
|
||||||
`providers.file` directory on your Traefik, if it wasn't already
|
|
||||||
watching it.
|
|
||||||
4. Point DNS: an A/AAAA record for `GATEWAY_CONTROL_PLANE_HOST` at wherever
|
|
||||||
your Traefik is reachable.
|
|
||||||
|
|
||||||
Run `ops/deploy.sh --render-only` to just (re)write `.env` and the rendered
|
|
||||||
`control-plane.yml` without touching anything live — useful to inspect what
|
|
||||||
would be generated, or after hand-editing `.env`.
|
|
||||||
|
|
||||||
### Docker Swarm caveats
|
### Docker Swarm caveats
|
||||||
|
|
||||||
If deploying via `docker stack deploy` instead of `docker compose up`:
|
If deploying via `docker stack deploy` instead of `docker compose up`:
|
||||||
- `docker-compose.yaml`'s `build: ./control_plane` doesn't work under
|
- `docker-compose.yaml`'s `build: ./control_plane` doesn't work under
|
||||||
Swarm at all — it only ever schedules pre-built, tagged images. Swarm
|
Swarm at all — it only ever schedules pre-built, tagged images. Build
|
||||||
falls back to the `image:` key on the same service instead, which
|
and tag the image yourself first (`docker build -t
|
||||||
`.forgejo/workflows/build-image.yml` keeps published to this instance's
|
furtka-gateway-control-plane:local ./control_plane`) and swap `build:`
|
||||||
container registry on every push to `master` (tagged `latest` plus the
|
for `image: furtka-gateway-control-plane:local` until this is packaged
|
||||||
short commit sha) — no manual build/tag step needed. Set
|
as a versioned image in CI (the planned long-term fix).
|
||||||
`GATEWAY_CONTROL_PLANE_IMAGE` in `.env` first if you want a specific
|
|
||||||
commit-sha tag rather than whatever `latest` currently points to.
|
|
||||||
- `docker stack deploy` does not read a `.env` file next to the compose
|
- `docker stack deploy` does not read a `.env` file next to the compose
|
||||||
file the way `docker compose up` does — `set -a; source .env; set +a`
|
file the way `docker compose up` does — `set -a; source .env; set +a`
|
||||||
first, or the `${VAR}` substitutions come out empty.
|
first, or the `${VAR}` substitutions come out empty.
|
||||||
- `docker network connect` (what `ops/deploy.sh` uses to join your
|
|
||||||
Traefik's network) is a plain compose/Docker concept, not
|
|
||||||
Swarm-specific — under Swarm, attach the service to your Traefik's
|
|
||||||
network directly in the stack file's own `networks:` section instead.
|
|
||||||
- `depends_on` is silently ignored by Swarm — harmless here, since
|
- `depends_on` is silently ignored by Swarm — harmless here, since
|
||||||
`control-plane` only talks to wg-easy/Traefik lazily on the first real
|
`control_plane` only talks to wg-easy/Traefik lazily on the first real
|
||||||
request, not at startup.
|
request, not at startup.
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# fill in WG_EASY_URL etc. for whatever Traefik/wg-easy you're testing
|
# GATEWAY_PUBLIC_HOST can be a LAN-reachable IP for a first smoke test.
|
||||||
# against locally, or use examples/starter-stack/ for a fully bundled
|
|
||||||
# local smoke test.
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
curl http://127.0.0.1:8090/healthz
|
curl http://127.0.0.1:8090/healthz
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ops/firewall.sh` and the `control-plane.yml` Traefik route are about
|
||||||
|
reaching this gateway from the real internet — skip both for local dev.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from http.server import BaseHTTPRequestHandler
|
||||||
|
|
||||||
from control_plane import boxes, routes
|
from control_plane import boxes, routes
|
||||||
from control_plane.db import Database, row_to_dict
|
from control_plane.db import Database, row_to_dict
|
||||||
from control_plane.wgeasy import WgEasyError, WgEasyProtocol
|
from control_plane.wgeasy import WgEasyClient, WgEasyError
|
||||||
|
|
||||||
_BOX_ACTION_RE = re.compile(r"^/v1/boxes/([^/]+)/(heartbeat|rotate-token|deregister)$")
|
_BOX_ACTION_RE = re.compile(r"^/v1/boxes/([^/]+)/(heartbeat|rotate-token|deregister)$")
|
||||||
_ROUTE_ID_RE = re.compile(r"^/v1/routes/([^/]+)$")
|
_ROUTE_ID_RE = re.compile(r"^/v1/routes/([^/]+)$")
|
||||||
|
|
@ -32,7 +32,7 @@ _ROUTE_ID_RE = re.compile(r"^/v1/routes/([^/]+)$")
|
||||||
@dataclass
|
@dataclass
|
||||||
class Context:
|
class Context:
|
||||||
db: Database
|
db: Database
|
||||||
wgeasy: WgEasyProtocol
|
wgeasy: WgEasyClient
|
||||||
mode: str
|
mode: str
|
||||||
cert_resolver: str | None
|
cert_resolver: str | None
|
||||||
single_account_id: str | None
|
single_account_id: str | None
|
||||||
|
|
|
||||||
|
|
@ -6,37 +6,19 @@ from control_plane import accounts
|
||||||
from control_plane.api import Context, Handler
|
from control_plane.api import Context, Handler
|
||||||
from control_plane.db import Database
|
from control_plane.db import Database
|
||||||
from control_plane.wgeasy import WgEasyClient
|
from control_plane.wgeasy import WgEasyClient
|
||||||
from control_plane.wgeasy_v15 import WgEasyV15Client
|
|
||||||
|
|
||||||
DEFAULT_BOX_LIMIT = 20
|
DEFAULT_BOX_LIMIT = 20
|
||||||
DEFAULT_ROUTE_LIMIT_PER_BOX = 20
|
DEFAULT_ROUTE_LIMIT_PER_BOX = 20
|
||||||
DEFAULT_WG_ALLOWED_IPS = "10.8.0.0/24"
|
|
||||||
|
|
||||||
|
|
||||||
def build_context(db: Database | None = None) -> Context:
|
def build_context(db: Database | None = None) -> Context:
|
||||||
mode = os.environ.get("GATEWAY_MODE", "single")
|
mode = os.environ.get("GATEWAY_MODE", "single")
|
||||||
db = db or Database()
|
db = db or Database()
|
||||||
|
|
||||||
wg_easy_url = os.environ.get("WG_EASY_URL", "http://host.docker.internal:51821")
|
wgeasy = WgEasyClient(
|
||||||
wg_easy_password = os.environ.get("WG_EASY_ADMIN_PASSWORD", "")
|
base_url=os.environ.get("WG_EASY_URL", "http://host.docker.internal:51821"),
|
||||||
if os.environ.get("WG_EASY_API_VERSION", "14") == "15":
|
admin_password=os.environ.get("WG_EASY_ADMIN_PASSWORD", ""),
|
||||||
wgeasy = WgEasyV15Client(
|
|
||||||
base_url=wg_easy_url,
|
|
||||||
username=os.environ.get("WG_EASY_ADMIN_USERNAME", ""),
|
|
||||||
password=wg_easy_password,
|
|
||||||
)
|
)
|
||||||
# v15 defaults new clients to full-tunnel AllowedIPs and has no
|
|
||||||
# equivalent to v14's WG_ALLOWED_IPS container env var for an
|
|
||||||
# already-initialized instance (relevant for a BYO wg-easy that
|
|
||||||
# existed before this control-plane ever connected to it) — see
|
|
||||||
# wgeasy_v15.py's docstring. Enforced on every startup, not just
|
|
||||||
# once, since a BYO instance's config isn't otherwise this
|
|
||||||
# process's to assume.
|
|
||||||
wgeasy.restrict_default_allowed_ips(
|
|
||||||
[os.environ.get("GATEWAY_WG_ALLOWED_IPS", DEFAULT_WG_ALLOWED_IPS)]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
wgeasy = WgEasyClient(base_url=wg_easy_url, admin_password=wg_easy_password)
|
|
||||||
|
|
||||||
single_account_id = None
|
single_account_id = None
|
||||||
if mode == "single":
|
if mode == "single":
|
||||||
|
|
@ -44,12 +26,9 @@ def build_context(db: Database | None = None) -> Context:
|
||||||
db, DEFAULT_BOX_LIMIT, DEFAULT_ROUTE_LIMIT_PER_BOX
|
db, DEFAULT_BOX_LIMIT, DEFAULT_ROUTE_LIMIT_PER_BOX
|
||||||
)
|
)
|
||||||
|
|
||||||
# Independent of GATEWAY_MODE — mirrors whatever certResolver name (if
|
# Shared mode uses one wildcard defaultGeneratedCert for every router
|
||||||
# any) the operator's own Traefik already has configured. Unset means
|
# (see traefik.shared.yml) rather than a per-router certResolver.
|
||||||
# no explicit resolver (`tls: {}` — see traefikconf.py), trusting that
|
cert_resolver = "le" if mode == "single" else None
|
||||||
# Traefik's own default TLS applies (e.g. shared mode's wildcard
|
|
||||||
# defaultGeneratedCert in traefik.shared.yml).
|
|
||||||
cert_resolver = os.environ.get("GATEWAY_CERT_RESOLVER") or None
|
|
||||||
|
|
||||||
return Context(
|
return Context(
|
||||||
db=db,
|
db=db,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from datetime import UTC, datetime
|
||||||
from control_plane import routes, tokens
|
from control_plane import routes, tokens
|
||||||
from control_plane.db import Database
|
from control_plane.db import Database
|
||||||
from control_plane.passwd import hash_password, verify_password
|
from control_plane.passwd import hash_password, verify_password
|
||||||
from control_plane.wgeasy import WgEasyProtocol
|
from control_plane.wgeasy import WgEasyClient
|
||||||
|
|
||||||
|
|
||||||
class BoxLimitExceeded(Exception):
|
class BoxLimitExceeded(Exception):
|
||||||
|
|
@ -23,7 +23,7 @@ def _box_count(db: Database, account_id: str) -> int:
|
||||||
|
|
||||||
def register_box(
|
def register_box(
|
||||||
db: Database,
|
db: Database,
|
||||||
wgeasy: WgEasyProtocol,
|
wgeasy: WgEasyClient,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
box_name: str,
|
box_name: str,
|
||||||
box_limit: int,
|
box_limit: int,
|
||||||
|
|
@ -71,11 +71,6 @@ def register_box(
|
||||||
"server_public_key": peer["server_public_key"],
|
"server_public_key": peer["server_public_key"],
|
||||||
"endpoint": peer["endpoint"],
|
"endpoint": peer["endpoint"],
|
||||||
"allowed_ips": peer["allowed_ips"],
|
"allowed_ips": peer["allowed_ips"],
|
||||||
# Only present for wg-easy v15 (see wgeasy_v15.py) — v15
|
|
||||||
# generates a preshared key per client by default and the
|
|
||||||
# server enforces it even if the client's own conf omits it,
|
|
||||||
# so this must reach the box or the handshake fails silently.
|
|
||||||
"preshared_key": peer.get("preshared_key"),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +107,7 @@ def rotate_box_token(db: Database, box_id: str) -> str | None:
|
||||||
return box_token
|
return box_token
|
||||||
|
|
||||||
|
|
||||||
def deregister_box(db: Database, wgeasy: WgEasyProtocol, box_id: str) -> bool:
|
def deregister_box(db: Database, wgeasy: WgEasyClient, box_id: str) -> bool:
|
||||||
row = db.query_one("SELECT wg_peer_id FROM boxes WHERE id = ?", (box_id,))
|
row = db.query_one("SELECT wg_peer_id FROM boxes WHERE id = ?", (box_id,))
|
||||||
if row is None:
|
if row is None:
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -39,24 +39,12 @@ import json
|
||||||
import re
|
import re
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class WgEasyError(Exception):
|
class WgEasyError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class WgEasyProtocol(Protocol):
|
|
||||||
"""Shape both WgEasyClient (v14) and WgEasyV15Client (v15, see
|
|
||||||
wgeasy_v15.py) satisfy structurally — the two images' actual REST APIs
|
|
||||||
are different enough (auth model, route shapes) to need separate
|
|
||||||
clients, but callers (boxes.py, api.py's Context) only ever need these
|
|
||||||
two operations."""
|
|
||||||
|
|
||||||
def create_client(self, name: str) -> dict: ...
|
|
||||||
def delete_client(self, client_id: str) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class WgEasyClient:
|
class WgEasyClient:
|
||||||
def __init__(self, base_url: str, admin_password: str, timeout: float = 10) -> None:
|
def __init__(self, base_url: str, admin_password: str, timeout: float = 10) -> None:
|
||||||
self._base_url = base_url.rstrip("/")
|
self._base_url = base_url.rstrip("/")
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
"""REST client for wg-easy v15's admin API — the Nitro/h3-based rewrite
|
|
||||||
wgeasy.py's module docstring warned about, confirmed here to be a real,
|
|
||||||
materially different API rather than a compatible evolution of v14's.
|
|
||||||
|
|
||||||
Verified directly against a real running container
|
|
||||||
(`ghcr.io/wg-easy/wg-easy:15`, booted headlessly via its `INIT_ENABLED=true`
|
|
||||||
bootstrap env vars, every endpoint below hit with live `curl` calls) rather
|
|
||||||
than trusted from reading the bundled source alone — the compiled Nitro
|
|
||||||
output's chunk filenames turned out to be a false trail (e.g.
|
|
||||||
`routes/api/client/index.post.mjs`'s handler body reads a router-param
|
|
||||||
`clientId` that doesn't exist on that literal path — it's not actually the
|
|
||||||
client-creation endpoint despite the name), so treat only the behavior
|
|
||||||
below as trustworthy, not the source layout.
|
|
||||||
|
|
||||||
What's different from v14 (see wgeasy.py):
|
|
||||||
|
|
||||||
- Auth: HTTP Basic (`Authorization: Basic base64(username:password)`)
|
|
||||||
against a real user account, not a bare plaintext-password header. An
|
|
||||||
admin user must already exist — create one headlessly by setting
|
|
||||||
`INIT_ENABLED=true`/`INIT_USERNAME`/`INIT_PASSWORD`/`INIT_HOST`/
|
|
||||||
`INIT_PORT` on the wg-easy container itself (confirmed live: this
|
|
||||||
fully skips the interactive setup wizard). Basic auth is rejected for
|
|
||||||
a user with TOTP (2FA) enabled — keep the automation account's 2FA off.
|
|
||||||
- `POST /api/client` requires `{"name": ..., "expiresAt": null}` (v15
|
|
||||||
added per-client expiry; `null` means never) and its response DOES
|
|
||||||
carry `{"clientId": ...}` directly — no more list-and-match-by-name.
|
|
||||||
The public key isn't in that response though, so `create_client()`
|
|
||||||
still does one follow-up `GET /api/client` list call, matched by the
|
|
||||||
now-unique numeric id (far less fragile than v14's match-by-name).
|
|
||||||
- `GET /api/client/{id}/configuration` returns a wg-quick .conf that now
|
|
||||||
also includes a `PresharedKey` line (v15 generates one per client by
|
|
||||||
default) — callers must thread this through to the box's own
|
|
||||||
wg-quick config, or the handshake fails silently (the server enforces
|
|
||||||
the PSK even if the client's own conf omits it).
|
|
||||||
- `DELETE /api/client/{id}` (id in the URL path — confirmed live; DELETE
|
|
||||||
on the bare `/api/client` collection 404s).
|
|
||||||
- New clients default to full-tunnel AllowedIPs (`0.0.0.0/0, ::/0`)
|
|
||||||
unless the instance's interface-wide default is restricted first via
|
|
||||||
`POST /api/admin/userconfig` — confirmed live that endpoint requires
|
|
||||||
the FULL current config object back (a partial/PATCH-style body
|
|
||||||
400s), so `restrict_default_allowed_ips()` GETs the current config
|
|
||||||
before POSTing the change.
|
|
||||||
|
|
||||||
Re-verify against the running container directly (not upstream docs) if
|
|
||||||
this image tag is ever bumped — the same lesson wgeasy.py's docstring
|
|
||||||
already learned once for v14.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
|
|
||||||
class WgEasyV15Error(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class WgEasyV15Client:
|
|
||||||
def __init__(self, base_url: str, username: str, password: str, timeout: float = 10) -> None:
|
|
||||||
self._base_url = base_url.rstrip("/")
|
|
||||||
self._auth_header = "Basic " + base64.b64encode(
|
|
||||||
f"{username}:{password}".encode()
|
|
||||||
).decode()
|
|
||||||
self._timeout = timeout
|
|
||||||
|
|
||||||
def restrict_default_allowed_ips(self, allowed_ips: list[str]) -> None:
|
|
||||||
"""Set the interface-wide default AllowedIPs new clients get (e.g.
|
|
||||||
split-tunnel `["10.8.0.0/24"]`) instead of v15's full-tunnel
|
|
||||||
default — the same concern v14's WG_ALLOWED_IPS container env var
|
|
||||||
addresses, but v15 has no equivalent env var that applies to an
|
|
||||||
already-initialized instance, so this calls the admin API
|
|
||||||
directly. Idempotent — safe to call on every control-plane
|
|
||||||
startup.
|
|
||||||
"""
|
|
||||||
current = json.loads(self._request("GET", "/api/admin/userconfig"))
|
|
||||||
current.pop("id", None)
|
|
||||||
current.pop("createdAt", None)
|
|
||||||
current.pop("updatedAt", None)
|
|
||||||
current["defaultAllowedIps"] = allowed_ips
|
|
||||||
self._request("POST", "/api/admin/userconfig", current)
|
|
||||||
|
|
||||||
def create_client(self, name: str) -> dict:
|
|
||||||
"""Create a new WireGuard peer and return its full connection material."""
|
|
||||||
created = json.loads(
|
|
||||||
self._request("POST", "/api/client", {"name": name, "expiresAt": None})
|
|
||||||
)
|
|
||||||
client_id = created["clientId"]
|
|
||||||
|
|
||||||
clients = json.loads(self._request("GET", "/api/client"))
|
|
||||||
matches = [c for c in clients if c["id"] == client_id]
|
|
||||||
if not matches:
|
|
||||||
raise WgEasyV15Error(
|
|
||||||
f"created client id {client_id!r} but it did not appear in the client list"
|
|
||||||
)
|
|
||||||
public_key = matches[0]["publicKey"]
|
|
||||||
|
|
||||||
conf_text = self._request(
|
|
||||||
"GET", f"/api/client/{client_id}/configuration"
|
|
||||||
).decode()
|
|
||||||
conf = _parse_wg_conf(conf_text)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": client_id,
|
|
||||||
"public_key": public_key,
|
|
||||||
"private_key": conf["private_key"],
|
|
||||||
"address": conf["address"],
|
|
||||||
"server_public_key": conf["peer_public_key"],
|
|
||||||
"endpoint": conf["endpoint"],
|
|
||||||
"allowed_ips": conf["allowed_ips"],
|
|
||||||
"preshared_key": conf.get("preshared_key"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def delete_client(self, client_id: str) -> None:
|
|
||||||
try:
|
|
||||||
self._request("DELETE", f"/api/client/{client_id}")
|
|
||||||
except WgEasyV15Error:
|
|
||||||
# Already gone on wg-easy's side shouldn't block us cleaning up
|
|
||||||
# our own box/route rows — deregistration must still succeed.
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _request(self, method: str, path: str, body: dict | None = None) -> bytes:
|
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
|
||||||
headers = {"Authorization": self._auth_header}
|
|
||||||
if data is not None:
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{self._base_url}{path}", data=data, method=method, headers=headers
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
||||||
return resp.read()
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
detail = e.read().decode(errors="replace")
|
|
||||||
raise WgEasyV15Error(f"{method} {path} -> HTTP {e.code}: {detail}") from e
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
raise WgEasyV15Error(f"{method} {path} -> {e}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_wg_conf(text: str) -> dict:
|
|
||||||
def find(pattern: str, required: bool = True) -> str | None:
|
|
||||||
m = re.search(pattern, text, re.MULTILINE)
|
|
||||||
if not m:
|
|
||||||
if required:
|
|
||||||
raise WgEasyV15Error(f"could not find {pattern!r} in wg-easy client configuration")
|
|
||||||
return None
|
|
||||||
return m.group(1).strip()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"private_key": find(r"^PrivateKey\s*=\s*(.+)$"),
|
|
||||||
"address": find(r"^Address\s*=\s*(.+)$"),
|
|
||||||
"peer_public_key": find(r"^PublicKey\s*=\s*(.+)$"),
|
|
||||||
"preshared_key": find(r"^PresharedKey\s*=\s*(.+)$", required=False),
|
|
||||||
"endpoint": find(r"^Endpoint\s*=\s*(.+)$"),
|
|
||||||
"allowed_ips": find(r"^AllowedIPs\s*=\s*(.+)$"),
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,77 @@
|
||||||
services:
|
services:
|
||||||
|
wg-easy:
|
||||||
|
image: ghcr.io/wg-easy/wg-easy:14
|
||||||
|
container_name: furtka-gateway-wg-easy
|
||||||
|
# Host networking, not the "internal" bridge network: wg-quick creates
|
||||||
|
# the wg0 interface and its `10.8.0.0/24 dev wg0` route only inside
|
||||||
|
# whatever network namespace this container runs in. On the "internal"
|
||||||
|
# bridge network that route existed only inside wg-easy's own isolated
|
||||||
|
# namespace, so sibling containers (Traefik, in particular) had no path
|
||||||
|
# to reach a WireGuard peer's address at all. With network_mode: host,
|
||||||
|
# wg0 and its routes live in the VPS host's own namespace, which the
|
||||||
|
# host can then route to/from its docker bridge interfaces normally
|
||||||
|
# (given net.ipv4.ip_forward=1 — see ops/host-sysctls.sh; compose's own
|
||||||
|
# `sysctls:` key can't set this here at all, see that script's comment).
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- WG_HOST=${GATEWAY_PUBLIC_HOST}
|
||||||
|
- PASSWORD_HASH=${WG_EASY_PASSWORD_HASH}
|
||||||
|
- PORT=51821
|
||||||
|
- WG_PORT=51820
|
||||||
|
# wg-easy's session/CSRF handling assumes HTTPS by default; we talk to
|
||||||
|
# it over plain HTTP from control-plane (see WG_EASY_URL below), which
|
||||||
|
# is fine precisely because this port is never meant to be reachable
|
||||||
|
# from the public internet — see the ops/firewall.sh note.
|
||||||
|
- INSECURE=true
|
||||||
|
# Default is "0.0.0.0/0, ::/0" — a full-tunnel config that would route
|
||||||
|
# every box's entire internet traffic through this gateway. We only
|
||||||
|
# want boxes reachable *from* the gateway for proxying, not routed
|
||||||
|
# *through* it — split-tunnel, restricted to wg-easy's own peer
|
||||||
|
# subnet (confirmed via config.js: WG_DEFAULT_ADDRESS defaults to
|
||||||
|
# 10.8.0.x, i.e. this same /24).
|
||||||
|
- WG_ALLOWED_IPS=10.8.0.0/24
|
||||||
|
# IPv6 isn't handled anywhere else in this repo yet (routes.py/
|
||||||
|
# traefikconf.py assume IPv4 peer addresses, ops/firewall.sh is
|
||||||
|
# IPv4-only) — turn it off here too rather than leave a half-wired
|
||||||
|
# IPv6 tunnel nothing else accounts for.
|
||||||
|
- DISABLE_IPV6=true
|
||||||
|
volumes:
|
||||||
|
- wg_easy_data:/etc/wireguard
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_MODULE
|
||||||
|
restart: unless-stopped
|
||||||
|
# network_mode: host means wg-easy's admin UI/API (port 51821) binds
|
||||||
|
# directly on the VPS's interfaces, including the public one — unlike
|
||||||
|
# the old bridge-network setup, Docker itself can no longer keep this
|
||||||
|
# off the internet. We can't fix this by binding wg-easy to a single
|
||||||
|
# private address either (it would then also refuse the control-plane
|
||||||
|
# container's own connections, which arrive via the docker bridge
|
||||||
|
# interface, not loopback). The actual fix is a host firewall rule —
|
||||||
|
# see ops/firewall.sh. RUN THAT SCRIPT (or an equivalent rule) BEFORE
|
||||||
|
# this is reachable from the internet.
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.1
|
||||||
|
container_name: furtka-gateway-traefik
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
|
volumes:
|
||||||
|
- ./traefik/static/traefik.${GATEWAY_MODE:-single}.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic:ro
|
||||||
|
- traefik_acme:/acme
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
# Traefik's own dashboard/API is disabled in both static configs
|
||||||
|
# (api.dashboard: false, api.insecure: false) — never reachable at all,
|
||||||
|
# let alone publicly.
|
||||||
|
|
||||||
control-plane:
|
control-plane:
|
||||||
build: ./control_plane
|
build: ./control_plane
|
||||||
# CI (.forgejo/workflows/build-image.yml) publishes this same build to
|
|
||||||
# the registry on every push to master, tagged `latest` plus the short
|
|
||||||
# commit sha. `docker compose up` builds fresh from `build:` above and
|
|
||||||
# tags the result as this name, ignoring this being a registry ref;
|
|
||||||
# `docker stack deploy` (Swarm ignores `build:` entirely, see the
|
|
||||||
# Swarm caveats in README.md) pulls it instead — no manual
|
|
||||||
# `docker build -t ... ./control_plane` step needed there anymore.
|
|
||||||
image: ${GATEWAY_CONTROL_PLANE_IMAGE:-forgejo.sourcegate.online/robert/furtka-gateway-control-plane:latest}
|
|
||||||
container_name: furtka-gateway-control-plane
|
container_name: furtka-gateway-control-plane
|
||||||
environment:
|
environment:
|
||||||
- GATEWAY_MODE=${GATEWAY_MODE:-single}
|
- GATEWAY_MODE=${GATEWAY_MODE:-single}
|
||||||
|
|
@ -16,56 +79,47 @@ services:
|
||||||
- GATEWAY_DYNAMIC_DIR=/dynamic
|
- GATEWAY_DYNAMIC_DIR=/dynamic
|
||||||
- GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN}
|
- GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN}
|
||||||
- GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN}
|
- GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN}
|
||||||
# Blank means no explicit resolver (`tls: {}`) — Traefik applies
|
# wg-easy is host-networked now (see above), so it's no longer a
|
||||||
# whatever default TLS is already configured on YOUR Traefik. Set
|
# fellow member of the "internal" bridge network reachable by service
|
||||||
# this to your Traefik's certResolver name if it doesn't have one.
|
# name — host.docker.internal (mapped below) reaches the VPS host
|
||||||
- GATEWAY_CERT_RESOLVER=${GATEWAY_CERT_RESOLVER:-}
|
# itself, where wg-easy's port 51821 is listening.
|
||||||
# Your existing wg-easy's admin API — reachable at whatever
|
- WG_EASY_URL=http://host.docker.internal:51821
|
||||||
# host:port makes sense for your setup (a sibling container on the
|
# This build of wg-easy (ghcr.io/wg-easy/wg-easy:14 — a legacy
|
||||||
# network this joins — see `networks:` below — or a real host:port
|
# Express-based codebase, confirmed by reading the actual container's
|
||||||
# if it's elsewhere). See the README's Prerequisites checklist for
|
# /app/lib/Server.js, NOT the newer rewrite wg-easy's GitHub `master`
|
||||||
# the network-reachability requirement this doesn't (can't)
|
# branch and docs describe) has no username/session-login API at
|
||||||
# automate away.
|
# all — every request just carries this plaintext password as a bare
|
||||||
- WG_EASY_URL=${WG_EASY_URL}
|
# `Authorization` header, checked with bcrypt against
|
||||||
|
# WG_EASY_PASSWORD_HASH above.
|
||||||
- WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD}
|
- WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD}
|
||||||
# "14" (default) or "15" — these are different, incompatible APIs
|
extra_hosts:
|
||||||
# (see control_plane/wgeasy.py vs wgeasy_v15.py's module docstrings).
|
- "host.docker.internal:host-gateway"
|
||||||
# Check which one your wg-easy image tag actually is.
|
|
||||||
- WG_EASY_API_VERSION=${WG_EASY_API_VERSION:-14}
|
|
||||||
# v15 only: the admin user's username (v14 has no username concept —
|
|
||||||
# see WG_EASY_ADMIN_PASSWORD above).
|
|
||||||
- WG_EASY_ADMIN_USERNAME=${WG_EASY_ADMIN_USERNAME:-}
|
|
||||||
# v15 only: restricts new clients' AllowedIPs to your wg-easy peer
|
|
||||||
# subnet (v15 defaults new clients to full-tunnel 0.0.0.0/0, ::/0
|
|
||||||
# otherwise — see wgeasy_v15.py's restrict_default_allowed_ips()).
|
|
||||||
- GATEWAY_WG_ALLOWED_IPS=${GATEWAY_WG_ALLOWED_IPS:-10.8.0.0/24}
|
|
||||||
volumes:
|
volumes:
|
||||||
- control_plane_data:/data
|
- control_plane_data:/data
|
||||||
# Bind-mount this at whatever host path YOUR Traefik's file provider
|
- ./traefik/dynamic:/dynamic
|
||||||
# already watches (or add a second `providers.file` directory to
|
|
||||||
# your Traefik's static config pointed at this same path — additive,
|
|
||||||
# doesn't touch your existing dynamic config). Default here is just
|
|
||||||
# a placeholder; ops/deploy.sh asks for the real path.
|
|
||||||
- ${GATEWAY_DYNAMIC_DIR_HOST:-./dynamic}:/dynamic
|
|
||||||
ports:
|
ports:
|
||||||
# Loopback-only by default. This is how a remote Furtka box reaches
|
# Loopback-only convenience for local health checks/debugging
|
||||||
# the registration API in the COMMON case (your own Traefik, on the
|
# (`curl 127.0.0.1:8090/healthz` from the VPS itself). This is NOT
|
||||||
# network below, proxies to it — see
|
# how a remote Furtka box reaches the registration API — that goes
|
||||||
# traefik/dynamic/control-plane.yml.example) — a public bind here
|
# through Traefik over HTTPS; see traefik/dynamic/control-plane.yml.example.
|
||||||
# isn't needed. Only widen GATEWAY_CONTROL_PLANE_BIND if your
|
- "127.0.0.1:8090:8090"
|
||||||
# Traefik genuinely can't join this container's docker network (a
|
|
||||||
# separate-host setup) — ops/deploy.sh flags the access-control
|
|
||||||
# implication of doing that when you choose it.
|
|
||||||
- "${GATEWAY_CONTROL_PLANE_BIND:-127.0.0.1:8090}:8090"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# No custom `networks:` here on purpose — this stays on the default
|
networks:
|
||||||
# bridge network compose creates unless you also run YOUR Traefik in
|
- internal
|
||||||
# this SAME compose project. To reach a Traefik running elsewhere (its
|
depends_on:
|
||||||
# own compose project, or your own homelab stack), ops/deploy.sh joins
|
- traefik
|
||||||
# this container to your Traefik's existing docker network after
|
|
||||||
# `up` via `docker network connect` — no external-network dependency
|
networks:
|
||||||
# baked into this file, so it works whether or not that network
|
internal:
|
||||||
# exists yet at compose-up time.
|
driver: bridge
|
||||||
|
# Pinned (rather than Docker's usual dynamic allocation) so
|
||||||
|
# ops/firewall.sh has a stable subnet to reference when it allows this
|
||||||
|
# network through to wg-easy's admin API but blocks everyone else.
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.28.0.0/24
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
wg_easy_data:
|
||||||
|
traefik_acme:
|
||||||
control_plane_data:
|
control_plane_data:
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
# Zero-infra starter stack: bundles its own Traefik + wg-easy alongside the
|
|
||||||
# control-plane. If you already run Traefik and/or wg-easy, use the main
|
|
||||||
# repo's root docker-compose.yaml + ops/deploy.sh instead — it plugs into
|
|
||||||
# what you already have rather than running a second copy.
|
|
||||||
#
|
|
||||||
# Filling this file in by hand? Run `deploy.sh` instead — it prompts for
|
|
||||||
# the values below, generates the secrets/tokens, handles the `$$`
|
|
||||||
# escaping gotcha noted under WG_EASY_PASSWORD_HASH for you, and renders
|
|
||||||
# traefik/static/traefik.active.yml + traefik/dynamic/control-plane.yml.
|
|
||||||
|
|
||||||
# The hostname (or IP, for a first local smoke test) wg-easy advertises to
|
|
||||||
# peers as the WireGuard endpoint. Must be reachable on udp/51820.
|
|
||||||
GATEWAY_PUBLIC_HOST=vpn.example.com
|
|
||||||
|
|
||||||
# ACME email for Let's Encrypt registration (HTTP-01, per-subdomain —
|
|
||||||
# fine at self-hosted scale, nowhere near Let's Encrypt's ~50-certs/
|
|
||||||
# registered-domain/week limit with a handful of published apps).
|
|
||||||
GATEWAY_ACME_EMAIL=admin@example.com
|
|
||||||
|
|
||||||
# bcrypt hash of the wg-easy admin password. Generate with:
|
|
||||||
# docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \
|
|
||||||
# "console.log(require('bcryptjs').hashSync(process.argv[1], 10))" 'your-password'
|
|
||||||
#
|
|
||||||
# IMPORTANT: bcrypt hashes contain literal `$` characters (e.g.
|
|
||||||
# "$2a$10$..."), and Docker Compose's own .env-file parser treats `$word`
|
|
||||||
# as a variable reference to substitute — confirmed by actually hitting
|
|
||||||
# this: an unescaped hash silently got truncated to "$2a$10" with
|
|
||||||
# everything after dropped. Every `$` in the value below MUST be doubled
|
|
||||||
# as `$$`, e.g. WG_EASY_PASSWORD_HASH=$$2a$$10$$abc123...
|
|
||||||
WG_EASY_PASSWORD_HASH=
|
|
||||||
|
|
||||||
# PLAINTEXT password matching the hash above. wg-easy's admin API (this
|
|
||||||
# specific pinned image, ghcr.io/wg-easy/wg-easy:14 — confirmed by reading
|
|
||||||
# its actual source, which turned out to differ from what wg-easy's current
|
|
||||||
# docs describe) has no login call or session cookie at all: every request
|
|
||||||
# just carries this password as a plain `Authorization` header, checked
|
|
||||||
# with bcrypt against WG_EASY_PASSWORD_HASH. No username concept exists.
|
|
||||||
WG_EASY_ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# The one box token this stack accepts at /v1/boxes/register, skipping
|
|
||||||
# full account/registration-token issuance. Generate with: openssl rand -hex 32
|
|
||||||
GATEWAY_BOX_TOKEN=
|
|
||||||
|
|
||||||
# Hostname a Furtka box actually reaches the control-plane API on — needs
|
|
||||||
# its own A/AAAA record pointing at this VPS. Not read by docker-compose
|
|
||||||
# itself; it's what deploy.sh substitutes into traefik/dynamic/control-plane.yml.
|
|
||||||
GATEWAY_CONTROL_PLANE_HOST=gw.example.com
|
|
||||||
|
|
||||||
# Bearer token required to create accounts via POST /v1/accounts.
|
|
||||||
# Only meaningful once account endpoints exist (Phase 2+); harmless to set
|
|
||||||
# now. Generate with: openssl rand -hex 32
|
|
||||||
GATEWAY_ADMIN_TOKEN=
|
|
||||||
|
|
||||||
# Optional: override the control-plane image `docker stack deploy` pulls
|
|
||||||
# (Swarm ignores docker-compose.yaml's `build:` key). Defaults to the
|
|
||||||
# `latest` tag CI publishes on every push to master; pin to a specific
|
|
||||||
# commit-sha tag for a reproducible deploy. Not read by plain
|
|
||||||
# `docker compose up`, which always builds locally.
|
|
||||||
GATEWAY_CONTROL_PLANE_IMAGE=
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# furtka-gateway starter stack
|
|
||||||
|
|
||||||
Zero-infra on-ramp: bundles its own Traefik + wg-easy alongside the
|
|
||||||
control-plane, for an operator who doesn't already run either. This is a
|
|
||||||
secondary path — if you already have Traefik and/or wg-easy running
|
|
||||||
(a common self-hosting setup), use the main repo's root `docker-compose.yaml`
|
|
||||||
+ `ops/deploy.sh` instead, which plugs into what you already have rather
|
|
||||||
than running a second copy. See the root `README.md`'s Prerequisites
|
|
||||||
checklist.
|
|
||||||
|
|
||||||
This stack always runs in `GATEWAY_MODE=single`, owns ports `80/tcp`,
|
|
||||||
`443/tcp`, and `51820/udp` on the host directly, and uses HTTP-01 certs
|
|
||||||
(no DNS provider needed) — the tradeoffs that make `single-parallel` mode
|
|
||||||
necessary elsewhere don't apply here, because this stack assumes nothing
|
|
||||||
else is already using those ports.
|
|
||||||
|
|
||||||
## Deploying on a VPS
|
|
||||||
|
|
||||||
1. Get a VPS with a static public IP, and point two A/AAAA records at it:
|
|
||||||
one for `GATEWAY_PUBLIC_HOST` (the WireGuard endpoint) and one for
|
|
||||||
`GATEWAY_CONTROL_PLANE_HOST` (the registration API — needs its own
|
|
||||||
hostname since the response carries a WireGuard private key in
|
|
||||||
plaintext JSON, so that call must go over HTTPS).
|
|
||||||
2. Run `./deploy.sh` from this directory and answer its prompts. It
|
|
||||||
generates the wg-easy admin password hash, `GATEWAY_BOX_TOKEN`, and
|
|
||||||
`GATEWAY_ADMIN_TOKEN`, writes `.env`, renders
|
|
||||||
`traefik/static/traefik.active.yml` and
|
|
||||||
`traefik/dynamic/control-plane.yml` from their tracked templates, runs
|
|
||||||
`sudo ops/host-sysctls.sh` (wg-easy needs `net.ipv4.ip_forward=1` and
|
|
||||||
`net.ipv4.conf.all.src_valid_mark` — see that script's comments for why
|
|
||||||
Docker's own `sysctls:` key can't set these under `network_mode: host`),
|
|
||||||
brings the stack up, runs `sudo ops/firewall.sh` (locks wg-easy's admin
|
|
||||||
API off the public interface — `network_mode: host` means Docker itself
|
|
||||||
can no longer do that automatically), and health-checks it.
|
|
||||||
3. Open `80/tcp`, `443/tcp`, `51820/udp` in whatever firewall/security
|
|
||||||
group sits in front of the VPS.
|
|
||||||
|
|
||||||
Run `./deploy.sh --render-only` to just (re)write `.env` and the rendered
|
|
||||||
Traefik files without touching sysctls/compose/firewall — useful to inspect
|
|
||||||
what would be generated, or after hand-editing `.env`.
|
|
||||||
|
|
||||||
## Local dev
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh --render-only
|
|
||||||
# GATEWAY_PUBLIC_HOST can be a LAN-reachable IP for a first smoke test.
|
|
||||||
docker compose up -d
|
|
||||||
curl http://127.0.0.1:8090/healthz
|
|
||||||
```
|
|
||||||
|
|
||||||
`ops/firewall.sh` and the `control-plane.yml` Traefik route are about
|
|
||||||
reaching this gateway from the real internet — skip both for local dev.
|
|
||||||
|
|
||||||
## Docker Swarm caveats
|
|
||||||
|
|
||||||
If deploying via `docker stack deploy` instead of `docker compose up`:
|
|
||||||
- `docker-compose.yaml`'s `build: ../../control_plane` doesn't work under
|
|
||||||
Swarm at all — it only ever schedules pre-built, tagged images. Swarm
|
|
||||||
falls back to the `image:` key on the same service instead, which CI
|
|
||||||
keeps published to the registry on every push to `master` (tagged
|
|
||||||
`latest` plus the short commit sha). Set `GATEWAY_CONTROL_PLANE_IMAGE` in
|
|
||||||
`.env` first if you want a specific commit-sha tag rather than whatever
|
|
||||||
`latest` currently points to.
|
|
||||||
- `docker stack deploy` does not read a `.env` file next to the compose
|
|
||||||
file the way `docker compose up` does — `set -a; source .env; set +a`
|
|
||||||
first, or the `${VAR}` substitutions come out empty. **Do not `source`
|
|
||||||
`.env` directly if `WG_EASY_PASSWORD_HASH` is set** — bash expands `$$`
|
|
||||||
(its own PID) while sourcing, silently corrupting the hash's doubled-`$`
|
|
||||||
escaping. Export that one variable separately, unescaped, or set it
|
|
||||||
directly on the `docker stack deploy` command line.
|
|
||||||
- `depends_on` is silently ignored by Swarm — harmless here, since
|
|
||||||
`control-plane` only talks to wg-easy/Traefik lazily on the first real
|
|
||||||
request, not at startup.
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Interactive setup for the zero-infra starter stack: bundles its own
|
|
||||||
# Traefik + wg-easy + control-plane. If you already run Traefik and/or
|
|
||||||
# wg-easy, use the main repo's root docker-compose.yaml + ops/deploy.sh
|
|
||||||
# instead — it plugs into what you already have.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./deploy.sh full setup: prompts, render, sysctls,
|
|
||||||
# compose up, firewall, health check
|
|
||||||
# ./deploy.sh --render-only only (re)write .env and the rendered
|
|
||||||
# Traefik files — skips sysctls/compose/
|
|
||||||
# firewall/health-check.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
RENDER_ONLY=0
|
|
||||||
if [[ "${1:-}" == "--render-only" ]]; then
|
|
||||||
RENDER_ONLY=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ENV_FILE=.env
|
|
||||||
|
|
||||||
# ---- load existing .env (if any) so prompts default to current values ----
|
|
||||||
# Deliberately NOT `source`d: bash expands `$$` (its own PID) and `$VAR`
|
|
||||||
# during sourcing, which would corrupt WG_EASY_PASSWORD_HASH's doubled-`$`
|
|
||||||
# escaping (that escaping is for docker compose's own .env parser, not
|
|
||||||
# bash's) — confirmed by hitting this while building the main ops/deploy.sh.
|
|
||||||
# Plain string splitting instead, so a value is never re-interpreted.
|
|
||||||
load_env_file() {
|
|
||||||
local file="$1" line key value
|
|
||||||
[[ -f "$file" ]] || return 0
|
|
||||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
||||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
|
||||||
key="${line%%=*}"
|
|
||||||
value="${line#*=}"
|
|
||||||
printf -v "$key" '%s' "$value"
|
|
||||||
done < "$file"
|
|
||||||
}
|
|
||||||
load_env_file "$ENV_FILE"
|
|
||||||
# Normalize back to the raw (single-`$`) hash for the rest of this script;
|
|
||||||
# re-escaped once, right before writing, further down.
|
|
||||||
WG_EASY_PASSWORD_HASH="${WG_EASY_PASSWORD_HASH:-}"
|
|
||||||
WG_EASY_PASSWORD_HASH="${WG_EASY_PASSWORD_HASH//\$\$/\$}"
|
|
||||||
|
|
||||||
ask() {
|
|
||||||
local __var="$1" __prompt="$2" __fallback="${3:-}"
|
|
||||||
local __current="${!__var:-}"
|
|
||||||
local __default="${__current:-$__fallback}"
|
|
||||||
local __ans
|
|
||||||
if [[ -n "$__default" ]]; then
|
|
||||||
read -r -p "$__prompt [$__default]: " __ans
|
|
||||||
else
|
|
||||||
read -r -p "$__prompt: " __ans
|
|
||||||
fi
|
|
||||||
printf -v "$__var" '%s' "${__ans:-$__default}"
|
|
||||||
}
|
|
||||||
|
|
||||||
ask_secret() {
|
|
||||||
local __var="$1" __prompt="$2"
|
|
||||||
local __current="${!__var:-}"
|
|
||||||
local __ans
|
|
||||||
if [[ -n "$__current" ]]; then
|
|
||||||
read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans
|
|
||||||
else
|
|
||||||
read -r -s -p "$__prompt: " __ans
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
printf -v "$__var" '%s' "${__ans:-$__current}"
|
|
||||||
}
|
|
||||||
|
|
||||||
require_cmd() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "$RENDER_ONLY" -eq 0 ]]; then
|
|
||||||
require_cmd docker
|
|
||||||
require_cmd openssl
|
|
||||||
require_cmd sudo
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== furtka-gateway starter-stack deploy =="
|
|
||||||
echo
|
|
||||||
|
|
||||||
ask GATEWAY_PUBLIC_HOST "WireGuard endpoint hostname (A/AAAA record for this VPS)" "vpn.example.com"
|
|
||||||
ask GATEWAY_CONTROL_PLANE_HOST "Control-plane API hostname (its own A/AAAA record)" "gw.example.com"
|
|
||||||
ask GATEWAY_ACME_EMAIL "Email for Let's Encrypt registration" "admin@example.com"
|
|
||||||
|
|
||||||
echo
|
|
||||||
_PREV_WG_PASSWORD="${WG_EASY_ADMIN_PASSWORD:-}"
|
|
||||||
ask_secret WG_EASY_ADMIN_PASSWORD "wg-easy admin password"
|
|
||||||
if [[ -z "${WG_EASY_ADMIN_PASSWORD:-}" ]]; then
|
|
||||||
echo "wg-easy admin password is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "$WG_EASY_ADMIN_PASSWORD" != "$_PREV_WG_PASSWORD" || -z "${WG_EASY_PASSWORD_HASH:-}" ]]; then
|
|
||||||
echo "Hashing wg-easy admin password..."
|
|
||||||
WG_EASY_PASSWORD_HASH="$(docker run --rm ghcr.io/wg-easy/wg-easy:14 node -e \
|
|
||||||
"console.log(require('bcryptjs').hashSync(process.argv[1], 10))" "$WG_EASY_ADMIN_PASSWORD")"
|
|
||||||
fi
|
|
||||||
WG_EASY_PASSWORD_HASH_ESCAPED="${WG_EASY_PASSWORD_HASH//\$/\$\$}"
|
|
||||||
|
|
||||||
GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
|
|
||||||
GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Writing $ENV_FILE"
|
|
||||||
{
|
|
||||||
echo "# Generated by deploy.sh — re-run it to change any of these."
|
|
||||||
echo "GATEWAY_PUBLIC_HOST=$GATEWAY_PUBLIC_HOST"
|
|
||||||
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
|
||||||
echo "GATEWAY_ACME_EMAIL=$GATEWAY_ACME_EMAIL"
|
|
||||||
echo "WG_EASY_PASSWORD_HASH=$WG_EASY_PASSWORD_HASH_ESCAPED"
|
|
||||||
echo "WG_EASY_ADMIN_PASSWORD=$WG_EASY_ADMIN_PASSWORD"
|
|
||||||
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
|
||||||
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
|
||||||
} > "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
|
|
||||||
echo "Rendering traefik/static/traefik.active.yml"
|
|
||||||
cp traefik/static/traefik.single.yml traefik/static/traefik.active.yml
|
|
||||||
sed -i "s|admin@example.com|$GATEWAY_ACME_EMAIL|" traefik/static/traefik.active.yml
|
|
||||||
|
|
||||||
echo "Rendering traefik/dynamic/control-plane.yml"
|
|
||||||
# Only the http: block onward — the .example's header comment mentions the
|
|
||||||
# placeholder by name too (for a human reading that template), which would
|
|
||||||
# otherwise also get corrupted by this same substitution.
|
|
||||||
sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > traefik/dynamic/control-plane.yml
|
|
||||||
sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" traefik/dynamic/control-plane.yml
|
|
||||||
|
|
||||||
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
|
||||||
echo
|
|
||||||
echo "--render-only: stopping before sysctls/compose/firewall/health-check."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== sudo ops/host-sysctls.sh =="
|
|
||||||
sudo "$SCRIPT_DIR/ops/host-sysctls.sh"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== docker compose up -d =="
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== sudo ops/firewall.sh =="
|
|
||||||
sudo "$SCRIPT_DIR/ops/firewall.sh"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== health check =="
|
|
||||||
for _ in $(seq 1 10); do
|
|
||||||
if curl -sf 127.0.0.1:8090/healthz >/dev/null; then
|
|
||||||
echo "control-plane is up."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
if ! curl -sf 127.0.0.1:8090/healthz >/dev/null; then
|
|
||||||
echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== done =="
|
|
||||||
echo "Still needed before a real box can connect:"
|
|
||||||
echo " - DNS: A/AAAA for $GATEWAY_CONTROL_PLANE_HOST and $GATEWAY_PUBLIC_HOST -> this VPS's public IP"
|
|
||||||
echo " - Open udp/51820, tcp/80, tcp/443 in your firewall/security group"
|
|
||||||
echo " - On the box: furtka gateway connect <this gateway's URL> <GATEWAY_BOX_TOKEN from .env> <box name>"
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
services:
|
|
||||||
wg-easy:
|
|
||||||
image: ghcr.io/wg-easy/wg-easy:14
|
|
||||||
container_name: furtka-gateway-wg-easy
|
|
||||||
# Host networking, not the "internal" bridge network: wg-quick creates
|
|
||||||
# the wg0 interface and its `10.8.0.0/24 dev wg0` route only inside
|
|
||||||
# whatever network namespace this container runs in. On the "internal"
|
|
||||||
# bridge network that route existed only inside wg-easy's own isolated
|
|
||||||
# namespace, so sibling containers (Traefik, in particular) had no path
|
|
||||||
# to reach a WireGuard peer's address at all. With network_mode: host,
|
|
||||||
# wg0 and its routes live in the VPS host's own namespace, which the
|
|
||||||
# host can then route to/from its docker bridge interfaces normally
|
|
||||||
# (given net.ipv4.ip_forward=1 — see ops/host-sysctls.sh; compose's own
|
|
||||||
# `sysctls:` key can't set this here at all, see that script's comment).
|
|
||||||
network_mode: host
|
|
||||||
environment:
|
|
||||||
- WG_HOST=${GATEWAY_PUBLIC_HOST}
|
|
||||||
- PASSWORD_HASH=${WG_EASY_PASSWORD_HASH}
|
|
||||||
- PORT=51821
|
|
||||||
- WG_PORT=51820
|
|
||||||
# wg-easy's session/CSRF handling assumes HTTPS by default; we talk to
|
|
||||||
# it over plain HTTP from control-plane (see WG_EASY_URL below), which
|
|
||||||
# is fine precisely because this port is never meant to be reachable
|
|
||||||
# from the public internet — see the ops/firewall.sh note.
|
|
||||||
- INSECURE=true
|
|
||||||
# Default is "0.0.0.0/0, ::/0" — a full-tunnel config that would route
|
|
||||||
# every box's entire internet traffic through this gateway. We only
|
|
||||||
# want boxes reachable *from* the gateway for proxying, not routed
|
|
||||||
# *through* it — split-tunnel, restricted to wg-easy's own peer
|
|
||||||
# subnet (confirmed via config.js: WG_DEFAULT_ADDRESS defaults to
|
|
||||||
# 10.8.0.x, i.e. this same /24).
|
|
||||||
- WG_ALLOWED_IPS=10.8.0.0/24
|
|
||||||
# IPv6 isn't handled anywhere else in this repo yet (routes.py/
|
|
||||||
# traefikconf.py assume IPv4 peer addresses, ops/firewall.sh is
|
|
||||||
# IPv4-only) — turn it off here too rather than leave a half-wired
|
|
||||||
# IPv6 tunnel nothing else accounts for.
|
|
||||||
- DISABLE_IPV6=true
|
|
||||||
volumes:
|
|
||||||
- wg_easy_data:/etc/wireguard
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
- SYS_MODULE
|
|
||||||
restart: unless-stopped
|
|
||||||
# network_mode: host means wg-easy's admin UI/API (port 51821) binds
|
|
||||||
# directly on the VPS's interfaces, including the public one — unlike
|
|
||||||
# the old bridge-network setup, Docker itself can no longer keep this
|
|
||||||
# off the internet. We can't fix this by binding wg-easy to a single
|
|
||||||
# private address either (it would then also refuse the control-plane
|
|
||||||
# container's own connections, which arrive via the docker bridge
|
|
||||||
# interface, not loopback). The actual fix is a host firewall rule —
|
|
||||||
# see ops/firewall.sh. RUN THAT SCRIPT (or an equivalent rule) BEFORE
|
|
||||||
# this is reachable from the internet.
|
|
||||||
|
|
||||||
traefik:
|
|
||||||
image: traefik:v3.1
|
|
||||||
container_name: furtka-gateway-traefik
|
|
||||||
command:
|
|
||||||
- --configFile=/etc/traefik/traefik.yml
|
|
||||||
volumes:
|
|
||||||
# Rendered by deploy.sh from traefik/static/traefik.single.yml with
|
|
||||||
# the ACME email substituted in — see that script. Gitignored, not
|
|
||||||
# the tracked template.
|
|
||||||
- ./traefik/static/traefik.active.yml:/etc/traefik/traefik.yml:ro
|
|
||||||
- ./traefik/dynamic:/etc/traefik/dynamic:ro
|
|
||||||
- traefik_acme:/acme
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
# Traefik's own dashboard/API is disabled (api.dashboard: false,
|
|
||||||
# api.insecure: false) — never reachable at all, let alone publicly.
|
|
||||||
|
|
||||||
control-plane:
|
|
||||||
build: ../../control_plane
|
|
||||||
# CI (.forgejo/workflows/build-image.yml) publishes this same build to
|
|
||||||
# the registry on every push to master, tagged `latest` plus the short
|
|
||||||
# commit sha. `docker compose up` builds fresh from `build:` above and
|
|
||||||
# tags the result as this name, ignoring this being a registry ref;
|
|
||||||
# `docker stack deploy` (Swarm ignores `build:` entirely) pulls it
|
|
||||||
# instead — no manual `docker build -t ... ./control_plane` step
|
|
||||||
# needed there anymore.
|
|
||||||
image: ${GATEWAY_CONTROL_PLANE_IMAGE:-forgejo.sourcegate.online/robert/furtka-gateway-control-plane:latest}
|
|
||||||
container_name: furtka-gateway-control-plane
|
|
||||||
environment:
|
|
||||||
- GATEWAY_MODE=${GATEWAY_MODE:-single}
|
|
||||||
- GATEWAY_STATE_DIR=/data
|
|
||||||
- GATEWAY_DYNAMIC_DIR=/dynamic
|
|
||||||
- GATEWAY_ADMIN_TOKEN=${GATEWAY_ADMIN_TOKEN}
|
|
||||||
- GATEWAY_BOX_TOKEN=${GATEWAY_BOX_TOKEN}
|
|
||||||
- GATEWAY_CERT_RESOLVER=le
|
|
||||||
# wg-easy is host-networked now (see above), so it's no longer a
|
|
||||||
# fellow member of the "internal" bridge network reachable by service
|
|
||||||
# name — host.docker.internal (mapped below) reaches the VPS host
|
|
||||||
# itself, where wg-easy's admin API is listening on 51821.
|
|
||||||
- WG_EASY_URL=http://host.docker.internal:51821
|
|
||||||
- WG_EASY_API_VERSION=14
|
|
||||||
# This pinned image (ghcr.io/wg-easy/wg-easy:14 — a legacy
|
|
||||||
# Express-based codebase, confirmed by reading the actual container's
|
|
||||||
# /app/lib/Server.js, NOT the newer rewrite wg-easy's GitHub `master`
|
|
||||||
# branch and docs describe) has no username/session-login API at
|
|
||||||
# all — every request just carries this plaintext password as a bare
|
|
||||||
# `Authorization` header, checked with bcrypt against
|
|
||||||
# WG_EASY_PASSWORD_HASH above.
|
|
||||||
- WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD}
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
volumes:
|
|
||||||
- control_plane_data:/data
|
|
||||||
- ./traefik/dynamic:/dynamic
|
|
||||||
ports:
|
|
||||||
# Loopback-only convenience for local health checks/debugging
|
|
||||||
# (`curl 127.0.0.1:8090/healthz` from the VPS itself). This is NOT
|
|
||||||
# how a remote Furtka box reaches the registration API — that goes
|
|
||||||
# through Traefik over HTTPS; see traefik/dynamic/control-plane.yml.example.
|
|
||||||
- "127.0.0.1:8090:8090"
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
depends_on:
|
|
||||||
- traefik
|
|
||||||
|
|
||||||
networks:
|
|
||||||
internal:
|
|
||||||
driver: bridge
|
|
||||||
# Pinned (rather than Docker's usual dynamic allocation) so
|
|
||||||
# ops/firewall.sh has a stable subnet to reference when it allows this
|
|
||||||
# network through to wg-easy's admin API but blocks everyone else.
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.28.0.0/24
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
wg_easy_data:
|
|
||||||
traefik_acme:
|
|
||||||
control_plane_data:
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# Copy to traefik/dynamic/control-plane.yml with GATEWAY_CONTROL_PLANE_HOST
|
|
||||||
# substituted for a real hostname you control (e.g. gw.example.com, with its
|
|
||||||
# own A/AAAA record pointing at this VPS).
|
|
||||||
#
|
|
||||||
# Without this file, the control-plane API is only reachable at
|
|
||||||
# 127.0.0.1:8090 on the gateway host itself — fine for a local health
|
|
||||||
# check, useless for a real remote Furtka box, which needs this over HTTPS
|
|
||||||
# anyway: the registration response carries a WireGuard private key in
|
|
||||||
# plaintext JSON, so that call must never go out over bare HTTP.
|
|
||||||
#
|
|
||||||
# Keep the certResolver line below as-is — this stack always runs
|
|
||||||
# GATEWAY_MODE=single with HTTP-01, same as every app route (see
|
|
||||||
# traefik/static/traefik.single.yml).
|
|
||||||
http:
|
|
||||||
routers:
|
|
||||||
control-plane:
|
|
||||||
rule: "Host(`GATEWAY_CONTROL_PLANE_HOST`)"
|
|
||||||
entryPoints: [websecure]
|
|
||||||
service: control-plane
|
|
||||||
tls:
|
|
||||||
certResolver: le
|
|
||||||
services:
|
|
||||||
control-plane:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "http://control-plane:8090"
|
|
||||||
256
ops/deploy.sh
256
ops/deploy.sh
|
|
@ -1,256 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Interactive setup for furtka-gateway's control-plane, plugged into a
|
|
||||||
# Traefik and wg-easy YOU already run (as sibling containers, or otherwise
|
|
||||||
# reachable) — see the root README's Prerequisites checklist before
|
|
||||||
# running this. No zero-infra option here: for that, use
|
|
||||||
# examples/starter-stack/deploy.sh instead, which bundles its own
|
|
||||||
# Traefik+wg-easy.
|
|
||||||
#
|
|
||||||
# Safe to re-run — existing .env values become the defaults for each
|
|
||||||
# prompt (press Enter to keep them), and rendering is always fully
|
|
||||||
# regenerated from the current .env, never patched in place.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ops/deploy.sh full setup: prompts, render, compose up,
|
|
||||||
# network join, health check
|
|
||||||
# ops/deploy.sh --render-only only (re)write .env and the rendered
|
|
||||||
# traefik/dynamic/control-plane.yml —
|
|
||||||
# skips compose up / network join /
|
|
||||||
# health check. Useful after hand-editing
|
|
||||||
# .env, or to inspect what would be
|
|
||||||
# generated before touching anything live.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
RENDER_ONLY=0
|
|
||||||
if [[ "${1:-}" == "--render-only" ]]; then
|
|
||||||
RENDER_ONLY=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ENV_FILE=.env
|
|
||||||
|
|
||||||
# ---- load existing .env (if any) so prompts default to current values ----
|
|
||||||
# Deliberately NOT `source`d: bash expands `$VAR`/`$$` during sourcing,
|
|
||||||
# which is wrong for values meant to be taken literally. Plain string
|
|
||||||
# splitting instead.
|
|
||||||
load_env_file() {
|
|
||||||
local file="$1" line key value
|
|
||||||
[[ -f "$file" ]] || return 0
|
|
||||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
||||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
|
||||||
key="${line%%=*}"
|
|
||||||
value="${line#*=}"
|
|
||||||
printf -v "$key" '%s' "$value"
|
|
||||||
done < "$file"
|
|
||||||
}
|
|
||||||
load_env_file "$ENV_FILE"
|
|
||||||
|
|
||||||
ask() {
|
|
||||||
# ask VARNAME "Prompt text" "fallback-default"
|
|
||||||
local __var="$1" __prompt="$2" __fallback="${3:-}"
|
|
||||||
local __current="${!__var:-}"
|
|
||||||
local __default="${__current:-$__fallback}"
|
|
||||||
local __ans
|
|
||||||
if [[ -n "$__default" ]]; then
|
|
||||||
read -r -p "$__prompt [$__default]: " __ans
|
|
||||||
else
|
|
||||||
read -r -p "$__prompt: " __ans
|
|
||||||
fi
|
|
||||||
printf -v "$__var" '%s' "${__ans:-$__default}"
|
|
||||||
}
|
|
||||||
|
|
||||||
ask_secret() {
|
|
||||||
local __var="$1" __prompt="$2"
|
|
||||||
local __current="${!__var:-}"
|
|
||||||
local __ans
|
|
||||||
if [[ -n "$__current" ]]; then
|
|
||||||
read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans
|
|
||||||
else
|
|
||||||
read -r -s -p "$__prompt: " __ans
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
printf -v "$__var" '%s' "${__ans:-$__current}"
|
|
||||||
}
|
|
||||||
|
|
||||||
require_cmd() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "$RENDER_ONLY" -eq 0 ]]; then
|
|
||||||
require_cmd docker
|
|
||||||
require_cmd openssl
|
|
||||||
require_cmd curl
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== furtka-gateway deploy (bring your own Traefik/wg-easy) =="
|
|
||||||
echo "Haven't read the README's Prerequisites checklist yet? Do that first —"
|
|
||||||
echo "this script can't verify your Traefik has a real network path to"
|
|
||||||
echo "wg-easy's WireGuard peer subnet, and a broken deploy from skipping"
|
|
||||||
echo "that is hard to debug after the fact."
|
|
||||||
echo
|
|
||||||
|
|
||||||
# ---- tenancy mode ----
|
|
||||||
while true; do
|
|
||||||
ask GATEWAY_MODE "Mode (single / shared)" "single"
|
|
||||||
case "$GATEWAY_MODE" in
|
|
||||||
single|shared) break ;;
|
|
||||||
*) echo "must be one of: single, shared" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---- wg-easy ----
|
|
||||||
echo
|
|
||||||
ask WG_EASY_URL "Your wg-easy's admin API URL (e.g. http://wg-easy:51821)" ""
|
|
||||||
if [[ -z "$WG_EASY_URL" ]]; then
|
|
||||||
echo "WG_EASY_URL is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
while true; do
|
|
||||||
ask WG_EASY_API_VERSION "Your wg-easy image's version (14 or 15 — check the tag)" "14"
|
|
||||||
case "$WG_EASY_API_VERSION" in
|
|
||||||
14|15) break ;;
|
|
||||||
*) echo "must be 14 or 15 — these are different, incompatible APIs (see control_plane/wgeasy.py vs wgeasy_v15.py)" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
if [[ "$WG_EASY_API_VERSION" == "15" ]]; then
|
|
||||||
ask WG_EASY_ADMIN_USERNAME "wg-easy admin username" "admin"
|
|
||||||
ask GATEWAY_WG_ALLOWED_IPS "wg-easy's WireGuard peer subnet (restricts new clients to split-tunnel)" "10.8.0.0/24"
|
|
||||||
fi
|
|
||||||
ask_secret WG_EASY_ADMIN_PASSWORD "wg-easy admin password"
|
|
||||||
if [[ -z "${WG_EASY_ADMIN_PASSWORD:-}" ]]; then
|
|
||||||
echo "wg-easy admin password is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- Traefik ----
|
|
||||||
echo
|
|
||||||
ask GATEWAY_DYNAMIC_DIR_HOST "Host directory YOUR Traefik's file provider watches (or a new one you'll add as an extra providers.file directory)" ""
|
|
||||||
if [[ -z "$GATEWAY_DYNAMIC_DIR_HOST" ]]; then
|
|
||||||
echo "GATEWAY_DYNAMIC_DIR_HOST is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ask GATEWAY_CERT_RESOLVER "Your Traefik's certResolver name (blank if it applies a default cert automatically)" ""
|
|
||||||
ask GATEWAY_CONTROL_PLANE_HOST "Control-plane API hostname (its own A/AAAA record)" "gw.example.com"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "How does YOUR Traefik reach this control-plane container?"
|
|
||||||
echo " 1) same docker network as my Traefik (recommended)"
|
|
||||||
echo " 2) separate host — I'll expose it at a reachable host:port myself"
|
|
||||||
while true; do
|
|
||||||
ask GATEWAY_REACHABILITY_MODE "Choice (1 or 2)" "1"
|
|
||||||
case "$GATEWAY_REACHABILITY_MODE" in
|
|
||||||
1|2) break ;;
|
|
||||||
*) echo "must be 1 or 2" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
if [[ "$GATEWAY_REACHABILITY_MODE" == "1" ]]; then
|
|
||||||
ask GATEWAY_TRAEFIK_NETWORK "Your Traefik's docker network name (docker network ls / docker inspect <traefik-container>)" ""
|
|
||||||
if [[ -z "$GATEWAY_TRAEFIK_NETWORK" ]]; then
|
|
||||||
echo "a network name is required for this choice" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
GATEWAY_CONTROL_PLANE_BIND="127.0.0.1:8090"
|
|
||||||
GATEWAY_CONTROL_PLANE_TARGET="furtka-gateway-control-plane:8090"
|
|
||||||
else
|
|
||||||
echo
|
|
||||||
echo "WARNING: this binds the control-plane API beyond loopback. Its"
|
|
||||||
echo "registration response carries a WireGuard private key in plaintext"
|
|
||||||
echo "JSON — put real access control (firewall/security group) around"
|
|
||||||
echo "whatever bind address you give it, not just this compose file."
|
|
||||||
ask GATEWAY_CONTROL_PLANE_BIND "Bind address:port to expose it on (e.g. 10.0.0.5:8090)" ""
|
|
||||||
ask GATEWAY_CONTROL_PLANE_TARGET "The same host:port, as YOUR Traefik will reach it" "$GATEWAY_CONTROL_PLANE_BIND"
|
|
||||||
GATEWAY_TRAEFIK_NETWORK=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- tokens (generated once, kept on re-run) ----
|
|
||||||
GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
|
|
||||||
GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
|
|
||||||
|
|
||||||
# ---- write .env ----
|
|
||||||
echo
|
|
||||||
echo "Writing $ENV_FILE"
|
|
||||||
{
|
|
||||||
echo "# Generated by ops/deploy.sh — re-run it to change any of these."
|
|
||||||
echo "GATEWAY_MODE=$GATEWAY_MODE"
|
|
||||||
echo "WG_EASY_URL=$WG_EASY_URL"
|
|
||||||
echo "WG_EASY_API_VERSION=$WG_EASY_API_VERSION"
|
|
||||||
[[ "$WG_EASY_API_VERSION" == "15" ]] && echo "WG_EASY_ADMIN_USERNAME=$WG_EASY_ADMIN_USERNAME"
|
|
||||||
[[ "$WG_EASY_API_VERSION" == "15" ]] && echo "GATEWAY_WG_ALLOWED_IPS=$GATEWAY_WG_ALLOWED_IPS"
|
|
||||||
echo "WG_EASY_ADMIN_PASSWORD=$WG_EASY_ADMIN_PASSWORD"
|
|
||||||
echo "GATEWAY_DYNAMIC_DIR_HOST=$GATEWAY_DYNAMIC_DIR_HOST"
|
|
||||||
echo "GATEWAY_CERT_RESOLVER=$GATEWAY_CERT_RESOLVER"
|
|
||||||
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
|
||||||
echo "GATEWAY_CONTROL_PLANE_BIND=$GATEWAY_CONTROL_PLANE_BIND"
|
|
||||||
echo "GATEWAY_CONTROL_PLANE_TARGET=$GATEWAY_CONTROL_PLANE_TARGET"
|
|
||||||
[[ -n "$GATEWAY_TRAEFIK_NETWORK" ]] && echo "GATEWAY_TRAEFIK_NETWORK=$GATEWAY_TRAEFIK_NETWORK"
|
|
||||||
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
|
||||||
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
|
||||||
} > "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
|
|
||||||
# ---- render traefik/dynamic/control-plane.yml ----
|
|
||||||
echo "Rendering traefik/dynamic/control-plane.yml"
|
|
||||||
mkdir -p "$GATEWAY_DYNAMIC_DIR_HOST"
|
|
||||||
# Only the http: block onward — the .example's header comment explains the
|
|
||||||
# placeholders BY NAME (for a human reading that template), which would
|
|
||||||
# otherwise also get corrupted by these same substitutions.
|
|
||||||
sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
||||||
sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
||||||
sed -i "s|GATEWAY_CONTROL_PLANE_TARGET|$GATEWAY_CONTROL_PLANE_TARGET|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
||||||
if [[ -z "$GATEWAY_CERT_RESOLVER" ]]; then
|
|
||||||
# No resolver configured on your Traefik — drop the tls: block entirely
|
|
||||||
# so its own default cert applies, mirroring what GATEWAY_CERT_RESOLVER
|
|
||||||
# blank means for every app route (see traefikconf.py).
|
|
||||||
sed -i "/^ tls:$/,/^ certResolver:.*$/d" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
||||||
else
|
|
||||||
sed -i "s|certResolver: le|certResolver: $GATEWAY_CERT_RESOLVER|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
|
||||||
echo
|
|
||||||
echo "--render-only: stopping before compose up / network join / health check."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- bring the control-plane up ----
|
|
||||||
echo
|
|
||||||
echo "== docker compose up -d =="
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# ---- join your Traefik's network, if that's the reachability choice ----
|
|
||||||
if [[ -n "$GATEWAY_TRAEFIK_NETWORK" ]]; then
|
|
||||||
echo
|
|
||||||
echo "== joining docker network: $GATEWAY_TRAEFIK_NETWORK =="
|
|
||||||
docker network connect "$GATEWAY_TRAEFIK_NETWORK" furtka-gateway-control-plane 2>/dev/null \
|
|
||||||
|| echo " (already connected, or connect failed — check \`docker network ls\` and the name above)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- health check ----
|
|
||||||
echo
|
|
||||||
echo "== health check =="
|
|
||||||
for _ in $(seq 1 10); do
|
|
||||||
if curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then
|
|
||||||
echo "control-plane is up."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
if ! curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then
|
|
||||||
echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== done =="
|
|
||||||
echo "Still needed before a real box can connect:"
|
|
||||||
echo " - Add $GATEWAY_DYNAMIC_DIR_HOST as a providers.file directory on"
|
|
||||||
echo " your Traefik, if it isn't already watching it."
|
|
||||||
echo " - Verify Traefik can actually reach wg-easy's WireGuard peer subnet"
|
|
||||||
echo " (the README's Prerequisites checklist has a command for this) —"
|
|
||||||
echo " this is the one thing that can't be checked from here."
|
|
||||||
echo " - DNS: A/AAAA for $GATEWAY_CONTROL_PLANE_HOST -> wherever your"
|
|
||||||
echo " Traefik is reachable."
|
|
||||||
echo " - On the box: furtka gateway connect <this gateway's URL> <GATEWAY_BOX_TOKEN from .env> <box name>"
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Restrict wg-easy's admin API (tcp/51821, or GATEWAY_WG_ADMIN_PORT if
|
# Restrict wg-easy's admin API (tcp/51821) to loopback and the gateway's own
|
||||||
# overridden) to loopback and the gateway's own docker bridge network. Run
|
# docker bridge network. Run this once on the VPS after `docker compose up
|
||||||
# this once on the VPS after `docker compose up -d`, and again if the
|
# -d`, and again if the "internal" network's subnet ever changes.
|
||||||
# "internal" network's subnet ever changes.
|
|
||||||
#
|
|
||||||
# `sudo` does not inherit your shell's env or read .env for you — if you
|
|
||||||
# overrode GATEWAY_WG_ADMIN_PORT, run:
|
|
||||||
# set -a; source .env; set +a; sudo -E ops/firewall.sh
|
|
||||||
#
|
#
|
||||||
# Why this exists: wg-easy runs with network_mode: host (see
|
# Why this exists: wg-easy runs with network_mode: host (see
|
||||||
# docker-compose.yaml's comment on why — Traefik needs a route to
|
# docker-compose.yaml's comment on why — Traefik needs a route to
|
||||||
|
|
@ -29,10 +24,7 @@
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Must match GATEWAY_WG_ADMIN_PORT in .env if you overrode it (e.g.
|
ADMIN_PORT=51821
|
||||||
# GATEWAY_MODE=single-parallel, where 51821 is already taken by another
|
|
||||||
# wg-easy on this host).
|
|
||||||
ADMIN_PORT="${GATEWAY_WG_ADMIN_PORT:-51821}"
|
|
||||||
# Must match docker-compose.yaml's networks.internal.ipam.config subnet.
|
# Must match docker-compose.yaml's networks.internal.ipam.config subnet.
|
||||||
DOCKER_BRIDGE_SUBNET="172.28.0.0/24"
|
DOCKER_BRIDGE_SUBNET="172.28.0.0/24"
|
||||||
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from control_plane.wgeasy_v15 import WgEasyV15Client, WgEasyV15Error
|
|
||||||
|
|
||||||
USERNAME = "admin"
|
|
||||||
PASSWORD = "hunter2"
|
|
||||||
_AUTH_HEADER = "Basic " + base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode()
|
|
||||||
|
|
||||||
FAKE_CONF = """[Interface]
|
|
||||||
PrivateKey = client-private-key==
|
|
||||||
Address = 10.8.0.2/32
|
|
||||||
MTU = 1420
|
|
||||||
DNS = 1.1.1.1
|
|
||||||
|
|
||||||
[Peer]
|
|
||||||
PublicKey = server-public-key==
|
|
||||||
PresharedKey = preshared-key==
|
|
||||||
AllowedIPs = 10.8.0.0/24
|
|
||||||
PersistentKeepalive = 0
|
|
||||||
Endpoint = gateway.example.com:51820
|
|
||||||
"""
|
|
||||||
|
|
||||||
CLIENT_RECORD = {
|
|
||||||
"id": 1,
|
|
||||||
"name": "box-1",
|
|
||||||
"enabled": True,
|
|
||||||
"publicKey": "client-public-key==",
|
|
||||||
}
|
|
||||||
|
|
||||||
USERCONFIG = {
|
|
||||||
"id": "wg0",
|
|
||||||
"defaultMtu": 1420,
|
|
||||||
"defaultAllowedIps": ["0.0.0.0/0", "::/0"],
|
|
||||||
"host": "gateway.example.com",
|
|
||||||
"port": 51820,
|
|
||||||
"createdAt": "2026-01-01 00:00:00",
|
|
||||||
"updatedAt": "2026-01-01 00:00:00",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FakeWgEasyV15Handler(BaseHTTPRequestHandler):
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _json(self, status, payload):
|
|
||||||
body = json.dumps(payload).encode()
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def _check_auth(self) -> bool:
|
|
||||||
ok = self.headers.get("Authorization") == _AUTH_HEADER
|
|
||||||
if not ok:
|
|
||||||
self._json(401, {"error": True, "statusMessage": "Not authenticated"})
|
|
||||||
return ok
|
|
||||||
|
|
||||||
def _body(self) -> dict:
|
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
|
||||||
return json.loads(self.rfile.read(length)) if length else {}
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
self.server.requests.append(("POST", self.path))
|
|
||||||
if not self._check_auth():
|
|
||||||
return
|
|
||||||
if self.path == "/api/client":
|
|
||||||
body = self._body()
|
|
||||||
assert body["expiresAt"] is None
|
|
||||||
self._json(200, {"success": True, "clientId": CLIENT_RECORD["id"]})
|
|
||||||
return
|
|
||||||
if self.path == "/api/admin/userconfig":
|
|
||||||
self.server.last_userconfig_post = self._body()
|
|
||||||
self._json(200, {"success": True})
|
|
||||||
return
|
|
||||||
self._json(404, {"error": True, "statusMessage": "not found"})
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
self.server.requests.append(("GET", self.path))
|
|
||||||
if not self._check_auth():
|
|
||||||
return
|
|
||||||
if self.path == "/api/client":
|
|
||||||
self._json(200, [CLIENT_RECORD])
|
|
||||||
return
|
|
||||||
if self.path == f"/api/client/{CLIENT_RECORD['id']}/configuration":
|
|
||||||
body = FAKE_CONF.encode()
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "text/plain")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
return
|
|
||||||
if self.path == "/api/admin/userconfig":
|
|
||||||
self._json(200, USERCONFIG)
|
|
||||||
return
|
|
||||||
self._json(404, {"error": True, "statusMessage": "not found"})
|
|
||||||
|
|
||||||
def do_DELETE(self):
|
|
||||||
self.server.requests.append(("DELETE", self.path))
|
|
||||||
if not self._check_auth():
|
|
||||||
return
|
|
||||||
if self.path == f"/api/client/{CLIENT_RECORD['id']}":
|
|
||||||
self._json(200, {"success": True})
|
|
||||||
return
|
|
||||||
self._json(404, {"error": True, "statusMessage": "not found"})
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_wgeasy():
|
|
||||||
server = ThreadingHTTPServer(("127.0.0.1", 0), FakeWgEasyV15Handler)
|
|
||||||
server.requests = []
|
|
||||||
server.last_userconfig_post = None
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
try:
|
|
||||||
yield server
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
thread.join()
|
|
||||||
|
|
||||||
|
|
||||||
def _client(server, username=USERNAME, password=PASSWORD):
|
|
||||||
port = server.server_address[1]
|
|
||||||
return WgEasyV15Client(f"http://127.0.0.1:{port}", username, password)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_client_returns_full_connection_material(fake_wgeasy):
|
|
||||||
client = _client(fake_wgeasy)
|
|
||||||
|
|
||||||
peer = client.create_client("box-1")
|
|
||||||
|
|
||||||
assert peer["id"] == CLIENT_RECORD["id"]
|
|
||||||
assert peer["public_key"] == "client-public-key=="
|
|
||||||
assert peer["private_key"] == "client-private-key=="
|
|
||||||
assert peer["address"] == "10.8.0.2/32"
|
|
||||||
assert peer["server_public_key"] == "server-public-key=="
|
|
||||||
assert peer["preshared_key"] == "preshared-key=="
|
|
||||||
assert peer["endpoint"] == "gateway.example.com:51820"
|
|
||||||
assert peer["allowed_ips"] == "10.8.0.0/24"
|
|
||||||
|
|
||||||
paths_hit = [(m, p) for m, p in fake_wgeasy.requests]
|
|
||||||
assert ("POST", "/api/client") in paths_hit
|
|
||||||
assert ("GET", "/api/client") in paths_hit
|
|
||||||
assert ("GET", f"/api/client/{CLIENT_RECORD['id']}/configuration") in paths_hit
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_request_uses_basic_auth_and_wrong_password_fails(fake_wgeasy):
|
|
||||||
client = _client(fake_wgeasy, password="wrong-password")
|
|
||||||
with pytest.raises(WgEasyV15Error):
|
|
||||||
client.create_client("box-1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_client_calls_endpoint(fake_wgeasy):
|
|
||||||
client = _client(fake_wgeasy)
|
|
||||||
client.delete_client(str(CLIENT_RECORD["id"]))
|
|
||||||
assert ("DELETE", f"/api/client/{CLIENT_RECORD['id']}") in [
|
|
||||||
(m, p) for m, p in fake_wgeasy.requests
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_client_swallows_not_found(fake_wgeasy):
|
|
||||||
client = _client(fake_wgeasy)
|
|
||||||
client.delete_client("999") # 404 from fake server — must not raise
|
|
||||||
|
|
||||||
|
|
||||||
def test_restrict_default_allowed_ips_sends_full_config_back(fake_wgeasy):
|
|
||||||
client = _client(fake_wgeasy)
|
|
||||||
|
|
||||||
client.restrict_default_allowed_ips(["10.8.0.0/24"])
|
|
||||||
|
|
||||||
sent = fake_wgeasy.last_userconfig_post
|
|
||||||
assert sent["defaultAllowedIps"] == ["10.8.0.0/24"]
|
|
||||||
# The full current config must be echoed back, not a partial patch —
|
|
||||||
# confirmed live against v15 that a partial body 400s.
|
|
||||||
assert sent["defaultMtu"] == USERCONFIG["defaultMtu"]
|
|
||||||
assert sent["host"] == USERCONFIG["host"]
|
|
||||||
# Server-managed fields must not be sent back.
|
|
||||||
assert "id" not in sent
|
|
||||||
assert "createdAt" not in sent
|
|
||||||
assert "updatedAt" not in sent
|
|
||||||
|
|
||||||
|
|
||||||
def test_bad_endpoint_raises_wgeasy_v15_error(fake_wgeasy):
|
|
||||||
client = _client(fake_wgeasy)
|
|
||||||
with pytest.raises(WgEasyV15Error):
|
|
||||||
client._request("GET", "/api/does-not-exist")
|
|
||||||
|
|
@ -1,43 +1,15 @@
|
||||||
# Copy to traefik/dynamic/control-plane.yml (into whatever directory YOUR
|
# Copy to traefik/dynamic/control-plane.yml with GATEWAY_CONTROL_PLANE_HOST
|
||||||
# Traefik's file provider watches — see the root README's Prerequisites
|
# substituted for a real hostname you control (e.g. gw.example.com, with its
|
||||||
# checklist) with two placeholders substituted:
|
# own A/AAAA record pointing at this VPS).
|
||||||
#
|
|
||||||
# GATEWAY_CONTROL_PLANE_HOST a real hostname you control (e.g.
|
|
||||||
# gw.example.com), with its own A/AAAA record
|
|
||||||
# pointing at this host.
|
|
||||||
# GATEWAY_CONTROL_PLANE_TARGET where YOUR Traefik actually reaches the
|
|
||||||
# control-plane container. If it joined your
|
|
||||||
# Traefik's docker network (ops/deploy.sh
|
|
||||||
# does this via `docker network connect` —
|
|
||||||
# the primary, recommended path), this is
|
|
||||||
# `furtka-gateway-control-plane:8090` — the
|
|
||||||
# container's fixed name, which Docker's
|
|
||||||
# embedded DNS resolves on any network it's
|
|
||||||
# joined to (unlike the compose *service*
|
|
||||||
# name `control-plane`, which is only
|
|
||||||
# guaranteed to resolve on networks compose
|
|
||||||
# itself manages — not one joined after the
|
|
||||||
# fact via a bare `docker network connect`).
|
|
||||||
# If instead you exposed it on a separate
|
|
||||||
# host (GATEWAY_CONTROL_PLANE_BIND widened
|
|
||||||
# beyond 127.0.0.1 in docker-compose.yaml),
|
|
||||||
# this is that host's real reachable
|
|
||||||
# `host:port` — see ops/deploy.sh's warning
|
|
||||||
# about the access-control implication of
|
|
||||||
# that path.
|
|
||||||
#
|
#
|
||||||
# Without this file, the control-plane API is only reachable at
|
# Without this file, the control-plane API is only reachable at
|
||||||
# 127.0.0.1:8090 on this host itself — fine for a local health check,
|
# 127.0.0.1:8090 on the gateway host itself — fine for a local health
|
||||||
# useless for a real remote Furtka box, which needs this over HTTPS
|
# check, useless for a real remote Furtka box, which needs this over HTTPS
|
||||||
# anyway: the registration response carries a WireGuard private key in
|
# anyway: the registration response carries a WireGuard private key in
|
||||||
# plaintext JSON, so that call must never go out over bare HTTP.
|
# plaintext JSON, so that call must never go out over bare HTTP.
|
||||||
#
|
#
|
||||||
# GATEWAY_MODE=single: keep the certResolver line below, set to whatever
|
# GATEWAY_MODE=single: keep the certResolver line below (HTTP-01 per host,
|
||||||
# resolver name YOUR Traefik already has configured (blank/omit the `tls:`
|
# same as every app route — see traefik.single.yml).
|
||||||
# block entirely if your Traefik applies a default cert automatically —
|
|
||||||
# see GATEWAY_CERT_RESOLVER in .env.example, which controls this same
|
|
||||||
# choice for every *app* route furtka-gateway publishes; keep both
|
|
||||||
# consistent).
|
|
||||||
# GATEWAY_MODE=shared: delete the `tls:` block entirely — the shared
|
# GATEWAY_MODE=shared: delete the `tls:` block entirely — the shared
|
||||||
# wildcard defaultGeneratedCert (traefik/dynamic/default-cert.yml) already
|
# wildcard defaultGeneratedCert (traefik/dynamic/default-cert.yml) already
|
||||||
# covers every hostname under GATEWAY_BASE_DOMAIN, this included.
|
# covers every hostname under GATEWAY_BASE_DOMAIN, this included.
|
||||||
|
|
@ -53,4 +25,4 @@ http:
|
||||||
control-plane:
|
control-plane:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
servers:
|
servers:
|
||||||
- url: "http://GATEWAY_CONTROL_PLANE_TARGET"
|
- url: "http://control-plane:8090"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ providers:
|
||||||
certificatesResolvers:
|
certificatesResolvers:
|
||||||
le:
|
le:
|
||||||
acme:
|
acme:
|
||||||
email: admin@example.com # overridden per-deployment; see deploy.sh
|
email: admin@example.com # overridden per-deployment; see ops/deploy.sh
|
||||||
storage: /acme/acme.json
|
storage: /acme/acme.json
|
||||||
httpChallenge:
|
httpChallenge:
|
||||||
entryPoint: web
|
entryPoint: web
|
||||||
Loading…
Add table
Reference in a new issue