server: send granted traffic from the address the session actually used
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s

fmr binds two IPv4 addresses. connFor picked whichever socket of the right
family came first in the bind list, so a downtrain for a session established on
.150 went out from .151 — and every packet was dropped by the client's NAT,
which has no mapping for that pair. tcpdump on the server showed all 50 leaving;
the client saw none. Read as "100% downstream loss", which is the worst kind of
wrong: a confident measurement of something that never happened.

Sessions now record which of our own bound addresses received their traffic, and
granted sends (and delayed echo) go back out through that socket. The fallback
to a family match is kept for the case where nothing has been received yet, and
the test pins both paths — a single-homed lab can never reproduce this.

Also: the client-side halves of the same work — anonymizer (core-privacy), local
run archive with retention (core-archive), upload client, and the app's settings
and history screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 10:45:43 +02:00
co-authored by Claude Fable 5
parent 7a94c9a3d7
commit ce1aaa332a
31 changed files with 2018 additions and 50 deletions
@@ -17,6 +17,9 @@ import javax.net.ssl.HttpsURLConnection
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443"
* @param pins the `pin-sha256` value(s) from the enrollment QR (base64, no prefix)
*/
/** The server declined to store this run, per the operator's policy. Not a transport failure. */
class UploadRefused(message: String) : Exception(message)
class ControlClient(private val controlUrl: String, pins: Set<String>) {
private val json = Json { ignoreUnknownKeys = true }
@@ -93,6 +96,44 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
return body
}
/**
* Uploads one measurement document. The body is sent exactly as given — whatever the
* anonymizer produced is what the server stores, so what the user was shown is what left
* the device. Returns the server's index entry as raw JSON.
*
* A refusal is not an error condition to retry: 403 means the operator's policy says no
* (uploads off, accounts required, or not anonymized enough), so it is surfaced as
* [UploadRefused] for the caller to show rather than swallow.
*/
fun uploadRun(credential: String, documentJson: String): String {
val conn = open("/v1/runs", "POST", credential)
writeJson(conn, documentJson)
val body = body(conn)
when (conn.responseCode) {
in 200..299 -> return body
403 -> throw UploadRefused(body)
413 -> throw UploadRefused("run is larger than this server accepts: $body")
else -> error("upload failed: ${conn.responseCode} $body")
}
}
/** Lists this device's runs stored on the server. */
fun listRuns(credential: String): String {
val conn = open("/v1/runs", "GET", credential)
check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode}" }
return body(conn)
}
fun getRun(credential: String, runId: String): String {
val conn = open("/v1/runs/$runId", "GET", credential)
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode}" }
return body(conn)
}
fun deleteRun(credential: String, runId: String) {
open("/v1/runs/$runId", "DELETE", credential).responseCode
}
fun observations(credential: String, sessionId: String): String {
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
@@ -32,6 +32,30 @@ data class SelfTest(
@SerialName("sysctl_ok") val sysctlOk: Boolean? = null,
)
/**
* The operator's upload rules, advertised in the profile so the app can present the choice
* honestly — greyed out with a reason when the server refuses, and pre-set to the server's
* minimum anonymization when it accepts — instead of discovering the policy by being rejected.
*/
@Serializable
data class UploadPolicy(
val mode: String = "off",
@SerialName("max_bytes") val maxBytes: Long = 0,
@SerialName("retention_days") val retentionDays: Int = 0,
@SerialName("max_runs_per_device") val maxRunsPerDevice: Int = 0,
@SerialName("min_anonymization") val minAnonymization: String = "full",
val reason: String? = null,
) {
val accepted: Boolean get() = mode == "anonymous" || mode == "account"
/** Why uploads are unavailable, in words a user can act on. */
fun refusalReason(): String? = when (mode) {
"off" -> reason ?: "This server does not accept uploaded runs."
"account" -> "This server only accepts uploads from signed-in accounts."
else -> null
}
}
@Serializable
data class Profile(
@SerialName("profile_version") val profileVersion: Int = 0,
@@ -42,6 +66,7 @@ data class Profile(
@SerialName("canary_zone") val canaryZone: String = "",
@SerialName("server_selftest") val serverSelftest: SelfTest? = null,
val pins: List<String> = emptyList(),
val uploads: UploadPolicy = UploadPolicy(),
) {
fun supports(capability: String) = capability in capabilities
}