mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
fix(pilot): inject loopback auth on agent-side HTTP/WS forwarding (#990)
After the central proxy forwards a request through the pilot tunnel, the agent receives an http_req or ws_open frame and opens a local loopback request to its own Sencho server. The forwarder previously copied the inbound headers verbatim with no Authorization header, so the local authMiddleware rejected every proxied call with 401 Authentication required. The central cannot sign the missing token because each Sencho instance has its own auth_jwt_secret generated at first-run setup. The agent process runs in the same Node process as the local Sencho server and shares the local DatabaseService, so it can mint a fresh pilot_tunnel-scoped JWT signed with the local secret. Token TTL is 5 minutes with a refresh-on-use 4-minute cache to avoid jwt.sign on every request. The header is built once per request via buildLoopbackHeaders and applied to both onHttpReq and onWsOpen so the two forwarder paths stay in lockstep. The local authMiddleware accepts the scope through its existing pilot_tunnel branch with no special-case bypass. Together with the proxy-side change in the previous commit, this completes the central, bridge, agent, local-Sencho HTTP path for pilot-agent-mode nodes.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Regression guard for the agent-side loopback auth injection.
|
||||
*
|
||||
* The central proxy strips browser cookies and the (empty) pilot-agent api_token
|
||||
* before forwarding through the tunnel. Without an inline auth header on the
|
||||
* agent's loopback request, the local Sencho's `authMiddleware` would 401 every
|
||||
* proxied call. The agent injects a `pilot_tunnel`-scoped JWT signed by the
|
||||
* LOCAL `auth_jwt_secret`, which the loopback `authMiddleware` accepts via the
|
||||
* existing scope branch with no special-case bypass.
|
||||
*
|
||||
* This test verifies:
|
||||
* 1. `getLoopbackAuthHeader` returns a Bearer header backed by a JWT that
|
||||
* the local secret verifies, with the expected scope.
|
||||
* 2. Subsequent calls within the refresh window return the cached header
|
||||
* (no re-mint per request).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { PilotAgent } from '../pilot/agent';
|
||||
|
||||
describe('PilotAgent loopback auth injection', () => {
|
||||
let tmpDir: string;
|
||||
let agent: PilotAgent;
|
||||
let mintHeader: () => string | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
// Importing the index loads DatabaseService against the seeded baseline so
|
||||
// getGlobalSettings().auth_jwt_secret resolves to TEST_JWT_SECRET.
|
||||
await import('../index');
|
||||
agent = new PilotAgent({
|
||||
primaryUrl: 'http://primary.invalid',
|
||||
loopbackPort: 1,
|
||||
initialToken: 'irrelevant-for-this-test',
|
||||
enrolling: false,
|
||||
});
|
||||
mintHeader = (agent as unknown as { getLoopbackAuthHeader: () => string | null }).getLoopbackAuthHeader.bind(agent);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
it('mints a Bearer header with a pilot_tunnel-scoped JWT signed by the local secret', () => {
|
||||
const header = mintHeader();
|
||||
expect(header).toMatch(/^Bearer /);
|
||||
const token = header!.slice('Bearer '.length);
|
||||
const decoded = jwt.verify(token, TEST_JWT_SECRET) as { scope?: string; nodeId?: number; exp?: number };
|
||||
expect(decoded.scope).toBe('pilot_tunnel');
|
||||
expect(typeof decoded.exp).toBe('number');
|
||||
});
|
||||
|
||||
it('returns the cached header on a quick second call', () => {
|
||||
const a = mintHeader();
|
||||
const b = mintHeader();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,11 @@ import fs from 'fs';
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import WebSocket from 'ws';
|
||||
import { getSenchoVersion } from '../services/CapabilityRegistry';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import {
|
||||
BinaryFrameType,
|
||||
MAX_FRAME_SIZE_BYTES,
|
||||
@@ -23,6 +26,8 @@ import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
const RECONNECT_MIN_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 60_000;
|
||||
const LOOPBACK_TOKEN_TTL_SECONDS = 300;
|
||||
const LOOPBACK_TOKEN_REFRESH_SECONDS = 240;
|
||||
const PING_INTERVAL_MS = 30_000;
|
||||
const TOKEN_PATH = path.join(process.env.DATA_DIR || '/app/data', 'pilot.jwt');
|
||||
|
||||
@@ -63,7 +68,7 @@ interface AgentOptions {
|
||||
enrolling: boolean;
|
||||
}
|
||||
|
||||
class PilotAgent {
|
||||
export class PilotAgent {
|
||||
private readonly options: AgentOptions;
|
||||
private token: string;
|
||||
private backoff = RECONNECT_MIN_MS;
|
||||
@@ -84,6 +89,10 @@ class PilotAgent {
|
||||
*/
|
||||
private readonly customCa: Buffer | null;
|
||||
|
||||
/** Cached pilot_tunnel-scoped token signed by the LOCAL Sencho's `auth_jwt_secret`, used to authenticate forwarded HTTP and WS requests against the local loopback Sencho. */
|
||||
private loopbackToken: string | null = null;
|
||||
private loopbackTokenIssuedAt = 0;
|
||||
|
||||
constructor(options: AgentOptions) {
|
||||
this.options = options;
|
||||
this.token = options.initialToken;
|
||||
@@ -91,6 +100,43 @@ class PilotAgent {
|
||||
this.customCa = readPilotCaBundle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint or reuse a `pilot_tunnel`-scoped JWT signed by the AGENT's local
|
||||
* `auth_jwt_secret`. The central proxy strips browser cookies before it
|
||||
* forwards a request through the tunnel; without an inline auth header on
|
||||
* the loopback request, the agent's local `authMiddleware` would 401 every
|
||||
* proxied call. The token's claim shape mirrors what the central mints at
|
||||
* enrollment, so the loopback `authMiddleware` accepts it via the existing
|
||||
* `pilot_tunnel` branch with no special-case bypass.
|
||||
*/
|
||||
private getLoopbackAuthHeader(): string | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (this.loopbackToken && now - this.loopbackTokenIssuedAt < LOOPBACK_TOKEN_REFRESH_SECONDS) {
|
||||
return `Bearer ${this.loopbackToken}`;
|
||||
}
|
||||
try {
|
||||
const secret = DatabaseService.getInstance().getGlobalSettings().auth_jwt_secret;
|
||||
if (!secret) return null;
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
this.loopbackToken = jwt.sign({ scope: 'pilot_tunnel', nodeId }, secret, { expiresIn: LOOPBACK_TOKEN_TTL_SECONDS });
|
||||
this.loopbackTokenIssuedAt = now;
|
||||
return `Bearer ${this.loopbackToken}`;
|
||||
} catch (err) {
|
||||
if (isDebugEnabled()) console.warn('[Pilot:diag] loopback token mint failed:', sanitizeForLog((err as Error).message));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildLoopbackHeaders(frameHeaders: Record<string, string>): Record<string, string> {
|
||||
const auth = this.getLoopbackAuthHeader();
|
||||
const headers: Record<string, string> = {
|
||||
...frameHeaders,
|
||||
host: `127.0.0.1:${this.options.loopbackPort}`,
|
||||
};
|
||||
if (auth) headers.authorization = auth;
|
||||
return headers;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.connect();
|
||||
process.on('SIGTERM', () => this.shutdown());
|
||||
@@ -348,7 +394,7 @@ class PilotAgent {
|
||||
port: this.options.loopbackPort,
|
||||
method: frame.method,
|
||||
path: frame.path,
|
||||
headers: { ...frame.headers, host: `127.0.0.1:${this.options.loopbackPort}` },
|
||||
headers: this.buildLoopbackHeaders(frame.headers),
|
||||
}, (res) => {
|
||||
const outHeaders: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(res.headers)) {
|
||||
@@ -424,7 +470,7 @@ class PilotAgent {
|
||||
|
||||
const target = `ws://127.0.0.1:${this.options.loopbackPort}${frame.path}`;
|
||||
const client = new WebSocket(target, {
|
||||
headers: { ...frame.headers, host: `127.0.0.1:${this.options.loopbackPort}` },
|
||||
headers: this.buildLoopbackHeaders(frame.headers),
|
||||
maxPayload: MAX_FRAME_SIZE_BYTES,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user