A self-hosted protocol and relay for end-to-end encrypted, device-to-device communication.
Relayly is a protocol and reference relay for end-to-end encrypted, device-to-device
communication over untrusted infrastructure. Devices authenticate to the relay and pair
through a short out-of-band code; the session itself is established directly between
the two devices using the Noise Protocol Framework
(Noise_XX_25519_ChaChaPoly_BLAKE2s), giving mutual authentication and forward secrecy
that hold regardless of the relay's own integrity. The relay coordinates pairing and
forwards ciphertext, but holds no key material and is cryptographically excluded from
every session it carries; operators run it themselves rather than depend on a
third-party provider. See docs/PROTOCOL.md for the normative wire
specification.
- Protocol & Conformance
- Features
- How it Works
- Quick Start
- Official Client SDKs
- CLI Reference
- Configuration
- Admin UI
- WebSocket Connection Protocol
- Production Deployment
- Security & Privacy
- Contributing
docs/PROTOCOL.md is the normative wire spec. Five independent
SDKs (Go, TypeScript, Python, Rust, C++) implement it from scratch, and a required
cross-language CI matrix pairs every one of them against every other, and against
itself, before any change can merge. Conformance is defined the same way in the spec
itself (§10): pass that matrix, or it doesn't count.
graph LR
subgraph Devices["Your devices (pick any SDK)"]
D1["📱 sdk/ts"]
D2["💻 sdk/go"]
D3["🛰️ sdk/cpp"]
end
D1 <-->|"Noise XX handshake<br/>E2E ciphertext only"| S
D2 <-->|"Noise XX handshake<br/>E2E ciphertext only"| S
D3 <-->|"Noise XX handshake<br/>E2E ciphertext only"| S
S(["🔒 Relayly server<br/>authenticates + pairs devices<br/>holds no key material"])
The server and all five official SDKs (sdk/go, sdk/ts, sdk/py, sdk/rust,
sdk/cpp) speak Protocol v1 today. See RFC-000
for the drift this replaced, and docs/ROADMAP.md for what's next.
Protocol v1 links exactly one peer per device; multi-peer fan-out is scoped for v0.8.
| Feature | Detail |
|---|---|
| 🔐 End-to-End Encryption | Noise Protocol XX (X25519, ChaChaPoly) device-to-device; the relay holds no key material capable of reading messages |
| 📱 Device Pairing | 6-digit short code or QR code, no accounts required |
| ⚡ Real-time Forwarding | Low-latency WebSocket relaying with minimal server overhead |
| ♻️ Auto-reconnect | Exponential-backoff reconnection built into SDKs |
| 🗄️ Zero-Config Storage | Embedded SQLite storage, no external database required |
| 🐳 Infrastructure Ready | Pre-built Docker images and single portable binary |
| 🖥️ Interactive Admin | HTMX-powered dashboard for device and pairing management |
| 🔑 Trustless Architecture | Public Key Locking prevents server-side impersonation |
Relayly authenticates devices, mediates pairing, and relays two kinds of WebSocket frames: JSON control messages (text) and an opaque E2E envelope (binary). The Noise XX handshake and all message encryption happen device-to-device; the relay forwards the binary envelope verbatim and holds no key material that could decrypt it.
sequenceDiagram
participant A as Device A
participant R as Relayly Server
participant B as Device B
Note over A,R: Connect + control channel (JSON text frames)
A->>R: connect ?device_id&token
R->>A: welcome
A->>R: announce_key
Note over A,B: Pairing (in-band 6-digit code, relayed by R)
A->>R: pair_request
R->>A: pair_code
B->>R: pair_accept {code}
R->>A: pair_complete
R->>B: pair_complete
Note over A,B: Noise XX handshake — device-to-device (binary envelope, relayed verbatim)
B->>R: msg1
R->>A: msg1
A->>R: msg2
R->>B: msg2
B->>R: msg3
R->>A: msg3
Note over A,B: Transport (binary envelope, relayed verbatim — R never decrypts)
A->>R: ciphertext
R->>B: ciphertext, byte-identical
B->>R: ciphertext
R->>A: ciphertext, byte-identical
Relayly runs Noise Protocol XX (Noise_XX_25519_ChaChaPoly_BLAKE2s) between the two
paired devices. This provides:
- Mutual Authentication: the two devices verify each other's static public keys directly with each other, the relay is not a party to the handshake.
- Forward Secrecy: session keys are ephemeral; a fresh handshake runs on reconnect
per the initiator rule in
docs/PROTOCOL.md. - Zero-Knowledge Relay: the relay holds no cipher states and cannot decrypt the binary envelope; it only sees who is talking to whom and how much, not the content. Client-side key pinning (not just the relay's own announced-key check) is the real security boundary, see §7 of the spec.
Two layers of key locking guard against a compromised relay: devices pin the peer's static key on first pairing (the actual boundary), and the relay separately locks each device's announced key as defense in depth. See RFC-000 for how this design was chosen.
The fastest way to get a relay running is via Docker:
git clone https://github.com/NIKX-Tech/relayly.git
cd relayly
docker compose up --build -d
# Want to test it? Try the Chat Demo (registers itself, no setup needed):
# cd examples/go/chat && go run .Check out the examples/ directory for ready-to-run implementations:
| Example | Language | Description |
|---|---|---|
| Chat Demo | Go | (Recommended) Live E2EE chat between two terminals |
| Clipboard Sync | Go | Sync clipboard across devices automatically |
| Basic Echo | Go | Simplest possible connect and message loop |
| Pair & Send | Go | CLI pairing and one-shot message exchange |
| Node.js Send | TypeScript | Connect, pair, and send from Node.js |
| Echo Server | TypeScript | Minimal echo client in TypeScript |
# Build the binary (Requires Go 1.22+)
go build -o relayly ./cmd/relayly
# Start the server
./relayly start
# In another terminal, generate a pairing code
./relayly pair "My Phone"Official SDKs for Go, TypeScript, Python, Rust, and C++ are in the sdk/ directory and published to their respective package registries (C++ is consumed via CMake FetchContent, see below).
go get github.com/NIKX-Tech/relayly/sdk/goimport relayly "github.com/NIKX-Tech/relayly/sdk/go"
key, _ := relayly.LoadOrGenerateKey("~/.relayly/device.key")
// deviceToken comes from POST /api/v1/devices (or `relayly pair`)
client, _ := relayly.Connect(ctx, "wss://your-server/ws", relayly.Options{
DeviceID: "my-laptop",
DeviceToken: deviceToken,
PrivateKey: key,
})
defer client.Close()
code, _ := client.RequestPairCode(ctx)
fmt.Println("Code:", code.Short)
peer, _ := client.AcceptPair(ctx, "483921")
client.Send(ctx, peer.ID, []byte("hello!"))
msg := <-client.Messages()pkg.go.dev/github.com/NIKX-Tech/relayly/sdk/go
npm install relaylyimport { RelaylyClient, generateKey } from 'relayly';
// deviceToken comes from POST /api/v1/devices
const client = new RelaylyClient('wss://your-server/ws', {
deviceId: 'my-laptop',
deviceToken,
keyPair: generateKey(),
});
await client.connect();
const code = await client.requestPairCode();
console.log('Code:', code.shortCode);
const peer = await client.acceptPair('483921');
await client.send(peer.id, 'hello!');
client.on('message', (msg) => console.log(msg.payload));npmjs.com/package/relayly - works in Node.js, browsers, and React Native.
pip install relaylyimport asyncio, relayly
async def main():
key = relayly.load_or_generate_key("~/.relayly/device.key")
# device_token comes from POST /api/v1/devices
client = await relayly.connect("wss://your-server/ws", relayly.Options(
device_id="my-laptop",
device_token=device_token,
private_key=key,
))
code = await client.request_pair_code()
print("Code:", code.short)
peer = await client.accept_pair("483921")
await client.send(peer.id, b"hello!")
async for msg in client.messages():
print(msg.payload.decode())
asyncio.run(main())[dependencies]
relayly = "0.6"
tokio = { version = "1", features = ["full"] }use relayly::{connect, load_or_generate_key, Options};
use std::path::Path;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = load_or_generate_key(Path::new("~/.relayly/device.key"))?;
// device_token comes from POST /api/v1/devices
let (client, mut messages) = connect("wss://your-server/ws", Options {
device_id: "my-laptop".into(),
device_token,
private_key: key,
..Default::default()
}).await?;
tokio::spawn(async move {
while let Some(msg) = messages.recv().await {
println!("[{}] {}", msg.from, String::from_utf8_lossy(&msg.payload));
}
});
let code = client.request_pair_code().await?;
println!("Share this code: {}", code.short);
let peer = code.wait().await?;
client.send(&peer.id, b"hello!").await?;
Ok(())
}include(FetchContent)
FetchContent_Declare(relayly
GIT_REPOSITORY https://github.com/NIKX-Tech/relayly.git
SOURCE_SUBDIR sdk/cpp
GIT_TAG main
)
FetchContent_MakeAvailable(relayly)
target_link_libraries(your_target PRIVATE relayly::relayly)#include <relayly/client.hpp>
#include <relayly/crypto.hpp>
using namespace relayly;
auto key = PrivateKey::LoadOrGenerate("~/.relayly/device.key");
Options opts;
opts.device_id = "my-laptop";
opts.device_token = device_token; // from POST /api/v1/devices
opts.private_key = key;
auto client = Client::Connect("wss://your-server/ws", opts);
auto code = client->RequestPairCode();
std::cout << "Code: " << code.short_code() << "\n";
auto peer = client->AcceptPair("483921").get();
std::string hello = "hello!";
client->Send(peer.id, std::as_bytes(std::span(hello)));See sdk/cpp/README.md for the threading model and dependency
rationale.
| Command | Description |
|---|---|
relayly start |
Start relay + admin servers |
relayly start --config path/to/relayly.yaml |
Use custom config |
relayly pair <name> |
Register device, print QR code |
relayly pair <name> --no-qr |
Print token only |
relayly link <id1> <id2> |
Pair two devices for relaying |
relayly status |
Show connected devices + uptime |
relayly status --format=json |
Machine-readable output |
All options can be set in config/relayly.yaml or via environment variables (RELAYLY_<KEY>, e.g. RELAYLY_PORT=9090):
| Key | Default | Description |
|---|---|---|
host |
0.0.0.0 |
Listen address |
port |
8080 |
Relay WebSocket port |
db.path |
./data/relayly.db |
SQLite file |
admin.enabled |
true |
Enable admin UI |
admin.host |
127.0.0.1 |
Admin bind address |
admin.port |
8081 |
Admin port |
log.level |
info |
`debug |
log.format |
json |
`json |
tls.enabled |
false |
Enable TLS (or use reverse proxy) |
Visit http://localhost:8081 after starting the server.
- Dashboard: Live connection count, uptime, device list.
- Devices: Full device management with one-click revoke.
- Auto-refreshes every 5 seconds via HTMX.
⚠️ The admin UI binds to127.0.0.1by default. Do not expose it publicly without authentication.
This is a summary; docs/PROTOCOL.md is the normative spec.
Clients connect to:
ws://<host>:<port>/ws?device_id=<uuid>&token=<device-token>
Auth happens at the HTTP layer (query params, before the WebSocket upgrade). There is no in-band auth frame and no client<->server cryptographic handshake.
- Text frames carry the JSON control channel:
welcome,announce_key, pairing (pair_request/pair_code/pair_accept/pair_complete),peer_status,ping/pong,error. - Binary frames carry a 1-byte-prefixed E2E envelope (
0x01Noise handshake message,0x02Noise transport ciphertext) between the two paired devices. The relay forwards these verbatim, it does not parse, decrypt, or hold any key material for them.
Two layers, both described in full in docs/PROTOCOL.md §7: each device pins its peer's
static key on first pairing (the real security boundary), and the relay separately locks
each device's announced key as defense in depth against a third party impersonating a
device to the relay. Neither substitutes for the other.
relay.yourdomain.com {
reverse_proxy localhost:8080
}- Run behind TLS (Caddy / nginx)
- Bind admin UI to
127.0.0.1(default) - Mount
/dataas a persistent volume (contains the database) - Back up
/data/relayly.db
Relayly is built on the principle of Privacy by Design:
- Zero Data Harvesting: No accounts, emails, or tracking.
- Public Key Locking: Once a device connects, the server "locks" it to that public key. Even a compromised server cannot swap keys without manual admin intervention.
- Auditability: Small, dependency-light codebase written in memory-safe Go.
relayly/
├── cmd/relayly/ # Main server entry point
├── internal/ # Private server logic (Relay, Database, Admin)
├── sdk/ # Official Client SDKs (Go, TS, Python, Rust, C++)
├── examples/ # Reference implementations
├── docs/ # Protocol specs & architecture deep-dives
├── .github/ # Unified CI/CD workflows
└── Dockerfile # Optimized production image
Have questions or want to show off what you're building? Join our Discord Server to connect with other developers and get real-time support.
Contributions are welcome! Please read our Contributing Guide for details on our code of conduct, and the process for submitting pull requests to us.
Distributed under the MIT License. See LICENSE for more information.
