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.
Files changed (34) hide show
  1. {proxy2vpn-0.2.0/src/proxy2vpn.egg-info → proxy2vpn-0.3.1}/PKG-INFO +14 -20
  2. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/README.md +13 -19
  3. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/pyproject.toml +1 -1
  4. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/cli.py +267 -52
  5. proxy2vpn-0.3.1/src/proxy2vpn/diagnostics.py +100 -0
  6. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/docker_ops.py +45 -0
  7. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/typer_ext.py +6 -6
  8. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1/src/proxy2vpn.egg-info}/PKG-INFO +14 -20
  9. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/SOURCES.txt +4 -1
  10. proxy2vpn-0.3.1/tests/test_cli_empty_commands.py +29 -0
  11. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_init_command.py +3 -3
  12. proxy2vpn-0.3.1/tests/test_version_option.py +31 -0
  13. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/LICENSE +0 -0
  14. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/setup.cfg +0 -0
  15. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/__init__.py +0 -0
  16. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/__main__.py +0 -0
  17. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/compose_manager.py +0 -0
  18. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/compose_utils.py +0 -0
  19. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/compose_validator.py +0 -0
  20. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/config.py +0 -0
  21. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/models.py +0 -0
  22. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/preset_manager.py +0 -0
  23. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn/server_manager.py +0 -0
  24. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/dependency_links.txt +0 -0
  25. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/entry_points.txt +0 -0
  26. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/requires.txt +0 -0
  27. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/src/proxy2vpn.egg-info/top_level.txt +0 -0
  28. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_cli_entrypoint.py +0 -0
  29. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_compose_manager.py +0 -0
  30. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_compose_utils.py +0 -0
  31. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_compose_validator.py +0 -0
  32. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_docker_ops.py +0 -0
  33. {proxy2vpn-0.2.0 → proxy2vpn-0.3.1}/tests/test_preset_manager.py +0 -0
  34. {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.2.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
- - Bulk start/stop operations
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
- ### Initialization
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
- - Bulk start/stop operations
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
- ### Initialization
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxy2vpn"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Proxy2VPN Python utilities"
9
9
  authors = [{ name = "Serhii Khalymon", email = "serhii.khalymon@pm.me" }]
10
10
  readme = "README.md"
@@ -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(bulk_app, name="bulk")
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
- # Initialization command
65
+ # System commands
46
66
  # ---------------------------------------------------------------------------
47
67
 
48
68
 
49
- @app.command("init")
50
- def init_command(
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(ctx: typer.Context):
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 get_vpn_containers, get_container_ip
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
- typer.echo(f"{'NAME':<15} {'PORT':<8} {'PROFILE':<12} {'STATUS':<10} {'IP':<15}")
161
- typer.echo("-" * 65)
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
- typer.echo(
171
- f"{svc.name:<15} {svc.port:<8} {svc.profile:<12} {status:<10} {ip:<15}"
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(ctx: typer.Context, name: str):
177
- """Start the container for a VPN service."""
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(ctx: typer.Context, name: str):
202
- """Stop the container for a VPN service."""
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(ctx: typer.Context, name: str):
227
- """Restart a VPN container."""
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 a VPN service and remove its container."""
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
- from .docker_ops import get_vpn_containers, get_container_ip
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
- from .docker_ops import get_vpn_containers, get_container_ip
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
- @app.command("validate")
458
- def validate(compose_file: Path = typer.Option(config.COMPOSE_FILE)):
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
- @app.command("test")
470
- def test(service: str):
471
- """Test that a VPN service proxy is working."""
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 test_vpn_connection
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
- if test_vpn_connection(service):
476
- typer.echo("VPN connection is active.")
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
- typer.echo("VPN connection failed.", err=True)
479
- raise typer.Exit(1)
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.message.strip()
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 "bulk" in command_path:
332
+ elif "system" in command_path:
333
333
  examples = [
334
- f"{command_path} up",
335
- f"{command_path} down",
336
- f"{command_path} status",
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
- "bulk": f"{base_url}#bulk-operations",
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.2.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
- - Bulk start/stop operations
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
- ### Initialization
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