Compare commits
11 Commits
no-more-no
...
feat/linux
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ddefc168 | ||
|
|
d456ae5837 | ||
|
|
3ec1598bbd | ||
|
|
ec3b418934 | ||
|
|
99b2066923 | ||
|
|
c2c88e956e | ||
|
|
aa69d2c1f1 | ||
|
|
e965754d4c | ||
|
|
efe5c455e0 | ||
|
|
be4f383602 | ||
|
|
adcaf3a116 |
1176
package-lock.json
generated
1176
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esbuild/darwin-arm64": "^0.28.0",
|
||||
"@rollup/rollup-darwin-arm64": "^4.60.2",
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,15 @@ export interface RemoteServerProbeResponse {
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionCreateRequest {
|
||||
baseUrl: string
|
||||
skipTlsVerify?: boolean
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionCreateResponse {
|
||||
windowUrl: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
|
||||
@@ -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"
|
||||
@@ -383,6 +384,11 @@ async function main() {
|
||||
|
||||
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 +428,7 @@ async function main() {
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
@@ -447,6 +454,7 @@ async function main() {
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
|
||||
@@ -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
|
||||
@@ -274,6 +277,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 })
|
||||
|
||||
533
packages/server/src/server/remote-proxy.ts
Normal file
533
packages/server/src/server/remote-proxy.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { Readable } from "stream"
|
||||
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
|
||||
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 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<string> {
|
||||
if (!this.options.httpsOptions) {
|
||||
throw new Error("Local HTTPS is required for remote proxy sessions")
|
||||
}
|
||||
|
||||
const targetBaseUrl = normalizeBaseUrl(baseUrl)
|
||||
const token = this.options.authManager.issueBootstrapToken()
|
||||
if (!token) {
|
||||
throw new Error("Bootstrap token generation is unavailable")
|
||||
}
|
||||
|
||||
const sessionId = randomUUID()
|
||||
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()
|
||||
app.addContentTypeParser("*", (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
|
||||
}
|
||||
|
||||
const body = parseTokenBody(request.body)
|
||||
if (!this.options.authManager.consumeBootstrapToken(body.token)) {
|
||||
reply.code(401).send({ error: "Invalid token" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||
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,
|
||||
targetBaseUrl,
|
||||
skipTlsVerify,
|
||||
localBaseUrl,
|
||||
entryUrl,
|
||||
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(token)}`,
|
||||
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 session.bootstrapUrl
|
||||
}
|
||||
|
||||
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) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId)
|
||||
session.dispatcher?.close().catch(() => {})
|
||||
await session.app.close().catch(() => {})
|
||||
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
|
||||
}
|
||||
}
|
||||
|
||||
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.send(Readable.fromWeb(response.body as any))
|
||||
} 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") {
|
||||
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") {
|
||||
return
|
||||
}
|
||||
|
||||
if (lower === "location") {
|
||||
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
|
||||
return
|
||||
}
|
||||
|
||||
reply.header(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
29
packages/server/src/server/routes/remote-proxy.ts
Normal file
29
packages/server/src/server/routes/remote-proxy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { RemoteProxySessionCreateResponse } from "../../api-types"
|
||||
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(),
|
||||
})
|
||||
|
||||
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 ?? {})
|
||||
const windowUrl = await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||
return { windowUrl }
|
||||
} 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" }
|
||||
}
|
||||
})
|
||||
}
|
||||
515
packages/tauri-app/Cargo.lock
generated
515
packages/tauri-app/Cargo.lock
generated
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -213,6 +222,105 @@ 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 = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"fs-err",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -408,6 +516,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -444,6 +554,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 +572,34 @@ 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",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"keepawake",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -477,7 +610,9 @@ dependencies = [
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"which",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -969,6 +1104,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 +1283,22 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[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 +1539,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 +1552,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 +1738,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"
|
||||
@@ -1689,6 +1872,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -1699,9 +1888,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1710,6 +1901,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 +2207,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 +2375,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"
|
||||
@@ -2217,6 +2441,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -2995,6 +3225,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 +3497,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 +3571,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3270,6 +3599,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 +3654,53 @@ 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-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -3502,6 +3892,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -3531,6 +3932,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 +4205,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 +4362,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -4367,6 +4786,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"
|
||||
@@ -4378,9 +4812,31 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -4512,6 +4968,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4550,6 +5007,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -4691,6 +5149,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 +5366,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 +5414,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 +5480,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 +5782,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 +6432,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"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
"@tauri-apps/cli": "^2.9.4",
|
||||
"@tauri-apps/cli-darwin-arm64": "^2.9.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,20 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
axum = "0.7"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
futures-util = "0.3"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
|
||||
rand = "0.8"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
@@ -28,4 +37,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"
|
||||
|
||||
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
248
packages/tauri-app/src-tauri/src/cert_manager.rs
Normal file
248
packages/tauri-app/src-tauri/src/cert_manager.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
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 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}"))
|
||||
}
|
||||
|
||||
/// Adds the DER-encoded CA certificate to the Windows `CurrentUser\Root` store.
|
||||
/// This will show a one-time Windows security confirmation dialog when needed.
|
||||
#[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 has_matching_trusted_marker(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(not(windows))]
|
||||
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}"))
|
||||
}
|
||||
88
packages/tauri-app/src-tauri/src/linux_tls.rs
Normal file
88
packages/tauri-app/src-tauri/src/linux_tls.rs
Normal 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, skip_tls_verify: bool) -> bool {
|
||||
skip_tls_verify && 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
|
||||
}
|
||||
@@ -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};
|
||||
@@ -45,6 +49,8 @@ pub struct AppState {
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub zoom_level: Mutex<f64>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
||||
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -53,6 +59,8 @@ struct RemoteWindowPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
base_url: String,
|
||||
entry_url: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
skip_tls_verify: bool,
|
||||
}
|
||||
|
||||
@@ -119,7 +127,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 +175,40 @@ 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();
|
||||
|
||||
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(), parsed.scheme() == "https");
|
||||
|
||||
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 +216,44 @@ 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, payload.skip_tls_verify)
|
||||
{
|
||||
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 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 +261,29 @@ 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 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 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 +411,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 +440,8 @@ fn main() {
|
||||
wake_lock: Mutex::new(None),
|
||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||
remote_origins: 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();
|
||||
|
||||
460
packages/tauri-app/src-tauri/src/remote_proxy.rs
Normal file
460
packages/tauri-app/src-tauri/src/remote_proxy.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
|
||||
use axum::response::Response;
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use futures_util::TryStreamExt;
|
||||
use rand::RngCore;
|
||||
use reqwest::redirect::Policy;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
const PROXY_TOKEN_QUERY: &str = "proxy_token";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProxyState {
|
||||
client: Client,
|
||||
target_base_url: Url,
|
||||
local_base_url: Url,
|
||||
session_token: String,
|
||||
session_activated: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// TLS configuration for the local HTTPS proxy.
|
||||
pub struct ProxyTlsConfig {
|
||||
pub cert_pem: String,
|
||||
pub key_pem: String,
|
||||
}
|
||||
|
||||
pub struct RemoteProxyHandle {
|
||||
local_base_url: Url,
|
||||
entry_url: Url,
|
||||
target_base_url: Url,
|
||||
skip_tls_verify: bool,
|
||||
server_handle: axum_server::Handle,
|
||||
}
|
||||
|
||||
impl RemoteProxyHandle {
|
||||
pub fn local_base_url(&self) -> &Url {
|
||||
&self.local_base_url
|
||||
}
|
||||
|
||||
pub fn entry_url(&self) -> &Url {
|
||||
&self.entry_url
|
||||
}
|
||||
|
||||
pub fn matches(&self, target_base_url: &Url, skip_tls_verify: bool) -> bool {
|
||||
self.target_base_url == *target_base_url && self.skip_tls_verify == skip_tls_verify
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.server_handle.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RemoteProxyHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_remote_proxy(
|
||||
target_base_url: Url,
|
||||
skip_tls_verify: bool,
|
||||
tls_config: Option<ProxyTlsConfig>,
|
||||
) -> Result<RemoteProxyHandle, String> {
|
||||
let client = Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.danger_accept_invalid_certs(skip_tls_verify)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Pre-bind a std TcpListener on port 0 to discover the actual port
|
||||
let std_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|err| err.to_string())?;
|
||||
let address = std_listener.local_addr().map_err(|err| err.to_string())?;
|
||||
|
||||
let scheme = if tls_config.is_some() { "https" } else { "http" };
|
||||
let local_base_url =
|
||||
Url::parse(&format!("{scheme}://{address}")).map_err(|err| err.to_string())?;
|
||||
let session_token = generate_session_token();
|
||||
let mut entry_url = local_base_url.clone();
|
||||
entry_url.set_path(target_base_url.path());
|
||||
entry_url.set_query(Some(&format!("{PROXY_TOKEN_QUERY}={session_token}")));
|
||||
|
||||
let state = Arc::new(ProxyState {
|
||||
client,
|
||||
target_base_url: target_base_url.clone(),
|
||||
local_base_url: local_base_url.clone(),
|
||||
session_token,
|
||||
session_activated: Arc::new(AtomicBool::new(false)),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/*path", any(proxy_request))
|
||||
.route("/", any(proxy_request))
|
||||
.with_state(state);
|
||||
|
||||
let server_handle = axum_server::Handle::new();
|
||||
let handle_clone = server_handle.clone();
|
||||
|
||||
if let Some(tls) = tls_config {
|
||||
let rustls_config =
|
||||
RustlsConfig::from_pem(tls.cert_pem.into_bytes(), tls.key_pem.into_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to build RustlsConfig: {err}"))?;
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum_server::from_tcp_rustls(std_listener, rustls_config)
|
||||
.handle(handle_clone)
|
||||
.serve(app.into_make_service());
|
||||
|
||||
if let Err(err) = server.await {
|
||||
eprintln!("[tauri] remote proxy (HTTPS) stopped with error: {err}");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum_server::from_tcp(std_listener)
|
||||
.handle(handle_clone)
|
||||
.serve(app.into_make_service());
|
||||
|
||||
if let Err(err) = server.await {
|
||||
eprintln!("[tauri] remote proxy (HTTP) stopped with error: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemoteProxyHandle {
|
||||
local_base_url,
|
||||
entry_url,
|
||||
target_base_url,
|
||||
skip_tls_verify,
|
||||
server_handle,
|
||||
})
|
||||
}
|
||||
|
||||
async fn proxy_request(
|
||||
State(state): State<Arc<ProxyState>>,
|
||||
request: Request,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
if !state.session_activated.load(Ordering::SeqCst) {
|
||||
if request_bootstraps_session(&request, &state.session_token) {
|
||||
state.session_activated.store(true, Ordering::SeqCst);
|
||||
return Ok(build_bootstrap_response(request.uri())?);
|
||||
}
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let upstream_url = build_upstream_url(&state.target_base_url, request.uri())
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut builder = state
|
||||
.client
|
||||
.request(request.method().clone(), upstream_url.clone());
|
||||
builder = builder.headers(filter_request_headers(
|
||||
request.headers(),
|
||||
&state.target_base_url,
|
||||
)?);
|
||||
|
||||
let body = axum::body::to_bytes(request.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
if !body.is_empty() {
|
||||
builder = builder.body(body);
|
||||
}
|
||||
|
||||
let upstream = builder.send().await.map_err(map_upstream_error)?;
|
||||
let status = upstream.status();
|
||||
let headers = rewrite_response_headers(
|
||||
upstream.headers(),
|
||||
&state.target_base_url,
|
||||
&state.local_base_url,
|
||||
)?;
|
||||
let stream = upstream
|
||||
.bytes_stream()
|
||||
.map_err(|err| std::io::Error::other(err.to_string()));
|
||||
|
||||
let mut response = Response::new(Body::from_stream(stream));
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn build_upstream_url(base_url: &Url, uri: &Uri) -> Result<Url, url::ParseError> {
|
||||
let mut url = base_url.clone();
|
||||
url.set_path(&rewrite_request_path(base_url, uri.path()));
|
||||
url.set_query(strip_proxy_token_query(uri.query()).as_deref());
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn rewrite_request_path(base_url: &Url, request_path: &str) -> String {
|
||||
let base_path = normalized_base_path(base_url);
|
||||
if base_path == "/" {
|
||||
return request_path.to_string();
|
||||
}
|
||||
|
||||
if request_path == "/" {
|
||||
return base_path.to_string();
|
||||
}
|
||||
|
||||
if path_has_base_prefix(base_path, request_path) {
|
||||
return request_path.to_string();
|
||||
}
|
||||
|
||||
format!("{base_path}{request_path}")
|
||||
}
|
||||
|
||||
fn normalized_base_path(base_url: &Url) -> &str {
|
||||
let path = base_url.path();
|
||||
if path.is_empty() { "/" } else { path }
|
||||
}
|
||||
|
||||
fn path_has_base_prefix(base_path: &str, request_path: &str) -> bool {
|
||||
request_path == base_path
|
||||
|| request_path
|
||||
.strip_prefix(base_path)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}
|
||||
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0_u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn request_bootstraps_session(request: &Request, session_token: &str) -> bool {
|
||||
request.uri().query().is_some_and(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.any(|(name, value)| name == PROXY_TOKEN_QUERY && value == session_token)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_bootstrap_response(uri: &Uri) -> Result<Response<Body>, StatusCode> {
|
||||
let redirect_target = sanitized_request_target(uri);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::FOUND)
|
||||
.header(axum::http::header::LOCATION, redirect_target)
|
||||
.body(Body::empty())
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
fn sanitized_request_target(uri: &Uri) -> String {
|
||||
let path = if uri.path().is_empty() { "/" } else { uri.path() };
|
||||
match strip_proxy_token_query(uri.query()) {
|
||||
Some(query) if !query.is_empty() => format!("{path}?{query}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_proxy_token_query(query: Option<&str>) -> Option<String> {
|
||||
let query = query?;
|
||||
let filtered: Vec<(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)> =
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.filter(|(name, _)| name != PROXY_TOKEN_QUERY)
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
url::form_urlencoded::Serializer::new(String::new())
|
||||
.extend_pairs(filtered)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn filter_request_headers(
|
||||
headers: &HeaderMap,
|
||||
target_base_url: &Url,
|
||||
) -> Result<HeaderMap, StatusCode> {
|
||||
let mut forwarded = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
if is_hop_by_hop_header(name) || *name == axum::http::header::HOST {
|
||||
continue;
|
||||
}
|
||||
forwarded.append(name.clone(), value.clone());
|
||||
}
|
||||
|
||||
let host = target_base_url.host_str().ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let host_value = match target_base_url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
forwarded.insert(
|
||||
axum::http::header::HOST,
|
||||
HeaderValue::from_str(&host_value).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
|
||||
let target_origin = target_base_url.origin().ascii_serialization();
|
||||
if let Ok(origin) = HeaderValue::from_str(&target_origin) {
|
||||
forwarded.insert(axum::http::header::ORIGIN, origin);
|
||||
}
|
||||
|
||||
if let Some(referer) = rewrite_referer_header(headers, target_base_url) {
|
||||
forwarded.insert(
|
||||
axum::http::header::REFERER,
|
||||
HeaderValue::from_str(&referer).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(forwarded)
|
||||
}
|
||||
|
||||
fn rewrite_referer_header(headers: &HeaderMap, target_base_url: &Url) -> Option<String> {
|
||||
let referer = headers.get(axum::http::header::REFERER)?.to_str().ok()?;
|
||||
let parsed = Url::parse(referer).ok()?;
|
||||
|
||||
let mut rewritten = target_base_url.clone();
|
||||
rewritten.set_path(&rewrite_request_path(target_base_url, parsed.path()));
|
||||
rewritten.set_query(parsed.query());
|
||||
rewritten.set_fragment(parsed.fragment());
|
||||
Some(rewritten.to_string())
|
||||
}
|
||||
|
||||
fn rewrite_response_headers(
|
||||
headers: &HeaderMap,
|
||||
target_base_url: &Url,
|
||||
local_base_url: &Url,
|
||||
) -> Result<HeaderMap, StatusCode> {
|
||||
let mut rewritten = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
if is_hop_by_hop_header(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if *name == axum::http::header::LOCATION {
|
||||
if let Ok(location) = value.to_str() {
|
||||
let next = rewrite_location(location, target_base_url, local_base_url);
|
||||
rewritten.append(
|
||||
name.clone(),
|
||||
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if *name == axum::http::header::SET_COOKIE {
|
||||
if let Ok(cookie) = value.to_str() {
|
||||
let next = rewrite_set_cookie(cookie);
|
||||
rewritten.append(
|
||||
name.clone(),
|
||||
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
rewritten.append(name.clone(), value.clone());
|
||||
}
|
||||
Ok(rewritten)
|
||||
}
|
||||
|
||||
fn rewrite_set_cookie(cookie: &str) -> String {
|
||||
cookie
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("Domain=")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn rewrite_location(location: &str, target_base_url: &Url, local_base_url: &Url) -> String {
|
||||
let Ok(parsed) = target_base_url.join(location) else {
|
||||
return location.to_string();
|
||||
};
|
||||
|
||||
if parsed.origin() != target_base_url.origin() {
|
||||
return location.to_string();
|
||||
}
|
||||
|
||||
let mut rewritten = local_base_url.clone();
|
||||
rewritten.set_path(parsed.path());
|
||||
rewritten.set_query(parsed.query());
|
||||
rewritten.set_fragment(parsed.fragment());
|
||||
rewritten.to_string()
|
||||
}
|
||||
|
||||
fn map_upstream_error(error: reqwest::Error) -> StatusCode {
|
||||
if error.is_timeout() {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else if error.is_connect() {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hop_by_hop_header(name: &HeaderName) -> bool {
|
||||
static HOP_BY_HOP: std::sync::OnceLock<HashSet<&'static str>> = std::sync::OnceLock::new();
|
||||
HOP_BY_HOP
|
||||
.get_or_init(|| {
|
||||
HashSet::from([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
])
|
||||
})
|
||||
.contains(name.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_prefixes_root_relative_requests_under_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/api/auth/status?foo=bar".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_keeps_requests_already_under_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/app/api/auth/status?foo=bar".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_maps_root_to_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_referer_header_prefixes_root_relative_path_under_base_path() {
|
||||
let target = Url::parse("https://example.com/app").unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::REFERER,
|
||||
HeaderValue::from_static("https://127.0.0.1:3000/api/auth/status?foo=bar"),
|
||||
);
|
||||
|
||||
let referer = rewrite_referer_header(&headers, &target).unwrap();
|
||||
|
||||
assert_eq!(referer, "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
}
|
||||
@@ -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,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
})
|
||||
|
||||
if (openWindow) {
|
||||
await openRemoteServerWindow(profile)
|
||||
const windowUrl =
|
||||
runtimeEnv.host === "tauri"
|
||||
? (await serverApi.createRemoteProxySession({
|
||||
baseUrl: profile.baseUrl,
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
})).windowUrl
|
||||
: undefined
|
||||
|
||||
await openRemoteServerWindow(profile, windowUrl)
|
||||
await markRemoteServerConnected(profile.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
SpeechTranscriptionResponse,
|
||||
SideCar,
|
||||
ServerMeta,
|
||||
RemoteProxySessionCreateRequest,
|
||||
RemoteProxySessionCreateResponse,
|
||||
RemoteServerProbeRequest,
|
||||
RemoteServerProbeResponse,
|
||||
VoiceModeStateResponse,
|
||||
@@ -256,6 +258,12 @@ 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),
|
||||
})
|
||||
},
|
||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||
},
|
||||
|
||||
@@ -6,14 +6,19 @@ export interface RemoteWindowOpenPayload {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
entryUrl?: 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,
|
||||
): Promise<void> {
|
||||
const payload: RemoteWindowOpenPayload = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
entryUrl,
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
}
|
||||
|
||||
|
||||
1
packages/ui/src/types/global.d.ts
vendored
1
packages/ui/src/types/global.d.ts
vendored
@@ -37,6 +37,7 @@ declare global {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
entryUrl?: string
|
||||
skipTlsVerify: boolean
|
||||
}) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user