homematic-manager 3.0.0-beta.0 → 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.
Files changed (62) hide show
  1. package/dist/cli.js +7 -0
  2. package/dist/occulite.d.ts +59 -0
  3. package/dist/occulite.js +97 -0
  4. package/dist/options.d.ts +17 -6
  5. package/dist/options.js +17 -6
  6. package/dist/server.d.ts +20 -2
  7. package/dist/server.js +82 -4
  8. package/dist/sessions.d.ts +9 -1
  9. package/dist/sessions.js +8 -2
  10. package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +12 -0
  11. package/node_modules/@homematic-manager/backend/dist/api/backend.js +210 -1
  12. package/node_modules/@homematic-manager/backend/dist/cache/names.d.ts +9 -0
  13. package/node_modules/@homematic-manager/backend/dist/cache/names.js +21 -0
  14. package/node_modules/@homematic-manager/backend/dist/config/defaults.js +10 -1
  15. package/node_modules/@homematic-manager/backend/dist/index.d.ts +1 -0
  16. package/node_modules/@homematic-manager/backend/dist/index.js +2 -0
  17. package/node_modules/@homematic-manager/backend/dist/meta/client.d.ts +120 -0
  18. package/node_modules/@homematic-manager/backend/dist/meta/client.js +307 -0
  19. package/node_modules/@homematic-manager/backend/dist/meta/credentials.d.ts +38 -0
  20. package/node_modules/@homematic-manager/backend/dist/meta/credentials.js +53 -0
  21. package/node_modules/@homematic-manager/backend/dist/meta/index.d.ts +11 -0
  22. package/node_modules/@homematic-manager/backend/dist/meta/index.js +11 -0
  23. package/node_modules/@homematic-manager/backend/dist/meta/localProvider.d.ts +47 -0
  24. package/node_modules/@homematic-manager/backend/dist/meta/localProvider.js +119 -0
  25. package/node_modules/@homematic-manager/backend/dist/meta/occuliteProvider.d.ts +79 -0
  26. package/node_modules/@homematic-manager/backend/dist/meta/occuliteProvider.js +305 -0
  27. package/node_modules/@homematic-manager/backend/dist/meta/provider.d.ts +67 -0
  28. package/node_modules/@homematic-manager/backend/dist/meta/provider.js +18 -0
  29. package/node_modules/@homematic-manager/backend/dist/meta/service.d.ts +120 -0
  30. package/node_modules/@homematic-manager/backend/dist/meta/service.js +337 -0
  31. package/node_modules/@homematic-manager/backend/dist/rpc/server.d.ts +6 -0
  32. package/node_modules/@homematic-manager/backend/dist/rpc/server.js +4 -0
  33. package/node_modules/@homematic-manager/backend/package.json +1 -1
  34. package/node_modules/@homematic-manager/core/dist/api/types.d.ts +188 -1
  35. package/node_modules/@homematic-manager/core/dist/api/types.js +6 -1
  36. package/node_modules/@homematic-manager/core/dist/index.d.ts +1 -0
  37. package/node_modules/@homematic-manager/core/dist/index.js +2 -0
  38. package/node_modules/@homematic-manager/core/dist/meta/document.d.ts +71 -0
  39. package/node_modules/@homematic-manager/core/dist/meta/document.js +260 -0
  40. package/node_modules/@homematic-manager/core/dist/meta/follow.d.ts +27 -0
  41. package/node_modules/@homematic-manager/core/dist/meta/follow.js +180 -0
  42. package/node_modules/@homematic-manager/core/dist/meta/index.d.ts +16 -0
  43. package/node_modules/@homematic-manager/core/dist/meta/index.js +16 -0
  44. package/node_modules/@homematic-manager/core/dist/meta/paths.d.ts +96 -0
  45. package/node_modules/@homematic-manager/core/dist/meta/paths.js +181 -0
  46. package/node_modules/@homematic-manager/core/dist/meta/slug.d.ts +22 -0
  47. package/node_modules/@homematic-manager/core/dist/meta/slug.js +59 -0
  48. package/node_modules/@homematic-manager/core/dist/meta/store.d.ts +147 -0
  49. package/node_modules/@homematic-manager/core/dist/meta/store.js +537 -0
  50. package/node_modules/@homematic-manager/core/dist/meta/tree.d.ts +19 -0
  51. package/node_modules/@homematic-manager/core/dist/meta/tree.js +53 -0
  52. package/node_modules/@homematic-manager/core/dist/meta/types.d.ts +139 -0
  53. package/node_modules/@homematic-manager/core/dist/meta/types.js +61 -0
  54. package/node_modules/@homematic-manager/core/dist/meta/view.d.ts +50 -0
  55. package/node_modules/@homematic-manager/core/dist/meta/view.js +103 -0
  56. package/node_modules/@homematic-manager/core/package.json +1 -1
  57. package/package.json +3 -3
  58. package/ui/assets/index-0KwZUy-l.js +13 -0
  59. package/ui/assets/index-qbhgN2pA.css +1 -0
  60. package/ui/index.html +2 -2
  61. package/ui/assets/index-C5J1qXk2.js +0 -9
  62. package/ui/assets/index-CD0Lc8Tv.css +0 -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
@@ -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 two modes? `parseRaw` has already refused everything else. */
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: "token: the token guards the api. rega: ask for a CCU login first (addon only, needs --local)";
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 two modes? `parseRaw` has already refused everything else. */
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 the login needs, and nothing at all in `token` mode
81
- const sessions = authMode === 'rega' ? new SessionStore(sessionStoreOptions(options)) : undefined;
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
- Location: `${base}login`,
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
- sendLoginPage(request, response, 200);
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
  }
@@ -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 = { id: this.#createId(), user, level, expiresAt: this.#now() + this.ttlMs };
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. */