mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(pilot): let SENCHO_PUBLIC_URL override the request Host in enrollment (#1122)
The enrollment minter inferred SENCHO_PRIMARY_URL from the request Host header, which baked loopback or LAN addresses into the compose YAML when the admin opened Add Node on the central's own machine. Pilots on a different network (a public cloud VPS, for example) cannot dial that. SENCHO_PUBLIC_URL on the primary now wins when set and well-formed (http(s)://, no loopback). Trailing slashes are stripped. Falls back to the request Host when unset or invalid.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:<ephemeral-port>`,
|
||||
// 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user