homematic-manager 3.0.0-beta.2 → 3.0.0-beta.4
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/cli.js +7 -0
- package/dist/occulite.d.ts +59 -0
- package/dist/occulite.js +97 -0
- package/dist/options.d.ts +17 -6
- package/dist/options.js +17 -6
- package/dist/server.d.ts +20 -2
- package/dist/server.js +82 -4
- package/dist/sessions.d.ts +9 -1
- package/dist/sessions.js +8 -2
- package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +12 -0
- package/node_modules/@homematic-manager/backend/dist/api/backend.js +199 -0
- package/node_modules/@homematic-manager/backend/dist/cache/names.d.ts +9 -0
- package/node_modules/@homematic-manager/backend/dist/cache/names.js +21 -0
- package/node_modules/@homematic-manager/backend/dist/config/defaults.js +10 -1
- package/node_modules/@homematic-manager/backend/dist/index.d.ts +1 -0
- package/node_modules/@homematic-manager/backend/dist/index.js +2 -0
- package/node_modules/@homematic-manager/backend/dist/meta/client.d.ts +120 -0
- package/node_modules/@homematic-manager/backend/dist/meta/client.js +307 -0
- package/node_modules/@homematic-manager/backend/dist/meta/credentials.d.ts +38 -0
- package/node_modules/@homematic-manager/backend/dist/meta/credentials.js +53 -0
- package/node_modules/@homematic-manager/backend/dist/meta/index.d.ts +11 -0
- package/node_modules/@homematic-manager/backend/dist/meta/index.js +11 -0
- package/node_modules/@homematic-manager/backend/dist/meta/localProvider.d.ts +47 -0
- package/node_modules/@homematic-manager/backend/dist/meta/localProvider.js +119 -0
- package/node_modules/@homematic-manager/backend/dist/meta/occuliteProvider.d.ts +79 -0
- package/node_modules/@homematic-manager/backend/dist/meta/occuliteProvider.js +305 -0
- package/node_modules/@homematic-manager/backend/dist/meta/provider.d.ts +67 -0
- package/node_modules/@homematic-manager/backend/dist/meta/provider.js +18 -0
- package/node_modules/@homematic-manager/backend/dist/meta/service.d.ts +120 -0
- package/node_modules/@homematic-manager/backend/dist/meta/service.js +337 -0
- package/node_modules/@homematic-manager/backend/package.json +1 -1
- package/node_modules/@homematic-manager/core/dist/api/types.d.ts +188 -1
- package/node_modules/@homematic-manager/core/dist/api/types.js +6 -1
- package/node_modules/@homematic-manager/core/dist/index.d.ts +1 -0
- package/node_modules/@homematic-manager/core/dist/index.js +2 -0
- package/node_modules/@homematic-manager/core/dist/meta/document.d.ts +71 -0
- package/node_modules/@homematic-manager/core/dist/meta/document.js +260 -0
- package/node_modules/@homematic-manager/core/dist/meta/follow.d.ts +27 -0
- package/node_modules/@homematic-manager/core/dist/meta/follow.js +180 -0
- package/node_modules/@homematic-manager/core/dist/meta/index.d.ts +16 -0
- package/node_modules/@homematic-manager/core/dist/meta/index.js +16 -0
- package/node_modules/@homematic-manager/core/dist/meta/paths.d.ts +96 -0
- package/node_modules/@homematic-manager/core/dist/meta/paths.js +181 -0
- package/node_modules/@homematic-manager/core/dist/meta/slug.d.ts +22 -0
- package/node_modules/@homematic-manager/core/dist/meta/slug.js +59 -0
- package/node_modules/@homematic-manager/core/dist/meta/store.d.ts +147 -0
- package/node_modules/@homematic-manager/core/dist/meta/store.js +537 -0
- package/node_modules/@homematic-manager/core/dist/meta/tree.d.ts +19 -0
- package/node_modules/@homematic-manager/core/dist/meta/tree.js +53 -0
- package/node_modules/@homematic-manager/core/dist/meta/types.d.ts +139 -0
- package/node_modules/@homematic-manager/core/dist/meta/types.js +61 -0
- package/node_modules/@homematic-manager/core/dist/meta/view.d.ts +50 -0
- package/node_modules/@homematic-manager/core/dist/meta/view.js +103 -0
- package/node_modules/@homematic-manager/core/package.json +1 -1
- package/package.json +3 -3
- package/ui/assets/{index-lB-HxsKR.js → index-0KwZUy-l.js} +2 -2
- package/ui/index.html +1 -1
package/dist/cli.js
CHANGED
|
@@ -98,6 +98,12 @@ export async function runCli(options = {}) {
|
|
|
98
98
|
// door, not a replacement, and saying so keeps the addon's two paths straight in the log
|
|
99
99
|
log.info(`login: CCU credentials required (ReGa), sessions last ${String(Math.round(parsed.sessionTtlMs / 3_600_000))} h of inactivity; the token cookie of settings.cgi is still accepted`);
|
|
100
100
|
}
|
|
101
|
+
if (parsed.authMode === 'occulite') {
|
|
102
|
+
// D-40: no form, no CCU users - the box decides who may in, and this host only checks that
|
|
103
|
+
// the session it was handed is one the box still knows
|
|
104
|
+
log.info(`login: the session openccu-lite hands over is checked against ${parsed.occuliteUrl ?? 'http://127.0.0.1'};` +
|
|
105
|
+
' the token cookie of settings.cgi is still accepted');
|
|
106
|
+
}
|
|
101
107
|
if (parsed.uiDevServer !== undefined) {
|
|
102
108
|
log.info(`development mode: everything but the api is proxied to ${parsed.uiDevServer}`);
|
|
103
109
|
}
|
|
@@ -147,6 +153,7 @@ function startHost(values, log, version) {
|
|
|
147
153
|
...(values.issueCookie === undefined ? {} : { issueCookie: values.issueCookie }),
|
|
148
154
|
authMode: values.authMode,
|
|
149
155
|
sessionTtlMs: values.sessionTtlMs,
|
|
156
|
+
...(values.occuliteUrl === undefined ? {} : { occuliteUrl: values.occuliteUrl }),
|
|
150
157
|
...(values.ccu === undefined ? {} : { ccu: values.ccu }),
|
|
151
158
|
...(values.local === undefined ? {} : { local: values.local }),
|
|
152
159
|
...(values.callbackIp === undefined ? {} : { callbackIp: values.callbackIp }),
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D-40: the login of the addon when it runs on openccu-lite.
|
|
3
|
+
*
|
|
4
|
+
* The CCU case (D-32, task 18) is unchanged and untouched: a user name and a password, checked
|
|
5
|
+
* against ReGa's user list and the CCU's authentication daemon on UDP 1998, both loopback-only.
|
|
6
|
+
* openccu-lite has neither - there is no ReGaHSS, and its own users live in `occulited` - so the
|
|
7
|
+
* question is a different one, and so is the answer.
|
|
8
|
+
*
|
|
9
|
+
* On openccu-lite nobody logs in *here*. The box's shell has already done it, lighttpd's gate has
|
|
10
|
+
* already refused everyone who has no session (their D-28), and the shell opens an addon page with
|
|
11
|
+
* the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention. That session id **is** a valid
|
|
12
|
+
* credential for the box's own APIs, so the check is one request:
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* GET /api/meta/v1/enums Authorization: Bearer <sid> -> 200 valid, 401 not
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* and nothing else is believed. The name of the user behind it comes from `/api/auth/v1/state`
|
|
19
|
+
* afterwards, best effort, only so that the UI can show who is logged in - a box that answers the
|
|
20
|
+
* first call and not the second still lets the session in.
|
|
21
|
+
*
|
|
22
|
+
* The metadata API and not the auth API for the decision, on purpose: it is the one openccu-lite
|
|
23
|
+
* declares normative (their D-16), it is the API this application depends on anyway, and a session
|
|
24
|
+
* that cannot read the metadata store is of no use to a device manager whatever else it can do.
|
|
25
|
+
*/
|
|
26
|
+
/** What the check answers with: who it is, in the shape the rest of the host already speaks. */
|
|
27
|
+
export interface OcculiteSession {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/**
|
|
30
|
+
* ReGa's `UserLevel()`, so that `SessionInfo` keeps one meaning across the two boxes: 8 for an
|
|
31
|
+
* administrator, 2 for a user. openccu-lite has exactly those two roles.
|
|
32
|
+
*/
|
|
33
|
+
readonly level: number;
|
|
34
|
+
/** The session id, which is also the credential every write to the box goes out with. */
|
|
35
|
+
readonly sid: string;
|
|
36
|
+
}
|
|
37
|
+
export interface OcculiteAuthOptions {
|
|
38
|
+
/** `http://127.0.0.1` on the box; a test points it at its own server. */
|
|
39
|
+
readonly baseUrl: string;
|
|
40
|
+
readonly timeoutMs?: number;
|
|
41
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
42
|
+
readonly onNotice?: (level: 'info' | 'warn' | 'error', message: string) => void;
|
|
43
|
+
}
|
|
44
|
+
export declare const OCCULITE_TIMEOUT_MS = 5000;
|
|
45
|
+
/** A session id as the shell hands it over, `@…@` and all, or `undefined` when it is not one. */
|
|
46
|
+
export declare function parseSid(value: string | null | undefined): string | undefined;
|
|
47
|
+
/** Checks a session id against the box that issued it. */
|
|
48
|
+
export declare class OcculiteAuthenticator {
|
|
49
|
+
#private;
|
|
50
|
+
constructor(options: OcculiteAuthOptions);
|
|
51
|
+
get baseUrl(): string;
|
|
52
|
+
/**
|
|
53
|
+
* The session, or `undefined` - which is the answer for an unknown id, an expired one and a box
|
|
54
|
+
* that cannot be asked alike. A caller cannot tell them apart, and therefore cannot use this to
|
|
55
|
+
* find out whether a session exists.
|
|
56
|
+
*/
|
|
57
|
+
check(rawSid: string | null | undefined): Promise<OcculiteSession | undefined>;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=occulite.d.ts.map
|
package/dist/occulite.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D-40: the login of the addon when it runs on openccu-lite.
|
|
3
|
+
*
|
|
4
|
+
* The CCU case (D-32, task 18) is unchanged and untouched: a user name and a password, checked
|
|
5
|
+
* against ReGa's user list and the CCU's authentication daemon on UDP 1998, both loopback-only.
|
|
6
|
+
* openccu-lite has neither - there is no ReGaHSS, and its own users live in `occulited` - so the
|
|
7
|
+
* question is a different one, and so is the answer.
|
|
8
|
+
*
|
|
9
|
+
* On openccu-lite nobody logs in *here*. The box's shell has already done it, lighttpd's gate has
|
|
10
|
+
* already refused everyone who has no session (their D-28), and the shell opens an addon page with
|
|
11
|
+
* the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention. That session id **is** a valid
|
|
12
|
+
* credential for the box's own APIs, so the check is one request:
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* GET /api/meta/v1/enums Authorization: Bearer <sid> -> 200 valid, 401 not
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* and nothing else is believed. The name of the user behind it comes from `/api/auth/v1/state`
|
|
19
|
+
* afterwards, best effort, only so that the UI can show who is logged in - a box that answers the
|
|
20
|
+
* first call and not the second still lets the session in.
|
|
21
|
+
*
|
|
22
|
+
* The metadata API and not the auth API for the decision, on purpose: it is the one openccu-lite
|
|
23
|
+
* declares normative (their D-16), it is the API this application depends on anyway, and a session
|
|
24
|
+
* that cannot read the metadata store is of no use to a device manager whatever else it can do.
|
|
25
|
+
*/
|
|
26
|
+
export const OCCULITE_TIMEOUT_MS = 5000;
|
|
27
|
+
/** A session id as the shell hands it over, `@…@` and all, or `undefined` when it is not one. */
|
|
28
|
+
export function parseSid(value) {
|
|
29
|
+
const bare = (value ?? '').replace(/^@|@$/g, '').trim();
|
|
30
|
+
return /^[0-9a-zA-Z]{6,64}$/.test(bare) ? bare : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Checks a session id against the box that issued it. */
|
|
33
|
+
export class OcculiteAuthenticator {
|
|
34
|
+
#options;
|
|
35
|
+
#fetch;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.#options = options;
|
|
38
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
39
|
+
}
|
|
40
|
+
get baseUrl() {
|
|
41
|
+
return this.#options.baseUrl.replace(/\/$/, '');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The session, or `undefined` - which is the answer for an unknown id, an expired one and a box
|
|
45
|
+
* that cannot be asked alike. A caller cannot tell them apart, and therefore cannot use this to
|
|
46
|
+
* find out whether a session exists.
|
|
47
|
+
*/
|
|
48
|
+
async check(rawSid) {
|
|
49
|
+
const sid = parseSid(rawSid);
|
|
50
|
+
if (sid === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const response = await this.#fetch(`${this.baseUrl}/api/meta/v1/enums`, {
|
|
55
|
+
signal: AbortSignal.timeout(this.#options.timeoutMs ?? OCCULITE_TIMEOUT_MS),
|
|
56
|
+
headers: { Authorization: `Bearer ${sid}`, Accept: 'application/json' },
|
|
57
|
+
});
|
|
58
|
+
if (response.status === 401 || response.status === 403) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
this.#options.onNotice?.('warn', `the box answered ${String(response.status)} for a session check`);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
this.#options.onNotice?.('warn', `the box could not be asked about a session: ${String(error)}`);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const who = await this.#who(sid);
|
|
71
|
+
return { sid, name: who?.name ?? 'openccu-lite', level: who?.level ?? 2 };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Who the session belongs to. Best effort: the session is already valid at this point, and a
|
|
75
|
+
* name that could not be read costs a label in the header and nothing else.
|
|
76
|
+
*/
|
|
77
|
+
async #who(sid) {
|
|
78
|
+
try {
|
|
79
|
+
const response = await this.#fetch(`${this.baseUrl}/api/auth/v1/state`, {
|
|
80
|
+
signal: AbortSignal.timeout(this.#options.timeoutMs ?? OCCULITE_TIMEOUT_MS),
|
|
81
|
+
headers: { Authorization: `Bearer ${sid}`, Accept: 'application/json' },
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const body = (await response.json());
|
|
87
|
+
if (body.authenticated !== true || typeof body.user !== 'string') {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return { name: body.user, level: body.role === 'admin' ? 8 : 2 };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=occulite.js.map
|
package/dist/options.d.ts
CHANGED
|
@@ -19,11 +19,15 @@ export declare const ENV_PREFIX = "HMM";
|
|
|
19
19
|
*/
|
|
20
20
|
export declare const DEFAULT_IDLE_UNSUBSCRIBE = "5m";
|
|
21
21
|
/**
|
|
22
|
-
* D-32: how a browser is let in. `token` is what every install type but the CCU addon uses, and
|
|
22
|
+
* D-32/D-40: how a browser is let in. `token` is what every install type but the CCU addon uses, and
|
|
23
23
|
* stays the default there too - the addon's `settings.cgi` hand-over is the primary path and the
|
|
24
24
|
* login is switched on by hand in `etc/hmm.env` or from the addon's settings page.
|
|
25
|
+
*
|
|
26
|
+
* `occulite` is the openccu-lite form of the same idea: no form of our own, the box's shell hands
|
|
27
|
+
* the session over on the URL and the host checks it against the box. The addon's `update_script`
|
|
28
|
+
* sets it by itself when it is installed on a box (`VARIANT=lite` in `/VERSION`).
|
|
25
29
|
*/
|
|
26
|
-
export declare const AUTH_MODES: readonly ["token", "rega"];
|
|
30
|
+
export declare const AUTH_MODES: readonly ["token", "rega", "occulite"];
|
|
27
31
|
/** D-32: a login lasts a day of not being used. Sliding, so a tab in use never expires. */
|
|
28
32
|
export declare const DEFAULT_SESSION_TTL = "24h";
|
|
29
33
|
/**
|
|
@@ -34,7 +38,7 @@ export declare const DEFAULT_SESSION_TTL = "24h";
|
|
|
34
38
|
* rather than a silently disabled feature.
|
|
35
39
|
*/
|
|
36
40
|
export declare function parseDuration(value: string, option: string): number;
|
|
37
|
-
/** Is this one of the
|
|
41
|
+
/** Is this one of the three modes? `parseRaw` has already refused everything else. */
|
|
38
42
|
export declare function isAuthMode(value: string | undefined): value is AuthMode;
|
|
39
43
|
export type OptionType = 'string' | 'number' | 'boolean';
|
|
40
44
|
export interface OptionDefinition {
|
|
@@ -102,13 +106,18 @@ export declare const OPTIONS: {
|
|
|
102
106
|
};
|
|
103
107
|
readonly 'auth-mode': {
|
|
104
108
|
readonly type: "string";
|
|
105
|
-
readonly describe:
|
|
106
|
-
readonly choices: readonly ["token", "rega"];
|
|
109
|
+
readonly describe: string;
|
|
110
|
+
readonly choices: readonly ["token", "rega", "occulite"];
|
|
107
111
|
readonly default: "token";
|
|
108
112
|
};
|
|
113
|
+
readonly 'occulite-url': {
|
|
114
|
+
readonly type: "string";
|
|
115
|
+
readonly describe: "with --auth-mode occulite: the box that issued the session";
|
|
116
|
+
readonly defaultDescription: "http://127.0.0.1";
|
|
117
|
+
};
|
|
109
118
|
readonly 'session-ttl': {
|
|
110
119
|
readonly type: "string";
|
|
111
|
-
readonly describe: "with --auth-mode rega: how long a login lasts without being used (24h, 90m, ...)";
|
|
120
|
+
readonly describe: "with --auth-mode rega or occulite: how long a login lasts without being used (24h, 90m, ...)";
|
|
112
121
|
readonly default: "24h";
|
|
113
122
|
};
|
|
114
123
|
readonly ccu: {
|
|
@@ -203,6 +212,8 @@ export interface WebOptions {
|
|
|
203
212
|
readonly issueCookie: boolean | undefined;
|
|
204
213
|
/** D-32: `token` or `rega`. */
|
|
205
214
|
readonly authMode: AuthMode;
|
|
215
|
+
/** D-40: the box `--auth-mode occulite` checks a handed-over session against. */
|
|
216
|
+
readonly occuliteUrl: string | undefined;
|
|
206
217
|
/** D-32, in milliseconds. */
|
|
207
218
|
readonly sessionTtlMs: number;
|
|
208
219
|
readonly ccu: string | undefined;
|
package/dist/options.js
CHANGED
|
@@ -20,11 +20,15 @@ export const ENV_PREFIX = 'HMM';
|
|
|
20
20
|
*/
|
|
21
21
|
export const DEFAULT_IDLE_UNSUBSCRIBE = '5m';
|
|
22
22
|
/**
|
|
23
|
-
* D-32: how a browser is let in. `token` is what every install type but the CCU addon uses, and
|
|
23
|
+
* D-32/D-40: how a browser is let in. `token` is what every install type but the CCU addon uses, and
|
|
24
24
|
* stays the default there too - the addon's `settings.cgi` hand-over is the primary path and the
|
|
25
25
|
* login is switched on by hand in `etc/hmm.env` or from the addon's settings page.
|
|
26
|
+
*
|
|
27
|
+
* `occulite` is the openccu-lite form of the same idea: no form of our own, the box's shell hands
|
|
28
|
+
* the session over on the URL and the host checks it against the box. The addon's `update_script`
|
|
29
|
+
* sets it by itself when it is installed on a box (`VARIANT=lite` in `/VERSION`).
|
|
26
30
|
*/
|
|
27
|
-
export const AUTH_MODES = ['token', 'rega'];
|
|
31
|
+
export const AUTH_MODES = ['token', 'rega', 'occulite'];
|
|
28
32
|
/** D-32: a login lasts a day of not being used. Sliding, so a tab in use never expires. */
|
|
29
33
|
export const DEFAULT_SESSION_TTL = '24h';
|
|
30
34
|
/**
|
|
@@ -43,9 +47,9 @@ export function parseDuration(value, option) {
|
|
|
43
47
|
const factor = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[match[2] ?? 's'] ?? 1000;
|
|
44
48
|
return amount * factor;
|
|
45
49
|
}
|
|
46
|
-
/** Is this one of the
|
|
50
|
+
/** Is this one of the three modes? `parseRaw` has already refused everything else. */
|
|
47
51
|
export function isAuthMode(value) {
|
|
48
|
-
return value === 'token' || value === 'rega';
|
|
52
|
+
return value === 'token' || value === 'rega' || value === 'occulite';
|
|
49
53
|
}
|
|
50
54
|
export const OPTIONS = {
|
|
51
55
|
port: {
|
|
@@ -101,13 +105,19 @@ export const OPTIONS = {
|
|
|
101
105
|
},
|
|
102
106
|
'auth-mode': {
|
|
103
107
|
type: 'string',
|
|
104
|
-
describe: 'token: the token guards the api. rega: ask for a CCU login first (addon only, needs --local)'
|
|
108
|
+
describe: 'token: the token guards the api. rega: ask for a CCU login first (addon only, needs --local). ' +
|
|
109
|
+
'occulite: take the session openccu-lite hands over',
|
|
105
110
|
choices: AUTH_MODES,
|
|
106
111
|
default: 'token',
|
|
107
112
|
},
|
|
113
|
+
'occulite-url': {
|
|
114
|
+
type: 'string',
|
|
115
|
+
describe: 'with --auth-mode occulite: the box that issued the session',
|
|
116
|
+
defaultDescription: 'http://127.0.0.1',
|
|
117
|
+
},
|
|
108
118
|
'session-ttl': {
|
|
109
119
|
type: 'string',
|
|
110
|
-
describe: 'with --auth-mode rega: how long a login lasts without being used (24h, 90m, ...)',
|
|
120
|
+
describe: 'with --auth-mode rega or occulite: how long a login lasts without being used (24h, 90m, ...)',
|
|
111
121
|
default: DEFAULT_SESSION_TTL,
|
|
112
122
|
},
|
|
113
123
|
ccu: {
|
|
@@ -295,6 +305,7 @@ export function parseOptions(argv, env = process.env) {
|
|
|
295
305
|
auth: boolean('auth'),
|
|
296
306
|
issueCookie: boolean('issue-cookie'),
|
|
297
307
|
authMode: isAuthMode(string('auth-mode')) ? string('auth-mode') : 'token',
|
|
308
|
+
occuliteUrl: string('occulite-url'),
|
|
298
309
|
sessionTtlMs: parseDuration(string('session-ttl'), '--session-ttl'),
|
|
299
310
|
ccu: string('ccu'),
|
|
300
311
|
local: boolean('local'),
|
package/dist/server.d.ts
CHANGED
|
@@ -46,8 +46,14 @@ export declare const KEEPALIVE_INTERVAL_MS = 25000;
|
|
|
46
46
|
* command line, the environment or the addon's session-checked `settings.cgi`. `rega` puts a login
|
|
47
47
|
* page in front of the UI and checks the credentials against the CCU itself - which only works
|
|
48
48
|
* *on* the CCU, because both services it asks are loopback-only.
|
|
49
|
+
*
|
|
50
|
+
* D-40 adds `occulite`, for the addon on an openccu-lite box. There is no login page in that mode:
|
|
51
|
+
* the box's shell has already authenticated the user and opens the addon with the session on the
|
|
52
|
+
* URL (`?sid=@xxxxxxxxxx@`), and the host checks that session against the box's metadata API. A
|
|
53
|
+
* request without one is sent to the box's own login rather than shown a form this application
|
|
54
|
+
* could not answer - openccu-lite's users are the box's, not the CCU's.
|
|
49
55
|
*/
|
|
50
|
-
export type AuthMode = 'token' | 'rega';
|
|
56
|
+
export type AuthMode = 'token' | 'rega' | 'occulite';
|
|
51
57
|
/** What the login endpoint asks. `RegaAuthenticator` is the real one; a test passes a fake. */
|
|
52
58
|
export interface CredentialChecker {
|
|
53
59
|
authenticate(user: string, password: string): Promise<{
|
|
@@ -55,6 +61,14 @@ export interface CredentialChecker {
|
|
|
55
61
|
level: number;
|
|
56
62
|
} | undefined>;
|
|
57
63
|
}
|
|
64
|
+
/** D-40: what the `occulite` hand-over asks. `OcculiteAuthenticator` is the real one. */
|
|
65
|
+
export interface SessionChecker {
|
|
66
|
+
check(sid: string | null | undefined): Promise<{
|
|
67
|
+
name: string;
|
|
68
|
+
level: number;
|
|
69
|
+
sid: string;
|
|
70
|
+
} | undefined>;
|
|
71
|
+
}
|
|
58
72
|
export interface WebHostOptions {
|
|
59
73
|
readonly port?: number;
|
|
60
74
|
readonly host?: string;
|
|
@@ -85,6 +99,10 @@ export interface WebHostOptions {
|
|
|
85
99
|
readonly sessionTtlMs?: number | undefined;
|
|
86
100
|
/** D-32: injected by the tests in place of the real ReGa and UDP check. */
|
|
87
101
|
readonly authenticator?: CredentialChecker | undefined;
|
|
102
|
+
/** D-40: the box the `occulite` mode checks sessions against. `http://127.0.0.1` by default. */
|
|
103
|
+
readonly occuliteUrl?: string | undefined;
|
|
104
|
+
/** D-40: injected by the tests in place of the real box. */
|
|
105
|
+
readonly sessionChecker?: SessionChecker | undefined;
|
|
88
106
|
/** Serve the UI in demo mode and start no backend at all. */
|
|
89
107
|
readonly demo?: boolean;
|
|
90
108
|
/** Written to `ConnectionConfig.host` when it differs from what is configured. */
|
|
@@ -126,7 +144,7 @@ export interface WebHost {
|
|
|
126
144
|
readonly token: string | undefined;
|
|
127
145
|
/** D-32: how a browser is let in. */
|
|
128
146
|
readonly authMode: AuthMode;
|
|
129
|
-
/** D-32: the login sessions; `undefined` in `token` mode, where there are none. */
|
|
147
|
+
/** D-32/D-40: the login sessions; `undefined` in `token` mode, where there are none. */
|
|
130
148
|
readonly sessions: SessionStore | undefined;
|
|
131
149
|
close(): Promise<void>;
|
|
132
150
|
}
|
package/dist/server.js
CHANGED
|
@@ -29,6 +29,7 @@ import { applyCookieToken, applySessionToken, clearedSessionCookie, createToken,
|
|
|
29
29
|
import { DeviceImageService, readIconMapFile } from './images.js';
|
|
30
30
|
import { clientAddress, parseLoginForm, pickLanguage, readBody, renderLoginPage, } from './login.js';
|
|
31
31
|
import { createLogger, silentLogger } from './log.js';
|
|
32
|
+
import { OcculiteAuthenticator, parseSid } from './occulite.js';
|
|
32
33
|
import { defaultDataDir, defaultMetadataDir, defaultUiDir, packageVersion } from './paths.js';
|
|
33
34
|
import { proxyRequest, proxyUpgrade } from './proxy.js';
|
|
34
35
|
import { RateLimiter, SessionStore } from './sessions.js';
|
|
@@ -77,8 +78,8 @@ export async function createWebHost(options = {}) {
|
|
|
77
78
|
const apiPath = `${base}api`;
|
|
78
79
|
const authMode = options.authMode ?? 'token';
|
|
79
80
|
requireLocalForRega(authMode, options, auth);
|
|
80
|
-
// D-32: everything
|
|
81
|
-
const sessions = authMode === '
|
|
81
|
+
// D-32/D-40: everything a login needs, and nothing at all in `token` mode
|
|
82
|
+
const sessions = authMode === 'token' ? undefined : new SessionStore(sessionStoreOptions(options));
|
|
82
83
|
const loginLimit = authMode === 'rega' ? new RateLimiter() : undefined;
|
|
83
84
|
const credentials = authMode === 'rega'
|
|
84
85
|
? (options.authenticator ??
|
|
@@ -86,6 +87,15 @@ export async function createWebHost(options = {}) {
|
|
|
86
87
|
onNotice: (level, message) => log[level](`login: ${message}`),
|
|
87
88
|
}))
|
|
88
89
|
: undefined;
|
|
90
|
+
// D-40: the box that issued the session the shell hands over
|
|
91
|
+
const occuliteUrl = (options.occuliteUrl ?? 'http://127.0.0.1').replace(/\/$/, '');
|
|
92
|
+
const boxSessions = authMode === 'occulite'
|
|
93
|
+
? (options.sessionChecker ??
|
|
94
|
+
new OcculiteAuthenticator({
|
|
95
|
+
baseUrl: occuliteUrl,
|
|
96
|
+
onNotice: (level, message) => log[level](`login: ${message}`),
|
|
97
|
+
}))
|
|
98
|
+
: undefined;
|
|
89
99
|
let connection;
|
|
90
100
|
let backend;
|
|
91
101
|
if (!demo) {
|
|
@@ -146,6 +156,17 @@ export async function createWebHost(options = {}) {
|
|
|
146
156
|
return;
|
|
147
157
|
}
|
|
148
158
|
const rest = pathname.slice(base.length);
|
|
159
|
+
// D-40: the hand-over from openccu-lite's shell. It opens an addon page with the user's
|
|
160
|
+
// session on the URL (`?sid=@xxxxxxxxxx@`), the same convention the CCU's WebUI uses; the
|
|
161
|
+
// session is checked against the box, turned into one of ours, and taken off the URL again
|
|
162
|
+
// so that it does not sit in the address bar, in a bookmark and in every referrer.
|
|
163
|
+
if (boxSessions && sessions && (method === 'GET' || method === 'HEAD')) {
|
|
164
|
+
const offered = url.searchParams.get('sid');
|
|
165
|
+
if (parseSid(offered) !== undefined) {
|
|
166
|
+
await handleHandover(request, response, offered, url, rest);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
149
170
|
// D-32: the login is the one route that takes a POST, and the one route that is reachable
|
|
150
171
|
// without a session - it is what hands one out
|
|
151
172
|
if (sessions && rest === 'login') {
|
|
@@ -168,7 +189,9 @@ export async function createWebHost(options = {}) {
|
|
|
168
189
|
if (rest === 'logout') {
|
|
169
190
|
sessions.remove(readCookie(request.headers.cookie, SESSION_COOKIE));
|
|
170
191
|
response.writeHead(302, {
|
|
171
|
-
|
|
192
|
+
// D-40: on openccu-lite the login is the box's, not ours, so this is where a
|
|
193
|
+
// logout goes; the box's own session stays valid until it is ended there
|
|
194
|
+
Location: authMode === 'occulite' ? '/' : `${base}login`,
|
|
172
195
|
'Set-Cookie': clearedSessionCookie(base, isHttps(request)),
|
|
173
196
|
'Cache-Control': 'no-store',
|
|
174
197
|
});
|
|
@@ -176,13 +199,27 @@ export async function createWebHost(options = {}) {
|
|
|
176
199
|
return;
|
|
177
200
|
}
|
|
178
201
|
session = sessions.get(readCookie(request.headers.cookie, SESSION_COOKIE));
|
|
202
|
+
if (session?.credential !== undefined) {
|
|
203
|
+
// D-40: writes to the box go out as the session of whoever is looking at the page
|
|
204
|
+
backend?.noteMetaSession(session.credential);
|
|
205
|
+
}
|
|
179
206
|
if (!session && !hasValidToken(request)) {
|
|
180
207
|
// the page itself becomes the login form; anything else - assets, metadata, images -
|
|
181
208
|
// is answered with a plain 401, because a login page in place of a stylesheet only
|
|
182
209
|
// confuses a browser. The `settings.cgi` hand-over of task 13 never lands here: its
|
|
183
210
|
// token cookie is accepted above, and the WebUI session it checked is why.
|
|
184
211
|
if (rest === '' || rest === 'index.html') {
|
|
185
|
-
|
|
212
|
+
if (authMode === 'occulite') {
|
|
213
|
+
// D-40: there is no form to show - openccu-lite's users are the box's, and
|
|
214
|
+
// lighttpd's own gate sends an unauthenticated request to the box's login
|
|
215
|
+
// long before it reaches this process. Landing there is the honest answer
|
|
216
|
+
// to a bookmark that was opened without a session.
|
|
217
|
+
response.writeHead(302, { Location: '/', 'Cache-Control': 'no-store' });
|
|
218
|
+
response.end();
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
sendLoginPage(request, response, 200);
|
|
222
|
+
}
|
|
186
223
|
}
|
|
187
224
|
else {
|
|
188
225
|
unauthorised(response);
|
|
@@ -277,6 +314,37 @@ export async function createWebHost(options = {}) {
|
|
|
277
314
|
});
|
|
278
315
|
response.end();
|
|
279
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* D-40: `?sid=@…@` from openccu-lite's shell into a session of this host.
|
|
319
|
+
*
|
|
320
|
+
* One check against the box and one redirect. A session that the box does not know goes back to
|
|
321
|
+
* the box's login rather than to a form here, and the failure is not counted or rate-limited:
|
|
322
|
+
* the id was not guessed at, it was handed over, and the only way to get a wrong one is for it
|
|
323
|
+
* to have expired.
|
|
324
|
+
*/
|
|
325
|
+
async function handleHandover(request, response, offered, url, rest) {
|
|
326
|
+
const checked = await boxSessions?.check(offered);
|
|
327
|
+
if (!checked) {
|
|
328
|
+
log.warn(`login: openccu-lite refused the session offered from ${clientAddress(request)}`);
|
|
329
|
+
response.writeHead(302, { Location: '/', 'Cache-Control': 'no-store' });
|
|
330
|
+
response.end();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const store = sessions;
|
|
334
|
+
const session = store.create(checked.name, checked.level, checked.sid);
|
|
335
|
+
backend?.noteMetaSession(checked.sid);
|
|
336
|
+
log.info(`login: ${checked.name} (level ${String(checked.level)}) through the openccu-lite shell`);
|
|
337
|
+
// the same page, without the session in the URL
|
|
338
|
+
const query = new URLSearchParams(url.searchParams);
|
|
339
|
+
query.delete('sid');
|
|
340
|
+
const search = query.size === 0 ? '' : `?${query.toString()}`;
|
|
341
|
+
response.writeHead(302, {
|
|
342
|
+
Location: `${base}${rest}${search}`,
|
|
343
|
+
'Set-Cookie': sessionCookie(session.id, base, store.ttlMs / 1000, isHttps(request)),
|
|
344
|
+
'Cache-Control': 'no-store',
|
|
345
|
+
});
|
|
346
|
+
response.end();
|
|
347
|
+
}
|
|
280
348
|
/** The token cookie of task 13 - `settings.cgi`'s hand-over still opens every door. */
|
|
281
349
|
function hasValidToken(request) {
|
|
282
350
|
return token !== undefined && readCookie(request.headers.cookie, TOKEN_COOKIE) === token;
|
|
@@ -416,6 +484,16 @@ export async function ensureDataDir(dataDir) {
|
|
|
416
484
|
* says why is the only useful answer.
|
|
417
485
|
*/
|
|
418
486
|
export function requireLocalForRega(authMode, options, auth) {
|
|
487
|
+
if (authMode === 'occulite') {
|
|
488
|
+
// D-40: no `--local` requirement - the box is reached over HTTP and may be named with
|
|
489
|
+
// `--occulite-url` - but the same rule about the token holds: without one the socket lets
|
|
490
|
+
// everybody in and checking the session would guard nothing.
|
|
491
|
+
if (!auth) {
|
|
492
|
+
throw new Error('--auth-mode occulite cannot be combined with --no-auth: without a token the api socket lets ' +
|
|
493
|
+
'everyone in anyway, and the session check would guard nothing.');
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
419
497
|
if (authMode !== 'rega') {
|
|
420
498
|
return;
|
|
421
499
|
}
|
package/dist/sessions.d.ts
CHANGED
|
@@ -22,6 +22,14 @@ export interface Session {
|
|
|
22
22
|
readonly user: string;
|
|
23
23
|
/** ReGa `UserLevel()`: 8 admin, 2 user, 1 guest. */
|
|
24
24
|
readonly level: number;
|
|
25
|
+
/**
|
|
26
|
+
* D-40: the credential the box gave this user - their `?sid=@…@`.
|
|
27
|
+
*
|
|
28
|
+
* Kept because on openccu-lite it is not only proof of a login: it is what a write to the box's
|
|
29
|
+
* metadata store goes out with, so that a rename is attributed to the person who asked for it
|
|
30
|
+
* rather than to the addon. Absent in every other mode, where nothing downstream needs one.
|
|
31
|
+
*/
|
|
32
|
+
readonly credential?: string;
|
|
25
33
|
/** Epoch milliseconds; pushed out by every request that uses the session. */
|
|
26
34
|
expiresAt: number;
|
|
27
35
|
}
|
|
@@ -41,7 +49,7 @@ export declare class SessionStore {
|
|
|
41
49
|
constructor(options?: SessionStoreOptions);
|
|
42
50
|
get size(): number;
|
|
43
51
|
/** A new session for a user who has just proved who they are. */
|
|
44
|
-
create(user: string, level: number): Session;
|
|
52
|
+
create(user: string, level: number, credential?: string): Session;
|
|
45
53
|
/**
|
|
46
54
|
* The session behind an id, expiry slid forward - or `undefined` when there is none, which is
|
|
47
55
|
* the answer for an unknown, a forged and an expired id alike.
|
package/dist/sessions.js
CHANGED
|
@@ -37,9 +37,15 @@ export class SessionStore {
|
|
|
37
37
|
return this.#sessions.size;
|
|
38
38
|
}
|
|
39
39
|
/** A new session for a user who has just proved who they are. */
|
|
40
|
-
create(user, level) {
|
|
40
|
+
create(user, level, credential) {
|
|
41
41
|
this.sweep();
|
|
42
|
-
const session = {
|
|
42
|
+
const session = {
|
|
43
|
+
id: this.#createId(),
|
|
44
|
+
user,
|
|
45
|
+
level,
|
|
46
|
+
...(credential === undefined ? {} : { credential }),
|
|
47
|
+
expiresAt: this.#now() + this.ttlMs,
|
|
48
|
+
};
|
|
43
49
|
this.#sessions.set(session.id, session);
|
|
44
50
|
return session;
|
|
45
51
|
}
|
|
@@ -17,6 +17,7 @@ import { maintenanceAddress, type AppConfig, type ApiEventName, type ApiEvents,
|
|
|
17
17
|
import { type ConfigStoreOptions } from '../config/store.js';
|
|
18
18
|
import { type DiscoverOptions } from '../discovery/discover.js';
|
|
19
19
|
import { InterfaceManager, type InterfaceManagerOptions } from '../interfaces/manager.js';
|
|
20
|
+
import { type MetaServiceOptions } from '../meta/service.js';
|
|
20
21
|
import { RegaService, type RegaServiceOptions } from '../rega/client.js';
|
|
21
22
|
import { ApiEventEmitter } from '../util/emitter.js';
|
|
22
23
|
/** How often the BidCos service messages are polled while the connection is up. */
|
|
@@ -48,6 +49,8 @@ export interface BackendOptions extends Omit<ConfigStoreOptions, 'version'> {
|
|
|
48
49
|
/** Injected by the tests in place of the real world. */
|
|
49
50
|
readonly createInterfaceManager?: (options: InterfaceManagerOptions) => InterfaceManager;
|
|
50
51
|
readonly createRega?: (options: RegaServiceOptions) => RegaService;
|
|
52
|
+
/** D-40: injected by the tests, and by the integration test that runs a real occulited. */
|
|
53
|
+
readonly metaOptions?: Partial<MetaServiceOptions>;
|
|
51
54
|
readonly discover?: (options: DiscoverOptions) => Promise<AppConfig['discovered']>;
|
|
52
55
|
readonly interfaceManagerOptions?: Partial<InterfaceManagerOptions>;
|
|
53
56
|
readonly regaOptions?: Partial<RegaServiceOptions>;
|
|
@@ -82,6 +85,15 @@ export declare class Backend {
|
|
|
82
85
|
* on the wire unchanged.
|
|
83
86
|
*/
|
|
84
87
|
request<M extends ApiMethodName>(method: M, ...params: ApiParams<M>): Promise<ApiResult<M>>;
|
|
88
|
+
/**
|
|
89
|
+
* D-40: the session of the person looking at the page, for the writes to an openccu-lite box.
|
|
90
|
+
*
|
|
91
|
+
* The shell of the box hands an addon page the user's session as `?sid=@xxxxxxxxxx@`, and that
|
|
92
|
+
* session is a valid credential for the metadata API. The addon reads with the box's local
|
|
93
|
+
* token, which is read-only by design, and writes as the user - so nothing renames a device
|
|
94
|
+
* unless a person asked for it. Hosts without a login never call this.
|
|
95
|
+
*/
|
|
96
|
+
noteMetaSession(sid: string | undefined): void;
|
|
85
97
|
/** One polling round; public so a test does not have to wait five minutes. */
|
|
86
98
|
pollServiceMessages(): Promise<void>;
|
|
87
99
|
/** One sweep; public so a test can run it without the timer. */
|