0x01 前言

我是用Cachet系统对外展示我系统的一些信息与状态,之前写过一篇文章,通过python脚本更新它的metrics数据,展示效果如下:

以下是相关文章:

近期有位群友想实现Cachet与zabbix之间的互动,获取zabbix中的告警信息并更新Cachet中的系统状态与添加事件记录。

我原本有个小脚本,但实现起来非常繁琐,所以我重新整理逻辑并花一天事件编写一个新的脚本实现以上需求。虽然实现了基本需求,但依旧繁琐且事件记录过于详细,容易泄露敏感数据;另外逻辑也有待优化。

0x02 思路

我利用zabbix的告警提醒功能调用外部的脚本,并向该脚本发送定制的告警内容。然后脚本自动处理告警信息,最后更新Cachet中的数据。

但在Cachet中的部件名称不一定为zabbix中的设备名,另外因为Cachet中的事件记录只支持获取全部或通过事件id进行获取,结合这两个原因,还需要建立一个临时文件,用于存储zabbix hostname、zabbix事件id与Cachet事件id的对应关系。

目前脚本仅支持固定得事件记录内容,而且当zabbix异常恢复后会更新事件记录得内容,但这会将异常信息删掉。在实际应用中这并不是很好的操作,所以计划在下一个版本修改为可自定义的格式并保留告警信息。

因为有可能会出现单一zabbix监控目标出现多个告警情况,所以还得对Cachet临时文件做出增删查的基本功能,这部分异常复杂,下个版本也需要更新。

0x03 部署

首先需要准备配置文件,配置文件名为“cachethq_status_updater_conf.json”,内容如下:

{
  "config_main": {
    "cachethq_url": "https://status.ngx.hk/api/v1/",
    "cachethq_api_key": "qazxfjhdxfkjhget"
  },
  "host": {
    "t620-idrac": 22,
    "cn1.ngx.hk": 16,
    "cn2.ngx.hk": 1,
    "hk1.ngx.hk": 2,
    "pub-ngx.t.com": 4,
    "gitlab.t.com": 5,
    "web.t.com": 6,
    "es6-node1.t.com": 7,
    "es6-node2.t.com": 7,
    "es6-node3.t.com": 7,
    "logstash6-node1.t.com": 7
  }
}

config_main中记录着Cachet的API地址与API key,这部分内容请留意以下文章:

最重要的是host中的字典,字典中的key为zabbix中的hostname,如果在zabbix host设置中为监控点设置了“Visible name”,则key应为“Visible name”的值:

而value是Cachet中部件所对应的的id:

0x03.1 zabbix host groups

然后还需要给zabbix配置告警动作,首先到configuration –> host groups中添加一个名为cachethq_status的主机组:

0x03.2 zabbix Media types

紧接着新建Media types:

相关内容如下:

# Name
cachethq_status_updater

# Type
Script

# Script name
cachethq_status_updater.py

# Script parameters
{ALERT.MESSAGE}

Options标签中的内容保持默认即可。

0x03.3 zabbix actions

最后到configuration –> actions中添加一个新的action:

在action标签中配置一个动作名称,并在condition中选择主机组等于cachethq_status,选择完成后记住单击“Add”才算完成condition的添加;勾选Enable,然后单击Operations标签:

配置内容如下:

其中”Default operation step duration”请添加尽可能大的数,因为我们不需要重复提醒;”Operation details”中的”Steps”请填写1-1,只通知一次即可;”“选择上面建立的Media type即可。

Operations标签中”Default subject”与”Default message”的内容如下:

problem::{EVENT.NAME}

start_time::{EVENT.DATE} {EVENT.TIME}
event_name::{EVENT.NAME}
host_name::{HOST.NAME}
severity::{EVENT.SEVERITY}
event_id::{EVENT.ID}

请勿更改以上内容与格式。

Recovery operations标签中的配置如下:

“Operation details”中的”“选择上面建立的Media type即可。

相关内容如下:

resolved::{EVENT.NAME}

resolved_time::{EVENT.RECOVERY.DATE} {EVENT.RECOVERY.TIME}
event_name::{EVENT.NAME}
host_name::{HOST.NAME}
severity::{EVENT.SEVERITY}
event_id::{EVENT.ID}

0x03.4 zabbix 脚本

clone源码之前需要修改zabbix server的配置文件:

# 编辑文件
[root@web ~]# vim /usr/local/zabbix/etc/zabbix_server.conf 

# 修改该字段
AlertScriptsPath=/usr/local/shell/zabbix

AlertScriptsPath的值为一个目录,其中的脚本的所有这需要修改为zabbix的用户与用户组并赋予可执行权限,具体路径请根据实际情况进行选择。

完成上面的配置后即可从我的gitlab中clone源码到本地并将以下文件放置到AlertScriptsPath的目录中:

[root@web ~]# ll /usr/local/shell/zabbix/
total 20
-rw-r--r-- 2 zabbix zabbix  425 Nov  8 21:27 cachethq_status_updater_conf.json
-rwxr-xr-x 2 zabbix zabbix 4941 Nov  8 21:15 cachethq_status_updater.py

0x03.5 测试

完成上述操作后需要重启zabbix server,随后即可通过调整zabbix中的阈值进行测试。最简单的测试方式是停止监控点的zabbix-agent,待出现告警后,zabbix server会自动调用脚本,更新Cachet中的数据:

事件记录如下:

重新启动zabbix agent后即可恢复正常。

0x04 源码

第一行需要定义python3的路径,紧接着接受传入数据,然后读取配置文件,最后是定义临时文件路径:

#!/usr/bin/python3

import sys
import requests
import json

# 接收传入内容
msg = sys.argv[1]

config_content = open(sys.path[0] + '/cachethq_status_updater_conf.json')
config_dict = json.load(config_content)

cachethq_url = config_dict['config_main']['cachethq_url']
cachethq_api_key = config_dict['config_main']['cachethq_api_key']
cachethq_host_dict = config_dict['host']

temp_file_path = sys.path[0] + '/cachethq_status_updater.temp'

针对传入的内容,转换为字典格式:

def zabbix_msg_handler():
    zbx_msg_list = msg.split('\n')
    zbx_msg_dict = dict()
    for i in zbx_msg_list:
        i_list = i.split('::')
        zbx_msg_dict[i_list[0]] = i_list[1].strip('\r')
    return zbx_msg_dict

传入内容如下:

# 告警内容
start_time::2018.11.08 11:27:32
event_name::Inlet Temp warning
host_name::t620-idrac
severity::Warning
event_id:::21759621

# 恢复内容
resolved_time::2018.11.08 11:49:32
event_name::Inlet Temp warning
host_name::t620-idrac
severity::Warning
event_id::21760075

转换后的内容如下:

# 告警内容
{'severity': 'Warning', 'host_name': 't620-idrac', 'event_id': ':21739925', 'start_time': '2018.11.07 23:03:02', 'event_name': 'Inlet Temp warning'}

# 恢复内容
{'event_name': 'Inlet Temp warning', 'resolved_time': '2018.11.08 11:35:33', 'host_name': 't620-idrac', 'severity': 'Warning', 'event_id': '21759715'}

以下是创建事件记录并修改部件状态的函数:

def create_incidents(api_token, inc_name, inc_msg, inc_status, inc_visible, comp_id, comp_status):
    req_url = cachethq_url + 'incidents'
    payload = {
        "name": inc_name,
        "message": inc_msg,
        "status": inc_status,
        "visible": inc_visible,
        "component_id": comp_id,
        "component_status": comp_status
    }
    headers = {'X-Cachet-Token': api_token}
    req_run = requests.request('POST', req_url, data=payload, headers=headers)
    req_content = json.loads(req_run.text)
    inc_id = req_content['data']['id']
    return inc_id

以下是修改事件记录并修改部件状态的函数:

def update_incidents(api_token, inc_id, inc_name, inc_msg, inc_status, inc_visible, com_id, comp_status):
    req_url = cachethq_url + 'incidents/' + str(inc_id)
    payload = {
        "name": inc_name,
        "message": inc_msg,
        "status": inc_status,
        "visible": inc_visible,
        "component_id": com_id,
        "component_status": comp_status
    }
    headers = {'X-Cachet-Token': api_token}
    req_run = requests.request('PUT', req_url, data=payload, headers=headers)
    req_content = json.loads(req_run.text)
    return req_content

创建与修改都需要通过”X-Cachet-Token”这个header传递api token,这两部分的API可以通过以下地址找到相关信息:

接下来是整个脚本逻辑最混乱的一个函数:

def r_w_d_temp_file(act_type, host_name, event_id, incident_id):
    try:
        r = open(temp_file_path, 'r')
    except FileNotFoundError:
        r = open(temp_file_path, 'w')
        r.write('{}')
        r.close()
        temp_content = dict()
    else:
        temp_content = json.loads(r.read())
        r.close()

    if act_type == 'r':
        try:
            incident_id = temp_content[host_name][event_id]
        except KeyError:
            return False
        else:
            event_count = len(temp_content[host_name])
            return incident_id, event_count
    elif act_type == 'd':
        del temp_content[host_name][event_id]
        r = open(temp_file_path, 'w')
        r.write(str(json.dumps(temp_content)))
        r.close()
        return None
    elif act_type == 'w':
        if host_name in temp_content:
            temp_content[host_name][event_id] = incident_id
        else:
            id_dict = dict()
            id_dict[event_id] = incident_id
            temp_content[host_name] = id_dict
        r = open(temp_file_path, 'w')
        r.write(str(json.dumps(temp_content)))
        r.close()
        return None
    else:
        pass

以上函数主要实现对临时文件的增删查功能,首先通过try尝试读取临时文件,如果文件不存在则创建文件并填入一个空字典,以免首次运行脚本时报错;如果文件存在则读取并格式化为字典。

接下来的逻辑如下:

  • “act_type”为”r”:查询模式,尝试读取”event_id”的value,如果值不存在的返还False,说明该值不存在,需要建立。我的设想是只有在zabbix出现告警时才会使用查询模式,但后来发现单一监控点可能会出现多个告警,这时候监控点告警恢复时也需要调用该模式。所以增加了event_count值在event_id存在时一起回传;
  • “act_type”为”d”:删除模式,当zabbix异常恢复后,从字典中删除对应的key。我的设想是异常恢复肯定在异常恢复之后,所以相关的event_id肯定在字典中,此时只需要删除即可,不需要其他操作;
  • “act_type”为”w”:写入模式,我的设想是只有在zabbix出现异常时才会写入,所以这里会有两种情况:
    • 监控点从未出现过:此时需要建立一个新的监控点字典并与现有的字典合并;
    • 监控点已出现过:此时只需要增加event_id及其对应的value即可。

最后的pass只是想让逻辑更完整而已,没啥用。

最后是run函数:

def run():
    msg_dict = zabbix_msg_handler()
    if 'start_time' in msg_dict:
        incidents_name = 'Host ' + msg_dict['host_name'] + ' has an ' + msg_dict['severity'] + ' level alarm.'
        incidents_msg = 'Event ID: ' + str(msg_dict['event_id']) + \
                        ', Event name: ' + msg_dict['event_name'] + \
                        ', Event start time: ' + msg_dict['start_time']
        if msg_dict['severity'] is 'Not classified' or 'Information':
            component_status = 2
        elif msg_dict['severity'] is 'Warning' or 'Average':
            component_status = 3
        elif msg_dict['severity'] is 'High' or 'Disaster':
            component_status = 4
        else:
            component_status = 1

        incidents_id = create_incidents(cachethq_api_key, incidents_name, incidents_msg, 2, 1,
                                        cachethq_host_dict[msg_dict['host_name']], component_status)
        r_w_d_temp_file('w', msg_dict['host_name'], msg_dict['event_id'], incidents_id)
    elif 'resolved_time' in msg_dict:
        incidents_name = 'The alarm of the ' + msg_dict['severity'] + ' level of host ' + msg_dict[
            'host_name'] + ' has been released.'
        incidents_msg = 'Event ID: ' + str(msg_dict['event_id']) + \
                        ', Event name: ' + msg_dict['event_name'] + \
                        ', Event resolved time: ' + msg_dict['resolved_time']
        incidents_id = r_w_d_temp_file('r', msg_dict['host_name'], msg_dict['event_id'], 0)[0]
        update_incidents(cachethq_api_key, incidents_id, incidents_name, incidents_msg, 4, 1,
                         cachethq_host_dict[msg_dict['host_name']], 1)
        r_w_d_temp_file('d', msg_dict['host_name'], msg_dict['event_id'], 0)
    else:
        pass

这部分内容也是一团糟,只是把功能实现而已。

首先判断传入的内容是发生异常还是异常恢复,然后根据内容的不容组合事件记录的内容,最后分别调用事件记录的创建与更新函数。

这部分有个明显的缺陷:内容过于详细,极其容易造成信息泄露。

0x05 结语

脚本尚处于dev阶段,目前可以通过以下地址找到完整的脚本:

因为这个脚本是被动的脚本,和更新Metrics的脚本有本质上的区别,所以两者不会合并。

这脚本是在半天内临时编写的,相关功能与逻辑尚不完善,开发工作会在接下来的一个月继续,有需要的朋友可持续关注我的gitlab。还可以通过以下地址查看我私有服务的状态: