feat(tauri): support self-signed remote HTTPS via server-backed proxy (#333)

## Summary

- add a server-backed HTTPS proxy flow for Tauri remote windows so
self-signed remote HTTPS works with the local CLI TLS assets and desktop
auth/cookie handling
- manage remote proxy sessions through `packages/server` with
per-session bootstrap, local-only cleanup, and explicit session
lifecycle handling
- support the Tauri desktop flow across environments, including packaged
Windows builds, `tauri dev`, and updated Linux/macOS handling for the
new local HTTPS proxy path

## Testing

- `npm run build --workspace @neuralnomads/codenomad`
- `cargo check`
- `npm run build --workspace @codenomad/tauri-app`
- Windows smoke test for concurrent remote proxy bootstrap sessions
- Windows manual validation of packaged Tauri remote connection flow

## Notes

- Windows was validated end-to-end.
- Linux and macOS code paths were updated for the new proxy flow, but
runtime validation on those platforms is still pending.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
Pascal André
2026-04-20 00:26:55 +02:00
committed by GitHub
parent 623a09fd7e
commit 04fc28c492
20 changed files with 4921 additions and 88 deletions

69
package-lock.json generated
View File

@@ -72,6 +72,7 @@
"version": "7.28.5",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -3676,6 +3677,7 @@
"version": "7.20.5",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
@@ -3777,6 +3779,7 @@
"version": "22.19.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3851,6 +3854,7 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cac": "^6.7.14",
"colorette": "^2.0.20",
@@ -3933,6 +3937,7 @@
"version": "6.12.6",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4135,7 +4140,6 @@
"version": "5.3.2",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"archiver-utils": "^2.1.0",
"async": "^3.2.4",
@@ -4153,7 +4157,6 @@
"version": "2.1.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob": "^7.1.4",
"graceful-fs": "^4.2.0",
@@ -4174,7 +4177,6 @@
"version": "2.3.8",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -4188,14 +4190,12 @@
"node_modules/archiver-utils/node_modules/safe-buffer": {
"version": "5.1.2",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -4509,7 +4509,6 @@
"version": "4.1.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
@@ -4573,6 +4572,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5063,7 +5063,6 @@
"version": "4.1.2",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2",
@@ -5193,7 +5192,6 @@
"version": "1.2.2",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"crc32": "bin/crc32.njs"
},
@@ -5205,7 +5203,6 @@
"version": "4.0.3",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^3.4.0"
@@ -5571,6 +5568,7 @@
"version": "24.13.3",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "24.13.3",
"builder-util": "24.13.1",
@@ -5737,7 +5735,6 @@
"version": "24.13.3",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "24.13.3",
"archiver": "^5.3.1",
@@ -5749,7 +5746,6 @@
"version": "10.1.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -5763,7 +5759,6 @@
"version": "6.2.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -5775,7 +5770,6 @@
"version": "2.0.1",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -6493,8 +6487,7 @@
"node_modules/fs-constants": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "8.1.0",
@@ -7711,8 +7704,7 @@
"node_modules/isarray": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/isbinaryfile": {
"version": "5.0.6",
@@ -7762,6 +7754,7 @@
"version": "1.21.7",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7893,7 +7886,6 @@
"version": "1.0.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readable-stream": "^2.0.5"
},
@@ -7905,7 +7897,6 @@
"version": "2.3.8",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -7919,14 +7910,12 @@
"node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -7991,26 +7980,22 @@
"node_modules/lodash.defaults": {
"version": "4.2.0",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.difference": {
"version": "4.5.0",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.flatten": {
"version": "4.4.0",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.sortby": {
"version": "4.7.0",
@@ -8022,8 +8007,7 @@
"node_modules/lodash.union": {
"version": "4.6.0",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
@@ -8827,6 +8811,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8974,8 +8959,7 @@
"node_modules/process-nextick-args": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/process-warning": {
"version": "3.0.0",
@@ -9224,7 +9208,6 @@
"version": "3.6.2",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -9238,7 +9221,6 @@
"version": "1.1.3",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"minimatch": "^5.1.0"
}
@@ -9541,6 +9523,7 @@
"version": "4.52.5",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -9764,6 +9747,7 @@
"node_modules/seroval": {
"version": "1.3.2",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -10087,6 +10071,7 @@
"node_modules/solid-js": {
"version": "1.9.10",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
@@ -10227,7 +10212,6 @@
"version": "1.3.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@ -10561,7 +10545,6 @@
"version": "2.2.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
@@ -10754,6 +10737,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11003,6 +10987,7 @@
"version": "5.9.3",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11350,6 +11335,7 @@
"version": "5.4.21",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -11834,6 +11820,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -12028,6 +12015,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -12316,7 +12304,6 @@
"version": "4.1.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2",
@@ -12330,7 +12317,6 @@
"version": "3.0.4",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob": "^7.2.3",
"graceful-fs": "^4.2.0",
@@ -12350,6 +12336,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -20,10 +20,24 @@ function getDefaultShellPath(): string {
return "/bin/bash"
}
function wrapCommandForShell(command: string, shellPath: string): string {
const shellName = path.basename(shellPath)
if (shellName.includes("bash")) {
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
}
if (shellName.includes("zsh")) {
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
}
return command
}
function buildShellArgs(shellPath: string): string[] {
const shellName = path.basename(shellPath)
if (shellName.includes("zsh") || shellName.includes("bash")) {
return ["-i", "-l", "-c"]
if (shellName.includes("zsh")) {
return ["-l", "-i", "-c"]
}
return ["-l", "-c"]
}
@@ -45,11 +59,12 @@ export function buildUserShellCommand(userCommand: string): ShellCommand {
}
const shellPath = getDefaultShellPath()
const script = wrapCommandForShell(userCommand, shellPath)
const args = buildShellArgs(shellPath)
return {
command: shellPath,
args: [...args, userCommand],
args: [...args, script],
}
}

View File

@@ -337,6 +337,16 @@ export interface RemoteServerProbeResponse {
errorCode?: string
}
export interface RemoteProxySessionCreateRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteProxySessionCreateResponse {
sessionId: string
windowUrl: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"

View File

@@ -21,6 +21,7 @@ import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { RemoteProxySessionManager } from "./server/remote-proxy"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
@@ -375,14 +376,15 @@ async function main() {
})
: null
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
const remoteProxySessionManager = new RemoteProxySessionManager({
authManager,
logger: logger.child({ component: "remote-proxy" }),
httpsOptions: tlsResolution?.httpsOptions,
})
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
@@ -422,6 +424,7 @@ async function main() {
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
@@ -447,6 +450,7 @@ async function main() {
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,

View File

@@ -0,0 +1,248 @@
import assert from "node:assert/strict"
import { after, afterEach, describe, it } from "node:test"
import fs from "node:fs"
import http, { type IncomingMessage, type ServerResponse } from "node:http"
import os from "node:os"
import path from "node:path"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../../auth/manager"
import type { Logger } from "../../logger"
import { RemoteProxySessionManager } from "../remote-proxy"
import { resolveHttpsOptions } from "../tls"
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
const sharedTls = resolveHttpsOptions({
enabled: true,
configDir: sharedTempDir,
host: "127.0.0.1",
logger: createStubLogger(),
})
if (!sharedTls) {
throw new Error("Failed to generate HTTPS options for remote proxy tests")
}
const sharedHttpsOptions = sharedTls.httpsOptions
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
const managers = new Set<RemoteProxySessionManager>()
afterEach(async () => {
for (const manager of managers) {
await disposeManager(manager)
}
managers.clear()
})
after(() => {
fs.rmSync(sharedTempDir, { recursive: true, force: true })
httpsDispatcher.close().catch(() => {})
})
describe("RemoteProxySessionManager", () => {
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
assert.equal(blocked.status, 403)
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session2.token }),
})
assert.equal(wrongTokenResponse.status, 401)
assert.equal(await activateSession(session1), true)
assert.equal(await activateSession(session2), true)
}, (req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.url ?? "")
})
})
it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
assert.equal(apiResponse.status, 200)
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" })
assert.equal(redirectResponse.status, 302)
assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`)
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/redirect") {
res.writeHead(302, { location: "/base/after?ok=1" })
res.end()
return
}
res.writeHead(200, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
assert.equal(loginResponse.status, 200)
const setCookie = getSetCookie(loginResponse)[0]
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
assert.doesNotMatch(setCookie, /domain=/i)
const cookieHeader = setCookie.split(";", 1)[0]
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
headers: { cookie: cookieHeader },
})
assert.equal(await whoamiResponse.text(), "session=abc123")
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/login") {
res.writeHead(200, {
"content-type": "text/plain",
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
})
res.end("ok")
return
}
if (requestUrl === "/base/whoami") {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.headers.cookie ?? "")
return
}
res.writeHead(404, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("supports explicit deletion and idle cleanup of sessions", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
assert.equal(await manager.deleteSession(session.sessionId), true)
assert.equal(await manager.deleteSession(session.sessionId), false)
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
await internalCleanup.call(manager)
assert.equal(internalSessions.has(session3.sessionId), false)
assert.equal(await manager.deleteSession(session3.sessionId), false)
}, (_req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end("ok")
})
})
})
function createSessionManager() {
const manager = new RemoteProxySessionManager({
authManager: {
isLoopbackRequest: () => true,
} as unknown as AuthManager,
logger: createStubLogger(),
httpsOptions: sharedHttpsOptions,
})
managers.add(manager)
return manager
}
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
const created = await manager.createSession(baseUrl, false)
const windowUrl = new URL(created.windowUrl)
return {
sessionId: created.sessionId,
windowUrl,
proxyOrigin: windowUrl.origin,
token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")),
}
}
async function activateSession(session: { proxyOrigin: string; token: string }) {
const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session.token }),
})
if (!response.ok) {
return false
}
const body = (await response.json()) as { ok?: boolean }
return body.ok === true
}
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(values) && values.length > 0) {
return values
}
const fallback = response.headers.get("set-cookie")
return fallback ? [fallback] : []
}
async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
return fetch(url, { dispatcher: httpsDispatcher, ...init })
}
async function disposeManager(manager: RemoteProxySessionManager) {
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).keys())
for (const sessionId of sessions) {
await manager.deleteSession(sessionId)
}
clearInterval((manager as any).cleanupTimer as NodeJS.Timeout)
}
async function withUpstreamServer(
callback: (baseUrl: string) => Promise<void>,
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
) {
const server = http.createServer(handler)
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
try {
const address = server.address()
if (!address || typeof address === "string") {
throw new Error("Failed to resolve upstream server address")
}
await callback(`http://127.0.0.1:${address.port}`)
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
}
function createStubLogger(): Logger {
const logger = {
info() {},
warn() {},
error() {},
child() {
return logger
},
}
return logger as unknown as Logger
}

View File

@@ -26,6 +26,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
@@ -38,6 +39,7 @@ import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
interface HttpServerDeps {
bindHost: string
@@ -58,6 +60,7 @@ interface HttpServerDeps {
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -199,7 +202,12 @@ export function createHttpServer(deps: HttpServerDeps) {
publicPagePaths.add("/auth/token")
}
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
const isLoopbackRemoteProxyDelete =
request.method === "DELETE" &&
pathname.startsWith("/api/remote-proxy/sessions/") &&
deps.authManager.isLoopbackRequest(request)
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
done()
return
}
@@ -274,6 +282,7 @@ export function createHttpServer(deps: HttpServerDeps) {
workspaceManager: deps.workspaceManager,
})
registerRemoteServerRoutes(app, { logger: apiLogger })
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })

View File

@@ -0,0 +1,566 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import { randomBytes, randomUUID } from "crypto"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../auth/manager"
import type { Logger } from "../logger"
const LOOPBACK_HOST = "127.0.0.1"
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
const SESSION_IDLE_TTL_MS = 30 * 60_000
interface RemoteProxySession {
id: string
bootstrapToken: string
targetBaseUrl: URL
skipTlsVerify: boolean
localBaseUrl: URL
entryUrl: URL
bootstrapUrl: string
activated: boolean
cookiePrefix: string
app: FastifyInstance
dispatcher?: Agent
createdAt: number
lastAccessAt: number
}
export interface RemoteProxySessionManagerOptions {
authManager: AuthManager
logger: Logger
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
}
export interface RemoteProxySessionCreateResult {
sessionId: string
windowUrl: string
}
export class RemoteProxySessionManager {
private readonly sessions = new Map<string, RemoteProxySession>()
private readonly cleanupTimer: NodeJS.Timeout
constructor(private readonly options: RemoteProxySessionManagerOptions) {
this.cleanupTimer = setInterval(() => {
void this.cleanupExpiredSessions()
}, 60_000)
this.cleanupTimer.unref()
}
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
if (!this.options.httpsOptions) {
throw new Error("Local HTTPS is required for remote proxy sessions")
}
const targetBaseUrl = normalizeBaseUrl(baseUrl)
const sessionId = randomUUID()
const bootstrapToken = randomBytes(32).toString("base64url")
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
const app = Fastify({ logger: false, https: this.options.httpsOptions })
let session: RemoteProxySession | null = null
app.removeAllContentTypeParsers()
// Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body))
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(buildBootstrapPageHtml())
})
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
const body = parseTokenBody(request.body)
if (body.token !== session.bootstrapToken) {
reply.code(401).send({ error: "Invalid token" })
return
}
session.activated = true
session.lastAccessAt = Date.now()
reply.send({ ok: true })
})
app.all("/*", async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
app.setNotFoundHandler(async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
const address = new URL(addressInfo)
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
const returnTo = buildReturnToTarget(entryUrl)
session = {
id: sessionId,
bootstrapToken,
targetBaseUrl,
skipTlsVerify,
localBaseUrl,
entryUrl,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
activated: false,
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
app,
dispatcher,
createdAt: Date.now(),
lastAccessAt: Date.now(),
}
this.sessions.set(sessionId, session)
this.options.logger.info(
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
"Created remote proxy session",
)
return { sessionId, windowUrl: session.bootstrapUrl }
}
async deleteSession(sessionId: string): Promise<boolean> {
return this.disposeSession(sessionId)
}
private async cleanupExpiredSessions() {
const now = Date.now()
for (const session of Array.from(this.sessions.values())) {
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
continue
}
await this.disposeSession(session.id)
}
}
private async disposeSession(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (!session) {
return false
}
this.sessions.delete(sessionId)
session.dispatcher?.close().catch(() => {})
await session.app.close().catch(() => {})
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
return true
}
}
function normalizeBaseUrl(input: string): URL {
const parsed = new URL(input.trim())
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Server URL must use http:// or https://")
}
parsed.hash = ""
parsed.search = ""
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
return parsed
}
function buildReturnToTarget(entryUrl: URL): string {
const query = entryUrl.search ? entryUrl.search : ""
return `${entryUrl.pathname || "/"}${query}`
}
function buildBootstrapPageHtml(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
h1 { font-size: 18px; margin: 0 0 12px; }
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Connecting...</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error"></div>
</div>
<script>
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
const params = new URLSearchParams(location.search)
const returnTo = sanitizeReturnTo(params.get("returnTo"))
const errorEl = document.getElementById("error")
function sanitizeReturnTo(value) {
if (!value || typeof value !== "string") return "/"
if (!value.startsWith("/")) return "/"
if (value.startsWith("//")) return "/"
return value
}
function showError(message) {
errorEl.textContent = message
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || "Token exchange failed (" + res.status + ")")
return
}
window.location.replace(returnTo)
} catch (error) {
showError(error && error.message ? error.message : String(error))
}
}
run()
</script>
</body>
</html>`
}
function parseTokenBody(body: unknown): { token: string } {
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
const token = typeof value?.token === "string" ? value.token.trim() : ""
if (!token) {
throw new Error("Missing bootstrap token")
}
return { token }
}
function normalizeJsonBody(body: unknown): unknown {
if (Buffer.isBuffer(body)) {
return JSON.parse(body.toString("utf-8"))
}
if (typeof body === "string") {
return JSON.parse(body)
}
return body
}
function toRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
async function proxyRequest(args: {
request: FastifyRequest
reply: FastifyReply
session: RemoteProxySession
logger: Logger
}) {
const { request, reply, session, logger } = args
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
const headers = filterRequestHeaders(request.headers, session)
const init: any = {
method: request.method,
headers,
dispatcher: session.dispatcher,
redirect: "manual",
}
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
try {
const response = await fetch(upstreamUrl, init as any)
reply.code(response.status)
applyResponseHeaders(reply, response, session)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
reply.hijack()
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
} catch (error) {
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
if (!reply.sent) {
reply.code(502).send({ error: "Remote proxy request failed" })
}
}
}
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
const parsed = new URL(rawUrl, "https://localhost")
const url = new URL(baseUrl.toString())
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
url.search = stripInternalQuery(parsed.search)
url.hash = ""
return url.toString()
}
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
const basePath = normalizedBasePath(baseUrl)
if (basePath === "/") {
return requestPath
}
if (requestPath === "/") {
return basePath
}
if (pathHasBasePrefix(basePath, requestPath)) {
return requestPath
}
return `${basePath}${requestPath}`
}
function normalizedBasePath(baseUrl: URL): string {
return baseUrl.pathname || "/"
}
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
}
function stripInternalQuery(search: string): string {
if (!search || search === "?") {
return ""
}
return search
}
function filterRequestHeaders(
headers: FastifyRequest["headers"],
session: RemoteProxySession,
): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value) continue
const lower = key.toLowerCase()
if (
isHopByHopHeader(lower) ||
lower === "host" ||
lower === "content-length" ||
lower === "accept-encoding"
) {
continue
}
if (lower === "origin") {
next[key] = session.targetBaseUrl.origin
continue
}
if (lower === "referer") {
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
if (rewritten) {
next[key] = rewritten
}
continue
}
if (lower === "cookie") {
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
if (rewritten) {
next[key] = rewritten
}
continue
}
next[key] = Array.isArray(value) ? value.join(",") : value
}
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
if (!next.origin) {
next.origin = session.targetBaseUrl.origin
}
return next
}
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
if (!referer) {
return null
}
try {
const parsed = new URL(referer)
const rewritten = new URL(targetBaseUrl.toString())
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return null
}
}
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(setCookie)) {
for (const cookie of setCookie) {
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
}
}
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (
isHopByHopHeader(lower) ||
lower === "set-cookie" ||
lower === "content-length" ||
lower === "content-encoding"
) {
return
}
if (lower === "location") {
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
return
}
reply.header(key, value)
})
}
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
const next: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
next[key] = Array.isArray(value) ? value.map(String) : String(value)
}
return next
}
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
const parts = cookie.split(";").map((part) => part.trim())
const first = parts.shift() ?? ""
const separator = first.indexOf("=")
if (separator <= 0) {
return cookie
}
const name = first.slice(0, separator).trim()
const value = first.slice(separator + 1)
const rewritten = [`${cookiePrefix}${name}=${value}`]
for (const part of parts) {
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
continue
}
rewritten.push(part)
}
return rewritten.join("; ")
}
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
const next: string[] = []
for (const rawPart of cookieHeader.split(";")) {
const part = rawPart.trim()
if (!part) continue
const separator = part.indexOf("=")
if (separator <= 0) continue
const name = part.slice(0, separator).trim()
const value = part.slice(separator + 1)
if (!name.startsWith(cookiePrefix)) {
continue
}
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
}
return next.join("; ")
}
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
try {
const parsed = new URL(location, targetBaseUrl)
if (parsed.origin !== targetBaseUrl.origin) {
return location
}
const rewritten = new URL(localBaseUrl.toString())
rewritten.pathname = parsed.pathname
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return location
}
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}

View File

@@ -0,0 +1,54 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { RemoteProxySessionCreateResponse } from "../../api-types"
import { isLoopbackAddress } from "../../auth/http-auth"
import type { Logger } from "../../logger"
import type { RemoteProxySessionManager } from "../remote-proxy"
interface RouteDeps {
logger: Logger
sessionManager: RemoteProxySessionManager
}
const CreateSessionSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
const SessionParamsSchema = z.object({
id: z.string().uuid(),
})
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
try {
const body = CreateSessionSchema.parse(request.body ?? {})
return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
} catch (error) {
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
}
})
app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => {
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404)
return { error: "Not found" }
}
try {
const params = SessionParamsSchema.parse(request.params ?? {})
const deleted = await deps.sessionManager.deleteSession(params.id)
if (!deleted) {
reply.code(404)
return { error: "Remote proxy session not found" }
}
return { ok: true }
} catch (error) {
deps.logger.warn({ err: error }, "Failed to delete remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" }
}
})
}

View File

@@ -213,6 +213,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -408,6 +430,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -444,6 +468,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -456,17 +486,28 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codenomad-tauri"
version = "0.14.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"dirs 5.0.1",
"keepawake",
"libc",
"once_cell",
"parking_lot",
"regex",
"reqwest 0.12.28",
"rustls",
"serde",
"serde_json",
"serde_yaml",
@@ -476,8 +517,8 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-opener",
"thiserror 1.0.69",
"url",
"webkit2gtk",
"which",
"windows-sys 0.59.0",
]
@@ -969,6 +1010,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1139,6 +1189,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1379,8 +1435,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1390,9 +1448,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1574,6 +1634,25 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1699,6 +1778,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1710,6 +1790,23 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1999,6 +2096,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -2157,6 +2264,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2995,6 +3108,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3212,6 +3380,50 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3242,7 +3454,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
@@ -3270,6 +3482,20 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3311,6 +3537,44 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3531,6 +3795,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
@@ -3792,6 +4068,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3943,7 +4225,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -4367,6 +4649,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
@@ -4381,6 +4678,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4691,6 +4998,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4902,6 +5215,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -4937,6 +5263,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@@ -4993,6 +5329,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5286,6 +5631,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5927,6 +6281,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -37,6 +37,12 @@ const braceExpansionPath = path.join(
"package.json",
)
const serverBuildDependencyPaths = [
path.join(serverRoot, "node_modules", "typescript", "package.json"),
path.join(serverRoot, "node_modules", "@types", "node-forge", "package.json"),
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
]
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
async function ensureMonacoAssets() {
@@ -98,7 +104,7 @@ function syncServerUiBundle() {
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
return
}

View File

@@ -12,10 +12,11 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
base64 = "0.22"
rustls = { version = "0.23", features = ["ring"] }
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
@@ -28,4 +29,7 @@ url = "2"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0.2"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,449 @@
use base64::Engine;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
const TLS_DIR_NAME: &str = "tls";
const CA_CERT_FILE: &str = "ca-cert.pem";
const SERVER_CERT_FILE: &str = "server-cert.pem";
const SERVER_KEY_FILE: &str = "server-key.pem";
const TRUSTED_MARKER: &str = "server-ca.trusted";
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy,
/// plus the CA certificate DER used for trust-store installation.
pub struct LocalCert {
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_der: Vec<u8>,
}
struct TlsAssetPaths {
cert_path: PathBuf,
key_path: PathBuf,
trust_path: PathBuf,
append_ca_to_cert: bool,
}
/// Loads the TLS assets already managed by `packages/server`.
pub fn ensure_local_cert() -> Result<LocalCert, String> {
let assets = resolve_tls_asset_paths()?;
let mut cert_pem = read_pem_file(&assets.cert_path)?;
let key_pem = read_pem_file(&assets.key_path)?;
let trust_pem = read_pem_file(&assets.trust_path)?;
if assets.append_ca_to_cert {
cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim());
}
let ca_cert_der = pem_to_der(&trust_pem)?;
Ok(LocalCert {
cert_pem,
key_pem,
ca_cert_der,
})
}
fn read_pem_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
}
fn server_tls_dir() -> Result<PathBuf, String> {
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
}
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
let tls_key_path = env::var("CLI_TLS_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_cert_path = env::var("CLI_TLS_CERT")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_ca_path = env::var("CLI_TLS_CA")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
match (tls_key_path, tls_cert_path) {
(Some(key_path), Some(cert_path)) => {
let append_ca_to_cert = tls_ca_path.is_some();
let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone());
Ok(TlsAssetPaths {
cert_path,
key_path,
trust_path,
append_ca_to_cert,
})
}
(Some(_), None) | (None, Some(_)) => Err(
"CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files"
.to_string(),
),
(None, None) => {
let tls_dir = server_tls_dir()?;
Ok(TlsAssetPaths {
cert_path: tls_dir.join(SERVER_CERT_FILE),
key_path: tls_dir.join(SERVER_KEY_FILE),
trust_path: tls_dir.join(CA_CERT_FILE),
append_ca_to_cert: true,
})
}
}
}
fn resolve_server_config_base_dir() -> Result<PathBuf, String> {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
let expanded = resolve_path_like_server(&raw)?;
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") {
return expanded
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display()));
}
Ok(expanded)
}
fn resolve_path_like_server(path: &str) -> Result<PathBuf, String> {
if path.starts_with("~/") {
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?;
return Ok(home.join(path.trim_start_matches("~/")));
}
let path = PathBuf::from(path);
if path.is_absolute() {
return Ok(path);
}
let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?;
Ok(cwd.join(path))
}
fn trusted_marker_path() -> Result<PathBuf, String> {
let base = dirs::data_local_dir()
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
#[cfg(windows)]
{
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER));
}
#[cfg(not(windows))]
{
Ok(base.join("codenomad").join(TRUSTED_MARKER))
}
}
fn trusted_marker_value(cert_der: &[u8]) -> String {
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn trusted_marker_file_suffix(cert_der: &[u8]) -> String {
trusted_marker_value(cert_der).chars().take(16).collect()
}
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
trusted_marker_path()
.ok()
.and_then(|path| fs::read_to_string(path).ok())
.map(|value| value.trim() == trusted_marker_value(cert_der))
.unwrap_or(false)
}
fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
let path = trusted_marker_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?;
}
fs::write(path, trusted_marker_value(cert_der))
.map_err(|e| format!("Failed to write trust marker: {e}"))
}
#[cfg(windows)]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!windows_cert_is_trusted(cert_der)?)
}
#[cfg(windows)]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use windows_sys::Win32::Security::Cryptography::{
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
};
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
let result = CertAddEncodedCertificateToStore(
store,
encoding,
cert_der.as_ptr(),
cert_der.len() as u32,
CERT_STORE_ADD_REPLACE_EXISTING,
std::ptr::null_mut(),
);
CertCloseStore(store, 0);
if result == 0 {
return Err(
"Failed to add certificate to trust store. The user may have declined the security dialog."
.into(),
);
}
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!(has_matching_trusted_marker(cert_der) && macos_cert_is_trusted(cert_der)?))
}
#[cfg(target_os = "macos")]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use std::process::Command;
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let mut command = Command::new("/usr/bin/security");
command.args(["add-trusted-cert", "-r", "trustRoot", "-k"]);
command.arg(&keychain_path);
let output = command.arg(&temp_path).output().map_err(|e| {
format!(
"Failed to launch macOS security tool to trust the local CA certificate: {e}"
)
})?;
let _ = fs::remove_file(&temp_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to add the local CodeNomad CA certificate to the macOS trust settings: {detail}"
));
}
if !macos_cert_is_trusted(cert_der)? {
return Err(format!(
"Added the local CodeNomad CA certificate to {} but could not verify that macOS trusts it",
keychain_path.display()
));
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(windows)]
fn windows_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use windows_sys::Win32::Security::Cryptography::{
CertCloseStore, CertEnumCertificatesInStore, CertOpenSystemStoreW,
};
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let mut context = CertEnumCertificatesInStore(store, std::ptr::null());
while !context.is_null() {
let encoded = std::slice::from_raw_parts(
(*context).pbCertEncoded,
(*context).cbCertEncoded as usize,
);
if encoded == cert_der {
CertCloseStore(store, 0);
return Ok(true);
}
context = CertEnumCertificatesInStore(store, context);
}
CertCloseStore(store, 0);
Ok(false)
}
}
#[cfg(target_os = "macos")]
fn resolve_macos_user_keychain() -> Result<PathBuf, String> {
let output = std::process::Command::new("/usr/bin/security")
.args(["default-keychain", "-d", "user"])
.output()
.map_err(|e| format!("Failed to resolve macOS default user keychain: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim().trim_matches('"');
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory for macOS keychain lookup".to_string())?;
Ok(home.join("Library/Keychains/login.keychain-db"))
}
#[cfg(target_os = "macos")]
fn macos_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use std::process::Command;
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-verify-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let fingerprint = macos_cert_sha256(&temp_path)?;
let find_output = Command::new("/usr/bin/security")
.args(["find-certificate", "-a", "-Z", "-c", "CodeNomad Local CA"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to query macOS keychain certificates: {e}"))?;
if !find_output.status.success() {
let _ = fs::remove_file(&temp_path);
let stderr = String::from_utf8_lossy(&find_output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", find_output.status)
} else {
stderr
};
return Err(format!(
"Failed to inspect the macOS keychain for the local CodeNomad CA certificate: {detail}"
));
}
let stdout = String::from_utf8_lossy(&find_output.stdout);
if !stdout.to_ascii_uppercase().contains(&fingerprint) {
let _ = fs::remove_file(&temp_path);
return Ok(false);
}
let verify_output = Command::new("/usr/bin/security")
.args(["verify-cert", "-q", "-L", "-l", "-p", "basic", "-c"])
.arg(&temp_path)
.args(["-k"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to verify macOS trust for the local CodeNomad CA certificate: {e}"))?;
let _ = fs::remove_file(&temp_path);
Ok(verify_output.status.success())
}
#[cfg(target_os = "macos")]
fn macos_cert_sha256(cert_path: &Path) -> Result<String, String> {
let output = std::process::Command::new("/usr/bin/shasum")
.args(["-a", "256"])
.arg(cert_path)
.output()
.map_err(|e| format!("Failed to compute SHA-256 for {}: {e}", cert_path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("shasum exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to compute SHA-256 for {}: {detail}",
cert_path.display()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let hash = stdout
.split_whitespace()
.next()
.ok_or_else(|| format!("Failed to parse SHA-256 output for {}", cert_path.display()))?;
Ok(hash.to_ascii_uppercase())
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn needs_trust_in_store(_cert_der: &[u8]) -> Result<bool, String> {
Ok(false)
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
Ok(())
}
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
let mut body = String::new();
let mut in_block = false;
for line in pem.lines() {
if line.starts_with("-----BEGIN CERTIFICATE-----") {
in_block = true;
continue;
}
if line.starts_with("-----END CERTIFICATE-----") {
break;
}
if in_block {
body.push_str(line.trim());
}
}
if body.is_empty() {
return Err("No certificate found in PEM file".to_string());
}
base64::engine::general_purpose::STANDARD
.decode(body)
.map_err(|e| format!("Failed to decode certificate PEM: {e}"))
}

View File

@@ -1094,7 +1094,8 @@ impl CliEntry {
];
if dev {
// Dev: plain HTTP + Vite dev server proxy.
// Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so
// remote proxy sessions can still spin up secure local windows.
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
@@ -1111,7 +1112,7 @@ impl CliEntry {
.unwrap_or_else(|| "info".to_string());
args.push("--https".to_string());
args.push("false".to_string());
args.push("true".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--http-port".to_string());

View File

@@ -0,0 +1,88 @@
use crate::AppState;
use tauri::{AppHandle, Manager, WebviewWindow};
use url::Url;
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
pub fn should_bootstrap_tls_navigation(target_url: &Url, allow_tls_certificate: bool) -> bool {
allow_tls_certificate && target_url.scheme() == "https"
}
pub fn ensure_remote_window_tls_handler(
window: &WebviewWindow,
app_handle: &AppHandle,
window_label: &str,
) -> Result<(), String> {
{
let state = app_handle.state::<AppState>();
let mut handlers = state
.remote_tls_handlers
.lock()
.map_err(|err| err.to_string())?;
if !handlers.insert(window_label.to_string()) {
return Ok(());
}
}
let app_handle = app_handle.clone();
let window_label = window_label.to_string();
window
.with_webview(move |platform_webview| {
let webview = platform_webview.inner();
let app_handle = app_handle.clone();
let window_label = window_label.clone();
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
allow_remote_tls_certificate(
&app_handle,
&window_label,
view,
failing_uri,
certificate,
)
});
})
.map_err(|err| err.to_string())
}
fn allow_remote_tls_certificate(
app_handle: &AppHandle,
window_label: &str,
view: &WebView,
failing_uri: &str,
certificate: &webkit2gtk::gio::TlsCertificate,
) -> bool {
let Ok(parsed_uri) = Url::parse(failing_uri) else {
return false;
};
let Some(host) = parsed_uri.host_str() else {
return false;
};
let state = app_handle.state::<AppState>();
let skip_tls_verify = state
.remote_skip_tls_verify
.lock()
.ok()
.and_then(|values| values.get(window_label).copied())
.unwrap_or(false);
if !skip_tls_verify {
return false;
}
let expected_origin = state
.remote_origins
.lock()
.ok()
.and_then(|origins| origins.get(window_label).cloned());
let parsed_origin = parsed_uri.origin().ascii_serialization();
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
return false;
}
let Some(context) = view.context() else {
return false;
};
context.allow_tls_certificate_for_host(certificate, host);
view.load_uri(failing_uri);
true
}

View File

@@ -1,12 +1,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[allow(dead_code)]
mod cert_manager;
mod cli_manager;
#[cfg(target_os = "linux")]
mod linux_tls;
use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -16,6 +20,7 @@ use tauri::webview::Webview;
use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
@@ -45,6 +50,9 @@ pub struct AppState {
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
}
#[derive(Debug, Deserialize)]
@@ -53,9 +61,87 @@ struct RemoteWindowPayload {
id: String,
name: String,
base_url: String,
entry_url: Option<String>,
proxy_session_id: Option<String>,
#[allow(dead_code)]
skip_tls_verify: bool,
}
fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
tauri::async_runtime::spawn(async move {
if let Err(err) = cleanup_remote_proxy_session(&app, &session_id).await {
eprintln!(
"[tauri] failed to clean up remote proxy session {}: {}",
session_id, err
);
}
});
}
async fn confirm_local_certificate_install(app: &AppHandle) -> Result<bool, String> {
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
let mut dialog = app
.dialog()
.message(
"CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
)
.title("Install Local Certificate")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom(
"Continue".into(),
"Cancel".into(),
));
if let Some(window) = app.get_webview_window("main") {
dialog = dialog.parent(&window);
}
dialog.show(move |accepted| {
let _ = sender.send(accepted);
});
tauri::async_runtime::spawn_blocking(move || receiver.recv().unwrap_or(false))
.await
.map_err(|err| err.to_string())
}
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
let status = app.state::<AppState>().manager.status();
let Some(base_url) = status.url else {
return Ok(());
};
let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?;
cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}"));
cleanup_url.set_query(None);
cleanup_url.set_fragment(None);
let client = if cleanup_url.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert()?;
let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der)
.map_err(|err| err.to_string())?;
reqwest::Client::builder()
.add_root_certificate(ca_cert)
.build()
.map_err(|err| err.to_string())?
} else {
reqwest::Client::new()
};
let response = client
.delete(cleanup_url.as_str())
.send()
.await
.map_err(|err| err.to_string())?;
if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(());
}
Err(format!("unexpected status {}", response.status()))
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
@@ -119,7 +205,7 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"tauri" | "asset" | "file" | "about" => true,
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
@@ -167,25 +253,61 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false
}
#[tauri::command]
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
return Err(
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
.to_string(),
);
}
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
async fn open_remote_window_impl(
app: AppHandle,
payload: RemoteWindowPayload,
) -> Result<(), String> {
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!(
"{} - {}",
payload.name,
parsed.host_str().unwrap_or(payload.base_url.as_str())
Url::parse(&payload.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| payload.base_url.clone())
);
let window_url = parsed.clone();
let allow_linux_tls_certificate =
parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify);
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), window_url.origin().ascii_serialization());
app.state::<AppState>()
.remote_skip_tls_verify
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), allow_linux_tls_certificate);
let replaced_session = {
let state = app.state::<AppState>();
let mut sessions = state
.remote_proxy_sessions
.lock()
.map_err(|err| err.to_string())?;
match payload.proxy_session_id.clone() {
Some(session_id) => sessions.insert(label.clone(), session_id),
None => sessions.remove(&label),
}
};
if let Some(previous) = replaced_session {
if payload.proxy_session_id.as_deref() != Some(previous.as_str()) {
schedule_remote_proxy_session_cleanup(app.clone(), previous);
}
}
if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(parsed.clone());
#[cfg(target_os = "linux")]
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
let _ = existing.navigate(window_url.clone());
let _ = existing.set_title(&title);
let _ = existing.show();
let _ = existing.unminimize();
@@ -193,25 +315,51 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
return Ok(());
}
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization());
#[cfg(target_os = "linux")]
let initial_url = if linux_tls::should_bootstrap_tls_navigation(
&window_url,
allow_linux_tls_certificate,
) {
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
};
let window =
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[cfg(not(target_os = "linux"))]
let initial_url = window_url.clone();
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[cfg(target_os = "linux")]
{
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
if initial_url != window_url {
let _ = window.navigate(window_url.clone());
}
}
let app_handle = app.clone();
let label_for_cleanup = label.clone();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label);
origins.remove(&label_for_cleanup);
}
if let Ok(mut sessions) = app_handle.state::<AppState>().remote_proxy_sessions.lock() {
if let Some(session_id) = sessions.remove(&label_for_cleanup) {
schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id);
}
}
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
values.remove(&label_for_cleanup);
}
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
handlers.remove(&label_for_cleanup);
}
}
});
@@ -219,6 +367,40 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
Ok(())
}
#[tauri::command]
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
if payload.proxy_session_id.is_some() && parsed.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!(
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
)
})?;
if cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
})? {
let accepted = confirm_local_certificate_install(&app).await?;
if !accepted {
return Err(
"CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows."
.to_string(),
);
}
}
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
return Err(format!(
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
));
}
}
}
open_remote_window_impl(app, payload).await
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
@@ -346,6 +528,8 @@ fn set_windows_app_user_model_id() {
fn set_windows_app_user_model_id() {}
fn main() {
let _ = rustls::crypto::ring::default_provider().install_default();
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
@@ -373,6 +557,9 @@ fn main() {
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
remote_proxy_sessions: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
})
.setup(|app| {
set_windows_app_user_model_id();

View File

@@ -16,6 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { runtimeEnv } from "../lib/runtime-env"
import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -332,7 +333,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
})
if (openWindow) {
await openRemoteServerWindow(profile)
const remoteProxySession =
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
? await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify,
})
: undefined
try {
await openRemoteServerWindow(profile, remoteProxySession?.windowUrl, remoteProxySession?.sessionId)
} catch (error) {
if (remoteProxySession) {
void serverApi.deleteRemoteProxySession(remoteProxySession.sessionId).catch(() => {})
}
throw error
}
await markRemoteServerConnected(profile.id)
}

View File

@@ -12,6 +12,8 @@ import type {
SpeechTranscriptionResponse,
SideCar,
ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
@@ -256,6 +258,15 @@ export const serverApi = {
body: JSON.stringify(payload),
})
},
createRemoteProxySession(payload: RemoteProxySessionCreateRequest): Promise<RemoteProxySessionCreateResponse> {
return request<RemoteProxySessionCreateResponse>("/api/remote-proxy/sessions", {
method: "POST",
body: JSON.stringify(payload),
})
},
deleteRemoteProxySession(id: string): Promise<void> {
return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" })
},
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
},

View File

@@ -6,14 +6,22 @@ export interface RemoteWindowOpenPayload {
id: string
name: string
baseUrl: string
entryUrl?: string
proxySessionId?: string
skipTlsVerify: boolean
}
export async function openRemoteServerWindow(profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">): Promise<void> {
export async function openRemoteServerWindow(
profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">,
entryUrl?: string,
proxySessionId?: string,
): Promise<void> {
const payload: RemoteWindowOpenPayload = {
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
entryUrl,
proxySessionId,
skipTlsVerify: profile.skipTlsVerify,
}

View File

@@ -37,6 +37,8 @@ declare global {
id: string
name: string
baseUrl: string
entryUrl?: string
proxySessionId?: string
skipTlsVerify: boolean
}) => Promise<{ ok: boolean }>
}