Skip to content

Commit af10828

Browse files
committed
Make the Diagnostics tab show what the Portal actually said
The tab has been empty since it was drawn. `SomewhereApp` called `DiagnosticsScreen()` with no arguments and the parameter defaulted to an empty list — honest, in that it showed nothing rather than fabricating, and NW-A-06 unimplemented. A flow that reaches a Portal now records what came back: the SetupResult, when, the target as host and port, the flow id, and which carrier answered. Only a Portal's own answer is recorded as one. A direct flow never asks a Portal anything, and a rejection that carries no SetupResult — silence, most often — has no result to report; inventing one would put a word in the Portal's mouth. The log lives on TunnelController rather than in the service, because the service is created and destroyed with each tunnel while the screen that reads it outlives both, and a log that vanished when the tunnel stopped would lose exactly the entries a user goes looking for. Two hundred entries, newest first, oldest dropped: a busy device opens hundreds of flows a minute and a log that grew with the tunnel would be a memory leak with a user interface. People paste logs into issues, and this repository is public. The guarantee is structural rather than careful: an entry has a result, a timestamp, a target, a flow id and a carrier, and there is no field a shared key or a subscription token could occupy. A test asserts the field list, so an entry that grew one fails rather than shipping. The target is shown, and should be — it is where the user asked to go. What it must not be is a URL. Every character that makes a `nowhere://` URL, a subscription URL or an Authorization header what it is, is one a hostname cannot contain, so the host is reduced to what a hostname can be. Five hundred random hosts up to U+02FF come back containing nothing else. The first version of that test asserted something else and was wrong: it demanded that no credential-shaped *substring* survive, which would have meant stripping the destination itself. What it caught by being wrong is the right statement — the log is safe because of what it has no room for, not because of what it filters. Gates green; device suite 51 cases.
1 parent f5b32a8 commit af10828

6 files changed

Lines changed: 330 additions & 2 deletions

File tree

‎app/src/main/kotlin/eu/nodepass/somewhere/ui/SomewhereApp.kt‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import androidx.compose.material3.Icon
2222
import androidx.compose.material3.Text
2323
import androidx.compose.runtime.Composable
2424
import androidx.compose.runtime.LaunchedEffect
25+
import androidx.compose.runtime.collectAsState
2526
import androidx.compose.runtime.getValue
2627
import androidx.compose.runtime.mutableStateOf
2728
import androidx.compose.runtime.remember
@@ -54,6 +55,7 @@ import eu.nodepass.somewhere.ui.screens.RoutingScreen
5455
import eu.nodepass.somewhere.ui.screens.SettingsScreen
5556
import eu.nodepass.somewhere.ui.theme.SomewhereTheme
5657
import eu.nodepass.somewhere.ui.theme.SomewhereType
58+
import eu.nodepass.somewhere.vpn.TunnelController
5759

5860
/** The four destinations that carry a tab. Everything else is pushed over them. */
5961
enum class Tab(
@@ -141,7 +143,10 @@ fun SomewhereApp(
141143
onImportRules = onImportRules,
142144
)
143145
}
144-
composable(Tab.Logs.route) { DiagnosticsScreen() }
146+
composable(Tab.Logs.route) {
147+
val entries by TunnelController.connectionLog.recent.collectAsState()
148+
DiagnosticsScreen(entries = entries)
149+
}
145150
composable(Routes.EDITOR) {
146151
NodeEditorScreen(
147152
nodes = nodes,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.vpn
5+
6+
import eu.nodepass.somewhere.protocol.frame.SetupResult
7+
import eu.nodepass.somewhere.protocol.target.Target
8+
import eu.nodepass.somewhere.ui.state.ConnectionLogEntry
9+
import kotlinx.coroutines.flow.MutableStateFlow
10+
import kotlinx.coroutines.flow.StateFlow
11+
import kotlinx.coroutines.flow.asStateFlow
12+
import java.text.SimpleDateFormat
13+
import java.util.Date
14+
import java.util.Locale
15+
16+
/**
17+
* What the Portal answered, flow by flow (NW-A-06).
18+
*
19+
* ## Bounded, and what that costs
20+
*
21+
* A tunnel opens a flow per connection and a busy device opens hundreds a
22+
* minute, so this keeps the most recent [CAPACITY] and drops the rest. A log
23+
* that grew with the tunnel would be a memory leak with a user interface.
24+
*
25+
* ## What must never appear here, and why it is checked
26+
*
27+
* People paste logs into issues. This repository is public, and a shared key or
28+
* a subscription token in a pasted log is a credential published by someone
29+
* trying to be helpful — which is exactly how this project already shipped one
30+
* defect, through a subscription title that could rewrite the stored URL.
31+
*
32+
* So an entry carries a `SetupResult`, a timestamp, a flow id, a carrier name
33+
* and **a target that has been reduced to a host and port**. No URL, no key, no
34+
* subscription. That is not enforced by care; `ConnectionLogTest` fuzzes
35+
* credential-shaped values through every field and fails if one comes out.
36+
*/
37+
class ConnectionLog(
38+
private val now: () -> Long = System::currentTimeMillis,
39+
) {
40+
private val entries = ArrayDeque<ConnectionLogEntry>()
41+
private val mutable = MutableStateFlow<List<ConnectionLogEntry>>(emptyList())
42+
43+
/** Most recent first, which is the order a reader wants and the screen draws. */
44+
val recent: StateFlow<List<ConnectionLogEntry>> = mutable.asStateFlow()
45+
46+
fun record(
47+
result: SetupResult,
48+
target: Target?,
49+
flowId: UInt?,
50+
carrier: String,
51+
) {
52+
val entry =
53+
ConnectionLogEntry(
54+
result = result,
55+
timestamp = FORMAT.format(Date(now())),
56+
target = target?.let(::describe),
57+
flowId = flowId?.toInt(),
58+
carrier = carrier,
59+
)
60+
synchronized(entries) {
61+
entries.addFirst(entry)
62+
while (entries.size > CAPACITY) entries.removeLast()
63+
mutable.value = entries.toList()
64+
}
65+
}
66+
67+
fun clear() {
68+
synchronized(entries) {
69+
entries.clear()
70+
mutable.value = emptyList()
71+
}
72+
}
73+
74+
/**
75+
* A target as `host:port`, and nothing else.
76+
*
77+
* A domain target already is one. An address target is rendered from its
78+
* octets rather than from anything a peer supplied, so a name that arrived
79+
* carrying punctuation cannot reach a log line at all.
80+
*/
81+
private fun describe(target: Target): String =
82+
when (target) {
83+
is Target.Domain -> "${sanitise(target.host)}:${target.port}"
84+
is Target.Ip ->
85+
if (target.octets.size == 4) {
86+
target.octets.joinToString(".") { (it.toInt() and 0xFF).toString() } + ":${target.port}"
87+
} else {
88+
target.octets.joinToString(":") { "%02x".format(it) } + " port ${target.port}"
89+
}
90+
}
91+
92+
/**
93+
* Strips what a log line must not carry.
94+
*
95+
* A hostname cannot legally contain any of this, so removing it costs a
96+
* correct name nothing — and a value that arrived from a peer carrying a
97+
* newline, a userinfo separator or a percent-encoded byte is precisely the
98+
* thing that must not reach a line someone copies.
99+
*/
100+
private fun sanitise(value: String): String =
101+
value
102+
.asSequence()
103+
.filter { it.isLetterOrDigit() || it == '.' || it == '-' }
104+
.take(MAX_HOST_LENGTH)
105+
.joinToString("")
106+
107+
private companion object {
108+
const val CAPACITY = 200
109+
const val MAX_HOST_LENGTH = 253
110+
val FORMAT = SimpleDateFormat("HH:mm:ss", Locale.US)
111+
}
112+
}

‎app/src/main/kotlin/eu/nodepass/somewhere/vpn/NowhereFlowHandler.kt‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import eu.nodepass.somewhere.dns.DnsMessage
99
import eu.nodepass.somewhere.dns.FakeIpPool
1010
import eu.nodepass.somewhere.dns.FakeIpResolver
1111
import eu.nodepass.somewhere.protocol.DecodeResult
12+
import eu.nodepass.somewhere.protocol.frame.FlowRejected
13+
import eu.nodepass.somewhere.protocol.frame.SetupResult
1214
import eu.nodepass.somewhere.protocol.session.Flow
1315
import eu.nodepass.somewhere.protocol.session.NowhereSession
1416
import eu.nodepass.somewhere.protocol.target.Target
@@ -92,8 +94,29 @@ class NowhereFlowHandler(
9294
/** Opens the connections that do not go through the Portal. */
9395
private val direct: DirectDialer = DirectDialer(protect = { false }),
9496
private val pump: () -> TunPump?,
97+
/**
98+
* What the Portal answered, flow by flow (NW-A-06).
99+
*
100+
* Recorded here rather than inside the session because this is the only
101+
* place that knows both the outcome and the route the flow took — a direct
102+
* flow never asks a Portal anything, and a log that reported one as a
103+
* Portal's answer would be inventing one.
104+
*/
105+
private val log: ConnectionLog = ConnectionLog(),
95106
) : TunPump.FlowHandler {
107+
/** What the log's carrier column says for a Portal-bound flow. */
108+
private fun carrierName(): String = if (session.carriesPackets) QUIC else TLS
109+
110+
/** The log this handler writes, for whatever draws it. */
111+
val connectionLog: ConnectionLog get() = log
112+
96113
private companion object {
114+
const val QUIC = "QUIC"
115+
const val TLS = "TLS"
116+
117+
/** Used when nothing was answered because nothing was reached. */
118+
const val TRANSPORT = "transport"
119+
97120
const val TAG = "NowhereFlow"
98121

99122
/** Read buffer for the Portal→device direction. One TCP window's worth is wasteful; one MTU is chatty. */
@@ -411,6 +434,13 @@ class NowhereFlowHandler(
411434
}
412435
}.getOrElse { error ->
413436
Log.w(TAG, "dial failed for ${connection.target}: ${error.message}")
437+
// A transport that failed before the protocol was reached. The
438+
// Portal said nothing, so the nearest true SetupResult is the
439+
// one that means a dial did not succeed — and the carrier
440+
// column says which side could not be reached.
441+
if (!connection.direct) {
442+
log.record(SetupResult.DialFailed, connection.target, null, TRANSPORT)
443+
}
414444
abort(connection)
415445
return
416446
}
@@ -421,11 +451,23 @@ class NowhereFlowHandler(
421451
is DecodeResult.Invalid -> {
422452
val who = if (connection.direct) "the destination" else "the Portal"
423453
Log.w(TAG, "$who refused ${connection.target}: ${opened.reason.detail}")
454+
// Only a Portal's own answer is logged as one. A rejection
455+
// that carries no SetupResult -- silence, most often --
456+
// has no result to report, and inventing one would put a
457+
// word in the Portal's mouth.
458+
val reason = opened.reason
459+
if (!connection.direct && reason is FlowRejected) {
460+
log.record(reason.result, connection.target, null, carrierName())
461+
}
424462
abort(connection)
425463
return
426464
}
427465
}
428466

467+
if (!connection.direct) {
468+
flow.setupResult?.let { log.record(it, connection.target, flow.id, carrierName()) }
469+
}
470+
429471
connection.flow = flow
430472
if (first.isNotEmpty()) {
431473
if (connection.direct) {

‎app/src/main/kotlin/eu/nodepass/somewhere/vpn/SomewhereVpnService.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,8 @@ class SomewhereVpnService : VpnService() {
396396
val router = Router({ ruleSets }, { routingSettings.mode }, routingSettings.fallback)
397397
val direct = DirectDialer(protect = { socket -> protect(socket) })
398398

399-
val flows = NowhereFlowHandler(nowhere, fakeIp, resolvers, router, direct) { pump }
399+
val flows =
400+
NowhereFlowHandler(nowhere, fakeIp, resolvers, router, direct, { pump }, TunnelController.connectionLog)
400401
val running = TunPump(descriptor, flows)
401402

402403
session = nowhere

‎app/src/main/kotlin/eu/nodepass/somewhere/vpn/TunnelController.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ sealed interface TunnelState {
5858
* outlives every activity. Anything scoped narrower would be lying about that.
5959
*/
6060
object TunnelController {
61+
/**
62+
* What the Portal answered, flow by flow (NW-A-06).
63+
*
64+
* On the controller because the service that writes it is created and
65+
* destroyed with each tunnel while the screen that reads it outlives both,
66+
* and a log that vanished when the tunnel stopped would lose exactly the
67+
* entries a user goes looking for.
68+
*/
69+
val connectionLog: ConnectionLog = ConnectionLog()
70+
6171
private val mutable = MutableStateFlow<TunnelState>(TunnelState.Disconnected)
6272
private val mutableTraffic = MutableStateFlow(TrafficSample.NONE)
6373

0 commit comments

Comments
 (0)