Skip to content

Commit 51a20a5

Browse files
committed
fix: address panel tunnel review followups
1 parent 2ad6876 commit 51a20a5

5 files changed

Lines changed: 338 additions & 65 deletions

File tree

packages/api/src/services/panel-cloudflare-tunnel-core.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import { existsSync, readFileSync } from "node:fs"
2-
3-
import { parseLinuxDefaultGatewayIp } from "./project-port-proxy-core.js"
4-
51
export type PanelTunnelTarget =
62
| {
73
readonly ok: true
@@ -145,19 +141,3 @@ export const resolvePanelTunnelTargetUrl = (
145141
targetUrl: target.toString()
146142
}
147143
}
148-
149-
const readDefaultGatewayIp = (): string | null => {
150-
try {
151-
return parseLinuxDefaultGatewayIp(readFileSync("/proc/net/route", "utf8"))
152-
} catch {
153-
return null
154-
}
155-
}
156-
157-
export const defaultPanelTunnelLocalhostHost = (): string => {
158-
const configured = process.env["DOCKER_GIT_PANEL_TUNNEL_LOCALHOST_HOST"]?.trim()
159-
if (configured !== undefined && configured.length > 0) {
160-
return configured
161-
}
162-
return existsSync("/.dockerenv") ? readDefaultGatewayIp() ?? "172.17.0.1" : "127.0.0.1"
163-
}

packages/api/src/services/panel-cloudflare-tunnel.ts

Lines changed: 94 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawn, type ChildProcess } from "node:child_process"
2-
import { mkdirSync, rmSync } from "node:fs"
2+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"
33
import { join } from "node:path"
44
import { randomUUID } from "node:crypto"
55

@@ -8,17 +8,18 @@ import { Duration, Effect } from "effect"
88
import type { PanelCloudflareTunnelSession, StartPanelCloudflareTunnelRequest } from "../api/contracts.js"
99
import { ApiBadRequestError, ApiInternalError } from "../api/errors.js"
1010
import {
11-
defaultPanelTunnelLocalhostHost,
1211
parseTryCloudflareUrl,
1312
resolvePanelTunnelTargetUrl
1413
} from "./panel-cloudflare-tunnel-core.js"
14+
import { parseLinuxDefaultGatewayIp } from "./project-port-proxy-core.js"
1515

1616
type PanelCloudflareTunnelRecord = {
1717
readonly homeDir: string
1818
logLines: ReadonlyArray<string>
1919
process: ChildProcess | null
2020
session: PanelCloudflareTunnelSession
2121
stderrRemainder: string
22+
stopping: boolean
2223
stdoutRemainder: string
2324
}
2425

@@ -112,11 +113,29 @@ const consumeChunk = (
112113
const processEnv = (
113114
homeDir: string
114115
): Readonly<Record<string, string | undefined>> => ({
115-
...process.env,
116+
PATH: process.env["PATH"],
117+
SSL_CERT_DIR: process.env["SSL_CERT_DIR"],
118+
SSL_CERT_FILE: process.env["SSL_CERT_FILE"],
116119
HOME: homeDir,
117120
NO_COLOR: "1"
118121
})
119122

123+
const readDefaultGatewayIp = (): string | null => {
124+
try {
125+
return parseLinuxDefaultGatewayIp(readFileSync("/proc/net/route", "utf8"))
126+
} catch {
127+
return null
128+
}
129+
}
130+
131+
const defaultPanelTunnelLocalhostHost = (): string => {
132+
const configured = process.env["DOCKER_GIT_PANEL_TUNNEL_LOCALHOST_HOST"]?.trim()
133+
if (configured !== undefined && configured.length > 0) {
134+
return configured
135+
}
136+
return existsSync("/.dockerenv") ? readDefaultGatewayIp() ?? "172.17.0.1" : "127.0.0.1"
137+
}
138+
120139
const createStartingRecord = (
121140
panelUrl: string
122141
): PanelCloudflareTunnelRecord => {
@@ -138,6 +157,7 @@ const createStartingRecord = (
138157
stoppedAt: null
139158
},
140159
stderrRemainder: "",
160+
stopping: false,
141161
stdoutRemainder: ""
142162
}
143163
}
@@ -150,36 +170,87 @@ const removeTunnelHomeBestEffort = (record: PanelCloudflareTunnelRecord): void =
150170
}
151171
}
152172

153-
const stopRecord = (
173+
const finishStoppedRecord = (
154174
record: PanelCloudflareTunnelRecord,
155175
error: string | null = null
156176
): PanelCloudflareTunnelSession => {
157-
const process = record.process
158177
record.process = null
178+
record.stopping = false
159179
const session = updateRecord(record, {
160180
error,
161181
status: error === null ? "stopped" : "failed",
162182
stoppedAt: nowIso()
163183
})
164-
if (process !== null && !process.killed) {
165-
try {
166-
process.kill("SIGTERM")
167-
} catch {
168-
// process already exited
184+
removeTunnelHomeBestEffort(record)
185+
return session
186+
}
187+
188+
const waitForChildClose = (child: ChildProcess): Effect.Effect<void> => {
189+
if (child.exitCode !== null || child.signalCode !== null) {
190+
return Effect.void
191+
}
192+
193+
return Effect.async((resume) => {
194+
let completed = false
195+
let killTimer: ReturnType<typeof setTimeout> | null = null
196+
const complete = (): void => {
197+
if (completed) {
198+
return
199+
}
200+
completed = true
201+
child.off("close", complete)
202+
child.off("error", complete)
203+
child.off("exit", complete)
204+
if (killTimer !== null) {
205+
clearTimeout(killTimer)
206+
}
207+
resume(Effect.void)
169208
}
170-
const killTimer = setTimeout(() => {
209+
210+
child.once("close", complete)
211+
child.once("error", complete)
212+
child.once("exit", complete)
213+
if (!child.killed) {
171214
try {
172-
if (process.exitCode === null && process.signalCode === null) {
173-
process.kill("SIGKILL")
215+
child.kill("SIGTERM")
216+
} catch {
217+
complete()
218+
return
219+
}
220+
}
221+
killTimer = setTimeout(() => {
222+
try {
223+
if (child.exitCode === null && child.signalCode === null) {
224+
child.kill("SIGKILL")
174225
}
175226
} catch {
176-
// process already exited
227+
complete()
177228
}
178229
}, 2_000)
179230
killTimer.unref()
231+
})
232+
}
233+
234+
const stopRecord = (
235+
record: PanelCloudflareTunnelRecord,
236+
error: string | null = null
237+
): Effect.Effect<PanelCloudflareTunnelSession> => {
238+
const child = record.process
239+
record.process = null
240+
record.stopping = true
241+
return (child === null ? Effect.void : waitForChildClose(child)).pipe(
242+
Effect.map(() => finishStoppedRecord(record, error))
243+
)
244+
}
245+
246+
const runStopRecord = (
247+
record: PanelCloudflareTunnelRecord,
248+
error: string
249+
): void => {
250+
if (record.stopping || record.session.status === "stopped" || record.session.status === "failed") {
251+
return
180252
}
181-
removeTunnelHomeBestEffort(record)
182-
return session
253+
Effect.runFork(stopRecord(record, error))
183254
}
184255

185256
const attachProcessHandlers = (
@@ -194,16 +265,16 @@ const attachProcessHandlers = (
194265
consumeChunk(record, "stderr", chunk)
195266
})
196267
child.on("error", (error) => {
197-
stopRecord(record, `cloudflared failed to start: ${error.message}`)
268+
runStopRecord(record, `cloudflared failed to start: ${error.message}`)
198269
})
199270
child.on("close", (exitCode, signal) => {
200271
flushRemainder(record, "stdout")
201272
flushRemainder(record, "stderr")
202-
if (record.session.status === "stopped" || record.session.status === "failed") {
273+
if (record.stopping || record.session.status === "stopped" || record.session.status === "failed") {
203274
return
204275
}
205276
const details = exitCode === null ? `signal ${signal ?? "unknown"}` : `exit code ${exitCode}`
206-
stopRecord(record, `cloudflared exited before the tunnel was stopped (${details}).`)
277+
runStopRecord(record, `cloudflared exited before the tunnel was stopped (${details}).`)
207278
})
208279
}
209280

@@ -303,7 +374,7 @@ const waitForRecordId = (
303374

304375
yield* _(preflightPanelTarget(resolved.targetUrl))
305376
if (currentRecord !== null) {
306-
stopRecord(currentRecord)
377+
yield* _(stopRecord(currentRecord))
307378
}
308379

309380
const record = createStartingRecord(resolved.panelUrl)
@@ -324,4 +395,6 @@ export const startPanelCloudflareTunnel = (
324395
})
325396

326397
export const stopPanelCloudflareTunnel = (): Effect.Effect<PanelCloudflareTunnelSession | null> =>
327-
Effect.sync(() => currentRecord === null ? null : stopRecord(currentRecord)).pipe(tunnelRecordLock.withPermits(1))
398+
Effect.gen(function*(_) {
399+
return currentRecord === null ? null : yield* _(stopRecord(currentRecord))
400+
}).pipe(tunnelRecordLock.withPermits(1))

packages/api/tests/panel-cloudflare-tunnel-core.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest"
2+
import * as fc from "fast-check"
23

34
import {
45
isLocalPanelHostname,
@@ -7,6 +8,58 @@ import {
78
resolvePanelTunnelTargetUrl
89
} from "../src/services/panel-cloudflare-tunnel-core.js"
910

11+
const localhostHost = "172.17.0.1"
12+
const labelCharacters = "abcdefghijklmnopqrstuvwxyz0123456789"
13+
14+
const labelCharacter = (value: number): string =>
15+
labelCharacters[value] ?? "a"
16+
17+
const hostnameLabelArbitrary = fc.array(fc.integer({ min: 0, max: 35 }), {
18+
maxLength: 24,
19+
minLength: 1
20+
}).map((values) => values.map((value) => labelCharacter(value)).join(""))
21+
22+
const urlSuffixArbitrary = fc.array(fc.integer({ min: 0, max: 35 }), {
23+
maxLength: 16,
24+
minLength: 0
25+
}).map((values) => values.length === 0 ? "" : `/share/${values.map((value) => labelCharacter(value)).join("")}?tab=share#logs`)
26+
27+
const publicHostnameArbitrary = fc.oneof(
28+
fc.constantFrom("example.com", "public.example.net"),
29+
hostnameLabelArbitrary.map((label) => `${label}.example.com`)
30+
)
31+
32+
const privateIpv4HostnameArbitrary = fc.constantFrom(
33+
"10.0.0.5",
34+
"100.64.0.1",
35+
"169.254.1.1",
36+
"172.16.0.1",
37+
"172.31.255.254",
38+
"192.168.1.2"
39+
)
40+
41+
const loopbackHostnameArbitrary = fc.constantFrom("0.0.0.0", "127.0.0.1", "localhost")
42+
43+
const tryCloudflareHostnameArbitrary = hostnameLabelArbitrary.map((label) => `${label}.trycloudflare.com`)
44+
45+
const httpUrl = (
46+
protocol: "http" | "https",
47+
hostname: string,
48+
port: number,
49+
suffix: string
50+
): string => `${protocol}://${hostname}:${port}${suffix}`
51+
52+
const originUrl = (value: string): string => {
53+
const url = new URL(value)
54+
url.pathname = "/"
55+
url.search = ""
56+
url.hash = ""
57+
return url.toString()
58+
}
59+
60+
const withoutTryCloudflareUrl = (value: string): boolean =>
61+
!value.toLowerCase().includes(".trycloudflare.com")
62+
1063
describe("panel Cloudflare tunnel core", () => {
1164
it("normalizes panel URLs to their origin", () => {
1265
expect(resolvePanelTunnelTargetUrl("http://192.168.0.206:4174/ports/project", "172.17.0.1")).toEqual({
@@ -59,4 +112,98 @@ describe("panel Cloudflare tunnel core", () => {
59112
parseTryCloudflareUrl("INF | https://yellow-field-123.trycloudflare.com |")
60113
).toBe("https://yellow-field-123.trycloudflare.com")
61114
})
115+
116+
it("normalizes generated private panel URLs to origins", () => {
117+
fc.assert(
118+
fc.property(
119+
fc.constantFrom("http", "https"),
120+
privateIpv4HostnameArbitrary,
121+
fc.integer({ max: 65_535, min: 1 }),
122+
urlSuffixArbitrary,
123+
(protocol, hostname, port, suffix) => {
124+
const panelUrl = httpUrl(protocol, hostname, port, suffix)
125+
const expectedUrl = originUrl(panelUrl)
126+
127+
expect(resolvePanelTunnelTargetUrl(panelUrl, localhostHost)).toEqual({
128+
ok: true,
129+
panelUrl: expectedUrl,
130+
targetUrl: expectedUrl
131+
})
132+
}
133+
)
134+
)
135+
})
136+
137+
it("maps generated loopback panel URLs to the provided localhost host", () => {
138+
fc.assert(
139+
fc.property(
140+
fc.constantFrom("http", "https"),
141+
loopbackHostnameArbitrary,
142+
fc.integer({ max: 65_535, min: 1 }),
143+
urlSuffixArbitrary,
144+
(protocol, hostname, port, suffix) => {
145+
const panelUrl = httpUrl(protocol, hostname, port, suffix)
146+
const expectedTarget = new URL(originUrl(panelUrl))
147+
expectedTarget.hostname = localhostHost
148+
149+
expect(resolvePanelTunnelTargetUrl(panelUrl, localhostHost)).toEqual({
150+
ok: true,
151+
panelUrl: originUrl(panelUrl),
152+
targetUrl: expectedTarget.toString()
153+
})
154+
}
155+
)
156+
)
157+
})
158+
159+
it("rejects generated public and trycloudflare hosts", () => {
160+
fc.assert(
161+
fc.property(
162+
fc.constantFrom("http", "https"),
163+
publicHostnameArbitrary,
164+
fc.integer({ max: 65_535, min: 1 }),
165+
urlSuffixArbitrary,
166+
(protocol, hostname, port, suffix) => {
167+
expect(resolvePanelTunnelTargetUrl(httpUrl(protocol, hostname, port, suffix), localhostHost)).toEqual({
168+
ok: false,
169+
message: "panelUrl must point to localhost or a private LAN address."
170+
})
171+
}
172+
)
173+
)
174+
175+
fc.assert(
176+
fc.property(
177+
tryCloudflareHostnameArbitrary,
178+
fc.integer({ max: 65_535, min: 1 }),
179+
urlSuffixArbitrary,
180+
(hostname, port, suffix) => {
181+
expect(isTryCloudflareHostname(hostname)).toBe(true)
182+
expect(resolvePanelTunnelTargetUrl(httpUrl("https", hostname, port, suffix), localhostHost)).toEqual({
183+
ok: false,
184+
message: "Open docker-git locally before starting a new Cloudflare tunnel."
185+
})
186+
}
187+
)
188+
)
189+
})
190+
191+
it("extracts only generated quick tunnel URLs from noisy cloudflared output", () => {
192+
fc.assert(
193+
fc.property(
194+
fc.string().filter(withoutTryCloudflareUrl),
195+
tryCloudflareHostnameArbitrary,
196+
fc.string().filter(withoutTryCloudflareUrl),
197+
(prefix, hostname, suffix) => {
198+
expect(parseTryCloudflareUrl(`${prefix} https://${hostname} ${suffix}`)).toBe(`https://${hostname}`)
199+
}
200+
)
201+
)
202+
203+
fc.assert(
204+
fc.property(fc.string().filter(withoutTryCloudflareUrl), (output) => {
205+
expect(parseTryCloudflareUrl(output)).toBeNull()
206+
})
207+
)
208+
})
62209
})

0 commit comments

Comments
 (0)