manta-node 0.5b0.dev337__py3-none-any.whl → 0.5b0.dev342__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,1174 @@
1
+ """Network management command implementation for WireGuard SDN.
2
+
3
+ Provides CLI tools for troubleshooting and managing WireGuard networks on the node.
4
+ Uses wireguard-tools Python library for programmatic access to WireGuard state.
5
+ Uses icmplib for network connectivity testing.
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.syntax import Syntax
15
+ from rich.table import Table
16
+
17
+ console = Console()
18
+
19
+
20
+ def _load_node_config():
21
+ """Load the node's config manager and node_id.
22
+
23
+ Returns (config_manager, node_id) or (None, None) if no config found.
24
+ """
25
+ import glob
26
+
27
+ config_files = glob.glob(str(Path.home() / ".manta/nodes/*.toml"))
28
+ if not config_files:
29
+ return None, None
30
+
31
+ try:
32
+ from manta_node.infrastructure.config.node_config_manager import (
33
+ NodeConfigManager,
34
+ )
35
+
36
+ config_manager = NodeConfigManager(config_files[0])
37
+ node_id = (
38
+ config_manager.config.identity.alias
39
+ or config_manager.config.metadata.name
40
+ or Path(config_files[0]).stem
41
+ )
42
+ return config_manager, node_id
43
+ except Exception as e:
44
+ console.print(f"[dim]Warning: Could not load node config: {e}[/dim]")
45
+ return None, None
46
+
47
+
48
+ def list_interfaces() -> list[str]:
49
+ """List all WireGuard interfaces using wireguard-tools."""
50
+ try:
51
+ from wireguard_tools import WireguardDevice
52
+
53
+ devices = list(WireguardDevice.list())
54
+ return [device.interface for device in devices]
55
+ except ImportError:
56
+ console.print("[red]Error: wireguard-tools library not installed[/red]")
57
+ console.print("Install with: [cyan]pip install wireguard-tools[/cyan]")
58
+ return []
59
+ except Exception as e:
60
+ console.print(f"[red]Error listing interfaces: {e}[/red]")
61
+ return []
62
+
63
+
64
+ def get_device_info(interface: str) -> dict | None:
65
+ """Get detailed device information using wireguard-tools."""
66
+ try:
67
+ from wireguard_tools import WireguardDevice, WireguardKey
68
+
69
+ device = WireguardDevice.get(interface)
70
+ config = device.get_config()
71
+
72
+ public_key_str = None
73
+ if hasattr(config, "private_key") and config.private_key:
74
+ # Derive public key from private key
75
+ private_key_obj = WireguardKey(config.private_key)
76
+ public_key_str = str(private_key_obj.public_key())
77
+ elif hasattr(config, "public_key") and config.public_key:
78
+ # Fallback when public_key is exposed directly on the config object
79
+ public_key_str = str(config.public_key)
80
+
81
+ return {
82
+ "interface": device.interface,
83
+ "public_key": public_key_str,
84
+ "listen_port": config.listen_port
85
+ if hasattr(config, "listen_port")
86
+ else None,
87
+ "fwmark": config.fwmark if hasattr(config, "fwmark") else None,
88
+ "peers": list(config.peers.values()) if hasattr(config, "peers") else [],
89
+ }
90
+ except ImportError:
91
+ console.print("[red]Error: wireguard-tools library not installed[/red]")
92
+ console.print("Install with: [cyan]pip install wireguard-tools[/cyan]")
93
+ return None
94
+ except Exception as e:
95
+ console.print(f"[red]Error getting device info: {e}[/red]")
96
+ return None
97
+
98
+
99
+ def list_networks_command(args: list[str]) -> int:
100
+ """List all WireGuard networks on this node."""
101
+ parser = argparse.ArgumentParser(
102
+ prog="manta_node network list", description="List all active WireGuard networks"
103
+ )
104
+ parser.add_argument(
105
+ "--plain", action="store_true", help="Use plain text output instead of table"
106
+ )
107
+
108
+ try:
109
+ parsed_args = parser.parse_args(args)
110
+ except SystemExit:
111
+ return 1
112
+
113
+ interfaces = list_interfaces()
114
+
115
+ if not interfaces:
116
+ console.print("[yellow]No WireGuard interfaces found.[/yellow]")
117
+ console.print(
118
+ "\nNetworks are created when swarms with network requirements are deployed."
119
+ )
120
+ return 0
121
+
122
+ if parsed_args.plain:
123
+ console.print(f"Found {len(interfaces)} WireGuard interface(s):")
124
+ for iface in interfaces:
125
+ console.print(f" - {iface}")
126
+ else:
127
+ table = Table(title="WireGuard Networks")
128
+ table.add_column("Interface", style="cyan")
129
+ table.add_column("Network ID", style="magenta")
130
+ table.add_column("Status", style="green")
131
+
132
+ for iface in interfaces:
133
+ # Extract network_id from interface name (format: wg-{swarm_id})
134
+ network_id = iface if iface.startswith("wg-") else f"wg-{iface}"
135
+ table.add_row(iface, network_id, "active")
136
+
137
+ console.print(table)
138
+
139
+ return 0
140
+
141
+
142
+ def show_network_command(args: list[str]) -> int:
143
+ """Show detailed information about a specific network."""
144
+ parser = argparse.ArgumentParser(
145
+ prog="manta_node network show",
146
+ description="Show detailed information about a WireGuard network",
147
+ )
148
+ parser.add_argument("interface", help="Interface name (e.g., wg-abc123)")
149
+ parser.add_argument("--json", action="store_true", help="Output in JSON format")
150
+
151
+ try:
152
+ parsed_args = parser.parse_args(args)
153
+ except SystemExit:
154
+ return 1
155
+
156
+ info = get_device_info(parsed_args.interface)
157
+
158
+ if not info:
159
+ console.print(
160
+ f"[red]Interface '{parsed_args.interface}' not found or not accessible[/red]"
161
+ )
162
+ return 1
163
+
164
+ if parsed_args.json:
165
+ import json
166
+
167
+ console.print(json.dumps(info, indent=2))
168
+ else:
169
+ # Display formatted information
170
+ console.print(
171
+ Panel(
172
+ f"[bold cyan]{info['interface']}[/bold cyan]",
173
+ title="WireGuard Network",
174
+ expand=False,
175
+ )
176
+ )
177
+
178
+ console.print("\n[bold]Interface Configuration:[/bold]")
179
+ console.print(f" Public Key: {info.get('public_key', 'N/A')}")
180
+ console.print(f" Listen Port: {info.get('listen_port', 'N/A')}")
181
+ if info.get("fwmark"):
182
+ console.print(f" Firewall Mark: {info['fwmark']}")
183
+
184
+ peer_count = len(info.get("peers", []))
185
+ console.print(f"\n[bold]Peers:[/bold] {peer_count} connected")
186
+
187
+ if peer_count == 0:
188
+ console.print(" [dim]No peers configured[/dim]")
189
+
190
+ return 0
191
+
192
+
193
+ def list_peers_command(args: list[str]) -> int:
194
+ """List all peers for a specific network."""
195
+ parser = argparse.ArgumentParser(
196
+ prog="manta_node network peers",
197
+ description="List all peers in a WireGuard network",
198
+ )
199
+ parser.add_argument("interface", help="Interface name (e.g., wg-abc123)")
200
+ parser.add_argument(
201
+ "--plain", action="store_true", help="Use plain text output instead of table"
202
+ )
203
+
204
+ try:
205
+ parsed_args = parser.parse_args(args)
206
+ except SystemExit:
207
+ return 1
208
+
209
+ info = get_device_info(parsed_args.interface)
210
+
211
+ if not info:
212
+ console.print(f"[red]Interface '{parsed_args.interface}' not found[/red]")
213
+ return 1
214
+
215
+ peers = info.get("peers", [])
216
+
217
+ if not peers:
218
+ console.print(
219
+ f"[yellow]No peers configured for {parsed_args.interface}[/yellow]"
220
+ )
221
+ return 0
222
+
223
+ if parsed_args.plain:
224
+ console.print(f"Peers for {parsed_args.interface}:")
225
+ for i, peer in enumerate(peers, 1):
226
+ console.print(f"\n Peer {i}:")
227
+ for key, value in peer.items():
228
+ console.print(f" {key}: {value}")
229
+ else:
230
+ table = Table(title=f"Peers for {parsed_args.interface}")
231
+ table.add_column("Public Key", style="cyan", max_width=40)
232
+ table.add_column("Endpoint", style="magenta")
233
+ table.add_column("Allowed IPs", style="green")
234
+ table.add_column("Latest Handshake", style="yellow")
235
+ table.add_column("Transfer", style="blue")
236
+
237
+ for peer in peers:
238
+ # Handle both wireguard-tools format and fallback format
239
+ if hasattr(peer, "public_key"):
240
+ # wireguard-tools WireguardPeer object
241
+ pubkey = str(peer.public_key)[:20] + "..."
242
+ endpoint = str(peer.endpoint) if peer.endpoint else "N/A"
243
+ allowed_ips = ", ".join(str(ip) for ip in peer.allowed_ips)
244
+ handshake = (
245
+ str(peer.latest_handshake) if peer.latest_handshake else "Never"
246
+ )
247
+ transfer = (
248
+ f"↓{peer.rx_bytes} ↑{peer.tx_bytes}"
249
+ if hasattr(peer, "rx_bytes")
250
+ else "N/A"
251
+ )
252
+ else:
253
+ # Fallback dict format
254
+ pubkey = peer.get("public_key", "N/A")[:20] + "..."
255
+ endpoint = peer.get("endpoint", "N/A")
256
+ allowed_ips = peer.get("allowed ips", "N/A")
257
+ handshake = peer.get("latest handshake", "Never")
258
+ transfer_rx = peer.get("transfer", {}).get("received", "0 B")
259
+ transfer_tx = peer.get("transfer", {}).get("sent", "0 B")
260
+ transfer = f"↓{transfer_rx} ↑{transfer_tx}"
261
+
262
+ table.add_row(pubkey, endpoint, allowed_ips, handshake, transfer)
263
+
264
+ console.print(table)
265
+
266
+ return 0
267
+
268
+
269
+ def show_peer_command(args: list[str]) -> int:
270
+ """Show detailed information about a specific peer."""
271
+ parser = argparse.ArgumentParser(
272
+ prog="manta_node network peer",
273
+ description="Show detailed information about a peer",
274
+ )
275
+ parser.add_argument("interface", help="Interface name (e.g., wg-abc123)")
276
+ parser.add_argument("peer_pubkey", help="Peer public key (or partial match)")
277
+
278
+ try:
279
+ parsed_args = parser.parse_args(args)
280
+ except SystemExit:
281
+ return 1
282
+
283
+ info = get_device_info(parsed_args.interface)
284
+
285
+ if not info:
286
+ console.print(f"[red]Interface '{parsed_args.interface}' not found[/red]")
287
+ return 1
288
+
289
+ # Find matching peer
290
+ peers = info.get("peers", [])
291
+ matching_peer = None
292
+
293
+ for peer in peers:
294
+ pubkey = (
295
+ str(peer.public_key)
296
+ if hasattr(peer, "public_key")
297
+ else peer.get("public_key", "")
298
+ )
299
+ if (
300
+ pubkey.startswith(parsed_args.peer_pubkey)
301
+ or parsed_args.peer_pubkey in pubkey
302
+ ):
303
+ matching_peer = peer
304
+ break
305
+
306
+ if not matching_peer:
307
+ console.print(
308
+ f"[red]Peer with public key matching '{parsed_args.peer_pubkey}' not found[/red]"
309
+ )
310
+ return 1
311
+
312
+ # Display peer information
313
+ console.print(
314
+ Panel(
315
+ "[bold cyan]Peer Details[/bold cyan]",
316
+ title=f"Network: {parsed_args.interface}",
317
+ expand=False,
318
+ )
319
+ )
320
+
321
+ if hasattr(matching_peer, "public_key"):
322
+ console.print("\n[bold]Identity:[/bold]")
323
+ console.print(f" Public Key: {matching_peer.public_key}")
324
+ console.print("\n[bold]Connection:[/bold]")
325
+ console.print(f" Endpoint: {matching_peer.endpoint or 'N/A'}")
326
+ console.print(
327
+ f" Allowed IPs: {', '.join(str(ip) for ip in matching_peer.allowed_ips)}"
328
+ )
329
+ console.print(
330
+ f" Latest Handshake: {matching_peer.latest_handshake or 'Never'}"
331
+ )
332
+ console.print(
333
+ f" Persistent Keepalive: {matching_peer.persistent_keepalive or 'Off'}"
334
+ )
335
+ if hasattr(matching_peer, "rx_bytes"):
336
+ console.print("\n[bold]Transfer:[/bold]")
337
+ console.print(f" Received: {matching_peer.rx_bytes} bytes")
338
+ console.print(f" Sent: {matching_peer.tx_bytes} bytes")
339
+ else:
340
+ for key, value in matching_peer.items():
341
+ console.print(f" {key}: {value}")
342
+
343
+ return 0
344
+
345
+
346
+ def test_connectivity_command(args: list[str]) -> int:
347
+ """Test connectivity to a peer."""
348
+ parser = argparse.ArgumentParser(
349
+ prog="manta_node network test",
350
+ description="Test connectivity to a peer via WireGuard",
351
+ )
352
+ parser.add_argument("interface", help="Interface name (e.g., wg-abc123)")
353
+ parser.add_argument("peer_ip", help="Peer IP address to ping")
354
+ parser.add_argument(
355
+ "--count",
356
+ type=int,
357
+ default=4,
358
+ help="Number of ping packets to send (default: 4)",
359
+ )
360
+
361
+ try:
362
+ parsed_args = parser.parse_args(args)
363
+ except SystemExit:
364
+ return 1
365
+
366
+ # Check interface exists
367
+ interfaces = list_interfaces()
368
+ if parsed_args.interface not in interfaces:
369
+ console.print(f"[red]Interface '{parsed_args.interface}' not found[/red]")
370
+ return 1
371
+
372
+ console.print(
373
+ f"Testing connectivity to {parsed_args.peer_ip} via {parsed_args.interface}..."
374
+ )
375
+
376
+ try:
377
+ from icmplib import ping
378
+
379
+ # Try unprivileged ping first (works if ping_group_range is configured)
380
+ # Fall back to privileged mode if that fails
381
+ host = None
382
+ privileged_mode = False
383
+
384
+ try:
385
+ host = ping(
386
+ parsed_args.peer_ip,
387
+ count=parsed_args.count,
388
+ interval=1,
389
+ timeout=2,
390
+ privileged=False, # Try unprivileged first
391
+ )
392
+ except (PermissionError, OSError):
393
+ # Unprivileged failed, try privileged mode
394
+ try:
395
+ host = ping(
396
+ parsed_args.peer_ip,
397
+ count=parsed_args.count,
398
+ interval=1,
399
+ timeout=2,
400
+ privileged=True, # Requires root/sudo or CAP_NET_RAW
401
+ )
402
+ privileged_mode = True
403
+ except (PermissionError, OSError):
404
+ console.print(
405
+ "[red]Error: Insufficient permissions for ICMP ping[/red]"
406
+ )
407
+ console.print("\n[bold]To enable unprivileged ping, configure:[/bold]")
408
+ console.print(
409
+ " [cyan]echo 'net.ipv4.ping_group_range = 0 2147483647' | sudo tee -a /etc/sysctl.conf[/cyan]"
410
+ )
411
+ console.print(" [cyan]sudo sysctl -p[/cyan]")
412
+ console.print("\n[bold]Or run with sudo:[/bold]")
413
+ console.print(" [cyan]sudo manta_node network test ...[/cyan]")
414
+ return 1
415
+
416
+ # Display results
417
+ console.print(f"\n[bold]PING {parsed_args.peer_ip}[/bold]")
418
+ if privileged_mode:
419
+ console.print(" [dim](using privileged ICMP sockets)[/dim]")
420
+ else:
421
+ console.print(" [dim](using unprivileged ping)[/dim]")
422
+
423
+ console.print(f" Packets sent: {host.packets_sent}")
424
+ console.print(f" Packets received: {host.packets_received}")
425
+ console.print(f" Packet loss: {host.packet_loss * 100:.1f}%")
426
+
427
+ if host.packets_received > 0:
428
+ console.print(f" Min RTT: {host.min_rtt:.2f} ms")
429
+ console.print(f" Avg RTT: {host.avg_rtt:.2f} ms")
430
+ console.print(f" Max RTT: {host.max_rtt:.2f} ms")
431
+
432
+ if host.is_alive:
433
+ console.print("\n[green]✓ Connectivity successful[/green]")
434
+ return 0
435
+ else:
436
+ console.print("\n[red]✗ Connectivity failed (no response)[/red]")
437
+ return 1
438
+
439
+ except ImportError:
440
+ console.print("[red]Error: icmplib library not installed[/red]")
441
+ console.print("Install with: [cyan]pip install icmplib[/cyan]")
442
+ return 1
443
+ except Exception as e:
444
+ console.print(f"[red]Error: {e}[/red]")
445
+ return 1
446
+
447
+
448
+ def show_config_command(args: list[str]) -> int:
449
+ """Show WireGuard configuration file."""
450
+ parser = argparse.ArgumentParser(
451
+ prog="manta_node network config",
452
+ description="Show WireGuard configuration file",
453
+ )
454
+ parser.add_argument("interface", help="Interface name (e.g., wg-abc123)")
455
+
456
+ try:
457
+ parsed_args = parser.parse_args(args)
458
+ except SystemExit:
459
+ return 1
460
+
461
+ config_path = Path(f"/etc/wireguard/{parsed_args.interface}.conf")
462
+
463
+ if not config_path.exists():
464
+ console.print(f"[red]Configuration file not found: {config_path}[/red]")
465
+ return 1
466
+
467
+ try:
468
+ config_content = config_path.read_text()
469
+
470
+ syntax = Syntax(config_content, "ini", theme="monokai", line_numbers=True)
471
+
472
+ console.print(
473
+ Panel(syntax, title=f"Configuration: {parsed_args.interface}", expand=False)
474
+ )
475
+
476
+ return 0
477
+ except PermissionError:
478
+ console.print(f"[red]Permission denied reading {config_path}[/red]")
479
+ console.print("Try running with sudo")
480
+ return 1
481
+ except Exception as e:
482
+ console.print(f"[red]Error reading config: {e}[/red]")
483
+ return 1
484
+
485
+
486
+ def generate_key_command(args: list[str]) -> int:
487
+ """Generate WireGuard keypair."""
488
+ parser = argparse.ArgumentParser(
489
+ prog="manta_node network generate-key",
490
+ description="Generate a WireGuard keypair",
491
+ )
492
+ parser.add_argument(
493
+ "--output",
494
+ "-o",
495
+ help="Output file for private key (public key goes to <file>.pub)",
496
+ )
497
+
498
+ try:
499
+ parsed_args = parser.parse_args(args)
500
+ except SystemExit:
501
+ return 1
502
+
503
+ try:
504
+ from wireguard_tools import WireguardKey
505
+
506
+ # Generate keypair
507
+ private_key = WireguardKey.generate()
508
+ public_key = private_key.public_key()
509
+
510
+ if parsed_args.output:
511
+ # Write to files
512
+ private_file = Path(parsed_args.output)
513
+ public_file = Path(f"{parsed_args.output}.pub")
514
+
515
+ # Check if files exist
516
+ if private_file.exists() or public_file.exists():
517
+ console.print("[yellow]Warning: File(s) already exist[/yellow]")
518
+ response = input("Overwrite? (y/N): ").strip().lower()
519
+ if response != "y":
520
+ console.print("[dim]Operation cancelled[/dim]")
521
+ return 0
522
+
523
+ # Write private key, pre-restricted so it is never world/group readable
524
+ private_file.touch(mode=0o600)
525
+ private_file.write_text(str(private_key))
526
+
527
+ # Write public key
528
+ public_file.write_text(str(public_key))
529
+ public_file.chmod(0o644)
530
+
531
+ console.print(f"[green]✓[/green] Private key written to: {private_file}")
532
+ console.print(f"[green]✓[/green] Public key written to: {public_file}")
533
+ else:
534
+ # Print to console
535
+ console.print("\n[bold]Private Key:[/bold]")
536
+ console.print(f"[cyan]{private_key}[/cyan]")
537
+ console.print("\n[bold]Public Key:[/bold]")
538
+ console.print(f"[cyan]{public_key}[/cyan]")
539
+ console.print("\n[dim]Tip: Use --output to save keys to files[/dim]")
540
+
541
+ return 0
542
+
543
+ except ImportError:
544
+ console.print("[red]Error: wireguard-tools library not installed[/red]")
545
+ console.print("Install with: [cyan]pip install wireguard-tools[/cyan]")
546
+ return 1
547
+ except Exception as e:
548
+ console.print(f"[red]Error generating keypair: {e}[/red]")
549
+ return 1
550
+
551
+
552
+ def create_network_command(args: list[str]) -> int:
553
+ """Create a new WireGuard network interface."""
554
+ parser = argparse.ArgumentParser(
555
+ prog="manta_node network create",
556
+ description="Create a new WireGuard network interface",
557
+ )
558
+ parser.add_argument("interface", help="Interface name (e.g., wg-test)")
559
+ parser.add_argument(
560
+ "--ip",
561
+ required=True,
562
+ help="Assigned IP address with CIDR (e.g., 10.100.0.5/24)",
563
+ )
564
+ parser.add_argument(
565
+ "--port", type=int, default=51820, help="Listen port (default: 51820)"
566
+ )
567
+ parser.add_argument("--mtu", type=int, default=1420, help="MTU (default: 1420)")
568
+ parser.add_argument(
569
+ "--private-key", help="Private key (will generate if not provided)"
570
+ )
571
+ parser.add_argument(
572
+ "--force", action="store_true", help="Skip confirmation prompts"
573
+ )
574
+
575
+ try:
576
+ parsed_args = parser.parse_args(args)
577
+ except SystemExit:
578
+ return 1
579
+
580
+ interface = parsed_args.interface
581
+ assigned_ip = parsed_args.ip
582
+ listen_port = parsed_args.port
583
+ mtu = parsed_args.mtu
584
+ private_key = parsed_args.private_key
585
+
586
+ # Check if interface already exists
587
+ existing_interfaces = list_interfaces()
588
+ if interface in existing_interfaces:
589
+ console.print(f"[red]Error: Interface {interface} already exists[/red]")
590
+ return 1
591
+
592
+ # Confirm operation
593
+ if not parsed_args.force:
594
+ console.print("\n[bold]Create WireGuard Network:[/bold]")
595
+ console.print(f" Interface: [cyan]{interface}[/cyan]")
596
+ console.print(f" IP: [cyan]{assigned_ip}[/cyan]")
597
+ console.print(f" Port: [cyan]{listen_port}[/cyan]")
598
+ console.print(f" MTU: [cyan]{mtu}[/cyan]")
599
+ response = input("\nProceed? (y/N): ").strip().lower()
600
+ if response != "y":
601
+ console.print("[dim]Operation cancelled[/dim]")
602
+ return 0
603
+
604
+ try:
605
+ from pyroute2 import IPRoute
606
+ from wireguard_tools import WireguardConfig, WireguardDevice, WireguardKey
607
+
608
+ # Generate or parse private key
609
+ if not private_key:
610
+ console.print("[dim]Generating keypair...[/dim]")
611
+ priv_key_obj = WireguardKey.generate()
612
+ pub_key_obj = priv_key_obj.public_key()
613
+ private_key = str(priv_key_obj)
614
+ console.print(f"Public key: [cyan]{pub_key_obj}[/cyan]")
615
+ else:
616
+ priv_key_obj = WireguardKey(private_key)
617
+ pub_key_obj = priv_key_obj.public_key()
618
+
619
+ # Parse IP address
620
+ ip_addr, prefix_len = assigned_ip.split("/")
621
+
622
+ # Create interface using pyroute2
623
+ console.print(f"[dim]Creating interface {interface}...[/dim]")
624
+ with IPRoute() as ip:
625
+ # Create interface
626
+ ip.link("add", ifname=interface, kind="wireguard")
627
+
628
+ # Get interface index
629
+ idx = ip.link_lookup(ifname=interface)[0]
630
+
631
+ # Set MTU
632
+ ip.link("set", index=idx, mtu=mtu)
633
+
634
+ # Add IP address
635
+ ip.addr("add", index=idx, address=ip_addr, prefixlen=int(prefix_len))
636
+
637
+ # Bring interface up
638
+ ip.link("set", index=idx, state="up")
639
+
640
+ # Configure WireGuard-specific parameters
641
+ console.print("[dim]Configuring WireGuard...[/dim]")
642
+ device = WireguardDevice.get(interface)
643
+ config = WireguardConfig(
644
+ private_key=private_key,
645
+ listen_port=listen_port,
646
+ peers={},
647
+ )
648
+ device.set_config(config)
649
+
650
+ # Persist state to keyring + TOML config
651
+ try:
652
+ import keyring
653
+
654
+ from manta_node.infrastructure.config.node_config_manager import (
655
+ WireGuardNetworkState,
656
+ )
657
+
658
+ config_manager, node_id = _load_node_config()
659
+ if config_manager and node_id:
660
+ keyring_service = "manta_node_wireguard"
661
+ keyring_key = f"{node_id}:{interface}"
662
+ keyring.set_password(keyring_service, keyring_key, private_key)
663
+
664
+ wg_state = WireGuardNetworkState(
665
+ network_id=interface,
666
+ interface_name=interface,
667
+ private_key_ref=keyring_key,
668
+ assigned_ip=assigned_ip,
669
+ network_cidr=assigned_ip,
670
+ listen_port=listen_port,
671
+ mtu=mtu,
672
+ peers=[],
673
+ )
674
+ config_manager.config.wireguard.networks[interface] = wg_state
675
+ config_manager.save()
676
+ console.print("[dim]Persisted network state to config + keyring[/dim]")
677
+ else:
678
+ console.print(
679
+ "[dim]Warning: No node config found — state not persisted[/dim]"
680
+ )
681
+ except Exception as e:
682
+ console.print(f"[yellow]Warning: Could not persist state: {e}[/yellow]")
683
+ console.print("[dim]Interface is up but keys won't survive reboot[/dim]")
684
+
685
+ console.print(
686
+ f"\n[green]✓[/green] Created WireGuard network: [cyan]{interface}[/cyan]"
687
+ )
688
+ console.print(f" IP: {assigned_ip}")
689
+ console.print(f" Public key: {pub_key_obj}")
690
+ console.print(f" Port: {listen_port}")
691
+
692
+ console.print("\n[bold]Next steps:[/bold]")
693
+ console.print(" 1. Share your public key with peers")
694
+ console.print(
695
+ f" 2. Add peers: [cyan]manta_node network add-peer {interface} --pubkey <KEY> --endpoint <IP:PORT> --allowed-ips <CIDR>[/cyan]"
696
+ )
697
+ console.print(
698
+ f" 3. Test connectivity: [cyan]manta_node network test {interface} <PEER_IP>[/cyan]"
699
+ )
700
+
701
+ return 0
702
+
703
+ except ImportError as e:
704
+ console.print(f"[red]Error: Required library not installed: {e}[/red]")
705
+ console.print("Install with: [cyan]pip install wireguard-tools pyroute2[/cyan]")
706
+ return 1
707
+ except Exception as e:
708
+ console.print(f"[red]Error creating interface: {e}[/red]")
709
+ return 1
710
+
711
+
712
+ def delete_network_command(args: list[str]) -> int:
713
+ """Delete a WireGuard network interface."""
714
+ parser = argparse.ArgumentParser(
715
+ prog="manta_node network delete",
716
+ description="Delete a WireGuard network interface",
717
+ )
718
+ parser.add_argument("interface", help="Interface name to delete")
719
+ parser.add_argument(
720
+ "--force", action="store_true", help="Skip confirmation prompts"
721
+ )
722
+
723
+ try:
724
+ parsed_args = parser.parse_args(args)
725
+ except SystemExit:
726
+ return 1
727
+
728
+ interface = parsed_args.interface
729
+
730
+ # Check if interface exists
731
+ existing_interfaces = list_interfaces()
732
+ if interface not in existing_interfaces:
733
+ console.print(f"[red]Error: Interface {interface} not found[/red]")
734
+ return 1
735
+
736
+ # Confirm operation
737
+ if not parsed_args.force:
738
+ console.print(
739
+ f"\n[yellow]Warning: This will delete interface {interface}[/yellow]"
740
+ )
741
+ response = input("Proceed? (y/N): ").strip().lower()
742
+ if response != "y":
743
+ console.print("[dim]Operation cancelled[/dim]")
744
+ return 0
745
+
746
+ try:
747
+ from pyroute2 import IPRoute
748
+
749
+ console.print(f"[dim]Deleting interface {interface}...[/dim]")
750
+
751
+ with IPRoute() as ip:
752
+ # Get interface index
753
+ idx = ip.link_lookup(ifname=interface)
754
+
755
+ if idx:
756
+ # Delete the interface
757
+ ip.link("del", index=idx[0])
758
+ console.print(
759
+ f"[green]✓[/green] Deleted interface: [cyan]{interface}[/cyan]"
760
+ )
761
+ else:
762
+ console.print(f"[red]Error: Interface {interface} not found[/red]")
763
+ return 1
764
+
765
+ # Clean up persisted state from keyring + TOML config
766
+ try:
767
+ config_manager, _ = _load_node_config()
768
+ if config_manager and interface in config_manager.config.wireguard.networks:
769
+ wg_state = config_manager.config.wireguard.networks[interface]
770
+
771
+ # Delete private key from keyring
772
+ try:
773
+ import keyring
774
+
775
+ keyring.delete_password(
776
+ "manta_node_wireguard", wg_state.private_key_ref
777
+ )
778
+ console.print("[dim]Removed key from keyring[/dim]")
779
+ except Exception as e:
780
+ console.print(
781
+ f"[dim]Warning: Could not remove keyring entry: {e}[/dim]"
782
+ )
783
+
784
+ # Remove network from config
785
+ del config_manager.config.wireguard.networks[interface]
786
+ config_manager.save()
787
+ console.print("[dim]Removed network from config[/dim]")
788
+ except Exception as e:
789
+ console.print(
790
+ f"[yellow]Warning: Could not clean up persisted state: {e}[/yellow]"
791
+ )
792
+
793
+ return 0
794
+
795
+ except ImportError:
796
+ console.print("[red]Error: pyroute2 library not installed[/red]")
797
+ console.print("Install with: [cyan]pip install pyroute2[/cyan]")
798
+ return 1
799
+ except Exception as e:
800
+ console.print(f"[red]Error deleting interface: {e}[/red]")
801
+ return 1
802
+
803
+
804
+ def add_peer_command(args: list[str]) -> int:
805
+ """Add a peer to a WireGuard network."""
806
+ parser = argparse.ArgumentParser(
807
+ prog="manta_node network add-peer",
808
+ description="Add a peer to a WireGuard network",
809
+ )
810
+ parser.add_argument("interface", help="Interface name")
811
+ parser.add_argument("--pubkey", required=True, help="Peer's public key")
812
+ parser.add_argument("--endpoint", help="Peer endpoint (IP:PORT)")
813
+ parser.add_argument(
814
+ "--allowed-ips",
815
+ required=True,
816
+ help="Comma-separated list of allowed IPs (e.g., 10.100.0.2/32)",
817
+ )
818
+ parser.add_argument(
819
+ "--keepalive",
820
+ type=int,
821
+ default=25,
822
+ help="Persistent keepalive interval in seconds (default: 25)",
823
+ )
824
+ parser.add_argument(
825
+ "--force", action="store_true", help="Skip confirmation prompts"
826
+ )
827
+
828
+ try:
829
+ parsed_args = parser.parse_args(args)
830
+ except SystemExit:
831
+ return 1
832
+
833
+ interface = parsed_args.interface
834
+ public_key = parsed_args.pubkey
835
+ endpoint = parsed_args.endpoint
836
+ allowed_ips = [ip.strip() for ip in parsed_args.allowed_ips.split(",")]
837
+ keepalive = parsed_args.keepalive
838
+
839
+ # Check if interface exists
840
+ existing_interfaces = list_interfaces()
841
+ if interface not in existing_interfaces:
842
+ console.print(f"[red]Error: Interface {interface} not found[/red]")
843
+ console.print("\nCreate the interface first:")
844
+ console.print(
845
+ f" [cyan]manta_node network create {interface} --ip <IP/CIDR>[/cyan]"
846
+ )
847
+ return 1
848
+
849
+ # Confirm operation
850
+ if not parsed_args.force:
851
+ console.print("\n[bold]Add Peer:[/bold]")
852
+ console.print(f" Interface: [cyan]{interface}[/cyan]")
853
+ console.print(f" Public key: [cyan]{public_key[:32]}...[/cyan]")
854
+ console.print(f" Endpoint: [cyan]{endpoint or 'None'}[/cyan]")
855
+ console.print(f" Allowed IPs: [cyan]{', '.join(allowed_ips)}[/cyan]")
856
+ response = input("\nProceed? (y/N): ").strip().lower()
857
+ if response != "y":
858
+ console.print("[dim]Operation cancelled[/dim]")
859
+ return 0
860
+
861
+ try:
862
+ from pyroute2 import IPRoute
863
+ from wireguard_tools import (
864
+ WireguardDevice,
865
+ WireguardKey,
866
+ )
867
+ from wireguard_tools import (
868
+ WireguardPeer as WGPeer,
869
+ )
870
+
871
+ console.print(f"[dim]Adding peer to {interface}...[/dim]")
872
+
873
+ # Parse endpoint string (e.g., "192.168.104.1:51820") into host/port
874
+ endpoint_host = None
875
+ endpoint_port = None
876
+ if endpoint:
877
+ parts = endpoint.rsplit(":", 1)
878
+ endpoint_host = parts[0]
879
+ endpoint_port = int(parts[1]) if len(parts) > 1 else None
880
+
881
+ # Get current device configuration
882
+ device = WireguardDevice.get(interface)
883
+ config = device.get_config()
884
+
885
+ # config.peers is a dict[WireguardKey, WireguardPeer]
886
+ peer_key = WireguardKey(public_key)
887
+ peer_exists = peer_key in config.peers
888
+
889
+ config.peers[peer_key] = WGPeer(
890
+ public_key=peer_key,
891
+ endpoint_host=endpoint_host,
892
+ endpoint_port=endpoint_port,
893
+ allowed_ips=allowed_ips,
894
+ persistent_keepalive=keepalive,
895
+ )
896
+
897
+ # Apply configuration
898
+ device.set_config(config)
899
+
900
+ # Add routes for allowed_ips
901
+ console.print("[dim]Adding routes...[/dim]")
902
+ with IPRoute() as ip:
903
+ idx = ip.link_lookup(ifname=interface)[0]
904
+
905
+ for allowed_ip in allowed_ips:
906
+ try:
907
+ # Add route
908
+ ip.route("add", dst=allowed_ip, oif=idx)
909
+ except Exception as e:
910
+ # Route might already exist
911
+ console.print(
912
+ f"[dim]Route for {allowed_ip} may already exist: {e}[/dim]"
913
+ )
914
+
915
+ # Persist peer to TOML config
916
+ try:
917
+ from manta_node.infrastructure.config.node_config_manager import (
918
+ WireGuardPeerState,
919
+ )
920
+
921
+ config_manager, _ = _load_node_config()
922
+ if config_manager and interface in config_manager.config.wireguard.networks:
923
+ network_state = config_manager.config.wireguard.networks[interface]
924
+ new_peer = WireGuardPeerState(
925
+ node_id="",
926
+ public_key=public_key,
927
+ endpoint=endpoint,
928
+ allowed_ips=allowed_ips,
929
+ persistent_keepalive=keepalive,
930
+ )
931
+
932
+ # Update existing peer or append new one
933
+ updated = False
934
+ for i, existing in enumerate(network_state.peers):
935
+ if existing.public_key == public_key:
936
+ network_state.peers[i] = new_peer
937
+ updated = True
938
+ break
939
+ if not updated:
940
+ network_state.peers.append(new_peer)
941
+
942
+ config_manager.save()
943
+ console.print("[dim]Persisted peer to config[/dim]")
944
+ except Exception as e:
945
+ console.print(f"[yellow]Warning: Could not persist peer: {e}[/yellow]")
946
+
947
+ action = "Updated" if peer_exists else "Added"
948
+ console.print(f"\n[green]✓[/green] {action} peer on [cyan]{interface}[/cyan]")
949
+ console.print(f" Public key: {public_key[:32]}...")
950
+ console.print(f" Allowed IPs: {', '.join(allowed_ips)}")
951
+
952
+ if endpoint:
953
+ console.print("\n[bold]Test connectivity:[/bold]")
954
+ peer_ip = allowed_ips[0].split("/")[0] # Get first IP without CIDR
955
+ console.print(
956
+ f" [cyan]manta_node network test {interface} {peer_ip}[/cyan]"
957
+ )
958
+
959
+ return 0
960
+
961
+ except ImportError as e:
962
+ console.print(f"[red]Error: Required library not installed: {e}[/red]")
963
+ console.print("Install with: [cyan]pip install wireguard-tools pyroute2[/cyan]")
964
+ return 1
965
+ except Exception as e:
966
+ console.print(f"[red]Error adding peer: {e}[/red]")
967
+ return 1
968
+
969
+
970
+ def remove_peer_command(args: list[str]) -> int:
971
+ """Remove a peer from a WireGuard network."""
972
+ parser = argparse.ArgumentParser(
973
+ prog="manta_node network remove-peer",
974
+ description="Remove a peer from a WireGuard network",
975
+ )
976
+ parser.add_argument("interface", help="Interface name")
977
+ parser.add_argument("--pubkey", required=True, help="Peer's public key to remove")
978
+ parser.add_argument(
979
+ "--force", action="store_true", help="Skip confirmation prompts"
980
+ )
981
+
982
+ try:
983
+ parsed_args = parser.parse_args(args)
984
+ except SystemExit:
985
+ return 1
986
+
987
+ interface = parsed_args.interface
988
+ public_key = parsed_args.pubkey
989
+
990
+ # Check if interface exists
991
+ existing_interfaces = list_interfaces()
992
+ if interface not in existing_interfaces:
993
+ console.print(f"[red]Error: Interface {interface} not found[/red]")
994
+ return 1
995
+
996
+ # Confirm operation
997
+ if not parsed_args.force:
998
+ console.print(f"\n[yellow]Remove peer from {interface}?[/yellow]")
999
+ console.print(f" Public key: [cyan]{public_key[:32]}...[/cyan]")
1000
+ response = input("\nProceed? (y/N): ").strip().lower()
1001
+ if response != "y":
1002
+ console.print("[dim]Operation cancelled[/dim]")
1003
+ return 0
1004
+
1005
+ try:
1006
+ from pyroute2 import IPRoute
1007
+ from wireguard_tools import WireguardDevice, WireguardKey
1008
+
1009
+ console.print(f"[dim]Removing peer from {interface}...[/dim]")
1010
+
1011
+ # Get current device configuration
1012
+ device = WireguardDevice.get(interface)
1013
+ config = device.get_config()
1014
+
1015
+ # config.peers is a dict[WireguardKey, WireguardPeer]
1016
+ peer_key = WireguardKey(public_key)
1017
+ peer_allowed_ips = []
1018
+
1019
+ if peer_key not in config.peers:
1020
+ console.print(f"[yellow]Warning: Peer not found in {interface}[/yellow]")
1021
+ return 1
1022
+
1023
+ existing_peer = config.peers[peer_key]
1024
+ peer_allowed_ips = (
1025
+ list(existing_peer.allowed_ips) if existing_peer.allowed_ips else []
1026
+ )
1027
+ del config.peers[peer_key]
1028
+
1029
+ device.set_config(config)
1030
+
1031
+ # Remove routes for peer's allowed_ips
1032
+ if peer_allowed_ips:
1033
+ console.print("[dim]Removing routes...[/dim]")
1034
+ with IPRoute() as ip:
1035
+ idx = ip.link_lookup(ifname=interface)[0]
1036
+
1037
+ for allowed_ip in peer_allowed_ips:
1038
+ try:
1039
+ # Remove route
1040
+ ip.route("del", dst=allowed_ip, oif=idx)
1041
+ except Exception as e:
1042
+ # Route might not exist
1043
+ console.print(
1044
+ f"[dim]Route for {allowed_ip} may not exist: {e}[/dim]"
1045
+ )
1046
+
1047
+ console.print(f"\n[green]✓[/green] Removed peer from [cyan]{interface}[/cyan]")
1048
+ console.print(f" Public key: {public_key[:32]}...")
1049
+
1050
+ return 0
1051
+
1052
+ except ImportError as e:
1053
+ console.print(f"[red]Error: Required library not installed: {e}[/red]")
1054
+ console.print("Install with: [cyan]pip install wireguard-tools pyroute2[/cyan]")
1055
+ return 1
1056
+ except Exception as e:
1057
+ console.print(f"[red]Error removing peer: {e}[/red]")
1058
+ return 1
1059
+
1060
+
1061
+ def network_command(args: list[str]) -> int:
1062
+ """Handle network command with subcommands.
1063
+
1064
+ Args:
1065
+ args: Command line arguments
1066
+
1067
+ Returns:
1068
+ Exit code (0 for success, non-zero for error)
1069
+ """
1070
+ if not args or args[0] in ["-h", "--help", "help"]:
1071
+ show_network_help()
1072
+ return 0
1073
+
1074
+ subcommand = args[0]
1075
+ subcommand_args = args[1:]
1076
+
1077
+ # Route to appropriate subcommand
1078
+ if subcommand == "list":
1079
+ return list_networks_command(subcommand_args)
1080
+ elif subcommand == "show":
1081
+ return show_network_command(subcommand_args)
1082
+ elif subcommand == "peers":
1083
+ return list_peers_command(subcommand_args)
1084
+ elif subcommand == "peer":
1085
+ return show_peer_command(subcommand_args)
1086
+ elif subcommand == "test":
1087
+ return test_connectivity_command(subcommand_args)
1088
+ elif subcommand == "config":
1089
+ return show_config_command(subcommand_args)
1090
+ elif subcommand == "generate-key":
1091
+ return generate_key_command(subcommand_args)
1092
+ elif subcommand == "create":
1093
+ return create_network_command(subcommand_args)
1094
+ elif subcommand == "delete":
1095
+ return delete_network_command(subcommand_args)
1096
+ elif subcommand == "add-peer":
1097
+ return add_peer_command(subcommand_args)
1098
+ elif subcommand == "remove-peer":
1099
+ return remove_peer_command(subcommand_args)
1100
+ else:
1101
+ console.print(f"[red]Unknown subcommand: {subcommand}[/red]")
1102
+ console.print("\nUse 'manta_node network help' to see available subcommands")
1103
+ return 1
1104
+
1105
+
1106
+ def show_network_help():
1107
+ """Show network command help."""
1108
+ help_text = """
1109
+ [bold cyan]Manta Node Network Management[/bold cyan]
1110
+
1111
+ Manage and troubleshoot WireGuard SDN networks on this node.
1112
+
1113
+ [bold]Usage:[/bold]
1114
+ manta_node network <subcommand> [options]
1115
+
1116
+ [bold]Read Operations (Query):[/bold]
1117
+ [cyan]list[/cyan] List all active WireGuard networks
1118
+ [cyan]show <interface>[/cyan] Show detailed network information
1119
+ [cyan]peers <interface>[/cyan] List all peers in a network
1120
+ [cyan]peer <interface> <pubkey>[/cyan] Show detailed peer information
1121
+ [cyan]test <interface> <ip>[/cyan] Test connectivity to a peer
1122
+ [cyan]config <interface>[/cyan] Show WireGuard configuration file
1123
+
1124
+ [bold]Write Operations (Manage):[/bold]
1125
+ [cyan]generate-key[/cyan] Generate a WireGuard keypair
1126
+ [cyan]create <interface> --ip <CIDR>[/cyan] Create a new WireGuard network
1127
+ [cyan]delete <interface>[/cyan] Delete a WireGuard network
1128
+ [cyan]add-peer <interface> ...[/cyan] Add a peer to a network
1129
+ [cyan]remove-peer <interface> ...[/cyan] Remove a peer from a network
1130
+
1131
+ [bold]Examples:[/bold]
1132
+ [dim]# Read operations[/dim]
1133
+ manta_node network list
1134
+ manta_node network show wg-abc123
1135
+ manta_node network peers wg-abc123
1136
+ manta_node network test wg-abc123 10.100.0.2
1137
+
1138
+ [dim]# Write operations[/dim]
1139
+ manta_node network generate-key --output /tmp/wg-key
1140
+ manta_node network create wg-test --ip 10.100.0.1/24 --port 51820
1141
+ manta_node network add-peer wg-test --pubkey <KEY> --endpoint 192.0.2.1:51820 --allowed-ips 10.100.0.2/32
1142
+ manta_node network remove-peer wg-test --pubkey <KEY>
1143
+ manta_node network delete wg-test
1144
+
1145
+ [bold]Permissions:[/bold]
1146
+ Most commands require elevated permissions to access WireGuard devices.
1147
+
1148
+ [bold]Option 1: Run with sudo[/bold]
1149
+ sudo manta_node network <subcommand>
1150
+
1151
+ [bold]Option 2: One-time setup (recommended)[/bold]
1152
+ Run the setup script to enable group-based access:
1153
+ sudo ./scripts/setup-wireguard-access.sh
1154
+
1155
+ This configures:
1156
+ - wireguard group membership
1157
+ - Unprivileged ping support
1158
+ - WireGuard socket permissions
1159
+
1160
+ [bold]Notes:[/bold]
1161
+ - Networks are automatically created when swarms are deployed via the manager
1162
+ - Manual network creation is useful for testing or standalone deployments
1163
+ - Network IDs follow the format: wg-<swarm_id> for managed networks
1164
+ - Write operations require root/sudo privileges
1165
+ - Use --force flag to skip confirmation prompts in scripts
1166
+
1167
+ For more help on a specific subcommand:
1168
+ manta_node network <subcommand> --help
1169
+ """
1170
+ console.print(help_text)
1171
+
1172
+
1173
+ if __name__ == "__main__":
1174
+ sys.exit(network_command(sys.argv[1:]))