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
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
|
susops/core/log_style.py
ADDED
|
@@ -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))
|