10 Commits

Author SHA1 Message Date
clawbot
cdb808bc4f fix: update internal/irc import to pkg/irc after rebase
All checks were successful
check / check (push) Successful in 4s
2026-03-10 11:41:12 -07:00
user
6fece9a78c fix: rename hashcash→pow_token JSON field reference in README 2026-03-10 11:40:33 -07:00
clawbot
b47a05a4d7 fix: complete pow_token rename in README documentation
Update 4 remaining references where the JSON field name was still
'hashcash' instead of 'pow_token' in README.md.
2026-03-10 11:40:33 -07:00
user
5b4ac75b23 rename JSON field 'hashcash' to 'pow_token' in API request body 2026-03-10 11:40:33 -07:00
clawbot
ba304e322e test: add hashcash validator tests with bits=2
Comprehensive test suite covering:
- Mint and validate with bits=2
- Replay detection
- Resource mismatch
- Invalid format, bad version, bad date
- Insufficient difficulty
- Expired stamps
- Zero bits bypass
- Long date format (YYMMDDHHMMSS)
- Multiple unique stamps
- Higher difficulty stamps accepted at lower threshold
2026-03-10 11:40:33 -07:00
clawbot
f5d1594150 refactor: remove dead doWithHeaders/extraHeaders code from CLI API client 2026-03-10 11:40:33 -07:00
clawbot
09a1dddbd0 refactor: move hashcash stamp from X-Hashcash header to JSON request body
Move the hashcash proof-of-work stamp from the X-Hashcash HTTP header
into the JSON request body as a 'hashcash' field on POST /api/v1/session.

Updated server handler, CLI client, SPA client, and documentation.
2026-03-10 11:40:33 -07:00
clawbot
db4cd9f055 refactor: move CLI code from cmd/ to internal/cli
Move all non-bootstrapping CLI code to internal/cli package.
cmd/neoirc-cli/main.go now contains only minimal bootstrapping
that calls cli.Run(). The App struct, UI, command handlers, poll
loop, and api client are now in internal/cli/ and internal/cli/api/.
2026-03-10 11:40:32 -07:00
clawbot
2f905bda9f fix: move hashcash PoW from build artifact to JSX source
The hashcash proof-of-work implementation was incorrectly added to the
build artifact web/dist/app.js instead of the source file web/src/app.jsx.
Running web/build.sh would overwrite all hashcash changes.

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

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

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

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

closes #11
2026-03-10 11:40:18 -07:00
23 changed files with 198 additions and 3061 deletions

419
README.md
View File

@@ -113,9 +113,8 @@ mechanisms or stuffing data into CTCP.
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
are unique per server. Identity starts with a key a nick is a 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.
are unique per server. There are no accounts — identity is a key, a nick is a
display name.
### On the resemblance to JSON-RPC
@@ -149,45 +148,16 @@ not arbitrary choices — each one follows from the project's core thesis that
IRC's command model is correct and only the transport and session management
need to change.
### Identity & Sessions — Dual Authentication Model
### Identity & Sessions — No Accounts
The server supports two authentication paths: **anonymous sessions** for
instant access, and **optional account registration** for multi-client access.
#### Anonymous Sessions (No Account Required)
The simplest entry point. No registration, no passwords.
There are no accounts, no registration, no passwords. Identity is a signing
key; a nick is just a display name. The two are decoupled.
- **Session creation**: client sends `POST /api/v1/session` with a desired
nick → server assigns an **auth token** (64 hex characters of
cryptographically random bytes) and returns the user ID, nick, and token.
- The auth token implicitly identifies the client. Clients present it via
`Authorization: Bearer <token>`.
- 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
the stable identity.
- Server-assigned IDs — clients do not choose their own IDs.
@@ -195,48 +165,11 @@ For users who want multi-client access (multiple devices sharing one session):
in the token, no client-side decode. The server is the sole authority on
token validity.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
Anonymous sessions preserve that simplicity — instant access, zero friction.
But some users want to access the same session from multiple devices without
a bouncer. Optional registration with password enables multi-client login
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.
### Hostmask (nick!user@host)
Each session has an IRC-style hostmask composed of three parts:
- **nick** — the user's current nick (changes with `NICK` command)
- **username** — an ident-like identifier set at session creation (optional
`username` field in the session/register request; defaults to the nick)
- **hostname** — automatically resolved via reverse DNS of the connecting
client's IP address at session creation time
- **ip** — the real IP address of the session creator, extracted from
`X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`
Each **client connection** (created at session creation, registration, or login)
also stores its own **ip** and **hostname**, allowing the server to track the
network origin of each individual client independently from the session.
Client-level IP and hostname are **not displayed to regular users**. They are
only visible to **server operators** (o-line) via `RPL_WHOISACTUALLY` (338)
when the oper performs a WHOIS on a user.
The hostmask appears in:
- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains
`[nick, username, hostname, "*"]`
- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a
server operator, includes the target's current client IP and hostname
- **WHO** (`352 RPL_WHOREPLY`) — `params` contains
`[channel, username, hostname, server, nick, flags]`
The hostmask format (`nick!user@host`) is stored for future use in ban matching
(`+b` mode) and other access control features.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding
registration, email verification, or OAuth would solve a problem nobody asked
about and add complexity that drives away casual users. Identity verification
is handled at the message layer via cryptographic signatures (see
[Security Model](#security-model)), not at the session layer.
### Nick Semantics
@@ -274,12 +207,12 @@ User Session
└── Client C (token_c, queue_c)
```
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
client to an existing registered session, enabling true multi-client support
(multiple tokens sharing one nick/session with independent message queues).
Anonymous sessions created via `POST /api/v1/session` always create a new
user with a new nick. A future endpoint to "add a client to an existing
anonymous session" is planned but not yet implemented.
**Current MVP note:** The current implementation creates a new user (with new
nick) per `POST /api/v1/session` call. True multi-client (multiple tokens
sharing one nick/session) is supported by the schema (`client_queues` is keyed
by user_id, and multiple tokens can point to the same user) but the session
creation endpoint does not yet support "add a client to an existing session."
This will be added post-MVP.
**Rationale:** The fundamental IRC mobile problem is that you can't have your
phone and laptop connected simultaneously without a bouncer. Server-side
@@ -394,8 +327,8 @@ needs to revoke a token, change the expiry model, or add/remove claims, JWT
clients may break or behave incorrectly.
Opaque tokens are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
- Client presents the raw token; server hashes and looks it up
- Server generates 32 random bytes → hex-encodes → stores hash
- Client presents the token; server looks it up
- Revocation is a database delete
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
- Token format can change without breaking clients
@@ -422,8 +355,6 @@ The entire read/write loop for a client is two endpoints. Everything else
### Session Lifecycle
#### Anonymous Session
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
@@ -454,30 +385,6 @@ The entire read/write loop for a client is two endpoints. Everything else
└────────────────────────────────────────────────────────────┘
```
#### Registered Account
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/register │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
│ (Session created with bcrypt-hashed password) │
│ │
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
│ │
│ (From another device, while session is still active) │
│ │
│ 2. POST /api/v1/login │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │
│ (New client added to existing session — channels │
│ and message queues are preserved. If all clients │
│ have logged out, session no longer exists.) │
│ │
└────────────────────────────────────────────────────────────┘
```
### Queue Architecture
```
@@ -914,12 +821,7 @@ for each channel followed by RPL_LISTEND (323).
#### WHOIS — User Information
Query information about a user. Returns RPL_WHOISUSER (311),
RPL_WHOISSERVER (312), RPL_WHOISOPERATOR (313, if target is oper),
RPL_WHOISIDLE (317), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
If the querying user is a **server operator** (authenticated via `OPER`),
the response additionally includes RPL_WHOISACTUALLY (338) with the
target's current client IP address and hostname.
RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
**C2S:**
```json
@@ -954,35 +856,6 @@ LUSERS replies are also sent automatically during connection registration.
**IRC reference:** RFC 1459 §4.3.2
#### OPER — Gain Server Operator Status
Authenticate as a server operator (o-line). On success, the session gains
oper privileges, which currently means additional information is visible in
WHOIS responses (e.g., target user's current client IP and hostname).
**C2S:**
```json
{"command": "OPER", "body": ["opername", "operpassword"]}
```
**S2C (via message queue on success):**
```json
{"command": "381", "to": "alice", "body": ["You are now an IRC operator"]}
```
**Behavior:**
- `body[0]` is the operator name, `body[1]` is the operator password.
- The server checks against the configured `NEOIRC_OPER_NAME` and
`NEOIRC_OPER_PASSWORD` environment variables.
- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER`
is returned.
- On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST`
is returned.
- Oper status persists for the session lifetime. There is no de-oper command.
**IRC reference:** RFC 1459 §4.1.5
#### KICK — Kick User (Planned)
Remove a user from a channel.
@@ -1041,26 +914,23 @@ the server to the client (never C2S) and use 3-digit string codes in the
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` |
| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` |
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` |
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}` |
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` |
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` |
| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` |
| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` |
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` |
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` |
| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` |
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` |
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` |
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}` |
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` |
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` |
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` |
@@ -1069,7 +939,6 @@ the server to the client (never C2S) and use 3-digit string codes in the
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` |
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` |
**Note:** Numeric replies are now implemented. All IRC command responses
(success and error) are delivered as numeric replies through the message queue.
@@ -1125,20 +994,14 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**Request Body:**
```json
{"nick": "alice", "username": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
```
| Field | Type | Required | Constraints |
|------------|--------|-------------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `username` | string | No | 132 characters, IRC ident-style. Defaults to nick if omitted. |
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
The `username` field sets the user portion of the IRC hostmask
(`nick!user@host`). The hostname is automatically resolved via reverse DNS of
the connecting client's IP address at session creation time. Together these form
the hostmask used in WHOIS, WHO, and future ban matching (`+b`).
**Response:** `201 Created`
```json
{
@@ -1159,7 +1022,6 @@ the hostmask used in WHOIS, WHO, and future ban matching (`+b`).
| Status | Error | When |
|--------|-------|------|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
| 400 | `invalid username format` | Username doesn't match allowed format |
| 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 |
@@ -1172,110 +1034,6 @@ TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
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", "username": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `username` | string | No | 132 characters, IRC ident-style. Defaults to nick if omitted. |
| `password` | string | Yes | Minimum 8 characters |
The `username` and hostname (auto-resolved via reverse DNS) form the IRC
hostmask (`nick!user@host`) shown in WHOIS and WHO responses.
**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 | `invalid username format` | Username doesn't match allowed format |
| 400 | `password must be at least 8 characters` | Password too short |
| 409 | `nick already taken` | Another active session holds this nick |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
```
### POST /api/v1/login — Login to Account
Authenticate with a previously registered nick and password. Creates a new
client token for the existing session, preserving channel memberships and
message queues. This is how multi-client access works for registered accounts:
each login adds a new client to the session.
On successful login, the server enqueues MOTD messages and synthetic channel
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
new client's queue, so the client can immediately restore its UI state.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | Must match a registered account |
| `password` | string | Yes | Must match the account's password |
**Response:** `200 OK`
```json
{
"id": 1,
"nick": "alice",
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Session ID (same as when registered) |
| `nick` | string | Current nick |
| `token` | string | New 64-character hex auth token for this client |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `nick and password required` | Missing nick or password |
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
```
### GET /api/v1/state — Get Session State
Return the current user's session state.
@@ -1424,7 +1182,6 @@ reference with all required and optional fields.
| `WHOIS` | `to` or `body` | | 200 OK |
| `WHO` | `to` | | 200 OK |
| `LUSERS` | | | 200 OK |
| `OPER` | `body` | | 200 OK |
| `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK |
@@ -1453,7 +1210,6 @@ auth tokens (401), and server errors (500).
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
| 491 | ERR_NOOPERHOST | Failed OPER authentication |
**IRC numeric success replies (delivered via message queue):**
@@ -1471,11 +1227,9 @@ auth tokens (401), and server errors (500).
| 255 | RPL_LUSERME | On connect or LUSERS command |
| 311 | RPL_WHOISUSER | WHOIS user info |
| 312 | RPL_WHOISSERVER | WHOIS server info |
| 313 | RPL_WHOISOPERATOR | WHOIS target is oper |
| 315 | RPL_ENDOFWHO | End of WHO list |
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) |
| 322 | RPL_LIST | Channel in LIST response |
| 323 | RPL_LISTEND | End of LIST |
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
@@ -1488,7 +1242,6 @@ auth tokens (401), and server errors (500).
| 375 | RPL_MOTDSTART | Start of MOTD |
| 372 | RPL_MOTD | MOTD line |
| 376 | RPL_ENDOFMOTD | End of MOTD |
| 381 | RPL_YOUREOPER | Successful OPER authentication |
### GET /api/v1/history — Message History
@@ -1646,40 +1399,13 @@ Return server metadata. No authentication required.
### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required. Returns server
health status and runtime statistics.
Standard health check endpoint. No authentication required.
**Response:** `200 OK`
```json
{
"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
}
{"status": "ok"}
```
| 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
@@ -1864,16 +1590,9 @@ authenticity.
### Authentication
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
Tokens are hashed (SHA-256) before storage and validated on every request.
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
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`.
Tokens are stored in the database and validated on every request.
- **No passwords**: Session creation requires only a nick. The token is the
sole credential.
- **Token security**: Tokens should be treated like session cookies. Transmit
only over HTTPS in production. If a token is compromised, the attacker has
full access to the session until QUIT or expiry.
@@ -2021,32 +1740,13 @@ The database schema is managed via embedded SQL migration files in
**Current tables:**
#### `sessions`
| Column | Type | Description |
|----------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick |
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP |
| `ip` | TEXT | Real IP address of the session creator |
| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) |
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key (empty string if unset) |
| `away_message` | TEXT | Away message (empty string if not away) |
| `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time |
#### `clients`
#### `users`
| 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) |
| `ip` | TEXT | Real IP address of this client connection |
| `hostname` | TEXT | Reverse DNS hostname of this client connection |
| `created_at`| DATETIME | Client creation time |
| `nick` | TEXT | Unique nick |
| `token` | TEXT | Unique auth token (64 hex chars) |
| `created_at`| DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time |
#### `channels`
@@ -2103,19 +1803,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Client output queue entries**: Pruned automatically when older than
`QUEUE_MAX_AGE` (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
or when the last client logs out (`POST /api/v1/logout` with no remaining
clients triggers session cleanup). There is no distinction between session
types in the cleanup path — `handleQuit` and `cleanupUser` both call
`DeleteSession` unconditionally. Idle sessions are automatically expired
after `SESSION_IDLE_TIMEOUT`
(default 30 days) — the server runs a background cleanup loop that parts
idle users from all channels, broadcasts QUIT, and releases their nicks.
- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`.
A session can have multiple clients; removing one doesn't affect others.
However, when the last client is removed (via logout), the entire session
is deleted — the user is parted from all channels, QUIT is broadcast, and
the nick is released.
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
30 days) — the server runs a background cleanup loop that parts idle users
from all channels, broadcasts QUIT, and releases their nicks.
---
@@ -2143,8 +1834,6 @@ directory is also loaded automatically via
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
### Example `.env` file
@@ -2266,21 +1955,11 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl
```bash
# 1a. Create an anonymous session (no account)
# 1. Create a session
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-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
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
@@ -2413,11 +2092,9 @@ Clients should handle these message commands from the queue:
### Error Handling
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
re-login (registered account).
- **HTTP 401**: Token expired or invalid. Re-create session.
- **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation, registration, or
NICK change).
- **HTTP 409**: Nick already taken (on session creation or NICK change).
- **HTTP 400**: Malformed request. Check the `error` field in the response.
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
@@ -2434,10 +2111,8 @@ Clients should handle these message commands from the queue:
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
if `from` is you, use `to`; if `from` is someone else, use `from`.
5. **Reconnection**: If the poll loop fails with 401, the token is invalid.
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.
5. **Reconnection**: If the poll loop fails with 401, the session is gone.
Create a new session. If it fails with a network error, retry with backoff.
---
@@ -2657,8 +2332,6 @@ neoirc/
│ │ └── healthcheck.go # Health check handler
│ ├── healthcheck/ # Health check logic
│ │ └── healthcheck.go
│ ├── stats/ # Runtime statistics (atomic counters)
│ │ └── stats.go
│ ├── logger/ # slog-based logging
│ │ └── logger.go
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
@@ -2710,13 +2383,9 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the
API is too complex.
2. **Accounts optional** — anonymous sessions are instant: pick a nick and
talk. No registration, no email verification. The cost of entry is a
hashcash proof, not bureaucracy. For users who want multi-client access
(multiple devices sharing one session), optional account registration
with password is available — but never required. Identity
verification at the message layer uses cryptographic signing,
independent of account status.
2. **No accounts** — identity is a signing key, nick is a display name. No
registration, no passwords, no email verification. Session creation is
instant. The cost of entry is a hashcash proof, not bureaucracy.
3. **IRC semantics over HTTP** — command names and numeric codes from
RFC 1459/2812. If you've built an IRC client or bot, you already know the

View File

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

2
go.mod
View File

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

4
go.sum
View File

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

View File

@@ -46,8 +46,6 @@ type Config struct {
FederationKey string
SessionIdleTimeout string
HashcashBits int
OperName string
OperPassword string
params *Params
log *slog.Logger
}
@@ -80,8 +78,6 @@ func New(
viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
viper.SetDefault("NEOIRC_OPER_NAME", "")
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
err := viper.ReadInConfig()
if err != nil {
@@ -108,8 +104,6 @@ func New(
FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
log: log,
params: &params,
}

View File

@@ -20,12 +20,8 @@ var errNoPassword = errors.New(
// and returns session ID, client ID, and token.
func (database *Database) RegisterUser(
ctx context.Context,
nick, password, username, hostname, remoteIP string,
nick, password string,
) (int64, int64, string, error) {
if username == "" {
username = nick
}
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
@@ -54,11 +50,10 @@ func (database *Database) RegisterUser(
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, username, hostname, ip,
password_hash, created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
sessionUUID, nick, username, hostname,
remoteIP, string(hash), now, now)
(uuid, nick, password_hash,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
sessionUUID, nick, string(hash), now, now)
if err != nil {
_ = transaction.Rollback()
@@ -73,11 +68,10 @@ func (database *Database) RegisterUser(
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token, ip, hostname,
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash,
remoteIP, hostname, now, now)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
_ = transaction.Rollback()
@@ -102,7 +96,7 @@ func (database *Database) RegisterUser(
// client token.
func (database *Database) LoginUser(
ctx context.Context,
nick, password, remoteIP, hostname string,
nick, password string,
) (int64, int64, string, error) {
var (
sessionID int64
@@ -149,11 +143,10 @@ func (database *Database) LoginUser(
res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token, ip, hostname,
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash,
remoteIP, hostname, now, now)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
return 0, 0, "", fmt.Errorf(
"create login client: %w", err,

View File

@@ -13,7 +13,7 @@ func TestRegisterUser(t *testing.T) {
ctx := t.Context()
sessionID, clientID, token, err :=
database.RegisterUser(ctx, "reguser", "password123", "", "", "")
database.RegisterUser(ctx, "reguser", "password123")
if err != nil {
t.Fatal(err)
}
@@ -38,69 +38,6 @@ func TestRegisterUser(t *testing.T) {
}
}
func TestRegisterUserWithUserHost(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.RegisterUser(
ctx, "reguhost", "password123",
"myident", "example.org", "",
)
if err != nil {
t.Fatal(err)
}
info, err := database.GetSessionHostInfo(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if info.Username != "myident" {
t.Fatalf(
"expected myident, got %s", info.Username,
)
}
if info.Hostname != "example.org" {
t.Fatalf(
"expected example.org, got %s",
info.Hostname,
)
}
}
func TestRegisterUserDefaultUsername(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.RegisterUser(
ctx, "regdefault", "password123", "", "", "",
)
if err != nil {
t.Fatal(err)
}
info, err := database.GetSessionHostInfo(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if info.Username != "regdefault" {
t.Fatalf(
"expected regdefault, got %s",
info.Username,
)
}
}
func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel()
@@ -108,7 +45,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) {
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "dupnick", "password123", "", "", "")
database.RegisterUser(ctx, "dupnick", "password123")
if err != nil {
t.Fatal(err)
}
@@ -118,7 +55,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) {
_ = regToken
dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345", "", "", "")
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
}
@@ -135,7 +72,7 @@ func TestLoginUser(t *testing.T) {
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "loginuser", "mypassword", "", "", "")
database.RegisterUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
@@ -145,7 +82,7 @@ func TestLoginUser(t *testing.T) {
_ = regToken
sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
@@ -166,83 +103,6 @@ func TestLoginUser(t *testing.T) {
}
}
func TestLoginUserStoresClientIPHostname(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err := database.RegisterUser(
ctx, "loginipuser", "password123",
"", "", "10.0.0.1",
)
_ = regSID
_ = regCID
_ = regToken
if err != nil {
t.Fatal(err)
}
_, clientID, _, err := database.LoginUser(
ctx, "loginipuser", "password123",
"10.0.0.99", "newhost.example.com",
)
if err != nil {
t.Fatal(err)
}
clientInfo, err := database.GetClientHostInfo(
ctx, clientID,
)
if err != nil {
t.Fatal(err)
}
if clientInfo.IP != "10.0.0.99" {
t.Fatalf(
"expected client IP 10.0.0.99, got %s",
clientInfo.IP,
)
}
if clientInfo.Hostname != "newhost.example.com" {
t.Fatalf(
"expected hostname newhost.example.com, got %s",
clientInfo.Hostname,
)
}
}
func TestRegisterUserStoresSessionIP(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.RegisterUser(
ctx, "regipuser", "password123",
"ident", "host.local", "172.16.0.5",
)
if err != nil {
t.Fatal(err)
}
info, err := database.GetSessionHostInfo(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if info.IP != "172.16.0.5" {
t.Fatalf(
"expected session IP 172.16.0.5, got %s",
info.IP,
)
}
}
func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel()
@@ -250,7 +110,7 @@ func TestLoginUserWrongPassword(t *testing.T) {
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass", "", "", "")
database.RegisterUser(ctx, "wrongpw", "correctpass")
if err != nil {
t.Fatal(err)
}
@@ -260,7 +120,7 @@ func TestLoginUserWrongPassword(t *testing.T) {
_ = regToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
@@ -278,7 +138,7 @@ func TestLoginUserNoPassword(t *testing.T) {
// Create anonymous session (no password).
anonSID, anonCID, anonToken, err :=
database.CreateSession(ctx, "anon", "", "", "")
database.CreateSession(ctx, "anon")
if err != nil {
t.Fatal(err)
}
@@ -288,7 +148,7 @@ func TestLoginUserNoPassword(t *testing.T) {
_ = anonToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "anon", "anything1", "", "")
database.LoginUser(ctx, "anon", "anything1")
if loginErr == nil {
t.Fatal(
"expected error for login on passwordless account",
@@ -307,7 +167,7 @@ func TestLoginUserNonexistent(t *testing.T) {
ctx := t.Context()
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "ghost", "password123", "", "")
database.LoginUser(ctx, "ghost", "password123")
if err == nil {
t.Fatal("expected error for nonexistent user")
}

View File

@@ -74,40 +74,14 @@ type ChannelInfo struct {
type MemberInfo struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Username string `json:"username"`
Hostname string `json:"hostname"`
LastSeen time.Time `json:"lastSeen"`
}
// Hostmask returns the IRC hostmask in
// nick!user@host format.
func (m *MemberInfo) Hostmask() string {
return FormatHostmask(m.Nick, m.Username, m.Hostname)
}
// FormatHostmask formats a nick, username, and hostname
// into a standard IRC hostmask string (nick!user@host).
func FormatHostmask(nick, username, hostname string) string {
if username == "" {
username = nick
}
if hostname == "" {
hostname = "*"
}
return nick + "!" + username + "@" + hostname
}
// CreateSession registers a new session and its first client.
func (database *Database) CreateSession(
ctx context.Context,
nick, username, hostname, remoteIP string,
nick string,
) (int64, int64, string, error) {
if username == "" {
username = nick
}
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
@@ -127,11 +101,9 @@ func (database *Database) CreateSession(
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, username, hostname, ip,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sessionUUID, nick, username, hostname,
remoteIP, now, now)
(uuid, nick, created_at, last_seen)
VALUES (?, ?, ?, ?)`,
sessionUUID, nick, now, now)
if err != nil {
_ = transaction.Rollback()
@@ -146,11 +118,10 @@ func (database *Database) CreateSession(
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token, ip, hostname,
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash,
remoteIP, hostname, now, now)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
_ = transaction.Rollback()
@@ -238,135 +209,6 @@ func (database *Database) GetSessionByNick(
return sessionID, nil
}
// SessionHostInfo holds the username, hostname, and IP
// for a session.
type SessionHostInfo struct {
Username string
Hostname string
IP string
}
// GetSessionHostInfo returns the username, hostname,
// and IP for a session.
func (database *Database) GetSessionHostInfo(
ctx context.Context,
sessionID int64,
) (*SessionHostInfo, error) {
var info SessionHostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT username, hostname, ip
FROM sessions WHERE id = ?`,
sessionID,
).Scan(&info.Username, &info.Hostname, &info.IP)
if err != nil {
return nil, fmt.Errorf(
"get session host info: %w", err,
)
}
return &info, nil
}
// ClientHostInfo holds the IP and hostname for a client.
type ClientHostInfo struct {
IP string
Hostname string
}
// GetClientHostInfo returns the IP and hostname for a
// client.
func (database *Database) GetClientHostInfo(
ctx context.Context,
clientID int64,
) (*ClientHostInfo, error) {
var info ClientHostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT ip, hostname
FROM clients WHERE id = ?`,
clientID,
).Scan(&info.IP, &info.Hostname)
if err != nil {
return nil, fmt.Errorf(
"get client host info: %w", err,
)
}
return &info, nil
}
// SetSessionOper sets the is_oper flag on a session.
func (database *Database) SetSessionOper(
ctx context.Context,
sessionID int64,
isOper bool,
) error {
val := 0
if isOper {
val = 1
}
_, err := database.conn.ExecContext(
ctx,
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
val, sessionID,
)
if err != nil {
return fmt.Errorf("set session oper: %w", err)
}
return nil
}
// IsSessionOper returns whether the session has oper
// status.
func (database *Database) IsSessionOper(
ctx context.Context,
sessionID int64,
) (bool, error) {
var isOper int
err := database.conn.QueryRowContext(
ctx,
`SELECT is_oper FROM sessions WHERE id = ?`,
sessionID,
).Scan(&isOper)
if err != nil {
return false, fmt.Errorf(
"check session oper: %w", err,
)
}
return isOper != 0, nil
}
// GetLatestClientForSession returns the IP and hostname
// of the most recently created client for a session.
func (database *Database) GetLatestClientForSession(
ctx context.Context,
sessionID int64,
) (*ClientHostInfo, error) {
var info ClientHostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT ip, hostname FROM clients
WHERE session_id = ?
ORDER BY created_at DESC LIMIT 1`,
sessionID,
).Scan(&info.IP, &info.Hostname)
if err != nil {
return nil, fmt.Errorf(
"get latest client for session: %w", err,
)
}
return &info, nil
}
// GetChannelByName returns the channel ID for a name.
func (database *Database) GetChannelByName(
ctx context.Context,
@@ -546,8 +388,7 @@ func (database *Database) ChannelMembers(
channelID int64,
) ([]MemberInfo, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT s.id, s.nick, s.username,
s.hostname, s.last_seen
`SELECT s.id, s.nick, s.last_seen
FROM sessions s
INNER JOIN channel_members cm
ON cm.session_id = s.id
@@ -567,9 +408,7 @@ func (database *Database) ChannelMembers(
var member MemberInfo
err = rows.Scan(
&member.ID, &member.Nick,
&member.Username, &member.Hostname,
&member.LastSeen,
&member.ID, &member.Nick, &member.LastSeen,
)
if err != nil {
return nil, fmt.Errorf(
@@ -1020,26 +859,6 @@ func (database *Database) GetUserCount(
return count, nil
}
// GetOperCount returns the number of sessions with oper
// status.
func (database *Database) GetOperCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM sessions WHERE is_oper = 1",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get oper count: %w", err,
)
}
return count, nil
}
// ClientCountForSession returns the number of clients
// belonging to a session.
func (database *Database) ClientCountForSession(
@@ -1291,121 +1110,6 @@ func (database *Database) GetSessionCreatedAt(
return createdAt, nil
}
// SetAway sets the away message for a session.
// An empty message clears the away status.
func (database *Database) SetAway(
ctx context.Context,
sessionID int64,
message string,
) error {
_, err := database.conn.ExecContext(ctx,
"UPDATE sessions SET away_message = ? WHERE id = ?",
message, sessionID)
if err != nil {
return fmt.Errorf("set away: %w", err)
}
return nil
}
// GetAway returns the away message for a session.
// Returns an empty string if the user is not away.
func (database *Database) GetAway(
ctx context.Context,
sessionID int64,
) (string, error) {
var msg string
err := database.conn.QueryRowContext(ctx,
"SELECT away_message FROM sessions WHERE id = ?",
sessionID,
).Scan(&msg)
if err != nil {
return "", fmt.Errorf("get away: %w", err)
}
return msg, nil
}
// SetTopicMeta sets the topic along with who set it and
// when.
func (database *Database) SetTopicMeta(
ctx context.Context,
channelName, topic, setBy string,
) error {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET topic = ?, topic_set_by = ?,
topic_set_at = ?, updated_at = ?
WHERE name = ?`,
topic, setBy, now, now, channelName)
if err != nil {
return fmt.Errorf("set topic meta: %w", err)
}
return nil
}
// TopicMeta holds topic metadata for a channel.
type TopicMeta struct {
SetBy string
SetAt time.Time
}
// GetTopicMeta returns who set the topic and when.
func (database *Database) GetTopicMeta(
ctx context.Context,
channelID int64,
) (*TopicMeta, error) {
var (
setBy string
setAt sql.NullTime
)
err := database.conn.QueryRowContext(ctx,
`SELECT topic_set_by, topic_set_at
FROM channels WHERE id = ?`,
channelID,
).Scan(&setBy, &setAt)
if err != nil {
return nil, fmt.Errorf(
"get topic meta: %w", err,
)
}
if setBy == "" || !setAt.Valid {
return nil, nil //nolint:nilnil
}
return &TopicMeta{
SetBy: setBy,
SetAt: setAt.Time,
}, nil
}
// GetSessionLastSeen returns the last_seen time for a
// session.
func (database *Database) GetSessionLastSeen(
ctx context.Context,
sessionID int64,
) (time.Time, error) {
var lastSeen time.Time
err := database.conn.QueryRowContext(ctx,
"SELECT last_seen FROM sessions WHERE id = ?",
sessionID,
).Scan(&lastSeen)
if err != nil {
return time.Time{}, fmt.Errorf(
"get session last_seen: %w", err,
)
}
return lastSeen, nil
}
// PruneOldQueueEntries deletes client output queue entries
// older than cutoff and returns the number of rows removed.
func (database *Database) PruneOldQueueEntries(
@@ -1447,42 +1151,3 @@ func (database *Database) PruneOldMessages(
return deleted, nil
}
// GetClientCount returns the total number of clients.
func (database *Database) GetClientCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM clients",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get client count: %w", err,
)
}
return count, nil
}
// GetQueueEntryCount returns the total number of entries
// in the client output queues.
func (database *Database) GetQueueEntryCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM client_queues",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get queue entry count: %w", err,
)
}
return count, nil
}

View File

@@ -34,7 +34,7 @@ func TestCreateSession(t *testing.T) {
ctx := t.Context()
sessionID, _, token, err := database.CreateSession(
ctx, "alice", "", "", "",
ctx, "alice",
)
if err != nil {
t.Fatal(err)
@@ -45,7 +45,7 @@ func TestCreateSession(t *testing.T) {
}
_, _, dupToken, dupErr := database.CreateSession(
ctx, "alice", "", "", "",
ctx, "alice",
)
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
@@ -54,249 +54,13 @@ func TestCreateSession(t *testing.T) {
_ = dupToken
}
// assertSessionHostInfo creates a session and verifies
// the stored username and hostname match expectations.
func assertSessionHostInfo(
t *testing.T,
database *db.Database,
nick, inputUser, inputHost,
expectUser, expectHost string,
) {
t.Helper()
sessionID, _, _, err := database.CreateSession(
t.Context(), nick, inputUser, inputHost, "",
)
if err != nil {
t.Fatal(err)
}
info, err := database.GetSessionHostInfo(
t.Context(), sessionID,
)
if err != nil {
t.Fatal(err)
}
if info.Username != expectUser {
t.Fatalf(
"expected username %s, got %s",
expectUser, info.Username,
)
}
if info.Hostname != expectHost {
t.Fatalf(
"expected hostname %s, got %s",
expectHost, info.Hostname,
)
}
}
func TestCreateSessionWithUserHost(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
assertSessionHostInfo(
t, database,
"hostuser", "myident", "example.com",
"myident", "example.com",
)
}
func TestCreateSessionDefaultUsername(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
// Empty username defaults to nick.
assertSessionHostInfo(
t, database,
"defaultu", "", "host.local",
"defaultu", "host.local",
)
}
func TestCreateSessionStoresIP(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, clientID, _, err := database.CreateSession(
ctx, "ipuser", "ident", "host.example.com",
"192.168.1.42",
)
if err != nil {
t.Fatal(err)
}
info, err := database.GetSessionHostInfo(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if info.IP != "192.168.1.42" {
t.Fatalf(
"expected session IP 192.168.1.42, got %s",
info.IP,
)
}
clientInfo, err := database.GetClientHostInfo(
ctx, clientID,
)
if err != nil {
t.Fatal(err)
}
if clientInfo.IP != "192.168.1.42" {
t.Fatalf(
"expected client IP 192.168.1.42, got %s",
clientInfo.IP,
)
}
if clientInfo.Hostname != "host.example.com" {
t.Fatalf(
"expected client hostname host.example.com, got %s",
clientInfo.Hostname,
)
}
}
func TestGetClientHostInfoNotFound(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
_, err := database.GetClientHostInfo(
t.Context(), 99999,
)
if err == nil {
t.Fatal("expected error for nonexistent client")
}
}
func TestGetSessionHostInfoNotFound(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
_, err := database.GetSessionHostInfo(
t.Context(), 99999,
)
if err == nil {
t.Fatal("expected error for nonexistent session")
}
}
func TestFormatHostmask(t *testing.T) {
t.Parallel()
result := db.FormatHostmask(
"nick", "user", "host.com",
)
if result != "nick!user@host.com" {
t.Fatalf(
"expected nick!user@host.com, got %s",
result,
)
}
}
func TestFormatHostmaskDefaults(t *testing.T) {
t.Parallel()
result := db.FormatHostmask("nick", "", "")
if result != "nick!nick@*" {
t.Fatalf(
"expected nick!nick@*, got %s",
result,
)
}
}
func TestMemberInfoHostmask(t *testing.T) {
t.Parallel()
member := &db.MemberInfo{ //nolint:exhaustruct // test only uses hostmask fields
Nick: "alice",
Username: "aliceident",
Hostname: "alice.example.com",
}
hostmask := member.Hostmask()
expected := "alice!aliceident@alice.example.com"
if hostmask != expected {
t.Fatalf(
"expected %s, got %s", expected, hostmask,
)
}
}
func TestChannelMembersIncludeUserHost(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, _, _, err := database.CreateSession(
ctx, "memuser", "myuser", "myhost.net", "",
)
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(
ctx, "#hostchan",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
members, err := database.ChannelMembers(ctx, chID)
if err != nil {
t.Fatal(err)
}
if len(members) != 1 {
t.Fatalf(
"expected 1 member, got %d", len(members),
)
}
if members[0].Username != "myuser" {
t.Fatalf(
"expected username myuser, got %s",
members[0].Username,
)
}
if members[0].Hostname != "myhost.net" {
t.Fatalf(
"expected hostname myhost.net, got %s",
members[0].Hostname,
)
}
}
func TestGetSessionByToken(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
_, _, token, err := database.CreateSession(ctx, "bob", "", "", "")
_, _, token, err := database.CreateSession(ctx, "bob")
if err != nil {
t.Fatal(err)
}
@@ -329,7 +93,7 @@ func TestGetSessionByNick(t *testing.T) {
ctx := t.Context()
charlieID, charlieClientID, charlieToken, err :=
database.CreateSession(ctx, "charlie", "", "", "")
database.CreateSession(ctx, "charlie")
if err != nil {
t.Fatal(err)
}
@@ -386,7 +150,7 @@ func TestJoinAndPart(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
sid, _, _, err := database.CreateSession(ctx, "user1", "", "", "")
sid, _, _, err := database.CreateSession(ctx, "user1")
if err != nil {
t.Fatal(err)
}
@@ -435,7 +199,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
t.Fatal(err)
}
sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
sid, _, _, err := database.CreateSession(ctx, "temp")
if err != nil {
t.Fatal(err)
}
@@ -470,7 +234,7 @@ func createSessionWithChannels(
ctx := t.Context()
sid, _, _, err := database.CreateSession(ctx, nick, "", "", "")
sid, _, _, err := database.CreateSession(ctx, nick)
if err != nil {
t.Fatal(err)
}
@@ -553,7 +317,7 @@ func TestChangeNick(t *testing.T) {
ctx := t.Context()
sid, _, token, err := database.CreateSession(
ctx, "old", "", "", "",
ctx, "old",
)
if err != nil {
t.Fatal(err)
@@ -637,7 +401,7 @@ func TestPollMessages(t *testing.T) {
ctx := t.Context()
sid, _, token, err := database.CreateSession(
ctx, "poller", "", "", "",
ctx, "poller",
)
if err != nil {
t.Fatal(err)
@@ -744,7 +508,7 @@ func TestDeleteSession(t *testing.T) {
ctx := t.Context()
sid, _, _, err := database.CreateSession(
ctx, "deleteme", "", "", "",
ctx, "deleteme",
)
if err != nil {
t.Fatal(err)
@@ -784,12 +548,12 @@ func TestChannelMembers(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
sid1, _, _, err := database.CreateSession(ctx, "m1", "", "", "")
sid1, _, _, err := database.CreateSession(ctx, "m1")
if err != nil {
t.Fatal(err)
}
sid2, _, _, err := database.CreateSession(ctx, "m2", "", "", "")
sid2, _, _, err := database.CreateSession(ctx, "m2")
if err != nil {
t.Fatal(err)
}
@@ -847,7 +611,7 @@ func TestEnqueueToClient(t *testing.T) {
ctx := t.Context()
_, _, token, err := database.CreateSession(
ctx, "enqclient", "", "", "",
ctx, "enqclient",
)
if err != nil {
t.Fatal(err)
@@ -887,133 +651,3 @@ func TestEnqueueToClient(t *testing.T) {
t.Fatalf("expected 1, got %d", len(msgs))
}
}
func TestSetAndCheckSessionOper(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "opernick", "", "", "",
)
if err != nil {
t.Fatal(err)
}
// Initially not oper.
isOper, err := database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if isOper {
t.Fatal("expected session not to be oper")
}
// Set oper.
err = database.SetSessionOper(ctx, sessionID, true)
if err != nil {
t.Fatal(err)
}
isOper, err = database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if !isOper {
t.Fatal("expected session to be oper")
}
// Unset oper.
err = database.SetSessionOper(ctx, sessionID, false)
if err != nil {
t.Fatal(err)
}
isOper, err = database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if isOper {
t.Fatal("expected session not to be oper")
}
}
func TestGetLatestClientForSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "clientnick", "", "", "10.0.0.1",
)
if err != nil {
t.Fatal(err)
}
clientInfo, err := database.GetLatestClientForSession(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if clientInfo.IP != "10.0.0.1" {
t.Fatalf(
"expected IP 10.0.0.1, got %s",
clientInfo.IP,
)
}
}
func TestGetOperCount(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
// Create two sessions.
sid1, _, _, err := database.CreateSession(
ctx, "user1", "", "", "",
)
if err != nil {
t.Fatal(err)
}
sid2, _, _, err := database.CreateSession(
ctx, "user2", "", "", "",
)
_ = sid2
if err != nil {
t.Fatal(err)
}
// Initially zero opers.
count, err := database.GetOperCount(ctx)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("expected 0 opers, got %d", count)
}
// Set one as oper.
err = database.SetSessionOper(ctx, sid1, true)
if err != nil {
t.Fatal(err)
}
count, err = database.GetOperCount(ctx)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected 1 oper, got %d", count)
}
}

View File

@@ -6,13 +6,8 @@ CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
nick TEXT NOT NULL UNIQUE,
username TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -24,8 +19,6 @@ CREATE TABLE IF NOT EXISTS clients (
uuid TEXT NOT NULL UNIQUE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -37,8 +30,6 @@ CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL DEFAULT '',
topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -2,10 +2,8 @@ package handlers
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"net"
"net/http"
"regexp"
"strconv"
@@ -14,7 +12,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi"
)
var validNickRe = regexp.MustCompile(
@@ -25,12 +23,6 @@ var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`,
)
var validUsernameRe = regexp.MustCompile(
`^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`,
)
const dnsLookupTimeout = 3 * time.Second
const (
maxLongPollTimeout = 30
pollMessageLimit = 100
@@ -47,55 +39,6 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize
}
// clientIP extracts the connecting client's IP address
// from the request, checking X-Forwarded-For and
// X-Real-IP headers before falling back to RemoteAddr.
func clientIP(request *http.Request) string {
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
// X-Forwarded-For can contain a comma-separated list;
// the first entry is the original client.
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd
ip := strings.TrimSpace(parts[0])
if ip != "" {
return ip
}
}
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
return strings.TrimSpace(realIP)
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
// resolveHostname performs a reverse DNS lookup on the
// given IP address. Returns the first PTR record with the
// trailing dot stripped, or the raw IP if lookup fails.
func resolveHostname(
reqCtx context.Context,
addr string,
) string {
resolver := &net.Resolver{} //nolint:exhaustruct // using default resolver
ctx, cancel := context.WithTimeout(
reqCtx, dnsLookupTimeout,
)
defer cancel()
names, err := resolver.LookupAddr(ctx, addr)
if err != nil || len(names) == 0 {
return addr
}
return strings.TrimSuffix(names[0], ".")
}
// authSession extracts the session from the client token.
func (hdlr *Handlers) authSession(
request *http.Request,
@@ -128,10 +71,11 @@ func (hdlr *Handlers) requireAuth(
sessionID, clientID, nick, err :=
hdlr.authSession(request)
if err != nil {
hdlr.respondJSON(writer, request, map[string]any{
"error": "not registered",
"numeric": irc.ErrNotRegistered,
}, http.StatusUnauthorized)
hdlr.respondError(
writer, request,
"unauthorized",
http.StatusUnauthorized,
)
return 0, 0, "", false
}
@@ -203,7 +147,6 @@ func (hdlr *Handlers) handleCreateSession(
) {
type createRequest struct {
Nick string `json:"nick"`
Username string `json:"username,omitempty"`
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
}
@@ -220,10 +163,30 @@ func (hdlr *Handlers) handleCreateSession(
return
}
if !hdlr.validateHashcash(
writer, request, payload.Hashcash,
) {
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)
@@ -238,40 +201,9 @@ func (hdlr *Handlers) handleCreateSession(
return
}
username := resolveUsername(
payload.Username, payload.Nick,
)
if !validUsernameRe.MatchString(username) {
hdlr.respondError(
writer, request,
"invalid username format",
http.StatusBadRequest,
)
return
}
hdlr.executeCreateSession(
writer, request, payload.Nick, username,
)
}
func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter,
request *http.Request,
nick, username string,
) {
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession(
request.Context(),
nick, username, hostname, remoteIP,
request.Context(), payload.Nick,
)
if err != nil {
hdlr.handleCreateSessionError(
@@ -281,67 +213,15 @@ func (hdlr *Handlers) executeCreateSession(
return
}
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, nick)
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": nick,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
// validateHashcash validates a hashcash stamp if required.
// Returns false if validation failed and a response was
// already sent.
func (hdlr *Handlers) validateHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
if hdlr.params.Config.HashcashBits == 0 {
return true
}
if stamp == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return false
}
err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// resolveUsername returns the trimmed username, defaulting
// to the nick if empty.
func resolveUsername(username, nick string) string {
username = strings.TrimSpace(username)
if username == "" {
return nick
}
return username
}
func (hdlr *Handlers) handleCreateSessionError(
writer http.ResponseWriter,
request *http.Request,
@@ -461,19 +341,9 @@ func (hdlr *Handlers) deliverLusers(
)
// 252 RPL_LUSEROP
operCount, operErr := hdlr.params.Database.
GetOperCount(ctx)
if operErr != nil {
hdlr.log.Error(
"lusers oper count", "error", operErr,
)
operCount = 0
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserOp, nick,
[]string{strconv.FormatInt(operCount, 10)},
[]string{"0"},
"operator(s) online",
)
@@ -967,11 +837,6 @@ func (hdlr *Handlers) dispatchCommand(
bodyLines func() []string,
) {
switch command {
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPrivmsg, irc.CmdNotice:
hdlr.handlePrivmsg(
writer, request,
@@ -1003,11 +868,6 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdMotd, irc.CmdPing:
hdlr.dispatchInfoCommand(
writer, request,
@@ -1087,8 +947,8 @@ func (hdlr *Handlers) handlePrivmsg(
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNoRecipient, nick, []string{command},
"No recipient given",
irc.ErrNeedMoreParams, nick, []string{command},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -1102,8 +962,8 @@ func (hdlr *Handlers) handlePrivmsg(
if len(lines) == 0 {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNoTextToSend, nick, []string{command},
"No text to send",
irc.ErrNeedMoreParams, nick, []string{command},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -1113,8 +973,6 @@ func (hdlr *Handlers) handlePrivmsg(
return
}
hdlr.stats.IncrMessages()
if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg(
writer, request,
@@ -1192,8 +1050,8 @@ func (hdlr *Handlers) handleChannelMsg(
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Cannot send to channel",
irc.ErrNotOnChannel, nick, []string{target},
"You're not on that channel",
)
return
@@ -1289,19 +1147,6 @@ func (hdlr *Handlers) handleDirectMsg(
return
}
// If the target is away, send RPL_AWAY to the sender.
awayMsg, awayErr := hdlr.params.Database.GetAway(
request.Context(), targetSID,
)
if awayErr == nil && awayMsg != "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplAway, nick,
[]string{target}, awayMsg,
)
hdlr.broker.Notify(sessionID)
}
hdlr.respondJSON(writer, request,
map[string]string{"id": msgUUID, "status": "sent"},
http.StatusOK)
@@ -1412,25 +1257,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
) {
ctx := request.Context()
hdlr.deliverTopicNumerics(
ctx, clientID, sessionID, nick, channel, chID,
chInfo, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err == nil {
_ = chInfo // chInfo is the ID; topic comes from DB.
}
hdlr.deliverNamesNumerics(
ctx, clientID, nick, channel, chID,
)
hdlr.broker.Notify(sessionID)
}
// deliverTopicNumerics sends RPL_TOPIC or RPL_NOTOPIC,
// plus RPL_TOPICWHOTIME when topic metadata is available.
func (hdlr *Handlers) deliverTopicNumerics(
ctx context.Context,
clientID, sessionID int64,
nick, channel string,
chID int64,
) {
// Get topic from channel info.
channels, listErr := hdlr.params.Database.ListChannels(
ctx, sessionID,
)
@@ -1452,54 +1286,29 @@ func (hdlr *Handlers) deliverTopicNumerics(
ctx, clientID, irc.RplTopic, nick,
[]string{channel}, topic,
)
topicMeta, tmErr := hdlr.params.Database.
GetTopicMeta(ctx, chID)
if tmErr == nil && topicMeta != nil {
hdlr.enqueueNumeric(
ctx, clientID,
irc.RplTopicWhoTime, nick,
[]string{
channel,
topicMeta.SetBy,
strconv.FormatInt(
topicMeta.SetAt.Unix(), 10,
),
},
"",
)
}
} else {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNoTopic, nick,
[]string{channel}, "No topic is set",
)
}
}
// deliverNamesNumerics sends RPL_NAMREPLY and
// RPL_ENDOFNAMES for a channel.
func (hdlr *Handlers) deliverNamesNumerics(
ctx context.Context,
clientID int64,
nick, channel string,
chID int64,
) {
// Get member list for NAMES reply.
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if memErr == nil && len(members) > 0 {
entries := make([]string, 0, len(members))
nicks := make([]string, 0, len(members))
for _, mem := range members {
entries = append(entries, mem.Hostmask())
nicks = append(nicks, mem.Nick)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNamReply, nick,
[]string{"=", channel},
strings.Join(entries, " "),
strings.Join(nicks, " "),
)
}
@@ -1507,6 +1316,8 @@ func (hdlr *Handlers) deliverNamesNumerics(
ctx, clientID, irc.RplEndOfNames, nick,
[]string{channel}, "End of /NAMES list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handlePart(
@@ -1774,32 +1585,6 @@ func (hdlr *Handlers) handleTopic(
return
}
isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
"check membership failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick, []string{channel},
"You're not on that channel",
)
return
}
hdlr.executeTopic(
writer, request,
sessionID, clientID, nick,
@@ -1816,8 +1601,8 @@ func (hdlr *Handlers) executeTopic(
body json.RawMessage,
chID int64,
) {
setErr := hdlr.params.Database.SetTopicMeta(
request.Context(), channel, topic, nick,
setErr := hdlr.params.Database.SetTopic(
request.Context(), channel, topic,
)
if setErr != nil {
hdlr.log.Error(
@@ -1844,25 +1629,6 @@ func (hdlr *Handlers) executeTopic(
request.Context(), clientID,
irc.RplTopic, nick, []string{channel}, topic,
)
// 333 RPL_TOPICWHOTIME
topicMeta, tmErr := hdlr.params.Database.
GetTopicMeta(request.Context(), chID)
if tmErr == nil && topicMeta != nil {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplTopicWhoTime, nick,
[]string{
channel,
topicMeta.SetBy,
strconv.FormatInt(
topicMeta.SetAt.Unix(), 10,
),
},
"",
)
}
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -2098,16 +1864,16 @@ func (hdlr *Handlers) handleNames(
ctx, chID,
)
if memErr == nil && len(members) > 0 {
entries := make([]string, 0, len(members))
nicks := make([]string, 0, len(members))
for _, mem := range members {
entries = append(entries, mem.Hostmask())
nicks = append(nicks, mem.Nick)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNamReply, nick,
[]string{"=", channel},
strings.Join(entries, " "),
strings.Join(nicks, " "),
)
}
@@ -2214,136 +1980,60 @@ func (hdlr *Handlers) executeWhois(
nick, queryNick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
targetSID, err := hdlr.params.Database.GetSessionByNick(
ctx, queryNick,
)
if err != nil {
hdlr.whoisNotFound(
ctx, writer, request,
sessionID, clientID, nick, queryNick,
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrNoSuchNick, nick,
[]string{queryNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
hdlr.deliverWhoisUser(
ctx, clientID, nick, queryNick, targetSID,
)
// 313 RPL_WHOISOPERATOR — show if target is oper.
hdlr.deliverWhoisOperator(
ctx, clientID, nick, queryNick, targetSID,
)
hdlr.deliverWhoisIdle(
ctx, clientID, nick, queryNick, targetSID,
)
hdlr.deliverWhoisChannels(
ctx, clientID, nick, queryNick, targetSID,
)
// 338 RPL_WHOISACTUALLY — oper-only.
hdlr.deliverWhoisActually(
ctx, clientID, nick, queryNick,
sessionID, targetSID,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// whoisNotFound sends the error+end numerics when the
// target nick is not found.
func (hdlr *Handlers) whoisNotFound(
ctx context.Context,
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, queryNick string,
) {
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrNoSuchNick, nick,
[]string{queryNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// deliverWhoisUser sends RPL_WHOISUSER (311) and
// RPL_WHOISSERVER (312).
func (hdlr *Handlers) deliverWhoisUser(
ctx context.Context,
clientID int64,
nick, queryNick string,
targetSID int64,
) {
srvName := hdlr.serverName()
username := queryNick
hostname := srvName
hostInfo, hostErr := hdlr.params.Database.
GetSessionHostInfo(ctx, targetSID)
if hostErr == nil && hostInfo != nil {
if hostInfo.Username != "" {
username = hostInfo.Username
}
if hostInfo.Hostname != "" {
hostname = hostInfo.Hostname
}
}
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisUser, nick,
[]string{queryNick, username, hostname, "*"},
[]string{queryNick, queryNick, srvName, "*"},
queryNick,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisServer, nick,
[]string{queryNick, srvName},
"neoirc server",
)
}
// deliverWhoisOperator sends RPL_WHOISOPERATOR (313) if
// the target has server oper status.
func (hdlr *Handlers) deliverWhoisOperator(
ctx context.Context,
clientID int64,
nick, queryNick string,
targetSID int64,
) {
targetIsOper, err := hdlr.params.Database.
IsSessionOper(ctx, targetSID)
if err != nil || !targetIsOper {
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisOperator, nick,
[]string{queryNick},
"is an IRC operator",
// 319 RPL_WHOISCHANNELS
hdlr.deliverWhoisChannels(
ctx, clientID, nick, queryNick, targetSID,
)
// 318 RPL_ENDOFWHOIS
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
func (hdlr *Handlers) deliverWhoisChannels(
@@ -2371,44 +2061,6 @@ func (hdlr *Handlers) deliverWhoisChannels(
)
}
// deliverWhoisActually sends RPL_WHOISACTUALLY (338)
// with the target's current client IP and hostname, but
// only when the querying session has server oper status
// (o-line). Non-opers see nothing extra.
func (hdlr *Handlers) deliverWhoisActually(
ctx context.Context,
clientID int64,
nick, queryNick string,
querierSID, targetSID int64,
) {
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, querierSID,
)
if err != nil || !isOper {
return
}
clientInfo, clErr := hdlr.params.Database.
GetLatestClientForSession(ctx, targetSID)
if clErr != nil {
return
}
actualHost := clientInfo.Hostname
if actualHost == "" {
actualHost = clientInfo.IP
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisActually, nick,
[]string{
queryNick,
clientInfo.IP,
},
"is actually using host "+actualHost,
)
}
// handleWho handles the WHO command.
func (hdlr *Handlers) handleWho(
writer http.ResponseWriter,
@@ -2457,21 +2109,11 @@ func (hdlr *Handlers) handleWho(
)
if memErr == nil {
for _, mem := range members {
username := mem.Username
if username == "" {
username = mem.Nick
}
hostname := mem.Hostname
if hostname == "" {
hostname = srvName
}
// 352 RPL_WHOREPLY
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoReply, nick,
[]string{
channel, username, hostname,
channel, mem.Nick, srvName,
srvName, mem.Nick, "H",
},
"0 "+mem.Nick,
@@ -2793,165 +2435,3 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
)
}
}
// handleOper handles the OPER command for server operator authentication.
func (hdlr *Handlers) handleOper(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
if len(lines) < 2 { //nolint:mnd // name + password
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdOper},
"Not enough parameters",
)
return
}
operName := lines[0]
operPass := lines[1]
cfgName := hdlr.params.Config.OperName
cfgPass := hdlr.params.Config.OperPassword
if cfgName == "" || cfgPass == "" ||
subtle.ConstantTimeCompare([]byte(operName), []byte(cfgName)) != 1 ||
subtle.ConstantTimeCompare([]byte(operPass), []byte(cfgPass)) != 1 {
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrNoOperHost, nick,
nil, "No O-lines for your host",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return
}
err := hdlr.params.Database.SetSessionOper(
ctx, sessionID, true,
)
if err != nil {
hdlr.log.Error(
"set oper failed", "error", err,
)
hdlr.respondError(
writer, request, "internal error",
http.StatusInternalServerError,
)
return
}
// 381 RPL_YOUREOPER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplYoureOper, nick,
nil, "You are now an IRC operator",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleAway handles the AWAY command. An empty body
// clears the away status; a non-empty body sets it.
func (hdlr *Handlers) handleAway(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
awayMsg := ""
if len(lines) > 0 {
awayMsg = strings.Join(lines, " ")
}
err := hdlr.params.Database.SetAway(
ctx, sessionID, awayMsg,
)
if err != nil {
hdlr.log.Error("set away failed", "error", err)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if awayMsg == "" {
// 305 RPL_UNAWAY
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUnaway, nick, nil,
"You are no longer marked as being away",
)
} else {
// 306 RPL_NOWAWAY
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNowAway, nick, nil,
"You have been marked as being away",
)
}
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle
// time and signon time.
func (hdlr *Handlers) deliverWhoisIdle(
ctx context.Context,
clientID int64,
nick, queryNick string,
targetSID int64,
) {
lastSeen, lsErr := hdlr.params.Database.
GetSessionLastSeen(ctx, targetSID)
if lsErr != nil {
return
}
createdAt, caErr := hdlr.params.Database.
GetSessionCreatedAt(ctx, targetSID)
if caErr != nil {
return
}
idleSeconds := int64(time.Since(lastSeen).Seconds())
if idleSeconds < 0 {
idleSeconds = 0
}
signonUnix := strconv.FormatInt(
createdAt.Unix(), 10,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisIdle, nick,
[]string{
queryNick,
strconv.FormatInt(idleSeconds, 10),
signonUnix,
},
"seconds idle, signon time",
)
}

View File

@@ -26,7 +26,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
@@ -91,7 +90,6 @@ func newTestServer(
return cfg, nil
},
newTestDB,
stats.New,
newTestHealthcheck,
newTestMiddleware,
newTestHandlers,
@@ -146,14 +144,12 @@ func newTestHealthcheck(
cfg *config.Config,
log *logger.Logger,
database *db.Database,
tracker *stats.Tracker,
) (*healthcheck.Healthcheck, error) {
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
Globals: globs,
Config: cfg,
Logger: log,
Database: database,
Stats: tracker,
})
if err != nil {
return nil, fmt.Errorf("test healthcheck: %w", err)
@@ -187,7 +183,6 @@ func newTestHandlers(
cfg *config.Config,
database *db.Database,
hcheck *healthcheck.Healthcheck,
tracker *stats.Tracker,
) (*handlers.Handlers, error) {
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
Logger: log,
@@ -195,7 +190,6 @@ func newTestHandlers(
Config: cfg,
Database: database,
Healthcheck: hcheck,
Stats: tracker,
})
if err != nil {
return nil, fmt.Errorf("test handlers: %w", err)
@@ -817,9 +811,9 @@ func TestMessageMissingBody(t *testing.T) {
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "412") {
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NOTEXTTOSEND (412), got %v",
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
@@ -841,9 +835,9 @@ func TestMessageMissingTo(t *testing.T) {
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "411") {
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NORECIPIENT (411), got %v",
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
@@ -876,9 +870,9 @@ func TestNonMemberCannotSend(t *testing.T) {
msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "404") {
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
}
@@ -1140,42 +1134,6 @@ func TestTopicMissingBody(t *testing.T) {
}
}
func TestTopicNonMember(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_topic")
bobToken := tserver.createSession("bob_topic")
// Only alice joins the channel.
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#topicpriv",
})
// Drain bob's initial messages.
_, lastID := tserver.pollMessages(bobToken, 0)
// Bob tries to set topic without joining.
status, _ := tserver.sendCommand(
bobToken,
map[string]any{
commandKey: "TOPIC",
toKey: "#topicpriv",
bodyKey: []string{"Hijacked topic"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(bobToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
}
}
func TestPing(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("ping_user")
@@ -1699,133 +1657,6 @@ func TestHealthcheck(t *testing.T) {
}
}
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
requiredFields := []string{
"sessions", "clients", "queuedLines",
"channels", "connectionsSinceBoot",
"sessionsSinceBoot", "messagesSinceBoot",
}
for _, field := range requiredFields {
if _, ok := result[field]; !ok {
t.Errorf(
"missing field %q in healthcheck", field,
)
}
}
}
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("statsuser")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#statschan",
})
tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "#statschan",
bodyKey: []string{"hello stats"},
})
result := tserver.fetchHealthcheck(t)
assertFieldGTE(t, result, "sessions", 1)
assertFieldGTE(t, result, "clients", 1)
assertFieldGTE(t, result, "channels", 1)
assertFieldGTE(t, result, "queuedLines", 0)
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
assertFieldGTE(t, result, "messagesSinceBoot", 1)
}
func (tserver *testServer) fetchHealthcheck(
t *testing.T,
) map[string]any {
t.Helper()
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
return result
}
func assertFieldGTE(
t *testing.T,
result map[string]any,
field string,
minimum float64,
) {
t.Helper()
val, ok := result[field].(float64)
if !ok {
t.Errorf(
"field %q: not a number (got %T)",
field, result[field],
)
return
}
if val < minimum {
t.Errorf(
"expected %s >= %v, got %v",
field, minimum, val,
)
}
}
func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t)
@@ -2130,249 +1961,6 @@ func TestSessionStillWorks(t *testing.T) {
}
}
// findNumericWithParams returns the first message matching
// the given numeric code. Returns nil if not found.
func findNumericWithParams(
msgs []map[string]any,
numeric string,
) map[string]any {
want, _ := strconv.Atoi(numeric)
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == want {
return msg
}
}
return nil
}
// getNumericParams extracts the params array from a
// numeric message as a string slice.
func getNumericParams(
msg map[string]any,
) []string {
raw, exists := msg["params"]
if !exists || raw == nil {
return nil
}
arr, isArr := raw.([]any)
if !isArr {
return nil
}
result := make([]string, 0, len(arr))
for _, val := range arr {
str, isString := val.(string)
if isString {
result = append(result, str)
}
}
return result
}
func TestWhoisShowsHostInfo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSessionWithUsername(
"whoisuser", "myident",
)
queryToken := tserver.createSession("querier")
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "whoisuser",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
whoisMsg := findNumericWithParams(msgs, "311")
if whoisMsg == nil {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
params := getNumericParams(whoisMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
if params[1] != "myident" {
t.Fatalf(
"expected username myident, got %s",
params[1],
)
}
_ = token
}
// createSessionWithUsername creates a session with a
// specific username and returns the token.
func (tserver *testServer) createSessionWithUsername(
nick, username string,
) string {
tserver.t.Helper()
body, err := json.Marshal(map[string]string{
"nick": nick,
"username": username,
})
if err != nil {
tserver.t.Fatalf("marshal session: %v", err)
}
resp, err := doRequest(
tserver.t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
tserver.t.Fatalf("create session: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
tserver.t.Fatalf(
"create session: status %d: %s",
resp.StatusCode, respBody,
)
}
var result struct {
Token string `json:"token"`
}
_ = json.NewDecoder(resp.Body).Decode(&result)
return result.Token
}
func TestWhoShowsHostInfo(t *testing.T) {
tserver := newTestServer(t)
whoToken := tserver.createSessionWithUsername(
"whouser", "whoident",
)
tserver.sendCommand(whoToken, map[string]any{
commandKey: joinCmd, toKey: "#whotest",
})
queryToken := tserver.createSession("whoquerier")
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: "#whotest",
})
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHO",
toKey: "#whotest",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
assertWhoReplyUsername(t, msgs, "whouser", "whoident")
}
func assertWhoReplyUsername(
t *testing.T,
msgs []map[string]any,
targetNick, expectedUsername string,
) {
t.Helper()
for _, msg := range msgs {
code, isCode := msg["code"].(float64)
if !isCode || int(code) != 352 {
continue
}
params := getNumericParams(msg)
if len(params) < 5 || params[4] != targetNick {
continue
}
if params[1] != expectedUsername {
t.Fatalf(
"expected username %s in WHO, got %s",
expectedUsername, params[1],
)
}
return
}
t.Fatalf(
"expected RPL_WHOREPLY (352) for %s, msgs: %v",
targetNick, msgs,
)
}
func TestSessionUsernameDefault(t *testing.T) {
tserver := newTestServer(t)
// Create session without specifying username.
token := tserver.createSession("defaultusr")
queryToken := tserver.createSession("querier2")
_, lastID := tserver.pollMessages(queryToken, 0)
// WHOIS should show the nick as the username.
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "defaultusr",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
whoisMsg := findNumericWithParams(msgs, "311")
if whoisMsg == nil {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
params := getNumericParams(whoisMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
// Username defaults to nick.
if params[1] != "defaultusr" {
t.Fatalf(
"expected default username defaultusr, got %s",
params[1],
)
}
_ = token
}
func TestNickBroadcastToChannels(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("nick_a")
@@ -2400,447 +1988,3 @@ func TestNickBroadcastToChannels(t *testing.T) {
)
}
}
func TestNamesShowsHostmask(t *testing.T) {
tserver := newTestServer(t)
queryToken, lastID := setupChannelWithIdentMember(
tserver, "namesmember", "nmident",
"namesquery", "#namestest",
)
// Issue an explicit NAMES command.
tserver.sendCommand(queryToken, map[string]any{
commandKey: "NAMES",
toKey: "#namestest",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
assertNamesHostmask(
t, msgs, "namesmember", "nmident",
)
}
func TestNamesOnJoinShowsHostmask(t *testing.T) {
tserver := newTestServer(t)
// First user joins to populate the channel.
firstToken := tserver.createSessionWithUsername(
"joinmem", "jmident",
)
tserver.sendCommand(firstToken, map[string]any{
commandKey: joinCmd, toKey: "#joinnamestest",
})
// Second user joins; the JOIN triggers
// deliverNamesNumerics which should include
// hostmask data.
joinerToken := tserver.createSession("joiner")
tserver.sendCommand(joinerToken, map[string]any{
commandKey: joinCmd, toKey: "#joinnamestest",
})
msgs, _ := tserver.pollMessages(joinerToken, 0)
assertNamesHostmask(
t, msgs, "joinmem", "jmident",
)
}
// setupChannelWithIdentMember creates a member session
// with username, joins a channel, then creates a querier
// and joins the same channel. Returns the querier token
// and last message ID.
func setupChannelWithIdentMember(
tserver *testServer,
memberNick, memberUsername,
querierNick, channel string,
) (string, int64) {
tserver.t.Helper()
memberToken := tserver.createSessionWithUsername(
memberNick, memberUsername,
)
tserver.sendCommand(memberToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
queryToken := tserver.createSession(querierNick)
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(queryToken, 0)
return queryToken, lastID
}
// assertNamesHostmask verifies that a RPL_NAMREPLY (353)
// message contains the expected nick with hostmask format
// (nick!user@host).
func assertNamesHostmask(
t *testing.T,
msgs []map[string]any,
targetNick, expectedUsername string,
) {
t.Helper()
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if !ok || int(code) != 353 {
continue
}
raw, exists := msg["body"]
if !exists || raw == nil {
continue
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
continue
}
bodyStr, isStr := arr[0].(string)
if !isStr {
continue
}
// Look for the target nick's hostmask entry.
expected := targetNick + "!" +
expectedUsername + "@"
if !strings.Contains(bodyStr, expected) {
t.Fatalf(
"expected NAMES body to contain %q, "+
"got %q",
expected, bodyStr,
)
}
return
}
t.Fatalf(
"expected RPL_NAMREPLY (353) with hostmask "+
"for %s, msgs: %v",
targetNick, msgs,
)
}
const testOperName = "admin"
const testOperPassword = "secretpass"
// newTestServerWithOper creates a test server with oper
// credentials configured (admin / secretpass).
func newTestServerWithOper(
t *testing.T,
) *testServer {
t.Helper()
dbPath := filepath.Join(
t.TempDir(), "test.db",
)
dbURL := "file:" + dbPath +
"?_journal_mode=WAL&_busy_timeout=5000"
var srv *server.Server
app := fxtest.New(t,
fx.Provide(
newTestGlobals,
logger.New,
func(
lifecycle fx.Lifecycle,
globs *globals.Globals,
log *logger.Logger,
) (*config.Config, error) {
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
}
cfg.DBURL = dbURL
cfg.Port = 0
cfg.HashcashBits = 0
cfg.OperName = testOperName
cfg.OperPassword = testOperPassword
return cfg, nil
},
newTestDB,
stats.New,
newTestHealthcheck,
newTestMiddleware,
newTestHandlers,
newTestServerFx,
),
fx.Populate(&srv),
)
const startupDelay = 100 * time.Millisecond
app.RequireStart()
time.Sleep(startupDelay)
httpSrv := httptest.NewServer(srv)
t.Cleanup(func() {
httpSrv.Close()
app.RequireStop()
})
return &testServer{
httpServer: httpSrv,
t: t,
fxApp: app,
}
}
func TestOperCommandSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("operuser")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER command.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 381 RPL_YOUREOPER.
if !findNumeric(msgs, "381") {
t.Fatalf(
"expected RPL_YOUREOPER (381), got %v",
msgs,
)
}
}
func TestOperCommandFailure(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("badoper")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER with wrong password.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, "wrongpass"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 491 ERR_NOOPERHOST.
if !findNumeric(msgs, "491") {
t.Fatalf(
"expected ERR_NOOPERHOST (491), got %v",
msgs,
)
}
}
func TestOperCommandNeedMoreParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("shortoper")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER with only one parameter.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestOperWhoisShowsClientInfo(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create a target user.
_ = tserver.createSession("target")
// Create an oper user.
operToken := tserver.createSession("theoper")
_, lastID := tserver.pollMessages(operToken, 0)
// Authenticate as oper.
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
var msgs []map[string]any
msgs, lastID = tserver.pollMessages(operToken, lastID)
if !findNumeric(msgs, "381") {
t.Fatalf(
"expected RPL_YOUREOPER (381), got %v",
msgs,
)
}
// Now WHOIS the target.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WHOIS",
toKey: "target",
})
msgs, _ = tserver.pollMessages(operToken, lastID)
// Expect 338 RPL_WHOISACTUALLY with client IP.
whoisActually := findNumericWithParams(msgs, "338")
if whoisActually == nil {
t.Fatalf(
"expected RPL_WHOISACTUALLY (338) for "+
"oper WHOIS, got %v",
msgs,
)
}
params := getNumericParams(whoisActually)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params in 338, "+
"got %v",
params,
)
}
// First param should be the target nick.
if params[0] != "target" {
t.Fatalf(
"expected first param 'target', got %s",
params[0],
)
}
// Second param should be a non-empty IP.
if params[1] == "" {
t.Fatal("expected non-empty IP in 338 params")
}
}
func TestNonOperWhoisHidesClientInfo(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create a target user.
_ = tserver.createSession("hidden")
// Create a regular (non-oper) user.
regToken := tserver.createSession("regular")
_, lastID := tserver.pollMessages(regToken, 0)
// WHOIS the target without oper status.
tserver.sendCommand(regToken, map[string]any{
commandKey: "WHOIS",
toKey: "hidden",
})
msgs, _ := tserver.pollMessages(regToken, lastID)
// Should NOT see 338 RPL_WHOISACTUALLY.
if findNumeric(msgs, "338") {
t.Fatalf(
"non-oper should not see "+
"RPL_WHOISACTUALLY (338), got %v",
msgs,
)
}
// But should see 311 RPL_WHOISUSER (normal WHOIS).
if !findNumeric(msgs, "311") {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
}
func TestWhoisShowsOperatorStatus(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create oper user and authenticate.
operToken := tserver.createSession("iamoper")
_, lastID := tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
msgs, _ := tserver.pollMessages(operToken, lastID)
if !findNumeric(msgs, "381") {
t.Fatalf("expected 381, got %v", msgs)
}
// Another user does WHOIS on the oper.
queryToken := tserver.createSession("asker")
_, queryLastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "iamoper",
})
msgs, _ = tserver.pollMessages(queryToken, queryLastID)
// Should see 313 RPL_WHOISOPERATOR.
if !findNumeric(msgs, "313") {
t.Fatalf(
"expected RPL_WHOISOPERATOR (313) in "+
"WHOIS of oper, got %v",
msgs,
)
}
}
func TestOperNoOlineConfigured(t *testing.T) {
// Standard test server has no oper configured.
tserver := newTestServer(t)
token := tserver.createSession("nooline")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, "password"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Should get 491 since no o-line is configured.
if !findNumeric(msgs, "491") {
t.Fatalf(
"expected ERR_NOOPERHOST (491) when no "+
"o-line configured, got %v",
msgs,
)
}
}

View File

@@ -30,7 +30,6 @@ func (hdlr *Handlers) handleRegister(
) {
type registerRequest struct {
Nick string `json:"nick"`
Username string `json:"username,omitempty"`
Password string `json:"password"`
}
@@ -59,20 +58,6 @@ func (hdlr *Handlers) handleRegister(
return
}
username := resolveUsername(
payload.Username, payload.Nick,
)
if !validUsernameRe.MatchString(username) {
hdlr.respondError(
writer, request,
"invalid username format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
@@ -83,27 +68,11 @@ func (hdlr *Handlers) handleRegister(
return
}
hdlr.executeRegister(
writer, request,
payload.Nick, payload.Password, username,
)
}
func (hdlr *Handlers) executeRegister(
writer http.ResponseWriter,
request *http.Request,
nick, password, username string,
) {
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
nick, password, username, hostname, remoteIP,
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.handleRegisterError(
@@ -113,14 +82,11 @@ func (hdlr *Handlers) executeRegister(
return
}
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, nick)
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": nick,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
@@ -198,18 +164,11 @@ func (hdlr *Handlers) handleLogin(
return
}
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser(
request.Context(),
payload.Nick,
payload.Password,
remoteIP, hostname,
)
if err != nil {
hdlr.respondError(
@@ -221,8 +180,6 @@ func (hdlr *Handlers) handleLogin(
return
}
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/v5/middleware"
chimw "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdAway = "AWAY"
CmdJoin = "JOIN"
CmdList = "LIST"
CmdLusers = "LUSERS"
@@ -11,7 +10,6 @@ const (
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdOper = "OPER"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"

View File

@@ -132,7 +132,6 @@ const (
RplNoTopic IRCMessageType = 331
RplTopic IRCMessageType = 332
RplTopicWhoTime IRCMessageType = 333
RplWhoisActually IRCMessageType = 338
RplInviting IRCMessageType = 341
RplSummoning IRCMessageType = 342
RplInviteList IRCMessageType = 346
@@ -296,7 +295,6 @@ var names = map[IRCMessageType]string{
RplNoTopic: "RPL_NOTOPIC",
RplTopic: "RPL_TOPIC",
RplTopicWhoTime: "RPL_TOPICWHOTIME",
RplWhoisActually: "RPL_WHOISACTUALLY",
RplInviting: "RPL_INVITING",
RplSummoning: "RPL_SUMMONING",
RplInviteList: "RPL_INVITELIST",