mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
c3e23ca81e
This is a small thing, but when visiting the admin, the websocket test doesn't send valid JSON, which the receiving endpoint expects. This results in a harmless exception being thrown. While this test should eventually be modified to be run from the frontend, for now let's just make a small fix and send valid JSON in order to avoid that JSON parsing exception.
287 lines
7.3 KiB
TypeScript
287 lines
7.3 KiB
TypeScript
import { ApiError } from 'app/common/ApiError';
|
|
import { BootProbeIds, BootProbeResult } from 'app/common/BootProbe';
|
|
import { removeTrailingSlash } from 'app/common/gutil';
|
|
import { expressWrap, jsonErrorHandler } from 'app/server/lib/expressWrap';
|
|
import { GristServer } from 'app/server/lib/GristServer';
|
|
import * as express from 'express';
|
|
import WS from 'ws';
|
|
import fetch from 'node-fetch';
|
|
|
|
/**
|
|
* Self-diagnostics useful when installing Grist.
|
|
*/
|
|
export class BootProbes {
|
|
// List of probes.
|
|
public _probes = new Array<Probe>();
|
|
|
|
// Probes indexed by id.
|
|
public _probeById = new Map<string, Probe>();
|
|
|
|
public constructor(private _app: express.Application,
|
|
private _server: GristServer,
|
|
private _base: string,
|
|
private _middleware: express.Handler[] = []) {
|
|
this._addProbes();
|
|
}
|
|
|
|
public addEndpoints() {
|
|
// Return a list of available probes.
|
|
this._app.use(`${this._base}/probes$`,
|
|
...this._middleware,
|
|
expressWrap(async (_, res) => {
|
|
res.json({
|
|
'probes': this._probes.map(probe => {
|
|
return { id: probe.id, name: probe.name };
|
|
}),
|
|
});
|
|
}));
|
|
|
|
// Return result of running an individual probe.
|
|
this._app.use(`${this._base}/probes/:probeId`,
|
|
...this._middleware,
|
|
expressWrap(async (req, res) => {
|
|
const probe = this._probeById.get(req.params.probeId);
|
|
if (!probe) {
|
|
throw new ApiError('unknown probe', 400);
|
|
}
|
|
const result = await probe.apply(this._server, req);
|
|
res.json(result);
|
|
}));
|
|
|
|
// Fall-back for errors.
|
|
this._app.use(`${this._base}/probes`, jsonErrorHandler);
|
|
}
|
|
|
|
private _addProbes() {
|
|
this._probes.push(_homeUrlReachableProbe);
|
|
this._probes.push(_statusCheckProbe);
|
|
this._probes.push(_userProbe);
|
|
this._probes.push(_bootProbe);
|
|
this._probes.push(_hostHeaderProbe);
|
|
this._probes.push(_sandboxingProbe);
|
|
this._probes.push(_authenticationProbe);
|
|
this._probes.push(_webSocketsProbe);
|
|
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An individual probe has an id, a name, an optional description,
|
|
* and a method that returns a probe result.
|
|
*/
|
|
export interface Probe {
|
|
id: BootProbeIds;
|
|
name: string;
|
|
description?: string;
|
|
apply: (server: GristServer, req: express.Request) => Promise<BootProbeResult>;
|
|
}
|
|
|
|
const _homeUrlReachableProbe: Probe = {
|
|
id: 'reachable',
|
|
name: 'Is home page available at expected URL',
|
|
apply: async (server, req) => {
|
|
const url = server.getHomeInternalUrl();
|
|
const details: Record<string, any> = {
|
|
url,
|
|
};
|
|
try {
|
|
const resp = await fetch(url);
|
|
details.status = resp.status;
|
|
if (resp.status !== 200) {
|
|
throw new ApiError(await resp.text(), resp.status);
|
|
}
|
|
return {
|
|
status: 'success',
|
|
details,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
details: {
|
|
...details,
|
|
error: String(e),
|
|
},
|
|
status: 'fault',
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
const _webSocketsProbe: Probe = {
|
|
id: 'websockets',
|
|
name: 'Can we open a websocket with the server',
|
|
apply: async (server, req) => {
|
|
return new Promise((resolve) => {
|
|
const url = new URL(server.getHomeUrl(req));
|
|
url.protocol = (url.protocol === 'https:') ? 'wss:' : 'ws:';
|
|
const ws = new WS.WebSocket(url.href);
|
|
const details: Record<string, any> = {
|
|
url,
|
|
};
|
|
ws.on('open', () => {
|
|
ws.send('{"msg": "Just nod if you can hear me."}');
|
|
resolve({
|
|
status: 'success',
|
|
details,
|
|
});
|
|
ws.close();
|
|
});
|
|
ws.on('error', (ev) => {
|
|
details.error = ev.message;
|
|
resolve({
|
|
status: 'fault',
|
|
details,
|
|
});
|
|
ws.close();
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const _statusCheckProbe: Probe = {
|
|
id: 'health-check',
|
|
name: 'Is an internal health check passing',
|
|
apply: async (server, req) => {
|
|
const baseUrl = server.getHomeInternalUrl();
|
|
const url = new URL(baseUrl);
|
|
url.pathname = removeTrailingSlash(url.pathname) + '/status';
|
|
const details: Record<string, any> = {
|
|
url: url.href,
|
|
};
|
|
try {
|
|
const resp = await fetch(url);
|
|
details.status = resp.status;
|
|
if (resp.status !== 200) {
|
|
throw new Error(`Failed with status ${resp.status}`);
|
|
}
|
|
const txt = await resp.text();
|
|
if (!txt.includes('is alive')) {
|
|
throw new Error(`Failed, page has unexpected content`);
|
|
}
|
|
return {
|
|
status: 'success',
|
|
details,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
details: {
|
|
...details,
|
|
error: String(e),
|
|
},
|
|
status: 'fault',
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|
|
const _userProbe: Probe = {
|
|
id: 'system-user',
|
|
name: 'Is the system user following best practice',
|
|
apply: async () => {
|
|
const details = {
|
|
uid: process.getuid ? process.getuid() : 'unavailable',
|
|
};
|
|
if (process.getuid && process.getuid() === 0) {
|
|
return {
|
|
details,
|
|
verdict: 'User appears to be root (UID 0)',
|
|
status: 'warning',
|
|
};
|
|
} else {
|
|
return {
|
|
status: 'success',
|
|
details,
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|
|
const _bootProbe: Probe = {
|
|
id: 'boot-page',
|
|
name: 'Is the boot page adequately protected',
|
|
apply: async (server) => {
|
|
const bootKey = server.getBootKey() || '';
|
|
const hasBoot = Boolean(bootKey);
|
|
const details: Record<string, any> = {
|
|
bootKeySet: hasBoot,
|
|
};
|
|
if (!hasBoot) {
|
|
return { status: 'success', details };
|
|
}
|
|
details.bootKeyLength = bootKey.length;
|
|
if (bootKey.length < 10) {
|
|
return {
|
|
verdict: 'Boot key length is shorter than 10.',
|
|
details,
|
|
status: 'fault',
|
|
};
|
|
}
|
|
return {
|
|
verdict: 'Boot key ideally should be removed after installation.',
|
|
details,
|
|
status: 'warning',
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Based on:
|
|
* https://github.com/gristlabs/grist-core/issues/228#issuecomment-1803304438
|
|
*
|
|
* When GRIST_SERVE_SAME_ORIGIN is set, requests arriving to Grist need
|
|
* to have an accurate Host header.
|
|
*/
|
|
const _hostHeaderProbe: Probe = {
|
|
id: 'host-header',
|
|
name: 'Does the host header look correct',
|
|
apply: async (server, req) => {
|
|
const host = req.header('host');
|
|
const url = new URL(server.getHomeUrl(req));
|
|
const details = {
|
|
homeUrlHost: url.hostname,
|
|
headerHost: host,
|
|
};
|
|
if (url.hostname === 'localhost') {
|
|
return {
|
|
status: 'none',
|
|
details,
|
|
};
|
|
}
|
|
if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) {
|
|
return {
|
|
details,
|
|
status: 'hmm',
|
|
};
|
|
}
|
|
return {
|
|
status: 'none',
|
|
details,
|
|
};
|
|
},
|
|
};
|
|
|
|
const _sandboxingProbe: Probe = {
|
|
id: 'sandboxing',
|
|
name: 'Is document sandboxing effective',
|
|
apply: async (server, req) => {
|
|
const details = server.getSandboxInfo();
|
|
return {
|
|
status: (details?.configured && details?.functional) ? 'success' : 'fault',
|
|
details,
|
|
};
|
|
},
|
|
};
|
|
|
|
const _authenticationProbe: Probe = {
|
|
id: 'authentication',
|
|
name: 'Authentication system',
|
|
apply: async(server, req) => {
|
|
const loginSystemId = server.getInfo('loginMiddlewareComment');
|
|
return {
|
|
status: (loginSystemId != undefined) ? 'success' : 'fault',
|
|
details: {
|
|
loginSystemId,
|
|
}
|
|
};
|
|
},
|
|
};
|