CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/compute/proxy.tsx
Views: 687
1
/*
2
The HTTPS proxy server.
3
*/
4
5
import { Alert, Button, Input, Spin, Switch } from "antd";
6
import { delay } from "awaiting";
7
import jsonic from "jsonic";
8
import { useEffect, useMemo, useRef, useState } from "react";
9
10
import { A, Icon } from "@cocalc/frontend/components";
11
import ShowError from "@cocalc/frontend/components/error";
12
import { PROXY_CONFIG } from "@cocalc/util/compute/constants";
13
import AuthToken from "./auth-token";
14
import { writeTextFileToComputeServer } from "./project";
15
16
import { useTypedRedux } from "@cocalc/frontend/app-framework";
17
import { TimeAgo } from "@cocalc/frontend/components";
18
import { CancelText } from "@cocalc/frontend/i18n/components";
19
import { open_new_tab } from "@cocalc/frontend/misc/open-browser-tab";
20
import { webapp_client } from "@cocalc/frontend/webapp-client";
21
import { defaultProxyConfig } from "@cocalc/util/compute/images";
22
import { EditModal } from "./compute-server";
23
import { getQuery } from "./description";
24
25
export default function Proxy({
26
id,
27
project_id,
28
setConfig,
29
configuration,
30
data,
31
state,
32
IMAGES,
33
}) {
34
const [help, setHelp] = useState<boolean>(false);
35
36
return (
37
<div>
38
<div style={{ color: "#666", marginBottom: "5px" }}>
39
<div>
40
<b>
41
<Switch
42
size="small"
43
checkedChildren={"Help"}
44
unCheckedChildren={"Help"}
45
style={{ float: "right" }}
46
checked={help}
47
onChange={(val) => setHelp(val)}
48
/>
49
<Icon name="global" /> Web Applications: VS Code, JupyterLab, etc.
50
</b>
51
</div>
52
{help && (
53
<Alert
54
showIcon
55
style={{ margin: "15px 0" }}
56
type="info"
57
message={"Proxy"}
58
description={
59
<div>
60
You can directly run servers such as JupyterLab, VS Code, and
61
Pluto on your compute server. The authorization token is used so
62
you and your project collaborators can access these servers.
63
<br />
64
<br />
65
<b>NOTE:</b> It can take a few minutes for an app to start
66
running the first time you launch it.
67
<br />
68
<br />
69
<b>WARNING:</b> You will see a security warning if you don't
70
configure a domain name. In some cases, e.g., JupyterLab via
71
Chrome, you <i>must</i> configure a domain name (due to a bug in
72
Chrome).
73
</div>
74
}
75
/>
76
)}
77
<ProxyConfig
78
id={id}
79
project_id={project_id}
80
setConfig={setConfig}
81
configuration={configuration}
82
state={state}
83
IMAGES={IMAGES}
84
/>
85
<AuthToken
86
id={id}
87
project_id={project_id}
88
setConfig={setConfig}
89
configuration={configuration}
90
state={state}
91
IMAGES={IMAGES}
92
/>
93
<Apps
94
state={state}
95
configuration={configuration}
96
data={data}
97
IMAGES={IMAGES}
98
style={{ marginTop: "10px" }}
99
compute_server_id={id}
100
project_id={project_id}
101
/>
102
</div>
103
</div>
104
);
105
}
106
107
function getProxy({ IMAGES, configuration }) {
108
return (
109
configuration?.proxy ??
110
defaultProxyConfig({ image: configuration?.image, IMAGES })
111
);
112
}
113
114
function ProxyConfig({
115
id,
116
project_id,
117
setConfig,
118
configuration,
119
state,
120
IMAGES,
121
}) {
122
const [edit, setEdit] = useState<boolean>(false);
123
const [error, setError] = useState<string>("");
124
const [saving, setSaving] = useState<boolean>(false);
125
const proxy = getProxy({ configuration, IMAGES });
126
const [proxyJson, setProxyJson] = useState<string>(stringify(proxy));
127
useEffect(() => {
128
setProxyJson(stringify(proxy));
129
}, [configuration]);
130
131
if (!edit) {
132
return (
133
<Button
134
style={{ marginTop: "15px", display: "inline-block", float: "right" }}
135
onClick={() => setEdit(true)}
136
>
137
Advanced...
138
</Button>
139
);
140
}
141
142
const save = async () => {
143
try {
144
setSaving(true);
145
setError("");
146
const proxy = jsonic(proxyJson);
147
setProxyJson(stringify(proxy));
148
await setConfig({ proxy });
149
if (state == "running") {
150
await writeProxy({
151
compute_server_id: id,
152
project_id,
153
proxy,
154
});
155
}
156
setEdit(false);
157
} catch (err) {
158
setError(`${err}`);
159
} finally {
160
setSaving(false);
161
}
162
};
163
return (
164
<div style={{ marginTop: "15px" }}>
165
<ShowError
166
error={error}
167
setError={setError}
168
style={{ margin: "15px 0" }}
169
/>
170
<Button
171
disabled={saving}
172
onClick={() => {
173
setProxyJson(stringify(proxy));
174
setEdit(false);
175
}}
176
style={{ marginRight: "5px" }}
177
>
178
<CancelText />
179
</Button>
180
<Button
181
type="primary"
182
disabled={saving || proxyJson == stringify(proxy)}
183
onClick={save}
184
>
185
Save {saving && <Spin />}
186
</Button>
187
<div
188
style={{
189
display: "inline-block",
190
color: "#666",
191
marginLeft: "30px",
192
}}
193
>
194
Configure <code>/cocalc/conf/proxy.json</code> using{" "}
195
<A href="https://github.com/sagemathinc/cocalc-compute-docker/tree/main/src/proxy">
196
this JSON format
197
</A>
198
.
199
</div>
200
<Input.TextArea
201
style={{ marginTop: "15px" }}
202
disabled={saving}
203
value={proxyJson}
204
onChange={(e) => setProxyJson(e.target.value)}
205
autoSize={{ minRows: 2, maxRows: 6 }}
206
/>
207
</div>
208
);
209
}
210
211
function stringify(proxy) {
212
return "[\n" + proxy.map((x) => " " + JSON.stringify(x)).join(",\n") + "\n]";
213
}
214
215
async function writeProxy({ proxy, project_id, compute_server_id }) {
216
const value = stringify(proxy);
217
await writeTextFileToComputeServer({
218
value,
219
project_id,
220
compute_server_id,
221
sudo: true,
222
path: PROXY_CONFIG,
223
});
224
}
225
226
function Apps({
227
compute_server_id,
228
configuration,
229
IMAGES,
230
style,
231
data,
232
project_id,
233
state,
234
}) {
235
const [error, setError] = useState<string>("");
236
const compute_servers_dns = useTypedRedux("customize", "compute_servers_dns");
237
const apps = useMemo(
238
() =>
239
getApps({
240
setError,
241
compute_server_id,
242
project_id,
243
configuration,
244
data,
245
IMAGES,
246
compute_servers_dns,
247
state,
248
}),
249
[
250
configuration?.image,
251
IMAGES != null,
252
configuration?.proxy,
253
data?.externalIp,
254
],
255
);
256
if (apps.length == 0) {
257
return null;
258
}
259
return (
260
<div style={style}>
261
<b>Launch App</b> (opens in new browser tab)
262
<div>
263
<div style={{ marginTop: "5px" }}>{apps}</div>
264
<ShowError
265
style={{ marginTop: "10px" }}
266
error={error}
267
setError={setError}
268
/>
269
</div>
270
</div>
271
);
272
}
273
274
function getApps({
275
compute_server_id,
276
configuration,
277
data,
278
IMAGES,
279
project_id,
280
compute_servers_dns,
281
setError,
282
state,
283
}) {
284
const image = configuration?.image;
285
if (IMAGES == null || image == null) {
286
return [];
287
}
288
const proxy = getProxy({ configuration, IMAGES });
289
const apps = IMAGES[image]?.apps ?? IMAGES["defaults"]?.apps ?? {};
290
291
const buttons: JSX.Element[] = [];
292
for (const name in apps) {
293
const app = apps[name];
294
if (app.disabled) {
295
continue;
296
}
297
for (const route of proxy) {
298
if (route.path == app.path) {
299
buttons.push(
300
<LauncherButton
301
key={name}
302
disabled={state != "running"}
303
name={name}
304
app={app}
305
compute_server_id={compute_server_id}
306
project_id={project_id}
307
configuration={configuration}
308
data={data}
309
compute_servers_dns={compute_servers_dns}
310
setError={setError}
311
route={route}
312
/>,
313
);
314
break;
315
}
316
}
317
}
318
return buttons;
319
}
320
321
export function getRoute({ app, configuration, IMAGES }) {
322
const proxy = getProxy({ configuration, IMAGES });
323
if (app.name) {
324
// It's best and most explicit to use the name.
325
for (const route of proxy) {
326
if (route.name == app.name) {
327
return route;
328
}
329
}
330
}
331
// Name is not specified or not matching, so we try to match the
332
// route path:
333
for (const route of proxy) {
334
if (route.path == app.path) {
335
return route;
336
}
337
}
338
// nothing matches.
339
throw Error(`No route found for app '${app.label}'`);
340
}
341
342
const START_DELAY_MS = 1500;
343
const MAX_DELAY_MS = 7500;
344
345
export function LauncherButton({
346
name,
347
app,
348
compute_server_id,
349
project_id,
350
configuration,
351
data,
352
compute_servers_dns,
353
setError,
354
disabled,
355
route,
356
noHide,
357
autoLaunch,
358
}: {
359
name: string;
360
app;
361
compute_server_id: number;
362
project_id: string;
363
configuration;
364
data;
365
compute_servers_dns?: string;
366
setError;
367
disabled?;
368
route;
369
noHide?: boolean;
370
autoLaunch?: boolean;
371
}) {
372
const [url, setUrl] = useState<string>("");
373
const [launching, setLaunching] = useState<boolean>(false);
374
const [log, setLog] = useState<string>("");
375
const cancelRef = useRef<boolean>(false);
376
const [start, setStart] = useState<Date | null>(null);
377
const [showSettings, setShowSettings] = useState<boolean>(false);
378
const dnsIssue =
379
!(configuration?.dns && compute_servers_dns) && app.requiresDns;
380
useEffect(() => {
381
if (autoLaunch) {
382
launch();
383
}
384
}, []);
385
const launch = async () => {
386
try {
387
setLaunching(true);
388
cancelRef.current = false;
389
const url = getUrl({
390
app,
391
configuration,
392
data,
393
compute_servers_dns,
394
});
395
setUrl(url);
396
let attempt = 0;
397
setStart(new Date());
398
const isRunning = async () => {
399
attempt += 1;
400
setLog(`Checking if ${route.target} is alive (attempt: ${attempt})...`);
401
return await isHttpServerResponding({
402
project_id,
403
compute_server_id,
404
target: route.target,
405
});
406
};
407
if (!(await isRunning())) {
408
setLog("Launching...");
409
await webapp_client.exec({
410
filesystem: false,
411
compute_server_id,
412
project_id,
413
command: app.launch,
414
err_on_exit: true,
415
});
416
}
417
let d = START_DELAY_MS;
418
while (!cancelRef.current && d < 60 * 1000 * 5) {
419
if (await isRunning()) {
420
setLog("Running!");
421
break;
422
}
423
d = Math.min(MAX_DELAY_MS, d * 1.2);
424
await delay(d);
425
}
426
if (!cancelRef.current) {
427
setLog("Opening tab");
428
open_new_tab(url);
429
}
430
} catch (err) {
431
setError(`${app.label}: ${err}`);
432
} finally {
433
setLaunching(false);
434
setLog("");
435
}
436
};
437
return (
438
<div key={name} style={{ display: "inline-block", marginRight: "5px" }}>
439
<Button disabled={disabled || dnsIssue || launching} onClick={launch}>
440
{app.icon ? <Icon name={app.icon} /> : undefined}
441
{app.label}{" "}
442
{dnsIssue && <span style={{ marginLeft: "5px" }}>(requires DNS)</span>}
443
{launching && <Spin />}
444
</Button>
445
{launching && (
446
<Button
447
style={{ marginLeft: "5px" }}
448
onClick={() => {
449
cancelRef.current = true;
450
setLaunching(false);
451
setUrl("");
452
}}
453
>
454
<CancelText />
455
</Button>
456
)}
457
{log && (
458
<div>
459
{log}
460
<TimeAgo date={start} />
461
</div>
462
)}
463
{url && (
464
<div
465
style={{
466
color: "#666",
467
maxWidth: "500px",
468
border: "1px solid #ccc",
469
padding: "15px",
470
borderRadius: "5px",
471
margin: "10px 0",
472
}}
473
>
474
It could take a minute for {app.label} to start, so revisit this URL
475
if necessary.
476
{dnsIssue && (
477
<Alert
478
style={{ margin: "10px" }}
479
type="warning"
480
showIcon
481
message={
482
<>
483
<b>WARNING:</b> {app.label} probably won't work without a DNS
484
subdomain configured.
485
<Button
486
style={{ marginLeft: "15px" }}
487
onClick={() => {
488
setShowSettings(true);
489
}}
490
>
491
<Icon name="settings" /> Settings
492
</Button>
493
{showSettings && (
494
<EditModal
495
id={compute_server_id}
496
project_id={project_id}
497
close={() => setShowSettings(false)}
498
/>
499
)}
500
</>
501
}
502
/>
503
)}
504
<div style={{ textAlign: "center" }}>
505
<A href={url}>{url}</A>
506
</div>
507
You can also share this URL with other people, who will be able to
508
access the server, even if they do not have a CoCalc account.
509
{!noHide && (
510
<Button size="small" type="link" onClick={() => setUrl("")}>
511
(hide)
512
</Button>
513
)}
514
</div>
515
)}
516
</div>
517
);
518
}
519
520
function getUrl({ app, configuration, data, compute_servers_dns }) {
521
const auth = getQuery(configuration.authToken);
522
if (configuration.dns && compute_servers_dns) {
523
return `https://${configuration.dns}.${compute_servers_dns}${app.url}${auth}`;
524
} else {
525
if (!data?.externalIp) {
526
throw Error("no external ip addressed assigned");
527
}
528
return `https://${data.externalIp}${app.url}${auth}`;
529
}
530
}
531
532
// Returns true if there is an http server responding at http://localhost:port on the
533
// given compute server.
534
async function isHttpServerResponding({
535
project_id,
536
compute_server_id,
537
target,
538
maxTimeS = 5,
539
}) {
540
const command = `curl --silent --fail --max-time ${maxTimeS} ${target} >/dev/null; echo $?`;
541
const { stdout } = await webapp_client.exec({
542
filesystem: false,
543
compute_server_id,
544
project_id,
545
command,
546
err_on_exit: false,
547
});
548
return stdout.trim() == "0";
549
}
550
551