mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
make a /boot/GRIST_BOOT_KEY page for diagnosing configuration problems (#850)
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. The page is ugly and doesn't have many diagnostics yet, but we can iterate. Visit `/boot` on a Grist server for tips on how to use this feature.
This commit is contained in:
parent
2693e01d08
commit
95b734149e
259
app/client/boot.ts
Normal file
259
app/client/boot.ts
Normal file
@ -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<BootProbeInfo[]>;
|
||||||
|
|
||||||
|
// Keep track of probe results we have received, by probe ID.
|
||||||
|
public results: Map<string, Observable<BootProbeResult>>;
|
||||||
|
|
||||||
|
// Keep track of probe requests we are making, by probe ID.
|
||||||
|
public requests: Map<string, BootProbe>;
|
||||||
|
|
||||||
|
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<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.
|
||||||
|
`
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about the probe.
|
||||||
|
*/
|
||||||
|
interface ProbeDetails {
|
||||||
|
info: string;
|
||||||
|
}
|
||||||
|
|
@ -146,6 +146,11 @@ export interface AppModel {
|
|||||||
switchUser(user: FullUser, org?: string): Promise<void>;
|
switchUser(user: FullUser, org?: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopAppModelOptions {
|
||||||
|
/** Defaults to true. */
|
||||||
|
useApi?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class TopAppModelImpl extends Disposable implements TopAppModel {
|
export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||||
public readonly isSingleOrg: boolean;
|
public readonly isSingleOrg: boolean;
|
||||||
public readonly productFlavor: ProductFlavor;
|
public readonly productFlavor: ProductFlavor;
|
||||||
@ -163,14 +168,16 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
|||||||
// up new widgets - that seems ok.
|
// up new widgets - that seems ok.
|
||||||
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
|
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();
|
super();
|
||||||
setErrorNotifier(this.notifier);
|
setErrorNotifier(this.notifier);
|
||||||
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
||||||
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
|
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
|
||||||
this._gristConfig = window.gristConfig;
|
this._gristConfig = window.gristConfig;
|
||||||
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
|
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
|
||||||
const widgets = await this.api.getWidgets();
|
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
|
||||||
this.customWidgets.set(widgets);
|
this.customWidgets.set(widgets);
|
||||||
return widgets;
|
return widgets;
|
||||||
});
|
});
|
||||||
@ -180,7 +187,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
|||||||
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
|
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
|
||||||
this.plugins = this._gristConfig?.plugins || [];
|
this.plugins = this._gristConfig?.plugins || [];
|
||||||
|
|
||||||
this.fetchUsersAndOrgs().catch(reportError);
|
if (this.options.useApi !== false) {
|
||||||
|
this.fetchUsersAndOrgs().catch(reportError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
public initialize(): void {
|
||||||
@ -237,6 +246,10 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
|||||||
|
|
||||||
private async _doInitialize() {
|
private async _doInitialize() {
|
||||||
this.appObs.set(null);
|
this.appObs.set(null);
|
||||||
|
if (this.options.useApi === false) {
|
||||||
|
AppModelImpl.create(this.appObs, this, null, null, {error: 'no-api', status: 500});
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const {user, org, orgError} = await this.api.getSessionActive();
|
const {user, org, orgError} = await this.api.getSessionActive();
|
||||||
if (this.isDisposed()) { return; }
|
if (this.isDisposed()) { return; }
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||||
import {setupLocale} from 'app/client/lib/localization';
|
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 {reportError, setUpErrorHandling} from 'app/client/models/errors';
|
||||||
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
|
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
|
||||||
import {addViewportTag} from 'app/client/ui/viewport';
|
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
|
* Sets up the application model, error handling, and global styles, and replaces
|
||||||
* the DOM body with the result of calling `buildAppPage`.
|
* 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();
|
setUpErrorHandling();
|
||||||
|
|
||||||
const topAppModel = TopAppModelImpl.create(null, {});
|
const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions);
|
||||||
|
|
||||||
addViewportTag();
|
addViewportTag();
|
||||||
attachCssRootVars(topAppModel.productFlavor);
|
attachCssRootVars(topAppModel.productFlavor);
|
||||||
|
22
app/common/BootProbe.ts
Normal file
22
app/common/BootProbe.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
185
app/server/lib/BootProbes.ts
Normal file
185
app/server/lib/BootProbes.ts
Normal file
@ -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<Probe>();
|
||||||
|
|
||||||
|
// Probes indexed by id.
|
||||||
|
public _probeById = new Map<string, Probe>();
|
||||||
|
|
||||||
|
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<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) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
@ -28,6 +28,7 @@ import {appSettings} from 'app/server/lib/AppSettings';
|
|||||||
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
|
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
|
||||||
isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
|
isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
|
||||||
import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} 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 {forceSessionChange} from 'app/server/lib/BrowserSession';
|
||||||
import {Comm} from 'app/server/lib/Comm';
|
import {Comm} from 'app/server/lib/Comm';
|
||||||
import {create} from 'app/server/lib/create';
|
import {create} from 'app/server/lib/create';
|
||||||
@ -175,6 +176,7 @@ export class FlexServer implements GristServer {
|
|||||||
private _getLoginSystem?: () => Promise<GristLoginSystem>;
|
private _getLoginSystem?: () => Promise<GristLoginSystem>;
|
||||||
// Set once ready() is called
|
// Set once ready() is called
|
||||||
private _isReady: boolean = false;
|
private _isReady: boolean = false;
|
||||||
|
private _probes: BootProbes;
|
||||||
|
|
||||||
constructor(public port: number, public name: string = 'flexServer',
|
constructor(public port: number, public name: string = 'flexServer',
|
||||||
public readonly options: FlexServerOptions = {}) {
|
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() {
|
public denyRequestsIfNotReady() {
|
||||||
this.app.use((_req, res, next) => {
|
this.app.use((_req, res, next) => {
|
||||||
if (!this._isReady) {
|
if (!this._isReady) {
|
||||||
|
@ -60,6 +60,7 @@ export interface GristServer {
|
|||||||
getPlugins(): LocalPlugin[];
|
getPlugins(): LocalPlugin[];
|
||||||
servesPlugins(): boolean;
|
servesPlugins(): boolean;
|
||||||
getBundledWidgets(): ICustomWidget[];
|
getBundledWidgets(): ICustomWidget[];
|
||||||
|
hasBoot(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GristLoginSystem {
|
export interface GristLoginSystem {
|
||||||
@ -147,6 +148,7 @@ export function createDummyGristServer(): GristServer {
|
|||||||
servesPlugins() { return false; },
|
servesPlugins() { return false; },
|
||||||
getPlugins() { return []; },
|
getPlugins() { return []; },
|
||||||
getBundledWidgets() { return []; },
|
getBundledWidgets() { return []; },
|
||||||
|
hasBoot() { return false; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,8 +139,11 @@ export function makeSendAppPage(opts: {
|
|||||||
const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) ||
|
const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) ||
|
||||||
options.googleTagManager === true;
|
options.googleTagManager === true;
|
||||||
const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : '';
|
const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : '';
|
||||||
const staticOrigin = process.env.APP_STATIC_URL || "";
|
const staticTag = options.tag || tag;
|
||||||
const staticBaseUrl = `${staticOrigin}/v/${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 customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? "";
|
||||||
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
|
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
|
||||||
// Preload all languages that will be used or are requested by client.
|
// Preload all languages that will be used or are requested by client.
|
||||||
|
@ -104,6 +104,9 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
}
|
}
|
||||||
|
|
||||||
server.addHealthCheck();
|
server.addHealthCheck();
|
||||||
|
if (includeHome || includeApp) {
|
||||||
|
server.addBootPage();
|
||||||
|
}
|
||||||
server.denyRequestsIfNotReady();
|
server.denyRequestsIfNotReady();
|
||||||
|
|
||||||
if (includeHome || includeStatic || includeApp) {
|
if (includeHome || includeStatic || includeApp) {
|
||||||
|
@ -14,6 +14,7 @@ module.exports = {
|
|||||||
main: "app/client/app",
|
main: "app/client/app",
|
||||||
errorPages: "app/client/errorMain",
|
errorPages: "app/client/errorMain",
|
||||||
apiconsole: "app/client/apiconsole",
|
apiconsole: "app/client/apiconsole",
|
||||||
|
boot: "app/client/boot",
|
||||||
billing: "app/client/billingMain",
|
billing: "app/client/billingMain",
|
||||||
form: "app/client/formMain",
|
form: "app/client/formMain",
|
||||||
// Include client test harness if it is present (it won't be in
|
// Include client test harness if it is present (it won't be in
|
||||||
|
15
static/boot.html
Normal file
15
static/boot.html
Normal file
@ -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>
|
52
test/nbrowser/Boot.ts
Normal file
52
test/nbrowser/Boot.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user