Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f375c0c26b | ||
|
|
ccb2bf7960 |
@@ -2408,36 +2408,36 @@ applied; `001_initial.sql` creates the tables below.
|
||||
|
||||
#### `sessions`
|
||||
|
||||
| Column | Type | Description |
|
||||
| --------------- | -------- | ------------------------------------------------------------------------------- |
|
||||
| `id` | INTEGER | Primary key (auto-increment) |
|
||||
| `uuid` | TEXT | Unique session UUID |
|
||||
| `nick` | TEXT | Unique nick |
|
||||
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
|
||||
| `hostname` | TEXT | Reverse DNS name of the creating client's IP, or the IP itself when it has none |
|
||||
| `ip` | TEXT | Real IP address of the session creator |
|
||||
| `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) |
|
||||
| `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) |
|
||||
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
|
||||
| `signing_key` | TEXT | Public signing key; nothing sets or reads it yet |
|
||||
| `away_message` | TEXT | Away message (empty string if not away) |
|
||||
| `created_at` | DATETIME | Session creation time |
|
||||
| `last_seen` | DATETIME | Last login or authenticated HTTP API request (or creation) |
|
||||
| Column | Type | Description |
|
||||
| --------------- | -------- | -------------------------------------------------------------------------------- |
|
||||
| `id` | INTEGER | Primary key (auto-increment) |
|
||||
| `uuid` | TEXT | Unique session UUID |
|
||||
| `nick` | TEXT | Unique nick |
|
||||
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
|
||||
| `hostname` | TEXT | Reverse DNS name of the creating client's IP, or the IP itself when it has none |
|
||||
| `ip` | TEXT | Real IP address of the session creator |
|
||||
| `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) |
|
||||
| `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) |
|
||||
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
|
||||
| `signing_key` | TEXT | Public signing key; nothing sets or reads it yet |
|
||||
| `away_message` | TEXT | Away message (empty string if not away) |
|
||||
| `created_at` | DATETIME | Session creation time |
|
||||
| `last_seen` | DATETIME | Last login, authenticated HTTP API request or IRC listener command (or creation) |
|
||||
|
||||
Index on `(uuid)`.
|
||||
|
||||
#### `clients`
|
||||
|
||||
| Column | Type | Description |
|
||||
| ------------ | -------- | ----------------------------------------------------------------------- |
|
||||
| `id` | INTEGER | Primary key (auto-increment) |
|
||||
| `uuid` | TEXT | Unique client UUID |
|
||||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||||
| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) |
|
||||
| `ip` | TEXT | Real IP address of this client connection |
|
||||
| `hostname` | TEXT | Reverse DNS name of this client's IP, or the IP itself when it has none |
|
||||
| `created_at` | DATETIME | Client creation time |
|
||||
| `last_seen` | DATETIME | Last authenticated HTTP API request (or creation) |
|
||||
| Column | Type | Description |
|
||||
| ------------ | -------- | ------------------------------------------------------------------------- |
|
||||
| `id` | INTEGER | Primary key (auto-increment) |
|
||||
| `uuid` | TEXT | Unique client UUID |
|
||||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||||
| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) |
|
||||
| `ip` | TEXT | Real IP address of this client connection |
|
||||
| `hostname` | TEXT | Reverse DNS name of this client's IP, or the IP itself when it has none |
|
||||
| `created_at` | DATETIME | Client creation time |
|
||||
| `last_seen` | DATETIME | Last authenticated HTTP API request or IRC listener command (or creation) |
|
||||
|
||||
Indexes on `(token)` and `(session_id)`.
|
||||
|
||||
@@ -2555,10 +2555,9 @@ issues) and simpler than UUIDs (integer comparison vs. string comparison).
|
||||
session types in the cleanup path — `handleQuit` and `cleanupUser` both call
|
||||
`DeleteSession` unconditionally. Idle clients are automatically removed after
|
||||
`SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API
|
||||
request — the server runs a background cleanup loop that, for a session left
|
||||
with no clients, parts the user from all channels, broadcasts QUIT, and
|
||||
releases the nick. A client on the IRC listener never makes such a request, so
|
||||
it counts as idle from when it connected.
|
||||
request or, on the IRC listener, a command — the server runs a background
|
||||
cleanup loop that, for a session left with no clients, parts the user from all
|
||||
channels, broadcasts QUIT, and releases the nick.
|
||||
- **Clients**: Individual client auth cookies are invalidated on
|
||||
`POST /api/v1/logout`. A session can have multiple clients; removing one
|
||||
doesn't affect others. However, when the last client is removed (via logout),
|
||||
@@ -2582,28 +2581,28 @@ and Viper also reads a YAML file `neoirc.yaml` from `/etc/neoirc/` or
|
||||
environment variable wins over the file. An environment variable set to the
|
||||
empty string counts as unset, so the file's value or the default applies.
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | int | `8080` | HTTP listen port |
|
||||
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. The SQLite driver ignores `_journal_mode`, so this does not turn on WAL mode. |
|
||||
| `DEBUG` | bool | `false` | Switch logging to debug level |
|
||||
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
|
||||
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
|
||||
| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. |
|
||||
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` |
|
||||
| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` |
|
||||
| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. |
|
||||
| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) |
|
||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
||||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||||
| `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. |
|
||||
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. |
|
||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||
| Variable | Type | Default | Description |
|
||||
| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | int | `8080` | HTTP listen port |
|
||||
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. The SQLite driver ignores `_journal_mode`, so this does not turn on WAL mode. |
|
||||
| `DEBUG` | bool | `false` | Switch logging to debug level |
|
||||
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
|
||||
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
|
||||
| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. |
|
||||
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` |
|
||||
| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` |
|
||||
| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. |
|
||||
| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) |
|
||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
||||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||||
| `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. |
|
||||
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. |
|
||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||
|
||||
### Example `.env` file
|
||||
|
||||
|
||||
+24
-3
@@ -204,21 +204,42 @@ func (database *Database) GetSessionByToken(
|
||||
)
|
||||
}
|
||||
|
||||
_ = database.UpdateLastSeen(ctx, sessionID, clientID)
|
||||
|
||||
return sessionID, clientID, nick, nil
|
||||
}
|
||||
|
||||
// UpdateLastSeen sets last_seen to now on a session and on
|
||||
// one of its clients, so that the idle cleanup keeps them.
|
||||
func (database *Database) UpdateLastSeen(
|
||||
ctx context.Context,
|
||||
sessionID, clientID int64,
|
||||
) error {
|
||||
now := time.Now()
|
||||
|
||||
_, _ = database.conn.ExecContext(
|
||||
_, err := database.conn.ExecContext(
|
||||
ctx,
|
||||
"UPDATE sessions SET last_seen = ? WHERE id = ?",
|
||||
now, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"update session last_seen: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
_, _ = database.conn.ExecContext(
|
||||
_, err = database.conn.ExecContext(
|
||||
ctx,
|
||||
"UPDATE clients SET last_seen = ? WHERE id = ?",
|
||||
now, clientID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"update client last_seen: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return sessionID, clientID, nick, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSessionByNick returns session id for a given nick.
|
||||
|
||||
@@ -368,6 +368,17 @@ func (c *Conn) handleMessage(
|
||||
return
|
||||
}
|
||||
|
||||
// Every command is activity, so the idle cleanup must not
|
||||
// remove this user.
|
||||
err := c.database.UpdateLastSeen(
|
||||
ctx, c.sessionID, c.clientID,
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Error(
|
||||
"failed to update last_seen", "error", err,
|
||||
)
|
||||
}
|
||||
|
||||
handler, ok := c.commands[msg.Command]
|
||||
if !ok {
|
||||
c.sendNumeric(
|
||||
|
||||
@@ -369,6 +369,39 @@ func TestPingPong(t *testing.T) {
|
||||
assertContains(t, lines, "PONG", "PONG response")
|
||||
}
|
||||
|
||||
// TestIdleCleanupKeepsActiveUser checks that a user who
|
||||
// sends commands is kept by the idle cleanup, and a user who
|
||||
// sends nothing is not.
|
||||
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const idleTimeout = 500 * time.Millisecond
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
active := env.dial(t)
|
||||
active.register("active")
|
||||
|
||||
idle := env.dial(t)
|
||||
idle.register("idle")
|
||||
|
||||
time.Sleep(idleTimeout)
|
||||
active.sendAndExpect("PING :still here", "PONG")
|
||||
|
||||
// The idle cleanup in internal/handlers removes exactly
|
||||
// the users this returns.
|
||||
stale, err := env.database.GetStaleOrphanSessions(
|
||||
t.Context(), time.Now().Add(-idleTimeout),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get stale sessions: %v", err)
|
||||
}
|
||||
|
||||
if len(stale) != 1 || stale[0].Nick != "idle" {
|
||||
t.Errorf("cleanup removes %v, want only idle", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user