Skip to main content

Directory & XML-CURL Integration

Overview

The Directory & XML-CURL Integration provides dynamic user authentication for FreeSWITCH via mod_xml_curl. Instead of static XML directory files, this system uses database-backed user provisioning from PostgreSQL, enabling real-time user management through the API.

This replaces the traditional approach of maintaining static directory XML files with a dynamic system where:

  • Extensions are managed via REST API
  • Changes take effect immediately
  • No manual FreeSWITCH configuration required
  • External SIP clients authenticate against PostgreSQL

Architecture Components

System Components

Core Components

  1. PostgreSQL Extensions Table: Centralized storage for SIP user credentials, display names, and features
  2. DirectoryPort: Port interface defining directory operations (ports & adapters pattern)
  3. XMLCURLDirectoryAdapter: Adapter that generates FreeSWITCH directory XML from PostgreSQL
  4. Directory Router: FastAPI endpoint (/v1/freeswitch/directory) that serves XML to mod_xml_curl
  5. FreeSWITCH mod_xml_curl: Module that queries the API when authentication is needed

Hybrid Authentication Model

The system implements a dual security model optimized for both performance and security:

Cluster-Internal Traffic (Trusted)

Who: Queue pods, IVR pods, internal components

Authentication: None (blind registration)

How it works:

  • Source IP is in cluster_network ACL (e.g., 10.42.0.0/16)
  • FreeSWITCH accepts registration without challenge
  • No XML-CURL call made (high performance)
  • No database query needed

Use case: High-throughput internal call routing where all components are trusted

External Traffic (Authenticated)

Who: Remote SIP clients, softphones, desk phones

Authentication: Username + password against PostgreSQL

How it works:

  • Source IP NOT in ACL (e.g., public internet)
  • FreeSWITCH challenges with 401 Unauthorized
  • Client responds with credentials
  • FreeSWITCH calls XML-CURL endpoint for validation
  • API queries PostgreSQL for user
  • Returns directory XML with password hash
  • FreeSWITCH validates credentials

Use case: Secure external client authentication with centralized user management

Hybrid Auth Decision Flow

Database Schema

The extensions table stores all user authentication data:

CREATE TABLE IF NOT EXISTS extensions (
number VARCHAR(20) PRIMARY KEY,
password VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
email VARCHAR(255),
department VARCHAR(255),
status VARCHAR(20) DEFAULT 'active',
call_forwarding_json JSONB,
voicemail_json JSONB,
features_json JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_extensions_status ON extensions(status);
CREATE INDEX idx_extensions_department ON extensions(department);

Key fields:

  • number: Extension number (e.g., "1001") - Primary key
  • password: Plain-text password (consider hashing in production)
  • status: Must be "active" for authentication to succeed
  • call_forwarding_json: Optional call forwarding configuration
  • voicemail_json: Optional voicemail settings

API Endpoints

Directory Lookup

FreeSWITCH calls this endpoint when external clients attempt to register.

Endpoint: POST /v1/freeswitch/directory

Authentication: X-FreeSWITCH-Token header (shared secret)

Request Format: application/x-www-form-urlencoded

POST /v1/freeswitch/directory HTTP/1.1
Host: api.client-demo-client.svc.cluster.local:8000
Content-Type: application/x-www-form-urlencoded
X-FreeSWITCH-Token: your-secure-token-here

section=directory&tag_name=domain&key_name=name&key_value=phone.coque.uucp.ca&user=1001&domain=phone.coque.uucp.ca&sip_auth_username=1001&sip_auth_realm=phone.coque.uucp.ca

Key Parameters:

  • section: Must be "directory" (FreeSWITCH queries all bindings)
  • user: Extension number to look up
  • domain: SIP domain
  • sip_auth_username: Username from SIP credentials
  • sip_auth_realm: Realm from SIP challenge

Success Response (200 OK):

<?xml version="1.0"?>
<document type="freeswitch/xml">
<section name="directory">
<domain name="phone.coque.uucp.ca">
<user id="1001">
<params>
<param name="password" value="secure-password-here"/>
<param name="dial-string" value="{^^:sip_invite_domain=${dialed_domain}:presence_id=${dialed_user}@${dialed_domain}}${sofia_contact(*/${dialed_user}@${dialed_domain})}"/>
</params>
<variables>
<variable name="user_context" value="default"/>
<variable name="effective_caller_id_name" value="John Doe"/>
<variable name="effective_caller_id_number" value="1001"/>
<variable name="email" value="john@example.com"/>
<variable name="department" value="Sales"/>
<variable name="call_forward_destination" value="1002"/>
<variable name="voicemail_enabled" value="true"/>
</variables>
</user>
</domain>
</section>
</document>

Not Found Response (200 OK with empty result):

<?xml version="1.0"?>
<document type="freeswitch/xml">
<section name="result">
<!-- Extension not found -->
</section>
</document>

Authentication Failed (418 I'm a teapot):

{
"detail": "I'm a teapot"
}
Security Note

The 418 status code is intentionally obscure to prevent enumeration attacks. It indicates an invalid token without revealing system details.

ACL Lookup (Bonus)

The directory router also serves dynamic ACL configuration.

Endpoint: POST /v1/freeswitch/acl

Authentication: X-FreeSWITCH-Token header

Request Format: application/x-www-form-urlencoded

POST /v1/freeswitch/acl HTTP/1.1
Host: api.client-demo-client.svc.cluster.local:8000
Content-Type: application/x-www-form-urlencoded
X-FreeSWITCH-Token: your-secure-token-here

section=configuration&tag_name=configuration&key_name=name&key_value=acl.conf

Response:

<?xml version="1.0"?>
<document type="freeswitch/xml">
<section name="configuration">
<configuration name="acl.conf" description="Network Access Control Lists">
<network-lists>
<list name="trusted_users" default="deny">
<!-- Database-backed ACL entries - updated in real-time -->
<!-- Cluster pod network -->
<node type="allow" cidr="10.42.0.0/16"/>
<!-- Service network -->
<node type="allow" cidr="10.43.0.0/16"/>
<!-- VPN network -->
<node type="allow" cidr="10.100.0.0/16"/>
</list>
</network-lists>
</configuration>
</section>
</document>

Configuration

FreeSWITCH Configuration

ACL Configuration (acl.conf.xml)

Define which networks bypass authentication:

<configuration name="acl.conf" description="Network Access Control Lists">
<network-lists>
<list name="cluster_network" default="deny">
<!-- Kubernetes pod network -->
<node type="allow" cidr="10.42.0.0/16"/>
<!-- Kubernetes service network -->
<node type="allow" cidr="10.43.0.0/16"/>
<!-- RFC 1918 private networks (optional) -->
<node type="allow" cidr="10.0.0.0/8"/>
<node type="allow" cidr="172.16.0.0/12"/>
<node type="allow" cidr="192.168.0.0/16"/>
</list>
</network-lists>
</configuration>

Sofia Profile Configuration (internal.xml)

Configure the registrar Sofia profile:

<profile name="internal">
<settings>
<!-- ACL for blind registration -->
<param name="apply-inbound-acl" value="cluster_network"/>
<param name="apply-register-acl" value="cluster_network"/>
<param name="accept-blind-reg" value="true"/>

<!-- Challenge external IPs -->
<param name="auth-calls" value="true"/>
<param name="challenge-realm" value="auto_from"/>

<!-- Other settings... -->
</settings>
</profile>

XML-CURL Configuration (xml_curl.conf.xml)

Configure mod_xml_curl to query the API:

<configuration name="xml_curl.conf" description="XML-CURL Configuration">
<bindings>
<binding name="directory">
<param name="gateway-url" value="http://api.client-demo-client.svc.cluster.local:8000/v1/freeswitch/directory"/>
<param name="gateway-credentials" value="username:${XMLCURL_TOKEN}"/>
<param name="auth-scheme" value="basic"/>
<param name="timeout" value="5"/>
<param name="enable-cacert-check" value="false"/>
<param name="enable-ssl-verifyhost" value="false"/>
</binding>
</bindings>
</configuration>
Token in Header

The API expects the token in the X-FreeSWITCH-Token header, not HTTP Basic Auth. FreeSWITCH automatically includes all configured credentials as headers.

API Configuration

Environment Variables (.env or Kubernetes Secret):

# PostgreSQL connection for extensions
DB_DSN=postgresql+asyncpg://callcenter_user:password@postgres.client-demo-client.svc.cluster.local:5432/callcenter

# XML-CURL shared secret token (must match FreeSWITCH)
FREESWITCH_XMLCURL_TOKEN=your-secure-token-here

Configuration Class (config.py):

class Settings(BaseSettings):
FREESWITCH_XMLCURL_TOKEN: str = Field(
default="change-me-in-production",
env="FREESWITCH_XMLCURL_TOKEN",
description="Shared secret for FreeSWITCH mod_xml_curl directory lookups",
)

Helm Configuration

values.yaml:

freeswitch:
registrar:
xmlcurl:
enabled: true
apiUrl: "http://api:8000/v1/freeswitch/directory"
token: "" # Override via --set or sealed secret

Deployment:

# Generate secure token
TOKEN=$(openssl rand -hex 32)

# Deploy with token
helm install demo-client ./charts/tenant-infra \
--set freeswitch.registrar.xmlcurl.token="${TOKEN}" \
--set api.xmlcurl.token="${TOKEN}"

Ports and Adapters Pattern

The directory system follows the hexagonal architecture pattern for testability and flexibility.

DirectoryPort Interface

from abc import ABC, abstractmethod
from typing import Optional

class DirectoryPort(ABC):
"""
Port for FreeSWITCH directory/user provisioning.

This port defines the interface for directory operations,
allowing different implementations (PostgreSQL, LDAP, etc.).
"""

@abstractmethod
async def get_user_xml(
self, username: str, domain: str, purpose: str
) -> Optional[str]:
"""
Generate FreeSWITCH directory XML for user lookup.

Args:
username: Extension number (e.g., "1001")
domain: SIP domain
purpose: "network-list" or "auth-user"

Returns:
XML string with user directory entry, or not-found XML
"""
raise NotImplementedError

@abstractmethod
async def reload_profile(self, profile_name: str) -> bool:
"""
Reload Sofia profile via XML-RPC.

Args:
profile_name: Name of profile to reload (e.g., "internal")

Returns:
True if successful, False otherwise
"""
raise NotImplementedError

XMLCURLDirectoryAdapter Implementation

class XMLCURLDirectoryAdapter(DirectoryPort):
"""
Adapter that serves FreeSWITCH directory XML from PostgreSQL.

Security model:
- Cluster IPs (in ACL): blind registration, no auth needed
- External IPs (not in ACL): challenged, authenticated via this adapter
"""

def __init__(self):
self.repo = ExtensionRepository()
self.settings = get_settings()

async def get_user_xml(
self, username: str, domain: str, purpose: str
) -> Optional[str]:
# Fetch extension from PostgreSQL
extension = await self.repo.get_by_number(username)

if not extension or extension.status != "active":
return self._generate_not_found_xml()

return self._generate_user_xml(extension, domain)

async def reload_profile(self, profile_name: str) -> bool:
client = await create_xml_rpc_client(...)
response = await client.execute_command(
"sofia", f"profile {profile_name} rescan"
)
return response.success

Dependency Injection

DI Container (core/di.py):

from functools import lru_cache
from core.ports.directory import DirectoryPort
from adapters.directory_xmlcurl import XMLCURLDirectoryAdapter

@lru_cache(maxsize=1)
def get_directory() -> DirectoryPort:
"""Get directory adapter instance (singleton)."""
return XMLCURLDirectoryAdapter()

Usage in Routers:

from fastapi import APIRouter, Depends
from core.di import get_directory
from core.ports.directory import DirectoryPort

router = APIRouter()

@router.post("/v1/extensions")
async def create_extension(
extension_data: ExtensionCreate,
directory: DirectoryPort = Depends(get_directory)
):
# Create extension in database
extension = await extension_repo.create(extension_data)

# Auto-reload FreeSWITCH profile
await directory.reload_profile("internal")

return extension

Extension Lifecycle Management

Create Extension

POST /v1/extensions
{
"number": "1001",
"password": "secure-password",
"display_name": "John Doe",
"email": "john@example.com",
"department": "Sales"
}

Flow:

  1. API validates request data
  2. Extension stored in PostgreSQL
  3. FreeSWITCH profile reloaded via XML-RPC (sofia profile internal rescan)
  4. Extension immediately available for authentication
  5. Next REGISTER from SIP client will succeed

Update Extension

PUT /v1/extensions/1001
{
"display_name": "John Smith",
"department": "Marketing"
}

Flow:

  1. API validates request data
  2. Extension updated in PostgreSQL
  3. FreeSWITCH profile reloaded (best effort)
  4. Changes take effect immediately
  5. Active registrations may need re-authentication (depends on change)

Delete Extension

DELETE /v1/extensions/1001

Flow:

  1. Extension removed from PostgreSQL
  2. Active SIP registrations cleared via XML-RPC (best effort)
  3. FreeSWITCH profile reloaded
  4. Extension no longer accepts authentication
  5. SIP client will receive 401 on next REGISTER

Security Considerations

Token Security

Best Practices:

  • Generate strong tokens: Use openssl rand -hex 32 (256 bits)
  • Rotate regularly: Implement 90-day rotation policy
  • Store in secrets: Use Kubernetes Secrets, never commit to git
  • Audit access: Log all directory lookup attempts
  • Never hardcode: Don't put tokens in Dockerfiles or Helm charts

Token Generation:

# Generate secure token
openssl rand -hex 32

# Example output
7f3d8e5a9c2b1f4e6d8a7c3e5f2b9d4a1e6c8f3b5d7e2a9c4f1b8d6e3a5c7f2b

Token Rotation:

# Generate new token
NEW_TOKEN=$(openssl rand -hex 32)

# Update FreeSWITCH secret
kubectl create secret generic freeswitch-xmlcurl \
--from-literal=token="${NEW_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f -

# Update API secret
kubectl create secret generic api-xmlcurl \
--from-literal=token="${NEW_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f -

# Restart pods to pick up new token
kubectl rollout restart deployment/freeswitch-registrar
kubectl rollout restart deployment/api

ACL Configuration

Best Practices:

  • Minimum necessary: Only allow specific cluster CIDRs
  • Review regularly: Audit ACL rules monthly
  • Layered security: Use both ACL and authentication
  • Monitor changes: Log ACL modifications
  • Don't allow 0.0.0.0/0: Never allow all IPs

Example Secure ACL:

<list name="cluster_network" default="deny">
<!-- Kubernetes pod network ONLY -->
<node type="allow" cidr="10.42.0.0/16"/>
<!-- Kubernetes service network ONLY -->
<node type="allow" cidr="10.43.0.0/16"/>
<!-- VPN network for admin access -->
<node type="allow" cidr="10.100.0.0/16"/>
</list>

Database Security

Best Practices:

  • Use TLS: Enable sslmode=require for PostgreSQL connections
  • Least privilege: Grant minimal permissions to database user
  • Password hashing: Consider bcrypt/argon2 for password storage (future)
  • Connection pooling: Use asyncpg with connection limits
  • Audit logging: Enable PostgreSQL query logging

Secure Connection String:

DB_DSN=postgresql+asyncpg://callcenter_user:password@postgres:5432/callcenter?sslmode=require

Performance Considerations

Caching Strategy

FreeSWITCH Directory Cache:

  • FreeSWITCH caches directory responses (default: 300 seconds)
  • Reduces API calls for repeated authentication attempts
  • Cache invalidation on profile reload

Database Connection Pooling:

  • asyncpg maintains connection pool (default: 10 connections)
  • Reduces connection overhead
  • Configure via DB_DSN max_size parameter

Blind Registration Performance:

  • Internal traffic bypasses XML-CURL entirely
  • No database queries for cluster pods
  • Sub-millisecond registration time

Scalability

Horizontal Scaling:

  • API pods are stateless (can scale to N replicas)
  • Directory lookups are read-only (no locking)
  • Load balancer distributes XML-CURL requests

Database Optimization:

  • Add indexes on number (primary key) and status
  • Consider read replicas for high-volume deployments
  • Monitor slow query log for optimization opportunities

Connection Limits:

  • Monitor PostgreSQL connection count
  • Tune max_connections based on load
  • Use PgBouncer for connection pooling if needed

Performance Benchmarks

MetricValueNotes
Directory lookup5-15msLocal PostgreSQL
XML-CURL request10-25msIncluding network overhead
Blind registration<1msNo authentication required
Profile reload50-100msVia XML-RPC
Extensions per tenant10,000+Tested with no degradation

Troubleshooting

External Clients Can't Register

Symptoms:

  • SIP client shows "Registration failed"
  • FreeSWITCH logs show "Auth failed"

Diagnosis:

  1. Check FreeSWITCH logs for XML-CURL errors:
    kubectl logs deployment/freeswitch-registrar | grep xml_curl
2. **Verify token matches**:
```bash
# Check FreeSWITCH secret
kubectl get secret freeswitch-xmlcurl -o jsonpath='{.data.token}' | base64 -d

# Check API secret
kubectl get secret api-xmlcurl -o jsonpath='{.data.token}' | base64 -d
  1. Check API logs for directory requests:

    kubectl logs deployment/api | grep "Directory lookup"
  2. Verify extension exists and is active:

    psql -U callcenter_user -d callcenter \
    -c "SELECT number, display_name, status FROM extensions WHERE number='1001';"
  3. Test directory endpoint manually:

    curl -X POST http://api:8000/v1/freeswitch/directory \
    -H "X-FreeSWITCH-Token: your-token-here" \
    -d "section=directory&user=1001&domain=phone.coque.uucp.ca"

Cluster Pods Can't Register

Symptoms:

  • Queue pods show "Registration failed"
  • IVR pods can't register to registrar

Diagnosis:

  1. Check if pod IP is in ACL:

    kubectl get pod queue-sales -o jsonpath='{.status.podIP}'
    # Verify IP is in 10.42.0.0/16 range
  2. Verify ACL configuration:

    kubectl exec deployment/freeswitch-registrar -- fs_cli -x "acl show"
  3. Check FreeSWITCH logs for ACL rejections:

    kubectl logs deployment/freeswitch-registrar | grep "Rejected by acl"
  4. Verify accept-blind-reg=true:

    kubectl exec deployment/freeswitch-registrar -- \
    fs_cli -x "sofia status profile internal" | grep "accept-blind-reg"

Extension Changes Not Taking Effect

Symptoms:

  • Updated extension password doesn't work
  • Display name changes not reflected

Diagnosis:

  1. Verify profile reload triggered:

    kubectl logs deployment/api | grep "Successfully reloaded Sofia profile"
  2. Check XML-RPC connectivity:

    kubectl exec deployment/api -- curl http://freeswitch-registrar:8080/RPC2
  3. Manually reload profile:

    kubectl exec deployment/freeswitch-registrar -- \
    fs_cli -x "sofia profile internal rescan"
  4. Force re-registration:

    # Clear existing registrations
    kubectl exec deployment/freeswitch-registrar -- \
    fs_cli -x "sofia profile internal flush_inbound_reg 1001"

Debug Commands

# Check all FreeSWITCH registrations
kubectl exec deployment/freeswitch-registrar -- \
fs_cli -x "show registrations"

# Check Sofia profile status
kubectl exec deployment/freeswitch-registrar -- \
fs_cli -x "sofia status profile internal"

# Test XML-CURL manually
curl -X POST http://api.client-demo-client.svc.cluster.local:8000/v1/freeswitch/directory \
-H "X-FreeSWITCH-Token: $(kubectl get secret api-xmlcurl -o jsonpath='{.data.token}' | base64 -d)" \
-d "section=directory&user=1001&domain=phone.coque.uucp.ca"

# Check PostgreSQL extensions
kubectl exec deployment/postgres -- \
psql -U callcenter_user -d callcenter \
-c "SELECT number, display_name, status, created_at FROM extensions ORDER BY created_at DESC LIMIT 10;"

# Monitor XML-CURL requests in real-time
kubectl logs -f deployment/api | grep "Directory lookup"
kubectl logs -f deployment/freeswitch-registrar | grep "xml_curl"

ADR: Dynamic Directory vs Static XML Files

Context

FreeSWITCH traditionally uses static XML files for user directory configuration. As the system scales, managing these files becomes cumbersome and doesn't support real-time updates.

Decision

We chose mod_xml_curl with PostgreSQL-backed directory over static XML files.

Rationale

Pros:

  • Real-time updates: Changes take effect immediately via API
  • Centralized management: Single source of truth in PostgreSQL
  • Scalability: No file I/O or XML parsing overhead
  • Multi-tenant support: Easy isolation per tenant
  • Audit trail: Database tracks all changes with timestamps
  • REST API integration: Manage users via standard API endpoints

Cons:

  • Database dependency: Adds database to critical path
  • Network latency: XML-CURL adds HTTP request overhead
  • Complexity: More moving parts than static files

Mitigation:

  • Database connection pooling minimizes latency
  • FreeSWITCH caches directory responses (300s default)
  • Blind registration for internal traffic bypasses XML-CURL entirely
  • Connection health checks ensure database availability

Alternatives Considered

  1. Static XML files: Simple but doesn't scale, no API integration
  2. LDAP: Good for enterprise but adds complexity and dependencies
  3. Redis cache: Fast but loses persistence and audit trail

Consequences

  • Directory lookups require database availability
  • API must be deployed before FreeSWITCH registrar
  • Token rotation requires coordinated restarts
  • Performance testing needed for high-volume deployments

Future Enhancements

Planned

  1. Password Hashing: Implement bcrypt/argon2 for password storage (security improvement)
  2. Directory Caching: Add Redis cache layer for directory lookups (performance optimization)
  3. Dynamic ACL Management: Allow ACL configuration via API (operational improvement)
  4. Multi-Domain Support: Support multiple SIP domains per tenant (feature request)

Under Consideration

  1. LDAP Integration: Add LDAP adapter for enterprise directory integration
  2. Audit Logging: Track directory lookup attempts and authentication failures
  3. Rate Limiting: Prevent brute-force authentication attempts
  4. Geo-Distributed Directory: Replicate directory across regions
  5. WebAuthn Support: Passwordless authentication for SIP clients

Community Contributions Welcome

We welcome contributions! See CONTRIBUTING.md for guidelines.

References