From 3c601eded9dcb68a492042efb338b9921b922618 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 13:41:42 +0300 Subject: [PATCH 1/5] refactor: remove chat history and user concept from protocol, server, handshake --- AGENTS.md | 213 ++++++++++++++++++++++++++++++++++ src/fognode/auth/handshake.py | 16 +-- src/fognode/core/server.py | 65 ++--------- src/fognode/types/protocol.py | 61 +--------- 4 files changed, 234 insertions(+), 121 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..80936b5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,213 @@ +# fognode — Agent Guide + +This file describes the project structure, conventions, and workflows for AI coding agents working on `fognode`. + +## Project Overview + +`fognode` is a Python library and CLI tool for headless secure encrypted data transmission. It provides both a high-level **aiogram-style** async API (`App`, `Router`, decorators) and a low-level **classic API** (`start_server`, `client_connect`). + +Security stack: +- TLS 1.2+ for transport +- X25519 ephemeral key exchange +- AESGCM-256 + HMAC-SHA256 for the encrypted channel +- PBKDF2 for password derivation +- HKDF for key expansion +- Self-signed RSA-4096 certificates with SHA-256 fingerprint verification +- Replay protection (counter + timestamp window) +- Per-IP rate limiting + +The project is published to PyPI as `fognode`. + +## Technology Stack + +- **Language**: Python 3.10+ +- **Build backend**: Hatchling + hatch-vcs (version from git tags) +- **Package manager**: `pip` / `uv` (a virtual environment `.venv` is present) +- **Core dependency**: `cryptography>=44.0.0` +- **Async runtime**: `asyncio` (high-level API) + `threading` (network I/O layer) + +## Project Structure + +``` +src/fognode/ +├── __init__.py # Public API exports +├── __main__.py # python -m fognode +├── app.py # App, Context, Message, Router (aiogram-style) +├── cipher.py # (does not exist; Cipher lives in types/constants.py) +├── context.py # (does not exist; Context lives in app.py) +├── message.py # (does not exist; Message lives in app.py) +├── router.py # (does not exist; Router lives in app.py) +├── decorators.py # @retry, @rate_limited, @timed +├── exceptions.py # Re-exports from types.exceptions +├── auth/ +│ └── handshake.py # Server/client handshake over TLS +├── ciphers/ # Thin re-export stubs (crypto primitives) +├── cli/ +│ └── entrypoint.py # argparse CLI: server, client, cert, probe, status, send, monitor +├── core/ +│ ├── client.py # client_connect() +│ ├── probe.py # probe_server() +│ ├── server.py # start_server() +│ └── state.py # ChatState (in-memory sessions & history) +├── crypto/ +│ ├── cert.py # Self-signed cert generation + SSL contexts +│ ├── channel.py # SecureChannel (AESGCM + HMAC framing) +│ ├── kdf.py # Session key helpers +│ ├── kx.py # X25519 keypair + shared secret +│ ├── password.py # PBKDF2 password store (fognode_passwd.bin) +│ └── primitives.py # pbkdf2, hmac256, hkdf_expand, aes_encrypt, aes_decrypt +├── filters/ +│ ├── base.py # BaseFilter +│ ├── command.py # Command filter (/cmd matching) +│ └── text.py # Text filter +├── handlers/ +│ └── handler.py # HandlerObject (callback + filter runner) +├── types/ +│ ├── constants.py # Defaults, limits, Cipher enum +│ ├── exceptions.py # SecurityError, AuthError, etc. +│ └── protocol.py # TypedDicts, dataclasses, Protocols +├── utils/ +│ ├── ipwords.py # IP ↔ 4-word codename conversion +│ ├── net.py # local_ip() +│ └── ratelimit.py # RateLimiter +└── wire/ + └── framing.py # Length-prefixed JSON send/recv + +tests/ +├── conftest.py # Shared pytest fixtures (e.g. password fixture) +├── test_crypto.py # Primitives + password store tests +├── test_framing.py # wire_send / wire_recv tests +├── test_ipwords.py # Roundtrip + validation tests +└── test_ratelimit.py # RateLimiter behavior tests + +examples/ +└── headless_server.py # Minimal classic-API example +``` + +**Important**: `cipher.py`, `context.py`, `message.py`, and `router.py` do **not** exist as standalone files. `Cipher`, `Context`, `Message`, and `Router` are all defined inside `src/fognode/app.py`. + +## Build, Install, and Run + +### Create / activate virtual environment +```bash +uv venv +source .venv/bin/activate +``` + +### Install in editable mode with dev dependencies +```bash +pip install -e ".[dev]" +``` + +### Run tests +```bash +pytest tests/ -v --tb=short +``` + +### Lint and format +```bash +ruff check src/ tests/ +black --check src/ tests/ +pyright src/ +``` + +### Auto-fix with ruff and black +```bash +ruff check --fix src/ tests/ +black src/ tests/ +``` + +### Build wheel/sdist +```bash +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 +``` + +## Code Style Guidelines + +- **Line length**: 100 characters (Black + Ruff). +- **Python target**: 3.10+ (`from __future__ import annotations` is used everywhere). +- **Formatter**: `black`. +- **Linter**: `ruff` with rules `E`, `F`, `I`, `UP`, `B`. Ignores: `E501`, `B008`, `UP006`, `UP007`, `B007`, `UP035`. +- **Type checker**: `pyright` in **strict** mode, but many strict reports are silenced (`reportUnknownMemberType`, `reportUnknownVariableType`, etc.) to reduce noise. +- **Import style**: Use `from __future__ import annotations` in every file. Use `TYPE_CHECKING` blocks for circular imports. +- **Naming**: `snake_case` for functions/variables, `PascalCase` for classes, `UPPER_CASE` for constants. +- **String formatting**: Prefer f-strings. + +## Testing Strategy + +- **Framework**: `pytest` + `pytest-asyncio` (`asyncio_mode = auto`). +- **Test locations**: `tests/` directory. +- **Fixtures**: Defined in `tests/conftest.py`. Currently only a `password` fixture. +- **Mocking**: Tests use simple hand-written mocks (e.g. `MockSocket` in `test_framing.py`) rather than `unittest.mock`. +- **Coverage areas**: + - Cryptographic primitives (`test_crypto.py`) + - Wire framing (`test_framing.py`) + - IP-word encoding (`test_ipwords.py`) + - Rate limiting (`test_ratelimit.py`) +- **CI runs tests on**: Python 3.10, 3.12, and 3.14 (allowing prereleases). + +## Security Considerations + +- **Do not** weaken PBKDF2 iterations, AES key sizes, or RSA key sizes. These are defined in `types/constants.py` and are intentionally conservative. +- **Do not** disable certificate fingerprint verification in `client_connect` or `probe_server`. +- `fognode_passwd.bin` and `fognode_key.pem` are created with `0o600` permissions. +- The `SecureChannel` enforces: + - Strictly increasing counters (replay/reorder detection) + - 30-second timestamp drift window + - HMAC-SHA256 verification before AES-GCM decryption +- Rate limiting is applied per-IP at the TLS handshake layer (`_handle_client` in `core/server.py`). +- When modifying `SecureChannel.send` / `recv`, maintain backward compatibility of the wire format: length-prefixed JSON with keys `c`, `t`, `n`, `e`, `s`. + +## Key Architectural Details + +### Two API Layers +1. **Classic API** (`start_server`, `client_connect`): Synchronous, returns `SecureChannel`, blocks on `recv()`. +2. **App API** (`App`, `App.client()`): Async decorator-based. Runs its own `asyncio` event loop and bridges threaded network I/O into the loop via `call_soon_threadsafe`. + +### Server Lifecycle +- `start_server()` spawns a daemon thread that listens on a plain `socket.socket`. +- Each incoming connection spawns another daemon thread (`_handle_client`). +- TLS wrapping → `server_handshake` → `SecureChannel` → `_session_loop`. +- `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()`. +- 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`). +- 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. + +## CI / CD + +- **PR Checks** (`.github/workflows/pr.yml`): + - Lint & type-check job: ruff, black, pyright. + - Test matrix job: Python 3.10, 3.12, 3.14 on Ubuntu. + - Comment job posts a summary table back to the PR. + - All steps use `continue-on-error: true` so the comment job always runs. + +- **Release** (`.github/workflows/build.yml`): + - Manual workflow dispatch. + - Builds with `python -m build`, verifies with `twine check`. + - Creates a git tag and GitHub release with auto-generated notes. + - Optionally publishes to PyPI via `pypa/gh-action-pypi-publish`. + +## Development Conventions + +- Keep the public API surface in `src/fognode/__init__.py` explicit (`__all__`). +- Re-export related symbols in package-level `__init__.py` files (e.g. `types/__init__.py`, `crypto/__init__.py`). +- Constants belong in `types/constants.py`. +- Custom exceptions belong in `types/exceptions.py`. +- Do not add heavy dependencies; the runtime only requires `cryptography`. +- When adding CLI commands, use `argparse` subparsers in `cli/entrypoint.py` and follow the existing `_banner` / `_kv` output style. diff --git a/src/fognode/auth/handshake.py b/src/fognode/auth/handshake.py index be614c0..8bfc730 100644 --- a/src/fognode/auth/handshake.py +++ b/src/fognode/auth/handshake.py @@ -11,16 +11,14 @@ from fognode.crypto.primitives import hkdf_expand, hmac256, hmac256_verify, pbkdf2 from fognode.types.constants import NONCE_LENGTH, TOKEN_LENGTH from fognode.types.exceptions import AuthError, SecurityError -from fognode.types.protocol import IPAddress, User +from fognode.types.protocol import IPAddress from fognode.utils.ratelimit import RateLimiter from fognode.wire.framing import wire_recv, wire_send _rl = RateLimiter() -def server_handshake( - tls: ssl.SSLSocket, client_ip: IPAddress, expected_user: User -) -> SecureChannel: +def server_handshake(tls: ssl.SSLSocket, client_ip: IPAddress) -> SecureChannel: salt, stored_key = load_password_key() server_fp = cert_fingerprint(cert_paths()[0]) nonce = os.urandom(NONCE_LENGTH) @@ -39,7 +37,6 @@ def server_handshake( ) resp = wire_recv(tls) - client_user = resp.get("user", "") hmac_resp = bytes.fromhex(resp.get("hmac_resp", "")) client_fp = resp.get("cert_fp", "") cl_pub = base64.b64decode(resp.get("ecdh_pub", "")) @@ -48,10 +45,6 @@ def server_handshake( wire_send(tls, {"ok": False, "error": "fp_mismatch"}) raise AuthError("fp mismatch") - if client_user.encode() != expected_user.encode(): - wire_send(tls, {"ok": False, "error": "bad_user"}) - raise AuthError("bad user") - if not hmac256_verify(nonce, stored_key, hmac_resp): wire_send(tls, {"ok": False, "error": "bad_password"}) raise AuthError("bad password") @@ -66,9 +59,7 @@ def server_handshake( return SecureChannel(tls, enc_key, mac_key) -def client_handshake( - tls: ssl.SSLSocket, username: User, password: str, measured_fp: str -) -> SecureChannel: +def client_handshake(tls: ssl.SSLSocket, password: str, measured_fp: str) -> SecureChannel: chal = wire_recv(tls) if chal.get("type") != "challenge": raise ConnectionError("expected challenge") @@ -88,7 +79,6 @@ def client_handshake( wire_send( tls, { - "user": username, "hmac_resp": hmac_resp.hex(), "cert_fp": measured_fp, "ecdh_pub": base64.b64encode(cl_pub).decode(), diff --git a/src/fognode/core/server.py b/src/fognode/core/server.py index 1f6b411..a00ea1e 100644 --- a/src/fognode/core/server.py +++ b/src/fognode/core/server.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import socket import ssl import threading @@ -10,38 +9,30 @@ from cryptography.x509.oid import NameOID from fognode.auth.handshake import server_handshake -from fognode.core.state import ChatState from fognode.crypto.cert import cert_fingerprint, cert_paths, generate_cert, ssl_server_context from fognode.crypto.channel import SecureChannel from fognode.crypto.password import store_password -from fognode.types.constants import CHAT_MAX_TEXT_LENGTH from fognode.types.exceptions import AuthError, SecurityError -from fognode.types.protocol import CodeName, IPAddress, OnConnect, OnDisconnect, Port, User +from fognode.types.protocol import CodeName, IPAddress, OnConnect, OnDisconnect, Port from fognode.utils.ipwords import ip_to_name from fognode.utils.net import local_ip from fognode.utils.ratelimit import RateLimiter -_state = ChatState() _rl = RateLimiter() def _session_loop( ch: SecureChannel, - user: User, ip: IPAddress, on_connect: OnConnect | None, on_disconnect: OnDisconnect | None, ) -> None: - _state.register(user, ch) if on_connect: - on_connect(user) + on_connect() ch.send( { "type": "welcome", - "user": user, - "online": _state.users(), - "history": [m.as_dict() for m in _state.history(30)], } ) @@ -50,59 +41,33 @@ def _session_loop( msg = ch.recv() mtype = msg.get("type", "") - if mtype == "chat": - text = str(msg.get("text", ""))[:CHAT_MAX_TEXT_LENGTH] - entry = _state.add(user, text) - ch.send(entry.as_dict()) - _state.broadcast(entry, exclude=user) - - elif mtype == "cmd": - _handle_cmd(ch, user, msg) + if mtype == "cmd": + _handle_cmd(ch, msg) except (ConnectionError, OSError, SecurityError): pass finally: - _state.unregister(user) ch.close() if on_disconnect: - on_disconnect(user) + on_disconnect() -def _handle_cmd(ch: SecureChannel, user: User, msg: dict) -> None: # type: ignore[type-arg] +def _handle_cmd(ch: SecureChannel, msg: dict) -> None: # type: ignore[type-arg] cmd = msg.get("cmd", "") - if cmd == "ping": - ch.send({"type": "pong", "ts": time.time()}) - elif cmd == "info": + if cmd == "info": ch.send( { "type": "info", "server": "fognode/1.0", - "user": user, - "online": _state.users(), - "msgs": _state.message_count(), "time": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), "tls": ch._sock.version(), "cipher": (ch._sock.cipher() or ["?"])[0], } ) - elif cmd == "history": - n = min(int(msg.get("n", 50)), 200) - ch.send( - { - "type": "history", - "messages": [m.as_dict() for m in _state.history(n)], - } - ) - elif cmd == "users": - ch.send({"type": "users", "online": _state.users()}) - elif cmd == "quit": - ch.send({"type": "bye"}) - raise ConnectionError("quit") def _handle_client( raw: socket.socket, client_ip: IPAddress, - user: User, ctx: ssl.SSLContext, on_connect: OnConnect | None, on_disconnect: OnDisconnect | None, @@ -113,17 +78,12 @@ def _handle_client( try: with ctx.wrap_socket(raw, server_side=True) as tls: tls.settimeout(30) - ch = server_handshake(tls, client_ip, user) - _session_loop(ch, user, client_ip, on_connect, on_disconnect) - except ssl.SSLError as e: - if "EOF" not in str(e): - logging.debug(f"TLS {client_ip}: {e}") + ch = server_handshake(tls, client_ip) + _session_loop(ch, client_ip, on_connect, on_disconnect) + except ssl.SSLError: _rl.fail(client_ip) - except AuthError as e: - logging.warning(f"auth fail {client_ip}: {e}") + except AuthError: _rl.fail(client_ip) - except Exception as e: - logging.debug(f"{client_ip}: {e}") finally: try: raw.close() @@ -134,7 +94,6 @@ def _handle_client( def start_server( host: str, port: Port, - username: User, password: str, on_connect: OnConnect | None = None, on_disconnect: OnDisconnect | None = None, @@ -174,7 +133,7 @@ def _serve() -> None: break threading.Thread( target=_handle_client, - args=(conn, addr[0], username, ctx, on_connect, on_disconnect), + args=(conn, addr[0], ctx, on_connect, on_disconnect), daemon=True, ).start() diff --git a/src/fognode/types/protocol.py b/src/fognode/types/protocol.py index 967e6da..c25ea07 100644 --- a/src/fognode/types/protocol.py +++ b/src/fognode/types/protocol.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from typing import Any, Callable, Protocol, TypedDict -User = str Password = str IPAddress = str CodeName = str @@ -23,7 +22,6 @@ class ChallengeMsg(TypedDict): class ResponseMsg(TypedDict): - user: str hmac_resp: str cert_fp: str ecdh_pub: str @@ -35,57 +33,14 @@ class AuthResult(TypedDict): token: str | None -class ChatMsgDict(TypedDict): - type: str - ts: float - user: str - text: str - - -class CmdMsg(TypedDict): - type: str - cmd: str - n: int | None - - -class WelcomeMsg(TypedDict): - type: str - user: str - online: list[str] - history: list[ChatMsgDict] - - class InfoMsg(TypedDict): type: str server: str - user: str - online: list[str] - msgs: int time: str tls: str | None cipher: str -class PongMsg(TypedDict): - type: str - ts: float - - -class HistoryMsg(TypedDict): - type: str - messages: list[ChatMsgDict] - - -class UsersMsg(TypedDict): - type: str - online: list[str] - - -class ByeMsg(TypedDict): - type: str - - -@dataclass(slots=True, frozen=True) class ServerInfo: name: str version: str @@ -94,35 +49,31 @@ class ServerInfo: timestamp: str -@dataclass(slots=True, frozen=True) class ConnectionInfo: ip: IPAddress port: Port - user: User fingerprint: Fingerprint code_name: CodeName @dataclass(slots=True) -class ChatMsg: - ts: float - user: User - text: str +class RawMsg: + data: dict[str, Any] def as_dict(self) -> dict[str, Any]: - return {"type": "chat", "ts": self.ts, "user": self.user, "text": self.text} + return dict(self.data) class OnConnect(Protocol): - def __call__(self, user: User) -> None: ... + def __call__(self) -> None: ... class OnDisconnect(Protocol): - def __call__(self, user: User) -> None: ... + def __call__(self) -> None: ... class OnMessage(Protocol): - def __call__(self, msg: ChatMsg) -> None: ... + def __call__(self, msg: RawMsg) -> None: ... MessageHandler = Callable[[dict[str, Any]], None] From 99c74ce89763dde522415b661d968c1c7ac809bc Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 13:45:20 +0300 Subject: [PATCH 2/5] refactor: add Client/Server classes, remove logs, builtin commands, chat state --- src/fognode/__init__.py | 29 +---- src/fognode/app.py | 230 +++++++++++++++------------------- src/fognode/cli/entrypoint.py | 83 +++--------- src/fognode/core/__init__.py | 3 +- src/fognode/core/client.py | 9 +- src/fognode/core/probe.py | 8 +- src/fognode/core/state.py | 66 ---------- src/fognode/types/__init__.py | 20 --- src/fognode/types/protocol.py | 8 ++ 9 files changed, 131 insertions(+), 325 deletions(-) delete mode 100644 src/fognode/core/state.py diff --git a/src/fognode/__init__.py b/src/fognode/__init__.py index 36d5b7f..1803867 100644 --- a/src/fognode/__init__.py +++ b/src/fognode/__init__.py @@ -1,16 +1,13 @@ from __future__ import annotations from fognode import ciphers, decorators, exceptions, filters, types -from fognode.app import App, Context, Message, Router +from fognode.app import Client, Context, Message, Server from fognode.core.client import client_connect from fognode.core.server import start_server -from fognode.core.state import ChatState from fognode.crypto.channel import SecureChannel from fognode.types import ( AESGCM_NONCE_LENGTH, CERT_VALIDITY_DAYS, - CHAT_HISTORY_LIMIT, - CHAT_MAX_TEXT_LENGTH, DEFAULT_HOST, DEFAULT_PORT, MAX_MESSAGE_SIZE, @@ -24,55 +21,39 @@ TOKEN_LENGTH, AuthError, AuthResult, - ByeMsg, Bytes32, CertError, ChallengeMsg, - ChatMsg, - ChatMsgDict, Cipher, - CmdMsg, CodeName, ConnectionInfo, ConnectString, Fingerprint, FrameError, HandshakeError, - HistoryMsg, InfoMsg, IPAddress, MessageHandler, OnConnect, OnDisconnect, OnMessage, - PongMsg, Port, ResponseMsg, SecurityError, ServerInfo, - User, - UsersMsg, WelcomeMsg, WireError, ) __all__ = [ "AESGCM_NONCE_LENGTH", - "App", "AuthError", "AuthResult", "Bytes32", - "ByeMsg", "CERT_VALIDITY_DAYS", - "CHAT_HISTORY_LIMIT", - "CHAT_MAX_TEXT_LENGTH", - "CertError", "ChallengeMsg", - "ChatMsg", - "ChatMsgDict", - "ChatState", "Cipher", - "CmdMsg", + "Client", "CodeName", "ConnectString", "ConnectionInfo", @@ -82,7 +63,6 @@ "Fingerprint", "FrameError", "HandshakeError", - "HistoryMsg", "InfoMsg", "IPAddress", "MAX_MESSAGE_SIZE", @@ -93,21 +73,18 @@ "OnDisconnect", "OnMessage", "PBKDF2_ITERATIONS", - "PongMsg", "Port", "RATE_LIMIT_MAX_ATTEMPTS", "RATE_LIMIT_WINDOW", "REPLAY_WINDOW", "RSA_KEY_SIZE", "ResponseMsg", - "Router", "SALT_LENGTH", "SecureChannel", "SecurityError", + "Server", "ServerInfo", "TOKEN_LENGTH", - "User", - "UsersMsg", "WelcomeMsg", "WireError", "ciphers", diff --git a/src/fognode/app.py b/src/fognode/app.py index 52dee0c..27d355c 100644 --- a/src/fognode/app.py +++ b/src/fognode/app.py @@ -6,21 +6,18 @@ from typing import TYPE_CHECKING, Any, Callable from fognode.core.client import client_connect -from fognode.core.server import _state, start_server +from fognode.core.server import start_server from fognode.crypto.channel import SecureChannel -from fognode.filters import Command from fognode.handlers import HandlerObject -from fognode.types.constants import DEFAULT_HOST, DEFAULT_PORT, Cipher -from fognode.types.protocol import ChatMsg +from fognode.types.constants import DEFAULT_HOST, DEFAULT_PORT if TYPE_CHECKING: - from fognode.app import App + from fognode.app import Client, Server @dataclass(slots=True, frozen=True) class Message: type: str - user: str text: str ts: float raw: dict[str, Any] @@ -29,7 +26,6 @@ class Message: def from_dict(cls, data: dict[str, Any]) -> Message: return cls( type=data.get("type", ""), - user=data.get("user", ""), text=data.get("text", ""), ts=data.get("ts", 0.0), raw=data, @@ -38,9 +34,8 @@ def from_dict(cls, data: dict[str, Any]) -> Message: @dataclass(slots=True) class Context: - app: App + app: Server | Client channel: SecureChannel | None - user: str message: Message | None = None async def answer(self, text: str) -> None: @@ -53,19 +48,24 @@ async def send(self, data: dict[str, Any]) -> None: raise RuntimeError("no channel available") self.channel.send(data) - async def reply(self, data: dict[str, Any]) -> None: - if self.channel is None: - raise RuntimeError("no channel available") - self.channel.send(data) - -class Router: - def __init__(self) -> None: +class Server: + def __init__( + self, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + password: str | None = None, + ) -> None: + self.host = host + self.port = port + self.password = password self._handlers: dict[str, list[HandlerObject]] = { "connect": [], "disconnect": [], "message": [], } + self._loop: asyncio.AbstractEventLoop | None = None + self._channel: SecureChannel | None = None def on_connect(self) -> Callable[..., Any]: def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: @@ -81,43 +81,74 @@ def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: return decorator - def on_message(self, *filters: Any) -> Callable[..., Any]: + def on_message(self) -> Callable[..., Any]: def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["message"].append(HandlerObject(callback, list(filters))) + self._handlers["message"].append(HandlerObject(callback)) return callback return decorator - def on_command(self, commands: str | list[str]) -> Callable[..., Any]: - def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["message"].append(HandlerObject(callback, [Command(commands)])) - return callback + def run(self) -> None: + if not self.password: + raise ValueError("password required for server") - return decorator + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + def _on_connect() -> None: + if self._loop is not None: + self._loop.call_soon_threadsafe(self._process_connect) + + def _on_disconnect() -> None: + if self._loop is not None: + self._loop.call_soon_threadsafe(self._process_disconnect) + + start_server( + self.host, + self.port, + self.password, + on_connect=_on_connect, + on_disconnect=_on_disconnect, + ) - def include(self, app: App) -> None: - for event, handlers in self._handlers.items(): - app._handlers[event].extend(handlers) + try: + self._loop.run_forever() + except KeyboardInterrupt: + pass + finally: + self._loop.close() + def _process_connect(self) -> None: + ctx = Context(self, self._channel) + for handler in self._handlers["connect"]: + asyncio.create_task(handler.call(ctx)) -class App: + def _process_disconnect(self) -> None: + ctx = Context(self, self._channel) + for handler in self._handlers["disconnect"]: + asyncio.create_task(handler.call(ctx)) + + def _process_message(self, msg: dict[str, Any]) -> None: + message = Message.from_dict(msg) + ctx = Context(self, self._channel, message) + for handler in self._handlers["message"]: + asyncio.create_task(self._run_handler(handler, msg, ctx)) + + async def _run_handler( + self, handler: HandlerObject, data: dict[str, Any], ctx: Context + ) -> None: + if await handler.check(data): + await handler.call(ctx) + + +class Client: def __init__( self, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - user: str | None = None, - password: str | None = None, - cipher: Cipher = Cipher.AESGCM, - mode: str = "server", - connect_string: str | None = None, + connect_string: str, + password: str, ) -> None: - self.host = host - self.port = port - self.user = user - self.password = password - self.cipher = cipher - self.mode = mode self.connect_string = connect_string + self.password = password self._handlers: dict[str, list[HandlerObject]] = { "connect": [], "disconnect": [], @@ -126,20 +157,6 @@ def __init__( self._loop: asyncio.AbstractEventLoop | None = None self._channel: SecureChannel | None = None - @classmethod - def client( - cls, - connect_string: str, - password: str, - cipher: Cipher = Cipher.AESGCM, - ) -> App: - return cls( - mode="client", - connect_string=connect_string, - password=password, - cipher=cipher, - ) - def on_connect(self) -> Callable[..., Any]: def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: self._handlers["connect"].append(HandlerObject(callback)) @@ -154,114 +171,67 @@ def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: return decorator - def on_message(self, *filters: Any) -> Callable[..., Any]: + def on_message(self) -> Callable[..., Any]: def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["message"].append(HandlerObject(callback, list(filters))) + self._handlers["message"].append(HandlerObject(callback)) return callback return decorator - def on_command(self, commands: str | list[str]) -> Callable[..., Any]: - def decorator(callback: Callable[..., Any]) -> Callable[..., Any]: - self._handlers["message"].append(HandlerObject(callback, [Command(commands)])) - return callback - - return decorator - - def include_router(self, router: Router) -> None: - router.include(self) + def connect(self) -> None: + if not self.connect_string or not self.password: + raise ValueError("connect_string and password required") - def run(self) -> None: self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) - if self.mode == "server": - self._setup_server() - else: - self._setup_client() - try: - self._loop.run_forever() - except KeyboardInterrupt: - pass - finally: - self._loop.close() - - def _setup_server(self) -> None: - if not self.user or not self.password: - raise ValueError("user and password required for server mode") - - def _on_connect(user: str) -> None: - if self._loop is not None: - self._loop.call_soon_threadsafe(self._process_connect, user) - - def _on_disconnect(user: str) -> None: - if self._loop is not None: - self._loop.call_soon_threadsafe(self._process_disconnect, user) - - def _on_msg(msg: ChatMsg) -> None: - if self._loop is not None: - self._loop.call_soon_threadsafe(self._process_chat, msg) - - start_server( - self.host, - self.port, - self.user, - self.password, - on_connect=_on_connect, - on_disconnect=_on_disconnect, - ) - _state.on_message(_on_msg) - - def _setup_client(self) -> None: - if not self.connect_string or not self.password: - raise ValueError("connect_string and password required for client mode") ch, _fp = client_connect(self.connect_string, self.password) self._channel = ch welcome = ch.recv() if welcome.get("type") == "welcome": - for m in welcome.get("history", []): - self._process_raw_msg(ch, m) + self._process_connect() def _recv() -> None: while True: try: msg = ch.recv() if self._loop is not None: - self._loop.call_soon_threadsafe(self._process_raw_msg, ch, msg) + self._loop.call_soon_threadsafe(self._process_raw_msg, msg) except Exception: + if self._loop is not None: + self._loop.call_soon_threadsafe(self._process_disconnect) break threading.Thread(target=_recv, daemon=True).start() - def _process_connect(self, user: str) -> None: - ch = _state.get_channel(user) - ctx = Context(self, ch, user) + try: + self._loop.run_forever() + except KeyboardInterrupt: + pass + finally: + self._loop.close() + + def send(self, data: dict[str, Any]) -> None: + if self._channel is None: + raise RuntimeError("not connected") + self._channel.send(data) + + def _process_connect(self) -> None: + ctx = Context(self, self._channel) for handler in self._handlers["connect"]: asyncio.create_task(handler.call(ctx)) - def _process_disconnect(self, user: str) -> None: - ch = _state.get_channel(user) - ctx = Context(self, ch, user) + def _process_disconnect(self) -> None: + ctx = Context(self, self._channel) for handler in self._handlers["disconnect"]: asyncio.create_task(handler.call(ctx)) - def _process_chat(self, m: ChatMsg) -> None: - ch = _state.get_channel(m.user) - if ch is None: - return - msg = Message.from_dict(m.as_dict()) - ctx = Context(self, ch, m.user, msg) + def _process_raw_msg(self, msg: dict[str, Any]) -> None: + message = Message.from_dict(msg) + ctx = Context(self, self._channel, message) for handler in self._handlers["message"]: - asyncio.create_task(self._run_handler(handler, msg.raw, ctx)) - - def _process_raw_msg(self, ch: SecureChannel, msg: dict[str, Any]) -> None: - mtype = msg.get("type", "") - if mtype == "chat": - message = Message.from_dict(msg) - ctx = Context(self, ch, msg.get("user", ""), message) - for handler in self._handlers["message"]: - asyncio.create_task(self._run_handler(handler, msg, ctx)) + asyncio.create_task(self._run_handler(handler, msg, ctx)) async def _run_handler( self, handler: HandlerObject, data: dict[str, Any], ctx: Context diff --git a/src/fognode/cli/entrypoint.py b/src/fognode/cli/entrypoint.py index 4ae0ce0..f2809e4 100644 --- a/src/fognode/cli/entrypoint.py +++ b/src/fognode/cli/entrypoint.py @@ -12,11 +12,10 @@ from fognode.core.client import client_connect from fognode.core.probe import probe_server -from fognode.core.server import _state, start_server +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.exceptions import AuthError, SecurityError -from fognode.types.protocol import ChatMsg def _ts() -> str: @@ -42,41 +41,19 @@ def _json_or_print(data: dict[str, Any], use_json: bool) -> None: def cmd_server(args: argparse.Namespace) -> None: - verbose: bool = args.verbose - username: str | None = args.user or os.environ.get("FOGNODE_USER") password: str | None = args.password or os.environ.get("FOGNODE_PASSWORD") - if not username: - username = input("Username: ").strip() - if not username: - sys.exit("username required") if not password: password = getpass.getpass("Password: ") confirm = getpass.getpass("Confirm: ") if password != confirm: sys.exit("passwords do not match") - on_connect = None - on_disconnect = None - if verbose: - - def _on_connect(user: str) -> None: - print(f" [{_ts()}] + {user} connected") - - def _on_disconnect(user: str) -> None: - print(f" [{_ts()}] - {user} disconnected") - - on_connect = _on_connect - on_disconnect = _on_disconnect - t0 = time.perf_counter() display_ip, code_name, fp = start_server( args.host, args.port, - username, password, - on_connect=on_connect, - on_disconnect=on_disconnect, ) t1 = time.perf_counter() @@ -106,14 +83,9 @@ def _on_disconnect(user: str) -> None: _kv("cert_sig_alg", cinfo.get("sig_alg")) _kv("cert_version", cinfo.get("version")) print() - _kv("connect_string", f"{username}@{code_name}:{args.port}") + _kv("connect_string", f"{code_name}:{args.port}") print("═" * 60) - def _on_msg(msg: ChatMsg) -> None: - print(f" [{_ts()}] {msg.user}: {msg.text}") - - _state.on_message(_on_msg) - try: signal.pause() except (KeyboardInterrupt, AttributeError): @@ -121,7 +93,6 @@ def _on_msg(msg: ChatMsg) -> None: def cmd_client(args: argparse.Namespace) -> None: - verbose: bool = args.verbose password: str | None = args.password or os.environ.get("FOGNODE_PASSWORD") if not password: password = getpass.getpass("Password: ") @@ -152,9 +123,7 @@ def cmd_client(args: argparse.Namespace) -> None: welcome = ch.recv() if welcome.get("type") == "welcome": - print(f"[+] online: {', '.join(welcome.get('online', []))}") - if verbose: - print(f"[+] history: {len(welcome.get('history', []))} messages") + print("[+] connected") def _recv() -> None: while True: @@ -163,13 +132,9 @@ def _recv() -> None: mtype = msg.get("type", "") if mtype == "chat": ts = time.strftime("%H:%M", time.localtime(msg.get("ts", 0))) - print(f"\r [{ts}] {msg['user']}: {msg['text']}") - elif mtype == "pong" and verbose: - print(f" [pong] ts={msg.get('ts', 0):.3f}") - elif mtype == "info" and verbose: - print(f" [info] users={len(msg.get('online', []))} msgs={msg.get('msgs')}") - elif mtype == "users" and verbose: - print(f" [users] {', '.join(msg.get('online', []))}") + print(f"\r [{ts}] {msg.get('text', '')}") + elif mtype == "info": + print(f" [info] tls={msg.get('tls')} cipher={msg.get('cipher')}") elif mtype == "bye": print(" [server closed session]") break @@ -189,16 +154,8 @@ def _recv() -> None: if cmd in ("quit", "q", "exit"): ch.send({"type": "cmd", "cmd": "quit"}) break - elif cmd == "ping": - ch.send({"type": "cmd", "cmd": "ping"}) elif cmd == "info": ch.send({"type": "cmd", "cmd": "info"}) - elif cmd.startswith("history"): - parts = cmd.split() - n = int(parts[1]) if len(parts) > 1 else 50 - ch.send({"type": "cmd", "cmd": "history", "n": n}) - elif cmd == "users": - ch.send({"type": "cmd", "cmd": "users"}) else: print(f"[!] unknown command: /{cmd}") else: @@ -243,9 +200,6 @@ def cmd_status(args: argparse.Namespace) -> None: ch.send({"type": "cmd", "cmd": "info"}) info_msg = ch.recv() - ch.send({"type": "cmd", "cmd": "users"}) - users_msg = ch.recv() - ch.send({"type": "cmd", "cmd": "quit"}) ch.close() data = { @@ -256,8 +210,6 @@ def cmd_status(args: argparse.Namespace) -> None: "cipher_bits": cipher[2], "server": info_msg.get("server"), "time": info_msg.get("time"), - "message_count": info_msg.get("msgs"), - "users_online": users_msg.get("online", []), } _banner(f"status {args.connect}") @@ -277,7 +229,7 @@ def cmd_send(args: argparse.Namespace) -> None: welcome = ch.recv() if welcome.get("type") == "welcome": - print(f"[+] online: {', '.join(welcome.get('online', []))}") + print("[+] connected") ch.send({"type": "chat", "text": args.text}) time.sleep(0.5) @@ -316,13 +268,9 @@ def _recv() -> None: else: if mtype == "chat": ts = time.strftime("%H:%M:%S", time.localtime(msg.get("ts", 0))) - print(f"[{ts}] {msg['user']}: {msg['text']}") - elif mtype == "pong": - print(f"[pong] ts={msg.get('ts', 0):.3f}") + print(f"[{ts}] {msg.get('text', '')}") elif mtype == "info": - print(f"[info] users={len(msg.get('online', []))} msgs={msg.get('msgs')}") - elif mtype == "users": - print(f"[users] {', '.join(msg.get('online', []))}") + print(f"[info] tls={msg.get('tls')} cipher={msg.get('cipher')}") elif mtype == "bye": print("[server closed session]") break @@ -351,15 +299,12 @@ def main() -> None: p_server = sub.add_parser("server", help="Run server") p_server.add_argument("--host", default=DEFAULT_HOST) p_server.add_argument("--port", type=int, default=DEFAULT_PORT) - p_server.add_argument("--user", default=None) p_server.add_argument("--password", default=None) - p_server.add_argument("-v", "--verbose", action="store_true") p_server.set_defaults(func=cmd_server) p_client = sub.add_parser("client", help="Run interactive client") - p_client.add_argument("connect", help="user@code:port") + p_client.add_argument("connect", help="code:port") p_client.add_argument("--password", default=None) - p_client.add_argument("-v", "--verbose", action="store_true") p_client.set_defaults(func=cmd_client) p_cert = sub.add_parser("cert", help="Show certificate info") @@ -368,24 +313,24 @@ def main() -> None: p_cert.set_defaults(func=cmd_cert) p_probe = sub.add_parser("probe", help="Probe server TLS without auth") - p_probe.add_argument("connect", help="user@code:port") + p_probe.add_argument("connect", help="code:port") p_probe.add_argument("--json", action="store_true") p_probe.set_defaults(func=cmd_probe) p_status = sub.add_parser("status", help="Server status via auth channel") - p_status.add_argument("connect", help="user@code:port") + p_status.add_argument("connect", help="code:port") p_status.add_argument("--password", default=None) p_status.add_argument("--json", action="store_true") p_status.set_defaults(func=cmd_status) p_send = sub.add_parser("send", help="Send message and exit") - p_send.add_argument("connect", help="user@code:port") + p_send.add_argument("connect", help="code:port") p_send.add_argument("--password", default=None) p_send.add_argument("--text", required=True) p_send.set_defaults(func=cmd_send) p_monitor = sub.add_parser("monitor", help="Monitor messages only") - p_monitor.add_argument("connect", help="user@code:port") + p_monitor.add_argument("connect", help="code:port") p_monitor.add_argument("--password", default=None) p_monitor.add_argument("--json", action="store_true") p_monitor.set_defaults(func=cmd_monitor) diff --git a/src/fognode/core/__init__.py b/src/fognode/core/__init__.py index 9a496b1..299abb2 100644 --- a/src/fognode/core/__init__.py +++ b/src/fognode/core/__init__.py @@ -3,6 +3,5 @@ from fognode.core.client import client_connect from fognode.core.probe import probe_server from fognode.core.server import start_server -from fognode.core.state import ChatState -__all__ = ["ChatState", "client_connect", "probe_server", "start_server"] +__all__ = ["client_connect", "probe_server", "start_server"] diff --git a/src/fognode/core/client.py b/src/fognode/core/client.py index 5b2de93..9a70430 100644 --- a/src/fognode/core/client.py +++ b/src/fognode/core/client.py @@ -9,17 +9,14 @@ from fognode.auth.handshake import client_handshake from fognode.crypto.cert import cert_fingerprint, ssl_probe_context, ssl_verified_context from fognode.crypto.channel import SecureChannel -from fognode.types.protocol import CodeName, ConnectString, IPAddress, Port, User +from fognode.types.protocol import ConnectString, IPAddress, Port from fognode.utils.ipwords import name_to_ip def client_connect(connect_str: ConnectString, password: str) -> tuple[SecureChannel, str]: try: - user_part, port_str = connect_str.rsplit(":", 1) + code_name, port_str = connect_str.rsplit(":", 1) port: Port = int(port_str) - username: User - code_name: CodeName - username, code_name = user_part.split("@", 1) except Exception: raise ValueError(f"Bad connect string: {connect_str!r}") from None @@ -50,5 +47,5 @@ def client_connect(connect_str: ConnectString, password: str) -> tuple[SecureCha finally: tmp.unlink(missing_ok=True) - channel = client_handshake(tls_sock, username, password, measured_fp) + channel = client_handshake(tls_sock, password, measured_fp) return channel, measured_fp diff --git a/src/fognode/core/probe.py b/src/fognode/core/probe.py index a39932f..01b1924 100644 --- a/src/fognode/core/probe.py +++ b/src/fognode/core/probe.py @@ -4,17 +4,14 @@ from typing import Any from fognode.crypto.cert import cert_fingerprint, ssl_probe_context -from fognode.types.protocol import CodeName, ConnectString, IPAddress, Port, User +from fognode.types.protocol import ConnectString, IPAddress, Port from fognode.utils.ipwords import name_to_ip def probe_server(connect_str: ConnectString) -> dict[str, Any]: try: - user_part, port_str = connect_str.rsplit(":", 1) + code_name, port_str = connect_str.rsplit(":", 1) port: Port = int(port_str) - username: User - code_name: CodeName - username, code_name = user_part.split("@", 1) except Exception: raise ValueError(f"Bad connect string: {connect_str!r}") from None @@ -30,7 +27,6 @@ def probe_server(connect_str: ConnectString) -> dict[str, Any]: return { "connect_string": connect_str, - "username": username, "code_name": code_name, "server_ip": server_ip, "port": port, diff --git a/src/fognode/core/state.py b/src/fognode/core/state.py deleted file mode 100644 index c82f4fd..0000000 --- a/src/fognode/core/state.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import time -from threading import Lock - -from fognode.crypto.channel import SecureChannel -from fognode.types.constants import CHAT_HISTORY_LIMIT -from fognode.types.protocol import ChatMsg, OnMessage, User - - -class ChatState: - def __init__(self, limit: int = CHAT_HISTORY_LIMIT) -> None: - self._lock = Lock() - self._hist: list[ChatMsg] = [] - self._sess: dict[User, SecureChannel] = {} - self._hooks: list[OnMessage] = [] - self.limit = limit - - def add(self, user: User, text: str) -> ChatMsg: - m = ChatMsg(time.time(), user, text) - with self._lock: - self._hist.append(m) - if len(self._hist) > self.limit: - self._hist = self._hist[-self.limit :] - for cb in self._hooks: - try: - cb(m) - except Exception: - pass - return m - - def history(self, n: int = 50) -> list[ChatMsg]: - with self._lock: - return list(self._hist[-n:]) - - def register(self, user: User, ch: SecureChannel) -> None: - with self._lock: - self._sess[user] = ch - - def unregister(self, user: User) -> None: - with self._lock: - self._sess.pop(user, None) - - def get_channel(self, user: User) -> SecureChannel | None: - with self._lock: - return self._sess.get(user) - - def users(self) -> list[User]: - with self._lock: - return list(self._sess.keys()) - - def broadcast(self, m: ChatMsg, exclude: User | None = None) -> None: - with self._lock: - targets = [(u, ch) for u, ch in self._sess.items() if u != exclude] - for _, ch in targets: - try: - ch.send(m.as_dict()) - except Exception: - pass - - def message_count(self) -> int: - with self._lock: - return len(self._hist) - - def on_message(self, cb: OnMessage) -> None: - self._hooks.append(cb) diff --git a/src/fognode/types/__init__.py b/src/fognode/types/__init__.py index 8d3807d..868f992 100644 --- a/src/fognode/types/__init__.py +++ b/src/fognode/types/__init__.py @@ -3,8 +3,6 @@ from fognode.types.constants import ( AESGCM_NONCE_LENGTH, CERT_VALIDITY_DAYS, - CHAT_HISTORY_LIMIT, - CHAT_MAX_TEXT_LENGTH, DEFAULT_HOST, DEFAULT_PORT, MAX_MESSAGE_SIZE, @@ -28,29 +26,21 @@ ) from fognode.types.protocol import ( AuthResult, - ByeMsg, Bytes32, ChallengeMsg, - ChatMsg, - ChatMsgDict, - CmdMsg, CodeName, ConnectionInfo, ConnectString, Fingerprint, - HistoryMsg, InfoMsg, IPAddress, MessageHandler, OnConnect, OnDisconnect, OnMessage, - PongMsg, Port, ResponseMsg, ServerInfo, - User, - UsersMsg, WelcomeMsg, ) @@ -60,15 +50,9 @@ "AuthResult", "CertError", "Bytes32", - "ByeMsg", "CERT_VALIDITY_DAYS", - "CHAT_HISTORY_LIMIT", - "CHAT_MAX_TEXT_LENGTH", "ChallengeMsg", - "ChatMsg", "Cipher", - "ChatMsgDict", - "CmdMsg", "CodeName", "ConnectString", "ConnectionInfo", @@ -77,7 +61,6 @@ "Fingerprint", "FrameError", "HandshakeError", - "HistoryMsg", "InfoMsg", "IPAddress", "MAX_MESSAGE_SIZE", @@ -87,7 +70,6 @@ "OnDisconnect", "OnMessage", "PBKDF2_ITERATIONS", - "PongMsg", "Port", "RATE_LIMIT_MAX_ATTEMPTS", "RATE_LIMIT_WINDOW", @@ -98,8 +80,6 @@ "SecurityError", "ServerInfo", "TOKEN_LENGTH", - "User", - "UsersMsg", "WelcomeMsg", "WireError", ] diff --git a/src/fognode/types/protocol.py b/src/fognode/types/protocol.py index c25ea07..2f75ca8 100644 --- a/src/fognode/types/protocol.py +++ b/src/fognode/types/protocol.py @@ -33,6 +33,10 @@ class AuthResult(TypedDict): token: str | None +class WelcomeMsg(TypedDict): + type: str + + class InfoMsg(TypedDict): type: str server: str @@ -41,6 +45,10 @@ class InfoMsg(TypedDict): cipher: str +class ByeMsg(TypedDict): + type: str + + class ServerInfo: name: str version: str From a0afea7183baf99281b6cd4679084942574cd646 Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 13:51:54 +0300 Subject: [PATCH 3/5] ci: auto-release on main push using latest tag --- .github/workflows/build.yml | 64 ++++++++++--------------------------- 1 file changed, 16 insertions(+), 48 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e167f0..bb9a8d0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,18 +1,9 @@ name: Release on: - workflow_dispatch: - inputs: - version: - description: "Release version" - required: true - default: "v0.1.0" - - publish_pypi: - description: "Publish package to PyPI" - type: boolean - required: true - default: true + push: + branches: + - main permissions: contents: write @@ -38,16 +29,6 @@ jobs: python-version: "3.12" cache: pip - - name: Validate version format - run: | - VERSION="${{ github.event.inputs.version }}" - if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Invalid version format: $VERSION" - echo "Expected: vX.Y.Z" - exit 1 - fi - echo "Version format valid: $VERSION" - - name: Install build dependencies run: | python -m pip install --upgrade pip @@ -59,20 +40,27 @@ jobs: - name: Verify package run: twine check dist/* + - name: Get latest tag + id: get_tag + run: | + TAG=$(git describe --tags --abbrev=0) + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Latest tag: $TAG" + - name: Generate release notes id: notes run: | - PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + PREV_TAG=$(git describe --tags --abbrev=0 --exclude="${{ steps.get_tag.outputs.tag }}" 2>/dev/null || echo "") if [ -z "$PREV_TAG" ]; then LOG=$(git log --max-count=50 --pretty=format:"- %s") else - LOG=$(git log ${PREV_TAG}..HEAD --max-count=100 --pretty=format:"- %s") + LOG=$(git log ${PREV_TAG}..${{ steps.get_tag.outputs.tag }} --max-count=100 --pretty=format:"- %s") fi { echo 'notes<> "$GITHUB_OUTPUT" - - name: Check if tag exists - id: check_tag - run: | - TAG="${{ github.event.inputs.version }}" - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Tag $TAG already exists" - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Create tag - if: steps.check_tag.outputs.exists == 'false' - run: | - TAG="${{ github.event.inputs.version }}" - git tag "$TAG" - git push origin "$TAG" - - - name: Create GitHub release - if: steps.check_tag.outputs.exists == 'false' + - name: Create or update GitHub release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.event.inputs.version }} - name: fognode ${{ github.event.inputs.version }} + tag_name: ${{ steps.get_tag.outputs.tag }} + name: fognode ${{ steps.get_tag.outputs.tag }} body: ${{ steps.notes.outputs.notes }} generate_release_notes: false files: | dist/* - name: Publish to PyPI - if: github.event.inputs.publish_pypi == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: attestations: false From f733c9870b3c1174f9cb8e35b76a5206187dd9bf Mon Sep 17 00:00:00 2001 From: IMDelewer Date: Sat, 16 May 2026 13:54:45 +0300 Subject: [PATCH 4/5] ci: add pr summary --- .github/workflows/pr-summary.yml | 474 +++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 .github/workflows/pr-summary.yml diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml new file mode 100644 index 0000000..eaf1cac --- /dev/null +++ b/.github/workflows/pr-summary.yml @@ -0,0 +1,474 @@ +name: PR Summary + +on: + pull_request: + types: + - opened + - synchronize + - reopened + +permissions: + pull-requests: write + contents: write + +jobs: + pr-summary: + runs-on: ubuntu-latest + + steps: + - name: Detect fork + id: detect + run: | + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then + echo "is_fork=true" >> "$GITHUB_OUTPUT" + else + echo "is_fork=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate PR summary + id: summary + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + const commits = await github.paginate( + github.rest.pulls.listCommits, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100 + } + ); + + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100 + } + ); + + const groups = { + feat: [], + fix: [], + refactor: [], + perf: [], + docs: [], + test: [], + chore: [], + ci: [], + style: [], + build: [], + revert: [], + other: [] + }; + + const titles = { + feat: "✨ Features", + fix: "🐛 Fixes", + refactor: "♻️ Refactoring", + perf: "⚡ Performance", + docs: "📝 Documentation", + test: "🧪 Tests", + chore: "🔧 Chores", + ci: "🚀 CI", + style: "🎨 Style", + build: "📦 Build", + revert: "⏪ Reverts", + other: "📌 Other" + }; + + let additions = 0; + let deletions = 0; + + const contributors = new Map(); + const scopeStats = new Map(); + const dirStats = new Map(); + + for (const file of files) { + additions += file.additions; + deletions += file.deletions; + + const dir = file.filename.includes("/") + ? file.filename.split("/")[0] + : "root"; + + dirStats.set(dir, (dirStats.get(dir) || 0) + 1); + } + + for (const commit of commits) { + const sha = commit.sha.substring(0, 7); + const url = commit.html_url; + + const author = + commit.author?.login || + commit.commit.author.name; + + contributors.set(author, (contributors.get(author) || 0) + 1); + + const message = commit.commit.message.split("\n")[0]; + + const match = message.match( + /^(\w+)(\((.*?)\))?:\s(.+)$/ + ); + + let type = "other"; + let scope = ""; + let description = message; + + if (match) { + type = match[1]; + scope = match[3] || ""; + description = match[4]; + } + + if (!groups[type]) { + type = "other"; + } + + if (scope) { + scopeStats.set(scope, (scopeStats.get(scope) || 0) + 1); + } + + groups[type].push({ + sha, + url, + scope, + description, + author + }); + } + + const topFiles = [...files] + .sort((a, b) => b.changes - a.changes) + .slice(0, 10); + + const topScopes = [...scopeStats.entries()] + .sort((a, b) => b[1] - a[1]); + + const topDirs = [...dirStats.entries()] + .sort((a, b) => b[1] - a[1]); + + function progress(value, total) { + const width = 20; + const filled = Math.round((value / total) * width); + + return ( + "█".repeat(filled) + + "░".repeat(width - filled) + ); + } + + const totalTypedCommits = Object.values(groups) + .reduce((acc, arr) => acc + arr.length, 0); + + let body = ""; + + body += `\n`; + body += `# 📋 PR Summary\n\n`; + body += `### ${pr.title}\n\n`; + body += `> ${pr.user.login} opened a pull request from \`${pr.head.ref}\` → \`${pr.base.ref}\`\n\n`; + + body += `---\n\n`; + body += `## 📊 Overview\n\n`; + body += `| Metric | Value |\n`; + body += `|---|---|\n`; + body += `| Commits | \`${commits.length}\` |\n`; + body += `| Changed Files | \`${files.length}\` |\n`; + body += `| Additions | \`+${additions}\` |\n`; + body += `| Deletions | \`-${deletions}\` |\n`; + body += `| Contributors | \`${contributors.size}\` |\n\n`; + + body += `---\n\n`; + body += `## 📈 Change Distribution\n\n`; + + for (const [type, items] of Object.entries(groups)) { + if (!items.length) continue; + + const bar = progress( + items.length, + totalTypedCommits + ); + + body += `- ${titles[type]} \`${bar}\` ${items.length}\n`; + } + + body += `\n---\n\n`; + + for (const [type, items] of Object.entries(groups)) { + if (!items.length) continue; + + body += `## ${titles[type]}\n\n`; + body += `
\n`; + body += `${items.length} commits\n\n`; + + for (const item of items) { + const scope = item.scope + ? `\`${item.scope}\` ` + : ""; + + body += `- [\`${item.sha}\`](${item.url}) ${scope}${item.description} — @${item.author}\n`; + } + + body += `\n
\n\n`; + } + + body += `---\n\n`; + body += `## 🎯 Main Impact Areas\n\n`; + + for (const [scope, count] of topScopes.slice(0, 8)) { + body += `- \`${scope}\` — ${count} commits\n`; + } + + body += `\n---\n\n`; + body += `## 📂 Most Changed Files\n\n`; + body += "```diff\n"; + + for (const file of topFiles) { + body += `+ ${String(file.additions).padEnd(4)}`; + body += `- ${String(file.deletions).padEnd(4)}`; + body += `${file.filename}\n`; + } + + body += "```\n\n"; + + body += `---\n\n`; + body += `## 🧩 Changed Directories\n\n`; + + for (const [dir, count] of topDirs.slice(0, 10)) { + body += `- \`${dir}/\` — ${count} files\n`; + } + + body += `\n---\n\n`; + body += `## ⚠️ High Impact Files\n\n`; + + const risky = files + .filter(file => file.changes > 200) + .sort((a, b) => b.changes - a.changes); + + if (risky.length) { + for (const file of risky) { + body += `- \`${file.filename}\` (+${file.additions} / -${file.deletions})\n`; + } + } else { + body += `No high impact files detected.\n`; + } + + body += `\n---\n\n`; + body += `## 👥 Contributors\n\n`; + + for (const [user, count] of contributors.entries()) { + body += `- @${user} — ${count} commits\n`; + } + + body += `\n---\n\n`; + body += `## 🔎 Raw Commit Messages\n\n`; + body += `
\n`; + body += `Show raw commits\n\n`; + body += "```text\n"; + + for (const commit of commits) { + body += `${commit.commit.message}\n\n`; + } + + body += "```\n"; + body += "
\n\n"; + body += "---\n\n"; + body += "Generated automatically from PR metadata."; + + core.setOutput("body", body); + + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + + const version = pr.head.ref.replace( + /[^a-zA-Z0-9.-]/g, + "-" + ); + + const sectionTitles = { + feat: "Added", + fix: "Fixed", + refactor: "Changed", + perf: "Changed", + docs: "Changed", + test: "Changed", + chore: "Changed", + ci: "Changed", + style: "Changed", + build: "Changed", + revert: "Removed", + other: "Changed" + }; + + let changelogEntry = ""; + + for (const [type, items] of Object.entries(groups)) { + if (!items.length) continue; + + changelogEntry += `### ${sectionTitles[type]}\n\n`; + + for (const item of items) { + const scope = item.scope + ? `**${item.scope}:** ` + : ""; + + changelogEntry += `- ${scope}${item.description} ([\`${item.sha}\`](${item.url}))\n`; + } + + changelogEntry += "\n"; + } + + core.setOutput("changelog", changelogEntry); + core.setOutput("version", version); + core.setOutput("date", dateStr); + + - name: Create or update PR comment + uses: actions/github-script@v7 + env: + BODY: ${{ steps.summary.outputs.body }} + with: + script: | + const marker = ""; + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: + context.payload.pull_request.number + } + ); + + const existing = comments.find(comment => + comment.body.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: process.env.BODY + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: + context.payload.pull_request.number, + body: process.env.BODY + }); + } + + - name: Update CHANGELOG.md + if: steps.detect.outputs.is_fork == 'false' + env: + VERSION: ${{ steps.summary.outputs.version }} + DATE: ${{ steps.summary.outputs.date }} + ENTRY: ${{ steps.summary.outputs.changelog }} + run: | + node <<'EOF' + const fs = require('fs'); + const path = require('path'); + + const changelogPath = path.join( + process.cwd(), + 'CHANGELOG.md' + ); + + const version = process.env.VERSION; + const date = process.env.DATE; + const entry = process.env.ENTRY; + + const header = `# Changelog + + All notable changes to this project will be documented in this file. + + The format is based on Keep a Changelog, + and this project adheres to Semantic Versioning. + + `; + + const newEntry = `## [${version}] - ${date} + + ${entry} + `; + + let content = header; + + if (fs.existsSync(changelogPath)) { + content = fs.readFileSync( + changelogPath, + 'utf8' + ); + + if (!content.startsWith('# Changelog')) { + content = header + content; + } + } + + const lines = content.split('\n'); + + let insertIndex = 0; + + for (let i = 0; i < lines.length; i++) { + if ( + lines[i].includes('Semantic Versioning') + ) { + insertIndex = i + 1; + + while ( + insertIndex < lines.length && + lines[insertIndex].trim() === '' + ) { + insertIndex++; + } + + break; + } + } + + lines.splice(insertIndex, 0, '', newEntry); + + fs.writeFileSync( + changelogPath, + lines.join('\n') + ); + + console.log('CHANGELOG.md updated'); + EOF + + - name: Commit CHANGELOG.md + if: steps.detect.outputs.is_fork == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + git remote set-url origin \ + https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git + + git add CHANGELOG.md + + if git diff --cached --quiet; then + exit 0 + fi + + git commit -m \ + "docs: update CHANGELOG.md for PR #${{ github.event.pull_request.number }}" + + git push \ + origin HEAD:${{ github.event.pull_request.head.ref }} From daef6f41b5d929b571a785f54396db82cc35eba7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 16 May 2026 10:55:03 +0000 Subject: [PATCH 5/5] docs: update CHANGELOG.md for PR #1 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a86487..4807d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ + +## [dev] - 2026-05-16 + +### Changed + +- remove chat history and user concept from protocol, server, handshake ([`3c601ed`](https://github.com/reekeer/fognode/commit/3c601eded9dcb68a492042efb338b9921b922618)) +- add Client/Server classes, remove logs, builtin commands, chat state ([`99c74ce`](https://github.com/reekeer/fognode/commit/99c74ce89763dde522415b661d968c1c7ac809bc)) + +### Changed + +- auto-release on main push using latest tag ([`a0afea7`](https://github.com/reekeer/fognode/commit/a0afea7183baf99281b6cd4679084942574cd646)) +- add pr summary ([`f733c98`](https://github.com/reekeer/fognode/commit/f733c9870b3c1174f9cb8e35b76a5206187dd9bf)) + + + # Changelog ## 0.1.0