@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.40
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/bin/thub-admin.js +26 -5
- package/config.json +5 -6
- package/package.json +2 -2
- package/public/js/actions.js +58 -0
- package/public/js/log-viewer.js +188 -56
- package/public/js/settings.js +66 -0
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +28 -22
- package/src/api/resource.js +11 -14
- package/src/api/sse.js +13 -10
- package/src/config.js +33 -10
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/db/migrations/024_add_settings.sql +10 -0
- package/src/db/migrations/025_add_job_artifacts.sql +4 -0
- package/src/dev/virtual.js +10 -6
- package/src/login-guard.js +111 -0
- package/src/rate-limit.js +137 -0
- package/src/security.js +104 -0
- package/src/server.js +53 -23
- package/src/services/admin-users.js +32 -19
- package/src/services/cleanup.js +2 -2
- package/src/services/heartbeat.js +23 -3
- package/src/services/job-artifacts.js +91 -0
- package/src/services/jobs.js +17 -9
- package/src/services/logs.js +12 -14
- package/src/services/registry.js +34 -0
- package/src/services/retention.js +5 -14
- package/src/services/session-store.js +127 -0
- package/src/services/settings.js +345 -0
- package/src/web/routes.js +230 -21
- package/test/admin-users.test.js +16 -16
- package/test/agent-visibility.test.js +85 -0
- package/test/cleanup.test.js +1 -5
- package/test/groups-tab.test.js +97 -0
- package/test/job-artifacts.test.js +115 -0
- package/test/login-guard.test.js +139 -0
- package/test/logs.test.js +150 -0
- package/test/rate-limit.test.js +106 -0
- package/test/scheduler.test.js +10 -12
- package/test/security.test.js +107 -0
- package/test/session.test.js +132 -0
- package/test/settings.test.js +206 -0
- package/views/admin/settings.pug +133 -0
- package/views/groups/list.pug +1 -1
- package/views/help/_agent-cli.pug +2 -2
- package/views/help/_agent-setup.pug +4 -1
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_coordinator.pug +18 -7
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +2 -1
- package/views/help/_git.pug +3 -2
- package/views/help/_overview.pug +2 -2
- package/views/help/_troubleshooting.pug +4 -0
- package/views/jobs/list.pug +5 -5
- package/views/jobs/show.pug +33 -7
- package/views/layout.pug +4 -1
- package/views/mixins/client-config.pug +65 -3
- package/views/mixins/list-controls.pug +1 -1
- package/views/mixins/log-viewer.pug +14 -0
- package/views/mixins/resource.pug +1 -8
- package/src/services/artifacts.js +0 -149
package/src/web/routes.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const fs = require('node:fs'),
|
|
17
17
|
os = require('node:os'),
|
|
18
18
|
path = require('node:path'),
|
|
19
|
+
{ once } = require('node:events'),
|
|
19
20
|
express = require('express'),
|
|
20
21
|
multer = require('multer'),
|
|
21
22
|
{ requireAdminSession, requireAdminRole } = require('../auth'),
|
|
@@ -26,11 +27,19 @@ const fs = require('node:fs'),
|
|
|
26
27
|
} = require('@andrian.yablonskyy/thub-common'),
|
|
27
28
|
{ retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
|
|
28
29
|
listPrefs = require('../services/list-prefs'),
|
|
29
|
-
search = require('../services/search')
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
search = require('../services/search'),
|
|
31
|
+
{ createLoginGuard } = require('../login-guard'),
|
|
32
|
+
{ formatBytes } = require('../services/job-artifacts');
|
|
33
|
+
|
|
34
|
+
// When this process started: the settings page tells a restart happened by it changing.
|
|
35
|
+
const STARTED_AT = new Date().toISOString(),
|
|
36
|
+
// Job log viewer: lines per page it loads (and the most one request may ask for).
|
|
37
|
+
LOG_PAGE = 1000,
|
|
38
|
+
LOG_PAGE_MAX = 5000,
|
|
39
|
+
|
|
40
|
+
// Resources page sort keys (list-prefs.js) -> comparators. Empty values
|
|
41
|
+
// (never heartbeated, no version reported) sort last in both directions.
|
|
42
|
+
RESOURCE_SORT_VALUE = {
|
|
34
43
|
name: (r) => r.name,
|
|
35
44
|
type: (r) => r.type,
|
|
36
45
|
version: (r) => r.client_version,
|
|
@@ -65,7 +74,8 @@ const RESOURCE_SORT_VALUE = {
|
|
|
65
74
|
// views hitting the same kind of SSE stream the Agent uses (§6.4), just
|
|
66
75
|
// authenticated by session cookie instead of a bearer token.
|
|
67
76
|
function createWebRouter({ services, config }){
|
|
68
|
-
const router = express.Router()
|
|
77
|
+
const router = express.Router(),
|
|
78
|
+
loginGuard = createLoginGuard();
|
|
69
79
|
|
|
70
80
|
// Shown as a toast (layout.pug): `danger` stays until closed, `warning`
|
|
71
81
|
// closes after 30 s, `info`/`success` after 10 s — unless `sticky`.
|
|
@@ -165,6 +175,8 @@ function createWebRouter({ services, config }){
|
|
|
165
175
|
res.locals.isNewer = isNewer;
|
|
166
176
|
res.locals.nextCronRun = nextCronRun;
|
|
167
177
|
res.locals.formatDateTime = formatDateTime;
|
|
178
|
+
// 1233 -> "1.2 KB" (job artifacts).
|
|
179
|
+
res.locals.fmtBytes = formatBytes;
|
|
168
180
|
res.locals.currentPath = req.originalUrl;
|
|
169
181
|
// A live-update re-fetch (server.js, req.thubPassive) renders the page
|
|
170
182
|
// only for its data: flash messages stay for the next real page view.
|
|
@@ -203,19 +215,59 @@ function createWebRouter({ services, config }){
|
|
|
203
215
|
next();
|
|
204
216
|
});
|
|
205
217
|
|
|
218
|
+
// A Secure-only session cookie (server.js cookieSecure, §12) is never
|
|
219
|
+
// sent back over plain HTTP, so signing in would just loop back here.
|
|
220
|
+
// Say why instead — usually a proxy not passing X-Forwarded-Proto.
|
|
221
|
+
function httpsProblem(req){
|
|
222
|
+
if (req.session.cookie.secure !== true || req.secure){
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
type: 'danger',
|
|
227
|
+
sticky: true,
|
|
228
|
+
text: `Signing in needs HTTPS: this Coordinator's session cookie is sent only over HTTPS (publicUrl is ${config.publicUrl}), ` +
|
|
229
|
+
'but this request reached it as plain HTTP. Open the dashboard through that https:// address; behind a reverse proxy, ' +
|
|
230
|
+
'make it send "X-Forwarded-Proto: https" and check trustProxy. To allow plain HTTP, set session.secureCookie to false.'
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
router.get('/login', (req, res) => {
|
|
207
235
|
if (req.session.user){
|
|
208
236
|
return res.redirect('/');
|
|
209
237
|
}
|
|
210
|
-
|
|
238
|
+
const problem = httpsProblem(req);
|
|
239
|
+
res.render('login', { title: 'Sign in', ...(problem ? { messages: [problem] } : {}) });
|
|
211
240
|
});
|
|
212
241
|
|
|
213
|
-
|
|
242
|
+
// Backoff after failed attempts, per IP and per username, checked before
|
|
243
|
+
// any hashing; a cap on password checks running at once (login-guard.js).
|
|
244
|
+
// The answer to a wrong password and to an unknown user is the same, and
|
|
245
|
+
// takes as long (admin-users.js).
|
|
246
|
+
function throttled(res, { reason, retryAfterSec }, what){
|
|
247
|
+
res.set('Retry-After', String(retryAfterSec));
|
|
248
|
+
const text = reason === 'busy'
|
|
249
|
+
? 'The Coordinator is busy checking other sign-ins — try again in a moment.'
|
|
250
|
+
: `Too many failed ${what} — try again in ${retryAfterSec} s.`;
|
|
251
|
+
return { status: reason === 'busy' ? 503 : 429, text };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
router.post('/login', async (req, res) => {
|
|
255
|
+
const problem = httpsProblem(req);
|
|
256
|
+
if (problem){
|
|
257
|
+
return res.status(400).render('login', { title: 'Sign in', messages: [problem] });
|
|
258
|
+
}
|
|
214
259
|
const { username, password } = req.body,
|
|
215
|
-
|
|
260
|
+
allowed = loginGuard.check(req.ip, username);
|
|
261
|
+
if (!allowed.ok){
|
|
262
|
+
const { status, text } = throttled(res, allowed, 'sign-ins');
|
|
263
|
+
return res.status(status).render('login', { title: 'Sign in', messages: [{ type: 'danger', text }] });
|
|
264
|
+
}
|
|
265
|
+
const user = await loginGuard.run(() => services.adminUsers.verify(username, password));
|
|
216
266
|
if (!user){
|
|
267
|
+
loginGuard.failure(req.ip, username);
|
|
217
268
|
return res.status(401).render('login', { title: 'Sign in', messages: [{ type: 'danger', text: 'Invalid credentials' }] });
|
|
218
269
|
}
|
|
270
|
+
loginGuard.success(req.ip, username);
|
|
219
271
|
req.session.user = user;
|
|
220
272
|
req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
|
|
221
273
|
res.redirect('/');
|
|
@@ -307,17 +359,29 @@ function createWebRouter({ services, config }){
|
|
|
307
359
|
// profile form since it needs the *current* password re-entered, and a
|
|
308
360
|
// mismatch between newPassword/confirmPassword is a form-level check
|
|
309
361
|
// that has nothing to do with the other fields.
|
|
310
|
-
|
|
311
|
-
|
|
362
|
+
// The current password is checked like a sign-in: the same backoff, so a
|
|
363
|
+
// stolen session can't be used to guess it.
|
|
364
|
+
router.post('/profile/password', async (req, res) => {
|
|
365
|
+
const { currentPassword, newPassword, confirmPassword } = req.body,
|
|
366
|
+
{ username } = req.session.user;
|
|
312
367
|
if (newPassword !== confirmPassword){
|
|
313
368
|
flash(req, 'danger', 'New password and confirmation do not match.');
|
|
314
369
|
return res.redirect('/profile');
|
|
315
370
|
}
|
|
371
|
+
const allowed = loginGuard.check(req.ip, username);
|
|
372
|
+
if (!allowed.ok){
|
|
373
|
+
flash(req, 'danger', throttled(res, allowed, 'password checks').text);
|
|
374
|
+
return res.redirect('/profile');
|
|
375
|
+
}
|
|
316
376
|
try {
|
|
317
|
-
services.adminUsers.changePassword(req.session.user.id, currentPassword, newPassword);
|
|
377
|
+
await loginGuard.run(() => services.adminUsers.changePassword(req.session.user.id, currentPassword, newPassword));
|
|
378
|
+
loginGuard.success(req.ip, username);
|
|
318
379
|
flash(req, 'success', 'Password changed.');
|
|
319
380
|
}
|
|
320
381
|
catch (err){
|
|
382
|
+
if (err.status === 401){
|
|
383
|
+
loginGuard.failure(req.ip, username);
|
|
384
|
+
}
|
|
321
385
|
flash(req, 'danger', err.message);
|
|
322
386
|
}
|
|
323
387
|
res.redirect('/profile');
|
|
@@ -563,6 +627,24 @@ function createWebRouter({ services, config }){
|
|
|
563
627
|
res.redirect(returnTo(req, '/resources'));
|
|
564
628
|
});
|
|
565
629
|
|
|
630
|
+
// Groups tab (resource card): the Client's group membership, as a config
|
|
631
|
+
// revision the Client writes to its file (registry.setClientGroups).
|
|
632
|
+
router.post('/resources/:id/groups', requireAdminRole, (req, res) => {
|
|
633
|
+
try {
|
|
634
|
+
const ids = [].concat(req.body.groups || []).filter((g) => typeof g === 'string' && g),
|
|
635
|
+
r = services.registry.setClientGroups(req.params.id, ids, {
|
|
636
|
+
by: req.session.user.username,
|
|
637
|
+
knownGroupIds: services.groups.list().map((g) => g.id)
|
|
638
|
+
});
|
|
639
|
+
flash(req, 'success', `Groups of ${r.name} saved: ${r.group_ids.length ? `${r.group_ids.length} group(s)` : 'none'} — in effect now.\n` +
|
|
640
|
+
'The Client writes them to its config file on its next heartbeat, then restarts once it has no job running.');
|
|
641
|
+
}
|
|
642
|
+
catch (err){
|
|
643
|
+
flash(req, 'danger', err.message);
|
|
644
|
+
}
|
|
645
|
+
res.redirect(returnTo(req, '/resources'));
|
|
646
|
+
});
|
|
647
|
+
|
|
566
648
|
// Export (resource card, admins — the file holds the join key): the
|
|
567
649
|
// Client's config file as JSON, secrets excluded.
|
|
568
650
|
router.get('/resources/:id/config/export', requireAdminRole, (req, res) => {
|
|
@@ -786,7 +868,7 @@ function createWebRouter({ services, config }){
|
|
|
786
868
|
});
|
|
787
869
|
|
|
788
870
|
// "Clean up database" (§9): everything before the given time — finished
|
|
789
|
-
// jobs with their logs
|
|
871
|
+
// jobs with their logs, and older history — then VACUUM. The
|
|
790
872
|
// time is dd/mm/yyyy HH:MM:SS in the user's own time zone.
|
|
791
873
|
router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
|
|
792
874
|
const tz = req.session.user.timezone || 'UTC',
|
|
@@ -818,8 +900,7 @@ function createWebRouter({ services, config }){
|
|
|
818
900
|
return res.status(404).send('Not found');
|
|
819
901
|
}
|
|
820
902
|
job.resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
821
|
-
const
|
|
822
|
-
jobActive = ACTIVE_JOB_STATES.has(job.state);
|
|
903
|
+
const jobActive = ACTIVE_JOB_STATES.has(job.state);
|
|
823
904
|
res.render('jobs/show', {
|
|
824
905
|
title: job.id,
|
|
825
906
|
active: 'jobs',
|
|
@@ -829,7 +910,6 @@ function createWebRouter({ services, config }){
|
|
|
829
910
|
? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
|
|
830
911
|
job.spec.target.client)
|
|
831
912
|
: null,
|
|
832
|
-
artifacts,
|
|
833
913
|
jobActive,
|
|
834
914
|
canCancel: req.session.user.role === 'admin' && jobActive
|
|
835
915
|
});
|
|
@@ -847,15 +927,53 @@ function createWebRouter({ services, config }){
|
|
|
847
927
|
res.redirect(returnTo(req, `/jobs/${req.params.id}`));
|
|
848
928
|
});
|
|
849
929
|
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
930
|
+
// The log viewer's pages (public/js/log-viewer.js): the last `limit`
|
|
931
|
+
// lines first, then earlier ones (`before` = the oldest seq it has) as the
|
|
932
|
+
// reader scrolls up. `total` is how many lines the job has in all.
|
|
853
933
|
router.get('/jobs/:id/logs', (req, res) => {
|
|
854
|
-
|
|
934
|
+
const before = Number.parseInt(req.query.before, 10),
|
|
935
|
+
limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || LOG_PAGE, 1), LOG_PAGE_MAX),
|
|
936
|
+
page = services.logs.pageBefore(req.params.id, before > 0 ? before : undefined, limit);
|
|
937
|
+
res.json({ ...page, total: services.logs.count(req.params.id) });
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
// RAW (log viewer): the whole log as plain text, for a new tab, a
|
|
941
|
+
// download or grep. Streamed in pages, waiting for the socket to drain,
|
|
942
|
+
// so a huge log never sits in memory. Log lines live as long as their job
|
|
943
|
+
// (§9), so this is always the complete log.
|
|
944
|
+
router.get('/jobs/:id/log.txt', async (req, res, next) => {
|
|
945
|
+
try {
|
|
946
|
+
const job = services.jobs.get(req.params.id);
|
|
947
|
+
if (!job){
|
|
948
|
+
return res.status(404).type('text/plain').send('Unknown job');
|
|
949
|
+
}
|
|
950
|
+
res.set({
|
|
951
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
952
|
+
// The log is whatever the job printed: never let a browser sniff it
|
|
953
|
+
// into HTML.
|
|
954
|
+
'X-Content-Type-Options': 'nosniff',
|
|
955
|
+
'Content-Disposition': `inline; filename="${job.id}.log"`,
|
|
956
|
+
'Cache-Control': 'no-store'
|
|
957
|
+
});
|
|
958
|
+
for (let seq = 0, page; (page = services.logs.listSince(job.id, seq, LOG_PAGE_MAX)).length;){
|
|
959
|
+
const chunk = page.map((l) => `[${l.ts}] [${l.stream}] ${l.line}\n`).join('');
|
|
960
|
+
seq = page.at(-1).seq;
|
|
961
|
+
if (!res.write(chunk)){
|
|
962
|
+
await once(res, 'drain');
|
|
963
|
+
}
|
|
964
|
+
if (res.destroyed){
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
res.end();
|
|
969
|
+
}
|
|
970
|
+
catch (err){
|
|
971
|
+
next(err);
|
|
972
|
+
}
|
|
855
973
|
});
|
|
856
974
|
|
|
857
975
|
router.get('/jobs/:id/stream', (req, res) => {
|
|
858
|
-
attachJobStream(req, res, { jobId: req.params.id, services
|
|
976
|
+
attachJobStream(req, res, { jobId: req.params.id, services });
|
|
859
977
|
});
|
|
860
978
|
|
|
861
979
|
// Resource groups (§13.1) — their own page since they're a distinct
|
|
@@ -927,6 +1045,97 @@ function createWebRouter({ services, config }){
|
|
|
927
1045
|
});
|
|
928
1046
|
}
|
|
929
1047
|
|
|
1048
|
+
// Coordinator settings (services/settings.js, README §13.2): stored in
|
|
1049
|
+
// the database over the config file. "Restart now" only under systemd,
|
|
1050
|
+
// whose Restart=always brings the process back (INVOCATION_ID is set for
|
|
1051
|
+
// every unit it starts); run by hand, exiting would just stop it.
|
|
1052
|
+
const canRestart = Boolean(process.env.INVOCATION_ID);
|
|
1053
|
+
|
|
1054
|
+
router.get('/admin/settings', requireAdminRole, (req, res) => {
|
|
1055
|
+
const items = services.settings.list(),
|
|
1056
|
+
groups = [...new Set(items.map((s) => s.group))].map((name) => ({ name, items: items.filter((s) => s.group === name) }));
|
|
1057
|
+
res.render('admin/settings', {
|
|
1058
|
+
title: 'Settings',
|
|
1059
|
+
active: 'settings',
|
|
1060
|
+
groups,
|
|
1061
|
+
pendingRestart: items.filter((s) => s.pendingRestart),
|
|
1062
|
+
canRestart,
|
|
1063
|
+
startedAt: STARTED_AT,
|
|
1064
|
+
restarting: req.query.restarting || null,
|
|
1065
|
+
readOnly: {
|
|
1066
|
+
listen: config.listen,
|
|
1067
|
+
dataDir: config.dataDir,
|
|
1068
|
+
configPath: config.configPath || '(built-in defaults)'
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
router.post('/admin/settings', requireAdminRole, (req, res) => {
|
|
1074
|
+
const posted = req.body.s || {},
|
|
1075
|
+
changes = {},
|
|
1076
|
+
reset = [].concat(req.body.reset || []);
|
|
1077
|
+
for (const s of services.settings.SETTINGS){
|
|
1078
|
+
if (s.type === 'secret'){
|
|
1079
|
+
// Empty means "unchanged": the current secret is never put in the page.
|
|
1080
|
+
if (req.body.clear?.[s.key] === '1'){
|
|
1081
|
+
changes[s.key] = '';
|
|
1082
|
+
}
|
|
1083
|
+
else if (posted[s.key]){
|
|
1084
|
+
changes[s.key] = posted[s.key];
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
else if (s.key in posted){
|
|
1088
|
+
// A checkbox posts a hidden "false" and, when checked, "true" after it.
|
|
1089
|
+
changes[s.key] = [].concat(posted[s.key]).at(-1);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
const result = services.settings.save({ changes, reset }, {
|
|
1094
|
+
by: req.session.user.username,
|
|
1095
|
+
// Don't let an admin lock everyone out: an HTTPS-only cookie
|
|
1096
|
+
// can't come back over the plain-HTTP connection they're using.
|
|
1097
|
+
guard: (after) => {
|
|
1098
|
+
const secureCookie = after['session.secureCookie'],
|
|
1099
|
+
httpsOnly = secureCookie === true || (secureCookie === 'auto' && /^https:\/\//i.test(after.publicUrl || ''));
|
|
1100
|
+
return httpsOnly && !req.secure
|
|
1101
|
+
? 'Secure session cookie: you\'re connected over plain HTTP, so with this setting nobody could sign in this way ' +
|
|
1102
|
+
'after the restart. Open the dashboard over https:// first (behind a proxy: it must send X-Forwarded-Proto: https), ' +
|
|
1103
|
+
'or set it to false.'
|
|
1104
|
+
: null;
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
if (!result.changed.length){
|
|
1108
|
+
flash(req, 'info', 'Nothing changed.');
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
const label = (k) => services.settings.SETTINGS.find((s) => s.key === k).label;
|
|
1112
|
+
flash(req, result.restart.length ? 'warning' : 'success', [
|
|
1113
|
+
result.now.length ? `Saved and applied: ${result.now.map(label).join(', ')}.` : '',
|
|
1114
|
+
result.restart.length ? `Saved, applies after a restart: ${result.restart.map(label).join(', ')}.` : ''
|
|
1115
|
+
].filter(Boolean).join('\n'));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
catch (err){
|
|
1119
|
+
flash(req, 'danger', err.message);
|
|
1120
|
+
}
|
|
1121
|
+
res.redirect('/admin/settings');
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
router.post('/admin/settings/restart', requireAdminRole, (req, res) => {
|
|
1125
|
+
if (!canRestart){
|
|
1126
|
+
flash(req, 'danger', 'This Coordinator isn\'t running under systemd, so it can\'t restart itself — restart it by hand.');
|
|
1127
|
+
return res.redirect('/admin/settings');
|
|
1128
|
+
}
|
|
1129
|
+
res.redirect(`/admin/settings?restarting=${encodeURIComponent(STARTED_AT)}`);
|
|
1130
|
+
// After the redirect is out; systemd (Restart=always) starts it again.
|
|
1131
|
+
res.on('finish', () => setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500));
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Polled by the settings page while restarting (public/js/settings.js).
|
|
1135
|
+
router.get('/admin/settings/status', requireAdminRole, (req, res) => {
|
|
1136
|
+
res.set('Cache-Control', 'no-store').json({ startedAt: STARTED_AT });
|
|
1137
|
+
});
|
|
1138
|
+
|
|
930
1139
|
router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
|
|
931
1140
|
|
|
932
1141
|
router.post('/admin/agents', requireAdminRole, (req, res) => {
|
package/test/admin-users.test.js
CHANGED
|
@@ -23,11 +23,11 @@ function buildService(){
|
|
|
23
23
|
return createAdminUsersService(db);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
test('a newly created user gets profile defaults (UTC, auto theme, 60min timeout)', () => {
|
|
26
|
+
test('a newly created user gets profile defaults (UTC, auto theme, 60min timeout)', async () => {
|
|
27
27
|
const adminUsers = buildService();
|
|
28
28
|
adminUsers.create({ username: 'alice', password: 'hunter2', role: 'admin' });
|
|
29
29
|
|
|
30
|
-
const user = adminUsers.verify('alice', 'hunter2');
|
|
30
|
+
const user = await adminUsers.verify('alice', 'hunter2');
|
|
31
31
|
assert.equal(user.timezone, 'UTC');
|
|
32
32
|
assert.equal(user.theme, 'auto');
|
|
33
33
|
assert.equal(user.sessionTimeoutMin, 60);
|
|
@@ -80,48 +80,48 @@ test('a session timeout outside the offered presets is rejected (400)', () => {
|
|
|
80
80
|
assert.throws(() => adminUsers.updateProfile(id, { sessionTimeoutMin: 7 }), (err) => err.status === 400);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test('resetPassword creates the user as admin when the username does not exist yet', () => {
|
|
83
|
+
test('resetPassword creates the user as admin when the username does not exist yet', async () => {
|
|
84
84
|
const adminUsers = buildService();
|
|
85
85
|
|
|
86
86
|
adminUsers.resetPassword({ username: 'admin', password: 'newpass' });
|
|
87
|
-
const user = adminUsers.verify('admin', 'newpass');
|
|
87
|
+
const user = await adminUsers.verify('admin', 'newpass');
|
|
88
88
|
assert.ok(user);
|
|
89
89
|
assert.equal(user.role, 'admin');
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
test('resetPassword on an existing user changes only the password, not the role', () => {
|
|
92
|
+
test('resetPassword on an existing user changes only the password, not the role', async () => {
|
|
93
93
|
const adminUsers = buildService();
|
|
94
94
|
adminUsers.create({ username: 'carol', password: 'oldpass', role: 'viewer' });
|
|
95
95
|
|
|
96
96
|
adminUsers.resetPassword({ username: 'carol', password: 'newpass' });
|
|
97
97
|
|
|
98
|
-
assert.equal(adminUsers.verify('carol', 'oldpass'), null);
|
|
99
|
-
const user = adminUsers.verify('carol', 'newpass');
|
|
98
|
+
assert.equal(await adminUsers.verify('carol', 'oldpass'), null);
|
|
99
|
+
const user = await adminUsers.verify('carol', 'newpass');
|
|
100
100
|
assert.ok(user);
|
|
101
101
|
assert.equal(user.role, 'viewer'); // not silently promoted to admin
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
test('changePassword succeeds with the correct current password', () => {
|
|
104
|
+
test('changePassword succeeds with the correct current password', async () => {
|
|
105
105
|
const adminUsers = buildService(),
|
|
106
106
|
{ id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
|
|
107
107
|
|
|
108
|
-
adminUsers.changePassword(id, 'oldpass', 'newpass');
|
|
108
|
+
await adminUsers.changePassword(id, 'oldpass', 'newpass');
|
|
109
109
|
|
|
110
|
-
assert.equal(adminUsers.verify('dave', 'oldpass'), null);
|
|
111
|
-
assert.ok(adminUsers.verify('dave', 'newpass'));
|
|
110
|
+
assert.equal(await adminUsers.verify('dave', 'oldpass'), null);
|
|
111
|
+
assert.ok(await adminUsers.verify('dave', 'newpass'));
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
test('changePassword is rejected (401) with the wrong current password, leaving it unchanged', () => {
|
|
114
|
+
test('changePassword is rejected (401) with the wrong current password, leaving it unchanged', async () => {
|
|
115
115
|
const adminUsers = buildService(),
|
|
116
116
|
{ id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
|
|
117
117
|
|
|
118
|
-
assert.
|
|
119
|
-
assert.ok(adminUsers.verify('dave', 'oldpass'));
|
|
118
|
+
await assert.rejects(adminUsers.changePassword(id, 'wrongpass', 'newpass'), (err) => err.status === 401);
|
|
119
|
+
assert.ok(await adminUsers.verify('dave', 'oldpass'));
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
test('changePassword rejects an empty new password (400)', () => {
|
|
122
|
+
test('changePassword rejects an empty new password (400)', async () => {
|
|
123
123
|
const adminUsers = buildService(),
|
|
124
124
|
{ id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
|
|
125
125
|
|
|
126
|
-
assert.
|
|
126
|
+
await assert.rejects(adminUsers.changePassword(id, 'oldpass', ''), (err) => err.status === 400);
|
|
127
127
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/agent-visibility.test.js
|
|
3
|
+
* @description Tests: which jobs an agent token may see — a cli token only its own, a ci token all
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
os = require('node:os'),
|
|
20
|
+
path = require('node:path'),
|
|
21
|
+
{ loadConfig } = require('../src/config'),
|
|
22
|
+
{ buildServices, createApp } = require('../src/server');
|
|
23
|
+
|
|
24
|
+
test('a cli agent sees only its own jobs (others look like unknown ids); a ci agent sees all', async (t) => {
|
|
25
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-vis-')),
|
|
26
|
+
file = path.join(dir, 'coordinator.json');
|
|
27
|
+
fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
|
|
28
|
+
const config = loadConfig(file),
|
|
29
|
+
services = buildServices(config),
|
|
30
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
31
|
+
t.after(() => new Promise((r) => {
|
|
32
|
+
server.closeAllConnections?.();
|
|
33
|
+
server.close(r);
|
|
34
|
+
}));
|
|
35
|
+
await new Promise((r) => server.on('listening', r));
|
|
36
|
+
services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
37
|
+
|
|
38
|
+
const base = `http://127.0.0.1:${server.address().port}/api/v1`,
|
|
39
|
+
alice = services.agents.create({ name: 'alice', kind: 'cli' }).token,
|
|
40
|
+
bob = services.agents.create({ name: 'bob', kind: 'cli' }).token,
|
|
41
|
+
ci = services.agents.create({ name: 'pipeline', kind: 'ci' }).token,
|
|
42
|
+
call = async (token, method, url, body) => {
|
|
43
|
+
const res = await fetch(`${base}${url}`, {
|
|
44
|
+
method,
|
|
45
|
+
headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
46
|
+
body: body ? JSON.stringify(body) : undefined
|
|
47
|
+
});
|
|
48
|
+
return { status: res.status, body: res.headers.get('content-type')?.includes('json') ? await res.json() : await res.text() };
|
|
49
|
+
},
|
|
50
|
+
submit = async (token) => (await call(token, 'POST', '/jobs', { target: { type: 'sw' }, command: './t.sh' })).body.jobId,
|
|
51
|
+
aliceJob = await submit(alice),
|
|
52
|
+
bobJob = await submit(bob),
|
|
53
|
+
ciJob = await submit(ci);
|
|
54
|
+
|
|
55
|
+
// Alice: her own job, every way of reading it.
|
|
56
|
+
assert.equal((await call(alice, 'GET', `/jobs/${aliceJob}`)).status, 200);
|
|
57
|
+
assert.equal((await call(alice, 'GET', `/jobs/${aliceJob}/logs`)).status, 200);
|
|
58
|
+
assert.equal((await call(alice, 'GET', `/jobs/${aliceJob}/artifacts`)).status, 200);
|
|
59
|
+
|
|
60
|
+
// Anyone else's job (a colleague's, a pipeline's): exactly like an id that doesn't exist.
|
|
61
|
+
const unknown = await call(alice, 'GET', '/jobs/M-99999');
|
|
62
|
+
for (const id of [bobJob, ciJob]){
|
|
63
|
+
for (const url of [`/jobs/${id}`, `/jobs/${id}/logs`, `/jobs/${id}/logs/stream`, `/jobs/${id}/artifacts`]){
|
|
64
|
+
const r = await call(alice, 'GET', url);
|
|
65
|
+
assert.equal(r.status, 404, url);
|
|
66
|
+
assert.deepEqual(r.body, unknown.body, url);
|
|
67
|
+
}
|
|
68
|
+
assert.equal((await call(alice, 'POST', `/jobs/${id}/cancel`)).status, 404);
|
|
69
|
+
}
|
|
70
|
+
assert.equal(services.jobs.get(bobJob).state, 'QUEUED'); // untouched
|
|
71
|
+
|
|
72
|
+
// Her list is her own jobs, --mine or not.
|
|
73
|
+
const ids = async (token, q = '') => (await call(token, 'GET', `/jobs${q}`)).body.jobs.map((j) => j.id).sort();
|
|
74
|
+
assert.deepEqual(await ids(alice), [aliceJob]);
|
|
75
|
+
assert.deepEqual(await ids(alice, '?mine=true'), [aliceJob]);
|
|
76
|
+
|
|
77
|
+
// A ci agent reads every job (and lists them all, or its own with --mine)…
|
|
78
|
+
assert.equal((await call(ci, 'GET', `/jobs/${aliceJob}`)).status, 200);
|
|
79
|
+
assert.equal((await call(ci, 'GET', `/jobs/${bobJob}/logs`)).status, 200);
|
|
80
|
+
assert.deepEqual(await ids(ci), [aliceJob, bobJob, ciJob].sort());
|
|
81
|
+
assert.deepEqual(await ids(ci, '?mine=1'), [ciJob]);
|
|
82
|
+
// …but still cancels only its own.
|
|
83
|
+
assert.equal((await call(ci, 'POST', `/jobs/${aliceJob}/cancel`)).status, 403);
|
|
84
|
+
assert.equal((await call(alice, 'POST', `/jobs/${aliceJob}/cancel`)).status, 200);
|
|
85
|
+
});
|
package/test/cleanup.test.js
CHANGED
|
@@ -23,7 +23,6 @@ const test = require('node:test'),
|
|
|
23
23
|
{ createEventsService } = require('../src/services/events'),
|
|
24
24
|
{ createRegistryService } = require('../src/services/registry'),
|
|
25
25
|
{ createAgentsService } = require('../src/services/agents'),
|
|
26
|
-
{ createArtifactsService } = require('../src/services/artifacts'),
|
|
27
26
|
{ createJobsService } = require('../src/services/jobs'),
|
|
28
27
|
{ createCleanupService, retentionCutoff } = require('../src/services/cleanup'),
|
|
29
28
|
{ JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
@@ -35,8 +34,6 @@ function setup(jobRetention = 'forever'){
|
|
|
35
34
|
config = {
|
|
36
35
|
dataDir,
|
|
37
36
|
dbPath: path.join(dataDir, 'thub.db'),
|
|
38
|
-
artifactsDir: path.join(dataDir, 'artifacts'),
|
|
39
|
-
artifacts: { linkTtlHours: 1 },
|
|
40
37
|
sessionSecret: 's',
|
|
41
38
|
publicUrl: 'http://x',
|
|
42
39
|
scheduler: { maxQueuedPerAgent: 1000 },
|
|
@@ -48,8 +45,7 @@ function setup(jobRetention = 'forever'){
|
|
|
48
45
|
events = createEventsService(db),
|
|
49
46
|
registry = createRegistryService(db, { bus, events }),
|
|
50
47
|
{ agent } = createAgentsService(db, { events }).create({ name: 'ci', kind: 'ci' }),
|
|
51
|
-
|
|
52
|
-
jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
|
|
48
|
+
jobs = createJobsService(db, { bus, events, registry, config }),
|
|
53
49
|
cleanup = createCleanupService(db, { jobs, config, now: () => NOW });
|
|
54
50
|
registry.registerAuto({ clientId: 'c', name: 'lab', type: 'sw', labels: [] });
|
|
55
51
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/groups-tab.test.js
|
|
3
|
+
* @description Tests: a Client's group membership set from the resource card's Groups tab
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
os = require('node:os'),
|
|
20
|
+
path = require('node:path'),
|
|
21
|
+
{ loadConfig } = require('../src/config'),
|
|
22
|
+
{ buildServices, createApp } = require('../src/server');
|
|
23
|
+
|
|
24
|
+
async function start(t){
|
|
25
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-grp-')),
|
|
26
|
+
file = path.join(dir, 'coordinator.json');
|
|
27
|
+
fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 }, rateLimit: { loginPerMinute: 0 } }));
|
|
28
|
+
const config = loadConfig(file),
|
|
29
|
+
services = buildServices(config),
|
|
30
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
31
|
+
t.after(() => new Promise((r) => {
|
|
32
|
+
server.closeAllConnections?.();
|
|
33
|
+
server.close(r);
|
|
34
|
+
}));
|
|
35
|
+
await new Promise((r) => server.on('listening', r));
|
|
36
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
37
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
38
|
+
services.adminUsers.create({ username: 'view', password: 'pw', role: 'viewer' });
|
|
39
|
+
const login = async (u) => (await fetch(`${base}/login`, {
|
|
40
|
+
method: 'POST', body: new URLSearchParams({ username: u, password: 'pw' }), redirect: 'manual'
|
|
41
|
+
})).headers.get('set-cookie').split(';')[0];
|
|
42
|
+
return { base, services, admin: await login('admin'), viewer: await login('view') };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A Client recent enough to report its config file (what Import needs too).
|
|
46
|
+
const register = (services, name, { configFile = { name, type: 'sw' } } = {}) => services.registry.registerAuto({
|
|
47
|
+
clientId: `id-${name}`, name, type: 'sw', labels: [], config: {}, ...(configFile ? { configFile } : {})
|
|
48
|
+
}).resourceId;
|
|
49
|
+
|
|
50
|
+
test('Groups tab: in effect at once, sent to the Client as its next config revision', async (t) => {
|
|
51
|
+
const { base, services, admin, viewer } = await start(t),
|
|
52
|
+
nightly = services.groups.create({ name: 'nightly' }),
|
|
53
|
+
pr = services.groups.create({ name: 'pr-pool', comment: 'pull requests' }),
|
|
54
|
+
id = register(services, 'lab-sw-01'),
|
|
55
|
+
save = (cookie, groups) => fetch(`${base}/resources/${id}/groups`, {
|
|
56
|
+
method: 'POST', headers: { cookie }, body: new URLSearchParams([...groups.map((g) => ['groups', g]), ['returnTo', '/resources']]), redirect: 'manual'
|
|
57
|
+
}),
|
|
58
|
+
page = async () => (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text();
|
|
59
|
+
|
|
60
|
+
assert.equal((await save(viewer, [nightly.id])).status, 403);
|
|
61
|
+
assert.equal((await save(admin, [nightly.id, pr.id])).status, 302);
|
|
62
|
+
const r = services.registry.get(id);
|
|
63
|
+
assert.deepEqual(r.group_ids.sort(), [nightly.id, pr.id].sort()); // scheduling follows now
|
|
64
|
+
const pending = services.registry.pendingConfig(id);
|
|
65
|
+
assert.equal(pending.revision, 1);
|
|
66
|
+
assert.deepEqual(pending.file.groups.sort(), [nightly.id, pr.id].sort()); // the Client writes it to its file
|
|
67
|
+
assert.match(await page(), /Groups of lab-sw-01 saved: 2 group\(s\) — in effect now/);
|
|
68
|
+
|
|
69
|
+
// The tab, with every group as a checkbox; no Groups line on Details any more.
|
|
70
|
+
const html = await page();
|
|
71
|
+
assert.match(html, new RegExp(`name="groups" value="${pr.id}" checked`));
|
|
72
|
+
assert.match(html, /data-config-tab="groups"/);
|
|
73
|
+
assert.match(html, /not written to the Client yet/);
|
|
74
|
+
assert.doesNotMatch(html, /<dt class="col-sm-4">Groups<\/dt>/);
|
|
75
|
+
|
|
76
|
+
// None at all: allowed.
|
|
77
|
+
await save(admin, []);
|
|
78
|
+
assert.deepEqual(services.registry.get(id).group_ids, []);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('Groups tab: keeps other pending changes; refuses unknown groups and Clients too old for it', async (t) => {
|
|
82
|
+
const { services } = await start(t),
|
|
83
|
+
g = services.groups.create({ name: 'g' }),
|
|
84
|
+
id = register(services, 'lab-sw-02');
|
|
85
|
+
|
|
86
|
+
// An Import not applied yet: its fields go along with the groups.
|
|
87
|
+
services.registry.importClientConfig(id, { labels: ['board:x'] }, { by: 'admin' });
|
|
88
|
+
services.registry.setClientGroups(id, [g.id], { by: 'admin', knownGroupIds: [g.id] });
|
|
89
|
+
assert.deepEqual(services.registry.pendingConfig(id).file, { labels: ['board:x'], groups: [g.id] });
|
|
90
|
+
// A device save meanwhile doesn't drop them either.
|
|
91
|
+
services.registry.setClientConfig(id, {}, { by: 'admin' });
|
|
92
|
+
assert.deepEqual(services.registry.pendingConfig(id).file.groups, [g.id]);
|
|
93
|
+
|
|
94
|
+
assert.throws(() => services.registry.setClientGroups(id, ['no-such-group'], { knownGroupIds: [g.id] }), /Unknown group/);
|
|
95
|
+
const old = register(services, 'lab-old', { configFile: null });
|
|
96
|
+
assert.throws(() => services.registry.setClientGroups(old, [g.id], { knownGroupIds: [g.id] }), /too old to take its groups/);
|
|
97
|
+
});
|