- 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
trunk
mio 2022-03-19 17:33:07 +00:00
parent 9005c0fba5
commit 7963d7221c
2 changed files with 138 additions and 70 deletions

View File

@ -2,6 +2,16 @@
-- Server configuration -- 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` -- `itte_servers`
-- Below is a sample server configuration. -- Below is a sample server configuration.
-- If the server does not support CAP negotiation (used for SASL -- If the server does not support CAP negotiation (used for SASL
@ -19,6 +29,6 @@ itte_servers = {
auth_user = "botuser", auth_user = "botuser",
auth_pass = "password", auth_pass = "password",
code_prefix = "!", code_prefix = "!",
admins = { adminuser = "password" }, admins = { adminuser = "password", },
}, },
} }

192
itte.lua
View File

@ -47,6 +47,9 @@ itte.servers = {}
-- Store custom handlers -- Store custom handlers
itte.handlers = {} itte.handlers = {}
-- Store global admins
itte.admins = {}
-- Internal only: store core handlers -- Internal only: store core handlers
itte._h = {} itte._h = {}
@ -57,8 +60,8 @@ itte.docs._h = {}
-- Internal only: track application state -- Internal only: track application state
itte.state = { itte.state = {
connected = false, connected = false,
connections = {},
} }
itte.contexts = {}
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -79,7 +82,7 @@ end
itte.docs.get_config = [[ () itte.docs.get_config = [[ ()
Load the config file. 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 prefix is not set, try custom file names or defaults
if itte.confs.prefix == nil then if itte.confs.prefix == nil then
util.source_file(itte.confs.config) util.source_file(itte.confs.config)
@ -103,6 +106,20 @@ function itte.get_config()
end end
if itte_config ~= nil then itte.config = itte_config end if itte_config ~= nil then itte.config = itte_config end
if itte_handlers ~= nil then itte.handlers = itte_handlers 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 end
@ -153,9 +170,9 @@ function itte.get_commands(svr)
}, },
-- Main commands -- Main commands
join = { join = {
-- 001 is a welcome message
-- Cannot join earlier on some servers if not registered -- 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 ", resp = "JOIN ",
}, },
nick = { nick = {
@ -177,7 +194,7 @@ function itte.get_commands(svr)
resp = "PONG ", resp = "PONG ",
}, },
privmsg = { privmsg = {
check = "PRIVMSG(.*):!", check = "PRIVMSG(.*):" .. svr.code_prefix,
resp = "PRIVMSG ", resp = "PRIVMSG ",
}, },
quit = { quit = {
@ -214,14 +231,16 @@ function itte.message(cxt, users, str)
end 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. 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 -- Load server context
local context = svr local context = svr
context.name = name
context.cmds = itte.get_commands(svr) context.cmds = itte.get_commands(svr)
context.state = { context.state = {
connected = false,
cap_greeted = false, cap_greeted = false,
cap_ls = false, cap_ls = false,
cap_checked = false, cap_checked = false,
@ -239,16 +258,43 @@ function itte.connect_server(svr)
verify = "none", verify = "none",
options = "all", options = "all",
} }
util.debug("conn", "Connecting to " .. svr.host .. "/" .. svr.port .. util.debug("conn", "Connecting to " .. svr.host .. "/" .. svr.port ..
" ...", itte.config.debug) " ...", itte.config.debug)
context.con:connect(svr.host, svr.port) context.con:connect(svr.host, svr.port)
context.con = ssl.wrap(context.con, con_params) context.con = ssl.wrap(context.con, con_params)
context.con:dohandshake() 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 context.state.connected = true
itte.state.connected = true
return context return context
end 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) itte.docs.negotiate_cap = [[ (context_table, data_str)
Negotiate client capabilities and authenticate with SASL. Negotiate client capabilities and authenticate with SASL.
]] ]]
@ -291,7 +337,7 @@ function itte.negotiate_cap(cxt, str)
cxt.state.authed = true cxt.state.authed = true
cxt.state.cap_checked = true cxt.state.cap_checked = true
if (cxt.state.cap_checked) and (not cxt.state.authed) and 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.send_command(cxt.con, cxt.cmds.cap_end.resp)
itte.message(cxt, util.table_keys(cxt.admins), itte.message(cxt, util.table_keys(cxt.admins),
itte.config.errors.sasl_auth_failed) 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 elseif util.is_substr(str, cxt.cmds.ns_identify_pass.check) then
cxt.state.authed = true cxt.state.authed = true
if (cxt.state.ns_checked) and (not cxt.state.authed) and 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.message(cxt, util.table_keys(cxt.admins).
itte.config.errors.ns_auth_failed) itte.config.errors.ns_auth_failed)
end end
@ -324,18 +370,29 @@ function itte.auth_nickserv(cxt, str)
end end
itte.docs.is_admin = [[ (context_table, message_table) itte.docs.is_admin = [[ (admins_table, message_table, mode_str)
Check whether a user is an admin. Return true if the user and password are Check whether a user is an admin. Modes: global, instance. If `mode` is
in the admin table, or false otherwise. 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) function itte.is_admin(admins, msg, mode)
-- No password provided local in_admins = false
if table.concat(msg.code_params) == "" then -- If no admin users set or no password provided
if (admins == nil) or (table.concat(msg.code_params) == "") then
do return false end do return false end
-- Incorrect password provided -- Incorrect password provided
elseif msg.code_params ~= nil then elseif msg.code_params ~= nil then
local in_admins = util.is_entry(cxt.admins, msg.sender, 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]) 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 if not in_admins then
do return false end do return false end
else else
@ -352,7 +409,7 @@ itte.docs.notify_no_perms = [[ (context_table, message_table)
function itte.notify_no_perms(cxt, msg) function itte.notify_no_perms(cxt, msg)
itte.message(cxt, { msg.reply_to }, string.gsub(itte.config.errors.no_perm, itte.message(cxt, { msg.reply_to }, string.gsub(itte.config.errors.no_perm,
"{{user}}", msg.sender)) "{{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, local notify_msg = string.gsub(itte.config.errors.notify_no_perm,
"{{user}}", msg.sender) "{{user}}", msg.sender)
notify_msg = string.gsub(notify_msg, "{{code}}", msg.code) 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. return an associative table of values.
]] ]]
function itte.parse_privmsg(cxt, str) function itte.parse_privmsg(cxt, str)
local code_full = string.sub(str, string.find(str, ":" .. cxt.code_prefix) + local code_full = ""
1 + string.len(cxt.code_prefix)) -- 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 = { local msg = {
sender = string.sub(str, string.find(str, "!") + 2, sender = string.sub(str, string.find(str, "!") + 2,
string.find(str, "@") - 1), string.find(str, "@") - 1),
recipient = string.sub(str, string.find(str, "PRIVMSG") + 8, 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, reply_to = string.sub(str, string.find(str, "!") + 2,
string.find(str, "@") - 1), string.find(str, "@") - 1),
body = string.sub(str, body_sep + 1),
} }
if util.is_substr(code_full, " ") then if util.is_substr(code_full, " ") then
msg.code = string.sub(code_full, 0, string.find(code_full, " ") - 1) 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 .. ", recipient: " .. msg.recipient ..
", reply_to: " .. msg.reply_to .. ", reply_to: " .. msg.reply_to ..
", code: " .. msg.code .. ", code: " .. msg.code ..
", code_params: " .. table.concat(msg.code_params) .. ", code_params: " .. table.concat(msg.code_params, " ") ..
", body: " .. msg.body ..
" }", itte.config.debug) " }", itte.config.debug)
return msg return msg
end end
@ -435,7 +500,7 @@ function itte._h.help(cxt, msg)
local codes = cxt.code_prefix .. table.concat(custom_h, ", " .. local codes = cxt.code_prefix .. table.concat(custom_h, ", " ..
cxt.code_prefix) cxt.code_prefix)
-- Core service codes are shown only to admins -- 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) local core_h = util.table_keys(itte._h)
codes = cxt.code_prefix .. table.concat(core_h, ", " .. cxt.code_prefix) .. codes = cxt.code_prefix .. table.concat(core_h, ", " .. cxt.code_prefix) ..
", " .. cxt.code_prefix .. table.concat(custom_h, ", " .. ", " .. cxt.code_prefix .. table.concat(custom_h, ", " ..
@ -450,7 +515,7 @@ itte.docs._h.join = [[ (context_table, message_table)
Join specified channels. Join specified channels.
]] ]]
function itte._h.join(cxt, msg) 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) itte.notify_no_perms(cxt, msg)
do return end do return end
end end
@ -465,7 +530,7 @@ itte.docs._h.part = [[ (context_table, message_table)
Leave specified channels. Leave specified channels.
]] ]]
function itte._h.part(cxt, msg) 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) itte.notify_no_perms(cxt, msg)
do return end do return end
end end
@ -488,7 +553,7 @@ itte.docs._h.quit = [[ (context_table, message_table)
Disconnect from the server. Disconnect from the server.
]] ]]
function itte._h.quit(cxt, msg) 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) itte.notify_no_perms(cxt, msg)
do return end do return end
end end
@ -500,11 +565,11 @@ itte.docs._h.reload = [[ (context_table, message_table)
Reload the server config. Reload the server config.
]] ]]
function itte._h.reload(cxt, msg) 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) itte.notify_no_perms(cxt, msg)
do return end do return end
end end
itte.get_config() itte.get_config(true)
itte.message(cxt, { msg.reply_to }, itte.config.messages.reload) itte.message(cxt, { msg.reply_to }, itte.config.messages.reload)
end end
@ -513,13 +578,15 @@ end
-- Service code mapping and runtime -- 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 Parse the socket data string and trigger a response to the server if the
string matches preset patterns. string matches preset patterns.
]] ]]
function itte.listen(cxt, str) function itte.listen(name, str)
util.debug("listen", str, itte.config.debug) util.debug("listen", str, itte.config.debug)
local cxt = itte.contexts[name]
-- Respond to server ping -- Respond to server ping
if util.is_substr(str, cxt.cmds.ping.check) then if util.is_substr(str, cxt.cmds.ping.check) then
itte.send_command(cxt.con, string.gsub(str, cxt.cmds.ping.check, itte.send_command(cxt.con, string.gsub(str, cxt.cmds.ping.check,
@ -536,9 +603,13 @@ function itte.listen(cxt, str)
elseif util.has_key(itte.handlers, msg.code) then elseif util.has_key(itte.handlers, msg.code) then
itte.handlers[msg.code](cxt, msg) itte.handlers[msg.code](cxt, msg)
else else
-- 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) itte.message(cxt, { msg.reply_to }, itte.config.errors.unknown_code)
end end
end end
end
end end
@ -548,22 +619,12 @@ itte.docs.add_listener = [[ (context_table)
set `state.connected` to false to activate a client exit. set `state.connected` to false to activate a client exit.
]] ]]
function itte.add_listener(cxt) function itte.add_listener(cxt)
-- Set a short timeout to be non-blocking
cxt.con:settimeout(0.5)
local data, status = cxt.con:receive() local data, status = cxt.con:receive()
if data ~= nil then if data ~= nil then
itte.listen(cxt, data) itte.listen(cxt.name, data)
elseif status == "closed" then elseif status == "closed" then
util.debug("conn", "Connection " .. status, itte.config.debug) itte.disconnect_server(cxt.name)
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
end end
end end
@ -575,50 +636,47 @@ function itte.run()
itte.get_config() itte.get_config()
-- Connect to servers and get context with socket ref for each server -- Connect to servers and get context with socket ref for each server
local contexts = {} itte.contexts = {}
for instance, prop in pairs(itte.servers) do for instance, prop in pairs(itte.servers) do
contexts[instance] = itte.connect_server(prop) itte.contexts[instance] = itte.connect_server(instance, prop)
contexts[instance].name = instance
itte.state.connected = true
itte.state.connections[instance] = true
contexts[instance].con:settimeout(1)
-- For PASS-based authentication, send PASS before greeting the server -- For PASS-based authentication, send PASS before greeting the server
if contexts[instance].auth_type == "pass" then if itte.contexts[instance].auth_type == "pass" then
itte.send_command(contexts[instance].con, itte.send_command(itte.contexts[instance].con,
contexts[instance].cmds.pass.resp) itte.contexts[instance].cmds.pass.resp)
contexts[instance].state.authed = true itte.contexts[instance].state.authed = true
end end
-- Greet the server in all auth scenarios except SASL, which greets the -- Greet the server in all auth scenarios except SASL, which greets the
-- server during CAP negotiation. -- server during CAP negotiation.
if (contexts[instance].auth_type ~= "sasl") then if (itte.contexts[instance].auth_type ~= "sasl") then
itte.send_command(contexts[instance].con, itte.send_command(itte.contexts[instance].con,
contexts[instance].cmds.user.resp) itte.contexts[instance].cmds.user.resp)
itte.send_command(contexts[instance].con, itte.send_command(itte.contexts[instance].con,
contexts[instance].cmds.nick.resp) itte.contexts[instance].cmds.nick.resp)
end end
local joined_chans = false local joined_chans = false
while (itte.state.connections[instance]) and (not joined_chans) do while (itte.contexts[instance].state.connected) and (not joined_chans) do
local data, status = contexts[instance].con:receive() local data, status = itte.contexts[instance].con:receive()
if contexts[instance].auth_type == "sasl" then if itte.contexts[instance].auth_type == "sasl" then
itte.negotiate_cap(contexts[instance], data) itte.negotiate_cap(itte.contexts[instance], data)
elseif contexts[instance].auth_type == "nickserv" then elseif itte.contexts[instance].auth_type == "nickserv" then
itte.auth_nickserv(contexts[instance], data) itte.auth_nickserv(itte.contexts[instance], data)
end end
-- Minimum requirements for joining channels: client has authenticated if -- Minimum requirements for joining channels: client has authenticated if
-- an auth type is set, or has received a NOTICE [nick] message during an -- an auth type is set, or has received a NOTICE [nick] message during an
-- ident lookup. -- ident lookup.
if contexts[instance].state.authed then if itte.contexts[instance].state.authed then
itte.traverse_channels(contexts[instance], "join") itte.traverse_channels(itte.contexts[instance], "join")
joined_chans = true joined_chans = true
elseif (data ~= nil) then elseif (data ~= nil) then
if util.is_substr(data, contexts[instance].cmds.join.check) then if util.is_substr(data, itte.contexts[instance].cmds.join.check)
itte.traverse_channels(contexts[instance], "join") then
itte.traverse_channels(itte.contexts[instance], "join")
joined_chans = true joined_chans = true
end end
end end
@ -627,7 +685,7 @@ function itte.run()
-- Add listeners -- Add listeners
while itte.state.connected do while itte.state.connected do
for instance, cxt in pairs(contexts) do for instance, cxt in pairs(itte.contexts) do
itte.add_listener(cxt) itte.add_listener(cxt)
end end
end end