From 5e394807576af827b8fa7c8d67f988631328535c Mon Sep 17 00:00:00 2001 From: Maksym Sobolyev Date: Thu, 26 Feb 2026 08:35:51 -0800 Subject: [PATCH 1/6] Restructure CI setup to build container images for all needed SSuT versions first and then run required test against each of those containers specifically to save time building same version multiple times. --- .github/docker/Dockerfile.ssut | 21 +++ .github/workflows/.main.yml | 257 ++++++++++++++++++++++++++++----- .github/workflows/main.yml | 96 +++++------- cibits/build_ssut_image.sh | 23 +++ install_depends/opensips.sh | 5 +- 5 files changed, 308 insertions(+), 94 deletions(-) create mode 100644 .github/docker/Dockerfile.ssut create mode 100755 cibits/build_ssut_image.sh diff --git a/.github/docker/Dockerfile.ssut b/.github/docker/Dockerfile.ssut new file mode 100644 index 0000000..cc40367 --- /dev/null +++ b/.github/docker/Dockerfile.ssut @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 + +ARG BASE_IMAGE=ghcr.io/sippy/rtpproxy:latest +FROM ${BASE_IMAGE} + +ARG MM_TYPE +ARG MM_BRANCH + +ENV MM_TYPE=${MM_TYPE} \ + MM_BRANCH=${MM_BRANCH} \ + RTPP_BRANCH=DOCKER \ + BASEDIR=/opt/voiptests \ + BUILDDIR=/opt/voiptests \ + PYTHON_CMD=python3 \ + DEBIAN_FRONTEND=noninteractive + +WORKDIR /opt/voiptests + +COPY . /opt/voiptests + +RUN /bin/sh -eux ./cibits/build_ssut_image.sh diff --git a/.github/workflows/.main.yml b/.github/workflows/.main.yml index e3c8ea9..a0c5fd5 100644 --- a/.github/workflows/.main.yml +++ b/.github/workflows/.main.yml @@ -3,10 +3,6 @@ name: Reusable Workflow on: workflow_call: inputs: - python-version: - required: false - type: string - default: '3.12' mm-type: required: true type: string @@ -14,13 +10,21 @@ on: required: false type: string default: 'master' - mm-auth: + mm-auths: required: false type: string - default: '' - rtppc-type: + default: '[""]' + python-versions: + required: false + type: string + default: '["3.12"]' + rtppc-types: required: true type: string + use-local-image: + required: false + type: boolean + default: false rtpp-image: required: false type: string @@ -35,63 +39,246 @@ on: default: '' jobs: - test: + build_ssut_image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image-ref: ${{ steps.meta.outputs.image-ref }} + image-artifact: ${{ steps.meta.outputs.image-artifact }} + env: + DOCKER_BUILD_SUMMARY: false + DOCKER_BUILD_RECORD_UPLOAD: false + steps: + - uses: actions/checkout@v5 + + - name: Compute image metadata + id: meta + run: | + MM_BRANCH_SAFE="$(echo '${{ inputs.mm-branch }}' | sed 's|[^a-zA-Z0-9_.-]|-|g')" + SHA_SHORT="$(printf '%s' "$GITHUB_SHA" | cut -c1-12)" + IMAGE_REF="ghcr.io/${{ github.repository_owner }}/voiptests-${{ inputs.mm-type }}:ssut-${MM_BRANCH_SAFE}-${SHA_SHORT}" + IMAGE_ARTIFACT="ssut-image-${{ inputs.mm-type }}-${MM_BRANCH_SAFE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "image-ref=${IMAGE_REF}" >> "$GITHUB_OUTPUT" + echo "image-artifact=${IMAGE_ARTIFACT}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + if: ${{ !inputs.use-local-image }} + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push SSUT image + if: ${{ !inputs.use-local-image }} + uses: docker/build-push-action@v6 + with: + context: . + file: ./.github/docker/Dockerfile.ssut + push: true + tags: ${{ steps.meta.outputs.image-ref }} + build-args: | + BASE_IMAGE=${{ inputs.rtpp-image }}:${{ inputs.rtpp-image-tag }} + MM_TYPE=${{ inputs.mm-type }} + MM_BRANCH=${{ inputs.mm-branch }} + + - name: Build SSUT image tarball + if: ${{ inputs.use-local-image }} + uses: docker/build-push-action@v6 + with: + context: . + file: ./.github/docker/Dockerfile.ssut + outputs: type=docker,dest=/tmp/ssut-image.tar + tags: ${{ steps.meta.outputs.image-ref }} + build-args: | + BASE_IMAGE=${{ inputs.rtpp-image }}:${{ inputs.rtpp-image-tag }} + MM_TYPE=${{ inputs.mm-type }} + MM_BRANCH=${{ inputs.mm-branch }} + + - name: Upload SSUT image artifact + if: ${{ inputs.use-local-image }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.meta.outputs.image-artifact }} + path: /tmp/ssut-image.tar + + test_ghcr: + if: ${{ !inputs.use-local-image }} runs-on: ubuntu-latest + needs: [build_ssut_image] container: - image: ${{ inputs.rtpp-image }}:${{ inputs.rtpp-image-tag }} + image: ${{ needs.build_ssut_image.outputs.image-ref }} options: --privileged --sysctl net.ipv6.conf.all.disable_ipv6=0 env: MM_TYPE: ${{ inputs.mm-type }} MM_BRANCH: ${{ inputs.mm-branch }} RTPP_BRANCH: DOCKER - RTPPC_TYPE: ${{ inputs.rtppc-type }} + RTPPC_TYPE: ${{ matrix.rtppc-type }} + MM_AUTH: ${{ matrix.mm-auth }} + ARTIFACT_FILES: ${{ inputs.artifact-files }} + strategy: + fail-fast: false + matrix: + rtppc-type: ${{ fromJSON(inputs.rtppc-types) }} + mm-auth: ${{ fromJSON(inputs.mm-auths) }} + python-version: ${{ fromJSON(inputs.python-versions) }} steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ inputs.python-version }} + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: ${{ inputs.python-version }} + python-version: ${{ matrix.python-version }} - name: Define PYTHON_CMD - run: | - PYTHON_VER="`echo ${{ matrix.python-version }} | sed 's|-dev$||'`" - echo "PYTHON_CMD=python${PYTHON_VER}" >> $GITHUB_ENV + run: echo "PYTHON_CMD=${pythonLocation}/bin/python" >> "$GITHUB_ENV" - - name: before_install - run: sh -x cibits/before_install.sh + - name: Install Python dependencies + run: | + cd /opt/voiptests + ${PYTHON_CMD} -m pip install --upgrade pip + ${PYTHON_CMD} -m pip install -r requirements.txt + if [ "${MM_TYPE}" = "b2bua" ] + then + ${PYTHON_CMD} -m pip install -U -r dist/b2bua/requirements.txt + fi - - name: transform_var - id: transform_var + - name: Build artifact name + id: artifact_name run: | - ARTNAME="test-logs_${{ inputs.mm-type }}_${{ inputs.mm-branch }}_${{ inputs.rtppc-type }}" - MM_AUTH="`echo "${{ inputs.mm-auth }}" | sed 's|/|.|g'`" - if [ "${MM_AUTH}" != "" ] + ARTNAME="test-logs_${{ inputs.mm-type }}_${{ inputs.mm-branch }}_${{ matrix.rtppc-type }}" + MM_AUTH_SAFE="$(echo "${{ matrix.mm-auth }}" | sed 's|/|.|g')" + if [ -n "${MM_AUTH_SAFE}" ] then - ARTNAME="${ARTNAME}_${MM_AUTH}" - echo "MM_AUTH=${{ inputs.mm-auth }}" >> $GITHUB_ENV + ARTNAME="${ARTNAME}_${MM_AUTH_SAFE}" fi if [ "${{ inputs.mm-type }}" = "b2bua" ] then - ARTNAME="${ARTNAME}_${{ inputs.python-version }}" + ARTNAME="${ARTNAME}_${{ matrix.python-version }}" fi - echo "ARTNAME=${ARTNAME}" >> $GITHUB_OUTPUT + echo "artname=${ARTNAME}" >> "$GITHUB_OUTPUT" - - name: Test (debug) + - name: Run tests run: | + cd /opt/voiptests + rm -rf test.logs + LOG_FILES="bob.log alice.log rtpproxy.log" + if [ -n "${ARTIFACT_FILES}" ] + then + LOG_FILES="${LOG_FILES} ${ARTIFACT_FILES}" + fi + RTPP_VERSION=debug sh -x ./test_run.sh mkdir -p test.logs/debug - mv bob.log alice.log rtpproxy.log ${{ inputs.artifact-files }} test.logs/debug + mv ${LOG_FILES} test.logs/debug - - name: Test (production) - run: | RTPP_VERSION=production sh -x ./test_run.sh mkdir -p test.logs/production - mv bob.log alice.log rtpproxy.log ${{ inputs.artifact-files }} test.logs/production + mv ${LOG_FILES} test.logs/production + + - name: Upload test logs + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact_name.outputs.artname }} + path: /opt/voiptests/test.logs + continue-on-error: true + + test_local: + if: ${{ inputs.use-local-image }} + runs-on: ubuntu-latest + needs: [build_ssut_image] + strategy: + fail-fast: false + matrix: + rtppc-type: ${{ fromJSON(inputs.rtppc-types) }} + mm-auth: ${{ fromJSON(inputs.mm-auths) }} + python-version: ${{ fromJSON(inputs.python-versions) }} + steps: + - name: Download SSUT image artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build_ssut_image.outputs.image-artifact }} + path: /tmp + + - name: Load SSUT image + run: docker load --input /tmp/ssut-image.tar + + - name: Build artifact name + id: artifact_name + run: | + ARTNAME="test-logs_${{ inputs.mm-type }}_${{ inputs.mm-branch }}_${{ matrix.rtppc-type }}" + MM_AUTH_SAFE="$(echo "${{ matrix.mm-auth }}" | sed 's|/|.|g')" + if [ -n "${MM_AUTH_SAFE}" ] + then + ARTNAME="${ARTNAME}_${MM_AUTH_SAFE}" + fi + if [ "${{ inputs.mm-type }}" = "b2bua" ] + then + ARTNAME="${ARTNAME}_${{ matrix.python-version }}" + fi + echo "artname=${ARTNAME}" >> "$GITHUB_OUTPUT" + + - name: Run tests in local image + run: | + PYTHON_VER="$(echo '${{ matrix.python-version }}' | sed 's|-dev$||')" + CTR_NAME="voiptests-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${{ strategy.job-index }}" + trap 'docker rm -f "${CTR_NAME}" >/dev/null 2>&1 || true' EXIT + + docker create \ + --name "${CTR_NAME}" \ + --privileged \ + --sysctl net.ipv6.conf.all.disable_ipv6=0 \ + -e MM_TYPE='${{ inputs.mm-type }}' \ + -e MM_BRANCH='${{ inputs.mm-branch }}' \ + -e MM_AUTH='${{ matrix.mm-auth }}' \ + -e RTPP_BRANCH='DOCKER' \ + -e RTPPC_TYPE='${{ matrix.rtppc-type }}' \ + -e ARTIFACT_FILES='${{ inputs.artifact-files }}' \ + -e PYTHON_CMD="python${PYTHON_VER}" \ + '${{ needs.build_ssut_image.outputs.image-ref }}' \ + /bin/sh -lc ' + set -e + cd /opt/voiptests + if ! command -v "${PYTHON_CMD}" >/dev/null 2>&1 + then + PYTHON_CMD=python3 + fi + export PYTHON_CMD + export PIP_BREAK_SYSTEM_PACKAGES=1 + + ${PYTHON_CMD} -m pip install --upgrade pip + ${PYTHON_CMD} -m pip install -r requirements.txt + if [ "${MM_TYPE}" = "b2bua" ] + then + ${PYTHON_CMD} -m pip install -U -r dist/b2bua/requirements.txt + fi + + rm -rf test.logs + LOG_FILES="bob.log alice.log rtpproxy.log" + if [ -n "${ARTIFACT_FILES}" ] + then + LOG_FILES="${LOG_FILES} ${ARTIFACT_FILES}" + fi + + RTPP_VERSION=debug sh -x ./test_run.sh + mkdir -p test.logs/debug + mv ${LOG_FILES} test.logs/debug + + RTPP_VERSION=production sh -x ./test_run.sh + mkdir -p test.logs/production + mv ${LOG_FILES} test.logs/production + ' + + docker start -a "${CTR_NAME}" + rm -rf test.logs + docker cp "${CTR_NAME}:/opt/voiptests/test.logs" ./test.logs - - name: Test logs + - name: Upload test logs uses: actions/upload-artifact@v4 with: - name: ${{ steps.transform_var.outputs.ARTNAME }} + name: ${{ steps.artifact_name.outputs.artname }} path: test.logs continue-on-error: true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 58d33a1..9ceb513 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,118 +1,98 @@ -# This is a basic workflow to help you get started with Actions - name: Alice->SSUT->Bob -# Controls when the action will run. on: - # Triggers the workflow on push or pull request events but only for the master branch push: -# branches: [ master ] pull_request: -# branches: [ master ] - release: types: [created] - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: - schedule: - cron: "30 0 * * 0" -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # Alice->OpenSIPS->Bob opensips: + name: OpenSIPS (${{ matrix.mm-branch }}) uses: ./.github/workflows/.main.yml with: mm-type: 'opensips' mm-branch: ${{ matrix.mm-branch }} - rtppc-type: ${{ matrix.rtppc-type }} + mm-auths: ${{ matrix.mm-auths }} + rtppc-types: ${{ matrix.rtppc-types }} + use-local-image: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} artifact-files: 'opensips.cfg' strategy: fail-fast: false matrix: - mm-branch: ['master', '3.5', '3.4', '3.3', '3.2'] - rtppc-type: ['unix', 'udp', 'udp6', 'tcp', 'tcp6'] include: - - mm-branch: '2.4' - rtppc-type: 'unix' - - mm-branch: '2.4' - rtppc-type: 'udp' - - mm-branch: '2.4' - rtppc-type: 'udp6' - - mm-branch: '3.0' - rtppc-type: 'unix' - - mm-branch: '3.0' - rtppc-type: 'udp' - - mm-branch: '3.0' - rtppc-type: 'udp6' + - mm-branch: 'master' + mm-auths: '["", "passtr", "UAC", "UAS/auth", "UAS/auth_db/calculate_ha1", "UAS/auth_db/ha1"]' + rtppc-types: '["unix", "udp", "udp6", "tcp", "tcp6"]' + - mm-branch: '3.5' + mm-auths: '[""]' + rtppc-types: '["unix", "udp", "udp6", "tcp", "tcp6"]' + - mm-branch: '3.4' + mm-auths: '[""]' + rtppc-types: '["unix", "udp", "udp6", "tcp", "tcp6"]' + - mm-branch: '3.3' + mm-auths: '[""]' + rtppc-types: '["unix", "udp", "udp6", "tcp", "tcp6"]' + - mm-branch: '3.2' + mm-auths: '[""]' + rtppc-types: '["unix", "udp", "udp6", "tcp", "tcp6"]' - mm-branch: '3.1' - rtppc-type: 'unix' - - mm-branch: '3.1' - rtppc-type: 'udp' - - mm-branch: '3.1' - rtppc-type: 'udp6' - - # Alice->OpenSIPS(RFC8760)->Bob - opensips_rfc8760: - uses: ./.github/workflows/.main.yml - with: - mm-type: 'opensips' - mm-auth: ${{ matrix.mm-auth }} - rtppc-type: ${{ matrix.rtppc-type }} - artifact-files: 'opensips.cfg' - - strategy: - fail-fast: false - matrix: - rtppc-type: ['unix', 'udp', 'udp6', 'tcp', 'tcp6'] - mm-auth: ['passtr', 'UAC', 'UAS/auth', 'UAS/auth_db/calculate_ha1', 'UAS/auth_db/ha1'] + mm-auths: '[""]' + rtppc-types: '["unix", "udp", "udp6"]' + - mm-branch: '3.0' + mm-auths: '[""]' + rtppc-types: '["unix", "udp", "udp6"]' + - mm-branch: '2.4' + mm-auths: '[""]' + rtppc-types: '["unix", "udp", "udp6"]' # Alice->Sippy Python B2B->Bob sippy_py_b2bua: + name: Sippy Py-B2BUA (${{ matrix.mm-branch }}) uses: ./.github/workflows/.main.yml with: mm-type: 'b2bua' mm-branch: ${{ matrix.mm-branch }} - rtppc-type: ${{ matrix.rtppc-type }} - python-version: ${{ matrix.python-version }} + python-versions: '["3.9", "3.10", "3.11", "3.12", "3.13-dev"]' + rtppc-types: '["unix", "cunix", "udp", "udp6", "tcp", "tcp6"]' + use-local-image: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} artifact-files: 'b2bua.log' strategy: fail-fast: false matrix: mm-branch: ['master', 'PRACK'] - rtppc-type: ['unix', 'cunix', 'udp', 'udp6', 'tcp', 'tcp6'] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13-dev'] # Alice->Sippy GO B2B->Bob sippy_go_b2bua: + name: Sippy GO-B2BUA (${{ matrix.mm-branch }}) uses: ./.github/workflows/.main.yml with: mm-type: 'go-b2bua' - rtppc-type: ${{ matrix.rtppc-type }} + rtppc-types: '["unix", "cunix", "udp", "udp6", "tcp", "tcp6"]' + use-local-image: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} artifact-files: 'b2bua.log' - strategy: - fail-fast: false - matrix: - rtppc-type: ['unix', 'cunix', 'udp', 'udp6', 'tcp', 'tcp6'] # Alice->Kamailio->Bob kamailio: + name: Kamailio (${{ matrix.mm-branch }}) uses: ./.github/workflows/.main.yml with: mm-type: 'kamailio' mm-branch: ${{ matrix.mm-branch }} - rtppc-type: ${{ matrix.rtppc-type }} + rtppc-types: '["unix", "udp", "udp6"]' + use-local-image: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} artifact-files: 'kamailio.cfg' strategy: fail-fast: false matrix: mm-branch: ['master', '6.0', '5.8', '5.7', '5.6', '5.5', '5.4', '5.3', '5.2', '5.1', '4.4'] - rtppc-type: ['unix', 'udp', 'udp6'] publish: - needs: [opensips, opensips_rfc8760, sippy_py_b2bua, sippy_go_b2bua, kamailio] + needs: [opensips, sippy_py_b2bua, sippy_go_b2bua, kamailio] if: github.event_name == 'release' && github.event.action == 'created' runs-on: ubuntu-latest diff --git a/cibits/build_ssut_image.sh b/cibits/build_ssut_image.sh new file mode 100755 index 0000000..f02c4d6 --- /dev/null +++ b/cibits/build_ssut_image.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +set -eux + +apt-get update +apt-get install -y --no-install-recommends \ + python3 python3-pip \ + gpp gdb tcpdump gcc libc6-dev git make ca-certificates \ + libssl-dev flex bison + +if [ "${MM_TYPE}" = "go-b2bua" ] +then + apt-get install -y --no-install-recommends golang-go +fi + +if [ "${MM_TYPE}" = "kamailio" ] +then + apt-get install -y --no-install-recommends libcurl4-gnutls-dev +fi + +SKIP_PY_DEPS=1 /bin/sh -x ./install_depends/opensips.sh + +rm -rf /var/lib/apt/lists/* diff --git a/install_depends/opensips.sh b/install_depends/opensips.sh index d44d645..897e720 100755 --- a/install_depends/opensips.sh +++ b/install_depends/opensips.sh @@ -72,7 +72,10 @@ fi if [ "${MM_TYPE}" = "b2bua" ] then - ${PYTHON_CMD} -m pip install -U -r b2bua/requirements.txt + if [ "${SKIP_PY_DEPS}" != "1" ] + then + ${PYTHON_CMD} -m pip install -U -r b2bua/requirements.txt + fi fi if [ "${MM_TYPE}" = "kamailio" ] From 87529e7bc6b260261975975ad05a9bc3c8fe959d Mon Sep 17 00:00:00 2001 From: Maksym Sobolyev Date: Sun, 15 Feb 2026 01:06:17 -0800 Subject: [PATCH 2/6] Use sippy from github@master. See if we dropped any cores. --- requirements.txt | 2 +- test_run.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7619d8f..60ddd59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -sippy +git+https://github.com/sippy/b2bua.git@master diff --git a/test_run.sh b/test_run.sh index 2d5cf14..92945b2 100755 --- a/test_run.sh +++ b/test_run.sh @@ -332,6 +332,8 @@ then fi fi +${SUDO} find /tmp/ -type f -name core\* + report_rc_log "${ALICE_RC}" "${MM_CFG} alice.log bob.log rtpproxy.log ${MM_LOG}" "Checking if Alice is happy" report_rc_log "${BOB_RC}" "${MM_CFG} bob.log alice.log rtpproxy.log ${MM_LOG}" "Checking if Bob is happy" From cbc564d26e9bd4b4a6b0b120067539bb08cd465e Mon Sep 17 00:00:00 2001 From: Maksym Sobolyev Date: Sun, 30 Nov 2025 11:41:17 -0800 Subject: [PATCH 3/6] Add test to simulate handling of NAT'ed contact header and check that the contact received in re-INVITE matches. This is to test add_contact_alias() and handle_ruri_alias() functions. --- functions | 9 ++-- lib/alice_testcore.py | 3 +- lib/bob_testcore.py | 3 +- scenarios/simple/opensips.cfg.in | 33 +++++++++++++- test_cases/nated_contact.py | 74 ++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 test_cases/nated_contact.py diff --git a/functions b/functions index 47e9f0d..f3c42bb 100755 --- a/functions +++ b/functions @@ -101,6 +101,7 @@ then BOB_TIMEOUT=90 TEST_SET="${TEST_SET},11,12,13,14,reinv_brkn1,reinv_brkn2,reinv_bad_ack" fi +TEST_SET="${TEST_SET},early_cancel_lost100" MM_ROOT="${MM_ROOT:-"${BUILDDIR}/dist/${MM_TYPE}"}" @@ -109,10 +110,10 @@ then TEST_SET="${TEST_SET},inv_brkn1" fi -#if [ "${MM_TYPE}" != "opensips" -o "${MM_BRANCH}" = "1.11" ] -#then -TEST_SET="${TEST_SET},early_cancel_lost100" -#fi +if [ "${MM_TYPE}" = "opensips" -a "${MM_BRANCH}" = "master" ] +then + TEST_SET="${TEST_SET},nated_contact" +fi #if [ "${MM_TYPE}" = "kamailio" ] #then diff --git a/lib/alice_testcore.py b/lib/alice_testcore.py index e9d5fd0..3b121a7 100644 --- a/lib/alice_testcore.py +++ b/lib/alice_testcore.py @@ -58,13 +58,14 @@ from ..test_cases.reinv_frombob import a_test_reinv_frombob from ..test_cases.reinv_bad_ack import a_test_reinv_bad_ack from ..test_cases.inv_brkn1 import a_test_inv_brkn1 +from ..test_cases.nated_contact import a_test_nated_contact ALL_TESTS = (a_test1, a_test2, a_test3, a_test4, a_test5, a_test6, a_test7, \ a_test8, a_test9, a_test10, a_test11, a_test12, a_test13, a_test14, \ a_test_early_cancel, a_test_early_cancel_lost100, a_test_reinvite, \ a_test_reinv_fail, a_test_reinv_brkn1, a_test_reinv_brkn2, \ a_test_reinv_onhold, a_test_reinv_adelay, a_test_reinv_frombob, \ - a_test_reinv_bad_ack, a_test_inv_brkn1) + a_test_reinv_bad_ack, a_test_inv_brkn1, a_test_nated_contact) class a_cfg(object): test_class = None diff --git a/lib/bob_testcore.py b/lib/bob_testcore.py index 0ed00ee..930d148 100644 --- a/lib/bob_testcore.py +++ b/lib/bob_testcore.py @@ -58,13 +58,14 @@ from ..test_cases.reinv_frombob import b_test_reinv_frombob from ..test_cases.reinv_bad_ack import b_test_reinv_bad_ack from ..test_cases.inv_brkn1 import b_test_inv_brkn1 +from ..test_cases.nated_contact import b_test_nated_contact ALL_TESTS = (b_test1, b_test2, b_test3, b_test4, b_test5, b_test6, b_test7, \ b_test8, b_test9, b_test10, b_test11, b_test12, b_test13, b_test14, \ b_test_early_cancel, b_test_early_cancel_lost100, b_test_reinvite, \ b_test_reinv_fail, b_test_reinv_brkn1, b_test_reinv_brkn2, \ b_test_reinv_onhold, b_test_reinv_adelay, b_test_reinv_frombob, \ - b_test_reinv_bad_ack, b_test_inv_brkn1) + b_test_reinv_bad_ack, b_test_inv_brkn1, b_test_nated_contact) class STMHooks(object): lossemul = 0 diff --git a/scenarios/simple/opensips.cfg.in b/scenarios/simple/opensips.cfg.in index 39552b3..c604cd5 100644 --- a/scenarios/simple/opensips.cfg.in +++ b/scenarios/simple/opensips.cfg.in @@ -15,6 +15,7 @@ loadmodule "sipmsgops/sipmsgops.so" loadmodule "sl/sl.so" loadmodule "tm/tm.so" loadmodule "rr/rr.so" +loadmodule "nathelper/nathelper.so" loadmodule "maxfwd/maxfwd.so" #if RTPPC_TYPE == rtp.io loadmodule "rtp.io/rtp.io.so" @@ -38,6 +39,12 @@ modparam("rtp.io", "rtpproxy_args", $$"-m 12000 -M 15000 RTPP_LISTEN"$$) modparam("rtpproxy", "rtpproxy_sock", RTPP_SOCK_TEST) #endif +#if OPENSIPS_VER_FULL == master || OPENSIPS_VER > 33 +#define NAT_PCONTACT "private-contact" +#else +#define NAT_PCONTACT INT(1) +#endif + #if OPENSIPS_VER != 24 && OPENSIPS_VER != 30 socket=udp:127.0.0.1:5060 socket=udp:[::1]:5060 @@ -88,13 +95,27 @@ route { }; if (is_method("BYE")) { - xlog(" calling rtpproxy_unforce()\n"); + xlog(" calling rtpproxy_unforce()\n"); rtpproxy_unforce(); }; record_route(); + if (is_method("INVITE") && !has_totag() && nat_uac_test(NAT_PCONTACT)) { + xlog(" NAT contact detected\n"); +#if OPENSIPS_VER_FULL == master + if (add_contact_alias()) { + xlog(" adding alias\n"); + }; +#endif + }; + if (loose_route()) { +#if OPENSIPS_VER_FULL == master + if (handle_ruri_alias()) { + xlog(" alias removed from the R-URI\n"); + }; +#endif t_relay(); exit; }; @@ -114,6 +135,14 @@ onreply_route[1] { xlog("OpenSIPS received a reply $rs/$rm from $si\n"); if (STATUS =~ "(183)|2[0-9][0-9]") { + if (nat_uac_test(NAT_PCONTACT)) { + xlog(" NAT contact detected\n"); +#if OPENSIPS_VER_FULL == master + if (add_contact_alias()) { + xlog(" adding alias\n"); + }; +#endif + }; xlog(" calling search()\n"); if(!search("^Content-Length:[ ]*0")) { xlog(" calling rtpproxy_answer()\n"); @@ -126,6 +155,6 @@ onreply_route[1] failure_route[1] { xlog("OpenSIPS handling $rm failure in from $si in failure_route[1]\n"); - xlog(" calling rtpproxy_unforce()\n"); + xlog(" calling rtpproxy_unforce()\n"); rtpproxy_unforce(); } diff --git a/test_cases/nated_contact.py b/test_cases/nated_contact.py new file mode 100644 index 0000000..bf1a1f2 --- /dev/null +++ b/test_cases/nated_contact.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025 Sippy Software, Inc. All rights reserved. +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from .reinv_frombob import a_test_reinv_frombob, b_test_reinv_frombob +from .TExceptions import ScenarioFailure + +from sippy.SipAddress import SipAddress +from sippy.SipContact import SipContact +from sippy.SipURL import SipURL +from sippy.UA import UA + + +class _ReinviteTrackingUA(UA): + """UA variant that keeps the R-URI of inbound in-dialog INVITEs.""" + + def recvRequest(self, req, *args, **kwargs): + if req.getMethod() == 'INVITE': + self.last_in_dialog_invite_ruri = str(req.getRURI()) + return super(_ReinviteTrackingUA, self).recvRequest(req, *args, **kwargs) + +class a_test_nated_contact(a_test_reinv_frombob): + cld = 'bob_nated_contact' + cli = 'alice_nated_contact' + name = f'{a_test_reinv_frombob.name}: NATed Contact preserved on re-INVITE' + compact_sip = False + nat_contact_ip = '192.168.255.10' + nat_contact_port = 5090 + + def build_ua(self, tccfg): + uaO = super(a_test_nated_contact, self).build_ua(tccfg, UA_class=_ReinviteTrackingUA) + nat_contact = self._build_nat_contact() + uaO.lContact = nat_contact + self.expected_contact_uri = str(nat_contact.getUrl()) + return uaO + + def _build_nat_contact(self): + nat_url = SipURL(username = self.cli, host = self.nat_contact_ip, + port = self.nat_contact_port) + nat_address = SipAddress(url = nat_url) + return SipContact(address = nat_address) + + def on_reinvite_connected(self, ua): + super(a_test_nated_contact, self).on_reinvite_connected(ua) + ruri = getattr(ua, 'last_in_dialog_invite_ruri', None) + if ruri != self.expected_contact_uri: + self.nerrs += 1 + print(ScenarioFailure('%s: expected re-INVITE R-URI %s, got %s' % + (self.failed_msg(), self.expected_contact_uri, ruri))) + +class b_test_nated_contact(b_test_reinv_frombob): + cli = a_test_nated_contact.cld + name = a_test_nated_contact.name From e21d1d9b0a1c4adf5102da3d6a552aee691cf760 Mon Sep 17 00:00:00 2001 From: Maksym Sobolyev Date: Thu, 19 Feb 2026 10:02:53 -0800 Subject: [PATCH 4/6] Implement first stab at the continuous mode of operation. --- alice_main.py | 8 +++++++ lib/alice_testcore.py | 51 ++++++++++++++++++++++++++++++++++++++----- lib/bob_testcore.py | 36 ++++++++++++++++++++++++++++-- lib/spinor.py | 15 +++++++++++++ lib/test_config.py | 11 +++++++++- lib/testcore_base.py | 39 ++++++++++++++++++++++++++++++++- test_cases/t1.py | 2 ++ 7 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 lib/spinor.py diff --git a/alice_main.py b/alice_main.py index 3187e84..15b3faa 100644 --- a/alice_main.py +++ b/alice_main.py @@ -27,6 +27,8 @@ import sys, getopt +from .contrib.objgraph import show_most_common_types + from sippy.MsgBody import MsgBody from sippy.SipLogger import SipLogger from sippy.Core.EventDispatcher import ED2 @@ -85,6 +87,9 @@ def main_func(): if o == '-s': tcfg.signalling_only = True continue + if o == '-C': + tcfg.cps = float(a) + continue if len(ttype) > 0: tcfg.ttype = tuple(ttype) @@ -101,4 +106,7 @@ def main_func(): if not dry_run: ED2.loop() + print('\n-- Types Summary: --') + show_most_common_types(50) + sys.exit(acore.rval) diff --git a/lib/alice_testcore.py b/lib/alice_testcore.py index 3b121a7..3439cb0 100644 --- a/lib/alice_testcore.py +++ b/lib/alice_testcore.py @@ -23,13 +23,15 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -##import sys +import sys +from signal import SIGINT ##sys.path.insert(0, 'dist/b2bua') from sippy.SipTransactionManager import SipTransactionManager -from sippy.Time.Timeout import Timeout +from sippy.Signal import Signal +from sippy.Time.Timeout import Timeout, TimeoutAbsMono from sippy.Core.EventDispatcher import ED2 -from random import shuffle +from random import shuffle, choice from .testcore_base import testcore_base @@ -84,6 +86,9 @@ class a_test(testcore_base): stats_name = 'Alice' nsubtests_running = 0 rval = 1 + tcfg = None + last_ulen = 0 + nextr = None def __init__(self, tcfg): self.setup_stm(tcfg) @@ -104,10 +109,22 @@ def __init__(self, tcfg): subtest_cfg.tcfg = tcfg.gen_tccfg(atype, signalling_only, \ self.subtest_done, cli) atests.append(subtest_cfg) - mode = 'signalling-only' if signalling_only else 'rtp-enabled' - print(f'Scheduling test: {subtest_class.name} [{atype}, {mode}, {cli=}]') + if tcfg.continuous: + subtest_class.debug_lvl = -1 + subtest_class.godead_timeout = 1.0 + else: + mode = 'signalling-only' if signalling_only else 'rtp-enabled' + print(f'Scheduling test: {subtest_class.name} [{atype}, {mode}, {cli=}]') atests = [x for x in atests if not x.disabled] super().__init__(tcfg) + if tcfg.continuous: + self.atests = atests + self.ntime = tcfg.ntime.getCopy() + #Timeout(self.runnext, 0.1, -1) + self.setup_continuous_stats() + self.runnext() + Signal(SIGINT, self.deorbit) + return shuffle(atests) for subtest_cfg in atests: subtest = subtest_cfg.init_test() @@ -119,3 +136,27 @@ def __init__(self, tcfg): def _on_no_subtests_left(self): super()._on_no_subtests_left() ED2.breakLoop() + + def deorbit(self, signum = None): + self.nextr.cancel() + Timeout(self.slowexit, 0.1, -1) + Timeout(self.timeout, 140.0) + + def slowexit(self): + self.update_stats() + if self.nsubtests_running > 0: + return + sys.stdout.write('\n') + sys.stdout.flush() + ED2.breakLoop() + + def runnext(self): + subtest_cfg = choice(self.atests) + subtest = subtest_cfg.init_test() + self.nsubtests_running += 1 + if self.tcfg.cps != None: + self.ntime.offset(1.0 / self.tcfg.cps) + self.nextr = TimeoutAbsMono(self.runnext, self.ntime) + else: + self.runnext() + self.update_stats() diff --git a/lib/bob_testcore.py b/lib/bob_testcore.py index 930d148..5e6bc16 100644 --- a/lib/bob_testcore.py +++ b/lib/bob_testcore.py @@ -23,11 +23,14 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -##import sys +import sys +from signal import SIGUSR1 ##sys.path.insert(0, 'dist/b2bua') from sippy.Time.Timeout import Timeout from sippy.SipTransactionManager import SipTransactionManager +from sippy.Signal import Signal + from random import random from .test_config import fillhostport, SIPPY_RTP_OUSER @@ -98,13 +101,30 @@ class b_test(testcore_base): rval = 1 nsubtests_running = 0 tcfg: 'test_config' = None + debug = False + spinor = None + last_ulen = 0 + active_subtests = None def __init__(self, tcfg:'test_config'): self.setup_stm(tcfg) super().__init__(tcfg) - Timeout(self.timeout, tcfg.test_timeout, 1) + if not tcfg.continuous: + Timeout(self.timeout, tcfg.test_timeout, 1) + else: + self.active_subtests = [] + self.setup_continuous_stats() + Signal(SIGUSR1, self.dumpcalls) + + def dumpcalls(self): + sys.stderr.write('BOB: Active tests:\n') + for c in self.active_subtests: + sys.stderr.write('\t%d %s\n' % (id(c), str(c))) + sys.stderr.flush() def recvRequest(self, req, sip_t): + if self.debug: + print('recvRequest') if req.getHFBody('to').getTag() is not None: # Request within dialog, but no such dialog return (req.genResponse(481, 'Call Leg/Transaction Does Not Exist'), None, None) @@ -132,11 +152,17 @@ def recvRequest(self, req, sip_t): tccfg = self.tcfg.gen_tccfg(atype, signalling_only, self.subtest_done) + if self.tcfg.continuous: + tclass.debug_lvl = -1 + tclass.godead_timeout = 1.0 subtest = tclass(tccfg) test_id = req.getHFBody('to').getUrl().username subtest.mightfail = test_id in self.tcfg.tests_mightfail self.nsubtests_running += 1 + if self.tcfg.continuous: + self.active_subtests.append(subtest) + self.update_stats() self.rval += 1 bidx = 0 if not tccfg.signalling_only or random() < 0.5 else 1 @@ -150,6 +176,9 @@ def recvRequest(self, req, sip_t): resp.appendHeaders(ce.challenges) self.nsubtests_running -= 1 self.rval -= 1 + if self.tcfg.continuous: + self.active_subtests.remove(subtest) + self.update_stats() resp.lossemul = STMHooks() resp.lossemul.dupemul = 0.001 return (resp, None, None) @@ -162,3 +191,6 @@ def timeout(self): if self.nsubtests_running == 0 and self.rval == 1: self.rval = 0 super().timeout() + + def _subtest_done_continuous(self, subtest): + self.active_subtests.remove(subtest) diff --git a/lib/spinor.py b/lib/spinor.py new file mode 100644 index 0000000..206e627 --- /dev/null +++ b/lib/spinor.py @@ -0,0 +1,15 @@ +class spinor(object): + i = 0 + busy_states = '-\\|/' + idle_states = '.oOo' + idle = False + + def tick(self): + if self.idle: + st = self.idle_states + else: + st = self.busy_states + ri = self.i % len(st) + rv = st[ri] + self.i += 1 + return rv diff --git a/lib/test_config.py b/lib/test_config.py index 2b2fedb..f0a39d8 100644 --- a/lib/test_config.py +++ b/lib/test_config.py @@ -65,6 +65,7 @@ class test_case_config(object): check_media_ips = None cli = None signalling_only = False + continuous = False def __init__(self, nh_address): self.nh_address = nh_address @@ -93,7 +94,7 @@ def checkhostport(self, sdp_body): return (True, 'all good') class test_config(object): - getopts = 'p:l:P:T:n:N:w:m:' + getopts = 'p:l:P:T:n:N:w:m:c' global_config = None ttype = ('IP4', 'IP6') body = None @@ -107,8 +108,12 @@ class test_config(object): tests_mightfail = tuple() signalling_only = False pre_wait = None + continuous = False + cps = None + ntime = None def gen_tccfg(self, atype, signalling_only, done_cb, cli=None): + self.ntime = MonoTime() if atype == 'IP4': nh_address = self.nh_address4 else: @@ -127,6 +132,7 @@ def gen_tccfg(self, atype, signalling_only, done_cb, cli=None): tccfg.atype = atype tccfg.portrange = self.portrange tccfg.signalling_only = signalling_only + tccfg.continuous = self.continuous return tccfg def _parse_common_opt(self, o, a): @@ -161,6 +167,9 @@ def _parse_common_opt(self, o, a): if o == '-m': self.tests_mightfail = tuple(a.split(',')) return True + if o == '-c': + self.continuous = True + return True return False def __init__(self, global_config, opts = None): diff --git a/lib/testcore_base.py b/lib/testcore_base.py index 2662f67..84e9517 100644 --- a/lib/testcore_base.py +++ b/lib/testcore_base.py @@ -26,8 +26,11 @@ import sys from sippy.Core.EventDispatcher import ED2 +from sippy.Math.recfilter import recfilter +from sippy.Time.Timeout import Timeout from .sleep_abs_mono import sleep_abs_mono +from .spinor import spinor class testcore_base(object): stm_class = None @@ -44,15 +47,42 @@ def setup_stm(self, tcfg): self.stm_class.model_udp_server[1].nworkers = 1 tcfg.global_config['_sip_tm'] = self.stm_class(tcfg.global_config, self.recvRequest) + def setup_continuous_stats(self): + self.spinor = spinor() + self.rcf = recfilter(0.999, 1.0) + self.update_stats() + Timeout(self.idle_update, 1.0, -1) + def recvRequest(self, req, sip_t): return (req.genResponse(501, 'Not Implemented'), None, None) def timeout(self): ED2.breakLoop() + def update_stats(self): + if self.nsubtests_running == 0: + self.spinor.idle = True + else: + self.spinor.idle = False + omsg = '\r%s: %d tests running %s, %f' % (self.stats_name, \ + self.nsubtests_running, self.spinor.tick(), self.rcf.lastval) + sys.stdout.write(omsg) + if len(omsg) < self.last_ulen: + pad = ' ' * (self.last_ulen - len(omsg)) + sys.stdout.write(pad) + sys.stdout.flush() + self.last_ulen = len(omsg) + + def idle_update(self): + if self.spinor.idle: + self.update_stats() + def _subtest_done_common(self, subtest): pass + def _subtest_done_continuous(self, subtest): + pass + def _on_no_subtests_left(self): pass @@ -61,5 +91,12 @@ def subtest_done(self, subtest): self._subtest_done_common(subtest) if subtest.rval == 0: self.rval -= 1 - if self.nsubtests_running == 0: + if self.tcfg.continuous: + self._subtest_done_continuous(subtest) + if subtest.rval == 0: + self.rcf.apply(1.0) + else: + self.rcf.apply(0.0) + self.update_stats() + elif self.nsubtests_running == 0: self._on_no_subtests_left() diff --git a/test_cases/t1.py b/test_cases/t1.py index 4bb0bf9..c740af2 100644 --- a/test_cases/t1.py +++ b/test_cases/t1.py @@ -256,6 +256,8 @@ def report_rtp(self): stats = self._rtp_shutdown_stats() if self._rtp_error is not None and self.debug_lvl > -1: print('%s: RTP error: %s' % (self.my_name(), self._rtp_error)) + if self.tccfg.continuous: + return if self._rtp_target is None: print('%s: RTP stats: no session' % self.my_name()) return From 93e7d9083c0f511a6aaca6a4bae7313069cd6f97 Mon Sep 17 00:00:00 2001 From: Maksym Sobolyev Date: Thu, 30 Jun 2022 08:48:45 -0700 Subject: [PATCH 5/6] Allow CPS to be a function of run time (i.e. time in seconds since test began) when arguments starts with "=". --- alice_main.py | 8 +++++++- lib/alice_testcore.py | 2 +- lib/test_config.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/alice_main.py b/alice_main.py index 15b3faa..2d0a98c 100644 --- a/alice_main.py +++ b/alice_main.py @@ -26,6 +26,7 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import sys, getopt +import math from .contrib.objgraph import show_most_common_types @@ -88,7 +89,12 @@ def main_func(): tcfg.signalling_only = True continue if o == '-C': - tcfg.cps = float(a) + if not a.startswith('='): + cpsval = float(a) + tcfg.cps = lambda now: cpsval + else: + cpsfunc = eval(F'lambda now: {a[1:]}') + tcfg.cps = cpsfunc continue if len(ttype) > 0: tcfg.ttype = tuple(ttype) diff --git a/lib/alice_testcore.py b/lib/alice_testcore.py index 3439cb0..921be50 100644 --- a/lib/alice_testcore.py +++ b/lib/alice_testcore.py @@ -155,7 +155,7 @@ def runnext(self): subtest = subtest_cfg.init_test() self.nsubtests_running += 1 if self.tcfg.cps != None: - self.ntime.offset(1.0 / self.tcfg.cps) + self.ntime.offset(1.0 / self.tcfg.cps(self.ntime - self.tcfg.ntime)) self.nextr = TimeoutAbsMono(self.runnext, self.ntime) else: self.runnext() diff --git a/lib/test_config.py b/lib/test_config.py index f0a39d8..6faf60b 100644 --- a/lib/test_config.py +++ b/lib/test_config.py @@ -94,7 +94,7 @@ def checkhostport(self, sdp_body): return (True, 'all good') class test_config(object): - getopts = 'p:l:P:T:n:N:w:m:c' + getopts = 'p:l:P:T:n:N:w:m:c:' global_config = None ttype = ('IP4', 'IP6') body = None From 47f51efdbecb7c4d6609d17bff1371683dd6bd7c Mon Sep 17 00:00:00 2001 From: Maksym Sobolyev Date: Mon, 14 Apr 2025 10:37:52 -0700 Subject: [PATCH 6/6] Add dummy usage(). --- bob_main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bob_main.py b/bob_main.py index be9f8bf..177586d 100644 --- a/bob_main.py +++ b/bob_main.py @@ -61,6 +61,9 @@ BODIES_ALL = (body_audio, body_fax) +def usage(args): + printf("bob.py -p xx -l yy -P zz -T tt -n aa -N bb -w cc -s") + def main_func(): global_config = {}