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/core/config.py ADDED
@@ -0,0 +1,253 @@
1
+ """Config module for SusOps.
2
+
3
+ Defines all Pydantic v2 config models and handles reading/writing
4
+ ~/.susops/config.yaml using ruamel.yaml for comment preservation.
5
+
6
+ Models:
7
+ - PortForward: A single port forward rule (local or remote)
8
+ - Forwards: Container for local and remote port forward lists
9
+ - Connection: A single SSH connection configuration
10
+ - AppConfig: Application-level settings
11
+ - SusOpsConfig: Root config model
12
+
13
+ I/O Functions:
14
+ - get_config_path: Resolve path to config.yaml
15
+ - load_config: Load config from disk, creating defaults if missing
16
+ - save_config: Persist config to disk with ruamel.yaml
17
+
18
+ Helper Functions:
19
+ - get_connection: Find a connection by tag
20
+ - get_default_connection: Return the first connection or None
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
29
+ from ruamel.yaml import YAML
30
+
31
+ from susops.core.types import LogoStyle
32
+
33
+ __all__ = [
34
+ "PortForward",
35
+ "Forwards",
36
+ "FileShare",
37
+ "Connection",
38
+ "AppConfig",
39
+ "SusOpsConfig",
40
+ "get_config_path",
41
+ "load_config",
42
+ "save_config",
43
+ "get_connection",
44
+ "get_default_connection",
45
+ "WORKSPACE_DEFAULT",
46
+ "CONFIG_FILENAME",
47
+ ]
48
+
49
+ WORKSPACE_DEFAULT = Path.home() / ".susops"
50
+ CONFIG_FILENAME = "config.yaml"
51
+
52
+
53
+ class PortForward(BaseModel):
54
+ model_config = ConfigDict(populate_by_name=True)
55
+
56
+ tag: str = ""
57
+ src_addr: str = "localhost"
58
+ src_port: int
59
+ dst_addr: str = "localhost"
60
+ dst_port: int
61
+ tcp: bool = True
62
+ udp: bool = False
63
+ enabled: bool = True
64
+
65
+ @model_validator(mode="before")
66
+ @classmethod
67
+ def handle_legacy_schema(cls, data: Any) -> Any:
68
+ """Handle old schema where 'src'/'dst' were plain port numbers."""
69
+ if isinstance(data, dict) and "src" in data and "src_port" not in data:
70
+ data = dict(data)
71
+ data["src_port"] = int(data.pop("src"))
72
+ data["dst_port"] = int(data.pop("dst", data["src_port"]))
73
+ return data
74
+
75
+ @model_validator(mode="after")
76
+ def require_at_least_one_protocol(self) -> "PortForward":
77
+ # Runs after handle_legacy_schema has already normalised the dict,
78
+ # so self.tcp and self.udp are already coerced to bool.
79
+ if not self.tcp and not self.udp:
80
+ raise ValueError("At least one of tcp/udp must be True")
81
+ return self
82
+
83
+
84
+ class Forwards(BaseModel):
85
+ local: list[PortForward] = []
86
+ remote: list[PortForward] = []
87
+
88
+
89
+ class FileShare(BaseModel):
90
+ """A persisted file share associated with a connection."""
91
+
92
+ file_path: str
93
+ password: str
94
+ port: int = 0 # 0 = auto-assigned, written back after first start
95
+ stopped: bool = False # True when manually stopped — not auto-restarted
96
+
97
+
98
+ class Connection(BaseModel):
99
+ tag: str
100
+ ssh_host: str
101
+ socks_proxy_port: int = 0
102
+ enabled: bool = True
103
+ forwards: Forwards = Forwards()
104
+ pac_hosts: list[str] = []
105
+ pac_hosts_disabled: list[str] = []
106
+ file_shares: list[FileShare] = []
107
+
108
+
109
+ class AppConfig(BaseModel):
110
+ stop_on_quit: bool = True
111
+ ephemeral_ports: bool = False
112
+ logo_style: LogoStyle = LogoStyle.COLORED_GLASSES
113
+ restore_shares_on_start: bool = True
114
+ tray_show_bandwidth: bool = False
115
+
116
+ @field_validator(
117
+ "stop_on_quit",
118
+ "ephemeral_ports",
119
+ "restore_shares_on_start",
120
+ "tray_show_bandwidth",
121
+ mode="before",
122
+ )
123
+ @classmethod
124
+ def coerce_bool_string(cls, v: Any) -> Any:
125
+ """Handle "1"/"0" string values from old yq-based config."""
126
+ if isinstance(v, str):
127
+ return v.strip() in ("1", "true", "True", "yes")
128
+ return v
129
+
130
+ @field_validator("logo_style", mode="before")
131
+ @classmethod
132
+ def coerce_logo_style(cls, v: Any) -> Any:
133
+ if isinstance(v, str):
134
+ return LogoStyle(v)
135
+ return v
136
+
137
+
138
+ class SusOpsConfig(BaseModel):
139
+ # Server ports are at the top of the config so they're the first thing
140
+ # users see when they open the file. All three default to 0 (auto-allocate
141
+ # at startup and write back).
142
+ rpc_server_port: int = 0
143
+ status_server_port: int = 0
144
+ pac_server_port: int = 0
145
+ connections: list[Connection] = []
146
+ susops_app: AppConfig = AppConfig()
147
+
148
+ @model_validator(mode="before")
149
+ @classmethod
150
+ def _migrate_status_server_port(cls, data: Any) -> Any:
151
+ """Pull legacy susops_app.status_server_port up to the top level.
152
+ """
153
+ if not isinstance(data, dict):
154
+ return data
155
+ app = data.get("susops_app")
156
+ if isinstance(app, dict) and "status_server_port" in app:
157
+ legacy = app.pop("status_server_port")
158
+ # Only adopt the legacy value when the new field isn't already set
159
+ # to something non-default.
160
+ if not data.get("status_server_port"):
161
+ data["status_server_port"] = legacy
162
+ return data
163
+
164
+
165
+ def get_config_path(workspace: Path = WORKSPACE_DEFAULT) -> Path:
166
+ """Return the path to config.yaml within the given workspace directory."""
167
+ return workspace / CONFIG_FILENAME
168
+
169
+
170
+ def load_config(workspace: Path = WORKSPACE_DEFAULT) -> SusOpsConfig:
171
+ """Load config from workspace/config.yaml. Creates default config if missing."""
172
+ path = get_config_path(workspace)
173
+ if not path.exists():
174
+ config = SusOpsConfig()
175
+ save_config(config, workspace)
176
+ return config
177
+ yaml = YAML()
178
+ data = yaml.load(path)
179
+ if data is None:
180
+ return SusOpsConfig()
181
+ return SusOpsConfig.model_validate(dict(data))
182
+
183
+
184
+ def save_config(config: SusOpsConfig, workspace: Path = WORKSPACE_DEFAULT) -> None:
185
+ """Save config to workspace/config.yaml using ruamel.yaml for comment preservation.
186
+
187
+ Writes atomically (to a temp file, then POSIX rename) so a concurrent
188
+ load_config never observes a half-written or freshly-truncated file. The
189
+ old behavior — `open(path, 'w')` — truncated the file to 0 bytes before
190
+ `yaml.dump` ran; a reader in that window got an empty file → empty config →
191
+ and any save that followed wiped the connections list. The TUI's
192
+ `@work(thread=True)` makes this a real (and reported) race on rapid Stop
193
+ clicks.
194
+ """
195
+ import os
196
+ path = get_config_path(workspace)
197
+ path.parent.mkdir(parents=True, exist_ok=True)
198
+ yaml = YAML()
199
+ yaml.default_flow_style = False
200
+ yaml.indent(mapping=2, sequence=4, offset=2)
201
+ # Convert to plain dict via model_dump, then save
202
+ data = config.model_dump(mode='python')
203
+ # Convert enums to their values for serialization
204
+ data['susops_app']['logo_style'] = config.susops_app.logo_style.value
205
+ # Compute the target mode BEFORE writing: preserve any user-set restrictive
206
+ # permissions (e.g. chmod 600 on a hardened install) since Path.replace()
207
+ # would otherwise clobber them with the temp file's umask-derived mode.
208
+ # New configs default to 0o600 — the file holds share passwords.
209
+ try:
210
+ target_mode = path.stat().st_mode & 0o777
211
+ except OSError:
212
+ target_mode = 0o600
213
+ # Temp file in the same directory so the rename stays on one filesystem.
214
+ # Open with O_EXCL so we never overwrite a leftover temp file from a
215
+ # crashed earlier save (would otherwise inherit its mode).
216
+ tmp_path = path.with_name(path.name + ".tmp")
217
+ try:
218
+ fd = os.open(
219
+ str(tmp_path),
220
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
221
+ target_mode,
222
+ )
223
+ try:
224
+ with os.fdopen(fd, 'w') as f:
225
+ yaml.dump(data, f)
226
+ except Exception:
227
+ # fdopen owns fd on success; on failure we may need to close it
228
+ # if fdopen never took ownership. Best-effort.
229
+ try:
230
+ os.close(fd)
231
+ except OSError:
232
+ pass
233
+ raise
234
+
235
+ os.chmod(tmp_path, target_mode)
236
+ tmp_path.replace(path)
237
+ except Exception:
238
+ # Best-effort cleanup of the temp file if writing failed.
239
+ try:
240
+ tmp_path.unlink(missing_ok=True)
241
+ except Exception:
242
+ pass
243
+ raise
244
+
245
+
246
+ def get_connection(config: SusOpsConfig, tag: str) -> Connection | None:
247
+ """Find a connection by tag."""
248
+ return next((c for c in config.connections if c.tag == tag), None)
249
+
250
+
251
+ def get_default_connection(config: SusOpsConfig) -> Connection | None:
252
+ """Return the first connection, or None if there are none."""
253
+ return config.connections[0] if config.connections else None
@@ -0,0 +1,92 @@
1
+ """Shared log-line styler.
2
+
3
+ Splits a raw log line into colored segments. Used by every frontend (TUI's
4
+ RichLog, the macOS NSTextView, the Linux GtkTextView) so the colour rules
5
+ stay consistent across surfaces.
6
+
7
+ Each segment is a ``(text, color)`` tuple where ``color`` is one of:
8
+
9
+ None — default text colour
10
+ "tag" — connection / debug tag prefix (cyan)
11
+ "ok" — success keywords (green)
12
+ "warn" — non-fatal status keywords (yellow)
13
+ "err" — failure keywords (red)
14
+ "dim" — supplementary detail like PID numbers (gray)
15
+ "info" — neutral highlight (blue)
16
+
17
+ Frontends map these labels to concrete colours.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import List, Tuple
23
+
24
+ LogSegment = Tuple[str, str | None]
25
+
26
+ # Order matters — first match wins per character offset. Earlier entries take
27
+ # precedence when ranges overlap.
28
+ _PATTERNS: list[tuple[re.Pattern[str], str]] = [
29
+ # Timestamp at the very start of the line: [HH:MM:SS]. Must match before
30
+ # the generic "tag prefix" rule so the clock doesn't get coloured like a
31
+ # connection tag.
32
+ (re.compile(r"^\[\d{2}:\d{2}:\d{2}\]"), "dim"),
33
+ # Tag prefix at the start of the line: [pi3] or [debug] or [error].
34
+ # Anchored to either the line start or the position immediately after a
35
+ # timestamp + space so both `[pi3] ...` and `[16:42:03] [pi3] ...` work.
36
+ (re.compile(r"(?:^|(?<=^\[\d{2}:\d{2}:\d{2}\] ))\[[^\]]+\]"), "tag"),
37
+ # Parenthesised PID detail (and similar dim suffixes).
38
+ (re.compile(r"\(PID \d+\)", re.IGNORECASE), "dim"),
39
+ (re.compile(r"\(pid=\d+\)", re.IGNORECASE), "dim"),
40
+ # Port numbers in "port 1234" / "on port 1234"
41
+ (re.compile(r"\b(?:on )?port \d+\b"), "info"),
42
+ # Warning / non-fatal status keywords (must come before "ok" so compound
43
+ # phrases like "already running" / "Connection lost" win over the lone
44
+ # "running" / "Connection restored" keywords).
45
+ (re.compile(
46
+ r"\b(Stopped|stopped|Disabled|disabled|skipping|skipped|"
47
+ r"already running|Connection lost|reconnecting|stale)\b"
48
+ ), "warn"),
49
+ # Success keywords.
50
+ (re.compile(
51
+ r"\b(Started|started|Restored|restored|Assigned|assigned|running|Running|"
52
+ r"Connected|connected|Connection restored|Reconnected)\b"
53
+ ), "ok"),
54
+ # Error keywords.
55
+ (re.compile(r"\b(Failed|failed|Error|error|crash(?:ed)?|denied)\b"), "err"),
56
+ ]
57
+
58
+
59
+ def style_log_line(line: str) -> List[LogSegment]:
60
+ """Split ``line`` into colored segments using the rule table above.
61
+
62
+ Greedy first-match, non-overlapping. Regions not matched by any pattern
63
+ fall through as default-coloured text.
64
+ """
65
+ if not line:
66
+ return [("", None)]
67
+
68
+ # Collect all non-overlapping matches in source order.
69
+ spans: list[tuple[int, int, str]] = []
70
+ occupied = [False] * len(line)
71
+
72
+ for pat, label in _PATTERNS:
73
+ for m in pat.finditer(line):
74
+ s, e = m.start(), m.end()
75
+ if any(occupied[s:e]):
76
+ continue
77
+ spans.append((s, e, label))
78
+ for i in range(s, e):
79
+ occupied[i] = True
80
+
81
+ spans.sort(key=lambda t: t[0])
82
+
83
+ out: list[LogSegment] = []
84
+ cursor = 0
85
+ for s, e, label in spans:
86
+ if s > cursor:
87
+ out.append((line[cursor:s], None))
88
+ out.append((line[s:e], label))
89
+ cursor = e
90
+ if cursor < len(line):
91
+ out.append((line[cursor:], None))
92
+ return out
susops/core/pac.py ADDED
@@ -0,0 +1,185 @@
1
+ """PAC file generation and Python HTTP server for SusOps."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import threading
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from susops.core.config import SusOpsConfig
12
+
13
+ from susops.core.ports import cidr_to_netmask
14
+
15
+ __all__ = ["generate_pac", "write_pac_file", "PacServer"]
16
+
17
+ _DIRECT = '"DIRECT"'
18
+
19
+
20
+ def _is_wildcard(host: str) -> bool:
21
+ return "*" in host or "?" in host
22
+
23
+
24
+ def _is_cidr(host: str) -> bool:
25
+ return re.match(r'^\d+\.\d+\.\d+\.\d+/\d+$', host) is not None
26
+
27
+
28
+ def _pac_rule(host: str, socks_port: int) -> str:
29
+ """Generate a single PAC rule line for a host/CIDR/wildcard."""
30
+ proxy = f"SOCKS5 127.0.0.1:{socks_port}"
31
+ if _is_wildcard(host):
32
+ return f" if (shExpMatch(host, '{host}')) return '{proxy}';"
33
+ if _is_cidr(host):
34
+ net, bits = host.split("/")
35
+ mask = cidr_to_netmask(int(bits))
36
+ return f" if (isInNet(host, '{net}', '{mask}')) return '{proxy}';"
37
+ # Plain hostname
38
+ return f" if (host == '{host}' || dnsDomainIs(host, '.{host}')) return '{proxy}';"
39
+
40
+
41
+ def generate_pac(config: "SusOpsConfig", active_tags: set[str] | None = None) -> str:
42
+ """Generate the FindProxyForURL JavaScript PAC function.
43
+
44
+ When active_tags is provided, only connections in that set are included.
45
+ When None, includes all connections (legacy behavior).
46
+ """
47
+ lines = ["function FindProxyForURL(url, host) {"]
48
+
49
+ for conn in config.connections:
50
+ if conn.socks_proxy_port == 0:
51
+ continue
52
+ if active_tags is not None and conn.tag not in active_tags:
53
+ continue
54
+ for host in conn.pac_hosts:
55
+ lines.append(_pac_rule(host, conn.socks_proxy_port))
56
+
57
+ lines.append(f' return {_DIRECT};')
58
+ lines.append("}")
59
+ return "\n".join(lines)
60
+
61
+
62
+ def write_pac_file(config: "SusOpsConfig", workspace: Path, active_tags: set[str] | None = None) -> Path:
63
+ """Write the PAC file to <workspace>/susops.pac and return its path."""
64
+ pac_path = workspace / "susops.pac"
65
+ pac_content = generate_pac(config, active_tags=active_tags)
66
+ pac_path.write_text(pac_content)
67
+ return pac_path
68
+
69
+
70
+ class _PacHandler(BaseHTTPRequestHandler):
71
+ """HTTP handler that serves the PAC file."""
72
+
73
+ pac_path: Path # set by PacServer before creating HTTPServer
74
+
75
+ def do_GET(self) -> None:
76
+ if self.path not in ("/susops.pac", "/"):
77
+ self.send_response(404)
78
+ self.end_headers()
79
+ return
80
+ try:
81
+ content = self.server.pac_path.read_bytes() # type: ignore[attr-defined]
82
+ except OSError:
83
+ self.send_response(500)
84
+ self.end_headers()
85
+ return
86
+ self.send_response(200)
87
+ self.send_header("Content-Type", "application/x-ns-proxy-autoconfig")
88
+ self.send_header("Content-Length", str(len(content)))
89
+ self.send_header("Connection", "close")
90
+ self.end_headers()
91
+ self.wfile.write(content)
92
+
93
+ def do_POST(self) -> None:
94
+ """POST /stop — remote shutdown so other processes can stop this server."""
95
+ if self.path != "/stop":
96
+ self.send_response(404)
97
+ self.end_headers()
98
+ return
99
+ self.send_response(200)
100
+ self.end_headers()
101
+ # Shut down in a background thread so the response can be sent first
102
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
103
+
104
+ def log_message(self, fmt: str, *args: object) -> None:
105
+ pass # suppress default access log
106
+
107
+
108
+ class _ReusableHTTPServer(HTTPServer):
109
+ """HTTPServer with SO_REUSEADDR enabled.
110
+
111
+ Python's default `HTTPServer.allow_reuse_address` is False, which means
112
+ re-binding to a just-released port can fail with EADDRINUSE during the
113
+ TCP TIME_WAIT window (~30 s on macOS/Linux). Without SO_REUSEADDR, when
114
+ the in-process server stops the port enters TIME_WAIT and the next bind
115
+ to the same port can silently fail — leaving the port file pointing at a
116
+ dead listener and subsequent starts unable to bind the port either.
117
+ """
118
+
119
+ allow_reuse_address = True
120
+
121
+
122
+ class PacServer:
123
+ """Python HTTP server that serves the PAC file.
124
+
125
+ Runs in a daemon thread. Replaces the nc-based loop in the original bash CLI.
126
+ """
127
+
128
+ def __init__(self) -> None:
129
+ self._server: HTTPServer | None = None
130
+ self._thread: threading.Thread | None = None
131
+ self._port: int = 0
132
+ self._pac_path: Path | None = None
133
+
134
+ def start(self, port: int, pac_path: Path) -> None:
135
+ """Start the PAC HTTP server on the given port.
136
+
137
+ Raises RuntimeError if already running or if port is in use.
138
+ """
139
+ if self._server is not None:
140
+ raise RuntimeError("PAC server is already running")
141
+
142
+ self._pac_path = pac_path
143
+
144
+ # Create HTTPServer and attach pac_path so handler can access it
145
+ server = _ReusableHTTPServer(("127.0.0.1", port), _PacHandler)
146
+ server.pac_path = pac_path # type: ignore[attr-defined]
147
+
148
+ self._server = server
149
+ self._port = server.server_address[1] # actual port (in case 0 was given)
150
+
151
+ self._thread = threading.Thread(
152
+ target=server.serve_forever,
153
+ name="susops-pac-server",
154
+ daemon=True,
155
+ )
156
+ self._thread.start()
157
+
158
+ def stop(self) -> None:
159
+ """Stop the PAC HTTP server."""
160
+ if self._server is not None:
161
+ self._server.shutdown()
162
+ self._server.server_close()
163
+ self._server = None
164
+ if self._thread is not None:
165
+ self._thread.join(timeout=2.0)
166
+ self._thread = None
167
+ self._port = 0
168
+
169
+ def is_running(self) -> bool:
170
+ """Return True if the server is currently running."""
171
+ return self._server is not None and self._thread is not None and self._thread.is_alive()
172
+
173
+ def get_port(self) -> int:
174
+ """Return the port the server is listening on (0 if not running)."""
175
+ return self._port
176
+
177
+ def get_pac_path(self) -> Path | None:
178
+ """Return the PAC file path currently being served."""
179
+ return self._pac_path
180
+
181
+ def reload(self, pac_path: Path) -> None:
182
+ """Update the PAC file path (takes effect on next request)."""
183
+ if self._server is not None:
184
+ self._server.pac_path = pac_path # type: ignore[attr-defined]
185
+ self._pac_path = pac_path
susops/core/ports.py ADDED
@@ -0,0 +1,57 @@
1
+ """Port allocation, validation, and CIDR utilities for SusOps."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+ import socket
6
+ import struct
7
+
8
+ __all__ = [
9
+ "get_random_free_port",
10
+ "is_port_free",
11
+ "validate_port",
12
+ "cidr_to_netmask"
13
+ ]
14
+
15
+
16
+ def get_random_free_port(start: int = 49152, end: int = 65535) -> int:
17
+ """Return a random free TCP port in [start, end].
18
+
19
+ Uses socket.bind to test availability — no lsof required.
20
+ Raises RuntimeError if no free port found after 100 attempts.
21
+ """
22
+ for _ in range(100):
23
+ port = random.randint(start, end)
24
+ if is_port_free(port):
25
+ return port
26
+ raise RuntimeError(f"No free port found in range {start}-{end}")
27
+
28
+
29
+ def is_port_free(port: int, host: str = "127.0.0.1") -> bool:
30
+ """Return True if the given TCP port is not currently bound."""
31
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
32
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
33
+ try:
34
+ s.bind((host, port))
35
+ return True
36
+ except OSError:
37
+ return False
38
+
39
+
40
+ def validate_port(port: int, *, allow_zero: bool = False) -> bool:
41
+ """Return True if port is in valid range 1–65535, or 0 when allow_zero=True."""
42
+ if not isinstance(port, int):
43
+ return False
44
+ if allow_zero and port == 0:
45
+ return True
46
+ return 1 <= port <= 65535
47
+
48
+
49
+ def cidr_to_netmask(cidr_bits: int) -> str:
50
+ """Convert CIDR prefix length to dotted-decimal netmask.
51
+
52
+ Example: cidr_to_netmask(24) -> "255.255.255.0"
53
+ """
54
+ if not 0 <= cidr_bits <= 32:
55
+ raise ValueError(f"CIDR bits must be 0-32, got {cidr_bits}")
56
+ mask = (0xFFFFFFFF << (32 - cidr_bits)) & 0xFFFFFFFF
57
+ return socket.inet_ntoa(struct.pack(">I", mask))