Compare commits

...
Author SHA1 Message Date
mrambossekandClaude Fable 5 ce1aaa332a 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>
2026-08-01 10:45:43 +02:00
31 changed files with 2018 additions and 50 deletions
+4 -2
View File
@@ -16,8 +16,8 @@ android {
applicationId = "app.echo_lot.app" applicationId = "app.echo_lot.app"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 2
versionName = "0.1.0" versionName = "0.2.0"
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true` // Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
// runs a measurement immediately and POSTs the report here (dev collection endpoint). // runs a measurement immediately and POSTs the report here (dev collection endpoint).
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"") buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
@@ -48,6 +48,8 @@ dependencies {
implementation(project(":core-engine")) implementation(project(":core-engine"))
implementation(project(":core-probe")) implementation(project(":core-probe"))
implementation(project(":core-shizuku")) implementation(project(":core-shizuku"))
implementation(project(":core-privacy"))
implementation(project(":core-archive"))
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.echo_lot.archive.ArchivedRun
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Archived runs, newest first.
*
* Each row states plainly whether the run left the device, because "is this backed up / did I
* share this?" is the question a history list actually gets asked.
*/
@Composable
fun HistoryScreen(
runs: List<ArchivedRun>,
status: String?,
onOpen: (String) -> Unit,
onUpload: (String) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onBack) { Text(" Back") }
Text("History", style = MaterialTheme.typography.titleLarge)
}
status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
if (runs.isEmpty()) {
Text(
"No archived runs yet. Finished runs are kept here automatically unless you turn " +
"archiving off in settings.",
style = MaterialTheme.typography.bodyMedium,
)
return@Column
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(runs, key = { it.id }) { r ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
r.verdict?.uppercase() ?: "",
color = verdictTint(r.verdict),
style = MaterialTheme.typography.titleMedium,
)
Text(
" " + humanTime(r.savedAtEpochMs),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
"${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · ${r.anonymization}",
style = MaterialTheme.typography.bodySmall,
)
Text(
if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}"
else "on this device only",
style = MaterialTheme.typography.bodySmall,
color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB),
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onOpen(r.id) }) { Text("Export") }
TextButton(onClick = { onUpload(r.id) }) {
Text(if (r.uploaded) "Upload again" else "Upload")
}
TextButton(onClick = { onDelete(r.id) }) { Text("Delete") }
}
}
}
}
}
}
}
private fun verdictTint(v: String?): Color = when (v?.lowercase()) {
"green", "ok", "pass" -> Color(0xFF7FD17F)
"yellow", "warn" -> Color(0xFFE0C060)
"red", "fail" -> Color(0xFFE07070)
else -> Color(0xFFBBBBBB)
}
private val stamp: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
private fun humanTime(epochMs: Long): String = stamp.format(Instant.ofEpochMilli(epochMs))
@@ -18,6 +18,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -26,9 +30,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import app.echo_lot.measurement.* import app.echo_lot.measurement.*
/** The app's three top-level screens. */
private enum class Screen { RUN, HISTORY, SETTINGS }
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val permissionLauncher = private val permissionLauncher =
@@ -41,13 +50,17 @@ class MainActivity : ComponentActivity() {
MaterialTheme(colorScheme = darkColorScheme()) { MaterialTheme(colorScheme = darkColorScheme()) {
Surface(color = MaterialTheme.colorScheme.background) { Surface(color = MaterialTheme.colorScheme.background) {
val vm: RunViewModel = viewModel() val vm: RunViewModel = viewModel()
// Three flat screens, so a plain state variable beats a navigation library:
// there is no back stack to model beyond "return to the run screen".
var screen by remember { mutableStateOf(Screen.RUN) }
var preview by remember { mutableStateOf<String?>(null) }
// Automation entry point: // Automation entry point:
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true // adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
// 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
androidx.compose.runtime.LaunchedEffect(autorun) { androidx.compose.runtime.LaunchedEffect(autorun) {
if (autorun) vm.run(upload = true) if (autorun) vm.run(devUpload = true)
} }
// In autorun the app is a batch job: once the run is done AND the upload // In autorun the app is a batch job: once the run is done AND the upload
// succeeded, show the result briefly, then close so the device is left as it // succeeded, show the result briefly, then close so the device is left as it
@@ -61,7 +74,42 @@ class MainActivity : ComponentActivity() {
finish() finish()
} }
} }
EcholotScreen( when (screen) {
Screen.SETTINGS -> SettingsScreen(
settings = vm.settings,
archivedRuns = vm.state.history.size,
archivedBytes = vm.archivedBytes(),
onApplyRetention = vm::applyRetention,
onDeleteAll = vm::deleteAllRuns,
onPreviewUpload = {
// Preview the newest run, since that is the one the user just made
// and the one they are deciding about.
vm.state.history.firstOrNull()?.let { r ->
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
}
},
onBack = { screen = Screen.RUN },
)
Screen.HISTORY -> HistoryScreen(
runs = vm.state.history,
status = vm.state.archiveStatus,
onOpen = { id ->
lifecycleScope.launch {
vm.readRun(id)?.let { text ->
startActivity(
Intent.createChooser(
Report.shareJson(this@MainActivity, id, text),
"Export Echolot run",
)
)
}
}
},
onUpload = vm::uploadRun,
onDelete = vm::deleteRun,
onBack = { screen = Screen.RUN },
)
Screen.RUN -> EcholotScreen(
state = vm.state, state = vm.state,
onRun = { vm.run() }, onRun = { vm.run() },
onCancel = vm::cancel, onCancel = vm::cancel,
@@ -86,7 +134,13 @@ class MainActivity : ComponentActivity() {
} }
}, },
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) }, onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
) onOpenSettings = { screen = Screen.SETTINGS },
onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY },
)
}
preview?.let { text ->
UploadPreviewDialog(text) { preview = null }
}
} }
} }
} }
@@ -123,6 +177,8 @@ private fun EcholotScreen(
onShizukuAction: () -> Unit, onShizukuAction: () -> Unit,
onDeveloperOptions: () -> Unit, onDeveloperOptions: () -> Unit,
onExport: (MeasurementDocument) -> Unit, onExport: (MeasurementDocument) -> Unit,
onOpenSettings: () -> Unit,
onOpenHistory: () -> Unit,
) { ) {
Column( Column(
Modifier Modifier
@@ -135,8 +191,15 @@ private fun EcholotScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) Column(Modifier.weight(1f)) {
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Text("measure, don't guess",
color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
}
TextButton(onClick = onOpenHistory) { Text("History") }
TextButton(onClick = onOpenSettings) { Text("Settings") }
}
// Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed — // Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed —
// only users who actually use it get reminded that it must be running. // only users who actually use it get reminded that it must be running.
@@ -185,6 +248,10 @@ private fun EcholotScreen(
} }
} }
state.archiveStatus?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
state.uploadStatus?.let { state.uploadStatus?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
@@ -338,3 +405,24 @@ private fun Dot(color: Color) {
private fun SectionTitle(text: String) { private fun SectionTitle(text: String) {
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
} }
/**
* Shows the exact JSON an upload would send.
*
* This exists because an anonymizer the user cannot inspect is just a promise. Being able to
* read the outgoing document — and find their own SSID absent from it — is what makes the
* privacy setting checkable rather than merely stated.
*/
@Composable
private fun UploadPreviewDialog(text: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
title = { Text("This is what would be uploaded") },
text = {
Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) {
Text(text, fontSize = 10.sp, fontFamily = FontFamily.Monospace)
}
},
)
}
@@ -17,10 +17,18 @@ object Report {
fun toJson(doc: MeasurementDocument): String = fun toJson(doc: MeasurementDocument): String =
json.encodeToString(MeasurementDocument.serializer(), doc) json.encodeToString(MeasurementDocument.serializer(), doc)
fun share(ctx: Context, doc: MeasurementDocument): Intent { fun share(ctx: Context, doc: MeasurementDocument): Intent =
shareJson(ctx, doc.run.id, toJson(doc))
/**
* Shares an already-serialized run — an archived one, whose bytes must go out exactly as
* stored rather than being re-serialized through the model (which would silently drop
* anything a newer schema version added).
*/
fun shareJson(ctx: Context, runId: String, json: String): Intent {
val dir = File(ctx.cacheDir, "reports").apply { mkdirs() } val dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
val file = File(dir, "echolot-run-${doc.run.id}.json") val file = File(dir, "echolot-run-$runId.json")
file.writeText(toJson(doc)) file.writeText(json)
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file) val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
return Intent(Intent.ACTION_SEND).apply { return Intent(Intent.ACTION_SEND).apply {
type = "application/json" type = "application/json"
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import app.echo_lot.archive.ArchivedRun
import app.echo_lot.archive.RunArchive
import app.echo_lot.measurement.MeasurementDocument
import app.echo_lot.privacy.Anonymizer
import app.echo_lot.privacy.PrivacyLevel
import app.echo_lot.privacy.Salt
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.UploadRefused
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.io.File
import java.security.SecureRandom
/**
* Ties together the three things that happen to a finished run: it gets archived, it may get
* anonymized, and it may get uploaded — in that order, and with the archive always holding the
* *unredacted* document.
*
* That ordering is the important decision. The local archive is the user's own data on their own
* device, and redacting it would destroy exactly the detail that makes a week-old run worth
* keeping; the anonymizer exists for the moment data crosses to someone else's machine. So
* redaction happens on the way out, per upload, and the archive is never the lossy copy.
*/
class RunStore(context: Context, private val settings: Settings) {
private val archive = RunArchive(File(context.filesDir, "runs"))
private val json = Json { encodeDefaults = true; explicitNulls = true }
fun list(): List<ArchivedRun> = archive.list()
fun read(id: String): String? = archive.read(id)
fun delete(id: String) = archive.delete(id)
fun deleteAll(): Int = archive.deleteAll()
fun totalBytes(): Long = archive.totalBytes()
/** Archives a finished run under the user's retention policy. Null when archiving is off. */
fun archive(doc: MeasurementDocument): ArchivedRun? =
archive.save(Report.toJson(doc), settings.retention())
/** Applies retention now — e.g. after the user tightens the limits in settings. */
fun purgeNow() = archive.purge(settings.retention())
/**
* Produces exactly the bytes an upload would send, so the UI can show the user their own
* document as the server will see it *before* it goes. "Preview what you're about to share"
* is the only honest way to present an anonymizer: its correctness is not something a user
* should have to take on faith.
*/
fun redactedForUpload(docJson: String, level: PrivacyLevel = settings.privacyLevel): String {
val parsed = runCatching { json.parseToJsonElement(docJson).jsonObject }.getOrNull()
?: return docJson
return json.encodeToString(JsonObject.serializer(), Anonymizer(level, salt()).anonymize(parsed))
}
private fun salt(): Salt =
if (settings.stableSalt) Salt.stable(settings.saltSecret())
else Salt.perRun(ByteArray(32).also { SecureRandom().nextBytes(it) })
sealed interface UploadOutcome {
data class Sent(val serverName: String, val detail: String) : UploadOutcome
/** The operator's policy says no. Not retryable, and not the user's fault. */
data class Refused(val reason: String) : UploadOutcome
data class Failed(val detail: String) : UploadOutcome
data object NotConfigured : UploadOutcome
}
/**
* Uploads one archived run to the configured server, redacting first.
*
* The server's advertised minimum wins over the user's preference when it is stricter — a
* server may demand more anonymization than the user chose, never less. Blocking; callers
* run it off the main thread.
*/
fun upload(runId: String): UploadOutcome {
if (!settings.serverConfigured) return UploadOutcome.NotConfigured
val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive")
return try {
val client = ControlClient(settings.serverUrl, setOf(settings.serverPin))
val profile = client.profile(settings.serverCredential)
profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) }
val level = PrivacyLevel.max(
settings.privacyLevel,
PrivacyLevel.fromWire(profile.uploads.minAnonymization),
)
val body = redactedForUpload(docJson, level)
val reply = client.uploadRun(settings.serverCredential, body)
archive.markUploaded(runId, profile.name)
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}")
} catch (e: UploadRefused) {
UploadOutcome.Refused(e.message ?: "refused by the server")
} catch (t: Throwable) {
UploadOutcome.Failed(t.message ?: t.javaClass.simpleName)
}
}
}
@@ -38,6 +38,10 @@ data class UiState(
val stepsDone: Int = 0, val stepsDone: Int = 0,
val stepsTotal: Int = 0, val stepsTotal: Int = 0,
val etaSeconds: Int = 0, val etaSeconds: Int = 0,
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
val archiveStatus: String? = null,
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
val history: List<app.echo_lot.archive.ArchivedRun> = emptyList(),
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */ /** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
val shizukuNotice: String? = null, val shizukuNotice: String? = null,
val shizukuReady: Boolean = false, val shizukuReady: Boolean = false,
@@ -56,6 +60,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
var state by mutableStateOf(UiState()) var state by mutableStateOf(UiState())
private set private set
val settings = Settings(app)
private val store = RunStore(app, settings)
private var runJob: kotlinx.coroutines.Job? = null private var runJob: kotlinx.coroutines.Job? = null
private val stopShizukuObserver: () -> Unit private val stopShizukuObserver: () -> Unit
@@ -92,22 +99,111 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
} }
/** /**
* Runs one measurement. With [upload] (autorun mode) the finished document is POSTed to the * Runs one measurement, then archives it and — if the user has turned that on — uploads it.
* collection endpoint so an unattended run can be retrieved without adb. *
* [devUpload] is the separate autorun/adb path (BuildConfig collection endpoint), kept apart
* from the user-facing upload so a debugging convenience can never be mistaken for, or
* silently satisfy, the consent-gated one.
*/ */
fun run(upload: Boolean = false) { fun run(devUpload: Boolean = false) {
if (state.running) return if (state.running) return
collected.clear() collected.clear()
state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null) state = state.copy(running = true, currentStep = "starting", document = null,
uploadStatus = null, archiveStatus = null)
runJob = viewModelScope.launch { runJob = viewModelScope.launch {
val doc = withContext(Dispatchers.IO) { measure() } val doc = withContext(Dispatchers.IO) { measure() }
step("archiving")
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
var archiveStatus = if (archived != null) {
"archived locally (${store.list().size} runs kept)"
} else {
"not archived — archiving is off in settings"
}
var status: String? = null var status: String? = null
if (upload) { if (devUpload) {
state = state.copy(currentStep = "uploading report") state = state.copy(currentStep = "uploading report")
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) } val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}" status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
} }
state = UiState(running = false, currentStep = null, document = doc, uploadStatus = status) if (archived != null && settings.autoUpload) {
state = state.copy(currentStep = "uploading to server")
val outcome = withContext(Dispatchers.IO) { store.upload(archived.id) }
archiveStatus += " · " + describe(outcome)
}
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = status, archiveStatus = archiveStatus,
history = withContext(Dispatchers.IO) { store.list() },
)
}
}
private fun describe(o: RunStore.UploadOutcome): String = when (o) {
is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})"
is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}"
is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}"
RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded"
}
// ---- history ---------------------------------------------------------------------
fun refreshHistory() {
viewModelScope.launch {
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
}
}
fun deleteRun(id: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) { store.delete(id) }
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
}
}
fun deleteAllRuns() {
viewModelScope.launch {
val n = withContext(Dispatchers.IO) { store.deleteAll() }
state = state.copy(
history = emptyList(),
archiveStatus = "deleted $n archived run(s)",
)
}
}
/** Uploads one already-archived run on demand, regardless of the auto-upload setting. */
fun uploadRun(id: String) {
viewModelScope.launch {
state = state.copy(archiveStatus = "uploading …")
val outcome = withContext(Dispatchers.IO) { store.upload(id) }
state = state.copy(
archiveStatus = describe(outcome),
history = withContext(Dispatchers.IO) { store.list() },
)
}
}
/** The archived document as stored, for export. */
suspend fun readRun(id: String): String? = withContext(Dispatchers.IO) { store.read(id) }
/** The exact bytes an upload would send, for the settings screen's preview. */
suspend fun uploadPreview(id: String): String? = withContext(Dispatchers.IO) {
store.read(id)?.let { store.redactedForUpload(it) }
}
fun archivedBytes(): Long = store.totalBytes()
/** Re-applies retention after the user changes the limits. */
fun applyRetention() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { store.purgeNow() }
state = state.copy(
history = withContext(Dispatchers.IO) { store.list() },
archiveStatus = if (result.isEmpty) "nothing to purge"
else "purged ${result.removed.size} run(s), freed ${result.freedBytes / 1024} kB",
)
} }
} }
@@ -122,6 +218,8 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
state = UiState( state = UiState(
running = false, currentStep = null, document = doc, running = false, currentStep = null, document = doc,
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded", uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
archiveStatus = "partial run — not archived",
history = state.history,
) )
} }
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import android.content.SharedPreferences
import app.echo_lot.archive.RetentionPolicy
import app.echo_lot.privacy.PrivacyLevel
import java.security.SecureRandom
/**
* User settings for archiving, uploading and anonymization.
*
* Defaults are the conservative reading of "an engineer's tool that still respects the person
* holding it": keep history (that is the point of the archive), never upload without being asked,
* and when uploading, strip identifiers unless the user says this is their own server.
*
* SharedPreferences rather than DataStore because these are a dozen scalars read synchronously at
* the start of a run; a coroutine-flow store would add a dependency and a lifecycle for nothing.
*/
class Settings(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
// ---- archive ---------------------------------------------------------------------
var archiveEnabled: Boolean
get() = prefs.getBoolean(ARCHIVE_ENABLED, true)
set(v) = prefs.edit().putBoolean(ARCHIVE_ENABLED, v).apply()
/** 0 = no ceiling. */
var maxRuns: Int
get() = prefs.getInt(MAX_RUNS, 100)
set(v) = prefs.edit().putInt(MAX_RUNS, v.coerceAtLeast(0)).apply()
var maxAgeDays: Int
get() = prefs.getInt(MAX_AGE_DAYS, 90)
set(v) = prefs.edit().putInt(MAX_AGE_DAYS, v.coerceAtLeast(0)).apply()
var maxTotalMb: Int
get() = prefs.getInt(MAX_TOTAL_MB, 64)
set(v) = prefs.edit().putInt(MAX_TOTAL_MB, v.coerceAtLeast(0)).apply()
fun retention(): RetentionPolicy = RetentionPolicy(
enabled = archiveEnabled,
maxRuns = maxRuns,
maxAgeDays = maxAgeDays,
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
)
// ---- upload ----------------------------------------------------------------------
/**
* Off by default. Measurement data describes the network the user is standing in; sending it
* anywhere is a decision they make, not one they discover after the fact.
*/
var autoUpload: Boolean
get() = prefs.getBoolean(AUTO_UPLOAD, false)
set(v) = prefs.edit().putBoolean(AUTO_UPLOAD, v).apply()
/** Anonymization applied before a run leaves the device. Never applied to the local archive. */
var privacyLevel: PrivacyLevel
get() = PrivacyLevel.fromWire(prefs.getString(PRIVACY_LEVEL, PrivacyLevel.BALANCED.wire))
set(v) = prefs.edit().putString(PRIVACY_LEVEL, v.wire).apply()
/**
* Whether pseudonyms stay stable across runs. That makes history diffable ("same SSID as
* last week") and is what someone wants on their own server — but it also produces an
* identifier that links a device's uploads, so it is off unless chosen.
*/
var stableSalt: Boolean
get() = prefs.getBoolean(STABLE_SALT, false)
set(v) = prefs.edit().putBoolean(STABLE_SALT, v).apply()
/**
* The device-local secret behind stable pseudonyms. Generated once, never leaves the device,
* and clearing it (via [resetSalt]) breaks the link to everything uploaded before.
*/
fun saltSecret(): ByteArray {
prefs.getString(SALT_SECRET, null)?.let { return hex(it) }
val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) }
prefs.edit().putString(SALT_SECRET, fresh.joinToString("") { "%02x".format(it) }).apply()
return fresh
}
fun resetSalt() = prefs.edit().remove(SALT_SECRET).apply()
// ---- server ----------------------------------------------------------------------
var serverUrl: String
get() = prefs.getString(SERVER_URL, "") ?: ""
set(v) = prefs.edit().putString(SERVER_URL, v.trim()).apply()
var serverPin: String
get() = prefs.getString(SERVER_PIN, "") ?: ""
set(v) = prefs.edit().putString(SERVER_PIN, v.trim()).apply()
var serverCredential: String
get() = prefs.getString(SERVER_CRED, "") ?: ""
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
val serverConfigured: Boolean
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
private fun hex(s: String) = ByteArray(s.length / 2) {
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
}
private companion object {
const val ARCHIVE_ENABLED = "archive_enabled"
const val MAX_RUNS = "archive_max_runs"
const val MAX_AGE_DAYS = "archive_max_age_days"
const val MAX_TOTAL_MB = "archive_max_total_mb"
const val AUTO_UPLOAD = "auto_upload"
const val PRIVACY_LEVEL = "privacy_level"
const val STABLE_SALT = "stable_salt"
const val SALT_SECRET = "salt_secret"
const val SERVER_URL = "server_url"
const val SERVER_PIN = "server_pin"
const val SERVER_CRED = "server_credential"
}
}
@@ -0,0 +1,214 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import app.echo_lot.privacy.PrivacyLevel
/**
* Archiving, upload and anonymization settings.
*
* The screen is written to make the consequences legible rather than to look tidy: every toggle
* says what it means for the user's data in a sentence, and the privacy levels are described by
* what survives them, because "balanced" on its own tells nobody anything.
*/
@Composable
fun SettingsScreen(
settings: Settings,
archivedRuns: Int,
archivedBytes: Long,
onApplyRetention: () -> Unit,
onDeleteAll: () -> Unit,
onPreviewUpload: () -> Unit,
onBack: () -> Unit,
) {
// SharedPreferences is not observable, so mirror each value into Compose state and write
// through on change. A dozen scalars; a store with flows would be ceremony for nothing.
var archiveEnabled by remember { mutableStateOf(settings.archiveEnabled) }
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
var privacy by remember { mutableStateOf(settings.privacyLevel) }
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
var serverPin by remember { mutableStateOf(settings.serverPin) }
var serverCred by remember { mutableStateOf(settings.serverCredential) }
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onBack) { Text(" Back") }
Text("Settings", style = MaterialTheme.typography.titleLarge)
}
// ---- archive ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Archive", style = MaterialTheme.typography.titleMedium)
Toggle(
label = "Keep finished runs on this device",
detail = "History is what makes a run comparable later. Archived runs are " +
"stored complete and unredacted — anonymization only applies to uploads.",
checked = archiveEnabled,
) { archiveEnabled = it; settings.archiveEnabled = it }
Text(
"Purge automatically when a run exceeds any of these. 0 turns that limit off.",
style = MaterialTheme.typography.bodySmall,
)
NumberField("Keep at most (runs)", maxRuns) {
maxRuns = it; settings.maxRuns = it.toIntOrNull() ?: 0
}
NumberField("Delete older than (days)", maxAgeDays) {
maxAgeDays = it; settings.maxAgeDays = it.toIntOrNull() ?: 0
}
NumberField("Keep at most (MB)", maxTotalMb) {
maxTotalMb = it; settings.maxTotalMb = it.toIntOrNull() ?: 0
}
Text(
"$archivedRuns run(s), ${archivedBytes / 1024} kB stored",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onApplyRetention) { Text("Apply now") }
TextButton(onClick = onDeleteAll) { Text("Delete all runs") }
}
}
}
// ---- privacy ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("What leaves the device", style = MaterialTheme.typography.titleMedium)
Text(
"Applied to uploads only. Measurements, verdicts and finding codes survive " +
"every level — only the parts that identify you or your network change.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (level in PrivacyLevel.entries) {
FilterChip(
selected = privacy == level,
onClick = { privacy = level; settings.privacyLevel = level },
label = { Text(level.wire) },
)
}
}
Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall)
Toggle(
label = "Stable pseudonyms across runs",
detail = "Lets you compare uploaded runs over time (same SSID reads the same " +
"each time). It also links your uploads together, so leave it off on a " +
"server you don't run yourself.",
checked = stableSalt,
) { stableSalt = it; settings.stableSalt = it }
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
}
}
// ---- upload ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Upload", style = MaterialTheme.typography.titleMedium)
Toggle(
label = "Upload finished runs automatically",
detail = "Sends each completed run to the server below, anonymized to the " +
"level above. The server may require more anonymization than you chose; " +
"it can never require less.",
checked = autoUpload,
) { autoUpload = it; settings.autoUpload = it }
OutlinedTextField(
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = serverPin, onValueChange = { serverPin = it; settings.serverPin = it },
label = { Text("Certificate pin (SPKI, base64)") }, singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = serverCred,
onValueChange = { serverCred = it; settings.serverCredential = it },
label = { Text("Device credential") }, singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(),
)
Text(
if (settings.serverConfigured) "Server configured."
else "Uploads stay off until all three fields are set.",
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(Modifier.height(24.dp))
}
}
private fun privacyExplanation(level: PrivacyLevel): String = when (level) {
PrivacyLevel.FULL ->
"Nothing is removed: SSIDs, MAC addresses, hostnames and discovered neighbours are sent " +
"as measured. Appropriate for a server you run yourself."
PrivacyLevel.BALANCED ->
"Network names and hostnames become pseudonyms, MAC addresses keep only their vendor " +
"prefix, public IP addresses keep only their /16, and discovered neighbours (SSDP, " +
"ARP, nearby networks) are dropped entirely. Private addresses stay readable, since " +
"192.168.1.1 describes the topology and not the person."
PrivacyLevel.STRICT ->
"Only numbers: test results, metrics and finding codes. No network description, no raw " +
"evidence, no finding text. Nothing left can identify a network."
}
@Composable
private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(detail, style = MaterialTheme.typography.bodySmall)
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun NumberField(label: String, value: String, onChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = { s -> onChange(s.filter { it.isDigit() }.take(7)) },
label = { Text(label) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The on-device run archive: measurement documents on disk, with retention.
// Pure Kotlin/JVM (it takes a directory, not a Context) so the retention rules
// — the part with edge cases — are unit-testable without a device.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() }
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package archive keeps completed measurement runs on the device.
//
// The point of an archive is the second run: "this network was fine on Tuesday" is only
// answerable if Tuesday was kept. But an app that silently accumulates network dumps forever is
// its own privacy problem, so retention is a first-class part of the type rather than a cleanup
// job somebody remembers to write — every save enforces it.
//
// Storage is one JSON file per run plus a small index entry, in a plain directory. Nothing here
// needs a database, and a plain directory is something a user can inspect, copy off, or delete
// with a file manager. Files are written to a temp name and renamed, so a run interrupted mid-
// write never leaves a half-document that reads as real.
package app.echo_lot.archive
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
/** Index entry for one archived run — enough for a history list without opening the documents. */
@Serializable
data class ArchivedRun(
val id: String,
@SerialName("saved_at_epoch_ms") val savedAtEpochMs: Long,
@SerialName("started_at") val startedAt: String? = null,
val verdict: String? = null,
@SerialName("finding_count") val findingCount: Int = 0,
@SerialName("size_bytes") val sizeBytes: Long = 0,
val anonymization: String = "full",
/** Whether this run has been accepted by a server, so history can show what is backed up. */
val uploaded: Boolean = false,
@SerialName("uploaded_to") val uploadedTo: String? = null,
)
/**
* Retention limits. All three are independent ceilings; a run is dropped when it violates any of
* them. Zero disables that limit.
*
* The default keeps a hundred runs or three months, whichever comes first. That is enough to see
* a pattern ("it degrades every evening") without turning the phone into an archive of every
* network its owner ever walked past.
*/
@Serializable
data class RetentionPolicy(
/**
* Whether to archive at all. Separate from the limits because "no limits" (every limit zero)
* and "keep nothing" are opposite intentions, and collapsing them onto the same value is how
* a user who turns all the caps off ends up with an empty history.
*/
val enabled: Boolean = true,
@SerialName("max_runs") val maxRuns: Int = 100,
@SerialName("max_age_days") val maxAgeDays: Int = 90,
@SerialName("max_total_bytes") val maxTotalBytes: Long = 64L * 1024 * 1024,
) {
companion object {
/** Archiving off: runs are shown once and never written. */
val KeepNothing = RetentionPolicy(enabled = false)
/** Archiving on with no ceilings. Every run is kept until the user deletes it. */
val Unlimited = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = 0)
val Default = RetentionPolicy()
}
val keepsAnything: Boolean get() = enabled
}
/** What a purge removed, so the UI can say "dropped 3 old runs" instead of silently deleting. */
data class PurgeResult(val removed: List<String>, val freedBytes: Long) {
val isEmpty: Boolean get() = removed.isEmpty()
}
class RunArchive(private val dir: File, private val now: () -> Long = System::currentTimeMillis) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
init {
dir.mkdirs()
}
/**
* Writes one run and applies retention. Returns the index entry, or null when the policy
* keeps nothing at all — in which case nothing is written, rather than written and instantly
* deleted (the difference matters on flash storage and to anyone watching the filesystem).
*/
fun save(runJson: String, policy: RetentionPolicy = RetentionPolicy.Default): ArchivedRun? {
if (!policy.keepsAnything) return null
val doc = runCatching { json.parseToJsonElement(runJson).jsonObject }.getOrNull() ?: return null
val meta = indexOf(doc, runJson.toByteArray().size.toLong()) ?: return null
writeAtomically(File(dir, meta.id + EXT), runJson)
writeAtomically(File(dir, meta.id + META_EXT), json.encodeToString(ArchivedRun.serializer(), meta))
purge(policy)
return meta
}
/** History, newest first. */
fun list(): List<ArchivedRun> =
(dir.listFiles { f -> f.name.endsWith(META_EXT) } ?: emptyArray())
.mapNotNull { f ->
runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
}
.sortedByDescending { it.savedAtEpochMs }
fun read(id: String): String? = File(dir, safe(id) + EXT).takeIf { it.isFile }?.readText()
fun delete(id: String): Boolean {
val s = safe(id)
val doc = File(dir, s + EXT).delete()
File(dir, s + META_EXT).delete()
return doc
}
fun deleteAll(): Int = list().count { delete(it.id) }
/** Records that a server accepted this run, so history can distinguish backed-up from local. */
fun markUploaded(id: String, serverName: String) {
val f = File(dir, safe(id) + META_EXT)
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
?: return
writeAtomically(
f,
json.encodeToString(
ArchivedRun.serializer(),
meta.copy(uploaded = true, uploadedTo = serverName),
),
)
}
fun totalBytes(): Long = list().sumOf { it.sizeBytes }
/**
* Enforces the policy. Age first, then total size, then count: dropping stale runs may already
* satisfy the other two, and it is the limit a user reasons about ("keep three months"), so it
* should not be pre-empted by a size sweep deleting last week instead.
*/
fun purge(policy: RetentionPolicy): PurgeResult {
val removed = ArrayList<String>()
var freed = 0L
fun drop(r: ArchivedRun) {
if (delete(r.id)) {
removed.add(r.id)
freed += r.sizeBytes
}
}
var kept = list()
if (policy.maxAgeDays > 0) {
val cutoff = now() - policy.maxAgeDays * 24L * 60 * 60 * 1000
val (fresh, stale) = kept.partition { it.savedAtEpochMs >= cutoff }
stale.forEach(::drop)
kept = fresh
}
if (policy.maxTotalBytes > 0) {
var total = kept.sumOf { it.sizeBytes }
// Oldest first until we are under the ceiling.
for (r in kept.reversed()) {
if (total <= policy.maxTotalBytes) break
drop(r)
total -= r.sizeBytes
}
kept = kept.filter { it.id !in removed }
}
if (policy.maxRuns > 0 && kept.size > policy.maxRuns) {
kept.drop(policy.maxRuns).forEach(::drop) // list() is newest-first
}
return PurgeResult(removed, freed)
}
// ---- internals ---------------------------------------------------------------------
private fun indexOf(doc: JsonObject, size: Long): ArchivedRun? {
val run = doc["run"]?.jsonObject ?: return null
val id = run["id"]?.jsonPrimitive?.content?.let(::safe)?.takeIf { it.isNotEmpty() } ?: return null
return ArchivedRun(
id = id,
savedAtEpochMs = now(),
startedAt = run["started_at"]?.jsonPrimitive?.content,
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content,
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
sizeBytes = size,
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
)
}
private fun writeAtomically(target: File, content: String) {
val tmp = File(target.parentFile, target.name + ".tmp")
tmp.writeText(content)
if (!tmp.renameTo(target)) {
target.delete()
tmp.renameTo(target)
}
}
/** Run ids reach the filesystem; keep them to characters that cannot climb out of [dir]. */
private fun safe(id: String): String = buildString {
for (c in id) if (c.isLetterOrDigit() || c == '-' || c == '_') append(c)
}.take(64)
private companion object {
const val EXT = ".json"
const val META_EXT = ".meta.json"
}
}
@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.archive
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RunArchiveTest {
private val dir: File = Files.createTempDirectory("echolot-archive").toFile()
private var clock = 1_000_000_000_000L // fixed: retention is time arithmetic, not wall time
private fun archive() = RunArchive(dir) { clock }
@AfterTest fun cleanup() { dir.deleteRecursively() }
private fun doc(id: String, findings: Int = 1, pad: Int = 0): String {
val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" }
return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" +
""""findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}"""
}
@Test
fun savedRunsComeBackNewestFirst() {
val a = archive()
for (i in 1..3) {
a.save(doc("run-$i"))
clock += 60_000
}
assertEquals(listOf("run-3", "run-2", "run-1"), a.list().map { it.id })
}
@Test
fun theIndexSummarisesTheDocument() {
val meta = assertNotNull(archive().save(doc("run-1", findings = 4)))
assertEquals("warn", meta.verdict)
assertEquals(4, meta.findingCount)
assertEquals("balanced", meta.anonymization)
assertEquals("2026-08-01T10:00:00Z", meta.startedAt)
assertFalse(meta.uploaded)
}
@Test
fun theDocumentComesBackByteForByte() {
val a = archive()
val original = doc("run-1")
a.save(original)
assertEquals(original, a.read("run-1"))
}
@Test
fun countLimitKeepsTheNewest() {
val a = archive()
val policy = RetentionPolicy(maxRuns = 3, maxAgeDays = 0, maxTotalBytes = 0)
for (i in 1..7) {
a.save(doc("run-$i"), policy)
clock += 60_000
}
assertEquals(listOf("run-7", "run-6", "run-5"), a.list().map { it.id })
assertNull(a.read("run-1"), "purged run's document should be gone, not just its index entry")
}
@Test
fun ageLimitDropsRunsPastTheWindow() {
val a = archive()
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 7, maxTotalBytes = 0)
a.save(doc("old"), policy)
clock += 30L * 24 * 60 * 60 * 1000 // a month later
a.save(doc("new"), policy)
assertEquals(listOf("new"), a.list().map { it.id })
}
@Test
fun sizeLimitDropsOldestUntilUnderTheCeiling() {
val a = archive()
val one = doc("x", pad = 900).toByteArray().size.toLong()
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = one * 2 + 10)
for (i in 1..5) {
a.save(doc("run-$i", pad = 900), policy)
clock += 60_000
}
val kept = a.list()
assertTrue(kept.size <= 2, "size ceiling not enforced: kept ${kept.size}")
assertEquals("run-5", kept.first().id, "the newest run must always survive")
assertTrue(a.totalBytes() <= policy.maxTotalBytes)
}
// A policy that keeps nothing must not write-then-delete: the run should never touch storage.
@Test
fun keepNothingWritesNothing() {
val a = archive()
assertNull(a.save(doc("run-1"), RetentionPolicy.KeepNothing))
assertTrue(a.list().isEmpty())
assertEquals(0, dir.listFiles()?.size ?: 0, "files were written for a keep-nothing policy")
}
// "no ceilings" and "keep nothing" must not be the same policy, however a user arrives at
// one: turning every limit off should keep everything, not wipe the history.
@Test
fun unlimitedKeepsEverythingWhileKeepNothingKeepsNone() {
val a = archive()
for (i in 1..5) {
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
clock += 60_000
}
assertEquals(5, a.list().size)
assertNull(a.save(doc("run-6"), RetentionPolicy.KeepNothing))
assertEquals(5, a.list().size, "keep-nothing must not touch what is already archived")
}
@Test
fun uploadStateIsRecorded() {
val a = archive()
a.save(doc("run-1"))
a.markUploaded("run-1", "fmr")
val meta = a.list().single()
assertTrue(meta.uploaded)
assertEquals("fmr", meta.uploadedTo)
assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry")
}
@Test
fun deleteRemovesBothFiles() {
val a = archive()
a.save(doc("run-1"))
assertTrue(a.delete("run-1"))
assertTrue(a.list().isEmpty())
assertNull(a.read("run-1"))
assertEquals(0, dir.listFiles()?.size ?: 0)
}
@Test
fun malformedInputIsRejectedRatherThanArchived() {
val a = archive()
assertNull(a.save("not json"))
assertNull(a.save("""{"summary":{"verdict":"ok"}}"""), "a document with no run id has no identity")
assertTrue(a.list().isEmpty())
}
// Run ids come from a document that may have been produced elsewhere; they must not be able
// to write outside the archive directory.
@Test
fun runIdsCannotEscapeTheArchiveDirectory() {
val a = archive()
a.save(doc("../../evil"))
val strays = dir.parentFile.listFiles { f -> f.name.contains("evil") } ?: emptyArray()
assertTrue(strays.isEmpty(), "wrote outside the archive: ${strays.toList()}")
}
@Test
fun purgeReportsWhatItRemoved() {
val a = archive()
for (i in 1..5) {
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
clock += 60_000
}
val result = a.purge(RetentionPolicy(maxRuns = 2, maxAgeDays = 0, maxTotalBytes = 0))
assertEquals(3, result.removed.size)
assertTrue(result.freedBytes > 0)
assertEquals(2, a.list().size)
}
}
+1
View File
@@ -12,6 +12,7 @@ plugins {
dependencies { dependencies {
implementation(project(":core-protocol")) implementation(project(":core-protocol"))
implementation(project(":core-measurement")) implementation(project(":core-measurement"))
implementation(project(":core-privacy"))
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
} }
@@ -54,19 +54,35 @@ class LiveGrantedTest {
"sizes=${down.map { it.sizeBytes }.distinct()}") "sizes=${down.map { it.sizeBytes }.distinct()}")
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken") assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
// --- big_send: which downstream sizes survive? --- // --- big_send with DF: the largest size that arrives is the downstream path MTU ---
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000) val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
val bsResp = control.action( val dfResp = control.action(
cred, session.sessionId, cred, session.sessionId,
"""{"action":"big_send","sizes_bytes":${sizes}}""", """{"action":"big_send","df":true,"sizes_bytes":${sizes}}""",
) )
println("big_send accepted: ${bsResp.take(160)}") println("big_send(df) accepted: ${dfResp.take(200)}")
val big = ps.collectGranted(windowMs = 4000) val dfArrived = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND } .filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
val arrived = big.map { it.sizeBytes }.sorted() println("big_send(df) arrived: $dfArrived")
println("big_send arrived sizes: $arrived (requested $sizes)") assertTrue(dfArrived.isNotEmpty(), "no unfragmented BIG_SEND packets arrived")
assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived") val pathMtu = dfArrived.max()
println("largest downstream datagram delivered: ${arrived.maxOrNull()}")
// --- and without DF, to see whether fragments get through above that ---
val fragResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","df":false,"sizes_bytes":${sizes}}""",
)
println("big_send(frag) accepted: ${fragResp.take(200)}")
val fragArrived = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
println("big_send(frag) arrived: $fragArrived")
// The distinction the DF flag exists for: fragmented delivery may exceed the
// unfragmented path MTU, and reporting the former as the latter would be a lie.
println("downstream path MTU (payload bytes) = $pathMtu; " +
"largest fragmented delivery = ${fragArrived.maxOrNull()}")
assertTrue((fragArrived.maxOrNull() ?: 0) >= pathMtu,
"fragmented delivery should reach at least as far as unfragmented")
} }
control.deleteSession(cred, session.sessionId) control.deleteSession(cred, session.sessionId)
} }
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.privacy.Anonymizer
import app.echo_lot.privacy.PrivacyLevel
import app.echo_lot.privacy.Salt
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.UploadRefused
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Drives the upload path against a LIVE server: anonymize, upload, list, fetch back, delete.
*
* The point is not that the HTTP works — it is that what comes *back off the server* has been
* stripped. Uploading and then re-reading the stored document is the only check that proves the
* anonymizer ran on the bytes that actually left, rather than on a copy. Self-skips without
* ECHOLOT_LIVE_*.
*/
class LiveUploadTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val json = Json { prettyPrint = false }
private fun sampleRun(id: String) = """
{
"schema": "echolot/measurement",
"run": {
"id": "$id", "trigger": "manual", "started_at": "2026-08-01T10:00:00Z",
"notes": "kitchen table",
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "203.0.113.77",
"ssdp_responders": [{"friendly_name": "Living Room TV"}]
}],
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0}}],
"findings": [{"id": "f1", "code": "nat.udp_rebinding", "severity": "medium"}],
"summary": {"verdict": "warn"}
}
""".trimIndent()
@Test
fun uploadRoundTrip() {
if (url == null || pin == null || cred == null) {
println("LiveUploadTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
val policy = profile.uploads
println("upload policy: mode=${policy.mode} min_anon=${policy.minAnonymization} " +
"max_bytes=${policy.maxBytes} retention_days=${policy.retentionDays}")
val runId = "livetest-" + System.nanoTime().toString().takeLast(10)
val level = PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.fromWire(policy.minAnonymization))
val redacted = json.encodeToString(
JsonObject.serializer(),
Anonymizer(level, Salt.perRun(ByteArray(32) { 9 }))
.anonymize(json.parseToJsonElement(sampleRun(runId)).jsonObject),
)
assertFalse(redacted.contains("Rambossek"), "the anonymizer did not strip the SSID before upload")
if (!policy.accepted) {
// A server configured to refuse must refuse — that is the behaviour worth asserting.
try {
control.uploadRun(cred, redacted)
throw AssertionError("server advertises mode=${policy.mode} but accepted an upload")
} catch (e: UploadRefused) {
println("upload correctly refused: ${e.message?.take(140)}")
return
}
}
val created = control.uploadRun(cred, redacted)
println("stored: ${created.take(200)}")
val listed = control.listRuns(cred)
assertTrue(listed.contains(runId), "uploaded run is missing from the server's list")
val fetched = control.getRun(cred, runId)
assertFalse(fetched.contains("Rambossek"), "the SSID is sitting on the server")
assertFalse(fetched.contains("Living Room TV"), "an SSDP neighbour name is sitting on the server")
assertFalse(fetched.contains("kitchen table"), "a free-text note is sitting on the server")
assertTrue(fetched.contains("nat.udp_rebinding"), "the finding code should survive — it is the point")
assertTrue(fetched.contains("12.4"), "metrics should survive anonymization")
println("round trip verified: identifiers stripped, measurements intact")
control.deleteRun(cred, runId)
assertFalse(control.listRuns(cred).contains(runId), "delete did not remove the run")
println("deleted")
}
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The anonymizer (measurement-schema.md §8). Pure Kotlin/JVM and deliberately
// dependency-free beyond JSON: it must be trivially auditable, because a bug
// here leaks a user's network onto someone else's server.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() }
@@ -0,0 +1,237 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package privacy implements the anonymization contract of measurement-schema.md §8.
//
// The threat model is specific. An engineer running their own server wants the full document —
// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's
// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a
// filter: the output is still a valid measurement document with the same tests, metrics and
// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is
// still answerable from pseudonyms alone.
//
// Two properties are load-bearing and are what the tests pin:
// - Consistency within a document: one input value always maps to one pseudonym, so
// correlations inside a run survive.
// - No consistency *across* documents unless the user asks for it: the salt is per-run by
// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is
// opt-in (`Salt.stable`) for people diffing their own history on their own server.
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import java.security.MessageDigest
import java.util.Locale
/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */
enum class PrivacyLevel(val wire: String) {
/** Nothing removed. The right choice for your own server. */
FULL("full"),
/**
* Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive:
* you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not
* which MikroTik, on which SSID, next to whose Chromecast.
*/
BALANCED("balanced"),
/**
* Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their
* codes and severities but lose descriptions (which quote real names). What is left cannot
* identify a network, and is still enough for aggregate "how common is this fault" work.
*/
STRICT("strict");
companion object {
fun fromWire(s: String?): PrivacyLevel =
entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL
/** The stricter of two levels — used to honour a server's minimum. */
fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b
}
}
/**
* The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded
* twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device.
* A stable salt trades that away for cross-run diffing and is only appropriate on a server you
* own — the app makes that an explicit choice, not a default.
*/
class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) {
companion object {
fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false)
fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true)
}
}
/**
* Transforms a measurement document to [level].
*
* Field classification is by JSON key name, because the schema names things consistently
* (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by
* reading one table. Anything unrecognized is treated as identifying when it is a string inside
* a known-sensitive container, and left alone otherwise — see [Classification].
*/
class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
private val cache = HashMap<String, String>()
fun anonymize(doc: JsonObject): JsonObject {
if (level == PrivacyLevel.FULL) return stamp(doc)
val walked = walkObject(doc, path = emptyList())
val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked
return stamp(out)
}
/** Records what was done, so a reader of the archived/uploaded document is never guessing. */
private fun stamp(doc: JsonObject): JsonObject {
val run = doc["run"]?.jsonObject ?: return doc
val privacy = buildJsonObject {
put("anonymization", level.wire)
put("salt", if (salt.stable) "stable" else "per_run")
}
return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy))))
}
// ---- the tree walk -------------------------------------------------------------------
private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject {
for ((k, v) in obj) {
val childPath = path + k
when {
Classification.dropAtBalanced(childPath) -> Unit // omit entirely
else -> put(k, walk(k, v, childPath))
}
}
}
private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) {
is JsonObject -> walkObject(v, path)
is JsonArray -> JsonArray(v.map { walk(key, it, path) })
is JsonPrimitive ->
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive)
else v
}
private fun transform(type: LogicalType?, value: String): String = when (type) {
null -> value
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
LogicalType.IP4 -> ip4(value)
LogicalType.IP6 -> ip6(value)
LogicalType.FQDN -> fqdn(value)
LogicalType.OPAQUE_ID -> "redacted"
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
}
// ---- per-type transforms -------------------------------------------------------------
/**
* Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
* diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT
* MikroTik" does not.
*/
private fun macPreservingOui(value: String): String {
val sep = if (value.contains('-')) '-' else ':'
val parts = value.split(sep)
if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) }
val nic = pseudo("mac", value) { it }
return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6)))
.joinToString(sep.toString())
.lowercase(Locale.ROOT)
}
/**
* Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and
* CGNAT addresses say something about the topology and nothing about the person, and a run
* where 192.168.1.1 became a random public address would be actively misleading to read.
* Public addresses keep only their /16 so the network is still locatable at ISP granularity.
*/
private fun ip4(value: String): String {
val o = value.split(".")
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
val n = o.map { it.toInt() }
val reserved = n[0] == 10 ||
(n[0] == 172 && n[1] in 16..31) ||
(n[0] == 192 && n[1] == 168) ||
(n[0] == 169 && n[1] == 254) ||
(n[0] == 100 && n[1] in 64..127) ||
n[0] == 127 || n[0] == 0 || n[0] >= 224
if (reserved) return value
val h = pseudo("ip4", value) { it }
return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}"
}
/**
* IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in
* the same allocation) and pseudonymizes the rest — the interface identifier is the part that
* is a device fingerprint, especially with EUI-64.
*/
private fun ip6(value: String): String {
val v = value.lowercase(Locale.ROOT)
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
val groups = v.substringBefore('%').split(":")
if (groups.size < 3) return v
val h = pseudo("ip6", value) { it }
return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}"
}
/**
* Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local"
* or "…under example.com" survives without naming the host. The suffix list is deliberately
* short: guessing wrong keeps *more* pseudonymized, never less.
*/
private fun fqdn(value: String): String {
if (value.isEmpty()) return value
val trailing = value.endsWith(".")
val labels = value.trimEnd('.').split(".")
if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) }
val keep = if (labels.last() in publicSuffixes) 1 else 0
val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } }
return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else ""
}
// ---- STRICT ---------------------------------------------------------------------------
/**
* STRICT keeps the shape of the document and the numbers, and nothing that quotes the
* network back. Evidence goes (trains carry addresses and hostnames), finding prose goes
* (it interpolates real names), networks go entirely.
*/
private fun strip(doc: JsonObject): JsonObject = buildJsonObject {
for ((k, v) in doc) {
when (k) {
"networks", "server_sessions" -> Unit
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t ->
val o = t.jsonObject
JsonObject(o.filterKeys { it != "evidence" && it != "params" })
}))
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f ->
val o = f.jsonObject
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" })
}))
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" }))
else -> put(k, v)
}
}
}
// ---- pseudonym machinery ---------------------------------------------------------------
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
cache.getOrPut("$domain$value") {
val md = MessageDigest.getInstance("SHA-256")
md.update(salt.bytes)
md.update(domain.toByteArray())
md.update(0)
md.update(value.lowercase(Locale.ROOT).toByteArray())
shape(md.digest().joinToString("") { "%02x".format(it) })
}
private companion object {
val publicSuffixes = setOf(
"local", "lan", "home", "internal", "arpa",
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk",
)
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
/** The logical types of measurement-schema.md §8. */
enum class LogicalType { IP4, IP6, MAC, BSSID, SSID, FQDN, OPAQUE_ID, FREETEXT }
/**
* Which fields hold which logical type, and which whole subtrees are dropped below FULL.
*
* This is a table on purpose. The alternative — annotating the Kotlin models and reflecting over
* them — spreads the answer across every module and makes "what exactly gets uploaded?" a
* question you answer by reading the whole app. Here it is one file a reviewer can check against
* the spec in a sitting, and a new field that nobody classified stays visible in the output
* rather than being silently mangled.
*
* The bias is toward over-classifying: a metric wrongly pseudonymized is a bug someone reports;
* an SSID wrongly kept is a leak nobody notices.
*/
object Classification {
private val byKey: Map<String, LogicalType> = buildMap {
listOf(
"ip4", "ipv4", "gateway_ip4", "dns_ip4", "src_ip4", "dst_ip4", "public_ip4",
"observed_ip4", "hop_ip4", "answer_ip4", "address_ip4", "server_ip4",
).forEach { put(it, LogicalType.IP4) }
listOf(
"ip6", "ipv6", "gateway_ip6", "dns_ip6", "src_ip6", "dst_ip6", "public_ip6",
"observed_ip6", "hop_ip6", "answer_ip6", "address_ip6", "server_ip6",
"link_local", "ra_source", "prefix",
).forEach { put(it, LogicalType.IP6) }
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
.forEach { put(it, LogicalType.MAC) }
listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) }
listOf("ssid", "network_name", "wifi_ssid").forEach { put(it, LogicalType.SSID) }
listOf(
"fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name",
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
).forEach { put(it, LogicalType.FQDN) }
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
.forEach { put(it, LogicalType.OPAQUE_ID) }
listOf("notes", "detail", "raw", "excerpt", "location", "model_description")
.forEach { put(it, LogicalType.FREETEXT) }
}
/**
* Whole subtrees that BALANCED removes rather than pseudonymizes.
*
* Neighbour inventories (SSDP/UPnP responders, ARP tables, discovered peers) are the clearest
* case: they describe other people's devices, they are a household fingerprint even with the
* names hashed, and no metric depends on them. Dropping beats mangling.
*/
private val droppedPaths: List<List<String>> = listOf(
listOf("networks", "neighbors"),
listOf("networks", "arp"),
listOf("networks", "wifi", "scan_results"),
listOf("run", "device", "security_patch"),
)
/** Key suffixes whose whole value is a neighbour inventory wherever they appear. */
private val droppedKeys = setOf(
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
"nearby_networks", "peers", "raw_dump", "dumpsys",
)
fun typeOf(key: String, path: List<String>): LogicalType? {
byKey[key]?.let { return it }
// Inside a discovery/neighbour container every string is someone's device name until
// proven otherwise, so classify unknown strings there as free text rather than passing
// them through.
if (path.any { it in droppedKeys }) return LogicalType.FREETEXT
return null
}
fun dropAtBalanced(path: List<String>): Boolean {
if (path.isNotEmpty() && path.last() in droppedKeys) return true
return droppedPaths.any { dropped -> dropped.all { path.contains(it) } }
}
}
@@ -0,0 +1,185 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* These tests are the audit of the anonymizer: each one states a property someone's privacy
* depends on, so a regression here fails loudly rather than quietly leaking.
*/
class AnonymizerTest {
private val json = Json { prettyPrint = false }
private val salt = Salt.perRun(ByteArray(32) { it.toByte() })
private fun sample(): JsonObject = json.parseToJsonElement(
"""
{
"schema": "echolot/measurement",
"run": {
"id": "0190-run", "trigger": "manual", "notes": "at Anna's flat",
"device": {"manufacturer": "OnePlus", "model": "CPH2747", "security_patch": "2026-06-05"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "89.185.109.150",
"gateway_ip6": "2001:1ad0:c4fe:6767::1", "link_local": "fe80::7a9a:18ff:feaa:bbcc",
"neighbors": [{"name": "Anna's Chromecast", "mac": "aa:bb:cc:dd:ee:ff"}],
"ssdp_responders": [{"friendly_name": "Living Room TV", "location": "http://192.168.1.44:8060/"}]
}],
"tests": [{
"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0},
"evidence": {"seq": [0,1,2], "t_rx_ns": [1,2,3]}
}],
"findings": [{
"id": "f1", "code": "nat.udp_rebinding", "severity": "medium",
"title": "NAT remapped the port", "description": "server saw 89.185.109.150:41000"
}],
"summary": {"verdict": "warn"}
}
""".trimIndent(),
).jsonObject
private fun anon(level: PrivacyLevel, doc: JsonObject = sample()) = Anonymizer(level, salt).anonymize(doc)
private fun flat(e: JsonElement): String = e.toString()
@Test
fun fullLeavesTheDocumentAloneButRecordsThat() {
val out = anon(PrivacyLevel.FULL)
assertEquals("Rambossek WLAN", out["networks"]!!.jsonArray[0].jsonObject["ssid"]!!.jsonPrimitive.content)
assertEquals("full", out["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content)
}
@Test
fun balancedRemovesTheSsidAndTheNotes() {
val text = flat(anon(PrivacyLevel.BALANCED))
assertFalse(text.contains("Rambossek"), "SSID survived: $text")
assertFalse(text.contains("Anna"), "free-text note or neighbour name survived: $text")
}
@Test
fun balancedDropsNeighbourInventoriesEntirely() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertNull(net["neighbors"], "neighbour list should be dropped, not pseudonymized")
assertNull(net["ssdp_responders"], "SSDP responders should be dropped, not pseudonymized")
}
@Test
fun balancedKeepsTheVendorHalfOfAMac() {
val bssid = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject["bssid"]!!.jsonPrimitive.content
assertTrue(bssid.startsWith("78:9a:18"), "OUI should survive so the vendor is still known: $bssid")
assertFalse(bssid.endsWith("aa:bb:cc"), "NIC part should be pseudonymized: $bssid")
}
@Test
fun privateAddressesAreKeptVerbatimAndPublicOnesAreNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("192.168.1.1", net["gateway_ip4"]!!.jsonPrimitive.content,
"RFC1918 says nothing about the user and everything about the topology")
assertNotEquals("89.185.109.150", net["public_ip4"]!!.jsonPrimitive.content)
assertTrue(net["public_ip4"]!!.jsonPrimitive.content.startsWith("89.185."),
"the /16 should survive for ISP-level context")
}
@Test
fun linkLocalIsKeptButGlobalV6IsNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("fe80::7a9a:18ff:feaa:bbcc", net["link_local"]!!.jsonPrimitive.content)
assertNotEquals("2001:1ad0:c4fe:6767::1", net["gateway_ip6"]!!.jsonPrimitive.content)
}
@Test
fun metricsAndVerdictsAreNeverTouched() {
for (level in PrivacyLevel.entries) {
val out = anon(level)
val t = out["tests"]!!.jsonArray[0].jsonObject
assertEquals(12.4, t["metrics"]!!.jsonObject["rtt_ms_avg"]!!.jsonPrimitive.double, 1e-9,
"$level changed a metric")
assertEquals("ok", t["status"]!!.jsonPrimitive.content)
assertEquals("warn", out["summary"]!!.jsonObject["verdict"]!!.jsonPrimitive.content)
}
}
@Test
fun findingCodesSurviveEveryLevelSoAggregationStillWorks() {
for (level in PrivacyLevel.entries) {
val f = anon(level)["findings"]!!.jsonArray[0].jsonObject
assertEquals("nat.udp_rebinding", f["code"]!!.jsonPrimitive.content, "$level lost the finding code")
assertEquals("medium", f["severity"]!!.jsonPrimitive.content)
}
}
@Test
fun strictDropsEvidenceAndProse() {
val out = anon(PrivacyLevel.STRICT)
assertNull(out["networks"], "STRICT should not describe the network at all")
assertNull(out["tests"]!!.jsonArray[0].jsonObject["evidence"])
assertNull(out["findings"]!!.jsonArray[0].jsonObject["description"])
assertFalse(flat(out).contains("89.185.109.150"), "an address leaked through finding prose")
}
@Test
fun pseudonymsAreConsistentWithinADocument() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"networks":[{"ssid":"Home"},{"ssid":"Home"},{"ssid":"Other"}]}"""
).jsonObject
val nets = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray
val a = nets[0].jsonObject["ssid"]!!.jsonPrimitive.content
val b = nets[1].jsonObject["ssid"]!!.jsonPrimitive.content
val c = nets[2].jsonObject["ssid"]!!.jsonPrimitive.content
assertEquals(a, b, "the same SSID must map to the same pseudonym inside one run")
assertNotEquals(a, c, "different SSIDs must not collide")
}
@Test
fun perRunSaltsDoNotLinkTwoUploadsOfTheSameNetwork() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 1 })).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 2 })).anonymize(doc)
assertNotEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
"a per-run salt must not produce a cross-run tracking identifier",
)
}
@Test
fun aStableSaltDoesLinkThemBecauseThatIsWhatItIsFor() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val secret = ByteArray(32) { 7 }
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
assertEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
)
assertEquals("stable", one["run"]!!.jsonObject["privacy"]!!.jsonObject["salt"]!!.jsonPrimitive.content)
}
@Test
fun theDeclaredLevelMatchesWhatWasApplied() {
for (level in PrivacyLevel.entries) {
assertEquals(
level.wire,
anon(level)["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content,
)
}
}
@Test
fun serverMinimumWins() {
assertEquals(PrivacyLevel.STRICT, PrivacyLevel.max(PrivacyLevel.FULL, PrivacyLevel.STRICT))
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense"))
}
}
@@ -17,6 +17,9 @@ import javax.net.ssl.HttpsURLConnection
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443" * @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) * @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>) { class ControlClient(private val controlUrl: String, pins: Set<String>) {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -93,6 +96,44 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
return body 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 { fun observations(credential: String, sessionId: String): String {
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential) val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" } check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
@@ -32,6 +32,30 @@ data class SelfTest(
@SerialName("sysctl_ok") val sysctlOk: Boolean? = null, @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 @Serializable
data class Profile( data class Profile(
@SerialName("profile_version") val profileVersion: Int = 0, @SerialName("profile_version") val profileVersion: Int = 0,
@@ -42,6 +66,7 @@ data class Profile(
@SerialName("canary_zone") val canaryZone: String = "", @SerialName("canary_zone") val canaryZone: String = "",
@SerialName("server_selftest") val serverSelftest: SelfTest? = null, @SerialName("server_selftest") val serverSelftest: SelfTest? = null,
val pins: List<String> = emptyList(), val pins: List<String> = emptyList(),
val uploads: UploadPolicy = UploadPolicy(),
) { ) {
fun supports(capability: String) = capability in capabilities fun supports(capability: String) = capability in capabilities
} }
+2
View File
@@ -24,6 +24,8 @@ rootProject.name = "echolot-app"
include(":core-protocol") include(":core-protocol")
include(":core-measurement") include(":core-measurement")
include(":core-engine") include(":core-engine")
include(":core-privacy")
include(":core-archive")
include(":core-probe") include(":core-probe")
include(":core-shizuku") include(":core-shizuku")
include(":app") include(":app")
+9 -10
View File
@@ -39,13 +39,13 @@ const (
// Query is one logged canary lookup (spec §6 dns_canary shape). // Query is one logged canary lookup (spec §6 dns_canary shape).
type Query struct { type Query struct {
QName string `json:"qname"` QName string `json:"qname"`
At time.Time `json:"at"` At time.Time `json:"at"`
ResolverIP string `json:"resolver_ip"` ResolverIP string `json:"resolver_ip"`
Transport string `json:"transport"` // "udp" | "tcp" Transport string `json:"transport"` // "udp" | "tcp"
EDNS edns `json:"edns"` EDNS edns `json:"edns"`
ECS string `json:"ecs,omitempty"` ECS string `json:"ecs,omitempty"`
CasePreserved bool `json:"case_preserved"` CasePreserved bool `json:"case_preserved"`
// qname_minimized is not reliably detectable authoritative-side without // qname_minimized is not reliably detectable authoritative-side without
// cross-query correlation; left false (TODO) rather than guessed. // cross-query correlation; left false (TODO) rather than guessed.
QNameMinimized bool `json:"qname_minimized"` QNameMinimized bool `json:"qname_minimized"`
@@ -59,8 +59,8 @@ type edns struct {
// Server is the authoritative responder for one canary zone. // Server is the authoritative responder for one canary zone.
type Server struct { type Server struct {
zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app." zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app."
nsName string // this server's own name for NS/authority answers nsName string // this server's own name for NS/authority answers
primaryV4 netip.Addr primaryV4 netip.Addr
primaryV6 netip.Addr primaryV6 netip.Addr
@@ -284,4 +284,3 @@ func (s *Server) errorResponse(id uint16, rcode int, opt *optInfo) []byte {
} }
return hdr return hdr
} }
+1 -1
View File
@@ -21,7 +21,7 @@ func buildQuery(name string, qtype uint16, ednsBufsize int) []byte {
msg = binary.BigEndian.AppendUint16(msg, classIN) msg = binary.BigEndian.AppendUint16(msg, classIN)
if ednsBufsize > 0 { if ednsBufsize > 0 {
binary.BigEndian.PutUint16(msg[10:12], 1) // ARCOUNT binary.BigEndian.PutUint16(msg[10:12], 1) // ARCOUNT
msg = append(msg, 0) // root name msg = append(msg, 0) // root name
msg = binary.BigEndian.AppendUint16(msg, typeOPT) msg = binary.BigEndian.AppendUint16(msg, typeOPT)
msg = binary.BigEndian.AppendUint16(msg, uint16(ednsBufsize)) msg = binary.BigEndian.AppendUint16(msg, uint16(ednsBufsize))
msg = binary.BigEndian.AppendUint32(msg, 0) msg = binary.BigEndian.AppendUint32(msg, 0)
-1
View File
@@ -60,4 +60,3 @@ func TestTLSReferenceReturnsChain(t *testing.T) {
t.Fatalf("leaf DER not round-tripped: %x", first) t.Fatalf("leaf DER not round-tripped: %x", first)
} }
} }
+71
View File
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package dataplane
import (
"net"
"net/netip"
"testing"
)
// A multi-homed server must answer from the address the client has been talking to. Sending from
// a sibling address is silently dropped by the client's NAT or stateful firewall, and the client
// then reports downstream loss that never happened — a wrong measurement, which is worse than a
// failed one. This was a real bug: fmr binds two IPv4 addresses, the granted train went out from
// the one the session had never used, and every packet vanished in transit.
func TestConnForPrefersTheAddressTheSessionUsed(t *testing.T) {
// Two loopback-bound sockets stand in for the two service addresses.
a := mustListen(t, "127.0.0.1:0")
b := mustListen(t, "127.0.0.1:0")
defer a.Close()
defer b.Close()
srv := &Server{}
srv.conns = []*net.UDPConn{a, b}
client := netip.MustParseAddrPort("198.51.100.7:41000")
bLocal := b.LocalAddr().(*net.UDPAddr).AddrPort()
if got := srv.connFor(client, bLocal); got != b {
t.Fatalf("connFor picked the wrong socket: want the one the session used (%v)", bLocal)
}
// With no recorded local address (nothing received yet) any socket of the right family is
// the best available answer — but it must still be one, not nil.
if got := srv.connFor(client, netip.AddrPort{}); got == nil {
t.Fatal("connFor returned nil when a family match exists")
}
}
func TestConnForFallsBackByFamily(t *testing.T) {
v4 := mustListen(t, "127.0.0.1:0")
defer v4.Close()
srv := &Server{}
srv.conns = []*net.UDPConn{v4}
// A recorded local address that no longer matches any bound socket (a reload changed the
// binds) must not strand the session: fall back rather than return nil.
stale := netip.MustParseAddrPort("203.0.113.1:8442")
if got := srv.connFor(netip.MustParseAddrPort("198.51.100.7:41000"), stale); got != v4 {
t.Fatal("connFor should fall back to a family match when the recorded socket is gone")
}
// No IPv6 socket is bound, so an IPv6 target has no answer — nil, not an IPv4 socket.
if got := srv.connFor(netip.MustParseAddrPort("[2001:db8::1]:41000"), netip.AddrPort{}); got != nil {
t.Fatal("connFor returned an IPv4 socket for an IPv6 target")
}
}
func mustListen(t *testing.T, addr string) *net.UDPConn {
t.Helper()
ua, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
t.Fatal(err)
}
c, err := net.ListenUDP("udp", ua)
if err != nil {
t.Fatal(err)
}
return c
}
+2 -2
View File
@@ -26,7 +26,7 @@ func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeB
if !target.IsValid() { if !target.IsValid() {
return 0, fmt.Errorf("no observed data-plane source") return 0, fmt.Errorf("no observed data-plane source")
} }
conn := s.connFor(target) conn := s.connFor(target, sess.DataLocal())
if conn == nil { if conn == nil {
return 0, fmt.Errorf("no data-plane socket matches target family") return 0, fmt.Errorf("no data-plane socket matches target family")
} }
@@ -75,7 +75,7 @@ func (s *Server) BigSend(sess *session.Session, g *session.Grant, sizes []int, d
if !target.IsValid() { if !target.IsValid() {
return nil, fmt.Errorf("no observed data-plane source") return nil, fmt.Errorf("no observed data-plane source")
} }
conn := s.connFor(target) conn := s.connFor(target, sess.DataLocal())
if conn == nil { if conn == nil {
return nil, fmt.Errorf("no data-plane socket matches target family") return nil, fmt.Errorf("no data-plane socket matches target family")
} }
+19 -2
View File
@@ -73,9 +73,23 @@ func (s *Server) Serve(conn *net.UDPConn) error {
} }
// connFor picks a retained socket whose family matches the target. // connFor picks a retained socket whose family matches the target.
func (s *Server) connFor(target netip.AddrPort) *net.UDPConn { // connFor picks the socket to send to target from.
//
// When the session recorded which local address it has been talking to (local), that socket wins
// outright. Falling back to "any socket of the right family" is only correct for a single-homed
// server: on a multi-homed one it sends from a sibling address the client's NAT has no mapping
// for, the packets are dropped in transit, and the client reports downstream loss that does not
// exist. That bug is invisible in a lab with one address, which is exactly why this is explicit.
func (s *Server) connFor(target, local netip.AddrPort) *net.UDPConn {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if local.IsValid() {
for _, c := range s.conns {
if c.LocalAddr().(*net.UDPAddr).AddrPort() == local {
return c
}
}
}
want4 := target.Addr().Unmap().Is4() want4 := target.Addr().Unmap().Is4()
for _, c := range s.conns { for _, c := range s.conns {
la := c.LocalAddr().(*net.UDPAddr).AddrPort() la := c.LocalAddr().(*net.UDPAddr).AddrPort()
@@ -94,7 +108,7 @@ func (s *Server) SendDelayedEcho(sess *session.Session, actionID string) error {
if !target.IsValid() { if !target.IsValid() {
return fmt.Errorf("session has no observed data-plane source yet") return fmt.Errorf("session has no observed data-plane source yet")
} }
conn := s.connFor(target) conn := s.connFor(target, sess.DataLocal())
if conn == nil { if conn == nil {
return fmt.Errorf("no data-plane socket matches target family") return fmt.Errorf("no data-plane socket matches target family")
} }
@@ -131,6 +145,9 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
return return
} }
sess.NoteDataSource(raddr) sess.NoteDataSource(raddr)
if la, ok := conn.LocalAddr().(*net.UDPAddr); ok {
sess.NoteDataLocal(la.AddrPort())
}
sess.RecordUDP(session.UDPObservation{ sess.RecordUDP(session.UDPObservation{
Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(), Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(),
Src: raddr.String(), Size: len(pkt), Type: typ, Src: raddr.String(), Size: len(pkt), Type: typ,
+4 -4
View File
@@ -33,10 +33,10 @@ type Check struct {
// MTUResult is one egress path-MTU probe outcome. // MTUResult is one egress path-MTU probe outcome.
type MTUResult struct { type MTUResult struct {
Target string `json:"target"` Target string `json:"target"`
DiscoveredMTU int `json:"discovered_mtu"` DiscoveredMTU int `json:"discovered_mtu"`
FullMTU bool `json:"full_mtu"` // >= 1500 FullMTU bool `json:"full_mtu"` // >= 1500
Err string `json:"err,omitempty"` Err string `json:"err,omitempty"`
} }
// Report is the whole self-test. // Report is the whole self-test.
+20
View File
@@ -27,6 +27,7 @@ type Session struct {
// Last data-plane source seen with a valid HMAC (NAT rebinding evidence). // Last data-plane source seen with a valid HMAC (NAT rebinding evidence).
mu sync.Mutex mu sync.Mutex
dataSource netip.AddrPort dataSource netip.AddrPort
dataLocal netip.AddrPort
// Replay window (spec §3.1: 1024-wide seq window). Highest seq seen plus // Replay window (spec §3.1: 1024-wide seq window). Highest seq seen plus
// a bitmask of the 1024 preceding. // a bitmask of the 1024 preceding.
maxSeq uint32 maxSeq uint32
@@ -192,6 +193,25 @@ func (s *Session) CheckSeq(seq uint32) bool {
return true return true
} }
// DataLocal returns the server-side address that received this session's data-plane traffic.
//
// This matters more than it looks: a server bound to several addresses must send granted traffic
// back from the one the client has been talking to. Any stateful firewall or NAT in between has
// a mapping keyed on that exact pair, and a reply from a sibling address is dropped — which the
// client would then measure as downstream loss. See connFor.
func (s *Session) DataLocal() netip.AddrPort {
s.mu.Lock()
defer s.mu.Unlock()
return s.dataLocal
}
// NoteDataLocal records which of our own bound addresses saw this session's traffic.
func (s *Session) NoteDataLocal(ap netip.AddrPort) {
s.mu.Lock()
defer s.mu.Unlock()
s.dataLocal = ap
}
// NoteDataSource records the latest verified data-plane source. // NoteDataSource records the latest verified data-plane source.
func (s *Session) NoteDataSource(ap netip.AddrPort) { func (s *Session) NoteDataSource(ap netip.AddrPort) {
s.mu.Lock() s.mu.Lock()
+3 -3
View File
@@ -18,7 +18,7 @@ func buildClientHello(ciphers []uint16) []byte {
var body []byte var body []byte
body = append(body, u16(0x0303)...) // client_version TLS1.2 body = append(body, u16(0x0303)...) // client_version TLS1.2
body = append(body, make([]byte, 32)...) // random body = append(body, make([]byte, 32)...) // random
body = append(body, 0) // session_id len 0 body = append(body, 0) // session_id len 0
// cipher suites // cipher suites
cs := []byte{} cs := []byte{}
for _, c := range ciphers { for _, c := range ciphers {
@@ -36,8 +36,8 @@ func buildClientHello(ciphers []uint16) []byte {
exts = append(exts, data...) exts = append(exts, data...)
} }
// SNI: server_name_list -> host_name "x" // SNI: server_name_list -> host_name "x"
sni := append(u16(3), 0) // list len 3, name_type host_name(0) sni := append(u16(3), 0) // list len 3, name_type host_name(0)
sni = append(sni, u16(1)...) // name len 1 sni = append(sni, u16(1)...) // name len 1
sni = append(sni, 'x') sni = append(sni, 'x')
addExt(0x0000, sni) addExt(0x0000, sni)
// ALPN: protocol_name_list -> "h2" // ALPN: protocol_name_list -> "h2"
+1 -1
View File
@@ -33,7 +33,7 @@ func TestPlainEchoServerSpeaksFirst(t *testing.T) {
t.Fatalf("no greeting: %v", err) t.Fatalf("no greeting: %v", err)
} }
var g struct { var g struct {
TLS bool `json:"tls"` TLS bool `json:"tls"`
Src string `json:"observed_src"` Src string `json:"observed_src"`
} }
if err := json.Unmarshal(line, &g); err != nil { if err := json.Unmarshal(line, &g); err != nil {