From 7963d7221c7c62bad062362e416d7b83debde342 Mon Sep 17 00:00:00 2001 From: mio Date: Sat, 19 Mar 2022 17:33:07 +0000 Subject: [PATCH] Bugfixes - Add global admins table, set disconnect and reload built-in handlers to be accessible by global admins only - Only send an unknown code error in private messages to avoid responding to other bots' codes when there is a prefix collision - Fix server contexts not being updated after a config reload - Fix service codes being improperly parsed in privmsgs when different code prefixes are used - Fix joining channels on some networks when connecting without authentication - Fix disconnecting from one server also causing disconnection from other servers --- examples/sample.servers.lua | 12 ++- itte.lua | 196 +++++++++++++++++++++++------------- 2 files changed, 138 insertions(+), 70 deletions(-) diff --git a/examples/sample.servers.lua b/examples/sample.servers.lua index 6c2da99..d06962b 100644 --- a/examples/sample.servers.lua +++ b/examples/sample.servers.lua @@ -2,6 +2,16 @@ -- Server configuration -- --------------------------------------------------------------------------- +-- `itte_admins` +-- Users who can access administrative functions in the client, not limited to +-- the server instance. The username is for the client only and independent of +-- IRC network usernames. +-- +-- This variable is optional, but certain handlers like server disconnection +-- will not work if it is unset. +itte_admins = { globaladmin = "password", } + + -- `itte_servers` -- Below is a sample server configuration. -- If the server does not support CAP negotiation (used for SASL @@ -19,6 +29,6 @@ itte_servers = { auth_user = "botuser", auth_pass = "password", code_prefix = "!", - admins = { adminuser = "password" }, + admins = { adminuser = "password", }, }, } diff --git a/itte.lua b/itte.lua index a2f4e7b..c5223cb 100644 --- a/itte.lua +++ b/itte.lua @@ -47,6 +47,9 @@ itte.servers = {} -- Store custom handlers itte.handlers = {} +-- Store global admins +itte.admins = {} + -- Internal only: store core handlers itte._h = {} @@ -57,8 +60,8 @@ itte.docs._h = {} -- Internal only: track application state itte.state = { connected = false, - connections = {}, } +itte.contexts = {} -- --------------------------------------------------------------------------- @@ -79,7 +82,7 @@ end itte.docs.get_config = [[ () Load the config file. ]] -function itte.get_config() +function itte.get_config(reload) -- If prefix is not set, try custom file names or defaults if itte.confs.prefix == nil then util.source_file(itte.confs.config) @@ -103,6 +106,20 @@ function itte.get_config() end if itte_config ~= nil then itte.config = itte_config end if itte_handlers ~= nil then itte.handlers = itte_handlers end + if itte_admins ~= nil then itte.admins = itte_admins end + + -- If reloading, reconstruct the context tables. + -- This only works if the instance name has not changed between reloads. + if reload then + for instance, prop in pairs(itte.servers) do + cxt_old = itte.contexts[instance] + itte.contexts[instance] = prop + itte.contexts[instance].name = instance + itte.contexts[instance].cmds = itte.get_commands(prop) + itte.contexts[instance].con = cxt_old.con + itte.contexts[instance].con:settimeout(0.5) + end + end end @@ -153,9 +170,9 @@ function itte.get_commands(svr) }, -- Main commands join = { - -- 001 is a welcome message -- Cannot join earlier on some servers if not registered - check = "001 " .. svr.nick, + -- "MODE" seems to be a more reliable check + check = "MODE", resp = "JOIN ", }, nick = { @@ -177,7 +194,7 @@ function itte.get_commands(svr) resp = "PONG ", }, privmsg = { - check = "PRIVMSG(.*):!", + check = "PRIVMSG(.*):" .. svr.code_prefix, resp = "PRIVMSG ", }, quit = { @@ -214,14 +231,16 @@ function itte.message(cxt, users, str) end -itte.docs.connect_server = [[ (server_table) +itte.docs.connect_server = [[ (name_str, server_table) Initialise a socket connection to a server and return a context table. ]] -function itte.connect_server(svr) +function itte.connect_server(name, svr) -- Load server context local context = svr + context.name = name context.cmds = itte.get_commands(svr) context.state = { + connected = false, cap_greeted = false, cap_ls = false, cap_checked = false, @@ -239,16 +258,43 @@ function itte.connect_server(svr) verify = "none", options = "all", } + util.debug("conn", "Connecting to " .. svr.host .. "/" .. svr.port .. " ...", itte.config.debug) context.con:connect(svr.host, svr.port) context.con = ssl.wrap(context.con, con_params) context.con:dohandshake() + -- Set a short timeout to be non-blocking + context.con:settimeout(0.1) + + -- Set context and global states context.state.connected = true + itte.state.connected = true + return context end +itte.docs.disconnect_server = [[ (name_str) + Close a socket connection to a server. + ]] +function itte.disconnect_server(name) + if itte.contexts[name] ~= nil then + util.debug("conn", "Connection to " .. name .. " closed.", + itte.config.debug) + end + itte.contexts[name].con:close() + itte.contexts[name] = nil + + -- Check if it is the last connection and trigger client exit + -- if no connections remain + if #util.table_keys(itte.contexts) == 0 then + util.debug("conn", "Exiting ...", itte.config.debug) + itte.state.connected = false + end +end + + itte.docs.negotiate_cap = [[ (context_table, data_str) Negotiate client capabilities and authenticate with SASL. ]] @@ -291,7 +337,7 @@ function itte.negotiate_cap(cxt, str) cxt.state.authed = true cxt.state.cap_checked = true if (cxt.state.cap_checked) and (not cxt.state.authed) and - (itte.config.notify_errors) then + (itte.config.notify_errors) and (cxt.admins ~= nil) then itte.send_command(cxt.con, cxt.cmds.cap_end.resp) itte.message(cxt, util.table_keys(cxt.admins), itte.config.errors.sasl_auth_failed) @@ -315,7 +361,7 @@ function itte.auth_nickserv(cxt, str) elseif util.is_substr(str, cxt.cmds.ns_identify_pass.check) then cxt.state.authed = true if (cxt.state.ns_checked) and (not cxt.state.authed) and - (itte.config.notify_errors) then + (itte.config.notify_errors) and (cxt.admins ~= nil) then itte.message(cxt, util.table_keys(cxt.admins). itte.config.errors.ns_auth_failed) end @@ -324,18 +370,29 @@ function itte.auth_nickserv(cxt, str) end -itte.docs.is_admin = [[ (context_table, message_table) - Check whether a user is an admin. Return true if the user and password are - in the admin table, or false otherwise. +itte.docs.is_admin = [[ (admins_table, message_table, mode_str) + Check whether a user is an admin. Modes: global, instance. If `mode` is + unspecified, check for the user in the instance's admins table. Return true + if the user and password are in the given admin table, or false otherwise. ]] -function itte.is_admin(cxt, msg) - -- No password provided - if table.concat(msg.code_params) == "" then +function itte.is_admin(admins, msg, mode) + local in_admins = false + -- If no admin users set or no password provided + if (admins == nil) or (table.concat(msg.code_params) == "") then do return false end -- Incorrect password provided elseif msg.code_params ~= nil then - local in_admins = util.is_entry(cxt.admins, msg.sender, - msg.code_params[#msg.code_params]) + if mode == "global" then + -- For global admins, both user and password are supplied in the message + -- to keep the auth network-independent + in_admins = util.is_entry(admins, msg.code_params[1], + msg.code_params[#msg.code_params]) + else + -- User is the IRC user, password is suppled in the message + in_admins = util.is_entry(admins, msg.sender, + msg.code_params[#msg.code_params]) + end + if not in_admins then do return false end else @@ -352,7 +409,7 @@ itte.docs.notify_no_perms = [[ (context_table, message_table) function itte.notify_no_perms(cxt, msg) itte.message(cxt, { msg.reply_to }, string.gsub(itte.config.errors.no_perm, "{{user}}", msg.sender)) - if itte.config.notify_errors then + if (itte.config.notify_errors) and (cxt.admins ~= nil) then local notify_msg = string.gsub(itte.config.errors.notify_no_perm, "{{user}}", msg.sender) notify_msg = string.gsub(notify_msg, "{{code}}", msg.code) @@ -386,15 +443,22 @@ itte.docs.parse_privmsg = [[ (context_table, data_str) return an associative table of values. ]] function itte.parse_privmsg(cxt, str) - local code_full = string.sub(str, string.find(str, ":" .. cxt.code_prefix) + - 1 + string.len(cxt.code_prefix)) + local code_full = "" + -- Separator marks the start of the message body + body_sep, _ = string.find(str, ":", 2) + -- Service code found with specified prefix + if util.is_substr(str, ":" .. cxt.code_prefix) then + code_full = string.sub(str, string.find(str, ":" .. + cxt.code_prefix) + 1 + string.len(cxt.code_prefix)) + end local msg = { sender = string.sub(str, string.find(str, "!") + 2, string.find(str, "@") - 1), recipient = string.sub(str, string.find(str, "PRIVMSG") + 8, - string.find(str, ":!") - 2), + string.find(str, ":" .. cxt.code_prefix) - 2), reply_to = string.sub(str, string.find(str, "!") + 2, string.find(str, "@") - 1), + body = string.sub(str, body_sep + 1), } if util.is_substr(code_full, " ") then msg.code = string.sub(code_full, 0, string.find(code_full, " ") - 1) @@ -416,7 +480,8 @@ function itte.parse_privmsg(cxt, str) ", recipient: " .. msg.recipient .. ", reply_to: " .. msg.reply_to .. ", code: " .. msg.code .. - ", code_params: " .. table.concat(msg.code_params) .. + ", code_params: " .. table.concat(msg.code_params, " ") .. + ", body: " .. msg.body .. " }", itte.config.debug) return msg end @@ -435,7 +500,7 @@ function itte._h.help(cxt, msg) local codes = cxt.code_prefix .. table.concat(custom_h, ", " .. cxt.code_prefix) -- Core service codes are shown only to admins - if itte.is_admin(cxt, msg) then + if itte.is_admin(cxt.admins, msg) then local core_h = util.table_keys(itte._h) codes = cxt.code_prefix .. table.concat(core_h, ", " .. cxt.code_prefix) .. ", " .. cxt.code_prefix .. table.concat(custom_h, ", " .. @@ -450,7 +515,7 @@ itte.docs._h.join = [[ (context_table, message_table) Join specified channels. ]] function itte._h.join(cxt, msg) - if not itte.is_admin(cxt, msg) then + if not itte.is_admin(cxt.admins, msg) then itte.notify_no_perms(cxt, msg) do return end end @@ -465,7 +530,7 @@ itte.docs._h.part = [[ (context_table, message_table) Leave specified channels. ]] function itte._h.part(cxt, msg) - if not itte.is_admin(cxt, msg) then + if not itte.is_admin(cxt.admins, msg) then itte.notify_no_perms(cxt, msg) do return end end @@ -488,7 +553,7 @@ itte.docs._h.quit = [[ (context_table, message_table) Disconnect from the server. ]] function itte._h.quit(cxt, msg) - if not itte.is_admin(cxt, msg) then + if not itte.is_admin(itte.admins, msg, "global") then itte.notify_no_perms(cxt, msg) do return end end @@ -500,11 +565,11 @@ itte.docs._h.reload = [[ (context_table, message_table) Reload the server config. ]] function itte._h.reload(cxt, msg) - if not itte.is_admin(cxt, msg) then + if not itte.is_admin(itte.admins, msg, "global") then itte.notify_no_perms(cxt, msg) do return end end - itte.get_config() + itte.get_config(true) itte.message(cxt, { msg.reply_to }, itte.config.messages.reload) end @@ -513,13 +578,15 @@ end -- Service code mapping and runtime -- --------------------------------------------------------------------------- -itte.docs.listen = [[ (context_table, data_str) +itte.docs.listen = [[ (name_str, data_str) Parse the socket data string and trigger a response to the server if the string matches preset patterns. ]] -function itte.listen(cxt, str) +function itte.listen(name, str) util.debug("listen", str, itte.config.debug) + local cxt = itte.contexts[name] + -- Respond to server ping if util.is_substr(str, cxt.cmds.ping.check) then itte.send_command(cxt.con, string.gsub(str, cxt.cmds.ping.check, @@ -536,7 +603,11 @@ function itte.listen(cxt, str) elseif util.has_key(itte.handlers, msg.code) then itte.handlers[msg.code](cxt, msg) else - itte.message(cxt, { msg.reply_to }, itte.config.errors.unknown_code) + -- Only hint with unknown code error in private messages + -- as there may be prefix collision in channels. + if not util.is_substr(msg.reply_to, "#") then + itte.message(cxt, { msg.reply_to }, itte.config.errors.unknown_code) + end end end end @@ -548,22 +619,12 @@ itte.docs.add_listener = [[ (context_table) set `state.connected` to false to activate a client exit. ]] function itte.add_listener(cxt) - -- Set a short timeout to be non-blocking - cxt.con:settimeout(0.5) local data, status = cxt.con:receive() if data ~= nil then - itte.listen(cxt, data) + itte.listen(cxt.name, data) elseif status == "closed" then - util.debug("conn", "Connection " .. status, itte.config.debug) - cxt.con:close() - itte.state.connections[cxt.name] = nil - -- Check if it is the last connection and trigger client exit - -- if no connections remain. - if #itte.state.connections == 0 then - util.debug("conn", "Exiting ...", itte.config.debug) - itte.state.connected = false - end + itte.disconnect_server(cxt.name) end end @@ -575,50 +636,47 @@ function itte.run() itte.get_config() -- Connect to servers and get context with socket ref for each server - local contexts = {} + itte.contexts = {} for instance, prop in pairs(itte.servers) do - contexts[instance] = itte.connect_server(prop) - contexts[instance].name = instance - itte.state.connected = true - itte.state.connections[instance] = true - contexts[instance].con:settimeout(1) + itte.contexts[instance] = itte.connect_server(instance, prop) -- For PASS-based authentication, send PASS before greeting the server - if contexts[instance].auth_type == "pass" then - itte.send_command(contexts[instance].con, - contexts[instance].cmds.pass.resp) - contexts[instance].state.authed = true + if itte.contexts[instance].auth_type == "pass" then + itte.send_command(itte.contexts[instance].con, + itte.contexts[instance].cmds.pass.resp) + itte.contexts[instance].state.authed = true end -- Greet the server in all auth scenarios except SASL, which greets the -- server during CAP negotiation. - if (contexts[instance].auth_type ~= "sasl") then - itte.send_command(contexts[instance].con, - contexts[instance].cmds.user.resp) - itte.send_command(contexts[instance].con, - contexts[instance].cmds.nick.resp) + if (itte.contexts[instance].auth_type ~= "sasl") then + itte.send_command(itte.contexts[instance].con, + itte.contexts[instance].cmds.user.resp) + itte.send_command(itte.contexts[instance].con, + itte.contexts[instance].cmds.nick.resp) end local joined_chans = false - while (itte.state.connections[instance]) and (not joined_chans) do - local data, status = contexts[instance].con:receive() + while (itte.contexts[instance].state.connected) and (not joined_chans) do + local data, status = itte.contexts[instance].con:receive() - if contexts[instance].auth_type == "sasl" then - itte.negotiate_cap(contexts[instance], data) + if itte.contexts[instance].auth_type == "sasl" then + itte.negotiate_cap(itte.contexts[instance], data) - elseif contexts[instance].auth_type == "nickserv" then - itte.auth_nickserv(contexts[instance], data) + elseif itte.contexts[instance].auth_type == "nickserv" then + itte.auth_nickserv(itte.contexts[instance], data) end -- Minimum requirements for joining channels: client has authenticated if -- an auth type is set, or has received a NOTICE [nick] message during an -- ident lookup. - if contexts[instance].state.authed then - itte.traverse_channels(contexts[instance], "join") + if itte.contexts[instance].state.authed then + itte.traverse_channels(itte.contexts[instance], "join") joined_chans = true elseif (data ~= nil) then - if util.is_substr(data, contexts[instance].cmds.join.check) then - itte.traverse_channels(contexts[instance], "join") + if util.is_substr(data, itte.contexts[instance].cmds.join.check) + then + itte.traverse_channels(itte.contexts[instance], "join") joined_chans = true end end @@ -627,7 +685,7 @@ function itte.run() -- Add listeners while itte.state.connected do - for instance, cxt in pairs(contexts) do + for instance, cxt in pairs(itte.contexts) do itte.add_listener(cxt) end end