redundanet 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
redundanet/vpn/mesh.py ADDED
@@ -0,0 +1,201 @@
1
+ """Mesh network operations for RedundaNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING
8
+
9
+ from redundanet.utils.logging import get_logger
10
+ from redundanet.utils.process import run_command
11
+
12
+ if TYPE_CHECKING:
13
+ from redundanet.core.config import NodeConfig
14
+ from redundanet.vpn.tinc import TincConfig
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class PeerInfo:
21
+ """Information about a connected peer."""
22
+
23
+ name: str
24
+ vpn_ip: str
25
+ public_ip: str | None = None
26
+ connected: bool = False
27
+ last_seen: datetime | None = None
28
+ latency_ms: float | None = None
29
+
30
+
31
+ @dataclass
32
+ class MeshStatus:
33
+ """Status of the mesh network."""
34
+
35
+ node_name: str
36
+ vpn_ip: str
37
+ interface: str = "tinc0"
38
+ is_running: bool = False
39
+ peers: list[PeerInfo] = field(default_factory=list)
40
+ connected_count: int = 0
41
+ total_peers: int = 0
42
+
43
+
44
+ class MeshNetwork:
45
+ """Manages mesh network operations and peer discovery."""
46
+
47
+ def __init__(self, config: TincConfig) -> None:
48
+ self.config = config
49
+ self._interface = "tinc0"
50
+
51
+ def get_status(self) -> MeshStatus:
52
+ """Get the current mesh network status."""
53
+ status = MeshStatus(
54
+ node_name=self.config.node_name,
55
+ vpn_ip=self.config.vpn_ip,
56
+ interface=self._interface,
57
+ )
58
+
59
+ # Check if interface is up
60
+ result = run_command(f"ip link show {self._interface}", check=False)
61
+ status.is_running = result.success and "UP" in result.stdout
62
+
63
+ return status
64
+
65
+ def ping_peer(self, peer_ip: str, count: int = 3, timeout: float = 5.0) -> float | None:
66
+ """Ping a peer and return the average latency.
67
+
68
+ Args:
69
+ peer_ip: IP address to ping
70
+ count: Number of ping packets
71
+ timeout: Timeout in seconds
72
+
73
+ Returns:
74
+ Average latency in milliseconds, or None if unreachable
75
+ """
76
+ result = run_command(
77
+ f"ping -c {count} -W {int(timeout)} -q {peer_ip}",
78
+ timeout=timeout * count + 5,
79
+ check=False,
80
+ )
81
+
82
+ if not result.success:
83
+ return None
84
+
85
+ # Parse ping output for average time
86
+ # Example: rtt min/avg/max/mdev = 0.123/0.456/0.789/0.123 ms
87
+ for line in result.stdout.split("\n"):
88
+ if "rtt" in line or "round-trip" in line:
89
+ parts = line.split("=")
90
+ if len(parts) >= 2:
91
+ times = parts[1].strip().split("/")
92
+ if len(times) >= 2:
93
+ try:
94
+ return float(times[1]) # Average time
95
+ except ValueError:
96
+ pass
97
+ return None
98
+
99
+ def check_peer_connectivity(self, peers: list[NodeConfig]) -> dict[str, PeerInfo]:
100
+ """Check connectivity to all peers.
101
+
102
+ Args:
103
+ peers: List of peer node configurations
104
+
105
+ Returns:
106
+ Dictionary mapping node name to PeerInfo
107
+ """
108
+ results = {}
109
+
110
+ for peer in peers:
111
+ if peer.name == self.config.node_name:
112
+ continue
113
+
114
+ peer_ip = peer.vpn_ip or peer.internal_ip
115
+ latency = self.ping_peer(peer_ip)
116
+
117
+ info = PeerInfo(
118
+ name=peer.name,
119
+ vpn_ip=peer_ip,
120
+ public_ip=peer.public_ip,
121
+ connected=latency is not None,
122
+ last_seen=datetime.now() if latency else None,
123
+ latency_ms=latency,
124
+ )
125
+ results[peer.name] = info
126
+
127
+ if latency:
128
+ logger.debug("Peer reachable", peer=peer.name, latency_ms=latency)
129
+ else:
130
+ logger.debug("Peer unreachable", peer=peer.name)
131
+
132
+ return results
133
+
134
+ def get_interface_info(self) -> dict[str, str | None]:
135
+ """Get information about the VPN interface."""
136
+ info: dict[str, str | None] = {
137
+ "name": self._interface,
138
+ "ip": None,
139
+ "state": "down",
140
+ }
141
+
142
+ # Get interface state
143
+ result = run_command(f"ip link show {self._interface}", check=False)
144
+ if result.success:
145
+ if "UP" in result.stdout:
146
+ info["state"] = "up"
147
+ elif "DOWN" in result.stdout:
148
+ info["state"] = "down"
149
+
150
+ # Get IP address
151
+ result = run_command(f"ip addr show {self._interface}", check=False)
152
+ if result.success:
153
+ for line in result.stdout.split("\n"):
154
+ if "inet " in line:
155
+ parts = line.strip().split()
156
+ if len(parts) >= 2:
157
+ info["ip"] = parts[1].split("/")[0]
158
+ break
159
+
160
+ return info
161
+
162
+ def discover_active_peers(self) -> list[str]:
163
+ """Discover which peers are currently active on the network.
164
+
165
+ This uses ARP table and ping to find active peers.
166
+
167
+ Returns:
168
+ List of active peer IPs
169
+ """
170
+ active_peers = []
171
+
172
+ # Check ARP table for the VPN interface
173
+ result = run_command(f"ip neigh show dev {self._interface}", check=False)
174
+ if result.success:
175
+ for line in result.stdout.strip().split("\n"):
176
+ if line and "REACHABLE" in line:
177
+ parts = line.split()
178
+ if parts:
179
+ active_peers.append(parts[0])
180
+
181
+ return active_peers
182
+
183
+ def wait_for_interface(self, timeout: float = 30.0) -> bool:
184
+ """Wait for the VPN interface to come up.
185
+
186
+ Args:
187
+ timeout: Maximum time to wait in seconds
188
+
189
+ Returns:
190
+ True if interface came up, False if timed out
191
+ """
192
+ import time
193
+
194
+ start = time.time()
195
+ while time.time() - start < timeout:
196
+ info = self.get_interface_info()
197
+ if info["state"] == "up" and info["ip"]:
198
+ return True
199
+ time.sleep(1)
200
+
201
+ return False
redundanet/vpn/tinc.py ADDED
@@ -0,0 +1,323 @@
1
+ """Tinc VPN management for RedundaNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from jinja2 import Template
10
+
11
+ from redundanet.core.exceptions import VPNError
12
+ from redundanet.utils.files import ensure_dir, read_file, write_file
13
+ from redundanet.utils.logging import get_logger
14
+ from redundanet.utils.process import is_command_available, run_command
15
+
16
+ if TYPE_CHECKING:
17
+ from redundanet.core.config import NodeConfig
18
+
19
+ logger = get_logger(__name__)
20
+
21
+ # Tinc configuration templates
22
+ TINC_CONF_TEMPLATE = """# Tinc configuration for {{ network_name }}
23
+ # Generated by RedundaNet
24
+
25
+ Name = {{ node_name }}
26
+ Mode = switch
27
+ Device = /dev/net/tun
28
+ DeviceType = tun
29
+ AddressFamily = ipv4
30
+ Port = {{ port }}
31
+
32
+ {% for connect_node in connect_to %}
33
+ ConnectTo = {{ connect_node }}
34
+ {% endfor %}
35
+
36
+ # Security settings
37
+ Compression = 9
38
+ Cipher = aes-256-cbc
39
+ Digest = sha256
40
+
41
+ # Process settings
42
+ ProcessPriority = high
43
+ """
44
+
45
+ TINC_UP_TEMPLATE = """#!/bin/bash
46
+ # Tinc-up script for {{ network_name }}
47
+ # Generated by RedundaNet
48
+
49
+ ip link set $INTERFACE up
50
+ ip addr add {{ vpn_ip }}/32 dev $INTERFACE
51
+ ip route add {{ vpn_network }} dev $INTERFACE
52
+ """
53
+
54
+ TINC_DOWN_TEMPLATE = """#!/bin/bash
55
+ # Tinc-down script for {{ network_name }}
56
+ # Generated by RedundaNet
57
+
58
+ ip route del {{ vpn_network }} dev $INTERFACE 2>/dev/null || true
59
+ ip addr del {{ vpn_ip }}/32 dev $INTERFACE 2>/dev/null || true
60
+ ip link set $INTERFACE down
61
+ """
62
+
63
+ HOST_FILE_TEMPLATE = """# Host file for {{ node_name }}
64
+ # Generated by RedundaNet
65
+
66
+ Subnet = {{ vpn_ip }}/32
67
+ {% if public_ip %}Address = {{ public_ip }}{% endif %}
68
+ Port = {{ port }}
69
+
70
+ {{ public_key }}
71
+ """
72
+
73
+
74
+ @dataclass
75
+ class TincConfig:
76
+ """Configuration for Tinc VPN."""
77
+
78
+ network_name: str = "redundanet"
79
+ node_name: str = ""
80
+ vpn_ip: str = ""
81
+ vpn_network: str = "10.100.0.0/16"
82
+ port: int = 655
83
+ public_ip: str | None = None
84
+ connect_to: list[str] = field(default_factory=list)
85
+ config_dir: Path = field(default_factory=lambda: Path("/etc/tinc"))
86
+
87
+ @property
88
+ def network_dir(self) -> Path:
89
+ """Get the network-specific configuration directory."""
90
+ return self.config_dir / self.network_name
91
+
92
+ @property
93
+ def hosts_dir(self) -> Path:
94
+ """Get the hosts directory."""
95
+ return self.network_dir / "hosts"
96
+
97
+ @classmethod
98
+ def from_node_config(cls, node: NodeConfig, network_name: str = "redundanet") -> TincConfig:
99
+ """Create TincConfig from a NodeConfig."""
100
+ return cls(
101
+ network_name=network_name,
102
+ node_name=node.name,
103
+ vpn_ip=node.vpn_ip or node.internal_ip,
104
+ port=node.ports.tinc,
105
+ public_ip=node.public_ip,
106
+ )
107
+
108
+
109
+ class TincManager:
110
+ """Manages Tinc VPN configuration and operations."""
111
+
112
+ def __init__(self, config: TincConfig) -> None:
113
+ self.config = config
114
+ self._private_key_path = self.config.network_dir / "rsa_key.priv"
115
+ self._public_key_path = self.config.hosts_dir / self.config.node_name
116
+
117
+ def ensure_tinc_installed(self) -> bool:
118
+ """Check if tinc is installed."""
119
+ if not is_command_available("tincd"):
120
+ raise VPNError("Tinc is not installed. Please install tinc first.")
121
+ return True
122
+
123
+ def setup(self, peers: list[NodeConfig] | None = None) -> None:
124
+ """Set up the complete Tinc configuration.
125
+
126
+ Args:
127
+ peers: List of peer nodes to connect to
128
+ """
129
+ logger.info("Setting up Tinc VPN", node=self.config.node_name)
130
+
131
+ # Create directories
132
+ ensure_dir(self.config.network_dir, mode=0o755)
133
+ ensure_dir(self.config.hosts_dir, mode=0o755)
134
+
135
+ # Determine which nodes to connect to
136
+ connect_nodes = []
137
+ if peers:
138
+ for peer in peers:
139
+ if peer.is_publicly_accessible and peer.name != self.config.node_name:
140
+ connect_nodes.append(peer.name)
141
+ self.config.connect_to = connect_nodes
142
+
143
+ # Generate configuration files
144
+ self._write_tinc_conf()
145
+ self._write_tinc_up()
146
+ self._write_tinc_down()
147
+
148
+ # Generate keys if they don't exist
149
+ if not self._private_key_path.exists():
150
+ self.generate_keys()
151
+
152
+ # Write peer host files
153
+ if peers:
154
+ for peer in peers:
155
+ if peer.name != self.config.node_name:
156
+ self._write_host_file(peer)
157
+
158
+ logger.info("Tinc VPN setup complete", node=self.config.node_name)
159
+
160
+ def _write_tinc_conf(self) -> None:
161
+ """Write the main tinc.conf file."""
162
+ template = Template(TINC_CONF_TEMPLATE)
163
+ content = template.render(
164
+ network_name=self.config.network_name,
165
+ node_name=self.config.node_name,
166
+ port=self.config.port,
167
+ connect_to=self.config.connect_to,
168
+ )
169
+
170
+ conf_path = self.config.network_dir / "tinc.conf"
171
+ write_file(conf_path, content, mode=0o644)
172
+ logger.debug("Wrote tinc.conf", path=str(conf_path))
173
+
174
+ def _write_tinc_up(self) -> None:
175
+ """Write the tinc-up script."""
176
+ template = Template(TINC_UP_TEMPLATE)
177
+ content = template.render(
178
+ network_name=self.config.network_name,
179
+ vpn_ip=self.config.vpn_ip,
180
+ vpn_network=self.config.vpn_network,
181
+ )
182
+
183
+ script_path = self.config.network_dir / "tinc-up"
184
+ write_file(script_path, content, executable=True)
185
+ logger.debug("Wrote tinc-up", path=str(script_path))
186
+
187
+ def _write_tinc_down(self) -> None:
188
+ """Write the tinc-down script."""
189
+ template = Template(TINC_DOWN_TEMPLATE)
190
+ content = template.render(
191
+ network_name=self.config.network_name,
192
+ vpn_ip=self.config.vpn_ip,
193
+ vpn_network=self.config.vpn_network,
194
+ )
195
+
196
+ script_path = self.config.network_dir / "tinc-down"
197
+ write_file(script_path, content, executable=True)
198
+ logger.debug("Wrote tinc-down", path=str(script_path))
199
+
200
+ def _write_host_file(
201
+ self,
202
+ node: NodeConfig,
203
+ public_key: str | None = None,
204
+ ) -> None:
205
+ """Write a host file for a node."""
206
+ template = Template(HOST_FILE_TEMPLATE)
207
+ content = template.render(
208
+ node_name=node.name,
209
+ vpn_ip=node.vpn_ip or node.internal_ip,
210
+ public_ip=node.public_ip if node.is_publicly_accessible else None,
211
+ port=node.ports.tinc,
212
+ public_key=public_key or "",
213
+ )
214
+
215
+ host_path = self.config.hosts_dir / node.name
216
+ write_file(host_path, content, mode=0o644)
217
+ logger.debug("Wrote host file", node=node.name, path=str(host_path))
218
+
219
+ def generate_keys(self) -> str:
220
+ """Generate Tinc RSA keypair.
221
+
222
+ Returns:
223
+ The public key as a string
224
+ """
225
+ logger.info("Generating Tinc keys", node=self.config.node_name)
226
+
227
+ # Create empty host file for key generation
228
+ host_file = self.config.hosts_dir / self.config.node_name
229
+ if not host_file.exists():
230
+ header = f"# Host file for {self.config.node_name}\n"
231
+ header += f"Subnet = {self.config.vpn_ip}/32\n"
232
+ if self.config.public_ip:
233
+ header += f"Address = {self.config.public_ip}\n"
234
+ header += f"Port = {self.config.port}\n"
235
+ write_file(host_file, header, mode=0o644)
236
+
237
+ # Generate keys using tincd
238
+ result = run_command(
239
+ f"tincd -n {self.config.network_name} -K4096",
240
+ input_text="\n", # Accept defaults
241
+ )
242
+
243
+ if not result.success:
244
+ raise VPNError(f"Failed to generate keys: {result.stderr}")
245
+
246
+ # Read and return the public key
247
+ return self.get_public_key()
248
+
249
+ def get_public_key(self) -> str:
250
+ """Get the public key for this node."""
251
+ host_file = self.config.hosts_dir / self.config.node_name
252
+ if not host_file.exists():
253
+ raise VPNError(f"Host file not found: {host_file}")
254
+
255
+ content = read_file(host_file)
256
+
257
+ # Extract the public key portion
258
+ lines = content.split("\n")
259
+ in_key = False
260
+ key_lines = []
261
+
262
+ for line in lines:
263
+ if "BEGIN RSA PUBLIC KEY" in line:
264
+ in_key = True
265
+ if in_key:
266
+ key_lines.append(line)
267
+ if "END RSA PUBLIC KEY" in line:
268
+ break
269
+
270
+ if not key_lines:
271
+ raise VPNError("No public key found in host file")
272
+
273
+ return "\n".join(key_lines)
274
+
275
+ def start(self) -> bool:
276
+ """Start the Tinc VPN daemon."""
277
+ logger.info("Starting Tinc VPN", network=self.config.network_name)
278
+
279
+ result = run_command(f"tincd -n {self.config.network_name}")
280
+ if not result.success:
281
+ logger.error("Failed to start Tinc", error=result.stderr)
282
+ return False
283
+
284
+ logger.info("Tinc VPN started")
285
+ return True
286
+
287
+ def stop(self) -> bool:
288
+ """Stop the Tinc VPN daemon."""
289
+ logger.info("Stopping Tinc VPN", network=self.config.network_name)
290
+
291
+ result = run_command(f"tincd -n {self.config.network_name} -k")
292
+ if not result.success:
293
+ logger.error("Failed to stop Tinc", error=result.stderr)
294
+ return False
295
+
296
+ logger.info("Tinc VPN stopped")
297
+ return True
298
+
299
+ def reload(self) -> bool:
300
+ """Reload Tinc VPN configuration."""
301
+ logger.info("Reloading Tinc VPN", network=self.config.network_name)
302
+
303
+ result = run_command(f"tincd -n {self.config.network_name} -kHUP")
304
+ return result.success
305
+
306
+ def is_running(self) -> bool:
307
+ """Check if Tinc is running."""
308
+ pid_file = Path(f"/var/run/tinc.{self.config.network_name}.pid")
309
+ if not pid_file.exists():
310
+ return False
311
+
312
+ pid = int(pid_file.read_text().strip())
313
+ return Path(f"/proc/{pid}").exists()
314
+
315
+ def get_status(self) -> dict[str, object]:
316
+ """Get the current status of Tinc VPN."""
317
+ return {
318
+ "running": self.is_running(),
319
+ "network": self.config.network_name,
320
+ "node": self.config.node_name,
321
+ "vpn_ip": self.config.vpn_ip,
322
+ "config_dir": str(self.config.network_dir),
323
+ }