From d73721be99e884d6af1c1bc849ea67e8fc83a70f Mon Sep 17 00:00:00 2001 From: FMSMITH91 <12152698+FMSMITH91@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:14:25 -0500 Subject: [PATCH 1/2] test: cover saving notification settings, the Telegram poller and Send test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of notifications.py's zero-coverage functions. The one that matters: save_settings treats a None secret as "keep what is stored", because the settings form never round-trips a real token back to the server. If that ever broke, every unrelated checkbox change would silently wipe the bot token and alerts would just stop — no error, nothing on screen. Asserted in both directions: None keeps the stored value, a real value replaces it AND is written encrypted (the plaintext must not appear in the config, and it must decrypt back). Also: thresholds clamp to their bounds rather than storing whatever was posted, and junk in a numeric field is ignored instead of overwriting a good value; the Telegram poller returns None for a malformed or empty token without building a URL; and Send test explains what is missing — five cases, each naming the field — rather than failing mute. update_config is stubbed throughout, so the real data/config.json is never written by these. Verified: the file's bytes are unchanged across a run. (Its mtime does move, but that predates this change — the suite already rewrote it byte-identically at HEAD.) Mutation-verified: making a None token overwrite fails 1, storing the secret in the clear fails 1, dropping the threshold clamp fails 1. Co-Authored-By: Claude Opus 5 --- tests/unit_test.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/unit_test.py b/tests/unit_test.py index a3e676f..45cd70e 100644 --- a/tests/unit_test.py +++ b/tests/unit_test.py @@ -3224,6 +3224,71 @@ def _fake_post(url, data, headers): check("alerts: _post blocks a URL outside the provider allow-list (SSRF barrier)", _blocked_ok is False and _blocked_reason == "blocked", _blocked_reason) +# ── Saving notification settings must not destroy the secrets ───────────────────────────────── +# The settings form never round-trips a real token back to the server — it posts None to mean +# "leave it alone". If save_settings took that literally it would wipe the bot token every time +# anyone touched an unrelated checkbox, and alerts would stop with nothing on screen to say why. +# update_config is stubbed throughout, so the real data/config.json is never written. +_saved_cfg = {} +_sv_cfgfn, _sv_update = N._cfg, N.update_config +try: + N.update_config = lambda fn: fn(_saved_cfg) + _existing_tok = N.encrypt_secret("123456789:AAtokenAAtokenAAtokenAAtokenAAtoken") + _existing_wh = N.encrypt_secret("https://discord.com/api/webhooks/1/x") + N._cfg = lambda: {"telegram": {"token": _existing_tok, "chat_id": "42"}, + "discord": {"webhook": _existing_wh}} + N.save_settings(telegram={"enabled": True, "chat_id": "42", "token": None}, + discord={"enabled": True, "webhook": None}, events={}) + _n = _saved_cfg["notifications"] + check("notify save: a None token KEEPS the stored one (the form never sends it back)", + _n["telegram"]["token"] == _existing_tok) + check("notify save: a None webhook keeps the stored one too", + _n["discord"]["webhook"] == _existing_wh) + + _saved_cfg.clear() + N.save_settings(telegram={"enabled": True, "chat_id": "42", "token": "999:NEWTOKEN"}, + discord={"enabled": False, "webhook": ""}, events={}) + _n = _saved_cfg["notifications"] + check("notify save: a new token replaces it", _n["telegram"]["token"] != _existing_tok) + check("notify save: and is stored ENCRYPTED, not in the clear", + "999:NEWTOKEN" not in _n["telegram"]["token"] + and N.decrypt_secret(_n["telegram"]["token"]) == "999:NEWTOKEN") + + # Thresholds are clamped, and junk must not overwrite a good value. + _saved_cfg.clear() + _k = sorted(N._DEFAULT_THRESHOLDS)[0] + _lo, _hi = N._THRESHOLD_BOUNDS[_k] + N.save_settings(telegram={}, discord={}, events={}, thresholds={_k: _hi + 10_000}) + check("notify save: an out-of-range threshold is clamped to its bound", + _saved_cfg["notifications"]["thresholds"][_k] == _hi, + "%s -> %s" % (_k, _saved_cfg["notifications"]["thresholds"][_k])) + _saved_cfg.clear() + N.save_settings(telegram={}, discord={}, events={}, thresholds={_k: "not a number"}) + check("notify save: junk in a threshold is ignored, not written", + isinstance(_saved_cfg["notifications"]["thresholds"][_k], int)) +finally: + N._cfg, N.update_config = _sv_cfgfn, _sv_update + +# ── The Telegram poller ──────────────────────────────────────────────────────────────────────── +check("telegram poll: a malformed token returns None without building a URL", + N.telegram_get_updates("not-a-token") is None) +check("telegram poll: so does an empty one", N.telegram_get_updates("") is None) + +# ── "Send test" must explain itself rather than fail silently ───────────────────────────────── +_sv_cfg2 = N._cfg +try: + N._cfg = lambda: {} + for _kind, _kw, _want in (("telegram", {}, "bot token"), + ("telegram", {"token": "nope"}, "expected format"), + ("telegram", {"token": "123456789:AAtokenAAtokenAAtokenAAtokenAAtoken"}, "chat ID"), + ("discord", {}, "webhook URL"), + ("discord", {"webhook": "https://evil.example/x"}, "Discord webhook URL")): + _ok, _msg = N.test_send(_kind, **_kw) + check("test send: %s with %s explains what is missing" % (_kind, list(_kw) or "nothing"), + _ok is False and _want.lower() in _msg.lower(), _msg[:70]) +finally: + N._cfg = _sv_cfg2 + passed = sum(1 for ok, _, _ in results if ok) for ok, name, detail in results: line = ("PASS" if ok else "FAIL") + " " + name From 605e85217cc28d3438b1b0c940e2b0b142c49c2a Mon Sep 17 00:00:00 2001 From: FMSMITH91 <12152698+FMSMITH91@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:22:53 -0500 Subject: [PATCH 2/2] test: a sixth fuzz target for the cron parsers, and tailnet address coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ── fuzz: cron ──────────────────────────────────────────────────────────────── Everything the cron parsers read comes off a remote host — the game user's crontab, which anyone with shell access edits by hand, and the base64-framed status blob the panel's own wrapper writes, which carries whatever a LinuxGSM command printed to stderr (for an update, text fetched off the internet). That path has already produced one real bug this way: a `tr` that missed \r truncated every record at the transport, so errors silently read as empty. The Scheduled Tasks page renders all of it, and a raise here takes out the page rather than one row. Six parsers under test — _split_cron_line, _unwrap_cron_command, _cron_role, _cron_line_managed, _cron_log_text, _clean_cron_error — with 11 seeds covering a wrapped line, @reboot, name-form fields, a valid and a broken base64 blob, and an ANSI-laden error. Property-checked over 200k random inputs before committing: nothing raised. ── tailnet address recognition ─────────────────────────────────────────────── is_tailscale_ip decides whether the panel treats a host as being ON the tailnet, which changes how it connects and what it exempts from fail2ban and UFW — so a false positive exempts a PUBLIC address from the security rules. Nine near-misses are asserted alongside the six hits, including "example.ts.net.evil.com" and "1.100.0.1". get_magic_url too: 80 and 443 are left off, any other port is appended, and a node with no MagicDNS name yields None rather than "https://None". Co-Authored-By: Claude Opus 5 --- .github/workflows/fuzz.yml | 2 +- tests/fuzz/corpus/cron/ansi_error | 1 + tests/fuzz/corpus/cron/at_reboot | 1 + tests/fuzz/corpus/cron/b64_broken | 1 + tests/fuzz/corpus/cron/b64_status | 1 + tests/fuzz/corpus/cron/comment | 3 ++ tests/fuzz/corpus/cron/daily_restart | 1 + tests/fuzz/corpus/cron/many_fields | 1 + tests/fuzz/corpus/cron/monitor | 1 + tests/fuzz/corpus/cron/names | 1 + tests/fuzz/corpus/cron/no_schedule | 1 + tests/fuzz/corpus/cron/wrapped | 1 + tests/fuzz/fuzz_cron.py | 54 ++++++++++++++++++++++++++++ tests/unit_test.py | 34 ++++++++++++++++++ 14 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/fuzz/corpus/cron/ansi_error create mode 100644 tests/fuzz/corpus/cron/at_reboot create mode 100644 tests/fuzz/corpus/cron/b64_broken create mode 100644 tests/fuzz/corpus/cron/b64_status create mode 100644 tests/fuzz/corpus/cron/comment create mode 100644 tests/fuzz/corpus/cron/daily_restart create mode 100644 tests/fuzz/corpus/cron/many_fields create mode 100644 tests/fuzz/corpus/cron/monitor create mode 100644 tests/fuzz/corpus/cron/names create mode 100644 tests/fuzz/corpus/cron/no_schedule create mode 100644 tests/fuzz/corpus/cron/wrapped create mode 100644 tests/fuzz/fuzz_cron.py diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 8619dc8..b968dff 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -43,7 +43,7 @@ jobs: strategy: fail-fast: false # fuzz every target even if one finds a crash, so all findings surface matrix: - target: [ game_status, firewall, config, fail2ban, console ] + target: [ game_status, firewall, config, fail2ban, console, cron ] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/tests/fuzz/corpus/cron/ansi_error b/tests/fuzz/corpus/cron/ansi_error new file mode 100644 index 0000000..63df31b --- /dev/null +++ b/tests/fuzz/corpus/cron/ansi_error @@ -0,0 +1 @@ +G1szMW1FUlJPUhtbMG06IHVwZGF0ZSBmYWlsZWQNCnJldHJ5DQo= \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/at_reboot b/tests/fuzz/corpus/cron/at_reboot new file mode 100644 index 0000000..42f5ccf --- /dev/null +++ b/tests/fuzz/corpus/cron/at_reboot @@ -0,0 +1 @@ +@reboot /home/gmserver/gmserver start \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/b64_broken b/tests/fuzz/corpus/cron/b64_broken new file mode 100644 index 0000000..459643e --- /dev/null +++ b/tests/fuzz/corpus/cron/b64_broken @@ -0,0 +1 @@ +not valid base64 !!! \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/b64_status b/tests/fuzz/corpus/cron/b64_status new file mode 100644 index 0000000..d8c67f5 --- /dev/null +++ b/tests/fuzz/corpus/cron/b64_status @@ -0,0 +1 @@ +ZmV0Y2hpbmcgWyB4LmNzdiBdY3VybDogKDIyKSA0MDQKRVJST1IK \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/comment b/tests/fuzz/corpus/cron/comment new file mode 100644 index 0000000..f2ab94b --- /dev/null +++ b/tests/fuzz/corpus/cron/comment @@ -0,0 +1,3 @@ +# a comment line + +*/5 * * * * echo hi \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/daily_restart b/tests/fuzz/corpus/cron/daily_restart new file mode 100644 index 0000000..844fc5e --- /dev/null +++ b/tests/fuzz/corpus/cron/daily_restart @@ -0,0 +1 @@ +0 5 * * * touch /home/gmserver/.restart-pending \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/many_fields b/tests/fuzz/corpus/cron/many_fields new file mode 100644 index 0000000..9f308a8 --- /dev/null +++ b/tests/fuzz/corpus/cron/many_fields @@ -0,0 +1 @@ +* * * * * * * * * * /home/gm/gmserver monitor \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/monitor b/tests/fuzz/corpus/cron/monitor new file mode 100644 index 0000000..82cabbe --- /dev/null +++ b/tests/fuzz/corpus/cron/monitor @@ -0,0 +1 @@ +*/5 * * * * /home/gmserver/gmserver monitor \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/names b/tests/fuzz/corpus/cron/names new file mode 100644 index 0000000..109aed9 --- /dev/null +++ b/tests/fuzz/corpus/cron/names @@ -0,0 +1 @@ +0 5 * * mon /home/gm/gmserver backup \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/no_schedule b/tests/fuzz/corpus/cron/no_schedule new file mode 100644 index 0000000..aa22e0d --- /dev/null +++ b/tests/fuzz/corpus/cron/no_schedule @@ -0,0 +1 @@ +just some text with no cron fields at all \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/wrapped b/tests/fuzz/corpus/cron/wrapped new file mode 100644 index 0000000..c4cd2ae --- /dev/null +++ b/tests/fuzz/corpus/cron/wrapped @@ -0,0 +1 @@ +*/5 * * * * /usr/bin/flock -n /tmp/x /home/gm/.lgsm-cron-rec 3f2a /home/gm/gmserver update \ No newline at end of file diff --git a/tests/fuzz/fuzz_cron.py b/tests/fuzz/fuzz_cron.py new file mode 100644 index 0000000..ecfa9af --- /dev/null +++ b/tests/fuzz/fuzz_cron.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Atheris fuzz harness: the crontab / cron-status parsers (ssh_manager). + +Everything these read comes off a REMOTE host: the game user's crontab, and the base64-framed +status blob the panel's own cron wrapper writes after each run. Both are outside the panel's +control — a crontab can be edited by hand on the box, and the status blob carries whatever a +LinuxGSM command printed to stderr, which for an update means text fetched off the internet. + +The Scheduled Tasks page renders all of it, and the panel's cron-status path has already produced +one real bug this way (a `tr` that missed \\r truncated every record at the transport, so errors +silently read as empty). These parsers are best-effort and must never raise: a crash here takes +out the whole page rather than one row. + +Run locally (from anywhere): + pip install atheris + python tests/fuzz/fuzz_cron.py -max_total_time=60 tests/fuzz/corpus/cron +""" +import importlib +import os +import sys + +import atheris + +# Running `python tests/fuzz/fuzz_x.py` puts tests/fuzz (not the project root) on sys.path. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +# Pre-load ssh_manager's heavy dependencies UNINSTRUMENTED so instrument_imports() covers only the +# parsers (faster, and the coverage signal stays on target). +for _dep in ("paramiko", "eventlet.tpool", "config"): + importlib.import_module(_dep) + +with atheris.instrument_imports(): + import ssh_manager + + +def TestOneInput(data): + fdp = atheris.FuzzedDataProvider(data) + text = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes()) + + ssh_manager._split_cron_line(text) # "*/5 * * * * cmd" -> (schedule, command) + ssh_manager._unwrap_cron_command(text) # strips the panel's recorder wrapper + ssh_manager._cron_role(text, "gm", "gmserver") # autostart / daily-restart label + ssh_manager._cron_line_managed(text, "gm", "gmserver") + ssh_manager._cron_log_text(text) # lenient base64 decode of a status blob + ssh_manager._clean_cron_error(text) # terminal-render + trim for display + + +def main(): + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/tests/unit_test.py b/tests/unit_test.py index 45cd70e..f2c190a 100644 --- a/tests/unit_test.py +++ b/tests/unit_test.py @@ -3289,6 +3289,40 @@ def _fake_post(url, data, headers): finally: N._cfg = _sv_cfg2 +# ── Tailscale address recognition ────────────────────────────────────────────────────────────── +# is_tailscale_ip decides whether the panel treats a host as being on the tailnet, which changes +# how it connects and what it exempts from fail2ban/UFW. A false positive would exempt a PUBLIC +# address from the security rules, so the near-misses matter as much as the hits. +import tailscale_integration as TS + +for _h in ("100.64.0.1", "100.115.92.7", "fd7a:115c:a1e0::1", + "ns106051.taile87e07.ts.net", "box.tail1234.ts.net", "host.taile87e07.example"): + check("tailnet: %r is recognised" % _h, TS.is_tailscale_ip(_h) is True) +for _h in ("10.0.0.1", "192.168.1.5", "1.100.0.1", "203.0.113.9", "example.ts.net.evil.com", + "fd7b:115c::1", "", None, "100abc.example.com"): + check("tailnet: %r is NOT taken for a tailnet address" % (_h,), TS.is_tailscale_ip(_h) is False) + +# get_magic_url: the port is omitted for the standard ones and appended otherwise, and there is no +# URL at all when the node has no MagicDNS name (rather than a broken "https://None"). +_sv_info = TS.get_tailscale_info +try: + class _Info(object): + def __init__(self, dns): self.dns_name, self.tailscale_ips, self.peers = dns, [], [] + TS.get_tailscale_info = lambda *a, **k: _Info("box.taile87e07.ts.net") + eq("magic url: no port for https", TS.get_magic_url(), "https://box.taile87e07.ts.net") + eq("magic url: 443 is left off", TS.get_magic_url(443), "https://box.taile87e07.ts.net") + eq("magic url: 80 is left off too", TS.get_magic_url(80), "https://box.taile87e07.ts.net") + eq("magic url: any other port is appended", TS.get_magic_url(5000), + "https://box.taile87e07.ts.net:5000") + eq("magic url: the protocol is honoured", TS.get_magic_url(8080, "http"), + "http://box.taile87e07.ts.net:8080") + TS.get_tailscale_info = lambda *a, **k: _Info("") + eq("magic url: no MagicDNS name means no URL, not a broken one", TS.get_magic_url(), None) +finally: + TS.get_tailscale_info = _sv_info + +check("tailnet: the panel can name the OS user it runs as", bool(TS._current_os_user())) + passed = sum(1 for ok, _, _ in results if ok) for ok, name, detail in results: line = ("PASS" if ok else "FAIL") + " " + name