@andrian.yablonskyy/thub-coordinator 1.0.39 → 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.
Files changed (45) hide show
  1. package/bin/thub-admin.js +23 -0
  2. package/config.json +4 -0
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/settings.js +66 -0
  6. package/src/api/agent.js +27 -15
  7. package/src/api/resource.js +5 -3
  8. package/src/config.js +17 -1
  9. package/src/db/migrations/024_add_settings.sql +10 -0
  10. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  11. package/src/dev/virtual.js +9 -5
  12. package/src/login-guard.js +111 -0
  13. package/src/rate-limit.js +137 -0
  14. package/src/security.js +104 -0
  15. package/src/server.js +17 -3
  16. package/src/services/admin-users.js +32 -19
  17. package/src/services/heartbeat.js +23 -3
  18. package/src/services/job-artifacts.js +91 -0
  19. package/src/services/jobs.js +13 -2
  20. package/src/services/registry.js +34 -0
  21. package/src/services/settings.js +345 -0
  22. package/src/web/routes.js +157 -10
  23. package/test/admin-users.test.js +16 -16
  24. package/test/agent-visibility.test.js +85 -0
  25. package/test/groups-tab.test.js +97 -0
  26. package/test/job-artifacts.test.js +115 -0
  27. package/test/login-guard.test.js +139 -0
  28. package/test/logs.test.js +1 -1
  29. package/test/rate-limit.test.js +106 -0
  30. package/test/security.test.js +107 -0
  31. package/test/settings.test.js +206 -0
  32. package/views/admin/settings.pug +133 -0
  33. package/views/groups/list.pug +1 -1
  34. package/views/help/_agent-cli.pug +1 -1
  35. package/views/help/_agent-setup.pug +4 -1
  36. package/views/help/_client-setup.pug +1 -1
  37. package/views/help/_coordinator.pug +9 -1
  38. package/views/help/_env.pug +1 -0
  39. package/views/help/_troubleshooting.pug +3 -1
  40. package/views/jobs/list.pug +3 -3
  41. package/views/jobs/show.pug +36 -0
  42. package/views/layout.pug +4 -1
  43. package/views/mixins/client-config.pug +65 -3
  44. package/views/mixins/list-controls.pug +1 -1
  45. package/views/mixins/resource.pug +1 -8
@@ -0,0 +1,345 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/settings.js
3
+ * @description Coordinator settings editable from the dashboard (/admin/settings): stored in the database, layered over the config file
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 { JOB_RETENTION } = require('./cleanup');
17
+
18
+ // README §13.2. Precedence, lowest first: built-in default < config file <
19
+ // dashboard setting (the `settings` table) < environment variable. The
20
+ // dashboard can't write the config file — the systemd unit only lets the
21
+ // service write its data directory — so its changes live in the database.
22
+ //
23
+ // `applies`: "now" — the code reads it on every use, so saving takes effect
24
+ // at once; "restart" — read once at startup (timers, middleware), so it
25
+ // takes effect when the Coordinator restarts. Settings that could cut the
26
+ // dashboard off from the inside (listen, dataDir, sessionSecret) stay in
27
+ // the config file.
28
+ const int = (min, max) => (raw) => {
29
+ const n = Number(raw);
30
+ if (!Number.isInteger(n) || n < min || n > max){
31
+ throw new Error(`must be a whole number from ${min} to ${max}`);
32
+ }
33
+ return n;
34
+ },
35
+ bool = (raw) => raw === true || raw === 'true' || raw === 'on' || raw === '1',
36
+ oneOf = (values) => (raw) => {
37
+ const v = values.find((x) => String(x) === String(raw));
38
+ if (v === undefined){
39
+ throw new Error(`must be one of: ${values.join(', ')}`);
40
+ }
41
+ return v;
42
+ },
43
+
44
+ SETTINGS = [
45
+ {
46
+ key: 'publicUrl', group: 'General', label: 'Public URL', applies: 'now', env: 'THUB_PUBLIC_URL', type: 'text',
47
+ help: 'The https:// address Agents, Clients and browsers use. Job links and the dashboard\'s same-origin check use it.',
48
+ parse: (raw) => {
49
+ const s = String(raw).trim().replace(/\/+$/, '');
50
+ let url;
51
+ try {
52
+ url = new URL(s);
53
+ }
54
+ catch {
55
+ throw new Error('must be a URL like https://thub.example.com');
56
+ }
57
+ if (!/^https?:$/.test(url.protocol) || url.pathname !== '/' || url.search || url.hash){
58
+ throw new Error('must be just http(s)://host[:port], without a path');
59
+ }
60
+ return s;
61
+ }
62
+ },
63
+ {
64
+ key: 'clientJoinKey', group: 'General', label: 'Client join key', applies: 'now', env: 'THUB_CLIENT_JOIN_KEY', type: 'secret',
65
+ help: 'The shared secret Clients self-register with (joinKey in their config). Empty turns self-registration off. ' +
66
+ 'Running Clients keep working; one with an old key can\'t register again after its next restart.',
67
+ parse: (raw) => {
68
+ const s = String(raw ?? '').trim();
69
+ if (s && s.length < 16){
70
+ throw new Error('must be at least 16 characters (or empty to turn registration off)');
71
+ }
72
+ return s || null;
73
+ }
74
+ },
75
+ {
76
+ key: 'trustProxy', group: 'General', label: 'Trusted proxies', applies: 'restart', type: 'text',
77
+ help: 'Whose X-Forwarded-For/-Proto to believe: loopback (a proxy on this host), a CIDR such as 10.0.0.0/8, ' +
78
+ 'a number of hops, or false. Wrong values break HTTPS detection and sign-in behind a proxy.',
79
+ parse: (raw) => {
80
+ const s = String(raw).trim();
81
+ if (s === 'false' || s === 'true'){
82
+ return s === 'true';
83
+ }
84
+ if (/^\d+$/.test(s)){
85
+ return Number(s);
86
+ }
87
+ if (!/^[\w.:/,\s-]+$/.test(s) || !s){
88
+ throw new Error('must be loopback, uniquelocal, linklocal, IP addresses/CIDRs (comma-separated), a hop count, true or false');
89
+ }
90
+ return s;
91
+ }
92
+ },
93
+ {
94
+ key: 'session.secureCookie', group: 'General', label: 'Secure session cookie', applies: 'restart', type: 'select',
95
+ options: ['auto', 'true', 'false'],
96
+ help: 'auto: HTTPS-only whenever the public URL is https://. true/false force it.',
97
+ parse: (raw) => ({ auto: 'auto', true: true, false: false })[String(raw)] ?? oneOf(['auto', 'true', 'false'])(raw)
98
+ },
99
+ {
100
+ key: 'heartbeat.intervalSec', group: 'Heartbeat', label: 'Heartbeat interval (s)', applies: 'now', type: 'number',
101
+ help: 'How often Clients report. They switch to a new value with their next heartbeat (lowering it, the old one is ' +
102
+ 'still allowed for a while). Clients older than this setting keep their own heartbeatIntervalSec (10 by default): ' +
103
+ 'don\'t go below that while any are connected.',
104
+ parse: int(2, 300)
105
+ },
106
+ {
107
+ key: 'heartbeat.missedLimit', group: 'Heartbeat', label: 'Missed heartbeats allowed', applies: 'now', type: 'number',
108
+ help: 'A Client that misses this many in a row goes OUT_OF_SERVICE, and its running job becomes LOST.',
109
+ parse: int(2, 20)
110
+ },
111
+ {
112
+ key: 'heartbeat.sweepIntervalSec', group: 'Heartbeat', label: 'Check every (s)', applies: 'restart', type: 'number',
113
+ help: 'How often missed heartbeats, unaccepted assignments and job timeouts are checked.',
114
+ parse: int(1, 60)
115
+ },
116
+ {
117
+ key: 'rateLimit.requestsPerMinute', group: 'Rate limits', label: 'Requests per minute', applies: 'now', type: 'number',
118
+ help: 'Per caller: an agent or resource token, a dashboard user, else the IP address. Short bursts are fine; ' +
119
+ 'past that, requests get 429 with Retry-After. A Client streaming logs makes up to ~240 a minute. 0: no limit.',
120
+ parse: int(0, 1_000_000)
121
+ },
122
+ {
123
+ key: 'rateLimit.loginPerMinute', group: 'Rate limits', label: 'Sign-in attempts per minute', applies: 'now', type: 'number',
124
+ help: 'Per IP address, against password guessing. 0: no limit.',
125
+ parse: int(0, 10_000)
126
+ },
127
+ {
128
+ key: 'jobs.defaultTimeoutSec', group: 'Jobs', label: 'Default job timeout (s)', applies: 'now', type: 'number',
129
+ help: 'For a job submitted without --timeout.', parse: int(60, 7 * 86400)
130
+ },
131
+ {
132
+ key: 'jobs.maxTimeoutSec', group: 'Jobs', label: 'Maximum job timeout (s)', applies: 'now', type: 'number',
133
+ help: 'A longer --timeout is cut to this.', parse: int(60, 7 * 86400)
134
+ },
135
+ {
136
+ key: 'scheduler.assignAckTimeoutSec', group: 'Scheduler', label: 'Accept timeout (s)', applies: 'now', type: 'number',
137
+ help: 'How long an assigned job waits for its Client to accept it before going back to the queue.', parse: int(5, 600)
138
+ },
139
+ {
140
+ key: 'scheduler.requeueOnLost', group: 'Scheduler', label: 'Retry lost jobs', applies: 'now', type: 'checkbox',
141
+ help: 'Requeue a job once when its Client goes offline mid-job.', parse: bool
142
+ },
143
+ {
144
+ key: 'scheduler.maxQueuedPerAgent', group: 'Scheduler', label: 'Max queued jobs per agent', applies: 'now', type: 'number',
145
+ help: 'Further submissions from that agent are refused until some run.', parse: int(1, 10000)
146
+ },
147
+ {
148
+ key: 'scheduler.tickIntervalSec', group: 'Scheduler', label: 'Safety-net pass (s)', applies: 'restart', type: 'number',
149
+ help: 'The scheduler also runs on every queued job and free Client; this periodic pass only catches anything missed.',
150
+ parse: int(1, 3600)
151
+ },
152
+ {
153
+ key: 'retention.jobRetention', group: 'Retention', label: 'Keep finished jobs', applies: 'now', type: 'select',
154
+ options: Object.keys(JOB_RETENTION), labels: Object.fromEntries(Object.entries(JOB_RETENTION).map(([k, v]) => [k, v.label])),
155
+ help: 'Older finished jobs, with their logs, are deleted hourly. forever: only by Clean up database.',
156
+ parse: oneOf(Object.keys(JOB_RETENTION))
157
+ },
158
+ {
159
+ key: 'updates.checkIntervalMin', group: 'Updates', label: 'Version check every (min)', applies: 'restart', type: 'number',
160
+ help: 'How often to look for new Coordinator/Agent/Client releases. 0 turns the periodic check off.', parse: int(0, 1440)
161
+ },
162
+ {
163
+ key: 'updates.registry', group: 'Updates', label: 'npm registry', applies: 'restart', type: 'text',
164
+ help: 'Empty: the public npm registry (or npm_config_registry). Set it for a private mirror.',
165
+ parse: (raw) => {
166
+ const s = String(raw ?? '').trim();
167
+ if (s && !/^https?:\/\/\S+$/.test(s)){
168
+ throw new Error('must be an http(s):// URL, or empty');
169
+ }
170
+ return s || null;
171
+ }
172
+ }
173
+ ],
174
+ BY_KEY = new Map(SETTINGS.map((s) => [s.key, s])),
175
+
176
+ getPath = (obj, key) => key.split('.').reduce((o, k) => o?.[k], obj),
177
+ setPath = (obj, key, value) => {
178
+ const parts = key.split('.'),
179
+ last = parts.pop(),
180
+ parent = parts.reduce((o, k) => (o[k] ??= {}), obj);
181
+ parent[last] = value;
182
+ },
183
+ same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
184
+
185
+ // `config` is the live object every service holds: "now" settings are
186
+ // written into it, so they take effect on the next use.
187
+ function createSettingsService(db, { config, env = process.env }){
188
+ const stmts = {
189
+ all: db.prepare('SELECT key, value, updated_at, updated_by FROM settings'),
190
+ upsert: db.prepare(
191
+ `INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?)
192
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`
193
+ ),
194
+ remove: db.prepare('DELETE FROM settings WHERE key = ?')
195
+ },
196
+ // What the config file (plus defaults) said, before any dashboard
197
+ // setting: what "Reset" goes back to.
198
+ fileValues = Object.fromEntries(SETTINGS.map((s) => [s.key, structuredClone(getPath(config, s.key))]));
199
+ let running = null; // the values this process started with (for "after restart")
200
+
201
+ function stored(){
202
+ const rows = {};
203
+ for (const r of stmts.all.all()){
204
+ if (BY_KEY.has(r.key)){
205
+ rows[r.key] = { value: JSON.parse(r.value), updatedAt: r.updated_at, updatedBy: r.updated_by };
206
+ }
207
+ }
208
+ return rows;
209
+ }
210
+
211
+ const envLocked = (s) => Boolean(s.env && env[s.env]);
212
+
213
+ // What each setting resolves to right now: env > dashboard > file.
214
+ function desired(rows = stored()){
215
+ return Object.fromEntries(SETTINGS.map((s) => [
216
+ s.key,
217
+ envLocked(s) ? getPath(config, s.key) : s.key in rows ? rows[s.key].value : fileValues[s.key]
218
+ ]));
219
+ }
220
+
221
+ // At startup: put the dashboard's settings into the config every service
222
+ // reads. Then remember what this process runs with.
223
+ function applyAtStartup(){
224
+ const rows = stored();
225
+ // A value saved by an older version that this one no longer accepts is
226
+ // skipped (with a warning), not allowed to stop the Coordinator.
227
+ for (const [key, { value }]of Object.entries(rows)){
228
+ try {
229
+ BY_KEY.get(key).parse(value === null ? '' : value);
230
+ }
231
+ catch (err){
232
+ console.warn(`Ignoring dashboard setting ${key}=${JSON.stringify(value)}: ${err.message}`);
233
+ delete rows[key];
234
+ }
235
+ }
236
+ const want = desired(rows);
237
+ for (const s of SETTINGS){
238
+ setPath(config, s.key, structuredClone(want[s.key]));
239
+ }
240
+ running = structuredClone(want);
241
+ return service;
242
+ }
243
+
244
+ // For the settings page.
245
+ function list(){
246
+ const rows = stored(),
247
+ want = desired(rows);
248
+ return SETTINGS.map((s) => ({
249
+ ...s,
250
+ value: want[s.key],
251
+ fileValue: fileValues[s.key],
252
+ source: envLocked(s) ? 'env' : s.key in rows ? 'dashboard' : 'file',
253
+ updatedAt: rows[s.key]?.updatedAt,
254
+ updatedBy: rows[s.key]?.updatedBy,
255
+ pendingRestart: s.applies === 'restart' && !same(want[s.key], running?.[s.key])
256
+ }));
257
+ }
258
+
259
+ // `changes`: { key: rawValue } for the settings to set, `reset`: keys to
260
+ // put back to the config file's value. Validates everything first, so a
261
+ // bad value saves nothing. Returns what changed.
262
+ function save({ changes = {}, reset = [] }, { by, guard } = {}){
263
+ const rows = stored(),
264
+ before = desired(rows),
265
+ parsed = {},
266
+ errors = [];
267
+ for (const [key, raw]of Object.entries(changes)){
268
+ const s = BY_KEY.get(key);
269
+ if (!s || envLocked(s) || reset.includes(key)){
270
+ continue;
271
+ }
272
+ try {
273
+ parsed[key] = s.parse(raw);
274
+ }
275
+ catch (err){
276
+ errors.push(`${s.label}: ${err.message}`);
277
+ }
278
+ }
279
+ const after = { ...before, ...parsed };
280
+ for (const key of reset){
281
+ if (BY_KEY.has(key) && !envLocked(BY_KEY.get(key))){
282
+ after[key] = fileValues[key];
283
+ }
284
+ }
285
+ if (after['jobs.defaultTimeoutSec'] > after['jobs.maxTimeoutSec']){
286
+ errors.push('Default job timeout: can\'t be longer than the maximum job timeout');
287
+ }
288
+ if (!errors.length && guard){
289
+ const problem = guard(after);
290
+ if (problem){
291
+ errors.push(problem);
292
+ }
293
+ }
294
+ if (errors.length){
295
+ throw Object.assign(new Error(errors.join('\n')), { status: 400 });
296
+ }
297
+
298
+ const now = new Date().toISOString(),
299
+ changed = [];
300
+ db.transaction(() => {
301
+ for (const s of SETTINGS){
302
+ if (envLocked(s)){
303
+ continue;
304
+ }
305
+ if (reset.includes(s.key) && s.key in rows){
306
+ stmts.remove.run(s.key);
307
+ }
308
+ else if (s.key in parsed && !same(parsed[s.key], before[s.key])){
309
+ stmts.upsert.run(s.key, JSON.stringify(parsed[s.key]), now, by || null);
310
+ }
311
+ else {
312
+ continue;
313
+ }
314
+ if (!same(after[s.key], before[s.key])){
315
+ changed.push(s);
316
+ }
317
+ }
318
+ })();
319
+ for (const s of changed){
320
+ if (s.applies === 'now'){
321
+ setPath(config, s.key, structuredClone(after[s.key]));
322
+ }
323
+ }
324
+ return {
325
+ changed: changed.map((s) => s.key),
326
+ now: changed.filter((s) => s.applies === 'now').map((s) => s.key),
327
+ restart: changed.filter((s) => s.applies === 'restart').map((s) => s.key)
328
+ };
329
+ }
330
+
331
+ // thub-admin settings reset — the way back if a setting locked you out:
332
+ // the named settings (all when none) go back to the config file's value
333
+ // at the next start.
334
+ function reset(keys = []){
335
+ if (!keys.length){
336
+ return db.prepare('DELETE FROM settings').run().changes;
337
+ }
338
+ return keys.reduce((n, k) => n + stmts.remove.run(k).changes, 0);
339
+ }
340
+
341
+ const service = { list, save, reset, applyAtStartup, SETTINGS };
342
+ return service;
343
+ }
344
+
345
+ module.exports = { createSettingsService, SETTINGS };
package/src/web/routes.js CHANGED
@@ -27,10 +27,14 @@ const fs = require('node:fs'),
27
27
  } = require('@andrian.yablonskyy/thub-common'),
28
28
  { retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
29
29
  listPrefs = require('../services/list-prefs'),
30
- search = require('../services/search');
31
-
32
- // Job log viewer: lines per page it loads (and the most one request may ask for).
33
- const LOG_PAGE = 1000,
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,
34
38
  LOG_PAGE_MAX = 5000,
35
39
 
36
40
  // Resources page sort keys (list-prefs.js) -> comparators. Empty values
@@ -70,7 +74,8 @@ const LOG_PAGE = 1000,
70
74
  // views hitting the same kind of SSE stream the Agent uses (§6.4), just
71
75
  // authenticated by session cookie instead of a bearer token.
72
76
  function createWebRouter({ services, config }){
73
- const router = express.Router();
77
+ const router = express.Router(),
78
+ loginGuard = createLoginGuard();
74
79
 
75
80
  // Shown as a toast (layout.pug): `danger` stays until closed, `warning`
76
81
  // closes after 30 s, `info`/`success` after 10 s — unless `sticky`.
@@ -170,6 +175,8 @@ function createWebRouter({ services, config }){
170
175
  res.locals.isNewer = isNewer;
171
176
  res.locals.nextCronRun = nextCronRun;
172
177
  res.locals.formatDateTime = formatDateTime;
178
+ // 1233 -> "1.2 KB" (job artifacts).
179
+ res.locals.fmtBytes = formatBytes;
173
180
  res.locals.currentPath = req.originalUrl;
174
181
  // A live-update re-fetch (server.js, req.thubPassive) renders the page
175
182
  // only for its data: flash messages stay for the next real page view.
@@ -232,16 +239,35 @@ function createWebRouter({ services, config }){
232
239
  res.render('login', { title: 'Sign in', ...(problem ? { messages: [problem] } : {}) });
233
240
  });
234
241
 
235
- router.post('/login', (req, res) => {
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) => {
236
255
  const problem = httpsProblem(req);
237
256
  if (problem){
238
257
  return res.status(400).render('login', { title: 'Sign in', messages: [problem] });
239
258
  }
240
259
  const { username, password } = req.body,
241
- user = services.adminUsers.verify(username, password);
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));
242
266
  if (!user){
267
+ loginGuard.failure(req.ip, username);
243
268
  return res.status(401).render('login', { title: 'Sign in', messages: [{ type: 'danger', text: 'Invalid credentials' }] });
244
269
  }
270
+ loginGuard.success(req.ip, username);
245
271
  req.session.user = user;
246
272
  req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
247
273
  res.redirect('/');
@@ -333,17 +359,29 @@ function createWebRouter({ services, config }){
333
359
  // profile form since it needs the *current* password re-entered, and a
334
360
  // mismatch between newPassword/confirmPassword is a form-level check
335
361
  // that has nothing to do with the other fields.
336
- router.post('/profile/password', (req, res) => {
337
- const { currentPassword, newPassword, confirmPassword } = req.body;
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;
338
367
  if (newPassword !== confirmPassword){
339
368
  flash(req, 'danger', 'New password and confirmation do not match.');
340
369
  return res.redirect('/profile');
341
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
+ }
342
376
  try {
343
- 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);
344
379
  flash(req, 'success', 'Password changed.');
345
380
  }
346
381
  catch (err){
382
+ if (err.status === 401){
383
+ loginGuard.failure(req.ip, username);
384
+ }
347
385
  flash(req, 'danger', err.message);
348
386
  }
349
387
  res.redirect('/profile');
@@ -589,6 +627,24 @@ function createWebRouter({ services, config }){
589
627
  res.redirect(returnTo(req, '/resources'));
590
628
  });
591
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
+
592
648
  // Export (resource card, admins — the file holds the join key): the
593
649
  // Client's config file as JSON, secrets excluded.
594
650
  router.get('/resources/:id/config/export', requireAdminRole, (req, res) => {
@@ -989,6 +1045,97 @@ function createWebRouter({ services, config }){
989
1045
  });
990
1046
  }
991
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
+
992
1139
  router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
993
1140
 
994
1141
  router.post('/admin/agents', requireAdminRole, (req, res) => {
@@ -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.throws(() => adminUsers.changePassword(id, 'wrongpass', 'newpass'), (err) => err.status === 401);
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.throws(() => adminUsers.changePassword(id, 'oldpass', ''), (err) => err.status === 400);
126
+ await assert.rejects(adminUsers.changePassword(id, 'oldpass', ''), (err) => err.status === 400);
127
127
  });