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
19 changed files with 1261 additions and 602 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 BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -7,10 +7,21 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build 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 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
lint: lint: ensure-web-dist
golangci-lint run --config .golangci.yml ./... golangci-lint run --config .golangci.yml ./...
fmt: fmt:
@@ -20,7 +31,7 @@ fmt:
fmt-check: fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: test: ensure-web-dist
go test -timeout 30s -v -race -cover ./... go test -timeout 30s -v -race -cover ./...
# check runs all validation without making changes # check runs all validation without making changes

552
README.md
View File

@@ -113,8 +113,9 @@ mechanisms or stuffing data into CTCP.
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`. `KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
are unique per server. There are no accounts — identity is a key, a nick is a are unique per server. Identity starts with a key a nick is a display name.
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 ### 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 IRC's command model is correct and only the transport and session management
need to change. need to change.
### Identity & Sessions — No Accounts ### Identity & Sessions — Cookie-Based Authentication
There are no accounts, no registration, no passwords. Identity is a signing The server uses **HTTP cookies** for all authentication. There is no separate
key; a nick is just a display name. The two are decoupled. 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 - **Session creation**: client sends `POST /api/v1/session` with a desired
nick → server assigns an **auth token** (64 hex characters of nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing
cryptographically random bytes) and returns the user ID, nick, and token. a cryptographically random token (64 hex characters) and returns the user
- The auth token implicitly identifies the client. Clients present it via ID and nick in the JSON response body. No token appears in the JSON body.
`Authorization: Bearer <token>`. - 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 - Nicks are changeable via the `NICK` command; the server-assigned user ID is
the stable identity. the stable identity.
- Server-assigned IDs — clients do not choose their own IDs. - Server-assigned IDs — clients do not choose their own IDs.
- Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded - Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry
in the token, no client-side decode. The server is the sole authority on encoded in the token, no client-side decode. The server is the sole authority
token validity. on cookie validity.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding **Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
registration, email verification, or OAuth would solve a problem nobody asked Anonymous sessions preserve that simplicity — instant access, zero friction.
about and add complexity that drives away casual users. Identity verification But some users want to access the same session from multiple devices without
is handled at the message layer via cryptographic signatures (see a bouncer. The PASS command enables multi-client login without adding friction
[Security Model](#security-model)), not at the session layer. 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 ### 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 - The server fans out all S2C messages to every active client queue for that
user session. user session.
- `GET /api/v1/messages` delivers from the calling client's specific queue, - `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth token. identified by the auth cookie.
- Client queues have **independent expiry/pruning** — one client going offline - Client queues have **independent expiry/pruning** — one client going offline
doesn't affect others. doesn't affect others.
@@ -207,12 +239,11 @@ User Session
└── Client C (token_c, queue_c) └── Client C (token_c, queue_c)
``` ```
**Current MVP note:** The current implementation creates a new user (with new **Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
nick) per `POST /api/v1/session` call. True multi-client (multiple tokens client to an existing session (one that has a password set via PASS command),
sharing one nick/session) is supported by the schema (`client_queues` is keyed enabling true multi-client support (multiple cookies sharing one nick/session
by user_id, and multiple tokens can point to the same user) but the session with independent message queues). Sessions without a password cannot be
creation endpoint does not yet support "add a client to an existing session." logged into.
This will be added post-MVP.
**Rationale:** The fundamental IRC mobile problem is that you can't have your **Rationale:** The fundamental IRC mobile problem is that you can't have your
phone and laptop connected simultaneously without a bouncer. Server-side phone and laptop connected simultaneously without a bouncer. Server-side
@@ -265,8 +296,8 @@ The server implements HTTP long-polling for real-time message delivery:
- The client disconnects (connection closed, no response needed) - The client disconnects (connection closed, no response needed)
**Implementation detail:** The server maintains an in-memory broker with **Implementation detail:** The server maintains an in-memory broker with
per-user notification channels. When a message is enqueued for a user, the per-client notification channels. When a message is enqueued for a client, the
broker closes all waiting channels for that user, waking up any blocked broker closes all waiting channels for that client, waking up any blocked
long-poll handlers. This is O(1) notification — no polling loops, no database long-poll handlers. This is O(1) notification — no polling loops, no database
scanning. scanning.
@@ -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 canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
matters for signing. 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 JWTs encode claims that clients can decode and potentially rely on. This
creates a coupling between token format and client behavior. If the server creates a coupling between token format and client behavior. If the server
needs to revoke a token, change the expiry model, or add/remove claims, JWT needs to revoke a token, change the expiry model, or add/remove claims, JWT
clients may break or behave incorrectly. clients may break or behave incorrectly.
Opaque tokens are simpler: Opaque auth cookies are simpler:
- Server generates 32 random bytes → hex-encodes → stores hash - Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
- Client presents the token; server looks it up sets raw hex as an HttpOnly cookie
- Revocation is a database delete - 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 - 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 Lifecycle
#### Session Creation
``` ```
┌─ Client ──────────────────────────────────────────────────┐ ┌─ Client ──────────────────────────────────────────────────┐
│ │ │ │
│ 1. POST /api/v1/session {"nick":"alice"} │ │ 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"} │ │ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
│ → {"status":"joined","channel":"#general"} │ │ → {"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", │ │ 3. POST /api/v1/messages {"command":"PRIVMSG", │
│ "to":"#general","body":["hello"]} │ │ "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"} │ │ 6. POST /api/v1/messages {"command":"QUIT"} │
│ → {"status":"quit"} │ │ → {"status":"quit"} │
│ (Server broadcasts QUIT, removes from channels, │ │ (Server broadcasts QUIT, removes from channels, │
│ deletes session, releases nick) │ 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.) │
│ │ │ │
└────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────┘
``` ```
@@ -398,28 +465,28 @@ The entire read/write loop for a client is two endpoints. Everything else
│ │ │ │ │ │
┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐ ┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐
│client_queue│ │client_queue│ │client_queue│ │client_queue│ │client_queue│ │client_queue│
user_id=1 │ │ user_id=2 │ │ user_id=3 client_id=1│ │ client_id=2│ │ client_id=3│
│ msg_id=N │ │ msg_id=N │ │ msg_id=N │ │ msg_id=N │ │ msg_id=N │ │ msg_id=N │
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
alice bob carol alice bob carol
Each message is stored ONCE. One queue entry per recipient. Each message is stored ONCE. One queue entry per recipient client.
``` ```
The `client_queues` table contains `(user_id, message_id)` pairs. When a The `client_queues` table contains `(client_id, message_id)` pairs. When a
client polls with `GET /messages?after=<queue_id>`, the server queries for client polls with `GET /messages?after=<queue_id>`, the server queries for
queue entries with `id > after` for that user, joins against the messages queue entries with `id > after` for that client, joins against the messages
table, and returns the results. The `queue_id` (auto-incrementing primary table, and returns the results. The `queue_id` (auto-incrementing primary
key of `client_queues`) serves as a monotonically increasing cursor. key of `client_queues`) serves as a monotonically increasing cursor.
### In-Memory Broker ### In-Memory Broker
The server maintains an in-memory notification broker to avoid database The server maintains an in-memory notification broker to avoid database
polling. The broker is a map of `user_id → []chan struct{}`. When a message polling. The broker is a map of `client_id → []chan struct{}`. When a message
is enqueued for a user: is enqueued for a client:
1. The handler calls `broker.Notify(userID)` 1. The handler calls `broker.Notify(clientID)`
2. The broker closes all waiting channels for that user 2. The broker closes all waiting channels for that client
3. Any goroutines blocked in `select` on those channels wake up 3. Any goroutines blocked in `select` on those channels wake up
4. The woken handler queries the database for new queue entries 4. The woken handler queries the database for new queue entries
5. Messages are returned to the client 5. Messages are returned to the client
@@ -667,6 +734,35 @@ Change the user's nickname.
**IRC reference:** RFC 1459 §4.1.2 **IRC reference:** RFC 1459 §4.1.2
#### PASS — Set Session Password
Set a password on the current session, enabling multi-client login via
`POST /api/v1/login`. The password is hashed with bcrypt and stored
server-side.
**C2S:**
```json
{"command": "PASS", "body": ["mypassword"]}
```
**Behavior:**
- `body[0]` is the password. Must be at least 8 characters.
- On success, the server responds with `{"status": "ok"}`.
- If the password is too short or missing, the server sends
ERR_NEEDMOREPARAMS (461) via the message queue.
- Calling PASS again overwrites the previous password.
- Once a password is set, `POST /api/v1/login` can be used with the nick
and password to create additional clients on the same session.
**Response:** `200 OK`
```json
{"status": "ok"}
```
**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for
session password management.
#### TOPIC — Set Channel Topic #### TOPIC — Set Channel Topic
Set or change a channel's topic. Set or change a channel's topic.
@@ -733,7 +829,7 @@ Destroy the session and disconnect from the server.
quitting user. The quitting user does **not** receive their own QUIT. quitting user. The quitting user does **not** receive their own QUIT.
- The user is removed from all channels. - The user is removed from all channels.
- Empty channels are deleted (ephemeral). - Empty channels are deleted (ephemeral).
- The user's session is destroyed — the auth token is invalidated, the nick - The user's session is destroyed — the auth cookie is invalidated, the nick
is released. is released.
- Subsequent requests with the old token return HTTP 401. - Subsequent requests with the old token return HTTP 401.
@@ -1024,8 +1120,8 @@ to compute stamps. Higher bit counts take exponentially longer to compute.
## API Reference ## API Reference
All endpoints accept and return `application/json`. Authenticated endpoints All endpoints accept and return `application/json`. Authenticated endpoints
require `Authorization: Bearer <token>` header. The token is obtained from require the `neoirc_auth` cookie, which is set automatically by
`POST /api/v1/session`. `POST /api/v1/session` and `POST /api/v1/login`.
All API responses include appropriate HTTP status codes. Error responses have All API responses include appropriate HTTP status codes. Error responses have
the format: the format:
@@ -1054,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) | | `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created` **Response:** `201 Created`
The response sets an `neoirc_auth` HttpOnly cookie containing the auth token.
The JSON body does **not** include the token.
```
Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
```
```json ```json
{ {
"id": 1, "id": 1,
"nick": "alice", "nick": "alice"
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
} }
``` ```
@@ -1066,7 +1169,16 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|---------|---------|-------------| |---------|---------|-------------|
| `id` | integer | Server-assigned user ID | | `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick (always matches request on success) | | `nick` | string | Confirmed nick (always matches request on success) |
| `token` | string | 64-character hex auth token. Store this — it's the only credential. |
**Cookie properties:**
| Property | Value |
|------------|-------|
| `Name` | `neoirc_auth` |
| `HttpOnly` | `true` (not accessible from JavaScript) |
| `SameSite` | `Strict` (prevents CSRF) |
| `Secure` | `true` when behind TLS |
| `Path` | `/` |
**Errors:** **Errors:**
@@ -1079,10 +1191,61 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**curl example:** **curl example:**
```bash ```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' \ -H 'Content-Type: application/json' \
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token) -d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}'
echo $TOKEN ```
### POST /api/v1/login — Login to Account
Authenticate with a nick and password (set via the PASS IRC command). Creates a
new client for the existing session, preserving channel memberships and message
queues. This is how multi-client access works: each login adds a new client to
the session with its own auth cookie and message delivery queue.
On successful login, the server enqueues MOTD messages and synthetic channel
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
new client's queue, so the client can immediately restore its UI state.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | Must match an active session with a password set |
| `password` | string | Yes | Must match the session's password |
**Response:** `200 OK`
The response sets an `neoirc_auth` HttpOnly cookie for the new client.
```json
{
"id": 1,
"nick": "alice"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Session ID |
| `nick` | string | Current nick |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `nick and password required` | Missing nick or password |
| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set |
**curl example:**
```bash
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}'
``` ```
### GET /api/v1/state — Get Session State ### GET /api/v1/state — Get Session State
@@ -1126,13 +1289,13 @@ Each channel object:
**curl example:** **curl example:**
```bash ```bash
curl -s http://localhost:8080/api/v1/state \ curl -s http://localhost:8080/api/v1/state \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
**Reconnect with channel state initialization:** **Reconnect with channel state initialization:**
```bash ```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \ curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
### GET /api/v1/messages — Poll Messages (Long-Poll) ### GET /api/v1/messages — Poll Messages (Long-Poll)
@@ -1192,14 +1355,12 @@ real-time endpoint — clients call it in a loop.
**curl example (immediate):** **curl example (immediate):**
```bash ```bash
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \ curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
**curl example (long-poll, 15s):** **curl example (long-poll, 15s):**
```bash ```bash
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \ curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
### POST /api/v1/messages — Send Command ### POST /api/v1/messages — Send Command
@@ -1226,6 +1387,7 @@ reference with all required and optional fields.
| `JOIN` | `to` | | 200 OK | | `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK | | `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK | | `NICK` | `body` | | 200 OK |
| `PASS` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK | | `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK | | `MODE` | `to` | | 200 OK |
| `NAMES` | `to` | | 200 OK | | `NAMES` | `to` | | 200 OK |
@@ -1240,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 are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are [Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid reserved for transport-level problems: malformed JSON (400), missing/invalid
auth tokens (401), and server errors (500). auth cookies (401), and server errors (500).
**HTTP errors (transport-level only):** **HTTP errors (transport-level only):**
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `invalid request` | Malformed JSON or empty command | | 400 | `invalid request` | Malformed JSON or empty command |
| 401 | `unauthorized` | Missing or invalid auth token | | 401 | `unauthorized` | Missing or invalid auth cookie |
| 500 | `internal error` | Server-side failure | | 500 | `internal error` | Server-side failure |
**IRC numeric error replies (delivered via message queue):** **IRC numeric error replies (delivered via message queue):**
@@ -1338,11 +1500,11 @@ events). Event messages are delivered via the live queue only.
```bash ```bash
# Latest 50 messages in #general # Latest 50 messages in #general
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \ curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
# Older messages (pagination) # Older messages (pagination)
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \ curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
### GET /api/v1/channels — List Channels ### GET /api/v1/channels — List Channels
@@ -1373,18 +1535,22 @@ List members of a channel. The `{name}` parameter is the channel name
**curl example:** **curl example:**
```bash ```bash
curl -s http://localhost:8080/api/v1/channels/general/members \ curl -s http://localhost:8080/api/v1/channels/general/members \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
### POST /api/v1/logout — Logout ### POST /api/v1/logout — Logout
Destroy the current client's auth token. If no other clients remain on the Destroy the current client's session cookie and server-side client record.
session, the user is fully cleaned up: parted from all channels (with QUIT If no other clients remain on the session, the user is fully cleaned up:
broadcast to members), session deleted, nick released. 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` **Response:** `200 OK`
The response clears the `neoirc_auth` cookie.
```json ```json
{"status": "ok"} {"status": "ok"}
``` ```
@@ -1393,12 +1559,11 @@ broadcast to members), session deleted, nick released.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth token | | 401 | `unauthorized` | Missing or invalid auth cookie |
**curl example:** **curl example:**
```bash ```bash
curl -s -X POST http://localhost:8080/api/v1/logout \ curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
-H "Authorization: Bearer $TOKEN" | jq .
``` ```
### GET /api/v1/users/me — Current User Info ### GET /api/v1/users/me — Current User Info
@@ -1422,7 +1587,7 @@ Return the current user's session state. This is an alias for
**curl example:** **curl example:**
```bash ```bash
curl -s http://localhost:8080/api/v1/users/me \ curl -s http://localhost:8080/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" | jq . -b cookies.txt | jq .
``` ```
### GET /api/v1/server — Server Info ### GET /api/v1/server — Server Info
@@ -1450,13 +1615,40 @@ Return server metadata. No authentication required.
### GET /.well-known/healthcheck.json — Health Check ### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required. Standard health check endpoint. No authentication required. Returns server
health status and runtime statistics.
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
{"status": "ok"} {
"status": "ok",
"now": "2024-01-15T12:00:00.000000000Z",
"uptimeSeconds": 3600,
"uptimeHuman": "1h0m0s",
"version": "0.1.0",
"appname": "neoirc",
"maintenanceMode": false,
"sessions": 42,
"clients": 85,
"queuedLines": 128,
"channels": 7,
"connectionsSinceBoot": 200,
"sessionsSinceBoot": 150,
"messagesSinceBoot": 5000
}
``` ```
| Field | Description |
| ---------------------- | ------------------------------------------------- |
| `sessions` | Current number of active sessions |
| `clients` | Current number of connected clients |
| `queuedLines` | Total entries in client output queues |
| `channels` | Current number of channels |
| `connectionsSinceBoot` | Total client connections since server start |
| `sessionsSinceBoot` | Total sessions created since server start |
| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start |
--- ---
## Message Flow ## Message Flow
@@ -1640,13 +1832,21 @@ authenticity.
### Authentication ### Authentication
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy). - **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of
Tokens are stored in the database and validated on every request. entropy). Tokens are hashed (SHA-256) before storage and validated on every
- **No passwords**: Session creation requires only a nick. The token is the request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict
sole credential. (CSRF protection), and Secure when behind TLS.
- **Token security**: Tokens should be treated like session cookies. Transmit - **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
only over HTTPS in production. If a token is compromised, the attacker has password, instant access. The auth cookie is the sole credential.
full access to the session until QUIT or expiry. - **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 ### Message Integrity
@@ -1683,8 +1883,10 @@ authenticity.
- **HTTPS is strongly recommended** for production deployments. The server - **HTTPS is strongly recommended** for production deployments. The server
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS
termination. termination.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`). - **CORS**: The server allows all origins with credentials
Restrict this in production via reverse proxy configuration if needed. (`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 - **Content-Security-Policy**: The server sets a strict CSP header on all
responses, restricting resource loading to same-origin and disabling responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The dangerous features (object embeds, framing, base tag injection). The
@@ -1791,33 +1993,52 @@ The database schema is managed via embedded SQL migration files in
**Current tables:** **Current tables:**
#### `users` #### `sessions`
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |-----------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `nick` | TEXT | Unique nick | | `uuid` | TEXT | Unique session UUID |
| `token` | TEXT | Unique auth token (64 hex chars) | | `nick` | TEXT | Unique nick |
| `created_at`| DATETIME | Session creation time | | `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
| `last_seen` | DATETIME | Last API request time | | `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 |
Index on `(uuid)`.
#### `clients`
| Column | Type | Description |
|--------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID |
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `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 |
Indexes on `(token)` and `(session_id)`.
#### `channels` #### `channels`
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |---------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `name` | TEXT | Unique channel name (e.g., `#general`) | | `name` | TEXT | Unique channel name (e.g., `#general`) |
| `topic` | TEXT | Channel topic (default empty) | | `topic` | TEXT | Channel topic (default empty) |
| `created_at`| DATETIME | Channel creation time | | `topic_set_by`| TEXT | Nick of the user who set the topic (default empty) |
| `updated_at`| DATETIME | Last modification time | | `topic_set_at`| DATETIME | When the topic was last set |
| `created_at` | DATETIME | Channel creation time |
| `updated_at` | DATETIME | Last modification time |
#### `channel_members` #### `channel_members`
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |--------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `channel_id`| INTEGER | FK → channels.id | | `channel_id` | INTEGER | FK → channels.id (cascade delete) |
| `user_id` | INTEGER | FK → users.id | | `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `joined_at` | DATETIME | When the user joined | | `joined_at` | DATETIME | When the user joined |
Unique constraint on `(channel_id, user_id)`. Unique constraint on `(channel_id, session_id)`.
#### `messages` #### `messages`
| Column | Type | Description | | Column | Type | Description |
@@ -1827,6 +2048,7 @@ Unique constraint on `(channel_id, user_id)`.
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | | `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
| `msg_from` | TEXT | Sender nick | | `msg_from` | TEXT | Sender nick |
| `msg_to` | TEXT | Target (`#channel` or nick) | | `msg_to` | TEXT | Target (`#channel` or nick) |
| `params` | TEXT | JSON-encoded IRC-style positional parameters |
| `body` | TEXT | JSON-encoded body (array or object) | | `body` | TEXT | JSON-encoded body (array or object) |
| `meta` | TEXT | JSON-encoded metadata | | `meta` | TEXT | JSON-encoded metadata |
| `created_at`| DATETIME | Server timestamp | | `created_at`| DATETIME | Server timestamp |
@@ -1837,11 +2059,11 @@ Indexes on `(msg_to, id)` and `(created_at)`.
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |-------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. | | `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. |
| `user_id` | INTEGER | FK → users.id | | `client_id` | INTEGER | FK → clients.id (cascade delete) |
| `message_id`| INTEGER | FK → messages.id | | `message_id`| INTEGER | FK → messages.id (cascade delete) |
| `created_at`| DATETIME | When the entry was queued | | `created_at`| DATETIME | When the entry was queued |
Unique constraint on `(user_id, message_id)`. Index on `(user_id, id)`. Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`.
The `client_queues.id` is the monotonically increasing cursor used by The `client_queues.id` is the monotonically increasing cursor used by
`GET /messages?after=<id>`. This is more reliable than timestamps (no clock `GET /messages?after=<id>`. This is more reliable than timestamps (no clock
@@ -1854,10 +2076,19 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Client output queue entries**: Pruned automatically when older than - **Client output queue entries**: Pruned automatically when older than
`QUEUE_MAX_AGE` (default 30 days). `QUEUE_MAX_AGE` (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral). - **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle - **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT`
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default or when the last client logs out (`POST /api/v1/logout` with no remaining
30 days) — the server runs a background cleanup loop that parts idle users clients triggers session cleanup). There is no distinction between session
from all channels, broadcasts QUIT, and releases their nicks. 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.
--- ---
@@ -2006,58 +2237,59 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl ### Step-by-Step with curl
```bash ```bash
# 1. Create a session # 1a. Create a session (cookie saved automatically with -c)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"nick":"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 # 2. Join a channel
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"JOIN","to":"#general"}' -d '{"command":"JOIN","to":"#general"}'
# 3. Send a message # 3. Send a message
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}' -d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
# 4. Poll for messages (one-shot) # 4. Poll for messages (one-shot)
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \ curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
-H "Authorization: Bearer $TOKEN" | jq .
# 5. Long-poll (blocks up to 15s waiting for messages) # 5. Long-poll (blocks up to 15s waiting for messages)
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \ curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
-H "Authorization: Bearer $TOKEN" | jq .
# 6. Send a DM # 6. Send a DM
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}' -d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
# 7. Change nick # 7. Change nick
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"NICK","body":["newnick"]}' -d '{"command":"NICK","body":["newnick"]}'
# 8. Set channel topic # 8. Set channel topic
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}' -d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
# 9. Leave a channel # 9. Leave a channel
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"PART","to":"#general","body":["goodbye"]}' -d '{"command":"PART","to":"#general","body":["goodbye"]}'
# 10. Disconnect # 10. Disconnect
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"command":"QUIT","body":["leaving"]}' -d '{"command":"QUIT","body":["leaving"]}'
``` ```
@@ -2067,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: The key to real-time messaging is the poll loop. Here's the pattern:
```python ```python
# Python example # Python example — using requests.Session for automatic cookie handling
import requests, json import requests, json, time
BASE = "http://localhost:8080/api/v1" BASE = "http://localhost:8080/api/v1"
token = None session = requests.Session() # Manages cookies automatically
last_id = 0 last_id = 0
# Create session # Create session (cookie set automatically via Set-Cookie header)
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"}) resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
token = resp.json()["token"] print(f"Session: {resp.json()}")
headers = {"Authorization": f"Bearer {token}"}
# Join channel # Join channel
requests.post(f"{BASE}/messages", headers=headers, session.post(f"{BASE}/messages",
json={"command": "JOIN", "to": "#general"}) json={"command": "JOIN", "to": "#general"})
# Poll loop # Poll loop
while True: while True:
try: try:
resp = requests.get(f"{BASE}/messages", resp = session.get(f"{BASE}/messages",
headers=headers,
params={"after": last_id, "timeout": 15}, params={"after": last_id, "timeout": 15},
timeout=20) # HTTP timeout > long-poll timeout timeout=20) # HTTP timeout > long-poll timeout
data = resp.json() data = resp.json()
@@ -2104,14 +2334,14 @@ while True:
``` ```
```javascript ```javascript
// JavaScript/browser example // JavaScript/browser example — cookies sent automatically
async function pollLoop(token) { async function pollLoop() {
let lastId = 0; let lastId = 0;
while (true) { while (true) {
try { try {
const resp = await fetch( const resp = await fetch(
`/api/v1/messages?after=${lastId}&timeout=15`, `/api/v1/messages?after=${lastId}&timeout=15`,
{headers: {'Authorization': `Bearer ${token}`}} {credentials: 'same-origin'} // Include cookies
); );
if (resp.status === 401) { /* session expired */ break; } if (resp.status === 401) { /* session expired */ break; }
const data = await resp.json(); const data = await resp.json();
@@ -2143,9 +2373,11 @@ Clients should handle these message commands from the queue:
### Error Handling ### 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 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. - **HTTP 400**: Malformed request. Check the `error` field in the response.
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s). - **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
@@ -2162,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 4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
(no `#` prefix), the DM tab should be keyed by the **other** user's nick: (no `#` prefix), the DM tab should be keyed by the **other** user's nick:
if `from` is you, use `to`; if `from` is someone else, use `from`. if `from` is you, use `to`; if `from` is someone else, use `from`.
5. **Reconnection**: If the poll loop fails with 401, the session is gone. 5. **Reconnection**: If the poll loop fails with 401, the auth cookie is
Create a new session. If it fails with a network error, retry with backoff. 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.
--- ---
@@ -2322,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) 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
- [ ] **Max message size enforcement** — reject oversized messages - [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) - [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [ ] **Multi-client sessions** — add client to existing session - [x] **Multi-client sessions** — set a password via PASS command, then
(share nick across devices) 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+) ### Future (1.0+)
@@ -2383,6 +2620,8 @@ neoirc/
│ │ └── healthcheck.go # Health check handler │ │ └── healthcheck.go # Health check handler
│ ├── healthcheck/ # Health check logic │ ├── healthcheck/ # Health check logic
│ │ └── healthcheck.go │ │ └── healthcheck.go
│ ├── stats/ # Runtime statistics (atomic counters)
│ │ └── stats.go
│ ├── logger/ # slog-based logging │ ├── logger/ # slog-based logging
│ │ └── logger.go │ │ └── logger.go
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth) │ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
@@ -2434,9 +2673,12 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the build a working IRC-style TUI client against this API in an afternoon, the
API is too complex. API is too complex.
2. **No accounts** — identity is a signing key, nick is a display name. No 2. **Passwords optional** — anonymous sessions are instant: pick a nick and
registration, no passwords, no email verification. Session creation is talk. No registration, no email verification. The cost of entry is a
instant. The cost of entry is a hashcash proof, not bureaucracy. 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 3. **IRC semantics over HTTP** — command names and numeric codes from
RFC 1459/2812. If you've built an IRC client or bot, you already know the RFC 1459/2812. If you've built an IRC client or bot, you already know the

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1267,6 +1267,45 @@ func (database *Database) PruneOldMessages(
return deleted, nil return deleted, nil
} }
// GetClientCount returns the total number of clients.
func (database *Database) GetClientCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM clients",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get client count: %w", err,
)
}
return count, nil
}
// GetQueueEntryCount returns the total number of entries
// in the client output queues.
func (database *Database) GetQueueEntryCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM client_queues",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get queue entry count: %w", err,
)
}
return count, nil
}
// GetChannelHashcashBits returns the hashcash difficulty // GetChannelHashcashBits returns the hashcash difficulty
// requirement for a channel. Returns 0 if not set. // requirement for a channel. Returns 0 if not set.
func (database *Database) GetChannelHashcashBits( func (database *Database) GetChannelHashcashBits(

View File

@@ -36,6 +36,7 @@ const (
defaultMaxBodySize = 4096 defaultMaxBodySize = 4096
defaultHistLimit = 50 defaultHistLimit = 50
maxHistLimit = 500 maxHistLimit = 500
authCookieName = "neoirc_auth"
) )
func (hdlr *Handlers) maxBodySize() int64 { func (hdlr *Handlers) maxBodySize() int64 {
@@ -46,23 +47,18 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize return defaultMaxBodySize
} }
// authSession extracts the session from the client token. // authSession extracts the session from the auth cookie.
func (hdlr *Handlers) authSession( func (hdlr *Handlers) authSession(
request *http.Request, request *http.Request,
) (int64, int64, string, error) { ) (int64, int64, string, error) {
auth := request.Header.Get("Authorization") cookie, err := request.Cookie(authCookieName)
if !strings.HasPrefix(auth, "Bearer ") { if err != nil || cookie.Value == "" {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
return 0, 0, "", errUnauthorized return 0, 0, "", errUnauthorized
} }
sessionID, clientID, nick, err := sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken( hdlr.params.Database.GetSessionByToken(
request.Context(), token, request.Context(), cookie.Value,
) )
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err) return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -71,6 +67,46 @@ func (hdlr *Handlers) authSession(
return sessionID, clientID, nick, nil return sessionID, clientID, nick, nil
} }
// setAuthCookie sets the authentication cookie on the
// response.
func (hdlr *Handlers) setAuthCookie(
writer http.ResponseWriter,
request *http.Request,
token string,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// clearAuthCookie removes the authentication cookie from
// the client.
func (hdlr *Handlers) clearAuthCookie(
writer http.ResponseWriter,
request *http.Request,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (hdlr *Handlers) requireAuth( func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -221,12 +257,16 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,
"token": token,
}, http.StatusCreated) }, http.StatusCreated)
} }
@@ -872,6 +912,11 @@ func (hdlr *Handlers) dispatchCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick, bodyLines,
) )
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic: case irc.CmdTopic:
hdlr.handleTopic( hdlr.handleTopic(
writer, request, writer, request,
@@ -988,6 +1033,8 @@ func (hdlr *Handlers) handlePrivmsg(
return return
} }
hdlr.stats.IncrMessages()
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg( hdlr.handleChannelMsg(
writer, request, writer, request,
@@ -1813,6 +1860,32 @@ func (hdlr *Handlers) handleTopic(
return return
} }
isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
"check membership failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick, []string{channel},
"You're not on that channel",
)
return
}
hdlr.executeTopic( hdlr.executeTopic(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
@@ -1974,6 +2047,8 @@ func (hdlr *Handlers) handleQuit(
request.Context(), sessionID, request.Context(), sessionID,
) )
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"}, map[string]string{"status": "quit"},
http.StatusOK) http.StatusOK)
@@ -2776,6 +2851,8 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
) )
} }
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)

View File

@@ -27,20 +27,22 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server" "git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
) )
const ( const (
commandKey = "command" commandKey = "command"
bodyKey = "body" bodyKey = "body"
toKey = "to" toKey = "to"
statusKey = "status" statusKey = "status"
privmsgCmd = "PRIVMSG" privmsgCmd = "PRIVMSG"
joinCmd = "JOIN" joinCmd = "JOIN"
apiMessages = "/api/v1/messages" apiMessages = "/api/v1/messages"
apiSession = "/api/v1/session" apiSession = "/api/v1/session"
apiState = "/api/v1/state" apiState = "/api/v1/state"
authCookieName = "neoirc_auth"
) )
// testServer wraps a test HTTP server with helpers. // testServer wraps a test HTTP server with helpers.
@@ -91,6 +93,7 @@ func newTestServer(
return cfg, nil return cfg, nil
}, },
newTestDB, newTestDB,
stats.New,
newTestHealthcheck, newTestHealthcheck,
newTestMiddleware, newTestMiddleware,
newTestHandlers, newTestHandlers,
@@ -145,12 +148,14 @@ func newTestHealthcheck(
cfg *config.Config, cfg *config.Config,
log *logger.Logger, log *logger.Logger,
database *db.Database, database *db.Database,
tracker *stats.Tracker,
) (*healthcheck.Healthcheck, error) { ) (*healthcheck.Healthcheck, error) {
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
Globals: globs, Globals: globs,
Config: cfg, Config: cfg,
Logger: log, Logger: log,
Database: database, Database: database,
Stats: tracker,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("test healthcheck: %w", err) return nil, fmt.Errorf("test healthcheck: %w", err)
@@ -184,6 +189,7 @@ func newTestHandlers(
cfg *config.Config, cfg *config.Config,
database *db.Database, database *db.Database,
hcheck *healthcheck.Healthcheck, hcheck *healthcheck.Healthcheck,
tracker *stats.Tracker,
) (*handlers.Handlers, error) { ) (*handlers.Handlers, error) {
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
Logger: log, Logger: log,
@@ -191,6 +197,7 @@ func newTestHandlers(
Config: cfg, Config: cfg,
Database: database, Database: database,
Healthcheck: hcheck, Healthcheck: hcheck,
Stats: tracker,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("test handlers: %w", err) return nil, fmt.Errorf("test handlers: %w", err)
@@ -255,7 +262,7 @@ func doRequest(
func doRequestAuth( func doRequestAuth(
t *testing.T, t *testing.T,
method, url, token string, method, url, cookie string,
body io.Reader, body io.Reader,
) (*http.Response, error) { ) (*http.Response, error) {
t.Helper() t.Helper()
@@ -273,10 +280,11 @@ func doRequestAuth(
) )
} }
if token != "" { if cookie != "" {
request.Header.Set( request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
"Authorization", "Bearer "+token, Name: authCookieName,
) Value: cookie,
})
} }
resp, err := http.DefaultClient.Do(request) resp, err := http.DefaultClient.Do(request)
@@ -319,17 +327,19 @@ func (tserver *testServer) createSession(
) )
} }
var result struct { // Drain the body.
ID int64 `json:"id"` _, _ = io.ReadAll(resp.Body)
Token string `json:"token"`
// Extract auth cookie from response.
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
return cookie.Value
}
} }
decErr := json.NewDecoder(resp.Body).Decode(&result) tserver.t.Fatal("no auth cookie in response")
if decErr != nil {
tserver.t.Fatalf("decode session: %v", decErr)
}
return result.Token return ""
} }
func (tserver *testServer) sendCommand( func (tserver *testServer) sendCommand(
@@ -486,10 +496,10 @@ func findNumeric(
func TestCreateSessionValid(t *testing.T) { func TestCreateSessionValid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("alice") cookie := tserver.createSession("alice")
if token == "" { if cookie == "" {
t.Fatal("expected token") t.Fatal("expected auth cookie")
} }
} }
@@ -611,7 +621,7 @@ func TestCreateSessionMalformed(t *testing.T) {
} }
} }
func TestAuthNoHeader(t *testing.T) { func TestAuthNoCookie(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
status, _ := tserver.getState("") status, _ := tserver.getState("")
@@ -620,11 +630,11 @@ func TestAuthNoHeader(t *testing.T) {
} }
} }
func TestAuthBadToken(t *testing.T) { func TestAuthBadCookie(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
status, _ := tserver.getState( status, _ := tserver.getState(
"invalid-token-12345", "invalid-cookie-12345",
) )
if status != http.StatusUnauthorized { if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status) t.Fatalf("expected 401, got %d", status)
@@ -1135,6 +1145,42 @@ func TestTopicMissingBody(t *testing.T) {
} }
} }
func TestTopicNonMember(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_topic")
bobToken := tserver.createSession("bob_topic")
// Only alice joins the channel.
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#topicpriv",
})
// Drain bob's initial messages.
_, lastID := tserver.pollMessages(bobToken, 0)
// Bob tries to set topic without joining.
status, _ := tserver.sendCommand(
bobToken,
map[string]any{
commandKey: "TOPIC",
toKey: "#topicpriv",
bodyKey: []string{"Hijacked topic"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(bobToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
}
}
func TestPing(t *testing.T) { func TestPing(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("ping_user") token := tserver.createSession("ping_user")
@@ -1658,21 +1704,14 @@ func TestHealthcheck(t *testing.T) {
} }
} }
func TestRegisterValid(t *testing.T) { func TestHealthcheckRuntimeStatsFields(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "reguser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest( resp, err := doRequest(
t, t,
http.MethodPost, http.MethodGet,
tserver.url("/api/v1/register"), tserver.url("/.well-known/healthcheck.json"),
bytes.NewReader(body), nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1680,65 +1719,115 @@ func TestRegisterValid(t *testing.T) {
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf( t.Fatalf(
"expected 201, got %d: %s", "expected 200, got %d", resp.StatusCode,
resp.StatusCode, respBody,
) )
} }
var result map[string]any var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result) decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
if result["token"] == nil || result["token"] == "" { t.Fatalf("decode healthcheck: %v", decErr)
t.Fatal("expected token in response")
} }
if result["nick"] != "reguser" { requiredFields := []string{
t.Fatalf( "sessions", "clients", "queuedLines",
"expected reguser, got %v", result["nick"], "channels", "connectionsSinceBoot",
) "sessionsSinceBoot", "messagesSinceBoot",
}
for _, field := range requiredFields {
if _, ok := result[field]; !ok {
t.Errorf(
"missing field %q in healthcheck", field,
)
}
} }
} }
func TestRegisterDuplicate(t *testing.T) { func TestHealthcheckRuntimeStatsValues(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{ token := tserver.createSession("statsuser")
"nick": "dupuser", "password": "password123",
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#statschan",
}) })
if err != nil { tserver.sendCommand(token, map[string]any{
t.Fatal(err) commandKey: privmsgCmd,
} toKey: "#statschan",
bodyKey: []string{"hello stats"},
})
result := tserver.fetchHealthcheck(t)
assertFieldGTE(t, result, "sessions", 1)
assertFieldGTE(t, result, "clients", 1)
assertFieldGTE(t, result, "channels", 1)
assertFieldGTE(t, result, "queuedLines", 0)
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
assertFieldGTE(t, result, "messagesSinceBoot", 1)
}
func (tserver *testServer) fetchHealthcheck(
t *testing.T,
) map[string]any {
t.Helper()
resp, err := doRequest( resp, err := doRequest(
t, t,
http.MethodPost, http.MethodGet,
tserver.url("/api/v1/register"), tserver.url("/.well-known/healthcheck.json"),
bytes.NewReader(body), nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_ = resp.Body.Close() defer func() { _ = resp.Body.Close() }()
resp2, err := doRequest( if resp.StatusCode != http.StatusOK {
t, t.Fatalf(
http.MethodPost, "expected 200, got %d", resp.StatusCode,
tserver.url("/api/v1/register"), )
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
} }
defer func() { _ = resp2.Body.Close() }() var result map[string]any
if resp2.StatusCode != http.StatusConflict { decErr := json.NewDecoder(resp.Body).Decode(&result)
t.Fatalf("expected 409, got %d", resp2.StatusCode) if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
return result
}
func assertFieldGTE(
t *testing.T,
result map[string]any,
field string,
minimum float64,
) {
t.Helper()
val, ok := result[field].(float64)
if !ok {
t.Errorf(
"field %q: not a number (got %T)",
field, result[field],
)
return
}
if val < minimum {
t.Errorf(
"expected %s >= %v, got %v",
field, minimum, val,
)
} }
} }
@@ -1776,36 +1865,102 @@ func postJSONExpectStatus(
} }
} }
func TestRegisterShortPassword(t *testing.T) { func TestPassCommand(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("passuser")
postJSONExpectStatus( // Drain initial messages.
t, tserver, "/api/v1/register", _, _ = tserver.pollMessages(token, 0)
map[string]string{
"nick": "shortpw", "password": "short", // Set password via PASS command.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "PASS",
bodyKey: []string{"s3cure_pass"},
}, },
http.StatusBadRequest,
) )
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result[statusKey] != "ok" {
t.Fatalf(
"expected ok, got %v", result[statusKey],
)
}
} }
func TestRegisterInvalidNick(t *testing.T) { func TestPassCommandShortPassword(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("shortpw")
postJSONExpectStatus( // Drain initial messages.
t, tserver, "/api/v1/register", _, lastID := tserver.pollMessages(token, 0)
map[string]string{
"nick": "bad nick!", // Try short password — should fail.
"password": "password123", status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: "PASS",
bodyKey: []string{"short"},
}, },
http.StatusBadRequest,
) )
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestPassCommandEmpty(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("emptypw")
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try empty password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{commandKey: "PASS"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
} }
func TestLoginValid(t *testing.T) { func TestLoginValid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
// Register first. // Create session and set password via PASS command.
regBody, err := json.Marshal(map[string]string{ token := tserver.createSession("loginuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"password123"},
})
// Login with nick + password.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123", "nick": "loginuser", "password": "password123",
}) })
if err != nil { if err != nil {
@@ -1813,26 +1968,6 @@ func TestLoginValid(t *testing.T) {
} }
resp, err := doRequest( resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
// Login.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t, t,
http.MethodPost, http.MethodPost,
tserver.url("/api/v1/login"), tserver.url("/api/v1/login"),
@@ -1842,31 +1977,33 @@ func TestLoginValid(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer func() { _ = resp2.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp2.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body) respBody, _ := io.ReadAll(resp.Body)
t.Fatalf( t.Fatalf(
"expected 200, got %d: %s", "expected 200, got %d: %s",
resp2.StatusCode, respBody, resp.StatusCode, respBody,
) )
} }
var result map[string]any // Extract auth cookie from login response.
var loginCookie string
_ = json.NewDecoder(resp2.Body).Decode(&result) for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
loginCookie = cookie.Value
if result["token"] == nil || result["token"] == "" { break
t.Fatal("expected token in response") }
} }
// Verify token works. if loginCookie == "" {
token, ok := result["token"].(string) t.Fatal("expected auth cookie from login")
if !ok {
t.Fatal("token not a string")
} }
status, state := tserver.getState(token) // Verify login cookie works for auth.
status, state := tserver.getState(loginCookie)
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status) t.Fatalf("expected 200, got %d", status)
} }
@@ -1882,49 +2019,22 @@ func TestLoginValid(t *testing.T) {
func TestLoginWrongPassword(t *testing.T) { func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
regBody, err := json.Marshal(map[string]string{ // Create session and set password via PASS command.
"nick": "wrongpwuser", "password": "correctpass1", token := tserver.createSession("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"correctpass1"},
}) })
if err != nil {
t.Fatal(err)
}
resp, err := doRequest( postJSONExpectStatus(
t, t, tserver, "/api/v1/login",
http.MethodPost, map[string]string{
tserver.url("/api/v1/register"), "nick": "wrongpwuser",
bytes.NewReader(regBody), "password": "wrongpass12",
},
http.StatusUnauthorized,
) )
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
loginBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "wrongpass12",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf(
"expected 401, got %d", resp2.StatusCode,
)
}
} }
func TestLoginNonexistentUser(t *testing.T) { func TestLoginNonexistentUser(t *testing.T) {
@@ -1940,13 +2050,74 @@ func TestLoginNonexistentUser(t *testing.T) {
) )
} }
func TestSessionCookie(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(
map[string]string{"nick": "cookietest"},
)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
t.Fatalf(
"expected 201, got %d", resp.StatusCode,
)
}
// Verify Set-Cookie header.
var authCookie *http.Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
authCookie = cookie
break
}
}
if authCookie == nil {
t.Fatal("expected neoirc_auth cookie")
}
if !authCookie.HttpOnly {
t.Fatal("cookie should be HttpOnly")
}
if authCookie.SameSite != http.SameSiteStrictMode {
t.Fatal("cookie should be SameSite=Strict")
}
// Verify JSON body does NOT contain token.
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if _, hasToken := result["token"]; hasToken {
t.Fatal("JSON body should not contain token")
}
}
func TestSessionStillWorks(t *testing.T) { func TestSessionStillWorks(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
// Verify anonymous session creation still works. // Verify anonymous session creation still works.
token := tserver.createSession("anon_user") token := tserver.createSession("anon_user")
if token == "" { if token == "" {
t.Fatal("expected token for anonymous session") t.Fatal("expected cookie for anonymous session")
} }
status, state := tserver.getState(token) status, state := tserver.getState(token)

View File

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

View File

@@ -16,6 +16,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/hashcash" "git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -30,6 +31,7 @@ type Params struct {
Config *config.Config Config *config.Config
Database *db.Database Database *db.Database
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
} }
const defaultIdleTimeout = 30 * 24 * time.Hour const defaultIdleTimeout = 30 * 24 * time.Hour
@@ -47,6 +49,7 @@ type Handlers struct {
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker
cancelCleanup context.CancelFunc cancelCleanup context.CancelFunc
} }
@@ -67,6 +70,7 @@ func New(
broker: broker.New(), broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(), channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{

View File

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

View File

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

View File

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

View File

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

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" CmdNames = "NAMES"
CmdNick = "NICK" CmdNick = "NICK"
CmdNotice = "NOTICE" CmdNotice = "NOTICE"
CmdPass = "PASS"
CmdPart = "PART" CmdPart = "PART"
CmdPing = "PING" CmdPing = "PING"
CmdPong = "PONG" CmdPong = "PONG"