diff --git a/echolot-app/.kotlin/errors/errors-1785578570586.log b/echolot-app/.kotlin/errors/errors-1785578570586.log
new file mode 100644
index 0000000..b6e3a58
--- /dev/null
+++ b/echolot-app/.kotlin/errors/errors-1785578570586.log
@@ -0,0 +1,75 @@
+kotlin version: 2.2.10
+error message: Daemon compilation failed: null
+java.lang.Exception
+ at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
+ at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
+ at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
+ at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61)
+ at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61)
+ at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
+ at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
+ at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58)
+ at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
+ at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:191)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$500(DefaultConditionalExecutionQueue.java:112)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:168)
+ at org.gradle.internal.Factories$1.create(Factories.java:30)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$runAndReleaseLocks$0(DefaultWorkerLeaseService.java:300)
+ at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:43)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAndReleaseLocks(DefaultWorkerLeaseService.java:298)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:294)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:286)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:130)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:135)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:163)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:125)
+ at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
+ at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
+ at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
+ at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
+ at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
+ at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
+ at java.base/java.lang.Thread.run(Unknown Source)
+Caused by: java.nio.file.NoSuchFileException: C:\Users\mram.AD\AppData\Local\Temp\kotlin-backups9735132086774701577\24.backup -> C:\Users\mram.AD\dev\echolot\echolot-app\core-protocol\build\classes\kotlin\main\META-INF\core-protocol.kotlin_module
+ at java.base/sun.nio.fs.WindowsException.translateToIOException(Unknown Source)
+ at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(Unknown Source)
+ at java.base/sun.nio.fs.WindowsFileCopy.move(Unknown Source)
+ at java.base/sun.nio.fs.WindowsFileSystemProvider.move(Unknown Source)
+ at java.base/java.nio.file.Files.move(Unknown Source)
+ at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.revertChanges(CompilationTransaction.kt:231)
+ at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.close(CompilationTransaction.kt:256)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:740)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:124)
+ at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:679)
+ at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:93)
+ at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1806)
+ at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
+ at java.base/java.lang.reflect.Method.invoke(Unknown Source)
+ at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
+ at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
+ at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
+ at java.base/java.security.AccessController.doPrivileged(Unknown Source)
+ at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
+ at java.base/java.security.AccessController.doPrivileged(Unknown Source)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
+ ... 3 more
+
+
diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml
index f1941bc..e29224f 100644
--- a/echolot-app/app/src/main/AndroidManifest.xml
+++ b/echolot-app/app/src/main/AndroidManifest.xml
@@ -27,6 +27,18 @@
+
+
+
+
+
+
+
Unit,
onPreviewUpload: () -> Unit,
onCheckServer: () -> Unit,
+ onEnroll: (String) -> Unit,
serverStatus: String?,
onBack: () -> Unit,
) {
@@ -59,6 +60,7 @@ fun SettingsScreen(
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
var privacy by remember { mutableStateOf(settings.privacyLevel) }
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
+ var enrollLink by remember { mutableStateOf("") }
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
var serverPin by remember { mutableStateOf(settings.serverPin) }
var serverCred by remember { mutableStateOf(settings.serverCredential) }
@@ -151,6 +153,32 @@ fun SettingsScreen(
checked = autoUpload,
) { autoUpload = it; settings.autoUpload = it }
+ // Enrollment first, because it is the path that works: one link carries the
+ // URL, the pin and a single-use token. The three fields below exist for when
+ // someone has to reconstruct a configuration by hand, not as the normal route.
+ Text(
+ "Paste an enrollment link from your server operator, or scan its QR code. " +
+ "It fills in all three fields below. The link contains a one-time token — " +
+ "treat it like a password until it is used.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ OutlinedTextField(
+ value = enrollLink, onValueChange = { enrollLink = it },
+ label = { Text("echolot://enroll?…") }, singleLine = true,
+ textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = {
+ onEnroll(enrollLink)
+ enrollLink = "" // spent either way; leaving it around invites a retry
+ serverUrl = settings.serverUrl
+ serverPin = settings.serverPin
+ serverCred = settings.serverCredential
+ },
+ enabled = enrollLink.isNotBlank(),
+ ) { Text("Enroll") }
+
OutlinedTextField(
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
diff --git a/echolot-app/core-engine/build.gradle.kts b/echolot-app/core-engine/build.gradle.kts
index 4491550..62ee61b 100644
--- a/echolot-app/core-engine/build.gradle.kts
+++ b/echolot-app/core-engine/build.gradle.kts
@@ -25,6 +25,6 @@ java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaV
tasks.test {
useJUnitPlatform()
- listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET")
+ listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET","ECHOLOT_ENROLL_URI")
.forEach { k -> System.getenv(k)?.let { environment(k, it) } }
}
diff --git a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveEnrollmentTest.kt b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveEnrollmentTest.kt
new file mode 100644
index 0000000..ff62767
--- /dev/null
+++ b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveEnrollmentTest.kt
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.engine
+
+import app.echo_lot.protocol.EnrollmentLink
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+/**
+ * Enrolls against a LIVE server using the link the server itself minted (probe-protocol.md §2.1).
+ *
+ * This is the test that matters for enrollment, because the failure mode it guards against is a
+ * *disagreement* between two programs: the Go side assembles the link, the Kotlin side takes it
+ * apart, and if they differ by one percent-encoding the pin is wrong by one character — which
+ * does not fail loudly, it fails as an inscrutable TLS error days later. A unit test on either
+ * side alone cannot see that.
+ *
+ * Needs ECHOLOT_ENROLL_URI (minted over SSH by scripts/test-fmr.sh); self-skips without it.
+ */
+class LiveEnrollmentTest {
+
+ private val enrollUri = System.getenv("ECHOLOT_ENROLL_URI")
+
+ @Test
+ fun enrollsFromTheServersOwnLink() {
+ if (enrollUri.isNullOrBlank()) {
+ println("LiveEnrollmentTest skipped (no ECHOLOT_ENROLL_URI)"); return
+ }
+ println("link: ${enrollUri.take(60)}…")
+
+ val link = assertNotNull(
+ EnrollmentLink.parse(enrollUri),
+ "the client could not parse a link the server produced — the two sides disagree",
+ )
+ println("parsed: url=${link.controlUrl} pin=${link.pin.take(12)}… token=${link.token.take(8)}…")
+
+ // Redeeming applies the pin to the very request that spends the token, so a wrong pin
+ // fails here at the handshake rather than after the token is gone.
+ val enrolled = link.redeem(deviceName = "live-test", appVersion = "0.2.0")
+ assertTrue(enrolled.credential.isNotBlank(), "no credential came back")
+ assertTrue(enrolled.deviceId.isNotBlank(), "no device id came back")
+ println("enrolled: device=${enrolled.deviceId} server=${enrolled.profile.name} " +
+ "${enrolled.profile.serverVersion}")
+
+ // The credential must actually work, and the pin from the link must be the one that
+ // verifies the server — that is the whole claim the link is making.
+ assertEquals(link.controlUrl, enrolled.controlUrl)
+ assertTrue(enrolled.profile.capabilities.contains("udp-probe"),
+ "profile fetched with the new credential looks wrong: ${enrolled.profile.capabilities}")
+
+ // Single-use: a token that still works after redemption is a token an attacker can reuse.
+ try {
+ link.redeem(deviceName = "should-not-happen", appVersion = "0.2.0")
+ fail("the enrollment token was accepted twice — it must be single-use")
+ } catch (t: Throwable) {
+ println("second redemption correctly refused: ${t.message?.take(120)}")
+ }
+ }
+}
diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt
new file mode 100644
index 0000000..c2a3ddb
--- /dev/null
+++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt
@@ -0,0 +1,119 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.protocol
+
+import java.net.URLDecoder
+import java.net.URLEncoder
+
+/**
+ * The enrollment bootstrap of probe-protocol.md §2.1.
+ *
+ * ```
+ * echolot://enroll?v=1&u=&p=pin-sha256:&t=
+ * ```
+ *
+ * One string carries everything a device needs to start trusting a server: where it is, which key
+ * to pin, and a single-use token proving the operator meant to admit this device. That is the
+ * whole point — it is why enrollment can be a paste or a QR scan rather than three fields typed
+ * from a screenshot, which is what people actually do wrong.
+ *
+ * **The link is a secret.** It contains a bearer token; anyone who sees it before the device does
+ * can enroll instead. Tokens are single-use and short-lived precisely so a leaked link is a
+ * bounded problem, but it should be treated like a password while it is live.
+ */
+data class EnrollmentLink(
+ /** e.g. "https://fmr-1.echo-lot.app:8443" */
+ val controlUrl: String,
+ /** Base64 SPKI hash, without the "pin-sha256:" prefix — the form [ControlClient] wants. */
+ val pin: String,
+ val token: String,
+) {
+ /** Rebuilds the URI. Round-trips with [parse]; used for tests and for sharing a link on. */
+ fun toUri(): String = buildString {
+ append("echolot://enroll?v=1")
+ append("&u=").append(enc(controlUrl))
+ append("&p=").append(enc(PIN_PREFIX + pin))
+ append("&t=").append(enc(token))
+ }
+
+ /**
+ * Redeems the token and returns a usable server configuration.
+ *
+ * The pin is applied to the very request that redeems the token, so a link pointing at an
+ * impostor fails at the TLS handshake rather than after handing it a token. That ordering is
+ * the reason the pin travels in the link at all.
+ */
+ fun redeem(deviceName: String? = null, appVersion: String = ""): Enrolled {
+ val client = ControlClient(controlUrl, setOf(pin), appVersion)
+ val response = client.enroll(token, deviceName)
+ val profile = client.profile(response.credential)
+ return Enrolled(
+ controlUrl = controlUrl,
+ pin = pin,
+ credential = response.credential,
+ deviceId = response.deviceId,
+ profile = profile,
+ )
+ }
+
+ companion object {
+ const val SCHEME = "echolot"
+ const val HOST = "enroll"
+ private const val PIN_PREFIX = "pin-sha256:"
+
+ /**
+ * Parses a bootstrap link. Returns null for anything that is not one — a malformed link
+ * must not be half-applied, because a half-configured server is a confusing failure much
+ * later rather than an obvious one now.
+ */
+ fun parse(raw: String?): EnrollmentLink? {
+ val s = raw?.trim() ?: return null
+ val scheme = s.substringBefore("://", "")
+ if (!scheme.equals(SCHEME, ignoreCase = true)) return null
+ val rest = s.substringAfter("://")
+ val host = rest.substringBefore('?').trim('/')
+ if (!host.equals(HOST, ignoreCase = true)) return null
+
+ val params = HashMap()
+ for (pair in rest.substringAfter('?', "").split('&')) {
+ if (pair.isEmpty()) continue
+ val k = pair.substringBefore('=')
+ val v = pair.substringAfter('=', "")
+ params[k] = dec(v)
+ }
+
+ // v is the link format, not the protocol. Unknown versions are refused rather than
+ // guessed at: the fields could mean anything.
+ val version = params["v"] ?: "1"
+ if (version != "1") return null
+
+ val url = params["u"]?.trim().orEmpty()
+ val pinRaw = params["p"]?.trim().orEmpty()
+ val token = params["t"]?.trim().orEmpty()
+ if (url.isEmpty() || pinRaw.isEmpty() || token.isEmpty()) return null
+ if (!url.startsWith("https://", ignoreCase = true)) return null
+
+ // A "+" in a query string decodes to a space, so a link whose base64 pin was pasted
+ // in unencoded arrives with spaces where "+" belonged — and a pin that is wrong by
+ // one character does not fail loudly, it just never matches, which surfaces much
+ // later as an inexplicable TLS error. Base64 has no spaces, so putting them back is
+ // unambiguous and cannot damage a correctly-encoded pin.
+ val pin = pinRaw.removePrefix(PIN_PREFIX).replace(' ', '+')
+ if (pin.isEmpty()) return null
+ return EnrollmentLink(controlUrl = url.trimEnd('/'), pin = pin, token = token)
+ }
+
+ private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
+ private fun dec(s: String) = runCatching { URLDecoder.decode(s, "UTF-8") }.getOrDefault(s)
+ }
+}
+
+/** A server this device is now enrolled with, ready to be stored in settings. */
+data class Enrolled(
+ val controlUrl: String,
+ val pin: String,
+ val credential: String,
+ val deviceId: String,
+ val profile: Profile,
+)
diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt
index 256246a..2fb5ff0 100644
--- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt
+++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt
@@ -13,8 +13,16 @@ import kotlinx.serialization.json.JsonElement
@Serializable
data class EnrollResponse(
@SerialName("device_id") val deviceId: String,
- val credential: String,
-)
+ /** The spec's name (§2.1). */
+ @SerialName("device_credential") val deviceCredential: String? = null,
+ /** What the first server implementation shipped. Read for older servers; do not emit. */
+ @SerialName("credential") val legacyCredential: String? = null,
+) {
+ /** Whichever field the server used. */
+ val credential: String
+ get() = deviceCredential ?: legacyCredential
+ ?: error("enroll response carried no credential")
+}
@Serializable
data class Target(
diff --git a/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/EnrollmentTest.kt b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/EnrollmentTest.kt
new file mode 100644
index 0000000..cf19059
--- /dev/null
+++ b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/EnrollmentTest.kt
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.protocol
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class EnrollmentTest {
+
+ private val pin = "zRV9qkiLnRexAeh4RrSfJzbPWO+U/2Oj2/NVM/KfXlg="
+ private val url = "https://fmr-1.echo-lot.app:8443"
+ private val token = "abc123-token_value"
+
+ @Test
+ fun parsesTheSpecFormat() {
+ val link = assertNotNull(
+ EnrollmentLink.parse(
+ "echolot://enroll?v=1&u=https%3A%2F%2Ffmr-1.echo-lot.app%3A8443" +
+ "&p=pin-sha256%3AzRV9qkiLnRexAeh4RrSfJzbPWO%2BU%2F2Oj2%2FNVM%2FKfXlg%3D" +
+ "&t=abc123-token_value"
+ )
+ )
+ assertEquals(url, link.controlUrl)
+ assertEquals(pin, link.pin, "the pin-sha256: prefix should be stripped for ControlClient")
+ assertEquals(token, link.token)
+ }
+
+ // The pin is base64: it contains +, / and = , every one of which means something else in a
+ // query string. Getting the decoding wrong yields a pin that silently never matches.
+ @Test
+ fun survivesBase64PunctuationThroughARoundTrip() {
+ val original = EnrollmentLink(url, pin, token)
+ val reparsed = assertNotNull(EnrollmentLink.parse(original.toUri()))
+ assertEquals(original, reparsed)
+ }
+
+ @Test
+ fun acceptsAnUnprefixedPin() {
+ val link = assertNotNull(EnrollmentLink.parse("echolot://enroll?v=1&u=$url&p=$pin&t=$token"))
+ assertEquals(pin, link.pin)
+ }
+
+ // A hand-assembled link often has its base64 pin pasted in raw. "+" then decodes to a space
+ // and the pin is wrong by one character — which does not fail loudly, it just never matches.
+ // Base64 contains no spaces, so restoring them is unambiguous.
+ @Test
+ fun repairsAPinWhosePlusSignsWereNotEncoded() {
+ val mangled = pin.replace("+", " ")
+ val link = assertNotNull(EnrollmentLink.parse("echolot://enroll?v=1&u=$url&p=$mangled&t=$token"))
+ assertEquals(pin, link.pin)
+ }
+
+ @Test
+ fun toleratesSurroundingWhitespaceAndCaseFromAPaste() {
+ val link = assertNotNull(
+ EnrollmentLink.parse(" ECHOLOT://ENROLL?v=1&u=$url&p=$pin&t=$token\n")
+ )
+ assertEquals(url, link.controlUrl)
+ }
+
+ // A half-applied link is a confusing failure much later; a rejected one is an obvious failure
+ // now. So anything missing or unrecognised parses to null rather than to a partial config.
+ @Test
+ fun rejectsAnythingItCannotFullyUnderstand() {
+ val bad = listOf(
+ null,
+ "",
+ "not a uri",
+ "https://fmr-1.echo-lot.app:8443", // a plain URL is not a bootstrap link
+ "echolot://run?v=1&u=$url&p=$pin&t=$token", // wrong action
+ "echolot://enroll?v=2&u=$url&p=$pin&t=$token", // unknown link version
+ "echolot://enroll?v=1&p=$pin&t=$token", // no url
+ "echolot://enroll?v=1&u=$url&t=$token", // no pin
+ "echolot://enroll?v=1&u=$url&p=$pin", // no token
+ "echolot://enroll?v=1&u=$url&p=pin-sha256:&t=$token", // empty pin
+ )
+ for (s in bad) assertNull(EnrollmentLink.parse(s), "should not parse: $s")
+ }
+
+ // The pin is the entire basis of trust, and it only protects the connection if the connection
+ // is TLS. A cleartext control URL would hand the token to anyone on the path.
+ @Test
+ fun refusesACleartextControlUrl() {
+ assertNull(EnrollmentLink.parse("echolot://enroll?v=1&u=http://fmr-1.echo-lot.app:8443&p=$pin&t=$token"))
+ }
+
+ @Test
+ fun aMissingVersionIsTreatedAsTheOnlyVersionThatExists() {
+ val link = assertNotNull(EnrollmentLink.parse("echolot://enroll?u=$url&p=$pin&t=$token"))
+ assertEquals(token, link.token)
+ }
+
+ @Test
+ fun trailingSlashesOnTheControlUrlAreNormalised() {
+ val link = assertNotNull(EnrollmentLink.parse("echolot://enroll?v=1&u=$url/&p=$pin&t=$token"))
+ assertEquals(url, link.controlUrl, "a trailing slash would double up when paths are appended")
+ }
+}
diff --git a/echolot-app/scripts/test-fmr.sh b/echolot-app/scripts/test-fmr.sh
index 9740317..5a4e4e1 100644
--- a/echolot-app/scripts/test-fmr.sh
+++ b/echolot-app/scripts/test-fmr.sh
@@ -19,13 +19,23 @@ UDP_PORT="${ECHOLOT_UDP_PORT:-8442}"
CTL_URL="https://${CTL_HOST}:${CTL_PORT}"
echo "· minting enrollment token on ${SSH_HOST} ..."
-TOKEN=$(ssh -o BatchMode=yes "$SSH_HOST" \
- 'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens' \
- | python -c 'import json,sys;print(json.load(sys.stdin)["token"])')
+MINTED=$(ssh -o BatchMode=yes "$SSH_HOST" \
+ 'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens')
+TOKEN=$(printf '%s' "$MINTED" | python -c 'import json,sys;print(json.load(sys.stdin)["token"])')
+# The server also returns the whole §2.1 bootstrap link. LiveEnrollmentTest redeems that link,
+# which is what proves the Go side and the Kotlin side agree on its encoding — a disagreement
+# there yields a pin wrong by one character, which fails much later and looks like anything but.
+ENROLL_URI=$(printf '%s' "$MINTED" \
+ | python -c 'import json,sys;print(json.load(sys.stdin).get("enroll_uri",""))')
echo "· enrolling over ${CTL_URL} ..."
-CRED=$(curl -sk -X POST "${CTL_URL}/v1/enroll" -H "Authorization: Bearer ${TOKEN}" \
- | python -c 'import json,sys;print(json.load(sys.stdin)["credential"])')
+# A second token, because the one above is single-use and may be spent by LiveEnrollmentTest.
+TOKEN2=$(ssh -o BatchMode=yes "$SSH_HOST" \
+ 'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens' \
+ | python -c 'import json,sys;print(json.load(sys.stdin)["token"])')
+CRED=$(curl -sk -X POST "${CTL_URL}/v1/enroll" -H "Authorization: Bearer ${TOKEN2}" \
+ -H "X-Echolot-App-Version: 0.2.0" \
+ | python -c 'import json,sys;d=json.load(sys.stdin);print(d.get("device_credential") or d["credential"])')
echo "· computing SPKI pin from served cert ..."
PIN=$(echo | openssl s_client -connect "${CTL_HOST}:${CTL_PORT}" 2>/dev/null \
@@ -43,5 +53,6 @@ ECHOLOT_LIVE_PIN="$PIN" \
ECHOLOT_LIVE_CRED="$CRED" \
ECHOLOT_LIVE_UDP="${CTL_HOST}:${UDP_PORT}" \
ECHOLOT_LIVE_TARGET="${ECHOLOT_LIVE_TARGET:-fmr}" \
+ECHOLOT_ENROLL_URI="$ENROLL_URI" \
./gradlew "$TASK" --tests "$FILTER" --info --rerun-tasks --console=plain \
- 2>&1 | grep -E "profile:|capabilities:|session:|echo |primed|mtu probe|downtrain|big_send|largest|observations bytes|Live[A-Za-z]*Test|BUILD|FAIL|PASS|^e:" || true
+ 2>&1 | grep -E "profile:|capabilities:|session:|echo |primed|mtu probe|downtrain|big_send|largest|observations bytes|link:|parsed:|enrolled:|refused|Live[A-Za-z]*Test|BUILD|FAIL|PASS|^e:" || true
diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go
index edbd0c3..908f460 100644
--- a/server/cmd/echolot-server/main.go
+++ b/server/cmd/echolot-server/main.go
@@ -31,6 +31,7 @@ import (
"os/signal"
"path/filepath"
"strconv"
+ "strings"
"sync/atomic"
"syscall"
"time"
@@ -145,12 +146,13 @@ func serve(cfg *config.Config) error {
Store: st, Sessions: sessions, Name: cfg.Name,
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)),
StunPort: mustPort(firstAddr(cfg.StunListen)), PinB64: pin, CertChain: cert.Certificate,
- DelayedEcho: dp.SendDelayedEcho,
- DownTrain: dp.DownTrain,
- BigSend: dp.BigSend,
- TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
- Runs: runStore,
- AppRange: appRange,
+ DelayedEcho: dp.SendDelayedEcho,
+ DownTrain: dp.DownTrain,
+ BigSend: dp.BigSend,
+ TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
+ Runs: runStore,
+ AppRange: appRange,
+ PublicControlURL: publicControlURL(cfg),
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@@ -446,3 +448,24 @@ func loadOrCreateCert(cfg *config.Config) (tls.Certificate, error) {
slog.Info("generated self-signed certificate", "cert", certPath)
return tls.X509KeyPair(certPem, keyPem)
}
+
+// publicControlURL is where clients should reach this server's control plane.
+//
+// Configured wins; otherwise the first control listen address is used, which is correct for the
+// plain case (bind an address, hand out that address). A wildcard bind has no single right answer,
+// so it is left to the operator rather than guessed — a link pointing at 0.0.0.0 is worse than a
+// link the operator was told to configure.
+func publicControlURL(cfg *config.Config) string {
+ if cfg.PublicControlURL != "" {
+ return strings.TrimRight(cfg.PublicControlURL, "/")
+ }
+ addr := firstAddr(cfg.ControlListen)
+ if addr == "" {
+ return ""
+ }
+ if strings.HasPrefix(addr, ":") || strings.HasPrefix(addr, "0.0.0.0:") || strings.HasPrefix(addr, "[::]:") {
+ slog.Warn("control plane is bound to a wildcard address; set ECHOLOT_PUBLIC_URL "+
+ "so enrollment links point somewhere reachable", "listen", addr)
+ }
+ return "https://" + addr
+}
diff --git a/server/internal/config/config.go b/server/internal/config/config.go
index ab3fbc4..5892754 100644
--- a/server/internal/config/config.go
+++ b/server/internal/config/config.go
@@ -63,6 +63,10 @@ type Config struct {
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive)
+ // Where clients reach the control plane, for enrollment links. Empty = derive from the
+ // first control listen address.
+ PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url
+
// Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
}
@@ -111,6 +115,7 @@ func Load(args []string) (*Config, *Actions, error) {
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
+ fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
diff --git a/server/internal/control/control.go b/server/internal/control/control.go
index 931b3b4..6d4e7b5 100644
--- a/server/internal/control/control.go
+++ b/server/internal/control/control.go
@@ -20,6 +20,7 @@ import (
"net"
"net/http"
"net/netip"
+ "net/url"
"strconv"
"strings"
"time"
@@ -72,6 +73,10 @@ type Server struct {
// server, not the client.
ProvenGood func() (mtuOK, sysctlOK bool)
+ // PublicControlURL is where clients reach this server, for the enrollment link (§2.1).
+ // Empty means "derive from the address we are listening on", which is right for a plain
+ // deployment and wrong behind a proxy or a name — hence the override.
+ PublicControlURL string
// AppRange is the app-version window this server will serve. Zero value means the built-in
// default (see DefaultAppRange).
AppRange compat.Range
@@ -456,8 +461,12 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
}
slog.Info("device enrolled", "device", dev.ID, "name", dev.Name)
writeJSON(w, http.StatusCreated, map[string]string{
- "device_id": dev.ID,
- "credential": dev.Credential, // returned exactly once
+ "device_id": dev.ID,
+ // The spec (§2.1) names this device_credential; the first implementation shipped
+ // "credential". Both are sent while deployed 0.5.x clients still read the old name;
+ // the client prefers the spec's. Drop "credential" once nothing reads it.
+ "device_credential": dev.Credential, // returned exactly once
+ "credential": dev.Credential, // deprecated alias, see above
})
}
@@ -662,3 +671,16 @@ func maxOrEmpty(r compat.Range) string {
}
return r.Max.String()
}
+
+// EnrollmentLink builds the §2.1 bootstrap string for a freshly minted token.
+//
+// The server assembles it rather than the operator, because it is the only party that knows all
+// three parts at once — its own URL, its own SPKI pin, and the token. An operator copying a pin
+// by hand is the step that goes wrong, and a pin wrong by one character does not fail loudly.
+func (s *Server) EnrollmentLink(token string) string {
+ u := s.PublicControlURL
+ return "echolot://enroll?v=1" +
+ "&u=" + url.QueryEscape(strings.TrimRight(u, "/")) +
+ "&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) +
+ "&t=" + url.QueryEscape(token)
+}