A Python port of the Invisible Internet Project (I2P)
at main 234 lines 7.8 kB view raw
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