Skip to content

Commit

Permalink
Allow ssh access to devtainers (#17)
Browse files Browse the repository at this point in the history
# 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.
  • Loading branch information
struanb authored Jan 21, 2024
1 parent dd32cba commit 5cb52e1
Show file tree
Hide file tree
Showing 33 changed files with 775 additions and 237 deletions.
33 changes: 26 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ ARG ALPINE_VERSION=3.14
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} as theia-build

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

ARG OPT_PATH
ARG TARGETPLATFORM
Expand All @@ -21,18 +21,29 @@ ARG TARGETPLATFORM
#
ENV BASH_ENV=/tmp/theia-bash-env

# Some but not all needed wstunnel binaries are published on https://github.com/erebe/wstunnel.
# Others we have had to compile from source. To ensure build reliability/reproducibility, we here
# obtain wstunnel binaries from the Dockside Google Cloud Storage bucket. wstunnel is published
# under https://github.com/erebe/wstunnel/blob/master/LICENSE.
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
THEIA_VERSION=1.40.0; \
WSTUNNEL_BINARY="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-x64"; \
elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
THEIA_VERSION=1.40.0; \
WSTUNNEL_BINARY="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-arm64"; \
elif [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
THEIA_VERSION=1.35.0; \
WSTUNNEL_BINARY="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-armv7"; \
else \
THEIA_VERSION=1.40.0; \
echo "Build error: Unsupported architecture '$TARGETPLATFORM'" >&2; \
exit 1; \
fi; \
echo "export THEIA_VERSION=$THEIA_VERSION" >$BASH_ENV; \
echo "export WSTUNNEL_BINARY=$WSTUNNEL_BINARY" >$BASH_ENV; \
echo "export THEIA_VERSION=$THEIA_VERSION" >>$BASH_ENV; \
echo "export THEIA_PATH=$OPT_PATH/ide/theia/theia-$THEIA_VERSION" >>$BASH_ENV; \
echo 'echo THEIA_VERSION=$THEIA_VERSION THEIA_PATH=$THEIA_PATH' >>$BASH_ENV; \
echo 'echo WSTUNNEL_BINARY=$WSTUNNEL_BINARY' >>$BASH_ENV; \
echo 'echo TARGETPLATFORM=$TARGETPLATFORM' >>$BASH_ENV; \
echo '[ -d $THEIA_PATH/theia ] && cd $THEIA_PATH/theia || true' >>$BASH_ENV; \
echo -e '#!/bin/bash\n\nexec "$@"\n' >/tmp/theia-exec && chmod 755 /tmp/theia-exec; \
. $BASH_ENV
Expand All @@ -49,7 +60,10 @@ RUN mkdir -p $THEIA_PATH && \
cp -a /tmp/build/ide/theia/$THEIA_VERSION/bin $THEIA_PATH/

# Build Theia
RUN PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 && NODE_OPTIONS="--max_old_space_size=4096" && yarn config set network-timeout 600000 -g && yarn
RUN PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 && \
PUPPETEER_SKIP_DOWNLOAD=1 && \
NODE_OPTIONS="--max_old_space_size=4096" && \
yarn config set network-timeout 600000 -g && yarn

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

FROM theia-clean as theia-findelfs

ENV BINARIES="node busybox s6-svscan curl"
ENV BINARIES="node busybox s6-svscan curl dropbear dropbearkey jq"

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

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

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

# Create a separate volume for host-specific data to be shared
# read-only with devtainers
VOLUME $OPT_PATH/host

################################################################################
# INITIALISE /opt/dockside/bin
#
Expand All @@ -296,7 +315,7 @@ VOLUME $OPT_PATH
#
USER root
RUN . /tmp/theia-bash-env && \
mkdir -p $OPT_PATH/bin && \
mkdir -p $OPT_PATH/bin $OPT_PATH/host && \
cp -a $HOME/$APP/app/scripts/container/launch.sh $OPT_PATH/bin/ && \
ln -sfr $OPT_PATH/bin/launch.sh $OPT_PATH/launch.sh && \
cp -a $HOME/$APP/app/server/assets/ico/favicon.ico $THEIA_PATH/theia/lib/ && \
Expand Down
5 changes: 4 additions & 1 deletion app/client/src/components/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<b-row>
<Sidebar></Sidebar>
<Main></Main>
<SSHInfo></SSHInfo>
</b-row>
</b-container>
<Footer></Footer>
Expand All @@ -16,14 +17,16 @@
import Footer from '@/components/Footer';
import Sidebar from '@/components/Sidebar';
import Main from '@/components/Main';
import SSHInfo from '@/components/SSHInfo';
export default {
name: 'App',
components: {
Header,
Footer,
Sidebar,
Main
Main,
SSHInfo
},
created() {
this.updateStateFromRoute(this.$route);
Expand Down
29 changes: 27 additions & 2 deletions app/client/src/components/Container.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@
<th>&#8674;&nbsp;{{ router.name }} </th>
<td v-if="!isEditMode && !isPrelaunchMode">
<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>
<b-button v-if="router.type != 'passthru' && container.status >= 0" size="sm" variant="outline-secondary" v-on:click="copy(makeUri(router))">Copy</b-button>
<b-button v-if="router.type != 'passthru' && container.status >= 0" size="sm" variant="outline-secondary" v-on:click="copyUri(router)">Copy</b-button>
<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>
({{ container.meta.access[router.name] }} access)
</td>
<td v-else>
Expand Down Expand Up @@ -319,7 +320,31 @@
copyToClipboard(value);
},
makeUri(router) {
return [router.https ? 'https' : 'http', '://', (router.prefixes[0] ? router.prefixes[0] : 'www'), '-', this.container.name, window.dockside.host].join('');
const protocol = router.https ? 'https' : 'http';
const prefix = router.prefixes[0] ? router.prefixes[0] : 'www';
const containerName = this.container.name;
const host = window.dockside.host;
if (router.type !== 'ssh') {
return `${protocol}://${prefix}-${containerName}${host}`;
} else {
const unixuser = this.container.data.unixuser;
const hostname = host.split(':')[0];
return `ssh://${unixuser}@${prefix}-${containerName}${hostname}`;
}
},
copyUri(router) {
if (router.type !== 'ssh') {
return copyToClipboard(this.makeUri(router));
}
const prefix = router.prefixes[0] ? router.prefixes[0] : 'www';
const containerName = this.container.name;
const host = window.dockside.host;
const unixuser = this.container.data.unixuser;
const hostname = host.split(':')[0];
return copyToClipboard(`ssh ${unixuser}@${prefix}-${containerName}${hostname}`);
},
makeUriTarget(router) {
return [(router.prefixes[0] ? router.prefixes[0] : 'www'), '-', this.container.name, window.dockside.host].join('');
Expand Down
108 changes: 108 additions & 0 deletions app/client/src/components/SSHInfo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// https://bootstrap-vue.org/docs/components/modal#modal

<template>
<b-modal id="sshinfo-modal" size="lg" v-model="showModal" @show="onModalShow" title="How to set up SSH" centered>
<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>
(<a href="https://github.com/erebe/wstunnel/blob/master/LICENSE" target="_blank" v-b-tooltip title="Open in new tab">LICENSE</a>)
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>
or the Dockside public bucket (which comprises copies of officially-released binaries and binaries compiled by Dockside):</p>
<p>
<ul>
<li>Linux:
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-x64" target="_blank">amd64/x86_64 v6.0</a>,
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-arm64" target="_blank">arm64/aarch64 v6.0</a>,
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-linux-armv7" target="_blank">armv7 (rPi) v6.0</a>
</li>
<li>Windows:
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-windows.exe" target="_blank">amd64/x86_64 v6.0</a>
</li>
<li>Mac OS:
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-macos-x64" target="_blank">amd64/x86_64 v6.0</a>,
<a href="https://storage.googleapis.com/dockside/wstunnel/v6.0/wstunnel-v6.0-macos-arm64" target="_blank">arm64/aarch64 v6.0</a>
</li>
</ul>
</p>
<p>Copy and paste the following text into your <code>~/.ssh/config</code> file:</p>
<pre>{{ text }}</pre>
<p>N.B.
<ul>
<li>After you paste, don't forget to edit the text to specify the correct path to your downloaded <code>wstunnel</code> binary.</li>
<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>
<li>Comment or remove the <code>Hostname</code> line if you prefer a separate <code>known_hosts</code> record for each devtainer;
doing this also works around a bug in Mac OS Terminal that repeatedly complains about missing <code>known_hosts</code> entries.</li>
<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>
</ul>
</p>
<b-button variant="outline-success" size="sm" type="button" @click="copy(text)">Copy</b-button>
<template #modal-footer>
<b-button variant="primary" @click="closeModal">OK</b-button>
</template>
</b-modal>
</template>

<script>
import copyToClipboard from '@/utilities/copy-to-clipboard';
import { getAuthCookies } from '@/services/container';
export default {
name: 'SSHInfo',
data() {
return {
showModal: false,
cookies: "<UNKNOWN>"
};
},
methods: {
openModal() {
this.showModal = true;
},
onModalShow() {
this.getCookies();
},
closeModal() {
this.showModal = false;
},
copy(value) {
copyToClipboard(value);
},
getCookies() {
getAuthCookies()
.then(data => {
// Escape '%' suitably for .ssh/config file
this.cookies = data.data.replace(/%/g, '%%');
})
.catch((error) => {
if(error.response && error.response.status == 401) {
console.log(error.response.data.msg);
alert(error.response.data.msg);
}
else {
console.error("Error fetching authentication cookie", error);
}
});
}
},
computed: {
sshHost() {
// Port number required if running on non-standard ports
return window.location.host;
},
sshHostname() {
// No port number required
return window.location.hostname;
},
sshWildcardHost() {
// No port number required
return 'ssh-*' + window.dockside.host.split(':')[0];
},
text() {
return `Host ${this.sshWildcardHost}
ProxyCommand <path/to>/wstunnel --hostHeader=%n "--customHeaders=Cookie: ${this.cookies}" -L stdio:127.0.0.1:%p wss://${this.sshHost}
Hostname ${this.sshHostname}
ForwardAgent yes`;
}
}
};
</script>
7 changes: 6 additions & 1 deletion app/client/src/services/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@ const controlContainer = (id, cmd) => {
return axios.get(url).then(response => response.data);
};

export { getContainers, putContainer, controlContainer, createReservationUri, getReservationLogsUri };
const getAuthCookies = () => {
const url = `/getAuthCookies`;
return axios.get(url).then(response => response.data);
};

export { getContainers, putContainer, controlContainer, createReservationUri, getReservationLogsUri, getAuthCookies };
12 changes: 10 additions & 2 deletions app/client/src/utilities/copy-to-clipboard.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const copyToClipboard = text => {
const copyToClipboardLegacy = text => {
const copyTextArea = document.createElement('textarea');

copyTextArea.value = text;
Expand All @@ -10,4 +10,12 @@ const copyToClipboard = text => {
document.body.removeChild(copyTextArea);
};

export default copyToClipboard;
export default async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
console.log("Text copied to clipboard successfully!");
} catch (error) {
console.error("Unable to copy text to clipboard using navigator.clipboard.writeText; using legacy method", error);
copyToClipboardLegacy(text);
}
}
Loading

0 comments on commit 5cb52e1

Please sign in to comment.