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.
- proxy_tuner/__init__.py +3 -0
- proxy_tuner/cli.py +109 -0
- proxy_tuner/cli_config.py +152 -0
- proxy_tuner/cli_logs.py +58 -0
- proxy_tuner/cli_monitor.py +84 -0
- proxy_tuner/cli_outbound.py +146 -0
- proxy_tuner/cli_rule.py +251 -0
- proxy_tuner/cli_start.py +277 -0
- proxy_tuner/cli_stats.py +71 -0
- proxy_tuner/completions.py +85 -0
- proxy_tuner/config.py +391 -0
- proxy_tuner/dns.py +258 -0
- proxy_tuner/doctor.py +207 -0
- proxy_tuner/firewall.py +172 -0
- proxy_tuner/forwarder.py +449 -0
- proxy_tuner/http_proxy.py +142 -0
- proxy_tuner/logging.py +96 -0
- proxy_tuner/outbounds.py +186 -0
- proxy_tuner/platform/__init__.py +40 -0
- proxy_tuner/platform/base.py +54 -0
- proxy_tuner/platform/linux.py +196 -0
- proxy_tuner/platform/macos.py +46 -0
- proxy_tuner/platform/windows.py +244 -0
- proxy_tuner/pool.py +157 -0
- proxy_tuner/rules.py +255 -0
- proxy_tuner/setup_wizard.py +157 -0
- proxy_tuner/socks5.py +241 -0
- proxy_tuner/tun.py +240 -0
- proxy_tuner-0.1.0.dist-info/METADATA +122 -0
- proxy_tuner-0.1.0.dist-info/RECORD +33 -0
- proxy_tuner-0.1.0.dist-info/WHEEL +5 -0
- proxy_tuner-0.1.0.dist-info/entry_points.txt +2 -0
- proxy_tuner-0.1.0.dist-info/top_level.txt +1 -0
proxy_tuner/__init__.py
ADDED
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()
|
proxy_tuner/cli_logs.py
ADDED
|
@@ -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]")
|