main
py 269 lines 8.73 KB
Raw
1 import os
2 import platform
3 import queue
4 import shutil
5 import subprocess
6 import tarfile
7 import tempfile
8 import threading
9 import time
10 import urllib.request
11 import zipfile
12 from collections import deque
13 from pathlib import Path
14
15 from flaredantic import NotifyEvent
16
17 from helpers import files
18 from helpers.tunnel_common import TunnelHelper
19
20
21 RUNTIME_BIN_DIR = Path(files.get_abs_path("tmp", "bin"))
22
23
24 def executable_name(name):
25 return f"{name}.exe" if platform.system().lower() == "windows" else name
26
27
28 def chmod_executable(path):
29 if platform.system().lower() != "windows":
30 path.chmod(0o755)
31
32
33 def notify_download(notify, message, data=None):
34 if callable(notify):
35 notify(NotifyEvent.DOWNLOADING, message, data)
36
37
38 def notify_download_complete(notify, message, data=None):
39 if callable(notify):
40 notify(NotifyEvent.DOWNLOAD_COMPLETE, message, data)
41
42
43 def download_file(url, destination, notify=None):
44 destination.parent.mkdir(parents=True, exist_ok=True)
45 notify_download(notify, f"Downloading {url}")
46 temp_fd, temp_name = tempfile.mkstemp(
47 prefix=f"{destination.name}.",
48 suffix=".download",
49 dir=str(destination.parent),
50 )
51 os.close(temp_fd)
52 temp_path = Path(temp_name)
53 try:
54 with urllib.request.urlopen(url, timeout=60) as response:
55 with temp_path.open("wb") as handle:
56 shutil.copyfileobj(response, handle)
57 temp_path.replace(destination)
58 notify_download_complete(notify, f"Downloaded {destination.name}")
59 return destination
60 except Exception:
61 temp_path.unlink(missing_ok=True)
62 raise
63
64
65 def platform_parts():
66 system = platform.system().lower()
67 machine = platform.machine().lower()
68 if machine in {"x86_64", "amd64"}:
69 arch = "amd64"
70 elif machine in {"aarch64", "arm64"}:
71 arch = "arm64"
72 elif machine in {"i386", "i686", "x86"}:
73 arch = "386"
74 elif machine.startswith("arm"):
75 arch = "arm"
76 else:
77 raise RuntimeError(f"Unsupported CPU architecture for tunnel binary: {machine}")
78
79 if system not in {"linux", "darwin", "windows"}:
80 raise RuntimeError(f"Unsupported operating system for tunnel binary: {system}")
81 return system, arch
82
83
84 def extract_named_members_from_tar(archive_path, destination_dir, member_names):
85 destination_dir.mkdir(parents=True, exist_ok=True)
86 extracted = {}
87 wanted = set(member_names)
88 with tarfile.open(archive_path, "r:*") as archive:
89 for member in archive.getmembers():
90 member_name = Path(member.name).name
91 if member_name not in wanted or not member.isfile():
92 continue
93 target = destination_dir / executable_name(member_name)
94 source = archive.extractfile(member)
95 if source is None:
96 continue
97 with source, target.open("wb") as handle:
98 shutil.copyfileobj(source, handle)
99 chmod_executable(target)
100 extracted[member_name] = target
101 missing = wanted - set(extracted)
102 if missing:
103 raise RuntimeError(
104 f"Archive {archive_path.name} did not contain expected binaries: "
105 f"{', '.join(sorted(missing))}."
106 )
107 return extracted
108
109
110 def extract_named_members_from_zip(archive_path, destination_dir, member_names):
111 destination_dir.mkdir(parents=True, exist_ok=True)
112 extracted = {}
113 wanted = set(member_names)
114 with zipfile.ZipFile(archive_path) as archive:
115 for member in archive.infolist():
116 member_name = Path(member.filename).name
117 normalized_name = member_name.removesuffix(".exe")
118 if normalized_name not in wanted or member.is_dir():
119 continue
120 target = destination_dir / executable_name(normalized_name)
121 with archive.open(member) as source, target.open("wb") as handle:
122 shutil.copyfileobj(source, handle)
123 chmod_executable(target)
124 extracted[normalized_name] = target
125 missing = wanted - set(extracted)
126 if missing:
127 raise RuntimeError(
128 f"Archive {archive_path.name} did not contain expected binaries: "
129 f"{', '.join(sorted(missing))}."
130 )
131 return extracted
132
133
134 class CliTunnelHelper(TunnelHelper):
135 label = "CLI tunnel"
136
137 def __init__(
138 self,
139 *,
140 label,
141 binary,
142 port,
143 command,
144 url_pattern,
145 missing_binary_message,
146 shutdown_command=None,
147 timeout=30,
148 notify=None,
149 binary_resolver=None,
150 preflight=None,
151 output_handler=None,
152 ):
153 super().__init__(port, notify=notify)
154 self.label = label
155 self.binary = binary
156 self.command = command
157 self.url_pattern = url_pattern
158 self.missing_binary_message = missing_binary_message
159 self.shutdown_command = shutdown_command
160 self.timeout = timeout
161 self.tunnel_process = None
162 self.binary_path = None
163 self.binary_resolver = binary_resolver
164 self.preflight = preflight
165 self.command_prefix = []
166 self.output_handler = output_handler
167
168 def _extract_url(self, line):
169 match = self.url_pattern.search(line)
170 if not match:
171 return None
172 return match.group(1 if match.lastindex else 0).rstrip(".,)")
173
174 def start(self):
175 binary_path = (
176 self.binary_resolver(self._notify)
177 if callable(self.binary_resolver)
178 else shutil.which(self.binary)
179 )
180 if not binary_path:
181 raise RuntimeError(self.missing_binary_message)
182 self.binary_path = binary_path
183 if callable(self.preflight):
184 preflight_result = self.preflight(binary_path, notify=self._notify)
185 if isinstance(preflight_result, dict):
186 self.command_prefix = list(preflight_result.get("command_prefix") or [])
187
188 command = [binary_path, *self.command_prefix, *self.command[1:]]
189 self.notify_starting(self.label)
190 self.tunnel_process = subprocess.Popen(
191 command,
192 stdout=subprocess.PIPE,
193 stderr=subprocess.STDOUT,
194 text=True,
195 bufsize=1,
196 )
197
198 output_queue = queue.Queue()
199
200 def read_output():
201 if self.tunnel_process is None or self.tunnel_process.stdout is None:
202 return
203 for line in self.tunnel_process.stdout:
204 output_queue.put(line)
205
206 threading.Thread(target=read_output, daemon=True).start()
207
208 deadline = time.monotonic() + self.timeout
209 recent_output = deque(maxlen=8)
210 while time.monotonic() < deadline:
211 try:
212 line = output_queue.get(timeout=0.1)
213 except queue.Empty:
214 if self.tunnel_process.poll() is not None:
215 break
216 continue
217
218 cleaned_line = line.strip()
219 if cleaned_line:
220 recent_output.append(cleaned_line)
221 if callable(self.output_handler):
222 try:
223 self.output_handler(cleaned_line, self._notify)
224 except Exception:
225 pass
226 url = self._extract_url(cleaned_line)
227 if url:
228 self.tunnel_url = url
229 self.notify_url_ready(self.label, url)
230 return self.tunnel_url
231
232 details = " ".join(recent_output)
233 if self.tunnel_process.poll() is not None:
234 raise RuntimeError(
235 f"{self.label} exited before it reported a remote URL."
236 + (f" Output: {details}" if details else "")
237 )
238 self._terminate_process()
239 raise RuntimeError(
240 f"{self.label} did not report a remote URL within {self.timeout} seconds."
241 + (f" Output: {details}" if details else "")
242 )
243
244 def _terminate_process(self):
245 if not self.tunnel_process or self.tunnel_process.poll() is not None:
246 return
247 self.tunnel_process.terminate()
248 try:
249 self.tunnel_process.wait(timeout=8)
250 except subprocess.TimeoutExpired:
251 self.tunnel_process.kill()
252 self.tunnel_process.wait(timeout=3)
253
254 def stop(self):
255 self._terminate_process()
256
257 if self.shutdown_command:
258 binary_path = self.binary_path or shutil.which(self.binary)
259 if binary_path:
260 subprocess.run(
261 [binary_path, *self.command_prefix, *self.shutdown_command[1:]],
262 check=False,
263 stdout=subprocess.DEVNULL,
264 stderr=subprocess.DEVNULL,
265 timeout=10,
266 )
267 self.tunnel_url = None
268 self.notify_stopped(self.label)
269 return True