diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt new file mode 100644 index 0000000000..ace49ffcaf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardState.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.tor + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +/** + * Pure, file- and JNI-free parsers over Arti's persisted guard sample + * (`/arti/state/state/guards.json`). + * + * Both recovery heuristics in the Tor stack read the same on-disk file, so the + * decisions live here where they can be unit-tested against captured fixtures + * (see `ArtiGuardStateTest`). [TorService] does the file IO and delegates the + * parsing; [TorManager] consumes [hasConfirmedGuard] to seed its + * `hasEverBootstrapped` flag across process restarts. + * + * The file shape is a map of guard-set selection name (e.g. `"default"`) to an + * object with a `"guards"` array. Each guard entry carries: + * - `disabled`: non-null once Arti permanently retires the guard (e.g. + * `TooManyIndeterminateFailures` on a flaky network). + * - `unlisted_since`: non-null once the guard drops out of the consensus. + * - `confirmed_at`: non-null once the guard has actually been used to build a + * circuit — i.e. a real bootstrap reached the guard-confirmation stage. + */ +object ArtiGuardState { + private val mapper = jacksonObjectMapper() + + /** Convenience for tests/callers holding the raw file text. */ + fun parse(json: String): JsonNode = mapper.readTree(json) + + /** + * True when at least one non-empty guard selection has *zero* usable guards — + * every guard permanently `disabled` or dropped from the consensus + * (`unlisted_since`). This is the AllGuardsDown wedge: Arti can neither build + * circuits nor replenish the sample (it's full of unusable entries), so it + * stays broken across restarts until the on-disk state is wiped. A single + * usable guard is enough to recover, so this only trips on a total wipeout. + */ + fun hasNoUsableGuards(root: JsonNode): Boolean { + var wedged = false + root.forEach { selection -> + val guards = selection.get("guards") ?: return@forEach + if (guards.isArray && guards.size() > 0) { + val usable = guards.count { !it.isDisabled() && !it.isUnlisted() } + if (usable == 0) wedged = true + } + } + return wedged + } + + /** + * True when the sample contains a guard Arti has *confirmed* (non-null + * `confirmed_at`), even if that guard is now disabled or unlisted. + * + * Confirmation only happens after a guard has successfully built circuits, so + * its presence on disk is durable proof that a real bootstrap completed at + * least once on this install — surviving the process restarts that reset + * [TorManager]'s in-memory `hasEverBootstrapped` flag to false. This lets the + * stuck-Connecting watchdog treat persisted state as stale/poisoned (wipe it) + * rather than as a pristine slow first bootstrap (leave it alone). + */ + fun hasConfirmedGuard(root: JsonNode): Boolean { + var confirmed = false + root.forEach { selection -> + val guards = selection.get("guards") ?: return@forEach + if (guards.isArray) { + guards.forEach { guard -> + val at = guard.get("confirmed_at") + if (at != null && !at.isNull) confirmed = true + } + } + } + return confirmed + } + + private fun JsonNode.isDisabled(): Boolean { + val d = get("disabled") + return d != null && !d.isNull + } + + private fun JsonNode.isUnlisted(): Boolean { + val u = get("unlisted_since") + return u != null && !u.isNull + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt index 3ccf01df15..5d36defa88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt @@ -37,4 +37,13 @@ interface TorBackend { suspend fun reset() suspend fun resetWithCleanState() + + /** + * True when on-disk state proves Tor bootstrapped successfully on this + * install before. Lets [TorManager] seed `hasEverBootstrapped` across process + * restarts so the stuck-Connecting watchdog wipes stale/poisoned state rather + * than waiting it out as a first bootstrap. Implementations do file IO, so + * this is a `suspend` call. + */ + suspend fun hasBootstrappedBefore(): Boolean } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 46a491b3f3..0f7f31c84b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -93,10 +93,27 @@ class TorManager( * bootstrap there is no stale state to wipe, and wiping just forces an unnecessary * re-bootstrap cycle. Once we've seen Tor work once, persisted `arti/state/` is fair game * for the recovery to wipe. + * + * Also seeded at startup from [TorBackend.hasBootstrappedBefore]: the in-memory flag resets + * every process, but Arti's persisted guard sample does not. If it already holds a confirmed + * guard, Tor bootstrapped here before, so a stuck Connecting span means the persisted state + * is stale/poisoned and the watchdog should wipe it. Without this seed a fresh process would + * mistake poisoned guards for a pristine first bootstrap and only ever gentle-reset (keeping + * the poison), looping forever — the exact "can't connect to Tor across restarts" failure. */ @Volatile private var hasEverBootstrapped: Boolean = false init { + // Seed hasEverBootstrapped from persisted on-disk evidence before the watchdog can fire + // (well under SELF_HEAL_AFTER_MS), so a stuck bootstrap on a previously-working install + // wipes its stale guard state instead of nursing it. + scope.launch(ioDispatcher) { + if (service.hasBootstrappedBefore()) { + hasEverBootstrapped = true + Log.d("TorManager") { "Seeded hasEverBootstrapped from persisted confirmed guard" } + } + } + scope.launch(ioDispatcher) { lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index 51d18a9b4e..bf3f7a245d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.tor import android.content.Context +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -99,9 +100,21 @@ class TorService( */ private fun guardsFile() = File(File(File(artiDataDir(), "state"), "state"), "guards.json") + /** Reads and parses [guardsFile], or null if it is absent/unreadable. */ + private fun readGuardsTree(): JsonNode? { + val file = guardsFile() + if (!file.exists()) return null + return try { + jacksonObjectMapper().readTree(file) + } catch (e: Exception) { + Log.w("TorService") { "Could not inspect guards.json: ${e.message}" } + null + } + } + /** * Detects the wedged-guard-sample state behind the long-standing "can't - * connect to Tor" bug. + * connect to Tor" bug (see [ArtiGuardState.hasNoUsableGuards]). * * On a flaky network, Arti records circuit failures past the first hop as * "indeterminate" (it can't tell whether the guard or a later hop was at @@ -116,43 +129,20 @@ class TorService( * `AllGuardsDown`. The state persists in `guards.json`, and bootstrap still * "succeeds" (it reads cached directory data), so none of the init-failure * self-heal paths ever fire and Tor is stuck across restarts. - * - * A single usable guard is enough to keep building circuits, so we only - * recover at the last resort: when a non-empty guard set has *zero* usable - * guards. A guard is unusable on disk if it has been permanently - * `disabled` or dropped from the consensus (`unlisted_since` set); - * reachability is in-memory only and not persisted, so it can't be checked - * here. Returns true when at least one non-empty selection has no usable - * guard left. */ - private fun noUsableGuards(): Boolean { - val file = guardsFile() - if (!file.exists()) return false + private fun noUsableGuards(): Boolean = readGuardsTree()?.let { ArtiGuardState.hasNoUsableGuards(it) } ?: false - return try { - val root = jacksonObjectMapper().readTree(file) - var wedged = false - // Each top-level field is a guard-set selection (e.g. "default"). - root.forEach { selection -> - val guards = selection.get("guards") ?: return@forEach - if (guards.isArray && guards.size() > 0) { - val usable = - guards.count { guard -> - val disabled = guard.get("disabled") - val unlisted = guard.get("unlisted_since") - val isDisabled = disabled != null && !disabled.isNull - val isUnlisted = unlisted != null && !unlisted.isNull - !isDisabled && !isUnlisted - } - if (usable == 0) wedged = true - } - } - wedged - } catch (e: Exception) { - Log.w("TorService") { "Could not inspect guards.json: ${e.message}" } - false + /** + * True when the persisted guard sample proves Tor bootstrapped successfully + * on this install before (a confirmed guard on disk; see + * [ArtiGuardState.hasConfirmedGuard]). [TorManager] seeds its in-memory + * `hasEverBootstrapped` from this so a stuck bootstrap on a fresh process + * wipes stale/poisoned state instead of nursing it as a first bootstrap. + */ + override suspend fun hasBootstrappedBefore(): Boolean = + withContext(Dispatchers.IO) { + readGuardsTree()?.let { ArtiGuardState.hasConfirmedGuard(it) } ?: false } - } /** * Clears all Arti persistent data (state + cache). Used as a last resort diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt new file mode 100644 index 0000000000..e244501338 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/ArtiGuardStateTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.tor + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtiGuardStateTest { + private fun load(resource: String): String = + javaClass.getResourceAsStream(resource)?.bufferedReader()?.use { it.readText() } + ?: error("Missing test resource: $resource") + + /** + * Real `guards.json` captured from an emulator stuck in "Connecting": two of the four + * confirmed primary guards were poisoned (`TooManyIndeterminateFailures`), but usable + * guards remained. This is the case that exposed the bug — full AllGuardsDown never trips, + * so only the [ArtiGuardState.hasConfirmedGuard] seed recovers it. + */ + @Test + fun `real device sample - confirmed guard present, not fully wedged`() { + val root = ArtiGuardState.parse(load("/tor/guards-confirmed-with-poisoned.json")) + + assertTrue( + "device sample has confirmed guards → prior bootstrap proven", + ArtiGuardState.hasConfirmedGuard(root), + ) + assertFalse( + "2 disabled of 22 guards is not a total wipeout → not wedged by the AllGuardsDown check", + ArtiGuardState.hasNoUsableGuards(root), + ) + } + + @Test + fun `confirmed guard counts even when disabled or unlisted`() { + val root = + ArtiGuardState.parse( + """ + { "default": { "guards": [ + { "confirmed_at": "2026-06-17T01:36:40Z", + "disabled": { "type": "TooManyIndeterminateFailures" } }, + { "confirmed_at": null, "disabled": null, "unlisted_since": null } + ] } } + """.trimIndent(), + ) + + assertTrue(ArtiGuardState.hasConfirmedGuard(root)) + } + + @Test + fun `fresh sample with no confirmed guard - first bootstrap, nothing to wipe`() { + val root = + ArtiGuardState.parse( + """ + { "default": { "guards": [ + { "confirmed_at": null, "disabled": null, "unlisted_since": null }, + { "confirmed_at": null, "disabled": null, "unlisted_since": null } + ] } } + """.trimIndent(), + ) + + assertFalse(ArtiGuardState.hasConfirmedGuard(root)) + assertFalse(ArtiGuardState.hasNoUsableGuards(root)) + } + + @Test + fun `all guards disabled or unlisted - AllGuardsDown wedge`() { + val root = + ArtiGuardState.parse( + """ + { "default": { "guards": [ + { "confirmed_at": "2026-06-10T15:52:00Z", + "disabled": { "type": "TooManyIndeterminateFailures" } }, + { "confirmed_at": "2026-06-11T15:52:00Z", + "disabled": null, "unlisted_since": "2026-06-12T00:00:00Z" } + ] } } + """.trimIndent(), + ) + + assertTrue("every guard unusable → wedged", ArtiGuardState.hasNoUsableGuards(root)) + // Even when fully wedged, prior confirmation is still proven. + assertTrue(ArtiGuardState.hasConfirmedGuard(root)) + } + + @Test + fun `empty selection is neither wedged nor confirmed`() { + val root = ArtiGuardState.parse("""{ "default": { "guards": [] }, "restricted": { "guards": [] } }""") + + assertFalse(ArtiGuardState.hasNoUsableGuards(root)) + assertFalse(ArtiGuardState.hasConfirmedGuard(root)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt index 7da36be10b..c6ae747701 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt @@ -272,6 +272,23 @@ class TorManagerTest { assertEquals(0, backend.resetWithCleanStateCount) } + @Test + fun `watchdog wipes state on first stuck-Connecting when guards prove prior bootstrap`() = + runTest(UnconfinedTestDispatcher()) { + val backend = FakeTorBackend().apply { bootstrappedBefore = true } + // Never reaches Active in this session, but on-disk state proves a prior bootstrap. + val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L }) + advanceUntilIdle() + + assertEquals(TorServiceStatus.Connecting, manager.status.value) + + advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L) + runCurrent() + + assertEquals("seeded from disk: must wipe stale/poisoned state, not gentle-reset", 0, backend.resetCount) + assertEquals(1, backend.resetWithCleanStateCount) + } + @Test fun `watchdog uses full reset after first Active`() = runTest(UnconfinedTestDispatcher()) { @@ -476,6 +493,11 @@ private class FakeTorBackend : TorBackend { var resetWithCleanStateCount = 0 private set + /** Simulates a persisted confirmed guard on disk (prior successful bootstrap). */ + var bootstrappedBefore = false + + override suspend fun hasBootstrappedBefore(): Boolean = bootstrappedBefore + override suspend fun start() { startCount++ _status.value = TorServiceStatus.Connecting diff --git a/amethyst/src/test/resources/tor/guards-confirmed-with-poisoned.json b/amethyst/src/test/resources/tor/guards-confirmed-with-poisoned.json new file mode 100644 index 0000000000..13fd374681 --- /dev/null +++ b/amethyst/src/test/resources/tor/guards-confirmed-with-poisoned.json @@ -0,0 +1,437 @@ +{ + "default": { + "guards": [ + { + "id": { + "ed25519": "rquRARGG0HF/y3ugtCi8HmhvZ6jIa1lSXQlFDUBrHBA", + "rsa": "b8f0736b96819c88a1aed46d5965fa18610694b5" + }, + "orports": [ + "142.132.205.43:9993", + "[2a01:4f8:261:5145::2]:9993" + ], + "added_at": "2026-06-14T13:48:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": { + "type": "TooManyIndeterminateFailures", + "history": { + "n_successes": 2, + "n_failures": 0, + "n_indeterminate": 14 + }, + "failure_ratio": 0.875, + "threshold_ratio": 0.7 + }, + "confirmed_at": "2026-06-18T08:49:30Z", + "unlisted_since": null + }, + { + "id": { + "ed25519": "1tmwk24X7PS9baMIflSwPnRxczUjs0AumO1YDHWzKpk", + "rsa": "dfb5ce34fe6f5d9c56377f38e86b3e55fffa1830" + }, + "orports": [ + "109.70.100.245:9010", + "[2a03:e600:100:c3::11]:9010" + ], + "added_at": "2026-06-16T08:21:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": { + "type": "TooManyIndeterminateFailures", + "history": { + "n_successes": 2, + "n_failures": 0, + "n_indeterminate": 15 + }, + "failure_ratio": 0.8823529411764706, + "threshold_ratio": 0.7 + }, + "confirmed_at": "2026-06-17T01:36:40Z", + "unlisted_since": null + }, + { + "id": { + "ed25519": "gvuYmLGk50ed9efpKqMiEZWKM8SqkJW4bB2nwmIPKtM", + "rsa": "c2f3631b1386fd7ba5227b28783315cd52eb1526" + }, + "orports": [ + "152.53.18.121:9001", + "[2a0a:4cc0:1:104e::1]:9001" + ], + "added_at": "2026-06-14T20:06:10Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": "2026-06-16T22:54:00Z", + "unlisted_since": null + }, + { + "id": { + "ed25519": "GX8TczQuVV+Wbk9Zeik5YA3LOPolZXO4wL3FYdGVcJ4", + "rsa": "76d2eba82bbcca3df9f254a9a8372a10a26f3d14" + }, + "orports": [ + "134.130.172.229:9001" + ], + "added_at": "2026-06-10T15:52:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": "2026-06-10T15:52:00Z", + "unlisted_since": null + }, + { + "id": { + "ed25519": "SHvdJYQ/zk1n24LrSDLjT0n1x/h/wn7UexhUnysm24Q", + "rsa": "dc5f31743bb3074bdf6153a066e9bed01e03311d" + }, + "orports": [ + "193.200.229.243:10443", + "[2a03:94e0:ffff:193:200:229:0:243]:10443" + ], + "added_at": "2026-06-09T03:23:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "zdp9Ni6/yN9xjXGwBkP8RgayDUl+gMzTkK1CvrqRGnQ", + "rsa": "239ff93a066c1e70d95dbdb0a6819941caf73021" + }, + "orports": [ + "51.83.41.117:9100", + "[2001:41d0:305:2100::8448]:9100" + ], + "added_at": "2026-06-16T19:32:40Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "//13KRovDTJtpueEaHyqB8zliL2sl1vNAriIvcUBWjs", + "rsa": "99ec64fe9ef0e0ecec3e125a2327c2779c1e7947" + }, + "orports": [ + "46.4.66.188:8000", + "[2a01:4f8:140:244f::2]:8000" + ], + "added_at": "2026-06-08T21:30:40Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "qA7+Jbr2gbkPMoDj13H5Bf02mmP+IdGdnTJfL2+DgAM", + "rsa": "861bcfdd148973985e7fe97c7455c9e4ac4e13be" + }, + "orports": [ + "157.90.212.53:443", + "[2a01:4f8:252:194b::2]:443" + ], + "added_at": "2026-06-16T22:43:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "y9oTlscEUikG1WYBbR7P1bEiV7h+Ne8RZvZoX/cPItM", + "rsa": "e3040061dd578d614d83ac0ae3526abdfe9fc323" + }, + "orports": [ + "87.106.54.7:9001" + ], + "added_at": "2026-06-12T02:23:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "MnaJaeA1kVXRa4jYkVOI6ENFjFlZfRH5GP9kXlds+Wk", + "rsa": "ef86ad86576f1a10f740ee26e8cb126e81dda5dd" + }, + "orports": [ + "178.215.228.25:444", + "[2a0d:5440::25]:9052" + ], + "added_at": "2026-06-11T12:10:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "febl0xAVXMcOxOuEY3xbH1bDbgkxdNyCSAlgbuhqAms", + "rsa": "f7b94b1a67b563459c6a7c6ad7d5b8031e127b26" + }, + "orports": [ + "192.42.116.143:443", + "[2001:67c:e60:c0c:192:42:116:143]:443" + ], + "added_at": "2026-06-15T01:38:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "AV7CHhiBquq6Y21MEabK7hnAD4ZeNSmH8JDS1ppUXCM", + "rsa": "04f4c22d98e5ba86a2ccf0bd6df42431c071f2aa" + }, + "orports": [ + "87.236.199.223:9007", + "[2a01:5f0:c001:108:29::8]:443" + ], + "added_at": "2026-06-14T12:44:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "N/B+0QX4ggFsvw9+/bfnGCeeBm5ScqJQlkFaWYGEN50", + "rsa": "c42c51efc275c45ef94cf810e02ba737fd99f7fa" + }, + "orports": [ + "194.5.250.250:443" + ], + "added_at": "2026-06-08T05:57:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "PtrkxtnBpL3NK7RQEw6TTdhy4YWT9EMpELF2DUh+vl8", + "rsa": "0ea859733751f8e8cecd70e33c9994bb7a04ac10" + }, + "orports": [ + "51.178.136.58:9000" + ], + "added_at": "2026-06-07T08:24:50Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "F60AmQxOFjWujKzP9zPpezfV35XuqMNog9BvxjaY3mc", + "rsa": "92fda7647b431e365b8a649851c3542e7a0ca280" + }, + "orports": [ + "194.13.81.26:9001", + "[2a03:4000:43:216:5443:2bff:fe16:c6b5]:9001" + ], + "added_at": "2026-06-13T05:48:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "RiuLP1z65m0ghVEAqOEFD+a/96ta5b5F7Oz7mZLolBM", + "rsa": "e08166ee25a85fd5c9433d133b39fc5ebde16d98" + }, + "orports": [ + "217.12.206.128:9001" + ], + "added_at": "2026-06-13T03:45:10Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "5fWkcDYvNYr0Gxwe2Ulu38f48m2FyDTBT6oziaTq8v8", + "rsa": "724f4622ea76583af64614979db847dd39eea61b" + }, + "orports": [ + "109.70.100.245:9030", + "[2a03:e600:100:c3::11]:9030" + ], + "added_at": "2026-06-08T00:07:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "xKIytpB9w1F0CNtCTkHT2sWBQ7nut7Ondyvim+NyJcg", + "rsa": "a2a3b91e706d5fc1b90b0bf4eb6f8c5ec18ea181" + }, + "orports": [ + "57.129.110.54:9100", + "[2001:41d0:701:1100::9a4e]:9100" + ], + "added_at": "2026-06-09T14:24:30Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "K8V12xahZ1pl0Sj2HN38VjL/TTq3DU+dcfn+qnm3KYs", + "rsa": "91892720f9262cb37b91a7176d3d2280f7af14be" + }, + "orports": [ + "46.4.66.178:9001", + "[2a01:4f8:140:2459::2]:9001" + ], + "added_at": "2026-06-11T12:53:40Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "MX9mVbBKjIQpNk9wDY5bsaYKcje0IeX73LxKVgapfp4", + "rsa": "02bc27735d76b4b43fc789757900bbb38de0402c" + }, + "orports": [ + "159.195.54.13:9001", + "[2a0a:4cc0:c1:d5e3:54c9:6eff:fe0a:e8f7]:9001" + ], + "added_at": "2026-06-10T06:03:10Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "wjv/3RiHzkbcjBQHBTFRxdds7YmsKReL2V9kMjSvuFc", + "rsa": "a3af96c0600301138631d0a35a567cc748ce749d" + }, + "orports": [ + "193.182.111.41:443", + "[2a03:8600::a1]:443" + ], + "added_at": "2026-06-10T07:03:00Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + }, + { + "id": { + "ed25519": "HA/2D2GGWjO+pWKHccrTu10TLIaZW9h0JlGnCIU7GZE", + "rsa": "beb887d44cb21ade582f2cb519c869ffdf520fdc" + }, + "orports": [ + "57.128.170.236:9000", + "[2001:41d0:801:2000::3129]:9000" + ], + "added_at": "2026-06-15T05:16:50Z", + "added_by": { + "crate": "tor-guardmgr", + "version": "0.42.0" + }, + "disabled": null, + "confirmed_at": null, + "unlisted_since": null + } + ], + "confirmed": [ + { + "ed25519": "1tmwk24X7PS9baMIflSwPnRxczUjs0AumO1YDHWzKpk", + "rsa": "dfb5ce34fe6f5d9c56377f38e86b3e55fffa1830" + }, + { + "ed25519": "rquRARGG0HF/y3ugtCi8HmhvZ6jIa1lSXQlFDUBrHBA", + "rsa": "b8f0736b96819c88a1aed46d5965fa18610694b5" + }, + { + "ed25519": "gvuYmLGk50ed9efpKqMiEZWKM8SqkJW4bB2nwmIPKtM", + "rsa": "c2f3631b1386fd7ba5227b28783315cd52eb1526" + }, + { + "ed25519": "GX8TczQuVV+Wbk9Zeik5YA3LOPolZXO4wL3FYdGVcJ4", + "rsa": "76d2eba82bbcca3df9f254a9a8372a10a26f3d14" + } + ] + }, + "restricted": { + "guards": [], + "confirmed": [] + } +} \ No newline at end of file