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/docker_ops.py
ADDED
|
@@ -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
|