proxy2vpn 0.2.0__tar.gz → 0.3.1__tar.gz
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.
- {proxy2vpn-0.2.0/src/proxy2vpn.egg-info → proxy2vpn-0.3.1}/PKG-INFO +14 -20
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/README.md +13 -19
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/pyproject.toml +1 -1
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/cli.py +267 -52
- proxy2vpn-0.3.1/src/proxy2vpn/diagnostics.py +100 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/docker_ops.py +45 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/typer_ext.py +6 -6
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1/src/proxy2vpn.egg-info}/PKG-INFO +14 -20
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/SOURCES.txt +4 -1
- proxy2vpn-0.3.1/tests/test_cli_empty_commands.py +29 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_init_command.py +3 -3
- proxy2vpn-0.3.1/tests/test_version_option.py +31 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/LICENSE +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/setup.cfg +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/__init__.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/__main__.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/compose_manager.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/compose_utils.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/compose_validator.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/config.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/models.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/preset_manager.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/server_manager.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/dependency_links.txt +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/entry_points.txt +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/requires.txt +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/top_level.txt +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_cli_entrypoint.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_compose_manager.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_compose_utils.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_compose_validator.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_docker_ops.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_preset_manager.py +0 -0
- {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_server_manager.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxy2vpn
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Proxy2VPN Python utilities
|
|
5
5
|
Author-email: Serhii Khalymon <serhii.khalymon@pm.me>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,7 +25,7 @@ Python command-line interface for managing multiple VPN containers with Docker.
|
|
|
25
25
|
## Features
|
|
26
26
|
- Manage VPN credentials as reusable profiles
|
|
27
27
|
- Create and control VPN services
|
|
28
|
-
-
|
|
28
|
+
- Multi-service control with `--all` flags
|
|
29
29
|
- Query and validate provider server locations
|
|
30
30
|
- Apply predefined presets for common setups
|
|
31
31
|
|
|
@@ -61,7 +61,7 @@ proxy2vpn --help
|
|
|
61
61
|
## Quick Start
|
|
62
62
|
1. Initialize the compose file:
|
|
63
63
|
```bash
|
|
64
|
-
proxy2vpn init
|
|
64
|
+
proxy2vpn system init
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
2. Create a profile file with your VPN credentials:
|
|
@@ -87,13 +87,15 @@ proxy2vpn --help
|
|
|
87
87
|
5. View status and test connectivity:
|
|
88
88
|
```bash
|
|
89
89
|
proxy2vpn vpn list
|
|
90
|
-
proxy2vpn test vpn1
|
|
90
|
+
proxy2vpn vpn test vpn1
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
## Command overview
|
|
94
94
|
|
|
95
|
-
###
|
|
96
|
-
- `proxy2vpn init [--force]`
|
|
95
|
+
### System operations
|
|
96
|
+
- `proxy2vpn system init [--force]`
|
|
97
|
+
- `proxy2vpn system validate`
|
|
98
|
+
- `proxy2vpn system diagnose [--lines N] [--all] [--verbose] [--json]`
|
|
97
99
|
|
|
98
100
|
### Profiles
|
|
99
101
|
- `proxy2vpn profile create NAME ENV_FILE`
|
|
@@ -102,18 +104,13 @@ proxy2vpn --help
|
|
|
102
104
|
|
|
103
105
|
### VPN services
|
|
104
106
|
- `proxy2vpn vpn create NAME PROFILE [--port PORT] [--provider PROVIDER] [--location LOCATION]`
|
|
105
|
-
- `proxy2vpn vpn list`
|
|
106
|
-
- `proxy2vpn vpn start NAME`
|
|
107
|
-
- `proxy2vpn vpn stop NAME`
|
|
108
|
-
- `proxy2vpn vpn restart NAME`
|
|
107
|
+
- `proxy2vpn vpn list [--diagnose] [--ips-only]`
|
|
108
|
+
- `proxy2vpn vpn start [NAME | --all]`
|
|
109
|
+
- `proxy2vpn vpn stop [NAME | --all]`
|
|
110
|
+
- `proxy2vpn vpn restart [NAME | --all]`
|
|
109
111
|
- `proxy2vpn vpn logs NAME [--lines N] [--follow]`
|
|
110
|
-
- `proxy2vpn vpn delete NAME`
|
|
111
|
-
|
|
112
|
-
### Bulk operations
|
|
113
|
-
- `proxy2vpn bulk up`
|
|
114
|
-
- `proxy2vpn bulk down`
|
|
115
|
-
- `proxy2vpn bulk status`
|
|
116
|
-
- `proxy2vpn bulk ips`
|
|
112
|
+
- `proxy2vpn vpn delete [NAME | --all]`
|
|
113
|
+
- `proxy2vpn vpn test NAME`
|
|
117
114
|
|
|
118
115
|
### Server database
|
|
119
116
|
- `proxy2vpn servers update`
|
|
@@ -126,9 +123,6 @@ proxy2vpn --help
|
|
|
126
123
|
- `proxy2vpn preset list`
|
|
127
124
|
- `proxy2vpn preset apply PRESET SERVICE [--port PORT]`
|
|
128
125
|
|
|
129
|
-
### Testing
|
|
130
|
-
- `proxy2vpn test SERVICE` – verify that a proxy container is reachable
|
|
131
|
-
|
|
132
126
|
## Development
|
|
133
127
|
|
|
134
128
|
### Setup
|
|
@@ -5,7 +5,7 @@ Python command-line interface for managing multiple VPN containers with Docker.
|
|
|
5
5
|
## Features
|
|
6
6
|
- Manage VPN credentials as reusable profiles
|
|
7
7
|
- Create and control VPN services
|
|
8
|
-
-
|
|
8
|
+
- Multi-service control with `--all` flags
|
|
9
9
|
- Query and validate provider server locations
|
|
10
10
|
- Apply predefined presets for common setups
|
|
11
11
|
|
|
@@ -41,7 +41,7 @@ proxy2vpn --help
|
|
|
41
41
|
## Quick Start
|
|
42
42
|
1. Initialize the compose file:
|
|
43
43
|
```bash
|
|
44
|
-
proxy2vpn init
|
|
44
|
+
proxy2vpn system init
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
2. Create a profile file with your VPN credentials:
|
|
@@ -67,13 +67,15 @@ proxy2vpn --help
|
|
|
67
67
|
5. View status and test connectivity:
|
|
68
68
|
```bash
|
|
69
69
|
proxy2vpn vpn list
|
|
70
|
-
proxy2vpn test vpn1
|
|
70
|
+
proxy2vpn vpn test vpn1
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
## Command overview
|
|
74
74
|
|
|
75
|
-
###
|
|
76
|
-
- `proxy2vpn init [--force]`
|
|
75
|
+
### System operations
|
|
76
|
+
- `proxy2vpn system init [--force]`
|
|
77
|
+
- `proxy2vpn system validate`
|
|
78
|
+
- `proxy2vpn system diagnose [--lines N] [--all] [--verbose] [--json]`
|
|
77
79
|
|
|
78
80
|
### Profiles
|
|
79
81
|
- `proxy2vpn profile create NAME ENV_FILE`
|
|
@@ -82,18 +84,13 @@ proxy2vpn --help
|
|
|
82
84
|
|
|
83
85
|
### VPN services
|
|
84
86
|
- `proxy2vpn vpn create NAME PROFILE [--port PORT] [--provider PROVIDER] [--location LOCATION]`
|
|
85
|
-
- `proxy2vpn vpn list`
|
|
86
|
-
- `proxy2vpn vpn start NAME`
|
|
87
|
-
- `proxy2vpn vpn stop NAME`
|
|
88
|
-
- `proxy2vpn vpn restart NAME`
|
|
87
|
+
- `proxy2vpn vpn list [--diagnose] [--ips-only]`
|
|
88
|
+
- `proxy2vpn vpn start [NAME | --all]`
|
|
89
|
+
- `proxy2vpn vpn stop [NAME | --all]`
|
|
90
|
+
- `proxy2vpn vpn restart [NAME | --all]`
|
|
89
91
|
- `proxy2vpn vpn logs NAME [--lines N] [--follow]`
|
|
90
|
-
- `proxy2vpn vpn delete NAME`
|
|
91
|
-
|
|
92
|
-
### Bulk operations
|
|
93
|
-
- `proxy2vpn bulk up`
|
|
94
|
-
- `proxy2vpn bulk down`
|
|
95
|
-
- `proxy2vpn bulk status`
|
|
96
|
-
- `proxy2vpn bulk ips`
|
|
92
|
+
- `proxy2vpn vpn delete [NAME | --all]`
|
|
93
|
+
- `proxy2vpn vpn test NAME`
|
|
97
94
|
|
|
98
95
|
### Server database
|
|
99
96
|
- `proxy2vpn servers update`
|
|
@@ -106,9 +103,6 @@ proxy2vpn --help
|
|
|
106
103
|
- `proxy2vpn preset list`
|
|
107
104
|
- `proxy2vpn preset apply PRESET SERVICE [--port PORT]`
|
|
108
105
|
|
|
109
|
-
### Testing
|
|
110
|
-
- `proxy2vpn test SERVICE` – verify that a proxy container is reachable
|
|
111
|
-
|
|
112
106
|
## Development
|
|
113
107
|
|
|
114
108
|
### Setup
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
import json
|
|
6
7
|
|
|
7
8
|
import typer
|
|
8
9
|
from .typer_ext import HelpfulTyper
|
|
@@ -19,35 +20,54 @@ app = HelpfulTyper(help="proxy2vpn command line interface")
|
|
|
19
20
|
profile_app = HelpfulTyper(help="Manage VPN profiles")
|
|
20
21
|
vpn_app = HelpfulTyper(help="Manage VPN services")
|
|
21
22
|
server_app = HelpfulTyper(help="Manage cached server lists")
|
|
23
|
+
system_app = HelpfulTyper(help="System level operations")
|
|
22
24
|
bulk_app = HelpfulTyper(help="Bulk container operations")
|
|
23
25
|
preset_app = HelpfulTyper(help="Manage presets")
|
|
24
26
|
|
|
25
27
|
app.add_typer(profile_app, name="profile")
|
|
26
28
|
app.add_typer(vpn_app, name="vpn")
|
|
27
29
|
app.add_typer(server_app, name="servers")
|
|
28
|
-
app.add_typer(
|
|
30
|
+
app.add_typer(system_app, name="system")
|
|
29
31
|
app.add_typer(preset_app, name="preset")
|
|
32
|
+
app.add_typer(bulk_app, name="bulk", hidden=True)
|
|
30
33
|
|
|
31
34
|
|
|
32
|
-
@app.callback()
|
|
35
|
+
@app.callback(invoke_without_command=True)
|
|
33
36
|
def main(
|
|
34
37
|
ctx: typer.Context,
|
|
35
38
|
compose_file: Path = typer.Option(
|
|
36
39
|
config.COMPOSE_FILE, "--compose-file", "-f", help="Path to compose file"
|
|
37
40
|
),
|
|
41
|
+
version: bool = typer.Option(
|
|
42
|
+
False,
|
|
43
|
+
"--version",
|
|
44
|
+
"-V",
|
|
45
|
+
help="Show version and exit",
|
|
46
|
+
is_eager=True,
|
|
47
|
+
),
|
|
38
48
|
):
|
|
39
49
|
"""Store global options in context."""
|
|
50
|
+
if version:
|
|
51
|
+
from . import __version__
|
|
52
|
+
|
|
53
|
+
typer.echo(__version__)
|
|
54
|
+
raise typer.Exit()
|
|
55
|
+
|
|
40
56
|
ctx.obj = ctx.obj or {}
|
|
41
57
|
ctx.obj["compose_file"] = compose_file
|
|
42
58
|
|
|
59
|
+
if ctx.invoked_subcommand is None:
|
|
60
|
+
typer.echo(ctx.get_help())
|
|
61
|
+
raise typer.Exit()
|
|
62
|
+
|
|
43
63
|
|
|
44
64
|
# ---------------------------------------------------------------------------
|
|
45
|
-
#
|
|
65
|
+
# System commands
|
|
46
66
|
# ---------------------------------------------------------------------------
|
|
47
67
|
|
|
48
68
|
|
|
49
|
-
@
|
|
50
|
-
def
|
|
69
|
+
@system_app.command("init")
|
|
70
|
+
def system_init(
|
|
51
71
|
ctx: typer.Context,
|
|
52
72
|
force: bool = typer.Option(
|
|
53
73
|
False, "--force", "-f", help="Overwrite existing compose file if it exists"
|
|
@@ -147,44 +167,96 @@ def vpn_create(
|
|
|
147
167
|
|
|
148
168
|
|
|
149
169
|
@vpn_app.command("list")
|
|
150
|
-
def vpn_list(
|
|
170
|
+
def vpn_list(
|
|
171
|
+
ctx: typer.Context,
|
|
172
|
+
diagnose: bool = typer.Option(
|
|
173
|
+
False, "--diagnose", help="Include diagnostic health scores"
|
|
174
|
+
),
|
|
175
|
+
ips_only: bool = typer.Option(
|
|
176
|
+
False, "--ips-only", help="Show only container IP addresses"
|
|
177
|
+
),
|
|
178
|
+
):
|
|
151
179
|
"""List VPN services with their status and IP addresses."""
|
|
152
180
|
|
|
153
181
|
compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
|
|
154
182
|
manager = ComposeManager(compose_file)
|
|
155
|
-
from .docker_ops import
|
|
183
|
+
from .docker_ops import (
|
|
184
|
+
get_vpn_containers,
|
|
185
|
+
get_container_ip,
|
|
186
|
+
analyze_container_logs,
|
|
187
|
+
)
|
|
188
|
+
from .diagnostics import DiagnosticAnalyzer
|
|
189
|
+
|
|
190
|
+
if ips_only:
|
|
191
|
+
containers = get_vpn_containers(all=False)
|
|
192
|
+
for container in containers:
|
|
193
|
+
ip = get_container_ip(container)
|
|
194
|
+
typer.echo(f"{container.name}: {ip}")
|
|
195
|
+
return
|
|
156
196
|
|
|
157
197
|
services = manager.list_services()
|
|
158
198
|
containers = {c.name: c for c in get_vpn_containers(all=True)}
|
|
199
|
+
analyzer = DiagnosticAnalyzer() if diagnose else None
|
|
159
200
|
|
|
160
|
-
|
|
161
|
-
|
|
201
|
+
header = f"{'NAME':<15} {'PORT':<8} {'PROFILE':<12} {'STATUS':<10} {'IP':<15}"
|
|
202
|
+
if diagnose:
|
|
203
|
+
header += f" {'HEALTH':<7}"
|
|
204
|
+
typer.echo(header)
|
|
205
|
+
typer.echo("-" * len(header))
|
|
162
206
|
for svc in services:
|
|
163
207
|
container = containers.get(svc.name)
|
|
164
208
|
if container:
|
|
165
209
|
status = container.status
|
|
166
210
|
ip = get_container_ip(container) if status == "running" else "N/A"
|
|
211
|
+
health = "N/A"
|
|
212
|
+
if diagnose:
|
|
213
|
+
results = analyze_container_logs(container.name, analyzer=analyzer)
|
|
214
|
+
health = str(analyzer.health_score(results))
|
|
167
215
|
else:
|
|
168
216
|
status = "not created"
|
|
169
217
|
ip = "N/A"
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
218
|
+
health = "N/A"
|
|
219
|
+
line = f"{svc.name:<15} {svc.port:<8} {svc.profile:<12} {status:<10} {ip:<15}"
|
|
220
|
+
if diagnose:
|
|
221
|
+
line += f" {health:<7}"
|
|
222
|
+
typer.echo(line)
|
|
173
223
|
|
|
174
224
|
|
|
175
225
|
@vpn_app.command("start")
|
|
176
|
-
def vpn_start(
|
|
177
|
-
|
|
226
|
+
def vpn_start(
|
|
227
|
+
ctx: typer.Context,
|
|
228
|
+
name: str | None = typer.Argument(None),
|
|
229
|
+
all: bool = typer.Option(False, "--all", help="Start all VPN services"),
|
|
230
|
+
):
|
|
231
|
+
"""Start one or all VPN containers."""
|
|
178
232
|
|
|
179
233
|
compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
|
|
180
234
|
manager = ComposeManager(compose_file)
|
|
235
|
+
if all and name is not None:
|
|
236
|
+
typer.echo("Cannot specify NAME when using --all", err=True)
|
|
237
|
+
raise typer.Exit(1)
|
|
238
|
+
if all:
|
|
239
|
+
from .docker_ops import start_all_vpn_containers
|
|
240
|
+
|
|
241
|
+
results = start_all_vpn_containers()
|
|
242
|
+
for svc_name, started in results:
|
|
243
|
+
if started:
|
|
244
|
+
typer.echo(f"\u2713 Started {svc_name}")
|
|
245
|
+
else:
|
|
246
|
+
typer.echo(f"\u2192 {svc_name} already running")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
if name is None:
|
|
250
|
+
typer.echo("Specify a service NAME or use --all", err=True)
|
|
251
|
+
raise typer.Exit(1)
|
|
181
252
|
try:
|
|
182
253
|
manager.get_service(name)
|
|
183
254
|
except KeyError:
|
|
184
255
|
typer.echo(f"Service '{name}' not found.", err=True)
|
|
185
256
|
raise typer.Exit(1)
|
|
186
257
|
|
|
187
|
-
from .docker_ops import start_container
|
|
258
|
+
from .docker_ops import start_container, analyze_container_logs
|
|
259
|
+
from .diagnostics import DiagnosticAnalyzer
|
|
188
260
|
|
|
189
261
|
try:
|
|
190
262
|
start_container(name)
|
|
@@ -194,22 +266,47 @@ def vpn_start(ctx: typer.Context, name: str):
|
|
|
194
266
|
raise typer.Exit(1)
|
|
195
267
|
except APIError as exc:
|
|
196
268
|
typer.echo(f"Failed to start '{name}': {exc.explanation}", err=True)
|
|
269
|
+
analyzer = DiagnosticAnalyzer()
|
|
270
|
+
results = analyze_container_logs(name, analyzer=analyzer)
|
|
271
|
+
if results:
|
|
272
|
+
typer.echo("Diagnostic hints:", err=True)
|
|
273
|
+
for res in results:
|
|
274
|
+
typer.echo(f" - {res.message}: {res.recommendation}", err=True)
|
|
197
275
|
raise typer.Exit(1)
|
|
198
276
|
|
|
199
277
|
|
|
200
278
|
@vpn_app.command("stop")
|
|
201
|
-
def vpn_stop(
|
|
202
|
-
|
|
279
|
+
def vpn_stop(
|
|
280
|
+
ctx: typer.Context,
|
|
281
|
+
name: str | None = typer.Argument(None),
|
|
282
|
+
all: bool = typer.Option(False, "--all", help="Stop all VPN services"),
|
|
283
|
+
):
|
|
284
|
+
"""Stop one or all VPN containers."""
|
|
203
285
|
|
|
204
286
|
compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
|
|
205
287
|
manager = ComposeManager(compose_file)
|
|
288
|
+
if all and name is not None:
|
|
289
|
+
typer.echo("Cannot specify NAME when using --all", err=True)
|
|
290
|
+
raise typer.Exit(1)
|
|
291
|
+
if all:
|
|
292
|
+
from .docker_ops import stop_all_vpn_containers
|
|
293
|
+
|
|
294
|
+
results = stop_all_vpn_containers()
|
|
295
|
+
for svc_name in results:
|
|
296
|
+
typer.echo(f"\u2713 Stopped {svc_name}")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
if name is None:
|
|
300
|
+
typer.echo("Specify a service NAME or use --all", err=True)
|
|
301
|
+
raise typer.Exit(1)
|
|
206
302
|
try:
|
|
207
303
|
manager.get_service(name)
|
|
208
304
|
except KeyError:
|
|
209
305
|
typer.echo(f"Service '{name}' not found.", err=True)
|
|
210
306
|
raise typer.Exit(1)
|
|
211
307
|
|
|
212
|
-
from .docker_ops import stop_container
|
|
308
|
+
from .docker_ops import stop_container, analyze_container_logs
|
|
309
|
+
from .diagnostics import DiagnosticAnalyzer
|
|
213
310
|
|
|
214
311
|
try:
|
|
215
312
|
stop_container(name)
|
|
@@ -219,22 +316,55 @@ def vpn_stop(ctx: typer.Context, name: str):
|
|
|
219
316
|
raise typer.Exit(1)
|
|
220
317
|
except APIError as exc:
|
|
221
318
|
typer.echo(f"Failed to stop '{name}': {exc.explanation}", err=True)
|
|
319
|
+
analyzer = DiagnosticAnalyzer()
|
|
320
|
+
results = analyze_container_logs(name, analyzer=analyzer)
|
|
321
|
+
if results:
|
|
322
|
+
typer.echo("Diagnostic hints:", err=True)
|
|
323
|
+
for res in results:
|
|
324
|
+
typer.echo(f" - {res.message}: {res.recommendation}", err=True)
|
|
222
325
|
raise typer.Exit(1)
|
|
223
326
|
|
|
224
327
|
|
|
225
328
|
@vpn_app.command("restart")
|
|
226
|
-
def vpn_restart(
|
|
227
|
-
|
|
329
|
+
def vpn_restart(
|
|
330
|
+
ctx: typer.Context,
|
|
331
|
+
name: str | None = typer.Argument(None),
|
|
332
|
+
all: bool = typer.Option(False, "--all", help="Restart all VPN services"),
|
|
333
|
+
):
|
|
334
|
+
"""Restart one or all VPN containers."""
|
|
228
335
|
|
|
229
336
|
compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
|
|
230
337
|
manager = ComposeManager(compose_file)
|
|
338
|
+
if all and name is not None:
|
|
339
|
+
typer.echo("Cannot specify NAME when using --all", err=True)
|
|
340
|
+
raise typer.Exit(1)
|
|
341
|
+
if all:
|
|
342
|
+
from .docker_ops import get_vpn_containers, restart_container
|
|
343
|
+
|
|
344
|
+
containers = get_vpn_containers(all=True)
|
|
345
|
+
for container in containers:
|
|
346
|
+
try:
|
|
347
|
+
restart_container(container.name)
|
|
348
|
+
typer.echo(f"\u2713 Restarted {container.name}")
|
|
349
|
+
except NotFound:
|
|
350
|
+
typer.echo(f"Container '{container.name}' does not exist.", err=True)
|
|
351
|
+
except APIError as exc:
|
|
352
|
+
typer.echo(
|
|
353
|
+
f"Failed to restart '{container.name}': {exc.explanation}", err=True
|
|
354
|
+
)
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
if name is None:
|
|
358
|
+
typer.echo("Specify a service NAME or use --all", err=True)
|
|
359
|
+
raise typer.Exit(1)
|
|
231
360
|
try:
|
|
232
361
|
manager.get_service(name)
|
|
233
362
|
except KeyError:
|
|
234
363
|
typer.echo(f"Service '{name}' not found.", err=True)
|
|
235
364
|
raise typer.Exit(1)
|
|
236
365
|
|
|
237
|
-
from .docker_ops import restart_container
|
|
366
|
+
from .docker_ops import restart_container, analyze_container_logs
|
|
367
|
+
from .diagnostics import DiagnosticAnalyzer
|
|
238
368
|
|
|
239
369
|
try:
|
|
240
370
|
restart_container(name)
|
|
@@ -244,6 +374,12 @@ def vpn_restart(ctx: typer.Context, name: str):
|
|
|
244
374
|
raise typer.Exit(1)
|
|
245
375
|
except APIError as exc:
|
|
246
376
|
typer.echo(f"Failed to restart '{name}': {exc.explanation}", err=True)
|
|
377
|
+
analyzer = DiagnosticAnalyzer()
|
|
378
|
+
results = analyze_container_logs(name, analyzer=analyzer)
|
|
379
|
+
if results:
|
|
380
|
+
typer.echo("Diagnostic hints:", err=True)
|
|
381
|
+
for res in results:
|
|
382
|
+
typer.echo(f" - {res.message}: {res.recommendation}", err=True)
|
|
247
383
|
raise typer.Exit(1)
|
|
248
384
|
|
|
249
385
|
|
|
@@ -277,13 +413,39 @@ def vpn_logs(
|
|
|
277
413
|
@vpn_app.command("delete")
|
|
278
414
|
def vpn_delete(
|
|
279
415
|
ctx: typer.Context,
|
|
280
|
-
name: str,
|
|
416
|
+
name: str | None = typer.Argument(None),
|
|
417
|
+
all: bool = typer.Option(False, "--all", help="Delete all VPN services"),
|
|
281
418
|
force: bool = typer.Option(False, "--force", "-f", help="Do not prompt"),
|
|
282
419
|
):
|
|
283
|
-
"""Delete
|
|
420
|
+
"""Delete one or all VPN services and remove their containers."""
|
|
284
421
|
|
|
285
422
|
compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
|
|
286
423
|
manager = ComposeManager(compose_file)
|
|
424
|
+
from .docker_ops import remove_container, stop_container
|
|
425
|
+
|
|
426
|
+
if all and name is not None:
|
|
427
|
+
typer.echo("Cannot specify NAME when using --all", err=True)
|
|
428
|
+
raise typer.Exit(1)
|
|
429
|
+
if all:
|
|
430
|
+
services = manager.list_services()
|
|
431
|
+
if not force and not typer.confirm("Delete all services?"):
|
|
432
|
+
raise typer.Exit()
|
|
433
|
+
for svc in services:
|
|
434
|
+
try:
|
|
435
|
+
stop_container(svc.name)
|
|
436
|
+
except NotFound:
|
|
437
|
+
pass
|
|
438
|
+
try:
|
|
439
|
+
remove_container(svc.name)
|
|
440
|
+
except NotFound:
|
|
441
|
+
pass
|
|
442
|
+
manager.remove_service(svc.name)
|
|
443
|
+
typer.echo(f"Service '{svc.name}' deleted.")
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
if name is None:
|
|
447
|
+
typer.echo("Specify a service NAME or use --all", err=True)
|
|
448
|
+
raise typer.Exit(1)
|
|
287
449
|
try:
|
|
288
450
|
manager.get_service(name)
|
|
289
451
|
except KeyError:
|
|
@@ -293,8 +455,6 @@ def vpn_delete(
|
|
|
293
455
|
if not force and not typer.confirm(f"Delete service '{name}'?"):
|
|
294
456
|
raise typer.Exit()
|
|
295
457
|
|
|
296
|
-
from .docker_ops import remove_container, stop_container
|
|
297
|
-
|
|
298
458
|
try:
|
|
299
459
|
stop_container(name)
|
|
300
460
|
except NotFound:
|
|
@@ -308,6 +468,27 @@ def vpn_delete(
|
|
|
308
468
|
typer.echo(f"Service '{name}' deleted.")
|
|
309
469
|
|
|
310
470
|
|
|
471
|
+
@vpn_app.command("test")
|
|
472
|
+
def vpn_test(ctx: typer.Context, name: str):
|
|
473
|
+
"""Test that a VPN service proxy is working."""
|
|
474
|
+
|
|
475
|
+
compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
|
|
476
|
+
manager = ComposeManager(compose_file)
|
|
477
|
+
try:
|
|
478
|
+
manager.get_service(name)
|
|
479
|
+
except KeyError:
|
|
480
|
+
typer.echo(f"Service '{name}' not found.", err=True)
|
|
481
|
+
raise typer.Exit(1)
|
|
482
|
+
|
|
483
|
+
from .docker_ops import test_vpn_connection
|
|
484
|
+
|
|
485
|
+
if test_vpn_connection(name):
|
|
486
|
+
typer.echo("VPN connection is active.")
|
|
487
|
+
else:
|
|
488
|
+
typer.echo("VPN connection failed.", err=True)
|
|
489
|
+
raise typer.Exit(1)
|
|
490
|
+
|
|
491
|
+
|
|
311
492
|
# ---------------------------------------------------------------------------
|
|
312
493
|
# Bulk container commands
|
|
313
494
|
# ---------------------------------------------------------------------------
|
|
@@ -317,6 +498,7 @@ def vpn_delete(
|
|
|
317
498
|
def bulk_up():
|
|
318
499
|
"""Start all VPN containers."""
|
|
319
500
|
|
|
501
|
+
typer.echo("Deprecated: use 'vpn start --all' instead.", err=True)
|
|
320
502
|
from .docker_ops import start_all_vpn_containers
|
|
321
503
|
|
|
322
504
|
results = start_all_vpn_containers()
|
|
@@ -331,6 +513,7 @@ def bulk_up():
|
|
|
331
513
|
def bulk_down():
|
|
332
514
|
"""Stop all running VPN containers."""
|
|
333
515
|
|
|
516
|
+
typer.echo("Deprecated: use 'vpn stop --all' instead.", err=True)
|
|
334
517
|
from .docker_ops import stop_all_vpn_containers
|
|
335
518
|
|
|
336
519
|
results = stop_all_vpn_containers()
|
|
@@ -339,30 +522,19 @@ def bulk_down():
|
|
|
339
522
|
|
|
340
523
|
|
|
341
524
|
@bulk_app.command("status")
|
|
342
|
-
def bulk_status():
|
|
525
|
+
def bulk_status(ctx: typer.Context):
|
|
343
526
|
"""Show status and IP address for VPN containers."""
|
|
344
527
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
containers = get_vpn_containers(all=True)
|
|
348
|
-
typer.echo(f"{'NAME':<15} {'STATUS':<10} {'PORT':<8} {'IP':<15}")
|
|
349
|
-
typer.echo("-" * 50)
|
|
350
|
-
for container in containers:
|
|
351
|
-
port = container.labels.get("vpn.port", "N/A")
|
|
352
|
-
ip = get_container_ip(container) if container.status == "running" else "N/A"
|
|
353
|
-
typer.echo(f"{container.name:<15} {container.status:<10} {port:<8} {ip:<15}")
|
|
528
|
+
typer.echo("Deprecated: use 'vpn list' instead.", err=True)
|
|
529
|
+
vpn_list(ctx)
|
|
354
530
|
|
|
355
531
|
|
|
356
532
|
@bulk_app.command("ips")
|
|
357
|
-
def bulk_ips():
|
|
533
|
+
def bulk_ips(ctx: typer.Context):
|
|
358
534
|
"""Show IP addresses of running VPN containers."""
|
|
359
535
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
containers = get_vpn_containers(all=False)
|
|
363
|
-
for container in containers:
|
|
364
|
-
ip = get_container_ip(container)
|
|
365
|
-
typer.echo(f"{container.name}: {ip}")
|
|
536
|
+
typer.echo("Deprecated: use 'vpn list --ips-only' instead.", err=True)
|
|
537
|
+
vpn_list(ctx, ips_only=True)
|
|
366
538
|
|
|
367
539
|
|
|
368
540
|
# ---------------------------------------------------------------------------
|
|
@@ -454,8 +626,8 @@ def preset_apply(
|
|
|
454
626
|
typer.echo(f"Service '{service}' created from preset '{preset}' on port {port}.")
|
|
455
627
|
|
|
456
628
|
|
|
457
|
-
@
|
|
458
|
-
def
|
|
629
|
+
@system_app.command("validate")
|
|
630
|
+
def system_validate(compose_file: Path = typer.Option(config.COMPOSE_FILE)):
|
|
459
631
|
"""Validate that the compose file is well formed."""
|
|
460
632
|
|
|
461
633
|
errors = validate_compose(compose_file)
|
|
@@ -466,17 +638,60 @@ def validate(compose_file: Path = typer.Option(config.COMPOSE_FILE)):
|
|
|
466
638
|
typer.echo("compose file is valid.")
|
|
467
639
|
|
|
468
640
|
|
|
469
|
-
@
|
|
470
|
-
def
|
|
471
|
-
|
|
641
|
+
@system_app.command("diagnose")
|
|
642
|
+
def system_diagnose(
|
|
643
|
+
lines: int = typer.Option(
|
|
644
|
+
100, "--lines", "-n", help="Number of log lines to analyze"
|
|
645
|
+
),
|
|
646
|
+
all_containers: bool = typer.Option(
|
|
647
|
+
False, "--all", help="Check all containers, not only problematic ones"
|
|
648
|
+
),
|
|
649
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
650
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
651
|
+
):
|
|
652
|
+
"""Diagnose VPN containers and report health."""
|
|
472
653
|
|
|
473
|
-
from .docker_ops import
|
|
654
|
+
from .docker_ops import (
|
|
655
|
+
get_problematic_containers,
|
|
656
|
+
get_vpn_containers,
|
|
657
|
+
get_container_diagnostics,
|
|
658
|
+
analyze_container_logs,
|
|
659
|
+
)
|
|
660
|
+
from .diagnostics import DiagnosticAnalyzer
|
|
474
661
|
|
|
475
|
-
|
|
476
|
-
|
|
662
|
+
analyzer = DiagnosticAnalyzer()
|
|
663
|
+
containers = (
|
|
664
|
+
get_vpn_containers(all=True)
|
|
665
|
+
if all_containers
|
|
666
|
+
else get_problematic_containers(all=True)
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
summary: list[dict[str, object]] = []
|
|
670
|
+
for container in containers:
|
|
671
|
+
diag = get_container_diagnostics(container)
|
|
672
|
+
results = analyze_container_logs(container.name, lines=lines, analyzer=analyzer)
|
|
673
|
+
score = analyzer.health_score(results)
|
|
674
|
+
entry = {
|
|
675
|
+
"container": container.name,
|
|
676
|
+
"status": diag["status"],
|
|
677
|
+
"health": score,
|
|
678
|
+
"issues": [r.message for r in results],
|
|
679
|
+
"recommendations": [r.recommendation for r in results],
|
|
680
|
+
}
|
|
681
|
+
summary.append(entry)
|
|
682
|
+
|
|
683
|
+
if json_output:
|
|
684
|
+
typer.echo(json.dumps(summary, indent=2))
|
|
477
685
|
else:
|
|
478
|
-
|
|
479
|
-
|
|
686
|
+
if not summary:
|
|
687
|
+
typer.echo("No containers to diagnose.")
|
|
688
|
+
for entry in summary:
|
|
689
|
+
typer.echo(
|
|
690
|
+
f"{entry['container']}: status={entry['status']} health={entry['health']}"
|
|
691
|
+
)
|
|
692
|
+
if verbose or entry["issues"]:
|
|
693
|
+
for issue, rec in zip(entry["issues"], entry["recommendations"]):
|
|
694
|
+
typer.echo(f" - {issue}: {rec}")
|
|
480
695
|
|
|
481
696
|
|
|
482
697
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Diagnostic analysis for proxy2vpn containers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import re
|
|
7
|
+
from typing import Iterable, List
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class DiagnosticResult:
|
|
12
|
+
"""Result of analyzing a log line."""
|
|
13
|
+
|
|
14
|
+
pattern: str
|
|
15
|
+
severity: str
|
|
16
|
+
message: str
|
|
17
|
+
recommendation: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DiagnosticAnalyzer:
|
|
21
|
+
"""Analyze container logs for common error patterns."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self.patterns: list[tuple[re.Pattern[str], str, str, str]] = [
|
|
25
|
+
(
|
|
26
|
+
re.compile(r"authentication (failed|failure)", re.I),
|
|
27
|
+
"critical",
|
|
28
|
+
"Authentication failure",
|
|
29
|
+
"Check credentials and provider configuration.",
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
re.compile(r"certificate|ssl", re.I),
|
|
33
|
+
"critical",
|
|
34
|
+
"Certificate or SSL issue",
|
|
35
|
+
"Verify certificates and TLS settings.",
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
re.compile(
|
|
39
|
+
r"(connection (refused|timed out|unreachable))|(network is unreachable)",
|
|
40
|
+
re.I,
|
|
41
|
+
),
|
|
42
|
+
"critical",
|
|
43
|
+
"Network connectivity issue",
|
|
44
|
+
"Ensure network access and proxy settings.",
|
|
45
|
+
),
|
|
46
|
+
(
|
|
47
|
+
re.compile(r"rate limit", re.I),
|
|
48
|
+
"warning",
|
|
49
|
+
"Rate limiting detected",
|
|
50
|
+
"Reduce request rate or check provider limits.",
|
|
51
|
+
),
|
|
52
|
+
(
|
|
53
|
+
re.compile(r"(dns (resolution|lookup) failed)|no such host", re.I),
|
|
54
|
+
"critical",
|
|
55
|
+
"DNS resolution failure",
|
|
56
|
+
"Check DNS configuration or server availability.",
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
re.compile(r"openvpn", re.I),
|
|
60
|
+
"info",
|
|
61
|
+
"OpenVPN error",
|
|
62
|
+
"Review OpenVPN configuration for issues.",
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
re.compile(r"wireguard", re.I),
|
|
66
|
+
"info",
|
|
67
|
+
"WireGuard error",
|
|
68
|
+
"Review WireGuard configuration for issues.",
|
|
69
|
+
),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def analyze(self, log_lines: Iterable[str]) -> List[DiagnosticResult]:
|
|
73
|
+
"""Return diagnostic results for matching log lines."""
|
|
74
|
+
|
|
75
|
+
results: List[DiagnosticResult] = []
|
|
76
|
+
for line in log_lines:
|
|
77
|
+
for pattern, severity, message, recommendation in self.patterns:
|
|
78
|
+
if pattern.search(line):
|
|
79
|
+
results.append(
|
|
80
|
+
DiagnosticResult(
|
|
81
|
+
pattern=pattern.pattern,
|
|
82
|
+
severity=severity,
|
|
83
|
+
message=message,
|
|
84
|
+
recommendation=recommendation,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
def health_score(self, results: Iterable[DiagnosticResult]) -> int:
|
|
90
|
+
"""Return a simple health score based on diagnostic results."""
|
|
91
|
+
|
|
92
|
+
score = 100
|
|
93
|
+
for res in results:
|
|
94
|
+
if res.severity == "critical":
|
|
95
|
+
score -= 40
|
|
96
|
+
elif res.severity == "warning":
|
|
97
|
+
score -= 20
|
|
98
|
+
else:
|
|
99
|
+
score -= 10
|
|
100
|
+
return max(score, 0)
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Iterable, Iterator
|
|
6
6
|
|
|
7
|
+
from .diagnostics import DiagnosticAnalyzer, DiagnosticResult
|
|
8
|
+
|
|
7
9
|
import docker
|
|
8
10
|
import requests
|
|
9
11
|
from docker.models.containers import Container
|
|
@@ -89,6 +91,49 @@ def get_vpn_containers(all: bool = False) -> list[Container]:
|
|
|
89
91
|
return client.containers.list(all=all, filters={"label": "vpn.type=vpn"})
|
|
90
92
|
|
|
91
93
|
|
|
94
|
+
def get_problematic_containers(all: bool = False) -> list[Container]:
|
|
95
|
+
"""Return containers that are not running properly."""
|
|
96
|
+
|
|
97
|
+
containers = get_vpn_containers(all=all)
|
|
98
|
+
problematic: list[Container] = []
|
|
99
|
+
for container in containers:
|
|
100
|
+
container.reload()
|
|
101
|
+
state = container.attrs.get("State", {})
|
|
102
|
+
if (
|
|
103
|
+
container.status != "running"
|
|
104
|
+
or state.get("ExitCode", 0) != 0
|
|
105
|
+
or state.get("RestartCount", 0) > 0
|
|
106
|
+
):
|
|
107
|
+
problematic.append(container)
|
|
108
|
+
return problematic
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_container_diagnostics(container: Container) -> dict:
|
|
112
|
+
"""Return diagnostic information for a container."""
|
|
113
|
+
|
|
114
|
+
container.reload()
|
|
115
|
+
state = container.attrs.get("State", {})
|
|
116
|
+
return {
|
|
117
|
+
"name": container.name,
|
|
118
|
+
"status": container.status,
|
|
119
|
+
"exit_code": state.get("ExitCode"),
|
|
120
|
+
"restart_count": state.get("RestartCount", 0),
|
|
121
|
+
"started_at": state.get("StartedAt"),
|
|
122
|
+
"finished_at": state.get("FinishedAt"),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def analyze_container_logs(
|
|
127
|
+
name: str, lines: int = 100, analyzer: DiagnosticAnalyzer | None = None
|
|
128
|
+
) -> list[DiagnosticResult]:
|
|
129
|
+
"""Analyze container logs and return diagnostic results."""
|
|
130
|
+
|
|
131
|
+
if analyzer is None:
|
|
132
|
+
analyzer = DiagnosticAnalyzer()
|
|
133
|
+
logs = list(container_logs(name, lines=lines, follow=False))
|
|
134
|
+
return analyzer.analyze(logs)
|
|
135
|
+
|
|
136
|
+
|
|
92
137
|
def start_all_vpn_containers() -> list[tuple[str, bool]]:
|
|
93
138
|
"""Start all VPN containers.
|
|
94
139
|
|
|
@@ -50,7 +50,7 @@ class HelpfulTyper(typer.Typer):
|
|
|
50
50
|
self._show_no_such_command_help(exc)
|
|
51
51
|
else:
|
|
52
52
|
# For other errors, format them nicely
|
|
53
|
-
error_msg = exc
|
|
53
|
+
error_msg = (getattr(exc, "message", "") or str(exc)).strip()
|
|
54
54
|
|
|
55
55
|
# Handle missing argument errors
|
|
56
56
|
if "Missing argument" in error_msg:
|
|
@@ -329,11 +329,11 @@ class HelpfulTyper(typer.Typer):
|
|
|
329
329
|
f"{command_path} list-providers",
|
|
330
330
|
f"{command_path} list-countries nordvpn",
|
|
331
331
|
]
|
|
332
|
-
elif "
|
|
332
|
+
elif "system" in command_path:
|
|
333
333
|
examples = [
|
|
334
|
-
f"{command_path}
|
|
335
|
-
f"{command_path}
|
|
336
|
-
f"{command_path}
|
|
334
|
+
f"{command_path} init",
|
|
335
|
+
f"{command_path} validate",
|
|
336
|
+
f"{command_path} diagnose",
|
|
337
337
|
]
|
|
338
338
|
elif "preset" in command_path:
|
|
339
339
|
examples = [
|
|
@@ -355,7 +355,7 @@ class HelpfulTyper(typer.Typer):
|
|
|
355
355
|
"vpn": f"{base_url}#vpn-management",
|
|
356
356
|
"profile": f"{base_url}#profile-management",
|
|
357
357
|
"servers": f"{base_url}#server-lists",
|
|
358
|
-
"
|
|
358
|
+
"system": f"{base_url}#system-operations",
|
|
359
359
|
"preset": f"{base_url}#presets",
|
|
360
360
|
}
|
|
361
361
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxy2vpn
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Proxy2VPN Python utilities
|
|
5
5
|
Author-email: Serhii Khalymon <serhii.khalymon@pm.me>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,7 +25,7 @@ Python command-line interface for managing multiple VPN containers with Docker.
|
|
|
25
25
|
## Features
|
|
26
26
|
- Manage VPN credentials as reusable profiles
|
|
27
27
|
- Create and control VPN services
|
|
28
|
-
-
|
|
28
|
+
- Multi-service control with `--all` flags
|
|
29
29
|
- Query and validate provider server locations
|
|
30
30
|
- Apply predefined presets for common setups
|
|
31
31
|
|
|
@@ -61,7 +61,7 @@ proxy2vpn --help
|
|
|
61
61
|
## Quick Start
|
|
62
62
|
1. Initialize the compose file:
|
|
63
63
|
```bash
|
|
64
|
-
proxy2vpn init
|
|
64
|
+
proxy2vpn system init
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
2. Create a profile file with your VPN credentials:
|
|
@@ -87,13 +87,15 @@ proxy2vpn --help
|
|
|
87
87
|
5. View status and test connectivity:
|
|
88
88
|
```bash
|
|
89
89
|
proxy2vpn vpn list
|
|
90
|
-
proxy2vpn test vpn1
|
|
90
|
+
proxy2vpn vpn test vpn1
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
## Command overview
|
|
94
94
|
|
|
95
|
-
###
|
|
96
|
-
- `proxy2vpn init [--force]`
|
|
95
|
+
### System operations
|
|
96
|
+
- `proxy2vpn system init [--force]`
|
|
97
|
+
- `proxy2vpn system validate`
|
|
98
|
+
- `proxy2vpn system diagnose [--lines N] [--all] [--verbose] [--json]`
|
|
97
99
|
|
|
98
100
|
### Profiles
|
|
99
101
|
- `proxy2vpn profile create NAME ENV_FILE`
|
|
@@ -102,18 +104,13 @@ proxy2vpn --help
|
|
|
102
104
|
|
|
103
105
|
### VPN services
|
|
104
106
|
- `proxy2vpn vpn create NAME PROFILE [--port PORT] [--provider PROVIDER] [--location LOCATION]`
|
|
105
|
-
- `proxy2vpn vpn list`
|
|
106
|
-
- `proxy2vpn vpn start NAME`
|
|
107
|
-
- `proxy2vpn vpn stop NAME`
|
|
108
|
-
- `proxy2vpn vpn restart NAME`
|
|
107
|
+
- `proxy2vpn vpn list [--diagnose] [--ips-only]`
|
|
108
|
+
- `proxy2vpn vpn start [NAME | --all]`
|
|
109
|
+
- `proxy2vpn vpn stop [NAME | --all]`
|
|
110
|
+
- `proxy2vpn vpn restart [NAME | --all]`
|
|
109
111
|
- `proxy2vpn vpn logs NAME [--lines N] [--follow]`
|
|
110
|
-
- `proxy2vpn vpn delete NAME`
|
|
111
|
-
|
|
112
|
-
### Bulk operations
|
|
113
|
-
- `proxy2vpn bulk up`
|
|
114
|
-
- `proxy2vpn bulk down`
|
|
115
|
-
- `proxy2vpn bulk status`
|
|
116
|
-
- `proxy2vpn bulk ips`
|
|
112
|
+
- `proxy2vpn vpn delete [NAME | --all]`
|
|
113
|
+
- `proxy2vpn vpn test NAME`
|
|
117
114
|
|
|
118
115
|
### Server database
|
|
119
116
|
- `proxy2vpn servers update`
|
|
@@ -126,9 +123,6 @@ proxy2vpn --help
|
|
|
126
123
|
- `proxy2vpn preset list`
|
|
127
124
|
- `proxy2vpn preset apply PRESET SERVICE [--port PORT]`
|
|
128
125
|
|
|
129
|
-
### Testing
|
|
130
|
-
- `proxy2vpn test SERVICE` – verify that a proxy container is reachable
|
|
131
|
-
|
|
132
126
|
## Development
|
|
133
127
|
|
|
134
128
|
### Setup
|
|
@@ -8,6 +8,7 @@ src/proxy2vpn/compose_manager.py
|
|
|
8
8
|
src/proxy2vpn/compose_utils.py
|
|
9
9
|
src/proxy2vpn/compose_validator.py
|
|
10
10
|
src/proxy2vpn/config.py
|
|
11
|
+
src/proxy2vpn/diagnostics.py
|
|
11
12
|
src/proxy2vpn/docker_ops.py
|
|
12
13
|
src/proxy2vpn/models.py
|
|
13
14
|
src/proxy2vpn/preset_manager.py
|
|
@@ -19,6 +20,7 @@ src/proxy2vpn.egg-info/dependency_links.txt
|
|
|
19
20
|
src/proxy2vpn.egg-info/entry_points.txt
|
|
20
21
|
src/proxy2vpn.egg-info/requires.txt
|
|
21
22
|
src/proxy2vpn.egg-info/top_level.txt
|
|
23
|
+
tests/test_cli_empty_commands.py
|
|
22
24
|
tests/test_cli_entrypoint.py
|
|
23
25
|
tests/test_compose_manager.py
|
|
24
26
|
tests/test_compose_utils.py
|
|
@@ -26,4 +28,5 @@ tests/test_compose_validator.py
|
|
|
26
28
|
tests/test_docker_ops.py
|
|
27
29
|
tests/test_init_command.py
|
|
28
30
|
tests/test_preset_manager.py
|
|
29
|
-
tests/test_server_manager.py
|
|
31
|
+
tests/test_server_manager.py
|
|
32
|
+
tests/test_version_option.py
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
|
10
|
+
env = os.environ.copy()
|
|
11
|
+
env["PYTHONPATH"] = str(repo_root / "src")
|
|
12
|
+
return subprocess.run(
|
|
13
|
+
[sys.executable, "-m", "proxy2vpn", *args],
|
|
14
|
+
capture_output=True,
|
|
15
|
+
text=True,
|
|
16
|
+
env=env,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_root_command_shows_help():
|
|
21
|
+
result = _run_cli()
|
|
22
|
+
assert result.returncode == 0
|
|
23
|
+
assert "proxy2vpn command line interface" in result.stdout
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_missing_arguments_show_error():
|
|
27
|
+
result = _run_cli("profile", "create")
|
|
28
|
+
assert result.returncode == 2
|
|
29
|
+
assert "Missing parameter" in result.stderr
|
|
@@ -20,7 +20,7 @@ def _run_proxy2vpn(args, cwd):
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def test_init_creates_compose(tmp_path):
|
|
23
|
-
result = _run_proxy2vpn(["init"], tmp_path)
|
|
23
|
+
result = _run_proxy2vpn(["system", "init"], tmp_path)
|
|
24
24
|
assert result.returncode == 0
|
|
25
25
|
compose = tmp_path / "compose.yml"
|
|
26
26
|
assert compose.exists()
|
|
@@ -34,8 +34,8 @@ def test_init_requires_force(tmp_path):
|
|
|
34
34
|
compose = tmp_path / "compose.yml"
|
|
35
35
|
compose.write_text("services: {}\n")
|
|
36
36
|
|
|
37
|
-
result = _run_proxy2vpn(["init"], tmp_path)
|
|
37
|
+
result = _run_proxy2vpn(["system", "init"], tmp_path)
|
|
38
38
|
assert result.returncode != 0
|
|
39
39
|
|
|
40
|
-
result = _run_proxy2vpn(["init", "--force"], tmp_path)
|
|
40
|
+
result = _run_proxy2vpn(["system", "init", "--force"], tmp_path)
|
|
41
41
|
assert result.returncode == 0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(repo_root / "src"))
|
|
8
|
+
import proxy2vpn # noqa
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
|
12
|
+
env = os.environ.copy()
|
|
13
|
+
env["PYTHONPATH"] = str(repo_root / "src")
|
|
14
|
+
return subprocess.run(
|
|
15
|
+
[sys.executable, "-m", "proxy2vpn", *args],
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
env=env,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_version_flag_long():
|
|
23
|
+
result = _run_cli("--version")
|
|
24
|
+
assert result.returncode == 0
|
|
25
|
+
assert result.stdout.strip() == proxy2vpn.__version__
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_version_flag_short():
|
|
29
|
+
result = _run_cli("-V")
|
|
30
|
+
assert result.returncode == 0
|
|
31
|
+
assert result.stdout.strip() == proxy2vpn.__version__
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|