Compare commits
2 Commits
feature/us
...
feature/lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e925bf70 | ||
|
|
e519ffa1e6 |
95
README.md
95
README.md
@@ -207,32 +207,6 @@ 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
|
||||||
@@ -1002,7 +976,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","bobident","host.example.com","*"],"body":["bob"]}` |
|
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}` |
|
||||||
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
|
| `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"]}` |
|
||||||
@@ -1013,8 +987,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","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` |
|
| `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!op1@host1 alice!alice@host2 bob!bob@host3"]}` |
|
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
|
||||||
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
| `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 -"]}` |
|
||||||
@@ -1082,20 +1056,14 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
|||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{"nick": "alice", "username": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Constraints |
|
| Field | Type | Required | Constraints |
|
||||||
|------------|--------|-------------|-------------|
|
|------------|--------|-------------|-------------|
|
||||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||||
| `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) |
|
| `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
|
||||||
{
|
{
|
||||||
@@ -1116,7 +1084,6 @@ the hostmask used in WHOIS, WHO, and future ban matching (`+b`).
|
|||||||
| 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 |
|
||||||
@@ -1138,18 +1105,14 @@ remains active.
|
|||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{"nick": "alice", "username": "alice", "password": "mypassword"}
|
{"nick": "alice", "password": "mypassword"}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Constraints |
|
| Field | Type | Required | Constraints |
|
||||||
|------------|--------|----------|-------------|
|
|------------|--------|----------|-------------|
|
||||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||||
| `username` | string | No | 1–32 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
|
||||||
{
|
{
|
||||||
@@ -1170,7 +1133,6 @@ hostmask (`nick!user@host`) shown in WHOIS and WHO responses.
|
|||||||
| 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 |
|
||||||
|
|
||||||
@@ -1979,8 +1941,6 @@ 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) |
|
||||||
@@ -2091,6 +2051,8 @@ directory is also loaded automatically via
|
|||||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||||
|
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||||||
|
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
@@ -2495,6 +2457,49 @@ creating one session pays once and keeps their session.
|
|||||||
- **Language-agnostic**: SHA-256 is available in every programming language.
|
- **Language-agnostic**: SHA-256 is available in every programming language.
|
||||||
The proof computation is trivially implementable in any client.
|
The proof computation is trivially implementable in any client.
|
||||||
|
|
||||||
|
### Login Rate Limiting
|
||||||
|
|
||||||
|
The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent
|
||||||
|
brute-force password attacks. This uses a token-bucket algorithm
|
||||||
|
(`golang.org/x/time/rate`) with configurable rate and burst.
|
||||||
|
|
||||||
|
| Environment Variable | Default | Description |
|
||||||
|
|---------------------|---------|-------------|
|
||||||
|
| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP |
|
||||||
|
| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP |
|
||||||
|
|
||||||
|
When the limit is exceeded, the server returns **429 Too Many Requests** with a
|
||||||
|
`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up
|
||||||
|
every 10 minutes.
|
||||||
|
|
||||||
|
> **⚠️ Security: Reverse Proxy Required for Production Use**
|
||||||
|
>
|
||||||
|
> The rate limiter extracts the client IP by checking the `X-Forwarded-For`
|
||||||
|
> header first, then `X-Real-IP`, and finally falling back to the TCP
|
||||||
|
> `RemoteAddr`. Both `X-Forwarded-For` and `X-Real-IP` are **client-controlled
|
||||||
|
> request headers** — any client can set them to arbitrary values.
|
||||||
|
>
|
||||||
|
> Without a properly configured reverse proxy in front of this server:
|
||||||
|
>
|
||||||
|
> - An attacker can **bypass rate limiting entirely** by rotating
|
||||||
|
> `X-Forwarded-For` values on each request (each value is treated as a
|
||||||
|
> distinct IP).
|
||||||
|
> - An attacker can **deny service to a specific user** by spoofing that user's
|
||||||
|
> IP in the `X-Forwarded-For` header, exhausting their rate limit bucket.
|
||||||
|
>
|
||||||
|
> **Recommendation:** Always deploy behind a reverse proxy (e.g. nginx, Caddy,
|
||||||
|
> Traefik) that strips or overwrites incoming `X-Forwarded-For` and `X-Real-IP`
|
||||||
|
> headers with the actual client IP. If running without a reverse proxy, be
|
||||||
|
> aware that the rate limiting provides no meaningful protection against a
|
||||||
|
> targeted attack.
|
||||||
|
|
||||||
|
**Why rate limits here but not on session creation?** Session creation is
|
||||||
|
protected by hashcash proof-of-work (stateless, no IP tracking needed). Login
|
||||||
|
involves bcrypt password verification against a registered account — a
|
||||||
|
fundamentally different threat model where an attacker targets a specific
|
||||||
|
account. Per-IP rate limiting is appropriate here because the cost of a wrong
|
||||||
|
guess is borne by the server (bcrypt), not the client.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
|
golang.org/x/time v0.6.0
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -151,6 +151,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type Config struct {
|
|||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
HashcashBits int
|
HashcashBits int
|
||||||
|
LoginRateLimit float64
|
||||||
|
LoginRateBurst int
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,8 @@ func New(
|
|||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
||||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
|
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
||||||
|
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,6 +108,8 @@ func New(
|
|||||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||||
|
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
||||||
|
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,8 @@ 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, username, hostname, remoteIP string,
|
nick, password 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,
|
||||||
)
|
)
|
||||||
@@ -54,11 +50,10 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
res, err := transaction.ExecContext(ctx,
|
res, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO sessions
|
`INSERT INTO sessions
|
||||||
(uuid, nick, username, hostname, ip,
|
(uuid, nick, password_hash,
|
||||||
password_hash, created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
sessionUUID, nick, username, hostname,
|
sessionUUID, nick, string(hash), now, now)
|
||||||
remoteIP, string(hash), now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -73,11 +68,10 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token, ip, hostname,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash,
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
remoteIP, hostname, now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -102,7 +96,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, remoteIP, hostname string,
|
nick, password string,
|
||||||
) (int64, int64, string, error) {
|
) (int64, int64, string, error) {
|
||||||
var (
|
var (
|
||||||
sessionID int64
|
sessionID int64
|
||||||
@@ -149,11 +143,10 @@ 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, ip, hostname,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash,
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
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,
|
||||||
|
|||||||
@@ -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,69 +38,6 @@ func TestRegisterUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterUserWithUserHost(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sessionID, _, _, err := database.RegisterUser(
|
|
||||||
ctx, "reguhost", "password123",
|
|
||||||
"myident", "example.org", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := database.GetSessionHostInfo(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Username != "myident" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected myident, got %s", info.Username,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Hostname != "example.org" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected example.org, got %s",
|
|
||||||
info.Hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterUserDefaultUsername(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sessionID, _, _, err := database.RegisterUser(
|
|
||||||
ctx, "regdefault", "password123", "", "", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := database.GetSessionHostInfo(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Username != "regdefault" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected regdefault, got %s",
|
|
||||||
info.Username,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterUserDuplicateNick(t *testing.T) {
|
func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -108,7 +45,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)
|
||||||
}
|
}
|
||||||
@@ -118,7 +55,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")
|
||||||
}
|
}
|
||||||
@@ -135,7 +72,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)
|
||||||
}
|
}
|
||||||
@@ -145,7 +82,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)
|
||||||
}
|
}
|
||||||
@@ -166,83 +103,6 @@ func TestLoginUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginUserStoresClientIPHostname(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
regSID, regCID, regToken, err := database.RegisterUser(
|
|
||||||
ctx, "loginipuser", "password123",
|
|
||||||
"", "", "10.0.0.1",
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = regSID
|
|
||||||
_ = regCID
|
|
||||||
_ = regToken
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, clientID, _, err := database.LoginUser(
|
|
||||||
ctx, "loginipuser", "password123",
|
|
||||||
"10.0.0.99", "newhost.example.com",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientInfo, err := database.GetClientHostInfo(
|
|
||||||
ctx, clientID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientInfo.IP != "10.0.0.99" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected client IP 10.0.0.99, got %s",
|
|
||||||
clientInfo.IP,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientInfo.Hostname != "newhost.example.com" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected hostname newhost.example.com, got %s",
|
|
||||||
clientInfo.Hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterUserStoresSessionIP(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sessionID, _, _, err := database.RegisterUser(
|
|
||||||
ctx, "regipuser", "password123",
|
|
||||||
"ident", "host.local", "172.16.0.5",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := database.GetSessionHostInfo(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IP != "172.16.0.5" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected session IP 172.16.0.5, got %s",
|
|
||||||
info.IP,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginUserWrongPassword(t *testing.T) {
|
func TestLoginUserWrongPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -250,7 +110,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)
|
||||||
}
|
}
|
||||||
@@ -260,7 +120,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")
|
||||||
}
|
}
|
||||||
@@ -278,7 +138,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)
|
||||||
}
|
}
|
||||||
@@ -288,7 +148,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",
|
||||||
@@ -307,7 +167,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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,40 +74,14 @@ 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, username, hostname, remoteIP string,
|
nick 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()
|
||||||
|
|
||||||
@@ -127,11 +101,9 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
res, err := transaction.ExecContext(ctx,
|
res, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO sessions
|
`INSERT INTO sessions
|
||||||
(uuid, nick, username, hostname, ip,
|
(uuid, nick, created_at, last_seen)
|
||||||
created_at, last_seen)
|
VALUES (?, ?, ?, ?)`,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
sessionUUID, nick, now, now)
|
||||||
sessionUUID, nick, username, hostname,
|
|
||||||
remoteIP, now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -146,11 +118,10 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token, ip, hostname,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash,
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
remoteIP, hostname, now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -238,66 +209,6 @@ 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,
|
||||||
@@ -477,8 +388,7 @@ 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.username,
|
`SELECT s.id, s.nick, s.last_seen
|
||||||
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
|
||||||
@@ -498,9 +408,7 @@ func (database *Database) ChannelMembers(
|
|||||||
var member MemberInfo
|
var member MemberInfo
|
||||||
|
|
||||||
err = rows.Scan(
|
err = rows.Scan(
|
||||||
&member.ID, &member.Nick,
|
&member.ID, &member.Nick, &member.LastSeen,
|
||||||
&member.Username, &member.Hostname,
|
|
||||||
&member.LastSeen,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
|
|||||||
@@ -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,249 +54,13 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -329,7 +93,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)
|
||||||
}
|
}
|
||||||
@@ -386,7 +150,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)
|
||||||
}
|
}
|
||||||
@@ -435,7 +199,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)
|
||||||
}
|
}
|
||||||
@@ -470,7 +234,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)
|
||||||
}
|
}
|
||||||
@@ -553,7 +317,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)
|
||||||
@@ -637,7 +401,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)
|
||||||
@@ -744,7 +508,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)
|
||||||
@@ -784,12 +548,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)
|
||||||
}
|
}
|
||||||
@@ -847,7 +611,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)
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ 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 '',
|
||||||
@@ -23,8 +20,6 @@ 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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -24,12 +23,6 @@ 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
|
||||||
@@ -46,55 +39,6 @@ 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,
|
||||||
@@ -202,7 +146,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,10 +162,30 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hdlr.validateHashcash(
|
// Validate hashcash proof-of-work if configured.
|
||||||
writer, request, payload.Hashcash,
|
if hdlr.params.Config.HashcashBits > 0 {
|
||||||
) {
|
if payload.Hashcash == "" {
|
||||||
return
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"hashcash proof-of-work required",
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = hdlr.hashcashVal.Validate(
|
||||||
|
payload.Hashcash, hdlr.params.Config.HashcashBits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid hashcash stamp: "+err.Error(),
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
payload.Nick = strings.TrimSpace(payload.Nick)
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
||||||
@@ -237,40 +200,9 @@ 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(),
|
request.Context(), payload.Nick,
|
||||||
nick, username, hostname, remoteIP,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.handleCreateSessionError(
|
hdlr.handleCreateSessionError(
|
||||||
@@ -283,64 +215,15 @@ func (hdlr *Handlers) executeCreateSession(
|
|||||||
hdlr.stats.IncrSessions()
|
hdlr.stats.IncrSessions()
|
||||||
hdlr.stats.IncrConnections()
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, nick)
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": payload.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,
|
||||||
@@ -1474,16 +1357,16 @@ func (hdlr *Handlers) deliverNamesNumerics(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if memErr == nil && len(members) > 0 {
|
if memErr == nil && len(members) > 0 {
|
||||||
entries := make([]string, 0, len(members))
|
nicks := make([]string, 0, len(members))
|
||||||
|
|
||||||
for _, mem := range members {
|
for _, mem := range members {
|
||||||
entries = append(entries, mem.Hostmask())
|
nicks = append(nicks, mem.Nick)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNamReply, nick,
|
ctx, clientID, irc.RplNamReply, nick,
|
||||||
[]string{"=", channel},
|
[]string{"=", channel},
|
||||||
strings.Join(entries, " "),
|
strings.Join(nicks, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2082,16 +1965,16 @@ func (hdlr *Handlers) handleNames(
|
|||||||
ctx, chID,
|
ctx, chID,
|
||||||
)
|
)
|
||||||
if memErr == nil && len(members) > 0 {
|
if memErr == nil && len(members) > 0 {
|
||||||
entries := make([]string, 0, len(members))
|
nicks := make([]string, 0, len(members))
|
||||||
|
|
||||||
for _, mem := range members {
|
for _, mem := range members {
|
||||||
entries = append(entries, mem.Hostmask())
|
nicks = append(nicks, mem.Nick)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNamReply, nick,
|
ctx, clientID, irc.RplNamReply, nick,
|
||||||
[]string{"=", channel},
|
[]string{"=", channel},
|
||||||
strings.Join(entries, " "),
|
strings.Join(nicks, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2222,26 +2105,10 @@ 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, username, hostname, "*"},
|
[]string{queryNick, queryNick, srvName, "*"},
|
||||||
queryNick,
|
queryNick,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2348,21 +2215,11 @@ 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, username, hostname,
|
channel, mem.Nick, srvName,
|
||||||
srvName, mem.Nick, "H",
|
srvName, mem.Nick, "H",
|
||||||
},
|
},
|
||||||
"0 "+mem.Nick,
|
"0 "+mem.Nick,
|
||||||
|
|||||||
@@ -2130,247 +2130,119 @@ func TestSessionStillWorks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// findNumericWithParams returns the first message matching
|
func TestLoginRateLimitExceeded(t *testing.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)
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
code, ok := msg["code"].(float64)
|
|
||||||
if ok && int(code) == want {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNumericParams extracts the params array from a
|
|
||||||
// numeric message as a string slice.
|
|
||||||
func getNumericParams(
|
|
||||||
msg map[string]any,
|
|
||||||
) []string {
|
|
||||||
raw, exists := msg["params"]
|
|
||||||
if !exists || raw == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
arr, isArr := raw.([]any)
|
|
||||||
if !isArr {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]string, 0, len(arr))
|
|
||||||
|
|
||||||
for _, val := range arr {
|
|
||||||
str, isString := val.(string)
|
|
||||||
if isString {
|
|
||||||
result = append(result, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhoisShowsHostInfo(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
token := tserver.createSessionWithUsername(
|
// Exhaust the burst (default: 5 per IP) using
|
||||||
"whoisuser", "myident",
|
// nonexistent users. These fail fast (no bcrypt),
|
||||||
)
|
// preventing token replenishment between requests.
|
||||||
|
for range 5 {
|
||||||
queryToken := tserver.createSession("querier")
|
loginBody, mErr := json.Marshal(
|
||||||
|
map[string]string{
|
||||||
_, lastID := tserver.pollMessages(queryToken, 0)
|
"nick": "nosuchuser",
|
||||||
|
"password": "doesnotmatter",
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
params := getNumericParams(whoisMsg)
|
// The next request should be rate-limited.
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
if len(params) < 2 {
|
"nick": "nosuchuser", "password": "doesnotmatter",
|
||||||
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 {
|
||||||
tserver.t.Fatalf("marshal session: %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := doRequest(
|
resp, err := doRequest(
|
||||||
tserver.t,
|
t,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
tserver.url(apiSession),
|
tserver.url("/api/v1/login"),
|
||||||
bytes.NewReader(body),
|
bytes.NewReader(loginBody),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tserver.t.Fatalf("create session: %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusTooManyRequests {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
t.Fatalf(
|
||||||
tserver.t.Fatalf(
|
"expected 429, got %d",
|
||||||
"create session: status %d: %s",
|
resp.StatusCode,
|
||||||
resp.StatusCode, respBody,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
Token string `json:"token"`
|
if retryAfter == "" {
|
||||||
|
t.Fatal("expected Retry-After header")
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
return result.Token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWhoShowsHostInfo(t *testing.T) {
|
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
whoToken := tserver.createSessionWithUsername(
|
// Register a user.
|
||||||
"whouser", "whoident",
|
regBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "normaluser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/register"),
|
||||||
|
bytes.NewReader(regBody),
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
tserver.sendCommand(whoToken, map[string]any{
|
t.Fatal(err)
|
||||||
commandKey: joinCmd, toKey: "#whotest",
|
|
||||||
})
|
|
||||||
|
|
||||||
queryToken := tserver.createSession("whoquerier")
|
|
||||||
|
|
||||||
tserver.sendCommand(queryToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#whotest",
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(queryToken, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(queryToken, map[string]any{
|
|
||||||
commandKey: "WHO",
|
|
||||||
toKey: "#whotest",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(queryToken, lastID)
|
|
||||||
|
|
||||||
assertWhoReplyUsername(t, msgs, "whouser", "whoident")
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertWhoReplyUsername(
|
|
||||||
t *testing.T,
|
|
||||||
msgs []map[string]any,
|
|
||||||
targetNick, expectedUsername string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
code, isCode := msg["code"].(float64)
|
|
||||||
if !isCode || int(code) != 352 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
params := getNumericParams(msg)
|
|
||||||
if len(params) < 5 || params[4] != targetNick {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if params[1] != expectedUsername {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected username %s in WHO, got %s",
|
|
||||||
expectedUsername, params[1],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Fatalf(
|
_ = resp.Body.Close()
|
||||||
"expected RPL_WHOREPLY (352) for %s, msgs: %v",
|
|
||||||
targetNick, msgs,
|
// A single login should succeed without rate limiting.
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "normaluser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp2, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/login"),
|
||||||
|
bytes.NewReader(loginBody),
|
||||||
)
|
)
|
||||||
}
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
func TestSessionUsernameDefault(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
// Create session without specifying username.
|
|
||||||
token := tserver.createSession("defaultusr")
|
|
||||||
|
|
||||||
queryToken := tserver.createSession("querier2")
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(queryToken, 0)
|
|
||||||
|
|
||||||
// WHOIS should show the nick as the username.
|
|
||||||
tserver.sendCommand(queryToken, map[string]any{
|
|
||||||
commandKey: "WHOIS",
|
|
||||||
toKey: "defaultusr",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(queryToken, lastID)
|
|
||||||
|
|
||||||
whoisMsg := findNumericWithParams(msgs, "311")
|
|
||||||
if whoisMsg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_WHOISUSER (311), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
params := getNumericParams(whoisMsg)
|
defer func() { _ = resp2.Body.Close() }()
|
||||||
|
|
||||||
if len(params) < 2 {
|
if resp2.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp2.Body)
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected at least 2 params, got %v",
|
"expected 200, got %d: %s",
|
||||||
params,
|
resp2.StatusCode, respBody,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -2400,135 +2272,3 @@ func TestNickBroadcastToChannels(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNamesShowsHostmask(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
queryToken, lastID := setupChannelWithIdentMember(
|
|
||||||
tserver, "namesmember", "nmident",
|
|
||||||
"namesquery", "#namestest",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Issue an explicit NAMES command.
|
|
||||||
tserver.sendCommand(queryToken, map[string]any{
|
|
||||||
commandKey: "NAMES",
|
|
||||||
toKey: "#namestest",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(queryToken, lastID)
|
|
||||||
|
|
||||||
assertNamesHostmask(
|
|
||||||
t, msgs, "namesmember", "nmident",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNamesOnJoinShowsHostmask(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
// First user joins to populate the channel.
|
|
||||||
firstToken := tserver.createSessionWithUsername(
|
|
||||||
"joinmem", "jmident",
|
|
||||||
)
|
|
||||||
|
|
||||||
tserver.sendCommand(firstToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#joinnamestest",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Second user joins; the JOIN triggers
|
|
||||||
// deliverNamesNumerics which should include
|
|
||||||
// hostmask data.
|
|
||||||
joinerToken := tserver.createSession("joiner")
|
|
||||||
|
|
||||||
tserver.sendCommand(joinerToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#joinnamestest",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(joinerToken, 0)
|
|
||||||
|
|
||||||
assertNamesHostmask(
|
|
||||||
t, msgs, "joinmem", "jmident",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupChannelWithIdentMember creates a member session
|
|
||||||
// with username, joins a channel, then creates a querier
|
|
||||||
// and joins the same channel. Returns the querier token
|
|
||||||
// and last message ID.
|
|
||||||
func setupChannelWithIdentMember(
|
|
||||||
tserver *testServer,
|
|
||||||
memberNick, memberUsername,
|
|
||||||
querierNick, channel string,
|
|
||||||
) (string, int64) {
|
|
||||||
tserver.t.Helper()
|
|
||||||
|
|
||||||
memberToken := tserver.createSessionWithUsername(
|
|
||||||
memberNick, memberUsername,
|
|
||||||
)
|
|
||||||
|
|
||||||
tserver.sendCommand(memberToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: channel,
|
|
||||||
})
|
|
||||||
|
|
||||||
queryToken := tserver.createSession(querierNick)
|
|
||||||
|
|
||||||
tserver.sendCommand(queryToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: channel,
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(queryToken, 0)
|
|
||||||
|
|
||||||
return queryToken, lastID
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertNamesHostmask verifies that a RPL_NAMREPLY (353)
|
|
||||||
// message contains the expected nick with hostmask format
|
|
||||||
// (nick!user@host).
|
|
||||||
func assertNamesHostmask(
|
|
||||||
t *testing.T,
|
|
||||||
msgs []map[string]any,
|
|
||||||
targetNick, expectedUsername string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
code, ok := msg["code"].(float64)
|
|
||||||
if !ok || int(code) != 353 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, exists := msg["body"]
|
|
||||||
if !exists || raw == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
arr, isArr := raw.([]any)
|
|
||||||
if !isArr || len(arr) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyStr, isStr := arr[0].(string)
|
|
||||||
if !isStr {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for the target nick's hostmask entry.
|
|
||||||
expected := targetNick + "!" +
|
|
||||||
expectedUsername + "@"
|
|
||||||
|
|
||||||
if !strings.Contains(bodyStr, expected) {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected NAMES body to contain %q, "+
|
|
||||||
"got %q",
|
|
||||||
expected, bodyStr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_NAMREPLY (353) with hostmask "+
|
|
||||||
"for %s, msgs: %v",
|
|
||||||
targetNick, msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,6 +11,33 @@ import (
|
|||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|
||||||
|
// clientIP extracts the client IP address from the request.
|
||||||
|
// It checks X-Forwarded-For and X-Real-IP headers before
|
||||||
|
// falling back to RemoteAddr.
|
||||||
|
func clientIP(request *http.Request) string {
|
||||||
|
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
// X-Forwarded-For may contain a comma-separated list;
|
||||||
|
// the first entry is the original client.
|
||||||
|
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd // split into two parts
|
||||||
|
ip := strings.TrimSpace(parts[0])
|
||||||
|
|
||||||
|
if ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
|
||||||
|
return strings.TrimSpace(realIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(request.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return request.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
// HandleRegister creates a new user with a password.
|
// HandleRegister creates a new user with a password.
|
||||||
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -30,7 +58,6 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,20 +86,6 @@ 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,
|
||||||
@@ -83,27 +96,11 @@ 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(),
|
||||||
nick, password, username, hostname, remoteIP,
|
payload.Nick,
|
||||||
|
payload.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.handleRegisterError(
|
hdlr.handleRegisterError(
|
||||||
@@ -116,11 +113,11 @@ func (hdlr *Handlers) executeRegister(
|
|||||||
hdlr.stats.IncrSessions()
|
hdlr.stats.IncrSessions()
|
||||||
hdlr.stats.IncrConnections()
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, nick)
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": payload.Nick,
|
||||||
"token": token,
|
"token": token,
|
||||||
}, http.StatusCreated)
|
}, http.StatusCreated)
|
||||||
}
|
}
|
||||||
@@ -168,6 +165,21 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
|
ip := clientIP(request)
|
||||||
|
|
||||||
|
if !hdlr.loginLimiter.Allow(ip) {
|
||||||
|
writer.Header().Set(
|
||||||
|
"Retry-After", "1",
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"too many login attempts, try again later",
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -198,18 +210,11 @@ 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(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -43,6 +44,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -57,13 +59,24 @@ func New(
|
|||||||
resource = "neoirc"
|
resource = "neoirc"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginRate := params.Config.LoginRateLimit
|
||||||
|
if loginRate <= 0 {
|
||||||
|
loginRate = ratelimit.DefaultRate
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBurst := params.Config.LoginRateBurst
|
||||||
|
if loginBurst <= 0 {
|
||||||
|
loginBurst = ratelimit.DefaultBurst
|
||||||
|
}
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
stats: params.Stats,
|
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||||
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -155,6 +168,10 @@ func (hdlr *Handlers) stopCleanup() {
|
|||||||
if hdlr.cancelCleanup != nil {
|
if hdlr.cancelCleanup != nil {
|
||||||
hdlr.cancelCleanup()
|
hdlr.cancelCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hdlr.loginLimiter != nil {
|
||||||
|
hdlr.loginLimiter.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
||||||
|
|||||||
122
internal/ratelimit/ratelimit.go
Normal file
122
internal/ratelimit/ratelimit.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultRate is the default number of allowed requests per second.
|
||||||
|
DefaultRate = 1.0
|
||||||
|
|
||||||
|
// DefaultBurst is the default maximum burst size.
|
||||||
|
DefaultBurst = 5
|
||||||
|
|
||||||
|
// DefaultSweepInterval controls how often stale entries are pruned.
|
||||||
|
DefaultSweepInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// DefaultEntryTTL is how long an unused entry lives before eviction.
|
||||||
|
DefaultEntryTTL = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// entry tracks a per-IP rate limiter and when it was last used.
|
||||||
|
type entry struct {
|
||||||
|
limiter *rate.Limiter
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter manages per-key rate limiters with automatic cleanup
|
||||||
|
// of stale entries.
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*entry
|
||||||
|
rate rate.Limit
|
||||||
|
burst int
|
||||||
|
entryTTL time.Duration
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new per-key rate Limiter.
|
||||||
|
// The ratePerSec parameter sets how many requests per second are
|
||||||
|
// allowed per key. The burst parameter sets the maximum number of
|
||||||
|
// requests that can be made in a single burst.
|
||||||
|
func New(ratePerSec float64, burst int) *Limiter {
|
||||||
|
limiter := &Limiter{
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
entries: make(map[string]*entry),
|
||||||
|
rate: rate.Limit(ratePerSec),
|
||||||
|
burst: burst,
|
||||||
|
entryTTL: DefaultEntryTTL,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go limiter.sweepLoop()
|
||||||
|
|
||||||
|
return limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether a request from the given key should be
|
||||||
|
// allowed. It consumes one token from the key's rate limiter.
|
||||||
|
func (l *Limiter) Allow(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
ent, exists := l.entries[key]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
ent = &entry{
|
||||||
|
limiter: rate.NewLimiter(l.rate, l.burst),
|
||||||
|
lastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
l.entries[key] = ent
|
||||||
|
} else {
|
||||||
|
ent.lastSeen = time.Now()
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
return ent.limiter.Allow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the background sweep goroutine.
|
||||||
|
func (l *Limiter) Stop() {
|
||||||
|
close(l.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of tracked keys (for testing).
|
||||||
|
func (l *Limiter) Len() int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
return len(l.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLoop periodically removes entries that haven't been seen
|
||||||
|
// within the TTL.
|
||||||
|
func (l *Limiter) sweepLoop() {
|
||||||
|
ticker := time.NewTicker(DefaultSweepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
l.sweep()
|
||||||
|
case <-l.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweep removes stale entries.
|
||||||
|
func (l *Limiter) sweep() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-l.entryTTL)
|
||||||
|
|
||||||
|
for key, ent := range l.entries {
|
||||||
|
if ent.lastSeen.Before(cutoff) {
|
||||||
|
delete(l.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/ratelimit/ratelimit_test.go
Normal file
106
internal/ratelimit/ratelimit_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package ratelimit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewCreatesLimiter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if limiter == nil {
|
||||||
|
t.Fatal("expected non-nil limiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowWithinBurst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 3)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
if !limiter.Allow("192.168.1.1") {
|
||||||
|
t.Fatalf(
|
||||||
|
"request %d should be allowed within burst",
|
||||||
|
i+1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowExceedsBurst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Rate of 0 means no token replenishment, only burst.
|
||||||
|
limiter := ratelimit.New(0, 3)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("fourth request should be denied after burst exhausted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowSeparateKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Rate of 0, burst of 1 — only one request per key.
|
||||||
|
limiter := ratelimit.New(0, 1)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if !limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("first request for key A should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !limiter.Allow("10.0.0.2") {
|
||||||
|
t.Fatal("first request for key B should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("second request for key A should be denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.2") {
|
||||||
|
t.Fatal("second request for key B should be denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLenTracksKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if limiter.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
limiter.Allow("10.0.0.2")
|
||||||
|
|
||||||
|
if limiter.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same key again should not increase count.
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
|
||||||
|
if limiter.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopDoesNotPanic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
limiter.Stop()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user