Compare commits
8 Commits
cdb808bc4f
...
feature/lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e925bf70 | ||
|
|
e519ffa1e6 | ||
| e36bd99ef6 | |||
| e9d794764b | |||
| 052674b4ee | |||
| cab5784913 | |||
| 75cecd9803 | |||
| f2e7a6ec85 |
360
README.md
360
README.md
@@ -113,8 +113,9 @@ mechanisms or stuffing data into CTCP.
|
|||||||
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
|
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
|
||||||
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
|
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
|
||||||
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
||||||
are unique per server. There are no accounts — identity is a key, a nick is a
|
are unique per server. Identity starts with a key — a nick is a display name.
|
||||||
display name.
|
Accounts are optional: you can create an anonymous session instantly, or
|
||||||
|
register with a password for multi-client access to a single session.
|
||||||
|
|
||||||
### On the resemblance to JSON-RPC
|
### On the resemblance to JSON-RPC
|
||||||
|
|
||||||
@@ -148,16 +149,45 @@ not arbitrary choices — each one follows from the project's core thesis that
|
|||||||
IRC's command model is correct and only the transport and session management
|
IRC's command model is correct and only the transport and session management
|
||||||
need to change.
|
need to change.
|
||||||
|
|
||||||
### Identity & Sessions — No Accounts
|
### Identity & Sessions — Dual Authentication Model
|
||||||
|
|
||||||
There are no accounts, no registration, no passwords. Identity is a signing
|
The server supports two authentication paths: **anonymous sessions** for
|
||||||
key; a nick is just a display name. The two are decoupled.
|
instant access, and **optional account registration** for multi-client access.
|
||||||
|
|
||||||
|
#### 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.
|
||||||
@@ -165,11 +195,17 @@ key; a nick is just a display name. The two are decoupled.
|
|||||||
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. Adding
|
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
|
||||||
registration, email verification, or OAuth would solve a problem nobody asked
|
Anonymous sessions preserve that simplicity — instant access, zero friction.
|
||||||
about and add complexity that drives away casual users. Identity verification
|
But some users want to access the same session from multiple devices without
|
||||||
is handled at the message layer via cryptographic signatures (see
|
a bouncer. Optional registration with password enables multi-client login
|
||||||
[Security Model](#security-model)), not at the session layer.
|
without adding friction for casual users: if you don't want an account,
|
||||||
|
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
|
||||||
|
|
||||||
@@ -207,12 +243,12 @@ User Session
|
|||||||
└── Client C (token_c, queue_c)
|
└── Client C (token_c, queue_c)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Current MVP note:** The current implementation creates a new user (with new
|
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
|
||||||
nick) per `POST /api/v1/session` call. True multi-client (multiple tokens
|
client to an existing registered session, enabling true multi-client support
|
||||||
sharing one nick/session) is supported by the schema (`client_queues` is keyed
|
(multiple tokens sharing one nick/session with independent message queues).
|
||||||
by user_id, and multiple tokens can point to the same user) but the session
|
Anonymous sessions created via `POST /api/v1/session` always create a new
|
||||||
creation endpoint does not yet support "add a client to an existing session."
|
user with a new nick. A future endpoint to "add a client to an existing
|
||||||
This will be added post-MVP.
|
anonymous session" is planned but not yet implemented.
|
||||||
|
|
||||||
**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
|
||||||
@@ -327,8 +363,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 hash
|
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
|
||||||
- Client presents the token; server looks it up
|
- Client presents the raw token; server hashes and 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
|
||||||
@@ -355,6 +391,8 @@ The entire read/write loop for a client is two endpoints. Everything else
|
|||||||
|
|
||||||
### Session Lifecycle
|
### Session Lifecycle
|
||||||
|
|
||||||
|
#### Anonymous Session
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─ Client ──────────────────────────────────────────────────┐
|
┌─ Client ──────────────────────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
@@ -385,6 +423,30 @@ 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
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1034,6 +1096,105 @@ TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
|||||||
echo $TOKEN
|
echo $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 | 1–32 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
|
||||||
|
```
|
||||||
|
|
||||||
### GET /api/v1/state — Get Session State
|
### GET /api/v1/state — Get Session State
|
||||||
|
|
||||||
Return the current user's session state.
|
Return the current user's session state.
|
||||||
@@ -1399,13 +1560,40 @@ Return server metadata. No authentication required.
|
|||||||
|
|
||||||
### GET /.well-known/healthcheck.json — Health Check
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
Standard health check endpoint. No authentication required.
|
Standard health check endpoint. No authentication required. Returns server
|
||||||
|
health status and runtime statistics.
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"status": "ok"}
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"now": "2024-01-15T12:00:00.000000000Z",
|
||||||
|
"uptimeSeconds": 3600,
|
||||||
|
"uptimeHuman": "1h0m0s",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"appname": "neoirc",
|
||||||
|
"maintenanceMode": false,
|
||||||
|
"sessions": 42,
|
||||||
|
"clients": 85,
|
||||||
|
"queuedLines": 128,
|
||||||
|
"channels": 7,
|
||||||
|
"connectionsSinceBoot": 200,
|
||||||
|
"sessionsSinceBoot": 150,
|
||||||
|
"messagesSinceBoot": 5000
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| ---------------------- | ------------------------------------------------- |
|
||||||
|
| `sessions` | Current number of active sessions |
|
||||||
|
| `clients` | Current number of connected clients |
|
||||||
|
| `queuedLines` | Total entries in client output queues |
|
||||||
|
| `channels` | Current number of channels |
|
||||||
|
| `connectionsSinceBoot` | Total client connections since server start |
|
||||||
|
| `sessionsSinceBoot` | Total sessions created since server start |
|
||||||
|
| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Message Flow
|
## Message Flow
|
||||||
@@ -1590,9 +1778,16 @@ 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 stored in the database and validated on every request.
|
Tokens are hashed (SHA-256) before storage and validated on every request.
|
||||||
- **No passwords**: Session creation requires only a nick. The token is the
|
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
|
||||||
sole credential.
|
password, instant access. The token is the 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.
|
||||||
@@ -1740,13 +1935,26 @@ The database schema is managed via embedded SQL migration files in
|
|||||||
|
|
||||||
**Current tables:**
|
**Current tables:**
|
||||||
|
|
||||||
#### `users`
|
#### `sessions`
|
||||||
|
| 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) |
|
||||||
| `nick` | TEXT | Unique nick |
|
| `uuid` | TEXT | Unique client UUID |
|
||||||
| `token` | TEXT | Unique auth token (64 hex chars) |
|
| `session_id`| INTEGER | FK → sessions.id (cascade delete) |
|
||||||
| `created_at`| DATETIME | Session creation time |
|
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) |
|
||||||
|
| `created_at`| DATETIME | Client creation time |
|
||||||
| `last_seen` | DATETIME | Last API request time |
|
| `last_seen` | DATETIME | Last API request time |
|
||||||
|
|
||||||
#### `channels`
|
#### `channels`
|
||||||
@@ -1803,10 +2011,19 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
|
|||||||
- **Client output queue entries**: Pruned automatically when older than
|
- **Client output queue entries**: Pruned automatically when older than
|
||||||
`QUEUE_MAX_AGE` (default 30 days).
|
`QUEUE_MAX_AGE` (default 30 days).
|
||||||
- **Channels**: Deleted when the last member leaves (ephemeral).
|
- **Channels**: Deleted when the last member leaves (ephemeral).
|
||||||
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
|
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
|
||||||
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
|
or when the last client logs out (`POST /api/v1/logout` with no remaining
|
||||||
30 days) — the server runs a background cleanup loop that parts idle users
|
clients triggers session cleanup). There is no distinction between session
|
||||||
from all channels, broadcasts QUIT, and releases their nicks.
|
types in the cleanup path — `handleQuit` and `cleanupUser` both call
|
||||||
|
`DeleteSession` unconditionally. Idle sessions are automatically expired
|
||||||
|
after `SESSION_IDLE_TIMEOUT`
|
||||||
|
(default 30 days) — the server runs a background cleanup loop that parts
|
||||||
|
idle users from all channels, broadcasts QUIT, and releases their nicks.
|
||||||
|
- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`.
|
||||||
|
A session can have multiple clients; removing one doesn't affect others.
|
||||||
|
However, when the last client is removed (via logout), the entire session
|
||||||
|
is deleted — the user is parted from all channels, QUIT is broadcast, and
|
||||||
|
the nick is released.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1834,6 +2051,8 @@ directory is also loaded automatically via
|
|||||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||||
|
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||||||
|
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
@@ -1955,11 +2174,21 @@ A complete client needs only four HTTP calls:
|
|||||||
### Step-by-Step with curl
|
### Step-by-Step with curl
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Create a session
|
# 1a. Create an anonymous session (no account)
|
||||||
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" \
|
||||||
@@ -2092,9 +2321,11 @@ Clients should handle these message commands from the queue:
|
|||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- **HTTP 401**: Token expired or invalid. Re-create session.
|
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
|
||||||
|
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 or NICK change).
|
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
||||||
|
NICK change).
|
||||||
- **HTTP 400**: Malformed request. Check the `error` field in the response.
|
- **HTTP 400**: Malformed request. Check the `error` field in the response.
|
||||||
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
|
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
|
||||||
|
|
||||||
@@ -2111,8 +2342,10 @@ Clients should handle these message commands from the queue:
|
|||||||
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
|
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
|
||||||
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
|
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
|
||||||
if `from` is you, use `to`; if `from` is someone else, use `from`.
|
if `from` is you, use `to`; if `from` is someone else, use `from`.
|
||||||
5. **Reconnection**: If the poll loop fails with 401, the session is gone.
|
5. **Reconnection**: If the poll loop fails with 401, the token is invalid.
|
||||||
Create a new session. If it fails with a network error, retry with backoff.
|
For anonymous sessions, create a new session. For registered accounts,
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2224,6 +2457,49 @@ creating one session pays once and keeps their session.
|
|||||||
- **Language-agnostic**: SHA-256 is available in every programming language.
|
- **Language-agnostic**: SHA-256 is available in every programming language.
|
||||||
The proof computation is trivially implementable in any client.
|
The proof computation is trivially implementable in any client.
|
||||||
|
|
||||||
|
### Login Rate Limiting
|
||||||
|
|
||||||
|
The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent
|
||||||
|
brute-force password attacks. This uses a token-bucket algorithm
|
||||||
|
(`golang.org/x/time/rate`) with configurable rate and burst.
|
||||||
|
|
||||||
|
| Environment Variable | Default | Description |
|
||||||
|
|---------------------|---------|-------------|
|
||||||
|
| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP |
|
||||||
|
| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP |
|
||||||
|
|
||||||
|
When the limit is exceeded, the server returns **429 Too Many Requests** with a
|
||||||
|
`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up
|
||||||
|
every 10 minutes.
|
||||||
|
|
||||||
|
> **⚠️ Security: Reverse Proxy Required for Production Use**
|
||||||
|
>
|
||||||
|
> The rate limiter extracts the client IP by checking the `X-Forwarded-For`
|
||||||
|
> header first, then `X-Real-IP`, and finally falling back to the TCP
|
||||||
|
> `RemoteAddr`. Both `X-Forwarded-For` and `X-Real-IP` are **client-controlled
|
||||||
|
> request headers** — any client can set them to arbitrary values.
|
||||||
|
>
|
||||||
|
> Without a properly configured reverse proxy in front of this server:
|
||||||
|
>
|
||||||
|
> - An attacker can **bypass rate limiting entirely** by rotating
|
||||||
|
> `X-Forwarded-For` values on each request (each value is treated as a
|
||||||
|
> distinct IP).
|
||||||
|
> - An attacker can **deny service to a specific user** by spoofing that user's
|
||||||
|
> IP in the `X-Forwarded-For` header, exhausting their rate limit bucket.
|
||||||
|
>
|
||||||
|
> **Recommendation:** Always deploy behind a reverse proxy (e.g. nginx, Caddy,
|
||||||
|
> Traefik) that strips or overwrites incoming `X-Forwarded-For` and `X-Real-IP`
|
||||||
|
> headers with the actual client IP. If running without a reverse proxy, be
|
||||||
|
> aware that the rate limiting provides no meaningful protection against a
|
||||||
|
> targeted attack.
|
||||||
|
|
||||||
|
**Why rate limits here but not on session creation?** Session creation is
|
||||||
|
protected by hashcash proof-of-work (stateless, no IP tracking needed). Login
|
||||||
|
involves bcrypt password verification against a registered account — a
|
||||||
|
fundamentally different threat model where an attacker targets a specific
|
||||||
|
account. Per-IP rate limiting is appropriate here because the cost of a wrong
|
||||||
|
guess is borne by the server (bcrypt), not the client.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -2332,6 +2608,8 @@ neoirc/
|
|||||||
│ │ └── healthcheck.go # Health check handler
|
│ │ └── healthcheck.go # Health check handler
|
||||||
│ ├── healthcheck/ # Health check logic
|
│ ├── healthcheck/ # Health check logic
|
||||||
│ │ └── healthcheck.go
|
│ │ └── healthcheck.go
|
||||||
|
│ ├── stats/ # Runtime statistics (atomic counters)
|
||||||
|
│ │ └── stats.go
|
||||||
│ ├── logger/ # slog-based logging
|
│ ├── logger/ # slog-based logging
|
||||||
│ │ └── logger.go
|
│ │ └── logger.go
|
||||||
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
|
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
|
||||||
@@ -2383,9 +2661,13 @@ neoirc/
|
|||||||
build a working IRC-style TUI client against this API in an afternoon, the
|
build a working IRC-style TUI client against this API in an afternoon, the
|
||||||
API is too complex.
|
API is too complex.
|
||||||
|
|
||||||
2. **No accounts** — identity is a signing key, nick is a display name. No
|
2. **Accounts optional** — anonymous sessions are instant: pick a nick and
|
||||||
registration, no passwords, no email verification. Session creation is
|
talk. No registration, no email verification. The cost of entry is a
|
||||||
instant. The cost of entry is a hashcash proof, not bureaucracy.
|
hashcash proof, not bureaucracy. For users who want multi-client access
|
||||||
|
(multiple devices sharing one session), 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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ func main() {
|
|||||||
server.New,
|
server.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
stats.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(func(*server.Server) {}),
|
fx.Invoke(func(*server.Server) {}),
|
||||||
).Run()
|
).Run()
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -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 v1.5.5
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
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
|
||||||
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
|
golang.org/x/time v0.6.0
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -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 v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
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=
|
||||||
@@ -151,6 +151,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type Config struct {
|
|||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
HashcashBits int
|
HashcashBits int
|
||||||
|
LoginRateLimit float64
|
||||||
|
LoginRateBurst int
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,8 @@ func New(
|
|||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
||||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
|
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
||||||
|
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,6 +108,8 @@ func New(
|
|||||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||||
|
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
||||||
|
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1110,6 +1110,121 @@ 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
|
// PruneOldQueueEntries deletes client output queue entries
|
||||||
// older than cutoff and returns the number of rows removed.
|
// older than cutoff and returns the number of rows removed.
|
||||||
func (database *Database) PruneOldQueueEntries(
|
func (database *Database) PruneOldQueueEntries(
|
||||||
@@ -1151,3 +1266,42 @@ func (database *Database) PruneOldMessages(
|
|||||||
|
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClientCount returns the total number of clients.
|
||||||
|
func (database *Database) GetClientCount(
|
||||||
|
ctx context.Context,
|
||||||
|
) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM clients",
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"get client count: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQueueEntryCount returns the total number of entries
|
||||||
|
// in the client output queues.
|
||||||
|
func (database *Database) GetQueueEntryCount(
|
||||||
|
ctx context.Context,
|
||||||
|
) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM client_queues",
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"get queue entry count: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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
|
||||||
);
|
);
|
||||||
@@ -30,6 +31,8 @@ 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,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
@@ -71,11 +71,10 @@ func (hdlr *Handlers) requireAuth(
|
|||||||
sessionID, clientID, nick, err :=
|
sessionID, clientID, nick, err :=
|
||||||
hdlr.authSession(request)
|
hdlr.authSession(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondError(
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
writer, request,
|
"error": "not registered",
|
||||||
"unauthorized",
|
"numeric": irc.ErrNotRegistered,
|
||||||
http.StatusUnauthorized,
|
}, http.StatusUnauthorized)
|
||||||
)
|
|
||||||
|
|
||||||
return 0, 0, "", false
|
return 0, 0, "", false
|
||||||
}
|
}
|
||||||
@@ -213,6 +212,9 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.stats.IncrSessions()
|
||||||
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
@@ -837,6 +839,11 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
|
case irc.CmdAway:
|
||||||
|
hdlr.handleAway(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -947,8 +954,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{command},
|
irc.ErrNoRecipient, nick, []string{command},
|
||||||
"Not enough parameters",
|
"No recipient given",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -962,8 +969,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{command},
|
irc.ErrNoTextToSend, nick, []string{command},
|
||||||
"Not enough parameters",
|
"No text to send",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -973,6 +980,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.stats.IncrMessages()
|
||||||
|
|
||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
hdlr.handleChannelMsg(
|
hdlr.handleChannelMsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -1050,8 +1059,8 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNotOnChannel, nick, []string{target},
|
irc.ErrCannotSendToChan, nick, []string{target},
|
||||||
"You're not on that channel",
|
"Cannot send to channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1147,6 +1156,19 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the target is away, send RPL_AWAY to the sender.
|
||||||
|
awayMsg, awayErr := hdlr.params.Database.GetAway(
|
||||||
|
request.Context(), targetSID,
|
||||||
|
)
|
||||||
|
if awayErr == nil && awayMsg != "" {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
request.Context(), clientID,
|
||||||
|
irc.RplAway, nick,
|
||||||
|
[]string{target}, awayMsg,
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"id": msgUUID, "status": "sent"},
|
map[string]string{"id": msgUUID, "status": "sent"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1257,14 +1279,25 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
chInfo, err := hdlr.params.Database.GetChannelByName(
|
hdlr.deliverTopicNumerics(
|
||||||
ctx, channel,
|
ctx, clientID, sessionID, nick, channel, chID,
|
||||||
)
|
)
|
||||||
if err == nil {
|
|
||||||
_ = chInfo // chInfo is the ID; topic comes from DB.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get topic from channel info.
|
hdlr.deliverNamesNumerics(
|
||||||
|
ctx, clientID, nick, channel, chID,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverTopicNumerics sends RPL_TOPIC or RPL_NOTOPIC,
|
||||||
|
// plus RPL_TOPICWHOTIME when topic metadata is available.
|
||||||
|
func (hdlr *Handlers) deliverTopicNumerics(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
) {
|
||||||
channels, listErr := hdlr.params.Database.ListChannels(
|
channels, listErr := hdlr.params.Database.ListChannels(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
@@ -1286,14 +1319,39 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
ctx, clientID, irc.RplTopic, nick,
|
ctx, clientID, irc.RplTopic, nick,
|
||||||
[]string{channel}, topic,
|
[]string{channel}, topic,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
topicMeta, tmErr := hdlr.params.Database.
|
||||||
|
GetTopicMeta(ctx, chID)
|
||||||
|
if tmErr == nil && topicMeta != nil {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID,
|
||||||
|
irc.RplTopicWhoTime, nick,
|
||||||
|
[]string{
|
||||||
|
channel,
|
||||||
|
topicMeta.SetBy,
|
||||||
|
strconv.FormatInt(
|
||||||
|
topicMeta.SetAt.Unix(), 10,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNoTopic, nick,
|
ctx, clientID, irc.RplNoTopic, nick,
|
||||||
[]string{channel}, "No topic is set",
|
[]string{channel}, "No topic is set",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get member list for NAMES reply.
|
// deliverNamesNumerics sends RPL_NAMREPLY and
|
||||||
|
// RPL_ENDOFNAMES for a channel.
|
||||||
|
func (hdlr *Handlers) deliverNamesNumerics(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID int64,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
) {
|
||||||
members, memErr := hdlr.params.Database.ChannelMembers(
|
members, memErr := hdlr.params.Database.ChannelMembers(
|
||||||
ctx, chID,
|
ctx, chID,
|
||||||
)
|
)
|
||||||
@@ -1316,8 +1374,6 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
ctx, clientID, irc.RplEndOfNames, nick,
|
ctx, clientID, irc.RplEndOfNames, nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handlePart(
|
func (hdlr *Handlers) handlePart(
|
||||||
@@ -1585,6 +1641,32 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isMember, err := hdlr.params.Database.IsChannelMember(
|
||||||
|
request.Context(), chID, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"check membership failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isMember {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNotOnChannel, nick, []string{channel},
|
||||||
|
"You're not on that channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.executeTopic(
|
hdlr.executeTopic(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -1601,8 +1683,8 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
chID int64,
|
chID int64,
|
||||||
) {
|
) {
|
||||||
setErr := hdlr.params.Database.SetTopic(
|
setErr := hdlr.params.Database.SetTopicMeta(
|
||||||
request.Context(), channel, topic,
|
request.Context(), channel, topic, nick,
|
||||||
)
|
)
|
||||||
if setErr != nil {
|
if setErr != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -1629,6 +1711,25 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.RplTopic, nick, []string{channel}, topic,
|
irc.RplTopic, nick, []string{channel}, topic,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 333 RPL_TOPICWHOTIME
|
||||||
|
topicMeta, tmErr := hdlr.params.Database.
|
||||||
|
GetTopicMeta(request.Context(), chID)
|
||||||
|
if tmErr == nil && topicMeta != nil {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
request.Context(), clientID,
|
||||||
|
irc.RplTopicWhoTime, nick,
|
||||||
|
[]string{
|
||||||
|
channel,
|
||||||
|
topicMeta.SetBy,
|
||||||
|
strconv.FormatInt(
|
||||||
|
topicMeta.SetAt.Unix(), 10,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -2018,6 +2119,11 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
"neoirc server",
|
"neoirc server",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 317 RPL_WHOISIDLE
|
||||||
|
hdlr.deliverWhoisIdle(
|
||||||
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
// 319 RPL_WHOISCHANNELS
|
// 319 RPL_WHOISCHANNELS
|
||||||
hdlr.deliverWhoisChannels(
|
hdlr.deliverWhoisChannels(
|
||||||
ctx, clientID, nick, queryNick, targetSID,
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
@@ -2435,3 +2541,95 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAway handles the AWAY command. An empty body
|
||||||
|
// clears the away status; a non-empty body sets it.
|
||||||
|
func (hdlr *Handlers) handleAway(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
|
||||||
|
awayMsg := ""
|
||||||
|
if len(lines) > 0 {
|
||||||
|
awayMsg = strings.Join(lines, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.params.Database.SetAway(
|
||||||
|
ctx, sessionID, awayMsg,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error("set away failed", "error", err)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if awayMsg == "" {
|
||||||
|
// 305 RPL_UNAWAY
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplUnaway, nick, nil,
|
||||||
|
"You are no longer marked as being away",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 306 RPL_NOWAWAY
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplNowAway, nick, nil,
|
||||||
|
"You have been marked as being away",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle
|
||||||
|
// time and signon time.
|
||||||
|
func (hdlr *Handlers) deliverWhoisIdle(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID int64,
|
||||||
|
nick, queryNick string,
|
||||||
|
targetSID int64,
|
||||||
|
) {
|
||||||
|
lastSeen, lsErr := hdlr.params.Database.
|
||||||
|
GetSessionLastSeen(ctx, targetSID)
|
||||||
|
if lsErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt, caErr := hdlr.params.Database.
|
||||||
|
GetSessionCreatedAt(ctx, targetSID)
|
||||||
|
if caErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idleSeconds := int64(time.Since(lastSeen).Seconds())
|
||||||
|
if idleSeconds < 0 {
|
||||||
|
idleSeconds = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
signonUnix := strconv.FormatInt(
|
||||||
|
createdAt.Unix(), 10,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplWhoisIdle, nick,
|
||||||
|
[]string{
|
||||||
|
queryNick,
|
||||||
|
strconv.FormatInt(idleSeconds, 10),
|
||||||
|
signonUnix,
|
||||||
|
},
|
||||||
|
"seconds idle, signon time",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
)
|
)
|
||||||
@@ -90,6 +91,7 @@ func newTestServer(
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
},
|
},
|
||||||
newTestDB,
|
newTestDB,
|
||||||
|
stats.New,
|
||||||
newTestHealthcheck,
|
newTestHealthcheck,
|
||||||
newTestMiddleware,
|
newTestMiddleware,
|
||||||
newTestHandlers,
|
newTestHandlers,
|
||||||
@@ -144,12 +146,14 @@ func newTestHealthcheck(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
log *logger.Logger,
|
log *logger.Logger,
|
||||||
database *db.Database,
|
database *db.Database,
|
||||||
|
tracker *stats.Tracker,
|
||||||
) (*healthcheck.Healthcheck, error) {
|
) (*healthcheck.Healthcheck, error) {
|
||||||
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
|
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
|
||||||
Globals: globs,
|
Globals: globs,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: log,
|
Logger: log,
|
||||||
Database: database,
|
Database: database,
|
||||||
|
Stats: tracker,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("test healthcheck: %w", err)
|
return nil, fmt.Errorf("test healthcheck: %w", err)
|
||||||
@@ -183,6 +187,7 @@ func newTestHandlers(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
database *db.Database,
|
database *db.Database,
|
||||||
hcheck *healthcheck.Healthcheck,
|
hcheck *healthcheck.Healthcheck,
|
||||||
|
tracker *stats.Tracker,
|
||||||
) (*handlers.Handlers, error) {
|
) (*handlers.Handlers, error) {
|
||||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||||
Logger: log,
|
Logger: log,
|
||||||
@@ -190,6 +195,7 @@ func newTestHandlers(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Database: database,
|
Database: database,
|
||||||
Healthcheck: hcheck,
|
Healthcheck: hcheck,
|
||||||
|
Stats: tracker,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("test handlers: %w", err)
|
return nil, fmt.Errorf("test handlers: %w", err)
|
||||||
@@ -811,9 +817,9 @@ func TestMessageMissingBody(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
if !findNumeric(msgs, "412") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected ERR_NOTEXTTOSEND (412), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -835,9 +841,9 @@ func TestMessageMissingTo(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
if !findNumeric(msgs, "411") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected ERR_NORECIPIENT (411), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -870,9 +876,9 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "442") {
|
if !findNumeric(msgs, "404") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1134,6 +1140,42 @@ func TestTopicMissingBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTopicNonMember(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
aliceToken := tserver.createSession("alice_topic")
|
||||||
|
bobToken := tserver.createSession("bob_topic")
|
||||||
|
|
||||||
|
// Only alice joins the channel.
|
||||||
|
tserver.sendCommand(aliceToken, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#topicpriv",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drain bob's initial messages.
|
||||||
|
_, lastID := tserver.pollMessages(bobToken, 0)
|
||||||
|
|
||||||
|
// Bob tries to set topic without joining.
|
||||||
|
status, _ := tserver.sendCommand(
|
||||||
|
bobToken,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: "TOPIC",
|
||||||
|
toKey: "#topicpriv",
|
||||||
|
bodyKey: []string{"Hijacked topic"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(bobToken, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "442") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPing(t *testing.T) {
|
func TestPing(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("ping_user")
|
token := tserver.createSession("ping_user")
|
||||||
@@ -1657,6 +1699,133 @@ 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)
|
||||||
|
|
||||||
@@ -1961,6 +2130,121 @@ func TestSessionStillWorks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimitExceeded(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
// Exhaust the burst (default: 5 per IP) using
|
||||||
|
// nonexistent users. These fail fast (no bcrypt),
|
||||||
|
// preventing token replenishment between requests.
|
||||||
|
for range 5 {
|
||||||
|
loginBody, mErr := json.Marshal(
|
||||||
|
map[string]string{
|
||||||
|
"nick": "nosuchuser",
|
||||||
|
"password": "doesnotmatter",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if mErr != nil {
|
||||||
|
t.Fatal(mErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp, rErr := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/login"),
|
||||||
|
bytes.NewReader(loginBody),
|
||||||
|
)
|
||||||
|
if rErr != nil {
|
||||||
|
t.Fatal(rErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = loginResp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request should be rate-limited.
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "nosuchuser", "password": "doesnotmatter",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/login"),
|
||||||
|
bytes.NewReader(loginBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 429, got %d",
|
||||||
|
resp.StatusCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
|
if retryAfter == "" {
|
||||||
|
t.Fatal("expected Retry-After header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
// Register a user.
|
||||||
|
regBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "normaluser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/register"),
|
||||||
|
bytes.NewReader(regBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// A single login should succeed without rate limiting.
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "normaluser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp2, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/login"),
|
||||||
|
bytes.NewReader(loginBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp2.Body.Close() }()
|
||||||
|
|
||||||
|
if resp2.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp2.Body)
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 200, got %d: %s",
|
||||||
|
resp2.StatusCode, respBody,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNickBroadcastToChannels(t *testing.T) {
|
func TestNickBroadcastToChannels(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
aliceToken := tserver.createSession("nick_a")
|
aliceToken := tserver.createSession("nick_a")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,6 +11,33 @@ import (
|
|||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|
||||||
|
// clientIP extracts the client IP address from the request.
|
||||||
|
// It checks X-Forwarded-For and X-Real-IP headers before
|
||||||
|
// falling back to RemoteAddr.
|
||||||
|
func clientIP(request *http.Request) string {
|
||||||
|
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
// X-Forwarded-For may contain a comma-separated list;
|
||||||
|
// the first entry is the original client.
|
||||||
|
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd // split into two parts
|
||||||
|
ip := strings.TrimSpace(parts[0])
|
||||||
|
|
||||||
|
if ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
|
||||||
|
return strings.TrimSpace(realIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(request.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return request.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
// HandleRegister creates a new user with a password.
|
// HandleRegister creates a new user with a password.
|
||||||
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -82,6 +110,9 @@ 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{
|
||||||
@@ -134,6 +165,21 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
|
ip := clientIP(request)
|
||||||
|
|
||||||
|
if !hdlr.loginLimiter.Allow(ip) {
|
||||||
|
writer.Header().Set(
|
||||||
|
"Retry-After", "1",
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"too many login attempts, try again later",
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -180,6 +226,8 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ 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/ratelimit"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
|
Stats *stats.Tracker
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||||
@@ -41,6 +44,8 @@ type Handlers struct {
|
|||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
|
loginLimiter *ratelimit.Limiter
|
||||||
|
stats *stats.Tracker
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +59,24 @@ func New(
|
|||||||
resource = "neoirc"
|
resource = "neoirc"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginRate := params.Config.LoginRateLimit
|
||||||
|
if loginRate <= 0 {
|
||||||
|
loginRate = ratelimit.DefaultRate
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBurst := params.Config.LoginRateBurst
|
||||||
|
if loginBurst <= 0 {
|
||||||
|
loginBurst = ratelimit.DefaultBurst
|
||||||
|
}
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
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),
|
||||||
|
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||||
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -151,6 +168,10 @@ func (hdlr *Handlers) stopCleanup() {
|
|||||||
if hdlr.cancelCleanup != nil {
|
if hdlr.cancelCleanup != nil {
|
||||||
hdlr.cancelCleanup()
|
hdlr.cancelCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hdlr.loginLimiter != nil {
|
||||||
|
hdlr.loginLimiter.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
resp := hdlr.hc.Healthcheck()
|
resp := hdlr.hc.Healthcheck(request.Context())
|
||||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
|
Stats *stats.Tracker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck tracks server uptime and provides health status.
|
// Healthcheck tracks server uptime and provides health status.
|
||||||
@@ -64,11 +66,22 @@ type Response struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Appname string `json:"appname"`
|
Appname string `json:"appname"`
|
||||||
Maintenance bool `json:"maintenanceMode"`
|
Maintenance bool `json:"maintenanceMode"`
|
||||||
|
|
||||||
|
// Runtime statistics.
|
||||||
|
Sessions int64 `json:"sessions"`
|
||||||
|
Clients int64 `json:"clients"`
|
||||||
|
QueuedLines int64 `json:"queuedLines"`
|
||||||
|
Channels int64 `json:"channels"`
|
||||||
|
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
|
||||||
|
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
|
||||||
|
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck returns the current health status of the server.
|
// Healthcheck returns the current health status of the server.
|
||||||
func (hcheck *Healthcheck) Healthcheck() *Response {
|
func (hcheck *Healthcheck) Healthcheck(
|
||||||
return &Response{
|
ctx context.Context,
|
||||||
|
) *Response {
|
||||||
|
resp := &Response{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||||
@@ -76,6 +89,64 @@ func (hcheck *Healthcheck) Healthcheck() *Response {
|
|||||||
Appname: hcheck.params.Globals.Appname,
|
Appname: hcheck.params.Globals.Appname,
|
||||||
Version: hcheck.params.Globals.Version,
|
Version: hcheck.params.Globals.Version,
|
||||||
Maintenance: hcheck.params.Config.MaintenanceMode,
|
Maintenance: hcheck.params.Config.MaintenanceMode,
|
||||||
|
|
||||||
|
Sessions: 0,
|
||||||
|
Clients: 0,
|
||||||
|
QueuedLines: 0,
|
||||||
|
Channels: 0,
|
||||||
|
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
|
||||||
|
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
|
||||||
|
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
|
||||||
|
}
|
||||||
|
|
||||||
|
hcheck.populateDBStats(ctx, resp)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// populateDBStats fills in database-derived counters.
|
||||||
|
func (hcheck *Healthcheck) populateDBStats(
|
||||||
|
ctx context.Context,
|
||||||
|
resp *Response,
|
||||||
|
) {
|
||||||
|
sessions, err := hcheck.params.Database.GetUserCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hcheck.log.Error(
|
||||||
|
"healthcheck: session count failed",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resp.Sessions = sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, err := hcheck.params.Database.GetClientCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hcheck.log.Error(
|
||||||
|
"healthcheck: client count failed",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resp.Clients = clients
|
||||||
|
}
|
||||||
|
|
||||||
|
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hcheck.log.Error(
|
||||||
|
"healthcheck: queue entry count failed",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resp.QueuedLines = queued
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, err := hcheck.params.Database.GetChannelCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hcheck.log.Error(
|
||||||
|
"healthcheck: channel count failed",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resp.Channels = channels
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/middleware"
|
chimw "github.com/go-chi/chi/v5/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"
|
||||||
|
|||||||
122
internal/ratelimit/ratelimit.go
Normal file
122
internal/ratelimit/ratelimit.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultRate is the default number of allowed requests per second.
|
||||||
|
DefaultRate = 1.0
|
||||||
|
|
||||||
|
// DefaultBurst is the default maximum burst size.
|
||||||
|
DefaultBurst = 5
|
||||||
|
|
||||||
|
// DefaultSweepInterval controls how often stale entries are pruned.
|
||||||
|
DefaultSweepInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// DefaultEntryTTL is how long an unused entry lives before eviction.
|
||||||
|
DefaultEntryTTL = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// entry tracks a per-IP rate limiter and when it was last used.
|
||||||
|
type entry struct {
|
||||||
|
limiter *rate.Limiter
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter manages per-key rate limiters with automatic cleanup
|
||||||
|
// of stale entries.
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*entry
|
||||||
|
rate rate.Limit
|
||||||
|
burst int
|
||||||
|
entryTTL time.Duration
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new per-key rate Limiter.
|
||||||
|
// The ratePerSec parameter sets how many requests per second are
|
||||||
|
// allowed per key. The burst parameter sets the maximum number of
|
||||||
|
// requests that can be made in a single burst.
|
||||||
|
func New(ratePerSec float64, burst int) *Limiter {
|
||||||
|
limiter := &Limiter{
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
entries: make(map[string]*entry),
|
||||||
|
rate: rate.Limit(ratePerSec),
|
||||||
|
burst: burst,
|
||||||
|
entryTTL: DefaultEntryTTL,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go limiter.sweepLoop()
|
||||||
|
|
||||||
|
return limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether a request from the given key should be
|
||||||
|
// allowed. It consumes one token from the key's rate limiter.
|
||||||
|
func (l *Limiter) Allow(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
ent, exists := l.entries[key]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
ent = &entry{
|
||||||
|
limiter: rate.NewLimiter(l.rate, l.burst),
|
||||||
|
lastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
l.entries[key] = ent
|
||||||
|
} else {
|
||||||
|
ent.lastSeen = time.Now()
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
return ent.limiter.Allow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the background sweep goroutine.
|
||||||
|
func (l *Limiter) Stop() {
|
||||||
|
close(l.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of tracked keys (for testing).
|
||||||
|
func (l *Limiter) Len() int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
return len(l.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLoop periodically removes entries that haven't been seen
|
||||||
|
// within the TTL.
|
||||||
|
func (l *Limiter) sweepLoop() {
|
||||||
|
ticker := time.NewTicker(DefaultSweepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
l.sweep()
|
||||||
|
case <-l.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweep removes stale entries.
|
||||||
|
func (l *Limiter) sweep() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-l.entryTTL)
|
||||||
|
|
||||||
|
for key, ent := range l.entries {
|
||||||
|
if ent.lastSeen.Before(cutoff) {
|
||||||
|
delete(l.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/ratelimit/ratelimit_test.go
Normal file
106
internal/ratelimit/ratelimit_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package ratelimit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewCreatesLimiter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if limiter == nil {
|
||||||
|
t.Fatal("expected non-nil limiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowWithinBurst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 3)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
if !limiter.Allow("192.168.1.1") {
|
||||||
|
t.Fatalf(
|
||||||
|
"request %d should be allowed within burst",
|
||||||
|
i+1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowExceedsBurst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Rate of 0 means no token replenishment, only burst.
|
||||||
|
limiter := ratelimit.New(0, 3)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("fourth request should be denied after burst exhausted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowSeparateKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Rate of 0, burst of 1 — only one request per key.
|
||||||
|
limiter := ratelimit.New(0, 1)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if !limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("first request for key A should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !limiter.Allow("10.0.0.2") {
|
||||||
|
t.Fatal("first request for key B should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("second request for key A should be denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.2") {
|
||||||
|
t.Fatal("second request for key B should be denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLenTracksKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if limiter.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
limiter.Allow("10.0.0.2")
|
||||||
|
|
||||||
|
if limiter.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same key again should not increase count.
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
|
||||||
|
if limiter.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopDoesNotPanic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
limiter.Stop()
|
||||||
|
}
|
||||||
@@ -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"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
52
internal/stats/stats.go
Normal file
52
internal/stats/stats.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Package stats tracks runtime statistics since server boot.
|
||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tracker holds atomic counters for runtime statistics
|
||||||
|
// that accumulate since the server started.
|
||||||
|
type Tracker struct {
|
||||||
|
connectionsSinceBoot atomic.Int64
|
||||||
|
sessionsSinceBoot atomic.Int64
|
||||||
|
messagesSinceBoot atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Tracker with all counters at zero.
|
||||||
|
func New() *Tracker {
|
||||||
|
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrConnections increments the total connection count.
|
||||||
|
func (t *Tracker) IncrConnections() {
|
||||||
|
t.connectionsSinceBoot.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrSessions increments the total session count.
|
||||||
|
func (t *Tracker) IncrSessions() {
|
||||||
|
t.sessionsSinceBoot.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrMessages increments the total PRIVMSG/NOTICE count.
|
||||||
|
func (t *Tracker) IncrMessages() {
|
||||||
|
t.messagesSinceBoot.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionsSinceBoot returns the total number of
|
||||||
|
// client connections since boot.
|
||||||
|
func (t *Tracker) ConnectionsSinceBoot() int64 {
|
||||||
|
return t.connectionsSinceBoot.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionsSinceBoot returns the total number of sessions
|
||||||
|
// created since boot.
|
||||||
|
func (t *Tracker) SessionsSinceBoot() int64 {
|
||||||
|
return t.sessionsSinceBoot.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesSinceBoot returns the total number of
|
||||||
|
// PRIVMSG/NOTICE messages sent since boot.
|
||||||
|
func (t *Tracker) MessagesSinceBoot() int64 {
|
||||||
|
return t.messagesSinceBoot.Load()
|
||||||
|
}
|
||||||
117
internal/stats/stats_test.go
Normal file
117
internal/stats/stats_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package stats_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
if tracker == nil {
|
||||||
|
t.Fatal("expected non-nil tracker")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.ConnectionsSinceBoot() != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 0 connections, got %d",
|
||||||
|
tracker.ConnectionsSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.SessionsSinceBoot() != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 0 sessions, got %d",
|
||||||
|
tracker.SessionsSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.MessagesSinceBoot() != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 0 messages, got %d",
|
||||||
|
tracker.MessagesSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrConnections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
|
||||||
|
tracker.IncrConnections()
|
||||||
|
tracker.IncrConnections()
|
||||||
|
tracker.IncrConnections()
|
||||||
|
|
||||||
|
got := tracker.ConnectionsSinceBoot()
|
||||||
|
if got != 3 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 3 connections, got %d", got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrSessions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
|
||||||
|
tracker.IncrSessions()
|
||||||
|
tracker.IncrSessions()
|
||||||
|
|
||||||
|
got := tracker.SessionsSinceBoot()
|
||||||
|
if got != 2 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 2 sessions, got %d", got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
|
||||||
|
tracker.IncrMessages()
|
||||||
|
|
||||||
|
got := tracker.MessagesSinceBoot()
|
||||||
|
if got != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 1 message, got %d", got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountersAreIndependent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
|
||||||
|
tracker.IncrConnections()
|
||||||
|
tracker.IncrSessions()
|
||||||
|
tracker.IncrMessages()
|
||||||
|
tracker.IncrMessages()
|
||||||
|
|
||||||
|
if tracker.ConnectionsSinceBoot() != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 1 connection, got %d",
|
||||||
|
tracker.ConnectionsSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.SessionsSinceBoot() != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 1 session, got %d",
|
||||||
|
tracker.SessionsSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.MessagesSinceBoot() != 2 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 2 messages, got %d",
|
||||||
|
tracker.MessagesSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user