diff --git a/portspoof_py/__init__.py b/portspoof_py/__init__.py index b18d460..b2ae092 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.1' +__version__ = '2603.2' diff --git a/portspoof_py/admin.py b/portspoof_py/admin.py index cdb2c61..f3c03d8 100644 --- a/portspoof_py/admin.py +++ b/portspoof_py/admin.py @@ -71,6 +71,63 @@ _UNAUTHORIZED = ( b'Unauthorized' ) +# ── passwd page ─────────────────────────────────────────────────────────────── + +_PASSWD_HTML = """\ + + + + + + portspoof — change password + + + +

portspoof admin

+

← dashboard

+ + {banner_html} + +
+

Change password

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +""" + +_PASSWD_ERRORS = { + 'wrong_current': 'Current password is incorrect.', + 'mismatch': 'New passwords do not match.', + 'empty': 'New password must not be empty.', + 'save': 'Password updated in memory but could not be written to disk.', +} + + +def _render_passwd(msg: str = '', msg_ok: bool = True) -> str: + banner_html = '' + if msg: + cls = 'ok' if msg_ok else 'err' + banner_html = f'' + return _PASSWD_HTML.format(css=_CONFIG_CSS, banner_html=banner_html) + + # ── config page ─────────────────────────────────────────────────────────────── _CONFIG_CSS = """ @@ -335,7 +392,8 @@ _HTML = """\ · email alerts · - JSON stats + JSON stats · + change password

@@ -532,7 +590,8 @@ async def _handle( writer: asyncio.StreamWriter, stats: Stats, cfg: Config, - creds: Tuple[str, str], + creds_mut: list, + passwd_file: str, notifier: Optional['Notifier'] = None, ) -> None: try: @@ -543,7 +602,7 @@ async def _handle( if len(parts) < 2: return - if not _check_auth(text, creds): + if not _check_auth(text, creds_mut): writer.write(_UNAUTHORIZED) await writer.drain() return @@ -645,6 +704,40 @@ async def _handle( body = json.dumps(notifier.get_config_safe(), indent=2) ct = 'application/json' + # ── password change ─────────────────────────────────────────────────── + elif path == '/passwd': + msg = '' + msg_ok = True + if 'ok' in qs: + msg = 'Password updated successfully.' + elif 'err' in qs: + msg = _PASSWD_ERRORS.get(qs['err'][0], 'Unknown error.') + msg_ok = False + body = _render_passwd(msg, msg_ok) + ct = 'text/html; charset=utf-8' + + elif path == '/api/passwd' and method == 'POST': + form = _parse_post_body(text) + current = form.get('current_password', '') + new_pw = form.get('new_password', '') + confirm = form.get('confirm_password', '') + _, expected_passwd = creds_mut + if not hmac.compare_digest(current, expected_passwd): + writer.write(_redirect('/passwd?err=wrong_current')) + elif not new_pw: + writer.write(_redirect('/passwd?err=empty')) + elif new_pw != confirm: + writer.write(_redirect('/passwd?err=mismatch')) + else: + creds_mut[1] = new_pw + try: + Path(passwd_file).write_text(f'{creds_mut[0]}:{new_pw}\n') + writer.write(_redirect('/passwd?ok=1')) + except Exception: + writer.write(_redirect('/passwd?err=save')) + await writer.drain() + return + # ── standard routes ─────────────────────────────────────────────────── elif path == '/api/stats/reset' and method == 'POST': stats.reset() @@ -721,11 +814,13 @@ async def run_admin( stop_event: asyncio.Event, notifier: Optional['Notifier'] = None, ssl_context: Optional[ssl.SSLContext] = None, + passwd_file: str = 'admin.passwd', ) -> 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 def handler(r, w): - asyncio.create_task(_handle(r, w, stats, cfg, creds, notifier)) + asyncio.create_task(_handle(r, w, stats, cfg, creds_mut, passwd_file, notifier)) server = await asyncio.start_server( handler, diff --git a/portspoof_py/cli.py b/portspoof_py/cli.py index 25e56d1..94598ab 100644 --- a/portspoof_py/cli.py +++ b/portspoof_py/cli.py @@ -176,7 +176,8 @@ 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, ssl_context=ssl_context) + notifier=notifier, ssl_context=ssl_context, + passwd_file=args.admin_passwd) )) await asyncio.gather(*tasks) finally: diff --git a/pyproject.toml b/pyproject.toml index 3bdac38..693c444 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "portspoof-py" -version = "2603.1" +version = "2603.2" description = "Python asyncio rewrite of the portspoof TCP honeypot" readme = "README.md" requires-python = ">=3.11"