3 Commits
Author SHA1 Message Date
sneak 6c555958d6 Stop the slow test packages waiting (closes #263)
check / check (push) Failing after 3s
- Tests hash passwords at bcrypt.MinCost, set through the
  PasswordCost field of db.Params. TestPasswordCost checks the
  cost with and without it.
- Test IRC servers check the queue only on broker notification;
  one test keeps the 100 ms fallback poll.
- The stop-timeout test shortens the timeout through a test-only
  setter.
- service and handlers tests build config.Config directly instead
  of through config.New, so they run in parallel. config exports
  DefaultTrustedProxies and ParseTrustedProxies for the handlers
  test config.
- Repeated race trials run as parallel subtests.
- Fixed sleeps become waits on the broker.
- A new test covers the relay's failed queue read, which only the
  100 ms poll had reached.

Model: opus-5-5
2026-11-10 22:12:13 +00:00
clawbot f42c6214a0 Make the image prepare its own data directory (closes #262)
check / check (push) Failing after 4s
A host directory mounted at /var/lib/neoirc kept the host's owner, so neoircd could not create state.db and stopped at startup.

The image now starts deploy/docker-entrypoint.sh as root. It creates /var/lib/neoirc, gives it and its contents to neoirc with mode 0700, and execs neoircd as neoirc through su-exec. The runtime stage drops USER neoirc and no longer creates the directory at build time.

Judgement call: the entrypoint assumes it starts as root; docker run --user fails at chown.

Model: opus-5-5
2026-11-10 22:42:02 +01:00
clawbot a3d9bbfd81 Honour forwarded headers only from trusted proxies (closes #259)
check / check (push) Failing after 8s
Before, the server took the client's address from X-Forwarded-For or X-Real-IP whoever sent them, so a direct client could get past the login rate limit and choose the address and hostname recorded for its session.

A new ClientIP middleware, run before request logging, resolves the address once: forwarded headers count only when the peer is in TRUSTED_PROXIES, which defaults to the RFC 1918 ranges. A set list replaces the default, the empty string trusts nothing, and an invalid entry stops startup. The request log, login rate limiter and session creation all use the resolved address.

A proxy on the same host as a server outside Docker connects from 127.0.0.1 or ::1, which the default does not trust; the README says to list it.

Model: opus-5-5
2026-11-10 22:41:50 +01:00
24 changed files with 1178 additions and 269 deletions

No files matched your search

+7 -6
View File
@@ -83,14 +83,15 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoir
# stage's chain and nothing else.
# alpine:3.21, 2026-02-26
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates \
&& addgroup -S neoirc && adduser -S neoirc -G neoirc \
&& mkdir -p /var/lib/neoirc \
&& chown neoirc:neoirc /var/lib/neoirc
# su-exec 0.2-r3, 2026-11-10
RUN apk add --no-cache ca-certificates su-exec=0.2-r3 \
&& addgroup -S neoirc && adduser -S neoirc -G neoirc
COPY --from=builder /neoircd /usr/local/bin/neoircd
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
USER neoirc
# No USER: the entrypoint starts as root to give /var/lib/neoirc to neoirc,
# then runs neoircd as neoirc.
EXPOSE 8080 6667
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["neoircd"]
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+80 -22
View File
@@ -1339,8 +1339,9 @@ Every session has a hostmask in IRC's form, `nick!username@hostname`:
from, or that IP address when the lookup gives no name. The lookup waits up to
three seconds.
Over the HTTP API, the server takes the client's IP address from the first entry
of the `X-Forwarded-For` header, then `X-Real-IP`, then the TCP connection (see
Over the HTTP API, the client's IP address is the address of the TCP connection.
When that connection comes from a trusted proxy, the server reads the address
from `X-Forwarded-For` or `X-Real-IP` instead (see
[Reverse proxy](#reverse-proxy)).
Every user can see a hostmask: in `RPL_WHOISUSER` (`311`), `RPL_WHOREPLY`
@@ -1450,10 +1451,9 @@ over the limit gets `429` `too many login attempts, try again later` with
attempt is more than 15 minutes old. Session creation has no rate limit; the
hashcash stamp is its cost.
The IP address comes from the first entry of `X-Forwarded-For`, else from
`X-Real-IP`, else from the TCP connection. Without a reverse proxy that
overwrites those headers, a client can set them itself and so pick any address
(see [Reverse proxy](#reverse-proxy)).
The IP address is the client's address as described in
[Reverse proxy](#reverse-proxy). A client outside the trusted proxies cannot
change it with `X-Forwarded-For` or `X-Real-IP`.
### Writing a client
@@ -1714,10 +1714,14 @@ Every setting is an environment variable. The server also reads a `.env` file in
its working directory, and a YAML file `neoirc.yaml` in `/etc/neoirc/` or
`$HOME/.config/neoirc/` with the same names as keys. A variable from the
environment wins over one in `.env`, and both win over `neoirc.yaml`. An
environment variable set to the empty string counts as unset, except
`IRC_LISTEN_ADDR`, where the empty string turns the IRC listener off. A
`neoirc.yaml` that is not valid YAML stops the server at startup. A setting that
does not parse or is out of range also stops the server at startup, and the
environment variable set to the empty string counts as unset, with two
exceptions:
- For `IRC_LISTEN_ADDR`, the empty string turns the IRC listener off.
- For `TRUSTED_PROXIES`, the empty string trusts no proxy.
A `neoirc.yaml` that is not valid YAML stops the server at startup. A setting
that does not parse or is out of range also stops the server at startup, and the
error names the setting.
- `PORT` (default `8080`): the HTTP port. `0` lets the system pick a free port.
@@ -1747,6 +1751,11 @@ error names the setting.
address. A fraction such as `0.5` is allowed. It must be above `0`.
- `LOGIN_RATE_BURST` (default `5`): how many attempts an address may make at
once before the rate applies. It must be at least `1`.
- `TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the
reverse proxies whose `X-Forwarded-For` and `X-Real-IP` headers the server
believes. It is a comma-separated list of networks, such as `10.0.0.0/8`, and
single addresses, such as `192.0.2.1`. A list replaces the default entirely.
The empty string trusts nothing. See [Reverse proxy](#reverse-proxy).
- `SESSION_IDLE_TIMEOUT` (default `720h`, 30 days): a duration with a unit, `h`
for hours, `m` for minutes or `s` for seconds, such as `720h` or `36h30m`. A
client with no request for this long is removed, and a session left with no
@@ -1781,13 +1790,17 @@ NEOIRC_HASHCASH_BITS=20
the linter first and stops when either fails. The image:
- is Alpine with the `neoircd` binary and CA certificates;
- runs as the user `neoirc`;
- runs `neoircd` as the user `neoirc`;
- exposes ports 8080 and 6667;
- keeps its database in `/var/lib/neoirc`;
- has a `HEALTHCHECK` that calls `/.well-known/healthcheck.json` every 30
seconds.
Run it with a volume for the database and the settings as environment variables:
The image prepares `/var/lib/neoirc` itself on every start. It creates the
directory, gives it to `neoirc` and then runs `neoircd` as `neoirc`.
Run it with a named volume for the database and the settings as environment
variables:
```bash
docker run -d --name neoirc \
@@ -1798,20 +1811,51 @@ docker run -d --name neoirc \
neoirc
```
A host directory works the same way in place of the named volume:
```bash
docker run -d --name neoirc \
-p 8080:8080 -p 6667:6667 \
-v /srv/neoirc:/var/lib/neoirc \
-e SERVER_NAME="My Server" \
-e MOTD="Welcome!" \
neoirc
```
### Reverse proxy
Run the server behind a reverse proxy that terminates TLS. The server itself
speaks plain HTTP, and the auth cookie is `Secure`, so browsers send it only
over HTTPS. The proxy must:
- Set `X-Forwarded-For` to the client's address, replacing any value the client
sent. The server reads that header first and trusts it from anyone; it reads
`X-Real-IP` only when `X-Forwarded-For` is absent. Without a proxy that
overwrites the header, a client can choose its own address, which sets its
hostname and lets it dodge the login rate limit.
- Connect from an address in `TRUSTED_PROXIES`. The default trusts the private
ranges `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`. The setting must
hold the address the server sees the proxy connect from. For a proxy on the
same host:
- With the server outside Docker, that address is `127.0.0.1` or `::1`. The
default does not trust them, so set `TRUSTED_PROXIES=127.0.0.1,::1`.
- Through a published Docker port, that address is the Docker bridge
gateway, such as `172.17.0.1`. The default trusts it.
- Add the client's address to `X-Forwarded-For`.
- Wait at least 60 seconds for a response. A long poll can take 30 seconds, and
the server's own write timeout is 60 seconds.
The server finds the client's address in these steps:
- A connection from outside `TRUSTED_PROXIES` is the client. The server ignores
its `X-Forwarded-For` and `X-Real-IP` headers.
- For a connection from a trusted proxy, the server reads `X-Forwarded-For` from
right to left. It skips trusted addresses and takes the first untrusted one.
When every entry is trusted, it takes the leftmost.
- Without `X-Forwarded-For`, the server takes `X-Real-IP`.
- When an entry it reads is not an IP address, the server uses the proxy's
address.
The client's address sets the session's hostname and counts toward the login
rate limit. Any connection from a trusted address can choose its client address
through `X-Forwarded-For`. If clients connect directly from private addresses,
set `TRUSTED_PROXIES` to the proxy's address alone.
Caddy:
```
@@ -1861,9 +1905,21 @@ The IRC listener has no TLS.
The server logs with Go's `slog`, one line per event, to standard output. The
lines are JSON, or plain text when standard output is a terminal. Every HTTP
request is logged with its method, URL, protocol, status, latency, remote
address, user agent, referrer and request ID. `DEBUG=true` adds debug-level
lines.
request is logged with:
- start time
- method
- URL
- protocol
- status
- latency
- client address
- user agent
- referrer
- request ID
The client address is the one found as described in
[Reverse proxy](#reverse-proxy). `DEBUG=true` adds debug-level lines.
### The database
@@ -1946,8 +2002,8 @@ and their sessions to end. Then it closes the database.
gets a message.
- `internal/hashcash/`: stamp checking and minting.
- `internal/ratelimit/`: the per-address login rate limiter.
- `internal/middleware/`: request logging, cross-origin requests, the
`Content-Security-Policy` header and metrics.
- `internal/middleware/`: finding the client's address, request logging,
cross-origin requests, the `Content-Security-Policy` header and metrics.
- `internal/healthcheck/` and `internal/stats/`: the health check and its
counters.
- `internal/logger/` and `internal/globals/`: logging setup and build
@@ -1959,6 +2015,8 @@ and their sessions to end. Then it closes the database.
- `schema/`: JSON Schema files for the message envelope, with their own
`README.md`.
- `script/`: the entrypoints below.
- `deploy/`: `docker-entrypoint.sh`, the image's entrypoint, which prepares
`/var/lib/neoirc` and starts `neoircd` as `neoirc`.
- `CONVENTIONS.md`, `REPO_POLICIES.md` and `AGENTS.md`: the architecture
conventions, the repository policies and the rules for coding agents.
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the image's ENTRYPOINT, run as root. A host
# directory mounted at /var/lib/neoirc keeps the host's owner, so this gives
# it to neoirc before running neoircd as neoirc.
set -eu
main() {
mkdir -p /var/lib/neoirc
chown -R neoirc:neoirc /var/lib/neoirc
chmod 0700 /var/lib/neoirc
exec su-exec neoirc neoircd "$@"
}
main "$@"
+1
View File
@@ -15,6 +15,7 @@ require (
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb
golang.org/x/crypto v0.48.0
golang.org/x/time v0.6.0
modernc.org/sqlite v1.45.0
+2
View File
@@ -111,6 +111,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+105 -6
View File
@@ -6,12 +6,15 @@ import (
"fmt"
"log/slog"
"math"
"net/netip"
"os"
"strconv"
"strings"
"time"
"github.com/spf13/viper"
"go.uber.org/fx"
"go4.org/netipx"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
@@ -32,8 +35,16 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
// DefaultTrustedProxies is TRUSTED_PROXIES when it is not set: the
// private address ranges of RFC 1918.
const DefaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
var errOutOfRange = errors.New("setting out of range")
var errNeverMatches = errors.New("entry can never match a connection")
var errNotString = errors.New("not a string")
// Params defines the dependencies for creating a Config.
type Params struct {
fx.In
@@ -64,13 +75,14 @@ type Config struct {
LoginRateLimit float64
LoginRateBurst int
IRCListenAddr string
TrustedProxies *netipx.IPSet
params *Params
log *slog.Logger
}
// New creates a new Config by reading from files and environment variables.
// It returns an error when a number, true/false or duration setting does
// not parse or is out of range.
// It returns an error when a number, true/false, duration or address
// setting does not parse or is out of range.
func New(
_ fx.Lifecycle, params Params,
) (*Config, error) {
@@ -103,6 +115,7 @@ func New(
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
viper.SetDefault("LOGIN_RATE_BURST", "5")
viper.SetDefault("IRC_LISTEN_ADDR", ":6667")
viper.SetDefault("TRUSTED_PROXIES", DefaultTrustedProxies)
err := viper.ReadInConfig()
if err != nil {
@@ -151,10 +164,10 @@ func New(
return cfg, nil
}
// parseSettings reads the number and true/false settings into cfg and
// checks the duration settings. Each number and duration must be above
// zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets the
// system pick a port, and 0 bits turns proof of work off. The error
// parseSettings reads the number, true/false and address settings into
// cfg and checks the duration settings. Each number and duration must be
// above zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets
// the system pick a port, and 0 bits turns proof of work off. The error
// names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error {
return errors.Join(
@@ -168,9 +181,95 @@ func parseSettings(cfg *Config) error {
checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge),
checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge),
checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout),
parseTrustedProxies(&cfg.TrustedProxies),
)
}
// parseTrustedProxies sets *value to the addresses in TRUSTED_PROXIES.
// Viper counts an empty environment variable as unset, so the
// environment is read directly as well.
func parseTrustedProxies(value **netipx.IPSet) error {
setting := viper.Get("TRUSTED_PROXIES")
fromEnv, set := os.LookupEnv("TRUSTED_PROXIES")
if set {
setting = fromEnv
}
// A YAML list or map in the config file is not a string, and
// viper.GetString would turn it into the empty string.
raw, isString := setting.(string)
if !isString {
return fmt.Errorf("cannot parse TRUSTED_PROXIES: %v is %w",
setting, errNotString)
}
ipSet, err := ParseTrustedProxies(raw)
if err != nil {
return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err)
}
*value = ipSet
return nil
}
// ParseTrustedProxies returns the addresses in list, which has the form
// of TRUSTED_PROXIES: a comma-separated list of networks such as
// 10.0.0.0/8 and single addresses such as 192.0.2.1. The empty string
// trusts nothing.
func ParseTrustedProxies(list string) (*netipx.IPSet, error) {
var builder netipx.IPSetBuilder
if strings.TrimSpace(list) != "" {
for entry := range strings.SplitSeq(list, ",") {
err := addTrustedProxy(&builder, strings.TrimSpace(entry))
if err != nil {
return nil, err
}
}
}
return builder.IPSet()
}
// addTrustedProxy adds entry, a network with a slash or a single
// address, to builder. An IPv4 address written in IPv6 form, such as
// ::ffff:192.0.2.1, is added as IPv4. A network in that form and an
// address with a zone are errors, since no connection could match them.
func addTrustedProxy(builder *netipx.IPSetBuilder, entry string) error {
if strings.Contains(entry, "/") {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return err //nolint:wrapcheck // the caller names the setting
}
// A connection from an IPv4 address is checked in IPv4 form.
if prefix.Addr().Is4In6() {
return fmt.Errorf("%w: %s is an IPv4 network in IPv6 form",
errNeverMatches, entry)
}
builder.AddPrefix(prefix)
return nil
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return err //nolint:wrapcheck // the caller names the setting
}
// IPSet.Contains is false for every address with a zone.
if addr.Zone() != "" {
return fmt.Errorf("%w: %s has a zone", errNeverMatches, entry)
}
builder.Add(addr.Unmap())
return nil
}
// parseInt sets *value to the setting key read as a whole number. It
// returns an error when the value does not parse or is below minimum.
func parseInt(key string, minimum int, value *int) error {
+129 -2
View File
@@ -1,11 +1,14 @@
package config_test
import (
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/viper"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
@@ -140,8 +143,7 @@ func settingsOf(cfg *config.Config) settings {
}
func TestBadSettingStopsStartup(t *testing.T) {
// For each setting, the first value does not parse and the others
// parse but are out of range.
// Each value is invalid for its setting.
tests := []struct {
key string
values []string
@@ -158,6 +160,10 @@ func TestBadSettingStopsStartup(t *testing.T) {
{key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}},
{key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}},
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
{key: "TRUSTED_PROXIES", values: []string{
"proxy.example.com", "10.0.0.0/33", "192.0.2.1:8080",
"::ffff:10.0.0.0/104", "fe80::1%eth0",
}},
}
for _, test := range tests {
@@ -206,3 +212,124 @@ func TestIRCListenAddrEmptyTurnsListenerOff(t *testing.T) {
t.Errorf("IRCListenAddr = %q, want empty", cfg.IRCListenAddr)
}
}
func TestTrustedProxies(t *testing.T) {
// Each case lists addresses that must be in the trusted set and
// addresses that must not.
tests := []struct {
name string
set bool
value string
trusted []string
untrusted []string
}{
{
name: "unset trusts the RFC 1918 ranges",
set: false,
value: "",
trusted: []string{
"10.1.2.3", "172.16.0.1", "172.31.255.254", "192.168.1.1",
},
untrusted: []string{
"127.0.0.1", "172.32.0.1", "192.0.2.1", "2001:db8::1",
},
},
{
name: "a value replaces the default",
set: true,
value: "198.51.100.0/24, 192.0.2.7,2001:db8::/32",
trusted: []string{"198.51.100.200", "192.0.2.7", "2001:db8::5"},
untrusted: []string{"10.4.5.6", "192.168.4.4", "192.0.2.8"},
},
{
name: "a mapped IPv6 address is trusted as IPv4",
set: true,
value: "::ffff:192.0.2.7",
trusted: []string{"192.0.2.7"},
untrusted: []string{"10.7.8.9"},
},
{
name: "the empty string trusts nothing",
set: true,
value: "",
trusted: nil,
untrusted: []string{"10.0.0.1", "192.168.0.1", "::1"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// t.Setenv puts back whatever value was there after the
// test, including when Unsetenv removes it.
t.Setenv("TRUSTED_PROXIES", test.value)
if !test.set {
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
}
proxies := newConfig(t).TrustedProxies
for _, addr := range test.trusted {
if !proxies.Contains(netip.MustParseAddr(addr)) {
t.Errorf("%s is not trusted", addr)
}
}
for _, addr := range test.untrusted {
if proxies.Contains(netip.MustParseAddr(addr)) {
t.Errorf("%s is trusted", addr)
}
}
})
}
}
func TestTrustedProxiesNotAStringInYAMLStopsStartup(t *testing.T) {
// YAML reads the first value as a list and the second as a map.
values := []string{"[10.0.0.1]", "{proxy: 10.0.0.1}"}
for _, value := range values {
t.Run(value, func(t *testing.T) {
// t.Setenv puts back whatever value was there after the
// test, including when Unsetenv removes it.
t.Setenv("TRUSTED_PROXIES", "")
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
// loadConfig names the app neoirc-test, so config.New reads
// $HOME/.config/neoirc-test/neoirc-test.yaml.
home := t.TempDir()
t.Setenv("HOME", home)
dir := filepath.Join(home, ".config", "neoirc-test")
err = os.MkdirAll(dir, 0o700)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(
filepath.Join(dir, "neoirc-test.yaml"),
[]byte("TRUSTED_PROXIES: "+value+"\n"), 0o600,
)
if err != nil {
t.Fatal(err)
}
// Viper keeps the values it read from this file after the
// file is gone. Reset clears them for later tests.
t.Cleanup(viper.Reset)
_, err = loadConfig(t)
if err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXIES") {
t.Errorf("error = %v, want one naming TRUSTED_PROXIES", err)
}
})
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ func (database *Database) SetPassword(
password string,
) error {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcrypt.DefaultCost,
[]byte(password), database.passwordCost,
)
if err != nil {
return fmt.Errorf("hash password: %w", err)
+18 -5
View File
@@ -15,6 +15,7 @@ import (
"strings"
"go.uber.org/fx"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/logger"
@@ -39,13 +40,19 @@ type Params struct {
Logger *logger.Logger
Config *config.Config
// PasswordCost is the bcrypt cost at which SetPassword hashes
// a password. The server leaves it unset, which means
// bcrypt.DefaultCost. Tests set a lower cost to save time.
PasswordCost int `optional:"true"`
}
// Database manages the SQLite connection and migrations.
type Database struct {
conn *sql.DB
log *slog.Logger
params *Params
conn *sql.DB
log *slog.Logger
params *Params
passwordCost int
}
// New creates a new Database and registers lifecycle hooks.
@@ -53,9 +60,15 @@ func New(
lifecycle fx.Lifecycle,
params Params,
) (*Database, error) {
passwordCost := params.PasswordCost
if passwordCost == 0 {
passwordCost = bcrypt.DefaultCost
}
database := &Database{ //nolint:exhaustruct // conn set in OnStart
params: &params,
log: params.Logger.Get(),
params: &params,
log: params.Logger.Get(),
passwordCost: passwordCost,
}
database.log.Info("Database instantiated")
+80
View File
@@ -10,6 +10,7 @@ import (
"time"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
@@ -236,6 +237,85 @@ func TestDBURLSettingForeignKeysFailsStartup(t *testing.T) {
}
}
// TestPasswordCost checks the bcrypt cost of the hash that SetPassword
// stores: bcrypt.DefaultCost when db.Params leaves PasswordCost unset,
// as the server does, and PasswordCost when it is set.
func TestPasswordCost(t *testing.T) {
t.Parallel()
tests := []struct {
name string
passwordCost int
wantCost int
}{
{name: "unset", wantCost: bcrypt.DefaultCost},
{
name: "bcrypt.MinCost",
passwordCost: bcrypt.MinCost,
wantCost: bcrypt.MinCost,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
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: dbtest.NewURL(t)},
PasswordCost: test.passwordCost,
})
if err != nil {
t.Fatal(err)
}
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
sessionID, _, _, err := database.CreateSession(
t.Context(), "costuser", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(t.Context(), sessionID, "password123")
if err != nil {
t.Fatal(err)
}
var hash string
err = database.GetDB().QueryRowContext(
t.Context(),
"SELECT password_hash FROM sessions WHERE id = ?",
sessionID,
).Scan(&hash)
if err != nil {
t.Fatal(err)
}
cost, err := bcrypt.Cost([]byte(hash))
if err != nil {
t.Fatal(err)
}
if cost != test.wantCost {
t.Errorf("cost = %d, want %d", cost, test.wantCost)
}
})
}
}
// startDatabase starts a Database for dbURL the way the server does.
// It returns the error that stopped it from starting, if any.
func startDatabase(t *testing.T, dbURL string) (*db.Database, error) {
+6 -3
View File
@@ -8,10 +8,12 @@ import (
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/dbtest"
)
// NewTestDatabase creates an in-memory database for testing.
// NewTestDatabase creates an in-memory database for testing. It
// hashes passwords at bcrypt.MinCost.
func NewTestDatabase(tb testing.TB) (*Database, error) {
tb.Helper()
@@ -23,8 +25,9 @@ func NewTestDatabase(tb testing.TB) (*Database, error) {
}
database := &Database{ //nolint:exhaustruct // test helper, params not needed
conn: conn,
log: slog.Default(),
conn: conn,
log: slog.Default(),
passwordCost: bcrypt.MinCost,
}
err = database.runMigrations(context.Background())
+2 -18
View File
@@ -42,25 +42,9 @@ func (hdlr *Handlers) maxBodySize() int64 {
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.
// clientIP returns the IP address in request.RemoteAddr, which the
// ClientIP middleware has set to the client's address.
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
+219 -86
View File
@@ -145,34 +145,36 @@ type testServer struct {
handlers *handlers.Handlers
}
// viperMu makes tests start their servers one at a time,
// because config.New writes the global viper instance and
// the server reads it as it starts.
//
//nolint:gochecknoglobals // guards a global, so must be one
var viperMu sync.Mutex
// maxMessageSize is MAX_MESSAGE_SIZE on every test server, the
// setting's default.
const maxMessageSize = 4096
func newTestServer(
t *testing.T,
) *testServer {
t.Helper()
return newTestServerWith(t, 0, "", "")
return newTestServerWith(t, 0, "", "", config.DefaultTrustedProxies)
}
func newTestServerWith(
t *testing.T,
hashcashBits int,
operName, operPassword string,
operName, operPassword, trustedProxies string,
) *testServer {
t.Helper()
viperMu.Lock()
defer viperMu.Unlock()
dbURL := dbtest.NewURL(t)
copyTemplateDB(t, dbURL)
trusted, err := config.ParseTrustedProxies(trustedProxies)
if err != nil {
t.Fatalf("parse trusted proxies: %v", err)
}
// Each duration setting defaults to 30 days.
const thirtyDays = "720h"
var (
srv *server.Server
brk *broker.Broker
@@ -184,31 +186,23 @@ func newTestServerWith(
fx.Provide(
newTestGlobals,
logger.New,
func(
lifecycle fx.Lifecycle,
globs *globals.Globals,
log *logger.Logger,
) (*config.Config, error) {
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
// Built here rather than by config.New, which writes
// the global viper instance, so servers start in
// parallel. Settings left out take their defaults.
func() *config.Config {
return &config.Config{
DBURL: dbURL,
// One line: each line is a database write per new session.
MOTD: "neoirc test server",
MaxMessageSize: maxMessageSize,
HashcashBits: hashcashBits,
OperName: operName,
OperPassword: operPassword,
MessageMaxAge: thirtyDays,
QueueMaxAge: thirtyDays,
SessionIdleTimeout: thirtyDays,
TrustedProxies: trusted,
}
cfg.DBURL = dbURL
cfg.Port = 0
// One line: each line is a database write per new session.
cfg.MOTD = "neoirc test server"
cfg.HashcashBits = hashcashBits
cfg.OperName = operName
cfg.OperPassword = operPassword
return cfg, nil
},
newTestDB,
broker.New,
@@ -255,7 +249,7 @@ func newTestDB(
cfg *config.Config,
) (*db.Database, error) {
database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct
Logger: log, Config: cfg,
Logger: log, Config: cfg, PasswordCost: bcrypt.MinCost,
})
if err != nil {
return nil, fmt.Errorf("test db: %w", err)
@@ -830,10 +824,7 @@ func (tserver *testServer) postBody(
func TestRequestBodyLimit(t *testing.T) {
t.Parallel()
const (
maxMessageSize = 4096 // the default MAX_MESSAGE_SIZE
nick = "alice_bodylimit"
)
const nick = "alice_bodylimit"
endpoints := []struct {
name, path, value string
@@ -1750,52 +1741,67 @@ func TestConcurrentQuitsSendOneQuit(t *testing.T) {
// that the second session then holds one QUIT from the
// first, and that the first session is deleted: its tokens
// are refused and its nick is free. It does all this
// quitRaceTrials times, each on a new server, because each
// trial logs in and logins are rate limited.
// quitRaceTrials times, at once, each on a new server, because
// each trial logs in and logins are rate limited.
func checkQuitRace(
t *testing.T,
leave func(tserver *testServer, tokens []string),
) {
t.Helper()
for trial := range quitRaceTrials {
t.Run(fmt.Sprintf("trial %d", trial), func(t *testing.T) {
t.Parallel()
checkQuitRaceOnce(t, newTestServer(t), leave)
})
}
}
// checkQuitRaceOnce runs one trial of checkQuitRace on
// tserver.
func checkQuitRaceOnce(
t *testing.T,
tserver *testServer,
leave func(tserver *testServer, tokens []string),
) {
t.Helper()
const nick = "racingquitter"
for trial := range quitRaceTrials {
tserver := newTestServer(t)
firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("raceobserver")
firstToken := tserver.createSession(nick)
observerToken := tserver.createSession("raceobserver")
tserver.setPassword(nick)
tserver.setPassword(nick)
tokens := []string{firstToken, tserver.login(nick)}
tokens := []string{firstToken, tserver.login(nick)}
for _, token := range []string{firstToken, observerToken} {
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#quitrace",
})
}
_, lastID := tserver.pollMessages(observerToken, 0)
leave(tserver, tokens)
msgs, _ := tserver.pollMessages(observerToken, lastID)
quits := countQuits(msgs, nick)
if quits != 1 {
t.Errorf("trial %d: %d QUITs, want 1: %v", trial, quits, msgs)
}
for _, token := range tokens {
status, _ := tserver.getState(token)
if status != http.StatusUnauthorized {
t.Errorf("trial %d: state got %d, want 401", trial, status)
}
}
// createSession fails the test if the nick is taken.
tserver.createSession(nick)
for _, token := range []string{firstToken, observerToken} {
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#quitrace",
})
}
_, lastID := tserver.pollMessages(observerToken, 0)
leave(tserver, tokens)
msgs, _ := tserver.pollMessages(observerToken, lastID)
quits := countQuits(msgs, nick)
if quits != 1 {
t.Errorf("%d QUITs, want 1: %v", quits, msgs)
}
for _, token := range tokens {
status, _ := tserver.getState(token)
if status != http.StatusUnauthorized {
t.Errorf("state got %d, want 401", status)
}
}
// createSession fails the test if the nick is taken.
tserver.createSession(nick)
}
// TestLogoutOfOneClientKeepsSession checks that when one of
@@ -1933,11 +1939,28 @@ const (
// return once the session has left.
quitWakeDeadline = 5 * time.Second
// pollWaitStep is how often checkQuitWakesPoll checks
// pollWaitStep is how often waitForLongPoll checks
// whether the long poll is waiting yet.
pollWaitStep = 10 * time.Millisecond
)
// waitForLongPoll waits until a long poll of the session
// sessionID is waiting, and fails the test if none is within
// quitWakeDeadline.
func (tserver *testServer) waitForLongPoll(sessionID int64) {
tserver.t.Helper()
deadline := time.Now().Add(quitWakeDeadline)
for tserver.broker.ListenerCount(sessionID) == 0 {
if time.Now().After(deadline) {
tserver.t.Fatal("the long poll did not start waiting")
}
time.Sleep(pollWaitStep)
}
}
// TestQuitWakesPeerLongPoll checks that a session's QUIT
// wakes the long poll of a user who shares a channel with
// it.
@@ -2012,15 +2035,7 @@ func checkQuitWakesPoll(
msgs, pollErr = tserver.longPoll(peerToken, lastID, quitWakePollTimeout)
}()
deadline := time.Now().Add(quitWakeDeadline)
for tserver.broker.ListenerCount(peerID) == 0 {
if time.Now().After(deadline) {
t.Fatal("the long poll did not start waiting")
}
time.Sleep(pollWaitStep)
}
tserver.waitForLongPoll(peerID)
leave(tserver, token)
@@ -2850,7 +2865,12 @@ func TestLongPoll(t *testing.T) {
pollMsgs = result.Messages
}()
time.Sleep(200 * time.Millisecond)
bobID, err := tserver.database.GetSessionByNick(t.Context(), "lp_bob")
if err != nil {
t.Fatalf("get session of lp_bob: %v", err)
}
tserver.waitForLongPoll(bobID)
tserver.sendCommand(aliceToken, map[string]any{
commandKey: privmsgCmd,
@@ -4281,6 +4301,116 @@ func TestLoginRateLimitExceeded(t *testing.T) {
}
}
func TestLoginRateLimitIgnoresForwardedHeaders(t *testing.T) {
t.Parallel()
// The test client connects from 127.0.0.1, which is outside the
// default trusted proxies, so the server must ignore these headers.
tests := []struct {
name string
header string
}{
{name: "X-Forwarded-For", header: "X-Forwarded-For"},
{name: "X-Real-IP", header: "X-Real-IP"},
}
loginBody, err := json.Marshal(map[string]string{
nickKey: "unknownnick", passwordKey: "wrongpassword",
})
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
// The default burst is 5 attempts, so the sixth gets 429
// although each claims a new address.
var status int
for attempt := range 6 {
request, rErr := http.NewRequestWithContext(
t.Context(), http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if rErr != nil {
t.Fatal(rErr)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set(
test.header, fmt.Sprintf("203.0.113.%d", attempt+1),
)
resp, dErr := http.DefaultClient.Do(request)
if dErr != nil {
t.Fatal(dErr)
}
status = resp.StatusCode
_ = resp.Body.Close()
}
if status != http.StatusTooManyRequests {
t.Fatalf("sixth attempt got %d, want 429", status)
}
})
}
}
func TestLoginRateLimitPerClientBehindTrustedProxy(t *testing.T) {
t.Parallel()
// The test client connects from 127.0.0.1. Trusted as a proxy, it
// passes each client's address in X-Forwarded-For.
tserver := newTestServerWith(t, 0, "", "", "127.0.0.1")
// An empty JSON object is refused after the rate limit counts the
// attempt, so no attempt checks a password or looks up a hostname.
loginFrom := func(forwardedFor string) int {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), http.MethodPost,
tserver.url("/api/v1/login"), strings.NewReader("{}"),
)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Forwarded-For", forwardedFor)
resp, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
return resp.StatusCode
}
// The default burst is 5 attempts per address.
for range 5 {
loginFrom("203.0.113.1")
}
status := loginFrom("203.0.113.1")
if status != http.StatusTooManyRequests {
t.Errorf("sixth attempt from 203.0.113.1 got %d, want 429", status)
}
status = loginFrom("203.0.113.2")
if status == http.StatusTooManyRequests {
t.Error("first attempt from 203.0.113.2 got 429")
}
}
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
t.Parallel()
@@ -4968,7 +5098,9 @@ func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
badNick = "bad nick"
)
tserver := newTestServerWith(t, bits, "", "")
tserver := newTestServerWith(
t, bits, "", "", config.DefaultTrustedProxies,
)
// A server with no SERVER_NAME takes stamps for "neoirc".
status := postSessionWithStamp(
@@ -5174,6 +5306,7 @@ func newTestServerWithOper(
return newTestServerWith(
t, 0, testOperName, testOperPassword,
config.DefaultTrustedProxies,
)
}
+7
View File
@@ -70,6 +70,10 @@ type Conn struct {
closed bool
killed bool
// pollInterval is how often the relay checks the client
// output queue when no broker notification arrives.
pollInterval time.Duration
// quitReason is the reason in the QUIT that peers are
// sent when the connection ends. The client's QUIT
// replaces it.
@@ -83,6 +87,7 @@ func newConn(
brk *broker.Broker,
cfg *config.Config,
svc *service.Service,
poll time.Duration,
) *Conn {
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
@@ -101,6 +106,8 @@ func newConn(
serverSfx: srvName,
remoteIP: host,
quitReason: "Connection closed",
pollInterval: poll,
}
conn.commands = conn.buildCommandMap()
+13 -9
View File
@@ -14,16 +14,16 @@ import (
)
// NewTestServer creates a Server suitable for testing, whose
// connections use interval and deadline in place of
// pingInterval and pongDeadline, and resolve in place of
// resolveHost.
// connections use interval, deadline and poll in place of
// pingInterval, pongDeadline and pollInterval, and resolve in
// place of resolveHost.
// The caller must call Stop() when finished.
func NewTestServer(
log *slog.Logger,
cfg *config.Config,
database *db.Database,
brk *broker.Broker,
interval, deadline time.Duration,
interval, deadline, poll time.Duration,
resolve func(ctx context.Context, addr string) string,
) *Server {
globs := &globals.Globals{
@@ -46,10 +46,18 @@ func NewTestServer(
pingInterval: interval,
pongDeadline: deadline,
pollInterval: poll,
stopTimeout: stopTimeout,
resolveHost: resolve,
}
}
// SetStopTimeout makes Stop wait at most timeout for the
// connections' cleanup.
func (s *Server) SetStopTimeout(timeout time.Duration) {
s.stopTimeout = timeout
}
// ResolveHost is the reverse DNS lookup that a server made by
// New does for each client.
func ResolveHost(ctx context.Context, addr string) string {
@@ -99,10 +107,6 @@ const PingInterval = pingInterval
// for a line before it closes the connection.
const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it.
func NewTestConn(
@@ -111,7 +115,7 @@ func NewTestConn(
tcpConn net.Conn,
nick string,
) *Conn {
conn := newConn(tcpConn, log, nil, nil, cfg, nil)
conn := newConn(tcpConn, log, nil, nil, cfg, nil, pollInterval)
conn.nick = nick
return conn
-4
View File
@@ -3,7 +3,6 @@ package ircserver_test
import (
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
)
@@ -1508,9 +1507,6 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
bob.joinAndDrain("#trio")
carol.joinAndDrain("#trio")
// Drain join notifications.
time.Sleep(100 * time.Millisecond)
// alice sends; both bob and carol should receive.
alice.send("PRIVMSG #trio :hello trio")
+1 -1
View File
@@ -17,7 +17,7 @@ import (
func (c *Conn) relayMessages(ctx context.Context) {
// Use a ticker as a fallback; primary wakeup is via
// broker notification.
ticker := time.NewTicker(pollInterval)
ticker := time.NewTicker(c.pollInterval)
defer ticker.Stop()
for {
+63 -1
View File
@@ -9,6 +9,7 @@ import (
"time"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
@@ -58,7 +59,11 @@ func (env *testEnv) queueMessage(
func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline,
ircserver.PollInterval, ircserver.ResolveHost,
)
client := env.dial(t)
client.register("idle")
@@ -119,6 +124,63 @@ func (env *testEnv) checkOneWaitChannel(
}
}
// TestRelayDeliversAfterFailedRead checks that when the relay
// cannot read the client output queue, it waits for the next
// notification, and delivers once the queue can be read again.
func TestRelayDeliversAfterFailedRead(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("reader")
sessionID, err := env.database.GetSessionByNick(t.Context(), "reader")
if err != nil {
t.Fatalf("get session of reader: %v", err)
}
dbtest.Exec(t, env.dbURL,
"ALTER TABLE client_queues RENAME TO hidden_queues")
// Each pass of the relay takes a wait channel and then reads
// the queue. Once it holds a channel after the second
// notification, the read after the first one has failed.
for range 2 {
env.waitForWaitChannel(t, sessionID)
env.brk.Notify(sessionID)
}
env.waitForWaitChannel(t, sessionID)
dbtest.Exec(t, env.dbURL,
"ALTER TABLE hidden_queues RENAME TO client_queues")
env.queueMessage(
t, "reader", "PRIVMSG", "carol", "reader", []string{"readable"},
)
client.readUntil(func(l string) bool {
return strings.Contains(l, "readable")
})
}
// waitForWaitChannel waits until the session sessionID holds a
// broker wait channel, and fails the test if it does not
// within testTimeout.
func (env *testEnv) waitForWaitChannel(t *testing.T, sessionID int64) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for env.brk.ListenerCount(sessionID) == 0 {
if time.Now().After(deadline) {
t.Fatal("the relay is not waiting for a notification")
}
time.Sleep(relayCheckStep)
}
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line.
+10 -3
View File
@@ -53,6 +53,11 @@ type Server struct {
pingInterval time.Duration
pongDeadline time.Duration
// pollInterval and stopTimeout are the constants of the
// same names, except in tests, which change them.
pollInterval time.Duration
stopTimeout time.Duration
// resolveHost is the function of the same name, except in
// tests, which replace it with one that blocks.
resolveHost func(ctx context.Context, addr string) string
@@ -79,6 +84,8 @@ func New(
pingInterval: pingInterval,
pongDeadline: pongDeadline,
pollInterval: pollInterval,
stopTimeout: stopTimeout,
resolveHost: resolveHost,
}
@@ -154,10 +161,10 @@ func (s *Server) stop() {
select {
case <-done:
case <-time.After(stopTimeout):
case <-time.After(s.stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", stopTimeout,
"timeout", s.stopTimeout,
)
}
}
@@ -181,7 +188,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
client := newConn(
tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc,
s.database, s.brk, s.cfg, s.svc, s.pollInterval,
)
s.mu.Lock()
+26 -13
View File
@@ -18,6 +18,7 @@ import (
"time"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
@@ -32,6 +33,12 @@ import (
const testTimeout = 5 * time.Second
// testPollInterval is how often the relay of a test server
// checks the client output queue without a broker
// notification. It is so long that the relay delivers only on
// notification, and a test fails when one is missing.
const testPollInterval = time.Hour
// testEnv holds the shared test infrastructure.
type testEnv struct {
dbURL string
@@ -91,16 +98,19 @@ func newTestEnvWithPingTimes(
t.Helper()
return newTestEnvWithResolver(
t, cfg, interval, deadline, ircserver.ResolveHost,
t, cfg, interval, deadline, testPollInterval,
ircserver.ResolveHost,
)
}
// newTestEnvWithResolver is newTestEnvWithPingTimes, except
// that the server looks up a client's hostname with resolve.
// that the relay checks the client output queue every poll
// without a broker notification, and the server looks up a
// client's hostname with resolve.
func newTestEnvWithResolver(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
interval, deadline, poll time.Duration,
resolve func(ctx context.Context, addr string) string,
) *testEnv {
t.Helper()
@@ -116,8 +126,9 @@ func newTestEnvWithResolver(
}
database, err := db.New(lifecycle, db.Params{
Logger: dbLog,
Config: &config.Config{DBURL: dbURL},
Logger: dbLog,
Config: &config.Config{DBURL: dbURL},
PasswordCost: bcrypt.MinCost,
})
if err != nil {
t.Fatalf("new database: %v", err)
@@ -134,7 +145,7 @@ func newTestEnvWithResolver(
))
srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline, resolve,
log, cfg, database, brk, interval, deadline, poll, resolve,
)
// Port 0 makes the system pick a free port. dial reads it
@@ -430,7 +441,8 @@ func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, resolve,
ircserver.PingInterval, ircserver.PongDeadline,
testPollInterval, resolve,
)
env.dial(t)
@@ -1039,12 +1051,16 @@ func TestStopEndsSessions(t *testing.T) {
}
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a
// cleanup that cannot finish does not hold up stopping.
// waiting for a connection's cleanup after its stop timeout,
// so a cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel()
const stopTimeout = 100 * time.Millisecond
env := newTestEnv(t)
env.srv.SetStopTimeout(stopTimeout)
client := env.dial(t)
client.register("stuck")
@@ -1070,7 +1086,7 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
select {
case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout):
case <-time.After(stopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck")
}
@@ -1122,9 +1138,6 @@ func TestDirectMessage(t *testing.T) {
receiver := env.dial(t)
receiver.register("dmreceiver")
// Give relay goroutines time to start.
time.Sleep(100 * time.Millisecond)
sender.send("PRIVMSG dmreceiver :hello privately")
lines := receiver.readUntil(func(line string) bool {
+75
View File
@@ -5,6 +5,9 @@ import (
"log/slog"
"net"
"net/http"
"net/netip"
"slices"
"strings"
"time"
basicauth "github.com/99designs/basicauth-go"
@@ -15,6 +18,7 @@ import (
"github.com/slok/go-http-metrics/middleware/std"
"github.com/spf13/viper"
"go.uber.org/fx"
"go4.org/netipx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
@@ -62,6 +66,77 @@ func ipFromHostPort(hostPort string) string {
return host
}
// ClientIP returns middleware that replaces request.RemoteAddr with the
// client's address and keeps the port of the TCP connection. The
// X-Forwarded-For and X-Real-IP headers count only when the connection
// comes from an address in TRUSTED_PROXIES.
func (mware *Middleware) ClientIP() func(http.Handler) http.Handler {
trusted := mware.params.Config.TrustedProxies
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
peer, err := netip.ParseAddrPort(request.RemoteAddr)
if err == nil {
client := clientAddr(
peer.Addr().Unmap(), request.Header, trusted,
)
request.RemoteAddr = netip.AddrPortFrom(
client, peer.Port(),
).String()
}
next.ServeHTTP(writer, request)
})
}
}
// clientAddr returns the client's address for a request from peer. A
// peer outside trusted is the client, whatever its headers say. A
// trusted peer is a proxy. Its X-Forwarded-For list is read from the
// right, past the trusted addresses, to the first untrusted one; when
// all are trusted, the client is the leftmost. Without X-Forwarded-For,
// the client is X-Real-IP. An entry that is not an IP address gives
// peer.
func clientAddr(
peer netip.Addr, header http.Header, trusted *netipx.IPSet,
) netip.Addr {
if !trusted.Contains(peer) {
return peer
}
forwarded := strings.Join(header.Values("X-Forwarded-For"), ",")
if forwarded == "" {
realIP, err := netip.ParseAddr(
strings.TrimSpace(header.Get("X-Real-IP")),
)
if err != nil {
return peer
}
return realIP.Unmap()
}
client := peer
for _, entry := range slices.Backward(strings.Split(forwarded, ",")) {
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
if err != nil {
return peer
}
client = addr.Unmap()
if !trusted.Contains(client) {
break
}
}
return client
}
type loggingResponseWriter struct {
http.ResponseWriter
+189
View File
@@ -0,0 +1,189 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"time"
"go4.org/netipx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
)
// The tests trust 10.0.0.0/8. Clients use the documentation ranges
// 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24.
const (
trustedNetwork = "10.0.0.0/8"
proxyPeer = "10.0.0.1:4000"
directPeer = "192.0.2.1:4000"
client = "203.0.113.7"
clientWithPort = "203.0.113.7:4000"
realIPClient = "203.0.113.8"
)
type clientIPTest struct {
name string
remoteAddr string
forwarded []string
realIP string
want string
}
func clientIPTests() []clientIPTest {
return []clientIPTest{
{
name: "untrusted peer ignores forged headers",
remoteAddr: directPeer,
forwarded: []string{client},
realIP: realIPClient,
want: directPeer,
},
{name: "trusted peer without headers", remoteAddr: proxyPeer, want: proxyPeer},
{
name: "trusted peer gives rightmost untrusted entry",
remoteAddr: proxyPeer,
forwarded: []string{"198.51.100.9, 203.0.113.7, 10.0.0.2"},
want: clientWithPort,
},
{
name: "entries split over several headers",
remoteAddr: proxyPeer,
forwarded: []string{"198.51.100.9", client, "10.0.0.2"},
want: clientWithPort,
},
{
name: "all-trusted chain gives leftmost entry",
remoteAddr: proxyPeer,
forwarded: []string{"10.0.0.3, 10.0.0.2"},
want: "10.0.0.3:4000",
},
{
name: "X-Real-IP without X-Forwarded-For",
remoteAddr: proxyPeer,
realIP: realIPClient,
want: "203.0.113.8:4000",
},
{
name: "X-Forwarded-For wins over X-Real-IP",
remoteAddr: proxyPeer,
forwarded: []string{client},
realIP: realIPClient,
want: clientWithPort,
},
{
name: "unparseable X-Forwarded-For entry gives peer",
remoteAddr: proxyPeer,
forwarded: []string{"203.0.113.7, not-an-ip, 10.0.0.2"},
want: proxyPeer,
},
{
name: "unparseable entry past the client is not read",
remoteAddr: proxyPeer,
forwarded: []string{"not-an-ip, 203.0.113.7"},
want: clientWithPort,
},
{
name: "unparseable X-Real-IP gives peer",
remoteAddr: proxyPeer,
realIP: "not-an-ip",
want: proxyPeer,
},
{
name: "mapped IPv6 peer is unmapped and trusted",
remoteAddr: "[::ffff:10.0.0.1]:4000",
forwarded: []string{client},
want: clientWithPort,
},
{
name: "mapped IPv6 peer outside the set",
remoteAddr: "[::ffff:192.0.2.1]:4000",
forwarded: []string{client},
want: directPeer,
},
{
name: "IPv6 client",
remoteAddr: proxyPeer,
forwarded: []string{"2001:db8::1"},
want: "[2001:db8::1]:4000",
},
}
}
func TestClientIP(t *testing.T) {
t.Parallel()
mware := newMiddleware(t, trustedNetwork)
for _, test := range clientIPTests() {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
var got string
handler := mware.ClientIP()(http.HandlerFunc(func(
_ http.ResponseWriter, request *http.Request,
) {
got = request.RemoteAddr
}))
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
request.RemoteAddr = test.remoteAddr
for _, value := range test.forwarded {
request.Header.Add("X-Forwarded-For", value)
}
if test.realIP != "" {
request.Header.Set("X-Real-IP", test.realIP)
}
handler.ServeHTTP(httptest.NewRecorder(), request)
if got != test.want {
t.Errorf("RemoteAddr = %q, want %q", got, test.want)
}
})
}
}
// newMiddleware returns a Middleware that trusts the network trusted.
func newMiddleware(t *testing.T, trusted string) *middleware.Middleware {
t.Helper()
var builder netipx.IPSetBuilder
builder.AddPrefix(netip.MustParsePrefix(trusted))
trustedSet, err := builder.IPSet()
if err != nil {
t.Fatal(err)
}
globs := &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
log, err := logger.New(nil, logger.Params{Globals: globs})
if err != nil {
t.Fatal(err)
}
mware, err := middleware.New(nil, middleware.Params{
Logger: log,
Globals: globs,
Config: &config.Config{TrustedProxies: trustedSet},
})
if err != nil {
t.Fatal(err)
}
return mware
}
+1
View File
@@ -23,6 +23,7 @@ func (srv *Server) SetupRoutes() {
srv.router.Use(middleware.Recoverer)
srv.router.Use(middleware.RequestID)
srv.router.Use(srv.mw.ClientIP())
srv.router.Use(srv.mw.Logging())
if viper.GetString("METRICS_USERNAME") != "" {
+129 -89
View File
@@ -1,7 +1,3 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package service_test
import (
@@ -59,28 +55,12 @@ func newTestEnv(t *testing.T) *testEnv {
}
},
logger.New,
func(
lifecycle fx.Lifecycle,
globs *globals.Globals,
log *logger.Logger,
) (*config.Config, error) {
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
func() *config.Config {
return &config.Config{
DBURL: dbURL,
OperName: "admin",
OperPassword: "secret",
}
cfg.DBURL = dbURL
cfg.Port = 0
cfg.OperName = "admin"
cfg.OperPassword = "secret"
return cfg, nil
},
func(
lifecycle fx.Lifecycle,
@@ -136,6 +116,8 @@ func createSession(
// environment starts on an empty database while another one
// is still open.
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
t.Parallel()
ctx := t.Context()
first := newTestEnv(t)
@@ -146,6 +128,8 @@ func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
}
func TestFanOut(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -173,6 +157,8 @@ func TestFanOut(t *testing.T) {
}
func TestJoinChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -213,6 +199,8 @@ func TestJoinChannel(t *testing.T) {
}
func TestPartChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -313,6 +301,8 @@ func joinConcurrently(
// concurrent JOINs of a +l 2 channel with one member, one
// gets in and the other gets ERR_CHANNELISFULL.
func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
t.Parallel()
const userLimit = 2
env := newTestEnv(t)
@@ -375,6 +365,8 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
// two concurrent JOINs that create a channel, exactly one
// makes its session the operator.
func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -426,6 +418,8 @@ func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) {
// either joins a channel that still exists or gets
// ERR_NOSUCHCHANNEL, and never fails otherwise.
func TestJoinConcurrentWithLastPart(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -491,6 +485,8 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
// quits, gets in only once the channel is deleted, into a
// new channel of that name with no key.
func TestJoinConcurrentWithLastQuit(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -569,81 +565,95 @@ const quitCrowdSize = 10
// ahead of that JOIN. The quitting session is also on a
// channel with quitCrowdSize other sessions, made before the
// watcher's channel, so that its QUIT reaches the watcher
// last.
// last. Each trial runs on a database of its own.
func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
t.Parallel()
for trial := range concurrentJoinTrials {
nick := fmt.Sprintf("reused%d", trial)
crowd := fmt.Sprintf("#crowd%d", trial)
shared := fmt.Sprintf("#shared%d", trial)
quitterSID := createSession(ctx, t, env.db, nick)
t.Run(fmt.Sprintf("trial %d", trial), func(t *testing.T) {
t.Parallel()
for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
}
checkQuitQueuedAheadOfJoin(t, newTestEnv(t))
})
}
}
// checkQuitQueuedAheadOfJoin runs one trial of
// TestQuitQueuedAheadOfNewNickHoldersJoin on env.
func checkQuitQueuedAheadOfJoin(t *testing.T, env *testEnv) {
t.Helper()
const (
nick = "reused"
crowd = "#crowd"
shared = "#shared"
watcherNick = "watcher"
)
ctx := t.Context()
quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
}
}
for member := range quitCrowdSize {
memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
memberSID := createSession(ctx, t, env.db, memberNick)
for member := range quitCrowdSize {
memberNick := fmt.Sprintf("crowd%d", member)
memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "")
if err != nil {
t.Fatalf("join %s: %v", memberNick, err)
}
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "")
if err != nil {
t.Fatalf("join %s: %v", memberNick, err)
}
}
watcherNick := fmt.Sprintf("watcher%d", trial)
watcherSID, watcherClientID, _, err := env.db.CreateSession(
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
)
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
watcherSID, watcherClientID, _, err := env.db.CreateSession(
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("new session: %v", errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"watcher got %v from %s, want QUIT then JOIN",
commands, nick,
)
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("trial %d: new session: %v", trial, errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"trial %d: watcher got %v from %s, want QUIT then JOIN",
trial, commands, nick,
)
}
}
}
@@ -691,6 +701,8 @@ func commandsFrom(msgs []db.IRCMessage, nick string) []string {
// into a +i channel uses the invite up, so that a JOIN
// after leaving gets ERR_INVITEONLYCHAN.
func TestJoinUsesUpInvite(t *testing.T) {
t.Parallel()
const channel = "#club"
env := newTestEnv(t)
@@ -736,6 +748,8 @@ func TestJoinUsesUpInvite(t *testing.T) {
// session's nick, username and hostname refuses its JOIN
// with ERR_BANNEDFROMCHAN.
func TestJoinRefusedByHostmaskBan(t *testing.T) {
t.Parallel()
const channel = "#club"
env := newTestEnv(t)
@@ -774,6 +788,8 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
// keeps a channel other members are still on and deletes
// the channel when its last member parts.
func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
t.Parallel()
const channel = "#room"
env := newTestEnv(t)
@@ -821,6 +837,8 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
// TestKickOfLastMemberDeletesChannel checks that a KICK
// that leaves a channel with no members deletes it.
func TestKickOfLastMemberDeletesChannel(t *testing.T) {
t.Parallel()
const channel = "#room"
env := newTestEnv(t)
@@ -845,6 +863,8 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) {
}
func TestSendChannelMessage(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -892,6 +912,8 @@ func TestSendChannelMessage(t *testing.T) {
}
func TestBroadcastQuit(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -929,6 +951,8 @@ func TestBroadcastQuit(t *testing.T) {
// error as a DM to an unknown nick. The trigger deletes the
// target's session just before the DM is stored.
func TestSendDirectMessageTargetQuits(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -960,6 +984,8 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
// trigger deletes the channel just before the message is
// stored.
func TestSendChannelMessageChannelDeleted(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -991,6 +1017,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
}
func TestSendChannelMessage_Moderated(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -1042,6 +1070,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
// not spend its hashcash stamp: the operator can then send
// the same message with it.
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
t.Parallel()
const (
channel = "#paid"
bits = 2
@@ -1067,6 +1097,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
for _, refusal := range refusals {
t.Run(refusal.name, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -1119,6 +1151,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
}
func TestQueryUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -1169,6 +1203,8 @@ func TestQueryUserMode(t *testing.T) {
}
func TestUserhostReply(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ctx := t.Context()
@@ -1274,10 +1310,14 @@ func applyUserModeTests() []applyUserModeTest {
}
func TestApplyUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, test := range applyUserModeTests() {
t.Run(test.modeStr, func(t *testing.T) {
t.Parallel()
ctx := t.Context()
sid := createSession(
ctx, t, env.db, fmt.Sprintf("user%d", i),