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.
- {proxy2vpn-0.3.0/src/proxy2vpn.egg-info → proxy2vpn-0.4.0}/PKG-INFO +6 -2
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/README.md +3 -1
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/pyproject.toml +3 -1
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/cli.py +146 -67
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/compose_manager.py +41 -4
- proxy2vpn-0.4.0/src/proxy2vpn/diagnostics.py +112 -0
- proxy2vpn-0.4.0/src/proxy2vpn/docker_ops.py +411 -0
- proxy2vpn-0.4.0/src/proxy2vpn/logging_utils.py +64 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/models.py +12 -0
- proxy2vpn-0.4.0/src/proxy2vpn/monitoring.py +43 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/server_manager.py +17 -6
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/typer_ext.py +1 -1
- proxy2vpn-0.4.0/src/proxy2vpn/utils.py +11 -0
- proxy2vpn-0.4.0/src/proxy2vpn/validators.py +29 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0/src/proxy2vpn.egg-info}/PKG-INFO +6 -2
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/SOURCES.txt +9 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/requires.txt +2 -0
- proxy2vpn-0.4.0/tests/test_cli_empty_commands.py +29 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_compose_manager.py +13 -1
- proxy2vpn-0.4.0/tests/test_diagnostics.py +30 -0
- proxy2vpn-0.4.0/tests/test_docker_ops.py +184 -0
- proxy2vpn-0.4.0/tests/test_integration_workflow.py +49 -0
- proxy2vpn-0.4.0/tests/test_monitoring.py +57 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_preset_manager.py +4 -1
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_server_manager.py +5 -1
- proxy2vpn-0.4.0/tests/test_validators.py +24 -0
- proxy2vpn-0.3.0/src/proxy2vpn/diagnostics.py +0 -100
- proxy2vpn-0.3.0/src/proxy2vpn/docker_ops.py +0 -215
- proxy2vpn-0.3.0/tests/test_docker_ops.py +0 -48
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/LICENSE +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/setup.cfg +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/__init__.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/__main__.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/compose_utils.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/compose_validator.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/config.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn/preset_manager.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/dependency_links.txt +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/entry_points.txt +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/src/proxy2vpn.egg-info/top_level.txt +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_cli_entrypoint.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_compose_utils.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_compose_validator.py +0 -0
- {proxy2vpn-0.3.0 → proxy2vpn-0.4.0}/tests/test_init_command.py +0 -0
- {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
|
+
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.
|
|
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,
|
|
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=
|
|
94
|
+
ComposeManager.create_initial_compose(compose_file, force=overwrite)
|
|
95
|
+
logger.info("compose_initialized", extra={"file": str(compose_file)})
|
|
77
96
|
except FileExistsError:
|
|
78
|
-
|
|
79
|
-
f"Compose file '{compose_file}' already exists
|
|
80
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
raise typer.Exit(1)
|
|
308
|
+
abort(f"Service '{name}' not found")
|
|
253
309
|
|
|
254
|
-
from .docker_ops import
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|