diff --git a/config.yaml.example b/config.yaml.example index 0f2f208..79b8333 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -311,6 +311,10 @@ mesh: # true = allow unscoped flooding, false = deny flooding globally unscoped_flood_allow: true + # Legacy alias for unscoped flood policy. Keep in sync with unscoped_flood_allow. + # true = allow unscoped flooding, false = deny + global_flood_allow: true + # Path hash mode for flood packets (0-hop): per-hop hash size in path encoding # 0 = 1-byte hashes (legacy), 1 = 2-byte, 2 = 3-byte. Must match mesh convention. # Affects originated adverts and any other flood packets sent by the repeater. @@ -320,6 +324,10 @@ mesh: # off = disabled, minimal = allow up to 3 self-hashes, moderate = allow up to 1, strict = allow 0 loop_detect: minimal + # Default flood scope for locally-originated flood adverts. + # Use null or "" via CLI command "region default " to clear. + default_region: null + # Multiple Identity Configuration (Optional) # Define additional identities for the repeater to manage # Each identity operates independently with its own key pair and configuration diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index b4ecc61..4c6a4b8 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -2143,11 +2143,29 @@ class SQLiteHandler: try: with self._connect() as conn: cursor = conn.execute("DELETE FROM adverts WHERE id = ?", (advert_id,)) + self._neighbors_cache = {"timestamp": 0.0, "value": None} return cursor.rowcount > 0 except Exception as e: logger.error(f"Failed to delete advert: {e}") return False + def delete_neighbors_by_pubkey_prefix(self, pubkey_prefix: Optional[str]) -> int: + """Delete neighbor adverts by pubkey prefix (or all when prefix is None).""" + try: + with self._connect() as conn: + if pubkey_prefix is None: + cursor = conn.execute("DELETE FROM adverts") + else: + cursor = conn.execute( + "DELETE FROM adverts WHERE lower(pubkey) LIKE ?", + (f"{pubkey_prefix.lower()}%",), + ) + self._neighbors_cache = {"timestamp": 0.0, "value": None} + return int(cursor.rowcount) + except Exception as e: + logger.error(f"Failed to delete neighbors by prefix: {e}") + raise + # ------------------------------------------------------------------ # Room Server Methods # ------------------------------------------------------------------ @@ -2755,7 +2773,9 @@ class SQLiteHandler: logger.error(f"Failed to count companion messages: {e}") return 0 - def companion_load_messages(self, companion_hash: str, limit: int = 100) -> Optional[List[Dict]]: + def companion_load_messages( + self, companion_hash: str, limit: int = 100 + ) -> Optional[List[Dict]]: """Load queued messages for a companion (oldest first for queue order). Returns [] when the companion has no persisted messages, or None when diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index 1a3303e..89fff18 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -551,6 +551,9 @@ class StorageCollector: def delete_advert(self, advert_id: int) -> bool: return self.sqlite_handler.delete_advert(advert_id) + def delete_neighbors_by_pubkey_prefix(self, pubkey_prefix: str | None) -> int: + return self.sqlite_handler.delete_neighbors_by_pubkey_prefix(pubkey_prefix) + def get_hardware_stats(self) -> Optional[dict]: """Get current hardware statistics""" try: diff --git a/repeater/data_acquisition/websocket_handler.py b/repeater/data_acquisition/websocket_handler.py index 17aeb1f..b324228 100644 --- a/repeater/data_acquisition/websocket_handler.py +++ b/repeater/data_acquisition/websocket_handler.py @@ -57,7 +57,11 @@ class PacketWebSocket(WebSocket): try: payload = jwt_handler.verify_jwt(token) if payload: - if client_id and payload.get("client_id") and payload.get("client_id") != client_id: + if ( + client_id + and payload.get("client_id") + and payload.get("client_id") != client_id + ): logger.warning("WebSocket connection rejected: client_id mismatch") self.close(code=1008, reason="unauthorized") return diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index 214c7a0..8854168 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -37,6 +37,112 @@ class MeshCLI: # Get repeater config shortcut self.repeater_config = config.get("repeater", {}) + self.mesh_config = config.setdefault("mesh", {}) + + def _get_node_name(self) -> str: + """Return the configured node name, preferring the newer key when present.""" + return self.repeater_config.get("node_name") or self.repeater_config.get("name", "Unknown") + + def _set_node_name(self, value: str) -> None: + """Persist node name to both legacy and current config keys for compatibility.""" + self.repeater_config["node_name"] = value + self.repeater_config["name"] = value + + def _get_local_pubkey_hex(self) -> Optional[str]: + """Return local node public key (hex) when available.""" + try: + if self.identity and hasattr(self.identity, "get_public_key"): + pubkey = self.identity.get_public_key() + if isinstance(pubkey, (bytes, bytearray)): + return bytes(pubkey).hex().lower() + if isinstance(pubkey, str): + normalized = pubkey.strip().lower() + if normalized.startswith("0x"): + normalized = normalized[2:] + if normalized: + return normalized + except Exception as exc: + logger.debug("Unable to read local identity pubkey: %s", exc) + + key = self.repeater_config.get("identity_key") + if isinstance(key, (bytes, bytearray)): + return bytes(key).hex().lower() + if isinstance(key, str): + normalized = key.strip().lower() + if normalized.startswith("0x"): + normalized = normalized[2:] + if normalized and all(ch in "0123456789abcdef" for ch in normalized): + return normalized + + return None + + def _is_local_pubkey(self, pubkey_hex: str) -> bool: + """Return True when a discovery result pubkey matches the local node.""" + candidate = (pubkey_hex or "").strip().lower() + if not candidate: + return False + if candidate.startswith("0x"): + candidate = candidate[2:] + + local_pubkey = self._get_local_pubkey_hex() + if not local_pubkey: + return False + + # Prefix-only discovery may return fewer bytes than full identity pubkey. + return local_pubkey.startswith(candidate) or candidate.startswith(local_pubkey) + + def _auto_add_discovery_result(self, result: Dict[str, Any]) -> Dict[str, Any]: + """Persist discovered neighbors automatically, excluding this node itself.""" + enriched = dict(result) + pub_key = str(enriched.get("pub_key") or "").strip().lower() + if not pub_key: + return enriched + + if self._is_local_pubkey(pub_key): + enriched["is_self"] = True + enriched["known_neighbor"] = True + return enriched + + if not self.storage_handler: + return enriched + + record_advert = getattr(self.storage_handler, "record_advert", None) + if not callable(record_advert): + return enriched + + try: + import time + + node_type = int(enriched.get("node_type", 0) or 0) + contact_type = { + 1: "Chat Node", + 2: "Repeater", + 3: "Room Server", + }.get(node_type, "Unknown") + + rssi = enriched.get("rssi") + snr = enriched.get("response_snr", enriched.get("snr")) + advert_record = { + "timestamp": time.time(), + "pubkey": pub_key, + "node_name": enriched.get("node_name"), + "is_repeater": node_type == 2, + "route_type": 2, + "contact_type": contact_type, + "latitude": None, + "longitude": None, + "rssi": int(rssi) if rssi is not None else None, + "snr": float(snr) if snr is not None else None, + "is_new_neighbor": True, + "zero_hop": True, + } + record_advert(advert_record) + enriched["known_neighbor"] = True + enriched["auto_added"] = True + except Exception as exc: + logger.debug("Auto-add discovery result failed for %s: %s", pub_key, exc) + + return enriched def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str: @@ -119,6 +225,8 @@ class MeshCLI: return self._cmd_neighbors() elif command.startswith("neighbor.remove "): return self._cmd_neighbor_remove(command) + elif command.startswith("discover.neighbors"): + return self._cmd_discover_neighbors(command) # Temporary radio params elif command.startswith("tempradio "): @@ -152,7 +260,7 @@ class MeshCLI: return self._help_detail(parts[1]) lines = [ - "=== pyMC CLI Commands ===", + "=== openHop CLI Commands ===", "", "System:", " reboot Restart the repeater service", @@ -174,11 +282,14 @@ class MeshCLI: " get repeat Repeat mode (on/off)", " get lat / get lon GPS coordinates", " get role Identity role", + " get owner.info Owner info text", " get guest.password Guest password", " get allow.read.only Read-only access setting", " get advert.interval Advert interval (minutes)", " get flood.advert.interval Flood advert interval (hours)", " get flood.max Max flood hops", + " get path.hash.mode Flood advert path hash mode (0-2)", + " get loop.detect Flood loop detection mode", " get rxdelay RX delay base", " get txdelay TX delay factor", " get direct.txdelay Direct TX delay factor", @@ -192,6 +303,7 @@ class MeshCLI: "Other:", " neighbors List neighbors", " neighbor.remove Remove neighbor by pubkey", + " discover.neighbors Send zero-hop neighbor discovery", " tempradio ", " setperm Set ACL permissions", " log start|stop|erase Logging control", @@ -216,10 +328,13 @@ class MeshCLI: " set lat Latitude\n" " set lon Longitude\n" " set guest.password Guest password\n" + " set owner.info Owner info text\n" " set allow.read.only on|off Read-only access\n" " set advert.interval 60-240 minutes\n" " set flood.advert.interval
3-168 hours\n" " set flood.max Max flood hops (max 64)\n" + " set path.hash.mode <0-2> Path hash mode (0=1B,1=2B,2=3B)\n" + " set loop.detect Flood loop detection\n" " set rxdelay RX delay base (>=0)\n" " set txdelay TX delay factor (>=0)\n" " set direct.txdelay Direct TX delay (>=0)\n" @@ -242,6 +357,7 @@ class MeshCLI: " freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8" ), "neighbors": "List known neighbor nodes from the routing table.", + "discover.neighbors": "Send a neighbor discovery request.", "setperm": "setperm \u2014 Set ACL permissions for a node.", "log": "log start|stop|erase \u2014 Control logging.", } @@ -372,8 +488,7 @@ class MeshCLI: return f"> {af}" elif param == "name": - name = self.repeater_config.get("name", "Unknown") - return f"> {name}" + return f"> {self._get_node_name()}" elif param == "repeat": mode = self.repeater_config.get("mode", "forward") @@ -426,6 +541,10 @@ class MeshCLI: guest_pw = self.config.get("security", {}).get("guest_password", "") return f"> {guest_pw}" + elif param == "owner.info": + owner_info = self.repeater_config.get("owner_info", "") + return f"> {owner_info}" + elif param == "allow.read.only": allow = self.config.get("security", {}).get("allow_read_only", False) return f"> {'on' if allow else 'off'}" @@ -442,6 +561,14 @@ class MeshCLI: max_flood = self.repeater_config.get("max_flood_hops", 64) return f"> {max_flood}" + elif param == "path.hash.mode": + path_hash_mode = self.mesh_config.get("path_hash_mode", 0) + return f"> {path_hash_mode}" + + elif param == "loop.detect": + loop_detect = self.mesh_config.get("loop_detect", "off") + return f"> {loop_detect}" + elif param == "rxdelay": delay = self.repeater_config.get("rx_delay_base", 0.0) return f"> {delay}" @@ -487,7 +614,7 @@ class MeshCLI: return "OK" elif key == "name": - self.repeater_config["node_name"] = value + self._set_node_name(value) saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(["repeater"]) return "OK" @@ -551,6 +678,12 @@ class MeshCLI: self.config_manager.live_update_daemon(["security"]) return "OK" + elif key == "owner.info": + self.repeater_config["owner_info"] = value.replace("|", "\n") + saved, _ = self.config_manager.save_to_file() + self.config_manager.live_update_daemon(["repeater"]) + return "OK" + elif key == "allow.read.only": if "security" not in self.config: self.config["security"] = {} @@ -586,6 +719,24 @@ class MeshCLI: self.config_manager.live_update_daemon(["repeater"]) return "OK" + elif key == "path.hash.mode": + mode = int(value) + if mode not in (0, 1, 2): + return "Error: path.hash.mode must be 0, 1, or 2" + self.mesh_config["path_hash_mode"] = mode + saved, _ = self.config_manager.save_to_file() + self.config_manager.live_update_daemon(["mesh"]) + return "OK" + + elif key == "loop.detect": + mode = str(value).strip().lower() + if mode not in ("off", "minimal", "moderate", "strict"): + return "Error: loop.detect must be off, minimal, moderate, or strict" + self.mesh_config["loop_detect"] = mode + saved, _ = self.config_manager.save_to_file() + self.config_manager.live_update_daemon(["mesh"]) + return "OK" + elif key == "rxdelay": delay = float(value) if delay < 0: @@ -665,22 +816,336 @@ class MeshCLI: # ==================== Region Commands ==================== def _cmd_region(self, command: str) -> str: - """Handle region commands.""" + """Handle region commands with MeshCore-compatible response shapes.""" parts = command.split() if len(parts) == 1: - return "Error: Region commands not implemented in Python repeater" + return self._region_export_tree() subcommand = parts[1] if subcommand == "load": - return "Error: Region commands not implemented" - elif subcommand == "save": - return "Error: Region commands not implemented" - elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"): - return "Error: Region commands not implemented" - else: - return "Err - ??" + return "Err - region load not supported" + if subcommand == "def": + return "Err - region def not supported" + if subcommand == "save": + return self._region_save() + if subcommand == "allowf" and len(parts) >= 3: + return self._region_set_flood(parts[2], allow=True) + if subcommand == "denyf" and len(parts) >= 3: + return self._region_set_flood(parts[2], allow=False) + if subcommand == "get" and len(parts) >= 3: + return self._region_get(parts[2]) + if subcommand == "home": + if len(parts) >= 3: + return self._region_home_set(parts[2]) + return self._region_home_get() + if subcommand == "default": + if len(parts) >= 3: + return self._region_default_set(parts[2]) + return self._region_default_get() + if subcommand == "put" and len(parts) >= 3: + parent = parts[3] if len(parts) >= 4 else "*" + return self._region_put(parts[2], parent) + if subcommand == "remove" and len(parts) >= 3: + return self._region_remove(parts[2]) + if subcommand == "list" and len(parts) >= 3: + return self._region_list(parts[2]) + + return "Err - ??" + + def _region_storage_available(self) -> bool: + return bool( + self.storage_handler + and hasattr(self.storage_handler, "get_transport_keys") + and callable(getattr(self.storage_handler, "get_transport_keys")) + ) + + def _region_load_records(self) -> list[dict]: + if not self._region_storage_available(): + return [] + records = self.storage_handler.get_transport_keys() + return records if isinstance(records, list) else [] + + @staticmethod + def _region_display_name(raw_name: str) -> str: + name = str(raw_name or "").strip() + if name.startswith("#"): + return name[1:] + return name + + def _region_find_prefix(self, query: str) -> Optional[dict]: + q = str(query or "").strip() + if not q: + return None + if q == "*": + return { + "id": 0, + "name": "*", + "display_name": "*", + "flood_policy": "allow" if self._region_unscoped_allow() else "deny", + "parent_id": None, + } + + q_lower = q.lower() + for rec in self._region_load_records(): + display = self._region_display_name(rec.get("name", "")) + if display.lower().startswith(q_lower): + return {**rec, "display_name": display} + return None + + def _region_find_exact(self, query: str) -> Optional[dict]: + q = str(query or "").strip() + if not q: + return None + if q == "*": + return { + "id": 0, + "name": "*", + "display_name": "*", + "flood_policy": "allow" if self._region_unscoped_allow() else "deny", + "parent_id": None, + } + + q_lower = q.lower() + for rec in self._region_load_records(): + display = self._region_display_name(rec.get("name", "")) + if display.lower() == q_lower: + return {**rec, "display_name": display} + return None + + def _region_unscoped_allow(self) -> bool: + return bool( + self.mesh_config.get( + "unscoped_flood_allow", + self.mesh_config.get("global_flood_allow", True), + ) + ) + + def _region_set_unscoped_allow(self, allow: bool) -> bool: + self.mesh_config["unscoped_flood_allow"] = bool(allow) + self.mesh_config["global_flood_allow"] = bool(allow) + save_result = self.config_manager.save_to_file() + saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result) + self.config_manager.live_update_daemon(["mesh"]) + return bool(saved) + + def _region_get_default_name(self) -> Optional[str]: + default_name = self.mesh_config.get("default_region") + text = str(default_name).strip() if default_name is not None else "" + return text or None + + def _region_set_default_name(self, value: Optional[str]) -> bool: + self.mesh_config["default_region"] = value + save_result = self.config_manager.save_to_file() + saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result) + self.config_manager.live_update_daemon(["mesh"]) + return bool(saved) + + def _region_export_tree(self) -> str: + records = self._region_load_records() + children_by_parent: Dict[int, list[dict]] = {} + for rec in records: + parent_id = rec.get("parent_id") + parent_key = int(parent_id) if isinstance(parent_id, int) and parent_id > 0 else 0 + children_by_parent.setdefault(parent_key, []).append(rec) + + for parent_list in children_by_parent.values(): + parent_list.sort(key=lambda r: str(r.get("name", "")).lower()) + + home_name = str(self.repeater_config.get("region_home") or "").strip().lower() + + lines: list[str] = [] + + def append_line(indent: int, display_name: str, flood_policy: str): + home_mark = "^" if home_name and display_name.lower() == home_name else "" + flood_mark = " F" if flood_policy == "allow" else "" + lines.append(f"{' ' * indent}{display_name}{home_mark}{flood_mark}") + + append_line(0, "*", "allow" if self._region_unscoped_allow() else "deny") + + def walk(parent_id: int, indent: int): + for rec in children_by_parent.get(parent_id, []): + display_name = self._region_display_name(rec.get("name", "")) + append_line(indent, display_name, str(rec.get("flood_policy", "deny"))) + walk(int(rec.get("id", 0)), indent + 1) + + walk(0, 1) + return "\n".join(lines) + + def _region_save(self) -> str: + save_result = self.config_manager.save_to_file() + saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result) + return "OK" if saved else "Err - save failed" + + def _region_set_flood(self, name_prefix: str, allow: bool) -> str: + region = self._region_find_prefix(name_prefix) + if not region: + return "Err - unknown region" + + if region.get("id") == 0: + return "OK" if self._region_set_unscoped_allow(allow) else "Err - save failed" + + update_fn = getattr(self.storage_handler, "update_transport_key", None) + if not callable(update_fn): + return "Error: Region commands not supported by storage backend" + + ok = update_fn(int(region["id"]), flood_policy="allow" if allow else "deny") + return "OK" if ok else "Err - unknown region" + + def _region_get(self, name_prefix: str) -> str: + region = self._region_find_prefix(name_prefix) + if not region: + return "Err - unknown region" + + display_name = str( + region.get("display_name") or self._region_display_name(region.get("name", "")) + ) + flood_suffix = "F" if region.get("flood_policy") == "allow" else "" + + parent_name = None + parent_id = region.get("parent_id") + if isinstance(parent_id, int) and parent_id > 0: + for rec in self._region_load_records(): + if int(rec.get("id", -1)) == parent_id: + parent_name = self._region_display_name(rec.get("name", "")) + break + + if parent_name: + return f" {display_name} ({parent_name}) {flood_suffix}".rstrip() + return f" {display_name} {flood_suffix}".rstrip() + + def _region_home_get(self) -> str: + home = str(self.repeater_config.get("region_home") or "").strip() + return f" home is {home or '*'}" + + def _region_home_set(self, name_prefix: str) -> str: + region = self._region_find_prefix(name_prefix) + if not region: + return "Err - unknown region" + + display_name = str(region.get("display_name") or "*") + self.repeater_config["region_home"] = display_name + save_result = self.config_manager.save_to_file() + saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result) + return f" home is now {display_name}" if saved else "Err - save failed" + + def _region_default_get(self) -> str: + default_region = self._region_get_default_name() + if default_region is None: + return " default scope is " + return f" default scope is {default_region}" + + def _region_default_set(self, value: str) -> str: + text = str(value or "").strip() + if text == "": + saved = self._region_set_default_name(None) + return " default scope is now " if saved else "Err - save failed" + + region = self._region_find_prefix(text) + if region: + display_name = str(region.get("display_name") or text) + if region.get("id") not in (None, 0): + update_fn = getattr(self.storage_handler, "update_transport_key", None) + if callable(update_fn): + update_fn(int(region["id"]), flood_policy="allow") + saved = self._region_set_default_name(display_name) + return f" default scope is now {display_name}" if saved else "Err - save failed" + + put_result = self._region_put(text, "*") + if not put_result.startswith("OK"): + return "Err - region table full" + + saved = self._region_set_default_name(text) + return f" default scope is now {text}" if saved else "Err - save failed" + + def _region_put(self, name: str, parent_name: str) -> str: + region_name = str(name or "").strip() + if not region_name: + return "Err - unable to put" + + parent = self._region_find_prefix(parent_name) + if not parent: + return "Err - unknown parent" + + parent_id = int(parent.get("id", 0)) + parent_storage_id = None if parent_id == 0 else parent_id + + existing = self._region_find_exact(region_name) + if existing and existing.get("id") != 0: + update_fn = getattr(self.storage_handler, "update_transport_key", None) + if not callable(update_fn): + return "Err - unable to put" + ok = update_fn( + int(existing["id"]), + flood_policy="allow", + parent_id=parent_storage_id, + ) + return "OK - (flood allowed)" if ok else "Err - unable to put" + + create_fn = getattr(self.storage_handler, "create_transport_key", None) + if not callable(create_fn): + return "Err - unable to put" + + key_id = create_fn( + region_name, + "allow", + None, + parent_storage_id, + None, + ) + return "OK - (flood allowed)" if key_id else "Err - unable to put" + + def _region_remove(self, name: str) -> str: + region = self._region_find_exact(name) + if not region or region.get("id") == 0: + return "Err - not found" + + region_id = int(region["id"]) + for rec in self._region_load_records(): + if int(rec.get("parent_id") or 0) == region_id: + return "Err - not empty" + + delete_fn = getattr(self.storage_handler, "delete_transport_key", None) + if not callable(delete_fn): + return "Err - not found" + + ok = delete_fn(region_id) + if not ok: + return "Err - not found" + + removed_name = str(region.get("display_name") or "") + if ( + str(self.repeater_config.get("region_home") or "").strip().lower() + == removed_name.lower() + ): + self.repeater_config["region_home"] = "" + default_name = self._region_get_default_name() + if str(default_name or "").strip().lower() == removed_name.lower(): + self.mesh_config["default_region"] = None + return "OK" + + def _region_list(self, filter_name: str) -> str: + mode = str(filter_name or "").strip().lower() + if mode not in ("allowed", "denied"): + return "Err - use 'allowed' or 'denied'" + + names: list[str] = [] + unscoped_allowed = self._region_unscoped_allow() + if (mode == "allowed" and unscoped_allowed) or (mode == "denied" and not unscoped_allowed): + names.append("*") + + records = sorted( + self._region_load_records(), + key=lambda r: self._region_display_name(r.get("name", "")).lower(), + ) + for rec in records: + flood_policy = str(rec.get("flood_policy", "deny")).lower() + allowed = flood_policy == "allow" + if (mode == "allowed" and allowed) or (mode == "denied" and not allowed): + names.append(self._region_display_name(rec.get("name", ""))) + + return ",".join(names) if names else "-none-" # ==================== Neighbor Commands ==================== @@ -695,15 +1160,15 @@ class MeshCLI: if not neighbors: return "No neighbors discovered yet" - # Filter to only show repeaters and zero hop nodes + # Match MeshCore behavior: show only zero-hop repeaters. filtered_neighbors = { pubkey: info for pubkey, info in neighbors.items() - if info.get("is_repeater", False) or info.get("zero_hop", False) + if info.get("is_repeater", False) and info.get("zero_hop", False) } if not filtered_neighbors: - return "No repeaters or zero hop neighbors discovered yet" + return "No zero hop repeaters discovered yet" # Format output similar to C++ version # Format: " heard Xs ago" @@ -731,14 +1196,71 @@ class MeshCLI: def _cmd_neighbor_remove(self, command: str) -> str: """Remove a neighbor.""" - pubkey_hex = command[16:].strip() + raw_suffix = command[16:] + pubkey_hex = raw_suffix.strip() - if not pubkey_hex: + # Keep MeshCore parity: plain empty is invalid, whitespace-only means remove all. + if raw_suffix == "": return "ERR: Missing pubkey" - # TODO: Remove neighbor from routing table - logger.info(f"neighbor.remove: {pubkey_hex}") - return "Error: Not yet implemented" + if not self.storage_handler: + return "Error: Storage not available" + + delete_fn = getattr(self.storage_handler, "delete_neighbors_by_pubkey_prefix", None) + if not callable(delete_fn): + return "Error: neighbor.remove not supported by storage backend" + + try: + if pubkey_hex == "": + delete_fn(None) + return "OK" + + if any(ch not in "0123456789abcdefABCDEF" for ch in pubkey_hex): + return "ERR: bad pubkey" + + delete_fn(pubkey_hex) + return "OK" + except Exception as e: + logger.error(f"neighbor.remove failed: {e}", exc_info=True) + return f"Error: {e}" + + def _cmd_discover_neighbors(self, command: str) -> str: + """Send a discovery request for nearby repeaters.""" + sub = command[18:] + if sub.strip(): + return "Err - discover.neighbors has no options" + + daemon_instance = getattr(self.config_manager, "daemon", None) + discovery_helper = getattr(daemon_instance, "discovery_helper", None) + if not discovery_helper: + return "Error: Discovery helper not available" + + import asyncio + + loop = self._event_loop + if loop is None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is None or not loop.is_running(): + return "Error: Event loop not available" + + try: + discovery_helper.cleanup_sessions() + session = discovery_helper.create_session( + timeout=5, + filter_mask=(1 << 2), + since=0, + prefix_only=False, + result_enricher=self._auto_add_discovery_result, + ) + loop.call_soon_threadsafe(discovery_helper.start_session_task, session["session_id"]) + return "OK - Discover sent" + except Exception as e: + logger.error(f"discover.neighbors failed: {e}", exc_info=True) + return f"Error: {e}" # ==================== Temporary Radio Commands ==================== diff --git a/repeater/handler_helpers/repeater_cli.py b/repeater/handler_helpers/repeater_cli.py deleted file mode 100644 index 96165ff..0000000 --- a/repeater/handler_helpers/repeater_cli.py +++ /dev/null @@ -1,700 +0,0 @@ -""" -Mesh CLI Handler -Handles administrative commands sent to repeaters and room servers via TXT_MSG packets. -Only users with admin permissions (via ACL) can execute these commands. -""" - -import logging -from pathlib import Path -from typing import Any, Callable, Dict - - -logger = logging.getLogger(__name__) - - -class MeshCLI: - """ - CLI command handler for mesh node administration (repeaters and room servers). - Commands follow the format: XX|command params - where XX is an optional sequence number that gets echoed in the reply. - """ - - def __init__( - self, - config_path: str, - config: Dict[str, Any], - save_config_callback: Callable, - identity_type: str = "repeater", - enable_regions: bool = True, - ): - """ - Initialize the CLI handler. - - Args: - config_path: Path to the config.yaml file - config: Current configuration dictionary - save_config_callback: Callback to save config changes - identity_type: Type of identity ('repeater' or 'room_server') - enable_regions: Whether to enable region commands (only for repeaters) - """ - self.config_path = Path(config_path) - self.config = config - self.save_config = save_config_callback - self.identity_type = identity_type - self.enable_regions = enable_regions - - # Get repeater config shortcut - self.repeater_config = config.get("repeater", {}) - - def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str: - """ - Handle an incoming command from a client. - - Args: - sender_pubkey: Public key of sender - command: Command string (may include XX| prefix) - is_admin: Whether sender has admin permissions - - Returns: - Reply string to send back to sender - """ - # Check admin permission first - if not is_admin: - return "Error: Admin permission required" - - logger.debug(f"handle_command received: '{command}' (len={len(command)})") - - # Extract optional sequence prefix (XX|) - prefix = "" - if len(command) > 4 and command[2] == "|": - prefix = command[:3] - command = command[3:] - logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'") - - # Strip leading/trailing whitespace - command = command.strip() - logger.debug(f"After strip: '{command}'") - - # Route to appropriate handler - reply = self._route_command(command) - - # Add prefix back to reply if present - if prefix: - return prefix + reply - return reply - - def _route_command(self, command: str) -> str: - """Route command to appropriate handler method.""" - - # Help - if command == "help" or command.startswith("help "): - return self._cmd_help(command) - - # System commands - elif command == "reboot": - return self._cmd_reboot() - elif command == "advert": - return self._cmd_advert() - elif command.startswith("clock"): - return self._cmd_clock(command) - elif command.startswith("time "): - return self._cmd_time(command) - elif command == "start ota": - return "Error: OTA not supported in Python repeater" - elif command.startswith("password "): - return self._cmd_password(command) - elif command == "clear stats": - return self._cmd_clear_stats() - elif command == "ver": - return self._cmd_version() - - # Get commands - elif command.startswith("get "): - return self._cmd_get(command[4:]) - - # Set commands - elif command.startswith("set "): - return self._cmd_set(command[4:]) - - # ACL commands - elif command.startswith("setperm "): - return self._cmd_setperm(command) - elif command == "get acl": - return "Error: Use 'get acl' via serial console only" - - # Region commands (repeaters only) - elif command.startswith("region"): - if self.enable_regions: - return self._cmd_region(command) - else: - return "Error: Region commands not available for room servers" - - # Neighbor commands - elif command == "neighbors": - return self._cmd_neighbors() - elif command.startswith("neighbor.remove "): - return self._cmd_neighbor_remove(command) - - # Temporary radio params - elif command.startswith("tempradio "): - return self._cmd_tempradio(command) - - # Sensor commands - elif command.startswith("sensor "): - return "Error: Sensor commands not implemented in Python repeater" - - # GPS commands - elif command.startswith("gps"): - return "Error: GPS commands not implemented in Python repeater" - - # Logging commands - elif command.startswith("log "): - return self._cmd_log(command) - - # Statistics commands - elif command.startswith("stats-"): - return "Error: Stats commands not fully implemented yet" - - else: - return "Unknown command" - - # ==================== Help Command ==================== - - def _cmd_help(self, command: str) -> str: - """Show available commands or detailed help for a specific command.""" - parts = command.split(None, 1) - if len(parts) == 2: - return self._help_detail(parts[1]) - - lines = [ - "=== pyMC CLI Commands ===", - "", - "System:", - " reboot Restart the repeater service", - " advert Send self advertisement", - " clock Show current UTC time", - " clock sync Sync clock (no-op, uses system time)", - " ver Show version info", - " password Change admin password", - " clear stats Clear statistics", - "", - "Get:", - " get name Node name", - " get radio Radio params (freq,bw,sf,cr)", - " get freq Frequency (MHz)", - " get tx TX power", - " get af Airtime factor", - " get repeat Repeat mode (on/off)", - " get lat / get lon GPS coordinates", - " get role Identity role", - " get guest.password Guest password", - " get allow.read.only Read-only access setting", - " get advert.interval Advert interval (minutes)", - " get flood.advert.interval Flood advert interval (hours)", - " get flood.max Max flood hops", - " get rxdelay RX delay base", - " get txdelay TX delay factor", - " get direct.txdelay Direct TX delay factor", - " get multi.acks Multi-ack count", - " get int.thresh Interference threshold", - " get agc.reset.interval AGC reset interval", - "", - "Set: (use 'help set' for details)", - " set ", - "", - "Other:", - " neighbors List neighbors", - " neighbor.remove Remove neighbor by pubkey", - " tempradio ", - " setperm Set ACL permissions", - " log start|stop|erase Logging control", - ] - if self.enable_regions: - lines.append(" region ... Region commands") - lines += ["", "Type 'help ' for details on a specific command."] - return "\n".join(lines) - - def _help_detail(self, topic: str) -> str: - """Return detailed help for a specific command topic.""" - topic = topic.strip() - details = { - "set": ( - "Set commands — set :\n" - " set name Set node name\n" - " set radio Set radio (restart required)\n" - " set freq Set frequency (restart required)\n" - " set tx Set TX power\n" - " set af Airtime factor\n" - " set repeat on|off Enable/disable repeating\n" - " set lat Latitude\n" - " set lon Longitude\n" - " set guest.password Guest password\n" - " set allow.read.only on|off Read-only access\n" - " set advert.interval 60-240 minutes\n" - " set flood.advert.interval
3-168 hours\n" - " set flood.max Max flood hops (max 64)\n" - " set rxdelay RX delay base (>=0)\n" - " set txdelay TX delay factor (>=0)\n" - " set direct.txdelay Direct TX delay (>=0)\n" - " set multi.acks Multi-ack count\n" - " set int.thresh Interference threshold\n" - " set agc.reset.interval AGC reset (rounded to x4)" - ), - "get": "Get commands — type 'help' to see all 'get' parameters.", - "reboot": "Restart the repeater service via systemd.", - "advert": "Trigger a self-advertisement flood packet.", - "clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).", - "ver": "Show repeater version and identity type.", - "password": "password — Change the admin password.", - "tempradio": ( - "tempradio \n" - " Apply temporary radio parameters that revert after timeout.\n" - " freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8" - ), - "neighbors": "List known neighbor nodes from the routing table.", - "setperm": "setperm — Set ACL permissions for a node.", - "log": "log start|stop|erase — Control logging.", - } - return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.") - - # ==================== System Commands == - - def _cmd_reboot(self) -> str: - """Reboot the repeater process.""" - from repeater.service_utils import restart_service - - logger.warning("Reboot command received via repeater CLI") - success, message = restart_service() - - if success: - return f"OK - {message}" - else: - return f"Error: {message}" - - def _cmd_advert(self) -> str: - """Send self advertisement.""" - logger.info("Advert command received") - # TODO: Trigger advertisement through packet handler - return "Error: Not yet implemented" - - def _cmd_clock(self, command: str) -> str: - """Handle clock commands.""" - if command == "clock": - # Display current time - import datetime - - dt = datetime.datetime.now(datetime.timezone.utc) - return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" - elif command == "clock sync": - # Clock sync happens automatically via sender_timestamp in protocol - return "OK - clock sync not needed (system time used)" - else: - return "Unknown clock command" - - def _cmd_time(self, command: str) -> str: - """Set time - not supported in Python (use system time).""" - return "Error: Time setting not supported (system time is used)" - - def _cmd_password(self, command: str) -> str: - """Change admin password.""" - new_password = command[9:].strip() - - if not new_password: - return "Error: Password cannot be empty" - - # Update security config - if "security" not in self.config: - self.config["security"] = {} - - self.config["security"]["password"] = new_password - - # Save config - try: - self.save_config() - return f"password now: {new_password}" - except Exception as e: - logger.error(f"Failed to save password: {e}") - return "Error: Failed to save password" - - def _cmd_clear_stats(self) -> str: - """Clear statistics.""" - # TODO: Implement stats clearing - return "Error: Not yet implemented" - - def _cmd_version(self) -> str: - """Get version information.""" - role = "room_server" if self.identity_type == "room_server" else "repeater" - version = self.config.get("version", "1.0.0") - return f"pyMC_{role} v{version}" - - # ==================== Get Commands ==================== - - def _cmd_get(self, param: str) -> str: - """Handle get commands.""" - param = param.strip() - logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})") - - if param == "af": - af = self.repeater_config.get("airtime_factor", 1.0) - return f"> {af}" - - elif param == "name": - name = self.repeater_config.get("name", "Unknown") - return f"> {name}" - - elif param == "repeat": - mode = self.repeater_config.get("mode", "forward") - return f"> {'on' if mode == 'forward' else 'off'}" - - elif param == "lat": - lat = self.repeater_config.get("latitude", 0.0) - return f"> {lat}" - - elif param == "lon": - lon = self.repeater_config.get("longitude", 0.0) - return f"> {lon}" - - elif param == "radio": - radio = self.config.get("radio", {}) - freq_hz = radio.get("frequency", 915000000) - bw_hz = radio.get("bandwidth", 125000) - sf = radio.get("spreading_factor", 7) - cr = radio.get("coding_rate", 5) - # Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output) - freq_mhz = freq_hz / 1_000_000.0 - bw_khz = bw_hz / 1_000.0 - return f"> {freq_mhz},{bw_khz},{sf},{cr}" - - elif param == "freq": - freq_hz = self.config.get("radio", {}).get("frequency", 915000000) - freq_mhz = freq_hz / 1_000_000.0 - return f"> {freq_mhz}" - - elif param == "tx": - power = self.config.get("radio", {}).get("tx_power", 20) - return f"> {power}" - - elif param == "public.key": - # TODO: Get from identity - return "Error: Not yet implemented" - - elif param == "role": - role = "room_server" if self.identity_type == "room_server" else "repeater" - return f"> {role}" - - elif param == "guest.password": - guest_pw = self.config.get("security", {}).get("guest_password", "") - return f"> {guest_pw}" - - elif param == "allow.read.only": - allow = self.config.get("security", {}).get("allow_read_only", False) - return f"> {'on' if allow else 'off'}" - - elif param == "advert.interval": - interval = self.repeater_config.get("advert_interval_minutes", 120) - return f"> {interval}" - - elif param == "flood.advert.interval": - interval = self.repeater_config.get("flood_advert_interval_hours", 24) - return f"> {interval}" - - elif param == "flood.max": - max_flood = self.repeater_config.get("max_flood_hops", 64) - return f"> {max_flood}" - - elif param == "rxdelay": - delay = self.repeater_config.get("rx_delay_base", 0.0) - return f"> {delay}" - - elif param == "txdelay": - delay = self.repeater_config.get("tx_delay_factor", 1.0) - return f"> {delay}" - - elif param == "direct.txdelay": - delay = self.repeater_config.get("direct_tx_delay_factor", 0.5) - return f"> {delay}" - - elif param == "multi.acks": - acks = self.repeater_config.get("multi_acks", 0) - return f"> {acks}" - - elif param == "int.thresh": - thresh = self.repeater_config.get("interference_threshold", -120) - return f"> {thresh}" - - elif param == "agc.reset.interval": - interval = self.repeater_config.get("agc_reset_interval", 0) - return f"> {interval}" - - else: - return f"??: {param}" - - # ==================== Set Commands ==================== - - def _cmd_set(self, param: str) -> str: - """Handle set commands.""" - parts = param.split(None, 1) - if len(parts) < 2: - return "Error: Missing value" - - key, value = parts[0], parts[1] - - try: - if key == "af": - self.repeater_config["airtime_factor"] = float(value) - self.save_config() - return "OK" - - elif key == "name": - self.repeater_config["name"] = value - self.save_config() - return "OK" - - elif key == "repeat": - self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor" - self.save_config() - return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}" - - elif key == "lat": - self.repeater_config["latitude"] = float(value) - self.save_config() - return "OK" - - elif key == "lon": - self.repeater_config["longitude"] = float(value) - self.save_config() - return "OK" - - elif key == "radio": - # Format: freq bw sf cr - radio_parts = value.split() - if len(radio_parts) != 4: - return "Error: Expected freq bw sf cr" - - if "radio" not in self.config: - self.config["radio"] = {} - - self.config["radio"]["frequency"] = float(radio_parts[0]) - self.config["radio"]["bandwidth"] = float(radio_parts[1]) - self.config["radio"]["spreading_factor"] = int(radio_parts[2]) - self.config["radio"]["coding_rate"] = int(radio_parts[3]) - self.save_config() - return "OK - restart repeater to apply" - - elif key == "freq": - if "radio" not in self.config: - self.config["radio"] = {} - self.config["radio"]["frequency"] = float(value) - self.save_config() - return "OK - restart repeater to apply" - - elif key == "tx": - if "radio" not in self.config: - self.config["radio"] = {} - self.config["radio"]["tx_power"] = int(value) - self.save_config() - return "OK" - - elif key == "guest.password": - if "security" not in self.config: - self.config["security"] = {} - self.config["security"]["guest_password"] = value - self.save_config() - return "OK" - - elif key == "allow.read.only": - if "security" not in self.config: - self.config["security"] = {} - self.config["security"]["allow_read_only"] = value.lower() == "on" - self.save_config() - return "OK" - - elif key == "advert.interval": - mins = int(value) - if mins > 0 and (mins < 60 or mins > 240): - return "Error: interval range is 60-240 minutes" - self.repeater_config["advert_interval_minutes"] = mins - self.save_config() - return "OK" - - elif key == "flood.advert.interval": - hours = int(value) - if (hours > 0 and hours < 3) or hours > 168: - return "Error: interval range is 3-168 hours" - self.repeater_config["flood_advert_interval_hours"] = hours - self.save_config() - return "OK" - - elif key == "flood.max": - max_val = int(value) - if max_val > 64: - return "Error: max 64" - self.repeater_config["max_flood_hops"] = max_val - self.save_config() - return "OK" - - elif key == "rxdelay": - delay = float(value) - if delay < 0: - return "Error: cannot be negative" - self.repeater_config["rx_delay_base"] = delay - self.save_config() - return "OK" - - elif key == "txdelay": - delay = float(value) - if delay < 0: - return "Error: cannot be negative" - self.repeater_config["tx_delay_factor"] = delay - self.save_config() - return "OK" - - elif key == "direct.txdelay": - delay = float(value) - if delay < 0: - return "Error: cannot be negative" - self.repeater_config["direct_tx_delay_factor"] = delay - self.save_config() - return "OK" - - elif key == "multi.acks": - self.repeater_config["multi_acks"] = int(value) - self.save_config() - return "OK" - - elif key == "int.thresh": - self.repeater_config["interference_threshold"] = int(value) - self.save_config() - return "OK" - - elif key == "agc.reset.interval": - interval = int(value) - # Round to nearest multiple of 4 - rounded = (interval // 4) * 4 - self.repeater_config["agc_reset_interval"] = rounded - self.save_config() - return f"OK - interval rounded to {rounded}" - - else: - return f"unknown config: {key}" - - except ValueError as e: - return f"Error: invalid value - {e}" - except Exception as e: - logger.error(f"Set command error: {e}") - return f"Error: {e}" - - # ==================== ACL Commands ==================== - - def _cmd_setperm(self, command: str) -> str: - """Set permissions for a public key.""" - # Format: setperm {pubkey-hex} {permissions-int} - parts = command[8:].split() - if len(parts) < 2: - return "Err - bad params" - - pubkey_hex = parts[0] - try: - permissions = int(parts[1]) - except ValueError: - return "Err - invalid permissions" - - # TODO: Apply permissions via ACL - logger.info(f"setperm command: {pubkey_hex} -> {permissions}") - return "Error: Not yet implemented - use config file" - - # ==================== Region Commands ==================== - - def _cmd_region(self, command: str) -> str: - """Handle region commands.""" - parts = command.split() - - if len(parts) == 1: - return "Error: Region commands not implemented in Python repeater" - - subcommand = parts[1] - - if subcommand == "load": - return "Error: Region commands not implemented" - elif subcommand == "save": - return "Error: Region commands not implemented" - elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"): - return "Error: Region commands not implemented" - else: - return "Err - ??" - - # ==================== Neighbor Commands ==================== - - def _cmd_neighbors(self) -> str: - """List neighbors.""" - # TODO: Get neighbors from routing table - return "Error: Not yet implemented" - - def _cmd_neighbor_remove(self, command: str) -> str: - """Remove a neighbor.""" - pubkey_hex = command[16:].strip() - - if not pubkey_hex: - return "ERR: Missing pubkey" - - # TODO: Remove neighbor from routing table - logger.info(f"neighbor.remove: {pubkey_hex}") - return "Error: Not yet implemented" - - # ==================== Temporary Radio Commands ==================== - - def _cmd_tempradio(self, command: str) -> str: - """Apply temporary radio parameters.""" - # Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins} - parts = command[10:].split() - - if len(parts) < 5: - return "Error: Expected freq bw sf cr timeout_mins" - - try: - freq = float(parts[0]) - bw = float(parts[1]) - sf = int(parts[2]) - cr = int(parts[3]) - timeout_mins = int(parts[4]) - - # Validate - if not (300.0 <= freq <= 2500.0): - return "Error: invalid frequency" - if not (7.0 <= bw <= 500.0): - return "Error: invalid bandwidth" - if not (5 <= sf <= 12): - return "Error: invalid spreading factor" - if not (5 <= cr <= 8): - return "Error: invalid coding rate" - if timeout_mins <= 0: - return "Error: invalid timeout" - - # TODO: Apply temporary radio parameters - logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min") - return "Error: Not yet implemented" - - except ValueError: - return "Error, invalid params" - - # ==================== Logging Commands ==================== - - def _cmd_log(self, command: str) -> str: - """Handle log commands.""" - if command == "log start": - # TODO: Enable logging - return "Error: Not yet implemented" - elif command == "log stop": - # TODO: Disable logging - return "Error: Not yet implemented" - elif command == "log erase": - # TODO: Clear log file - return "Error: Not yet implemented" - elif command == "log": - return "Error: Use journalctl to view logs" - else: - return "Unknown log command" - - -# Backward compatibility alias -RepeaterCLI = MeshCLI diff --git a/repeater/handler_helpers/text.py b/repeater/handler_helpers/text.py index a9e2d8b..869192f 100644 --- a/repeater/handler_helpers/text.py +++ b/repeater/handler_helpers/text.py @@ -18,6 +18,7 @@ from .room_server import RoomServer logger = logging.getLogger("TextHelper") + # Text message type flags TXT_TYPE_PLAIN = 0x00 TXT_TYPE_CLI_DATA = 0x01 @@ -464,6 +465,7 @@ class TextHelper: "board", "neighbors", "neighbor.", + "discover.", "tempradio ", "setperm ", "region", diff --git a/repeater/main.py b/repeater/main.py index 19664d0..6ad287a 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -35,6 +35,7 @@ from repeater.handler_helpers import ( from repeater.identity_manager import IdentityManager from repeater.packet_router import PacketRouter from repeater.sensors import SensorManager +from repeater.utils_packet import create_scoped_advert_packet from repeater.web.http_server import HTTPStatsServer, _log_buffer logger = logging.getLogger("RepeaterDaemon") @@ -1222,7 +1223,6 @@ class RepeaterDaemon: return False try: - from openhop_core.protocol import PacketBuilder from openhop_core.protocol.constants import ( ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_REPEATER, @@ -1243,15 +1243,16 @@ class RepeaterDaemon: flags = ADVERT_FLAG_IS_REPEATER | ADVERT_FLAG_HAS_NAME - packet = PacketBuilder.create_advert( + mesh_config = self.config.get("mesh", {}) + default_region = mesh_config.get("default_region") + packet, scoped_region_name = create_scoped_advert_packet( local_identity=self.local_identity, - name=node_name, - lat=latitude, - lon=longitude, - feature1=0, - feature2=0, + node_name=node_name, + latitude=latitude, + longitude=longitude, flags=flags, - route_type="flood", + default_region=default_region, + scope_label="advert", ) # Send via dispatcher @@ -1270,6 +1271,8 @@ class RepeaterDaemon: longitude, location_source, ) + if scoped_region_name: + logger.info("Advert scoped to default region '%s'", scoped_region_name) return True except Exception as e: diff --git a/repeater/utils_packet.py b/repeater/utils_packet.py new file mode 100644 index 0000000..bde3379 --- /dev/null +++ b/repeater/utils_packet.py @@ -0,0 +1,61 @@ +import logging +from typing import Optional, Tuple + +from openhop_core.protocol import PacketBuilder +from openhop_core.protocol.constants import ROUTE_TYPE_TRANSPORT_FLOOD + +logger = logging.getLogger("RepeaterPacketUtils") + + +def create_scoped_advert_packet( + *, + local_identity, + node_name: str, + latitude: float, + longitude: float, + flags: int, + default_region, + scope_label: str, +) -> Tuple[object, Optional[str]]: + """Create a flood advert packet and apply default-region transport scope when configured.""" + packet = PacketBuilder.create_advert( + local_identity=local_identity, + name=node_name, + lat=latitude, + lon=longitude, + feature1=0, + feature2=0, + flags=flags, + route_type="flood", + ) + + scoped_region_name = _apply_default_region_scope( + packet=packet, + default_region=default_region, + scope_label=scope_label, + ) + return packet, scoped_region_name + + +def _apply_default_region_scope(*, packet, default_region, scope_label: str) -> Optional[str]: + """Apply transport-flood scoping for a default region if provided.""" + region_name = str(default_region).strip() if default_region not in (None, "") else "" + if not region_name: + return None + + try: + from openhop_core.protocol.transport_keys import calc_transport_code, get_auto_key_for + + region_key = get_auto_key_for(region_name) + packet.transport_codes[0] = calc_transport_code(region_key, packet) + packet.transport_codes[1] = 0 # reserved for home region + packet.header = (packet.header & ~0x03) | ROUTE_TYPE_TRANSPORT_FLOOD + return region_name + except Exception as scope_err: + logger.warning( + "Failed to apply default region scope '%s' to %s; sending unscoped flood: %s", + region_name, + scope_label, + scope_err, + ) + return None diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 539a206..bbe5a3f 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -28,6 +28,7 @@ from repeater.companion.utils import ( from repeater.config import resolve_storage_dir from repeater.policy_engine import PolicyEngine from repeater.service_utils import get_buildroot_image_info +from repeater.utils_packet import create_scoped_advert_packet from .auth.middleware import require_auth from .auth_endpoints import AuthAPIEndpoints @@ -341,6 +342,8 @@ class APIEndpoints: if not pub_key: return enriched + enriched["is_self"] = self._is_local_discovery_pubkey(pub_key) + try: enriched["node_hash"] = self._fmt_hash(bytes.fromhex(pub_key)) except ValueError: @@ -381,6 +384,52 @@ class APIEndpoints: enriched["advert_count"] = neighbor_info.get("advert_count") return enriched + def _get_local_pubkey_hex(self) -> Optional[str]: + """Return local node public key in hex when available.""" + daemon = getattr(self, "daemon_instance", None) + identity = getattr(daemon, "local_identity", None) + + try: + if identity and hasattr(identity, "get_public_key"): + pubkey = identity.get_public_key() + if isinstance(pubkey, (bytes, bytearray)): + return bytes(pubkey).hex().lower() + if isinstance(pubkey, str): + normalized = pubkey.strip().lower() + if normalized.startswith("0x"): + normalized = normalized[2:] + if normalized: + return normalized + except Exception as exc: + logger.debug("Unable to read local identity pubkey: %s", exc) + + repeater_cfg = self.config.get("repeater", {}) if isinstance(self.config, dict) else {} + key = repeater_cfg.get("identity_key") + if isinstance(key, (bytes, bytearray)): + return bytes(key).hex().lower() + if isinstance(key, str): + normalized = key.strip().lower() + if normalized.startswith("0x"): + normalized = normalized[2:] + if normalized and all(ch in "0123456789abcdef" for ch in normalized): + return normalized + + return None + + def _is_local_discovery_pubkey(self, pub_key: str) -> bool: + """Return True if discovery pub_key matches the local node key (including prefix form).""" + candidate = str(pub_key or "").strip().lower() + if not candidate: + return False + if candidate.startswith("0x"): + candidate = candidate[2:] + + local_pubkey = self._get_local_pubkey_hex() + if not local_pubkey: + return False + + return local_pubkey.startswith(candidate) or candidate.startswith(local_pubkey) + def _process_counter_data(self, data_points, timestamps_ms): rates = [] prev_value = None @@ -3323,6 +3372,7 @@ class APIEndpoints: "direct_tx_delay_factor": 0.5, # Direct TX delay (0.0-5.0) "rx_delay_base": 0.0, # RX delay base (>= 0) "node_name": "MyNode", # Node name + "owner_info": "Owner text", # Owner info text "latitude": 0.0, # Latitude (-90 to 90) "longitude": 0.0, # Longitude (-180 to 180) "max_flood_hops": 64, # Max flood hops (0-64) @@ -3434,6 +3484,11 @@ class APIEndpoints: self.config["repeater"]["node_name"] = name applied.append(f"name={name}") + if "owner_info" in data: + owner_info = str(data["owner_info"]).replace("|", "\n") + self.config["repeater"]["owner_info"] = owner_info + applied.append("owner.info") + # Update latitude if "latitude" in data: lat = float(data["latitude"]) @@ -3950,6 +4005,90 @@ class APIEndpoints: else: return self._error("Method not supported") + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def default_region(self): + """ + Get or update mesh default region configuration. + + GET /default_region + POST /default_region + Body: {"default_region": "region-name" | null} + """ + if cherrypy.request.method == "GET": + try: + mesh_cfg = self.config.get("mesh", {}) if isinstance(self.config, dict) else {} + default_region = mesh_cfg.get("default_region") + value = str(default_region).strip() if default_region not in (None, "") else None + return self._success({"default_region": value}) + except Exception as e: + logger.error(f"Error getting default region: {e}") + return self._error(e) + + if cherrypy.request.method == "POST": + try: + data = cherrypy.request.json or {} + if "default_region" not in data: + return self._error("Missing required field: default_region") + + raw_value = data.get("default_region") + default_region = None + if raw_value is not None: + text = str(raw_value).strip() + if text and text != "": + default_region = text + + if "mesh" not in self.config: + self.config["mesh"] = {} + + # Keep region table compatible with firmware "region default": if non-null + # and missing, auto-create region and ensure flood allow. + if default_region: + storage = self._get_storage() + records = storage.get_transport_keys() or [] + + existing = None + needle = default_region.lower() + for rec in records: + name = str(rec.get("name") or "").strip() + display = name[1:] if name.startswith("#") else name + if display.lower() == needle: + existing = rec + break + + if existing: + key_id = existing.get("id") + if key_id is not None: + storage.update_transport_key(int(key_id), flood_policy="allow") + else: + storage.create_transport_key( + default_region, "allow", None, None, time.time() + ) + + self.config["mesh"]["default_region"] = default_region + + saved = self.config_manager.save_to_file() + if not saved: + return self._error("Failed to save configuration to file") + + if hasattr(self.config_manager, "live_update_daemon"): + self.config_manager.live_update_daemon(["mesh"]) + + return self._success( + {"default_region": default_region}, + message=( + "Default region cleared" + if default_region is None + else f"Default region set to {default_region}" + ), + ) + except Exception as e: + logger.error(f"Error updating default region: {e}") + return self._error(e) + + return self._error("Method not supported") + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() @@ -4297,6 +4436,19 @@ class APIEndpoints: if not re.fullmatch(r"[0-9a-f]{16}|[0-9a-f]{64}", pub_key): return self._error("pub_key must be 8-byte or 32-byte hex") + if self._is_local_discovery_pubkey(pub_key): + enriched = self._enrich_discovery_result( + { + "pub_key": pub_key, + "node_name": node_name, + "node_type": node_type, + "rssi": rssi, + "response_snr": snr, + } + ) + enriched["is_self"] = True + return self._success(enriched, message="Skipped local node: not added to neighbors") + contact_type = { 1: "Chat Node", 2: "Repeater", @@ -5279,7 +5431,6 @@ class APIEndpoints: ): """Send advert for a room server identity""" try: - from openhop_core.protocol import PacketBuilder from openhop_core.protocol.constants import ( ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_ROOM_SERVER, @@ -5292,15 +5443,16 @@ class APIEndpoints: # Build flags - just use HAS_NAME for room servers flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME - packet = PacketBuilder.create_advert( + mesh_config = self.config.get("mesh", {}) if isinstance(self.config, dict) else {} + default_region = mesh_config.get("default_region") + packet, scoped_region_name = create_scoped_advert_packet( local_identity=identity, - name=node_name, - lat=latitude, - lon=longitude, - feature1=0, - feature2=0, + node_name=node_name, + latitude=latitude, + longitude=longitude, flags=flags, - route_type="flood", + default_region=default_region, + scope_label="room server advert", ) # Send via dispatcher @@ -5314,6 +5466,8 @@ class APIEndpoints: logger.info( f"Sent flood advert for room server '{node_name}' at ({latitude:.6f}, {longitude:.6f})" ) + if scoped_region_name: + logger.info("Room server advert scoped to default region '%s'", scoped_region_name) return True except Exception as e: diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 97d0115..b874674 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -1541,6 +1541,61 @@ paths: schema: $ref: '#/components/schemas/SuccessResponse' + /default_region: + get: + tags: [Network Policy] + summary: Get default region + description: Get current mesh default region used for locally-originated flood adverts. + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Current default region + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + default_region: + type: string + nullable: true + message: + type: string + error: + type: string + post: + tags: [Network Policy] + summary: Update default region + description: Set or clear mesh default region. Pass null to clear. + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [default_region] + properties: + default_region: + type: string + nullable: true + description: Region name to set, or null to clear. + responses: + '200': + description: Default region updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + /ping_neighbor: post: tags: [Network Policy] diff --git a/tests/test_api_endpoints_core_coverage.py b/tests/test_api_endpoints_core_coverage.py index ae93f77..ca82074 100644 --- a/tests/test_api_endpoints_core_coverage.py +++ b/tests/test_api_endpoints_core_coverage.py @@ -1,7 +1,8 @@ +import asyncio from concurrent.futures import TimeoutError as FutureTimeoutError from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import cherrypy import pytest @@ -311,6 +312,43 @@ def test_add_discovered_neighbor_normalizes_unknown_name(cherrypy_ctx, monkeypat assert advert_record["node_name"] is None +def test_add_discovered_neighbor_skips_local_node(cherrypy_ctx, monkeypatch): + request, _ = cherrypy_ctx + request.method = "POST" + request.headers = {"Authorization": "Bearer test-token"} + request.path_info = "/api/add_discovered_neighbor" + request.json = { + "pub_key": "aa" * 32, + "node_name": "Self", + "node_type": 2, + } + + jwt_handler = MagicMock() + jwt_handler.verify_jwt.return_value = {"sub": "tester", "client_id": "ui"} + token_manager = MagicMock() + monkeypatch.setattr( + cherrypy, + "config", + {"jwt_handler": jwt_handler, "token_manager": token_manager}, + raising=False, + ) + + storage = MagicMock() + api = _make_api() + api._enrich_discovery_result = APIEndpoints._enrich_discovery_result.__get__(api, APIEndpoints) + _attach_storage(api, storage) + api.daemon_instance.local_identity = SimpleNamespace( + get_public_key=lambda: bytes.fromhex("aa" * 32) + ) + + result = api.add_discovered_neighbor() + + assert result["success"] is True + assert "Skipped local node" in result["message"] + assert result["data"]["is_self"] is True + storage.record_advert.assert_not_called() + + def test_enrich_discovery_result_treats_placeholder_name_as_unknown(): api = _make_api() @@ -1473,6 +1511,41 @@ def test_send_advert_paths(cherrypy_ctx): future_timeout.cancel.assert_called_once() +def test_room_server_advert_applies_default_region_scope(): + from openhop_core.protocol.constants import ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD + + api = _make_api({"mesh": {"default_region": "alpha"}, "repeater": {}}) + dispatcher = SimpleNamespace(send_packet=AsyncMock()) + api.daemon_instance = SimpleNamespace(dispatcher=dispatcher, repeater_handler=None) + + packet = SimpleNamespace( + header=ROUTE_TYPE_FLOOD, + transport_codes=[0, 0], + get_payload_type=lambda: 3, + get_payload=lambda: b"room_server_advert_payload", + ) + + with ( + patch("openhop_core.protocol.PacketBuilder.create_advert", return_value=packet), + patch("openhop_core.protocol.transport_keys.get_auto_key_for", return_value=b"\x01" * 16), + patch("openhop_core.protocol.transport_keys.calc_transport_code", return_value=0xCAFE), + ): + result = asyncio.run( + api._send_room_server_advert_async( + identity=SimpleNamespace(), + node_name="RoomAlpha", + latitude=1.0, + longitude=2.0, + disable_fwd=False, + ) + ) + + assert result is True + assert packet.transport_codes == [0xCAFE, 0] + assert (packet.header & 0x03) == ROUTE_TYPE_TRANSPORT_FLOOD + dispatcher.send_packet.assert_awaited_once_with(packet, wait_for_ack=False) + + def test_set_mode_and_set_duty_cycle_paths(cherrypy_ctx): request, _ = cherrypy_ctx api = _make_api({"repeater": {}, "duty_cycle": {}}) @@ -1757,13 +1830,40 @@ def test_transport_keys_and_transport_key_and_unscoped_policy(cherrypy_ctx): request.method = "GET" assert api.unscoped_flood_policy()["success"] is False + got_default = api.default_region() + assert got_default["success"] is True + assert got_default["data"]["default_region"] is None + request.method = "POST" request.json = {} assert api.unscoped_flood_policy()["success"] is False + request.json = {} + assert api.default_region()["success"] is False + request.json = {"unscoped_flood_allow": "yes"} assert api.unscoped_flood_policy()["success"] is False + request.json = {"default_region": "alpha"} + set_default = api.default_region() + assert set_default["success"] is True + assert api.config["mesh"]["default_region"] == "alpha" + assert any( + call.args and call.args[0] == "alpha" and call.args[1] == "allow" + for call in storage.create_transport_key.call_args_list + ) + + request.method = "GET" + got_default_after = api.default_region() + assert got_default_after["success"] is True + assert got_default_after["data"]["default_region"] == "alpha" + + request.method = "POST" + request.json = {"default_region": None} + clear_default = api.default_region() + assert clear_default["success"] is True + assert api.config["mesh"]["default_region"] is None + api.config_manager.save_to_file.return_value = True request.json = {"unscoped_flood_allow": True} ok = api.unscoped_flood_policy() @@ -1771,6 +1871,31 @@ def test_transport_keys_and_transport_key_and_unscoped_policy(cherrypy_ctx): assert api.config["mesh"]["unscoped_flood_allow"] is True +def test_update_radio_config_owner_info_and_mesh_fields(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + request.json = { + "owner_info": "Alice|Ops", + "path_hash_mode": 2, + "loop_detect": "strict", + } + + api = _make_api({"repeater": {}, "mesh": {}, "radio": {}, "delays": {}}) + api.config_manager.update_and_save.return_value = { + "success": True, + "saved": True, + "live_updated": True, + } + + out = api.update_radio_config() + + assert out["success"] is True + assert api.config["repeater"]["owner_info"] == "Alice\nOps" + assert api.config["mesh"]["path_hash_mode"] == 2 + assert api.config["mesh"]["loop_detect"] == "strict" + assert "owner.info" in out["data"].get("applied", []) + + class _FakeIdentityObj: def __init__(self, first=0x42): self._pk = bytes([first]) + (b"A" * 31) diff --git a/tests/test_companion_state_load.py b/tests/test_companion_state_load.py index e30f5a8..f6f3c06 100644 --- a/tests/test_companion_state_load.py +++ b/tests/test_companion_state_load.py @@ -30,7 +30,9 @@ class TestLoadCompanionRowsVerified: async def test_genuinely_empty_returns_without_retry(self): loader = MagicMock(return_value=[]) counter = MagicMock(return_value=0) - rows, stored = await _load_companion_rows_verified(loader, counter, "channels", _HASH, _NAME) + rows, stored = await _load_companion_rows_verified( + loader, counter, "channels", _HASH, _NAME + ) assert rows == [] assert stored == 0 assert loader.call_count == 1 diff --git a/tests/test_handler_helpers_mesh_cli.py b/tests/test_handler_helpers_mesh_cli.py index e9d454a..48662f5 100644 --- a/tests/test_handler_helpers_mesh_cli.py +++ b/tests/test_handler_helpers_mesh_cli.py @@ -1,7 +1,6 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch - from repeater.handler_helpers.mesh_cli import MeshCLI @@ -11,6 +10,7 @@ def _base_config(): "repeater": { "name": "node-a", "mode": "forward", + "owner_info": "owner-a", "latitude": 1.2, "longitude": 3.4, "airtime_factor": 1.1, @@ -31,6 +31,7 @@ def _base_config(): "coding_rate": 5, "tx_power": 22, }, + "mesh": {"path_hash_mode": 0, "loop_detect": "minimal"}, "security": {"guest_password": "guest", "allow_read_only": True}, } @@ -52,12 +53,13 @@ def test_handle_command_admin_and_prefix_behavior(): def test_help_routing_and_basic_unknown_paths(): cli = MeshCLI("/tmp/cfg.yaml", _base_config(), _cfg_mgr(), enable_regions=False) - assert "pyMC CLI Commands" in cli._route_command("help") + assert "openHop CLI Commands" in cli._route_command("help") assert "No detailed help" in cli._route_command("help nope") assert cli._route_command("start ota").startswith("Error:") assert cli._route_command("sensor read").startswith("Error:") assert cli._route_command("gps on").startswith("Error:") assert cli._route_command("stats-foo").startswith("Error:") + assert cli._route_command("discover.neighbors").startswith("Error:") assert cli._route_command("region load x").startswith("Error: Region commands not available") assert cli._route_command("unknown") == "Unknown command" @@ -129,17 +131,24 @@ def test_cmd_get_public_key_and_neighbor_branches(): storage.get_neighbors = lambda: { "aa": {"is_repeater": False, "zero_hop": False, "last_seen": 1} } - assert "No repeaters or zero hop" in cli._cmd_neighbors() + assert "No zero hop repeaters" in cli._cmd_neighbors() storage.get_neighbors = lambda: { "abcdef12feed": {"is_repeater": True, "zero_hop": False, "last_seen": 10, "snr": 4.9}, "11223344aabb": {"is_repeater": False, "zero_hop": True, "last_seen": 20, "snr": 1.2}, + "99aabbccddeeff00": { + "is_repeater": True, + "zero_hop": True, + "last_seen": 21, + "snr": 6.3, + }, } with patch("time.time", return_value=30): out = cli._cmd_neighbors() - assert "abcdef12:20:4" in out - assert "11223344:10:1" in out + assert "99aabbcc:9:6" in out + assert "abcdef12:20:4" not in out + assert "11223344:10:1" not in out cli.storage_handler = SimpleNamespace( get_neighbors=MagicMock(side_effect=RuntimeError("db fail")) @@ -147,6 +156,14 @@ def test_cmd_get_public_key_and_neighbor_branches(): assert cli._cmd_neighbors().startswith("Error:") +def test_cmd_get_owner_info_path_hash_mode_and_loop_detect(): + cli = MeshCLI("/tmp/cfg.yaml", _base_config(), _cfg_mgr()) + + assert cli._cmd_get("owner.info") == "> owner-a" + assert cli._cmd_get("path.hash.mode") == "> 0" + assert cli._cmd_get("loop.detect") == "> minimal" + + def test_cmd_set_updates_and_validation_errors(): cfg = _base_config() mgr = _cfg_mgr() @@ -167,7 +184,18 @@ def test_cmd_set_updates_and_validation_errors(): assert cli._cmd_set("freq 868000000").startswith("OK") assert cli._cmd_set("tx 17") == "OK" assert cli._cmd_set("guest.password g") == "OK" + assert cli._cmd_set("owner.info Alice|Ops") == "OK" + assert cfg["repeater"]["owner_info"] == "Alice\nOps" assert cli._cmd_set("allow.read.only off") == "OK" + assert cli._cmd_set("path.hash.mode 2") == "OK" + assert cfg["mesh"]["path_hash_mode"] == 2 + assert cli._cmd_set("path.hash.mode 3") == "Error: path.hash.mode must be 0, 1, or 2" + assert cli._cmd_set("loop.detect strict") == "OK" + assert cfg["mesh"]["loop_detect"] == "strict" + assert ( + cli._cmd_set("loop.detect weird") + == "Error: loop.detect must be off, minimal, moderate, or strict" + ) assert cli._cmd_set("advert.interval 59").startswith("Error: interval range") assert cli._cmd_set("flood.advert.interval 2").startswith("Error: interval range") @@ -185,10 +213,12 @@ def test_cmd_set_updates_and_validation_errors(): def test_misc_commands_and_routes(): cli = MeshCLI("/tmp/cfg.yaml", _base_config(), _cfg_mgr(), enable_regions=True) - assert cli._cmd_region("region").startswith("Error:") - assert cli._cmd_region("region load us").startswith("Error:") - assert cli._cmd_region("region save").startswith("Error:") - assert cli._cmd_region("region remove x").startswith("Error:") + cli.storage_handler = SimpleNamespace(get_transport_keys=lambda: []) + + assert cli._cmd_region("region") == "* F" + assert cli._cmd_region("region load us").startswith("Err -") + assert cli._cmd_region("region save") == "OK" + assert cli._cmd_region("region remove x") == "Err - not found" assert cli._cmd_region("region unknown").startswith("Err -") assert cli._cmd_setperm("setperm") == "Err - bad params" @@ -204,11 +234,170 @@ def test_misc_commands_and_routes(): assert cli._cmd_tempradio("tempradio 915 125 7 5 nope") == "Error, invalid params" assert cli._cmd_tempradio("tempradio 915 125 7 5 10").startswith("Error:") - assert cli._cmd_neighbor_remove("neighbor.remove ") == "ERR: Missing pubkey" - assert cli._cmd_neighbor_remove("neighbor.remove abc").startswith("Error:") + assert cli._cmd_neighbor_remove("neighbor.remove") == "ERR: Missing pubkey" + + storage = SimpleNamespace(delete_neighbors_by_pubkey_prefix=MagicMock(return_value=1)) + cli.storage_handler = storage + assert cli._cmd_neighbor_remove("neighbor.remove abc") == "OK" + storage.delete_neighbors_by_pubkey_prefix.assert_called_with("abc") + + assert cli._cmd_neighbor_remove("neighbor.remove ") == "OK" + storage.delete_neighbors_by_pubkey_prefix.assert_called_with(None) + + assert cli._cmd_neighbor_remove("neighbor.remove zz") == "ERR: bad pubkey" + + no_delete = SimpleNamespace() + cli.storage_handler = no_delete + assert "not supported" in cli._cmd_neighbor_remove("neighbor.remove ab") + + cli.storage_handler = None + assert cli._cmd_neighbor_remove("neighbor.remove ab") == "Error: Storage not available" + + discovery = SimpleNamespace( + cleanup_sessions=MagicMock(), + create_session=MagicMock(return_value={"session_id": "s1"}), + start_session_task=MagicMock(), + ) + loop = SimpleNamespace(is_running=lambda: True, call_soon_threadsafe=MagicMock()) + cli._event_loop = loop + cli.config_manager.daemon = SimpleNamespace(discovery_helper=discovery) + + assert cli._cmd_discover_neighbors("discover.neighbors") == "OK - Discover sent" + discovery.cleanup_sessions.assert_called_once() + assert callable(discovery.create_session.call_args.kwargs["result_enricher"]) + loop.call_soon_threadsafe.assert_called_once_with(discovery.start_session_task, "s1") + + assert cli._cmd_discover_neighbors("discover.neighbors foo").startswith("Err -") + + cli.config_manager.daemon = SimpleNamespace(discovery_helper=None) + assert cli._cmd_discover_neighbors("discover.neighbors").startswith("Error: Discovery helper") assert cli._cmd_log("log start").startswith("Error:") assert cli._cmd_log("log stop").startswith("Error:") assert cli._cmd_log("log erase").startswith("Error:") assert cli._cmd_log("log") == "Error: Use journalctl to view logs" assert cli._cmd_log("log whatever") == "Unknown log command" + + +def test_region_command_response_shapes_and_ops(): + keys = [ + { + "id": 1, + "name": "alpha", + "flood_policy": "allow", + "parent_id": None, + }, + { + "id": 2, + "name": "#beta", + "flood_policy": "deny", + "parent_id": 1, + }, + ] + + created = [] + updated = [] + deleted = [] + + def get_transport_keys(): + return list(keys) + + def update_transport_key(key_id, **kwargs): + updated.append((key_id, kwargs)) + for rec in keys: + if rec["id"] == key_id: + rec.update(kwargs) + return True + return False + + def create_transport_key(name, flood_policy, transport_key, parent_id, last_used): + new_id = max([r["id"] for r in keys] + [0]) + 1 + keys.append( + { + "id": new_id, + "name": name, + "flood_policy": flood_policy, + "parent_id": parent_id, + } + ) + created.append((name, flood_policy, parent_id)) + return new_id + + def delete_transport_key(key_id): + for idx, rec in enumerate(keys): + if rec["id"] == key_id: + deleted.append(key_id) + del keys[idx] + return True + return False + + cfg = _base_config() + cfg["mesh"] = {"unscoped_flood_allow": True, "default_region": None} + cli = MeshCLI("/tmp/cfg.yaml", cfg, _cfg_mgr(), enable_regions=True) + cli.storage_handler = SimpleNamespace( + get_transport_keys=get_transport_keys, + update_transport_key=update_transport_key, + create_transport_key=create_transport_key, + delete_transport_key=delete_transport_key, + ) + + tree = cli._cmd_region("region") + assert "* F" in tree + assert " alpha F" in tree + assert " beta" in tree + + assert cli._cmd_region("region get alpha") == " alpha F" + assert cli._cmd_region("region get beta") == " beta (alpha)" + assert cli._cmd_region("region get missing") == "Err - unknown region" + + assert cli._cmd_region("region allowf beta") == "OK" + assert keys[1]["flood_policy"] == "allow" + assert cli._cmd_region("region denyf beta") == "OK" + assert keys[1]["flood_policy"] == "deny" + + assert cli._cmd_region("region home alpha") == " home is now alpha" + assert cli._cmd_region("region home") == " home is alpha" + + assert cli._cmd_region("region default") == " default scope is " + assert cli._cmd_region("region default alpha") == " default scope is now alpha" + assert cfg["mesh"]["default_region"] == "alpha" + assert cli._cmd_region("region default") == " default scope is alpha" + assert cli._cmd_region("region default ") == " default scope is now " + assert cfg["mesh"]["default_region"] is None + + assert cli._cmd_region("region put gamma alpha") == "OK - (flood allowed)" + assert any(r[0] == "gamma" for r in created) + + assert cli._cmd_region("region remove alpha") == "Err - not empty" + assert cli._cmd_region("region remove gamma") == "OK" + assert deleted + + assert cli._cmd_region("region list denied") == "beta" + listed = cli._cmd_region("region list allowed") + assert listed.startswith("*,") + assert "alpha" in listed + assert cli._cmd_region("region list nope") == "Err - use 'allowed' or 'denied'" + + +def test_discovery_auto_add_skips_local_node_and_persists_remote(): + identity = SimpleNamespace(get_public_key=lambda: bytes.fromhex("11" * 32)) + storage = SimpleNamespace(record_advert=MagicMock()) + cli = MeshCLI( + "/tmp/cfg.yaml", _base_config(), _cfg_mgr(), identity=identity, storage_handler=storage + ) + + self_result = cli._auto_add_discovery_result({"pub_key": "11" * 8, "node_type": 2}) + assert self_result["is_self"] is True + storage.record_advert.assert_not_called() + + remote_result = cli._auto_add_discovery_result( + { + "pub_key": "22" * 32, + "node_name": "Remote Repeater", + "node_type": 2, + "rssi": -70, + "response_snr": 4.25, + } + ) + assert remote_result["auto_added"] is True + storage.record_advert.assert_called_once() diff --git a/tests/test_handler_helpers_path_protocol_text.py b/tests/test_handler_helpers_path_protocol_text.py index 054ec8b..e70a0c5 100644 --- a/tests/test_handler_helpers_path_protocol_text.py +++ b/tests/test_handler_helpers_path_protocol_text.py @@ -312,6 +312,7 @@ def test_text_helper_cli_prefix_and_admin_permission_checks(): assert helper._is_cli_command("get status") is True assert helper._is_cli_command("99|get status") is True + assert helper._is_cli_command("04|discover.neighbors") is True assert helper._is_cli_command("hello world") is False assert helper._check_admin_permission_for_identity(0x21, 0x41) is True diff --git a/tests/test_handler_helpers_room_server.py b/tests/test_handler_helpers_room_server.py index 1b55433..8125034 100644 --- a/tests/test_handler_helpers_room_server.py +++ b/tests/test_handler_helpers_room_server.py @@ -6,9 +6,9 @@ import pytest from repeater.handler_helpers.room_server import ( MAX_UNSYNCED_POSTS, - RoomServer, TXT_TYPE_PLAIN, TXT_TYPE_SIGNED_PLAIN, + RoomServer, ) @@ -208,7 +208,9 @@ async def test_room_server_push_expected_ack_matches_firmware_signed_ack(): assert ok is False assert len(sent) == 1 - upserts = [c.kwargs for c in db.upsert_client_sync.call_args_list if "pending_ack_crc" in c.kwargs] + upserts = [ + c.kwargs for c in db.upsert_client_sync.call_args_list if "pending_ack_crc" in c.kwargs + ] assert len(upserts) == 1 expected_ack_crc = upserts[0]["pending_ack_crc"] diff --git a/tests/test_identity_manager.py b/tests/test_identity_manager.py new file mode 100644 index 0000000..32ad340 --- /dev/null +++ b/tests/test_identity_manager.py @@ -0,0 +1,58 @@ +from repeater.identity_manager import IdentityManager + + +class _FakeIdentity: + def __init__(self, pubkey: bytes, addr: bytes = b"\xaa\xbb"): + self._pubkey = pubkey + self._addr = addr + + def get_public_key(self): + return self._pubkey + + def get_address_bytes(self): + return self._addr + + +def test_identity_manager_register_lookup_and_collision_paths(): + mgr = IdentityManager(config={}) + id_a = _FakeIdentity(bytes([0x11]) + b"A" * 31, addr=b"\x01\x02") + id_b_collision = _FakeIdentity(bytes([0x11]) + b"B" * 31, addr=b"\x03\x04") + + assert mgr.register_identity("alpha", id_a, {"k": 1}, "repeater") is True + assert mgr.has_identity(0x11) is True + assert mgr.get_identity_by_hash(0x11)[0] is id_a + assert mgr.get_identity_by_name("alpha")[0] is id_a + + assert mgr.register_identity("beta", id_b_collision, {"k": 2}, "room_server") is False + + +def test_identity_manager_list_and_type_filtering(): + mgr = IdentityManager(config={}) + id_a = _FakeIdentity(bytes([0x22]) + b"A" * 31) + id_b = _FakeIdentity(bytes([0x33]) + b"B" * 31) + + mgr.register_identity("rep-main", id_a, {"x": 1}, "repeater") + mgr.register_identity("room-a", id_b, {"y": 2}, "room_server") + + listed = mgr.list_identities() + assert len(listed) == 2 + assert any(item["hash"] == "0x22" and item["name"] == "repeater:rep-main" for item in listed) + assert any(item["hash"] == "0x33" and item["type"] == "room_server" for item in listed) + + assert mgr.has_identity_type("repeater") is True + assert mgr.has_identity_type("room_server") is True + assert mgr.has_identity_type("unknown") is False + + by_type = mgr.get_identities_by_type("room_server") + assert len(by_type) == 1 + assert by_type[0][0] == "room-a" + + +def test_identity_manager_list_handles_none_identity_fields(): + mgr = IdentityManager(config={}) + mgr.identities[0x44] = (None, {}, "repeater") + mgr.registered_hashes[0x44] = "repeater:ghost" + + listed = mgr.list_identities() + assert listed[0]["address"] == "N/A" + assert listed[0]["public_key"] is None diff --git a/tests/test_identity_manager_and_repeater_cli.py b/tests/test_identity_manager_and_repeater_cli.py deleted file mode 100644 index f1109cf..0000000 --- a/tests/test_identity_manager_and_repeater_cli.py +++ /dev/null @@ -1,267 +0,0 @@ -from unittest.mock import MagicMock, patch - - -from repeater.handler_helpers.repeater_cli import MeshCLI, RepeaterCLI -from repeater.identity_manager import IdentityManager - - -class _FakeIdentity: - def __init__(self, pubkey: bytes, addr: bytes = b"\xaa\xbb"): - self._pubkey = pubkey - self._addr = addr - - def get_public_key(self): - return self._pubkey - - def get_address_bytes(self): - return self._addr - - -def _base_config(): - return { - "version": "9.9.9", - "repeater": { - "name": "node-1", - "mode": "forward", - "latitude": 12.3, - "longitude": 45.6, - "airtime_factor": 1.1, - "advert_interval_minutes": 120, - "flood_advert_interval_hours": 24, - "max_flood_hops": 32, - "rx_delay_base": 0.4, - "tx_delay_factor": 1.2, - "direct_tx_delay_factor": 0.7, - "multi_acks": 2, - "interference_threshold": -111, - "agc_reset_interval": 8, - }, - "radio": { - "frequency": 915000000, - "bandwidth": 125000, - "spreading_factor": 7, - "coding_rate": 5, - "tx_power": 22, - }, - "security": {"guest_password": "guest", "allow_read_only": True}, - } - - -def test_identity_manager_register_lookup_and_collision_paths(): - mgr = IdentityManager(config={}) - id_a = _FakeIdentity(bytes([0x11]) + b"A" * 31, addr=b"\x01\x02") - id_b_collision = _FakeIdentity(bytes([0x11]) + b"B" * 31, addr=b"\x03\x04") - - assert mgr.register_identity("alpha", id_a, {"k": 1}, "repeater") is True - assert mgr.has_identity(0x11) is True - assert mgr.get_identity_by_hash(0x11)[0] is id_a - assert mgr.get_identity_by_name("alpha")[0] is id_a - - # Collision on first pubkey byte should be rejected. - assert mgr.register_identity("beta", id_b_collision, {"k": 2}, "room_server") is False - - -def test_identity_manager_list_and_type_filtering(): - mgr = IdentityManager(config={}) - id_a = _FakeIdentity(bytes([0x22]) + b"A" * 31) - id_b = _FakeIdentity(bytes([0x33]) + b"B" * 31) - - mgr.register_identity("rep-main", id_a, {"x": 1}, "repeater") - mgr.register_identity("room-a", id_b, {"y": 2}, "room_server") - - listed = mgr.list_identities() - assert len(listed) == 2 - assert any(item["hash"] == "0x22" and item["name"] == "repeater:rep-main" for item in listed) - assert any(item["hash"] == "0x33" and item["type"] == "room_server" for item in listed) - - assert mgr.has_identity_type("repeater") is True - assert mgr.has_identity_type("room_server") is True - assert mgr.has_identity_type("unknown") is False - - by_type = mgr.get_identities_by_type("room_server") - assert len(by_type) == 1 - assert by_type[0][0] == "room-a" - - -def test_identity_manager_list_handles_none_identity_fields(): - mgr = IdentityManager(config={}) - mgr.identities[0x44] = (None, {}, "repeater") - mgr.registered_hashes[0x44] = "repeater:ghost" - - listed = mgr.list_identities() - assert listed[0]["address"] == "N/A" - assert listed[0]["public_key"] is None - - -def test_repeater_cli_alias_points_to_mesh_cli(): - assert RepeaterCLI is MeshCLI - - -def test_cli_non_admin_and_prefix_passthrough(): - cfg = _base_config() - save = MagicMock() - cli = MeshCLI("/tmp/config.yaml", cfg, save) - - assert cli.handle_command(b"x", "help", is_admin=False) == "Error: Admin permission required" - assert cli.handle_command(b"x", "01|help set", is_admin=True).startswith("01|") - - -def test_cli_help_and_route_unknown_commands(): - cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock()) - - help_text = cli._route_command("help") - assert "pyMC CLI Commands" in help_text - - assert "No detailed help" in cli._route_command("help not-a-topic") - assert cli._route_command("start ota").startswith("Error:") - assert cli._route_command("gps now").startswith("Error:") - assert cli._route_command("stats-air").startswith("Error:") - assert cli._route_command("totally-unknown") == "Unknown command" - - -def test_cli_reboot_uses_service_utils_result(): - cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock()) - - with patch("repeater.service_utils.restart_service", return_value=(True, "restarted")): - assert cli._cmd_reboot() == "OK - restarted" - - with patch("repeater.service_utils.restart_service", return_value=(False, "denied")): - assert cli._cmd_reboot() == "Error: denied" - - -def test_cli_clock_time_password_and_version_commands(): - cfg = _base_config() - save = MagicMock() - cli = MeshCLI("/tmp/config.yaml", cfg, save, identity_type="room_server") - - assert "UTC" in cli._cmd_clock("clock") - assert "not needed" in cli._cmd_clock("clock sync") - assert cli._cmd_clock("clock bad") == "Unknown clock command" - assert cli._cmd_time("time 1 2").startswith("Error:") - - assert cli._cmd_password("password ") == "Error: Password cannot be empty" - assert cli._cmd_password("password newpass") == "password now: newpass" - assert cfg["security"]["password"] == "newpass" - save.assert_called() - - assert cli._cmd_version() == "pyMC_room_server v9.9.9" - - -def test_cli_get_commands_cover_expected_fields(): - cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock()) - - assert cli._cmd_get("af") == "> 1.1" - assert cli._cmd_get("name") == "> node-1" - assert cli._cmd_get("repeat") == "> on" - assert cli._cmd_get("lat") == "> 12.3" - assert cli._cmd_get("lon") == "> 45.6" - assert cli._cmd_get("radio") == "> 915.0,125.0,7,5" - assert cli._cmd_get("freq") == "> 915.0" - assert cli._cmd_get("tx") == "> 22" - assert cli._cmd_get("role") == "> repeater" - assert cli._cmd_get("guest.password") == "> guest" - assert cli._cmd_get("allow.read.only") == "> on" - assert cli._cmd_get("advert.interval") == "> 120" - assert cli._cmd_get("flood.advert.interval") == "> 24" - assert cli._cmd_get("flood.max") == "> 32" - assert cli._cmd_get("rxdelay") == "> 0.4" - assert cli._cmd_get("txdelay") == "> 1.2" - assert cli._cmd_get("direct.txdelay") == "> 0.7" - assert cli._cmd_get("multi.acks") == "> 2" - assert cli._cmd_get("int.thresh") == "> -111" - assert cli._cmd_get("agc.reset.interval") == "> 8" - assert cli._cmd_get("public.key").startswith("Error:") - assert cli._cmd_get("missing") == "??: missing" - - -def test_cli_set_commands_apply_and_validate_ranges(): - cfg = _base_config() - save = MagicMock() - cli = MeshCLI("/tmp/config.yaml", cfg, save) - - assert cli._cmd_set("af 2.5") == "OK" - assert cfg["repeater"]["airtime_factor"] == 2.5 - - assert cli._cmd_set("name repeater-z") == "OK" - assert cfg["repeater"]["name"] == "repeater-z" - - assert cli._cmd_set("repeat off").endswith("OFF") - assert cfg["repeater"]["mode"] == "monitor" - - assert cli._cmd_set("lat 1.25") == "OK" - assert cli._cmd_set("lon 2.5") == "OK" - - assert cli._cmd_set("radio 900000000 250000 9 6").startswith("OK") - assert cfg["radio"]["frequency"] == 900000000.0 - - assert cli._cmd_set("freq 868000000").startswith("OK") - assert cli._cmd_set("tx 17") == "OK" - assert cli._cmd_set("guest.password gpw") == "OK" - assert cli._cmd_set("allow.read.only off") == "OK" - - assert cli._cmd_set("advert.interval 59").startswith("Error: interval range") - assert cli._cmd_set("advert.interval 60") == "OK" - - assert cli._cmd_set("flood.advert.interval 2").startswith("Error: interval range") - assert cli._cmd_set("flood.advert.interval 168") == "OK" - - assert cli._cmd_set("flood.max 65") == "Error: max 64" - assert cli._cmd_set("flood.max 64") == "OK" - - assert cli._cmd_set("rxdelay -1") == "Error: cannot be negative" - assert cli._cmd_set("txdelay -1") == "Error: cannot be negative" - assert cli._cmd_set("direct.txdelay -1") == "Error: cannot be negative" - - assert cli._cmd_set("multi.acks 5") == "OK" - assert cli._cmd_set("int.thresh -120") == "OK" - assert cli._cmd_set("agc.reset.interval 10") == "OK - interval rounded to 8" - - -def test_cli_set_command_error_paths(): - cfg = _base_config() - save = MagicMock() - cli = MeshCLI("/tmp/config.yaml", cfg, save) - - assert cli._cmd_set("af") == "Error: Missing value" - assert cli._cmd_set("radio 1 2 3") == "Error: Expected freq bw sf cr" - assert cli._cmd_set("unknown.key 1") == "unknown config: unknown.key" - assert cli._cmd_set("tx not-int").startswith("Error: invalid value") - - cli.save_config = MagicMock(side_effect=RuntimeError("disk full")) - assert cli._cmd_set("name x").startswith("Error:") - - -def test_cli_setperm_region_neighbor_tempradio_log_paths(): - cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock(), enable_regions=False) - - assert cli._cmd_setperm("setperm") == "Err - bad params" - assert cli._cmd_setperm("setperm deadbeef zz") == "Err - invalid permissions" - assert cli._cmd_setperm("setperm deadbeef 2").startswith("Error:") - - assert "not available" in cli._route_command("region load us") - - cli_regions = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock(), enable_regions=True) - assert cli_regions._cmd_region("region").startswith("Error:") - assert cli_regions._cmd_region("region load x").startswith("Error:") - assert cli_regions._cmd_region("region save").startswith("Error:") - assert cli_regions._cmd_region("region allowf").startswith("Error:") - assert cli_regions._cmd_region("region what").startswith("Err -") - - assert cli._cmd_neighbors().startswith("Error:") - assert cli._cmd_neighbor_remove("neighbor.remove ") == "ERR: Missing pubkey" - assert cli._cmd_neighbor_remove("neighbor.remove 001122").startswith("Error:") - - assert cli._cmd_tempradio("tempradio 1 2 3").startswith("Error:") - assert cli._cmd_tempradio("tempradio 299 125 7 5 10") == "Error: invalid frequency" - assert cli._cmd_tempradio("tempradio 915 6 7 5 10") == "Error: invalid bandwidth" - assert cli._cmd_tempradio("tempradio 915 125 4 5 10") == "Error: invalid spreading factor" - assert cli._cmd_tempradio("tempradio 915 125 7 9 10") == "Error: invalid coding rate" - assert cli._cmd_tempradio("tempradio 915 125 7 5 0") == "Error: invalid timeout" - assert cli._cmd_tempradio("tempradio 915 125 7 5 x") == "Error, invalid params" - assert cli._cmd_tempradio("tempradio 915 125 7 5 10").startswith("Error:") - - assert cli._cmd_log("log start").startswith("Error:") - assert cli._cmd_log("log stop").startswith("Error:") - assert cli._cmd_log("log erase").startswith("Error:") - assert cli._cmd_log("log") == "Error: Use journalctl to view logs" - assert cli._cmd_log("log weird") == "Unknown log command" diff --git a/tests/test_main_py_coverage.py b/tests/test_main_py_coverage.py index d049ec3..faf3606 100644 --- a/tests/test_main_py_coverage.py +++ b/tests/test_main_py_coverage.py @@ -264,6 +264,39 @@ async def test_send_advert_branches_and_success_path(): daemon.dispatcher.packet_filter.track_packet.assert_called_once() +@pytest.mark.asyncio +async def test_send_advert_applies_transport_scope_when_default_region_set(): + from openhop_core.protocol.constants import ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD + + daemon = RepeaterDaemon(_base_config(), radio=object()) + daemon.dispatcher = SimpleNamespace( + send_packet=AsyncMock(), packet_filter=SimpleNamespace(track_packet=MagicMock()) + ) + daemon.local_identity = _FakeIdentity(b"\x22" + b"y" * 31) + daemon.repeater_handler = SimpleNamespace(mark_seen=MagicMock()) + daemon.config["mesh"] = {"default_region": "alpha"} + + packet = SimpleNamespace( + header=ROUTE_TYPE_FLOOD, + transport_codes=[0, 0], + get_payload_type=lambda: 3, + get_payload=lambda: b"minimal_advert_payload", + calculate_packet_hash=lambda: b"\xcd" * 16, + ) + + with ( + patch("openhop_core.protocol.PacketBuilder.create_advert", return_value=packet), + patch("openhop_core.protocol.transport_keys.get_auto_key_for", return_value=b"\x01" * 16), + patch("openhop_core.protocol.transport_keys.calc_transport_code", return_value=0xBEEF), + ): + ok = await daemon.send_advert() + + assert ok is True + assert packet.transport_codes == [0xBEEF, 0] + assert (packet.header & 0x03) == ROUTE_TYPE_TRANSPORT_FLOOD + daemon.dispatcher.send_packet.assert_awaited_once_with(packet, wait_for_ack=False) + + def test_update_repeater_location_from_gps_branches(): daemon = RepeaterDaemon(_base_config(), radio=object())