Compare commits
13 Commits
bd4326ad6f
...
feature/us
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6244cf039d | ||
|
|
3e6dc108db | ||
|
|
67460ea6b2 | ||
|
|
b7999b201f | ||
|
|
d85956ca1a | ||
|
|
58f958c8d3 | ||
|
|
0fef7929ad | ||
|
|
c4652728b8 | ||
| bf4d63bc4d | |||
| efbd8fe9ff | |||
| e36bd99ef6 | |||
| e9d794764b | |||
| 052674b4ee |
21
Makefile
21
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
|
||||
|
||||
BINARY := neoircd
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
@@ -7,10 +7,21 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
|
||||
all: check build
|
||||
|
||||
build:
|
||||
# ensure-web-dist creates placeholder files so //go:embed dist/* in
|
||||
# web/embed.go resolves without a full Node.js build. The real SPA is
|
||||
# built by the web-builder Docker stage; these placeholders let
|
||||
# "make test" and "make build" work outside Docker.
|
||||
ensure-web-dist:
|
||||
@if [ ! -d web/dist ]; then \
|
||||
mkdir -p web/dist && \
|
||||
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
|
||||
echo "==> Created placeholder web/dist/ for go:embed"; \
|
||||
fi
|
||||
|
||||
build: ensure-web-dist
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||
|
||||
lint:
|
||||
lint: ensure-web-dist
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
fmt:
|
||||
@@ -20,8 +31,8 @@ fmt:
|
||||
fmt-check:
|
||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||
|
||||
test:
|
||||
go test -timeout 30s -v -race -cover ./...
|
||||
test: ensure-web-dist
|
||||
go test -timeout 30s -race -cover ./... || go test -timeout 30s -race -v ./...
|
||||
|
||||
# check runs all validation without making changes
|
||||
# Used by CI and Docker build — fails if anything is wrong
|
||||
|
||||
465
README.md
465
README.md
@@ -113,8 +113,9 @@ 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. There are no accounts — identity is a key, a nick is a
|
||||
display name.
|
||||
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.
|
||||
|
||||
### On the resemblance to JSON-RPC
|
||||
|
||||
@@ -148,16 +149,45 @@ not arbitrary choices — each one follows from the project's core thesis that
|
||||
IRC's command model is correct and only the transport and session management
|
||||
need to change.
|
||||
|
||||
### Identity & Sessions — No Accounts
|
||||
### Identity & Sessions — Dual Authentication Model
|
||||
|
||||
There are no accounts, no registration, no passwords. Identity is a signing
|
||||
key; a nick is just a display name. The two are decoupled.
|
||||
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.
|
||||
|
||||
- **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.
|
||||
@@ -165,11 +195,48 @@ key; a nick is just a display name. The two are decoupled.
|
||||
in the token, no client-side decode. The server is the sole authority on
|
||||
token validity.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
### Nick Semantics
|
||||
|
||||
@@ -207,12 +274,12 @@ User Session
|
||||
└── Client C (token_c, queue_c)
|
||||
```
|
||||
|
||||
**Current MVP note:** The current implementation creates a new session (with new
|
||||
nick) per `POST /api/v1/session` call. True multi-client (multiple clients
|
||||
sharing one session/nick) is supported by the schema — `clients` references
|
||||
`sessions` via `session_id`, and `client_queues` is keyed by `client_id` — but
|
||||
the session creation endpoint does not yet support "add a client to an existing
|
||||
session." This will be added post-MVP.
|
||||
**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.
|
||||
|
||||
**Rationale:** The fundamental IRC mobile problem is that you can't have your
|
||||
phone and laptop connected simultaneously without a bouncer. Server-side
|
||||
@@ -327,8 +394,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 hash
|
||||
- Client presents the token; server looks it up
|
||||
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
|
||||
- Client presents the raw token; server hashes and 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
|
||||
@@ -355,6 +422,8 @@ The entire read/write loop for a client is two endpoints. Everything else
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
#### Anonymous Session
|
||||
|
||||
```
|
||||
┌─ Client ──────────────────────────────────────────────────┐
|
||||
│ │
|
||||
@@ -385,6 +454,30 @@ The entire read/write loop for a client is two endpoints. Everything else
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Registered Account
|
||||
|
||||
```
|
||||
┌─ Client ──────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 1. POST /api/v1/register │
|
||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
||||
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
|
||||
│ (Session created with bcrypt-hashed password) │
|
||||
│ │
|
||||
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
|
||||
│ │
|
||||
│ (From another device, while session is still active) │
|
||||
│ │
|
||||
│ 2. POST /api/v1/login │
|
||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
||||
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │
|
||||
│ (New client added to existing session — channels │
|
||||
│ and message queues are preserved. If all clients │
|
||||
│ have logged out, session no longer exists.) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Queue Architecture
|
||||
|
||||
```
|
||||
@@ -461,7 +554,7 @@ the same JSON envelope:
|
||||
| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). |
|
||||
| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** |
|
||||
| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. |
|
||||
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. |
|
||||
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. |
|
||||
|
||||
**Important invariants:**
|
||||
|
||||
@@ -821,7 +914,12 @@ for each channel followed by RPL_LISTEND (323).
|
||||
#### WHOIS — User Information
|
||||
|
||||
Query information about a user. Returns RPL_WHOISUSER (311),
|
||||
RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
|
||||
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.
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
@@ -856,6 +954,35 @@ 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.
|
||||
@@ -914,23 +1041,26 @@ 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","bob","neoirc","*"],"body":["bob"]}` |
|
||||
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"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","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"]}` |
|
||||
| `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"]}` |
|
||||
| `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"]}` |
|
||||
@@ -939,6 +1069,7 @@ 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.
|
||||
@@ -951,12 +1082,13 @@ carries IRC-style parameters (e.g., channel name, target nick).
|
||||
Inspired by IRC, simplified:
|
||||
|
||||
| Mode | Name | Meaning |
|
||||
|------|--------------|---------|
|
||||
|------|----------------|---------|
|
||||
| `+i` | Invite-only | Only invited users can join |
|
||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
|
||||
| `+s` | Secret | Channel hidden from LIST response |
|
||||
| `+t` | Topic lock | Only operators can change the topic |
|
||||
| `+n` | No external | Only channel members can send messages to the channel |
|
||||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
|
||||
|
||||
**User channel modes (set per-user per-channel):**
|
||||
|
||||
@@ -967,6 +1099,56 @@ Inspired by IRC, simplified:
|
||||
|
||||
**Status:** Channel modes are defined but not yet enforced. The `modes` column
|
||||
exists in the channels table but the server does not check modes on actions.
|
||||
Exception: `+H` (hashcash) is fully enforced — see below.
|
||||
|
||||
### Per-Channel Hashcash (Anti-Spam)
|
||||
|
||||
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
|
||||
anti-spam mechanism: channel operators set a difficulty level, and clients must
|
||||
compute a proof-of-work stamp bound to the specific channel and message before
|
||||
sending.
|
||||
|
||||
**Setting the requirement:**
|
||||
|
||||
```
|
||||
MODE #channel +H <bits> — require <bits> leading zero bits (1-40)
|
||||
MODE #channel -H — disable hashcash requirement
|
||||
```
|
||||
|
||||
**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter`
|
||||
|
||||
- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp)
|
||||
- `YYMMDD` — current date (prevents old token reuse)
|
||||
- `channel` — channel name (prevents cross-channel reuse)
|
||||
- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message)
|
||||
- `counter` — hex nonce
|
||||
|
||||
**Sending a message to a hashcash-protected channel:**
|
||||
|
||||
Include the hashcash stamp in the `meta` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "PRIVMSG",
|
||||
"to": "#general",
|
||||
"body": ["hello world"],
|
||||
"meta": {
|
||||
"hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server validation:** The server checks that the stamp is well-formed, meets
|
||||
the required difficulty, is bound to the correct channel and message body, has a
|
||||
recent date, and has not been previously used. Spent stamps are cached for 1
|
||||
year to prevent replay attacks.
|
||||
|
||||
**Error responses:** If the channel requires hashcash and the stamp is missing,
|
||||
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
|
||||
descriptive reason.
|
||||
|
||||
**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)`
|
||||
to compute stamps. Higher bit counts take exponentially longer to compute.
|
||||
|
||||
---
|
||||
|
||||
@@ -994,14 +1176,20 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
||||
{"nick": "alice", "username": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|------------|--------|-------------|-------------|
|
||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||
| `username` | string | No | 1–32 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
|
||||
{
|
||||
@@ -1022,6 +1210,7 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
| 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 |
|
||||
@@ -1034,6 +1223,110 @@ 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 | 1–32 characters, must be unique on the server |
|
||||
| `username` | string | No | 1–32 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.
|
||||
@@ -1182,6 +1475,7 @@ 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 |
|
||||
|
||||
@@ -1210,6 +1504,7 @@ 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):**
|
||||
|
||||
@@ -1227,9 +1522,11 @@ 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 |
|
||||
@@ -1242,6 +1539,7 @@ 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
|
||||
|
||||
@@ -1399,13 +1697,40 @@ Return server metadata. No authentication required.
|
||||
|
||||
### GET /.well-known/healthcheck.json — Health Check
|
||||
|
||||
Standard health check endpoint. No authentication required.
|
||||
Standard health check endpoint. No authentication required. Returns server
|
||||
health status and runtime statistics.
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
{
|
||||
"status": "ok",
|
||||
"now": "2024-01-15T12:00:00.000000000Z",
|
||||
"uptimeSeconds": 3600,
|
||||
"uptimeHuman": "1h0m0s",
|
||||
"version": "0.1.0",
|
||||
"appname": "neoirc",
|
||||
"maintenanceMode": false,
|
||||
"sessions": 42,
|
||||
"clients": 85,
|
||||
"queuedLines": 128,
|
||||
"channels": 7,
|
||||
"connectionsSinceBoot": 200,
|
||||
"sessionsSinceBoot": 150,
|
||||
"messagesSinceBoot": 5000
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------- | ------------------------------------------------- |
|
||||
| `sessions` | Current number of active sessions |
|
||||
| `clients` | Current number of connected clients |
|
||||
| `queuedLines` | Total entries in client output queues |
|
||||
| `channels` | Current number of channels |
|
||||
| `connectionsSinceBoot` | Total client connections since server start |
|
||||
| `sessionsSinceBoot` | Total sessions created since server start |
|
||||
| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start |
|
||||
|
||||
---
|
||||
|
||||
## Message Flow
|
||||
@@ -1590,9 +1915,16 @@ authenticity.
|
||||
### Authentication
|
||||
|
||||
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
|
||||
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.
|
||||
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`.
|
||||
- **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.
|
||||
@@ -1742,13 +2074,17 @@ The database schema is managed via embedded SQL migration files in
|
||||
|
||||
#### `sessions`
|
||||
| Column | Type | Description |
|
||||
|-----------------|----------|-------------|
|
||||
|----------------|----------|-------------|
|
||||
| `id` | INTEGER | Primary key (auto-increment) |
|
||||
| `uuid` | TEXT | Unique session UUID |
|
||||
| `nick` | TEXT | Unique nick |
|
||||
| `password_hash` | TEXT | Password hash (default empty) |
|
||||
| `signing_key` | TEXT | Ed25519 signing key (default empty) |
|
||||
| `away_message` | TEXT | Away message (default empty) |
|
||||
| `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 |
|
||||
|
||||
@@ -1756,11 +2092,13 @@ Index on `(uuid)`.
|
||||
|
||||
#### `clients`
|
||||
| Column | Type | Description |
|
||||
|--------------|----------|-------------|
|
||||
|-------------|----------|-------------|
|
||||
| `id` | INTEGER | Primary key (auto-increment) |
|
||||
| `uuid` | TEXT | Unique client UUID |
|
||||
| `session_id`| INTEGER | FK → sessions.id (cascade delete) |
|
||||
| `token` | TEXT | Unique auth token (64 hex chars) |
|
||||
| `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 |
|
||||
| `last_seen` | DATETIME | Last API request time |
|
||||
|
||||
@@ -1823,10 +2161,19 @@ 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).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1854,6 +2201,8 @@ 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
|
||||
@@ -1975,11 +2324,21 @@ A complete client needs only four HTTP calls:
|
||||
### Step-by-Step with curl
|
||||
|
||||
```bash
|
||||
# 1. Create a session
|
||||
# 1a. Create an anonymous session (no account)
|
||||
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" \
|
||||
@@ -2112,9 +2471,11 @@ Clients should handle these message commands from the queue:
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **HTTP 401**: Token expired or invalid. Re-create session.
|
||||
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
|
||||
re-login (registered account).
|
||||
- **HTTP 404**: Channel or user not found.
|
||||
- **HTTP 409**: Nick already taken (on session creation or NICK change).
|
||||
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
||||
NICK change).
|
||||
- **HTTP 400**: Malformed request. Check the `error` field in the response.
|
||||
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
|
||||
|
||||
@@ -2131,8 +2492,10 @@ Clients should handle these message commands from the queue:
|
||||
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
|
||||
(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 session is gone.
|
||||
Create a new session. If it fails with a network error, retry with backoff.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -2352,6 +2715,8 @@ 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)
|
||||
@@ -2403,9 +2768,13 @@ neoirc/
|
||||
build a working IRC-style TUI client against this API in an afternoon, the
|
||||
API is too complex.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -35,6 +36,7 @@ func main() {
|
||||
server.New,
|
||||
middleware.New,
|
||||
healthcheck.New,
|
||||
stats.New,
|
||||
),
|
||||
fx.Invoke(func(*server.Server) {}),
|
||||
).Run()
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,6 +39,23 @@ func MintHashcash(bits int, resource string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// MintChannelHashcash computes a hashcash stamp bound to
|
||||
// a specific channel and message body. The stamp format
|
||||
// is 1:bits:YYMMDD:channel:bodyhash:counter where
|
||||
// bodyhash is the hex-encoded SHA-256 of the message
|
||||
// body bytes. Delegates to the internal/hashcash package.
|
||||
func MintChannelHashcash(
|
||||
bits int,
|
||||
channel string,
|
||||
body []byte,
|
||||
) string {
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
return hashcash.MintChannelStamp(
|
||||
bits, channel, bodyHash,
|
||||
)
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if hash has at least numBits
|
||||
// leading zero bits.
|
||||
func hasLeadingZeroBits(
|
||||
|
||||
@@ -46,6 +46,8 @@ type Config struct {
|
||||
FederationKey string
|
||||
SessionIdleTimeout string
|
||||
HashcashBits int
|
||||
OperName string
|
||||
OperPassword string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -78,6 +80,8 @@ 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 {
|
||||
@@ -104,6 +108,8 @@ 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: ¶ms,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const bcryptCost = bcrypt.DefaultCost
|
||||
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
|
||||
var bcryptCost = bcrypt.DefaultCost
|
||||
|
||||
// SetBcryptCost overrides the bcrypt cost.
|
||||
// Use bcrypt.MinCost in tests to avoid slow hashing.
|
||||
func SetBcryptCost(cost int) { bcryptCost = cost }
|
||||
|
||||
var errNoPassword = errors.New(
|
||||
"account has no password set",
|
||||
@@ -20,8 +25,12 @@ var errNoPassword = errors.New(
|
||||
// and returns session ID, client ID, and token.
|
||||
func (database *Database) RegisterUser(
|
||||
ctx context.Context,
|
||||
nick, password string,
|
||||
nick, password, username, hostname, remoteIP string,
|
||||
) (int64, int64, string, error) {
|
||||
if username == "" {
|
||||
username = nick
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(password), bcryptCost,
|
||||
)
|
||||
@@ -50,10 +59,11 @@ func (database *Database) RegisterUser(
|
||||
|
||||
res, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO sessions
|
||||
(uuid, nick, password_hash,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
sessionUUID, nick, string(hash), now, now)
|
||||
(uuid, nick, username, hostname, ip,
|
||||
password_hash, created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sessionUUID, nick, username, hostname,
|
||||
remoteIP, string(hash), now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
@@ -68,10 +78,11 @@ func (database *Database) RegisterUser(
|
||||
|
||||
clientRes, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO clients
|
||||
(uuid, session_id, token,
|
||||
(uuid, session_id, token, ip, hostname,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash, now, now)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash,
|
||||
remoteIP, hostname, now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
@@ -96,7 +107,7 @@ func (database *Database) RegisterUser(
|
||||
// client token.
|
||||
func (database *Database) LoginUser(
|
||||
ctx context.Context,
|
||||
nick, password string,
|
||||
nick, password, remoteIP, hostname string,
|
||||
) (int64, int64, string, error) {
|
||||
var (
|
||||
sessionID int64
|
||||
@@ -143,10 +154,11 @@ func (database *Database) LoginUser(
|
||||
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
`INSERT INTO clients
|
||||
(uuid, session_id, token,
|
||||
(uuid, session_id, token, ip, hostname,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash, now, now)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash,
|
||||
remoteIP, hostname, now, now)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"create login client: %w", err,
|
||||
|
||||
@@ -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,6 +38,69 @@ 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()
|
||||
|
||||
@@ -45,7 +108,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)
|
||||
}
|
||||
@@ -55,7 +118,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")
|
||||
}
|
||||
@@ -72,7 +135,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)
|
||||
}
|
||||
@@ -82,7 +145,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)
|
||||
}
|
||||
@@ -103,6 +166,83 @@ 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()
|
||||
|
||||
@@ -110,7 +250,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)
|
||||
}
|
||||
@@ -120,7 +260,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")
|
||||
}
|
||||
@@ -138,7 +278,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)
|
||||
}
|
||||
@@ -148,7 +288,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",
|
||||
@@ -167,7 +307,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")
|
||||
}
|
||||
|
||||
14
internal/db/main_test.go
Normal file
14
internal/db/main_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
db.SetBcryptCost(bcrypt.MinCost)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -74,14 +74,40 @@ 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 string,
|
||||
nick, username, hostname, remoteIP string,
|
||||
) (int64, int64, string, error) {
|
||||
if username == "" {
|
||||
username = nick
|
||||
}
|
||||
|
||||
sessionUUID := uuid.New().String()
|
||||
clientUUID := uuid.New().String()
|
||||
|
||||
@@ -101,9 +127,11 @@ func (database *Database) CreateSession(
|
||||
|
||||
res, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO sessions
|
||||
(uuid, nick, created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
sessionUUID, nick, now, now)
|
||||
(uuid, nick, username, hostname, ip,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
sessionUUID, nick, username, hostname,
|
||||
remoteIP, now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
@@ -118,10 +146,11 @@ func (database *Database) CreateSession(
|
||||
|
||||
clientRes, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO clients
|
||||
(uuid, session_id, token,
|
||||
(uuid, session_id, token, ip, hostname,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash, now, now)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash,
|
||||
remoteIP, hostname, now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
@@ -209,6 +238,135 @@ 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,
|
||||
@@ -388,7 +546,8 @@ func (database *Database) ChannelMembers(
|
||||
channelID int64,
|
||||
) ([]MemberInfo, error) {
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT s.id, s.nick, s.last_seen
|
||||
`SELECT s.id, s.nick, s.username,
|
||||
s.hostname, s.last_seen
|
||||
FROM sessions s
|
||||
INNER JOIN channel_members cm
|
||||
ON cm.session_id = s.id
|
||||
@@ -408,7 +567,9 @@ func (database *Database) ChannelMembers(
|
||||
var member MemberInfo
|
||||
|
||||
err = rows.Scan(
|
||||
&member.ID, &member.Nick, &member.LastSeen,
|
||||
&member.ID, &member.Nick,
|
||||
&member.Username, &member.Hostname,
|
||||
&member.LastSeen,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
@@ -859,6 +1020,26 @@ 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(
|
||||
@@ -1266,3 +1447,149 @@ 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
|
||||
}
|
||||
|
||||
// GetChannelHashcashBits returns the hashcash difficulty
|
||||
// requirement for a channel. Returns 0 if not set.
|
||||
func (database *Database) GetChannelHashcashBits(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (int, error) {
|
||||
var bits int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT hashcash_bits FROM channels WHERE id = ?",
|
||||
channelID,
|
||||
).Scan(&bits)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"get channel hashcash bits: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
// SetChannelHashcashBits sets the hashcash difficulty
|
||||
// requirement for a channel. A value of 0 disables the
|
||||
// requirement.
|
||||
func (database *Database) SetChannelHashcashBits(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
bits int,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`UPDATE channels
|
||||
SET hashcash_bits = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
bits, time.Now(), channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"set channel hashcash bits: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSpentHashcash stores a spent hashcash stamp hash
|
||||
// for replay prevention.
|
||||
func (database *Database) RecordSpentHashcash(
|
||||
ctx context.Context,
|
||||
stampHash string,
|
||||
) error {
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO spent_hashcash
|
||||
(stamp_hash, created_at)
|
||||
VALUES (?, ?)`,
|
||||
stampHash, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"record spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsHashcashSpent checks whether a hashcash stamp hash
|
||||
// has already been used.
|
||||
func (database *Database) IsHashcashSpent(
|
||||
ctx context.Context,
|
||||
stampHash string,
|
||||
) (bool, error) {
|
||||
var count int
|
||||
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM spent_hashcash
|
||||
WHERE stamp_hash = ?`,
|
||||
stampHash,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// PruneSpentHashcash deletes spent hashcash tokens older
|
||||
// than the cutoff and returns the number of rows removed.
|
||||
func (database *Database) PruneSpentHashcash(
|
||||
ctx context.Context,
|
||||
cutoff time.Time,
|
||||
) (int64, error) {
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
"DELETE FROM spent_hashcash WHERE created_at < ?",
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"prune spent hashcash: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
deleted, _ := res.RowsAffected()
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
@@ -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,13 +54,249 @@ 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)
|
||||
}
|
||||
@@ -93,7 +329,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)
|
||||
}
|
||||
@@ -150,7 +386,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)
|
||||
}
|
||||
@@ -199,7 +435,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)
|
||||
}
|
||||
@@ -234,7 +470,7 @@ func createSessionWithChannels(
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, _, err := database.CreateSession(ctx, nick)
|
||||
sid, _, _, err := database.CreateSession(ctx, nick, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -317,7 +553,7 @@ func TestChangeNick(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, token, err := database.CreateSession(
|
||||
ctx, "old",
|
||||
ctx, "old", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -401,7 +637,7 @@ func TestPollMessages(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, token, err := database.CreateSession(
|
||||
ctx, "poller",
|
||||
ctx, "poller", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -508,7 +744,7 @@ func TestDeleteSession(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, _, err := database.CreateSession(
|
||||
ctx, "deleteme",
|
||||
ctx, "deleteme", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -548,12 +784,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)
|
||||
}
|
||||
@@ -611,7 +847,7 @@ func TestEnqueueToClient(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
_, _, token, err := database.CreateSession(
|
||||
ctx, "enqclient",
|
||||
ctx, "enqclient", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -651,3 +887,133 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ 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 '',
|
||||
@@ -20,6 +24,8 @@ 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
|
||||
);
|
||||
@@ -33,6 +39,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
topic TEXT NOT NULL DEFAULT '',
|
||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
||||
topic_set_at DATETIME,
|
||||
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -61,6 +68,14 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
||||
|
||||
-- Spent hashcash tokens for replay prevention (1-year TTL)
|
||||
CREATE TABLE IF NOT EXISTS spent_hashcash (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stamp_hash TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
|
||||
|
||||
-- Per-client message queues for fan-out delivery
|
||||
CREATE TABLE IF NOT EXISTS client_queues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ func (hdlr *Handlers) handleRegister(
|
||||
) {
|
||||
type registerRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
@@ -58,6 +59,20 @@ 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,
|
||||
@@ -68,11 +83,27 @@ 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(),
|
||||
payload.Nick,
|
||||
payload.Password,
|
||||
nick, password, username, hostname, remoteIP,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.handleRegisterError(
|
||||
@@ -82,11 +113,14 @@ func (hdlr *Handlers) handleRegister(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||
hdlr.stats.IncrSessions()
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, nick)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
"nick": nick,
|
||||
"token": token,
|
||||
}, http.StatusCreated)
|
||||
}
|
||||
@@ -164,11 +198,18 @@ 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(
|
||||
@@ -180,6 +221,8 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -30,10 +31,16 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||
|
||||
// spentHashcashTTL is how long spent hashcash tokens are
|
||||
// retained for replay prevention. Per issue requirements,
|
||||
// this is 1 year.
|
||||
const spentHashcashTTL = 365 * 24 * time.Hour
|
||||
|
||||
// Handlers manages HTTP request handling.
|
||||
type Handlers struct {
|
||||
params *Params
|
||||
@@ -41,6 +48,8 @@ type Handlers struct {
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
stats *stats.Tracker
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -60,6 +69,8 @@ func New(
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
stats: params.Stats,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
@@ -281,4 +292,20 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prune spent hashcash tokens older than 1 year.
|
||||
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
|
||||
|
||||
pruned, err := hdlr.params.Database.
|
||||
PruneSpentHashcash(ctx, hashcashCutoff)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"spent hashcash pruning failed", "error", err,
|
||||
)
|
||||
} else if pruned > 0 {
|
||||
hdlr.log.Info(
|
||||
"pruned spent hashcash tokens",
|
||||
"deleted", pruned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
resp := hdlr.hc.Healthcheck()
|
||||
resp := hdlr.hc.Healthcheck(request.Context())
|
||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
186
internal/hashcash/channel.go
Normal file
186
internal/hashcash/channel.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package hashcash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errBodyHashMismatch = errors.New(
|
||||
"body hash mismatch",
|
||||
)
|
||||
errBodyHashMissing = errors.New(
|
||||
"body hash missing",
|
||||
)
|
||||
)
|
||||
|
||||
// ChannelValidator checks hashcash stamps for
|
||||
// per-channel PRIVMSG validation. It verifies that
|
||||
// stamps are bound to a specific channel and message
|
||||
// body. Replay prevention is handled externally via
|
||||
// the database spent_hashcash table for persistence
|
||||
// across server restarts (1-year TTL).
|
||||
type ChannelValidator struct{}
|
||||
|
||||
// NewChannelValidator creates a ChannelValidator.
|
||||
func NewChannelValidator() *ChannelValidator {
|
||||
return &ChannelValidator{}
|
||||
}
|
||||
|
||||
// BodyHash computes the hex-encoded SHA-256 hash of a
|
||||
// message body for use in hashcash stamp validation.
|
||||
func BodyHash(body []byte) string {
|
||||
hash := sha256.Sum256(body)
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ValidateStamp checks a channel hashcash stamp. It
|
||||
// verifies the stamp format, difficulty, date, channel
|
||||
// binding, body hash binding, and proof-of-work. Replay
|
||||
// detection is NOT performed here — callers must check
|
||||
// the spent_hashcash table separately.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||
func (cv *ChannelValidator) ValidateStamp(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
channel string,
|
||||
bodyHash string,
|
||||
) error {
|
||||
if requiredBits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(stamp, ":")
|
||||
if len(parts) != stampFields {
|
||||
return fmt.Errorf(
|
||||
"%w: expected %d, got %d",
|
||||
errInvalidFields, stampFields, len(parts),
|
||||
)
|
||||
}
|
||||
|
||||
version := parts[0]
|
||||
bitsStr := parts[1]
|
||||
dateStr := parts[2]
|
||||
resource := parts[3]
|
||||
stampBodyHash := parts[4]
|
||||
|
||||
headerErr := validateChannelHeader(
|
||||
version, bitsStr, resource,
|
||||
requiredBits, channel,
|
||||
)
|
||||
if headerErr != nil {
|
||||
return headerErr
|
||||
}
|
||||
|
||||
stampTime, parseErr := parseStampDate(dateStr)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
timeErr := validateTime(stampTime)
|
||||
if timeErr != nil {
|
||||
return timeErr
|
||||
}
|
||||
|
||||
bodyErr := validateBodyHash(
|
||||
stampBodyHash, bodyHash,
|
||||
)
|
||||
if bodyErr != nil {
|
||||
return bodyErr
|
||||
}
|
||||
|
||||
return validateProof(stamp, requiredBits)
|
||||
}
|
||||
|
||||
// StampHash returns a deterministic hash of a stamp
|
||||
// string for use as a spent-token key.
|
||||
func StampHash(stamp string) string {
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func validateChannelHeader(
|
||||
version, bitsStr, resource string,
|
||||
requiredBits int,
|
||||
channel string,
|
||||
) error {
|
||||
if version != stampVersion {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", errBadVersion, version,
|
||||
)
|
||||
}
|
||||
|
||||
claimedBits, err := strconv.Atoi(bitsStr)
|
||||
if err != nil || claimedBits < requiredBits {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d bits",
|
||||
errInsufficientBits, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
if resource != channel {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errWrongResource, resource, channel,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBodyHash(
|
||||
stampBodyHash, expectedBodyHash string,
|
||||
) error {
|
||||
if stampBodyHash == "" {
|
||||
return errBodyHashMissing
|
||||
}
|
||||
|
||||
if stampBodyHash != expectedBodyHash {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errBodyHashMismatch,
|
||||
stampBodyHash, expectedBodyHash,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MintChannelStamp computes a channel hashcash stamp
|
||||
// with the given difficulty, channel name, and body hash.
|
||||
// This is intended for clients to generate stamps before
|
||||
// sending PRIVMSG to hashcash-protected channels.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||
func MintChannelStamp(
|
||||
bits int,
|
||||
channel string,
|
||||
bodyHash string,
|
||||
) string {
|
||||
date := time.Now().UTC().Format(dateFormatShort)
|
||||
prefix := fmt.Sprintf(
|
||||
"1:%d:%s:%s:%s:",
|
||||
bits, date, channel, bodyHash,
|
||||
)
|
||||
|
||||
counter := uint64(0)
|
||||
|
||||
for {
|
||||
stamp := prefix + strconv.FormatUint(counter, 16)
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
if hasLeadingZeroBits(hash[:], bits) {
|
||||
return stamp
|
||||
}
|
||||
|
||||
counter++
|
||||
}
|
||||
}
|
||||
244
internal/hashcash/channel_test.go
Normal file
244
internal/hashcash/channel_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package hashcash_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
)
|
||||
|
||||
const (
|
||||
testChannel = "#general"
|
||||
testBodyText = `["hello world"]`
|
||||
)
|
||||
|
||||
func testBodyHash() string {
|
||||
hash := sha256.Sum256([]byte(testBodyText))
|
||||
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func TestChannelValidateHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("valid channel stamp rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateWrongChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, "#other", bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected channel mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateWrongBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
wrongHash := sha256.Sum256([]byte("different body"))
|
||||
wrongBodyHash := hex.EncodeToString(wrongHash[:])
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, wrongBodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected body hash mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateInsufficientBits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Mint with 2 bits but require 4.
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, 4, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected insufficient bits error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateZeroBitsSkips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
"garbage", 0, "#ch", "abc",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("zero bits should skip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateBadFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
"not:valid", testBits, testChannel, "abc",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad format error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateBadVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad version error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateExpiredStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Mint with a very old date by manually constructing.
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, testChannel, "200101",
|
||||
)
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired stamp error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelValidateMissingBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewChannelValidator()
|
||||
bodyHash := testBodyHash()
|
||||
|
||||
// Construct a stamp with empty body hash field.
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, testChannel, todayDate(),
|
||||
)
|
||||
|
||||
// This uses the session-style stamp which has empty
|
||||
// ext field — body hash is missing.
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing body hash error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := []byte(`["hello world"]`)
|
||||
bodyHash := hashcash.BodyHash(body)
|
||||
|
||||
if len(bodyHash) != 64 {
|
||||
t.Fatalf(
|
||||
"expected 64-char hex hash, got %d",
|
||||
len(bodyHash),
|
||||
)
|
||||
}
|
||||
|
||||
// Same input should produce same hash.
|
||||
bodyHash2 := hashcash.BodyHash(body)
|
||||
if bodyHash != bodyHash2 {
|
||||
t.Fatal("body hash not deterministic")
|
||||
}
|
||||
|
||||
// Different input should produce different hash.
|
||||
bodyHash3 := hashcash.BodyHash([]byte("different"))
|
||||
if bodyHash == bodyHash3 {
|
||||
t.Fatal("different inputs produced same hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash1 := hashcash.StampHash("stamp1")
|
||||
hash2 := hashcash.StampHash("stamp2")
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("different stamps produced same hash")
|
||||
}
|
||||
|
||||
// Same input should be deterministic.
|
||||
hash1b := hashcash.StampHash("stamp1")
|
||||
if hash1 != hash1b {
|
||||
t.Fatal("stamp hash not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintChannelStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bodyHash := testBodyHash()
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
testBits, testChannel, bodyHash,
|
||||
)
|
||||
|
||||
if stamp == "" {
|
||||
t.Fatal("expected non-empty stamp")
|
||||
}
|
||||
|
||||
// Validate the minted stamp.
|
||||
validator := hashcash.NewChannelValidator()
|
||||
|
||||
err := validator.ValidateStamp(
|
||||
stamp, testBits, testChannel, bodyHash,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("minted stamp failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,7 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *db.Database
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
// Healthcheck tracks server uptime and provides health status.
|
||||
@@ -64,11 +66,22 @@ 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() *Response {
|
||||
return &Response{
|
||||
func (hcheck *Healthcheck) Healthcheck(
|
||||
ctx context.Context,
|
||||
) *Response {
|
||||
resp := &Response{
|
||||
Status: "ok",
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||
@@ -76,6 +89,64 @@ func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
internal/stats/stats.go
Normal file
52
internal/stats/stats.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package stats tracks runtime statistics since server boot.
|
||||
package stats
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Tracker holds atomic counters for runtime statistics
|
||||
// that accumulate since the server started.
|
||||
type Tracker struct {
|
||||
connectionsSinceBoot atomic.Int64
|
||||
sessionsSinceBoot atomic.Int64
|
||||
messagesSinceBoot atomic.Int64
|
||||
}
|
||||
|
||||
// New creates a new Tracker with all counters at zero.
|
||||
func New() *Tracker {
|
||||
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
|
||||
}
|
||||
|
||||
// IncrConnections increments the total connection count.
|
||||
func (t *Tracker) IncrConnections() {
|
||||
t.connectionsSinceBoot.Add(1)
|
||||
}
|
||||
|
||||
// IncrSessions increments the total session count.
|
||||
func (t *Tracker) IncrSessions() {
|
||||
t.sessionsSinceBoot.Add(1)
|
||||
}
|
||||
|
||||
// IncrMessages increments the total PRIVMSG/NOTICE count.
|
||||
func (t *Tracker) IncrMessages() {
|
||||
t.messagesSinceBoot.Add(1)
|
||||
}
|
||||
|
||||
// ConnectionsSinceBoot returns the total number of
|
||||
// client connections since boot.
|
||||
func (t *Tracker) ConnectionsSinceBoot() int64 {
|
||||
return t.connectionsSinceBoot.Load()
|
||||
}
|
||||
|
||||
// SessionsSinceBoot returns the total number of sessions
|
||||
// created since boot.
|
||||
func (t *Tracker) SessionsSinceBoot() int64 {
|
||||
return t.sessionsSinceBoot.Load()
|
||||
}
|
||||
|
||||
// MessagesSinceBoot returns the total number of
|
||||
// PRIVMSG/NOTICE messages sent since boot.
|
||||
func (t *Tracker) MessagesSinceBoot() int64 {
|
||||
return t.messagesSinceBoot.Load()
|
||||
}
|
||||
117
internal/stats/stats_test.go
Normal file
117
internal/stats/stats_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package stats_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
if tracker == nil {
|
||||
t.Fatal("expected non-nil tracker")
|
||||
}
|
||||
|
||||
if tracker.ConnectionsSinceBoot() != 0 {
|
||||
t.Errorf(
|
||||
"expected 0 connections, got %d",
|
||||
tracker.ConnectionsSinceBoot(),
|
||||
)
|
||||
}
|
||||
|
||||
if tracker.SessionsSinceBoot() != 0 {
|
||||
t.Errorf(
|
||||
"expected 0 sessions, got %d",
|
||||
tracker.SessionsSinceBoot(),
|
||||
)
|
||||
}
|
||||
|
||||
if tracker.MessagesSinceBoot() != 0 {
|
||||
t.Errorf(
|
||||
"expected 0 messages, got %d",
|
||||
tracker.MessagesSinceBoot(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrConnections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
|
||||
tracker.IncrConnections()
|
||||
tracker.IncrConnections()
|
||||
tracker.IncrConnections()
|
||||
|
||||
got := tracker.ConnectionsSinceBoot()
|
||||
if got != 3 {
|
||||
t.Errorf(
|
||||
"expected 3 connections, got %d", got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
|
||||
tracker.IncrSessions()
|
||||
tracker.IncrSessions()
|
||||
|
||||
got := tracker.SessionsSinceBoot()
|
||||
if got != 2 {
|
||||
t.Errorf(
|
||||
"expected 2 sessions, got %d", got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
|
||||
tracker.IncrMessages()
|
||||
|
||||
got := tracker.MessagesSinceBoot()
|
||||
if got != 1 {
|
||||
t.Errorf(
|
||||
"expected 1 message, got %d", got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountersAreIndependent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracker := stats.New()
|
||||
|
||||
tracker.IncrConnections()
|
||||
tracker.IncrSessions()
|
||||
tracker.IncrMessages()
|
||||
tracker.IncrMessages()
|
||||
|
||||
if tracker.ConnectionsSinceBoot() != 1 {
|
||||
t.Errorf(
|
||||
"expected 1 connection, got %d",
|
||||
tracker.ConnectionsSinceBoot(),
|
||||
)
|
||||
}
|
||||
|
||||
if tracker.SessionsSinceBoot() != 1 {
|
||||
t.Errorf(
|
||||
"expected 1 session, got %d",
|
||||
tracker.SessionsSinceBoot(),
|
||||
)
|
||||
}
|
||||
|
||||
if tracker.MessagesSinceBoot() != 2 {
|
||||
t.Errorf(
|
||||
"expected 2 messages, got %d",
|
||||
tracker.MessagesSinceBoot(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
CmdNames = "NAMES"
|
||||
CmdNick = "NICK"
|
||||
CmdNotice = "NOTICE"
|
||||
CmdOper = "OPER"
|
||||
CmdPart = "PART"
|
||||
CmdPing = "PING"
|
||||
CmdPong = "PONG"
|
||||
|
||||
@@ -132,6 +132,7 @@ const (
|
||||
RplNoTopic IRCMessageType = 331
|
||||
RplTopic IRCMessageType = 332
|
||||
RplTopicWhoTime IRCMessageType = 333
|
||||
RplWhoisActually IRCMessageType = 338
|
||||
RplInviting IRCMessageType = 341
|
||||
RplSummoning IRCMessageType = 342
|
||||
RplInviteList IRCMessageType = 346
|
||||
@@ -295,6 +296,7 @@ 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",
|
||||
|
||||
Reference in New Issue
Block a user