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(