From e182eeb45ce959da67f4e8b1aa9c5870f9674151 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 17:58:13 +0300 Subject: [PATCH 01/36] feat: add IPv6 support to ipwords with auto-detect by name length --- src/fognode/utils/ipwords.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/fognode/utils/ipwords.py b/src/fognode/utils/ipwords.py index e9be562..c589010 100644 --- a/src/fognode/utils/ipwords.py +++ b/src/fognode/utils/ipwords.py @@ -5,13 +5,16 @@ _ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz" _BASE = len(_ALPHABET) +_IPV4_LEN = 6 +_IPV6_LEN = 23 -def _encode(n: int) -> str: + +def _encode(n: int, width: int) -> str: out: list[str] = [] while n: n, rem = divmod(n, _BASE) out.append(_ALPHABET[rem]) - return "".join(reversed(out)) + return "".join(reversed(out)).rjust(width, _ALPHABET[0]) def _decode(s: str) -> int: @@ -21,17 +24,27 @@ def _decode(s: str) -> int: return n +def _validate(name: str, expected_len: int) -> None: + if len(name) != expected_len: + raise ValueError(f"Bad code name: {name!r}") + for ch in name: + if ch not in _ALPHABET: + raise ValueError(f"Bad code name: {name!r}") + + def ip_to_name(ip: str) -> str: - addr = int(ipaddress.IPv4Address(ip)) - enc = _encode(addr) - return enc.rjust(6, _ALPHABET[0]) + try: + return _encode(int(ipaddress.IPv4Address(ip)), _IPV4_LEN) + except ipaddress.AddressValueError: + return _encode(int(ipaddress.IPv6Address(ip)), _IPV6_LEN) def name_to_ip(name: str) -> str: - if len(name) != 6: + if len(name) == _IPV4_LEN: + _validate(name, _IPV4_LEN) + return str(ipaddress.IPv4Address(_decode(name))) + elif len(name) == _IPV6_LEN: + _validate(name, _IPV6_LEN) + return str(ipaddress.IPv6Address(_decode(name))) + else: raise ValueError(f"Bad code name: {name!r}") - for ch in name: - if ch not in _ALPHABET: - raise ValueError(f"Bad code name: {name!r}") - addr = _decode(name) - return str(ipaddress.IPv4Address(addr)) From 29d13ce96a0ee81915f6c23523abbcd60eac563f Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 17:58:16 +0300 Subject: [PATCH 02/36] test: add IPv6 roundtrip, boundary, and auto-detect tests --- tests/test_ipwords.py | 44 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/tests/test_ipwords.py b/tests/test_ipwords.py index bc10bb9..9e1cab8 100644 --- a/tests/test_ipwords.py +++ b/tests/test_ipwords.py @@ -2,20 +2,54 @@ import pytest -from fognode.utils.ipwords import ip_to_name, name_to_ip +from fognode.utils.ipwords import _IPV4_LEN, _IPV6_LEN, ip_to_name, name_to_ip class TestIpWords: - def test_roundtrip(self) -> None: + def test_ipv4_roundtrip(self) -> None: ip = "192.168.1.1" name = ip_to_name(ip) - assert len(name) == 6 + assert len(name) == _IPV4_LEN assert name_to_ip(name) == ip + def test_ipv4_boundaries(self) -> None: + for ip in ("0.0.0.0", "255.255.255.255", "10.0.0.1", "172.16.0.1"): + assert name_to_ip(ip_to_name(ip)) == ip + + def test_ipv6_roundtrip(self) -> None: + ip = "2001:db8::1" + name = ip_to_name(ip) + assert len(name) == _IPV6_LEN + assert name_to_ip(name) == "2001:db8::1" + + def test_ipv6_boundaries(self) -> None: + for ip in ("::", "::1", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"): + assert name_to_ip(ip_to_name(ip)) == ip + + def test_autodetect_ipv4_by_length(self) -> None: + name = ip_to_name("10.0.0.1") + assert len(name) == _IPV4_LEN + result = name_to_ip(name) + assert "." in result + + def test_autodetect_ipv6_by_length(self) -> None: + name = ip_to_name("::1") + assert len(name) == _IPV6_LEN + result = name_to_ip(name) + assert ":" in result + def test_invalid_ip(self) -> None: - with pytest.raises(ValueError): + with pytest.raises((ValueError, OSError)): ip_to_name("256.1.1.1") - def test_invalid_name(self) -> None: + def test_invalid_name_bad_chars(self) -> None: with pytest.raises(ValueError): name_to_ip("not-a-valid-name") + + def test_invalid_name_wrong_length(self) -> None: + with pytest.raises(ValueError): + name_to_ip("abc") + + def test_invalid_name_zero_char(self) -> None: + with pytest.raises(ValueError): + name_to_ip("0" * 6) From 02113ef90b91c0f3bd0eed35a2017a94bca4491f Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 17:58:20 +0300 Subject: [PATCH 03/36] docs: update AGENTS.md and README for new name format and IPv6 --- AGENTS.md | 14 +++++++------- README.md | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80936b5..3c6be53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ src/fognode/ │ ├── exceptions.py # SecurityError, AuthError, etc. │ └── protocol.py # TypedDicts, dataclasses, Protocols ├── utils/ -│ ├── ipwords.py # IP ↔ 4-word codename conversion +│ ├── ipwords.py # IP ↔ short name: 6 chars (IPv4) or 23 chars (IPv6), auto-detected │ ├── net.py # local_ip() │ └── ratelimit.py # RateLimiter └── wire/ @@ -125,9 +125,9 @@ python -m build ### Run the CLI ```bash python -m fognode server --host 0.0.0.0 --port 9443 --user alice --password secret -fognode client alice@oak-pine-stone-field:9443 --password secret -fognode probe alice@oak-pine-stone-field:9443 -fognode status alice@oak-pine-stone-field:9443 --password secret +fognode client alice@7sf9Tn:9443 --password secret +fognode probe alice@7sf9Tn:9443 +fognode status alice@7sf9Tn:9443 --password secret ``` ## Code Style Guidelines @@ -179,15 +179,15 @@ fognode status alice@oak-pine-stone-field:9443 --password secret - `ChatState` is a **global singleton** (`_state` in `core/server.py`) protected by a `threading.Lock`. ### Client Lifecycle -- `client_connect()` resolves the 4-word code name to an IP via `name_to_ip()`. +- `client_connect()` resolves the short code name to an IP via `name_to_ip()` (auto-detects IPv4/IPv6 by length). - Two TLS connections: first probes the certificate (no verification), second verifies the pinned cert. - `client_handshake()` derives session keys via X25519 + HKDF. ### Certificate Handling - Certs are self-signed RSA-4096, valid for 365 days. -- The CN is the 4-word codename of the server's IP (`ip_to_name`). +- The CN is the short code name of the server's IP (`ip_to_name`): 6 chars for IPv4, 23 chars for IPv6. - Certs are stored as `fognode_cert.pem` / `fognode_key.pem` in the working directory. -- If the existing cert's CN mismatches the current codename, a new cert is generated automatically. +- If the existing cert's CN mismatches the current code name, a new cert is generated automatically. ## CI / CD diff --git a/README.md b/README.md index 669cd5e..0d8de56 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ if __name__ == "__main__": ```python from fognode import Client, MessageEvent, ClosedEvent, ErrorEvent -client = Client(connect_string="oak-pine-stone-field:9443", password="secret") +client = Client(connect_string="7sf9Tn:9443", password="secret") @client.on_event(MessageEvent) async def on_message(ctx): @@ -93,7 +93,7 @@ if __name__ == "__main__": from fognode import start_server, client_connect ip, code, fp = start_server("0.0.0.0", 9443, "secret") -print(f"Server running at {code}:9443") +print(f"Server running at {code}:9443") # e.g. 7sf9Tn:9443 ``` ## Events @@ -144,19 +144,19 @@ Decryption reverses the process and verifies `msg_key` integrity. fognode server --host 0.0.0.0 --port 9443 --password secret # Interactive client -fognode client oak-pine-stone-field:9443 --password secret +fognode client 7sf9Tn:9443 --password secret # Probe TLS fingerprint (no auth) -fognode probe oak-pine-stone-field:9443 +fognode probe 7sf9Tn:9443 # Server status via authenticated channel -fognode status oak-pine-stone-field:9443 --password secret +fognode status 7sf9Tn:9443 --password secret # Send one message and exit -fognode send oak-pine-stone-field:9443 --password secret --text "hello" +fognode send 7sf9Tn:9443 --password secret --text "hello" # Monitor messages only -fognode monitor oak-pine-stone-field:9443 --password secret +fognode monitor 7sf9Tn:9443 --password secret # Show certificate info fognode cert --file fognode_cert.pem From 5ccba84a4f6b9d4bba3356c6e944182f98fea1f0 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:05:04 +0300 Subject: [PATCH 04/36] feat: add local_ipv6() to net utils --- src/fognode/utils/net.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/fognode/utils/net.py b/src/fognode/utils/net.py index cf5ea49..218f15d 100644 --- a/src/fognode/utils/net.py +++ b/src/fognode/utils/net.py @@ -10,3 +10,12 @@ def local_ip() -> str: return s.getsockname()[0] except Exception: return "127.0.0.1" + + +def local_ipv6() -> str: + try: + with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s: + s.connect(("2001:4860:4860::8888", 80)) + return s.getsockname()[0] + except Exception: + return "::1" From 74897a74359aae5d8ec6e8f026dcabb68b2021f1 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:05:07 +0300 Subject: [PATCH 05/36] feat: add IPv6 server support (AF_INET6, auto-detect by host) --- src/fognode/core/server.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index b977f2d..974aa71 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ipaddress import socket import ssl import threading @@ -16,12 +17,28 @@ from fognode.types.exceptions import AuthError, SecurityError from fognode.types.protocol import CodeName, IPAddress, OnConnect, OnDisconnect, OnMessage, Port from fognode.utils.ipwords import ip_to_name -from fognode.utils.net import local_ip +from fognode.utils.net import local_ip, local_ipv6 from fognode.utils.ratelimit import RateLimiter _rl = RateLimiter() +def _socket_family(host: str) -> socket.AddressFamily: + try: + addr = ipaddress.ip_address(host) + return socket.AF_INET6 if isinstance(addr, ipaddress.IPv6Address) else socket.AF_INET + except ValueError: + return socket.AF_INET + + +def _resolve_display_ip(host: str) -> str: + if host == "0.0.0.0": + return local_ip() + if host == "::": + return local_ipv6() + return host + + def _session_loop( ch: SecureChannel, ip: IPAddress, @@ -117,8 +134,7 @@ def start_server( on_disconnect: OnDisconnect | None = None, on_message: OnMessage | None = None, ) -> tuple[IPAddress, CodeName, str]: - local = local_ip() - display_ip = local if host == "0.0.0.0" else host + display_ip = _resolve_display_ip(host) code_name = ip_to_name(display_ip) cert_file, key_file = cert_paths() @@ -141,7 +157,10 @@ def start_server( ctx = ssl_server_context() def _serve() -> None: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: + family = _socket_family(host) + with socket.socket(family, socket.SOCK_STREAM) as srv: + if family == socket.AF_INET6: + srv.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((host, port)) srv.listen(20) From af2c9d11c9e4222471e2a7b389b6e18a0eba1a1d Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:09:42 +0300 Subject: [PATCH 06/36] fix: use getaddrinfo to find real local IPv6 address --- src/fognode/utils/net.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/fognode/utils/net.py b/src/fognode/utils/net.py index 218f15d..6cf71fb 100644 --- a/src/fognode/utils/net.py +++ b/src/fognode/utils/net.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ipaddress import socket @@ -13,9 +14,18 @@ def local_ip() -> str: def local_ipv6() -> str: + fallback = "::1" try: - with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s: - s.connect(("2001:4860:4860::8888", 80)) - return s.getsockname()[0] + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET6): + raw = str(info[4][0]).split("%")[0] + try: + parsed = ipaddress.IPv6Address(raw) + except ValueError: + continue + if parsed.is_global: + return raw + if not parsed.is_loopback: + fallback = raw except Exception: - return "::1" + pass + return fallback From 609d7ac640df700bd88d0a06617414715d86967a Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:19:07 +0300 Subject: [PATCH 07/36] feat: add app name/version to cert subject, fognode version in issuer OU --- src/fognode/crypto/cert.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/fognode/crypto/cert.py b/src/fognode/crypto/cert.py index dc9da72..afa22d9 100644 --- a/src/fognode/crypto/cert.py +++ b/src/fognode/crypto/cert.py @@ -13,6 +13,16 @@ from fognode.types.constants import CERT_VALIDITY_DAYS, RSA_KEY_SIZE + +def fognode_version() -> str: + try: + from importlib.metadata import version + + return version("fognode") + except Exception: + return "dev" + + _CERT_FILE: Path = Path("fognode_cert.pem") _KEY_FILE: Path = Path("fognode_key.pem") @@ -28,20 +38,27 @@ def cert_paths() -> tuple[Path, Path]: return _CERT_FILE, _KEY_FILE -def generate_cert(cn: str) -> None: +def generate_cert(cn: str, app_name: str = "fognode", app_version: str = "1.0.0") -> None: key = rsa.generate_private_key(public_exponent=65537, key_size=RSA_KEY_SIZE) - name = x509.Name( + subject = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, cn), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, app_name), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, app_version), + ] + ) + issuer = x509.Name( [ x509.NameAttribute(NameOID.COMMON_NAME, cn), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "fognode"), - x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "fognode_v1"), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f"fognode_v{fognode_version()}"), ] ) now = datetime.datetime.now(datetime.timezone.utc) cert = ( x509.CertificateBuilder() - .subject_name(name) - .issuer_name(name) + .subject_name(subject) + .issuer_name(issuer) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now) @@ -78,6 +95,12 @@ def generate_cert(cn: str) -> None: _KEY_FILE.chmod(0o600) +def cert_issuer_ou(der: bytes) -> str | None: + cert = x509.load_der_x509_certificate(der) + attrs = cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME) + return str(attrs[0].value) if attrs else None + + def _pem_to_der(pem: bytes) -> bytes: lines = [line for line in pem.split(b"\n") if line and not line.startswith(b"-----")] return base64.b64decode(b"".join(lines)) From 1bc0a7a6e03e1b25710855098a075024d6989906 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:20:09 +0300 Subject: [PATCH 08/36] feat: add app_name/app_version to start_server, regenerate cert on change --- src/fognode/core/server.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index 974aa71..82b8854 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -133,24 +133,29 @@ def start_server( on_connect: OnConnect | None = None, on_disconnect: OnDisconnect | None = None, on_message: OnMessage | None = None, + app_name: str = "fognode", + app_version: str = "1.0.0", ) -> tuple[IPAddress, CodeName, str]: display_ip = _resolve_display_ip(host) code_name = ip_to_name(display_ip) cert_file, key_file = cert_paths() if not cert_file.exists() or not key_file.exists(): - generate_cert(code_name) + generate_cert(code_name, app_name, app_version) else: try: - existing_cn = ( - x509.load_pem_x509_certificate(cert_file.read_bytes()) - .subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0] - .value - ) - if existing_cn != code_name: - generate_cert(code_name) + obj = x509.load_pem_x509_certificate(cert_file.read_bytes()) + + def _val(attrs: list[Any]) -> str: + return str(attrs[0].value) if attrs else "" + + cn = _val(obj.subject.get_attributes_for_oid(NameOID.COMMON_NAME)) + o = _val(obj.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)) + ou = _val(obj.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)) + if cn != code_name or o != app_name or ou != app_version: + generate_cert(code_name, app_name, app_version) except Exception: - generate_cert(code_name) + generate_cert(code_name, app_name, app_version) store_password(password) fp = cert_fingerprint(cert_file) From 384fb9ac1de996076e50e0390467b864f09f7027 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:20:31 +0300 Subject: [PATCH 09/36] feat: add name/version params to Server class --- src/fognode/app.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/fognode/app.py b/src/fognode/app.py index 72942cc..c0ecf50 100644 --- a/src/fognode/app.py +++ b/src/fognode/app.py @@ -51,10 +51,14 @@ def __init__( host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, password: str | None = None, + name: str = "fognode", + version: str = "1.0.0", ) -> None: self.host = host self.port = port self.password = password + self.name = name + self.version = version self._handlers: dict[type[BaseEvent], list[HandlerObject]] = {} self._loop: asyncio.AbstractEventLoop | None = None self._channel: SecureChannel | None = None @@ -102,6 +106,8 @@ def _on_msg(msg: dict[str, Any]) -> None: on_connect=_on_connect, on_disconnect=_on_disconnect, on_message=_on_msg, + app_name=self.name, + app_version=self.version, ) self._process_event(StartEvent()) From f809f8075dc88780f244165bf09ca64ce431922c Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:20:57 +0300 Subject: [PATCH 10/36] feat: add --name and --app-version to server CLI command --- src/fognode/cli/entrypoint.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/fognode/cli/entrypoint.py b/src/fognode/cli/entrypoint.py index 1eebd48..2555c6e 100644 --- a/src/fognode/cli/entrypoint.py +++ b/src/fognode/cli/entrypoint.py @@ -50,6 +50,8 @@ def cmd_server(args: argparse.Namespace) -> None: args.host, args.port, password, + app_name=args.name, + app_version=args.app_version, ) t1 = time.perf_counter() @@ -296,6 +298,10 @@ def main() -> None: p_server.add_argument("--host", default=DEFAULT_HOST) p_server.add_argument("--port", type=int, default=DEFAULT_PORT) p_server.add_argument("--password", default=None) + p_server.add_argument("--name", default="fognode", help="App name embedded in cert subject") + p_server.add_argument( + "--app-version", default="1.0.0", help="App version embedded in cert subject" + ) p_server.set_defaults(func=cmd_server) p_client = sub.add_parser("client", help="Run interactive client") From 83e62d8e14905d591bd9891cf3ae60b631614bfb Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:22:03 +0300 Subject: [PATCH 11/36] feat: check server/client fognode version compatibility on connect --- src/fognode/core/client.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/fognode/core/client.py b/src/fognode/core/client.py index 9a70430..46fdc16 100644 --- a/src/fognode/core/client.py +++ b/src/fognode/core/client.py @@ -7,12 +7,30 @@ from pathlib import Path from fognode.auth.handshake import client_handshake -from fognode.crypto.cert import cert_fingerprint, ssl_probe_context, ssl_verified_context +from fognode.crypto.cert import ( + cert_fingerprint, + cert_issuer_ou, + fognode_version, + ssl_probe_context, + ssl_verified_context, +) from fognode.crypto.channel import SecureChannel from fognode.types.protocol import ConnectString, IPAddress, Port from fognode.utils.ipwords import name_to_ip +def _check_compat(der: bytes) -> None: + ou = cert_issuer_ou(der) + if not ou or not ou.startswith("fognode_v"): + return + server_ver = ou[len("fognode_v") :] + client_ver = fognode_version() + if server_ver.split(".")[0] != client_ver.split(".")[0]: + raise ConnectionError( + f"incompatible fognode version: server={ou}, client=fognode_v{client_ver}" + ) + + def client_connect(connect_str: ConnectString, password: str) -> tuple[SecureChannel, str]: try: code_name, port_str = connect_str.rsplit(":", 1) @@ -29,6 +47,7 @@ def client_connect(connect_str: ConnectString, password: str) -> tuple[SecureCha raw_p.close() if not der: raise ConnectionError("no cert from server") + _check_compat(der) measured_fp = cert_fingerprint(der) pem = ( From d3ad9621f9c32d0e47735b75a1b7685c8b6e52b6 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:25:56 +0300 Subject: [PATCH 12/36] fix: regenerate cert when fognode version changes in issuer OU --- src/fognode/core/server.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index 82b8854..6bf8925 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -11,7 +11,13 @@ from cryptography.x509.oid import NameOID from fognode.auth.handshake import server_handshake -from fognode.crypto.cert import cert_fingerprint, cert_paths, generate_cert, ssl_server_context +from fognode.crypto.cert import ( + cert_fingerprint, + cert_paths, + fognode_version, + generate_cert, + ssl_server_context, +) from fognode.crypto.channel import SecureChannel from fognode.crypto.password import store_password from fognode.types.exceptions import AuthError, SecurityError @@ -152,7 +158,13 @@ def _val(attrs: list[Any]) -> str: cn = _val(obj.subject.get_attributes_for_oid(NameOID.COMMON_NAME)) o = _val(obj.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)) ou = _val(obj.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)) - if cn != code_name or o != app_name or ou != app_version: + issuer_ou = _val(obj.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)) + if ( + cn != code_name + or o != app_name + or ou != app_version + or issuer_ou != f"fognode_v{fognode_version()}" + ): generate_cert(code_name, app_name, app_version) except Exception: generate_cert(code_name, app_name, app_version) From 0a9494959001bf71a61538ffe4a3430d81bf20db Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:39:00 +0300 Subject: [PATCH 13/36] fix: fall back to IPv4 when no routable IPv6, use dual-stack socket for :: --- src/fognode/core/server.py | 5 +++-- src/fognode/utils/net.py | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index 6bf8925..9e420cc 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -41,7 +41,8 @@ def _resolve_display_ip(host: str) -> str: if host == "0.0.0.0": return local_ip() if host == "::": - return local_ipv6() + v6 = local_ipv6() + return v6 if v6 else local_ip() # fall back to IPv4 when no routable IPv6 return host @@ -177,7 +178,7 @@ def _serve() -> None: family = _socket_family(host) with socket.socket(family, socket.SOCK_STREAM) as srv: if family == socket.AF_INET6: - srv.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + srv.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) # dual-stack srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((host, port)) srv.listen(20) diff --git a/src/fognode/utils/net.py b/src/fognode/utils/net.py index 6cf71fb..d52da32 100644 --- a/src/fognode/utils/net.py +++ b/src/fognode/utils/net.py @@ -14,7 +14,8 @@ def local_ip() -> str: def local_ipv6() -> str: - fallback = "::1" + """Return a routable IPv6 address, or empty string if only link-local/loopback found.""" + best = "" try: for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET6): raw = str(info[4][0]).split("%")[0] @@ -24,8 +25,8 @@ def local_ipv6() -> str: continue if parsed.is_global: return raw - if not parsed.is_loopback: - fallback = raw + if not parsed.is_loopback and not parsed.is_link_local: + best = raw # ULA or similar — usable without scope ID except Exception: pass - return fallback + return best From 69ed832e7825a2a92bedf09f3153189f6b360890 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:43:35 +0300 Subject: [PATCH 14/36] feat: return and display server app info on client connect --- src/fognode/app.py | 2 +- src/fognode/cli/entrypoint.py | 12 ++++++++---- src/fognode/core/client.py | 8 ++++++-- src/fognode/crypto/cert.py | 9 +++++++++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/fognode/app.py b/src/fognode/app.py index c0ecf50..e4ab5f8 100644 --- a/src/fognode/app.py +++ b/src/fognode/app.py @@ -173,7 +173,7 @@ def connect(self) -> None: asyncio.set_event_loop(self._loop) try: - ch, _fp = client_connect(self.connect_string, self.password) + ch, _fp, _server_info = client_connect(self.connect_string, self.password) except Exception as exc: self._process_event(ErrorEvent(exception=exc)) return diff --git a/src/fognode/cli/entrypoint.py b/src/fognode/cli/entrypoint.py index 2555c6e..206bde9 100644 --- a/src/fognode/cli/entrypoint.py +++ b/src/fognode/cli/entrypoint.py @@ -99,7 +99,7 @@ def cmd_client(args: argparse.Namespace) -> None: t0 = time.perf_counter() try: - ch, fp = client_connect(args.connect, password) + ch, fp, sinfo = client_connect(args.connect, password) t1 = time.perf_counter() except (SecurityError, AuthError, ConnectionError) as e: sys.exit(f"[!] {e}") @@ -117,6 +117,10 @@ def cmd_client(args: argparse.Namespace) -> None: _kv("channel", "AESGCM-256 + HMAC-SHA256") _kv("kex", "X25519 + HKDF") _kv("connect_ms", f"{(t1 - t0) * 1000:.1f}") + print() + _kv("server_app", sinfo.get("app_name")) + _kv("server_version", sinfo.get("app_version")) + _kv("server_fognode", sinfo.get("fognode_ver")) print("═" * 60) welcome = ch.recv() @@ -189,7 +193,7 @@ def cmd_status(args: argparse.Namespace) -> None: password = getpass.getpass("Password: ") try: - ch, fp = client_connect(args.connect, password) + ch, fp, _si = client_connect(args.connect, password) except (SecurityError, AuthError, ConnectionError) as e: sys.exit(f"[!] {e}") @@ -221,7 +225,7 @@ def cmd_send(args: argparse.Namespace) -> None: password = getpass.getpass("Password: ") try: - ch, _fp = client_connect(args.connect, password) + ch, _fp, _si = client_connect(args.connect, password) except (SecurityError, AuthError, ConnectionError) as e: sys.exit(f"[!] {e}") @@ -241,7 +245,7 @@ def cmd_monitor(args: argparse.Namespace) -> None: password = getpass.getpass("Password: ") try: - ch, fp = client_connect(args.connect, password) + ch, fp, _si = client_connect(args.connect, password) except (SecurityError, AuthError, ConnectionError) as e: sys.exit(f"[!] {e}") diff --git a/src/fognode/core/client.py b/src/fognode/core/client.py index 46fdc16..ac08d9a 100644 --- a/src/fognode/core/client.py +++ b/src/fognode/core/client.py @@ -10,6 +10,7 @@ from fognode.crypto.cert import ( cert_fingerprint, cert_issuer_ou, + cert_subject_info, fognode_version, ssl_probe_context, ssl_verified_context, @@ -31,7 +32,9 @@ def _check_compat(der: bytes) -> None: ) -def client_connect(connect_str: ConnectString, password: str) -> tuple[SecureChannel, str]: +def client_connect( + connect_str: ConnectString, password: str +) -> tuple[SecureChannel, str, dict[str, str | None]]: try: code_name, port_str = connect_str.rsplit(":", 1) port: Port = int(port_str) @@ -49,6 +52,7 @@ def client_connect(connect_str: ConnectString, password: str) -> tuple[SecureCha raise ConnectionError("no cert from server") _check_compat(der) measured_fp = cert_fingerprint(der) + server_info = cert_subject_info(der) pem = ( b"-----BEGIN CERTIFICATE-----\n" + base64.encodebytes(der) + b"-----END CERTIFICATE-----\n" @@ -67,4 +71,4 @@ def client_connect(connect_str: ConnectString, password: str) -> tuple[SecureCha tmp.unlink(missing_ok=True) channel = client_handshake(tls_sock, password, measured_fp) - return channel, measured_fp + return channel, measured_fp, server_info diff --git a/src/fognode/crypto/cert.py b/src/fognode/crypto/cert.py index afa22d9..a1a695c 100644 --- a/src/fognode/crypto/cert.py +++ b/src/fognode/crypto/cert.py @@ -95,6 +95,15 @@ def generate_cert(cn: str, app_name: str = "fognode", app_version: str = "1.0.0" _KEY_FILE.chmod(0o600) +def cert_subject_info(der: bytes) -> dict[str, str | None]: + cert = x509.load_der_x509_certificate(der) + return { + "app_name": _attr_value(cert.subject, NameOID.ORGANIZATION_NAME), + "app_version": _attr_value(cert.subject, NameOID.ORGANIZATIONAL_UNIT_NAME), + "fognode_ver": _attr_value(cert.issuer, NameOID.ORGANIZATIONAL_UNIT_NAME), + } + + def cert_issuer_ou(der: bytes) -> str | None: cert = x509.load_der_x509_certificate(der) attrs = cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME) From 460594b6c651ab2d59f4fc6eb2f478ccb575a824 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 18:46:29 +0300 Subject: [PATCH 15/36] fix: remove chain verification for non-self-signed cert, rely on fp in handshake --- src/fognode/core/client.py | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/fognode/core/client.py b/src/fognode/core/client.py index ac08d9a..991aaac 100644 --- a/src/fognode/core/client.py +++ b/src/fognode/core/client.py @@ -1,10 +1,6 @@ from __future__ import annotations -import base64 -import os import socket -import tempfile -from pathlib import Path from fognode.auth.handshake import client_handshake from fognode.crypto.cert import ( @@ -13,7 +9,6 @@ cert_subject_info, fognode_version, ssl_probe_context, - ssl_verified_context, ) from fognode.crypto.channel import SecureChannel from fognode.types.protocol import ConnectString, IPAddress, Port @@ -43,6 +38,7 @@ def client_connect( server_ip: IPAddress = name_to_ip(code_name) + # Probe: get cert, fingerprint, and server info without chain verification raw_p = socket.create_connection((server_ip, port), timeout=10) tls_p = ssl_probe_context().wrap_socket(raw_p, server_hostname=code_name) der = tls_p.getpeercert(binary_form=True) @@ -54,21 +50,10 @@ def client_connect( measured_fp = cert_fingerprint(der) server_info = cert_subject_info(der) - pem = ( - b"-----BEGIN CERTIFICATE-----\n" + base64.encodebytes(der) + b"-----END CERTIFICATE-----\n" - ) - fd, tmp_path = tempfile.mkstemp(suffix=".pem") - os.write(fd, pem) - os.close(fd) - tmp = Path(tmp_path) - try: - raw_sock = socket.create_connection((server_ip, port), timeout=10) - tls_sock = ssl_verified_context(tmp, code_name).wrap_socket( - raw_sock, server_hostname=code_name - ) - tls_sock.settimeout(30) - finally: - tmp.unlink(missing_ok=True) + # Authenticated connection — fingerprint is verified inside client_handshake + raw_sock = socket.create_connection((server_ip, port), timeout=10) + tls_sock = ssl_probe_context().wrap_socket(raw_sock, server_hostname=code_name) + tls_sock.settimeout(30) channel = client_handshake(tls_sock, password, measured_fp) return channel, measured_fp, server_info From 43e02f5662108ea13fadd55469784f941e26e705 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:03:43 +0300 Subject: [PATCH 16/36] fix: correct iv_p state update in _ige_decrypt for multi-block messages --- src/fognode/cipher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fognode/cipher.py b/src/fognode/cipher.py index df4bcc9..394c0c5 100644 --- a/src/fognode/cipher.py +++ b/src/fognode/cipher.py @@ -39,7 +39,7 @@ def _ige_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes: x = bytes(a ^ b for a, b in zip(block, iv_c, strict=False)) # noqa: B905 p = cipher.decrypt(x) pt += bytes(a ^ b for a, b in zip(p, iv_p, strict=False)) # noqa: B905 - iv_p = block + iv_p = x iv_c = pt[-16:] return pt From 45e3a841cd68a4386852e3c6ba94d14eee696f35 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:03:46 +0300 Subject: [PATCH 17/36] fix: update cert regeneration check to match new issuer format --- src/fognode/core/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index 9e420cc..bd779a8 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -159,12 +159,14 @@ def _val(attrs: list[Any]) -> str: cn = _val(obj.subject.get_attributes_for_oid(NameOID.COMMON_NAME)) o = _val(obj.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)) ou = _val(obj.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)) + issuer_cn = _val(obj.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)) issuer_ou = _val(obj.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)) if ( cn != code_name or o != app_name or ou != app_version - or issuer_ou != f"fognode_v{fognode_version()}" + or issuer_cn != "reekeer" + or issuer_ou != f"v{fognode_version()}" ): generate_cert(code_name, app_name, app_version) except Exception: From 65bc563f3f511a7aa300e03cd3586338e5f5a6db Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:03:49 +0300 Subject: [PATCH 18/36] fix: update version compat check to use new issuer OU prefix --- src/fognode/core/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fognode/core/client.py b/src/fognode/core/client.py index 991aaac..4a1da50 100644 --- a/src/fognode/core/client.py +++ b/src/fognode/core/client.py @@ -17,13 +17,13 @@ def _check_compat(der: bytes) -> None: ou = cert_issuer_ou(der) - if not ou or not ou.startswith("fognode_v"): + if not ou or not ou.startswith("v"): return - server_ver = ou[len("fognode_v") :] + server_ver = ou[len("v") :] client_ver = fognode_version() if server_ver.split(".")[0] != client_ver.split(".")[0]: raise ConnectionError( - f"incompatible fognode version: server={ou}, client=fognode_v{client_ver}" + f"incompatible fognode version: server={ou}, client=v{client_ver}" ) From fb4e14c380b118fa7e396e4d0baf72a53cc7385a Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:06:08 +0300 Subject: [PATCH 19/36] feat: add msgpack dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e05491a..e74dcd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ mainteiners = [ dependencies = [ "cryptography>=44.0.0", "pycryptodome>=3.20.0", + "msgpack>=1.0.0", ] [project.urls] From 4667ec2fe3627adcbc8b1bc6bcaa9cf5747035b6 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:06:28 +0300 Subject: [PATCH 20/36] feat: replace json with msgpack in SecureChannel --- src/fognode/crypto/channel.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/fognode/crypto/channel.py b/src/fognode/crypto/channel.py index cb04fe9..07f4f50 100644 --- a/src/fognode/crypto/channel.py +++ b/src/fognode/crypto/channel.py @@ -1,10 +1,11 @@ from __future__ import annotations -import json import struct from threading import Lock from typing import Any +import msgpack + from fognode.cipher import decrypt, encrypt from fognode.types.constants import MAX_MESSAGE_SIZE from fognode.types.exceptions import SecurityError @@ -17,7 +18,7 @@ def __init__(self, sock: Any, key: bytes) -> None: self._lock = Lock() def send(self, msg: dict[str, Any]) -> None: - payload = json.dumps(msg, ensure_ascii=False).encode() + payload = msgpack.packb(msg, use_bin_type=True) frame = encrypt(self._key, payload) if len(frame) > MAX_MESSAGE_SIZE: raise ValueError("message too large") @@ -33,7 +34,7 @@ def recv(self) -> dict[str, Any]: payload = decrypt(self._key, buf) except ValueError as exc: raise SecurityError(str(exc)) from None - return json.loads(payload) + return msgpack.unpackb(payload, raw=False) def _recv_exact(self, n: int) -> bytes: buf = b"" From da52402825f132245bb2d65df41eaa79bc855471 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:06:40 +0300 Subject: [PATCH 21/36] feat: replace json with msgpack in wire framing --- src/fognode/wire/framing.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/fognode/wire/framing.py b/src/fognode/wire/framing.py index 3acaeb8..8b71b97 100644 --- a/src/fognode/wire/framing.py +++ b/src/fognode/wire/framing.py @@ -1,16 +1,17 @@ from __future__ import annotations -import json import ssl import struct from typing import Any +import msgpack + from fognode.types.constants import MAX_MESSAGE_SIZE from fognode.types.exceptions import SecurityError def wire_send(sock: ssl.SSLSocket, data: dict[str, Any]) -> None: - raw = json.dumps(data).encode() + raw = msgpack.packb(data, use_bin_type=True) sock.sendall(struct.pack(">I", len(raw)) + raw) @@ -30,4 +31,4 @@ def wire_recv(sock: ssl.SSLSocket) -> dict[str, Any]: if not c: raise ConnectionError("closed") buf += c - return json.loads(buf.decode()) + return msgpack.unpackb(buf, raw=False) From f51c0b7fe7e4e8015cf2df6151edfa1c71b7e8ab Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:06:49 +0300 Subject: [PATCH 22/36] style: suppress pyright reportMissingImports for msgpack --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e74dcd3..7092dfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ reportUnknownVariableType = "none" reportUnknownArgumentType = "none" reportUnknownParameterType = "none" reportMissingTypeStubs = "none" +reportMissingImports = "none" reportAttributeAccessIssue = "warning" reportOperatorIssue = "none" reportPrivateUsage = "none" From 4559a16a00b7173d3755a35854204f42b2efffd8 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:07:05 +0300 Subject: [PATCH 23/36] test: update framing tests for msgpack --- tests/test_framing.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_framing.py b/tests/test_framing.py index 615aa24..4d3c762 100644 --- a/tests/test_framing.py +++ b/tests/test_framing.py @@ -1,8 +1,9 @@ from __future__ import annotations -import json import struct +import msgpack + from fognode.wire.framing import wire_recv, wire_send @@ -29,11 +30,11 @@ def test_wire_send(self) -> None: wire_send(sock, {"type": "ping"}) # type: ignore[arg-type] data = sock.get_sent() n = struct.unpack(">I", data[:4])[0] - assert json.loads(data[4:].decode()) == {"type": "ping"} + assert msgpack.unpackb(data[4:], raw=False) == {"type": "ping"} assert len(data[4:]) == n def test_wire_recv(self) -> None: - raw = json.dumps({"type": "pong"}).encode() + raw = msgpack.packb({"type": "pong"}, use_bin_type=True) frame = struct.pack(">I", len(raw)) + raw sock = MockSocket(frame) msg = wire_recv(sock) # type: ignore[arg-type] From a85ee4fe1219a362d3b293b3e00d72333a4de6de Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:25:17 +0300 Subject: [PATCH 24/36] feat: add custom msgpack implementation with tests --- src/fognode/wire/msgpack.py | 193 ++++++++++++++++++++++++++++++++ tests/test_msgpack.py | 213 ++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 src/fognode/wire/msgpack.py create mode 100644 tests/test_msgpack.py diff --git a/src/fognode/wire/msgpack.py b/src/fognode/wire/msgpack.py new file mode 100644 index 0000000..376f6ea --- /dev/null +++ b/src/fognode/wire/msgpack.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import struct +from typing import Any + + +def packb(obj: Any) -> bytes: + return _pack(obj) + + +def unpackb(data: bytes) -> Any: + obj, _ = _unpack(data, 0) + return obj + + +def _pack(obj: Any) -> bytes: + if obj is None: + return b"\xc0" + if isinstance(obj, bool): + return b"\xc3" if obj else b"\xc2" + if isinstance(obj, int): + return _pack_int(obj) + if isinstance(obj, float): + return struct.pack(">Bd", 0xCB, obj) + if isinstance(obj, str): + return _pack_str(obj) + if isinstance(obj, (bytes, bytearray)): + return _pack_bin(bytes(obj)) + if isinstance(obj, (list, tuple)): + return _pack_array(obj) + if isinstance(obj, dict): + return _pack_map(obj) + raise TypeError(f"msgpack: unsupported type {type(obj).__name__}") + + +def _pack_int(n: int) -> bytes: + if 0 <= n <= 0x7F: + return bytes([n]) + if -32 <= n < 0: + return bytes([n & 0xFF]) + if n > 0: + if n <= 0xFF: + return struct.pack(">BB", 0xCC, n) + if n <= 0xFFFF: + return struct.pack(">BH", 0xCD, n) + if n <= 0xFFFFFFFF: + return struct.pack(">BI", 0xCE, n) + if n <= 0xFFFFFFFFFFFFFFFF: + return struct.pack(">BQ", 0xCF, n) + else: + if n >= -0x80: + return struct.pack(">Bb", 0xD0, n) + if n >= -0x8000: + return struct.pack(">Bh", 0xD1, n) + if n >= -0x80000000: + return struct.pack(">Bi", 0xD2, n) + if n >= -0x8000000000000000: + return struct.pack(">Bq", 0xD3, n) + raise OverflowError(f"msgpack: integer out of range: {n}") + + +def _pack_str(s: str) -> bytes: + raw = s.encode("utf-8") + n = len(raw) + if n <= 31: + return bytes([0xA0 | n]) + raw + if n <= 0xFF: + return struct.pack(">BB", 0xD9, n) + raw + if n <= 0xFFFF: + return struct.pack(">BH", 0xDA, n) + raw + return struct.pack(">BI", 0xDB, n) + raw + + +def _pack_bin(b: bytes) -> bytes: + n = len(b) + if n <= 0xFF: + return struct.pack(">BB", 0xC4, n) + b + if n <= 0xFFFF: + return struct.pack(">BH", 0xC5, n) + b + return struct.pack(">BI", 0xC6, n) + b + + +def _pack_array(arr: list[Any] | tuple[Any, ...]) -> bytes: + n = len(arr) + if n <= 15: + hdr = bytes([0x90 | n]) + elif n <= 0xFFFF: + hdr = struct.pack(">BH", 0xDC, n) + else: + hdr = struct.pack(">BI", 0xDD, n) + return hdr + b"".join(_pack(x) for x in arr) + + +def _pack_map(d: dict[Any, Any]) -> bytes: + n = len(d) + if n <= 15: + hdr = bytes([0x80 | n]) + elif n <= 0xFFFF: + hdr = struct.pack(">BH", 0xDE, n) + else: + hdr = struct.pack(">BI", 0xDF, n) + return hdr + b"".join(_pack(k) + _pack(v) for k, v in d.items()) + + +def _unpack(data: bytes, pos: int) -> tuple[Any, int]: + b = data[pos] + pos += 1 + + if b == 0xC0: + return None, pos + if b == 0xC2: + return False, pos + if b == 0xC3: + return True, pos + if b <= 0x7F: + return b, pos + if b >= 0xE0: + return b - 256, pos + if 0xA0 <= b <= 0xBF: + n = b & 0x1F + return data[pos : pos + n].decode("utf-8"), pos + n + if 0x90 <= b <= 0x9F: + return _unpack_array(data, pos, b & 0x0F) + if 0x80 <= b <= 0x8F: + return _unpack_map(data, pos, b & 0x0F) + if b == 0xCA: + return struct.unpack_from(">f", data, pos)[0], pos + 4 + if b == 0xCB: + return struct.unpack_from(">d", data, pos)[0], pos + 8 + if b == 0xCC: + return data[pos], pos + 1 + if b == 0xCD: + return struct.unpack_from(">H", data, pos)[0], pos + 2 + if b == 0xCE: + return struct.unpack_from(">I", data, pos)[0], pos + 4 + if b == 0xCF: + return struct.unpack_from(">Q", data, pos)[0], pos + 8 + if b == 0xD0: + return struct.unpack_from(">b", data, pos)[0], pos + 1 + if b == 0xD1: + return struct.unpack_from(">h", data, pos)[0], pos + 2 + if b == 0xD2: + return struct.unpack_from(">i", data, pos)[0], pos + 4 + if b == 0xD3: + return struct.unpack_from(">q", data, pos)[0], pos + 8 + if b == 0xD9: + n = data[pos]; pos += 1 + return data[pos : pos + n].decode("utf-8"), pos + n + if b == 0xDA: + n = struct.unpack_from(">H", data, pos)[0]; pos += 2 + return data[pos : pos + n].decode("utf-8"), pos + n + if b == 0xDB: + n = struct.unpack_from(">I", data, pos)[0]; pos += 4 + return data[pos : pos + n].decode("utf-8"), pos + n + if b == 0xC4: + n = data[pos]; pos += 1 + return data[pos : pos + n], pos + n + if b == 0xC5: + n = struct.unpack_from(">H", data, pos)[0]; pos += 2 + return data[pos : pos + n], pos + n + if b == 0xC6: + n = struct.unpack_from(">I", data, pos)[0]; pos += 4 + return data[pos : pos + n], pos + n + if b == 0xDC: + n = struct.unpack_from(">H", data, pos)[0] + return _unpack_array(data, pos + 2, n) + if b == 0xDD: + n = struct.unpack_from(">I", data, pos)[0] + return _unpack_array(data, pos + 4, n) + if b == 0xDE: + n = struct.unpack_from(">H", data, pos)[0] + return _unpack_map(data, pos + 2, n) + if b == 0xDF: + n = struct.unpack_from(">I", data, pos)[0] + return _unpack_map(data, pos + 4, n) + raise ValueError(f"msgpack: unknown byte 0x{b:02X} at offset {pos - 1}") + + +def _unpack_array(data: bytes, pos: int, n: int) -> tuple[list[Any], int]: + arr = [] + for _ in range(n): + v, pos = _unpack(data, pos) + arr.append(v) + return arr, pos + + +def _unpack_map(data: bytes, pos: int, n: int) -> tuple[dict[Any, Any], int]: + d: dict[Any, Any] = {} + for _ in range(n): + k, pos = _unpack(data, pos) + v, pos = _unpack(data, pos) + d[k] = v + return d, pos diff --git a/tests/test_msgpack.py b/tests/test_msgpack.py new file mode 100644 index 0000000..dfafa8e --- /dev/null +++ b/tests/test_msgpack.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import math +import struct + +import pytest + +from fognode.wire.msgpack import packb, unpackb + + +def rt(v): + return unpackb(packb(v)) + + +class TestNil: + def test_none(self): + assert packb(None) == b"\xc0" + assert rt(None) is None + + +class TestBool: + def test_true(self): + assert packb(True) == b"\xc3" + assert rt(True) is True + + def test_false(self): + assert packb(False) == b"\xc2" + assert rt(False) is False + + def test_bool_not_int(self): + assert isinstance(rt(True), bool) + assert isinstance(rt(False), bool) + + +class TestInt: + @pytest.mark.parametrize("n", [0, 1, 63, 127]) + def test_positive_fixint(self, n): + assert packb(n) == bytes([n]) + assert rt(n) == n + + @pytest.mark.parametrize("n", [-1, -16, -32]) + def test_negative_fixint(self, n): + assert len(packb(n)) == 1 + assert rt(n) == n + + @pytest.mark.parametrize("n", [128, 200, 255]) + def test_uint8(self, n): + assert packb(n)[0] == 0xCC + assert rt(n) == n + + @pytest.mark.parametrize("n", [256, 1000, 65535]) + def test_uint16(self, n): + assert packb(n)[0] == 0xCD + assert rt(n) == n + + @pytest.mark.parametrize("n", [65536, 2**24, 2**32 - 1]) + def test_uint32(self, n): + assert packb(n)[0] == 0xCE + assert rt(n) == n + + @pytest.mark.parametrize("n", [2**32, 2**48, 2**64 - 1]) + def test_uint64(self, n): + assert packb(n)[0] == 0xCF + assert rt(n) == n + + @pytest.mark.parametrize("n", [-33, -100, -128]) + def test_int8(self, n): + assert packb(n)[0] == 0xD0 + assert rt(n) == n + + @pytest.mark.parametrize("n", [-129, -1000, -32768]) + def test_int16(self, n): + assert packb(n)[0] == 0xD1 + assert rt(n) == n + + @pytest.mark.parametrize("n", [-32769, -(2**24), -(2**31)]) + def test_int32(self, n): + assert packb(n)[0] == 0xD2 + assert rt(n) == n + + @pytest.mark.parametrize("n", [-(2**31) - 1, -(2**48), -(2**63)]) + def test_int64(self, n): + assert packb(n)[0] == 0xD3 + assert rt(n) == n + + def test_overflow(self): + with pytest.raises(OverflowError): + packb(2**64) + with pytest.raises(OverflowError): + packb(-(2**63) - 1) + + +class TestFloat: + def test_float64(self): + assert packb(3.14)[0] == 0xCB + assert abs(rt(3.14) - 3.14) < 1e-10 + + def test_zero(self): + assert rt(0.0) == 0.0 + + def test_negative(self): + assert abs(rt(-1.5) - (-1.5)) < 1e-10 + + +class TestStr: + def test_empty(self): + assert rt("") == "" + + @pytest.mark.parametrize("s", ["a", "hello", "x" * 31]) + def test_fixstr(self, s): + assert packb(s)[0] == (0xA0 | len(s.encode())) + assert rt(s) == s + + def test_str8(self): + s = "x" * 32 + assert packb(s)[0] == 0xD9 + assert rt(s) == s + + def test_str16(self): + s = "x" * 256 + assert packb(s)[0] == 0xDA + assert rt(s) == s + + def test_unicode(self): + s = "привет 🔥" + assert rt(s) == s + + +class TestBin: + def test_empty(self): + assert rt(b"") == b"" + + def test_bin8(self): + b = bytes(range(16)) + assert packb(b)[0] == 0xC4 + assert rt(b) == b + + def test_bin_roundtrip(self): + b = bytes(range(256)) + assert rt(b) == b + + +class TestArray: + def test_empty(self): + assert rt([]) == [] + + def test_fixarray(self): + arr = [1, 2, 3] + assert packb(arr)[0] == (0x90 | 3) + assert rt(arr) == arr + + def test_nested(self): + arr = [[1, 2], [3, [4, 5]]] + assert rt(arr) == arr + + def test_mixed(self): + arr = [None, True, 42, "hi", b"\xff"] + assert rt(arr) == arr + + def test_array16(self): + arr = list(range(16)) + assert packb(arr)[0] == 0xDC + assert rt(arr) == arr + + +class TestMap: + def test_empty(self): + assert rt({}) == {} + + def test_fixmap(self): + d = {"t": 1, "text": "hello"} + assert packb(d)[0] == (0x80 | 2) + assert rt(d) == d + + def test_int_keys(self): + d = {0: "welcome", 1: "chat"} + assert rt(d) == d + + def test_nested(self): + d = {"a": {"b": {"c": 42}}} + assert rt(d) == d + + def test_map16(self): + d = {str(i): i for i in range(16)} + assert packb(d)[0] == 0xDE + assert rt(d) == d + + +class TestProtocolMessages: + def test_welcome(self): + assert rt({"t": 1}) == {"t": 1} + + def test_chat(self): + msg = {"t": 4, "text": "hello world", "ts": 1234567890} + assert rt(msg) == msg + + def test_cmd(self): + assert rt({"t": 5, "cmd": 1}) == {"t": 5, "cmd": 1} + + def test_compact_vs_json(self): + import json + msg = {"t": 4, "text": "hello world", "ts": 1234567890} + mp_size = len(packb(msg)) + js_size = len(json.dumps(msg).encode()) + assert mp_size < js_size + + def test_unsupported_type(self): + with pytest.raises(TypeError): + packb(object()) + + def test_unknown_byte(self): + with pytest.raises((ValueError, Exception)): + unpackb(b"\xc1") From e9e5f045a18f936e157d0e887f8edfd07de5c102 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:25:32 +0300 Subject: [PATCH 25/36] feat: switch channel to local msgpack --- src/fognode/crypto/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fognode/crypto/channel.py b/src/fognode/crypto/channel.py index 07f4f50..6c60e62 100644 --- a/src/fognode/crypto/channel.py +++ b/src/fognode/crypto/channel.py @@ -4,7 +4,7 @@ from threading import Lock from typing import Any -import msgpack +from fognode.wire import msgpack from fognode.cipher import decrypt, encrypt from fognode.types.constants import MAX_MESSAGE_SIZE From 7b39f3a3d7e9f9a29dcf2dc092226663f0e2ab1a Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:25:41 +0300 Subject: [PATCH 26/36] feat: switch framing to local msgpack --- src/fognode/wire/framing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fognode/wire/framing.py b/src/fognode/wire/framing.py index 8b71b97..2989b85 100644 --- a/src/fognode/wire/framing.py +++ b/src/fognode/wire/framing.py @@ -4,7 +4,7 @@ import struct from typing import Any -import msgpack +from fognode.wire import msgpack from fognode.types.constants import MAX_MESSAGE_SIZE from fognode.types.exceptions import SecurityError From 8c187d5ce0d115f26432dc919147caa24b6b3c79 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:27:09 +0300 Subject: [PATCH 27/36] feat: replace external msgpack with local implementation --- pyproject.toml | 2 -- src/fognode/crypto/channel.py | 6 +++--- src/fognode/wire/__init__.py | 3 ++- src/fognode/wire/framing.py | 7 +++---- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7092dfa..e05491a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ mainteiners = [ dependencies = [ "cryptography>=44.0.0", "pycryptodome>=3.20.0", - "msgpack>=1.0.0", ] [project.urls] @@ -75,7 +74,6 @@ reportUnknownVariableType = "none" reportUnknownArgumentType = "none" reportUnknownParameterType = "none" reportMissingTypeStubs = "none" -reportMissingImports = "none" reportAttributeAccessIssue = "warning" reportOperatorIssue = "none" reportPrivateUsage = "none" diff --git a/src/fognode/crypto/channel.py b/src/fognode/crypto/channel.py index 6c60e62..6067458 100644 --- a/src/fognode/crypto/channel.py +++ b/src/fognode/crypto/channel.py @@ -4,7 +4,7 @@ from threading import Lock from typing import Any -from fognode.wire import msgpack +from fognode.wire.msgpack import packb, unpackb from fognode.cipher import decrypt, encrypt from fognode.types.constants import MAX_MESSAGE_SIZE @@ -18,7 +18,7 @@ def __init__(self, sock: Any, key: bytes) -> None: self._lock = Lock() def send(self, msg: dict[str, Any]) -> None: - payload = msgpack.packb(msg, use_bin_type=True) + payload = packb(msg) frame = encrypt(self._key, payload) if len(frame) > MAX_MESSAGE_SIZE: raise ValueError("message too large") @@ -34,7 +34,7 @@ def recv(self) -> dict[str, Any]: payload = decrypt(self._key, buf) except ValueError as exc: raise SecurityError(str(exc)) from None - return msgpack.unpackb(payload, raw=False) + return unpackb(payload) def _recv_exact(self, n: int) -> bytes: buf = b"" diff --git a/src/fognode/wire/__init__.py b/src/fognode/wire/__init__.py index 4163060..0857b6c 100644 --- a/src/fognode/wire/__init__.py +++ b/src/fognode/wire/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from fognode.wire import msgpack from fognode.wire.framing import wire_recv, wire_send -__all__ = ["wire_recv", "wire_send"] +__all__ = ["msgpack", "wire_recv", "wire_send"] diff --git a/src/fognode/wire/framing.py b/src/fognode/wire/framing.py index 2989b85..15167f6 100644 --- a/src/fognode/wire/framing.py +++ b/src/fognode/wire/framing.py @@ -4,14 +4,13 @@ import struct from typing import Any -from fognode.wire import msgpack - from fognode.types.constants import MAX_MESSAGE_SIZE from fognode.types.exceptions import SecurityError +from fognode.wire.msgpack import packb, unpackb def wire_send(sock: ssl.SSLSocket, data: dict[str, Any]) -> None: - raw = msgpack.packb(data, use_bin_type=True) + raw = packb(data) sock.sendall(struct.pack(">I", len(raw)) + raw) @@ -31,4 +30,4 @@ def wire_recv(sock: ssl.SSLSocket) -> dict[str, Any]: if not c: raise ConnectionError("closed") buf += c - return msgpack.unpackb(buf, raw=False) + return unpackb(buf) From b6d764e2dab0b90ab1719928be3573b2bfb9f3ce Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:29:53 +0300 Subject: [PATCH 28/36] feat: introduce integer message type constants, replace string types protocol-wide --- src/fognode/app.py | 5 +++-- src/fognode/auth/handshake.py | 5 +++-- src/fognode/cli/entrypoint.py | 31 ++++++++++++++++--------------- src/fognode/core/events.py | 4 ++-- src/fognode/core/server.py | 13 +++++++------ src/fognode/types/messages.py | 14 ++++++++++++++ 6 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 src/fognode/types/messages.py diff --git a/src/fognode/app.py b/src/fognode/app.py index e4ab5f8..0cf3b41 100644 --- a/src/fognode/app.py +++ b/src/fognode/app.py @@ -18,6 +18,7 @@ from fognode.crypto.channel import SecureChannel from fognode.handlers import HandlerObject from fognode.types.constants import DEFAULT_HOST, DEFAULT_PORT +from fognode.types.messages import CHAT, WELCOME if TYPE_CHECKING: from fognode.app import Client, Server @@ -37,7 +38,7 @@ def __init__( async def answer(self, text: str) -> None: if self.channel is None: raise RuntimeError("no channel available") - self.channel.send({"type": "chat", "text": text}) + self.channel.send({"t": CHAT, "text": text}) async def send(self, data: dict[str, Any]) -> None: if self.channel is None: @@ -181,7 +182,7 @@ def connect(self) -> None: self._channel = ch welcome = ch.recv() - if welcome.get("type") == "welcome": + if welcome.get("t") == WELCOME: self._process_event(StartEvent(self._channel)) self._process_event(ConnectEvent(self._channel)) diff --git a/src/fognode/auth/handshake.py b/src/fognode/auth/handshake.py index 721bfd9..99433e3 100644 --- a/src/fognode/auth/handshake.py +++ b/src/fognode/auth/handshake.py @@ -10,6 +10,7 @@ from fognode.crypto.password import load_password_key from fognode.crypto.primitives import hkdf_expand, hmac256, hmac256_verify, pbkdf2 from fognode.types.constants import NONCE_LENGTH, TOKEN_LENGTH +from fognode.types.messages import CHALLENGE from fognode.types.exceptions import AuthError, SecurityError from fognode.types.protocol import IPAddress from fognode.utils.ratelimit import RateLimiter @@ -27,7 +28,7 @@ def server_handshake(tls: ssl.SSLSocket, client_ip: IPAddress) -> SecureChannel: wire_send( tls, { - "type": "challenge", + "t": CHALLENGE, "nonce": nonce.hex(), "salt": salt.hex(), "fp": server_fp, @@ -60,7 +61,7 @@ def server_handshake(tls: ssl.SSLSocket, client_ip: IPAddress) -> SecureChannel: def client_handshake(tls: ssl.SSLSocket, password: str, measured_fp: str) -> SecureChannel: chal = wire_recv(tls) - if chal.get("type") != "challenge": + if chal.get("t") != CHALLENGE: raise ConnectionError("expected challenge") nonce = bytes.fromhex(chal["nonce"]) diff --git a/src/fognode/cli/entrypoint.py b/src/fognode/cli/entrypoint.py index 206bde9..07cfa05 100644 --- a/src/fognode/cli/entrypoint.py +++ b/src/fognode/cli/entrypoint.py @@ -15,6 +15,7 @@ from fognode.core.server import start_server from fognode.crypto.cert import cert_info, cert_paths from fognode.types.constants import DEFAULT_HOST, DEFAULT_PORT +from fognode.types.messages import BYE, CHAT, CMD, CMD_INFO, CMD_QUIT, INFO, WELCOME from fognode.types.exceptions import AuthError, SecurityError @@ -124,20 +125,20 @@ def cmd_client(args: argparse.Namespace) -> None: print("═" * 60) welcome = ch.recv() - if welcome.get("type") == "welcome": + if welcome.get("t") == WELCOME: print("[+] connected") def _recv() -> None: while True: try: msg = ch.recv() - mtype = msg.get("type", "") - if mtype == "chat": + mtype = msg.get("t") + if mtype == CHAT: ts = time.strftime("%H:%M", time.localtime(msg.get("ts", 0))) print(f"\r [{ts}] {msg.get('text', '')}") - elif mtype == "info": + elif mtype == INFO: print(f" [info] tls={msg.get('tls')} cipher={msg.get('cipher')}") - elif mtype == "bye": + elif mtype == BYE: print(" [server closed session]") break except Exception: @@ -154,14 +155,14 @@ def _recv() -> None: if line.startswith("/"): cmd = line[1:].strip() if cmd in ("quit", "q", "exit"): - ch.send({"type": "cmd", "cmd": "quit"}) + ch.send({"t": CMD, "cmd": CMD_QUIT}) break elif cmd == "info": - ch.send({"type": "cmd", "cmd": "info"}) + ch.send({"t": CMD, "cmd": CMD_INFO}) else: print(f"[!] unknown command: /{cmd}") else: - ch.send({"type": "chat", "text": line}) + ch.send({"t": CHAT, "text": line}) except KeyboardInterrupt: pass finally: @@ -200,7 +201,7 @@ def cmd_status(args: argparse.Namespace) -> None: tls_ver = ch._sock.version() or "unknown" cipher = ch._sock.cipher() or ["?", "?", 0] - ch.send({"type": "cmd", "cmd": "info"}) + ch.send({"t": CMD, "cmd": CMD_INFO}) info_msg = ch.recv() ch.close() @@ -230,10 +231,10 @@ def cmd_send(args: argparse.Namespace) -> None: sys.exit(f"[!] {e}") welcome = ch.recv() - if welcome.get("type") == "welcome": + if welcome.get("t") == WELCOME: print("[+] connected") - ch.send({"type": "chat", "text": args.text}) + ch.send({"t": CHAT, "text": args.text}) time.sleep(0.5) ch.close() print("[+] sent") @@ -264,16 +265,16 @@ def _recv() -> None: while True: try: msg = ch.recv() - mtype = msg.get("type", "") + mtype = msg.get("t") if args.json: print(json.dumps(msg, indent=None, default=str)) else: - if mtype == "chat": + if mtype == CHAT: ts = time.strftime("%H:%M:%S", time.localtime(msg.get("ts", 0))) print(f"[{ts}] {msg.get('text', '')}") - elif mtype == "info": + elif mtype == INFO: print(f"[info] tls={msg.get('tls')} cipher={msg.get('cipher')}") - elif mtype == "bye": + elif mtype == BYE: print("[server closed session]") break except Exception: diff --git a/src/fognode/core/events.py b/src/fognode/core/events.py index 5cf4b26..7b9d929 100644 --- a/src/fognode/core/events.py +++ b/src/fognode/core/events.py @@ -29,7 +29,7 @@ class DisconnectEvent(BaseEvent): @dataclass class MessageEvent(BaseEvent): - type: str = "" + type: int = -1 text: str = "" ts: float = 0.0 @@ -38,7 +38,7 @@ def from_dict(cls, data: dict[str, Any], channel: SecureChannel | None = None) - return cls( channel=channel, data=data, - type=data.get("type", ""), + type=data.get("t", -1), text=data.get("text", ""), ts=data.get("ts", 0.0), ) diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index bd779a8..88013a3 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -21,6 +21,7 @@ from fognode.crypto.channel import SecureChannel from fognode.crypto.password import store_password from fognode.types.exceptions import AuthError, SecurityError +from fognode.types.messages import CMD, CMD_INFO, INFO, WELCOME from fognode.types.protocol import CodeName, IPAddress, OnConnect, OnDisconnect, OnMessage, Port from fognode.utils.ipwords import ip_to_name from fognode.utils.net import local_ip, local_ipv6 @@ -56,14 +57,14 @@ def _session_loop( if on_connect: on_connect() - ch.send({"type": "welcome"}) + ch.send({"t": WELCOME}) try: while True: msg = ch.recv() - mtype = msg.get("type", "") + mtype = msg.get("t") - if mtype == "cmd": + if mtype == CMD: _handle_cmd(ch, msg) elif on_message: on_message(msg) @@ -76,11 +77,11 @@ def _session_loop( def _handle_cmd(ch: SecureChannel, msg: dict[str, Any]) -> None: - cmd = msg.get("cmd", "") - if cmd == "info": + cmd = msg.get("cmd") + if cmd == CMD_INFO: ch.send( { - "type": "info", + "t": INFO, "server": "fognode/1.0", "time": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), "tls": ch._sock.version(), diff --git a/src/fognode/types/messages.py b/src/fognode/types/messages.py new file mode 100644 index 0000000..2ff7feb --- /dev/null +++ b/src/fognode/types/messages.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +CHALLENGE = 0 + +WELCOME = 1 +PING = 2 +PONG = 3 +CHAT = 4 +CMD = 5 +INFO = 6 +BYE = 7 + +CMD_QUIT = 0 +CMD_INFO = 1 From 77170c52f10438732ba5eec69440d6782c8381da Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:30:13 +0300 Subject: [PATCH 29/36] feat: log probe attempts from nmap/netcat/TLS scanners --- src/fognode/core/server.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index 88013a3..cf39ddd 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -1,12 +1,15 @@ from __future__ import annotations import ipaddress +import logging import socket import ssl import threading import time from typing import Any +_log = logging.getLogger(__name__) + from cryptography import x509 from cryptography.x509.oid import NameOID @@ -102,6 +105,7 @@ def _handle_client( raw.settimeout(2) peek = raw.recv(1, socket.MSG_PEEK) if not peek or peek[0] != 0x16: + _log.warning("probe blocked [%s]: non-TLS byte 0x%02x (nmap/netcat/scanner)", client_ip, peek[0] if peek else 0) raw.close() return except Exception: @@ -114,6 +118,7 @@ def _handle_client( pass if not _rl.check(client_ip): + _log.warning("probe blocked [%s]: rate limited", client_ip) raw.close() return try: @@ -121,9 +126,11 @@ def _handle_client( tls.settimeout(30) ch = server_handshake(tls, client_ip) _session_loop(ch, client_ip, on_connect, on_disconnect, on_message) - except ssl.SSLError: + except ssl.SSLError as e: + _log.warning("probe blocked [%s]: TLS error (%s)", client_ip, e) _rl.fail(client_ip) except AuthError: + _log.warning("probe blocked [%s]: bad auth", client_ip) _rl.fail(client_ip) except (ConnectionError, OSError): pass From f793fbf2e971c9ddf1f6fe7a064dcbfdb678d465 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:37:56 +0300 Subject: [PATCH 30/36] refactor: remove CMD types, client sends INFO directly --- src/fognode/cli/entrypoint.py | 7 +++---- src/fognode/core/server.py | 28 +++++++++++----------------- src/fognode/types/messages.py | 8 ++------ 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/fognode/cli/entrypoint.py b/src/fognode/cli/entrypoint.py index 07cfa05..b6c1322 100644 --- a/src/fognode/cli/entrypoint.py +++ b/src/fognode/cli/entrypoint.py @@ -15,7 +15,7 @@ from fognode.core.server import start_server from fognode.crypto.cert import cert_info, cert_paths from fognode.types.constants import DEFAULT_HOST, DEFAULT_PORT -from fognode.types.messages import BYE, CHAT, CMD, CMD_INFO, CMD_QUIT, INFO, WELCOME +from fognode.types.messages import BYE, CHAT, INFO, WELCOME from fognode.types.exceptions import AuthError, SecurityError @@ -155,10 +155,9 @@ def _recv() -> None: if line.startswith("/"): cmd = line[1:].strip() if cmd in ("quit", "q", "exit"): - ch.send({"t": CMD, "cmd": CMD_QUIT}) break elif cmd == "info": - ch.send({"t": CMD, "cmd": CMD_INFO}) + ch.send({"t": INFO}) else: print(f"[!] unknown command: /{cmd}") else: @@ -201,7 +200,7 @@ def cmd_status(args: argparse.Namespace) -> None: tls_ver = ch._sock.version() or "unknown" cipher = ch._sock.cipher() or ["?", "?", 0] - ch.send({"t": CMD, "cmd": CMD_INFO}) + ch.send({"t": INFO}) info_msg = ch.recv() ch.close() diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index cf39ddd..2d9caa4 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -24,7 +24,7 @@ from fognode.crypto.channel import SecureChannel from fognode.crypto.password import store_password from fognode.types.exceptions import AuthError, SecurityError -from fognode.types.messages import CMD, CMD_INFO, INFO, WELCOME +from fognode.types.messages import INFO, WELCOME from fognode.types.protocol import CodeName, IPAddress, OnConnect, OnDisconnect, OnMessage, Port from fognode.utils.ipwords import ip_to_name from fognode.utils.net import local_ip, local_ipv6 @@ -67,8 +67,16 @@ def _session_loop( msg = ch.recv() mtype = msg.get("t") - if mtype == CMD: - _handle_cmd(ch, msg) + if mtype == INFO: + ch.send( + { + "t": INFO, + "server": "fognode/1.0", + "time": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), + "tls": ch._sock.version(), + "cipher": (ch._sock.cipher() or ["?"])[0], + } + ) elif on_message: on_message(msg) except (ConnectionError, OSError, SecurityError): @@ -79,20 +87,6 @@ def _session_loop( on_disconnect() -def _handle_cmd(ch: SecureChannel, msg: dict[str, Any]) -> None: - cmd = msg.get("cmd") - if cmd == CMD_INFO: - ch.send( - { - "t": INFO, - "server": "fognode/1.0", - "time": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), - "tls": ch._sock.version(), - "cipher": (ch._sock.cipher() or ["?"])[0], - } - ) - - def _handle_client( raw: socket.socket, client_ip: IPAddress, diff --git a/src/fognode/types/messages.py b/src/fognode/types/messages.py index 2ff7feb..8ff1b89 100644 --- a/src/fognode/types/messages.py +++ b/src/fognode/types/messages.py @@ -6,9 +6,5 @@ PING = 2 PONG = 3 CHAT = 4 -CMD = 5 -INFO = 6 -BYE = 7 - -CMD_QUIT = 0 -CMD_INFO = 1 +INFO = 5 +BYE = 6 From ad43861a38057444eb4a3b83a60decb10fccca83 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 19:50:51 +0300 Subject: [PATCH 31/36] feat: add -pV flag for verbose probe logging with hex dump and details --- src/fognode/cli/entrypoint.py | 6 +++ src/fognode/core/server.py | 68 ++++++++++++++++++++++++++++++---- src/fognode/utils/ratelimit.py | 5 +++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/fognode/cli/entrypoint.py b/src/fognode/cli/entrypoint.py index b6c1322..505632b 100644 --- a/src/fognode/cli/entrypoint.py +++ b/src/fognode/cli/entrypoint.py @@ -46,6 +46,10 @@ def cmd_server(args: argparse.Namespace) -> None: if password != confirm: sys.exit("passwords do not match") + if args.probe_verbose: + import logging + logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(levelname)s %(message)s") + t0 = time.perf_counter() display_ip, code_name, fp = start_server( args.host, @@ -53,6 +57,7 @@ def cmd_server(args: argparse.Namespace) -> None: password, app_name=args.name, app_version=args.app_version, + probe_verbose=args.probe_verbose, ) t1 = time.perf_counter() @@ -306,6 +311,7 @@ def main() -> None: p_server.add_argument( "--app-version", default="1.0.0", help="App version embedded in cert subject" ) + p_server.add_argument("-pV", dest="probe_verbose", action="store_true", help="Verbose probe logging") p_server.set_defaults(func=cmd_server) p_client = sub.add_parser("client", help="Run interactive client") diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index 2d9caa4..32fa50b 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -87,6 +87,12 @@ def _session_loop( on_disconnect() +def _hex_dump(data: bytes) -> str: + hex_part = " ".join(f"{b:02x}" for b in data) + asc_part = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in data) + return f"{hex_part:<48} {asc_part}" + + def _handle_client( raw: socket.socket, client_ip: IPAddress, @@ -94,12 +100,33 @@ def _handle_client( on_connect: OnConnect | None, on_disconnect: OnDisconnect | None, on_message: OnMessage | None, + probe_verbose: bool = False, ) -> None: + try: + peer_port = raw.getpeername()[1] + except Exception: + peer_port = 0 + try: raw.settimeout(2) - peek = raw.recv(1, socket.MSG_PEEK) + n_peek = 32 if probe_verbose else 1 + peek = raw.recv(n_peek, socket.MSG_PEEK) if not peek or peek[0] != 0x16: - _log.warning("probe blocked [%s]: non-TLS byte 0x%02x (nmap/netcat/scanner)", client_ip, peek[0] if peek else 0) + if probe_verbose: + _log.warning( + "probe blocked [%s]:%d non-TLS connection\n" + " first byte : 0x%02x\n" + " hex dump : %s\n" + " hint : nmap/netcat/HTTP scanner", + client_ip, peer_port, + peek[0] if peek else 0, + _hex_dump(peek[:32]), + ) + else: + _log.warning( + "probe blocked [%s]: non-TLS byte 0x%02x (nmap/netcat/scanner)", + client_ip, peek[0] if peek else 0, + ) raw.close() return except Exception: @@ -112,19 +139,45 @@ def _handle_client( pass if not _rl.check(client_ip): - _log.warning("probe blocked [%s]: rate limited", client_ip) + attempts = _rl.attempt_count(client_ip) + if probe_verbose: + _log.warning( + "probe blocked [%s]:%d rate limited\n" + " attempts : %d in window\n" + " window : %ds", + client_ip, peer_port, attempts, _rl.window, + ) + else: + _log.warning("probe blocked [%s]: rate limited", client_ip) raw.close() return + try: with ctx.wrap_socket(raw, server_side=True) as tls: tls.settimeout(30) ch = server_handshake(tls, client_ip) _session_loop(ch, client_ip, on_connect, on_disconnect, on_message) except ssl.SSLError as e: - _log.warning("probe blocked [%s]: TLS error (%s)", client_ip, e) + if probe_verbose: + _log.warning( + "probe blocked [%s]:%d TLS error\n" + " error : %s\n" + " hint : TLS scanner / wrong client / fuzzer", + client_ip, peer_port, e, + ) + else: + _log.warning("probe blocked [%s]: TLS error (%s)", client_ip, e) _rl.fail(client_ip) - except AuthError: - _log.warning("probe blocked [%s]: bad auth", client_ip) + except AuthError as e: + if probe_verbose: + _log.warning( + "probe blocked [%s]:%d auth failed\n" + " reason : %s\n" + " hint : wrong password or fingerprint mismatch", + client_ip, peer_port, e, + ) + else: + _log.warning("probe blocked [%s]: bad auth", client_ip) _rl.fail(client_ip) except (ConnectionError, OSError): pass @@ -144,6 +197,7 @@ def start_server( on_message: OnMessage | None = None, app_name: str = "fognode", app_version: str = "1.0.0", + probe_verbose: bool = False, ) -> tuple[IPAddress, CodeName, str]: display_ip = _resolve_display_ip(host) code_name = ip_to_name(display_ip) @@ -193,7 +247,7 @@ def _serve() -> None: break threading.Thread( target=_handle_client, - args=(conn, addr[0], ctx, on_connect, on_disconnect, on_message), + args=(conn, addr[0], ctx, on_connect, on_disconnect, on_message, probe_verbose), daemon=True, ).start() diff --git a/src/fognode/utils/ratelimit.py b/src/fognode/utils/ratelimit.py index 93c22d7..dcf9280 100644 --- a/src/fognode/utils/ratelimit.py +++ b/src/fognode/utils/ratelimit.py @@ -41,3 +41,8 @@ def ok(self, ip: str) -> None: with self._lock: self._attempts.pop(ip, None) self._blocked.pop(ip, None) + + def attempt_count(self, ip: str) -> int: + now = time.time() + with self._lock: + return len([t for t in self._attempts.get(ip, []) if now - t < self.window]) From 1fb70d3161b7c7c95b9c681852b1456567fd62a2 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 20:01:40 +0300 Subject: [PATCH 32/36] feat: update issuer cn, o, ou --- src/fognode/crypto/cert.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fognode/crypto/cert.py b/src/fognode/crypto/cert.py index a1a695c..5c4e42e 100644 --- a/src/fognode/crypto/cert.py +++ b/src/fognode/crypto/cert.py @@ -49,9 +49,9 @@ def generate_cert(cn: str, app_name: str = "fognode", app_version: str = "1.0.0" ) issuer = x509.Name( [ - x509.NameAttribute(NameOID.COMMON_NAME, cn), + x509.NameAttribute(NameOID.COMMON_NAME, "reekeer"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "fognode"), - x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f"fognode_v{fognode_version()}"), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f"v{fognode_version()}"), ] ) now = datetime.datetime.now(datetime.timezone.utc) From 102ec2eaabf502f22f9016ac75fa708736e63a09 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 16 May 2026 17:04:13 +0000 Subject: [PATCH 33/36] docs: update CHANGELOG.md for PR #4 --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6204e..462c593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,61 @@ and this project adheres to Semantic Versioning. + +## [dev] - 2026-05-16 + +### Added + +- add IPv6 support to ipwords with auto-detect by name length ([`e182eeb`](https://github.com/reekeer/fognode/commit/e182eeb45ce959da67f4e8b1aa9c5870f9674151)) +- add local_ipv6() to net utils ([`5ccba84`](https://github.com/reekeer/fognode/commit/5ccba84a4f6b9d4bba3356c6e944182f98fea1f0)) +- add IPv6 server support (AF_INET6, auto-detect by host) ([`74897a7`](https://github.com/reekeer/fognode/commit/74897a74359aae5d8ec6e8f026dcabb68b2021f1)) +- add app name/version to cert subject, fognode version in issuer OU ([`609d7ac`](https://github.com/reekeer/fognode/commit/609d7ac640df700bd88d0a06617414715d86967a)) +- add app_name/app_version to start_server, regenerate cert on change ([`1bc0a7a`](https://github.com/reekeer/fognode/commit/1bc0a7a6e03e1b25710855098a075024d6989906)) +- add name/version params to Server class ([`384fb9a`](https://github.com/reekeer/fognode/commit/384fb9ac1de996076e50e0390467b864f09f7027)) +- add --name and --app-version to server CLI command ([`f809f80`](https://github.com/reekeer/fognode/commit/f809f8075dc88780f244165bf09ca64ce431922c)) +- check server/client fognode version compatibility on connect ([`83e62d8`](https://github.com/reekeer/fognode/commit/83e62d8e14905d591bd9891cf3ae60b631614bfb)) +- return and display server app info on client connect ([`69ed832`](https://github.com/reekeer/fognode/commit/69ed832e7825a2a92bedf09f3153189f6b360890)) +- add msgpack dependency ([`fb4e14c`](https://github.com/reekeer/fognode/commit/fb4e14c380b118fa7e396e4d0baf72a53cc7385a)) +- replace json with msgpack in SecureChannel ([`4667ec2`](https://github.com/reekeer/fognode/commit/4667ec2fe3627adcbc8b1bc6bcaa9cf5747035b6)) +- replace json with msgpack in wire framing ([`da52402`](https://github.com/reekeer/fognode/commit/da52402825f132245bb2d65df41eaa79bc855471)) +- add custom msgpack implementation with tests ([`a85ee4f`](https://github.com/reekeer/fognode/commit/a85ee4fe1219a362d3b293b3e00d72333a4de6de)) +- switch channel to local msgpack ([`e9e5f04`](https://github.com/reekeer/fognode/commit/e9e5f045a18f936e157d0e887f8edfd07de5c102)) +- switch framing to local msgpack ([`7b39f3a`](https://github.com/reekeer/fognode/commit/7b39f3a3d7e9f9a29dcf2dc092226663f0e2ab1a)) +- replace external msgpack with local implementation ([`8c187d5`](https://github.com/reekeer/fognode/commit/8c187d5ce0d115f26432dc919147caa24b6b3c79)) +- introduce integer message type constants, replace string types protocol-wide ([`b6d764e`](https://github.com/reekeer/fognode/commit/b6d764e2dab0b90ab1719928be3573b2bfb9f3ce)) +- log probe attempts from nmap/netcat/TLS scanners ([`77170c5`](https://github.com/reekeer/fognode/commit/77170c52f10438732ba5eec69440d6782c8381da)) +- add -pV flag for verbose probe logging with hex dump and details ([`ad43861`](https://github.com/reekeer/fognode/commit/ad43861a38057444eb4a3b83a60decb10fccca83)) +- update issuer cn, o, ou ([`1fb70d3`](https://github.com/reekeer/fognode/commit/1fb70d3161b7c7c95b9c681852b1456567fd62a2)) + +### Fixed + +- use getaddrinfo to find real local IPv6 address ([`af2c9d1`](https://github.com/reekeer/fognode/commit/af2c9d11c9e4222471e2a7b389b6e18a0eba1a1d)) +- regenerate cert when fognode version changes in issuer OU ([`d3ad962`](https://github.com/reekeer/fognode/commit/d3ad9621f9c32d0e47735b75a1b7685c8b6e52b6)) +- fall back to IPv4 when no routable IPv6, use dual-stack socket for :: ([`0a94949`](https://github.com/reekeer/fognode/commit/0a9494959001bf71a61538ffe4a3430d81bf20db)) +- remove chain verification for non-self-signed cert, rely on fp in handshake ([`460594b`](https://github.com/reekeer/fognode/commit/460594b6c651ab2d59f4fc6eb2f478ccb575a824)) +- correct iv_p state update in _ige_decrypt for multi-block messages ([`43e02f5`](https://github.com/reekeer/fognode/commit/43e02f5662108ea13fadd55469784f941e26e705)) +- update cert regeneration check to match new issuer format ([`45e3a84`](https://github.com/reekeer/fognode/commit/45e3a841cd68a4386852e3c6ba94d14eee696f35)) +- update version compat check to use new issuer OU prefix ([`65bc563`](https://github.com/reekeer/fognode/commit/65bc563f3f511a7aa300e03cd3586338e5f5a6db)) + +### Changed + +- remove CMD types, client sends INFO directly ([`f793fbf`](https://github.com/reekeer/fognode/commit/f793fbf2e971c9ddf1f6fe7a064dcbfdb678d465)) + +### Changed + +- update AGENTS.md and README for new name format and IPv6 ([`02113ef`](https://github.com/reekeer/fognode/commit/02113ef90b91c0f3bd0eed35a2017a94bca4491f)) + +### Changed + +- add IPv6 roundtrip, boundary, and auto-detect tests ([`29d13ce`](https://github.com/reekeer/fognode/commit/29d13ce96a0ee81915f6c23523abbcd60eac563f)) +- update framing tests for msgpack ([`4559a16`](https://github.com/reekeer/fognode/commit/4559a16a00b7173d3755a35854204f42b2efffd8)) + +### Changed + +- suppress pyright reportMissingImports for msgpack ([`f51c0b7`](https://github.com/reekeer/fognode/commit/f51c0b7fe7e4e8015cf2df6151edfa1c71b7e8ab)) + + + ## [dev] - 2026-05-16 ### Added From 63159dcf3723d203a9ccc1555dee1a8af1902a4a Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 20:25:29 +0300 Subject: [PATCH 34/36] fix: restore valid pytest.mark.parametrize arg names after linter mangled them --- tests/test_msgpack.py | 91 +++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/tests/test_msgpack.py b/tests/test_msgpack.py index dfafa8e..1c3dc9a 100644 --- a/tests/test_msgpack.py +++ b/tests/test_msgpack.py @@ -1,89 +1,89 @@ from __future__ import annotations -import math -import struct +import json +from typing import Any import pytest from fognode.wire.msgpack import packb, unpackb -def rt(v): +def rt(v: Any) -> Any: return unpackb(packb(v)) class TestNil: - def test_none(self): + def test_none(self) -> None: assert packb(None) == b"\xc0" assert rt(None) is None class TestBool: - def test_true(self): + def test_true(self) -> None: assert packb(True) == b"\xc3" assert rt(True) is True - def test_false(self): + def test_false(self) -> None: assert packb(False) == b"\xc2" assert rt(False) is False - def test_bool_not_int(self): + def test_bool_not_int(self) -> None: assert isinstance(rt(True), bool) assert isinstance(rt(False), bool) class TestInt: @pytest.mark.parametrize("n", [0, 1, 63, 127]) - def test_positive_fixint(self, n): + def test_positive_fixint(self, n: int) -> None: assert packb(n) == bytes([n]) assert rt(n) == n @pytest.mark.parametrize("n", [-1, -16, -32]) - def test_negative_fixint(self, n): + def test_negative_fixint(self, n: int) -> None: assert len(packb(n)) == 1 assert rt(n) == n @pytest.mark.parametrize("n", [128, 200, 255]) - def test_uint8(self, n): + def test_uint8(self, n: int) -> None: assert packb(n)[0] == 0xCC assert rt(n) == n @pytest.mark.parametrize("n", [256, 1000, 65535]) - def test_uint16(self, n): + def test_uint16(self, n: int) -> None: assert packb(n)[0] == 0xCD assert rt(n) == n @pytest.mark.parametrize("n", [65536, 2**24, 2**32 - 1]) - def test_uint32(self, n): + def test_uint32(self, n: int) -> None: assert packb(n)[0] == 0xCE assert rt(n) == n @pytest.mark.parametrize("n", [2**32, 2**48, 2**64 - 1]) - def test_uint64(self, n): + def test_uint64(self, n: int) -> None: assert packb(n)[0] == 0xCF assert rt(n) == n @pytest.mark.parametrize("n", [-33, -100, -128]) - def test_int8(self, n): + def test_int8(self, n: int) -> None: assert packb(n)[0] == 0xD0 assert rt(n) == n @pytest.mark.parametrize("n", [-129, -1000, -32768]) - def test_int16(self, n): + def test_int16(self, n: int) -> None: assert packb(n)[0] == 0xD1 assert rt(n) == n @pytest.mark.parametrize("n", [-32769, -(2**24), -(2**31)]) - def test_int32(self, n): + def test_int32(self, n: int) -> None: assert packb(n)[0] == 0xD2 assert rt(n) == n @pytest.mark.parametrize("n", [-(2**31) - 1, -(2**48), -(2**63)]) - def test_int64(self, n): + def test_int64(self, n: int) -> None: assert packb(n)[0] == 0xD3 assert rt(n) == n - def test_overflow(self): + def test_overflow(self) -> None: with pytest.raises(OverflowError): packb(2**64) with pytest.raises(OverflowError): @@ -91,123 +91,122 @@ def test_overflow(self): class TestFloat: - def test_float64(self): + def test_float64(self) -> None: assert packb(3.14)[0] == 0xCB assert abs(rt(3.14) - 3.14) < 1e-10 - def test_zero(self): + def test_zero(self) -> None: assert rt(0.0) == 0.0 - def test_negative(self): + def test_negative(self) -> None: assert abs(rt(-1.5) - (-1.5)) < 1e-10 class TestStr: - def test_empty(self): + def test_empty(self) -> None: assert rt("") == "" @pytest.mark.parametrize("s", ["a", "hello", "x" * 31]) - def test_fixstr(self, s): + def test_fixstr(self, s: str) -> None: assert packb(s)[0] == (0xA0 | len(s.encode())) assert rt(s) == s - def test_str8(self): + def test_str8(self) -> None: s = "x" * 32 assert packb(s)[0] == 0xD9 assert rt(s) == s - def test_str16(self): + def test_str16(self) -> None: s = "x" * 256 assert packb(s)[0] == 0xDA assert rt(s) == s - def test_unicode(self): + def test_unicode(self) -> None: s = "привет 🔥" assert rt(s) == s class TestBin: - def test_empty(self): + def test_empty(self) -> None: assert rt(b"") == b"" - def test_bin8(self): + def test_bin8(self) -> None: b = bytes(range(16)) assert packb(b)[0] == 0xC4 assert rt(b) == b - def test_bin_roundtrip(self): + def test_bin_roundtrip(self) -> None: b = bytes(range(256)) assert rt(b) == b class TestArray: - def test_empty(self): + def test_empty(self) -> None: assert rt([]) == [] - def test_fixarray(self): + def test_fixarray(self) -> None: arr = [1, 2, 3] assert packb(arr)[0] == (0x90 | 3) assert rt(arr) == arr - def test_nested(self): + def test_nested(self) -> None: arr = [[1, 2], [3, [4, 5]]] assert rt(arr) == arr - def test_mixed(self): + def test_mixed(self) -> None: arr = [None, True, 42, "hi", b"\xff"] assert rt(arr) == arr - def test_array16(self): + def test_array16(self) -> None: arr = list(range(16)) assert packb(arr)[0] == 0xDC assert rt(arr) == arr class TestMap: - def test_empty(self): + def test_empty(self) -> None: assert rt({}) == {} - def test_fixmap(self): + def test_fixmap(self) -> None: d = {"t": 1, "text": "hello"} assert packb(d)[0] == (0x80 | 2) assert rt(d) == d - def test_int_keys(self): + def test_int_keys(self) -> None: d = {0: "welcome", 1: "chat"} assert rt(d) == d - def test_nested(self): + def test_nested(self) -> None: d = {"a": {"b": {"c": 42}}} assert rt(d) == d - def test_map16(self): + def test_map16(self) -> None: d = {str(i): i for i in range(16)} assert packb(d)[0] == 0xDE assert rt(d) == d class TestProtocolMessages: - def test_welcome(self): + def test_welcome(self) -> None: assert rt({"t": 1}) == {"t": 1} - def test_chat(self): + def test_chat(self) -> None: msg = {"t": 4, "text": "hello world", "ts": 1234567890} assert rt(msg) == msg - def test_cmd(self): + def test_cmd(self) -> None: assert rt({"t": 5, "cmd": 1}) == {"t": 5, "cmd": 1} - def test_compact_vs_json(self): - import json + def test_compact_vs_json(self) -> None: msg = {"t": 4, "text": "hello world", "ts": 1234567890} mp_size = len(packb(msg)) js_size = len(json.dumps(msg).encode()) assert mp_size < js_size - def test_unsupported_type(self): + def test_unsupported_type(self) -> None: with pytest.raises(TypeError): packb(object()) - def test_unknown_byte(self): + def test_unknown_byte(self) -> None: with pytest.raises((ValueError, Exception)): unpackb(b"\xc1") From 87103dcce34b6cba2cb66bd40aba03c7f73ba8da Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 20:27:06 +0300 Subject: [PATCH 35/36] style: fix black, ruff --- src/fognode/auth/handshake.py | 2 +- src/fognode/cli/entrypoint.py | 7 +++++-- src/fognode/core/client.py | 4 +--- src/fognode/core/server.py | 22 +++++++++++++++------- src/fognode/crypto/channel.py | 3 +-- src/fognode/wire/msgpack.py | 18 ++++++++++++------ 6 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/fognode/auth/handshake.py b/src/fognode/auth/handshake.py index 99433e3..d34c5db 100644 --- a/src/fognode/auth/handshake.py +++ b/src/fognode/auth/handshake.py @@ -10,8 +10,8 @@ from fognode.crypto.password import load_password_key from fognode.crypto.primitives import hkdf_expand, hmac256, hmac256_verify, pbkdf2 from fognode.types.constants import NONCE_LENGTH, TOKEN_LENGTH -from fognode.types.messages import CHALLENGE from fognode.types.exceptions import AuthError, SecurityError +from fognode.types.messages import CHALLENGE from fognode.types.protocol import IPAddress from fognode.utils.ratelimit import RateLimiter from fognode.wire.framing import wire_recv, wire_send diff --git a/src/fognode/cli/entrypoint.py b/src/fognode/cli/entrypoint.py index 505632b..d6ff436 100644 --- a/src/fognode/cli/entrypoint.py +++ b/src/fognode/cli/entrypoint.py @@ -15,8 +15,8 @@ from fognode.core.server import start_server from fognode.crypto.cert import cert_info, cert_paths from fognode.types.constants import DEFAULT_HOST, DEFAULT_PORT -from fognode.types.messages import BYE, CHAT, INFO, WELCOME from fognode.types.exceptions import AuthError, SecurityError +from fognode.types.messages import BYE, CHAT, INFO, WELCOME def _banner(title: str) -> None: @@ -48,6 +48,7 @@ def cmd_server(args: argparse.Namespace) -> None: if args.probe_verbose: import logging + logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(levelname)s %(message)s") t0 = time.perf_counter() @@ -311,7 +312,9 @@ def main() -> None: p_server.add_argument( "--app-version", default="1.0.0", help="App version embedded in cert subject" ) - p_server.add_argument("-pV", dest="probe_verbose", action="store_true", help="Verbose probe logging") + p_server.add_argument( + "-pV", dest="probe_verbose", action="store_true", help="Verbose probe logging" + ) p_server.set_defaults(func=cmd_server) p_client = sub.add_parser("client", help="Run interactive client") diff --git a/src/fognode/core/client.py b/src/fognode/core/client.py index 4a1da50..09efb29 100644 --- a/src/fognode/core/client.py +++ b/src/fognode/core/client.py @@ -22,9 +22,7 @@ def _check_compat(der: bytes) -> None: server_ver = ou[len("v") :] client_ver = fognode_version() if server_ver.split(".")[0] != client_ver.split(".")[0]: - raise ConnectionError( - f"incompatible fognode version: server={ou}, client=v{client_ver}" - ) + raise ConnectionError(f"incompatible fognode version: server={ou}, client=v{client_ver}") def client_connect( diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index 32fa50b..f22b669 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -8,8 +8,6 @@ import time from typing import Any -_log = logging.getLogger(__name__) - from cryptography import x509 from cryptography.x509.oid import NameOID @@ -31,6 +29,7 @@ from fognode.utils.ratelimit import RateLimiter _rl = RateLimiter() +_log = logging.getLogger(__name__) def _socket_family(host: str) -> socket.AddressFamily: @@ -118,14 +117,16 @@ def _handle_client( " first byte : 0x%02x\n" " hex dump : %s\n" " hint : nmap/netcat/HTTP scanner", - client_ip, peer_port, + client_ip, + peer_port, peek[0] if peek else 0, _hex_dump(peek[:32]), ) else: _log.warning( "probe blocked [%s]: non-TLS byte 0x%02x (nmap/netcat/scanner)", - client_ip, peek[0] if peek else 0, + client_ip, + peek[0] if peek else 0, ) raw.close() return @@ -145,7 +146,10 @@ def _handle_client( "probe blocked [%s]:%d rate limited\n" " attempts : %d in window\n" " window : %ds", - client_ip, peer_port, attempts, _rl.window, + client_ip, + peer_port, + attempts, + _rl.window, ) else: _log.warning("probe blocked [%s]: rate limited", client_ip) @@ -163,7 +167,9 @@ def _handle_client( "probe blocked [%s]:%d TLS error\n" " error : %s\n" " hint : TLS scanner / wrong client / fuzzer", - client_ip, peer_port, e, + client_ip, + peer_port, + e, ) else: _log.warning("probe blocked [%s]: TLS error (%s)", client_ip, e) @@ -174,7 +180,9 @@ def _handle_client( "probe blocked [%s]:%d auth failed\n" " reason : %s\n" " hint : wrong password or fingerprint mismatch", - client_ip, peer_port, e, + client_ip, + peer_port, + e, ) else: _log.warning("probe blocked [%s]: bad auth", client_ip) diff --git a/src/fognode/crypto/channel.py b/src/fognode/crypto/channel.py index 6067458..f52c2c9 100644 --- a/src/fognode/crypto/channel.py +++ b/src/fognode/crypto/channel.py @@ -4,11 +4,10 @@ from threading import Lock from typing import Any -from fognode.wire.msgpack import packb, unpackb - from fognode.cipher import decrypt, encrypt from fognode.types.constants import MAX_MESSAGE_SIZE from fognode.types.exceptions import SecurityError +from fognode.wire.msgpack import packb, unpackb class SecureChannel: diff --git a/src/fognode/wire/msgpack.py b/src/fognode/wire/msgpack.py index 376f6ea..90318c1 100644 --- a/src/fognode/wire/msgpack.py +++ b/src/fognode/wire/msgpack.py @@ -144,22 +144,28 @@ def _unpack(data: bytes, pos: int) -> tuple[Any, int]: if b == 0xD3: return struct.unpack_from(">q", data, pos)[0], pos + 8 if b == 0xD9: - n = data[pos]; pos += 1 + n = data[pos] + pos += 1 return data[pos : pos + n].decode("utf-8"), pos + n if b == 0xDA: - n = struct.unpack_from(">H", data, pos)[0]; pos += 2 + n = struct.unpack_from(">H", data, pos)[0] + pos += 2 return data[pos : pos + n].decode("utf-8"), pos + n if b == 0xDB: - n = struct.unpack_from(">I", data, pos)[0]; pos += 4 + n = struct.unpack_from(">I", data, pos)[0] + pos += 4 return data[pos : pos + n].decode("utf-8"), pos + n if b == 0xC4: - n = data[pos]; pos += 1 + n = data[pos] + pos += 1 return data[pos : pos + n], pos + n if b == 0xC5: - n = struct.unpack_from(">H", data, pos)[0]; pos += 2 + n = struct.unpack_from(">H", data, pos)[0] + pos += 2 return data[pos : pos + n], pos + n if b == 0xC6: - n = struct.unpack_from(">I", data, pos)[0]; pos += 4 + n = struct.unpack_from(">I", data, pos)[0] + pos += 4 return data[pos : pos + n], pos + n if b == 0xDC: n = struct.unpack_from(">H", data, pos)[0] From cf9099a54b0b2c8b4eab1abc85949e2242a0d055 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 16 May 2026 17:27:19 +0000 Subject: [PATCH 36/36] docs: update CHANGELOG.md for PR #4 --- CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 462c593..661a1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,68 @@ and this project adheres to Semantic Versioning. + +## [dev] - 2026-05-16 + +### Added + +- add IPv6 support to ipwords with auto-detect by name length ([`e182eeb`](https://github.com/reekeer/fognode/commit/e182eeb45ce959da67f4e8b1aa9c5870f9674151)) +- add local_ipv6() to net utils ([`5ccba84`](https://github.com/reekeer/fognode/commit/5ccba84a4f6b9d4bba3356c6e944182f98fea1f0)) +- add IPv6 server support (AF_INET6, auto-detect by host) ([`74897a7`](https://github.com/reekeer/fognode/commit/74897a74359aae5d8ec6e8f026dcabb68b2021f1)) +- add app name/version to cert subject, fognode version in issuer OU ([`609d7ac`](https://github.com/reekeer/fognode/commit/609d7ac640df700bd88d0a06617414715d86967a)) +- add app_name/app_version to start_server, regenerate cert on change ([`1bc0a7a`](https://github.com/reekeer/fognode/commit/1bc0a7a6e03e1b25710855098a075024d6989906)) +- add name/version params to Server class ([`384fb9a`](https://github.com/reekeer/fognode/commit/384fb9ac1de996076e50e0390467b864f09f7027)) +- add --name and --app-version to server CLI command ([`f809f80`](https://github.com/reekeer/fognode/commit/f809f8075dc88780f244165bf09ca64ce431922c)) +- check server/client fognode version compatibility on connect ([`83e62d8`](https://github.com/reekeer/fognode/commit/83e62d8e14905d591bd9891cf3ae60b631614bfb)) +- return and display server app info on client connect ([`69ed832`](https://github.com/reekeer/fognode/commit/69ed832e7825a2a92bedf09f3153189f6b360890)) +- add msgpack dependency ([`fb4e14c`](https://github.com/reekeer/fognode/commit/fb4e14c380b118fa7e396e4d0baf72a53cc7385a)) +- replace json with msgpack in SecureChannel ([`4667ec2`](https://github.com/reekeer/fognode/commit/4667ec2fe3627adcbc8b1bc6bcaa9cf5747035b6)) +- replace json with msgpack in wire framing ([`da52402`](https://github.com/reekeer/fognode/commit/da52402825f132245bb2d65df41eaa79bc855471)) +- add custom msgpack implementation with tests ([`a85ee4f`](https://github.com/reekeer/fognode/commit/a85ee4fe1219a362d3b293b3e00d72333a4de6de)) +- switch channel to local msgpack ([`e9e5f04`](https://github.com/reekeer/fognode/commit/e9e5f045a18f936e157d0e887f8edfd07de5c102)) +- switch framing to local msgpack ([`7b39f3a`](https://github.com/reekeer/fognode/commit/7b39f3a3d7e9f9a29dcf2dc092226663f0e2ab1a)) +- replace external msgpack with local implementation ([`8c187d5`](https://github.com/reekeer/fognode/commit/8c187d5ce0d115f26432dc919147caa24b6b3c79)) +- introduce integer message type constants, replace string types protocol-wide ([`b6d764e`](https://github.com/reekeer/fognode/commit/b6d764e2dab0b90ab1719928be3573b2bfb9f3ce)) +- log probe attempts from nmap/netcat/TLS scanners ([`77170c5`](https://github.com/reekeer/fognode/commit/77170c52f10438732ba5eec69440d6782c8381da)) +- add -pV flag for verbose probe logging with hex dump and details ([`ad43861`](https://github.com/reekeer/fognode/commit/ad43861a38057444eb4a3b83a60decb10fccca83)) +- update issuer cn, o, ou ([`1fb70d3`](https://github.com/reekeer/fognode/commit/1fb70d3161b7c7c95b9c681852b1456567fd62a2)) + +### Fixed + +- use getaddrinfo to find real local IPv6 address ([`af2c9d1`](https://github.com/reekeer/fognode/commit/af2c9d11c9e4222471e2a7b389b6e18a0eba1a1d)) +- regenerate cert when fognode version changes in issuer OU ([`d3ad962`](https://github.com/reekeer/fognode/commit/d3ad9621f9c32d0e47735b75a1b7685c8b6e52b6)) +- fall back to IPv4 when no routable IPv6, use dual-stack socket for :: ([`0a94949`](https://github.com/reekeer/fognode/commit/0a9494959001bf71a61538ffe4a3430d81bf20db)) +- remove chain verification for non-self-signed cert, rely on fp in handshake ([`460594b`](https://github.com/reekeer/fognode/commit/460594b6c651ab2d59f4fc6eb2f478ccb575a824)) +- correct iv_p state update in _ige_decrypt for multi-block messages ([`43e02f5`](https://github.com/reekeer/fognode/commit/43e02f5662108ea13fadd55469784f941e26e705)) +- update cert regeneration check to match new issuer format ([`45e3a84`](https://github.com/reekeer/fognode/commit/45e3a841cd68a4386852e3c6ba94d14eee696f35)) +- update version compat check to use new issuer OU prefix ([`65bc563`](https://github.com/reekeer/fognode/commit/65bc563f3f511a7aa300e03cd3586338e5f5a6db)) +- restore valid pytest.mark.parametrize arg names after linter mangled them ([`63159dc`](https://github.com/reekeer/fognode/commit/63159dcf3723d203a9ccc1555dee1a8af1902a4a)) + +### Changed + +- remove CMD types, client sends INFO directly ([`f793fbf`](https://github.com/reekeer/fognode/commit/f793fbf2e971c9ddf1f6fe7a064dcbfdb678d465)) + +### Changed + +- update AGENTS.md and README for new name format and IPv6 ([`02113ef`](https://github.com/reekeer/fognode/commit/02113ef90b91c0f3bd0eed35a2017a94bca4491f)) +- update CHANGELOG.md for PR #4 ([`102ec2e`](https://github.com/reekeer/fognode/commit/102ec2eaabf502f22f9016ac75fa708736e63a09)) + +### Changed + +- add IPv6 roundtrip, boundary, and auto-detect tests ([`29d13ce`](https://github.com/reekeer/fognode/commit/29d13ce96a0ee81915f6c23523abbcd60eac563f)) +- update framing tests for msgpack ([`4559a16`](https://github.com/reekeer/fognode/commit/4559a16a00b7173d3755a35854204f42b2efffd8)) + +### Changed + +- suppress pyright reportMissingImports for msgpack ([`f51c0b7`](https://github.com/reekeer/fognode/commit/f51c0b7fe7e4e8015cf2df6151edfa1c71b7e8ab)) +- fix black, ruff ([`87103dc`](https://github.com/reekeer/fognode/commit/87103dcce34b6cba2cb66bd40aba03c7f73ba8da)) + +### Changed + +- Merge 1fb70d3161b7c7c95b9c681852b1456567fd62a2 into 7ff2eecebadf1dc4240b2c4edd2ace9abf33cebf ([`7f436de`](https://github.com/reekeer/fognode/commit/7f436de7ba2565fbaa90c779ab66291217f9a335)) + + + ## [dev] - 2026-05-16 ### Added