proxy2vpn 0.1.3__py3-none-any.whl
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/__init__.py +20 -0
- proxy2vpn/cli.py +416 -0
- proxy2vpn/compose_manager.py +115 -0
- proxy2vpn/compose_utils.py +38 -0
- proxy2vpn/config.py +34 -0
- proxy2vpn/docker_ops.py +170 -0
- proxy2vpn/models.py +98 -0
- proxy2vpn/preset_manager.py +48 -0
- proxy2vpn/server_manager.py +111 -0
- proxy2vpn/typer_ext.py +379 -0
- proxy2vpn-0.1.3.dist-info/METADATA +143 -0
- proxy2vpn-0.1.3.dist-info/RECORD +15 -0
- proxy2vpn-0.1.3.dist-info/WHEEL +5 -0
- proxy2vpn-0.1.3.dist-info/entry_points.txt +2 -0
- proxy2vpn-0.1.3.dist-info/top_level.txt +1 -0
proxy2vpn/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""proxy2vpn Python package."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
__version__ = version("proxy2vpn")
|
|
7
|
+
except Exception:
|
|
8
|
+
# Fallback when package is not installed (development mode)
|
|
9
|
+
__version__ = "dev"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"cli",
|
|
13
|
+
"compose_utils",
|
|
14
|
+
"docker_ops",
|
|
15
|
+
"compose_manager",
|
|
16
|
+
"models",
|
|
17
|
+
"config",
|
|
18
|
+
"server_manager",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
proxy2vpn/cli.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Command line interface for proxy2vpn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from .typer_ext import HelpfulTyper
|
|
9
|
+
from docker.errors import APIError, NotFound
|
|
10
|
+
|
|
11
|
+
from . import config
|
|
12
|
+
from .compose_manager import ComposeManager
|
|
13
|
+
from .models import Profile, VPNService
|
|
14
|
+
from .server_manager import ServerManager
|
|
15
|
+
|
|
16
|
+
app = HelpfulTyper(help="proxy2vpn command line interface")
|
|
17
|
+
|
|
18
|
+
profile_app = HelpfulTyper(help="Manage VPN profiles")
|
|
19
|
+
vpn_app = HelpfulTyper(help="Manage VPN services")
|
|
20
|
+
server_app = HelpfulTyper(help="Manage cached server lists")
|
|
21
|
+
bulk_app = HelpfulTyper(help="Bulk container operations")
|
|
22
|
+
preset_app = HelpfulTyper(help="Manage presets")
|
|
23
|
+
|
|
24
|
+
app.add_typer(profile_app, name="profile")
|
|
25
|
+
app.add_typer(vpn_app, name="vpn")
|
|
26
|
+
app.add_typer(server_app, name="servers")
|
|
27
|
+
app.add_typer(bulk_app, name="bulk")
|
|
28
|
+
app.add_typer(preset_app, name="preset")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Profile commands
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@profile_app.command("create")
|
|
37
|
+
def profile_create(name: str, env_file: Path):
|
|
38
|
+
"""Create a new VPN profile."""
|
|
39
|
+
|
|
40
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
41
|
+
profile = Profile(name=name, env_file=str(env_file))
|
|
42
|
+
manager.add_profile(profile)
|
|
43
|
+
typer.echo(f"Profile '{name}' created.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@profile_app.command("list")
|
|
47
|
+
def profile_list():
|
|
48
|
+
"""List available profiles."""
|
|
49
|
+
|
|
50
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
51
|
+
for profile in manager.list_profiles():
|
|
52
|
+
typer.echo(profile.name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@profile_app.command("delete")
|
|
56
|
+
def profile_delete(name: str):
|
|
57
|
+
"""Delete a profile by NAME."""
|
|
58
|
+
|
|
59
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
60
|
+
manager.remove_profile(name)
|
|
61
|
+
typer.echo(f"Profile '{name}' deleted.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# VPN container commands
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@vpn_app.command("create")
|
|
70
|
+
def vpn_create(
|
|
71
|
+
name: str,
|
|
72
|
+
profile: str,
|
|
73
|
+
port: int = typer.Option(0, help="Host port to expose; 0 for auto"),
|
|
74
|
+
provider: str = typer.Option(config.DEFAULT_PROVIDER),
|
|
75
|
+
location: str = typer.Option("", help="Optional location, e.g. city"),
|
|
76
|
+
):
|
|
77
|
+
"""Create a VPN service entry in the compose file."""
|
|
78
|
+
|
|
79
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
80
|
+
if port == 0:
|
|
81
|
+
port = manager.next_available_port(config.DEFAULT_PORT_START)
|
|
82
|
+
env = {"VPN_SERVICE_PROVIDER": provider}
|
|
83
|
+
if location:
|
|
84
|
+
env["SERVER_CITIES"] = location
|
|
85
|
+
labels = {
|
|
86
|
+
"vpn.type": "vpn",
|
|
87
|
+
"vpn.port": str(port),
|
|
88
|
+
"vpn.provider": provider,
|
|
89
|
+
"vpn.profile": profile,
|
|
90
|
+
"vpn.location": location,
|
|
91
|
+
}
|
|
92
|
+
svc = VPNService(
|
|
93
|
+
name=name,
|
|
94
|
+
port=port,
|
|
95
|
+
provider=provider,
|
|
96
|
+
profile=profile,
|
|
97
|
+
location=location,
|
|
98
|
+
environment=env,
|
|
99
|
+
labels=labels,
|
|
100
|
+
)
|
|
101
|
+
manager.add_service(svc)
|
|
102
|
+
typer.echo(f"Service '{name}' created on port {port}.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@vpn_app.command("list")
|
|
106
|
+
def vpn_list():
|
|
107
|
+
"""List VPN services with their status and IP addresses."""
|
|
108
|
+
|
|
109
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
110
|
+
from .docker_ops import get_vpn_containers, get_container_ip
|
|
111
|
+
|
|
112
|
+
services = manager.list_services()
|
|
113
|
+
containers = {c.name: c for c in get_vpn_containers(all=True)}
|
|
114
|
+
|
|
115
|
+
typer.echo(f"{'NAME':<15} {'PORT':<8} {'PROFILE':<12} {'STATUS':<10} {'IP':<15}")
|
|
116
|
+
typer.echo("-" * 65)
|
|
117
|
+
for svc in services:
|
|
118
|
+
container = containers.get(svc.name)
|
|
119
|
+
if container:
|
|
120
|
+
status = container.status
|
|
121
|
+
ip = get_container_ip(container) if status == "running" else "N/A"
|
|
122
|
+
else:
|
|
123
|
+
status = "not created"
|
|
124
|
+
ip = "N/A"
|
|
125
|
+
typer.echo(
|
|
126
|
+
f"{svc.name:<15} {svc.port:<8} {svc.profile:<12} {status:<10} {ip:<15}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@vpn_app.command("start")
|
|
131
|
+
def vpn_start(name: str):
|
|
132
|
+
"""Start the container for a VPN service."""
|
|
133
|
+
|
|
134
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
135
|
+
try:
|
|
136
|
+
manager.get_service(name)
|
|
137
|
+
except KeyError:
|
|
138
|
+
typer.echo(f"Service '{name}' not found.", err=True)
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
|
|
141
|
+
from .docker_ops import start_container
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
start_container(name)
|
|
145
|
+
typer.echo(f"Started '{name}'.")
|
|
146
|
+
except NotFound:
|
|
147
|
+
typer.echo(f"Container '{name}' does not exist.", err=True)
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
except APIError as exc:
|
|
150
|
+
typer.echo(f"Failed to start '{name}': {exc.explanation}", err=True)
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@vpn_app.command("stop")
|
|
155
|
+
def vpn_stop(name: str):
|
|
156
|
+
"""Stop the container for a VPN service."""
|
|
157
|
+
|
|
158
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
159
|
+
try:
|
|
160
|
+
manager.get_service(name)
|
|
161
|
+
except KeyError:
|
|
162
|
+
typer.echo(f"Service '{name}' not found.", err=True)
|
|
163
|
+
raise typer.Exit(1)
|
|
164
|
+
|
|
165
|
+
from .docker_ops import stop_container
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
stop_container(name)
|
|
169
|
+
typer.echo(f"Stopped '{name}'.")
|
|
170
|
+
except NotFound:
|
|
171
|
+
typer.echo(f"Container '{name}' does not exist.", err=True)
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
except APIError as exc:
|
|
174
|
+
typer.echo(f"Failed to stop '{name}': {exc.explanation}", err=True)
|
|
175
|
+
raise typer.Exit(1)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@vpn_app.command("restart")
|
|
179
|
+
def vpn_restart(name: str):
|
|
180
|
+
"""Restart a VPN container."""
|
|
181
|
+
|
|
182
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
183
|
+
try:
|
|
184
|
+
manager.get_service(name)
|
|
185
|
+
except KeyError:
|
|
186
|
+
typer.echo(f"Service '{name}' not found.", err=True)
|
|
187
|
+
raise typer.Exit(1)
|
|
188
|
+
|
|
189
|
+
from .docker_ops import restart_container
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
restart_container(name)
|
|
193
|
+
typer.echo(f"Restarted '{name}'.")
|
|
194
|
+
except NotFound:
|
|
195
|
+
typer.echo(f"Container '{name}' does not exist.", err=True)
|
|
196
|
+
raise typer.Exit(1)
|
|
197
|
+
except APIError as exc:
|
|
198
|
+
typer.echo(f"Failed to restart '{name}': {exc.explanation}", err=True)
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@vpn_app.command("logs")
|
|
203
|
+
def vpn_logs(
|
|
204
|
+
name: str,
|
|
205
|
+
lines: int = typer.Option(100, "--lines", help="Number of lines to show"),
|
|
206
|
+
follow: bool = typer.Option(False, "--follow", help="Follow log output"),
|
|
207
|
+
):
|
|
208
|
+
"""Show logs for a VPN container."""
|
|
209
|
+
|
|
210
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
211
|
+
try:
|
|
212
|
+
manager.get_service(name)
|
|
213
|
+
except KeyError:
|
|
214
|
+
typer.echo(f"Service '{name}' not found.", err=True)
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
|
|
217
|
+
from .docker_ops import container_logs
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
for line in container_logs(name, lines=lines, follow=follow):
|
|
221
|
+
typer.echo(line)
|
|
222
|
+
except NotFound:
|
|
223
|
+
typer.echo(f"Container '{name}' does not exist.", err=True)
|
|
224
|
+
raise typer.Exit(1)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@vpn_app.command("delete")
|
|
228
|
+
def vpn_delete(
|
|
229
|
+
name: str, force: bool = typer.Option(False, "--force", "-f", help="Do not prompt")
|
|
230
|
+
):
|
|
231
|
+
"""Delete a VPN service and remove its container."""
|
|
232
|
+
|
|
233
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
234
|
+
try:
|
|
235
|
+
manager.get_service(name)
|
|
236
|
+
except KeyError:
|
|
237
|
+
typer.echo(f"Service '{name}' not found.", err=True)
|
|
238
|
+
raise typer.Exit(1)
|
|
239
|
+
|
|
240
|
+
if not force and not typer.confirm(f"Delete service '{name}'?"):
|
|
241
|
+
raise typer.Exit()
|
|
242
|
+
|
|
243
|
+
from .docker_ops import remove_container, stop_container
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
stop_container(name)
|
|
247
|
+
except NotFound:
|
|
248
|
+
pass
|
|
249
|
+
try:
|
|
250
|
+
remove_container(name)
|
|
251
|
+
except NotFound:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
manager.remove_service(name)
|
|
255
|
+
typer.echo(f"Service '{name}' deleted.")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# Bulk container commands
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@bulk_app.command("up")
|
|
264
|
+
def bulk_up():
|
|
265
|
+
"""Start all VPN containers."""
|
|
266
|
+
|
|
267
|
+
from .docker_ops import start_all_vpn_containers
|
|
268
|
+
|
|
269
|
+
results = start_all_vpn_containers()
|
|
270
|
+
for name, started in results:
|
|
271
|
+
if started:
|
|
272
|
+
typer.echo(f"\u2713 Started {name}")
|
|
273
|
+
else:
|
|
274
|
+
typer.echo(f"\u2192 {name} already running")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@bulk_app.command("down")
|
|
278
|
+
def bulk_down():
|
|
279
|
+
"""Stop all running VPN containers."""
|
|
280
|
+
|
|
281
|
+
from .docker_ops import stop_all_vpn_containers
|
|
282
|
+
|
|
283
|
+
results = stop_all_vpn_containers()
|
|
284
|
+
for name in results:
|
|
285
|
+
typer.echo(f"\u2713 Stopped {name}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@bulk_app.command("status")
|
|
289
|
+
def bulk_status():
|
|
290
|
+
"""Show status and IP address for VPN containers."""
|
|
291
|
+
|
|
292
|
+
from .docker_ops import get_vpn_containers, get_container_ip
|
|
293
|
+
|
|
294
|
+
containers = get_vpn_containers(all=True)
|
|
295
|
+
typer.echo(f"{'NAME':<15} {'STATUS':<10} {'PORT':<8} {'IP':<15}")
|
|
296
|
+
typer.echo("-" * 50)
|
|
297
|
+
for container in containers:
|
|
298
|
+
port = container.labels.get("vpn.port", "N/A")
|
|
299
|
+
ip = get_container_ip(container) if container.status == "running" else "N/A"
|
|
300
|
+
typer.echo(f"{container.name:<15} {container.status:<10} {port:<8} {ip:<15}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@bulk_app.command("ips")
|
|
304
|
+
def bulk_ips():
|
|
305
|
+
"""Show IP addresses of running VPN containers."""
|
|
306
|
+
|
|
307
|
+
from .docker_ops import get_vpn_containers, get_container_ip
|
|
308
|
+
|
|
309
|
+
containers = get_vpn_containers(all=False)
|
|
310
|
+
for container in containers:
|
|
311
|
+
ip = get_container_ip(container)
|
|
312
|
+
typer.echo(f"{container.name}: {ip}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Server commands
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@server_app.command("update")
|
|
321
|
+
def servers_update(
|
|
322
|
+
insecure: bool = typer.Option(
|
|
323
|
+
False,
|
|
324
|
+
"--insecure",
|
|
325
|
+
help="Disable SSL certificate verification (for troubleshooting)",
|
|
326
|
+
),
|
|
327
|
+
):
|
|
328
|
+
"""Download and cache the latest server list."""
|
|
329
|
+
|
|
330
|
+
mgr = ServerManager()
|
|
331
|
+
verify = not insecure
|
|
332
|
+
mgr.update_servers(verify=verify)
|
|
333
|
+
typer.echo("Server list updated.")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@server_app.command("list-providers")
|
|
337
|
+
def servers_list_providers():
|
|
338
|
+
"""List VPN providers from the cached server list."""
|
|
339
|
+
|
|
340
|
+
mgr = ServerManager()
|
|
341
|
+
for provider in mgr.list_providers():
|
|
342
|
+
typer.echo(provider)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@server_app.command("list-countries")
|
|
346
|
+
def servers_list_countries(provider: str):
|
|
347
|
+
"""List countries for a VPN provider."""
|
|
348
|
+
|
|
349
|
+
mgr = ServerManager()
|
|
350
|
+
for country in mgr.list_countries(provider):
|
|
351
|
+
typer.echo(country)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@server_app.command("list-cities")
|
|
355
|
+
def servers_list_cities(provider: str, country: str):
|
|
356
|
+
"""List cities for a VPN provider in a country."""
|
|
357
|
+
|
|
358
|
+
mgr = ServerManager()
|
|
359
|
+
for city in mgr.list_cities(provider, country):
|
|
360
|
+
typer.echo(city)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@server_app.command("validate-location")
|
|
364
|
+
def servers_validate_location(provider: str, location: str):
|
|
365
|
+
"""Validate that a location exists for a provider."""
|
|
366
|
+
|
|
367
|
+
mgr = ServerManager()
|
|
368
|
+
if mgr.validate_location(provider, location):
|
|
369
|
+
typer.echo("valid")
|
|
370
|
+
else:
|
|
371
|
+
typer.echo("invalid", err=True)
|
|
372
|
+
raise typer.Exit(1)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@preset_app.command("list")
|
|
376
|
+
def preset_list():
|
|
377
|
+
"""List available presets."""
|
|
378
|
+
|
|
379
|
+
from .preset_manager import list_available_presets
|
|
380
|
+
|
|
381
|
+
for preset in list_available_presets():
|
|
382
|
+
typer.echo(preset)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@preset_app.command("apply")
|
|
386
|
+
def preset_apply(
|
|
387
|
+
preset: str,
|
|
388
|
+
service: str,
|
|
389
|
+
port: int = typer.Option(0, help="Host port to expose; 0 for auto"),
|
|
390
|
+
):
|
|
391
|
+
"""Create a VPN service from a preset."""
|
|
392
|
+
|
|
393
|
+
manager = ComposeManager(config.COMPOSE_FILE)
|
|
394
|
+
if port == 0:
|
|
395
|
+
port = manager.next_available_port(config.DEFAULT_PORT_START)
|
|
396
|
+
from .preset_manager import apply_preset
|
|
397
|
+
|
|
398
|
+
apply_preset(preset, service, port)
|
|
399
|
+
typer.echo(f"Service '{service}' created from preset '{preset}' on port {port}.")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@app.command("test")
|
|
403
|
+
def test(service: str):
|
|
404
|
+
"""Test that a VPN service proxy is working."""
|
|
405
|
+
|
|
406
|
+
from .docker_ops import test_vpn_connection
|
|
407
|
+
|
|
408
|
+
if test_vpn_connection(service):
|
|
409
|
+
typer.echo("VPN connection is active.")
|
|
410
|
+
else:
|
|
411
|
+
typer.echo("VPN connection failed.", err=True)
|
|
412
|
+
raise typer.Exit(1)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
if __name__ == "__main__":
|
|
416
|
+
app()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
from ruamel.yaml import YAML
|
|
7
|
+
from ruamel.yaml.comments import CommentedMap
|
|
8
|
+
|
|
9
|
+
from .models import Profile, VPNService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ComposeManager:
|
|
13
|
+
"""Manage docker-compose files for VPN services."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, compose_path: Path) -> None:
|
|
16
|
+
self.compose_path = compose_path
|
|
17
|
+
self.yaml = YAML()
|
|
18
|
+
self.data: Dict[str, Any] = self._load()
|
|
19
|
+
|
|
20
|
+
def _load(self) -> Dict[str, Any]:
|
|
21
|
+
with self.compose_path.open("r", encoding="utf-8") as f:
|
|
22
|
+
return self.yaml.load(f)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def config(self) -> Dict[str, Any]:
|
|
26
|
+
"""Return global configuration stored under x-config."""
|
|
27
|
+
return self.data.get("x-config", {})
|
|
28
|
+
|
|
29
|
+
def list_services(self) -> List[VPNService]:
|
|
30
|
+
services = self.data.get("services", {})
|
|
31
|
+
return [
|
|
32
|
+
VPNService.from_compose_service(name, svc) for name, svc in services.items()
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def get_service(self, name: str) -> VPNService:
|
|
36
|
+
services = self.data.get("services", {})
|
|
37
|
+
if name not in services:
|
|
38
|
+
raise KeyError(f"Service '{name}' not found")
|
|
39
|
+
return VPNService.from_compose_service(name, services[name])
|
|
40
|
+
|
|
41
|
+
def add_service(self, service: VPNService) -> None:
|
|
42
|
+
services = self.data.setdefault("services", {})
|
|
43
|
+
if service.name in services:
|
|
44
|
+
raise ValueError(f"Service '{service.name}' already exists")
|
|
45
|
+
profile_key = f"x-vpn-base-{service.profile}"
|
|
46
|
+
profile_map = self.data.get(profile_key)
|
|
47
|
+
if profile_map is None:
|
|
48
|
+
raise KeyError(f"Profile '{service.profile}' not found")
|
|
49
|
+
svc_map = CommentedMap(service.to_compose_service())
|
|
50
|
+
svc_map.merge_attrib = [profile_map]
|
|
51
|
+
services[service.name] = svc_map
|
|
52
|
+
self.save()
|
|
53
|
+
|
|
54
|
+
def remove_service(self, name: str) -> None:
|
|
55
|
+
services = self.data.get("services", {})
|
|
56
|
+
if name not in services:
|
|
57
|
+
raise KeyError(f"Service '{name}' not found")
|
|
58
|
+
del services[name]
|
|
59
|
+
self.save()
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Profile management
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def list_profiles(self) -> List[Profile]:
|
|
66
|
+
profiles: List[Profile] = []
|
|
67
|
+
for key, value in self.data.items():
|
|
68
|
+
if key.startswith("x-vpn-base-"):
|
|
69
|
+
name = key[len("x-vpn-base-") :]
|
|
70
|
+
profiles.append(Profile.from_anchor(name, value))
|
|
71
|
+
return profiles
|
|
72
|
+
|
|
73
|
+
def get_profile(self, name: str) -> Profile:
|
|
74
|
+
key = f"x-vpn-base-{name}"
|
|
75
|
+
if key not in self.data:
|
|
76
|
+
raise KeyError(f"Profile '{name}' not found")
|
|
77
|
+
return Profile.from_anchor(name, self.data[key])
|
|
78
|
+
|
|
79
|
+
def add_profile(self, profile: Profile) -> None:
|
|
80
|
+
key = f"x-vpn-base-{profile.name}"
|
|
81
|
+
if key in self.data:
|
|
82
|
+
raise ValueError(f"Profile '{profile.name}' already exists")
|
|
83
|
+
anchor_map = CommentedMap(profile.to_anchor())
|
|
84
|
+
anchor_map.yaml_set_anchor(f"vpn-base-{profile.name}", always_dump=True)
|
|
85
|
+
self.data[key] = anchor_map
|
|
86
|
+
self.save()
|
|
87
|
+
|
|
88
|
+
def remove_profile(self, name: str) -> None:
|
|
89
|
+
key = f"x-vpn-base-{name}"
|
|
90
|
+
if key not in self.data:
|
|
91
|
+
raise KeyError(f"Profile '{name}' not found")
|
|
92
|
+
del self.data[key]
|
|
93
|
+
self.save()
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Utility helpers
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def next_available_port(self, start: int = 0) -> int:
|
|
100
|
+
"""Find the next available host port starting from START.
|
|
101
|
+
|
|
102
|
+
If START is 0 the search begins from 20000 which is the default
|
|
103
|
+
range used by proxy2vpn. Existing service ports are inspected and
|
|
104
|
+
the first free port is returned.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
port = start or 20000
|
|
108
|
+
used = {svc.port for svc in self.list_services()}
|
|
109
|
+
while port in used:
|
|
110
|
+
port += 1
|
|
111
|
+
return port
|
|
112
|
+
|
|
113
|
+
def save(self) -> None:
|
|
114
|
+
with self.compose_path.open("w", encoding="utf-8") as f:
|
|
115
|
+
self.yaml.dump(self.data, f)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Utilities for manipulating docker-compose YAML files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ruamel.yaml import YAML
|
|
9
|
+
|
|
10
|
+
yaml = YAML()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_compose(path: Path) -> dict[str, Any]:
|
|
14
|
+
"""Load a docker-compose YAML file."""
|
|
15
|
+
with path.open("r", encoding="utf-8") as f:
|
|
16
|
+
return yaml.load(f)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save_compose(data: dict[str, Any], path: Path) -> None:
|
|
20
|
+
"""Save a docker-compose YAML file."""
|
|
21
|
+
with path.open("w", encoding="utf-8") as f:
|
|
22
|
+
yaml.dump(data, f)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_service_image(compose_path: Path, service: str, image: str) -> None:
|
|
26
|
+
"""Update the image of a service in the compose file.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
compose_path: Path to the docker-compose.yml file.
|
|
30
|
+
service: Name of the service to update.
|
|
31
|
+
image: New image string.
|
|
32
|
+
"""
|
|
33
|
+
data = load_compose(compose_path)
|
|
34
|
+
services = data.get("services", {})
|
|
35
|
+
if service not in services:
|
|
36
|
+
raise KeyError(f"Service '{service}' not found")
|
|
37
|
+
services[service]["image"] = image
|
|
38
|
+
save_compose(data, compose_path)
|
proxy2vpn/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Default configuration for proxy2vpn.
|
|
2
|
+
|
|
3
|
+
This module centralizes paths and default values used across the
|
|
4
|
+
application. All state is stored in the docker compose file referenced
|
|
5
|
+
by :data:`COMPOSE_FILE`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Path to the docker compose file that acts as the single source of truth
|
|
13
|
+
# for all proxy2vpn state. The path is relative to the current working
|
|
14
|
+
# directory of the CLI unless an absolute path is provided by the user.
|
|
15
|
+
COMPOSE_FILE: Path = Path("compose.yml")
|
|
16
|
+
|
|
17
|
+
# Directory used to cache data such as the downloaded server lists. The
|
|
18
|
+
# cache location defaults to ``~/.cache/proxy2vpn`` which follows the
|
|
19
|
+
# XDG base directory specification on Linux systems.
|
|
20
|
+
CACHE_DIR: Path = Path.home() / ".cache" / "proxy2vpn"
|
|
21
|
+
|
|
22
|
+
# Default VPN provider used when creating new services if none is
|
|
23
|
+
# explicitly specified by the user.
|
|
24
|
+
DEFAULT_PROVIDER = "protonvpn"
|
|
25
|
+
|
|
26
|
+
# Starting port used when automatically allocating ports for new VPN
|
|
27
|
+
# services. The manager will search for the next free port starting from
|
|
28
|
+
# this value.
|
|
29
|
+
DEFAULT_PORT_START = 20000
|
|
30
|
+
|
|
31
|
+
# URL of the gluetun server list JSON file. This file is fetched and
|
|
32
|
+
# cached by :class:`ServerManager` to provide location validation and
|
|
33
|
+
# listing of available servers.
|
|
34
|
+
SERVER_LIST_URL = "https://raw.githubusercontent.com/qdm12/gluetun/master/internal/storage/servers.json"
|