笔者在2022年写过一篇《使用EC20模块配合asterisk及freepbx实现短信转发和网络电话》。当时虽然已经可以用SIP客户端接听插在EC20里的手机卡,但文章最后还留了一个很影响实际使用的问题:手机上的SIP客户端必须保持运行,否则呼入电话无法连接到SIP Client,呼叫端口会提示忙音。彼时笔者找到的简单方案只有Zoiper Push Proxy,但它并不是免费功能。
几年后,随着Agentic LLM的发展(误),笔者终于重新捡起了这个遗留问题。在与Codex交流一晚上后,笔者形成了以下的方案:在公网VPS上自行部署Flexisip,把它作为Issabel/Asterisk前的SIP Push Gateway和RTP中继;然后fork Linphone Android,更换自行创建的Firebase项目并重新编译。
为什么普通SIP客户端在后台收不到电话
传统SIP客户端启动后,会向服务器发送REGISTER,告诉服务器“这个分机目前可以在这个IP、端口或TCP连接上找到”。当有电话打进来时,Asterisk再把INVITE发给对应的Contact。
这套机制在IP电话机上没有问题,因为IP电话机是一直通电并注册到PBX的,但由于Android会限制后台进程,移动网络和家用路由器的NAT映射也会过期,因此连接在App被划掉,网络变化之后就会失效。即使客户端使用TCP/TLS,长期保持一个连接也会增加耗电,而且不能保证应用被系统回收后还能继续收到数据。
SIP Push则多加入了一层能够保存SIP注册信息并调用手机厂商Push服务的代理。以本文的Android方案为例,大致流程如下:
- Linphone从FCM取得当前App实例的registration token;
- Linphone注册SIP账户时,把
pn-provider、pn-param和pn-prid等参数放进Contact; - Flexisip把
REGISTER转发给Asterisk鉴权,同时记住手机原来的Contact和FCM参数; - 手机睡眠后,Asterisk仍把这个分机的
INVITE送回Flexisip; - PBX收到来电,传递给Flexisip,Flexisip发现移动端需要唤醒,通过FCM发送Push;
- Android启动Linphone,客户端恢复SIP连接,Flexisip再把原来的来电送过去。
这里最容易忽略的一点是:FCM凭据属于某一个Android应用,而不是属于某一台SIP服务器。Google Play版Linphone使用的是Linphone官方的package name、签名和Firebase项目;即使它把自己的FCM token写进了SIP Contact,笔者的服务器也没有官方Firebase项目的发送权限。因此,要完全自行托管Push,客户端和服务端必须使用同一个由自己控制的Firebase项目。
Zoiper和Linphone自带的Push方案
Zoiper官方文档把它的Push服务描述为位于SIP客户端和服务商之间的代理,除了SIP信令,还可以选择代理RTP来解决NAT和单向语音问题。配置确实很方便,也提供7天试用,但其产品页显示Android和iOS版目前的标价均为每月2.49欧元(写作本文时的价格,后续可能变化)。它很适合不想维护服务器的用户,但笔者还是更希望把整个通话链路掌握在自己手里。
Linphone本身是开源的,也支持登录任意第三方SIP账户。然而Linphone官方FAQ明确说明,商店版移动客户端的Push只对Linphone账户启用,第三方SIP账户默认不提供Push;组织用户可以联系官方购买相应服务。不过这并不是Linphone在技术上做不到:Linphone Android源码已经集成Firebase,官方也说明如果使用自己的Push服务器,可以替换app/google-services.json。此外,Linphone团队开发的Flexisip Push Gateway正好可以代理后端的第三方SIP服务器。
笔者没有找到一个同时满足“免费、开箱即用、允许接入任意自建SIP服务器、App被回收后仍能可靠收来电”的商店版客户端。于是最后采用了Flexisip加自编译Linphone的方案。
最终使用的网络结构
笔者没有把Issabel的SIP端口直接暴露到互联网,而是在公网VPS和两台Issabel之间建立WireGuard。手机只连接公网VPS上的Flexisip:
┌──────── Google FCM ────────┐
│ │
Android Linphone ── TLS 5061 ──> Flexisip on public VPS ── Push ┘
│ │
│ RTP 10000-10099 │ SIP + RTP over WireGuard
└──────────────────────────>│
▼
Issabel / Asterisk 16
│
▼
chan_quectel / EC20 / SIM
在这个结构里:
- 公网只开放Flexisip的
5061/TCP和一小段RTP UDP端口; - Flexisip通过WireGuard地址把SIP请求转给Issabel的PJSIP端口;
- 分机密码仍由Asterisk验证,Flexisip不保存另一套用户数据库;
- 手机的RTP先到VPS上的MediaRelay,再经WireGuard到Issabel;
- 家里的公网IP不需要开放SIP或RTP端口。
笔者实际使用的是Asterisk 16.28、Flexisip 2.6.1和Linphone Android 6.2.7。VPS宿主机已经安装Docker,Flexisip容器基于debian:trixie-slim构建。
Issabel侧准备PJSIP分机
先在Issabel/FreePBX中新建一个PJSIP分机。建议为移动客户端单独建立分机(Extensions),不要直接复用已经在线的IP电话。除了用户名和密码外,本文用到的关键设置为:
[301](+type=endpoint)
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=no
[301](+type=aor)
support_path=no
default_expiration=604800
maximum_expiration=604800
remove_existing=yes
remove_unavailable=no
qualify_frequency=0
这些内容放在/etc/asterisk/pjsip_custom_post.conf里,不要直接改FreePBX自动生成的主配置。301需要换成自己的分机号。应用配置后,以运行时输出为准:
asterisk -rx 'pjsip show endpoint 301'
asterisk -rx 'pjsip show aor 301'
部分配置的解释:
direct_media=no让媒体继续经过服务器,避免两端在复杂NAT下直接互发RTP;rewrite_contact=no避免Asterisk再次破坏Flexisip已经改写过的Contact;qualify_frequency=0避免手机睡眠后,因为OPTIONS没有回应而被Asterisk提前判为不可用;- 较长的expiration让后端在手机睡眠期间仍保留这条注册;
remove_existing=yes适合一个分机只绑定一台手机的情况,如果需要多设备同时响铃则要重新设计。
Flexisip官方建议后端支持RFC 3327 Path,笔者最开始也设置了support_path=yes。但标准Linphone Android 6.2.7的REGISTER没有声明Supported: path,Flexisip又加入了Path,最终触发Asterisk 16返回420 Bad Extension:
Invalid modifications made to REGISTER request by intervening proxy
将AOR改成support_path=no后,注册才正常变成预期的401 Unauthorized → 带Digest认证的REGISTER → 200 OK。本文的部署中,Asterisk保存的Contact本来就指向WireGuard上的Flexisip,并且保留了CtRt...参数,因此不依赖Path也能正常返回代理。
建立自己的Firebase项目
接着在Firebase Console中创建一个项目,并添加Android App(Add app)。这里的package name必须和稍后编译出来的APK完全一致。笔者使用了me.sparktour.sippush,一方面避免覆盖商店版Linphone,另一方面也能与自己的Firebase配置对应。
需要下载两份用途完全不同的配置:
- Android App的
google-services.json(Firebase Project → Project settings → General → Android App → Download),放入Linphone源码的app/google-services.json(可参考我的commit); - 供Flexisip调用FCM HTTP v1 API的service-account JSON (
Firebase Project → Project settings → Service accounts → Firebase Admin SDK → Generate new private key),只放在服务器上。
google-services.json本来就是会被打包进APK的客户端配置;service-account JSON则包含私钥,不能公开。还需要记录Firebase项目的数字Project Number,后面Flexisip配置中的firebase-service-accounts和客户端REGISTER里的pn-param必须对应。
Fork并修改Linphone Android
笔者从官方仓库的稳定标签6.2.7建立分支,没有重新编译liblinphone,而是继续使用项目下载的预编译Linphone SDK。实际构建时使用Java 21、Android SDK 37和Linphone SDK 5.5.21。
首先修改app/build.gradle.kts中的package name:
val packageName = "me.sparktour.sippush"
val useDifferentPackageNameForDebugBuild = false
然后用Firebase下载的文件替换:
app/google-services.json
仅仅做到这里还不够。Linphone Android的changelog说明,6.0.4之后会限制第三方SIP账户的Push;如果域名不在允许列表内,账户创建页面会把Push关闭。需要修改app/src/main/assets/assistant_third_party_default_values:
<entry name="push_notification_allowed" overwrite="true">1</entry>
并在app/src/main/assets/linphonerc_default的[net]部分加入自己的域名:
push_notification_domains=sippush.sparktour.me
如果一个APK要连接多个Flexisip身份域,可以用逗号隔开:
push_notification_domains=sippush.sparktour.me,sippush-cu.sparktour.me
linphonerc_default只会在首次安装时复制。为了让旧版APK覆盖升级后也能获得新域名,笔者还在LinphoneApplication初始化配置后合并了一次必要域名:
val requiredPushDomains = arrayOf(
"sippush.sparktour.me",
"sippush-cu.sparktour.me"
)
val configuredPushDomains = corePreferences.pushNotificationCompatibleDomains
if (!configuredPushDomains.toSet().containsAll(requiredPushDomains.toSet())) {
corePreferences.pushNotificationCompatibleDomains =
(configuredPushDomains + requiredPushDomains).distinct().toTypedArray()
}
这样覆盖安装新APK时不需要删除原来的账户,同时也不会覆盖用户自行添加的其他兼容域名。
最后执行构建:
export JAVA_HOME=/path/to/jdk-21
export ANDROID_HOME=/path/to/Android/sdk
./gradlew assembleDebug
如果想快速测试一下的话可以先安装debug APK:
adb install -r app/build/outputs/apk/debug/linphone-android-debug-*.apk
用Docker运行Flexisip
笔者把所有服务端文件放在/opt/docker/flexisip,大致结构如下:
/opt/docker/flexisip/
├── compose.yaml
├── Dockerfile
├── belledonne-container.sources
├── flexisip.conf
├── routes.conf
├── domain-registrations.conf
├── firebase/
│ └── service-account.json
└── logs/
Debian 13容器使用Belledonne的trixie仓库,并固定Flexisip版本:
FROM debian:trixie-slim
ARG DEBIAN_FRONTEND=noninteractive
COPY belledonne-container.sources /etc/apt/sources.list.d/belledonne.sources
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg \
&& curl -fsSL https://download.linphone.org/snapshots/debian/keyring.gpg \
| gpg --dearmor -o /usr/share/keyrings/belledonne-archive-keyring.gpg \
&& apt-get update \
&& apt-get install -y --no-install-recommends bc-flexisip=2.6.1-1 \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/opt/belledonne-communications/bin/flexisip"]
CMD ["--server", "proxy"]
belledonne-container.sources内容为:
Types: deb
URIs: https://download.linphone.org/snapshots/debian/
Suites: trixie
Architectures: amd64
Components: stable
Signed-By: /usr/share/keyrings/belledonne-archive-keyring.gpg
Compose使用host network,因为Flexisip需要同时在公网网卡和WireGuard接口上收发SIP、RTP:
services:
flexisip:
build:
context: .
dockerfile: Dockerfile
image: local/flexisip:2.6.1-trixie
container_name: flexisip
network_mode: host
restart: unless-stopped
init: true
security_opt:
- no-new-privileges:true
volumes:
- ./flexisip.conf:/etc/flexisip/flexisip.conf:ro
- ./routes.conf:/etc/flexisip/routes.conf:ro
- ./domain-registrations.conf:/etc/flexisip/domain-registrations.conf:ro
- /path/to/tls:/etc/flexisip/tls:ro
- ./firebase/service-account.json:/etc/flexisip/firebase/service-account.json:ro
- ./logs:/var/opt/belledonne-communications/log/flexisip
command:
- --server
- proxy
Flexisip的主要配置
下面是本文单后端配置的精简版本。<PUBLIC_IP>,<FCM_PROJECT_NUMBER>,<vps wireguard ip>,<vps wireguard CIDR>等具体的ip可以在配置时替换为实际值:
[global]
aliases=localhost sippush.sparktour.me
transports=sips:sippush.sparktour.me:5061;maddr=<PUBLIC_IP> sip:<vps wireguard ip>:5080;transport=udp;network=<vps wireguard CIDR>/24
tls-certificates-check-interval=1
tls-certificates-file=/etc/flexisip/tls/cert.pem
tls-certificates-private-key=/etc/flexisip/tls/key.pem
log-level=message
[module::Authentication]
enabled=false
[module::Registrar]
enabled=true
reg-domains=*
reg-on-response=true
db-implementation=internal
max-expires=604800
[module::Router]
filter=(is_request && (request.uri.params contains 'doroute' || request.uri.params contains 'CtRt')) || is_response
use-global-domain=true
fork-late=true
[module::PushNotification]
enabled=true
apple=false
firebase=true
firebase-service-accounts=<FCM_PROJECT_NUMBER>:/etc/flexisip/firebase/service-account.json
[module::ContactRouteInserter]
enabled=true
insert-domain=true
[module::MediaRelay]
enabled=true
sdp-port-range=10000-10099
max-calls=10
force-public-ip-for-sdp-masquerading=false
[module::Forward]
enabled=true
routes-config-path=/etc/flexisip/routes.conf
部分配置的解释:
Authentication关闭,表示Flexisip不自行验证密码,而是把认证交给后面的Asterisk;reg-on-response=true让Flexisip只在Asterisk对REGISTER返回成功后保存移动端Contact;ContactRouteInserter把Asterisk看到的Contact改写成指向Flexisip,并加入返回路由参数;fork-late=true允许Flexisip在Push唤醒后把仍在等待的INVITE送给新注册的Contact;MediaRelay要求音频走公网VPS;- 本文使用内存Registrar,Flexisip重启后注册和FCM token会丢失,需要打开一次App让它重新REGISTER。如果要做真正的高可用部署,可以考虑接Redis之类的持久存储。
routes.conf明确把这个SIP身份域发往WireGuard内的Issabel,避免域名的公网DNS又解析回Flexisip自己:
<sip:<PBX IP>:5066;transport=udp> is_request && (request.uri.domain == 'sippush.sparktour.me')
笔者使用Flexisip 2.6.1时还遇到一个版本兼容问题:Contact里已经有实例相关的CtRt...参数,但返回请求没有按官方示例稳定恢复成doroute。因此上面的Router filter同时匹配doroute和CtRt,并临时启用了use-global-domain=true。这让当前版本可以正常找到内存Registrar中的Contact,但代价是不同SIP域之间不能使用相同用户名;如果部署多个后端,分机号需要保持全局唯一。
配置完成后启动:
docker compose config --quiet
docker compose up -d --build
docker compose logs -f flexisip
在Linphone中添加账户
编译并安装APK后,选择添加第三方SIP账户:
| 字段 | 内容 |
|---|---|
| SIP identity | sip:301@<SIP Push Domain> |
| Auth username | 301 |
| Password | Issabel中配置的分机密码 |
| Domain | <SIP Push Domain> |
| Transport | TLS |
| Outbound proxy | sips:<SIP Push Domain>:5061 |
| Push notification | Enabled |
正常注册时,Flexisip和Asterisk的日志里应该能看到一次Digest认证过程:
REGISTER
401 Unauthorized
REGISTER with Authorization
200 OK
同时需要检查两边保存的Contact。Flexisip里的原始移动端Contact应该包含:
pn-provider=fcm
pn-param=<FCM_PROJECT_NUMBER>
pn-prid=<DEVICE_FCM_TOKEN>
pn-silent=1
pn-prid相当于设备的Push地址,贴日志时需要脱敏。Asterisk侧保存的Contact则应该指向Flexisip的WireGuard地址,并保留CtRt...。如果Asterisk把这些参数删除了,即使界面显示“注册成功”,后续来电也无法正确返回Push Gateway。
测试结果
笔者按下面的顺序测试,能省下不少同时排查SIP、Push和RTP的时间:
- 先用标准Linphone测试TLS注册,确认
401 → 200; - App保持前台,测试分机互拨、外线呼入呼出、DTMF和挂断;
- 抓取公网和WireGuard两侧RTP,确认双向语音均经过VPS;
- 再安装带自己Firebase配置的APK,检查REGISTER Contact的三个
pn-*参数; - App退到后台并锁屏,测试来电;
- 从最近任务中划掉App,再重复呼入;
- 分别用Wi-Fi、移动数据以及二者切换后的网络测试。
在Pixel上的一次实际呼叫中,Flexisip日志显示INVITE命中FCM Contact,Push模块返回110 Push sent,约428毫秒后收到180 Ringing,随后接听和双向通话均正常。修改前,从最近任务中划掉App后不会响铃;重新编译、覆盖安装并重新注册后,这一场景已经可以正常收到电话。