feat: add username/hostname support with IRC hostmask format
All checks were successful
check / check (push) Successful in 2m11s
All checks were successful
check / check (push) Successful in 2m11s
- Add username and hostname columns to sessions table (001_initial.sql) - Accept optional username field in session creation and registration endpoints; defaults to nick if not provided - Resolve hostname via reverse DNS of connecting client IP at session creation time (supports X-Forwarded-For and X-Real-IP headers) - Display real username and hostname in WHOIS (311 RPL_WHOISUSER) and WHO (352 RPL_WHOREPLY) responses instead of nick/servername - Add FormatHostmask helper for nick!user@host format - Add SessionHostInfo type and GetSessionHostInfo query - Include username/hostname in MemberInfo and ChannelMembers results - Extract validateHashcash and resolveUsername helpers to stay under funlen limits - Add comprehensive unit tests for all new DB functions, hostmask formatting, and integration tests for WHOIS/WHO responses - Update README with hostmask documentation, new API fields, and updated schema reference
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -23,6 +24,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
|
||||
@@ -39,6 +46,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,
|
||||
@@ -146,6 +202,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
|
||||
}
|
||||
|
||||
@@ -162,30 +219,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)
|
||||
@@ -200,9 +237,28 @@ 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
|
||||
}
|
||||
|
||||
hostname := resolveHostname(
|
||||
request.Context(), clientIP(request),
|
||||
)
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.CreateSession(
|
||||
request.Context(), payload.Nick,
|
||||
request.Context(),
|
||||
payload.Nick, username, hostname,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.handleCreateSessionError(
|
||||
@@ -224,6 +280,55 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
}, 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,
|
||||
@@ -2105,10 +2210,26 @@ func (hdlr *Handlers) executeWhois(
|
||||
return
|
||||
}
|
||||
|
||||
// Look up username and hostname for the target.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 311 RPL_WHOISUSER
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplWhoisUser, nick,
|
||||
[]string{queryNick, queryNick, srvName, "*"},
|
||||
[]string{queryNick, username, hostname, "*"},
|
||||
queryNick,
|
||||
)
|
||||
|
||||
@@ -2215,11 +2336,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,
|
||||
|
||||
Reference in New Issue
Block a user