susops 3.0.0rc3.dev1__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.
- susops/__init__.py +4 -0
- susops/client.py +230 -0
- susops/core/__init__.py +0 -0
- susops/core/browsers.py +330 -0
- susops/core/config.py +253 -0
- susops/core/log_style.py +92 -0
- susops/core/pac.py +185 -0
- susops/core/ports.py +57 -0
- susops/core/process.py +167 -0
- susops/core/rpc_protocol.py +186 -0
- susops/core/rpc_server.py +131 -0
- susops/core/services_daemon.py +312 -0
- susops/core/share.py +323 -0
- susops/core/socat.py +200 -0
- susops/core/ssh.py +330 -0
- susops/core/ssh_config.py +40 -0
- susops/core/status.py +245 -0
- susops/core/types.py +171 -0
- susops/facade.py +2237 -0
- susops/tray/__init__.py +20 -0
- susops/tray/base.py +650 -0
- susops/tray/linux.py +1623 -0
- susops/tray/mac.py +3105 -0
- susops/tui/__init__.py +0 -0
- susops/tui/__main__.py +44 -0
- susops/tui/app.py +191 -0
- susops/tui/cli.py +665 -0
- susops/tui/screens/__init__.py +114 -0
- susops/tui/screens/connections.py +871 -0
- susops/tui/screens/dashboard.py +935 -0
- susops/tui/screens/shares.py +357 -0
- susops/tui/widgets/__init__.py +0 -0
- susops/tui/widgets/connection_card.py +137 -0
- susops/version.py +12 -0
- susops-3.0.0rc3.dev1.dist-info/METADATA +977 -0
- susops-3.0.0rc3.dev1.dist-info/RECORD +40 -0
- susops-3.0.0rc3.dev1.dist-info/WHEEL +5 -0
- susops-3.0.0rc3.dev1.dist-info/entry_points.txt +7 -0
- susops-3.0.0rc3.dev1.dist-info/licenses/LICENSE +674 -0
- susops-3.0.0rc3.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
"""Connection editor screen — CRUD for connections, PAC hosts, port forwards."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from textual import work
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Horizontal
|
|
8
|
+
from textual.screen import Screen, ModalScreen
|
|
9
|
+
from textual.widgets import (
|
|
10
|
+
Button,
|
|
11
|
+
Checkbox,
|
|
12
|
+
DataTable,
|
|
13
|
+
Input,
|
|
14
|
+
Label,
|
|
15
|
+
Select,
|
|
16
|
+
Static,
|
|
17
|
+
TabbedContent,
|
|
18
|
+
TabPane,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from susops.core.config import PortForward
|
|
22
|
+
from susops.core.ports import is_port_free, validate_port
|
|
23
|
+
from susops.core.ssh_config import get_ssh_hosts
|
|
24
|
+
from susops.tui.screens import _CollapsingLabel, compose_footer, fmt_bps, fmt_bytes, proto_label, status_dot
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _select_str(screen: ModalScreen, selector_id: str, default: str = "") -> str:
|
|
28
|
+
"""Return a Select widget's value as str, or default when nothing is selected."""
|
|
29
|
+
val = screen.query_one(selector_id, Select).value
|
|
30
|
+
return val if isinstance(val, str) else default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fw_dot(mgr, fw: PortForward, conn_tag: str, direction: str, conn_running: bool) -> str:
|
|
34
|
+
"""Return the status dot for a port forward, handling TCP+UDP partial state."""
|
|
35
|
+
if not fw.enabled:
|
|
36
|
+
return "─"
|
|
37
|
+
if not conn_running:
|
|
38
|
+
return "[red]○[/red]"
|
|
39
|
+
# Connection is running: TCP is up (via master). For TCP+UDP, also check UDP.
|
|
40
|
+
if fw.tcp and fw.udp:
|
|
41
|
+
try:
|
|
42
|
+
udp_up = mgr.is_udp_forward_running(conn_tag, fw.src_port, direction)
|
|
43
|
+
except Exception:
|
|
44
|
+
udp_up = False
|
|
45
|
+
partial = not udp_up # TCP up, UDP down
|
|
46
|
+
return status_dot(running=True, partial=partial)
|
|
47
|
+
return "[green]●[/green]"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _AddConnectionDialog(ModalScreen):
|
|
51
|
+
"""Modal for adding a new SSH connection."""
|
|
52
|
+
|
|
53
|
+
def compose(self) -> ComposeResult:
|
|
54
|
+
ssh_hosts = get_ssh_hosts()
|
|
55
|
+
with Static(classes="modal-dialog"):
|
|
56
|
+
yield Label("[bold]Add Connection[/bold]")
|
|
57
|
+
with Horizontal(classes="modal-form-row"):
|
|
58
|
+
with Static(classes="modal-field"):
|
|
59
|
+
yield Label("Tag:")
|
|
60
|
+
yield Input(placeholder="e.g. work", id="tag")
|
|
61
|
+
with Static(classes="modal-field"):
|
|
62
|
+
yield Label("SOCKS port (0 = auto):")
|
|
63
|
+
yield Input(placeholder="0", id="socks-port", value="0")
|
|
64
|
+
if ssh_hosts:
|
|
65
|
+
yield Label("SSH host (pick from ~/.ssh/config or type below):")
|
|
66
|
+
options = [(h, h) for h in ssh_hosts]
|
|
67
|
+
yield Select(options, prompt="— pick from SSH config —", id="ssh-host-select", allow_blank=True)
|
|
68
|
+
else:
|
|
69
|
+
yield Label("SSH host (user@host):")
|
|
70
|
+
yield Input(placeholder="user@hostname", id="ssh-host")
|
|
71
|
+
yield _CollapsingLabel("", id="error", classes="modal-error")
|
|
72
|
+
with Horizontal(classes="modal-btn-row"):
|
|
73
|
+
yield Button("Add", id="btn-ok", variant="success")
|
|
74
|
+
yield Button("Cancel", id="btn-cancel")
|
|
75
|
+
|
|
76
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
77
|
+
if event.select.id == "ssh-host-select" and isinstance(event.value, str):
|
|
78
|
+
self.query_one("#ssh-host", Input).value = event.value
|
|
79
|
+
|
|
80
|
+
def on_button_pressed(self, event) -> None:
|
|
81
|
+
if event.button.id == "btn-cancel":
|
|
82
|
+
self.dismiss(None)
|
|
83
|
+
return
|
|
84
|
+
tag = self.query_one("#tag", Input).value.strip()
|
|
85
|
+
host = self.query_one("#ssh-host", Input).value.strip()
|
|
86
|
+
port_str = self.query_one("#socks-port", Input).value.strip()
|
|
87
|
+
error_label = self.query_one(".modal-error", Label)
|
|
88
|
+
if not tag:
|
|
89
|
+
error_label.update("Tag is required.")
|
|
90
|
+
return
|
|
91
|
+
if not host:
|
|
92
|
+
error_label.update("SSH host is required.")
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
port = int(port_str or "0")
|
|
96
|
+
except ValueError:
|
|
97
|
+
error_label.update("SOCKS port must be a number.")
|
|
98
|
+
return
|
|
99
|
+
if not validate_port(port, allow_zero=True):
|
|
100
|
+
error_label.update("SOCKS port must be 0 (auto) or between 1 and 65535.")
|
|
101
|
+
return
|
|
102
|
+
if port != 0 and not is_port_free(port):
|
|
103
|
+
error_label.update(f"Port {port} is already in use.")
|
|
104
|
+
return
|
|
105
|
+
self.dismiss({"tag": tag, "host": host, "port": port})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _AddPacHostDialog(ModalScreen):
|
|
109
|
+
"""Modal for adding a PAC host."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, connections: list[str], pac_hosts: dict[str, list[str]], **kwargs) -> None:
|
|
112
|
+
super().__init__(**kwargs)
|
|
113
|
+
self._connections = connections
|
|
114
|
+
self._pac_hosts = pac_hosts # {conn_tag: [host, ...]}
|
|
115
|
+
|
|
116
|
+
def compose(self) -> ComposeResult:
|
|
117
|
+
options = [(tag, tag) for tag in self._connections]
|
|
118
|
+
with Static(classes="modal-dialog"):
|
|
119
|
+
yield Label("[bold]Add PAC Host[/bold]")
|
|
120
|
+
with Horizontal(classes="modal-form-row"):
|
|
121
|
+
with Static(classes="modal-field"):
|
|
122
|
+
yield Label("Host / wildcard / CIDR:")
|
|
123
|
+
yield Input(placeholder="*.example.com or 10.0.0.0/8", id="host")
|
|
124
|
+
with Static(classes="modal-field"):
|
|
125
|
+
yield Label("Connection:")
|
|
126
|
+
yield Select(options, allow_blank=False, id="conn")
|
|
127
|
+
yield _CollapsingLabel("", id="hint", classes="modal-hint")
|
|
128
|
+
yield _CollapsingLabel("", id="error", classes="modal-error")
|
|
129
|
+
with Horizontal(classes="modal-btn-row"):
|
|
130
|
+
yield Button("Add", id="btn-ok", variant="success")
|
|
131
|
+
yield Button("Cancel", id="btn-cancel")
|
|
132
|
+
|
|
133
|
+
def _update_hint(self) -> None:
|
|
134
|
+
host = self.query_one("#host", Input).value.strip()
|
|
135
|
+
selected_conn = _select_str(self, "#conn") or None
|
|
136
|
+
hint = self.query_one("#hint", Label)
|
|
137
|
+
if not host:
|
|
138
|
+
hint.update("")
|
|
139
|
+
return
|
|
140
|
+
in_same = selected_conn is not None and host in self._pac_hosts.get(selected_conn, [])
|
|
141
|
+
others = [tag for tag, hosts in self._pac_hosts.items() if host in hosts and tag != selected_conn]
|
|
142
|
+
if in_same and others:
|
|
143
|
+
hint.update(f"[red]Already in this connection and: {', '.join(others)}[/red]")
|
|
144
|
+
elif in_same:
|
|
145
|
+
hint.update("[red]Already assigned to this connection[/red]")
|
|
146
|
+
elif others:
|
|
147
|
+
hint.update(f"[yellow]Already in: {', '.join(others)}[/yellow]")
|
|
148
|
+
else:
|
|
149
|
+
hint.update("")
|
|
150
|
+
|
|
151
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
152
|
+
if event.input.id == "host":
|
|
153
|
+
self._update_hint()
|
|
154
|
+
|
|
155
|
+
def on_select_changed(self, event) -> None:
|
|
156
|
+
if event.select.id == "conn":
|
|
157
|
+
self._update_hint()
|
|
158
|
+
|
|
159
|
+
def on_button_pressed(self, event) -> None:
|
|
160
|
+
if event.button.id == "btn-cancel":
|
|
161
|
+
self.dismiss(None)
|
|
162
|
+
return
|
|
163
|
+
host = self.query_one("#host", Input).value.strip()
|
|
164
|
+
conn = _select_str(self, "#conn") or None
|
|
165
|
+
if not host:
|
|
166
|
+
self.query_one(".modal-error", Label).update("Host pattern is required.")
|
|
167
|
+
return
|
|
168
|
+
self.dismiss({"host": host, "conn": conn})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _AddForwardDialog(ModalScreen):
|
|
172
|
+
"""Modal for adding a local or remote port forward."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, direction: str, connections: list[str], **kwargs) -> None:
|
|
175
|
+
super().__init__(**kwargs)
|
|
176
|
+
self._direction = direction
|
|
177
|
+
self._connections = connections
|
|
178
|
+
|
|
179
|
+
def compose(self) -> ComposeResult:
|
|
180
|
+
d = self._direction
|
|
181
|
+
conn_options = [(tag, tag) for tag in self._connections]
|
|
182
|
+
bind_options = [("localhost", "localhost"), ("172.17.0.1", "172.17.0.1"), ("0.0.0.0", "0.0.0.0")]
|
|
183
|
+
with Static(classes="modal-dialog"):
|
|
184
|
+
yield Label(f"[bold]Add {d.capitalize()} Forward[/bold]")
|
|
185
|
+
with Horizontal(classes="modal-form-row"):
|
|
186
|
+
with Static(classes="modal-field"):
|
|
187
|
+
yield Label("Connection:")
|
|
188
|
+
yield Select(conn_options, allow_blank=False, id="conn")
|
|
189
|
+
with Static(classes="modal-field"):
|
|
190
|
+
yield Label("Label (optional):")
|
|
191
|
+
yield Input(placeholder="", id="tag")
|
|
192
|
+
with Horizontal(classes="modal-form-row"):
|
|
193
|
+
with Static(classes="modal-field"):
|
|
194
|
+
yield Label("Forward Local Port *:" if d == "local" else "Forward Remote Port *:")
|
|
195
|
+
yield Input(placeholder="8080", id="src-port")
|
|
196
|
+
with Static(classes="modal-field"):
|
|
197
|
+
yield Label("To Remote Port *:" if d == "local" else "To Local Port *:")
|
|
198
|
+
yield Input(placeholder="8080", id="dst-port")
|
|
199
|
+
with Horizontal(classes="modal-form-row"):
|
|
200
|
+
with Static(classes="modal-field"):
|
|
201
|
+
yield Label("Local Bind:" if d == "local" else "Remote Bind:")
|
|
202
|
+
yield Select(bind_options, allow_blank=False, id="src-addr")
|
|
203
|
+
with Static(classes="modal-field"):
|
|
204
|
+
yield Label("Remote Bind:" if d == "local" else "Local Bind:")
|
|
205
|
+
yield Select(bind_options, allow_blank=False, id="dst-addr")
|
|
206
|
+
yield Label("Protocol:")
|
|
207
|
+
with Horizontal(classes="modal-proto-row"):
|
|
208
|
+
yield Checkbox("TCP", value=True, id="proto-tcp")
|
|
209
|
+
yield Checkbox("UDP", value=False, id="proto-udp")
|
|
210
|
+
yield _CollapsingLabel("", id="error", classes="modal-error")
|
|
211
|
+
with Horizontal(classes="modal-btn-row"):
|
|
212
|
+
yield Button("Add", id="btn-ok", variant="success")
|
|
213
|
+
yield Button("Cancel", id="btn-cancel")
|
|
214
|
+
|
|
215
|
+
def on_button_pressed(self, event) -> None:
|
|
216
|
+
if event.button.id == "btn-cancel":
|
|
217
|
+
self.dismiss(None)
|
|
218
|
+
return
|
|
219
|
+
conn = _select_str(self, "#conn")
|
|
220
|
+
src_addr = _select_str(self, "#src-addr", "localhost")
|
|
221
|
+
dst_addr = _select_str(self, "#dst-addr", "localhost")
|
|
222
|
+
tag = self.query_one("#tag", Input).value.strip()
|
|
223
|
+
tcp = self.query_one("#proto-tcp", Checkbox).value
|
|
224
|
+
udp = self.query_one("#proto-udp", Checkbox).value
|
|
225
|
+
error_label = self.query_one(".modal-error", Label)
|
|
226
|
+
if not tcp and not udp:
|
|
227
|
+
error_label.update("Select at least one protocol (TCP or UDP).")
|
|
228
|
+
return
|
|
229
|
+
try:
|
|
230
|
+
src = int(self.query_one("#src-port", Input).value.strip())
|
|
231
|
+
dst = int(self.query_one("#dst-port", Input).value.strip())
|
|
232
|
+
except ValueError:
|
|
233
|
+
error_label.update("Ports must be valid numbers.")
|
|
234
|
+
return
|
|
235
|
+
if not validate_port(src) or not validate_port(dst):
|
|
236
|
+
error_label.update("Ports must be between 1 and 65535.")
|
|
237
|
+
return
|
|
238
|
+
if self._direction == "local" and not is_port_free(src):
|
|
239
|
+
error_label.update(f"Local port {src} is already in use.")
|
|
240
|
+
return
|
|
241
|
+
self.dismiss({
|
|
242
|
+
"conn": conn, "src": src, "dst": dst,
|
|
243
|
+
"src_addr": src_addr, "dst_addr": dst_addr,
|
|
244
|
+
"tag": tag, "dir": self._direction,
|
|
245
|
+
"tcp": tcp, "udp": udp,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class ConnectionsScreen(Screen):
|
|
250
|
+
"""TabbedContent CRUD screen for connections, PAC hosts, and port forwards."""
|
|
251
|
+
|
|
252
|
+
BINDINGS = [
|
|
253
|
+
Binding("escape", "app.pop_screen", "Back"),
|
|
254
|
+
Binding("a", "add_item", "Add"),
|
|
255
|
+
Binding("d", "delete_item", "Delete"),
|
|
256
|
+
Binding("t", "toggle_enabled", "Toggle"),
|
|
257
|
+
Binding("e", "test_item", "Test"),
|
|
258
|
+
Binding("s", "start_item", "Start"),
|
|
259
|
+
Binding("x", "stop_item", "Stop"),
|
|
260
|
+
Binding("r", "restart_item", "Restart"), # connections only
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
DEFAULT_CSS = """
|
|
264
|
+
ConnectionEditorScreen { layout: vertical; }
|
|
265
|
+
#editor-tabs { height: 1fr; }
|
|
266
|
+
DataTable { height: 1fr; }
|
|
267
|
+
#detail-preview {
|
|
268
|
+
height: 7;
|
|
269
|
+
border: round $primary-darken-1;
|
|
270
|
+
border-title-align: left;
|
|
271
|
+
padding: 0 1;
|
|
272
|
+
margin: 0 1 1 1;
|
|
273
|
+
color: $text-muted;
|
|
274
|
+
}
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def compose(self) -> ComposeResult:
|
|
278
|
+
# yield Header()
|
|
279
|
+
with TabbedContent(id="editor-tabs"):
|
|
280
|
+
with TabPane("Connections", id="tab-connections"):
|
|
281
|
+
yield DataTable(id="tbl-connections", cursor_type="row")
|
|
282
|
+
with TabPane("Domain / IP / CIDR", id="tab-pac"):
|
|
283
|
+
yield DataTable(id="tbl-pac", cursor_type="row")
|
|
284
|
+
with TabPane("Local Forwards", id="tab-local"):
|
|
285
|
+
yield DataTable(id="tbl-local", cursor_type="row")
|
|
286
|
+
with TabPane("Remote Forwards", id="tab-remote"):
|
|
287
|
+
yield DataTable(id="tbl-remote", cursor_type="row")
|
|
288
|
+
yield Static("", id="detail-preview")
|
|
289
|
+
yield from compose_footer()
|
|
290
|
+
|
|
291
|
+
def on_mount(self) -> None:
|
|
292
|
+
self.query_one("#detail-preview", Static).border_title = "Details"
|
|
293
|
+
self._setup_tables()
|
|
294
|
+
self._reload()
|
|
295
|
+
self.set_interval(5.0, self._bg_reload)
|
|
296
|
+
|
|
297
|
+
def _setup_tables(self) -> None:
|
|
298
|
+
tbl = self.query_one("#tbl-connections", DataTable)
|
|
299
|
+
tbl.add_columns("Status", "Tag", "SSH Host", "SOCKS Port", "Domains", "Forwards")
|
|
300
|
+
|
|
301
|
+
tbl = self.query_one("#tbl-pac", DataTable)
|
|
302
|
+
tbl.add_columns("Status", "Host", "Connection")
|
|
303
|
+
|
|
304
|
+
tbl = self.query_one("#tbl-local", DataTable)
|
|
305
|
+
tbl.add_columns("Status", "Connection", "Local Port", "Local Bind", "Remote Port", "Remote Bind", "Protocol",
|
|
306
|
+
"Label")
|
|
307
|
+
|
|
308
|
+
tbl = self.query_one("#tbl-remote", DataTable)
|
|
309
|
+
tbl.add_columns("Status", "Connection", "Remote Port", "Remote Bind", "Local Port", "Local Bind", "Protocol",
|
|
310
|
+
"Label")
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _reload_table(tbl: DataTable, rows: list[tuple[tuple, "str | None"]]) -> None:
|
|
314
|
+
"""Repopulate a DataTable while preserving the cursor row.
|
|
315
|
+
|
|
316
|
+
rows is a list of (row_values_tuple, key_or_None) pairs.
|
|
317
|
+
"""
|
|
318
|
+
cur = tbl.cursor_row
|
|
319
|
+
tbl.clear()
|
|
320
|
+
for row_args, key in rows:
|
|
321
|
+
if key is not None:
|
|
322
|
+
tbl.add_row(*row_args, key=key)
|
|
323
|
+
else:
|
|
324
|
+
tbl.add_row(*row_args)
|
|
325
|
+
if tbl.row_count:
|
|
326
|
+
tbl.move_cursor(row=min(cur, tbl.row_count - 1))
|
|
327
|
+
|
|
328
|
+
@work(thread=True)
|
|
329
|
+
def _bg_reload(self) -> None:
|
|
330
|
+
"""Background status refresh — fetches live tunnel state then updates UI."""
|
|
331
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
332
|
+
try:
|
|
333
|
+
status_result = mgr.status()
|
|
334
|
+
status_map = {cs.tag: cs.running for cs in status_result.connection_statuses}
|
|
335
|
+
except Exception:
|
|
336
|
+
status_map = {}
|
|
337
|
+
self.app.call_from_thread(self._reload, status_map)
|
|
338
|
+
|
|
339
|
+
def _reload(self, status_map: dict | None = None) -> None:
|
|
340
|
+
"""Repopulate all tables, preserving each table's cursor position."""
|
|
341
|
+
if status_map is None:
|
|
342
|
+
try:
|
|
343
|
+
status_result = self.app.manager.status() # type: ignore[attr-defined]
|
|
344
|
+
status_map = {cs.tag: cs.running for cs in status_result.connection_statuses}
|
|
345
|
+
except Exception:
|
|
346
|
+
status_map = {}
|
|
347
|
+
|
|
348
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
349
|
+
config = mgr.list_config()
|
|
350
|
+
|
|
351
|
+
conn_rows: list[tuple[tuple, str | None]] = []
|
|
352
|
+
for conn in config.connections:
|
|
353
|
+
running = status_map.get(conn.tag, False)
|
|
354
|
+
conn_rows.append((
|
|
355
|
+
(status_dot(running, conn.enabled), conn.tag, conn.ssh_host,
|
|
356
|
+
str(conn.socks_proxy_port) if conn.socks_proxy_port else "auto",
|
|
357
|
+
str(len(conn.pac_hosts)),
|
|
358
|
+
str(len(conn.forwards.local) + len(conn.forwards.remote))),
|
|
359
|
+
conn.tag,
|
|
360
|
+
))
|
|
361
|
+
self._reload_table(self.query_one("#tbl-connections", DataTable), conn_rows)
|
|
362
|
+
|
|
363
|
+
pac_rows: list[tuple[tuple, str | None]] = []
|
|
364
|
+
for conn in config.connections:
|
|
365
|
+
conn_running = status_map.get(conn.tag, False)
|
|
366
|
+
# Merge enabled + disabled hosts and sort alphabetically per
|
|
367
|
+
# connection — without this, toggling a host's enabled flag
|
|
368
|
+
# moves it across the enabled/disabled boundary in the table.
|
|
369
|
+
# Alphabetical sort keeps each host in a stable position
|
|
370
|
+
# regardless of state.
|
|
371
|
+
enabled_set = set(conn.pac_hosts)
|
|
372
|
+
all_hosts = sorted(enabled_set | set(conn.pac_hosts_disabled))
|
|
373
|
+
for host in all_hosts:
|
|
374
|
+
if host in enabled_set:
|
|
375
|
+
pac_rows.append((
|
|
376
|
+
(status_dot(conn_running), host, conn.tag),
|
|
377
|
+
f"{conn.tag}:{host}",
|
|
378
|
+
))
|
|
379
|
+
else:
|
|
380
|
+
pac_rows.append((
|
|
381
|
+
("─", host, conn.tag),
|
|
382
|
+
f"{conn.tag}:{host}",
|
|
383
|
+
))
|
|
384
|
+
self._reload_table(self.query_one("#tbl-pac", DataTable), pac_rows)
|
|
385
|
+
|
|
386
|
+
local_rows: list[tuple[tuple, str | None]] = []
|
|
387
|
+
for conn in config.connections:
|
|
388
|
+
conn_running = status_map.get(conn.tag, False)
|
|
389
|
+
for fw in conn.forwards.local:
|
|
390
|
+
dot = _fw_dot(mgr, fw, conn.tag, "local", conn_running)
|
|
391
|
+
local_rows.append((
|
|
392
|
+
(dot, conn.tag, str(fw.src_port), fw.src_addr,
|
|
393
|
+
str(fw.dst_port), fw.dst_addr, proto_label(fw), fw.tag or ""),
|
|
394
|
+
f"{conn.tag}:L:{fw.src_port}",
|
|
395
|
+
))
|
|
396
|
+
self._reload_table(self.query_one("#tbl-local", DataTable), local_rows)
|
|
397
|
+
|
|
398
|
+
remote_rows: list[tuple[tuple, str | None]] = []
|
|
399
|
+
for conn in config.connections:
|
|
400
|
+
conn_running = status_map.get(conn.tag, False)
|
|
401
|
+
for fw in conn.forwards.remote:
|
|
402
|
+
dot = _fw_dot(mgr, fw, conn.tag, "remote", conn_running)
|
|
403
|
+
remote_rows.append((
|
|
404
|
+
(dot, conn.tag, str(fw.src_port), fw.src_addr,
|
|
405
|
+
str(fw.dst_port), fw.dst_addr, proto_label(fw), fw.tag or ""),
|
|
406
|
+
f"{conn.tag}:R:{fw.src_port}",
|
|
407
|
+
))
|
|
408
|
+
self._reload_table(self.query_one("#tbl-remote", DataTable), remote_rows)
|
|
409
|
+
|
|
410
|
+
def _conn_tags(self) -> list[str]:
|
|
411
|
+
return [c.tag for c in self.app.manager.list_config().connections] # type: ignore[attr-defined]
|
|
412
|
+
|
|
413
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
414
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
415
|
+
preview = self.query_one("#detail-preview", Static)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
419
|
+
config = mgr.list_config()
|
|
420
|
+
conn_map = {c.tag: c for c in config.connections}
|
|
421
|
+
except Exception:
|
|
422
|
+
preview.update("")
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
if active == "tab-connections":
|
|
426
|
+
tbl = self.query_one("#tbl-connections", DataTable)
|
|
427
|
+
if tbl.row_count == 0:
|
|
428
|
+
preview.update("")
|
|
429
|
+
return
|
|
430
|
+
try:
|
|
431
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
432
|
+
tag = str(row[1])
|
|
433
|
+
conn = conn_map.get(tag)
|
|
434
|
+
if not conn:
|
|
435
|
+
preview.update("")
|
|
436
|
+
return
|
|
437
|
+
rx, tx = mgr.get_bandwidth(tag)
|
|
438
|
+
rx_total, tx_total = mgr.get_bandwidth_totals(tag)
|
|
439
|
+
uptime = mgr.get_uptime(tag)
|
|
440
|
+
port = conn.socks_proxy_port or "auto"
|
|
441
|
+
enabled_local = sum(1 for f in conn.forwards.local if f.enabled)
|
|
442
|
+
enabled_remote = sum(1 for f in conn.forwards.remote if f.enabled)
|
|
443
|
+
total_fwd = len(conn.forwards.local) + len(conn.forwards.remote)
|
|
444
|
+
enabled_fwd = enabled_local + enabled_remote
|
|
445
|
+
uptime_str = f"{int(uptime)}s" if uptime and uptime < 60 else (
|
|
446
|
+
f"{int(uptime // 60)}m{int(uptime % 60)}s" if uptime else "—")
|
|
447
|
+
lines = [
|
|
448
|
+
f"[bold]{conn.ssh_host}[/bold] SOCKS ::{port} up {uptime_str}",
|
|
449
|
+
f"[green]↓[/green] {fmt_bps(rx):>8} total [cyan]{fmt_bytes(rx_total)}[/cyan] "
|
|
450
|
+
f"[yellow]↑[/yellow] {fmt_bps(tx):>8} total [cyan]{fmt_bytes(tx_total)}[/cyan]",
|
|
451
|
+
f"Forwards {enabled_fwd}/{total_fwd} enabled "
|
|
452
|
+
f"({enabled_local} local · {enabled_remote} remote) "
|
|
453
|
+
f"PAC domains {len(conn.pac_hosts)}",
|
|
454
|
+
f"Proxy curl --proxy socks5h://127.0.0.1:{port} http://example.com",
|
|
455
|
+
]
|
|
456
|
+
preview.update("\n".join(lines))
|
|
457
|
+
except Exception:
|
|
458
|
+
preview.update("")
|
|
459
|
+
|
|
460
|
+
elif active == "tab-pac":
|
|
461
|
+
tbl = self.query_one("#tbl-pac", DataTable)
|
|
462
|
+
if tbl.row_count == 0:
|
|
463
|
+
preview.update("")
|
|
464
|
+
return
|
|
465
|
+
try:
|
|
466
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
467
|
+
pattern, conn_tag = str(row[1]), str(row[2])
|
|
468
|
+
conn = conn_map.get(conn_tag)
|
|
469
|
+
port = conn.socks_proxy_port if conn else "?"
|
|
470
|
+
example = pattern.lstrip("*.")
|
|
471
|
+
lines = [
|
|
472
|
+
f"Pattern [bold]{pattern}[/bold]",
|
|
473
|
+
f"Connection {conn_tag}",
|
|
474
|
+
f"Proxy SOCKS5 127.0.0.1:{port}",
|
|
475
|
+
f"Example curl --proxy socks5h://127.0.0.1:{port} http://{example}",
|
|
476
|
+
]
|
|
477
|
+
preview.update("\n".join(lines))
|
|
478
|
+
except Exception:
|
|
479
|
+
preview.update("")
|
|
480
|
+
|
|
481
|
+
elif active in ("tab-local", "tab-remote"):
|
|
482
|
+
direction = "local" if active == "tab-local" else "remote"
|
|
483
|
+
tbl_id = f"#tbl-{direction}"
|
|
484
|
+
tbl = self.query_one(tbl_id, DataTable)
|
|
485
|
+
if tbl.row_count == 0:
|
|
486
|
+
preview.update("")
|
|
487
|
+
return
|
|
488
|
+
try:
|
|
489
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
490
|
+
# row: Status, Connection, src_port, src_addr, dst_port, dst_addr, Protocol, Label
|
|
491
|
+
conn_tag = str(row[1])
|
|
492
|
+
src_port, src_addr = int(str(row[2])), str(row[3])
|
|
493
|
+
dst_port, dst_addr = int(str(row[4])), str(row[5])
|
|
494
|
+
proto = str(row[6])
|
|
495
|
+
label = str(row[7])
|
|
496
|
+
conn = conn_map.get(conn_tag)
|
|
497
|
+
|
|
498
|
+
enabled_fwds = (conn.forwards.local if direction == "local" else conn.forwards.remote) if conn else []
|
|
499
|
+
fw = next((f for f in enabled_fwds if f.src_port == src_port), None)
|
|
500
|
+
enabled = fw.enabled if fw else False
|
|
501
|
+
state = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
|
|
502
|
+
|
|
503
|
+
port_free = is_port_free(src_port)
|
|
504
|
+
port_status = "[green]✓ free[/green]" if port_free else "[red]✗ in use[/red]"
|
|
505
|
+
|
|
506
|
+
fwd_spec = f"{src_addr}:{src_port}:{dst_addr}:{dst_port}"
|
|
507
|
+
flag = "-L" if direction == "local" else "-R"
|
|
508
|
+
|
|
509
|
+
lines = [
|
|
510
|
+
f"[bold]{label or conn_tag}[/bold] {state} {proto} via {conn_tag}",
|
|
511
|
+
f"Bind {src_addr}:{src_port} → {dst_addr}:{dst_port}",
|
|
512
|
+
f"Port {src_port} {port_status}",
|
|
513
|
+
f"Forward ssh -O forward {flag} {fwd_spec}",
|
|
514
|
+
]
|
|
515
|
+
preview.update("\n".join(lines))
|
|
516
|
+
except Exception:
|
|
517
|
+
preview.update("")
|
|
518
|
+
else:
|
|
519
|
+
preview.update("")
|
|
520
|
+
|
|
521
|
+
def action_add_item(self) -> None:
|
|
522
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
523
|
+
if active == "tab-connections":
|
|
524
|
+
self._do_add_connection()
|
|
525
|
+
elif active == "tab-pac":
|
|
526
|
+
self._do_add_pac()
|
|
527
|
+
elif active == "tab-local":
|
|
528
|
+
self._do_add_forward("local")
|
|
529
|
+
elif active == "tab-remote":
|
|
530
|
+
self._do_add_forward("remote")
|
|
531
|
+
|
|
532
|
+
def action_delete_item(self) -> None:
|
|
533
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
534
|
+
if active == "tab-connections":
|
|
535
|
+
self._do_rm_connection()
|
|
536
|
+
elif active == "tab-pac":
|
|
537
|
+
self._do_rm_pac()
|
|
538
|
+
elif active == "tab-local":
|
|
539
|
+
self._do_rm_forward("local")
|
|
540
|
+
elif active == "tab-remote":
|
|
541
|
+
self._do_rm_forward("remote")
|
|
542
|
+
|
|
543
|
+
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
544
|
+
self.refresh_bindings()
|
|
545
|
+
|
|
546
|
+
def check_action(self, action: str, parameters: tuple) -> bool | None:
|
|
547
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
548
|
+
on_forward = active in ("tab-local", "tab-remote")
|
|
549
|
+
on_conn = active == "tab-connections"
|
|
550
|
+
if action in ("start_item", "stop_item"):
|
|
551
|
+
return on_conn or on_forward
|
|
552
|
+
if action == "restart_item":
|
|
553
|
+
return on_conn # restart not applicable to forwards
|
|
554
|
+
if action == "toggle_enabled":
|
|
555
|
+
return active in ("tab-connections", "tab-pac", "tab-local", "tab-remote")
|
|
556
|
+
return True
|
|
557
|
+
|
|
558
|
+
def _selected_conn_tag(self) -> str | None:
|
|
559
|
+
tbl = self.query_one("#tbl-connections", DataTable)
|
|
560
|
+
if tbl.row_count == 0:
|
|
561
|
+
return None
|
|
562
|
+
try:
|
|
563
|
+
return str(tbl.get_row_at(tbl.cursor_row)[1])
|
|
564
|
+
except (IndexError, Exception):
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
def _selected_forward(self, direction: str) -> tuple[str, int] | None:
|
|
568
|
+
tbl_id = "#tbl-local" if direction == "local" else "#tbl-remote"
|
|
569
|
+
tbl = self.query_one(tbl_id, DataTable)
|
|
570
|
+
if tbl.row_count == 0:
|
|
571
|
+
return None
|
|
572
|
+
try:
|
|
573
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
574
|
+
return str(row[1]), int(str(row[2]))
|
|
575
|
+
except (IndexError, ValueError):
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
def action_start_item(self) -> None:
|
|
579
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
580
|
+
if active == "tab-connections":
|
|
581
|
+
if tag := self._selected_conn_tag():
|
|
582
|
+
self._run_conn_op("start", tag)
|
|
583
|
+
elif active in ("tab-local", "tab-remote"):
|
|
584
|
+
direction = "local" if active == "tab-local" else "remote"
|
|
585
|
+
if sel := self._selected_forward(direction):
|
|
586
|
+
self._run_forward_op("start", sel[0], sel[1], direction)
|
|
587
|
+
|
|
588
|
+
def action_stop_item(self) -> None:
|
|
589
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
590
|
+
if active == "tab-connections":
|
|
591
|
+
if tag := self._selected_conn_tag():
|
|
592
|
+
self._run_conn_op("stop", tag)
|
|
593
|
+
elif active in ("tab-local", "tab-remote"):
|
|
594
|
+
direction = "local" if active == "tab-local" else "remote"
|
|
595
|
+
if sel := self._selected_forward(direction):
|
|
596
|
+
self._run_forward_op("stop", sel[0], sel[1], direction)
|
|
597
|
+
|
|
598
|
+
def action_restart_item(self) -> None:
|
|
599
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
600
|
+
if active == "tab-connections":
|
|
601
|
+
if tag := self._selected_conn_tag():
|
|
602
|
+
self._run_conn_op("restart", tag)
|
|
603
|
+
elif active in ("tab-local", "tab-remote"):
|
|
604
|
+
direction = "local" if active == "tab-local" else "remote"
|
|
605
|
+
if sel := self._selected_forward(direction):
|
|
606
|
+
self._run_forward_op("restart", sel[0], sel[1], direction)
|
|
607
|
+
|
|
608
|
+
def action_test_item(self) -> None:
|
|
609
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
610
|
+
if active == "tab-connections":
|
|
611
|
+
if tag := self._selected_conn_tag():
|
|
612
|
+
self._run_test_conn(tag)
|
|
613
|
+
elif active == "tab-pac":
|
|
614
|
+
tbl = self.query_one("#tbl-pac", DataTable)
|
|
615
|
+
if tbl.row_count == 0:
|
|
616
|
+
return
|
|
617
|
+
try:
|
|
618
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
619
|
+
host, conn_tag = str(row[1]), str(row[2])
|
|
620
|
+
self._run_test_domain(host, conn_tag)
|
|
621
|
+
except (IndexError, ValueError):
|
|
622
|
+
pass
|
|
623
|
+
elif active in ("tab-local", "tab-remote"):
|
|
624
|
+
direction = "local" if active == "tab-local" else "remote"
|
|
625
|
+
if sel := self._selected_forward(direction):
|
|
626
|
+
self._run_test_forward(sel[0], sel[1], direction)
|
|
627
|
+
|
|
628
|
+
@work(thread=True)
|
|
629
|
+
def _run_test_conn(self, conn_tag: str) -> None:
|
|
630
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
631
|
+
config = mgr.list_config()
|
|
632
|
+
conn = next((c for c in config.connections if c.tag == conn_tag), None)
|
|
633
|
+
ssh_host = conn.ssh_host if conn else conn_tag
|
|
634
|
+
result = mgr.test_connection(conn_tag)
|
|
635
|
+
if result.success:
|
|
636
|
+
dot = "[green]●[/green]"
|
|
637
|
+
msg = f"{result.message} [dim]{result.latency_ms:.0f}ms[/dim]"
|
|
638
|
+
else:
|
|
639
|
+
dot = "[red]○[/red]"
|
|
640
|
+
msg = result.message
|
|
641
|
+
body = f"[dim]SSH host:[/dim] {ssh_host}\n{dot} {msg}"
|
|
642
|
+
severity = "information" if result.success else "error"
|
|
643
|
+
self.app.call_from_thread(
|
|
644
|
+
self.app.notify, body, title=f"Test: {conn_tag}", severity=severity, timeout=8.0
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
@work(thread=True)
|
|
648
|
+
def _run_test_domain(self, host: str, conn_tag: str) -> None:
|
|
649
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
650
|
+
result = mgr.test_domain(host, conn_tag)
|
|
651
|
+
if result.success:
|
|
652
|
+
dot = "[green]●[/green]"
|
|
653
|
+
msg = f"{result.message} [dim]{result.latency_ms:.0f}ms[/dim]"
|
|
654
|
+
else:
|
|
655
|
+
dot = "[red]○[/red]"
|
|
656
|
+
msg = result.message
|
|
657
|
+
body = f"[dim]Connection:[/dim] {conn_tag}\n[dim]Host:[/dim] {host}\n{dot} {msg}"
|
|
658
|
+
severity = "information" if result.success else "error"
|
|
659
|
+
self.app.call_from_thread(
|
|
660
|
+
self.app.notify, body, title=f"Test: {host}", severity=severity, timeout=8.0
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
@work(thread=True)
|
|
664
|
+
def _run_test_forward(self, conn_tag: str, src_port: int, direction: str) -> None:
|
|
665
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
666
|
+
try:
|
|
667
|
+
results = mgr.test_forward(conn_tag, src_port, direction)
|
|
668
|
+
except Exception as exc:
|
|
669
|
+
self.app.call_from_thread(
|
|
670
|
+
self.app.notify, f"[red]{exc}[/red]",
|
|
671
|
+
title=f"Test: {direction} {src_port}", severity="error", timeout=8.0,
|
|
672
|
+
)
|
|
673
|
+
return
|
|
674
|
+
parts: list[str] = [
|
|
675
|
+
f"[dim]Connection:[/dim] {conn_tag}",
|
|
676
|
+
f"[dim]Direction:[/dim] {direction} port {src_port}",
|
|
677
|
+
]
|
|
678
|
+
all_ok = True
|
|
679
|
+
for proto, ok in results.items():
|
|
680
|
+
dot = "[green]●[/green]" if ok else "[red]○[/red]"
|
|
681
|
+
if not ok:
|
|
682
|
+
all_ok = False
|
|
683
|
+
if proto == "tcp":
|
|
684
|
+
detail = "port bound" if ok else "port not bound"
|
|
685
|
+
if direction == "remote":
|
|
686
|
+
detail = "master socket alive" if ok else "master socket dead"
|
|
687
|
+
else:
|
|
688
|
+
detail = "socat process running" if ok else "socat process not running"
|
|
689
|
+
parts.append(f"{dot} {proto.upper()} {detail}")
|
|
690
|
+
severity = "information" if all_ok else "error"
|
|
691
|
+
self.app.call_from_thread(
|
|
692
|
+
self.app.notify, "\n".join(parts),
|
|
693
|
+
title=f"Test: {direction} :{src_port}", severity=severity, timeout=8.0,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
def action_toggle_enabled(self) -> None:
|
|
697
|
+
active = self.query_one("#editor-tabs", TabbedContent).active
|
|
698
|
+
if active == "tab-connections":
|
|
699
|
+
if tag := self._selected_conn_tag():
|
|
700
|
+
self._run_toggle_conn(tag)
|
|
701
|
+
elif active == "tab-pac":
|
|
702
|
+
tbl = self.query_one("#tbl-pac", DataTable)
|
|
703
|
+
if tbl.row_count == 0:
|
|
704
|
+
return
|
|
705
|
+
try:
|
|
706
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
707
|
+
host, conn_tag = str(row[1]), str(row[2])
|
|
708
|
+
self._run_toggle_pac(host, conn_tag)
|
|
709
|
+
except (IndexError, ValueError):
|
|
710
|
+
pass
|
|
711
|
+
elif active in ("tab-local", "tab-remote"):
|
|
712
|
+
direction = "local" if active == "tab-local" else "remote"
|
|
713
|
+
if sel := self._selected_forward(direction):
|
|
714
|
+
self._run_toggle_forward(sel[0], sel[1], direction)
|
|
715
|
+
|
|
716
|
+
@work(thread=True)
|
|
717
|
+
def _run_toggle_conn(self, tag: str) -> None:
|
|
718
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
719
|
+
config = mgr.list_config()
|
|
720
|
+
conn = next((c for c in config.connections if c.tag == tag), None)
|
|
721
|
+
if conn:
|
|
722
|
+
try:
|
|
723
|
+
mgr.set_connection_enabled(tag, not conn.enabled)
|
|
724
|
+
except Exception as exc:
|
|
725
|
+
self.app.call_from_thread(self.notify, str(exc), title="SusOps", severity="error", timeout=3)
|
|
726
|
+
self.app.call_from_thread(self._bg_reload)
|
|
727
|
+
|
|
728
|
+
@work(thread=True)
|
|
729
|
+
def _run_toggle_pac(self, host: str, conn_tag: str) -> None:
|
|
730
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
731
|
+
config = mgr.list_config()
|
|
732
|
+
conn = next((c for c in config.connections if c.tag == conn_tag), None)
|
|
733
|
+
if conn:
|
|
734
|
+
currently_enabled = host in conn.pac_hosts
|
|
735
|
+
try:
|
|
736
|
+
mgr.set_pac_host_enabled(host, not currently_enabled, conn_tag=conn_tag)
|
|
737
|
+
except Exception as exc:
|
|
738
|
+
self.app.call_from_thread(self.notify, str(exc), title="SusOps", severity="error", timeout=3)
|
|
739
|
+
self.app.call_from_thread(self._bg_reload)
|
|
740
|
+
|
|
741
|
+
@work(thread=True)
|
|
742
|
+
def _run_toggle_forward(self, conn_tag: str, src_port: int, direction: str) -> None:
|
|
743
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
744
|
+
try:
|
|
745
|
+
mgr.toggle_forward_enabled(conn_tag, src_port, direction)
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
self.app.call_from_thread(self.notify, str(exc), title="SusOps", severity="error", timeout=3)
|
|
748
|
+
self.app.call_from_thread(self._bg_reload)
|
|
749
|
+
|
|
750
|
+
@work(thread=True)
|
|
751
|
+
def _run_conn_op(self, op: str, tag: str) -> None:
|
|
752
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
753
|
+
if op == "start":
|
|
754
|
+
mgr.start(tag=tag)
|
|
755
|
+
elif op == "stop":
|
|
756
|
+
mgr.stop(tag=tag)
|
|
757
|
+
elif op == "restart":
|
|
758
|
+
mgr.restart(tag=tag)
|
|
759
|
+
self.app.call_from_thread(self._bg_reload)
|
|
760
|
+
|
|
761
|
+
@work(thread=True)
|
|
762
|
+
def _run_forward_op(self, op: str, conn_tag: str, src_port: int, direction: str) -> None:
|
|
763
|
+
mgr = self.app.manager # type: ignore[attr-defined]
|
|
764
|
+
try:
|
|
765
|
+
if op == "start":
|
|
766
|
+
mgr.set_forward_enabled(conn_tag, src_port, direction, True)
|
|
767
|
+
elif op == "stop":
|
|
768
|
+
mgr.set_forward_enabled(conn_tag, src_port, direction, False)
|
|
769
|
+
elif op == "restart":
|
|
770
|
+
mgr.set_forward_enabled(conn_tag, src_port, direction, False)
|
|
771
|
+
mgr.set_forward_enabled(conn_tag, src_port, direction, True)
|
|
772
|
+
except Exception as exc:
|
|
773
|
+
self.app.call_from_thread(self.notify, str(exc), title="SusOps", severity="error", timeout=3)
|
|
774
|
+
self.app.call_from_thread(self._bg_reload)
|
|
775
|
+
|
|
776
|
+
# --- Connection CRUD ---
|
|
777
|
+
|
|
778
|
+
def _do_add_connection(self) -> None:
|
|
779
|
+
def _on_result(data) -> None:
|
|
780
|
+
if not data:
|
|
781
|
+
return
|
|
782
|
+
try:
|
|
783
|
+
self.app.manager.add_connection(data["tag"], data["host"], data["port"]) # type: ignore[attr-defined]
|
|
784
|
+
self._bg_reload()
|
|
785
|
+
except ValueError as e:
|
|
786
|
+
self.app.notify(str(e), title="SusOps", severity="error")
|
|
787
|
+
|
|
788
|
+
self.app.push_screen(_AddConnectionDialog(), _on_result)
|
|
789
|
+
|
|
790
|
+
def _do_rm_connection(self) -> None:
|
|
791
|
+
tbl = self.query_one("#tbl-connections", DataTable)
|
|
792
|
+
if tbl.row_count == 0:
|
|
793
|
+
return
|
|
794
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
795
|
+
tag = str(row[1])
|
|
796
|
+
try:
|
|
797
|
+
self.app.manager.remove_connection(tag) # type: ignore[attr-defined]
|
|
798
|
+
self._bg_reload()
|
|
799
|
+
except ValueError as e:
|
|
800
|
+
self.app.notify(str(e), title="SusOps", severity="error")
|
|
801
|
+
|
|
802
|
+
# --- PAC host CRUD ---
|
|
803
|
+
|
|
804
|
+
def _do_add_pac(self) -> None:
|
|
805
|
+
def _on_result(data) -> None:
|
|
806
|
+
if not data:
|
|
807
|
+
return
|
|
808
|
+
try:
|
|
809
|
+
self.app.manager.add_pac_host(data["host"], conn_tag=data["conn"]) # type: ignore[attr-defined]
|
|
810
|
+
self._bg_reload()
|
|
811
|
+
except ValueError as e:
|
|
812
|
+
self.app.notify(str(e), title="SusOps", severity="error")
|
|
813
|
+
|
|
814
|
+
config = self.app.manager.list_config() # type: ignore[attr-defined]
|
|
815
|
+
pac_hosts = {c.tag: list(c.pac_hosts) for c in config.connections}
|
|
816
|
+
self.app.push_screen(_AddPacHostDialog(self._conn_tags(), pac_hosts), _on_result)
|
|
817
|
+
|
|
818
|
+
def _do_rm_pac(self) -> None:
|
|
819
|
+
tbl = self.query_one("#tbl-pac", DataTable)
|
|
820
|
+
if tbl.row_count == 0:
|
|
821
|
+
return
|
|
822
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
823
|
+
host = str(row[1])
|
|
824
|
+
conn_tag = str(row[2])
|
|
825
|
+
try:
|
|
826
|
+
self.app.manager.remove_pac_host(host, conn_tag=conn_tag) # type: ignore[attr-defined]
|
|
827
|
+
self._bg_reload()
|
|
828
|
+
except ValueError as e:
|
|
829
|
+
self.app.notify(str(e), title="SusOps", severity="error")
|
|
830
|
+
|
|
831
|
+
# --- Port forward CRUD ---
|
|
832
|
+
|
|
833
|
+
def _do_add_forward(self, direction: str) -> None:
|
|
834
|
+
def _on_result(data) -> None:
|
|
835
|
+
if not data:
|
|
836
|
+
return
|
|
837
|
+
fw = PortForward(
|
|
838
|
+
src_addr=data["src_addr"],
|
|
839
|
+
src_port=data["src"],
|
|
840
|
+
dst_addr=data["dst_addr"],
|
|
841
|
+
dst_port=data["dst"],
|
|
842
|
+
tag=data["tag"],
|
|
843
|
+
tcp=data["tcp"],
|
|
844
|
+
udp=data["udp"],
|
|
845
|
+
)
|
|
846
|
+
try:
|
|
847
|
+
if direction == "local":
|
|
848
|
+
self.app.manager.add_local_forward(data["conn"], fw) # type: ignore[attr-defined]
|
|
849
|
+
else:
|
|
850
|
+
self.app.manager.add_remote_forward(data["conn"], fw) # type: ignore[attr-defined]
|
|
851
|
+
self._bg_reload()
|
|
852
|
+
except ValueError as e:
|
|
853
|
+
self.app.notify(str(e), title="SusOps", severity="error")
|
|
854
|
+
|
|
855
|
+
self.app.push_screen(_AddForwardDialog(direction, self._conn_tags()), _on_result)
|
|
856
|
+
|
|
857
|
+
def _do_rm_forward(self, direction: str) -> None:
|
|
858
|
+
tbl_id = "#tbl-local" if direction == "local" else "#tbl-remote"
|
|
859
|
+
tbl = self.query_one(tbl_id, DataTable)
|
|
860
|
+
if tbl.row_count == 0:
|
|
861
|
+
return
|
|
862
|
+
row = tbl.get_row_at(tbl.cursor_row)
|
|
863
|
+
port = int(str(row[2]))
|
|
864
|
+
try:
|
|
865
|
+
if direction == "local":
|
|
866
|
+
self.app.manager.remove_local_forward(port) # type: ignore[attr-defined]
|
|
867
|
+
else:
|
|
868
|
+
self.app.manager.remove_remote_forward(port) # type: ignore[attr-defined]
|
|
869
|
+
self._bg_reload()
|
|
870
|
+
except ValueError as e:
|
|
871
|
+
self.app.notify(str(e), title="SusOps", severity="error")
|