Repository: coder/code-server (TypeScript)
Date: 2021-05-06 23:24:26+00:00
Task type: Bug Fix

Issue:
Fail to create tls-proxy for the second process.
<!--
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.

If you can reproduce the issue on vanilla VS Code,
please file the issue at the VS Code repository instead.

Provide screenshots if applicable.

Please fill in the issue template and try to be as detailed
and clear as possible!
-->

- Web Browser: Chrome
- Local OS: Windows 10
- Remote OS: Ubuntu 16.04
- Remote Architecture: x86_64
- `code-server --version`: 3.5.0

When running two process in the same server by two different users, the second one won't work and gives the following error:

```
discarding socket connection: EACCES: permission denied, unlink '/tmp/code-server/tls-proxy'
```

Notice that the `tls-proxy` is created by the first process. Obviously, it is not accessible by the second process.

After a little research, I find out that this bug is caused by a logic error in `socket.ts`, line 94.

```js
public async findFreeSocketPath(basePath: string, maxTries = 100): Promise<string> {
    let i = 0
    let path = basePath
    while ((await canConnect(path)) && i < maxTries) { // the line with bug
      path = `${basePath}-${++i}`
    }
    return path
  }
```

which should be

```js
public async findFreeSocketPath(basePath: string, maxTries = 100): Promise<string> {
    let i = 0
    let path = basePath
    while (!(await canConnect(path)) && i < maxTries) { // fix the bug
      path = `${basePath}-${++i}`
    }
    return path
  }
```

When the current path is not accessible, then try next one. 

> What is interesting is that when the second user is `root`, then `canConnect` returns `true` in the original code, a new socket file `tls-proxy-1` is created successfully. So that I think the code here should be further modified. We should check if there is a file in `path`. If there is a file here, we should not use this even it is accessible unless the current user is not root.

--- 

There is another problem here:  the directory `/tmp/code-server` is created and owned by the first user, which means this directory is not writable for the second user, so the new socket file `tls-proxy-1` cannot be created. I have to manually change the permissions of the directory to make everything works.

---

Ok I forget one last thing. When I fix every as discribed above, the web client still shows nothing. After checking chrome console, a 404 error occurs for file in path `code-server/release-standalone/dist/pages/pages/vscode.js`. Notice that there are two `pages` here, while the structure of the dist folder is `dist/pages/vscode.js`. I have to manually create another `pages` folder, and copy everything else into it. 

finally, everything works fine.


Issue discussion:
Nice catch! One workaround is to handle TLS in a reverse proxy, then
code-server won't have to create the TLS proxy.

Maybe we should use a path in the user's data directory instead? Then we
won't have any permission issues or users stepping on other users' toes.

Or we could use a random path in /tmp which would do the same thing
(this is how VS Code does it).

I'm not sure about the double pages directory though. The HTML that
loads that script is:

```
<script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/pages/vscode.js"></script>
```

So it seems like it shouldn't be possible. I'm not able to replicate
with our 3.5.0 release but I'll keep an eye out for it. It might be a
bug in the build process that doesn't occur in CI.

I try to replicate this time and it is gone now. But I find a very very interesting thing! 

I wrap my url to the code-server with [nativefier](https://github.com/jiahaog/nativefier) to create a standalone native application in Win10. Within the built-in console in the wrapped app, I run a new code-server process on my remote server on a different port. Normally it should print some logs, and I should open a new chrome page and visit the new port. However, this time a new window of the native app is automatically created, which lead me to the code-server service on the new port! 
Now everything is crazy. As I said in the last comment, running code-server within the console of the native application wrapped by nativefier, a new window is displayed as ask for password. Since the new code-server command is simply `./code-server --bind-addr 0.0.0.0:5010 .`, it means the same default configuration file `~/.config/code-server/.config.yaml` is used. Thus I input the password defined in that file, and the authentication succeeds.

Next, I want to replicate this, so I close the new window, and run the code-server in the native app again. This time a new window is created but nothing is displayed. What is worst is that the original code-server process is corrupted!

I check the log of the first code-server process, and it says:

```
(node:59077) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 error listeners added to [ChildProcess]. Use emitter.setMaxListeners() to increase limit
```

---

To conclude everything above: If two code-servers are started by the same user, then the second process may still try to use the tls-proxy created by the first process (note that here I fix the bug mentioned above and use the code-server binary built by myself), which may corrupt everything. Notice that the tls-proxy file is indeed accessible by the second process, but the connection number is limitted.

---

When I say 'the original code-server process is corrupted', I mean that even if I stop all other code-server process, and the tls-proxy does not exist (It is not manually deleted, I think it is deleted by other code-server process on exit). The original code-server process still holds some kind of handler to that deleted tls-proxy file. So when new connection comes,  instead of creating a new tls-proxy, it tries to connect the deleted socket file, which will certainly fail.

Solution patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
--- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
+++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
@@ -2,7 +2,7 @@
 Please link to the issue this PR solves.
 If there is no existing issue, please first create one unless the fix is minor.
 
-Please make sure the base of your PR is the master branch!
+Please make sure the base of your PR is the default branch!
 -->
 
 ## Checklist
diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -72,6 +72,7 @@ VS Code v1.56
 ### Development
 
 - chore: ignore updates to microsoft/playwright-github-action
+- fix(socket): use xdgBasedir.runtime instead of tmp #3304 @jsjoeio
 
 ## 3.10.0
 
diff --git a/src/node/socket.ts b/src/node/socket.ts
--- a/src/node/socket.ts
+++ b/src/node/socket.ts
@@ -4,8 +4,7 @@ import * as path from "path"
 import * as tls from "tls"
 import { Emitter } from "../common/emitter"
 import { generateUuid } from "../common/util"
-import { tmpdir } from "./constants"
-import { canConnect } from "./util"
+import { canConnect, paths } from "./util"
 
 /**
  * Provides a way to proxy a TLS socket. Can be used when you need to pass a
@@ -13,7 +12,7 @@ import { canConnect } from "./util"
  */
 export class SocketProxyProvider {
   private readonly onProxyConnect = new Emitter<net.Socket>()
-  private proxyPipe = path.join(tmpdir, "tls-proxy")
+  private proxyPipe = path.join(paths.runtime, "tls-proxy")
   private _proxyServer?: Promise<net.Server>
   private readonly proxyTimeout = 5000
 
@@ -76,7 +75,10 @@ export class SocketProxyProvider {
       this._proxyServer = this.findFreeSocketPath(this.proxyPipe)
         .then((pipe) => {
           this.proxyPipe = pipe
-          return Promise.all([fs.mkdir(tmpdir, { recursive: true }), fs.rmdir(this.proxyPipe, { recursive: true })])
+          return Promise.all([
+            fs.mkdir(path.dirname(this.proxyPipe), { recursive: true }),
+            fs.rmdir(this.proxyPipe, { recursive: true }),
+          ])
         })
         .then(() => {
           return new Promise((resolve) => {
diff --git a/src/node/util.ts b/src/node/util.ts
--- a/src/node/util.ts
+++ b/src/node/util.ts
@@ -8,9 +8,10 @@ import * as path from "path"
 import * as util from "util"
 import xdgBasedir from "xdg-basedir"
 
-interface Paths {
+export interface Paths {
   data: string
   config: string
+  runtime: string
 }
 
 export const paths = getEnvPaths()
@@ -20,23 +21,34 @@ export const paths = getEnvPaths()
  * On MacOS this function gets the standard XDG directories instead of using the native macOS
  * ones. Most CLIs do this as in practice only GUI apps use the standard macOS directories.
  */
-function getEnvPaths(): Paths {
-  let paths: Paths
-  if (process.platform === "win32") {
-    paths = envPaths("code-server", {
-      suffix: "",
-    })
-  } else {
-    if (xdgBasedir.data === undefined || xdgBasedir.config === undefined) {
-      throw new Error("No home folder?")
-    }
-    paths = {
-      data: path.join(xdgBasedir.data, "code-server"),
-      config: path.join(xdgBasedir.config, "code-server"),
-    }
+export function getEnvPaths(): Paths {
+  const paths = envPaths("code-server", { suffix: "" })
+  const append = (p: string): string => path.join(p, "code-server")
+  switch (process.platform) {
+    case "darwin":
+      return {
+        // envPaths uses native directories so force Darwin to use the XDG spec
+        // to align with other CLI tools.
+        data: xdgBasedir.data ? append(xdgBasedir.data) : paths.data,
+        config: xdgBasedir.config ? append(xdgBasedir.config) : paths.config,
+        // Fall back to temp if there is no runtime dir.
+        runtime: xdgBasedir.runtime ? append(xdgBasedir.runtime) : paths.temp,
+      }
+    case "win32":
+      return {
+        data: paths.data,
+        config: paths.config,
+        // Windows doesn't have a runtime dir.
+        runtime: paths.temp,
+      }
+    default:
+      return {
+        data: paths.data,
+        config: paths.config,
+        // Fall back to temp if there is no runtime dir.
+        runtime: xdgBasedir.runtime ? append(xdgBasedir.runtime) : paths.temp,
+      }
   }
-
-  return paths
 }
 
 /**


Test patch:
diff --git a/test/unit/node/util.test.ts b/test/unit/node/util.test.ts
new file mode 100644
--- /dev/null
+++ b/test/unit/node/util.test.ts
@@ -0,0 +1,147 @@
+describe("getEnvPaths", () => {
+  describe("on darwin", () => {
+    let ORIGINAL_PLATFORM = ""
+
+    beforeAll(() => {
+      ORIGINAL_PLATFORM = process.platform
+
+      Object.defineProperty(process, "platform", {
+        value: "darwin",
+      })
+    })
+
+    beforeEach(() => {
+      jest.resetModules()
+      jest.mock("env-paths", () => {
+        return () => ({
+          data: "/home/envPath/.local/share",
+          config: "/home/envPath/.config",
+          temp: "/tmp/envPath/runtime",
+        })
+      })
+    })
+
+    afterAll(() => {
+      // Restore old platform
+
+      Object.defineProperty(process, "platform", {
+        value: ORIGINAL_PLATFORM,
+      })
+    })
+
+    it("should return the env paths using xdgBasedir", () => {
+      jest.mock("xdg-basedir", () => ({
+        data: "/home/usr/.local/share",
+        config: "/home/usr/.config",
+        runtime: "/tmp/runtime",
+      }))
+      const getEnvPaths = require("../../../src/node/util").getEnvPaths
+      const envPaths = getEnvPaths()
+
+      expect(envPaths.data).toEqual("/home/usr/.local/share/code-server")
+      expect(envPaths.config).toEqual("/home/usr/.config/code-server")
+      expect(envPaths.runtime).toEqual("/tmp/runtime/code-server")
+    })
+
+    it("should return the env paths using envPaths when xdgBasedir is undefined", () => {
+      jest.mock("xdg-basedir", () => ({}))
+      const getEnvPaths = require("../../../src/node/util").getEnvPaths
+      const envPaths = getEnvPaths()
+
+      expect(envPaths.data).toEqual("/home/envPath/.local/share")
+      expect(envPaths.config).toEqual("/home/envPath/.config")
+      expect(envPaths.runtime).toEqual("/tmp/envPath/runtime")
+    })
+  })
+  describe("on win32", () => {
+    let ORIGINAL_PLATFORM = ""
+
+    beforeAll(() => {
+      ORIGINAL_PLATFORM = process.platform
+
+      Object.defineProperty(process, "platform", {
+        value: "win32",
+      })
+    })
+
+    beforeEach(() => {
+      jest.resetModules()
+      jest.mock("env-paths", () => {
+        return () => ({
+          data: "/windows/envPath/.local/share",
+          config: "/windows/envPath/.config",
+          temp: "/tmp/envPath/runtime",
+        })
+      })
+    })
+
+    afterAll(() => {
+      // Restore old platform
+
+      Object.defineProperty(process, "platform", {
+        value: ORIGINAL_PLATFORM,
+      })
+    })
+
+    it("should return the env paths using envPaths", () => {
+      const getEnvPaths = require("../../../src/node/util").getEnvPaths
+      const envPaths = getEnvPaths()
+
+      expect(envPaths.data).toEqual("/windows/envPath/.local/share")
+      expect(envPaths.config).toEqual("/windows/envPath/.config")
+      expect(envPaths.runtime).toEqual("/tmp/envPath/runtime")
+    })
+  })
+  describe("on other platforms", () => {
+    let ORIGINAL_PLATFORM = ""
+
+    beforeAll(() => {
+      ORIGINAL_PLATFORM = process.platform
+
+      Object.defineProperty(process, "platform", {
+        value: "linux",
+      })
+    })
+
+    beforeEach(() => {
+      jest.resetModules()
+      jest.mock("env-paths", () => {
+        return () => ({
+          data: "/linux/envPath/.local/share",
+          config: "/linux/envPath/.config",
+          temp: "/tmp/envPath/runtime",
+        })
+      })
+    })
+
+    afterAll(() => {
+      // Restore old platform
+
+      Object.defineProperty(process, "platform", {
+        value: ORIGINAL_PLATFORM,
+      })
+    })
+
+    it("should return the runtime using xdgBasedir if it exists", () => {
+      jest.mock("xdg-basedir", () => ({
+        runtime: "/tmp/runtime",
+      }))
+      const getEnvPaths = require("../../../src/node/util").getEnvPaths
+      const envPaths = getEnvPaths()
+
+      expect(envPaths.data).toEqual("/linux/envPath/.local/share")
+      expect(envPaths.config).toEqual("/linux/envPath/.config")
+      expect(envPaths.runtime).toEqual("/tmp/runtime/code-server")
+    })
+
+    it("should return the env paths using envPaths when xdgBasedir is undefined", () => {
+      jest.mock("xdg-basedir", () => ({}))
+      const getEnvPaths = require("../../../src/node/util").getEnvPaths
+      const envPaths = getEnvPaths()
+
+      expect(envPaths.data).toEqual("/linux/envPath/.local/share")
+      expect(envPaths.config).toEqual("/linux/envPath/.config")
+      expect(envPaths.runtime).toEqual("/tmp/envPath/runtime")
+    })
+  })
+})