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}
+
+
+
+
+"""
+
+_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'{html.escape(msg)}
'
+ return _PASSWD_HTML.format(css=_CONFIG_CSS, banner_html=banner_html)
+
+
# ── config page ───────────────────────────────────────────────────────────────
_CONFIG_CSS = """
@@ -335,7 +392,8 @@ _HTML = """\
reset stats
· 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"