feat(server): add HTTPS with self-signed certs
Default to HTTPS with optional loopback HTTP, generate/rotate self-signed certs via node-forge, and surface Local/Remote connection URLs. Update /api/meta schema, UI remote access overlay, and desktop shells to follow the new startup output.
This commit is contained in:
21
package-lock.json
generated
21
package-lock.json
generated
@@ -3453,6 +3453,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/plist": {
|
||||
"version": "3.0.5",
|
||||
"dev": true,
|
||||
@@ -8059,6 +8069,15 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"dev": true,
|
||||
@@ -11980,6 +11999,7 @@
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yauzl": "^2.10.0",
|
||||
@@ -11989,6 +12009,7 @@
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -347,38 +347,27 @@ export class CliProcessManager extends EventEmitter {
|
||||
console.info(`[cli][${stream}] ${trimmed}`)
|
||||
this.emit("log", { stream, message: trimmed })
|
||||
|
||||
const port = this.extractPort(trimmed)
|
||||
if (port && this.status.state === "starting") {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
console.info(`[cli] ready on ${url}`)
|
||||
this.updateStatus({ state: "ready", port, url })
|
||||
const localUrl = this.extractLocalUrl(trimmed)
|
||||
if (localUrl && this.status.state === "starting") {
|
||||
let port: number | undefined
|
||||
try {
|
||||
port = Number(new URL(localUrl).port) || undefined
|
||||
} catch {
|
||||
port = undefined
|
||||
}
|
||||
console.info(`[cli] ready on ${localUrl}`)
|
||||
this.updateStatus({ state: "ready", port, url: localUrl })
|
||||
this.emit("ready", this.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractPort(line: string): number | null {
|
||||
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
||||
if (readyMatch) {
|
||||
return parseInt(readyMatch[1], 10)
|
||||
private extractLocalUrl(line: string): string | null {
|
||||
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (line.toLowerCase().includes("http server listening")) {
|
||||
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
|
||||
if (httpMatch) {
|
||||
return parseInt(httpMatch[1], 10)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (typeof parsed.port === "number") {
|
||||
return parsed.port
|
||||
}
|
||||
} catch {
|
||||
// not JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return match[1] ?? null
|
||||
}
|
||||
|
||||
private updateStatus(patch: Partial<CliStatus>) {
|
||||
@@ -387,7 +376,15 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
|
||||
const args = ["serve", "--host", host, "--generate-token"]
|
||||
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
args.push("--https", "false", "--http", "true")
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https", "true", "--http", "true")
|
||||
}
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
|
||||
@@ -31,6 +31,11 @@ You can run CodeNomad directly without installing it:
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
On startup, CodeNomad prints two URLs:
|
||||
|
||||
- `Local Connection URL : ...` (used by desktop shells)
|
||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||
|
||||
### Install Globally
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
@@ -44,7 +49,14 @@ You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
|------|--------------|-------------|
|
||||
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
|
||||
| `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
|
||||
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
|
||||
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
|
||||
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
|
||||
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
|
||||
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
|
||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||
@@ -56,6 +68,42 @@ You can configure the server using flags or environment variables:
|
||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||
|
||||
### HTTP vs HTTPS
|
||||
|
||||
- Default: `--https=true --http=false` (HTTPS only).
|
||||
- To run plain HTTP only (useful for development):
|
||||
|
||||
```sh
|
||||
codenomad --https=false --http=true
|
||||
```
|
||||
|
||||
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
|
||||
|
||||
```sh
|
||||
codenomad --https=true --http=true
|
||||
```
|
||||
|
||||
### Remote Access Binding Rules
|
||||
|
||||
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
|
||||
- HTTP listens on `127.0.0.1` only.
|
||||
- HTTPS listens on `--host` (LAN/all interfaces).
|
||||
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
|
||||
- Both HTTP and HTTPS listen on `127.0.0.1`.
|
||||
|
||||
### Self-Signed Certificates
|
||||
|
||||
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
|
||||
|
||||
- `~/.config/codenomad/tls/ca-cert.pem`
|
||||
- `~/.config/codenomad/tls/server-cert.pem`
|
||||
|
||||
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
|
||||
|
||||
```sh
|
||||
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||
@@ -71,8 +119,7 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
||||
|
||||
> **TLS requirement**
|
||||
> Browsers require a secure (`https://`) connection for PWA installation.
|
||||
> If you host CodeNomad on a remote machine, serve it behind a reverse proxy (e.g. Caddy, nginx) with a valid TLS certificate.
|
||||
> Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
|
||||
### Data Storage
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -31,12 +31,14 @@
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -236,7 +236,8 @@ export interface NetworkAddress {
|
||||
ip: string
|
||||
family: "ipv4" | "ipv6"
|
||||
scope: "external" | "internal" | "loopback"
|
||||
url: string
|
||||
/** Remote URL using the server's remote protocol/port for this IP. */
|
||||
remoteUrl: string
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
@@ -262,16 +263,20 @@ export interface SupportMeta {
|
||||
}
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
/** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
|
||||
localUrl: string
|
||||
/** URL remote clients should use (prefers HTTPS when enabled). */
|
||||
remoteUrl?: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||
host: string
|
||||
/** Listening mode derived from host binding. */
|
||||
listeningMode: "local" | "all"
|
||||
/** Actual port in use after binding. */
|
||||
port: number
|
||||
/** Actual local port in use after binding. */
|
||||
localPort: number
|
||||
/** Actual remote port in use after binding (when remoteUrl is set). */
|
||||
remotePort?: number
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
|
||||
@@ -119,10 +119,18 @@ export class AuthManager {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||
}
|
||||
|
||||
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
|
||||
}
|
||||
|
||||
clearSessionCookie(reply: FastifyReply) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||
}
|
||||
|
||||
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
|
||||
}
|
||||
|
||||
private requireAuthStore(): AuthStore {
|
||||
if (!this.authStore) {
|
||||
throw new Error("Auth store is unavailable")
|
||||
@@ -143,8 +151,11 @@ function resolvePath(filePath: string) {
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||
if (options?.secure) {
|
||||
parts.push("Secure")
|
||||
}
|
||||
if (options?.maxAgeSeconds !== undefined) {
|
||||
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -28,8 +30,15 @@ const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
|
||||
interface CliOptions {
|
||||
port: number
|
||||
host: string
|
||||
https: boolean
|
||||
http: boolean
|
||||
httpsPort: number
|
||||
httpPort: number
|
||||
tlsKeyPath?: string
|
||||
tlsCertPath?: string
|
||||
tlsCaPath?: string
|
||||
tlsSANs?: string
|
||||
rootDir: string
|
||||
configPath: string
|
||||
unrestrictedRoot: boolean
|
||||
@@ -47,9 +56,10 @@ interface CliOptions {
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
const DEFAULT_HOST = "127.0.0.1"
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
const DEFAULT_HTTPS_PORT = 9898
|
||||
const DEFAULT_HTTP_PORT = 9899
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
const program = new Command()
|
||||
@@ -57,7 +67,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.description("CodeNomad CLI server")
|
||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--https <enabled>", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true"))
|
||||
.addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
|
||||
.addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
|
||||
.addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
|
||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||
.addOption(
|
||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
)
|
||||
@@ -97,7 +114,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
host: string
|
||||
port: number
|
||||
https?: string
|
||||
http?: string
|
||||
httpsPort: number
|
||||
httpPort: number
|
||||
tlsKey?: string
|
||||
tlsCert?: string
|
||||
tlsCa?: string
|
||||
tlsSANs?: string
|
||||
workspaceRoot?: string
|
||||
root?: string
|
||||
unrestrictedRoot?: boolean
|
||||
@@ -128,9 +152,23 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
||||
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
||||
|
||||
const httpsEnabled = parseBooleanEnv(parsed.https)
|
||||
const httpEnabled = parseBooleanEnv(parsed.http)
|
||||
|
||||
if (!httpsEnabled && !httpEnabled) {
|
||||
throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)")
|
||||
}
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: normalizedHost,
|
||||
https: httpsEnabled,
|
||||
http: httpEnabled,
|
||||
httpsPort: parsed.httpsPort,
|
||||
httpPort: parsed.httpPort,
|
||||
tlsKeyPath: parsed.tlsKey,
|
||||
tlsCertPath: parsed.tlsCert,
|
||||
tlsCaPath: parsed.tlsCa,
|
||||
tlsSANs: parsed.tlsSANs,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
@@ -172,6 +210,13 @@ function resolveHost(input: string | undefined): string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function programHasArg(argv: string[], flag: string): boolean {
|
||||
return argv.includes(flag)
|
||||
}
|
||||
@@ -200,12 +245,20 @@ async function main() {
|
||||
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const configDir = path.dirname(resolvePath(options.configPath))
|
||||
|
||||
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
||||
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
||||
}
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
localUrl: "http://localhost:0",
|
||||
remoteUrl: undefined,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||
port: options.port,
|
||||
localPort: 0,
|
||||
remotePort: undefined,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
addresses: [],
|
||||
@@ -229,6 +282,19 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const tlsResolution = resolveHttpsOptions({
|
||||
enabled: options.https,
|
||||
configDir,
|
||||
host: options.host,
|
||||
tlsKeyPath: options.tlsKeyPath,
|
||||
tlsCertPath: options.tlsCertPath,
|
||||
tlsCaPath: options.tlsCaPath,
|
||||
tlsSANs: options.tlsSANs,
|
||||
logger: logger.child({ component: "tls" }),
|
||||
})
|
||||
|
||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
@@ -237,7 +303,8 @@ async function main() {
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.httpBaseUrl,
|
||||
getServerBaseUrl: () => serverMeta.localUrl,
|
||||
nodeExtraCaCertsPath,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
@@ -277,28 +344,121 @@ async function main() {
|
||||
minServerVersion: uiResolution.minServerVersion,
|
||||
}
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
if (uiResolution.uiDevServerUrl && options.https) {
|
||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
||||
}
|
||||
|
||||
const startInfo = await server.start()
|
||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
|
||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||
|
||||
const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0
|
||||
const httpBindPort = httpPortExplicit ? options.httpPort : 0
|
||||
|
||||
// Listener binding rules:
|
||||
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
|
||||
// - Remote access disabled: both listen on loopback.
|
||||
// - HTTP-only mode: respect --host (used for dev/testing).
|
||||
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
|
||||
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"
|
||||
|
||||
const servers: Array<ReturnType<typeof createHttpServer>> = []
|
||||
|
||||
const httpServer = options.http
|
||||
? createHttpServer({
|
||||
bindHost: httpBindHost,
|
||||
bindPort: httpBindPort,
|
||||
defaultPort: options.httpPort,
|
||||
protocol: "http",
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
: null
|
||||
|
||||
const httpsServer = options.https
|
||||
? createHttpServer({
|
||||
bindHost: httpsBindHost,
|
||||
bindPort: httpsBindPort,
|
||||
defaultPort: options.httpsPort,
|
||||
protocol: "https",
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
})
|
||||
: null
|
||||
|
||||
if (httpServer) servers.push(httpServer)
|
||||
if (httpsServer) servers.push(httpsServer)
|
||||
|
||||
const [httpStart, httpsStart] = await Promise.all([
|
||||
httpServer ? httpServer.start() : Promise.resolve(null),
|
||||
httpsServer ? httpsServer.start() : Promise.resolve(null),
|
||||
])
|
||||
|
||||
const localStart = httpStart ?? httpsStart
|
||||
if (!localStart) {
|
||||
throw new Error("No listeners started")
|
||||
}
|
||||
|
||||
const remoteStart = httpsStart ?? httpStart
|
||||
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
||||
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
||||
|
||||
const localUrl = `${localProtocol}://localhost:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
serverMeta.localPort = localStart.port
|
||||
serverMeta.remoteUrl = remoteUrl
|
||||
serverMeta.remotePort = remoteStart?.port
|
||||
serverMeta.host = options.host
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (serverMeta.remoteUrl) {
|
||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||
}
|
||||
|
||||
if (options.launch) {
|
||||
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
||||
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
@@ -328,8 +488,8 @@ async function main() {
|
||||
|
||||
const shutdownHttp = (async () => {
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
await Promise.allSettled(servers.map((srv) => srv.stop()))
|
||||
logger.info("HTTP server(s) stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
|
||||
@@ -30,8 +30,12 @@ import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
port: number
|
||||
bindHost: string
|
||||
bindPort: number
|
||||
/** When bindPort is 0, try this first. */
|
||||
defaultPort: number
|
||||
protocol: "http" | "https"
|
||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
workspaceManager: WorkspaceManager
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
@@ -51,10 +55,15 @@ interface HttpServerStartResult {
|
||||
displayHost: string
|
||||
}
|
||||
|
||||
const DEFAULT_HTTP_PORT = 9898
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
|
||||
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
|
||||
const app = Fastify(
|
||||
({
|
||||
logger: false,
|
||||
...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
|
||||
} as unknown) as any,
|
||||
) as unknown as FastifyInstance
|
||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||
const apiLogger = deps.logger.child({ component: "http" })
|
||||
const sseLogger = deps.logger.child({ component: "sse" })
|
||||
@@ -97,6 +106,27 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const getSelfOrigins = (): Set<string> => {
|
||||
const origins = new Set<string>()
|
||||
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
try {
|
||||
origins.add(new URL(candidate).origin)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
for (const addr of deps.serverMeta.addresses ?? []) {
|
||||
try {
|
||||
origins.add(new URL(addr.remoteUrl).origin)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
app.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
if (!origin) {
|
||||
@@ -104,14 +134,8 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
let selfOrigin: string | null = null
|
||||
try {
|
||||
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
|
||||
} catch {
|
||||
selfOrigin = null
|
||||
}
|
||||
|
||||
if (selfOrigin && origin === selfOrigin) {
|
||||
const selfOrigins = getSelfOrigins()
|
||||
if (selfOrigins.has(origin)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
@@ -122,7 +146,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
|
||||
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
|
||||
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
@@ -245,12 +269,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
instance: app,
|
||||
start: async (): Promise<HttpServerStartResult> => {
|
||||
const attemptListen = async (requestedPort: number) => {
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
|
||||
return { addressInfo, requestedPort }
|
||||
}
|
||||
|
||||
const autoPortRequested = deps.port === 0
|
||||
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
||||
const autoPortRequested = deps.bindPort === 0
|
||||
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
|
||||
|
||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||
if (!autoPortRequested) return false
|
||||
@@ -286,15 +310,10 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
|
||||
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.serverMeta.host = deps.host
|
||||
deps.serverMeta.port = actualPort
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
|
||||
|
||||
return { port: actualPort, url: serverUrl, displayHost }
|
||||
},
|
||||
|
||||
75
packages/server/src/server/network-addresses.ts
Normal file
75
packages/server/src/server/network-addresses.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
port: number
|
||||
}): NetworkAddress[] {
|
||||
const { host, protocol, port } = args
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
})
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const session = deps.authManager.createSession(body.username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -112,12 +112,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
const username = deps.authManager.getStatus().username
|
||||
const session = deps.authManager.createSession(username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/logout", async (_request, reply) => {
|
||||
deps.authManager.clearSessionCookie(reply)
|
||||
app.post("/api/auth/logout", async (request, reply) => {
|
||||
deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -139,6 +139,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
function isSecureRequest(request: any) {
|
||||
if (request.protocol === "https") {
|
||||
return true
|
||||
}
|
||||
return Boolean(request.raw?.socket && request.raw.socket.encrypted)
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"]/g, (char) => {
|
||||
switch (char) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import os from "os"
|
||||
import { NetworkAddress, ServerMeta } from "../../api-types"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import { resolveNetworkAddresses } from "../network-addresses"
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
@@ -11,23 +11,25 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const port = resolvePort(meta)
|
||||
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
||||
const localPort = resolveLocalPort(meta)
|
||||
const remote = resolveRemote(meta)
|
||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
port,
|
||||
localPort,
|
||||
remotePort: remote?.port,
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.port) && meta.port > 0) {
|
||||
return meta.port
|
||||
function resolveLocalPort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.localPort) && meta.localPort > 0) {
|
||||
return meta.localPort
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.httpBaseUrl)
|
||||
const parsed = new URL(meta.localUrl)
|
||||
const port = Number(parsed.port)
|
||||
return Number.isInteger(port) && port > 0 ? port : 0
|
||||
} catch {
|
||||
@@ -35,74 +37,22 @@ function resolvePort(meta: ServerMeta): number {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null {
|
||||
if (!meta.remoteUrl) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.remoteUrl)
|
||||
const protocol = parsed.protocol === "https:" ? "https" : "http"
|
||||
const port = Number(parsed.port)
|
||||
return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
}
|
||||
|
||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
})
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
// NetworkAddress shape is resolved in ../network-addresses
|
||||
|
||||
283
packages/server/src/server/tls.ts
Normal file
283
packages/server/src/server/tls.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import crypto from "crypto"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
type Forge = typeof import("node-forge")
|
||||
|
||||
function loadForge(): Forge {
|
||||
// node-forge is CJS in many installs; require keeps this compatible with our ESM output.
|
||||
return require("node-forge") as Forge
|
||||
}
|
||||
|
||||
export interface ResolvedHttpsOptions {
|
||||
httpsOptions: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
/** Path to CA certificate suitable for NODE_EXTRA_CA_CERTS. */
|
||||
caCertPath?: string
|
||||
mode: "provided" | "generated"
|
||||
}
|
||||
|
||||
export interface ResolveHttpsOptionsArgs {
|
||||
enabled: boolean
|
||||
configDir: string
|
||||
host: string
|
||||
tlsKeyPath?: string
|
||||
tlsCertPath?: string
|
||||
tlsCaPath?: string
|
||||
tlsSANs?: string
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const LEAF_VALIDITY_DAYS = 30
|
||||
const ROTATE_IF_EXPIRES_WITHIN_DAYS = 3
|
||||
|
||||
const CA_VALIDITY_DAYS = 365
|
||||
|
||||
export function resolveHttpsOptions(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions | null {
|
||||
if (!args.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasProvided = Boolean(args.tlsKeyPath && args.tlsCertPath)
|
||||
if (hasProvided) {
|
||||
const key = fs.readFileSync(args.tlsKeyPath!, "utf-8")
|
||||
const cert = fs.readFileSync(args.tlsCertPath!, "utf-8")
|
||||
const ca = args.tlsCaPath ? fs.readFileSync(args.tlsCaPath, "utf-8") : undefined
|
||||
return {
|
||||
httpsOptions: { key, cert, ca },
|
||||
caCertPath: args.tlsCaPath,
|
||||
mode: "provided",
|
||||
}
|
||||
}
|
||||
|
||||
return ensureGeneratedTls(args)
|
||||
}
|
||||
|
||||
function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions {
|
||||
const tlsDir = path.join(args.configDir, "tls")
|
||||
const caKeyPath = path.join(tlsDir, "ca-key.pem")
|
||||
const caCertPath = path.join(tlsDir, "ca-cert.pem")
|
||||
const keyPath = path.join(tlsDir, "server-key.pem")
|
||||
const certPath = path.join(tlsDir, "server-cert.pem")
|
||||
|
||||
fs.mkdirSync(tlsDir, { recursive: true })
|
||||
|
||||
const shouldRotateLeaf = () => {
|
||||
try {
|
||||
if (!fs.existsSync(certPath)) return true
|
||||
const pem = fs.readFileSync(certPath, "utf-8")
|
||||
const x509 = new crypto.X509Certificate(pem)
|
||||
const validToMs = Date.parse(x509.validTo)
|
||||
if (!Number.isFinite(validToMs)) return true
|
||||
const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000
|
||||
return Date.now() >= rotateAt
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRotateCa = () => {
|
||||
try {
|
||||
if (!fs.existsSync(caCertPath)) return true
|
||||
const pem = fs.readFileSync(caCertPath, "utf-8")
|
||||
const x509 = new crypto.X509Certificate(pem)
|
||||
const validToMs = Date.parse(x509.validTo)
|
||||
if (!Number.isFinite(validToMs)) return true
|
||||
// CA rotates only when expired.
|
||||
return Date.now() >= validToMs
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRotateCa() || !fs.existsSync(caKeyPath)) {
|
||||
const { caKeyPem, caCertPem } = generateCaCertificate()
|
||||
writePemFile(caKeyPath, caKeyPem, 0o600)
|
||||
writePemFile(caCertPath, caCertPem, 0o644)
|
||||
args.logger.info({ caCertPath }, "Generated self-signed CodeNomad CA certificate")
|
||||
}
|
||||
|
||||
if (shouldRotateLeaf() || !fs.existsSync(keyPath)) {
|
||||
const caKeyPem = fs.readFileSync(caKeyPath, "utf-8")
|
||||
const caCertPem = fs.readFileSync(caCertPath, "utf-8")
|
||||
|
||||
const { keyPem, certPem } = generateServerCertificate({
|
||||
host: args.host,
|
||||
tlsSANs: args.tlsSANs,
|
||||
caKeyPem,
|
||||
caCertPem,
|
||||
})
|
||||
|
||||
writePemFile(keyPath, keyPem, 0o600)
|
||||
writePemFile(certPath, certPem, 0o644)
|
||||
args.logger.info({ certPath }, "Generated CodeNomad HTTPS certificate")
|
||||
}
|
||||
|
||||
const key = fs.readFileSync(keyPath, "utf-8")
|
||||
const cert = fs.readFileSync(certPath, "utf-8")
|
||||
const ca = fs.readFileSync(caCertPath, "utf-8")
|
||||
|
||||
// Present the CA as part of the chain.
|
||||
const chainedCert = `${cert.trim()}\n${ca.trim()}\n`
|
||||
|
||||
return {
|
||||
httpsOptions: {
|
||||
key,
|
||||
cert: chainedCert,
|
||||
},
|
||||
caCertPath,
|
||||
mode: "generated",
|
||||
}
|
||||
}
|
||||
|
||||
function writePemFile(filePath: string, content: string, mode: number) {
|
||||
fs.writeFileSync(filePath, content, { encoding: "utf-8", mode })
|
||||
try {
|
||||
fs.chmodSync(filePath, mode)
|
||||
} catch {
|
||||
// best effort on platforms that ignore chmod
|
||||
}
|
||||
}
|
||||
|
||||
function generateCaCertificate(): { caKeyPem: string; caCertPem: string } {
|
||||
const forge = loadForge()
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048)
|
||||
const cert = forge.pki.createCertificate()
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = crypto.randomBytes(16).toString("hex")
|
||||
|
||||
const now = new Date()
|
||||
const notBefore = new Date(now.getTime() - 60_000)
|
||||
const notAfter = new Date(now.getTime() + CA_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
|
||||
cert.validity.notBefore = notBefore
|
||||
cert.validity.notAfter = notAfter
|
||||
|
||||
const attrs = [{ name: "commonName", value: "CodeNomad Local CA" }]
|
||||
cert.setSubject(attrs)
|
||||
cert.setIssuer(attrs)
|
||||
|
||||
cert.setExtensions([
|
||||
{ name: "basicConstraints", cA: true },
|
||||
{ name: "keyUsage", keyCertSign: true, cRLSign: true, digitalSignature: true },
|
||||
{ name: "subjectKeyIdentifier" },
|
||||
])
|
||||
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create())
|
||||
|
||||
return {
|
||||
caKeyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
||||
caCertPem: forge.pki.certificateToPem(cert),
|
||||
}
|
||||
}
|
||||
|
||||
function generateServerCertificate(args: {
|
||||
host: string
|
||||
tlsSANs?: string
|
||||
caKeyPem: string
|
||||
caCertPem: string
|
||||
}): { keyPem: string; certPem: string } {
|
||||
const forge = loadForge()
|
||||
|
||||
const caKey = forge.pki.privateKeyFromPem(args.caKeyPem)
|
||||
const caCert = forge.pki.certificateFromPem(args.caCertPem)
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048)
|
||||
const cert = forge.pki.createCertificate()
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = crypto.randomBytes(16).toString("hex")
|
||||
|
||||
const now = new Date()
|
||||
const notBefore = new Date(now.getTime() - 60_000)
|
||||
const notAfter = new Date(now.getTime() + LEAF_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
|
||||
cert.validity.notBefore = notBefore
|
||||
cert.validity.notAfter = notAfter
|
||||
|
||||
const commonName = pickCommonName(args.host)
|
||||
cert.setSubject([{ name: "commonName", value: commonName }])
|
||||
cert.setIssuer(caCert.subject.attributes)
|
||||
|
||||
const san = buildSubjectAltNames(args.host, args.tlsSANs)
|
||||
|
||||
cert.setExtensions([
|
||||
{ name: "basicConstraints", cA: false },
|
||||
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
|
||||
{ name: "extKeyUsage", serverAuth: true },
|
||||
{ name: "subjectAltName", altNames: san },
|
||||
{ name: "subjectKeyIdentifier" },
|
||||
])
|
||||
|
||||
cert.sign(caKey, forge.md.sha256.create())
|
||||
|
||||
return {
|
||||
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
||||
certPem: forge.pki.certificateToPem(cert),
|
||||
}
|
||||
}
|
||||
|
||||
function pickCommonName(host: string): string {
|
||||
if (!host || host === "0.0.0.0") {
|
||||
return "localhost"
|
||||
}
|
||||
if (host === "127.0.0.1") {
|
||||
return "localhost"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> {
|
||||
const dns = new Set<string>()
|
||||
const ips = new Set<string>()
|
||||
|
||||
dns.add("localhost")
|
||||
ips.add("127.0.0.1")
|
||||
|
||||
if (host && host !== "0.0.0.0") {
|
||||
if (isIPv4(host)) {
|
||||
ips.add(host)
|
||||
} else {
|
||||
dns.add(host)
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of splitList(tlsSANs)) {
|
||||
if (isIPv4(token)) {
|
||||
ips.add(token)
|
||||
} else if (token) {
|
||||
dns.add(token)
|
||||
}
|
||||
}
|
||||
|
||||
const altNames: Array<{ type: number; value?: string; ip?: string }> = []
|
||||
|
||||
// 2 = DNS, 7 = IP
|
||||
for (const name of Array.from(dns)) {
|
||||
altNames.push({ type: 2, value: name })
|
||||
}
|
||||
for (const ip of Array.from(ips)) {
|
||||
altNames.push({ type: 7, ip })
|
||||
}
|
||||
|
||||
return altNames
|
||||
}
|
||||
|
||||
function splitList(input: string | undefined): string[] {
|
||||
if (!input) return []
|
||||
return input
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isIPv4(value: string): boolean {
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,8 @@ interface WorkspaceManagerOptions {
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
getServerBaseUrl: () => string
|
||||
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
|
||||
nodeExtraCaCertsPath?: string
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
@@ -132,6 +134,7 @@ export class WorkspaceManager {
|
||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
|
||||
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
@@ -531,7 +531,7 @@ impl CliProcessManager {
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
@@ -559,12 +559,12 @@ impl CliProcessManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(port) = port_regex
|
||||
if let Some(url) = local_url_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
.map(|m| m.as_str().to_string())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -574,13 +574,13 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -597,12 +597,15 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
port: u16,
|
||||
base_url: String,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let port = Url::parse(&base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.port_or_known_default())
|
||||
.map(|p| p as u16);
|
||||
let mut locked = status.lock();
|
||||
locked.port = Some(port);
|
||||
locked.port = port;
|
||||
locked.url = Some(base_url.clone());
|
||||
locked.state = CliState::Ready;
|
||||
locked.error = None;
|
||||
@@ -611,22 +614,29 @@ impl CliProcessManager {
|
||||
let token = bootstrap_token.lock().take();
|
||||
|
||||
if let Some(token) = token {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
navigate_main(app, &base_url);
|
||||
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
|
||||
// skip the exchange and let the user authenticate normally.
|
||||
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
|
||||
if scheme.as_deref() != Some("http") {
|
||||
navigate_main(app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
navigate_main(app, &base_url);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log_line("bootstrap token exchange failed (invalid token)");
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log_line("bootstrap token exchange failed (invalid token)");
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -709,19 +719,24 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--port".to_string(),
|
||||
"0".to_string(),
|
||||
"--generate-token".to_string(),
|
||||
];
|
||||
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
|
||||
|
||||
if dev {
|
||||
// Dev: plain HTTP + Vite dev server proxy.
|
||||
args.push("--https".to_string());
|
||||
args.push("false".to_string());
|
||||
args.push("--http".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--ui-dev-server".to_string());
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push("--log-level".to_string());
|
||||
args.push("debug".to_string());
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--http".to_string());
|
||||
args.push("true".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
@@ -37,10 +37,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const list = addresses()
|
||||
if (allowExternalConnections()) {
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
if (!allowExternalConnections()) {
|
||||
return []
|
||||
}
|
||||
return list.filter((address) => address.scope === "loopback")
|
||||
// Local URL is displayed separately; list only remote-friendly addresses.
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
@@ -311,34 +312,27 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<div class="remote-address-list">
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
const expandedState = () => expandedUrl() === address.url
|
||||
const qr = () => qrCodes()[address.url]
|
||||
const scopeLabel = () =>
|
||||
address.scope === "external"
|
||||
? t("remoteAccess.address.scope.network")
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
<Show when={meta()?.localUrl}>
|
||||
{(url) => {
|
||||
const value = () => url()
|
||||
const expandedState = () => expandedUrl() === value()
|
||||
const qr = () => qrCodes()[value()]
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{address.url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
</p>
|
||||
<p class="remote-address-url">{value()}</p>
|
||||
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(address.url)}
|
||||
onClick={() => void toggleExpanded(value())}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
@@ -352,7 +346,60 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
{(dataUrl) => (
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url: address.url })}
|
||||
alt={t("remoteAccess.address.qrAlt", { url: value() })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
const scopeLabel = () =>
|
||||
address.scope === "external"
|
||||
? t("remoteAccess.address.scope.network")
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -26,9 +26,8 @@ import type {
|
||||
} from "../../../server/src/api-types"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE ?? FALLBACK_API_BASE : FALLBACK_API_BASE
|
||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined
|
||||
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
||||
|
||||
Reference in New Issue
Block a user