raijin-server 0.2.41__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of raijin-server might be problematic. Click here for more details.

@@ -0,0 +1,438 @@
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
107
+ current_peer = {}
108
+ for line in content.split("\n"):
109
+ line = line.strip()
110
+
111
+ if line.startswith("# Cliente:"):
112
+ if current_peer:
113
+ clients.append(current_peer)
114
+ current_peer = {"name": line.split(":", 1)[1].strip()}
115
+ elif line.startswith("PublicKey =") and current_peer:
116
+ current_peer["public_key"] = line.split("=")[1].strip()
117
+ elif line.startswith("AllowedIPs =") and current_peer:
118
+ current_peer["ip"] = line.split("=")[1].strip()
119
+
120
+ if current_peer:
121
+ clients.append(current_peer)
122
+
123
+ return clients
124
+
125
+
126
+ def _get_next_client_ip(server_config: dict) -> str:
127
+ """Calcula próximo IP disponível para cliente."""
128
+ network = server_config["network"]
129
+ base_ip = network.split("/")[0]
130
+ base_parts = base_ip.rsplit(".", 1)
131
+ base_network = base_parts[0]
132
+
133
+ existing_clients = _list_existing_clients()
134
+ used_ips = {client["ip"].split("/")[0] for client in existing_clients}
135
+ used_ips.add(base_ip) # IP do servidor
136
+
137
+ # Procura próximo IP disponível
138
+ for i in range(2, 255):
139
+ candidate = f"{base_network}.{i}"
140
+ if candidate not in used_ips:
141
+ return f"{candidate}/32"
142
+
143
+ typer.secho("Erro: Rede VPN cheia (254 clientes)", fg=typer.colors.RED)
144
+ raise typer.Exit(1)
145
+
146
+
147
+ def _add_peer_to_server(name: str, public_key: str, client_ip: str, ctx: ExecutionContext) -> None:
148
+ """Adiciona peer ao arquivo de configuração do servidor."""
149
+ content = WG0_CONF.read_text()
150
+
151
+ peer_block = f"""
152
+ # Cliente: {name}
153
+ [Peer]
154
+ PublicKey = {public_key}
155
+ AllowedIPs = {client_ip}
156
+ """
157
+
158
+ # Adiciona peer ao final
159
+ content += "\n" + peer_block
160
+
161
+ write_file(WG0_CONF, content, ctx)
162
+
163
+ # Recarrega configuração
164
+ typer.echo("Recarregando WireGuard...")
165
+ run_cmd(["systemctl", "restart", "wg-quick@wg0"], ctx, check=False)
166
+
167
+
168
+ def _remove_peer_from_server(public_key: str, ctx: ExecutionContext) -> None:
169
+ """Remove peer do arquivo de configuração do servidor."""
170
+ content = WG0_CONF.read_text()
171
+
172
+ # Remove bloco do peer
173
+ lines = content.split("\n")
174
+ new_lines = []
175
+ skip_until_next_section = False
176
+
177
+ for line in lines:
178
+ if f"PublicKey = {public_key}" in line:
179
+ skip_until_next_section = True
180
+ # Remove também o comentário anterior
181
+ if new_lines and new_lines[-1].startswith("# Cliente:"):
182
+ new_lines.pop()
183
+ if new_lines and new_lines[-1].strip() == "":
184
+ new_lines.pop()
185
+ if new_lines and new_lines[-1].strip() == "[Peer]":
186
+ new_lines.pop()
187
+ continue
188
+
189
+ if skip_until_next_section:
190
+ if line.startswith("[") or line.startswith("# Cliente:"):
191
+ skip_until_next_section = False
192
+ else:
193
+ continue
194
+
195
+ new_lines.append(line)
196
+
197
+ write_file(WG0_CONF, "\n".join(new_lines), ctx)
198
+
199
+ # Recarrega configuração
200
+ typer.echo("Recarregando WireGuard...")
201
+ run_cmd(["systemctl", "restart", "wg-quick@wg0"], ctx, check=False)
202
+
203
+
204
+ def _create_client_config(
205
+ name: str,
206
+ private_key: str,
207
+ client_ip: str,
208
+ server_config: dict,
209
+ ) -> str:
210
+ """Cria arquivo de configuração do cliente."""
211
+ server_ip = server_config["address"]
212
+ server_network = server_config["network"]
213
+ dns = server_config["dns"]
214
+ endpoint = server_config["endpoint"]
215
+ server_public_key = server_config["public_key"]
216
+ server_port = server_config["port"]
217
+
218
+ if not endpoint:
219
+ endpoint = typer.prompt("Endpoint público do servidor (IP ou domínio)")
220
+
221
+ config = f"""[Interface]
222
+ # Cliente: {name}
223
+ PrivateKey = {private_key}
224
+ Address = {client_ip}
225
+ DNS = {dns}
226
+
227
+ [Peer]
228
+ PublicKey = {server_public_key}
229
+ Endpoint = {endpoint}:{server_port}
230
+ AllowedIPs = {server_network}, 10.0.0.0/8
231
+ PersistentKeepalive = 25
232
+ """
233
+
234
+ return config
235
+
236
+
237
+ def add_client(ctx: ExecutionContext) -> None:
238
+ """Adiciona novo cliente VPN."""
239
+ require_root(ctx)
240
+
241
+ typer.echo("Adicionando novo cliente WireGuard VPN...\n")
242
+
243
+ # Lê configuração do servidor
244
+ server_config = _read_server_config()
245
+
246
+ # Lista clientes existentes
247
+ existing_clients = _list_existing_clients()
248
+ if existing_clients:
249
+ typer.echo("Clientes existentes:")
250
+ for client in existing_clients:
251
+ typer.echo(f" - {client['name']} ({client['ip']})")
252
+ typer.echo("")
253
+
254
+ # Solicita informações do novo cliente
255
+ name = typer.prompt("Nome do cliente")
256
+
257
+ # Verifica se já existe
258
+ if any(c["name"] == name for c in existing_clients):
259
+ typer.secho(f"Cliente '{name}' já existe!", fg=typer.colors.RED)
260
+ raise typer.Exit(1)
261
+
262
+ # Gera chaves
263
+ typer.echo("Gerando par de chaves...")
264
+ private_key, public_key = _generate_keypair()
265
+
266
+ # Calcula próximo IP
267
+ client_ip = _get_next_client_ip(server_config)
268
+
269
+ typer.echo(f"IP atribuído: {client_ip}")
270
+
271
+ # Cria diretório de clientes
272
+ CLIENTS_DIR.mkdir(parents=True, exist_ok=True)
273
+
274
+ # Cria configuração do cliente
275
+ client_config = _create_client_config(name, private_key, client_ip, server_config)
276
+
277
+ client_file = CLIENTS_DIR / f"{name}.conf"
278
+ write_file(client_file, client_config, ctx)
279
+ client_file.chmod(0o600)
280
+
281
+ # Salva chaves separadamente
282
+ key_file = CLIENTS_DIR / f"{name}.key"
283
+ write_file(key_file, f"Private: {private_key}\nPublic: {public_key}\n", ctx)
284
+ key_file.chmod(0o600)
285
+
286
+ # Adiciona peer ao servidor
287
+ _add_peer_to_server(name, public_key, client_ip, ctx)
288
+
289
+ typer.secho(f"\n✓ Cliente '{name}' adicionado com sucesso!", fg=typer.colors.GREEN, bold=True)
290
+ typer.echo(f"\nArquivo de configuração: {client_file}")
291
+ typer.echo("\nPara usar:")
292
+ typer.echo(f" 1. Copie o arquivo: scp root@servidor:{client_file} .")
293
+ typer.echo(f" 2. Importe no WireGuard (Windows/Mac/Linux)")
294
+ typer.echo(f" 3. Ou gere QR code: qrencode -t ansiutf8 {client_file}")
295
+
296
+
297
+ def list_clients(ctx: ExecutionContext) -> None:
298
+ """Lista todos os clientes VPN."""
299
+ require_root(ctx)
300
+
301
+ clients = _list_existing_clients()
302
+
303
+ if not clients:
304
+ typer.echo("Nenhum cliente configurado.")
305
+ return
306
+
307
+ typer.secho(f"\n{'Nome':<20} {'IP':<20} {'Chave Pública'}", fg=typer.colors.CYAN, bold=True)
308
+ typer.echo("=" * 80)
309
+
310
+ for client in clients:
311
+ name = client.get("name", "Desconhecido")
312
+ ip = client.get("ip", "N/A")
313
+ pubkey = client.get("public_key", "N/A")[:40] + "..."
314
+ typer.echo(f"{name:<20} {ip:<20} {pubkey}")
315
+
316
+ typer.echo(f"\nTotal: {len(clients)} cliente(s)")
317
+
318
+
319
+ def remove_client(ctx: ExecutionContext) -> None:
320
+ """Remove cliente VPN."""
321
+ require_root(ctx)
322
+
323
+ clients = _list_existing_clients()
324
+
325
+ if not clients:
326
+ typer.echo("Nenhum cliente configurado.")
327
+ return
328
+
329
+ # Mostra lista
330
+ typer.echo("Clientes disponíveis:")
331
+ for i, client in enumerate(clients, 1):
332
+ typer.echo(f" {i}. {client['name']} ({client['ip']})")
333
+
334
+ # Solicita nome
335
+ name = typer.prompt("\nNome do cliente para remover")
336
+
337
+ # Encontra cliente
338
+ client = next((c for c in clients if c["name"] == name), None)
339
+ if not client:
340
+ typer.secho(f"Cliente '{name}' não encontrado!", fg=typer.colors.RED)
341
+ raise typer.Exit(1)
342
+
343
+ # Confirma remoção
344
+ confirm = typer.confirm(
345
+ f"Remover cliente '{name}' ({client['ip']})?",
346
+ default=False
347
+ )
348
+
349
+ if not confirm:
350
+ typer.echo("Operação cancelada.")
351
+ return
352
+
353
+ # Remove do servidor
354
+ _remove_peer_from_server(client["public_key"], ctx)
355
+
356
+ # Remove arquivos locais
357
+ client_file = CLIENTS_DIR / f"{name}.conf"
358
+ key_file = CLIENTS_DIR / f"{name}.key"
359
+
360
+ if client_file.exists():
361
+ client_file.unlink()
362
+ if key_file.exists():
363
+ key_file.unlink()
364
+
365
+ typer.secho(f"\n✓ Cliente '{name}' removido com sucesso!", fg=typer.colors.GREEN, bold=True)
366
+
367
+
368
+ def show_client_config(ctx: ExecutionContext) -> None:
369
+ """Mostra configuração de um cliente."""
370
+ require_root(ctx)
371
+
372
+ clients = _list_existing_clients()
373
+
374
+ if not clients:
375
+ typer.echo("Nenhum cliente configurado.")
376
+ return
377
+
378
+ # Lista clientes
379
+ typer.echo("Clientes disponíveis:")
380
+ for client in clients:
381
+ typer.echo(f" - {client['name']}")
382
+
383
+ # Solicita nome
384
+ name = typer.prompt("\nNome do cliente")
385
+
386
+ client_file = CLIENTS_DIR / f"{name}.conf"
387
+
388
+ if not client_file.exists():
389
+ typer.secho(f"Arquivo de configuração não encontrado: {client_file}", fg=typer.colors.RED)
390
+ raise typer.Exit(1)
391
+
392
+ typer.echo(f"\n{'='*60}")
393
+ typer.echo(f"Configuração do cliente: {name}")
394
+ typer.echo(f"Arquivo: {client_file}")
395
+ typer.echo(f"{'='*60}\n")
396
+
397
+ typer.echo(client_file.read_text())
398
+
399
+ typer.echo(f"\n{'='*60}")
400
+ typer.echo("\nPara copiar:")
401
+ typer.echo(f" scp root@servidor:{client_file} .")
402
+ typer.echo("\nPara QR code (mobile):")
403
+ typer.echo(f" qrencode -t ansiutf8 {client_file}")
404
+
405
+
406
+ def run(ctx: ExecutionContext) -> None:
407
+ """Menu interativo para gerenciar clientes VPN."""
408
+ require_root(ctx)
409
+
410
+ while True:
411
+ typer.echo("\n" + "="*60)
412
+ typer.secho("Gerenciamento de Clientes VPN", fg=typer.colors.CYAN, bold=True)
413
+ typer.echo("="*60)
414
+ typer.echo("\n1. Adicionar cliente")
415
+ typer.echo("2. Listar clientes")
416
+ typer.echo("3. Remover cliente")
417
+ typer.echo("4. Mostrar configuração de cliente")
418
+ typer.echo("5. Sair")
419
+
420
+ choice = typer.prompt("\nEscolha uma opção", default="5")
421
+
422
+ try:
423
+ if choice == "1":
424
+ add_client(ctx)
425
+ elif choice == "2":
426
+ list_clients(ctx)
427
+ elif choice == "3":
428
+ remove_client(ctx)
429
+ elif choice == "4":
430
+ show_client_config(ctx)
431
+ elif choice == "5":
432
+ typer.echo("Saindo...")
433
+ break
434
+ else:
435
+ typer.secho("Opção inválida!", fg=typer.colors.RED)
436
+ except (KeyboardInterrupt, typer.Exit):
437
+ typer.echo("\n")
438
+ continue