@nubisco/openbridge 0.38.6 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/auth-gate.test.d.ts +2 -0
- package/dist/__tests__/auth-gate.test.d.ts.map +1 -0
- package/dist/__tests__/auth-gate.test.js +141 -0
- package/dist/__tests__/auth-gate.test.js.map +1 -0
- package/dist/__tests__/observations.test.d.ts +2 -0
- package/dist/__tests__/observations.test.d.ts.map +1 -0
- package/dist/__tests__/observations.test.js +115 -0
- package/dist/__tests__/observations.test.js.map +1 -0
- package/dist/auth.d.ts +27 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +71 -11
- package/dist/auth.js.map +1 -1
- package/dist/daemon.d.ts +9 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +14 -1
- package/dist/daemon.js.map +1 -1
- package/dist/observations.d.ts +73 -0
- package/dist/observations.d.ts.map +1 -0
- package/dist/observations.js +93 -0
- package/dist/observations.js.map +1 -0
- package/dist/server.d.ts +11 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +49 -12
- package/dist/server.js.map +1 -1
- package/package.json +10 -10
- package/ui-dist/assets/{index-kJUKPvHa.css → index-B6hvIAIu.css} +1 -1
- package/ui-dist/assets/index-D9v2kI8s.js +109 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/assets/index-DNB3pY-c.js +0 -108
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-gate.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/auth-gate.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { createHmac } from 'crypto';
|
|
3
|
+
import { request } from 'http';
|
|
4
|
+
import Fastify from 'fastify';
|
|
5
|
+
import websocket from '@fastify/websocket';
|
|
6
|
+
import { normalizeRequestPath, registerAuthRoutes, requiresSession } from '../auth.js';
|
|
7
|
+
const SECRET = Buffer.from('auth-gate-test-secret-0123456789', 'utf8');
|
|
8
|
+
function sessionCookie() {
|
|
9
|
+
const data = Buffer.from(JSON.stringify({ userId: 'u1', email: 'u1@example.test', exp: Math.floor(Date.now() / 1000) + 600 }), 'utf8').toString('base64url');
|
|
10
|
+
const sig = createHmac('sha256', SECRET).update(data).digest('base64url');
|
|
11
|
+
return `openbridge_session=${data}.${sig}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Every spelling here reached a protected handler, or would have if the
|
|
15
|
+
* router learned to fold it, while the gate compared the raw URL against
|
|
16
|
+
* "/api/" and "/ws/".
|
|
17
|
+
*/
|
|
18
|
+
const BYPASS_ATTEMPTS = [
|
|
19
|
+
'/%61pi/plugins',
|
|
20
|
+
'/%61%70%69/plugins',
|
|
21
|
+
'/%2561pi/plugins',
|
|
22
|
+
'/%41PI/plugins',
|
|
23
|
+
'/API/plugins',
|
|
24
|
+
'/Api/Plugins',
|
|
25
|
+
'/api%2fplugins',
|
|
26
|
+
'/api%2Fplugins',
|
|
27
|
+
'/%2fapi/plugins',
|
|
28
|
+
'//api/plugins',
|
|
29
|
+
'/api//plugins',
|
|
30
|
+
'/api/plugins.',
|
|
31
|
+
'/api./plugins',
|
|
32
|
+
'/api/./plugins',
|
|
33
|
+
'/%2e/api/plugins',
|
|
34
|
+
'/assets/../api/plugins',
|
|
35
|
+
'/assets/..%2fapi/plugins',
|
|
36
|
+
'/assets/%2e%2e/api/plugins',
|
|
37
|
+
'/static/..%5capi/plugins',
|
|
38
|
+
'/api/plugins?x=1',
|
|
39
|
+
];
|
|
40
|
+
describe('normalizeRequestPath', () => {
|
|
41
|
+
it('folds every spelling of a protected path to the same form', () => {
|
|
42
|
+
for (const url of BYPASS_ATTEMPTS) {
|
|
43
|
+
expect(normalizeRequestPath(url), url).toBe('/api/plugins');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
it('leaves dashboard routes alone', () => {
|
|
47
|
+
expect(normalizeRequestPath('/devices/abc')).toBe('/devices/abc');
|
|
48
|
+
expect(normalizeRequestPath('/')).toBe('/');
|
|
49
|
+
});
|
|
50
|
+
it('refuses a path that cannot be decoded', () => {
|
|
51
|
+
expect(normalizeRequestPath('/%E0%A4%A')).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('requiresSession', () => {
|
|
55
|
+
it('gates by the matched route even when the raw URL looks unrelated', () => {
|
|
56
|
+
expect(requiresSession('/api/plugins', '/%61pi/plugins')).toBe(true);
|
|
57
|
+
expect(requiresSession('/ws/shell', '/%77s/shell')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
it('gates unmatched spellings of protected paths', () => {
|
|
60
|
+
for (const url of BYPASS_ATTEMPTS)
|
|
61
|
+
expect(requiresSession(undefined, url), url).toBe(true);
|
|
62
|
+
expect(requiresSession(undefined, '/WS/logs')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
it('keeps health, auth and the dashboard public', () => {
|
|
65
|
+
expect(requiresSession('/api/health', '/api/health')).toBe(false);
|
|
66
|
+
expect(requiresSession('/auth/me', '/auth/me')).toBe(false);
|
|
67
|
+
expect(requiresSession('/auth/platform/config', '/auth/platform/config')).toBe(false);
|
|
68
|
+
expect(requiresSession(undefined, '/')).toBe(false);
|
|
69
|
+
expect(requiresSession(undefined, '/devices/abc')).toBe(false);
|
|
70
|
+
expect(requiresSession(undefined, '/apiary')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('platform auth gate on a running server', () => {
|
|
74
|
+
let app;
|
|
75
|
+
let port = 0;
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
app = Fastify({ logger: false });
|
|
78
|
+
await app.register(websocket);
|
|
79
|
+
const config = { enabled: true, issuer: 'https://issuer.invalid', appId: null, secret: SECRET };
|
|
80
|
+
await registerAuthRoutes(app, config);
|
|
81
|
+
app.get('/api/health', async () => ({ status: 'ok' }));
|
|
82
|
+
app.get('/api/plugins', async () => ({ plugins: ['secret'] }));
|
|
83
|
+
app.get('/ws/shell', { websocket: true }, (ws) => ws.send('shell-open'));
|
|
84
|
+
// Mirrors the dashboard fallback in server.ts.
|
|
85
|
+
app.setNotFoundHandler(async (_req, reply) => reply.type('text/html').send('<html></html>'));
|
|
86
|
+
await app.listen({ port: 0, host: '127.0.0.1' });
|
|
87
|
+
port = app.server.address().port;
|
|
88
|
+
});
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await app.close();
|
|
91
|
+
});
|
|
92
|
+
it.each(BYPASS_ATTEMPTS)('refuses %s without a session', async (url) => {
|
|
93
|
+
const res = await app.inject({ method: 'GET', url });
|
|
94
|
+
expect(res.statusCode).toBe(401);
|
|
95
|
+
expect(res.json()).toEqual({ error: 'unauthenticated' });
|
|
96
|
+
});
|
|
97
|
+
it('serves protected routes and health as before', async () => {
|
|
98
|
+
expect((await app.inject({ method: 'GET', url: '/api/health' })).statusCode).toBe(200);
|
|
99
|
+
expect((await app.inject({ method: 'GET', url: '/api/plugins' })).statusCode).toBe(401);
|
|
100
|
+
const authed = await app.inject({ method: 'GET', url: '/%61pi/plugins', headers: { cookie: sessionCookie() } });
|
|
101
|
+
expect(authed.statusCode).toBe(200);
|
|
102
|
+
expect(authed.json()).toEqual({ plugins: ['secret'] });
|
|
103
|
+
expect((await app.inject({ method: 'GET', url: '/devices/abc' })).statusCode).toBe(200);
|
|
104
|
+
});
|
|
105
|
+
/** Send a WebSocket upgrade and report the status the server answered with. */
|
|
106
|
+
function upgrade(path, cookie) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
// A fresh connection per attempt: the server closes one it refused an upgrade on.
|
|
109
|
+
const req = request({
|
|
110
|
+
agent: false,
|
|
111
|
+
host: '127.0.0.1',
|
|
112
|
+
port,
|
|
113
|
+
path,
|
|
114
|
+
headers: {
|
|
115
|
+
Connection: 'Upgrade',
|
|
116
|
+
Upgrade: 'websocket',
|
|
117
|
+
'Sec-WebSocket-Version': '13',
|
|
118
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
119
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
req.on('upgrade', (res, socket) => {
|
|
123
|
+
socket.destroy();
|
|
124
|
+
resolve(res.statusCode ?? 0);
|
|
125
|
+
});
|
|
126
|
+
req.on('response', (res) => {
|
|
127
|
+
res.resume();
|
|
128
|
+
resolve(res.statusCode ?? 0);
|
|
129
|
+
});
|
|
130
|
+
req.on('error', reject);
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
it.each(['/ws/shell', '/%77s/shell', '/%77%73/shell', '/WS/shell', '/ws//shell', '/assets/..%2fws/shell'])('refuses a WebSocket upgrade to %s without a session', async (path) => {
|
|
135
|
+
expect(await upgrade(path)).toBe(401);
|
|
136
|
+
});
|
|
137
|
+
it('upgrades a WebSocket with a session', async () => {
|
|
138
|
+
expect(await upgrade('/ws/shell', sessionCookie())).toBe(101);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
//# sourceMappingURL=auth-gate.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-gate.test.js","sourceRoot":"","sources":["../../src/__tests__/auth-gate.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"observations.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/observations.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, readdirSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { ObservationStore, validateObservation } from '../observations.js';
|
|
6
|
+
let home;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
home = mkdtempSync(join(tmpdir(), 'ob-obs-'));
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
rmSync(home, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
function obs(over = {}) {
|
|
14
|
+
return { at: new Date().toISOString(), roomTemperature: 21, comfort: 'ok', ...over };
|
|
15
|
+
}
|
|
16
|
+
describe('ObservationStore', () => {
|
|
17
|
+
it('returns nothing for a device that has never been reported on', () => {
|
|
18
|
+
expect(new ObservationStore(home).list('heating')).toEqual([]);
|
|
19
|
+
expect(new ObservationStore(home).count('heating')).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
it('stores and returns an observation with its context', () => {
|
|
22
|
+
const store = new ObservationStore(home);
|
|
23
|
+
store.append('heating', obs({ roomTemperature: 19, comfort: 'cold', context: { outside: 8, flow: 40 } }));
|
|
24
|
+
const [first] = store.list('heating');
|
|
25
|
+
expect(first.roomTemperature).toBe(19);
|
|
26
|
+
expect(first.comfort).toBe('cold');
|
|
27
|
+
expect(first.context).toEqual({ outside: 8, flow: 40 });
|
|
28
|
+
});
|
|
29
|
+
it('returns newest first', () => {
|
|
30
|
+
const store = new ObservationStore(home);
|
|
31
|
+
store.append('heating', obs({ roomTemperature: 1 }));
|
|
32
|
+
store.append('heating', obs({ roomTemperature: 2 }));
|
|
33
|
+
store.append('heating', obs({ roomTemperature: 3 }));
|
|
34
|
+
expect(store.list('heating').map((o) => o.roomTemperature)).toEqual([3, 2, 1]);
|
|
35
|
+
});
|
|
36
|
+
it('keeps devices apart', () => {
|
|
37
|
+
const store = new ObservationStore(home);
|
|
38
|
+
store.append('heating', obs({ roomTemperature: 19 }));
|
|
39
|
+
store.append('pool', obs({ roomTemperature: 28 }));
|
|
40
|
+
expect(store.list('heating')).toHaveLength(1);
|
|
41
|
+
expect(store.list('pool')[0].roomTemperature).toBe(28);
|
|
42
|
+
});
|
|
43
|
+
it('survives a torn line rather than losing the whole history', () => {
|
|
44
|
+
// A power cut mid-append leaves half a line. One bad record must not take
|
|
45
|
+
// a season of calibration data with it.
|
|
46
|
+
const store = new ObservationStore(home);
|
|
47
|
+
store.append('heating', obs({ roomTemperature: 19 }));
|
|
48
|
+
mkdirSync(join(home, 'observations'), { recursive: true });
|
|
49
|
+
const file = join(home, 'observations', 'heating.jsonl');
|
|
50
|
+
writeFileSync(file, readFileSync(file, 'utf8') + '{"at":"2026-01-01","roomTem\n', 'utf8');
|
|
51
|
+
store.append('heating', obs({ roomTemperature: 21 }));
|
|
52
|
+
expect(store.list('heating').map((o) => o.roomTemperature)).toEqual([21, 19]);
|
|
53
|
+
});
|
|
54
|
+
it('caps how many it returns', () => {
|
|
55
|
+
const store = new ObservationStore(home);
|
|
56
|
+
for (let i = 0; i < 20; i++)
|
|
57
|
+
store.append('heating', obs({ roomTemperature: i }));
|
|
58
|
+
expect(store.list('heating', 5)).toHaveLength(5);
|
|
59
|
+
});
|
|
60
|
+
it('cannot be made to write outside its directory by a hostile device id', () => {
|
|
61
|
+
// Device ids come from plugins and reach the filesystem here. Separators are
|
|
62
|
+
// replaced, so a traversal becomes one flat filename inside observations/
|
|
63
|
+
// rather than a write up the tree.
|
|
64
|
+
const store = new ObservationStore(home);
|
|
65
|
+
store.append('../../escape', obs());
|
|
66
|
+
const written = readdirSync(join(home, 'observations'));
|
|
67
|
+
expect(written).toHaveLength(1);
|
|
68
|
+
expect(written[0]).not.toContain('/');
|
|
69
|
+
expect(store.count('../../escape')).toBe(1);
|
|
70
|
+
// Nothing landed beside the home directory, which is where a traversal
|
|
71
|
+
// would have put it.
|
|
72
|
+
expect(readdirSync(home)).toEqual(['observations']);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('validateObservation', () => {
|
|
76
|
+
it('accepts a well-formed report and stamps its own time', () => {
|
|
77
|
+
const r = validateObservation({ roomTemperature: 21.5, comfort: 'ok' });
|
|
78
|
+
expect(r.ok).toBe(true);
|
|
79
|
+
if (!r.ok)
|
|
80
|
+
return;
|
|
81
|
+
expect(r.value.roomTemperature).toBe(21.5);
|
|
82
|
+
// Stamped here so a client's clock cannot disagree with the rest of the data.
|
|
83
|
+
expect(Date.parse(r.value.at)).toBeGreaterThan(0);
|
|
84
|
+
});
|
|
85
|
+
it('rejects a missing or non-numeric temperature', () => {
|
|
86
|
+
expect(validateObservation({ comfort: 'ok' }).ok).toBe(false);
|
|
87
|
+
expect(validateObservation({ roomTemperature: 'warm', comfort: 'ok' }).ok).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it('rejects a temperature outside any plausible room', () => {
|
|
90
|
+
// A sanity bound against a typo or a Fahrenheit reading, not an opinion
|
|
91
|
+
// about comfort: one bad sample skews a curve fitted from very few.
|
|
92
|
+
expect(validateObservation({ roomTemperature: 72, comfort: 'ok' }).ok).toBe(false);
|
|
93
|
+
expect(validateObservation({ roomTemperature: -40, comfort: 'ok' }).ok).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
it('requires a comfort verdict', () => {
|
|
96
|
+
expect(validateObservation({ roomTemperature: 21 }).ok).toBe(false);
|
|
97
|
+
expect(validateObservation({ roomTemperature: 21, comfort: 'chilly' }).ok).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
it('keeps a note but bounds its length', () => {
|
|
100
|
+
const r = validateObservation({ roomTemperature: 21, comfort: 'ok', note: 'x'.repeat(900) });
|
|
101
|
+
expect(r.ok).toBe(true);
|
|
102
|
+
if (!r.ok)
|
|
103
|
+
return;
|
|
104
|
+
expect(r.value.note).toHaveLength(500);
|
|
105
|
+
});
|
|
106
|
+
it('omits an absent note rather than storing an empty one', () => {
|
|
107
|
+
const r = validateObservation({ roomTemperature: 21, comfort: 'ok' });
|
|
108
|
+
expect(r.ok && 'note' in r.value).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
it('rejects a non-object body', () => {
|
|
111
|
+
expect(validateObservation(null).ok).toBe(false);
|
|
112
|
+
expect(validateObservation('21').ok).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
//# sourceMappingURL=observations.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"observations.test.js","sourceRoot":"","sources":["../../src/__tests__/observations.test.ts"],"names":[],"mappings":"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"}
|
package/dist/auth.d.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* - Verifies platform-issued JWTs via JWKS (no extra deps).
|
|
8
8
|
* - Issues a short-lived, HMAC-signed local session cookie so subsequent
|
|
9
9
|
* /api/* requests don't have to re-validate against the platform.
|
|
10
|
-
* - Adds a Fastify
|
|
11
|
-
* /api/* or /ws/*
|
|
12
|
-
* keeps working)
|
|
10
|
+
* - Adds a Fastify onRequest hook that requires a valid session for any
|
|
11
|
+
* /api/* or /ws/* route, except /api/health (so Docker healthcheck
|
|
12
|
+
* keeps working). /auth/* (the auth endpoints themselves) stays public.
|
|
13
|
+
* The check uses the route the router matched and a normalized path,
|
|
14
|
+
* never the raw URL, see requiresSession().
|
|
13
15
|
*/
|
|
14
16
|
import type { FastifyInstance } from 'fastify';
|
|
15
17
|
export interface TAuthConfig {
|
|
@@ -36,4 +38,26 @@ export interface TAuthConfig {
|
|
|
36
38
|
export declare function acceptPictureClaim(issuer: string | null, picture: unknown): string | undefined;
|
|
37
39
|
export declare function loadAuthConfig(): TAuthConfig;
|
|
38
40
|
export declare function registerAuthRoutes(app: FastifyInstance, config: TAuthConfig): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Reduce a request path to the form the gate compares against, so that no
|
|
43
|
+
* spelling of a protected path can slip past it: percent-encoding (applied any
|
|
44
|
+
* number of times, including encoded slashes and dots), backslashes, repeated
|
|
45
|
+
* slashes, dot segments, trailing dots on a segment and letter case all fold
|
|
46
|
+
* away. Returns null when the path cannot be decoded.
|
|
47
|
+
*/
|
|
48
|
+
export declare function normalizeRequestPath(rawUrl: string): string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Whether a request must carry a valid session.
|
|
51
|
+
*
|
|
52
|
+
* The decision rests on what the router matched, never on the raw URL alone.
|
|
53
|
+
* The router decodes the path before matching, so a check on the raw string
|
|
54
|
+
* let `/%61pi/plugins` reach `/api/plugins` (and `/%77s/shell` open a shell)
|
|
55
|
+
* without a session. The matched route pattern is authoritative for anything
|
|
56
|
+
* that reaches a handler. The normalized path is checked as well, so an
|
|
57
|
+
* unmatched spelling of a protected path is refused rather than falling
|
|
58
|
+
* through, and a future router option (case-insensitive matching, trailing
|
|
59
|
+
* slash folding) cannot reopen the hole. A path that cannot be decoded is
|
|
60
|
+
* refused.
|
|
61
|
+
*/
|
|
62
|
+
export declare function requiresSession(routeUrl: string | undefined, rawUrl: string): boolean;
|
|
39
63
|
//# sourceMappingURL=auth.d.ts.map
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,SAAS,CAAA;AAS9D,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;CACf;AAUD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAc9F;AA2BD,wBAAgB,cAAc,IAAI,WAAW,CAS5C;AAkCD,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,eAAe,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAqFjG;AASD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA0BlE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAKrF"}
|
package/dist/auth.js
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* - Verifies platform-issued JWTs via JWKS (no extra deps).
|
|
8
8
|
* - Issues a short-lived, HMAC-signed local session cookie so subsequent
|
|
9
9
|
* /api/* requests don't have to re-validate against the platform.
|
|
10
|
-
* - Adds a Fastify
|
|
11
|
-
* /api/* or /ws/*
|
|
12
|
-
* keeps working)
|
|
10
|
+
* - Adds a Fastify onRequest hook that requires a valid session for any
|
|
11
|
+
* /api/* or /ws/* route, except /api/health (so Docker healthcheck
|
|
12
|
+
* keeps working). /auth/* (the auth endpoints themselves) stays public.
|
|
13
|
+
* The check uses the route the router matched and a normalized path,
|
|
14
|
+
* never the raw URL, see requiresSession().
|
|
13
15
|
*/
|
|
14
16
|
import { createHmac, randomBytes } from 'crypto';
|
|
15
17
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
@@ -192,19 +194,77 @@ export async function registerAuthRoutes(app, config) {
|
|
|
192
194
|
// Gate /api/* and /ws/* when auth is enabled. Health and auth routes
|
|
193
195
|
// are public so the Docker healthcheck and login flow keep working.
|
|
194
196
|
app.addHook('onRequest', async (req, reply) => {
|
|
195
|
-
|
|
196
|
-
const gated = url.startsWith('/api/') || url.startsWith('/ws/');
|
|
197
|
-
if (!gated)
|
|
198
|
-
return;
|
|
199
|
-
if (url === '/api/health')
|
|
200
|
-
return;
|
|
201
|
-
if (url.startsWith('/auth/'))
|
|
197
|
+
if (!requiresSession(req.routeOptions?.url, req.url))
|
|
202
198
|
return;
|
|
203
199
|
const cookie = getCookie(req, COOKIE_NAME);
|
|
204
200
|
const session = cookie ? verifySession(config.secret, cookie) : null;
|
|
205
201
|
if (!session) {
|
|
206
|
-
reply.code(401).send({ error: 'unauthenticated' });
|
|
202
|
+
return reply.code(401).send({ error: 'unauthenticated' });
|
|
207
203
|
}
|
|
208
204
|
});
|
|
209
205
|
}
|
|
206
|
+
const PUBLIC_PATHS = new Set(['/api/health']);
|
|
207
|
+
function isGatedPath(path) {
|
|
208
|
+
if (PUBLIC_PATHS.has(path))
|
|
209
|
+
return false;
|
|
210
|
+
return path === '/api' || path.startsWith('/api/') || path === '/ws' || path.startsWith('/ws/');
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Reduce a request path to the form the gate compares against, so that no
|
|
214
|
+
* spelling of a protected path can slip past it: percent-encoding (applied any
|
|
215
|
+
* number of times, including encoded slashes and dots), backslashes, repeated
|
|
216
|
+
* slashes, dot segments, trailing dots on a segment and letter case all fold
|
|
217
|
+
* away. Returns null when the path cannot be decoded.
|
|
218
|
+
*/
|
|
219
|
+
export function normalizeRequestPath(rawUrl) {
|
|
220
|
+
let path = rawUrl.split('?')[0]?.split('#')[0] ?? '';
|
|
221
|
+
for (let i = 0; i < 5; i++) {
|
|
222
|
+
let decoded;
|
|
223
|
+
try {
|
|
224
|
+
decoded = decodeURIComponent(path);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (decoded === path)
|
|
230
|
+
break;
|
|
231
|
+
path = decoded;
|
|
232
|
+
}
|
|
233
|
+
if (/%[0-9a-f]{2}/i.test(path))
|
|
234
|
+
return null;
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const raw of path.replace(/\\/g, '/').toLowerCase().split('/')) {
|
|
237
|
+
const segment = raw.trim();
|
|
238
|
+
if (segment === '' || segment === '.')
|
|
239
|
+
continue;
|
|
240
|
+
if (segment === '..') {
|
|
241
|
+
out.pop();
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Some layers treat "api." and "api" as the same name, so the gate does too.
|
|
245
|
+
out.push(segment.replace(/[.\s]+$/, '') || segment);
|
|
246
|
+
}
|
|
247
|
+
return '/' + out.join('/');
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Whether a request must carry a valid session.
|
|
251
|
+
*
|
|
252
|
+
* The decision rests on what the router matched, never on the raw URL alone.
|
|
253
|
+
* The router decodes the path before matching, so a check on the raw string
|
|
254
|
+
* let `/%61pi/plugins` reach `/api/plugins` (and `/%77s/shell` open a shell)
|
|
255
|
+
* without a session. The matched route pattern is authoritative for anything
|
|
256
|
+
* that reaches a handler. The normalized path is checked as well, so an
|
|
257
|
+
* unmatched spelling of a protected path is refused rather than falling
|
|
258
|
+
* through, and a future router option (case-insensitive matching, trailing
|
|
259
|
+
* slash folding) cannot reopen the hole. A path that cannot be decoded is
|
|
260
|
+
* refused.
|
|
261
|
+
*/
|
|
262
|
+
export function requiresSession(routeUrl, rawUrl) {
|
|
263
|
+
if (routeUrl !== undefined && isGatedPath(routeUrl))
|
|
264
|
+
return true;
|
|
265
|
+
const path = normalizeRequestPath(rawUrl);
|
|
266
|
+
if (path === null)
|
|
267
|
+
return true;
|
|
268
|
+
return isGatedPath(path);
|
|
269
|
+
}
|
|
210
270
|
//# sourceMappingURL=auth.js.map
|
package/dist/auth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
|
package/dist/daemon.d.ts
CHANGED
|
@@ -22,6 +22,15 @@ export declare class Daemon {
|
|
|
22
22
|
private metricSeries;
|
|
23
23
|
/** Per-device event timeline, written by plugins through ctx.recordEvent */
|
|
24
24
|
private deviceEvents;
|
|
25
|
+
private observations;
|
|
26
|
+
/**
|
|
27
|
+
* Per-device providers of machine state to attach to an observation.
|
|
28
|
+
*
|
|
29
|
+
* Registered by plugins, because only the plugin knows which of its readings
|
|
30
|
+
* matter. The daemon asks at submit time rather than letting a client supply
|
|
31
|
+
* them, so a report cannot claim conditions that were not happening.
|
|
32
|
+
*/
|
|
33
|
+
private observationContexts;
|
|
25
34
|
/** Per-service HomeKit type overrides, applied at the bridge */
|
|
26
35
|
private homekitServiceTypes;
|
|
27
36
|
/** Per-accessory HomeKit visibility, enforced at the bridge */
|
package/dist/daemon.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AA0BA,eAAO,MAAM,eAAe,QAAuC,CAAA;AACnE,eAAO,MAAM,cAAc,QAAiD,CAAA;AAC5E,eAAO,MAAM,cAAc,QAAiD,CAAA;AAC5E,eAAO,MAAM,WAAW,QAAmC,CAAA;AAiB3D,MAAM,WAAW,aAAa;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAID,qBAAa,MAAM;IACjB,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,SAAS,CAAqC;IACtD,OAAO,CAAC,aAAa,CAAe;IACpC,8FAA8F;IAC9F,OAAO,CAAC,mBAAmB,CAAoB;IAC/C,OAAO,CAAC,QAAQ,CAAoC;IACpD,OAAO,CAAC,kBAAkB,CAAoB;IAC9C,wEAAwE;IACxE,OAAO,CAAC,YAAY,CAAiD;IACrE,gFAAgF;IAChF,OAAO,CAAC,aAAa,CAAsB;IAC3C,qEAAqE;IACrE,OAAO,CAAC,YAAY,CAAkC;IACtD,4EAA4E;IAC5E,OAAO,CAAC,YAAY,CAA6D;IACjF,OAAO,CAAC,YAAY,CAAwC;IAC5D;;;;;;OAMG;IACH,OAAO,CAAC,mBAAmB,CAAmD;IAC9E,gEAAgE;IAChE,OAAO,CAAC,mBAAmB,CAA+E;IAC1G,+DAA+D;IAC/D,OAAO,CAAC,iBAAiB,CAIxB;IAED;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,sBAAsB;IAK9B,0EAA0E;IAC1E,OAAO,CAAC,mBAAmB;IAI3B,OAAO,CAAC,mBAAmB;IAMrB,KAAK,CAAC,OAAO,GAAE,aAAkB;YAkYzB,0BAA0B;IA0LxC,OAAO,CAAC,WAAW;IA8CnB;;;;;OAKG;IACH,OAAO,CAAC,SAAS;IASjB,+EAA+E;IAC/E,OAAO,CAAC,aAAa;IAmCrB,yEAAyE;IACzE,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,mBAAmB;CA8B5B"}
|
package/dist/daemon.js
CHANGED
|
@@ -9,6 +9,7 @@ import { Logger } from '@nubisco/openbridge-logger';
|
|
|
9
9
|
import { waitForFreePort } from './port.js';
|
|
10
10
|
import { rotateIfLarge } from './log-rotation.js';
|
|
11
11
|
import { DeviceEventLog } from './device-events.js';
|
|
12
|
+
import { ObservationStore } from './observations.js';
|
|
12
13
|
import { loadConfig, defaultConfigPath } from '@nubisco/openbridge-config';
|
|
13
14
|
import { createServer } from './server.js';
|
|
14
15
|
import { DeviceSeries, DEFAULT_TIERS } from './timeseries.js';
|
|
@@ -52,6 +53,15 @@ export class Daemon {
|
|
|
52
53
|
metricSeries = new Map();
|
|
53
54
|
/** Per-device event timeline, written by plugins through ctx.recordEvent */
|
|
54
55
|
deviceEvents = new DeviceEventLog(join(OPENBRIDGE_HOME, 'device-events'));
|
|
56
|
+
observations = new ObservationStore(OPENBRIDGE_HOME);
|
|
57
|
+
/**
|
|
58
|
+
* Per-device providers of machine state to attach to an observation.
|
|
59
|
+
*
|
|
60
|
+
* Registered by plugins, because only the plugin knows which of its readings
|
|
61
|
+
* matter. The daemon asks at submit time rather than letting a client supply
|
|
62
|
+
* them, so a report cannot claim conditions that were not happening.
|
|
63
|
+
*/
|
|
64
|
+
observationContexts = new Map();
|
|
55
65
|
/** Per-service HomeKit type overrides, applied at the bridge */
|
|
56
66
|
homekitServiceTypes = new HomeKitServiceTypes(join(OPENBRIDGE_HOME, 'homekit-service-types.json'));
|
|
57
67
|
/** Per-accessory HomeKit visibility, enforced at the bridge */
|
|
@@ -348,7 +358,7 @@ export class Daemon {
|
|
|
348
358
|
}
|
|
349
359
|
// ── HTTP server ───────────────────────────────────────────────────────────
|
|
350
360
|
const localPluginSources = config.localPluginSources ?? [];
|
|
351
|
-
const server = await createServer(this.registry, homebridgeAPI, hapInfo, localPluginSources, this.knownHbPackageNames, this.controls, this.restrictedControls, this.homekitVisibility, this.homekitServiceTypes, (deviceId) => this.resolveNativeAccessory(deviceId), (deviceId) => this.nativeAccessoryUuid(deviceId), this.deviceEvents);
|
|
361
|
+
const server = await createServer(this.registry, homebridgeAPI, hapInfo, localPluginSources, this.knownHbPackageNames, this.controls, this.restrictedControls, this.homekitVisibility, this.homekitServiceTypes, (deviceId) => this.resolveNativeAccessory(deviceId), (deviceId) => this.nativeAccessoryUuid(deviceId), this.deviceEvents, this.observations, (deviceId) => this.observationContexts.get(deviceId)?.());
|
|
352
362
|
await server.listen({ port, host: '0.0.0.0' });
|
|
353
363
|
// ── Energy history sampling ───────────────────────────────────────────────
|
|
354
364
|
setInterval(() => {
|
|
@@ -604,6 +614,9 @@ export class Daemon {
|
|
|
604
614
|
recordEvent: (deviceId, event) => {
|
|
605
615
|
this.deviceEvents.record(deviceId, event);
|
|
606
616
|
},
|
|
617
|
+
registerObservationContext: (deviceId, provider) => {
|
|
618
|
+
this.observationContexts.set(deviceId, provider);
|
|
619
|
+
},
|
|
607
620
|
registerHapBridge(info) {
|
|
608
621
|
const entry = registry.get(plugin.manifest.name);
|
|
609
622
|
if (!entry) {
|