From ecff20276bbba1ec7a604c0b87f678240c904ac9 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:00:29 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20deploy=20papercuts=20=E2=80=94=20marker?= =?UTF-8?q?=20host=20ACME=20spam,=20.env.example=20footgun,=20INSTALL=2040?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Ownership marker host (gc-owner.invalid, RFC 6761 reserved TLD) was a real host matcher with no TLS policy → Caddy automatic_https issued a doomed public ACME order, retried forever. Add srv0.automatic_https.skip so the marker (which never serves) is excluded from cert management. Contract test asserts the skip entry and that the marker is never an ACME subject. 2. .env.example shipped active GC_WG_POST_UP/POST_DOWN with hardcoded eth0 and GC_NET_INTERFACE=eth0. A set POST_UP overrides the entrypoint's superior auto-detected rules (scoped MASQUERADE + RELATED,ESTABLISHED + MSS clamp), and eth0 is wrong on most Debian/KVM hosts. Blank them with guidance; mirror GC_NET_INTERFACE in the stale deploy/ copy. 3. INSTALL.{md,de.md} told users to download/cp `.env.example`, but the release asset is `default.env.example` (GitHub forbids a leading dot) → 404 / failed cp. Point the download URLs, cp, and cross-references at default.env.example. --- .../tests/domain_verify.test.js | 47 +++++++++++++++++++ .../pihole-phase1/src/services/piholeSync.js | 1 + .env.example | 15 ++++-- INSTALL.de.md | 8 ++-- INSTALL.md | 8 ++-- deploy/.env.example | 3 +- src/services/caddyConfig.js | 7 ++- src/services/caddyOwner.js | 1 + tests/caddyConfig_contract.test.js | 21 +++++++++ 9 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 .claire/worktrees/feature+domain-registry/tests/domain_verify.test.js create mode 100644 .claire/worktrees/pihole-phase1/src/services/piholeSync.js diff --git a/.claire/worktrees/feature+domain-registry/tests/domain_verify.test.js b/.claire/worktrees/feature+domain-registry/tests/domain_verify.test.js new file mode 100644 index 00000000..165c7eeb --- /dev/null +++ b/.claire/worktrees/feature+domain-registry/tests/domain_verify.test.js @@ -0,0 +1,47 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); + +let domains, settings; +beforeEach(async () => { + await setup(); + domains = require('../src/services/domains'); + settings = require('../src/services/settings'); + settings.set('server.public_ip', '198.51.100.7'); // known-good server IP +}); +afterEach(teardown); + +test('verified when a resolved A matches the server IP', async () => { + domains._setResolverForTest(async (h, f) => (f === 4 ? ['198.51.100.7'] : [])); + const r = await domains.verify('home.example.com'); + assert.equal(r.status, 'verified'); + assert.equal(r.resolvedIp, '198.51.100.7'); +}); + +test('failed when resolves elsewhere (server IP known-good)', async () => { + domains._setResolverForTest(async (h, f) => (f === 4 ? ['203.0.113.1'] : [])); + const r = await domains.verify('elsewhere.example.com'); + assert.equal(r.status, 'failed'); + assert.match(r.error, /198\.51\.100\.7/); // expected IP in the message +}); + +test('pending (not failed) when server IP unknown', async () => { + settings.set('server.public_ip', ''); // clear the override → must derive + // CRITICAL: the stub answers ALL hosts incl. GC_WG_HOST (test.example.com). To make + // the server IP truly unknown, GC_WG_HOST must resolve to [] (else getServerPublicIp + // would derive an IP and the test would not exercise the unknown path). + domains._setResolverForTest(async (h, f) => + (h === 'test.example.com' ? [] : (f === 4 ? ['203.0.113.1'] : []))); + const r = await domains.verify('x.example.com'); + assert.equal(r.status, 'pending'); +}); + +test('pending (not failed) when all public resolvers unreachable', async () => { + domains._setResolverForTest(async () => { const e = new Error('ESERVFAIL'); throw e; }); + const r = await domains.verify('x.example.com'); + assert.equal(r.status, 'pending'); + assert.match(r.error, /resolver|erreichbar/i); +}); diff --git a/.claire/worktrees/pihole-phase1/src/services/piholeSync.js b/.claire/worktrees/pihole-phase1/src/services/piholeSync.js new file mode 100644 index 00000000..b3a42524 --- /dev/null +++ b/.claire/worktrees/pihole-phase1/src/services/piholeSync.js @@ -0,0 +1 @@ +placeholder \ No newline at end of file diff --git a/.env.example b/.env.example index 7640ed93..b5f676bc 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,14 @@ GC_WG_GATEWAY_IP=10.8.0.1 GC_WG_DNS=1.1.1.1,8.8.8.8 GC_WG_ALLOWED_IPS=0.0.0.0/0 GC_WG_PERSISTENT_KEEPALIVE=25 -GC_WG_POST_UP=iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE -GC_WG_POST_DOWN=iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE +# Leave GC_WG_POST_UP / GC_WG_POST_DOWN EMPTY. The container entrypoint +# auto-detects the egress interface and installs better rules than these +# (scoped MASQUERADE limited to the WG subnet + RELATED,ESTABLISHED return +# path + TCP MSS clamping). Setting them OVERRIDES those smart defaults — and +# the hardcoded `eth0` below is wrong on most Debian/KVM hosts (ens18, enp1s0…). +# Only set them if you must, and then mirror the entrypoint's full ruleset. +GC_WG_POST_UP= +GC_WG_POST_DOWN= GC_WG_MTU= # ─── Caddy ─────────────────────────────────────────── @@ -42,7 +48,10 @@ GC_AVAILABLE_LANGUAGES=en,de GC_DEFAULT_THEME=default # ─── Network ───────────────────────────────────────── -GC_NET_INTERFACE=eth0 +# Egress interface for VPN NAT. Leave EMPTY — the entrypoint auto-detects the +# default-route interface (and overrides a stale/nonexistent value anyway). +# Only pin this if auto-detection picks the wrong NIC on a multi-homed host. +GC_NET_INTERFACE= # ─── Encryption ────────────────────────────────────── GC_ENCRYPTION_KEY= diff --git a/INSTALL.de.md b/INSTALL.de.md index 2cf5a25a..fbfd8ba5 100644 --- a/INSTALL.de.md +++ b/INSTALL.de.md @@ -108,7 +108,7 @@ Drei Varianten. Alle enden mit denselben Dateien in `/opt/gatecontrol/`. cd /opt/gatecontrol curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/setup.sh curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/docker-compose.yml -curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/.env.example +curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/default.env.example bash setup.sh ``` @@ -119,10 +119,10 @@ Dann direkt zu [§6 Erster Login](#6-erster-login) — setup.sh erledigt den Res ```bash cd /opt/gatecontrol curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/docker-compose.yml -curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/.env.example +curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/default.env.example curl -fsSLO https://raw.githubusercontent.com/CallMeTechie/gatecontrol/master/update.sh chmod +x update.sh -cp .env.example .env +cp default.env.example .env ``` Weiter mit [§4 `.env` konfigurieren](#4-env-konfigurieren). @@ -137,7 +137,7 @@ docker load < gatecontrol-image.tar.gz rm gatecontrol-image.tar.gz ``` -Weiter mit `docker-compose.yml` und `.env.example` aus Variante B. +Weiter mit `docker-compose.yml` und `default.env.example` aus Variante B. --- diff --git a/INSTALL.md b/INSTALL.md index 73301d9d..5cb81332 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -108,7 +108,7 @@ Download and run `setup.sh`. It installs Docker if missing, walks you through `. cd /opt/gatecontrol curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/setup.sh curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/docker-compose.yml -curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/.env.example +curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/default.env.example bash setup.sh ``` @@ -119,10 +119,10 @@ Skip straight to [§6 First login](#6-first-login) — setup.sh does the rest. ```bash cd /opt/gatecontrol curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/docker-compose.yml -curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/.env.example +curl -fsSLO https://github.com/CallMeTechie/gatecontrol/releases/latest/download/default.env.example curl -fsSLO https://raw.githubusercontent.com/CallMeTechie/gatecontrol/master/update.sh chmod +x update.sh -cp .env.example .env +cp default.env.example .env ``` Proceed to [§4 Configure `.env`](#4-configure-env). @@ -137,7 +137,7 @@ docker load < gatecontrol-image.tar.gz rm gatecontrol-image.tar.gz ``` -Continue with the `docker-compose.yml` and `.env.example` from Option B. +Continue with the `docker-compose.yml` and `default.env.example` from Option B. --- diff --git a/deploy/.env.example b/deploy/.env.example index bfb7273c..4a3e83e8 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -42,7 +42,8 @@ GC_AVAILABLE_LANGUAGES=en,de GC_DEFAULT_THEME=default # ─── Network ───────────────────────────────────────── -GC_NET_INTERFACE=eth0 +# Leave EMPTY — the entrypoint auto-detects the default-route interface. +GC_NET_INTERFACE= # ─── Encryption ────────────────────────────────────── GC_ENCRYPTION_KEY= diff --git a/src/services/caddyConfig.js b/src/services/caddyConfig.js index e3cf9aff..266d8565 100644 --- a/src/services/caddyConfig.js +++ b/src/services/caddyConfig.js @@ -46,7 +46,7 @@ const { buildRouteAuthProxy, buildAuthHandlerChain } = require('./caddyAuthSubro const { getAclPeers, setAclPeers } = require('./caddyAcl'); const { renderMaintenancePage } = require('./caddyMaintenance'); const { renderAccessWindowPage } = require('./caddyAccessWindow'); -const { getOwnerId, ownerMarkerRoute, extractOwner, ownershipDecision } = require('./caddyOwner'); +const { getOwnerId, ownerMarkerRoute, extractOwner, ownershipDecision, MARKER_HOST } = require('./caddyOwner'); const { caddyApi, _caddyApi, @@ -843,6 +843,11 @@ function buildCaddyConfig(injectedRoutes, options = {}) { caddyConfig.apps.http.servers.srv0 = { listen: [':443', ':80'], routes: serverRoutes, + // The ownership marker's host (RFC 6761 reserved .invalid TLD) is a real + // host matcher Caddy would otherwise feed to automatic_https → a doomed + // public ACME order ("not a valid public suffix") retried forever. It + // never serves on the wire, so skip cert management for it entirely. + automatic_https: { skip: [MARKER_HOST] }, logs: { default_logger_name: 'access', }, diff --git a/src/services/caddyOwner.js b/src/services/caddyOwner.js index cc8721fd..eaecc4eb 100644 --- a/src/services/caddyOwner.js +++ b/src/services/caddyOwner.js @@ -159,6 +159,7 @@ function _resetOwnerCache() { module.exports = { OWNER_ID_PREFIX, + MARKER_HOST, getOwnerId, ownerMarkerRoute, extractOwner, diff --git a/tests/caddyConfig_contract.test.js b/tests/caddyConfig_contract.test.js index 16fd586c..91d3566e 100644 --- a/tests/caddyConfig_contract.test.js +++ b/tests/caddyConfig_contract.test.js @@ -111,6 +111,27 @@ describe('caddyConfig contract: top-level shape', () => { assert.ok(!cfg.apps.layer4, 'no layer4 app when no l4 routes'); }); + it('ownership marker host is excluded from automatic_https (no doomed ACME order)', () => { + const { MARKER_HOST } = require('../src/services/caddyOwner'); + const cfg = buildCaddyConfig([{ + id: 1, domain: 'a.example.com', route_type: 'http', + target_kind: 'peer', target_ip: '10.8.0.7', target_port: 80, + enabled: 1, https_enabled: 1, + }]); + const skip = cfg.apps.http.servers.srv0.automatic_https + && cfg.apps.http.servers.srv0.automatic_https.skip; + assert.ok(Array.isArray(skip) && skip.includes(MARKER_HOST), + 'srv0.automatic_https.skip must contain the marker host'); + // and it must never appear as an ACME subject + const policies = (cfg.apps.tls && cfg.apps.tls.automation && cfg.apps.tls.automation.policies) || []; + for (const p of policies) { + const acme = (p.issuers || []).some(i => i.module === 'acme'); + if (acme && Array.isArray(p.subjects)) { + assert.ok(!p.subjects.includes(MARKER_HOST), 'marker host must not be an ACME subject'); + } + } + }); + it('l4 routes add apps.layer4.servers', () => { const cfg = buildCaddyConfig([{ id: 10, route_type: 'l4', target_kind: 'peer',