6 Commits

Author SHA1 Message Date
user
cd9fd0c5c5 refactor: replace Bearer token auth with HttpOnly cookies
All checks were successful
check / check (push) Successful in 2m21s
- Remove POST /api/v1/register endpoint entirely
- Session creation (POST /api/v1/session) now sets neoirc_auth HttpOnly
  cookie instead of returning token in JSON body
- Login (POST /api/v1/login) now sets neoirc_auth HttpOnly cookie
  instead of returning token in JSON body
- Add PASS IRC command for setting session password (enables multi-client
  login via POST /api/v1/login)
- All per-request auth reads from neoirc_auth cookie instead of
  Authorization: Bearer header
- Cookie properties: HttpOnly, SameSite=Strict, Secure when behind TLS
- Logout and QUIT clear the auth cookie
- Update CORS to AllowCredentials:true with origin reflection
- Remove Authorization from CORS AllowedHeaders
- Update CLI client to use cookie jar (net/http/cookiejar)
- Remove Token field from SessionResponse
- Add SetPassword to DB layer, remove RegisterUser
- Comprehensive test updates for cookie-based auth
- Add tests: TestPassCommand, TestPassCommandShortPassword,
  TestPassCommandEmpty, TestSessionCookie
- Update README extensively: auth model, API reference, curl examples,
  security model, design principles, roadmap

closes #83
2026-03-17 20:33:12 -07:00
bf4d63bc4d feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam (#79)
Some checks failed
check / check (push) Failing after 1m48s
closes #12

## Summary

Implements per-channel hashcash proof-of-work requirement for PRIVMSG as an anti-spam mechanism. Channel operators set a difficulty level via `MODE +H <bits>`, and clients must compute a proof-of-work stamp bound to the channel name and message body before sending.

## Changes

### Database
- Added `hashcash_bits` column to `channels` table (default 0 = no requirement)
- Added `spent_hashcash` table with `stamp_hash` unique key and `created_at` for TTL pruning
- New queries: `GetChannelHashcashBits`, `SetChannelHashcashBits`, `RecordSpentHashcash`, `IsHashcashSpent`, `PruneSpentHashcash`

### Hashcash Validation (`internal/hashcash/channel.go`)
- `ChannelValidator` type for per-channel stamp validation
- `BodyHash()` computes hex-encoded SHA-256 of message body
- `StampHash()` computes deterministic hash of stamp for spent-token key
- `MintChannelStamp()` generates valid stamps (for clients)
- Stamp format: `1:bits:YYMMDD:channel:bodyhash:counter`
- Validates: version, difficulty, date freshness (48h), channel binding, body hash binding, proof-of-work

### Handler Changes (`internal/handlers/api.go`)
- `validateChannelHashcash()` + `verifyChannelStamp()` — checks hashcash on PRIVMSG to protected channels
- `extractHashcashFromMeta()` — parses hashcash stamp from meta JSON
- `applyChannelMode()` / `setHashcashMode()` / `clearHashcashMode()` — MODE +H/-H support
- `queryChannelMode()` — shows +nH in mode query when hashcash is set
- Meta field now passed through the full dispatch chain (dispatchCommand → handlePrivmsg → handleChannelMsg → sendChannelMsg → fanOut → InsertMessage)
- ISUPPORT updated: `CHANMODES=,H,,imnst` (H in type B = parameter when set)

### Replay Prevention
- Spent stamps persisted to SQLite `spent_hashcash` table
- 1-year TTL (per issue requirements)
- Automatic pruning in cleanup loop

### Client Support (`internal/cli/api/hashcash.go`)
- `MintChannelHashcash(bits, channel, body)` — computes stamps for channel messages

### Tests
- **12 unit tests** in `internal/hashcash/channel_test.go`: happy path, wrong channel, wrong body hash, insufficient bits, zero bits skip, bad format, bad version, expired stamp, missing body hash, body hash determinism, stamp hash, mint+validate round-trip
- **10 integration tests** in `internal/handlers/api_test.go`: set mode, query mode, clear mode, reject no stamp, accept valid stamp, reject replayed stamp, no requirement works, invalid bits range, missing bits arg

### README
- Added `+H` to channel modes table
- Added "Per-Channel Hashcash (Anti-Spam)" section with full documentation
- Updated `meta` field description to mention hashcash

## How It Works

1. Channel operator sets requirement: `MODE #general +H 20` (20 bits)
2. Client mints stamp: computes SHA-256 hashcash bound to `#general` + SHA-256(body)
3. Client sends PRIVMSG with `meta.hashcash` field containing the stamp
4. Server validates stamp, checks spent cache, records as spent, relays message
5. Replayed stamps are rejected for 1 year

## Docker Build

`docker build .` passes clean (formatting, linting, all tests).

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #79
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-18 03:40:33 +01:00
efbd8fe9ff docs: update README schema section to match sessions/clients tables (#76)
All checks were successful
check / check (push) Successful in 5s
Updates the README Schema section and all related references throughout the document to accurately reflect the current database schema in `001_initial.sql`.

## Changes

**Schema section:**
- Renamed `users` table → `sessions` with new columns: `uuid`, `password_hash`, `signing_key`, `away_message`
- Added new `clients` table (multi-client support: `uuid`, `session_id` FK, `token`, `created_at`, `last_seen`)
- Added `topic_set_by` and `topic_set_at` columns to `channels` table
- Updated `channel_members` FK from `user_id` → `session_id`
- Added `params` column to `messages` table
- Updated `client_queues` FK from `user_id` → `client_id`
- Added cascade delete annotations to FK descriptions
- Added index documentation for `sessions` and `clients` tables

**References throughout README:**
- Updated Queue Architecture diagram labels (`user_id=N` → `client_id=N`)
- Updated `client_queues` description text (`user_id` → `client_id`)
- Updated In-Memory Broker description to use `client_id` terminology
- Updated Multi-Client Model MVP note to reflect sessions/clients architecture
- Updated long-polling implementation detail to reference per-client notification channels

closes #37

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #76
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-18 03:38:36 +01:00
e36bd99ef6 security: enforce channel membership check in handleTopic (#75)
All checks were successful
check / check (push) Successful in 1m48s
## Summary

`handleTopic` in `internal/handlers/api.go` did NOT check that the user was a member of the channel before allowing them to set a topic. Any authenticated user could set the topic on any channel they hadn't joined.

## Changes

- **`internal/handlers/api.go`**: Added `IsChannelMember` check after resolving the channel ID and before calling `executeTopic`, mirroring the existing pattern in `handleChannelMsg`. Non-members now receive `ERR_NOTONCHANNEL` (442).
- **`internal/handlers/api_test.go`**: Added `TestTopicNonMember` — creates a channel with one user, then verifies a second user who hasn't joined receives numeric 442 when attempting to set the topic.

## Testing

- All existing tests pass
- New `TestTopicNonMember` test validates the fix
- `docker build .` passes clean (formatting, linting, tests, build)

closes #33

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #75
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-17 12:47:00 +01:00
e9d794764b docs: document register/login and dual authentication model (#77)
All checks were successful
check / check (push) Successful in 1m46s
closes #36

The README claimed "no accounts" and "no passwords" but the codebase has `POST /api/v1/register` and `POST /api/v1/login` endpoints with bcrypt password hashing. This PR updates the README to accurately describe the dual authentication model.

## Changes

### Identity & Sessions section
- Renamed from "No Accounts" to "Dual Authentication Model"
- Documented anonymous sessions (`POST /api/v1/session`) as the instant-access path
- Documented optional account registration (`POST /api/v1/register`) with password requirements
- Documented login (`POST /api/v1/login`) for returning to registered accounts
- Updated rationale to explain why both paths exist

### API Reference
- Added `POST /api/v1/register` endpoint documentation: request/response format, field constraints (min 8 char password), error codes, curl example
- Added `POST /api/v1/login` endpoint documentation: request/response format, channel state initialization behavior, error codes, curl example

### Security Model → Authentication
- Added password hashing details (bcrypt at default cost)
- Documented that anonymous sessions have empty `password_hash` and cannot use `/login`
- Distinguished between anonymous and registered auth paths

### Design Principles
- Changed principle #2 from "No accounts" to "Accounts optional" with updated description

### Schema section
- Updated from outdated `users` table to actual `sessions` table (with `password_hash`, `signing_key`, `away_message`, `uuid` columns)
- Added `clients` table documentation (session_id FK, token, uuid)

### Session Lifecycle
- Added "Registered Account" flow diagram showing register → use → login-from-new-device

### Multi-Client Model
- Updated MVP note to document that `POST /api/v1/login` is the working multi-client mechanism

### Client Development Guide
- Added register and login curl examples alongside anonymous session creation
- Updated error handling and reconnection guidance for registered accounts

### Data Lifecycle
- Documented that registered sessions persist across logouts (unlike anonymous)
- Added client lifecycle documentation

### Other
- Fixed token storage description (SHA-256 hash, not raw)
- Updated "What didn't change" section to reflect optional accounts

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #77
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-17 12:44:48 +01:00
052674b4ee feat: add runtime statistics to healthcheck endpoint (#80)
Some checks failed
check / check (push) Has been cancelled
## Summary

Expands the `/.well-known/healthcheck.json` endpoint with runtime statistics, giving operators visibility into server load and usage patterns.

closes #74

## New healthcheck fields

| Field | Source | Description |
|-------|--------|-------------|
| `sessions` | DB | Current active session count |
| `clients` | DB | Current connected client count |
| `queuedLines` | DB | Total entries in client output queues |
| `channels` | DB | Current channel count |
| `connectionsSinceBoot` | Memory | Total client connections since server start |
| `sessionsSinceBoot` | Memory | Total sessions created since server start |
| `messagesSinceBoot` | Memory | Total PRIVMSG/NOTICE messages since server start |

## Implementation

- **New `internal/stats` package** — atomic counters for boot-scoped metrics (`connectionsSinceBoot`, `sessionsSinceBoot`, `messagesSinceBoot`). Thread-safe via `sync/atomic`.
- **New DB queries** — `GetClientCount()` and `GetQueueEntryCount()` for current snapshot counts.
- **Healthcheck changes** — `Healthcheck()` now accepts `context.Context` to query the database. Response struct extended with all 7 new fields. DB-derived stats populated with graceful error handling (logged, not fatal).
- **Counter instrumentation** — Increments added at:
  - `handleCreateSession` → `IncrSessions` + `IncrConnections`
  - `handleRegister` → `IncrSessions` + `IncrConnections`
  - `handleLogin` → `IncrConnections` (new client for existing session)
  - `handlePrivmsg` → `IncrMessages` (covers both PRIVMSG and NOTICE)
- **Wired via fx** — `stats.Tracker` provided through Uber fx DI in both production and test setups.

## Tests

- `internal/stats/stats_test.go` — 5 tests covering all counter operations (100% coverage)
- `TestHealthcheckRuntimeStatsFields` — verifies all 7 new fields are present in the response
- `TestHealthcheckRuntimeStatsValues` — end-to-end: creates a session, joins a channel, sends a message, then verifies counts are nonzero

## README

Updated healthcheck documentation with full response shape, field descriptions, and project structure listing for `internal/stats/`.

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #80
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-17 12:43:39 +01:00
23 changed files with 2644 additions and 607 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -7,10 +7,21 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build
build:
# ensure-web-dist creates placeholder files so //go:embed dist/* in
# 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
lint:
lint: ensure-web-dist
golangci-lint run --config .golangci.yml ./...
fmt:
@@ -20,7 +31,7 @@ fmt:
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test:
test: ensure-web-dist
go test -timeout 30s -v -race -cover ./...
# check runs all validation without making changes

523
README.md
View File

@@ -113,8 +113,9 @@ mechanisms or stuffing data into CTCP.
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
are unique per server. There are no accounts — identity is a key, a nick is a
display name.
are unique per server. Identity starts with a key a nick is a display name.
Accounts are optional: you can create an anonymous session instantly, or
set a password via the PASS command for multi-client access to a single session.
### On the resemblance to JSON-RPC
@@ -148,28 +149,59 @@ 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
need to change.
### Identity & Sessions — No Accounts
### Identity & Sessions — Cookie-Based Authentication
There are no accounts, no registration, no passwords. Identity is a signing
key; a nick is just a display name. The two are decoupled.
The server uses **HTTP cookies** for all authentication. There is no separate
registration step — sessions start anonymous and can optionally set a password
for multi-client access.
#### Session Creation
- **Session creation**: client sends `POST /api/v1/session` with a desired
nick → server assigns an **auth token** (64 hex characters of
cryptographically random bytes) and returns the user ID, nick, and token.
- The auth token implicitly identifies the client. Clients present it via
`Authorization: Bearer <token>`.
nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing
a cryptographically random token (64 hex characters) and returns the user
ID and nick in the JSON response body. No token appears in the JSON body.
- The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS.
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
the stable identity.
- Server-assigned IDs — clients do not choose their own IDs.
- Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded
in the token, no client-side decode. The server is the sole authority on
token validity.
- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry
encoded in the token, no client-side decode. The server is the sole authority
on cookie validity.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding
registration, email verification, or OAuth would solve a problem nobody asked
about and add complexity that drives away casual users. Identity verification
is handled at the message layer via cryptographic signatures (see
[Security Model](#security-model)), not at the session layer.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
Anonymous sessions preserve that simplicity — instant access, zero friction.
But some users want to access the same session from multiple devices without
a bouncer. The PASS command enables multi-client login without adding friction
for casual users: if you don't need multi-client, just create a session and
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
@@ -196,7 +228,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
user session.
- `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth token.
identified by the auth cookie.
- Client queues have **independent expiry/pruning** — one client going offline
doesn't affect others.
@@ -207,12 +239,11 @@ User Session
└── Client C (token_c, queue_c)
```
**Current MVP note:** The current implementation creates a new session (with new
nick) per `POST /api/v1/session` call. True multi-client (multiple clients
sharing one session/nick) is supported by the schema — `clients` references
`sessions` via `session_id`, and `client_queues` is keyed by `client_id` — but
the session creation endpoint does not yet support "add a client to an existing
session." This will be added post-MVP.
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
client to an existing session (one that has a password set via PASS command),
enabling true multi-client support (multiple cookies sharing one nick/session
with independent message queues). Sessions without a password cannot be
logged into.
**Rationale:** The fundamental IRC mobile problem is that you can't have your
phone and laptop connected simultaneously without a bouncer. Server-side
@@ -319,19 +350,22 @@ over binary is measured in bytes per message, not meaningful bandwidth. The
canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
matters for signing.
### Why Opaque Tokens Instead of JWTs
### Why Opaque Cookies Instead of JWTs
JWTs encode claims that clients can decode and potentially rely on. This
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
clients may break or behave incorrectly.
Opaque tokens are simpler:
- Server generates 32 random bytes → hex-encodes → stores hash
- Client presents the token; server looks it up
- Revocation is a database delete
Opaque auth cookies are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
sets raw hex as an HttpOnly cookie
- On each request, server hashes the cookie value and looks it up
- Revocation is a database delete (cookie becomes invalid immediately)
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
- Token format can change without breaking clients
- Cookie format can change without breaking clients
- Clients never handle tokens directly — browsers and HTTP cookie jars
manage everything automatically
---
@@ -355,15 +389,18 @@ The entire read/write loop for a client is two endpoints. Everything else
### Session Lifecycle
#### Session Creation
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session {"nick":"alice"} │
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."}
│ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ │
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
│ → {"status":"joined","channel":"#general"} │
│ (Server fans out JOIN event to all #general members)
│ (Cookie sent automatically on all subsequent requests)
│ │
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │
│ "to":"#general","body":["hello"]} │
@@ -380,7 +417,37 @@ The entire read/write loop for a client is two endpoints. Everything else
│ 6. POST /api/v1/messages {"command":"QUIT"} │
│ → {"status":"quit"} │
│ (Server broadcasts QUIT, removes from channels, │
│ deletes session, releases nick)
│ deletes session, releases nick, clears cookie)
│ │
└────────────────────────────────────────────────────────────┘
```
#### 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.) │
│ │
└────────────────────────────────────────────────────────────┘
```
@@ -461,7 +528,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). |
| `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. |
| `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. |
| `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. |
**Important invariants:**
@@ -667,6 +734,35 @@ Change the user's nickname.
**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
Set or change a channel's topic.
@@ -733,7 +829,7 @@ Destroy the session and disconnect from the server.
quitting user. The quitting user does **not** receive their own QUIT.
- The user is removed from all channels.
- Empty channels are deleted (ephemeral).
- The user's session is destroyed — the auth token is invalidated, the nick
- The user's session is destroyed — the auth cookie is invalidated, the nick
is released.
- Subsequent requests with the old token return HTTP 401.
@@ -951,12 +1047,13 @@ carries IRC-style parameters (e.g., channel name, target nick).
Inspired by IRC, simplified:
| Mode | Name | Meaning |
|------|--------------|---------|
|------|----------------|---------|
| `+i` | Invite-only | Only invited users can join |
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
| `+s` | Secret | Channel hidden from LIST response |
| `+t` | Topic lock | Only operators can change the topic |
| `+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):**
@@ -967,14 +1064,64 @@ Inspired by IRC, simplified:
**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.
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
All endpoints accept and return `application/json`. Authenticated endpoints
require `Authorization: Bearer <token>` header. The token is obtained from
`POST /api/v1/session`.
require the `neoirc_auth` cookie, which is set automatically by
`POST /api/v1/session` and `POST /api/v1/login`.
All API responses include appropriate HTTP status codes. Error responses have
the format:
@@ -1003,11 +1150,18 @@ 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) |
**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
{
"id": 1,
"nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
"nick": "alice"
}
```
@@ -1015,7 +1169,16 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|---------|---------|-------------|
| `id` | integer | Server-assigned user ID |
| `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:**
@@ -1028,10 +1191,61 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
# Use -c to save cookies, -b to send them
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
echo $TOKEN
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}'
```
### 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
@@ -1075,13 +1289,13 @@ Each channel object:
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/state \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
**Reconnect with channel state initialization:**
```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
### GET /api/v1/messages — Poll Messages (Long-Poll)
@@ -1141,14 +1355,12 @@ real-time endpoint — clients call it in a loop.
**curl example (immediate):**
```bash
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
```
**curl example (long-poll, 15s):**
```bash
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
```
### POST /api/v1/messages — Send Command
@@ -1175,6 +1387,7 @@ reference with all required and optional fields.
| `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK |
| `PASS` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK |
| `NAMES` | `to` | | 200 OK |
@@ -1189,14 +1402,14 @@ All IRC commands return HTTP 200 OK. IRC-level success and error responses
are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid
auth tokens (401), and server errors (500).
auth cookies (401), and server errors (500).
**HTTP errors (transport-level only):**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid request` | Malformed JSON or empty command |
| 401 | `unauthorized` | Missing or invalid auth token |
| 401 | `unauthorized` | Missing or invalid auth cookie |
| 500 | `internal error` | Server-side failure |
**IRC numeric error replies (delivered via message queue):**
@@ -1287,11 +1500,11 @@ events). Event messages are delivered via the live queue only.
```bash
# Latest 50 messages in #general
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
# Older messages (pagination)
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
### GET /api/v1/channels — List Channels
@@ -1322,18 +1535,22 @@ List members of a channel. The `{name}` parameter is the channel name
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/channels/general/members \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
### POST /api/v1/logout — Logout
Destroy the current client's auth token. If no other clients remain on the
session, the user is fully cleaned up: parted from all channels (with QUIT
broadcast to members), session deleted, nick released.
Destroy the current client's session cookie and server-side client record.
If no other clients remain on the session, the user is fully cleaned up:
parted from all channels (with QUIT broadcast to members), session deleted,
nick released. The auth cookie is cleared in the response.
**Request:** No body. Requires auth.
**Request:** No body. Requires auth cookie.
**Response:** `200 OK`
The response clears the `neoirc_auth` cookie.
```json
{"status": "ok"}
```
@@ -1342,12 +1559,11 @@ broadcast to members), session deleted, nick released.
| Status | Error | When |
|--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth token |
| 401 | `unauthorized` | Missing or invalid auth cookie |
**curl example:**
```bash
curl -s -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
```
### GET /api/v1/users/me — Current User Info
@@ -1371,7 +1587,7 @@ Return the current user's session state. This is an alias for
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" | jq .
-b cookies.txt | jq .
```
### GET /api/v1/server — Server Info
@@ -1399,13 +1615,40 @@ Return server metadata. No authentication required.
### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required.
Standard health check endpoint. No authentication required. Returns server
health status and runtime statistics.
**Response:** `200 OK`
```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
@@ -1589,13 +1832,21 @@ authenticity.
### Authentication
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
Tokens are stored in the database and validated on every request.
- **No passwords**: Session creation requires only a nick. The token is the
sole credential.
- **Token security**: Tokens should be treated like session cookies. Transmit
only over HTTPS in production. If a token is compromised, the attacker has
full access to the session until QUIT or expiry.
- **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of
entropy). Tokens are hashed (SHA-256) before storage and validated on every
request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict
(CSRF protection), and Secure when behind TLS.
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
password, instant access. The auth cookie is the sole credential.
- **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed
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
@@ -1632,8 +1883,10 @@ authenticity.
- **HTTPS is strongly recommended** for production deployments. The server
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS
termination.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
Restrict this in production via reverse proxy configuration if needed.
- **CORS**: The server allows all origins with credentials
(`Access-Control-Allow-Credentials: true`), reflecting the request Origin.
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
responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The
@@ -1746,9 +1999,9 @@ The database schema is managed via embedded SQL migration files in
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick |
| `password_hash` | TEXT | Password hash (default empty) |
| `signing_key` | TEXT | Ed25519 signing key (default empty) |
| `away_message` | TEXT | Away message (default empty) |
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key (empty string if unset) |
| `away_message` | TEXT | Away message (empty string if not away) |
| `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time |
@@ -1760,7 +2013,7 @@ Index on `(uuid)`.
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID |
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `token` | TEXT | Unique auth token (64 hex chars) |
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) |
| `created_at` | DATETIME | Client creation time |
| `last_seen` | DATETIME | Last API request time |
@@ -1823,10 +2076,19 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Client output queue entries**: Pruned automatically when older than
`QUEUE_MAX_AGE` (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. 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.
- **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT`
or when the last client logs out (`POST /api/v1/logout` with no remaining
clients triggers session cleanup). There is no distinction between session
types in the cleanup path — `handleQuit` and `cleanupUser` both call
`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.
---
@@ -1975,58 +2237,59 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl
```bash
# 1. Create a session
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
# 1a. Create a session (cookie saved automatically with -c)
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser"}' | jq -r .token)
-d '{"nick":"testuser"}'
# 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
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"JOIN","to":"#general"}'
# 3. Send a message
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
# 4. Poll for messages (one-shot)
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
# 5. Long-poll (blocks up to 15s waiting for messages)
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
# 6. Send a DM
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
# 7. Change nick
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"NICK","body":["newnick"]}'
# 8. Set channel topic
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
# 9. Leave a channel
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"PART","to":"#general","body":["goodbye"]}'
# 10. Disconnect
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H 'Content-Type: application/json' \
-d '{"command":"QUIT","body":["leaving"]}'
```
@@ -2036,27 +2299,25 @@ curl -s -X POST http://localhost:8080/api/v1/messages \
The key to real-time messaging is the poll loop. Here's the pattern:
```python
# Python example
import requests, json
# Python example — using requests.Session for automatic cookie handling
import requests, json, time
BASE = "http://localhost:8080/api/v1"
token = None
session = requests.Session() # Manages cookies automatically
last_id = 0
# Create session
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
token = resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Create session (cookie set automatically via Set-Cookie header)
resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
print(f"Session: {resp.json()}")
# Join channel
requests.post(f"{BASE}/messages", headers=headers,
session.post(f"{BASE}/messages",
json={"command": "JOIN", "to": "#general"})
# Poll loop
while True:
try:
resp = requests.get(f"{BASE}/messages",
headers=headers,
resp = session.get(f"{BASE}/messages",
params={"after": last_id, "timeout": 15},
timeout=20) # HTTP timeout > long-poll timeout
data = resp.json()
@@ -2073,14 +2334,14 @@ while True:
```
```javascript
// JavaScript/browser example
async function pollLoop(token) {
// JavaScript/browser example — cookies sent automatically
async function pollLoop() {
let lastId = 0;
while (true) {
try {
const resp = await fetch(
`/api/v1/messages?after=${lastId}&timeout=15`,
{headers: {'Authorization': `Bearer ${token}`}}
{credentials: 'same-origin'} // Include cookies
);
if (resp.status === 401) { /* session expired */ break; }
const data = await resp.json();
@@ -2112,9 +2373,11 @@ Clients should handle these message commands from the queue:
### Error Handling
- **HTTP 401**: Token expired or invalid. Re-create session.
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or
re-login (if a password was set).
- **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation or NICK change).
- **HTTP 409**: Nick already taken (on session creation, registration, or
NICK change).
- **HTTP 400**: Malformed request. Check the `error` field in the response.
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
@@ -2131,8 +2394,11 @@ Clients should handle these message commands from the queue:
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:
if `from` is you, use `to`; if `from` is someone else, use `from`.
5. **Reconnection**: If the poll loop fails with 401, the session is gone.
Create a new session. If it fails with a network error, retry with backoff.
5. **Reconnection**: If the poll loop fails with 401, the auth cookie is
invalid. For sessions without a password, create a new session. For
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.
---
@@ -2291,8 +2557,10 @@ creating one session pays once and keeps their session.
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
- [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [ ] **Multi-client sessions** — add client to existing session
(share nick across devices)
- [x] **Multi-client sessions** — set a password via PASS command, then
login from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
all API authentication
### Future (1.0+)
@@ -2352,6 +2620,8 @@ neoirc/
│ │ └── healthcheck.go # Health check handler
│ ├── healthcheck/ # Health check logic
│ │ └── healthcheck.go
│ ├── stats/ # Runtime statistics (atomic counters)
│ │ └── stats.go
│ ├── logger/ # slog-based logging
│ │ └── logger.go
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
@@ -2403,9 +2673,12 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the
API is too complex.
2. **No accounts** — identity is a signing key, nick is a display name. No
registration, no passwords, no email verification. Session creation is
instant. The cost of entry is a hashcash proof, not bureaucracy.
2. **Passwords optional** — anonymous sessions are instant: pick a nick and
talk. No registration, no email verification. The cost of entry is a
hashcash proof, not bureaucracy. For users who want multi-client access
(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
RFC 1459/2812. If you've built an IRC client or bot, you already know the

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ import (
"fmt"
"math/big"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (
@@ -37,6 +39,23 @@ 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
// leading zero bits.
func hasLeadingZeroBits(

View File

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

View File

@@ -16,80 +16,28 @@ var errNoPassword = errors.New(
"account has no password set",
)
// RegisterUser creates a session with a hashed password
// and returns session ID, client ID, and token.
func (database *Database) RegisterUser(
// SetPassword sets a bcrypt-hashed password on a session,
// enabling multi-client login via POST /api/v1/login.
func (database *Database) SetPassword(
ctx context.Context,
nick, password string,
) (int64, int64, string, error) {
sessionID int64,
password string,
) error {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
if err != nil {
return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
return fmt.Errorf("hash password: %w", err)
}
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
token, err := generateToken()
_, err = database.conn.ExecContext(ctx,
"UPDATE sessions SET password_hash = ? WHERE id = ?",
string(hash), sessionID)
if err != nil {
return 0, 0, "", err
return fmt.Errorf("set password: %w", err)
}
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
return nil
}
// LoginUser verifies a nick/password and creates a new

View File

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

View File

@@ -1266,3 +1266,149 @@ func (database *Database) PruneOldMessages(
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,6 +33,7 @@ CREATE TABLE IF NOT EXISTS channels (
topic TEXT NOT NULL DEFAULT '',
topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -61,6 +62,14 @@ 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_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
CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -3,6 +3,7 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
@@ -11,10 +12,16 @@ import (
"time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/go-chi/chi/v5"
)
var (
errHashcashRequired = errors.New("hashcash required")
errHashcashReused = errors.New("hashcash reused")
)
var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
)
@@ -29,6 +36,7 @@ const (
defaultMaxBodySize = 4096
defaultHistLimit = 50
maxHistLimit = 500
authCookieName = "neoirc_auth"
)
func (hdlr *Handlers) maxBodySize() int64 {
@@ -39,23 +47,18 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize
}
// authSession extracts the session from the client token.
// authSession extracts the session from the auth cookie.
func (hdlr *Handlers) authSession(
request *http.Request,
) (int64, int64, string, error) {
auth := request.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
cookie, err := request.Cookie(authCookieName)
if err != nil || cookie.Value == "" {
return 0, 0, "", errUnauthorized
}
sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken(
request.Context(), token,
request.Context(), cookie.Value,
)
if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -64,6 +67,46 @@ func (hdlr *Handlers) authSession(
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(
writer http.ResponseWriter,
request *http.Request,
@@ -88,10 +131,11 @@ func (hdlr *Handlers) fanOut(
request *http.Request,
command, from, target string,
body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64,
) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, nil, body, nil,
request.Context(), command, from, target, nil, body, meta,
)
if err != nil {
return "", fmt.Errorf("insert message: %w", err)
@@ -117,10 +161,11 @@ func (hdlr *Handlers) fanOutSilent(
request *http.Request,
command, from, target string,
body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64,
) error {
_, err := hdlr.fanOut(
request, command, from, target, body, sessionIDs,
request, command, from, target, body, meta, sessionIDs,
)
return err
@@ -212,12 +257,16 @@ func (hdlr *Handlers) handleCreateSession(
return
}
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
@@ -291,7 +340,7 @@ func (hdlr *Handlers) deliverWelcome(
[]string{
"CHANTYPES=#",
"NICKLEN=32",
"CHANMODES=,,," + "imnst",
"CHANMODES=,,H," + "imnst",
"NETWORK=neoirc",
"CASEMAPPING=ascii",
},
@@ -822,7 +871,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
writer, request,
sessionID, clientID, nick,
payload.Command, payload.To,
payload.Body, bodyLines,
payload.Body, payload.Meta, bodyLines,
)
}
}
@@ -833,6 +882,7 @@ func (hdlr *Handlers) dispatchCommand(
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string,
) {
switch command {
@@ -845,7 +895,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg(
writer, request,
sessionID, clientID, nick,
command, target, body, bodyLines,
command, target, body, meta, bodyLines,
)
case irc.CmdJoin:
hdlr.handleJoin(
@@ -862,6 +912,11 @@ func (hdlr *Handlers) dispatchCommand(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic:
hdlr.handleTopic(
writer, request,
@@ -946,6 +1001,7 @@ func (hdlr *Handlers) handlePrivmsg(
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string,
) {
if target == "" {
@@ -977,11 +1033,13 @@ func (hdlr *Handlers) handlePrivmsg(
return
}
hdlr.stats.IncrMessages()
if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg(
writer, request,
sessionID, clientID, nick,
command, target, body,
command, target, body, meta,
)
return
@@ -990,7 +1048,7 @@ func (hdlr *Handlers) handlePrivmsg(
hdlr.handleDirectMsg(
writer, request,
sessionID, clientID, nick,
command, target, body,
command, target, body, meta,
)
}
@@ -1021,6 +1079,7 @@ func (hdlr *Handlers) handleChannelMsg(
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
) {
chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target,
@@ -1061,9 +1120,172 @@ func (hdlr *Handlers) handleChannelMsg(
return
}
hdlr.sendChannelMsg(
writer, request, command, nick, target, body, chID,
hashcashErr := hdlr.validateChannelHashcash(
request, clientID, sessionID,
writer, nick, target, body, meta, chID,
)
if hashcashErr != nil {
return
}
hdlr.sendChannelMsg(
writer, request, command, nick, target,
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(
@@ -1071,6 +1293,7 @@ func (hdlr *Handlers) sendChannelMsg(
request *http.Request,
command, nick, target string,
body json.RawMessage,
meta json.RawMessage,
chID int64,
) {
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
@@ -1090,7 +1313,7 @@ func (hdlr *Handlers) sendChannelMsg(
}
msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, memberIDs,
request, command, nick, target, body, meta, memberIDs,
)
if err != nil {
hdlr.log.Error("send message failed", "error", err)
@@ -1114,6 +1337,7 @@ func (hdlr *Handlers) handleDirectMsg(
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
) {
targetSID, err := hdlr.params.Database.GetSessionByNick(
request.Context(), target,
@@ -1138,7 +1362,7 @@ func (hdlr *Handlers) handleDirectMsg(
}
msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, recipients,
request, command, nick, target, body, meta, recipients,
)
if err != nil {
hdlr.log.Error("send dm failed", "error", err)
@@ -1249,7 +1473,7 @@ func (hdlr *Handlers) executeJoin(
)
_ = hdlr.fanOutSilent(
request, irc.CmdJoin, nick, channel, nil, memberIDs,
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
)
hdlr.deliverJoinNumerics(
@@ -1419,7 +1643,7 @@ func (hdlr *Handlers) handlePart(
)
_ = hdlr.fanOutSilent(
request, irc.CmdPart, nick, channel, body, memberIDs,
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
)
err = hdlr.params.Database.PartChannel(
@@ -1636,6 +1860,32 @@ func (hdlr *Handlers) handleTopic(
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(
writer, request,
sessionID, clientID, nick,
@@ -1673,7 +1923,7 @@ func (hdlr *Handlers) executeTopic(
)
_ = hdlr.fanOutSilent(
request, irc.CmdTopic, nick, channel, body, memberIDs,
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
)
hdlr.enqueueNumeric(
@@ -1797,6 +2047,8 @@ func (hdlr *Handlers) handleQuit(
request.Context(), sessionID,
)
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"},
http.StatusOK)
@@ -1836,11 +2088,10 @@ func (hdlr *Handlers) handleMode(
return
}
_ = bodyLines
hdlr.handleChannelMode(
writer, request,
sessionID, clientID, nick, channel,
bodyLines,
)
}
@@ -1849,6 +2100,7 @@ func (hdlr *Handlers) handleChannelMode(
request *http.Request,
sessionID, clientID int64,
nick, channel string,
bodyLines func() []string,
) {
ctx := request.Context()
@@ -1865,10 +2117,47 @@ func (hdlr *Handlers) handleChannelMode(
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
hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, "+n"}, "",
[]string{channel, modeStr}, "",
)
// 329 RPL_CREATIONTIME
@@ -1893,6 +2182,156 @@ func (hdlr *Handlers) handleChannelMode(
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.
func (hdlr *Handlers) handleNames(
writer http.ResponseWriter,
@@ -2412,6 +2851,8 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
)
}
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)

File diff suppressed because it is too large Load Diff

View File

@@ -5,117 +5,11 @@ import (
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
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.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
@@ -180,6 +74,8 @@ func (hdlr *Handlers) handleLogin(
return
}
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)
@@ -190,9 +86,66 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick,
)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, 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,6 +16,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
@@ -30,10 +31,16 @@ type Params struct {
Config *config.Config
Database *db.Database
Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
}
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.
type Handlers struct {
params *Params
@@ -41,6 +48,8 @@ type Handlers struct {
hc *healthcheck.Healthcheck
broker *broker.Broker
hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker
cancelCleanup context.CancelFunc
}
@@ -60,6 +69,8 @@ func New(
hc: params.Healthcheck,
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats,
}
lifecycle.Append(fx.Hook{
@@ -281,4 +292,20 @@ 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,
request *http.Request,
) {
resp := hdlr.hc.Healthcheck()
resp := hdlr.hc.Healthcheck(request.Context())
hdlr.respondJSON(writer, request, resp, httpStatusOK)
}
}

View File

@@ -0,0 +1,186 @@
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

@@ -0,0 +1,244 @@
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,6 +10,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
@@ -21,6 +22,7 @@ type Params struct {
Config *config.Config
Logger *logger.Logger
Database *db.Database
Stats *stats.Tracker
}
// Healthcheck tracks server uptime and provides health status.
@@ -64,11 +66,22 @@ type Response struct {
Version string `json:"version"`
Appname string `json:"appname"`
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.
func (hcheck *Healthcheck) Healthcheck() *Response {
return &Response{
func (hcheck *Healthcheck) Healthcheck(
ctx context.Context,
) *Response {
resp := &Response{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(hcheck.uptime().Seconds()),
@@ -76,6 +89,64 @@ func (hcheck *Healthcheck) Healthcheck() *Response {
Appname: hcheck.params.Globals.Appname,
Version: hcheck.params.Globals.Version,
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,18 +126,23 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
}
// 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 {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowedOrigins: []string{"*"},
AllowOriginFunc: func(
_ *http.Request, _ string,
) bool {
return true
},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
"Accept", "Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
AllowCredentials: true,
MaxAge: corsMaxAge,
})
}

View File

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

52
internal/stats/stats.go Normal file
View File

@@ -0,0 +1,52 @@
// 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

@@ -0,0 +1,117 @@
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,6 +11,7 @@ const (
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdPass = "PASS"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"