Skip to content

Commit 5cb52e1

Browse files
authored
Allow ssh access to devtainers (#17)
# Integrated support for SSH, VS Code & GitHub Copilot This significant release offers integrated SSH server support, and indirectly support for VS Code server and [GitHub Copilot](https://github.com/features/copilot). Dockside now facilitates: - SSH access to any devtainer by authorised developers; - use command line tools that benefit from key forwarding, such as `git`; - seamless [VS Code remote development](https://code.visualstudio.com/docs/remote/ssh) via the [Remote SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) extension. Dockside achieves this through: - Provisioning an SSH and a wstunnel server daemon for each devtainer. - Maintaining each devtainer's `~/.ssh/authorized_keys` file with the public ssh keys of the devtainer owner and any other developers with whom the devtainer is shared. - A UI function to open SSH on a devtainer directly with a single click. - Setup instructions, integrated in the Dockside UI, for developers needing to install the wstunnel helper client and configure their local `~/.ssh/config` N.B. Dockside now enables SSH access by default for all new devtainers, though this can be disabled by setting `ssh.default=0` in `config.json`. See [documentation](https://github.com/newsnowlabs/dockside/blob/8a94c67737d9a584df220b4403a1ba0ac1dc4333/docs/extensions/ssh.md) for full details on configuring Dockside for SSH access and see the new Dockside UI for details on configuring clients to tunnel ssh over wstunnel. WARNING: Dockside now takes over control of `~/.ssh/authorized_keys` in new devtainers. Accordingly, SSH support is _not compatible_ with any profiles that mount over this file (or over ~/.ssh if the mounted filesystem contains an `authorized_keys` file). You should take care to disable SSH in such profiles as, otherwise, if you make changes manually to this file on a devtainer that has SSH enabled, your changes may be lost.
1 parent dd32cba commit 5cb52e1

33 files changed

+775
-237
lines changed

Dockerfile

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ ARG ALPINE_VERSION=3.14
44
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} as theia-build
55

66
RUN apk update && \
7-
apk add --no-cache make gcc g++ python3 libsecret-dev s6 curl file patchelf bash
7+
apk add --no-cache make gcc g++ python3 libsecret-dev s6 curl file patchelf bash dropbear jq
88

99
ARG OPT_PATH
1010
ARG TARGETPLATFORM
@@ -21,18 +21,29 @@ ARG TARGETPLATFORM
2121
#
2222
ENV BASH_ENV=/tmp/theia-bash-env
2323

24+
# Some but not all needed wstunnel binaries are published on https://github.com/erebe/wstunnel.
25+
# Others we have had to compile from source. To ensure build reliability/reproducibility, we here
26+
# obtain wstunnel binaries from the Dockside Google Cloud Storage bucket. wstunnel is published
27+
# under https://github.com/erebe/wstunnel/blob/master/LICENSE.
2428
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
2529
THEIA_VERSION=1.40.0; \
30+
WSTUNNEL_BINARY="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-x64"; \
2631
elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
2732
THEIA_VERSION=1.40.0; \
33+
WSTUNNEL_BINARY="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-arm64"; \
2834
elif [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
2935
THEIA_VERSION=1.35.0; \
36+
WSTUNNEL_BINARY="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-armv7"; \
3037
else \
31-
THEIA_VERSION=1.40.0; \
38+
echo "Build error: Unsupported architecture '$TARGETPLATFORM'" >&2; \
39+
exit 1; \
3240
fi; \
33-
echo "export THEIA_VERSION=$THEIA_VERSION" >$BASH_ENV; \
41+
echo "export WSTUNNEL_BINARY=$WSTUNNEL_BINARY" >$BASH_ENV; \
42+
echo "export THEIA_VERSION=$THEIA_VERSION" >>$BASH_ENV; \
3443
echo "export THEIA_PATH=$OPT_PATH/ide/theia/theia-$THEIA_VERSION" >>$BASH_ENV; \
3544
echo 'echo THEIA_VERSION=$THEIA_VERSION THEIA_PATH=$THEIA_PATH' >>$BASH_ENV; \
45+
echo 'echo WSTUNNEL_BINARY=$WSTUNNEL_BINARY' >>$BASH_ENV; \
46+
echo 'echo TARGETPLATFORM=$TARGETPLATFORM' >>$BASH_ENV; \
3647
echo '[ -d $THEIA_PATH/theia ] && cd $THEIA_PATH/theia || true' >>$BASH_ENV; \
3748
echo -e '#!/bin/bash\n\nexec "$@"\n' >/tmp/theia-exec && chmod 755 /tmp/theia-exec; \
3849
. $BASH_ENV
@@ -49,7 +60,10 @@ RUN mkdir -p $THEIA_PATH && \
4960
cp -a /tmp/build/ide/theia/$THEIA_VERSION/bin $THEIA_PATH/
5061

5162
# Build Theia
52-
RUN PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 && NODE_OPTIONS="--max_old_space_size=4096" && yarn config set network-timeout 600000 -g && yarn
63+
RUN PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 && \
64+
PUPPETEER_SKIP_DOWNLOAD=1 && \
65+
NODE_OPTIONS="--max_old_space_size=4096" && \
66+
yarn config set network-timeout 600000 -g && yarn
5367

5468
# Default diagnostics entrypoint for this stage
5569
# (and the next, which inherits it)
@@ -73,7 +87,7 @@ RUN yarn autoclean --init && \
7387

7488
FROM theia-clean as theia-findelfs
7589

76-
ENV BINARIES="node busybox s6-svscan curl"
90+
ENV BINARIES="node busybox s6-svscan curl dropbear dropbearkey jq"
7791

7892
RUN /tmp/build/ide/theia/elf-patcher.sh --findelfs
7993

@@ -92,7 +106,8 @@ RUN /tmp/build/ide/theia/elf-patcher.sh --patchelfs && \
92106
cd $THEIA_PATH/bin && \
93107
ln -sf busybox sh && \
94108
ln -sf busybox su && \
95-
ln -sf busybox pgrep
109+
ln -sf busybox pgrep && \
110+
curl -SsL -o wstunnel $WSTUNNEL_BINARY && chmod 755 wstunnel
96111

97112
# Default diagnostics entrypoint for this stage (uses patched node)
98113
ENTRYPOINT ["/tmp/theia-exec", "../bin/node", "./src-gen/backend/main.js", "/root", "--hostname", "0.0.0.0", "--port", "3131"]
@@ -286,6 +301,10 @@ RUN cp -a ~/$APP/build/development/dot-theia .theia && \
286301
#
287302
VOLUME $OPT_PATH
288303

304+
# Create a separate volume for host-specific data to be shared
305+
# read-only with devtainers
306+
VOLUME $OPT_PATH/host
307+
289308
################################################################################
290309
# INITIALISE /opt/dockside/bin
291310
#
@@ -296,7 +315,7 @@ VOLUME $OPT_PATH
296315
#
297316
USER root
298317
RUN . /tmp/theia-bash-env && \
299-
mkdir -p $OPT_PATH/bin && \
318+
mkdir -p $OPT_PATH/bin $OPT_PATH/host && \
300319
cp -a $HOME/$APP/app/scripts/container/launch.sh $OPT_PATH/bin/ && \
301320
ln -sfr $OPT_PATH/bin/launch.sh $OPT_PATH/launch.sh && \
302321
cp -a $HOME/$APP/app/server/assets/ico/favicon.ico $THEIA_PATH/theia/lib/ && \

app/client/src/components/App.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<b-row>
66
<Sidebar></Sidebar>
77
<Main></Main>
8+
<SSHInfo></SSHInfo>
89
</b-row>
910
</b-container>
1011
<Footer></Footer>
@@ -16,14 +17,16 @@
1617
import Footer from '@/components/Footer';
1718
import Sidebar from '@/components/Sidebar';
1819
import Main from '@/components/Main';
20+
import SSHInfo from '@/components/SSHInfo';
1921
2022
export default {
2123
name: 'App',
2224
components: {
2325
Header,
2426
Footer,
2527
Sidebar,
26-
Main
28+
Main,
29+
SSHInfo
2730
},
2831
created() {
2932
this.updateStateFromRoute(this.$route);

app/client/src/components/Container.vue

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@
8888
<th>&#8674;&nbsp;{{ router.name }} </th>
8989
<td v-if="!isEditMode && !isPrelaunchMode">
9090
<b-button v-if="router.type != 'passthru' && container.status == 1" size="sm" variant="primary" v-bind:href="makeUri(router)" :target="makeUriTarget(router)">Open</b-button>
91-
<b-button v-if="router.type != 'passthru' && container.status >= 0" size="sm" variant="outline-secondary" v-on:click="copy(makeUri(router))">Copy</b-button>
91+
<b-button v-if="router.type != 'passthru' && container.status >= 0" size="sm" variant="outline-secondary" v-on:click="copyUri(router)">Copy</b-button>
92+
<b-button v-if="router.type === 'ssh' && container.status >= 0" size="sm" variant="outline-secondary" type="button" v-b-modal="'sshinfo-modal'" v-b-tooltip title="Configure SSH for Dockside">Setup</b-button>
9293
({{ container.meta.access[router.name] }} access)
9394
</td>
9495
<td v-else>
@@ -319,7 +320,31 @@
319320
copyToClipboard(value);
320321
},
321322
makeUri(router) {
322-
return [router.https ? 'https' : 'http', '://', (router.prefixes[0] ? router.prefixes[0] : 'www'), '-', this.container.name, window.dockside.host].join('');
323+
const protocol = router.https ? 'https' : 'http';
324+
const prefix = router.prefixes[0] ? router.prefixes[0] : 'www';
325+
const containerName = this.container.name;
326+
const host = window.dockside.host;
327+
328+
if (router.type !== 'ssh') {
329+
return `${protocol}://${prefix}-${containerName}${host}`;
330+
} else {
331+
const unixuser = this.container.data.unixuser;
332+
const hostname = host.split(':')[0];
333+
return `ssh://${unixuser}@${prefix}-${containerName}${hostname}`;
334+
}
335+
},
336+
copyUri(router) {
337+
if (router.type !== 'ssh') {
338+
return copyToClipboard(this.makeUri(router));
339+
}
340+
341+
const prefix = router.prefixes[0] ? router.prefixes[0] : 'www';
342+
const containerName = this.container.name;
343+
const host = window.dockside.host;
344+
const unixuser = this.container.data.unixuser;
345+
const hostname = host.split(':')[0];
346+
347+
return copyToClipboard(`ssh ${unixuser}@${prefix}-${containerName}${hostname}`);
323348
},
324349
makeUriTarget(router) {
325350
return [(router.prefixes[0] ? router.prefixes[0] : 'www'), '-', this.container.name, window.dockside.host].join('');
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// https://bootstrap-vue.org/docs/components/modal#modal
2+
3+
<template>
4+
<b-modal id="sshinfo-modal" size="lg" v-model="showModal" @show="onModalShow" title="How to set up SSH" centered>
5+
<p>Download a suitable <a href="https://github.com/erebe/wstunnel" target="_blank" v-b-tooltip title="Open wstunnel in new tab"><code>wstunnel</code></a>
6+
(<a href="https://github.com/erebe/wstunnel/blob/master/LICENSE" target="_blank" v-b-tooltip title="Open in new tab">LICENSE</a>)
7+
binary to your local machine, from either the <a href="https://github.com/erebe/wstunnel/releases" target="_blank" v-b-tooltip title="Open wstunnel in new tab"><code>wstunnel</code> releases page</a>
8+
or the Dockside public bucket (which comprises copies of officially-released binaries and binaries compiled by Dockside):</p>
9+
<p>
10+
<ul>
11+
<li>Linux:
12+
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-x64" target="_blank">amd64/x86_64 v6.0</a>,
13+
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-arm64" target="_blank">arm64/aarch64 v6.0</a>,
14+
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-armv7" target="_blank">armv7 (rPi) v6.0</a>
15+
</li>
16+
<li>Windows:
17+
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-windows.exe" target="_blank">amd64/x86_64 v6.0</a>
18+
</li>
19+
<li>Mac OS:
20+
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-macos-x64" target="_blank">amd64/x86_64 v6.0</a>,
21+
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-macos-arm64" target="_blank">arm64/aarch64 v6.0</a>
22+
</li>
23+
</ul>
24+
</p>
25+
<p>Copy and paste the following text into your <code>~/.ssh/config</code> file:</p>
26+
<pre>{{ text }}</pre>
27+
<p>N.B.
28+
<ul>
29+
<li>After you paste, don't forget to edit the text to specify the correct path to your downloaded <code>wstunnel</code> binary.</li>
30+
<li>On Unix-like systems, be sure to run <code>chmod a+x</code> on your <code>wstunnel</code> binary to make it executable.</li>
31+
<li>Comment or remove the <code>Hostname</code> line if you prefer a separate <code>known_hosts</code> record for each devtainer;
32+
doing this also works around a bug in Mac OS Terminal that repeatedly complains about missing <code>known_hosts</code> entries.</li>
33+
<li>For better results on Mac OS, use <a href="https://iterm2.com/" target="_blank" v-b-tooltip title="Open iterm2 in new tab">iTerm2</a>.</li>
34+
</ul>
35+
</p>
36+
<b-button variant="outline-success" size="sm" type="button" @click="copy(text)">Copy</b-button>
37+
<template #modal-footer>
38+
<b-button variant="primary" @click="closeModal">OK</b-button>
39+
</template>
40+
</b-modal>
41+
</template>
42+
43+
<script>
44+
import copyToClipboard from '@/utilities/copy-to-clipboard';
45+
import { getAuthCookies } from '@/services/container';
46+
47+
export default {
48+
name: 'SSHInfo',
49+
data() {
50+
return {
51+
showModal: false,
52+
cookies: "<UNKNOWN>"
53+
};
54+
},
55+
methods: {
56+
openModal() {
57+
this.showModal = true;
58+
},
59+
onModalShow() {
60+
this.getCookies();
61+
},
62+
closeModal() {
63+
this.showModal = false;
64+
},
65+
copy(value) {
66+
copyToClipboard(value);
67+
},
68+
getCookies() {
69+
getAuthCookies()
70+
.then(data => {
71+
// Escape '%' suitably for .ssh/config file
72+
this.cookies = data.data.replace(/%/g, '%%');
73+
})
74+
.catch((error) => {
75+
if(error.response && error.response.status == 401) {
76+
console.log(error.response.data.msg);
77+
alert(error.response.data.msg);
78+
}
79+
else {
80+
console.error("Error fetching authentication cookie", error);
81+
}
82+
83+
84+
});
85+
}
86+
},
87+
computed: {
88+
sshHost() {
89+
// Port number required if running on non-standard ports
90+
return window.location.host;
91+
},
92+
sshHostname() {
93+
// No port number required
94+
return window.location.hostname;
95+
},
96+
sshWildcardHost() {
97+
// No port number required
98+
return 'ssh-*' + window.dockside.host.split(':')[0];
99+
},
100+
text() {
101+
return `Host ${this.sshWildcardHost}
102+
ProxyCommand <path/to>/wstunnel --hostHeader=%n "--customHeaders=Cookie: ${this.cookies}" -L stdio:127.0.0.1:%p wss://${this.sshHost}
103+
Hostname ${this.sshHostname}
104+
ForwardAgent yes`;
105+
}
106+
}
107+
};
108+
</script>

app/client/src/services/container.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,9 @@ const controlContainer = (id, cmd) => {
4141
return axios.get(url).then(response => response.data);
4242
};
4343

44-
export { getContainers, putContainer, controlContainer, createReservationUri, getReservationLogsUri };
44+
const getAuthCookies = () => {
45+
const url = `/getAuthCookies`;
46+
return axios.get(url).then(response => response.data);
47+
};
48+
49+
export { getContainers, putContainer, controlContainer, createReservationUri, getReservationLogsUri, getAuthCookies };

app/client/src/utilities/copy-to-clipboard.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const copyToClipboard = text => {
1+
const copyToClipboardLegacy = text => {
22
const copyTextArea = document.createElement('textarea');
33

44
copyTextArea.value = text;
@@ -10,4 +10,12 @@ const copyToClipboard = text => {
1010
document.body.removeChild(copyTextArea);
1111
};
1212

13-
export default copyToClipboard;
13+
export default async function copyToClipboard(text) {
14+
try {
15+
await navigator.clipboard.writeText(text);
16+
console.log("Text copied to clipboard successfully!");
17+
} catch (error) {
18+
console.error("Unable to copy text to clipboard using navigator.clipboard.writeText; using legacy method", error);
19+
copyToClipboardLegacy(text);
20+
}
21+
}

0 commit comments

Comments
 (0)