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.
@@ -0,0 +1,170 @@
1
+ """Interactions with Docker using the docker SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable, Iterator
6
+
7
+ import docker
8
+ import requests
9
+ from docker.models.containers import Container
10
+
11
+
12
+ def _client() -> docker.DockerClient:
13
+ """Return a Docker client configured from environment."""
14
+ return docker.from_env()
15
+
16
+
17
+ def create_container(
18
+ name: str, image: str, command: Iterable[str] | None = None
19
+ ) -> Container:
20
+ """Create a container with the given name and image.
21
+
22
+ The image is pulled if it is not available locally.
23
+ """
24
+ client = _client()
25
+ client.images.pull(image)
26
+ return client.containers.create(image, name=name, command=command, detach=True)
27
+
28
+
29
+ def start_container(name: str) -> Container:
30
+ """Start an existing container by name."""
31
+ client = _client()
32
+ container = client.containers.get(name)
33
+ container.start()
34
+ return container
35
+
36
+
37
+ def stop_container(name: str) -> Container:
38
+ """Stop a running container by name."""
39
+ client = _client()
40
+ container = client.containers.get(name)
41
+ container.stop()
42
+ return container
43
+
44
+
45
+ def restart_container(name: str) -> Container:
46
+ """Restart a container by name and return it."""
47
+ client = _client()
48
+ container = client.containers.get(name)
49
+ container.restart()
50
+ container.reload()
51
+ return container
52
+
53
+
54
+ def remove_container(name: str) -> None:
55
+ """Remove a container by name."""
56
+ client = _client()
57
+ container = client.containers.get(name)
58
+ container.remove(force=True)
59
+
60
+
61
+ def container_logs(name: str, lines: int = 100, follow: bool = False) -> Iterator[str]:
62
+ """Yield log lines from a container.
63
+
64
+ If ``follow`` is ``True`` the generator will yield new log lines as they
65
+ arrive until the container stops or the caller interrupts. Otherwise the
66
+ last ``lines`` lines are returned.
67
+ """
68
+
69
+ client = _client()
70
+ container = client.containers.get(name)
71
+ if follow:
72
+ for line in container.logs(stream=True, follow=True, tail=lines):
73
+ yield line.decode().rstrip()
74
+ else:
75
+ output = container.logs(tail=lines).decode().splitlines()
76
+ for line in output:
77
+ yield line
78
+
79
+
80
+ def list_containers(all: bool = False) -> list[Container]:
81
+ """List containers."""
82
+ client = _client()
83
+ return client.containers.list(all=all)
84
+
85
+
86
+ def get_vpn_containers(all: bool = False) -> list[Container]:
87
+ """Return containers labeled as VPN services."""
88
+ client = _client()
89
+ return client.containers.list(all=all, filters={"label": "vpn.type=vpn"})
90
+
91
+
92
+ def start_all_vpn_containers() -> list[tuple[str, bool]]:
93
+ """Start all VPN containers.
94
+
95
+ Returns a list of tuples ``(name, started)`` where ``started`` is ``True``
96
+ if the container was started by this function and ``False`` if it was
97
+ already running.
98
+ """
99
+
100
+ containers = get_vpn_containers(all=True)
101
+ results: list[tuple[str, bool]] = []
102
+ for container in containers:
103
+ if container.status != "running":
104
+ container.start()
105
+ results.append((container.name, True))
106
+ else:
107
+ results.append((container.name, False))
108
+ return results
109
+
110
+
111
+ def stop_all_vpn_containers() -> list[str]:
112
+ """Stop all running VPN containers.
113
+
114
+ Returns a list of container names that were stopped.
115
+ """
116
+
117
+ containers = get_vpn_containers(all=False)
118
+ results: list[str] = []
119
+ for container in containers:
120
+ container.stop()
121
+ results.append(container.name)
122
+ return results
123
+
124
+
125
+ def get_container_ip(container: Container) -> str:
126
+ """Return the external IP address for a running container.
127
+
128
+ The IP address is retrieved via ``ifconfig.me`` through the proxy exposed on
129
+ the port specified by the ``vpn.port`` label. If the container is not
130
+ running, has no port label or the request fails, ``"N/A"`` is returned.
131
+ """
132
+
133
+ port = container.labels.get("vpn.port")
134
+ if not port or container.status != "running":
135
+ return "N/A"
136
+ try:
137
+ response = requests.get(
138
+ "https://ifconfig.me",
139
+ proxies={"http": f"localhost:{port}", "https": f"localhost:{port}"},
140
+ timeout=5,
141
+ )
142
+ return response.text.strip()
143
+ except Exception:
144
+ return "N/A"
145
+
146
+
147
+ def test_vpn_connection(name: str) -> bool:
148
+ """Return ``True`` if the VPN proxy for NAME appears to work."""
149
+
150
+ client = _client()
151
+ try:
152
+ container = client.containers.get(name)
153
+ except Exception:
154
+ return False
155
+ port = container.labels.get("vpn.port")
156
+ if not port or container.status != "running":
157
+ return False
158
+ try:
159
+ direct = requests.get("https://ifconfig.me", timeout=5).text.strip()
160
+ proxied = requests.get(
161
+ "https://ifconfig.me",
162
+ proxies={
163
+ "http": f"http://localhost:{port}",
164
+ "https": f"http://localhost:{port}",
165
+ },
166
+ timeout=5,
167
+ ).text.strip()
168
+ return proxied != "" and proxied != direct
169
+ except Exception:
170
+ return False
proxy2vpn/models.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, List
5
+
6
+
7
+ @dataclass
8
+ class VPNService:
9
+ name: str
10
+ port: int
11
+ provider: str
12
+ profile: str
13
+ location: str
14
+ environment: Dict[str, str]
15
+ labels: Dict[str, str]
16
+
17
+ @classmethod
18
+ def from_compose_service(cls, name: str, service_def: Dict) -> "VPNService":
19
+ ports = service_def.get("ports", [])
20
+ host_port = 0
21
+ if ports:
22
+ mapping = str(ports[0])
23
+ parts = mapping.split(":")
24
+ if len(parts) >= 3:
25
+ host_port = int(parts[1])
26
+ elif len(parts) == 2:
27
+ host_port = int(parts[0])
28
+ else:
29
+ host_port = int(mapping)
30
+ env_list = service_def.get("environment", [])
31
+ env_dict: Dict[str, str] = {}
32
+ for item in env_list:
33
+ if isinstance(item, str) and "=" in item:
34
+ k, v = item.split("=", 1)
35
+ env_dict[k] = v
36
+ labels = dict(service_def.get("labels", {}))
37
+ provider = labels.get("vpn.provider", env_dict.get("VPN_SERVICE_PROVIDER", ""))
38
+ profile = labels.get("vpn.profile", "")
39
+ location = labels.get("vpn.location", env_dict.get("SERVER_CITIES", ""))
40
+ return cls(
41
+ name=name,
42
+ port=host_port,
43
+ provider=provider,
44
+ profile=profile,
45
+ location=location,
46
+ environment=env_dict,
47
+ labels=labels,
48
+ )
49
+
50
+ def to_compose_service(self) -> Dict:
51
+ env_list = [f"{k}={v}" for k, v in self.environment.items()]
52
+ service = {
53
+ "ports": [f"{self.port}:8888/tcp"],
54
+ "environment": env_list,
55
+ "labels": self.labels,
56
+ }
57
+ return service
58
+
59
+
60
+ @dataclass
61
+ class Profile:
62
+ """Representation of a VPN profile stored as a YAML anchor.
63
+
64
+ The profile contains the base configuration used by VPN services. In
65
+ the compose file profiles are stored under a key of the form
66
+ ``x-vpn-base-<name>`` with an anchor ``&vpn-base-<name>``. Services can
67
+ then merge the profile using ``<<: *vpn-base-<name>``.
68
+ """
69
+
70
+ name: str
71
+ env_file: str
72
+ image: str = "qmcgaw/gluetun"
73
+ cap_add: List[str] = field(default_factory=lambda: ["NET_ADMIN"])
74
+ devices: List[str] = field(default_factory=lambda: ["/dev/net/tun:/dev/net/tun"])
75
+
76
+ @classmethod
77
+ def from_anchor(cls, name: str, data: Dict) -> "Profile":
78
+ """Create a :class:`Profile` from an anchor section."""
79
+
80
+ env_files = data.get("env_file", [])
81
+ env_file = env_files[0] if env_files else ""
82
+ return cls(
83
+ name=name,
84
+ env_file=env_file,
85
+ image=data.get("image", "qmcgaw/gluetun"),
86
+ cap_add=list(data.get("cap_add", [])),
87
+ devices=list(data.get("devices", [])),
88
+ )
89
+
90
+ def to_anchor(self) -> Dict:
91
+ """Return a dictionary representing the profile configuration."""
92
+
93
+ return {
94
+ "image": self.image,
95
+ "cap_add": list(self.cap_add),
96
+ "devices": list(self.devices),
97
+ "env_file": [self.env_file] if self.env_file else [],
98
+ }
@@ -0,0 +1,48 @@
1
+ """Preset utilities built on top of YAML anchors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ from . import config
9
+ from .compose_manager import ComposeManager
10
+ from .models import VPNService
11
+
12
+
13
+ def list_available_presets(compose_file: Path | None = None) -> List[str]:
14
+ """Return names of available presets (profile anchors)."""
15
+
16
+ mgr = ComposeManager(compose_file or config.COMPOSE_FILE)
17
+ return [profile.name for profile in mgr.list_profiles()]
18
+
19
+
20
+ def apply_preset(
21
+ preset_name: str,
22
+ service_name: str,
23
+ port: int,
24
+ compose_file: Path | None = None,
25
+ ) -> None:
26
+ """Create a VPN service using a preset anchor."""
27
+
28
+ mgr = ComposeManager(compose_file or config.COMPOSE_FILE)
29
+ # Ensure preset exists
30
+ mgr.get_profile(preset_name)
31
+ env = {"VPN_SERVICE_PROVIDER": config.DEFAULT_PROVIDER}
32
+ labels = {
33
+ "vpn.type": "vpn",
34
+ "vpn.port": str(port),
35
+ "vpn.provider": config.DEFAULT_PROVIDER,
36
+ "vpn.profile": preset_name,
37
+ "vpn.location": "",
38
+ }
39
+ service = VPNService(
40
+ name=service_name,
41
+ port=port,
42
+ provider=config.DEFAULT_PROVIDER,
43
+ profile=preset_name,
44
+ location="",
45
+ environment=env,
46
+ labels=labels,
47
+ )
48
+ mgr.add_service(service)
@@ -0,0 +1,111 @@
1
+ """Utilities for fetching and caching VPN server lists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Dict, List
9
+
10
+ import requests
11
+ import typer
12
+
13
+ from . import config
14
+
15
+
16
+ class ServerManager:
17
+ """Manage gluetun server list information.
18
+
19
+ The server list is downloaded from GitHub and cached locally to avoid
20
+ repeated network requests. The cache is considered valid for ``ttl``
21
+ seconds (24h by default).
22
+ """
23
+
24
+ def __init__(self, cache_dir: Path | None = None, ttl: int = 24 * 3600) -> None:
25
+ self.cache_dir = cache_dir or config.CACHE_DIR
26
+ self.cache_file = self.cache_dir / "servers.json"
27
+ self.ttl = ttl
28
+ self.data: Dict[str, Dict] | None = None
29
+
30
+ # ------------------------------------------------------------------
31
+ # Fetching and caching
32
+ # ------------------------------------------------------------------
33
+
34
+ def _is_cache_valid(self) -> bool:
35
+ if not self.cache_file.exists():
36
+ return False
37
+ age = time.time() - self.cache_file.stat().st_mtime
38
+ return age < self.ttl
39
+
40
+ def update_servers(self, verify: bool = True) -> Dict[str, Dict]:
41
+ """Fetch the server list, using the cache when possible.
42
+
43
+ Parameters
44
+ ----------
45
+ verify:
46
+ Whether to verify SSL certificates when downloading the server
47
+ list. Set to ``False`` for troubleshooting.
48
+ """
49
+
50
+ if not self._is_cache_valid():
51
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
52
+ try:
53
+ response = requests.get(
54
+ config.SERVER_LIST_URL, timeout=30, verify=verify
55
+ )
56
+ except requests.exceptions.SSLError:
57
+ typer.echo(
58
+ "Failed to download server list—check network connection or CA certificates",
59
+ err=True,
60
+ )
61
+ raise typer.Exit(1)
62
+ response.raise_for_status()
63
+ self.cache_file.write_text(response.text, encoding="utf-8")
64
+ with self.cache_file.open("r", encoding="utf-8") as f:
65
+ self.data = json.load(f)
66
+ return self.data
67
+
68
+ # ------------------------------------------------------------------
69
+ # Listing helpers
70
+ # ------------------------------------------------------------------
71
+
72
+ def list_providers(self) -> List[str]:
73
+ data = self.data or self.update_servers()
74
+ return sorted(k for k in data.keys() if k != "version")
75
+
76
+ def list_countries(self, provider: str) -> List[str]:
77
+ """Return available countries for PROVIDER."""
78
+
79
+ data = self.data or self.update_servers()
80
+ prov = data.get(provider, {})
81
+ servers = prov.get("servers", [])
82
+ countries = {srv.get("country") for srv in servers if srv.get("country")}
83
+ return sorted(countries)
84
+
85
+ def list_cities(self, provider: str, country: str) -> List[str]:
86
+ """Return available cities for PROVIDER in COUNTRY."""
87
+
88
+ data = self.data or self.update_servers()
89
+ prov = data.get(provider, {})
90
+ servers = prov.get("servers", [])
91
+ cities = {
92
+ srv.get("city")
93
+ for srv in servers
94
+ if srv.get("country") == country and srv.get("city")
95
+ }
96
+ return sorted(cities)
97
+
98
+ def validate_location(self, provider: str, location: str) -> bool:
99
+ """Return ``True`` if LOCATION exists for PROVIDER."""
100
+
101
+ data = self.data or self.update_servers()
102
+ prov = data.get(provider, {})
103
+ servers = prov.get("servers", [])
104
+ loc = location.lower()
105
+ for srv in servers:
106
+ if (
107
+ srv.get("city", "").lower() == loc
108
+ or srv.get("country", "").lower() == loc
109
+ ):
110
+ return True
111
+ return False