diff --git a/docs/build-status.md b/docs/build-status.md index 66f7a9b..cf206c4 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -1399,6 +1399,20 @@ plus periodic M-SEARCH), LLMNR, NetBIOS-NS and WS-Discovery — all IP multicast sockets an app may open. The security reading matters as much as the inventory: LLMNR and NetBIOS-NS being live on a segment is a finding in itself, since both are trivially spoofable. +**One of those four cannot run at app tier either, and says so.** `local.netbios_inventory` +reports `unsupported` with the bind error attached: UDP 137 is below 1024, and Android reserves +privileged ports exactly like any other Linux. The decoder, the evidence shape and the registry +id are built and tested, waiting for the Shizuku tier to supply a socket. Recorded as a result +rather than dropped, so nobody later reads its absence as an oversight. + +Two deliberate restraints in that work, both worth keeping: no active WS-Discovery Probe (an +M-SEARCH is traffic every SSDP device expects constantly, whereas a WSD Probe from an unknown +host announces *this* device to the segment), and no NBSTAT sweep (that is host scanning, not +measurement — the app listens to what a network broadcasts, it does not interrogate its +neighbours). The parsers are also hardened against the input they will actually meet: a DNS +compression pointer is refused rather than followed (the classic parser hang), and the WSD +extractor is string-based on purpose, tested against an entity bomb and 20 000-deep nesting. + ## Design note: what BLE between two devices is actually for (2026-08-02, not built) Two or more phones running Echolot, talking over Bluetooth LE. The schema already anticipates diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index 8c26637..16c76ba 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -497,6 +497,22 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { listenMs = (windowMs - 2_000).coerceAtLeast(10_000) ) ), + // The rest of what a segment says about itself unprompted. All passive listeners, + // which is exactly why they belong to long mode: the announcements are periodic + // and sparse, so a thirty-second run mostly hears silence and would report an + // empty network as confidently as a quiet one. + // + // Searches are paced across the window rather than fired at the start: a device + // that was asleep for the first minute still gets asked. NetBIOS is included + // knowing it will usually report `unsupported` — UDP 137 is privileged, so the + // app tier cannot bind it — because a recorded reason beats an absent test, and + // the decoder is ready for the Shizuku tier. + app.echo_lot.probe.SsdpCollector( + searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000), + ), + app.echo_lot.probe.LlmnrCollector(), + app.echo_lot.probe.NetbiosCollector(), + app.echo_lot.probe.WsdCollector(), ) activeCollectors = collectors for (c in collectors) runCatching { c.start(ctx, ids) } diff --git a/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Classification.kt b/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Classification.kt index a425de6..29bdf14 100644 --- a/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Classification.kt +++ b/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Classification.kt @@ -40,6 +40,10 @@ object Classification { listOf( "addr", "address", "gateway", "dst", "src", "servers", "server", "resolver", "next_hop", "via", "public_ip", "observed_ip", + // Who sent each passive-discovery announcement (SSDP / LLMNR / NetBIOS / WS-Discovery). + // Family-agnostic on purpose: the same field carries a dotted quad from a v4 group and + // a link-local from ff02::c, and the v6 transform hands dotted quads to the v4 path. + "source_ip", ).forEach { put(it, LogicalType.IP6) } listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac") @@ -52,13 +56,28 @@ object Classification { "friendly_name", "server_name", "sni", "cname", "search_domain", "device_name", // Plural and prefixed variants the models actually use. "search_domains", "private_dns_hostname", "domains", "hostnames", + // Names a device shouts at the whole segment. An LLMNR question and a NetBIOS + // registration are hostnames in every sense that matters here — they name a machine on + // somebody's home network — so they get the same per-label treatment as any other. + "netbios_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( + "session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid", + // Discovery identities. A UPnP USN and a WS-Discovery endpoint UUID are stable, + // globally unique per device and frequently derived from a serial number — exactly the + // thing that lets two uploads be recognised as the same household. + "usn", "device_uuid", + ).forEach { put(it, LogicalType.OPAQUE_ID) } - listOf("notes", "detail", "raw", "excerpt", "location", "model_description") - .forEach { put(it, LogicalType.FREETEXT) } + listOf( + "notes", "detail", "raw", "excerpt", "location", "model_description", + // The make/model a device volunteers, and the URLs it points at. `server_banner` is + // deliberately not named `server`, which the family-agnostic address block already + // claims — a SERVER header run through the address transform would be mangled into + // nonsense while protecting nothing. + "server_banner", "product_hint", "wsd_types", "wsd_xaddrs", + ).forEach { put(it, LogicalType.FREETEXT) } } /** @@ -79,6 +98,13 @@ object Classification { private val droppedKeys = setOf( "ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results", "nearby_networks", "peers", "raw_dump", "dumpsys", + // The long run's passive-discovery inventories. Same argument as `ssdp_responders`, only + // more so: these are minutes of everything the segment said about itself — device models, + // hostnames, printers, who is looking for whom. Even fully pseudonymized the *shape* of a + // household is a fingerprint, no metric depends on the list (the counts live in `metrics`, + // which survives), and the collectors' own status fields stay behind to say the capture + // worked. Dropping beats mangling. + "ssdp_devices", "llmnr_queries", "netbios_names", "wsd_devices", ) fun typeOf(key: String, path: List): LogicalType? { diff --git a/echolot-app/core-probe/build.gradle.kts b/echolot-app/core-probe/build.gradle.kts index 0e41227..f08e4bd 100644 --- a/echolot-app/core-probe/build.gradle.kts +++ b/echolot-app/core-probe/build.gradle.kts @@ -28,4 +28,9 @@ dependencies { implementation(project(":core-measurement")) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.android) + // JVM unit tests for the pure wire-format decoders (DiscoveryParsers.kt) against realistic and + // deliberately malformed payloads — no device, no Android runtime. Same setup as core-shizuku's + // dump-parser tests: JUnit 4, because that is what AGP's unit-test source set runs. + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.junit4) } diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DiscoveryParsers.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DiscoveryParsers.kt new file mode 100644 index 0000000..d09500c --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DiscoveryParsers.kt @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +/** + * Wire-format decoders for the passive discovery collectors (SSDP, LLMNR, NetBIOS-NS, WS-Discovery). + * + * No Android imports on purpose: every byte these see was broadcast by an unidentified device on + * someone else's network, so they are the part of the collectors that most needs to be exercised + * against malformed input — and that is only cheap to do if it runs on a plain JVM. See + * DiscoveryParsersTest. + * + * The universal contract here is **return null, never throw**. A collector that dies on one + * malformed datagram loses the whole window's inventory, and a device that emits garbage is a + * device we still want counted. Every decoder therefore bounds-checks by hand rather than relying + * on an exception, and treats "this is not the protocol I parse" and "this is the protocol but it + * is broken" as the same answer: nothing to record. + */ + +// ---- shared ------------------------------------------------------------------------------- + +/** + * Makes a decoded string safe to embed in a JSON document. + * + * Names arrive as arbitrary bytes. Control characters would survive JSON encoding as escapes and + * turn up in a terminal that interprets them, and an unbounded length is a memory cost decided by + * whoever is shouting on the segment — so both are capped here rather than at each call site. + */ +internal fun sanitizeText(s: String, max: Int = 255): String { + val sb = StringBuilder(minOf(s.length, max)) + for (c in s) { + if (sb.length >= max) break + sb.append(if (c.isISOControl() || c == '�') '?' else c) + } + return sb.toString() +} + +private fun u8(b: ByteArray, i: Int) = b[i].toInt() and 0xFF +private fun u16(b: ByteArray, i: Int) = (u8(b, i) shl 8) or u8(b, i + 1) + +// ---- SSDP --------------------------------------------------------------------------------- + +enum class SsdpKind { ALIVE, BYEBYE, UPDATE, RESPONSE, SEARCH, OTHER } + +/** + * One SSDP message. [target] is NT for announcements and ST for search responses — they name the + * same thing (which service is being talked about) from the two sides of the conversation, so they + * collapse into one field. + */ +data class SsdpMessage( + val kind: SsdpKind, + val target: String?, + val usn: String?, + val serverBanner: String?, + val location: String?, +) + +object SsdpParser { + + /** + * SSDP is HTTP-shaped but is not HTTP: there is no framing, no content length, and vendors + * disagree about line endings. Parsing it as "a start line plus colon-separated headers, be + * liberal about the rest" is the whole job — running it through an HTTP client would reject + * messages that real devices send and that we want to count. + * + * ISO-8859-1 decoding because the headers are byte-oriented and this mapping is total: no byte + * sequence can fail to decode, so a device with a Latin-1 model name in its SERVER banner is + * recorded rather than replaced by question marks. + */ + fun parse(bytes: ByteArray, len: Int): SsdpMessage? { + if (len <= 0 || len > bytes.size) return null + val text = String(bytes, 0, len, Charsets.ISO_8859_1) + val lines = text.split('\n') + val start = lines.firstOrNull()?.trim().orEmpty() + if (start.isEmpty()) return null + + val headers = HashMap() + for (i in 1 until lines.size) { + val line = lines[i].trimEnd('\r') + if (line.isBlank()) break + val c = line.indexOf(':') + if (c <= 0) continue + val key = line.substring(0, c).trim().uppercase() + // First occurrence wins: a duplicated header is a device bug, and taking the first + // matches what every SSDP implementation in the wild does. + if (key !in headers) headers[key] = sanitizeText(line.substring(c + 1).trim(), 512) + } + + val kind = when { + start.startsWith("NOTIFY", ignoreCase = true) -> when (headers["NTS"]?.lowercase()) { + "ssdp:alive" -> SsdpKind.ALIVE + "ssdp:byebye" -> SsdpKind.BYEBYE + "ssdp:update" -> SsdpKind.UPDATE + else -> SsdpKind.OTHER + } + start.startsWith("M-SEARCH", ignoreCase = true) -> SsdpKind.SEARCH + start.startsWith("HTTP/", ignoreCase = true) -> SsdpKind.RESPONSE + else -> return null // not SSDP at all + } + + return SsdpMessage( + kind = kind, + target = headers["NT"] ?: headers["ST"], + usn = headers["USN"], + serverBanner = headers["SERVER"], + location = headers["LOCATION"], + ) + } + + /** + * The make/model inside a SERVER banner, as a hint. + * + * A banner reads `Linux/4.4 UPnP/1.0 Synology/DSM-7.3` or `FRITZ!Box 7590 UPnP/1.0`: the + * interesting token is whichever one is not the OS and not the protocol version, and there is + * no grammar that says which. Dropping the known-boilerplate tokens and keeping the rest is + * therefore a heuristic and is reported as such — [SsdpMessage.serverBanner] is kept verbatim + * beside it so nobody has to trust this to read the evidence. + */ + fun productHint(serverBanner: String?): String? { + val banner = serverBanner?.trim().orEmpty() + if (banner.isEmpty()) return null + val kept = banner.split(' ', '\t') + .map { it.trim() } + .filter { it.isNotEmpty() && it.substringBefore('/').lowercase() !in BOILERPLATE } + .distinct() + return kept.joinToString(" ").takeIf { it.isNotEmpty() } + } + + private val BOILERPLATE = setOf( + "upnp", "http", "dlnadoc", "linux", "unix", "windows", "darwin", "posix", "sdk", + "upnp-device-host", "microsoft-windows", "mono.upnp", "webos", "android", + ) +} + +// ---- DNS-format questions (LLMNR, and the shape NetBIOS-NS borrows) ------------------------- + +/** One question from a DNS-format packet. [isQuery] separates "who is asking" from "who answered". */ +data class DnsQuestion( + val id: Int, + val name: String, + val qtype: Int, + val qclass: Int, + val isQuery: Boolean, + val opcode: Int, +) + +object LlmnrParser { + + /** + * Decodes the first question of an LLMNR packet, which is DNS wire format with a different + * transport. + * + * Name compression is rejected rather than followed. RFC 4795 forbids it in LLMNR, so a pointer + * here is either a broken sender or someone hoping the parser will chase it — and a pointer + * loop is the classic way to hang a DNS decoder. Refusing costs nothing real and removes the + * only unbounded loop this decoder could have had. + */ + fun parse(bytes: ByteArray, len: Int): DnsQuestion? { + if (len < HEADER + 5 || len > bytes.size) return null + val flags = u16(bytes, 2) + if (u16(bytes, 4) < 1) return null // no question section + + val sb = StringBuilder() + var i = HEADER + var labels = 0 + while (true) { + if (i >= len) return null + val l = u8(bytes, i) + if (l == 0) { i++; break } + if (l and 0xC0 != 0) return null // compression pointer / reserved length + i++ + if (i + l > len) return null + if (++labels > MAX_LABELS || sb.length + l > MAX_NAME) return null + if (sb.isNotEmpty()) sb.append('.') + sb.append(String(bytes, i, l, Charsets.UTF_8)) + i += l + } + if (sb.isEmpty()) return null + if (i + 4 > len) return null + + return DnsQuestion( + id = u16(bytes, 0), + name = sanitizeText(sb.toString()), + qtype = u16(bytes, i), + qclass = u16(bytes, i + 2), + isQuery = (flags and 0x8000) == 0, + opcode = (flags shr 11) and 0x0F, + ) + } + + /** The record types worth naming in evidence; anything else is reported as its number. */ + fun qtypeName(qtype: Int): String = when (qtype) { + 1 -> "A" + 28 -> "AAAA" + 12 -> "PTR" + 33 -> "SRV" + 255 -> "ANY" + else -> qtype.toString() + } + + private const val HEADER = 12 + private const val MAX_LABELS = 64 + private const val MAX_NAME = 255 +} + +// ---- NetBIOS name service ------------------------------------------------------------------ + +/** + * A decoded NetBIOS name-service question. + * + * [suffix] is the sixteenth byte of the name and is the part an engineer reads first: it says what + * the announcement is *for* (a workstation, a file server, a browser election) rather than who is + * making it. + */ +data class NetbiosName( + val name: String, + val suffix: Int, + val role: String, + val isResponse: Boolean, + val opcode: Int, +) + +object NetbiosParser { + + /** + * Decodes the question name from an NBNS packet (RFC 1002 §4.2). + * + * The header is DNS-shaped, but the name is not: NetBIOS first-level encoding splits each of + * the 16 name bytes into two nibbles and adds 'A' to each, so a 16-byte name is always exactly + * 32 characters drawn from A-P. That fixed shape is also the validity check — anything outside + * A-P means this is not an NBNS name, and it is cheaper and safer to reject the packet than to + * guess at what a half-decodable name was supposed to say. + */ + fun parse(bytes: ByteArray, len: Int): NetbiosName? { + if (len < HEADER + 34 || len > bytes.size) return null + if (u16(bytes, 4) < 1) return null // no question section + if (u8(bytes, HEADER) != ENCODED_LEN) return null + + val raw = ByteArray(16) + for (j in 0 until 16) { + val hi = u8(bytes, HEADER + 1 + j * 2) - 'A'.code + val lo = u8(bytes, HEADER + 2 + j * 2) - 'A'.code + if (hi !in 0..15 || lo !in 0..15) return null + raw[j] = ((hi shl 4) or lo).toByte() + } + + // The first 15 bytes are the name, space-padded; the 16th is the suffix. + val padded = String(raw, 0, 15, Charsets.ISO_8859_1) + val name = sanitizeText(padded.trimEnd { it == ' ' || it.isISOControl() }, 15) + if (name.isEmpty()) return null + val suffix = raw[15].toInt() and 0xFF + val flags = u16(bytes, 2) + return NetbiosName( + name = name, + suffix = suffix, + role = roleOf(suffix), + isResponse = (flags and 0x8000) != 0, + opcode = (flags shr 11) and 0x0F, + ) + } + + /** RFC 1001 §15 / the Microsoft suffix assignments people actually see on a LAN. */ + fun roleOf(suffix: Int): String = when (suffix) { + 0x00 -> "workstation" + 0x03 -> "messenger" + 0x1B -> "domain_master_browser" + 0x1C -> "domain_controllers" + 0x1D -> "master_browser" + 0x1E -> "browser_elections" + 0x20 -> "file_server" + else -> "suffix_0x%02X".format(suffix) + } + + /** NBNS opcodes: what the sender is doing, not just that it is talking. */ + fun opcodeName(opcode: Int): String = when (opcode) { + 0 -> "query" + 5 -> "registration" + 6 -> "release" + 7 -> "wack" + 8 -> "refresh" + else -> "opcode_$opcode" + } + + private const val HEADER = 12 + private const val ENCODED_LEN = 32 +} + +// ---- WS-Discovery -------------------------------------------------------------------------- + +/** The four things a WS-Discovery datagram is worth reading for. */ +data class WsdMessage( + val action: String?, + val deviceUuid: String?, + val types: String?, + val xaddrs: String?, +) + +object WsdParser { + + /** + * Pulls four leaf values out of SOAP-over-UDP by targeted matching, deliberately **without an + * XML parser**. + * + * The input is unauthenticated, unsolicited, and written by whatever is on the segment. Handing + * that to a real XML parser buys namespace correctness and pays for it with the whole XML + * attack surface — entity expansion (a 700-byte datagram that allocates gigabytes), DTDs that + * fetch external resources, and nesting deep enough to exhaust the stack — all inside a probe + * whose contract is that it never throws. None of that surface is needed to read four leaf + * elements out of a message we are only ever going to count and quote. + * + * So: cap the text, then match `<[prefix:]Tag ...>value<` with a character class that cannot + * backtrack. The worst case is a value we fail to extract, which is recorded as an absence. + */ + fun parse(bytes: ByteArray, len: Int): WsdMessage? { + if (len <= 0 || len > bytes.size) return null + val text = String(bytes, 0, minOf(len, MAX_TEXT), Charsets.UTF_8) + if (!text.contains("Envelope", ignoreCase = true)) return null + + // The action tail is the message type — Hello, Bye, Probe, ProbeMatches, ResolveMatches. + val action = leaf(text, "Action")?.substringAfterLast('/')?.takeIf { it.isNotEmpty() } + // The device's stable identity is an EndpointReference/Address holding a urn:uuid. Other + // Address elements exist (wsa:To names the discovery group), so the uuid shape picks the + // right one rather than the first one. + val uuid = leaves(text, "Address").firstOrNull { it.contains("uuid:", ignoreCase = true) } + val msg = WsdMessage( + action = action?.let { sanitizeText(it, 64) }, + deviceUuid = uuid?.let { sanitizeText(it, 128) }, + types = leaf(text, "Types")?.let { sanitizeText(it, 200) }, + xaddrs = leaf(text, "XAddrs")?.let { sanitizeText(it, 300) }, + ) + val empty = msg.action == null && msg.deviceUuid == null && + msg.types == null && msg.xaddrs == null + return if (empty) null else msg + } + + private fun leaf(xml: String, tag: String): String? = leaves(xml, tag).firstOrNull() + + private fun leaves(xml: String, tag: String): List { + val re = TAGS[tag] ?: return emptyList() + return re.findAll(xml).map { it.groupValues[1].trim() }.filter { it.isNotEmpty() }.toList() + } + + /** + * `[^<]{0,512}` rather than a lazy `.*?`: it can only ever match forward, so there is no input + * that makes this regex expensive. WS-Discovery leaves hold no markup, so nothing is lost. + * + * Built once, eagerly, because the alternative is a memoizing map touched from the capture + * thread — a data race for the sake of four Regex allocations. + */ + private val TAGS: Map = + listOf("Action", "Address", "Types", "XAddrs").associateWith { tag -> + Regex( + """<(?:[A-Za-z0-9_.\-]{1,32}:)?$tag\b[^>]{0,256}>([^<]{0,512})<""", + RegexOption.IGNORE_CASE, + ) + } + + private const val MAX_TEXT = 16 * 1024 +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/LlmnrCollector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/LlmnrCollector.kt new file mode 100644 index 0000000..e604654 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/LlmnrCollector.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import app.echo_lot.measurement.Test +import app.echo_lot.measurement.TestError +import app.echo_lot.measurement.TestType +import app.echo_lot.measurement.Tier +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * local.llmnr_inventory — who is resolving names with LLMNR on this segment, and what for. + * + * The measurement is twofold, and the second half is the one people underestimate: + * + * 1. **Which hostnames are being looked up.** An LLMNR query is a device saying out loud, to + * everyone, the name of something it wants to reach. Over a window that is a map of who talks + * to whom — and, when the names are things like `wpad` or a server that no longer exists, a map + * of what is failing to resolve through DNS and falling back. + * 2. **That LLMNR is in use at all.** LLMNR (and its NetBIOS sibling) is a name-resolution + * fallback that trusts whoever answers first, which is the mechanism behind the standard + * credential-relay attack on Windows networks. Its mere presence on a segment is a finding + * independent of any individual query — which is why this earns a test id rather than folding + * into the mDNS inventory. + * + * Purely passive: queries are broadcast to the group, so listening is the entire measurement, and + * answering or querying would make this device a participant in exactly the trust relationship the + * measurement is about. + */ +class LlmnrCollector : BaseCollector() { + + override val type = TestType.LOCAL_LLMNR_INVENTORY + override val tier = Tier.APP + + private val capture = MulticastCapture( + label = "llmnr", + port = PORT, + group4 = GROUP4, + // ff02::1:3 is the IPv6 LLMNR group. Joined where IPv6 exists; a v4-only network simply + // records an empty joined_v6 rather than an error, because that is not one. + group6 = GROUP6, + ) + + private var startedAtMonoNs = 0L + + override suspend fun start(ctx: Context, ids: ProbeIds) { + begin(ids) + startedAtMonoNs = ids.monoNs() + capture.acquireLock(ctx) + capture.start(ids) + } + + override suspend fun stop(): Test { + val packets = capture.stop() + + val table = DiscoveryTable() + var queries = 0 + var responses = 0 + var undecodable = 0 + + for (p in packets) { + val q = LlmnrParser.parse(p.data, p.data.size) + if (q == null) { + undecodable++ + continue + } + if (q.isQuery) queries++ else responses++ + // Responses are normally unicast back to the querier, so what lands here is + // overwhelmingly queries — but a response that does reach the group is still a device + // claiming a name, which is worth the same row. + table.observe(p.sourceIp, "${q.name}/${q.qtype}", p.atMonoNs) { + buildJsonObject { + put("query_name", q.name) + put("qtype", LlmnrParser.qtypeName(q.qtype)) + put("kind", if (q.isQuery) "query" else "response") + } + } + } + + val evidence: JsonObject = buildJsonObject { + put("capture", capture.statusJson()) + put("llmnr_queries", table.toJson()) + put("evidence_truncated", capture.truncated || table.overflowed) + } + val metrics: JsonObject = buildJsonObject { + put("distinct_sources", table.distinctSources) + put("distinct_names", table.size) + put("packets", capture.packetsSeen) + put("queries", queries) + put("responses", responses) + put("undecodable_packets", undecodable) + // The headline: whether this protocol is live here at all. A boolean rather than an + // inference from a count, so a reader (or a future finding rule) never has to decide + // what "zero packets" meant — the capture's own status says whether zero is trustworthy. + put("llmnr_in_use", table.distinctSources > 0) + put("truncated", capture.truncated || table.overflowed) + } + val saw = !table.isEmpty + return build( + capture.outcome(saw), + evidence = evidence, + metrics = metrics, + params = params(), + error = capture.reason(saw)?.let { TestError("listen_incomplete", it) }, + ) + } + + private fun params(): JsonObject = buildJsonObject { + put("mode", "passive") + put("group_v4", "$GROUP4:$PORT") + put("group_v6", "[$GROUP6]:$PORT") + put("started_mono_ns", startedAtMonoNs) + } + + private companion object { + const val PORT = 5355 + const val GROUP4 = "224.0.0.252" + const val GROUP6 = "ff02::1:3" + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MulticastCapture.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MulticastCapture.kt new file mode 100644 index 0000000..ee74305 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/MulticastCapture.kt @@ -0,0 +1,420 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import android.net.wifi.WifiManager +import app.echo_lot.measurement.TestStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.add +import java.net.DatagramPacket +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.MulticastSocket +import java.net.NetworkInterface +import java.net.SocketAddress +import java.net.SocketTimeoutException + +/** + * The listening half of every passive-discovery collector: hold a multicast lock, bind a + * well-known UDP port, join a group on every interface that will take it, and retain the datagrams + * that arrive until the window closes. + * + * Four things here are the difference between a working listener and one that silently reports an + * empty network, and each of them has cost somebody a day: + * + * - **The MulticastLock.** Android's wifi stack filters out frames not addressed to this device + * unless an app holds one. Without it every collector built on this returns "nothing on this + * network" on a network full of chatter — the single most common reason a listener like this + * appears to work and measures nothing. Whether it was actually held is therefore reported, not + * assumed: an unheld lock plus silence is not a clean result and [outcome] refuses to call it one. + * - **SO_REUSEADDR before bind.** The well-known discovery ports are shared by construction — the + * system's own mDNS/SSDP responders and any other app doing this are already there — so binding + * exclusively fails on exactly the networks worth measuring. + * - **Joining per interface.** The group must be joined on the interface the traffic arrives on, + * which is not necessarily the default route: a phone with wifi plus a VPN plus cellular has + * three, and the LAN chatter is on the one that is not carrying the default route. + * - **A bound buffer, bounded per source too.** A chatty network must not decide how much memory + * this uses, and one device announcing every two seconds must not be able to fill the buffer and + * hide the twenty quieter devices behind it. Both caps set [truncated] rather than being silent. + * + * Nothing here throws. Every failure lands in [failure] (the capture is dead) or [degraded] (it is + * running but cannot see everything), which the owning collector turns into a recorded status. + */ +internal class MulticastCapture( + /** Names the MulticastLock, so a `dumpsys wifi` during a run says which listener holds what. */ + private val label: String, + private val port: Int, + private val group4: String? = null, + private val group6: String? = null, + private val maxPackets: Int = 400, + private val maxBytesRetained: Int = 128 * 1024, + private val maxPerSource: Int = 24, + /** + * Whether to fall back to an ephemeral port when the well-known one cannot be bound. + * + * Only useful for a protocol with an active half: replies to our own searches come back to + * whatever port we sent from, so an ephemeral socket still collects those — but it can never + * see the unsolicited announcements, which is why it is [degraded] and not normal operation. + */ + private val allowEphemeralFallback: Boolean = false, +) { + + /** One retained datagram. Not a data class: the payload is a ByteArray, and structural equality + * over it would be both wrong and expensive. */ + class Packet(val sourceIp: String, val atMonoNs: Long, val data: ByteArray) + + /** Set when the capture could not be brought up at all; the collector reports `unsupported`. */ + var failure: String? = null + private set + + /** Set when the capture is running but blind to part of what it exists to see. */ + var degraded: String? = null + private set + + var lockHeld: Boolean = false + private set + + var boundPort: Int = 0 + private set + + var packetsSeen: Int = 0 + private set + + var truncated: Boolean = false + private set + + val joined4 = ArrayList() + val joined6 = ArrayList() + private val joinErrors = ArrayList() + + /** Whether this protocol needs a group join at all — NetBIOS is broadcast, not multicast. */ + private val expectsGroup = group4 != null || group6 != null + + private val packets = ArrayList() + private val perSource = HashMap() + private var retainedBytes = 0 + + @Volatile private var running = false + private var socket: MulticastSocket? = null + private var lock: WifiManager.MulticastLock? = null + private var scope: CoroutineScope? = null + + /** + * Brings the listener up and returns whether it is capturing. Setup is a handful of syscalls, + * so it finishes in milliseconds and the [Collector.start] promptness contract holds; only the + * receive loop is handed to a background scope. + */ + suspend fun start(ids: ProbeIds): Boolean = withContext(Dispatchers.IO) { + val s = bind() ?: return@withContext false + socket = s + running = true + // A degraded (ephemeral-port) socket is deliberately not joined to the group: it would be + // joining for a port nothing sends to, and the resulting "joined wlan0" in the evidence + // would claim a capability the capture does not have. + if (expectsGroup && degraded == null) joinGroups(s) + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { it.launch { pump(s, ids) } } + true + } + + /** Acquires the multicast lock. Separate from [start] because it needs a Context and the rest + * does not, and because whether it succeeded is itself evidence. */ + fun acquireLock(ctx: Context) { + val wifi = runCatching { ctx.applicationContext.getSystemService(WifiManager::class.java) } + .getOrNull() + lock = runCatching { + wifi?.createMulticastLock("echolot-$label")?.apply { + setReferenceCounted(false) + acquire() + } + }.getOrNull() + lockHeld = runCatching { lock?.isHeld == true }.getOrDefault(false) + } + + /** Sends from the capture socket, so replies land back in this capture rather than on a second + * socket nobody is reading. Returns whether the datagram left the device. */ + fun send(payload: ByteArray, host: String, toPort: Int): Boolean = runCatching { + val s = socket ?: return false + s.send(DatagramPacket(payload, payload.size, InetSocketAddress(host, toPort))) + true + }.getOrDefault(false) + + /** Stops listening and hands back what was retained. Safe after a failed [start], and never + * throws — a cancelled run still owes the caller the packets it did see. */ + fun stop(): List { + running = false + // Closed before the coroutine is cancelled: a blocking receive() does not notice + // cancellation, and closing the socket is what makes it return. + runCatching { socket?.close() } + socket = null + scope?.coroutineContext?.get(Job)?.cancel() + scope = null + runCatching { lock?.release() } + lock = null + // lockHeld deliberately survives the release: it records whether the capture *could* hear + // multicast while it ran, which is what [outcome] needs to decide whether silence is a + // fact about the network. Clearing it here would make every quiet network report that the + // lock was missing. + return synchronized(packets) { packets.toList() } + } + + // ---- status ------------------------------------------------------------------------------ + + /** + * The status this capture's Test deserves, given whether anything was decoded. + * + * The interesting case is the last one. Silence on a network is a legitimate and useful + * finding — but only when we know the listener could have heard something. Without the + * multicast lock, or without a single successful group join, "nothing was seen" describes this + * app and not the network, and reporting it as `ok` would be the collector lying by omission. + */ + fun outcome(sawAnything: Boolean): TestStatus = when { + failure != null -> TestStatus.UNSUPPORTED + degraded != null -> TestStatus.PARTIAL + sawAnything -> TestStatus.OK + expectsGroup && joined4.isEmpty() && joined6.isEmpty() -> TestStatus.PARTIAL + !lockHeld -> TestStatus.PARTIAL + else -> TestStatus.OK + } + + /** Why [outcome] was not OK, in words, or null when it was. */ + fun reason(sawAnything: Boolean): String? = when { + failure != null -> failure + degraded != null -> degraded + sawAnything -> null + expectsGroup && joined4.isEmpty() && joined6.isEmpty() -> + "no interface accepted the group join, so silence here says nothing about the network" + !lockHeld -> + "the wifi multicast lock was not held, so silence here says nothing about the network" + else -> null + } + + /** The capture's own facts, for the collector's evidence. Every collector reports these + * identically so that "saw nothing" can always be told apart from "could not listen". */ + fun statusJson(): JsonObject = buildJsonObject { + put("multicast_lock", lockHeld) + put("bound_port", boundPort) + putJsonArray("joined_v4") { for (n in joined4) add(n) } + putJsonArray("joined_v6") { for (n in joined6) add(n) } + if (joinErrors.isNotEmpty()) { + put("join_errors", joinErrors.take(6).joinToString("; ").take(400)) + } + degraded?.let { put("degraded", it) } + failure?.let { put("failure", it) } + put("packets_seen", packetsSeen) + put("packets_retained", synchronized(packets) { packets.size }) + put("evidence_truncated", truncated) + } + + // ---- internals --------------------------------------------------------------------------- + + private fun bind(): MulticastSocket? { + // Unbound first, so SO_REUSEADDR is set *before* bind — setting it afterwards has no + // effect, and these ports are always already in use by something. + runCatching { + val s = MulticastSocket(null as SocketAddress?) + s.reuseAddress = true + s.bind(InetSocketAddress(port)) + s.soTimeout = SO_TIMEOUT_MS + boundPort = port + return s + }.onFailure { first -> + val why = describe(first) + if (!allowEphemeralFallback) { + // Ports below 1024 are privileged on Android as on any Linux, so this is the + // expected outcome for NetBIOS and is a finding rather than a bug — see + // NetbiosCollector. + failure = "could not bind UDP $port: $why" + return null + } + runCatching { + val s = MulticastSocket(null as SocketAddress?) + s.reuseAddress = true + s.bind(InetSocketAddress(0)) + s.soTimeout = SO_TIMEOUT_MS + boundPort = s.localPort + degraded = "UDP $port could not be bound ($why); listening on an ephemeral port " + + "instead, so only replies to our own searches are visible and unsolicited " + + "announcements are not" + return s + }.onFailure { failure = "could not bind UDP $port ($why) or any ephemeral port" } + } + return null + } + + private fun joinGroups(s: MulticastSocket) { + val ifaces = runCatching { NetworkInterface.getNetworkInterfaces()?.toList() } + .getOrNull().orEmpty() + .filter { + runCatching { it.isUp && !it.isLoopback && it.supportsMulticast() } + .getOrDefault(false) + } + val g4 = group4?.let { runCatching { InetAddress.getByName(it) }.getOrNull() } + val g6 = group6?.let { runCatching { InetAddress.getByName(it) }.getOrNull() } + + for (ni in ifaces) { + val addrs = runCatching { ni.inetAddresses.toList() }.getOrNull().orEmpty() + if (g4 != null && addrs.any { it is Inet4Address }) { + join(s, g4, ni)?.let { joined4.add(it) } + } + // A network with no IPv6 address is not a failure to report as an error — it is a + // v4-only network, which is most of them. Only interfaces that could have joined are + // asked to. + if (g6 != null && addrs.any { it is Inet6Address }) { + join(s, g6, ni)?.let { joined6.add(it) } + } + } + + // Last resort: let the kernel pick the interface. Some vendor builds refuse the explicit + // form on the very interface that carries the traffic, and a default-interface join is + // better than no listener at all. + if (joined4.isEmpty() && joined6.isEmpty()) { + @Suppress("DEPRECATION") + (g4 ?: g6)?.let { g -> + runCatching { s.joinGroup(g) } + .onSuccess { (if (g is Inet4Address) joined4 else joined6).add("(default)") } + .onFailure { joinErrors.add("default: ${describe(it)}") } + } + } + } + + private fun join(s: MulticastSocket, group: InetAddress, ni: NetworkInterface): String? = + runCatching { + s.joinGroup(InetSocketAddress(group, boundPort), ni) + ni.name + }.onFailure { + joinErrors.add("${ni.name}/${if (group is Inet4Address) "v4" else "v6"}: ${describe(it)}") + }.getOrNull() + + private fun pump(s: MulticastSocket, ids: ProbeIds) { + val buf = ByteArray(READ_BUFFER) + while (running) { + val p = DatagramPacket(buf, buf.size) + try { + s.receive(p) + } catch (t: SocketTimeoutException) { + // The timeout exists only so this loop notices `running` going false if the socket + // close somehow does not wake it. Nothing to record. + continue + } catch (t: Throwable) { + // The normal exit: stop() closed the socket underneath us. It is also what a + // vanishing interface looks like, and neither is worth a status of its own — the + // packets gathered so far are still the measurement. + return + } + packetsSeen++ + val ip = p.address?.hostAddress ?: continue + val len = p.length + if (len <= 0) continue + synchronized(packets) { + val fromThis = perSource[ip] ?: 0 + if (packets.size >= maxPackets || + retainedBytes + len > maxBytesRetained || + fromThis >= maxPerSource + ) { + truncated = true + } else { + packets.add(Packet(ip, ids.monoNs(), p.data.copyOf(len))) + perSource[ip] = fromThis + 1 + retainedBytes += len + } + } + } + } + + private companion object { + const val SO_TIMEOUT_MS = 1_000 + + + /** Larger than any discovery datagram anyone sends; oversized ones are truncated by the + * kernel, which the parsers survive by design. */ + const val READ_BUFFER = 4_096 + + fun describe(t: Throwable): String = + (t.message ?: t.javaClass.simpleName).take(160) + } +} + +/** + * The other half of what every discovery collector does: fold a stream of decoded messages into a + * deduplicated inventory of *things*, not packets. + * + * Deduplication is by source **and** identity, never by identity alone. Two devices announcing the + * same UPnP service type are two devices, and collapsing them would turn the one measurement worth + * having (how many things are on this segment) into a count of protocols. Conversely one device + * re-announcing every thirty seconds must not appear ten times — that is what [count] is for, and + * the repetition rate is itself readable from count over the window length. + * + * First-seen is kept on the monotonic clock per the two-clock rule: it answers "was this device + * here from the start, or did it appear four minutes in", which is exactly the question a long run + * exists to answer and the one a wall-clock stamp cannot be trusted for. + */ +internal class DiscoveryTable(private val maxEntries: Int = MAX_ENTRIES) { + + private class Row(val firstSeenMonoNs: Long, val fields: JsonObject) { + var count = 0 + } + + private val rows = LinkedHashMap, Row>() + private val sources = HashSet() + + /** True when a network was busy enough that entries had to be dropped — reported, never hidden. */ + var overflowed = false + private set + + /** + * [fields] is a lambda so the JSON for a repeat sighting is never built: on a chatty segment + * the overwhelming majority of packets are the same device saying the same thing again. + */ + fun observe(sourceIp: String, identity: String, atMonoNs: Long, fields: () -> JsonObject) { + sources.add(sourceIp) + val key = sourceIp to identity + val existing = rows[key] + if (existing != null) { + existing.count++ + return + } + if (rows.size >= maxEntries) { + overflowed = true + return + } + val built = buildJsonObject { + put("source_ip", sourceIp) + for ((k, v) in fields()) put(k, v) + } + rows[key] = Row(atMonoNs, built).also { it.count = 1 } + } + + val distinctSources: Int get() = sources.size + val size: Int get() = rows.size + val isEmpty: Boolean get() = rows.isEmpty() + + fun toJson(): kotlinx.serialization.json.JsonArray = kotlinx.serialization.json.JsonArray( + rows.values.map { r -> + JsonObject( + r.fields + mapOf( + "first_seen_mono_ns" to kotlinx.serialization.json.JsonPrimitive(r.firstSeenMonoNs), + "count" to kotlinx.serialization.json.JsonPrimitive(r.count), + ) + ) + } + ) + + private companion object { + /** Generous enough for any real segment, small enough that a broadcast storm cannot make + * one test dominate the document. */ + const val MAX_ENTRIES = 200 + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetbiosCollector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetbiosCollector.kt new file mode 100644 index 0000000..7766f87 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetbiosCollector.kt @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import app.echo_lot.measurement.Test +import app.echo_lot.measurement.TestError +import app.echo_lot.measurement.TestType +import app.echo_lot.measurement.Tier +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * local.netbios_inventory — NetBIOS name-service chatter (UDP 137) on this segment. + * + * NetBIOS name registration and query traffic is broadcast, not multicast, so there is no group to + * join: a socket bound to 137 sees it by being on the segment. Every packet carries a + * first-level-encoded name plus a suffix byte saying what the name is *for* — a workstation, a file + * server, a master browser — which makes a passive window over it a Windows-side inventory of the + * LAN, and, like LLMNR, a security observation in its own right: NBT-NS is the other half of the + * classic name-resolution spoofing surface. + * + * **Expect this to report `unsupported` on the app tier, and read that as a result rather than a + * bug.** 137 is below 1024, and Linux — Android included — reserves those ports for privileged + * processes; an unprivileged app UID cannot bind one. So the honest measurement here is usually + * "this tier cannot observe NetBIOS on this device", recorded with the exact bind error, rather + * than a silent absence that reads as a quiet network. The observation belongs to the Shizuku tier, + * whose shell UID can bind it; the collector is written now so that the decoder, the evidence shape + * and the registry id are settled and tested by the time that lands. + * + * An active alternative exists — send an NBSTAT query from an ephemeral port and read the unicast + * replies — and is deliberately not taken: it is a host sweep, which is scanning rather than + * measuring, and it would change what the run does to the network it is observing. + */ +class NetbiosCollector : BaseCollector() { + + override val type = TestType.LOCAL_NETBIOS_INVENTORY + override val tier = Tier.APP + + private val capture = MulticastCapture( + label = "netbios", + port = PORT, + // No group: NBT-NS is subnet broadcast. The multicast lock is still taken, because + // Android's wifi firmware filters broadcast as well as multicast under power save. + group4 = null, + group6 = null, + // Registration bursts repeat the same name several times a second; the per-source cap is + // what keeps one noisy Windows box from filling the buffer. + maxPerSource = 12, + ) + + private var startedAtMonoNs = 0L + + override suspend fun start(ctx: Context, ids: ProbeIds) { + begin(ids) + startedAtMonoNs = ids.monoNs() + capture.acquireLock(ctx) + capture.start(ids) + } + + override suspend fun stop(): Test { + val packets = capture.stop() + + val table = DiscoveryTable() + var queries = 0 + var registrations = 0 + var undecodable = 0 + + for (p in packets) { + val n = NetbiosParser.parse(p.data, p.data.size) + if (n == null) { + undecodable++ + continue + } + when (n.opcode) { + 0 -> queries++ + 5, 8 -> registrations++ + } + // Identity is name + suffix, not the name alone: one host registers the same name + // several times with different suffixes, and those are different facts about it. + table.observe(p.sourceIp, n.name + "#" + "%02X".format(n.suffix), p.atMonoNs) { + buildJsonObject { + put("netbios_name", n.name) + put("suffix", "0x%02X".format(n.suffix)) + put("role", n.role) + put("operation", NetbiosParser.opcodeName(n.opcode)) + put("kind", if (n.isResponse) "response" else "request") + } + } + } + + val evidence: JsonObject = buildJsonObject { + put("capture", capture.statusJson()) + put("netbios_names", table.toJson()) + put("evidence_truncated", capture.truncated || table.overflowed) + } + val metrics: JsonObject = buildJsonObject { + put("distinct_sources", table.distinctSources) + put("distinct_names", table.size) + put("packets", capture.packetsSeen) + put("name_queries", queries) + put("name_registrations", registrations) + put("undecodable_packets", undecodable) + put("netbios_in_use", table.distinctSources > 0) + put("truncated", capture.truncated || table.overflowed) + } + val saw = !table.isEmpty + return build( + capture.outcome(saw), + evidence = evidence, + metrics = metrics, + params = params(), + error = capture.reason(saw)?.let { + TestError(if (capture.failure != null) "port_unavailable" else "listen_incomplete", it) + }, + ) + } + + private fun params(): JsonObject = buildJsonObject { + put("mode", "passive") + put("port", PORT) + put("transport", "udp broadcast") + put("started_mono_ns", startedAtMonoNs) + } + + private companion object { + const val PORT = 137 + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/SsdpCollector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/SsdpCollector.kt new file mode 100644 index 0000000..fd4ae78 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/SsdpCollector.kt @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import app.echo_lot.measurement.Test +import app.echo_lot.measurement.TestError +import app.echo_lot.measurement.TestType +import app.echo_lot.measurement.Tier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * local.ssdp_inventory — what UPnP/SSDP devices are on this segment, gathered over the whole window. + * + * **Both halves are needed, and neither is sufficient.** Passive listening catches ssdp:alive and + * ssdp:byebye announcements, which is the only way to see a device that ignores searches (plenty + * do, deliberately) and the only way to see one leave. But announcements are periodic and sparse — + * a device re-announces on its own cache-control interval, commonly 30 minutes — so a five-minute + * passive window silently misses most of the segment. An M-SEARCH provokes an immediate reply from + * everything that is listening, which is most things, and catches the quiet ones. Running only the + * active half is what [RouterIdentityProbe] already does in the battery, and it is why that probe + * cannot tell you that a device disappeared halfway through the run. + * + * The searches are paced, not flooded: [maxSearches] of them spread [searchIntervalMs] apart. A + * repeat catches devices that joined the network after the run started or were asleep at t=0, while + * staying orders of magnitude below a rate that would itself perturb the network being measured — + * the tool must not become the fault it is looking for. `upnp:rootdevice` rather than `ssdp:all` + * for the same reason: one reply per device instead of one per service. + * + * The searches go out from the capture socket bound to 1900, so unicast replies land in the same + * capture as the multicast announcements rather than needing a second socket nobody is reading. + */ +class SsdpCollector( + private val searchIntervalMs: Long = 60_000, + private val maxSearches: Int = 5, +) : BaseCollector() { + + override val type = TestType.LOCAL_SSDP_INVENTORY + override val tier = Tier.APP + + private val capture = MulticastCapture( + label = "ssdp", + port = PORT, + group4 = GROUP4, + // The IPv6 link-local SSDP group. Free to join where IPv6 exists and skipped where it does + // not, so a v4-only network costs nothing and a v6-only device is not invisible. + group6 = GROUP6, + // SSDP is the chattiest of the four; a device announcing every service it hosts can emit a + // dozen NOTIFYs per cycle, so the per-source cap does most of the work here. + maxPackets = 600, + // The only one of the four with an active half worth keeping when 1900 is taken. + allowEphemeralFallback = true, + ) + + private var scope: CoroutineScope? = null + private var searchesSent = 0 + private var startedAtMonoNs = 0L + + override suspend fun start(ctx: Context, ids: ProbeIds) { + begin(ids) + startedAtMonoNs = ids.monoNs() + capture.acquireLock(ctx) + if (!capture.start(ids)) return + + val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it } + s.launch { + while (isActive && searchesSent < maxSearches) { + if (capture.send(MSEARCH, GROUP4, PORT)) searchesSent++ else break + delay(searchIntervalMs) + } + } + } + + override suspend fun stop(): Test { + scope?.coroutineContext?.get(Job)?.cancel() + scope = null + val packets = capture.stop() + + val table = DiscoveryTable() + var alive = 0 + var byebye = 0 + var responses = 0 + var undecodable = 0 + + for (p in packets) { + val msg = SsdpParser.parse(p.data, p.data.size) + if (msg == null) { + undecodable++ + continue + } + when (msg.kind) { + SsdpKind.ALIVE -> alive++ + SsdpKind.BYEBYE -> byebye++ + SsdpKind.RESPONSE -> responses++ + // Our own M-SEARCH comes back to us through the group; counting it as a device + // would inventory the phone doing the measuring. + SsdpKind.SEARCH -> continue + else -> Unit + } + // USN is the device+service identity SSDP itself uses; NT/ST is the fallback for + // devices that omit it, and the source IP already separates two devices offering the + // same service type. + val identity = msg.usn ?: msg.target ?: "(unidentified)" + table.observe(p.sourceIp, identity, p.atMonoNs) { + buildJsonObject { + put("usn", identity) + msg.target?.let { put("target", it) } + msg.serverBanner?.let { put("server_banner", it) } + SsdpParser.productHint(msg.serverBanner)?.let { put("product_hint", it) } + msg.location?.let { put("location", it) } + put("kind", msg.kind.name.lowercase()) + } + } + } + + val evidence: JsonObject = buildJsonObject { + put("capture", capture.statusJson()) + put("ssdp_devices", table.toJson()) + // Two truncation sources, reported apart: the capture dropping datagrams and the + // inventory dropping distinct entries mean different things about the network. + put("evidence_truncated", capture.truncated || table.overflowed) + } + val metrics: JsonObject = buildJsonObject { + put("distinct_sources", table.distinctSources) + put("distinct_advertisements", table.size) + put("packets", capture.packetsSeen) + put("alive", alive) + put("byebye", byebye) + put("search_responses", responses) + put("undecodable_packets", undecodable) + put("searches_sent", searchesSent) + put("truncated", capture.truncated || table.overflowed) + } + val status = capture.outcome(sawAnything = !table.isEmpty) + val reason = capture.reason(sawAnything = !table.isEmpty) + return build( + status, + evidence = evidence, + metrics = metrics, + params = params(), + error = reason?.let { TestError("listen_incomplete", it) }, + ) + } + + private fun params(): JsonObject = buildJsonObject { + put("mode", "passive+msearch") + put("group_v4", "$GROUP4:$PORT") + put("group_v6", "[$GROUP6]:$PORT") + put("search_target", SEARCH_TARGET) + put("search_interval_ms", searchIntervalMs) + put("max_searches", maxSearches) + put("started_mono_ns", startedAtMonoNs) + } + + private companion object { + const val PORT = 1900 + const val GROUP4 = "239.255.255.250" + const val GROUP6 = "ff02::c" + const val SEARCH_TARGET = "upnp:rootdevice" + + /** MX is the maximum random delay a responder waits, in seconds. 3 spreads the replies + * enough that a segment full of devices does not answer in one burst we then drop. */ + val MSEARCH: ByteArray = ( + "M-SEARCH * HTTP/1.1\r\n" + + "HOST: $GROUP4:$PORT\r\n" + + "MAN: \"ssdp:discover\"\r\n" + + "MX: 3\r\n" + + "ST: $SEARCH_TARGET\r\n\r\n" + ).toByteArray(Charsets.ISO_8859_1) + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/WsdCollector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/WsdCollector.kt new file mode 100644 index 0000000..139df71 --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/WsdCollector.kt @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import app.echo_lot.measurement.Test +import app.echo_lot.measurement.TestError +import app.echo_lot.measurement.TestType +import app.echo_lot.measurement.Tier +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * local.wsd_inventory — WS-Discovery (SOAP-over-UDP, 3702) Hello / Bye / Probe / ProbeMatches. + * + * This is the protocol Windows and modern printers use to find each other, and it inventories a + * class of device the other three miss: network printers, scanners and IP cameras announce here and + * frequently nowhere else. A `Hello` is a device arriving, a `Bye` is one leaving, and a `Probe` + * from a workstation names what it is hunting for — so a window over 3702 shows both the equipment + * on the segment and which machines are looking for it. + * + * Passive only. WS-Discovery's active half is a Probe multicast, which would make this app a + * participant announcing itself to every device on the segment; SSDP's M-SEARCH is a single small + * request that devices expect constantly, whereas a WSD Probe from an unknown host is the kind of + * thing that shows up in someone's security log. Listening costs the network nothing. + * + * Payloads are decoded by [WsdParser], which does targeted extraction rather than XML parsing — + * see its documentation for why that is the right call for unauthenticated broadcast input. + */ +class WsdCollector : BaseCollector() { + + override val type = TestType.LOCAL_WSD_INVENTORY + override val tier = Tier.APP + + private val capture = MulticastCapture( + label = "wsd", + port = PORT, + group4 = GROUP4, + group6 = GROUP6, + // SOAP envelopes are an order of magnitude larger than the other three protocols' + // datagrams, so the byte ceiling binds before the packet count does. Both are set + // explicitly rather than left to the default, which was chosen for 200-byte packets. + maxPackets = 250, + maxBytesRetained = 192 * 1024, + ) + + private var startedAtMonoNs = 0L + + override suspend fun start(ctx: Context, ids: ProbeIds) { + begin(ids) + startedAtMonoNs = ids.monoNs() + capture.acquireLock(ctx) + capture.start(ids) + } + + override suspend fun stop(): Test { + val packets = capture.stop() + + val table = DiscoveryTable() + var hello = 0 + var bye = 0 + var probes = 0 + var matches = 0 + var undecodable = 0 + + for (p in packets) { + val m = WsdParser.parse(p.data, p.data.size) + if (m == null) { + undecodable++ + continue + } + when (m.action?.lowercase()) { + "hello" -> hello++ + "bye" -> bye++ + "probe" -> probes++ + "probematches", "resolvematches" -> matches++ + } + // The device UUID is WS-Discovery's own stable identity and survives address changes, + // so it is the identity where present; a Probe carries none (it names what it wants, + // not who it is), and there the action plus the types is what distinguishes one + // observation from a repeat of it. + val identity = m.deviceUuid ?: (m.action.orEmpty() + "/" + m.types.orEmpty()) + table.observe(p.sourceIp, identity.ifEmpty { "(unidentified)" }, p.atMonoNs) { + buildJsonObject { + m.deviceUuid?.let { put("device_uuid", it) } + m.action?.let { put("action", it) } + m.types?.let { put("wsd_types", it) } + m.xaddrs?.let { put("wsd_xaddrs", it) } + } + } + } + + val evidence: JsonObject = buildJsonObject { + put("capture", capture.statusJson()) + put("wsd_devices", table.toJson()) + put("evidence_truncated", capture.truncated || table.overflowed) + } + val metrics: JsonObject = buildJsonObject { + put("distinct_sources", table.distinctSources) + put("distinct_devices", table.size) + put("packets", capture.packetsSeen) + put("hello", hello) + put("bye", bye) + put("probes", probes) + put("probe_matches", matches) + put("undecodable_packets", undecodable) + put("truncated", capture.truncated || table.overflowed) + } + val saw = !table.isEmpty + return build( + capture.outcome(saw), + evidence = evidence, + metrics = metrics, + params = params(), + error = capture.reason(saw)?.let { TestError("listen_incomplete", it) }, + ) + } + + private fun params(): JsonObject = buildJsonObject { + put("mode", "passive") + put("group_v4", "$GROUP4:$PORT") + put("group_v6", "[$GROUP6]:$PORT") + put("started_mono_ns", startedAtMonoNs) + } + + private companion object { + const val PORT = 3702 + const val GROUP4 = "239.255.255.250" + const val GROUP6 = "ff02::c" + } +} diff --git a/echolot-app/core-probe/src/test/kotlin/app/echo_lot/probe/DiscoveryParsersTest.kt b/echolot-app/core-probe/src/test/kotlin/app/echo_lot/probe/DiscoveryParsersTest.kt new file mode 100644 index 0000000..a7eee02 --- /dev/null +++ b/echolot-app/core-probe/src/test/kotlin/app/echo_lot/probe/DiscoveryParsersTest.kt @@ -0,0 +1,386 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The decoders in DiscoveryParsers.kt, against payloads shaped like the ones real devices emit and + * against the ones a broken or hostile device emits. + * + * The malformed cases are the point. These four decoders are the only place in the app where bytes + * from an unidentified third party on the local segment are interpreted; they run inside a + * collector whose contract is that it never throws, and every one of them is reachable by anyone + * who can put a frame on the wire. So each protocol is fed truncation, junk, and the specific abuse + * its format invites — a DNS compression pointer, a NetBIOS name outside the A-P alphabet, an XML + * entity bomb — and the assertion is always the same pair: no exception, and no invented data. + */ +class DiscoveryParsersTest { + + private fun bytes(s: String) = s.toByteArray(Charsets.ISO_8859_1) + + // ---- SSDP -------------------------------------------------------------------------------- + + private val notifyAlive = bytes( + "NOTIFY * HTTP/1.1\r\n" + + "HOST: 239.255.255.250:1900\r\n" + + "CACHE-CONTROL: max-age=1800\r\n" + + "LOCATION: http://192.168.1.44:8060/\r\n" + + "NT: upnp:rootdevice\r\n" + + "NTS: ssdp:alive\r\n" + + "SERVER: Roku/12.5.5 UPnP/1.0 Roku/12.5.5\r\n" + + "USN: uuid:roku:ecp:YH00E1234567::upnp:rootdevice\r\n\r\n" + ) + + private val searchResponse = bytes( + "HTTP/1.1 200 OK\r\n" + + "CACHE-CONTROL: max-age=1800\r\n" + + "EXT:\r\n" + + "LOCATION: http://192.168.1.1:49000/rootDesc.xml\r\n" + + "SERVER: FRITZ!Box 7590 UPnP/1.0 AVM FRITZ!Box 7590 154.07.57\r\n" + + "ST: upnp:rootdevice\r\n" + + "USN: uuid:75802409-bccb-40e7-8e6c-c0ff33445566::upnp:rootdevice\r\n\r\n" + ) + + @Test + fun ssdpAliveAnnouncementYieldsIdentityAndLocation() { + val m = assertNotNull(SsdpParser.parse(notifyAlive, notifyAlive.size)) + assertEquals(SsdpKind.ALIVE, m.kind) + assertEquals("upnp:rootdevice", m.target) + assertEquals("uuid:roku:ecp:YH00E1234567::upnp:rootdevice", m.usn) + assertEquals("http://192.168.1.44:8060/", m.location) + assertEquals("Roku/12.5.5 UPnP/1.0 Roku/12.5.5", m.serverBanner) + } + + @Test + fun ssdpByebyeIsDistinguishedFromAlive() { + val byebye = bytes( + "NOTIFY * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n" + + "NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\nNTS: ssdp:byebye\r\n" + + "USN: uuid:aabbccdd::urn:schemas-upnp-org:device:MediaRenderer:1\r\n\r\n" + ) + val m = assertNotNull(SsdpParser.parse(byebye, byebye.size)) + assertEquals(SsdpKind.BYEBYE, m.kind) + assertEquals("urn:schemas-upnp-org:device:MediaRenderer:1", m.target) + assertNull(m.location) + } + + @Test + fun ssdpSearchResponseReadsStAsTheTarget() { + val m = assertNotNull(SsdpParser.parse(searchResponse, searchResponse.size)) + assertEquals(SsdpKind.RESPONSE, m.kind) + assertEquals("upnp:rootdevice", m.target) + assertEquals("http://192.168.1.1:49000/rootDesc.xml", m.location) + } + + /** Our own M-SEARCH comes back through the group; it must be recognisable so the collector + * does not inventory the phone doing the measuring. */ + @Test + fun ssdpOwnSearchIsRecognisable() { + val search = bytes( + "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n" + + "MAN: \"ssdp:discover\"\r\nMX: 3\r\nST: upnp:rootdevice\r\n\r\n" + ) + assertEquals(SsdpKind.SEARCH, assertNotNull(SsdpParser.parse(search, search.size)).kind) + } + + @Test + fun ssdpProductHintDropsBoilerplateAndKeepsTheModel() { + assertEquals("Roku/12.5.5", SsdpParser.productHint("Roku/12.5.5 UPnP/1.0 Roku/12.5.5")) + assertEquals("Synology/DSM-7.3", SsdpParser.productHint("Linux/4.4 UPnP/1.0 Synology/DSM-7.3")) + val fritz = assertNotNull(SsdpParser.productHint("FRITZ!Box 7590 UPnP/1.0 AVM FRITZ!Box 7590")) + assertTrue(fritz.contains("FRITZ!Box")) + assertFalse(fritz.contains("UPnP"), "protocol boilerplate leaked into the model hint") + assertNull(SsdpParser.productHint(null)) + assertNull(SsdpParser.productHint(" ")) + // A banner that is nothing but boilerplate reveals no model and must say so, rather than + // returning an empty string that reads as a name nobody could see. + assertNull(SsdpParser.productHint("Linux/4.4 UPnP/1.0")) + } + + @Test + fun ssdpMalformedIsRejectedWithoutThrowing() { + // Truncated mid-header: the headers that did arrive are still usable, and the missing NTS + // makes the kind unknown rather than making the packet a lie. + val cut = notifyAlive.copyOf(70) + assertEquals(SsdpKind.OTHER, assertNotNull(SsdpParser.parse(cut, cut.size)).kind) + + assertNull(SsdpParser.parse(ByteArray(0), 0)) + val blank = bytes("\r\n\r\n") + assertNull(SsdpParser.parse(blank, blank.size)) + val http = bytes("GET / HTTP/1.0\r\n\r\n") + assertNull(SsdpParser.parse(http, http.size), "not an SSDP verb") + // Arbitrary binary, including the high bytes ISO-8859-1 must not choke on. + val junk = ByteArray(256) { it.toByte() } + assertNull(SsdpParser.parse(junk, junk.size)) + // A declared length longer than the buffer must be refused, not read past. + assertNull(SsdpParser.parse(notifyAlive, notifyAlive.size + 100)) + // Header lines with no colon are skipped rather than fatal. + val noColon = bytes("NOTIFY * HTTP/1.1\r\ngarbage line\r\nNTS: ssdp:alive\r\n\r\n") + assertEquals(SsdpKind.ALIVE, assertNotNull(SsdpParser.parse(noColon, noColon.size)).kind) + } + + // ---- LLMNR ------------------------------------------------------------------------------- + + /** Builds a DNS-format packet: header, one question, nothing else. */ + private fun dnsQuery( + name: String, + qtype: Int = 1, + id: Int = 0x1234, + flags: Int = 0x0000, + qdcount: Int = 1, + ): ByteArray { + val out = ArrayList() + fun u16(v: Int) { out.add((v shr 8).toByte()); out.add((v and 0xFF).toByte()) } + u16(id); u16(flags); u16(qdcount); u16(0); u16(0); u16(0) + for (label in name.split('.')) { + val b = label.toByteArray(Charsets.UTF_8) + out.add(b.size.toByte()) + b.forEach { out.add(it) } + } + out.add(0) + u16(qtype); u16(1) + return out.toByteArray() + } + + @Test + fun llmnrQueryYieldsTheNameAWorkstationIsHuntingFor() { + val p = dnsQuery("wpad") + val q = assertNotNull(LlmnrParser.parse(p, p.size)) + assertEquals("wpad", q.name) + assertEquals(1, q.qtype) + assertEquals("A", LlmnrParser.qtypeName(q.qtype)) + assertTrue(q.isQuery) + assertEquals(0, q.opcode) + } + + @Test + fun llmnrMultiLabelNamesAndAaaaSurviveIntact() { + val p = dnsQuery("nas-backup.local", qtype = 28) + val q = assertNotNull(LlmnrParser.parse(p, p.size)) + assertEquals("nas-backup.local", q.name) + assertEquals("AAAA", LlmnrParser.qtypeName(q.qtype)) + } + + @Test + fun llmnrResponsesAreSeparatedFromQueries() { + val p = dnsQuery("DESKTOP-A1B2C3", flags = 0x8000) + assertFalse(assertNotNull(LlmnrParser.parse(p, p.size)).isQuery) + } + + @Test + fun llmnrMalformedIsRejectedWithoutThrowing() { + val good = dnsQuery("printer") + + assertNull(LlmnrParser.parse(ByteArray(0), 0)) + assertNull(LlmnrParser.parse(good, 8), "a header-length prefix is not a question") + assertNull(LlmnrParser.parse(good, good.size + 50), "declared length past the buffer") + + val noQuestion = dnsQuery("x", qdcount = 0) + assertNull(LlmnrParser.parse(noQuestion, noQuestion.size)) + + // A label length that runs off the end of the datagram — the classic truncation. + val overrun = good.copyOf(good.size - 6) + assertNull(LlmnrParser.parse(overrun, overrun.size)) + + // A compression pointer: legal DNS, forbidden in LLMNR, and the shape that makes a naive + // decoder loop forever. It must be refused rather than followed. + val pointer = good.copyOf(20) + pointer[12] = 0xC0.toByte() + pointer[13] = 0x0C + assertNull(LlmnrParser.parse(pointer, pointer.size)) + + // Random bytes behind a plausible header: whatever comes back, it is not an exception. + val junk = ByteArray(64) { (it * 37).toByte() } + junk[4] = 0; junk[5] = 1 + LlmnrParser.parse(junk, junk.size) + } + + // ---- NetBIOS ----------------------------------------------------------------------------- + + /** First-level encoding, the same transform the decoder has to undo. */ + private fun nbnsPacket(name: String, suffix: Int, flags: Int = 0x2810): ByteArray { + val raw = ByteArray(16) { ' '.code.toByte() } + name.forEachIndexed { i, c -> if (i < 15) raw[i] = c.code.toByte() } + raw[15] = suffix.toByte() + + val out = ArrayList() + fun u16(v: Int) { out.add((v shr 8).toByte()); out.add((v and 0xFF).toByte()) } + u16(0x8001); u16(flags); u16(1); u16(0); u16(0); u16(1) + out.add(32) + for (b in raw) { + val v = b.toInt() and 0xFF + out.add(('A'.code + (v shr 4)).toByte()) + out.add(('A'.code + (v and 0x0F)).toByte()) + } + out.add(0) + u16(0x0020); u16(0x0001) + return out.toByteArray() + } + + @Test + fun netbiosNameDecodesWithItsSuffixAndRole() { + val p = nbnsPacket("DESKTOP-A1B2C3", 0x20) + val n = assertNotNull(NetbiosParser.parse(p, p.size)) + assertEquals("DESKTOP-A1B2C3", n.name) + assertEquals(0x20, n.suffix) + assertEquals("file_server", n.role) + assertFalse(n.isResponse) + assertEquals("registration", NetbiosParser.opcodeName(n.opcode)) + } + + @Test + fun netbiosPaddingIsStrippedAndSuffixesAreNamed() { + val p = nbnsPacket("WORKGROUP", 0x1E) + val n = assertNotNull(NetbiosParser.parse(p, p.size)) + assertEquals("WORKGROUP", n.name, "the 15-byte space padding leaked into the name") + assertEquals("browser_elections", n.role) + assertEquals("workstation", NetbiosParser.roleOf(0x00)) + assertEquals("suffix_0xAB", NetbiosParser.roleOf(0xAB)) + } + + @Test + fun netbiosResponsesAreSeparatedFromRequests() { + val p = nbnsPacket("FILESRV", 0x20, flags = 0x8500) + assertTrue(assertNotNull(NetbiosParser.parse(p, p.size)).isResponse) + } + + @Test + fun netbiosMalformedIsRejectedWithoutThrowing() { + val good = nbnsPacket("PRINTER", 0x00) + + assertNull(NetbiosParser.parse(ByteArray(0), 0)) + assertNull(NetbiosParser.parse(good, 20), "truncated before the encoded name ends") + assertNull(NetbiosParser.parse(good, good.size + 40), "declared length past the buffer") + + // A character outside A-P cannot be half of an encoded byte. Guessing at the rest would + // fabricate a hostname, so the whole packet is refused. + val badAlphabet = good.copyOf() + badAlphabet[15] = 'Z'.code.toByte() + assertNull(NetbiosParser.parse(badAlphabet, badAlphabet.size)) + + // The length byte must be exactly 32; anything else is a different protocol on this port. + val badLen = good.copyOf() + badLen[12] = 16 + assertNull(NetbiosParser.parse(badLen, badLen.size)) + + // A name that decodes to nothing but padding is not a name. + val blank = nbnsPacket("", 0x00) + assertNull(NetbiosParser.parse(blank, blank.size)) + + val junk = ByteArray(80) { (it * 13).toByte() } + NetbiosParser.parse(junk, junk.size) + } + + @Test + fun netbiosControlCharactersInANameAreNeutralised() { + // Legal first-level encoding, illegal content: these bytes decode cleanly and would + // otherwise reach a JSON document, and from there somebody's terminal. + val p = nbnsPacket("A\u0001B\u0002C", 0x00) + assertEquals("A?B?C", assertNotNull(NetbiosParser.parse(p, p.size)).name) + } + + // ---- WS-Discovery ------------------------------------------------------------------------ + + private val wsdHello = bytes( + """ + + + urn:schemas-xmlsoap-org:ws:2005:04:discovery + http://schemas.xmlsoap.org/ws/2005/04/discovery/Hello + urn:uuid:0a7e6d1b-0000-4000-8000-000000000001 + + + + + urn:uuid:9f8e7d6c-1111-4222-8333-444455556666 + + wsdp:Device pub:Computer + http://192.168.1.77:5357/8f2c-4b1a/ + 1 + + + """ + ) + + @Test + fun wsdHelloYieldsActionUuidTypesAndXaddrs() { + val m = assertNotNull(WsdParser.parse(wsdHello, wsdHello.size)) + assertEquals("Hello", m.action) + assertEquals( + "urn:uuid:9f8e7d6c-1111-4222-8333-444455556666", m.deviceUuid, + "the EndpointReference address is the device identity, not the header MessageID", + ) + assertEquals("wsdp:Device pub:Computer", m.types) + assertEquals("http://192.168.1.77:5357/8f2c-4b1a/", m.xaddrs) + } + + @Test + fun wsdProbeCarriesNoDeviceIdentityAndIsStillRecorded() { + val probe = bytes( + """ + + http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe + + wsdp:Device + """ + ) + val m = assertNotNull(WsdParser.parse(probe, probe.size)) + assertEquals("Probe", m.action) + assertNull(m.deviceUuid) + assertEquals("wsdp:Device", m.types) + } + + @Test + fun wsdMalformedIsRejectedWithoutThrowing() { + assertNull(WsdParser.parse(ByteArray(0), 0)) + assertNull(WsdParser.parse(wsdHello, wsdHello.size + 100), "declared length past the buffer") + val prose = bytes("hello world") + assertNull(WsdParser.parse(prose, prose.size), "not a SOAP envelope") + // An envelope with no leaf worth reading is an absence, not an empty device. + val bare = bytes("") + assertNull(WsdParser.parse(bare, bare.size)) + + // Cut mid-element: whatever was complete is extracted, the rest is simply absent. + val cut = wsdHello.copyOf(wsdHello.size / 2) + WsdParser.parse(cut, cut.size)?.let { assertNull(it.xaddrs, "an unterminated element was invented") } + + val junk = ByteArray(512) { (it * 7).toByte() } + assertNull(WsdParser.parse(junk, junk.size)) + } + + @Test + fun wsdHostileXmlCostsNothingBecauseNothingParsesItAsXml() { + // A billion-laughs entity bomb. Against a real XML parser this expands to gigabytes; here + // the entities are never resolved, which is the entire reason for not using one. + val bomb = bytes( + "" + + (0..8).joinToString("") { i -> + val prev = if (i == 0) "" else (i - 1).toString() + "" + } + + "]>x/Bye&lol8;" + ) + assertEquals("Bye", assertNotNull(WsdParser.parse(bomb, bomb.size)).action) + + // Nesting deep enough to blow a recursive-descent parser's stack. + val deep = bytes("" + "".repeat(20_000) + "") + WsdParser.parse(deep, deep.size) + + // A payload far larger than any real datagram, pinning that the text cap is applied before + // the matching rather than after it. + val huge = bytes("x/Hello" + "z".repeat(200_000)) + assertEquals("Hello", assertNotNull(WsdParser.parse(huge, huge.size)).action) + } +}