diff --git a/bin/send_modbus_and_register.py b/bin/script/send_modbus_and_register.py similarity index 100% rename from bin/send_modbus_and_register.py rename to bin/script/send_modbus_and_register.py diff --git a/bin/script/send_sip_register_attack.py b/bin/script/send_sip_register_attack.py new file mode 100644 index 00000000..b0796010 --- /dev/null +++ b/bin/script/send_sip_register_attack.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""本地 SIP 攻击模拟""" + +import socket + +def send_sip_register(target_ip, target_port, sip_id, local_ip="127.0.0.1", local_port=50600): + message = f"""REGISTER sip:{target_ip} SIP/2.0\r +Via: SIP/2.0/UDP {local_ip}:{local_port};rport;branch=z9hG4bK-test-{sip_id}\r +From: ;tag=12345\r +To: \r +Call-ID: test-{sip_id}@{local_ip}\r +CSeq: 1 REGISTER\r +Contact: \r +Max-Forwards: 70\r +User-Agent: Python-Test\r +Expires: 3600\r +Content-Length: 0\r +\r +""" + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(2) + + try: + sock.sendto(message.encode(), (target_ip, target_port)) + response, _ = sock.recvfrom(4096) + return response.decode() + except socket.timeout: + return "Timeout" + finally: + sock.close() + +IP_POOL = ("1.113.12.33","1.113.12.34","1.113.12.35") + +if __name__ == "__main__": + TARGET_IP = "127.0.0.1" # 本地测试 + TARGET_PORT = 5061 # SIP端口 + + # 模拟攻击:连续发送20个请求 + for i in range(100): + sip_id = str(7000 + i) + response = send_sip_register(TARGET_IP, TARGET_PORT, sip_id) + print(f"[{sip_id}] {response.split(chr(13))[0] if response else 'No response'}") \ No newline at end of file diff --git a/fastbee-admin/src/main/resources/application-dev.yml b/fastbee-admin/src/main/resources/application-dev.yml index 5f9807f0..fe7c6cbc 100644 --- a/fastbee-admin/src/main/resources/application-dev.yml +++ b/fastbee-admin/src/main/resources/application-dev.yml @@ -144,6 +144,12 @@ sip: log: true zlmRecordPath: /opt/media/bin/www mp4_max_second: 30 # 视频录像时长,单位秒 + # SIP安全防护配置 + security: + enabled: true # 是否启用安全防护 + rate-limit: 30 # 每分钟最大请求数 + block-threshold: 50 # 触发封禁的请求次数 + block-duration: 30 # 封禁时长(分钟) # 日志配置 logging: diff --git a/fastbee-admin/src/main/resources/application-prod.yml b/fastbee-admin/src/main/resources/application-prod.yml index c936a670..7cf3f1de 100644 --- a/fastbee-admin/src/main/resources/application-prod.yml +++ b/fastbee-admin/src/main/resources/application-prod.yml @@ -139,6 +139,12 @@ sip: log: false zlmRecordPath: /opt/media/bin/www mp4MaxSecond: 30 # 视频录像时长,单位秒 + # SIP安全防护配置 + security: + enabled: true # 是否启用安全防护 + rate-limit: 10 # 每分钟最大请求数(生产环境更严格) + block-threshold: 20 # 触发封禁的请求次数 + block-duration: 10080 # 封禁时长(分钟)一个星期 # 日志配置 logging: diff --git a/fastbee-admin/src/main/resources/application-test.yml b/fastbee-admin/src/main/resources/application-test.yml index 7157c618..d1b61ac9 100644 --- a/fastbee-admin/src/main/resources/application-test.yml +++ b/fastbee-admin/src/main/resources/application-test.yml @@ -78,10 +78,10 @@ emqx: ApiSecret: tMbf5EEZFb08sut88i4tZscxTc9CE3gaRQewSOTRNvWL # sip 配置 sip: - enabled: false # 是否启用视频监控SIP,true为启用 + enabled: true # 是否启用视频监控SIP,true为启用 ## 本地调试时,绑定网卡局域网IP,设备在同一局域网,设备接入IP填写绑定IP ## 部署服务端时,默认绑定容器IP,设备接入IP填写服务器公网IP - ip: 47.121.180.229 # 容器部署容器IP:47.121.180.229;云服务器部署写公网IP;本地部署127.0.0.1;总原则:能被摄像机访问的地址 + ip: 127.0.0.1 # 容器部署容器IP:47.121.180.229;云服务器部署写公网IP;本地部署127.0.0.1;总原则:能被摄像机访问的地址 port: 5061 # SIP端口(保持默认) domain: 3402000000 # 由省级、市级、区级、基层编号组成 id: 34020000002000000001 # 同上,另外增加编号,(可保持默认) @@ -89,6 +89,12 @@ sip: log: false zlmRecordPath: /opt/media/bin/www mp4MaxSecond: 30 # 视频录像时长,单位秒 + # SIP安全防护配置 + security: + enabled: true # 是否启用安全防护 + rate-limit: 20 # 每分钟最大请求数(生产环境更严格) + block-threshold: 30 # 触发封禁的请求次数 + block-duration: 60 # 封禁时长(分钟) # 日志配置 logging: diff --git a/fastbee-open-api/src/main/java/com/fastbee/controller/sip/SipRelationController.java b/fastbee-open-api/src/main/java/com/fastbee/controller/sip/SipRelationController.java index 6219fe2c..156d4a59 100644 --- a/fastbee-open-api/src/main/java/com/fastbee/controller/sip/SipRelationController.java +++ b/fastbee-open-api/src/main/java/com/fastbee/controller/sip/SipRelationController.java @@ -6,12 +6,14 @@ import com.fastbee.common.core.page.TableDataInfo; import com.fastbee.common.utils.poi.ExcelUtil; import com.fastbee.iot.domain.SipRelation; import com.fastbee.iot.service.ISipRelationService; +import com.fastbee.sip.service.ISipSecurityService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; import java.util.List; @@ -28,6 +30,9 @@ public class SipRelationController extends BaseController { @Autowired private ISipRelationService sipRelationService; + @Resource + private ISipSecurityService sipSecurityService; + /** * 查询监控设备关联列表 */ @@ -98,4 +103,14 @@ public class SipRelationController extends BaseController { public AjaxResult remove(@PathVariable Long[] ids) { return toAjax(sipRelationService.deleteSipRelationByIds(ids)); } + + // ================================= 解禁IP ================================= + + @PreAuthorize("@ss.hasPermi('iot:relation:edit')") + @GetMapping("/unblockIp") + @ApiOperation("删除监控设备关联") + public AjaxResult unblockIp(@RequestParam("ip") String ip) { + sipSecurityService.unblockIp(ip); + return success(); + } } diff --git a/fastbee-server/sip-server/pom.xml b/fastbee-server/sip-server/pom.xml index 6d907ecc..b41feefa 100644 --- a/fastbee-server/sip-server/pom.xml +++ b/fastbee-server/sip-server/pom.xml @@ -63,6 +63,12 @@ jackson-databind + + + com.fastbee + fastbee-common + + diff --git a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/handler/req/RegisterReqHandler.java b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/handler/req/RegisterReqHandler.java index d8ee9b4f..921fc05d 100644 --- a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/handler/req/RegisterReqHandler.java +++ b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/handler/req/RegisterReqHandler.java @@ -16,6 +16,7 @@ import com.fastbee.sip.service.IMqttService; import com.fastbee.sip.service.ISipConfigService; import com.fastbee.sip.service.ISipDeviceChannelService; import com.fastbee.sip.service.ISipDeviceService; +import com.fastbee.sip.service.ISipSecurityService; import com.fastbee.sip.util.DigestAuthUtil; import gov.nist.javax.sip.address.AddressImpl; import gov.nist.javax.sip.address.SipUri; @@ -23,11 +24,11 @@ import gov.nist.javax.sip.header.Expires; import gov.nist.javax.sip.header.SIPDateHeader; import gov.nist.javax.sip.message.SIPRequest; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; import javax.sip.InvalidArgumentException; import javax.sip.RequestEvent; @@ -70,42 +71,70 @@ public class RegisterReqHandler extends ReqAbstractHandler implements Initializi @Autowired private SIPSender sipSender; + @Autowired + private ISipSecurityService sipSecurityService; + @Override public void processMsg(RequestEvent evt) { try { - log.info("收到注册请求,开始处理"); Request request = evt.getRequest(); - SIPRequest Sipreq = (SIPRequest) evt.getRequest(); + SIPRequest sipReq = (SIPRequest) evt.getRequest(); Response response; - // 注册标志 0:未携带授权头或者密码错误 1:注册成功 2:注销成功 - int registerFlag; + + // ========== 安全防护检查 ========== + // 提取客户端IP + ViaHeader viaHeader = (ViaHeader) request.getHeader(ViaHeader.NAME); + String clientIp = viaHeader.getReceived(); + + if (StringUtils.isEmpty(clientIp)) { + clientIp = viaHeader.getHost(); + } + + // 检查IP是否在黑名单中 + if (sipSecurityService.isBlocked(clientIp)) { + log.debug("[SIP安全] IP {} 已封禁,拒绝REGISTER请求", clientIp); + return; // 直接丢弃,不响应 + } + + // 检查速率限制 FromHeader fromHeader = (FromHeader) request.getHeader(FromHeader.NAME); AddressImpl address = (AddressImpl) fromHeader.getAddress(); SipUri uri = (SipUri) address.getURI(); String sipId = uri.getUser(); + + if (sipSecurityService.checkAndRecord(clientIp, sipId)) { + log.debug("[SIP安全] IP {} 请求超限,拒绝请求,sipId: {}", clientIp, sipId); + return; // 直接丢弃,让攻击者超时等待 + } + + log.debug("收到注册请求,开始处理,sipId: {}, IP: {}", sipId, clientIp); + // ========== 安全防护检查结束 ========== + + // 注册标志 0:未携带授权头或者密码错误 1:注册成功 2:注销成功 + int registerFlag; //取默认Sip配置 SipConfig sipConfig = sipConfigService.selectSipConfigBydeviceSipId(sipId); if (sipConfig != null) { - AuthorizationHeader authorhead = (AuthorizationHeader) request.getHeader(AuthorizationHeader.NAME); + AuthorizationHeader authorHead = (AuthorizationHeader) request.getHeader(AuthorizationHeader.NAME); // 校验密码是否正确 - if (authorhead == null && !ObjectUtils.isEmpty(sipConfig.getPassword())) { - log.info("未携带授权头 回复401,sipId:{}", sipId); + if (authorHead == null && !ObjectUtils.isEmpty(sipConfig.getPassword())) { + log.debug("未携带授权头 回复401,sipId:{},IP:{}", sipId, clientIp); response = getMessageFactory().createResponse(Response.UNAUTHORIZED, request); new DigestAuthUtil().generateChallenge(getHeaderFactory(), response, sipConfig.getDomain()); - sipSender.transmitRequest(Sipreq.getLocalAddress().getHostAddress(), response); + sipSender.transmitRequest(sipReq.getLocalAddress().getHostAddress(), response); return; } - boolean pcheck = new DigestAuthUtil().doAuthenticatePlainTextPassword(request, + boolean preCheck = new DigestAuthUtil().doAuthenticatePlainTextPassword(request, sipConfig.getPassword()); - boolean syscheck = new DigestAuthUtil().doAuthenticatePlainTextPassword(request, + boolean sysCheck = new DigestAuthUtil().doAuthenticatePlainTextPassword(request, sysSipConfig.getPassword()); - if (!pcheck && !syscheck) { + if (!preCheck && !sysCheck) { // 注册失败 response = getMessageFactory().createResponse(Response.FORBIDDEN, request); response.setReasonPhrase("wrong password"); - log.info("[注册请求] 密码/SIP服务器ID错误, 回复403 sipId:" + sipId); + log.info("[注册请求] 密码/SIP服务器ID错误, 回复403 sipId:{}", sipId); getServerTransaction(evt).sendResponse(response); return; } @@ -122,7 +151,7 @@ public class RegisterReqHandler extends ReqAbstractHandler implements Initializi response.addHeader(request.getHeader(ContactHeader.NAME)); // 添加Expires头 response.addHeader(request.getExpires()); - ViaHeader viaHeader = (ViaHeader) request.getHeader(ViaHeader.NAME); + //ViaHeader viaHeader = (ViaHeader) request.getHeader(ViaHeader.NAME); String received = viaHeader.getReceived(); int rPort = viaHeader.getRPort(); // 本地模拟设备 received 为空 rPort 为 -1 @@ -153,20 +182,20 @@ public class RegisterReqHandler extends ReqAbstractHandler implements Initializi } device.setTransport(isTcp ? "TCP" : "UDP"); } - sipSender.transmitRequest(Sipreq.getLocalAddress().getHostAddress(), response); + sipSender.transmitRequest(sipReq.getLocalAddress().getHostAddress(), response); // 注册成功 if (registerFlag == 1) { - log.info("注册成功! sipId:" + device.getDeviceSipId()); + log.info("注册成功! sipId:{}", device.getDeviceSipId()); device.setRegistertime(DateUtils.getNowDate()); sipDeviceService.updateDevice(device); List channels = sipDeviceChannelService.selectSipDeviceChannelByDeviceSipId(device.getDeviceSipId()); - if (channels.size() > 0) { + if (!channels.isEmpty()) { Device iotdev = deviceService.selectDeviceBySerialNumber(device.getDeviceSipId()); if (iotdev == null) { //添加到统一设备管理 默认添加到admin账号下 int result = deviceService.insertDeviceAuto(device.getDeviceSipId(), 1L, sipConfig.getProductId()); if (result == 1) { - log.info("-----------sip设备认证成功,并自动添加设备到系统,SipId:" + device.getDeviceSipId() + "---------------"); + log.info("-----------sip设备认证成功,并自动添加设备到系统,SipId:{}---------------", device.getDeviceSipId()); } iotdev = new Device(); } @@ -177,10 +206,10 @@ public class RegisterReqHandler extends ReqAbstractHandler implements Initializi // 重新注册更新设备和通道,以免设备替换或更新后信息无法更新 onRegister(device); } else { - log.info("未找到改设备! deviceId:" + device.getDeviceId()); + log.info("未找到改设备! deviceId:{}", device.getDeviceId()); } } else if (registerFlag == 2) { - log.info("注销成功! deviceId:" + device.getDeviceId()); + log.info("注销成功! deviceId:{}", device.getDeviceId()); mqttService.publishStatus(device, 4); } diff --git a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/ISipSecurityService.java b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/ISipSecurityService.java new file mode 100644 index 00000000..4ccf9f16 --- /dev/null +++ b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/ISipSecurityService.java @@ -0,0 +1,60 @@ +package com.fastbee.sip.service; + +/** + * SIP安全防护服务接口 + * + * @author George + * @date 2026/7/23 + */ +public interface ISipSecurityService { + + /** + * Redis key前缀 - IP封禁黑名单 + */ + String BLOCK_PREFIX = "sip:block:"; + + /** + * Redis key前缀 - IP请求计数 + */ + String COUNT_PREFIX = "sip:count:"; + + /** + * 检查IP是否被封禁 + * + * @param ip 客户端IP + * @return true=已封禁, false=未封禁 + */ + boolean isBlocked(String ip); + + /** + * 记录请求并检查是否超过速率限制 + * + * @param ip 客户端IP + * @param sipId SIP设备ID + * @return true=超过限制触发封禁, false=正常 + */ + boolean checkAndRecord(String ip, String sipId); + + /** + * 手动封禁IP + * + * @param ip 客户端IP + * @param minutes 封禁时长(分钟) + */ + void blockIp(String ip, int minutes); + + /** + * 解除IP封禁 + * + * @param ip 客户端IP + */ + void unblockIp(String ip); + + /** + * 获取IP请求计数 + * + * @param ip 客户端IP + * @return 当前计数 + */ + long getRequestCount(String ip); +} \ No newline at end of file diff --git a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/SipSecurityServiceImpl.java b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/SipSecurityServiceImpl.java new file mode 100644 index 00000000..45e7c678 --- /dev/null +++ b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/SipSecurityServiceImpl.java @@ -0,0 +1,121 @@ +package com.fastbee.sip.service.impl; + +import com.fastbee.common.core.redis.RedisCache; +import com.fastbee.sip.service.ISipSecurityService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +/** + * SIP安全防护服务实现 + * 基于Redis实现IP速率限制和自动黑名单 + * + * @author George + * @date 2026/7/23 + */ +@Slf4j +@Service +public class SipSecurityServiceImpl implements ISipSecurityService { + + @Autowired + private RedisCache redisCache; + + /** + * 速率限制:每分钟最大请求数 + */ + @Value("${sip.security.rate-limit:20}") + private int rateLimit; + + /** + * 触发封禁的失败次数阈值 + */ + @Value("${sip.security.block-threshold:30}") + private int blockThreshold; + + /** + * 默认封禁时长(分钟) + */ + @Value("${sip.security.block-duration:120}") + private int blockDuration; + + @Override + public boolean isBlocked(String ip) { + if (ip == null || ip.isEmpty()) { + return false; + } + String key = BLOCK_PREFIX + ip; + return Boolean.TRUE.equals(redisCache.hasKey(key)); + } + + @Override + public boolean checkAndRecord(String ip, String sipId) { + if (ip == null || ip.isEmpty()) { + return false; + } + + // 1. 检查是否已封禁 + if (isBlocked(ip)) { + log.warn("[SIP安全] IP {} 已在黑名单中,拒绝请求", ip); + return true; + } + + // 2. 记录请求计数(1分钟窗口) + String countKey = COUNT_PREFIX + ip; + long count = redisCache.incr(countKey, 1); + + // 首次访问设置过期时间 + if (count == 1) { + redisCache.expire(countKey, 60, TimeUnit.SECONDS); + } + + // 3. 检查是否超过速率限制 + if (count > rateLimit) { + log.warn("[SIP安全] IP {} 请求频率超限,当前: {}/min,限制: {}/min", ip, count, rateLimit); + + // 超过阈值则自动封禁 + if (count >= blockThreshold) { + blockIp(ip, blockDuration); + log.warn("[SIP安全] IP {} 触发自动封禁,时长: {}分钟", ip, blockDuration); + } + return true; + } + + return false; + } + + @Override + public void blockIp(String ip, int minutes) { + if (ip == null || ip.isEmpty()) { + return; + } + String key = BLOCK_PREFIX + ip; + redisCache.setCacheObject(key, System.currentTimeMillis(), minutes, TimeUnit.MINUTES); + log.info("[SIP安全] 封禁IP: {},时长: {}分钟", ip, minutes); + } + + @Override + public void unblockIp(String ip) { + if (ip == null || ip.isEmpty()) { + return; + } + String key = BLOCK_PREFIX + ip; + redisCache.deleteObject(key); + log.info("[SIP安全] 解封IP: {}", ip); + } + + @Override + public long getRequestCount(String ip) { + if (ip == null || ip.isEmpty()) { + return 0; + } + String countKey = COUNT_PREFIX + ip; + Object obj = redisCache.getCacheObject(countKey); + if (obj == null) { + return 0; + } + return Long.parseLong(obj.toString()); + } +} \ No newline at end of file