refactor(desktop): move Tauri remote proxy into packages/server
This commit is contained in:
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"
|
"google-auth-library": "^10.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@esbuild/darwin-arm64": "^0.28.0",
|
||||||
|
"@rollup/rollup-darwin-arm64": "^4.60.2",
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,6 +337,15 @@ export interface RemoteServerProbeResponse {
|
|||||||
errorCode?: string
|
errorCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionCreateRequest {
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionCreateResponse {
|
||||||
|
windowUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { launchInBrowser } from "./launcher"
|
|||||||
import { resolveUi } from "./ui/remote-ui"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
|
import { RemoteProxySessionManager } from "./server/remote-proxy"
|
||||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
import { SpeechService } from "./speech/service"
|
||||||
@@ -383,6 +384,11 @@ async function main() {
|
|||||||
|
|
||||||
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||||
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
|
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({
|
const voiceModeManager = new VoiceModeManager({
|
||||||
connections: clientConnectionManager,
|
connections: clientConnectionManager,
|
||||||
channel: pluginChannel,
|
channel: pluginChannel,
|
||||||
@@ -422,6 +428,7 @@ async function main() {
|
|||||||
clientConnectionManager,
|
clientConnectionManager,
|
||||||
pluginChannel,
|
pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager,
|
||||||
|
remoteProxySessionManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
@@ -447,6 +454,7 @@ async function main() {
|
|||||||
clientConnectionManager,
|
clientConnectionManager,
|
||||||
pluginChannel,
|
pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager,
|
||||||
|
remoteProxySessionManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
|||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||||
|
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
|
||||||
import { registerSideCarRoutes } from "./routes/sidecars"
|
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
@@ -38,6 +39,7 @@ import { ClientConnectionManager } from "../clients/connection-manager"
|
|||||||
import { PluginChannelManager } from "../plugins/channel"
|
import { PluginChannelManager } from "../plugins/channel"
|
||||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||||
import type { SideCarManager } from "../sidecars/manager"
|
import type { SideCarManager } from "../sidecars/manager"
|
||||||
|
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -58,6 +60,7 @@ interface HttpServerDeps {
|
|||||||
clientConnectionManager: ClientConnectionManager
|
clientConnectionManager: ClientConnectionManager
|
||||||
pluginChannel: PluginChannelManager
|
pluginChannel: PluginChannelManager
|
||||||
voiceModeManager: VoiceModeManager
|
voiceModeManager: VoiceModeManager
|
||||||
|
remoteProxySessionManager: RemoteProxySessionManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -274,6 +277,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||||
|
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||||
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
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" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4",
|
||||||
|
"@tauri-apps/cli-darwin-arm64": "^2.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
#[allow(dead_code)]
|
||||||
mod cert_manager;
|
mod cert_manager;
|
||||||
mod cli_manager;
|
mod cli_manager;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux_tls;
|
mod linux_tls;
|
||||||
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
|
||||||
mod remote_proxy;
|
|
||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
use keepawake::KeepAwake;
|
use keepawake::KeepAwake;
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
use remote_proxy::start_remote_proxy;
|
|
||||||
use remote_proxy::{ProxyTlsConfig, RemoteProxyHandle};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -56,7 +51,6 @@ pub struct AppState {
|
|||||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||||
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
||||||
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
||||||
pub remote_proxies: Mutex<HashMap<String, RemoteProxyHandle>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -65,6 +59,8 @@ struct RemoteWindowPayload {
|
|||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
|
entry_url: Option<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
skip_tls_verify: bool,
|
skip_tls_verify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,49 +178,21 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
async fn open_remote_window_impl(
|
async fn open_remote_window_impl(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
payload: RemoteWindowPayload,
|
payload: RemoteWindowPayload,
|
||||||
_tls_config: Option<ProxyTlsConfig>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_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 label = format!("remote-{}", payload.id);
|
||||||
let title = format!(
|
let title = format!(
|
||||||
"{} - {}",
|
"{} - {}",
|
||||||
payload.name,
|
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())
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let window_url = parsed.clone();
|
let window_url = parsed.clone();
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
let window_url = {
|
|
||||||
let state = app.state::<AppState>();
|
|
||||||
let reuses_existing_proxy = {
|
|
||||||
let proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
|
|
||||||
proxies
|
|
||||||
.get(&label)
|
|
||||||
.map(|existing| existing.matches(&parsed, payload.skip_tls_verify))
|
|
||||||
.unwrap_or(false)
|
|
||||||
};
|
|
||||||
|
|
||||||
if reuses_existing_proxy {
|
|
||||||
let proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
|
|
||||||
proxies
|
|
||||||
.get(&label)
|
|
||||||
.map(|handle| handle.entry_url().clone())
|
|
||||||
.ok_or_else(|| "Remote proxy disappeared before reuse".to_string())?
|
|
||||||
} else {
|
|
||||||
let new_proxy =
|
|
||||||
start_remote_proxy(parsed.clone(), payload.skip_tls_verify, _tls_config).await?;
|
|
||||||
let local_url = new_proxy.entry_url().clone();
|
|
||||||
let mut proxies = state.remote_proxies.lock().map_err(|err| err.to_string())?;
|
|
||||||
if let Some(existing) = proxies.remove(&label) {
|
|
||||||
existing.shutdown();
|
|
||||||
}
|
|
||||||
proxies.insert(label.clone(), new_proxy);
|
|
||||||
local_url
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
app.state::<AppState>()
|
app.state::<AppState>()
|
||||||
.remote_origins
|
.remote_origins
|
||||||
.lock()
|
.lock()
|
||||||
@@ -234,7 +202,7 @@ async fn open_remote_window_impl(
|
|||||||
.remote_skip_tls_verify
|
.remote_skip_tls_verify
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|err| err.to_string())?
|
.map_err(|err| err.to_string())?
|
||||||
.insert(label.clone(), payload.skip_tls_verify);
|
.insert(label.clone(), parsed.scheme() == "https");
|
||||||
|
|
||||||
if let Some(existing) = app.get_webview_window(&label) {
|
if let Some(existing) = app.get_webview_window(&label) {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -287,11 +255,6 @@ async fn open_remote_window_impl(
|
|||||||
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
|
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
|
||||||
handlers.remove(&label_for_cleanup);
|
handlers.remove(&label_for_cleanup);
|
||||||
}
|
}
|
||||||
if let Ok(mut proxies) = app_handle.state::<AppState>().remote_proxies.lock() {
|
|
||||||
if let Some(handle) = proxies.remove(&label_for_cleanup) {
|
|
||||||
handle.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,33 +263,25 @@ async fn open_remote_window_impl(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
return open_remote_window_impl(app, payload, None).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let tls_config = match cert_manager::ensure_local_cert() {
|
{
|
||||||
Ok(local_cert) => {
|
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) {
|
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some(ProxyTlsConfig {
|
|
||||||
cert_pem: local_cert.cert_pem,
|
|
||||||
key_pem: local_cert.key_pem,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
|
||||||
return Err(format!(
|
|
||||||
"Failed to create the local HTTPS proxy certificate. This remote HTTPS connection cannot fall back to HTTP because secure cookies would break: {err}"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
open_remote_window_impl(app, payload).await
|
||||||
open_remote_window_impl(app, payload, tls_config).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
@@ -487,7 +442,6 @@ fn main() {
|
|||||||
remote_origins: Mutex::new(HashMap::new()),
|
remote_origins: Mutex::new(HashMap::new()),
|
||||||
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
||||||
remote_tls_handlers: Mutex::new(HashSet::new()),
|
remote_tls_handlers: Mutex::new(HashSet::new()),
|
||||||
remote_proxies: Mutex::new(HashMap::new()),
|
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
set_windows_app_user_model_id();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
import { openExternalUrl } from "../lib/external-url"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { runtimeEnv } from "../lib/runtime-env"
|
||||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
@@ -332,7 +333,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (openWindow) {
|
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)
|
await markRemoteServerConnected(profile.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
SpeechTranscriptionResponse,
|
SpeechTranscriptionResponse,
|
||||||
SideCar,
|
SideCar,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
|
RemoteProxySessionCreateRequest,
|
||||||
|
RemoteProxySessionCreateResponse,
|
||||||
RemoteServerProbeRequest,
|
RemoteServerProbeRequest,
|
||||||
RemoteServerProbeResponse,
|
RemoteServerProbeResponse,
|
||||||
VoiceModeStateResponse,
|
VoiceModeStateResponse,
|
||||||
@@ -256,6 +258,12 @@ export const serverApi = {
|
|||||||
body: JSON.stringify(payload),
|
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 }> {
|
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ export interface RemoteWindowOpenPayload {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
entryUrl?: string
|
||||||
skipTlsVerify: boolean
|
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 = {
|
const payload: RemoteWindowOpenPayload = {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
baseUrl: profile.baseUrl,
|
baseUrl: profile.baseUrl,
|
||||||
|
entryUrl,
|
||||||
skipTlsVerify: profile.skipTlsVerify,
|
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
|
id: string
|
||||||
name: string
|
name: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
entryUrl?: string
|
||||||
skipTlsVerify: boolean
|
skipTlsVerify: boolean
|
||||||
}) => Promise<{ ok: boolean }>
|
}) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user