@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/security.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/security.js
|
|
3
|
+
* @description HTTP security headers for every response, and the same-origin check on the dashboard's state-changing requests (README §12)
|
|
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
|
+
// Everything the dashboard loads: its own files, plus Bootstrap and its
|
|
17
|
+
// icon font from jsDelivr (layout.pug). No inline script at all — the
|
|
18
|
+
// templates use data-* attributes (public/js/actions.js) — while inline
|
|
19
|
+
// style="" attributes, used throughout the templates, stay allowed.
|
|
20
|
+
const CDN = 'https://cdn.jsdelivr.net',
|
|
21
|
+
CONTENT_SECURITY_POLICY = [
|
|
22
|
+
'default-src \'self\'',
|
|
23
|
+
`script-src 'self' ${CDN}`,
|
|
24
|
+
`style-src 'self' ${CDN} 'unsafe-inline'`,
|
|
25
|
+
`font-src 'self' ${CDN}`,
|
|
26
|
+
// data: — Bootstrap's CSS draws some icons (select arrow, close button)
|
|
27
|
+
// as inline SVG images.
|
|
28
|
+
'img-src \'self\' data:',
|
|
29
|
+
'connect-src \'self\'',
|
|
30
|
+
'object-src \'none\'',
|
|
31
|
+
'base-uri \'self\'',
|
|
32
|
+
'form-action \'self\'',
|
|
33
|
+
'frame-ancestors \'none\''
|
|
34
|
+
].join('; '),
|
|
35
|
+
// 180 days, this host only: HTTPS is how a TLS-fronted Coordinator is
|
|
36
|
+
// reached; other hosts under the same domain are none of its business.
|
|
37
|
+
HSTS = 'max-age=15552000',
|
|
38
|
+
UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
39
|
+
|
|
40
|
+
// Sent on every response (dashboard, API, static files).
|
|
41
|
+
function securityHeaders(req, res, next){
|
|
42
|
+
res.set({
|
|
43
|
+
'Content-Security-Policy': CONTENT_SECURITY_POLICY,
|
|
44
|
+
// Also frame-ancestors above; this one for browsers that predate it.
|
|
45
|
+
'X-Frame-Options': 'DENY',
|
|
46
|
+
'X-Content-Type-Options': 'nosniff',
|
|
47
|
+
// Full URLs (job ids, searches) only within the dashboard itself.
|
|
48
|
+
'Referrer-Policy': 'same-origin',
|
|
49
|
+
'Cross-Origin-Opener-Policy': 'same-origin'
|
|
50
|
+
});
|
|
51
|
+
// Only on HTTPS (behind a TLS proxy: X-Forwarded-Proto from a trusted
|
|
52
|
+
// proxy, trustProxy) — over plain HTTP a browser ignores it anyway.
|
|
53
|
+
if (req.secure){
|
|
54
|
+
res.set('Strict-Transport-Security', HSTS);
|
|
55
|
+
}
|
|
56
|
+
next();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function originOf(url){
|
|
60
|
+
try {
|
|
61
|
+
return new URL(url).origin;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// CSRF defence for the session-authenticated routes (the dashboard, and
|
|
69
|
+
// /api/v1/admin), on top of the session cookie's SameSite=Lax: a request
|
|
70
|
+
// that changes something must come from this dashboard's own pages. The
|
|
71
|
+
// browser says where from in Origin (every modern browser sends it on a
|
|
72
|
+
// POST), else Referer; a request carrying neither isn't from a browser page
|
|
73
|
+
// (curl, a script) and couldn't have been forged by one, so it passes. The
|
|
74
|
+
// Agent/Client APIs use bearer tokens, which a browser never attaches by
|
|
75
|
+
// itself, and don't go through here.
|
|
76
|
+
function sameOriginOnly(config){
|
|
77
|
+
return (req, res, next) => {
|
|
78
|
+
if (!UNSAFE_METHODS.has(req.method)){
|
|
79
|
+
return next();
|
|
80
|
+
}
|
|
81
|
+
const origin = req.get('origin'),
|
|
82
|
+
from = origin !== undefined ? origin : req.get('referer') !== undefined ? originOf(req.get('referer')) : undefined;
|
|
83
|
+
if (from === undefined){
|
|
84
|
+
return next();
|
|
85
|
+
}
|
|
86
|
+
// The address the browser used (Host, as the reverse proxy passes it),
|
|
87
|
+
// or the configured publicUrl (a proxy that rewrites Host) — read on
|
|
88
|
+
// every request, since the dashboard can change it (§13.2).
|
|
89
|
+
const configured = originOf(config.publicUrl),
|
|
90
|
+
allowed = [configured, `${req.protocol}://${req.get('host')}`];
|
|
91
|
+
if (from && allowed.includes(from)){
|
|
92
|
+
return next();
|
|
93
|
+
}
|
|
94
|
+
const text = `Blocked: this request came from ${from || 'an unknown page'}, not from this dashboard (${configured}). ` +
|
|
95
|
+
'Changes can only be made from the dashboard\'s own pages. If you opened the dashboard at a different address, ' +
|
|
96
|
+
'set publicUrl to that address.';
|
|
97
|
+
if (req.originalUrl.startsWith('/api/')){ // req.path is relative to the router's mount point
|
|
98
|
+
return res.status(403).json({ error: text });
|
|
99
|
+
}
|
|
100
|
+
res.status(403).type('text/plain').send(text);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { securityHeaders, sameOriginOnly, CONTENT_SECURITY_POLICY };
|
package/src/server.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const fs = require('node:fs'),
|
|
19
|
+
path = require('node:path'),
|
|
19
20
|
express = require('express'),
|
|
20
21
|
session = require('express-session'),
|
|
21
22
|
|
|
@@ -31,21 +32,38 @@ const path = require('node:path'),
|
|
|
31
32
|
{ createScheduler } = require('./services/scheduler'),
|
|
32
33
|
{ createHeartbeatMonitor } = require('./services/heartbeat'),
|
|
33
34
|
{ createLogsService } = require('./services/logs'),
|
|
34
|
-
{ createArtifactsService } = require('./services/artifacts'),
|
|
35
35
|
{ createCommandsService } = require('./services/commands'),
|
|
36
36
|
{ createRetentionService } = require('./services/retention'),
|
|
37
37
|
{ createCleanupService } = require('./services/cleanup'),
|
|
38
38
|
{ startDevMode } = require('./dev/virtual'),
|
|
39
39
|
{ createUpdatesService } = require('./services/updates'),
|
|
40
40
|
{ createLiveService } = require('./services/live'),
|
|
41
|
+
{ SqliteSessionStore } = require('./services/session-store'),
|
|
42
|
+
{ securityHeaders, sameOriginOnly } = require('./security'),
|
|
43
|
+
{ createSettingsService } = require('./services/settings'),
|
|
44
|
+
{ createRateLimiter } = require('./rate-limit'),
|
|
41
45
|
|
|
42
46
|
{ createAgentRouter } = require('./api/agent'),
|
|
43
47
|
{ createResourceRouter } = require('./api/resource'),
|
|
44
48
|
{ createAdminRouter } = require('./api/admin'),
|
|
45
49
|
{ createWebRouter } = require('./web/routes');
|
|
46
50
|
|
|
51
|
+
// Artifacts are no longer stored on the Coordinator (README §9): an
|
|
52
|
+
// upgraded one frees the space the old <dataDir>/artifacts took, once.
|
|
53
|
+
function removeLegacyArtifacts(config){
|
|
54
|
+
const dir = path.join(config.dataDir, 'artifacts');
|
|
55
|
+
if (fs.existsSync(dir)){
|
|
56
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
console.log(`Removed ${dir}: the Coordinator no longer stores job artifacts`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
function buildServices(config){
|
|
62
|
+
removeLegacyArtifacts(config);
|
|
48
63
|
const db = openDb(config.dbPath),
|
|
64
|
+
// First: dashboard settings (README §13.2) go into `config` before any
|
|
65
|
+
// service reads it — some read theirs only once, at creation.
|
|
66
|
+
settings = createSettingsService(db, { config }).applyAtStartup(),
|
|
49
67
|
events = createEventsService(db),
|
|
50
68
|
registry = createRegistryService(db, { bus, events }),
|
|
51
69
|
agents = createAgentsService(db, { events }),
|
|
@@ -53,14 +71,14 @@ function buildServices(config){
|
|
|
53
71
|
adminUsers = createAdminUsersService(db),
|
|
54
72
|
commands = createCommandsService({ bus }),
|
|
55
73
|
logs = createLogsService(db, { bus }),
|
|
56
|
-
|
|
57
|
-
jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
|
|
74
|
+
jobs = createJobsService(db, { bus, events, registry, config }),
|
|
58
75
|
scheduler = createScheduler(db, { bus, events, registry, config }),
|
|
59
76
|
heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config }),
|
|
60
|
-
retention = createRetentionService(db, {
|
|
77
|
+
retention = createRetentionService(db, { config }),
|
|
61
78
|
cleanup = createCleanupService(db, { jobs, config }),
|
|
62
79
|
updates = createUpdatesService({ config }),
|
|
63
|
-
live = createLiveService(db, { bus, updates })
|
|
80
|
+
live = createLiveService(db, { bus, updates }),
|
|
81
|
+
sessionStore = new SqliteSessionStore(db);
|
|
64
82
|
|
|
65
83
|
jobs.reconcileOnStartup();
|
|
66
84
|
updates.start();
|
|
@@ -83,13 +101,14 @@ function buildServices(config){
|
|
|
83
101
|
commands,
|
|
84
102
|
jobs,
|
|
85
103
|
logs,
|
|
86
|
-
artifacts,
|
|
87
104
|
scheduler,
|
|
88
105
|
heartbeatMonitor,
|
|
89
106
|
retention,
|
|
90
107
|
cleanup,
|
|
91
108
|
updates,
|
|
92
|
-
live
|
|
109
|
+
live,
|
|
110
|
+
sessionStore,
|
|
111
|
+
settings
|
|
93
112
|
};
|
|
94
113
|
}
|
|
95
114
|
|
|
@@ -101,11 +120,26 @@ function commonVersion(){
|
|
|
101
120
|
return require('@andrian.yablonskyy/thub-common/package.json').version;
|
|
102
121
|
}
|
|
103
122
|
|
|
123
|
+
// The session cookie's Secure flag (config session.secureCookie, §12).
|
|
124
|
+
// "auto": always Secure for an https:// publicUrl — the cookie must never
|
|
125
|
+
// travel over plain HTTP there — else express-session's per-request 'auto'
|
|
126
|
+
// (Secure on HTTPS connections), so plain-HTTP development still works.
|
|
127
|
+
function cookieSecure(config){
|
|
128
|
+
const setting = config.session?.secureCookie ?? 'auto';
|
|
129
|
+
if (setting === true || setting === false){
|
|
130
|
+
return setting;
|
|
131
|
+
}
|
|
132
|
+
return /^https:\/\//i.test(config.publicUrl || '') ? true : 'auto';
|
|
133
|
+
}
|
|
134
|
+
|
|
104
135
|
function createApp(config, services){
|
|
105
136
|
const app = express();
|
|
106
137
|
app.set('view engine', 'pug');
|
|
107
138
|
app.set('views', path.join(__dirname, '..', 'views'));
|
|
108
139
|
app.set('trust proxy', config.trustProxy);
|
|
140
|
+
app.disable('x-powered-by');
|
|
141
|
+
// CSP, framing, nosniff, Referrer-Policy, HSTS on HTTPS (security.js, §12).
|
|
142
|
+
app.use(securityHeaders);
|
|
109
143
|
app.locals.services = services;
|
|
110
144
|
// Shown in every dashboard page's footer (layout.pug).
|
|
111
145
|
app.locals.coordinatorVersion = require('../package.json').version;
|
|
@@ -119,6 +153,9 @@ function createApp(config, services){
|
|
|
119
153
|
app.use(
|
|
120
154
|
session({
|
|
121
155
|
secret: config.sessionSecret,
|
|
156
|
+
// In the SQLite database (services/session-store.js), not in memory:
|
|
157
|
+
// survives restarts, and expired sessions are pruned.
|
|
158
|
+
store: services.sessionStore,
|
|
122
159
|
resave: false,
|
|
123
160
|
saveUninitialized: false,
|
|
124
161
|
// Idle timeout, not a fixed one (§10.1): `rolling` re-sends a fresh
|
|
@@ -127,7 +164,7 @@ function createApp(config, services){
|
|
|
127
164
|
// (routes.js) after their *last* request. Each user's own preference
|
|
128
165
|
// is applied per-request in web/routes.js, since it isn't known here.
|
|
129
166
|
rolling: true,
|
|
130
|
-
cookie: { httpOnly: true, sameSite: 'lax' }
|
|
167
|
+
cookie: { httpOnly: true, sameSite: 'lax', secure: cookieSecure(config) }
|
|
131
168
|
})
|
|
132
169
|
);
|
|
133
170
|
// Background requests of the dashboard's live updates (public/js/live.js):
|
|
@@ -147,23 +184,16 @@ function createApp(config, services){
|
|
|
147
184
|
});
|
|
148
185
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
149
186
|
app.use('/avatars', express.static(config.avatarsDir));
|
|
187
|
+
// Everything below (API, dashboard, sign-in) — static files above aren't
|
|
188
|
+
// counted. Per credential, else per IP (rate-limit.js, §12).
|
|
189
|
+
app.use(createRateLimiter(config, services));
|
|
150
190
|
|
|
151
191
|
app.use('/api/v1', createAgentRouter({ services, config }));
|
|
152
192
|
app.use('/api/v1', createResourceRouter({ services, config }));
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// HMAC-signed artifact downloads (§3.1, §12) — no session or bearer token.
|
|
156
|
-
app.get('/artifacts/download/:token', (req, res, next) => {
|
|
157
|
-
try {
|
|
158
|
-
const artifact = services.artifacts.verify(req.params.token);
|
|
159
|
-
res.download(artifact.path, artifact.name);
|
|
160
|
-
}
|
|
161
|
-
catch (err){
|
|
162
|
-
next(err);
|
|
163
|
-
}
|
|
164
|
-
});
|
|
193
|
+
// Session-authenticated, like the dashboard: same-origin requests only.
|
|
194
|
+
app.use('/api/v1/admin', sameOriginOnly(config), createAdminRouter({ services }));
|
|
165
195
|
|
|
166
|
-
app.use('/', createWebRouter({ services, config }));
|
|
196
|
+
app.use('/', sameOriginOnly(config), createWebRouter({ services, config }));
|
|
167
197
|
|
|
168
198
|
app.use((err, req, res, next) => {
|
|
169
199
|
const status = err.status || 500;
|
|
@@ -225,4 +255,4 @@ if (require.main === module){
|
|
|
225
255
|
start();
|
|
226
256
|
}
|
|
227
257
|
|
|
228
|
-
module.exports = { start, buildServices, createApp };
|
|
258
|
+
module.exports = { start, buildServices, createApp, cookieSecure };
|
|
@@ -14,25 +14,42 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const crypto = require('node:crypto'),
|
|
17
|
+
{ promisify } = require('node:util'),
|
|
17
18
|
{ v4: uuid } = require('uuid'),
|
|
18
19
|
{ normalize: normalizeListPrefs } = require('./list-prefs');
|
|
19
20
|
|
|
21
|
+
// Stored as "<salt hex>:<scrypt hex>". The synchronous one is only for the
|
|
22
|
+
// CLI and startup (thub-admin create-admin, the bootstrap password); every
|
|
23
|
+
// request uses the async versions, which run scrypt on libuv's thread pool —
|
|
24
|
+
// scryptSync would stall every request for each sign-in attempt.
|
|
25
|
+
const scrypt = promisify(crypto.scrypt);
|
|
26
|
+
|
|
20
27
|
function hashPassword(password){
|
|
21
28
|
const salt = crypto.randomBytes(16).toString('hex'),
|
|
22
29
|
derived = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
23
30
|
return `${salt}:${derived}`;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
function
|
|
33
|
+
async function hashPasswordAsync(password){
|
|
34
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
35
|
+
return `${salt}:${(await scrypt(password, salt, 64)).toString('hex')}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function verifyPassword(password, stored){
|
|
27
39
|
const [salt, derived] = stored.split(':'),
|
|
28
|
-
check =
|
|
29
|
-
return crypto.timingSafeEqual(
|
|
40
|
+
check = await scrypt(String(password ?? ''), salt, 64);
|
|
41
|
+
return crypto.timingSafeEqual(check, Buffer.from(derived, 'hex'));
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
const
|
|
44
|
+
// Checked instead of a real hash when the username doesn't exist, so that
|
|
45
|
+
// answer takes as long as a wrong password and doesn't reveal which
|
|
46
|
+
// usernames exist.
|
|
47
|
+
const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex')),
|
|
48
|
+
|
|
49
|
+
// Presets offered on the profile page (§10.1) — a fixed list rather than a
|
|
50
|
+
// free-text field, so a user can't accidentally set a 3-second or 10-year
|
|
51
|
+
// idle timeout.
|
|
52
|
+
SESSION_TIMEOUT_OPTIONS_MIN = [15, 30, 60, 120, 240, 480, 1440],
|
|
36
53
|
THEMES = ['auto', 'light', 'dark'];
|
|
37
54
|
|
|
38
55
|
function toProfile(row){
|
|
@@ -105,26 +122,22 @@ function createAdminUsersService(db){
|
|
|
105
122
|
// Self-service password change (§10.1) — requires knowing the *current*
|
|
106
123
|
// password, unlike resetPassword() above (that's the admin/operator
|
|
107
124
|
// break-glass path, which deliberately doesn't need it).
|
|
108
|
-
function changePassword(id, currentPassword, newPassword){
|
|
125
|
+
async function changePassword(id, currentPassword, newPassword){
|
|
109
126
|
const row = db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
|
|
110
|
-
if (!row || !verifyPassword(currentPassword, row.password_hash)){
|
|
127
|
+
if (!row || !(await verifyPassword(currentPassword, row.password_hash))){
|
|
111
128
|
throw Object.assign(new Error('Current password is incorrect'), { status: 401 });
|
|
112
129
|
}
|
|
113
130
|
if (!newPassword){
|
|
114
131
|
throw Object.assign(new Error('New password must not be empty'), { status: 400 });
|
|
115
132
|
}
|
|
116
|
-
db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(
|
|
133
|
+
db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
|
|
117
134
|
}
|
|
118
135
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!verifyPassword(password, user.password_hash)){
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
return toProfile(user);
|
|
136
|
+
// Async (thread pool); the same work whether or not the user exists.
|
|
137
|
+
async function verify(username, password){
|
|
138
|
+
const user = getByUsername(String(username ?? '')),
|
|
139
|
+
ok = await verifyPassword(password, user ? user.password_hash : DUMMY_HASH);
|
|
140
|
+
return user && ok ? toProfile(user) : null;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
// Partial update — only the fields present in `fields` are touched, so a
|
package/src/services/cleanup.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/src/services/cleanup.js
|
|
3
|
-
* @description Database cleanup: wipe finished jobs (logs,
|
|
3
|
+
* @description Database cleanup: wipe finished jobs (logs, events) and old events before a cutoff, then
|
|
4
4
|
* VACUUM to shrink the file — on demand from the dashboard, and hourly per retention.jobRetention (README §9)
|
|
5
5
|
*
|
|
6
6
|
* @author Andrian Yablonskyy
|
|
@@ -81,7 +81,7 @@ function createCleanupService(db, { jobs, config, now = () => new Date() }){
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// Wipe everything older than `before` (a Date; default now): finished
|
|
84
|
-
// jobs that ended before it, with their logs
|
|
84
|
+
// jobs that ended before it, with their logs and events, and
|
|
85
85
|
// the other audit events (resource/agent/group) logged before it. Events
|
|
86
86
|
// of jobs that stay (still active, or newer) are kept. `vacuum`: true,
|
|
87
87
|
// false, or 'auto' (only if something was deleted and the last VACUUM is
|
|
@@ -18,9 +18,29 @@ const { RESOURCE_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub
|
|
|
18
18
|
// §5.1 sweeper: runs every sweepIntervalSec, marks resources OUT_OF_SERVICE
|
|
19
19
|
// after missedLimit missed heartbeats and moves any active job to LOST.
|
|
20
20
|
function createHeartbeatMonitor(db, { bus, events, registry, jobs, config }){
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// heartbeat.intervalSec can change while running (dashboard Settings,
|
|
22
|
+
// §13.2). Clients learn the new one from their next heartbeat reply, so
|
|
23
|
+
// right after it's *lowered* they're still on the old, longer one: keep
|
|
24
|
+
// judging by that until every Client has had time to switch over.
|
|
25
|
+
let seenInterval = config.heartbeat.intervalSec,
|
|
26
|
+
graceInterval = 0,
|
|
27
|
+
graceUntil = 0;
|
|
28
|
+
function effectiveInterval(now){
|
|
29
|
+
const current = config.heartbeat.intervalSec;
|
|
30
|
+
if (current !== seenInterval){
|
|
31
|
+
if (current < seenInterval){
|
|
32
|
+
graceInterval = seenInterval;
|
|
33
|
+
graceUntil = now + (config.heartbeat.missedLimit + 1) * seenInterval * 1000;
|
|
34
|
+
}
|
|
35
|
+
seenInterval = current;
|
|
36
|
+
}
|
|
37
|
+
return now < graceUntil ? Math.max(current, graceInterval) : current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sweepOnce(now = Date.now()){
|
|
41
|
+
const { missedLimit } = config.heartbeat,
|
|
42
|
+
intervalSec = effectiveInterval(now),
|
|
43
|
+
cutoff = new Date(now - missedLimit * intervalSec * 1000).toISOString(),
|
|
24
44
|
|
|
25
45
|
stale = db
|
|
26
46
|
.prepare(
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/job-artifacts.js
|
|
3
|
+
* @description Artifacts a finished job reports (metadata only): validation, normalization and size formatting
|
|
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
|
+
// README §7.3. The job's command publishes its files wherever it likes and
|
|
17
|
+
// lists them in $THUB_ARTIFACTS_FILE; the Client sends that list with the
|
|
18
|
+
// result, and the job keeps only this metadata:
|
|
19
|
+
// { name, size, link, timestamp }
|
|
20
|
+
// It comes from the job's own command, so it's treated as untrusted: only
|
|
21
|
+
// http(s) links (a `javascript:` one would run in the dashboard when
|
|
22
|
+
// clicked), bounded sizes, and entries that don't fit are dropped, not fatal.
|
|
23
|
+
const MAX_ARTIFACTS = 500,
|
|
24
|
+
MAX_NAME = 255,
|
|
25
|
+
MAX_LINK = 2048;
|
|
26
|
+
|
|
27
|
+
// Unix seconds, Unix milliseconds or an ISO date -> ISO string (or null).
|
|
28
|
+
function normalizeTimestamp(value){
|
|
29
|
+
let ms = NaN;
|
|
30
|
+
if (typeof value === 'number' && Number.isFinite(value)){
|
|
31
|
+
ms = value < 1e11 ? value * 1000 : value; // 1e11 s is the year 5138: smaller means seconds
|
|
32
|
+
}
|
|
33
|
+
else if (typeof value === 'string' && value.trim()){
|
|
34
|
+
ms = /^\d+(\.\d+)?$/.test(value.trim()) ? normalizeTimestampMs(Number(value)) : Date.parse(value);
|
|
35
|
+
}
|
|
36
|
+
return Number.isFinite(ms) && ms >= 0 && ms < 8.64e15 ? new Date(ms).toISOString() : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeTimestampMs(n){
|
|
40
|
+
return n < 1e11 ? n * 1000 : n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeLink(value){
|
|
44
|
+
if (typeof value !== 'string' || value.length > MAX_LINK){
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(value.trim());
|
|
49
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -> { artifacts: [...valid, normalized], dropped: <count> }
|
|
57
|
+
function sanitizeArtifacts(raw){
|
|
58
|
+
if (!Array.isArray(raw)){
|
|
59
|
+
return { artifacts: [], dropped: raw === undefined || raw === null ? 0 : 1 };
|
|
60
|
+
}
|
|
61
|
+
const artifacts = [];
|
|
62
|
+
let dropped = Math.max(0, raw.length - MAX_ARTIFACTS);
|
|
63
|
+
for (const item of raw.slice(0, MAX_ARTIFACTS)){
|
|
64
|
+
const name = typeof item?.name === 'string' ? item.name.trim().slice(0, MAX_NAME) : '',
|
|
65
|
+
link = normalizeLink(item?.link),
|
|
66
|
+
size = Number.isSafeInteger(item?.size) && item.size >= 0 ? item.size : null;
|
|
67
|
+
if (!name || !link){
|
|
68
|
+
dropped += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
artifacts.push({ name, size, link, timestamp: normalizeTimestamp(item.timestamp) });
|
|
72
|
+
}
|
|
73
|
+
return { artifacts, dropped };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 1233 -> "1.2 KB" (1024-based, as file managers show it).
|
|
77
|
+
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
78
|
+
function formatBytes(bytes){
|
|
79
|
+
if (!Number.isFinite(bytes) || bytes < 0){
|
|
80
|
+
return '—';
|
|
81
|
+
}
|
|
82
|
+
let value = bytes,
|
|
83
|
+
unit = 0;
|
|
84
|
+
while (value >= 1024 && unit < UNITS.length - 1){
|
|
85
|
+
value /= 1024;
|
|
86
|
+
unit += 1;
|
|
87
|
+
}
|
|
88
|
+
return unit === 0 ? `${value} B` : `${value < 10 ? value.toFixed(1) : Math.round(value)} ${UNITS[unit]}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { sanitizeArtifacts, normalizeTimestamp, formatBytes, MAX_ARTIFACTS };
|
package/src/services/jobs.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
17
|
{ paginate } = require('./list-prefs'),
|
|
18
18
|
{ parseTerms, likeClause } = require('./search'),
|
|
19
|
+
{ sanitizeArtifacts } = require('./job-artifacts'),
|
|
19
20
|
// Named in spec errors: a field the Agent sends but this thub-common
|
|
20
21
|
// doesn't know means the Coordinator needs an update.
|
|
21
22
|
VERSIONS = `Coordinator v${require('../../package.json').version}, ` +
|
|
@@ -28,7 +29,8 @@ function rowToJob(row){
|
|
|
28
29
|
return {
|
|
29
30
|
...row,
|
|
30
31
|
spec: JSON.parse(row.spec),
|
|
31
|
-
summary: row.summary ? JSON.parse(row.summary) : null
|
|
32
|
+
summary: row.summary ? JSON.parse(row.summary) : null,
|
|
33
|
+
artifacts: row.artifacts ? JSON.parse(row.artifacts) : []
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -56,7 +58,7 @@ function nextJobId(db, source){
|
|
|
56
58
|
return `${prefix}-${String(n).padStart(5, '0')}`;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
function createJobsService(db, { bus, events, registry,
|
|
61
|
+
function createJobsService(db, { bus, events, registry, config }){
|
|
60
62
|
function get(id){
|
|
61
63
|
return rowToJob(db.prepare('SELECT * FROM jobs WHERE id = ?').get(id));
|
|
62
64
|
}
|
|
@@ -365,7 +367,9 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
365
367
|
}
|
|
366
368
|
|
|
367
369
|
// Resource: POST /jobs/:id/result — final verdict from the test runner.
|
|
368
|
-
|
|
370
|
+
// `artifacts`: what the job published elsewhere (job-artifacts.js) — kept
|
|
371
|
+
// as metadata, stored before the job turns final so its page shows them.
|
|
372
|
+
function applyResult(id, resourceId, { state, exitCode, summary, artifacts }){
|
|
369
373
|
const job = get(id);
|
|
370
374
|
if (!job || job.resource_id !== resourceId){
|
|
371
375
|
throw Object.assign(new Error('Job not assigned to this resource'), { status: 403 });
|
|
@@ -373,6 +377,13 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
373
377
|
if (![JOB_STATES.PASSED, JOB_STATES.FAILED, JOB_STATES.ERROR].includes(state)){
|
|
374
378
|
throw Object.assign(new Error('Invalid result state'), { status: 400 });
|
|
375
379
|
}
|
|
380
|
+
if (artifacts !== undefined){
|
|
381
|
+
const { artifacts: list, dropped } = sanitizeArtifacts(artifacts);
|
|
382
|
+
db.prepare('UPDATE jobs SET artifacts = ? WHERE id = ?').run(list.length ? JSON.stringify(list) : null, id);
|
|
383
|
+
if (dropped){
|
|
384
|
+
events.record('job', id, 'job.artifacts_dropped', { dropped });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
376
387
|
return setState(id, state, { exitCode, summary });
|
|
377
388
|
}
|
|
378
389
|
|
|
@@ -483,7 +494,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
483
494
|
}
|
|
484
495
|
|
|
485
496
|
// Admin: "clean the queue" — permanently delete finished jobs (and their
|
|
486
|
-
// logs
|
|
497
|
+
// logs, in the DB), for when the history itself,
|
|
487
498
|
// not just active work, needs clearing out. Unlike the nightly retention
|
|
488
499
|
// sweep (§9), this runs on demand and isn't limited to old jobs.
|
|
489
500
|
function cleanHistory(){
|
|
@@ -491,7 +502,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
491
502
|
}
|
|
492
503
|
|
|
493
504
|
// Finished jobs that ended before `beforeIso` (null: all of them), with
|
|
494
|
-
// their logs
|
|
505
|
+
// their logs and events — manual cleanup and the
|
|
495
506
|
// job retention task (services/cleanup.js). Active jobs are never touched.
|
|
496
507
|
function purgeFinishedBefore(beforeIso){
|
|
497
508
|
const placeholders = [...TERMINAL_JOB_STATES].map(() => '?').join(','),
|
|
@@ -504,14 +515,11 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
504
515
|
return 0;
|
|
505
516
|
}
|
|
506
517
|
|
|
507
|
-
const
|
|
508
|
-
deleteLogs = db.prepare('DELETE FROM job_logs WHERE job_id = ?'),
|
|
518
|
+
const deleteLogs = db.prepare('DELETE FROM job_logs WHERE job_id = ?'),
|
|
509
519
|
deleteEvents = db.prepare('DELETE FROM events WHERE entity = \'job\' AND entity_id = ?'),
|
|
510
520
|
deleteJob = db.prepare('DELETE FROM jobs WHERE id = ?'),
|
|
511
521
|
tx = db.transaction(() => {
|
|
512
522
|
for (const id of ids){
|
|
513
|
-
artifacts.deleteJobArtifacts(id);
|
|
514
|
-
deleteArtifacts.run(id);
|
|
515
523
|
deleteLogs.run(id);
|
|
516
524
|
deleteEvents.run(id);
|
|
517
525
|
deleteJob.run(id);
|
package/src/services/logs.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
// §3.1 Log service: accepts batched log lines, assigns a monotonically
|
|
17
17
|
// increasing seq per job, stores them, and fans them out over the bus for
|
|
18
|
-
// SSE subscribers (§6.4).
|
|
18
|
+
// SSE subscribers (§6.4). Lines are deleted with their job (§9).
|
|
19
19
|
function createLogsService(db, { bus }){
|
|
20
20
|
const insert = db.prepare(
|
|
21
21
|
'INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, ?, ?, ?, ?)'
|
|
@@ -47,24 +47,22 @@ function createLogsService(db, { bus }){
|
|
|
47
47
|
.all(jobId, afterSeq, limit);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
|
|
50
|
+
// The dashboard log viewer's pages (views/mixins/log-viewer.pug): the
|
|
51
|
+
// `limit` lines just before `beforeSeq` (the end of the log when omitted),
|
|
52
|
+
// oldest first, and whether there are earlier ones to scroll up to.
|
|
53
|
+
function pageBefore(jobId, beforeSeq, limit){
|
|
52
54
|
const rows = db
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
.prepare('SELECT seq, ts, stream, line FROM job_logs WHERE job_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?')
|
|
56
|
+
.all(jobId, beforeSeq ?? Number.MAX_SAFE_INTEGER, limit + 1),
|
|
57
|
+
hasMore = rows.length > limit;
|
|
58
|
+
return { lines: rows.slice(0, limit).reverse(), hasMore };
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
db.prepare(
|
|
61
|
-
`DELETE FROM job_logs WHERE job_id IN (
|
|
62
|
-
SELECT id FROM jobs WHERE finished_at IS NOT NULL AND finished_at <= ?
|
|
63
|
-
)`
|
|
64
|
-
).run(cutoff);
|
|
61
|
+
function count(jobId){
|
|
62
|
+
return db.prepare('SELECT COUNT(*) AS n FROM job_logs WHERE job_id = ?').get(jobId).n;
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
return { appendBatch, listSince,
|
|
65
|
+
return { appendBatch, listSince, pageBefore, count };
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
module.exports = { createLogsService };
|