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',