NetSIP is an allocation-conscious SIP-over-TLS server and byte-oriented SIP parser for .NET 10 LTS. It provides a reusable library, a runnable host, a dependency-free test executable, and a deterministic allocation harness.
| Project | Purpose |
|---|---|
src\NetSIP |
TLS server, streaming framer, parser, handlers, response writer, and certificate loader |
samples\NetSIP.Sample |
Configuration-driven OPTIONS/REGISTER/INVITE server |
tests\NetSIP.Tests |
Unit and real-network TLS integration tests |
benchmarks\NetSIP.Benchmarks |
Warmed parser throughput and allocation measurement |
The solution targets net10.0 and pins the installed stable 10.0.302 SDK.
.NET 10 is an LTS release.
- A bounded
Socketaccept loop applies the configured concurrent-connection limit before accepting more clients. - Each connection uses
NetworkStream,SslStream, andSystem.IO.Pipelines. TLS is restricted to TLS 1.2 and TLS 1.3. SipMessageFramerwalks a possibly segmentedReadOnlySequence<byte>to findCRLF CRLF, validate headers, readContent-Length, and isolate exactly one message. Fragmented bodies and multiple pipelined messages are supported.SipParservalidates the complete message fromReadOnlySpan<byte>and returns a stack-onlySipMessageView. Headers are enumerated on demand; no dictionary or per-message header collection is created.- A per-connection
SipRequestContextis reused for serial dispatch. The response writer copies bytes directly into the pooled output pipeline.
The server does not queue messages independently of the pipeline. A handler must complete before the input buffer is advanced, providing bounded backpressure and preserving borrowed-buffer lifetimes.
Create a PFX certificate:
dotnet dev-certs https -ep .\server.pfx -p changeit
$env:NETSIP_PFX_PASSWORD = "changeit"
dotnet run --project .\samples\NetSIP.Sample -c Release -- --pfx .\server.pfx --port 5061Or create PEM files with OpenSSL:
openssl req -x509 -newkey rsa:3072 -sha256 -nodes -days 30 `
-keyout server.key -out server.pem -subj "/CN=localhost" `
-addext "subjectAltName=DNS:localhost"
dotnet run --project .\samples\NetSIP.Sample -c Release -- --pem .\server.pem --key .\server.keyEncrypted PEM keys are supported with --key-password-env; the default
environment variable is NETSIP_PEM_KEY_PASSWORD. PFX passwords use
--password-env, defaulting to NETSIP_PFX_PASSWORD. Passwords and private-key
material are never logged. On Windows, certificates are imported into the user
key store because Schannel cannot authenticate with ephemeral private keys; on
other platforms, ephemeral key storage is used.
The sample accepts --address (default 0.0.0.0) and --port (default 5061).
Press Ctrl+C for graceful shutdown.
To enable the *86 playback route, provide a WAV file and the unicast address
that callers can reach for RTP:
$env:NETSIP_STAR86_WAV = "C:\media\voicemail.wav"
$env:NETSIP_RTP_ADDRESS = "192.0.2.20"NETSIP_RTP_BIND_ADDRESS overrides the local UDP bind address, and
NETSIP_STAR86_CONTACT overrides the generated SIP Contact. By default, the
SDP media address offered by a caller must match its signaling peer address.
using System.Net;
using NetSIP;
using var certificate = SipCertificateLoader.Load(
new SipCertificateOptions
{
PfxPath = "server.pfx",
PfxPassword = Environment.GetEnvironmentVariable("NETSIP_PFX_PASSWORD")
});
const string realm = "sip.example.com";
string password = Environment.GetEnvironmentVariable("NETSIP_DIGEST_PASSWORD")
?? throw new InvalidOperationException("Set NETSIP_DIGEST_PASSWORD.");
var application = new DefaultSipRequestHandler(
new RegisterSipRequestHandler(),
new SipInviteRequestHandler(
new PrefixSipDialPlanProcessor(
[
new SipDialPlanRule(
"1",
SipDialPlanResult.Redirect(
"sips:gateway@example.com"u8.ToArray()))
],
SipDialPlanResult.Reject(
404,
"Not Found"u8.ToArray()))));
var credentials = new InMemorySipDigestCredentialProvider(
realm,
[new KeyValuePair<string, string>("alice", password)]);
var handler = new SipDigestAuthenticationHandler(
application,
credentials,
new SipDigestAuthenticationOptions
{
Realm = realm,
Algorithms = SipDigestAlgorithms.Sha256
});
await using var server = new SipTlsServer(
new SipTlsServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Any, 5061),
ServerCertificate = certificate,
MaxConcurrentConnections = 512,
Limits = new SipServerLimits
{
MaxStartLineBytes = 2 * 1024,
MaxHeaderLineBytes = 8 * 1024,
MaxHeaderBytes = 64 * 1024,
MaxHeaderCount = 128,
MaxBodyBytes = 1024 * 1024,
MaxMessagesPerConnection = 10_000
}
},
handler);
await server.StartAsync();DefaultSipRequestHandler returns 200 OK for OPTIONS, copies every Via, and
preserves From, To, Call-ID, and CSeq. Pass a RegisterSipRequestHandler to its
constructor to opt into REGISTER; the parameterless default rejects REGISTER
with 501 Not Implemented. The REGISTER handler provides a bounded,
process-local location store with binding queries, multiple Contact values,
per-contact/global expiration, automatic expiry, expires=0 removal, and
wildcard removal. Successful responses enumerate current bindings with
effective expires parameters. Intervals below the configured minimum receive
423 Interval Too Brief; stale requests using a lower CSeq for the same
Call-ID receive 500 Server Internal Error. The store applies address,
binding, Call-ID, per-contact-byte, and aggregate-byte limits.
The built-in store is not durable and is intended for a single server process.
Production registrars requiring shared persistence, replication, or custom
authorization should implement ISipRequestHandler around their location
service and call SipResponseWriter.WriteRegisterOk with the resulting
SipRegistrationBinding set. REGISTER necessarily allocates owned binding
state and is outside the zero-allocation parser guarantee.
REGISTER is opt-in because the built-in handler does not authenticate requests.
Configure SipRegisterHandlerOptions conservatively and place authentication
and authorization ahead of it before exposing a registrar to untrusted peers.
INVITE is also opt-in. SipInviteRequestHandler validates CSeq, Contact, and
Max-Forwards before calling an ISipDialPlanProcessor. A
PrefixSipDialPlanProcessor applies longest-prefix matching to the request-URI
user and returns an owned SipDialPlanResult: Answer (200 with Contact and
optional body), Redirect (3xx with Contact), or Reject (4xx-6xx).
Custom processors may perform asynchronous routing lookups while the borrowed
SipInviteContext remains valid only until ProcessAsync completes.
SipAudioFileDialPlanProcessor wraps another dialplan and reserves *86 for
one-shot audio playback. It loads and validates the WAV file at startup,
transcodes it once to G.711 PCMU, answers compatible SDP offers with a
dynamically allocated RTP port, and sends 20 ms RTP packets at 8 kHz. PCM WAV
input may be mono or stereo, 8 or 16 bits, and 8-48 kHz; mono 8 kHz mu-law WAV
is accepted without transcoding. Other destinations continue through the
wrapped dialplan. Playback accepts an offer with exactly one PCMU audio media
section whose effective direction permits the caller to receive media.
Playback is bounded by configured file size, duration, and concurrent-session limits. The secure default accepts only unicast media addresses equal to the TLS signaling peer, preventing third-party UDP reflection. Dispose the processor after stopping the SIP server to cancel playback and release RTP sockets. This is intentionally a one-shot media feature: it does not implement RTCP, DTMF, re-INVITE media renegotiation, or dialog-driven BYE/ACK state.
SipDigestAuthenticationHandler can protect REGISTER, INVITE, or both before
delegating to the application handler. It implements SIP Digest with
qop=auth, prefers SHA-256, binds the response to the exact method and
request URI, compares digests in constant time, signs expiring nonces with a
per-process HMAC secret, and keeps a bounded highest-nc replay table.
ISipDigestCredentialProvider supports asynchronous credential lookup and
returns owned H(A1) values so plaintext passwords need not be retained.
InMemorySipDigestCredentialProvider hashes configured passwords during
startup and does not retain them. If the replay table is full of live entries,
new authentications fail closed until an entry expires rather than weakening
replay protection.
Authorization parsing is intentionally strict: duplicate Authorization headers, duplicate known parameters, escaped quoted-pairs, invalid UTF-8 usernames, and digest URIs that are not byte-identical to the request target are rejected.
MD5 is available only for legacy interoperability by enabling
SipDigestAlgorithms.Md5. When SHA-256 and MD5 are enabled, the server emits
separate challenges in preference order. MD5 is cryptographically weak and
must not be enabled unless required by a client; TLS remains mandatory because
Digest does not encrypt SIP headers or bodies. The sample enables
authentication when NETSIP_DIGEST_USERNAME and NETSIP_DIGEST_PASSWORD are
set. NETSIP_DIGEST_REALM selects the realm, and
NETSIP_DIGEST_ALLOW_MD5=true opts into MD5.
The sample enables a catch-all INVITE dialplan. Set
NETSIP_INVITE_REDIRECT to a safe SIP/SIPS contact URI to redirect calls;
without it, INVITE receives 404 Not Found.
Custom handlers implement ISipRequestHandler. Response construction is
synchronous and span-based; the server flushes after the handler completes:
public sealed class Handler : ISipRequestHandler
{
public ValueTask HandleAsync(
SipRequestContext context,
CancellationToken cancellationToken)
{
SipMessageView request = context.Message;
if (!context.Response.WriteResponse(
200,
"OK"u8,
request,
"ready"u8,
"text/plain"u8))
{
context.Response.WriteError(400);
}
return ValueTask.CompletedTask;
}
}SipMessageView, SipHeaderView, and their spans are borrowed. They are valid
only until HandleAsync completes and must not be retained or used by work that
outlives the handler. SipRequestContext.CopyMessage() performs the explicit
allocation required when ownership is needed.
Startup validation rejects missing/private-key-less/expired certificates, invalid endpoints, non-positive connection settings, invalid limits, and non-finite timeouts. The server enforces:
- start-line, individual header-line, total header, header-count, and body limits;
- maximum messages per connection and maximum concurrent connections;
- TLS handshake, transport-read, and handler timeouts;
- strict CRLF framing, valid header tokens, non-folded headers, numeric
Content-Length, matching duplicate long/compact Content-Length values, and REGISTER wildcard/CSeq rules; - WAV file size/duration and concurrent RTP playback limits when
*86is enabled.
Malformed messages receive 400 Bad Request; oversized messages receive
513 Message Too Large. When enough of a valid request is available, error
responses preserve its transaction headers. Incomplete-message read timeouts
close silently because no valid SIP transaction is available to receive a 408.
Error responses are sent after TLS establishment when possible, then the
connection is closed.
NetSIP is a transport/parser with a process-local registrar and optional Digest gate plus one-shot RTP playback, not a complete SIP proxy or media server. Digest authentication does not provide authorization, account lockout, or identity/IP rate limiting. NetSIP also does not provide transaction/dialog or media-session storage, durable/shared location storage, client-certificate authentication, certificate rotation, UDP, or WebSocket transport. Deploy behind appropriate network controls, use a publicly or privately trusted certificate, keep limits conservative, authorize authenticated identities in the application, and add rate limiting appropriate to the deployment.
The following application-controlled operations are designed to allocate zero managed bytes after JIT warmup:
- contiguous
SipParser.TryParsecalls; SipMessageFramersequence walking;- header lookup/enumeration through borrowed ref-struct views;
- default OPTIONS parsing and response serialization into already-available pipeline buffers.
The benchmark verifies the isolated parser loop with
GC.GetAllocatedBytesForCurrentThread. A representative run on the development
machine processed roughly 2-3 million messages/second and reported exactly
0 bytes across 1,000,000 parses after warmup. Throughput is machine-dependent;
the allocation check exits nonzero if the measured loop allocates.
This is not a claim that the complete TLS server is zero-allocation.
Socket, SslStream, pipelines, Tasks/async runtime machinery, cancellation
timers, logging providers, cryptography, and connection tracking may allocate.
NetSIP allocates connection state once per connection and reuses its request
context and timeout sources. A message spanning pipeline segments is copied
through ArrayPool<byte> and returned after dispatch; pool growth can allocate.
Digest authentication allocates an owned username for asynchronous credential
lookup and bounded replay state after successful authentication.
Audio playback allocates owned transcoded media at startup and one RTP packet
buffer, UDP socket, and session task per active call.
Calling CopyMessage, retaining application state, or allocating in a custom
handler is explicitly outside the parser/serializer guarantee.
Handler timeouts are cooperative: NetSIP cancels the handler token at the deadline, and handlers are contractually required to observe it. .NET cannot safely terminate arbitrary user code; an implementation that ignores cancellation can delay its connection slot and graceful shutdown. This preserves the borrowed message buffer until the handler actually returns and avoids unsafe pool reuse.
dotnet restore .\NetSIP.slnx
dotnet build .\NetSIP.slnx -c Release --no-restore
dotnet run --project .\tests\NetSIP.Tests -c Release --no-build
dotnet run --project .\benchmarks\NetSIP.Benchmarks -c Release --no-buildThe test executable has no test-framework package dependency. It exits nonzero on failure and covers fragmented and segmented framing, pipelining, body boundaries, limits, raw/case-insensitive headers, PFX/PEM loading, concurrent real TLS clients, REGISTER/INVITE routing, Digest authentication, RTP playback, malformed-message responses, and graceful shutdown.