| import os from "node:os"; |
| import { resolveGatewayPort } from "../config/paths.js"; |
| import type { OpenClawConfig } from "../config/types.js"; |
| import { |
| hasConfiguredSecretInput, |
| normalizeSecretInputString, |
| resolveSecretInputRef, |
| } from "../config/types.secrets.js"; |
| import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; |
| import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js"; |
| import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; |
| import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; |
| import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js"; |
| import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; |
|
|
| export type PairingSetupPayload = { |
| url: string; |
| bootstrapToken: string; |
| }; |
|
|
| export type PairingSetupCommandResult = { |
| code: number | null; |
| stdout: string; |
| stderr?: string; |
| }; |
|
|
| export type PairingSetupCommandRunner = ( |
| argv: string[], |
| opts: { timeoutMs: number }, |
| ) => Promise<PairingSetupCommandResult>; |
|
|
| export type ResolvePairingSetupOptions = { |
| env?: NodeJS.ProcessEnv; |
| publicUrl?: string; |
| preferRemoteUrl?: boolean; |
| forceSecure?: boolean; |
| pairingBaseDir?: string; |
| runCommandWithTimeout?: PairingSetupCommandRunner; |
| networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>; |
| }; |
|
|
| export type PairingSetupResolution = |
| | { |
| ok: true; |
| payload: PairingSetupPayload; |
| authLabel: "token" | "password"; |
| urlSource: string; |
| } |
| | { |
| ok: false; |
| error: string; |
| }; |
|
|
| type ResolveUrlResult = { |
| url?: string; |
| source?: string; |
| error?: string; |
| }; |
|
|
| type ResolveAuthLabelResult = { |
| label?: "token" | "password"; |
| error?: string; |
| }; |
|
|
| function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { |
| const trimmed = raw.trim(); |
| if (!trimmed) { |
| return null; |
| } |
| try { |
| const parsed = new URL(trimmed); |
| const scheme = parsed.protocol.replace(":", ""); |
| if (!scheme) { |
| return null; |
| } |
| const resolvedScheme = scheme === "http" ? "ws" : scheme === "https" ? "wss" : scheme; |
| if (resolvedScheme !== "ws" && resolvedScheme !== "wss") { |
| return null; |
| } |
| const host = parsed.hostname; |
| if (!host) { |
| return null; |
| } |
| const port = parsed.port ? `:${parsed.port}` : ""; |
| return `${resolvedScheme}://${host}${port}`; |
| } catch { |
| |
| } |
|
|
| const withoutPath = trimmed.split("/")[0] ?? ""; |
| if (!withoutPath) { |
| return null; |
| } |
| return `${schemeFallback}://${withoutPath}`; |
| } |
|
|
| function resolveScheme( |
| cfg: OpenClawConfig, |
| opts?: { |
| forceSecure?: boolean; |
| }, |
| ): "ws" | "wss" { |
| if (opts?.forceSecure) { |
| return "wss"; |
| } |
| return cfg.gateway?.tls?.enabled === true ? "wss" : "ws"; |
| } |
|
|
| function isPrivateIPv4(address: string): boolean { |
| return isRfc1918Ipv4Address(address); |
| } |
|
|
| function isTailnetIPv4(address: string): boolean { |
| return isCarrierGradeNatIpv4Address(address); |
| } |
|
|
| function pickIPv4Matching( |
| networkInterfaces: () => ReturnType<typeof os.networkInterfaces>, |
| matches: (address: string) => boolean, |
| ): string | null { |
| const nets = networkInterfaces(); |
| for (const entries of Object.values(nets)) { |
| if (!entries) { |
| continue; |
| } |
| for (const entry of entries) { |
| const family = entry?.family; |
| const isIpv4 = family === "IPv4"; |
| if (!entry || entry.internal || !isIpv4) { |
| continue; |
| } |
| const address = entry.address?.trim() ?? ""; |
| if (!address) { |
| continue; |
| } |
| if (matches(address)) { |
| return address; |
| } |
| } |
| } |
| return null; |
| } |
|
|
| function pickLanIPv4( |
| networkInterfaces: () => ReturnType<typeof os.networkInterfaces>, |
| ): string | null { |
| return pickIPv4Matching(networkInterfaces, isPrivateIPv4); |
| } |
|
|
| function pickTailnetIPv4( |
| networkInterfaces: () => ReturnType<typeof os.networkInterfaces>, |
| ): string | null { |
| return pickIPv4Matching(networkInterfaces, isTailnetIPv4); |
| } |
|
|
| function resolveGatewayTokenFromEnv(env: NodeJS.ProcessEnv): string | undefined { |
| return env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined; |
| } |
|
|
| function resolveGatewayPasswordFromEnv(env: NodeJS.ProcessEnv): string | undefined { |
| return ( |
| env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || undefined |
| ); |
| } |
|
|
| function resolvePairingSetupAuthLabel( |
| cfg: OpenClawConfig, |
| env: NodeJS.ProcessEnv, |
| ): ResolveAuthLabelResult { |
| const mode = cfg.gateway?.auth?.mode; |
| const defaults = cfg.secrets?.defaults; |
| const tokenRef = resolveSecretInputRef({ |
| value: cfg.gateway?.auth?.token, |
| defaults, |
| }).ref; |
| const passwordRef = resolveSecretInputRef({ |
| value: cfg.gateway?.auth?.password, |
| defaults, |
| }).ref; |
| const envToken = resolveGatewayTokenFromEnv(env); |
| const envPassword = resolveGatewayPasswordFromEnv(env); |
| const token = |
| envToken || (tokenRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.token)); |
| const password = |
| envPassword || |
| (passwordRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.password)); |
|
|
| if (mode === "password") { |
| if (!password) { |
| return { error: "Gateway auth is set to password, but no password is configured." }; |
| } |
| return { label: "password" }; |
| } |
| if (mode === "token") { |
| if (!token) { |
| return { error: "Gateway auth is set to token, but no token is configured." }; |
| } |
| return { label: "token" }; |
| } |
| if (token) { |
| return { label: "token" }; |
| } |
| if (password) { |
| return { label: "password" }; |
| } |
| return { error: "Gateway auth is not configured (no token or password)." }; |
| } |
|
|
| async function resolveGatewayTokenSecretRef( |
| cfg: OpenClawConfig, |
| env: NodeJS.ProcessEnv, |
| ): Promise<OpenClawConfig> { |
| const hasTokenEnvCandidate = Boolean(resolveGatewayTokenFromEnv(env)); |
| if (hasTokenEnvCandidate) { |
| return cfg; |
| } |
| const mode = cfg.gateway?.auth?.mode; |
| if (mode === "password" || mode === "none" || mode === "trusted-proxy") { |
| return cfg; |
| } |
| if (mode !== "token") { |
| const hasPasswordEnvCandidate = Boolean( |
| env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim(), |
| ); |
| if (hasPasswordEnvCandidate) { |
| return cfg; |
| } |
| } |
| const token = await resolveRequiredConfiguredSecretRefInputString({ |
| config: cfg, |
| env, |
| value: cfg.gateway?.auth?.token, |
| path: "gateway.auth.token", |
| }); |
| if (!token) { |
| return cfg; |
| } |
| return { |
| ...cfg, |
| gateway: { |
| ...cfg.gateway, |
| auth: { |
| ...cfg.gateway?.auth, |
| token, |
| }, |
| }, |
| }; |
| } |
|
|
| async function resolveGatewayPasswordSecretRef( |
| cfg: OpenClawConfig, |
| env: NodeJS.ProcessEnv, |
| ): Promise<OpenClawConfig> { |
| const hasPasswordEnvCandidate = Boolean(resolveGatewayPasswordFromEnv(env)); |
| if (hasPasswordEnvCandidate) { |
| return cfg; |
| } |
| const mode = cfg.gateway?.auth?.mode; |
| if (mode === "token" || mode === "none" || mode === "trusted-proxy") { |
| return cfg; |
| } |
| if (mode !== "password") { |
| const hasTokenCandidate = |
| Boolean(resolveGatewayTokenFromEnv(env)) || |
| hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults); |
| if (hasTokenCandidate) { |
| return cfg; |
| } |
| } |
| const password = await resolveRequiredConfiguredSecretRefInputString({ |
| config: cfg, |
| env, |
| value: cfg.gateway?.auth?.password, |
| path: "gateway.auth.password", |
| }); |
| if (!password) { |
| return cfg; |
| } |
| return { |
| ...cfg, |
| gateway: { |
| ...cfg.gateway, |
| auth: { |
| ...cfg.gateway?.auth, |
| password, |
| }, |
| }, |
| }; |
| } |
|
|
| async function materializePairingSetupAuthConfig( |
| cfg: OpenClawConfig, |
| env: NodeJS.ProcessEnv, |
| ): Promise<OpenClawConfig> { |
| const cfgWithToken = await resolveGatewayTokenSecretRef(cfg, env); |
| return await resolveGatewayPasswordSecretRef(cfgWithToken, env); |
| } |
|
|
| async function resolveGatewayUrl( |
| cfg: OpenClawConfig, |
| opts: { |
| env: NodeJS.ProcessEnv; |
| publicUrl?: string; |
| preferRemoteUrl?: boolean; |
| forceSecure?: boolean; |
| runCommandWithTimeout?: PairingSetupCommandRunner; |
| networkInterfaces: () => ReturnType<typeof os.networkInterfaces>; |
| }, |
| ): Promise<ResolveUrlResult> { |
| const scheme = resolveScheme(cfg, { forceSecure: opts.forceSecure }); |
| const port = resolveGatewayPort(cfg, opts.env); |
|
|
| if (typeof opts.publicUrl === "string" && opts.publicUrl.trim()) { |
| const url = normalizeUrl(opts.publicUrl, scheme); |
| if (url) { |
| return { url, source: "plugins.entries.device-pair.config.publicUrl" }; |
| } |
| return { error: "Configured publicUrl is invalid." }; |
| } |
|
|
| const remoteUrlRaw = cfg.gateway?.remote?.url; |
| const remoteUrl = |
| typeof remoteUrlRaw === "string" && remoteUrlRaw.trim() |
| ? normalizeUrl(remoteUrlRaw, scheme) |
| : null; |
| if (opts.preferRemoteUrl && remoteUrl) { |
| return { url: remoteUrl, source: "gateway.remote.url" }; |
| } |
|
|
| const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; |
| if (tailscaleMode === "serve" || tailscaleMode === "funnel") { |
| const host = await resolveTailnetHostWithRunner(opts.runCommandWithTimeout); |
| if (!host) { |
| return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." }; |
| } |
| return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` }; |
| } |
|
|
| if (remoteUrl) { |
| return { url: remoteUrl, source: "gateway.remote.url" }; |
| } |
|
|
| const bindResult = resolveGatewayBindUrl({ |
| bind: cfg.gateway?.bind, |
| customBindHost: cfg.gateway?.customBindHost, |
| scheme, |
| port, |
| pickTailnetHost: () => pickTailnetIPv4(opts.networkInterfaces), |
| pickLanHost: () => pickLanIPv4(opts.networkInterfaces), |
| }); |
| if (bindResult) { |
| return bindResult; |
| } |
|
|
| return { |
| error: |
| "Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl.", |
| }; |
| } |
|
|
| export function encodePairingSetupCode(payload: PairingSetupPayload): string { |
| const json = JSON.stringify(payload); |
| const base64 = Buffer.from(json, "utf8").toString("base64"); |
| return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); |
| } |
|
|
| export async function resolvePairingSetupFromConfig( |
| cfg: OpenClawConfig, |
| options: ResolvePairingSetupOptions = {}, |
| ): Promise<PairingSetupResolution> { |
| assertExplicitGatewayAuthModeWhenBothConfigured(cfg); |
| const env = options.env ?? process.env; |
| const cfgForAuth = await materializePairingSetupAuthConfig(cfg, env); |
| const authLabel = resolvePairingSetupAuthLabel(cfgForAuth, env); |
| if (authLabel.error) { |
| return { ok: false, error: authLabel.error }; |
| } |
|
|
| const urlResult = await resolveGatewayUrl(cfgForAuth, { |
| env, |
| publicUrl: options.publicUrl, |
| preferRemoteUrl: options.preferRemoteUrl, |
| forceSecure: options.forceSecure, |
| runCommandWithTimeout: options.runCommandWithTimeout, |
| networkInterfaces: options.networkInterfaces ?? os.networkInterfaces, |
| }); |
|
|
| if (!urlResult.url) { |
| return { ok: false, error: urlResult.error ?? "Gateway URL unavailable." }; |
| } |
|
|
| if (!authLabel.label) { |
| return { ok: false, error: "Gateway auth is not configured (no token or password)." }; |
| } |
|
|
| return { |
| ok: true, |
| payload: { |
| url: urlResult.url, |
| bootstrapToken: ( |
| await issueDeviceBootstrapToken({ |
| baseDir: options.pairingBaseDir, |
| }) |
| ).token, |
| }, |
| authLabel: authLabel.label, |
| urlSource: urlResult.source ?? "unknown", |
| }; |
| } |
|
|