servctl 1.0.0__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.
- servctl/__init__.py +4 -0
- servctl/main.py +1123 -0
- servctl-1.0.0.dist-info/METADATA +27 -0
- servctl-1.0.0.dist-info/RECORD +6 -0
- servctl-1.0.0.dist-info/WHEEL +4 -0
- servctl-1.0.0.dist-info/entry_points.txt +2 -0
servctl/__init__.py
ADDED
servctl/main.py
ADDED
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
servctl.main
|
|
4
|
+
Complete unprivileged server manager and background daemon runner.
|
|
5
|
+
Zero external dependencies (Python Standard Library only).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import email
|
|
10
|
+
from email import policy
|
|
11
|
+
import http.server
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import select
|
|
17
|
+
import signal
|
|
18
|
+
import socket
|
|
19
|
+
import socketserver
|
|
20
|
+
import ssl
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
|
|
29
|
+
# Global file system paths
|
|
30
|
+
APP_DIR = Path.home() / ".servctl"
|
|
31
|
+
REGISTRY_FILE = APP_DIR / "registry.json"
|
|
32
|
+
LOGS_DIR = APP_DIR / "logs"
|
|
33
|
+
DNS_CACHE_FILE = APP_DIR / "dns_cache.json"
|
|
34
|
+
CERT_FILE = APP_DIR / "cert.pem"
|
|
35
|
+
KEY_FILE = APP_DIR / "key.pem"
|
|
36
|
+
|
|
37
|
+
DEFAULT_PORTS = {
|
|
38
|
+
"web": 8080,
|
|
39
|
+
"websecure": 8443,
|
|
40
|
+
"ftps": 2121,
|
|
41
|
+
"dns": 5353,
|
|
42
|
+
"proxy": 8888
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Utility Functions
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def ensure_dirs():
|
|
50
|
+
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
def get_ssl_context() -> ssl.SSLContext:
|
|
54
|
+
"""Ensures self-signed certificates exist and returns an SSLContext."""
|
|
55
|
+
ensure_dirs()
|
|
56
|
+
if not (CERT_FILE.exists() and KEY_FILE.exists()):
|
|
57
|
+
cmd = [
|
|
58
|
+
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
|
59
|
+
"-keyout", str(KEY_FILE), "-out", str(CERT_FILE),
|
|
60
|
+
"-days", "3650", "-nodes",
|
|
61
|
+
"-subj", "/CN=localhost"
|
|
62
|
+
]
|
|
63
|
+
try:
|
|
64
|
+
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
65
|
+
except Exception:
|
|
66
|
+
# Fallback self-signed cert if system openssl command is unavailable
|
|
67
|
+
fallback_cert = (
|
|
68
|
+
"-----BEGIN CERTIFICATE-----\n"
|
|
69
|
+
"MIICpDCCAYwCCQCg/y8U0O9sETANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls\n"
|
|
70
|
+
"b2NhbGhvc3QwIBcNMjMwMTAxMDAwMDAwWhgPMjEyMzAxMDEMDAwMDAwWjAUMRIw\n"
|
|
71
|
+
"EAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB\n"
|
|
72
|
+
"AQDCKqO074bE/X6Ua6Pvx/68xJ3gqUuS7D8FwzTfKk7z2bJp5P+dGZ8sY0fWkP1w\n"
|
|
73
|
+
"4F9lZ+LpC2zN6L7p0l7g0aJ+ZqM6k7Y1d0r1Jk8r0vO2w3eE2lFpD6mRk+X8c2eP\n"
|
|
74
|
+
"AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJ5p5F8j8p1wF8r2t6n4m7u3p6L7k8f1\n"
|
|
75
|
+
"-----END CERTIFICATE-----\n"
|
|
76
|
+
)
|
|
77
|
+
fallback_key = (
|
|
78
|
+
"-----BEGIN PRIVATE KEY-----\n"
|
|
79
|
+
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCKqO074bE/X6U\n"
|
|
80
|
+
"a6Pvx/68xJ3gqUuS7D8FwzTfKk7z2bJp5P+dGZ8sY0fWkP1w4F9lZ+LpC2zN6L7p\n"
|
|
81
|
+
"-----END PRIVATE KEY-----\n"
|
|
82
|
+
)
|
|
83
|
+
CERT_FILE.write_text(fallback_cert)
|
|
84
|
+
KEY_FILE.write_text(fallback_key)
|
|
85
|
+
|
|
86
|
+
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
87
|
+
ctx.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE))
|
|
88
|
+
return ctx
|
|
89
|
+
|
|
90
|
+
def get_local_ip() -> str:
|
|
91
|
+
"""Finds the local network (LAN) IP for other devices on Wi-Fi/Ethernet."""
|
|
92
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
93
|
+
try:
|
|
94
|
+
s.connect(("10.254.254.254", 1))
|
|
95
|
+
return s.getsockname()[0]
|
|
96
|
+
except Exception:
|
|
97
|
+
return "127.0.0.1"
|
|
98
|
+
finally:
|
|
99
|
+
s.close()
|
|
100
|
+
|
|
101
|
+
def is_port_in_use(port: int, host: str = "127.0.0.1", udp: bool = False) -> bool:
|
|
102
|
+
sock_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM
|
|
103
|
+
with socket.socket(socket.AF_INET, sock_type) as s:
|
|
104
|
+
try:
|
|
105
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
106
|
+
s.bind((host, port))
|
|
107
|
+
return False
|
|
108
|
+
except OSError:
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
def is_pid_running(pid: int) -> bool:
|
|
112
|
+
if pid <= 0:
|
|
113
|
+
return False
|
|
114
|
+
try:
|
|
115
|
+
os.kill(pid, 0)
|
|
116
|
+
return True
|
|
117
|
+
except (OSError, PermissionError):
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
def read_registry() -> dict:
|
|
121
|
+
ensure_dirs()
|
|
122
|
+
if not REGISTRY_FILE.exists():
|
|
123
|
+
return {}
|
|
124
|
+
try:
|
|
125
|
+
with open(REGISTRY_FILE, "r") as f:
|
|
126
|
+
return json.load(f)
|
|
127
|
+
except Exception:
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
def write_registry(data: dict):
|
|
131
|
+
ensure_dirs()
|
|
132
|
+
tmp = REGISTRY_FILE.with_suffix(".tmp")
|
|
133
|
+
with open(tmp, "w") as f:
|
|
134
|
+
json.dump(data, f, indent=2)
|
|
135
|
+
tmp.replace(REGISTRY_FILE)
|
|
136
|
+
|
|
137
|
+
def clean_registry() -> dict:
|
|
138
|
+
reg = read_registry()
|
|
139
|
+
updated = {sid: meta for sid, meta in reg.items() if is_pid_running(meta["pid"])}
|
|
140
|
+
if len(updated) != len(reg):
|
|
141
|
+
write_registry(updated)
|
|
142
|
+
return updated
|
|
143
|
+
|
|
144
|
+
def format_uptime(seconds: float) -> str:
|
|
145
|
+
s = int(seconds)
|
|
146
|
+
if s < 60:
|
|
147
|
+
return f"{s}s"
|
|
148
|
+
m, s = divmod(s, 60)
|
|
149
|
+
if m < 60:
|
|
150
|
+
return f"{m}m {s}s"
|
|
151
|
+
h, m = divmod(m, 60)
|
|
152
|
+
if h < 24:
|
|
153
|
+
return f"{h}h {m}m"
|
|
154
|
+
d, h = divmod(h, 24)
|
|
155
|
+
return f"{d}d {h}h"
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# 1. DNS Resolver Engine (Cloudflare DoH 1.1.1.1)
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
DNS_CACHE = {}
|
|
162
|
+
DNS_LOCK = threading.Lock()
|
|
163
|
+
|
|
164
|
+
def load_dns_cache_file():
|
|
165
|
+
global DNS_CACHE
|
|
166
|
+
if DNS_CACHE_FILE.exists():
|
|
167
|
+
try:
|
|
168
|
+
with open(DNS_CACHE_FILE, "r") as f:
|
|
169
|
+
DNS_CACHE = json.load(f)
|
|
170
|
+
except Exception:
|
|
171
|
+
DNS_CACHE = {}
|
|
172
|
+
|
|
173
|
+
def flush_dns_cache_file():
|
|
174
|
+
with DNS_LOCK:
|
|
175
|
+
try:
|
|
176
|
+
with open(DNS_CACHE_FILE, "w") as f:
|
|
177
|
+
json.dump(DNS_CACHE, f, indent=2)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def doh_resolve(hostname: str, ip_target="1.1.1.1", host_hdr="cloudflare-dns.com", path_base="/dns-query"):
|
|
182
|
+
clean_host = hostname.strip("[]").strip().rstrip(".")
|
|
183
|
+
now = time.time()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
ipaddress.ip_address(clean_host)
|
|
187
|
+
return clean_host
|
|
188
|
+
except ValueError:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
with DNS_LOCK:
|
|
192
|
+
if clean_host in DNS_CACHE:
|
|
193
|
+
rec = DNS_CACHE[clean_host]
|
|
194
|
+
if isinstance(rec, dict) and rec.get("expires", 0) > now:
|
|
195
|
+
return rec["ip"]
|
|
196
|
+
|
|
197
|
+
if clean_host.endswith((".local", ".test", ".localhost", ".internal")):
|
|
198
|
+
return "127.0.0.1"
|
|
199
|
+
|
|
200
|
+
for _ in range(2):
|
|
201
|
+
try:
|
|
202
|
+
ctx = ssl.create_default_context()
|
|
203
|
+
sock = socket.create_connection((ip_target, 443), timeout=3.0)
|
|
204
|
+
ssock = ctx.wrap_socket(sock, server_hostname=host_hdr)
|
|
205
|
+
query_path = f"{path_base}?name={urllib.parse.quote(clean_host)}&type=A"
|
|
206
|
+
req = (
|
|
207
|
+
f"GET {query_path} HTTP/1.1\r\n"
|
|
208
|
+
f"Host: {host_hdr}\r\n"
|
|
209
|
+
f"Accept: application/dns-json\r\n"
|
|
210
|
+
f"User-Agent: servctl-DoH/1.0\r\n"
|
|
211
|
+
f"Connection: close\r\n\r\n"
|
|
212
|
+
)
|
|
213
|
+
ssock.sendall(req.encode("utf-8"))
|
|
214
|
+
raw_resp = b""
|
|
215
|
+
while True:
|
|
216
|
+
chunk = ssock.recv(4096)
|
|
217
|
+
if not chunk:
|
|
218
|
+
break
|
|
219
|
+
raw_resp += chunk
|
|
220
|
+
ssock.close()
|
|
221
|
+
|
|
222
|
+
start = raw_resp.find(b'{')
|
|
223
|
+
end = raw_resp.rfind(b'}')
|
|
224
|
+
if start != -1 and end != -1 and end > start:
|
|
225
|
+
data = json.loads(raw_resp[start:end+1].decode("utf-8", errors="ignore"))
|
|
226
|
+
for answer in data.get("Answer", []):
|
|
227
|
+
if answer.get("type") == 1:
|
|
228
|
+
ip = answer.get("data")
|
|
229
|
+
ttl = answer.get("TTL", 300)
|
|
230
|
+
with DNS_LOCK:
|
|
231
|
+
DNS_CACHE[clean_host] = {"ip": ip, "expires": now + ttl}
|
|
232
|
+
flush_dns_cache_file()
|
|
233
|
+
return ip
|
|
234
|
+
except Exception:
|
|
235
|
+
time.sleep(0.05)
|
|
236
|
+
|
|
237
|
+
return "127.0.0.1"
|
|
238
|
+
|
|
239
|
+
def parse_dns_domain(data: bytes) -> str:
|
|
240
|
+
if len(data) < 13:
|
|
241
|
+
return ""
|
|
242
|
+
idx = 12
|
|
243
|
+
parts = []
|
|
244
|
+
while idx < len(data):
|
|
245
|
+
l = data[idx]
|
|
246
|
+
if l == 0:
|
|
247
|
+
break
|
|
248
|
+
idx += 1
|
|
249
|
+
parts.append(data[idx:idx+l].decode("ascii", errors="ignore"))
|
|
250
|
+
idx += l
|
|
251
|
+
return ".".join(parts)
|
|
252
|
+
|
|
253
|
+
def build_dns_a_response(query: bytes, ip_str: str, ttl: int = 120) -> bytes:
|
|
254
|
+
tx_id = query[:2]
|
|
255
|
+
flags = b'\x81\x80'
|
|
256
|
+
counts = b'\x00\x01\x00\x01\x00\x00\x00\x00'
|
|
257
|
+
idx = 12
|
|
258
|
+
while idx < len(query):
|
|
259
|
+
l = query[idx]
|
|
260
|
+
if l == 0:
|
|
261
|
+
idx += 5
|
|
262
|
+
break
|
|
263
|
+
idx += 1 + l
|
|
264
|
+
question = query[12:idx]
|
|
265
|
+
ttl_bytes = int(ttl).to_bytes(4, byteorder="big")
|
|
266
|
+
ans = b'\xc0\x0c\x00\x01\x00\x01' + ttl_bytes + b'\x00\x04' + socket.inet_aton(ip_str)
|
|
267
|
+
return tx_id + flags + counts + question + ans
|
|
268
|
+
|
|
269
|
+
class DNSUDPHandler(socketserver.BaseRequestHandler):
|
|
270
|
+
def handle(self):
|
|
271
|
+
data, sock = self.request
|
|
272
|
+
domain = parse_dns_domain(data)
|
|
273
|
+
resolved_ip = doh_resolve(domain)
|
|
274
|
+
reply = build_dns_a_response(data, resolved_ip)
|
|
275
|
+
sock.sendto(reply, self.client_address)
|
|
276
|
+
print(f"[{time.strftime('%X')}] [DNS] Query: {domain} -> {resolved_ip}")
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# 2. RFC 4217 Explicit FTPS (TLS Encrypted FTP Server)
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
class FTPSClientSession(threading.Thread):
|
|
283
|
+
def __init__(self, conn, addr, root_dir, ssl_ctx):
|
|
284
|
+
super().__init__(daemon=True)
|
|
285
|
+
self.conn = conn
|
|
286
|
+
self.addr = addr
|
|
287
|
+
self.root_dir = os.path.realpath(root_dir)
|
|
288
|
+
self.ssl_ctx = ssl_ctx
|
|
289
|
+
self.cwd = "/"
|
|
290
|
+
self.pasv_sock = None
|
|
291
|
+
self.is_tls = False
|
|
292
|
+
self.prot = "C"
|
|
293
|
+
|
|
294
|
+
def _safe_path(self, path: str):
|
|
295
|
+
if not path:
|
|
296
|
+
path = self.cwd
|
|
297
|
+
elif not path.startswith("/"):
|
|
298
|
+
path = os.path.join(self.cwd, path)
|
|
299
|
+
norm = os.path.normpath(path)
|
|
300
|
+
rel = norm.lstrip("/")
|
|
301
|
+
full = os.path.realpath(os.path.join(self.root_dir, rel))
|
|
302
|
+
if full == self.root_dir or full.startswith(self.root_dir + os.sep):
|
|
303
|
+
return full, norm
|
|
304
|
+
return None, None
|
|
305
|
+
|
|
306
|
+
def send_resp(self, code: int, msg: str):
|
|
307
|
+
self.conn.sendall(f"{code} {msg}\r\n".encode("utf-8"))
|
|
308
|
+
|
|
309
|
+
def _open_data_channel(self):
|
|
310
|
+
if not self.pasv_sock:
|
|
311
|
+
self.send_resp(425, "Use PASV or EPSV first.")
|
|
312
|
+
return None
|
|
313
|
+
self.send_resp(150, "Opening data connection.")
|
|
314
|
+
self.pasv_sock.settimeout(10.0)
|
|
315
|
+
try:
|
|
316
|
+
raw_conn, _ = self.pasv_sock.accept()
|
|
317
|
+
except Exception:
|
|
318
|
+
return None
|
|
319
|
+
finally:
|
|
320
|
+
self.pasv_sock.close()
|
|
321
|
+
self.pasv_sock = None
|
|
322
|
+
|
|
323
|
+
if self.prot == "P" and self.is_tls:
|
|
324
|
+
try:
|
|
325
|
+
return self.ssl_ctx.wrap_socket(raw_conn, server_side=True)
|
|
326
|
+
except Exception:
|
|
327
|
+
raw_conn.close()
|
|
328
|
+
return None
|
|
329
|
+
return raw_conn
|
|
330
|
+
|
|
331
|
+
def _close_data_channel(self, data_sock):
|
|
332
|
+
if hasattr(data_sock, "unwrap"):
|
|
333
|
+
try:
|
|
334
|
+
data_sock.unwrap()
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
try:
|
|
338
|
+
data_sock.close()
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
def run(self):
|
|
343
|
+
self.send_resp(220, "servctl FTPS Ready (Explicit TLS Required).")
|
|
344
|
+
f = self.conn.makefile("r", encoding="latin-1", errors="replace")
|
|
345
|
+
try:
|
|
346
|
+
while True:
|
|
347
|
+
line = f.readline()
|
|
348
|
+
if not line:
|
|
349
|
+
break
|
|
350
|
+
line = line.strip()
|
|
351
|
+
if not line:
|
|
352
|
+
continue
|
|
353
|
+
parts = line.split(" ", 1)
|
|
354
|
+
cmd = parts[0].upper()
|
|
355
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
356
|
+
|
|
357
|
+
if cmd == "AUTH" and arg.upper() in ("TLS", "SSL"):
|
|
358
|
+
self.send_resp(234, "AUTH TLS successful. Proceed with TLS handshake.")
|
|
359
|
+
try:
|
|
360
|
+
self.conn = self.ssl_ctx.wrap_socket(self.conn, server_side=True)
|
|
361
|
+
f = self.conn.makefile("r", encoding="latin-1", errors="replace")
|
|
362
|
+
self.is_tls = True
|
|
363
|
+
self.prot = "P"
|
|
364
|
+
except Exception:
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
elif cmd == "PBSZ":
|
|
368
|
+
self.send_resp(200, "PBSZ 0 successful.")
|
|
369
|
+
|
|
370
|
+
elif cmd == "PROT":
|
|
371
|
+
if arg.upper() in ("P", "C"):
|
|
372
|
+
self.prot = arg.upper()
|
|
373
|
+
self.send_resp(200, f"Data protection level set to {self.prot}.")
|
|
374
|
+
else:
|
|
375
|
+
self.send_resp(504, "Unsupported protection level.")
|
|
376
|
+
|
|
377
|
+
elif cmd in ("USER", "PASS") and not self.is_tls:
|
|
378
|
+
self.send_resp(530, "TLS Required. Please send AUTH TLS first.")
|
|
379
|
+
|
|
380
|
+
elif cmd == "USER":
|
|
381
|
+
self.send_resp(331, "Any password accepted.")
|
|
382
|
+
|
|
383
|
+
elif cmd == "PASS":
|
|
384
|
+
self.send_resp(230, "Logged in securely via TLS.")
|
|
385
|
+
|
|
386
|
+
elif cmd == "SYST":
|
|
387
|
+
self.send_resp(215, "UNIX Type: L8")
|
|
388
|
+
|
|
389
|
+
elif cmd == "FEAT":
|
|
390
|
+
feat = "211-Features:\r\n AUTH TLS\r\n PBSZ\r\n PROT\r\n PASV\r\n EPSV\r\n UTF8\r\n SIZE\r\n211 End"
|
|
391
|
+
self.conn.sendall(feat.encode("utf-8") + b"\r\n")
|
|
392
|
+
|
|
393
|
+
elif cmd == "PWD":
|
|
394
|
+
self.send_resp(257, f'"{self.cwd}" is current directory.')
|
|
395
|
+
|
|
396
|
+
elif cmd == "TYPE":
|
|
397
|
+
self.send_resp(200, "Type set.")
|
|
398
|
+
|
|
399
|
+
elif cmd == "CWD":
|
|
400
|
+
target, norm = self._safe_path(arg)
|
|
401
|
+
if target and os.path.isdir(target):
|
|
402
|
+
self.cwd = norm
|
|
403
|
+
self.send_resp(250, f'Directory changed to "{self.cwd}".')
|
|
404
|
+
else:
|
|
405
|
+
self.send_resp(550, "Directory not found.")
|
|
406
|
+
|
|
407
|
+
elif cmd == "CDUP":
|
|
408
|
+
parent = os.path.dirname(self.cwd) or "/"
|
|
409
|
+
target, norm = self._safe_path(parent)
|
|
410
|
+
if target and os.path.isdir(target):
|
|
411
|
+
self.cwd = norm
|
|
412
|
+
self.send_resp(200, f'Directory changed to "{self.cwd}".')
|
|
413
|
+
else:
|
|
414
|
+
self.send_resp(550, "Failed to change directory.")
|
|
415
|
+
|
|
416
|
+
elif cmd == "PASV":
|
|
417
|
+
if self.pasv_sock:
|
|
418
|
+
self.pasv_sock.close()
|
|
419
|
+
self.pasv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
420
|
+
self.pasv_sock.bind((self.conn.getsockname()[0], 0))
|
|
421
|
+
self.pasv_sock.listen(1)
|
|
422
|
+
p_port = self.pasv_sock.getsockname()[1]
|
|
423
|
+
p_ip = self.conn.getsockname()[0].replace(".", ",")
|
|
424
|
+
self.send_resp(227, f"Entering Passive Mode ({p_ip},{p_port // 256},{p_port % 256}).")
|
|
425
|
+
|
|
426
|
+
elif cmd == "EPSV":
|
|
427
|
+
if self.pasv_sock:
|
|
428
|
+
self.pasv_sock.close()
|
|
429
|
+
self.pasv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
430
|
+
self.pasv_sock.bind((self.conn.getsockname()[0], 0))
|
|
431
|
+
self.pasv_sock.listen(1)
|
|
432
|
+
p_port = self.pasv_sock.getsockname()[1]
|
|
433
|
+
self.send_resp(229, f"Entering Extended Passive Mode (|||{p_port}|)")
|
|
434
|
+
|
|
435
|
+
elif cmd in ("LIST", "NLST"):
|
|
436
|
+
target, _ = self._safe_path(arg)
|
|
437
|
+
if not target or not os.path.exists(target):
|
|
438
|
+
self.send_resp(550, "Directory not found.")
|
|
439
|
+
continue
|
|
440
|
+
data_conn = self._open_data_channel()
|
|
441
|
+
if not data_conn:
|
|
442
|
+
continue
|
|
443
|
+
try:
|
|
444
|
+
entries = os.listdir(target) if os.path.isdir(target) else [os.path.basename(target)]
|
|
445
|
+
lines = []
|
|
446
|
+
for e in entries:
|
|
447
|
+
if cmd == "NLST":
|
|
448
|
+
lines.append(f"{e}\r\n")
|
|
449
|
+
else:
|
|
450
|
+
full = os.path.join(target, e) if os.path.isdir(target) else target
|
|
451
|
+
is_d = os.path.isdir(full)
|
|
452
|
+
perms = "drwxr-xr-x" if is_d else "-rw-r--r--"
|
|
453
|
+
size = os.path.getsize(full) if not is_d else 4096
|
|
454
|
+
lines.append(f"{perms} 1 owner group {size:8d} Jan 01 00:00 {e}\r\n")
|
|
455
|
+
data_conn.sendall("".join(lines).encode("utf-8"))
|
|
456
|
+
finally:
|
|
457
|
+
self._close_data_channel(data_conn)
|
|
458
|
+
self.send_resp(226, "Directory listing complete.")
|
|
459
|
+
|
|
460
|
+
elif cmd == "RETR":
|
|
461
|
+
target, _ = self._safe_path(arg)
|
|
462
|
+
if not target or not os.path.isfile(target):
|
|
463
|
+
self.send_resp(550, "File not found.")
|
|
464
|
+
continue
|
|
465
|
+
data_conn = self._open_data_channel()
|
|
466
|
+
if not data_conn:
|
|
467
|
+
continue
|
|
468
|
+
try:
|
|
469
|
+
with open(target, "rb") as df:
|
|
470
|
+
while chunk := df.read(16384):
|
|
471
|
+
data_conn.sendall(chunk)
|
|
472
|
+
finally:
|
|
473
|
+
self._close_data_channel(data_conn)
|
|
474
|
+
self.send_resp(226, "Transfer complete.")
|
|
475
|
+
|
|
476
|
+
elif cmd == "STOR":
|
|
477
|
+
target, _ = self._safe_path(arg)
|
|
478
|
+
if not target:
|
|
479
|
+
self.send_resp(553, "Invalid path.")
|
|
480
|
+
continue
|
|
481
|
+
data_conn = self._open_data_channel()
|
|
482
|
+
if not data_conn:
|
|
483
|
+
continue
|
|
484
|
+
try:
|
|
485
|
+
with open(target, "wb") as df:
|
|
486
|
+
while chunk := data_conn.recv(16384):
|
|
487
|
+
df.write(chunk)
|
|
488
|
+
finally:
|
|
489
|
+
self._close_data_channel(data_conn)
|
|
490
|
+
self.send_resp(226, "Upload successful.")
|
|
491
|
+
|
|
492
|
+
elif cmd == "SIZE":
|
|
493
|
+
target, _ = self._safe_path(arg)
|
|
494
|
+
if target and os.path.isfile(target):
|
|
495
|
+
self.send_resp(213, str(os.path.getsize(target)))
|
|
496
|
+
else:
|
|
497
|
+
self.send_resp(550, "File not found.")
|
|
498
|
+
|
|
499
|
+
elif cmd == "DELE":
|
|
500
|
+
target, _ = self._safe_path(arg)
|
|
501
|
+
if target and os.path.isfile(target):
|
|
502
|
+
try:
|
|
503
|
+
os.remove(target)
|
|
504
|
+
self.send_resp(250, "File deleted.")
|
|
505
|
+
except Exception as e:
|
|
506
|
+
self.send_resp(550, f"Delete failed: {e}")
|
|
507
|
+
else:
|
|
508
|
+
self.send_resp(550, "File not found.")
|
|
509
|
+
|
|
510
|
+
elif cmd == "MKD":
|
|
511
|
+
target, norm = self._safe_path(arg)
|
|
512
|
+
if target:
|
|
513
|
+
try:
|
|
514
|
+
os.makedirs(target, exist_ok=True)
|
|
515
|
+
self.send_resp(257, f'"{norm}" directory created.')
|
|
516
|
+
except Exception as e:
|
|
517
|
+
self.send_resp(550, f"Create failed: {e}")
|
|
518
|
+
else:
|
|
519
|
+
self.send_resp(550, "Invalid path.")
|
|
520
|
+
|
|
521
|
+
elif cmd == "QUIT":
|
|
522
|
+
self.send_resp(221, "Goodbye.")
|
|
523
|
+
break
|
|
524
|
+
|
|
525
|
+
elif cmd == "NOOP":
|
|
526
|
+
self.send_resp(200, "OK.")
|
|
527
|
+
|
|
528
|
+
else:
|
|
529
|
+
self.send_resp(502, f"Command '{cmd}' not implemented.")
|
|
530
|
+
finally:
|
|
531
|
+
try:
|
|
532
|
+
self.conn.close()
|
|
533
|
+
except Exception:
|
|
534
|
+
pass
|
|
535
|
+
if self.pasv_sock:
|
|
536
|
+
self.pasv_sock.close()
|
|
537
|
+
|
|
538
|
+
def start_ftps_server(host, port, root_dir):
|
|
539
|
+
ssl_ctx = get_ssl_context()
|
|
540
|
+
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
541
|
+
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
542
|
+
srv.bind((host, port))
|
|
543
|
+
srv.listen(10)
|
|
544
|
+
print(f"[{time.strftime('%X')}] FTPS server (TLS) listening on {host}:{port} (Root: {root_dir})")
|
|
545
|
+
while True:
|
|
546
|
+
try:
|
|
547
|
+
conn, addr = srv.accept()
|
|
548
|
+
FTPSClientSession(conn, addr, root_dir, ssl_ctx).start()
|
|
549
|
+
except Exception:
|
|
550
|
+
break
|
|
551
|
+
|
|
552
|
+
# ---------------------------------------------------------------------------
|
|
553
|
+
# 3. Web & Proxy Server Handlers
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
class DefaultWebHandler(http.server.SimpleHTTPRequestHandler):
|
|
557
|
+
def __init__(self, *args, directory=None, **kwargs):
|
|
558
|
+
super().__init__(*args, directory=directory, **kwargs)
|
|
559
|
+
|
|
560
|
+
def end_headers(self):
|
|
561
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
562
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
|
|
563
|
+
self.send_header("Access-Control-Allow-Headers", "*")
|
|
564
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
565
|
+
super().end_headers()
|
|
566
|
+
|
|
567
|
+
def do_GET(self):
|
|
568
|
+
path = self.translate_path(self.path)
|
|
569
|
+
if not os.path.exists(path) and not self.path.startswith("/api"):
|
|
570
|
+
index_path = os.path.join(self.directory, "index.html")
|
|
571
|
+
if os.path.exists(index_path):
|
|
572
|
+
self.path = "/index.html"
|
|
573
|
+
return super().do_GET()
|
|
574
|
+
|
|
575
|
+
class ProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|
576
|
+
def do_CONNECT(self):
|
|
577
|
+
try:
|
|
578
|
+
host, port = self.path.split(":")
|
|
579
|
+
remote = socket.create_connection((host, int(port)), timeout=10)
|
|
580
|
+
self.send_response(200, "Connection Established")
|
|
581
|
+
self.end_headers()
|
|
582
|
+
|
|
583
|
+
sockets = [self.connection, remote]
|
|
584
|
+
while True:
|
|
585
|
+
r, _, _ = select.select(sockets, [], sockets, 60)
|
|
586
|
+
if not r:
|
|
587
|
+
break
|
|
588
|
+
for s in r:
|
|
589
|
+
other = remote if s is self.connection else self.connection
|
|
590
|
+
data = s.recv(8192)
|
|
591
|
+
if not data:
|
|
592
|
+
return
|
|
593
|
+
other.sendall(data)
|
|
594
|
+
except Exception as e:
|
|
595
|
+
self.send_error(502, f"Tunnel Error: {e}")
|
|
596
|
+
|
|
597
|
+
def do_proxy(self):
|
|
598
|
+
target_base = getattr(self.server, "target", None)
|
|
599
|
+
url = urllib.parse.urljoin(target_base, self.path) if target_base else self.path
|
|
600
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
601
|
+
body = self.rfile.read(length) if length > 0 else None
|
|
602
|
+
|
|
603
|
+
req_headers = dict(self.headers)
|
|
604
|
+
for h in ["Connection", "Keep-Alive", "Proxy-Authenticate", "TE", "Transfer-Encoding", "Upgrade"]:
|
|
605
|
+
req_headers.pop(h, None)
|
|
606
|
+
req_headers.pop(h.lower(), None)
|
|
607
|
+
|
|
608
|
+
if target_base:
|
|
609
|
+
req_headers["Host"] = urllib.parse.urlsplit(target_base).netloc
|
|
610
|
+
req_headers["X-Forwarded-For"] = self.client_address[0]
|
|
611
|
+
|
|
612
|
+
req = urllib.request.Request(url, data=body, headers=req_headers, method=self.command)
|
|
613
|
+
try:
|
|
614
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
615
|
+
self.send_response(resp.status)
|
|
616
|
+
for k, v in resp.getheaders():
|
|
617
|
+
if k.lower() not in ["transfer-encoding", "content-length"]:
|
|
618
|
+
self.send_header(k, v)
|
|
619
|
+
data = resp.read()
|
|
620
|
+
self.send_header("Content-Length", str(len(data)))
|
|
621
|
+
self.end_headers()
|
|
622
|
+
self.wfile.write(data)
|
|
623
|
+
except urllib.error.HTTPError as e:
|
|
624
|
+
self.send_response(e.code)
|
|
625
|
+
data = e.read()
|
|
626
|
+
self.send_header("Content-Length", str(len(data)))
|
|
627
|
+
self.end_headers()
|
|
628
|
+
self.wfile.write(data)
|
|
629
|
+
except Exception as e:
|
|
630
|
+
self.send_error(502, f"Bad Gateway: {e}")
|
|
631
|
+
|
|
632
|
+
do_GET = do_proxy
|
|
633
|
+
do_POST = do_proxy
|
|
634
|
+
do_PUT = do_proxy
|
|
635
|
+
do_DELETE = do_proxy
|
|
636
|
+
do_PATCH = do_proxy
|
|
637
|
+
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
# Background Daemon Worker Entrypoint
|
|
640
|
+
# ---------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
def run_worker(args):
|
|
643
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
644
|
+
sys.stderr.reconfigure(line_buffering=True)
|
|
645
|
+
|
|
646
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
647
|
+
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
648
|
+
|
|
649
|
+
host = args.host
|
|
650
|
+
port = args.port
|
|
651
|
+
srv_type = args.type
|
|
652
|
+
root = Path(args.root).expanduser().resolve()
|
|
653
|
+
|
|
654
|
+
if root.is_dir():
|
|
655
|
+
os.chdir(root)
|
|
656
|
+
|
|
657
|
+
if srv_type in ("ftp", "ftps", "fileshare"):
|
|
658
|
+
start_ftps_server(host, port, str(root))
|
|
659
|
+
|
|
660
|
+
elif srv_type in ("websecure", "https"):
|
|
661
|
+
class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
|
|
662
|
+
allow_reuse_address = True
|
|
663
|
+
handler = lambda *hargs, **hkwargs: DefaultWebHandler(*hargs, directory=str(root), **hkwargs)
|
|
664
|
+
server = ThreadedHTTPServer((host, port), handler)
|
|
665
|
+
ssl_ctx = get_ssl_context()
|
|
666
|
+
server.socket = ssl_ctx.wrap_socket(server.socket, server_side=True)
|
|
667
|
+
print(f"[{time.strftime('%X')}] Secure HTTPS Web Server running on {host}:{port} (Root: {root})")
|
|
668
|
+
server.serve_forever()
|
|
669
|
+
|
|
670
|
+
elif srv_type in ("web", "website"):
|
|
671
|
+
class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
|
|
672
|
+
allow_reuse_address = True
|
|
673
|
+
handler = lambda *hargs, **hkwargs: DefaultWebHandler(*hargs, directory=str(root), **hkwargs)
|
|
674
|
+
server = ThreadedHTTPServer((host, port), handler)
|
|
675
|
+
print(f"[{time.strftime('%X')}] Default HTTP Web Server running on {host}:{port} (Root: {root})")
|
|
676
|
+
server.serve_forever()
|
|
677
|
+
|
|
678
|
+
elif srv_type == "dns":
|
|
679
|
+
load_dns_cache_file()
|
|
680
|
+
class ThreadedUDPServer(socketserver.ThreadingMixIn, socketserver.UDPServer):
|
|
681
|
+
allow_reuse_address = True
|
|
682
|
+
server = ThreadedUDPServer((host, port), DNSUDPHandler)
|
|
683
|
+
print(f"[{time.strftime('%X')}] DNS DoH Resolver (1.1.1.1) running on {host}:{port}")
|
|
684
|
+
server.serve_forever()
|
|
685
|
+
|
|
686
|
+
elif srv_type == "proxy":
|
|
687
|
+
class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
|
|
688
|
+
allow_reuse_address = True
|
|
689
|
+
server = ThreadedHTTPServer((host, port), ProxyHandler)
|
|
690
|
+
server.target = args.target
|
|
691
|
+
print(f"[{time.strftime('%X')}] Proxy server running on {host}:{port}")
|
|
692
|
+
server.serve_forever()
|
|
693
|
+
|
|
694
|
+
# ---------------------------------------------------------------------------
|
|
695
|
+
# CLI Commands
|
|
696
|
+
# ---------------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
def cmd_start(args):
|
|
699
|
+
clean_registry()
|
|
700
|
+
|
|
701
|
+
# Canonical type mapping
|
|
702
|
+
if args.type in ("websecure", "https"):
|
|
703
|
+
canonical_type = "websecure"
|
|
704
|
+
elif args.type in ("ftp", "ftps", "fileshare"):
|
|
705
|
+
canonical_type = "ftps"
|
|
706
|
+
elif args.type in ("web", "website"):
|
|
707
|
+
canonical_type = "web"
|
|
708
|
+
else:
|
|
709
|
+
canonical_type = args.type
|
|
710
|
+
|
|
711
|
+
port = args.port or DEFAULT_PORTS.get(canonical_type, 8080)
|
|
712
|
+
host = "0.0.0.0" if args.access == "network" else "127.0.0.1"
|
|
713
|
+
is_udp = (canonical_type == "dns")
|
|
714
|
+
|
|
715
|
+
if port < 1024 and os.name != "nt" and os.geteuid() != 0:
|
|
716
|
+
print(f"Error: Port {port} is a privileged port (< 1024) requiring root/sudo.")
|
|
717
|
+
print(f"To run servctl without sudo, use an unprivileged port (default is {DEFAULT_PORTS.get(canonical_type)}).")
|
|
718
|
+
sys.exit(1)
|
|
719
|
+
|
|
720
|
+
raw_root = args.root or args.path or "."
|
|
721
|
+
root_path = Path(raw_root).expanduser().resolve()
|
|
722
|
+
|
|
723
|
+
if canonical_type in ("web", "websecure", "ftps") and not root_path.is_dir():
|
|
724
|
+
print(f"Error: Directory '{root_path}' does not exist.")
|
|
725
|
+
sys.exit(1)
|
|
726
|
+
|
|
727
|
+
if is_port_in_use(port, host, udp=is_udp):
|
|
728
|
+
print(f"Error: Port {port} ({'UDP' if is_udp else 'TCP'}) is already in use on {host}.")
|
|
729
|
+
sys.exit(1)
|
|
730
|
+
|
|
731
|
+
srv_id = args.name or f"{canonical_type}-{port}"
|
|
732
|
+
ensure_dirs()
|
|
733
|
+
log_path = LOGS_DIR / f"{srv_id}.log"
|
|
734
|
+
log_fd = open(log_path, "a")
|
|
735
|
+
|
|
736
|
+
main_file = str(Path(__file__).resolve())
|
|
737
|
+
pkg_root = str(Path(__file__).resolve().parent.parent)
|
|
738
|
+
env = os.environ.copy()
|
|
739
|
+
env["PYTHONPATH"] = os.pathsep.join(filter(None, [pkg_root, env.get("PYTHONPATH", "")]))
|
|
740
|
+
|
|
741
|
+
cmd = [
|
|
742
|
+
sys.executable,
|
|
743
|
+
main_file,
|
|
744
|
+
"_worker",
|
|
745
|
+
"--id", srv_id,
|
|
746
|
+
"--type", canonical_type,
|
|
747
|
+
"--port", str(port),
|
|
748
|
+
"--host", host,
|
|
749
|
+
"--root", str(root_path),
|
|
750
|
+
]
|
|
751
|
+
if args.target:
|
|
752
|
+
cmd.extend(["--target", args.target])
|
|
753
|
+
|
|
754
|
+
kwargs = {
|
|
755
|
+
"stdout": log_fd,
|
|
756
|
+
"stderr": log_fd,
|
|
757
|
+
"stdin": subprocess.DEVNULL,
|
|
758
|
+
"cwd": str(root_path),
|
|
759
|
+
"env": env
|
|
760
|
+
}
|
|
761
|
+
if os.name == "nt":
|
|
762
|
+
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
763
|
+
else:
|
|
764
|
+
kwargs["start_new_session"] = True
|
|
765
|
+
|
|
766
|
+
proc = subprocess.Popen(cmd, **kwargs)
|
|
767
|
+
time.sleep(0.4)
|
|
768
|
+
|
|
769
|
+
if proc.poll() is not None:
|
|
770
|
+
log_fd.close()
|
|
771
|
+
print(f"Error: Server failed to start. Log contents ({log_path}):")
|
|
772
|
+
with open(log_path) as f:
|
|
773
|
+
print(f.read())
|
|
774
|
+
sys.exit(1)
|
|
775
|
+
|
|
776
|
+
reg = read_registry()
|
|
777
|
+
reg[srv_id] = {
|
|
778
|
+
"id": srv_id,
|
|
779
|
+
"type": canonical_type,
|
|
780
|
+
"port": port,
|
|
781
|
+
"host": host,
|
|
782
|
+
"access": args.access,
|
|
783
|
+
"root": str(root_path),
|
|
784
|
+
"target": args.target,
|
|
785
|
+
"pid": proc.pid,
|
|
786
|
+
"started_at": time.time(),
|
|
787
|
+
"log_file": str(log_path)
|
|
788
|
+
}
|
|
789
|
+
write_registry(reg)
|
|
790
|
+
|
|
791
|
+
lan_ip = get_local_ip()
|
|
792
|
+
if canonical_type == "websecure":
|
|
793
|
+
protocol = "https"
|
|
794
|
+
elif canonical_type == "ftps":
|
|
795
|
+
protocol = "ftps"
|
|
796
|
+
elif canonical_type == "dns":
|
|
797
|
+
protocol = "dns"
|
|
798
|
+
else:
|
|
799
|
+
protocol = "http"
|
|
800
|
+
|
|
801
|
+
local_url = f"{protocol}://127.0.0.1:{port}"
|
|
802
|
+
lan_url = f"{protocol}://{lan_ip}:{port}"
|
|
803
|
+
|
|
804
|
+
type_label = "SECURE HTTPS WEB" if canonical_type == "websecure" else canonical_type.upper()
|
|
805
|
+
print(f"\n✔ Successfully started {type_label} server in background!")
|
|
806
|
+
print("─" * 66)
|
|
807
|
+
print(f" Server ID: {srv_id}")
|
|
808
|
+
print(f" PID: {proc.pid}")
|
|
809
|
+
print(f" Type: {canonical_type}")
|
|
810
|
+
print(f" Port: {port} ({'UDP' if is_udp else 'TCP'})")
|
|
811
|
+
print(f" Access Scope: {args.access} ({'This device only' if args.access == 'local' else 'LAN Sharing Enabled'})")
|
|
812
|
+
print(f" This Device: {local_url}")
|
|
813
|
+
if args.access == "network":
|
|
814
|
+
print(f" Other Devices: {lan_url} <-- Open this on phone, tablet, etc.")
|
|
815
|
+
else:
|
|
816
|
+
print(f" Other Devices: [Localhost Only] Re-run with '-a network' to access via {lan_url}")
|
|
817
|
+
print(f" LAN IP Address: {lan_ip}")
|
|
818
|
+
if canonical_type in ("web", "websecure", "ftps"):
|
|
819
|
+
print(f" Root Dir: {root_path}")
|
|
820
|
+
if args.target:
|
|
821
|
+
print(f" Target: {args.target}")
|
|
822
|
+
print(f" Log File: {log_path}")
|
|
823
|
+
print("─" * 66)
|
|
824
|
+
if canonical_type == "ftps":
|
|
825
|
+
connect_host = lan_ip if args.access == "network" else "127.0.0.1"
|
|
826
|
+
print(" Client Connection:")
|
|
827
|
+
print(f" curl --ssl -k ftp://{connect_host}:{port}/")
|
|
828
|
+
print(" FileZilla / Cyberduck: Require explicit FTP over TLS")
|
|
829
|
+
elif canonical_type == "websecure":
|
|
830
|
+
print(" HTTPS Self-Signed Certificate:")
|
|
831
|
+
print(" Accept the certificate warning in your browser upon first visit.")
|
|
832
|
+
print(f" curl -k {local_url}")
|
|
833
|
+
print(f" Logs: servctl logs {srv_id}")
|
|
834
|
+
print(f" Stop: servctl stop {srv_id}\n")
|
|
835
|
+
|
|
836
|
+
def cmd_stop(args):
|
|
837
|
+
reg = read_registry()
|
|
838
|
+
if not reg:
|
|
839
|
+
print("No managed servers found.")
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
to_stop_ids = []
|
|
843
|
+
|
|
844
|
+
if args.all:
|
|
845
|
+
to_stop_ids = list(reg.keys())
|
|
846
|
+
elif args.targets:
|
|
847
|
+
for query in args.targets:
|
|
848
|
+
matched = False
|
|
849
|
+
for sid, meta in reg.items():
|
|
850
|
+
if sid == query or str(meta["port"]) == query:
|
|
851
|
+
if sid not in to_stop_ids:
|
|
852
|
+
to_stop_ids.append(sid)
|
|
853
|
+
matched = True
|
|
854
|
+
if not matched:
|
|
855
|
+
print(f"Warning: No running server matching '{query}'.")
|
|
856
|
+
else:
|
|
857
|
+
print("Please specify one or more server IDs/ports, or use --all to stop all.")
|
|
858
|
+
return
|
|
859
|
+
|
|
860
|
+
if not to_stop_ids:
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
for sid in to_stop_ids:
|
|
864
|
+
meta = reg.pop(sid, None)
|
|
865
|
+
if not meta:
|
|
866
|
+
continue
|
|
867
|
+
pid = meta["pid"]
|
|
868
|
+
if is_pid_running(pid):
|
|
869
|
+
try:
|
|
870
|
+
os.kill(pid, signal.SIGTERM)
|
|
871
|
+
for _ in range(15):
|
|
872
|
+
if not is_pid_running(pid):
|
|
873
|
+
break
|
|
874
|
+
time.sleep(0.1)
|
|
875
|
+
if is_pid_running(pid):
|
|
876
|
+
os.kill(pid, signal.SIGKILL)
|
|
877
|
+
except OSError:
|
|
878
|
+
pass
|
|
879
|
+
print(f"✔ Stopped server '{sid}' (PID: {pid}).")
|
|
880
|
+
else:
|
|
881
|
+
print(f"Server '{sid}' was already inactive.")
|
|
882
|
+
|
|
883
|
+
write_registry(reg)
|
|
884
|
+
|
|
885
|
+
def cmd_list(args):
|
|
886
|
+
reg = clean_registry()
|
|
887
|
+
if not reg:
|
|
888
|
+
print("No servers running. Start one using: servctl start")
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
lan_ip = get_local_ip()
|
|
892
|
+
headers = ["ID", "TYPE", "PORT", "THIS DEVICE", "OTHER DEVICES (LAN IP)", "ROOT / TARGET", "PID", "UPTIME"]
|
|
893
|
+
rows = []
|
|
894
|
+
for sid, m in reg.items():
|
|
895
|
+
uptime = format_uptime(time.time() - m["started_at"])
|
|
896
|
+
if m["type"] in ("websecure", "https"):
|
|
897
|
+
protocol = "https"
|
|
898
|
+
elif m["type"] in ("ftp", "ftps"):
|
|
899
|
+
protocol = "ftps"
|
|
900
|
+
elif m["type"] == "dns":
|
|
901
|
+
protocol = "dns"
|
|
902
|
+
else:
|
|
903
|
+
protocol = "http"
|
|
904
|
+
|
|
905
|
+
local_url = f"{protocol}://127.0.0.1:{m['port']}"
|
|
906
|
+
if m.get("access") == "network":
|
|
907
|
+
other_dev = f"{protocol}://{lan_ip}:{m['port']}"
|
|
908
|
+
else:
|
|
909
|
+
other_dev = f"Local only (LAN: {lan_ip})"
|
|
910
|
+
|
|
911
|
+
dest = m.get("target") or m.get("root") or "-"
|
|
912
|
+
if len(dest) > 22:
|
|
913
|
+
dest = "..." + dest[-19:]
|
|
914
|
+
rows.append([sid, m["type"], str(m["port"]), local_url, other_dev, dest, str(m["pid"]), uptime])
|
|
915
|
+
|
|
916
|
+
widths = [len(h) for h in headers]
|
|
917
|
+
for row in rows:
|
|
918
|
+
for i, val in enumerate(row):
|
|
919
|
+
widths[i] = max(widths[i], len(val))
|
|
920
|
+
|
|
921
|
+
sep = "+-" + "-+-".join("-" * w for w in widths) + "-+"
|
|
922
|
+
hdr = "| " + " | ".join(h.ljust(widths[i]) for i, h in enumerate(headers)) + " |"
|
|
923
|
+
|
|
924
|
+
print("\nActive servctl Servers:")
|
|
925
|
+
print(sep)
|
|
926
|
+
print(hdr)
|
|
927
|
+
print(sep)
|
|
928
|
+
for row in rows:
|
|
929
|
+
print("| " + " | ".join(row[i].ljust(widths[i]) for i in range(len(headers))) + " |")
|
|
930
|
+
print(sep + "\n")
|
|
931
|
+
|
|
932
|
+
def cmd_logs(args):
|
|
933
|
+
reg = read_registry()
|
|
934
|
+
target_meta = next((m for sid, m in reg.items() if sid == args.target or str(m["port"]) == args.target), None)
|
|
935
|
+
log_path = Path(target_meta["log_file"]) if target_meta else (LOGS_DIR / f"{args.target}.log")
|
|
936
|
+
|
|
937
|
+
if not log_path.exists():
|
|
938
|
+
print(f"Log file '{log_path}' not found.")
|
|
939
|
+
return
|
|
940
|
+
|
|
941
|
+
with open(log_path, "r", errors="replace") as f:
|
|
942
|
+
lines = f.readlines()
|
|
943
|
+
for line in lines[-args.lines:]:
|
|
944
|
+
sys.stdout.write(line)
|
|
945
|
+
sys.stdout.flush()
|
|
946
|
+
|
|
947
|
+
if args.follow:
|
|
948
|
+
try:
|
|
949
|
+
while True:
|
|
950
|
+
line = f.readline()
|
|
951
|
+
if line:
|
|
952
|
+
sys.stdout.write(line)
|
|
953
|
+
sys.stdout.flush()
|
|
954
|
+
else:
|
|
955
|
+
time.sleep(0.2)
|
|
956
|
+
except KeyboardInterrupt:
|
|
957
|
+
pass
|
|
958
|
+
|
|
959
|
+
def cmd_man(args):
|
|
960
|
+
print("""
|
|
961
|
+
================================================================================
|
|
962
|
+
SERVCTL(1) User Commands SERVCTL(1)
|
|
963
|
+
================================================================================
|
|
964
|
+
|
|
965
|
+
NAME
|
|
966
|
+
servctl - Unprivileged background server and localhost proxy manager
|
|
967
|
+
|
|
968
|
+
SYNOPSIS
|
|
969
|
+
servctl start [DIRECTORY] [-t TYPE] [-p PORT] [-r ROOT] [-a ACCESS] [--target TARGET] [-n NAME]
|
|
970
|
+
servctl stop [PORT | ID ...] [--all]
|
|
971
|
+
servctl list
|
|
972
|
+
servctl logs [PORT | ID] [-f] [-n LINES]
|
|
973
|
+
servctl man
|
|
974
|
+
|
|
975
|
+
DESCRIPTION
|
|
976
|
+
servctl is a command-line tool that spins up background development
|
|
977
|
+
servers without requiring root/sudo privileges. Processes run in
|
|
978
|
+
detached mode, keeping your terminal responsive and free.
|
|
979
|
+
|
|
980
|
+
ROOT DIRECTORY HANDLING
|
|
981
|
+
By default, servctl uses the current working directory where you
|
|
982
|
+
execute the command as the server root.
|
|
983
|
+
|
|
984
|
+
You can point to any other custom directory without having to 'cd'
|
|
985
|
+
into it, either by passing it as a positional argument:
|
|
986
|
+
$ servctl start ~/Downloads
|
|
987
|
+
|
|
988
|
+
Or explicitly using the --root / -r flag:
|
|
989
|
+
$ servctl start --root /var/www/html
|
|
990
|
+
|
|
991
|
+
CONNECTING FROM OTHER DEVICES (LAN SHARING)
|
|
992
|
+
Use the '-a network' flag to allow other computers, phones, or tablets
|
|
993
|
+
on the same Wi-Fi network to connect.
|
|
994
|
+
|
|
995
|
+
servctl automatically detects your device's LAN IP address and prints
|
|
996
|
+
the exact URL to use on other devices both during startup and in the
|
|
997
|
+
'servctl list' table.
|
|
998
|
+
|
|
999
|
+
SERVER TYPES (-t, --type)
|
|
1000
|
+
web (Default)
|
|
1001
|
+
A static HTTP web server with Single Page App (SPA) fallback,
|
|
1002
|
+
permissive CORS, and no-cache development headers.
|
|
1003
|
+
Default port: 8080.
|
|
1004
|
+
|
|
1005
|
+
websecure (or https)
|
|
1006
|
+
A secure HTTPS web server using TLS encryption and auto-generated
|
|
1007
|
+
certificates. Includes SPA fallback, CORS, and no-cache headers.
|
|
1008
|
+
Default port: 8443.
|
|
1009
|
+
|
|
1010
|
+
ftps (or ftp, fileshare)
|
|
1011
|
+
An RFC 4217 Explicit FTPS server. Requires TLS encryption
|
|
1012
|
+
(AUTH TLS) before authenticating. Protects control and data
|
|
1013
|
+
channels (PROT P) with auto-generated certificates.
|
|
1014
|
+
Rejects insecure plaintext requests. Default port: 2121.
|
|
1015
|
+
|
|
1016
|
+
dns
|
|
1017
|
+
A local UDP DNS proxy engine that resolves queries through
|
|
1018
|
+
Cloudflare DNS-over-HTTPS (1.1.1.1:443) with in-memory and
|
|
1019
|
+
disk-backed caching (~/.servctl/dns_cache.json). Resolves .local,
|
|
1020
|
+
.test, and .internal queries to 127.0.0.1. Default port: 5353.
|
|
1021
|
+
|
|
1022
|
+
proxy
|
|
1023
|
+
An HTTP forward proxy (with HTTPS CONNECT tunneling) or reverse
|
|
1024
|
+
proxy when used with --target. Default port: 8888.
|
|
1025
|
+
|
|
1026
|
+
ACCESS MODES (-a, --access)
|
|
1027
|
+
local (Default)
|
|
1028
|
+
Binds to 127.0.0.1 (accessible only on this machine).
|
|
1029
|
+
|
|
1030
|
+
network
|
|
1031
|
+
Binds to 0.0.0.0 (accessible to other devices on the LAN/Wi-Fi).
|
|
1032
|
+
|
|
1033
|
+
STOPPING SERVERS
|
|
1034
|
+
Stop one server:
|
|
1035
|
+
$ servctl stop 8080
|
|
1036
|
+
|
|
1037
|
+
Stop multiple servers simultaneously:
|
|
1038
|
+
$ servctl stop 8080 8443 2121
|
|
1039
|
+
$ servctl stop web-8080 websecure-8443
|
|
1040
|
+
|
|
1041
|
+
Stop all running servers:
|
|
1042
|
+
$ servctl stop --all
|
|
1043
|
+
|
|
1044
|
+
EXAMPLES
|
|
1045
|
+
1. Start a default HTTP website:
|
|
1046
|
+
$ servctl start
|
|
1047
|
+
|
|
1048
|
+
2. Start a secure HTTPS website on port 8443:
|
|
1049
|
+
$ servctl start -t websecure
|
|
1050
|
+
|
|
1051
|
+
3. Start an HTTPS website pointing to another folder for LAN access:
|
|
1052
|
+
$ servctl start -t websecure ~/Projects/site/dist -a network
|
|
1053
|
+
|
|
1054
|
+
4. Start an FTPS server in a custom folder:
|
|
1055
|
+
$ servctl start -t ftps ~/SharedFolder -a network
|
|
1056
|
+
|
|
1057
|
+
5. View running servers and connection URLs:
|
|
1058
|
+
$ servctl list
|
|
1059
|
+
|
|
1060
|
+
6. Stop multiple servers at once:
|
|
1061
|
+
$ servctl stop 8080 8443
|
|
1062
|
+
================================================================================
|
|
1063
|
+
""")
|
|
1064
|
+
|
|
1065
|
+
def main():
|
|
1066
|
+
parser = argparse.ArgumentParser(prog="servctl", description="Unprivileged background server and proxy manager")
|
|
1067
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
1068
|
+
|
|
1069
|
+
# START
|
|
1070
|
+
p_start = subparsers.add_parser("start", help="Start a background server")
|
|
1071
|
+
p_start.add_argument("path", nargs="?", default=None, help="Root directory (default: current working directory)")
|
|
1072
|
+
p_start.add_argument("-r", "--root", default=None, help="Custom root directory path (overrides current directory)")
|
|
1073
|
+
p_start.add_argument("-t", "--type", choices=["web", "website", "websecure", "https", "ftp", "ftps", "fileshare", "dns", "proxy"], default="web", help="Server type (default: web)")
|
|
1074
|
+
p_start.add_argument("-p", "--port", type=int, default=None, help="Port to bind (defaults: web: 8080, websecure: 8443, ftps: 2121, dns: 5353, proxy: 8888)")
|
|
1075
|
+
p_start.add_argument("-a", "--access", choices=["local", "network"], default="local", help="Access scope: local (127.0.0.1) or network (0.0.0.0)")
|
|
1076
|
+
p_start.add_argument("--target", default=None, help="Target URL for reverse proxy")
|
|
1077
|
+
p_start.add_argument("-n", "--name", default=None, help="Custom identifier for this instance")
|
|
1078
|
+
|
|
1079
|
+
# STOP (Accepts multiple targets or --all)
|
|
1080
|
+
p_stop = subparsers.add_parser("stop", help="Stop running servers")
|
|
1081
|
+
p_stop.add_argument("targets", nargs="*", help="One or more Server IDs or Ports to stop")
|
|
1082
|
+
p_stop.add_argument("-a", "--all", action="store_true", help="Stop all running servers")
|
|
1083
|
+
|
|
1084
|
+
# LIST
|
|
1085
|
+
subparsers.add_parser("list", aliases=["ls", "status"], help="List active servers")
|
|
1086
|
+
|
|
1087
|
+
# LOGS
|
|
1088
|
+
p_logs = subparsers.add_parser("logs", help="View server logs")
|
|
1089
|
+
p_logs.add_argument("target", help="Server ID or Port")
|
|
1090
|
+
p_logs.add_argument("-n", "--lines", type=int, default=50, help="Number of lines to show")
|
|
1091
|
+
p_logs.add_argument("-f", "--follow", action="store_true", help="Follow log stream")
|
|
1092
|
+
|
|
1093
|
+
# MAN
|
|
1094
|
+
subparsers.add_parser("man", help="View manual page")
|
|
1095
|
+
|
|
1096
|
+
# HIDDEN WORKER (Invoked by background daemon subprocesses)
|
|
1097
|
+
p_worker = subparsers.add_parser("_worker")
|
|
1098
|
+
p_worker.add_argument("--id", required=True)
|
|
1099
|
+
p_worker.add_argument("--type", required=True)
|
|
1100
|
+
p_worker.add_argument("--port", type=int, required=True)
|
|
1101
|
+
p_worker.add_argument("--host", required=True)
|
|
1102
|
+
p_worker.add_argument("--root", required=True)
|
|
1103
|
+
p_worker.add_argument("--target", default=None)
|
|
1104
|
+
|
|
1105
|
+
args = parser.parse_args()
|
|
1106
|
+
|
|
1107
|
+
if args.command == "_worker":
|
|
1108
|
+
run_worker(args)
|
|
1109
|
+
elif args.command == "start":
|
|
1110
|
+
cmd_start(args)
|
|
1111
|
+
elif args.command == "stop":
|
|
1112
|
+
cmd_stop(args)
|
|
1113
|
+
elif args.command in ("list", "ls", "status"):
|
|
1114
|
+
cmd_list(args)
|
|
1115
|
+
elif args.command == "logs":
|
|
1116
|
+
cmd_logs(args)
|
|
1117
|
+
elif args.command == "man":
|
|
1118
|
+
cmd_man(args)
|
|
1119
|
+
else:
|
|
1120
|
+
parser.print_help()
|
|
1121
|
+
|
|
1122
|
+
if __name__ == "__main__":
|
|
1123
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: servctl
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Unprivileged background server and localhost proxy CLI manager
|
|
5
|
+
Author: avevall
|
|
6
|
+
Keywords: cli,devops,dns,doh,ftp,localhost,proxy,server
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: System Administrators
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: File Transfer Protocol (FTP)
|
|
21
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
22
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
Hello
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
servctl/__init__.py,sha256=0AJluu-rtCOLCqI2QHldDpx2C1J8sbgHVHY9QVvVtwk,122
|
|
2
|
+
servctl/main.py,sha256=qUXiVBx2wRZmqpza84eErBf9fhp4xNeCg1h9P0SCjGE,42411
|
|
3
|
+
servctl-1.0.0.dist-info/METADATA,sha256=GRU_ru73mM64MhBrG9rhEEjxJX3eKfQSqJPytWp8O08,1119
|
|
4
|
+
servctl-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
servctl-1.0.0.dist-info/entry_points.txt,sha256=NlFnFf_ddskRQDxE6tZWk9Y_ohuctzt90xv5qXEh-fw,46
|
|
6
|
+
servctl-1.0.0.dist-info/RECORD,,
|