A Python port of the Invisible Internet Project (I2P)
1"""HTTPS reseed client — bootstrap peer discovery.
2
3New I2P routers fetch RouterInfo bundles from HTTPS reseed servers.
4The response is an SU3 signed file containing a ZIP of RouterInfo files.
5
6Ported from net.i2p.router.networkdb.reseed.Reseeder.
7"""
8
9from __future__ import annotations
10
11import asyncio
12import base64
13import logging
14import os
15import random
16import urllib.error
17import urllib.request
18
19from i2p_data.su3 import SU3File
20
21log = logging.getLogger(__name__)
22
23# Default I2P reseed servers
24DEFAULT_RESEED_URLS = [
25 "https://reseed.stormycloud.org",
26 "https://reseed2.i2p.net",
27 "https://reseed.diva.exchange",
28 "https://i2p.novg.net",
29 "https://reseed.onion.im",
30 "https://reseed.memcpy.io",
31 "https://reseed.atomike.ninja",
32 "https://reseed2.polarpoint.io",
33 "https://banana.incognet.io",
34 "https://reseed-fr.i2pd.xyz",
35 "https://reseed.i2pgit.org",
36 "https://i2pseed.creativecowpat.net:8443",
37 "https://reseed.i2p.vzaws.com:8443",
38]
39
40# Maximum SU3 download size (1 MB)
41_MAX_SU3_SIZE = 1_048_576
42
43
44class ReseedCertificateStore:
45 """Store for reseed signer certificates.
46
47 Loads X.509 certificates from a directory. Each .crt file is keyed
48 by filename without extension (which matches the SU3 signer ID).
49 """
50
51 def __init__(self, cert_dir: str | None = None) -> None:
52 self._certs: dict[str, bytes] = {}
53 if cert_dir:
54 self._load_from_dir(cert_dir)
55
56 def _load_from_dir(self, cert_dir: str) -> None:
57 """Load .crt files from *cert_dir*."""
58 if not os.path.isdir(cert_dir):
59 log.warning("Certificate directory does not exist: %s", cert_dir)
60 return
61 for fname in os.listdir(cert_dir):
62 if not fname.endswith(".crt"):
63 continue
64 signer_id = fname[: -len(".crt")]
65 path = os.path.join(cert_dir, fname)
66 try:
67 with open(path, "rb") as f:
68 self._certs[signer_id] = f.read()
69 except OSError as exc:
70 log.warning("Failed to read certificate %s: %s", path, exc)
71
72 def add_certificate(self, signer_id: str, cert_data: bytes) -> None:
73 """Add a certificate for the given signer ID."""
74 self._certs[signer_id] = cert_data
75
76 def get_certificate(self, signer_id: str) -> bytes | None:
77 """Return certificate bytes or None if not found."""
78 return self._certs.get(signer_id)
79
80
81class ReseedClient:
82 """HTTPS reseed client for bootstrapping the NetDB.
83
84 Fetches SU3 files from well-known reseed servers, parses them,
85 and extracts RouterInfo entries for initial peer discovery.
86 """
87
88 def __init__(
89 self,
90 reseed_urls: list[str] | None = None,
91 cert_store: ReseedCertificateStore | None = None,
92 max_ri_size: int = 4096,
93 target_count: int = 100,
94 min_servers: int = 2,
95 timeout: int = 420,
96 ) -> None:
97 self._urls = reseed_urls or list(DEFAULT_RESEED_URLS)
98 self._cert_store = cert_store or ReseedCertificateStore()
99 self._max_ri_size = max_ri_size
100 self._target_count = target_count
101 self._min_servers = min_servers
102 self._timeout = timeout
103
104 async def reseed(self) -> list[bytes]:
105 """Fetch RouterInfos from reseed servers.
106
107 Tries servers in random order. Downloads SU3 files, parses them,
108 extracts and validates RouterInfos.
109
110 Returns a list of raw RouterInfo bytes.
111 """
112 urls = list(self._urls)
113 random.shuffle(urls)
114
115 collected: list[bytes] = []
116 servers_used = 0
117 loop = asyncio.get_running_loop()
118
119 for url in urls:
120 try:
121 su3_data = await loop.run_in_executor(None, self._fetch_su3, url)
122 ris = self._extract_router_infos(su3_data)
123 collected.extend(ris)
124 servers_used += 1
125 log.info(
126 "Reseed from %s: got %d RIs (total %d)",
127 url, len(ris), len(collected),
128 )
129 except Exception:
130 log.warning("Reseed from %s failed", url, exc_info=True)
131 continue
132
133 # Stop when we have enough from enough servers
134 if len(collected) >= self._target_count and servers_used >= self._min_servers:
135 break
136
137 log.info(
138 "Reseed complete: %d RouterInfos from %d servers",
139 len(collected), servers_used,
140 )
141 return collected
142
143 def _fetch_su3(self, base_url: str) -> bytes:
144 """Fetch an SU3 file from a single reseed server.
145
146 Uses urllib.request (not httpx, which has timeout bugs with
147 long-running requests).
148
149 Raises urllib.error.HTTPError or urllib.error.URLError on failure.
150 """
151 url = base_url.rstrip("/") + "/i2pseeds.su3?netid=2"
152 req = urllib.request.Request(
153 url,
154 headers={"User-Agent": "Wget/1.11.4"},
155 )
156 with urllib.request.urlopen(req, timeout=self._timeout) as resp:
157 data = resp.read(_MAX_SU3_SIZE)
158 return data
159
160 def _extract_router_infos(self, su3_data: bytes) -> list[bytes]:
161 """Parse SU3 and extract RouterInfo bytes from the embedded ZIP.
162
163 Filters out entries larger than *max_ri_size*.
164
165 Raises ValueError if *su3_data* is not a valid SU3 reseed file.
166 """
167 su3 = SU3File.from_bytes(su3_data)
168 raw_ris = su3.extract_routerinfos()
169
170 result: list[bytes] = []
171 for ri_bytes in raw_ris:
172 if len(ri_bytes) > self._max_ri_size:
173 log.debug(
174 "Skipping oversized RouterInfo (%d bytes > %d)",
175 len(ri_bytes), self._max_ri_size,
176 )
177 continue
178 result.append(ri_bytes)
179 return result
180
181
182class ReseedManager:
183 """Bootstraps the router with initial RouterInfos from reseed servers.
184
185 Provides a simple interface for determining whether a reseed is needed,
186 parsing a simple base64-encoded reseed response format, and tracking
187 reseed state.
188 """
189
190 _MIN_DATASTORE_COUNT = 25
191
192 def __init__(self, reseed_urls: list[str] | None = None) -> None:
193 self._urls = reseed_urls or []
194 self._reseeded = False
195
196 def needs_reseed(self, datastore_count: int) -> bool:
197 """Returns True if the datastore has too few entries and we have not
198 already reseeded during this session."""
199 return datastore_count < self._MIN_DATASTORE_COUNT and not self._reseeded
200
201 def parse_reseed_response(self, data: bytes) -> list[bytes]:
202 """Parse a reseed response in simple format.
203
204 Accepts newline-separated base64-encoded RouterInfo blobs.
205 Returns a list of dicts with a ``raw`` key containing the
206 decoded bytes for each entry.
207
208 Real SU3 parsing is handled by :class:`ReseedClient`; this
209 method supports a simplified format for testing and lightweight
210 bootstrap scenarios.
211 """
212 if not data:
213 return []
214 results: list[bytes] = []
215 for line in data.split(b"\n"):
216 line = line.strip()
217 if not line:
218 continue
219 try:
220 decoded = base64.b64decode(line)
221 results.append(decoded)
222 except Exception:
223 log.debug("Skipping invalid base64 line in reseed response")
224 continue
225 return results
226
227 def mark_reseeded(self) -> None:
228 """Mark that a reseed has been performed this session."""
229 self._reseeded = True
230
231 @property
232 def is_reseeded(self) -> bool:
233 """Whether the router has already been reseeded."""
234 return self._reseeded