Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Fable 5 5291bdd045 enrollment: actually emit enroll_uri from the admin endpoint
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 31s
The previous commit's edit to the admin handler silently did not apply, so the
endpoint still returned just the token. Caught by deploying and looking at the
response rather than by trusting the build to have picked it up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:08:11 +02:00
mrambossekandClaude Fable 5 fe3658e009 chore: ignore the Kotlin compiler's .kotlin scratch directory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:06:47 +02:00
mrambossekandClaude Fable 5 ad85f3bfcd enrollment: the server mints the §2.1 bootstrap link, the app consumes it
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
POST /admin/enroll-tokens now returns the whole link, not just the token:

  echolot://enroll?v=1&u=<control URL>&p=pin-sha256:<b64>&t=<token>

The server is the only party that knows all three parts at once, and the part
an operator gets wrong by hand is the base64 pin — which does not fail loudly,
it just never matches, surfacing days later as an inscrutable TLS error. The
app takes the link from a paste or from an echolot:// deep link (QR scan), and
writes URL, pin and credential together or not at all.

One trap the tests pin: an unencoded "+" in a query string decodes to a space,
so a hand-assembled link arrives with a pin wrong by one character. Base64 has
no spaces, so they are restored — unambiguous, and it cannot damage a correctly
encoded pin.

Also fixes a spec divergence: §2.1 names the field device_credential and the
first implementation shipped "credential". Both are sent now and the client
prefers the spec's; the alias goes once nothing reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:06:22 +02:00
mrambossekandClaude Fable 5 8166611af1 docs: record the compatibility-window work in build-status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:43:02 +02:00
16 changed files with 511 additions and 18 deletions
+3
View File
@@ -43,3 +43,6 @@ web/.wrangler/
# Eclipse/JDT output from the VSCodium Java extension — not a build artifact we own
echolot-app/*/bin/
# Kotlin compiler scratch/error logs
echolot-app/.kotlin/
+39
View File
@@ -621,3 +621,42 @@ finding code and the metrics survive, then deleted it.
- Accounts/OIDC on the server, which is what `uploads=account` is waiting for.
- Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC
against the live server. On-device verification is the next step.
### SemVer compatibility windows between app and server (server-v0.5.0 … v0.5.2, 2026-08-01)
Both artifacts are SemVer, and each now declares — and enforces — which peer versions it will talk
to. Spec: `docs/probe-protocol.md` §8.
**Two axes, deliberately not conflated.** Release versions are a *proxy* for what actually has to
match, so the real thing is checked first:
- `protocol_version` — **can** these builds talk. Advertised in the profile; a peer in a different
breaking series is refused whatever its release version says. Below 1.0.0 the **minor** is the
breaking axis (SemVer §4).
- release-version window — **may** they, per policy. `[min, max)`, min inclusive, max exclusive,
because the useful bound is always "the version that broke it".
Bounds sit at breaking boundaries, not at releases, so shipping a patch never requires editing a
range. The app requires server `>= 0.4.2` for a stated reason, not caution: earlier multi-homed
servers mis-addressed granted sends and the client measured 100 % downstream loss that never
happened. Operators override the server side with `ECHOLOT_MIN_APP_VERSION` /
`ECHOLOT_MAX_APP_VERSION`; a malformed bound is fatal at startup rather than ignored, so a typo
cannot silently disable a restriction.
Three rules that shaped the implementation:
1. **`GET /v1/profile` is never gated.** It is where a refused client learns which version it needs;
gating it leaves the user with a network error instead of an answer.
2. **An unparseable or absent version is `unknown`, and is allowed.** Dev builds report `dev`, and a
client too old to send the header cannot be identified anyway.
3. **Refusal is 426 with a body naming both versions and the window**, surfaced client-side as a
distinct `VersionRefused` rather than folded into "network error".
The app's `versionCode` is now derived from its SemVer (`major*1e6 + minor*1e4 + patch*10`) instead
of being a second number to remember.
Verified live against fmr (`LiveCompatTest`): profile advertises the window and stays readable for a
refused version; 0.1.0 and 99.0.0 are both refused with actionable messages; 0.2.0 and a missing
header are both served.
One user-visible bug caught in the process: Go's JSON encoder HTML-escapes `<`, `>` and `&` by
default, so the refusal reached the client as `needs \u003e= 0.2.0`. Disabled at the encoder (this
is an API, not a page), and the client now *parses* the error field instead of pattern-matching it,
so it survives whatever a future encoder decides to escape.
@@ -27,6 +27,18 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Enrollment bootstrap (probe-protocol.md §2.1): echolot://enroll?v=1&u=…&p=…&t=…
Scanning a QR or tapping a link the operator sent configures the server in one
action, instead of transcribing a URL, a base64 pin and a token by hand — the pin
in particular fails silently when it is wrong by one character.
-->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="echolot" android:host="enroll" />
</intent-filter>
</activity>
<provider
@@ -59,6 +59,17 @@ class MainActivity : ComponentActivity() {
// starts a run immediately and uploads the report, so an unattended
// measurement needs no UI tapping and no adb round-trip to collect.
val autorun = intent?.getBooleanExtra("autorun", false) == true
// An echolot://enroll link (QR scan, or a link the operator sent) opens the
// app straight into settings with the enrollment already done, so the user
// sees the result rather than a form they still have to fill in.
val enrollUri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString
androidx.compose.runtime.LaunchedEffect(enrollUri) {
if (enrollUri != null) {
vm.enroll(enrollUri)
screen = Screen.SETTINGS
}
}
androidx.compose.runtime.LaunchedEffect(autorun) {
if (autorun) vm.run(devUpload = true)
}
@@ -89,6 +100,7 @@ class MainActivity : ComponentActivity() {
}
},
onCheckServer = vm::checkServer,
onEnroll = vm::enroll,
serverStatus = vm.state.archiveStatus,
onBack = { screen = Screen.RUN },
)
@@ -108,6 +108,35 @@ class RunStore(context: Context, private val settings: Settings) {
}
}
/**
* Redeems an enrollment link and stores the resulting server configuration (§2.1).
*
* Everything is written at once or not at all: a half-applied server — say a URL and pin with
* no credential — fails later, somewhere else, with an error that points at the wrong thing.
* Blocking; callers run it off the main thread.
*/
fun enroll(link: String, deviceName: String?): String {
val parsed = app.echo_lot.protocol.EnrollmentLink.parse(link)
?: return "That does not look like an Echolot enrollment link. It should start with " +
"echolot://enroll and carry a URL, a pin and a token."
return try {
val enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
val compat = Compat.check(enrolled.profile, BuildConfig.APP_SEMVER)
settings.serverUrl = enrolled.controlUrl
settings.serverPin = enrolled.pin
settings.serverCredential = enrolled.credential
val head = "Enrolled with ${enrolled.profile.name} " +
"(server ${enrolled.profile.serverVersion})."
if (compat.message != null) head + " " + compat.message else head
} catch (e: VersionRefused) {
"That server will not serve this app: ${e.message}"
} catch (t: Throwable) {
// The commonest causes are a spent token and a wrong pin, and they look nothing alike
// in the message — so pass it through rather than flattening it to "enrollment failed".
"Enrollment failed: ${t.message ?: t.javaClass.simpleName}"
}
}
/**
* Uploads one archived run to the configured server, redacting first.
*
@@ -196,6 +196,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
fun archivedBytes(): Long = store.totalBytes()
/** Redeems an enrollment link, from a paste or from an echolot:// deep link. */
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
viewModelScope.launch {
state = state.copy(archiveStatus = "enrolling …")
state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.enroll(link, deviceName) })
}
}
/** Settings-screen action: report what the configured server is and whether we can use it. */
fun checkServer() {
viewModelScope.launch {
@@ -47,6 +47,7 @@ fun SettingsScreen(
onDeleteAll: () -> 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(),
+1 -1
View File
@@ -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) } }
}
@@ -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)}")
}
}
}
@@ -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=<control-URL, urlencoded>&p=pin-sha256:<b64 SPKI hash>&t=<token>
* ```
*
* 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<String, String>()
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,
)
@@ -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(
@@ -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")
}
}
+17 -6
View File
@@ -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
+40 -7
View File
@@ -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)
@@ -232,7 +234,17 @@ func serve(cfg *config.Config) error {
http.Error(w, err.Error(), 500)
return
}
fmt.Fprintf(w, `{"token":%q,"expires_in_s":86400}`+"\n", tok)
// The whole bootstrap, not just the token: this is what gets pasted or turned into a
// QR code, and assembling it here is what keeps an operator from transcribing a pin by
// hand — a pin wrong by one character fails as an inscrutable TLS error days later.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false) // the link is full of / and =; escaping them helps nobody
_ = enc.Encode(map[string]any{
"token": tok,
"expires_in_s": 86400,
"enroll_uri": ctl.EnrollmentLink(tok),
})
})
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
@@ -446,3 +458,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
}
+5
View File
@@ -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)")
+24 -2
View File
@@ -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)
}