1 Commits

Author SHA1 Message Date
user
bd4326ad6f docs: update README schema section to match actual database schema
All checks were successful
check / check (push) Successful in 1m11s
Update the Schema section and related references throughout README.md to
accurately reflect the current 001_initial.sql migration:

- Rename 'users' table to 'sessions' with new columns: uuid, password_hash,
  signing_key, away_message
- Add new 'clients' table (uuid, session_id FK, token, created_at, last_seen)
- Add topic_set_by and topic_set_at columns to 'channels' table
- Update channel_members FK from user_id to session_id
- Add params column to messages table
- Update client_queues FK from user_id to client_id
- Update Queue Architecture diagram labels and surrounding text
- Update In-Memory Broker description to use client_id terminology
- Update Multi-Client Model MVP note to reflect sessions/clients split
2026-03-17 02:17:34 -07:00
23 changed files with 606 additions and 2643 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist .PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
BINARY := neoircd BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -7,21 +7,10 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build all: check build
# ensure-web-dist creates placeholder files so //go:embed dist/* in build:
# web/embed.go resolves without a full Node.js build. The real SPA is
# built by the web-builder Docker stage; these placeholders let
# "make test" and "make build" work outside Docker.
ensure-web-dist:
@if [ ! -d web/dist ]; then \
mkdir -p web/dist && \
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
echo "==> Created placeholder web/dist/ for go:embed"; \
fi
build: ensure-web-dist
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
lint: ensure-web-dist lint:
golangci-lint run --config .golangci.yml ./... golangci-lint run --config .golangci.yml ./...
fmt: fmt:
@@ -31,7 +20,7 @@ fmt:
fmt-check: fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: ensure-web-dist test:
go test -timeout 30s -v -race -cover ./... go test -timeout 30s -v -race -cover ./...
# check runs all validation without making changes # check runs all validation without making changes

523
README.md
View File

@@ -113,9 +113,8 @@ mechanisms or stuffing data into CTCP.
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`. `KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
are unique per server. Identity starts with a key a nick is a display name. are unique per server. There are no accounts — identity is a key, a nick is a
Accounts are optional: you can create an anonymous session instantly, or display name.
set a password via the PASS command for multi-client access to a single session.
### On the resemblance to JSON-RPC ### On the resemblance to JSON-RPC
@@ -149,59 +148,28 @@ not arbitrary choices — each one follows from the project's core thesis that
IRC's command model is correct and only the transport and session management IRC's command model is correct and only the transport and session management
need to change. need to change.
### Identity & Sessions — Cookie-Based Authentication ### Identity & Sessions — No Accounts
The server uses **HTTP cookies** for all authentication. There is no separate There are no accounts, no registration, no passwords. Identity is a signing
registration step — sessions start anonymous and can optionally set a password key; a nick is just a display name. The two are decoupled.
for multi-client access.
#### Session Creation
- **Session creation**: client sends `POST /api/v1/session` with a desired - **Session creation**: client sends `POST /api/v1/session` with a desired
nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing nick → server assigns an **auth token** (64 hex characters of
a cryptographically random token (64 hex characters) and returns the user cryptographically random bytes) and returns the user ID, nick, and token.
ID and nick in the JSON response body. No token appears in the JSON body. - The auth token implicitly identifies the client. Clients present it via
- The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS. `Authorization: Bearer <token>`.
Clients never need to handle the token directly — the browser/HTTP client
manages cookies automatically.
- Sessions start anonymous — no password required. When the session expires
or the user QUITs, the nick is released.
#### Setting a Password (Optional, for Multi-Client Access)
For users who want to access the same session from multiple devices:
- **Set password via IRC PASS command**: the authenticated client sends
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`.
The server hashes the password with bcrypt and stores it on the session.
Password must be at least 8 characters.
- **Login from another client**: `POST /api/v1/login` with nick and password →
server verifies the password, creates a new client for the existing session,
and sets an auth cookie. Channel memberships and message queues are shared.
Login only works while the session still exists — if all clients have logged
out or the user has sent QUIT, the session is deleted and the password is
lost.
#### Common Properties
- Nicks are changeable via the `NICK` command; the server-assigned user ID is - Nicks are changeable via the `NICK` command; the server-assigned user ID is
the stable identity. the stable identity.
- Server-assigned IDs — clients do not choose their own IDs. - Server-assigned IDs — clients do not choose their own IDs.
- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry - Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded
encoded in the token, no client-side decode. The server is the sole authority in the token, no client-side decode. The server is the sole authority on
on cookie validity. token validity.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. **Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding
Anonymous sessions preserve that simplicity — instant access, zero friction. registration, email verification, or OAuth would solve a problem nobody asked
But some users want to access the same session from multiple devices without about and add complexity that drives away casual users. Identity verification
a bouncer. The PASS command enables multi-client login without adding friction is handled at the message layer via cryptographic signatures (see
for casual users: if you don't need multi-client, just create a session and [Security Model](#security-model)), not at the session layer.
go. Cookie-based auth eliminates token management from client code entirely —
browsers and HTTP cookie jars handle it automatically. Note: both anonymous
and password-protected sessions are deleted when the last client disconnects
(QUIT or logout). Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent of
password status.
### Nick Semantics ### Nick Semantics
@@ -228,7 +196,7 @@ A single user session can have multiple clients (phone, laptop, terminal).
- The server fans out all S2C messages to every active client queue for that - The server fans out all S2C messages to every active client queue for that
user session. user session.
- `GET /api/v1/messages` delivers from the calling client's specific queue, - `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth cookie. identified by the auth token.
- Client queues have **independent expiry/pruning** — one client going offline - Client queues have **independent expiry/pruning** — one client going offline
doesn't affect others. doesn't affect others.
@@ -239,11 +207,12 @@ User Session
└── Client C (token_c, queue_c) └── Client C (token_c, queue_c)
``` ```
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new **Current MVP note:** The current implementation creates a new session (with new
client to an existing session (one that has a password set via PASS command), nick) per `POST /api/v1/session` call. True multi-client (multiple clients
enabling true multi-client support (multiple cookies sharing one nick/session sharing one session/nick) is supported by the schema — `clients` references
with independent message queues). Sessions without a password cannot be `sessions` via `session_id`, and `client_queues` is keyed by `client_id` — but
logged into. the session creation endpoint does not yet support "add a client to an existing
session." This will be added post-MVP.
**Rationale:** The fundamental IRC mobile problem is that you can't have your **Rationale:** The fundamental IRC mobile problem is that you can't have your
phone and laptop connected simultaneously without a bouncer. Server-side phone and laptop connected simultaneously without a bouncer. Server-side
@@ -350,22 +319,19 @@ over binary is measured in bytes per message, not meaningful bandwidth. The
canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
matters for signing. matters for signing.
### Why Opaque Cookies Instead of JWTs ### Why Opaque Tokens Instead of JWTs
JWTs encode claims that clients can decode and potentially rely on. This JWTs encode claims that clients can decode and potentially rely on. This
creates a coupling between token format and client behavior. If the server creates a coupling between token format and client behavior. If the server
needs to revoke a token, change the expiry model, or add/remove claims, JWT needs to revoke a token, change the expiry model, or add/remove claims, JWT
clients may break or behave incorrectly. clients may break or behave incorrectly.
Opaque auth cookies are simpler: Opaque tokens are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash - Server generates 32 random bytes → hex-encodes → stores hash
sets raw hex as an HttpOnly cookie - Client presents the token; server looks it up
- On each request, server hashes the cookie value and looks it up - Revocation is a database delete
- Revocation is a database delete (cookie becomes invalid immediately)
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks - No clock skew issues, no algorithm confusion, no "none" algorithm attacks
- Cookie format can change without breaking clients - Token format can change without breaking clients
- Clients never handle tokens directly — browsers and HTTP cookie jars
manage everything automatically
--- ---
@@ -389,18 +355,15 @@ The entire read/write loop for a client is two endpoints. Everything else
### Session Lifecycle ### Session Lifecycle
#### Session Creation
``` ```
┌─ Client ──────────────────────────────────────────────────┐ ┌─ Client ──────────────────────────────────────────────────┐
│ │ │ │
│ 1. POST /api/v1/session {"nick":"alice"} │ │ 1. POST /api/v1/session {"nick":"alice"} │
│ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │ │ → {"id":1, "nick":"alice", "token":"a1b2c3..."}
│ → {"id":1, "nick":"alice"} │
│ │ │ │
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │ │ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
│ → {"status":"joined","channel":"#general"} │ │ → {"status":"joined","channel":"#general"} │
│ (Cookie sent automatically on all subsequent requests) │ (Server fans out JOIN event to all #general members)
│ │ │ │
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │ │ 3. POST /api/v1/messages {"command":"PRIVMSG", │
│ "to":"#general","body":["hello"]} │ │ "to":"#general","body":["hello"]} │
@@ -417,37 +380,7 @@ The entire read/write loop for a client is two endpoints. Everything else
│ 6. POST /api/v1/messages {"command":"QUIT"} │ │ 6. POST /api/v1/messages {"command":"QUIT"} │
│ → {"status":"quit"} │ │ → {"status":"quit"} │
│ (Server broadcasts QUIT, removes from channels, │ │ (Server broadcasts QUIT, removes from channels, │
│ deletes session, releases nick, clears cookie) │ deletes session, releases nick)
│ │
└────────────────────────────────────────────────────────────┘
```
#### Multi-Client via Password
```
┌─ Client A ────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session {"nick":"alice"} │
│ → Set-Cookie: neoirc_auth=<token_a>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ │
│ 2. POST /api/v1/messages │
│ {"command":"PASS","body":["s3cret!!"]} │
│ → {"status":"ok"} │
│ (Password set via IRC PASS command) │
│ │
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
│ │
└────────────────────────────────────────────────────────────┘
┌─ Client B (another device, while session is still active) ┐
│ │
│ 3. POST /api/v1/login │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → Set-Cookie: neoirc_auth=<token_b>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ (New client added to existing session — channels │
│ and message queues are preserved.) │
│ │ │ │
└────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────┘
``` ```
@@ -528,7 +461,7 @@ the same JSON envelope:
| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | | `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). |
| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | | `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** |
| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. | | `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. |
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | | `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. |
**Important invariants:** **Important invariants:**
@@ -734,35 +667,6 @@ Change the user's nickname.
**IRC reference:** RFC 1459 §4.1.2 **IRC reference:** RFC 1459 §4.1.2
#### PASS — Set Session Password
Set a password on the current session, enabling multi-client login via
`POST /api/v1/login`. The password is hashed with bcrypt and stored
server-side.
**C2S:**
```json
{"command": "PASS", "body": ["mypassword"]}
```
**Behavior:**
- `body[0]` is the password. Must be at least 8 characters.
- On success, the server responds with `{"status": "ok"}`.
- If the password is too short or missing, the server sends
ERR_NEEDMOREPARAMS (461) via the message queue.
- Calling PASS again overwrites the previous password.
- Once a password is set, `POST /api/v1/login` can be used with the nick
and password to create additional clients on the same session.
**Response:** `200 OK`
```json
{"status": "ok"}
```
**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for
session password management.
#### TOPIC — Set Channel Topic #### TOPIC — Set Channel Topic
Set or change a channel's topic. Set or change a channel's topic.
@@ -829,7 +733,7 @@ Destroy the session and disconnect from the server.
quitting user. The quitting user does **not** receive their own QUIT. quitting user. The quitting user does **not** receive their own QUIT.
- The user is removed from all channels. - The user is removed from all channels.
- Empty channels are deleted (ephemeral). - Empty channels are deleted (ephemeral).
- The user's session is destroyed — the auth cookie is invalidated, the nick - The user's session is destroyed — the auth token is invalidated, the nick
is released. is released.
- Subsequent requests with the old token return HTTP 401. - Subsequent requests with the old token return HTTP 401.
@@ -1047,13 +951,12 @@ carries IRC-style parameters (e.g., channel name, target nick).
Inspired by IRC, simplified: Inspired by IRC, simplified:
| Mode | Name | Meaning | | Mode | Name | Meaning |
|------|----------------|---------| |------|--------------|---------|
| `+i` | Invite-only | Only invited users can join | | `+i` | Invite-only | Only invited users can join |
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | | `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
| `+s` | Secret | Channel hidden from LIST response | | `+s` | Secret | Channel hidden from LIST response |
| `+t` | Topic lock | Only operators can change the topic | | `+t` | Topic lock | Only operators can change the topic |
| `+n` | No external | Only channel members can send messages to the channel | | `+n` | No external | Only channel members can send messages to the channel |
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
**User channel modes (set per-user per-channel):** **User channel modes (set per-user per-channel):**
@@ -1064,64 +967,14 @@ Inspired by IRC, simplified:
**Status:** Channel modes are defined but not yet enforced. The `modes` column **Status:** Channel modes are defined but not yet enforced. The `modes` column
exists in the channels table but the server does not check modes on actions. exists in the channels table but the server does not check modes on actions.
Exception: `+H` (hashcash) is fully enforced — see below.
### Per-Channel Hashcash (Anti-Spam)
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
anti-spam mechanism: channel operators set a difficulty level, and clients must
compute a proof-of-work stamp bound to the specific channel and message before
sending.
**Setting the requirement:**
```
MODE #channel +H <bits> — require <bits> leading zero bits (1-40)
MODE #channel -H — disable hashcash requirement
```
**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter`
- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp)
- `YYMMDD` — current date (prevents old token reuse)
- `channel` — channel name (prevents cross-channel reuse)
- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message)
- `counter` — hex nonce
**Sending a message to a hashcash-protected channel:**
Include the hashcash stamp in the `meta` field:
```json
{
"command": "PRIVMSG",
"to": "#general",
"body": ["hello world"],
"meta": {
"hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a"
}
}
```
**Server validation:** The server checks that the stamp is well-formed, meets
the required difficulty, is bound to the correct channel and message body, has a
recent date, and has not been previously used. Spent stamps are cached for 1
year to prevent replay attacks.
**Error responses:** If the channel requires hashcash and the stamp is missing,
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
descriptive reason.
**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)`
to compute stamps. Higher bit counts take exponentially longer to compute.
--- ---
## API Reference ## API Reference
All endpoints accept and return `application/json`. Authenticated endpoints All endpoints accept and return `application/json`. Authenticated endpoints
require the `neoirc_auth` cookie, which is set automatically by require `Authorization: Bearer <token>` header. The token is obtained from
`POST /api/v1/session` and `POST /api/v1/login`. `POST /api/v1/session`.
All API responses include appropriate HTTP status codes. Error responses have All API responses include appropriate HTTP status codes. Error responses have
the format: the format:
@@ -1150,18 +1003,11 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | | `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created` **Response:** `201 Created`
The response sets an `neoirc_auth` HttpOnly cookie containing the auth token.
The JSON body does **not** include the token.
```
Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
```
```json ```json
{ {
"id": 1, "id": 1,
"nick": "alice" "nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
} }
``` ```
@@ -1169,16 +1015,7 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
|---------|---------|-------------| |---------|---------|-------------|
| `id` | integer | Server-assigned user ID | | `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick (always matches request on success) | | `nick` | string | Confirmed nick (always matches request on success) |
| `token` | string | 64-character hex auth token. Store this — it's the only credential. |
**Cookie properties:**
| Property | Value |
|------------|-------|
| `Name` | `neoirc_auth` |
| `HttpOnly` | `true` (not accessible from JavaScript) |
| `SameSite` | `Strict` (prevents CSRF) |
| `Secure` | `true` when behind TLS |
| `Path` | `/` |
**Errors:** **Errors:**
@@ -1191,61 +1028,10 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
**curl example:** **curl example:**
```bash ```bash
# Use -c to save cookies, -b to send them TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' -d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
``` echo $TOKEN
### POST /api/v1/login — Login to Account
Authenticate with a nick and password (set via the PASS IRC command). Creates a
new client for the existing session, preserving channel memberships and message
queues. This is how multi-client access works: each login adds a new client to
the session with its own auth cookie and message delivery queue.
On successful login, the server enqueues MOTD messages and synthetic channel
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
new client's queue, so the client can immediately restore its UI state.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | Must match an active session with a password set |
| `password` | string | Yes | Must match the session's password |
**Response:** `200 OK`
The response sets an `neoirc_auth` HttpOnly cookie for the new client.
```json
{
"id": 1,
"nick": "alice"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Session ID |
| `nick` | string | Current nick |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `nick and password required` | Missing nick or password |
| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set |
**curl example:**
```bash
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}'
``` ```
### GET /api/v1/state — Get Session State ### GET /api/v1/state — Get Session State
@@ -1289,13 +1075,13 @@ Each channel object:
**curl example:** **curl example:**
```bash ```bash
curl -s http://localhost:8080/api/v1/state \ curl -s http://localhost:8080/api/v1/state \
-b cookies.txt | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
**Reconnect with channel state initialization:** **Reconnect with channel state initialization:**
```bash ```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \ curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-b cookies.txt | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
### GET /api/v1/messages — Poll Messages (Long-Poll) ### GET /api/v1/messages — Poll Messages (Long-Poll)
@@ -1355,12 +1141,14 @@ real-time endpoint — clients call it in a loop.
**curl example (immediate):** **curl example (immediate):**
```bash ```bash
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq . curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
**curl example (long-poll, 15s):** **curl example (long-poll, 15s):**
```bash ```bash
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq . curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
### POST /api/v1/messages — Send Command ### POST /api/v1/messages — Send Command
@@ -1387,7 +1175,6 @@ reference with all required and optional fields.
| `JOIN` | `to` | | 200 OK | | `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK | | `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK | | `NICK` | `body` | | 200 OK |
| `PASS` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK | | `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK | | `MODE` | `to` | | 200 OK |
| `NAMES` | `to` | | 200 OK | | `NAMES` | `to` | | 200 OK |
@@ -1402,14 +1189,14 @@ All IRC commands return HTTP 200 OK. IRC-level success and error responses
are delivered as **numeric replies** through the message queue (see are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are [Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid reserved for transport-level problems: malformed JSON (400), missing/invalid
auth cookies (401), and server errors (500). auth tokens (401), and server errors (500).
**HTTP errors (transport-level only):** **HTTP errors (transport-level only):**
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `invalid request` | Malformed JSON or empty command | | 400 | `invalid request` | Malformed JSON or empty command |
| 401 | `unauthorized` | Missing or invalid auth cookie | | 401 | `unauthorized` | Missing or invalid auth token |
| 500 | `internal error` | Server-side failure | | 500 | `internal error` | Server-side failure |
**IRC numeric error replies (delivered via message queue):** **IRC numeric error replies (delivered via message queue):**
@@ -1500,11 +1287,11 @@ events). Event messages are delivered via the live queue only.
```bash ```bash
# Latest 50 messages in #general # Latest 50 messages in #general
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \ curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
-b cookies.txt | jq . -H "Authorization: Bearer $TOKEN" | jq .
# Older messages (pagination) # Older messages (pagination)
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \ curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
-b cookies.txt | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
### GET /api/v1/channels — List Channels ### GET /api/v1/channels — List Channels
@@ -1535,22 +1322,18 @@ List members of a channel. The `{name}` parameter is the channel name
**curl example:** **curl example:**
```bash ```bash
curl -s http://localhost:8080/api/v1/channels/general/members \ curl -s http://localhost:8080/api/v1/channels/general/members \
-b cookies.txt | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
### POST /api/v1/logout — Logout ### POST /api/v1/logout — Logout
Destroy the current client's session cookie and server-side client record. Destroy the current client's auth token. If no other clients remain on the
If no other clients remain on the session, the user is fully cleaned up: session, the user is fully cleaned up: parted from all channels (with QUIT
parted from all channels (with QUIT broadcast to members), session deleted, broadcast to members), session deleted, nick released.
nick released. The auth cookie is cleared in the response.
**Request:** No body. Requires auth cookie. **Request:** No body. Requires auth.
**Response:** `200 OK` **Response:** `200 OK`
The response clears the `neoirc_auth` cookie.
```json ```json
{"status": "ok"} {"status": "ok"}
``` ```
@@ -1559,11 +1342,12 @@ The response clears the `neoirc_auth` cookie.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth cookie | | 401 | `unauthorized` | Missing or invalid auth token |
**curl example:** **curl example:**
```bash ```bash
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq . curl -s -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
### GET /api/v1/users/me — Current User Info ### GET /api/v1/users/me — Current User Info
@@ -1587,7 +1371,7 @@ Return the current user's session state. This is an alias for
**curl example:** **curl example:**
```bash ```bash
curl -s http://localhost:8080/api/v1/users/me \ curl -s http://localhost:8080/api/v1/users/me \
-b cookies.txt | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
### GET /api/v1/server — Server Info ### GET /api/v1/server — Server Info
@@ -1615,40 +1399,13 @@ Return server metadata. No authentication required.
### GET /.well-known/healthcheck.json — Health Check ### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required. Returns server Standard health check endpoint. No authentication required.
health status and runtime statistics.
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
{ {"status": "ok"}
"status": "ok",
"now": "2024-01-15T12:00:00.000000000Z",
"uptimeSeconds": 3600,
"uptimeHuman": "1h0m0s",
"version": "0.1.0",
"appname": "neoirc",
"maintenanceMode": false,
"sessions": 42,
"clients": 85,
"queuedLines": 128,
"channels": 7,
"connectionsSinceBoot": 200,
"sessionsSinceBoot": 150,
"messagesSinceBoot": 5000
}
``` ```
| Field | Description |
| ---------------------- | ------------------------------------------------- |
| `sessions` | Current number of active sessions |
| `clients` | Current number of connected clients |
| `queuedLines` | Total entries in client output queues |
| `channels` | Current number of channels |
| `connectionsSinceBoot` | Total client connections since server start |
| `sessionsSinceBoot` | Total sessions created since server start |
| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start |
--- ---
## Message Flow ## Message Flow
@@ -1832,21 +1589,13 @@ authenticity.
### Authentication ### Authentication
- **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of - **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
entropy). Tokens are hashed (SHA-256) before storage and validated on every Tokens are stored in the database and validated on every request.
request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict - **No passwords**: Session creation requires only a nick. The token is the
(CSRF protection), and Secure when behind TLS. sole credential.
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No - **Token security**: Tokens should be treated like session cookies. Transmit
password, instant access. The auth cookie is the sole credential. only over HTTPS in production. If a token is compromised, the attacker has
- **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed full access to the session until QUIT or expiry.
password on the session. `POST /api/v1/login` authenticates against the
stored hash and issues a new client cookie.
- **Password security**: Passwords are never stored in plain text. bcrypt
handles salting and key stretching automatically. Sessions without a
password cannot be logged into via `/login`.
- **Cookie security**: Auth cookies should only be transmitted over HTTPS in
production. If a cookie is compromised, the attacker has full access to the
session until QUIT or expiry.
### Message Integrity ### Message Integrity
@@ -1883,10 +1632,8 @@ authenticity.
- **HTTPS is strongly recommended** for production deployments. The server - **HTTPS is strongly recommended** for production deployments. The server
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS
termination. termination.
- **CORS**: The server allows all origins with credentials - **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
(`Access-Control-Allow-Credentials: true`), reflecting the request Origin. Restrict this in production via reverse proxy configuration if needed.
This enables cookie-based auth from cross-origin clients. Restrict origins
in production via reverse proxy configuration if needed.
- **Content-Security-Policy**: The server sets a strict CSP header on all - **Content-Security-Policy**: The server sets a strict CSP header on all
responses, restricting resource loading to same-origin and disabling responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The dangerous features (object embeds, framing, base tag injection). The
@@ -1999,9 +1746,9 @@ The database schema is managed via embedded SQL migration files in
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID | | `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick | | `nick` | TEXT | Unique nick |
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) | | `password_hash` | TEXT | Password hash (default empty) |
| `signing_key` | TEXT | Public signing key (empty string if unset) | | `signing_key` | TEXT | Ed25519 signing key (default empty) |
| `away_message` | TEXT | Away message (empty string if not away) | | `away_message` | TEXT | Away message (default empty) |
| `created_at` | DATETIME | Session creation time | | `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time | | `last_seen` | DATETIME | Last API request time |
@@ -2013,7 +1760,7 @@ Index on `(uuid)`.
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID | | `uuid` | TEXT | Unique client UUID |
| `session_id` | INTEGER | FK → sessions.id (cascade delete) | | `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) | | `token` | TEXT | Unique auth token (64 hex chars) |
| `created_at` | DATETIME | Client creation time | | `created_at` | DATETIME | Client creation time |
| `last_seen` | DATETIME | Last API request time | | `last_seen` | DATETIME | Last API request time |
@@ -2076,19 +1823,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Client output queue entries**: Pruned automatically when older than - **Client output queue entries**: Pruned automatically when older than
`QUEUE_MAX_AGE` (default 30 days). `QUEUE_MAX_AGE` (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral). - **Channels**: Deleted when the last member leaves (ephemeral).
- **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT` - **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
or when the last client logs out (`POST /api/v1/logout` with no remaining sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
clients triggers session cleanup). There is no distinction between session 30 days) — the server runs a background cleanup loop that parts idle users
types in the cleanup path — `handleQuit` and `cleanupUser` both call from all channels, broadcasts QUIT, and releases their nicks.
`DeleteSession` unconditionally. Idle sessions are automatically expired
after `SESSION_IDLE_TIMEOUT`
(default 30 days) — the server runs a background cleanup loop that parts
idle users from all channels, broadcasts QUIT, and releases their nicks.
- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`.
A session can have multiple clients; removing one doesn't affect others.
However, when the last client is removed (via logout), the entire session
is deleted — the user is parted from all channels, QUIT is broadcast, and
the nick is released.
--- ---
@@ -2237,59 +1975,58 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl ### Step-by-Step with curl
```bash ```bash
# 1a. Create a session (cookie saved automatically with -c) # 1. Create a session
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"nick":"testuser"}' -d '{"nick":"testuser"}' | jq -r .token)
# 1b. Optionally set a password for multi-client access
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"PASS","body":["mypassword"]}'
# 1c. Login from another device (saves new cookie)
curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}'
# 2. Join a channel # 2. Join a channel
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"JOIN","to":"#general"}' -d '{"command":"JOIN","to":"#general"}'
# 3. Send a message # 3. Send a message
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}' -d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
# 4. Poll for messages (one-shot) # 4. Poll for messages (one-shot)
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq . curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
# 5. Long-poll (blocks up to 15s waiting for messages) # 5. Long-poll (blocks up to 15s waiting for messages)
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq . curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
# 6. Send a DM # 6. Send a DM
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}' -d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
# 7. Change nick # 7. Change nick
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"NICK","body":["newnick"]}' -d '{"command":"NICK","body":["newnick"]}'
# 8. Set channel topic # 8. Set channel topic
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}' -d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
# 9. Leave a channel # 9. Leave a channel
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"PART","to":"#general","body":["goodbye"]}' -d '{"command":"PART","to":"#general","body":["goodbye"]}'
# 10. Disconnect # 10. Disconnect
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"QUIT","body":["leaving"]}' -d '{"command":"QUIT","body":["leaving"]}'
``` ```
@@ -2299,25 +2036,27 @@ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
The key to real-time messaging is the poll loop. Here's the pattern: The key to real-time messaging is the poll loop. Here's the pattern:
```python ```python
# Python example — using requests.Session for automatic cookie handling # Python example
import requests, json, time import requests, json
BASE = "http://localhost:8080/api/v1" BASE = "http://localhost:8080/api/v1"
session = requests.Session() # Manages cookies automatically token = None
last_id = 0 last_id = 0
# Create session (cookie set automatically via Set-Cookie header) # Create session
resp = session.post(f"{BASE}/session", json={"nick": "pybot"}) resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
print(f"Session: {resp.json()}") token = resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Join channel # Join channel
session.post(f"{BASE}/messages", requests.post(f"{BASE}/messages", headers=headers,
json={"command": "JOIN", "to": "#general"}) json={"command": "JOIN", "to": "#general"})
# Poll loop # Poll loop
while True: while True:
try: try:
resp = session.get(f"{BASE}/messages", resp = requests.get(f"{BASE}/messages",
headers=headers,
params={"after": last_id, "timeout": 15}, params={"after": last_id, "timeout": 15},
timeout=20) # HTTP timeout > long-poll timeout timeout=20) # HTTP timeout > long-poll timeout
data = resp.json() data = resp.json()
@@ -2334,14 +2073,14 @@ while True:
``` ```
```javascript ```javascript
// JavaScript/browser example — cookies sent automatically // JavaScript/browser example
async function pollLoop() { async function pollLoop(token) {
let lastId = 0; let lastId = 0;
while (true) { while (true) {
try { try {
const resp = await fetch( const resp = await fetch(
`/api/v1/messages?after=${lastId}&timeout=15`, `/api/v1/messages?after=${lastId}&timeout=15`,
{credentials: 'same-origin'} // Include cookies {headers: {'Authorization': `Bearer ${token}`}}
); );
if (resp.status === 401) { /* session expired */ break; } if (resp.status === 401) { /* session expired */ break; }
const data = await resp.json(); const data = await resp.json();
@@ -2373,11 +2112,9 @@ Clients should handle these message commands from the queue:
### Error Handling ### Error Handling
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or - **HTTP 401**: Token expired or invalid. Re-create session.
re-login (if a password was set).
- **HTTP 404**: Channel or user not found. - **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation, registration, or - **HTTP 409**: Nick already taken (on session creation or NICK change).
NICK change).
- **HTTP 400**: Malformed request. Check the `error` field in the response. - **HTTP 400**: Malformed request. Check the `error` field in the response.
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s). - **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
@@ -2394,11 +2131,8 @@ Clients should handle these message commands from the queue:
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel 4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
(no `#` prefix), the DM tab should be keyed by the **other** user's nick: (no `#` prefix), the DM tab should be keyed by the **other** user's nick:
if `from` is you, use `to`; if `from` is someone else, use `from`. if `from` is you, use `to`; if `from` is someone else, use `from`.
5. **Reconnection**: If the poll loop fails with 401, the auth cookie is 5. **Reconnection**: If the poll loop fails with 401, the session is gone.
invalid. For sessions without a password, create a new session. For Create a new session. If it fails with a network error, retry with backoff.
sessions with a password set (via PASS command), log in again via
`POST /api/v1/login` to get a fresh cookie on the same session. If it
fails with a network error, retry with backoff.
--- ---
@@ -2557,10 +2291,8 @@ creating one session pays once and keeps their session.
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors) 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
- [ ] **Max message size enforcement** — reject oversized messages - [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) - [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [x] **Multi-client sessions** — set a password via PASS command, then - [ ] **Multi-client sessions** — add client to existing session
login from additional devices via `POST /api/v1/login` (share nick across devices)
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
all API authentication
### Future (1.0+) ### Future (1.0+)
@@ -2620,8 +2352,6 @@ neoirc/
│ │ └── healthcheck.go # Health check handler │ │ └── healthcheck.go # Health check handler
│ ├── healthcheck/ # Health check logic │ ├── healthcheck/ # Health check logic
│ │ └── healthcheck.go │ │ └── healthcheck.go
│ ├── stats/ # Runtime statistics (atomic counters)
│ │ └── stats.go
│ ├── logger/ # slog-based logging │ ├── logger/ # slog-based logging
│ │ └── logger.go │ │ └── logger.go
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth) │ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
@@ -2673,12 +2403,9 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the build a working IRC-style TUI client against this API in an afternoon, the
API is too complex. API is too complex.
2. **Passwords optional** — anonymous sessions are instant: pick a nick and 2. **No accounts** — identity is a signing key, nick is a display name. No
talk. No registration, no email verification. The cost of entry is a registration, no passwords, no email verification. Session creation is
hashcash proof, not bureaucracy. For users who want multi-client access instant. The cost of entry is a hashcash proof, not bureaucracy.
(multiple devices sharing one session), the PASS command sets a password
on the session — but it's never required. Identity verification at the
message layer uses cryptographic signing, independent of password status.
3. **IRC semantics over HTTP** — command names and numeric codes from 3. **IRC semantics over HTTP** — command names and numeric codes from
RFC 1459/2812. If you've built an IRC client or bot, you already know the RFC 1459/2812. If you've built an IRC client or bot, you already know the

View File

@@ -10,7 +10,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server" "git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -36,7 +35,6 @@ func main() {
server.New, server.New,
middleware.New, middleware.New,
healthcheck.New, healthcheck.New,
stats.New,
), ),
fx.Invoke(func(*server.Server) {}), fx.Invoke(func(*server.Server) {}),
).Run() ).Run()

View File

@@ -9,7 +9,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
@@ -29,19 +28,16 @@ var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the neoirc server API. // Client wraps HTTP calls to the neoirc server API.
type Client struct { type Client struct {
BaseURL string BaseURL string
Token string
HTTPClient *http.Client HTTPClient *http.Client
} }
// NewClient creates a new API client with a cookie jar // NewClient creates a new API client.
// for automatic auth cookie management.
func NewClient(baseURL string) *Client { func NewClient(baseURL string) *Client {
jar, _ := cookiejar.New(nil) return &Client{ //nolint:exhaustruct // Token set after CreateSession
return &Client{
BaseURL: baseURL, BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: httpTimeout, Timeout: httpTimeout,
Jar: jar,
}, },
} }
} }
@@ -83,6 +79,8 @@ func (client *Client) CreateSession(
return nil, fmt.Errorf("decode session: %w", err) return nil, fmt.Errorf("decode session: %w", err)
} }
client.Token = resp.Token
return &resp, nil return &resp, nil
} }
@@ -123,7 +121,6 @@ func (client *Client) PollMessages(
Timeout: time.Duration( Timeout: time.Duration(
timeout+pollExtraTime, timeout+pollExtraTime,
) * time.Second, ) * time.Second,
Jar: client.HTTPClient.Jar,
} }
params := url.Values{} params := url.Values{}
@@ -148,6 +145,10 @@ func (client *Client) PollMessages(
return nil, fmt.Errorf("new request: %w", err) return nil, fmt.Errorf("new request: %w", err)
} }
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
resp, err := pollClient.Do(request) resp, err := pollClient.Do(request)
if err != nil { if err != nil {
return nil, fmt.Errorf("poll request: %w", err) return nil, fmt.Errorf("poll request: %w", err)
@@ -303,6 +304,12 @@ func (client *Client) do(
"Content-Type", "application/json", "Content-Type", "application/json",
) )
if client.Token != "" {
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
}
resp, err := client.HTTPClient.Do(request) resp, err := client.HTTPClient.Do(request)
if err != nil { if err != nil {
return nil, fmt.Errorf("http: %w", err) return nil, fmt.Errorf("http: %w", err)

View File

@@ -7,8 +7,6 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
) )
const ( const (
@@ -39,23 +37,6 @@ func MintHashcash(bits int, resource string) string {
} }
} }
// MintChannelHashcash computes a hashcash stamp bound to
// a specific channel and message body. The stamp format
// is 1:bits:YYMMDD:channel:bodyhash:counter where
// bodyhash is the hex-encoded SHA-256 of the message
// body bytes. Delegates to the internal/hashcash package.
func MintChannelHashcash(
bits int,
channel string,
body []byte,
) string {
bodyHash := hashcash.BodyHash(body)
return hashcash.MintChannelStamp(
bits, channel, bodyHash,
)
}
// hasLeadingZeroBits checks if hash has at least numBits // hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits. // leading zero bits.
func hasLeadingZeroBits( func hasLeadingZeroBits(

View File

@@ -12,6 +12,7 @@ type SessionRequest struct {
type SessionResponse struct { type SessionResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Token string `json:"token"`
} }
// StateResponse is the response from GET /api/v1/state. // StateResponse is the response from GET /api/v1/state.

View File

@@ -16,28 +16,80 @@ var errNoPassword = errors.New(
"account has no password set", "account has no password set",
) )
// SetPassword sets a bcrypt-hashed password on a session, // RegisterUser creates a session with a hashed password
// enabling multi-client login via POST /api/v1/login. // and returns session ID, client ID, and token.
func (database *Database) SetPassword( func (database *Database) RegisterUser(
ctx context.Context, ctx context.Context,
sessionID int64, nick, password string,
password string, ) (int64, int64, string, error) {
) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost, []byte(password), bcryptCost,
) )
if err != nil { if err != nil {
return fmt.Errorf("hash password: %w", err) return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
} }
_, err = database.conn.ExecContext(ctx, sessionUUID := uuid.New().String()
"UPDATE sessions SET password_hash = ? WHERE id = ?", clientUUID := uuid.New().String()
string(hash), sessionID)
token, err := generateToken()
if err != nil { if err != nil {
return fmt.Errorf("set password: %w", err) return 0, 0, "", err
} }
return nil now := time.Now()
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, 0, "", fmt.Errorf(
"begin tx: %w", err,
)
}
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, password_hash,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
sessionUUID, nick, string(hash), now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create session: %w", err,
)
}
sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create client: %w", err,
)
}
clientID, _ := clientRes.LastInsertId()
err = transaction.Commit()
if err != nil {
return 0, 0, "", fmt.Errorf(
"commit registration: %w", err,
)
}
return sessionID, clientID, token, nil
} }
// LoginUser verifies a nick/password and creates a new // LoginUser verifies a nick/password and creates a new

View File

@@ -6,65 +6,63 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
func TestSetPassword(t *testing.T) { func TestRegisterUser(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := sessionID, clientID, token, err :=
database.CreateSession(ctx, "passuser") database.RegisterUser(ctx, "reguser", "password123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.SetPassword( if sessionID == 0 || clientID == 0 || token == "" {
ctx, sessionID, "password123",
)
if err != nil {
t.Fatal(err)
}
// Verify we can now log in with the password.
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "passuser", "password123")
if err != nil {
t.Fatal(err)
}
if loginSID == 0 || loginCID == 0 || loginToken == "" {
t.Fatal("expected valid ids and token") t.Fatal("expected valid ids and token")
} }
// Verify session works via token lookup.
sid, cid, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
} }
func TestSetPasswordThenWrongLogin(t *testing.T) { if sid != sessionID || cid != clientID {
t.Fatal("session/client id mismatch")
}
if nick != "reguser" {
t.Fatalf("expected reguser, got %s", nick)
}
}
func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := regSID, regCID, regToken, err :=
database.CreateSession(ctx, "wrongpw") database.RegisterUser(ctx, "dupnick", "password123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.SetPassword( _ = regSID
ctx, sessionID, "correctpass", _ = regCID
) _ = regToken
if err != nil {
t.Fatal(err) dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
} }
loginSID, loginCID, loginToken, loginErr := _ = dupSID
database.LoginUser(ctx, "wrongpw", "wrongpass12") _ = dupCID
if loginErr == nil { _ = dupToken
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
} }
func TestLoginUser(t *testing.T) { func TestLoginUser(t *testing.T) {
@@ -73,26 +71,23 @@ func TestLoginUser(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := regSID, regCID, regToken, err :=
database.CreateSession(ctx, "loginuser") database.RegisterUser(ctx, "loginuser", "mypassword")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.SetPassword( _ = regSID
ctx, sessionID, "mypassword", _ = regCID
) _ = regToken
if err != nil {
t.Fatal(err)
}
loginSID, loginCID, token, err := sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword") database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if loginSID == 0 || loginCID == 0 || token == "" { if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token") t.Fatal("expected valid ids and token")
} }
@@ -108,6 +103,33 @@ func TestLoginUser(t *testing.T) {
} }
} }
func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNoPassword(t *testing.T) { func TestLoginUserNoPassword(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -1266,149 +1266,3 @@ func (database *Database) PruneOldMessages(
return deleted, nil return deleted, nil
} }
// GetClientCount returns the total number of clients.
func (database *Database) GetClientCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM clients",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get client count: %w", err,
)
}
return count, nil
}
// GetQueueEntryCount returns the total number of entries
// in the client output queues.
func (database *Database) GetQueueEntryCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM client_queues",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get queue entry count: %w", err,
)
}
return count, nil
}
// GetChannelHashcashBits returns the hashcash difficulty
// requirement for a channel. Returns 0 if not set.
func (database *Database) GetChannelHashcashBits(
ctx context.Context,
channelID int64,
) (int, error) {
var bits int
err := database.conn.QueryRowContext(
ctx,
"SELECT hashcash_bits FROM channels WHERE id = ?",
channelID,
).Scan(&bits)
if err != nil {
return 0, fmt.Errorf(
"get channel hashcash bits: %w", err,
)
}
return bits, nil
}
// SetChannelHashcashBits sets the hashcash difficulty
// requirement for a channel. A value of 0 disables the
// requirement.
func (database *Database) SetChannelHashcashBits(
ctx context.Context,
channelID int64,
bits int,
) error {
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET hashcash_bits = ?, updated_at = ?
WHERE id = ?`,
bits, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel hashcash bits: %w", err,
)
}
return nil
}
// RecordSpentHashcash stores a spent hashcash stamp hash
// for replay prevention.
func (database *Database) RecordSpentHashcash(
ctx context.Context,
stampHash string,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO spent_hashcash
(stamp_hash, created_at)
VALUES (?, ?)`,
stampHash, time.Now())
if err != nil {
return fmt.Errorf(
"record spent hashcash: %w", err,
)
}
return nil
}
// IsHashcashSpent checks whether a hashcash stamp hash
// has already been used.
func (database *Database) IsHashcashSpent(
ctx context.Context,
stampHash string,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM spent_hashcash
WHERE stamp_hash = ?`,
stampHash,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check spent hashcash: %w", err,
)
}
return count > 0, nil
}
// PruneSpentHashcash deletes spent hashcash tokens older
// than the cutoff and returns the number of rows removed.
func (database *Database) PruneSpentHashcash(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM spent_hashcash WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune spent hashcash: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}

View File

@@ -33,7 +33,6 @@ CREATE TABLE IF NOT EXISTS channels (
topic TEXT NOT NULL DEFAULT '', topic TEXT NOT NULL DEFAULT '',
topic_set_by TEXT NOT NULL DEFAULT '', topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME, topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -62,14 +61,6 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id); CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Spent hashcash tokens for replay prevention (1-year TTL)
CREATE TABLE IF NOT EXISTS spent_hashcash (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stamp_hash TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
-- Per-client message queues for fan-out delivery -- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues ( CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -3,7 +3,6 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
@@ -12,16 +11,10 @@ import (
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/pkg/irc" "git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
var (
errHashcashRequired = errors.New("hashcash required")
errHashcashReused = errors.New("hashcash reused")
)
var validNickRe = regexp.MustCompile( var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`, `^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
) )
@@ -36,7 +29,6 @@ const (
defaultMaxBodySize = 4096 defaultMaxBodySize = 4096
defaultHistLimit = 50 defaultHistLimit = 50
maxHistLimit = 500 maxHistLimit = 500
authCookieName = "neoirc_auth"
) )
func (hdlr *Handlers) maxBodySize() int64 { func (hdlr *Handlers) maxBodySize() int64 {
@@ -47,18 +39,23 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize return defaultMaxBodySize
} }
// authSession extracts the session from the auth cookie. // authSession extracts the session from the client token.
func (hdlr *Handlers) authSession( func (hdlr *Handlers) authSession(
request *http.Request, request *http.Request,
) (int64, int64, string, error) { ) (int64, int64, string, error) {
cookie, err := request.Cookie(authCookieName) auth := request.Header.Get("Authorization")
if err != nil || cookie.Value == "" { if !strings.HasPrefix(auth, "Bearer ") {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
return 0, 0, "", errUnauthorized return 0, 0, "", errUnauthorized
} }
sessionID, clientID, nick, err := sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken( hdlr.params.Database.GetSessionByToken(
request.Context(), cookie.Value, request.Context(), token,
) )
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err) return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -67,46 +64,6 @@ func (hdlr *Handlers) authSession(
return sessionID, clientID, nick, nil return sessionID, clientID, nick, nil
} }
// setAuthCookie sets the authentication cookie on the
// response.
func (hdlr *Handlers) setAuthCookie(
writer http.ResponseWriter,
request *http.Request,
token string,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// clearAuthCookie removes the authentication cookie from
// the client.
func (hdlr *Handlers) clearAuthCookie(
writer http.ResponseWriter,
request *http.Request,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (hdlr *Handlers) requireAuth( func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -131,11 +88,10 @@ func (hdlr *Handlers) fanOut(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) (string, error) { ) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage( dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, nil, body, meta, request.Context(), command, from, target, nil, body, nil,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -161,11 +117,10 @@ func (hdlr *Handlers) fanOutSilent(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) error { ) error {
_, err := hdlr.fanOut( _, err := hdlr.fanOut(
request, command, from, target, body, meta, sessionIDs, request, command, from, target, body, sessionIDs,
) )
return err return err
@@ -257,16 +212,12 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,
"token": token,
}, http.StatusCreated) }, http.StatusCreated)
} }
@@ -340,7 +291,7 @@ func (hdlr *Handlers) deliverWelcome(
[]string{ []string{
"CHANTYPES=#", "CHANTYPES=#",
"NICKLEN=32", "NICKLEN=32",
"CHANMODES=,,H," + "imnst", "CHANMODES=,,," + "imnst",
"NETWORK=neoirc", "NETWORK=neoirc",
"CASEMAPPING=ascii", "CASEMAPPING=ascii",
}, },
@@ -871,7 +822,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
payload.Command, payload.To, payload.Command, payload.To,
payload.Body, payload.Meta, bodyLines, payload.Body, bodyLines,
) )
} }
} }
@@ -882,7 +833,6 @@ func (hdlr *Handlers) dispatchCommand(
sessionID, clientID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
switch command { switch command {
@@ -895,7 +845,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, bodyLines, command, target, body, bodyLines,
) )
case irc.CmdJoin: case irc.CmdJoin:
hdlr.handleJoin( hdlr.handleJoin(
@@ -912,11 +862,6 @@ func (hdlr *Handlers) dispatchCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick, bodyLines,
) )
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic: case irc.CmdTopic:
hdlr.handleTopic( hdlr.handleTopic(
writer, request, writer, request,
@@ -1001,7 +946,6 @@ func (hdlr *Handlers) handlePrivmsg(
sessionID, clientID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target == "" { if target == "" {
@@ -1033,13 +977,11 @@ func (hdlr *Handlers) handlePrivmsg(
return return
} }
hdlr.stats.IncrMessages()
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg( hdlr.handleChannelMsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, command, target, body,
) )
return return
@@ -1048,7 +990,7 @@ func (hdlr *Handlers) handlePrivmsg(
hdlr.handleDirectMsg( hdlr.handleDirectMsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, command, target, body,
) )
} }
@@ -1079,7 +1021,6 @@ func (hdlr *Handlers) handleChannelMsg(
sessionID, clientID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
) { ) {
chID, err := hdlr.params.Database.GetChannelByName( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target, request.Context(), target,
@@ -1120,180 +1061,16 @@ func (hdlr *Handlers) handleChannelMsg(
return return
} }
hashcashErr := hdlr.validateChannelHashcash(
request, clientID, sessionID,
writer, nick, target, body, meta, chID,
)
if hashcashErr != nil {
return
}
hdlr.sendChannelMsg( hdlr.sendChannelMsg(
writer, request, command, nick, target, writer, request, command, nick, target, body, chID,
body, meta, chID,
) )
} }
// validateChannelHashcash checks whether the channel
// requires hashcash proof-of-work for messages and
// validates the stamp from the message meta field.
// Returns nil on success or if the channel has no
// hashcash requirement. On failure, it sends the
// appropriate IRC error and returns a non-nil error.
func (hdlr *Handlers) validateChannelHashcash(
request *http.Request,
clientID, sessionID int64,
writer http.ResponseWriter,
nick, target string,
body json.RawMessage,
meta json.RawMessage,
chID int64,
) error {
ctx := request.Context()
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
ctx, chID,
)
if bitsErr != nil {
hdlr.log.Error(
"get channel hashcash bits", "error", bitsErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
}
if bits <= 0 {
return nil
}
stamp := hdlr.extractHashcashFromMeta(meta)
if stamp == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Channel requires hashcash proof-of-work",
)
return errHashcashRequired
}
return hdlr.verifyChannelStamp(
request, writer,
clientID, sessionID,
nick, target, body, stamp, bits,
)
}
// verifyChannelStamp validates a channel hashcash stamp
// and checks for replay attacks.
func (hdlr *Handlers) verifyChannelStamp(
request *http.Request,
writer http.ResponseWriter,
clientID, sessionID int64,
nick, target string,
body json.RawMessage,
stamp string,
bits int,
) error {
ctx := request.Context()
bodyHashStr := hashcash.BodyHash(body)
valErr := hdlr.channelHashcash.ValidateStamp(
stamp, bits, target, bodyHashStr,
)
if valErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Invalid hashcash: "+valErr.Error(),
)
return fmt.Errorf("channel hashcash: %w", valErr)
}
stampKey := hashcash.StampHash(stamp)
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
ctx, stampKey,
)
if spentErr != nil {
hdlr.log.Error(
"check spent hashcash", "error", spentErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("check spent hashcash: %w", spentErr)
}
if spent {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Hashcash stamp already used",
)
return errHashcashReused
}
recordErr := hdlr.params.Database.RecordSpentHashcash(
ctx, stampKey,
)
if recordErr != nil {
hdlr.log.Error(
"record spent hashcash", "error", recordErr,
)
}
return nil
}
// extractHashcashFromMeta parses the meta JSON and
// returns the hashcash stamp string, or empty string
// if not present.
func (hdlr *Handlers) extractHashcashFromMeta(
meta json.RawMessage,
) string {
if len(meta) == 0 {
return ""
}
var metaMap map[string]json.RawMessage
err := json.Unmarshal(meta, &metaMap)
if err != nil {
return ""
}
raw, ok := metaMap["hashcash"]
if !ok {
return ""
}
var stamp string
err = json.Unmarshal(raw, &stamp)
if err != nil {
return ""
}
return stamp
}
func (hdlr *Handlers) sendChannelMsg( func (hdlr *Handlers) sendChannelMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
command, nick, target string, command, nick, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
chID int64, chID int64,
) { ) {
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs( memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
@@ -1313,7 +1090,7 @@ func (hdlr *Handlers) sendChannelMsg(
} }
msgUUID, err := hdlr.fanOut( msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, meta, memberIDs, request, command, nick, target, body, memberIDs,
) )
if err != nil { if err != nil {
hdlr.log.Error("send message failed", "error", err) hdlr.log.Error("send message failed", "error", err)
@@ -1337,7 +1114,6 @@ func (hdlr *Handlers) handleDirectMsg(
sessionID, clientID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
) { ) {
targetSID, err := hdlr.params.Database.GetSessionByNick( targetSID, err := hdlr.params.Database.GetSessionByNick(
request.Context(), target, request.Context(), target,
@@ -1362,7 +1138,7 @@ func (hdlr *Handlers) handleDirectMsg(
} }
msgUUID, err := hdlr.fanOut( msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, meta, recipients, request, command, nick, target, body, recipients,
) )
if err != nil { if err != nil {
hdlr.log.Error("send dm failed", "error", err) hdlr.log.Error("send dm failed", "error", err)
@@ -1473,7 +1249,7 @@ func (hdlr *Handlers) executeJoin(
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs, request, irc.CmdJoin, nick, channel, nil, memberIDs,
) )
hdlr.deliverJoinNumerics( hdlr.deliverJoinNumerics(
@@ -1643,7 +1419,7 @@ func (hdlr *Handlers) handlePart(
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdPart, nick, channel, body, nil, memberIDs, request, irc.CmdPart, nick, channel, body, memberIDs,
) )
err = hdlr.params.Database.PartChannel( err = hdlr.params.Database.PartChannel(
@@ -1860,32 +1636,6 @@ func (hdlr *Handlers) handleTopic(
return return
} }
isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
"check membership failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick, []string{channel},
"You're not on that channel",
)
return
}
hdlr.executeTopic( hdlr.executeTopic(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
@@ -1923,7 +1673,7 @@ func (hdlr *Handlers) executeTopic(
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdTopic, nick, channel, body, nil, memberIDs, request, irc.CmdTopic, nick, channel, body, memberIDs,
) )
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
@@ -2047,8 +1797,6 @@ func (hdlr *Handlers) handleQuit(
request.Context(), sessionID, request.Context(), sessionID,
) )
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"}, map[string]string{"status": "quit"},
http.StatusOK) http.StatusOK)
@@ -2088,10 +1836,11 @@ func (hdlr *Handlers) handleMode(
return return
} }
_ = bodyLines
hdlr.handleChannelMode( hdlr.handleChannelMode(
writer, request, writer, request,
sessionID, clientID, nick, channel, sessionID, clientID, nick, channel,
bodyLines,
) )
} }
@@ -2100,7 +1849,6 @@ func (hdlr *Handlers) handleChannelMode(
request *http.Request, request *http.Request,
sessionID, clientID int64, sessionID, clientID int64,
nick, channel string, nick, channel string,
bodyLines func() []string,
) { ) {
ctx := request.Context() ctx := request.Context()
@@ -2117,47 +1865,10 @@ func (hdlr *Handlers) handleChannelMode(
return return
} }
lines := bodyLines()
if len(lines) > 0 {
hdlr.applyChannelMode(
writer, request,
sessionID, clientID, nick,
channel, chID, lines,
)
return
}
hdlr.queryChannelMode(
writer, request,
sessionID, clientID, nick, channel, chID,
)
}
// queryChannelMode sends RPL_CHANNELMODEIS and
// RPL_CREATIONTIME for a channel. Includes +H if
// the channel has a hashcash requirement.
func (hdlr *Handlers) queryChannelMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
) {
ctx := request.Context()
modeStr := "+n"
bits, bitsErr := hdlr.params.Database.
GetChannelHashcashBits(ctx, chID)
if bitsErr == nil && bits > 0 {
modeStr = fmt.Sprintf("+nH %d", bits)
}
// 324 RPL_CHANNELMODEIS // 324 RPL_CHANNELMODEIS
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick, ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, modeStr}, "", []string{channel, "+n"}, "",
) )
// 329 RPL_CREATIONTIME // 329 RPL_CREATIONTIME
@@ -2182,156 +1893,6 @@ func (hdlr *Handlers) queryChannelMode(
http.StatusOK) http.StatusOK)
} }
// applyChannelMode handles setting channel modes.
// Currently supports +H/-H for hashcash bits.
func (hdlr *Handlers) applyChannelMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
modeArgs []string,
) {
ctx := request.Context()
modeStr := modeArgs[0]
switch modeStr {
case "+H":
hdlr.setHashcashMode(
writer, request,
sessionID, clientID, nick,
channel, chID, modeArgs,
)
case "-H":
hdlr.clearHashcashMode(
writer, request,
sessionID, clientID, nick,
channel, chID,
)
default:
// Unknown or unsupported mode change.
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrUnknownMode, nick,
[]string{modeStr},
"is unknown mode char to me",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
}
}
const (
// minHashcashBits is the minimum allowed hashcash
// difficulty for channels.
minHashcashBits = 1
// maxHashcashBits is the maximum allowed hashcash
// difficulty for channels.
maxHashcashBits = 40
)
// setHashcashMode handles MODE #channel +H <bits>.
func (hdlr *Handlers) setHashcashMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
modeArgs []string,
) {
ctx := request.Context()
if len(modeArgs) < 2 { //nolint:mnd // +H requires a bits arg
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
"Not enough parameters (+H requires bits)",
)
return
}
bits, err := strconv.Atoi(modeArgs[1])
if err != nil || bits < minHashcashBits ||
bits > maxHashcashBits {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUnknownMode, nick, []string{"+H"},
fmt.Sprintf(
"Invalid hashcash bits (must be %d-%d)",
minHashcashBits, maxHashcashBits,
),
)
return
}
err = hdlr.params.Database.SetChannelHashcashBits(
ctx, chID, bits,
)
if err != nil {
hdlr.log.Error(
"set channel hashcash bits", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick,
[]string{
channel,
fmt.Sprintf("+H %d", bits),
}, "",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// clearHashcashMode handles MODE #channel -H.
func (hdlr *Handlers) clearHashcashMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
) {
ctx := request.Context()
err := hdlr.params.Database.SetChannelHashcashBits(
ctx, chID, 0,
)
if err != nil {
hdlr.log.Error(
"clear channel hashcash bits", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, "+n"}, "",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleNames sends NAMES reply for a channel. // handleNames sends NAMES reply for a channel.
func (hdlr *Handlers) handleNames( func (hdlr *Handlers) handleNames(
writer http.ResponseWriter, writer http.ResponseWriter,
@@ -2851,8 +2412,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
) )
} }
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,117 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/pkg/irc" "git.eeqj.de/sneak/neoirc/internal/db"
) )
const minPasswordLength = 8 const minPasswordLength = 8
// HandleRegister creates a new user with a password.
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleRegister(writer, request)
}
}
func (hdlr *Handlers) handleRegister(
writer http.ResponseWriter,
request *http.Request,
) {
type registerRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload registerRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
"password must be at least 8 characters",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.handleRegisterError(
writer, request, err,
)
return
}
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
func (hdlr *Handlers) handleRegisterError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if db.IsUniqueConstraintError(err) {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"register user failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// HandleLogin authenticates a user with nick and password. // HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc { func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func( return func(
@@ -74,8 +180,6 @@ func (hdlr *Handlers) handleLogin(
return return
} }
hdlr.stats.IncrConnections()
hdlr.deliverMOTD( hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, payload.Nick,
) )
@@ -86,66 +190,9 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, payload.Nick,
) )
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,
"token": token,
}, http.StatusOK) }, http.StatusOK)
} }
// handlePass handles the IRC PASS command to set a
// password on the authenticated session, enabling
// multi-client login via POST /api/v1/login.
func (hdlr *Handlers) handlePass(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 || lines[0] == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Not enough parameters",
)
return
}
password := lines[0]
if len(password) < minPasswordLength {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Password must be at least 8 characters",
)
return
}
err := hdlr.params.Database.SetPassword(
request.Context(), sessionID, password,
)
if err != nil {
hdlr.log.Error(
"set password failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}

View File

@@ -16,7 +16,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/hashcash" "git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -31,16 +30,10 @@ type Params struct {
Config *config.Config Config *config.Config
Database *db.Database Database *db.Database
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
} }
const defaultIdleTimeout = 30 * 24 * time.Hour const defaultIdleTimeout = 30 * 24 * time.Hour
// spentHashcashTTL is how long spent hashcash tokens are
// retained for replay prevention. Per issue requirements,
// this is 1 year.
const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling. // Handlers manages HTTP request handling.
type Handlers struct { type Handlers struct {
params *Params params *Params
@@ -48,8 +41,6 @@ type Handlers struct {
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker
cancelCleanup context.CancelFunc cancelCleanup context.CancelFunc
} }
@@ -69,8 +60,6 @@ func New(
hc: params.Healthcheck, hc: params.Healthcheck,
broker: broker.New(), broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
@@ -292,20 +281,4 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
) )
} }
} }
// Prune spent hashcash tokens older than 1 year.
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
pruned, err := hdlr.params.Database.
PruneSpentHashcash(ctx, hashcashCutoff)
if err != nil {
hdlr.log.Error(
"spent hashcash pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned spent hashcash tokens",
"deleted", pruned,
)
}
} }

View File

@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
resp := hdlr.hc.Healthcheck(request.Context()) resp := hdlr.hc.Healthcheck()
hdlr.respondJSON(writer, request, resp, httpStatusOK) hdlr.respondJSON(writer, request, resp, httpStatusOK)
} }
} }

View File

@@ -1,186 +0,0 @@
package hashcash
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var (
errBodyHashMismatch = errors.New(
"body hash mismatch",
)
errBodyHashMissing = errors.New(
"body hash missing",
)
)
// ChannelValidator checks hashcash stamps for
// per-channel PRIVMSG validation. It verifies that
// stamps are bound to a specific channel and message
// body. Replay prevention is handled externally via
// the database spent_hashcash table for persistence
// across server restarts (1-year TTL).
type ChannelValidator struct{}
// NewChannelValidator creates a ChannelValidator.
func NewChannelValidator() *ChannelValidator {
return &ChannelValidator{}
}
// BodyHash computes the hex-encoded SHA-256 hash of a
// message body for use in hashcash stamp validation.
func BodyHash(body []byte) string {
hash := sha256.Sum256(body)
return hex.EncodeToString(hash[:])
}
// ValidateStamp checks a channel hashcash stamp. It
// verifies the stamp format, difficulty, date, channel
// binding, body hash binding, and proof-of-work. Replay
// detection is NOT performed here — callers must check
// the spent_hashcash table separately.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func (cv *ChannelValidator) ValidateStamp(
stamp string,
requiredBits int,
channel string,
bodyHash string,
) error {
if requiredBits <= 0 {
return nil
}
parts := strings.Split(stamp, ":")
if len(parts) != stampFields {
return fmt.Errorf(
"%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts),
)
}
version := parts[0]
bitsStr := parts[1]
dateStr := parts[2]
resource := parts[3]
stampBodyHash := parts[4]
headerErr := validateChannelHeader(
version, bitsStr, resource,
requiredBits, channel,
)
if headerErr != nil {
return headerErr
}
stampTime, parseErr := parseStampDate(dateStr)
if parseErr != nil {
return parseErr
}
timeErr := validateTime(stampTime)
if timeErr != nil {
return timeErr
}
bodyErr := validateBodyHash(
stampBodyHash, bodyHash,
)
if bodyErr != nil {
return bodyErr
}
return validateProof(stamp, requiredBits)
}
// StampHash returns a deterministic hash of a stamp
// string for use as a spent-token key.
func StampHash(stamp string) string {
hash := sha256.Sum256([]byte(stamp))
return hex.EncodeToString(hash[:])
}
func validateChannelHeader(
version, bitsStr, resource string,
requiredBits int,
channel string,
) error {
if version != stampVersion {
return fmt.Errorf(
"%w: %s", errBadVersion, version,
)
}
claimedBits, err := strconv.Atoi(bitsStr)
if err != nil || claimedBits < requiredBits {
return fmt.Errorf(
"%w: need %d bits",
errInsufficientBits, requiredBits,
)
}
if resource != channel {
return fmt.Errorf(
"%w: got %q, want %q",
errWrongResource, resource, channel,
)
}
return nil
}
func validateBodyHash(
stampBodyHash, expectedBodyHash string,
) error {
if stampBodyHash == "" {
return errBodyHashMissing
}
if stampBodyHash != expectedBodyHash {
return fmt.Errorf(
"%w: got %q, want %q",
errBodyHashMismatch,
stampBodyHash, expectedBodyHash,
)
}
return nil
}
// MintChannelStamp computes a channel hashcash stamp
// with the given difficulty, channel name, and body hash.
// This is intended for clients to generate stamps before
// sending PRIVMSG to hashcash-protected channels.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func MintChannelStamp(
bits int,
channel string,
bodyHash string,
) string {
date := time.Now().UTC().Format(dateFormatShort)
prefix := fmt.Sprintf(
"1:%d:%s:%s:%s:",
bits, date, channel, bodyHash,
)
counter := uint64(0)
for {
stamp := prefix + strconv.FormatUint(counter, 16)
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
counter++
}
}

View File

@@ -1,244 +0,0 @@
package hashcash_test
import (
"crypto/sha256"
"encoding/hex"
"testing"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (
testChannel = "#general"
testBodyText = `["hello world"]`
)
func testBodyHash() string {
hash := sha256.Sum256([]byte(testBodyText))
return hex.EncodeToString(hash[:])
}
func TestChannelValidateHappyPath(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err != nil {
t.Fatalf("valid channel stamp rejected: %v", err)
}
}
func TestChannelValidateWrongChannel(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, testBits, "#other", bodyHash,
)
if err == nil {
t.Fatal("expected channel mismatch error")
}
}
func TestChannelValidateWrongBodyHash(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
wrongHash := sha256.Sum256([]byte("different body"))
wrongBodyHash := hex.EncodeToString(wrongHash[:])
err := validator.ValidateStamp(
stamp, testBits, testChannel, wrongBodyHash,
)
if err == nil {
t.Fatal("expected body hash mismatch error")
}
}
func TestChannelValidateInsufficientBits(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Mint with 2 bits but require 4.
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, 4, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected insufficient bits error")
}
}
func TestChannelValidateZeroBitsSkips(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
"garbage", 0, "#ch", "abc",
)
if err != nil {
t.Fatalf("zero bits should skip: %v", err)
}
}
func TestChannelValidateBadFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
"not:valid", testBits, testChannel, "abc",
)
if err == nil {
t.Fatal("expected bad format error")
}
}
func TestChannelValidateBadVersion(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected bad version error")
}
}
func TestChannelValidateExpiredStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Mint with a very old date by manually constructing.
stamp := mintStampWithDate(
t, testBits, testChannel, "200101",
)
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected expired stamp error")
}
}
func TestChannelValidateMissingBodyHash(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Construct a stamp with empty body hash field.
stamp := mintStampWithDate(
t, testBits, testChannel, todayDate(),
)
// This uses the session-style stamp which has empty
// ext field — body hash is missing.
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected missing body hash error")
}
}
func TestBodyHash(t *testing.T) {
t.Parallel()
body := []byte(`["hello world"]`)
bodyHash := hashcash.BodyHash(body)
if len(bodyHash) != 64 {
t.Fatalf(
"expected 64-char hex hash, got %d",
len(bodyHash),
)
}
// Same input should produce same hash.
bodyHash2 := hashcash.BodyHash(body)
if bodyHash != bodyHash2 {
t.Fatal("body hash not deterministic")
}
// Different input should produce different hash.
bodyHash3 := hashcash.BodyHash([]byte("different"))
if bodyHash == bodyHash3 {
t.Fatal("different inputs produced same hash")
}
}
func TestStampHash(t *testing.T) {
t.Parallel()
hash1 := hashcash.StampHash("stamp1")
hash2 := hashcash.StampHash("stamp2")
if hash1 == hash2 {
t.Fatal("different stamps produced same hash")
}
// Same input should be deterministic.
hash1b := hashcash.StampHash("stamp1")
if hash1 != hash1b {
t.Fatal("stamp hash not deterministic")
}
}
func TestMintChannelStamp(t *testing.T) {
t.Parallel()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
if stamp == "" {
t.Fatal("expected non-empty stamp")
}
// Validate the minted stamp.
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err != nil {
t.Fatalf("minted stamp failed validation: %v", err)
}
}

View File

@@ -10,7 +10,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -22,7 +21,6 @@ type Params struct {
Config *config.Config Config *config.Config
Logger *logger.Logger Logger *logger.Logger
Database *db.Database Database *db.Database
Stats *stats.Tracker
} }
// Healthcheck tracks server uptime and provides health status. // Healthcheck tracks server uptime and provides health status.
@@ -66,22 +64,11 @@ type Response struct {
Version string `json:"version"` Version string `json:"version"`
Appname string `json:"appname"` Appname string `json:"appname"`
Maintenance bool `json:"maintenanceMode"` Maintenance bool `json:"maintenanceMode"`
// Runtime statistics.
Sessions int64 `json:"sessions"`
Clients int64 `json:"clients"`
QueuedLines int64 `json:"queuedLines"`
Channels int64 `json:"channels"`
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
} }
// Healthcheck returns the current health status of the server. // Healthcheck returns the current health status of the server.
func (hcheck *Healthcheck) Healthcheck( func (hcheck *Healthcheck) Healthcheck() *Response {
ctx context.Context, return &Response{
) *Response {
resp := &Response{
Status: "ok", Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano), Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(hcheck.uptime().Seconds()), UptimeSeconds: int64(hcheck.uptime().Seconds()),
@@ -89,64 +76,6 @@ func (hcheck *Healthcheck) Healthcheck(
Appname: hcheck.params.Globals.Appname, Appname: hcheck.params.Globals.Appname,
Version: hcheck.params.Globals.Version, Version: hcheck.params.Globals.Version,
Maintenance: hcheck.params.Config.MaintenanceMode, Maintenance: hcheck.params.Config.MaintenanceMode,
Sessions: 0,
Clients: 0,
QueuedLines: 0,
Channels: 0,
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
}
hcheck.populateDBStats(ctx, resp)
return resp
}
// populateDBStats fills in database-derived counters.
func (hcheck *Healthcheck) populateDBStats(
ctx context.Context,
resp *Response,
) {
sessions, err := hcheck.params.Database.GetUserCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: session count failed",
"error", err,
)
} else {
resp.Sessions = sessions
}
clients, err := hcheck.params.Database.GetClientCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: client count failed",
"error", err,
)
} else {
resp.Clients = clients
}
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: queue entry count failed",
"error", err,
)
} else {
resp.QueuedLines = queued
}
channels, err := hcheck.params.Database.GetChannelCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: channel count failed",
"error", err,
)
} else {
resp.Channels = channels
} }
} }

View File

@@ -126,23 +126,18 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
} }
// CORS returns middleware that handles Cross-Origin Resource Sharing. // CORS returns middleware that handles Cross-Origin Resource Sharing.
// AllowCredentials is true so browsers include cookies in
// cross-origin API requests.
func (mware *Middleware) CORS() func(http.Handler) http.Handler { func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowOriginFunc: func( AllowedOrigins: []string{"*"},
_ *http.Request, _ string,
) bool {
return true
},
AllowedMethods: []string{ AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS", "GET", "POST", "PUT", "DELETE", "OPTIONS",
}, },
AllowedHeaders: []string{ AllowedHeaders: []string{
"Accept", "Content-Type", "X-CSRF-Token", "Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
}, },
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: true, AllowCredentials: false,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
}) })
} }

View File

@@ -75,6 +75,10 @@ func (srv *Server) setupAPIv1(router chi.Router) {
"/session", "/session",
srv.handlers.HandleCreateSession(), srv.handlers.HandleCreateSession(),
) )
router.Post(
"/register",
srv.handlers.HandleRegister(),
)
router.Post( router.Post(
"/login", "/login",
srv.handlers.HandleLogin(), srv.handlers.HandleLogin(),

View File

@@ -1,52 +0,0 @@
// Package stats tracks runtime statistics since server boot.
package stats
import (
"sync/atomic"
)
// Tracker holds atomic counters for runtime statistics
// that accumulate since the server started.
type Tracker struct {
connectionsSinceBoot atomic.Int64
sessionsSinceBoot atomic.Int64
messagesSinceBoot atomic.Int64
}
// New creates a new Tracker with all counters at zero.
func New() *Tracker {
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
}
// IncrConnections increments the total connection count.
func (t *Tracker) IncrConnections() {
t.connectionsSinceBoot.Add(1)
}
// IncrSessions increments the total session count.
func (t *Tracker) IncrSessions() {
t.sessionsSinceBoot.Add(1)
}
// IncrMessages increments the total PRIVMSG/NOTICE count.
func (t *Tracker) IncrMessages() {
t.messagesSinceBoot.Add(1)
}
// ConnectionsSinceBoot returns the total number of
// client connections since boot.
func (t *Tracker) ConnectionsSinceBoot() int64 {
return t.connectionsSinceBoot.Load()
}
// SessionsSinceBoot returns the total number of sessions
// created since boot.
func (t *Tracker) SessionsSinceBoot() int64 {
return t.sessionsSinceBoot.Load()
}
// MessagesSinceBoot returns the total number of
// PRIVMSG/NOTICE messages sent since boot.
func (t *Tracker) MessagesSinceBoot() int64 {
return t.messagesSinceBoot.Load()
}

View File

@@ -1,117 +0,0 @@
package stats_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/stats"
)
func TestNew(t *testing.T) {
t.Parallel()
tracker := stats.New()
if tracker == nil {
t.Fatal("expected non-nil tracker")
}
if tracker.ConnectionsSinceBoot() != 0 {
t.Errorf(
"expected 0 connections, got %d",
tracker.ConnectionsSinceBoot(),
)
}
if tracker.SessionsSinceBoot() != 0 {
t.Errorf(
"expected 0 sessions, got %d",
tracker.SessionsSinceBoot(),
)
}
if tracker.MessagesSinceBoot() != 0 {
t.Errorf(
"expected 0 messages, got %d",
tracker.MessagesSinceBoot(),
)
}
}
func TestIncrConnections(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrConnections()
tracker.IncrConnections()
tracker.IncrConnections()
got := tracker.ConnectionsSinceBoot()
if got != 3 {
t.Errorf(
"expected 3 connections, got %d", got,
)
}
}
func TestIncrSessions(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrSessions()
tracker.IncrSessions()
got := tracker.SessionsSinceBoot()
if got != 2 {
t.Errorf(
"expected 2 sessions, got %d", got,
)
}
}
func TestIncrMessages(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrMessages()
got := tracker.MessagesSinceBoot()
if got != 1 {
t.Errorf(
"expected 1 message, got %d", got,
)
}
}
func TestCountersAreIndependent(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrConnections()
tracker.IncrSessions()
tracker.IncrMessages()
tracker.IncrMessages()
if tracker.ConnectionsSinceBoot() != 1 {
t.Errorf(
"expected 1 connection, got %d",
tracker.ConnectionsSinceBoot(),
)
}
if tracker.SessionsSinceBoot() != 1 {
t.Errorf(
"expected 1 session, got %d",
tracker.SessionsSinceBoot(),
)
}
if tracker.MessagesSinceBoot() != 2 {
t.Errorf(
"expected 2 messages, got %d",
tracker.MessagesSinceBoot(),
)
}
}

View File

@@ -11,7 +11,6 @@ const (
CmdNames = "NAMES" CmdNames = "NAMES"
CmdNick = "NICK" CmdNick = "NICK"
CmdNotice = "NOTICE" CmdNotice = "NOTICE"
CmdPass = "PASS"
CmdPart = "PART" CmdPart = "PART"
CmdPing = "PING" CmdPing = "PING"
CmdPong = "PONG" CmdPong = "PONG"