Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6c24850d4 | |||
| 7da5050ce3 | |||
| 1cb693a1eb | |||
| 5c3a8cefe1 |
Binary file not shown.
@@ -58,6 +58,7 @@ class PluginInstance extends Model {
|
|||||||
lastRunAt: { type: 'integer' },
|
lastRunAt: { type: 'integer' },
|
||||||
lastStatus: { type: 'string' },
|
lastStatus: { type: 'string' },
|
||||||
lastError: { type: 'text' },
|
lastError: { type: 'text' },
|
||||||
|
lastLog: { type: 'text' },
|
||||||
// Audit stamps (set by the route handler, not by an ORM hook).
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
created_by: { type: 'string' },
|
created_by: { type: 'string' },
|
||||||
created_on: { type: 'integer' },
|
created_on: { type: 'integer' },
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ class Resource extends Model {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxUpdated = 0;
|
||||||
resObjs.forEach(r => {
|
resObjs.forEach(r => {
|
||||||
r.metadata.isProduction = checkProd(r.id);
|
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,
|
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
|
||||||
|
|||||||
@@ -773,7 +773,7 @@ User.setActive = async function(active) {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await client.modify(this.dn, [
|
await client.modify(this.dn, [
|
||||||
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }),
|
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['00000101000000Z'] }) }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -788,7 +788,7 @@ User.setActive = async function(active) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
|
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
|
||||||
this.isActive = active ? 'active' : '';
|
this.isActive = active ? 'active' : '';
|
||||||
this.isInactive = active ? '' : 'inactive';
|
this.isInactive = active ? '' : 'inactive';
|
||||||
cache.clear();
|
cache.clear();
|
||||||
@@ -907,6 +907,13 @@ User.login = async function(data){
|
|||||||
}
|
}
|
||||||
let user = await this.get(data.uid || data.username);
|
let user = await this.get(data.uid || data.username);
|
||||||
|
|
||||||
|
if (user.pwdAccountLockedTime) {
|
||||||
|
let error = new Error('Invalid Credentials, login failed.');
|
||||||
|
error.name = 'LDAPLoginFailed';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const loginClient = makeClient();
|
const loginClient = makeClient();
|
||||||
try {
|
try {
|
||||||
await loginClient.bind(user.dn, data.password);
|
await loginClient.bind(user.dn, data.password);
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ module.exports = {
|
|||||||
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
|
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
|
||||||
const scan = new nmap.NmapScan(targetRange);
|
const scan = new nmap.NmapScan(targetRange);
|
||||||
scan.command.push('-Pn');
|
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) {
|
scan.on('complete', function(data) {
|
||||||
|
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
|
||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
const edges = [];
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
discover: async (config) => {
|
discover: async (config) => {
|
||||||
const { url, tokenId, tokenSecret } = config;
|
let { url, tokenId, tokenSecret } = config;
|
||||||
if (!url || !tokenId || !tokenSecret) {
|
if (!url || !tokenId || !tokenSecret) {
|
||||||
throw new Error("Missing Proxmox config");
|
throw new Error("Missing Proxmox config");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
router.delete('/resources/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await Resource.get(req.params.id);
|
const r = await Resource.get(req.params.id);
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ router.get('/:id/runs', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const inst = await PluginInstance.get(req.params.id);
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ router.get('/graph', async (req, res, next) => {
|
|||||||
res.json(envelope({
|
res.json(envelope({
|
||||||
resources: projectResources(graph.resources, { fullMetadata }),
|
resources: projectResources(graph.resources, { fullMetadata }),
|
||||||
edges: graph.edges,
|
edges: graph.edges,
|
||||||
|
updated_on: graph.updated_on
|
||||||
}));
|
}));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -98,6 +99,42 @@ router.get('/me', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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', '/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
|
// POST /api/discovery/sync
|
||||||
// Used by external agents (e.g. ldap-client) to push discovery data.
|
// Used by external agents (e.g. ldap-client) to push discovery data.
|
||||||
router.post('/sync', async (req, res, next) => {
|
router.post('/sync', async (req, res, next) => {
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ router.get('/plugins', function(req, res, next) {
|
|||||||
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
|
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
|
||||||
// the same server-side. Same header-vs-navigation auth model as /conf and
|
// the same server-side. Same header-vs-navigation auth model as /conf and
|
||||||
// /vault (auth-token is a client-set header, not a cookie).
|
// /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) {
|
router.get('/vault', function(req, res) {
|
||||||
|
|||||||
@@ -71,17 +71,23 @@ async function runPluginJob(instanceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
|
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 {
|
try {
|
||||||
const cfg = await pluginSecrets.mergeForRun(instance);
|
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);
|
const payload = await runFn(cfg);
|
||||||
if (instance.category === 'discovery') {
|
if (instance.category === 'discovery') {
|
||||||
await DiscoveryReconciler.reconcile(instance.slug, payload);
|
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) {
|
} catch (err) {
|
||||||
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
|
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') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
|
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
|
||||||
<select id="filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
|
||||||
<option value="all">All Resources</option>
|
|
||||||
<option value="unmanaged" selected>Unmanaged Only</option>
|
|
||||||
<option value="managed">Managed Only</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
@@ -132,10 +127,9 @@
|
|||||||
// Name search
|
// Name search
|
||||||
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
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);
|
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
if(managedFilter === 'managed' && !isManaged) return false;
|
if(isManaged) return false;
|
||||||
if(managedFilter === 'unmanaged' && isManaged) return false;
|
|
||||||
|
|
||||||
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
if(!isAuto) return false;
|
if(!isAuto) return false;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
<button class="btn btn-sm btn-warning" title="Edit Secrets" onclick="openSecretsModal('{{id}}')"><i class="fa-solid fa-key"></i></button>
|
<button class="btn btn-sm btn-warning" title="Edit Secrets" onclick="openSecretsModal('{{id}}')"><i class="fa-solid fa-key"></i></button>
|
||||||
<button class="btn btn-sm btn-info" title="Test" onclick="testPlugin('{{id}}')"><i class="fa-solid fa-vial"></i></button>
|
<button class="btn btn-sm btn-info" title="Test" onclick="testPlugin('{{id}}')"><i class="fa-solid fa-vial"></i></button>
|
||||||
<button class="btn btn-sm btn-success" title="Run now" onclick="runNow('{{id}}')"><i class="fa-solid fa-play"></i></button>
|
<button class="btn btn-sm btn-success" title="Run now" onclick="runNow('{{id}}')"><i class="fa-solid fa-play"></i></button>
|
||||||
{{#lastError}}<button class="btn btn-sm btn-secondary" title="View Logs" onclick="showLogs('{{id}}')"><i class="fa-solid fa-file-lines"></i></button>{{/lastError}}
|
{{#lastRunAt}}<button class="btn btn-sm btn-secondary" title="View Logs" onclick="showLogs('{{id}}')"><i class="fa-solid fa-file-lines"></i></button>{{/lastRunAt}}
|
||||||
{{#enabled}}<button class="btn btn-sm btn-outline-danger" title="Unload" onclick="togglePlugin('{{id}}', false)">Unload</button>{{/enabled}}
|
{{#enabled}}<button class="btn btn-sm btn-outline-danger" title="Unload" onclick="togglePlugin('{{id}}', false)">Unload</button>{{/enabled}}
|
||||||
{{^enabled}}<button class="btn btn-sm btn-outline-success" title="Load" onclick="togglePlugin('{{id}}', true)">Load</button>{{/enabled}}
|
{{^enabled}}<button class="btn btn-sm btn-outline-success" title="Load" onclick="togglePlugin('{{id}}', true)">Load</button>{{/enabled}}
|
||||||
<button class="btn btn-sm btn-outline-danger" title="Delete" onclick="deletePlugin('{{id}}')"><i class="fa-solid fa-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" title="Delete" onclick="deletePlugin('{{id}}')"><i class="fa-solid fa-trash"></i></button>
|
||||||
@@ -370,14 +370,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLogs(id) {
|
async function showLogs(id) {
|
||||||
var p = pluginsById[id];
|
var p = pluginsById[id];
|
||||||
if (!p || !p.lastError) return;
|
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({
|
app.modal.open({
|
||||||
title: 'Logs — ' + p.name,
|
title: 'Logs — ' + p.name,
|
||||||
bodyHtml: '<pre class="bg-dark text-white p-3 rounded" style="white-space: pre-wrap; font-size: 0.85em;">' + String(p.lastError).replace(/</g, '<').replace(/>/g, '>') + '</pre>',
|
bodyHtml: '<pre class="bg-dark text-white p-3 rounded" style="white-space: pre-wrap; font-size: 0.85em;">' + String(logText).replace(/</g, '<').replace(/>/g, '>') + '</pre>',
|
||||||
footer: { buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' }
|
footer: { buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' }
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast('Failed to load logs: ' + (err.message || err), 'danger');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePlugin(id, enable) {
|
async function togglePlugin(id, enable) {
|
||||||
|
|||||||
Reference in New Issue
Block a user