Compare commits
3
Commits
d39e0f2bb5
...
6c555958d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c555958d6 | ||
|
|
f42c6214a0 | ||
|
|
a3d9bbfd81 |
No files matched your search
+7
-6
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
|
||||
Executable
+14
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
passwordCost: passwordCost,
|
||||
}
|
||||
|
||||
database.log.Info("Database instantiated")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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") != "" {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user