proxy-tuner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """ProxyTuner — cross-platform proxy routing CLI."""
2
+
3
+ __version__ = "0.1.0"
proxy_tuner/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """Main CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from proxy_tuner import __version__
11
+ from proxy_tuner.config import ConfigManager
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.group()
17
+ @click.version_option(version=__version__, prog_name="proxy-tuner")
18
+ @click.option(
19
+ "--config",
20
+ "config_path",
21
+ type=click.Path(path_type=Path),
22
+ default=None,
23
+ help="Path to config file (default: platform default)",
24
+ )
25
+ @click.pass_context
26
+ def main(ctx: click.Context, config_path: Path | None) -> None:
27
+ """ProxyTuner — cross-platform proxy routing CLI.
28
+
29
+ Route network traffic through multiple proxy outbounds with
30
+ flexible rule-based routing.
31
+ """
32
+ ctx.ensure_object(dict)
33
+ ctx.obj["config_manager"] = ConfigManager(config_path)
34
+
35
+
36
+ # Register subcommands
37
+ from proxy_tuner.cli_config import config_group # noqa: E402
38
+ from proxy_tuner.cli_logs import logs # noqa: E402
39
+ from proxy_tuner.cli_monitor import monitor # noqa: E402
40
+ from proxy_tuner.cli_outbound import outbound_group # noqa: E402
41
+ from proxy_tuner.cli_rule import rule_group # noqa: E402
42
+ from proxy_tuner.cli_start import start, status, stop # noqa: E402
43
+ from proxy_tuner.cli_stats import stats # noqa: E402
44
+ from proxy_tuner.completions import completions_group # noqa: E402
45
+ from proxy_tuner.doctor import doctor # noqa: E402
46
+ from proxy_tuner.setup_wizard import setup_wizard # noqa: E402
47
+
48
+ main.add_command(outbound_group)
49
+ main.add_command(rule_group)
50
+ main.add_command(config_group)
51
+ main.add_command(completions_group)
52
+ main.add_command(setup_wizard)
53
+ main.add_command(doctor)
54
+ main.add_command(logs)
55
+ main.add_command(stats)
56
+ main.add_command(monitor)
57
+ main.add_command(start)
58
+ main.add_command(stop)
59
+ main.add_command(status)
60
+
61
+
62
+ @main.command("version")
63
+ def version_cmd() -> None:
64
+ """Show version information."""
65
+ from rich.console import Console
66
+
67
+ Console().print(f"proxy-tuner {__version__}")
68
+
69
+
70
+ @main.command("reload")
71
+ @click.pass_context
72
+ def reload(ctx: click.Context) -> None:
73
+ """Reload configuration (hot-reload while running)."""
74
+ import os
75
+ import signal
76
+
77
+ manager: ConfigManager = ctx.obj["config_manager"]
78
+
79
+ from proxy_tuner.cli_start import _read_pid
80
+
81
+ pid = _read_pid(manager)
82
+ if pid is None:
83
+ console.print("[yellow]ProxyTuner is not running.[/yellow]")
84
+ raise click.Abort()
85
+
86
+ # Validate new config
87
+ try:
88
+ config = manager.load()
89
+ except Exception as e:
90
+ console.print(f"[red]Config error:[/red] {e}")
91
+ raise click.Abort() from e
92
+
93
+ errors = config.validate_references()
94
+ if errors:
95
+ console.print("[red]Config validation errors:[/red]")
96
+ for err in errors:
97
+ console.print(f" • {err}")
98
+ raise click.Abort()
99
+
100
+ # Send SIGHUP to trigger reload
101
+ try:
102
+ os.kill(pid, signal.SIGHUP)
103
+ console.print(f"[green]✓[/green] Sent reload signal to PID {pid}")
104
+ except OSError as e:
105
+ console.print(f"[red]Error:[/red] {e}")
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
@@ -0,0 +1,152 @@
1
+ """Config management CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.syntax import Syntax
8
+
9
+ from proxy_tuner.config import ConfigManager
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.group("config")
15
+ def config_group() -> None:
16
+ """Manage configuration."""
17
+
18
+
19
+ @config_group.command("show")
20
+ @click.pass_context
21
+ def show_config(ctx: click.Context) -> None:
22
+ """Display the current configuration."""
23
+ import json
24
+
25
+ manager: ConfigManager = ctx.obj["config_manager"]
26
+ config = manager.get()
27
+
28
+ from proxy_tuner.config import _serialize_config
29
+
30
+ output = json.dumps(_serialize_config(config), indent=2, ensure_ascii=False)
31
+ console.print(Syntax(output, "json", theme="monokai"))
32
+
33
+
34
+ @config_group.command("path")
35
+ @click.pass_context
36
+ def config_path(ctx: click.Context) -> None:
37
+ """Show the config file path."""
38
+ manager: ConfigManager = ctx.obj["config_manager"]
39
+ console.print(str(manager.path))
40
+
41
+
42
+ @config_group.command("edit")
43
+ @click.pass_context
44
+ def edit_config(ctx: click.Context) -> None:
45
+ """Open the config file in the default editor."""
46
+ import os
47
+ import subprocess
48
+
49
+ manager: ConfigManager = ctx.obj["config_manager"]
50
+
51
+ # Ensure file exists
52
+ if not manager.path.exists():
53
+ manager.save(manager.get())
54
+
55
+ editor = os.environ.get("EDITOR", "vi")
56
+ try:
57
+ subprocess.run([editor, str(manager.path)], check=True)
58
+ console.print(f"[green]✓[/green] Config edited at {manager.path}")
59
+ except FileNotFoundError:
60
+ msg = f"[red]Error:[/red] Editor '{editor}' not found."
61
+ console.print(msg + " Set $EDITOR or use 'config show'.")
62
+ except subprocess.CalledProcessError as e:
63
+ console.print(f"[red]Error:[/red] Editor exited with code {e.returncode}")
64
+
65
+
66
+ @config_group.command("validate")
67
+ @click.pass_context
68
+ def validate_config(ctx: click.Context) -> None:
69
+ """Validate the configuration file."""
70
+ manager: ConfigManager = ctx.obj["config_manager"]
71
+
72
+ try:
73
+ config = manager.load()
74
+ except Exception as e:
75
+ console.print(f"[red]Parse error:[/red] {e}")
76
+ raise click.Abort() from e
77
+
78
+ errors = config.validate_references()
79
+ if errors:
80
+ console.print("[red]Validation errors:[/red]")
81
+ for err in errors:
82
+ console.print(f" • {err}")
83
+ raise click.Abort()
84
+
85
+ console.print("[green]✓[/green] Configuration is valid")
86
+
87
+
88
+ @config_group.command("init")
89
+ @click.pass_context
90
+ def init_config(ctx: click.Context) -> None:
91
+ """Create a default config file."""
92
+ from proxy_tuner.config import Config
93
+
94
+ manager: ConfigManager = ctx.obj["config_manager"]
95
+
96
+ if manager.path.exists():
97
+ console.print(f"[yellow]Config already exists at {manager.path}[/yellow]")
98
+ if not click.confirm("Overwrite?"):
99
+ return
100
+
101
+ manager.save(Config())
102
+ console.print(f"[green]✓[/green] Created default config at {manager.path}")
103
+
104
+
105
+ @config_group.command("set")
106
+ @click.argument("key")
107
+ @click.argument("value")
108
+ @click.pass_context
109
+ def set_config(ctx: click.Context, key: str, value: str) -> None:
110
+ """Set a configuration value.
111
+
112
+ Examples:
113
+ proxy-tuner config set settings.listen_port 9090
114
+ proxy-tuner config set settings.log_level debug
115
+ proxy-tuner config set settings.dns_server 1.1.1.1
116
+ """
117
+ manager: ConfigManager = ctx.obj["config_manager"]
118
+ config = manager.get()
119
+
120
+ # Parse the key path
121
+ parts = key.split(".")
122
+ if len(parts) < 2:
123
+ msg = "[red]Error:[/red] Key must be in format 'section.field'"
124
+ console.print(f"{msg} (e.g., settings.listen_port)")
125
+ raise click.Abort()
126
+
127
+ section = parts[0]
128
+ field_name = parts[1]
129
+
130
+ # Type coercion
131
+ if value.lower() == "true":
132
+ coerced: object = True
133
+ elif value.lower() == "false":
134
+ coerced = False
135
+ elif value.lower() == "null" or value.lower() == "none":
136
+ coerced = None
137
+ else:
138
+ try:
139
+ coerced = int(value)
140
+ except ValueError:
141
+ try:
142
+ coerced = float(value)
143
+ except ValueError:
144
+ coerced = value
145
+
146
+ if section == "settings" and hasattr(config.settings, field_name):
147
+ setattr(config.settings, field_name, coerced)
148
+ manager.save(config)
149
+ console.print(f"[green]✓[/green] Set {key} = {coerced}")
150
+ else:
151
+ console.print(f"[red]Error:[/red] Unknown setting '{key}'. Valid sections: settings")
152
+ raise click.Abort()
@@ -0,0 +1,58 @@
1
+ """Logs viewing CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+
11
+ @click.command("logs")
12
+ @click.option("-n", "--lines", default=50, type=int, help="Number of lines to show")
13
+ @click.option("-f", "--follow", is_flag=True, help="Follow log output (tail -f)")
14
+ @click.pass_context
15
+ def logs(ctx: click.Context, lines: int, follow: bool) -> None:
16
+ """View ProxyTuner logs."""
17
+
18
+ from proxy_tuner.config import get_config_dir
19
+
20
+ log_file = get_config_dir() / "proxy-tuner.log"
21
+
22
+ if follow:
23
+ # Tail -f mode
24
+ if not log_file.exists():
25
+ console.print(f"[yellow]Log file not found: {log_file}[/yellow]")
26
+ return
27
+
28
+ console.print(f"[dim]Following {log_file} (Ctrl+C to stop)[/dim]")
29
+ try:
30
+ with open(log_file) as f:
31
+ # Seek to end minus some bytes
32
+ f.seek(0, 2)
33
+ size = f.tell()
34
+ f.seek(max(0, size - 4096))
35
+ f.readline() # Skip partial line
36
+
37
+ while True:
38
+ line = f.readline()
39
+ if line:
40
+ console.print(line.rstrip())
41
+ else:
42
+ import time
43
+ time.sleep(0.1)
44
+ except KeyboardInterrupt:
45
+ pass
46
+ else:
47
+ # Show last N lines
48
+ if not log_file.exists():
49
+ console.print(f"[yellow]No log file found at {log_file}[/yellow]")
50
+ msg = "[dim]Logs are written when running with "
51
+ console.print(msg + "--log-file or in daemon mode[/dim]")
52
+ return
53
+
54
+ with open(log_file) as f:
55
+ all_lines = f.readlines()
56
+ recent = all_lines[-lines:]
57
+ for line in recent:
58
+ console.print(line.rstrip())
@@ -0,0 +1,84 @@
1
+ """Monitor command — live view of connections and activity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.live import Live
10
+ from rich.table import Table
11
+
12
+ console = Console()
13
+
14
+
15
+ def _format_bytes(n: int) -> str:
16
+ for unit in ("B", "KB", "MB", "GB"):
17
+ if abs(n) < 1024:
18
+ return f"{n:.1f} {unit}"
19
+ n /= 1024
20
+ return f"{n:.1f} TB"
21
+
22
+
23
+ @click.command("monitor")
24
+ @click.option("--interval", "-i", default=2, type=int, help="Refresh interval in seconds")
25
+ @click.pass_context
26
+ def monitor(ctx: click.Context, interval: int) -> None:
27
+ """Live monitoring dashboard."""
28
+ import json
29
+
30
+ from proxy_tuner.config import get_config_dir
31
+
32
+ stats_file = get_config_dir() / "stats.json"
33
+ pid_file = get_config_dir() / "proxytuner.pid"
34
+
35
+ def _build_table() -> Table:
36
+ table = Table(title="ProxyTuner Monitor", show_header=True)
37
+ table.add_column("Metric", style="bold")
38
+ table.add_column("Value", justify="right")
39
+
40
+ # Status
41
+ running = False
42
+ if pid_file.exists():
43
+ try:
44
+ import os
45
+ pid = int(pid_file.read_text().strip())
46
+ os.kill(pid, 0)
47
+ running = True
48
+ except (ValueError, OSError):
49
+ pass
50
+
51
+ status = "[green]Running[/green]" if running else "[red]Stopped[/red]"
52
+ table.add_row("Status", status)
53
+
54
+ # Load stats
55
+ data = {}
56
+ if stats_file.exists():
57
+ try:
58
+ with open(stats_file) as f:
59
+ data = json.load(f)
60
+ except Exception:
61
+ pass
62
+
63
+ # Aggregate stats
64
+ total_conns = sum(s.get("connections", 0) for s in data.values())
65
+ total_sent = sum(s.get("bytes_sent", 0) for s in data.values())
66
+ total_recv = sum(s.get("bytes_received", 0) for s in data.values())
67
+ total_errors = sum(s.get("errors", 0) for s in data.values())
68
+
69
+ table.add_row("Total connections", str(total_conns))
70
+ table.add_row("Bytes sent", _format_bytes(total_sent))
71
+ table.add_row("Bytes received", _format_bytes(total_recv))
72
+ table.add_row("Errors", str(total_errors))
73
+ table.add_row("Last update", time.strftime("%H:%M:%S"))
74
+
75
+ return table
76
+
77
+ try:
78
+ refresh = 1 // max(interval, 1)
79
+ with Live(_build_table(), refresh_per_second=refresh, console=console) as live:
80
+ while True:
81
+ time.sleep(interval)
82
+ live.update(_build_table())
83
+ except KeyboardInterrupt:
84
+ pass
@@ -0,0 +1,146 @@
1
+ """Outbound management CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from proxy_tuner.config import ConfigManager, HttpOutbound, Socks5Outbound
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.group("outbound")
15
+ def outbound_group() -> None:
16
+ """Manage proxy outbounds."""
17
+
18
+
19
+ @outbound_group.command("add")
20
+ @click.argument("name")
21
+ @click.option("--type", "outbound_type", type=click.Choice(["socks5", "http"]), required=True)
22
+ @click.option("--host", required=True, help="Proxy server hostname or IP")
23
+ @click.option("--port", required=True, type=int, help="Proxy server port")
24
+ @click.option("--username", default=None, help="Proxy auth username")
25
+ @click.option("--password", default=None, help="Proxy auth password")
26
+ @click.option("--timeout", default=10, type=int, show_default=True,
27
+ help="Connection timeout in seconds")
28
+ @click.pass_context
29
+ def add_outbound(
30
+ ctx: click.Context,
31
+ name: str,
32
+ outbound_type: str,
33
+ host: str,
34
+ port: int,
35
+ username: str | None,
36
+ password: str | None,
37
+ timeout: int,
38
+ ) -> None:
39
+ """Add a new proxy outbound."""
40
+ manager: ConfigManager = ctx.obj["config_manager"]
41
+
42
+ try:
43
+ if outbound_type == "socks5":
44
+ outbound = Socks5Outbound(
45
+ type="socks5",
46
+ host=host,
47
+ port=port,
48
+ username=username,
49
+ password=password,
50
+ timeout=timeout,
51
+ )
52
+ else:
53
+ outbound = HttpOutbound(
54
+ type="http",
55
+ host=host,
56
+ port=port,
57
+ username=username,
58
+ password=password,
59
+ timeout=timeout,
60
+ )
61
+
62
+ manager.add_outbound(name, outbound)
63
+ console.print(f"[green]✓[/green] Added outbound '[bold]{name}[/bold]' ({outbound_type}://{host}:{port})")
64
+
65
+ except ValueError as e:
66
+ console.print(f"[red]Error:[/red] {e}")
67
+ raise click.Abort() from e
68
+
69
+
70
+ @outbound_group.command("remove")
71
+ @click.argument("name")
72
+ @click.pass_context
73
+ def remove_outbound(ctx: click.Context, name: str) -> None:
74
+ """Remove an outbound."""
75
+ manager: ConfigManager = ctx.obj["config_manager"]
76
+
77
+ try:
78
+ manager.remove_outbound(name)
79
+ console.print(f"[green]✓[/green] Removed outbound '[bold]{name}[/bold]'")
80
+ except ValueError as e:
81
+ console.print(f"[red]Error:[/red] {e}")
82
+ raise click.Abort() from e
83
+
84
+
85
+ @outbound_group.command("list")
86
+ @click.pass_context
87
+ def list_outbounds(ctx: click.Context) -> None:
88
+ """List all configured outbounds."""
89
+ manager: ConfigManager = ctx.obj["config_manager"]
90
+ config = manager.get()
91
+
92
+ if not config.outbounds:
93
+ console.print("[dim]No outbounds configured.[/dim]")
94
+ return
95
+
96
+ table = Table(title="Outbounds")
97
+ table.add_column("Name", style="bold")
98
+ table.add_column("Type")
99
+ table.add_column("Host")
100
+ table.add_column("Port")
101
+ table.add_column("Timeout")
102
+
103
+ for name, ob in config.outbounds.items():
104
+ if ob.type == "direct":
105
+ table.add_row(name, "direct", "—", "—", "—")
106
+ else:
107
+ table.add_row(
108
+ name,
109
+ ob.type,
110
+ ob.host,
111
+ str(ob.port),
112
+ f"{ob.timeout}s",
113
+ )
114
+
115
+ console.print(table)
116
+
117
+
118
+ @outbound_group.command("test")
119
+ @click.argument("name")
120
+ @click.pass_context
121
+ def test_outbound(ctx: click.Context, name: str) -> None:
122
+ """Test connectivity through an outbound proxy."""
123
+ import asyncio
124
+
125
+ from proxy_tuner.outbounds import OutboundManager
126
+
127
+ manager: ConfigManager = ctx.obj["config_manager"]
128
+ config = manager.get()
129
+
130
+ if name not in config.outbounds:
131
+ console.print(f"[red]Error:[/red] Outbound '{name}' does not exist")
132
+ raise click.Abort()
133
+
134
+ ob = config.outbounds[name]
135
+ target = f"{ob.type}://{ob.host}:{ob.port}" if ob.type != "direct" else "direct"
136
+ console.print(f"Testing [bold]{name}[/bold] ({target})...")
137
+
138
+ ob_manager = OutboundManager(config=config)
139
+ result = asyncio.run(ob_manager.test_outbound(name))
140
+
141
+ if result.success:
142
+ console.print(f" Connection: [green]OK[/green] ({result.latency_ms:.0f}ms)")
143
+ console.print(" Overall: [green]PASS[/green]")
144
+ else:
145
+ console.print(f" Connection: [red]FAIL[/red] — {result.error}")
146
+ console.print(" Overall: [red]FAIL[/red]")