diff --git a/.env.example b/.env.example index a601f35d..3ee937fd 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,14 @@ API_RATE_LIMIT=200 # Increase for environments with many concurrent browser sessions behind shared NAT. API_POLLING_RATE_LIMIT=300 +# Public URL of THIS primary instance, used to bake an externally-reachable +# SENCHO_PRIMARY_URL into pilot enrollment YAML. Set this when the primary +# sits behind a reverse proxy or Cloudflare Tunnel so pilots on a different +# network can dial the public hostname instead of the request Host header +# the admin happens to be on (loopback, LAN, etc.). Must be http(s)://; no +# trailing slash. When unset, enrollment falls back to the request Host. +SENCHO_PUBLIC_URL= + # ─── Pilot agent (remote host only) ────────────────────────────── # These three vars are required ONLY on a remote host running as a # pilot-agent reverse-tunnel container. The primary instance does not diff --git a/backend/src/__tests__/pilot-enrollment.test.ts b/backend/src/__tests__/pilot-enrollment.test.ts index eff731ef..ffd4099a 100644 --- a/backend/src/__tests__/pilot-enrollment.test.ts +++ b/backend/src/__tests__/pilot-enrollment.test.ts @@ -134,6 +134,71 @@ describe('POST /api/nodes (pilot_agent mode)', () => { }); }); +describe('SENCHO_PUBLIC_URL override in mintPilotEnrollment', () => { + const ORIGINAL = process.env.SENCHO_PUBLIC_URL; + afterAll(() => { + if (ORIGINAL === undefined) delete process.env.SENCHO_PUBLIC_URL; + else process.env.SENCHO_PUBLIC_URL = ORIGINAL; + }); + + it('bakes the env-var URL into composeYaml when set and valid', async () => { + process.env.SENCHO_PUBLIC_URL = 'https://sencho.example.com'; + const res = await request(app) + .post('/api/nodes') + .set('Cookie', adminCookie) + .send({ name: 'pilot-public-url-set', type: 'remote', mode: 'pilot_agent' }); + + const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile; + expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toBe('https://sencho.example.com'); + }); + + it('strips a trailing slash from the env-var URL', async () => { + process.env.SENCHO_PUBLIC_URL = 'https://sencho.example.com/'; + const res = await request(app) + .post('/api/nodes') + .set('Cookie', adminCookie) + .send({ name: 'pilot-public-url-trailing', type: 'remote', mode: 'pilot_agent' }); + + const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile; + expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toBe('https://sencho.example.com'); + }); + + it('falls back to request host when env var is unset', async () => { + delete process.env.SENCHO_PUBLIC_URL; + const res = await request(app) + .post('/api/nodes') + .set('Cookie', adminCookie) + .send({ name: 'pilot-public-url-unset', type: 'remote', mode: 'pilot_agent' }); + + const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile; + // Supertest sends requests with host = `127.0.0.1:`, + // so the fallback URL must reflect that, not the env-var override. + expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + }); + + it('falls back to request host when env var is malformed', async () => { + process.env.SENCHO_PUBLIC_URL = 'not a url'; + const res = await request(app) + .post('/api/nodes') + .set('Cookie', adminCookie) + .send({ name: 'pilot-public-url-bad', type: 'remote', mode: 'pilot_agent' }); + + const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile; + expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + }); + + it('rejects a loopback URL and falls back to the request host', async () => { + process.env.SENCHO_PUBLIC_URL = 'http://127.0.0.1:1852'; + const res = await request(app) + .post('/api/nodes') + .set('Cookie', adminCookie) + .send({ name: 'pilot-public-url-loopback', type: 'remote', mode: 'pilot_agent' }); + + const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile; + expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + }); +}); + describe('POST /api/nodes/:id/pilot/enroll', () => { it('regenerates the enrollment for an existing pilot node', async () => { const create = await request(app) diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index f8b3317d..e209c817 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -22,6 +22,27 @@ import { getErrorMessage } from '../utils/errors'; const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.'; const REMOTE_META_CACHE_TTL = 3 * 60 * 1000; +/** + * Pick the URL the pilot agent should dial. SENCHO_PUBLIC_URL wins when set + * and well-formed, because the request Host header is only reachable from + * the network the operator opened the dialog from. Pilots on a public cloud + * cannot dial a LAN or loopback address, so an explicit public URL is the + * only thing that lets the enrolled YAML work unmodified. + */ +function resolvePrimaryUrl(req: Request): string { + const override = process.env.SENCHO_PUBLIC_URL?.trim(); + if (override) { + const check = isValidRemoteUrl(override); + if (check.valid) return override.replace(/\/$/, ''); + console.warn(`[Enrollment] SENCHO_PUBLIC_URL is set but invalid (${check.reason}); falling back to request host.`); + } + const forwardedProto = req.headers['x-forwarded-proto']; + const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const protocol = protoHeader || req.protocol || 'http'; + const host = req.get('host') || 'localhost:1852'; + return `${protocol}://${host}`; +} + function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; composeYaml: string } { const db = DatabaseService.getInstance(); const jwtSecret = db.getGlobalSettings().auth_jwt_secret; @@ -38,11 +59,7 @@ function mintPilotEnrollment(nodeId: number, req: Request): { token: string; exp const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); db.createPilotEnrollment(nodeId, tokenHash, expiresAt); - const forwardedProto = req.headers['x-forwarded-proto']; - const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; - const protocol = protoHeader || req.protocol || 'http'; - const host = req.get('host') || 'localhost:1852'; - const primaryUrl = `${protocol}://${host}`; + const primaryUrl = resolvePrimaryUrl(req); // Top-level `name` plus `container_name` make the agent container's HOSTNAME // equal to `sencho-agent`, which is how SelfUpdateService locates its own diff --git a/docs/features/pilot-agent.mdx b/docs/features/pilot-agent.mdx index 55978a91..9169f2d6 100644 --- a/docs/features/pilot-agent.mdx +++ b/docs/features/pilot-agent.mdx @@ -27,6 +27,18 @@ Deploying with Docker Compose also lets the primary trigger over-the-air updates Only outbound HTTPS from the remote to the primary is required. Nothing else is exposed. +## Setting the primary's public URL + +The enrollment dialog bakes the primary's URL into the compose file under `SENCHO_PRIMARY_URL`. The primary infers that URL from the request the admin's browser sent, which works on a LAN but breaks when the pilot lives on a different network (a public cloud VPS, for example) and the admin happened to open the dialog at `http://127.0.0.1:1852` or a LAN address. + +Set `SENCHO_PUBLIC_URL` on the primary to lock in a publicly-routable URL: + +```bash +-e SENCHO_PUBLIC_URL=https://sencho.example.com +``` + +Any HTTPS hostname reachable from the pilot works: a Cloudflare Tunnel, a reverse proxy, a port-forwarded home network with DDNS. The primary validates the value (must be `http(s)://`, no loopback) and falls back to the request host when unset. + ## Enrollment walkthrough ### 1. Add the node on the primary