一、方案概述
本方案实现钉钉群机器人 + 后端服务 远程控制多台服务器执行启动/停止脚本。
核心能力:
- 钉钉群发送可点击按钮卡片
- 点击按钮自动回调后端接口
- 后端验签、IP白名单校验
- 远程SSH登录服务器执行脚本
- 安全、可控、可追溯
二、整体架构流程
- 运行Java程序 → 钉钉群发送按钮卡片
- 员工点击钉钉按钮 → 请求后端回调接口
- 后端校验:IP白名单 + 签名防篡改
- 校验通过 → SSH远程登录目标服务器
- 执行指定启动脚本 → 返回执行结果
三、环境依赖
- JDK 8+
- SpringBoot 2.x
- 钉钉群机器人(支持加签)
- 服务器开启SSH端口(默认20)
- 服务器账号密码/密钥
四、实现步骤
步骤1:创建钉钉机器人并获取密钥
- 钉钉群 → 群设置 → 机器人 → 添加机器人 → 选择自定义机器人
- 安全设置:加签 ,复制
Webhook地址和密钥(SECRET) - 记住:必须开启加签,否则无法使用
步骤2:配置 application.yml
yaml
dingtalk:
url: 加签xxx
trusted:
enable: true
ips: 白名单
script: 执行脚本如启动脚本
remote:
server:
port: 6220
username: 远程服务器用户
password: 远程服务器密码
步骤3:编写钉钉消息发送工具类
DingTalkService.java
作用:生成带签名的钉钉卡片,发送到群里
java
import com.alibaba.fastjson.JSONObject;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class DingTalkService {
private static final String WEBHOOK_URL = "WEBHOOK_URLx'x'x ";
private static final String SECRET = "你的SEC密钥";
private static final String CALLBACK_DOMAIN = "https://你的域名:7777/task/dingtalk";
public void sendActionCardMessage() throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis());
String sign = sign(timestamp, SECRET);
String webhookWithSign = WEBHOOK_URL + "×tamp=" + timestamp + "&sign=" + sign;
JSONObject cardData = new JSONObject();
cardData.put("msgtype", "actionCard");
JSONObject actionCard = new JSONObject();
actionCard.put("title", "价签服务远程控制面板");
actionCard.put("text", "### 价签服务一键启动\n支持服务器:242、245、003\n点击按钮执行启动脚本");
actionCard.put("btnOrientation", "0");
List<JSONObject> btnList = new ArrayList<>();
btnList.add(createBtn("242-server", "18.2.0.24.242"));
btnList.add(createBtn("245-server", "18.2.0.24.245"));
btnList.add(createBtn("003-server", "18.2.0.24.3"));
actionCard.put("btns", btnList);
cardData.put("actionCard", actionCard);
String response = sendHttpPost(webhookWithSign, cardData.toJSONString());
System.out.println("发送结果:" + response);
}
private JSONObject createBtn(String title, String serverId) {
JSONObject btn = new JSONObject();
btn.put("title", title);
try {
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "");
String signStr = timestamp + nonce + serverId;
String sign = generateCallbackSign(signStr, SECRET);
String callbackUrl = String.format(
"%s/callback?serverId=%s×tamp=%s&nonce=%s&sign=%s",
CALLBACK_DOMAIN,
URLEncoder.encode(serverId, "UTF-8"),
URLEncoder.encode(timestamp, "UTF-8"),
URLEncoder.encode(nonce, "UTF-8"),
URLEncoder.encode(sign, "UTF-8")
);
btn.put("actionURL", callbackUrl);
} catch (Exception e) {
e.printStackTrace();
}
return btn;
}
private String generateCallbackSign(String signStr, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] signData = mac.doFinal(signStr.getBytes(StandardCharsets.UTF_8));
return new String(java.util.Base64.getEncoder().encode(signData));
}
private String sign(String timestamp, String secret) throws Exception {
String stringToSign = timestamp + "\n" + secret;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] signData = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
return URLEncoder.encode(new String(java.util.Base64.getEncoder().encode(signData)), "UTF-8");
}
private String sendHttpPost(String url, String body) throws Exception {
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
HttpPost post = new HttpPost(url);
post.setHeader("Content-Type", "application/json;charset=utf-8");
post.setEntity(new StringEntity(body, StandardCharsets.UTF_8));
return EntityUtils.toString(httpClient.execute(post).getEntity(), StandardCharsets.UTF_8);
}
}
public static void main(String[] args) {
try {
new DingTalkService().sendActionCardMessage();
System.out.println("发送成功!");
} catch (Exception e) {
e.printStackTrace();
}
}
}
步骤4:编写回调接口(核心)
DingTalkCallbackController.java
作用:接收钉钉回调、验签、IP校验、远程执行脚本
java
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/dingtalk")
public class DingTalkCallbackController {
private static final Logger log = LoggerFactory.getLogger(DingTalkCallbackController.class);
@Value("${dingtalk.url}")
private String CALLBACK_SECRET;
@Value("${dingtalk.trusted.enable:false}")
private Boolean enable;
@Value("${dingtalk.trusted.ips:}")
private String trustedIpsConfig;
@Value("${dingtalk.script}")
private String script;
@Value("${remote.server.port:22}")
private int remotePort;
@Value("${remote.server.username}")
private String remoteUsername;
@Value("${remote.server.password}")
private String remotePassword;
@GetMapping("/callback")
public String callback(@RequestParam Map<String, String> params, HttpServletRequest request) {
try {
String clientIp = getClientIp(request);
if (enable && !isIpAllowed(clientIp)) {
return "error: ip not allowed";
}
String serverId = params.get("serverId");
String timestamp = params.get("timestamp");
String nonce = params.get("nonce");
String sign = params.get("sign");
String signStr = timestamp + nonce + serverId;
String calcSign = generateCallbackSign(signStr, CALLBACK_SECRET);
if (!calcSign.equals(sign)) {
return "error: sign invalid";
}
log.info("验签通过,执行服务器:{}", serverId);
return executeScript(serverId);
} catch (Exception e) {
log.error("执行异常", e);
return "error: " + e.getMessage();
}
}
private String executeScript(String serverIp) throws Exception {
Session session = null;
ChannelExec channel = null;
try {
JSch jsch = new JSch();
session = jsch.getSession(remoteUsername, serverIp, remotePort);
session.setPassword(remotePassword);
session.setConfig("StrictHostKeyChecking", "no");
session.connect(30000);
channel = (ChannelExec) session.openChannel("exec");
channel.setCommand(script);
BufferedReader reader = new BufferedReader(
new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8));
channel.connect();
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
} finally {
if (channel != null) channel.disconnect();
if (session != null) session.disconnect();
}
}
private String generateCallbackSign(String signStr, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] signData = mac.doFinal(signStr.getBytes(StandardCharsets.UTF_8));
return new String(java.util.Base64.getEncoder().encode(signData));
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.contains("unknown")) ip = request.getRemoteAddr();
return ip.split(",")[0].trim();
}
private boolean isIpAllowed(String clientIp) {
if (trustedIpsConfig == null) return true;
List<String> list = Arrays.asList(trustedIpsConfig.split(","));
return list.stream().anyMatch(s -> s.trim().equals(clientIp));
}
}
步骤5:启动服务并发送钉钉卡片
- 启动SpringBoot服务
- 运行
DingTalkService.java的 main 方法 - 钉钉群收到按钮卡片
步骤6:点击按钮测试
点击按钮 → 后端自动执行脚本 → 返回执行结果
五、安全机制(非常重要)
- 钉钉机器人加签:防止伪造消息
- 回调接口签名:防止非法调用接口
- IP白名单:只允许钉钉服务器/本机访问
- SSH账号密码:远程服务器权限控制
六、扩展能力
- 支持多服务切换(修改script配置)
- 支持多环境(dev/test/prod)
- 支持执行结果钉钉回推
- 支持操作日志记录