raijin-server 0.2.41__py3-none-any.whl → 0.3.1__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,526 @@
1
+ """Gerenciamento de clientes WireGuard VPN."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import List, Tuple
9
+
10
+ import typer
11
+
12
+ from raijin_server.utils import ExecutionContext, logger, require_root, run_cmd, write_file
13
+
14
+ WIREGUARD_DIR = Path("/etc/wireguard")
15
+ WG0_CONF = WIREGUARD_DIR / "wg0.conf"
16
+ CLIENTS_DIR = WIREGUARD_DIR / "clients"
17
+
18
+
19
+ def _generate_keypair() -> Tuple[str, str]:
20
+ """Gera par de chaves WireGuard."""
21
+ try:
22
+ result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True)
23
+ private_key = result.stdout.strip()
24
+
25
+ pub_result = subprocess.run(
26
+ ["wg", "pubkey"],
27
+ input=private_key,
28
+ capture_output=True,
29
+ text=True,
30
+ check=True,
31
+ )
32
+ public_key = pub_result.stdout.strip()
33
+
34
+ return private_key, public_key
35
+ except subprocess.CalledProcessError as exc:
36
+ typer.secho(f"Erro ao gerar chaves: {exc}", fg=typer.colors.RED)
37
+ raise typer.Exit(1)
38
+
39
+
40
+ def _read_server_config() -> dict:
41
+ """Lê configuração do servidor."""
42
+ if not WG0_CONF.exists():
43
+ typer.secho(
44
+ f"Arquivo {WG0_CONF} não encontrado. Execute 'raijin vpn' primeiro.",
45
+ fg=typer.colors.RED
46
+ )
47
+ raise typer.Exit(1)
48
+
49
+ content = WG0_CONF.read_text()
50
+
51
+ # Extrai informações do servidor
52
+ server_public_key = ""
53
+ server_port = ""
54
+ server_address = ""
55
+ endpoint = ""
56
+ dns = ""
57
+
58
+ for line in content.split("\n"):
59
+ if "ListenPort" in line:
60
+ server_port = line.split("=")[1].strip()
61
+ elif "Address" in line and not server_address:
62
+ server_address = line.split("=")[1].strip()
63
+ elif "PrivateKey" in line:
64
+ # Gera chave pública do servidor a partir da privada
65
+ private_key = line.split("=")[1].strip()
66
+ try:
67
+ result = subprocess.run(
68
+ ["wg", "pubkey"],
69
+ input=private_key,
70
+ capture_output=True,
71
+ text=True,
72
+ check=True,
73
+ )
74
+ server_public_key = result.stdout.strip()
75
+ except:
76
+ pass
77
+
78
+ # Tenta encontrar endpoint nos peers existentes
79
+ peer_match = re.search(r"# Endpoint: (.+)", content)
80
+ if peer_match:
81
+ endpoint = peer_match.group(1)
82
+
83
+ # Tenta encontrar DNS
84
+ dns_match = re.search(r"# DNS: (.+)", content)
85
+ if dns_match:
86
+ dns = dns_match.group(1)
87
+
88
+ return {
89
+ "public_key": server_public_key,
90
+ "port": server_port,
91
+ "address": server_address.split("/")[0] if server_address else "",
92
+ "network": server_address,
93
+ "endpoint": endpoint,
94
+ "dns": dns or "1.1.1.1,8.8.8.8",
95
+ }
96
+
97
+
98
+ def _list_existing_clients() -> List[dict]:
99
+ """Lista clientes existentes."""
100
+ if not WG0_CONF.exists():
101
+ return []
102
+
103
+ content = WG0_CONF.read_text()
104
+ clients = []
105
+
106
+ # Parse peers - suporta múltiplos formatos de comentário
107
+ lines = content.split("\n")
108
+ i = 0
109
+
110
+ while i < len(lines):
111
+ line = lines[i].strip()
112
+
113
+ # Detecta início de um bloco [Peer]
114
+ if line == "[Peer]":
115
+ peer_name = None
116
+ public_key = None
117
+ allowed_ips = None
118
+
119
+ # Verifica linha anterior para comentário com nome
120
+ if i > 0:
121
+ prev_line = lines[i - 1].strip()
122
+ # Formato: "# cliente_nome" ou "# Cliente: nome"
123
+ if prev_line.startswith("#"):
124
+ comment = prev_line[1:].strip()
125
+ if comment.lower().startswith("cliente:"):
126
+ peer_name = comment.split(":", 1)[1].strip()
127
+ else:
128
+ # Nome direto após # (formato do módulo vpn)
129
+ peer_name = comment
130
+
131
+ # Lê configurações do peer
132
+ i += 1
133
+ while i < len(lines):
134
+ peer_line = lines[i].strip()
135
+ if peer_line.startswith("[") or (peer_line.startswith("#") and i + 1 < len(lines) and lines[i + 1].strip() == "[Peer]"):
136
+ break
137
+
138
+ if peer_line.startswith("PublicKey"):
139
+ public_key = peer_line.split("=", 1)[1].strip()
140
+ elif peer_line.startswith("AllowedIPs"):
141
+ allowed_ips = peer_line.split("=", 1)[1].strip()
142
+ # Comentário inline com nome do cliente
143
+ elif peer_line.startswith("#") and not peer_name:
144
+ peer_name = peer_line[1:].strip()
145
+
146
+ i += 1
147
+
148
+ # Adiciona peer se tiver pelo menos chave pública
149
+ if public_key:
150
+ clients.append({
151
+ "name": peer_name or f"cliente_{len(clients) + 1}",
152
+ "public_key": public_key,
153
+ "ip": allowed_ips or "N/A",
154
+ })
155
+ continue
156
+
157
+ i += 1
158
+
159
+ return clients
160
+
161
+
162
+ def _get_next_client_ip(server_config: dict) -> str:
163
+ """Calcula próximo IP disponível para cliente."""
164
+ network = server_config["network"]
165
+ base_ip = network.split("/")[0]
166
+ base_parts = base_ip.rsplit(".", 1)
167
+ base_network = base_parts[0]
168
+
169
+ existing_clients = _list_existing_clients()
170
+ used_ips = {client["ip"].split("/")[0] for client in existing_clients}
171
+ used_ips.add(base_ip) # IP do servidor
172
+
173
+ # Procura próximo IP disponível
174
+ for i in range(2, 255):
175
+ candidate = f"{base_network}.{i}"
176
+ if candidate not in used_ips:
177
+ return f"{candidate}/32"
178
+
179
+ typer.secho("Erro: Rede VPN cheia (254 clientes)", fg=typer.colors.RED)
180
+ raise typer.Exit(1)
181
+
182
+
183
+ def _add_peer_to_server(name: str, public_key: str, client_ip: str, ctx: ExecutionContext) -> None:
184
+ """Adiciona peer ao arquivo de configuração do servidor."""
185
+ content = WG0_CONF.read_text()
186
+
187
+ # Remove linhas em branco extras no final
188
+ content = content.rstrip() + "\n"
189
+
190
+ # Formato compatível com o módulo vpn original
191
+ peer_block = f"""
192
+ # {name}
193
+ [Peer]
194
+ PublicKey = {public_key}
195
+ AllowedIPs = {client_ip}
196
+ """
197
+
198
+ # Adiciona peer ao final
199
+ content += peer_block
200
+
201
+ write_file(WG0_CONF, content, ctx)
202
+
203
+ # Recarrega configuração via wg syncconf (mais rápido) ou restart
204
+ typer.echo("Recarregando WireGuard...")
205
+ # Tenta wg syncconf primeiro (não derruba conexões existentes)
206
+ result = run_cmd(
207
+ ["bash", "-c", f"wg syncconf wg0 <(wg-quick strip wg0)"],
208
+ ctx,
209
+ check=False
210
+ )
211
+ if result.returncode != 0:
212
+ # Fallback para restart
213
+ run_cmd(["systemctl", "restart", "wg-quick@wg0"], ctx, check=False)
214
+
215
+
216
+ def _remove_peer_from_server(public_key: str, ctx: ExecutionContext) -> None:
217
+ """Remove peer do arquivo de configuração do servidor."""
218
+ content = WG0_CONF.read_text()
219
+
220
+ # Remove bloco do peer - suporta múltiplos formatos
221
+ lines = content.split("\n")
222
+ new_lines = []
223
+ skip_until_next_section = False
224
+
225
+ for i, line in enumerate(lines):
226
+ if f"PublicKey = {public_key}" in line:
227
+ skip_until_next_section = True
228
+ # Remove também o comentário anterior e [Peer]
229
+ while new_lines and (
230
+ new_lines[-1].startswith("#") or
231
+ new_lines[-1].strip() == "" or
232
+ new_lines[-1].strip() == "[Peer]"
233
+ ):
234
+ new_lines.pop()
235
+ continue
236
+
237
+ if skip_until_next_section:
238
+ # Próximo peer ou seção
239
+ stripped = line.strip()
240
+ if stripped.startswith("[") or (stripped.startswith("#") and i + 1 < len(lines) and lines[i + 1].strip() == "[Peer]"):
241
+ skip_until_next_section = False
242
+ else:
243
+ continue
244
+
245
+ new_lines.append(line)
246
+
247
+ # Remove linhas em branco extras no final
248
+ while new_lines and new_lines[-1].strip() == "":
249
+ new_lines.pop()
250
+
251
+ write_file(WG0_CONF, "\n".join(new_lines) + "\n", ctx)
252
+
253
+ # Recarrega configuração
254
+ typer.echo("Recarregando WireGuard...")
255
+ result = run_cmd(
256
+ ["bash", "-c", f"wg syncconf wg0 <(wg-quick strip wg0)"],
257
+ ctx,
258
+ check=False
259
+ )
260
+ if result.returncode != 0:
261
+ run_cmd(["systemctl", "restart", "wg-quick@wg0"], ctx, check=False)
262
+
263
+
264
+ def _create_client_config(
265
+ name: str,
266
+ private_key: str,
267
+ client_ip: str,
268
+ server_config: dict,
269
+ ) -> str:
270
+ """Cria arquivo de configuração do cliente."""
271
+ server_ip = server_config["address"]
272
+ server_network = server_config["network"]
273
+ dns = server_config["dns"]
274
+ endpoint = server_config["endpoint"]
275
+ server_public_key = server_config["public_key"]
276
+ server_port = server_config["port"]
277
+
278
+ if not endpoint:
279
+ endpoint = typer.prompt("Endpoint público do servidor (IP ou domínio)")
280
+
281
+ config = f"""[Interface]
282
+ # Cliente: {name}
283
+ PrivateKey = {private_key}
284
+ Address = {client_ip}
285
+ DNS = {dns}
286
+
287
+ [Peer]
288
+ PublicKey = {server_public_key}
289
+ Endpoint = {endpoint}:{server_port}
290
+ AllowedIPs = {server_network}, 10.0.0.0/8
291
+ PersistentKeepalive = 25
292
+ """
293
+
294
+ return config
295
+
296
+
297
+ def add_client(ctx: ExecutionContext) -> None:
298
+ """Adiciona novo cliente VPN."""
299
+ require_root(ctx)
300
+
301
+ typer.echo("Adicionando novo cliente WireGuard VPN...\n")
302
+
303
+ # Lê configuração do servidor
304
+ server_config = _read_server_config()
305
+
306
+ # Lista clientes existentes
307
+ existing_clients = _list_existing_clients()
308
+ if existing_clients:
309
+ typer.echo("Clientes existentes:")
310
+ for client in existing_clients:
311
+ typer.echo(f" - {client['name']} ({client['ip']})")
312
+ typer.echo("")
313
+
314
+ # Solicita informações do novo cliente
315
+ name = typer.prompt("Nome do cliente")
316
+
317
+ # Verifica se já existe
318
+ if any(c["name"] == name for c in existing_clients):
319
+ typer.secho(f"Cliente '{name}' já existe!", fg=typer.colors.RED)
320
+ raise typer.Exit(1)
321
+
322
+ # Gera chaves
323
+ typer.echo("Gerando par de chaves...")
324
+ private_key, public_key = _generate_keypair()
325
+
326
+ # Calcula próximo IP
327
+ client_ip = _get_next_client_ip(server_config)
328
+
329
+ typer.echo(f"IP atribuído: {client_ip}")
330
+
331
+ # Cria diretório de clientes
332
+ CLIENTS_DIR.mkdir(parents=True, exist_ok=True)
333
+
334
+ # Cria configuração do cliente
335
+ client_config = _create_client_config(name, private_key, client_ip, server_config)
336
+
337
+ client_file = CLIENTS_DIR / f"{name}.conf"
338
+ write_file(client_file, client_config, ctx)
339
+ client_file.chmod(0o600)
340
+
341
+ # Salva chaves separadamente
342
+ key_file = CLIENTS_DIR / f"{name}.key"
343
+ write_file(key_file, f"Private: {private_key}\nPublic: {public_key}\n", ctx)
344
+ key_file.chmod(0o600)
345
+
346
+ # Adiciona peer ao servidor
347
+ _add_peer_to_server(name, public_key, client_ip, ctx)
348
+
349
+ typer.secho(f"\n✓ Cliente '{name}' adicionado com sucesso!", fg=typer.colors.GREEN, bold=True)
350
+ typer.echo(f"\nArquivo de configuração: {client_file}")
351
+ typer.echo("\nPara usar:")
352
+ typer.echo(f" 1. Copie o arquivo: scp root@servidor:{client_file} .")
353
+ typer.echo(f" 2. Importe no WireGuard (Windows/Mac/Linux)")
354
+ typer.echo(f" 3. Ou gere QR code: qrencode -t ansiutf8 {client_file}")
355
+
356
+
357
+ def list_clients(ctx: ExecutionContext) -> None:
358
+ """Lista todos os clientes VPN."""
359
+ require_root(ctx)
360
+
361
+ clients = _list_existing_clients()
362
+
363
+ if not clients:
364
+ typer.echo("Nenhum cliente configurado.")
365
+ return
366
+
367
+ typer.secho(f"\n{'Nome':<20} {'IP':<20} {'Chave Pública'}", fg=typer.colors.CYAN, bold=True)
368
+ typer.echo("=" * 80)
369
+
370
+ for client in clients:
371
+ name = client.get("name", "Desconhecido")
372
+ ip = client.get("ip", "N/A")
373
+ pubkey = client.get("public_key", "N/A")[:40] + "..."
374
+ typer.echo(f"{name:<20} {ip:<20} {pubkey}")
375
+
376
+ typer.echo(f"\nTotal: {len(clients)} cliente(s)")
377
+
378
+
379
+ def remove_client(ctx: ExecutionContext) -> None:
380
+ """Remove cliente VPN."""
381
+ require_root(ctx)
382
+
383
+ clients = _list_existing_clients()
384
+
385
+ if not clients:
386
+ typer.echo("Nenhum cliente configurado.")
387
+ return
388
+
389
+ # Mostra lista
390
+ typer.echo("Clientes disponíveis:")
391
+ for i, client in enumerate(clients, 1):
392
+ typer.echo(f" {i}. {client['name']} ({client['ip']})")
393
+
394
+ # Solicita nome
395
+ name = typer.prompt("\nNome do cliente para remover")
396
+
397
+ # Encontra cliente
398
+ client = next((c for c in clients if c["name"] == name), None)
399
+ if not client:
400
+ typer.secho(f"Cliente '{name}' não encontrado!", fg=typer.colors.RED)
401
+ raise typer.Exit(1)
402
+
403
+ # Confirma remoção
404
+ confirm = typer.confirm(
405
+ f"Remover cliente '{name}' ({client['ip']})?",
406
+ default=False
407
+ )
408
+
409
+ if not confirm:
410
+ typer.echo("Operação cancelada.")
411
+ return
412
+
413
+ # Remove do servidor
414
+ _remove_peer_from_server(client["public_key"], ctx)
415
+
416
+ # Remove arquivos locais
417
+ client_file = CLIENTS_DIR / f"{name}.conf"
418
+ key_file = CLIENTS_DIR / f"{name}.key"
419
+
420
+ if client_file.exists():
421
+ client_file.unlink()
422
+ if key_file.exists():
423
+ key_file.unlink()
424
+
425
+ typer.secho(f"\n✓ Cliente '{name}' removido com sucesso!", fg=typer.colors.GREEN, bold=True)
426
+
427
+
428
+ def show_client_config(ctx: ExecutionContext) -> None:
429
+ """Mostra configuração de um cliente."""
430
+ require_root(ctx)
431
+
432
+ clients = _list_existing_clients()
433
+
434
+ if not clients:
435
+ typer.echo("Nenhum cliente configurado.")
436
+ return
437
+
438
+ # Lista clientes
439
+ typer.echo("Clientes disponíveis:")
440
+ for client in clients:
441
+ typer.echo(f" - {client['name']}")
442
+
443
+ # Solicita nome
444
+ name = typer.prompt("\nNome do cliente")
445
+
446
+ client_file = CLIENTS_DIR / f"{name}.conf"
447
+
448
+ if not client_file.exists():
449
+ typer.secho(f"Arquivo de configuração não encontrado: {client_file}", fg=typer.colors.RED)
450
+ typer.echo("\nDica: O cliente pode ter sido criado pelo módulo 'vpn' (inicial).")
451
+ typer.echo(f"Verifique se existe: {CLIENTS_DIR}")
452
+ raise typer.Exit(1)
453
+
454
+ typer.echo(f"\n{'='*60}")
455
+ typer.echo(f"Configuração do cliente: {name}")
456
+ typer.echo(f"Arquivo: {client_file}")
457
+ typer.echo(f"{'='*60}\n")
458
+
459
+ typer.echo(client_file.read_text())
460
+
461
+ # Detecta hostname/IP do servidor
462
+ import socket
463
+ hostname = socket.gethostname()
464
+
465
+ typer.echo(f"\n{'='*60}")
466
+ typer.secho("COMO COPIAR PARA WINDOWS/MAC/LINUX:", fg=typer.colors.CYAN, bold=True)
467
+ typer.echo(f"{'='*60}")
468
+
469
+ typer.echo("\n📋 Opção 1 - SCP (requer SSH configurado):")
470
+ typer.echo(f" scp usuario@{hostname}:{client_file} .")
471
+ typer.echo(f" # ou com IP: scp usuario@SEU_IP:{client_file} .")
472
+
473
+ typer.echo("\n📋 Opção 2 - Copiar conteúdo manualmente:")
474
+ typer.echo(" 1. Copie o texto acima (entre as linhas de '=')")
475
+ typer.echo(f" 2. No Windows, crie arquivo: {name}.conf")
476
+ typer.echo(" 3. Cole o conteúdo e salve")
477
+
478
+ typer.echo("\n📋 Opção 3 - SFTP (FileZilla, WinSCP):")
479
+ typer.echo(f" Conecte no servidor e baixe: {client_file}")
480
+
481
+ typer.echo("\n📱 Para celular (QR Code):")
482
+ typer.echo(f" qrencode -t ansiutf8 < {client_file}")
483
+
484
+ typer.echo(f"\n{'='*60}")
485
+ typer.secho("NO WINDOWS 11:", fg=typer.colors.GREEN, bold=True)
486
+ typer.echo(f"{'='*60}")
487
+ typer.echo(" 1. Baixe WireGuard: https://www.wireguard.com/install/")
488
+ typer.echo(" 2. Abra WireGuard → 'Import tunnel(s) from file...'")
489
+ typer.echo(f" 3. Selecione o arquivo {name}.conf")
490
+ typer.echo(" 4. Clique 'Activate' para conectar")
491
+ typer.echo("")
492
+
493
+
494
+ def run(ctx: ExecutionContext) -> None:
495
+ """Menu interativo para gerenciar clientes VPN."""
496
+ require_root(ctx)
497
+
498
+ while True:
499
+ typer.echo("\n" + "="*60)
500
+ typer.secho("Gerenciamento de Clientes VPN", fg=typer.colors.CYAN, bold=True)
501
+ typer.echo("="*60)
502
+ typer.echo("\n1. Adicionar cliente")
503
+ typer.echo("2. Listar clientes")
504
+ typer.echo("3. Remover cliente")
505
+ typer.echo("4. Mostrar configuração de cliente")
506
+ typer.echo("5. Sair")
507
+
508
+ choice = typer.prompt("\nEscolha uma opção", default="5")
509
+
510
+ try:
511
+ if choice == "1":
512
+ add_client(ctx)
513
+ elif choice == "2":
514
+ list_clients(ctx)
515
+ elif choice == "3":
516
+ remove_client(ctx)
517
+ elif choice == "4":
518
+ show_client_config(ctx)
519
+ elif choice == "5":
520
+ typer.echo("Saindo...")
521
+ break
522
+ else:
523
+ typer.secho("Opção inválida!", fg=typer.colors.RED)
524
+ except (KeyboardInterrupt, typer.Exit):
525
+ typer.echo("\n")
526
+ continue