3 Commits

Author SHA1 Message Date
clawbot
8854b17ebc refactor: move CLI code from cmd/ to internal/cli
All checks were successful
check / check (push) Successful in 4s
Move all non-bootstrapping CLI code to internal/cli package.
cmd/neoirc-cli/main.go now contains only minimal bootstrapping
that calls cli.Run(). The App struct, UI, command handlers, poll
loop, and api client are now in internal/cli/ and internal/cli/api/.
2026-03-10 03:28:52 -07:00
clawbot
6fa46f4b75 fix: move hashcash PoW from build artifact to JSX source
The hashcash proof-of-work implementation was incorrectly added to the
build artifact web/dist/app.js instead of the source file web/src/app.jsx.
Running web/build.sh would overwrite all hashcash changes.

Changes:
- Add checkLeadingZeros() and mintHashcash() functions to app.jsx
- Integrate hashcash into LoginScreen: fetch hashcash_bits from /server,
  compute stamp via Web Crypto API before session creation, show
  'Computing proof-of-work...' feedback
- Remove web/dist/ from git tracking (build artifacts)
- Add web/dist/ to .gitignore
2026-03-10 03:27:16 -07:00
clawbot
a89393186f feat: implement hashcash proof-of-work for session creation
Add SHA-256-based hashcash proof-of-work requirement to POST /session
to prevent abuse via rapid session creation. The server advertises the
required difficulty via GET /server (hashcash_bits field), and clients
must include a valid stamp in the X-Hashcash request header.

Server-side:
- New internal/hashcash package with stamp validation (format, bits,
  date, resource, replay prevention via in-memory spent set)
- Config: NEOIRC_HASHCASH_BITS env var (default 20, set 0 to disable)
- GET /server includes hashcash_bits when > 0
- POST /session validates X-Hashcash header when enabled
- Returns HTTP 402 for missing/invalid stamps

Client-side:
- SPA: fetches hashcash_bits from /server, computes stamp using Web
  Crypto API with batched SHA-256, shows 'Computing proof-of-work...'
  feedback during computation
- CLI: api package gains MintHashcash() function, CreateSession()
  auto-fetches server info and computes stamp when required

Stamp format: 1:bits:YYMMDD:resource::counter (standard hashcash)

closes #11
2026-03-10 03:27:16 -07:00
33 changed files with 387 additions and 3597 deletions

View File

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

434
README.md
View File

@@ -113,9 +113,8 @@ mechanisms or stuffing data into CTCP.
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`. `KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
are unique per server. Identity starts with a key a nick is a display name. are unique per server. There are no accounts — identity is a key, a nick is a
Accounts are optional: you can create an anonymous session instantly, or display name.
register with a password for multi-client access to a single session.
### On the resemblance to JSON-RPC ### On the resemblance to JSON-RPC
@@ -149,45 +148,16 @@ 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 — Dual Authentication Model ### Identity & Sessions — No Accounts
The server supports two authentication paths: **anonymous sessions** for There are no accounts, no registration, no passwords. Identity is a signing
instant access, and **optional account registration** for multi-client access. key; a nick is just a display name. The two are decoupled.
#### Anonymous Sessions (No Account Required)
The simplest entry point. No registration, no passwords.
- **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 assigns an **auth token** (64 hex characters of
cryptographically random bytes) and returns the user ID, nick, and token. cryptographically random bytes) and returns the user ID, nick, and token.
- The auth token implicitly identifies the client. Clients present it via - The auth token implicitly identifies the client. Clients present it via
`Authorization: Bearer <token>`. `Authorization: Bearer <token>`.
- Anonymous sessions are ephemeral — when the session expires or the user
QUITs, the nick is released and there is no way to reclaim it.
#### Registered Accounts (Optional)
For users who want multi-client access (multiple devices sharing one session):
- **Registration**: client sends `POST /api/v1/register` with a nick and
password (minimum 8 characters) → server creates a session with the
password hashed via bcrypt, and returns the user ID, nick, and auth token.
- **Login**: client sends `POST /api/v1/login` with nick and password →
server verifies the password against the stored bcrypt hash and creates a
new client token for the existing session. This enables multi-client
access: logging in from a new device adds a client to the existing session
rather than creating a new one, so channel memberships and message queues
are shared. Note: 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 registration is lost.
- Registered accounts cannot be logged into via `POST /api/v1/session`
that endpoint is for anonymous sessions only.
- Anonymous sessions (created via `/session`) cannot be logged into via
`/login` because they have no password set.
#### Common Properties (Both Paths)
- 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.
@@ -195,17 +165,11 @@ For users who want multi-client access (multiple devices sharing one session):
in the token, no client-side decode. The server is the sole authority on in the token, no client-side decode. The server is the sole authority on
token validity. token validity.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. **Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding
Anonymous sessions preserve that simplicity — instant access, zero friction. registration, email verification, or OAuth would solve a problem nobody asked
But some users want to access the same session from multiple devices without about and add complexity that drives away casual users. Identity verification
a bouncer. Optional registration with password enables multi-client login is handled at the message layer via cryptographic signatures (see
without adding friction for casual users: if you don't want an account, [Security Model](#security-model)), not at the session layer.
don't create one. Note: in the current implementation, both anonymous and
registered sessions are deleted when the last client disconnects (QUIT or
logout); registration does not make a session survive all-client
removal. Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent
of account registration.
### Nick Semantics ### Nick Semantics
@@ -243,12 +207,12 @@ User Session
└── Client C (token_c, queue_c) └── Client C (token_c, queue_c)
``` ```
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new **Current MVP note:** The current implementation creates a new user (with new
client to an existing registered session, enabling true multi-client support nick) per `POST /api/v1/session` call. True multi-client (multiple tokens
(multiple tokens sharing one nick/session with independent message queues). sharing one nick/session) is supported by the schema (`client_queues` is keyed
Anonymous sessions created via `POST /api/v1/session` always create a new by user_id, and multiple tokens can point to the same user) but the session
user with a new nick. A future endpoint to "add a client to an existing creation endpoint does not yet support "add a client to an existing session."
anonymous session" is planned but not yet implemented. 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
@@ -285,8 +249,8 @@ Key properties:
- **Ordered**: Queue entries have monotonically increasing IDs. Messages are - **Ordered**: Queue entries have monotonically increasing IDs. Messages are
always delivered in order within a client's queue. always delivered in order within a client's queue.
- **No delivery/read receipts** for channel messages. DM receipts are planned. - **No delivery/read receipts** for channel messages. DM receipts are planned.
- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`. - **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48
Default is 30 days. Entries older than this are pruned. hours. Entries older than this are pruned.
### Long-Polling ### Long-Polling
@@ -363,8 +327,8 @@ 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 tokens are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash - Server generates 32 random bytes → hex-encodes → stores hash
- Client presents the raw token; server hashes and looks it up - Client presents the token; server looks it up
- Revocation is a database delete - Revocation is a database delete
- 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 - Token format can change without breaking clients
@@ -391,8 +355,6 @@ The entire read/write loop for a client is two endpoints. Everything else
### Session Lifecycle ### Session Lifecycle
#### Anonymous Session
``` ```
┌─ Client ──────────────────────────────────────────────────┐ ┌─ Client ──────────────────────────────────────────────────┐
│ │ │ │
@@ -423,30 +385,6 @@ The entire read/write loop for a client is two endpoints. Everything else
└────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────┘
``` ```
#### Registered Account
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/register │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
│ (Session created with bcrypt-hashed password) │
│ │
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
│ │
│ (From another device, while session is still active) │
│ │
│ 2. POST /api/v1/login │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │
│ (New client added to existing session — channels │
│ and message queues are preserved. If all clients │
│ have logged out, session no longer exists.) │
│ │
└────────────────────────────────────────────────────────────┘
```
### Queue Architecture ### Queue Architecture
``` ```
@@ -523,7 +461,7 @@ the same JSON envelope:
| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | | `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). |
| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | | `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** |
| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. | | `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. |
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | | `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. |
**Important invariants:** **Important invariants:**
@@ -1012,14 +950,13 @@ carries IRC-style parameters (e.g., channel name, target nick).
Inspired by IRC, simplified: Inspired by IRC, simplified:
| Mode | Name | Meaning | | Mode | Name | Meaning |
|------|----------------|---------| |------|--------------|---------|
| `+i` | Invite-only | Only invited users can join | | `+i` | Invite-only | Only invited users can join |
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | | `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
| `+s` | Secret | Channel hidden from LIST response | | `+s` | Secret | Channel hidden from LIST response |
| `+t` | Topic lock | Only operators can change the topic | | `+t` | Topic lock | Only operators can change the topic |
| `+n` | No external | Only channel members can send messages to the channel | | `+n` | No external | Only channel members can send messages to the channel |
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
**User channel modes (set per-user per-channel):** **User channel modes (set per-user per-channel):**
@@ -1030,56 +967,6 @@ Inspired by IRC, simplified:
**Status:** Channel modes are defined but not yet enforced. The `modes` column **Status:** Channel modes are defined but not yet enforced. The `modes` column
exists in the channels table but the server does not check modes on actions. exists in the channels table but the server does not check modes on actions.
Exception: `+H` (hashcash) is fully enforced — see below.
### Per-Channel Hashcash (Anti-Spam)
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
anti-spam mechanism: channel operators set a difficulty level, and clients must
compute a proof-of-work stamp bound to the specific channel and message before
sending.
**Setting the requirement:**
```
MODE #channel +H <bits> — require <bits> leading zero bits (1-40)
MODE #channel -H — disable hashcash requirement
```
**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter`
- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp)
- `YYMMDD` — current date (prevents old token reuse)
- `channel` — channel name (prevents cross-channel reuse)
- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message)
- `counter` — hex nonce
**Sending a message to a hashcash-protected channel:**
Include the hashcash stamp in the `meta` field:
```json
{
"command": "PRIVMSG",
"to": "#general",
"body": ["hello world"],
"meta": {
"hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a"
}
}
```
**Server validation:** The server checks that the stamp is well-formed, meets
the required difficulty, is bound to the correct channel and message body, has a
recent date, and has not been previously used. Spent stamps are cached for 1
year to prevent replay attacks.
**Error responses:** If the channel requires hashcash and the stamp is missing,
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
descriptive reason.
**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)`
to compute stamps. Higher bit counts take exponentially longer to compute.
--- ---
@@ -1102,18 +989,23 @@ Create a new user session. This is the entry point for all clients.
If the server requires hashcash proof-of-work (see If the server requires hashcash proof-of-work (see
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a [Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
valid stamp in the `pow_token` field of the JSON request body. The required valid stamp in the `X-Hashcash` request header. The required difficulty is
difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**Request Headers:**
| Header | Required | Description |
|--------------|----------|-------------|
| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Request Body:** **Request Body:**
```json ```json
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"} {"nick": "alice"}
``` ```
| Field | Type | Required | Constraints | | Field | Type | Required | Constraints |
|------------|--------|-------------|-------------| |--------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server | | `nick` | string | Yes | 132 characters, must be unique on the server |
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created` **Response:** `201 Created`
```json ```json
@@ -1135,7 +1027,7 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick | | 400 | `nick must be 1-32 characters` | Empty or too-long nick |
| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled | | 402 | `hashcash proof-of-work required` | Missing `X-Hashcash` header when hashcash is enabled |
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | | 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
| 409 | `nick already taken` | Another active session holds this nick | | 409 | `nick already taken` | Another active session holds this nick |
@@ -1143,106 +1035,8 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
```bash ```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ TOKEN=$(curl -s -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) -H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \
echo $TOKEN -d '{"nick":"alice"}' | jq -r .token)
```
### POST /api/v1/register — Register Account
Create a new user session with a password. The password is hashed
with bcrypt and stored server-side. The password enables login from
additional clients via `POST /api/v1/login` while the session
remains active.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `password` | string | Yes | Minimum 8 characters |
**Response:** `201 Created`
```json
{
"id": 1,
"nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick |
| `token` | string | 64-character hex auth token |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid nick format` | Nick doesn't match allowed format |
| 400 | `password must be at least 8 characters` | Password too short |
| 409 | `nick already taken` | Another active session holds this nick |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
```
### POST /api/v1/login — Login to Account
Authenticate with a previously registered nick and password. Creates a new
client token for the existing session, preserving channel memberships and
message queues. This is how multi-client access works for registered accounts:
each login adds a new client to the session.
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 a registered account |
| `password` | string | Yes | Must match the account's password |
**Response:** `200 OK`
```json
{
"id": 1,
"nick": "alice",
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Session ID (same as when registered) |
| `nick` | string | Current nick |
| `token` | string | New 64-character hex auth token for this client |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `nick and password required` | Missing nick or password |
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN echo $TOKEN
``` ```
@@ -1611,40 +1405,13 @@ Return server metadata. No authentication required.
### GET /.well-known/healthcheck.json — Health Check ### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required. Returns server Standard health check endpoint. No authentication required.
health status and runtime statistics.
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
{ {"status": "ok"}
"status": "ok",
"now": "2024-01-15T12:00:00.000000000Z",
"uptimeSeconds": 3600,
"uptimeHuman": "1h0m0s",
"version": "0.1.0",
"appname": "neoirc",
"maintenanceMode": false,
"sessions": 42,
"clients": 85,
"queuedLines": 128,
"channels": 7,
"connectionsSinceBoot": 200,
"sessionsSinceBoot": 150,
"messagesSinceBoot": 5000
}
``` ```
| Field | Description |
| ---------------------- | ------------------------------------------------- |
| `sessions` | Current number of active sessions |
| `clients` | Current number of connected clients |
| `queuedLines` | Total entries in client output queues |
| `channels` | Current number of channels |
| `connectionsSinceBoot` | Total client connections since server start |
| `sessionsSinceBoot` | Total sessions created since server start |
| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start |
--- ---
## Message Flow ## Message Flow
@@ -1829,16 +1596,9 @@ authenticity.
### Authentication ### Authentication
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy). - **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
Tokens are hashed (SHA-256) before storage and validated on every request. Tokens are stored in the database and validated on every request.
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No - **No passwords**: Session creation requires only a nick. The token is the
password, instant access. The token is the sole credential. sole credential.
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password
(minimum 8 characters). The password is hashed with bcrypt at the default
cost factor and stored alongside the session. `POST /api/v1/login`
authenticates against the stored hash and issues a new client token.
- **Password security**: Passwords are never stored in plain text. bcrypt
handles salting and key stretching automatically. Anonymous sessions have
an empty `password_hash` and cannot be logged into via `/login`.
- **Token security**: Tokens should be treated like session cookies. Transmit - **Token security**: Tokens should be treated like session cookies. Transmit
only over HTTPS in production. If a token is compromised, the attacker has only over HTTPS in production. If a token is compromised, the attacker has
full access to the session until QUIT or expiry. full access to the session until QUIT or expiry.
@@ -1986,26 +1746,13 @@ The database schema is managed via embedded SQL migration files in
**Current tables:** **Current tables:**
#### `sessions` #### `users`
| Column | Type | Description |
|----------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick |
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key (empty string if unset) |
| `away_message` | TEXT | Away message (empty string if not away) |
| `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time |
#### `clients`
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |-------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID | | `nick` | TEXT | Unique nick |
| `session_id`| INTEGER | FK → sessions.id (cascade delete) | | `token` | TEXT | Unique auth token (64 hex chars) |
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) | | `created_at`| DATETIME | Session creation time |
| `created_at`| DATETIME | Client creation time |
| `last_seen` | DATETIME | Last API request time | | `last_seen` | DATETIME | Last API request time |
#### `channels` #### `channels`
@@ -2057,24 +1804,15 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
### Data Lifecycle ### Data Lifecycle
- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` - **Messages**: Stored indefinitely in the current implementation. Rotation
(default 30 days). per `MAX_HISTORY` is planned.
- **Client output queue entries**: Pruned automatically when older than - **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
`QUEUE_MAX_AGE` (default 30 days). planned.
- **Channels**: Deleted when the last member leaves (ephemeral). - **Channels**: Deleted when the last member leaves (ephemeral).
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT` - **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
or when the last client logs out (`POST /api/v1/logout` with no remaining sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
clients triggers session cleanup). There is no distinction between session 24h) — the server runs a background cleanup loop that parts idle users
types in the cleanup path — `handleQuit` and `cleanupUser` both call from all channels, broadcasts QUIT, and releases their nicks.
`DeleteSession` unconditionally. Idle sessions are automatically expired
after `SESSION_IDLE_TIMEOUT`
(default 30 days) — the server runs a background cleanup loop that parts
idle users from all channels, broadcasts QUIT, and releases their nicks.
- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`.
A session can have multiple clients; removing one doesn't affect others.
However, when the last client is removed (via logout), the entire session
is deleted — the user is parted from all channels, QUIT is broadcast, and
the nick is released.
--- ---
@@ -2090,9 +1828,9 @@ directory is also loaded automatically via
| `PORT` | int | `8080` | HTTP listen port | | `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | | `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | | `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | | `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. Default is 30 days. | | `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | | `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). |
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | | `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) |
| `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | | `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) |
| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | | `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` |
@@ -2112,7 +1850,7 @@ SERVER_NAME=My NeoIRC Server
MOTD=Welcome! Be excellent to each other. MOTD=Welcome! Be excellent to each other.
DEBUG=false DEBUG=false
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
SESSION_IDLE_TIMEOUT=720h SESSION_IDLE_TIMEOUT=24h
NEOIRC_HASHCASH_BITS=20 NEOIRC_HASHCASH_BITS=20
``` ```
@@ -2223,21 +1961,11 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl ### Step-by-Step with curl
```bash ```bash
# 1a. Create an anonymous session (no account) # 1. Create a session
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"nick":"testuser"}' | jq -r .token) -d '{"nick":"testuser"}' | jq -r .token)
# 1b. Or register an account (multi-client support)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
# 1c. Or login to an existing account
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
# 2. Join a channel # 2. Join a channel
curl -s -X POST http://localhost:8080/api/v1/messages \ curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
@@ -2370,11 +2098,9 @@ Clients should handle these message commands from the queue:
### Error Handling ### Error Handling
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or - **HTTP 401**: Token expired or invalid. Re-create session.
re-login (registered account).
- **HTTP 404**: Channel or user not found. - **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation, registration, or - **HTTP 409**: Nick already taken (on session creation or NICK change).
NICK change).
- **HTTP 400**: Malformed request. Check the `error` field in the response. - **HTTP 400**: Malformed request. Check the `error` field in the response.
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s). - **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
@@ -2391,10 +2117,8 @@ Clients should handle these message commands from the queue:
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel 4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
(no `#` prefix), the DM tab should be keyed by the **other** user's nick: (no `#` prefix), the DM tab should be keyed by the **other** user's nick:
if `from` is you, use `to`; if `from` is someone else, use `from`. if `from` is you, use `to`; if `from` is someone else, use `from`.
5. **Reconnection**: If the poll loop fails with 401, the token is invalid. 5. **Reconnection**: If the poll loop fails with 401, the session is gone.
For anonymous sessions, create a new session. For registered accounts, Create a new session. If it fails with a network error, retry with backoff.
log in again via `POST /api/v1/login` to get a fresh token on the same
session. If it fails with a network error, retry with backoff.
--- ---
@@ -2414,7 +2138,7 @@ account registration, no IP-based rate limits that punish shared networks.
2. Client computes a hashcash stamp: find a counter value such that the 2. Client computes a hashcash stamp: find a counter value such that the
SHA-256 hash of the stamp string has the required number of leading zero SHA-256 hash of the stamp string has the required number of leading zero
bits. bits.
3. Client includes the stamp in the `pow_token` field of the JSON request body when creating 3. Client includes the stamp in the `X-Hashcash` request header when creating
a session: `POST /api/v1/session`. a session: `POST /api/v1/session`.
4. Server validates the stamp: 4. Server validates the stamp:
- Version is `1` - Version is `1`
@@ -2471,7 +2195,7 @@ Both the embedded web SPA and the CLI client automatically handle hashcash:
1. Fetch `GET /api/v1/server` to read `hashcash_bits` 1. Fetch `GET /api/v1/server` to read `hashcash_bits`
2. If `hashcash_bits > 0`, compute a valid stamp 2. If `hashcash_bits > 0`, compute a valid stamp
3. Include the stamp in the `pow_token` field of the JSON body on `POST /api/v1/session` 3. Include the stamp in the `X-Hashcash` header on `POST /api/v1/session`
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256 The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
@@ -2535,8 +2259,8 @@ creating one session pays once and keeps their session.
### Post-MVP (Planned) ### Post-MVP (Planned)
- [x] **Hashcash proof-of-work** for session creation (abuse prevention) - [x] **Hashcash proof-of-work** for session creation (abuse prevention)
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE` - [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` - [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
- [ ] **User channel modes** — `+o` (operator), `+v` (voice) - [ ] **User channel modes** — `+o` (operator), `+v` (voice)
- [x] **MODE command** — query channel and user modes (set not yet implemented) - [x] **MODE command** — query channel and user modes (set not yet implemented)
@@ -2614,8 +2338,6 @@ neoirc/
│ │ └── healthcheck.go # Health check handler │ │ └── healthcheck.go # Health check handler
│ ├── healthcheck/ # Health check logic │ ├── healthcheck/ # Health check logic
│ │ └── healthcheck.go │ │ └── healthcheck.go
│ ├── stats/ # Runtime statistics (atomic counters)
│ │ └── stats.go
│ ├── logger/ # slog-based logging │ ├── logger/ # slog-based logging
│ │ └── logger.go │ │ └── logger.go
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth) │ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
@@ -2667,13 +2389,9 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the build a working IRC-style TUI client against this API in an afternoon, the
API is too complex. API is too complex.
2. **Accounts optional** — anonymous sessions are instant: pick a nick and 2. **No accounts** — identity is a signing key, nick is a display name. No
talk. No registration, no email verification. The cost of entry is a registration, no passwords, no email verification. Session creation is
hashcash proof, not bureaucracy. For users who want multi-client access instant. The cost of entry is a hashcash proof, not bureaucracy.
(multiple devices sharing one session), optional account registration
with password is available — but never required. Identity
verification at the message layer uses cryptographic signing,
independent of account status.
3. **IRC semantics over HTTP** — command names and numeric codes from 3. **IRC semantics over HTTP** — command names and numeric codes from
RFC 1459/2812. If you've built an IRC client or bot, you already know the RFC 1459/2812. If you've built an IRC client or bot, you already know the

View File

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

2
go.mod
View File

@@ -6,7 +6,7 @@ require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/gdamore/tcell/v2 v2.13.8 github.com/gdamore/tcell/v2 v2.13.8
github.com/getsentry/sentry-go v0.42.0 github.com/getsentry/sentry-go v0.42.0
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1

4
go.sum
View File

@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

View File

@@ -14,7 +14,7 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc" "git.eeqj.de/sneak/neoirc/internal/irc"
) )
const ( const (
@@ -52,7 +52,7 @@ func (client *Client) CreateSession(
// Fetch server info to check for hashcash requirement. // Fetch server info to check for hashcash requirement.
info, err := client.GetServerInfo() info, err := client.GetServerInfo()
var hashcashStamp string var headers map[string]string
if err == nil && info.HashcashBits > 0 { if err == nil && info.HashcashBits > 0 {
resource := info.Name resource := info.Name
@@ -60,13 +60,17 @@ func (client *Client) CreateSession(
resource = "neoirc" resource = "neoirc"
} }
hashcashStamp = MintHashcash(info.HashcashBits, resource) stamp := MintHashcash(info.HashcashBits, resource)
headers = map[string]string{
"X-Hashcash": stamp,
}
} }
data, err := client.do( data, err := client.doWithHeaders(
http.MethodPost, http.MethodPost,
"/api/v1/session", "/api/v1/session",
&SessionRequest{Nick: nick, Hashcash: hashcashStamp}, &SessionRequest{Nick: nick},
headers,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -278,6 +282,16 @@ func (client *Client) GetServerInfo() (
func (client *Client) do( func (client *Client) do(
method, path string, method, path string,
body any, body any,
) ([]byte, error) {
return client.doWithHeaders(
method, path, body, nil,
)
}
func (client *Client) doWithHeaders(
method, path string,
body any,
extraHeaders map[string]string,
) ([]byte, error) { ) ([]byte, error) {
var bodyReader io.Reader var bodyReader io.Reader
@@ -310,6 +324,10 @@ func (client *Client) do(
) )
} }
for key, val := range extraHeaders {
request.Header.Set(key, val)
}
resp, err := client.HTTPClient.Do(request) resp, err := client.HTTPClient.Do(request)
if err != nil { if err != nil {
return nil, fmt.Errorf("http: %w", err) return nil, fmt.Errorf("http: %w", err)

View File

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

View File

@@ -4,8 +4,7 @@ import "time"
// SessionRequest is the body for POST /api/v1/session. // SessionRequest is the body for POST /api/v1/session.
type SessionRequest struct { type SessionRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
} }
// SessionResponse is the response from session creation. // SessionResponse is the response from session creation.

View File

@@ -9,7 +9,7 @@ import (
"time" "time"
api "git.eeqj.de/sneak/neoirc/internal/cli/api" api "git.eeqj.de/sneak/neoirc/internal/cli/api"
"git.eeqj.de/sneak/neoirc/pkg/irc" "git.eeqj.de/sneak/neoirc/internal/irc"
) )
const ( const (

View File

@@ -38,9 +38,8 @@ type Config struct {
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
MessageMaxAge string MaxHistory int
MaxMessageSize int MaxMessageSize int
QueueMaxAge string
MOTD string MOTD string
ServerName string ServerName string
FederationKey string FederationKey string
@@ -70,13 +69,12 @@ func New(
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MESSAGE_MAX_AGE", "720h") viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("QUEUE_MAX_AGE", "720h")
viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h") viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20") viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
err := viper.ReadInConfig() err := viper.ReadInConfig()
@@ -96,9 +94,8 @@ func New(
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MaxHistory: viper.GetInt("MAX_HISTORY"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),

View File

@@ -64,14 +64,12 @@ func (database *Database) RegisterUser(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now) clientUUID, sessionID, token, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -139,14 +137,12 @@ func (database *Database) LoginUser(
now := time.Now() now := time.Now()
tokenHash := hashToken(token)
res, err := database.conn.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now) clientUUID, sessionID, token, now, now)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(
"create login client: %w", err, "create login client: %w", err,

View File

@@ -1,20 +0,0 @@
// Package db provides database access and migration management.
package db
import (
"errors"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// IsUniqueConstraintError reports whether err is a SQLite
// unique-constraint violation.
func IsUniqueConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}

View File

@@ -3,7 +3,6 @@ package db
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -11,7 +10,7 @@ import (
"strconv" "strconv"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc" "git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -32,14 +31,6 @@ func generateToken() (string, error) {
return hex.EncodeToString(buf), nil return hex.EncodeToString(buf), nil
} }
// hashToken returns the lowercase hex-encoded SHA-256
// digest of a plaintext token string.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// IRCMessage is the IRC envelope for all messages. // IRCMessage is the IRC envelope for all messages.
type IRCMessage struct { type IRCMessage struct {
ID string `json:"id"` ID string `json:"id"`
@@ -114,14 +105,12 @@ func (database *Database) CreateSession(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now) clientUUID, sessionID, token, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -154,8 +143,6 @@ func (database *Database) GetSessionByToken(
nick string nick string
) )
tokenHash := hashToken(token)
err := database.conn.QueryRowContext( err := database.conn.QueryRowContext(
ctx, ctx,
`SELECT s.id, c.id, s.nick `SELECT s.id, c.id, s.nick
@@ -163,7 +150,7 @@ func (database *Database) GetSessionByToken(
INNER JOIN sessions s INNER JOIN sessions s
ON s.id = c.session_id ON s.id = c.session_id
WHERE c.token = ?`, WHERE c.token = ?`,
tokenHash, token,
).Scan(&sessionID, &clientID, &nick) ).Scan(&sessionID, &clientID, &nick)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(
@@ -746,8 +733,8 @@ func scanMessages(
code, _ := strconv.Atoi(msg.Command) code, _ := strconv.Atoi(msg.Command)
msg.Code = code msg.Code = code
if mt, err := irc.FromInt(code); err == nil { if name := irc.Name(code); name != "" {
msg.Command = mt.Name() msg.Command = name
} }
} }
@@ -1109,306 +1096,3 @@ func (database *Database) GetSessionCreatedAt(
return createdAt, nil return createdAt, nil
} }
// SetAway sets the away message for a session.
// An empty message clears the away status.
func (database *Database) SetAway(
ctx context.Context,
sessionID int64,
message string,
) error {
_, err := database.conn.ExecContext(ctx,
"UPDATE sessions SET away_message = ? WHERE id = ?",
message, sessionID)
if err != nil {
return fmt.Errorf("set away: %w", err)
}
return nil
}
// GetAway returns the away message for a session.
// Returns an empty string if the user is not away.
func (database *Database) GetAway(
ctx context.Context,
sessionID int64,
) (string, error) {
var msg string
err := database.conn.QueryRowContext(ctx,
"SELECT away_message FROM sessions WHERE id = ?",
sessionID,
).Scan(&msg)
if err != nil {
return "", fmt.Errorf("get away: %w", err)
}
return msg, nil
}
// SetTopicMeta sets the topic along with who set it and
// when.
func (database *Database) SetTopicMeta(
ctx context.Context,
channelName, topic, setBy string,
) error {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET topic = ?, topic_set_by = ?,
topic_set_at = ?, updated_at = ?
WHERE name = ?`,
topic, setBy, now, now, channelName)
if err != nil {
return fmt.Errorf("set topic meta: %w", err)
}
return nil
}
// TopicMeta holds topic metadata for a channel.
type TopicMeta struct {
SetBy string
SetAt time.Time
}
// GetTopicMeta returns who set the topic and when.
func (database *Database) GetTopicMeta(
ctx context.Context,
channelID int64,
) (*TopicMeta, error) {
var (
setBy string
setAt sql.NullTime
)
err := database.conn.QueryRowContext(ctx,
`SELECT topic_set_by, topic_set_at
FROM channels WHERE id = ?`,
channelID,
).Scan(&setBy, &setAt)
if err != nil {
return nil, fmt.Errorf(
"get topic meta: %w", err,
)
}
if setBy == "" || !setAt.Valid {
return nil, nil //nolint:nilnil
}
return &TopicMeta{
SetBy: setBy,
SetAt: setAt.Time,
}, nil
}
// GetSessionLastSeen returns the last_seen time for a
// session.
func (database *Database) GetSessionLastSeen(
ctx context.Context,
sessionID int64,
) (time.Time, error) {
var lastSeen time.Time
err := database.conn.QueryRowContext(ctx,
"SELECT last_seen FROM sessions WHERE id = ?",
sessionID,
).Scan(&lastSeen)
if err != nil {
return time.Time{}, fmt.Errorf(
"get session last_seen: %w", err,
)
}
return lastSeen, nil
}
// PruneOldQueueEntries deletes client output queue entries
// older than cutoff and returns the number of rows removed.
func (database *Database) PruneOldQueueEntries(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM client_queues WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune old client output queue entries: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}
// PruneOldMessages deletes messages older than cutoff and
// returns the number of rows removed.
func (database *Database) PruneOldMessages(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM messages WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune old messages: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}
// GetClientCount returns the total number of clients.
func (database *Database) GetClientCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM clients",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get client count: %w", err,
)
}
return count, nil
}
// GetQueueEntryCount returns the total number of entries
// in the client output queues.
func (database *Database) GetQueueEntryCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM client_queues",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get queue entry count: %w", err,
)
}
return count, nil
}
// GetChannelHashcashBits returns the hashcash difficulty
// requirement for a channel. Returns 0 if not set.
func (database *Database) GetChannelHashcashBits(
ctx context.Context,
channelID int64,
) (int, error) {
var bits int
err := database.conn.QueryRowContext(
ctx,
"SELECT hashcash_bits FROM channels WHERE id = ?",
channelID,
).Scan(&bits)
if err != nil {
return 0, fmt.Errorf(
"get channel hashcash bits: %w", err,
)
}
return bits, nil
}
// SetChannelHashcashBits sets the hashcash difficulty
// requirement for a channel. A value of 0 disables the
// requirement.
func (database *Database) SetChannelHashcashBits(
ctx context.Context,
channelID int64,
bits int,
) error {
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET hashcash_bits = ?, updated_at = ?
WHERE id = ?`,
bits, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel hashcash bits: %w", err,
)
}
return nil
}
// RecordSpentHashcash stores a spent hashcash stamp hash
// for replay prevention.
func (database *Database) RecordSpentHashcash(
ctx context.Context,
stampHash string,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO spent_hashcash
(stamp_hash, created_at)
VALUES (?, ?)`,
stampHash, time.Now())
if err != nil {
return fmt.Errorf(
"record spent hashcash: %w", err,
)
}
return nil
}
// IsHashcashSpent checks whether a hashcash stamp hash
// has already been used.
func (database *Database) IsHashcashSpent(
ctx context.Context,
stampHash string,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM spent_hashcash
WHERE stamp_hash = ?`,
stampHash,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check spent hashcash: %w", err,
)
}
return count > 0, nil
}
// PruneSpentHashcash deletes spent hashcash tokens older
// than the cutoff and returns the number of rows removed.
func (database *Database) PruneSpentHashcash(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM spent_hashcash WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune spent hashcash: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -22,12 +22,10 @@ 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/handlers" "git.eeqj.de/sneak/neoirc/internal/handlers"
"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/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"
) )
@@ -92,7 +90,6 @@ func newTestServer(
return cfg, nil return cfg, nil
}, },
newTestDB, newTestDB,
stats.New,
newTestHealthcheck, newTestHealthcheck,
newTestMiddleware, newTestMiddleware,
newTestHandlers, newTestHandlers,
@@ -147,14 +144,12 @@ 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)
@@ -188,7 +183,6 @@ 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,
@@ -196,7 +190,6 @@ 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)
@@ -818,9 +811,9 @@ func TestMessageMissingBody(t *testing.T) {
msgs, _ := tserver.pollMessages(token, lastID) msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "412") { if !findNumeric(msgs, "461") {
t.Fatalf( t.Fatalf(
"expected ERR_NOTEXTTOSEND (412), got %v", "expected ERR_NEEDMOREPARAMS (461), got %v",
msgs, msgs,
) )
} }
@@ -842,9 +835,9 @@ func TestMessageMissingTo(t *testing.T) {
msgs, _ := tserver.pollMessages(token, lastID) msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "411") { if !findNumeric(msgs, "461") {
t.Fatalf( t.Fatalf(
"expected ERR_NORECIPIENT (411), got %v", "expected ERR_NEEDMOREPARAMS (461), got %v",
msgs, msgs,
) )
} }
@@ -877,9 +870,9 @@ func TestNonMemberCannotSend(t *testing.T) {
msgs, _ := tserver.pollMessages(aliceToken, lastID) msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "404") { if !findNumeric(msgs, "442") {
t.Fatalf( t.Fatalf(
"expected ERR_CANNOTSENDTOCHAN (404), got %v", "expected ERR_NOTONCHANNEL (442), got %v",
msgs, msgs,
) )
} }
@@ -1141,42 +1134,6 @@ 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")
@@ -1700,133 +1657,6 @@ func TestHealthcheck(t *testing.T) {
} }
} }
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
requiredFields := []string{
"sessions", "clients", "queuedLines",
"channels", "connectionsSinceBoot",
"sessionsSinceBoot", "messagesSinceBoot",
}
for _, field := range requiredFields {
if _, ok := result[field]; !ok {
t.Errorf(
"missing field %q in healthcheck", field,
)
}
}
}
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("statsuser")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#statschan",
})
tserver.sendCommand(token, map[string]any{
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(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
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,
)
}
}
func TestRegisterValid(t *testing.T) { func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
@@ -2158,397 +1988,3 @@ func TestNickBroadcastToChannels(t *testing.T) {
) )
} }
} }
// --- Channel Hashcash Tests ---
const (
metaKey = "meta"
modeCmd = "MODE"
hashcashKey = "hashcash"
)
func mintTestChannelHashcash(
tb testing.TB,
bits int,
channel string,
body json.RawMessage,
) string {
tb.Helper()
bodyHash := hashcash.BodyHash(body)
return hashcash.MintChannelStamp(bits, channel, bodyHash)
}
func TestChannelHashcashSetMode(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcmode_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hctest",
})
_, lastID := tserver.pollMessages(token, 0)
// Set hashcash bits to 2 via MODE +H.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: modeCmd,
toKey: "#hctest",
bodyKey: []string{"+H", "2"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get RPL_CHANNELMODEIS (324) confirming +H.
if !findNumeric(msgs, "324") {
t.Fatalf(
"expected RPL_CHANNELMODEIS (324), got %v",
msgs,
)
}
}
func TestChannelHashcashQueryMode(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcquery_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcquery",
})
// Set hashcash bits.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcquery",
bodyKey: []string{"+H", "5"},
})
_, lastID := tserver.pollMessages(token, 0)
// Query mode — should show +nH.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcquery",
})
msgs, _ := tserver.pollMessages(token, lastID)
found := false
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == 324 {
found = true
}
}
if !found {
t.Fatalf(
"expected RPL_CHANNELMODEIS (324), got %v",
msgs,
)
}
}
func TestChannelHashcashClearMode(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcclear_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcclear",
})
// Set hashcash bits.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcclear",
bodyKey: []string{"+H", "5"},
})
// Clear hashcash bits.
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcclear",
bodyKey: []string{"-H"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
// Now message should succeed without hashcash.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcclear",
bodyKey: []string{"test message"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
}
func TestChannelHashcashRejectNoStamp(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcreject_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcreject",
})
// Set hashcash requirement.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcreject",
bodyKey: []string{"+H", "2"},
})
_, lastID := tserver.pollMessages(token, 0)
// Send message without hashcash — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcreject",
bodyKey: []string{"spam message"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get ERR_CANNOTSENDTOCHAN (404).
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
msgs,
)
}
}
func TestChannelHashcashAcceptValidStamp(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcaccept_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcaccept",
})
// Set hashcash requirement (2 bits = fast to mint).
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcaccept",
bodyKey: []string{"+H", "2"},
})
_, lastID := tserver.pollMessages(token, 0)
// Mint a valid hashcash stamp.
msgBody, marshalErr := json.Marshal(
[]string{"hello world"},
)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stamp := mintTestChannelHashcash(
t, 2, "#hcaccept", msgBody,
)
// Send message with valid hashcash.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcaccept",
bodyKey: []string{"hello world"},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id for valid hashcash")
}
// Verify the message was delivered.
msgs, _ := tserver.pollMessages(token, lastID)
if !findMessage(msgs, privmsgCmd, "hcaccept_user") {
t.Fatalf(
"message not received: %v", msgs,
)
}
}
func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcreplay_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcreplay",
})
// Set hashcash requirement.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcreplay",
bodyKey: []string{"+H", "2"},
})
_, _ = tserver.pollMessages(token, 0)
// Mint and send once — should succeed.
msgBody, marshalErr := json.Marshal(
[]string{"unique msg"},
)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stamp := mintTestChannelHashcash(
t, 2, "#hcreplay", msgBody,
)
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcreplay",
bodyKey: []string{"unique msg"},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
_, lastID := tserver.pollMessages(token, 0)
// Replay the same stamp — should fail.
status, _ = tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcreplay",
bodyKey: []string{"unique msg"},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get ERR_CANNOTSENDTOCHAN (404).
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected replay rejection (404), got %v",
msgs,
)
}
}
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcnone_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#nohashcash",
})
// No hashcash set — message should work.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#nohashcash",
bodyKey: []string{"free message"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id")
}
}
func TestChannelHashcashInvalidBitsRange(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcbits_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcbits",
})
_, lastID := tserver.pollMessages(token, 0)
// Try to set bits to 0 — should fail.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcbits",
bodyKey: []string{"+H", "0"},
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "472") {
t.Fatalf(
"expected ERR_UNKNOWNMODE (472), got %v",
msgs,
)
}
}
func TestChannelHashcashMissingBitsArg(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcnoarg_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcnoarg",
})
_, lastID := tserver.pollMessages(token, 0)
// Try to set +H without bits argument.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcnoarg",
bodyKey: []string{"+H"},
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}

View File

@@ -4,8 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/internal/db"
) )
const minPasswordLength = 8 const minPasswordLength = 8
@@ -82,9 +80,6 @@ func (hdlr *Handlers) handleRegister(
return return
} }
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
@@ -99,7 +94,7 @@ func (hdlr *Handlers) handleRegisterError(
request *http.Request, request *http.Request,
err error, err error,
) { ) {
if db.IsUniqueConstraintError(err) { if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"nick already taken", "nick already taken",
@@ -183,8 +178,6 @@ func (hdlr *Handlers) handleLogin(
return return
} }
hdlr.stats.IncrConnections()
hdlr.deliverMOTD( hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, payload.Nick,
) )

View File

@@ -16,7 +16,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/hashcash" "git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -31,26 +30,18 @@ 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 = 24 * time.Hour
// spentHashcashTTL is how long spent hashcash tokens are
// retained for replay prevention. Per issue requirements,
// this is 1 year.
const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling. // Handlers manages HTTP request handling.
type Handlers struct { type Handlers struct {
params *Params params *Params
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator cancelCleanup context.CancelFunc
stats *stats.Tracker
cancelCleanup context.CancelFunc
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
@@ -64,13 +55,11 @@ func New(
} }
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
broker: broker.New(), broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
@@ -219,93 +208,4 @@ func (hdlr *Handlers) runCleanup(
"deleted", deleted, "deleted", deleted,
) )
} }
hdlr.pruneQueuesAndMessages(ctx)
}
// parseDurationConfig parses a Go duration string,
// returning zero on empty input and logging on error.
func (hdlr *Handlers) parseDurationConfig(
name, raw string,
) time.Duration {
if raw == "" {
return 0
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid duration config, skipping",
"name", name, "value", raw, "error", err,
)
return 0
}
return dur
}
// pruneQueuesAndMessages removes old client output queue
// entries per QUEUE_MAX_AGE and old messages per
// MESSAGE_MAX_AGE.
func (hdlr *Handlers) pruneQueuesAndMessages(
ctx context.Context,
) {
queueMaxAge := hdlr.parseDurationConfig(
"QUEUE_MAX_AGE",
hdlr.params.Config.QueueMaxAge,
)
if queueMaxAge > 0 {
queueCutoff := time.Now().Add(-queueMaxAge)
pruned, err := hdlr.params.Database.
PruneOldQueueEntries(ctx, queueCutoff)
if err != nil {
hdlr.log.Error(
"client output queue pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old client output queue entries",
"deleted", pruned,
)
}
}
messageMaxAge := hdlr.parseDurationConfig(
"MESSAGE_MAX_AGE",
hdlr.params.Config.MessageMaxAge,
)
if messageMaxAge > 0 {
msgCutoff := time.Now().Add(-messageMaxAge)
pruned, err := hdlr.params.Database.
PruneOldMessages(ctx, msgCutoff)
if err != nil {
hdlr.log.Error(
"message pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old messages",
"deleted", pruned,
)
}
}
// Prune spent hashcash tokens older than 1 year.
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
pruned, err := hdlr.params.Database.
PruneSpentHashcash(ctx, hashcashCutoff)
if err != nil {
hdlr.log.Error(
"spent hashcash pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned spent hashcash tokens",
"deleted", pruned,
)
}
} }

View File

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

View File

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

View File

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

View File

@@ -1,261 +0,0 @@
package hashcash_test
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const testBits = 2
// mintStampWithDate creates a valid hashcash stamp using
// the given date string.
func mintStampWithDate(
tb testing.TB,
bits int,
resource string,
date string,
) string {
tb.Helper()
prefix := fmt.Sprintf(
"1:%d:%s:%s::", bits, date, resource,
)
for {
counterVal, err := rand.Int(
rand.Reader, big.NewInt(1<<48),
)
if err != nil {
tb.Fatalf("random counter: %v", err)
}
stamp := prefix + hex.EncodeToString(
counterVal.Bytes(),
)
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
}
}
// hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits. Duplicated here for test minting.
func hasLeadingZeroBits(
hash []byte,
numBits int,
) bool {
fullBytes := numBits / 8
remainBits := numBits % 8
for idx := range fullBytes {
if hash[idx] != 0 {
return false
}
}
if remainBits > 0 && fullBytes < len(hash) {
mask := byte(0xFF << (8 - remainBits))
if hash[fullBytes]&mask != 0 {
return false
}
}
return true
}
func todayDate() string {
return time.Now().UTC().Format("060102")
}
func TestMintAndValidate(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
func TestReplayDetection(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("first use failed: %v", err)
}
err = validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
}
func TestResourceMismatch(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("correct-resource")
stamp := mintStampWithDate(
t, testBits, "wrong-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected resource mismatch error")
}
}
func TestInvalidStampFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
err := validator.Validate(
"not:a:valid:stamp", testBits,
)
if err == nil {
t.Fatal("expected error for bad format")
}
}
func TestBadVersion(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := fmt.Sprintf(
"2:%d:%s:%s::abc123",
testBits, todayDate(), "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected bad version error")
}
}
func TestInsufficientDifficulty(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
// Claimed bits=1, but we require testBits=2.
stamp := fmt.Sprintf(
"1:1:%s:%s::counter",
todayDate(), "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected insufficient bits error")
}
}
func TestExpiredStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
oldDate := time.Now().Add(-72 * time.Hour).
UTC().Format("060102")
stamp := mintStampWithDate(
t, testBits, "test-resource", oldDate,
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected expired stamp error")
}
}
func TestZeroBitsSkipsValidation(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
err := validator.Validate("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip: %v", err)
}
}
func TestLongDateFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
longDate := time.Now().UTC().Format("060102150405")
stamp := mintStampWithDate(
t, testBits, "test-resource", longDate,
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("long date stamp rejected: %v", err)
}
}
func TestBadDateFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := fmt.Sprintf(
"1:%d:BADDATE:%s::counter",
testBits, "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected bad date error")
}
}
func TestMultipleUniqueStamps(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
for range 5 {
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("unique stamp rejected: %v", err)
}
}
}
func TestHigherBitsStillValid(t *testing.T) {
t.Parallel()
// Mint with bits=4 but validate requiring only 2.
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, 4, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf(
"higher-difficulty stamp rejected: %v",
err,
)
}
}

View File

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

View File

@@ -2,7 +2,6 @@ package irc
// IRC command names (RFC 1459 / RFC 2812). // IRC command names (RFC 1459 / RFC 2812).
const ( const (
CmdAway = "AWAY"
CmdJoin = "JOIN" CmdJoin = "JOIN"
CmdList = "LIST" CmdList = "LIST"
CmdLusers = "LUSERS" CmdLusers = "LUSERS"

150
internal/irc/numerics.go Normal file
View File

@@ -0,0 +1,150 @@
// Package irc provides constants and utilities for the
// IRC protocol, including numeric reply codes from
// RFC 1459 and RFC 2812, and standard command names.
package irc
// Connection registration replies (001-005).
const (
RplWelcome = 1
RplYourHost = 2
RplCreated = 3
RplMyInfo = 4
RplIsupport = 5
)
// Command responses (200-399).
const (
RplUmodeIs = 221
RplLuserClient = 251
RplLuserOp = 252
RplLuserUnknown = 253
RplLuserChannels = 254
RplLuserMe = 255
RplAway = 301
RplUserHost = 302
RplIson = 303
RplUnaway = 305
RplNowAway = 306
RplWhoisUser = 311
RplWhoisServer = 312
RplWhoisOperator = 313
RplEndOfWho = 315
RplWhoisIdle = 317
RplEndOfWhois = 318
RplWhoisChannels = 319
RplList = 322
RplListEnd = 323
RplChannelModeIs = 324
RplCreationTime = 329
RplNoTopic = 331
RplTopic = 332
RplTopicWhoTime = 333
RplInviting = 341
RplWhoReply = 352
RplNamReply = 353
RplEndOfNames = 366
RplBanList = 367
RplEndOfBanList = 368
RplMotd = 372
RplMotdStart = 375
RplEndOfMotd = 376
)
// Error replies (400-599).
const (
ErrNoSuchNick = 401
ErrNoSuchServer = 402
ErrNoSuchChannel = 403
ErrCannotSendToChan = 404
ErrTooManyChannels = 405
ErrNoRecipient = 411
ErrNoTextToSend = 412
ErrUnknownCommand = 421
ErrNoNicknameGiven = 431
ErrErroneusNickname = 432
ErrNicknameInUse = 433
ErrUserNotInChannel = 441
ErrNotOnChannel = 442
ErrNotRegistered = 451
ErrNeedMoreParams = 461
ErrAlreadyRegistered = 462
ErrChannelIsFull = 471
ErrInviteOnlyChan = 473
ErrBannedFromChan = 474
ErrBadChannelKey = 475
ErrChanOpPrivsNeeded = 482
)
// names maps numeric codes to their standard IRC names.
//
//nolint:gochecknoglobals
var names = map[int]string{
RplWelcome: "RPL_WELCOME",
RplYourHost: "RPL_YOURHOST",
RplCreated: "RPL_CREATED",
RplMyInfo: "RPL_MYINFO",
RplIsupport: "RPL_ISUPPORT",
RplUmodeIs: "RPL_UMODEIS",
RplLuserClient: "RPL_LUSERCLIENT",
RplLuserOp: "RPL_LUSEROP",
RplLuserUnknown: "RPL_LUSERUNKNOWN",
RplLuserChannels: "RPL_LUSERCHANNELS",
RplLuserMe: "RPL_LUSERME",
RplAway: "RPL_AWAY",
RplUserHost: "RPL_USERHOST",
RplIson: "RPL_ISON",
RplUnaway: "RPL_UNAWAY",
RplNowAway: "RPL_NOWAWAY",
RplWhoisUser: "RPL_WHOISUSER",
RplWhoisServer: "RPL_WHOISSERVER",
RplWhoisOperator: "RPL_WHOISOPERATOR",
RplEndOfWho: "RPL_ENDOFWHO",
RplWhoisIdle: "RPL_WHOISIDLE",
RplEndOfWhois: "RPL_ENDOFWHOIS",
RplWhoisChannels: "RPL_WHOISCHANNELS",
RplList: "RPL_LIST",
RplListEnd: "RPL_LISTEND", //nolint:misspell
RplChannelModeIs: "RPL_CHANNELMODEIS",
RplCreationTime: "RPL_CREATIONTIME",
RplNoTopic: "RPL_NOTOPIC",
RplTopic: "RPL_TOPIC",
RplTopicWhoTime: "RPL_TOPICWHOTIME",
RplInviting: "RPL_INVITING",
RplWhoReply: "RPL_WHOREPLY",
RplNamReply: "RPL_NAMREPLY",
RplEndOfNames: "RPL_ENDOFNAMES",
RplBanList: "RPL_BANLIST",
RplEndOfBanList: "RPL_ENDOFBANLIST",
RplMotd: "RPL_MOTD",
RplMotdStart: "RPL_MOTDSTART",
RplEndOfMotd: "RPL_ENDOFMOTD",
ErrNoSuchNick: "ERR_NOSUCHNICK",
ErrNoSuchServer: "ERR_NOSUCHSERVER",
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
ErrNoRecipient: "ERR_NORECIPIENT",
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
ErrNotOnChannel: "ERR_NOTONCHANNEL",
ErrNotRegistered: "ERR_NOTREGISTERED",
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
ErrChannelIsFull: "ERR_CHANNELISFULL",
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
ErrBadChannelKey: "ERR_BADCHANNELKEY",
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
}
// Name returns the standard IRC name for a numeric code
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
// empty string if the code is unknown.
func Name(code int) string {
return names[code]
}

View File

@@ -11,7 +11,7 @@ import (
"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"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus" metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware" ghmm "github.com/slok/go-http-metrics/middleware"
@@ -142,6 +142,20 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
}) })
} }
// Auth returns middleware that performs authentication.
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
mware.log.Info("AUTH: before request")
next.ServeHTTP(writer, request)
})
}
}
// Metrics returns middleware that records HTTP metrics. // Metrics returns middleware that records HTTP metrics.
func (mware *Middleware) Metrics() func(http.Handler) http.Handler { func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields

View File

@@ -8,8 +8,8 @@ import (
"git.eeqj.de/sneak/neoirc/web" "git.eeqj.de/sneak/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper" "github.com/spf13/viper"
) )

View File

@@ -20,7 +20,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi"
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )

View File

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

View File

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

View File

@@ -1,391 +0,0 @@
// Package irc provides constants and utilities for the
// IRC protocol, including numeric reply codes from
// RFC 1459 and RFC 2812, and standard command names.
package irc
import (
"errors"
"fmt"
)
// IRCMessageType represents an IRC numeric reply or error code.
type IRCMessageType int //nolint:revive // Name requested by project owner.
// Name returns the standard IRC name for this numeric code
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
// Returns an empty string if the code is unknown.
func (t IRCMessageType) Name() string {
return names[t]
}
// String returns the name and numeric code in angle brackets
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
// If the code is unknown, returns "UNKNOWN <NNN>".
func (t IRCMessageType) String() string {
n := names[t]
if n == "" {
n = "UNKNOWN"
}
return fmt.Sprintf("%s <%03d>", n, int(t))
}
// Code returns the three-digit zero-padded string representation
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
func (t IRCMessageType) Code() string {
return fmt.Sprintf("%03d", int(t))
}
// Int returns the bare integer value of the numeric code.
func (t IRCMessageType) Int() int {
return int(t)
}
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
// FromInt converts an integer to an IRCMessageType, returning an error
// if the numeric code is not a known IRC reply or error code.
func FromInt(n int) (IRCMessageType, error) {
t := IRCMessageType(n)
if _, ok := names[t]; !ok {
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
}
return t, nil
}
// Connection registration replies (001-005).
const (
RplWelcome IRCMessageType = 1
RplYourHost IRCMessageType = 2
RplCreated IRCMessageType = 3
RplMyInfo IRCMessageType = 4
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
)
// Command responses (200-399).
const (
// RFC 2812 trace/stats/links replies (200-219).
RplTraceLink IRCMessageType = 200
RplTraceConnecting IRCMessageType = 201
RplTraceHandshake IRCMessageType = 202
RplTraceUnknown IRCMessageType = 203
RplTraceOperator IRCMessageType = 204
RplTraceUser IRCMessageType = 205
RplTraceServer IRCMessageType = 206
RplTraceService IRCMessageType = 207
RplTraceNewType IRCMessageType = 208
RplTraceClass IRCMessageType = 209
RplStatsLinkInfo IRCMessageType = 211
RplStatsCommands IRCMessageType = 212
RplStatsCLine IRCMessageType = 213
RplStatsNLine IRCMessageType = 214
RplStatsILine IRCMessageType = 215
RplStatsKLine IRCMessageType = 216
RplStatsQLine IRCMessageType = 217
RplStatsYLine IRCMessageType = 218
RplEndOfStats IRCMessageType = 219
RplUmodeIs IRCMessageType = 221
RplServList IRCMessageType = 234
RplServListEnd IRCMessageType = 235
RplStatsLLine IRCMessageType = 241
RplStatsUptime IRCMessageType = 242
RplStatsOLine IRCMessageType = 243
RplStatsHLine IRCMessageType = 244
RplLuserClient IRCMessageType = 251
RplLuserOp IRCMessageType = 252
RplLuserUnknown IRCMessageType = 253
RplLuserChannels IRCMessageType = 254
RplLuserMe IRCMessageType = 255
RplAdminMe IRCMessageType = 256
RplAdminLoc1 IRCMessageType = 257
RplAdminLoc2 IRCMessageType = 258
RplAdminEmail IRCMessageType = 259
RplTraceLog IRCMessageType = 261
RplTraceEnd IRCMessageType = 262
RplTryAgain IRCMessageType = 263
RplAway IRCMessageType = 301
RplUserHost IRCMessageType = 302
RplIson IRCMessageType = 303
RplUnaway IRCMessageType = 305
RplNowAway IRCMessageType = 306
RplWhoisUser IRCMessageType = 311
RplWhoisServer IRCMessageType = 312
RplWhoisOperator IRCMessageType = 313
RplWhoWasUser IRCMessageType = 314
RplEndOfWho IRCMessageType = 315
RplWhoisIdle IRCMessageType = 317
RplEndOfWhois IRCMessageType = 318
RplWhoisChannels IRCMessageType = 319
RplListStart IRCMessageType = 321
RplList IRCMessageType = 322
RplListEnd IRCMessageType = 323
RplChannelModeIs IRCMessageType = 324
RplUniqOpIs IRCMessageType = 325
RplCreationTime IRCMessageType = 329
RplNoTopic IRCMessageType = 331
RplTopic IRCMessageType = 332
RplTopicWhoTime IRCMessageType = 333
RplInviting IRCMessageType = 341
RplSummoning IRCMessageType = 342
RplInviteList IRCMessageType = 346
RplEndOfInviteList IRCMessageType = 347
RplExceptList IRCMessageType = 348
RplEndOfExceptList IRCMessageType = 349
RplVersion IRCMessageType = 351
RplWhoReply IRCMessageType = 352
RplNamReply IRCMessageType = 353
RplLinks IRCMessageType = 364
RplEndOfLinks IRCMessageType = 365
RplEndOfNames IRCMessageType = 366
RplBanList IRCMessageType = 367
RplEndOfBanList IRCMessageType = 368
RplEndOfWhowas IRCMessageType = 369
RplInfo IRCMessageType = 371
RplMotd IRCMessageType = 372
RplEndOfInfo IRCMessageType = 374
RplMotdStart IRCMessageType = 375
RplEndOfMotd IRCMessageType = 376
RplYoureOper IRCMessageType = 381
RplRehashing IRCMessageType = 382
RplYoureService IRCMessageType = 383
RplTime IRCMessageType = 391
RplUsersStart IRCMessageType = 392
RplUsers IRCMessageType = 393
RplEndOfUsers IRCMessageType = 394
RplNoUsers IRCMessageType = 395
)
// Error replies (400-599).
const (
ErrNoSuchNick IRCMessageType = 401
ErrNoSuchServer IRCMessageType = 402
ErrNoSuchChannel IRCMessageType = 403
ErrCannotSendToChan IRCMessageType = 404
ErrTooManyChannels IRCMessageType = 405
ErrWasNoSuchNick IRCMessageType = 406
ErrTooManyTargets IRCMessageType = 407
ErrNoSuchService IRCMessageType = 408
ErrNoOrigin IRCMessageType = 409
ErrNoRecipient IRCMessageType = 411
ErrNoTextToSend IRCMessageType = 412
ErrNoTopLevel IRCMessageType = 413
ErrWildTopLevel IRCMessageType = 414
ErrBadMask IRCMessageType = 415
ErrUnknownCommand IRCMessageType = 421
ErrNoMotd IRCMessageType = 422
ErrNoAdminInfo IRCMessageType = 423
ErrFileError IRCMessageType = 424
ErrNoNicknameGiven IRCMessageType = 431
ErrErroneusNickname IRCMessageType = 432
ErrNicknameInUse IRCMessageType = 433
ErrNickCollision IRCMessageType = 436
ErrUnavailResource IRCMessageType = 437
ErrUserNotInChannel IRCMessageType = 441
ErrNotOnChannel IRCMessageType = 442
ErrUserOnChannel IRCMessageType = 443
ErrNoLogin IRCMessageType = 444
ErrSummonDisabled IRCMessageType = 445
ErrUsersDisabled IRCMessageType = 446
ErrNotRegistered IRCMessageType = 451
ErrNeedMoreParams IRCMessageType = 461
ErrAlreadyRegistered IRCMessageType = 462
ErrNoPermForHost IRCMessageType = 463
ErrPasswdMismatch IRCMessageType = 464
ErrYoureBannedCreep IRCMessageType = 465
ErrYouWillBeBanned IRCMessageType = 466
ErrKeySet IRCMessageType = 467
ErrChannelIsFull IRCMessageType = 471
ErrUnknownMode IRCMessageType = 472
ErrInviteOnlyChan IRCMessageType = 473
ErrBannedFromChan IRCMessageType = 474
ErrBadChannelKey IRCMessageType = 475
ErrBadChanMask IRCMessageType = 476
ErrNoChanModes IRCMessageType = 477
ErrBanListFull IRCMessageType = 478
ErrNoPrivileges IRCMessageType = 481
ErrChanOpPrivsNeeded IRCMessageType = 482
ErrCantKillServer IRCMessageType = 483
ErrRestricted IRCMessageType = 484
ErrUniqOpPrivsNeeded IRCMessageType = 485
ErrNoOperHost IRCMessageType = 491
ErrUmodeUnknownFlag IRCMessageType = 501
ErrUsersDoNotMatch IRCMessageType = 502
)
// names maps numeric codes to their standard IRC names.
//
//nolint:gochecknoglobals
var names = map[IRCMessageType]string{
RplWelcome: "RPL_WELCOME",
RplYourHost: "RPL_YOURHOST",
RplCreated: "RPL_CREATED",
RplMyInfo: "RPL_MYINFO",
RplBounce: "RPL_BOUNCE",
RplTraceLink: "RPL_TRACELINK",
RplTraceConnecting: "RPL_TRACECONNECTING",
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
RplTraceUnknown: "RPL_TRACEUNKNOWN",
RplTraceOperator: "RPL_TRACEOPERATOR",
RplTraceUser: "RPL_TRACEUSER",
RplTraceServer: "RPL_TRACESERVER",
RplTraceService: "RPL_TRACESERVICE",
RplTraceNewType: "RPL_TRACENEWTYPE",
RplTraceClass: "RPL_TRACECLASS",
RplStatsLinkInfo: "RPL_STATSLINKINFO",
RplStatsCommands: "RPL_STATSCOMMANDS",
RplStatsCLine: "RPL_STATSCLINE",
RplStatsNLine: "RPL_STATSNLINE",
RplStatsILine: "RPL_STATSILINE",
RplStatsKLine: "RPL_STATSKLINE",
RplStatsQLine: "RPL_STATSQLINE",
RplStatsYLine: "RPL_STATSYLINE",
RplEndOfStats: "RPL_ENDOFSTATS",
RplUmodeIs: "RPL_UMODEIS",
RplServList: "RPL_SERVLIST",
RplServListEnd: "RPL_SERVLISTEND",
RplStatsLLine: "RPL_STATSLLINE",
RplStatsUptime: "RPL_STATSUPTIME",
RplStatsOLine: "RPL_STATSOLINE",
RplStatsHLine: "RPL_STATSHLINE",
RplLuserClient: "RPL_LUSERCLIENT",
RplLuserOp: "RPL_LUSEROP",
RplLuserUnknown: "RPL_LUSERUNKNOWN",
RplLuserChannels: "RPL_LUSERCHANNELS",
RplLuserMe: "RPL_LUSERME",
RplAdminMe: "RPL_ADMINME",
RplAdminLoc1: "RPL_ADMINLOC1",
RplAdminLoc2: "RPL_ADMINLOC2",
RplAdminEmail: "RPL_ADMINEMAIL",
RplTraceLog: "RPL_TRACELOG",
RplTraceEnd: "RPL_TRACEEND",
RplTryAgain: "RPL_TRYAGAIN",
RplAway: "RPL_AWAY",
RplUserHost: "RPL_USERHOST",
RplIson: "RPL_ISON",
RplUnaway: "RPL_UNAWAY",
RplNowAway: "RPL_NOWAWAY",
RplWhoisUser: "RPL_WHOISUSER",
RplWhoisServer: "RPL_WHOISSERVER",
RplWhoisOperator: "RPL_WHOISOPERATOR",
RplWhoWasUser: "RPL_WHOWASUSER",
RplEndOfWho: "RPL_ENDOFWHO",
RplWhoisIdle: "RPL_WHOISIDLE",
RplEndOfWhois: "RPL_ENDOFWHOIS",
RplWhoisChannels: "RPL_WHOISCHANNELS",
RplListStart: "RPL_LISTSTART",
RplList: "RPL_LIST",
RplListEnd: "RPL_LISTEND", //nolint:misspell
RplChannelModeIs: "RPL_CHANNELMODEIS",
RplUniqOpIs: "RPL_UNIQOPIS",
RplCreationTime: "RPL_CREATIONTIME",
RplNoTopic: "RPL_NOTOPIC",
RplTopic: "RPL_TOPIC",
RplTopicWhoTime: "RPL_TOPICWHOTIME",
RplInviting: "RPL_INVITING",
RplSummoning: "RPL_SUMMONING",
RplInviteList: "RPL_INVITELIST",
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
RplExceptList: "RPL_EXCEPTLIST",
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
RplVersion: "RPL_VERSION",
RplWhoReply: "RPL_WHOREPLY",
RplNamReply: "RPL_NAMREPLY",
RplLinks: "RPL_LINKS",
RplEndOfLinks: "RPL_ENDOFLINKS",
RplEndOfNames: "RPL_ENDOFNAMES",
RplBanList: "RPL_BANLIST",
RplEndOfBanList: "RPL_ENDOFBANLIST",
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
RplInfo: "RPL_INFO",
RplMotd: "RPL_MOTD",
RplEndOfInfo: "RPL_ENDOFINFO",
RplMotdStart: "RPL_MOTDSTART",
RplEndOfMotd: "RPL_ENDOFMOTD",
RplYoureOper: "RPL_YOUREOPER",
RplRehashing: "RPL_REHASHING",
RplYoureService: "RPL_YOURESERVICE",
RplTime: "RPL_TIME",
RplUsersStart: "RPL_USERSSTART",
RplUsers: "RPL_USERS",
RplEndOfUsers: "RPL_ENDOFUSERS",
RplNoUsers: "RPL_NOUSERS",
ErrNoSuchNick: "ERR_NOSUCHNICK",
ErrNoSuchServer: "ERR_NOSUCHSERVER",
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
ErrNoSuchService: "ERR_NOSUCHSERVICE",
ErrNoOrigin: "ERR_NOORIGIN",
ErrNoRecipient: "ERR_NORECIPIENT",
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
ErrNoTopLevel: "ERR_NOTOPLEVEL",
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
ErrBadMask: "ERR_BADMASK",
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
ErrNoMotd: "ERR_NOMOTD",
ErrNoAdminInfo: "ERR_NOADMININFO",
ErrFileError: "ERR_FILEERROR",
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
ErrNickCollision: "ERR_NICKCOLLISION",
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
ErrNotOnChannel: "ERR_NOTONCHANNEL",
ErrUserOnChannel: "ERR_USERONCHANNEL",
ErrNoLogin: "ERR_NOLOGIN",
ErrSummonDisabled: "ERR_SUMMONDISABLED",
ErrUsersDisabled: "ERR_USERSDISABLED",
ErrNotRegistered: "ERR_NOTREGISTERED",
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
ErrNoPermForHost: "ERR_NOPERMFORHOST",
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
ErrKeySet: "ERR_KEYSET",
ErrChannelIsFull: "ERR_CHANNELISFULL",
ErrUnknownMode: "ERR_UNKNOWNMODE",
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
ErrBadChannelKey: "ERR_BADCHANNELKEY",
ErrBadChanMask: "ERR_BADCHANMASK",
ErrNoChanModes: "ERR_NOCHANMODES",
ErrBanListFull: "ERR_BANLISTFULL",
ErrNoPrivileges: "ERR_NOPRIVILEGES",
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
ErrCantKillServer: "ERR_CANTKILLSERVER",
ErrRestricted: "ERR_RESTRICTED",
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
ErrNoOperHost: "ERR_NOOPERHOST",
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
}
// Name returns the standard IRC name for a numeric code
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
// empty string if the code is unknown.
//
// Deprecated: Use IRCMessageType.Name() instead.
func Name(code IRCMessageType) string {
return names[code]
}

View File

@@ -1,163 +0,0 @@
package irc_test
import (
"errors"
"testing"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
func TestName(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "RPL_WELCOME"},
{irc.RplBounce, "RPL_BOUNCE"},
{irc.RplLuserOp, "RPL_LUSEROP"},
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
}
for _, tc := range tests {
if got := tc.numeric.Name(); got != tc.want {
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestString(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "RPL_WELCOME <001>"},
{irc.RplBounce, "RPL_BOUNCE <005>"},
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
}
for _, tc := range tests {
if got := tc.numeric.String(); got != tc.want {
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestCode(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "001"},
{irc.RplBounce, "005"},
{irc.RplLuserOp, "252"},
{irc.ErrCannotSendToChan, "404"},
}
for _, tc := range tests {
if got := tc.numeric.Code(); got != tc.want {
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestInt(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want int
}{
{irc.RplWelcome, 1},
{irc.RplBounce, 5},
{irc.RplLuserOp, 252},
{irc.ErrCannotSendToChan, 404},
}
for _, tc := range tests {
if got := tc.numeric.Int(); got != tc.want {
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
}
}
}
func TestFromInt_Known(t *testing.T) {
t.Parallel()
tests := []struct {
code int
want irc.IRCMessageType
}{
{1, irc.RplWelcome},
{5, irc.RplBounce},
{252, irc.RplLuserOp},
{404, irc.ErrCannotSendToChan},
{433, irc.ErrNicknameInUse},
}
for _, test := range tests {
got, err := irc.FromInt(test.code)
if err != nil {
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
continue
}
if got != test.want {
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
}
}
}
func TestFromInt_Unknown(t *testing.T) {
t.Parallel()
unknowns := []int{0, 999, 600, -1}
for _, code := range unknowns {
_, err := irc.FromInt(code)
if err == nil {
t.Errorf("FromInt(%d) expected error, got nil", code)
continue
}
if !errors.Is(err, irc.ErrUnknownNumeric) {
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
}
}
}
func TestUnknownNumeric_Name(t *testing.T) {
t.Parallel()
unknown := irc.IRCMessageType(999)
if got := unknown.Name(); got != "" {
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
}
}
func TestUnknownNumeric_String(t *testing.T) {
t.Parallel()
unknown := irc.IRCMessageType(999)
want := "UNKNOWN <999>"
if got := unknown.String(); got != want {
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
}
}
func TestDeprecatedNameFunc(t *testing.T) {
t.Parallel()
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
}
}

View File

@@ -135,22 +135,20 @@ function LoginScreen({ onLogin }) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { try {
let hashcashStamp = ""; const extraHeaders = {};
if (hashcashBitsRef.current > 0) { if (hashcashBitsRef.current > 0) {
setError("Computing proof-of-work..."); setError("Computing proof-of-work...");
hashcashStamp = await mintHashcash( const stamp = await mintHashcash(
hashcashBitsRef.current, hashcashBitsRef.current,
hashcashResourceRef.current, hashcashResourceRef.current,
); );
extraHeaders["X-Hashcash"] = stamp;
setError(""); setError("");
} }
const reqBody = { nick: nick.trim() };
if (hashcashStamp) {
reqBody.pow_token = hashcashStamp;
}
const res = await api("/session", { const res = await api("/session", {
method: "POST", method: "POST",
body: JSON.stringify(reqBody), body: JSON.stringify({ nick: nick.trim() }),
headers: extraHeaders,
}); });
localStorage.setItem("neoirc_token", res.token); localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);