make a /boot/GRIST_BOOT_KEY page for diagnosing configuration problems

This is a start at a page for diagnosing problems while setting up
Grist. Starting to add some diagnostics based on feedback in github
issues. We should make Grist installation easier! But when there is
a problem it should be easier to diagnose than it is now, and this
may help.
pull/850/head
Paul Fitzpatrick 4 months ago
parent 2693e01d08
commit 07ce8bfcf6
No known key found for this signature in database
GPG Key ID: 07F16BF3214888F6

@ -0,0 +1,227 @@
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;
max-width: 500px;
`);
const cssHeader = styled('div', `
padding: 20px;
`);
/**
* A "boot" page for inspecting the state of the Grist installation.
* The page should ideally be visible even if a lot is wrong with the,
* installation, so avoid introducing dependenceis on middleware, or
* authentication, or anything else that could break. 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.
*
* TODO: deferring using any internationalization machinery so as not
* to have to worry about its failure modes yet, but should be
* straightforward really.
*/
export class Boot extends Disposable {
public probes: Observable<BootProbeInfo[]>;
public results: Map<string, Observable<BootProbeResult>>;
public requests: Map<string, BootProbe>;
constructor(_appModel: AppModel) {
super();
// Setting title in constructor seems to be how we are doing this?
document.title = 'Booting Grist';
this.probes = Observable.create(this, []);
this.results = new Map();
this.requests = new Map();
}
public buildDom() {
const config = getGristConfig();
const errMessage = config.errMessage;
if (!errMessage) {
// Probe tool URLs are relative to current URL. Don't trust configuration.
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname = url.pathname + '/probe';
fetch(url.href).then(async resp => {
const _probes = await resp.json();
this.probes.set(_probes.probes);
});
}
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 main;
}
),
);
return rootNode;
}
public buildBody(use: UseCBOwner, options: {errMessage?: string}) {
const p = use(this.probes);
const main = options.errMessage ? this.buildError() : [
...p.map(p0 => {
const {id} = p0;
let ob = this.results.get(id);
if (!ob) {
ob = Observable.create(this, {});
this.results.set(id, ob);
}
let ob2 = this.requests.get(id);
if (!ob2) {
ob2 = new BootProbe(id, this);
this.requests.set(id, ob2);
}
ob2.start();
const result = use(ob);
const deets = probeDetails[id];
return this.buildResult(p0, result, deets);
}),
];
return cssBody(main);
}
public buildError() {
return dom(
'div',
'A diagnostics page is 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.',
);
}
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 ob = this.boot.results.get(this.id);
if (!ob) {
ob = Observable.create(this.boot, {});
this.boot.results.set(this.id, ob);
}
}
}
/**
* Create a stripped down page to show boot information.
* Make sure the API isn't used since it may well be broken.
*/
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.
*/
interface ProbeDetails {
info: string;
}
const probeDetails: Record<string, ProbeDetails> = {
'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.
`
},
}

@ -146,6 +146,11 @@ export interface AppModel {
switchUser(user: FullUser, org?: string): Promise<void>;
}
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<ICustomWidget[]>;
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<ICustomWidget[]>(async () => {
const widgets = await this.api.getWidgets();
const widgets = this.options.useApi ? (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) {
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: 'zing', status: 500});
return;
}
try {
const {user, org, orgError} = await this.api.getSessionActive();
if (this.isDisposed()) { return; }

@ -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);

@ -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<string, any>;
}
export interface BootProbeInfo {
id: BootProbeIds;
name: string;
}

@ -0,0 +1,175 @@
import { BootProbeIds, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
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 {
public probes = new Array<Probe>();
public constructor(public app: express.Application,
public server: GristServer,
public base: string) {
}
public addProbes() {
this.probes.push(_homeUrlReachableProbe);
this.probes.push(_statusCheckProbe);
this.probes.push(_userProbe);
this.probes.push(_bootProbe);
this.probes.push(_hostHeaderProbe);
this.app.use(`${this.base}/probe$`, async (_, res) => {
res.json({
'probes': this.probes.map(probe => {
return { id: probe.id, name: probe.name };
}),
});
});
}
// NEED TO UPDATE WITH AUTH
public addEndpoints() {
for (const probe of this.probes) {
this.app.use(`${this.base}/probe/${probe.id}$`, async (req, res) => {
const result = await probe.apply(this.server, req);
res.json(result);
});
}
}
}
export interface Probe {
id: BootProbeIds;
name: string;
description?: string;
apply: (server: GristServer, req: express.Request) => Promise<BootProbeResult>;
}
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) {
return {
success: false,
severity: 'fault',
details: {
error: await resp.text(),
status: 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,
};
},
}

@ -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<GristLoginSystem>;
// 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,34 @@ export class FlexServer implements GristServer {
});
}
public addBootPage() {
if (this._check('boot')) { return; }
const bootKey = appSettings.section('boot').flag('key').readString({
envVar: 'GRIST_BOOT_KEY'
});
if (!bootKey) { return; }
const base = `/boot/${bootKey}`;
this._probes = new BootProbes(this.app, this, base);
this.app.get('/boot(/:bootKey/?)?$', async (req, res) => {
const goodKey = req.params.bootKey === bootKey;
return this._sendAppPage(req, res, {
path: 'boot.html', status: 200, config: goodKey ? {
} : {
errMessage: 'wo wo',
}, tag: 'boot',
});
});
this._probes.addProbes();
this._probes.addEndpoints();
//this.app.get(base, async (req, res) => {
//this._sendAppPage(req, res, {path: 'error.html', status: 200, config: {errPage: 'access-denied'}});
//});
}
public hasBoot(): boolean {
return Boolean(this._probes);
}
public denyRequestsIfNotReady() {
this.app.use((_req, res, next) => {
if (!this._isReady) {

@ -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; },
};
}

@ -104,6 +104,7 @@ export async function main(port: number, serverTypes: ServerType[],
}
server.addHealthCheck();
server.addBootPage();
server.denyRequestsIfNotReady();
if (includeHome || includeStatic || includeApp) {

@ -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

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<!-- INSERT BASE -->
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
<link rel="stylesheet" href="icons/icons.css">
<!-- INSERT LOCALE -->
<!-- INSERT CONFIG -->
<title>Loading...<!-- INSERT TITLE SUFFIX --></title>
</head>
<body>
<script crossorigin="anonymous" src="boot.bundle.js"></script>
</body>
</html>
Loading…
Cancel
Save