监控告警 http 代理企业微信服务

1. 项目背景

朋友在一家日企,运维工具老旧,希望增加一款系统告警工具。我提议通过微信企业号(改版为企业微信)或者短信推送告警信息。提出两点要求:

  1. 可以群发给企业微信中的多个人
  2. 监测健康检测服务是否存活(绕嘴。。)

做了第一版demo,定位为通信渠道的http代理。贴出企业微信相关代码,如果有需要的同学可以拿去用,记得点个star就好。
https://github.com/wangyuheng/pharos

1.1 依赖项目

因为定位为http代理,并未使用数据库及持久化工具。

  1. springboot
  2. 企业微信接口 https://work.weixin.qq.com/api/doc
  3. 可能是目前最好最全的微信Java开发工具包(SDK)https://github.com/Wechat-Group/weixin-java-tools
  4. swagger 工具 https://github.com/wangyuheng/spring-boot-swagger-starter

2. 项目code

代理入口为健康检测工具,出口为企业微信、短信等。同时需要保障和健康检测工具之间的网络通畅。

wechat-message-proxy-00

最近在尝试画图

2.1 微信接口

企业微信相关配置如下

wechat:
  cp:
    corpid:
    agentid:
    corp:
      secret:
  1. corpid 企业ID
  2. agentid 应用ID,在企业微信管理后台创建应用后,可以查看应用ID
  3. corp.secret 应用的凭证密钥

weixin-java-tools已经对微信接口进行了友好的封装,可以通过标签、部门等分组,查询用户标识。配置bean方法如下

package com.crick.business.pharos.config;

import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.api.impl.WxCpDepartmentServiceImpl;
import me.chanjar.weixin.cp.api.impl.WxCpServiceImpl;
import me.chanjar.weixin.cp.api.impl.WxCpTagServiceImpl;
import me.chanjar.weixin.cp.api.impl.WxCpUserServiceImpl;
import me.chanjar.weixin.cp.config.WxCpConfigStorage;
import me.chanjar.weixin.cp.config.WxCpInMemoryConfigStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnClass(WxCpService.class)
public class WechatCpConfig {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Value("${wechat.cp.corpid}")
    private String corpid;
    @Value("${wechat.cp.corp.secret}")
    private String corpSecret;
    @Value("${wechat.cp.agentid}")
    private Integer agentid;

    @Bean
    @ConditionalOnMissingBean
    public WxCpConfigStorage configStorage() {
        WxCpInMemoryConfigStorage configStorage = new WxCpInMemoryConfigStorage();
        logger.info("****************wechat properties start****************");
        logger.info("corpid:{}", corpid);
        logger.info("corpSecret:{}", corpSecret);
        logger.info("agentid:{}", agentid);
        logger.info("****************wechat properties end****************");
        configStorage.setCorpId(corpid);
        configStorage.setCorpSecret(corpSecret);
        configStorage.setAgentId(agentid);
        return configStorage;
    }

    @Bean
    @ConditionalOnMissingBean
    public WxCpService WxCpService(WxCpConfigStorage configStorage) {
        WxCpService wxCpService = new WxCpServiceImpl();
        wxCpService.setWxCpConfigStorage(configStorage);
        wxCpService.setTagService(new WxCpTagServiceImpl(wxCpService));
        wxCpService.setDepartmentService(new WxCpDepartmentServiceImpl(wxCpService));
        wxCpService.setUserService(new WxCpUserServiceImpl(wxCpService));
        return wxCpService;
    }

}

同时也提供了发送消息、已经封装消息的类

package com.crick.business.pharos.service;

import me.chanjar.weixin.cp.bean.WxCpMessage;

public class AlertTextBuilder {

    private Integer agentid;

    public AlertTextBuilder(Integer agentid) {
        this.agentid = agentid;
    }

    public WxCpMessage buildForTag(String content, String tag) {
        return WxCpMessage.TEXT().agentId(agentid).content(content).toTag(tag).build();
    }

    public WxCpMessage buildForUsers(String content, String users) {
        return WxCpMessage.TEXT().agentId(agentid).content(content).toUser(users).build();
    }

}
package com.crick.business.pharos.service;

import me.chanjar.weixin.common.exception.WxErrorException;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.WxCpUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class WechatAlertService implements AlertService {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private WxCpService wxCpService;
    @Autowired
    private AlertTextBuilder alertTextBuilder;

    @Override
    public void alertTextToTag(String content, String tag) {
        try {
            wxCpService.messageSend(alertTextBuilder.buildForTag(content, tag));
        } catch (WxErrorException e) {
            logger.error("alertTextToTag error! tag:{}", tag, e);
        }
    }

    @Override
    public void alertTextToUsers(String content, List<String> users) {
        try {
            wxCpService.messageSend(alertTextBuilder.buildForUsers(content, String.join(",", users)));
        } catch (WxErrorException e) {
            logger.error("alertTextToUsers error! users:{}", users, e);
        }
    }

    @Override
    public void alertTextToDepartment(String content, Integer department) {
        try {
            List<WxCpUser> wxCpUserList = wxCpService.getUserService().listSimpleByDepartment(department, true, 0);
            if (null != wxCpUserList) {
                String userList = wxCpUserList.stream()
                        .map(WxCpUser::getUserId)
                        .collect(Collectors.joining(","));
                wxCpService.messageSend(alertTextBuilder.buildForUsers(content, userList));
            }
        } catch (WxErrorException e) {
            logger.error("alertTextToDepartment error! department:{}", department, e);
        }
    }

}

2.2 restful接口

对外暴露的接口主要提供两个功能

  1. 代理企业微信,用于查询部门、标签、userId等
  2. 发送告警信息

具体实现在service中,restful暴露了http请求接口及swagger接口。并且将首页指向了swagger页面


@Controller
public class IndexController {
    @GetMapping("/")
    public String index() {
        return "redirect:swagger-ui.html";
    }
}

wechat-message-proxy-01

2.3 验权

两种验权方式

  1. 参数+secret通过SHA加密签名
  2. ip白名单

通过interceptor实现

2.3.1 白名单校验

public class AuthorInterceptor extends HandlerInterceptorAdapter {

    @Value("#{'${white.list}'.split(',')}")
    private List<String> whiteList;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            if (handlerMethod.getBeanType().isAnnotationPresent(Anonymous.class)) {
                return true;
            }
        }
        String clientIp = getIpAddress(request);
        if (!whiteList.contains(clientIp)) {
            throw new RestfulException("client ip: " + clientIp + " not in white list", RestfulErrorCode.AUTHOR_ERROR);
        }
        return super.preHandle(request, response, handler);
    }

    private String getIpAddress(HttpServletRequest request) {
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

2.3.2 接口参数签名校验

public class SignInterceptor extends HandlerInterceptorAdapter {

    @Value("${secret.key}")
    public String secretKey;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            if (handlerMethod.getBeanType().isAnnotationPresent(Anonymous.class)) {
                return true;
            }
        }

        String sign = request.getParameter("sign");
        if (null == sign || "".equals(sign)) {
            throw new RestfulException("must have a sign param!", RestfulErrorCode.SIGN_ERROR);
        } else {
            Map<String, String[]> parameters = request.getParameterMap();
            if (parameters.size() > 0) {
                StringBuilder sb = new StringBuilder();
                for (String key : parameters.keySet()) {
                    if ("sign".equals(key)) {
                        continue;
                    }
                    sb.append(key).append("-").append(Arrays.toString(parameters.get(key))).append("-");
                }
                sb.append("token").append("-").append(secretKey);
                if (!sign.equals(EncryptUtil.sha1(sb.toString()))) {
                    throw new RestfulException("sign check fail!", RestfulErrorCode.SIGN_ERROR);
                }
            }
        }
        return super.preHandle(request, response, handler);
    }

}

SHA加密工具封装

public class EncryptUtil {

    private EncryptUtil() {
    }

    private static final String SHA_1_ALGORITHM = "SHA-1";
    private static final String SHA_256_ALGORITHM = "SHA-256";

    public static String sha1(String source) {
        return sha(source, SHA_1_ALGORITHM);
    }

    public static String sha256(String source) {
        return sha(source, SHA_256_ALGORITHM);
    }

    private static String sha(String source, String instance) {
        MessageDigest md;
        try {
            md = MessageDigest.getInstance(instance);
            md.update(source.getBytes());
            return new String(Hex.encodeHex(md.digest()));
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
    }
}

如果需要对sign有效期进行校验,需要提供获取服务器时钟的方法,避免因为服务器时间不一致导致的时间差, 此方法可以通过@Anonymous去掉验权操作。

@RestController
@RequestMapping("common")
@Anonymous
public class CommonController {

    /**
     * 获取系统时间,避免客户端时间不一致
     */
    @GetMapping("current")
    public Long current() {
        return System.currentTimeMillis();
    }
}

2.4 健康检测

定时用http get 请求确认网络通畅,如果网络连接失败次数超过阈值,报警给系统管理员

通过Scheduled编写定时任务

@Component
public class PingCheckTask {
    private static OkHttpClient okHttpClient = new OkHttpClient();

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Value("${ping.service.url}")
    private String serviceUrl;
    @Value("${ping.period:3}")
    private int period;

    private static Map<String, Integer> errorCalculate = new ConcurrentHashMap<>();

    private void resetCount(String url) {
        errorCalculate.put(url, 0);
    }

    private int pushCount(String url) {
        errorCalculate.put(url, errorCalculate.getOrDefault(url, 0) + 1);
        return errorCalculate.get(url);
    }

    @Scheduled(cron = "0/20 * * * * ?") // 每20秒执行一次
    public void scheduler() throws IOException {
        Request request = new Request.Builder()
                .url(serviceUrl)
                .build();
        Response response = okHttpClient.newCall(request).execute();
        if (response.isSuccessful()) {
            logger.info("ping check {} success!", serviceUrl);
            resetCount(serviceUrl);
        } else {
            logger.info("ping check {} fail! response:{}", serviceUrl, response);
            int count = pushCount(serviceUrl);
            if (count > period) {
                // alert to admin!
            }
        }
    }
}

在配置bean中需要注入bean并允许启动调度

@Configuration
@EnableScheduling
public class WebConfig implements WebMvcConfigurer {

 @Bean
    public PingCheckTask pingCheckTask(){
        return new PingCheckTask();
    }
}

3. 其他

项目写的比较仓促,后续根据实际使用场景进行调整优化。都是站在巨人的肩膀上,利用现成的工具进行拼装。 如果有建议或者希望实现哪些功能,可以留言或者给我提issue https://github.com/wangyuheng/pharos

春城无处不飞花,寒食东风御柳斜。

推荐大家吃 青团(艾团), 简直发现了新大陆

wechat-message-proxy-02

wangyuheng wechat
扫码关注我的公众号,获取更多文章推送