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(); // Probes indexed by id. public _probeById = new Map(); 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; } const _homeUrlReachableProbe: Probe = { id: 'reachable', name: 'Is home page available at expected URL', apply: async (server, req) => { const url = server.getHomeInternalUrl(); const details: Record = { 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 = { url, }; ws.on('open', () => { ws.send('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 = { 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 = { 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, } }; }, };