KDX Client & Server
A from-scratch Rust rebuild of KDX — an early-2000s Hotline-style chat and file-sharing system. A custom binary protocol over TLS 1.3, a challenge-response login where the password never leaves the machine, chat rooms with presence and private messages, threaded public news, a virtual file tree with drop boxes, in-app account and role administration, and resumable chunked transfers both directions. The client is a Tauri desktop app in the classic black / blood-red / phosphor-green aesthetic.
This document is the reference for what the interface does and how each feature works underneath. Read the interface sections to operate the app; read "Under the hood" to understand or extend the machinery each window drives.
kdx-protocol and kdx-crypto crates the server uses, so the wire format has a single implementation shared by both sides — no drift between what the server speaks and what the client expects.
System map
Every user action follows one path: the webview calls a Tauri command, the command forwards to the async client library, which frames the request and writes it to the TLS socket. Server pushes travel back the same channels as events.
| Crate / dir | Role |
|---|---|
kdx-protocol | Wire format: 20-byte framed packets, codec, fragmentation, typed messages. Shared by client and server. |
kdx-crypto | Argon2id password hashing + challenge-response helpers. |
kdx-server-core | Connection state machine, auth, chat rooms + presence, private messages, roles/privilege overrides, public news, file tree, transfers, tracker. |
kdx-storage | sqlx / SQLite persistence (accounts, roles, news, file tree, resume state). |
kdx-client-core | Async client library, zero UI deps. TOFU trust, transfer engines, event stream. |
kdxd | The server daemon binary (+ useradd subcommand). |
apps/kdx-client | Tauri v2 desktop app: a thin Rust shell + the webview frontend. |
The desktop
The client is a single OS window hosting a desktop metaphor: a thin top menubar (brand + connection chip) over an open desktop, plus a vertical Button Bar — itself a floating, draggable window sized to its content — that launches all 14 feature windows. Windows are lazily built on first open, are resizable and rememeber their geometry, can be minimized to a bottom-left dock, and support a per-window "Keep on Top" toggle. F1 opens a window switcher; Ctrl/Cmd+W closes the focused window.
Button Bar
- Server header — shows
host:portor "Not connected"; click opens Connect. - Feature buttons — News, Public Chat, Files, User List, Administration, Disconnect / Connect, Address Book, File Transfers, Messages (unread LED), Settings, About, Exit.
- Footer counters — users online · rooms · active transfers.
- Buttons auto-enable once connected; admin-only windows still open for everyone but the actions inside them are gated server-side.
The 14 windows
- Connect · Chat · Files · Transfers — the original core loop.
- User List · User Info · Messages — presence and private messaging.
- Public News — newsgroups and threaded posts.
- Administration · Accounts — roles, privilege overrides, disconnect/ban, in-app account creation.
- Address Book · Trackers · Settings · About — client-only conveniences.
Connect window
The entry point. Enter server host, port, and account, then Authenticate. The window remembers your last host / port / username via local storage, and stays available after login so it doubles as the reconnect surface.
The authenticate flow
- 1 · connect — TCP + TLS + KDX version handshake.
- 2 · trust check — first contact triggers the TOFU prompt (below).
- 3 · login — challenge-response; password never sent.
- 4 · join lobby — you land in
#lobbyand the Chat window focuses.
Status appears inline below the button: logged in as phraq (power user) or a red error line.
Trust-on-first-use prompt
KDX pins server certificates SSH-style. The first time you reach a server, or any time its key changes, the connect step is refused and an inline prompt shows the certificate fingerprint for you to verify out-of-band before trusting it.
sha256:… fingerprint. Trust & connect pins it and reconnects. Expected against the self-signed dev server — its key regenerates each run, so you'll re-prompt on every restart.
Chat window
A topic bar, a scrolling message stream, a live user list, and an input line. The title-bar tag shows the current member count.
Message line types
- normal —
<sender> text, phosphor green, sender highlighted. - action —
/memessages, italic,*** sender text. - system — join/leave and server notices, dim italic (clients can't forge these).
- warning — flood control and advisories, amber.
- error — server error frames, red.
Input commands
Both / and \ are accepted as the command prefix. New in v0.2: /away, /back, /afk, /clear, /help — plus right-click on any member for Send Message or Get Info, which opens the Messages or User Info window.
| You type | Effect | Command |
|---|---|---|
| hello there | Send a normal message to the room. | send_chat(flags 0) |
| /me waves | Action message. | send_chat(flags 1) |
| /topic digital freedom | Set the room topic (needs CHAT_SET_TOPIC). | set_topic |
| /away brb lunch | Action: "is away (brb lunch)". | send_chat(flags 1) |
| /back | Action: "is back". | send_chat(flags 1) |
| /afk | Action: "is afk". | send_chat(flags 1) |
| /clear | Clear the local transcript — client-side only, doesn't sync to other clients. | — |
| /help | Show the slash-command list inline. | — |
/name and /desc are recognized but stubbed — see Gaps & next steps.
Files window
A path bar with Up / New Folder / Refresh / Upload controls over a filterable directory listing. Each row shows a kind tag and — for files — a size. Clicking a folder navigates in; clicking a file opens a native save dialog and downloads it. Uploading opens a native file picker and sends into the current directory. Right-click a row for Download / Delete / Copy name / Get Info (Delete needs FILE_DELETE).
FILE_MANAGE_TREE.
Node kinds
[DIR]— a folder; click to enter.[FILE]— click to download to a chosen path.[DROP]— a drop box, shown in amber. You may upload into it but never list or read it — enforced server-side for every class, admins included.[UP]— an upload folder.
Transfers window
One card per active transfer, each with a live chunk-bitmap grid: every cell is one chunk, lighting up phosphor-green as it is sent (upload) or received and hash-verified (download). The card shows direction (↑/↓), percent, and done/total chunks; on completion it flips to verified ✓ or a failure message.
The bitmap is driven directly by the transfer_progress event's bitmap field, so what you see is the actual resume state — the same bytes persisted for resuming an interrupted transfer.
User List & User Info
User List is server-wide, not room-scoped — every logged-in session, anywhere on the server, with a class-colored bar per row, idle time, and a filter box. Click a row to open User Info (class, login time, idle duration, IP address); right-click for Send Message, Get Info, or Disconnect (needs USER_KICK).
Presence is pushed live: the presence event fires on every login/logout server-wide, so the list updates incrementally without a manual refresh.
Messages window
Classic two-pane private messaging: a conversation list on the left (unread ones highlighted red), the selected thread and an input line on the right. Sending echoes the message back into your own conversation so the sent line appears immediately. An incoming private message while the window is closed or on another conversation opens/updates it and lights the Button Bar's Messages LED.
Public News window
A three-pane newsreader: newsgroups on the left, the selected group's posts (threaded by reply depth) in the center, the selected post's full text on the right. New Message starts a thread, Reply nests under the open post; the author (or an admin) can delete a post, which removes it from the list for everyone.
USER_ADMIN.
Administration window
Three panels behind one Discord-shaped model: custom roles (name, sort rank, color swatch, a checkbox grid of privileges), a role-assignment panel (assign/unassign a role to a username, or look up a user's current roles), and a Disconnect panel (username, optional reason, and a ban duration — none, 5m, 1h, 1 day, 1 week, 1 year). Every action here needs USER_ADMIN (roles) or USER_KICK/USER_BAN (disconnect).
Accounts window
Closes the old "accounts only via CLI" gap. Lists every account with its base class and a summary of privilege overrides; Create takes a username, password, and base class; Edit changes the class and lets you flip each privilege to inherit / grant / revoke independent of role assignment. Password is only set at creation — there's no in-app reset yet. Needs USER_ADMIN.
Address Book, Trackers, Settings & About
Address Book
Client-only bookmarks: name, host:port, login, saved password, comments, connect count, and last-connected date. Click a row to pre-fill and auto-submit Connect; right-click for Connect / Edit / Remove.
Trackers
Queries the connected server's tracker for other live KDX servers it knows about (name, population, address, description) with a type-to-filter box; picking one pre-fills Connect with its address.
Settings
Theme picker (Blood Red, TRON, Amber, Matrix — live preview), a timestamps toggle, and identity fields (display name, description) that are saved locally but not yet sent to the server — see Gaps.
About
Static branding, version, and credits. Read-only.
Command reference
The webview calls these via __TAURI__.core.invoke. Each is a thin async forward into kdx-client-core; none holds business logic. Errors return a tagged object — kind: "untrusted_certificate" (with host, port, fingerprint, previous) drives the TOFU prompt, everything else is kind: "message". 33 commands total, up from 11 in v0.1.
Session & chat
| Command | Arguments | Returns | Effect |
|---|---|---|---|
| connect | host, port | "connected" | TCP + TLS + KDX handshake; spawns the event forwarder. May return an untrusted-cert error. |
| trust | host, port, fingerprint | — | Pin a fingerprint in known_hosts.toml so the next connect trusts it. |
| login | username, password | class: u8 | Challenge-response login; returns the account's base class (0–3). |
| join_room | room | — | Join (or create, with privilege) a room. |
| leave_room | room | — | Leave a room. |
| send_chat | room, flags, text | — | Send a message; flags bit 0 = action (/me). |
| set_topic | room, topic | — | Set a room topic (needs CHAT_SET_TOPIC). |
| send_private | to, text | — | Send a direct message; echoed back to the sender's own sessions. |
| disconnect | — | — | Close the connection. |
Presence & tracker
| Command | Arguments | Returns | Effect |
|---|---|---|---|
| list_users | — | PresenceUser[] | Server-wide roster: username, class, idle time, address. |
| get_user_info | username | PresenceUser | One user's presence snapshot. |
| list_servers | filter | TrackerServer[] | Query the tracker for other live servers matching a substring. |
Files & transfers
| Command | Arguments | Returns | Effect |
|---|---|---|---|
| list_files | path | {path, entries[]} | List a directory (recursive tree); entries carry name, kind, size. |
| create_folder | path, name, kind, min_read_class, min_write_class | FileListDto | Create a folder / drop box / upload folder with its ACL (needs FILE_MANAGE_TREE). |
| delete_path | path | FileListDto | Delete a file or folder (needs FILE_DELETE). |
| upload | local, remote_dir | — | Upload a local file; resolves when verified. Progress via events. |
| download | remote_path, local | — | Download to a local path, resuming from a sidecar if present. |
Roles & accounts USER_ADMIN
| Command | Arguments | Returns | Effect |
|---|---|---|---|
| list_roles | — | RoleInfo[] | Fetch all custom roles. |
| create_role | name, privileges, rank, color | RoleInfo[] | Define a new role; returns the updated list. |
| update_role | id, name, privileges, rank, color | RoleInfo[] | Modify an existing role. |
| delete_role | id | RoleInfo[] | Remove a role. |
| assign_role / unassign_role | username, role_id | RoleInfo[] | Grant or revoke a role on an account. |
| account_roles | username | String[] | List role IDs assigned to an account. |
| disconnect_user | username, reason, ban_secs | — | Kick and optionally ban (needs USER_KICK / USER_BAN). |
| list_accounts | — | AccountSummary[] | All accounts with class and privilege overrides. |
| create_account | username, password, base_class, granted, revoked | AccountSummary[] | Create a new account in-app. |
| update_account | username, base_class, granted, revoked | AccountSummary[] | Edit an account's class and privilege overrides. |
Public news
| Command | Arguments | Returns | Effect |
|---|---|---|---|
| list_newsgroups | — | NewsgroupInfo[] | All newsgroups with read/post class thresholds. |
| create_newsgroup | name, description, min_read_class, min_post_class | NewsgroupInfo[] | Create a newsgroup (needs USER_ADMIN). |
| list_thread | newsgroup_id | NewsPost[] | Fetch all posts in a group (flat; UI threads by parent_id). |
| create_post | newsgroup_id, parent_id, subject, body | NewsPost[] | Post a root (empty parent_id) or a reply. |
| delete_post | post_id | NewsPost[] | Delete a post (author, or USER_ADMIN for others). |
Event stream
The server's pushes and transfer progress arrive on one channel. kdx-client-core emits a typed Event; the Tauri shell forwards each as a "kdx" webview event whose payload is tagged by type (snake_case). The UI switches on that tag.
| type | Payload | What the UI does |
|---|---|---|
| connected | fingerprint | Informational; connection is considered up after login succeeds. |
| authenticated | class | Reserved — login() already returns the class directly. |
| chat | room, sender, timestamp, flags, text | Render a chat line (normal / action / system by flags). |
| user_list | room, users[] | Repaint the user list; update the member-count tag. |
| topic | room, topic | Update the topic bar. |
| presence | user, online | Server-wide login/logout; patches the User List incrementally. |
| private_message | from, to, timestamp, text | Opens/updates the Messages window; marks unread if it isn't the active conversation. |
| transfer_progress | id, direction, done, total, bitmap | Update / create the transfer card and its chunk-bitmap grid. |
| transfer_complete | id, direction, status, message | Mark the card done (status 0 = verified); refresh Files. |
| server_warning | text | Amber chat line (e.g. flood control). |
| server_error | text | Red chat line. |
| server_info | text | System-styled chat line. |
| disconnected | reason | Drop to offline, tag chat "offline", focus Connect for reconnection. |
Protocol & framing
Every message is a length-framed packet. The header is a fixed 20 bytes, all multi-byte fields big-endian:
// 20-byte header magic 4 b"KDX\0" version 1 packet_type 1 handshake / auth / file / chat / system ranges flags 2 bitfield incl. fragmented, more-fragments sequence 4 per-connection monotonic length 4 payload bytes (u32) reserved 2 checksum 2 CRC32C-folded — framing guard, not security
- Fragmentation — payloads over a 16 KB frame cap (a file chunk, say) split across frames flagged
FRAGMENTED/MORE_FRAGMENTSand reassemble on the far side, guarded by a size cap and timeout. - Integrity comes from TLS and per-chunk SHA-256 — the header checksum only catches framing corruption.
packet_type ranges (v0.2)
The original four ranges (handshake / auth / file / chat) are unchanged; presence, private messages, roles/accounts, and news each claimed a new range as they were wired in:
| Range | Group | Since |
|---|---|---|
| 0x00–0x0F | Connection — handshake, ping/pong, disconnect | v0.1 |
| 0x10–0x1F | Authentication — challenge/response | v0.1 |
| 0x20–0x2F | Files — list, transfer start/data/end, create/delete | v0.1 |
| 0x30–0x34 | Chat — message, join/leave, user list, topic | v0.1 |
| 0x35–0x39 | Presence — list/change, user info | P1 |
| 0x40–0x42 | Private messages & admin disconnect | P1 / P3 |
| 0x50–0x5C | Roles & accounts — CRUD, assign/unassign, list | P3 |
| 0x60–0x66 | Public news — newsgroups, threads, posts | P4 |
| 0xF0–0xF2 | System — error, warning, info | v0.1 |
Auth & trust
Challenge-response login
The server stores only an Argon2id hash. To log in, the client receives a one-time random challenge plus the account's salt and cost parameters, recomputes the Argon2 verifier locally, and answers with SHA-256(challenge ‖ verifier). The server compares in constant time. The password never crosses the wire — not even inside TLS — and a captured answer is useless because each login gets a fresh challenge. Unknown usernames still get a plausible decoy challenge, so the wire can't distinguish "no such user" from "wrong password."
Trust-on-first-use
The client pins the SHA-256 of the server's certificate per host:port in known_hosts.toml. An unknown or changed key fails the TLS handshake; the library surfaces the observed fingerprint (and any previous one) so the UI can prompt. This is the desktop-app answer to self-signed and rotating server certs without a CA.
Chat & user classes
Each chat room is its own actor task owning its membership, topic, and per-member flood gate — broadcasting a message is N channel sends with no shared lock on the hot path. A token-bucket flood gate (burst 5, refill 1/s) throttles a spammer with a server_warning. Clients cannot forge system-flagged messages.
Classes & privileges
Four base classes with strictly-increasing privilege sets; custom roles and per-account overrides can grant beyond or revoke from the class (see Roles & overrides).
| Class | Gains (on top of the one below) |
|---|---|
| guest | CHAT_SEND, FILE_LIST |
| user | CHAT_PRIVATE, FILE_DOWNLOAD, FILE_UPLOAD |
| power | CHAT_CREATE_ROOM, CHAT_SET_TOPIC, FILE_DELETE, FILE_MANAGE_TREE |
| admin | everything (USER_KICK, USER_BAN, USER_ADMIN, SERVER_ADMIN) |
The underlying bitflags, from kdx-server-core/src/auth/classes.rs:
CHAT_SEND = 1 << 0 CHAT_PRIVATE = 1 << 1 CHAT_CREATE_ROOM = 1 << 2 CHAT_SET_TOPIC = 1 << 3 FILE_LIST = 1 << 4 FILE_DOWNLOAD = 1 << 5 FILE_UPLOAD = 1 << 6 FILE_DELETE = 1 << 7 FILE_MANAGE_TREE = 1 << 8 // create/remove folders, dropboxes USER_KICK = 1 << 16 USER_BAN = 1 << 17 USER_ADMIN = 1 << 18 // create/edit accounts, manage roles SERVER_ADMIN = 1 << 19 // config, shutdown
Roles & overrides
On top of the four base classes, P3 added a Discord-shaped custom-role layer: a SysOp defines named roles (with a sort rank and a color) as arbitrary privilege bundles, and assigns any number of them to an account. Independently, each account can carry per-privilege grant/revoke bits that apply on top of everything else.
effective_privileges(base, roles, granted, revoked) computes: base class privileges, unioned with every assigned role's privileges, unioned with per-account grants — then the per-account revokes are subtracted last. Revokes always win, so an admin can silence one specific privilege on one specific account regardless of class or role.
This is exactly what the Administration and Accounts windows edit: roles are the reusable bundle, account overrides are the one-off exception.
Files & ACLs
The file tree is loaded from SQLite into memory (read-heavy, write-rare) and written through on mutation. Every folder carries a minimum-class-to-read and minimum-class-to-write. File bytes live on the filesystem, never in the database.
- Drop boxes reject listing and reading structurally — the check sits in the ACL layer and fires for every caller regardless of class, so write-only is a property of the node, not a convention.
- Downloads resolve the file through the same read-ACL path, so a file inside a drop box can be uploaded but never downloaded.
Transfers & resume
Files move in 32 KB chunks, each carrying its own SHA-256; the whole file is SHA-256-verified before it's accepted. Both directions are throttled by a token bucket and support resume — the difference is who remembers what:
Upload ↑
The server holds the resume state: a received-chunk bitmap in the transfer_state table, updated after every chunk-ack. A reconnecting client is told which chunks the server already has and sends only the rest. A crash loses at most one chunk.
Download ↓
The client holds the resume state: a .kdxpart sidecar next to the partial file records the bitmap. On resume the client sends its bitmap and the server streams only the missing chunks; the whole-file hash is checked before .part is renamed to the final name.
The chunk grid in the Transfers window is a direct view of this bitmap — the visualization and the resume mechanism are the same bytes.
Private messaging
Closes the v0.1 gap where CHAT_PRIVATE existed but wasn't wired end to end. send_private(to, text) sends a PrivateSend (0x40) packet; the server delivers a PrivateMessage (0x41) to every one of the recipient's sessions and echoes it back to every one of the sender's own sessions, so a user logged in twice sees the same conversation on both.
There's no persistence yet — a message not received live (recipient offline) is simply not delivered; there's no mailbox or history replay.
News & threading
Newsgroups and posts persist in SQLite (kdx-storage/src/news.rs), with domain logic in kdx-server-core/src/news.rs. A post is a root (empty parent_id) or a reply nested under any existing post in the same group; the server returns the whole group as a flat list and the client threads it by walking parent_id and indenting by depth.
Access is per-group, not per-post: min_read_class gates list_thread, min_post_class gates create_post. Deleting a post is restricted to its author or an admin (USER_ADMIN) — there's no edit, only delete-and-repost.
Running it
Server
# create an account: guest | user | power | admin cargo run -p kdxd -- useradd phraq s3cret power # start it (defaults to 0.0.0.0:10700, self-signed dev cert) cargo run -p kdxd
Client
cargo run -p kdx-client
The window opens; enter host 127.0.0.1, port 10700, your account, and Authenticate. Accept the TOFU fingerprint on first contact. No Node toolchain is needed — the bundled frontend is served directly.
cargo install tauri-cli --version '^2', then in apps/kdx-client/src-tauri run cargo tauri icon icons/icon.png and cargo tauri build for a signed .app + .dmg. A placeholder icon ships so cargo run works out of the box.
Verification
The whole stack runs green under cargo test --workspace — 162 tests (up from 107), no external services. The client library is tested headlessly against a real in-process kdxd (real TLS over 127.0.0.1), and the server's own tests are an independent second implementation of the wire flows.
- Protocol — codec round-trips, checksum corruption, fragment reassembly.
- Crypto — Argon2 verify, challenge round-trip, replay rejection.
- End-to-end — login, chat both directions, uploads/downloads with kill-and-resume in both directions, DropBox refusal, throttle pacing.
- Presence & messaging — server-wide login/logout fan-out, private message delivery + sender echo.
- Roles & accounts — effective-privilege resolution (base ∪ roles ∪ grants − revokes), CRUD round-trips.
- News — newsgroup creation, threaded post/reply, per-class read/post gating, delete authorization.
- Cross-process smoke —
cargo run -p kdx-client-core --example smokedrives a separately-runningkdxd: TOFU-pin → login → join → chat → list, over a real socket.
Gaps & next steps
list_servers) · Account admin (Accounts window, in-app create/edit) · Private messaging (Messages window, fully wired) · Custom roles (Administration window — didn't exist as a concept in v0.1) · Public news (didn't exist in v0.1).
localStorage only — never sent to the server or reflected in presence. The /name and /desc slash commands are recognized but stubbed with a "not supported yet" message. Needs a protocol field + server-side storage.
#lobby; join_room / leave_room and the room-scoped protocol exist, but there's no room list or switcher UI. A room picker is a UI-only addition on top of already-wired plumbing.
- Postgres — persistence is SQLite; a Postgres swap is confined to
kdx-storage. - No PM history — private messages aren't persisted; a message to an offline user is simply not delivered, there's no mailbox or replay.
- No password reset UI — Accounts sets a password only at creation; changing an existing account's password still needs direct DB access.
- File manager — no drag-drop and no batch upload/download; the resumable-chunk plumbing exists but the UI never surfaces a partial-download resume prompt.
- GUI manual smoke — the desktop window itself is verified by hand (
cargo run -p kdx-client); every layer it wraps is covered by automated tests.