feat: implement ISUPPORT and add support for a few easy features

fix: CAP ACK commands with no acknowledged capabilities causing an OOB.

Also log unsupported and unknown capabilities and features.
This commit is contained in:
2025-08-15 21:10:52 +02:00
parent 4be731c813
commit 41645fb679
4 changed files with 141 additions and 65 deletions
+4
View File
@@ -95,9 +95,13 @@ type Client struct {
// The acknowledged capabilities // The acknowledged capabilities
EnabledCapabilities Capabilities EnabledCapabilities Capabilities
// The features currently enabled for this client
EnabledFeatures Features
} }
type Capabilities map[string]string type Capabilities map[string]string
type Features map[string]string
type MsgFmtOpts struct { type MsgFmtOpts struct {
WithTimestamp bool WithTimestamp bool
+57 -54
View File
@@ -58,7 +58,7 @@ const (
NOTICE = "NOTICE" // Send notices between users and to channels. NOTICE = "NOTICE" // Send notices between users and to channels.
// Operator Messages // Operator Messages
KILL = "KILL" // CLose connection between a client and the server. IRC Operators only (do we need this ??) KILL = "KILL" // Close connection between a client and the server. IRC Operators only (do we need this ??)
// Optional Messages // Optional Messages
AWAY = "AWAY" // Indicate that the client (user) is away/afk/etc.. AWAY = "AWAY" // Indicate that the client (user) is away/afk/etc..
@@ -305,64 +305,67 @@ const (
/* --- RPL_ISUPPORT Parameters --- */ /* --- RPL_ISUPPORT Parameters --- */
var Features = map[string]bool{ var Features = map[string]bool{
"ACCEPT": true, "ACCEPT": false,
// The AWAYLEN parameter indicates the maximum length for the <reason> of an AWAY command.
// If an AWAY <reason> has more characters than this parameter, it may be silently truncated by the server before being passed on to other clients.
// Clients MAY receive an AWAY <reason> that has more characters than this parameter
"AWAYLEN": true, "AWAYLEN": true,
"BOT": true, "BOT": false,
"CALLERID": true, "CALLERID": false,
"CASEMAPPING": true, "CASEMAPPING": false,
"CHANLIMIT": true, "CHANLIMIT": false,
"CHANMODES": true, "CHANMODES": false,
"CHANNELLEN": true, "CHANNELLEN": false,
"CHANTYPES": true, // Channel Types. Default is #. Available are #& "CHANTYPES": false, // Channel Types. Default is #. Available are #&
"CHARSET": true, // Deprecated but might still be used "CHARSET": false, // Deprecated but might still be used
"CLIENTVER": true, // Deprecated but might still be used "CLIENTVER": false, // Deprecated but might still be used
"CNOTICE": true, "CNOTICE": false,
"CPRIVMSG": true, "CPRIVMSG": false,
"DEAF": true, "DEAF": false,
"ELIST": true, "ELIST": false,
"ESILENCE": true, "ESILENCE": false,
"ETRACE": true, "ETRACE": false,
"EXCEPTS": true, "EXCEPTS": false,
"EXTBAN": true, "EXTBAN": false,
"FNC": true, // Deprecated but might still be used "FNC": false, // Deprecated but might still be used
"INVEX": true, "INVEX": false,
"KEYLEN": true, "KEYLEN": false,
"KICKLEN": true, "KICKLEN": true,
"KNOCK": true, "KNOCK": false,
"LINELEN": true, // Proposed "LINELEN": false, // Proposed
"MAP": true, // Deprecated but might still be used "MAP": false, // Deprecated but might still be used
"MAXBANS": true, // Deprecated but might still be used "MAXBANS": false, // Deprecated but might still be used
"MAXCHANNELS": true, // Deprecated but might still be used "MAXCHANNELS": false, // Deprecated but might still be used
"MAXLIST": true, "MAXLIST": false,
"MAXNICKLEN": true, "MAXNICKLEN": false,
"MAXPARA": true, // Deprecated but might still be used "MAXPARA": false, // Deprecated but might still be used
"MAXTARGETS": true, "MAXTARGETS": false,
"METADATA": true, "METADATA": false,
"MODES": true, "MODES": false,
"MONITOR": true, "MONITOR": false,
"NAMESX": true, // Deprecated but might still be used "NAMESX": false, // Deprecated but might still be used
"NETWORK": true, "NETWORK": true,
"NICKLEN": true, "NICKLEN": true,
"OVERRIDE": true, "OVERRIDE": false,
"PREFIX": true, "PREFIX": false,
"SAFELIST": true, "SAFELIST": false,
"SECURELIST": true, "SECURELIST": false,
"SILENCE": true, "SILENCE": false,
"SSL": true, // Deprecated but might still be used "SSL": false, // Deprecated but might still be used
"STARTTLS": true, // Deprecated but might still be used "STARTTLS": false, // Deprecated but might still be used
"STATUSMSG": true, "STATUSMSG": false,
"STD": true, // Deprecated but might still be used "STD": false, // Deprecated but might still be used
"TARGMAX": true, "TARGMAX": false,
"TOPICLEN": true, "TOPICLEN": true,
"UHNAMES": true, // Deprecated but might still be used "UHNAMES": false, // Deprecated but might still be used
"USERIP": true, "USERIP": false,
"USERLEN": true, // Proposed "USERLEN": false, // Proposed
"VBANLIST": true, // Deprecared but might still be used "VBANLIST": false, // Deprecared but might still be used
"VLIST": true, "VLIST": false,
"WALLCHOPS": true, // Deprecated but might still be used "WALLCHOPS": false, // Deprecated but might still be used
"WALLVOICES": true, // Deprecated but might still be used "WALLVOICES": false, // Deprecated but might still be used
"WATCH": true, "WATCH": false,
"WHOX": true, "WHOX": false,
} }
var Capabilities = map[string]bool{ var Capabilities = map[string]bool{
+49 -11
View File
@@ -35,6 +35,7 @@ import (
func ReadLoop(client *irc.Client) { func ReadLoop(client *irc.Client) {
// 512 bytes as a base + 8192 additional bytes for tags // 512 bytes as a base + 8192 additional bytes for tags
// as specified here: https://modern.ircdocs.horse/#message-format
r := bufio.NewReaderSize(client.TCPConn, 8192+512) r := bufio.NewReaderSize(client.TCPConn, 8192+512)
for { for {
@@ -235,18 +236,22 @@ func handleCAP(msg irc.Message, client *irc.Client) {
value = parts[1] value = parts[1]
} }
if commands.Capabilities[key] { if capEnabled, ok := commands.Capabilities[key]; ok {
// When the final parameter approaches 510 bytes, if capEnabled {
// we send multiple REQ commands // When the final parameter approaches 510 bytes,
// (https://ircv3.net/specs/extensions/capability-negotiation.html#the-cap-req-subcommand) // we send multiple REQ commands
if length+len(capability) >= 500 { // (https://ircv3.net/specs/extensions/capability-negotiation.html#the-cap-req-subcommand)
recognizedCaps = append(recognizedCaps, capabilities) if length+len(capability) >= 500 {
capabilities = make(irc.Capabilities, 0) recognizedCaps = append(recognizedCaps, capabilities)
length = 0 capabilities = make(irc.Capabilities, 0)
} length = 0
}
capabilities[key] = value capabilities[key] = value
length += len(capability) length += len(capability)
} else {
log.Println("Unsupported capability:", capability)
}
} else { } else {
log.Println("Unknown capability:", capability) log.Println("Unknown capability:", capability)
} }
@@ -266,6 +271,9 @@ func handleCAP(msg irc.Message, client *irc.Client) {
} }
client.SendCommand(commands.CAP, "END") client.SendCommand(commands.CAP, "END")
case "ACK": case "ACK":
if msg.Parameters[2] == "" {
return
}
caps := strings.Split(msg.Parameters[2], " ") caps := strings.Split(msg.Parameters[2], " ")
for _, capability := range caps { for _, capability := range caps {
if capability[0] == '-' { if capability[0] == '-' {
@@ -342,6 +350,34 @@ func handleMYINFO(msg irc.Message, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, info, msgOpts) client.Channels[0].AppendMsg(msg.Timestamp, info, msgOpts)
} }
func handleISUPPORT(msg irc.Message, client *irc.Client) {
msgOpts := irc.MsgFmtOpts{
WithTimestamp: true,
AsServerMsg: true,
}
for _, token := range msg.Parameters[1 : len(msg.Parameters)-1] {
splits := strings.Split(token, "=")
key := splits[0]
value := ""
if len(splits) > 1 {
value = splits[1]
}
if supported, known := commands.Features[key]; known {
if supported {
client.EnabledFeatures[key] = value
} else {
log.Printf("Unsupported feature: \"%v\" with value: \"%v\"\n", key, value)
}
} else {
log.Printf("Unknown feature: \"%v\" with value: \"%v\"\n", key, value)
}
client.Channels[0].AppendMsg(msg.Timestamp, token, msgOpts)
}
}
func handleLUSERCLIENT(msg irc.Message, client *irc.Client) { func handleLUSERCLIENT(msg irc.Message, client *irc.Client) {
message := msg.Parameters[1] message := msg.Parameters[1]
@@ -695,6 +731,8 @@ func HandleCommand(msg irc.Message, client *irc.Client) {
handleCREATED(msg, client) handleCREATED(msg, client)
case commands.RPL_MYINFO: case commands.RPL_MYINFO:
handleMYINFO(msg, client) handleMYINFO(msg, client)
case commands.RPL_ISUPPORT:
handleISUPPORT(msg, client)
case commands.RPL_LUSERCLIENT: case commands.RPL_LUSERCLIENT:
handleLUSERCLIENT(msg, client) handleLUSERCLIENT(msg, client)
case commands.RPL_LUSEROP: case commands.RPL_LUSEROP:
+31
View File
@@ -18,6 +18,7 @@ package mainscreen
import ( import (
"math" "math"
"strconv"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
@@ -194,6 +195,36 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) {
cmdsToProcess = append(cmdsToProcess, cmd) cmdsToProcess = append(cmdsToProcess, cmd)
case InputBox: case InputBox:
s.InputBox, cmd = s.InputBox.Update(msg) s.InputBox, cmd = s.InputBox.Update(msg)
width := 0
command := strings.Split(s.InputBox.Input.Value(), " ")[0]
if len(command) > 0 && command[0] == '/' {
switch strings.ToUpper(command)[1:] {
case commands.AWAY:
w, err := strconv.ParseInt(s.Client.EnabledFeatures["AWAYLEN"], 10, 32)
if err == nil {
width = int(w)
}
case commands.TOPIC:
w, err := strconv.ParseInt(s.Client.EnabledFeatures["TOPICLEN"], 10, 32)
if err == nil {
width = int(w)
}
case commands.NICK:
w, err := strconv.ParseInt(s.Client.EnabledFeatures["NICKLEN"], 10, 32)
if err == nil {
width = int(w)
}
case commands.KICK:
w, err := strconv.ParseInt(s.Client.EnabledFeatures["KICKLEN"], 10, 32)
if err == nil {
width = int(w)
}
}
}
s.InputBox.Input.CharLimit = width
cmdsToProcess = append(cmdsToProcess, cmd) cmdsToProcess = append(cmdsToProcess, cmd)
} }