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 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"