node-red-contrib-room-tools 1.8.3 → 1.8.8
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/CONTROL_PANEL_AI_GUIDE.md +156 -39
- package/README.md +12 -0
- package/control-catalog.js +7 -6
- package/control-panel.js +5 -5
- package/control-policy.js +36 -478
- package/control-type-model.js +30 -31
- package/gateway.cjs +16 -264
- package/managed-bridge-admin.js +5 -11
- package/native-binding-plan.js +1 -1
- package/npm-shrinkwrap.json +433 -0
- package/package.json +8 -5
- package/panel-binding-editor.js +25 -36
- package/panel-editor-auth.js +7 -50
- package/panel-http.js +69 -0
- package/panel-visual-apply.js +19 -13
package/panel-editor-auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const crypto = require('node:crypto')
|
|
2
|
+
const { readJsonBody, replyJson: reply, sameOrigin } = require('./panel-http')
|
|
2
3
|
|
|
3
4
|
function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions }) {
|
|
4
5
|
if (!username || !password) return null
|
|
@@ -20,58 +21,14 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
|
|
|
20
21
|
const ttl = 30 * 60 * 1000
|
|
21
22
|
let closed = false
|
|
22
23
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
26
|
-
'Cache-Control': 'no-store',
|
|
27
|
-
'X-Content-Type-Options': 'nosniff',
|
|
28
|
-
...headers
|
|
29
|
-
})
|
|
30
|
-
res.end(JSON.stringify(data))
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function cookie (value, age) {
|
|
34
|
-
return `${cookieName}=${value}; Path=${prefix}; HttpOnly; SameSite=Strict; Max-Age=${age}${secureCookie ? '; Secure' : ''}`
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function sameOrigin (req) {
|
|
38
|
-
const origin = req.headers.origin
|
|
39
|
-
if (!origin) return false
|
|
40
|
-
try {
|
|
41
|
-
const parsed = new URL(origin)
|
|
42
|
-
return parsed.host === req.headers.host &&
|
|
43
|
-
parsed.protocol === (req.socket.encrypted ? 'https:' : 'http:')
|
|
44
|
-
} catch (_) { return false }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function localOrSecure (req) {
|
|
48
|
-
const address = String(req.socket.remoteAddress || '')
|
|
49
|
-
return ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address) ||
|
|
50
|
-
req.socket.encrypted === true
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function jsonBody (req) {
|
|
54
|
-
if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
|
|
55
|
-
throw new Error('JSON body required')
|
|
56
|
-
}
|
|
57
|
-
if (req.body !== undefined) {
|
|
58
|
-
if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'))
|
|
59
|
-
if (typeof req.body === 'string') return JSON.parse(req.body)
|
|
60
|
-
if (typeof req.body === 'object' && req.body !== null && Object.keys(req.body).length) return req.body
|
|
61
|
-
}
|
|
62
|
-
let raw = ''
|
|
63
|
-
for await (const chunk of req) {
|
|
64
|
-
raw += chunk
|
|
65
|
-
if (Buffer.byteLength(raw) > 65536) throw new Error('Request too large')
|
|
66
|
-
}
|
|
67
|
-
return JSON.parse(raw)
|
|
24
|
+
function cookie (value, age, req) {
|
|
25
|
+
return `${cookieName}=${value}; Path=${prefix}; HttpOnly; SameSite=Strict; Max-Age=${age}${secureCookie && req.socket.encrypted ? '; Secure' : ''}`
|
|
68
26
|
}
|
|
69
27
|
|
|
70
28
|
async function handler (req, res) {
|
|
71
29
|
const path = new URL(req.url, 'http://localhost').pathname
|
|
72
30
|
if (!path.startsWith(prefix + '/')) return false
|
|
73
31
|
if (closed) { reply(res, 503, { error: 'Editor unavailable' }); return true }
|
|
74
|
-
if (!localOrSecure(req)) { reply(res, 403, { error: 'HTTPS required' }); return true }
|
|
75
32
|
if (req.method === 'POST' && !sameOrigin(req)) {
|
|
76
33
|
reply(res, 403, { error: 'Origin rejected' })
|
|
77
34
|
return true
|
|
@@ -89,7 +46,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
|
|
|
89
46
|
}
|
|
90
47
|
attempt.count++
|
|
91
48
|
attempts.set(ip, attempt)
|
|
92
|
-
const body = await
|
|
49
|
+
const body = await readJsonBody(req, 65536)
|
|
93
50
|
const supplied = String(body.password || '')
|
|
94
51
|
if (supplied.length > 256) { reply(res, 401, { error: 'Invalid credentials' }); return true }
|
|
95
52
|
const test = await new Promise((resolve, reject) => crypto.scrypt(supplied, salt, 32,
|
|
@@ -103,7 +60,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
|
|
|
103
60
|
const token = crypto.randomBytes(32).toString('hex')
|
|
104
61
|
const csrf = crypto.randomBytes(24).toString('hex')
|
|
105
62
|
sessions.set(token, { csrf, expiry: now + ttl })
|
|
106
|
-
reply(res, 200, { csrf }, { 'Set-Cookie': cookie(token, ttl / 1000) })
|
|
63
|
+
reply(res, 200, { csrf }, { 'Set-Cookie': cookie(token, ttl / 1000, req) })
|
|
107
64
|
return true
|
|
108
65
|
}
|
|
109
66
|
const token = String(req.headers.cookie || '').split(';').map(part => part.trim())
|
|
@@ -118,7 +75,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
|
|
|
118
75
|
reply(res, 200, { csrf: session.csrf })
|
|
119
76
|
} else if (path === prefix + '/logout' && req.method === 'POST') {
|
|
120
77
|
sessions.delete(token)
|
|
121
|
-
reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) })
|
|
78
|
+
reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0, req) })
|
|
122
79
|
} else if (path === prefix + '/config' && req.method === 'GET') {
|
|
123
80
|
reply(res, 200, await getConfig(req))
|
|
124
81
|
} else if (path === prefix + '/devices' && req.method === 'GET') {
|
|
@@ -127,7 +84,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
|
|
|
127
84
|
const device = new URL(req.url, 'http://localhost').searchParams.get('device')
|
|
128
85
|
reply(res, 200, { targets: await getTargets(req, device) })
|
|
129
86
|
} else if (path === prefix + '/apply' && req.method === 'POST') {
|
|
130
|
-
reply(res, 200, await apply(req, await
|
|
87
|
+
reply(res, 200, await apply(req, await readJsonBody(req, 65536)))
|
|
131
88
|
} else {
|
|
132
89
|
reply(res, 404, { error: 'Not found' })
|
|
133
90
|
}
|
package/panel-http.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function sameOrigin (req) {
|
|
2
|
+
if (!req.headers.origin) return false
|
|
3
|
+
try {
|
|
4
|
+
const origin = new URL(req.headers.origin)
|
|
5
|
+
return origin.host === req.headers.host &&
|
|
6
|
+
origin.protocol === (req.socket.encrypted ? 'https:' : 'http:')
|
|
7
|
+
} catch (_) { return false }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function replyJson (res, status, data, headers = {}) {
|
|
11
|
+
res.writeHead(status, {
|
|
12
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
13
|
+
'Cache-Control': 'no-store',
|
|
14
|
+
'X-Content-Type-Options': 'nosniff',
|
|
15
|
+
...headers
|
|
16
|
+
})
|
|
17
|
+
res.end(JSON.stringify(data))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function readJsonBody (req, maxBytes) {
|
|
21
|
+
if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') {
|
|
22
|
+
throw new Error('JSON body required')
|
|
23
|
+
}
|
|
24
|
+
let raw
|
|
25
|
+
if (req.body !== undefined) {
|
|
26
|
+
// MUSE's middleware can expose an empty placeholder object while the raw
|
|
27
|
+
// JSON bytes are still on the request stream. Keep the legacy fallback so
|
|
28
|
+
// login and Apply receive the actual payload on that runtime.
|
|
29
|
+
const hasParsedObject = req.body && typeof req.body === 'object' &&
|
|
30
|
+
!Buffer.isBuffer(req.body) && !Array.isArray(req.body) &&
|
|
31
|
+
Object.keys(req.body).length > 0
|
|
32
|
+
if (Buffer.isBuffer(req.body) || typeof req.body === 'string' || hasParsedObject) {
|
|
33
|
+
raw = Buffer.isBuffer(req.body) ? req.body
|
|
34
|
+
: typeof req.body === 'string' ? Buffer.from(req.body) : Buffer.from(JSON.stringify(req.body))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (raw === undefined && typeof req[Symbol.asyncIterator] === 'function') {
|
|
38
|
+
const chunks = []
|
|
39
|
+
let size = 0
|
|
40
|
+
for await (const chunk of req) {
|
|
41
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
42
|
+
size += bytes.length
|
|
43
|
+
if (size > maxBytes) throw new Error('Request too large')
|
|
44
|
+
chunks.push(bytes)
|
|
45
|
+
}
|
|
46
|
+
raw = Buffer.concat(chunks)
|
|
47
|
+
}
|
|
48
|
+
if (raw === undefined) {
|
|
49
|
+
const value = req.body === undefined ? {} : req.body
|
|
50
|
+
raw = Buffer.from(JSON.stringify(value))
|
|
51
|
+
}
|
|
52
|
+
if (raw.length > maxBytes) throw new Error('Request too large')
|
|
53
|
+
const value = JSON.parse(raw.toString('utf8'))
|
|
54
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('JSON object required')
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Serialized into the Vue component, so keep this function browser-only and self-contained.
|
|
59
|
+
async function fetchWithTimeout (url, options = {}, timeoutMs = 12000) {
|
|
60
|
+
const controller = new AbortController()
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
62
|
+
try {
|
|
63
|
+
return await fetch(url, { ...options, signal: controller.signal })
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { sameOrigin, replyJson, readJsonBody, fetchWithTimeout }
|
package/panel-visual-apply.js
CHANGED
|
@@ -4,7 +4,7 @@ const { validatedVisualBindings } = require('./panel-visual-bindings')
|
|
|
4
4
|
const { listCatalogDevices, listCatalogTargets } = require('./control-catalog')
|
|
5
5
|
const { configuredRoutes } = require('./control-bound-protocol')
|
|
6
6
|
const { provisionManagedBridges } = require('./managed-native-bridge')
|
|
7
|
-
const { backupFlow, flowLocation,
|
|
7
|
+
const { backupFlow, flowLocation, runtimeDirectory } = require('./managed-bridge-admin')
|
|
8
8
|
|
|
9
9
|
function conflict (message) {
|
|
10
10
|
const error = new Error(message)
|
|
@@ -12,15 +12,20 @@ function conflict (message) {
|
|
|
12
12
|
return error
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function adminUrl (localPort) {
|
|
15
|
+
function adminUrl (localPort, adminRoot = '/red') {
|
|
16
16
|
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
|
|
17
|
-
throw new Error('
|
|
17
|
+
throw new Error('Node-RED runtime port unavailable')
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
if (typeof adminRoot !== 'string' || !adminRoot.startsWith('/') || /[?#]/.test(adminRoot)) {
|
|
20
|
+
throw new Error('Node-RED admin root unavailable')
|
|
21
|
+
}
|
|
22
|
+
const url = new URL(`http://127.0.0.1:${localPort}`)
|
|
23
|
+
url.pathname = adminRoot.replace(/\/$/, '') + '/flows'
|
|
24
|
+
return url.href
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
async function getSnapshot (localPort, fetcher = fetch) {
|
|
23
|
-
const response = await fetcher(adminUrl(localPort), {
|
|
27
|
+
async function getSnapshot (localPort, fetcher = fetch, adminRoot = '/red') {
|
|
28
|
+
const response = await fetcher(adminUrl(localPort, adminRoot), {
|
|
24
29
|
headers: { 'Node-RED-API-Version': 'v2' },
|
|
25
30
|
signal: AbortSignal.timeout(10000)
|
|
26
31
|
})
|
|
@@ -59,18 +64,19 @@ function editorConfig (snapshot, panelId) {
|
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
async function applyVisualBindings ({
|
|
62
|
-
panelId, rev, templateHash, entries, userDir,
|
|
67
|
+
panelId, rev, templateHash, entries, userDir, localPort, adminRoot = '/red',
|
|
63
68
|
catalogBase, fetcher = fetch
|
|
64
69
|
}) {
|
|
65
|
-
|
|
70
|
+
// The editor authenticates the browser; Flow access stays on the runtime's loopback interface.
|
|
71
|
+
const directory = runtimeDirectory(userDir)
|
|
66
72
|
if (!catalogBase) throw conflict('Device catalog is unavailable')
|
|
67
|
-
const snapshot = await getSnapshot(localPort, fetcher)
|
|
73
|
+
const snapshot = await getSnapshot(localPort, fetcher, adminRoot)
|
|
68
74
|
if (snapshot.rev !== rev) throw conflict('Flow changed; refresh the visual editor')
|
|
69
75
|
const panel = findPanel(snapshot, panelId)
|
|
70
76
|
if (inspectTemplate(panel.format).templateHash !== templateHash) {
|
|
71
77
|
throw conflict('Template changed; refresh the visual editor')
|
|
72
78
|
}
|
|
73
|
-
const location = flowLocation(
|
|
79
|
+
const location = flowLocation(directory)
|
|
74
80
|
if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
|
|
75
81
|
throw conflict('Deployed Flow differs from the active project file')
|
|
76
82
|
}
|
|
@@ -92,13 +98,13 @@ async function applyVisualBindings ({
|
|
|
92
98
|
nextPanel.nativeBridge = configuredRoutes(nextPanel).size > 0
|
|
93
99
|
const provisioned = provisionManagedBridges(next, { panelId })
|
|
94
100
|
|
|
95
|
-
const latest = await getSnapshot(localPort, fetcher)
|
|
101
|
+
const latest = await getSnapshot(localPort, fetcher, adminRoot)
|
|
96
102
|
if (latest.rev !== snapshot.rev) throw conflict('Flow changed during validation; refresh and retry')
|
|
97
103
|
if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
|
|
98
104
|
throw conflict('Active project changed during validation')
|
|
99
105
|
}
|
|
100
106
|
const backupDir = backupFlow(location, snapshot.rev, snapshot.flows)
|
|
101
|
-
const response = await fetcher(adminUrl(localPort), {
|
|
107
|
+
const response = await fetcher(adminUrl(localPort, adminRoot), {
|
|
102
108
|
method: 'POST',
|
|
103
109
|
headers: {
|
|
104
110
|
'Content-Type': 'application/json',
|
|
@@ -112,7 +118,7 @@ async function applyVisualBindings ({
|
|
|
112
118
|
const applied = await response.json().catch(() => ({}))
|
|
113
119
|
let confirmed
|
|
114
120
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
115
|
-
confirmed = await getSnapshot(localPort, fetcher)
|
|
121
|
+
confirmed = await getSnapshot(localPort, fetcher, adminRoot)
|
|
116
122
|
const confirmedPanel = confirmed.flows.find(node => node.id === panelId)
|
|
117
123
|
if (confirmedPanel?.visualBindings === nextPanel.visualBindings &&
|
|
118
124
|
confirmedPanel.bridgeRevisionApplied === provisioned.flows.find(node => node.id === panelId)?.bridgeRevisionApplied) {
|