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,
|
||||
|
||||
@@ -2130,6 +2130,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")
|
||||
|
||||
@@ -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,25 @@ 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,
|
||||
) {
|
||||
hostname := resolveHostname(
|
||||
request.Context(), clientIP(request),
|
||||
)
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.RegisterUser(
|
||||
request.Context(),
|
||||
payload.Nick,
|
||||
payload.Password,
|
||||
nick, password, username, hostname,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.handleRegisterError(
|
||||
@@ -85,11 +114,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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user