Skip to content

Commit 4af67c5

Browse files
committed
feat: add --socket-fd CLI option
1 parent 313bf03 commit 4af67c5

4 files changed

Lines changed: 100 additions & 5 deletions

File tree

docs/FAQ.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
- [How do I disable the proxy?](#how-do-i-disable-the-proxy)
4242
- [How do I disable file download?](#how-do-i-disable-file-download)
4343
- [Why do web views not work?](#why-do-web-views-not-work)
44+
- [Can I run code-server with systemd socket activation?](#can-i-run-code-server-with-systemd-socket-activation)
4445

4546
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
4647
<!-- prettier-ignore-end -->
@@ -560,3 +561,41 @@ To fix this, you must either:
560561
create and trust a certificate manually).
561562
- Disable security if your browser allows it. For example, in Chromium see
562563
`chrome://flags/#unsafely-treat-insecure-origin-as-secure`
564+
565+
## Can I run code-server with systemd socket activation?
566+
567+
Yes. Pass the inherited socket to code-server with `--socket-fd`. systemd
568+
passes the first listening socket as file descriptor `3`.
569+
570+
Create a socket unit, `~/.config/systemd/user/code-server.socket`:
571+
572+
```ini
573+
[Socket]
574+
ListenStream=8080
575+
576+
[Install]
577+
WantedBy=sockets.target
578+
```
579+
580+
And a matching service unit, `~/.config/systemd/user/code-server.service`:
581+
582+
```ini
583+
[Service]
584+
ExecStart=/usr/bin/code-server --socket-fd 3
585+
```
586+
587+
Then enable and start the socket:
588+
589+
```bash
590+
systemctl --user enable --now code-server.socket
591+
```
592+
593+
code-server will start on the first connection and listen on the socket
594+
systemd created. `--socket-fd` takes precedence over `--socket` and
595+
`--bind-addr`/`--port`/`--host`, and `--socket-mode` is ignored because
596+
systemd owns the socket's permissions.
597+
598+
Socket activation only changes how code-server binds; your usual
599+
authentication still applies (it keeps prompting for the configured password
600+
unless you set `--auth none`), so keep authentication enabled when exposing the
601+
server.

src/node/app.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { EditorSessionManager, makeEditorSessionManagerServer } from "./vscodeSo
1313
import { handleUpgrade } from "./wsRouter"
1414

1515
type SocketOptions = { socket: string; "socket-mode"?: string }
16-
type ListenOptions = DefaultedArgs | SocketOptions
16+
type FdOptions = { "socket-fd": number }
17+
type ListenOptions = DefaultedArgs | SocketOptions | FdOptions
1718

1819
export interface App extends Disposable {
1920
/** Handles regular HTTP requests. */
@@ -30,8 +31,12 @@ const isSocketOpts = (opts: ListenOptions): opts is SocketOptions => {
3031
return !!(opts as SocketOptions).socket || !(opts as DefaultedArgs).host
3132
}
3233

34+
export const isFdOpts = (opts: ListenOptions): opts is FdOptions => {
35+
return typeof (opts as FdOptions)["socket-fd"] === "number"
36+
}
37+
3338
export const listen = async (server: http.Server, opts: ListenOptions) => {
34-
if (isSocketOpts(opts)) {
39+
if (!isFdOpts(opts) && isSocketOpts(opts)) {
3540
try {
3641
await fs.unlink(opts.socket)
3742
} catch (error: any) {
@@ -46,7 +51,9 @@ export const listen = async (server: http.Server, opts: ListenOptions) => {
4651
server.on("error", (err) => util.logError(logger, "http server error", err))
4752
resolve()
4853
}
49-
if (isSocketOpts(opts)) {
54+
if (isFdOpts(opts)) {
55+
server.listen({ fd: opts["socket-fd"] }, onListen)
56+
} else if (isSocketOpts(opts)) {
5057
server.listen(opts.socket, onListen)
5158
} else {
5259
// [] is the correct format when using :: but Node errors with them.
@@ -56,7 +63,7 @@ export const listen = async (server: http.Server, opts: ListenOptions) => {
5663

5764
// NOTE@jsjoeio: we need to chmod after the server is finished
5865
// listening. Otherwise, the socket may not have been created yet.
59-
if (isSocketOpts(opts)) {
66+
if (!isFdOpts(opts) && isSocketOpts(opts)) {
6067
if (opts["socket-mode"]) {
6168
await fs.chmod(opts.socket, opts["socket-mode"])
6269
}

src/node/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs {
8383
open?: boolean
8484
"bind-addr"?: string
8585
socket?: string
86+
"socket-fd"?: number
8687
"socket-mode"?: string
8788
"trusted-origins"?: string[]
8889
version?: boolean
@@ -235,6 +236,10 @@ export const options: Options<Required<UserProvidedArgs>> = {
235236
port: { type: "number", description: "" },
236237

237238
socket: { type: "string", path: true, description: "Path to a socket (bind-addr will be ignored)." },
239+
"socket-fd": {
240+
type: "number",
241+
description: "File descriptor of a pre-bound, listening socket to use (for systemd socket activation).",
242+
},
238243
"socket-mode": { type: "string", description: "File mode of the socket." },
239244
"trusted-origins": {
240245
type: "string[]",

test/unit/node/app.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { promises } from "fs"
33
import * as http from "http"
44
import * as https from "https"
55
import * as path from "path"
6-
import { createApp, ensureAddress, handleArgsSocketCatchError, listen } from "../../../src/node/app"
6+
import { createApp, ensureAddress, handleArgsSocketCatchError, isFdOpts, listen } from "../../../src/node/app"
77
import { OptionalString, setDefaults } from "../../../src/node/cli"
88
import { generateCertificate } from "../../../src/node/util"
99
import { clean, mockLogger, getAvailablePort, tmpdir } from "../../utils/helpers"
@@ -261,3 +261,47 @@ describe("listen", () => {
261261
}
262262
})
263263
})
264+
265+
describe("listen (socket-fd)", () => {
266+
// Wrap a bound-but-not-yet-listening TCP socket so we get a real file
267+
// descriptor that listen({ fd }) can adopt, mirroring the systemd socket
268+
// activation case where the process inherits an fd and calls listen(2) on it.
269+
// Using a live net.Server's fd instead fails with EEXIST because the socket
270+
// is already listening in-process.
271+
// eslint-disable-next-line @typescript-eslint/no-var-requires
272+
const { TCP, constants: TCPConstants } = (process as any).binding("tcp_wrap")
273+
274+
let inherited: any
275+
let httpServer: http.Server
276+
let unlinkSpy: jest.SpyInstance
277+
278+
beforeEach(async () => {
279+
mockLogger()
280+
unlinkSpy = jest.spyOn(promises, "unlink")
281+
inherited = new TCP(TCPConstants.SERVER)
282+
inherited.bind("127.0.0.1", 0)
283+
httpServer = http.createServer()
284+
})
285+
286+
afterEach(() => {
287+
httpServer.close()
288+
try {
289+
inherited.close()
290+
} catch {
291+
// The fd is adopted by httpServer.close() above; ignore double-close.
292+
}
293+
jest.clearAllMocks()
294+
})
295+
296+
it("isFdOpts detects a numeric socket-fd", () => {
297+
expect(isFdOpts({ "socket-fd": 3 })).toBe(true)
298+
expect(isFdOpts({ socket: "/tmp/x.sock" } as any)).toBe(false)
299+
})
300+
301+
it("listens on an inherited fd without unlinking", async () => {
302+
const fd = inherited.fd as number
303+
await listen(httpServer, { "socket-fd": fd })
304+
expect(httpServer.address()).not.toBeNull()
305+
expect(unlinkSpy).not.toHaveBeenCalled()
306+
})
307+
})

0 commit comments

Comments
 (0)