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
- PostgreSQL Extensions Table: Centralized storage for SIP user credentials, display names, and features
- DirectoryPort: Port interface defining directory operations (ports & adapters pattern)
- XMLCURLDirectoryAdapter: Adapter that generates FreeSWITCH directory XML from PostgreSQL
- Directory Router: FastAPI endpoint (
/v1/freeswitch/directory) that serves XML to mod_xml_curl - 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_networkACL (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 keypassword: Plain-text password (consider hashing in production)status: Must be "active" for authentication to succeedcall_forwarding_json: Optional call forwarding configurationvoicemail_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 updomain: SIP domainsip_auth_username: Username from SIP credentialssip_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"
}
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>
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:
- API validates request data
- Extension stored in PostgreSQL
- FreeSWITCH profile reloaded via XML-RPC (
sofia profile internal rescan) - Extension immediately available for authentication
- Next REGISTER from SIP client will succeed
Update Extension
PUT /v1/extensions/1001
{
"display_name": "John Smith",
"department": "Marketing"
}
Flow:
- API validates request data
- Extension updated in PostgreSQL
- FreeSWITCH profile reloaded (best effort)
- Changes take effect immediately
- Active registrations may need re-authentication (depends on change)
Delete Extension
DELETE /v1/extensions/1001
Flow:
- Extension removed from PostgreSQL
- Active SIP registrations cleared via XML-RPC (best effort)
- FreeSWITCH profile reloaded
- Extension no longer accepts authentication
- 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=requirefor 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_DSNmax_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) andstatus - Consider read replicas for high-volume deployments
- Monitor slow query log for optimization opportunities
Connection Limits:
- Monitor PostgreSQL connection count
- Tune
max_connectionsbased on load - Use PgBouncer for connection pooling if needed
Performance Benchmarks
| Metric | Value | Notes |
|---|---|---|
| Directory lookup | 5-15ms | Local PostgreSQL |
| XML-CURL request | 10-25ms | Including network overhead |
| Blind registration | <1ms | No authentication required |
| Profile reload | 50-100ms | Via XML-RPC |
| Extensions per tenant | 10,000+ | Tested with no degradation |
Troubleshooting
External Clients Can't Register
Symptoms:
- SIP client shows "Registration failed"
- FreeSWITCH logs show "Auth failed"
Diagnosis:
- 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
-
Check API logs for directory requests:
kubectl logs deployment/api | grep "Directory lookup" -
Verify extension exists and is active:
psql -U callcenter_user -d callcenter \
-c "SELECT number, display_name, status FROM extensions WHERE number='1001';" -
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:
-
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 -
Verify ACL configuration:
kubectl exec deployment/freeswitch-registrar -- fs_cli -x "acl show" -
Check FreeSWITCH logs for ACL rejections:
kubectl logs deployment/freeswitch-registrar | grep "Rejected by acl" -
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:
-
Verify profile reload triggered:
kubectl logs deployment/api | grep "Successfully reloaded Sofia profile" -
Check XML-RPC connectivity:
kubectl exec deployment/api -- curl http://freeswitch-registrar:8080/RPC2 -
Manually reload profile:
kubectl exec deployment/freeswitch-registrar -- \
fs_cli -x "sofia profile internal rescan" -
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
- Static XML files: Simple but doesn't scale, no API integration
- LDAP: Good for enterprise but adds complexity and dependencies
- 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
- Password Hashing: Implement bcrypt/argon2 for password storage (security improvement)
- Directory Caching: Add Redis cache layer for directory lookups (performance optimization)
- Dynamic ACL Management: Allow ACL configuration via API (operational improvement)
- Multi-Domain Support: Support multiple SIP domains per tenant (feature request)
Under Consideration
- LDAP Integration: Add LDAP adapter for enterprise directory integration
- Audit Logging: Track directory lookup attempts and authentication failures
- Rate Limiting: Prevent brute-force authentication attempts
- Geo-Distributed Directory: Replicate directory across regions
- WebAuthn Support: Passwordless authentication for SIP clients
Community Contributions Welcome
We welcome contributions! See CONTRIBUTING.md for guidelines.
Related Documentation
- Extension Management API - REST API for managing extensions
- ACL Management - Network access control configuration
- Ports & Adapters Pattern - Architectural pattern
- PostgreSQL Schema - Database schema documentation