@dokebi/cli 0.4.26 → 0.4.28
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/AI.md +20 -3
- package/catalog.json +7 -7
- package/dist/bin.js +121 -3
- package/dist/check/shared-state-rules.d.ts +13 -0
- package/dist/check/shared-state-rules.js +5 -2
- package/dist/commands.js +76 -7
- package/dist/config-secrets.d.ts +54 -0
- package/dist/config-secrets.js +226 -0
- package/dist/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +564 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -0
- package/dist/project-inspector.js +17 -1
- package/dist/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -0
- package/package.json +3 -3
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// `dokebi doctor --production` (D-0184 §4): the checks of a server, on the settings the app
|
|
2
|
+
// reads there (DOKEBI_CONFIG, merged with the secrets file DOKEBI_SECRETS). The per-process
|
|
3
|
+
// state rules mirror what the plugins refuse at start in production (D-0161), so a deploy learns
|
|
4
|
+
// them before the restart instead of from a failed start.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { isAbsolute, join } from 'node:path';
|
|
8
|
+
import { applySecrets, ConfigError, parseConfig, SECRETS_VARIABLE } from '@dokebi/config';
|
|
9
|
+
import { kept } from './check/shared-state-rules.js';
|
|
10
|
+
import { placeholderPaths } from './config-init.js';
|
|
11
|
+
import { configSecrets } from './config-secrets.js';
|
|
12
|
+
import { readSettings } from './config-show.js';
|
|
13
|
+
import { debug, describeError } from './diagnostics.js';
|
|
14
|
+
import { CliError } from './errors.js';
|
|
15
|
+
import { parseObject } from './filesystem.js';
|
|
16
|
+
import { optionsKey } from './manifest.js';
|
|
17
|
+
import { readState } from './project-state.js';
|
|
18
|
+
function valueAt(options, path) {
|
|
19
|
+
let value = options;
|
|
20
|
+
for (const key of path) {
|
|
21
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.hasOwn(value, key))
|
|
22
|
+
value = value[key];
|
|
23
|
+
else
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
/** `dokebi-migrate status --json` with the settings of the server; undefined when it cannot run. */
|
|
29
|
+
function migrationStatus(root, env) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
let stdout = '';
|
|
32
|
+
const child = spawn(join(root, 'node_modules', '.bin', 'dokebi-migrate'), ['status', '--json'], {
|
|
33
|
+
cwd: root,
|
|
34
|
+
env,
|
|
35
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
timeout: 60_000,
|
|
37
|
+
});
|
|
38
|
+
child.stdout.setEncoding('utf8');
|
|
39
|
+
child.stdout.on('data', (chunk) => {
|
|
40
|
+
stdout += chunk;
|
|
41
|
+
});
|
|
42
|
+
child.once('error', (error) => {
|
|
43
|
+
debug('dokebi-migrate status could not run', { error: describeError(error) });
|
|
44
|
+
resolve(undefined);
|
|
45
|
+
});
|
|
46
|
+
child.once('close', () => {
|
|
47
|
+
try {
|
|
48
|
+
resolve(JSON.parse(stdout));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
debug('dokebi-migrate status gave no JSON', { error: describeError(error) });
|
|
52
|
+
resolve(undefined);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** The production diagnostics of the project at `root` (D-0184 §4). */
|
|
58
|
+
export async function productionDiagnostics(root, env = process.env) {
|
|
59
|
+
const found = [];
|
|
60
|
+
const add = (code, severity, path, hint, plugin = null, details) => found.push({
|
|
61
|
+
code,
|
|
62
|
+
severity,
|
|
63
|
+
path,
|
|
64
|
+
plugin,
|
|
65
|
+
hint,
|
|
66
|
+
...(details === undefined ? {} : { details }),
|
|
67
|
+
});
|
|
68
|
+
if (env.NODE_ENV !== 'production')
|
|
69
|
+
add('NODE_ENV_NOT_PRODUCTION', 'error', 'NODE_ENV', 'NODE_ENV is not production here, so this check (and an app started like this) runs with development behaviour: run it as the service runs, NODE_ENV=production npx dokebi doctor --production (the systemd unit and compose file set it)');
|
|
70
|
+
const { target, source } = await readSettings(root);
|
|
71
|
+
const secretsFile = env[SECRETS_VARIABLE] || undefined;
|
|
72
|
+
let options;
|
|
73
|
+
try {
|
|
74
|
+
options = (await applySecrets(parseConfig(source), secretsFile)).options;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (!(error instanceof ConfigError))
|
|
78
|
+
throw error;
|
|
79
|
+
// The app would refuse to start with the same code; the rest is checked on the file alone.
|
|
80
|
+
add(error.code, 'error', target.file, `${error.message}. ${error.fix}`, 'dokebi-config', {
|
|
81
|
+
issues: error.issues,
|
|
82
|
+
});
|
|
83
|
+
// A settings file that does not parse has nothing more to check; a secrets problem leaves
|
|
84
|
+
// the settings file itself readable.
|
|
85
|
+
if (!error.code.startsWith('CONFIG_SECRETS') && !/secrets file/.test(error.message))
|
|
86
|
+
return found;
|
|
87
|
+
options = parseConfig(source).options;
|
|
88
|
+
}
|
|
89
|
+
const state = readState(parseObject(await readFile(join(root, '.dokebi/state.json'), 'utf8'), 'state'));
|
|
90
|
+
const installed = new Set(state.plugins.map((plugin) => optionsKey(plugin)));
|
|
91
|
+
// The same state `dokebi check` names for several workers (D-0161): in production one process
|
|
92
|
+
// is refused too (a restart loses it).
|
|
93
|
+
const redis = Object.keys(valueAt(options['dokebi-redis'], ['connections']) ??
|
|
94
|
+
{})[0];
|
|
95
|
+
for (const item of kept(installed, options))
|
|
96
|
+
add('STATE_NOT_SHARED', 'error', item.path.join('.'), `${item.what} would live in one process and be lost on every restart; the plugin refuses to start so in production (D-0161): ${item.fix(redis)}`, item.path[0] ?? null);
|
|
97
|
+
for (const plugin of ['dokebi-lock', 'dokebi-cache'])
|
|
98
|
+
if (installed.has(plugin)) {
|
|
99
|
+
const backends = valueAt(options[plugin], ['backends']);
|
|
100
|
+
for (const [name, backend] of Object.entries(backends && typeof backends === 'object' ? backends : {}))
|
|
101
|
+
if ((valueAt(backend, ['driver']) ?? 'local') === 'local')
|
|
102
|
+
add('STATE_NOT_SHARED', 'warning', `${plugin}.backends.${name}`, `the ${name} backend is local to one process: right for one process, wrong for a cluster or two instances that must share it; use driver: redis for shared ${plugin === 'dokebi-lock' ? 'locks' : 'cache entries'}`, plugin);
|
|
103
|
+
}
|
|
104
|
+
if (installed.has('dokebi-mail')) {
|
|
105
|
+
const driver = valueAt(options['dokebi-mail'], ['transport', 'driver']) ?? 'log';
|
|
106
|
+
if (driver === 'log' || driver === 'capture')
|
|
107
|
+
add('TRANSPORT_NOT_FOR_PRODUCTION', 'error', 'dokebi-mail.transport.driver', `the ${String(driver)} transport keeps mail in memory and the plugin refuses it in production: set options.dokebi-mail.transport to smtp or ses`, 'dokebi-mail');
|
|
108
|
+
}
|
|
109
|
+
if (installed.has('dokebi-web')) {
|
|
110
|
+
const web = options['dokebi-web'];
|
|
111
|
+
const port = valueAt(web, ['port']);
|
|
112
|
+
if (typeof port !== 'number' || port === 0)
|
|
113
|
+
add('WEB_PORT_UNSET', 'error', 'dokebi-web.port', 'the port is 0 (any free port), so the reverse proxy cannot reach the app: set the instance port (deploy init writes it)', 'dokebi-web');
|
|
114
|
+
const trust = valueAt(web, ['trustProxy']);
|
|
115
|
+
if (!Array.isArray(trust) || trust.length === 0)
|
|
116
|
+
add('TRUST_PROXY_EMPTY', 'warning', 'dokebi-web.trustProxy', 'behind a reverse proxy the app must trust its address, or it ignores X-Forwarded-Proto and -Host (http URLs, cookies without Secure, wrong client addresses): list the proxy addresses, 127.0.0.1 and ::1 on the same server', 'dokebi-web');
|
|
117
|
+
const host = valueAt(web, ['host']) ?? '127.0.0.1';
|
|
118
|
+
if (host === '0.0.0.0' || host === '::')
|
|
119
|
+
add('WEB_LISTENS_EVERYWHERE', 'warning', 'dokebi-web.host', 'the app listens on every interface, so it can be reached without the reverse proxy (no TLS): listen on 127.0.0.1 unless a firewall closes the port', 'dokebi-web');
|
|
120
|
+
}
|
|
121
|
+
if (installed.has('dokebi-storage')) {
|
|
122
|
+
const storage = options['dokebi-storage'];
|
|
123
|
+
const directory = valueAt(storage, ['directory']) ?? '.dokebi/storage';
|
|
124
|
+
if ((valueAt(storage, ['driver']) ?? 'local') === 'local' && !isAbsolute(String(directory)))
|
|
125
|
+
add('STORAGE_RELATIVE', 'warning', 'dokebi-storage.directory', `the stored files go to ${String(directory)} inside the working folder, which a new release replaces: set an absolute folder outside the release (deploy init uses /var/lib/<app>/<instance>/storage)`, 'dokebi-storage');
|
|
126
|
+
}
|
|
127
|
+
const placeholders = placeholderPaths(options);
|
|
128
|
+
if (placeholders.length)
|
|
129
|
+
add('PLACEHOLDER_LEFT', 'error', target.file, `these options still hold a <placeholder> of the sample: ${placeholders.join(', ')}; fill them in the settings (or the secrets file)`, null, { paths: placeholders });
|
|
130
|
+
if (!found.some((item) => item.code.startsWith('CONFIG_SECRETS'))) {
|
|
131
|
+
try {
|
|
132
|
+
const secrets = await configSecrets(root, env);
|
|
133
|
+
for (const warning of secrets.warnings ?? [])
|
|
134
|
+
add(warning.code, 'warning', warning.path, warning.hint);
|
|
135
|
+
const unlisted = secrets.missing.filter((path) => !secrets.listed.includes(path));
|
|
136
|
+
if (unlisted.length)
|
|
137
|
+
add('SECRETS_MISSING', 'error', target.file, `secrets the installed plugins need are not set: ${unlisted.join(', ')}; put them in the secrets file (npx dokebi config secrets --json)`, null, { paths: unlisted });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (!(error instanceof CliError))
|
|
141
|
+
throw error;
|
|
142
|
+
add(error.code, 'error', target.file, error.message);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (installed.has('dokebi-migrate')) {
|
|
146
|
+
const status = await migrationStatus(root, {
|
|
147
|
+
...env,
|
|
148
|
+
...(env.DOKEBI_CONFIG ? {} : { DOKEBI_CONFIG: target.path }),
|
|
149
|
+
});
|
|
150
|
+
if (!status?.results)
|
|
151
|
+
add('MIGRATIONS_UNCHECKED', 'error', 'dokebi-migrate', `dokebi-migrate status could not read the migrations${status?.error?.code ? ` (${status.error.code}: ${status.error.message ?? ''})` : ''}: check its options and that the database is reachable with these settings (npx dokebi-migrate status)`, 'dokebi-migrate');
|
|
152
|
+
else
|
|
153
|
+
for (const [connection, list] of Object.entries(status.results)) {
|
|
154
|
+
const pending = list.filter((item) => item.state === 'pending');
|
|
155
|
+
const before = pending
|
|
156
|
+
.filter((item) => item.phase !== 'post-deploy')
|
|
157
|
+
.map((item) => item.name);
|
|
158
|
+
const after = pending
|
|
159
|
+
.filter((item) => item.phase === 'post-deploy')
|
|
160
|
+
.map((item) => item.name);
|
|
161
|
+
const missing = list.filter((item) => item.state === 'missing').map((item) => item.name);
|
|
162
|
+
if (before.length)
|
|
163
|
+
add('MIGRATIONS_PENDING', 'error', `dokebi-migrate.connections.${connection}`, `${before.length} migration(s) to apply before the new code starts: ${before.join(', ')}; run npx dokebi-migrate up --phase pre-deploy --json (DEPLOY.md, release step 2)`, 'dokebi-migrate');
|
|
164
|
+
if (after.length)
|
|
165
|
+
add('MIGRATIONS_POST_DEPLOY_PENDING', 'warning', `dokebi-migrate.connections.${connection}`, `post-deploy migration(s) wait for the switch: ${after.join(', ')}; run npx dokebi-migrate up --json after the new code runs`, 'dokebi-migrate');
|
|
166
|
+
if (missing.length)
|
|
167
|
+
add('MIGRATION_FILE_MISSING', 'warning', `dokebi-migrate.connections.${connection}`, `applied migrations whose files are gone (an older release folder?): ${missing.join(', ')}`, 'dokebi-migrate');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
debug('Production checks done', {
|
|
171
|
+
errors: found.filter((item) => item.severity === 'error').length,
|
|
172
|
+
warnings: found.filter((item) => item.severity === 'warning').length,
|
|
173
|
+
secretsFile: secretsFile !== undefined,
|
|
174
|
+
});
|
|
175
|
+
return found;
|
|
176
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { applySecrets, ConfigError, SECRETS_VARIABLE } from '@dokebi/config';
|
|
3
4
|
import satisfies from 'semver/functions/satisfies.js';
|
|
4
5
|
import valid from 'semver/functions/valid.js';
|
|
5
6
|
import { CONFIG_FILE, configTarget, FRESH_COPY_FIX, LEGACY_CONFIG_FILE, parseProjectConfig, readProjectConfigSource, } from './configuration.js';
|
|
@@ -193,7 +194,22 @@ export class ProjectInspector {
|
|
|
193
194
|
throw new CliError((await exists(await securePath(root, CONFIG_FILE)))
|
|
194
195
|
? 'CONFIG_CONFLICT'
|
|
195
196
|
: 'MIGRATION_REQUIRED', 'Explicit YAML migration is required');
|
|
196
|
-
|
|
197
|
+
const parsed = parseProjectConfig(await read(configFile));
|
|
198
|
+
// D-0184: on a server the options are the settings with the secrets file merged.
|
|
199
|
+
const secrets = process.env[SECRETS_VARIABLE];
|
|
200
|
+
if (!secrets)
|
|
201
|
+
return parsed;
|
|
202
|
+
try {
|
|
203
|
+
const merged = await applySecrets({ schemaVersion: 1, options: parsed.options }, secrets);
|
|
204
|
+
return { ...parsed, options: merged.options };
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!(error instanceof ConfigError))
|
|
208
|
+
throw error;
|
|
209
|
+
throw new CliError(error.code, error.message, {
|
|
210
|
+
details: { issues: error.issues, fix: error.fix },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
197
213
|
});
|
|
198
214
|
result.requiredPlugins =
|
|
199
215
|
(await check('node_modules/@dokebi/core/package.json', () => readRequiredPlugins(root))) ??
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface RemoteCheck {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly ok: boolean;
|
|
4
|
+
/** A failed check that is only a warning (the result stays ok). */
|
|
5
|
+
readonly warning?: boolean;
|
|
6
|
+
readonly status?: number;
|
|
7
|
+
readonly detail: string;
|
|
8
|
+
readonly ms: number;
|
|
9
|
+
}
|
|
10
|
+
export interface RemoteVerifyResult {
|
|
11
|
+
readonly ok: boolean;
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly checks: readonly RemoteCheck[];
|
|
14
|
+
readonly certificate?: {
|
|
15
|
+
readonly subject: string;
|
|
16
|
+
readonly issuer: string;
|
|
17
|
+
readonly validTo: string;
|
|
18
|
+
readonly daysLeft: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface RemoteVerifyOptions {
|
|
22
|
+
readonly root?: string;
|
|
23
|
+
readonly timeoutMs?: number;
|
|
24
|
+
/** Trusted certificates besides the system's (tests: a local CA). */
|
|
25
|
+
readonly ca?: string;
|
|
26
|
+
/** The port of the plain http check (default 80; tests use a free one). */
|
|
27
|
+
readonly httpPort?: number;
|
|
28
|
+
}
|
|
29
|
+
/** `dokebi verify --remote` (D-0184 §4). */
|
|
30
|
+
export declare function remoteVerify(input: string, options?: RemoteVerifyOptions): Promise<RemoteVerifyResult>;
|
|
31
|
+
export declare function remoteVerifyText(result: RemoteVerifyResult): string;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// `dokebi verify --remote <https-url>` (D-0184 §4): checks a deployed site from the outside —
|
|
2
|
+
// its TLS certificate, the http to https redirect, the health routes and the security headers.
|
|
3
|
+
// Nothing local runs; the result says which check failed and why.
|
|
4
|
+
import { request as httpRequest } from 'node:http';
|
|
5
|
+
import { request as httpsRequest } from 'node:https';
|
|
6
|
+
import { connect } from 'node:tls';
|
|
7
|
+
import { parseConfig } from '@dokebi/config';
|
|
8
|
+
import { readSettings } from './config-show.js';
|
|
9
|
+
import { debug, describeError } from './diagnostics.js';
|
|
10
|
+
import { CliError } from './errors.js';
|
|
11
|
+
/** Days left below which the certificate check warns (renewal should have happened). */
|
|
12
|
+
const RENEW_DAYS = 14;
|
|
13
|
+
/** HSTS max-age below this many seconds (180 days) is a warning. */
|
|
14
|
+
const HSTS_MIN = 15_552_000;
|
|
15
|
+
function fetchOnce(url, timeoutMs, ca) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
18
|
+
const request = send(url, {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
headers: { 'user-agent': 'dokebi-verify-remote', accept: '*/*' },
|
|
21
|
+
timeout: timeoutMs,
|
|
22
|
+
...(ca === undefined ? {} : { ca }),
|
|
23
|
+
}, (response) => {
|
|
24
|
+
// Only the status and headers matter; the body is read to the end and dropped.
|
|
25
|
+
response.resume();
|
|
26
|
+
response.once('end', () => resolve({ status: response.statusCode ?? 0, headers: response.headers }));
|
|
27
|
+
response.once('error', reject);
|
|
28
|
+
});
|
|
29
|
+
request.once('timeout', () => request.destroy(new Error(`no answer within ${timeoutMs} ms`)));
|
|
30
|
+
request.once('error', reject);
|
|
31
|
+
request.end();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function certificateOf(url, timeoutMs, ca) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const socket = connect({
|
|
37
|
+
host: url.hostname,
|
|
38
|
+
port: Number(url.port || 443),
|
|
39
|
+
servername: url.hostname,
|
|
40
|
+
timeout: timeoutMs,
|
|
41
|
+
...(ca === undefined ? {} : { ca }),
|
|
42
|
+
});
|
|
43
|
+
socket.once('secureConnect', () => {
|
|
44
|
+
const certificate = socket.getPeerCertificate();
|
|
45
|
+
socket.end();
|
|
46
|
+
if (!socket.authorized)
|
|
47
|
+
reject(new Error(String(socket.authorizationError)));
|
|
48
|
+
else
|
|
49
|
+
resolve(certificate);
|
|
50
|
+
});
|
|
51
|
+
socket.once('timeout', () => socket.destroy(new Error(`no TLS handshake within ${timeoutMs} ms`)));
|
|
52
|
+
socket.once('error', reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function header(answer, name) {
|
|
56
|
+
const value = answer.headers[name];
|
|
57
|
+
return Array.isArray(value) ? value.join(', ') : value;
|
|
58
|
+
}
|
|
59
|
+
/** The health routes of the project's settings when run in one, else the defaults. */
|
|
60
|
+
async function healthRoutes(root) {
|
|
61
|
+
const defaults = { live: '/health/live', ready: '/health/ready' };
|
|
62
|
+
if (root === undefined)
|
|
63
|
+
return defaults;
|
|
64
|
+
try {
|
|
65
|
+
const { source } = await readSettings(root);
|
|
66
|
+
const routes = parseConfig(source).options['dokebi-web']?.healthRoutes;
|
|
67
|
+
return {
|
|
68
|
+
live: typeof routes?.live === 'string' ? routes.live : defaults.live,
|
|
69
|
+
ready: typeof routes?.ready === 'string' ? routes.ready : defaults.ready,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// Outside a project (or settings that do not parse) the default routes are checked.
|
|
74
|
+
debug('verify --remote uses the default health routes', { error: describeError(error) });
|
|
75
|
+
return defaults;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** `dokebi verify --remote` (D-0184 §4). */
|
|
79
|
+
export async function remoteVerify(input, options = {}) {
|
|
80
|
+
let url;
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(input);
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
throw new CliError('USAGE', `--remote ${input}: an https URL such as https://shop.example.com`, {
|
|
86
|
+
phase: 'arguments',
|
|
87
|
+
cause,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
91
|
+
throw new CliError('USAGE', '--remote takes an https URL without credentials', {
|
|
92
|
+
phase: 'arguments',
|
|
93
|
+
details: { fix: 'npx dokebi verify --remote https://<domain> --json' },
|
|
94
|
+
});
|
|
95
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
96
|
+
const routes = await healthRoutes(options.root);
|
|
97
|
+
const checks = [];
|
|
98
|
+
const timed = async (name, run) => {
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
try {
|
|
101
|
+
checks.push({ name, ...(await run()), ms: Date.now() - started });
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
checks.push({
|
|
105
|
+
name,
|
|
106
|
+
ok: false,
|
|
107
|
+
detail: describeError(error).split('\n')[0] ?? 'failed',
|
|
108
|
+
ms: Date.now() - started,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
let certificate;
|
|
113
|
+
await timed('tls', async () => {
|
|
114
|
+
const peer = await certificateOf(url, timeoutMs, options.ca);
|
|
115
|
+
const daysLeft = Math.floor((Date.parse(peer.valid_to) - Date.now()) / 86_400_000);
|
|
116
|
+
certificate = {
|
|
117
|
+
subject: String(peer.subject?.CN ?? ''),
|
|
118
|
+
issuer: String(peer.issuer?.O ?? peer.issuer?.CN ?? ''),
|
|
119
|
+
validTo: new Date(peer.valid_to).toISOString(),
|
|
120
|
+
daysLeft,
|
|
121
|
+
};
|
|
122
|
+
return daysLeft < RENEW_DAYS
|
|
123
|
+
? {
|
|
124
|
+
ok: false,
|
|
125
|
+
warning: true,
|
|
126
|
+
detail: `the certificate ends in ${daysLeft} days (renewal should have happened)`,
|
|
127
|
+
}
|
|
128
|
+
: { ok: true, detail: `valid for ${url.hostname}, ${daysLeft} days left` };
|
|
129
|
+
});
|
|
130
|
+
await timed('redirect', async () => {
|
|
131
|
+
const plain = new URL(url);
|
|
132
|
+
plain.protocol = 'http:';
|
|
133
|
+
plain.port = options.httpPort === undefined ? '' : String(options.httpPort);
|
|
134
|
+
const answer = await fetchOnce(plain, timeoutMs);
|
|
135
|
+
const location = header(answer, 'location') ?? '';
|
|
136
|
+
const ok = [301, 302, 307, 308].includes(answer.status) && location.startsWith('https://');
|
|
137
|
+
return ok
|
|
138
|
+
? { ok, status: answer.status, detail: `http answers ${answer.status} to ${location}` }
|
|
139
|
+
: {
|
|
140
|
+
ok,
|
|
141
|
+
warning: true,
|
|
142
|
+
status: answer.status,
|
|
143
|
+
detail: `http answers ${answer.status} without a redirect to https`,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
for (const [name, path] of [
|
|
147
|
+
['live', routes.live],
|
|
148
|
+
['ready', routes.ready],
|
|
149
|
+
])
|
|
150
|
+
await timed(name, async () => {
|
|
151
|
+
const answer = await fetchOnce(new URL(path, url), timeoutMs, options.ca);
|
|
152
|
+
return {
|
|
153
|
+
ok: answer.status === 200,
|
|
154
|
+
status: answer.status,
|
|
155
|
+
detail: answer.status === 200 ? `${path} answers 200` : `${path} answers ${answer.status}`,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
await timed('headers', async () => {
|
|
159
|
+
const answer = await fetchOnce(url, timeoutMs, options.ca);
|
|
160
|
+
const problems = [];
|
|
161
|
+
const notes = [];
|
|
162
|
+
const hsts = header(answer, 'strict-transport-security');
|
|
163
|
+
const age = Number(/max-age=(\d+)/i.exec(hsts ?? '')?.[1] ?? Number.NaN);
|
|
164
|
+
if (!hsts)
|
|
165
|
+
problems.push('no Strict-Transport-Security');
|
|
166
|
+
else if (!(age >= HSTS_MIN))
|
|
167
|
+
notes.push(`Strict-Transport-Security max-age ${age} is under 180 days`);
|
|
168
|
+
if (header(answer, 'x-content-type-options')?.toLowerCase() !== 'nosniff')
|
|
169
|
+
problems.push('no X-Content-Type-Options: nosniff');
|
|
170
|
+
// Framing and the referrer policy matter for pages (D-0149 sets them on HTML answers); an
|
|
171
|
+
// API answer at the URL needs HSTS and nosniff only.
|
|
172
|
+
const html = /text\/html/i.test(header(answer, 'content-type') ?? '');
|
|
173
|
+
const csp = header(answer, 'content-security-policy') ?? '';
|
|
174
|
+
if (html && !header(answer, 'x-frame-options') && !/frame-ancestors/i.test(csp))
|
|
175
|
+
problems.push('no X-Frame-Options or CSP frame-ancestors');
|
|
176
|
+
if (html && !header(answer, 'referrer-policy'))
|
|
177
|
+
notes.push('no Referrer-Policy');
|
|
178
|
+
if (header(answer, 'x-powered-by'))
|
|
179
|
+
problems.push('X-Powered-By names the server software');
|
|
180
|
+
return problems.length
|
|
181
|
+
? { ok: false, status: answer.status, detail: [...problems, ...notes].join('; ') }
|
|
182
|
+
: notes.length
|
|
183
|
+
? { ok: false, warning: true, status: answer.status, detail: notes.join('; ') }
|
|
184
|
+
: {
|
|
185
|
+
ok: true,
|
|
186
|
+
status: answer.status,
|
|
187
|
+
detail: html
|
|
188
|
+
? 'HSTS, nosniff, framing and referrer policy set'
|
|
189
|
+
: `HSTS and nosniff set (a ${header(answer, 'content-type')?.split(';')[0] ?? 'non-HTML'} answer: framing and referrer policy not checked)`,
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
const ok = checks.every((check) => check.ok || check.warning === true);
|
|
193
|
+
debug('Remote site checked', {
|
|
194
|
+
host: url.hostname,
|
|
195
|
+
ok,
|
|
196
|
+
failed: checks.filter((check) => !check.ok).map((check) => check.name),
|
|
197
|
+
});
|
|
198
|
+
return { ok, url: url.href, checks, ...(certificate ? { certificate } : {}) };
|
|
199
|
+
}
|
|
200
|
+
export function remoteVerifyText(result) {
|
|
201
|
+
return `${result.checks
|
|
202
|
+
.map((check) => `${check.ok ? 'ok' : check.warning ? 'WARN' : 'FAIL'} ${check.name} (${check.ms} ms): ${check.detail}`)
|
|
203
|
+
.join('\n')}\n${result.ok ? `verify --remote ok: ${result.url}` : `verify --remote FAILED: ${result.url}`}\n`;
|
|
204
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.28",
|
|
4
4
|
"description": "Project scaffolding, plugin catalog, recipes, install and verification CLI for Dokebi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dokebi",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"semver": "7.8.5",
|
|
37
|
-
"@dokebi/core": "^0.4.
|
|
38
|
-
"@dokebi/config": "^0.4.
|
|
37
|
+
"@dokebi/core": "^0.4.3",
|
|
38
|
+
"@dokebi/config": "^0.4.3",
|
|
39
39
|
"yaml": "2.9.1"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|