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.
@@ -0,0 +1,135 @@
1
+ """Node representation and management for RedundaNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING
9
+
10
+ from redundanet.core.config import NodeRole
11
+
12
+ if TYPE_CHECKING:
13
+ from redundanet.core.config import NodeConfig
14
+
15
+
16
+ class NodeStatus(str, Enum):
17
+ """Runtime status of a node."""
18
+
19
+ ONLINE = "online"
20
+ OFFLINE = "offline"
21
+ CONNECTING = "connecting"
22
+ ERROR = "error"
23
+ UNKNOWN = "unknown"
24
+
25
+
26
+ @dataclass
27
+ class NodeHealth:
28
+ """Health information for a node."""
29
+
30
+ vpn_connected: bool = False
31
+ storage_available: bool = False
32
+ introducer_reachable: bool = False
33
+ last_seen: datetime | None = None
34
+ uptime_seconds: int = 0
35
+ errors: list[str] = field(default_factory=list)
36
+
37
+ @property
38
+ def is_healthy(self) -> bool:
39
+ """Check if the node is in a healthy state."""
40
+ return self.vpn_connected and len(self.errors) == 0
41
+
42
+
43
+ @dataclass
44
+ class Node:
45
+ """Represents a node in the RedundaNet network."""
46
+
47
+ name: str
48
+ internal_ip: str
49
+ vpn_ip: str
50
+ roles: list[NodeRole] = field(default_factory=list)
51
+ public_ip: str | None = None
52
+ gpg_key_id: str | None = None
53
+ region: str | None = None
54
+ is_publicly_accessible: bool = False
55
+ storage_contribution: str | None = None
56
+ storage_allocation: str | None = None
57
+
58
+ # Runtime state
59
+ status: NodeStatus = NodeStatus.UNKNOWN
60
+ health: NodeHealth = field(default_factory=NodeHealth)
61
+
62
+ @classmethod
63
+ def from_config(cls, config: NodeConfig) -> Node:
64
+ """Create a Node instance from NodeConfig."""
65
+ return cls(
66
+ name=config.name,
67
+ internal_ip=config.internal_ip,
68
+ vpn_ip=config.vpn_ip or config.internal_ip,
69
+ roles=[NodeRole(r.value) for r in config.roles],
70
+ public_ip=config.public_ip,
71
+ gpg_key_id=config.gpg_key_id,
72
+ region=config.region,
73
+ is_publicly_accessible=config.is_publicly_accessible,
74
+ storage_contribution=config.storage_contribution,
75
+ storage_allocation=config.storage_allocation,
76
+ )
77
+
78
+ def has_role(self, role: NodeRole) -> bool:
79
+ """Check if this node has a specific role."""
80
+ return role in self.roles
81
+
82
+ @property
83
+ def is_introducer(self) -> bool:
84
+ """Check if this node is a Tahoe-LAFS introducer."""
85
+ return self.has_role(NodeRole.TAHOE_INTRODUCER)
86
+
87
+ @property
88
+ def is_storage(self) -> bool:
89
+ """Check if this node provides storage."""
90
+ return self.has_role(NodeRole.TAHOE_STORAGE)
91
+
92
+ @property
93
+ def is_client(self) -> bool:
94
+ """Check if this node is a Tahoe-LAFS client."""
95
+ return self.has_role(NodeRole.TAHOE_CLIENT)
96
+
97
+ @property
98
+ def is_vpn_node(self) -> bool:
99
+ """Check if this node participates in the VPN mesh."""
100
+ return self.has_role(NodeRole.TINC_VPN)
101
+
102
+ @property
103
+ def fqdn(self) -> str:
104
+ """Get the fully qualified domain name for this node."""
105
+ return f"{self.name}.redundanet.local"
106
+
107
+ def to_dict(self) -> dict[str, object]:
108
+ """Convert node to dictionary representation."""
109
+ return {
110
+ "name": self.name,
111
+ "internal_ip": self.internal_ip,
112
+ "vpn_ip": self.vpn_ip,
113
+ "public_ip": self.public_ip,
114
+ "gpg_key_id": self.gpg_key_id,
115
+ "region": self.region,
116
+ "roles": [r.value for r in self.roles],
117
+ "is_publicly_accessible": self.is_publicly_accessible,
118
+ "storage_contribution": self.storage_contribution,
119
+ "storage_allocation": self.storage_allocation,
120
+ "status": self.status.value,
121
+ }
122
+
123
+
124
+ @dataclass
125
+ class LocalNode(Node):
126
+ """Represents the local node with additional local-only information."""
127
+
128
+ config_path: str | None = None
129
+ data_path: str | None = None
130
+ private_key_path: str | None = None
131
+
132
+ @property
133
+ def is_configured(self) -> bool:
134
+ """Check if the local node is properly configured."""
135
+ return bool(self.config_path and self.private_key_path)
@@ -0,0 +1,11 @@
1
+ """Network utilities module for RedundaNet."""
2
+
3
+ from redundanet.network.discovery import NodeDiscovery
4
+ from redundanet.network.dns import DNSManager
5
+ from redundanet.network.validation import NetworkValidator
6
+
7
+ __all__ = [
8
+ "NodeDiscovery",
9
+ "NetworkValidator",
10
+ "DNSManager",
11
+ ]
@@ -0,0 +1,218 @@
1
+ """Node discovery for RedundaNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING
9
+
10
+ from redundanet.utils.logging import get_logger
11
+ from redundanet.utils.process import run_command
12
+
13
+ if TYPE_CHECKING:
14
+ from redundanet.core.config import NodeConfig
15
+ from redundanet.core.manifest import Manifest
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class DiscoveredNode:
22
+ """Information about a discovered node."""
23
+
24
+ name: str
25
+ vpn_ip: str
26
+ reachable: bool = False
27
+ latency_ms: float | None = None
28
+ last_seen: datetime | None = None
29
+ services: list[str] = field(default_factory=list)
30
+
31
+
32
+ class NodeDiscovery:
33
+ """Discovers and monitors nodes in the network."""
34
+
35
+ def __init__(
36
+ self,
37
+ manifest: Manifest,
38
+ local_node_name: str,
39
+ ) -> None:
40
+ """Initialize node discovery.
41
+
42
+ Args:
43
+ manifest: Network manifest
44
+ local_node_name: Name of the local node
45
+ """
46
+ self.manifest = manifest
47
+ self.local_node_name = local_node_name
48
+ self._discovered: dict[str, DiscoveredNode] = {}
49
+
50
+ def discover_all(self) -> dict[str, DiscoveredNode]:
51
+ """Discover all nodes defined in the manifest.
52
+
53
+ Returns:
54
+ Dictionary mapping node name to DiscoveredNode
55
+ """
56
+ logger.info("Discovering nodes", count=len(self.manifest.nodes))
57
+
58
+ for node in self.manifest.nodes:
59
+ if node.name == self.local_node_name:
60
+ continue
61
+
62
+ discovered = self._probe_node(node)
63
+ self._discovered[node.name] = discovered
64
+
65
+ if discovered.reachable:
66
+ logger.debug(
67
+ "Node reachable",
68
+ node=node.name,
69
+ latency_ms=discovered.latency_ms,
70
+ )
71
+ else:
72
+ logger.debug("Node unreachable", node=node.name)
73
+
74
+ return self._discovered
75
+
76
+ def _probe_node(self, node: NodeConfig) -> DiscoveredNode:
77
+ """Probe a single node for reachability.
78
+
79
+ Args:
80
+ node: Node configuration
81
+
82
+ Returns:
83
+ DiscoveredNode with probe results
84
+ """
85
+ vpn_ip = node.vpn_ip or node.internal_ip
86
+
87
+ discovered = DiscoveredNode(
88
+ name=node.name,
89
+ vpn_ip=vpn_ip,
90
+ )
91
+
92
+ # Ping the node
93
+ result = run_command(f"ping -c 2 -W 2 -q {vpn_ip}", check=False)
94
+
95
+ if result.success:
96
+ discovered.reachable = True
97
+ discovered.last_seen = datetime.now()
98
+
99
+ # Parse latency from ping output
100
+ for line in result.stdout.split("\n"):
101
+ if "rtt" in line or "round-trip" in line:
102
+ parts = line.split("=")
103
+ if len(parts) >= 2:
104
+ times = parts[1].strip().split("/")
105
+ if len(times) >= 2:
106
+ with contextlib.suppress(ValueError):
107
+ discovered.latency_ms = float(times[1])
108
+ break
109
+
110
+ # Probe services
111
+ discovered.services = self._probe_services(vpn_ip, node)
112
+
113
+ return discovered
114
+
115
+ def _probe_services(self, ip: str, node: NodeConfig) -> list[str]:
116
+ """Probe which services are available on a node.
117
+
118
+ Args:
119
+ ip: Node IP address
120
+ node: Node configuration
121
+
122
+ Returns:
123
+ List of available service names
124
+ """
125
+ services = []
126
+
127
+ # Check Tahoe-LAFS ports based on roles
128
+ role_ports = {
129
+ "tahoe_introducer": node.ports.tahoe_introducer,
130
+ "tahoe_storage": node.ports.tahoe_storage,
131
+ "tahoe_client": node.ports.tahoe_client,
132
+ }
133
+
134
+ for role in node.roles:
135
+ role_str = role.value
136
+ if role_str in role_ports:
137
+ port = role_ports[role_str]
138
+ if self._port_open(ip, port):
139
+ services.append(role_str)
140
+
141
+ return services
142
+
143
+ def _port_open(self, ip: str, port: int, timeout: float = 2.0) -> bool:
144
+ """Check if a TCP port is open.
145
+
146
+ Args:
147
+ ip: IP address
148
+ port: Port number
149
+ timeout: Timeout in seconds
150
+
151
+ Returns:
152
+ True if port is open
153
+ """
154
+ result = run_command(
155
+ f"nc -z -w {int(timeout)} {ip} {port}",
156
+ check=False,
157
+ timeout=timeout + 1,
158
+ )
159
+ return result.success
160
+
161
+ def get_reachable_nodes(self) -> list[DiscoveredNode]:
162
+ """Get all reachable nodes.
163
+
164
+ Returns:
165
+ List of reachable nodes
166
+ """
167
+ return [n for n in self._discovered.values() if n.reachable]
168
+
169
+ def get_unreachable_nodes(self) -> list[DiscoveredNode]:
170
+ """Get all unreachable nodes.
171
+
172
+ Returns:
173
+ List of unreachable nodes
174
+ """
175
+ return [n for n in self._discovered.values() if not n.reachable]
176
+
177
+ def get_nodes_with_service(self, service: str) -> list[DiscoveredNode]:
178
+ """Get nodes that have a specific service running.
179
+
180
+ Args:
181
+ service: Service name (e.g., 'tahoe_storage')
182
+
183
+ Returns:
184
+ List of nodes with the service
185
+ """
186
+ return [n for n in self._discovered.values() if service in n.services]
187
+
188
+ def refresh_node(self, node_name: str) -> DiscoveredNode | None:
189
+ """Refresh the status of a single node.
190
+
191
+ Args:
192
+ node_name: Name of the node to refresh
193
+
194
+ Returns:
195
+ Updated DiscoveredNode or None if not found
196
+ """
197
+ node = self.manifest.get_node(node_name)
198
+ if not node:
199
+ return None
200
+
201
+ discovered = self._probe_node(node)
202
+ self._discovered[node_name] = discovered
203
+ return discovered
204
+
205
+ def get_discovery_stats(self) -> dict[str, int]:
206
+ """Get discovery statistics.
207
+
208
+ Returns:
209
+ Dictionary with stats
210
+ """
211
+ total = len(self._discovered)
212
+ reachable = len(self.get_reachable_nodes())
213
+
214
+ return {
215
+ "total_nodes": total,
216
+ "reachable": reachable,
217
+ "unreachable": total - reachable,
218
+ }
@@ -0,0 +1,180 @@
1
+ """DNS and hosts file management for RedundaNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from redundanet.utils.files import read_file, write_file
9
+ from redundanet.utils.logging import get_logger
10
+
11
+ if TYPE_CHECKING:
12
+ from redundanet.core.manifest import Manifest
13
+
14
+ logger = get_logger(__name__)
15
+
16
+ HOSTS_MARKER_START = "# BEGIN REDUNDANET NODES"
17
+ HOSTS_MARKER_END = "# END REDUNDANET NODES"
18
+
19
+
20
+ class DNSManager:
21
+ """Manages local DNS resolution for network nodes."""
22
+
23
+ def __init__(
24
+ self,
25
+ manifest: Manifest,
26
+ hosts_file: Path = Path("/etc/hosts"),
27
+ domain: str = "redundanet.local",
28
+ ) -> None:
29
+ """Initialize DNS manager.
30
+
31
+ Args:
32
+ manifest: Network manifest
33
+ hosts_file: Path to hosts file
34
+ domain: Domain name for nodes
35
+ """
36
+ self.manifest = manifest
37
+ self.hosts_file = hosts_file
38
+ self.domain = domain
39
+ self._resolution_cache: dict[str, str] = {}
40
+
41
+ def update_hosts_file(self) -> None:
42
+ """Update /etc/hosts with node entries."""
43
+ logger.info("Updating hosts file", path=str(self.hosts_file))
44
+
45
+ # Read current hosts file
46
+ content = read_file(self.hosts_file) if self.hosts_file.exists() else ""
47
+
48
+ # Remove existing RedundaNet entries
49
+ lines = content.split("\n")
50
+ new_lines = []
51
+ in_redundanet_section = False
52
+
53
+ for line in lines:
54
+ if HOSTS_MARKER_START in line:
55
+ in_redundanet_section = True
56
+ continue
57
+ if HOSTS_MARKER_END in line:
58
+ in_redundanet_section = False
59
+ continue
60
+ if not in_redundanet_section:
61
+ new_lines.append(line)
62
+
63
+ # Add new RedundaNet entries
64
+ new_lines.append("")
65
+ new_lines.append(HOSTS_MARKER_START)
66
+
67
+ for node in self.manifest.nodes:
68
+ ip = node.vpn_ip or node.internal_ip
69
+ fqdn = f"{node.name}.{self.domain}"
70
+ new_lines.append(f"{ip} {node.name} {fqdn}")
71
+ self._resolution_cache[node.name] = ip
72
+ self._resolution_cache[fqdn] = ip
73
+
74
+ new_lines.append(HOSTS_MARKER_END)
75
+ new_lines.append("")
76
+
77
+ # Write updated hosts file
78
+ content = "\n".join(new_lines)
79
+ write_file(self.hosts_file, content, mode=0o644)
80
+
81
+ logger.info("Hosts file updated", entries=len(self.manifest.nodes))
82
+
83
+ def resolve_node(self, name: str) -> str | None:
84
+ """Resolve a node name to IP address.
85
+
86
+ Args:
87
+ name: Node name or FQDN
88
+
89
+ Returns:
90
+ IP address or None
91
+ """
92
+ # Check cache first
93
+ if name in self._resolution_cache:
94
+ return self._resolution_cache[name]
95
+
96
+ # Check FQDN
97
+ fqdn = f"{name}.{self.domain}"
98
+ if fqdn in self._resolution_cache:
99
+ return self._resolution_cache[fqdn]
100
+
101
+ # Look up in manifest
102
+ node = self.manifest.get_node(name)
103
+ if node:
104
+ ip = node.vpn_ip or node.internal_ip
105
+ self._resolution_cache[name] = ip
106
+ return ip
107
+
108
+ return None
109
+
110
+ def get_all_resolutions(self) -> dict[str, str]:
111
+ """Get all node name to IP resolutions.
112
+
113
+ Returns:
114
+ Dictionary mapping names to IPs
115
+ """
116
+ resolutions = {}
117
+
118
+ for node in self.manifest.nodes:
119
+ ip = node.vpn_ip or node.internal_ip
120
+ resolutions[node.name] = ip
121
+ resolutions[f"{node.name}.{self.domain}"] = ip
122
+
123
+ return resolutions
124
+
125
+ def remove_hosts_entries(self) -> None:
126
+ """Remove RedundaNet entries from hosts file."""
127
+ if not self.hosts_file.exists():
128
+ return
129
+
130
+ content = read_file(self.hosts_file)
131
+
132
+ # Remove RedundaNet section
133
+ lines = content.split("\n")
134
+ new_lines = []
135
+ in_redundanet_section = False
136
+
137
+ for line in lines:
138
+ if HOSTS_MARKER_START in line:
139
+ in_redundanet_section = True
140
+ continue
141
+ if HOSTS_MARKER_END in line:
142
+ in_redundanet_section = False
143
+ continue
144
+ if not in_redundanet_section:
145
+ new_lines.append(line)
146
+
147
+ content = "\n".join(new_lines)
148
+ write_file(self.hosts_file, content, mode=0o644)
149
+
150
+ self._resolution_cache.clear()
151
+ logger.info("Removed RedundaNet entries from hosts file")
152
+
153
+ def write_resolution_file(self, path: Path) -> None:
154
+ """Write a separate resolution file for other scripts.
155
+
156
+ Args:
157
+ path: Path to write resolution file
158
+ """
159
+ lines = []
160
+
161
+ for node in self.manifest.nodes:
162
+ ip = node.vpn_ip or node.internal_ip
163
+ fqdn = f"{node.name}.{self.domain}"
164
+ lines.append(f"{ip} {node.name} {fqdn}")
165
+
166
+ content = "\n".join(lines) + "\n"
167
+ write_file(path, content, mode=0o644)
168
+
169
+ logger.debug("Wrote resolution file", path=str(path))
170
+
171
+ def validate_resolution(self, name: str) -> bool:
172
+ """Validate that a name can be resolved.
173
+
174
+ Args:
175
+ name: Name to validate
176
+
177
+ Returns:
178
+ True if resolvable
179
+ """
180
+ return self.resolve_node(name) is not None