mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 14:45:46 +00:00
fix(pairing): align mobile setup with secure endpoints
This commit is contained in:
@@ -16,6 +16,8 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayDiscovery
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeResult
|
||||
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.node.*
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
|
||||
@@ -44,7 +46,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
class NodeRuntime(
|
||||
context: Context,
|
||||
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||
private val tlsFingerprintProbe: suspend (String, Int) -> String? = ::probeGatewayTlsFingerprint,
|
||||
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
|
||||
) {
|
||||
data class GatewayConnectAuth(
|
||||
val token: String?,
|
||||
@@ -839,8 +841,9 @@ class NodeRuntime(
|
||||
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
|
||||
_statusText.value = "Verify gateway TLS fingerprint…"
|
||||
scope.launch {
|
||||
val fp = tlsFingerprintProbe(endpoint.host, endpoint.port) ?: run {
|
||||
_statusText.value = "Failed: can't read TLS fingerprint"
|
||||
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
|
||||
val fp = tlsProbe.fingerprintSha256 ?: run {
|
||||
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
|
||||
return@launch
|
||||
}
|
||||
_pendingGatewayTrust.value =
|
||||
@@ -888,6 +891,15 @@ class NodeRuntime(
|
||||
_statusText.value = "Offline"
|
||||
}
|
||||
|
||||
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String {
|
||||
return when (failure) {
|
||||
GatewayTlsProbeFailure.TLS_UNAVAILABLE ->
|
||||
"Failed: remote mobile nodes require wss:// or Tailscale Serve. No TLS endpoint detected for this host."
|
||||
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->
|
||||
"Failed: couldn't reach a secure gateway endpoint. Remote mobile nodes require wss:// or Tailscale Serve."
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
|
||||
|
||||
@@ -3,7 +3,11 @@ package ai.openclaw.app.gateway
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.EOFException
|
||||
import java.net.ConnectException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
@@ -12,6 +16,7 @@ import java.util.Locale
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.SSLParameters
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.SNIHostName
|
||||
@@ -32,6 +37,16 @@ data class GatewayTlsConfig(
|
||||
val hostnameVerifier: HostnameVerifier,
|
||||
)
|
||||
|
||||
enum class GatewayTlsProbeFailure {
|
||||
TLS_UNAVAILABLE,
|
||||
ENDPOINT_UNREACHABLE,
|
||||
}
|
||||
|
||||
data class GatewayTlsProbeResult(
|
||||
val fingerprintSha256: String? = null,
|
||||
val failure: GatewayTlsProbeFailure? = null,
|
||||
)
|
||||
|
||||
fun buildGatewayTlsConfig(
|
||||
params: GatewayTlsParams?,
|
||||
onStore: ((String) -> Unit)? = null,
|
||||
@@ -85,10 +100,10 @@ suspend fun probeGatewayTlsFingerprint(
|
||||
host: String,
|
||||
port: Int,
|
||||
timeoutMs: Int = 3_000,
|
||||
): String? {
|
||||
): GatewayTlsProbeResult {
|
||||
val trimmedHost = host.trim()
|
||||
if (trimmedHost.isEmpty()) return null
|
||||
if (port !in 1..65535) return null
|
||||
if (trimmedHost.isEmpty()) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
|
||||
if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val trustAll =
|
||||
@@ -121,10 +136,21 @@ suspend fun probeGatewayTlsFingerprint(
|
||||
}
|
||||
|
||||
socket.startHandshake()
|
||||
val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
|
||||
sha256Hex(cert.encoded)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
val cert =
|
||||
socket.session.peerCertificates.firstOrNull() as? X509Certificate
|
||||
?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
|
||||
GatewayTlsProbeResult(fingerprintSha256 = sha256Hex(cert.encoded))
|
||||
} catch (err: Throwable) {
|
||||
val failure =
|
||||
when (err) {
|
||||
is SSLException,
|
||||
is EOFException -> GatewayTlsProbeFailure.TLS_UNAVAILABLE
|
||||
is ConnectException,
|
||||
is SocketTimeoutException,
|
||||
is UnknownHostException -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
|
||||
else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
|
||||
}
|
||||
GatewayTlsProbeResult(failure = failure)
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
|
||||
@@ -256,9 +256,23 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
if (config == null) {
|
||||
validationText =
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
"Paste a valid setup code to connect."
|
||||
val parsedSetup = decodeGatewaySetupCode(setupCode)
|
||||
if (parsedSetup == null) {
|
||||
"Paste a valid setup code to connect."
|
||||
} else {
|
||||
val parsedGateway = parseGatewayEndpointResult(parsedSetup.url)
|
||||
gatewayEndpointValidationMessage(
|
||||
parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.SETUP_CODE,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
"Enter a valid manual host and port to connect."
|
||||
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)
|
||||
val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult)
|
||||
gatewayEndpointValidationMessage(
|
||||
parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.MANUAL,
|
||||
)
|
||||
}
|
||||
return@Button
|
||||
}
|
||||
@@ -386,6 +400,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary)
|
||||
CommandBlock("openclaw qr --setup-code-only")
|
||||
CommandBlock("openclaw qr --json")
|
||||
Text(
|
||||
"Remote mobile nodes require wss:// or Tailscale Serve. ws:// is only for localhost or the Android emulator.",
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
@@ -468,7 +487,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Use TLS", style = mobileHeadline, color = mobileText)
|
||||
Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary)
|
||||
Text(
|
||||
"Required for remote hosts. Use Tailscale Serve or a wss:// gateway URL.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = manualTlsInput,
|
||||
|
||||
@@ -33,7 +33,32 @@ internal data class GatewayConnectConfig(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
internal enum class GatewayEndpointValidationError {
|
||||
INVALID_URL,
|
||||
INSECURE_REMOTE_URL,
|
||||
}
|
||||
|
||||
internal enum class GatewayEndpointInputSource {
|
||||
SETUP_CODE,
|
||||
MANUAL,
|
||||
QR_SCAN,
|
||||
}
|
||||
|
||||
internal data class GatewayEndpointParseResult(
|
||||
val config: GatewayEndpointConfig? = null,
|
||||
val error: GatewayEndpointValidationError? = null,
|
||||
)
|
||||
|
||||
internal data class GatewayScannedSetupCodeResult(
|
||||
val setupCode: String? = null,
|
||||
val error: GatewayEndpointValidationError? = null,
|
||||
)
|
||||
|
||||
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
|
||||
private const val remoteGatewaySecurityRule =
|
||||
"Remote mobile nodes require wss:// or Tailscale Serve. ws:// is only for localhost or the Android emulator."
|
||||
private const val remoteGatewaySecurityFix =
|
||||
"Enable Tailscale Serve or expose a wss:// gateway URL."
|
||||
|
||||
internal fun resolveGatewayConnectConfig(
|
||||
useSetupCode: Boolean,
|
||||
@@ -50,7 +75,7 @@ internal fun resolveGatewayConnectConfig(
|
||||
): GatewayConnectConfig? {
|
||||
if (useSetupCode) {
|
||||
val setup = decodeGatewaySetupCode(setupCode) ?: return null
|
||||
val parsed = parseGatewayEndpoint(setup.url) ?: return null
|
||||
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
|
||||
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
|
||||
val sharedToken =
|
||||
when {
|
||||
@@ -75,10 +100,10 @@ internal fun resolveGatewayConnectConfig(
|
||||
}
|
||||
|
||||
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null
|
||||
val parsed = parseGatewayEndpoint(manualUrl) ?: return null
|
||||
val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null
|
||||
val savedManualEndpoint =
|
||||
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
|
||||
?.let(::parseGatewayEndpoint)
|
||||
?.let { parseGatewayEndpointResult(it).config }
|
||||
val preserveBootstrapToken =
|
||||
savedManualEndpoint != null &&
|
||||
savedManualEndpoint.host == parsed.host &&
|
||||
@@ -97,13 +122,19 @@ internal fun resolveGatewayConnectConfig(
|
||||
}
|
||||
|
||||
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
||||
return parseGatewayEndpointResult(rawInput).config
|
||||
}
|
||||
|
||||
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
|
||||
val raw = rawInput.trim()
|
||||
if (raw.isEmpty()) return null
|
||||
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
|
||||
val normalized = if (raw.contains("://")) raw else "https://$raw"
|
||||
val uri = runCatching { URI(normalized) }.getOrNull() ?: return null
|
||||
val uri =
|
||||
runCatching { URI(normalized) }.getOrNull()
|
||||
?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
val host = uri.host?.trim()?.trim('[', ']').orEmpty()
|
||||
if (host.isEmpty()) return null
|
||||
if (host.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
|
||||
val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
|
||||
val tls =
|
||||
@@ -112,7 +143,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
||||
"wss", "https" -> true
|
||||
else -> true
|
||||
}
|
||||
if (!tls && !isLoopbackGatewayHost(host)) return null
|
||||
if (!tls && !isLoopbackGatewayHost(host)) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
|
||||
}
|
||||
val defaultPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
@@ -134,7 +167,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
||||
"${if (tls) "https" else "http"}://$displayHost:$port"
|
||||
}
|
||||
|
||||
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
|
||||
return GatewayEndpointParseResult(
|
||||
config = GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
@@ -165,9 +200,44 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
}
|
||||
|
||||
internal fun resolveScannedSetupCode(rawInput: String): String? {
|
||||
val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
|
||||
val decoded = decodeGatewaySetupCode(setupCode) ?: return null
|
||||
return setupCode.takeIf { parseGatewayEndpoint(decoded.url) != null }
|
||||
return resolveScannedSetupCodeResult(rawInput).setupCode
|
||||
}
|
||||
|
||||
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
|
||||
val setupCode =
|
||||
resolveSetupCodeCandidate(rawInput)
|
||||
?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
val decoded =
|
||||
decodeGatewaySetupCode(setupCode)
|
||||
?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
val parsed = parseGatewayEndpointResult(decoded.url)
|
||||
if (parsed.config == null) {
|
||||
return GatewayScannedSetupCodeResult(error = parsed.error)
|
||||
}
|
||||
return GatewayScannedSetupCodeResult(setupCode = setupCode)
|
||||
}
|
||||
|
||||
internal fun gatewayEndpointValidationMessage(
|
||||
error: GatewayEndpointValidationError,
|
||||
source: GatewayEndpointInputSource,
|
||||
): String {
|
||||
return when (error) {
|
||||
GatewayEndpointValidationError.INSECURE_REMOTE_URL ->
|
||||
when (source) {
|
||||
GatewayEndpointInputSource.SETUP_CODE ->
|
||||
"Setup code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix"
|
||||
GatewayEndpointInputSource.QR_SCAN ->
|
||||
"QR code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix"
|
||||
GatewayEndpointInputSource.MANUAL ->
|
||||
"$remoteGatewaySecurityRule $remoteGatewaySecurityFix"
|
||||
}
|
||||
GatewayEndpointValidationError.INVALID_URL ->
|
||||
when (source) {
|
||||
GatewayEndpointInputSource.SETUP_CODE -> "Setup code has invalid gateway URL."
|
||||
GatewayEndpointInputSource.QR_SCAN -> "QR code did not contain a valid setup code."
|
||||
GatewayEndpointInputSource.MANUAL -> "Enter a valid manual host and port to connect."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {
|
||||
|
||||
@@ -49,6 +49,7 @@ internal fun buildGatewayDiagnosticsReport(
|
||||
Please:
|
||||
- pick one route only: same machine, same LAN, Tailscale, or public URL
|
||||
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
|
||||
- remember: remote mobile nodes require wss:// or Tailscale Serve; ws:// is only for localhost or the Android emulator
|
||||
- quote the exact app status/error below
|
||||
- tell me whether `openclaw devices list` should show a pending pairing request
|
||||
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`
|
||||
|
||||
@@ -566,12 +566,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
if (contents.isEmpty()) {
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
val scannedSetupCode = resolveScannedSetupCode(contents)
|
||||
if (scannedSetupCode == null) {
|
||||
gatewayError = "QR code did not contain a valid setup code."
|
||||
val scannedSetupCode = resolveScannedSetupCodeResult(contents)
|
||||
if (scannedSetupCode.setupCode == null) {
|
||||
gatewayError =
|
||||
gatewayEndpointValidationMessage(
|
||||
scannedSetupCode.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.QR_SCAN,
|
||||
)
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
setupCode = scannedSetupCode
|
||||
setupCode = scannedSetupCode.setupCode
|
||||
gatewayInputMode = GatewayInputMode.SetupCode
|
||||
gatewayError = null
|
||||
attemptedConnect = false
|
||||
@@ -799,9 +803,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
gatewayError = "Scan QR code first, or use Advanced setup."
|
||||
return@Button
|
||||
}
|
||||
val parsedGateway = parseGatewayEndpoint(parsedSetup.url)
|
||||
if (parsedGateway == null) {
|
||||
gatewayError = "Setup code has invalid gateway URL."
|
||||
val parsedGateway = parseGatewayEndpointResult(parsedSetup.url)
|
||||
if (parsedGateway.config == null) {
|
||||
gatewayError =
|
||||
gatewayEndpointValidationMessage(
|
||||
parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.SETUP_CODE,
|
||||
)
|
||||
return@Button
|
||||
}
|
||||
gatewayUrl = parsedSetup.url
|
||||
@@ -819,12 +827,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
} else {
|
||||
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
|
||||
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
|
||||
if (parsedGateway == null) {
|
||||
gatewayError = "Manual endpoint is invalid."
|
||||
val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult)
|
||||
if (parsedGateway?.config == null) {
|
||||
gatewayError =
|
||||
gatewayEndpointValidationMessage(
|
||||
parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.MANUAL,
|
||||
)
|
||||
return@Button
|
||||
}
|
||||
gatewayUrl = parsedGateway.displayUrl
|
||||
gatewayUrl = parsedGateway.config.displayUrl
|
||||
viewModel.setGatewayBootstrapToken("")
|
||||
}
|
||||
step = OnboardingStep.Permissions
|
||||
@@ -863,19 +875,23 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
val parsed = parseGatewayEndpoint(gatewayUrl)
|
||||
if (parsed == null) {
|
||||
val parsed = parseGatewayEndpointResult(gatewayUrl)
|
||||
if (parsed.config == null) {
|
||||
step = OnboardingStep.Gateway
|
||||
gatewayError = "Invalid gateway URL."
|
||||
gatewayError =
|
||||
gatewayEndpointValidationMessage(
|
||||
parsed.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.MANUAL,
|
||||
)
|
||||
return@Button
|
||||
}
|
||||
val token = persistedGatewayToken.trim()
|
||||
val password = gatewayPassword.trim()
|
||||
attemptedConnect = true
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(parsed.host)
|
||||
viewModel.setManualPort(parsed.port)
|
||||
viewModel.setManualTls(parsed.tls)
|
||||
viewModel.setManualHost(parsed.config.host)
|
||||
viewModel.setManualPort(parsed.config.port)
|
||||
viewModel.setManualTls(parsed.config.tls)
|
||||
if (gatewayInputMode == GatewayInputMode.Manual) {
|
||||
viewModel.setGatewayBootstrapToken("")
|
||||
}
|
||||
@@ -886,7 +902,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
viewModel.setGatewayPassword(password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = parsed.host, port = parsed.port),
|
||||
GatewayEndpoint.manual(host = parsed.config.host, port = parsed.config.port),
|
||||
token = token.ifEmpty { null },
|
||||
bootstrapToken =
|
||||
if (gatewayInputMode == GatewayInputMode.SetupCode) {
|
||||
@@ -1040,7 +1056,7 @@ private fun GatewayStep(
|
||||
|
||||
StepShell(title = "Gateway Connection") {
|
||||
Text(
|
||||
"Run `openclaw qr` on your gateway host, then scan the code with this device.",
|
||||
"Run `openclaw qr` on your gateway host, then scan the code with this device. Remote mobile nodes require wss:// or Tailscale Serve.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
@@ -1072,7 +1088,7 @@ private fun GatewayStep(
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary)
|
||||
Text("Paste setup code or enter host/port manually. ws:// is only for localhost or the Android emulator.", style = onboardingCaption1Style, color = onboardingTextSecondary)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
@@ -1153,7 +1169,11 @@ private fun GatewayStep(
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
|
||||
Text(
|
||||
"Required for remote hosts. Use Tailscale Serve or a wss:// gateway URL.",
|
||||
style = onboardingCalloutStyle.copy(lineHeight = 18.sp),
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = manualTls,
|
||||
|
||||
@@ -2,6 +2,8 @@ package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -77,7 +79,7 @@ class GatewayBootstrapAuthTest {
|
||||
NodeRuntime(
|
||||
app,
|
||||
prefs,
|
||||
tlsFingerprintProbe = { _, _ -> "fp-1" },
|
||||
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp-1") },
|
||||
)
|
||||
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
|
||||
val explicitAuth =
|
||||
@@ -98,6 +100,29 @@ class GatewayBootstrapAuthTest {
|
||||
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val runtime =
|
||||
NodeRuntime(
|
||||
app,
|
||||
tlsFingerprintProbe = { _, _ ->
|
||||
GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
|
||||
},
|
||||
)
|
||||
|
||||
runtime.connect(
|
||||
GatewayEndpoint.manual(host = "gateway.example", port = 18789),
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Failed: remote mobile nodes require wss:// or Tailscale Serve. No TLS endpoint detected for this host.",
|
||||
waitForStatusText(runtime),
|
||||
)
|
||||
assertNull(runtime.pendingGatewayTrust.value)
|
||||
}
|
||||
|
||||
private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt {
|
||||
repeat(50) {
|
||||
runtime.pendingGatewayTrust.value?.let { return it }
|
||||
@@ -106,6 +131,17 @@ class GatewayBootstrapAuthTest {
|
||||
error("Expected pending gateway trust prompt")
|
||||
}
|
||||
|
||||
private fun waitForStatusText(runtime: NodeRuntime): String {
|
||||
repeat(50) {
|
||||
val status = runtime.statusText.value
|
||||
if (status != "Verify gateway TLS fingerprint…") {
|
||||
return status
|
||||
}
|
||||
Thread.sleep(10)
|
||||
}
|
||||
error("Expected status text update")
|
||||
}
|
||||
|
||||
private fun desiredBootstrapToken(runtime: NodeRuntime, sessionFieldName: String): String? {
|
||||
val session = readField<GatewaySession>(runtime, sessionFieldName)
|
||||
val desired = readField<Any?>(session, "desired") ?: return null
|
||||
|
||||
@@ -220,6 +220,25 @@ class GatewayConfigResolverTest {
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultFlagsInsecureRemoteGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointResultFlagsInsecureRemoteGateway() {
|
||||
val parsed = parseGatewayEndpointResult("ws://gateway.example:18789")
|
||||
|
||||
assertNull(parsed.config)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeGatewaySetupCodeParsesBootstrapToken() {
|
||||
val setupCode =
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw qr` (generate iOS pairing QR + setup code)"
|
||||
summary: "CLI reference for `openclaw qr` (generate mobile pairing QR + setup code)"
|
||||
read_when:
|
||||
- You want to pair the iOS app with a gateway quickly
|
||||
- You want to pair a mobile node app with a gateway quickly
|
||||
- You need setup-code output for remote/manual sharing
|
||||
title: "qr"
|
||||
---
|
||||
|
||||
# `openclaw qr`
|
||||
|
||||
Generate an iOS pairing QR and setup code from your current Gateway configuration.
|
||||
Generate a mobile pairing QR and setup code from your current Gateway configuration.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -35,6 +35,7 @@ openclaw qr --url wss://gateway.example/ws
|
||||
|
||||
- `--token` and `--password` are mutually exclusive.
|
||||
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
|
||||
- Mobile pairing fails closed for insecure remote `ws://` gateway URLs. For remote/mobile use, prefer Tailscale Serve/Funnel or a `wss://` gateway URL. Plain `ws://` is only valid for localhost/debugging.
|
||||
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.
|
||||
- Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed:
|
||||
- `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins).
|
||||
|
||||
@@ -95,6 +95,12 @@ If the gateway can detect it is running under Tailscale, it publishes `tailnetDn
|
||||
|
||||
The macOS app now prefers MagicDNS names over raw Tailscale IPs for gateway discovery. This improves reliability when tailnet IPs change (for example after node restarts or CGNAT reassignment), because MagicDNS names resolve to the current IP automatically.
|
||||
|
||||
For mobile node pairing, discovery hints do not relax transport security:
|
||||
|
||||
- iOS/Android still require a secure first-time remote connect path (`wss://` or Tailscale Serve/Funnel).
|
||||
- A discovered raw tailnet IP is a routing hint, not permission to use plaintext remote `ws://`.
|
||||
- If you want the simplest Tailscale path for mobile nodes, use Tailscale Serve so discovery and the setup code both resolve to the same secure MagicDNS endpoint.
|
||||
|
||||
### 3) Manual / SSH target
|
||||
|
||||
When there is no direct route (or direct is disabled), clients can always connect via SSH by forwarding the loopback gateway port.
|
||||
@@ -108,6 +114,7 @@ Recommended client behavior:
|
||||
1. If a paired direct endpoint is configured and reachable, use it.
|
||||
2. Else, if Bonjour finds a gateway on LAN, offer a one-tap “Use this gateway” choice and save it as the direct endpoint.
|
||||
3. Else, if a tailnet DNS/IP is configured, try direct.
|
||||
For mobile nodes, direct means a secure endpoint, not plaintext remote `ws://`.
|
||||
4. Else, fall back to SSH.
|
||||
|
||||
## Pairing + auth (direct transport)
|
||||
|
||||
@@ -27,7 +27,13 @@ System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gatew
|
||||
|
||||
Android node app ⇄ (mDNS/NSD + WebSocket) ⇄ **Gateway**
|
||||
|
||||
Android connects directly to the Gateway WebSocket (default `ws://<host>:18789`) and uses device pairing (`role: node`).
|
||||
Android connects directly to the Gateway WebSocket and uses device pairing (`role: node`).
|
||||
|
||||
For remote hosts, Android requires a secure endpoint:
|
||||
|
||||
- Preferred: Tailscale Serve / Funnel with `https://<magicdns>` / `wss://<magicdns>`
|
||||
- Also supported: any other `wss://` Gateway URL with a real TLS endpoint
|
||||
- Local debugging only: `ws://` on `localhost`, `127.0.0.1`, or the Android emulator bridge (`10.0.2.2`)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -36,6 +42,7 @@ Android connects directly to the Gateway WebSocket (default `ws://<host>:18789`)
|
||||
- Same LAN with mDNS/NSD, **or**
|
||||
- Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or**
|
||||
- Manual gateway host/port (fallback)
|
||||
- Remote mobile pairing does **not** use raw tailnet IP `ws://` endpoints. Use Tailscale Serve or another `wss://` URL instead.
|
||||
- You can run the CLI (`openclaw`) on the gateway machine (or via SSH).
|
||||
|
||||
### 1) Start the Gateway
|
||||
@@ -48,10 +55,13 @@ Confirm in logs you see something like:
|
||||
|
||||
- `listening on ws://0.0.0.0:18789`
|
||||
|
||||
For tailnet-only setups (recommended for Vienna ⇄ London), bind the gateway to the tailnet IP:
|
||||
For remote Android access over Tailscale, prefer Serve/Funnel instead of a raw tailnet bind:
|
||||
|
||||
- Set `gateway.bind: "tailnet"` in `~/.openclaw/openclaw.json` on the gateway host.
|
||||
- Restart the Gateway / macOS menubar app.
|
||||
```bash
|
||||
openclaw gateway --tailscale serve
|
||||
```
|
||||
|
||||
This gives Android a secure `wss://` / `https://` endpoint. A plain `gateway.bind: "tailnet"` setup is not enough for first-time remote Android pairing unless you also terminate TLS separately.
|
||||
|
||||
### 2) Verify discovery (optional)
|
||||
|
||||
@@ -65,7 +75,9 @@ More debugging notes: [Bonjour](/gateway/bonjour).
|
||||
|
||||
#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
||||
|
||||
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead.
|
||||
|
||||
Discovery alone is not sufficient for remote Android pairing. The discovered route still needs a secure endpoint (`wss://` or Tailscale Serve):
|
||||
|
||||
1. Set up a DNS-SD zone (example `openclaw.internal.`) on the gateway host and publish `_openclaw-gw._tcp` records.
|
||||
2. Configure Tailscale split DNS for your chosen domain pointing at that DNS server.
|
||||
@@ -79,7 +91,7 @@ In the Android app:
|
||||
- The app keeps its gateway connection alive via a **foreground service** (persistent notification).
|
||||
- Open the **Connect** tab.
|
||||
- Use **Setup Code** or **Manual** mode.
|
||||
- If discovery is blocked, use manual host/port (and TLS/token/password when required) in **Advanced controls**.
|
||||
- If discovery is blocked, use manual host/port in **Advanced controls**. For remote hosts, turn on TLS and use a `wss://` / Tailscale Serve endpoint.
|
||||
|
||||
After the first successful pairing, Android auto-reconnects on launch:
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ const entries: SubCliEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "qr",
|
||||
description: "Generate iOS pairing QR/setup code",
|
||||
description: "Generate mobile pairing QR/setup code",
|
||||
hasSubcommands: false,
|
||||
register: async (program) => {
|
||||
const mod = await import("../qr-cli.js");
|
||||
|
||||
@@ -80,7 +80,7 @@ export const SUB_CLI_DESCRIPTORS = [
|
||||
},
|
||||
{
|
||||
name: "qr",
|
||||
description: "Generate iOS pairing QR/setup code",
|
||||
description: "Generate mobile pairing QR/setup code",
|
||||
hasSubcommands: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -87,7 +87,7 @@ function createLocalGatewayConfigWithAuth(auth: Record<string, unknown>) {
|
||||
secrets: createDefaultSecretProvider(),
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth,
|
||||
},
|
||||
};
|
||||
@@ -149,7 +149,7 @@ describe("registerQrCli", () => {
|
||||
}
|
||||
|
||||
function expectLoggedLocalSetupCode() {
|
||||
expectLoggedSetupCode("ws://gateway.local:18789");
|
||||
expectLoggedSetupCode("ws://127.0.0.1:18789");
|
||||
}
|
||||
|
||||
function mockTailscaleStatusLookup() {
|
||||
@@ -178,7 +178,7 @@ describe("registerQrCli", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
@@ -186,7 +186,7 @@ describe("registerQrCli", () => {
|
||||
await runQr(["--setup-code-only"]);
|
||||
|
||||
const expected = encodePairingSetupCode({
|
||||
url: "ws://gateway.local:18789",
|
||||
url: "ws://127.0.0.1:18789",
|
||||
bootstrapToken: "bootstrap-123",
|
||||
});
|
||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||
@@ -198,7 +198,7 @@ describe("registerQrCli", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
@@ -213,11 +213,27 @@ describe("registerQrCli", () => {
|
||||
expect(output).toContain("openclaw devices approve <requestId>");
|
||||
});
|
||||
|
||||
it("accepts --token override when config has no auth", async () => {
|
||||
it("fails fast for insecure remote mobile pairing setup urls", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
|
||||
await expectQrExit(["--setup-code-only"]);
|
||||
|
||||
const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
|
||||
expect(output).toContain("Mobile pairing requires a secure remote gateway URL");
|
||||
expect(output).toContain("gateway.tailscale.mode=serve");
|
||||
});
|
||||
|
||||
it("accepts --token override when config has no auth", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "127.0.0.1",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ function emitQrSecretResolveDiagnostics(diagnostics: string[], opts: QrCliOption
|
||||
export function registerQrCli(program: Command) {
|
||||
program
|
||||
.command("qr")
|
||||
.description("Generate an iOS pairing QR code and setup code")
|
||||
.description("Generate a mobile pairing QR code and setup code")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/qr", "docs.openclaw.ai/cli/qr")}\n`,
|
||||
@@ -236,7 +236,7 @@ export function registerQrCli(program: Command) {
|
||||
|
||||
const lines: string[] = [
|
||||
theme.heading("Pairing QR"),
|
||||
"Scan this with the OpenClaw iOS app (Onboarding -> Scan QR).",
|
||||
"Scan this with the OpenClaw mobile app (Onboarding -> Scan QR).",
|
||||
"",
|
||||
];
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ function createGatewayTokenRefFixture() {
|
||||
},
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
customBindHost: "127.0.0.1",
|
||||
port: 18789,
|
||||
auth: {
|
||||
mode: "token",
|
||||
@@ -157,7 +157,7 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
|
||||
const setupCode = runtimeLogs.at(-1);
|
||||
expect(setupCode).toBeTruthy();
|
||||
const payload = decodeSetupCode(setupCode ?? "");
|
||||
expect(payload.url).toBe("ws://gateway.local:18789");
|
||||
expect(payload.url).toBe("ws://127.0.0.1:18789");
|
||||
expect(payload.bootstrapToken).toBeTruthy();
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("pairing setup code", () => {
|
||||
...config,
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth,
|
||||
},
|
||||
};
|
||||
@@ -57,6 +57,23 @@ describe("pairing setup code", () => {
|
||||
}));
|
||||
}
|
||||
|
||||
function createIpv4NetworkInterfaces(
|
||||
address: string,
|
||||
): ReturnType<NonNullable<NonNullable<ResolveSetupOptions>["networkInterfaces"]>> {
|
||||
return {
|
||||
en0: [
|
||||
{
|
||||
address,
|
||||
family: "IPv4",
|
||||
internal: false,
|
||||
netmask: "255.255.255.0",
|
||||
mac: "00:00:00:00:00:00",
|
||||
cidr: `${address}/24`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function expectResolvedSetupOk(
|
||||
resolved: ResolvedSetup,
|
||||
params: {
|
||||
@@ -279,7 +296,7 @@ describe("pairing setup code", () => {
|
||||
{
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth: { token },
|
||||
},
|
||||
...defaultEnvSecretProviderConfig,
|
||||
@@ -351,14 +368,14 @@ describe("pairing setup code", () => {
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
customBindHost: "127.0.0.1",
|
||||
port: 19001,
|
||||
auth: { mode: "token", token: "tok_123" },
|
||||
},
|
||||
} satisfies ResolveSetupConfig,
|
||||
expected: {
|
||||
authLabel: "token",
|
||||
url: "ws://gateway.local:19001",
|
||||
url: "ws://127.0.0.1:19001",
|
||||
urlSource: "gateway.bind=custom",
|
||||
},
|
||||
},
|
||||
@@ -367,7 +384,7 @@ describe("pairing setup code", () => {
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth: { mode: "token", token: "old" },
|
||||
},
|
||||
} satisfies ResolveSetupConfig,
|
||||
@@ -378,7 +395,7 @@ describe("pairing setup code", () => {
|
||||
} satisfies ResolveSetupOptions,
|
||||
expected: {
|
||||
authLabel: "token",
|
||||
url: "ws://gateway.local:18789",
|
||||
url: "ws://127.0.0.1:18789",
|
||||
urlSource: "gateway.bind=custom",
|
||||
},
|
||||
},
|
||||
@@ -390,6 +407,52 @@ describe("pairing setup code", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "rejects custom bind remote ws setup urls for mobile pairing",
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth: { mode: "token", token: "tok_123" },
|
||||
},
|
||||
} satisfies ResolveSetupConfig,
|
||||
expectedError: "Mobile pairing requires a secure remote gateway URL",
|
||||
},
|
||||
{
|
||||
name: "rejects tailnet bind remote ws setup urls for mobile pairing",
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "tailnet",
|
||||
auth: { mode: "token", token: "tok_123" },
|
||||
},
|
||||
} satisfies ResolveSetupConfig,
|
||||
options: {
|
||||
networkInterfaces: () => createIpv4NetworkInterfaces("100.64.0.9"),
|
||||
} satisfies ResolveSetupOptions,
|
||||
expectedError: "prefer gateway.tailscale.mode=serve",
|
||||
},
|
||||
{
|
||||
name: "rejects lan bind remote ws setup urls for mobile pairing",
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: { mode: "password", password: "secret" },
|
||||
},
|
||||
} satisfies ResolveSetupConfig,
|
||||
options: {
|
||||
networkInterfaces: () => createIpv4NetworkInterfaces("192.168.1.20"),
|
||||
} satisfies ResolveSetupOptions,
|
||||
expectedError: "ws:// is only valid for localhost",
|
||||
},
|
||||
] as const)("$name", async ({ config, options, expectedError }) => {
|
||||
await expectResolvedSetupFailureCase({
|
||||
config,
|
||||
options,
|
||||
expectedError,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "errors when gateway is loopback only",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveSecretInputRef,
|
||||
} from "../config/types.secrets.js";
|
||||
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
|
||||
import { isLoopbackHost, isSecureWebSocketUrl } from "../gateway/net.js";
|
||||
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js";
|
||||
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
|
||||
import {
|
||||
@@ -62,6 +63,34 @@ type ResolveUrlResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function describeSecureMobilePairingFix(source?: string): string {
|
||||
const sourceNote = source ? ` Resolved source: ${source}.` : "";
|
||||
return (
|
||||
"Mobile pairing requires a secure remote gateway URL (wss://) or Tailscale Serve/Funnel." +
|
||||
sourceNote +
|
||||
" Fix: prefer gateway.tailscale.mode=serve, or set gateway.remote.url / " +
|
||||
"plugins.entries.device-pair.config.publicUrl to a wss:// URL. ws:// is only valid for localhost."
|
||||
);
|
||||
}
|
||||
|
||||
function validateMobilePairingUrl(url: string, source?: string): string | null {
|
||||
if (isSecureWebSocketUrl(url)) {
|
||||
return null;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return "Resolved mobile pairing URL is invalid.";
|
||||
}
|
||||
const protocol =
|
||||
parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol;
|
||||
if (protocol !== "ws:" || isLoopbackHost(parsed.hostname)) {
|
||||
return null;
|
||||
}
|
||||
return describeSecureMobilePairingFix(source);
|
||||
}
|
||||
|
||||
type ResolveAuthLabelResult = {
|
||||
label?: "token" | "password";
|
||||
error?: string;
|
||||
@@ -373,6 +402,10 @@ export async function resolvePairingSetupFromConfig(
|
||||
if (!urlResult.url) {
|
||||
return { ok: false, error: urlResult.error ?? "Gateway URL unavailable." };
|
||||
}
|
||||
const mobilePairingUrlError = validateMobilePairingUrl(urlResult.url, urlResult.source);
|
||||
if (mobilePairingUrlError) {
|
||||
return { ok: false, error: mobilePairingUrlError };
|
||||
}
|
||||
|
||||
if (!authLabel.label) {
|
||||
return { ok: false, error: "Gateway auth is not configured (no token or password)." };
|
||||
|
||||
Reference in New Issue
Block a user