@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.
- package/bin/thub-admin.js +23 -0
- package/config.json +4 -0
- package/package.json +2 -2
- package/public/js/actions.js +58 -0
- package/public/js/settings.js +66 -0
- package/src/api/agent.js +27 -15
- package/src/api/resource.js +5 -3
- package/src/config.js +17 -1
- 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 +9 -5
- package/src/login-guard.js +111 -0
- package/src/rate-limit.js +137 -0
- package/src/security.js +104 -0
- package/src/server.js +17 -3
- package/src/services/admin-users.js +32 -19
- package/src/services/heartbeat.js +23 -3
- package/src/services/job-artifacts.js +91 -0
- package/src/services/jobs.js +13 -2
- package/src/services/registry.js +34 -0
- package/src/services/settings.js +345 -0
- package/src/web/routes.js +157 -10
- package/test/admin-users.test.js +16 -16
- package/test/agent-visibility.test.js +85 -0
- 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 +1 -1
- package/test/rate-limit.test.js +106 -0
- package/test/security.test.js +107 -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 +1 -1
- package/views/help/_agent-setup.pug +4 -1
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_coordinator.pug +9 -1
- package/views/help/_env.pug +1 -0
- package/views/help/_troubleshooting.pug +3 -1
- package/views/jobs/list.pug +3 -3
- package/views/jobs/show.pug +36 -0
- 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/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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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) => {
|
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
|
});
|