proxy2vpn 0.3.0__tar.gz → 0.4.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 (45) hide show
  1. {proxy2vpn-0.3.0/src/proxy2vpn.egg-info → proxy2vpn-0.4.0}/PKG-INFO +6 -2
  2. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/README.md +3 -1
  3. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/pyproject.toml +3 -1
  4. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/cli.py +146 -67
  5. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/compose_manager.py +41 -4
  6. proxy2vpn-0.4.0/src/proxy2vpn/diagnostics.py +112 -0
  7. proxy2vpn-0.4.0/src/proxy2vpn/docker_ops.py +411 -0
  8. proxy2vpn-0.4.0/src/proxy2vpn/logging_utils.py +64 -0
  9. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/models.py +12 -0
  10. proxy2vpn-0.4.0/src/proxy2vpn/monitoring.py +43 -0
  11. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/server_manager.py +17 -6
  12. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/typer_ext.py +1 -1
  13. proxy2vpn-0.4.0/src/proxy2vpn/utils.py +11 -0
  14. proxy2vpn-0.4.0/src/proxy2vpn/validators.py +29 -0
  15. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0/src/proxy2vpn.egg-info}/PKG-INFO +6 -2
  16. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/SOURCES.txt +9 -0
  17. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/requires.txt +2 -0
  18. proxy2vpn-0.4.0/tests/test_cli_empty_commands.py +29 -0
  19. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_compose_manager.py +13 -1
  20. proxy2vpn-0.4.0/tests/test_diagnostics.py +30 -0
  21. proxy2vpn-0.4.0/tests/test_docker_ops.py +184 -0
  22. proxy2vpn-0.4.0/tests/test_integration_workflow.py +49 -0
  23. proxy2vpn-0.4.0/tests/test_monitoring.py +57 -0
  24. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_preset_manager.py +4 -1
  25. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_server_manager.py +5 -1
  26. proxy2vpn-0.4.0/tests/test_validators.py +24 -0
  27. proxy2vpn-0.3.0/src/proxy2vpn/diagnostics.py +0 -100
  28. proxy2vpn-0.3.0/src/proxy2vpn/docker_ops.py +0 -215
  29. proxy2vpn-0.3.0/tests/test_docker_ops.py +0 -48
  30. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/LICENSE +0 -0
  31. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/setup.cfg +0 -0
  32. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/__init__.py +0 -0
  33. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/__main__.py +0 -0
  34. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/compose_utils.py +0 -0
  35. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/compose_validator.py +0 -0
  36. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/config.py +0 -0
  37. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/preset_manager.py +0 -0
  38. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/dependency_links.txt +0 -0
  39. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/entry_points.txt +0 -0
  40. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/top_level.txt +0 -0
  41. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_cli_entrypoint.py +0 -0
  42. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_compose_utils.py +0 -0
  43. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_compose_validator.py +0 -0
  44. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_init_command.py +0 -0
  45. {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_version_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxy2vpn
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Proxy2VPN Python utilities
5
5
  Author-email: Serhii Khalymon <serhii.khalymon@pm.me>
6
6
  License-Expression: MIT
@@ -14,6 +14,8 @@ Requires-Dist: typer
14
14
  Requires-Dist: ruamel.yaml
15
15
  Requires-Dist: docker
16
16
  Requires-Dist: requests
17
+ Requires-Dist: filelock
18
+ Requires-Dist: psutil
17
19
  Provides-Extra: dev
18
20
  Requires-Dist: towncrier; extra == "dev"
19
21
  Dynamic: license-file
@@ -82,6 +84,8 @@ proxy2vpn --help
82
84
  ```bash
83
85
  proxy2vpn vpn create vpn1 myprofile --port 8888 --provider protonvpn --location "New York"
84
86
  proxy2vpn vpn start vpn1
87
+ # Use --force to recreate the container if it already exists
88
+ # proxy2vpn vpn start --force vpn1
85
89
  ```
86
90
 
87
91
  5. View status and test connectivity:
@@ -105,7 +109,7 @@ proxy2vpn --help
105
109
  ### VPN services
106
110
  - `proxy2vpn vpn create NAME PROFILE [--port PORT] [--provider PROVIDER] [--location LOCATION]`
107
111
  - `proxy2vpn vpn list [--diagnose] [--ips-only]`
108
- - `proxy2vpn vpn start [NAME | --all]`
112
+ - `proxy2vpn vpn start [NAME | --all] [--force]`
109
113
  - `proxy2vpn vpn stop [NAME | --all]`
110
114
  - `proxy2vpn vpn restart [NAME | --all]`
111
115
  - `proxy2vpn vpn logs NAME [--lines N] [--follow]`
@@ -62,6 +62,8 @@ proxy2vpn --help
62
62
  ```bash
63
63
  proxy2vpn vpn create vpn1 myprofile --port 8888 --provider protonvpn --location "New York"
64
64
  proxy2vpn vpn start vpn1
65
+ # Use --force to recreate the container if it already exists
66
+ # proxy2vpn vpn start --force vpn1
65
67
  ```
66
68
 
67
69
  5. View status and test connectivity:
@@ -85,7 +87,7 @@ proxy2vpn --help
85
87
  ### VPN services
86
88
  - `proxy2vpn vpn create NAME PROFILE [--port PORT] [--provider PROVIDER] [--location LOCATION]`
87
89
  - `proxy2vpn vpn list [--diagnose] [--ips-only]`
88
- - `proxy2vpn vpn start [NAME | --all]`
90
+ - `proxy2vpn vpn start [NAME | --all] [--force]`
89
91
  - `proxy2vpn vpn stop [NAME | --all]`
90
92
  - `proxy2vpn vpn restart [NAME | --all]`
91
93
  - `proxy2vpn vpn logs NAME [--lines N] [--follow]`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxy2vpn"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Proxy2VPN Python utilities"
9
9
  authors = [{ name = "Serhii Khalymon", email = "serhii.khalymon@pm.me" }]
10
10
  readme = "README.md"
@@ -21,6 +21,8 @@ dependencies = [
21
21
  "ruamel.yaml",
22
22
  "docker",
23
23
  "requests",
24
+ "filelock",
25
+ "psutil",
24
26
  ]
25
27
 
26
28
  [project.optional-dependencies]
@@ -14,6 +14,9 @@ from .compose_manager import ComposeManager
14
14
  from .models import Profile, VPNService
15
15
  from .server_manager import ServerManager
16
16
  from .compose_validator import validate_compose
17
+ from .utils import abort
18
+ from .validators import sanitize_name, sanitize_path, validate_port
19
+ from .logging_utils import configure_logging, get_logger
17
20
 
18
21
  app = HelpfulTyper(help="proxy2vpn command line interface")
19
22
 
@@ -31,12 +34,18 @@ app.add_typer(system_app, name="system")
31
34
  app.add_typer(preset_app, name="preset")
32
35
  app.add_typer(bulk_app, name="bulk", hidden=True)
33
36
 
37
+ logger = get_logger(__name__)
38
+
34
39
 
35
40
  @app.callback(invoke_without_command=True)
36
41
  def main(
37
42
  ctx: typer.Context,
38
43
  compose_file: Path = typer.Option(
39
- config.COMPOSE_FILE, "--compose-file", "-f", help="Path to compose file"
44
+ config.COMPOSE_FILE,
45
+ "--compose-file",
46
+ "-f",
47
+ help="Path to compose file",
48
+ callback=sanitize_path,
40
49
  ),
41
50
  version: bool = typer.Option(
42
51
  False,
@@ -47,6 +56,7 @@ def main(
47
56
  ),
48
57
  ):
49
58
  """Store global options in context."""
59
+ configure_logging()
50
60
  if version:
51
61
  from . import __version__
52
62
 
@@ -56,6 +66,10 @@ def main(
56
66
  ctx.obj = ctx.obj or {}
57
67
  ctx.obj["compose_file"] = compose_file
58
68
 
69
+ if ctx.invoked_subcommand is None:
70
+ typer.echo(ctx.get_help())
71
+ raise typer.Exit()
72
+
59
73
 
60
74
  # ---------------------------------------------------------------------------
61
75
  # System commands
@@ -72,14 +86,18 @@ def system_init(
72
86
  """Generate an initial compose.yml file."""
73
87
 
74
88
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
89
+ overwrite = force
90
+ if compose_file.exists() and not force:
91
+ typer.confirm(f"Overwrite existing '{compose_file}'?", abort=True)
92
+ overwrite = True
75
93
  try:
76
- ComposeManager.create_initial_compose(compose_file, force=force)
94
+ ComposeManager.create_initial_compose(compose_file, force=overwrite)
95
+ logger.info("compose_initialized", extra={"file": str(compose_file)})
77
96
  except FileExistsError:
78
- typer.echo(
79
- f"Compose file '{compose_file}' already exists. Use --force to overwrite.",
80
- err=True,
97
+ abort(
98
+ f"Compose file '{compose_file}' already exists",
99
+ "Use --force to overwrite",
81
100
  )
82
- raise typer.Exit(1)
83
101
  typer.echo(f"Created '{compose_file}'.")
84
102
 
85
103
 
@@ -89,13 +107,23 @@ def system_init(
89
107
 
90
108
 
91
109
  @profile_app.command("create")
92
- def profile_create(ctx: typer.Context, name: str, env_file: Path):
110
+ def profile_create(
111
+ ctx: typer.Context,
112
+ name: str = typer.Argument(..., callback=sanitize_name),
113
+ env_file: Path = typer.Argument(..., callback=sanitize_path),
114
+ ):
93
115
  """Create a new VPN profile."""
94
116
 
117
+ if not env_file.exists():
118
+ abort(
119
+ f"Environment file '{env_file}' not found",
120
+ "Create the file before creating the profile",
121
+ )
95
122
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
96
123
  manager = ComposeManager(compose_file)
97
124
  profile = Profile(name=name, env_file=str(env_file))
98
125
  manager.add_profile(profile)
126
+ logger.info("profile_created", extra={"name": name})
99
127
  typer.echo(f"Profile '{name}' created.")
100
128
 
101
129
 
@@ -110,11 +138,21 @@ def profile_list(ctx: typer.Context):
110
138
 
111
139
 
112
140
  @profile_app.command("delete")
113
- def profile_delete(ctx: typer.Context, name: str):
141
+ def profile_delete(
142
+ ctx: typer.Context,
143
+ name: str = typer.Argument(..., callback=sanitize_name),
144
+ force: bool = typer.Option(False, "--force", "-f", help="Do not prompt"),
145
+ ):
114
146
  """Delete a profile by NAME."""
115
147
 
116
148
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
117
149
  manager = ComposeManager(compose_file)
150
+ try:
151
+ manager.get_profile(name)
152
+ except KeyError:
153
+ abort(f"Profile '{name}' not found")
154
+ if not force:
155
+ typer.confirm(f"Delete profile '{name}'?", abort=True)
118
156
  manager.remove_profile(name)
119
157
  typer.echo(f"Profile '{name}' deleted.")
120
158
 
@@ -127,9 +165,13 @@ def profile_delete(ctx: typer.Context, name: str):
127
165
  @vpn_app.command("create")
128
166
  def vpn_create(
129
167
  ctx: typer.Context,
130
- name: str,
131
- profile: str,
132
- port: int = typer.Option(0, help="Host port to expose; 0 for auto"),
168
+ name: str = typer.Argument(..., callback=sanitize_name),
169
+ profile: str = typer.Argument(..., callback=sanitize_name),
170
+ port: int = typer.Option(
171
+ 0,
172
+ callback=validate_port,
173
+ help="Host port to expose; 0 for auto",
174
+ ),
133
175
  provider: str = typer.Option(config.DEFAULT_PROVIDER),
134
176
  location: str = typer.Option("", help="Optional location, e.g. city"),
135
177
  ):
@@ -137,9 +179,17 @@ def vpn_create(
137
179
 
138
180
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
139
181
  manager = ComposeManager(compose_file)
182
+ try:
183
+ manager.get_profile(profile)
184
+ except KeyError:
185
+ abort(
186
+ f"Profile '{profile}' not found",
187
+ "Create it with 'proxy2vpn profile create'",
188
+ )
140
189
  if port == 0:
141
190
  port = manager.next_available_port(config.DEFAULT_PORT_START)
142
191
  env = {"VPN_SERVICE_PROVIDER": provider}
192
+ location = location.strip()
143
193
  if location:
144
194
  env["SERVER_CITIES"] = location
145
195
  labels = {
@@ -199,7 +249,8 @@ def vpn_list(
199
249
  header += f" {'HEALTH':<7}"
200
250
  typer.echo(header)
201
251
  typer.echo("-" * len(header))
202
- for svc in services:
252
+ iterator = typer.progressbar(services, label="Checking") if diagnose else services
253
+ for svc in iterator:
203
254
  container = containers.get(svc.name)
204
255
  if container:
205
256
  status = container.status
@@ -221,60 +272,97 @@ def vpn_list(
221
272
  @vpn_app.command("start")
222
273
  def vpn_start(
223
274
  ctx: typer.Context,
224
- name: str | None = typer.Argument(None),
275
+ name: str | None = typer.Argument(
276
+ None, callback=lambda v: sanitize_name(v) if v else None
277
+ ),
225
278
  all: bool = typer.Option(False, "--all", help="Start all VPN services"),
279
+ force: bool = typer.Option(
280
+ False, "--force", "-f", help="Recreate container if it already exists"
281
+ ),
226
282
  ):
227
283
  """Start one or all VPN containers."""
228
284
 
229
285
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
230
286
  manager = ComposeManager(compose_file)
231
287
  if all and name is not None:
232
- typer.echo("Cannot specify NAME when using --all", err=True)
233
- raise typer.Exit(1)
288
+ abort("Cannot specify NAME when using --all")
234
289
  if all:
235
290
  from .docker_ops import start_all_vpn_containers
236
291
 
237
- results = start_all_vpn_containers()
292
+ results = start_all_vpn_containers(manager, force=force)
238
293
  for svc_name, started in results:
239
294
  if started:
240
- typer.echo(f"\u2713 Started {svc_name}")
295
+ if force:
296
+ typer.echo(f"\u2713 Recreated and started {svc_name}")
297
+ else:
298
+ typer.echo(f"\u2713 Started {svc_name}")
241
299
  else:
242
300
  typer.echo(f"\u2192 {svc_name} already running")
243
301
  return
244
302
 
245
303
  if name is None:
246
- typer.echo("Specify a service NAME or use --all", err=True)
247
- raise typer.Exit(1)
304
+ abort("Specify a service NAME or use --all")
248
305
  try:
249
306
  manager.get_service(name)
250
307
  except KeyError:
251
- typer.echo(f"Service '{name}' not found.", err=True)
252
- raise typer.Exit(1)
308
+ abort(f"Service '{name}' not found")
253
309
 
254
- from .docker_ops import start_container, analyze_container_logs
310
+ from .docker_ops import (
311
+ start_container,
312
+ analyze_container_logs,
313
+ create_vpn_container,
314
+ recreate_vpn_container,
315
+ )
255
316
  from .diagnostics import DiagnosticAnalyzer
256
317
 
318
+ svc = manager.get_service(name)
319
+ profile = manager.get_profile(svc.profile)
320
+ if force:
321
+ try:
322
+ recreate_vpn_container(svc, profile)
323
+ start_container(name)
324
+ typer.echo(f"Recreated and started '{name}'.")
325
+ except APIError as exc:
326
+ analyzer = DiagnosticAnalyzer()
327
+ results = analyze_container_logs(name, analyzer=analyzer)
328
+ if results:
329
+ typer.echo("Diagnostic hints:", err=True)
330
+ for res in results:
331
+ typer.echo(f" - {res.message}: {res.recommendation}", err=True)
332
+ abort(f"Failed to start '{name}': {exc.explanation}")
333
+ return
257
334
  try:
258
335
  start_container(name)
259
336
  typer.echo(f"Started '{name}'.")
260
337
  except NotFound:
261
- typer.echo(f"Container '{name}' does not exist.", err=True)
262
- raise typer.Exit(1)
338
+ try:
339
+ create_vpn_container(svc, profile)
340
+ start_container(name)
341
+ typer.echo(f"Created and started '{name}'.")
342
+ except APIError as exc:
343
+ analyzer = DiagnosticAnalyzer()
344
+ results = analyze_container_logs(name, analyzer=analyzer)
345
+ if results:
346
+ typer.echo("Diagnostic hints:", err=True)
347
+ for res in results:
348
+ typer.echo(f" - {res.message}: {res.recommendation}", err=True)
349
+ abort(f"Failed to start '{name}': {exc.explanation}")
263
350
  except APIError as exc:
264
- typer.echo(f"Failed to start '{name}': {exc.explanation}", err=True)
265
351
  analyzer = DiagnosticAnalyzer()
266
352
  results = analyze_container_logs(name, analyzer=analyzer)
267
353
  if results:
268
354
  typer.echo("Diagnostic hints:", err=True)
269
355
  for res in results:
270
356
  typer.echo(f" - {res.message}: {res.recommendation}", err=True)
271
- raise typer.Exit(1)
357
+ abort(f"Failed to start '{name}': {exc.explanation}")
272
358
 
273
359
 
274
360
  @vpn_app.command("stop")
275
361
  def vpn_stop(
276
362
  ctx: typer.Context,
277
- name: str | None = typer.Argument(None),
363
+ name: str | None = typer.Argument(
364
+ None, callback=lambda v: sanitize_name(v) if v else None
365
+ ),
278
366
  all: bool = typer.Option(False, "--all", help="Stop all VPN services"),
279
367
  ):
280
368
  """Stop one or all VPN containers."""
@@ -282,8 +370,7 @@ def vpn_stop(
282
370
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
283
371
  manager = ComposeManager(compose_file)
284
372
  if all and name is not None:
285
- typer.echo("Cannot specify NAME when using --all", err=True)
286
- raise typer.Exit(1)
373
+ abort("Cannot specify NAME when using --all")
287
374
  if all:
288
375
  from .docker_ops import stop_all_vpn_containers
289
376
 
@@ -293,13 +380,11 @@ def vpn_stop(
293
380
  return
294
381
 
295
382
  if name is None:
296
- typer.echo("Specify a service NAME or use --all", err=True)
297
- raise typer.Exit(1)
383
+ abort("Specify a service NAME or use --all")
298
384
  try:
299
385
  manager.get_service(name)
300
386
  except KeyError:
301
- typer.echo(f"Service '{name}' not found.", err=True)
302
- raise typer.Exit(1)
387
+ abort(f"Service '{name}' not found")
303
388
 
304
389
  from .docker_ops import stop_container, analyze_container_logs
305
390
  from .diagnostics import DiagnosticAnalyzer
@@ -308,23 +393,23 @@ def vpn_stop(
308
393
  stop_container(name)
309
394
  typer.echo(f"Stopped '{name}'.")
310
395
  except NotFound:
311
- typer.echo(f"Container '{name}' does not exist.", err=True)
312
- raise typer.Exit(1)
396
+ abort(f"Container '{name}' does not exist")
313
397
  except APIError as exc:
314
- typer.echo(f"Failed to stop '{name}': {exc.explanation}", err=True)
315
398
  analyzer = DiagnosticAnalyzer()
316
399
  results = analyze_container_logs(name, analyzer=analyzer)
317
400
  if results:
318
401
  typer.echo("Diagnostic hints:", err=True)
319
402
  for res in results:
320
403
  typer.echo(f" - {res.message}: {res.recommendation}", err=True)
321
- raise typer.Exit(1)
404
+ abort(f"Failed to stop '{name}': {exc.explanation}")
322
405
 
323
406
 
324
407
  @vpn_app.command("restart")
325
408
  def vpn_restart(
326
409
  ctx: typer.Context,
327
- name: str | None = typer.Argument(None),
410
+ name: str | None = typer.Argument(
411
+ None, callback=lambda v: sanitize_name(v) if v else None
412
+ ),
328
413
  all: bool = typer.Option(False, "--all", help="Restart all VPN services"),
329
414
  ):
330
415
  """Restart one or all VPN containers."""
@@ -332,8 +417,7 @@ def vpn_restart(
332
417
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
333
418
  manager = ComposeManager(compose_file)
334
419
  if all and name is not None:
335
- typer.echo("Cannot specify NAME when using --all", err=True)
336
- raise typer.Exit(1)
420
+ abort("Cannot specify NAME when using --all")
337
421
  if all:
338
422
  from .docker_ops import get_vpn_containers, restart_container
339
423
 
@@ -351,13 +435,11 @@ def vpn_restart(
351
435
  return
352
436
 
353
437
  if name is None:
354
- typer.echo("Specify a service NAME or use --all", err=True)
355
- raise typer.Exit(1)
438
+ abort("Specify a service NAME or use --all")
356
439
  try:
357
440
  manager.get_service(name)
358
441
  except KeyError:
359
- typer.echo(f"Service '{name}' not found.", err=True)
360
- raise typer.Exit(1)
442
+ abort(f"Service '{name}' not found")
361
443
 
362
444
  from .docker_ops import restart_container, analyze_container_logs
363
445
  from .diagnostics import DiagnosticAnalyzer
@@ -366,35 +448,33 @@ def vpn_restart(
366
448
  restart_container(name)
367
449
  typer.echo(f"Restarted '{name}'.")
368
450
  except NotFound:
369
- typer.echo(f"Container '{name}' does not exist.", err=True)
370
- raise typer.Exit(1)
451
+ abort(f"Container '{name}' does not exist")
371
452
  except APIError as exc:
372
- typer.echo(f"Failed to restart '{name}': {exc.explanation}", err=True)
373
453
  analyzer = DiagnosticAnalyzer()
374
454
  results = analyze_container_logs(name, analyzer=analyzer)
375
455
  if results:
376
456
  typer.echo("Diagnostic hints:", err=True)
377
457
  for res in results:
378
458
  typer.echo(f" - {res.message}: {res.recommendation}", err=True)
379
- raise typer.Exit(1)
459
+ abort(f"Failed to restart '{name}': {exc.explanation}")
380
460
 
381
461
 
382
462
  @vpn_app.command("logs")
383
463
  def vpn_logs(
384
464
  ctx: typer.Context,
385
- name: str,
465
+ name: str = typer.Argument(..., callback=sanitize_name),
386
466
  lines: int = typer.Option(100, "--lines", help="Number of lines to show"),
387
467
  follow: bool = typer.Option(False, "--follow", help="Follow log output"),
388
468
  ):
389
469
  """Show logs for a VPN container."""
390
-
470
+ if lines <= 0:
471
+ abort("LINES must be positive")
391
472
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
392
473
  manager = ComposeManager(compose_file)
393
474
  try:
394
475
  manager.get_service(name)
395
476
  except KeyError:
396
- typer.echo(f"Service '{name}' not found.", err=True)
397
- raise typer.Exit(1)
477
+ abort(f"Service '{name}' not found")
398
478
 
399
479
  from .docker_ops import container_logs
400
480
 
@@ -402,14 +482,15 @@ def vpn_logs(
402
482
  for line in container_logs(name, lines=lines, follow=follow):
403
483
  typer.echo(line)
404
484
  except NotFound:
405
- typer.echo(f"Container '{name}' does not exist.", err=True)
406
- raise typer.Exit(1)
485
+ abort(f"Container '{name}' does not exist")
407
486
 
408
487
 
409
488
  @vpn_app.command("delete")
410
489
  def vpn_delete(
411
490
  ctx: typer.Context,
412
- name: str | None = typer.Argument(None),
491
+ name: str | None = typer.Argument(
492
+ None, callback=lambda v: sanitize_name(v) if v else None
493
+ ),
413
494
  all: bool = typer.Option(False, "--all", help="Delete all VPN services"),
414
495
  force: bool = typer.Option(False, "--force", "-f", help="Do not prompt"),
415
496
  ):
@@ -420,8 +501,7 @@ def vpn_delete(
420
501
  from .docker_ops import remove_container, stop_container
421
502
 
422
503
  if all and name is not None:
423
- typer.echo("Cannot specify NAME when using --all", err=True)
424
- raise typer.Exit(1)
504
+ abort("Cannot specify NAME when using --all")
425
505
  if all:
426
506
  services = manager.list_services()
427
507
  if not force and not typer.confirm("Delete all services?"):
@@ -440,13 +520,11 @@ def vpn_delete(
440
520
  return
441
521
 
442
522
  if name is None:
443
- typer.echo("Specify a service NAME or use --all", err=True)
444
- raise typer.Exit(1)
523
+ abort("Specify a service NAME or use --all")
445
524
  try:
446
525
  manager.get_service(name)
447
526
  except KeyError:
448
- typer.echo(f"Service '{name}' not found.", err=True)
449
- raise typer.Exit(1)
527
+ abort(f"Service '{name}' not found")
450
528
 
451
529
  if not force and not typer.confirm(f"Delete service '{name}'?"):
452
530
  raise typer.Exit()
@@ -465,7 +543,9 @@ def vpn_delete(
465
543
 
466
544
 
467
545
  @vpn_app.command("test")
468
- def vpn_test(ctx: typer.Context, name: str):
546
+ def vpn_test(
547
+ ctx: typer.Context, name: str = typer.Argument(..., callback=sanitize_name)
548
+ ):
469
549
  """Test that a VPN service proxy is working."""
470
550
 
471
551
  compose_file: Path = ctx.obj.get("compose_file", config.COMPOSE_FILE)
@@ -473,16 +553,14 @@ def vpn_test(ctx: typer.Context, name: str):
473
553
  try:
474
554
  manager.get_service(name)
475
555
  except KeyError:
476
- typer.echo(f"Service '{name}' not found.", err=True)
477
- raise typer.Exit(1)
556
+ abort(f"Service '{name}' not found")
478
557
 
479
558
  from .docker_ops import test_vpn_connection
480
559
 
481
560
  if test_vpn_connection(name):
482
561
  typer.echo("VPN connection is active.")
483
562
  else:
484
- typer.echo("VPN connection failed.", err=True)
485
- raise typer.Exit(1)
563
+ abort("VPN connection failed", "Check container logs")
486
564
 
487
565
 
488
566
  # ---------------------------------------------------------------------------
@@ -497,7 +575,8 @@ def bulk_up():
497
575
  typer.echo("Deprecated: use 'vpn start --all' instead.", err=True)
498
576
  from .docker_ops import start_all_vpn_containers
499
577
 
500
- results = start_all_vpn_containers()
578
+ manager = ComposeManager(config.COMPOSE_FILE)
579
+ results = start_all_vpn_containers(manager)
501
580
  for name, started in results:
502
581
  if started:
503
582
  typer.echo(f"\u2713 Started {name}")
@@ -2,11 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
4
  from typing import Any, Dict, List
5
+ import os
6
+ import shutil
5
7
 
8
+ from filelock import FileLock
6
9
  from ruamel.yaml import YAML
7
10
  from ruamel.yaml.comments import CommentedMap
8
11
 
9
12
  from .models import Profile, VPNService
13
+ from .compose_validator import validate_compose
10
14
 
11
15
  # Minimal compose template used when initializing a new project
12
16
  INITIAL_COMPOSE_TEMPLATE = """\
@@ -28,6 +32,7 @@ class ComposeManager:
28
32
  def __init__(self, compose_path: Path) -> None:
29
33
  self.compose_path = compose_path
30
34
  self.yaml = YAML()
35
+ self.lock = FileLock(str(compose_path) + ".lock")
31
36
  self.data: Dict[str, Any] = self._load()
32
37
 
33
38
  def _load(self) -> Dict[str, Any]:
@@ -35,8 +40,23 @@ class ComposeManager:
35
40
  raise FileNotFoundError(
36
41
  f"compose file '{self.compose_path}' not found. Run 'proxy2vpn init' to create it."
37
42
  )
38
- with self.compose_path.open("r", encoding="utf-8") as f:
39
- return self.yaml.load(f)
43
+ backup_path = self.compose_path.with_suffix(self.compose_path.suffix + ".bak")
44
+ with self.lock:
45
+ try:
46
+ with self.compose_path.open("r", encoding="utf-8") as f:
47
+ data = self.yaml.load(f)
48
+ if not isinstance(data, dict):
49
+ raise ValueError("compose file does not contain a mapping")
50
+ # basic validation: ensure YAML structure can be parsed
51
+ validate_compose(self.compose_path)
52
+ return data
53
+ except Exception:
54
+ if backup_path.exists():
55
+ with backup_path.open("r", encoding="utf-8") as f:
56
+ data = self.yaml.load(f)
57
+ shutil.copy2(backup_path, self.compose_path)
58
+ return data
59
+ raise
40
60
 
41
61
  @staticmethod
42
62
  def create_initial_compose(path: Path, force: bool = False) -> None:
@@ -145,5 +165,22 @@ class ComposeManager:
145
165
  return port
146
166
 
147
167
  def save(self) -> None:
148
- with self.compose_path.open("w", encoding="utf-8") as f:
149
- self.yaml.dump(self.data, f)
168
+ backup_path = self.compose_path.with_suffix(self.compose_path.suffix + ".bak")
169
+ tmp_path = self.compose_path.with_suffix(self.compose_path.suffix + ".tmp")
170
+ with self.lock:
171
+ try:
172
+ if self.compose_path.exists():
173
+ shutil.copy2(self.compose_path, backup_path)
174
+ with tmp_path.open("w", encoding="utf-8") as f:
175
+ self.yaml.dump(self.data, f)
176
+ os.replace(tmp_path, self.compose_path)
177
+ except Exception:
178
+ if backup_path.exists():
179
+ shutil.copy2(backup_path, self.compose_path)
180
+ raise
181
+ finally:
182
+ if tmp_path.exists():
183
+ try:
184
+ tmp_path.unlink()
185
+ except FileNotFoundError:
186
+ pass