server: DF-mode big_send + uploaded-run storage with an operator policy
big_send now forces the Don't-Fragment bit for the whole burst by default, so the largest size that arrives IS the downstream path MTU rather than "fragments got through" — two different measurements the schema already separates. Sizes above our own egress MTU (from the startup self-test) are refused up front and reported as max_df_bytes, because absence caused by our kernel must not be read as a limit of the client's path. Uploads: one JSON file per run under the state dir, with the policy the operator actually cares about — who may upload (off / anonymous / account), how large, how long to keep, and the least anonymization accepted. The profile advertises all of it so the app can present the switch honestly instead of discovering the rules by failing. `account` refuses today rather than falling back to anonymous: picking the strict setting before OIDC lands must not silently mean the loose one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7e1015c211
commit
2521d39989
@@ -80,6 +80,19 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
return json.decodeFromString(SessionResponse.serializer(), body(conn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a §5 action. The server creates an asymmetric grant for the granted ones
|
||||
* (downtrain / big_send) and starts sending toward the session's observed data-plane source,
|
||||
* so the caller must already have sent at least one ECHO. Returns the raw JSON reply.
|
||||
*/
|
||||
fun action(credential: String, sessionId: String, bodyJson: String): String {
|
||||
val conn = open("/v1/sessions/$sessionId/actions", "POST", credential)
|
||||
writeJson(conn, bodyJson)
|
||||
val body = body(conn)
|
||||
check(conn.responseCode in 200..299) { "action failed: ${conn.responseCode} $body" }
|
||||
return body
|
||||
}
|
||||
|
||||
fun observations(credential: String, sessionId: String): String {
|
||||
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
|
||||
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
|
||||
|
||||
@@ -60,6 +60,40 @@ class ProbeSession(
|
||||
(resp.payload[3].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects packets the SERVER sends under a grant (downtrain / big_send) for [windowMs].
|
||||
* These arrive unsolicited after a control-plane action, so this just drains the socket and
|
||||
* keeps every HMAC-verified packet — anything that fails verification is not ours and is
|
||||
* silently ignored (an injected packet must not be able to fake a measurement).
|
||||
*/
|
||||
fun collectGranted(windowMs: Long): List<Received> {
|
||||
val out = ArrayList<Received>()
|
||||
val deadline = System.nanoTime() + windowMs * 1_000_000
|
||||
val buf = ByteArray(9200)
|
||||
val prevTimeout = socket.soTimeout
|
||||
try {
|
||||
while (System.nanoTime() < deadline) {
|
||||
val remainMs = ((deadline - System.nanoTime()) / 1_000_000).toInt()
|
||||
if (remainMs <= 0) break
|
||||
socket.soTimeout = remainMs.coerceAtMost(2000)
|
||||
val dp = DatagramPacket(buf, buf.size)
|
||||
try {
|
||||
socket.receive(dp)
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
continue
|
||||
}
|
||||
val pkt = Wire.parseVerified(buf, dp.length, key) ?: continue
|
||||
out.add(Received(pkt.type, pkt.seq, dp.length, (System.nanoTime() - epochNanos)))
|
||||
}
|
||||
} finally {
|
||||
socket.soTimeout = prevTimeout
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** One packet received from the server, with the wire size actually delivered. */
|
||||
data class Received(val type: Int, val seq: Int, val sizeBytes: Int, val tRxNs: Long)
|
||||
|
||||
private fun receive(wantType: Int): Wire.Packet? {
|
||||
val buf = ByteArray(2048)
|
||||
return try {
|
||||
|
||||
@@ -28,6 +28,9 @@ object Wire {
|
||||
const val TYPE_MTU_PROBE: Int = 0x09
|
||||
const val TYPE_MTU_ACK: Int = 0x0A
|
||||
const val TYPE_DELAYED_ECHO: Int = 0x0B
|
||||
/** Server->client under an asymmetric grant (spec §3.4/§5). */
|
||||
const val TYPE_DOWNTRAIN_DATA: Int = 0x06
|
||||
const val TYPE_BIG_SEND: Int = 0x0C
|
||||
|
||||
/** The 8-byte on-the-wire prefix = first 16 hex chars of the session id, decoded. */
|
||||
fun wirePrefix(sessionId: String): ByteArray {
|
||||
|
||||
@@ -80,6 +80,19 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
return json.decodeFromString(SessionResponse.serializer(), body(conn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a §5 action. The server creates an asymmetric grant for the granted ones
|
||||
* (downtrain / big_send) and starts sending toward the session's observed data-plane source,
|
||||
* so the caller must already have sent at least one ECHO. Returns the raw JSON reply.
|
||||
*/
|
||||
fun action(credential: String, sessionId: String, bodyJson: String): String {
|
||||
val conn = open("/v1/sessions/$sessionId/actions", "POST", credential)
|
||||
writeJson(conn, bodyJson)
|
||||
val body = body(conn)
|
||||
check(conn.responseCode in 200..299) { "action failed: ${conn.responseCode} $body" }
|
||||
return body
|
||||
}
|
||||
|
||||
fun observations(credential: String, sessionId: String): String {
|
||||
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
|
||||
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
|
||||
|
||||
@@ -60,6 +60,40 @@ class ProbeSession(
|
||||
(resp.payload[3].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects packets the SERVER sends under a grant (downtrain / big_send) for [windowMs].
|
||||
* These arrive unsolicited after a control-plane action, so this just drains the socket and
|
||||
* keeps every HMAC-verified packet — anything that fails verification is not ours and is
|
||||
* silently ignored (an injected packet must not be able to fake a measurement).
|
||||
*/
|
||||
fun collectGranted(windowMs: Long): List<Received> {
|
||||
val out = ArrayList<Received>()
|
||||
val deadline = System.nanoTime() + windowMs * 1_000_000
|
||||
val buf = ByteArray(9200)
|
||||
val prevTimeout = socket.soTimeout
|
||||
try {
|
||||
while (System.nanoTime() < deadline) {
|
||||
val remainMs = ((deadline - System.nanoTime()) / 1_000_000).toInt()
|
||||
if (remainMs <= 0) break
|
||||
socket.soTimeout = remainMs.coerceAtMost(2000)
|
||||
val dp = DatagramPacket(buf, buf.size)
|
||||
try {
|
||||
socket.receive(dp)
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
continue
|
||||
}
|
||||
val pkt = Wire.parseVerified(buf, dp.length, key) ?: continue
|
||||
out.add(Received(pkt.type, pkt.seq, dp.length, (System.nanoTime() - epochNanos)))
|
||||
}
|
||||
} finally {
|
||||
socket.soTimeout = prevTimeout
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** One packet received from the server, with the wire size actually delivered. */
|
||||
data class Received(val type: Int, val seq: Int, val sizeBytes: Int, val tRxNs: Long)
|
||||
|
||||
private fun receive(wantType: Int): Wire.Packet? {
|
||||
val buf = ByteArray(2048)
|
||||
return try {
|
||||
|
||||
@@ -28,6 +28,9 @@ object Wire {
|
||||
const val TYPE_MTU_PROBE: Int = 0x09
|
||||
const val TYPE_MTU_ACK: Int = 0x0A
|
||||
const val TYPE_DELAYED_ECHO: Int = 0x0B
|
||||
/** Server->client under an asymmetric grant (spec §3.4/§5). */
|
||||
const val TYPE_DOWNTRAIN_DATA: Int = 0x06
|
||||
const val TYPE_BIG_SEND: Int = 0x0C
|
||||
|
||||
/** The 8-byte on-the-wire prefix = first 16 hex chars of the session id, decoded. */
|
||||
fun wirePrefix(sessionId: String): ByteArray {
|
||||
|
||||
Reference in New Issue
Block a user