proxy2vpn 0.2.0__tar.gz → 0.3.0__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 (33) hide show
  1. {proxy2vpn-0.2.0/src/proxy2vpn.egg-info → proxy2vpn-0.3.0}/PKG-INFO +14 -20
  2. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/README.md +13 -19
  3. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/pyproject.toml +1 -1
  4. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/cli.py +263 -52
  5. proxy2vpn-0.3.0/src/proxy2vpn/diagnostics.py +100 -0
  6. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/docker_ops.py +45 -0
  7. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/typer_ext.py +5 -5
  8. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0/src/proxy2vpn.egg-info}/PKG-INFO +14 -20
  9. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn.egg-info/SOURCES.txt +3 -1
  10. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/tests/test_init_command.py +3 -3
  11. proxy2vpn-0.3.0/tests/test_version_option.py +31 -0
  12. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/LICENSE +0 -0
  13. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/setup.cfg +0 -0
  14. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/__init__.py +0 -0
  15. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/__main__.py +0 -0
  16. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/compose_manager.py +0 -0
  17. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/compose_utils.py +0 -0
  18. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/compose_validator.py +0 -0
  19. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/config.py +0 -0
  20. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/models.py +0 -0
  21. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/preset_manager.py +0 -0
  22. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn/server_manager.py +0 -0
  23. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn.egg-info/dependency_links.txt +0 -0
  24. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn.egg-info/entry_points.txt +0 -0
  25. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn.egg-info/requires.txt +0 -0
  26. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/src/proxy2vpn.egg-info/top_level.txt +0 -0
  27. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/tests/test_cli_entrypoint.py +0 -0
  28. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/tests/test_compose_manager.py +0 -0
  29. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/tests/test_compose_utils.py +0 -0
  30. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/tests/test_compose_validator.py +0 -0
  31. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/tests/test_docker_ops.py +0 -0
  32. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/tests/test_preset_manager.py +0 -0
  33. {proxy2vpn-0.2.0 → proxy2vpn-0.3.0}/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.0
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.0"
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,50 @@ 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
 
43
59
 
44
60
  # ---------------------------------------------------------------------------
45
- # Initialization command
61
+ # System commands
46
62
  # ---------------------------------------------------------------------------
47
63
 
48
64
 
49
- @app.command("init")
50
- def init_command(
65
+ @system_app.command("init")
66
+ def system_init(
51
67
  ctx: typer.Context,
52
68
  force: bool = typer.Option(
53
69
  False, "--force", "-f", help="Overwrite existing compose file if it exists"
@@ -147,44 +163,96 @@ def vpn_create(
147
163
 
148
164
 
149
165
  @vpn_app.command("list")
150
- def vpn_list(ctx: typer.Context):
166
+ def vpn_list(
167
+ ctx: typer.Context,
168
+ diagnose: bool = typer.Option(
169
+ False, "--diagnose", help="Include diagnostic health scores"
170
+ ),
171
+ ips_only: bool = typer.Option(
172
+ False, "--ips-only", help="Show only container IP addresses"
173
+ ),
174
+ ):
151
175
  """List VPN services with their status and IP addresses."""
152
176
 
153
177
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
154
178
  manager = ComposeManager(compose_file)
155
- from .docker_ops import get_vpn_containers, get_container_ip
179
+ from .docker_ops import (
180
+ get_vpn_containers,
181
+ get_container_ip,
182
+ analyze_container_logs,
183
+ )
184
+ from .diagnostics import DiagnosticAnalyzer
185
+
186
+ if ips_only:
187
+ containers = get_vpn_containers(all=False)
188
+ for container in containers:
189
+ ip = get_container_ip(container)
190
+ typer.echo(f"{container.name}: {ip}")
191
+ return
156
192
 
157
193
  services = manager.list_services()
158
194
  containers = {c.name: c for c in get_vpn_containers(all=True)}
195
+ analyzer = DiagnosticAnalyzer() if diagnose else None
159
196
 
160
- typer.echo(f"{'NAME':<15} {'PORT':<8} {'PROFILE':<12} {'STATUS':<10} {'IP':<15}")
161
- typer.echo("-" * 65)
197
+ header = f"{'NAME':<15} {'PORT':<8} {'PROFILE':<12} {'STATUS':<10} {'IP':<15}"
198
+ if diagnose:
199
+ header += f" {'HEALTH':<7}"
200
+ typer.echo(header)
201
+ typer.echo("-" * len(header))
162
202
  for svc in services:
163
203
  container = containers.get(svc.name)
164
204
  if container:
165
205
  status = container.status
166
206
  ip = get_container_ip(container) if status == "running" else "N/A"
207
+ health = "N/A"
208
+ if diagnose:
209
+ results = analyze_container_logs(container.name, analyzer=analyzer)
210
+ health = str(analyzer.health_score(results))
167
211
  else:
168
212
  status = "not created"
169
213
  ip = "N/A"
170
- typer.echo(
171
- f"{svc.name:<15} {svc.port:<8} {svc.profile:<12} {status:<10} {ip:<15}"
172
- )
214
+ health = "N/A"
215
+ line = f"{svc.name:<15} {svc.port:<8} {svc.profile:<12} {status:<10} {ip:<15}"
216
+ if diagnose:
217
+ line += f" {health:<7}"
218
+ typer.echo(line)
173
219
 
174
220
 
175
221
  @vpn_app.command("start")
176
- def vpn_start(ctx: typer.Context, name: str):
177
- """Start the container for a VPN service."""
222
+ def vpn_start(
223
+ ctx: typer.Context,
224
+ name: str | None = typer.Argument(None),
225
+ all: bool = typer.Option(False, "--all", help="Start all VPN services"),
226
+ ):
227
+ """Start one or all VPN containers."""
178
228
 
179
229
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
180
230
  manager = ComposeManager(compose_file)
231
+ if all and name is not None:
232
+ typer.echo("Cannot specify NAME when using --all", err=True)
233
+ raise typer.Exit(1)
234
+ if all:
235
+ from .docker_ops import start_all_vpn_containers
236
+
237
+ results = start_all_vpn_containers()
238
+ for svc_name, started in results:
239
+ if started:
240
+ typer.echo(f"\u2713 Started {svc_name}")
241
+ else:
242
+ typer.echo(f"\u2192 {svc_name} already running")
243
+ return
244
+
245
+ if name is None:
246
+ typer.echo("Specify a service NAME or use --all", err=True)
247
+ raise typer.Exit(1)
181
248
  try:
182
249
  manager.get_service(name)
183
250
  except KeyError:
184
251
  typer.echo(f"Service '{name}' not found.", err=True)
185
252
  raise typer.Exit(1)
186
253
 
187
- from .docker_ops import start_container
254
+ from .docker_ops import start_container, analyze_container_logs
255
+ from .diagnostics import DiagnosticAnalyzer
188
256
 
189
257
  try:
190
258
  start_container(name)
@@ -194,22 +262,47 @@ def vpn_start(ctx: typer.Context, name: str):
194
262
  raise typer.Exit(1)
195
263
  except APIError as exc:
196
264
  typer.echo(f"Failed to start '{name}': {exc.explanation}", err=True)
265
+ analyzer = DiagnosticAnalyzer()
266
+ results = analyze_container_logs(name, analyzer=analyzer)
267
+ if results:
268
+ typer.echo("Diagnostic hints:", err=True)
269
+ for res in results:
270
+ typer.echo(f" - {res.message}: {res.recommendation}", err=True)
197
271
  raise typer.Exit(1)
198
272
 
199
273
 
200
274
  @vpn_app.command("stop")
201
- def vpn_stop(ctx: typer.Context, name: str):
202
- """Stop the container for a VPN service."""
275
+ def vpn_stop(
276
+ ctx: typer.Context,
277
+ name: str | None = typer.Argument(None),
278
+ all: bool = typer.Option(False, "--all", help="Stop all VPN services"),
279
+ ):
280
+ """Stop one or all VPN containers."""
203
281
 
204
282
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
205
283
  manager = ComposeManager(compose_file)
284
+ if all and name is not None:
285
+ typer.echo("Cannot specify NAME when using --all", err=True)
286
+ raise typer.Exit(1)
287
+ if all:
288
+ from .docker_ops import stop_all_vpn_containers
289
+
290
+ results = stop_all_vpn_containers()
291
+ for svc_name in results:
292
+ typer.echo(f"\u2713 Stopped {svc_name}")
293
+ return
294
+
295
+ if name is None:
296
+ typer.echo("Specify a service NAME or use --all", err=True)
297
+ raise typer.Exit(1)
206
298
  try:
207
299
  manager.get_service(name)
208
300
  except KeyError:
209
301
  typer.echo(f"Service '{name}' not found.", err=True)
210
302
  raise typer.Exit(1)
211
303
 
212
- from .docker_ops import stop_container
304
+ from .docker_ops import stop_container, analyze_container_logs
305
+ from .diagnostics import DiagnosticAnalyzer
213
306
 
214
307
  try:
215
308
  stop_container(name)
@@ -219,22 +312,55 @@ def vpn_stop(ctx: typer.Context, name: str):
219
312
  raise typer.Exit(1)
220
313
  except APIError as exc:
221
314
  typer.echo(f"Failed to stop '{name}': {exc.explanation}", err=True)
315
+ analyzer = DiagnosticAnalyzer()
316
+ results = analyze_container_logs(name, analyzer=analyzer)
317
+ if results:
318
+ typer.echo("Diagnostic hints:", err=True)
319
+ for res in results:
320
+ typer.echo(f" - {res.message}: {res.recommendation}", err=True)
222
321
  raise typer.Exit(1)
223
322
 
224
323
 
225
324
  @vpn_app.command("restart")
226
- def vpn_restart(ctx: typer.Context, name: str):
227
- """Restart a VPN container."""
325
+ def vpn_restart(
326
+ ctx: typer.Context,
327
+ name: str | None = typer.Argument(None),
328
+ all: bool = typer.Option(False, "--all", help="Restart all VPN services"),
329
+ ):
330
+ """Restart one or all VPN containers."""
228
331
 
229
332
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
230
333
  manager = ComposeManager(compose_file)
334
+ if all and name is not None:
335
+ typer.echo("Cannot specify NAME when using --all", err=True)
336
+ raise typer.Exit(1)
337
+ if all:
338
+ from .docker_ops import get_vpn_containers, restart_container
339
+
340
+ containers = get_vpn_containers(all=True)
341
+ for container in containers:
342
+ try:
343
+ restart_container(container.name)
344
+ typer.echo(f"\u2713 Restarted {container.name}")
345
+ except NotFound:
346
+ typer.echo(f"Container '{container.name}' does not exist.", err=True)
347
+ except APIError as exc:
348
+ typer.echo(
349
+ f"Failed to restart '{container.name}': {exc.explanation}", err=True
350
+ )
351
+ return
352
+
353
+ if name is None:
354
+ typer.echo("Specify a service NAME or use --all", err=True)
355
+ raise typer.Exit(1)
231
356
  try:
232
357
  manager.get_service(name)
233
358
  except KeyError:
234
359
  typer.echo(f"Service '{name}' not found.", err=True)
235
360
  raise typer.Exit(1)
236
361
 
237
- from .docker_ops import restart_container
362
+ from .docker_ops import restart_container, analyze_container_logs
363
+ from .diagnostics import DiagnosticAnalyzer
238
364
 
239
365
  try:
240
366
  restart_container(name)
@@ -244,6 +370,12 @@ def vpn_restart(ctx: typer.Context, name: str):
244
370
  raise typer.Exit(1)
245
371
  except APIError as exc:
246
372
  typer.echo(f"Failed to restart '{name}': {exc.explanation}", err=True)
373
+ analyzer = DiagnosticAnalyzer()
374
+ results = analyze_container_logs(name, analyzer=analyzer)
375
+ if results:
376
+ typer.echo("Diagnostic hints:", err=True)
377
+ for res in results:
378
+ typer.echo(f" - {res.message}: {res.recommendation}", err=True)
247
379
  raise typer.Exit(1)
248
380
 
249
381
 
@@ -277,13 +409,39 @@ def vpn_logs(
277
409
  @vpn_app.command("delete")
278
410
  def vpn_delete(
279
411
  ctx: typer.Context,
280
- name: str,
412
+ name: str | None = typer.Argument(None),
413
+ all: bool = typer.Option(False, "--all", help="Delete all VPN services"),
281
414
  force: bool = typer.Option(False, "--force", "-f", help="Do not prompt"),
282
415
  ):
283
- """Delete a VPN service and remove its container."""
416
+ """Delete one or all VPN services and remove their containers."""
284
417
 
285
418
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
286
419
  manager = ComposeManager(compose_file)
420
+ from .docker_ops import remove_container, stop_container
421
+
422
+ if all and name is not None:
423
+ typer.echo("Cannot specify NAME when using --all", err=True)
424
+ raise typer.Exit(1)
425
+ if all:
426
+ services = manager.list_services()
427
+ if not force and not typer.confirm("Delete all services?"):
428
+ raise typer.Exit()
429
+ for svc in services:
430
+ try:
431
+ stop_container(svc.name)
432
+ except NotFound:
433
+ pass
434
+ try:
435
+ remove_container(svc.name)
436
+ except NotFound:
437
+ pass
438
+ manager.remove_service(svc.name)
439
+ typer.echo(f"Service '{svc.name}' deleted.")
440
+ return
441
+
442
+ if name is None:
443
+ typer.echo("Specify a service NAME or use --all", err=True)
444
+ raise typer.Exit(1)
287
445
  try:
288
446
  manager.get_service(name)
289
447
  except KeyError:
@@ -293,8 +451,6 @@ def vpn_delete(
293
451
  if not force and not typer.confirm(f"Delete service '{name}'?"):
294
452
  raise typer.Exit()
295
453
 
296
- from .docker_ops import remove_container, stop_container
297
-
298
454
  try:
299
455
  stop_container(name)
300
456
  except NotFound:
@@ -308,6 +464,27 @@ def vpn_delete(
308
464
  typer.echo(f"Service '{name}' deleted.")
309
465
 
310
466
 
467
+ @vpn_app.command("test")
468
+ def vpn_test(ctx: typer.Context, name: str):
469
+ """Test that a VPN service proxy is working."""
470
+
471
+ compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
472
+ manager = ComposeManager(compose_file)
473
+ try:
474
+ manager.get_service(name)
475
+ except KeyError:
476
+ typer.echo(f"Service '{name}' not found.", err=True)
477
+ raise typer.Exit(1)
478
+
479
+ from .docker_ops import test_vpn_connection
480
+
481
+ if test_vpn_connection(name):
482
+ typer.echo("VPN connection is active.")
483
+ else:
484
+ typer.echo("VPN connection failed.", err=True)
485
+ raise typer.Exit(1)
486
+
487
+
311
488
  # ---------------------------------------------------------------------------
312
489
  # Bulk container commands
313
490
  # ---------------------------------------------------------------------------
@@ -317,6 +494,7 @@ def vpn_delete(
317
494
  def bulk_up():
318
495
  """Start all VPN containers."""
319
496
 
497
+ typer.echo("Deprecated: use 'vpn start --all' instead.", err=True)
320
498
  from .docker_ops import start_all_vpn_containers
321
499
 
322
500
  results = start_all_vpn_containers()
@@ -331,6 +509,7 @@ def bulk_up():
331
509
  def bulk_down():
332
510
  """Stop all running VPN containers."""
333
511
 
512
+ typer.echo("Deprecated: use 'vpn stop --all' instead.", err=True)
334
513
  from .docker_ops import stop_all_vpn_containers
335
514
 
336
515
  results = stop_all_vpn_containers()
@@ -339,30 +518,19 @@ def bulk_down():
339
518
 
340
519
 
341
520
  @bulk_app.command("status")
342
- def bulk_status():
521
+ def bulk_status(ctx: typer.Context):
343
522
  """Show status and IP address for VPN containers."""
344
523
 
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}")
524
+ typer.echo("Deprecated: use 'vpn list' instead.", err=True)
525
+ vpn_list(ctx)
354
526
 
355
527
 
356
528
  @bulk_app.command("ips")
357
- def bulk_ips():
529
+ def bulk_ips(ctx: typer.Context):
358
530
  """Show IP addresses of running VPN containers."""
359
531
 
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}")
532
+ typer.echo("Deprecated: use 'vpn list --ips-only' instead.", err=True)
533
+ vpn_list(ctx, ips_only=True)
366
534
 
367
535
 
368
536
  # ---------------------------------------------------------------------------
@@ -454,8 +622,8 @@ def preset_apply(
454
622
  typer.echo(f"Service '{service}' created from preset '{preset}' on port {port}.")
455
623
 
456
624
 
457
- @app.command("validate")
458
- def validate(compose_file: Path = typer.Option(config.COMPOSE_FILE)):
625
+ @system_app.command("validate")
626
+ def system_validate(compose_file: Path = typer.Option(config.COMPOSE_FILE)):
459
627
  """Validate that the compose file is well formed."""
460
628
 
461
629
  errors = validate_compose(compose_file)
@@ -466,17 +634,60 @@ def validate(compose_file: Path = typer.Option(config.COMPOSE_FILE)):
466
634
  typer.echo("compose file is valid.")
467
635
 
468
636
 
469
- @app.command("test")
470
- def test(service: str):
471
- """Test that a VPN service proxy is working."""
637
+ @system_app.command("diagnose")
638
+ def system_diagnose(
639
+ lines: int = typer.Option(
640
+ 100, "--lines", "-n", help="Number of log lines to analyze"
641
+ ),
642
+ all_containers: bool = typer.Option(
643
+ False, "--all", help="Check all containers, not only problematic ones"
644
+ ),
645
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
646
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
647
+ ):
648
+ """Diagnose VPN containers and report health."""
472
649
 
473
- from .docker_ops import test_vpn_connection
650
+ from .docker_ops import (
651
+ get_problematic_containers,
652
+ get_vpn_containers,
653
+ get_container_diagnostics,
654
+ analyze_container_logs,
655
+ )
656
+ from .diagnostics import DiagnosticAnalyzer
474
657
 
475
- if test_vpn_connection(service):
476
- typer.echo("VPN connection is active.")
658
+ analyzer = DiagnosticAnalyzer()
659
+ containers = (
660
+ get_vpn_containers(all=True)
661
+ if all_containers
662
+ else get_problematic_containers(all=True)
663
+ )
664
+
665
+ summary: list[dict[str, object]] = []
666
+ for container in containers:
667
+ diag = get_container_diagnostics(container)
668
+ results = analyze_container_logs(container.name, lines=lines, analyzer=analyzer)
669
+ score = analyzer.health_score(results)
670
+ entry = {
671
+ "container": container.name,
672
+ "status": diag["status"],
673
+ "health": score,
674
+ "issues": [r.message for r in results],
675
+ "recommendations": [r.recommendation for r in results],
676
+ }
677
+ summary.append(entry)
678
+
679
+ if json_output:
680
+ typer.echo(json.dumps(summary, indent=2))
477
681
  else:
478
- typer.echo("VPN connection failed.", err=True)
479
- raise typer.Exit(1)
682
+ if not summary:
683
+ typer.echo("No containers to diagnose.")
684
+ for entry in summary:
685
+ typer.echo(
686
+ f"{entry['container']}: status={entry['status']} health={entry['health']}"
687
+ )
688
+ if verbose or entry["issues"]:
689
+ for issue, rec in zip(entry["issues"], entry["recommendations"]):
690
+ typer.echo(f" - {issue}: {rec}")
480
691
 
481
692
 
482
693
  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
 
@@ -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.0
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
@@ -26,4 +27,5 @@ tests/test_compose_validator.py
26
27
  tests/test_docker_ops.py
27
28
  tests/test_init_command.py
28
29
  tests/test_preset_manager.py
29
- tests/test_server_manager.py
30
+ tests/test_server_manager.py
31
+ tests/test_version_option.py
@@ -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