smart-home-engine 0.10.4 → 0.11.0

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.
@@ -1,4 +1,4 @@
1
- import{gU as O}from"./monaco-langs-DZ6hB11b.js";import{t as I}from"./index-Bdf2J0nm.js";/*!-----------------------------------------------------------------------------
1
+ import{gU as O}from"./monaco-langs-DZ6hB11b.js";import{t as I}from"./index-DD-XScWV.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -153,10 +153,10 @@
153
153
  }
154
154
  })();
155
155
  </script>
156
- <script type="module" crossorigin src="/assets/index-Bdf2J0nm.js"></script>
156
+ <script type="module" crossorigin src="/assets/index-DD-XScWV.js"></script>
157
157
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-DZ6hB11b.js">
158
158
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
159
- <link rel="stylesheet" crossorigin href="/assets/index-DkhtWYJx.css">
159
+ <link rel="stylesheet" crossorigin href="/assets/index-BbwiXmS-.css">
160
160
  </head>
161
161
  <body>
162
162
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -26,6 +26,7 @@
26
26
  "author": "Sebastian \u0027hobbyquaker\u0027 Raff \u003chobbyquaker@gmail.com\u003e",
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
+ "bcryptjs": "^2.4.3",
29
30
  "@elastic/elasticsearch": "^9.4.2",
30
31
  "@influxdata/influxdb-client": "^1.35.0",
31
32
  "@matter/main": "^0.17.0",
package/src/config.js CHANGED
@@ -45,6 +45,9 @@ const config = require('yargs')
45
45
  disableWatch: false,
46
46
  dbRetain: false,
47
47
  dbPrefix: 'she/db/',
48
+ auth: 'none',
49
+ proxyHeader: 'X-Remote-User',
50
+ bindAddress: '0.0.0.0',
48
51
  })
49
52
  .version()
50
53
  .help('help')
package/src/index.js CHANGED
@@ -53,9 +53,17 @@ log.info('she ' + pkg.version + ' starting');
53
53
  log.debug('loaded config: ', config);
54
54
 
55
55
  if (typeof config.port !== 'undefined') {
56
+ // Validate: password mode requires a password hash
57
+ if (config.auth === 'password' && !config.password) {
58
+ log.error('auth is set to "password" but no password is configured. Set a password via the web UI Config → Authentication section first.');
59
+ process.exit(1);
60
+ }
56
61
  require('./web/server')
57
62
  .startServer(config.port, {
58
- apiKey: config.apiKey,
63
+ auth: config.auth,
64
+ password: config.password || null,
65
+ proxyHeader: config.proxyHeader,
66
+ bindAddress: config.bindAddress,
59
67
  configPath: config.config,
60
68
  scriptDir: config.dir || null,
61
69
  })
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Authentication module for the she web server.
5
+ *
6
+ * Supports three modes (configured via config.json `auth` field):
7
+ * 'none' — no auth, all /she/* routes are open (default)
8
+ * 'password' — single-password session auth with an HttpOnly cookie
9
+ * 'proxy' — trust a header set by nginx/authentik (e.g. X-Remote-User)
10
+ *
11
+ * Public endpoints (no auth required in any mode):
12
+ * GET /she/auth/mode — returns current mode
13
+ * POST /she/auth/login — password mode only; sets session cookie
14
+ * POST /she/auth/logout — clears session cookie
15
+ *
16
+ * Protected endpoint (auth required):
17
+ * POST /she/auth/setup — change auth mode / password / proxyHeader
18
+ */
19
+
20
+ const crypto = require('crypto');
21
+ const bcrypt = require('bcryptjs');
22
+ const express = require('express');
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const BCRYPT_ROUNDS = 10;
27
+ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
28
+
29
+ // In-memory session store — intentionally cleared on restart
30
+ const _sessions = new Map(); // token (hex64) → { createdAt: number }
31
+
32
+ let _mode = 'none';
33
+ let _passwordHash = null; // bcrypt hash, only used in 'password' mode
34
+ let _proxyHeader = 'x-remote-user'; // lowercase for req.headers lookup
35
+ let _configPath = null;
36
+
37
+ /**
38
+ * Initialise auth state. Called once from startServer().
39
+ */
40
+ function init({ auth = 'none', password = null, proxyHeader = 'X-Remote-User', configPath = null } = {}) {
41
+ _mode = auth;
42
+ _passwordHash = password || null;
43
+ _proxyHeader = proxyHeader.toLowerCase();
44
+ _configPath = configPath;
45
+ }
46
+
47
+ function getMode() {
48
+ return _mode;
49
+ }
50
+
51
+ // ── Session helpers ─────────────────────────────────────────────────────────
52
+
53
+ function _getSessionToken(req) {
54
+ const cookie = req.headers.cookie || '';
55
+ const m = cookie.match(/(?:^|;\s*)she_session=([a-f0-9]{64})/);
56
+ return m ? m[1] : null;
57
+ }
58
+
59
+ function _validateSession(token) {
60
+ if (!token) return false;
61
+ const s = _sessions.get(token);
62
+ if (!s) return false;
63
+ if (Date.now() - s.createdAt > SESSION_TTL_MS) {
64
+ _sessions.delete(token);
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ // ── Auth check (used by middleware and WS gate) ─────────────────────────────
71
+
72
+ /**
73
+ * Returns true if the request is authenticated according to the current mode.
74
+ * @param {import('http').IncomingMessage} req
75
+ */
76
+ function checkAuth(req) {
77
+ if (_mode === 'none') return true;
78
+ if (_mode === 'proxy') return !!req.headers[_proxyHeader];
79
+ if (_mode === 'password') return _validateSession(_getSessionToken(req));
80
+ return true;
81
+ }
82
+
83
+ /**
84
+ * Express middleware that enforces auth on /she/* routes.
85
+ * Mount this AFTER the public auth router so login/mode/logout bypass it.
86
+ */
87
+ function authMiddleware(req, res, next) {
88
+ if (checkAuth(req)) return next();
89
+ res.status(401).json({ error: 'Unauthorized' });
90
+ }
91
+
92
+ // ── Auth API router ─────────────────────────────────────────────────────────
93
+
94
+ const router = express.Router();
95
+
96
+ /** GET /she/auth/mode — always public */
97
+ router.get('/mode', (req, res) => {
98
+ res.json({ mode: _mode });
99
+ });
100
+
101
+ /** POST /she/auth/login — always public; only meaningful in password mode */
102
+ router.post('/login', async (req, res) => {
103
+ if (_mode !== 'password') return res.status(400).json({ error: 'Not in password mode' });
104
+ const { password } = req.body || {};
105
+ if (!password || !_passwordHash) return res.status(401).json({ error: 'Unauthorized' });
106
+ try {
107
+ const ok = await bcrypt.compare(password, _passwordHash);
108
+ if (!ok) return res.status(401).json({ error: 'Invalid password' });
109
+ const token = crypto.randomBytes(32).toString('hex');
110
+ _sessions.set(token, { createdAt: Date.now() });
111
+ res.setHeader('Set-Cookie', `she_session=${token}; HttpOnly; SameSite=Strict; Path=/`);
112
+ res.json({ ok: true });
113
+ } catch {
114
+ res.status(500).json({ error: 'Internal error' });
115
+ }
116
+ });
117
+
118
+ /** POST /she/auth/logout — always public; clears cookie */
119
+ router.post('/logout', (req, res) => {
120
+ const token = _getSessionToken(req);
121
+ if (token) _sessions.delete(token);
122
+ res.setHeader('Set-Cookie', 'she_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
123
+ res.json({ ok: true });
124
+ });
125
+
126
+ /**
127
+ * POST /she/auth/setup — protected; change auth mode / password / proxyHeader.
128
+ * Body: { mode: 'none'|'password'|'proxy', password?: string, proxyHeader?: string }
129
+ *
130
+ * Self-guards when in password mode (requires valid session).
131
+ */
132
+ router.post('/setup', async (req, res) => {
133
+ // Self-guard: in password mode the caller must be authenticated
134
+ if (_mode === 'password' && !_validateSession(_getSessionToken(req))) {
135
+ return res.status(401).json({ error: 'Unauthorized' });
136
+ }
137
+
138
+ const { mode, password, proxyHeader } = req.body || {};
139
+
140
+ if (!['none', 'password', 'proxy'].includes(mode)) {
141
+ return res.status(400).json({ error: 'Invalid auth mode. Must be none, password, or proxy.' });
142
+ }
143
+ if (mode === 'password' && !password) {
144
+ return res.status(400).json({ error: 'A non-empty password is required for password mode.' });
145
+ }
146
+
147
+ try {
148
+ // Read existing config
149
+ let cfg = {};
150
+ try {
151
+ cfg = JSON.parse(fs.readFileSync(_configPath, 'utf8'));
152
+ } catch {
153
+ // config file does not exist yet — start from empty
154
+ }
155
+
156
+ // Update auth fields, remove stale ones
157
+ cfg.auth = mode;
158
+ delete cfg.password;
159
+ delete cfg.proxyHeader;
160
+
161
+ if (mode === 'password') {
162
+ cfg.password = await bcrypt.hash(password, BCRYPT_ROUNDS);
163
+ }
164
+ if (mode === 'proxy') {
165
+ cfg.proxyHeader = proxyHeader || 'X-Remote-User';
166
+ }
167
+
168
+ // Write back
169
+ fs.mkdirSync(path.dirname(_configPath), { recursive: true });
170
+ fs.writeFileSync(_configPath, JSON.stringify(cfg, null, 2), 'utf8');
171
+
172
+ // Apply in-memory (no restart needed)
173
+ _mode = mode;
174
+ _passwordHash = cfg.password || null;
175
+ _proxyHeader = (cfg.proxyHeader || 'X-Remote-User').toLowerCase();
176
+
177
+ // Invalidate all existing sessions when switching away from password mode
178
+ if (mode !== 'password') _sessions.clear();
179
+
180
+ res.json({ ok: true });
181
+ } catch (err) {
182
+ res.status(500).json({ error: err.message });
183
+ }
184
+ });
185
+
186
+ module.exports = { init, authMiddleware, checkAuth, getMode, router };
package/src/web/log-ws.js CHANGED
@@ -12,11 +12,18 @@ const _clients = new Set();
12
12
  * - { type: 'ping' } — keepalive every 30 s
13
13
  *
14
14
  * @param {import('http').Server} httpServer
15
+ * @param {(req: import('http').IncomingMessage) => boolean} [authCheck]
16
+ * Optional function that receives the upgrade request and returns true if
17
+ * the connection should be allowed. Defaults to always-allow.
15
18
  */
16
- function attachWss(httpServer) {
19
+ function attachWss(httpServer, authCheck = () => true) {
17
20
  _wss = new WebSocketServer({ server: httpServer, path: '/she/ws' });
18
21
 
19
- _wss.on('connection', (ws) => {
22
+ _wss.on('connection', (ws, req) => {
23
+ if (!authCheck(req)) {
24
+ ws.close(1008, 'Unauthorized');
25
+ return;
26
+ }
20
27
  _clients.add(ws);
21
28
  ws.on('close', () => _clients.delete(ws));
22
29
  ws.on('error', () => _clients.delete(ws));
package/src/web/server.js CHANGED
@@ -11,18 +11,21 @@ const { router: depsRouter } = require('./deps-api');
11
11
  const { router: gitRouter } = require('./git-api');
12
12
  const { router: aiRouter } = require('./ai-api');
13
13
  const { attachWss, closeWss } = require('./log-ws');
14
+ const { init: initAuth, authMiddleware, checkAuth, router: authRouter } = require('./auth');
14
15
 
15
16
  const app = express();
16
17
  app.use(express.json());
17
18
 
18
- // Lazy auth check_apiKey is populated by startServer(); null = auth disabled.
19
- // Covers both /she/* (internal system routes) and /api/* (user-script routes).
20
- let _apiKey = null;
21
- app.use(['/she', '/api'], (req, res, next) => {
22
- if (!_apiKey) return next();
23
- const auth = req.headers['authorization'];
24
- if (auth === `Bearer ${_apiKey}`) return next();
25
- res.status(401).json({ error: 'Unauthorized' });
19
+ // Public auth routesalways accessible regardless of auth mode.
20
+ // Must be mounted BEFORE the auth middleware.
21
+ app.use('/she/auth', authRouter);
22
+
23
+ // Auth middleware for all /she/* routes except the public auth endpoints above.
24
+ // /api/* is intentionally excluded — user scripts control their own auth.
25
+ const OPEN_SHE_PATHS = new Set(['/she/auth/mode', '/she/auth/login', '/she/auth/logout']);
26
+ app.use('/she', (req, res, next) => {
27
+ if (OPEN_SHE_PATHS.has(req.originalUrl.split('?')[0])) return next();
28
+ authMiddleware(req, res, next);
26
29
  });
27
30
 
28
31
  // Config REST endpoints: GET /she/config and PUT /she/config
@@ -90,20 +93,26 @@ let httpServer = null;
90
93
  /**
91
94
  * Start listening. Resolves with the actual port (useful when port 0 is given).
92
95
  * @param {number} port
93
- * @param {{ apiKey?: string, configPath?: string, scriptDir?: string }} [options]
96
+ * @param {{ auth?: string, password?: string, proxyHeader?: string, bindAddress?: string, configPath?: string, scriptDir?: string }} [options]
94
97
  * @returns {Promise<number>}
95
98
  */
96
99
  function startServer(port, options = {}) {
97
- _apiKey = options.apiKey || null;
100
+ initAuth({
101
+ auth: options.auth || 'none',
102
+ password: options.password || null,
103
+ proxyHeader: options.proxyHeader || 'X-Remote-User',
104
+ configPath: options.configPath || null,
105
+ });
98
106
  if (options.configPath) {
99
107
  app.locals.configPath = options.configPath;
100
108
  }
101
109
  if (options.scriptDir) {
102
110
  app.locals.scriptDir = options.scriptDir;
103
111
  }
112
+ const host = options.bindAddress || '0.0.0.0';
104
113
  return new Promise((resolve, reject) => {
105
- httpServer = app.listen(port, () => {
106
- attachWss(httpServer);
114
+ httpServer = app.listen(port, host, () => {
115
+ attachWss(httpServer, checkAuth);
107
116
  resolve(httpServer.address().port);
108
117
  });
109
118
  httpServer.on('error', reject);