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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: servctl
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Unprivileged background server and localhost proxy CLI manager
5
5
  Author: avevall
6
6
  Keywords: cli,devops,dns,doh,ftp,localhost,proxy,server
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "servctl"
7
- version = "1.0.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
- "ftps": 2121,
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
- """Ensures self-signed certificates exist and returns an SSLContext."""
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
- # 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
+ 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. RFC 4217 Explicit FTPS (TLS Encrypted FTP Server)
405
+ # 2. Unified FTP & FTPS Server (Plaintext & Explicit TLS)
280
406
  # ---------------------------------------------------------------------------
281
407
 
282
- class FTPSClientSession(threading.Thread):
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
- self.send_resp(220, "servctl FTPS Ready (Explicit TLS Required).")
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
- self.send_resp(234, "AUTH TLS successful. Proceed with TLS handshake.")
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 level set to {self.prot}.")
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, "Any password accepted.")
512
+ self.send_resp(331, "Password required.")
382
513
 
383
514
  elif cmd == "PASS":
384
- self.send_resp(230, "Logged in securely via TLS.")
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
- 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")
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 start_ftps_server(host, port, root_dir):
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
- print(f"[{time.strftime('%X')}] FTPS server (TLS) listening on {host}:{port} (Root: {root_dir})")
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
- FTPSClientSession(conn, addr, root_dir, ssl_ctx).start()
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. Web & Proxy Server Handlers
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 in ("ftp", "ftps", "fileshare"):
658
- start_ftps_server(host, port, str(root))
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 in ("web", "website"):
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
- # 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
-
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
- 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)
1026
+ pid, log_path = spawn_server(srv_id, canonical_type, port, host, str(root_path), args.target)
768
1027
 
769
- if proc.poll() is not None:
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": proc.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
- 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"
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 == "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")
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.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}).")
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
- lan_ip = get_local_ip()
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
- 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])
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 + "\n")
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 and auto-generated
1007
- certificates. Includes SPA fallback, CORS, and no-cache headers.
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
- ftps (or ftp, fileshare)
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: 2121.
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
- 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
1343
+ STATUS & CONFIGURATION MANAGEMENT
1344
+ servctl status show [ID/PORT]
1345
+ Displays full connection URLs, runtime metrics, and configuration.
1036
1346
 
1037
- Stop multiple servers simultaneously:
1038
- $ servctl stop 8080 8443 2121
1039
- $ servctl stop web-8080 websecure-8443
1347
+ servctl status show --all
1348
+ Displays detailed status cards for all active servers.
1040
1349
 
1041
- Stop all running servers:
1042
- $ servctl stop --all
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 website:
1355
+ 1. Start a default HTTP web server:
1046
1356
  $ servctl start
1047
1357
 
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
1358
+ 2. List active servers (concise overview):
1359
+ $ servctl list
1053
1360
 
1054
- 4. Start an FTPS server in a custom folder:
1055
- $ servctl start -t ftps ~/SharedFolder -a network
1361
+ 3. Inspect full configuration and connection URLs:
1362
+ $ servctl status show web-8080
1363
+ $ servctl status show --all
1056
1364
 
1057
- 5. View running servers and connection URLs:
1058
- $ servctl list
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
- 6. Stop multiple servers at once:
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("-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)")
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", aliases=["ls", "status"], help="List active servers")
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
- args = parser.parse_args()
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 in ("list", "ls", "status"):
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