Compare commits
7 Commits
761bce32de
...
docs/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2af55e4a5 | ||
| e36bd99ef6 | |||
| e9d794764b | |||
| 052674b4ee | |||
| cab5784913 | |||
| 75cecd9803 | |||
| f2e7a6ec85 |
548
README.md
548
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
|
||||||
@@ -265,8 +301,8 @@ The server implements HTTP long-polling for real-time message delivery:
|
|||||||
- The client disconnects (connection closed, no response needed)
|
- The client disconnects (connection closed, no response needed)
|
||||||
|
|
||||||
**Implementation detail:** The server maintains an in-memory broker with
|
**Implementation detail:** The server maintains an in-memory broker with
|
||||||
per-user notification channels. When a message is enqueued for a user, the
|
per-client notification channels. When a message is enqueued for a client, the
|
||||||
broker closes all waiting channels for that user, waking up any blocked
|
broker closes all waiting channels for that client, waking up any blocked
|
||||||
long-poll handlers. This is O(1) notification — no polling loops, no database
|
long-poll handlers. This is O(1) notification — no polling loops, no database
|
||||||
scanning.
|
scanning.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -398,28 +460,28 @@ The entire read/write loop for a client is two endpoints. Everything else
|
|||||||
│ │ │
|
│ │ │
|
||||||
┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐
|
┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐
|
||||||
│client_queue│ │client_queue│ │client_queue│
|
│client_queue│ │client_queue│ │client_queue│
|
||||||
│ user_id=1 │ │ user_id=2 │ │ user_id=3 │
|
│ client_id=1│ │ client_id=2│ │ client_id=3│
|
||||||
│ msg_id=N │ │ msg_id=N │ │ msg_id=N │
|
│ msg_id=N │ │ msg_id=N │ │ msg_id=N │
|
||||||
└────────────┘ └────────────┘ └────────────┘
|
└────────────┘ └────────────┘ └────────────┘
|
||||||
alice bob carol
|
alice bob carol
|
||||||
|
|
||||||
Each message is stored ONCE. One queue entry per recipient.
|
Each message is stored ONCE. One queue entry per recipient client.
|
||||||
```
|
```
|
||||||
|
|
||||||
The `client_queues` table contains `(user_id, message_id)` pairs. When a
|
The `client_queues` table contains `(client_id, message_id)` pairs. When a
|
||||||
client polls with `GET /messages?after=<queue_id>`, the server queries for
|
client polls with `GET /messages?after=<queue_id>`, the server queries for
|
||||||
queue entries with `id > after` for that user, joins against the messages
|
queue entries with `id > after` for that client, joins against the messages
|
||||||
table, and returns the results. The `queue_id` (auto-incrementing primary
|
table, and returns the results. The `queue_id` (auto-incrementing primary
|
||||||
key of `client_queues`) serves as a monotonically increasing cursor.
|
key of `client_queues`) serves as a monotonically increasing cursor.
|
||||||
|
|
||||||
### In-Memory Broker
|
### In-Memory Broker
|
||||||
|
|
||||||
The server maintains an in-memory notification broker to avoid database
|
The server maintains an in-memory notification broker to avoid database
|
||||||
polling. The broker is a map of `user_id → []chan struct{}`. When a message
|
polling. The broker is a map of `client_id → []chan struct{}`. When a message
|
||||||
is enqueued for a user:
|
is enqueued for a client:
|
||||||
|
|
||||||
1. The handler calls `broker.Notify(userID)`
|
1. The handler calls `broker.Notify(clientID)`
|
||||||
2. The broker closes all waiting channels for that user
|
2. The broker closes all waiting channels for that client
|
||||||
3. Any goroutines blocked in `select` on those channels wake up
|
3. Any goroutines blocked in `select` on those channels wake up
|
||||||
4. The woken handler queries the database for new queue entries
|
4. The woken handler queries the database for new queue entries
|
||||||
5. Messages are returned to the client
|
5. Messages are returned to the client
|
||||||
@@ -987,14 +1049,20 @@ the format:
|
|||||||
|
|
||||||
Create a new user session. This is the entry point for all clients.
|
Create a new user session. This is the entry point for all clients.
|
||||||
|
|
||||||
**Request:**
|
If the server requires hashcash proof-of-work (see
|
||||||
|
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
|
||||||
|
valid stamp in the `pow_token` field of the JSON request body. The required
|
||||||
|
difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{"nick": "alice"}
|
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Constraints |
|
| Field | Type | Required | Constraints |
|
||||||
|--------|--------|----------|-------------|
|
|------------|--------|-------------|-------------|
|
||||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||||
|
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||||
|
|
||||||
**Response:** `201 Created`
|
**Response:** `201 Created`
|
||||||
```json
|
```json
|
||||||
@@ -1016,13 +1084,114 @@ Create a new user session. This is the entry point for all clients.
|
|||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
||||||
|
| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled |
|
||||||
|
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
||||||
| 409 | `nick already taken` | Another active session holds this nick |
|
| 409 | `nick already taken` | Another active session holds this nick |
|
||||||
|
|
||||||
**curl example:**
|
**curl example:**
|
||||||
```bash
|
```bash
|
||||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"nick":"alice"}' | jq -r .token)
|
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .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
|
echo $TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1376,26 +1545,55 @@ Return server metadata. No authentication required.
|
|||||||
"name": "My NeoIRC Server",
|
"name": "My NeoIRC Server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"motd": "Welcome! Be nice.",
|
"motd": "Welcome! Be nice.",
|
||||||
"users": 42
|
"users": 42,
|
||||||
|
"hashcash_bits": 20
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-----------|---------|-------------|
|
|-----------------|---------|-------------|
|
||||||
| `name` | string | Server display name |
|
| `name` | string | Server display name |
|
||||||
| `version` | string | Server version |
|
| `version` | string | Server version |
|
||||||
| `motd` | string | Message of the day |
|
| `motd` | string | Message of the day |
|
||||||
| `users` | integer | Number of currently active user sessions |
|
| `users` | integer | Number of currently active user sessions |
|
||||||
|
| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). |
|
||||||
|
|
||||||
### 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
|
||||||
@@ -1580,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.
|
||||||
@@ -1730,33 +1935,52 @@ The database schema is managed via embedded SQL migration files in
|
|||||||
|
|
||||||
**Current tables:**
|
**Current tables:**
|
||||||
|
|
||||||
#### `users`
|
#### `sessions`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|-------------|----------|-------------|
|
|-----------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment) |
|
| `id` | INTEGER | Primary key (auto-increment) |
|
||||||
|
| `uuid` | TEXT | Unique session UUID |
|
||||||
| `nick` | TEXT | Unique nick |
|
| `nick` | TEXT | Unique nick |
|
||||||
| `token` | TEXT | Unique auth token (64 hex chars) |
|
| `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 |
|
| `created_at` | DATETIME | Session creation time |
|
||||||
| `last_seen` | DATETIME | Last API request time |
|
| `last_seen` | DATETIME | Last API request time |
|
||||||
|
|
||||||
|
Index on `(uuid)`.
|
||||||
|
|
||||||
|
#### `clients`
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------------|----------|-------------|
|
||||||
|
| `id` | INTEGER | Primary key (auto-increment) |
|
||||||
|
| `uuid` | TEXT | Unique client UUID |
|
||||||
|
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||||||
|
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) |
|
||||||
|
| `created_at` | DATETIME | Client creation time |
|
||||||
|
| `last_seen` | DATETIME | Last API request time |
|
||||||
|
|
||||||
|
Indexes on `(token)` and `(session_id)`.
|
||||||
|
|
||||||
#### `channels`
|
#### `channels`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|-------------|----------|-------------|
|
|---------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment) |
|
| `id` | INTEGER | Primary key (auto-increment) |
|
||||||
| `name` | TEXT | Unique channel name (e.g., `#general`) |
|
| `name` | TEXT | Unique channel name (e.g., `#general`) |
|
||||||
| `topic` | TEXT | Channel topic (default empty) |
|
| `topic` | TEXT | Channel topic (default empty) |
|
||||||
|
| `topic_set_by`| TEXT | Nick of the user who set the topic (default empty) |
|
||||||
|
| `topic_set_at`| DATETIME | When the topic was last set |
|
||||||
| `created_at` | DATETIME | Channel creation time |
|
| `created_at` | DATETIME | Channel creation time |
|
||||||
| `updated_at` | DATETIME | Last modification time |
|
| `updated_at` | DATETIME | Last modification time |
|
||||||
|
|
||||||
#### `channel_members`
|
#### `channel_members`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|-------------|----------|-------------|
|
|--------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment) |
|
| `id` | INTEGER | Primary key (auto-increment) |
|
||||||
| `channel_id`| INTEGER | FK → channels.id |
|
| `channel_id` | INTEGER | FK → channels.id (cascade delete) |
|
||||||
| `user_id` | INTEGER | FK → users.id |
|
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||||||
| `joined_at` | DATETIME | When the user joined |
|
| `joined_at` | DATETIME | When the user joined |
|
||||||
|
|
||||||
Unique constraint on `(channel_id, user_id)`.
|
Unique constraint on `(channel_id, session_id)`.
|
||||||
|
|
||||||
#### `messages`
|
#### `messages`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
@@ -1766,6 +1990,7 @@ Unique constraint on `(channel_id, user_id)`.
|
|||||||
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
|
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
|
||||||
| `msg_from` | TEXT | Sender nick |
|
| `msg_from` | TEXT | Sender nick |
|
||||||
| `msg_to` | TEXT | Target (`#channel` or nick) |
|
| `msg_to` | TEXT | Target (`#channel` or nick) |
|
||||||
|
| `params` | TEXT | JSON-encoded IRC-style positional parameters |
|
||||||
| `body` | TEXT | JSON-encoded body (array or object) |
|
| `body` | TEXT | JSON-encoded body (array or object) |
|
||||||
| `meta` | TEXT | JSON-encoded metadata |
|
| `meta` | TEXT | JSON-encoded metadata |
|
||||||
| `created_at`| DATETIME | Server timestamp |
|
| `created_at`| DATETIME | Server timestamp |
|
||||||
@@ -1776,11 +2001,11 @@ Indexes on `(msg_to, id)` and `(created_at)`.
|
|||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|-------------|----------|-------------|
|
|-------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. |
|
| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. |
|
||||||
| `user_id` | INTEGER | FK → users.id |
|
| `client_id` | INTEGER | FK → clients.id (cascade delete) |
|
||||||
| `message_id`| INTEGER | FK → messages.id |
|
| `message_id`| INTEGER | FK → messages.id (cascade delete) |
|
||||||
| `created_at`| DATETIME | When the entry was queued |
|
| `created_at`| DATETIME | When the entry was queued |
|
||||||
|
|
||||||
Unique constraint on `(user_id, message_id)`. Index on `(user_id, id)`.
|
Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`.
|
||||||
|
|
||||||
The `client_queues.id` is the monotonically increasing cursor used by
|
The `client_queues.id` is the monotonically increasing cursor used by
|
||||||
`GET /messages?after=<id>`. This is more reliable than timestamps (no clock
|
`GET /messages?after=<id>`. This is more reliable than timestamps (no clock
|
||||||
@@ -1793,10 +2018,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1823,6 +2057,7 @@ directory is also loaded automatically via
|
|||||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||||
| `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. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
@@ -1834,6 +2069,7 @@ MOTD=Welcome! Be excellent to each other.
|
|||||||
DEBUG=false
|
DEBUG=false
|
||||||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||||
SESSION_IDLE_TIMEOUT=720h
|
SESSION_IDLE_TIMEOUT=720h
|
||||||
|
NEOIRC_HASHCASH_BITS=20
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1943,11 +2179,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" \
|
||||||
@@ -2080,9 +2326,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).
|
||||||
|
|
||||||
@@ -2099,69 +2347,111 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rate Limiting & Abuse Prevention
|
## Rate Limiting & Abuse Prevention
|
||||||
|
|
||||||
Session creation (`POST /api/v1/session`) will require a
|
### Hashcash Proof-of-Work
|
||||||
|
|
||||||
|
Session creation (`POST /api/v1/session`) requires a
|
||||||
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
|
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
|
||||||
This is the primary defense against resource exhaustion — no CAPTCHAs, no
|
This is the primary defense against resource exhaustion — no CAPTCHAs, no
|
||||||
account registration, no IP-based rate limits that punish shared networks.
|
account registration, no IP-based rate limits that punish shared networks.
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
1. Client requests a challenge: `GET /api/v1/challenge`
|
1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits`
|
||||||
```json
|
field (e.g., `20`) indicating the required difficulty.
|
||||||
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
|
2. Client computes a hashcash stamp: find a counter value such that the
|
||||||
```
|
SHA-256 hash of the stamp string has the required number of leading zero
|
||||||
2. Server returns a nonce and a required difficulty (number of leading zero
|
bits.
|
||||||
bits in the SHA-256 hash)
|
3. Client includes the stamp in the `pow_token` field of the JSON request body when creating
|
||||||
3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)`
|
a session: `POST /api/v1/session`.
|
||||||
has the required number of leading zero bits:
|
4. Server validates the stamp:
|
||||||
```
|
- Version is `1`
|
||||||
SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good)
|
- Claimed bits ≥ required bits
|
||||||
SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good)
|
- Resource matches the server name
|
||||||
...
|
- Date is within 48 hours (not expired, not too far in the future)
|
||||||
SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!)
|
- SHA-256 hash has the required leading zero bits
|
||||||
```
|
- Stamp has not been used before (replay prevention)
|
||||||
4. Client submits the proof with the session request:
|
|
||||||
```json
|
|
||||||
POST /api/v1/session
|
|
||||||
{"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}}
|
|
||||||
```
|
|
||||||
5. Server verifies:
|
|
||||||
- Nonce was issued by this server and hasn't expired
|
|
||||||
- Nonce hasn't been used before (prevent replay)
|
|
||||||
- `SHA-256(nonce || ":" || counter)` has the required leading zeros
|
|
||||||
- If valid, create the session normally
|
|
||||||
|
|
||||||
### Adaptive Difficulty
|
### Stamp Format
|
||||||
|
|
||||||
The required difficulty scales with server load. Under normal conditions, the
|
Standard hashcash format:
|
||||||
cost is negligible (a few milliseconds of CPU). As concurrent sessions or
|
|
||||||
session creation rate increases, difficulty rises — making bulk session creation
|
|
||||||
exponentially more expensive for attackers while remaining cheap for legitimate
|
|
||||||
single-user connections.
|
|
||||||
|
|
||||||
| Server Load | Difficulty (bits) | Approx. Client CPU |
|
```
|
||||||
|--------------------|-------------------|--------------------|
|
1:bits:date:resource::counter
|
||||||
| Normal (< 100/min) | 16 | ~1ms |
|
```
|
||||||
| Elevated | 20 | ~15ms |
|
|
||||||
| High | 24 | ~250ms |
|
|
||||||
| Under attack | 28+ | ~4s+ |
|
|
||||||
|
|
||||||
Each additional bit of difficulty doubles the expected work. An attacker
|
| Field | Description |
|
||||||
creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate
|
|------------|-------------|
|
||||||
user creating one session needs ~4 seconds once and never again for the
|
| `1` | Version (always `1`) |
|
||||||
duration of their session.
|
| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) |
|
||||||
|
| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) |
|
||||||
|
| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) |
|
||||||
|
| (empty) | Extension field (unused) |
|
||||||
|
| `counter` | Hex counter value found by the client to satisfy the PoW |
|
||||||
|
|
||||||
|
**Example stamp:** `1:20:260310:neoirc::3a2f1b`
|
||||||
|
|
||||||
|
The SHA-256 hash of this entire string must have at least 20 leading zero bits.
|
||||||
|
|
||||||
|
### Computing a Stamp
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pseudocode
|
||||||
|
bits = 20
|
||||||
|
resource = "neoirc"
|
||||||
|
date = "260310" # YYMMDD in UTC
|
||||||
|
counter = 0
|
||||||
|
|
||||||
|
loop:
|
||||||
|
stamp = "1:{bits}:{date}:{resource}::{hex(counter)}"
|
||||||
|
hash = SHA-256(stamp)
|
||||||
|
if leading_zero_bits(hash) >= bits:
|
||||||
|
return stamp
|
||||||
|
counter++
|
||||||
|
```
|
||||||
|
|
||||||
|
At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on
|
||||||
|
average, taking roughly 0.5–2 seconds on modern hardware.
|
||||||
|
|
||||||
|
### Client Integration
|
||||||
|
|
||||||
|
Both the embedded web SPA and the CLI client automatically handle hashcash:
|
||||||
|
|
||||||
|
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
|
||||||
|
2. If `hashcash_bits > 0`, compute a valid stamp
|
||||||
|
3. Include the stamp in the `pow_token` field of the JSON body on `POST /api/v1/session`
|
||||||
|
|
||||||
|
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
|
||||||
|
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Set `NEOIRC_HASHCASH_BITS` to control difficulty:
|
||||||
|
|
||||||
|
| Value | Effect | Approx. Client CPU |
|
||||||
|
|-------|--------|---------------------|
|
||||||
|
| `0` | Disabled (no proof-of-work required) | — |
|
||||||
|
| `16` | Light protection | ~1ms |
|
||||||
|
| `20` | Default — good balance | ~0.5–2s |
|
||||||
|
| `24` | Strong protection | ~10–30s |
|
||||||
|
| `28` | Very strong (may frustrate users) | ~2–10min |
|
||||||
|
|
||||||
|
Each additional bit doubles the expected work. An attacker creating 1000
|
||||||
|
sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user
|
||||||
|
creating one session pays once and keeps their session.
|
||||||
|
|
||||||
### Why Hashcash and Not Rate Limits?
|
### Why Hashcash and Not Rate Limits?
|
||||||
|
|
||||||
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
||||||
The server only needs to verify a hash.
|
The server only needs to verify a single hash.
|
||||||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||||||
campuses, corporate networks, Tor exits). Every client computes their own
|
campuses, corporate networks, Tor exits). Every client computes their own
|
||||||
proof independently.
|
proof independently.
|
||||||
@@ -2169,36 +2459,9 @@ duration of their session.
|
|||||||
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
||||||
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
|
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
|
||||||
No registration, no email, no phone number, no CAPTCHA. Just compute.
|
No registration, no email, no phone number, no CAPTCHA. Just compute.
|
||||||
- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU
|
|
||||||
once. A botnet trying to create thousands of sessions pays exponentially more.
|
|
||||||
- **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.
|
||||||
|
|
||||||
### Challenge Endpoint (Planned)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/challenge
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nonce": "a1b2c3d4e5f6...",
|
|
||||||
"difficulty": 20,
|
|
||||||
"algorithm": "sha256",
|
|
||||||
"expires": "2026-02-10T20:01:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|--------------|---------|-------------|
|
|
||||||
| `nonce` | string | Server-generated random hex string (32+ chars) |
|
|
||||||
| `difficulty` | integer | Required number of leading zero bits in the hash |
|
|
||||||
| `algorithm` | string | Hash algorithm (always `sha256` for now) |
|
|
||||||
| `expires` | string | ISO 8601 expiry time for this challenge |
|
|
||||||
|
|
||||||
**Status:** Not yet implemented. Tracked for post-MVP.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -2227,7 +2490,7 @@ GET /api/v1/challenge
|
|||||||
|
|
||||||
### Post-MVP (Planned)
|
### Post-MVP (Planned)
|
||||||
|
|
||||||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||||
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
|
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
|
||||||
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
|
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
|
||||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||||
@@ -2280,15 +2543,18 @@ neoirc/
|
|||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── neoircd/ # Server binary entry point
|
│ ├── neoircd/ # Server binary entry point
|
||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ └── neoirc-cli/ # TUI client
|
│ └── neoirc-cli/ # TUI client entry point
|
||||||
│ ├── main.go # Command handling, poll loop
|
│ └── main.go # Minimal bootstrapping (calls internal/cli)
|
||||||
│ ├── ui.go # tview-based terminal UI
|
|
||||||
│ └── api/
|
|
||||||
│ ├── client.go # HTTP API client library
|
|
||||||
│ └── types.go # Request/response types
|
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── broker/ # In-memory pub/sub for long-poll notifications
|
│ ├── broker/ # In-memory pub/sub for long-poll notifications
|
||||||
│ │ └── broker.go
|
│ │ └── broker.go
|
||||||
|
│ ├── cli/ # TUI client implementation
|
||||||
|
│ │ ├── app.go # App struct, command handling, poll loop
|
||||||
|
│ │ ├── ui.go # tview-based terminal UI
|
||||||
|
│ │ └── api/
|
||||||
|
│ │ ├── client.go # HTTP API client library
|
||||||
|
│ │ ├── types.go # Request/response types
|
||||||
|
│ │ └── hashcash.go # Hashcash proof-of-work minting
|
||||||
│ ├── config/ # Viper-based configuration
|
│ ├── config/ # Viper-based configuration
|
||||||
│ │ └── config.go
|
│ │ └── config.go
|
||||||
│ ├── db/ # Database access and migrations
|
│ ├── db/ # Database access and migrations
|
||||||
@@ -2304,6 +2570,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)
|
||||||
@@ -2355,9 +2623,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
|
||||||
|
|||||||
@@ -1,911 +1,8 @@
|
|||||||
// Package main is the entry point for the neoirc-cli client.
|
// Package main is the entry point for the neoirc-cli client.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
splitParts = 2
|
|
||||||
pollTimeout = 15
|
|
||||||
pollRetry = 2 * time.Second
|
|
||||||
timeFormat = "15:04"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App holds the application state.
|
|
||||||
type App struct {
|
|
||||||
ui *UI
|
|
||||||
client *api.Client
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
nick string
|
|
||||||
target string
|
|
||||||
connected bool
|
|
||||||
lastQID int64
|
|
||||||
stopPoll chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := &App{ //nolint:exhaustruct
|
cli.Run()
|
||||||
ui: NewUI(),
|
|
||||||
nick: "guest",
|
|
||||||
}
|
|
||||||
|
|
||||||
app.ui.OnInput(app.handleInput)
|
|
||||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
|
||||||
|
|
||||||
app.ui.AddStatus(
|
|
||||||
"Welcome to neoirc-cli — an IRC-style client",
|
|
||||||
)
|
|
||||||
app.ui.AddStatus(
|
|
||||||
"Type [yellow]/connect <server-url>" +
|
|
||||||
"[white] to begin, " +
|
|
||||||
"or [yellow]/help[white] for commands",
|
|
||||||
)
|
|
||||||
|
|
||||||
err := app.ui.Run()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleInput(text string) {
|
|
||||||
if strings.HasPrefix(text, "/") {
|
|
||||||
a.handleCommand(text)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
target := a.target
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Not connected. Use /connect <url>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if target == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]No target. " +
|
|
||||||
"Use /join #channel or /query nick",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdPrivmsg,
|
|
||||||
To: target,
|
|
||||||
Body: []string{text},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Send error: " + err.Error(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := time.Now().Format(timeFormat)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
|
||||||
timestamp, nick, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleCommand(text string) {
|
|
||||||
parts := strings.SplitN(text, " ", splitParts)
|
|
||||||
cmd := strings.ToLower(parts[0])
|
|
||||||
|
|
||||||
args := ""
|
|
||||||
if len(parts) > 1 {
|
|
||||||
args = parts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
a.dispatchCommand(cmd, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) dispatchCommand(cmd, args string) {
|
|
||||||
switch cmd {
|
|
||||||
case "/connect":
|
|
||||||
a.cmdConnect(args)
|
|
||||||
case "/nick":
|
|
||||||
a.cmdNick(args)
|
|
||||||
case "/join":
|
|
||||||
a.cmdJoin(args)
|
|
||||||
case "/part":
|
|
||||||
a.cmdPart(args)
|
|
||||||
case "/msg":
|
|
||||||
a.cmdMsg(args)
|
|
||||||
case "/query":
|
|
||||||
a.cmdQuery(args)
|
|
||||||
case "/topic":
|
|
||||||
a.cmdTopic(args)
|
|
||||||
case "/window", "/w":
|
|
||||||
a.cmdWindow(args)
|
|
||||||
case "/quit":
|
|
||||||
a.cmdQuit()
|
|
||||||
case "/help":
|
|
||||||
a.cmdHelp()
|
|
||||||
default:
|
|
||||||
a.dispatchInfoCommand(cmd, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) dispatchInfoCommand(cmd, args string) {
|
|
||||||
switch cmd {
|
|
||||||
case "/names":
|
|
||||||
a.cmdNames()
|
|
||||||
case "/list":
|
|
||||||
a.cmdList()
|
|
||||||
case "/motd":
|
|
||||||
a.cmdMotd()
|
|
||||||
case "/who":
|
|
||||||
a.cmdWho(args)
|
|
||||||
case "/whois":
|
|
||||||
a.cmdWhois(args)
|
|
||||||
default:
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Unknown command: " + cmd,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdConnect(serverURL string) {
|
|
||||||
if serverURL == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /connect <server-url>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serverURL = strings.TrimRight(serverURL, "/")
|
|
||||||
|
|
||||||
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
client := api.NewClient(serverURL)
|
|
||||||
|
|
||||||
resp, err := client.CreateSession(nick)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Connection failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
a.client = client
|
|
||||||
a.nick = resp.Nick
|
|
||||||
a.connected = true
|
|
||||||
a.lastQID = 0
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[green]Connected! Nick: %s, Session: %d",
|
|
||||||
resp.Nick, resp.ID,
|
|
||||||
))
|
|
||||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
|
||||||
|
|
||||||
a.stopPoll = make(chan struct{})
|
|
||||||
|
|
||||||
go a.pollLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdNick(nick string) {
|
|
||||||
if nick == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /nick <name>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.mu.Lock()
|
|
||||||
a.nick = nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"Nick set to " + nick +
|
|
||||||
" (will be used on connect)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdNick,
|
|
||||||
Body: []string{nick},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Nick change failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
a.nick = nick
|
|
||||||
target := a.target
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SetStatus(nick, target, "connected")
|
|
||||||
a.ui.AddStatus("Nick changed to " + nick)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdJoin(channel string) {
|
|
||||||
if channel == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /join #channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(channel, "#") {
|
|
||||||
channel = "#" + channel
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.JoinChannel(channel)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Join failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
a.target = channel
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SwitchToBuffer(channel)
|
|
||||||
a.ui.AddLine(channel,
|
|
||||||
"[yellow]*** Joined "+channel,
|
|
||||||
)
|
|
||||||
a.ui.SetStatus(nick, channel, "connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdPart(channel string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
if channel == "" {
|
|
||||||
channel = a.target
|
|
||||||
}
|
|
||||||
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if channel == "" ||
|
|
||||||
!strings.HasPrefix(channel, "#") {
|
|
||||||
a.ui.AddStatus("[red]No channel to part")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.PartChannel(channel)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Part failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddLine(channel,
|
|
||||||
"[yellow]*** Left "+channel,
|
|
||||||
)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
if a.target == channel {
|
|
||||||
a.target = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SwitchBuffer(0)
|
|
||||||
a.ui.SetStatus(nick, "", "connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdMsg(args string) {
|
|
||||||
parts := strings.SplitN(args, " ", splitParts)
|
|
||||||
if len(parts) < splitParts {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /msg <nick> <text>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target, text := parts[0], parts[1]
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdPrivmsg,
|
|
||||||
To: target,
|
|
||||||
Body: []string{text},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Send failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := time.Now().Format(timeFormat)
|
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
|
||||||
timestamp, nick, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdQuery(nick string) {
|
|
||||||
if nick == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /query <nick>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
a.target = nick
|
|
||||||
myNick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SwitchToBuffer(nick)
|
|
||||||
a.ui.SetStatus(myNick, nick, "connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdTopic(args string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
target := a.target
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(target, "#") {
|
|
||||||
a.ui.AddStatus("[red]Not in a channel")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if args == "" {
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdTopic,
|
|
||||||
To: target,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Topic query failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdTopic,
|
|
||||||
To: target,
|
|
||||||
Body: []string{args},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Topic set failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdNames() {
|
|
||||||
a.mu.Lock()
|
|
||||||
target := a.target
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(target, "#") {
|
|
||||||
a.ui.AddStatus("[red]Not in a channel")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
members, err := a.client.GetMembers(target)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Names failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
|
||||||
"[cyan]*** Members of %s: %s",
|
|
||||||
target, strings.Join(members, " "),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdList() {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
channels, err := a.client.ListChannels()
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]List failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddStatus("[cyan]*** Channel list:")
|
|
||||||
|
|
||||||
for _, ch := range channels {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
" %s (%d members) %s",
|
|
||||||
ch.Name, ch.Members, ch.Topic,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdMotd() {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]MOTD failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWho(args string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
target := a.target
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
channel := args
|
|
||||||
if channel == "" {
|
|
||||||
channel = target
|
|
||||||
}
|
|
||||||
|
|
||||||
if channel == "" ||
|
|
||||||
!strings.HasPrefix(channel, "#") {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /who #channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdWho, To: channel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]WHO failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWhois(args string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if args == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /whois <nick>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdWhois, To: args,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]WHOIS failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWindow(args string) {
|
|
||||||
if args == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /window <number>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var bufIndex int
|
|
||||||
|
|
||||||
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
|
||||||
|
|
||||||
a.ui.SwitchBuffer(bufIndex)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
|
||||||
buf := a.ui.buffers[bufIndex]
|
|
||||||
if buf.Name != "(status)" {
|
|
||||||
a.mu.Lock()
|
|
||||||
a.target = buf.Name
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SetStatus(
|
|
||||||
nick, buf.Name, "connected",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
a.ui.SetStatus(nick, "", "connected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdQuit() {
|
|
||||||
a.mu.Lock()
|
|
||||||
|
|
||||||
if a.connected && a.client != nil {
|
|
||||||
_ = a.client.SendMessage(
|
|
||||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.stopPoll != nil {
|
|
||||||
close(a.stopPoll)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Unlock()
|
|
||||||
a.ui.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdHelp() {
|
|
||||||
help := []string{
|
|
||||||
"[cyan]*** neoirc-cli commands:",
|
|
||||||
" /connect <url> — Connect to server",
|
|
||||||
" /nick <name> — Change nickname",
|
|
||||||
" /join #channel — Join channel",
|
|
||||||
" /part [#chan] — Leave channel",
|
|
||||||
" /msg <nick> <text> — Send DM",
|
|
||||||
" /query <nick> — Open DM window",
|
|
||||||
" /topic [text] — View/set topic",
|
|
||||||
" /names — List channel members",
|
|
||||||
" /list — List channels",
|
|
||||||
" /who [#channel] — List users in channel",
|
|
||||||
" /whois <nick> — Show user info",
|
|
||||||
" /motd — Show message of the day",
|
|
||||||
" /window <n> — Switch buffer",
|
|
||||||
" /quit — Disconnect and exit",
|
|
||||||
" /help — This help",
|
|
||||||
" Plain text sends to current target.",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range help {
|
|
||||||
a.ui.AddStatus(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollLoop long-polls for messages in the background.
|
|
||||||
func (a *App) pollLoop() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-a.stopPoll:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
client := a.client
|
|
||||||
lastQID := a.lastQID
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if client == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := client.PollMessages(
|
|
||||||
lastQID, pollTimeout,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
time.Sleep(pollRetry)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.LastID > 0 {
|
|
||||||
a.mu.Lock()
|
|
||||||
a.lastQID = result.LastID
|
|
||||||
a.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range result.Messages {
|
|
||||||
a.handleServerMessage(&result.Messages[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleServerMessage(msg *api.Message) {
|
|
||||||
timestamp := a.formatTS(msg)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
myNick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
switch msg.Command {
|
|
||||||
case irc.CmdPrivmsg:
|
|
||||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
|
||||||
case irc.CmdJoin:
|
|
||||||
a.handleJoinEvent(msg, timestamp)
|
|
||||||
case irc.CmdPart:
|
|
||||||
a.handlePartEvent(msg, timestamp)
|
|
||||||
case irc.CmdQuit:
|
|
||||||
a.handleQuitEvent(msg, timestamp)
|
|
||||||
case irc.CmdNick:
|
|
||||||
a.handleNickEvent(msg, timestamp, myNick)
|
|
||||||
case irc.CmdNotice:
|
|
||||||
a.handleNoticeEvent(msg, timestamp)
|
|
||||||
case irc.CmdTopic:
|
|
||||||
a.handleTopicEvent(msg, timestamp)
|
|
||||||
default:
|
|
||||||
a.handleDefaultEvent(msg, timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) formatTS(msg *api.Message) string {
|
|
||||||
if msg.TS != "" {
|
|
||||||
return msg.ParseTS().UTC().Format(timeFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Now().Format(timeFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handlePrivmsgEvent(
|
|
||||||
msg *api.Message, timestamp, myNick string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
if msg.From == myNick {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target := msg.To
|
|
||||||
if !strings.HasPrefix(target, "#") {
|
|
||||||
target = msg.From
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
|
||||||
timestamp, msg.From, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleJoinEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
if msg.To == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has joined %s",
|
|
||||||
timestamp, msg.From, msg.To,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handlePartEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
if msg.To == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
reason := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
if reason != "" {
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has left %s (%s)",
|
|
||||||
timestamp, msg.From, msg.To, reason,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has left %s",
|
|
||||||
timestamp, msg.From, msg.To,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleQuitEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
reason := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
if reason != "" {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
|
||||||
timestamp, msg.From, reason,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has quit",
|
|
||||||
timestamp, msg.From,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleNickEvent(
|
|
||||||
msg *api.Message, timestamp, myNick string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
|
|
||||||
newNick := ""
|
|
||||||
if len(lines) > 0 {
|
|
||||||
newNick = lines[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.From == myNick && newNick != "" {
|
|
||||||
a.mu.Lock()
|
|
||||||
a.nick = newNick
|
|
||||||
|
|
||||||
target := a.target
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SetStatus(newNick, target, "connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s is now known as %s",
|
|
||||||
timestamp, msg.From, newNick,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleNoticeEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [magenta]--%s-- %s",
|
|
||||||
timestamp, msg.From, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleTopicEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
if msg.To == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
|
||||||
"[gray]%s [cyan]*** %s set topic: %s",
|
|
||||||
timestamp, msg.From, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleDefaultEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
if text != "" {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [white][%s] %s",
|
|
||||||
timestamp, msg.Command, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
2
go.mod
2
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
|
||||||
|
|||||||
4
go.sum
4
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=
|
||||||
|
|||||||
@@ -43,13 +43,30 @@ func NewClient(baseURL string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession creates a new session on the server.
|
// CreateSession creates a new session on the server.
|
||||||
|
// If the server requires hashcash proof-of-work, it
|
||||||
|
// automatically fetches the difficulty and computes a
|
||||||
|
// valid stamp.
|
||||||
func (client *Client) CreateSession(
|
func (client *Client) CreateSession(
|
||||||
nick string,
|
nick string,
|
||||||
) (*SessionResponse, error) {
|
) (*SessionResponse, error) {
|
||||||
|
// Fetch server info to check for hashcash requirement.
|
||||||
|
info, err := client.GetServerInfo()
|
||||||
|
|
||||||
|
var hashcashStamp string
|
||||||
|
|
||||||
|
if err == nil && info.HashcashBits > 0 {
|
||||||
|
resource := info.Name
|
||||||
|
if resource == "" {
|
||||||
|
resource = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
||||||
|
}
|
||||||
|
|
||||||
data, err := client.do(
|
data, err := client.do(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/api/v1/session",
|
"/api/v1/session",
|
||||||
&SessionRequest{Nick: nick},
|
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
79
internal/cli/api/hashcash.go
Normal file
79
internal/cli/api/hashcash.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package neoircapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// bitsPerByte is the number of bits in a byte.
|
||||||
|
bitsPerByte = 8
|
||||||
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||||
|
fullByteMask = 0xFF
|
||||||
|
// counterSpace is the range for random counter seeds.
|
||||||
|
counterSpace = 1 << 48
|
||||||
|
)
|
||||||
|
|
||||||
|
// MintHashcash computes a hashcash stamp with the given
|
||||||
|
// difficulty (leading zero bits) and resource string.
|
||||||
|
func MintHashcash(bits int, resource string) string {
|
||||||
|
date := time.Now().UTC().Format("060102")
|
||||||
|
prefix := fmt.Sprintf(
|
||||||
|
"1:%d:%s:%s::", bits, date, resource,
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
counter := randomCounter()
|
||||||
|
stamp := prefix + counter
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
if hasLeadingZeroBits(hash[:], bits) {
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLeadingZeroBits checks if hash has at least numBits
|
||||||
|
// leading zero bits.
|
||||||
|
func hasLeadingZeroBits(
|
||||||
|
hash []byte,
|
||||||
|
numBits int,
|
||||||
|
) bool {
|
||||||
|
fullBytes := numBits / bitsPerByte
|
||||||
|
remainBits := numBits % bitsPerByte
|
||||||
|
|
||||||
|
for idx := range fullBytes {
|
||||||
|
if hash[idx] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainBits > 0 && fullBytes < len(hash) {
|
||||||
|
mask := byte(
|
||||||
|
fullByteMask << (bitsPerByte - remainBits),
|
||||||
|
)
|
||||||
|
|
||||||
|
if hash[fullBytes]&mask != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomCounter generates a random hex counter string.
|
||||||
|
func randomCounter() string {
|
||||||
|
counterVal, err := rand.Int(
|
||||||
|
rand.Reader, big.NewInt(counterSpace),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to timestamp-based counter on error.
|
||||||
|
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(counterVal.Bytes())
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import "time"
|
|||||||
// SessionRequest is the body for POST /api/v1/session.
|
// SessionRequest is the body for POST /api/v1/session.
|
||||||
type SessionRequest struct {
|
type SessionRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
|
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionResponse is the response from session creation.
|
// SessionResponse is the response from session creation.
|
||||||
@@ -66,6 +67,7 @@ type ServerInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MOTD string `json:"motd"`
|
MOTD string `json:"motd"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessagesResponse wraps polling results.
|
// MessagesResponse wraps polling results.
|
||||||
912
internal/cli/app.go
Normal file
912
internal/cli/app.go
Normal file
@@ -0,0 +1,912 @@
|
|||||||
|
// Package cli implements the neoirc-cli terminal client.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||||
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
splitParts = 2
|
||||||
|
pollTimeout = 15
|
||||||
|
pollRetry = 2 * time.Second
|
||||||
|
timeFormat = "15:04"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App holds the application state.
|
||||||
|
type App struct {
|
||||||
|
ui *UI
|
||||||
|
client *api.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
nick string
|
||||||
|
target string
|
||||||
|
connected bool
|
||||||
|
lastQID int64
|
||||||
|
stopPoll chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run creates and runs the CLI application.
|
||||||
|
func Run() {
|
||||||
|
app := &App{ //nolint:exhaustruct
|
||||||
|
ui: NewUI(),
|
||||||
|
nick: "guest",
|
||||||
|
}
|
||||||
|
|
||||||
|
app.ui.OnInput(app.handleInput)
|
||||||
|
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||||
|
|
||||||
|
app.ui.AddStatus(
|
||||||
|
"Welcome to neoirc-cli — an IRC-style client",
|
||||||
|
)
|
||||||
|
app.ui.AddStatus(
|
||||||
|
"Type [yellow]/connect <server-url>" +
|
||||||
|
"[white] to begin, " +
|
||||||
|
"or [yellow]/help[white] for commands",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := app.ui.Run()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleInput(text string) {
|
||||||
|
if strings.HasPrefix(text, "/") {
|
||||||
|
a.handleCommand(text)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Not connected. Use /connect <url>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]No target. " +
|
||||||
|
"Use /join #channel or /query nick",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdPrivmsg,
|
||||||
|
To: target,
|
||||||
|
Body: []string{text},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Send error: " + err.Error(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().Format(timeFormat)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
|
timestamp, nick, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleCommand(text string) {
|
||||||
|
parts := strings.SplitN(text, " ", splitParts)
|
||||||
|
cmd := strings.ToLower(parts[0])
|
||||||
|
|
||||||
|
args := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
args = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
a.dispatchCommand(cmd, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) dispatchCommand(cmd, args string) {
|
||||||
|
switch cmd {
|
||||||
|
case "/connect":
|
||||||
|
a.cmdConnect(args)
|
||||||
|
case "/nick":
|
||||||
|
a.cmdNick(args)
|
||||||
|
case "/join":
|
||||||
|
a.cmdJoin(args)
|
||||||
|
case "/part":
|
||||||
|
a.cmdPart(args)
|
||||||
|
case "/msg":
|
||||||
|
a.cmdMsg(args)
|
||||||
|
case "/query":
|
||||||
|
a.cmdQuery(args)
|
||||||
|
case "/topic":
|
||||||
|
a.cmdTopic(args)
|
||||||
|
case "/window", "/w":
|
||||||
|
a.cmdWindow(args)
|
||||||
|
case "/quit":
|
||||||
|
a.cmdQuit()
|
||||||
|
case "/help":
|
||||||
|
a.cmdHelp()
|
||||||
|
default:
|
||||||
|
a.dispatchInfoCommand(cmd, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) dispatchInfoCommand(cmd, args string) {
|
||||||
|
switch cmd {
|
||||||
|
case "/names":
|
||||||
|
a.cmdNames()
|
||||||
|
case "/list":
|
||||||
|
a.cmdList()
|
||||||
|
case "/motd":
|
||||||
|
a.cmdMotd()
|
||||||
|
case "/who":
|
||||||
|
a.cmdWho(args)
|
||||||
|
case "/whois":
|
||||||
|
a.cmdWhois(args)
|
||||||
|
default:
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Unknown command: " + cmd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdConnect(serverURL string) {
|
||||||
|
if serverURL == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /connect <server-url>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL = strings.TrimRight(serverURL, "/")
|
||||||
|
|
||||||
|
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
client := api.NewClient(serverURL)
|
||||||
|
|
||||||
|
resp, err := client.CreateSession(nick)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]Connection failed: %v", err,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.client = client
|
||||||
|
a.nick = resp.Nick
|
||||||
|
a.connected = true
|
||||||
|
a.lastQID = 0
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[green]Connected! Nick: %s, Session: %d",
|
||||||
|
resp.Nick, resp.ID,
|
||||||
|
))
|
||||||
|
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||||
|
|
||||||
|
a.stopPoll = make(chan struct{})
|
||||||
|
|
||||||
|
go a.pollLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdNick(nick string) {
|
||||||
|
if nick == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /nick <name>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.nick = nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"Nick set to " + nick +
|
||||||
|
" (will be used on connect)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdNick,
|
||||||
|
Body: []string{nick},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]Nick change failed: %v", err,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.nick = nick
|
||||||
|
target := a.target
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SetStatus(nick, target, "connected")
|
||||||
|
a.ui.AddStatus("Nick changed to " + nick)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdJoin(channel string) {
|
||||||
|
if channel == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /join #channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(channel, "#") {
|
||||||
|
channel = "#" + channel
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.JoinChannel(channel)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]Join failed: %v", err,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.target = channel
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchToBuffer(channel)
|
||||||
|
a.ui.AddLine(channel,
|
||||||
|
"[yellow]*** Joined "+channel,
|
||||||
|
)
|
||||||
|
a.ui.SetStatus(nick, channel, "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdPart(channel string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
if channel == "" {
|
||||||
|
channel = a.target
|
||||||
|
}
|
||||||
|
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if channel == "" ||
|
||||||
|
!strings.HasPrefix(channel, "#") {
|
||||||
|
a.ui.AddStatus("[red]No channel to part")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.PartChannel(channel)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]Part failed: %v", err,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(channel,
|
||||||
|
"[yellow]*** Left "+channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.target == channel {
|
||||||
|
a.target = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchBuffer(0)
|
||||||
|
a.ui.SetStatus(nick, "", "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdMsg(args string) {
|
||||||
|
parts := strings.SplitN(args, " ", splitParts)
|
||||||
|
if len(parts) < splitParts {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /msg <nick> <text>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target, text := parts[0], parts[1]
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdPrivmsg,
|
||||||
|
To: target,
|
||||||
|
Body: []string{text},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]Send failed: %v", err,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().Format(timeFormat)
|
||||||
|
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
|
timestamp, nick, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdQuery(nick string) {
|
||||||
|
if nick == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /query <nick>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.target = nick
|
||||||
|
myNick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchToBuffer(nick)
|
||||||
|
a.ui.SetStatus(myNick, nick, "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdTopic(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
a.ui.AddStatus("[red]Not in a channel")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if args == "" {
|
||||||
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdTopic,
|
||||||
|
To: target,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]Topic query failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdTopic,
|
||||||
|
To: target,
|
||||||
|
Body: []string{args},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]Topic set failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdNames() {
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
a.ui.AddStatus("[red]Not in a channel")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := a.client.GetMembers(target)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]Names failed: %v", err,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
|
"[cyan]*** Members of %s: %s",
|
||||||
|
target, strings.Join(members, " "),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdList() {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, err := a.client.ListChannels()
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]List failed: %v", err,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddStatus("[cyan]*** Channel list:")
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
" %s (%d members) %s",
|
||||||
|
ch.Name, ch.Members, ch.Topic,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdMotd() {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]MOTD failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWho(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
target := a.target
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := args
|
||||||
|
if channel == "" {
|
||||||
|
channel = target
|
||||||
|
}
|
||||||
|
|
||||||
|
if channel == "" ||
|
||||||
|
!strings.HasPrefix(channel, "#") {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /who #channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdWho, To: channel,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]WHO failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWhois(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if args == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /whois <nick>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdWhois, To: args,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]WHOIS failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWindow(args string) {
|
||||||
|
if args == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /window <number>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var bufIndex int
|
||||||
|
|
||||||
|
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
||||||
|
|
||||||
|
a.ui.SwitchBuffer(bufIndex)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
||||||
|
buf := a.ui.buffers[bufIndex]
|
||||||
|
if buf.Name != "(status)" {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.target = buf.Name
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SetStatus(
|
||||||
|
nick, buf.Name, "connected",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
a.ui.SetStatus(nick, "", "connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdQuit() {
|
||||||
|
a.mu.Lock()
|
||||||
|
|
||||||
|
if a.connected && a.client != nil {
|
||||||
|
_ = a.client.SendMessage(
|
||||||
|
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.stopPoll != nil {
|
||||||
|
close(a.stopPoll)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.ui.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdHelp() {
|
||||||
|
help := []string{
|
||||||
|
"[cyan]*** neoirc-cli commands:",
|
||||||
|
" /connect <url> — Connect to server",
|
||||||
|
" /nick <name> — Change nickname",
|
||||||
|
" /join #channel — Join channel",
|
||||||
|
" /part [#chan] — Leave channel",
|
||||||
|
" /msg <nick> <text> — Send DM",
|
||||||
|
" /query <nick> — Open DM window",
|
||||||
|
" /topic [text] — View/set topic",
|
||||||
|
" /names — List channel members",
|
||||||
|
" /list — List channels",
|
||||||
|
" /who [#channel] — List users in channel",
|
||||||
|
" /whois <nick> — Show user info",
|
||||||
|
" /motd — Show message of the day",
|
||||||
|
" /window <n> — Switch buffer",
|
||||||
|
" /quit — Disconnect and exit",
|
||||||
|
" /help — This help",
|
||||||
|
" Plain text sends to current target.",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range help {
|
||||||
|
a.ui.AddStatus(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollLoop long-polls for messages in the background.
|
||||||
|
func (a *App) pollLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.stopPoll:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
client := a.client
|
||||||
|
lastQID := a.lastQID
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.PollMessages(
|
||||||
|
lastQID, pollTimeout,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(pollRetry)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.LastID > 0 {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.lastQID = result.LastID
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range result.Messages {
|
||||||
|
a.handleServerMessage(&result.Messages[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleServerMessage(msg *api.Message) {
|
||||||
|
timestamp := a.formatTS(msg)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
myNick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
switch msg.Command {
|
||||||
|
case irc.CmdPrivmsg:
|
||||||
|
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||||
|
case irc.CmdJoin:
|
||||||
|
a.handleJoinEvent(msg, timestamp)
|
||||||
|
case irc.CmdPart:
|
||||||
|
a.handlePartEvent(msg, timestamp)
|
||||||
|
case irc.CmdQuit:
|
||||||
|
a.handleQuitEvent(msg, timestamp)
|
||||||
|
case irc.CmdNick:
|
||||||
|
a.handleNickEvent(msg, timestamp, myNick)
|
||||||
|
case irc.CmdNotice:
|
||||||
|
a.handleNoticeEvent(msg, timestamp)
|
||||||
|
case irc.CmdTopic:
|
||||||
|
a.handleTopicEvent(msg, timestamp)
|
||||||
|
default:
|
||||||
|
a.handleDefaultEvent(msg, timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) formatTS(msg *api.Message) string {
|
||||||
|
if msg.TS != "" {
|
||||||
|
return msg.ParseTS().UTC().Format(timeFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Now().Format(timeFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handlePrivmsgEvent(
|
||||||
|
msg *api.Message, timestamp, myNick string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if msg.From == myNick {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := msg.To
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
target = msg.From
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
|
timestamp, msg.From, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleJoinEvent(
|
||||||
|
msg *api.Message, timestamp string,
|
||||||
|
) {
|
||||||
|
if msg.To == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has joined %s",
|
||||||
|
timestamp, msg.From, msg.To,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handlePartEvent(
|
||||||
|
msg *api.Message, timestamp string,
|
||||||
|
) {
|
||||||
|
if msg.To == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
reason := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if reason != "" {
|
||||||
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has left %s (%s)",
|
||||||
|
timestamp, msg.From, msg.To, reason,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has left %s",
|
||||||
|
timestamp, msg.From, msg.To,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleQuitEvent(
|
||||||
|
msg *api.Message, timestamp string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
reason := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if reason != "" {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has quit (%s)",
|
||||||
|
timestamp, msg.From, reason,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has quit",
|
||||||
|
timestamp, msg.From,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleNickEvent(
|
||||||
|
msg *api.Message, timestamp, myNick string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
|
||||||
|
newNick := ""
|
||||||
|
if len(lines) > 0 {
|
||||||
|
newNick = lines[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.From == myNick && newNick != "" {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.nick = newNick
|
||||||
|
|
||||||
|
target := a.target
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SetStatus(newNick, target, "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s is now known as %s",
|
||||||
|
timestamp, msg.From, newNick,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleNoticeEvent(
|
||||||
|
msg *api.Message, timestamp string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[gray]%s [magenta]--%s-- %s",
|
||||||
|
timestamp, msg.From, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleTopicEvent(
|
||||||
|
msg *api.Message, timestamp string,
|
||||||
|
) {
|
||||||
|
if msg.To == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
|
"[gray]%s [cyan]*** %s set topic: %s",
|
||||||
|
timestamp, msg.From, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDefaultEvent(
|
||||||
|
msg *api.Message, timestamp string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[gray]%s [white][%s] %s",
|
||||||
|
timestamp, msg.Command, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -45,6 +45,7 @@ type Config struct {
|
|||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
|
HashcashBits int
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -76,6 +77,7 @@ func New(
|
|||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
||||||
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,6 +103,7 @@ func New(
|
|||||||
ServerName: viper.GetString("SERVER_NAME"),
|
ServerName: viper.GetString("SERVER_NAME"),
|
||||||
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"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1266,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -146,6 +146,7 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
) {
|
) {
|
||||||
type createRequest struct {
|
type createRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
|
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload createRequest
|
var payload createRequest
|
||||||
@@ -161,6 +162,32 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate hashcash proof-of-work if configured.
|
||||||
|
if hdlr.params.Config.HashcashBits > 0 {
|
||||||
|
if payload.Hashcash == "" {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"hashcash proof-of-work required",
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = hdlr.hashcashVal.Validate(
|
||||||
|
payload.Hashcash, hdlr.params.Config.HashcashBits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid hashcash stamp: "+err.Error(),
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
payload.Nick = strings.TrimSpace(payload.Nick)
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
||||||
|
|
||||||
if !validNickRe.MatchString(payload.Nick) {
|
if !validNickRe.MatchString(payload.Nick) {
|
||||||
@@ -185,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{
|
||||||
@@ -950,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,
|
||||||
@@ -1609,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,
|
||||||
@@ -2467,12 +2525,20 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
resp := map[string]any{
|
||||||
"name": hdlr.params.Config.ServerName,
|
"name": hdlr.params.Config.ServerName,
|
||||||
"version": hdlr.params.Globals.Version,
|
"version": hdlr.params.Globals.Version,
|
||||||
"motd": hdlr.params.Config.MOTD,
|
"motd": hdlr.params.Config.MOTD,
|
||||||
"users": users,
|
"users": users,
|
||||||
}, http.StatusOK)
|
}
|
||||||
|
|
||||||
|
if hdlr.params.Config.HashcashBits > 0 {
|
||||||
|
resp["hashcash_bits"] = hdlr.params.Config.HashcashBits
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(
|
||||||
|
writer, request, resp, http.StatusOK,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -85,10 +86,12 @@ func newTestServer(
|
|||||||
|
|
||||||
cfg.DBURL = dbURL
|
cfg.DBURL = dbURL
|
||||||
cfg.Port = 0
|
cfg.Port = 0
|
||||||
|
cfg.HashcashBits = 0
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
},
|
},
|
||||||
newTestDB,
|
newTestDB,
|
||||||
|
stats.New,
|
||||||
newTestHealthcheck,
|
newTestHealthcheck,
|
||||||
newTestMiddleware,
|
newTestMiddleware,
|
||||||
newTestHandlers,
|
newTestHandlers,
|
||||||
@@ -143,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)
|
||||||
@@ -182,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,
|
||||||
@@ -189,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)
|
||||||
@@ -1133,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")
|
||||||
@@ -1656,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)
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,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{
|
||||||
@@ -180,6 +183,8 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"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/hashcash"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
|
Stats *stats.Tracker
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||||
@@ -39,6 +42,8 @@ type Handlers struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
|
hashcashVal *hashcash.Validator
|
||||||
|
stats *stats.Tracker
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +52,18 @@ func New(
|
|||||||
lifecycle fx.Lifecycle,
|
lifecycle fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Handlers, error) {
|
) (*Handlers, error) {
|
||||||
|
resource := params.Config.ServerName
|
||||||
|
if resource == "" {
|
||||||
|
resource = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
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),
|
||||||
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
277
internal/hashcash/hashcash.go
Normal file
277
internal/hashcash/hashcash.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// Package hashcash implements SHA-256-based hashcash
|
||||||
|
// proof-of-work validation for abuse prevention.
|
||||||
|
//
|
||||||
|
// Stamp format: 1:bits:YYMMDD:resource::counter.
|
||||||
|
//
|
||||||
|
// The SHA-256 hash of the entire stamp string must have
|
||||||
|
// at least `bits` leading zero bits.
|
||||||
|
package hashcash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// stampVersion is the only supported hashcash version.
|
||||||
|
stampVersion = "1"
|
||||||
|
// stampFields is the number of fields in a stamp.
|
||||||
|
stampFields = 6
|
||||||
|
// maxStampAge is how old a stamp can be before
|
||||||
|
// rejection.
|
||||||
|
maxStampAge = 48 * time.Hour
|
||||||
|
// maxFutureSkew allows stamps slightly in the future.
|
||||||
|
maxFutureSkew = 1 * time.Hour
|
||||||
|
// pruneInterval controls how often expired stamps are
|
||||||
|
// removed from the spent set.
|
||||||
|
pruneInterval = 10 * time.Minute
|
||||||
|
// dateFormatShort is the YYMMDD date layout.
|
||||||
|
dateFormatShort = "060102"
|
||||||
|
// dateFormatLong is the YYMMDDHHMMSS date layout.
|
||||||
|
dateFormatLong = "060102150405"
|
||||||
|
// dateShortLen is the length of YYMMDD.
|
||||||
|
dateShortLen = 6
|
||||||
|
// dateLongLen is the length of YYMMDDHHMMSS.
|
||||||
|
dateLongLen = 12
|
||||||
|
// bitsPerByte is the number of bits in a byte.
|
||||||
|
bitsPerByte = 8
|
||||||
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||||
|
fullByteMask = 0xFF
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidFields = errors.New("invalid stamp field count")
|
||||||
|
errBadVersion = errors.New("unsupported stamp version")
|
||||||
|
errInsufficientBits = errors.New("insufficient difficulty")
|
||||||
|
errWrongResource = errors.New("wrong resource")
|
||||||
|
errStampExpired = errors.New("stamp expired")
|
||||||
|
errStampFuture = errors.New("stamp date in future")
|
||||||
|
errProofFailed = errors.New("proof-of-work failed")
|
||||||
|
errStampReused = errors.New("stamp already used")
|
||||||
|
errBadDateFormat = errors.New("unrecognized date format")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator checks hashcash stamps for validity and
|
||||||
|
// prevents replay attacks via an in-memory spent set.
|
||||||
|
type Validator struct {
|
||||||
|
resource string
|
||||||
|
mu sync.Mutex
|
||||||
|
spent map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidator creates a Validator for the given resource.
|
||||||
|
func NewValidator(resource string) *Validator {
|
||||||
|
validator := &Validator{
|
||||||
|
resource: resource,
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
spent: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
go validator.pruneLoop()
|
||||||
|
|
||||||
|
return validator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks a hashcash stamp. It returns nil if the
|
||||||
|
// stamp is valid and has not been seen before.
|
||||||
|
func (v *Validator) Validate(
|
||||||
|
stamp string,
|
||||||
|
requiredBits int,
|
||||||
|
) error {
|
||||||
|
if requiredBits <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(stamp, ":")
|
||||||
|
if len(parts) != stampFields {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: expected %d, got %d",
|
||||||
|
errInvalidFields, stampFields, len(parts),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := parts[0]
|
||||||
|
bitsStr := parts[1]
|
||||||
|
dateStr := parts[2]
|
||||||
|
resource := parts[3]
|
||||||
|
|
||||||
|
if err := v.validateHeader(
|
||||||
|
version, bitsStr, resource, requiredBits,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stampTime, err := parseStampDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateTime(stampTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateProof(
|
||||||
|
stamp, requiredBits,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.checkAndRecordStamp(stamp, stampTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) validateHeader(
|
||||||
|
version, bitsStr, resource string,
|
||||||
|
requiredBits int,
|
||||||
|
) error {
|
||||||
|
if version != stampVersion {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", errBadVersion, version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedBits, err := strconv.Atoi(bitsStr)
|
||||||
|
if err != nil || claimedBits < requiredBits {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: need %d bits",
|
||||||
|
errInsufficientBits, requiredBits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource != v.resource {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
errWrongResource, resource, v.resource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTime(stampTime time.Time) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if now.Sub(stampTime) > maxStampAge {
|
||||||
|
return errStampExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
if stampTime.Sub(now) > maxFutureSkew {
|
||||||
|
return errStampFuture
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProof(stamp string, requiredBits int) error {
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
if !hasLeadingZeroBits(hash[:], requiredBits) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: need %d leading zero bits",
|
||||||
|
errProofFailed, requiredBits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) checkAndRecordStamp(
|
||||||
|
stamp string,
|
||||||
|
stampTime time.Time,
|
||||||
|
) error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := v.spent[stamp]; ok {
|
||||||
|
return errStampReused
|
||||||
|
}
|
||||||
|
|
||||||
|
v.spent[stamp] = stampTime
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLeadingZeroBits checks if the hash has at least n
|
||||||
|
// leading zero bits.
|
||||||
|
func hasLeadingZeroBits(hash []byte, numBits int) bool {
|
||||||
|
fullBytes := numBits / bitsPerByte
|
||||||
|
remainBits := numBits % bitsPerByte
|
||||||
|
|
||||||
|
for idx := range fullBytes {
|
||||||
|
if hash[idx] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainBits > 0 && fullBytes < len(hash) {
|
||||||
|
mask := byte(
|
||||||
|
fullByteMask << (bitsPerByte - remainBits),
|
||||||
|
)
|
||||||
|
|
||||||
|
if hash[fullBytes]&mask != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStampDate parses a hashcash date stamp.
|
||||||
|
// Supports YYMMDD and YYMMDDHHMMSS formats.
|
||||||
|
func parseStampDate(dateStr string) (time.Time, error) {
|
||||||
|
switch len(dateStr) {
|
||||||
|
case dateShortLen:
|
||||||
|
parsed, err := time.Parse(
|
||||||
|
dateFormatShort, dateStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"parse date: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
case dateLongLen:
|
||||||
|
parsed, err := time.Parse(
|
||||||
|
dateFormatLong, dateStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"parse date: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"%w: %q", errBadDateFormat, dateStr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLoop periodically removes expired stamps from the
|
||||||
|
// spent set.
|
||||||
|
func (v *Validator) pruneLoop() {
|
||||||
|
ticker := time.NewTicker(pruneInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
v.prune()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) prune() {
|
||||||
|
cutoff := time.Now().Add(-maxStampAge)
|
||||||
|
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
for stamp, stampTime := range v.spent {
|
||||||
|
if stampTime.Before(cutoff) {
|
||||||
|
delete(v.spent, stamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
261
internal/hashcash/hashcash_test.go
Normal file
261
internal/hashcash/hashcash_test.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package hashcash_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testBits = 2
|
||||||
|
|
||||||
|
// mintStampWithDate creates a valid hashcash stamp using
|
||||||
|
// the given date string.
|
||||||
|
func mintStampWithDate(
|
||||||
|
tb testing.TB,
|
||||||
|
bits int,
|
||||||
|
resource string,
|
||||||
|
date string,
|
||||||
|
) string {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
prefix := fmt.Sprintf(
|
||||||
|
"1:%d:%s:%s::", bits, date, resource,
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
counterVal, err := rand.Int(
|
||||||
|
rand.Reader, big.NewInt(1<<48),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("random counter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := prefix + hex.EncodeToString(
|
||||||
|
counterVal.Bytes(),
|
||||||
|
)
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
if hasLeadingZeroBits(hash[:], bits) {
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLeadingZeroBits checks if hash has at least numBits
|
||||||
|
// leading zero bits. Duplicated here for test minting.
|
||||||
|
func hasLeadingZeroBits(
|
||||||
|
hash []byte,
|
||||||
|
numBits int,
|
||||||
|
) bool {
|
||||||
|
fullBytes := numBits / 8
|
||||||
|
remainBits := numBits % 8
|
||||||
|
|
||||||
|
for idx := range fullBytes {
|
||||||
|
if hash[idx] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainBits > 0 && fullBytes < len(hash) {
|
||||||
|
mask := byte(0xFF << (8 - remainBits))
|
||||||
|
|
||||||
|
if hash[fullBytes]&mask != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayDate() string {
|
||||||
|
return time.Now().UTC().Format("060102")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintAndValidate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayDetection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first use failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("replay not detected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResourceMismatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("correct-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "wrong-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected resource mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidStampFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
|
err := validator.Validate(
|
||||||
|
"not:a:valid:stamp", testBits,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for bad format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := fmt.Sprintf(
|
||||||
|
"2:%d:%s:%s::abc123",
|
||||||
|
testBits, todayDate(), "test-resource",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected bad version error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsufficientDifficulty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
// Claimed bits=1, but we require testBits=2.
|
||||||
|
stamp := fmt.Sprintf(
|
||||||
|
"1:1:%s:%s::counter",
|
||||||
|
todayDate(), "test-resource",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected insufficient bits error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredStamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
oldDate := time.Now().Add(-72 * time.Hour).
|
||||||
|
UTC().Format("060102")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", oldDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected expired stamp error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroBitsSkipsValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
|
err := validator.Validate("garbage", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zero bits should skip: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongDateFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
longDate := time.Now().UTC().Format("060102150405")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", longDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("long date stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadDateFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := fmt.Sprintf(
|
||||||
|
"1:%d:BADDATE:%s::counter",
|
||||||
|
testBits, "test-resource",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected bad date error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipleUniqueStamps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unique stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHigherBitsStillValid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Mint with bits=4 but validate requiring only 2.
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, 4, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"higher-difficulty stamp rejected: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,56 @@ const MEMBER_REFRESH_INTERVAL = 10000;
|
|||||||
const ACTION_PREFIX = "\x01ACTION ";
|
const ACTION_PREFIX = "\x01ACTION ";
|
||||||
const ACTION_SUFFIX = "\x01";
|
const ACTION_SUFFIX = "\x01";
|
||||||
|
|
||||||
|
// Hashcash proof-of-work helpers using Web Crypto API.
|
||||||
|
|
||||||
|
function checkLeadingZeros(hashBytes, bits) {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < hashBytes.length; i++) {
|
||||||
|
if (hashBytes[i] === 0) {
|
||||||
|
count += 8;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let b = hashBytes[i];
|
||||||
|
while ((b & 0x80) === 0) {
|
||||||
|
count++;
|
||||||
|
b <<= 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return count >= bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mintHashcash(bits, resource) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const now = new Date();
|
||||||
|
const date =
|
||||||
|
String(now.getUTCFullYear()).slice(2) +
|
||||||
|
String(now.getUTCMonth() + 1).padStart(2, "0") +
|
||||||
|
String(now.getUTCDate()).padStart(2, "0");
|
||||||
|
const prefix = `1:${bits}:${date}:${resource}::`;
|
||||||
|
let nonce = Math.floor(Math.random() * 0x100000);
|
||||||
|
const batchSize = 1024;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const stamps = [];
|
||||||
|
const hashPromises = [];
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
const stamp = prefix + (nonce + i).toString(16);
|
||||||
|
stamps.push(stamp);
|
||||||
|
hashPromises.push(
|
||||||
|
crypto.subtle.digest("SHA-256", encoder.encode(stamp)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hashes = await Promise.all(hashPromises);
|
||||||
|
for (let i = 0; i < hashes.length; i++) {
|
||||||
|
if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) {
|
||||||
|
return stamps[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nonce += batchSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function api(path, opts = {}) {
|
function api(path, opts = {}) {
|
||||||
const token = localStorage.getItem("neoirc_token");
|
const token = localStorage.getItem("neoirc_token");
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -60,12 +110,16 @@ function LoginScreen({ onLogin }) {
|
|||||||
const [motd, setMotd] = useState("");
|
const [motd, setMotd] = useState("");
|
||||||
const [serverName, setServerName] = useState("NeoIRC");
|
const [serverName, setServerName] = useState("NeoIRC");
|
||||||
const inputRef = useRef();
|
const inputRef = useRef();
|
||||||
|
const hashcashBitsRef = useRef(0);
|
||||||
|
const hashcashResourceRef = useRef("neoirc");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api("/server")
|
api("/server")
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (s.name) setServerName(s.name);
|
if (s.name) setServerName(s.name);
|
||||||
if (s.motd) setMotd(s.motd);
|
if (s.motd) setMotd(s.motd);
|
||||||
|
hashcashBitsRef.current = s.hashcash_bits || 0;
|
||||||
|
if (s.name) hashcashResourceRef.current = s.name;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
@@ -81,9 +135,22 @@ function LoginScreen({ onLogin }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
let hashcashStamp = "";
|
||||||
|
if (hashcashBitsRef.current > 0) {
|
||||||
|
setError("Computing proof-of-work...");
|
||||||
|
hashcashStamp = await mintHashcash(
|
||||||
|
hashcashBitsRef.current,
|
||||||
|
hashcashResourceRef.current,
|
||||||
|
);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
const reqBody = { nick: nick.trim() };
|
||||||
|
if (hashcashStamp) {
|
||||||
|
reqBody.pow_token = hashcashStamp;
|
||||||
|
}
|
||||||
const res = await api("/session", {
|
const res = await api("/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ nick: nick.trim() }),
|
body: JSON.stringify(reqBody),
|
||||||
});
|
});
|
||||||
localStorage.setItem("neoirc_token", res.token);
|
localStorage.setItem("neoirc_token", res.token);
|
||||||
onLogin(res.nick);
|
onLogin(res.nick);
|
||||||
|
|||||||
Reference in New Issue
Block a user