Compare commits

..

75 Commits
3.0.0 ... 3.2.0

Author SHA1 Message Date
Asher
fd36a99a4c Update vscode patch notes and bump version 2020-04-29 15:22:11 -05:00
Asher
4b09746c37 Merge pull request #1574 from cdr/transformer
Remove transformer file
2020-04-29 12:45:07 -05:00
Asher
870cf4f3fe Fix yarn.lock
Things got really out of whack when trying to update dependencies
earlier.
2020-04-29 12:39:42 -05:00
Asher
1ff35f177d Remove transformer file
Also remove some unused imports that were causing build errors (they
were left over from the fix that allowed installing any extension kind).
2020-04-29 12:13:44 -05:00
Asher
f3edb1cc5f Update node to latest lts (12.16.3) and update deps 2020-04-29 11:43:13 -05:00
Asher
86dc38e69f Allow extensions of any kind
This enables vscode-icons among others.
2020-04-28 17:57:56 -05:00
Asher
a2b69c8f3f Fix inconsistencies in log flags and env var
- Fix priority to match the commented behavior.
- Ignore bogus LOG_LEVEL values.
2020-04-28 17:57:55 -05:00
Asher
4cfd7c50ad Remove unused class
I managed to lose this deletion in a merge.
2020-04-28 17:57:54 -05:00
Anmol Sethi
a96606e589 Fix mention of host/port in docs 2020-04-28 18:29:25 -04:00
Anmol Sethi
30aefe19b5 Update issue template to mention check against regular VS Code 2020-04-28 14:50:08 -04:00
Anmol Sethi
37184f456c Merge pull request #1562 from cdr/bindaddr
Deprecate --host and --port in favour of --bind-addr
2020-04-28 14:33:38 -04:00
Anmol Sethi
05456024c4 Merge pull request #1561 from cdr/ratelimit
Add basic rate limiting to login endpoint
2020-04-28 14:33:18 -04:00
Anmol Sethi
5accf3fe5f Add basic rate limiting to login endpoint
Closes #1320
2020-04-28 14:21:08 -04:00
Anmol Sethi
2dd27b4cb8 gitignore release-upload 2020-04-28 14:19:25 -04:00
Anmol Sethi
af28885ea6 Deprecate --host and --port in favour of --bind-addr 2020-04-28 14:19:24 -04:00
Anmol Sethi
f21ba53609 Merge pull request #1563 from cdr/remove-ssh
Remove SSH server
2020-04-28 14:15:54 -04:00
Anmol Sethi
181e0ea6c8 Remove ssh2 dep 2020-04-28 14:04:56 -04:00
Asher
6074ca275b Fill out some missing browser environment values
Pass the user data dir to the browser environment service then derive
all the paths we can based off that path like the global storage path
which the vim extension uses to store history (otherwise it gets stored
in the working directory from when code-server was spawned).

Arguably the better solution is to use the userdata scheme but that
won't work because the vim extension ignores the VS Code API.

Fixes #1551.
2020-04-27 17:15:37 -05:00
Anmol Sethi
d0d5461a67 Remove SSH server
Closes #1502
2020-04-27 09:27:45 -04:00
Anmol Sethi
8608ae2f08 Merge pull request #1546 from cdr/readlink-mac
Fix code-server.sh script on macOS
2020-04-22 18:01:25 -04:00
Anmol Sethi
401f08db63 Fix code-server.sh script on macOS 2020-04-22 17:49:02 -04:00
Asher
caa299b60d Update VS Code to 1.44.2 2020-04-21 14:25:27 -05:00
Asher
dcde596002 Document debugging process
Closes #1465.
2020-04-20 18:55:14 -05:00
Asher
ee14db20f1 Allow data: in CSP for font-src
Closes #1530.
2020-04-20 18:10:07 -05:00
Asher
27ba64c7e4 Improve request error handling
See #1532 for more context.

- Errored JSON requests will get back the error in JSON instead of using
  the status text. This seems better to me because it seems more correct
  to utilize the response body over hijacking the status text. The
  caller is expecting JSON anyway. Worst of all I never actually set the
  status text like I thought I did so it wasn't working to begin with.
- Allow the update error to propagate for JSON update requests. It was
  caught to show the error inline instead of an error page when using
  the update page but for JSON requests it meant there was no error and
  no error code so it looked like it succeeded.
- Make errors for failed requests to GitHub less incomprehensible.
  Previously they would just be the code which is no context at all.
2020-04-17 15:16:10 -05:00
Anmol Sethi
c7753f2cf9 Update docker one liner to forward UID/GID
Closes #1425
2020-04-16 14:57:41 -04:00
Asher
974d4cb8fc Allow specifying a workspace on the command line
Fixes #1535.
2020-04-16 11:56:46 -05:00
Charles Moog
29b6115c77 Adds dev container and docs (#1499) 2020-04-14 17:22:52 -05:00
Asher
28e91ba70c Fix domain issues when setting the cookie
Fixes #1507.
2020-04-13 16:14:40 -05:00
Asher
5aded14b87 Merge pull request #1453 from cdr/proxy
HTTP proxy
2020-04-08 12:44:29 -05:00
Asher
a288351ad4 Respond when proxy errors
Otherwise the request will just hang.
2020-04-08 11:54:18 -05:00
Asher
3b39482420 Document workspace and folder behavior
Also fixed a type issue.
2020-04-07 17:49:50 -05:00
Asher
a5c35af81b Fix encoding issues with folder and workspace params
The raw value is now passed back to VS Code so it can do the parsing
with its own URI class rather than trying to parse using Node's url
module first since that has no guarantee of working the same way. It
also lets us keep the vscode-remote bit internal to VS Code.

Removed the logic that keeps trying paths until it finds a valid one
because it seems confusing to open a path and silently get some other
path instead of an error for the one you tried to open. Now it'll just
use exactly what you specified or fail trying.

Fixes #1488. The problem here was that url.parse was encoding the spaces
then the validation failed looking for a literal %20.
2020-04-07 15:18:19 -05:00
Charles Moog
b78bdaf46e Merge pull request #1496 from cdr/report-issue-url
Send report issues to code-server repo
2020-04-06 17:29:53 -05:00
cmoog
aefef5b0e8 Send report issues to code-server repo 2020-04-06 22:23:14 +00:00
Abin Simon
ca998240a0 Fix typo in FAQ (#1489) 2020-04-03 13:09:32 -05:00
Asher
d2a31477c7 Merge pull request #1486 from cdr/update-backup
Back up old directory when updating
2020-04-02 17:28:27 -05:00
Asher
9c6581273e Show proper error when an update fails 2020-04-02 17:20:25 -05:00
Asher
d1445a8135 Back up code-server directory when updating 2020-04-02 16:21:48 -05:00
Asher
5fc00acc39 Fix incorrect reporting that an update failed 2020-04-02 14:48:15 -05:00
Asher
363cdd02df Improve proxy documentation 2020-04-02 13:40:30 -05:00
Asher
a5d1d3b90e Move proxy logic into main HTTP server
This makes the code much more internally consistent (providers just
return payloads, include the proxy provider).
2020-04-02 13:40:29 -05:00
Asher
aaa6c279a1 Use Set for proxy domains 2020-04-02 13:40:28 -05:00
Asher
498becd11f Use route.fullPath when adding trailing slash
There's no need to specially construct the path.
2020-04-02 13:40:27 -05:00
Asher
411c61fb02 Create helper for determining if route is the root 2020-04-02 13:40:26 -05:00
Asher
74a0bacdcf Rename hxxp to isHttp 2020-04-02 13:40:25 -05:00
Asher
e7e7b0ffb7 Fix redirects through subpath proxy 2020-04-02 13:40:25 -05:00
Asher
fd339a7433 Include query parameters when proxying 2020-04-02 13:40:24 -05:00
Asher
561b6343c8 Ensure a trailing slash on subpath proxy 2020-04-02 13:40:23 -05:00
Asher
e68d72c4d6 Add documentation for proxying 2020-04-02 13:40:22 -05:00
Asher
737a8f5965 Catch proxy errors
Otherwise they'll crash code-server.
2020-04-02 13:40:21 -05:00
Asher
c0dd29c591 Fix domains with ports & localhost subdomains 2020-04-02 13:40:20 -05:00
Asher
8aa5675ba2 Implement the actual proxy 2020-04-02 13:40:19 -05:00
Asher
2086648c87 Only handle exact domain matches
This simplifies the logic a bit.
2020-04-02 13:40:18 -05:00
Asher
3a98d856a5 Handle authentication with proxy
The cookie will be set for the proxy domain so it'll work for all of its
subdomains.
2020-04-02 13:40:17 -05:00
Asher
90fd1f7dd1 Add proxy provider
It'll be able to handle /proxy requests as well as subdomains.
2020-04-02 13:40:16 -05:00
Asher
77ad73d579 Set domain on cookie
This allows it to be used in subdomains.
2020-04-02 13:40:15 -05:00
Asher
13534fa0c0 Add proxy-domain flag
This will be used for proxying ports.
2020-04-02 13:40:14 -05:00
Asher
37299abcc9 Minor startup code improvements
- Add type to HTTP options.
- Fix certificate message always saying it was generated.
- Dedent output not directly related to the HTTP server.
- Remove unnecessary comma.
2020-04-02 13:40:13 -05:00
Asher
e480f6527e Update VS Code to 1.43.2 2020-04-01 15:27:28 -05:00
Asher
26584f2060 Strip protocol from remote authority
In Google cloud shell the host header is 127.0.0.1:8080 instead of the
actual URL. This is what we write out to the HTML so VS Code can pick it
up. However cloud shell rewrites this string when found in the HTML
before serving it so it becomes https://8080-[...].appspot.com,
resulting in an extra unexpected https:// in the
URI (vscode-remote://https://8080[...]). The resulting malformed URI
causes the extension host to exit.

- Fixes #1471
- Fixes #1468
- Fixes #1440 (most likely).
2020-04-01 13:41:05 -05:00
Asher
a4c0fd1fdc Run ssh server listen after http
That way if they happen to conflict code-server doesn't crash.
2020-03-30 17:43:11 -05:00
Asher
6c104c016e Prevent exiting when an exception is uncaught 2020-03-30 17:43:10 -05:00
Asher
599670136d Output commit along with the version 2020-03-30 17:43:09 -05:00
Asher
ce637d318d Add descriptions to SSH flags 2020-03-30 17:43:08 -05:00
Anmol Sethi
d8654b5a19 Merge pull request #1460 from mjgallag/peg-yarn-version
Peg yarn version to ensure deterministic builds
2020-03-30 01:52:14 -04:00
Michael Gallagher
12c3ccd6c7 Peg yarn version to ensure deterministic builds
"Yarn is fully deterministic as long as all your teammates are using the same Yarn version." (https://classic.yarnpkg.com/blog/2017/05/31/determinism/)
2020-03-28 14:29:04 -07:00
Asher
7954656610 Set background color using VS Code theme 2020-03-27 16:58:50 -05:00
Asher
87ebf03eb7 Skip vscode dependencies for test phase
They aren't used so we can skip them.
2020-03-27 13:40:42 -05:00
Asher
df1c34e291 Overwrite GitHub releases again
I was under the impression this was causing existing releases to become
drafts again but that happens without this flag.
2020-03-27 12:03:01 -05:00
Asher
4a65b58772 Fix arm builds 2020-03-27 12:02:56 -05:00
Asher
11fdb8854b Skip unused dependencies 2020-03-26 15:12:17 -05:00
Asher
0a92bb1607 Fix node version mismatch 2020-03-26 13:54:41 -05:00
Asher
5bac2cbdb8 Add build test 2020-03-26 13:54:40 -05:00
Asher
511c3e95b2 Remove npm rebuild 2020-03-25 17:07:26 -05:00
43 changed files with 2131 additions and 1939 deletions

View File

@@ -21,3 +21,4 @@ extends:
rules:
# For overloads.
no-dupe-class-members: off
"@typescript-eslint/no-use-before-define": off

View File

@@ -2,5 +2,10 @@
Please file all questions and support requests at https://www.reddit.com/r/codeserver/
The issue tracker is only for bugs.
Please see https://github.com/cdr/code-server/blob/master/doc/FAQ.md#how-do-i-debug-issues-with-code-server
and include any logging information relevant to the issue.
Please search for existing issues before filing.
Please ensure you cannot reproduce on VS Code before filing.
-->

3
.gitignore vendored
View File

@@ -3,6 +3,7 @@
build
dist*
out*
release*
release/
release-upload/
node_modules
binaries

View File

@@ -4,17 +4,17 @@ jobs:
include:
- name: Test
if: tag IS blank
script: ./ci/image/run.sh "yarn && GITHUB_TOKEN=3229b0eec0a1622d6d1d1e00fca5b626070f5a10 yarn vscode && ./ci/ci.sh"
script: ./ci/image/run.sh "yarn && git submodule update --init && yarn vscode:patch && ./ci/ci.sh"
deploy: null
- name: Linux Release
if: tag IS present
script:
- travis_wait 60 ./ci/image/run.sh "yarn && yarn vscode && ci/release.sh"
- travis_wait 60 ./ci/image/run.sh "yarn && yarn vscode && ci/release.sh && ./ci/build-test.sh"
- ./ci/release-image/push.sh
- name: Linux ARM64 Release
if: tag IS present
script:
- ./ci/image/run.sh "yarn && yarn vscode && ci/release.sh"
- ./ci/image/run.sh "yarn && yarn vscode && ci/release.sh && ./ci/build-test.sh"
- ./ci/release-image/push.sh
arch: arm64
- name: MacOS Release
@@ -22,7 +22,7 @@ jobs:
os: osx
language: node_js
node_js: 12
script: yarn && yarn vscode && travis_wait 60 ci/release.sh
script: yarn && yarn vscode && travis_wait 60 ci/release.sh && ./ci/build-test.sh
before_deploy:
- echo "$JSON_KEY" | base64 --decode > ./ci/key.json
@@ -31,6 +31,7 @@ deploy:
- provider: releases
edge: true
draft: true
overwrite: true
tag_name: $TRAVIS_TAG
target_commitish: $TRAVIS_COMMIT
name: $TRAVIS_TAG
@@ -47,6 +48,8 @@ deploy:
local_dir: release-upload
on:
tags: true
# TODO: The gcs provider fails to install on arm64.
condition: $TRAVIS_CPU_ARCH = amd64
cache:
timeout: 600

View File

@@ -6,7 +6,7 @@ remote server, accessible through the browser.
Try it out:
```bash
docker run -it -p 127.0.0.1:8080:8080 -v "$PWD:/home/coder/project" codercom/code-server
docker run -it -p 127.0.0.1:8080:8080 -v "$PWD:/home/coder/project" -u "$(id -u):$(id -g)" codercom/code-server:latest
```
- **Code anywhere:** Code on your Chromebook, tablet, and laptop with a

21
ci/build-test.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# build-test.bash -- Make sure the build worked.
# This is to make sure we don't have Node version errors or any other
# compilation-related errors.
set -euo pipefail
function main() {
cd "$(dirname "${0}")/.." || exit 1
local output
output=$(node ./build/out/node/entry.js --list-extensions 2>&1)
if echo "$output" | grep 'was compiled against a different Node.js version'; then
echo "$output"
exit 1
else
echo "Build ran successfully"
fi
}
main "$@"

View File

@@ -1,6 +1,18 @@
#!/usr/bin/env sh
# code-server.sh -- Run code-server with the bundled Node binary.
# Runs code-server with the bundled Node binary.
dir="$(dirname "$(readlink -f "$0" || realpath "$0")")"
# More complicated than readlink -f or realpath to support macOS.
# See https://github.com/cdr/code-server/issues/1537
get_installation_dir() {
# We read the symlink, which may be relative from $0.
dst="$(readlink "$0")"
# We cd into the $0 directory.
cd "$(dirname "$0")"
# Now we can cd into the dst directory.
cd "$(dirname "$dst")"
# Finally we use pwd -P to print the absolute path of the directory of $dst.
pwd -P
}
dir=$(get_installation_dir)
exec "$dir/node" "$dir/out/node/entry.js" "$@"

13
ci/dev-image/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:12
RUN apt-get update && apt-get install -y \
curl \
iproute2 \
vim \
iptables \
net-tools \
libsecret-1-dev \
libx11-dev \
libxkbfile-dev
CMD ["/bin/bash"]

49
ci/dev-image/exec.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# exec.sh opens an interactive bash session inside of a docker container
# for improved isolation during development
# if the container exists it is restarted if necessary, then reused
set -euo pipefail
cd "$(dirname "$0")"
# Ensure submodules are cloned and up to date.
git submodule update --init
container_name=code-server-dev
enter() {
echo "--- Entering $container_name"
docker exec -it $container_id /bin/bash
}
run() {
echo "--- Spawning $container_name"
container_id=$(docker run \
-it \
--name $container_name \
"-v=$PWD:/code-server" \
"-w=/code-server" \
"-p=127.0.0.1:8080:8080" \
$([[ -t 0 ]] && echo -it || true) \
$container_name)
}
build() {
echo "--- Building $container_name"
cd ../../
docker build -t $container_name -f ./ci/dev-image/Dockerfile . > /dev/null
}
container_id=$(docker container inspect --format="{{.Id}}" $container_name 2> /dev/null) || true
if [ "$container_id" != "" ]; then
echo "-- Starting container"
docker start $container_id > /dev/null
enter
exit 0
fi
build
run
enter

View File

@@ -13,9 +13,9 @@ RUN yum update -y && yum install -y \
libx11-devel
RUN mkdir /usr/share/node && cd /usr/share/node \
&& curl "https://nodejs.org/dist/v12.14.0/node-v12.14.0-linux-$(uname -m | sed 's/86_//; s/aarch/arm/').tar.xz" | tar xJ --strip-components=1 --
&& curl "https://nodejs.org/dist/v12.16.3/node-v12.16.3-linux-$(uname -m | sed 's/86_//; s/aarch/arm/').tar.xz" | tar xJ --strip-components=1 --
ENV PATH "$PATH:/usr/share/node/bin"
RUN npm install -g yarn
RUN npm install -g yarn@1.22.4
RUN curl -L "https://github.com/mvdan/sh/releases/download/v3.0.1/shfmt_v3.0.1_linux_$(uname -m | sed 's/x86_/amd/; s/aarch64/arm/')" > /usr/local/bin/shfmt \
&& chmod +x /usr/local/bin/shfmt

View File

@@ -5,9 +5,22 @@ set -euo pipefail
main() {
cd "$(dirname "$0")/../.."
# This, strangely enough, fixes the arm build being terminated for not having
# output on Travis. It's as if output is buffered and only displayed once a
# certain amount is collected. Five seconds didn't work but one second seems
# to generate enough output to make it work.
local pid
while true; do
echo 'Still running...'
sleep 1
done &
pid=$!
docker build ci/image
imageTag="$(docker build -q ci/image)"
docker run -t --rm -e CI -e GITHUB_TOKEN -e TRAVIS_TAG -v "$(yarn cache dir):/usr/local/share/.cache/yarn/v6" -v "$PWD:/repo" -w /repo "$imageTag" "$*"
kill $pid
}
main "$@"

View File

@@ -40,4 +40,4 @@ RUN cd /tmp && tar -xzf code-server*.tar.gz && rm code-server*.tar.gz && \
EXPOSE 8080
USER coder
WORKDIR /home/coder
ENTRYPOINT ["dumb-init", "fixuid", "-q", "/usr/local/bin/code-server", "--host", "0.0.0.0", "."]
ENTRYPOINT ["dumb-init", "fixuid", "-q", "/usr/local/bin/code-server", "--bind-addr", "0.0.0.0:8080", "."]

View File

@@ -17,7 +17,7 @@ function package() {
fi
local arch
arch="$(uname -m)"
arch=$(uname -m | sed 's/aarch/arm/')
echo -n "Creating release..."
@@ -43,6 +43,7 @@ function package() {
echo "done (release/$archive_name)"
# release-upload is for uploading to the GCP bucket whereas release is used for GitHub.
mkdir -p "./release-upload/$VERSION"
cp "./release/$archive_name$ext" "./release-upload/$VERSION/$target-$arch$ext"
mkdir -p "./release-upload/latest"

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,6 @@ main() {
cd lib/vscode
# Install VS Code dependencies.
yarn
# NODE_MODULE_VERSION mismatch errors without this.
npm rebuild
)
}

View File

@@ -10,6 +10,16 @@ yarn vscode
yarn watch # Visit http://localhost:8080 once completed.
```
To develop inside of an isolated docker container:
```shell
./ci/dev-image/exec.sh
root@12345:/code-server# yarn
root@12345:/code-server# yarn vscode
root@12345:/code-server# yarn watch
```
Any changes made to the source will be live reloaded.
If changes are made to the patch and you've built previously you must manually

View File

@@ -19,7 +19,7 @@ As a result, Coder has created its own marketplace for open source extensions. I
GitHub for VS Code extensions and building them. It's not perfect but getting better by the day with
more and more extensions.
Issue [https://github.com/cdr/code-server/issues/1299](#1299) is a big one in making the experience here
Issue [#1299](https://github.com/cdr/code-server/issues/1299) is a big one in making the experience here
better by allowing the community to submit extensions and repos to avoid waiting until the scraper finds
an extension.
@@ -52,6 +52,8 @@ randomly generated password so you can use that. You can set the `PASSWORD` envi
to use your own instead. If you want to handle authentication yourself, use `--auth none`
to disable password authentication.
**note**: code-server will rate limit password authentication attempts at 2 a minute and 12 an hour.
If you want to use external authentication you should handle this with a reverse
proxy using something like [oauth2_proxy](https://github.com/pusher/oauth2_proxy).
@@ -65,6 +67,35 @@ only to HTTP requests.
You can use [Let's Encrypt](https://letsencrypt.org/) to get an SSL certificate
for free.
## How do I securely access web services?
code-server is capable of proxying to any port using either a subdomain or a
subpath which means you can securely access these services using code-server's
built-in authentication.
### Sub-domains
You will need a DNS entry that points to your server for each port you want to
access. You can either set up a wildcard DNS entry for `*.<domain>` if your domain
name registrar supports it or you can create one for every port you want to
access (`3000.<domain>`, `8080.<domain>`, etc).
You should also set up TLS certificates for these subdomains, either using a
wildcard certificate for `*.<domain>` or individual certificates for each port.
Start code-server with the `--proxy-domain` flag set to your domain.
```
code-server --proxy-domain <domain>
```
Now you can browse to `<port>.<domain>`. Note that this uses the host header so
ensure your reverse proxy forwards that information if you are using one.
### Sub-paths
Just browse to `/proxy/<port>/`.
## x86 releases?
node has dropped support for x86 and so we decided to as well. See
@@ -104,6 +135,44 @@ demand and will work on it when the time is right.
Use the `--disable-telemetry` flag to completely disable telemetry. We use the
data collected only to improve code-server.
## How does code-server decide what workspace or folder to open?
code-server tries the following in order:
1. The `workspace` query parameter.
2. The `folder` query parameter.
3. The workspace or directory passed on the command line.
4. The last opened workspace or directory.
## How do I debug issues with code-server?
First run code-server with at least `debug` logging (or `trace` to be really
thorough) by setting the `--log` flag or the `LOG_LEVEL` environment variable.
`-vvv` and `--verbose` are aliases for `--log trace`.
```
code-server --log debug
```
Once this is done, replicate the issue you're having then collect logging
information from the following places:
1. stdout.
2. The most recently created directory in the `logs` directory (found in the
data directory; see below for how to find that).
3. The browser console and network tabs.
Additionally, collecting core dumps (you may need to enable them first) if
code-server crashes can be helpful.
### Where is the data directory?
If the `XDG_DATA_HOME` environment variable is set the data directory will be
`$XDG_DATA_HOME/code-server`. Otherwise the default is:
1. Linux: `~/.local/share/code-server`.
2. Mac: `~/Library/Application\ Support/code-server`.
## Enterprise
Visit [our enterprise page](https://coder.com) for more information about our

View File

@@ -1,7 +1,7 @@
{
"name": "code-server",
"license": "MIT",
"version": "3.0.1",
"version": "3.2.0",
"scripts": {
"clean": "ci/clean.sh",
"vscode": "ci/vscode.sh",
@@ -17,6 +17,7 @@
"devDependencies": {
"@types/adm-zip": "^0.4.32",
"@types/fs-extra": "^8.0.1",
"@types/http-proxy": "^1.17.4",
"@types/mocha": "^5.2.7",
"@types/node": "^12.12.7",
"@types/parcel-bundler": "^1.12.1",
@@ -24,8 +25,6 @@
"@types/safe-compare": "^1.1.0",
"@types/semver": "^7.1.0",
"@types/tar-fs": "^1.16.2",
"@types/ssh2": "0.5.39",
"@types/ssh2-streams": "^0.1.6",
"@types/tar-stream": "^1.6.1",
"@types/ws": "^6.0.4",
"@typescript-eslint/eslint-plugin": "^2.0.0",
@@ -52,13 +51,14 @@
"@coder/logger": "1.1.11",
"adm-zip": "^0.4.14",
"fs-extra": "^8.1.0",
"http-proxy": "^1.18.0",
"httpolyglot": "^0.1.2",
"limiter": "^1.1.5",
"node-pty": "^0.9.0",
"pem": "^1.14.2",
"safe-compare": "^1.1.4",
"semver": "^7.1.3",
"tar": "^6.0.1",
"ssh2": "^0.8.7",
"tar-fs": "^2.0.0",
"ws": "^7.2.0"
}

View File

@@ -8,7 +8,7 @@
/>
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />

View File

@@ -6,7 +6,10 @@
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;" />
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
<title>{{ERROR_TITLE}} - code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link

View File

@@ -8,7 +8,7 @@
/>
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
@@ -17,7 +17,7 @@
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
crossorigin="use-credentials"
/>
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.pnggg" />
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" />
</head>

View File

@@ -8,7 +8,7 @@
/>
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"
content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
<title>code-server login</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />

View File

@@ -6,7 +6,10 @@
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;" />
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link

View File

@@ -6,7 +6,7 @@
<meta
http-equiv="Content-Security-Policy"
content="font-src 'self'; connect-src ws: wss: 'self' https:; default-src ws: wss: 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; manifest-src 'self'; img-src 'self' data: https:;"
content="font-src 'self' data:; connect-src ws: wss: 'self' https:; default-src ws: wss: 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; manifest-src 'self'; img-src 'self' data: https:;"
/>
<!-- Disable pinch zooming -->
@@ -100,4 +100,11 @@
<script>
require(["vs/code/browser/workbench/workbench"], function() {})
</script>
<script>
try {
document.body.style.background = JSON.parse(localStorage.getItem("colorThemeData")).colorMap["editor.background"]
} catch (error) {
// Oh well.
}
</script>
</html>

View File

@@ -1,204 +0,0 @@
import { field, logger, Logger } from "@coder/logger"
import { Emitter } from "../common/emitter"
import { generateUuid } from "../common/util"
const decoder = new TextDecoder("utf8")
export const decode = (buffer: string | ArrayBuffer): string => {
return typeof buffer !== "string" ? decoder.decode(buffer) : buffer
}
/**
* A web socket that reconnects itself when it closes. Sending messages while
* disconnected will throw an error.
*/
export class ReconnectingSocket {
protected readonly _onMessage = new Emitter<string | ArrayBuffer>()
public readonly onMessage = this._onMessage.event
protected readonly _onDisconnect = new Emitter<number | undefined>()
public readonly onDisconnect = this._onDisconnect.event
protected readonly _onClose = new Emitter<number | undefined>()
public readonly onClose = this._onClose.event
protected readonly _onConnect = new Emitter<void>()
public readonly onConnect = this._onConnect.event
// This helps distinguish messages between sockets.
private readonly logger: Logger
private socket?: WebSocket
private connecting?: Promise<void>
private closed = false
private readonly openTimeout = 10000
// Every time the socket fails to connect, the retry will be increasingly
// delayed up to a maximum.
private readonly retryBaseDelay = 1000
private readonly retryMaxDelay = 10000
private retryDelay?: number
private readonly retryDelayFactor = 1.5
// The socket must be connected for this amount of time before resetting the
// retry delay. This prevents rapid retries when the socket does connect but
// is closed shortly after.
private resetRetryTimeout?: NodeJS.Timeout
private readonly resetRetryDelay = 10000
private _binaryType: typeof WebSocket.prototype.binaryType = "arraybuffer"
public constructor(private path: string, public readonly id: string = generateUuid(4)) {
// On Firefox the socket seems to somehow persist a page reload so the close
// event runs and we see "attempting to reconnect".
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", () => this.close())
}
this.logger = logger.named(this.id)
}
public set binaryType(b: typeof WebSocket.prototype.binaryType) {
this._binaryType = b
if (this.socket) {
this.socket.binaryType = b
}
}
/**
* Permanently close the connection. Will not attempt to reconnect. Will
* remove event listeners.
*/
public close(code?: number): void {
if (this.closed) {
return
}
if (code) {
this.logger.info(`closing with code ${code}`)
}
if (this.resetRetryTimeout) {
clearTimeout(this.resetRetryTimeout)
}
this.closed = true
if (this.socket) {
this.socket.close()
} else {
this._onClose.emit(code)
}
}
public dispose(): void {
this._onMessage.dispose()
this._onDisconnect.dispose()
this._onClose.dispose()
this._onConnect.dispose()
this.logger.debug("disposed handlers")
}
/**
* Send a message on the socket. Logs an error if currently disconnected.
*/
public send(message: string | ArrayBuffer): void {
this.logger.trace(() => ["sending message", field("message", decode(message))])
if (!this.socket) {
return logger.error("tried to send message on closed socket")
}
this.socket.send(message)
}
/**
* Connect to the socket. Can also be called to wait until the connection is
* established in the case of disconnections. Multiple calls will be handled
* correctly.
*/
public async connect(): Promise<void> {
if (!this.connecting) {
this.connecting = new Promise((resolve, reject) => {
const tryConnect = (): void => {
if (this.closed) {
return reject(new Error("disconnected")) // Don't keep trying if we've closed permanently.
}
if (typeof this.retryDelay === "undefined") {
this.retryDelay = 0
} else {
this.retryDelay = this.retryDelay * this.retryDelayFactor || this.retryBaseDelay
if (this.retryDelay > this.retryMaxDelay) {
this.retryDelay = this.retryMaxDelay
}
}
this._connect()
.then((socket) => {
this.logger.info("connected")
this.socket = socket
this.socket.binaryType = this._binaryType
if (this.resetRetryTimeout) {
clearTimeout(this.resetRetryTimeout)
}
this.resetRetryTimeout = setTimeout(() => (this.retryDelay = undefined), this.resetRetryDelay)
this.connecting = undefined
this._onConnect.emit()
resolve()
})
.catch((error) => {
this.logger.error(`failed to connect: ${error.message}`)
tryConnect()
})
}
tryConnect()
})
}
return this.connecting
}
private async _connect(): Promise<WebSocket> {
const socket = await new Promise<WebSocket>((resolve, _reject) => {
if (this.retryDelay) {
this.logger.info(`retrying in ${this.retryDelay}ms...`)
}
setTimeout(() => {
this.logger.info("connecting...", field("path", this.path))
const socket = new WebSocket(this.path)
const reject = (): void => {
_reject(new Error("socket closed"))
}
const timeout = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
socket.removeEventListener("open", open)
socket.removeEventListener("close", reject)
_reject(new Error("timeout"))
}, this.openTimeout)
const open = (): void => {
clearTimeout(timeout)
socket.removeEventListener("close", reject)
resolve(socket)
}
socket.addEventListener("open", open)
socket.addEventListener("close", reject)
}, this.retryDelay)
})
socket.addEventListener("message", (event) => {
this.logger.trace(() => ["got message", field("message", decode(event.data))])
this._onMessage.emit(event.data)
})
socket.addEventListener("close", (event) => {
this.socket = undefined
if (!this.closed) {
this._onDisconnect.emit(event.code)
// It might be closed in the event handler.
if (!this.closed) {
this.logger.info("connection closed; attempting to reconnect")
this.connect()
}
} else {
this._onClose.emit(event.code)
this.logger.info("connection closed permanently")
}
})
return socket
}
}

View File

@@ -42,8 +42,9 @@ To generate a new patch, **stage all the changes** you want to be included in
the patch in the VS Code source, then run `yarn patch:generate` in this
directory.
Our changes include:
Notable changes include:
- Add our own build file which includes our code and VS Code's web code.
- Allow multiple extension directories (both user and built-in).
- Modify the loader, websocket, webview, service worker, and asset requests to
use the URL of the page as a base (and TLS if necessary for the websocket).
@@ -51,8 +52,8 @@ Our changes include:
- Make changing the display language work.
- Make it possible for us to load code on the client.
- Make extensions work in the browser.
- Make it possible to install extensions of any kind.
- Fix getting permanently disconnected when you sleep or hibernate for a while.
- Make it possible to automatically update the binary.
- Add connection type to web socket query parameters.
## Future

View File

@@ -43,7 +43,7 @@ export class ApiHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
this.ensureAuthenticated(request)
if (route.requestPath !== "/index.html") {
if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound)
}

View File

@@ -20,7 +20,7 @@ export class DashboardHttpProvider extends HttpProvider {
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
if (route.requestPath !== "/index.html") {
if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound)
}

View File

@@ -1,4 +1,5 @@
import * as http from "http"
import * as limiter from "limiter"
import * as querystring from "querystring"
import { HttpCode, HttpError } from "../../common/http"
import { AuthType, HttpProvider, HttpResponse, Route } from "../http"
@@ -18,7 +19,7 @@ interface LoginPayload {
*/
export class LoginHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
if (this.options.auth !== AuthType.Password || route.requestPath !== "/index.html") {
if (this.options.auth !== AuthType.Password || !this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (route.base) {
@@ -48,6 +49,8 @@ export class LoginHttpProvider extends HttpProvider {
return this.replaceTemplates(route, response)
}
private readonly limiter = new RateLimiter()
/**
* Try logging in. On failure, show the login page with an error.
*/
@@ -59,6 +62,10 @@ export class LoginHttpProvider extends HttpProvider {
}
try {
if (!this.limiter.try()) {
throw new Error("Login rate limited!")
}
const data = await this.getData(request)
const payload = data ? querystring.parse(data) : {}
return await this.login(payload, route, request)
@@ -108,3 +115,17 @@ export class LoginHttpProvider extends HttpProvider {
throw new Error("Missing password")
}
}
// RateLimiter wraps around the limiter library for logins.
// It allows 2 logins every minute and 12 logins every hour.
class RateLimiter {
private readonly minuteLimiter = new limiter.RateLimiter(2, "minute")
private readonly hourLimiter = new limiter.RateLimiter(12, "hour")
public try(): boolean {
if (this.minuteLimiter.tryRemoveTokens(1)) {
return true
}
return this.hourLimiter.tryRemoveTokens(1)
}
}

43
src/node/app/proxy.ts Normal file
View File

@@ -0,0 +1,43 @@
import * as http from "http"
import { HttpCode, HttpError } from "../../common/http"
import { HttpProvider, HttpResponse, Route, WsResponse } from "../http"
/**
* Proxy HTTP provider.
*/
export class ProxyHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
if (!this.authenticated(request)) {
if (this.isRoot(route)) {
return { redirect: "/login", query: { to: route.fullPath } }
}
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
}
// Ensure there is a trailing slash so relative paths work correctly.
if (this.isRoot(route) && !route.fullPath.endsWith("/")) {
return {
redirect: `${route.fullPath}/`,
}
}
const port = route.base.replace(/^\//, "")
return {
proxy: {
base: `${this.options.base}/${port}`,
port,
},
}
}
public async handleWebSocket(route: Route, request: http.IncomingMessage): Promise<WsResponse> {
this.ensureAuthenticated(request)
const port = route.base.replace(/^\//, "")
return {
proxy: {
base: `${this.options.base}/${port}`,
port,
},
}
}
}

View File

@@ -61,7 +61,7 @@ export class UpdateHttpProvider extends HttpProvider {
this.ensureAuthenticated(request)
this.ensureMethod(request)
if (route.requestPath !== "/index.html") {
if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound)
}
@@ -87,8 +87,7 @@ export class UpdateHttpProvider extends HttpProvider {
public async getRoot(
route: Route,
request: http.IncomingMessage,
appliedUpdate?: string,
error?: Error,
errorOrUpdate?: Update | Error,
): Promise<HttpResponse> {
if (request.headers["content-type"] === "application/json") {
if (!this.enabled) {
@@ -108,8 +107,13 @@ export class UpdateHttpProvider extends HttpProvider {
}
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/update.html")
response.content = response.content
.replace(/{{UPDATE_STATUS}}/, appliedUpdate ? `Updated to ${appliedUpdate}` : await this.getUpdateHtml())
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "")
.replace(
/{{UPDATE_STATUS}}/,
errorOrUpdate && !(errorOrUpdate instanceof Error)
? `Updated to ${errorOrUpdate.version}`
: await this.getUpdateHtml(),
)
.replace(/{{ERROR}}/, errorOrUpdate instanceof Error ? `<div class="error">${errorOrUpdate.message}</div>` : "")
return this.replaceTemplates(route, response)
}
@@ -186,11 +190,16 @@ export class UpdateHttpProvider extends HttpProvider {
const update = await this.getUpdate()
if (!this.isLatestVersion(update)) {
await this.downloadAndApplyUpdate(update)
return this.getRoot(route, request, update.version)
return this.getRoot(route, request, update)
}
return this.getRoot(route, request)
} catch (error) {
return this.getRoot(route, request, undefined, error)
// For JSON requests propagate the error. Otherwise catch it so we can
// show the error inline with the update button instead of an error page.
if (request.headers["content-type"] === "application/json") {
throw error
}
return this.getRoot(route, error)
}
}
@@ -221,8 +230,13 @@ export class UpdateHttpProvider extends HttpProvider {
targetPath = path.resolve(__dirname, "../../../")
}
logger.debug("Replacing files", field("target", targetPath))
await fs.move(directoryPath, targetPath, { overwrite: true })
// Move the old directory to prevent potential data loss.
const backupPath = path.resolve(targetPath, `../${path.basename(targetPath)}.${Date.now().toString()}`)
logger.debug("Replacing files", field("target", targetPath), field("backup", backupPath))
await fs.move(targetPath, backupPath)
// Move the new directory.
await fs.move(directoryPath, targetPath)
await fs.remove(downloadPath)
@@ -357,7 +371,7 @@ export class UpdateHttpProvider extends HttpProvider {
}
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) {
return reject(new Error(`${response.statusCode || "500"}`))
return reject(new Error(`${uri}: ${response.statusCode || "500"}`))
}
resolve(response)

View File

@@ -5,7 +5,6 @@ import * as fs from "fs-extra"
import * as http from "http"
import * as net from "net"
import * as path from "path"
import * as url from "url"
import {
CodeServerMessage,
Options,
@@ -128,7 +127,7 @@ export class VscodeHttpProvider extends HttpProvider {
switch (route.base) {
case "/":
if (route.requestPath !== "/index.html") {
if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) {
return { redirect: "/login", query: { to: this.options.base } }
@@ -168,15 +167,12 @@ export class VscodeHttpProvider extends HttpProvider {
private async getRoot(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
const remoteAuthority = request.headers.host as string
const { lastVisited } = await settings.read()
const startPath = await this.getFirstValidPath(
[
{ url: route.query.workspace, workspace: true },
{ url: route.query.folder, workspace: false },
this.args._ && this.args._.length > 0 ? { url: path.resolve(this.args._[this.args._.length - 1]) } : undefined,
lastVisited,
],
remoteAuthority,
)
const startPath = await this.getFirstPath([
{ url: route.query.workspace, workspace: true },
{ url: route.query.folder, workspace: false },
this.args._ && this.args._.length > 0 ? { url: path.resolve(this.args._[this.args._.length - 1]) } : undefined,
lastVisited,
])
const [response, options] = await Promise.all([
await this.getUtf8Resource(this.rootPath, "src/browser/pages/vscode.html"),
this.initialize({
@@ -209,41 +205,31 @@ export class VscodeHttpProvider extends HttpProvider {
}
/**
* Choose the first valid path. If `workspace` is undefined then either a
* workspace or a directory are acceptable. Otherwise it must be a file if a
* workspace or a directory otherwise.
* Choose the first non-empty path.
*/
private async getFirstValidPath(
private async getFirstPath(
startPaths: Array<{ url?: string | string[]; workspace?: boolean } | undefined>,
remoteAuthority: string,
): Promise<StartPath | undefined> {
const isFile = async (path: string): Promise<boolean> => {
try {
const stat = await fs.stat(path)
return stat.isFile()
} catch (error) {
logger.warn(error.message)
return false
}
}
for (let i = 0; i < startPaths.length; ++i) {
const startPath = startPaths[i]
if (!startPath) {
continue
}
const paths = typeof startPath.url === "string" ? [startPath.url] : startPath.url || []
for (let j = 0; j < paths.length; ++j) {
const uri = url.parse(paths[j])
try {
if (!uri.pathname) {
throw new Error(`${paths[j]} is not a valid URL`)
}
const stat = await fs.stat(uri.pathname)
if (typeof startPath.workspace === "undefined" || startPath.workspace !== stat.isDirectory()) {
return {
url: url.format({
protocol: uri.protocol || "vscode-remote",
hostname: remoteAuthority.split(":")[0],
port: remoteAuthority.split(":")[1],
pathname: uri.pathname,
slashes: true,
}),
workspace: !stat.isDirectory(),
}
}
} catch (error) {
logger.warn(error.message)
const url =
startPath && (typeof startPath.url === "string" ? [startPath.url] : startPath.url || []).find((p) => !!p)
if (startPath && url) {
return {
url,
// The only time `workspace` is undefined is for the command-line
// argument, in which case it's a path (not a URL) so we can stat it
// without having to parse it.
workspace: typeof startPath.workspace !== "undefined" ? startPath.workspace : await isFile(url),
}
}
}

View File

@@ -30,15 +30,15 @@ export interface Args extends VsArgs {
log?: LogLevel
readonly open?: boolean
readonly port?: number
readonly "bind-addr"?: string
readonly socket?: string
readonly "ssh-host-key"?: string
readonly "disable-ssh"?: boolean
readonly version?: boolean
readonly force?: boolean
readonly "list-extensions"?: boolean
readonly "install-extension"?: string[]
readonly "show-versions"?: boolean
readonly "uninstall-extension"?: string[]
readonly "proxy-domain"?: string[]
readonly locale?: string
readonly _: string[]
}
@@ -89,18 +89,20 @@ const options: Options<Required<Args>> = {
"cert-key": { type: "string", path: true, description: "Path to certificate key when using non-generated cert." },
"disable-updates": { type: "boolean", description: "Disable automatic updates." },
"disable-telemetry": { type: "boolean", description: "Disable telemetry." },
host: { type: "string", description: "Host for the HTTP server." },
help: { type: "boolean", short: "h", description: "Show this output." },
json: { type: "boolean" },
open: { type: "boolean", description: "Open in browser on startup. Does not work remotely." },
port: { type: "number", description: "Port for the HTTP server." },
socket: { type: "string", path: true, description: "Path to a socket (host and port will be ignored)." },
"bind-addr": { type: "string", description: "Address to bind to in host:port." },
// These two have been deprecated by bindAddr.
host: { type: "string", description: "" },
port: { type: "number", description: "" },
socket: { type: "string", path: true, description: "Path to a socket (bind-addr will be ignored)." },
version: { type: "boolean", short: "v", description: "Display version information." },
_: { type: "string[]" },
"disable-ssh": { type: "boolean" },
"ssh-host-key": { type: "string", path: true },
"user-data-dir": { type: "string", path: true, description: "Path to the user data directory." },
"extensions-dir": { type: "string", path: true, description: "Path to the extensions directory." },
"builtin-extensions-dir": { type: "string", path: true },
@@ -111,6 +113,7 @@ const options: Options<Required<Args>> = {
"install-extension": { type: "string[]", description: "Install or update a VS Code extension by id or vsix." },
"uninstall-extension": { type: "string[]", description: "Uninstall a VS Code extension by id." },
"show-versions": { type: "boolean", description: "Show VS Code extension versions." },
"proxy-domain": { type: "string[]", description: "Domain used for proxying ports." },
locale: { type: "string" },
log: { type: LogLevel },
@@ -210,7 +213,7 @@ export const parse = (argv: string[]): Args => {
;(args[key] as OptionalString) = new OptionalString(value)
break
default: {
if (!Object.values(option.type).find((v) => v === value)) {
if (!Object.values(option.type).includes(value)) {
throw new Error(`--${key} valid values: [${Object.values(option.type).join(", ")}]`)
}
;(args[key] as string) = value
@@ -227,20 +230,26 @@ export const parse = (argv: string[]): Args => {
logger.debug("parsed command line", field("args", args))
// Ensure the environment variable and the flag are synced up. The flag takes
// priority over the environment variable.
if (args.log === LogLevel.Trace || process.env.LOG_LEVEL === LogLevel.Trace || args.verbose) {
args.log = process.env.LOG_LEVEL = LogLevel.Trace
args.verbose = true
} else if (!args.log && process.env.LOG_LEVEL) {
// --verbose takes priority over --log and --log takes priority over the
// environment variable.
if (args.verbose) {
args.log = LogLevel.Trace
} else if (
!args.log &&
process.env.LOG_LEVEL &&
Object.values(LogLevel).includes(process.env.LOG_LEVEL as LogLevel)
) {
args.log = process.env.LOG_LEVEL as LogLevel
} else if (args.log) {
process.env.LOG_LEVEL = args.log
}
// Sync --log, --verbose, the environment variable, and logger level.
if (args.log) {
process.env.LOG_LEVEL = args.log
}
switch (args.log) {
case LogLevel.Trace:
logger.level = Level.Trace
args.verbose = true
break
case LogLevel.Debug:
logger.level = Level.Debug

View File

@@ -5,87 +5,83 @@ import { CliMessage } from "../../lib/vscode/src/vs/server/ipc"
import { ApiHttpProvider } from "./app/api"
import { DashboardHttpProvider } from "./app/dashboard"
import { LoginHttpProvider } from "./app/login"
import { ProxyHttpProvider } from "./app/proxy"
import { StaticHttpProvider } from "./app/static"
import { UpdateHttpProvider } from "./app/update"
import { VscodeHttpProvider } from "./app/vscode"
import { Args, optionDescriptions, parse } from "./cli"
import { AuthType, HttpServer } from "./http"
import { SshProvider } from "./ssh/server"
import { generateCertificate, generatePassword, generateSshHostKey, hash, open } from "./util"
import { AuthType, HttpServer, HttpServerOptions } from "./http"
import { generateCertificate, generatePassword, hash, open } from "./util"
import { ipcMain, wrap } from "./wrapper"
process.on("uncaughtException", (error) => {
logger.error(`Uncaught exception: ${error.message}`)
if (typeof error.stack !== "undefined") {
logger.error(error.stack)
}
})
let pkg: { version?: string; commit?: string } = {}
try {
pkg = require("../../package.json")
} catch (error) {
logger.warn(error.message)
}
const version = pkg.version || "development"
const commit = pkg.commit || "development"
const main = async (args: Args): Promise<void> => {
const auth = args.auth || AuthType.Password
const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword()))
let commit: string | undefined
try {
commit = require("../../package.json").commit
} catch (error) {
logger.warn(error.message)
let host = args.host
let port = args.port
if (args["bind-addr"] !== undefined) {
const u = new URL(`http://${args["bind-addr"]}`)
host = u.hostname
port = parseInt(u.port, 10)
}
// Spawn the main HTTP server.
const options = {
const options: HttpServerOptions = {
auth,
cert: args.cert ? args.cert.value : undefined,
certKey: args["cert-key"],
sshHostKey: args["ssh-host-key"],
commit: commit || "development",
host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"),
commit,
host: host || (args.auth === AuthType.Password && args.cert !== undefined ? "0.0.0.0" : "localhost"),
password: originalPassword ? hash(originalPassword) : undefined,
port: typeof args.port !== "undefined" ? args.port : process.env.PORT ? parseInt(process.env.PORT, 10) : 8080,
port: port !== undefined ? port : process.env.PORT ? parseInt(process.env.PORT, 10) : 8080,
proxyDomains: args["proxy-domain"],
socket: args.socket,
...(args.cert && !args.cert.value
? await generateCertificate()
: {
cert: args.cert && args.cert.value,
certKey: args["cert-key"],
}),
}
if (!options.cert && args.cert) {
const { cert, certKey } = await generateCertificate()
options.cert = cert
options.certKey = certKey
} else if (args.cert && !args["cert-key"]) {
if (options.cert && !options.certKey) {
throw new Error("--cert-key is missing")
}
if (!args["disable-ssh"]) {
if (!options.sshHostKey && typeof options.sshHostKey !== "undefined") {
throw new Error("--ssh-host-key cannot be blank")
} else if (!options.sshHostKey) {
try {
options.sshHostKey = await generateSshHostKey()
} catch (error) {
logger.error("Unable to start SSH server", field("error", error.message))
}
}
}
const httpServer = new HttpServer(options)
const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args)
const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"])
const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"])
httpServer.registerHttpProvider("/proxy", ProxyHttpProvider)
httpServer.registerHttpProvider("/login", LoginHttpProvider)
httpServer.registerHttpProvider("/static", StaticHttpProvider)
httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update)
ipcMain().onDispose(() => httpServer.dispose())
logger.info(`code-server ${require("../../package.json").version}`)
let sshPort = ""
if (!args["disable-ssh"] && options.sshHostKey) {
const sshProvider = httpServer.registerHttpProvider("/ssh", SshProvider, options.sshHostKey as string)
try {
sshPort = await sshProvider.listen()
} catch (error) {
logger.warn(`SSH server: ${error.message}`)
}
}
logger.info(`code-server ${version} ${commit}`)
const serverAddress = await httpServer.listen()
logger.info(`Server listening on ${serverAddress}`)
logger.info(`HTTP server listening on ${serverAddress}`)
if (auth === AuthType.Password && !process.env.PASSWORD) {
logger.info(` - Password is ${originalPassword}`)
logger.info(" - To use your own password, set the PASSWORD environment variable")
logger.info(" - To use your own password set the PASSWORD environment variable")
if (!args.auth) {
logger.info(" - To disable use `--auth none`")
}
@@ -97,7 +93,7 @@ const main = async (args: Args): Promise<void> => {
if (httpServer.protocol === "https") {
logger.info(
typeof args.cert === "string"
args.cert && args.cert.value
? ` - Using provided certificate and key for HTTPS`
: ` - Using generated certificate and key for HTTPS`,
)
@@ -105,19 +101,18 @@ const main = async (args: Args): Promise<void> => {
logger.info(" - Not serving HTTPS")
}
logger.info(` - Automatic updates are ${update.enabled ? "enabled" : "disabled"}`)
if (sshPort) {
logger.info(` - SSH Server - Listening :${sshPort}`)
} else {
logger.info(" - SSH Server - Disabled")
if (httpServer.proxyDomains.size > 0) {
logger.info(` - Proxying the following domain${httpServer.proxyDomains.size === 1 ? "" : "s"}:`)
httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`))
}
logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`)
if (serverAddress && !options.socket && args.open) {
// The web socket doesn't seem to work if browsing with 0.0.0.0.
const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost")
await open(openAddress).catch(console.error)
logger.info(` - Opened ${openAddress}`)
logger.info(`Opened ${openAddress}`)
}
}
@@ -132,7 +127,7 @@ const tryParse = (): Args => {
const args = tryParse()
if (args.help) {
console.log("code-server", require("../../package.json").version)
console.log("code-server", version, commit)
console.log("")
console.log(`Usage: code-server [options] [path]`)
console.log("")
@@ -141,14 +136,14 @@ if (args.help) {
console.log("", description)
})
} else if (args.version) {
const version = require("../../package.json").version
if (args.json) {
console.log({
codeServer: version,
commit,
vscode: require("../../lib/vscode/package.json").version,
})
} else {
console.log(version)
console.log(version, commit)
}
process.exit(0)
} else if (args["list-extensions"] || args["install-extension"] || args["uninstall-extension"]) {

View File

@@ -1,6 +1,7 @@
import { field, logger } from "@coder/logger"
import * as fs from "fs-extra"
import * as http from "http"
import proxy from "http-proxy"
import * as httpolyglot from "httpolyglot"
import * as https from "https"
import * as net from "net"
@@ -18,6 +19,10 @@ import { getMediaMime, xdgLocalDir } from "./util"
export type Cookies = { [key: string]: string[] | undefined }
export type PostData = { [key: string]: string | string[] | undefined }
interface ProxyRequest extends http.IncomingMessage {
base?: string
}
interface AuthPayload extends Cookies {
key?: string[]
}
@@ -29,6 +34,17 @@ export enum AuthType {
export type Query = { [key: string]: string | string[] | undefined }
export interface ProxyOptions {
/**
* A base path to strip from from the request before proxying if necessary.
*/
base?: string
/**
* The port to proxy.
*/
port: string
}
export interface HttpResponse<T = string | Buffer | object> {
/*
* Whether to set cache-control headers for this response.
@@ -77,6 +93,17 @@ export interface HttpResponse<T = string | Buffer | object> {
* `undefined` to remove a query variable.
*/
query?: Query
/**
* Indicates the request should be proxied.
*/
proxy?: ProxyOptions
}
export interface WsResponse {
/**
* Indicates the web socket should be proxied.
*/
proxy?: ProxyOptions
}
/**
@@ -100,14 +127,31 @@ export interface HttpServerOptions {
readonly host?: string
readonly password?: string
readonly port?: number
readonly proxyDomains?: string[]
readonly socket?: string
}
export interface Route {
/**
* Base path part (in /test/path it would be "/test").
*/
base: string
/**
* Remaining part of the route (in /test/path it would be "/path"). It can be
* blank.
*/
requestPath: string
/**
* Query variables included in the request.
*/
query: querystring.ParsedUrlQuery
/**
* Normalized version of `originalPath`.
*/
fullPath: string
/**
* Original path of the request without any modifications.
*/
originalPath: string
}
@@ -136,7 +180,9 @@ export abstract class HttpProvider {
}
/**
* Handle web sockets on the registered endpoint.
* Handle web sockets on the registered endpoint. Normally the provider
* handles the request itself but it can return a response when necessary. The
* default is to throw a 404.
*/
public handleWebSocket(
/* eslint-disable @typescript-eslint/no-unused-vars */
@@ -145,7 +191,7 @@ export abstract class HttpProvider {
_socket: net.Socket,
_head: Buffer,
/* eslint-enable @typescript-eslint/no-unused-vars */
): Promise<void> {
): Promise<WsResponse | void> {
throw new HttpError("Not found", HttpCode.NotFound)
}
@@ -264,7 +310,7 @@ export abstract class HttpProvider {
* Return the provided password value if the payload contains the right
* password otherwise return false. If no payload is specified use cookies.
*/
protected authenticated(request: http.IncomingMessage, payload?: AuthPayload): string | boolean {
public authenticated(request: http.IncomingMessage, payload?: AuthPayload): string | boolean {
switch (this.options.auth) {
case AuthType.None:
return true
@@ -335,6 +381,14 @@ export abstract class HttpProvider {
}
return cookies as T
}
/**
* Return true if the route is for the root page. For example /base, /base/,
* or /base/index.html but not /base/path or /base/file.js.
*/
protected isRoot(route: Route): boolean {
return !route.requestPath || route.requestPath === "/index.html"
}
}
/**
@@ -407,7 +461,18 @@ export class HttpServer {
private readonly heart: Heart
private readonly socketProvider = new SocketProxyProvider()
/**
* Proxy domains are stored here without the leading `*.`
*/
public readonly proxyDomains: Set<string>
/**
* Provides the actual proxying functionality.
*/
private readonly proxy = proxy.createProxyServer({})
public constructor(private readonly options: HttpServerOptions) {
this.proxyDomains = new Set((options.proxyDomains || []).map((d) => d.replace(/^\*\./, "")))
this.heart = new Heart(path.join(xdgLocalDir, "heartbeat"), async () => {
const connections = await this.getConnections()
logger.trace(`${connections} active connection${plural(connections)}`)
@@ -425,6 +490,16 @@ export class HttpServer {
} else {
this.server = http.createServer(this.onRequest)
}
this.proxy.on("error", (error, _request, response) => {
response.writeHead(HttpCode.ServerError)
response.end(error.message)
})
// Intercept the response to rewrite absolute redirects against the base path.
this.proxy.on("proxyRes", (response, request: ProxyRequest) => {
if (response.headers.location && response.headers.location.startsWith("/") && request.base) {
response.headers.location = request.base + response.headers.location
}
})
}
public dispose(): void {
@@ -525,9 +600,12 @@ export class HttpServer {
"Set-Cookie": [
`${payload.cookie.key}=${payload.cookie.value}`,
`Path=${normalize(payload.cookie.path || "/", true)}`,
this.getCookieDomain(request.headers.host || ""),
// "HttpOnly",
"SameSite=strict",
].join(";"),
"SameSite=lax",
]
.filter((l) => !!l)
.join(";"),
}
: {}),
...payload.headers,
@@ -547,25 +625,40 @@ export class HttpServer {
response.end()
}
}
try {
const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request))
if (!payload) {
throw new HttpError("Not found", HttpCode.NotFound)
const payload =
this.maybeRedirect(request, route) ||
(route.provider.authenticated(request) && this.maybeProxy(request)) ||
(await route.provider.handleRequest(route, request))
if (payload.proxy) {
this.doProxy(route, request, response, payload.proxy)
} else {
write(payload)
}
write(payload)
} catch (error) {
let e = error
if (error.code === "ENOENT" || error.code === "EISDIR") {
e = new HttpError("Not found", HttpCode.NotFound)
}
logger.debug("Request error", field("url", request.url))
logger.debug(error.stack)
const code = typeof e.code === "number" ? e.code : HttpCode.ServerError
const payload = await route.provider.getErrorRoot(route, code, code, e.message)
write({
code,
...payload,
})
logger.debug("Request error", field("url", request.url), field("code", code))
if (code >= HttpCode.ServerError) {
logger.error(error.stack)
}
if (request.headers["content-type"] === "application/json") {
write({
code,
content: {
error: e.message,
},
})
} else {
write({
code,
...(await route.provider.getErrorRoot(route, code, code, e.message)),
})
}
}
}
@@ -625,7 +718,14 @@ export class HttpServer {
throw new HttpError("Not found", HttpCode.NotFound)
}
await route.provider.handleWebSocket(route, request, await this.socketProvider.createProxy(socket), head)
// The socket proxy is so we can pass them to child processes (TLS sockets
// can't be transferred so we need an in-between).
const socketProxy = await this.socketProvider.createProxy(socket)
const payload =
this.maybeProxy(request) || (await route.provider.handleWebSocket(route, request, socketProxy, head))
if (payload && payload.proxy) {
this.doProxy(route, request, { socket: socketProxy, head }, payload.proxy)
}
} catch (error) {
socket.destroy(error)
logger.warn(`discarding socket connection: ${error.message}`)
@@ -647,7 +747,6 @@ export class HttpServer {
// Happens if it's a plain `domain.com`.
base = "/"
}
requestPath = requestPath || "/index.html"
return { base, requestPath }
}
@@ -670,4 +769,125 @@ export class HttpServer {
}
return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath }
}
/**
* Proxy a request to the target.
*/
private doProxy(
route: Route,
request: http.IncomingMessage,
response: http.ServerResponse,
options: ProxyOptions,
): void
/**
* Proxy a web socket to the target.
*/
private doProxy(
route: Route,
request: http.IncomingMessage,
response: { socket: net.Socket; head: Buffer },
options: ProxyOptions,
): void
/**
* Proxy a request or web socket to the target.
*/
private doProxy(
route: Route,
request: http.IncomingMessage,
response: http.ServerResponse | { socket: net.Socket; head: Buffer },
options: ProxyOptions,
): void {
const port = parseInt(options.port, 10)
if (isNaN(port)) {
throw new HttpError(`"${options.port}" is not a valid number`, HttpCode.BadRequest)
}
// REVIEW: Absolute redirects need to be based on the subpath but I'm not
// sure how best to get this information to the `proxyRes` event handler.
// For now I'm sticking it on the request object which is passed through to
// the event.
;(request as ProxyRequest).base = options.base
const isHttp = response instanceof http.ServerResponse
const path = options.base ? route.fullPath.replace(options.base, "") : route.fullPath
const proxyOptions: proxy.ServerOptions = {
changeOrigin: true,
ignorePath: true,
target: `${isHttp ? "http" : "ws"}://127.0.0.1:${port}${path}${
Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : ""
}`,
ws: !isHttp,
}
if (response instanceof http.ServerResponse) {
this.proxy.web(request, response, proxyOptions)
} else {
this.proxy.ws(request, response.socket, response.head, proxyOptions)
}
}
/**
* Get the value that should be used for setting a cookie domain. This will
* allow the user to authenticate only once. This will use the highest level
* domain (e.g. `coder.com` over `test.coder.com` if both are specified).
*/
private getCookieDomain(host: string): string | undefined {
const idx = host.lastIndexOf(":")
host = idx !== -1 ? host.substring(0, idx) : host
if (
// Might be blank/missing, so there's nothing more to do.
!host ||
// IP addresses can't have subdomains so there's no value in setting the
// domain for them. Assume anything with a : is ipv6 (valid domain name
// characters are alphanumeric or dashes).
host.includes(":") ||
// Assume anything entirely numbers and dots is ipv4 (currently tlds
// cannot be entirely numbers).
!/[^0-9.]/.test(host) ||
// localhost subdomains don't seem to work at all (browser bug?).
host.endsWith(".localhost") ||
// It might be localhost (or an IP, see above) if it's a proxy and it
// isn't setting the host header to match the access domain.
host === "localhost"
) {
return undefined
}
this.proxyDomains.forEach((domain) => {
if (host.endsWith(domain) && domain.length < host.length) {
host = domain
}
})
return host ? `Domain=${host}` : undefined
}
/**
* Return a response if the request should be proxied. Anything that ends in a
* proxy domain and has a *single* subdomain should be proxied. Anything else
* should return `undefined` and will be handled as normal.
*
* For example if `coder.com` is specified `8080.coder.com` will be proxied
* but `8080.test.coder.com` and `test.8080.coder.com` will not.
*/
public maybeProxy(request: http.IncomingMessage): HttpResponse | undefined {
// Split into parts.
const host = request.headers.host || ""
const idx = host.indexOf(":")
const domain = idx !== -1 ? host.substring(0, idx) : host
const parts = domain.split(".")
// There must be an exact match.
const port = parts.shift()
const proxyDomain = parts.join(".")
if (!port || !this.proxyDomains.has(proxyDomain)) {
return undefined
}
return {
proxy: {
port,
},
}
}
}

View File

@@ -1,110 +0,0 @@
import * as http from "http"
import * as net from "net"
import * as ssh from "ssh2"
import * as ws from "ws"
import * as fs from "fs"
import { logger } from "@coder/logger"
import safeCompare from "safe-compare"
import { HttpProvider, HttpResponse, HttpProviderOptions, Route } from "../http"
import { HttpCode } from "../../common/http"
import { forwardSshPort, fillSshSession } from "./ssh"
import { hash } from "../util"
export class SshProvider extends HttpProvider {
private readonly wss = new ws.Server({ noServer: true })
private sshServer: ssh.Server
public constructor(options: HttpProviderOptions, hostKeyPath: string) {
super(options)
const hostKey = fs.readFileSync(hostKeyPath)
this.sshServer = new ssh.Server({ hostKeys: [hostKey] }, this.handleSsh)
this.sshServer.on("error", (err) => {
logger.trace(`SSH server error: ${err.stack}`)
})
}
public async listen(): Promise<string> {
return new Promise((resolve, reject) => {
this.sshServer.once("error", reject)
this.sshServer.listen(() => {
resolve(this.sshServer.address().port.toString())
})
})
}
public async handleRequest(): Promise<HttpResponse> {
// SSH has no HTTP endpoints
return { code: HttpCode.NotFound }
}
public handleWebSocket(
_route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer,
): Promise<void> {
// Create a fake websocket to the sshServer
const sshSocket = net.connect(this.sshServer.address().port, "localhost")
return new Promise((resolve) => {
this.wss.handleUpgrade(request, socket, head, (ws) => {
// Send SSH data to WS as compressed binary
sshSocket.on("data", (data) => {
ws.send(data, {
binary: true,
compress: true,
fin: true,
})
})
// Send WS data to SSH as buffer
ws.on("message", (msg) => {
// Buffer.from is cool with all types, but casting as string keeps typing simple
sshSocket.write(Buffer.from(msg as string))
})
ws.on("error", (err) => {
logger.error(`SSH websocket error: ${err.stack}`)
})
resolve()
})
})
}
/**
* Determine how to handle incoming SSH connections.
*/
private handleSsh = (client: ssh.Connection, info: ssh.ClientInfo): void => {
logger.debug(`Incoming SSH connection from ${info.ip}`)
client.on("authentication", (ctx) => {
// Allow any auth to go through if we have no password
if (!this.options.password) {
return ctx.accept()
}
// Otherwise require the same password as code-server
if (ctx.method === "password") {
if (
safeCompare(this.options.password, hash(ctx.password)) ||
safeCompare(this.options.password, ctx.password)
) {
return ctx.accept()
}
}
// Reject, letting them know that password is the only method we allow
ctx.reject(["password"])
})
client.on("tcpip", forwardSshPort)
client.on("session", fillSshSession)
client.on("error", (err) => {
// Don't bother logging Keepalive errors, they probably just disconnected
if (err.message === "Keepalive timeout") {
return logger.debug("SSH client keepalive timeout")
}
logger.error(`SSH client error: ${err.stack}`)
})
}
}

View File

@@ -1,201 +0,0 @@
/**
* Provides utilities for handling SSH connections
*/
import * as fs from "fs"
import * as path from "path"
import * as ssh from "ssh2"
import { FileEntry, SFTPStream } from "ssh2-streams"
/**
* Fills out all the functionality of SFTP using fs.
*/
export function fillSftpStream(accept: () => SFTPStream): void {
const sftp = accept()
let oid = 0
const fds: { [key: number]: boolean } = {}
const ods: {
[key: number]: {
path: string
read: boolean
}
} = {}
const sftpStatus = (reqID: number, err?: NodeJS.ErrnoException | null): boolean => {
let code = ssh.SFTP_STATUS_CODE.OK
if (err) {
if (err.code === "EACCES") {
code = ssh.SFTP_STATUS_CODE.PERMISSION_DENIED
} else if (err.code === "ENOENT") {
code = ssh.SFTP_STATUS_CODE.NO_SUCH_FILE
} else {
code = ssh.SFTP_STATUS_CODE.FAILURE
}
}
return sftp.status(reqID, code)
}
sftp.on("OPEN", (reqID, filename) => {
fs.open(filename, "w", (err, fd) => {
if (err) {
return sftpStatus(reqID, err)
}
fds[fd] = true
const buf = Buffer.alloc(4)
buf.writeUInt32BE(fd, 0)
return sftp.handle(reqID, buf)
})
})
sftp.on("OPENDIR", (reqID, path) => {
const buf = Buffer.alloc(4)
const id = oid++
buf.writeUInt32BE(id, 0)
ods[id] = {
path,
read: false,
}
sftp.handle(reqID, buf)
})
sftp.on("READDIR", (reqID, handle) => {
const od = handle.readUInt32BE(0)
if (!ods[od]) {
return sftp.status(reqID, ssh.SFTP_STATUS_CODE.NO_SUCH_FILE)
}
if (ods[od].read) {
sftp.status(reqID, ssh.SFTP_STATUS_CODE.EOF)
return
}
return fs.readdir(ods[od].path, (err, files) => {
if (err) {
return sftpStatus(reqID, err)
}
return Promise.all(
files.map((f) => {
return new Promise<FileEntry>((resolve, reject) => {
const fullPath = path.join(ods[od].path, f)
fs.stat(fullPath, (err, stats) => {
if (err) {
return reject(err)
}
resolve({
filename: f,
longname: fullPath,
attrs: {
atime: stats.atimeMs,
gid: stats.gid,
mode: stats.mode,
size: stats.size,
mtime: stats.mtimeMs,
uid: stats.uid,
},
})
})
})
}),
)
.then((files) => {
sftp.name(reqID, files)
ods[od].read = true
})
.catch(() => {
sftp.status(reqID, ssh.SFTP_STATUS_CODE.FAILURE)
})
})
})
sftp.on("WRITE", (reqID, handle, offset, data) => {
const fd = handle.readUInt32BE(0)
if (!fds[fd]) {
return sftp.status(reqID, ssh.SFTP_STATUS_CODE.NO_SUCH_FILE)
}
return fs.write(fd, data, offset, (err) => sftpStatus(reqID, err))
})
sftp.on("CLOSE", (reqID, handle) => {
const fd = handle.readUInt32BE(0)
if (!fds[fd]) {
if (ods[fd]) {
delete ods[fd]
return sftp.status(reqID, ssh.SFTP_STATUS_CODE.OK)
}
return sftp.status(reqID, ssh.SFTP_STATUS_CODE.NO_SUCH_FILE)
}
return fs.close(fd, (err) => sftpStatus(reqID, err))
})
sftp.on("STAT", (reqID, path) => {
fs.stat(path, (err, stats) => {
if (err) {
return sftpStatus(reqID, err)
}
return sftp.attrs(reqID, {
atime: stats.atime.getTime(),
gid: stats.gid,
mode: stats.mode,
mtime: stats.mtime.getTime(),
size: stats.size,
uid: stats.uid,
})
})
})
sftp.on("MKDIR", (reqID, path) => {
fs.mkdir(path, (err) => sftpStatus(reqID, err))
})
sftp.on("LSTAT", (reqID, path) => {
fs.lstat(path, (err, stats) => {
if (err) {
return sftpStatus(reqID, err)
}
return sftp.attrs(reqID, {
atime: stats.atimeMs,
gid: stats.gid,
mode: stats.mode,
mtime: stats.mtimeMs,
size: stats.size,
uid: stats.uid,
})
})
})
sftp.on("REMOVE", (reqID, path) => {
fs.unlink(path, (err) => sftpStatus(reqID, err))
})
sftp.on("RMDIR", (reqID, path) => {
fs.rmdir(path, (err) => sftpStatus(reqID, err))
})
sftp.on("REALPATH", (reqID, path) => {
fs.realpath(path, (pathErr, resolved) => {
if (pathErr) {
return sftpStatus(reqID, pathErr)
}
fs.stat(path, (statErr, stat) => {
if (statErr) {
return sftpStatus(reqID, statErr)
}
sftp.name(reqID, [
{
filename: resolved,
longname: resolved,
attrs: {
mode: stat.mode,
uid: stat.uid,
gid: stat.gid,
size: stat.size,
atime: stat.atime.getTime(),
mtime: stat.mtime.getTime(),
},
},
])
return
})
return
})
})
}

View File

@@ -1,122 +0,0 @@
/**
* Provides utilities for handling SSH connections
*/
import * as net from "net"
import * as cp from "child_process"
import * as ssh from "ssh2"
import * as nodePty from "node-pty"
import { fillSftpStream } from "./sftp"
/**
* Fills out all of the functionality of SSH using node equivalents.
*/
export function fillSshSession(accept: () => ssh.Session): void {
let pty: nodePty.IPty | undefined
let activeProcess: cp.ChildProcess
let ptyInfo: ssh.PseudoTtyInfo | undefined
const env: { [key: string]: string } = {}
const session = accept()
// Run a command, stream back the data
const cmd = (command: string, channel: ssh.ServerChannel): void => {
if (ptyInfo) {
// Remove undefined and project env vars
// keysToRemove taken from sanitizeProcessEnvironment
const keysToRemove = [/^ELECTRON_.+$/, /^GOOGLE_API_KEY$/, /^VSCODE_.+$/, /^SNAP(|_.*)$/]
const env = Object.keys(process.env).reduce((prev, k) => {
if (process.env[k] === undefined) {
return prev
}
const val = process.env[k] as string
if (keysToRemove.find((rx) => val.search(rx))) {
return prev
}
prev[k] = val
return prev
}, {} as { [key: string]: string })
pty = nodePty.spawn(command, [], {
cols: ptyInfo.cols,
rows: ptyInfo.rows,
env,
})
pty.onData((d) => channel.write(d))
pty.on("exit", (exitCode) => {
channel.exit(exitCode)
channel.close()
})
channel.on("data", (d: string) => pty && pty.write(d))
return
}
const proc = cp.spawn(command, { shell: true })
proc.stdout.on("data", (d) => channel.stdout.write(d))
proc.stderr.on("data", (d) => channel.stderr.write(d))
proc.on("exit", (exitCode) => {
channel.exit(exitCode || 0)
channel.close()
})
channel.stdin.on("data", (d: unknown) => proc.stdin.write(d))
channel.stdin.on("close", () => proc.stdin.end())
}
session.on("pty", (accept, _, info) => {
ptyInfo = info
accept && accept()
})
session.on("shell", (accept) => {
cmd(process.env.SHELL || "/usr/bin/env bash", accept())
})
session.on("exec", (accept, _, info) => {
cmd(info.command, accept())
})
session.on("sftp", fillSftpStream)
session.on("signal", (accept, _, info) => {
accept && accept()
process.kill((pty || activeProcess).pid, info.name)
})
session.on("env", (accept, _reject, info) => {
accept && accept()
env[info.key] = info.value
})
session.on("auth-agent", (accept) => {
accept()
})
session.on("window-change", (accept, reject, info) => {
if (pty) {
pty.resize(info.cols, info.rows)
accept && accept()
} else {
reject()
}
})
}
/**
* Pipes a requested port over SSH
*/
export function forwardSshPort(
accept: () => ssh.ServerChannel,
reject: () => boolean,
info: ssh.TcpipRequestInfo,
): void {
const fwdSocket = net.createConnection(info.destPort, info.destIP)
fwdSocket.on("error", () => reject())
fwdSocket.on("connect", () => {
const channel = accept()
channel.pipe(fwdSocket)
channel.on("close", () => fwdSocket.end())
fwdSocket.pipe(channel)
fwdSocket.on("close", () => channel.close())
fwdSocket.on("error", () => channel.end())
fwdSocket.on("end", () => channel.end())
})
}

View File

@@ -44,12 +44,6 @@ export const generateCertificate = async (): Promise<{ cert: string; certKey: st
return paths
}
export const generateSshHostKey = async (): Promise<string> => {
// Just reuse the SSL cert as the SSH host key
const { certKey } = await generateCertificate()
return certKey
}
export const generatePassword = async (length = 24): Promise<string> => {
const buffer = Buffer.alloc(Math.ceil(length / 2))
await util.promisify(crypto.randomFill)(buffer)

View File

@@ -1,3 +1,4 @@
import { logger, Level } from "@coder/logger"
import * as assert from "assert"
import * as path from "path"
import { parse } from "../src/node/cli"
@@ -8,17 +9,21 @@ describe("cli", () => {
delete process.env.LOG_LEVEL
})
// The parser will always fill these out.
const defaults = {
_: [],
"extensions-dir": path.join(xdgLocalDir, "extensions"),
"user-data-dir": xdgLocalDir,
}
it("should set defaults", () => {
assert.deepEqual(parse([]), {
_: [],
"extensions-dir": path.join(xdgLocalDir, "extensions"),
"user-data-dir": xdgLocalDir,
})
assert.deepEqual(parse([]), defaults)
})
it("should parse all available options", () => {
assert.deepEqual(
parse([
"--bind-addr=192.169.0.1:8080",
"--auth",
"none",
"--extensions-dir",
@@ -74,42 +79,71 @@ describe("cli", () => {
"user-data-dir": path.resolve("bar"),
verbose: true,
version: true,
"bind-addr": "192.169.0.1:8080",
},
)
})
it("should work with short options", () => {
assert.deepEqual(parse(["-vvv", "-v"]), {
_: [],
"extensions-dir": path.join(xdgLocalDir, "extensions"),
"user-data-dir": xdgLocalDir,
...defaults,
log: "trace",
verbose: true,
version: true,
})
assert.equal(process.env.LOG_LEVEL, "trace")
assert.equal(logger.level, Level.Trace)
})
it("should use log level env var", () => {
process.env.LOG_LEVEL = "debug"
assert.deepEqual(parse([]), {
_: [],
"extensions-dir": path.join(xdgLocalDir, "extensions"),
"user-data-dir": xdgLocalDir,
...defaults,
log: "debug",
})
assert.equal(process.env.LOG_LEVEL, "debug")
assert.equal(logger.level, Level.Debug)
process.env.LOG_LEVEL = "trace"
assert.deepEqual(parse([]), {
...defaults,
log: "trace",
verbose: true,
})
assert.equal(process.env.LOG_LEVEL, "trace")
assert.equal(logger.level, Level.Trace)
})
it("should prefer --log to env var", () => {
it("should prefer --log to env var and --verbose to --log", () => {
process.env.LOG_LEVEL = "debug"
assert.deepEqual(parse(["--log", "info"]), {
_: [],
"extensions-dir": path.join(xdgLocalDir, "extensions"),
"user-data-dir": xdgLocalDir,
...defaults,
log: "info",
})
assert.equal(process.env.LOG_LEVEL, "info")
assert.equal(logger.level, Level.Info)
process.env.LOG_LEVEL = "trace"
assert.deepEqual(parse(["--log", "info"]), {
...defaults,
log: "info",
})
assert.equal(process.env.LOG_LEVEL, "info")
assert.equal(logger.level, Level.Info)
process.env.LOG_LEVEL = "warn"
assert.deepEqual(parse(["--log", "info", "--verbose"]), {
...defaults,
log: "trace",
verbose: true,
})
assert.equal(process.env.LOG_LEVEL, "trace")
assert.equal(logger.level, Level.Trace)
})
it("should ignore invalid log level env var", () => {
process.env.LOG_LEVEL = "bogus"
assert.deepEqual(parse([]), defaults)
})
it("should error if value isn't provided", () => {
@@ -117,6 +151,7 @@ describe("cli", () => {
assert.throws(() => parse(["--auth=", "--log=debug"]), /--auth requires a value/)
assert.throws(() => parse(["--auth", "--log"]), /--auth requires a value/)
assert.throws(() => parse(["--auth", "--invalid"]), /--auth requires a value/)
assert.throws(() => parse(["--bind-addr"]), /--bind-addr requires a value/)
})
it("should error if value is invalid", () => {
@@ -131,9 +166,7 @@ describe("cli", () => {
it("should not error if the value is optional", () => {
assert.deepEqual(parse(["--cert"]), {
_: [],
"extensions-dir": path.join(xdgLocalDir, "extensions"),
"user-data-dir": xdgLocalDir,
...defaults,
cert: {
value: undefined,
},
@@ -144,9 +177,7 @@ describe("cli", () => {
assert.throws(() => parse(["--socket", "--socket-path-value"]), /--socket requires a value/)
// If you actually had a path like this you would do this instead:
assert.deepEqual(parse(["--socket", "./--socket-path-value"]), {
_: [],
"extensions-dir": path.join(xdgLocalDir, "extensions"),
"user-data-dir": xdgLocalDir,
...defaults,
socket: path.resolve("--socket-path-value"),
})
assert.throws(() => parse(["--cert", "--socket-path-value"]), /Unknown option --socket-path-value/)
@@ -154,10 +185,20 @@ describe("cli", () => {
it("should allow positional arguments before options", () => {
assert.deepEqual(parse(["foo", "test", "--auth", "none"]), {
...defaults,
_: ["foo", "test"],
"extensions-dir": path.join(xdgLocalDir, "extensions"),
"user-data-dir": xdgLocalDir,
auth: "none",
})
})
it("should support repeatable flags", () => {
assert.deepEqual(parse(["--proxy-domain", "*.coder.com"]), {
...defaults,
"proxy-domain": ["*.coder.com"],
})
assert.deepEqual(parse(["--proxy-domain", "*.coder.com", "--proxy-domain", "test.com"]), {
...defaults,
"proxy-domain": ["*.coder.com", "test.com"],
})
})
})

View File

@@ -214,13 +214,18 @@ describe("update", () => {
await p.downloadAndApplyUpdate(update, destination)
assert.equal(`console.log("UPDATED")`, await fs.readFile(entry, "utf8"))
// Should still work if there is no existing version somehow.
await fs.remove(destination)
await p.downloadAndApplyUpdate(update, destination)
assert.equal(`console.log("UPDATED")`, await fs.readFile(entry, "utf8"))
// There should be a backup.
const dir = (await fs.readdir(path.join(tmpdir, "tests/updates"))).filter((dir) => {
return dir.startsWith("code-server.")
})
assert.equal(dir.length, 1)
assert.equal(
`console.log("OLD")`,
await fs.readFile(path.join(tmpdir, "tests/updates", dir[0], "code-server"), "utf8"),
)
const archiveName = await p.getReleaseName(update)
assert.deepEqual(spy, ["/latest", `/download/${version}/${archiveName}`, `/download/${version}/${archiveName}`])
assert.deepEqual(spy, ["/latest", `/download/${version}/${archiveName}`])
})
it("should not reject if unable to fetch", async () => {

1777
yarn.lock

File diff suppressed because it is too large Load Diff