Compare commits
No commits in common. "1b2cba2ded12e5ce759b66dcbcf7892e8dc32a3b" and "e240f071d527bbf0216ff63969e290d8547eecf4" have entirely different histories.
1b2cba2ded
...
e240f071d5
13 changed files with 684 additions and 688 deletions
14
.env.example
14
.env.example
|
|
@ -58,10 +58,12 @@ GATEWAY_CERT_RESOLVER=
|
||||||
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
|
# Where the control-plane's own API is bound (docker-compose.yaml's port
|
||||||
# publish) and where your Traefik reaches it, respectively. For the
|
# publish) and where your Traefik reaches it, respectively. Same value
|
||||||
# recommended same-docker-network setup, ops/deploy.sh keeps the bind
|
# for the recommended same-docker-network setup (ops/deploy.sh sets both
|
||||||
# loopback-only and sets the target to the fixed container name
|
# to furtka-gateway-control-plane:8090 automatically, with the bind
|
||||||
# furtka-gateway-control-plane:8090.
|
# 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
|
# Different values only for a separate-host setup, where
|
||||||
# GATEWAY_CONTROL_PLANE_BIND must be widened beyond 127.0.0.1 — put real
|
# 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
|
# access control around that bind address, this file carries a WireGuard
|
||||||
|
|
@ -72,8 +74,8 @@ GATEWAY_CONTROL_PLANE_TARGET=furtka-gateway-control-plane:8090
|
||||||
# Same-docker-network setup only (the recommended path — see the README's
|
# Same-docker-network setup only (the recommended path — see the README's
|
||||||
# Prerequisites checklist): your Traefik's docker network name
|
# Prerequisites checklist): your Traefik's docker network name
|
||||||
# (`docker network ls` / `docker inspect <your-traefik-container>`).
|
# (`docker network ls` / `docker inspect <your-traefik-container>`).
|
||||||
# ops/deploy.sh writes a docker-compose.override.yaml that joins this
|
# ops/deploy.sh runs `docker network connect` with this after bringing the
|
||||||
# network. Leave blank for a separate-host setup.
|
# stack up. Leave blank for a separate-host setup.
|
||||||
GATEWAY_TRAEFIK_NETWORK=
|
GATEWAY_TRAEFIK_NETWORK=
|
||||||
|
|
||||||
# The one box token a single-tenant deployment accepts at
|
# The one box token a single-tenant deployment accepts at
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,6 +10,3 @@ 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
|
traefik/static/traefik.active.yml
|
||||||
examples/starter-stack/dynamic/
|
|
||||||
rendered/
|
|
||||||
docker-compose.override.yaml
|
|
||||||
|
|
|
||||||
309
README.md
309
README.md
|
|
@ -1,206 +1,129 @@
|
||||||
# furtka-gateway
|
# furtka-gateway
|
||||||
|
|
||||||
Lets a Furtka box — typically at home behind NAT or CGNAT, with no public IP —
|
WireGuard (wg-easy) + Traefik reverse-proxy that lets a Furtka box — typically
|
||||||
publish individual apps on the internet under a real domain, with automatic
|
sitting behind NAT/CGNAT with no public IP — expose individual apps to the
|
||||||
HTTPS.
|
internet under a real domain with automatic TLS.
|
||||||
|
|
||||||
```
|
A Furtka box becomes a WireGuard peer of this gateway. Your Traefik
|
||||||
internet ──443──▶ Traefik ──WireGuard tunnel──▶ Furtka box (behind NAT) ──▶ app
|
reverse-proxies public HTTPS traffic over that tunnel to whichever apps the
|
||||||
▲
|
box owner has explicitly published. Only apps whose Furtka `manifest.json`
|
||||||
Furtka box ──51820/udp──▶ wg-easy (box dials out; no port forwarding at home)
|
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
|
||||||
|
anything on its own.
|
||||||
|
|
||||||
A box connects as a WireGuard peer of wg-easy. Traefik then reverse-proxies
|
## What this actually is
|
||||||
HTTPS traffic through that tunnel to the apps the box owner has explicitly
|
|
||||||
published. Only apps whose Furtka manifest declares `internet.viable: true` can
|
|
||||||
be published at all, and each one is a separate opt-in on the box — the
|
|
||||||
gateway never exposes anything by itself.
|
|
||||||
|
|
||||||
This repo's own code is the **control-plane** (`control_plane/`, one small
|
The control-plane (`control_plane/`, one small stdlib-only Docker image) is
|
||||||
stdlib-only Docker image). It creates WireGuard peers in wg-easy when a box
|
all this repo ships. It does two things: talks to wg-easy's admin API to
|
||||||
registers, and writes Traefik file-provider routes when a box publishes an app.
|
provision WireGuard peers for boxes, and writes plain Traefik file-provider
|
||||||
|
YAML for the routes those boxes publish. It does not own or bundle a Traefik
|
||||||
|
or wg-easy of its own — **you plug it into a Traefik and wg-easy you already
|
||||||
|
run** (a very common self-hosting pair), the same way you'd plug any other
|
||||||
|
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.
|
||||||
|
|
||||||
## Choose your setup
|
Don't already run Traefik or wg-easy? See
|
||||||
|
[`examples/starter-stack/`](examples/starter-stack/) — a zero-infra on-ramp
|
||||||
|
that bundles both.
|
||||||
|
|
||||||
| | [Option A: New server](#option-a-new-server) | [Option B: Existing Traefik + wg-easy](#option-b-existing-traefik--wg-easy) |
|
`GATEWAY_MODE` is purely a tenancy switch: `single` (one operator, the
|
||||||
|---|---|---|
|
common case) or `shared` (one larger, multi-tenant instance — same schema,
|
||||||
| For | An empty VPS | A server that already runs Traefik and wg-easy |
|
same code, out of scope for the walkthrough below). It has no effect on
|
||||||
| Runs | wg-easy + Traefik + control-plane | control-plane only |
|
ports, certs, or which Traefik/wg-easy you use — that's all just your own
|
||||||
| Setup | `examples/starter-stack/deploy.sh`, 4 prompts | `ops/deploy.sh`, plus one routing step you do yourself |
|
infrastructure now.
|
||||||
|
|
||||||
## Option A: New server
|
Status: **Phase 2** — the control plane (accounts/boxes/routes, wg-easy
|
||||||
|
peer provisioning, Traefik dynamic-config generation) is built and tested
|
||||||
|
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
|
||||||
|
plan, not yet implemented — see the plan doc for the phased delivery.
|
||||||
|
|
||||||
Full guide, including server and DNS requirements:
|
## Prerequisites checklist
|
||||||
**[examples/starter-stack/README.md](examples/starter-stack/README.md)**.
|
|
||||||
|
|
||||||
In short: a Linux VPS with Docker, ports `80/tcp`, `443/tcp`, `51820/udp`
|
Read this before running `ops/deploy.sh` — a couple of these can't be
|
||||||
open, and a wildcard DNS record `*.example.com` pointing at it. Then:
|
verified from the script itself, and a deploy that skips them fails in
|
||||||
|
confusing ways later.
|
||||||
|
|
||||||
|
- **A running Traefik**, with a file-provider directory you can point
|
||||||
|
`GATEWAY_DYNAMIC_DIR_HOST` at (either one it 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).
|
||||||
|
- **A running wg-easy.** Know its version — `ghcr.io/wg-easy/wg-easy:14`
|
||||||
|
and `:15` are confirmed supported (`control_plane/wgeasy.py` and
|
||||||
|
`wgeasy_v15.py` respectively — these are genuinely different,
|
||||||
|
incompatible APIs, not just version numbers; each module's own docstring
|
||||||
|
documents exactly what was verified, live, against a real container).
|
||||||
|
Anything else is unverified.
|
||||||
|
- **Traefik must have an actual routed network path to wg-easy's WireGuard
|
||||||
|
peer subnet** (default `10.8.0.0/24`). This is the one thing that can't
|
||||||
|
be automated or checked from `ops/deploy.sh` — it depends entirely on
|
||||||
|
how you've deployed the two. A plain bridge-networked wg-easy container
|
||||||
|
(Docker's default) will **not** work: `wg0`'s route only exists inside
|
||||||
|
wg-easy's own network namespace unless wg-easy runs with
|
||||||
|
`network_mode: host`, a macvlan, or explicit host routing that bridges
|
||||||
|
its WireGuard interface out to wherever Traefik is. Verify this
|
||||||
|
yourself before running `ops/deploy.sh`: register a test box (or use
|
||||||
|
any existing WireGuard peer's address), then from Traefik's container,
|
||||||
|
`wget -qO- http://10.8.0.x:<some-port>` (or `ping 10.8.0.x`) should
|
||||||
|
succeed. If it doesn't, fix your wg-easy/Traefik network setup first —
|
||||||
|
nothing in this repo can route around it.
|
||||||
|
- **How Traefik reaches the control-plane container itself.** The
|
||||||
|
recommended path is joining your Traefik's own docker network (
|
||||||
|
`ops/deploy.sh` does this for you via `docker network connect` after
|
||||||
|
`docker compose up`); a separate-host setup is also supported but needs
|
||||||
|
its own access control — see the script's prompts.
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
**`ops/deploy.sh`** prompts for all of the above, generates
|
||||||
|
`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
|
||||||
|
|
||||||
|
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
|
||||||
|
`.forgejo/workflows/build-image.yml` keeps published to this instance's
|
||||||
|
container registry on every push to `master` (tagged `latest` plus the
|
||||||
|
short commit sha) — no manual build/tag step needed. 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.
|
||||||
|
- `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
|
||||||
|
`control-plane` only talks to wg-easy/Traefik lazily on the first real
|
||||||
|
request, not at startup.
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://forgejo.sourcegate.online/robert/furtka-gateway.git
|
cp .env.example .env
|
||||||
cd furtka-gateway/examples/starter-stack && ./deploy.sh
|
# fill in WG_EASY_URL etc. for whatever Traefik/wg-easy you're testing
|
||||||
|
# against locally, or use examples/starter-stack/ for a fully bundled
|
||||||
|
# local smoke test.
|
||||||
|
docker compose up -d
|
||||||
|
curl http://127.0.0.1:8090/healthz
|
||||||
```
|
```
|
||||||
|
|
||||||
## Option B: Existing Traefik + wg-easy
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- **Traefik v3** with a **file provider directory** (`providers.file.directory`).
|
|
||||||
The control-plane writes one file per published app into it. If your Traefik
|
|
||||||
uses `providers.file.filename` today, switch it to a directory and move your
|
|
||||||
existing file into it — Traefik allows only one file provider.
|
|
||||||
- **wg-easy 14 or 15.** Their admin APIs differ; you tell the deploy script
|
|
||||||
which one you run. **On v15, the control-plane sets wg-easy's default
|
|
||||||
"Allowed IPs" for new clients to your peer subnet (split tunnel)** on every
|
|
||||||
start. Existing clients aren't touched, but new non-Furtka clients (phones,
|
|
||||||
laptops) also get split tunnel unless you edit them.
|
|
||||||
- **Docker Compose v2.24+** on the server.
|
|
||||||
- **A route from Traefik to wg-easy's peer subnet** (default `10.8.0.0/24`) —
|
|
||||||
see the next section. This is the one step you do yourself.
|
|
||||||
- **DNS:** an A record for the gateway API hostname (e.g. `gw.example.com`)
|
|
||||||
pointing at your Traefik, plus one per published app — or simply a wildcard
|
|
||||||
`*.example.com`. Any registrar works; certificates come from your Traefik's
|
|
||||||
existing resolver.
|
|
||||||
- **No new ports.** Traefik's 443 and wg-easy's 51820/udp are already open.
|
|
||||||
|
|
||||||
### Routing: Traefik must reach the WireGuard peers
|
|
||||||
|
|
||||||
Published routes point Traefik at a box's tunnel address (`10.8.0.x`). That
|
|
||||||
address only exists inside wg-easy's network namespace, so a standard setup
|
|
||||||
(wg-easy on a Docker bridge network) can't reach it. Pick whichever fits:
|
|
||||||
|
|
||||||
**1. wg-easy on a bridge network (most common)** — route the subnet through
|
|
||||||
wg-easy and NAT it. Verified on a live Traefik v3.7 + wg-easy v15 setup.
|
|
||||||
|
|
||||||
a. Pin wg-easy's address on Traefik's network (in wg-easy's compose file),
|
|
||||||
so the route below stays valid:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
networks:
|
|
||||||
traefik:
|
|
||||||
ipv4_address: 172.19.0.5 # any free address in that network's subnet
|
|
||||||
```
|
|
||||||
|
|
||||||
b. Route the peer subnet from the host to wg-easy, permanently
|
|
||||||
(`/etc/systemd/system/furtka-gateway-route.service`):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Route WireGuard peer subnet via wg-easy (furtka-gateway)
|
|
||||||
Requires=docker.service
|
|
||||||
After=docker.service
|
|
||||||
PartOf=docker.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
RemainAfterExit=yes
|
|
||||||
ExecStart=/bin/sh -c 'for i in $(seq 90); do ip route replace 10.8.0.0/24 via 172.19.0.5 && exit 0; sleep 2; done; exit 1'
|
|
||||||
ExecStop=/sbin/ip route del 10.8.0.0/24 via 172.19.0.5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload && sudo systemctl enable --now furtka-gateway-route
|
|
||||||
```
|
|
||||||
|
|
||||||
c. Make replies come back through the tunnel: in wg-easy's admin UI
|
|
||||||
(**Admin → Hooks**), append to **PostUp**
|
|
||||||
|
|
||||||
```
|
|
||||||
iptables -t nat -A POSTROUTING -s 172.19.0.0/16 -o wg0 -j MASQUERADE;
|
|
||||||
```
|
|
||||||
|
|
||||||
and to **PostDown** the same line with `-D` instead of `-A` (use your
|
|
||||||
Traefik network's subnet). Then restart wg-easy once.
|
|
||||||
|
|
||||||
**2. Traefik in wg-easy's network namespace** — no routes or NAT at all; this
|
|
||||||
is what [Option A](examples/starter-stack/) does. Only practical if you can
|
|
||||||
restructure both containers.
|
|
||||||
|
|
||||||
**3. wg-easy with `network_mode: host`** — wg0 then lives on the host. Needs
|
|
||||||
`net.ipv4.ip_forward=1` on the host, and a firewall rule keeping wg-easy's
|
|
||||||
admin port (51821) off the public interface, because host networking publishes
|
|
||||||
it.
|
|
||||||
|
|
||||||
**Check it**, once a box is connected:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec <your-traefik> ping -c1 10.8.0.<box>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://forgejo.sourcegate.online/robert/furtka-gateway.git
|
|
||||||
cd furtka-gateway
|
|
||||||
ops/deploy.sh --render-only # optional: inspect .env and rendered/control-plane.yml first
|
|
||||||
ops/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
It asks for your wg-easy URL, version and login, your Traefik's dynamic
|
|
||||||
directory, cert resolver and docker network, and the API hostname. It then
|
|
||||||
builds and starts the control-plane on your Traefik's network, installs its
|
|
||||||
route into the dynamic directory, and checks `https://<api-host>/healthz`.
|
|
||||||
|
|
||||||
Finding the answers:
|
|
||||||
|
|
||||||
| Prompt | Where to look |
|
|
||||||
|---|---|
|
|
||||||
| wg-easy URL | `http://<wg-easy container name>:51821` if it's on your Traefik's network |
|
|
||||||
| Version | The tag of your wg-easy image (`docker ps`) |
|
|
||||||
| Dynamic directory | The **host** path mounted at your Traefik's `providers.file.directory` |
|
|
||||||
| Cert resolver | `certificatesResolvers.<name>` in your Traefik's static config |
|
|
||||||
| Docker network | `docker inspect <traefik> --format '{{json .NetworkSettings.Networks}}'` |
|
|
||||||
|
|
||||||
Update later with `git pull && ops/deploy.sh --non-interactive`. The image is
|
|
||||||
always rebuilt from your checkout.
|
|
||||||
|
|
||||||
## Connect a box
|
|
||||||
|
|
||||||
Same for both options. On the Furtka box: **Settings → Gateway**, enter the
|
|
||||||
gateway URL (`https://gw.example.com`), the registration token
|
|
||||||
(`GATEWAY_BOX_TOKEN` in the server's `.env`) and any box name. Then on the Apps
|
|
||||||
page, open an app's **⋯** menu → **Expose to Internet**, and give it a hostname
|
|
||||||
covered by your DNS.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Cause / fix |
|
|
||||||
|---|---|
|
|
||||||
| Box: `Unit wg-quick@furtka-gw.service does not exist` | Box image lacks `wireguard-tools`. Install it (`pacman -S wireguard-tools`) or reinstall from a newer ISO, then click Connect again — the existing registration is reused. |
|
|
||||||
| Box says connected, but wg-easy shows no handshake | Older box builds don't write wg-easy v15's preshared key into the tunnel config. Update the box. |
|
|
||||||
| App URL gives 502 / timeout, but the box's tunnel is up | Traefik can't reach `10.8.0.x` — see [Routing](#routing-traefik-must-reach-the-wireguard-peers). |
|
|
||||||
| No "Expose to Internet" in an app's menu | That app's manifest lacks `internet.viable: true`. |
|
|
||||||
| `deploy.sh`: control-plane not healthy | `docker compose logs control-plane` — usually a wrong wg-easy URL or password. |
|
|
||||||
| `https://<api-host>/healthz` fails after deploy | DNS not pointing at Traefik yet, certificate still being issued, or Traefik isn't watching the dynamic directory. |
|
|
||||||
|
|
||||||
## Security notes
|
|
||||||
|
|
||||||
- A box's registration response contains its WireGuard private key, so the API
|
|
||||||
is only exposed through Traefik over HTTPS. The direct port is bound to
|
|
||||||
`127.0.0.1:8090`.
|
|
||||||
- `GATEWAY_BOX_TOKEN` lets anyone register a box. Treat it like a password;
|
|
||||||
rotate it by deleting it from `.env` and re-running the deploy script.
|
|
||||||
- Keep wg-easy's admin UI (port 51821) off the public internet.
|
|
||||||
|
|
||||||
## Docker Swarm (Option B only)
|
|
||||||
|
|
||||||
- `build:` doesn't work under Swarm. It uses the `image:` CI publishes on every
|
|
||||||
push to `master` (`latest` plus the short commit sha); set
|
|
||||||
`GATEWAY_CONTROL_PLANE_IMAGE` in `.env` to pin one.
|
|
||||||
- `docker stack deploy` doesn't read `.env` — `set -a; source .env; set +a` first.
|
|
||||||
- Attach the service to your Traefik's network in the stack file's own
|
|
||||||
`networks:` section (the generated `docker-compose.override.yaml` isn't used).
|
|
||||||
- Option A's stack can't run under Swarm (`network_mode: service:`).
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest # control-plane tests
|
|
||||||
ruff check . && ruff format --check .
|
|
||||||
```
|
|
||||||
|
|
||||||
`GATEWAY_MODE` is a tenancy switch: `single` (one operator — everything above)
|
|
||||||
or `shared` (multi-tenant; same code, not covered here).
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
services:
|
services:
|
||||||
control-plane:
|
control-plane:
|
||||||
build: ./control_plane
|
build: ./control_plane
|
||||||
# Always build from this checkout — never pull a registry image that may
|
# CI (.forgejo/workflows/build-image.yml) publishes this same build to
|
||||||
# be older than the code you just pulled. The image name below only
|
# the registry on every push to master, tagged `latest` plus the short
|
||||||
# matters for `docker stack deploy` (Swarm can't build; see the Swarm
|
# commit sha. `docker compose up` builds fresh from `build:` above and
|
||||||
# caveats in README.md), which pulls what CI publishes on every push to
|
# tags the result as this name, ignoring this being a registry ref;
|
||||||
# master (`latest` plus the short commit sha).
|
# `docker stack deploy` (Swarm ignores `build:` entirely, see the
|
||||||
pull_policy: build
|
# 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}
|
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:
|
||||||
|
|
@ -57,10 +58,14 @@ services:
|
||||||
# implication of doing that when you choose it.
|
# implication of doing that when you choose it.
|
||||||
- "${GATEWAY_CONTROL_PLANE_BIND:-127.0.0.1:8090}:8090"
|
- "${GATEWAY_CONTROL_PLANE_BIND:-127.0.0.1:8090}:8090"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# No `networks:` here on purpose — your Traefik's network name differs
|
# No custom `networks:` here on purpose — this stays on the default
|
||||||
# per setup. ops/deploy.sh writes a gitignored
|
# bridge network compose creates unless you also run YOUR Traefik in
|
||||||
# docker-compose.override.yaml (auto-loaded by `docker compose`) that
|
# this SAME compose project. To reach a Traefik running elsewhere (its
|
||||||
# joins this container to it from the first second.
|
# own compose project, or your own homelab stack), ops/deploy.sh joins
|
||||||
|
# this container to your Traefik's existing docker network after
|
||||||
|
# `up` via `docker network connect` — no external-network dependency
|
||||||
|
# baked into this file, so it works whether or not that network
|
||||||
|
# exists yet at compose-up time.
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
control_plane_data:
|
control_plane_data:
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,73 @@
|
||||||
# furtka-gateway on a new server
|
# furtka-gateway starter stack
|
||||||
|
|
||||||
Everything in one Docker Compose stack: **wg-easy** (WireGuard), **Traefik**
|
Zero-infra on-ramp: bundles its own Traefik + wg-easy alongside the
|
||||||
(HTTPS reverse proxy with Let's Encrypt), and the **furtka-gateway
|
control-plane, for an operator who doesn't already run either. This is a
|
||||||
control-plane**. Use this on a server that doesn't run anything else on
|
secondary path — if you already have Traefik and/or wg-easy running
|
||||||
ports 80/443. If you already run Traefik and wg-easy, see
|
(a common self-hosting setup), use the main repo's root `docker-compose.yaml`
|
||||||
[the root README](../../README.md#option-b-existing-traefik--wg-easy) instead.
|
+ `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`,
|
||||||
internet ──443──▶ Traefik ──WireGuard tunnel──▶ Furtka box (behind NAT) ──▶ app
|
`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
|
||||||
Furtka box ──51820/udp──▶ wg-easy (box dials out; no port forwarding at home)
|
necessary elsewhere don't apply here, because this stack assumes nothing
|
||||||
```
|
else is already using those ports.
|
||||||
|
|
||||||
## Server requirements
|
## 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
|
||||||
| Machine | Any Linux VPS, x86_64 or arm64. 1 vCPU / 512 MB RAM is plenty. |
|
`GATEWAY_CONTROL_PLANE_HOST` (the registration API — needs its own
|
||||||
| Public IP | A static public IPv4 address. |
|
hostname since the response carries a WireGuard private key in
|
||||||
| Kernel | WireGuard support (Linux 5.6+ — every current distro). |
|
plaintext JSON, so that call must go over HTTPS).
|
||||||
| Software | Docker Engine with the Compose plugin **v2.24 or newer** (`docker compose version`), plus `git`, `curl`, `openssl`. |
|
2. Run `./deploy.sh` from this directory and answer its prompts. It
|
||||||
| Free ports | `80/tcp`, `443/tcp`, `51820/udp` — nothing else may use them. |
|
generates the wg-easy admin password hash, `GATEWAY_BOX_TOKEN`, and
|
||||||
| Firewall | Allow those three ports inbound, in the server's firewall *and* any provider firewall/security group. Nothing else needs to be open. |
|
`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.
|
||||||
|
|
||||||
Installing Docker on a fresh Debian/Ubuntu server:
|
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
|
```bash
|
||||||
curl -fsSL https://get.docker.com | sh
|
./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
|
||||||
```
|
```
|
||||||
|
|
||||||
## DNS requirements
|
`ops/firewall.sh` and the `control-plane.yml` Traefik route are about
|
||||||
|
reaching this gateway from the real internet — skip both for local dev.
|
||||||
|
|
||||||
Easiest: **one wildcard record** for your domain, pointing at the server.
|
## Docker Swarm caveats
|
||||||
|
|
||||||
| Type | Name | Value |
|
If deploying via `docker stack deploy` instead of `docker compose up`:
|
||||||
|---|---|---|
|
- `docker-compose.yaml`'s `build: ../../control_plane` doesn't work under
|
||||||
| A | `*.example.com` | your server's IPv4 |
|
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
|
||||||
That covers everything at once:
|
keeps published to the registry on every push to `master` (tagged
|
||||||
|
`latest` plus the short commit sha). Set `GATEWAY_CONTROL_PLANE_IMAGE` in
|
||||||
- `vpn.example.com` — the WireGuard endpoint boxes dial
|
`.env` first if you want a specific commit-sha tag rather than whatever
|
||||||
- `gw.example.com` — the gateway API you enter on a box
|
`latest` currently points to.
|
||||||
- `<app>.example.com` — every app a box publishes later, with no DNS change per app
|
- `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`
|
||||||
No wildcard possible? Create A records for `vpn.` and `gw.`, plus one per app
|
first, or the `${VAR}` substitutions come out empty. **Do not `source`
|
||||||
before you publish it. Certificates use Let's Encrypt's HTTP challenge, so no
|
`.env` directly if `WG_EASY_PASSWORD_HASH` is set** — bash expands `$$`
|
||||||
DNS provider API is needed — any registrar works.
|
(its own PID) while sourcing, silently corrupting the hash's doubled-`$`
|
||||||
|
escaping. Export that one variable separately, unescaped, or set it
|
||||||
If you also add AAAA (IPv6) records, the server must be reachable over IPv6 on
|
directly on the `docker stack deploy` command line.
|
||||||
the same three ports.
|
- `depends_on` is silently ignored by Swarm — harmless here, since
|
||||||
|
`control-plane` only talks to wg-easy/Traefik lazily on the first real
|
||||||
## Install
|
request, not at startup.
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://forgejo.sourcegate.online/robert/furtka-gateway.git
|
|
||||||
cd furtka-gateway/examples/starter-stack
|
|
||||||
./deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
It asks for four things — your domain, the two hostnames (defaults
|
|
||||||
`vpn.<domain>` and `gw.<domain>`), and an email for Let's Encrypt — then
|
|
||||||
generates all passwords and tokens into `.env`, starts the stack, checks DNS
|
|
||||||
and health, and prints what to enter on a box.
|
|
||||||
|
|
||||||
`./deploy.sh --render-only` writes `.env` and `dynamic/control-plane.yml`
|
|
||||||
without starting anything. `./deploy.sh --non-interactive` takes every value
|
|
||||||
from an existing `.env`, for scripted installs.
|
|
||||||
|
|
||||||
## Connect a box
|
|
||||||
|
|
||||||
On the Furtka box: **Settings → Gateway**, enter
|
|
||||||
|
|
||||||
- **Gateway URL:** `https://gw.example.com`
|
|
||||||
- **Registration token:** `GATEWAY_BOX_TOKEN` from `.env`
|
|
||||||
- **Box name:** anything
|
|
||||||
|
|
||||||
Then publish an app from the Apps page: **⋯ → Expose to Internet**, and give it
|
|
||||||
a hostname like `tools.example.com`.
|
|
||||||
|
|
||||||
## Day to day
|
|
||||||
|
|
||||||
| Task | Command (in this folder) |
|
|
||||||
|---|---|
|
|
||||||
| Update | `git pull && ./deploy.sh --non-interactive` |
|
|
||||||
| Logs | `docker compose logs -f control-plane` (or `wg-easy`, `traefik`) |
|
|
||||||
| Restart | `docker compose restart` |
|
|
||||||
| wg-easy admin UI | `ssh -L 51821:127.0.0.1:51821 <server>`, then open http://localhost:51821 — user `admin`, password `WG_EASY_ADMIN_PASSWORD` from `.env` |
|
|
||||||
| Backup | `.env`, `dynamic/`, and the Docker volumes `*_wg_easy_data`, `*_control_plane_data`, `*_traefik_acme` |
|
|
||||||
|
|
||||||
Changed the wg-easy password in its UI? Put the new one in `.env` as
|
|
||||||
`WG_EASY_ADMIN_PASSWORD` and run `./deploy.sh --non-interactive`.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
wg-easy creates the `wg0` interface and the route to the boxes
|
|
||||||
(`10.8.0.0/24`) inside its own network namespace. Traefik must be in that same
|
|
||||||
namespace to reach a box's tunnel address. So both join one namespace owned by
|
|
||||||
`gateway-net`, a do-nothing container whose only job is to hold the namespace
|
|
||||||
and publish the ports. Because it never restarts, wg-easy and Traefik can each
|
|
||||||
restart independently without cutting the other off. No host routes, NAT rules,
|
|
||||||
sysctls or host networking are needed, and wg-easy's admin UI is never
|
|
||||||
published to the internet.
|
|
||||||
|
|
||||||
Two consequences:
|
|
||||||
|
|
||||||
- **Restart through Compose.** `docker compose restart` handles everything.
|
|
||||||
If `furtka-gateway-net` itself was restarted with a bare `docker restart`,
|
|
||||||
wg-easy and Traefik lose their network — fix with `docker compose restart`
|
|
||||||
(plain `docker compose up -d` does *not* repair it).
|
|
||||||
- **Compose only, not Swarm.** `network_mode: service:` isn't supported by
|
|
||||||
`docker stack deploy`.
|
|
||||||
|
|
||||||
Verified with a simulated box: route publishing, plus restarts of wg-easy,
|
|
||||||
Traefik and `gateway-net`, and a full stack stop/start. A real server reboot
|
|
||||||
hasn't been tested yet — after your first reboot, check that
|
|
||||||
`https://gw.example.com/healthz` answers.
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,33 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Sets up furtka-gateway on an empty server: wg-easy + Traefik +
|
# Interactive setup for the zero-infra starter stack: bundles its own
|
||||||
# control-plane, all from this folder's docker-compose.yaml. See README.md
|
# Traefik + wg-easy + control-plane. If you already run Traefik and/or
|
||||||
# here for the server and DNS requirements.
|
# wg-easy, use the main repo's root docker-compose.yaml + ops/deploy.sh
|
||||||
#
|
# instead — it plugs into what you already have.
|
||||||
# Safe to re-run — existing .env values become the defaults for each
|
|
||||||
# prompt (press Enter to keep them), and secrets are generated once.
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./deploy.sh prompts, writes .env + dynamic/, starts
|
# ./deploy.sh full setup: prompts, render, sysctls,
|
||||||
# the stack, health-checks it
|
# compose up, firewall, health check
|
||||||
# ./deploy.sh --non-interactive same, but no prompts: every value comes
|
# ./deploy.sh --render-only only (re)write .env and the rendered
|
||||||
# from .env (fails if one is missing)
|
# Traefik files — skips sysctls/compose/
|
||||||
# ./deploy.sh --render-only only (re)write .env and
|
# firewall/health-check.
|
||||||
# dynamic/control-plane.yml; starts nothing
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
RENDER_ONLY=0
|
RENDER_ONLY=0
|
||||||
INTERACTIVE=1
|
if [[ "${1:-}" == "--render-only" ]]; then
|
||||||
for arg in "$@"; do
|
RENDER_ONLY=1
|
||||||
case "$arg" in
|
fi
|
||||||
--render-only) RENDER_ONLY=1 ;;
|
|
||||||
--non-interactive) INTERACTIVE=0 ;;
|
|
||||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
ENV_FILE=.env
|
ENV_FILE=.env
|
||||||
|
|
||||||
# Deliberately not `source`d: bash would expand `$` in values.
|
# ---- 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() {
|
load_env_file() {
|
||||||
local file="$1" line key value
|
local file="$1" line key value
|
||||||
[[ -f "$file" ]] || return 0
|
[[ -f "$file" ]] || return 0
|
||||||
|
|
@ -42,47 +39,68 @@ load_env_file() {
|
||||||
done < "$file"
|
done < "$file"
|
||||||
}
|
}
|
||||||
load_env_file "$ENV_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() {
|
ask() {
|
||||||
# ask VARNAME "Prompt text" "fallback-default"
|
|
||||||
local __var="$1" __prompt="$2" __fallback="${3:-}"
|
local __var="$1" __prompt="$2" __fallback="${3:-}"
|
||||||
local __current="${!__var:-}"
|
local __current="${!__var:-}"
|
||||||
local __default="${__current:-$__fallback}"
|
local __default="${__current:-$__fallback}"
|
||||||
local __ans=""
|
local __ans
|
||||||
if [[ "$INTERACTIVE" -eq 1 ]]; then
|
if [[ -n "$__default" ]]; then
|
||||||
if [[ -n "$__default" ]]; then
|
read -r -p "$__prompt [$__default]: " __ans
|
||||||
read -r -p "$__prompt [$__default]: " __ans
|
else
|
||||||
else
|
read -r -p "$__prompt: " __ans
|
||||||
read -r -p "$__prompt: " __ans
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
printf -v "$__var" '%s' "${__ans:-$__default}"
|
printf -v "$__var" '%s' "${__ans:-$__default}"
|
||||||
if [[ -z "${!__var}" ]]; then
|
}
|
||||||
echo "$__var is required" >&2
|
|
||||||
exit 1
|
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
|
fi
|
||||||
|
echo
|
||||||
|
printf -v "$__var" '%s' "${__ans:-$__current}"
|
||||||
}
|
}
|
||||||
|
|
||||||
require_cmd() {
|
require_cmd() {
|
||||||
command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
|
command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
require_cmd openssl
|
|
||||||
if [[ "$RENDER_ONLY" -eq 0 ]]; then
|
if [[ "$RENDER_ONLY" -eq 0 ]]; then
|
||||||
require_cmd docker
|
require_cmd docker
|
||||||
require_cmd curl
|
require_cmd openssl
|
||||||
docker compose version >/dev/null 2>&1 || { echo "missing: docker compose plugin" >&2; exit 1; }
|
require_cmd sudo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== furtka-gateway: new server setup =="
|
echo "== furtka-gateway starter-stack deploy =="
|
||||||
echo
|
echo
|
||||||
ask GATEWAY_DOMAIN "Your domain (boxes publish apps as <name>.<domain>)" ""
|
|
||||||
ask GATEWAY_PUBLIC_HOST "WireGuard endpoint hostname" "vpn.$GATEWAY_DOMAIN"
|
|
||||||
ask GATEWAY_CONTROL_PLANE_HOST "Gateway API hostname (what you enter on the box)" "gw.$GATEWAY_DOMAIN"
|
|
||||||
ask GATEWAY_ACME_EMAIL "Email for Let's Encrypt certificate notices" ""
|
|
||||||
|
|
||||||
# Secrets: generated once, kept on re-run.
|
ask GATEWAY_PUBLIC_HOST "WireGuard endpoint hostname (A/AAAA record for this VPS)" "vpn.example.com"
|
||||||
WG_EASY_ADMIN_PASSWORD="${WG_EASY_ADMIN_PASSWORD:-$(openssl rand -hex 16)}"
|
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_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
|
GATEWAY_ADMIN_TOKEN="${GATEWAY_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
|
|
||||||
|
|
@ -90,97 +108,62 @@ echo
|
||||||
echo "Writing $ENV_FILE"
|
echo "Writing $ENV_FILE"
|
||||||
{
|
{
|
||||||
echo "# Generated by deploy.sh — re-run it to change any of these."
|
echo "# Generated by deploy.sh — re-run it to change any of these."
|
||||||
echo "GATEWAY_DOMAIN=$GATEWAY_DOMAIN"
|
|
||||||
echo "GATEWAY_PUBLIC_HOST=$GATEWAY_PUBLIC_HOST"
|
echo "GATEWAY_PUBLIC_HOST=$GATEWAY_PUBLIC_HOST"
|
||||||
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
||||||
echo "GATEWAY_ACME_EMAIL=$GATEWAY_ACME_EMAIL"
|
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 "WG_EASY_ADMIN_PASSWORD=$WG_EASY_ADMIN_PASSWORD"
|
||||||
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
||||||
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
||||||
[[ -n "${GATEWAY_CONTROL_PLANE_IMAGE:-}" ]] && echo "GATEWAY_CONTROL_PLANE_IMAGE=$GATEWAY_CONTROL_PLANE_IMAGE"
|
|
||||||
} > "$ENV_FILE"
|
} > "$ENV_FILE"
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
echo "Writing dynamic/control-plane.yml"
|
echo "Rendering traefik/static/traefik.active.yml"
|
||||||
mkdir -p dynamic
|
cp traefik/static/traefik.single.yml traefik/static/traefik.active.yml
|
||||||
cat > dynamic/control-plane.yml <<EOF
|
sed -i "s|admin@example.com|$GATEWAY_ACME_EMAIL|" traefik/static/traefik.active.yml
|
||||||
# Generated by deploy.sh — Traefik route for the gateway's own API.
|
|
||||||
http:
|
echo "Rendering traefik/dynamic/control-plane.yml"
|
||||||
routers:
|
# Only the http: block onward — the .example's header comment mentions the
|
||||||
control-plane:
|
# placeholder by name too (for a human reading that template), which would
|
||||||
rule: "Host(\`$GATEWAY_CONTROL_PLANE_HOST\`)"
|
# otherwise also get corrupted by this same substitution.
|
||||||
entryPoints: [websecure]
|
sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > traefik/dynamic/control-plane.yml
|
||||||
service: control-plane
|
sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" traefik/dynamic/control-plane.yml
|
||||||
tls:
|
|
||||||
certResolver: le
|
|
||||||
services:
|
|
||||||
control-plane:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "http://control-plane:8090"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
||||||
echo
|
echo
|
||||||
echo "--render-only: nothing started."
|
echo "--render-only: stopping before sysctls/compose/firewall/health-check."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- DNS check (warn only — records may still be propagating) ----
|
|
||||||
echo
|
echo
|
||||||
echo "== DNS =="
|
echo "== sudo ops/host-sysctls.sh =="
|
||||||
SERVER_IP="$(curl -4 -s -m 10 https://ifconfig.me || true)"
|
sudo "$SCRIPT_DIR/ops/host-sysctls.sh"
|
||||||
for host in "$GATEWAY_PUBLIC_HOST" "$GATEWAY_CONTROL_PLANE_HOST" "check-wildcard.$GATEWAY_DOMAIN"; do
|
|
||||||
resolved="$(getent ahostsv4 "$host" 2>/dev/null | awk 'NR==1{print $1}')"
|
|
||||||
if [[ -z "$resolved" ]]; then
|
|
||||||
echo " WARNING: $host does not resolve yet"
|
|
||||||
elif [[ -n "$SERVER_IP" && "$resolved" != "$SERVER_IP" ]]; then
|
|
||||||
echo " WARNING: $host -> $resolved, but this server is $SERVER_IP"
|
|
||||||
else
|
|
||||||
echo " ok: $host -> $resolved"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== docker compose up -d --build =="
|
echo "== docker compose up -d =="
|
||||||
docker compose up -d --build
|
docker compose up -d
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== sudo ops/firewall.sh =="
|
||||||
|
sudo "$SCRIPT_DIR/ops/firewall.sh"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== health check =="
|
echo "== health check =="
|
||||||
healthy=0
|
for _ in $(seq 1 10); do
|
||||||
for _ in $(seq 1 60); do
|
if curl -sf 127.0.0.1:8090/healthz >/dev/null; then
|
||||||
if curl -sf http://127.0.0.1:8090/healthz >/dev/null 2>&1; then
|
echo "control-plane is up."
|
||||||
healthy=1
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 1
|
||||||
done
|
done
|
||||||
if [[ "$healthy" -ne 1 ]]; then
|
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 wg-easy" >&2
|
echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo " ok: control-plane is up"
|
|
||||||
|
|
||||||
public_ok=0
|
|
||||||
for _ in $(seq 1 20); do
|
|
||||||
if curl -sf -m 10 "https://$GATEWAY_CONTROL_PLANE_HOST/healthz" >/dev/null 2>&1; then
|
|
||||||
public_ok=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
if [[ "$public_ok" -eq 1 ]]; then
|
|
||||||
echo " ok: https://$GATEWAY_CONTROL_PLANE_HOST/healthz (valid certificate)"
|
|
||||||
else
|
|
||||||
echo " WARNING: https://$GATEWAY_CONTROL_PLANE_HOST/healthz not reachable yet."
|
|
||||||
echo " Usually DNS or the certificate is still pending — check: docker compose logs traefik"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== done =="
|
echo "== done =="
|
||||||
echo "Connect a Furtka box: open its web UI -> Settings -> Gateway, and enter"
|
echo "Still needed before a real box can connect:"
|
||||||
echo " Gateway URL: https://$GATEWAY_CONTROL_PLANE_HOST"
|
echo " - DNS: A/AAAA for $GATEWAY_CONTROL_PLANE_HOST and $GATEWAY_PUBLIC_HOST -> this VPS's public IP"
|
||||||
echo " Registration token: the GATEWAY_BOX_TOKEN value in $SCRIPT_DIR/.env"
|
echo " - Open udp/51820, tcp/80, tcp/443 in your firewall/security group"
|
||||||
echo
|
echo " - On the box: furtka gateway connect <this gateway's URL> <GATEWAY_BOX_TOKEN from .env> <box name>"
|
||||||
echo "wg-easy admin UI (optional): ssh -L 51821:127.0.0.1:51821 <this server>,"
|
|
||||||
echo "then open http://localhost:51821 — user 'admin', password WG_EASY_ADMIN_PASSWORD in .env."
|
|
||||||
|
|
|
||||||
|
|
@ -1,131 +1,135 @@
|
||||||
# furtka-gateway on an empty server: wg-easy + Traefik + control-plane.
|
|
||||||
# Configure via .env (deploy.sh writes it — see README.md in this folder).
|
|
||||||
#
|
|
||||||
# The one trick: wg-easy and Traefik share one network namespace, owned
|
|
||||||
# by the tiny `gateway-net` container. wg0 and its 10.8.0.0/24 route live
|
|
||||||
# in that namespace, so Traefik has a direct path to every box's tunnel
|
|
||||||
# address — no host routes, NAT rules, host sysctls, or host-networked
|
|
||||||
# wg-easy needed. `gateway-net` does nothing else and never restarts, so
|
|
||||||
# wg-easy and Traefik can each restart without cutting the other off
|
|
||||||
# (a namespace owned by wg-easy itself would vanish on every wg-easy
|
|
||||||
# restart, leaving Traefik with no network at all).
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
gateway-net:
|
|
||||||
image: alpine:3.20
|
|
||||||
container_name: furtka-gateway-net
|
|
||||||
command: ["sleep", "infinity"]
|
|
||||||
init: true
|
|
||||||
ports:
|
|
||||||
# WireGuard
|
|
||||||
- "51820:51820/udp"
|
|
||||||
# Traefik
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
# wg-easy admin UI, loopback only: ssh -L 51821:127.0.0.1:51821 <server>
|
|
||||||
- "127.0.0.1:51821:51821"
|
|
||||||
sysctls:
|
|
||||||
- net.ipv4.ip_forward=1
|
|
||||||
- net.ipv4.conf.all.src_valid_mark=1
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
wg-easy:
|
wg-easy:
|
||||||
image: ghcr.io/wg-easy/wg-easy:15
|
image: ghcr.io/wg-easy/wg-easy:14
|
||||||
container_name: furtka-gateway-wg-easy
|
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:
|
environment:
|
||||||
# Unattended first-run setup (only applied while wg-easy's database
|
- WG_HOST=${GATEWAY_PUBLIC_HOST}
|
||||||
# is still empty — change the password later in its UI, then update
|
- PASSWORD_HASH=${WG_EASY_PASSWORD_HASH}
|
||||||
# WG_EASY_ADMIN_PASSWORD in .env to match).
|
- PORT=51821
|
||||||
- INIT_ENABLED=true
|
- WG_PORT=51820
|
||||||
- INIT_USERNAME=admin
|
# wg-easy's session/CSRF handling assumes HTTPS by default; we talk to
|
||||||
- INIT_PASSWORD=${WG_EASY_ADMIN_PASSWORD}
|
# it over plain HTTP from control-plane (see WG_EASY_URL below), which
|
||||||
- INIT_HOST=${GATEWAY_PUBLIC_HOST}
|
# is fine precisely because this port is never meant to be reachable
|
||||||
- INIT_PORT=51820
|
# from the public internet — see the ops/firewall.sh note.
|
||||||
# Split tunnel: boxes only route the gateway's peer subnet through
|
|
||||||
# the tunnel, never their whole internet traffic.
|
|
||||||
- INIT_ALLOWED_IPS=10.8.0.0/24
|
|
||||||
- DISABLE_IPV6=true
|
|
||||||
# The admin UI is only published on the server's loopback (see
|
|
||||||
# gateway-net) and reached over an SSH tunnel, so plain HTTP is fine.
|
|
||||||
- INSECURE=true
|
- 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:
|
volumes:
|
||||||
- wg_easy_data:/etc/wireguard
|
- wg_easy_data:/etc/wireguard
|
||||||
- /lib/modules:/lib/modules:ro
|
|
||||||
network_mode: service:gateway-net
|
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
depends_on:
|
|
||||||
gateway-net:
|
|
||||||
condition: service_started
|
|
||||||
restart: true
|
|
||||||
restart: unless-stopped
|
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:
|
traefik:
|
||||||
image: traefik:v3.7
|
image: traefik:v3.1
|
||||||
container_name: furtka-gateway-traefik
|
container_name: furtka-gateway-traefik
|
||||||
network_mode: service:gateway-net
|
|
||||||
command:
|
command:
|
||||||
- --entrypoints.web.address=:80
|
- --configFile=/etc/traefik/traefik.yml
|
||||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
|
||||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
|
||||||
- --entrypoints.websecure.address=:443
|
|
||||||
- --providers.file.directory=/dynamic
|
|
||||||
- --providers.file.watch=true
|
|
||||||
- --certificatesresolvers.le.acme.email=${GATEWAY_ACME_EMAIL}
|
|
||||||
- --certificatesresolvers.le.acme.storage=/acme/acme.json
|
|
||||||
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
|
||||||
- --log.level=INFO
|
|
||||||
volumes:
|
volumes:
|
||||||
# control-plane.yml (written by deploy.sh) + one route-*.yml per
|
# Rendered by deploy.sh from traefik/static/traefik.single.yml with
|
||||||
# published app (written by the control-plane).
|
# the ACME email substituted in — see that script. Gitignored, not
|
||||||
- ./dynamic:/dynamic:ro
|
# the tracked template.
|
||||||
|
- ./traefik/static/traefik.active.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic:/etc/traefik/dynamic:ro
|
||||||
- traefik_acme:/acme
|
- traefik_acme:/acme
|
||||||
depends_on:
|
ports:
|
||||||
gateway-net:
|
- "80:80"
|
||||||
condition: service_started
|
- "443:443"
|
||||||
restart: true
|
|
||||||
restart: unless-stopped
|
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:
|
control-plane:
|
||||||
build: ../../control_plane
|
build: ../../control_plane
|
||||||
# Always build from this checkout — never run a registry image that may
|
# CI (.forgejo/workflows/build-image.yml) publishes this same build to
|
||||||
# be older than the code you just pulled. (The image name only matters
|
# the registry on every push to master, tagged `latest` plus the short
|
||||||
# for Docker Swarm, which can't build; see README.md.)
|
# commit sha. `docker compose up` builds fresh from `build:` above and
|
||||||
pull_policy: build
|
# 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}
|
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=single
|
- GATEWAY_MODE=${GATEWAY_MODE:-single}
|
||||||
- GATEWAY_STATE_DIR=/data
|
- GATEWAY_STATE_DIR=/data
|
||||||
- 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}
|
||||||
- GATEWAY_CERT_RESOLVER=le
|
- GATEWAY_CERT_RESOLVER=le
|
||||||
- WG_EASY_URL=http://gateway-net:51821
|
# wg-easy is host-networked now (see above), so it's no longer a
|
||||||
- WG_EASY_API_VERSION=15
|
# fellow member of the "internal" bridge network reachable by service
|
||||||
- WG_EASY_ADMIN_USERNAME=admin
|
# 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}
|
- WG_EASY_ADMIN_PASSWORD=${WG_EASY_ADMIN_PASSWORD}
|
||||||
- GATEWAY_WG_ALLOWED_IPS=10.8.0.0/24
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- control_plane_data:/data
|
- control_plane_data:/data
|
||||||
- ./dynamic:/dynamic
|
- ./traefik/dynamic:/dynamic
|
||||||
ports:
|
ports:
|
||||||
# Loopback-only, for health checks from the server itself. Boxes
|
# Loopback-only convenience for local health checks/debugging
|
||||||
# reach the API through Traefik over HTTPS (dynamic/control-plane.yml).
|
# (`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"
|
- "127.0.0.1:8090:8090"
|
||||||
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
depends_on:
|
depends_on:
|
||||||
# The control-plane configures wg-easy on startup.
|
- traefik
|
||||||
wg-easy:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
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:
|
volumes:
|
||||||
wg_easy_data:
|
wg_easy_data:
|
||||||
|
|
|
||||||
60
examples/starter-stack/ops/firewall.sh
Executable file
60
examples/starter-stack/ops/firewall.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restrict wg-easy's admin API (tcp/51821, or GATEWAY_WG_ADMIN_PORT if
|
||||||
|
# overridden) to loopback and the gateway's own docker bridge network. Run
|
||||||
|
# this once on the VPS after `docker compose up -d`, and again if the
|
||||||
|
# "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
|
||||||
|
# docker-compose.yaml's comment on why — Traefik needs a route to
|
||||||
|
# WireGuard peer addresses that only exists in whatever network namespace
|
||||||
|
# wg-easy's wg0 interface lives in). Host networking means Docker itself
|
||||||
|
# can no longer keep wg-easy's admin port off the VPS's public interface
|
||||||
|
# the way it does for every other container here — this script is that
|
||||||
|
# missing piece, done with iptables instead.
|
||||||
|
#
|
||||||
|
# NOT yet validated against a real multi-interface VPS. Review
|
||||||
|
# DOCKER_BRIDGE_SUBNET against your actual `docker network inspect
|
||||||
|
# furtka-gateway_internal` output before relying on this as your only line
|
||||||
|
# of defense. A cloud provider security group that blocks 51821/tcp
|
||||||
|
# entirely is a good belt-and-suspenders addition on top of this — wg-easy's
|
||||||
|
# admin API has no legitimate reason to ever be reached from outside this
|
||||||
|
# host.
|
||||||
|
#
|
||||||
|
# IPv4 only. If this VPS also has a public IPv6 address, ip6tables needs
|
||||||
|
# the equivalent rules added by hand — not yet handled here.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Must match GATEWAY_WG_ADMIN_PORT in .env if you overrode it (e.g.
|
||||||
|
# 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.
|
||||||
|
DOCKER_BRIDGE_SUBNET="172.28.0.0/24"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "must be run as root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Insert ACCEPT rules at the head of the chain, then append DROP at the
|
||||||
|
# tail — this ordering holds regardless of how many times the script runs,
|
||||||
|
# since -I always lands above whatever's already there (including a
|
||||||
|
# previous run's DROP) and -A always lands below.
|
||||||
|
iptables -C INPUT -p tcp --dport "$ADMIN_PORT" -s 127.0.0.1 -j ACCEPT 2>/dev/null \
|
||||||
|
|| iptables -I INPUT -p tcp --dport "$ADMIN_PORT" -s 127.0.0.1 -j ACCEPT
|
||||||
|
|
||||||
|
iptables -C INPUT -p tcp --dport "$ADMIN_PORT" -s "$DOCKER_BRIDGE_SUBNET" -j ACCEPT 2>/dev/null \
|
||||||
|
|| iptables -I INPUT -p tcp --dport "$ADMIN_PORT" -s "$DOCKER_BRIDGE_SUBNET" -j ACCEPT
|
||||||
|
|
||||||
|
iptables -C INPUT -p tcp --dport "$ADMIN_PORT" -j DROP 2>/dev/null \
|
||||||
|
|| iptables -A INPUT -p tcp --dport "$ADMIN_PORT" -j DROP
|
||||||
|
|
||||||
|
echo "wg-easy admin API (tcp/$ADMIN_PORT) now restricted to loopback + $DOCKER_BRIDGE_SUBNET"
|
||||||
|
echo "NOTE: these rules do not persist across reboot on most distros —"
|
||||||
|
echo "install iptables-persistent (Debian/Ubuntu) or an equivalent, or add"
|
||||||
|
echo "this script to a boot-time hook."
|
||||||
34
examples/starter-stack/ops/host-sysctls.sh
Executable file
34
examples/starter-stack/ops/host-sysctls.sh
Executable file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Set the two kernel sysctls wg-easy needs, directly on the VPS host.
|
||||||
|
#
|
||||||
|
# Why this can't just be docker-compose.yaml's `sysctls:` key: that key
|
||||||
|
# sets namespaced (per-network-namespace) sysctls inside a container's own
|
||||||
|
# network namespace. wg-easy runs with network_mode: host (see the comment
|
||||||
|
# on that service), which means it has no network namespace of its own —
|
||||||
|
# runc flatly refuses to start the container if `sysctls:` is set at all
|
||||||
|
# under host networking ("not allowed in host network namespace"),
|
||||||
|
# confirmed by actually trying it, not assumed. These have to be host-level
|
||||||
|
# settings instead.
|
||||||
|
#
|
||||||
|
# Run this once on the VPS, before `docker compose up -d`.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "must be run as root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONF_FILE=/etc/sysctl.d/99-furtka-gateway.conf
|
||||||
|
|
||||||
|
cat > "$CONF_FILE" <<'EOF'
|
||||||
|
# Required by furtka-gateway's wg-easy service (network_mode: host) — see
|
||||||
|
# docker-compose.yaml and ops/host-sysctls.sh.
|
||||||
|
net.ipv4.ip_forward=1
|
||||||
|
net.ipv4.conf.all.src_valid_mark=1
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sysctl --system >/dev/null
|
||||||
|
|
||||||
|
echo "Applied and persisted (via $CONF_FILE):"
|
||||||
|
sysctl net.ipv4.ip_forward net.ipv4.conf.all.src_valid_mark
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 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"
|
||||||
34
examples/starter-stack/traefik/static/traefik.single.yml
Normal file
34
examples/starter-stack/traefik/static/traefik.single.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Single-tenant mode: one operator's own domain, HTTP-01 challenge 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.
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
http:
|
||||||
|
redirections:
|
||||||
|
entryPoint:
|
||||||
|
to: websecure
|
||||||
|
scheme: https
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
|
||||||
|
providers:
|
||||||
|
file:
|
||||||
|
directory: /etc/traefik/dynamic
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
le:
|
||||||
|
acme:
|
||||||
|
email: admin@example.com # overridden per-deployment; see deploy.sh
|
||||||
|
storage: /acme/acme.json
|
||||||
|
httpChallenge:
|
||||||
|
entryPoint: web
|
||||||
|
|
||||||
|
api:
|
||||||
|
dashboard: false
|
||||||
|
insecure: false
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: INFO
|
||||||
299
ops/deploy.sh
299
ops/deploy.sh
|
|
@ -1,23 +1,24 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Sets up furtka-gateway's control-plane, plugged into a Traefik and
|
# Interactive setup for furtka-gateway's control-plane, plugged into a
|
||||||
# wg-easy YOU already run. Read the README's "Existing Traefik + wg-easy"
|
# Traefik and wg-easy YOU already run (as sibling containers, or otherwise
|
||||||
# section first — in particular the routing requirement, which this script
|
# reachable) — see the root README's Prerequisites checklist before
|
||||||
# can't set up or verify for you. On an empty server, use
|
# running this. No zero-infra option here: for that, use
|
||||||
# examples/starter-stack/ instead.
|
# 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
|
# Safe to re-run — existing .env values become the defaults for each
|
||||||
# prompt (press Enter to keep them).
|
# prompt (press Enter to keep them), and rendering is always fully
|
||||||
|
# regenerated from the current .env, never patched in place.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ops/deploy.sh prompts, writes .env, starts the
|
# ops/deploy.sh full setup: prompts, render, compose up,
|
||||||
# control-plane, installs its Traefik
|
# network join, health check
|
||||||
# route, health-checks it
|
# ops/deploy.sh --render-only only (re)write .env and the rendered
|
||||||
# ops/deploy.sh --non-interactive same, but no prompts: every value
|
# traefik/dynamic/control-plane.yml —
|
||||||
# comes from .env (fails if one is missing)
|
# skips compose up / network join /
|
||||||
# ops/deploy.sh --render-only only (re)write .env and
|
# health check. Useful after hand-editing
|
||||||
# rendered/control-plane.yml — touches
|
# .env, or to inspect what would be
|
||||||
# nothing live (not even your Traefik's
|
# generated before touching anything live.
|
||||||
# dynamic directory)
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
@ -25,14 +26,9 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
RENDER_ONLY=0
|
RENDER_ONLY=0
|
||||||
INTERACTIVE=1
|
if [[ "${1:-}" == "--render-only" ]]; then
|
||||||
for arg in "$@"; do
|
RENDER_ONLY=1
|
||||||
case "$arg" in
|
fi
|
||||||
--render-only) RENDER_ONLY=1 ;;
|
|
||||||
--non-interactive) INTERACTIVE=0 ;;
|
|
||||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
ENV_FILE=.env
|
ENV_FILE=.env
|
||||||
|
|
||||||
|
|
@ -57,13 +53,11 @@ ask() {
|
||||||
local __var="$1" __prompt="$2" __fallback="${3:-}"
|
local __var="$1" __prompt="$2" __fallback="${3:-}"
|
||||||
local __current="${!__var:-}"
|
local __current="${!__var:-}"
|
||||||
local __default="${__current:-$__fallback}"
|
local __default="${__current:-$__fallback}"
|
||||||
local __ans=""
|
local __ans
|
||||||
if [[ "$INTERACTIVE" -eq 1 ]]; then
|
if [[ -n "$__default" ]]; then
|
||||||
if [[ -n "$__default" ]]; then
|
read -r -p "$__prompt [$__default]: " __ans
|
||||||
read -r -p "$__prompt [$__default]: " __ans
|
else
|
||||||
else
|
read -r -p "$__prompt: " __ans
|
||||||
read -r -p "$__prompt: " __ans
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
printf -v "$__var" '%s' "${__ans:-$__default}"
|
printf -v "$__var" '%s' "${__ans:-$__default}"
|
||||||
}
|
}
|
||||||
|
|
@ -71,95 +65,105 @@ ask() {
|
||||||
ask_secret() {
|
ask_secret() {
|
||||||
local __var="$1" __prompt="$2"
|
local __var="$1" __prompt="$2"
|
||||||
local __current="${!__var:-}"
|
local __current="${!__var:-}"
|
||||||
local __ans=""
|
local __ans
|
||||||
if [[ "$INTERACTIVE" -eq 1 ]]; then
|
if [[ -n "$__current" ]]; then
|
||||||
if [[ -n "$__current" ]]; then
|
read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans
|
||||||
read -r -s -p "$__prompt [unchanged, Enter to keep]: " __ans
|
else
|
||||||
else
|
read -r -s -p "$__prompt: " __ans
|
||||||
read -r -s -p "$__prompt: " __ans
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
fi
|
fi
|
||||||
|
echo
|
||||||
printf -v "$__var" '%s' "${__ans:-$__current}"
|
printf -v "$__var" '%s' "${__ans:-$__current}"
|
||||||
}
|
}
|
||||||
|
|
||||||
require() {
|
|
||||||
# require VARNAME — fail if empty
|
|
||||||
if [[ -z "${!1:-}" ]]; then
|
|
||||||
echo "$1 is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
require_cmd() {
|
require_cmd() {
|
||||||
command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
|
command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
require_cmd openssl
|
|
||||||
if [[ "$RENDER_ONLY" -eq 0 ]]; then
|
if [[ "$RENDER_ONLY" -eq 0 ]]; then
|
||||||
require_cmd docker
|
require_cmd docker
|
||||||
|
require_cmd openssl
|
||||||
require_cmd curl
|
require_cmd curl
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== furtka-gateway: connect to your existing Traefik + wg-easy =="
|
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
|
echo
|
||||||
|
|
||||||
# ---- tenancy mode ----
|
# ---- tenancy mode ----
|
||||||
ask GATEWAY_MODE "Mode (single / shared)" "single"
|
while true; do
|
||||||
case "$GATEWAY_MODE" in
|
ask GATEWAY_MODE "Mode (single / shared)" "single"
|
||||||
single|shared) ;;
|
case "$GATEWAY_MODE" in
|
||||||
*) echo "GATEWAY_MODE must be single or shared" >&2; exit 1 ;;
|
single|shared) break ;;
|
||||||
esac
|
*) echo "must be one of: single, shared" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# ---- wg-easy ----
|
# ---- wg-easy ----
|
||||||
echo
|
echo
|
||||||
ask WG_EASY_URL "Your wg-easy's admin API URL, as the control-plane will reach it (e.g. http://wg-easy:51821)" ""
|
ask WG_EASY_URL "Your wg-easy's admin API URL (e.g. http://wg-easy:51821)" ""
|
||||||
require WG_EASY_URL
|
if [[ -z "$WG_EASY_URL" ]]; then
|
||||||
ask WG_EASY_API_VERSION "Your wg-easy image's major version (14 or 15 — check the tag)" "15"
|
echo "WG_EASY_URL is required" >&2
|
||||||
case "$WG_EASY_API_VERSION" in
|
exit 1
|
||||||
14|15) ;;
|
fi
|
||||||
*) echo "WG_EASY_API_VERSION must be 14 or 15 (different, incompatible APIs)" >&2; exit 1 ;;
|
while true; do
|
||||||
esac
|
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
|
if [[ "$WG_EASY_API_VERSION" == "15" ]]; then
|
||||||
ask WG_EASY_ADMIN_USERNAME "wg-easy admin username" "admin"
|
ask WG_EASY_ADMIN_USERNAME "wg-easy admin username" "admin"
|
||||||
ask GATEWAY_WG_ALLOWED_IPS "wg-easy's WireGuard peer subnet (new clients get split-tunnel to this)" "10.8.0.0/24"
|
ask GATEWAY_WG_ALLOWED_IPS "wg-easy's WireGuard peer subnet (restricts new clients to split-tunnel)" "10.8.0.0/24"
|
||||||
fi
|
fi
|
||||||
ask_secret WG_EASY_ADMIN_PASSWORD "wg-easy admin password"
|
ask_secret WG_EASY_ADMIN_PASSWORD "wg-easy admin password"
|
||||||
require WG_EASY_ADMIN_PASSWORD
|
if [[ -z "${WG_EASY_ADMIN_PASSWORD:-}" ]]; then
|
||||||
|
echo "wg-easy admin password is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ---- Traefik ----
|
# ---- Traefik ----
|
||||||
echo
|
echo
|
||||||
ask GATEWAY_DYNAMIC_DIR_HOST "Host directory your Traefik's file provider watches" ""
|
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)" ""
|
||||||
require GATEWAY_DYNAMIC_DIR_HOST
|
if [[ -z "$GATEWAY_DYNAMIC_DIR_HOST" ]]; then
|
||||||
ask GATEWAY_CERT_RESOLVER "Your Traefik's certResolver name (blank if a default cert applies automatically)" ""
|
echo "GATEWAY_DYNAMIC_DIR_HOST is required" >&2
|
||||||
ask GATEWAY_CONTROL_PLANE_HOST "Gateway API hostname (what you enter on the box)" ""
|
exit 1
|
||||||
require GATEWAY_CONTROL_PLANE_HOST
|
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
|
||||||
echo "How does your Traefik reach the control-plane container?"
|
echo "How does YOUR Traefik reach this control-plane container?"
|
||||||
echo " 1) it's on the same docker network as my Traefik (recommended)"
|
echo " 1) same docker network as my Traefik (recommended)"
|
||||||
echo " 2) separate host — I'll expose it at a reachable host:port myself"
|
echo " 2) separate host — I'll expose it at a reachable host:port myself"
|
||||||
ask GATEWAY_REACHABILITY_MODE "Choice (1 or 2)" "1"
|
while true; do
|
||||||
case "$GATEWAY_REACHABILITY_MODE" in
|
ask GATEWAY_REACHABILITY_MODE "Choice (1 or 2)" "1"
|
||||||
1)
|
case "$GATEWAY_REACHABILITY_MODE" in
|
||||||
ask GATEWAY_TRAEFIK_NETWORK "Your Traefik's docker network name (see: docker inspect <traefik> --format '{{json .NetworkSettings.Networks}}')" ""
|
1|2) break ;;
|
||||||
require GATEWAY_TRAEFIK_NETWORK
|
*) echo "must be 1 or 2" ;;
|
||||||
GATEWAY_CONTROL_PLANE_BIND="127.0.0.1:8090"
|
esac
|
||||||
GATEWAY_CONTROL_PLANE_TARGET="furtka-gateway-control-plane:8090"
|
done
|
||||||
;;
|
if [[ "$GATEWAY_REACHABILITY_MODE" == "1" ]]; then
|
||||||
2)
|
ask GATEWAY_TRAEFIK_NETWORK "Your Traefik's docker network name (docker network ls / docker inspect <traefik-container>)" ""
|
||||||
echo
|
if [[ -z "$GATEWAY_TRAEFIK_NETWORK" ]]; then
|
||||||
echo "WARNING: this binds the control-plane API beyond loopback. Its"
|
echo "a network name is required for this choice" >&2
|
||||||
echo "registration response carries a WireGuard private key in plaintext"
|
exit 1
|
||||||
echo "JSON — put real access control (firewall/security group) around"
|
fi
|
||||||
echo "whatever bind address you give it."
|
GATEWAY_CONTROL_PLANE_BIND="127.0.0.1:8090"
|
||||||
ask GATEWAY_CONTROL_PLANE_BIND "Bind address:port to expose it on (e.g. 10.0.0.5:8090)" ""
|
GATEWAY_CONTROL_PLANE_TARGET="furtka-gateway-control-plane:8090"
|
||||||
require GATEWAY_CONTROL_PLANE_BIND
|
else
|
||||||
ask GATEWAY_CONTROL_PLANE_TARGET "The same host:port, as your Traefik will reach it" "$GATEWAY_CONTROL_PLANE_BIND"
|
echo
|
||||||
GATEWAY_TRAEFIK_NETWORK=""
|
echo "WARNING: this binds the control-plane API beyond loopback. Its"
|
||||||
;;
|
echo "registration response carries a WireGuard private key in plaintext"
|
||||||
*) echo "GATEWAY_REACHABILITY_MODE must be 1 or 2" >&2; exit 1 ;;
|
echo "JSON — put real access control (firewall/security group) around"
|
||||||
esac
|
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) ----
|
# ---- tokens (generated once, kept on re-run) ----
|
||||||
GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
|
GATEWAY_BOX_TOKEN="${GATEWAY_BOX_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
|
|
@ -179,111 +183,74 @@ echo "Writing $ENV_FILE"
|
||||||
echo "GATEWAY_DYNAMIC_DIR_HOST=$GATEWAY_DYNAMIC_DIR_HOST"
|
echo "GATEWAY_DYNAMIC_DIR_HOST=$GATEWAY_DYNAMIC_DIR_HOST"
|
||||||
echo "GATEWAY_CERT_RESOLVER=$GATEWAY_CERT_RESOLVER"
|
echo "GATEWAY_CERT_RESOLVER=$GATEWAY_CERT_RESOLVER"
|
||||||
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
echo "GATEWAY_CONTROL_PLANE_HOST=$GATEWAY_CONTROL_PLANE_HOST"
|
||||||
echo "GATEWAY_REACHABILITY_MODE=$GATEWAY_REACHABILITY_MODE"
|
|
||||||
echo "GATEWAY_CONTROL_PLANE_BIND=$GATEWAY_CONTROL_PLANE_BIND"
|
echo "GATEWAY_CONTROL_PLANE_BIND=$GATEWAY_CONTROL_PLANE_BIND"
|
||||||
echo "GATEWAY_CONTROL_PLANE_TARGET=$GATEWAY_CONTROL_PLANE_TARGET"
|
echo "GATEWAY_CONTROL_PLANE_TARGET=$GATEWAY_CONTROL_PLANE_TARGET"
|
||||||
[[ -n "$GATEWAY_TRAEFIK_NETWORK" ]] && echo "GATEWAY_TRAEFIK_NETWORK=$GATEWAY_TRAEFIK_NETWORK"
|
[[ -n "$GATEWAY_TRAEFIK_NETWORK" ]] && echo "GATEWAY_TRAEFIK_NETWORK=$GATEWAY_TRAEFIK_NETWORK"
|
||||||
[[ -n "${GATEWAY_CONTROL_PLANE_IMAGE:-}" ]] && echo "GATEWAY_CONTROL_PLANE_IMAGE=$GATEWAY_CONTROL_PLANE_IMAGE"
|
|
||||||
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
echo "GATEWAY_BOX_TOKEN=$GATEWAY_BOX_TOKEN"
|
||||||
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
echo "GATEWAY_ADMIN_TOKEN=$GATEWAY_ADMIN_TOKEN"
|
||||||
} > "$ENV_FILE"
|
} > "$ENV_FILE"
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
# ---- join your Traefik's network from the compose file itself ----
|
# ---- render traefik/dynamic/control-plane.yml ----
|
||||||
# An override (auto-loaded by `docker compose`, gitignored) rather than a
|
echo "Rendering traefik/dynamic/control-plane.yml"
|
||||||
# `docker network connect` after startup: the control-plane talks to
|
mkdir -p "$GATEWAY_DYNAMIC_DIR_HOST"
|
||||||
# wg-easy as soon as it starts, so the network must be there from the
|
|
||||||
# first second, and it must survive container recreation.
|
|
||||||
if [[ -n "$GATEWAY_TRAEFIK_NETWORK" ]]; then
|
|
||||||
echo "Writing docker-compose.override.yaml (joins network: $GATEWAY_TRAEFIK_NETWORK)"
|
|
||||||
cat > docker-compose.override.yaml <<EOF
|
|
||||||
# Generated by ops/deploy.sh — joins your Traefik's docker network.
|
|
||||||
services:
|
|
||||||
control-plane:
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
- traefik
|
|
||||||
networks:
|
|
||||||
traefik:
|
|
||||||
name: $GATEWAY_TRAEFIK_NETWORK
|
|
||||||
external: true
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
rm -f docker-compose.override.yaml
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- render the control-plane's Traefik route (not yet installed) ----
|
|
||||||
echo "Writing rendered/control-plane.yml"
|
|
||||||
mkdir -p rendered
|
|
||||||
# Only the http: block onward — the .example's header comment explains the
|
# Only the http: block onward — the .example's header comment explains the
|
||||||
# placeholders BY NAME, which these substitutions would otherwise corrupt.
|
# placeholders BY NAME (for a human reading that template), which would
|
||||||
sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > rendered/control-plane.yml
|
# otherwise also get corrupted by these same substitutions.
|
||||||
sed -i "s|GATEWAY_CONTROL_PLANE_HOST|$GATEWAY_CONTROL_PLANE_HOST|" rendered/control-plane.yml
|
sed -n '/^http:/,$p' traefik/dynamic/control-plane.yml.example > "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
||||||
sed -i "s|GATEWAY_CONTROL_PLANE_TARGET|$GATEWAY_CONTROL_PLANE_TARGET|" rendered/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
|
if [[ -z "$GATEWAY_CERT_RESOLVER" ]]; then
|
||||||
# No resolver on your Traefik — drop the tls: block so its default cert
|
# No resolver configured on your Traefik — drop the tls: block entirely
|
||||||
# applies, mirroring what a blank GATEWAY_CERT_RESOLVER means for every
|
# so its own default cert applies, mirroring what GATEWAY_CERT_RESOLVER
|
||||||
# app route (see traefikconf.py).
|
# blank means for every app route (see traefikconf.py).
|
||||||
sed -i "/^ tls:$/,/^ certResolver:.*$/d" rendered/control-plane.yml
|
sed -i "/^ tls:$/,/^ certResolver:.*$/d" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
||||||
else
|
else
|
||||||
sed -i "s|certResolver: le|certResolver: $GATEWAY_CERT_RESOLVER|" rendered/control-plane.yml
|
sed -i "s|certResolver: le|certResolver: $GATEWAY_CERT_RESOLVER|" "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
if [[ "$RENDER_ONLY" -eq 1 ]]; then
|
||||||
echo
|
echo
|
||||||
echo "--render-only: nothing live was touched. Inspect .env and rendered/control-plane.yml."
|
echo "--render-only: stopping before compose up / network join / health check."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- bring the control-plane up (always built from this checkout) ----
|
# ---- bring the control-plane up ----
|
||||||
echo
|
echo
|
||||||
echo "== docker compose up -d --build =="
|
echo "== docker compose up -d =="
|
||||||
docker compose up -d --build
|
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
|
||||||
echo "== health check =="
|
echo "== health check =="
|
||||||
healthy=0
|
for _ in $(seq 1 10); do
|
||||||
for _ in $(seq 1 30); do
|
|
||||||
if curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then
|
if curl -sf "http://${GATEWAY_CONTROL_PLANE_BIND}/healthz" >/dev/null 2>&1; then
|
||||||
healthy=1
|
echo "control-plane is up."
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 1
|
||||||
done
|
done
|
||||||
if [[ "$healthy" -ne 1 ]]; then
|
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
|
echo "control-plane did not become healthy — check: docker compose logs control-plane" >&2
|
||||||
echo "(a wrong WG_EASY_URL or wg-easy password shows up there)" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo " ok: control-plane is up"
|
|
||||||
|
|
||||||
# ---- install the route into your Traefik's dynamic directory ----
|
|
||||||
mkdir -p "$GATEWAY_DYNAMIC_DIR_HOST"
|
|
||||||
cp rendered/control-plane.yml "$GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
||||||
echo " installed $GATEWAY_DYNAMIC_DIR_HOST/control-plane.yml"
|
|
||||||
|
|
||||||
public_ok=0
|
|
||||||
for _ in $(seq 1 20); do
|
|
||||||
if curl -sf -m 10 "https://$GATEWAY_CONTROL_PLANE_HOST/healthz" >/dev/null 2>&1; then
|
|
||||||
public_ok=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
if [[ "$public_ok" -eq 1 ]]; then
|
|
||||||
echo " ok: https://$GATEWAY_CONTROL_PLANE_HOST/healthz"
|
|
||||||
else
|
|
||||||
echo " WARNING: https://$GATEWAY_CONTROL_PLANE_HOST/healthz not reachable."
|
|
||||||
echo " Check that DNS points at your Traefik and that Traefik watches"
|
|
||||||
echo " $GATEWAY_DYNAMIC_DIR_HOST (providers.file.directory)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== done =="
|
echo "== done =="
|
||||||
echo "Before publishing apps, make sure your Traefik can reach wg-easy's peer"
|
echo "Still needed before a real box can connect:"
|
||||||
echo "subnet — see the README's routing section. Quick check after a box connects:"
|
echo " - Add $GATEWAY_DYNAMIC_DIR_HOST as a providers.file directory on"
|
||||||
echo " docker exec <your-traefik> ping -c1 10.8.0.<box>"
|
echo " your Traefik, if it isn't already watching it."
|
||||||
echo
|
echo " - Verify Traefik can actually reach wg-easy's WireGuard peer subnet"
|
||||||
echo "Connect a Furtka box: open its web UI -> Settings -> Gateway, and enter"
|
echo " (the README's Prerequisites checklist has a command for this) —"
|
||||||
echo " Gateway URL: https://$GATEWAY_CONTROL_PLANE_HOST"
|
echo " this is the one thing that can't be checked from here."
|
||||||
echo " Registration token: the GATEWAY_BOX_TOKEN value in $REPO_ROOT/.env"
|
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,18 +1,23 @@
|
||||||
# Template for the control-plane's own Traefik route. ops/deploy.sh renders
|
# Copy to traefik/dynamic/control-plane.yml (into whatever directory YOUR
|
||||||
# it to rendered/control-plane.yml and installs it into the directory YOUR
|
# Traefik's file provider watches — see the root README's Prerequisites
|
||||||
# Traefik's file provider watches, with two placeholders substituted:
|
# checklist) with two placeholders substituted:
|
||||||
#
|
#
|
||||||
# GATEWAY_CONTROL_PLANE_HOST a real hostname you control (e.g.
|
# GATEWAY_CONTROL_PLANE_HOST a real hostname you control (e.g.
|
||||||
# gw.example.com), with its own A/AAAA record
|
# gw.example.com), with its own A/AAAA record
|
||||||
# pointing at this host.
|
# pointing at this host.
|
||||||
# GATEWAY_CONTROL_PLANE_TARGET where YOUR Traefik actually reaches the
|
# GATEWAY_CONTROL_PLANE_TARGET where YOUR Traefik actually reaches the
|
||||||
# control-plane container. If it joined your
|
# control-plane container. If it joined your
|
||||||
# Traefik's docker network (the recommended
|
# Traefik's docker network (ops/deploy.sh
|
||||||
# path — ops/deploy.sh sets that up), this is
|
# does this via `docker network connect` —
|
||||||
# `furtka-gateway-control-plane:8090`, the
|
# the primary, recommended path), this is
|
||||||
# container's fixed name (unambiguous on a
|
# `furtka-gateway-control-plane:8090` — the
|
||||||
# shared network, unlike a generic service
|
# container's fixed name, which Docker's
|
||||||
# name like `control-plane`).
|
# 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
|
# If instead you exposed it on a separate
|
||||||
# host (GATEWAY_CONTROL_PLANE_BIND widened
|
# host (GATEWAY_CONTROL_PLANE_BIND widened
|
||||||
# beyond 127.0.0.1 in docker-compose.yaml),
|
# beyond 127.0.0.1 in docker-compose.yaml),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue