Compare commits
19 Commits
v1.19.0
...
bump-1.19.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 80317d1b7e | |||
| ded6a1b0d5 | |||
| a6c24850d4 | |||
| 7da5050ce3 | |||
| 1cb693a1eb | |||
| 5c3a8cefe1 | |||
| 15b3a424bc | |||
| 6cb309b6d9 | |||
| f0ceb750a8 | |||
| 6e95defcf5 | |||
| 92c2e8a03b | |||
| 230e5be2fd | |||
| 0331cb976a | |||
| 90cf65e920 | |||
| 2d202b4979 | |||
| 8f04c20cd7 | |||
| df330c6c0f | |||
| 522093e898 | |||
| 7b84a10420 |
@@ -182,10 +182,8 @@ COPY tos.md /tos.md
|
||||
# without internet access. Same flattened-path convention as tos.md above.
|
||||
COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||
COPY API.md /API.md
|
||||
COPY directory_spec.md /directory_spec.md
|
||||
COPY docs /docs
|
||||
|
||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||
|
||||
@@ -36,10 +36,8 @@ RUN mkdir -p /app/config
|
||||
COPY tos.md /tos.md
|
||||
COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||
COPY API.md /API.md
|
||||
COPY directory_spec.md /directory_spec.md
|
||||
COPY docs /docs
|
||||
|
||||
# Seed script and utility
|
||||
COPY test_seed.js ./test_seed.js
|
||||
|
||||
Binary file not shown.
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -773,7 +773,7 @@ User.setActive = async function(active) {
|
||||
]);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
|
||||
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
|
||||
this.isActive = active ? 'active' : '';
|
||||
this.isInactive = active ? '' : 'inactive';
|
||||
cache.clear();
|
||||
@@ -907,6 +907,13 @@ User.login = async function(data){
|
||||
}
|
||||
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();
|
||||
try {
|
||||
await loginClient.bind(user.dn, data.password);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.5",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ module.exports = {
|
||||
const edges = [];
|
||||
|
||||
for (const c of containers) {
|
||||
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\\//, '') : c.Id.substring(0, 12);
|
||||
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
|
||||
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
|
||||
|
||||
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||
|
||||
@@ -31,17 +31,25 @@ module.exports = {
|
||||
if (!targetRange) throw new Error("Missing targetRange for Nmap");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const scan = new nmap.OsAndPortScan(targetRange);
|
||||
// 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 = [];
|
||||
|
||||
for (const host of data) {
|
||||
if (!host.mac || !host.ip) continue;
|
||||
const hostSlug = `nmap-host-${host.mac.replace(/:/g, '')}`;
|
||||
if (!host.ip) continue;
|
||||
const hostId = host.mac ? host.mac.replace(/:/g, '') : host.ip.replace(/\\./g, '_');
|
||||
const hostSlug = `nmap-host-${hostId}`;
|
||||
|
||||
const interfaces = [{ mac: host.mac, ip: host.ip }];
|
||||
const interfaces = [{ mac: host.mac || null, ip: host.ip }];
|
||||
|
||||
resources.push({
|
||||
kind: 'host',
|
||||
@@ -52,7 +60,7 @@ module.exports = {
|
||||
|
||||
if (host.openPorts && host.openPorts.length > 0) {
|
||||
for (const port of host.openPorts) {
|
||||
const svcSlug = `nmap-svc-${host.mac.replace(/:/g, '')}-${port.port}`;
|
||||
const svcSlug = `nmap-svc-${hostId}-${port.port}`;
|
||||
resources.push({
|
||||
kind: 'service',
|
||||
name: `${port.service} on ${port.port}`,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -43,6 +43,9 @@ module.exports = {
|
||||
const headers = {
|
||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
|
||||
};
|
||||
|
||||
// Ensure URL has no trailing slash
|
||||
url = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
|
||||
const resources = [];
|
||||
const edges = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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', '/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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ class DiscoveryReconciler {
|
||||
|
||||
for (const res of resources) {
|
||||
if (!res.metadata) res.metadata = {};
|
||||
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||
|
||||
let existing = null;
|
||||
|
||||
@@ -94,10 +95,11 @@ class DiscoveryReconciler {
|
||||
metadata: mergedMeta,
|
||||
updated_on: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
res._actualId = existing.id;
|
||||
} else {
|
||||
// Create new
|
||||
const sources = [sourceName];
|
||||
res.metadata.discovery_sources = sources;
|
||||
const sources = new Set([sourceName]);
|
||||
res.metadata.discovery_sources = [...sources];
|
||||
res.metadata.last_seen = Date.now();
|
||||
|
||||
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
@@ -112,12 +114,48 @@ class DiscoveryReconciler {
|
||||
});
|
||||
|
||||
newDevices++;
|
||||
res._actualId = created.id; // Map original slug to actual ID
|
||||
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere
|
||||
// or we just trust the plugins to give us explicit parent-child mappings by slug.
|
||||
// Now process edges
|
||||
const allRes = await Resource.list();
|
||||
const existingEdges = await ResourceEdge.list();
|
||||
|
||||
for (const edge of edges) {
|
||||
// Find parent ID. It might be in the current payload (mapped to _actualId) or in DB by slug
|
||||
let parentId = null;
|
||||
const parentResInPayload = resources.find(r => r._originalSlug === edge.parentSlug);
|
||||
if (parentResInPayload && parentResInPayload._actualId) {
|
||||
parentId = parentResInPayload._actualId;
|
||||
} else {
|
||||
const parentResInDb = allRes.find(r => r.slug === edge.parentSlug);
|
||||
if (parentResInDb) parentId = parentResInDb.id;
|
||||
}
|
||||
|
||||
// Find child ID
|
||||
let childId = null;
|
||||
const childResInPayload = resources.find(r => r._originalSlug === edge.childSlug);
|
||||
if (childResInPayload && childResInPayload._actualId) {
|
||||
childId = childResInPayload._actualId;
|
||||
} else {
|
||||
const childResInDb = allRes.find(r => r.slug === edge.childSlug);
|
||||
if (childResInDb) childId = childResInDb.id;
|
||||
}
|
||||
|
||||
if (parentId && childId) {
|
||||
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
|
||||
if (!edgeExists) {
|
||||
await ResourceEdge.create({
|
||||
id: crypto.randomUUID(),
|
||||
parentId,
|
||||
childId,
|
||||
relation: edge.relation
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newDevices > 0) {
|
||||
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,6 @@
|
||||
</div>
|
||||
<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;">
|
||||
<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 class="card-header actionMessage" style="display:none"></div>
|
||||
@@ -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;
|
||||
@@ -164,7 +158,7 @@
|
||||
return;
|
||||
}
|
||||
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
|
||||
renderTable();
|
||||
loadResources();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +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-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>
|
||||
{{#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-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>
|
||||
@@ -280,7 +281,7 @@
|
||||
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
|
||||
footer: {
|
||||
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
|
||||
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
|
||||
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit(\'' + id + '\')', saveLabel: 'Save' })
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -324,7 +325,7 @@
|
||||
app.modal.open({
|
||||
title: 'Edit Secrets — ' + p.name,
|
||||
bodyHtml: html,
|
||||
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets("' + id + '")', saveLabel: 'Save Secrets' }) }
|
||||
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets(\'' + id + '\')', saveLabel: 'Save Secrets' }) }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,6 +370,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function showLogs(id) {
|
||||
var p = pluginsById[id];
|
||||
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: '<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>' }
|
||||
});
|
||||
} catch (err) {
|
||||
app.messages.toast('Failed to load logs: ' + (err.message || err), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(id, enable) {
|
||||
try {
|
||||
await app.api.post('plugins/' + id + (enable ? '/load' : '/unload'), {});
|
||||
|
||||
Reference in New Issue
Block a user