diff --git a/portspoof_py/__init__.py b/portspoof_py/__init__.py
index e2d2c96..bd85f4c 100644
--- a/portspoof_py/__init__.py
+++ b/portspoof_py/__init__.py
@@ -1,2 +1,2 @@
"""portspoof_py — asyncio Python rewrite of the portspoof TCP honeypot."""
-__version__ = '2603.4'
+__version__ = '2603.5'
diff --git a/portspoof_py/admin.py b/portspoof_py/admin.py
index f3c03d8..a062bc0 100644
--- a/portspoof_py/admin.py
+++ b/portspoof_py/admin.py
@@ -22,6 +22,7 @@ from . import __version__
from .config import Config
from .geo import country as _geo_country
from .notifier import Notifier
+from .settings import Settings
from .stats import Stats
# ── credentials ───────────────────────────────────────────────────────────────
@@ -128,6 +129,62 @@ def _render_passwd(msg: str = '', msg_ok: bool = True) -> str:
return _PASSWD_HTML.format(css=_CONFIG_CSS, banner_html=banner_html)
+# ── settings page ─────────────────────────────────────────────────────────────
+
+_SETTINGS_HTML = """\
+
+
+
+
+
+ portspoof — settings
+
+
+
+ portspoof admin
+ ← dashboard
+
+ {banner_html}
+
+
+
+
+"""
+
+
+def _render_settings(settings: Settings, msg: str = '', msg_ok: bool = True) -> str:
+ banner_html = ''
+ if msg:
+ cls = 'ok' if msg_ok else 'err'
+ banner_html = f'{html.escape(msg)}
'
+ return _SETTINGS_HTML.format(
+ css=_CONFIG_CSS,
+ banner_html=banner_html,
+ delay_min=settings.delay_min,
+ delay_max=settings.delay_max,
+ )
+
+
# ── config page ───────────────────────────────────────────────────────────────
_CONFIG_CSS = """
@@ -391,7 +448,8 @@ _HTML = """\
- · email alerts ·
+ · settings ·
+ email alerts ·
JSON stats ·
change password
@@ -592,6 +650,7 @@ async def _handle(
cfg: Config,
creds_mut: list,
passwd_file: str,
+ settings: Settings,
notifier: Optional['Notifier'] = None,
) -> None:
try:
@@ -704,6 +763,33 @@ async def _handle(
body = json.dumps(notifier.get_config_safe(), indent=2)
ct = 'application/json'
+ # ── settings ──────────────────────────────────────────────────────────
+ elif path == '/settings':
+ msg = ''
+ msg_ok = True
+ if 'saved' in qs:
+ msg = 'Settings saved.'
+ elif 'err' in qs:
+ msg = 'Invalid values — min must be ≥ 0 and max must be ≥ min.'
+ msg_ok = False
+ body = _render_settings(settings, msg, msg_ok)
+ ct = 'text/html; charset=utf-8'
+
+ elif path == '/api/settings' and method == 'POST':
+ form = _parse_post_body(text)
+ try:
+ d_min = float(form.get('delay_min', settings.delay_min))
+ d_max = float(form.get('delay_max', settings.delay_max))
+ if d_min < 0 or d_max < d_min:
+ raise ValueError
+ settings.delay_min = d_min
+ settings.delay_max = d_max
+ writer.write(_redirect('/settings?saved=1'))
+ except (ValueError, TypeError):
+ writer.write(_redirect('/settings?err=1'))
+ await writer.drain()
+ return
+
# ── password change ───────────────────────────────────────────────────
elif path == '/passwd':
msg = ''
@@ -815,12 +901,15 @@ async def run_admin(
notifier: Optional['Notifier'] = None,
ssl_context: Optional[ssl.SSLContext] = None,
passwd_file: str = 'admin.passwd',
+ settings: Optional[Settings] = None,
) -> None:
"""Start the admin HTTP(S) server and run until stop_event is set."""
creds_mut = list(creds) # mutable so password changes propagate across requests
+ if settings is None:
+ settings = Settings()
def handler(r, w):
- asyncio.create_task(_handle(r, w, stats, cfg, creds_mut, passwd_file, notifier))
+ asyncio.create_task(_handle(r, w, stats, cfg, creds_mut, passwd_file, settings, notifier))
server = await asyncio.start_server(
handler,
diff --git a/portspoof_py/cli.py b/portspoof_py/cli.py
index 94598ab..aeb71a0 100644
--- a/portspoof_py/cli.py
+++ b/portspoof_py/cli.py
@@ -15,6 +15,7 @@ from .iptables import add_rules, check_root, remove_rules
from .logger import Logger
from .notifier import Notifier
from .server import run_server
+from .settings import Settings
from .stats import Stats
@@ -150,6 +151,7 @@ def main(argv=None) -> int:
notifier: Notifier = Notifier(args.email_config)
print(f'[portspoof] email config: {args.email_config}')
+ settings = Settings()
stats = Stats()
log = Logger(args.log_file, verbose=args.verbose, stats=stats, notifier=notifier)
@@ -171,13 +173,13 @@ def main(argv=None) -> int:
try:
bind_ip: Optional[str] = args.bind_ip or None
tasks.append(asyncio.create_task(
- run_server(cfg, log, bind_ip, args.port, args.verbose, stop_event)
+ run_server(cfg, log, bind_ip, args.port, args.verbose, stop_event, settings)
))
if args.admin_port:
tasks.append(asyncio.create_task(
run_admin(stats, cfg, args.admin_host, args.admin_port, creds, stop_event,
notifier=notifier, ssl_context=ssl_context,
- passwd_file=args.admin_passwd)
+ passwd_file=args.admin_passwd, settings=settings)
))
await asyncio.gather(*tasks)
finally:
diff --git a/portspoof_py/server.py b/portspoof_py/server.py
index f9e0459..0f0e280 100644
--- a/portspoof_py/server.py
+++ b/portspoof_py/server.py
@@ -7,11 +7,9 @@ import socket
import struct
from typing import Optional
-_DELAY_MIN = 1.0 # seconds
-_DELAY_MAX = 5.0 # seconds
-
from .config import Config
from .logger import Logger
+from .settings import Settings
# Linux kernel constants
SOL_IP = 0
@@ -39,6 +37,7 @@ async def _handle(
cfg: Config,
log: Logger,
verbose: bool,
+ settings: Settings,
) -> None:
sock = writer.get_extra_info('socket')
peername = writer.get_extra_info('peername') or ('?.?.?.?', 0)
@@ -59,7 +58,7 @@ async def _handle(
)
try:
- await asyncio.sleep(random.uniform(_DELAY_MIN, _DELAY_MAX))
+ await asyncio.sleep(random.uniform(settings.delay_min, settings.delay_max))
if banner:
writer.write(banner)
await writer.drain()
@@ -82,11 +81,12 @@ async def run_server(
port: int,
verbose: bool,
stop_event: asyncio.Event,
+ settings: Settings,
) -> None:
"""Start the asyncio TCP server and run until stop_event is set."""
def handler(r, w):
- asyncio.create_task(_handle(r, w, cfg, log, verbose))
+ asyncio.create_task(_handle(r, w, cfg, log, verbose, settings))
server = await asyncio.start_server(
handler,
diff --git a/portspoof_py/settings.py b/portspoof_py/settings.py
new file mode 100644
index 0000000..9cc08ad
--- /dev/null
+++ b/portspoof_py/settings.py
@@ -0,0 +1,8 @@
+"""Mutable runtime settings shared between the server and admin interface."""
+from dataclasses import dataclass
+
+
+@dataclass
+class Settings:
+ delay_min: float = 1.0 # seconds to wait before sending banner (minimum)
+ delay_max: float = 5.0 # seconds to wait before sending banner (maximum)
diff --git a/pyproject.toml b/pyproject.toml
index 214f1df..aead026 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
[project]
name = "portspoof-py"
-version = "2603.4"
+version = "2603.5"
description = "Python asyncio rewrite of the portspoof TCP honeypot"
readme = "README.md"
requires-python = ">=3.11"