mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) add a sandbox check to admin panel, and start reconciling boot and admin pages
Summary: This adds a basic sandbox check to the admin panel. It also makes the "probes" used in the boot page available from the admin panel, though they are not yet displayed. The sandbox check is built as a probe. In the interests of time, a lot of steps had to be deferred: * Reconcile fully the admin panel and boot page. Specifically, the admin panel should be equally robust to common configuration problems. * Add tests for the sandbox check. * Generalize to multi-server setups. The read-out will not yet be useful for setups where doc workers and home servers are configured separately. Test Plan: Added new test Reviewers: jarek, georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4241
This commit is contained in:
parent
61cb80d4e3
commit
d431c1eb63
@ -1,8 +1,8 @@
|
|||||||
import { AppModel } from 'app/client/models/AppModel';
|
import { AppModel } from 'app/client/models/AppModel';
|
||||||
|
import { AdminChecks, ProbeDetails } from 'app/client/models/AdminChecks';
|
||||||
import { createAppPage } from 'app/client/ui/createAppPage';
|
import { createAppPage } from 'app/client/ui/createAppPage';
|
||||||
import { pagePanels } from 'app/client/ui/PagePanels';
|
import { pagePanels } from 'app/client/ui/PagePanels';
|
||||||
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
|
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
|
||||||
import { removeTrailingSlash } from 'app/common/gutil';
|
|
||||||
import { getGristConfig } from 'app/common/urlUtils';
|
import { getGristConfig } from 'app/common/urlUtils';
|
||||||
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
|
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
|
||||||
|
|
||||||
@ -30,24 +30,14 @@ const cssResult = styled('div', `
|
|||||||
*/
|
*/
|
||||||
export class Boot extends Disposable {
|
export class Boot extends Disposable {
|
||||||
|
|
||||||
// The back end will offer a set of probes (diagnostics) we
|
private _checks: AdminChecks;
|
||||||
// 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) {
|
constructor(_appModel: AppModel) {
|
||||||
super();
|
super();
|
||||||
// Setting title in constructor seems to be how we are doing this,
|
// Setting title in constructor seems to be how we are doing this,
|
||||||
// based on other similar pages.
|
// based on other similar pages.
|
||||||
document.title = 'Booting Grist';
|
document.title = 'Booting Grist';
|
||||||
this.probes = Observable.create(this, []);
|
this._checks = new AdminChecks(this);
|
||||||
this.results = new Map();
|
|
||||||
this.requests = new Map();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,20 +45,10 @@ export class Boot extends Disposable {
|
|||||||
* side panel, just for convenience. Could be made a lot prettier.
|
* side panel, just for convenience. Could be made a lot prettier.
|
||||||
*/
|
*/
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
|
this._checks.fetchAvailableChecks().catch(e => reportError(e));
|
||||||
|
|
||||||
const config = getGristConfig();
|
const config = getGristConfig();
|
||||||
const errMessage = config.errMessage;
|
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',
|
const rootNode = dom('div',
|
||||||
dom.domComputed(
|
dom.domComputed(
|
||||||
use => {
|
use => {
|
||||||
@ -99,21 +79,10 @@ export class Boot extends Disposable {
|
|||||||
return cssBody(cssResult(this.buildError()));
|
return cssBody(cssResult(this.buildError()));
|
||||||
}
|
}
|
||||||
return cssBody([
|
return cssBody([
|
||||||
...use(this.probes).map(probe => {
|
...use(this._checks.probes).map(probe => {
|
||||||
const {id} = probe;
|
const req = this._checks.requestCheck(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(
|
return cssResult(
|
||||||
this.buildResult(probe, use(result), probeDetails[id]));
|
this.buildResult(req.probe, use(req.result), req.details));
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -164,7 +133,7 @@ export class Boot extends Disposable {
|
|||||||
for (const [key, val] of Object.entries(result.details)) {
|
for (const [key, val] of Object.entries(result.details)) {
|
||||||
out.push(dom(
|
out.push(dom(
|
||||||
'div',
|
'div',
|
||||||
key,
|
cssLabel(key),
|
||||||
dom('input', dom.prop('value', JSON.stringify(val)))));
|
dom('input', dom.prop('value', JSON.stringify(val)))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -172,31 +141,6 @@ export class Boot extends Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* Create a stripped down page to show boot information.
|
||||||
* Make sure the API isn't used since it may well be unreachable
|
* Make sure the API isn't used since it may well be unreachable
|
||||||
@ -208,52 +152,9 @@ createAppPage(appModel => {
|
|||||||
useApi: false,
|
useApi: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const cssLabel = styled('div', `
|
||||||
* Basic information about diagnostics is kept on the server,
|
display: inline-block;
|
||||||
* but it can be useful to show extra details and tips in the
|
min-width: 100px;
|
||||||
* client.
|
text-align: right;
|
||||||
*/
|
padding-right: 5px;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
165
app/client/models/AdminChecks.ts
Normal file
165
app/client/models/AdminChecks.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { BootProbeIds, BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
|
||||||
|
import { removeTrailingSlash } from 'app/common/gutil';
|
||||||
|
import { getGristConfig } from 'app/common/urlUtils';
|
||||||
|
import { Disposable, Observable, UseCBOwner } from 'grainjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage a collection of checks about the status of Grist, for
|
||||||
|
* presentation on the admin panel or the boot page.
|
||||||
|
*/
|
||||||
|
export class AdminChecks {
|
||||||
|
|
||||||
|
// 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 requests we are making, by probe ID.
|
||||||
|
private _requests: Map<string, AdminCheckRunner>;
|
||||||
|
|
||||||
|
// Keep track of probe results we have received, by probe ID.
|
||||||
|
private _results: Map<string, Observable<BootProbeResult>>;
|
||||||
|
|
||||||
|
constructor(private _parent: Disposable) {
|
||||||
|
this.probes = Observable.create(_parent, []);
|
||||||
|
this._results = new Map();
|
||||||
|
this._requests = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a list of available checks from the server.
|
||||||
|
*/
|
||||||
|
public async fetchAvailableChecks() {
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// We have been careful to make URLs available with appropriate
|
||||||
|
// middleware relative to both of the admin panel and the boot page.
|
||||||
|
const url = new URL(removeTrailingSlash(document.location.href));
|
||||||
|
url.pathname += '/probe';
|
||||||
|
const resp = await fetch(url.href);
|
||||||
|
const _probes = await resp.json();
|
||||||
|
this.probes.set(_probes.probes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the result of one of the available checks. Returns information
|
||||||
|
* about the check and a way to observe the result when it arrives.
|
||||||
|
*/
|
||||||
|
public requestCheck(probe: BootProbeInfo): AdminCheckRequest {
|
||||||
|
const {id} = probe;
|
||||||
|
let result = this._results.get(id);
|
||||||
|
if (!result) {
|
||||||
|
result = Observable.create(this._parent, {});
|
||||||
|
this._results.set(id, result);
|
||||||
|
}
|
||||||
|
let request = this._requests.get(id);
|
||||||
|
if (!request) {
|
||||||
|
request = new AdminCheckRunner(id, this._results, this._parent);
|
||||||
|
this._requests.set(id, request);
|
||||||
|
}
|
||||||
|
request.start();
|
||||||
|
return {
|
||||||
|
probe,
|
||||||
|
result,
|
||||||
|
details: probeDetails[id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the result of a check, by its id.
|
||||||
|
*/
|
||||||
|
public requestCheckById(use: UseCBOwner, id: BootProbeIds): AdminCheckRequest|undefined {
|
||||||
|
const probe = use(this.probes).find(p => p.id === id);
|
||||||
|
if (!probe) { return; }
|
||||||
|
return this.requestCheck(probe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about a check and a way to observe its result once available.
|
||||||
|
*/
|
||||||
|
export interface AdminCheckRequest {
|
||||||
|
probe: BootProbeInfo,
|
||||||
|
result: Observable<BootProbeResult>,
|
||||||
|
details: ProbeDetails,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage a single check.
|
||||||
|
*/
|
||||||
|
export class AdminCheckRunner {
|
||||||
|
constructor(public id: string, public results: Map<string, Observable<BootProbeResult>>,
|
||||||
|
public parent: Disposable) {
|
||||||
|
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 = results.get(id);
|
||||||
|
if (ob) {
|
||||||
|
ob.set(_probes);
|
||||||
|
}
|
||||||
|
}).catch(e => console.error(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
public start() {
|
||||||
|
let result = this.results.get(this.id);
|
||||||
|
if (!result) {
|
||||||
|
result = Observable.create(this.parent, {});
|
||||||
|
this.results.set(this.id, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export interface ProbeDetails {
|
||||||
|
info: string;
|
||||||
|
}
|
@ -2,7 +2,8 @@ import {buildHomeBanners} from 'app/client/components/Banners';
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
|
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
|
||||||
import {getTimeFromNow} from 'app/client/lib/timeUtils';
|
import {getTimeFromNow} from 'app/client/lib/timeUtils';
|
||||||
import {AppModel, getHomeUrl} from 'app/client/models/AppModel';
|
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
|
||||||
|
import {AdminChecks} from 'app/client/models/AdminChecks';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||||
@ -16,7 +17,8 @@ import {toggle} from 'app/client/ui2018/checkbox';
|
|||||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink, makeLinks} from 'app/client/ui2018/links';
|
import {cssLink, makeLinks} from 'app/client/ui2018/links';
|
||||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
import {SandboxingBootProbeDetails} from 'app/common/BootProbe';
|
||||||
|
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
|
||||||
import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI';
|
import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI';
|
||||||
import {naturalCompare} from 'app/common/SortFunc';
|
import {naturalCompare} from 'app/common/SortFunc';
|
||||||
import {getGristConfig} from 'app/common/urlUtils';
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
@ -35,13 +37,18 @@ export function getAdminPanelName() {
|
|||||||
export class AdminPanel extends Disposable {
|
export class AdminPanel extends Disposable {
|
||||||
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
||||||
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
|
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
|
||||||
|
private _checks: AdminChecks;
|
||||||
|
|
||||||
constructor(private _appModel: AppModel) {
|
constructor(private _appModel: AppModel) {
|
||||||
super();
|
super();
|
||||||
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
|
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
|
||||||
|
this._checks = new AdminChecks(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
|
this._checks.fetchAvailableChecks().catch(err => {
|
||||||
|
reportError(err);
|
||||||
|
});
|
||||||
const panelOpen = Observable.create(this, false);
|
const panelOpen = Observable.create(this, false);
|
||||||
return pagePanels({
|
return pagePanels({
|
||||||
leftPanel: {
|
leftPanel: {
|
||||||
@ -92,6 +99,16 @@ export class AdminPanel extends Disposable {
|
|||||||
expandedContent: this._supportGrist.buildSponsorshipSection(),
|
expandedContent: this._supportGrist.buildSponsorshipSection(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
cssSection(
|
||||||
|
cssSectionTitle(t('Security Settings')),
|
||||||
|
this._buildItem(owner, {
|
||||||
|
id: 'sandboxing',
|
||||||
|
name: t('Sandboxing'),
|
||||||
|
description: t('Sandbox settings for data engine'),
|
||||||
|
value: this._buildSandboxingDisplay(owner),
|
||||||
|
expandedContent: this._buildSandboxingNotice(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
cssSection(
|
cssSection(
|
||||||
cssSectionTitle(t('Version')),
|
cssSectionTitle(t('Version')),
|
||||||
this._buildItem(owner, {
|
this._buildItem(owner, {
|
||||||
@ -106,6 +123,42 @@ export class AdminPanel extends Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _buildSandboxingDisplay(owner: IDisposableOwner) {
|
||||||
|
return dom.domComputed(
|
||||||
|
use => {
|
||||||
|
const req = this._checks.requestCheckById(use, 'sandboxing');
|
||||||
|
const result = req ? use(req.result) : undefined;
|
||||||
|
const success = result?.success;
|
||||||
|
const details = result?.details as SandboxingBootProbeDetails|undefined;
|
||||||
|
if (!details) {
|
||||||
|
return cssValueLabel(t('unknown'));
|
||||||
|
}
|
||||||
|
const flavor = details.flavor;
|
||||||
|
const configured = details.configured;
|
||||||
|
return cssValueLabel(
|
||||||
|
configured ?
|
||||||
|
(success ? cssHappy(t('OK') + `: ${flavor}`) :
|
||||||
|
cssError(t('Error') + `: ${flavor}`)) :
|
||||||
|
cssError(t('unconfigured')));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildSandboxingNotice() {
|
||||||
|
return [
|
||||||
|
t('Grist allows for very powerful formulas, using Python. \
|
||||||
|
We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor \
|
||||||
|
if your hardware supports it (most will), \
|
||||||
|
to run formulas in each document within a sandbox \
|
||||||
|
isolated from other documents and isolated from the network.'),
|
||||||
|
dom(
|
||||||
|
'div',
|
||||||
|
{style: 'margin-top: 8px'},
|
||||||
|
cssLink({href: commonUrls.helpSandboxing, target: '_blank'}, t('Learn more.'))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private _buildItem(owner: IDisposableOwner, options: {
|
private _buildItem(owner: IDisposableOwner, options: {
|
||||||
id: string,
|
id: string,
|
||||||
name: DomContents,
|
name: DomContents,
|
||||||
@ -537,3 +590,11 @@ const cssCheckNowButton = styled(basicButton, `
|
|||||||
const cssGrayed = styled('span', `
|
const cssGrayed = styled('span', `
|
||||||
color: ${theme.lightText};
|
color: ${theme.lightText};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
export const cssError = styled('div', `
|
||||||
|
color: ${theme.errorText};
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssHappy = styled('div', `
|
||||||
|
color: ${theme.controlFg};
|
||||||
|
`);
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
import { SandboxInfo } from 'app/common/SandboxInfo';
|
||||||
|
|
||||||
export type BootProbeIds =
|
export type BootProbeIds =
|
||||||
'boot-page' |
|
'boot-page' |
|
||||||
'health-check' |
|
'health-check' |
|
||||||
'reachable' |
|
'reachable' |
|
||||||
'host-header' |
|
'host-header' |
|
||||||
|
'sandboxing' |
|
||||||
'system-user'
|
'system-user'
|
||||||
;
|
;
|
||||||
|
|
||||||
@ -20,3 +22,4 @@ export interface BootProbeInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SandboxingBootProbeDetails = SandboxInfo;
|
||||||
|
9
app/common/SandboxInfo.ts
Normal file
9
app/common/SandboxInfo.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface SandboxInfo {
|
||||||
|
flavor: string; // the type of sandbox in use (gvisor, unsandboxed, etc)
|
||||||
|
functional: boolean; // whether the sandbox can run code
|
||||||
|
effective: boolean; // whether the sandbox is actually giving protection
|
||||||
|
configured: boolean; // whether a sandbox type has been specified
|
||||||
|
// if sandbox fails to run, this records the last step that worked
|
||||||
|
lastSuccessfulStep: 'none' | 'create' | 'use' | 'all';
|
||||||
|
error?: string; // if sandbox fails, this stores an error
|
||||||
|
}
|
@ -87,6 +87,7 @@ export const commonUrls = {
|
|||||||
helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
|
helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
|
||||||
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
|
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
|
||||||
helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown",
|
helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown",
|
||||||
|
helpSandboxing: "https://support.getgrist.com/self-managed/#how-do-i-sandbox-documents",
|
||||||
freeCoachingCall: getFreeCoachingCallUrl(),
|
freeCoachingCall: getFreeCoachingCallUrl(),
|
||||||
contactSupport: getContactSupportUrl(),
|
contactSupport: getContactSupportUrl(),
|
||||||
plans: "https://www.getgrist.com/pricing",
|
plans: "https://www.getgrist.com/pricing",
|
||||||
|
@ -95,12 +95,14 @@ import {checksumFile} from 'app/server/lib/checksumFile';
|
|||||||
import {Client} from 'app/server/lib/Client';
|
import {Client} from 'app/server/lib/Client';
|
||||||
import {getMetaTables} from 'app/server/lib/DocApi';
|
import {getMetaTables} from 'app/server/lib/DocApi';
|
||||||
import {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager';
|
import {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager';
|
||||||
|
import {GristServer} from 'app/server/lib/GristServer';
|
||||||
import {ICreateActiveDocOptions} from 'app/server/lib/ICreate';
|
import {ICreateActiveDocOptions} from 'app/server/lib/ICreate';
|
||||||
import {makeForkIds} from 'app/server/lib/idUtils';
|
import {makeForkIds} from 'app/server/lib/idUtils';
|
||||||
import {GRIST_DOC_SQL, GRIST_DOC_WITH_TABLE1_SQL} from 'app/server/lib/initialDocSql';
|
import {GRIST_DOC_SQL, GRIST_DOC_WITH_TABLE1_SQL} from 'app/server/lib/initialDocSql';
|
||||||
import {ISandbox} from 'app/server/lib/ISandbox';
|
import {ISandbox} from 'app/server/lib/ISandbox';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import {LogMethods} from "app/server/lib/LogMethods";
|
import {LogMethods} from "app/server/lib/LogMethods";
|
||||||
|
import {ISandboxOptions} from 'app/server/lib/NSandbox';
|
||||||
import {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox';
|
import {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox';
|
||||||
import {DocRequests} from 'app/server/lib/Requests';
|
import {DocRequests} from 'app/server/lib/Requests';
|
||||||
import {shortDesc} from 'app/server/lib/shortDesc';
|
import {shortDesc} from 'app/server/lib/shortDesc';
|
||||||
@ -2764,11 +2766,9 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this._docManager.gristServer.create.NSandbox({
|
return createSandbox({
|
||||||
comment: this._docName,
|
server: this._docManager.gristServer,
|
||||||
logCalls: false,
|
docId: this._docName,
|
||||||
logTimes: true,
|
|
||||||
logMeta: {docId: this._docName},
|
|
||||||
preferredPythonVersion,
|
preferredPythonVersion,
|
||||||
sandboxOptions: {
|
sandboxOptions: {
|
||||||
exports: {
|
exports: {
|
||||||
@ -2951,3 +2951,23 @@ export async function getRealTableId(
|
|||||||
export function sanitizeApplyUAOptions(options?: ApplyUAOptions): ApplyUAOptions {
|
export function sanitizeApplyUAOptions(options?: ApplyUAOptions): ApplyUAOptions {
|
||||||
return pick(options||{}, ['desc', 'otherId', 'linkId', 'parseStrings']);
|
return pick(options||{}, ['desc', 'otherId', 'linkId', 'parseStrings']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a sandbox in its default initial state and with default logging.
|
||||||
|
*/
|
||||||
|
export function createSandbox(options: {
|
||||||
|
server: GristServer,
|
||||||
|
docId: string,
|
||||||
|
preferredPythonVersion: '2' | '3' | undefined,
|
||||||
|
sandboxOptions?: Partial<ISandboxOptions>,
|
||||||
|
}) {
|
||||||
|
const {docId, preferredPythonVersion, sandboxOptions, server} = options;
|
||||||
|
return server.create.NSandbox({
|
||||||
|
comment: docId,
|
||||||
|
logCalls: false,
|
||||||
|
logTimes: true,
|
||||||
|
logMeta: {docId},
|
||||||
|
preferredPythonVersion,
|
||||||
|
sandboxOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -18,13 +18,16 @@ export class BootProbes {
|
|||||||
|
|
||||||
public constructor(private _app: express.Application,
|
public constructor(private _app: express.Application,
|
||||||
private _server: GristServer,
|
private _server: GristServer,
|
||||||
private _base: string) {
|
private _base: string,
|
||||||
|
private _middleware: express.Handler[] = []) {
|
||||||
this._addProbes();
|
this._addProbes();
|
||||||
}
|
}
|
||||||
|
|
||||||
public addEndpoints() {
|
public addEndpoints() {
|
||||||
// Return a list of available probes.
|
// Return a list of available probes.
|
||||||
this._app.use(`${this._base}/probe$`, expressWrap(async (_, res) => {
|
this._app.use(`${this._base}/probe$`,
|
||||||
|
...this._middleware,
|
||||||
|
expressWrap(async (_, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
'probes': this._probes.map(probe => {
|
'probes': this._probes.map(probe => {
|
||||||
return { id: probe.id, name: probe.name };
|
return { id: probe.id, name: probe.name };
|
||||||
@ -33,7 +36,9 @@ export class BootProbes {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Return result of running an individual probe.
|
// Return result of running an individual probe.
|
||||||
this._app.use(`${this._base}/probe/:probeId`, expressWrap(async (req, res) => {
|
this._app.use(`${this._base}/probe/:probeId`,
|
||||||
|
...this._middleware,
|
||||||
|
expressWrap(async (req, res) => {
|
||||||
const probe = this._probeById.get(req.params.probeId);
|
const probe = this._probeById.get(req.params.probeId);
|
||||||
if (!probe) {
|
if (!probe) {
|
||||||
throw new ApiError('unknown probe', 400);
|
throw new ApiError('unknown probe', 400);
|
||||||
@ -52,6 +57,7 @@ export class BootProbes {
|
|||||||
this._probes.push(_userProbe);
|
this._probes.push(_userProbe);
|
||||||
this._probes.push(_bootProbe);
|
this._probes.push(_bootProbe);
|
||||||
this._probes.push(_hostHeaderProbe);
|
this._probes.push(_hostHeaderProbe);
|
||||||
|
this._probes.push(_sandboxingProbe);
|
||||||
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -183,3 +189,16 @@ const _hostHeaderProbe: Probe = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const _sandboxingProbe: Probe = {
|
||||||
|
id: 'sandboxing',
|
||||||
|
name: 'Sandboxing is working',
|
||||||
|
apply: async (server, req) => {
|
||||||
|
const details = server.getSandboxInfo();
|
||||||
|
return {
|
||||||
|
success: details?.configured && details?.functional,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -9,6 +9,7 @@ import {getOrgUrlInfo} from 'app/common/gristUrls';
|
|||||||
import {isAffirmative, safeJsonParse} from 'app/common/gutil';
|
import {isAffirmative, safeJsonParse} from 'app/common/gutil';
|
||||||
import {InstallProperties} from 'app/common/InstallAPI';
|
import {InstallProperties} from 'app/common/InstallAPI';
|
||||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||||
|
import {SandboxInfo} from 'app/common/SandboxInfo';
|
||||||
import {tbind} from 'app/common/tbind';
|
import {tbind} from 'app/common/tbind';
|
||||||
import * as version from 'app/common/version';
|
import * as version from 'app/common/version';
|
||||||
import {ApiServer, getOrgFromRequest} from 'app/gen-server/ApiServer';
|
import {ApiServer, getOrgFromRequest} from 'app/gen-server/ApiServer';
|
||||||
@ -23,6 +24,7 @@ import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
|||||||
import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
|
import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
|
||||||
import {Usage} from 'app/gen-server/lib/Usage';
|
import {Usage} from 'app/gen-server/lib/Usage';
|
||||||
import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens';
|
import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens';
|
||||||
|
import {createSandbox} from 'app/server/lib/ActiveDoc';
|
||||||
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
|
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
|
||||||
import {appSettings} from 'app/server/lib/AppSettings';
|
import {appSettings} from 'app/server/lib/AppSettings';
|
||||||
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
|
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
|
||||||
@ -181,6 +183,7 @@ export class FlexServer implements GristServer {
|
|||||||
private _isReady: boolean = false;
|
private _isReady: boolean = false;
|
||||||
private _probes: BootProbes;
|
private _probes: BootProbes;
|
||||||
private _updateManager: UpdateManager;
|
private _updateManager: UpdateManager;
|
||||||
|
private _sandboxInfo: SandboxInfo;
|
||||||
|
|
||||||
constructor(public port: number, public name: string = 'flexServer',
|
constructor(public port: number, public name: string = 'flexServer',
|
||||||
public readonly options: FlexServerOptions = {}) {
|
public readonly options: FlexServerOptions = {}) {
|
||||||
@ -1367,6 +1370,47 @@ export class FlexServer implements GristServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async checkSandbox() {
|
||||||
|
if (this._check('sandbox', 'doc')) { return; }
|
||||||
|
const flavor = process.env.GRIST_SANDBOX_FLAVOR || 'unknown';
|
||||||
|
const info = this._sandboxInfo = {
|
||||||
|
flavor,
|
||||||
|
configured: flavor !== 'unsandboxed',
|
||||||
|
functional: false,
|
||||||
|
effective: false,
|
||||||
|
sandboxed: false,
|
||||||
|
lastSuccessfulStep: 'none',
|
||||||
|
} as SandboxInfo;
|
||||||
|
try {
|
||||||
|
const sandbox = createSandbox({
|
||||||
|
server: this,
|
||||||
|
docId: 'test', // The id is just used in logging - no
|
||||||
|
// document is created or read at this level.
|
||||||
|
// In olden times, and in SaaS, Python 2 is supported. In modern
|
||||||
|
// times Python 2 is long since deprecated and defunct.
|
||||||
|
preferredPythonVersion: '3',
|
||||||
|
});
|
||||||
|
info.flavor = sandbox.getFlavor();
|
||||||
|
info.configured = info.flavor !== 'unsandboxed';
|
||||||
|
info.lastSuccessfulStep = 'create';
|
||||||
|
const result = await sandbox.pyCall('get_version');
|
||||||
|
if (typeof result !== 'number') {
|
||||||
|
throw new Error(`Expected a number: ${result}`);
|
||||||
|
}
|
||||||
|
info.lastSuccessfulStep = 'use';
|
||||||
|
await sandbox.shutdown();
|
||||||
|
info.lastSuccessfulStep = 'all';
|
||||||
|
info.functional = true;
|
||||||
|
info.effective = ![ 'skip', 'unsandboxed' ].includes(info.flavor);
|
||||||
|
} catch (e) {
|
||||||
|
info.error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSandboxInfo(): SandboxInfo|undefined {
|
||||||
|
return this._sandboxInfo;
|
||||||
|
}
|
||||||
|
|
||||||
public disableExternalStorage() {
|
public disableExternalStorage() {
|
||||||
if (this.deps.has('doc')) {
|
if (this.deps.has('doc')) {
|
||||||
throw new Error('disableExternalStorage called too late');
|
throw new Error('disableExternalStorage called too late');
|
||||||
@ -1827,6 +1871,8 @@ export class FlexServer implements GristServer {
|
|||||||
this.app.get('/admin', ...adminPageMiddleware, expressWrap(async (req, resp) => {
|
this.app.get('/admin', ...adminPageMiddleware, expressWrap(async (req, resp) => {
|
||||||
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
|
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
|
||||||
}));
|
}));
|
||||||
|
const probes = new BootProbes(this.app, this, '/admin', adminPageMiddleware);
|
||||||
|
probes.addEndpoints();
|
||||||
|
|
||||||
// Restrict this endpoint to install admins too, for the same reason as the /admin page.
|
// Restrict this endpoint to install admins too, for the same reason as the /admin page.
|
||||||
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
|
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ICustomWidget } from 'app/common/CustomWidget';
|
import { ICustomWidget } from 'app/common/CustomWidget';
|
||||||
import { GristDeploymentType, GristLoadConfig } from 'app/common/gristUrls';
|
import { GristDeploymentType, GristLoadConfig } from 'app/common/gristUrls';
|
||||||
import { LocalPlugin } from 'app/common/plugin';
|
import { LocalPlugin } from 'app/common/plugin';
|
||||||
|
import { SandboxInfo } from 'app/common/SandboxInfo';
|
||||||
import { UserProfile } from 'app/common/UserAPI';
|
import { UserProfile } from 'app/common/UserAPI';
|
||||||
import { Document } from 'app/gen-server/entity/Document';
|
import { Document } from 'app/gen-server/entity/Document';
|
||||||
import { Organization } from 'app/gen-server/entity/Organization';
|
import { Organization } from 'app/gen-server/entity/Organization';
|
||||||
@ -64,6 +65,7 @@ export interface GristServer {
|
|||||||
servesPlugins(): boolean;
|
servesPlugins(): boolean;
|
||||||
getBundledWidgets(): ICustomWidget[];
|
getBundledWidgets(): ICustomWidget[];
|
||||||
hasBoot(): boolean;
|
hasBoot(): boolean;
|
||||||
|
getSandboxInfo(): SandboxInfo|undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GristLoginSystem {
|
export interface GristLoginSystem {
|
||||||
@ -154,6 +156,7 @@ export function createDummyGristServer(): GristServer {
|
|||||||
getPlugins() { return []; },
|
getPlugins() { return []; },
|
||||||
getBundledWidgets() { return []; },
|
getBundledWidgets() { return []; },
|
||||||
hasBoot() { return false; },
|
hasBoot() { return false; },
|
||||||
|
getSandboxInfo() { return undefined; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ export interface ISandbox {
|
|||||||
shutdown(): Promise<unknown>; // TODO: tighten up this type.
|
shutdown(): Promise<unknown>; // TODO: tighten up this type.
|
||||||
pyCall(funcName: string, ...varArgs: unknown[]): Promise<any>;
|
pyCall(funcName: string, ...varArgs: unknown[]): Promise<any>;
|
||||||
reportMemoryUsage(): Promise<void>;
|
reportMemoryUsage(): Promise<void>;
|
||||||
|
getFlavor(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISandboxCreator {
|
export interface ISandboxCreator {
|
||||||
|
@ -230,6 +230,10 @@ export class NSandbox implements ISandbox {
|
|||||||
log.rawDebug('Sandbox memory', {memory, ...this._logMeta});
|
log.rawDebug('Sandbox memory', {memory, ...this._logMeta});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getFlavor() {
|
||||||
|
return this._logMeta.flavor;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get ready to communicate with a sandbox process using stdin,
|
* Get ready to communicate with a sandbox process using stdin,
|
||||||
* stdout, and stderr.
|
* stdout, and stderr.
|
||||||
@ -466,6 +470,10 @@ const spawners = {
|
|||||||
gvisor, // Gvisor's runsc sandbox.
|
gvisor, // Gvisor's runsc sandbox.
|
||||||
macSandboxExec, // Use "sandbox-exec" on Mac.
|
macSandboxExec, // Use "sandbox-exec" on Mac.
|
||||||
pyodide, // Run data engine using pyodide.
|
pyodide, // Run data engine using pyodide.
|
||||||
|
skip: unsandboxed, // Same as unsandboxed. Used to mean that the
|
||||||
|
// user deliberately doesn't want sandboxing.
|
||||||
|
// The "unsandboxed" setting is ambiguous in this
|
||||||
|
// respect.
|
||||||
};
|
};
|
||||||
|
|
||||||
function isFlavor(flavor: string): flavor is keyof typeof spawners {
|
function isFlavor(flavor: string): flavor is keyof typeof spawners {
|
||||||
|
@ -18,4 +18,8 @@ export class NullSandbox implements ISandbox {
|
|||||||
public async reportMemoryUsage() {
|
public async reportMemoryUsage() {
|
||||||
throw new UnavailableSandboxMethodError('reportMemoryUsage is not available');
|
throw new UnavailableSandboxMethodError('reportMemoryUsage is not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getFlavor() {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -179,6 +179,12 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
server.checkOptionCombinations();
|
server.checkOptionCombinations();
|
||||||
server.summary();
|
server.summary();
|
||||||
server.ready();
|
server.ready();
|
||||||
|
|
||||||
|
// Some tests have their timing perturbed by having this earlier
|
||||||
|
// TODO: update those tests.
|
||||||
|
if (includeDocs) {
|
||||||
|
await server.checkSandbox();
|
||||||
|
}
|
||||||
return server;
|
return server;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
await server.close();
|
await server.close();
|
||||||
|
@ -177,6 +177,19 @@ describe('AdminPanel', function() {
|
|||||||
assert.match(await driver.find('.test-admin-panel-item-value-version').getText(), /^Version \d+\./);
|
assert.match(await driver.find('.test-admin-panel-item-value-version').getText(), /^Version \d+\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show sandbox', async function() {
|
||||||
|
await driver.get(`${server.getHost()}/admin`);
|
||||||
|
await waitForAdminPanel();
|
||||||
|
assert.equal(await driver.find('.test-admin-panel-item-sandboxing').isDisplayed(), true);
|
||||||
|
await gu.waitToPass(
|
||||||
|
async () => assert.match(await driver.find('.test-admin-panel-item-value-sandboxing').getText(), /^unknown/),
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
// It would be good to test other scenarios, but we are using
|
||||||
|
// a multi-server setup and the sandbox test isn't useful there
|
||||||
|
// yet.
|
||||||
|
});
|
||||||
|
|
||||||
const upperCheckNow = () => driver.find('.test-admin-panel-updates-upper-check-now');
|
const upperCheckNow = () => driver.find('.test-admin-panel-updates-upper-check-now');
|
||||||
const lowerCheckNow = () => driver.find('.test-admin-panel-updates-lower-check-now');
|
const lowerCheckNow = () => driver.find('.test-admin-panel-updates-lower-check-now');
|
||||||
const autoCheckToggle = () => driver.find('.test-admin-panel-updates-auto-check');
|
const autoCheckToggle = () => driver.find('.test-admin-panel-updates-auto-check');
|
||||||
|
Loading…
Reference in New Issue
Block a user