diff --git a/app/client/boot.ts b/app/client/boot.ts new file mode 100644 index 00000000..fed21107 --- /dev/null +++ b/app/client/boot.ts @@ -0,0 +1,259 @@ +import { AppModel } from 'app/client/models/AppModel'; +import { createAppPage } from 'app/client/ui/createAppPage'; +import { pagePanels } from 'app/client/ui/PagePanels'; +import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe'; +import { removeTrailingSlash } from 'app/common/gutil'; +import { getGristConfig } from 'app/common/urlUtils'; +import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs'; + +const cssBody = styled('div', ` + padding: 20px; + overflow: auto; +`); + +const cssHeader = styled('div', ` + padding: 20px; +`); + +const cssResult = styled('div', ` + max-width: 500px; +`); + +/** + * + * A "boot" page for inspecting the state of the Grist installation. + * + * TODO: deferring using any localization machinery so as not + * to have to worry about its failure modes yet, but it should be + * fine as long as assets served locally are used. + * + */ +export class Boot extends Disposable { + + // The back end will offer a set of probes (diagnostics) we + // can use. Probes have unique IDs. + public probes: Observable; + + // Keep track of probe results we have received, by probe ID. + public results: Map>; + + // Keep track of probe requests we are making, by probe ID. + public requests: Map; + + constructor(_appModel: AppModel) { + super(); + // Setting title in constructor seems to be how we are doing this, + // based on other similar pages. + document.title = 'Booting Grist'; + this.probes = Observable.create(this, []); + this.results = new Map(); + this.requests = new Map(); + } + + /** + * Set up the page. Uses the generic Grist layout with an empty + * side panel, just for convenience. Could be made a lot prettier. + */ + public buildDom() { + const config = getGristConfig(); + const errMessage = config.errMessage; + if (!errMessage) { + // Probe tool URLs are relative to the current URL. Don't trust configuration, + // because it may be buggy if the user is here looking at the boot page + // to figure out some problem. + const url = new URL(removeTrailingSlash(document.location.href)); + url.pathname += '/probe'; + fetch(url.href).then(async resp => { + const _probes = await resp.json(); + this.probes.set(_probes.probes); + }).catch(e => reportError(e)); + } + + const rootNode = dom('div', + dom.domComputed( + use => { + return pagePanels({ + leftPanel: { + panelWidth: Observable.create(this, 240), + panelOpen: Observable.create(this, false), + hideOpener: true, + header: null, + content: null, + }, + headerMain: cssHeader(dom('h1', 'Grist Boot')), + contentMain: this.buildBody(use, {errMessage}), + }); + } + ), + ); + return rootNode; + } + + /** + * The body of the page is very simple right now, basically a + * placeholder. Make a section for each probe, and kick them off in + * parallel, showing results as they come in. + */ + public buildBody(use: UseCBOwner, options: {errMessage?: string}) { + if (options.errMessage) { + return cssBody(cssResult(this.buildError())); + } + return cssBody([ + ...use(this.probes).map(probe => { + const {id} = probe; + let result = this.results.get(id); + if (!result) { + result = Observable.create(this, {}); + this.results.set(id, result); + } + let request = this.requests.get(id); + if (!request) { + request = new BootProbe(id, this); + this.requests.set(id, request); + } + request.start(); + return cssResult( + this.buildResult(probe, use(result), probeDetails[id])); + }), + ]); + } + + /** + * This is used when there is an attempt to access the boot page + * but something isn't right - either the page isn't enabled, or + * the key in the URL is wrong. Give the user some information about + * how to set things up. + */ + public buildError() { + return dom( + 'div', + dom('p', + 'A diagnostics page can be made available at:', + dom('blockquote', '/boot/GRIST_BOOT_KEY'), + 'GRIST_BOOT_KEY is an environment variable ', + ' set before Grist starts. It should only', + ' contain characters that are valid in a URL.', + ' It should be a secret, since no authentication is needed', + ' to visit the diagnostics page.'), + dom('p', + 'You are seeing this page because either the key is not set,', + ' or it is not in the URL.'), + ); + } + + /** + * An ugly rendering of information returned by the probe. + */ + public buildResult(info: BootProbeInfo, result: BootProbeResult, + details: ProbeDetails|undefined) { + const out: (HTMLElement|string|null)[] = []; + out.push(dom('h2', info.name)); + if (details) { + out.push(dom('p', '> ', details.info)); + } + if (result.verdict) { + out.push(dom('pre', result.verdict)); + } + if (result.success !== undefined) { + out.push(result.success ? '✅' : '❌'); + } + if (result.done === true) { + out.push(dom('p', 'no fault detected')); + } + if (result.details) { + for (const [key, val] of Object.entries(result.details)) { + out.push(dom( + 'div', + key, + dom('input', dom.prop('value', JSON.stringify(val))))); + } + } + return out; + } +} + +/** + * Represents a single diagnostic. + */ +export class BootProbe { + constructor(public id: string, public boot: Boot) { + const url = new URL(removeTrailingSlash(document.location.href)); + url.pathname = url.pathname + '/probe/' + id; + fetch(url.href).then(async resp => { + const _probes: BootProbeResult = await resp.json(); + const ob = boot.results.get(id); + if (ob) { + ob.set(_probes); + } + }).catch(e => console.error(e)); + } + + public start() { + let result = this.boot.results.get(this.id); + if (!result) { + result = Observable.create(this.boot, {}); + this.boot.results.set(this.id, result); + } + } +} + +/** + * Create a stripped down page to show boot information. + * Make sure the API isn't used since it may well be unreachable + * due to a misconfiguration, especially in multi-server setups. + */ +createAppPage(appModel => { + return dom.create(Boot, appModel); +}, { + useApi: false, +}); + +/** + * Basic information about diagnostics is kept on the server, + * but it can be useful to show extra details and tips in the + * client. + */ +const probeDetails: Record = { + 'boot-page': { + info: ` +This boot page should not be too easy to access. Either turn +it off when configuration is ok (by unsetting GRIST_BOOT_KEY) +or make GRIST_BOOT_KEY long and cryptographically secure. +`, + }, + + 'health-check': { + info: ` +Grist has a small built-in health check often used when running +it as a container. +`, + }, + + 'host-header': { + info: ` +Requests arriving to Grist should have an accurate Host +header. This is essential when GRIST_SERVE_SAME_ORIGIN +is set. +`, + }, + + 'system-user': { + info: ` +It is good practice not to run Grist as the root user. +`, + }, + + 'reachable': { + info: ` +The main page of Grist should be available. +` + }, +}; + +/** + * Information about the probe. + */ +interface ProbeDetails { + info: string; +} + diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index d3f16f46..555dd66d 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -146,6 +146,11 @@ export interface AppModel { switchUser(user: FullUser, org?: string): Promise; } +export interface TopAppModelOptions { + /** Defaults to true. */ + useApi?: boolean; +} + export class TopAppModelImpl extends Disposable implements TopAppModel { public readonly isSingleOrg: boolean; public readonly productFlavor: ProductFlavor; @@ -163,14 +168,16 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { // up new widgets - that seems ok. private readonly _widgets: AsyncCreate; - constructor(window: {gristConfig?: GristLoadConfig}, public readonly api: UserAPI = newUserAPIImpl()) { + constructor(window: {gristConfig?: GristLoadConfig}, + public readonly api: UserAPI = newUserAPIImpl(), + public readonly options: TopAppModelOptions = {}) { super(); setErrorNotifier(this.notifier); this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg); this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org); this._gristConfig = window.gristConfig; this._widgets = new AsyncCreate(async () => { - const widgets = await this.api.getWidgets(); + const widgets = this.options.useApi === false ? [] : await this.api.getWidgets(); this.customWidgets.set(widgets); return widgets; }); @@ -180,7 +187,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize())); this.plugins = this._gristConfig?.plugins || []; - this.fetchUsersAndOrgs().catch(reportError); + if (this.options.useApi !== false) { + this.fetchUsersAndOrgs().catch(reportError); + } } public initialize(): void { @@ -237,6 +246,10 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { private async _doInitialize() { this.appObs.set(null); + if (this.options.useApi === false) { + AppModelImpl.create(this.appObs, this, null, null, {error: 'no-api', status: 500}); + return; + } try { const {user, org, orgError} = await this.api.getSessionActive(); if (this.isDisposed()) { return; } diff --git a/app/client/ui/createAppPage.ts b/app/client/ui/createAppPage.ts index dfe242f9..f5840d70 100644 --- a/app/client/ui/createAppPage.ts +++ b/app/client/ui/createAppPage.ts @@ -1,6 +1,6 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {setupLocale} from 'app/client/lib/localization'; -import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel'; +import {AppModel, TopAppModelImpl, TopAppModelOptions} from 'app/client/models/AppModel'; import {reportError, setUpErrorHandling} from 'app/client/models/errors'; import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; import {addViewportTag} from 'app/client/ui/viewport'; @@ -14,10 +14,12 @@ const G = getBrowserGlobals('document', 'window'); * Sets up the application model, error handling, and global styles, and replaces * the DOM body with the result of calling `buildAppPage`. */ -export function createAppPage(buildAppPage: (appModel: AppModel) => DomContents) { +export function createAppPage( + buildAppPage: (appModel: AppModel) => DomContents, + modelOptions: TopAppModelOptions = {}) { setUpErrorHandling(); - const topAppModel = TopAppModelImpl.create(null, {}); + const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions); addViewportTag(); attachCssRootVars(topAppModel.productFlavor); diff --git a/app/common/BootProbe.ts b/app/common/BootProbe.ts new file mode 100644 index 00000000..5f0ee785 --- /dev/null +++ b/app/common/BootProbe.ts @@ -0,0 +1,22 @@ + +export type BootProbeIds = + 'boot-page' | + 'health-check' | + 'reachable' | + 'host-header' | + 'system-user' +; + +export interface BootProbeResult { + verdict?: string; + success?: boolean; + done?: boolean; + severity?: 'fault' | 'warning' | 'hmm'; + details?: Record; +} + +export interface BootProbeInfo { + id: BootProbeIds; + name: string; +} + diff --git a/app/server/lib/BootProbes.ts b/app/server/lib/BootProbes.ts new file mode 100644 index 00000000..dad91f7e --- /dev/null +++ b/app/server/lib/BootProbes.ts @@ -0,0 +1,185 @@ +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 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) { + this._addProbes(); + } + + public addEndpoints() { + // Return a list of available probes. + this._app.use(`${this._base}/probe$`, 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}/probe/:probeId`, 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}/probe`, jsonErrorHandler); + } + + private _addProbes() { + this._probes.push(_homeUrlReachableProbe); + this._probes.push(_statusCheckProbe); + this._probes.push(_userProbe); + this._probes.push(_bootProbe); + this._probes.push(_hostHeaderProbe); + 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: 'Grist is reachable', + apply: async (server, req) => { + const url = server.getHomeUrl(req); + try { + const resp = await fetch(url); + if (resp.status !== 200) { + throw new ApiError(await resp.text(), resp.status); + } + return { + success: true, + }; + } catch (e) { + return { + success: false, + details: { + error: String(e), + }, + severity: 'fault', + }; + } + } +}; + +const _statusCheckProbe: Probe = { + id: 'health-check', + name: 'Built-in Health check', + apply: async (server, req) => { + const baseUrl = server.getHomeUrl(req); + const url = new URL(baseUrl); + url.pathname = removeTrailingSlash(url.pathname) + '/status'; + try { + const resp = await fetch(url); + 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 { + success: true, + }; + } catch (e) { + return { + success: false, + error: String(e), + severity: 'fault', + }; + } + }, +}; + +const _userProbe: Probe = { + id: 'system-user', + name: 'System user is sane', + apply: async () => { + if (process.getuid && process.getuid() === 0) { + return { + success: false, + verdict: 'User appears to be root (UID 0)', + severity: 'warning', + }; + } else { + return { + success: true, + }; + } + }, +}; + +const _bootProbe: Probe = { + id: 'boot-page', + name: 'Boot page exposure', + apply: async (server) => { + if (!server.hasBoot) { + return { success: true }; + } + const maybeSecureEnough = String(process.env.GRIST_BOOT_KEY).length > 10; + return { + success: maybeSecureEnough, + severity: 'hmm', + }; + }, +}; + +/** + * 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: 'Host header is sane', + apply: async (server, req) => { + const host = req.header('host'); + const url = new URL(server.getHomeUrl(req)); + if (url.hostname === 'localhost') { + return { + done: true, + }; + } + if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) { + return { + success: false, + severity: 'hmm', + }; + } + return { + done: true, + }; + }, +}; diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index c1127958..86ff54ad 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -28,6 +28,7 @@ import {appSettings} from 'app/server/lib/AppSettings'; import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser, isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer'; import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer'; +import {BootProbes} from 'app/server/lib/BootProbes'; import {forceSessionChange} from 'app/server/lib/BrowserSession'; import {Comm} from 'app/server/lib/Comm'; import {create} from 'app/server/lib/create'; @@ -175,6 +176,7 @@ export class FlexServer implements GristServer { private _getLoginSystem?: () => Promise; // Set once ready() is called private _isReady: boolean = false; + private _probes: BootProbes; constructor(public port: number, public name: string = 'flexServer', public readonly options: FlexServerOptions = {}) { @@ -481,6 +483,57 @@ export class FlexServer implements GristServer { }); } + /** + * + * Adds a /boot/$GRIST_BOOT_KEY page that shows diagnostics. + * Accepts any /boot/... URL in order to let the front end + * give some guidance if the user is stumbling around trying + * to find the boot page, but won't actually provide diagnostics + * unless GRIST_BOOT_KEY is set in the environment, and is present + * in the URL. + * + * We take some steps to make the boot page available even when + * things are going wrong, and should take more in future. + * + * When rendering the page a hardcoded 'boot' tag is used, which + * is used to ensure that static assets are served locally and + * we aren't relying on APP_STATIC_URL being set correctly. + * + * We use a boot key so that it is more acceptable to have this + * boot page living outside of the authentication system, which + * could be broken. + * + * TODO: there are some configuration problems that currently + * result in Grist not running at all. ideally they would result in + * Grist running in a limited mode that is enough to bring up the boot + * page. + * + */ + public addBootPage() { + if (this._check('boot')) { return; } + const bootKey = appSettings.section('boot').flag('key').readString({ + envVar: 'GRIST_BOOT_KEY' + }); + const base = `/boot/${bootKey}`; + this._probes = new BootProbes(this.app, this, base); + // Respond to /boot, /boot/, /boot/KEY, /boot/KEY/ to give + // a helpful message even if user gets KEY wrong or omits it. + this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => { + const goodKey = bootKey && req.params.bootKey === bootKey; + return this._sendAppPage(req, res, { + path: 'boot.html', status: 200, config: goodKey ? { + } : { + errMessage: 'not-the-key', + }, tag: 'boot', + }); + }); + this._probes.addEndpoints(); + } + + public hasBoot(): boolean { + return Boolean(this._probes); + } + public denyRequestsIfNotReady() { this.app.use((_req, res, next) => { if (!this._isReady) { diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index ac4dbd1c..75482aa0 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -60,6 +60,7 @@ export interface GristServer { getPlugins(): LocalPlugin[]; servesPlugins(): boolean; getBundledWidgets(): ICustomWidget[]; + hasBoot(): boolean; } export interface GristLoginSystem { @@ -147,6 +148,7 @@ export function createDummyGristServer(): GristServer { servesPlugins() { return false; }, getPlugins() { return []; }, getBundledWidgets() { return []; }, + hasBoot() { return false; }, }; } diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 1c2d5f45..1647b4d4 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -139,8 +139,11 @@ export function makeSendAppPage(opts: { const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) || options.googleTagManager === true; const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : ''; - const staticOrigin = process.env.APP_STATIC_URL || ""; - const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`; + const staticTag = options.tag || tag; + // If boot tag is used, serve assets locally, otherwise respect + // APP_STATIC_URL. + const staticOrigin = staticTag === 'boot' ? '' : (process.env.APP_STATIC_URL || ''); + const staticBaseUrl = `${staticOrigin}/v/${staticTag}/`; const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? ""; const warning = testLogin ? "
Authentication is not enforced
" : ""; // Preload all languages that will be used or are requested by client. diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index 6f2817f1..3a8ea22f 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -104,6 +104,9 @@ export async function main(port: number, serverTypes: ServerType[], } server.addHealthCheck(); + if (includeHome || includeApp) { + server.addBootPage(); + } server.denyRequestsIfNotReady(); if (includeHome || includeStatic || includeApp) { diff --git a/buildtools/webpack.config.js b/buildtools/webpack.config.js index eb0bca39..297c27fe 100644 --- a/buildtools/webpack.config.js +++ b/buildtools/webpack.config.js @@ -14,6 +14,7 @@ module.exports = { main: "app/client/app", errorPages: "app/client/errorMain", apiconsole: "app/client/apiconsole", + boot: "app/client/boot", billing: "app/client/billingMain", form: "app/client/formMain", // Include client test harness if it is present (it won't be in diff --git a/static/boot.html b/static/boot.html new file mode 100644 index 00000000..8f67607f --- /dev/null +++ b/static/boot.html @@ -0,0 +1,15 @@ + + + + + + + + + + Loading...<!-- INSERT TITLE SUFFIX --> + + + + + diff --git a/test/nbrowser/Boot.ts b/test/nbrowser/Boot.ts new file mode 100644 index 00000000..230adc85 --- /dev/null +++ b/test/nbrowser/Boot.ts @@ -0,0 +1,52 @@ +import {assert, driver} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('Boot', function() { + this.timeout(30000); + setupTestSuite(); + + let oldEnv: testUtils.EnvironmentSnapshot; + + afterEach(() => gu.checkForErrors()); + + async function hasPrompt() { + assert.include( + await driver.findContentWait('p', /diagnostics page/, 2000).getText(), + 'A diagnostics page can be made available'); + } + + it('gives prompt about how to enable boot page', async function() { + await driver.get(`${server.getHost()}/boot`); + await hasPrompt(); + }); + + describe('with a GRIST_BOOT_KEY', function() { + before(async function() { + oldEnv = new testUtils.EnvironmentSnapshot(); + process.env.GRIST_BOOT_KEY = 'lala'; + await server.restart(); + }); + + after(async function() { + oldEnv.restore(); + await server.restart(); + }); + + it('gives prompt when key is missing', async function() { + await driver.get(`${server.getHost()}/boot`); + await hasPrompt(); + }); + + it('gives prompt when key is wrong', async function() { + await driver.get(`${server.getHost()}/boot/bilbo`); + await hasPrompt(); + }); + + it('gives page when key is right', async function() { + await driver.get(`${server.getHost()}/boot/lala`); + await driver.findContentWait('h2', /Grist is reachable/, 2000); + }); + }); +});