3 Commits

Author SHA1 Message Date
user
16258722c7 fix: include hostmask in NAMES replies (RPL_NAMREPLY)
All checks were successful
check / check (push) Successful in 1m4s
2026-03-17 09:05:16 -07:00
user
953771f2aa add IP to sessions, IP+hostname to clients
All checks were successful
check / check (push) Successful in 1m5s
- Add ip column to sessions table (real client IP of session creator)
- Add ip and hostname columns to clients table (per-connection tracking)
- Update CreateSession, RegisterUser, LoginUser to store new fields
- Add GetClientHostInfo query method
- Update SessionHostInfo to include IP
- Extract executeCreateSession to fix funlen lint
- Add tests for session IP, client IP/hostname, login client tracking
- Update README with new field documentation
2026-03-17 08:52:50 -07:00
user
e42c6c1868 feat: add username/hostname support with IRC hostmask format
All checks were successful
check / check (push) Successful in 2m11s
- Add username and hostname columns to sessions table (001_initial.sql)
- Accept optional username field in session creation and registration
  endpoints; defaults to nick if not provided
- Resolve hostname via reverse DNS of connecting client IP at session
  creation time (supports X-Forwarded-For and X-Real-IP headers)
- Display real username and hostname in WHOIS (311 RPL_WHOISUSER) and
  WHO (352 RPL_WHOREPLY) responses instead of nick/servername
- Add FormatHostmask helper for nick!user@host format
- Add SessionHostInfo type and GetSessionHostInfo query
- Include username/hostname in MemberInfo and ChannelMembers results
- Extract validateHashcash and resolveUsername helpers to stay under
  funlen limits
- Add comprehensive unit tests for all new DB functions, hostmask
  formatting, and integration tests for WHOIS/WHO responses
- Update README with hostmask documentation, new API fields, and
  updated schema reference
2026-03-17 05:34:57 -07:00
15 changed files with 1133 additions and 514 deletions

View File

@@ -207,6 +207,32 @@ removal. Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent signatures (see [Security Model](#security-model)) remains independent
of account registration. 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.
The hostmask appears in:
- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains
`[nick, username, 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 ### Nick Semantics
- Nicks are **unique per server at any point in time** — two sessions cannot - Nicks are **unique per server at any point in time** — two sessions cannot
@@ -976,7 +1002,7 @@ 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"]}` | | `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"]}` | | `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"]}` | | `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"]}` | | `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | | `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"]}` | | `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
@@ -987,8 +1013,8 @@ the server to the client (never C2S) and use 3-digit string codes in the
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | | `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"]}` | | `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!"]}` | | `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"]}` | | `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 alice bob +voiced1"]}` | | `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"]}` | | `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"]}` | | `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 -"]}` | | `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` |
@@ -1056,14 +1082,20 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**Request Body:** **Request Body:**
```json ```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 | | Field | Type | Required | Constraints |
|------------|--------|-------------|-------------| |------------|--------|-------------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server | | `nick` | string | Yes | 132 characters, must be unique on the server |
| `username` | string | No | 132 characters, IRC ident-style. Defaults to nick if omitted. |
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | | `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` **Response:** `201 Created`
```json ```json
{ {
@@ -1084,6 +1116,7 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick | | 400 | `nick must be 1-32 characters` | Empty or too-long nick |
| 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 | `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.) | | 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
| 409 | `nick already taken` | Another active session holds this nick | | 409 | `nick already taken` | Another active session holds this nick |
@@ -1105,14 +1138,18 @@ remains active.
**Request Body:** **Request Body:**
```json ```json
{"nick": "alice", "password": "mypassword"} {"nick": "alice", "username": "alice", "password": "mypassword"}
``` ```
| Field | Type | Required | Constraints | | Field | Type | Required | Constraints |
|------------|--------|----------|-------------| |------------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server | | `nick` | string | Yes | 132 characters, must be unique on the server |
| `username` | string | No | 132 characters, IRC ident-style. Defaults to nick if omitted. |
| `password` | string | Yes | Minimum 8 characters | | `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` **Response:** `201 Created`
```json ```json
{ {
@@ -1133,6 +1170,7 @@ remains active.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `invalid nick format` | Nick doesn't match allowed format | | 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 | | 400 | `password must be at least 8 characters` | Password too short |
| 409 | `nick already taken` | Another active session holds this nick | | 409 | `nick already taken` | Another active session holds this nick |
@@ -1941,6 +1979,8 @@ The database schema is managed via embedded SQL migration files in
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID | | `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick | | `nick` | TEXT | Unique nick |
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP |
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) | | `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key (empty string if unset) | | `signing_key` | TEXT | Public signing key (empty string if unset) |
| `away_message` | TEXT | Away message (empty string if not away) | | `away_message` | TEXT | Away message (empty string if not away) |
@@ -2051,8 +2091,6 @@ directory is also loaded automatically via
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | | `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | | `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | | `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | | `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
### Example `.env` file ### Example `.env` file
@@ -2457,49 +2495,6 @@ creating one session pays once and keeps their session.
- **Language-agnostic**: SHA-256 is available in every programming language. - **Language-agnostic**: SHA-256 is available in every programming language.
The proof computation is trivially implementable in any client. The proof computation is trivially implementable in any client.
### Login Rate Limiting
The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent
brute-force password attacks. This uses a token-bucket algorithm
(`golang.org/x/time/rate`) with configurable rate and burst.
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP |
| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP |
When the limit is exceeded, the server returns **429 Too Many Requests** with a
`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up
every 10 minutes.
> **⚠️ Security: Reverse Proxy Required for Production Use**
>
> The rate limiter extracts the client IP by checking the `X-Forwarded-For`
> header first, then `X-Real-IP`, and finally falling back to the TCP
> `RemoteAddr`. Both `X-Forwarded-For` and `X-Real-IP` are **client-controlled
> request headers** — any client can set them to arbitrary values.
>
> Without a properly configured reverse proxy in front of this server:
>
> - An attacker can **bypass rate limiting entirely** by rotating
> `X-Forwarded-For` values on each request (each value is treated as a
> distinct IP).
> - An attacker can **deny service to a specific user** by spoofing that user's
> IP in the `X-Forwarded-For` header, exhausting their rate limit bucket.
>
> **Recommendation:** Always deploy behind a reverse proxy (e.g. nginx, Caddy,
> Traefik) that strips or overwrites incoming `X-Forwarded-For` and `X-Real-IP`
> headers with the actual client IP. If running without a reverse proxy, be
> aware that the rate limiting provides no meaningful protection against a
> targeted attack.
**Why rate limits here but not on session creation?** Session creation is
protected by hashcash proof-of-work (stateless, no IP tracking needed). Login
involves bcrypt password verification against a registered account — a
fundamentally different threat model where an attacker targets a specific
account. Per-IP rate limiting is appropriate here because the cost of a wrong
guess is borne by the server (bcrypt), not the client.
--- ---
## Roadmap ## Roadmap

1
go.mod
View File

@@ -16,7 +16,6 @@ require (
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.48.0
golang.org/x/time v0.6.0
modernc.org/sqlite v1.45.0 modernc.org/sqlite v1.45.0
) )

2
go.sum
View File

@@ -151,8 +151,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ func TestRegisterUser(t *testing.T) {
ctx := t.Context() ctx := t.Context()
sessionID, clientID, token, err := sessionID, clientID, token, err :=
database.RegisterUser(ctx, "reguser", "password123") database.RegisterUser(ctx, "reguser", "password123", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) 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) { func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel() t.Parallel()
@@ -45,7 +108,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) {
ctx := t.Context() ctx := t.Context()
regSID, regCID, regToken, err := regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "dupnick", "password123") database.RegisterUser(ctx, "dupnick", "password123", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -55,7 +118,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) {
_ = regToken _ = regToken
dupSID, dupCID, dupToken, dupErr := dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345") database.RegisterUser(ctx, "dupnick", "other12345", "", "", "")
if dupErr == nil { if dupErr == nil {
t.Fatal("expected error for duplicate nick") t.Fatal("expected error for duplicate nick")
} }
@@ -72,7 +135,7 @@ func TestLoginUser(t *testing.T) {
ctx := t.Context() ctx := t.Context()
regSID, regCID, regToken, err := regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "loginuser", "mypassword") database.RegisterUser(ctx, "loginuser", "mypassword", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -82,7 +145,7 @@ func TestLoginUser(t *testing.T) {
_ = regToken _ = regToken
sessionID, clientID, token, err := sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword") database.LoginUser(ctx, "loginuser", "mypassword", "", "")
if err != nil { if err != nil {
t.Fatal(err) 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) { func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel() t.Parallel()
@@ -110,7 +250,7 @@ func TestLoginUserWrongPassword(t *testing.T) {
ctx := t.Context() ctx := t.Context()
regSID, regCID, regToken, err := regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass") database.RegisterUser(ctx, "wrongpw", "correctpass", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -120,7 +260,7 @@ func TestLoginUserWrongPassword(t *testing.T) {
_ = regToken _ = regToken
loginSID, loginCID, loginToken, loginErr := loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12") database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
if loginErr == nil { if loginErr == nil {
t.Fatal("expected error for wrong password") t.Fatal("expected error for wrong password")
} }
@@ -138,7 +278,7 @@ func TestLoginUserNoPassword(t *testing.T) {
// Create anonymous session (no password). // Create anonymous session (no password).
anonSID, anonCID, anonToken, err := anonSID, anonCID, anonToken, err :=
database.CreateSession(ctx, "anon") database.CreateSession(ctx, "anon", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -148,7 +288,7 @@ func TestLoginUserNoPassword(t *testing.T) {
_ = anonToken _ = anonToken
loginSID, loginCID, loginToken, loginErr := loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "anon", "anything1") database.LoginUser(ctx, "anon", "anything1", "", "")
if loginErr == nil { if loginErr == nil {
t.Fatal( t.Fatal(
"expected error for login on passwordless account", "expected error for login on passwordless account",
@@ -167,7 +307,7 @@ func TestLoginUserNonexistent(t *testing.T) {
ctx := t.Context() ctx := t.Context()
loginSID, loginCID, loginToken, err := loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "ghost", "password123") database.LoginUser(ctx, "ghost", "password123", "", "")
if err == nil { if err == nil {
t.Fatal("expected error for nonexistent user") t.Fatal("expected error for nonexistent user")
} }

View File

@@ -74,14 +74,40 @@ type ChannelInfo struct {
type MemberInfo struct { type MemberInfo struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Username string `json:"username"`
Hostname string `json:"hostname"`
LastSeen time.Time `json:"lastSeen"` 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. // CreateSession registers a new session and its first client.
func (database *Database) CreateSession( func (database *Database) CreateSession(
ctx context.Context, ctx context.Context,
nick string, nick, username, hostname, remoteIP string,
) (int64, int64, string, error) { ) (int64, int64, string, error) {
if username == "" {
username = nick
}
sessionUUID := uuid.New().String() sessionUUID := uuid.New().String()
clientUUID := uuid.New().String() clientUUID := uuid.New().String()
@@ -101,9 +127,11 @@ func (database *Database) CreateSession(
res, err := transaction.ExecContext(ctx, res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions `INSERT INTO sessions
(uuid, nick, created_at, last_seen) (uuid, nick, username, hostname, ip,
VALUES (?, ?, ?, ?)`, created_at, last_seen)
sessionUUID, nick, now, now) VALUES (?, ?, ?, ?, ?, ?, ?)`,
sessionUUID, nick, username, hostname,
remoteIP, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -118,10 +146,11 @@ func (database *Database) CreateSession(
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token, ip, hostname,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now) clientUUID, sessionID, tokenHash,
remoteIP, hostname, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -209,6 +238,66 @@ func (database *Database) GetSessionByNick(
return sessionID, nil 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
}
// GetChannelByName returns the channel ID for a name. // GetChannelByName returns the channel ID for a name.
func (database *Database) GetChannelByName( func (database *Database) GetChannelByName(
ctx context.Context, ctx context.Context,
@@ -388,7 +477,8 @@ func (database *Database) ChannelMembers(
channelID int64, channelID int64,
) ([]MemberInfo, error) { ) ([]MemberInfo, error) {
rows, err := database.conn.QueryContext(ctx, 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 FROM sessions s
INNER JOIN channel_members cm INNER JOIN channel_members cm
ON cm.session_id = s.id ON cm.session_id = s.id
@@ -408,7 +498,9 @@ func (database *Database) ChannelMembers(
var member MemberInfo var member MemberInfo
err = rows.Scan( err = rows.Scan(
&member.ID, &member.Nick, &member.LastSeen, &member.ID, &member.Nick,
&member.Username, &member.Hostname,
&member.LastSeen,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(

View File

@@ -34,7 +34,7 @@ func TestCreateSession(t *testing.T) {
ctx := t.Context() ctx := t.Context()
sessionID, _, token, err := database.CreateSession( sessionID, _, token, err := database.CreateSession(
ctx, "alice", ctx, "alice", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -45,7 +45,7 @@ func TestCreateSession(t *testing.T) {
} }
_, _, dupToken, dupErr := database.CreateSession( _, _, dupToken, dupErr := database.CreateSession(
ctx, "alice", ctx, "alice", "", "", "",
) )
if dupErr == nil { if dupErr == nil {
t.Fatal("expected error for duplicate nick") t.Fatal("expected error for duplicate nick")
@@ -54,13 +54,249 @@ func TestCreateSession(t *testing.T) {
_ = dupToken _ = 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) { func TestGetSessionByToken(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
_, _, token, err := database.CreateSession(ctx, "bob") _, _, token, err := database.CreateSession(ctx, "bob", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -93,7 +329,7 @@ func TestGetSessionByNick(t *testing.T) {
ctx := t.Context() ctx := t.Context()
charlieID, charlieClientID, charlieToken, err := charlieID, charlieClientID, charlieToken, err :=
database.CreateSession(ctx, "charlie") database.CreateSession(ctx, "charlie", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -150,7 +386,7 @@ func TestJoinAndPart(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sid, _, _, err := database.CreateSession(ctx, "user1") sid, _, _, err := database.CreateSession(ctx, "user1", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -199,7 +435,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
sid, _, _, err := database.CreateSession(ctx, "temp") sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -234,7 +470,7 @@ func createSessionWithChannels(
ctx := t.Context() ctx := t.Context()
sid, _, _, err := database.CreateSession(ctx, nick) sid, _, _, err := database.CreateSession(ctx, nick, "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -317,7 +553,7 @@ func TestChangeNick(t *testing.T) {
ctx := t.Context() ctx := t.Context()
sid, _, token, err := database.CreateSession( sid, _, token, err := database.CreateSession(
ctx, "old", ctx, "old", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -401,7 +637,7 @@ func TestPollMessages(t *testing.T) {
ctx := t.Context() ctx := t.Context()
sid, _, token, err := database.CreateSession( sid, _, token, err := database.CreateSession(
ctx, "poller", ctx, "poller", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -508,7 +744,7 @@ func TestDeleteSession(t *testing.T) {
ctx := t.Context() ctx := t.Context()
sid, _, _, err := database.CreateSession( sid, _, _, err := database.CreateSession(
ctx, "deleteme", ctx, "deleteme", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -548,12 +784,12 @@ func TestChannelMembers(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sid1, _, _, err := database.CreateSession(ctx, "m1") sid1, _, _, err := database.CreateSession(ctx, "m1", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
sid2, _, _, err := database.CreateSession(ctx, "m2") sid2, _, _, err := database.CreateSession(ctx, "m2", "", "", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -611,7 +847,7 @@ func TestEnqueueToClient(t *testing.T) {
ctx := t.Context() ctx := t.Context()
_, _, token, err := database.CreateSession( _, _, token, err := database.CreateSession(
ctx, "enqclient", ctx, "enqclient", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -6,6 +6,9 @@ CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE, uuid TEXT NOT NULL UNIQUE,
nick TEXT NOT NULL UNIQUE, nick TEXT NOT NULL UNIQUE,
username TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '', away_message TEXT NOT NULL DEFAULT '',
@@ -20,6 +23,8 @@ CREATE TABLE IF NOT EXISTS clients (
uuid TEXT NOT NULL UNIQUE, uuid TEXT NOT NULL UNIQUE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE, token TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
); );

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@@ -23,6 +24,12 @@ var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`, `^#[a-zA-Z0-9_\-]{1,63}$`,
) )
var validUsernameRe = regexp.MustCompile(
`^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`,
)
const dnsLookupTimeout = 3 * time.Second
const ( const (
maxLongPollTimeout = 30 maxLongPollTimeout = 30
pollMessageLimit = 100 pollMessageLimit = 100
@@ -39,6 +46,55 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize return defaultMaxBodySize
} }
// clientIP extracts the connecting client's IP address
// from the request, checking X-Forwarded-For and
// X-Real-IP headers before falling back to RemoteAddr.
func clientIP(request *http.Request) string {
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
// X-Forwarded-For can contain a comma-separated list;
// the first entry is the original client.
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd
ip := strings.TrimSpace(parts[0])
if ip != "" {
return ip
}
}
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
return strings.TrimSpace(realIP)
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
// resolveHostname performs a reverse DNS lookup on the
// given IP address. Returns the first PTR record with the
// trailing dot stripped, or the raw IP if lookup fails.
func resolveHostname(
reqCtx context.Context,
addr string,
) string {
resolver := &net.Resolver{} //nolint:exhaustruct // using default resolver
ctx, cancel := context.WithTimeout(
reqCtx, dnsLookupTimeout,
)
defer cancel()
names, err := resolver.LookupAddr(ctx, addr)
if err != nil || len(names) == 0 {
return addr
}
return strings.TrimSuffix(names[0], ".")
}
// authSession extracts the session from the client token. // authSession extracts the session from the client token.
func (hdlr *Handlers) authSession( func (hdlr *Handlers) authSession(
request *http.Request, request *http.Request,
@@ -146,6 +202,7 @@ func (hdlr *Handlers) handleCreateSession(
) { ) {
type createRequest struct { type createRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Username string `json:"username,omitempty"`
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
} }
@@ -162,30 +219,10 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
// Validate hashcash proof-of-work if configured. if !hdlr.validateHashcash(
if hdlr.params.Config.HashcashBits > 0 { writer, request, payload.Hashcash,
if payload.Hashcash == "" { ) {
hdlr.respondError( return
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return
}
err = hdlr.hashcashVal.Validate(
payload.Hashcash, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return
}
} }
payload.Nick = strings.TrimSpace(payload.Nick) payload.Nick = strings.TrimSpace(payload.Nick)
@@ -200,9 +237,40 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
username := resolveUsername(
payload.Username, payload.Nick,
)
if !validUsernameRe.MatchString(username) {
hdlr.respondError(
writer, request,
"invalid username format",
http.StatusBadRequest,
)
return
}
hdlr.executeCreateSession(
writer, request, payload.Nick, username,
)
}
func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter,
request *http.Request,
nick, username string,
) {
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession( hdlr.params.Database.CreateSession(
request.Context(), payload.Nick, request.Context(),
nick, username, hostname, remoteIP,
) )
if err != nil { if err != nil {
hdlr.handleCreateSessionError( hdlr.handleCreateSessionError(
@@ -215,15 +283,64 @@ func (hdlr *Handlers) handleCreateSession(
hdlr.stats.IncrSessions() hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections() hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) hdlr.deliverMOTD(request, clientID, sessionID, nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": nick,
"token": token, "token": token,
}, http.StatusCreated) }, http.StatusCreated)
} }
// validateHashcash validates a hashcash stamp if required.
// Returns false if validation failed and a response was
// already sent.
func (hdlr *Handlers) validateHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
if hdlr.params.Config.HashcashBits == 0 {
return true
}
if stamp == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return false
}
err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// resolveUsername returns the trimmed username, defaulting
// to the nick if empty.
func resolveUsername(username, nick string) string {
username = strings.TrimSpace(username)
if username == "" {
return nick
}
return username
}
func (hdlr *Handlers) handleCreateSessionError( func (hdlr *Handlers) handleCreateSessionError(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1357,16 +1474,16 @@ func (hdlr *Handlers) deliverNamesNumerics(
) )
if memErr == nil && len(members) > 0 { if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members)) entries := make([]string, 0, len(members))
for _, mem := range members { for _, mem := range members {
nicks = append(nicks, mem.Nick) entries = append(entries, mem.Hostmask())
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplNamReply, nick, ctx, clientID, irc.RplNamReply, nick,
[]string{"=", channel}, []string{"=", channel},
strings.Join(nicks, " "), strings.Join(entries, " "),
) )
} }
@@ -1965,16 +2082,16 @@ func (hdlr *Handlers) handleNames(
ctx, chID, ctx, chID,
) )
if memErr == nil && len(members) > 0 { if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members)) entries := make([]string, 0, len(members))
for _, mem := range members { for _, mem := range members {
nicks = append(nicks, mem.Nick) entries = append(entries, mem.Hostmask())
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplNamReply, nick, ctx, clientID, irc.RplNamReply, nick,
[]string{"=", channel}, []string{"=", channel},
strings.Join(nicks, " "), strings.Join(entries, " "),
) )
} }
@@ -2105,10 +2222,26 @@ func (hdlr *Handlers) executeWhois(
return return
} }
// Look up username and hostname for the target.
username := queryNick
hostname := srvName
hostInfo, hostErr := hdlr.params.Database.
GetSessionHostInfo(ctx, targetSID)
if hostErr == nil && hostInfo != nil {
if hostInfo.Username != "" {
username = hostInfo.Username
}
if hostInfo.Hostname != "" {
hostname = hostInfo.Hostname
}
}
// 311 RPL_WHOISUSER // 311 RPL_WHOISUSER
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisUser, nick, ctx, clientID, irc.RplWhoisUser, nick,
[]string{queryNick, queryNick, srvName, "*"}, []string{queryNick, username, hostname, "*"},
queryNick, queryNick,
) )
@@ -2215,11 +2348,21 @@ func (hdlr *Handlers) handleWho(
) )
if memErr == nil { if memErr == nil {
for _, mem := range members { for _, mem := range members {
username := mem.Username
if username == "" {
username = mem.Nick
}
hostname := mem.Hostname
if hostname == "" {
hostname = srvName
}
// 352 RPL_WHOREPLY // 352 RPL_WHOREPLY
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoReply, nick, ctx, clientID, irc.RplWhoReply, nick,
[]string{ []string{
channel, mem.Nick, srvName, channel, username, hostname,
srvName, mem.Nick, "H", srvName, mem.Nick, "H",
}, },
"0 "+mem.Nick, "0 "+mem.Nick,

View File

@@ -2130,119 +2130,247 @@ func TestSessionStillWorks(t *testing.T) {
} }
} }
func TestLoginRateLimitExceeded(t *testing.T) { // findNumericWithParams returns the first message matching
tserver := newTestServer(t) // the given numeric code. Returns nil if not found.
func findNumericWithParams(
msgs []map[string]any,
numeric string,
) map[string]any {
want, _ := strconv.Atoi(numeric)
// Exhaust the burst (default: 5 per IP) using for _, msg := range msgs {
// nonexistent users. These fail fast (no bcrypt), code, ok := msg["code"].(float64)
// preventing token replenishment between requests. if ok && int(code) == want {
for range 5 { return msg
loginBody, mErr := json.Marshal(
map[string]string{
"nick": "nosuchuser",
"password": "doesnotmatter",
},
)
if mErr != nil {
t.Fatal(mErr)
} }
loginResp, rErr := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if rErr != nil {
t.Fatal(rErr)
}
_ = loginResp.Body.Close()
} }
// The next request should be rate-limited. return nil
loginBody, err := json.Marshal(map[string]string{ }
"nick": "nosuchuser", "password": "doesnotmatter",
// getNumericParams extracts the params array from a
// numeric message as a string slice.
func getNumericParams(
msg map[string]any,
) []string {
raw, exists := msg["params"]
if !exists || raw == nil {
return nil
}
arr, isArr := raw.([]any)
if !isArr {
return nil
}
result := make([]string, 0, len(arr))
for _, val := range arr {
str, isString := val.(string)
if isString {
result = append(result, str)
}
}
return result
}
func TestWhoisShowsHostInfo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSessionWithUsername(
"whoisuser", "myident",
)
queryToken := tserver.createSession("querier")
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "whoisuser",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
whoisMsg := findNumericWithParams(msgs, "311")
if whoisMsg == nil {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
params := getNumericParams(whoisMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
if params[1] != "myident" {
t.Fatalf(
"expected username myident, got %s",
params[1],
)
}
_ = token
}
// createSessionWithUsername creates a session with a
// specific username and returns the token.
func (tserver *testServer) createSessionWithUsername(
nick, username string,
) string {
tserver.t.Helper()
body, err := json.Marshal(map[string]string{
"nick": nick,
"username": username,
}) })
if err != nil { if err != nil {
t.Fatal(err) tserver.t.Fatalf("marshal session: %v", err)
} }
resp, err := doRequest( resp, err := doRequest(
t, tserver.t,
http.MethodPost, http.MethodPost,
tserver.url("/api/v1/login"), tserver.url(apiSession),
bytes.NewReader(loginBody), bytes.NewReader(body),
) )
if err != nil { if err != nil {
t.Fatal(err) tserver.t.Fatalf("create session: %v", err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusTooManyRequests { if resp.StatusCode != http.StatusCreated {
t.Fatalf( respBody, _ := io.ReadAll(resp.Body)
"expected 429, got %d", tserver.t.Fatalf(
resp.StatusCode, "create session: status %d: %s",
resp.StatusCode, respBody,
) )
} }
retryAfter := resp.Header.Get("Retry-After") var result struct {
if retryAfter == "" { Token string `json:"token"`
t.Fatal("expected Retry-After header")
} }
_ = json.NewDecoder(resp.Body).Decode(&result)
return result.Token
} }
func TestLoginRateLimitAllowsNormalUse(t *testing.T) { func TestWhoShowsHostInfo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
// Register a user. whoToken := tserver.createSessionWithUsername(
regBody, err := json.Marshal(map[string]string{ "whouser", "whoident",
"nick": "normaluser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
) )
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close() tserver.sendCommand(whoToken, map[string]any{
commandKey: joinCmd, toKey: "#whotest",
// A single login should succeed without rate limiting.
loginBody, err := json.Marshal(map[string]string{
"nick": "normaluser", "password": "password123",
}) })
if err != nil {
t.Fatal(err) queryToken := tserver.createSession("whoquerier")
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: "#whotest",
})
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHO",
toKey: "#whotest",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
assertWhoReplyUsername(t, msgs, "whouser", "whoident")
}
func assertWhoReplyUsername(
t *testing.T,
msgs []map[string]any,
targetNick, expectedUsername string,
) {
t.Helper()
for _, msg := range msgs {
code, isCode := msg["code"].(float64)
if !isCode || int(code) != 352 {
continue
}
params := getNumericParams(msg)
if len(params) < 5 || params[4] != targetNick {
continue
}
if params[1] != expectedUsername {
t.Fatalf(
"expected username %s in WHO, got %s",
expectedUsername, params[1],
)
}
return
} }
resp2, err := doRequest( t.Fatalf(
t, "expected RPL_WHOREPLY (352) for %s, msgs: %v",
http.MethodPost, targetNick, msgs,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
) )
if err != nil { }
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }() func TestSessionUsernameDefault(t *testing.T) {
tserver := newTestServer(t)
if resp2.StatusCode != http.StatusOK { // Create session without specifying username.
respBody, _ := io.ReadAll(resp2.Body) token := tserver.createSession("defaultusr")
queryToken := tserver.createSession("querier2")
_, lastID := tserver.pollMessages(queryToken, 0)
// WHOIS should show the nick as the username.
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "defaultusr",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
whoisMsg := findNumericWithParams(msgs, "311")
if whoisMsg == nil {
t.Fatalf( t.Fatalf(
"expected 200, got %d: %s", "expected RPL_WHOISUSER (311), got %v",
resp2.StatusCode, respBody, msgs,
) )
} }
params := getNumericParams(whoisMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
// Username defaults to nick.
if params[1] != "defaultusr" {
t.Fatalf(
"expected default username defaultusr, got %s",
params[1],
)
}
_ = token
} }
func TestNickBroadcastToChannels(t *testing.T) { func TestNickBroadcastToChannels(t *testing.T) {
@@ -2272,3 +2400,135 @@ func TestNickBroadcastToChannels(t *testing.T) {
) )
} }
} }
func TestNamesShowsHostmask(t *testing.T) {
tserver := newTestServer(t)
queryToken, lastID := setupChannelWithIdentMember(
tserver, "namesmember", "nmident",
"namesquery", "#namestest",
)
// Issue an explicit NAMES command.
tserver.sendCommand(queryToken, map[string]any{
commandKey: "NAMES",
toKey: "#namestest",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
assertNamesHostmask(
t, msgs, "namesmember", "nmident",
)
}
func TestNamesOnJoinShowsHostmask(t *testing.T) {
tserver := newTestServer(t)
// First user joins to populate the channel.
firstToken := tserver.createSessionWithUsername(
"joinmem", "jmident",
)
tserver.sendCommand(firstToken, map[string]any{
commandKey: joinCmd, toKey: "#joinnamestest",
})
// Second user joins; the JOIN triggers
// deliverNamesNumerics which should include
// hostmask data.
joinerToken := tserver.createSession("joiner")
tserver.sendCommand(joinerToken, map[string]any{
commandKey: joinCmd, toKey: "#joinnamestest",
})
msgs, _ := tserver.pollMessages(joinerToken, 0)
assertNamesHostmask(
t, msgs, "joinmem", "jmident",
)
}
// setupChannelWithIdentMember creates a member session
// with username, joins a channel, then creates a querier
// and joins the same channel. Returns the querier token
// and last message ID.
func setupChannelWithIdentMember(
tserver *testServer,
memberNick, memberUsername,
querierNick, channel string,
) (string, int64) {
tserver.t.Helper()
memberToken := tserver.createSessionWithUsername(
memberNick, memberUsername,
)
tserver.sendCommand(memberToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
queryToken := tserver.createSession(querierNick)
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(queryToken, 0)
return queryToken, lastID
}
// assertNamesHostmask verifies that a RPL_NAMREPLY (353)
// message contains the expected nick with hostmask format
// (nick!user@host).
func assertNamesHostmask(
t *testing.T,
msgs []map[string]any,
targetNick, expectedUsername string,
) {
t.Helper()
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if !ok || int(code) != 353 {
continue
}
raw, exists := msg["body"]
if !exists || raw == nil {
continue
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
continue
}
bodyStr, isStr := arr[0].(string)
if !isStr {
continue
}
// Look for the target nick's hostmask entry.
expected := targetNick + "!" +
expectedUsername + "@"
if !strings.Contains(bodyStr, expected) {
t.Fatalf(
"expected NAMES body to contain %q, "+
"got %q",
expected, bodyStr,
)
}
return
}
t.Fatalf(
"expected RPL_NAMREPLY (353) with hostmask "+
"for %s, msgs: %v",
targetNick, msgs,
)
}

View File

@@ -2,7 +2,6 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"strings" "strings"
@@ -11,33 +10,6 @@ import (
const minPasswordLength = 8 const minPasswordLength = 8
// clientIP extracts the client IP address from the request.
// It checks X-Forwarded-For and X-Real-IP headers before
// falling back to RemoteAddr.
func clientIP(request *http.Request) string {
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
// X-Forwarded-For may contain a comma-separated list;
// the first entry is the original client.
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd // split into two parts
ip := strings.TrimSpace(parts[0])
if ip != "" {
return ip
}
}
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
return strings.TrimSpace(realIP)
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
// HandleRegister creates a new user with a password. // HandleRegister creates a new user with a password.
func (hdlr *Handlers) HandleRegister() http.HandlerFunc { func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
return func( return func(
@@ -58,6 +30,7 @@ func (hdlr *Handlers) handleRegister(
) { ) {
type registerRequest struct { type registerRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Username string `json:"username,omitempty"`
Password string `json:"password"` Password string `json:"password"`
} }
@@ -86,6 +59,20 @@ func (hdlr *Handlers) handleRegister(
return 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 { if len(payload.Password) < minPasswordLength {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
@@ -96,11 +83,27 @@ func (hdlr *Handlers) handleRegister(
return 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 := sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser( hdlr.params.Database.RegisterUser(
request.Context(), request.Context(),
payload.Nick, nick, password, username, hostname, remoteIP,
payload.Password,
) )
if err != nil { if err != nil {
hdlr.handleRegisterError( hdlr.handleRegisterError(
@@ -113,11 +116,11 @@ func (hdlr *Handlers) handleRegister(
hdlr.stats.IncrSessions() hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections() hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) hdlr.deliverMOTD(request, clientID, sessionID, nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": nick,
"token": token, "token": token,
}, http.StatusCreated) }, http.StatusCreated)
} }
@@ -165,21 +168,6 @@ func (hdlr *Handlers) handleLogin(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
ip := clientIP(request)
if !hdlr.loginLimiter.Allow(ip) {
writer.Header().Set(
"Retry-After", "1",
)
hdlr.respondError(
writer, request,
"too many login attempts, try again later",
http.StatusTooManyRequests,
)
return
}
type loginRequest struct { type loginRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Password string `json:"password"` Password string `json:"password"`
@@ -210,11 +198,18 @@ func (hdlr *Handlers) handleLogin(
return return
} }
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser( hdlr.params.Database.LoginUser(
request.Context(), request.Context(),
payload.Nick, payload.Nick,
payload.Password, payload.Password,
remoteIP, hostname,
) )
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(

View File

@@ -16,7 +16,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/hashcash" "git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
"git.eeqj.de/sneak/neoirc/internal/stats" "git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -44,7 +43,6 @@ type Handlers struct {
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
loginLimiter *ratelimit.Limiter
stats *stats.Tracker stats *stats.Tracker
cancelCleanup context.CancelFunc cancelCleanup context.CancelFunc
} }
@@ -59,24 +57,13 @@ func New(
resource = "neoirc" resource = "neoirc"
} }
loginRate := params.Config.LoginRateLimit
if loginRate <= 0 {
loginRate = ratelimit.DefaultRate
}
loginBurst := params.Config.LoginRateBurst
if loginBurst <= 0 {
loginBurst = ratelimit.DefaultBurst
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
broker: broker.New(), broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
loginLimiter: ratelimit.New(loginRate, loginBurst), stats: params.Stats,
stats: params.Stats,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
@@ -168,10 +155,6 @@ func (hdlr *Handlers) stopCleanup() {
if hdlr.cancelCleanup != nil { if hdlr.cancelCleanup != nil {
hdlr.cancelCleanup() hdlr.cancelCleanup()
} }
if hdlr.loginLimiter != nil {
hdlr.loginLimiter.Stop()
}
} }
func (hdlr *Handlers) cleanupLoop(ctx context.Context) { func (hdlr *Handlers) cleanupLoop(ctx context.Context) {

View File

@@ -1,122 +0,0 @@
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
package ratelimit
import (
"sync"
"time"
"golang.org/x/time/rate"
)
const (
// DefaultRate is the default number of allowed requests per second.
DefaultRate = 1.0
// DefaultBurst is the default maximum burst size.
DefaultBurst = 5
// DefaultSweepInterval controls how often stale entries are pruned.
DefaultSweepInterval = 10 * time.Minute
// DefaultEntryTTL is how long an unused entry lives before eviction.
DefaultEntryTTL = 15 * time.Minute
)
// entry tracks a per-IP rate limiter and when it was last used.
type entry struct {
limiter *rate.Limiter
lastSeen time.Time
}
// Limiter manages per-key rate limiters with automatic cleanup
// of stale entries.
type Limiter struct {
mu sync.Mutex
entries map[string]*entry
rate rate.Limit
burst int
entryTTL time.Duration
stopCh chan struct{}
}
// New creates a new per-key rate Limiter.
// The ratePerSec parameter sets how many requests per second are
// allowed per key. The burst parameter sets the maximum number of
// requests that can be made in a single burst.
func New(ratePerSec float64, burst int) *Limiter {
limiter := &Limiter{
mu: sync.Mutex{},
entries: make(map[string]*entry),
rate: rate.Limit(ratePerSec),
burst: burst,
entryTTL: DefaultEntryTTL,
stopCh: make(chan struct{}),
}
go limiter.sweepLoop()
return limiter
}
// Allow reports whether a request from the given key should be
// allowed. It consumes one token from the key's rate limiter.
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
ent, exists := l.entries[key]
if !exists {
ent = &entry{
limiter: rate.NewLimiter(l.rate, l.burst),
lastSeen: time.Now(),
}
l.entries[key] = ent
} else {
ent.lastSeen = time.Now()
}
l.mu.Unlock()
return ent.limiter.Allow()
}
// Stop terminates the background sweep goroutine.
func (l *Limiter) Stop() {
close(l.stopCh)
}
// Len returns the number of tracked keys (for testing).
func (l *Limiter) Len() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.entries)
}
// sweepLoop periodically removes entries that haven't been seen
// within the TTL.
func (l *Limiter) sweepLoop() {
ticker := time.NewTicker(DefaultSweepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
l.sweep()
case <-l.stopCh:
return
}
}
}
// sweep removes stale entries.
func (l *Limiter) sweep() {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := time.Now().Add(-l.entryTTL)
for key, ent := range l.entries {
if ent.lastSeen.Before(cutoff) {
delete(l.entries, key)
}
}
}

View File

@@ -1,106 +0,0 @@
package ratelimit_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
)
func TestNewCreatesLimiter(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
defer limiter.Stop()
if limiter == nil {
t.Fatal("expected non-nil limiter")
}
}
func TestAllowWithinBurst(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 3)
defer limiter.Stop()
for i := range 3 {
if !limiter.Allow("192.168.1.1") {
t.Fatalf(
"request %d should be allowed within burst",
i+1,
)
}
}
}
func TestAllowExceedsBurst(t *testing.T) {
t.Parallel()
// Rate of 0 means no token replenishment, only burst.
limiter := ratelimit.New(0, 3)
defer limiter.Stop()
for range 3 {
limiter.Allow("10.0.0.1")
}
if limiter.Allow("10.0.0.1") {
t.Fatal("fourth request should be denied after burst exhausted")
}
}
func TestAllowSeparateKeys(t *testing.T) {
t.Parallel()
// Rate of 0, burst of 1 — only one request per key.
limiter := ratelimit.New(0, 1)
defer limiter.Stop()
if !limiter.Allow("10.0.0.1") {
t.Fatal("first request for key A should be allowed")
}
if !limiter.Allow("10.0.0.2") {
t.Fatal("first request for key B should be allowed")
}
if limiter.Allow("10.0.0.1") {
t.Fatal("second request for key A should be denied")
}
if limiter.Allow("10.0.0.2") {
t.Fatal("second request for key B should be denied")
}
}
func TestLenTracksKeys(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
defer limiter.Stop()
if limiter.Len() != 0 {
t.Fatalf("expected 0 entries, got %d", limiter.Len())
}
limiter.Allow("10.0.0.1")
limiter.Allow("10.0.0.2")
if limiter.Len() != 2 {
t.Fatalf("expected 2 entries, got %d", limiter.Len())
}
// Same key again should not increase count.
limiter.Allow("10.0.0.1")
if limiter.Len() != 2 {
t.Fatalf("expected 2 entries, got %d", limiter.Len())
}
}
func TestStopDoesNotPanic(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
limiter.Stop()
}