Skip to content

Commit a5c5c88

Browse files
committed
feat: add panel Cloudflare sharing
1 parent 6ab8042 commit a5c5c88

37 files changed

Lines changed: 1087 additions & 5 deletions

packages/api/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ RUN set -eu; \
3434
xvfb libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libgtk-3-0 \
3535
libnss3 libpango-1.0-0 libx11-xcb1 libxcb-dri3-0 libxcomposite1 libxdamage1 libxfixes3 \
3636
libxkbcommon0 libxrandr2 libxshmfence1 \
37+
&& curl --location --output /tmp/cloudflared.deb "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$(dpkg --print-architecture).deb" \
38+
&& dpkg -i /tmp/cloudflared.deb \
39+
&& rm -f /tmp/cloudflared.deb \
3740
&& mkdir -p /usr/share/keyrings \
3841
&& curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
3942
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg \

packages/api/src/api/contracts.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,23 @@ export type ProjectPortForwardRequest = {
7474
readonly hostPort?: number | undefined
7575
}
7676

77+
export type PanelCloudflareTunnelStatus = "starting" | "running" | "stopped" | "failed"
78+
79+
export type PanelCloudflareTunnelSession = {
80+
readonly error: string | null
81+
readonly id: string
82+
readonly logTail: ReadonlyArray<string>
83+
readonly panelUrl: string
84+
readonly publicUrl: string | null
85+
readonly startedAt: string
86+
readonly status: PanelCloudflareTunnelStatus
87+
readonly stoppedAt: string | null
88+
}
89+
90+
export type StartPanelCloudflareTunnelRequest = {
91+
readonly panelUrl: string
92+
}
93+
7794
export type ProjectBrowserStatus = "running" | "stopped" | "missing" | "unknown"
7895

7996
export type ProjectBrowserSession = {

packages/api/src/api/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ export const ProjectPortForwardRequestSchema = Schema.Struct({
173173
targetPort: Schema.Number
174174
})
175175

176+
export const StartPanelCloudflareTunnelRequestSchema = Schema.Struct({
177+
panelUrl: Schema.String
178+
})
179+
176180
export const ProjectBrowserStatusSchema = Schema.Literal("running", "stopped", "missing", "unknown")
177181

178182
export const ProjectBrowserSessionSchema = Schema.Struct({

packages/api/src/http.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
ProjectPromptUpdateRequestSchema,
3737
ProjectSkillUpdateRequestSchema,
3838
StartProjectTerminalSessionRequestSchema,
39+
StartPanelCloudflareTunnelRequestSchema,
3940
StateCommitRequestSchema,
4041
StateInitRequestSchema,
4142
StateSyncRequestSchema,
@@ -109,6 +110,11 @@ import {
109110
import type { ProjectSkillScope } from "./services/project-skills.js"
110111
import { readProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js"
111112
import { parseProjectBrowserProxyPath } from "./services/project-browser-core.js"
113+
import {
114+
readPanelCloudflareTunnel,
115+
startPanelCloudflareTunnel,
116+
stopPanelCloudflareTunnel
117+
} from "./services/panel-cloudflare-tunnel.js"
112118
import {
113119
deleteProjectDatabaseForward,
114120
deleteProjectDatabaseProfile,
@@ -475,6 +481,8 @@ const skillScopeFromBody = (scope: string): ProjectSkillScope | null => {
475481
}
476482
}
477483
const readProjectPortForwardRequest = () => HttpServerRequest.schemaBodyJson(ProjectPortForwardRequestSchema)
484+
const readStartPanelCloudflareTunnelRequest = () =>
485+
HttpServerRequest.schemaBodyJson(StartPanelCloudflareTunnelRequestSchema)
478486
const readProjectDatabaseProfileRequest = () => HttpServerRequest.schemaBodyJson(ProjectDatabaseProfileRequestSchema)
479487
const readStateInitRequest = () => HttpServerRequest.schemaBodyJson(StateInitRequestSchema)
480488
const readStateCommitRequest = () => HttpServerRequest.schemaBodyJson(StateCommitRequestSchema)
@@ -797,6 +805,28 @@ export const makeRouter = () => {
797805
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
798806
Effect.catchAll(errorResponse)
799807
)
808+
),
809+
HttpRouter.get(
810+
"/cloudflare-tunnels/panel",
811+
readPanelCloudflareTunnel().pipe(
812+
Effect.flatMap((tunnel) => jsonResponse({ tunnel }, 200)),
813+
Effect.catchAll(errorResponse)
814+
)
815+
),
816+
HttpRouter.post(
817+
"/cloudflare-tunnels/panel",
818+
Effect.gen(function*(_) {
819+
const request = yield* _(readStartPanelCloudflareTunnelRequest())
820+
const tunnel = yield* _(startPanelCloudflareTunnel(request))
821+
return yield* _(jsonResponse({ tunnel }, 202))
822+
}).pipe(Effect.catchAll(errorResponse))
823+
),
824+
HttpRouter.del(
825+
"/cloudflare-tunnels/panel",
826+
stopPanelCloudflareTunnel().pipe(
827+
Effect.flatMap((tunnel) => jsonResponse({ tunnel }, 200)),
828+
Effect.catchAll(errorResponse)
829+
)
800830
)
801831
)
802832

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { existsSync, readFileSync } from "node:fs"
2+
3+
import { parseLinuxDefaultGatewayIp } from "./project-port-proxy-core.js"
4+
5+
export type PanelTunnelTarget =
6+
| {
7+
readonly ok: true
8+
readonly panelUrl: string
9+
readonly targetUrl: string
10+
}
11+
| {
12+
readonly ok: false
13+
readonly message: string
14+
}
15+
16+
const tryCloudflareHostSuffix = ".trycloudflare.com"
17+
const tryCloudflareUrlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/iu
18+
19+
const stripIpv6Brackets = (value: string): string =>
20+
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value
21+
22+
export const isTryCloudflareHostname = (hostname: string): boolean =>
23+
hostname.toLowerCase().endsWith(tryCloudflareHostSuffix)
24+
25+
export const isLocalPanelHostname = (hostname: string): boolean => {
26+
const normalized = stripIpv6Brackets(hostname).toLowerCase()
27+
return normalized === "localhost" ||
28+
normalized === "127.0.0.1" ||
29+
normalized === "0.0.0.0" ||
30+
normalized === "::1"
31+
}
32+
33+
export const parseTryCloudflareUrl = (output: string): string | null =>
34+
tryCloudflareUrlPattern.exec(output)?.[0] ?? null
35+
36+
const normalizePanelUrl = (value: string): PanelTunnelTarget => {
37+
const trimmed = value.trim()
38+
if (trimmed.length === 0) {
39+
return { ok: false, message: "panelUrl must be a non-empty URL." }
40+
}
41+
42+
try {
43+
const url = new URL(trimmed)
44+
if (url.protocol !== "http:" && url.protocol !== "https:") {
45+
return { ok: false, message: "panelUrl must use http or https." }
46+
}
47+
if (isTryCloudflareHostname(url.hostname)) {
48+
return { ok: false, message: "Open docker-git locally before starting a new Cloudflare tunnel." }
49+
}
50+
url.pathname = "/"
51+
url.search = ""
52+
url.hash = ""
53+
return {
54+
ok: true,
55+
panelUrl: url.toString(),
56+
targetUrl: url.toString()
57+
}
58+
} catch {
59+
return { ok: false, message: "panelUrl must be a valid URL." }
60+
}
61+
}
62+
63+
export const resolvePanelTunnelTargetUrl = (
64+
panelUrl: string,
65+
localhostHost: string
66+
): PanelTunnelTarget => {
67+
const normalized = normalizePanelUrl(panelUrl)
68+
if (!normalized.ok) {
69+
return normalized
70+
}
71+
72+
const target = new URL(normalized.targetUrl)
73+
if (isLocalPanelHostname(target.hostname)) {
74+
target.hostname = localhostHost
75+
}
76+
77+
return {
78+
ok: true,
79+
panelUrl: normalized.panelUrl,
80+
targetUrl: target.toString()
81+
}
82+
}
83+
84+
const readDefaultGatewayIp = (): string | null => {
85+
try {
86+
return parseLinuxDefaultGatewayIp(readFileSync("/proc/net/route", "utf8"))
87+
} catch {
88+
return null
89+
}
90+
}
91+
92+
export const defaultPanelTunnelLocalhostHost = (): string => {
93+
const configured = process.env["DOCKER_GIT_PANEL_TUNNEL_LOCALHOST_HOST"]?.trim()
94+
if (configured !== undefined && configured.length > 0) {
95+
return configured
96+
}
97+
return existsSync("/.dockerenv") ? readDefaultGatewayIp() ?? "172.17.0.1" : "127.0.0.1"
98+
}

0 commit comments

Comments
 (0)