Simple IRC Bot statusing icinga2 via its API.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

365 lines
12 KiB

  1. #! /usr/bin/env python3
  2. #
  3. # Simple IRC Bot statusing icinga2 via its API.
  4. #
  5. # François Poulain <fpoulain@metrodore.fr>
  6. #
  7. #
  8. # Based on example program using irc.bot.
  9. # from
  10. # Joel Rosdahl <joel@rosdahl.net>
  11. # https://github.com/jaraco/irc/raw/master/scripts/testbot.py
  12. import configparser
  13. import json
  14. import os.path
  15. import re
  16. import sys
  17. from itertools import groupby
  18. import requests
  19. import irc.bot
  20. import irc.strings
  21. """A simple IRC Bot statusing icinga2.
  22. It is based on TestBot example bot that uses the SingleServerIRCBot class from
  23. irc.bot. The bot enters a channel and listens for commands in private messages
  24. and channel traffic. Commands in channel messages are given by prefixing the
  25. text by the bot name followed by a colon. Periodically, the bot read Icinga2
  26. status and report all new KO services.
  27. Requirements: python3-irc python3-requests
  28. The known commands are:
  29. """
  30. commands = {
  31. "ack": {"help": "Acknowledge all services matching the query."},
  32. "recheck": {
  33. "help": "Recheck all services matching the query.",
  34. "synonyms": [r"refresh"],
  35. },
  36. "list": {"help": "List all KO services.", "synonyms": [r"lsit", r"lits"]},
  37. "leave": {
  38. "help": "Disconnect the bot."
  39. " The bot will try to reconnect after 300 seconds.",
  40. },
  41. "mute": {
  42. "help": "Mute the bot (no more status report)."
  43. " The bot will unmute after 1 hour or after receiving any command.",
  44. "synonyms": [
  45. r"fuck",
  46. r"chut",
  47. r"couché",
  48. r"sieste",
  49. r"t(a|o)\s*g(ueu|o)le",
  50. ],
  51. },
  52. "die": {"help": "Let the bot cease to exist."},
  53. "help": {"help": "Print help."},
  54. }
  55. # Load configuration.
  56. configurationFilename = "/etc/icingabot/icingabot.conf"
  57. if os.path.isfile(configurationFilename):
  58. config = configparser.RawConfigParser()
  59. config.read(configurationFilename)
  60. settings = {
  61. "ircsrv": config.get("irc", "irc.server"),
  62. "ircchan": config.get("irc", "irc.chan"),
  63. "ircport": config.getint("irc", "irc.port"),
  64. "ircnick": config.get("irc", "irc.nick"),
  65. "irccmd": config.get("irc", "irc.cmd_prefix"),
  66. # see /etc/icinga2/conf.d/api-users.conf
  67. "icinga2user": config.get("icinga2", "icinga2.user"),
  68. "icinga2pass": config.get("icinga2", "icinga2.password"),
  69. "icinga2ca": config.get("icinga2", "icinga2.ca"),
  70. "icinga2fqdn": config.get("icinga2", "icinga2.fqdn"),
  71. "icinga2port": config.getint("icinga2", "icinga2.port"),
  72. }
  73. else:
  74. print("Missing configuration file [/etc/icingabot/icingabot.conf].")
  75. sys.exit()
  76. class Icinga2ServiceManager:
  77. ko_services = []
  78. def build_request_url(self, uri, params={}):
  79. # Since icinga2 wants « URL-encoded strings » but requests
  80. # seems to makes « application/x-www-form-urlencoded »,
  81. # so we munge params and shortcut urlencode.
  82. # See
  83. # https://www.icinga.com/docs/icinga2/latest/doc/12-icinga2-api/#parameters
  84. url = "https://{}:{}{}".format(
  85. settings["icinga2fqdn"], settings["icinga2port"], uri
  86. )
  87. return (
  88. requests.Request("GET", url, params=params)
  89. .prepare()
  90. .url.replace("+", "%20")
  91. )
  92. def fetch_ko_services(self):
  93. headers = {
  94. "Accept": "application/json",
  95. "X-HTTP-Method-Override": "GET",
  96. }
  97. data = {
  98. "attrs": ["last_check_result", "display_name", "host_name", "acknowledgement"],
  99. "filter": "service.state!=ServiceOK",
  100. }
  101. try:
  102. r = requests.post(
  103. self.build_request_url("/v1/objects/services"),
  104. headers=headers,
  105. auth=(settings["icinga2user"], settings["icinga2pass"]),
  106. data=json.dumps(data),
  107. verify=settings["icinga2ca"],
  108. )
  109. if r.status_code == 200:
  110. new_ko_services = [
  111. n for n in r.json()["results"] if n is not None
  112. ]
  113. news = [
  114. n
  115. for n in new_ko_services
  116. if n["name"] not in [nn["name"] for nn in self.ko_services]
  117. ]
  118. lost = [
  119. n
  120. for n in self.ko_services
  121. if n["name"] not in [nn["name"] for nn in new_ko_services]
  122. ]
  123. self.ko_services = new_ko_services
  124. return (lost, news)
  125. except Exception as e:
  126. self.send("Unable to fetch from Icinga2: {}".format(e))
  127. return (False, False)
  128. def post_on_services(self, pattern, uri, data={}):
  129. headers = {
  130. "Accept": "application/json",
  131. "X-HTTP-Method-Override": "POST",
  132. }
  133. params = {
  134. "type": "Service",
  135. "filter": "service.state!=ServiceOK"
  136. }
  137. if pattern:
  138. params['filter'] += '&& match("*{}*", service.__name)'.format(pattern)
  139. try:
  140. r = requests.post(
  141. self.build_request_url(uri, params=params),
  142. headers=headers,
  143. auth=(settings["icinga2user"], settings["icinga2pass"]),
  144. data=json.dumps(data),
  145. verify=settings["icinga2ca"],
  146. )
  147. for a in r.json()["results"]:
  148. self.send(a["status"])
  149. if pattern is not None and not r.json()["results"]:
  150. self.send("No matching service for « {} »".format(pattern))
  151. except Exception as e:
  152. self.send("Unable to post to Icinga2: {}".format(e))
  153. def ack_service(self, pattern, comment, nick):
  154. data = {
  155. "author": nick,
  156. "comment": comment or " ", # never "" !
  157. }
  158. self.post_on_services(pattern, '/v1/actions/acknowledge-problem', data)
  159. def recheck_service(self, pattern):
  160. self.post_on_services(pattern, '/v1/actions/reschedule-check')
  161. class IcingaBot(Icinga2ServiceManager, irc.bot.SingleServerIRCBot):
  162. args = ""
  163. muted = False
  164. nick_suffix = ""
  165. def __init__(self, channel, nickname, server, port=6667):
  166. irc.bot.SingleServerIRCBot.__init__(
  167. self,
  168. [(server, port)],
  169. nickname,
  170. nickname,
  171. reconnection_interval=300,
  172. )
  173. self.nick = nickname
  174. self.channel = channel
  175. self.connection.execute_every(30, self.refresh_ko_services)
  176. self.refresh_ko_services()
  177. def suffix_nick(self, suffix):
  178. self.nick_suffix = suffix
  179. if self.connection.is_connected():
  180. self.connection.nick("{}{}".format(self.nick, suffix))
  181. def unmute(self):
  182. self.muted = False
  183. if self.ko_services:
  184. self.suffix_nick("[{}]".format(len(self.ko_services)))
  185. else:
  186. self.suffix_nick("")
  187. def send(self, msg):
  188. if not self.muted and self.connection.is_connected():
  189. for line in msg.split("\n"):
  190. self.connection.privmsg(self.channel, line)
  191. def refresh_ko_services(self):
  192. lost, news = self.fetch_ko_services()
  193. if lost is False and news is False:
  194. return
  195. if self.ko_services:
  196. self.suffix_nick("[{}]".format(len(self.ko_services)))
  197. else:
  198. self.suffix_nick("")
  199. return
  200. def on_nicknameinuse(self, c, e):
  201. c.nick(+"_")
  202. def on_welcome(self, c, e):
  203. c.join(self.channel)
  204. def on_privmsg(self, c, e):
  205. self.do_command(e, e.arguments[0])
  206. def on_pubmsg(self, c, e):
  207. if e.arguments[0].startswith(settings["irccmd"]):
  208. self.do_command(e, e.arguments[0][1:])
  209. return
  210. a = e.arguments[0].split(":", 1)
  211. if (
  212. len(a) > 1
  213. and a[0].lower() == self.connection.get_nickname().lower()
  214. ):
  215. self.do_command(e, a[1].strip())
  216. return
  217. def do_cmd(self, s):
  218. self.args = None
  219. tokens = s.split(" ", 1)
  220. cmd = tokens[0]
  221. if len(tokens) > 1:
  222. self.args = tokens[1].strip()
  223. for k, v in commands.items():
  224. if cmd == k and hasattr(self, "do_" + cmd):
  225. return getattr(self, "do_" + cmd)
  226. if "synonyms" in v:
  227. for s in v["synonyms"]:
  228. if re.match(s, cmd) and hasattr(self, "do_" + k):
  229. return getattr(self, "do_" + k)
  230. return False
  231. def do_help(self, c, e):
  232. for k, v in commands.items():
  233. self.send("{} -- {}".format(k, v["help"]))
  234. if "synonyms" in v:
  235. self.send(" synonyms: {}".format(", ".join(v["synonyms"])))
  236. def do_die(self, c, e):
  237. print("Ok master, I die. Aaargh...")
  238. self.die()
  239. def do_leave(self, c, e):
  240. self.disconnect()
  241. def get_unack_ko_services(self):
  242. return [s for s in self.ko_services if not s["attrs"]["acknowledgement"]]
  243. def regrouped_ko_services(self):
  244. def regroup_key(elem):
  245. return elem["attrs"]["display_name"]
  246. return [
  247. (group, [service["attrs"]["host_name"] for service in services])
  248. for group, services in groupby(
  249. sorted(self.get_unack_ko_services(), key=regroup_key), regroup_key
  250. )
  251. ]
  252. def do_list(self, c, e):
  253. if self.ko_services:
  254. host_by_service = [
  255. (service, [hostname.split(".")[0] for hostname in hosts])
  256. for service, hosts in sorted(
  257. self.regrouped_ko_services(), key=lambda x: -len(x[1])
  258. )
  259. ]
  260. self.send(
  261. "\n".join(
  262. [
  263. "{} ({}): {}".format(
  264. service, len(hostnames), ", ".join(hostnames)
  265. )
  266. for service, hostnames in host_by_service
  267. ]
  268. )
  269. )
  270. acknowledged = [s for s in self.ko_services if s["attrs"]["acknowledgement"]]
  271. if acknowledged:
  272. self.send(
  273. "Acknowledged ({}): {}".format(
  274. len(acknowledged),
  275. ', '.join({s["attrs"]["display_name"] for s in acknowledged})
  276. )
  277. )
  278. else:
  279. self.send("Nothing particularly exciting.")
  280. def do_ack(self, c, e):
  281. if self.args is None:
  282. self.send(
  283. e.source.nick + ": usage: !ack <pattern or all> [: comment]"
  284. )
  285. return
  286. tokens = self.args.split(":", 1)
  287. pattern, comment = tokens[0].strip(), ""
  288. if len(tokens) > 1:
  289. comment = tokens[1].strip()
  290. if pattern == "all":
  291. pattern = None
  292. self.ack_service(pattern, comment, e.source.nick)
  293. def do_recheck(self, c, e):
  294. if self.args == "all":
  295. self.args = None
  296. self.recheck_service(self.args)
  297. self.connection.execute_delayed(15, self.refresh_ko_services)
  298. def do_mute(self, c, e):
  299. self.muted = True
  300. self.suffix_nick("[zZz]")
  301. self.connection.execute_delayed(3600, self.refresh_ko_services)
  302. def do_command(self, e, cmd):
  303. nick = e.source.nick
  304. c = self.connection
  305. if self.do_cmd(cmd):
  306. self.unmute()
  307. (self.do_cmd(cmd))(c, e)
  308. else:
  309. self.send(nick + ": Not understood: " + cmd)
  310. def main():
  311. bot = IcingaBot(
  312. settings["ircchan"],
  313. settings["ircnick"],
  314. settings["ircsrv"],
  315. settings["ircport"],
  316. )
  317. bot.start()
  318. if __name__ == "__main__":
  319. main()