fix(tor): seed hasEverBootstrapped from on-disk guard sample

A fresh process reset TorManager.hasEverBootstrapped to false, so the
stuck-Connecting self-heal watchdog used the gentle reset() (drop client,
keep state) instead of resetWithCleanState() (wipe state). When guards.json
carried guards poisoned by TooManyIndeterminateFailures from a prior
session, every retry reloaded the same poisoned guards and Tor stayed stuck
in Connecting forever — never wiping the one thing blocking it.

Seed hasEverBootstrapped at startup from durable on-disk evidence: Arti only
writes confirmed_at on a guard after it has built real circuits, so a
confirmed guard proves Tor bootstrapped successfully on this install before,
even across the restarts that clear the in-memory flag. With it set, a stuck
bootstrap correctly wipes the stale/poisoned state and rebuilds a fresh
guard sample.

- ArtiGuardState: pure, file/JNI-free parsers over guards.json
  (hasConfirmedGuard + hasNoUsableGuards extracted from TorService).
- TorService.hasBootstrappedBefore() reads the file off-thread.
- TorBackend gains the suspend method; TorManager seeds in init.
- Tests cover the parser against a real captured poisoned-but-confirmed
  guards.json fixture, plus a watchdog test for the wipe-on-first-stuck path.

Verified on an emulator stuck in Connecting from real poisoned guards:
self-heal wiped state and Tor reached Active in ~5s (clean 0-disabled
guard sample).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-18 13:33:51 -04:00
co-authored by Claude Opus 4.8
parent 24e9cf2aaa
commit 2f602a29bc
7 changed files with 724 additions and 35 deletions
@@ -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
* (`<filesDir>/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
}
}
@@ -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
}
@@ -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()
}
@@ -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
@@ -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))
}
}
@@ -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
@@ -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": []
}
}