├── .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 | }
--------------------------------------------------------------------------------