Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
275670d092 |
@@ -2581,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
|
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.
|
empty string counts as unset, so the file's value or the default applies.
|
||||||
|
|
||||||
| Variable | Type | Default | Description |
|
| Variable | Type | Default | Description |
|
||||||
| ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PORT` | int | `8080` | HTTP listen port |
|
| `PORT` | int | `8080` | HTTP listen port |
|
||||||
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. See [SQLite Considerations](#sqlite-considerations). |
|
| `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 |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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` |
|
| `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` |
|
| `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. |
|
| `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) |
|
| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) |
|
||||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||||
| `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. |
|
||||||
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
| `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. |
|
| `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_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. |
|
| `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. |
|
| `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) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
|
|
||||||
@@ -2611,7 +2611,7 @@ PORT=8080
|
|||||||
SERVER_NAME=My NeoIRC Server
|
SERVER_NAME=My NeoIRC Server
|
||||||
MOTD=Welcome! Be excellent to each other.
|
MOTD=Welcome! Be excellent to each other.
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
DBURL=file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)
|
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||||
SESSION_IDLE_TIMEOUT=720h
|
SESSION_IDLE_TIMEOUT=720h
|
||||||
NEOIRC_HASHCASH_BITS=20
|
NEOIRC_HASHCASH_BITS=20
|
||||||
```
|
```
|
||||||
@@ -2683,11 +2683,9 @@ and session infrastructure, so a user connected via IRC and a user connected via
|
|||||||
the HTTP API can talk in the same channels.
|
the HTTP API can talk in the same channels.
|
||||||
|
|
||||||
Each line of a multi-line `body` reaches an IRC listener user as its own
|
Each line of a multi-line `body` reaches an IRC listener user as its own
|
||||||
`PRIVMSG` or `NOTICE` line. A CR, LF or NUL inside a line is removed before it
|
`PRIVMSG` or `NOTICE` line. A mode change made over the HTTP API reaches IRC
|
||||||
is sent, so it cannot start a line of its own. A mode change made over the HTTP
|
listener members as an IRC `MODE` line with its value as a separate parameter,
|
||||||
API reaches IRC listener members as an IRC `MODE` line with its value as a
|
e.g. `MODE #general +k secretpass` or `MODE #general +o bob`.
|
||||||
separate parameter, e.g. `MODE #general +k secretpass` or
|
|
||||||
`MODE #general +o bob`.
|
|
||||||
|
|
||||||
One gap remains: the IRC listener does not pass on a `PRIVMSG`, `NOTICE`,
|
One gap remains: the IRC listener does not pass on a `PRIVMSG`, `NOTICE`,
|
||||||
`JOIN`, `PART`, `NICK` or `QUIT` whose sender's nick matches the user's own nick
|
`JOIN`, `PART`, `NICK` or `QUIT` whose sender's nick matches the user's own nick
|
||||||
@@ -2822,17 +2820,14 @@ seconds to accommodate long-poll connections.
|
|||||||
|
|
||||||
### SQLite Considerations
|
### SQLite Considerations
|
||||||
|
|
||||||
- **Journal mode**: the default connection string runs the database in WAL mode
|
- **Journal mode**: the default connection string carries `?_journal_mode=WAL`,
|
||||||
with a 5000 ms busy timeout, through the SQLite driver's
|
but the SQLite driver ignores that parameter, so the database runs in SQLite's
|
||||||
`_pragma=journal_mode(WAL)` and `_pragma=busy_timeout(5000)` parameters. A
|
default rollback-journal mode, not WAL.
|
||||||
`DBURL` of your own should keep both.
|
|
||||||
- **Single writer**: the server uses one database connection, so reads and
|
- **Single writer**: the server uses one database connection, so reads and
|
||||||
writes take turns. For high-traffic servers, Postgres support is planned.
|
writes take turns. For high-traffic servers, Postgres support is planned.
|
||||||
- **Backup**: Back the database up with
|
- **Backup**: The database is a single file. Back it up with
|
||||||
`sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy `state.db`
|
`sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy the file while
|
||||||
while the server is stopped, together with `state.db-wal` if one is left
|
the server is stopped.
|
||||||
beside it: in WAL mode that file holds recent writes not yet folded into
|
|
||||||
`state.db`.
|
|
||||||
- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the
|
- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the
|
||||||
`DBURL` env var to place it elsewhere.
|
`DBURL` env var to place it elsewhere.
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
|
|||||||
Welcome to NeoIRC — IRC semantics over HTTP.
|
Welcome to NeoIRC — IRC semantics over HTTP.
|
||||||
Type /help for available commands.`
|
Type /help for available commands.`
|
||||||
|
|
||||||
// DefaultDBURL is the database URL used when DBURL is not set. Its
|
|
||||||
// _pragma parameters, which the SQLite driver runs on every connection
|
|
||||||
// it opens, set WAL mode and a 5000 ms busy timeout.
|
|
||||||
const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
|
|
||||||
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
|
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Config.
|
// Params defines the dependencies for creating a Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -77,7 +71,7 @@ func New(
|
|||||||
viper.SetDefault("DEBUG", "false")
|
viper.SetDefault("DEBUG", "false")
|
||||||
viper.SetDefault("MAINTENANCE_MODE", "false")
|
viper.SetDefault("MAINTENANCE_MODE", "false")
|
||||||
viper.SetDefault("PORT", "8080")
|
viper.SetDefault("PORT", "8080")
|
||||||
viper.SetDefault("DBURL", DefaultDBURL)
|
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
|
||||||
viper.SetDefault("SENTRY_DSN", "")
|
viper.SetDefault("SENTRY_DSN", "")
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
|
|||||||
+8
-1
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
|
|||||||
func (database *Database) connect(ctx context.Context) error {
|
func (database *Database) connect(ctx context.Context) error {
|
||||||
dbURL := database.params.Config.DBURL
|
dbURL := database.params.Config.DBURL
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
dbURL = config.DefaultDBURL
|
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||||
}
|
}
|
||||||
|
|
||||||
database.log.Info(
|
database.log.Info(
|
||||||
@@ -116,6 +116,13 @@ func (database *Database) connect(ctx context.Context) error {
|
|||||||
return fmt.Errorf("enable foreign keys: %w", err)
|
return fmt.Errorf("enable foreign keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = database.conn.ExecContext(
|
||||||
|
ctx, "PRAGMA busy_timeout = 5000",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set busy timeout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return database.runMigrations(ctx)
|
return database.runMigrations(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package db_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
"sneak.berlin/go/neoirc/internal/config"
|
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
|
||||||
"sneak.berlin/go/neoirc/internal/globals"
|
|
||||||
"sneak.berlin/go/neoirc/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
const wantBusyTimeoutMS = 5000
|
|
||||||
|
|
||||||
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
|
|
||||||
// with the default URL's parameters the way the server does,
|
|
||||||
// and checks the journal mode and busy timeout it ends up with.
|
|
||||||
func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, params, _ := strings.Cut(config.DefaultDBURL, "?")
|
|
||||||
dbURL := "file://" + filepath.Join(t.TempDir(), "state.db") +
|
|
||||||
"?" + params
|
|
||||||
|
|
||||||
lifecycle := fxtest.NewLifecycle(t)
|
|
||||||
|
|
||||||
log, err := logger.New(lifecycle, logger.Params{
|
|
||||||
Globals: &globals.Globals{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
database, err := db.New(lifecycle, db.Params{
|
|
||||||
Logger: log,
|
|
||||||
Config: &config.Config{DBURL: dbURL},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycle.RequireStart()
|
|
||||||
t.Cleanup(lifecycle.RequireStop)
|
|
||||||
|
|
||||||
var journalMode string
|
|
||||||
|
|
||||||
err = database.GetDB().QueryRowContext(
|
|
||||||
t.Context(), "PRAGMA journal_mode",
|
|
||||||
).Scan(&journalMode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if journalMode != "wal" {
|
|
||||||
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
|
|
||||||
}
|
|
||||||
|
|
||||||
var busyTimeout int
|
|
||||||
|
|
||||||
err = database.GetDB().QueryRowContext(
|
|
||||||
t.Context(), "PRAGMA busy_timeout",
|
|
||||||
).Scan(&busyTimeout)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if busyTimeout != wantBusyTimeoutMS {
|
|
||||||
t.Errorf(
|
|
||||||
"busy_timeout = %d, want %d",
|
|
||||||
busyTimeout, wantBusyTimeoutMS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -297,19 +297,11 @@ func (c *Conn) send(line string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendWithin writes a formatted IRC line to the connection,
|
// sendWithin writes a formatted IRC line to the connection,
|
||||||
// giving up after timeout. CR, LF and NUL are removed first:
|
// giving up after timeout.
|
||||||
// stored text can hold them, and on the wire they would end
|
|
||||||
// the line early. They are removed, not replaced with a
|
|
||||||
// space, because the line is already formatted and a space
|
|
||||||
// would split a parameter in two.
|
|
||||||
func (c *Conn) sendWithin(
|
func (c *Conn) sendWithin(
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
line string,
|
line string,
|
||||||
) {
|
) {
|
||||||
line = strings.ReplaceAll(line, "\r", "")
|
|
||||||
line = strings.ReplaceAll(line, "\n", "")
|
|
||||||
line = strings.ReplaceAll(line, "\x00", "")
|
|
||||||
|
|
||||||
c.writeMu.Lock()
|
c.writeMu.Lock()
|
||||||
defer c.writeMu.Unlock()
|
defer c.writeMu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -139,43 +139,3 @@ func TestRelayModeParameters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRelayLineBreaks checks that a CR, LF or NUL in a stored
|
|
||||||
// body line, key or mask never reaches an IRC listener user
|
|
||||||
// as a line break, so no extra line arrives.
|
|
||||||
func TestRelayLineBreaks(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
alice := env.dial(t)
|
|
||||||
alice.register("alice")
|
|
||||||
|
|
||||||
env.queueMessage(t, "alice", "PRIVMSG", "carol", "alice", []string{
|
|
||||||
"one\r\n:admin!a@b PRIVMSG alice :hi",
|
|
||||||
"two\n:admin!a@b NOTICE alice :hi\x00",
|
|
||||||
})
|
|
||||||
env.queueMessage(t, "alice", "MODE", "op", "#breaks",
|
|
||||||
[]string{"+k", "key\r\nERROR :Closing link"})
|
|
||||||
env.queueMessage(t, "alice", "MODE", "op", "#breaks",
|
|
||||||
[]string{"+b", "*!*@*\r\n:admin!a@b KICK #breaks alice"})
|
|
||||||
env.queueMessage(t, "alice", "PRIVMSG", "carol", "alice",
|
|
||||||
[]string{"the end"})
|
|
||||||
|
|
||||||
lines := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.HasSuffix(l, " :the end")
|
|
||||||
})
|
|
||||||
|
|
||||||
want := []string{
|
|
||||||
":carol!carol@* PRIVMSG alice :one:admin!a@b PRIVMSG alice :hi",
|
|
||||||
":carol!carol@* PRIVMSG alice :two:admin!a@b NOTICE alice :hi",
|
|
||||||
":op!op@* MODE #breaks +k :keyERROR :Closing link",
|
|
||||||
":op!op@* MODE #breaks +b :*!*@*:admin!a@b KICK #breaks alice",
|
|
||||||
":carol!carol@* PRIVMSG alice :the end",
|
|
||||||
}
|
|
||||||
|
|
||||||
got := lines[max(0, len(lines)-len(want)):]
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Errorf("got %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func newTestEnvWithConfig(
|
|||||||
// A random name, so that no other test environment, not
|
// A random name, so that no other test environment, not
|
||||||
// even an earlier run of the same test, can share it.
|
// even an earlier run of the same test, can share it.
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"file:%s?mode=memory&cache=shared",
|
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||||
rand.Text(),
|
rand.Text(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user