diff --git a/app/client/boot.ts b/app/client/boot.ts index fed21107..a37fd363 100644 --- a/app/client/boot.ts +++ b/app/client/boot.ts @@ -1,8 +1,8 @@ import { AppModel } from 'app/client/models/AppModel'; +import { AdminChecks, ProbeDetails } from 'app/client/models/AdminChecks'; 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'; @@ -30,24 +30,14 @@ const cssResult = styled('div', ` */ 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; + private _checks: AdminChecks; 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(); + this._checks = new AdminChecks(this); } /** @@ -55,20 +45,10 @@ export class Boot extends Disposable { * side panel, just for convenience. Could be made a lot prettier. */ public buildDom() { + this._checks.fetchAvailableChecks().catch(e => reportError(e)); + 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 => { @@ -99,21 +79,10 @@ export class Boot extends Disposable { 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(); + ...use(this._checks.probes).map(probe => { + const req = this._checks.requestCheck(probe); 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)) { out.push(dom( 'div', - key, + cssLabel(key), 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. * Make sure the API isn't used since it may well be unreachable @@ -208,52 +152,9 @@ createAppPage(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; -} - +export const cssLabel = styled('div', ` + display: inline-block; + min-width: 100px; + text-align: right; + padding-right: 5px; +`); diff --git a/app/client/models/AdminChecks.ts b/app/client/models/AdminChecks.ts new file mode 100644 index 00000000..45de41ed --- /dev/null +++ b/app/client/models/AdminChecks.ts @@ -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; + + // Keep track of probe requests we are making, by probe ID. + private _requests: Map; + + // Keep track of probe results we have received, by probe ID. + private _results: Map>; + + 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, + details: ProbeDetails, +} + +/** + * Manage a single check. + */ +export class AdminCheckRunner { + constructor(public id: string, public results: Map>, + 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 = { + '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; +} diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts index da2c4393..f02d8351 100644 --- a/app/client/ui/AdminPanel.ts +++ b/app/client/ui/AdminPanel.ts @@ -2,7 +2,8 @@ import {buildHomeBanners} from 'app/client/components/Banners'; import {makeT} from 'app/client/lib/localization'; import {localStorageJsonObs} from 'app/client/lib/localStorageObs'; 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 {AppHeader} from 'app/client/ui/AppHeader'; 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 {icon} from 'app/client/ui2018/icons'; 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 {naturalCompare} from 'app/common/SortFunc'; import {getGristConfig} from 'app/common/urlUtils'; @@ -35,13 +37,18 @@ export function getAdminPanelName() { export class AdminPanel extends Disposable { private _supportGrist = SupportGristPage.create(this, this._appModel); private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl()); + private _checks: AdminChecks; constructor(private _appModel: AppModel) { super(); document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig()); + this._checks = new AdminChecks(this); } public buildDom() { + this._checks.fetchAvailableChecks().catch(err => { + reportError(err); + }); const panelOpen = Observable.create(this, false); return pagePanels({ leftPanel: { @@ -92,6 +99,16 @@ export class AdminPanel extends Disposable { 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( cssSectionTitle(t('Version')), 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: { id: string, name: DomContents, @@ -537,3 +590,11 @@ const cssCheckNowButton = styled(basicButton, ` const cssGrayed = styled('span', ` color: ${theme.lightText}; `); + +export const cssError = styled('div', ` + color: ${theme.errorText}; +`); + +export const cssHappy = styled('div', ` + color: ${theme.controlFg}; +`); diff --git a/app/common/BootProbe.ts b/app/common/BootProbe.ts index 5f0ee785..753af1c4 100644 --- a/app/common/BootProbe.ts +++ b/app/common/BootProbe.ts @@ -1,9 +1,11 @@ +import { SandboxInfo } from 'app/common/SandboxInfo'; export type BootProbeIds = 'boot-page' | 'health-check' | 'reachable' | 'host-header' | + 'sandboxing' | 'system-user' ; @@ -20,3 +22,4 @@ export interface BootProbeInfo { name: string; } +export type SandboxingBootProbeDetails = SandboxInfo; diff --git a/app/common/SandboxInfo.ts b/app/common/SandboxInfo.ts new file mode 100644 index 00000000..374672a7 --- /dev/null +++ b/app/common/SandboxInfo.ts @@ -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 +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index b852c853..8779d23a 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -87,6 +87,7 @@ export const commonUrls = { helpCalendarWidget: "https://support.getgrist.com/widget-calendar", helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys", 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(), contactSupport: getContactSupportUrl(), plans: "https://www.getgrist.com/pricing", diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index b65ba14b..c7fa5570 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -95,12 +95,14 @@ import {checksumFile} from 'app/server/lib/checksumFile'; import {Client} from 'app/server/lib/Client'; import {getMetaTables} from 'app/server/lib/DocApi'; 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 {makeForkIds} from 'app/server/lib/idUtils'; import {GRIST_DOC_SQL, GRIST_DOC_WITH_TABLE1_SQL} from 'app/server/lib/initialDocSql'; import {ISandbox} from 'app/server/lib/ISandbox'; import log from 'app/server/lib/log'; import {LogMethods} from "app/server/lib/LogMethods"; +import {ISandboxOptions} from 'app/server/lib/NSandbox'; import {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox'; import {DocRequests} from 'app/server/lib/Requests'; import {shortDesc} from 'app/server/lib/shortDesc'; @@ -2764,11 +2766,9 @@ export class ActiveDoc extends EventEmitter { } } } - return this._docManager.gristServer.create.NSandbox({ - comment: this._docName, - logCalls: false, - logTimes: true, - logMeta: {docId: this._docName}, + return createSandbox({ + server: this._docManager.gristServer, + docId: this._docName, preferredPythonVersion, sandboxOptions: { exports: { @@ -2951,3 +2951,23 @@ export async function getRealTableId( export function sanitizeApplyUAOptions(options?: ApplyUAOptions): ApplyUAOptions { 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, +}) { + const {docId, preferredPythonVersion, sandboxOptions, server} = options; + return server.create.NSandbox({ + comment: docId, + logCalls: false, + logTimes: true, + logMeta: {docId}, + preferredPythonVersion, + sandboxOptions, + }); +} diff --git a/app/server/lib/BootProbes.ts b/app/server/lib/BootProbes.ts index dad91f7e..7cddb99f 100644 --- a/app/server/lib/BootProbes.ts +++ b/app/server/lib/BootProbes.ts @@ -18,13 +18,16 @@ export class BootProbes { public constructor(private _app: express.Application, private _server: GristServer, - private _base: string) { + private _base: string, + private _middleware: express.Handler[] = []) { this._addProbes(); } public addEndpoints() { // 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({ 'probes': this._probes.map(probe => { return { id: probe.id, name: probe.name }; @@ -33,7 +36,9 @@ export class BootProbes { })); // 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); if (!probe) { throw new ApiError('unknown probe', 400); @@ -52,6 +57,7 @@ export class BootProbes { this._probes.push(_userProbe); this._probes.push(_bootProbe); this._probes.push(_hostHeaderProbe); + this._probes.push(_sandboxingProbe); 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, + }; + }, +}; diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 5c10a65c..17ac46a3 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -9,6 +9,7 @@ import {getOrgUrlInfo} from 'app/common/gristUrls'; import {isAffirmative, safeJsonParse} from 'app/common/gutil'; import {InstallProperties} from 'app/common/InstallAPI'; import {UserProfile} from 'app/common/LoginSessionAPI'; +import {SandboxInfo} from 'app/common/SandboxInfo'; import {tbind} from 'app/common/tbind'; import * as version from 'app/common/version'; 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 {Usage} from 'app/gen-server/lib/Usage'; import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens'; +import {createSandbox} from 'app/server/lib/ActiveDoc'; import {attachAppEndpoint} from 'app/server/lib/AppEndpoint'; import {appSettings} from 'app/server/lib/AppSettings'; import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser, @@ -42,7 +44,7 @@ import {expressWrap, jsonErrorHandler, secureJsonErrorHandler} from 'app/server/ import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg'; import {addGoogleAuthEndpoint} from "app/server/lib/GoogleAuth"; import {DocTemplate, GristLoginMiddleware, GristLoginSystem, GristServer, - RequestWithGrist} from 'app/server/lib/GristServer'; + RequestWithGrist} from 'app/server/lib/GristServer'; import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions'; import {HostedStorageManager} from 'app/server/lib/HostedStorageManager'; import {IBilling} from 'app/server/lib/IBilling'; @@ -181,6 +183,7 @@ export class FlexServer implements GristServer { private _isReady: boolean = false; private _probes: BootProbes; private _updateManager: UpdateManager; + private _sandboxInfo: SandboxInfo; constructor(public port: number, public name: string = 'flexServer', 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() { if (this.deps.has('doc')) { 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) => { 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. this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => { diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index b72b3ed5..9c53f347 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -1,6 +1,7 @@ import { ICustomWidget } from 'app/common/CustomWidget'; import { GristDeploymentType, GristLoadConfig } from 'app/common/gristUrls'; import { LocalPlugin } from 'app/common/plugin'; +import { SandboxInfo } from 'app/common/SandboxInfo'; import { UserProfile } from 'app/common/UserAPI'; import { Document } from 'app/gen-server/entity/Document'; import { Organization } from 'app/gen-server/entity/Organization'; @@ -64,6 +65,7 @@ export interface GristServer { servesPlugins(): boolean; getBundledWidgets(): ICustomWidget[]; hasBoot(): boolean; + getSandboxInfo(): SandboxInfo|undefined; } export interface GristLoginSystem { @@ -154,6 +156,7 @@ export function createDummyGristServer(): GristServer { getPlugins() { return []; }, getBundledWidgets() { return []; }, hasBoot() { return false; }, + getSandboxInfo() { return undefined; }, }; } diff --git a/app/server/lib/ISandbox.ts b/app/server/lib/ISandbox.ts index c53583fa..89d09fd8 100644 --- a/app/server/lib/ISandbox.ts +++ b/app/server/lib/ISandbox.ts @@ -26,6 +26,7 @@ export interface ISandbox { shutdown(): Promise; // TODO: tighten up this type. pyCall(funcName: string, ...varArgs: unknown[]): Promise; reportMemoryUsage(): Promise; + getFlavor(): string; } export interface ISandboxCreator { diff --git a/app/server/lib/NSandbox.ts b/app/server/lib/NSandbox.ts index d57aacbe..86a1ded6 100644 --- a/app/server/lib/NSandbox.ts +++ b/app/server/lib/NSandbox.ts @@ -230,6 +230,10 @@ export class NSandbox implements ISandbox { log.rawDebug('Sandbox memory', {memory, ...this._logMeta}); } + public getFlavor() { + return this._logMeta.flavor; + } + /** * Get ready to communicate with a sandbox process using stdin, * stdout, and stderr. @@ -466,6 +470,10 @@ const spawners = { gvisor, // Gvisor's runsc sandbox. macSandboxExec, // Use "sandbox-exec" on Mac. 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 { diff --git a/app/server/lib/NullSandbox.ts b/app/server/lib/NullSandbox.ts index 7eadf723..96045972 100644 --- a/app/server/lib/NullSandbox.ts +++ b/app/server/lib/NullSandbox.ts @@ -18,4 +18,8 @@ export class NullSandbox implements ISandbox { public async reportMemoryUsage() { throw new UnavailableSandboxMethodError('reportMemoryUsage is not available'); } + + public getFlavor() { + return 'null'; + } } diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index 0c62010d..8405cd8d 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -179,6 +179,12 @@ export async function main(port: number, serverTypes: ServerType[], server.checkOptionCombinations(); server.summary(); server.ready(); + + // Some tests have their timing perturbed by having this earlier + // TODO: update those tests. + if (includeDocs) { + await server.checkSandbox(); + } return server; } catch(e) { await server.close(); diff --git a/test/nbrowser/AdminPanel.ts b/test/nbrowser/AdminPanel.ts index 209baf8c..f5d47aa1 100644 --- a/test/nbrowser/AdminPanel.ts +++ b/test/nbrowser/AdminPanel.ts @@ -177,6 +177,19 @@ describe('AdminPanel', function() { 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 lowerCheckNow = () => driver.find('.test-admin-panel-updates-lower-check-now'); const autoCheckToggle = () => driver.find('.test-admin-panel-updates-auto-check');