Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
branches:
- main
tags:
- "v*"

permissions:
contents: write
Expand All @@ -13,6 +15,7 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
if: github.ref_type == 'tag' || github.event.base_ref == 'refs/heads/main'

environment:
name: pypi
Expand All @@ -23,44 +26,62 @@ jobs:
with:
fetch-depth: 0

- name: Check if HEAD is tagged
id: check_tag
run: |
TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
echo "No tag on HEAD, skipping release"
echo "has_tag=false" >> "$GITHUB_OUTPUT"
else
echo "Tag on HEAD: $TAG"
echo "has_tag=true" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
fi

- name: Skip if no tag
if: steps.check_tag.outputs.has_tag == 'false'
run: |
echo "Skipping release build — no tag on current commit"
exit 0

- name: Setup Python
if: steps.check_tag.outputs.has_tag == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install build dependencies
if: steps.check_tag.outputs.has_tag == 'true'
run: |
python -m pip install --upgrade pip
pip install build twine hatchling

- name: Build package
if: steps.check_tag.outputs.has_tag == 'true'
run: python -m build

- name: Verify package
if: steps.check_tag.outputs.has_tag == 'true'
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
if: steps.check_tag.outputs.has_tag == 'true'
id: notes
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --exclude="${{ steps.get_tag.outputs.tag }}" 2>/dev/null || echo "")
TAG="${{ steps.check_tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 --exclude="$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}..${{ steps.get_tag.outputs.tag }} --max-count=100 --pretty=format:"- %s")
LOG=$(git log ${PREV_TAG}..${TAG} --max-count=100 --pretty=format:"- %s")
fi

{
echo 'notes<<EOF'
echo "# fognode ${{ steps.get_tag.outputs.tag }}"
echo "# fognode $TAG"
echo
echo "## Changes"
echo
Expand All @@ -79,17 +100,19 @@ jobs:
echo 'EOF'
} >> "$GITHUB_OUTPUT"

- name: Create or update GitHub release
- name: Create GitHub release
if: steps.check_tag.outputs.has_tag == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_tag.outputs.tag }}
name: fognode ${{ steps.get_tag.outputs.tag }}
tag_name: ${{ steps.check_tag.outputs.tag }}
name: fognode ${{ steps.check_tag.outputs.tag }}
body: ${{ steps.notes.outputs.notes }}
generate_release_notes: false
files: |
dist/*

- name: Publish to PyPI
if: steps.check_tag.outputs.has_tag == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
attestations: false
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
# 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.



## [dev] - 2026-05-16

### Added

- add event classes in core/events.py ([`18bef8c`](https://github.com/reekeer/fognode/commit/18bef8c63a3ecd4b8b568960a5d46bd31ed6baa0))
- add on_event decorator and event-driven Server/Client API ([`bf132b5`](https://github.com/reekeer/fognode/commit/bf132b5ee0085e365ddfd8bd2523d2740292aa6c))

### Changed

- trigger release workflow on tag push instead of main branch ([`6e4087c`](https://github.com/reekeer/fognode/commit/6e4087c94e76a5fa359445ef1a4e853f4ba8483a))
- run release on main/tag push only when HEAD is tagged ([`2d6b8f2`](https://github.com/reekeer/fognode/commit/2d6b8f22b067cdf68a115217b83a37dfd47cc1cc))

### Changed

- disable local version suffix for PyPI compatibility ([`c539563`](https://github.com/reekeer/fognode/commit/c539563b076afe33985d82e967cb5b21181a3136))



## [dev] - 2026-05-16

Expand Down
80 changes: 42 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,82 +25,86 @@ Stack: TLS 1.2+ · X25519 · AESGCM-256 · HMAC-SHA256 · PBKDF2 · HKDF
pip install fognode
```

## Quick start (aiogram-style)
## Quick start

### Server

```python
from fognode import App, Cipher
from fognode import Server, MessageEvent

app = App(
host="0.0.0.0",
port=9443,
user="alice",
password="secret",
cipher=Cipher.AESGCM,
)
server = Server(host="0.0.0.0", port=9443, password="secret")

@app.on_message()
@server.on_event(MessageEvent)
async def echo(ctx):
await ctx.answer(f"echo: {ctx.message.text}")
if ctx.event.text:
await ctx.answer(f"echo: {ctx.event.text}")

@app.on_command("ping")
async def ping(ctx):
await ctx.answer("pong")

@app.on_connect()
@server.on_event(ConnectEvent)
async def on_connect(ctx):
print(f"+ {ctx.user}")
print("+ peer connected")

@server.on_event(DisconnectEvent)
async def on_disconnect(ctx):
print("- peer disconnected")

if __name__ == "__main__":
app.run()
server.run()
```

### Client

```python
from fognode import App, Cipher
from fognode import Client, MessageEvent, ClosedEvent

app = App.client(
connect_string="alice@oak-pine-stone-field:9443",
password="secret",
cipher=Cipher.AESGCM,
)
client = Client(connect_string="oak-pine-stone-field:9443", password="secret")

@app.on_message()
@client.on_event(MessageEvent)
async def on_message(ctx):
print(f"{ctx.message.user}: {ctx.message.text}")
print(f"msg: {ctx.event.text}")

@client.on_event(ClosedEvent)
async def on_closed(ctx):
print("connection closed")

if __name__ == "__main__":
app.run()
client.connect()
```

## Events

| Event | Server | Client | Description |
|---|---|---|---|
| `StartEvent` | ✅ | ✅ | Server/client started |
| `ConnectEvent` | ✅ | ✅ | Peer connected |
| `DisconnectEvent` | ✅ | ✅ | Peer disconnected |
| `MessageEvent` | ✅ | ✅ | Message received |
| `ClosedEvent` | ❌ | ✅ | Connection closed |
| `ErrorEvent` | ❌ | ✅ | Error occurred |

## Classic API

```python
from fognode import start_server, client_connect

ip, code, fp = start_server("0.0.0.0", 9443, "alice", "secret")
print(f"Connect: alice@{code}:9443")
ip, code, fp = start_server("0.0.0.0", 9443, "secret")
print(f"Connect: {code}:9443")
```

## Structure

```
src/fognode/
├── app.py # App class (aiogram-style)
├── cipher.py # Cipher enum
├── context.py # Context for handlers
├── message.py # Message dataclass
├── router.py # Router for handlers
├── filters/ # Command, Text filters
├── handlers/ # HandlerObject
├── types/ # exceptions, constants, protocol
├── app.py # Server, Client, Context
├── core/
│ ├── events.py # Event classes
│ ├── server.py # start_server()
│ ├── client.py # client_connect()
│ └── probe.py # probe_server()
├── crypto/ # primitives, kdf, cert, channel
├── ciphers/ # aesgcm, chacha20, x25519, hkdf, pbkdf2, hmac
├── wire/ # framing
├── auth/ # handshake
├── core/ # server, client, session, state
├── types/ # exceptions, constants, protocol
├── decorators.py # retry, rate_limited, timed
├── exceptions.py # errors
├── utils/ # ipwords, ratelimit, net
Expand Down
25 changes: 16 additions & 9 deletions examples/headless_server.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from __future__ import annotations

from fognode import start_server
from fognode import Server, ConnectEvent, DisconnectEvent, MessageEvent

ip, code, fp = start_server("0.0.0.0", 9443, "alice", "secret")
print(f"Server running at {ip} ({code}) port 9443")
print(f"Fingerprint: {fp}")
server = Server(host="0.0.0.0", port=9443, password="secret")

import signal
@server.on_event(ConnectEvent)
async def on_connect(ctx):
print("+ peer connected")

try:
signal.pause()
except (KeyboardInterrupt, AttributeError):
pass
@server.on_event(DisconnectEvent)
async def on_disconnect(ctx):
print("- peer disconnected")

@server.on_event(MessageEvent)
async def on_message(ctx):
if ctx.event.text:
await ctx.answer(f"echo: {ctx.event.text}")

if __name__ == "__main__":
server.run()
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ fognode = "fognode.cli.entrypoint:main"
[tool.hatch.version]
source = "vcs"

[tool.hatch.version.raw-options]
local_scheme = "no-local-version"

[tool.hatch.build.targets.wheel]
packages = ["src/fognode"]

Expand Down
17 changes: 15 additions & 2 deletions src/fognode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
from __future__ import annotations

from fognode import ciphers, decorators, exceptions, filters, types
from fognode.app import Client, Context, Message, Server
from fognode.app import Client, Context, Server
from fognode.core.client import client_connect
from fognode.core.events import (
BaseEvent,
ClosedEvent,
ConnectEvent,
DisconnectEvent,
ErrorEvent,
MessageEvent,
StartEvent,
)
from fognode.core.server import start_server
from fognode.crypto.channel import SecureChannel
from fognode.types import (
Expand Down Expand Up @@ -57,18 +66,22 @@
"CodeName",
"ConnectString",
"ConnectionInfo",
"ConnectEvent",
"Context",
"DEFAULT_HOST",
"DEFAULT_PORT",
"DisconnectEvent",
"ErrorEvent",
"Fingerprint",
"FrameError",
"HandshakeError",
"InfoMsg",
"IPAddress",
"MAX_MESSAGE_SIZE",
"Message",
"MessageEvent",
"MessageHandler",
"NONCE_LENGTH",
"StartEvent",
"OnConnect",
"OnDisconnect",
"OnMessage",
Expand Down
Loading