Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
1 change: 1 addition & 0 deletions .claire/worktrees/pihole-phase1/src/services/piholeSync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
placeholder
15 changes: 12 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────
Expand All @@ -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=
Expand Down
8 changes: 4 additions & 4 deletions INSTALL.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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).
Expand All @@ -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.

---

Expand Down
8 changes: 4 additions & 4 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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).
Expand All @@ -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.

---

Expand Down
3 changes: 2 additions & 1 deletion deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
7 changes: 6 additions & 1 deletion src/services/caddyConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
},
Expand Down
1 change: 1 addition & 0 deletions src/services/caddyOwner.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ function _resetOwnerCache() {

module.exports = {
OWNER_ID_PREFIX,
MARKER_HOST,
getOwnerId,
ownerMarkerRoute,
extractOwner,
Expand Down
21 changes: 21 additions & 0 deletions tests/caddyConfig_contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading