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