diff --git a/Bot_Usage.md b/Bot_Usage.md index 7465043..ee1b62a 100644 --- a/Bot_Usage.md +++ b/Bot_Usage.md @@ -236,7 +236,11 @@ admin subcommand reference). ## 3. Configuration Reference `mcbot.conf` uses INI syntax (`configparser`). CLI flags override conf -values where applicable. +values where applicable. Unrecognized sections/keys (typos, or settings +removed in an upgrade) are ignored, but each is logged loudly as a +`WARNING` at startup (e.g. `mcbot.conf: [bot] unrecognized key 'foo' +(ignored)`). The `[env]` and `[channels]` sections accept arbitrary keys +and are not checked. ### `[radio]` diff --git a/mcbot.py b/mcbot.py index 172199e..a6e0477 100755 --- a/mcbot.py +++ b/mcbot.py @@ -387,6 +387,9 @@ class Config: # path of the config file actually loaded (None if none was found); # recorded so the startup banner can show where settings came from. config_path: Optional[str] = None + # human-readable warnings about the config file (e.g. unrecognized keys), + # collected during load and logged loudly at startup. + config_warnings: list = field(default_factory=list) commands_dir: Path = Path("./commands") privkey_path: Optional[Path] = None # default: .privkey log_channels: str = "all" @@ -450,6 +453,58 @@ def target_desc(self) -> str: return f"tcp {self.host}:{self.port}" +# Recognized option keys per config section (lowercase — configparser folds +# option names to lowercase). Used to warn about typos / stale settings at +# startup. Keep in sync with the keys load_config actually reads below. +_KNOWN_CONFIG_KEYS = { + "radio": {"transport", "host", "port", "serial_port", "serial_baud", + "device_pin"}, + "storage": {"db", "max_channel_messages", "max_dms", "max_contacts", + "max_packets"}, + "channel_logging": {"channels"}, + "logging": {"logs_dir", "log_level"}, + "bot": {"commands_dir", "enabled", "repeat_tracking", "repeat_timeout", + "privkey_path", "dm_max_attempts", "dm_flood_after", + "dm_max_flood_attempts", "radio_evict_enabled", + "radio_evict_headroom", "radio_evict_max_per_run", + "radio_evict_min_interval", "radio_evict_protect_types", + "advert_interval_hours", "owner_pubkeys"}, + "web": {"enabled", "host", "port", "admin_user", "admin_password_hash", + "session_secret", "cors_origins", "api_tokens", "tls_cert", + "tls_key"}, +} +# Sections whose keys are user data (env-var names, channel indexes), not a +# fixed set of option names — accept any key there. +_DYNAMIC_CONFIG_SECTIONS = {"env", "channels"} +# Options that were removed; flagged as deprecated (ignored) rather than +# "unrecognized" so upgraders get a clear, friendly nudge. +_DEPRECATED_CONFIG_KEYS = {"bot": {"rx_log_decrypt"}} + + +def _check_unknown_config_keys(parser) -> list: + """Return warnings for unrecognized sections/keys in the parsed config.""" + warnings = [] + for section in parser.sections(): + if section in _DYNAMIC_CONFIG_SECTIONS: + continue + if section not in _KNOWN_CONFIG_KEYS: + warnings.append(f"unrecognized section [{section}] (ignored)") + continue + allowed = _KNOWN_CONFIG_KEYS[section] + deprecated = _DEPRECATED_CONFIG_KEYS.get(section, frozenset()) + for key in parser[section]: + if key in allowed: + continue + if key in deprecated: + warnings.append( + f"[{section}] '{key}' is deprecated and ignored " + "(safe to remove)" + ) + else: + warnings.append(f"[{section}] unrecognized key '{key}' (ignored)") + return warnings + + def load_config(args) -> Config: cfg = Config() @@ -625,6 +680,10 @@ def load_config(args) -> Config: sys.exit(2) cfg.channels[idx] = (name, secret) + # flag unrecognized sections/keys (typos, stale settings); logged + # loudly at startup once the logger is configured. + cfg.config_warnings = _check_unknown_config_keys(parser) + # CLI overrides if args.transport: cfg.transport = args.transport.strip().lower() @@ -3608,6 +3667,11 @@ async def run(self) -> int: ) self.logger.info("=" * 60) + # surface config typos / stale settings loudly so they're not silently + # ignored (mcbot.conf is not strictly validated). + for w in self.cfg.config_warnings: + self.logger.warning("mcbot.conf: %s", w) + # MeshCore.__init__ forces the "meshcore" logger level from its `debug` # arg (debug -> DEBUG, else INFO), overriding setup_logging. Drive that # arg from our effective level, then re-assert the level after connect diff --git a/tests/test_config_keys.py b/tests/test_config_keys.py new file mode 100644 index 0000000..f1950a9 --- /dev/null +++ b/tests/test_config_keys.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""load_config flags unrecognized config sections/keys (and deprecated ones), +while accepting dynamic [env]/[channels] keys and valid options silently. + +Run: /home/steve/dev/meshcore/meshcore-bot/venv/bin/python tests/test_config_keys.py +""" + +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import mcbot # noqa: E402 + +_failures = 0 + + +def check(cond, msg): + global _failures + print(f" {'ok' if cond else 'FAIL'}: {msg}") + if not cond: + _failures += 1 + + +def warnings_for(text): + fd, path = tempfile.mkstemp(suffix=".conf") + os.write(fd, text.encode()) + os.close(fd) + try: + cfg = mcbot.load_config(mcbot.parse_args(["--config", path])) + return cfg.config_warnings + finally: + os.unlink(path) + + +def main(): + # a fully valid config -> no warnings (no false positives) + valid = ( + "[radio]\nhost = 1.2.3.4\nport = 4000\n\n" + "[bot]\nenabled = true\nrepeat_tracking = true\nadvert_interval_hours = 3\n\n" + "[env]\nPWS_API_KEY = abc\n\n" + "[channels]\n0 = #bot\n" + ) + w = warnings_for(valid) + check(w == [], f"valid config -> no warnings (got {w})") + + # bad config: unknown key, unknown section, deprecated key; dynamic ok + bad = ( + "[radio]\nhost = 1.2.3.4\n\n" + "[bot]\nenabled = true\nbogus_key = 1\nrx_log_decrypt = false\n\n" + "[bogus]\nfoo = bar\n\n" + "[env]\nANY_NAME = x\n\n" + "[channels]\n1 = #bot-cmd-test\n" + ) + w = warnings_for(bad) + joined = " | ".join(w) + check(any("unrecognized key 'bogus_key'" in x for x in w), + "warns on unrecognized [bot] key") + check(any("unrecognized section [bogus]" in x for x in w), + "warns on unrecognized section") + check(any("rx_log_decrypt" in x and "deprecated" in x for x in w), + "flags rx_log_decrypt as deprecated (not unrecognized)") + check(not any("ANY_NAME" in x or "any_name" in x for x in w), + "does NOT warn on [env] keys") + check(not any("bot-cmd-test" in x or "'1'" in x for x in w), + "does NOT warn on [channels] keys") + print(" (warnings:", joined, ")") + + print() + if _failures: + print(f"FAILED: {_failures} check(s)") + sys.exit(1) + print("ALL TESTS PASSED") + + +if __name__ == "__main__": + main()