From 5ffa0cee7c934cfc0e88a2cbd2eeb9127423c4f9 Mon Sep 17 00:00:00 2001 From: htjcAdmin Date: Wed, 13 Aug 2025 14:10:11 +0800 Subject: [PATCH] =?UTF-8?q?/*B=E6=8E=A5=E5=8F=A3SIP=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E4=BB=A3=E7=A0=81=E5=88=9D=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4*/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + pom.xml | 25 + .../sip/config/AccountManagerImpl.java | 27 + .../tcpserver/sip/config/SipConfig.java | 36 ++ .../sip/items/RequestHistoryAlarmItem.java | 33 + .../sip/items/RequestResourceItem.java | 18 + .../tcpserver/sip/items/SipEventRegistry.java | 18 + .../sip/service/SipClientService.java | 583 ++++++++++++++++++ .../tcpserver/sip/service/SipVideoClient.java | 229 +++++++ .../tcpserver/sip/utils/DigestUtil.java | 62 ++ .../tcpserver/sip/utils/SipXmlEnvelope.java | 20 + .../tcpserver/sip/utils/SipXmlParser.java | 29 + src/main/resources/application-dev.yml | 11 + src/main/resources/application.yml | 11 + 14 files changed, 1103 insertions(+) create mode 100644 src/main/java/com/inspect/tcpserver/sip/config/AccountManagerImpl.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/config/SipConfig.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/items/RequestHistoryAlarmItem.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/items/RequestResourceItem.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/items/SipEventRegistry.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/service/SipClientService.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/service/SipVideoClient.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/utils/DigestUtil.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/utils/SipXmlEnvelope.java create mode 100644 src/main/java/com/inspect/tcpserver/sip/utils/SipXmlParser.java diff --git a/.gitignore b/.gitignore index 9ca24a1..f1d9b30 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ build/ /log/ /logs/ /logs/inspect-tcpserver/info.log +sipserverlog.txt diff --git a/pom.xml b/pom.xml index 3e9dab4..64bd741 100644 --- a/pom.xml +++ b/pom.xml @@ -113,6 +113,31 @@ okhttp 4.11.0 + + + javax.sip + jain-sip-ri + 1.2.279 + + + + org.slf4j + log4j-over-slf4j + 1.7.36 + + + + log4j + log4j + 1.2.17 + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + diff --git a/src/main/java/com/inspect/tcpserver/sip/config/AccountManagerImpl.java b/src/main/java/com/inspect/tcpserver/sip/config/AccountManagerImpl.java new file mode 100644 index 0000000..0c20786 --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/config/AccountManagerImpl.java @@ -0,0 +1,27 @@ +package com.inspect.tcpserver.sip.config; + +import gov.nist.javax.sip.clientauthutils.AccountManager; +import gov.nist.javax.sip.clientauthutils.UserCredentials; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.sip.ClientTransaction; + +@Component +public class AccountManagerImpl implements AccountManager { + + @Value("${sip.username}") + private String username; + + @Value("${sip.password}") + private String password; + + @Value("${sip.domain}") + private String domain; + + @Override + public UserCredentials getCredentials(ClientTransaction clientTransaction, String s) { + + return null; + } +} diff --git a/src/main/java/com/inspect/tcpserver/sip/config/SipConfig.java b/src/main/java/com/inspect/tcpserver/sip/config/SipConfig.java new file mode 100644 index 0000000..8dca165 --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/config/SipConfig.java @@ -0,0 +1,36 @@ +package com.inspect.tcpserver.sip.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sip.SipFactory; +import javax.sip.SipStack; +import java.util.Properties; + +@Configuration +public class SipConfig { + + @Value("${sip.local-ip}") + private String localIp; + + @Value("${sip.transport}") + private String transport; + + @Bean + public SipStack sipStack() throws Exception { + SipFactory sipFactory = SipFactory.getInstance(); + sipFactory.setPathName("gov.nist"); + + Properties properties = new Properties(); + properties.setProperty("javax.sip.STACK_NAME", "spring-boot-sip-stack"); + properties.setProperty("gov.nist.javax.sip.IP_ADDRESS", localIp); + // 日志文件 + properties.setProperty("gov.nist.javax.sip.DEBUG_LOG", "sipdebug.txt"); + properties.setProperty("gov.nist.javax.sip.SERVER_LOG", "sipserverlog.txt"); + // 延迟/线程池配置 + properties.setProperty("gov.nist.javax.sip.THREAD_POOL_SIZE", "20"); + + return sipFactory.createSipStack(properties); + } +} diff --git a/src/main/java/com/inspect/tcpserver/sip/items/RequestHistoryAlarmItem.java b/src/main/java/com/inspect/tcpserver/sip/items/RequestHistoryAlarmItem.java new file mode 100644 index 0000000..63ccec8 --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/items/RequestHistoryAlarmItem.java @@ -0,0 +1,33 @@ +package com.inspect.tcpserver.sip.items; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RequestHistoryAlarmItem { + @JacksonXmlProperty(isAttribute = true, localName = "Code") + private String code; + + @JacksonXmlProperty(isAttribute = true, localName = "UserCode") + private String userCode; + + @JacksonXmlProperty(isAttribute = true, localName = "Type") + private String type; + + @JacksonXmlProperty(isAttribute = true, localName = "BeginTime") + private String beginTime; + + @JacksonXmlProperty(isAttribute = true, localName = "EndTime") + private String endTime; + + @JacksonXmlProperty(isAttribute = true, localName = "Level") + private String level; + + @JacksonXmlProperty(isAttribute = true, localName = "FromIndex") + private int fromIndex; + + @JacksonXmlProperty(isAttribute = true, localName = "ToIndex") + private int toIndex; +} diff --git a/src/main/java/com/inspect/tcpserver/sip/items/RequestResourceItem.java b/src/main/java/com/inspect/tcpserver/sip/items/RequestResourceItem.java new file mode 100644 index 0000000..6102d9a --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/items/RequestResourceItem.java @@ -0,0 +1,18 @@ +package com.inspect.tcpserver.sip.items; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RequestResourceItem { + @JacksonXmlProperty(isAttribute = true, localName = "Code") + private String code; + + @JacksonXmlProperty(isAttribute = true, localName = "FromIndex") + private int fromIndex; + + @JacksonXmlProperty(isAttribute = true, localName = "ToIndex") + private int toIndex; +} diff --git a/src/main/java/com/inspect/tcpserver/sip/items/SipEventRegistry.java b/src/main/java/com/inspect/tcpserver/sip/items/SipEventRegistry.java new file mode 100644 index 0000000..bd9ba8f --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/items/SipEventRegistry.java @@ -0,0 +1,18 @@ +package com.inspect.tcpserver.sip.items; + +import java.util.HashMap; +import java.util.Map; + +public class SipEventRegistry { + private static final Map> EVENT_MAP = new HashMap<>(); + + static { + EVENT_MAP.put("Request_Resource", RequestResourceItem.class); + EVENT_MAP.put("Request_History_Alarm", RequestHistoryAlarmItem.class); + } + + public static Class getItemClass(String eventType) { + return EVENT_MAP.get(eventType); + } +} + diff --git a/src/main/java/com/inspect/tcpserver/sip/service/SipClientService.java b/src/main/java/com/inspect/tcpserver/sip/service/SipClientService.java new file mode 100644 index 0000000..dbe6ad3 --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/service/SipClientService.java @@ -0,0 +1,583 @@ +package com.inspect.tcpserver.sip.service; + +import com.inspect.tcpserver.sip.items.SipEventRegistry; +import com.inspect.tcpserver.sip.utils.DigestUtil; +import com.inspect.tcpserver.sip.utils.SipXmlEnvelope; +import com.inspect.tcpserver.sip.utils.SipXmlParser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.sip.*; +import javax.sip.address.Address; +import javax.sip.address.AddressFactory; +import javax.sip.address.SipURI; +import javax.sip.header.*; +import javax.sip.message.MessageFactory; +import javax.sip.message.Request; +import javax.sip.message.Response; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Service +@ConditionalOnProperty(name = "sip.enable", havingValue = "true") +public class SipClientService implements SipListener { + + private SipFactory sipFactory; + private SipStack sipStack; + private SipProvider sipProvider; + private MessageFactory messageFactory; + private HeaderFactory headerFactory; + private AddressFactory addressFactory; + + // 保存上次请求信息,重发用 + private String lastDeviceCode; + private String lastServerIp; + private int lastServerPort; + private String lastXmlBody; + + // 保存 WWW-Authenticate 参数 + private String lastNonce; + private String lastRealm; + private String lastOpaque; + private String lastQop; + + + @Value("${sip.username}") + private String username; + + @Value("${sip.password}") + private String password; + + @Value("${sip.domain}") + private String domain; + + @Value("${sip.port}") + private int port; + + @Value("${sip.transport}") + private String transport; + + @Value("${sip.local-ip}") + private String localIp; + + @Value("${sip.local-port}") + private int localPort; + + @Value("${sip.expires}") + private int expires; + + private ClientTransaction lastRegisterTransaction; + private Timer refreshTimer = new Timer(true); + private String fromTag = Long.toHexString(System.currentTimeMillis()); + + private AtomicInteger cSeqCounter = new AtomicInteger(1); + + String testXml = "\n" + + "\n" + + " 1234567890\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + + + @PostConstruct + public void init() throws Exception { + sipFactory = SipFactory.getInstance(); + sipFactory.setPathName("gov.nist"); + + headerFactory = sipFactory.createHeaderFactory(); + addressFactory = sipFactory.createAddressFactory(); + messageFactory = sipFactory.createMessageFactory(); + + Properties properties = new Properties(); + properties.setProperty("javax.sip.STACK_NAME", "spring-boot-sip-stack"); + properties.setProperty("gov.nist.javax.sip.IP_ADDRESS", localIp); + properties.setProperty("gov.nist.javax.sip.DEBUG_LOG", "sipdebug.txt"); + properties.setProperty("gov.nist.javax.sip.SERVER_LOG", "sipserverlog.txt"); + + sipStack = sipFactory.createSipStack(properties); + + ListeningPoint lp = sipStack.createListeningPoint(localIp, localPort, transport); + + sipProvider = sipStack.createSipProvider(lp); + sipProvider.addSipListener(this); + + sendRegister(null); + } + + public void sendRegister(AuthorizationHeader authHeader) throws Exception { + SipURI requestUri = addressFactory.createSipURI(null, domain); + requestUri.setTransportParam(transport.toUpperCase()); + requestUri.setPort(port); + + Address fromAddress = addressFactory.createAddress(username, addressFactory.createSipURI(username, domain)); + FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, fromTag); + Address toAddress = addressFactory.createAddress(username, addressFactory.createSipURI(username, domain)); + ToHeader toHeader = headerFactory.createToHeader(toAddress, null); + + CallIdHeader callId = sipProvider.getNewCallId(); + CSeqHeader cSeq = headerFactory.createCSeqHeader(1L, Request.REGISTER); + MaxForwardsHeader maxForwards = headerFactory.createMaxForwardsHeader(70); + + // 创建带自定义deviceid参数的Contact URI + SipURI contactUri = addressFactory.createSipURI(username, localIp); + contactUri.setPort(localPort); + contactUri.setTransportParam(transport.toUpperCase()); + contactUri.setParameter("deviceid", "123456"); + + Address contactAddress = addressFactory.createAddress(contactUri); + ContactHeader contactHeader = headerFactory.createContactHeader(contactAddress); + ExpiresHeader expiresHeader = headerFactory.createExpiresHeader(expires); + + List viaHeaders = new ArrayList<>(); + ViaHeader viaHeader = headerFactory.createViaHeader(localIp, localPort, transport.toUpperCase(), null); + viaHeaders.add(viaHeader); + + Request request = messageFactory.createRequest( + requestUri, + Request.REGISTER, + callId, + cSeq, + fromHeader, + toHeader, + viaHeaders, + maxForwards + ); + + request.addHeader(contactHeader); + request.addHeader(expiresHeader); + + // 添加自定义头,携带设备编号 + Header deviceIdHeader = headerFactory.createHeader("X-Device-ID", "123456"); // 设备编号 + request.addHeader(deviceIdHeader); + if (authHeader != null) { + request.addHeader(authHeader); + } + + lastRegisterTransaction = sipProvider.getNewClientTransaction(request); + lastRegisterTransaction.sendRequest(); + } + + public void sendXmlResource(String targetSipUri, String xml) throws Exception { + log.info("Sending XML resource to {}", targetSipUri); + + SipURI fromUri = addressFactory.createSipURI(username, domain + ":" + port); + Address fromAddress = addressFactory.createAddress(fromUri); + FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, fromTag); + + javax.sip.address.URI toUri = addressFactory.createURI(targetSipUri); + Address toAddress = addressFactory.createAddress(toUri); + ToHeader toHeader = headerFactory.createToHeader(toAddress, null); + + // 创建空的Via头列表 + List viaHeaders = new ArrayList<>(); + + Request message = messageFactory.createRequest( + toUri, + Request.MESSAGE, + sipProvider.getNewCallId(), + headerFactory.createCSeqHeader(1L, Request.MESSAGE), + fromHeader, + toHeader, + viaHeaders, + headerFactory.createMaxForwardsHeader(70) + ); + + // 手动创建Via头并添加 + ViaHeader viaHeader = headerFactory.createViaHeader(localIp, localPort, "UDP", null); + message.addHeader(viaHeader); + + ContentTypeHeader ct = headerFactory.createContentTypeHeader("Application", "MANSCDP+xml"); + message.setContent(xml, ct); + + ClientTransaction tx = sipProvider.getNewClientTransaction(message); + tx.sendRequest(); + } + + + @Override + public void processResponse(ResponseEvent responseEvent) { + Response response = responseEvent.getResponse(); + int status = response.getStatusCode(); + log.info("Received response: {}", status); + CSeqHeader cSeqHeader = (CSeqHeader) response.getHeader(CSeqHeader.NAME); + if (cSeqHeader == null) return; + String method = cSeqHeader.getMethod(); + + try { + if (Request.REGISTER.equalsIgnoreCase(method)) { + if (status == 401 || status == 407) { + log.info("Received 401/407, attempt auth..."); + + WWWAuthenticateHeader wwwAuth = (WWWAuthenticateHeader) response.getHeader(WWWAuthenticateHeader.NAME); + if (wwwAuth == null) { + log.error("No WWW-Authenticate header found, cannot authenticate"); + return; + } + + String realm = wwwAuth.getRealm(); + String nonce = wwwAuth.getNonce(); + String qop = wwwAuth.getQop(); + + String uri = "sip:" + domain + ":" + port; + + String nc = "00000001"; + String cNonce = generateCNonce(); + + String responseDigest = DigestUtil.computeResponse( + username, + password, + realm, + nonce, + "REGISTER", + uri, + nc, + cNonce, + qop + ); + + AuthorizationHeader authHeader = headerFactory.createAuthorizationHeader("Digest"); + authHeader.setUsername(username); + authHeader.setRealm(realm); + authHeader.setNonce(nonce); + authHeader.setURI(addressFactory.createURI(uri)); + authHeader.setResponse(responseDigest); + + if (qop != null) { + authHeader.setQop(qop); + authHeader.setCNonce(cNonce); + authHeader.setNonceCount(Integer.parseInt(nc, 16)); + } + + sendRegister(authHeader); + + } else if (status >= 200 && status < 300) { + log.info("REGISTER success: {}", status); + int delay = Math.max(5, expires - 60); + refreshTimer.schedule(new TimerTask() { + @Override + public void run() { + try { + sendRegister(null); + } catch (Exception e) { + log.error("Failed to refresh register", e); + } + } + }, delay * 1000L); + + sendNotify(username, domain, port, testXml); + } else { + log.warn("REGISTER response: {}", status); + } + } else if (Request.MESSAGE.equalsIgnoreCase(method)) { + log.info("MESSAGE response: {}", status); + } else if (Request.NOTIFY.equalsIgnoreCase(method)) { + log.info("NOTIFY response: {}", status); + if (status == 401) { + WWWAuthenticateHeader wwwAuth = (WWWAuthenticateHeader) response.getHeader(WWWAuthenticateHeader.NAME); + if (wwwAuth != null) { + lastNonce = wwwAuth.getNonce(); + lastRealm = wwwAuth.getRealm(); + lastOpaque = wwwAuth.getOpaque(); + lastQop = wwwAuth.getQop(); + try { + resendNotifyWithAuth(); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + } catch (Exception ex) { + log.error("processResponse error", ex); + } + } + + private void resendNotifyWithAuth() throws Exception { + String method = Request.NOTIFY; + String uriStr = lastDeviceCode + "@" + lastServerIp; + String nc = "00000001"; + String cNonce = md5Hex(Long.toString(System.currentTimeMillis())); + + String responseDigest = computeResponse( + username, + lastRealm, + password, + method, + "sip:" + uriStr, + lastNonce, + nc, + cNonce, + lastQop != null ? lastQop : "auth" + ); + + AuthorizationHeader authHeader = headerFactory.createAuthorizationHeader("Digest"); + authHeader.setUsername(username); + authHeader.setRealm(lastRealm); + authHeader.setNonce(lastNonce); + authHeader.setURI(addressFactory.createURI("sip:" + uriStr)); + authHeader.setResponse(responseDigest); + authHeader.setAlgorithm("MD5"); + authHeader.setCNonce(cNonce); + if (lastOpaque != null) authHeader.setOpaque(lastOpaque); + authHeader.setQop(lastQop != null ? lastQop : "auth"); + authHeader.setNonceCount(1); + + Request requestWithAuth = createNotifyRequest(lastDeviceCode, lastServerIp, lastServerPort, lastXmlBody, authHeader); + ClientTransaction transaction = sipProvider.getNewClientTransaction(requestWithAuth); + transaction.sendRequest(); + } + + // 构造NOTIFY请求 + private Request createNotifyRequest(String deviceCode, String serverIp, int serverPort, String xmlBody, AuthorizationHeader authHeader) throws Exception { + String notifierUser = "290010021201070000"; + String localHost = this.localIp; + int localPort = this.localPort; + + SipURI requestUri = addressFactory.createSipURI(deviceCode, serverIp); + requestUri.setTransportParam("tcp"); + requestUri.setPort(serverPort); + + Address fromAddress = addressFactory.createAddress("sip:" + username + "@" + localHost); + FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, UUID.randomUUID().toString().substring(0, 8)); + + Address toAddress = addressFactory.createAddress("sip:" + deviceCode + "@" + serverIp); + ToHeader toHeader = headerFactory.createToHeader(toAddress, null); + + CallIdHeader callId = sipProvider.getNewCallId(); + CSeqHeader cSeq = headerFactory.createCSeqHeader(cSeqCounter.getAndIncrement(), Request.NOTIFY); + MaxForwardsHeader maxForwards = headerFactory.createMaxForwardsHeader(70); + + List viaHeaders = new ArrayList<>(); + ViaHeader viaHeader = headerFactory.createViaHeader(localHost, localPort, transport, null); + viaHeaders.add(viaHeader); + + Address contactAddr = addressFactory.createAddress("sip:" + username + "@" + localHost + ":" + localPort); + ContactHeader contactHeader = headerFactory.createContactHeader(contactAddr); + + EventHeader eventHeader = headerFactory.createEventHeader("Push_Resource"); + + SubscriptionStateHeader subscriptionStateHeader = headerFactory.createSubscriptionStateHeader(SubscriptionStateHeader.TERMINATED); + + ContentTypeHeader contentTypeHeader = headerFactory.createContentTypeHeader("application", "xml"); + + Request request = messageFactory.createRequest( + requestUri, + Request.NOTIFY, + callId, + cSeq, + fromHeader, + toHeader, + viaHeaders, + maxForwards + ); + + request.addHeader(contactHeader); + request.addHeader(eventHeader); + request.addHeader(subscriptionStateHeader); + request.addHeader(contentTypeHeader); + request.setContent(xmlBody, contentTypeHeader); + + if (authHeader != null) { + request.addHeader(authHeader); + } + + return request; + } + + private String generateCNonce() { + return Long.toHexString(System.currentTimeMillis() & 0xffffffffL); + } + + @Override + public void processRequest(RequestEvent requestEvent) { + Request request = requestEvent.getRequest(); + String method = request.getMethod(); + log.info("Received SIP request: {}", method); + try { + if (Request.MESSAGE.equalsIgnoreCase(method)) { + log.info("Processing MESSAGE request from {}", ((FromHeader) request.getHeader(FromHeader.NAME)).getAddress()); + + ContentTypeHeader contentTypeHeader = (ContentTypeHeader) request.getHeader(ContentTypeHeader.NAME); + if (contentTypeHeader != null && + "application".equalsIgnoreCase(contentTypeHeader.getContentType()) && + "xml".equalsIgnoreCase(contentTypeHeader.getContentSubType())) { + + String xml = new String(request.getRawContent(), StandardCharsets.UTF_8); + xml = xml.replace("”", "\"").replace("“", "\""); + log.info("MESSAGE body:\n{}", xml); + + String eventType = SipXmlParser.peekEventType(xml); + log.info("RECOGNIZE EventType = {}", eventType); + + Class itemClass = SipEventRegistry.getItemClass(eventType); + if (itemClass == null) { + log.warn("UNREGISTERED EventType: {}", eventType); + return; + } + + SipXmlEnvelope envelope = SipXmlParser.parse(xml, itemClass); + //handleSipEvent(envelope); + } + + } else { + Response notImpl = messageFactory.createResponse(Response.NOT_IMPLEMENTED, request); + requestEvent.getServerTransaction().sendResponse(notImpl); + log.warn("Method {} not implemented, replied 501", method); + } + } catch (Exception ex) { + log.error("Error processing request: {}", ex.getMessage(), ex); + try { + Response errorResponse = messageFactory.createResponse(Response.SERVER_INTERNAL_ERROR, request); + requestEvent.getServerTransaction().sendResponse(errorResponse); + } catch (Exception e) { + log.error("Failed to send error response", e); + } + } + } + + @Override + public void processTimeout(TimeoutEvent timeoutEvent) { + log.warn("SIP timeout: {}", timeoutEvent); + } + + @Override + public void processIOException(IOExceptionEvent exceptionEvent) { + log.error("SIP IO Exception: {}", exceptionEvent); + } + + @Override + public void processTransactionTerminated(TransactionTerminatedEvent tte) { + } + + @Override + public void processDialogTerminated(DialogTerminatedEvent dte) { + } + + @PreDestroy + public void shutdown() { + log.info("Shutting down SIP stack..."); + refreshTimer.cancel(); + if (sipProvider != null) sipProvider.removeSipListener(this); + if (sipStack != null) sipStack.stop(); + } + + + /** + * 发送 SIP NOTIFY 消息 + * + * @param deviceCode 客户端编码(也是 SIP 用户名) + * @param serverIp 服务器 IP + * @param serverPort 服务器 平台端口 + * @param xmlBody XML 消息体 + * @throws Exception + */ + // 公开调用,发送NOTIFY(首次不带认证) + public void sendNotify(String deviceCode, String serverIp, int serverPort, String xmlBody) throws Exception { + lastDeviceCode = deviceCode; + lastServerIp = serverIp; + lastServerPort = serverPort; + lastXmlBody = xmlBody; + + Request request = createNotifyRequest(deviceCode, serverIp, serverPort, xmlBody, null); + ClientTransaction transaction = sipProvider.getNewClientTransaction(request); + transaction.sendRequest(); + } + + /** + * 分片 XML(保证每块 <= maxBytes) + */ + private List splitXml(String xml, int maxBytes) throws UnsupportedEncodingException { + List parts = new ArrayList<>(); + byte[] data = xml.getBytes("UTF-8"); + int offset = 0; + while (offset < data.length) { + int len = Math.min(maxBytes, data.length - offset); + parts.add(new String(data, offset, len, "UTF-8")); + offset += len; + } + return parts; + } + + // 计算Digest响应值 + private String computeResponse(String username, String realm, String password, String method, + String uri, String nonce, String nc, String cNonce, String qop) throws Exception { + MessageDigest md = MessageDigest.getInstance("MD5"); + + String ha1 = md5Hex(md, username + ":" + realm + ":" + password); + String ha2 = md5Hex(md, method + ":" + uri); + return md5Hex(md, ha1 + ":" + nonce + ":" + nc + ":" + cNonce + ":" + qop + ":" + ha2); + } + + private String md5Hex(String data) throws Exception { + MessageDigest md = MessageDigest.getInstance("MD5"); + return md5Hex(md, data); + } + + private String md5Hex(MessageDigest md, String data) { + byte[] digest = md.digest(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/inspect/tcpserver/sip/service/SipVideoClient.java b/src/main/java/com/inspect/tcpserver/sip/service/SipVideoClient.java new file mode 100644 index 0000000..8b6c0e7 --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/service/SipVideoClient.java @@ -0,0 +1,229 @@ +package com.inspect.tcpserver.sip.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.sip.*; +import javax.sip.address.Address; +import javax.sip.address.AddressFactory; +import javax.sip.address.SipURI; +import javax.sip.header.*; +import javax.sip.message.MessageFactory; +import javax.sip.message.Request; +import javax.sip.message.Response; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.Random; + +@Slf4j +@Component +@ConditionalOnProperty(name = "sip.enable", havingValue = "true") +public class SipVideoClient implements SipListener { + + private SipFactory sipFactory; + private SipStack sipStack; + private SipProvider sipProvider; + private HeaderFactory headerFactory; + private AddressFactory addressFactory; + private MessageFactory messageFactory; + + @Value("${sip.local-ip}") + private String localIp; // 本地IP + @Value("${sip.local-port2:5062}") + private int localPort; // 本地监听端口 + @Value("${sip.domain}") + private String remoteIp; // Asterisk IP + @Value("${sip.port}") + private int remotePort; // Asterisk端口 + + @Value("${sip.username}") + private String username; + @Value("${sip.domain}") + private String domain; + @Value("${sip.transport}") + private String transport; + + @PostConstruct + public void init() throws Exception { + sipFactory = SipFactory.getInstance(); + sipFactory.setPathName("gov.nist"); + + headerFactory = sipFactory.createHeaderFactory(); + addressFactory = sipFactory.createAddressFactory(); + messageFactory = sipFactory.createMessageFactory(); + + Properties properties = new Properties(); + properties.setProperty("javax.sip.STACK_NAME", "sip-video-client"); + properties.setProperty("gov.nist.javax.sip.IP_ADDRESS", localIp); + + sipStack = sipFactory.createSipStack(properties); + + ListeningPoint lp = sipStack.createListeningPoint(localIp, localPort, transport); + sipProvider = sipStack.createSipProvider(lp); + sipProvider.addSipListener(this); + } + + public void sendInvite() throws Exception { + SipURI requestUri = addressFactory.createSipURI(null, domain); + requestUri.setTransportParam(transport); + requestUri.setPort(remotePort); + + Address fromAddress = addressFactory.createAddress("sip:" + username + "@" + domain); + FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, Integer.toHexString(new Random().nextInt())); + + Address toAddress = addressFactory.createAddress("sip:" + username + "@" + domain); + ToHeader toHeader = headerFactory.createToHeader(toAddress, null); + + CallIdHeader callIdHeader = sipProvider.getNewCallId(); + CSeqHeader cSeqHeader = headerFactory.createCSeqHeader(1L, Request.INVITE); + MaxForwardsHeader maxForwards = headerFactory.createMaxForwardsHeader(70); + + List viaHeaders = new ArrayList<>(); + viaHeaders.add(headerFactory.createViaHeader(localIp, localPort, transport, null)); + + Request inviteRequest = messageFactory.createRequest( + requestUri, + Request.INVITE, + callIdHeader, + cSeqHeader, + fromHeader, + toHeader, + viaHeaders, + maxForwards); + + // 添加Contact头 + Address contactAddress = addressFactory.createAddress("sip:" + username + "@" + localIp + ":" + localPort); + ContactHeader contactHeader = headerFactory.createContactHeader(contactAddress); + inviteRequest.addHeader(contactHeader); + + // 添加SDP Body — 这描述视频流的RTP信息 + String sdpData = buildSdpBody(localIp, 9000); // 本地RTP端口9000 + ContentTypeHeader contentTypeHeader = headerFactory.createContentTypeHeader("application", "sdp"); + inviteRequest.setContent(sdpData, contentTypeHeader); + + ClientTransaction inviteTransaction = sipProvider.getNewClientTransaction(inviteRequest); + inviteTransaction.sendRequest(); + } + + private String buildSdpBody(String ip, int rtpPort) { + return "v=0\r\n" + + "o=- 0 0 IN IP4 " + ip + "\r\n" + + "s=Video Call\r\n" + + "c=IN IP4 " + ip + "\r\n" + + "t=0 0\r\n" + + "m=video " + rtpPort + " RTP/AVP 96\r\n" + + "a=rtpmap:96 H264/90000\r\n" + + "a=sendrecv\r\n"; + } + + @Override + public void processResponse(ResponseEvent responseEvent) { + Response response = responseEvent.getResponse(); + CSeqHeader cSeq = (CSeqHeader) response.getHeader(CSeqHeader.NAME); + int statusCode = response.getStatusCode(); + log.info("Received response: {} for: {}", statusCode, cSeq.getMethod()); + if (statusCode == 200 && Request.INVITE.equals(cSeq.getMethod())) { + try { + ClientTransaction ct = responseEvent.getClientTransaction(); + Dialog dialog = ct.getDialog(); + + Request ackRequest = dialog.createAck(cSeq.getSeqNumber()); + dialog.sendAck(ackRequest); + + log.info("Sent ACK, 开始推送视频流"); + startVideoStreaming(); + + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + @Override + public void processRequest(RequestEvent requestEvent) { + Request request = requestEvent.getRequest(); + String method = request.getMethod(); + log.info("Received request: {}", method); + + if (Request.BYE.equals(method)) { + try { + ServerTransaction st = requestEvent.getServerTransaction(); + if (st == null) { + st = sipProvider.getNewServerTransaction(request); + } + Response response = messageFactory.createResponse(200, request); + st.sendResponse(response); + + log.info("Received BYE,通话结束,停止推流"); + stopVideoStreaming(); + + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + @Override + public void processTimeout(TimeoutEvent timeoutEvent) {} + + @Override + public void processIOException(IOExceptionEvent exceptionEvent) {} + + @Override + public void processTransactionTerminated(TransactionTerminatedEvent transactionTerminatedEvent) {} + + @Override + public void processDialogTerminated(DialogTerminatedEvent dialogTerminatedEvent) {} + + private Process ffmpegProcess; + + private void startVideoStreaming() { + try { + String rtspUrl = "rtsp://localhost:8554/mystream"; + + ProcessBuilder pb = new ProcessBuilder( + "ffmpeg", + "-rtsp_transport", "tcp", // 如果rtsp拉流失败可试试tcp + "-i", rtspUrl, + "-vcodec", "copy", + "-an", + "-f", "rtp", + "rtp://" + remoteIp + ":9000" + ); + + pb.redirectErrorStream(true); + ffmpegProcess = pb.start(); + + // 启动一个线程打印FFMPEG日志 + new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(ffmpegProcess.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.info("[FFMPEG] {}", line); + } + } catch (IOException e) { + e.printStackTrace(); + } + }).start(); + + log.info("视频推流开始"); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void stopVideoStreaming() { + if (ffmpegProcess != null) { + ffmpegProcess.destroy(); + System.out.println("视频推流停止"); + } + } +} + diff --git a/src/main/java/com/inspect/tcpserver/sip/utils/DigestUtil.java b/src/main/java/com/inspect/tcpserver/sip/utils/DigestUtil.java new file mode 100644 index 0000000..fc147dd --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/utils/DigestUtil.java @@ -0,0 +1,62 @@ +package com.inspect.tcpserver.sip.utils; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Formatter; + +public class DigestUtil { + + /** + * 计算Digest响应值 + * @param username 用户名 + * @param password 密码 + * @param realm 域 + * @param nonce 随机数 + * @param method SIP请求方法,如REGISTER + * @param uri 请求URI + * @param nc 请求计数(可以传null或"00000001"初始值) + * @param cnonce 客户端随机串(可传null) + * @param qop 质量保护参数(可传null) + * @return response摘要字符串 + */ + public static String computeResponse(String username, String password, String realm, String nonce, + String method, String uri, String nc, String cnonce, String qop) { + try { + String ha1 = md5Hex(username + ":" + realm + ":" + password); + String ha2 = md5Hex(method + ":" + uri); + + String response; + if (qop != null && (qop.equals("auth") || qop.equals("auth-int"))) { + response = md5Hex(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2); + } else { + response = md5Hex(ha1 + ":" + nonce + ":" + ha2); + } + return response; + } catch (Exception e) { + throw new RuntimeException("Failed to compute digest response", e); + } + } + + /** + * MD5加密,返回16进制字符串 + */ + private static String md5Hex(String data) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(data.getBytes()); + return byteArrayToHexString(digest); + } + + /** + * 将字节数组转为16进制字符串 + */ + private static String byteArrayToHexString(byte[] bytes) { + Formatter formatter = new Formatter(); + for (byte b : bytes) { + formatter.format("%02x", b); + } + String res = formatter.toString(); + formatter.close(); + return res; + } +} + diff --git a/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlEnvelope.java b/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlEnvelope.java new file mode 100644 index 0000000..91631a5 --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlEnvelope.java @@ -0,0 +1,20 @@ +package com.inspect.tcpserver.sip.utils; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class SipXmlEnvelope { + @JacksonXmlProperty(isAttribute = true, localName = "EventType") + private String eventType; + + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "Item") + private List items; +} + diff --git a/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlParser.java b/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlParser.java new file mode 100644 index 0000000..fd13301 --- /dev/null +++ b/src/main/java/com/inspect/tcpserver/sip/utils/SipXmlParser.java @@ -0,0 +1,29 @@ +package com.inspect.tcpserver.sip.utils; + +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SipXmlParser { + private static final XmlMapper XML_MAPPER = new XmlMapper(); + + public static String peekEventType(String xml) throws Exception { + EventTypeOnly temp = XML_MAPPER.readValue(xml, EventTypeOnly.class); + return temp.getEventType(); + } + + public static SipXmlEnvelope parse(String xml, Class itemClass) throws Exception { + return XML_MAPPER.readValue(xml, + XML_MAPPER.getTypeFactory().constructParametricType(SipXmlEnvelope.class, itemClass)); + } + + public static class EventTypeOnly { + @com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty(isAttribute = true, localName = "EventType") + private String eventType; + + public String getEventType() { return eventType; } + public void setEventType(String eventType) { this.eventType = eventType; } + } +} + + diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 7e19e3f..ce12085 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -45,3 +45,14 @@ minio: secret-key: Cgygs@2025 bucket-name: znxsxt-production-upload +sip: + enable: true + username: 290010021201070000 + password: 123456 + domain: 192.168.1.116 # 平台SIP服务IP或域名 + port: 5060 + transport: tcp # 也可以为udp + local-ip: 192.168.1.11 # 本服务的外网或可达IP + local-port: 5061 + expires: 3600 + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a6c5860..c1d06e7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -46,4 +46,15 @@ minio: secret-key: minioadmin bucket-name: mybucket +sip: + enable: true + username: 290010021201070000 + password: 123456 + domain: 192.168.1.116 # 平台SIP服务IP或域名 + port: 5060 + transport: tcp # 也可以为udp + local-ip: 192.168.1.11 # 本服务的外网或可达IP + local-port: 5061 + expires: 3600 +