"""Rebuild the phonebox number directory from serf membership. Usage: phonebox-sync Runs as a serf event handler. Every alive member carries its number record as tags (number, key, sig, owner). A record is accepted only when the yggdrasil address it is gossiped from derives from `key` and `sig` is a valid ed25519 signature of "phonebox:" under that key, so a number can only ever be bound to the address of the node that signed it. Output, consumed by the asterisk dialplan: /numbers/ the yggdrasil address, no trailing newline /contacts.txt " : " per line, for the fax """ import base64 import ipaddress import json import os import subprocess import sys import syslog import tempfile from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey def yggdrasil_address(public_key: bytes) -> ipaddress.IPv6Address: """Port of yggdrasil-go address.AddrForKey. Invert the key; the address is 0x02, the count of leading one bits, then the bits following the first zero, truncated to 128 bits. """ bits = "".join(f"{b ^ 0xFF:08b}" for b in public_key) ones = len(bits) - len(bits.lstrip("1")) body = int(bits[ones + 1:ones + 113], 2).to_bytes(14, "big") return ipaddress.IPv6Address(bytes([0x02, ones]) + body) def verified_record(member: dict) -> tuple[str, str, str, str]: """Return (number, key hex, address, owner) or raise ValueError.""" tags = member.get("tags", {}) try: number, key_hex, sig = tags["number"], tags["key"], tags["sig"] key = bytes.fromhex(key_hex) host, _ = member["addr"].rsplit(":", 1) address = ipaddress.IPv6Address(host.strip("[]")) signature = base64.b64decode(sig, validate=True) except (KeyError, ValueError) as e: raise ValueError(f"malformed record: {e}") if not (len(number) == 6 and number.isdigit() and number[0] != "0"): raise ValueError(f"invalid number {number!r}") if yggdrasil_address(key) != address: raise ValueError(f"key does not derive to address {address}") try: Ed25519PublicKey.from_public_bytes(key).verify(signature, b"phonebox:" + number.encode()) except (ValueError, InvalidSignature): raise ValueError(f"bad signature for number {number}") return number, key_hex, str(address), tags.get("owner", "") def directory(members: list[dict]) -> dict[str, tuple[str, str, str]]: """number -> (key hex, address, owner); on a collision the lowest key wins.""" book = {} for member in members: try: number, key, address, owner = verified_record(member) except ValueError as e: syslog.syslog(syslog.LOG_WARNING, f"ignoring {member.get('name')}: {e}") continue if number in book: syslog.syslog(syslog.LOG_WARNING, f"number {number} claimed by keys {book[number][0]} and {key}") if book[number][0] <= key: continue book[number] = (key, address, owner) return book def write_atomic(path: str, content: str) -> None: fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path)) with os.fdopen(fd, "w") as f: f.write(content) os.chmod(tmp, 0o644) os.replace(tmp, path) def main() -> None: rpc_addr, state_dir = sys.argv[1:] syslog.openlog("phonebox-sync") out = subprocess.run( ["serf", "members", "-format=json", "-status=alive", f"-rpc-addr={rpc_addr}"], check=True, capture_output=True, text=True, ).stdout book = directory(json.loads(out)["members"]) numbers_dir = os.path.join(state_dir, "numbers") os.makedirs(numbers_dir, exist_ok=True) for number, (_, address, _) in book.items(): write_atomic(os.path.join(numbers_dir, number), address) for stale in set(os.listdir(numbers_dir)) - book.keys(): os.unlink(os.path.join(numbers_dir, stale)) write_atomic( os.path.join(state_dir, "contacts.txt"), "".join(f"{number}\t\t: \t\t{book[number][2]}\n" for number in sorted(book)), ) syslog.syslog(syslog.LOG_INFO, f"directory has {len(book)} numbers") if __name__ == "__main__": main()