feat: add username/hostname support with IRC hostmask format (#82)
All checks were successful
check / check (push) Successful in 6s

## Summary

Adds username and hostname support to sessions, enabling standard IRC hostmask format (`nick!user@host`) for WHOIS, WHO, and future `+b` ban matching.

closes #81

## Changes

### Schema (`001_initial.sql`)
- Added `username TEXT NOT NULL DEFAULT ''` and `hostname TEXT NOT NULL DEFAULT ''` columns to the `sessions` table

### Database layer (`internal/db/`)
- `CreateSession` now accepts `username` and `hostname` parameters; username defaults to nick if empty
- `RegisterUser` now accepts `username` and `hostname` parameters
- New `SessionHostInfo` type and `GetSessionHostInfo` query to retrieve username/hostname for a session
- `MemberInfo` now includes `Username` and `Hostname` fields
- `ChannelMembers` query updated to return username/hostname
- New `FormatHostmask(nick, username, hostname)` helper that produces `nick!user@host` format
- New `Hostmask()` method on `MemberInfo`

### Handler layer (`internal/handlers/`)
- Session creation (`POST /api/v1/session`) accepts optional `username` field; resolves hostname via reverse DNS of connecting client IP (respects `X-Forwarded-For` and `X-Real-IP` headers)
- Registration (`POST /api/v1/register`) accepts optional `username` field with the same hostname resolution
- Username validation regex: `^[a-zA-Z0-9_\-\[\]\\^{}|` + "\`" + `]{1,32}$`
- WHOIS (`311 RPL_WHOISUSER`) now returns the real username and hostname instead of nick/servername
- WHO (`352 RPL_WHOREPLY`) now returns the real username and hostname instead of nick/servername
- Extracted `validateHashcash` and `resolveUsername` helpers to keep functions under the linter's `funlen` limit
- Extracted `executeRegister` helper for the same reason
- Reverse DNS uses `(*net.Resolver).LookupAddr` with a 3-second timeout context

### Tests
- `TestCreateSessionWithUserHost` — verifies username/hostname are stored and retrievable
- `TestCreateSessionDefaultUsername` — verifies empty username defaults to nick
- `TestGetSessionHostInfoNotFound` — verifies error on nonexistent session
- `TestFormatHostmask` — verifies `nick!user@host` formatting
- `TestFormatHostmaskDefaults` — verifies fallback when username/hostname empty
- `TestMemberInfoHostmask` — verifies `Hostmask()` method on `MemberInfo`
- `TestChannelMembersIncludeUserHost` — verifies `ChannelMembers` returns username/hostname
- `TestRegisterUserWithUserHost` — verifies registration stores username/hostname
- `TestRegisterUserDefaultUsername` — verifies registration defaults username to nick
- `TestWhoisShowsHostInfo` — integration test verifying WHOIS returns the correct username
- `TestWhoShowsHostInfo` — integration test verifying WHO returns the correct username
- `TestSessionUsernameDefault` — integration test verifying default username in WHOIS
- All existing tests updated for new `CreateSession`/`RegisterUser` signatures

### README
- New "Hostmask" section documenting the `nick!user@host` format
- Updated session creation and registration API docs with the new `username` field
- Updated WHOIS/WHO numeric examples to show real username/hostname
- Updated sessions schema table with new columns

## Docker build

`docker build .` passes cleanly (lint, format, tests, build).

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #82
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #82.
This commit is contained in:
2026-03-20 06:53:35 +01:00
committed by Jeffrey Paul
parent bf4d63bc4d
commit db3d23c224
14 changed files with 2009 additions and 137 deletions

View File

@@ -2,9 +2,11 @@ package handlers
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"regexp"
"strconv"
@@ -30,6 +32,12 @@ var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`,
)
var validUsernameRe = regexp.MustCompile(
`^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`,
)
const dnsLookupTimeout = 3 * time.Second
const (
maxLongPollTimeout = 30
pollMessageLimit = 100
@@ -46,6 +54,55 @@ 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.
func clientIP(request *http.Request) string {
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
// X-Forwarded-For can contain a comma-separated list;
// the first entry is the original client.
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd
ip := strings.TrimSpace(parts[0])
if ip != "" {
return ip
}
}
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
return strings.TrimSpace(realIP)
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
// resolveHostname performs a reverse DNS lookup on the
// given IP address. Returns the first PTR record with the
// trailing dot stripped, or the raw IP if lookup fails.
func resolveHostname(
reqCtx context.Context,
addr string,
) string {
resolver := &net.Resolver{} //nolint:exhaustruct // using default resolver
ctx, cancel := context.WithTimeout(
reqCtx, dnsLookupTimeout,
)
defer cancel()
names, err := resolver.LookupAddr(ctx, addr)
if err != nil || len(names) == 0 {
return addr
}
return strings.TrimSuffix(names[0], ".")
}
// authSession extracts the session from the client token.
func (hdlr *Handlers) authSession(
request *http.Request,
@@ -155,6 +212,7 @@ func (hdlr *Handlers) handleCreateSession(
) {
type createRequest struct {
Nick string `json:"nick"`
Username string `json:"username,omitempty"`
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
}
@@ -171,30 +229,10 @@ func (hdlr *Handlers) handleCreateSession(
return
}
// Validate hashcash proof-of-work if configured.
if hdlr.params.Config.HashcashBits > 0 {
if payload.Hashcash == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return
}
err = hdlr.hashcashVal.Validate(
payload.Hashcash, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return
}
if !hdlr.validateHashcash(
writer, request, payload.Hashcash,
) {
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
@@ -209,9 +247,40 @@ func (hdlr *Handlers) handleCreateSession(
return
}
username := resolveUsername(
payload.Username, payload.Nick,
)
if !validUsernameRe.MatchString(username) {
hdlr.respondError(
writer, request,
"invalid username format",
http.StatusBadRequest,
)
return
}
hdlr.executeCreateSession(
writer, request, payload.Nick, username,
)
}
func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter,
request *http.Request,
nick, username string,
) {
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession(
request.Context(), payload.Nick,
request.Context(),
nick, username, hostname, remoteIP,
)
if err != nil {
hdlr.handleCreateSessionError(
@@ -224,15 +293,64 @@ func (hdlr *Handlers) handleCreateSession(
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.deliverMOTD(request, clientID, sessionID, nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"nick": nick,
"token": token,
}, http.StatusCreated)
}
// validateHashcash validates a hashcash stamp if required.
// Returns false if validation failed and a response was
// already sent.
func (hdlr *Handlers) validateHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
if hdlr.params.Config.HashcashBits == 0 {
return true
}
if stamp == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return false
}
err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// resolveUsername returns the trimmed username, defaulting
// to the nick if empty.
func resolveUsername(username, nick string) string {
username = strings.TrimSpace(username)
if username == "" {
return nick
}
return username
}
func (hdlr *Handlers) handleCreateSessionError(
writer http.ResponseWriter,
request *http.Request,
@@ -352,9 +470,19 @@ func (hdlr *Handlers) deliverLusers(
)
// 252 RPL_LUSEROP
operCount, operErr := hdlr.params.Database.
GetOperCount(ctx)
if operErr != nil {
hdlr.log.Error(
"lusers oper count", "error", operErr,
)
operCount = 0
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserOp, nick,
[]string{"0"},
[]string{strconv.FormatInt(operCount, 10)},
"operator(s) online",
)
@@ -885,6 +1013,11 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdMotd, irc.CmdPing:
hdlr.dispatchInfoCommand(
writer, request,
@@ -1534,16 +1667,16 @@ func (hdlr *Handlers) deliverNamesNumerics(
)
if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members))
entries := make([]string, 0, len(members))
for _, mem := range members {
nicks = append(nicks, mem.Nick)
entries = append(entries, mem.Hostmask())
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNamReply, nick,
[]string{"=", channel},
strings.Join(nicks, " "),
strings.Join(entries, " "),
)
}
@@ -2329,16 +2462,16 @@ func (hdlr *Handlers) handleNames(
ctx, chID,
)
if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members))
entries := make([]string, 0, len(members))
for _, mem := range members {
nicks = append(nicks, mem.Nick)
entries = append(entries, mem.Hostmask())
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNamReply, nick,
[]string{"=", channel},
strings.Join(nicks, " "),
strings.Join(entries, " "),
)
}
@@ -2445,55 +2578,42 @@ func (hdlr *Handlers) executeWhois(
nick, queryNick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
targetSID, err := hdlr.params.Database.GetSessionByNick(
ctx, queryNick,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrNoSuchNick, nick,
[]string{queryNick},
"No such nick/channel",
hdlr.whoisNotFound(
ctx, writer, request,
sessionID, clientID, nick, queryNick,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisUser, nick,
[]string{queryNick, queryNick, srvName, "*"},
queryNick,
hdlr.deliverWhoisUser(
ctx, clientID, nick, queryNick, targetSID,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisServer, nick,
[]string{queryNick, srvName},
"neoirc server",
// 313 RPL_WHOISOPERATOR — show if target is oper.
hdlr.deliverWhoisOperator(
ctx, clientID, nick, queryNick, targetSID,
)
// 317 RPL_WHOISIDLE
hdlr.deliverWhoisIdle(
ctx, clientID, nick, queryNick, targetSID,
)
// 319 RPL_WHOISCHANNELS
hdlr.deliverWhoisChannels(
ctx, clientID, nick, queryNick, targetSID,
)
// 318 RPL_ENDOFWHOIS
// 338 RPL_WHOISACTUALLY — oper-only.
hdlr.deliverWhoisActually(
ctx, clientID, nick, queryNick,
sessionID, targetSID,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
@@ -2506,6 +2626,90 @@ func (hdlr *Handlers) executeWhois(
http.StatusOK)
}
// whoisNotFound sends the error+end numerics when the
// target nick is not found.
func (hdlr *Handlers) whoisNotFound(
ctx context.Context,
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, queryNick string,
) {
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrNoSuchNick, nick,
[]string{queryNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// deliverWhoisUser sends RPL_WHOISUSER (311) and
// RPL_WHOISSERVER (312).
func (hdlr *Handlers) deliverWhoisUser(
ctx context.Context,
clientID int64,
nick, queryNick string,
targetSID int64,
) {
srvName := hdlr.serverName()
username := queryNick
hostname := srvName
hostInfo, hostErr := hdlr.params.Database.
GetSessionHostInfo(ctx, targetSID)
if hostErr == nil && hostInfo != nil {
if hostInfo.Username != "" {
username = hostInfo.Username
}
if hostInfo.Hostname != "" {
hostname = hostInfo.Hostname
}
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisUser, nick,
[]string{queryNick, username, hostname, "*"},
queryNick,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisServer, nick,
[]string{queryNick, srvName},
"neoirc server",
)
}
// deliverWhoisOperator sends RPL_WHOISOPERATOR (313) if
// the target has server oper status.
func (hdlr *Handlers) deliverWhoisOperator(
ctx context.Context,
clientID int64,
nick, queryNick string,
targetSID int64,
) {
targetIsOper, err := hdlr.params.Database.
IsSessionOper(ctx, targetSID)
if err != nil || !targetIsOper {
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisOperator, nick,
[]string{queryNick},
"is an IRC operator",
)
}
func (hdlr *Handlers) deliverWhoisChannels(
ctx context.Context,
clientID int64,
@@ -2531,6 +2735,44 @@ func (hdlr *Handlers) deliverWhoisChannels(
)
}
// deliverWhoisActually sends RPL_WHOISACTUALLY (338)
// with the target's current client IP and hostname, but
// only when the querying session has server oper status
// (o-line). Non-opers see nothing extra.
func (hdlr *Handlers) deliverWhoisActually(
ctx context.Context,
clientID int64,
nick, queryNick string,
querierSID, targetSID int64,
) {
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, querierSID,
)
if err != nil || !isOper {
return
}
clientInfo, clErr := hdlr.params.Database.
GetLatestClientForSession(ctx, targetSID)
if clErr != nil {
return
}
actualHost := clientInfo.Hostname
if actualHost == "" {
actualHost = clientInfo.IP
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisActually, nick,
[]string{
queryNick,
clientInfo.IP,
},
"is actually using host "+actualHost,
)
}
// handleWho handles the WHO command.
func (hdlr *Handlers) handleWho(
writer http.ResponseWriter,
@@ -2579,11 +2821,21 @@ func (hdlr *Handlers) handleWho(
)
if memErr == nil {
for _, mem := range members {
username := mem.Username
if username == "" {
username = mem.Nick
}
hostname := mem.Hostname
if hostname == "" {
hostname = srvName
}
// 352 RPL_WHOREPLY
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoReply, nick,
[]string{
channel, mem.Nick, srvName,
channel, username, hostname,
srvName, mem.Nick, "H",
},
"0 "+mem.Nick,
@@ -2906,6 +3158,76 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
}
}
// handleOper handles the OPER command for server operator authentication.
func (hdlr *Handlers) handleOper(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
if len(lines) < 2 { //nolint:mnd // name + password
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdOper},
"Not enough parameters",
)
return
}
operName := lines[0]
operPass := lines[1]
cfgName := hdlr.params.Config.OperName
cfgPass := hdlr.params.Config.OperPassword
if cfgName == "" || cfgPass == "" ||
subtle.ConstantTimeCompare([]byte(operName), []byte(cfgName)) != 1 ||
subtle.ConstantTimeCompare([]byte(operPass), []byte(cfgPass)) != 1 {
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrNoOperHost, nick,
nil, "No O-lines for your host",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return
}
err := hdlr.params.Database.SetSessionOper(
ctx, sessionID, true,
)
if err != nil {
hdlr.log.Error(
"set oper failed", "error", err,
)
hdlr.respondError(
writer, request, "internal error",
http.StatusInternalServerError,
)
return
}
// 381 RPL_YOUREOPER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplYoureOper, nick,
nil, "You are now an IRC operator",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleAway handles the AWAY command. An empty body
// clears the away status; a non-empty body sets it.
func (hdlr *Handlers) handleAway(

View File

@@ -11,6 +11,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
@@ -30,8 +31,14 @@ import (
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
)
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
const (
commandKey = "command"
bodyKey = "body"
@@ -102,7 +109,6 @@ func newTestServer(
)
app.RequireStart()
time.Sleep(100 * time.Millisecond)
httpSrv := httptest.NewServer(srv)
@@ -2131,6 +2137,249 @@ func TestSessionStillWorks(t *testing.T) {
}
}
// findNumericWithParams returns the first message matching
// the given numeric code. Returns nil if not found.
func findNumericWithParams(
msgs []map[string]any,
numeric string,
) map[string]any {
want, _ := strconv.Atoi(numeric)
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == want {
return msg
}
}
return nil
}
// getNumericParams extracts the params array from a
// numeric message as a string slice.
func getNumericParams(
msg map[string]any,
) []string {
raw, exists := msg["params"]
if !exists || raw == nil {
return nil
}
arr, isArr := raw.([]any)
if !isArr {
return nil
}
result := make([]string, 0, len(arr))
for _, val := range arr {
str, isString := val.(string)
if isString {
result = append(result, str)
}
}
return result
}
func TestWhoisShowsHostInfo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSessionWithUsername(
"whoisuser", "myident",
)
queryToken := tserver.createSession("querier")
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "whoisuser",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
whoisMsg := findNumericWithParams(msgs, "311")
if whoisMsg == nil {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
params := getNumericParams(whoisMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
if params[1] != "myident" {
t.Fatalf(
"expected username myident, got %s",
params[1],
)
}
_ = token
}
// createSessionWithUsername creates a session with a
// specific username and returns the token.
func (tserver *testServer) createSessionWithUsername(
nick, username string,
) string {
tserver.t.Helper()
body, err := json.Marshal(map[string]string{
"nick": nick,
"username": username,
})
if err != nil {
tserver.t.Fatalf("marshal session: %v", err)
}
resp, err := doRequest(
tserver.t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
tserver.t.Fatalf("create session: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
tserver.t.Fatalf(
"create session: status %d: %s",
resp.StatusCode, respBody,
)
}
var result struct {
Token string `json:"token"`
}
_ = json.NewDecoder(resp.Body).Decode(&result)
return result.Token
}
func TestWhoShowsHostInfo(t *testing.T) {
tserver := newTestServer(t)
whoToken := tserver.createSessionWithUsername(
"whouser", "whoident",
)
tserver.sendCommand(whoToken, map[string]any{
commandKey: joinCmd, toKey: "#whotest",
})
queryToken := tserver.createSession("whoquerier")
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: "#whotest",
})
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHO",
toKey: "#whotest",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
assertWhoReplyUsername(t, msgs, "whouser", "whoident")
}
func assertWhoReplyUsername(
t *testing.T,
msgs []map[string]any,
targetNick, expectedUsername string,
) {
t.Helper()
for _, msg := range msgs {
code, isCode := msg["code"].(float64)
if !isCode || int(code) != 352 {
continue
}
params := getNumericParams(msg)
if len(params) < 5 || params[4] != targetNick {
continue
}
if params[1] != expectedUsername {
t.Fatalf(
"expected username %s in WHO, got %s",
expectedUsername, params[1],
)
}
return
}
t.Fatalf(
"expected RPL_WHOREPLY (352) for %s, msgs: %v",
targetNick, msgs,
)
}
func TestSessionUsernameDefault(t *testing.T) {
tserver := newTestServer(t)
// Create session without specifying username.
token := tserver.createSession("defaultusr")
queryToken := tserver.createSession("querier2")
_, lastID := tserver.pollMessages(queryToken, 0)
// WHOIS should show the nick as the username.
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "defaultusr",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
whoisMsg := findNumericWithParams(msgs, "311")
if whoisMsg == nil {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
params := getNumericParams(whoisMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
// Username defaults to nick.
if params[1] != "defaultusr" {
t.Fatalf(
"expected default username defaultusr, got %s",
params[1],
)
}
_ = token
}
func TestNickBroadcastToChannels(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("nick_a")
@@ -2552,3 +2801,444 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
)
}
}
func TestNamesShowsHostmask(t *testing.T) {
tserver := newTestServer(t)
queryToken, lastID := setupChannelWithIdentMember(
tserver, "namesmember", "nmident",
"namesquery", "#namestest",
)
// Issue an explicit NAMES command.
tserver.sendCommand(queryToken, map[string]any{
commandKey: "NAMES",
toKey: "#namestest",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
assertNamesHostmask(
t, msgs, "namesmember", "nmident",
)
}
func TestNamesOnJoinShowsHostmask(t *testing.T) {
tserver := newTestServer(t)
// First user joins to populate the channel.
firstToken := tserver.createSessionWithUsername(
"joinmem", "jmident",
)
tserver.sendCommand(firstToken, map[string]any{
commandKey: joinCmd, toKey: "#joinnamestest",
})
// Second user joins; the JOIN triggers
// deliverNamesNumerics which should include
// hostmask data.
joinerToken := tserver.createSession("joiner")
tserver.sendCommand(joinerToken, map[string]any{
commandKey: joinCmd, toKey: "#joinnamestest",
})
msgs, _ := tserver.pollMessages(joinerToken, 0)
assertNamesHostmask(
t, msgs, "joinmem", "jmident",
)
}
// setupChannelWithIdentMember creates a member session
// with username, joins a channel, then creates a querier
// and joins the same channel. Returns the querier token
// and last message ID.
func setupChannelWithIdentMember(
tserver *testServer,
memberNick, memberUsername,
querierNick, channel string,
) (string, int64) {
tserver.t.Helper()
memberToken := tserver.createSessionWithUsername(
memberNick, memberUsername,
)
tserver.sendCommand(memberToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
queryToken := tserver.createSession(querierNick)
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(queryToken, 0)
return queryToken, lastID
}
// assertNamesHostmask verifies that a RPL_NAMREPLY (353)
// message contains the expected nick with hostmask format
// (nick!user@host).
func assertNamesHostmask(
t *testing.T,
msgs []map[string]any,
targetNick, expectedUsername string,
) {
t.Helper()
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if !ok || int(code) != 353 {
continue
}
raw, exists := msg["body"]
if !exists || raw == nil {
continue
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
continue
}
bodyStr, isStr := arr[0].(string)
if !isStr {
continue
}
// Look for the target nick's hostmask entry.
expected := targetNick + "!" +
expectedUsername + "@"
if !strings.Contains(bodyStr, expected) {
t.Fatalf(
"expected NAMES body to contain %q, "+
"got %q",
expected, bodyStr,
)
}
return
}
t.Fatalf(
"expected RPL_NAMREPLY (353) with hostmask "+
"for %s, msgs: %v",
targetNick, msgs,
)
}
const testOperName = "admin"
const testOperPassword = "secretpass"
// newTestServerWithOper creates a test server with oper
// credentials configured (admin / secretpass).
func newTestServerWithOper(
t *testing.T,
) *testServer {
t.Helper()
dbPath := filepath.Join(
t.TempDir(), "test.db",
)
dbURL := "file:" + dbPath +
"?_journal_mode=WAL&_busy_timeout=5000"
var srv *server.Server
app := fxtest.New(t,
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,
)
}
cfg.DBURL = dbURL
cfg.Port = 0
cfg.HashcashBits = 0
cfg.OperName = testOperName
cfg.OperPassword = testOperPassword
return cfg, nil
},
newTestDB,
stats.New,
newTestHealthcheck,
newTestMiddleware,
newTestHandlers,
newTestServerFx,
),
fx.Populate(&srv),
)
app.RequireStart()
httpSrv := httptest.NewServer(srv)
t.Cleanup(func() {
httpSrv.Close()
app.RequireStop()
})
return &testServer{
httpServer: httpSrv,
t: t,
fxApp: app,
}
}
func TestOperCommandSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("operuser")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER command.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 381 RPL_YOUREOPER.
if !findNumeric(msgs, "381") {
t.Fatalf(
"expected RPL_YOUREOPER (381), got %v",
msgs,
)
}
}
func TestOperCommandFailure(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("badoper")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER with wrong password.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, "wrongpass"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 491 ERR_NOOPERHOST.
if !findNumeric(msgs, "491") {
t.Fatalf(
"expected ERR_NOOPERHOST (491), got %v",
msgs,
)
}
}
func TestOperCommandNeedMoreParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("shortoper")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER with only one parameter.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestOperWhoisShowsClientInfo(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create a target user.
_ = tserver.createSession("target")
// Create an oper user.
operToken := tserver.createSession("theoper")
_, lastID := tserver.pollMessages(operToken, 0)
// Authenticate as oper.
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
var msgs []map[string]any
msgs, lastID = tserver.pollMessages(operToken, lastID)
if !findNumeric(msgs, "381") {
t.Fatalf(
"expected RPL_YOUREOPER (381), got %v",
msgs,
)
}
// Now WHOIS the target.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WHOIS",
toKey: "target",
})
msgs, _ = tserver.pollMessages(operToken, lastID)
// Expect 338 RPL_WHOISACTUALLY with client IP.
whoisActually := findNumericWithParams(msgs, "338")
if whoisActually == nil {
t.Fatalf(
"expected RPL_WHOISACTUALLY (338) for "+
"oper WHOIS, got %v",
msgs,
)
}
params := getNumericParams(whoisActually)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params in 338, "+
"got %v",
params,
)
}
// First param should be the target nick.
if params[0] != "target" {
t.Fatalf(
"expected first param 'target', got %s",
params[0],
)
}
// Second param should be a non-empty IP.
if params[1] == "" {
t.Fatal("expected non-empty IP in 338 params")
}
}
func TestNonOperWhoisHidesClientInfo(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create a target user.
_ = tserver.createSession("hidden")
// Create a regular (non-oper) user.
regToken := tserver.createSession("regular")
_, lastID := tserver.pollMessages(regToken, 0)
// WHOIS the target without oper status.
tserver.sendCommand(regToken, map[string]any{
commandKey: "WHOIS",
toKey: "hidden",
})
msgs, _ := tserver.pollMessages(regToken, lastID)
// Should NOT see 338 RPL_WHOISACTUALLY.
if findNumeric(msgs, "338") {
t.Fatalf(
"non-oper should not see "+
"RPL_WHOISACTUALLY (338), got %v",
msgs,
)
}
// But should see 311 RPL_WHOISUSER (normal WHOIS).
if !findNumeric(msgs, "311") {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
}
func TestWhoisShowsOperatorStatus(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create oper user and authenticate.
operToken := tserver.createSession("iamoper")
_, lastID := tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
msgs, _ := tserver.pollMessages(operToken, lastID)
if !findNumeric(msgs, "381") {
t.Fatalf("expected 381, got %v", msgs)
}
// Another user does WHOIS on the oper.
queryToken := tserver.createSession("asker")
_, queryLastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHOIS",
toKey: "iamoper",
})
msgs, _ = tserver.pollMessages(queryToken, queryLastID)
// Should see 313 RPL_WHOISOPERATOR.
if !findNumeric(msgs, "313") {
t.Fatalf(
"expected RPL_WHOISOPERATOR (313) in "+
"WHOIS of oper, got %v",
msgs,
)
}
}
func TestOperNoOlineConfigured(t *testing.T) {
// Standard test server has no oper configured.
tserver := newTestServer(t)
token := tserver.createSession("nooline")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, "password"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Should get 491 since no o-line is configured.
if !findNumeric(msgs, "491") {
t.Fatalf(
"expected ERR_NOOPERHOST (491) when no "+
"o-line configured, got %v",
msgs,
)
}
}

View File

@@ -30,6 +30,7 @@ func (hdlr *Handlers) handleRegister(
) {
type registerRequest struct {
Nick string `json:"nick"`
Username string `json:"username,omitempty"`
Password string `json:"password"`
}
@@ -58,6 +59,20 @@ func (hdlr *Handlers) handleRegister(
return
}
username := resolveUsername(
payload.Username, payload.Nick,
)
if !validUsernameRe.MatchString(username) {
hdlr.respondError(
writer, request,
"invalid username format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
@@ -68,11 +83,27 @@ func (hdlr *Handlers) handleRegister(
return
}
hdlr.executeRegister(
writer, request,
payload.Nick, payload.Password, username,
)
}
func (hdlr *Handlers) executeRegister(
writer http.ResponseWriter,
request *http.Request,
nick, password, username string,
) {
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
payload.Nick,
payload.Password,
nick, password, username, hostname, remoteIP,
)
if err != nil {
hdlr.handleRegisterError(
@@ -85,11 +116,11 @@ func (hdlr *Handlers) handleRegister(
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.deliverMOTD(request, clientID, sessionID, nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"nick": nick,
"token": token,
}, http.StatusCreated)
}
@@ -167,11 +198,18 @@ func (hdlr *Handlers) handleLogin(
return
}
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser(
request.Context(),
payload.Nick,
payload.Password,
remoteIP, hostname,
)
if err != nil {
hdlr.respondError(