pia-proxy 1.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.
cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ PIA SOCKS5 Multi-Worker Proxy Pool - CLI Management Suite
3
+ """
4
+
5
+ __version__ = "1.0.0"
cli/api_client.py ADDED
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from typing import Any
8
+
9
+
10
+ class APIClientError(Exception):
11
+ def __init__(self, message: str, status_code: int | None = None, response_body: Any = None):
12
+ super().__init__(message)
13
+ self.status_code = status_code
14
+ self.response_body = response_body
15
+
16
+
17
+ class APIClient:
18
+ def __init__(self, base_url: str, token: str = "", timeout: float = 10.0):
19
+ self.base_url = base_url.rstrip("/")
20
+ self.token = token.strip()
21
+ self.timeout = timeout
22
+
23
+ def _request(
24
+ self,
25
+ method: str,
26
+ path: str,
27
+ params: dict[str, Any] | None = None,
28
+ json_data: dict[str, Any] | None = None,
29
+ timeout: float | None = None,
30
+ ) -> dict[str, Any]:
31
+ url = f"{self.base_url}{path}"
32
+ if params:
33
+ query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
34
+ if query:
35
+ url = f"{url}?{query}"
36
+
37
+ headers = {
38
+ "User-Agent": "pia-proxy-cli/1.0",
39
+ "Accept": "application/json",
40
+ }
41
+ if self.token:
42
+ headers["Authorization"] = f"Bearer {self.token}"
43
+
44
+ data_bytes = None
45
+ if json_data is not None:
46
+ data_bytes = json.dumps(json_data).encode("utf-8")
47
+ headers["Content-Type"] = "application/json"
48
+
49
+ req = urllib.request.Request(url, data=data_bytes, headers=headers, method=method.upper())
50
+
51
+ try:
52
+ with urllib.request.urlopen(req, timeout=timeout or self.timeout) as resp:
53
+ raw = resp.read().decode("utf-8")
54
+ if not raw.strip():
55
+ return {}
56
+ return json.loads(raw)
57
+ except urllib.error.HTTPError as e:
58
+ raw_err = e.read().decode("utf-8", errors="ignore")
59
+ try:
60
+ body = json.loads(raw_err)
61
+ except Exception:
62
+ body = raw_err
63
+ raise APIClientError(
64
+ message=f"HTTP {e.code}: {e.reason}",
65
+ status_code=e.code,
66
+ response_body=body,
67
+ ) from e
68
+ except urllib.error.URLError as e:
69
+ raise APIClientError(message=f"Connection failed: {e.reason}") from e
70
+ except Exception as e:
71
+ raise APIClientError(message=f"Request error: {str(e)}") from e
72
+
73
+ def is_alive(self) -> bool:
74
+ try:
75
+ res = self._request("GET", "/healthz", timeout=3.0)
76
+ return bool(res.get("ok"))
77
+ except Exception:
78
+ return False
79
+
80
+ def get_status(self, refresh: bool = False) -> dict[str, Any]:
81
+ return self._request("GET", "/api/status", params={"refresh": refresh}, timeout=12.0)
82
+
83
+ def get_nodes(self, refresh: bool = False) -> list[dict[str, Any]]:
84
+ res = self._request("GET", "/api/nodes", params={"refresh": refresh}, timeout=10.0)
85
+ return res.get("nodes", [])
86
+
87
+ def get_healthy_nodes(self) -> dict[str, Any]:
88
+ return self._request("GET", "/api/nodes/healthy", timeout=6.0)
89
+
90
+ def get_node(self, node_id: str) -> dict[str, Any]:
91
+ return self._request("GET", f"/api/nodes/{node_id}", timeout=6.0)
92
+
93
+ def set_mode(self, worker_count: int) -> dict[str, Any]:
94
+ return self._request("POST", "/api/mode", json_data={"worker_count": worker_count}, timeout=30.0)
95
+
96
+ def get_mode(self) -> dict[str, Any]:
97
+ return self._request("GET", "/api/mode", timeout=5.0)
98
+
99
+ def rotate_any(self, country: str | None = None, server: str | None = None, wait_for_ready: bool = False) -> dict[str, Any]:
100
+ payload: dict[str, Any] = {"wait_for_ready": wait_for_ready}
101
+ if country:
102
+ payload["country"] = country
103
+ if server:
104
+ payload["server"] = server
105
+ return self._request("POST", "/api/rotate-any", json_data=payload, timeout=20.0)
106
+
107
+ def rotate_node(self, node_id: str, country: str | None = None, server: str | None = None, wait_for_ready: bool = False) -> dict[str, Any]:
108
+ payload: dict[str, Any] = {"wait_for_ready": wait_for_ready}
109
+ if country:
110
+ payload["country"] = country
111
+ if server:
112
+ payload["server"] = server
113
+ return self._request("POST", f"/api/nodes/{node_id}/rotate", json_data=payload, timeout=25.0)
114
+
115
+ def recover_all(self, force: bool = False) -> dict[str, Any]:
116
+ return self._request("POST", "/api/recover", json_data={"force": force}, timeout=15.0)
117
+
118
+ def recover_node(self, node_id: str, force: bool = False) -> dict[str, Any]:
119
+ return self._request("POST", f"/api/nodes/{node_id}/recover", json_data={"force": force}, timeout=15.0)
120
+
121
+ def test_proxy(self) -> dict[str, Any]:
122
+ return self._request("GET", "/api/test-proxy", timeout=15.0)
123
+
124
+ def get_countries(self, refresh: bool = False) -> dict[str, Any]:
125
+ return self._request("GET", "/api/countries", params={"refresh": refresh}, timeout=15.0)
126
+
127
+ def get_logs(self, tail: int = 100) -> dict[str, Any]:
128
+ return self._request("GET", "/api/logs", params={"tail": tail}, timeout=10.0)
@@ -0,0 +1,3 @@
1
+ """
2
+ CLI Subcommands package
3
+ """
cli/commands/config.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from cli.config import CLIConfig
10
+
11
+
12
+ @click.group("config", help="Manage proxy gateway credentials, environment settings, and ports.")
13
+ def config_group() -> None:
14
+ pass
15
+
16
+
17
+ @config_group.command("show", help="Display all active configuration parameters.")
18
+ @click.pass_obj
19
+ def config_show(obj: dict[str, Any]) -> None:
20
+ config: CLIConfig = obj["config"]
21
+ console = Console()
22
+
23
+ table = Table(title="[bold cyan]PIA Proxy Configuration Summary[/bold cyan]", expand=True)
24
+ table.add_column("Parameter", style="bold white", width=24)
25
+ table.add_column("Value", style="yellow")
26
+ table.add_column("Location / Source", style="dim")
27
+
28
+ table.add_row("Project Root", str(config.project_root), "Detected")
29
+ table.add_row("Compose Dir", str(config.compose_dir), "Detected")
30
+ table.add_row(".env File", str(config.env_file), "Detected")
31
+ table.add_row("Credentials Dir", str(config.credentials_dir), "Detected")
32
+ table.add_row("SOCKS5 Proxy Port", str(config.proxy_port), ".env (PROXY_PORT)")
33
+ table.add_row("SOCKS5 Username", config.socks5_user, ".env (SOCKS5_USERNAME)")
34
+ table.add_row("SOCKS5 Password", "***" + config.socks5_pass[-4:] if len(config.socks5_pass) > 4 else "***", ".env (SOCKS5_PASSWORD)")
35
+ table.add_row("Backend API Port", str(config.api_port), ".env (API_PORT)")
36
+ table.add_row("Active Worker Count", str(config.worker_count), ".env (WORKER_COUNT)")
37
+ table.add_row("Max Worker Count", str(config.max_worker_count), ".env (MAX_WORKER_COUNT)")
38
+
39
+ u, p = config.get_pia_account()
40
+ if u and p:
41
+ table.add_row("PIA Account", f"{u} / {'*' * len(p)}", "credentials/pia_account_1")
42
+ else:
43
+ table.add_row("PIA Account", "[bold red]Not Configured![/bold red]", "credentials/pia_account_1")
44
+
45
+ session_file = config.credentials_dir / "pia_session_slot_1.json"
46
+ table.add_row("PIA Session Cache", "Present" if session_file.exists() else "Missing", "credentials/pia_session_slot_1.json")
47
+
48
+ console.print(table)
49
+
50
+
51
+ @config_group.command("set-credentials", help="Configure PIA account username and password.")
52
+ @click.option("--username", "-u", prompt=True, help="PIA Username (e.g. p1234567)")
53
+ @click.option("--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True, help="PIA Password")
54
+ @click.pass_obj
55
+ def config_set_credentials(obj: dict[str, Any], username: str, password: str) -> None:
56
+ config: CLIConfig = obj["config"]
57
+ console = Console()
58
+
59
+ if not username.strip() or not password.strip():
60
+ console.print("[bold red]Username and password cannot be empty.[/bold red]")
61
+ raise SystemExit(1)
62
+
63
+ config.set_pia_account(username.strip(), password.strip())
64
+ console.print(f"[bold green]✔ Saved PIA account credentials to {config.credentials_dir / 'pia_account_1'}.[/bold green]")
65
+
66
+
67
+ @config_group.command("set-proxy-auth", help="Set username and password required for SOCKS5 proxy clients.")
68
+ @click.option("--username", "-u", prompt=True, help="Proxy username")
69
+ @click.option("--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True, help="Proxy password")
70
+ @click.pass_obj
71
+ def config_set_proxy_auth(obj: dict[str, Any], username: str, password: str) -> None:
72
+ config: CLIConfig = obj["config"]
73
+ console = Console()
74
+
75
+ config.update_env_value("SOCKS5_USERNAME", username.strip())
76
+ config.update_env_value("SOCKS5_PASSWORD", password.strip())
77
+ console.print("[bold green]✔ Updated SOCKS5 credentials in .env.[/bold green]")
78
+ console.print("[dim]Note: Restart proxy pool with 'pia-proxy restart' to apply new proxy credentials.[/dim]")
79
+
80
+
81
+ @config_group.command("set-port", help="Set the external port for SOCKS5 gateway or backend API.")
82
+ @click.option("--proxy-port", type=int, default=None, help="New SOCKS5 gateway port (e.g. 1087, 1080)")
83
+ @click.option("--api-port", type=int, default=None, help="New Backend API port (e.g. 8007)")
84
+ @click.pass_obj
85
+ def config_set_port(obj: dict[str, Any], proxy_port: int | None, api_port: int | None) -> None:
86
+ config: CLIConfig = obj["config"]
87
+ console = Console()
88
+
89
+ if proxy_port:
90
+ config.update_env_value("PROXY_PORT", proxy_port)
91
+ console.print(f"[bold green]✔ Set PROXY_PORT={proxy_port} in .env.[/bold green]")
92
+ if api_port:
93
+ config.update_env_value("API_PORT", api_port)
94
+ console.print(f"[bold green]✔ Set API_PORT={api_port} in .env.[/bold green]")
95
+
96
+ console.print("[dim]Note: Restart proxy pool with 'pia-proxy restart' to apply port changes.[/dim]")
cli/commands/doctor.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import socket
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from cli.config import CLIConfig
15
+
16
+
17
+ def _is_port_open(port: int, host: str = "127.0.0.1") -> bool:
18
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
19
+ s.settimeout(0.5)
20
+ return s.connect_ex((host, port)) == 0
21
+
22
+
23
+ @click.command("doctor", help="Inspect system readiness, tun device, ports, swap, and Docker requirements.")
24
+ @click.pass_obj
25
+ def doctor_cmd(obj: dict[str, Any]) -> None:
26
+ config: CLIConfig = obj["config"]
27
+ console = Console()
28
+
29
+ table = Table(title="[bold cyan]System Environment & Health Diagnostics[/bold cyan]", expand=True)
30
+ table.add_column("Component", style="bold white", width=22)
31
+ table.add_column("Status", width=16)
32
+ table.add_column("Details", style="dim")
33
+
34
+ # 1. Docker Engine
35
+ docker_ok = False
36
+ try:
37
+ r = subprocess.run(["docker", "info"], capture_output=True, text=True, timeout=3)
38
+ docker_ok = r.returncode == 0
39
+ except Exception:
40
+ pass
41
+
42
+ if docker_ok:
43
+ table.add_row("Docker Engine", Text("✔ OK", style="bold green"), "Daemon is active and reachable")
44
+ else:
45
+ table.add_row("Docker Engine", Text("✗ ERROR", style="bold red"), "Docker daemon not running or access denied")
46
+
47
+ # 2. Docker Compose
48
+ compose_ok = False
49
+ try:
50
+ r = subprocess.run(["docker", "compose", "version"], capture_output=True, text=True, timeout=3)
51
+ compose_ok = r.returncode == 0
52
+ compose_ver = r.stdout.strip()
53
+ except Exception:
54
+ compose_ver = "Not installed"
55
+
56
+ if compose_ok:
57
+ table.add_row("Docker Compose", Text("✔ OK", style="bold green"), compose_ver)
58
+ else:
59
+ table.add_row("Docker Compose", Text("✗ ERROR", style="bold red"), "docker compose command missing")
60
+
61
+ # 3. TUN device
62
+ tun_path = Path("/dev/net/tun")
63
+ if tun_path.exists():
64
+ table.add_row("TUN Device", Text("✔ OK", style="bold green"), "/dev/net/tun is present")
65
+ else:
66
+ table.add_row("TUN Device", Text("✗ MISSING", style="bold red"), "Run 'mkdir -p /dev/net && mknod /dev/net/tun c 10 200'")
67
+
68
+ # 4. RAM & Swap
69
+ mem_ok = False
70
+ mem_info = "Unable to read /proc/meminfo"
71
+ try:
72
+ with open("/proc/meminfo") as f:
73
+ lines = f.readlines()
74
+ total_ram = 0
75
+ total_swap = 0
76
+ for line in lines:
77
+ if line.startswith("MemTotal:"):
78
+ total_ram = int(line.split()[1]) // 1024
79
+ elif line.startswith("SwapTotal:"):
80
+ total_swap = int(line.split()[1]) // 1024
81
+ mem_info = f"RAM: {total_ram}MB | Swap: {total_swap}MB"
82
+ mem_ok = total_swap >= 1000 or total_ram >= 3000
83
+ except Exception:
84
+ pass
85
+
86
+ if mem_ok:
87
+ table.add_row("Memory & Swap", Text("✔ OK", style="bold green"), mem_info)
88
+ else:
89
+ table.add_row("Memory & Swap", Text("⚠ WARN", style="bold yellow"), f"{mem_info} (Recommend adding >= 2GB swap)")
90
+
91
+ # 5. PIA Credentials
92
+ u, p = config.get_pia_account()
93
+ if u and p:
94
+ table.add_row("PIA Account", Text("✔ READY", style="bold green"), f"Username: {u}")
95
+ else:
96
+ table.add_row("PIA Account", Text("✗ NOT SET", style="bold red"), "Configure with 'pia-proxy config set-credentials'")
97
+
98
+ # 6. Gateway Port (1087)
99
+ gw_in_use = _is_port_open(config.proxy_port)
100
+ table.add_row(f"Proxy Port ({config.proxy_port})", Text("IN USE" if gw_in_use else "AVAILABLE", style="cyan" if gw_in_use else "green"), "SOCKS5 Proxy Gateway")
101
+
102
+ # 7. API Port (8007)
103
+ api_in_use = _is_port_open(config.api_port)
104
+ table.add_row(f"API Port ({config.api_port})", Text("IN USE" if api_in_use else "AVAILABLE", style="cyan" if api_in_use else "green"), "FastAPI Backend")
105
+
106
+ console.print(table)
cli/commands/export.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from cli.config import CLIConfig
10
+
11
+
12
+ @click.command("export", help="Export proxy connection strings formatted for bots, antidetect browsers, or crawlers.")
13
+ @click.option(
14
+ "--format",
15
+ "-f",
16
+ type=click.Choice(["url", "standard", "curl", "json", "env"]),
17
+ default="standard",
18
+ help="Output format (standard = IP:PORT:USER:PASS).",
19
+ show_default=True,
20
+ )
21
+ @click.option("--host", "-H", default=None, help="Custom server IP or hostname override.")
22
+ @click.pass_obj
23
+ def export_cmd(obj: dict[str, Any], format: str, host: str | None) -> None:
24
+ config: CLIConfig = obj["config"]
25
+ console = Console()
26
+
27
+ server_ip = host or config.get_public_ip()
28
+ port = config.proxy_port
29
+ user = config.socks5_user
30
+ pwd = config.socks5_pass
31
+
32
+ if format == "standard":
33
+ # IP:PORT:USER:PASS format commonly accepted by antidetect tools
34
+ click.echo(f"{server_ip}:{port}:{user}:{pwd}")
35
+ elif format == "url":
36
+ click.echo(f"socks5h://{user}:{pwd}@{server_ip}:{port}")
37
+ elif format == "curl":
38
+ click.echo(f"curl -x socks5h://{user}:{pwd}@{server_ip}:{port} https://api.ipify.org")
39
+ elif format == "json":
40
+ data = {
41
+ "type": "socks5",
42
+ "host": server_ip,
43
+ "port": port,
44
+ "username": user,
45
+ "password": pwd,
46
+ "url": f"socks5h://{user}:{pwd}@{server_ip}:{port}",
47
+ "standard": f"{server_ip}:{port}:{user}:{pwd}",
48
+ }
49
+ click.echo(json.dumps(data, indent=2))
50
+ elif format == "env":
51
+ click.echo(f'export ALL_PROXY="socks5h://{user}:{pwd}@{server_ip}:{port}"')
52
+ click.echo(f'export HTTP_PROXY="socks5h://{user}:{pwd}@{server_ip}:{port}"')
53
+ click.echo(f'export HTTPS_PROXY="socks5h://{user}:{pwd}@{server_ip}:{port}"')
cli/commands/logs.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+
7
+ from cli.docker_ctl import DockerController
8
+
9
+
10
+ @click.command("logs", help="Tail or follow real-time logs from proxy containers or workers.")
11
+ @click.option("--service", "-s", default=None, help="Target container or service (e.g. 'gateway', 'backend', 'vpn-worker-1').")
12
+ @click.option("--tail", "-n", default=100, help="Number of historical lines to show.", show_default=True)
13
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output continuously.")
14
+ @click.pass_obj
15
+ def logs_cmd(obj: dict[str, Any], service: str | None, tail: int, follow: bool) -> None:
16
+ docker_ctl: DockerController = obj["docker_ctl"]
17
+
18
+ svc_name = service
19
+ if svc_name:
20
+ if svc_name in {"gateway", "proxy-gateway", "haproxy"}:
21
+ svc_name = "proxy-gateway"
22
+ elif svc_name in {"api", "backend"}:
23
+ svc_name = "backend"
24
+ elif svc_name.isdigit():
25
+ svc_name = f"vpn-worker-{svc_name}"
26
+ elif svc_name.startswith("worker"):
27
+ svc_name = f"vpn-{svc_name}"
28
+
29
+ try:
30
+ for line in docker_ctl.stream_logs(service=svc_name, tail=tail, follow=follow):
31
+ click.echo(line)
32
+ except KeyboardInterrupt:
33
+ pass
cli/commands/proxy.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ from cli.config import CLIConfig
10
+
11
+
12
+ @click.command("proxy", help="View or update SOCKS5 proxy port and authentication credentials.")
13
+ @click.argument("port", type=int, required=False, default=None)
14
+ @click.argument("username", required=False, default=None)
15
+ @click.argument("password", required=False, default=None)
16
+ @click.pass_obj
17
+ def proxy_cmd(
18
+ obj: dict[str, Any],
19
+ port: int | None,
20
+ username: str | None,
21
+ password: str | None,
22
+ ) -> None:
23
+ config: CLIConfig = obj["config"]
24
+ console = Console()
25
+
26
+ # If no arguments provided, view current proxy info
27
+ if port is None and username is None and password is None:
28
+ ip = config.get_public_ip()
29
+ msg = (
30
+ f"• Cổng SOCKS5 (Port): [bold cyan]{config.proxy_port}[/bold cyan]\n"
31
+ f"• Tài khoản đăng nhập: [bold white]{config.socks5_user}[/bold white]\n"
32
+ f"• Mật khẩu: [bold white]{'***' + config.socks5_pass[-4:] if len(config.socks5_pass) > 4 else '***'}[/bold white]\n"
33
+ f"• URL kết nối nội bộ: [bold green]socks5h://{config.socks5_user}:{config.socks5_pass}@127.0.0.1:{config.proxy_port}[/bold green]\n"
34
+ f"• Chuỗi Proxy công khai: [bold green]{ip}:{config.proxy_port}:{config.socks5_user}:{config.socks5_pass}[/bold green]\n\n"
35
+ f"[dim]Cú pháp đổi cấu hình: pia-proxy proxy <cổng> [username] [password][/dim]\n"
36
+ f"[dim]Ví dụ: pia-proxy proxy 1087 myuser mypass[/dim]"
37
+ )
38
+ console.print(Panel(msg, title="[bold yellow]Cấu Hình Cổng SOCKS5 Proxy[/bold yellow]", border_style="cyan"))
39
+ return
40
+
41
+ # Update port
42
+ if port:
43
+ if port < 1024 or port > 65535:
44
+ console.print("[bold red]Lỗi: Cổng phải nằm trong khoảng từ 1024 đến 65535.[/bold red]")
45
+ raise SystemExit(1)
46
+ config.update_env_value("PROXY_PORT", port)
47
+
48
+ # Update username
49
+ if username:
50
+ config.update_env_value("SOCKS5_USERNAME", username.strip())
51
+
52
+ # Update password
53
+ if password:
54
+ config.update_env_value("SOCKS5_PASSWORD", password.strip())
55
+
56
+ console.print("[bold green]✔ Đã cập nhật cấu hình SOCKS5 Gateway thành công:[/bold green]")
57
+ if port:
58
+ console.print(f" • Cổng mới: [bold cyan]{port}[/bold cyan]")
59
+ if username:
60
+ console.print(f" • Username mới: [bold cyan]{username}[/bold cyan]")
61
+ if password:
62
+ console.print(f" • Mật khẩu mới: [bold cyan]******[/bold cyan]")
63
+ console.print("[dim]Chạy 'pia-proxy restart' để áp dụng cấu hình mới vào container.[/dim]")
cli/commands/rotate.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from cli.api_client import APIClient, APIClientError
10
+
11
+
12
+ @click.command("rotate", help="Trigger immediate IP rotation for a worker or across the pool.")
13
+ @click.argument("worker", required=False, default=None)
14
+ @click.option("--all", "rotate_all", is_flag=True, help="Sequentially trigger IP rotation for all active workers.")
15
+ @click.option("--country", "-c", default=None, help="Target country code or region name for new IP.")
16
+ @click.option("--server", "-s", default=None, help="Target specific server hostname.")
17
+ @click.option("--wait", is_flag=True, help="Wait for new IP verification before returning.")
18
+ @click.pass_obj
19
+ def rotate_cmd(
20
+ obj: dict[str, Any],
21
+ worker: str | None,
22
+ rotate_all: bool,
23
+ country: str | None,
24
+ server: str | None,
25
+ wait: bool,
26
+ ) -> None:
27
+ client: APIClient = obj["client"]
28
+ console = Console()
29
+
30
+ if not client.is_alive():
31
+ console.print("[bold red]Proxy service is offline. Please start it with 'pia-proxy start' first.[/bold red]")
32
+ raise SystemExit(1)
33
+
34
+ if rotate_all:
35
+ console.print("[bold cyan]Rotating all active workers...[/bold cyan]")
36
+ try:
37
+ nodes = client.get_nodes()
38
+ active_nodes = [n for n in nodes if n.get("active")]
39
+ for n in active_nodes:
40
+ wid = n.get("id")
41
+ try:
42
+ res = client.rotate_node(wid, country=country, server=server, wait_for_ready=wait)
43
+ console.print(f" • Worker {wid}: [green]Rotation triggered[/green]")
44
+ except Exception as e:
45
+ console.print(f" • Worker {wid}: [red]{e}[/red]")
46
+ time.sleep(0.5)
47
+ console.print("[bold green]✔ Completed rotation triggers for all active workers.[/bold green]")
48
+ except Exception as exc:
49
+ console.print(f"[bold red]Failed to rotate all: {exc}[/bold red]")
50
+ return
51
+
52
+ if worker:
53
+ wid = f"worker{worker}" if worker.isdigit() else worker
54
+ wid_clean = wid.replace("vpn-", "").replace("pia-", "")
55
+ console.print(f"[bold cyan]Rotating worker '{wid_clean}'...[/bold cyan]")
56
+ try:
57
+ with console.status(f"[yellow]Rotating {wid_clean}...[/yellow]"):
58
+ res = client.rotate_node(wid_clean, country=country, server=server, wait_for_ready=wait)
59
+
60
+ if res.get("ok"):
61
+ new_ip = res.get("new_ip") or res.get("current_ip")
62
+ if new_ip:
63
+ console.print(f"[bold green]✔ Worker '{wid_clean}' rotated! New Exit IP: {new_ip}[/bold green]")
64
+ else:
65
+ console.print(f"[bold green]✔ Worker '{wid_clean}' rotation initiated in background.[/bold green]")
66
+ else:
67
+ console.print(f"[yellow]Rotation result: {res}[/yellow]")
68
+ except APIClientError as exc:
69
+ console.print(f"[bold red]Failed to rotate worker: {exc}[/bold red]")
70
+ raise SystemExit(1)
71
+ return
72
+
73
+ # Default: rotate any ready worker
74
+ console.print("[bold cyan]Rotating an active worker behind the gateway...[/bold cyan]")
75
+ try:
76
+ with console.status("[yellow]Rotating...[/yellow]"):
77
+ res = client.rotate_any(country=country, server=server, wait_for_ready=wait)
78
+
79
+ if res.get("ok"):
80
+ node_id = res.get("node_id") or "worker"
81
+ new_ip = res.get("new_ip") or res.get("current_ip")
82
+ if new_ip:
83
+ console.print(f"[bold green]✔ Worker '{node_id}' rotated! New Exit IP: {new_ip}[/bold green]")
84
+ else:
85
+ console.print(f"[bold green]✔ Worker '{node_id}' detached and rotating in background.[/bold green]")
86
+ else:
87
+ console.print(f"[yellow]Rotation response: {res}[/yellow]")
88
+ except APIClientError as exc:
89
+ console.print(f"[bold red]Failed to rotate: {exc}[/bold red]")
90
+ raise SystemExit(1)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ from cli.config import CLIConfig
10
+
11
+
12
+ @click.command("rotate-time", help="View or set auto-rotation intervals & max uptime (e.g. 'pia-proxy rotate-time 36 69 70').")
13
+ @click.argument("min_sec", type=int, required=False, default=None)
14
+ @click.argument("max_sec", type=int, required=False, default=None)
15
+ @click.argument("max_uptime", type=int, required=False, default=None)
16
+ @click.pass_obj
17
+ def rotate_time_cmd(
18
+ obj: dict[str, Any],
19
+ min_sec: int | None,
20
+ max_sec: int | None,
21
+ max_uptime: int | None,
22
+ ) -> None:
23
+ config: CLIConfig = obj["config"]
24
+ console = Console()
25
+
26
+ # Read current values from .env
27
+ cur_min = 36
28
+ cur_max = 69
29
+ cur_uptime = 70
30
+ if config.env_file.exists():
31
+ for line in config.env_file.read_text(encoding="utf-8", errors="ignore").splitlines():
32
+ line = line.strip()
33
+ if line.startswith("AUTO_ROTATE_INTERVAL_MIN_SECONDS="):
34
+ try: cur_min = int(line.split("=", 1)[1].strip())
35
+ except: pass
36
+ elif line.startswith("AUTO_ROTATE_INTERVAL_MAX_SECONDS="):
37
+ try: cur_max = int(line.split("=", 1)[1].strip())
38
+ except: pass
39
+ elif line.startswith("AUTO_ROTATE_MAX_UPTIME_SECONDS="):
40
+ try: cur_uptime = int(line.split("=", 1)[1].strip())
41
+ except: pass
42
+
43
+ # If no arguments provided, display current settings
44
+ if min_sec is None:
45
+ msg = (
46
+ f"• Thời gian xoay tối thiểu: [bold cyan]{cur_min}s[/bold cyan]\n"
47
+ f"• Thời gian xoay tối đa: [bold cyan]{cur_max}s[/bold cyan]\n"
48
+ f"• Giới hạn cứng Uptime: [bold yellow]{cur_uptime}s[/bold yellow] (Bất kỳ IP nào quá thời gian này sẽ bị ép xoay ngay)\n\n"
49
+ f"[dim]Cú pháp cài đặt: pia-proxy rotate-time <min_giây> <max_giây> [max_uptime_giây][/dim]\n"
50
+ f"[dim]Ví dụ cấu hình chuẩn: pia-proxy rotate-time 36 69 70[/dim]"
51
+ )
52
+ console.print(Panel(msg, title="[bold yellow]Cấu Hình Chu Kỳ Xoay IP (Auto-Rotate Rules)[/bold yellow]", border_style="cyan"))
53
+ return
54
+
55
+ # Update values
56
+ val_min = min_sec
57
+ val_max = max_sec or (val_min + 30)
58
+ val_uptime = max_uptime or (val_max + 1)
59
+
60
+ if val_min < 5 or val_max <= val_min:
61
+ console.print("[bold red]Lỗi: Thời gian tối đa phải lớn hơn thời gian tối thiểu (min >= 5s)![/bold red]")
62
+ raise SystemExit(1)
63
+
64
+ config.update_env_value("AUTO_ROTATE_INTERVAL_MIN_SECONDS", val_min)
65
+ config.update_env_value("AUTO_ROTATE_INTERVAL_MAX_SECONDS", val_max)
66
+ config.update_env_value("AUTO_ROTATE_MAX_UPTIME_SECONDS", val_uptime)
67
+
68
+ console.print("[bold green]✔ Đã cập nhật quy tắc xoay IP thành công:[/bold green]")
69
+ console.print(f" • Chu kỳ xoay tự động: [bold cyan]{val_min}s – {val_max}s[/bold cyan]")
70
+ console.print(f" • Giới hạn cứng Uptime: [bold yellow]{val_uptime}s[/bold yellow] (Ép xoay)")
71
+ console.print("[dim]Chạy 'pia-proxy restart' để áp dụng chu kỳ mới cho backend.[/dim]")