- 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
-- ---------------------------------------------------------------------------
-- `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", },
},
}

192
itte.lua
View File

@ -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,
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,10 +603,14 @@ function itte.listen(cxt, str)
elseif util.has_key(itte.handlers, msg.code) then
itte.handlers[msg.code](cxt, msg)
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)
end
end
end
end
itte.docs.add_listener = [[ (context_table)
@ -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