riptide-transfer 1.3.6__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.
- riptide/__init__.py +28 -0
- riptide/__main__.py +4 -0
- riptide/app.py +45 -0
- riptide/core/__init__.py +1 -0
- riptide/core/config.py +176 -0
- riptide/core/file_select.py +89 -0
- riptide/core/firewall.py +552 -0
- riptide/core/focus.py +82 -0
- riptide/core/identity.py +243 -0
- riptide/core/nat.py +102 -0
- riptide/core/session.py +887 -0
- riptide/core/transfer.py +89 -0
- riptide/screens/__init__.py +1 -0
- riptide/screens/create.py +529 -0
- riptide/screens/join.py +479 -0
- riptide/screens/menu.py +94 -0
- riptide/screens/session_screen.py +1059 -0
- riptide/theme.py +1049 -0
- riptide_transfer-1.3.6.dist-info/METADATA +176 -0
- riptide_transfer-1.3.6.dist-info/RECORD +23 -0
- riptide_transfer-1.3.6.dist-info/WHEEL +5 -0
- riptide_transfer-1.3.6.dist-info/entry_points.txt +2 -0
- riptide_transfer-1.3.6.dist-info/top_level.txt +1 -0
riptide/core/firewall.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""Firewall checks and port configuration across Windows, Linux, and macOS."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_os() -> str:
|
|
12
|
+
"""Return lowercase operating system name ('windows', 'linux', 'darwin', etc.)."""
|
|
13
|
+
return platform.system().lower()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _port_in_localport(localport: str, port: int) -> bool:
|
|
17
|
+
"""True if a netsh LocalPort value explicitly covers the port.
|
|
18
|
+
|
|
19
|
+
Handles single ports, comma lists, and ranges like 49990-50010.
|
|
20
|
+
A value of Any is deliberately NOT counted: Riptide only
|
|
21
|
+
cares about rules for port 50000, and Any-port rules are
|
|
22
|
+
program or service rules, not port rules. Counting Any made
|
|
23
|
+
a normal Windows machine, with 35 Any-port rules and 2 Any
|
|
24
|
+
block rules, always look blocked and always skip the UAC
|
|
25
|
+
prompt, even though traffic worked.
|
|
26
|
+
"""
|
|
27
|
+
for token in localport.split(","):
|
|
28
|
+
token = token.strip().lower()
|
|
29
|
+
if not token:
|
|
30
|
+
continue
|
|
31
|
+
if token == "any":
|
|
32
|
+
continue
|
|
33
|
+
if "-" in token:
|
|
34
|
+
try:
|
|
35
|
+
low, high = token.split("-", 1)
|
|
36
|
+
if int(low) <= port <= int(high):
|
|
37
|
+
return True
|
|
38
|
+
except ValueError:
|
|
39
|
+
continue
|
|
40
|
+
elif token == str(port):
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_netsh_rules(stdout: str) -> list[dict[str, str]]:
|
|
46
|
+
"""Parse netsh show rule output into one dict per rule.
|
|
47
|
+
|
|
48
|
+
Each rule starts with a Rule Name line, followed by a dash
|
|
49
|
+
line, then its fields. The dash line is inside a rule, not
|
|
50
|
+
between rules, so splitting on it used to put the rule name
|
|
51
|
+
in a separate chunk from its fields, and the matcher chunk
|
|
52
|
+
had no name. Dash lines are now ignored, and a rule ends
|
|
53
|
+
only when the next Rule Name line appears after an Action.
|
|
54
|
+
"""
|
|
55
|
+
rules: list[dict[str, str]] = []
|
|
56
|
+
props: dict[str, str] = {}
|
|
57
|
+
for line in stdout.splitlines():
|
|
58
|
+
stripped = line.strip()
|
|
59
|
+
if stripped and set(stripped) == {"-"}:
|
|
60
|
+
continue
|
|
61
|
+
if ":" not in line:
|
|
62
|
+
continue
|
|
63
|
+
k, v = line.split(":", 1)
|
|
64
|
+
k = k.strip().lower()
|
|
65
|
+
if k == "rule name" and props and "action" in props:
|
|
66
|
+
rules.append(props)
|
|
67
|
+
props = {}
|
|
68
|
+
props[k] = v.strip().lower()
|
|
69
|
+
if props:
|
|
70
|
+
rules.append(props)
|
|
71
|
+
return rules
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _windows_rule_matches(rule: dict[str, str], port: int) -> bool:
|
|
75
|
+
"""True if a netsh rule covers inbound TCP traffic on the port."""
|
|
76
|
+
if rule.get("enabled") != "yes":
|
|
77
|
+
return False
|
|
78
|
+
if rule.get("direction") and rule.get("direction") != "in":
|
|
79
|
+
return False
|
|
80
|
+
if rule.get("protocol") and rule.get("protocol") not in ("tcp", "any"):
|
|
81
|
+
return False
|
|
82
|
+
return _port_in_localport(rule.get("localport", ""), port)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def windows_block_rule_exists(port: int) -> bool:
|
|
86
|
+
"""True if an enabled inbound block rule covers the port on Windows.
|
|
87
|
+
|
|
88
|
+
A block rule wins over an allow rule in Windows Firewall, so an
|
|
89
|
+
allow rule alone does not open the port in that case.
|
|
90
|
+
"""
|
|
91
|
+
if get_os() != "windows":
|
|
92
|
+
return False
|
|
93
|
+
try:
|
|
94
|
+
res = subprocess.run(
|
|
95
|
+
["netsh", "advfirewall", "firewall", "show", "rule", "name=all", "dir=in"],
|
|
96
|
+
capture_output=True, text=True, timeout=10,
|
|
97
|
+
)
|
|
98
|
+
if res.returncode != 0 or not isinstance(res.stdout, str):
|
|
99
|
+
return False
|
|
100
|
+
return any(
|
|
101
|
+
rule.get("action") == "block" and _windows_rule_matches(rule, port)
|
|
102
|
+
for rule in _parse_netsh_rules(res.stdout)
|
|
103
|
+
)
|
|
104
|
+
except Exception:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_firewall_port(port: int) -> bool:
|
|
109
|
+
"""Check if the given port is open in the OS firewall.
|
|
110
|
+
|
|
111
|
+
Checks the operating system first and uses the native OS firewall tools.
|
|
112
|
+
Returns True if the port is open/allowed, False if closed.
|
|
113
|
+
"""
|
|
114
|
+
os_name = get_os()
|
|
115
|
+
|
|
116
|
+
if os_name == "windows":
|
|
117
|
+
try:
|
|
118
|
+
# Query inbound rules using netsh for fast execution (~50ms)
|
|
119
|
+
cmd = ["netsh", "advfirewall", "firewall", "show", "rule", "name=all", "dir=in"]
|
|
120
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
121
|
+
if res.returncode != 0 or not isinstance(res.stdout, str):
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
rules = _parse_netsh_rules(res.stdout)
|
|
125
|
+
allow = any(
|
|
126
|
+
rule.get("action") == "allow" and _windows_rule_matches(rule, port)
|
|
127
|
+
for rule in rules
|
|
128
|
+
)
|
|
129
|
+
block = any(
|
|
130
|
+
rule.get("action") == "block" and _windows_rule_matches(rule, port)
|
|
131
|
+
for rule in rules
|
|
132
|
+
)
|
|
133
|
+
# A block rule wins over an allow rule, so allow plus
|
|
134
|
+
# block is still closed. Reporting it as open was the
|
|
135
|
+
# silent failure behind a dialler timeout.
|
|
136
|
+
return allow and not block
|
|
137
|
+
except Exception:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
elif os_name == "linux":
|
|
141
|
+
try:
|
|
142
|
+
# 1. Check UFW if installed
|
|
143
|
+
if shutil.which("ufw"):
|
|
144
|
+
res = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5)
|
|
145
|
+
if "Status: active" in res.stdout:
|
|
146
|
+
for line in res.stdout.splitlines():
|
|
147
|
+
if str(port) in line and "ALLOW" in line.upper():
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
# 2. Check Firewalld if running
|
|
152
|
+
if shutil.which("firewall-cmd"):
|
|
153
|
+
state = subprocess.run(["firewall-cmd", "--state"], capture_output=True, text=True, timeout=5)
|
|
154
|
+
if state.returncode == 0 and "running" in state.stdout:
|
|
155
|
+
query = subprocess.run(
|
|
156
|
+
["firewall-cmd", f"--query-port={port}/tcp"],
|
|
157
|
+
capture_output=True, text=True, timeout=5,
|
|
158
|
+
)
|
|
159
|
+
return query.returncode == 0
|
|
160
|
+
|
|
161
|
+
# 3. Check iptables if default policy drops
|
|
162
|
+
if shutil.which("iptables"):
|
|
163
|
+
res = subprocess.run(["iptables", "-L", "INPUT", "-n"], capture_output=True, text=True, timeout=5)
|
|
164
|
+
if res.returncode == 0:
|
|
165
|
+
lines = res.stdout.splitlines()
|
|
166
|
+
if lines and any(p in lines[0] for p in ("policy DROP", "policy REJECT")):
|
|
167
|
+
return f"dpt:{port}" in res.stdout
|
|
168
|
+
|
|
169
|
+
# 4. Check nftables if it has a drop policy for input
|
|
170
|
+
if shutil.which("nft"):
|
|
171
|
+
res = subprocess.run(["nft", "list", "ruleset"], capture_output=True, text=True, timeout=5)
|
|
172
|
+
if res.returncode == 0 and "policy drop" in res.stdout.lower():
|
|
173
|
+
return f"dport {port}" in res.stdout
|
|
174
|
+
|
|
175
|
+
# If no firewall daemon is active/blocking, OS firewall allows inbound connections
|
|
176
|
+
return True
|
|
177
|
+
except Exception:
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
elif os_name == "darwin":
|
|
181
|
+
try:
|
|
182
|
+
fw = "/usr/libexec/ApplicationFirewall/socketfilterfw"
|
|
183
|
+
if shutil.which(fw):
|
|
184
|
+
res = subprocess.run([fw, "--getglobalstate"], capture_output=True, text=True, timeout=5)
|
|
185
|
+
if "disabled" in res.stdout.lower():
|
|
186
|
+
return True
|
|
187
|
+
return True
|
|
188
|
+
except Exception:
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def open_firewall_port(port: int, rule_name: str = "Riptide") -> bool:
|
|
195
|
+
"""Attempt to open the given port in the OS firewall.
|
|
196
|
+
|
|
197
|
+
Checks the operating system first and runs the corresponding rule addition.
|
|
198
|
+
Returns True if the command succeeded, False otherwise.
|
|
199
|
+
"""
|
|
200
|
+
os_name = get_os()
|
|
201
|
+
|
|
202
|
+
if os_name == "windows":
|
|
203
|
+
try:
|
|
204
|
+
# Single-word rule name: a name with spaces was fragile
|
|
205
|
+
# in netsh argument parsing.
|
|
206
|
+
cmd = [
|
|
207
|
+
"netsh", "advfirewall", "firewall", "add", "rule",
|
|
208
|
+
f"name={rule_name}",
|
|
209
|
+
"dir=in",
|
|
210
|
+
"action=allow",
|
|
211
|
+
"protocol=TCP",
|
|
212
|
+
f"localport={port}",
|
|
213
|
+
]
|
|
214
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
215
|
+
if res.returncode == 0:
|
|
216
|
+
return True
|
|
217
|
+
# Add fails when a rule with that name already exists,
|
|
218
|
+
# which is exactly the case when the user turned the
|
|
219
|
+
# rule off instead of deleting it. Enable it instead.
|
|
220
|
+
for name in (rule_name,):
|
|
221
|
+
res = subprocess.run(
|
|
222
|
+
[
|
|
223
|
+
"netsh", "advfirewall", "firewall", "set", "rule",
|
|
224
|
+
f"name={name}", "new", "enable=yes",
|
|
225
|
+
],
|
|
226
|
+
capture_output=True, text=True, timeout=10,
|
|
227
|
+
)
|
|
228
|
+
if res.returncode == 0:
|
|
229
|
+
return True
|
|
230
|
+
return False
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
elif os_name == "linux":
|
|
235
|
+
try:
|
|
236
|
+
# Use sudo -n, never plain sudo. Plain sudo would prompt for a
|
|
237
|
+
# password that the Textual UI cannot show, and would hang
|
|
238
|
+
# until the subprocess timeout. sudo -n fails fast when no
|
|
239
|
+
# passwordless sudo is available, and the direct command
|
|
240
|
+
# still works when Riptide itself runs as root.
|
|
241
|
+
def _try(cmd: list[str]) -> bool:
|
|
242
|
+
for full in ([ "sudo", "-n", *cmd], cmd):
|
|
243
|
+
try:
|
|
244
|
+
res = subprocess.run(full, capture_output=True, text=True, timeout=10)
|
|
245
|
+
except Exception:
|
|
246
|
+
continue
|
|
247
|
+
if res.returncode == 0:
|
|
248
|
+
return True
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
# 1. Try UFW
|
|
252
|
+
if shutil.which("ufw"):
|
|
253
|
+
if _try(["ufw", "allow", f"{port}/tcp"]):
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
# 2. Try Firewalld
|
|
257
|
+
if shutil.which("firewall-cmd"):
|
|
258
|
+
if _try(["firewall-cmd", f"--add-port={port}/tcp", "--permanent"]):
|
|
259
|
+
_try(["firewall-cmd", "--reload"])
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
# 3. Try iptables
|
|
263
|
+
if shutil.which("iptables"):
|
|
264
|
+
if _try(["iptables", "-I", "INPUT", "-p", "tcp", "--dport", str(port), "-j", "ACCEPT"]):
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
# 4. Try nftables, common inet filter input chain
|
|
268
|
+
if shutil.which("nft"):
|
|
269
|
+
if _try(["nft", "add", "rule", "inet", "filter", "input", "tcp", "dport", str(port), "accept"]):
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
return False
|
|
273
|
+
except Exception:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def manual_firewall_command(port: int) -> str:
|
|
280
|
+
"""The manual command to open the port, for the Create page banner.
|
|
281
|
+
|
|
282
|
+
Returns the command for the OS and firewall tool found on this
|
|
283
|
+
machine, or a generic command when none is found. No command is
|
|
284
|
+
run by this function.
|
|
285
|
+
"""
|
|
286
|
+
os_name = get_os()
|
|
287
|
+
if os_name == "windows":
|
|
288
|
+
return (
|
|
289
|
+
f"netsh advfirewall firewall add rule name=Riptide "
|
|
290
|
+
f"dir=in action=allow protocol=TCP localport={port}"
|
|
291
|
+
)
|
|
292
|
+
if os_name == "linux":
|
|
293
|
+
if shutil.which("ufw"):
|
|
294
|
+
return f"sudo ufw allow {port}/tcp"
|
|
295
|
+
if shutil.which("firewall-cmd"):
|
|
296
|
+
return f"sudo firewall-cmd --add-port={port}/tcp --permanent && sudo firewall-cmd --reload"
|
|
297
|
+
if shutil.which("iptables"):
|
|
298
|
+
return f"sudo iptables -I INPUT -p tcp --dport {port} -j ACCEPT"
|
|
299
|
+
if shutil.which("nft"):
|
|
300
|
+
return f"sudo nft add rule inet filter input tcp dport {port} accept"
|
|
301
|
+
return f"sudo ufw allow {port}/tcp"
|
|
302
|
+
return ""
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def is_elevated() -> bool:
|
|
306
|
+
"""True if this process already has admin or root rights.
|
|
307
|
+
|
|
308
|
+
Windows: IsUserAnAdmin. Linux and macOS: effective user id 0.
|
|
309
|
+
False on any error, so a failed check never skips a prompt it
|
|
310
|
+
should have shown, it only falls back to the normal path.
|
|
311
|
+
"""
|
|
312
|
+
os_name = get_os()
|
|
313
|
+
if os_name == "windows":
|
|
314
|
+
try:
|
|
315
|
+
import ctypes
|
|
316
|
+
|
|
317
|
+
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
|
318
|
+
except Exception:
|
|
319
|
+
return False
|
|
320
|
+
if os_name in ("linux", "darwin"):
|
|
321
|
+
try:
|
|
322
|
+
return os.geteuid() == 0
|
|
323
|
+
except Exception:
|
|
324
|
+
return False
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _valid_port(port: int) -> bool:
|
|
329
|
+
return isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _shell_execute_runas(executable: str, parameters: str) -> int:
|
|
333
|
+
"""Run one program as administrator on Windows, via ShellExecuteW.
|
|
334
|
+
|
|
335
|
+
This is a thin wrapper so tests can mock it without touching
|
|
336
|
+
ctypes. Return value: above 32 means success, 1223 means the
|
|
337
|
+
user cancelled the UAC prompt, 5 means access denied, 32 or
|
|
338
|
+
below otherwise means another error. A standard-user
|
|
339
|
+
credentials prompt returns 5 when the user clicks No, while
|
|
340
|
+
an admin Yes/No prompt returns 1223, both observed live.
|
|
341
|
+
ShellExecuteW returns immediately, it does not wait for the
|
|
342
|
+
program, so the caller must poll check_firewall_port after it.
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
import ctypes
|
|
346
|
+
|
|
347
|
+
return int(
|
|
348
|
+
ctypes.windll.shell32.ShellExecuteW(
|
|
349
|
+
None, "runas", executable, parameters, None, 0
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
except Exception:
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _netsh_path() -> str:
|
|
357
|
+
system_root = os.environ.get("SystemRoot", "")
|
|
358
|
+
if system_root:
|
|
359
|
+
return os.path.join(system_root, "System32", "netsh.exe")
|
|
360
|
+
return "netsh"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _windows_riptide_rule_disabled() -> bool:
|
|
364
|
+
"""True if a Riptide rule exists but is turned off on Windows.
|
|
365
|
+
|
|
366
|
+
Knowing this before elevating keeps the prompt to one: an
|
|
367
|
+
existing disabled rule is enabled, otherwise a new rule is
|
|
368
|
+
added. A second elevated call would prompt a second time.
|
|
369
|
+
"""
|
|
370
|
+
if get_os() != "windows":
|
|
371
|
+
return False
|
|
372
|
+
try:
|
|
373
|
+
res = subprocess.run(
|
|
374
|
+
["netsh", "advfirewall", "firewall", "show", "rule", "name=Riptide"],
|
|
375
|
+
capture_output=True, text=True, timeout=10,
|
|
376
|
+
)
|
|
377
|
+
if res.returncode != 0 or not isinstance(res.stdout, str):
|
|
378
|
+
return False
|
|
379
|
+
return any(
|
|
380
|
+
rule.get("rule name") == "riptide" and rule.get("enabled") == "no"
|
|
381
|
+
for rule in _parse_netsh_rules(res.stdout)
|
|
382
|
+
)
|
|
383
|
+
except Exception:
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _poll_firewall_open(port: int, timeout: float = 30, interval: float = 0.5) -> bool:
|
|
388
|
+
"""Poll check_firewall_port until it is open or the timeout ends.
|
|
389
|
+
|
|
390
|
+
Used after a UAC prompt, because ShellExecuteW returns before
|
|
391
|
+
the user has answered the prompt and before netsh has run.
|
|
392
|
+
"""
|
|
393
|
+
deadline = time.monotonic() + timeout
|
|
394
|
+
while True:
|
|
395
|
+
if check_firewall_port(port):
|
|
396
|
+
return True
|
|
397
|
+
if time.monotonic() >= deadline:
|
|
398
|
+
return False
|
|
399
|
+
time.sleep(interval)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def elevated_open_firewall_port_windows(port: int, poll_timeout: float = 30) -> str:
|
|
403
|
+
"""Open the port with one UAC prompt on Windows.
|
|
404
|
+
|
|
405
|
+
Returns "opened", "cancelled", or "failed". The command is
|
|
406
|
+
fixed: rule name Riptide, inbound, allow, TCP, and the port
|
|
407
|
+
as a validated integer. No user input goes into it.
|
|
408
|
+
"""
|
|
409
|
+
if not _valid_port(port):
|
|
410
|
+
return "failed"
|
|
411
|
+
if _windows_riptide_rule_disabled():
|
|
412
|
+
parameters = "advfirewall firewall set rule name=Riptide new enable=yes"
|
|
413
|
+
else:
|
|
414
|
+
parameters = (
|
|
415
|
+
"advfirewall firewall add rule name=Riptide "
|
|
416
|
+
f"dir=in action=allow protocol=TCP localport={port}"
|
|
417
|
+
)
|
|
418
|
+
result = _shell_execute_runas(_netsh_path(), parameters)
|
|
419
|
+
if result in (1223, 5):
|
|
420
|
+
return "cancelled"
|
|
421
|
+
if result <= 32:
|
|
422
|
+
return "failed"
|
|
423
|
+
return "opened" if _poll_firewall_open(port, timeout=poll_timeout) else "failed"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _pkexec_run(cmd: list[str]) -> int:
|
|
427
|
+
"""Run one command as root on Linux, via pkexec.
|
|
428
|
+
|
|
429
|
+
Returns the program return code, 126 if the user dismissed
|
|
430
|
+
the authentication dialog, or 127 if pkexec is missing, no
|
|
431
|
+
authentication agent is available, or authorisation failed.
|
|
432
|
+
Those codes are from the pkexec manual. The
|
|
433
|
+
--disable-internal-agent flag is deliberate: without it,
|
|
434
|
+
pkexec would register its own textual agent and prompt for
|
|
435
|
+
a password in the terminal, which would fight the Textual
|
|
436
|
+
UI the same way plain sudo did. With the flag, a machine
|
|
437
|
+
with no graphical agent fails fast instead of hanging,
|
|
438
|
+
and the caller falls back to the manual command.
|
|
439
|
+
"""
|
|
440
|
+
if not shutil.which("pkexec"):
|
|
441
|
+
return 127
|
|
442
|
+
try:
|
|
443
|
+
res = subprocess.run(
|
|
444
|
+
["pkexec", "--disable-internal-agent", *cmd],
|
|
445
|
+
capture_output=True, text=True, timeout=120,
|
|
446
|
+
)
|
|
447
|
+
return res.returncode
|
|
448
|
+
except Exception:
|
|
449
|
+
return 127
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def elevated_open_firewall_port_linux(port: int) -> str:
|
|
453
|
+
"""Open the port with one pkexec prompt on Linux.
|
|
454
|
+
|
|
455
|
+
Returns "opened", "cancelled", or "failed". Only the first
|
|
456
|
+
firewall tool found is tried, so at most one prompt appears
|
|
457
|
+
per click. For firewalld, add and reload run in one pkexec
|
|
458
|
+
shell command, again so only one prompt appears.
|
|
459
|
+
"""
|
|
460
|
+
if not _valid_port(port):
|
|
461
|
+
return "failed"
|
|
462
|
+
if shutil.which("ufw"):
|
|
463
|
+
cmd = ["ufw", "allow", f"{port}/tcp"]
|
|
464
|
+
elif shutil.which("firewall-cmd"):
|
|
465
|
+
cmd = [
|
|
466
|
+
"/bin/sh", "-c",
|
|
467
|
+
f"firewall-cmd --add-port={port}/tcp --permanent "
|
|
468
|
+
"&& firewall-cmd --reload",
|
|
469
|
+
]
|
|
470
|
+
elif shutil.which("iptables"):
|
|
471
|
+
cmd = ["iptables", "-I", "INPUT", "-p", "tcp", "--dport", str(port), "-j", "ACCEPT"]
|
|
472
|
+
elif shutil.which("nft"):
|
|
473
|
+
cmd = ["nft", "add", "rule", "inet", "filter", "input", "tcp", "dport", str(port), "accept"]
|
|
474
|
+
else:
|
|
475
|
+
return "failed"
|
|
476
|
+
result = _pkexec_run(cmd)
|
|
477
|
+
if result == 126:
|
|
478
|
+
return "cancelled"
|
|
479
|
+
if result != 0:
|
|
480
|
+
return "failed"
|
|
481
|
+
return "opened" if check_firewall_port(port) else "failed"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def elevated_open_firewall_port(port: int, poll_timeout: float = 30) -> str:
|
|
485
|
+
"""Open the port with an elevation prompt, Windows or Linux.
|
|
486
|
+
|
|
487
|
+
Returns "opened", "cancelled", or "failed". Other operating
|
|
488
|
+
systems have no elevation path here and return "failed".
|
|
489
|
+
"""
|
|
490
|
+
os_name = get_os()
|
|
491
|
+
if os_name == "windows":
|
|
492
|
+
return elevated_open_firewall_port_windows(port, poll_timeout=poll_timeout)
|
|
493
|
+
if os_name == "linux":
|
|
494
|
+
return elevated_open_firewall_port_linux(port)
|
|
495
|
+
return "failed"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def ensure_firewall_port_with_elevation(
|
|
499
|
+
port: int, poll_timeout: float = 30
|
|
500
|
+
) -> tuple[bool, bool, bool, bool]:
|
|
501
|
+
"""Check the port, open it if closed, with elevation if needed.
|
|
502
|
+
|
|
503
|
+
Returns (was_open, opened, is_open_now, cancelled). This is
|
|
504
|
+
the menu path only: it may prompt once, on the Create Server
|
|
505
|
+
click. The 20 second watch loop must keep using the plain
|
|
506
|
+
ensure_firewall_port, which never prompts.
|
|
507
|
+
|
|
508
|
+
Order matters, so no prompt appears when it is not needed:
|
|
509
|
+
already open, then a block rule on Windows (an allow rule
|
|
510
|
+
cannot open it, so prompting would mislead), then already
|
|
511
|
+
elevated, then the plain open (passwordless sudo on Linux),
|
|
512
|
+
and only then the elevation prompt. The command is fixed
|
|
513
|
+
to port 50000 in practice through config.session_port, and
|
|
514
|
+
only that port is ever opened; no program rule is checked
|
|
515
|
+
or relied on.
|
|
516
|
+
"""
|
|
517
|
+
was_open = check_firewall_port(port)
|
|
518
|
+
if was_open:
|
|
519
|
+
return (True, False, True, False)
|
|
520
|
+
if get_os() == "windows" and windows_block_rule_exists(port):
|
|
521
|
+
return (False, False, False, False)
|
|
522
|
+
if is_elevated() or get_os() not in ("windows", "linux"):
|
|
523
|
+
_was, opened, is_open_now = ensure_firewall_port(port)
|
|
524
|
+
return (_was, opened, is_open_now, False)
|
|
525
|
+
_was, opened, is_open_now = ensure_firewall_port(port)
|
|
526
|
+
if is_open_now:
|
|
527
|
+
return (_was, opened, is_open_now, False)
|
|
528
|
+
result = elevated_open_firewall_port(port, poll_timeout=poll_timeout)
|
|
529
|
+
if result == "opened":
|
|
530
|
+
return (False, True, True, False)
|
|
531
|
+
if result == "cancelled":
|
|
532
|
+
return (False, False, False, True)
|
|
533
|
+
return (False, False, False, False)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def ensure_firewall_port(port: int) -> tuple[bool, bool, bool]:
|
|
537
|
+
"""Check the port, open it if closed, and report the result.
|
|
538
|
+
|
|
539
|
+
Returns (was_open, opened, is_open_now). was_open is the state
|
|
540
|
+
before any change. opened and is_open_now are True only when
|
|
541
|
+
a re-check after opening finds the port open. Trusting the
|
|
542
|
+
open command alone was wrong: an allow rule does not open
|
|
543
|
+
the port when a block rule for the same port still exists.
|
|
544
|
+
When opening fails, is_open_now is False and the caller should
|
|
545
|
+
show manual_firewall_command(port) to the user.
|
|
546
|
+
"""
|
|
547
|
+
was_open = check_firewall_port(port)
|
|
548
|
+
if was_open:
|
|
549
|
+
return (True, False, True)
|
|
550
|
+
open_firewall_port(port)
|
|
551
|
+
is_open_now = check_firewall_port(port)
|
|
552
|
+
return (False, is_open_now, is_open_now)
|
riptide/core/focus.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Arrow-key focus navigation.
|
|
2
|
+
|
|
3
|
+
Root cause of the reported bug: Textual moves focus with Tab by
|
|
4
|
+
default. Plain Buttons in a Vertical container do not move focus on
|
|
5
|
+
Up/Down, and no widget was focused on mount, so pressing an arrow key
|
|
6
|
+
did nothing visible at all.
|
|
7
|
+
|
|
8
|
+
These helpers make Up/Down work explicitly, in DOM order, wrapping
|
|
9
|
+
around, on every screen. They are deliberately small and unit-testable:
|
|
10
|
+
they only need a screen-like object with ``focused``, ``query`` and
|
|
11
|
+
``app.set_focus``.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def focusable_widgets(screen):
|
|
17
|
+
"""Return focusable widgets in DOM order, skipping disabled ones."""
|
|
18
|
+
try:
|
|
19
|
+
widgets = list(screen.query("OptionList, ListView, Button, Input"))
|
|
20
|
+
except Exception:
|
|
21
|
+
return []
|
|
22
|
+
result = []
|
|
23
|
+
for widget in widgets:
|
|
24
|
+
try:
|
|
25
|
+
if getattr(widget, "disabled", False):
|
|
26
|
+
continue
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
result.append(widget)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def focus_first(screen) -> bool:
|
|
34
|
+
"""Focus the first focusable widget. Return True if one was focused."""
|
|
35
|
+
widgets = focusable_widgets(screen)
|
|
36
|
+
if not widgets:
|
|
37
|
+
return False
|
|
38
|
+
_set_focus(screen, widgets[0])
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def move_focus(screen, direction: int) -> bool:
|
|
43
|
+
"""Move focus by direction (+1 down, -1 up), wrapping. Return True if moved."""
|
|
44
|
+
widgets = focusable_widgets(screen)
|
|
45
|
+
if not widgets:
|
|
46
|
+
return False
|
|
47
|
+
focused = getattr(screen, "focused", None)
|
|
48
|
+
if focused not in widgets:
|
|
49
|
+
target = widgets[0] if direction >= 0 else widgets[-1]
|
|
50
|
+
_set_focus(screen, target)
|
|
51
|
+
return True
|
|
52
|
+
index = widgets.index(focused)
|
|
53
|
+
target = widgets[(index + direction) % len(widgets)]
|
|
54
|
+
_set_focus(screen, target)
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def handle_arrow_key(screen, key: str) -> bool:
|
|
59
|
+
"""Handle an Up/Down key for a screen. Return True if handled."""
|
|
60
|
+
try:
|
|
61
|
+
from textual.widgets import OptionList, ListView
|
|
62
|
+
focused = getattr(screen, "focused", None)
|
|
63
|
+
if isinstance(focused, (OptionList, ListView)):
|
|
64
|
+
return False
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
if key == "down":
|
|
68
|
+
return move_focus(screen, +1)
|
|
69
|
+
if key == "up":
|
|
70
|
+
return move_focus(screen, -1)
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _set_focus(screen, widget) -> None:
|
|
75
|
+
app = getattr(screen, "app", None)
|
|
76
|
+
set_focus = getattr(app, "set_focus", None) if app is not None else None
|
|
77
|
+
if callable(set_focus):
|
|
78
|
+
set_focus(widget)
|
|
79
|
+
else:
|
|
80
|
+
focus = getattr(widget, "focus", None)
|
|
81
|
+
if callable(focus):
|
|
82
|
+
focus()
|