Skip to content

SIP & SDP manipulation

The classic header-manipulation (HMR) job: rewrite, add, or strip headers and SDP as a message crosses a boundary. SIPhon hands you the parsed message and gets out of the way. You decide what changes, in Python, with the whole standard library (including re) available. There is no manipulation DSL to learn; it is just code.

Headers

Every header operation is a method on the request (or, on the B2BUA, the call). Names are case-insensitive.

from siphon import proxy

@proxy.on_request
def edit(request):
    # read
    ua = request.get_header("User-Agent")        # str | None
    has_pai = request.has_header("P-Asserted-Identity")

    # every value, for a header the peer may spread over several lines
    vias = request.get_headers("Via")            # list[str]

    # strip inbound internal headers by name prefix (case-insensitive), e.g. X-*
    request.remove_headers_matching("X-")

    # then stamp our own
    request.set_header("X-Trunk", "wholesale-a")  # replace (or add if absent)
    request.ensure_header("Max-Forwards", "70")   # set only if not already present
    request.remove_header("P-Preferred-Identity")

    # one value out of a multi-value header
    request.remove_from_header_list("Supported", "100rel")

    request.relay()
Call What it does
get_header(name) / header(name) Read the first value (None if absent).
get_headers(name) Read every value, in order ([] if absent).
has_header(name) Presence check.
set_header(name, value) Replace the header, or add it if absent.
ensure_header(name, value) Set only if not already present.
remove_header(name) Remove a header by name.
remove_headers_matching(prefix) Remove every header whose name starts with prefix (case-insensitive).
remove_from_header_list(name, value) Drop one value from a comma-separated header.

Regex

remove_headers_matching is a name-prefix strip, not a regex. When you need a real regular expression, reach for Python's re on the value you read. The scripts are ordinary Python, so the whole re module is there:

import re
from siphon import proxy

SCANNERS = re.compile(r"sipvicious|friendly-scanner|sipcli", re.IGNORECASE)

@proxy.on_request
def drop_scanners(request):
    # drop known scanners silently (no response = no fingerprint)
    if SCANNERS.search(request.get_header("User-Agent") or ""):
        return

    # rewrite inside a header value
    diversion = request.get_header("Diversion")
    if diversion:
        request.set_header("Diversion", re.sub(r"sip:(\d+)@", r"tel:\1;", diversion))

    request.relay()

SDP

Parse the body, edit it, apply it back. apply() sets the body, Content-Type, and Content-Length for you.

from siphon import proxy, sdp

@proxy.on_request("INVITE")
def edit(request):
    if not request.has_body("application/sdp"):
        request.relay(); return

    s = sdp.parse(request)

    # keep only G.711
    s.filter_codecs(["PCMU", "PCMA"])

    # strip video
    s.remove_media("video")

    # put audio on hold
    for m in s.media:
        if m.media_type == "audio":
            m.port = 0

    # session- and media-level a= attributes
    s.set_attr("ice-lite")                 # flag attribute
    for m in s.media:
        m.set_attr("ptime", "20")

    s.apply(request)
    request.relay()

The full sdp surface (media sections, codecs, a= attributes, connection lines) is in the SDP reference.

On the B2BUA

At an SBC boundary, the same header and SDP methods exist on the call, but the right tool for which headers cross the A↔B boundary is a header policy, not per-header code. Pick a preset, then apply per-call deltas only for the exceptions:

from siphon import b2bua

@b2bua.on_invite
def on_invite(call):
    call.remove_headers_matching("X-")           # strip internal headers
    call.dial(
        "sip:+15551234567@carrier.example.net",
        header_policy="sip-trunk-edge@2026",     # the boundary hygiene preset
        copy=["X-Account-Id"],                    # let this one through
        strip=["History-Info"],                   # drop this one
    )

Script call.set_header() / call.remove_header() always win over the preset. The presets and precedence are covered in the SBC recipe.

Test it

Exercise the logic without a running server using the siphon-sip mock SDK:

from siphon_sdk import mock_module
from siphon_sdk.request import Request

mock_module.install()

# internal headers are stripped, and the request is relayed
request = Request(method="INVITE", headers={"X-Internal-Trace": "abc"})
edit(request)
assert request.get_header("X-Internal-Trace") is None
assert request.last_action.kind == "relay"

# a scanner is dropped: no routing action was taken
scanner = Request(method="INVITE", headers={"User-Agent": "friendly-scanner"})
drop_scanners(scanner)
assert scanner.actions == []

The line

The manipulation is yours to define; SIPhon just gives you a clean, parsed message to change and re-serializes it correctly (Via, Content-Length, header ordering). What to rewrite, and why, is your policy.

See also

Multi-value headers

RFC 3261 §7.3.1 lets a peer spread one header over several lines, and the ones a script reaches for most often arrive that way: Via, Record-Route, Route, Contact, Supported, Path, the P-* family. get_header returns the first value, so reading a multi-line header through it truncates silently — the script sees a well-formed value and never learns there were more.

# every entry, in arrival order
for record_route in request.get_headers("Record-Route"):
    log.info(record_route)

# the first one only — fine for User-Agent, wrong for Via
top_via = request.get_header("Via")

One entry per header line. A single line carrying several comma-separated URIs stays one entry; split it yourself if you need them apart.

reply.get_headers(name) reads a response the same way.

The route set on a dialog-forming response

siphon copies Record-Route into a dialog-forming 2xx (and an early-dialog provisional) by itself — RFC 3261 §12.1.1 requires a UAS to echo every value, in order, with all parameters intact, and the UAC reverses that list to build its route set. You do not need to write it, and you should not: dropping one entry hands the peer a route set short by a hop, which strands every in-dialog request it sends.

It applies to an initial INVITE, SUBSCRIBE or REFER your script answers with request.reply(), and to a B2BUA call.answer(). A re-INVITE, an in-dialog request, a non-dialog-forming method, 100 Trying and any failure response are all left alone.

Override it only if you mean to reshape the route set — set_reply_header replaces what the framework copied:

request.set_reply_header("Record-Route", "<sip:203.0.113.9:5060;lr>")