A Python port of the Invisible Internet Project (I2P)
1"""First-run configuration wizard — interactive and non-interactive modes.
2
3Runs three-layer diagnostics and produces a router config:
4 Layer 1: Local-only probes (CPU, RAM, disk)
5 Layer 2: Passive network probes (STUN, DNS, RTT, bandwidth)
6 Layer 3: Post-bootstrap (deferred to router runtime)
7"""
8
9from __future__ import annotations
10
11import json
12import logging
13import os
14from pathlib import Path
15
16from i2p_apps.setup.config_advisor import advise
17from i2p_apps.setup.local_probe import run_local_probes
18from i2p_apps.setup.network_probe import run_network_probes
19from i2p_apps.setup.stun_probe import detect_nat_type
20
21log = logging.getLogger(__name__)
22
23CONFIG_FILENAME = "router.config.json"
24
25
26class SetupWizard:
27 """First-run configuration wizard."""
28
29 def __init__(self, data_dir: str = "~/.i2p-python"):
30 self.data_dir = os.path.expanduser(data_dir)
31 os.makedirs(self.data_dir, exist_ok=True)
32
33 @property
34 def config_path(self) -> str:
35 return os.path.join(self.data_dir, CONFIG_FILENAME)
36
37 def is_first_run(self) -> bool:
38 """Check if router.config.json exists."""
39 return not os.path.exists(self.config_path)
40
41 def save_config(self, config: dict) -> None:
42 """Save config to data_dir/router.config.json."""
43 with open(self.config_path, "w") as f:
44 json.dump(config, f, indent=2)
45 log.info("Config saved to %s", self.config_path)
46
47 def load_config(self) -> dict | None:
48 """Load saved config, or None if first run."""
49 if not os.path.exists(self.config_path):
50 return None
51 with open(self.config_path) as f:
52 return json.load(f)
53
54 def run_auto(self, mode: str = "normal") -> dict:
55 """Run non-interactive wizard.
56
57 Modes:
58 - normal: all probes including network
59 - paranoid: local probes only, no network traffic
60 - performance: all probes, maximize resource allocation
61 """
62 log.info("Running setup wizard in %s mode", mode)
63
64 # Layer 1: Local probes (always run)
65 local = run_local_probes(data_dir=self.data_dir)
66 log.info(
67 "Local probes: %d crypto ops/s, %d MB RAM, %.0f MB/s disk",
68 local.crypto_ops_per_sec,
69 local.available_ram_bytes // (1024 * 1024),
70 local.disk_write_mbps,
71 )
72
73 nat = None
74 network = None
75
76 if mode != "paranoid":
77 # Layer 2a: STUN NAT detection
78 nat = detect_nat_type()
79 log.info("NAT probe: type=%s, external=%s:%s",
80 nat.nat_type, nat.external_ip, nat.external_port)
81
82 # Layer 2b+2c: Network probes
83 network = run_network_probes(
84 include_bandwidth=(mode != "paranoid"),
85 )
86 log.info("Network probe: DNS=%s, RTT=%.0fms",
87 network.dns.can_resolve_neutral, network.latency.rtt_ms)
88
89 # Generate recommendation
90 recommendation = advise(local, nat=nat, network=network)
91
92 config = recommendation.to_router_config_overrides()
93 config["mode"] = mode
94 config["version"] = 1
95
96 if recommendation.warnings:
97 for w in recommendation.warnings:
98 log.warning("Setup: %s", w)
99
100 self.save_config(config)
101 return config
102
103 def run_interactive(self) -> dict:
104 """Run interactive CLI wizard. Returns config dict.
105
106 For non-TTY environments, falls back to run_auto("normal").
107 """
108 if not os.isatty(0):
109 return self.run_auto("normal")
110
111 print("\nWelcome to I2P Python Router\n")
112 print("Checking your system...\n")
113
114 # Layer 1
115 local = run_local_probes(data_dir=self.data_dir)
116 print(f" CPU: {local.crypto_ops_per_sec:,} crypto ops/sec")
117 print(f" RAM: {local.available_ram_bytes // (1024**3)} GB available")
118 print(f" Disk: {local.disk_write_mbps:.0f} MB/s, "
119 f"{local.disk_free_bytes // (1024**3)} GB free\n")
120
121 # Ask about network tests
122 print("Would you like to test your network connection?")
123 print("This uses standard protocols (STUN, HTTPS) that look like")
124 print("normal video call and web browsing traffic.\n")
125 print("[Y]es [S]kip [P]aranoid (no network tests at all)")
126
127 choice = input("> ").strip().lower()
128
129 nat = None
130 network = None
131
132 if choice in ("y", "yes", ""):
133 nat = detect_nat_type()
134 network = run_network_probes()
135 print(f"\n NAT: {nat.nat_type}")
136 if nat.external_ip:
137 print(f" External IP: {nat.external_ip}")
138 print(f" DNS: {'OK' if network.dns.can_resolve_neutral else 'FAILED'}")
139 print(f" Latency: {network.latency.rtt_ms:.0f}ms median")
140 if network.bandwidth:
141 print(f" Bandwidth: ~{network.bandwidth.download_mbps:.0f} Mbps")
142
143 recommendation = advise(local, nat=nat, network=network)
144 config = recommendation.to_router_config_overrides()
145
146 print(f"\nRecommended configuration:")
147 print(f" Bandwidth share: {config['share_percentage']}%")
148 print(f" Transit tunnels: {config['inbound_tunnel_count']}")
149 print(f" Floodfill: {'yes' if config['floodfill'] else 'no'}")
150 print(f" Transport port: {config['listen_port']}")
151 print(f" NAT traversal: {'UPnP enabled' if config['upnp_enabled'] else 'disabled'}")
152
153 for w in recommendation.warnings:
154 print(f" WARNING: {w}")
155
156 print("\n[A]ccept [Q]uit")
157 accept = input("> ").strip().lower()
158
159 if accept in ("a", "accept", ""):
160 config["version"] = 1
161 self.save_config(config)
162 return config
163 else:
164 raise SystemExit("Setup cancelled.")