servctl 1.0.0__tar.gz → 1.0.2__tar.gz
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-1.0.0 → servctl-1.0.2}/PKG-INFO +1 -1
- {servctl-1.0.0 → servctl-1.0.2}/pyproject.toml +2 -2
- {servctl-1.0.0 → servctl-1.0.2}/src/servctl/main.py +608 -237
- {servctl-1.0.0 → servctl-1.0.2}/README.md +0 -0
- {servctl-1.0.0 → servctl-1.0.2}/man/servctl.1 +0 -0
- {servctl-1.0.0 → servctl-1.0.2}/src/servctl/__init__.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "servctl"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.2"
|
|
8
8
|
description = "Unprivileged background server and localhost proxy CLI manager"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -45,4 +45,4 @@ include = [
|
|
|
45
45
|
"/man",
|
|
46
46
|
"/README.md",
|
|
47
47
|
"/pyproject.toml",
|
|
48
|
-
]
|
|
48
|
+
]
|
|
@@ -6,6 +6,7 @@ Zero external dependencies (Python Standard Library only).
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
|
+
import datetime
|
|
9
10
|
import email
|
|
10
11
|
from email import policy
|
|
11
12
|
import http.server
|
|
@@ -14,6 +15,7 @@ import json
|
|
|
14
15
|
import os
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
import select
|
|
18
|
+
import shutil
|
|
17
19
|
import signal
|
|
18
20
|
import socket
|
|
19
21
|
import socketserver
|
|
@@ -37,7 +39,9 @@ KEY_FILE = APP_DIR / "key.pem"
|
|
|
37
39
|
DEFAULT_PORTS = {
|
|
38
40
|
"web": 8080,
|
|
39
41
|
"websecure": 8443,
|
|
40
|
-
"
|
|
42
|
+
"webdav": 8088,
|
|
43
|
+
"ftp": 2121,
|
|
44
|
+
"ftps": 2122,
|
|
41
45
|
"dns": 5353,
|
|
42
46
|
"proxy": 8888
|
|
43
47
|
}
|
|
@@ -50,54 +54,66 @@ def ensure_dirs():
|
|
|
50
54
|
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
55
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
56
|
|
|
57
|
+
def get_local_ip() -> str:
|
|
58
|
+
"""Finds the local network (LAN) IP for other devices on Wi-Fi/Ethernet."""
|
|
59
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
60
|
+
try:
|
|
61
|
+
s.connect(("10.254.254.254", 1))
|
|
62
|
+
return s.getsockname()[0]
|
|
63
|
+
except Exception:
|
|
64
|
+
return "127.0.0.1"
|
|
65
|
+
finally:
|
|
66
|
+
s.close()
|
|
67
|
+
|
|
53
68
|
def get_ssl_context() -> ssl.SSLContext:
|
|
54
|
-
"""
|
|
69
|
+
"""Generates unprivileged local self-signed TLS certificates for encryption."""
|
|
55
70
|
ensure_dirs()
|
|
71
|
+
lan_ip = get_local_ip()
|
|
72
|
+
|
|
56
73
|
if not (CERT_FILE.exists() and KEY_FILE.exists()):
|
|
74
|
+
san_ext = f"subjectAltName=DNS:localhost,IP:127.0.0.1,IP:{lan_ip}"
|
|
57
75
|
cmd = [
|
|
58
76
|
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
|
59
77
|
"-keyout", str(KEY_FILE), "-out", str(CERT_FILE),
|
|
60
78
|
"-days", "3650", "-nodes",
|
|
61
|
-
"-subj", "/CN=localhost"
|
|
79
|
+
"-subj", "/CN=localhost",
|
|
80
|
+
"-addext", san_ext
|
|
62
81
|
]
|
|
63
82
|
try:
|
|
64
83
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
65
84
|
except Exception:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
cmd_basic = [
|
|
86
|
+
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
|
87
|
+
"-keyout", str(KEY_FILE), "-out", str(CERT_FILE),
|
|
88
|
+
"-days", "3650", "-nodes",
|
|
89
|
+
"-subj", "/CN=localhost"
|
|
90
|
+
]
|
|
91
|
+
try:
|
|
92
|
+
subprocess.run(cmd_basic, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
93
|
+
except Exception:
|
|
94
|
+
fallback_cert = (
|
|
95
|
+
"-----BEGIN CERTIFICATE-----\n"
|
|
96
|
+
"MIICpDCCAYwCCQCg/y8U0O9sETANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls\n"
|
|
97
|
+
"b2NhbGhvc3QwIBcNMjMwMTAxMDAwMDAwWhgPMjEyMzAxMDEMDAwMDAwWjAUMRIw\n"
|
|
98
|
+
"EAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB\n"
|
|
99
|
+
"AQDCKqO074bE/X6Ua6Pvx/68xJ3gqUuS7D8FwzTfKk7z2bJp5P+dGZ8sY0fWkP1w\n"
|
|
100
|
+
"4F9lZ+LpC2zN6L7p0l7g0aJ+ZqM6k7Y1d0r1Jk8r0vO2w3eE2lFpD6mRk+X8c2eP\n"
|
|
101
|
+
"AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJ5p5F8j8p1wF8r2t6n4m7u3p6L7k8f1\n"
|
|
102
|
+
"-----END CERTIFICATE-----\n"
|
|
103
|
+
)
|
|
104
|
+
fallback_key = (
|
|
105
|
+
"-----BEGIN PRIVATE KEY-----\n"
|
|
106
|
+
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCKqO074bE/X6U\n"
|
|
107
|
+
"a6Pvx/68xJ3gqUuS7D8FwzTfKk7z2bJp5P+dGZ8sY0fWkP1w4F9lZ+LpC2zN6L7p\n"
|
|
108
|
+
"-----END PRIVATE KEY-----\n"
|
|
109
|
+
)
|
|
110
|
+
CERT_FILE.write_text(fallback_cert)
|
|
111
|
+
KEY_FILE.write_text(fallback_key)
|
|
85
112
|
|
|
86
113
|
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
87
114
|
ctx.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE))
|
|
88
115
|
return ctx
|
|
89
116
|
|
|
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
117
|
def is_port_in_use(port: int, host: str = "127.0.0.1", udp: bool = False) -> bool:
|
|
102
118
|
sock_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM
|
|
103
119
|
with socket.socket(socket.AF_INET, sock_type) as s:
|
|
@@ -154,6 +170,116 @@ def format_uptime(seconds: float) -> str:
|
|
|
154
170
|
d, h = divmod(h, 24)
|
|
155
171
|
return f"{d}d {h}h"
|
|
156
172
|
|
|
173
|
+
def get_protocol(srv_type: str) -> str:
|
|
174
|
+
if srv_type in ("websecure", "https"):
|
|
175
|
+
return "https"
|
|
176
|
+
elif srv_type == "ftps":
|
|
177
|
+
return "ftps"
|
|
178
|
+
elif srv_type == "ftp":
|
|
179
|
+
return "ftp"
|
|
180
|
+
elif srv_type == "dns":
|
|
181
|
+
return "dns"
|
|
182
|
+
return "http"
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# Background Process Manager (Spawn & Stop Helpers)
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def spawn_server(srv_id: str, srv_type: str, port: int, host: str, root_path: str, target: str = None):
|
|
189
|
+
ensure_dirs()
|
|
190
|
+
log_path = LOGS_DIR / f"{srv_id}.log"
|
|
191
|
+
log_fd = open(log_path, "a")
|
|
192
|
+
|
|
193
|
+
main_file = str(Path(__file__).resolve())
|
|
194
|
+
pkg_root = str(Path(__file__).resolve().parent.parent)
|
|
195
|
+
env = os.environ.copy()
|
|
196
|
+
env["PYTHONPATH"] = os.pathsep.join(filter(None, [pkg_root, env.get("PYTHONPATH", "")]))
|
|
197
|
+
|
|
198
|
+
cmd = [
|
|
199
|
+
sys.executable,
|
|
200
|
+
main_file,
|
|
201
|
+
"_worker",
|
|
202
|
+
"--id", srv_id,
|
|
203
|
+
"--type", srv_type,
|
|
204
|
+
"--port", str(port),
|
|
205
|
+
"--host", host,
|
|
206
|
+
"--root", str(root_path),
|
|
207
|
+
]
|
|
208
|
+
if target:
|
|
209
|
+
cmd.extend(["--target", target])
|
|
210
|
+
|
|
211
|
+
kwargs = {
|
|
212
|
+
"stdout": log_fd,
|
|
213
|
+
"stderr": log_fd,
|
|
214
|
+
"stdin": subprocess.DEVNULL,
|
|
215
|
+
"cwd": str(root_path),
|
|
216
|
+
"env": env
|
|
217
|
+
}
|
|
218
|
+
if os.name == "nt":
|
|
219
|
+
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
220
|
+
else:
|
|
221
|
+
kwargs["start_new_session"] = True
|
|
222
|
+
|
|
223
|
+
proc = subprocess.Popen(cmd, **kwargs)
|
|
224
|
+
time.sleep(0.4)
|
|
225
|
+
|
|
226
|
+
if proc.poll() is not None:
|
|
227
|
+
log_fd.close()
|
|
228
|
+
return None, log_path
|
|
229
|
+
|
|
230
|
+
return proc.pid, log_path
|
|
231
|
+
|
|
232
|
+
def stop_server_by_id(srv_id: str, reg: dict) -> bool:
|
|
233
|
+
meta = reg.pop(srv_id, None)
|
|
234
|
+
if not meta:
|
|
235
|
+
return False
|
|
236
|
+
pid = meta["pid"]
|
|
237
|
+
if is_pid_running(pid):
|
|
238
|
+
try:
|
|
239
|
+
os.kill(pid, signal.SIGTERM)
|
|
240
|
+
for _ in range(15):
|
|
241
|
+
if not is_pid_running(pid):
|
|
242
|
+
break
|
|
243
|
+
time.sleep(0.1)
|
|
244
|
+
if is_pid_running(pid):
|
|
245
|
+
os.kill(pid, signal.SIGKILL)
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
write_registry(reg)
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
def print_server_status_card(meta: dict, lan_ip: str = None):
|
|
252
|
+
if not lan_ip:
|
|
253
|
+
lan_ip = get_local_ip()
|
|
254
|
+
|
|
255
|
+
canonical_type = meta["type"]
|
|
256
|
+
is_udp = (canonical_type == "dns")
|
|
257
|
+
protocol = get_protocol(canonical_type)
|
|
258
|
+
port = meta["port"]
|
|
259
|
+
local_url = f"{protocol}://127.0.0.1:{port}"
|
|
260
|
+
lan_url = f"{protocol}://{lan_ip}:{port}"
|
|
261
|
+
uptime = format_uptime(time.time() - meta["started_at"])
|
|
262
|
+
|
|
263
|
+
print("─" * 66)
|
|
264
|
+
print(f" Server ID: {meta['id']}")
|
|
265
|
+
print(f" PID: {meta['pid']}")
|
|
266
|
+
print(f" Type: {canonical_type}")
|
|
267
|
+
print(f" Status: Active (Uptime: {uptime})")
|
|
268
|
+
print(f" Port: {port} ({'UDP' if is_udp else 'TCP'})")
|
|
269
|
+
print(f" Access Scope: {meta['access']} ({'This device only' if meta['access'] == 'local' else 'LAN Sharing Enabled'})")
|
|
270
|
+
print(f" This Device: {local_url}")
|
|
271
|
+
if meta['access'] == "network":
|
|
272
|
+
print(f" Other Devices: {lan_url} <-- Open on phone, tablet, etc.")
|
|
273
|
+
else:
|
|
274
|
+
print(f" Other Devices: [Localhost Only] Re-run with '-a network' to access via {lan_url}")
|
|
275
|
+
print(f" LAN IP Address: {lan_ip}")
|
|
276
|
+
if canonical_type in ("web", "websecure", "ftp", "ftps", "webdav"):
|
|
277
|
+
print(f" Root Dir: {meta['root']}")
|
|
278
|
+
if meta.get("target"):
|
|
279
|
+
print(f" Target: {meta['target']}")
|
|
280
|
+
print(f" Log File: {meta['log_file']}")
|
|
281
|
+
print("─" * 66)
|
|
282
|
+
|
|
157
283
|
# ---------------------------------------------------------------------------
|
|
158
284
|
# 1. DNS Resolver Engine (Cloudflare DoH 1.1.1.1)
|
|
159
285
|
# ---------------------------------------------------------------------------
|
|
@@ -276,15 +402,16 @@ class DNSUDPHandler(socketserver.BaseRequestHandler):
|
|
|
276
402
|
print(f"[{time.strftime('%X')}] [DNS] Query: {domain} -> {resolved_ip}")
|
|
277
403
|
|
|
278
404
|
# ---------------------------------------------------------------------------
|
|
279
|
-
# 2.
|
|
405
|
+
# 2. Unified FTP & FTPS Server (Plaintext & Explicit TLS)
|
|
280
406
|
# ---------------------------------------------------------------------------
|
|
281
407
|
|
|
282
|
-
class
|
|
283
|
-
def __init__(self, conn, addr, root_dir, ssl_ctx):
|
|
408
|
+
class FTPClientSession(threading.Thread):
|
|
409
|
+
def __init__(self, conn, addr, root_dir, require_tls=False, ssl_ctx=None):
|
|
284
410
|
super().__init__(daemon=True)
|
|
285
411
|
self.conn = conn
|
|
286
412
|
self.addr = addr
|
|
287
413
|
self.root_dir = os.path.realpath(root_dir)
|
|
414
|
+
self.require_tls = require_tls
|
|
288
415
|
self.ssl_ctx = ssl_ctx
|
|
289
416
|
self.cwd = "/"
|
|
290
417
|
self.pasv_sock = None
|
|
@@ -320,7 +447,7 @@ class FTPSClientSession(threading.Thread):
|
|
|
320
447
|
self.pasv_sock.close()
|
|
321
448
|
self.pasv_sock = None
|
|
322
449
|
|
|
323
|
-
if self.prot == "P" and self.is_tls:
|
|
450
|
+
if self.prot == "P" and self.is_tls and self.ssl_ctx:
|
|
324
451
|
try:
|
|
325
452
|
return self.ssl_ctx.wrap_socket(raw_conn, server_side=True)
|
|
326
453
|
except Exception:
|
|
@@ -340,7 +467,8 @@ class FTPSClientSession(threading.Thread):
|
|
|
340
467
|
pass
|
|
341
468
|
|
|
342
469
|
def run(self):
|
|
343
|
-
|
|
470
|
+
mode_label = "FTPS Ready (TLS Required)" if self.require_tls else "FTP Ready (Plaintext)"
|
|
471
|
+
self.send_resp(220, f"servctl {mode_label}.")
|
|
344
472
|
f = self.conn.makefile("r", encoding="latin-1", errors="replace")
|
|
345
473
|
try:
|
|
346
474
|
while True:
|
|
@@ -355,7 +483,10 @@ class FTPSClientSession(threading.Thread):
|
|
|
355
483
|
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
356
484
|
|
|
357
485
|
if cmd == "AUTH" and arg.upper() in ("TLS", "SSL"):
|
|
358
|
-
|
|
486
|
+
if not self.ssl_ctx:
|
|
487
|
+
self.send_resp(502, "TLS support not enabled on this port.")
|
|
488
|
+
continue
|
|
489
|
+
self.send_resp(234, "AUTH TLS successful. Negotiating TLS.")
|
|
359
490
|
try:
|
|
360
491
|
self.conn = self.ssl_ctx.wrap_socket(self.conn, server_side=True)
|
|
361
492
|
f = self.conn.makefile("r", encoding="latin-1", errors="replace")
|
|
@@ -370,25 +501,28 @@ class FTPSClientSession(threading.Thread):
|
|
|
370
501
|
elif cmd == "PROT":
|
|
371
502
|
if arg.upper() in ("P", "C"):
|
|
372
503
|
self.prot = arg.upper()
|
|
373
|
-
self.send_resp(200, f"Data protection
|
|
504
|
+
self.send_resp(200, f"Data protection set to {self.prot}.")
|
|
374
505
|
else:
|
|
375
506
|
self.send_resp(504, "Unsupported protection level.")
|
|
376
507
|
|
|
377
|
-
elif cmd in ("USER", "PASS") and not self.is_tls:
|
|
508
|
+
elif cmd in ("USER", "PASS") and self.require_tls and not self.is_tls:
|
|
378
509
|
self.send_resp(530, "TLS Required. Please send AUTH TLS first.")
|
|
379
510
|
|
|
380
511
|
elif cmd == "USER":
|
|
381
|
-
self.send_resp(331, "
|
|
512
|
+
self.send_resp(331, "Password required.")
|
|
382
513
|
|
|
383
514
|
elif cmd == "PASS":
|
|
384
|
-
self.send_resp(230, "Logged in
|
|
515
|
+
self.send_resp(230, "Logged in.")
|
|
385
516
|
|
|
386
517
|
elif cmd == "SYST":
|
|
387
518
|
self.send_resp(215, "UNIX Type: L8")
|
|
388
519
|
|
|
389
520
|
elif cmd == "FEAT":
|
|
390
|
-
|
|
391
|
-
self.
|
|
521
|
+
features = ["211-Features:"]
|
|
522
|
+
if self.ssl_ctx:
|
|
523
|
+
features.extend([" AUTH TLS", " PBSZ", " PROT"])
|
|
524
|
+
features.extend([" PASV", " EPSV", " UTF8", " SIZE", "211 End"])
|
|
525
|
+
self.conn.sendall(("\r\n".join(features) + "\r\n").encode("utf-8"))
|
|
392
526
|
|
|
393
527
|
elif cmd == "PWD":
|
|
394
528
|
self.send_resp(257, f'"{self.cwd}" is current directory.')
|
|
@@ -535,22 +669,180 @@ class FTPSClientSession(threading.Thread):
|
|
|
535
669
|
if self.pasv_sock:
|
|
536
670
|
self.pasv_sock.close()
|
|
537
671
|
|
|
538
|
-
def
|
|
539
|
-
ssl_ctx = get_ssl_context()
|
|
672
|
+
def start_ftp_server(host, port, root_dir, require_tls=False):
|
|
673
|
+
ssl_ctx = get_ssl_context() if require_tls else None
|
|
540
674
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
541
675
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
542
676
|
srv.bind((host, port))
|
|
543
677
|
srv.listen(10)
|
|
544
|
-
|
|
678
|
+
mode = "FTPS (TLS Required)" if require_tls else "FTP (Plaintext)"
|
|
679
|
+
print(f"[{time.strftime('%X')}] {mode} server listening on {host}:{port} (Root: {root_dir})")
|
|
545
680
|
while True:
|
|
546
681
|
try:
|
|
547
682
|
conn, addr = srv.accept()
|
|
548
|
-
|
|
683
|
+
FTPClientSession(conn, addr, root_dir, require_tls=require_tls, ssl_ctx=ssl_ctx).start()
|
|
549
684
|
except Exception:
|
|
550
685
|
break
|
|
551
686
|
|
|
552
687
|
# ---------------------------------------------------------------------------
|
|
553
|
-
# 3.
|
|
688
|
+
# 3. WebDAV Server (Native OS-Mountable Network Drive - RFC 4918)
|
|
689
|
+
# ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
class WebDAVHandler(http.server.SimpleHTTPRequestHandler):
|
|
692
|
+
def __init__(self, *args, directory=None, **kwargs):
|
|
693
|
+
self.root_dir = os.path.realpath(directory)
|
|
694
|
+
super().__init__(*args, directory=directory, **kwargs)
|
|
695
|
+
|
|
696
|
+
def _safe_path(self, path):
|
|
697
|
+
clean = urllib.parse.urlsplit(path).path.lstrip("/")
|
|
698
|
+
full = os.path.realpath(os.path.join(self.root_dir, clean))
|
|
699
|
+
if full == self.root_dir or full.startswith(self.root_dir + os.sep):
|
|
700
|
+
return full
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
def do_OPTIONS(self):
|
|
704
|
+
self.send_response(200)
|
|
705
|
+
self.send_header("DAV", "1, 2")
|
|
706
|
+
self.send_header("MS-Author-Via", "DAV")
|
|
707
|
+
self.send_header("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, MKCOL, MOVE, COPY, LOCK, UNLOCK")
|
|
708
|
+
self.end_headers()
|
|
709
|
+
|
|
710
|
+
def do_PROPFIND(self):
|
|
711
|
+
target = self._safe_path(self.path)
|
|
712
|
+
if not target or not os.path.exists(target):
|
|
713
|
+
self.send_error(404, "Not Found")
|
|
714
|
+
return
|
|
715
|
+
|
|
716
|
+
entries = []
|
|
717
|
+
is_dir = os.path.isdir(target)
|
|
718
|
+
req_clean = self.path.rstrip("/") + ("/" if is_dir else "")
|
|
719
|
+
entries.append((req_clean, is_dir, os.path.getsize(target) if not is_dir else 0, os.path.getmtime(target)))
|
|
720
|
+
|
|
721
|
+
if is_dir:
|
|
722
|
+
try:
|
|
723
|
+
for item in sorted(os.listdir(target)):
|
|
724
|
+
full = os.path.join(target, item)
|
|
725
|
+
item_is_dir = os.path.isdir(full)
|
|
726
|
+
item_url = self.path.rstrip("/") + "/" + urllib.parse.quote(item) + ("/" if item_is_dir else "")
|
|
727
|
+
sz = os.path.getsize(full) if not item_is_dir else 0
|
|
728
|
+
mt = os.path.getmtime(full)
|
|
729
|
+
entries.append((item_url, item_is_dir, sz, mt))
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
732
|
+
|
|
733
|
+
xml = ['<?xml version="1.0" encoding="utf-8" ?>', '<D:multistatus xmlns:D="DAV:">']
|
|
734
|
+
for url, d, size, mtime in entries:
|
|
735
|
+
dt_str = datetime.datetime.fromtimestamp(mtime, tz=datetime.timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")
|
|
736
|
+
res_type = "<D:collection/>" if d else ""
|
|
737
|
+
xml.append(f""" <D:response>
|
|
738
|
+
<D:href>{url}</D:href>
|
|
739
|
+
<D:propstat>
|
|
740
|
+
<D:prop>
|
|
741
|
+
<D:resourcetype>{res_type}</D:resourcetype>
|
|
742
|
+
<D:getcontentlength>{size}</D:getcontentlength>
|
|
743
|
+
<D:getlastmodified>{dt_str}</D:getlastmodified>
|
|
744
|
+
</D:prop>
|
|
745
|
+
<D:status>HTTP/1.1 200 OK</D:status>
|
|
746
|
+
</D:propstat>
|
|
747
|
+
</D:response>""")
|
|
748
|
+
xml.append('</D:multistatus>')
|
|
749
|
+
xml_data = "\n".join(xml).encode("utf-8")
|
|
750
|
+
|
|
751
|
+
self.send_response(207)
|
|
752
|
+
self.send_header("Content-Type", 'application/xml; charset="utf-8"')
|
|
753
|
+
self.send_header("Content-Length", str(len(xml_data)))
|
|
754
|
+
self.end_headers()
|
|
755
|
+
self.wfile.write(xml_data)
|
|
756
|
+
|
|
757
|
+
def do_PUT(self):
|
|
758
|
+
target = self._safe_path(self.path)
|
|
759
|
+
if not target:
|
|
760
|
+
self.send_error(403, "Forbidden")
|
|
761
|
+
return
|
|
762
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
763
|
+
exists = os.path.exists(target)
|
|
764
|
+
try:
|
|
765
|
+
with open(target, "wb") as f:
|
|
766
|
+
if length > 0:
|
|
767
|
+
f.write(self.rfile.read(length))
|
|
768
|
+
self.send_response(204 if exists else 201)
|
|
769
|
+
except Exception as e:
|
|
770
|
+
self.send_error(500, f"Write error: {e}")
|
|
771
|
+
self.end_headers()
|
|
772
|
+
|
|
773
|
+
def do_MKCOL(self):
|
|
774
|
+
target = self._safe_path(self.path)
|
|
775
|
+
if not target or os.path.exists(target):
|
|
776
|
+
self.send_error(405, "Already exists")
|
|
777
|
+
return
|
|
778
|
+
try:
|
|
779
|
+
os.makedirs(target, exist_ok=True)
|
|
780
|
+
self.send_response(201, "Created")
|
|
781
|
+
except Exception as e:
|
|
782
|
+
self.send_error(500, str(e))
|
|
783
|
+
self.end_headers()
|
|
784
|
+
|
|
785
|
+
def do_DELETE(self):
|
|
786
|
+
target = self._safe_path(self.path)
|
|
787
|
+
if not target or not os.path.exists(target):
|
|
788
|
+
self.send_error(404, "Not Found")
|
|
789
|
+
return
|
|
790
|
+
try:
|
|
791
|
+
if os.path.isdir(target):
|
|
792
|
+
shutil.rmtree(target)
|
|
793
|
+
else:
|
|
794
|
+
os.remove(target)
|
|
795
|
+
self.send_response(204)
|
|
796
|
+
except Exception as e:
|
|
797
|
+
self.send_error(500, str(e))
|
|
798
|
+
self.end_headers()
|
|
799
|
+
|
|
800
|
+
def do_MOVE(self):
|
|
801
|
+
src = self._safe_path(self.path)
|
|
802
|
+
dest_hdr = self.headers.get("Destination")
|
|
803
|
+
if not src or not dest_hdr:
|
|
804
|
+
self.send_error(400, "Bad Request")
|
|
805
|
+
return
|
|
806
|
+
dest_path = urllib.parse.urlsplit(dest_hdr).path
|
|
807
|
+
dst = self._safe_path(dest_path)
|
|
808
|
+
if not dst:
|
|
809
|
+
self.send_error(403, "Forbidden")
|
|
810
|
+
return
|
|
811
|
+
try:
|
|
812
|
+
shutil.move(src, dst)
|
|
813
|
+
self.send_response(201)
|
|
814
|
+
except Exception as e:
|
|
815
|
+
self.send_error(500, str(e))
|
|
816
|
+
self.end_headers()
|
|
817
|
+
|
|
818
|
+
def do_LOCK(self):
|
|
819
|
+
xml = f"""<?xml version="1.0" encoding="utf-8" ?>
|
|
820
|
+
<D:prop xmlns:D="DAV:">
|
|
821
|
+
<D:lockdiscovery>
|
|
822
|
+
<D:activelock>
|
|
823
|
+
<D:locktype><D:write/></D:locktype>
|
|
824
|
+
<D:lockscope><D:exclusive/></D:lockscope>
|
|
825
|
+
<D:depth>0</D:depth>
|
|
826
|
+
<D:owner><D:href>servctl</D:href></D:owner>
|
|
827
|
+
<D:timeout>Second-3600</D:timeout>
|
|
828
|
+
<D:locktoken><D:href>urn:uuid:servctl-lock-token</D:href></D:locktoken>
|
|
829
|
+
<D:lockroot><D:href>{self.path}</D:href></D:lockroot>
|
|
830
|
+
</D:activelock>
|
|
831
|
+
</D:lockdiscovery>
|
|
832
|
+
</D:prop>""".encode("utf-8")
|
|
833
|
+
self.send_response(200)
|
|
834
|
+
self.send_header("Content-Type", 'application/xml; charset="utf-8"')
|
|
835
|
+
self.send_header("Lock-Token", "<urn:uuid:servctl-lock-token>")
|
|
836
|
+
self.send_header("Content-Length", str(len(xml)))
|
|
837
|
+
self.end_headers()
|
|
838
|
+
self.wfile.write(xml)
|
|
839
|
+
|
|
840
|
+
def do_UNLOCK(self):
|
|
841
|
+
self.send_response(204)
|
|
842
|
+
self.end_headers()
|
|
843
|
+
|
|
844
|
+
# ---------------------------------------------------------------------------
|
|
845
|
+
# 4. Web & Proxy Server Handlers
|
|
554
846
|
# ---------------------------------------------------------------------------
|
|
555
847
|
|
|
556
848
|
class DefaultWebHandler(http.server.SimpleHTTPRequestHandler):
|
|
@@ -654,8 +946,19 @@ def run_worker(args):
|
|
|
654
946
|
if root.is_dir():
|
|
655
947
|
os.chdir(root)
|
|
656
948
|
|
|
657
|
-
if srv_type
|
|
658
|
-
|
|
949
|
+
if srv_type == "ftp":
|
|
950
|
+
start_ftp_server(host, port, str(root), require_tls=False)
|
|
951
|
+
|
|
952
|
+
elif srv_type == "ftps":
|
|
953
|
+
start_ftp_server(host, port, str(root), require_tls=True)
|
|
954
|
+
|
|
955
|
+
elif srv_type == "webdav":
|
|
956
|
+
class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
|
|
957
|
+
allow_reuse_address = True
|
|
958
|
+
handler = lambda *hargs, **hkwargs: WebDAVHandler(*hargs, directory=str(root), **hkwargs)
|
|
959
|
+
server = ThreadedHTTPServer((host, port), handler)
|
|
960
|
+
print(f"[{time.strftime('%X')}] WebDAV server running on {host}:{port} (Root: {root})")
|
|
961
|
+
server.serve_forever()
|
|
659
962
|
|
|
660
963
|
elif srv_type in ("websecure", "https"):
|
|
661
964
|
class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
|
|
@@ -667,7 +970,7 @@ def run_worker(args):
|
|
|
667
970
|
print(f"[{time.strftime('%X')}] Secure HTTPS Web Server running on {host}:{port} (Root: {root})")
|
|
668
971
|
server.serve_forever()
|
|
669
972
|
|
|
670
|
-
elif srv_type
|
|
973
|
+
elif srv_type == "web":
|
|
671
974
|
class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
|
|
672
975
|
allow_reuse_address = True
|
|
673
976
|
handler = lambda *hargs, **hkwargs: DefaultWebHandler(*hargs, directory=str(root), **hkwargs)
|
|
@@ -698,16 +1001,7 @@ def run_worker(args):
|
|
|
698
1001
|
def cmd_start(args):
|
|
699
1002
|
clean_registry()
|
|
700
1003
|
|
|
701
|
-
|
|
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
|
-
|
|
1004
|
+
canonical_type = "websecure" if args.type in ("websecure", "https") else args.type
|
|
711
1005
|
port = args.port or DEFAULT_PORTS.get(canonical_type, 8080)
|
|
712
1006
|
host = "0.0.0.0" if args.access == "network" else "127.0.0.1"
|
|
713
1007
|
is_udp = (canonical_type == "dns")
|
|
@@ -720,7 +1014,7 @@ def cmd_start(args):
|
|
|
720
1014
|
raw_root = args.root or args.path or "."
|
|
721
1015
|
root_path = Path(raw_root).expanduser().resolve()
|
|
722
1016
|
|
|
723
|
-
if canonical_type in ("web", "websecure", "ftps") and not root_path.is_dir():
|
|
1017
|
+
if canonical_type in ("web", "websecure", "ftp", "ftps", "webdav") and not root_path.is_dir():
|
|
724
1018
|
print(f"Error: Directory '{root_path}' does not exist.")
|
|
725
1019
|
sys.exit(1)
|
|
726
1020
|
|
|
@@ -729,45 +1023,9 @@ def cmd_start(args):
|
|
|
729
1023
|
sys.exit(1)
|
|
730
1024
|
|
|
731
1025
|
srv_id = args.name or f"{canonical_type}-{port}"
|
|
732
|
-
|
|
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)
|
|
1026
|
+
pid, log_path = spawn_server(srv_id, canonical_type, port, host, str(root_path), args.target)
|
|
768
1027
|
|
|
769
|
-
if
|
|
770
|
-
log_fd.close()
|
|
1028
|
+
if not pid:
|
|
771
1029
|
print(f"Error: Server failed to start. Log contents ({log_path}):")
|
|
772
1030
|
with open(log_path) as f:
|
|
773
1031
|
print(f.read())
|
|
@@ -782,56 +1040,32 @@ def cmd_start(args):
|
|
|
782
1040
|
"access": args.access,
|
|
783
1041
|
"root": str(root_path),
|
|
784
1042
|
"target": args.target,
|
|
785
|
-
"pid":
|
|
1043
|
+
"pid": pid,
|
|
786
1044
|
"started_at": time.time(),
|
|
787
1045
|
"log_file": str(log_path)
|
|
788
1046
|
}
|
|
789
1047
|
write_registry(reg)
|
|
790
1048
|
|
|
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
1049
|
type_label = "SECURE HTTPS WEB" if canonical_type == "websecure" else canonical_type.upper()
|
|
805
1050
|
print(f"\n✔ Successfully started {type_label} server in background!")
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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"
|
|
1051
|
+
print_server_status_card(reg[srv_id])
|
|
1052
|
+
|
|
1053
|
+
connect_host = get_local_ip() if args.access == "network" else "127.0.0.1"
|
|
1054
|
+
if canonical_type == "webdav":
|
|
1055
|
+
print(" Mount as a Native Network Drive:")
|
|
1056
|
+
print(f" macOS Finder: Finder -> Go -> Connect to Server -> http://{connect_host}:{port}")
|
|
1057
|
+
print(f" Windows Explorer: Map Network Drive -> http://{connect_host}:{port}")
|
|
1058
|
+
elif canonical_type == "ftps":
|
|
826
1059
|
print(" Client Connection:")
|
|
827
1060
|
print(f" curl --ssl -k ftp://{connect_host}:{port}/")
|
|
828
1061
|
print(" FileZilla / Cyberduck: Require explicit FTP over TLS")
|
|
829
|
-
elif canonical_type == "
|
|
830
|
-
print("
|
|
831
|
-
print("
|
|
832
|
-
print(f" curl
|
|
833
|
-
print(f" Logs:
|
|
834
|
-
print(f"
|
|
1062
|
+
elif canonical_type == "ftp":
|
|
1063
|
+
print(" Client Connection:")
|
|
1064
|
+
print(f" ftp {connect_host} {port}")
|
|
1065
|
+
print(f" curl ftp://{connect_host}:{port}/")
|
|
1066
|
+
print(f" Logs: servctl logs {srv_id}")
|
|
1067
|
+
print(f" Status: servctl status show {srv_id}")
|
|
1068
|
+
print(f" Stop: servctl stop {srv_id}\n")
|
|
835
1069
|
|
|
836
1070
|
def cmd_stop(args):
|
|
837
1071
|
reg = read_registry()
|
|
@@ -861,57 +1095,24 @@ def cmd_stop(args):
|
|
|
861
1095
|
return
|
|
862
1096
|
|
|
863
1097
|
for sid in to_stop_ids:
|
|
864
|
-
meta = reg.
|
|
865
|
-
if
|
|
866
|
-
|
|
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}).")
|
|
1098
|
+
meta = reg.get(sid)
|
|
1099
|
+
if meta and stop_server_by_id(sid, reg):
|
|
1100
|
+
print(f"✔ Stopped server '{sid}' (PID: {meta['pid']}).")
|
|
880
1101
|
else:
|
|
881
1102
|
print(f"Server '{sid}' was already inactive.")
|
|
882
1103
|
|
|
883
|
-
write_registry(reg)
|
|
884
|
-
|
|
885
1104
|
def cmd_list(args):
|
|
1105
|
+
"""Concise process summary: port and name of the process."""
|
|
886
1106
|
reg = clean_registry()
|
|
887
1107
|
if not reg:
|
|
888
1108
|
print("No servers running. Start one using: servctl start")
|
|
889
1109
|
return
|
|
890
1110
|
|
|
891
|
-
|
|
892
|
-
headers = ["ID", "TYPE", "PORT", "THIS DEVICE", "OTHER DEVICES (LAN IP)", "ROOT / TARGET", "PID", "UPTIME"]
|
|
1111
|
+
headers = ["ID", "PORT", "TYPE", "PID", "UPTIME"]
|
|
893
1112
|
rows = []
|
|
894
1113
|
for sid, m in reg.items():
|
|
895
1114
|
uptime = format_uptime(time.time() - m["started_at"])
|
|
896
|
-
|
|
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])
|
|
1115
|
+
rows.append([sid, str(m["port"]), m["type"], str(m["pid"]), uptime])
|
|
915
1116
|
|
|
916
1117
|
widths = [len(h) for h in headers]
|
|
917
1118
|
for row in rows:
|
|
@@ -927,7 +1128,131 @@ def cmd_list(args):
|
|
|
927
1128
|
print(sep)
|
|
928
1129
|
for row in rows:
|
|
929
1130
|
print("| " + " | ".join(row[i].ljust(widths[i]) for i in range(len(headers))) + " |")
|
|
930
|
-
print(sep
|
|
1131
|
+
print(sep)
|
|
1132
|
+
print("Tip: Run 'servctl status show <ID>' to view full connection URLs and configuration.\n")
|
|
1133
|
+
|
|
1134
|
+
def cmd_status_show(args):
|
|
1135
|
+
reg = clean_registry()
|
|
1136
|
+
if not reg:
|
|
1137
|
+
print("No servers running. Start one using: servctl start")
|
|
1138
|
+
return
|
|
1139
|
+
|
|
1140
|
+
lan_ip = get_local_ip()
|
|
1141
|
+
|
|
1142
|
+
if getattr(args, "all", False) or not getattr(args, "target", None):
|
|
1143
|
+
print("\nDetailed Server Configurations & Status:")
|
|
1144
|
+
for sid, meta in reg.items():
|
|
1145
|
+
print_server_status_card(meta, lan_ip)
|
|
1146
|
+
print()
|
|
1147
|
+
else:
|
|
1148
|
+
matched = False
|
|
1149
|
+
for sid, meta in reg.items():
|
|
1150
|
+
if sid == args.target or str(meta["port"]) == args.target:
|
|
1151
|
+
print(f"\nConfiguration & Runtime for '{sid}':")
|
|
1152
|
+
print_server_status_card(meta, lan_ip)
|
|
1153
|
+
print()
|
|
1154
|
+
matched = True
|
|
1155
|
+
break
|
|
1156
|
+
if not matched:
|
|
1157
|
+
print(f"Error: No running server matching '{args.target}'.")
|
|
1158
|
+
|
|
1159
|
+
def cmd_status_change(args):
|
|
1160
|
+
reg = clean_registry()
|
|
1161
|
+
if not reg:
|
|
1162
|
+
print("No servers running. Start one using: servctl start")
|
|
1163
|
+
return
|
|
1164
|
+
|
|
1165
|
+
target_id = None
|
|
1166
|
+
for sid, meta in reg.items():
|
|
1167
|
+
if sid == args.target or str(meta["port"]) == args.target:
|
|
1168
|
+
target_id = sid
|
|
1169
|
+
break
|
|
1170
|
+
|
|
1171
|
+
if not target_id:
|
|
1172
|
+
print(f"Error: No running server matching '{args.target}'.")
|
|
1173
|
+
return
|
|
1174
|
+
|
|
1175
|
+
old_meta = reg[target_id]
|
|
1176
|
+
|
|
1177
|
+
# Resolve optional positional extra (could be port or root directory)
|
|
1178
|
+
parsed_port = args.port
|
|
1179
|
+
parsed_root = args.root
|
|
1180
|
+
if getattr(args, "extra", None):
|
|
1181
|
+
if args.extra.isdigit() and not parsed_port:
|
|
1182
|
+
parsed_port = int(args.extra)
|
|
1183
|
+
elif not parsed_root:
|
|
1184
|
+
parsed_root = args.extra
|
|
1185
|
+
|
|
1186
|
+
# Target new properties
|
|
1187
|
+
new_type = args.type if getattr(args, "type", None) else old_meta["type"]
|
|
1188
|
+
if new_type in ("websecure", "https"):
|
|
1189
|
+
new_type = "websecure"
|
|
1190
|
+
|
|
1191
|
+
new_port = parsed_port if parsed_port is not None else old_meta["port"]
|
|
1192
|
+
new_access = args.access if args.access is not None else old_meta["access"]
|
|
1193
|
+
new_root = str(Path(parsed_root).expanduser().resolve()) if parsed_root is not None else old_meta["root"]
|
|
1194
|
+
new_target = args.target_url if getattr(args, "target_url", None) is not None else old_meta.get("target")
|
|
1195
|
+
|
|
1196
|
+
# Check if anything changed
|
|
1197
|
+
if (new_port == old_meta["port"] and
|
|
1198
|
+
new_access == old_meta["access"] and
|
|
1199
|
+
new_root == old_meta["root"] and
|
|
1200
|
+
new_type == old_meta["type"] and
|
|
1201
|
+
new_target == old_meta.get("target") and
|
|
1202
|
+
not getattr(args, "name", None)):
|
|
1203
|
+
print("Notice: No property changes specified. Use -p, -r, -t, -a, or --target to modify properties.")
|
|
1204
|
+
return
|
|
1205
|
+
|
|
1206
|
+
# Check directory
|
|
1207
|
+
if new_type in ("web", "websecure", "ftp", "ftps", "webdav") and not Path(new_root).is_dir():
|
|
1208
|
+
print(f"Error: Directory '{new_root}' does not exist.")
|
|
1209
|
+
return
|
|
1210
|
+
|
|
1211
|
+
# Check port conflicts
|
|
1212
|
+
new_host = "0.0.0.0" if new_access == "network" else "127.0.0.1"
|
|
1213
|
+
is_udp = (new_type == "dns")
|
|
1214
|
+
if new_port != old_meta["port"] and is_port_in_use(new_port, new_host, udp=is_udp):
|
|
1215
|
+
print(f"Error: Port {new_port} ({'UDP' if is_udp else 'TCP'}) is already in use on {new_host}.")
|
|
1216
|
+
return
|
|
1217
|
+
|
|
1218
|
+
# Check privilege
|
|
1219
|
+
if new_port < 1024 and os.name != "nt" and os.geteuid() != 0:
|
|
1220
|
+
print(f"Error: Port {new_port} is a privileged port (< 1024) requiring root/sudo.")
|
|
1221
|
+
return
|
|
1222
|
+
|
|
1223
|
+
# Determine new ID
|
|
1224
|
+
if getattr(args, "name", None):
|
|
1225
|
+
new_id = args.name
|
|
1226
|
+
elif old_meta["id"] == f"{old_meta['type']}-{old_meta['port']}":
|
|
1227
|
+
new_id = f"{new_type}-{new_port}"
|
|
1228
|
+
else:
|
|
1229
|
+
new_id = old_meta["id"]
|
|
1230
|
+
|
|
1231
|
+
print(f"Applying property changes to '{old_meta['id']}'...")
|
|
1232
|
+
stop_server_by_id(target_id, reg)
|
|
1233
|
+
|
|
1234
|
+
pid, log_path = spawn_server(new_id, new_type, new_port, new_host, new_root, new_target)
|
|
1235
|
+
if not pid:
|
|
1236
|
+
print(f"Error: Failed to re-launch server with new settings. Check log ({log_path}).")
|
|
1237
|
+
return
|
|
1238
|
+
|
|
1239
|
+
reg[new_id] = {
|
|
1240
|
+
"id": new_id,
|
|
1241
|
+
"type": new_type,
|
|
1242
|
+
"port": new_port,
|
|
1243
|
+
"host": new_host,
|
|
1244
|
+
"access": new_access,
|
|
1245
|
+
"root": new_root,
|
|
1246
|
+
"target": new_target,
|
|
1247
|
+
"pid": pid,
|
|
1248
|
+
"started_at": time.time(),
|
|
1249
|
+
"log_file": str(log_path)
|
|
1250
|
+
}
|
|
1251
|
+
write_registry(reg)
|
|
1252
|
+
|
|
1253
|
+
print(f"\n✔ Successfully updated '{old_meta['id']}' -> '{new_id}'!")
|
|
1254
|
+
print_server_status_card(reg[new_id])
|
|
1255
|
+
print()
|
|
931
1256
|
|
|
932
1257
|
def cmd_logs(args):
|
|
933
1258
|
reg = read_registry()
|
|
@@ -969,6 +1294,8 @@ SYNOPSIS
|
|
|
969
1294
|
servctl start [DIRECTORY] [-t TYPE] [-p PORT] [-r ROOT] [-a ACCESS] [--target TARGET] [-n NAME]
|
|
970
1295
|
servctl stop [PORT | ID ...] [--all]
|
|
971
1296
|
servctl list
|
|
1297
|
+
servctl status show [PORT | ID | --all]
|
|
1298
|
+
servctl status change <PORT | ID> [-p PORT] [-r ROOT] [-t TYPE] [-a ACCESS] [--target TARGET] [-n NAME]
|
|
972
1299
|
servctl logs [PORT | ID] [-f] [-n LINES]
|
|
973
1300
|
servctl man
|
|
974
1301
|
|
|
@@ -977,25 +1304,6 @@ DESCRIPTION
|
|
|
977
1304
|
servers without requiring root/sudo privileges. Processes run in
|
|
978
1305
|
detached mode, keeping your terminal responsive and free.
|
|
979
1306
|
|
|
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
1307
|
SERVER TYPES (-t, --type)
|
|
1000
1308
|
web (Default)
|
|
1001
1309
|
A static HTTP web server with Single Page App (SPA) fallback,
|
|
@@ -1003,15 +1311,24 @@ SERVER TYPES (-t, --type)
|
|
|
1003
1311
|
Default port: 8080.
|
|
1004
1312
|
|
|
1005
1313
|
websecure (or https)
|
|
1006
|
-
A secure HTTPS web server using TLS encryption
|
|
1007
|
-
|
|
1314
|
+
A secure HTTPS web server using self-signed TLS encryption.
|
|
1315
|
+
Includes SPA fallback, CORS, and no-cache headers.
|
|
1008
1316
|
Default port: 8443.
|
|
1009
1317
|
|
|
1010
|
-
|
|
1318
|
+
webdav
|
|
1319
|
+
An RFC 4918 WebDAV network drive server. Mounts natively as a
|
|
1320
|
+
shared network folder in macOS Finder (Cmd+K) and Windows File
|
|
1321
|
+
Explorer (Map Network Drive). Default port: 8088.
|
|
1322
|
+
|
|
1323
|
+
ftp
|
|
1324
|
+
A standard unencrypted RFC 959 FTP server. Allows plaintext
|
|
1325
|
+
uploads and downloads without TLS. Default port: 2121.
|
|
1326
|
+
|
|
1327
|
+
ftps
|
|
1011
1328
|
An RFC 4217 Explicit FTPS server. Requires TLS encryption
|
|
1012
1329
|
(AUTH TLS) before authenticating. Protects control and data
|
|
1013
1330
|
channels (PROT P) with auto-generated certificates.
|
|
1014
|
-
Rejects insecure plaintext requests. Default port:
|
|
1331
|
+
Rejects insecure plaintext requests. Default port: 2122.
|
|
1015
1332
|
|
|
1016
1333
|
dns
|
|
1017
1334
|
A local UDP DNS proxy engine that resolves queries through
|
|
@@ -1023,45 +1340,68 @@ SERVER TYPES (-t, --type)
|
|
|
1023
1340
|
An HTTP forward proxy (with HTTPS CONNECT tunneling) or reverse
|
|
1024
1341
|
proxy when used with --target. Default port: 8888.
|
|
1025
1342
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
|
1343
|
+
STATUS & CONFIGURATION MANAGEMENT
|
|
1344
|
+
servctl status show [ID/PORT]
|
|
1345
|
+
Displays full connection URLs, runtime metrics, and configuration.
|
|
1036
1346
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
$ servctl stop web-8080 websecure-8443
|
|
1347
|
+
servctl status show --all
|
|
1348
|
+
Displays detailed status cards for all active servers.
|
|
1040
1349
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1350
|
+
servctl status change <ID/PORT> [OPTIONS...]
|
|
1351
|
+
Dynamically changes running server properties (port, root, type,
|
|
1352
|
+
access, target) and restarts it seamlessly in the background.
|
|
1043
1353
|
|
|
1044
1354
|
EXAMPLES
|
|
1045
|
-
1. Start a default HTTP
|
|
1355
|
+
1. Start a default HTTP web server:
|
|
1046
1356
|
$ servctl start
|
|
1047
1357
|
|
|
1048
|
-
2.
|
|
1049
|
-
$ servctl
|
|
1050
|
-
|
|
1051
|
-
3. Start an HTTPS website pointing to another folder for LAN access:
|
|
1052
|
-
$ servctl start -t websecure ~/Projects/site/dist -a network
|
|
1358
|
+
2. List active servers (concise overview):
|
|
1359
|
+
$ servctl list
|
|
1053
1360
|
|
|
1054
|
-
|
|
1055
|
-
$ servctl
|
|
1361
|
+
3. Inspect full configuration and connection URLs:
|
|
1362
|
+
$ servctl status show web-8080
|
|
1363
|
+
$ servctl status show --all
|
|
1056
1364
|
|
|
1057
|
-
|
|
1058
|
-
$ servctl
|
|
1365
|
+
4. Change a running server's properties on the fly:
|
|
1366
|
+
$ servctl status change web-8080 -p 8085 -a network
|
|
1367
|
+
$ servctl status change web-8080 -t websecure
|
|
1059
1368
|
|
|
1060
|
-
|
|
1369
|
+
5. Stop multiple servers at once:
|
|
1061
1370
|
$ servctl stop 8080 8443
|
|
1062
1371
|
================================================================================
|
|
1063
1372
|
""")
|
|
1064
1373
|
|
|
1374
|
+
# ---------------------------------------------------------------------------
|
|
1375
|
+
# Argument Parser with Intelligent Shorthand Normalizer
|
|
1376
|
+
# ---------------------------------------------------------------------------
|
|
1377
|
+
|
|
1378
|
+
def normalize_status_argv(argv):
|
|
1379
|
+
"""Allows shorthand like 'servctl status' or 'servctl status web-8080'."""
|
|
1380
|
+
try:
|
|
1381
|
+
idx = argv.index("status")
|
|
1382
|
+
except ValueError:
|
|
1383
|
+
return argv
|
|
1384
|
+
|
|
1385
|
+
args_after = argv[idx + 1:]
|
|
1386
|
+
if not args_after:
|
|
1387
|
+
return argv[:idx + 1] + ["show", "--all"]
|
|
1388
|
+
elif args_after[0] in ("-h", "--help", "show", "change"):
|
|
1389
|
+
return argv
|
|
1390
|
+
elif args_after[0] in ("-a", "--all"):
|
|
1391
|
+
return argv[:idx + 1] + ["show", "--all"] + args_after[1:]
|
|
1392
|
+
else:
|
|
1393
|
+
return argv[:idx + 1] + ["show"] + args_after
|
|
1394
|
+
|
|
1395
|
+
def setup_change_parser(p):
|
|
1396
|
+
p.add_argument("target", help="Server ID or Port to modify")
|
|
1397
|
+
p.add_argument("extra", nargs="?", default=None, help="Optional new port or root directory")
|
|
1398
|
+
p.add_argument("-p", "--port", type=int, default=None, help="New port number")
|
|
1399
|
+
p.add_argument("-r", "--root", default=None, help="New root directory")
|
|
1400
|
+
p.add_argument("-t", "--type", choices=["web", "websecure", "https", "webdav", "ftp", "ftps", "dns", "proxy"], default=None, help="Change server type")
|
|
1401
|
+
p.add_argument("-a", "--access", choices=["local", "network"], default=None, help="New access scope")
|
|
1402
|
+
p.add_argument("--target", dest="target_url", default=None, help="New upstream target URL")
|
|
1403
|
+
p.add_argument("-n", "--name", default=None, help="Rename server ID")
|
|
1404
|
+
|
|
1065
1405
|
def main():
|
|
1066
1406
|
parser = argparse.ArgumentParser(prog="servctl", description="Unprivileged background server and proxy manager")
|
|
1067
1407
|
subparsers = parser.add_subparsers(dest="command")
|
|
@@ -1070,8 +1410,13 @@ def main():
|
|
|
1070
1410
|
p_start = subparsers.add_parser("start", help="Start a background server")
|
|
1071
1411
|
p_start.add_argument("path", nargs="?", default=None, help="Root directory (default: current working directory)")
|
|
1072
1412
|
p_start.add_argument("-r", "--root", default=None, help="Custom root directory path (overrides current directory)")
|
|
1073
|
-
p_start.add_argument(
|
|
1074
|
-
|
|
1413
|
+
p_start.add_argument(
|
|
1414
|
+
"-t", "--type",
|
|
1415
|
+
choices=["web", "websecure", "https", "webdav", "ftp", "ftps", "dns", "proxy"],
|
|
1416
|
+
default="web",
|
|
1417
|
+
help="Server type (default: web)"
|
|
1418
|
+
)
|
|
1419
|
+
p_start.add_argument("-p", "--port", type=int, default=None, help="Port to bind")
|
|
1075
1420
|
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
1421
|
p_start.add_argument("--target", default=None, help="Target URL for reverse proxy")
|
|
1077
1422
|
p_start.add_argument("-n", "--name", default=None, help="Custom identifier for this instance")
|
|
@@ -1081,8 +1426,25 @@ def main():
|
|
|
1081
1426
|
p_stop.add_argument("targets", nargs="*", help="One or more Server IDs or Ports to stop")
|
|
1082
1427
|
p_stop.add_argument("-a", "--all", action="store_true", help="Stop all running servers")
|
|
1083
1428
|
|
|
1084
|
-
# LIST
|
|
1085
|
-
subparsers.add_parser("list",
|
|
1429
|
+
# LIST (Concise overview)
|
|
1430
|
+
subparsers.add_parser("list", help="List running servers (concise process overview)")
|
|
1431
|
+
|
|
1432
|
+
# STATUS (Inspection and Property Changes)
|
|
1433
|
+
p_status = subparsers.add_parser("status", help="Inspect or change server status and configuration")
|
|
1434
|
+
sub_status = p_status.add_subparsers(dest="status_cmd")
|
|
1435
|
+
|
|
1436
|
+
# status show
|
|
1437
|
+
p_show = sub_status.add_parser("show", help="Show detailed status and connections")
|
|
1438
|
+
p_show.add_argument("target", nargs="?", default=None, help="Server ID or Port")
|
|
1439
|
+
p_show.add_argument("-a", "--all", action="store_true", help="Show all servers in detail")
|
|
1440
|
+
|
|
1441
|
+
# status change
|
|
1442
|
+
p_change = sub_status.add_parser("change", help="Change properties of a running server")
|
|
1443
|
+
setup_change_parser(p_change)
|
|
1444
|
+
|
|
1445
|
+
# Top-level direct 'servctl change'
|
|
1446
|
+
p_top_change = subparsers.add_parser("change", help="Change properties of a running server")
|
|
1447
|
+
setup_change_parser(p_top_change)
|
|
1086
1448
|
|
|
1087
1449
|
# LOGS
|
|
1088
1450
|
p_logs = subparsers.add_parser("logs", help="View server logs")
|
|
@@ -1102,7 +1464,9 @@ def main():
|
|
|
1102
1464
|
p_worker.add_argument("--root", required=True)
|
|
1103
1465
|
p_worker.add_argument("--target", default=None)
|
|
1104
1466
|
|
|
1105
|
-
|
|
1467
|
+
# Normalize argv so shorthand 'servctl status' and 'servctl status <name>' work directly
|
|
1468
|
+
normalized_argv = normalize_status_argv(sys.argv)
|
|
1469
|
+
args = parser.parse_args(normalized_argv[1:])
|
|
1106
1470
|
|
|
1107
1471
|
if args.command == "_worker":
|
|
1108
1472
|
run_worker(args)
|
|
@@ -1110,8 +1474,15 @@ def main():
|
|
|
1110
1474
|
cmd_start(args)
|
|
1111
1475
|
elif args.command == "stop":
|
|
1112
1476
|
cmd_stop(args)
|
|
1113
|
-
elif args.command
|
|
1477
|
+
elif args.command == "list":
|
|
1114
1478
|
cmd_list(args)
|
|
1479
|
+
elif args.command == "status":
|
|
1480
|
+
if getattr(args, "status_cmd", None) == "change":
|
|
1481
|
+
cmd_status_change(args)
|
|
1482
|
+
else:
|
|
1483
|
+
cmd_status_show(args)
|
|
1484
|
+
elif args.command == "change":
|
|
1485
|
+
cmd_status_change(args)
|
|
1115
1486
|
elif args.command == "logs":
|
|
1116
1487
|
cmd_logs(args)
|
|
1117
1488
|
elif args.command == "man":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|