From 7598c2654aebc48321d83fbf33d7a4dc1e9bcb9b Mon Sep 17 00:00:00 2001 From: DAProgs Date: Sun, 8 Mar 2026 13:42:58 -0400 Subject: [PATCH] added ssl admin --- .gitignore | 2 + README.md | 21 +++++++ .../__pycache__/admin.cpython-312.pyc | Bin 31625 -> 31496 bytes portspoof_py/__pycache__/cli.cpython-312.pyc | Bin 8971 -> 11945 bytes portspoof_py/admin.py | 10 ++- portspoof_py/cli.py | 57 +++++++++++++++++- 6 files changed, 86 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 5ba5619..28a8876 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ #Password files admin.passwd email.json +admin.crt +admin.key diff --git a/README.md b/README.md index 9b63e1f..3d905bf 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,9 @@ portspoof-py [OPTIONS] | `--admin-port PORT` | disabled | Start the web admin interface on this port. | | `--admin-host HOST` | `127.0.0.1` | Address the admin interface binds to. Set to `0.0.0.0` to expose it on all interfaces (protect with a firewall). | | `--admin-passwd FILE` | `admin.passwd` | File containing `username:password` on a single line. Required when `--admin-port` is used. | +| `--admin-ssl` | off | Serve the admin interface over HTTPS. | +| `--admin-ssl-cert FILE` | `admin.crt` | TLS certificate PEM file. If the file does not exist it is auto-generated as a self-signed cert (requires `openssl` on PATH). | +| `--admin-ssl-key FILE` | `admin.key` | TLS private key PEM file. Auto-generated alongside the cert if missing. | | `--email-config FILE` | `email.json` | JSON file where email alert settings are stored. Created automatically when you first save settings from the admin UI. | --- @@ -354,6 +357,24 @@ echo "admin:changeme" > admin.passwd The file must contain a single line in `username:password` format. Pass a custom path with `--admin-passwd FILE`. +### HTTPS / TLS + +Add `--admin-ssl` to serve the interface over HTTPS: + +```bash +sudo python3 -m portspoof_py \ + -p 4444 -s tools/portspoof_signatures \ + --admin-port 8080 --admin-ssl +``` + +On first run, a self-signed certificate (`admin.crt` / `admin.key`) is generated automatically using `openssl`. Your browser will show an untrusted-certificate warning — add an exception or use a real cert. + +To use your own certificate: + +```bash +--admin-ssl --admin-ssl-cert /etc/ssl/mycert.pem --admin-ssl-key /etc/ssl/mykey.pem +``` + ### Dashboard — `GET /` A dark-themed HTML page that auto-refreshes every 5 seconds. Sections: diff --git a/portspoof_py/__pycache__/admin.cpython-312.pyc b/portspoof_py/__pycache__/admin.cpython-312.pyc index d72480373f5f6a13177c3a5b89041998f3a31ace..0c47301cbc88be6aa723fb334d6bc1d92aa863c5 100644 GIT binary patch delta 2698 zcmaJ?eQaA-6@S<7!{2riJ6}%HxM{M)X_{AyWKL2Y)~!Aq@dyDFea-X%qVg@z30?(5k@1InQZQ zCd7;KyT5bJJs@;OL;Yzy0?xZK|0a>Begg03dE-|?+QJO3Zmzmt2 z@Fo3WKX8XyPMxY#EmzCqmTJftm#FKs6t19duy{5sIYEGpm2ri7!CXa4!qv1i9H3?4 z8rnvEY8mx|L%CWByUOD>T5(zq*V0Ned>sU>0Zd`qgl<-$$m(*aZ7p;In49bZ-A1?YlcZ8^RLR%?fA*m7$UT?)>IN^L z>Yz>dZq!LFaSwd}N^7wT5TSiNNwb**_q0GM|Lq@w_=hn5u8l~ouwNM)0*B8$>v_uP zR>-q8V+nQS1D0<{q$YnwX_H7x{&$Wj^Cq z8#&JUcxkg==|}4T-`e~<8RFNP>&aoh(%d3LzX&(-pEkd5sT#u_et?jTjlg0^!zr3! zWBl)}`(+IjALaLM?Ic9!*|wlO4T^u~Uu#?RHD`dbT>zQ|AQ)s=zTt?d$+Vt{u+$Wr z1;s@^-MNhn=ilu7RBAr~ih+=Y9f6g72_Pi0E?5l9Xxbn#O#~iXi^Lx1f9na6%luyl ze%kRII+)%pyWz&VQGFWVe|cWZ`#)bUlIM8qle62=HzaK&X7ivh#DqEy<(MT9_5Sx* z%l@0Z_*4h^+*!=CfN&0AMhbp$sCxp7{ma}pLoPN5%#ihz9!(fxES)rziL^eNR2k*R z`?irBf2r?b<;&W#25^_Ou`i(T zG{QlILjWNs`%I|+8>JZHpuo4&%at!+kTV!WV(%dT7Q%U6px-AK_(fH%!(KL|F)cN2 z$aEr^));mzdj{bRUKig?run{jV1E)7KSu~7;CC>rQ7uA8V>j7mS;6Xfg7n$XodwcjH4Z|3)_um zDV}0Uvw;okxH=Vy#gDN^(P~y`9`7$u{1HN)>&KpuKLYx3e)-soj-B`=42Pzt(h+r1 z9oG$~rX?b=)VQur>oFTx?>)#*!T%?5o?}5`;~(_a=WJQaG|?$oUTd)5i)$ch(*SCp%y z7>@{9`%d8$xj(8R3!s+yN`s$A4moNW-ZEwIevu_Z`TTmZS zgHZ}673>`vdb(vW#5d_@AL)c_Fid_8i{XqhHL9x-J*pjN_|Za&L6||NidSdjv`+F5 z_1$EV*G}x|kXQkBnk{CynL44Vktn51)igtdZ#!VfBf6f}3<;Ns0FO-8E%#DbD1Grm{(u8FHG9!eL2M(NP`d#zqIN=VoSgH?b76p*}`- z2LO7h7(>xwBWhALY$LgprbpmjYc8-KJsJ!ig+F|Xli1EC#^D{Xr$so7*Wr3=KZBkB z{Ht(hPPkn~$zDX zR5#8azEUyQv+DKT@HQ;epP1|Yvt)nQ^}wacOCwiei!FknNYy594yqx(JSaeiiM zhqWG@?-Z)|@>FB4wd*0WNV@#eHDpUQq^m+%suhvf`@3qzr5&WJN?F<|A>Zu;{jx~9 z0@AWnioAj#RuQl+2TWe8bhTTT8%S4^Yk7+md8-xnz)0T%FFM3J0YjF7fl!-ajYNVp z6^lg7b74(jVIc%_82=I3U7nq8BuW0-bd%FO8YgP^^Ob3T=Wmfl5h4i1nPW~495#kL zoY7T{;jbfm3*jol8wj%qZ}X;1uoHXHkPny_)-gEP*Im4?=JA-nYpe!0fo28pb-Kw*sAj0~A-CK}hEhvK1x3FV= zTfvHwO>lY&c4Q90Rl49?h%Hpj?<+V_;}S}%&b!aK3vQG=g12&EX2sJ`@S<2Elm#wq adA0qe_Lb650W%fK1jTX72Mh{@cKQ#kv#WFf delta 2666 zcmZ`)dr(|g8NX-u-hJ=FvI{H_!jeE>d4w7gY}1&eY{?Ta4XL%;#>=wj^4wR>y|92# zn36uwIwhV?9;UWBtr43>soPPf>Nvp}XR0%qL^Iahai-S(qyEv^n2t$bj^B4TK>5Qv zbARXeJ#w@i5tsu(UcO ze#O6Tb&7ge$ol9I%y9(gC|jSWkTyV`(1a<}oNYIKOC;sltCl8_)MY<&#LLOYe7N>S zawBWqQbt-2!s^)}0Ntf(QKo5eRgMjgur5@2(iu4&kH(b2RGcb|b))l_c~9L_e^gg54TAbwc1!)croae#_yIyTHUxt%_NC|q8{w6W4@ep)KFG!z_YyM7Uu_CX z6QKAs`*zckk4*B(z17kap!g2|-ri;s%U;?0U$J-!6x|^cdmKh~93W(7Vg6o6fOK$j zI7BY+nti`%nM14L%BJu7pyok@rvOwlKrk3&c+@N(xs=_tzt~LX_|Zet+b}jHuFb-} z0Set5SCTMy7&%c?6O8L~T*)>bZXx$AfPtMt_%^_#7<~9xcoe7n`|JxL7dr+_m$Z}? zjqB!EI-y%e)7nr%VG8G+&19Be?%ZLKG5iVsX=g*>>Rxs8pfb+R@wTq*0b@aueBfAD zCyQc2Q4aU?u=D)Mu5CNoK@sw@8O#wwm_>LMK(`L1WtPgQ><1{>(iRW4VdJ7aUWkeG{Py0k>~#kUgE{eFq5H&)-qWJ%^2@t8YlBRQQr)g9Nh)g!>_3TK zze9Ko0S`jAMpc;(#Wc2rk~uMyWUryohwwv$MT8$AWcYIb{Z+q2`YVLB+o(jb4Pl6H z7}#3!4p7~6Le(ws$!VjiZh{$7ym!D~k0q;HlS)R8^$)OJ7-KAH7TNC+vIuYT7Y7bX zIiNSQ{~9>$sKfKn9jcZ}%gUIN)M8dptUSo);JtN{zsZ8c%9jpQwOFT2<3u$;fydP9EZfyL|gTn;`fA$ zdCOM{XHJtdgb2@7iNb_%$($j~SgncSH>*1b6irRVMv~Elvf63X^_>R%CO5FrBrFN) zZ|kmjDi)2aySMHdP?E}cn(h7te3@zmxcx)8Gb{YDIJ|h`h1l8Zmz&QupKmF8r=;Op zarh$@Z}-W~wPduKzdTwwGpK3loh>bs1&ZU8_24{#-Q=bxL7N$ z6xORtX*QJ9bTJbh(n5~U75eHaGUJ^84N>{Jzk~6Xd@@~vv-L1lb%3(BHyG><_VOcR z0q-lgkDr0^6JsN{OZdcCuID6A5or&J%RVpiT7pAv$qow^z*5xmd_U*3aZC2#> zS-}TxaVKoySpJ$DbgL}WR7@_FjbmlCU}GT!qs#03&zTyM(S)VJ$^r22 znToR=C|^hDL+C}ww>qOYVUN?Lz6t1J4Er0qgz!4T1qA%5vbXrmc(4dR2wm#H4#CV& ztZbV9dOYCp0L9W(ZT#KwjX#}uhK$@Ss34VpaTeyxF#gS6kdt7{Sp=tVp>@7FXGO^- zI6XN#GKb*uF8CH=3xWAPIVWmdf;Vu+eafA4qvR0^N*5-tc{b%RZLUzTI6m|OV_3HT E1MBCT?EnA( diff --git a/portspoof_py/__pycache__/cli.cpython-312.pyc b/portspoof_py/__pycache__/cli.cpython-312.pyc index 154d5249a540520128f45e7f2307a284b4609e4f..7ad5df5799707b19aa57b5d148c092ec6f0601f6 100644 GIT binary patch delta 4163 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 et5=Zkyccz!_q!`W>JdeHW9EN%Q(njb diff --git a/portspoof_py/admin.py b/portspoof_py/admin.py index dcdfc05..ee45083 100644 --- a/portspoof_py/admin.py +++ b/portspoof_py/admin.py @@ -13,6 +13,7 @@ import base64 import hmac import html import json +import ssl from pathlib import Path from typing import Optional, Tuple from urllib.parse import parse_qs, urlparse @@ -680,8 +681,9 @@ async def run_admin( creds: Tuple[str, str], stop_event: asyncio.Event, notifier: Optional['Notifier'] = None, + ssl_context: Optional[ssl.SSLContext] = None, ) -> None: - """Start the admin HTTP server and run until stop_event is set.""" + """Start the admin HTTP(S) server and run until stop_event is set.""" def handler(r, w): asyncio.create_task(_handle(r, w, stats, cfg, creds, notifier)) @@ -691,10 +693,12 @@ async def run_admin( host=host or '127.0.0.1', port=port, reuse_address=True, + ssl=ssl_context, ) - addrs = ', '.join(str(s.getsockname()) for s in server.sockets) - print(f'[admin] web interface → http://{addrs}') + scheme = 'https' if ssl_context else 'http' + host_str = host or '127.0.0.1' + print(f'[admin] web interface → {scheme}://{host_str}:{port}') async with server: await stop_event.wait() diff --git a/portspoof_py/cli.py b/portspoof_py/cli.py index a65cd9e..25e56d1 100644 --- a/portspoof_py/cli.py +++ b/portspoof_py/cli.py @@ -4,6 +4,8 @@ CLI entry point — argument parsing, signal handling, startup/shutdown. import argparse import asyncio import signal +import ssl +import subprocess import sys from typing import Optional @@ -46,9 +48,49 @@ def _parse_args(argv=None): '(default: admin.passwd)') p.add_argument('--email-config', default='email.json', metavar='FILE', help='JSON file for email alert config (default: email.json)') + p.add_argument('--admin-ssl', action='store_true', + help='Serve the admin interface over HTTPS') + p.add_argument('--admin-ssl-cert', default='admin.crt', metavar='FILE', + help='TLS certificate PEM file (default: admin.crt). ' + 'Auto-generated self-signed cert if the file does not exist.') + p.add_argument('--admin-ssl-key', default='admin.key', metavar='FILE', + help='TLS private key PEM file (default: admin.key). ' + 'Auto-generated alongside the cert if it does not exist.') return p.parse_args(argv) +def _ensure_ssl_cert(cert_file: str, key_file: str) -> None: + """Generate a self-signed cert+key with openssl if they don't already exist.""" + from pathlib import Path as _Path + if _Path(cert_file).exists() and _Path(key_file).exists(): + return + print(f'[admin] generating self-signed TLS cert ({cert_file}, {key_file}) …') + try: + subprocess.run( + [ + 'openssl', 'req', '-x509', '-newkey', 'rsa:2048', + '-keyout', key_file, '-out', cert_file, + '-days', '3650', '-nodes', + '-subj', '/CN=portspoof-admin', + ], + check=True, + capture_output=True, + ) + except FileNotFoundError: + print('ERROR: openssl not found — install it or supply --admin-ssl-cert / --admin-ssl-key', + file=sys.stderr) + raise + except subprocess.CalledProcessError as exc: + print(f'ERROR: openssl failed: {exc.stderr.decode()}', file=sys.stderr) + raise + + +def _build_ssl_context(cert_file: str, key_file: str) -> ssl.SSLContext: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(certfile=cert_file, keyfile=key_file) + return ctx + + def main(argv=None) -> int: args = _parse_args(argv) @@ -92,6 +134,19 @@ def main(argv=None) -> int: ) return 1 + # SSL context for admin interface + ssl_context: Optional[ssl.SSLContext] = None + if args.admin_ssl: + if not args.admin_port: + print('ERROR: --admin-ssl requires --admin-port', file=sys.stderr) + return 1 + try: + _ensure_ssl_cert(args.admin_ssl_cert, args.admin_ssl_key) + ssl_context = _build_ssl_context(args.admin_ssl_cert, args.admin_ssl_key) + print(f'[admin] TLS enabled (cert={args.admin_ssl_cert})') + except Exception: + return 1 + notifier: Notifier = Notifier(args.email_config) print(f'[portspoof] email config: {args.email_config}') @@ -121,7 +176,7 @@ def main(argv=None) -> int: if args.admin_port: tasks.append(asyncio.create_task( run_admin(stats, cfg, args.admin_host, args.admin_port, creds, stop_event, - notifier=notifier) + notifier=notifier, ssl_context=ssl_context) )) await asyncio.gather(*tasks) finally: