├── .gitignore ├── src ├── test │ ├── resources │ │ ├── codecs.yml │ │ └── udf │ │ │ ├── BootstrapTest │ │ │ └── BootstrapTest.groovy │ │ │ ├── UdfManagerTest │ │ │ ├── UdfManagerTestV1.groovy │ │ │ └── UdfManagerTestV2.groovy │ │ │ ├── RouterTest │ │ │ └── RouterTest.groovy │ │ │ └── SipMessageHandlerTest │ │ │ └── SipMessageHandlerTest.groovy │ └── kotlin │ │ └── io │ │ └── sip3 │ │ └── salto │ │ └── ce │ │ ├── util │ │ ├── MediaUtilTest.kt │ │ ├── DurationUtilTest.kt │ │ ├── MediaAddressUtilTest.kt │ │ └── AttributeUtilTest.kt │ │ ├── MongoExtension.kt │ │ ├── MockKSingletonExtension.kt │ │ ├── domain │ │ └── AddressTest.kt │ │ ├── mongo │ │ └── MongoBulkWriterTest.kt │ │ ├── udf │ │ └── UdfManagerTest.kt │ │ ├── server │ │ ├── TcpServerTest.kt │ │ ├── UdpServerTest.kt │ │ └── AbstractServerTest.kt │ │ ├── management │ │ ├── AbstractServerTest.kt │ │ ├── component │ │ │ └── ComponentRegistryTest.kt │ │ ├── TcpServerTest.kt │ │ ├── UdpServerTest.kt │ │ └── host │ │ │ └── HostRegistryTest.kt │ │ ├── BootstrapTest.kt │ │ └── attributes │ │ └── AttributesRegistryTest.kt └── main │ ├── resources │ ├── vertx-options.json │ ├── logback-test.xml │ ├── codecs.yml │ ├── udf │ │ └── sip_message_udf.groovy │ ├── logback.xml │ └── application.yml │ └── kotlin │ └── io │ └── sip3 │ └── salto │ └── ce │ ├── util │ ├── MediaUtil.kt │ ├── MediaAddressUtil.kt │ ├── MediaDescriptionFieldUtil.kt │ ├── DurationUtil.kt │ ├── AttributeUtil.kt │ └── SIPMessageUtil.kt │ ├── sdp │ └── SessionDescriptionParser.kt │ ├── Bootstrap.kt │ ├── domain │ ├── Packet.kt │ └── Address.kt │ ├── RoutesCE.kt │ ├── Attributes.kt │ ├── management │ ├── AbstractServer.kt │ ├── UdpServer.kt │ ├── component │ │ └── ComponentRegistry.kt │ ├── TcpServer.kt │ └── host │ │ └── HostRegistry.kt │ ├── server │ ├── AbstractServer.kt │ ├── UdpServer.kt │ └── TcpServer.kt │ ├── mongo │ ├── MongoBulkWriter.kt │ └── MongoCollectionManager.kt │ ├── rtpr │ ├── RtprStream.kt │ └── RtprSession.kt │ ├── udf │ ├── UdfExecutor.kt │ └── UdfManager.kt │ ├── media │ └── MediaManager.kt │ ├── attributes │ └── AttributesRegistry.kt │ ├── router │ └── Router.kt │ ├── decoder │ ├── Decoder.kt │ └── HepDecoder.kt │ ├── sip │ └── SipMessageParser.kt │ └── recording │ └── RecordingHandler.kt ├── README.md └── pom.xml /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | *.ipr 4 | 5 | .vertx 6 | 7 | *.class 8 | target -------------------------------------------------------------------------------- /src/test/resources/codecs.yml: -------------------------------------------------------------------------------- 1 | codecs: 2 | - name: PcmA 3 | payload_types: [ 8 ] 4 | clock_rate: 8000 5 | ie: 0 6 | bpl: 4.3 -------------------------------------------------------------------------------- /src/main/resources/vertx-options.json: -------------------------------------------------------------------------------- 1 | { 2 | "metricsOptions": { 3 | "enabled": false, 4 | "jvmMetricsEnabled": false, 5 | "labels": [ 6 | "EB_ADDRESS", 7 | "EB_FAILURE", 8 | "EB_SIDE", 9 | "LOCAL", 10 | "REMOTE", 11 | "POOL_TYPE", 12 | "POOL_NAME", 13 | "CLASS_NAME" 14 | ], 15 | "influxDbOptions": { 16 | "enabled": true, 17 | "uri": "http://127.0.0.1:8086", 18 | "db": "sip3", 19 | "step": 5, 20 | "retention-duration": "7d" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{dd-MM-yyyy HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/test/resources/udf/BootstrapTest/BootstrapTest.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package udf.BootstrapTest 18 | 19 | vertx.eventBus().localConsumer("groovy", { event -> 20 | vertx.eventBus().send("kotlin", event.body()) 21 | }) -------------------------------------------------------------------------------- /src/test/resources/udf/UdfManagerTest/UdfManagerTestV1.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package udf.BootstrapTest 18 | 19 | vertx.eventBus().localConsumer("groovy", { event -> 20 | vertx.eventBus().send("groovy1", event.body()) 21 | }) -------------------------------------------------------------------------------- /src/test/resources/udf/UdfManagerTest/UdfManagerTestV2.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package udf.BootstrapTest 18 | 19 | vertx.eventBus().localConsumer("groovy", { event -> 20 | vertx.eventBus().send("groovy2", event.body()) 21 | }) -------------------------------------------------------------------------------- /src/test/resources/udf/RouterTest/RouterTest.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package udf.RouterTest 18 | 19 | vertx.eventBus().localConsumer("packet_udf", { event -> 20 | def packet = event.body() 21 | 22 | if (packet['sender_host'] == 'sip3-captain') { 23 | event.reply(true) 24 | } else { 25 | event.reply(false) 26 | } 27 | }) -------------------------------------------------------------------------------- /src/test/resources/udf/SipMessageHandlerTest/SipMessageHandlerTest.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package udf.SipMessageHandlerTest 18 | 19 | vertx.eventBus().localConsumer("sip_message_udf", { event -> 20 | def packet = event.body() 21 | 22 | def attributes = packet['attributes'] 23 | attributes['string'] = 'string' 24 | attributes['boolean'] = true 25 | attributes['user-agent'] = packet['payload']['user-agent'] 26 | 27 | event.reply(true) 28 | }) -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/util/MediaUtilTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | import org.junit.jupiter.api.Assertions.assertEquals 20 | import org.junit.jupiter.api.Test 21 | 22 | class MediaUtilTest { 23 | 24 | @Test 25 | fun `MoS calculation`() { 26 | assertEquals(MediaUtil.MOS_MIN, MediaUtil.computeMos(-1.0F)) 27 | assertEquals(MediaUtil.MOS_MAX, MediaUtil.computeMos(999.0F)) 28 | assertEquals(4.4F, MediaUtil.computeMos(92.729821F)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/util/MediaUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | object MediaUtil { 20 | 21 | const val R0 = 93.2F 22 | const val MOS_MIN = 1F 23 | const val MOS_MAX = 4.5F 24 | 25 | fun computeMos(rFactor: Float): Float { 26 | return when { 27 | rFactor < 0 -> MOS_MIN 28 | rFactor > 100F -> MOS_MAX 29 | else -> (1 + rFactor * 0.035 + rFactor * (100 - rFactor) * (rFactor - 60) * 0.000007).toFloat() 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/util/MediaAddressUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | import io.sip3.commons.domain.media.MediaAddress 20 | import io.sip3.salto.ce.domain.Address 21 | 22 | fun MediaAddress.rtpAddress(): Address { 23 | return Address().apply { 24 | addr = this@rtpAddress.addr 25 | port = rtpPort 26 | } 27 | } 28 | 29 | fun MediaAddress.rtcpAddress(): Address { 30 | return Address().apply { 31 | addr = this@rtcpAddress.addr 32 | port = rtcpPort 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/sdp/SessionDescriptionParser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.sdp 18 | 19 | import org.restcomm.media.sdp.SessionDescription 20 | import org.restcomm.media.sdp.SessionDescriptionParser 21 | 22 | object SessionDescriptionParser { 23 | 24 | val REGEX_EXCLUDE = Regex("(?m)^m=image.*(?:\\r?\\n)?") 25 | val REGEX_TCP = Regex("(?m)^(a=candidate:.*)TCP(.*)\$") 26 | 27 | fun parse(text: String?): SessionDescription { 28 | return SessionDescriptionParser.parse(text?.replace(REGEX_EXCLUDE, "")?.replace(REGEX_TCP, "$1tcp$2")) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/Bootstrap.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce 18 | 19 | import io.sip3.commons.vertx.AbstractBootstrap 20 | import io.sip3.salto.ce.udf.UdfManager 21 | import io.vertx.core.json.JsonObject 22 | 23 | open class Bootstrap : AbstractBootstrap() { 24 | 25 | override val configLocations = listOf("config.location", "codecs.location") 26 | 27 | override suspend fun deployVerticles(config: JsonObject) { 28 | super.deployVerticles(config) 29 | 30 | System.getProperty("udf.location")?.let { path -> 31 | UdfManager(vertx).start(path) 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/util/MediaDescriptionFieldUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | import org.restcomm.media.sdp.fields.MediaDescriptionField 20 | 21 | fun MediaDescriptionField.defineRtcpPort(isRtcpMux: Boolean): Int { 22 | return if (this.rtcp != null && this.rtcp?.port != this.port) { 23 | this.rtcp.port 24 | } else { 25 | if (this.rtcpMux != null && isRtcpMux) this.port else this.port + 1 26 | } 27 | } 28 | 29 | fun MediaDescriptionField.ptime(): Int? { 30 | return this.ptime?.time 31 | } 32 | 33 | fun MediaDescriptionField.address(): String { 34 | return this.connection.address 35 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/MongoExtension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce 18 | 19 | import org.junit.jupiter.api.extension.BeforeAllCallback 20 | import org.junit.jupiter.api.extension.ExtensionContext 21 | import org.testcontainers.containers.MongoDBContainer 22 | 23 | class MongoExtension : BeforeAllCallback { 24 | 25 | companion object { 26 | 27 | @JvmField 28 | val MONGODB_CONTAINER = MongoDBContainer("mongo:4.4").apply { 29 | start() 30 | } 31 | 32 | val MONGO_URI 33 | get() = "mongodb://${MONGODB_CONTAINER.host}:${MONGODB_CONTAINER.firstMappedPort}" 34 | } 35 | 36 | override fun beforeAll(context: ExtensionContext?) { 37 | // Do nothing 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SIP3 Salto CE # 2 | 3 | **SIP3 Salto** is the beating heart of the SIP3 platform - scalable and high performance event driven pipeline (based on 4 | Vert.x project which implements multi-reactor pattern) capable to send useful payload to various components and 5 | integration endpoints. 6 | 7 | ## 1. Prerequsites 8 | 9 | Before starting with `sip3-salto-ce`, make sure you have installed: 10 | 11 | * [Docker](https://docs.docker.com/get-docker/) 12 | * [Maven](https://maven.apache.org/install.html) 13 | * [sip3-parent](https://github.com/sip3io/sip3-parent) 14 | * [sip3-commons](https://github.com/sip3io/sip3-commons) 15 | 16 | ## 2. Building 17 | 18 | To build `sip3-salto-ce` executable jar run the following command: 19 | 20 | ``` 21 | mvn clean package -P executable-jar 22 | ``` 23 | 24 | ## 3. Documentation 25 | 26 | The entire SIP3 Documentation including `Installation Guide`, `Features`, `Tutorials` 27 | and `Realease Notes` can be found [here](https://sip3.io/docs/InstallationGuide.html). 28 | 29 | ## 4. Support 30 | 31 | If you have a question about SIP3, just leave us a message in our 32 | community [Slack](https://join.slack.com/t/sip3-community/shared_invite/enQtOTIyMjg3NDI0MjU3LWUwYzhlOTFhODYxMTEwNjllYjZjNzc1M2NmM2EyNDM0ZjJmNTVkOTg1MGQ3YmFmNWU5NjlhOGI3MWU1MzUwMjE) 33 | and [Telegram](https://t.me/sip3io), or send us an [email](mailto:support@sip3.io). We will be happy to help you. 34 | -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/util/DurationUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | import java.time.Duration 20 | 21 | object DurationUtil { 22 | 23 | fun parseDuration(duration: String): Duration { 24 | return when { 25 | duration.endsWith("ms") -> Duration.ofMillis(duration.substringBefore("ms").toLong()) 26 | duration.endsWith("s") -> Duration.ofSeconds(duration.substringBefore("s").toLong()) 27 | duration.endsWith("m") -> Duration.ofMinutes(duration.substringBefore("m").toLong()) 28 | duration.endsWith("h") -> Duration.ofHours(duration.substringBefore("h").toLong()) 29 | else -> throw IllegalArgumentException("Unsupported time format: $duration") 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/domain/Packet.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.domain 18 | 19 | import io.sip3.commons.PacketTypes 20 | 21 | class Packet { 22 | 23 | var createdAt: Long = 0 24 | var nanos: Int = 0 25 | 26 | var type = PacketTypes.SIP3 27 | 28 | lateinit var srcAddr: Address 29 | lateinit var dstAddr: Address 30 | 31 | lateinit var source: String 32 | 33 | var protocolCode: Byte = 0 34 | lateinit var payload: ByteArray 35 | 36 | var attributes: MutableMap? = null 37 | 38 | override fun toString(): String { 39 | return "Packet(createdAt=$createdAt, nanos=$nanos, srcAddr=$srcAddr, dstAddr=$dstAddr, source=$source, protocolCode=$protocolCode, payload=${payload.contentToString()}, attributes=$attributes)" 40 | } 41 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/util/DurationUtilTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | import io.sip3.salto.ce.util.DurationUtil.parseDuration 20 | import org.junit.jupiter.api.Assertions.assertEquals 21 | import org.junit.jupiter.api.Test 22 | import org.junit.jupiter.api.assertThrows 23 | import java.time.Duration 24 | 25 | class DurationUtilTest { 26 | 27 | @Test 28 | fun `Check parseDuration() method`() { 29 | assertEquals(Duration.ofMillis(100), parseDuration("100ms")) 30 | assertEquals(Duration.ofSeconds(10), parseDuration("10s")) 31 | assertEquals(Duration.ofMinutes(5), parseDuration("5m")) 32 | assertEquals(Duration.ofHours(1), parseDuration("1h")) 33 | assertThrows { parseDuration("1hour") } 34 | } 35 | } -------------------------------------------------------------------------------- /src/main/resources/codecs.yml: -------------------------------------------------------------------------------- 1 | #! Codec directory 2 | # 3 | # Contains information about codecs used in RTP. All data filled up according to: 4 | # 1) ITU-T Recommendation G.113: Transmission impairments due to speech processing and it's Amendments (https://www.itu.int/rec/T-REC-G.113) 5 | # 2) RFC-3551: RTP Profile for Audio and Video Conferences with Minimal Control (https://tools.ietf.org/html/rfc3551) 6 | # 7 | # Every entry MUST contain: 8 | # name(e.i. PCMA, PCMU) for matching with SDP information 9 | # payload_type - reserved payload type; 10 | # clock_rate - clock rate for codec; 11 | # ie - equipment impairment factor, Ie; 12 | # bpl - packet-loss robustness factor, Bpl. 13 | # 14 | 15 | codecs: 16 | - name: PCMA 17 | payload_types: [ 8 ] 18 | clock_rate: 8000 19 | ie: 0 20 | bpl: 4.3 21 | 22 | - name: PCMU 23 | payload_types: [ 0 ] 24 | clock_rate: 8000 25 | ie: 0 26 | bpl: 4.3 27 | 28 | - name: G722 29 | payload_types: [ 9 ] 30 | clock_rate: 8000 31 | ie: 5 32 | bpl: 7.1 33 | 34 | - name: CN 35 | payload_types: [ 13 ] 36 | clock_rate: 8000 37 | ie: 0 38 | bpl: 4.3 39 | 40 | - name: G723 41 | payload_types: [ 4 ] 42 | clock_rate: 8000 43 | ie: 15 44 | bpl: 16.1 45 | 46 | - name: G729 47 | payload_types: [ 18 ] 48 | clock_rate: 8000 49 | ie: 10 50 | bpl: 19.0 51 | 52 | - name: GSM 53 | payload_types: [ 3, 5, 100..110 ] 54 | clock_rate: 8000 55 | ie: 20 56 | bpl: 10.0 -------------------------------------------------------------------------------- /src/main/resources/udf/sip_message_udf.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package udf 18 | 19 | /* 20 | Default `sip_message_udf` endpoint 21 | */ 22 | vertx.eventBus().localConsumer("sip_message_udf", { event -> 23 | // Please, check `sip3-documentation` to learn what could be done within SIP3 UDFs. 24 | // 25 | // Example 1: User-Defined attribute 26 | // def packet = event.body() 27 | // def sip_message = packet['payload'] 28 | // if (sip_message['from'].matches(' 2 | 3 | 4 | /var/log/sip3-salto/default.log 5 | 6 | /var/log/sip3-salto/%d{yyyy-MM-dd}.default.log 7 | 15 8 | 9 | 10 | %d{dd-MM-yyyy HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | /var/log/sip3-salto/metrics.log 20 | 21 | /var/log/sip3-salto/%d{yyyy-MM-dd}.metrics.log 22 | 15 23 | 24 | 25 | %d{dd-MM-yyyy HH:mm:ss.SSS} - %msg%n 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/RoutesCE.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce 18 | 19 | import io.sip3.commons.Routes 20 | 21 | interface RoutesCE : Routes { 22 | 23 | companion object : RoutesCE 24 | 25 | // UDF 26 | val packet_udf get() = "packet_udf" 27 | val sip_message_udf get() = "sip_message_udf" 28 | val sip_call_udf get() = "sip_call_udf" 29 | 30 | // Decoder 31 | val sip3 get() = "sip3" 32 | val hep2 get() = "hep2" 33 | val hep3 get() = "hep3" 34 | 35 | // Router 36 | val router get() = "router" 37 | 38 | // SIP 39 | val sip get() = "sip" 40 | 41 | // SDP 42 | val sdp get() = "sdp" 43 | 44 | // Media 45 | val rtcp get() = "rtcp" 46 | val rtpr get() = "rtpr" 47 | val rtpe get() = "rtpe" 48 | val media get() = "media" 49 | 50 | // Management 51 | val management get() = "management" 52 | 53 | // Recording 54 | val rec get() = "rec" 55 | 56 | // Mongo 57 | val mongo_bulk_writer get() = "mongo_bulk_writer" 58 | val mongo_collection_hint get() = "mongo_collection_hint" 59 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/util/MediaAddressUtilTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | import io.sip3.commons.domain.media.MediaAddress 20 | import io.sip3.salto.ce.domain.Address 21 | import org.junit.jupiter.api.Assertions.assertEquals 22 | import org.junit.jupiter.api.Test 23 | 24 | class MediaAddressUtilTest { 25 | 26 | companion object { 27 | 28 | val MEDIA_ADDRESS = MediaAddress().apply { 29 | addr = "192.168.168.100" 30 | rtpPort = 12002 31 | rtcpPort = 12003 32 | } 33 | } 34 | 35 | @Test 36 | fun `Check 'rtpAddress()' method`() { 37 | val addr = Address().apply { 38 | addr = MEDIA_ADDRESS.addr 39 | port = MEDIA_ADDRESS.rtpPort 40 | } 41 | 42 | assertEquals(addr, MEDIA_ADDRESS.rtpAddress()) 43 | } 44 | 45 | @Test 46 | fun `Check 'rtcpAddress()' method`() { 47 | val addr = Address().apply { 48 | addr = MEDIA_ADDRESS.addr 49 | port = MEDIA_ADDRESS.rtcpPort 50 | } 51 | 52 | assertEquals(addr, MEDIA_ADDRESS.rtcpAddress()) 53 | } 54 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/MockKSingletonExtension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce 18 | 19 | import io.mockk.every 20 | import io.mockk.mockkObject 21 | import io.mockk.unmockkObject 22 | import io.sip3.salto.ce.management.component.ComponentRegistry 23 | import io.sip3.salto.ce.management.host.HostRegistry 24 | import org.junit.jupiter.api.extension.AfterEachCallback 25 | import org.junit.jupiter.api.extension.BeforeEachCallback 26 | import org.junit.jupiter.api.extension.ExtensionContext 27 | 28 | class MockKSingletonExtension : BeforeEachCallback, AfterEachCallback { 29 | 30 | override fun beforeEach(context: ExtensionContext?) { 31 | // HostRegistry mock 32 | mockkObject(HostRegistry) 33 | every { 34 | HostRegistry.getInstance(any(), any()) 35 | } returns HostRegistry 36 | 37 | // ComponentRegistry mock 38 | mockkObject(ComponentRegistry) 39 | every { 40 | ComponentRegistry.getInstance(any(), any()) 41 | } returns ComponentRegistry 42 | } 43 | 44 | override fun afterEach(context: ExtensionContext?) { 45 | unmockkObject(HostRegistry) 46 | unmockkObject(ComponentRegistry) 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/domain/Address.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.domain 18 | 19 | import io.sip3.commons.util.MediaUtil 20 | 21 | class Address { 22 | 23 | lateinit var addr: String 24 | var port: Int = 0 25 | var host: String? = null 26 | 27 | fun compositeKey(other: Address, keyMapping: (Address) -> String): String { 28 | val thisKey = keyMapping.invoke(this) 29 | val otherKey = keyMapping.invoke(other) 30 | 31 | return if (thisKey > otherKey) { 32 | "$thisKey:$otherKey" 33 | } else { 34 | "$otherKey:$thisKey" 35 | } 36 | } 37 | 38 | fun compositeKey(other: Address): String { 39 | return compositeKey(other) { it.host ?: it.addr } 40 | } 41 | 42 | fun sdpSessionId(): String { 43 | return MediaUtil.sdpSessionId(addr, port) 44 | } 45 | 46 | override fun equals(other: Any?): Boolean { 47 | if (this === other) return true 48 | if (other !is Address) return false 49 | 50 | return addr == other.addr && port == other.port 51 | } 52 | 53 | override fun hashCode(): Int { 54 | var result = addr.hashCode() 55 | result = 31 * result + port 56 | return result 57 | } 58 | 59 | override fun toString(): String { 60 | return "Address(addr='$addr', port=$port, host=$host)" 61 | } 62 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/domain/AddressTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.domain 18 | 19 | import org.junit.jupiter.api.Assertions.assertEquals 20 | import org.junit.jupiter.api.Test 21 | 22 | class AddressTest { 23 | 24 | @Test 25 | fun `Check 'compositeKey()' method`() { 26 | val srcAddr = Address().apply { 27 | addr = "29.11.19.88" 28 | port = 5060 29 | } 30 | var dstAddr = Address().apply { 31 | addr = "23.08.20.15" 32 | port = 5061 33 | } 34 | assertEquals("29.11.19.88:23.08.20.15", srcAddr.compositeKey(dstAddr)) 35 | assertEquals("29.11.19.88:23.08.20.15", dstAddr.compositeKey(srcAddr)) 36 | assertEquals("5061:5060", srcAddr.compositeKey(dstAddr) { it.port.toString() }) 37 | assertEquals("5061:5060", dstAddr.compositeKey(srcAddr) { it.port.toString() }) 38 | 39 | dstAddr = Address().apply { 40 | addr = "23.08.20.15" 41 | host = "Test" 42 | port = 5061 43 | } 44 | assertEquals("Test:29.11.19.88", srcAddr.compositeKey(dstAddr)) 45 | assertEquals("Test:29.11.19.88", dstAddr.compositeKey(srcAddr)) 46 | assertEquals("5061:5060", srcAddr.compositeKey(dstAddr) { it.port.toString() }) 47 | assertEquals("5061:5060", dstAddr.compositeKey(srcAddr) { it.port.toString() }) 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/Attributes.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce 18 | 19 | object Attributes { 20 | 21 | const val src_addr = "src_addr" 22 | const val src_host = "src_host" 23 | const val dst_addr = "dst_addr" 24 | const val dst_host = "dst_host" 25 | const val method = "method" 26 | const val call_id = "call_id" 27 | const val x_call_id = "x_call_id" 28 | const val state = "state" 29 | const val caller = "caller" 30 | const val callee = "callee" 31 | const val expired = "expired" 32 | const val error_code = "error_code" 33 | const val error_type = "error_type" 34 | const val duration = "duration" 35 | const val distribution = "distribution" 36 | const val trying_delay = "trying_delay" 37 | const val setup_time = "setup_time" 38 | const val establish_time = "establish_time" 39 | const val cancel_time = "cancel_time" 40 | const val disconnect_time = "disconnect_time" 41 | const val terminated_by = "terminated_by" 42 | const val transactions = "transactions" 43 | const val retransmits = "retransmits" 44 | const val recorded = "recorded" 45 | const val mos = "mos" 46 | const val r_factor = "r_factor" 47 | const val ranked = "ranked" 48 | const val one_way = "one_way" 49 | const val codec = "codec" 50 | const val bad_report_fraction = "bad_report_fraction" 51 | const val overlapped_interval = "overlapped_interval" 52 | const val overlapped_fraction = "overlapped_fraction" 53 | const val recording_mode = "recording_mode" 54 | const val debug = "debug" 55 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/management/AbstractServer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management 18 | 19 | import io.sip3.commons.vertx.util.localRequest 20 | import io.sip3.salto.ce.RoutesCE 21 | import io.vertx.core.AbstractVerticle 22 | import io.vertx.core.json.JsonObject 23 | import mu.KotlinLogging 24 | import java.net.URI 25 | 26 | /** 27 | * Abstract Management Server 28 | */ 29 | abstract class AbstractServer : AbstractVerticle() { 30 | 31 | private val logger = KotlinLogging.logger {} 32 | 33 | lateinit var name: String 34 | 35 | override fun start() { 36 | name = config().getString("name") 37 | 38 | readConfig() 39 | 40 | vertx.eventBus().localConsumer>>(RoutesCE.management + "_send") { event -> 41 | try { 42 | val (message, uris) = event.body() 43 | send(message, uris) 44 | } catch (e: Exception) { 45 | logger.error(e) { "AbstractServer 'send()' failed." } 46 | } 47 | } 48 | 49 | startServer() 50 | } 51 | 52 | abstract fun readConfig() 53 | 54 | abstract fun startServer() 55 | 56 | abstract fun send(message: JsonObject, uris: List) 57 | 58 | open fun handle(uri: URI, message: JsonObject) { 59 | logger.trace { "Handle message from $uri: $message" } 60 | vertx.eventBus().localRequest(RoutesCE.management, Pair(uri, message)) { event -> 61 | event.result()?.body()?.let { response -> 62 | logger.trace { "Send response to $uri: $response" } 63 | send(response, listOf(uri)) 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/server/AbstractServer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.server 18 | 19 | import io.sip3.commons.micrometer.Metrics 20 | import io.sip3.commons.vertx.util.localSend 21 | import io.sip3.salto.ce.RoutesCE 22 | import io.sip3.salto.ce.domain.Address 23 | import io.vertx.core.AbstractVerticle 24 | import io.vertx.core.buffer.Buffer 25 | import mu.KotlinLogging 26 | 27 | /** 28 | * Abstract server that retrieves SIP3 and HEP3 packets 29 | */ 30 | abstract class AbstractServer : AbstractVerticle() { 31 | 32 | private val logger = KotlinLogging.logger {} 33 | 34 | companion object { 35 | 36 | const val PROTO_SIP3 = "SIP3" 37 | const val PROTO_HEP3 = "HEP3" 38 | val PROTO_HEP2 = byteArrayOf(0x02, 0x10, 0x02) 39 | } 40 | 41 | private val packetsReceived = Metrics.counter("packets_received") 42 | 43 | override fun start() { 44 | readConfig() 45 | startServer() 46 | } 47 | 48 | abstract fun readConfig() 49 | 50 | abstract fun startServer() 51 | 52 | open fun onRawPacket(sender: Address, buffer: Buffer) { 53 | packetsReceived.increment() 54 | 55 | if (buffer.length() < 4) return 56 | 57 | // SIP3 and HEP3 58 | when (buffer.getString(0, 4)) { 59 | PROTO_SIP3 -> vertx.eventBus().localSend(RoutesCE.sip3, Pair(sender, buffer)) 60 | PROTO_HEP3 -> vertx.eventBus().localSend(RoutesCE.hep3, Pair(sender, buffer)) 61 | else -> { 62 | // HEP2 63 | val prefix = buffer.getBytes(0, 3) 64 | if (prefix.contentEquals(PROTO_HEP2)) { 65 | vertx.eventBus().localSend(RoutesCE.hep2, Pair(sender, buffer)) 66 | } 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/util/AttributeUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | fun Map.toAttributes(excluded: List = emptyList()): MutableMap { 20 | val attributes = mutableMapOf() 21 | 22 | forEach { (k, v) -> 23 | val key = k.substringAfter(":") 24 | if (!excluded.contains(key) && AttributeUtil.modeDatabase(k)) { 25 | attributes[key] = if (v is String && !AttributeUtil.modeOptions(k)) "" else v 26 | } 27 | } 28 | 29 | return attributes 30 | } 31 | 32 | fun Map.toDatabaseAttributes(excluded: List = emptyList()): MutableMap { 33 | val attributes = mutableMapOf() 34 | 35 | forEach { (k, v) -> 36 | val key = k.substringAfter(":") 37 | if (!excluded.contains(key) && AttributeUtil.modeDatabase(k)) { 38 | attributes[key] = v 39 | } 40 | } 41 | 42 | return attributes 43 | } 44 | 45 | fun Map.toMetricsAttributes(excluded: List = emptyList()): MutableMap { 46 | val attributes = mutableMapOf() 47 | 48 | forEach { (k, v) -> 49 | val key = k.substringAfter(":") 50 | if (!excluded.contains(key) && AttributeUtil.modeMetrics(k)) { 51 | attributes[key] = v 52 | } 53 | } 54 | 55 | return attributes 56 | } 57 | 58 | private object AttributeUtil { 59 | 60 | private const val MODE_DATABASE = "d" 61 | private const val MODE_OPTIONS = "o" 62 | private const val MODE_METRICS = "m" 63 | 64 | fun modeDatabase(name: String): Boolean { 65 | return hasMode(name, MODE_DATABASE) 66 | } 67 | 68 | fun modeOptions(name: String): Boolean { 69 | return hasMode(name, MODE_OPTIONS) 70 | } 71 | 72 | fun modeMetrics(name: String): Boolean { 73 | return hasMode(name, MODE_METRICS) 74 | } 75 | 76 | private fun hasMode(name: String, mode: String): Boolean { 77 | val delimiterIndex = name.indexOf(':') 78 | return delimiterIndex < 0 || name.indexOf(mode, ignoreCase = true) in 0..delimiterIndex 79 | } 80 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/mongo/MongoBulkWriterTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.mongo 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.commons.vertx.util.localSend 21 | import io.sip3.salto.ce.MongoExtension 22 | import io.sip3.salto.ce.RoutesCE 23 | import io.vertx.core.json.JsonObject 24 | import io.vertx.ext.mongo.MongoClient 25 | import org.junit.jupiter.api.Assertions.assertEquals 26 | import org.junit.jupiter.api.Test 27 | import org.junit.jupiter.api.extension.ExtendWith 28 | 29 | @ExtendWith(MongoExtension::class) 30 | class MongoBulkWriterTest : VertxTest() { 31 | 32 | @Test 33 | fun `Write document to MongoDB`() { 34 | val document = JsonObject().apply { 35 | put("name", "test") 36 | } 37 | runTest( 38 | deploy = { 39 | vertx.deployTestVerticle(MongoBulkWriter::class, JsonObject().apply { 40 | put("mongo", JsonObject().apply { 41 | put("uri", MongoExtension.MONGO_URI) 42 | put("db", "sip3") 43 | put("bulk_size", 1) 44 | }) 45 | }) 46 | }, 47 | execute = { 48 | vertx.eventBus().localSend(RoutesCE.mongo_bulk_writer, Pair("test", JsonObject().apply { put("document", document) })) 49 | }, 50 | assert = { 51 | val mongo = MongoClient.createShared(vertx, JsonObject().apply { 52 | put("connection_string", MongoExtension.MONGO_URI) 53 | put("db_name", "sip3") 54 | }) 55 | vertx.setPeriodic(500, 100) { 56 | mongo.find("test", JsonObject()) { asr -> 57 | if (asr.succeeded()) { 58 | val documents = asr.result() 59 | if (documents.isNotEmpty()) { 60 | context.verify { 61 | assertEquals(document, documents[0]) 62 | } 63 | context.completeNow() 64 | } 65 | } 66 | } 67 | } 68 | } 69 | ) 70 | } 71 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/server/UdpServer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.server 18 | 19 | import io.sip3.commons.vertx.annotations.ConditionalOnProperty 20 | import io.sip3.commons.vertx.annotations.Instance 21 | import io.sip3.salto.ce.domain.Address 22 | import io.vertx.core.datagram.DatagramSocketOptions 23 | import mu.KotlinLogging 24 | import java.net.URI 25 | 26 | /** 27 | * Retrieves SIP3 and HEP3 packets via UDP 28 | */ 29 | @ConditionalOnProperty(pointer = "/server", matcher = ".*: ?\"?udp://.*") 30 | @Instance(singleton = true, worker = true) 31 | open class UdpServer : AbstractServer() { 32 | 33 | private val logger = KotlinLogging.logger {} 34 | 35 | private lateinit var uri: URI 36 | private var bufferSize: Int? = null 37 | 38 | override fun readConfig() { 39 | config().getJsonObject("server")?.let { server -> 40 | val config = server.getJsonObject("udp") ?: server 41 | uri = URI(config.getString("uri") ?: throw IllegalArgumentException("uri")) 42 | bufferSize = config.getInteger("buffer_size") 43 | } 44 | } 45 | 46 | override fun startServer() { 47 | val options = DatagramSocketOptions().apply { 48 | isIpV6 = uri.host.matches(Regex("\\[.*]")) 49 | bufferSize?.let { receiveBufferSize = it } 50 | } 51 | 52 | 53 | vertx.createDatagramSocket(options) 54 | .handler { packet -> 55 | val sender = Address().apply { 56 | addr = if (options.isIpV6) { 57 | packet.sender().host().substringBefore("%") 58 | } else { 59 | packet.sender().host() 60 | } 61 | port = packet.sender().port() 62 | } 63 | 64 | val buffer = packet.data() 65 | try { 66 | onRawPacket(sender, buffer) 67 | } catch (e: Exception) { 68 | logger.error(e) { "Server 'onRawPacket()' failed." } 69 | } 70 | } 71 | .listen(uri.port, uri.host) 72 | .onFailure { t -> 73 | logger.error(t) { "UDP connection failed. URI: $uri" } 74 | throw t 75 | } 76 | .onSuccess { logger.info { "Listening on $uri" } } 77 | } 78 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/management/UdpServer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management 18 | 19 | import io.sip3.commons.util.toURI 20 | import io.sip3.commons.vertx.annotations.ConditionalOnProperty 21 | import io.sip3.commons.vertx.annotations.Instance 22 | import io.vertx.core.datagram.DatagramSocket 23 | import io.vertx.core.datagram.DatagramSocketOptions 24 | import io.vertx.core.json.JsonObject 25 | import mu.KotlinLogging 26 | import java.net.URI 27 | 28 | @Instance(singleton = true) 29 | @ConditionalOnProperty(pointer = "/management", matcher = ".*: ?\"?udp://.*") 30 | open class UdpServer : AbstractServer() { 31 | 32 | private val logger = KotlinLogging.logger {} 33 | 34 | private lateinit var uri: URI 35 | 36 | private lateinit var socket: DatagramSocket 37 | 38 | override fun readConfig() { 39 | config().getJsonObject("management")?.let { management -> 40 | val config = management.getJsonObject("udp") ?: management 41 | uri = URI(config.getString("uri") ?: throw IllegalArgumentException("uri")) 42 | } 43 | } 44 | 45 | override fun startServer() { 46 | val options = DatagramSocketOptions().apply { 47 | isIpV6 = uri.host.matches(Regex("\\[.*]")) 48 | } 49 | 50 | socket = vertx.createDatagramSocket(options) 51 | 52 | socket.handler { packet -> 53 | val socketAddress = packet.sender() 54 | val senderUri = socketAddress.toURI("udp", options.isIpV6) 55 | val buffer = packet.data() 56 | try { 57 | val message = buffer.toJsonObject() 58 | handle(senderUri, message) 59 | } catch (e: Exception) { 60 | logger.error(e) { "ManagementSocket 'handle()' failed." } 61 | } 62 | } 63 | 64 | socket.listen(uri.port, uri.host) 65 | .onFailure { t -> 66 | logger.error(t) { "UDP connection failed. URI: $uri" } 67 | throw t 68 | } 69 | .onSuccess { 70 | logger.info { "Listening on $uri" } 71 | } 72 | } 73 | 74 | override fun send(message: JsonObject, uris: List) { 75 | val buffer = message.toBuffer() 76 | uris.filter { it.scheme == "udp"} 77 | .map { Pair(it.host, it.port) } 78 | .forEach { (host, port) -> 79 | logger.trace { "Sending message to $host:$port" } 80 | socket.send(buffer, port, host) 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/udf/UdfManagerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.udf 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.commons.vertx.util.localSend 21 | import io.vertx.core.json.JsonObject 22 | import org.junit.jupiter.api.AfterEach 23 | import org.junit.jupiter.api.BeforeEach 24 | import org.junit.jupiter.api.Test 25 | import java.nio.file.Files 26 | import java.nio.file.Path 27 | import java.nio.file.Paths 28 | import java.util.concurrent.atomic.AtomicInteger 29 | import kotlin.io.path.copyTo 30 | 31 | class UdfManagerTest : VertxTest() { 32 | 33 | companion object { 34 | 35 | const val UDF_LOCATION = "src/test/resources/udf/UdfManagerTest" 36 | } 37 | 38 | 39 | private lateinit var tmpDir: Path 40 | 41 | @BeforeEach 42 | fun createTemporaryDirectory() { 43 | tmpDir = Files.createTempDirectory("sip3-udf-manager") 44 | System.setProperty("udf.location", tmpDir.toAbsolutePath().toString()) 45 | } 46 | 47 | @Test 48 | fun `Deploy and re-deploy Groovy UDF`() { 49 | val message = "Groovy is awesome" 50 | val counter = AtomicInteger() 51 | 52 | runTest( 53 | deploy = { 54 | vertx.orCreateContext.config().put("udf", JsonObject().apply { 55 | put("check_period", 1000) 56 | }) 57 | UdfManager(vertx).start(tmpDir.toAbsolutePath().toString()) 58 | }, 59 | execute = { 60 | var script = Paths.get(UDF_LOCATION, "UdfManagerTestV1.groovy") 61 | script.copyTo(tmpDir.resolve("UdfManagerTest.groovy"), overwrite = true) 62 | vertx.setPeriodic(200) { 63 | if (counter.get() == 1 && script == Paths.get(UDF_LOCATION, "UdfManagerTestV1.groovy")) { 64 | script = Paths.get(UDF_LOCATION, "UdfManagerTestV2.groovy") 65 | script.copyTo(tmpDir.resolve("UdfManagerTest.groovy"), overwrite = true) 66 | } 67 | } 68 | vertx.setPeriodic(500) { 69 | vertx.eventBus().localSend("groovy", message) 70 | } 71 | }, 72 | assert = { 73 | vertx.eventBus().localConsumer("groovy1") { counter.compareAndSet(0, 1) } 74 | vertx.eventBus().localConsumer("groovy2") { counter.compareAndSet(1, 2) } 75 | vertx.setPeriodic(200) { 76 | if (counter.get() == 2) { 77 | context.completeNow() 78 | } 79 | } 80 | } 81 | ) 82 | } 83 | 84 | @AfterEach 85 | fun deleteTemporaryDirectory() { 86 | Files.deleteIfExists(tmpDir.resolve("UdfManagerTest.groovy")) 87 | Files.deleteIfExists(tmpDir) 88 | } 89 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/mongo/MongoBulkWriter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.mongo 18 | 19 | import io.sip3.commons.mongo.MongoClient 20 | import io.sip3.commons.vertx.annotations.Instance 21 | import io.sip3.salto.ce.RoutesCE 22 | import io.vertx.core.AbstractVerticle 23 | import io.vertx.core.json.JsonObject 24 | import io.vertx.ext.mongo.BulkOperation 25 | import io.vertx.ext.mongo.BulkWriteOptions 26 | import io.vertx.ext.mongo.WriteOption 27 | import mu.KotlinLogging 28 | 29 | /** 30 | * Sends bulks of operations to MongoDB 31 | */ 32 | @Instance 33 | open class MongoBulkWriter : AbstractVerticle() { 34 | 35 | private val logger = KotlinLogging.logger {} 36 | 37 | private lateinit var client: io.vertx.ext.mongo.MongoClient 38 | private var bulkSize = 0 39 | private val bulkWriteOptions = BulkWriteOptions(false) 40 | 41 | private val operations = mutableMapOf>() 42 | private var size = 0 43 | 44 | override fun start() { 45 | config().getJsonObject("mongo").let { config -> 46 | client = MongoClient.createShared(vertx, config) 47 | bulkSize = config.getInteger("bulk_size") 48 | config.getInteger("write_option")?.let { writeOption -> 49 | bulkWriteOptions.writeOption = WriteOption.values()[writeOption] 50 | } 51 | } 52 | 53 | vertx.eventBus().localConsumer>(RoutesCE.mongo_bulk_writer) { bulkOperation -> 54 | try { 55 | val (collection, operation) = bulkOperation.body() 56 | handle(collection, operation) 57 | } catch (e: Exception) { 58 | logger.error(e) { "MongoBulkWriter 'handle()' failed." } 59 | } 60 | } 61 | } 62 | 63 | override fun stop() { 64 | flushToDatabase() 65 | } 66 | 67 | open fun handle(collection: String, operation: JsonObject) { 68 | operation.apply { 69 | if (!containsKey("type")) { 70 | put("type", "INSERT") 71 | } 72 | if (!containsKey("multi")) { 73 | put("multi", false) 74 | } 75 | if (!containsKey("upsert")) { 76 | put("upsert", false) 77 | } 78 | } 79 | val bulkOperations = operations.getOrPut(collection) { mutableListOf() } 80 | bulkOperations.add(BulkOperation(operation)) 81 | size++ 82 | if (size >= bulkSize) { 83 | flushToDatabase() 84 | } 85 | } 86 | 87 | private fun flushToDatabase() { 88 | operations.forEach { (collection, bulkOperations) -> 89 | client.bulkWriteWithOptions(collection, bulkOperations, bulkWriteOptions) { asr -> 90 | if (asr.failed()) { 91 | logger.error(asr.cause()) { "MongoClient 'bulkWriteWithOptions()' failed." } 92 | } 93 | } 94 | } 95 | operations.clear() 96 | size = 0 97 | } 98 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/server/TcpServer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.server 18 | 19 | import io.netty.buffer.ByteBufUtil 20 | import io.sip3.commons.vertx.annotations.ConditionalOnProperty 21 | import io.sip3.commons.vertx.annotations.Instance 22 | import io.sip3.salto.ce.domain.Address 23 | import io.vertx.core.net.NetServerOptions 24 | import io.vertx.core.parsetools.RecordParser 25 | import mu.KotlinLogging 26 | import java.net.URI 27 | 28 | /** 29 | * Retrieves SIP3 and HEP3 packets via TCP 30 | */ 31 | @ConditionalOnProperty(pointer = "/server", matcher = ".*: ?\"?tcp://.*") 32 | @Instance(worker = true) 33 | open class TcpServer : AbstractServer() { 34 | 35 | private val logger = KotlinLogging.logger {} 36 | 37 | private lateinit var uri: URI 38 | private var bufferSize: Int? = null 39 | private var delimiter = "\r\n\r\n3PIS\r\n\r\n" 40 | 41 | override fun readConfig() { 42 | config().getJsonObject("server")?.let { server -> 43 | val config = server.getJsonObject("tcp") ?: server 44 | uri = URI(config.getString("uri") ?: throw IllegalArgumentException("uri")) 45 | bufferSize = config.getInteger("buffer_size") 46 | config.getString("delimiter")?.let { delimiter = it } 47 | } 48 | } 49 | 50 | override fun startServer() { 51 | val options = tcpConnectionOptions() 52 | vertx.createNetServer(options) 53 | .connectHandler { socket -> 54 | val sender = Address().apply { 55 | addr = socket.remoteAddress().host() 56 | port = socket.remoteAddress().port() 57 | } 58 | logger.debug { "TCP connection established from $sender" } 59 | 60 | val parser = RecordParser.newDelimited(delimiter) { buffer -> 61 | try { 62 | onRawPacket(sender, buffer) 63 | } catch (e: Exception) { 64 | logger.error(e) { "Server 'onRawPacket()' failed." } 65 | logger.debug { "Sender: $sender, buffer: ${ByteBufUtil.prettyHexDump(buffer.byteBuf)}" } 66 | } 67 | } 68 | 69 | socket.handler { buffer -> 70 | try { 71 | parser.handle(buffer) 72 | } catch (e: Exception) { 73 | logger.error(e) { "RecordParser 'handle()' failed." } 74 | logger.debug { "Sender: $sender, buffer: ${ByteBufUtil.prettyHexDump(buffer.byteBuf)}" } 75 | } 76 | } 77 | } 78 | .listen(uri.port, uri.host) 79 | .onFailure { t -> 80 | logger.error(t) { "TCP connection failed. URI: $uri" } 81 | throw t 82 | } 83 | .onSuccess { logger.info { "Listening on $uri" } } 84 | } 85 | 86 | open fun tcpConnectionOptions(): NetServerOptions { 87 | return NetServerOptions().apply { 88 | bufferSize?.let { receiveBufferSize = it } 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/server/TcpServerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.server 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.salto.ce.RoutesCE 21 | import io.sip3.salto.ce.domain.Address 22 | import io.vertx.core.buffer.Buffer 23 | import io.vertx.core.json.JsonObject 24 | import io.vertx.kotlin.core.net.netClientOptionsOf 25 | import io.vertx.kotlin.coroutines.coAwait 26 | import org.junit.jupiter.api.Assertions.* 27 | import org.junit.jupiter.api.Test 28 | import java.nio.charset.Charset 29 | 30 | class TcpServerTest : VertxTest() { 31 | 32 | companion object { 33 | 34 | const val MESSAGE_1 = "SIP3 is awesome!" 35 | val EXTRA_LONG_MESSAGE = MESSAGE_1.repeat(900) 36 | val NET_CLIENT_OPTIONS = netClientOptionsOf(reconnectInterval = 100L, reconnectAttempts = 100) 37 | } 38 | 39 | @Test 40 | fun `Retrieve SIP3 packet via TCP`() { 41 | val port = findRandomPort() 42 | runTest( 43 | deploy = { 44 | vertx.deployTestVerticle(TcpServer::class, JsonObject().apply { 45 | put("server", JsonObject().apply { 46 | put("uri", "tcp://127.0.0.1:$port") 47 | }) 48 | }) 49 | }, 50 | execute = { 51 | vertx.createNetClient(NET_CLIENT_OPTIONS) 52 | .connect(port, "127.0.0.1").coAwait() 53 | .write(Buffer.buffer(MESSAGE_1).appendString("\r\n\r\n3PIS\r\n\r\n")) 54 | }, 55 | assert = { 56 | vertx.eventBus().consumer>(RoutesCE.sip3) { event -> 57 | val (sender, buffer) = event.body() 58 | context.verify { 59 | assertEquals("127.0.0.1", sender.addr) 60 | assertEquals(UdpServerTest.MESSAGE_1, buffer.toString(Charset.defaultCharset())) 61 | } 62 | context.completeNow() 63 | } 64 | } 65 | ) 66 | } 67 | 68 | @Test 69 | fun `Retrieve big SIP3 packet via TCP`() { 70 | val port = findRandomPort() 71 | runTest( 72 | deploy = { 73 | vertx.deployTestVerticle(TcpServer::class, JsonObject().apply { 74 | put("server", JsonObject().apply { 75 | put("uri", "tcp://127.0.0.1:$port") 76 | }) 77 | }) 78 | }, 79 | execute = { 80 | vertx.createNetClient(NET_CLIENT_OPTIONS) 81 | .connect(port, "127.0.0.1").coAwait() 82 | .write(Buffer.buffer(EXTRA_LONG_MESSAGE).appendString("\r\n\r\n3PIS\r\n\r\n")) 83 | }, 84 | assert = { 85 | vertx.eventBus().consumer>(RoutesCE.sip3) { event -> 86 | val (sender, buffer) = event.body() 87 | context.verify { 88 | assertEquals("127.0.0.1", sender.addr) 89 | assertEquals(EXTRA_LONG_MESSAGE, buffer.toString(Charset.defaultCharset())) 90 | } 91 | context.completeNow() 92 | } 93 | } 94 | ) 95 | } 96 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/util/AttributeUtilTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | import org.junit.jupiter.api.Assertions.* 20 | import org.junit.jupiter.api.Test 21 | 22 | class AttributeUtilTest { 23 | 24 | @Test 25 | fun `Validate mode parsing`() { 26 | val attributes = mutableMapOf( 27 | "uda_without_prefix" to "with_options", 28 | ":uda_empty_prefix" to true, 29 | "d:uda_database_only" to "no_options", 30 | "o:uda_options_only" to "with_options", 31 | "m:uda_metrics_only" to "no_options", 32 | "do:uda_database_with_options" to "with_options", 33 | "dm:uda_all_without_options" to "no_options", 34 | "om:uda_options_with_metrics" to "with_options", 35 | "dom:uda_all_modes" to true 36 | ) 37 | 38 | val resultAttributes = attributes.toAttributes() 39 | assertEquals(5, resultAttributes.size) 40 | assertTrue(resultAttributes.keys.none { it.contains(":") }) 41 | assertFalse(resultAttributes.contains("uda_empty_prefix")) 42 | assertEquals("", resultAttributes["uda_database_only"]) 43 | assertEquals("", resultAttributes["uda_all_without_options"]) 44 | assertEquals("with_options", resultAttributes["uda_database_with_options"]) 45 | assertEquals("with_options", resultAttributes["uda_without_prefix"]) 46 | 47 | val databaseAttributes = attributes.toDatabaseAttributes() 48 | assertEquals(5, databaseAttributes.size) 49 | assertTrue(databaseAttributes.keys.none { it.contains(":") }) 50 | assertFalse(databaseAttributes.contains("uda_empty_prefix")) 51 | assertEquals("no_options", databaseAttributes["uda_database_only"]) 52 | assertEquals("no_options", databaseAttributes["uda_all_without_options"]) 53 | assertEquals("with_options", databaseAttributes["uda_database_with_options"]) 54 | assertEquals("with_options", databaseAttributes["uda_without_prefix"]) 55 | 56 | val metricsAttributes = attributes.toMetricsAttributes() 57 | assertEquals(5, metricsAttributes.size) 58 | assertTrue(metricsAttributes.keys.none { it.contains(":") }) 59 | } 60 | 61 | @Test 62 | fun `Validate attributes exclusion parsing`() { 63 | val attributes = mutableMapOf( 64 | "uda_without_prefix" to "with_options", 65 | ":uda_empty_prefix" to true, 66 | "d:uda_database_only" to "no_options", 67 | "o:uda_options_only" to "with_options", 68 | "m:uda_metrics_only" to "no_options", 69 | "do:uda_database_with_options" to "with_options", 70 | "dm:uda_all_without_options" to "no_options", 71 | "om:uda_options_with_metrics" to "with_options", 72 | "dom:uda_all_modes" to true 73 | ) 74 | 75 | val excludedAttributes = listOf("uda_database_with_options", "uda_all_modes") 76 | 77 | val databaseAttributes = attributes.toDatabaseAttributes(excludedAttributes) 78 | assertFalse(databaseAttributes.keys.any { excludedAttributes.contains(it) }) 79 | 80 | val metricsAttributes = attributes.toMetricsAttributes(excludedAttributes) 81 | assertFalse(metricsAttributes.keys.any { excludedAttributes.contains(it) }) 82 | } 83 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/management/AbstractServerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.commons.vertx.util.localReply 21 | import io.sip3.commons.vertx.util.localSend 22 | import io.sip3.salto.ce.RoutesCE 23 | import io.vertx.core.json.JsonObject 24 | 25 | import org.junit.jupiter.api.Assertions.* 26 | import org.junit.jupiter.api.Test 27 | import java.net.URI 28 | 29 | class AbstractServerTest : VertxTest() { 30 | 31 | @Test 32 | fun `Validate 'handle()' method`() { 33 | runTest( 34 | deploy = { 35 | vertx.deployTestVerticle(ServerTestImpl::class, JsonObject().apply { 36 | put("name", "name") 37 | put("a", "value1") 38 | put("b", "value2") 39 | put("message", JsonObject().apply { 40 | put("enabled", true) 41 | }) 42 | }) 43 | }, 44 | execute = { 45 | 46 | }, 47 | assert = { 48 | vertx.eventBus().localConsumer>(RoutesCE.management) { event -> 49 | val (uri, message) = event.body() 50 | context.verify { 51 | assertEquals("tcp://127.0.0.1:4567", uri.toString()) 52 | assertTrue(message.getJsonObject("message").getBoolean("enabled")) 53 | } 54 | 55 | event.localReply(JsonObject().apply { 56 | put("type", "register_response") 57 | put("payload", JsonObject()) 58 | }) 59 | } 60 | 61 | vertx.eventBus().localConsumer>>("test_validate") { event -> 62 | val (message, uris) = event.body() 63 | context.verify { 64 | assertEquals("register_response", message.getString("type")) 65 | assertNotNull(message.getJsonObject("payload")) 66 | assertEquals(1, uris.size) 67 | assertEquals("tcp://127.0.0.1:4567", uris.first().toString()) 68 | } 69 | context.completeNow() 70 | } 71 | } 72 | ) 73 | } 74 | } 75 | 76 | class ServerTestImpl : AbstractServer() { 77 | 78 | lateinit var a: String 79 | lateinit var b: String 80 | lateinit var message: JsonObject 81 | 82 | lateinit var server: String 83 | 84 | override fun readConfig() { 85 | a = config().getString("a") 86 | b = config().getString("b") 87 | message = config().getJsonObject("message") 88 | } 89 | 90 | override fun startServer() { 91 | server = "SomeServer-$a-$b" 92 | 93 | val uri = URI("tcp://127.0.0.1:4567") 94 | vertx.setPeriodic(100L, 100L) { 95 | handle(uri, JsonObject().apply { 96 | put("message", message) 97 | }) 98 | } 99 | } 100 | override fun send(message: JsonObject, uris: List) { 101 | vertx.eventBus().localSend("test_validate", Pair(message, uris)) 102 | } 103 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/management/component/ComponentRegistry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management.component 18 | 19 | import io.sip3.commons.mongo.MongoClient 20 | import io.vertx.core.Future 21 | import io.vertx.core.Vertx 22 | import io.vertx.core.json.JsonObject 23 | import io.vertx.kotlin.ext.mongo.updateOptionsOf 24 | import mu.KotlinLogging 25 | 26 | /** 27 | * Handles SIP3 components 28 | */ 29 | object ComponentRegistry { 30 | 31 | private val logger = KotlinLogging.logger {} 32 | 33 | private const val COLLECTION = "components" 34 | 35 | private var expirationDelay = 30000L 36 | private var cleanupTimeout = 300000L 37 | 38 | private var vertx: Vertx? = null 39 | private lateinit var config: JsonObject 40 | private lateinit var client: io.vertx.ext.mongo.MongoClient 41 | 42 | @Synchronized 43 | fun getInstance(vertx: Vertx, config: JsonObject): ComponentRegistry { 44 | if (ComponentRegistry.vertx == null) { 45 | ComponentRegistry.vertx = vertx 46 | ComponentRegistry.config = config 47 | init() 48 | } 49 | 50 | return this 51 | } 52 | 53 | private fun init() { 54 | config.getJsonObject("management")?.let { components -> 55 | components.getLong("expiration_delay")?.let { 56 | expirationDelay = it 57 | } 58 | 59 | components.getLong("cleanup_timeout")?.let { 60 | cleanupTimeout = it 61 | } 62 | } 63 | 64 | config.getJsonObject("mongo").let { 65 | client = MongoClient.createShared(vertx!!, it.getJsonObject("management") ?: it) 66 | } 67 | 68 | vertx!!.setPeriodic(expirationDelay) { 69 | removeExpired() 70 | } 71 | } 72 | 73 | private fun removeExpired() { 74 | val query = JsonObject().apply { 75 | put("updated_at", JsonObject().apply { 76 | put("\$lt", System.currentTimeMillis() - cleanupTimeout) 77 | }) 78 | } 79 | 80 | client.removeDocuments(COLLECTION, query) 81 | .onFailure { logger.error(it) { "MongoClient 'removeDocuments()' failed." } } 82 | .onSuccess { result -> 83 | result?.removedCount?.takeIf { it > 0L }?.let { removedCount -> 84 | logger.info { "Removed expired components: $removedCount" } 85 | }} 86 | } 87 | 88 | fun list(): Future> { 89 | return client.find(COLLECTION, JsonObject()) 90 | } 91 | 92 | fun save(component: JsonObject) { 93 | val query = JsonObject().apply { 94 | put("deployment_id", component.getString("deployment_id")) 95 | } 96 | 97 | client.replaceDocumentsWithOptions(COLLECTION, query, component, updateOptionsOf(upsert = true)) 98 | .onFailure { logger.error(it) { "MongoClient 'replaceDocumentsWithOptions()' failed." } } 99 | } 100 | 101 | fun remove(deploymentId: String) { 102 | val query = JsonObject().apply { 103 | put("deployment_id", deploymentId) 104 | } 105 | client.removeDocument(COLLECTION, query) 106 | .onFailure { logger.error(it) { "MongoClient 'removeDocument()' failed." } } 107 | } 108 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/rtpr/RtprStream.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.rtpr 18 | 19 | import io.sip3.commons.domain.media.MediaControl 20 | import io.sip3.commons.domain.payload.RtpReportPayload 21 | import io.sip3.salto.ce.domain.Address 22 | import io.sip3.salto.ce.domain.Packet 23 | import io.sip3.salto.ce.util.MediaUtil 24 | import kotlin.math.max 25 | import kotlin.math.min 26 | 27 | class RtprStream(private val rFactorThreshold: Float? = null) { 28 | 29 | var createdAt: Long = 0L 30 | var terminatedAt: Long = 0L 31 | 32 | lateinit var srcAddr: Address 33 | lateinit var dstAddr: Address 34 | 35 | var report: RtpReportPayload = RtpReportPayload().apply { 36 | createdAt = System.currentTimeMillis() 37 | } 38 | 39 | val source: Byte 40 | get() = report.source 41 | 42 | var mediaControl: MediaControl? = null 43 | val codecNames = mutableSetOf() 44 | val callId: String? 45 | get() = mediaControl?.callId 46 | 47 | val mos: Double? 48 | get() = report.mos.takeIf { it != 1F }?.toDouble() 49 | 50 | val rFactor: Double? 51 | get() = report.rFactor.takeIf { it != 0F }?.toDouble() 52 | 53 | var reportCount = 0 54 | var badReportCount = 0 55 | 56 | val attributes = mutableMapOf() 57 | 58 | fun add(packet: Packet, payload: RtpReportPayload) { 59 | if (createdAt == 0L) { 60 | srcAddr = packet.srcAddr 61 | dstAddr = packet.dstAddr 62 | } 63 | 64 | codecNames.add(payload.codecName ?: "UNDEFINED(${payload.payloadType})") 65 | 66 | report.mergeIn(payload) 67 | 68 | createdAt = report.createdAt 69 | terminatedAt = report.createdAt + report.duration 70 | 71 | reportCount++ 72 | rFactorThreshold?.let { if (report.rFactor in 0F..rFactorThreshold) badReportCount++ } 73 | 74 | packet.attributes?.forEach { (name, value) -> attributes[name] = value } 75 | } 76 | 77 | private fun RtpReportPayload.mergeIn(other: RtpReportPayload) { 78 | if (source < 0) source = other.source 79 | if (ssrc == 0L) ssrc = other.ssrc 80 | if (callId == null) callId = other.callId 81 | if (codecName == null) codecName = other.codecName 82 | if (payloadType < 0) payloadType = other.payloadType 83 | 84 | expectedPacketCount += other.expectedPacketCount 85 | receivedPacketCount += other.receivedPacketCount 86 | rejectedPacketCount += other.rejectedPacketCount 87 | lostPacketCount += other.lostPacketCount 88 | markerPacketCount += other.markerPacketCount 89 | 90 | duration += other.duration 91 | 92 | if (reportedAt < other.reportedAt) lastJitter = other.lastJitter 93 | avgJitter = (avgJitter * reportCount + other.avgJitter) / (reportCount + 1) 94 | minJitter = min(minJitter, other.minJitter) 95 | maxJitter = max(maxJitter, other.maxJitter) 96 | 97 | if (rFactor > 0.0F || other.rFactor > 0.0F) { 98 | rFactor = (rFactor * reportCount + other.rFactor) / (reportCount + 1) 99 | mos = MediaUtil.computeMos(rFactor) 100 | } 101 | fractionLost = lostPacketCount.toFloat() / expectedPacketCount 102 | 103 | createdAt = min(createdAt, other.createdAt) 104 | reportedAt = max(reportedAt, other.reportedAt) 105 | } 106 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/udf/UdfExecutor.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.udf 18 | 19 | import io.sip3.commons.vertx.util.endpoints 20 | import io.sip3.commons.vertx.util.localRequest 21 | import io.vertx.core.AsyncResult 22 | import io.vertx.core.Future 23 | import io.vertx.core.Vertx 24 | import io.vertx.core.eventbus.DeliveryOptions 25 | import io.vertx.core.json.JsonObject 26 | import mu.KotlinLogging 27 | 28 | /** 29 | * Executes User-Defined Functions 30 | */ 31 | class UdfExecutor(val vertx: Vertx) { 32 | 33 | private val logger = KotlinLogging.logger {} 34 | 35 | companion object { 36 | 37 | val NO_RESULT_FUTURE: Future>> = Future.succeededFuture(Pair(true, emptyMap())) 38 | val DELIVERY_OPTIONS = DeliveryOptions() 39 | } 40 | 41 | private var checkPeriod: Long = 10000 42 | private var executionTimeout: Long = 100 43 | 44 | private var endpoints = emptySet() 45 | 46 | init { 47 | vertx.orCreateContext.config().getJsonObject("udf")?.let { config -> 48 | config.getLong("check_period")?.let { 49 | checkPeriod = it 50 | } 51 | config.getLong("execution_timeout")?.let { 52 | executionTimeout = it 53 | } 54 | } 55 | 56 | DELIVERY_OPTIONS.apply { 57 | sendTimeout = executionTimeout 58 | } 59 | 60 | vertx.setPeriodic(0, checkPeriod) { 61 | endpoints = vertx.eventBus().endpoints() 62 | logger.trace { "Update UDF endpoints: $endpoints" } 63 | } 64 | } 65 | 66 | fun execute( 67 | endpoint: String, 68 | mappingFunction: () -> MutableMap, 69 | completionHandler: (AsyncResult>>) -> Unit 70 | ) { 71 | if (!endpoints.contains(endpoint)) { 72 | completionHandler.invoke(NO_RESULT_FUTURE) 73 | return 74 | } 75 | 76 | var attributes: Map = mutableMapOf() 77 | 78 | val payload = mappingFunction.invoke() 79 | payload["attributes"] = attributes 80 | 81 | vertx.eventBus().localRequest(endpoint, payload, DELIVERY_OPTIONS) { asr -> 82 | if (asr.failed()) { 83 | logger.error(asr.cause()) { "UdfExecutor 'execute()' failed. Endpoint: $endpoint, payload: ${JsonObject(payload).encodePrettily()}" } 84 | completionHandler.invoke(NO_RESULT_FUTURE) 85 | } else { 86 | val result = asr.result() 87 | when (result.body()) { 88 | true -> { 89 | attributes = attributes.filter { (k, v) -> 90 | when (v) { 91 | is String, is Boolean -> true 92 | else -> { 93 | logger.warn { "UDF attribute $k will be skipped due to unsupported value type." } 94 | return@filter false 95 | } 96 | } 97 | } 98 | completionHandler.invoke(Future.succeededFuture(Pair(true, attributes))) 99 | } 100 | else -> { 101 | completionHandler.invoke(Future.succeededFuture(Pair(false, emptyMap()))) 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/management/component/ComponentRegistryTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management.component 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.salto.ce.MongoExtension 21 | import io.vertx.core.json.JsonObject 22 | import io.vertx.ext.mongo.MongoClient 23 | import org.junit.jupiter.api.Assertions.assertEquals 24 | import org.junit.jupiter.api.Test 25 | import org.junit.jupiter.api.extension.ExtendWith 26 | import java.net.URI 27 | import java.util.* 28 | 29 | @ExtendWith(MongoExtension::class) 30 | class ComponentRegistryTest : VertxTest() { 31 | 32 | companion object { 33 | 34 | const val MONGO_DB = "sip3-test-components" 35 | 36 | val COMPONENT_1 = JsonObject().apply { 37 | put("name", "sip3-captain") 38 | put("deployment_id", UUID.randomUUID().toString()) 39 | put("type", "captain") 40 | put("uri", URI("udp://127.0.0.1:15090").toString()) 41 | put("connected_to", "sip3-salto") 42 | 43 | put("registered_at", System.currentTimeMillis()) 44 | put("updated_at", System.currentTimeMillis()) 45 | put("remote_updated_at", System.currentTimeMillis() - 100L) 46 | 47 | put("config", JsonObject().apply { 48 | put("key", "value") 49 | }) 50 | } 51 | } 52 | 53 | @Test 54 | fun `Validate ComponentRegistry 'save()' method `() { 55 | lateinit var componentRegistry: ComponentRegistry 56 | lateinit var mongo: MongoClient 57 | runTest( 58 | deploy = { 59 | mongo = MongoClient.createShared(vertx, JsonObject().apply { 60 | put("connection_string", MongoExtension.MONGO_URI) 61 | put("db_name", MONGO_DB) 62 | }) 63 | 64 | componentRegistry = ComponentRegistry.getInstance(vertx, JsonObject().apply { 65 | put("mongo", JsonObject().apply { 66 | put("management", JsonObject().apply { 67 | put("uri", MongoExtension.MONGO_URI) 68 | put("db", MONGO_DB) 69 | }) 70 | }) 71 | put("management", JsonObject().apply { 72 | put("expiration_delay", 100L) 73 | put("cleanup_timeout", 500L) 74 | }) 75 | }) 76 | }, 77 | execute = { 78 | componentRegistry.save(COMPONENT_1) 79 | }, 80 | assert = { 81 | vertx.setPeriodic(300L, 100L) { 82 | mongo.findOne("components", JsonObject(), JsonObject()) 83 | .onSuccess { saved -> 84 | if (saved == null) return@onSuccess 85 | 86 | context.verify { 87 | COMPONENT_1.fieldNames().forEach { key -> 88 | assertEquals(COMPONENT_1.getValue(key), saved.getValue(key)) 89 | } 90 | } 91 | } 92 | } 93 | 94 | vertx.setTimer(700L) { 95 | mongo.count("components", JsonObject()) 96 | .onSuccess { count -> 97 | if (count < 1) { 98 | context.completeNow() 99 | } 100 | } 101 | } 102 | } 103 | ) 104 | } 105 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/management/TcpServer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management 18 | 19 | import io.netty.buffer.ByteBufUtil 20 | import io.sip3.commons.vertx.annotations.ConditionalOnProperty 21 | import io.sip3.commons.vertx.annotations.Instance 22 | import io.vertx.core.json.JsonObject 23 | import io.vertx.core.net.NetServerOptions 24 | import io.vertx.core.net.NetSocket 25 | import io.vertx.core.parsetools.RecordParser 26 | import mu.KotlinLogging 27 | import java.net.URI 28 | 29 | @Instance(singleton = true) 30 | @ConditionalOnProperty(pointer = "/management", matcher = ".*: ?\"?tcp://.*") 31 | open class TcpServer : AbstractServer() { 32 | 33 | private val logger = KotlinLogging.logger {} 34 | 35 | private lateinit var uri: URI 36 | private var bufferSize: Int? = null 37 | private var delimiter = "\r\n\r\n3PIS\r\n\r\n" 38 | 39 | private val sockets = mutableMapOf() 40 | 41 | override fun readConfig() { 42 | config().getJsonObject("management")?.let { management -> 43 | val config = management.getJsonObject("tcp") ?: management 44 | uri = URI(config.getString("uri") ?: throw IllegalArgumentException("uri")) 45 | bufferSize = config.getInteger("buffer_size") 46 | config.getString("delimiter")?.let { delimiter = it } 47 | } 48 | } 49 | 50 | override fun startServer() { 51 | val options = tcpConnectionOptions() 52 | vertx.createNetServer(options) 53 | .connectHandler { socket -> 54 | val sender = socket.remoteAddress() 55 | val senderUri = URI("tcp://${sender.host()}:${sender.port()}") 56 | logger.debug { "TCP connection established from $sender" } 57 | sockets[senderUri] = socket 58 | 59 | val parser = RecordParser.newDelimited(delimiter) { buffer -> 60 | try { 61 | handle(senderUri, buffer.toJsonObject()) 62 | } catch (e: Exception) { 63 | logger.error(e) { "Server 'onRawPacket()' failed." } 64 | logger.debug { "Sender: $sender, buffer: ${ByteBufUtil.prettyHexDump(buffer.byteBuf)}" } 65 | } 66 | } 67 | 68 | socket.handler { buffer -> 69 | try { 70 | parser.handle(buffer) 71 | } catch (e: Exception) { 72 | logger.error(e) { "RecordParser 'handle()' failed." } 73 | logger.debug { "Sender: $sender, buffer: ${ByteBufUtil.prettyHexDump(buffer.byteBuf)}" } 74 | } 75 | } 76 | socket.closeHandler { 77 | sockets.remove(senderUri) 78 | } 79 | } 80 | .listen(uri.port, uri.host) 81 | .onFailure { t -> 82 | logger.error(t) { "TCP connection failed. URI: $uri" } 83 | throw t 84 | } 85 | .onSuccess { logger.info { "Listening on $uri" } } 86 | } 87 | 88 | open fun tcpConnectionOptions(): NetServerOptions { 89 | return NetServerOptions().apply { 90 | bufferSize?.let { receiveBufferSize = it } 91 | } 92 | } 93 | 94 | override fun send(message: JsonObject, uris: List) { 95 | val buffer = message.toBuffer() 96 | uris.filter { it.scheme == "tcp" } 97 | .mapNotNull { sockets[it] } 98 | .forEach { socket -> 99 | socket.write(buffer.appendString(delimiter)) 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/media/MediaManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.media 18 | 19 | import io.sip3.commons.domain.media.MediaControl 20 | import io.sip3.commons.domain.media.Recording 21 | import io.sip3.commons.domain.media.SdpSession 22 | import io.sip3.commons.vertx.annotations.Instance 23 | import io.sip3.commons.vertx.util.localPublish 24 | import io.sip3.commons.vertx.util.localRequest 25 | import io.sip3.commons.vertx.util.localSend 26 | import io.sip3.salto.ce.RoutesCE 27 | import io.sip3.salto.ce.sip.SipTransaction 28 | import io.vertx.core.AbstractVerticle 29 | import io.vertx.core.json.JsonObject 30 | import mu.KotlinLogging 31 | 32 | /** 33 | * Manages media feature 34 | */ 35 | @Instance(singleton = true) 36 | open class MediaManager : AbstractVerticle() { 37 | 38 | private val logger = KotlinLogging.logger {} 39 | 40 | protected var recordingEnabled: Boolean = false 41 | 42 | override fun start() { 43 | config().getJsonObject("recording")?.let { config -> 44 | config.getBoolean("enabled")?.let { 45 | recordingEnabled = it 46 | } 47 | } 48 | 49 | vertx.eventBus().localConsumer(RoutesCE.config_change) { event -> 50 | try { 51 | val config = event.body() 52 | onConfigChange(config) 53 | } catch (e: Exception) { 54 | logger.error(e) { "MediaManager 'onConfigChange()' failed." } 55 | } 56 | } 57 | 58 | vertx.eventBus().localConsumer(RoutesCE.media + "_sdp") { event -> 59 | try { 60 | val transaction = event.body() 61 | handleSipTransaction(transaction) 62 | } catch (e: Exception) { 63 | logger.error(e) { "MediaManager 'handleSipTransaction()' failed." } 64 | } 65 | } 66 | } 67 | 68 | open fun onConfigChange(config: JsonObject) { 69 | config.getJsonObject("recording")?.getBoolean("enabled")?.let { 70 | recordingEnabled = it 71 | } 72 | 73 | if (!recordingEnabled) { 74 | vertx.eventBus().localSend(RoutesCE.media + "_recording_reset", JsonObject()) 75 | } 76 | } 77 | 78 | open fun handleSipTransaction(transaction: SipTransaction) { 79 | vertx.eventBus().localRequest(RoutesCE.sdp + "_session", transaction) { asr -> 80 | if (asr.succeeded()) { 81 | try { 82 | val sdpSession = asr.result().body() 83 | if (sdpSession != null) { 84 | handleSdpSession(transaction, sdpSession) 85 | } 86 | } catch (e: Exception) { 87 | logger.error(e) { "MediaManager 'handleSdpSession()' failed." } 88 | } 89 | } 90 | } 91 | } 92 | 93 | open fun handleSdpSession(transaction: SipTransaction, sdpSession: SdpSession) { 94 | val mediaControl = createMediaControl(transaction, sdpSession) 95 | vertx.eventBus().localPublish(RoutesCE.media + "_control", mediaControl) 96 | } 97 | 98 | open fun createMediaControl(transaction: SipTransaction, sdpSession: SdpSession): MediaControl { 99 | return MediaControl().apply { 100 | timestamp = transaction.createdAt 101 | 102 | callId = transaction.callId 103 | caller = (transaction.attributes["caller"] as? String) ?: transaction.caller 104 | callee = (transaction.attributes["callee"] as? String) ?: transaction.callee 105 | 106 | this.sdpSession = sdpSession 107 | 108 | if (recordingEnabled) { 109 | recording = Recording() 110 | } 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/util/SIPMessageUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.util 18 | 19 | import gov.nist.javax.sip.address.SipUri 20 | import gov.nist.javax.sip.message.Content 21 | import gov.nist.javax.sip.message.SIPMessage 22 | import gov.nist.javax.sip.message.SIPRequest 23 | import gov.nist.javax.sip.message.SIPResponse 24 | import io.sip3.salto.ce.sdp.SessionDescriptionParser 25 | import org.restcomm.media.sdp.SessionDescription 26 | import javax.sip.address.TelURL 27 | import javax.sip.address.URI 28 | 29 | fun SIPMessage.callId(): String? { 30 | return callId?.callId 31 | } 32 | 33 | fun SIPMessage.branchId(): String? { 34 | return topmostVia?.branch 35 | } 36 | 37 | fun SIPMessage.toUri(): String? { 38 | return to?.address?.uri?.toString() 39 | } 40 | 41 | fun SIPMessage.toUserOrNumber(allowEmptyUser: Boolean = false): String? { 42 | return to?.address?.uri?.userOrNumber(allowEmptyUser) 43 | } 44 | 45 | fun SIPMessage.fromUri(): String? { 46 | return from?.address?.uri?.toString() 47 | } 48 | 49 | fun SIPMessage.fromUserOrNumber(allowEmptyUser: Boolean = false): String? { 50 | return from?.address?.uri?.userOrNumber(allowEmptyUser) 51 | } 52 | 53 | fun SIPMessage.cseqMethod(): String? { 54 | return cSeq?.method 55 | } 56 | 57 | fun SIPMessage.cseqNumber(): Long? { 58 | return cSeq?.seqNumber 59 | } 60 | 61 | fun SIPMessage.method(): String? { 62 | return (this as? SIPRequest)?.requestLine?.method 63 | } 64 | 65 | fun SIPMessage.statusCode(): Int? { 66 | return (this as? SIPResponse)?.statusCode 67 | } 68 | 69 | fun SIPMessage.transactionId(): String { 70 | return "${callId()}:${branchId()}:${cseqNumber()}" 71 | } 72 | 73 | fun SIPMessage.headersMap(): Map { 74 | return mutableMapOf().apply { 75 | (this@headersMap as? SIPRequest)?.let { 76 | put("request-line", it.requestLine.toString().replace("\r\n", "")) 77 | } 78 | (this@headersMap as? SIPResponse)?.let { 79 | put("status-line", it.statusLine.toString().replace("\r\n", "")) 80 | } 81 | headers.forEach { header -> put(header.headerName.lowercase(), header.headerValue) } 82 | } 83 | } 84 | 85 | fun SIPMessage.hasSdp(): Boolean { 86 | contentTypeHeader?.let { contentType -> 87 | if (contentType.mediaSubType == "sdp") { 88 | return true 89 | } else { 90 | multipartMimeContent?.contents?.forEach { mimeContent -> 91 | if (mimeContent.matches("sdp")) { 92 | return true 93 | } 94 | } 95 | } 96 | } 97 | 98 | return false 99 | } 100 | 101 | fun SIPMessage.sessionDescription(): SessionDescription? { 102 | if (this.contentTypeHeader?.mediaSubType == "sdp") { 103 | return SessionDescriptionParser.parse(this.messageContent) 104 | } else { 105 | this.multipartMimeContent?.contents?.forEach { mimeContent -> 106 | if (mimeContent.matches("sdp")) { 107 | return SessionDescriptionParser.parse(mimeContent.content.toString()) 108 | } 109 | } 110 | } 111 | 112 | return null 113 | } 114 | 115 | fun SIPMessage.expires(): Int? { 116 | return expires?.expires 117 | ?: contactHeader?.contactParms?.getValue("expires")?.toString()?.toInt() 118 | } 119 | 120 | fun URI.userOrNumber(allowEmptyUser: Boolean = false) = when (this) { 121 | is SipUri -> user?.ifBlank { null } ?: if (allowEmptyUser) host else null 122 | is TelURL -> phoneNumber 123 | else -> throw IllegalArgumentException("Unsupported URI format: '$this'") 124 | } 125 | 126 | fun Content.matches(proto: String): Boolean { 127 | return contentTypeHeader?.contentSubType?.lowercase()?.contains(proto.lowercase()) ?: false 128 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/udf/UdfManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.udf 18 | 19 | import io.vertx.core.DeploymentOptions 20 | import io.vertx.core.Vertx 21 | import io.vertx.kotlin.coroutines.coAwait 22 | import io.vertx.kotlin.coroutines.dispatcher 23 | import kotlinx.coroutines.GlobalScope 24 | import kotlinx.coroutines.launch 25 | import mu.KotlinLogging 26 | import java.io.File 27 | import kotlin.coroutines.CoroutineContext 28 | 29 | /** 30 | * Manages User-Defined Functions Deployment 31 | */ 32 | class UdfManager(val vertx: Vertx) { 33 | 34 | private val logger = KotlinLogging.logger {} 35 | 36 | companion object { 37 | 38 | val DEPLOYMENT_OPTIONS = DeploymentOptions() 39 | } 40 | 41 | private var checkPeriod: Long = 10000 42 | 43 | private val deployments = mutableMapOf() 44 | private var lastChecked: Long = 0 45 | 46 | fun start(path: String) { 47 | vertx.orCreateContext.config().let { config -> 48 | config.getJsonObject("udf")?.getLong("check_period")?.let { 49 | checkPeriod = it 50 | } 51 | 52 | DEPLOYMENT_OPTIONS.apply { 53 | this.config = config 54 | this.instances = config.getJsonObject("vertx")?.getInteger("instances") ?: 1 55 | } 56 | } 57 | 58 | vertx.setPeriodic(0, checkPeriod) { 59 | GlobalScope.launch(vertx.dispatcher() as CoroutineContext) { 60 | manage(path) 61 | } 62 | } 63 | } 64 | 65 | private suspend fun manage(path: String) { 66 | val now = System.currentTimeMillis() 67 | 68 | // 1. Walk through the UDF directory and update `deployments` 69 | File(path).walkTopDown().filter(File::isFile).forEach { file -> 70 | var deploymentId: String? = null 71 | 72 | if (file.lastModified() >= lastChecked) { 73 | try { 74 | logger.info { "Deploying new UDF. File: `$file`" } 75 | deploymentId = vertx.deployVerticle(file.absolutePath, DEPLOYMENT_OPTIONS).coAwait() 76 | } catch (e: Exception) { 77 | logger.error("Vertx 'deployVerticle()' failed. File: $file", e) 78 | } 79 | } 80 | 81 | when { 82 | deploymentId != null -> { 83 | deployments.put(file.absolutePath, Deployment(deploymentId))?.let { deployment -> 84 | try { 85 | logger.info { "Removing the old UDF. File: `$file`, " } 86 | vertx.undeploy(deployment.id).coAwait() 87 | } catch (e: Exception) { 88 | logger.error("Vertx 'undeploy()' failed. File: $file", e) 89 | } 90 | } 91 | } 92 | else -> { 93 | deployments[file.absolutePath]?.let { deployment -> deployment.lastUpdated = now } 94 | } 95 | } 96 | } 97 | 98 | // 2. Walk through `deployments` and remove ones that expired 99 | deployments.filterValues { deployment -> deployment.lastUpdated < now }.forEach { (file, deployment) -> 100 | try { 101 | logger.info { "Removing an expired UDF. File: `$file`, " } 102 | vertx.undeploy(deployment.id).coAwait() 103 | } catch (e: Exception) { 104 | logger.error("Vertx 'undeploy()' failed. File: $file", e) 105 | } 106 | deployments.remove(file) 107 | } 108 | 109 | lastChecked = now 110 | } 111 | 112 | inner class Deployment(var id: String) { 113 | 114 | var lastUpdated = System.currentTimeMillis() 115 | } 116 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/BootstrapTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce 18 | 19 | import io.mockk.junit5.MockKExtension 20 | import io.sip3.commons.domain.media.Codec 21 | import io.sip3.commons.vertx.test.VertxTest 22 | import io.vertx.core.json.JsonObject 23 | import org.junit.jupiter.api.Assertions.assertEquals 24 | import org.junit.jupiter.api.Assertions.assertTrue 25 | import org.junit.jupiter.api.Test 26 | import org.junit.jupiter.api.extension.ExtendWith 27 | 28 | @ExtendWith(MockKExtension::class, MockKSingletonExtension::class, MongoExtension::class) 29 | class BootstrapTest : VertxTest() { 30 | 31 | companion object { 32 | 33 | const val UDF_LOCATION = "src/test/resources/udf/BootstrapTest" 34 | const val CODEC_LOCATION = "src/test/resources/codecs.yml" 35 | } 36 | 37 | init { 38 | System.setProperty("udf.location", UDF_LOCATION) 39 | System.setProperty("codecs.location", CODEC_LOCATION) 40 | } 41 | 42 | @Test 43 | fun `Deploy and test Groovy UDF`() { 44 | val message = "Groovy is awesome" 45 | runTest( 46 | deploy = { 47 | vertx.deployTestVerticle(Bootstrap::class, JsonObject().apply { 48 | put("server", JsonObject().apply { 49 | put("uri", "udp://0.0.0.0:${findRandomPort()}") 50 | }) 51 | put("mongo", JsonObject().apply { 52 | put("uri", MongoExtension.MONGO_URI) 53 | put("db", "sip3-bootstrap-test") 54 | put("bulk_size", 1) 55 | }) 56 | }) 57 | }, 58 | execute = { 59 | vertx.setPeriodic(500, 100) { 60 | vertx.eventBus().send("groovy", message) 61 | } 62 | }, 63 | assert = { 64 | vertx.eventBus().localConsumer("kotlin") { event -> 65 | context.verify { 66 | assertEquals(message, event.body()) 67 | } 68 | context.completeNow() 69 | } 70 | } 71 | ) 72 | } 73 | 74 | @Test 75 | fun `Codec directory read`() { 76 | runTest( 77 | deploy = { 78 | vertx.deployTestVerticle(Bootstrap::class, JsonObject().apply { 79 | put("server", JsonObject().apply { 80 | put("uri", "udp://0.0.0.0:${findRandomPort()}") 81 | }) 82 | put("mongo", JsonObject().apply { 83 | put("uri", MongoExtension.MONGO_URI) 84 | put("db", "sip3-bootstrap-test") 85 | put("bulk_size", 1) 86 | }) 87 | }) 88 | }, 89 | assert = { 90 | vertx.eventBus().localConsumer(RoutesCE.config_change) { event -> 91 | context.verify { 92 | val config = event.body() 93 | assertTrue(config.containsKey("codecs")) 94 | 95 | val codecs = config.getJsonArray("codecs") 96 | assertEquals(1, codecs.size()) 97 | 98 | val codec = (codecs.first() as JsonObject).mapTo(Codec::class.java) 99 | assertEquals("PcmA", codec.name) 100 | assertEquals(0x08, codec.payloadTypes.first()) 101 | assertEquals(8000, codec.clockRate) 102 | assertEquals(0.0F, codec.ie) 103 | assertEquals(4.3F, codec.bpl) 104 | } 105 | 106 | context.completeNow() 107 | } 108 | } 109 | ) 110 | } 111 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/attributes/AttributesRegistry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.attributes 18 | 19 | import io.sip3.commons.domain.Attribute 20 | import io.sip3.commons.util.format 21 | import io.sip3.commons.vertx.util.localSend 22 | import io.sip3.salto.ce.RoutesCE 23 | import io.vertx.core.Vertx 24 | import io.vertx.core.json.JsonObject 25 | import java.time.format.DateTimeFormatter 26 | 27 | /** 28 | * Handles attributes 29 | */ 30 | class AttributesRegistry(val vertx: Vertx, config: JsonObject) { 31 | 32 | companion object { 33 | 34 | const val PREFIX = "attributes" 35 | } 36 | 37 | private var timeSuffix: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") 38 | private var checkPeriod: Long = 5000 39 | 40 | private var currentTimeSuffix: String 41 | 42 | private val registry = mutableMapOf() 43 | 44 | init { 45 | config.getString("time_suffix")?.let { 46 | timeSuffix = DateTimeFormatter.ofPattern(it) 47 | } 48 | config.getJsonObject("attributes")?.getLong("check_period")?.let { 49 | checkPeriod = it 50 | } 51 | 52 | currentTimeSuffix = timeSuffix.format(System.currentTimeMillis()) 53 | vertx.setPeriodic(checkPeriod) { 54 | val newTimeSuffix = timeSuffix.format(System.currentTimeMillis()) 55 | if (currentTimeSuffix < newTimeSuffix) { 56 | currentTimeSuffix = newTimeSuffix 57 | registry.clear() 58 | } 59 | } 60 | } 61 | 62 | fun handle(prefix: String, attributes: Map) { 63 | attributes.forEach { (key, value) -> 64 | handle(prefix, key, value) 65 | } 66 | } 67 | 68 | fun handle(prefix: String, key: String, value: Any) { 69 | val name = "$prefix.$key" 70 | val type = when (value) { 71 | is String -> Attribute.TYPE_STRING 72 | is Number -> Attribute.TYPE_NUMBER 73 | is Boolean -> Attribute.TYPE_BOOLEAN 74 | else -> return 75 | } 76 | 77 | var attribute = registry[name] 78 | if (attribute == null) { 79 | attribute = Attribute().apply { 80 | this.name = name 81 | this.type = type 82 | } 83 | registry[name] = attribute 84 | 85 | if ((value !is String) || value.isEmpty()) { 86 | writeToDatabase(PREFIX, name, type) 87 | return 88 | } 89 | } 90 | 91 | if ((value is String) && value.isNotEmpty()) { 92 | var options = attribute.options 93 | if (options == null) { 94 | options = mutableSetOf() 95 | attribute.options = options 96 | } 97 | 98 | if (options.add(value)) { 99 | writeToDatabase(PREFIX, name, type, value) 100 | } 101 | } 102 | } 103 | 104 | private fun writeToDatabase(prefix: String, name: String, type: String, option: String? = null) { 105 | val collection = prefix + "_" + currentTimeSuffix 106 | 107 | val operation = JsonObject().apply { 108 | put("type", "UPDATE") 109 | put("upsert", true) 110 | put("filter", JsonObject().apply { 111 | put("name", name) 112 | }) 113 | put("document", JsonObject().apply { 114 | put("\$setOnInsert", JsonObject().apply { 115 | put("type", type) 116 | }) 117 | if (option != null) { 118 | put("\$addToSet", JsonObject().apply { 119 | put("options", option) 120 | }) 121 | } 122 | }) 123 | } 124 | 125 | vertx.eventBus().localSend(RoutesCE.mongo_bulk_writer, Pair(collection, operation)) 126 | } 127 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/server/UdpServerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.server 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.salto.ce.RoutesCE 21 | import io.sip3.salto.ce.domain.Address 22 | import io.vertx.core.buffer.Buffer 23 | import io.vertx.core.json.JsonObject 24 | import io.vertx.kotlin.coroutines.coAwait 25 | import org.junit.jupiter.api.Assertions.* 26 | import org.junit.jupiter.api.Test 27 | import java.nio.charset.Charset 28 | 29 | class UdpServerTest : VertxTest() { 30 | 31 | companion object { 32 | 33 | const val MESSAGE_1 = "SIP3 is awesome!" 34 | const val MESSAGE_2 = "HEP3 is awesome!" 35 | val MESSAGE_3 = byteArrayOf(0x02, 0x10, 0x02, 0x42) 36 | } 37 | 38 | @Test 39 | fun `Retrieve SIP3 packet via UDP`() { 40 | val port = findRandomPort() 41 | runTest( 42 | deploy = { 43 | vertx.deployTestVerticle(UdpServer::class, JsonObject().apply { 44 | put("server", JsonObject().apply { 45 | put("uri", "udp://127.0.0.1:$port") 46 | }) 47 | }) 48 | }, 49 | execute = { 50 | vertx.setPeriodic(100L, 100L) { 51 | vertx.createDatagramSocket().send(MESSAGE_1, port, "127.0.0.1") 52 | } 53 | 54 | }, 55 | assert = { 56 | vertx.eventBus().consumer>(RoutesCE.sip3) { event -> 57 | val (sender, buffer) = event.body() 58 | context.verify { 59 | assertEquals("127.0.0.1", sender.addr) 60 | assertEquals(MESSAGE_1, buffer.toString(Charset.defaultCharset())) 61 | } 62 | context.completeNow() 63 | } 64 | } 65 | ) 66 | } 67 | 68 | @Test 69 | fun `Retrieve HEP3 packet via UDP`() { 70 | val port = findRandomPort() 71 | runTest( 72 | deploy = { 73 | vertx.deployTestVerticle(UdpServer::class, JsonObject().apply { 74 | put("server", JsonObject().apply { 75 | put("uri", "udp://127.0.0.1:$port") 76 | }) 77 | }) 78 | }, 79 | execute = { 80 | vertx.createDatagramSocket() 81 | .send(Buffer.buffer(MESSAGE_2), port, "127.0.0.1").coAwait() 82 | }, 83 | assert = { 84 | vertx.eventBus().consumer>(RoutesCE.hep3) { event -> 85 | val (sender, buffer) = event.body() 86 | context.verify { 87 | assertEquals("127.0.0.1", sender.addr) 88 | assertEquals(MESSAGE_2, buffer.toString(Charset.defaultCharset())) 89 | } 90 | context.completeNow() 91 | } 92 | } 93 | ) 94 | } 95 | 96 | @Test 97 | fun `Retrieve HEP2 packet via UDP`() { 98 | val port = findRandomPort() 99 | runTest( 100 | deploy = { 101 | vertx.deployTestVerticle(UdpServer::class, JsonObject().apply { 102 | put("server", JsonObject().apply { 103 | put("uri", "udp://127.0.0.1:$port") 104 | }) 105 | }) 106 | }, 107 | execute = { 108 | vertx.createDatagramSocket() 109 | .send(Buffer.buffer(MESSAGE_3), port, "127.0.0.1").coAwait() 110 | }, 111 | assert = { 112 | vertx.eventBus().consumer>(RoutesCE.hep2) { event -> 113 | val (sender, buffer) = event.body() 114 | context.verify { 115 | assertEquals("127.0.0.1", sender.addr) 116 | assertArrayEquals(MESSAGE_3, buffer.bytes) 117 | } 118 | context.completeNow() 119 | } 120 | } 121 | ) 122 | } 123 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/server/AbstractServerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.server 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.salto.ce.RoutesCE 21 | import io.sip3.salto.ce.domain.Address 22 | import io.vertx.core.buffer.Buffer 23 | import io.vertx.core.json.JsonObject 24 | import org.junit.jupiter.api.Assertions.assertEquals 25 | import org.junit.jupiter.api.Test 26 | import java.nio.charset.Charset 27 | 28 | class AbstractServerTest : VertxTest() { 29 | 30 | companion object { 31 | 32 | const val MESSAGE_1 = "SIP3 is awesome!" 33 | const val MESSAGE_2 = "HEP3 is awesome!" 34 | val MESSAGE_3 = byteArrayOf(0x02, 0x10, 0x02, 0x42).toString(Charset.defaultCharset()) 35 | } 36 | 37 | @Test 38 | fun `Handle SIP3 Packet from server`() { 39 | runTest( 40 | deploy = { 41 | vertx.deployTestVerticle(ServerTestImpl::class, JsonObject().apply { 42 | put("a", "value1") 43 | put("b", "value2") 44 | put("message", MESSAGE_1) 45 | }) 46 | }, 47 | execute = { }, 48 | assert = { 49 | vertx.eventBus().consumer>(RoutesCE.sip3) { event -> 50 | val (sender, buffer) = event.body() 51 | context.verify { 52 | assertEquals("127.0.1.2", sender.addr) 53 | assertEquals(3456, sender.port) 54 | assertEquals("$MESSAGE_1-value1-value2", buffer.toString()) 55 | } 56 | context.completeNow() 57 | } 58 | } 59 | ) 60 | } 61 | 62 | @Test 63 | fun `Handle HEP3 Packet from server`() { 64 | runTest( 65 | deploy = { 66 | vertx.deployTestVerticle(ServerTestImpl::class, JsonObject().apply { 67 | put("a", "value1") 68 | put("b", "value2") 69 | put("message", MESSAGE_2) 70 | }) 71 | }, 72 | execute = { }, 73 | assert = { 74 | vertx.eventBus().consumer>(RoutesCE.hep3) { event -> 75 | val (sender, buffer) = event.body() 76 | context.verify { 77 | assertEquals("127.0.1.2", sender.addr) 78 | assertEquals(3456, sender.port) 79 | assertEquals("$MESSAGE_2-value1-value2", buffer.toString()) 80 | } 81 | context.completeNow() 82 | } 83 | } 84 | ) 85 | } 86 | 87 | @Test 88 | fun `Handle HEP2 Packet from server`() { 89 | runTest( 90 | deploy = { 91 | vertx.deployTestVerticle(ServerTestImpl::class, JsonObject().apply { 92 | put("a", "value1") 93 | put("b", "value2") 94 | put("message", MESSAGE_3) 95 | }) 96 | }, 97 | execute = { }, 98 | assert = { 99 | vertx.eventBus().consumer>(RoutesCE.hep2) { event -> 100 | val (sender, buffer) = event.body() 101 | context.verify { 102 | assertEquals("127.0.1.2", sender.addr) 103 | assertEquals(3456, sender.port) 104 | assertEquals("$MESSAGE_3-value1-value2", buffer.toString()) 105 | } 106 | context.completeNow() 107 | } 108 | } 109 | ) 110 | } 111 | } 112 | 113 | class ServerTestImpl : AbstractServer() { 114 | 115 | lateinit var a: String 116 | lateinit var b: String 117 | lateinit var message: String 118 | 119 | lateinit var server: String 120 | 121 | override fun readConfig() { 122 | a = config().getString("a") 123 | b = config().getString("b") 124 | message = config().getString("message") 125 | } 126 | 127 | override fun startServer() { 128 | server = "SomeServer-$a-$b" 129 | 130 | val address = Address().apply { 131 | addr = "127.0.1.2" 132 | port = 3456 133 | } 134 | vertx.setPeriodic(100L, 100L) { 135 | onRawPacket(address, Buffer.buffer("$message-$a-$b")) 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/router/Router.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.router 18 | 19 | import io.sip3.commons.PacketTypes 20 | import io.sip3.commons.ProtocolCodes 21 | import io.sip3.commons.domain.payload.RawPayload 22 | import io.sip3.commons.micrometer.Metrics 23 | import io.sip3.commons.vertx.annotations.Instance 24 | import io.sip3.commons.vertx.util.localSend 25 | import io.sip3.salto.ce.RoutesCE 26 | import io.sip3.salto.ce.domain.Address 27 | import io.sip3.salto.ce.domain.Packet 28 | import io.sip3.salto.ce.management.host.HostRegistry 29 | import io.sip3.salto.ce.udf.UdfExecutor 30 | import io.vertx.core.AbstractVerticle 31 | import io.vertx.core.buffer.Buffer 32 | import mu.KotlinLogging 33 | 34 | /** 35 | * Routes packets by `protocolCode` 36 | */ 37 | @Instance 38 | open class Router : AbstractVerticle() { 39 | 40 | private val logger = KotlinLogging.logger {} 41 | 42 | companion object { 43 | 44 | const val PROTO_HEP3 = "HEP3" 45 | } 46 | 47 | val packetsRouted = Metrics.counter("packets_routed") 48 | 49 | lateinit var udfExecutor: UdfExecutor 50 | private lateinit var hostRegistry: HostRegistry 51 | 52 | override fun start() { 53 | udfExecutor = UdfExecutor(vertx) 54 | hostRegistry = HostRegistry.getInstance(vertx, config()) 55 | 56 | vertx.eventBus().localConsumer>>(RoutesCE.router) { event -> 57 | val (sender, packets) = event.body() 58 | packets.forEach { packet -> 59 | try { 60 | handle(sender, packet) 61 | } catch (e: Exception) { 62 | logger.error("Router 'handle()' failed.", e) 63 | } 64 | } 65 | } 66 | } 67 | 68 | open fun handle(sender: Address, packet: Packet) { 69 | // Route Raw Packets 70 | if (packet.type == PacketTypes.RAW) { 71 | routeRaw(sender, packet) 72 | return; 73 | } 74 | 75 | // Assign host to all addresses 76 | assignHost(sender) 77 | assignHost(packet.srcAddr) 78 | assignHost(packet.dstAddr) 79 | 80 | udfExecutor.execute(RoutesCE.packet_udf, 81 | // Prepare UDF payload 82 | mappingFunction = { 83 | mutableMapOf().apply { 84 | put("sender_addr", sender.addr) 85 | put("sender_port", sender.port) 86 | sender.host?.let { put("sender_host", it) } 87 | 88 | put("payload", mutableMapOf().apply { 89 | val src = packet.srcAddr 90 | put("src_addr", src.addr) 91 | put("src_port", src.port) 92 | src.host?.let { put("src_host", it) } 93 | 94 | val dst = packet.dstAddr 95 | put("dst_addr", dst.addr) 96 | put("dst_port", dst.port) 97 | dst.host?.let { put("dst_host", it) } 98 | }) 99 | } 100 | }, 101 | // Handle UDF result 102 | completionHandler = { asr -> 103 | val (result, _) = asr.result() 104 | if (result) { 105 | route(packet) 106 | } 107 | }) 108 | } 109 | 110 | open fun routeRaw(sender: Address, packet: Packet) { 111 | val rawPayload = RawPayload().apply { 112 | decode(packet.payload) 113 | } 114 | 115 | // Route HEP3 packet 116 | val buffer = Buffer.buffer(rawPayload.payload) 117 | if (buffer.getString(0, 4) == PROTO_HEP3) { 118 | vertx.eventBus().localSend(RoutesCE.hep3, Pair(sender, buffer)) 119 | } 120 | } 121 | 122 | open fun assignHost(address: Address) { 123 | hostRegistry.getHostName(address.addr, address.port)?.let { address.host = it } 124 | } 125 | 126 | open fun route(packet: Packet) { 127 | val route = when (packet.protocolCode) { 128 | ProtocolCodes.SIP -> RoutesCE.sip 129 | ProtocolCodes.RTCP -> RoutesCE.rtcp 130 | ProtocolCodes.RTPR -> RoutesCE.rtpr 131 | ProtocolCodes.RTPE -> RoutesCE.rtpe 132 | ProtocolCodes.REC -> RoutesCE.rec 133 | else -> null 134 | } 135 | 136 | if (route != null) { 137 | packetsRouted.increment() 138 | vertx.eventBus().localSend(route, packet) 139 | } 140 | } 141 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/attributes/AttributesRegistryTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.attributes 18 | 19 | import io.sip3.commons.domain.Attribute 20 | import io.sip3.commons.vertx.test.VertxTest 21 | import io.sip3.salto.ce.RoutesCE 22 | import io.vertx.core.json.JsonObject 23 | import org.junit.jupiter.api.Assertions.* 24 | import org.junit.jupiter.api.Test 25 | 26 | class AttributesRegistryTest : VertxTest() { 27 | 28 | @Test 29 | fun `Write STRING attribute`() { 30 | var attributesRegistry: AttributesRegistry? = null 31 | runTest( 32 | deploy = { 33 | attributesRegistry = AttributesRegistry(vertx, JsonObject()) 34 | }, 35 | execute = { 36 | val attributes = mapOf("name" to "string") 37 | attributesRegistry?.handle(Attribute.TYPE_STRING, attributes) 38 | }, 39 | assert = { 40 | vertx.eventBus().consumer>(RoutesCE.mongo_bulk_writer) { event -> 41 | val (collection, operation) = event.body() 42 | 43 | val filter = operation.getJsonObject("filter") 44 | val document = operation.getJsonObject("document") 45 | 46 | context.verify { 47 | assertTrue(collection.startsWith("attributes")) 48 | assertEquals(Attribute.TYPE_STRING + ".name", filter.getString("name")) 49 | assertEquals(Attribute.TYPE_STRING, document.getJsonObject("\$setOnInsert").getString("type")) 50 | } 51 | if (document.containsKey("\$addToSet")) { 52 | context.completeNow() 53 | } 54 | } 55 | } 56 | ) 57 | } 58 | 59 | @Test 60 | fun `Write NUMBER attribute`() { 61 | var attributesRegistry: AttributesRegistry? = null 62 | runTest( 63 | deploy = { 64 | attributesRegistry = AttributesRegistry(vertx, JsonObject()) 65 | }, 66 | execute = { 67 | val attributes = mapOf("name" to 42) 68 | attributesRegistry?.handle(Attribute.TYPE_NUMBER, attributes) 69 | }, 70 | assert = { 71 | vertx.eventBus().consumer>(RoutesCE.mongo_bulk_writer) { event -> 72 | val (collection, operation) = event.body() 73 | 74 | val filter = operation.getJsonObject("filter") 75 | val document = operation.getJsonObject("document") 76 | 77 | context.verify { 78 | assertTrue(collection.startsWith("attributes")) 79 | assertEquals(Attribute.TYPE_NUMBER + ".name", filter.getString("name")) 80 | assertEquals(Attribute.TYPE_NUMBER, document.getJsonObject("\$setOnInsert").getString("type")) 81 | assertFalse(document.containsKey("\$addToSet")) 82 | } 83 | context.completeNow() 84 | } 85 | } 86 | ) 87 | } 88 | 89 | @Test 90 | fun `Write BOOLEAN attribute`() { 91 | var attributesRegistry: AttributesRegistry? = null 92 | runTest( 93 | deploy = { 94 | attributesRegistry = AttributesRegistry(vertx, JsonObject()) 95 | }, 96 | execute = { 97 | val attributes = mapOf("name" to true) 98 | attributesRegistry?.handle(Attribute.TYPE_BOOLEAN, attributes) 99 | }, 100 | assert = { 101 | vertx.eventBus().consumer>(RoutesCE.mongo_bulk_writer) { event -> 102 | val (collection, operation) = event.body() 103 | 104 | val filter = operation.getJsonObject("filter") 105 | val document = operation.getJsonObject("document") 106 | 107 | context.verify { 108 | assertTrue(collection.startsWith("attributes")) 109 | assertEquals(Attribute.TYPE_BOOLEAN + ".name", filter.getString("name")) 110 | assertEquals(Attribute.TYPE_BOOLEAN, document.getJsonObject("\$setOnInsert").getString("type")) 111 | assertFalse(document.containsKey("\$addToSet")) 112 | } 113 | context.completeNow() 114 | } 115 | } 116 | ) 117 | } 118 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/rtpr/RtprSession.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.rtpr 18 | 19 | import io.sip3.commons.domain.media.MediaControl 20 | import io.sip3.commons.domain.payload.RtpReportPayload 21 | import io.sip3.salto.ce.domain.Address 22 | import io.sip3.salto.ce.domain.Packet 23 | import kotlin.math.max 24 | import kotlin.math.min 25 | 26 | class RtprSession { 27 | 28 | companion object { 29 | 30 | fun create(source: Byte, mediaControl: MediaControl, packet: Packet): RtprSession { 31 | return RtprSession().apply { 32 | this.source = source 33 | this.mediaControl = mediaControl 34 | 35 | val sdpSession = mediaControl.sdpSession 36 | when (source) { 37 | RtpReportPayload.SOURCE_RTP -> { 38 | if (sdpSession.src.rtpId == packet.srcAddr.sdpSessionId() 39 | || sdpSession.dst.rtpId == packet.dstAddr.sdpSessionId() 40 | ) { 41 | srcAddr = packet.srcAddr 42 | dstAddr = packet.dstAddr 43 | } else { 44 | srcAddr = packet.dstAddr 45 | dstAddr = packet.srcAddr 46 | } 47 | } 48 | 49 | RtpReportPayload.SOURCE_RTCP -> { 50 | if (sdpSession.src.rtcpId == packet.dstAddr.sdpSessionId() 51 | || sdpSession.dst.rtcpId == packet.srcAddr.sdpSessionId() 52 | ) { 53 | srcAddr = packet.dstAddr 54 | dstAddr = packet.srcAddr 55 | } else { 56 | srcAddr = packet.srcAddr 57 | dstAddr = packet.dstAddr 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | var createdAt: Long = Long.MAX_VALUE 66 | var terminatedAt: Long = 0L 67 | 68 | lateinit var srcAddr: Address 69 | lateinit var dstAddr: Address 70 | 71 | var source: Byte = RtpReportPayload.SOURCE_RTP 72 | lateinit var mediaControl: MediaControl 73 | 74 | var recorded = false 75 | 76 | var rFactorThreshold: Float? = null 77 | 78 | var forward: RtprStream? = null 79 | var reverse: RtprStream? = null 80 | 81 | val callId: String 82 | get() = mediaControl.callId 83 | val caller: String 84 | get() = mediaControl.caller 85 | val callee: String 86 | get() = mediaControl.callee 87 | 88 | val reportCount: Int 89 | get() = (forward?.reportCount ?: 0) + (reverse?.reportCount ?: 0) 90 | val badReportCount: Int 91 | get() = (forward?.badReportCount ?: 0) + (reverse?.badReportCount ?: 0) 92 | val badReportFraction: Double 93 | get() { 94 | return if (reportCount > 0) { 95 | badReportCount / reportCount.toDouble() 96 | } else { 97 | 0.0 98 | } 99 | } 100 | 101 | val codecs: Set 102 | get() = mutableSetOf().apply { 103 | forward?.codecNames?.let { addAll(it) } 104 | reverse?.codecNames?.let { addAll(it) } 105 | } 106 | 107 | val isOneWay: Boolean 108 | get() = (source == RtpReportPayload.SOURCE_RTP) && ((forward != null) xor (reverse != null)) 109 | 110 | val duration: Long 111 | get() = terminatedAt - createdAt 112 | 113 | val attributes: MutableMap 114 | get() = mutableMapOf().apply { 115 | forward?.attributes?.forEach { (name, value) -> put(name, value) } 116 | reverse?.attributes?.forEach { (name, value) -> put(name, value) } 117 | } 118 | 119 | fun add(packet: Packet, payload: RtpReportPayload) { 120 | val isForward = if (source == RtpReportPayload.SOURCE_RTP) { 121 | packet.srcAddr.equals(srcAddr) || packet.dstAddr.equals(dstAddr) 122 | } else { 123 | packet.srcAddr.equals(dstAddr) || packet.dstAddr.equals(srcAddr) 124 | } 125 | 126 | // Update streams 127 | if (isForward) { 128 | if (forward == null) forward = RtprStream(rFactorThreshold) 129 | forward!!.add(packet, payload) 130 | createdAt = min(createdAt, forward!!.createdAt) 131 | terminatedAt = max(terminatedAt, forward!!.terminatedAt) 132 | } else { 133 | if (reverse == null) reverse = RtprStream(rFactorThreshold) 134 | reverse!!.add(packet, payload) 135 | createdAt = min(createdAt, reverse!!.createdAt) 136 | terminatedAt = max(terminatedAt, reverse!!.terminatedAt) 137 | } 138 | 139 | recorded = payload.recorded || recorded 140 | } 141 | } -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | #! Name 2 | name: sip3-salto 3 | version: 2025.1.1-SNAPSHOT 4 | 5 | #! Vert.x 6 | #vertx: 7 | # instances: 1 8 | 9 | #! Server 10 | #server: 11 | # uri: udp://0.0.0.0:15060 12 | # buffer_size: 65535 13 | # ssl: 14 | # key_store: ... 15 | # key_store_password: ... 16 | 17 | #! Management socket 18 | #management: 19 | # uri: udp://0.0.0.0:15091 20 | # expiration_delay: 60000 21 | # expiration_timeout: 120000 22 | # cleanup_timeout: 300000 23 | # publish_media_control_mode: 0 24 | 25 | #! Application 26 | #time_suffix: yyyyMMdd 27 | #udf: 28 | # check_period: 10000 29 | # execution_timeout: 100 30 | #attributes: 31 | # record_ip_addresses: true 32 | # record_call_users: true 33 | # check_period: 5000 34 | #sip: 35 | # message: 36 | # exclusions: [MESSAGE, SUBSCRIBE, NOTIFY, OPTIONS] 37 | # x_correlation_header: X-Call-ID 38 | # parser: 39 | # mode: 1 40 | # extension_headers: [User-Agent, Reason, Diversion] 41 | # transaction: 42 | # expiration_delay: 1000 43 | # response_timeout: 3000 44 | # aggregation_timeout: 60000 45 | # termination_timeout: 4500 46 | # save_sip_message_payload_mode: 0 47 | # call: 48 | # expiration_delay: 1000 49 | # aggregation_timeout: 60000 50 | # termination_timeout: 2000 51 | # duration_timeout: 3600000 52 | # duration_distributions: [10s, 30s, 1m, 5m, 1h] 53 | # correlation: 54 | # role: reporter|aggregator 55 | # register: 56 | # expiration_delay: 1000 57 | # aggregation_timeout: 10000 58 | # update_period: 60000 59 | # duration_timeout: 900000 60 | #media: 61 | # rtcp: 62 | # expiration_delay: 1000 63 | # aggregation_timeout: 30000 64 | # rtp_r: 65 | # expiration_delay: 1000 66 | # aggregation_timeout: 30000 67 | # min_expected_packets: 100 68 | # r_factor_distributions: [30, 50, 75, 95] 69 | # duration_distributions: [10s, 30s, 1m, 5m, 1h] 70 | 71 | #! Metrics 72 | #metrics: 73 | # logging: 74 | # step: 1000 75 | # influxdb: 76 | # uri: http://127.0.0.1:8086 77 | # db: sip3 78 | # step: 1000 79 | # retention_duration: 7d 80 | # statsd: 81 | # host: 127.0.0.1 82 | # port: 8125 83 | # step: 1000 84 | # flavour: datadog 85 | # elastic: 86 | # host: 127.0.0.1:9200 87 | # index: sip3 88 | # step: 1000 89 | 90 | #! Mongo 91 | #mongo: 92 | # uri: mongodb://127.0.0.1:27017 93 | # db: sip3 94 | # bulk_size: 1 95 | # update_period: 300000 96 | # management: 97 | # uri: mongodb://127.0.0.1:27017 98 | # db: sip3 99 | # collections: 100 | # - prefix: attributes 101 | # indexes: 102 | # ascending: [name] 103 | # max_collections: 30 104 | # - prefix: sip_call_index 105 | # indexes: 106 | # ascending: [created_at, terminated_at, src_addr, src_host, dst_addr, dst_host, caller, callee, state, error_code, error_type, duration, trying_delay, setup_time, establish_time, disconnect_time, transactions, retransmits, terminated_by, debug] 107 | # hashed: [call_id, x_call_id] 108 | # hint: 109 | # call_id: hashed 110 | # max_collections: 30 111 | # - prefix: sip_call_raw 112 | # indexes: 113 | # ascending: [created_at] 114 | # hashed: [call_id] 115 | # max_collections: 30 116 | # - prefix: sip_register_index 117 | # indexes: 118 | # ascending: [created_at, terminated_at, src_addr, src_host, dst_addr, dst_host, caller, callee, state, error_code, error_type transactions, retransmits, debug] 119 | # hashed: [call_id] 120 | # hint: 121 | # call_id: hashed 122 | # max_collections: 30 123 | # - prefix: sip_register_raw 124 | # indexes: 125 | # ascending: [created_at] 126 | # hashed: [call_id] 127 | # max_collections: 30 128 | # - prefix: sip_message_index 129 | # indexes: 130 | # ascending: [created_at, terminated_at, src_addr, src_host, dst_addr, dst_host, caller, callee, state, error_code, error_type, retransmits, debug] 131 | # hashed: [call_id] 132 | # max_collections: 30 133 | # - prefix: sip_message_raw 134 | # indexes: 135 | # ascending: [created_at] 136 | # hashed: [call_id] 137 | # max_collections: 30 138 | # - prefix: sip_options_index 139 | # indexes: 140 | # ascending: [created_at, terminated_at, src_addr, src_host, dst_addr, dst_host, caller, callee, state, error_code, error_type, retransmits, debug] 141 | # hashed: [call_id] 142 | # max_collections: 30 143 | # - prefix: sip_options_raw 144 | # indexes: 145 | # ascending: [created_at] 146 | # hashed: [call_id] 147 | # max_collections: 30 148 | # - prefix: rtpr_rtp_index 149 | # indexes: 150 | # ascending: [created_at, src_addr, src_host, dst_addr, dst_host, caller, callee, recorded, mos, r_factor, codec, one_way, duration, bad_report_fraction] 151 | # hashed: [call_id] 152 | # max_collections: 30 153 | # - prefix: rtpr_rtp_raw 154 | # indexes: 155 | # ascending: [created_at] 156 | # hashed: [call_id] 157 | # max_collections: 30 158 | # - prefix: rtpr_rtcp_index 159 | # indexes: 160 | # ascending: [created_at, src_addr, src_host, dst_addr, dst_host, caller, callee, mos, r_factor, codec, one_way, duration, bad_report_fraction] 161 | # hashed: [call_id] 162 | # max_collections: 30 163 | # - prefix: rtpr_rtcp_raw 164 | # indexes: 165 | # ascending: [created_at] 166 | # hashed: [call_id] 167 | # max_collections: 30 168 | # - prefix: rec_raw 169 | # indexes: 170 | # ascending: [created_at, src_addr, src_host, dst_addr, dst_host] 171 | # hashed: [call_id] 172 | # max_collections: 30 -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/management/TcpServerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.commons.vertx.util.localReply 21 | import io.sip3.salto.ce.RoutesCE 22 | import io.vertx.core.json.JsonArray 23 | import io.vertx.core.json.JsonObject 24 | import org.junit.jupiter.api.Assertions.* 25 | import org.junit.jupiter.api.BeforeEach 26 | import org.junit.jupiter.api.Test 27 | import java.net.URI 28 | import java.util.* 29 | 30 | class TcpServerTest : VertxTest() { 31 | 32 | companion object { 33 | 34 | private const val DELIMITER = "\r\n\r\n3PIS\r\n\r\n" 35 | 36 | private val HOST = JsonObject().apply { 37 | put("name", "sbc1") 38 | put("addr", JsonArray().apply { 39 | add("10.10.10.10") 40 | add("10.10.20.10:5060") 41 | }) 42 | } 43 | 44 | private val CONFIG = JsonObject().apply { 45 | put("management", JsonObject().apply { 46 | put("uri", "tcp://127.0.0.1:15090") 47 | put("register_delay", 2000L) 48 | }) 49 | put("host", HOST) 50 | put("rtp", JsonObject().apply { 51 | put("enabled", true) 52 | }) 53 | } 54 | 55 | private val DEPLOYMENT_ID = UUID.randomUUID().toString() 56 | 57 | private val REGISTER_MESSAGE_1 = JsonObject().apply { 58 | put("type", ManagementHandler.TYPE_REGISTER) 59 | put("payload", JsonObject().apply { 60 | put("timestamp", System.currentTimeMillis()) 61 | put("deployment_id", DEPLOYMENT_ID) 62 | put("config", CONFIG) 63 | }) 64 | } 65 | 66 | private val REGISTER_MESSAGE_2 = JsonObject().apply { 67 | put("type", ManagementHandler.TYPE_REGISTER) 68 | put("payload", JsonObject().apply { 69 | put("timestamp", System.currentTimeMillis()) 70 | put("name", DEPLOYMENT_ID) 71 | put("config", CONFIG.copy().apply { 72 | remove("host") 73 | }) 74 | }) 75 | } 76 | } 77 | 78 | private lateinit var config: JsonObject 79 | private var localPort = -1 80 | private var remotePort = -1 81 | 82 | @BeforeEach 83 | fun init() { 84 | localPort = findRandomPort() 85 | remotePort = findRandomPort() 86 | config = JsonObject().apply { 87 | put("name", "sip3-salto-unit-test") 88 | 89 | put("server", JsonObject().apply { 90 | put("uri", "tcp://127.0.0.1:15060") 91 | }) 92 | 93 | put("management", JsonObject().apply { 94 | put("uri", "tcp://127.0.0.1:$localPort") 95 | put("expiration_timeout", 1500L) 96 | put("expiration_delay", 800L) 97 | }) 98 | 99 | put("mongo", JsonObject().apply { 100 | put("management", JsonObject().apply { 101 | put("uri", "mongodb://superhost.com:10000/?w=1") 102 | put("db", "salto-component-management-test") 103 | }) 104 | put("uri", "mongodb://superhost.com:20000/?w=1") 105 | put("db", "salto-component-test") 106 | }) 107 | } 108 | } 109 | 110 | @Test 111 | fun `Receive register from remote host with host information`() { 112 | runTest( 113 | deploy = { 114 | vertx.deployTestVerticle(TcpServer::class, config) 115 | }, 116 | execute = { 117 | vertx.createNetClient().connect(localPort, "127.0.0.1").onSuccess { socket -> 118 | socket.handler { buffer -> 119 | val text = buffer.toString() 120 | context.verify { 121 | assertTrue(text.endsWith(DELIMITER)) 122 | val response = JsonObject(text.replace(DELIMITER, "")) 123 | assertEquals("register_response", response.getString("type")) 124 | assertNotNull(response.getJsonObject("payload")) 125 | } 126 | context.completeNow() 127 | } 128 | 129 | socket.write(REGISTER_MESSAGE_1.toBuffer().appendString(DELIMITER)) 130 | } 131 | 132 | }, 133 | assert = { 134 | vertx.eventBus().localConsumer>(RoutesCE.management) { event -> 135 | val (uri, message) = event.body() 136 | context.verify { 137 | assertEquals(REGISTER_MESSAGE_1.getString("type"), message.getString("type")) 138 | } 139 | event.localReply(JsonObject().apply { 140 | put("type", "register_response") 141 | put("payload", JsonObject()) 142 | }) 143 | } 144 | } 145 | ) 146 | } 147 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/mongo/MongoCollectionManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.mongo 18 | 19 | import io.sip3.commons.mongo.MongoClient 20 | import io.sip3.commons.vertx.annotations.Instance 21 | import io.sip3.commons.vertx.util.localReply 22 | import io.sip3.salto.ce.RoutesCE 23 | import io.vertx.core.json.JsonObject 24 | import io.vertx.kotlin.coroutines.CoroutineVerticle 25 | import io.vertx.kotlin.coroutines.coAwait 26 | import io.vertx.kotlin.coroutines.dispatcher 27 | import kotlinx.coroutines.GlobalScope 28 | import kotlinx.coroutines.launch 29 | import mu.KotlinLogging 30 | import java.text.DateFormat 31 | import java.text.SimpleDateFormat 32 | import java.util.* 33 | import kotlin.coroutines.CoroutineContext 34 | 35 | /** 36 | * Manages MongoDB collections 37 | */ 38 | @Instance(order = 0, singleton = true) 39 | class MongoCollectionManager : CoroutineVerticle() { 40 | 41 | private val logger = KotlinLogging.logger {} 42 | 43 | companion object { 44 | 45 | const val DEFAULT_MAX_COLLECTIONS = 30 46 | const val COLLECTIONS_AHEAD: Int = 4 47 | const val STASH_SUFFIX = "stash" 48 | } 49 | 50 | private var timeSuffix: DateFormat = SimpleDateFormat("yyyyMMdd").apply { 51 | timeZone = TimeZone.getTimeZone("UTC") 52 | } 53 | private var timeSuffixInterval: Long = 0 54 | 55 | private lateinit var client: io.vertx.ext.mongo.MongoClient 56 | private var updatePeriod: Long = 3600000 57 | private var collections = mutableListOf() 58 | 59 | override suspend fun start() { 60 | config.getString("time_suffix")?.let { 61 | timeSuffix = SimpleDateFormat(it).apply { 62 | timeZone = TimeZone.getTimeZone("UTC") 63 | } 64 | } 65 | 66 | config.getJsonObject("mongo").let { config -> 67 | client = MongoClient.createShared(vertx, config) 68 | config.getLong("update_period")?.let { updatePeriod = it } 69 | config.getJsonArray("collections")?.forEach { 70 | collections.add(it as JsonObject) 71 | } 72 | } 73 | 74 | defineTimeSuffixInterval() 75 | 76 | vertx.setPeriodic(0L, updatePeriod) { 77 | GlobalScope.launch(vertx.dispatcher() as CoroutineContext) { 78 | manageCollections() 79 | } 80 | } 81 | 82 | vertx.eventBus().localConsumer(RoutesCE.mongo_collection_hint) { event -> 83 | val prefix = event.body() 84 | event.localReply(findHint(prefix)) 85 | } 86 | } 87 | 88 | private fun findHint(prefix: String): JsonObject? { 89 | return collections.firstOrNull { it.getString("prefix") == prefix }?.getJsonObject("hint") 90 | } 91 | 92 | private fun defineTimeSuffixInterval() { 93 | val now = System.currentTimeMillis() 94 | 95 | val first = timeSuffix.format(now) 96 | var second: String 97 | 98 | var i = 1 99 | do { 100 | second = timeSuffix.format(now + updatePeriod * i++) 101 | } while (second == first) 102 | 103 | timeSuffixInterval = timeSuffix.parse(second).time - timeSuffix.parse(first).time 104 | } 105 | 106 | private suspend fun manageCollections() { 107 | val now = System.currentTimeMillis() 108 | 109 | val timeSuffixes = (0..COLLECTIONS_AHEAD).map { timeSuffix.format(now + timeSuffixInterval * it) } 110 | 111 | // Drop and create collections 112 | collections.forEach { collection -> 113 | try { 114 | // Drop old collections 115 | dropOldCollections(collection) 116 | 117 | // Create new collections if needed 118 | timeSuffixes.forEach { 119 | createCollectionIfNeeded(collection.getString("prefix") + "_$it", collection.getJsonObject("indexes")) 120 | } 121 | } catch (e: Exception) { 122 | logger.error(e) { "MongoCollectionManager 'manageCollections()' failed." } 123 | } 124 | } 125 | } 126 | 127 | private suspend fun dropOldCollections(collection: JsonObject) { 128 | client.collections.coAwait() 129 | .filter { name -> name.startsWith(collection.getString("prefix")) } 130 | .filter { name -> !name.endsWith("_$STASH_SUFFIX") } 131 | .sortedDescending() 132 | .drop((collection.getInteger("max_collections") ?: DEFAULT_MAX_COLLECTIONS) + COLLECTIONS_AHEAD) 133 | .forEach { name -> client.dropCollection(name).coAwait() } 134 | } 135 | 136 | private suspend fun createCollectionIfNeeded(name: String, indexes: JsonObject? = null) { 137 | // Create collection 138 | if (!client.collections.coAwait().contains(name)) { 139 | try { 140 | client.createCollection(name).coAwait() 141 | } catch (e: Exception) { 142 | logger.debug(e) { "MongoClient 'createCollection()' failed." } 143 | } 144 | } 145 | 146 | // Create collection indexes 147 | if (indexes != null) { 148 | val indexCount = client.listIndexes(name).coAwait().count() 149 | if (indexCount < 2) { 150 | createIndexes(name, indexes) 151 | } 152 | } 153 | } 154 | 155 | private suspend fun createIndexes(name: String, indexes: JsonObject) { 156 | // Create ascending indexes if needed 157 | indexes.getJsonArray("ascending")?.forEach { index -> 158 | client.createIndex(name, JsonObject().apply { 159 | put(index as String, 1) 160 | }).coAwait() 161 | } 162 | 163 | // Create hashed indexes if needed 164 | indexes.getJsonArray("hashed")?.forEach { index -> 165 | client.createIndex(name, JsonObject().apply { 166 | put(index as String, "hashed") 167 | }).coAwait() 168 | } 169 | } 170 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/decoder/Decoder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.decoder 18 | 19 | import io.netty.buffer.ByteBufUtil 20 | import io.sip3.commons.PacketTypes 21 | import io.sip3.commons.micrometer.Metrics 22 | import io.sip3.commons.util.IpUtil 23 | import io.sip3.commons.vertx.annotations.Instance 24 | import io.sip3.commons.vertx.util.localSend 25 | import io.sip3.salto.ce.RoutesCE 26 | import io.sip3.salto.ce.domain.Address 27 | import io.sip3.salto.ce.domain.Packet 28 | import io.vertx.core.AbstractVerticle 29 | import io.vertx.core.buffer.Buffer 30 | import mu.KotlinLogging 31 | import java.io.ByteArrayInputStream 32 | import java.util.zip.InflaterInputStream 33 | 34 | /** 35 | * Decodes packets in SIP3 protocol 36 | */ 37 | @Instance 38 | class Decoder : AbstractVerticle() { 39 | 40 | private val logger = KotlinLogging.logger {} 41 | 42 | companion object { 43 | 44 | const val HEADER_LENGTH = 4 45 | } 46 | 47 | private val packetsDecoded = Metrics.counter("packets_decoded", mapOf("proto" to "sip3")) 48 | 49 | override fun start() { 50 | vertx.eventBus().localConsumer>(RoutesCE.sip3) { event -> 51 | val (sender, buffer) = event.body() 52 | try { 53 | decode(sender, buffer) 54 | } catch (e: Exception) { 55 | logger.error(e) { "Decoder 'decode()' failed."} 56 | logger.debug { "Packet buffer:\n${ByteBufUtil.prettyHexDump(buffer.byteBuf)}" } 57 | } 58 | } 59 | } 60 | 61 | private fun decode(sender: Address, buffer: Buffer) { 62 | var offset = HEADER_LENGTH 63 | 64 | // Protocol Version 65 | val protocolVersion = buffer.getByte(offset++) 66 | 67 | val packets = when (protocolVersion.toInt()) { 68 | 2 -> { 69 | val compressed = (buffer.getByte(offset++).toInt() == 1) 70 | if (compressed) { 71 | val payload = buffer.slice(offset, buffer.length()).bytes 72 | InflaterInputStream(ByteArrayInputStream(payload)).use { inflater -> 73 | decode(Buffer.buffer(inflater.readBytes())) 74 | } 75 | } else { 76 | decode(buffer.slice(offset, buffer.length())) 77 | } 78 | } 79 | else -> throw NotImplementedError("Unsupported protocol version. Version: $protocolVersion") 80 | } 81 | 82 | packetsDecoded.increment(packets.size.toDouble()) 83 | vertx.eventBus().localSend(RoutesCE.router, Pair(sender, packets)) 84 | } 85 | 86 | private fun decode(buffer: Buffer): List { 87 | val packets = mutableListOf() 88 | 89 | var offset = 0 90 | while (offset < buffer.length()) { 91 | var packetOffset = offset 92 | // Packet Type 93 | val packetType = buffer.getByte(packetOffset) 94 | // Packet Version 95 | packetOffset += 1 96 | val packetVersion = buffer.getByte(packetOffset) 97 | 98 | if ((packetType != PacketTypes.SIP3 && packetType != PacketTypes.RAW) || packetVersion.toInt() != 1) { 99 | throw NotImplementedError("Unknown SIP3 packet type or version. Type: $packetType, Version: $packetVersion") 100 | } 101 | 102 | var millis: Long? = null 103 | var nanos: Int? = null 104 | var srcAddr: ByteArray? = null 105 | var dstAddr: ByteArray? = null 106 | var srcPort: Int? = null 107 | var dstPort: Int? = null 108 | var protocolCode: Byte? = null 109 | var payload: ByteArray? = null 110 | 111 | // Packet Length 112 | packetOffset += 1 113 | val packetLength = buffer.getUnsignedShort(packetOffset) 114 | 115 | packetOffset += 2 116 | while (packetOffset < offset + packetLength) { 117 | // Type 118 | val type = buffer.getByte(packetOffset) 119 | // Length 120 | packetOffset += 1 121 | val length = buffer.getUnsignedShort(packetOffset) - 3 122 | // Value 123 | packetOffset += 2 124 | when (type.toInt()) { 125 | 1 -> millis = buffer.getLong(packetOffset) 126 | 2 -> nanos = buffer.getInt(packetOffset) 127 | 3 -> srcAddr = buffer.getBytes(packetOffset, packetOffset + length) 128 | 4 -> dstAddr = buffer.getBytes(packetOffset, packetOffset + length) 129 | 5 -> srcPort = buffer.getUnsignedShort(packetOffset) 130 | 6 -> dstPort = buffer.getUnsignedShort(packetOffset) 131 | 7 -> protocolCode = buffer.getByte(packetOffset) 132 | 8 -> payload = buffer.getBytes(packetOffset, packetOffset + length) 133 | } 134 | packetOffset += length 135 | } 136 | 137 | val packet = Packet().apply { 138 | this.createdAt = millis!! 139 | this.nanos = nanos!! 140 | this.type = packetType 141 | this.payload = payload!! 142 | 143 | if (packetType == PacketTypes.SIP3) { 144 | this.srcAddr = Address().apply { 145 | addr = IpUtil.convertToString(srcAddr!!) 146 | port = srcPort!! 147 | } 148 | this.dstAddr = Address().apply { 149 | addr = IpUtil.convertToString(dstAddr!!) 150 | port = dstPort!! 151 | } 152 | 153 | this.protocolCode = protocolCode!! 154 | } 155 | 156 | this.source = "sip3" 157 | } 158 | 159 | packets.add(packet) 160 | 161 | offset += packetLength 162 | } 163 | 164 | return packets 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/decoder/HepDecoder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.decoder 18 | 19 | import io.sip3.commons.ProtocolCodes 20 | import io.sip3.commons.micrometer.Metrics 21 | import io.sip3.commons.util.IpUtil 22 | import io.sip3.commons.vertx.annotations.Instance 23 | import io.sip3.commons.vertx.util.localSend 24 | import io.sip3.salto.ce.RoutesCE 25 | import io.sip3.salto.ce.domain.Address 26 | import io.sip3.salto.ce.domain.Packet 27 | import io.vertx.core.AbstractVerticle 28 | import io.vertx.core.buffer.Buffer 29 | import mu.KotlinLogging 30 | 31 | /** 32 | * Decodes packets in HEP2 and HEP3 protocols 33 | */ 34 | @Instance 35 | class HepDecoder : AbstractVerticle() { 36 | 37 | private val logger = KotlinLogging.logger {} 38 | 39 | companion object { 40 | 41 | const val HEP3_HEADER_LENGTH = 6 42 | const val HEP3_TYPE_SIP: Byte = 1 43 | const val HEP3_TYPE_RTCP: Byte = 5 44 | } 45 | 46 | private var rtcpEnabled = false 47 | 48 | private val packetsDecoded = Metrics.counter("packets_decoded", mapOf("proto" to "hep")) 49 | 50 | override fun start() { 51 | config().getJsonObject("hep")?.getJsonObject("rtcp")?.getBoolean("enabled")?.let { 52 | rtcpEnabled = it 53 | } 54 | 55 | vertx.eventBus().localConsumer>(RoutesCE.hep2) { event -> 56 | try { 57 | val (sender, buffer) = event.body() 58 | decodeHep2(sender, buffer) 59 | } catch (e: Exception) { 60 | logger.error("HepDecoder 'decodeHep2()' failed.", e) 61 | } 62 | } 63 | 64 | vertx.eventBus().localConsumer>(RoutesCE.hep3) { event -> 65 | try { 66 | val (sender, buffer) = event.body() 67 | decodeHep3(sender, buffer) 68 | } catch (e: Exception) { 69 | logger.error("HepDecoder 'decodeHep3()' failed.", e) 70 | } 71 | } 72 | } 73 | 74 | fun decodeHep2(sender: Address, buffer: Buffer) { 75 | val packetLength = buffer.length() 76 | if (packetLength < 31) { 77 | logger.warn("HEP2 payload is to short: $packetLength") 78 | return 79 | } 80 | 81 | val srcPort: Int = buffer.getUnsignedShort(4) 82 | val dstPort: Int = buffer.getUnsignedShort(6) 83 | val srcAddr: ByteArray = buffer.getBytes(8, 12) 84 | val dstAddr: ByteArray = buffer.getBytes(12, 16) 85 | val seconds: Long = buffer.getUnsignedIntLE(16) 86 | val uSeconds: Long = buffer.getUnsignedIntLE(20) 87 | val payload: ByteArray = buffer.getBytes(28, packetLength) 88 | 89 | val packet = Packet().apply { 90 | this.createdAt = seconds * 1000 + uSeconds / 1000 91 | this.nanos = (uSeconds % 1000).toInt() 92 | this.srcAddr = Address().apply { 93 | addr = IpUtil.convertToString(srcAddr) 94 | port = srcPort 95 | } 96 | this.dstAddr = Address().apply { 97 | addr = IpUtil.convertToString(dstAddr) 98 | port = dstPort 99 | } 100 | this.protocolCode = ProtocolCodes.SIP 101 | this.payload = payload 102 | } 103 | 104 | packetsDecoded.increment() 105 | vertx.eventBus().localSend(RoutesCE.router, Pair(sender, listOf(packet))) 106 | } 107 | 108 | fun decodeHep3(sender: Address, buffer: Buffer) { 109 | var seconds: Long? = null 110 | var uSeconds: Long? = null 111 | var srcAddr: ByteArray? = null 112 | var dstAddr: ByteArray? = null 113 | var srcPort: Int? = null 114 | var dstPort: Int? = null 115 | var protocolType: Byte? = null 116 | var payload: ByteArray? = null 117 | 118 | var offset = HEP3_HEADER_LENGTH 119 | while (offset < buffer.length()) { 120 | // Type 121 | offset += 2 122 | val type = buffer.getShort(offset) 123 | // Length 124 | offset += 2 125 | val length = buffer.getShort(offset) - 6 126 | // Value 127 | offset += 2 128 | when (type.toInt()) { 129 | 3 -> srcAddr = buffer.getBytes(offset + length - 4, offset + length) 130 | 4 -> dstAddr = buffer.getBytes(offset + length - 4, offset + length) 131 | 7 -> srcPort = buffer.getUnsignedShort(offset) 132 | 8 -> dstPort = buffer.getUnsignedShort(offset) 133 | 9 -> seconds = buffer.getUnsignedInt(offset) 134 | 10 -> uSeconds = buffer.getUnsignedInt(offset) 135 | 11 -> protocolType = buffer.getByte(offset) 136 | 15 -> payload = buffer.getBytes(offset, offset + length) 137 | } 138 | offset += length 139 | } 140 | 141 | // Skip RTCP if disabled 142 | if (!rtcpEnabled && protocolType == HEP3_TYPE_RTCP) return 143 | 144 | val packet = Packet().apply { 145 | this.createdAt = seconds!! * 1000 + uSeconds!! / 1000 146 | this.nanos = (uSeconds % 1000).toInt() 147 | this.srcAddr = Address().apply { 148 | addr = IpUtil.convertToString(srcAddr!!) 149 | port = srcPort!! 150 | } 151 | this.dstAddr = Address().apply { 152 | addr = IpUtil.convertToString(dstAddr!!) 153 | port = dstPort!! 154 | } 155 | this.source = "hep3" 156 | when (protocolType) { 157 | HEP3_TYPE_SIP -> this.protocolCode = ProtocolCodes.SIP 158 | HEP3_TYPE_RTCP -> this.protocolCode = ProtocolCodes.RTCP 159 | else -> throw NotImplementedError("Unknown HEPv3 protocol type: $protocolType") 160 | } 161 | this.payload = payload!! 162 | } 163 | 164 | packetsDecoded.increment() 165 | vertx.eventBus().localSend(RoutesCE.router, Pair(sender, listOf(packet))) 166 | } 167 | } -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | io.sip3.salto.ce 8 | sip3-salto-ce 9 | 2025.3.2-SNAPSHOT 10 | jar 11 | 12 | 13 | io.sip3 14 | sip3-parent 15 | 2025.3.2-SNAPSHOT 16 | 17 | 18 | 19 | 20 | 21 | io.sip3.commons 22 | sip3-commons 23 | 2025.3.2-SNAPSHOT 24 | 25 | 26 | 27 | 28 | io.vertx 29 | vertx-web-client 30 | 31 | 32 | io.vertx 33 | vertx-lang-groovy 34 | 35 | 36 | 37 | 38 | io.vertx 39 | vertx-mongo-client 40 | 41 | 42 | 43 | 44 | javax.sip 45 | jain-sip-ri 46 | 47 | 48 | 49 | 50 | org.restcomm.media.core 51 | sdp 52 | 53 | 54 | log4j 55 | log4j 56 | 57 | 58 | 59 | 60 | 61 | 62 | commons-net 63 | commons-net 64 | 65 | 66 | 67 | 68 | io.mockk 69 | mockk-jvm 70 | 71 | 72 | org.jetbrains.kotlin 73 | kotlin-stdlib-jdk7 74 | 75 | 76 | org.jetbrains.kotlin 77 | kotlin-stdlib-jdk8 78 | 79 | 80 | test 81 | 82 | 83 | org.testcontainers 84 | testcontainers 85 | test 86 | 87 | 88 | org.slf4j 89 | slf4j-api 90 | 91 | 92 | 93 | 94 | org.testcontainers 95 | junit-jupiter 96 | test 97 | 98 | 99 | org.testcontainers 100 | mongodb 101 | test 102 | 103 | 104 | 105 | 106 | sip3-salto-ce 107 | 108 | 109 | org.jetbrains.kotlin 110 | kotlin-maven-plugin 111 | ${kotlin.version} 112 | 113 | 114 | compile 115 | 116 | compile 117 | 118 | 119 | 120 | src/main/kotlin 121 | 122 | 123 | 124 | 125 | test-compile 126 | 127 | test-compile 128 | 129 | 130 | 131 | src/test/kotlin 132 | 133 | 134 | 135 | 136 | 137 | 138 | org.apache.maven.plugins 139 | maven-surefire-plugin 140 | ${maven-surefire-plugin.version} 141 | 142 | 143 | 144 | 145 | 146 | 147 | executable-jar 148 | 149 | false 150 | 151 | 152 | 153 | 154 | io.reactiverse 155 | vertx-maven-plugin 156 | ${vertx-maven-plugin.version} 157 | 158 | 159 | vmp 160 | 161 | initialize 162 | package 163 | 164 | 165 | 166 | 167 | io.sip3.salto.ce.Bootstrap 168 | true 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/sip/SipMessageParser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.sip 18 | 19 | import gov.nist.javax.sip.header.CSeq 20 | import gov.nist.javax.sip.header.ExtensionHeaderImpl 21 | import gov.nist.javax.sip.message.SIPMessage 22 | import gov.nist.javax.sip.message.SIPRequest 23 | import gov.nist.javax.sip.parser.* 24 | import io.sip3.salto.ce.domain.Packet 25 | import mu.KotlinLogging 26 | 27 | /** 28 | * Parses SIP messages 29 | */ 30 | class SipMessageParser(val supportedMethods: Set, val mode: Int = MODE_ALL, extensionHeaders: Set = emptySet()) { 31 | 32 | private val logger = KotlinLogging.logger {} 33 | 34 | companion object { 35 | 36 | const val MODE_ALL = 0 37 | const val MODE_EXTENSION_HEADERS = 1 38 | 39 | const val CR: Byte = 0x0d 40 | const val LF: Byte = 0x0a 41 | 42 | val CONTENT_LENGTH_HEADERS = setOf("content-length", "l") 43 | } 44 | 45 | private val extensionHeaders: Set = extensionHeaders.map { it.lowercase() }.toSet() 46 | 47 | fun parse(packet: Packet): List> { 48 | val result = mutableListOf>() 49 | parse(packet, result) 50 | return result 51 | } 52 | 53 | private fun parse(packet: Packet, accumulator: MutableList>) { 54 | val payload = packet.payload 55 | var offset = 0 56 | 57 | // Skip blank lines 58 | while (isCrLf(offset, payload)) { 59 | offset += 2 60 | } 61 | 62 | // Create new parser 63 | val parser = StringMessageParser() 64 | 65 | // Parse message headers 66 | val message = parser.parseSIPMessage(payload, false, false, null) ?: return 67 | offset += message.size 68 | 69 | // Parse message content if needed 70 | val length = message.contentLengthHeader?.contentLength ?: 0 71 | if (length > 0 && message.contentTypeHeader?.contentType != null){ 72 | message.setMessageContent(payload.copyOfRange(offset, offset + length)) 73 | } 74 | offset += length 75 | 76 | // Skip blank lines 77 | while (isCrLf(offset, payload)) { 78 | offset += 2 79 | } 80 | 81 | // Skip or save message 82 | if (!parser.skipMessage) { 83 | if (payload.size > offset) { 84 | packet.payload = payload.copyOfRange(0, offset) 85 | } 86 | accumulator.add(Pair(packet, message)) 87 | } 88 | 89 | // Check if there is more than a single message 90 | if (payload.size > offset) { 91 | val pkt = Packet().apply { 92 | this.createdAt = packet.createdAt 93 | this.nanos = packet.nanos 94 | this.srcAddr = packet.srcAddr 95 | this.dstAddr = packet.dstAddr 96 | this.protocolCode = packet.protocolCode 97 | this.payload = payload.copyOfRange(offset, payload.size) 98 | } 99 | parse(pkt, accumulator) 100 | } 101 | } 102 | 103 | private fun isCrLf(offset: Int, payload: ByteArray): Boolean { 104 | if (payload.size <= offset + 1) { 105 | return false 106 | } 107 | return payload[offset] == CR && payload[offset + 1] == LF 108 | } 109 | 110 | inner class StringMessageParser : StringMsgParser() { 111 | 112 | var skipMessage = false 113 | 114 | override fun processFirstLine(firstLine: String?, parseExceptionListener: ParseExceptionListener?, msgBuffer: ByteArray?): SIPMessage { 115 | val message = super.processFirstLine(firstLine, parseExceptionListener, msgBuffer) 116 | if (message is SIPRequest) { 117 | skipMessage = !supportedMethods.contains(message.method) 118 | } 119 | return message 120 | } 121 | 122 | override fun processHeader(header: String?, message: SIPMessage, parseExceptionListener: ParseExceptionListener?, rawMessage: ByteArray) { 123 | if (header.isNullOrEmpty()) { 124 | return 125 | } 126 | 127 | val name = Lexer.getHeaderName(header) 128 | // Don't skip Content Length headers 129 | if (skipMessage && !CONTENT_LENGTH_HEADERS.contains(name.lowercase())) { 130 | return 131 | } 132 | 133 | val hdr = when (name.lowercase()) { 134 | // These headers may or will be used in the SIP3 aggregation logic 135 | "content-length", "l" -> ContentLengthParser(header + "\n").parse() 136 | "cseq" -> { 137 | CSeqParser(header + "\n").parse().also { cseq -> 138 | skipMessage = !supportedMethods.contains((cseq as CSeq).method) 139 | } 140 | } 141 | "to", "t" -> ToParser(header + "\n").parse() 142 | "from", "f" -> FromParser(header + "\n").parse() 143 | "via", "v" -> ViaParser(header + "\n").parse() 144 | "contact", "m" -> ContactParser(header + "\n").parse() 145 | "content-type", "c" -> ContentTypeParser(header + "\n").parse() 146 | "call-id", "i" -> CallIDParser(header + "\n").parse() 147 | "route" -> RouteParser(header + "\n").parse() 148 | "record-route" -> RecordRouteParser(header + "\n").parse() 149 | "max-forwards" -> MaxForwardsParser(header + "\n").parse() 150 | "expires" -> ExpiresParser(header + "\n").parse() 151 | else -> { 152 | // These headers won't be used in the SIP3 aggregation logic 153 | // So we can just attach them as generic `Extension` headers 154 | if (mode == MODE_ALL || extensionHeaders.contains(name.lowercase())) { 155 | ExtensionHeaderImpl().apply { 156 | this.name = name 157 | this.value = Lexer.getHeaderValue(header)?.trim() 158 | } 159 | } else { 160 | null 161 | } 162 | } 163 | } 164 | 165 | hdr?.let { message.attachHeader(it, false) } 166 | } 167 | } 168 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/management/host/HostRegistry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management.host 18 | 19 | import io.sip3.commons.mongo.MongoClient 20 | import io.vertx.core.Future 21 | import io.vertx.core.Promise 22 | import io.vertx.core.Vertx 23 | import io.vertx.core.json.JsonArray 24 | import io.vertx.core.json.JsonObject 25 | import io.vertx.ext.mongo.MongoClientUpdateResult 26 | import io.vertx.kotlin.ext.mongo.updateOptionsOf 27 | import mu.KotlinLogging 28 | import org.apache.commons.net.util.SubnetUtils 29 | 30 | /** 31 | * Handles host mappings 32 | */ 33 | object HostRegistry { 34 | 35 | private val logger = KotlinLogging.logger {} 36 | 37 | private const val HOST_COLLECTION = "hosts" 38 | private var checkPeriod: Long = 30000 39 | 40 | private var vertx: Vertx? = null 41 | private lateinit var config: JsonObject 42 | private lateinit var client: io.vertx.ext.mongo.MongoClient 43 | private var periodicTask: Long? = null 44 | 45 | private var hosts = emptyMap() 46 | private var mappings = emptyMap() 47 | private var features = emptyMap>() 48 | 49 | @Synchronized 50 | fun getInstance(vertx: Vertx, config: JsonObject): HostRegistry { 51 | if (HostRegistry.vertx == null) { 52 | HostRegistry.vertx = vertx 53 | HostRegistry.config = config 54 | init() 55 | } 56 | 57 | return this 58 | } 59 | 60 | @Synchronized 61 | fun destroy() { 62 | periodicTask?.let { 63 | vertx?.cancelTimer(it) 64 | } 65 | 66 | vertx = null 67 | periodicTask = null 68 | client.close() 69 | } 70 | 71 | private fun init() { 72 | config.getJsonObject("hosts")?.getLong("check_period")?.let { 73 | checkPeriod = it 74 | } 75 | config.getJsonObject("mongo").let { 76 | client = MongoClient.createShared(vertx!!, it.getJsonObject("management") ?: it) 77 | } 78 | 79 | periodicTask = vertx!!.setPeriodic(0L, checkPeriod) { 80 | updateHosts() 81 | } 82 | } 83 | 84 | fun getHostName(addr: String): String? { 85 | return hosts[addr] 86 | } 87 | 88 | fun getHostName(addr: String, port: Int): String? { 89 | return hosts[addr] ?: hosts["${addr}:${port}"] 90 | } 91 | 92 | fun getAddrMapping(addr: String): String? { 93 | return mappings[addr] 94 | } 95 | 96 | fun getFeatures(name: String): Set? { 97 | return features[name] 98 | } 99 | 100 | fun save(host: JsonObject): Future { 101 | val query = JsonObject().apply { 102 | put("name", host.getString("name")) 103 | } 104 | 105 | return client.replaceDocumentsWithOptions(HOST_COLLECTION, query, host, updateOptionsOf(upsert = true)) 106 | .onFailure { t -> 107 | logger.error(t) { "MongoClient 'removeDocuments()' failed." } 108 | } 109 | } 110 | 111 | fun saveAndRemoveDuplicates(host: JsonObject): Future { 112 | val promise = Promise.promise() 113 | 114 | save(host) 115 | .onFailure { t -> 116 | logger.error(t) { "HostRegistry 'save()' failed." } 117 | promise.fail(t) 118 | } 119 | .onSuccess { _ -> 120 | val query = JsonObject().apply { 121 | put("\$and", JsonArray().apply { 122 | add(JsonObject().apply { 123 | put("name", JsonObject().apply { 124 | put("\$ne", host.getString("name")) 125 | }) 126 | }) 127 | add(JsonObject().apply { 128 | put("addr", JsonObject().apply { 129 | put("\$in", host.getJsonArray("addr")) 130 | }) 131 | }) 132 | }) 133 | } 134 | 135 | client.removeDocuments(HOST_COLLECTION, query) 136 | .onFailure { t -> 137 | logger.error(t) { "MongoClient 'removeDocuments()' failed." } 138 | promise.fail(t) 139 | } 140 | .onSuccess { result -> 141 | logger.trace { "Duplicates removed: ${result?.removedCount}" } 142 | promise.complete(result?.removedCount) 143 | } 144 | } 145 | return promise.future() 146 | } 147 | 148 | private fun updateHosts() { 149 | client.find(HOST_COLLECTION, JsonObject()) 150 | .onFailure { logger.error(it) { "MongoClient 'find()' failed." } } 151 | .onSuccess { result -> 152 | val tmpHosts = mutableMapOf() 153 | val tmpMappings = mutableMapOf() 154 | val tmpFeatures = mutableMapOf>() 155 | 156 | result.forEach { host -> 157 | val name = host.getString("name") 158 | 159 | try { 160 | // Update `hosts` 161 | host.getJsonArray("addr") 162 | ?.map { it as String } 163 | ?.forEach { addr -> 164 | tmpHosts[addr] = name 165 | if (addr.contains("/")) { 166 | SubnetUtils(addr).apply { isInclusiveHostCount = true }.info 167 | .allAddresses 168 | .forEach { tmpHosts[it] = name } 169 | } 170 | } 171 | 172 | // Update `mappings` 173 | host.getJsonArray("mapping") 174 | ?.map { it as JsonObject } 175 | ?.forEach { 176 | tmpMappings[it.getString("source")] = it.getString("target") 177 | } 178 | 179 | // Update `features` 180 | host.getJsonArray("feature") 181 | ?.map { it as String } 182 | ?.toSet() 183 | ?.let { 184 | tmpFeatures[name] = it 185 | } 186 | } catch (e: Exception) { 187 | logger.error(e) { "Router `mapHostToAddr()` failed. Host: $host" } 188 | } 189 | } 190 | 191 | hosts = tmpHosts 192 | mappings = tmpMappings 193 | features = tmpFeatures 194 | } 195 | } 196 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/sip3/salto/ce/recording/RecordingHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.recording 18 | 19 | import io.netty.buffer.Unpooled 20 | import io.sip3.commons.ProtocolCodes 21 | import io.sip3.commons.domain.payload.RecordingPayload 22 | import io.sip3.commons.util.format 23 | import io.sip3.commons.vertx.annotations.Instance 24 | import io.sip3.commons.vertx.collections.PeriodicallyExpiringHashMap 25 | import io.sip3.commons.vertx.util.localSend 26 | import io.sip3.salto.ce.RoutesCE 27 | import io.sip3.salto.ce.domain.Address 28 | import io.sip3.salto.ce.domain.Packet 29 | import io.vertx.core.AbstractVerticle 30 | import io.vertx.core.json.JsonObject 31 | import io.vertx.kotlin.coroutines.coAwait 32 | import io.vertx.kotlin.coroutines.dispatcher 33 | import kotlinx.coroutines.GlobalScope 34 | import kotlinx.coroutines.launch 35 | import mu.KotlinLogging 36 | import java.time.format.DateTimeFormatter 37 | import kotlin.coroutines.CoroutineContext 38 | import kotlin.math.abs 39 | 40 | /** 41 | * Handles Recording Payload 42 | */ 43 | @Instance 44 | open class RecordingHandler : AbstractVerticle() { 45 | 46 | private val logger = KotlinLogging.logger {} 47 | 48 | private var instances = 1 49 | private var timeSuffix: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") 50 | private var expirationDelay = 1000L 51 | private var aggregationTimeout = 30000L 52 | 53 | private var bulkSize = 64 54 | 55 | private lateinit var recordings: PeriodicallyExpiringHashMap 56 | 57 | override fun start() { 58 | config().getJsonObject("vertx")?.getInteger("instances")?.let { 59 | instances = it 60 | } 61 | config().getString("time_suffix")?.let { 62 | timeSuffix = DateTimeFormatter.ofPattern(it) 63 | } 64 | config().getJsonObject("recording")?.let { config -> 65 | config.getLong("expiration_delay")?.let { 66 | expirationDelay = it 67 | } 68 | config.getLong("aggregation_timeout")?.let { 69 | aggregationTimeout = it 70 | } 71 | config.getInteger("bulk_size")?.let { 72 | bulkSize = it 73 | } 74 | } 75 | 76 | recordings = PeriodicallyExpiringHashMap.Builder() 77 | .delay(expirationDelay) 78 | .period((aggregationTimeout / expirationDelay).toInt()) 79 | .expireAt { _, recording -> recording.createdAt + aggregationTimeout } 80 | .onExpire { _, recording -> writeToDatabase(recording) } 81 | .build(vertx) 82 | 83 | vertx.eventBus().localConsumer(RoutesCE.rec) { event -> 84 | try { 85 | val packet = event.body() 86 | handle(packet) 87 | } catch (e: Exception) { 88 | logger.error("RecordingHandler 'handle()' failed.", e) 89 | } 90 | } 91 | 92 | GlobalScope.launch(vertx.dispatcher() as CoroutineContext) { 93 | val index = vertx.sharedData().getLocalCounter(RoutesCE.rec).coAwait() 94 | vertx.eventBus() 95 | .localConsumer>(RoutesCE.rec + "_${index.andIncrement.coAwait()}") { event -> 96 | try { 97 | val (packet, recording) = event.body() 98 | handleRecording(packet, recording) 99 | } catch (e: Exception) { 100 | logger.error(e) { "RecordingHandler 'handleRecording()' failed." } 101 | } 102 | } 103 | } 104 | } 105 | 106 | open fun handle(packet: Packet) { 107 | val recording = RecordingPayload().apply { 108 | val payload = Unpooled.wrappedBuffer(packet.payload) 109 | decode(payload) 110 | } 111 | 112 | if (recording.type == ProtocolCodes.RTCP) { 113 | val rtcpPacket = Packet().apply { 114 | createdAt = packet.createdAt 115 | nanos = packet.nanos 116 | srcAddr = packet.srcAddr 117 | dstAddr = packet.dstAddr 118 | protocolCode = ProtocolCodes.RTCP 119 | source = "sip3" 120 | payload = recording.payload 121 | } 122 | vertx.eventBus().localSend(RoutesCE.rtcp, rtcpPacket) 123 | } 124 | 125 | val index = abs(recording.callId.hashCode()) % instances 126 | vertx.eventBus().localSend(RoutesCE.rec + "_${index}", Pair(packet, recording)) 127 | } 128 | 129 | open fun handleRecording(packet: Packet, recordingPayload: RecordingPayload) { 130 | val key = "${recordingPayload.callId}:${packet.srcAddr.addr}:${packet.srcAddr.port}:${packet.dstAddr.addr}:${packet.dstAddr.port}" 131 | val recording = recordings.getOrPut(key) { Recording() } 132 | recording.apply { 133 | if (createdAt == 0L) { 134 | createdAt = packet.createdAt 135 | srcAddr = packet.srcAddr 136 | dstAddr = packet.dstAddr 137 | 138 | callId = recordingPayload.callId 139 | } 140 | 141 | packets.add(JsonObject().apply { 142 | put("created_at", packet.createdAt) 143 | put("nanos", packet.nanos) 144 | 145 | put("type", recordingPayload.type.toInt()) 146 | put("raw_data", String(recordingPayload.payload, Charsets.ISO_8859_1)) 147 | }) 148 | } 149 | 150 | if (recording.packets.size >= bulkSize) { 151 | writeToDatabase(recording) 152 | recording.createdAt = packet.createdAt 153 | recording.packets.clear() 154 | } 155 | } 156 | 157 | open fun writeToDatabase(recording: Recording) { 158 | val collection = "rec_raw_" + timeSuffix.format(recording.createdAt) 159 | 160 | val operation = JsonObject().apply { 161 | put("document", JsonObject().apply { 162 | put("created_at", recording.createdAt) 163 | 164 | val src = recording.srcAddr 165 | put("src_addr", src.addr) 166 | put("src_port", src.port) 167 | src.host?.let { put("src_host", it) } 168 | 169 | val dst = recording.dstAddr 170 | put("dst_addr", dst.addr) 171 | put("dst_port", dst.port) 172 | dst.host?.let { put("dst_host", it) } 173 | 174 | put("call_id", recording.callId) 175 | put("packets", recording.packets.toList()) 176 | }) 177 | } 178 | 179 | vertx.eventBus().localSend(RoutesCE.mongo_bulk_writer, Pair(collection, operation)) 180 | } 181 | 182 | open class Recording { 183 | 184 | var createdAt: Long = 0L 185 | lateinit var srcAddr: Address 186 | lateinit var dstAddr: Address 187 | 188 | lateinit var callId: String 189 | 190 | val packets = mutableListOf() 191 | } 192 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/management/UdpServerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.commons.vertx.util.localReply 21 | import io.sip3.salto.ce.RoutesCE 22 | import io.vertx.core.datagram.DatagramSocket 23 | import io.vertx.core.json.JsonArray 24 | import io.vertx.core.json.JsonObject 25 | import io.vertx.kotlin.core.datagram.datagramSocketOptionsOf 26 | import io.vertx.kotlin.coroutines.coAwait 27 | import org.junit.jupiter.api.Assertions.* 28 | import org.junit.jupiter.api.BeforeEach 29 | import org.junit.jupiter.api.Test 30 | import java.net.URI 31 | import java.util.* 32 | 33 | class UdpServerTest : VertxTest() { 34 | 35 | companion object { 36 | 37 | private val HOST = JsonObject().apply { 38 | put("name", "sbc1") 39 | put("addr", JsonArray().apply { 40 | add("10.10.10.10") 41 | add("10.10.20.10:5060") 42 | }) 43 | } 44 | 45 | private val CONFIG = JsonObject().apply { 46 | put("management", JsonObject().apply { 47 | put("uri", "udp://127.0.0.1:15090") 48 | put("register_delay", 2000L) 49 | }) 50 | put("host", HOST) 51 | put("rtp", JsonObject().apply { 52 | put("enabled", true) 53 | }) 54 | } 55 | 56 | private val DEPLOYMENT_ID = UUID.randomUUID().toString() 57 | 58 | private val REGISTER_MESSAGE_1 = JsonObject().apply { 59 | put("type", ManagementHandler.TYPE_REGISTER) 60 | put("payload", JsonObject().apply { 61 | put("timestamp", System.currentTimeMillis()) 62 | put("deployment_id", DEPLOYMENT_ID) 63 | put("config", CONFIG) 64 | }) 65 | } 66 | 67 | private val REGISTER_MESSAGE_2 = JsonObject().apply { 68 | put("type", ManagementHandler.TYPE_REGISTER) 69 | put("payload", JsonObject().apply { 70 | put("timestamp", System.currentTimeMillis()) 71 | put("name", DEPLOYMENT_ID) 72 | put("config", CONFIG.copy().apply { 73 | remove("host") 74 | }) 75 | }) 76 | } 77 | } 78 | 79 | private lateinit var config: JsonObject 80 | private var localPort = -1 81 | private var remotePort = -1 82 | 83 | @BeforeEach 84 | fun init() { 85 | localPort = findRandomPort() 86 | remotePort = findRandomPort() 87 | config = JsonObject().apply { 88 | put("name", "sip3-salto-unit-test") 89 | 90 | put("server", JsonObject().apply { 91 | put("uri", "udp://127.0.0.1:15060") 92 | }) 93 | 94 | put("management", JsonObject().apply { 95 | put("uri", "udp://127.0.0.1:$localPort") 96 | put("expiration_timeout", 1500L) 97 | put("expiration_delay", 800L) 98 | }) 99 | 100 | put("mongo", JsonObject().apply { 101 | put("management", JsonObject().apply { 102 | put("uri", "mongodb://superhost.com:10000/?w=1") 103 | put("db", "salto-component-management-test") 104 | }) 105 | put("uri", "mongodb://superhost.com:20000/?w=1") 106 | put("db", "salto-component-test") 107 | }) 108 | } 109 | } 110 | 111 | @Test 112 | fun `Receive register from remote host with host information`() { 113 | 114 | lateinit var remoteSocket: DatagramSocket 115 | runTest( 116 | deploy = { 117 | vertx.deployTestVerticle(UdpServer::class, config) 118 | }, 119 | execute = { 120 | remoteSocket.send(REGISTER_MESSAGE_1.toBuffer(), localPort, "127.0.0.1").coAwait() 121 | }, 122 | assert = { 123 | remoteSocket = vertx.createDatagramSocket() 124 | .handler { packet -> 125 | val response = packet.data().toJsonObject() 126 | context.verify { 127 | assertEquals("register_response", response.getString("type")) 128 | assertNotNull(response.getJsonObject("payload")) 129 | } 130 | 131 | context.completeNow() 132 | } 133 | 134 | remoteSocket.listen(remotePort, "127.0.0.1") 135 | 136 | vertx.eventBus().localConsumer>(RoutesCE.management) { event -> 137 | val (uri, message) = event.body() 138 | context.verify { 139 | assertEquals(remotePort, uri.port) 140 | assertEquals(REGISTER_MESSAGE_1.getString("type"), message.getString("type")) 141 | } 142 | event.localReply(JsonObject().apply { 143 | put("type", "register_response") 144 | put("payload", JsonObject()) 145 | }) 146 | } 147 | } 148 | ) 149 | } 150 | 151 | @Test 152 | fun `Receive register from remote host via IPv6`() { 153 | 154 | lateinit var remoteSocket: DatagramSocket 155 | runTest( 156 | deploy = { 157 | val ipV6config = JsonObject().apply { 158 | put("name", "sip3-salto-unit-test") 159 | put("management", JsonObject().apply { 160 | put("uri", "udp://[fe80::1]:$localPort") 161 | put("expiration_timeout", 1500L) 162 | put("expiration_delay", 800L) 163 | }) 164 | } 165 | 166 | vertx.deployTestVerticle(UdpServer::class, ipV6config) 167 | }, 168 | execute = { 169 | remoteSocket.send(REGISTER_MESSAGE_1.toBuffer(), localPort, "[fe80::1]").coAwait() 170 | }, 171 | assert = { 172 | remoteSocket = vertx.createDatagramSocket(datagramSocketOptionsOf(ipV6 = true)) 173 | .handler { packet -> 174 | val response = packet.data().toJsonObject() 175 | context.verify { 176 | assertEquals("register_response", response.getString("type")) 177 | assertNotNull(response.getJsonObject("payload")) 178 | } 179 | 180 | context.completeNow() 181 | } 182 | 183 | remoteSocket.listen(remotePort, "[fe80::1]") 184 | 185 | vertx.eventBus().localConsumer>(RoutesCE.management) { event -> 186 | val (uri, message) = event.body() 187 | context.verify { 188 | assertEquals(remotePort, uri.port) 189 | assertEquals(REGISTER_MESSAGE_1.getString("type"), message.getString("type")) 190 | } 191 | event.localReply(JsonObject().apply { 192 | put("type", "register_response") 193 | put("payload", JsonObject()) 194 | }) 195 | } 196 | } 197 | ) 198 | } 199 | } -------------------------------------------------------------------------------- /src/test/kotlin/io/sip3/salto/ce/management/host/HostRegistryTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018-2025 SIP3.IO, Corp. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.sip3.salto.ce.management.host 18 | 19 | import io.sip3.commons.vertx.test.VertxTest 20 | import io.sip3.salto.ce.MongoExtension 21 | import io.vertx.core.json.JsonArray 22 | import io.vertx.core.json.JsonObject 23 | import io.vertx.ext.mongo.MongoClient 24 | import io.vertx.kotlin.coroutines.coAwait 25 | import org.junit.jupiter.api.Assertions.assertEquals 26 | import org.junit.jupiter.api.Test 27 | import org.junit.jupiter.api.extension.ExtendWith 28 | 29 | @ExtendWith(MongoExtension::class) 30 | class HostRegistryTest : VertxTest() { 31 | 32 | companion object { 33 | 34 | val HOST_1 = JsonObject().apply { 35 | put("name", "host_1") 36 | put("addr", JsonArray().apply { 37 | add("1.1.1.1/32") 38 | add("2.2.2.0/30") 39 | }) 40 | } 41 | 42 | val HOST_2 = JsonObject().apply { 43 | put("name", "host_2") 44 | put("addr", JsonArray().apply { 45 | add("4.4.4.4") 46 | add("5.5.5.5") 47 | }) 48 | put("mapping", JsonArray().apply { 49 | add(JsonObject().apply { 50 | put("source", "3.3.3.1") 51 | put("target", "4.4.4.4") 52 | }) 53 | add(JsonObject().apply { 54 | put("source", "2.2.2.2") 55 | put("target", "5.5.5.5") 56 | }) 57 | put("feature", JsonArray().apply { 58 | add("feature1") 59 | add("feature2") 60 | }) 61 | }) 62 | } 63 | 64 | val HOST_3 = JsonObject().apply { 65 | put("name", "host_3") 66 | put("addr", JsonArray().apply { 67 | add("6.6.6.6") 68 | add("7.7.7.7") 69 | }) 70 | put("mapping", JsonArray().apply { 71 | add(JsonObject().apply { 72 | put("source", "7.7.7.7") 73 | put("target", "6.6.6.6") 74 | }) 75 | }) 76 | put("feature", JsonArray().apply { 77 | add("feature1") 78 | add("feature2") 79 | }) 80 | } 81 | 82 | val HOST_4= JsonObject().apply { 83 | put("name", "host_4") 84 | put("addr", JsonArray().apply { 85 | add("8.8.8.8") 86 | add("9.9.9.9") 87 | }) 88 | } 89 | 90 | val HOST_5 = JsonObject().apply { 91 | put("name", "host_5") 92 | put("addr", JsonArray().apply { 93 | add("9.9.9.9") 94 | add("10.10.10.10") 95 | }) 96 | } 97 | 98 | val MONGO_DB = "sip3_host_registry_test" 99 | } 100 | 101 | @Test 102 | fun `Validate HostRegistry methods`() { 103 | var hostRegistry: HostRegistry? = null 104 | 105 | runTest( 106 | deploy = { 107 | val mongo = MongoClient.createShared(vertx, JsonObject().apply { 108 | put("connection_string", MongoExtension.MONGO_URI) 109 | put("db_name", MONGO_DB) 110 | }) 111 | 112 | mongo.save("hosts", HOST_1).coAwait() 113 | mongo.save("hosts", HOST_2).coAwait() 114 | mongo.close().coAwait() 115 | 116 | hostRegistry = HostRegistry.getInstance(vertx, JsonObject().apply { 117 | put("mongo", JsonObject().apply { 118 | put("management", JsonObject().apply { 119 | put("uri", MongoExtension.MONGO_URI) 120 | put("db", MONGO_DB) 121 | }) 122 | }) 123 | put("hosts", JsonObject().apply { 124 | put("check_period", 500L) 125 | }) 126 | }) 127 | 128 | hostRegistry!!.save(HOST_3).coAwait() 129 | }, 130 | assert = { 131 | vertx.setPeriodic(500L, 100L) { 132 | if (hostRegistry?.getHostName("1.1.1.1", 5060) != null 133 | && hostRegistry?.getHostName("6.6.6.6") != null 134 | ) { 135 | context.verify { 136 | assertEquals(HOST_1.getString("name"), hostRegistry?.getHostName("1.1.1.1", 5060), "1") 137 | assertEquals(HOST_1.getString("name"), hostRegistry?.getHostName("2.2.2.0/30", 5060), "2") 138 | assertEquals(HOST_1.getString("name"), hostRegistry?.getHostName("2.2.2.1", 5060), "3") 139 | 140 | assertEquals(HOST_2.getString("name"), hostRegistry?.getHostName("4.4.4.4", 15053), "4") 141 | assertEquals("5.5.5.5", hostRegistry?.getAddrMapping("2.2.2.2"), "5") 142 | assertEquals("feature1", hostRegistry?.getFeatures(HOST_2.getString("name"))?.first()) 143 | assertEquals("feature2", hostRegistry?.getFeatures(HOST_2.getString("name"))?.last()) 144 | 145 | assertEquals(HOST_3.getString("name"), hostRegistry?.getHostName("6.6.6.6")) 146 | } 147 | 148 | context.completeNow() 149 | } 150 | } 151 | }, 152 | cleanup = { hostRegistry?.destroy() } 153 | ) 154 | } 155 | 156 | @Test 157 | fun `Validate HostRegistry 'saveAndRemoveDuplicates'`() { 158 | var hostRegistry: HostRegistry? = null 159 | 160 | runTest( 161 | deploy = { 162 | val mongo = MongoClient.createShared(vertx, JsonObject().apply { 163 | put("connection_string", MongoExtension.MONGO_URI) 164 | put("db_name", MONGO_DB) 165 | }) 166 | 167 | mongo.save("hosts", HOST_4).coAwait() 168 | mongo.close().coAwait() 169 | 170 | hostRegistry = HostRegistry.getInstance(vertx, JsonObject().apply { 171 | put("mongo", JsonObject().apply { 172 | put("management", JsonObject().apply { 173 | put("uri", MongoExtension.MONGO_URI) 174 | put("db", MONGO_DB) 175 | }) 176 | }) 177 | put("hosts", JsonObject().apply { 178 | put("check_period", 500L) 179 | }) 180 | }) 181 | 182 | hostRegistry!!.saveAndRemoveDuplicates(HOST_5).coAwait() 183 | }, 184 | assert = { 185 | vertx.setPeriodic(500L, 100L) { 186 | if (hostRegistry?.getHostName("10.10.10.10") != null && 187 | hostRegistry?.getHostName("8.8.8.8") == null 188 | ) { 189 | context.verify { 190 | assertEquals(HOST_5.getString("name"), hostRegistry?.getHostName("10.10.10.10")) 191 | assertEquals(HOST_5.getString("name"), hostRegistry?.getHostName("9.9.9.9")) 192 | } 193 | 194 | context.completeNow() 195 | } 196 | } 197 | }, 198 | cleanup = { hostRegistry?.destroy() } 199 | ) 200 | } 201 | } --------------------------------------------------------------------------------