Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5291bdd045 | ||
|
|
fe3658e009 | ||
|
|
ad85f3bfcd | ||
|
|
8166611af1 | ||
|
|
33a6acb0bf |
@@ -43,3 +43,6 @@ web/.wrangler/
|
|||||||
|
|
||||||
# Eclipse/JDT output from the VSCodium Java extension — not a build artifact we own
|
# Eclipse/JDT output from the VSCodium Java extension — not a build artifact we own
|
||||||
echolot-app/*/bin/
|
echolot-app/*/bin/
|
||||||
|
|
||||||
|
# Kotlin compiler scratch/error logs
|
||||||
|
echolot-app/.kotlin/
|
||||||
|
|||||||
@@ -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.
|
- 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
|
- 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.
|
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" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
// starts a run immediately and uploads the report, so an unattended
|
// starts a run immediately and uploads the report, so an unattended
|
||||||
// measurement needs no UI tapping and no adb round-trip to collect.
|
// measurement needs no UI tapping and no adb round-trip to collect.
|
||||||
val autorun = intent?.getBooleanExtra("autorun", false) == true
|
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) {
|
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||||
if (autorun) vm.run(devUpload = true)
|
if (autorun) vm.run(devUpload = true)
|
||||||
}
|
}
|
||||||
@@ -89,6 +100,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCheckServer = vm::checkServer,
|
onCheckServer = vm::checkServer,
|
||||||
|
onEnroll = vm::enroll,
|
||||||
serverStatus = vm.state.archiveStatus,
|
serverStatus = vm.state.archiveStatus,
|
||||||
onBack = { screen = Screen.RUN },
|
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.
|
* 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()
|
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. */
|
/** Settings-screen action: report what the configured server is and whether we can use it. */
|
||||||
fun checkServer() {
|
fun checkServer() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ fun SettingsScreen(
|
|||||||
onDeleteAll: () -> Unit,
|
onDeleteAll: () -> Unit,
|
||||||
onPreviewUpload: () -> Unit,
|
onPreviewUpload: () -> Unit,
|
||||||
onCheckServer: () -> Unit,
|
onCheckServer: () -> Unit,
|
||||||
|
onEnroll: (String) -> Unit,
|
||||||
serverStatus: String?,
|
serverStatus: String?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -59,6 +60,7 @@ fun SettingsScreen(
|
|||||||
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
||||||
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
||||||
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
||||||
|
var enrollLink by remember { mutableStateOf("") }
|
||||||
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
|
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
|
||||||
var serverPin by remember { mutableStateOf(settings.serverPin) }
|
var serverPin by remember { mutableStateOf(settings.serverPin) }
|
||||||
var serverCred by remember { mutableStateOf(settings.serverCredential) }
|
var serverCred by remember { mutableStateOf(settings.serverCredential) }
|
||||||
@@ -151,6 +153,32 @@ fun SettingsScreen(
|
|||||||
checked = autoUpload,
|
checked = autoUpload,
|
||||||
) { autoUpload = it; settings.autoUpload = it }
|
) { 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(
|
OutlinedTextField(
|
||||||
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
|
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
|
||||||
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -25,6 +25,6 @@ java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaV
|
|||||||
|
|
||||||
tasks.test {
|
tasks.test {
|
||||||
useJUnitPlatform()
|
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) } }
|
.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)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
package app.echo_lot.protocol
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
|
||||||
@@ -59,13 +61,16 @@ class ControlClient(
|
|||||||
if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200))
|
if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pulls the "error" string out of a JSON body without pulling in a parser for one field. */
|
/**
|
||||||
private fun extractError(body: String): String? =
|
* Pulls the "error" string out of a JSON body.
|
||||||
Regex(""""error"\s*:\s*"((?:[^"\\]|\\.)*)"""").find(body)
|
*
|
||||||
?.groupValues?.get(1)
|
* Parsed rather than pattern-matched: an encoder may legitimately escape characters in the
|
||||||
?.replace("\\\"", "\"")
|
* message (Go escapes ">" by default), and a regex hands the user "needs \u003e= 0.2.0".
|
||||||
?.replace("\\n", "\n")
|
* The parser knows how to undo every escape; a regex would have to be taught each one.
|
||||||
?.replace("\\\\", "\\")
|
*/
|
||||||
|
private fun extractError(body: String): String? = runCatching {
|
||||||
|
json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.content
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the response body, and turns a 426 into [VersionRefused] first.
|
* Reads the response body, and turns a 426 into [VersionRefused] first.
|
||||||
|
|||||||
@@ -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
|
@Serializable
|
||||||
data class EnrollResponse(
|
data class EnrollResponse(
|
||||||
@SerialName("device_id") val deviceId: String,
|
@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
|
@Serializable
|
||||||
data class Target(
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,13 +19,23 @@ UDP_PORT="${ECHOLOT_UDP_PORT:-8442}"
|
|||||||
CTL_URL="https://${CTL_HOST}:${CTL_PORT}"
|
CTL_URL="https://${CTL_HOST}:${CTL_PORT}"
|
||||||
|
|
||||||
echo "· minting enrollment token on ${SSH_HOST} ..."
|
echo "· minting enrollment token on ${SSH_HOST} ..."
|
||||||
TOKEN=$(ssh -o BatchMode=yes "$SSH_HOST" \
|
MINTED=$(ssh -o BatchMode=yes "$SSH_HOST" \
|
||||||
'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens' \
|
'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens')
|
||||||
| python -c 'import json,sys;print(json.load(sys.stdin)["token"])')
|
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} ..."
|
echo "· enrolling over ${CTL_URL} ..."
|
||||||
CRED=$(curl -sk -X POST "${CTL_URL}/v1/enroll" -H "Authorization: Bearer ${TOKEN}" \
|
# A second token, because the one above is single-use and may be spent by LiveEnrollmentTest.
|
||||||
| python -c 'import json,sys;print(json.load(sys.stdin)["credential"])')
|
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 ..."
|
echo "· computing SPKI pin from served cert ..."
|
||||||
PIN=$(echo | openssl s_client -connect "${CTL_HOST}:${CTL_PORT}" 2>/dev/null \
|
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_CRED="$CRED" \
|
||||||
ECHOLOT_LIVE_UDP="${CTL_HOST}:${UDP_PORT}" \
|
ECHOLOT_LIVE_UDP="${CTL_HOST}:${UDP_PORT}" \
|
||||||
ECHOLOT_LIVE_TARGET="${ECHOLOT_LIVE_TARGET:-fmr}" \
|
ECHOLOT_LIVE_TARGET="${ECHOLOT_LIVE_TARGET:-fmr}" \
|
||||||
|
ECHOLOT_ENROLL_URI="$ENROLL_URI" \
|
||||||
./gradlew "$TASK" --tests "$FILTER" --info --rerun-tasks --console=plain \
|
./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
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -151,6 +152,7 @@ func serve(cfg *config.Config) error {
|
|||||||
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
|
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
|
||||||
Runs: runStore,
|
Runs: runStore,
|
||||||
AppRange: appRange,
|
AppRange: appRange,
|
||||||
|
PublicControlURL: publicControlURL(cfg),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
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)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
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}
|
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
||||||
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
|
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)
|
slog.Info("generated self-signed certificate", "cert", certPath)
|
||||||
return tls.X509KeyPair(certPem, keyPem)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ type Config struct {
|
|||||||
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
|
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
|
||||||
MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive)
|
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
|
// Mode
|
||||||
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
|
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.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.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.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.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.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)")
|
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -72,6 +73,10 @@ type Server struct {
|
|||||||
// server, not the client.
|
// server, not the client.
|
||||||
ProvenGood func() (mtuOK, sysctlOK bool)
|
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
|
// AppRange is the app-version window this server will serve. Zero value means the built-in
|
||||||
// default (see DefaultAppRange).
|
// default (see DefaultAppRange).
|
||||||
AppRange compat.Range
|
AppRange compat.Range
|
||||||
@@ -430,7 +435,12 @@ func bearer(r *http.Request) string {
|
|||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
enc := json.NewEncoder(w)
|
||||||
|
// Go escapes <, > and & by default, for JSON embedded in HTML. This is an API, and the
|
||||||
|
// escaping is actively harmful here: a refusal message reading "needs >= 0.2.0" is what
|
||||||
|
// the user ends up seeing. Nothing we emit is ever interpolated into a page.
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
_ = enc.Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// enroll redeems a single-use enrollment token for a device credential (§2.1).
|
// enroll redeems a single-use enrollment token for a device credential (§2.1).
|
||||||
@@ -452,7 +462,11 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
|
|||||||
slog.Info("device enrolled", "device", dev.ID, "name", dev.Name)
|
slog.Info("device enrolled", "device", dev.ID, "name", dev.Name)
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{
|
writeJSON(w, http.StatusCreated, map[string]string{
|
||||||
"device_id": dev.ID,
|
"device_id": dev.ID,
|
||||||
"credential": dev.Credential, // returned exactly once
|
// 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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,3 +671,16 @@ func maxOrEmpty(r compat.Range) string {
|
|||||||
}
|
}
|
||||||
return r.Max.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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user