added ssl admin

This commit is contained in:
2026-03-08 13:42:58 -04:00
parent 8a101892f2
commit 7598c2654a
6 changed files with 86 additions and 4 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
#Password files #Password files
admin.passwd admin.passwd
email.json email.json
admin.crt
admin.key

View File

@@ -186,6 +186,9 @@ portspoof-py [OPTIONS]
| `--admin-port PORT` | disabled | Start the web admin interface on this port. | | `--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-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-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. | | `--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`. 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 /` ### Dashboard — `GET /`
A dark-themed HTML page that auto-refreshes every 5 seconds. Sections: A dark-themed HTML page that auto-refreshes every 5 seconds. Sections:

View File

@@ -13,6 +13,7 @@ import base64
import hmac import hmac
import html import html
import json import json
import ssl
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@@ -680,8 +681,9 @@ async def run_admin(
creds: Tuple[str, str], creds: Tuple[str, str],
stop_event: asyncio.Event, stop_event: asyncio.Event,
notifier: Optional['Notifier'] = None, notifier: Optional['Notifier'] = None,
ssl_context: Optional[ssl.SSLContext] = None,
) -> 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): def handler(r, w):
asyncio.create_task(_handle(r, w, stats, cfg, creds, notifier)) 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', host=host or '127.0.0.1',
port=port, port=port,
reuse_address=True, reuse_address=True,
ssl=ssl_context,
) )
addrs = ', '.join(str(s.getsockname()) for s in server.sockets) scheme = 'https' if ssl_context else 'http'
print(f'[admin] web interface → http://{addrs}') host_str = host or '127.0.0.1'
print(f'[admin] web interface → {scheme}://{host_str}:{port}')
async with server: async with server:
await stop_event.wait() await stop_event.wait()

View File

@@ -4,6 +4,8 @@ CLI entry point — argument parsing, signal handling, startup/shutdown.
import argparse import argparse
import asyncio import asyncio
import signal import signal
import ssl
import subprocess
import sys import sys
from typing import Optional from typing import Optional
@@ -46,9 +48,49 @@ def _parse_args(argv=None):
'(default: admin.passwd)') '(default: admin.passwd)')
p.add_argument('--email-config', default='email.json', metavar='FILE', p.add_argument('--email-config', default='email.json', metavar='FILE',
help='JSON file for email alert config (default: email.json)') 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) 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: def main(argv=None) -> int:
args = _parse_args(argv) args = _parse_args(argv)
@@ -92,6 +134,19 @@ def main(argv=None) -> int:
) )
return 1 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) notifier: Notifier = Notifier(args.email_config)
print(f'[portspoof] email config: {args.email_config}') print(f'[portspoof] email config: {args.email_config}')
@@ -121,7 +176,7 @@ def main(argv=None) -> int:
if args.admin_port: if args.admin_port:
tasks.append(asyncio.create_task( tasks.append(asyncio.create_task(
run_admin(stats, cfg, args.admin_host, args.admin_port, creds, stop_event, 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) await asyncio.gather(*tasks)
finally: finally: