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} + +
+

Connection delay

+
+
+
+ + +
+
+ + +
+
+

Random delay applied before sending a banner to each connecting client.

+
+ +
+
+
+ + +""" + + +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'' + 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"