From 5c3a8cefe109294995f73dd653be4dc2821dbab9 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sun, 2 Aug 2026 18:15:28 -0400 Subject: [PATCH 1/4] fix: enforce pwdAccountLockedTime check in app and LDAP (#68) --- nodejs/config/inventory.sqlite | Bin 69632 -> 69632 bytes nodejs/models/user_ldap.js | 11 +++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index 7cc5e8e3d470e9ac5b8dbd369a0fff32a307ed40..13788ae8d8efa1c376fc32ac01d9e09b5a411292 100644 GIT binary patch delta 3829 zcma)9OKe^OAK(A|XD+|F|MHvrUz+SAFHfE9Bfoike0<)}%yAkB%2>t`bAlsd7)AjH?K!r_ zV4c4ERUt>f?{~~D8|Py-ou&? zPuw+cd76=6HT-L^h*7DKp&tGs%1MzL>uH)VPRvz1(D{k6gU{RoMOiPXjx|a4A6Pm# z_gHfK;$jkicl%ewhaJ*E8p!>n`;Vts4?82T7 zdHTWg-ZHJF|fO7weDJua7)Y8>^ljxL6CdzYRQ; z-*@KQ1C_6bX9j;<`KLMS|XM)57<5+<*C+LmSLl58a)MdOk)GG#0fYCk|NDPpi-F;4Fjbz?`iAe5C8n= zybwC0f)eDUhA=4sF{Z*HrImt9zz@s(!u3;X^}VY*cNt$`J*KT~ZT;u^-|MAdZoEG> zG;h7rg3=Ua!D$HHV+dV96&kPrbh$1Bhrs~EH-)={Na5z|JUSi57W zfVvVvx1Uu}=)%DGRja|y$ zSjQ(jEn!0T(CMzV7F#r$By|R#7viUN`{l#+_B57ZOH{9wpih}si17*s) z4?K^Qs8j3fzclI_&xI{I$FP5L9$pN~XMqS$tt>;gc$XimD>2-#x5Ea3VyGwfc7k5V z5NFyR`9D7&+)&pTL+h*0`$vxsoJ{Vh+)V1P)aOTjJ929HPs5F&XNJBS{BrQc!Ee=O zYpv>y>h}gdtK1xTvPEXiZOto8RF=NEw6wTbMk(V<`dpdaTW0M>d1~%>iOXK^D%G)G zVSF?0GQ!=Cd$*XHx4z$ZP43EQ1WTYuJ-`}m9}uS=KAla{4hRa+-1$|6?>%PlMWth{ z{Vcf~8V7hi8rO);je@zCPqVlOw}RRkQ>W}ZR|>?`u{PKyT$vr&Mr-bNp_ILzon31q z-O8@SqTM~Y1MuZf@~D^1&Z}2)zA1DKx(QHbLGKO#_Y2vpqg&Q}82@gz*7w%~{{wnY BuZREu delta 2645 zcma)8U2GIp6yE8!v^(3~Ez(Nc-L?x|Z3*6b@16V0gV-2_CX(RK8WJUC?#vxTo3?}! zLjemf`lNtk+$Xh0<6oi#HbLWqf%r--J{XNAg7LvbA9yoSe)P_kcDF=yCfQ_e=Hz_m zeCNC0*<8=NWE~ZcQCb*IUMcms=Sc4a!rg?uG?16sL94yaWjNlAWS5hTgqL7 zyAx~2#uCxvG8I5&(qf5C=|q~+wZNtcN&-Z+q=i<-;BdJE!Qn~=O&_l6 zKv0CSL=-THT|lTIOEHN7_ceitQDkt#{<|AHI9lkb0a!HLzp!qZb3t)!xn^6vhJ4|W zmC~&zSC#HyqzpHvU`Lqz(qlmS904Sm>j$3aOD)6q`x;CCrmseRs)_Su0M6?GiH}zS zD2$m1ga*=K9w5RoZwd=?(A;_INZMkkmOj@*f(l4ui=;dy@S3hxbPex zDKn$=9R!5K1n@jg6!QbB7!R-Yf0SYrN|W0W;U@#VHe_3%+yco63+D@+`TEh3w}^Re z@^T{joh`neor`msQi+5$5a({R8HxW9N8_y~aKSZNo%=trt}&7C#SvYL2|OG}OwA_n z@ntPc{+Ae%CUR4pp*9UA%c&s7yw1Y6vJF@7g9q+uIDNb7l-ycs_y)HQHJtXW`u57I z(-)1W>aSb-I)?9Wn@yyCPi@P+l-ZxQ+ZS@*r=84K_KDo?-0AFIyE8T2Hfsm=&mrD< zZ*yiK{Y-Vw&K<3%=D(}X?)vemlH+sCod5wX0&4E1KtK{l0-q4i^`#Maz`{ePxA#u| zbOS@PGzm`HL&(x{qyuMxmZchp6_fgL8XmjY67$}wnivIKtY$7 zTkC7Bs7shq;iZMHaL2`UnWd}1p%|YEg7BTik@gk+3yEq~JYuefd0#P!Bq`?y@(-s5 zM$NRNXKc)Oj8h_P)_`=lF;Uk80tFl}PkNAJj|CFfAbS_?YHxs?j1bdxL>{hPT-Q(m zr{T$kLP-UTpvX8~4h^4DNZ<;}07Y5|7jo%1QAe9Q>Z#SCCngT=y)8YhoXlE1wSH)Q zAy{vie)_gG_Cr16qgqhln^^$@4VlU}PU8D!S1M`R323dK&%bV@&_6hR8=_M~ToWpNmXjI}rEh7Bw zYPvp_>dmXkjt%y!iHu!L Date: Sun, 2 Aug 2026 18:45:16 -0400 Subject: [PATCH 2/4] fix: resolve discovery, plugins, and vault issues --- nodejs/models/plugin_instance.js | 1 + nodejs/models/resource.js | 4 ++- nodejs/plugins/discovery/nmap.js | 6 +++++ nodejs/plugins/discovery/proxmox.js | 2 +- nodejs/routes/api_directory_admin.js | 10 ++++++++ nodejs/routes/api_plugins.js | 2 +- nodejs/routes/discovery.js | 37 ++++++++++++++++++++++++++++ nodejs/routes/index.js | 3 ++- nodejs/services/scheduler.js | 12 ++++++--- nodejs/views/discovery.ejs | 10 ++------ nodejs/views/plugins.ejs | 22 +++++++++++------ 11 files changed, 86 insertions(+), 23 deletions(-) diff --git a/nodejs/models/plugin_instance.js b/nodejs/models/plugin_instance.js index f3c0ec8..82032ef 100644 --- a/nodejs/models/plugin_instance.js +++ b/nodejs/models/plugin_instance.js @@ -58,6 +58,7 @@ class PluginInstance extends Model { lastRunAt: { type: 'integer' }, lastStatus: { type: 'string' }, lastError: { type: 'text' }, + lastLog: { type: 'text' }, // Audit stamps (set by the route handler, not by an ORM hook). created_by: { type: 'string' }, created_on: { type: 'integer' }, diff --git a/nodejs/models/resource.js b/nodejs/models/resource.js index 42d1998..92f296e 100644 --- a/nodejs/models/resource.js +++ b/nodejs/models/resource.js @@ -96,11 +96,13 @@ class Resource extends Model { return false; } + let maxUpdated = 0; resObjs.forEach(r => { r.metadata.isProduction = checkProd(r.id); + if (r.updated_on && r.updated_on > maxUpdated) maxUpdated = r.updated_on; }); - return { resources: resObjs, edges }; + return { resources: resObjs, edges, updated_on: maxUpdated || Date.now() }; } // Stamp `resolvedAddress` on each resource: its own address/ip if it has one, diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js index d0eba1e..2461bce 100644 --- a/nodejs/plugins/discovery/nmap.js +++ b/nodejs/plugins/discovery/nmap.js @@ -34,7 +34,13 @@ module.exports = { // OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root). const scan = new nmap.NmapScan(targetRange); scan.command.push('-Pn'); + scan.command.push('-F'); // fast scan, 100 top ports + scan.command.push('--min-rate', '100'); // speed up the scan + + if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`); + scan.on('complete', function(data) { + if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`); const resources = []; const edges = []; diff --git a/nodejs/plugins/discovery/proxmox.js b/nodejs/plugins/discovery/proxmox.js index 8261bd0..e670116 100644 --- a/nodejs/plugins/discovery/proxmox.js +++ b/nodejs/plugins/discovery/proxmox.js @@ -35,7 +35,7 @@ module.exports = { }, discover: async (config) => { - const { url, tokenId, tokenSecret } = config; + let { url, tokenId, tokenSecret } = config; if (!url || !tokenId || !tokenSecret) { throw new Error("Missing Proxmox config"); } diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 5b0c5de..bbf3c77 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -201,6 +201,16 @@ router.post('/resources/:id/rotate-secret', async (req, res, next) => { } }); +router.post('/resources/:id/service-token', async (req, res, next) => { + try { + const { ServiceToken } = require('../models/token'); + const token = await ServiceToken.issue(req.params.id, req.user.uid); + res.json({ results: { token: token.token } }); + } catch (err) { + next(err); + } +}); + router.delete('/resources/:id', async (req, res, next) => { try { const r = await Resource.get(req.params.id); diff --git a/nodejs/routes/api_plugins.js b/nodejs/routes/api_plugins.js index 23301a2..8b4aabc 100644 --- a/nodejs/routes/api_plugins.js +++ b/nodejs/routes/api_plugins.js @@ -274,7 +274,7 @@ router.get('/:id/runs', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); - res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError } }); + res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError, lastLog: inst.lastLog } }); } catch (err) { next(err); } }); diff --git a/nodejs/routes/discovery.js b/nodejs/routes/discovery.js index 09507c0..2fb1279 100644 --- a/nodejs/routes/discovery.js +++ b/nodejs/routes/discovery.js @@ -62,6 +62,7 @@ router.get('/graph', async (req, res, next) => { res.json(envelope({ resources: projectResources(graph.resources, { fullMetadata }), edges: graph.edges, + updated_on: graph.updated_on })); } catch (err) { next(err); } }); @@ -98,6 +99,42 @@ router.get('/me', async (req, res, next) => { } catch (err) { next(err); } }); +// GET /api/discovery/access/:uid[/:slug] +// Answers per-user access for a machine caller (e.g. jump-host). +router.get('/access/:uid/:slug?', async (req, res, next) => { + try { + const { fullMetadata } = await callerView(req); + if (!req.user || (!req.user.isMachine && !fullMetadata)) { + return res.status(403).json(envelope({ error: 'Only machine identities or admins may query access for other users.' })); + } + const { User } = require('../models/user_ldap'); + const { groupCns } = require('../utils/user_groups'); + + const targetUser = await User.get(req.params.uid).catch(() => null); + if (!targetUser) return res.status(404).json(envelope({ error: 'User not found' })); + + const groups = await groupCns(targetUser); + const ids = new Set(); + if (groups.length) { + const rgs = await ResourceGroup.list({ where: { groupCn: { in: groups } } }); + for (const rg of rgs) ids.add(rg.resourceId); + } + + let all = await Resource.list(); + if (req.params.slug) all = all.filter(r => r.slug === req.params.slug); + + let accessible = all.filter(r => { + const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual'); + const isManaged = r.metadata?.managed === true; + if (isAuto && !isManaged) return false; + return ids.has(r.id) || (r.metadata && r.metadata.isPublic); + }); + + accessible = await Resource.withResolvedAddress(accessible); + res.json(envelope(projectResources(accessible, { fullMetadata }))); + } catch (err) { next(err); } +}); + // POST /api/discovery/sync // Used by external agents (e.g. ldap-client) to push discovery data. router.post('/sync', async (req, res, next) => { diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 212f6f3..f021c89 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -90,7 +90,8 @@ router.get('/plugins', function(req, res, next) { // 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce // the same server-side. Same header-vs-navigation auth model as /conf and // /vault (auth-token is a client-set header, not a cookie). - res.render('plugins', {...values}); + const registry = require('../services/plugin_registry'); + res.render('plugins', {...values, pluginTypes: registry.types }); }); router.get('/vault', function(req, res) { diff --git a/nodejs/services/scheduler.js b/nodejs/services/scheduler.js index d183145..0611b49 100644 --- a/nodejs/services/scheduler.js +++ b/nodejs/services/scheduler.js @@ -71,17 +71,23 @@ async function runPluginJob(instanceId) { } console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`); - await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null }); + await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null, lastLog: null }); + let logs = []; try { const cfg = await pluginSecrets.mergeForRun(instance); + cfg.log = (msg) => { + logs.push(`[${new Date().toISOString()}] ${msg}`); + console.log(`[Plugin ${instance.slug}] ${msg}`); + if (logs.length > 1000) logs.shift(); + }; const payload = await runFn(cfg); if (instance.category === 'discovery') { await DiscoveryReconciler.reconcile(instance.slug, payload); } - await instance.update({ lastStatus: STATUS.OK, lastError: null }); + await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') }); } catch (err) { console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message); - await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err) }); + await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err), lastLog: logs.join('\n') }); } } diff --git a/nodejs/views/discovery.ejs b/nodejs/views/discovery.ejs index 2992eea..ba45a20 100644 --- a/nodejs/views/discovery.ejs +++ b/nodejs/views/discovery.ejs @@ -21,11 +21,6 @@
-
@@ -132,10 +127,9 @@ // Name search if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false; - // Managed filter + // Always hide items that have been committed to the catalog (managed) const isManaged = !!(r.metadata && r.metadata.managed); - if(managedFilter === 'managed' && !isManaged) return false; - if(managedFilter === 'unmanaged' && isManaged) return false; + if(isManaged) return false; const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual'); if(!isAuto) return false; diff --git a/nodejs/views/plugins.ejs b/nodejs/views/plugins.ejs index ab691f3..49bdefb 100644 --- a/nodejs/views/plugins.ejs +++ b/nodejs/views/plugins.ejs @@ -57,7 +57,7 @@ - {{#lastError}}{{/lastError}} + {{#lastRunAt}}{{/lastRunAt}} {{#enabled}}{{/enabled}} {{^enabled}}{{/enabled}} @@ -370,14 +370,20 @@ } } - function showLogs(id) { + async function showLogs(id) { var p = pluginsById[id]; - if (!p || !p.lastError) return; - app.modal.open({ - title: 'Logs — ' + p.name, - bodyHtml: '
' + String(p.lastError).replace(//g, '>') + '
', - footer: { buttonsHtml: '' } - }); + if (!p) return; + try { + const res = await app.api.get('plugins/' + id + '/runs'); + const logText = (res.results && res.results.lastLog) || (res.results && res.results.lastError) || 'No logs available.'; + app.modal.open({ + title: 'Logs — ' + p.name, + bodyHtml: '
' + String(logText).replace(//g, '>') + '
', + footer: { buttonsHtml: '' } + }); + } catch (err) { + app.messages.toast('Failed to load logs: ' + (err.message || err), 'danger'); + } } async function togglePlugin(id, enable) { From 7da5050ce3f34109b327eb50f40795e79dc65a0c Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sun, 2 Aug 2026 18:50:31 -0400 Subject: [PATCH 3/4] fix: correct path-to-regexp syntax --- nodejs/config/inventory.sqlite | Bin 69632 -> 69632 bytes nodejs/routes/discovery.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index 13788ae8d8efa1c376fc32ac01d9e09b5a411292..255b0cba01b80cb71fc6606b1e8a02d8b766f5bf 100644 GIT binary patch delta 848 zcma))PiqrF7{+&-h^Y#z*eWzzT1vozzA}GzX1s|63DSc|j}muwc0?#K(DtAgbMsmm z(4#4S0I3!9J7|nY4<7sm`3B;w`RhU0+0)BD^FHtId8gN}_WISMdf0pw-wB&1k3N2B z2ui8enu4)u4h%~T(h>taF+_@#8O(C`eX#0wzOG!rNGT-{Z9lcv9S`v`N4F5VNoDfp zq!zBWX65E}RQw5o3frx3)SJs2w}bI%weo}ut`Z7FVgW423Uq1^AWAbr1T_?kzm98z z%jf5gox-p-aY)>8d}ZpMD0ei78=1;-q8SDyl>{b?21RoTT&GA_%u;Xb``(CVJ@vB( zdxwt~K^Xc6grONo_}YW4-(3W;sWl|C2B^&`Fe^A{jT9io7|If)uo{DW{IKR5@2eJqSv43bF#jEjs>ogtKEt~TN|Ywkx|~nKy98Uw>C-byY8XUS<~+FTB4~Cd4JL z+%A*KhhKZqy=f}NBQ2(Wd64fPw7Y)OPdaTZBo}ie zdOjE5TQADxM*wR8QB-4F&86o0{L+)?^4i#79%Pzmi#YcwVoqu#jbtd?3bdp~VqK^$ z3~pbas&&g^gcM@4L5z^>c+hG)z}7koVQEYe6OJH7v_wWIp;OKhXL5$ejmgAx|K-l! z>+)2%oSGv5nIOa`0b%i389}9tQ;-3{C_qd}gQUd_QLU{4pIR(rf$;5T$uPUuE2%M= zj2S|i!LZe~k{O+*4uSF{xchuHlvm4N z%RRptMGY3tVSX-d^&d3et<<*SDxuGY&p%bks~#E;szgu=jW0H<_%{Kr)EdK+ycHTSy diff --git a/nodejs/routes/discovery.js b/nodejs/routes/discovery.js index 2fb1279..6185aa2 100644 --- a/nodejs/routes/discovery.js +++ b/nodejs/routes/discovery.js @@ -101,7 +101,7 @@ router.get('/me', async (req, res, next) => { // GET /api/discovery/access/:uid[/:slug] // Answers per-user access for a machine caller (e.g. jump-host). -router.get('/access/:uid/:slug?', async (req, res, next) => { +router.get(['/access/:uid', '/access/:uid/:slug'], async (req, res, next) => { try { const { fullMetadata } = await callerView(req); if (!req.user || (!req.user.isMachine && !fullMetadata)) { From a6c24850d415f24115853d6f6057b77be555a09a Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sun, 2 Aug 2026 18:57:13 -0400 Subject: [PATCH 4/4] chore: update sqlite test fixture schema --- nodejs/config/inventory.sqlite | Bin 69632 -> 61440 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index 255b0cba01b80cb71fc6606b1e8a02d8b766f5bf..9fe0fc79f641eecf4f0a52764865ad854fdc7e64 100644 GIT binary patch delta 1123 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 Jm)nr~e*q%uvuFSS