SIP 协议、PJSIP 与全栈通话实战
本文分三部分:① SIP 协议 、② PJSIP 协议栈 、③ 全栈搭建 (Ubuntu 虚拟机 + Asterisk + Java 配置服务 + Android / pjsua2 App,跑通注册 → 呼叫 → 接听 → 双向语音 → 挂断)。
协议与栈的概念在前两部讲清;第三部按步骤落地,编号与正文互指。
第一部分 SIP 协议详解
1.1 协议定位
SIP(Session Initiation Protocol,会话发起协议)由 IETF 在 RFC 3261 (2002,取代 RFC 2543)定义,是应用层信令控制协议,用来创建、修改、终止多媒体会话(语音/视频、会议、Presence、IM 等)。
| 特征 | 说明 |
|---|---|
| 只管信令,不管媒体 | 会话协商用 SIP;音视频走 RTP/SRTP,两者独立 |
| 文本协议 | 格式类似 HTTP(请求行/状态行 + 头域 + 消息体),便于抓包对照 |
| 端到端 + 中间节点 | 终端(UA)可直连,也可经 Proxy / Registrar 路由 |
| 传输无关 | 可跑在 UDP、TCP、TLS(SIPS)、WebSocket(RFC 7118)上 |
| 借鉴成熟协议 | 寻址用 URL(sip:user@host),认证用 HTTP Digest,解析用 DNS SRV/NAPTR |
本教程信令走 TCP 或 UDP、端口 9060;TLS(9061)放在进阶。
1.2 网络模型与逻辑组件
┌────────── UA(用户代理)──────────┐
│ UAC(发起方) ⇄ UAS(接收方) │ ← 逻辑角色,每次请求时动态确定
└──────────────────────────────────┘
│ REGISTER │ INVITE/BYE/...
▼ ▼
Registrar(注册服务器) Proxy(代理服务器)──► Redirect Server(重定向)
│ │
▼ ▼
Location Service 路由/改写/转发
- UA:终端。发起请求时是 UAC,应答时是 UAS。软电话、本教程 App 都是 UA。
- Proxy:中间路由,可改写部分头域后转发。本教程终端直接注册到 Asterisk,不经过多级 Proxy。
- Registrar :收 REGISTER,把
AOR(Address of Record)→ Contact URI写入 Location Service。 - Dialog :两个 UA 之间一段时间的点对点关系,由
Call-ID + From-tag + To-tag标识。一次通话 = 一个 Dialog。 - Transaction :一个请求及其所有应答(直到最终应答),由 Via 的
branch标识。UDP 下 T1=500ms 指数重传。 - Session:Dialog 里由 SDP 描述的媒体会话。
1.3 消息结构
请求方法
教程主链路用到的方法加粗。
| 方法 | 作用 | RFC |
|---|---|---|
| REGISTER | 向注册服务器登记 Contact(含 Expires) | 3261 |
| INVITE | 发起会话,消息体带 SDP offer | 3261 |
| ACK | 确认收到 INVITE 的最终应答 | 3261 |
| BYE | 终止已建立的会话 | 3261 |
| CANCEL | 取消尚未完成的 INVITE(已振铃未接) | 3261 |
| OPTIONS | 探测能力/在线 | 3261 |
| INFO | 会话中带应用层信息(常见:视频关键帧请求) | 6086 |
| UPDATE | 会话建立前更新 SDP | 3311 |
| SUBSCRIBE / NOTIFY | 订阅/通知(Presence) | 3265 |
| REFER | 呼转 | 3515 |
| MESSAGE | 即时消息 | 3428 |
响应分类
| 类别 | 含义 | 例子 |
|---|---|---|
| 1xx 临时 | 呼叫进行中 | 100 Trying / 180 Ringing / 183 Session Progress(早期媒体) |
| 2xx 成功 | 最终成功 | 200 OK(注册成功、接听都用它) |
| 3xx 重定向 | 301/302 | |
| 4xx 客户端错误 | 401/407 (Digest 挑战)、404(用户不存在)、480(暂时不可达)、486(忙)、487(已取消) | |
| 5xx 服务器错误 | 500/503 | |
| 6xx 全局失败 | 603 Decline(拒接) |
报文示例(INVITE + SDP offer)
与第三部实验室参数一致:账号 1000→1001,服务器 192.168.2.110:9060,音频 PCMA。
sip
INVITE sip:1001@192.168.2.110:9060;transport=tcp SIP/2.0
Via: SIP/2.0/TCP 192.168.2.118:53237;branch=z9hG4bKxxxx;rport
Max-Forwards: 70
From: "1000" <sip:1000@192.168.2.110>;tag=abc123
To: <sip:1001@192.168.2.110>
Call-ID: a1b2c3d4@192.168.2.118
CSeq: 1 INVITE
Contact: <sip:1000@192.168.2.118:53237;transport=tcp>
User-Agent: MicroSIP/3.22.12
Supported: replaces, 100rel
Content-Type: application/sdp
Content-Length: ...
v=0
o=- 0 0 IN IP4 192.168.2.118
s=pjmedia
c=IN IP4 192.168.2.118
t=0 0
m=audio 40002 RTP/AVP 8 0
a=rtpmap:8 PCMA/8000
a=rtpmap:0 PCMU/8000
a=sendrecv
1.4 关键头域
| 头域 | 作用 |
|---|---|
| Via | 事务路径与重传标识(branch);rport 帮助 NAT 回程 |
| From / To | 逻辑主被叫;tag 参与 Dialog 标识。号码一般从 URI user 段读取 |
| Call-ID | 会话全局唯一 ID |
| CSeq | 方法 + 序号;401 后重发 INVITE 必须 +1 |
| Contact | 本机可达地址,REGISTER 的绑定值 |
| Authorization | Digest 应答(收到 401/407 后回填) |
| WWW-Authenticate | 服务端挑战:realm、nonce、qop |
| User-Agent | 终端标识 |
| Record-Route / Route | 经 Proxy 时后续请求的路由 |
| Alert-Info / Call-Info | 标准语义是振铃提示 / 附加 URI;也可被业务扩展(本教程不用) |
1.5 典型流程
注册(Digest)
UA Registrar
│ REGISTER (无认证) ────────────────────► │
│ ◄────────────────── 401 Unauthorized │ (WWW-Authenticate: nonce, realm)
│ REGISTER (Authorization: Digest ...) ► │
│ ◄────────────────── 200 OK │ (Contact 有效期)
本教程 realm=asterisk,与 Asterisk pjsip.conf 的 default_realm 一致。客户端若只重传第一条 REGISTER/INVITE、不带 Authorization,会一直 401。
呼叫与挂断
主叫 UAC 被叫 UAS
│ INVITE (SDP offer) ──────────────────► │
│ ◄──────────────── 100 Trying │
│ ◄──────────────── 180 Ringing │
│ INVITE (Digest 重发, 如需) ───────────► │
│ ◄──────────────── 200 OK (SDP answer) │
│ ACK ─────────────────────────────────► │ 会话建立(CONFIRMED)
│ ═══ RTP/SRTP 媒体流 ═══ │
│ BYE ─────────────────────────────────► │
│ ◄──────────────── 200 OK │
- 振铃阶段标准做法可回 183 + SDP 传早期媒体;本教程软电话/App 在本地播回铃音,不依赖早期媒体。
- 最终应答前主叫可 CANCEL (对端 487);被叫拒接常见 603。
- Asterisk
Dial(...,30)超时未接会 CANCEL 被叫,并对主叫回 603------不是被叫点了拒绝。
1.6 SDP Offer/Answer(RFC 3264 / RFC 4566)
- INVITE 带 offer (
m=列出 payload type),2xx 带 answer(从 offer 子集中选)。 - 方向:
sendrecv/sendonly/recvonly/inactive。本教程双向语音用sendrecv。 - 静态 PT:PCMA=8、PCMU=0;动态 PT 从 96 起(如 H264)+
a=rtpmap。 - 视频时钟标准为 90000:
a=rtpmap:96 H264/90000。
本教程音频基线:G.711A(PCMA)/ 8 kHz / 单声道 。Asterisk endpoint 必须 allow=alaw。
1.7 媒体面:RTP / SRTP
- RTP(RFC 3550):序列号 + 时间戳 + SSRC;RTCP 报丢包/抖动。
- SRTP(RFC 3711) :
- SDES :密钥在 SDP
a=crypto:,依赖信令 TLS; - DTLS-SRTP :媒体面 DTLS 握手,指纹在
a=fingerprint:。
- SDES :密钥在 SDP
- 本教程主链路为明文 RTP ,经 Asterisk 转发(
direct_media=no)。SRTP 见第三部进阶。
1.8 NAT 穿透
私网地址写进 Contact/SDP 后对端不可达。常用 STUN (RFC 5389)、TURN (RFC 5766)、ICE (RFC 8445)、Via rport。
PJSIP 完整支持 ICE/TURN。本教程是局域网桥接 + 服务器中继 RTP,不启 ICE;endpoint 开 force_rport、rewrite_contact。
1.9 安全机制
| 层 | 机制 | 本教程 |
|---|---|---|
| 信令传输 | UDP / TCP;可选 TLS(SIPS) | 主链路 TCP 或 UDP :9060;TLS :9061 为进阶 |
| 信令认证 | HTTP Digest(realm + nonce) | 账号 1000/1001,realm asterisk |
| 媒体 | RTP;可选 SRTP | 主链路 RTP;SRTP 为进阶 |
| 账号下发 | 应用层 HTTP,数据来自业务库 | GET /api/v1/sip-server?number=,见 3.5 |
1.10 本教程用到的协议子集
| 特性 | 用途 |
|---|---|
| REGISTER + Digest | 终端上线 |
| INVITE / 180 / 200 / ACK | 呼叫建立 |
| BYE / CANCEL | 挂断 / 取消未接 |
| URI `;transport=tcp | udp` |
| SDP PCMA | 音频协商 |
| 401 → 带 Authorization 的新事务 | 注册与呼叫鉴权 |
第二部分 PJSIP 详解
2.1 概况
PJSIP(pjproject) 是开源 C 语言 SIP + 媒体栈(GPLv2 / 商业双许可),体积小、可移植,被 Zoiper、CSipSimple 及大量嵌入式产品采用。
- 纯 C,自带内存池与异步 I/O;
- 单线程化(应用线程 + 内部 select/epoll),适合 JNI / SWIG 封装;
- 覆盖:SIP 事务/对话、注册、订阅、会议桥、视频、ICE/TURN、SRTP/TLS。
2.2 分层架构
┌─────────────────────────────────────────────┐
│ PJSUA-API / pjsua2(账号、呼叫、会议、媒体) │ ← 本教程绑定层
├─────────────────────────────────────────────┤
│ PJSIP │
│ ├ sip_core (解析 / transport) │
│ ├ sip_transaction(事务层 TU) │
│ ├ sip_dialog (对话层) │
│ ├ sip_ua (INVITE 会话 / 注册 usage)│
│ └ module(100rel / PRACK ...) │
├─────────────────────────────────────────────┤
│ PJMEDIA (SDP / RTP / 会议桥 / 声卡 / 编解码)│
│ PJMEDIA-CODEC(G711 / Speex / iLBC / L16 / ...)│
├─────────────────────────────────────────────┤
│ PJNATH(ICE / TURN / STUN) PJLIB-UTIL(DNS)│
├─────────────────────────────────────────────┤
│ PJLIB(pool / 线程 / 锁 / 定时器 / sock) │
└─────────────────────────────────────────────┘
| 库 | 职责 |
|---|---|
| PJLIB | 平台抽象:内存池、线程/锁、事件循环、定时器、socket |
| PJLIB-UTIL | 文本扫描(SIP 解析基础)、MD5/CRC、异步 DNS、STUN 消息 |
| PJNATH | STUN / TURN / ICE |
| PJSIP | 传输 → 解析 → 事务 → 对话 → UA |
| PJMEDIA | 会议桥、声卡 port、RTP/RTCP、SRTP、编解码、视频 |
| PJSUA-API | 把上面组装成「一个进程一部软电话」 |
2.3 PJSUA 生命周期与 pjsua2 对应
C 层典型顺序:
c
pjsua_create();
pjsua_config_default(&cfg);
cfg.cb.on_incoming_call = ...;
cfg.cb.on_call_state = ...;
cfg.cb.on_call_media_state = ...;
cfg.cb.on_reg_state = ...;
pjsua_init(&cfg, &media_cfg, &log_cfg);
pjsua_transport_create(PJSIP_TRANSPORT_TCP, &tcfg, &tid);
pjsua_acc_add(&acc_cfg, PJ_TRUE, &acc_id);
pjsua_call_make_call(acc_id, &dst_uri, &call_opt, NULL, NULL, &call_id);
pjsua_call_answer(call_id, 200, NULL, NULL);
pjsua_call_hangup(call_id, 0, NULL, NULL);
pjsua_destroy();
第三部 Android 代码走官方 pjsua2(SWIG Java),一一对应:
| pjsua C | pjsua2 Java |
|---|---|
pjsua_create / pjsua_init / pjsua_destroy |
Endpoint.libCreate / libInit / libDestroy |
pjsua_transport_create |
Endpoint.transportCreate |
pjsua_acc_add |
Account.create(AccountConfig) |
pjsua_call_make_call |
Call.makeCall |
pjsua_call_answer / hangup |
Call.answer / hangup |
on_reg_state |
Account.onRegState |
on_incoming_call |
Account.onIncomingCall |
on_call_state / on_call_media_state |
Call.onCallState / onCallMediaState |
非 pjsua2 创建的线程调用 API 前必须 Endpoint.libRegisterThread(),否则无响应或崩溃。
2.4 PJMEDIA 媒体管线
mic ──► sound dev port ──┐ ┌──► sound dev port ──► speaker
▼ ▲
┌────────────────────┐ │
│ Conference Bridge │
└────────────────────┘ ▲
▲ │
codec(G711A) ◄──► stream port(RTP/SRTP) ──────┘
- 会议桥:mic、扬声器、每路 RTP、wav 都是桥上的 port,可任意互连(混音、监听)。
- stream port:一路 RTP + 编解码,SDP 协商产物。
- 编解码:G.711 / L16 内置;AMR、OPUS、Speex、iLBC 等以插件注册。
- Android 上 pjsua2 默认走 OpenSL / 系统录音放音。本教程在
onCallMediaState里把通话AudioMedia与采集/播放设备互连。
2.5 视频
管线:采集 → H.264 packetizer → RTP;接收相反。关键帧:RTCP PLI 或 SIP INFO 。渲染由应用挂 Surface(VideoPreview / VideoWindow)。主链路不含视频,见 3.11。
2.6 NAT / ICE 与安全
- ICE / TURN 完整支持;
- SRTP:SDES 与 DTLS 两种 keying;
- TLS:证书路径由 transport /
TlsConfig指定。
本教程默认不启 ICE;媒体经 Asterisk 转发。
2.7 Android 集成路线
| 路线 | 做法 | 本教程 |
|---|---|---|
| 官方 pjsua2 + SWIG | 交叉编译 pjproject,生成 libpjsua2.so + org.pjsip.pjsua2.* |
采用(3.6) |
| 自研 JNI 薄封装 | C 层包一层业务 API,Java 只见少量 native + 回调 | 体积更小,需自维护,本文不展开 |
第三部分 全栈实战:SIP 服务器 + Android 通话 App
在 Ubuntu 虚拟机上搭建 Asterisk(注册 / 鉴权 / 媒体转发)和可选的 Java 配置服务,再新建独立 Android 工程(Java + pjsua2),完成 注册 → 呼叫 → 接听 → 双向语音 → 挂断 。
协议对照第一部分,API 对照第二部分。踩坑实录见 3.12。
3.1 目标与拓扑
Android 终端 A(★ 新写的 SIP App,账号 1000) 对端 B(账号 1001:软电话或第二台装同款 App)
│ ① HTTP GET /api/v1/sip-server?number=(Java 读库下发)│
│ ② REGISTER / INVITE / BYE → :9060 tcp|udp │
└──────────────┐ ▲ │
▼ │ ③ RTP(PCMA 音频) │
┌────────────────────────────────────┐ ◄────────┘
│ 虚拟机 Ubuntu 20.04(桥接 192.168.2.0/23,静态 IP 192.168.2.110)│
│ ├─ MariaDB sip_lab 账号唯一真相(sip_account / sip_cluster)│
│ ├─ Asterisk :9060/udp+tcp SIP 信令(账号由 sip-lab-sync 生成)│
│ ├─ Asterisk :10000-20000/udp RTP 媒体转发 │
│ └─ Java ConfigServer :8080 读库下发 / 开通后同步 Asterisk│
└────────────────────────────────────┘
| 端口 | 协议 | 用途 | 说明 |
|---|---|---|---|
| 9060 | UDP + TCP | SIP 信令 | 本教程默认;与软电话 / App 的 registrar 一致 |
| 9061 | TCP | SIP over TLS(可选) | 进阶(3.11) |
| 10000-20000 | UDP | RTP 媒体 | Asterisk rtp.conf 默认段 |
| 8080 | TCP | Java 配置服务 | GET /api/v1/sip-server?number= 读库下发;POST /api/v1/sip-accounts 开通 |
| 3306 | TCP | MariaDB | 仅本机;Asterisk 与 Java 的共同数据源 |
技术选型
| 组件 | 选型 | 理由 |
|---|---|---|
| SIP + 媒体栈 | pjproject pjsua2(SWIG Java 绑定) | 官方 Android 路线;与 §2.3 生命周期一一对应;自带采集/播放/会议桥(§2.4) |
| App 语言 | Java | pjsua2 官方绑定即 Java,零第三方依赖 |
| 业务库 | MariaDB (库名 sip_lab) |
号码/密码/集群参数的唯一真相;禁止 Java 与 Asterisk 各写一份 |
| 配置服务 | Java HttpServer + JDBC | GET 按号码读库;POST 开通后跑 sip-lab-sync 生成 PJSIP 账号并 reload |
| 账号同步 | sip-lab-sync |
从 sip_account 生成 /etc/asterisk/pjsip_accounts.conf |
| SIP 服务器 | Asterisk 16+(需 res_pjsip) | 注册/鉴权/Dialplan/媒体中继,PCMA 内置、H264 可透传 |
本教程里的 sudo(先读再敲)
sudo = 以 root(系统管理员)身份执行后面那一条命令 。普通用户写不了 /etc、装不了包、管不了 systemd 服务,所以这些操作前面要加 sudo。它要的是当前登录用户自己的密码(不是另设的 root 密码);几分钟内再 sudo 通常不用重输。
已经是 root 时不要再套 sudo。 判据任选:
bash
whoami # 输出 root = 已是管理员
id -u # 输出 0 = root
# 提示符末尾是 #(root@主机名:~#)而不是 $
提示符是 root@...#:把行首的 sudo 整词删掉 再执行(sudo apt install → apt install)。再套一层多数也能跑,但是多余;精简环境没装 sudo 包时还会 sudo: command not found。后文 3.3 部分命令按 root 实验室写成了不带 sudo------普通用户必须自行加回,原因见下表。
sudo 只提升紧跟的那一条 。管道右侧另算:echo '...' | sudo tee 文件 里,tee 才是 root,echo 仍是当前用户(这样写是为了往 /etc 里写文件)。sudo mysql < schema.sql 整条重定向由 shell 先打开文件再交给已提权的 mysql,文件路径必须当前用户读得到(/root/sip-lab 只有 root 读得了,所以普通用户应把文件放在自己的家目录,或 sudo mysql < /root/... 时用 root 打开)。
后文同一条命令只写业务注释,为什么要 sudo 以本表为准。
| 命令 | 为什么必须提权 | 不做 sudo 会怎样 | 已是 root |
|---|---|---|---|
sudo nano /etc/netplan/....yaml |
/etc/netplan 属 root,普通用户不能改网卡配置 |
Permission denied |
nano /etc/netplan/....yaml |
sudo chmod 600 /etc/netplan/*.yaml |
改系统文件权限;新版 netplan 要求配置文件不能对「其他人」可读 | chmod 失败或 apply 报权限过开 | 去掉 sudo |
sudo netplan apply / sudo netplan --debug apply |
改内核路由、地址、启停网卡,只有 root 能调 | Insufficient permissions / 配置不生效 |
去掉 sudo |
sudo netplan try |
同上;SSH 远程改网用,120s 内不确认则回滚,防改挂失连 | 同 apply | 去掉 sudo |
sudo reboot |
重启整机 | must be superuser |
reboot |
sudo nmcli con del "..." |
删除 NetworkManager 系统连接(/etc/NetworkManager) |
删不掉或只影响当前用户会话 | 去掉 sudo |
sudo ufw allow ... / disable / status |
ufw 改内核 netfilter,属系统防火墙 | You need to be root |
去掉 sudo |
sudo apt update |
写 /var/lib/apt 软件源索引 |
Permission denied / 锁文件 /var/lib/dpkg/lock |
去掉 sudo |
sudo apt install -y 包 |
往 /usr 装系统软件(asterisk、mariadb、jdk、swig...) |
同上 | 去掉 sudo |
sudo apt-add-repository universe |
改系统软件源列表 | 改不了 sources | 去掉 sudo |
sudo cp /etc/apt/sources.list ... |
备份系统 apt 配置 | 写不了 /etc/apt |
去掉 sudo |
sudo sed -i '...' /etc/apt/sources.list |
原地改系统源(换国内镜像) | 文件只读 | 去掉 sudo |
sudo cp pjsip.conf pjsip.conf.orig |
/etc/asterisk 属 asterisk/root |
拷不进去 | 去掉 sudo |
sudo tee /etc/asterisk/....conf |
以 root 覆盖 Asterisk 配置(tee 才是被提升的一侧) |
文件写不进 | 去掉 sudo,tee /etc/asterisk/... |
sudo systemctl start/stop/restart/status/enable/disable 服务 |
systemd 系统服务(asterisk、mariadb、自建 sip-config) | Access denied / 服务不动 |
去掉 sudo |
sudo systemctl enable --now mariadb |
立刻启动 并且 写入开机自启 | 库没起来或重启后没了 | 去掉 sudo |
sudo install -m 750 ... /usr/local/sbin/sip-lab-sync |
把脚本装进系统 sbin(全局命令) |
写不了 /usr/local/sbin |
去掉 sudo |
sudo touch /etc/asterisk/pjsip_accounts.conf |
在 Asterisk 配置目录建空文件 | Permission denied |
去掉 sudo |
sudo chown asterisk:asterisk 文件 |
把属主改成 Asterisk 进程用户,reload 才能读 | 属主仍是你,asterisk 可能读不了(640) | 去掉 sudo |
sudo sip-lab-sync |
脚本内部要求 uid=0 :写 pjsip_accounts.conf + asterisk -rx reload |
脚本直接 exit 1:run as root |
直接 sip-lab-sync |
sudo tee /etc/sudoers.d/sip-lab-sync |
写 sudo 规则本身,只能 root 改 | 普通用户改 sudoers 会锁死提权 | 必须是 root ;已是 root 则 tee /etc/sudoers.d/...(不要再套 sudo 也行) |
sudo chmod 440 /etc/sudoers.d/... |
sudoers 文件权限必须是 440/400,否则 sudo 拒读 | visudo 风格检查失败 | 去掉 sudo |
sudo asterisk -rvvv |
连正在跑的 Asterisk CLI(控制 socket 在 /var/run/asterisk,仅 root/asterisk) |
Unable to connect to remote asterisk |
去掉 sudo |
sudo asterisk -rx "命令" |
不进交互、只执行一条 CLI(pjsip show / reload) |
同上,连不上 | 去掉 sudo |
sudo asterisk -cvvvvv |
前台拉起 Asterisk(绑 9060、写 pid),只能一份 | 端口被占或权限不够 | 去掉 sudo |
sudo journalctl -u asterisk ... |
读 systemd 该服务的日志(普通用户默认看不到) | 空或 permission denied |
去掉 sudo |
sudo ss -lntup |
-p 要看进程名/PID,内核只给 root |
能看到端口,PROCESS 列是 - |
只看端口可去掉 sudo;要对上 java/asterisk 就留着 |
sudo mysql / sudo mysql < schema.sql / sudo mysql -e '...' |
Ubuntu/Debian 默认用 unix_socket:系统 root 才能免密进 MariaDB 管理员 | Access denied(普通用户不是 DB 的 root) |
直接 mysql / mysql < ... |
sudo grep ... /etc/asterisk/pjsip_accounts.conf |
生成文件权限 640、属主 asterisk,普通用户读不了 | Permission denied |
去掉 sudo |
sudo update-alternatives --config java(及 javac) |
改系统默认 /usr/bin/java 指向哪套 JDK |
改不了 alternatives | 去掉 sudo |
sudo tcpdump -i any -w ... |
抓网卡原始包需要 CAP_NET_RAW,一般仅 root |
You don't have permission to capture |
去掉 sudo |
sudo -n sip-lab-sync(Java 非 root 开通时) |
-n = 不向终端要密码;必须事先写好 sudoers 那一行 |
Java 里 sync 失败,POST 返回 synced 失败 |
用 root 跑 Java 则进程内直接 exec 脚本,不走 sudo |
sudo sed -i '...' /etc/asterisk/modules.conf |
改 Asterisk 模块表(如 noload => chan_sip.so) |
写不了 /etc/asterisk |
去掉 sudo |
sudo 输错密码会记失败次数;连续失败可能被 pam 暂锁。教程命令都是本机维护,不要对不认识的脚本 sudo。
3.2 第一步:虚拟机与网络
本节
sudo的含义见上一节总表。已是 root(whoami→root)则删掉行首sudo。
-
环境:VirtualBox 7.x 或 VMware Workstation + Ubuntu Server 20.04/22.04 LTS(最小安装,2C/2G/20G 足够)。
-
网络模式选择:
模式 真机可达 模拟器可达 宿主机软电话可达 建议 桥接 Bridged ✅(同一 WiFi) ✅ ✅ 推荐 仅主机 Host-Only ❌ ✅(经宿主机栈) ✅ 仅模拟器/宿主机测试 NAT ❌(需逐端口转发) ❌ ❌ 不推荐 VMware 桥接模式要点(虚拟机设置 → 网络适配器):
- 网络连接选 "桥接模式(B):直接连接物理网络";
- 必须勾选"复制物理网络连接状态§"------该复选框只在选中桥接模式后才可点击(未选桥接时灰显)。作用:主机在有线网卡与无线 WiFi 之间切换(如笔记本拔网线转 WiFi)时,虚拟机自动跟随绑定当前活跃的物理网卡;不勾的话桥接可能绑到已断开的网卡上,虚拟机拿不到 DHCP/不通网;
- "设备状态"区确认 "已连接©" 与 "启动时连接(O)" 均已勾选------"已连接"未勾时虚拟机内根本看不到网卡设备;
- 如需固定桥接到某块物理网卡(多网卡主机):选"自定义(U)"→ VMnet0,再点"高级(V)..."设置,或用"虚拟网络编辑器"(vmnetcfg)指定桥接网卡。
-
静态 IP (下文统一以
192.168.2.110演示,替换为实际值):为什么必须静态 IP :本实验所有配置都把服务器地址写死------新 App 的
registrarUri(3.6.3)、ConfigServer 下发的serverip(3.5)、软电话账号、抓包目标,全指向192.168.2.110。DHCP 下虚拟机重启或换网络后 IP 一变(桥接模式勾了"复制物理网络连接状态"后,会跟随宿主机在有线/WiFi 间切换网段,变化更频繁),注册、呼叫、拉配置全部失联,且要逐处改配置才能恢复。生产环境 SIP 服务器同样是固定地址/域名------静态 IP 也是贴近真实的做法。01-lab.yaml默认不存在,需手工创建 (这是本教程自起的文件名)。Ubuntu Server 装好后/etc/netplan/里默认只有安装器生成的00-installer-config.yaml(内容通常就是dhcp4: true;桌面版是01-network-manager-all.yaml)。netplan 按文件名字典序 合并/etc/netplan/*.yaml,同一网卡的同名键后加载的覆盖先加载的,因此两种做法二选一:- 做法 A(推荐,最干净) :直接编辑默认文件
sudo nano /etc/netplan/00-installer-config.yaml(sudo:/etc只许 root 写;已是 root →nano ...),内容整体替换为下文; - 做法 B :保留默认文件,另建
/etc/netplan/01-lab.yaml(序号靠后,dhcp4: false、addresses会覆盖默认文件的同键)。
- 做法 A(推荐,最干净) :直接编辑默认文件
yaml
# /etc/netplan/01-lab.yaml(本实战桥接示例:宿主机网段 192.168.2.0/23、网关 192.168.2.1;Host-Only 则用 192.168.56.x/24 且可删 routes)
# 网卡名先 ip -br addr 查(忽略 lo/virbr*):VMware 多为 ens33,VirtualBox 多为 enp0s3
network:
version: 2
ethernets:
ens33:
dhcp4: false
addresses: [192.168.2.110/23]
routes: # 默认网关:桥接模式必须(否则上不了网、apt 装不了软件);Host-Only 可删
- to: default
via: 192.168.2.1
nameservers:
addresses: [223.5.5.5, 8.8.8.8]
bash
ip -br addr # 先查网卡名(忽略 lo/virbr*):VMware 多为 ens33,VirtualBox 多为 enp0s3
sudo chmod 600 /etc/netplan/*.yaml # sudo:改 /etc 下文件权限;新版 netplan 要求不能对其他人可读
sudo netplan apply # sudo:改内核地址/路由。写错网卡名会 not found;SSH 远程建议 netplan try
静态 IP 是否生效的四级验证 (ip addr 输出中 DHCP 地址必带 dynamic 标记,静态地址没有------这是最快判据):
bash
ip -4 addr show ens33 # ① 只剩设的静态地址且无 dynamic 字样(仍见 dynamic/双地址 = 没生效或双文件冲突)
ip route # ② 有 default via <网关> dev ens33 与本网段路由两行
ping -c3 192.168.2.1 # ③ 网关通 → 链路对(换成实际网关;Host-Only 下 .1 是宿主机 VMnet1,同样可 ping)
ping -c3 223.5.5.5 # 外网通 → 网关对;ping baidu.com 通 → DNS 对
sudo reboot # sudo:重启整机。④ 重启后重跑 ①②③,IP 不变 = 持久化成功
仍见
dynamic或新旧两个地址并存------netplan apply无报错只代表语法通过、不等于配置被读取 ,先ls -l /etc/netplan/确认静态配置文件还在且后缀是.yaml,再检查是否有默认00-*.yaml(dhcp4: true)与新文件并存打架(删掉或改掉旧文件只留一份);sudo netplan --debug apply可看渲染详情与报错;文件名必须.yaml结尾(.yml与.yaml.txt都会被 netplan 静默忽略 ,配置等于没写)、缩进只能空格不能 Tab(vi 粘贴前先:set paste,否则自动缩进会把缩进逐行加倍,via:深到 16 格就是它)------报错形如00-installer-config.yaml:10:9 Invalid YAML: inconsistent indentation时按文件:行:列定位(routes:/nameservers:必须与dhcp4:/addresses:同级对齐),且 netplan 解析任何一份 文件失败整个 apply 就中止,别只盯最后编辑的那份;一个文件里只能有一个顶层network:键 (把桌面版默认文件的network: renderer: NetworkManager段粘进同一文件会把前面的静态配置整个覆盖掉)。
桌面版(Desktop)注意 :网卡归 NetworkManager 管,netplan/systemd-networkd 路线坑多,直接用 nmcli 配静态最省事。若坚持走 netplan 文件:renderer: NetworkManager 必须写进唯一的 network: 块内 (与 ethernets 同级),netplan 才会把静态配置生成为 NM 连接档案;不写 renderer 配置会落给 systemd-networkd,而 NM 仍用自己的 DHCP 连接抢网卡------dynamic 消不掉的第二原因。应用后若重启 IP 又漂回 DHCP,nmcli con show 会看到旧 DHCP 连接(如 "Wired connection 1")与新连接并存,sudo nmcli con del "Wired connection 1" 删旧的即可:
bash
nmcli con show # 找连接名,如 "Wired connection 1"
GW=$(ip route | awk '/default/ {print $3; exit}') # 自动取当前网关(靠现存 DHCP 默认路由,桥接模式适用)
# ↓ 地址以桥接 192.168.2.0/23 网段为例;掩码照抄 ip -br addr 实际显示(见"地址选择"①)
nmcli con mod "Wired connection 1" ipv4.method manual \
ipv4.addresses 192.168.2.110/23 ipv4.gateway "$GW" \
ipv4.dns "223.5.5.5 8.8.8.8"
nmcli con up "Wired connection 1" && ip -4 addr show ens33 # 生效后无 dynamic 即成功
桌面版坚持走 netplan 文件路线的完整做法 :只留默认文件 01-network-manager-all.yaml 一份,内容整体替换(renderer 写进 network: 块内 + 静态参数;地址仍以桥接 192.168.2.0/23 为例):
yaml
# /etc/netplan/01-network-manager-all.yaml(桌面版默认文件,内容整体替换;缩进只用空格)
network:
version: 2
renderer: NetworkManager # ← 桌面版关键行,必须在 network: 块内
ethernets:
ens33:
dhcp4: false
addresses: [192.168.2.110/23]
routes:
- to: default
via: 192.168.2.1
nameservers:
addresses: [223.5.5.5, 8.8.8.8]
bash
sudo chmod 600 /etc/netplan/*.yaml && sudo netplan apply
# sudo:改系统网卡配置权限并生效。已是 root → chmod ... && netplan apply
nmcli con show # 应出现 netplan 生成的 "netplan-ens33";旧 DHCP 连接仍在则 sudo nmcli con del 掉(sudo:删系统连接)
ip -4 addr show ens33 # 目标地址、无 dynamic 即成;sudo reboot 复核(sudo:重启整机)
桥接模式的地址选择 :① 静态地址必须与宿主机当前局域网同网段且掩码一致 (改前先
ip -br addr看当前 DHCP 地址的掩码照抄------如显示/23就写/23,不能想当然写/24;宿主机ipconfig交叉核对);② 避开路由器 DHCP 分配池和宿主机自己的 IP ------先ipconfig看清宿主机地址把它排除(宿主机 ping 自己的 IP 永远"通" ,"ping 候选没人回 = 空闲"这条判据对宿主机本机 IP 完全失效;选成重复 IP 的症状极具迷惑性:ARP 表在两机间来回翻,宿主机↔虚拟机互 ping 看似正常,虚拟机 ping 网关/外网却全不通------回包全被宿主机截走。验空闲的正确姿势:从宿主机 ping 候选地址,请求超时才算空闲,候选 ≠ 宿主机本机 IP);③ 必须配默认网关 (ip route查default via后的地址填进routes),否则虚拟机上不了外网、后续apt install asterisk失败。网关属于网段不属于设备------桥接下虚拟机与宿主机同网段,网关就是同一台局域网路由器 :虚拟机ip route的default via与宿主机ipconfig的"默认网关"应完全一致,两处互相核对(宿主机多网卡时看 IPv4 与虚拟机同网段的那块);④ 宿主机换网(公司↔家)后虚拟机要同步改 addresses 与网关。Host-Only 则固定用虚拟网段(VMware VMnet1 / VirtualBox 192.168.56.x),无需网关,不受宿主机换网影响------这也是它适合当"稳定实验台"的原因。
- 防火墙 (实验环境最省事
sudo ufw disable;规范做法按端口放行):
bash
# ufw 一条命令只认一个"端口/协议",两个并排写会把第二个当地址子句 → ERROR: Need 'to' or 'from' clause
# 下面每条 sudo:改内核防火墙,必须 root。已是 root → 去掉 sudo。status 为 inactive 时本就不拦端口
sudo ufw allow 9060/udp # SIP(UDP)
sudo ufw allow 9060/tcp # SIP(TCP;或简写 ufw allow 9060 = TCP+UDP 一起放行)
sudo ufw allow 10000:20000/udp # RTP
sudo ufw allow 8080/tcp # Java ConfigServer(可选)
sudo ufw status # Status: inactive = 防火墙没启用、本就不拦(规则 enable 后才生效)
-
连通性自检 (宿主机执行):
ping 192.168.2.110、nc -zv 192.168.2.110 9060;模拟器内用adb shell同样测试。 -
宿主机 ↔ 虚拟机传文件 (仓库
sip-lab/、NDK zip、libpjsua2.so都要从一边拷到另一边)。下面两套任选一套能通即可 ;大文件(NDK /.so)优先共享文件夹,小文本也可用scp。
方式 A:VMware 共享文件夹(推荐,编完 so 往 Windows 拷)
虚拟机须是 VMware Workstation,且已装客户机工具(Desktop 常见已带;没有就装):
bash
apt update
apt install -y open-vm-tools open-vm-tools-desktop
# 装完建议 reboot 一次
宿主机 VMware 窗口:
- 虚拟机 → 设置 → 选项 → 共享文件夹
- 选 总是启用
- 添加 :宿主机目录例如
D:\Android\progect\iConvNet_Android(或单独建D:\vm-share),名称填share(不要空格) - 勾选 启用此共享 、建议再勾 在客户机中映射为网络驱动器(若有此项)
- 确定。虚拟机若已开机,有时要 挂起再恢复 或重启后
/mnt/hgfs才出现
虚拟机里挂载并验收:
bash
mkdir -p /mnt/hgfs
# 多数 Desktop 开机已自动挂;没有就手动:
vmhgfs-fuse .host:/ /mnt/hgfs -o allow_other
ls /mnt/hgfs/share
# 应能看到宿主机那个目录里的文件
开机自动挂(没有自动出现时):
bash
grep -q vmhgfs-fuse /etc/fstab || \
echo '.host:/ /mnt/hgfs fuse.vmhgfs-fuse defaults,allow_other 0 0' >> /etc/fstab
两边怎么用:
| 方向 | 做法 |
|---|---|
| 宿主机 → 虚拟机 | Windows 把文件放进共享目录;虚拟机 cp /mnt/hgfs/share/某文件 /root/sip-lab/ |
| 虚拟机 → 宿主机 | arm64-v8a 是目录,必须 cp -r:cp -r .../jniLibs/arm64-v8a /mnt/hgfs/share/(共享名按 VMware 里实际名称,如 winShare)。只拷 so:cp .../jniLibs/arm64-v8a/*.so /mnt/hgfs/share/。Windows 立刻能看到 |
ls /mnt/hgfs 为空:共享文件夹没启用、名称不是 share、或没装 open-vm-tools。vmhgfs-fuse: command not found 再装一次 open-vm-tools。
VirtualBox 等价:设备 → 安装增强功能;设置 → 共享文件夹 → 添加,勾选自动挂载;客户机常见挂载点 /media/sf_share(用户须加入 vboxsf 组:usermod -aG vboxsf $USER 后重新登录)。
方式 B:桥接网上的 scp / WinSCP(不配共享文件夹时)
虚拟机先开 SSH(已是 root 去掉 sudo):
bash
apt install -y openssh-server
systemctl enable --now ssh
ss -lntup | grep ':22'
# 防火墙若启用:ufw allow 22/tcp
宿主机 PowerShell (IP 换成 ip -4 addr 的实际值;用户按你登录的是 root 还是 ubuntu20):
powershell
# 仓库 → 虚拟机
scp sip-lab/schema.sql sip-lab/sip-lab-sync.sh sip-lab/ConfigServer.java root@192.168.2.110:/root/sip-lab/
# NDK zip → 虚拟机
scp android-ndk-r26d-linux.zip ubuntu20@192.168.2.110:/tmp/
# 虚拟机 so → 宿主机当前目录
scp ubuntu20@192.168.2.110:/home/ubuntu20/Desktop/pjproject-2.15.1/pjsip-apps/src/swig/java/android/pjsua2/src/main/jniLibs/arm64-v8a/libpjsua2.so .
图形界面:宿主机装 WinSCP ,协议 SFTP,主机 192.168.2.110,端口 22,拖文件即可。
Permission denied:用户/密码不对,或该用户家目录权限太开。Connection refused:虚拟机没开 ssh 或 22 被拦。第一次会问 fingerprint,输入 yes。
3.3 第二步:安装并配置 Asterisk(SIP 服务器)
sudo :每条为何提权见 3.1「本教程里的 sudo」 。已是
root@...#则删掉行首sudo。
商业形态(账号唯一真相)
号码和密码只存在业务库 sip_lab.sip_account。Java 下发只读库;Asterisk 禁止手写 [1000] 这类账号段,由 sip-lab-sync 生成 #include 的 pjsip_accounts.conf。
开通/改密 ──INSERT/UPDATE sip_account──► MariaDB sip_lab
├── GET /api/v1/sip-server?number= → App / curl(3.5)
└── sip-lab-sync → /etc/asterisk/pjsip_accounts.conf → pjsip reload
从装 Asterisk 到 Java 验证的完整主线(实验室已按此跑通):
| 步 | 做什么 | 过关 |
|---|---|---|
| ① | apt install asterisk |
asterisk -V 有版本 |
| ② | pjsip.conf 只留传输 + 三个模板 + #include |
文件里没有 手写 [1000] |
| ③ | extensions.conf 用 _XXXX;确认 rtp.conf |
--- |
| ④ | 装 MariaDB,导入 schema.sql |
SELECT 见 1000/sip1000、1001/sip1001 |
| ⑤ | 安装并执行 sip-lab-sync |
pjsip_accounts.conf 出现 [1000]/[1001] 各三段 |
| ⑥ | systemctl restart asterisk,CLI 看 endpoints |
1000/1001 在,State=Unavailable 正常 |
| ⑦ | 3.4 软电话(密码以库表为准) | 互拨通 |
| ⑧ | 3.5 先 Java 17 起 ConfigServer,curl 验证 | 无 number→400;?number=1000→200;POST 1002 后 endpoint 出现 |
| ⑨ | (可选)同一台再切 Java 8 用编译启动,curl 再验一遍 | 行为与 ⑧ 相同 |
旧教程手写账号 / 写死 JSON 的迁法见 3.5 末「从旧实验室迁」。
选型对比:Asterisk(推荐------注册/鉴权/媒体中继/Dialplan 开箱即用,PCMA 内置、H264 透传);Kamailio(纯代理,高并发但无内置媒体、配置复杂);FreeSWITCH(功能全但更重)。App 是标准 PJSIP 客户端(第二部分),三者皆可互通,本文选 Asterisk。
bash
sudo apt update && sudo apt install -y asterisk
# sudo:写系统软件库、往 /usr 装 asterisk。已是 root → apt update && apt install -y asterisk
asterisk -V # Ubuntu 20.04 = 16.x,22.04 = 18.x,24.04 = 20.x,均满足(需 chan_pjsip,即 res_pjsip)
国内网络注意 :apt 报
Connection failed [IP: 91.189.91.x 80]时先定性再动手 ------ping -c3 <网关>和ping -c3 223.5.5.5:ping 不通 = 本机网络问题 (IP 冲突/网关配错,见 3.2 地址选择②;域名能解析只说明 DNS 配了,不等于出网链路通);ping 通而 apt 仍失败 = 默认源us.archive.ubuntu.com(美国归档站)不可达(国内常见),换国内镜像(阿里云/清华)即可:
bash
sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak
# sudo:/etc/apt 只许 root 写;先留底再改
sudo sed -i 's|http://us.archive.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g; s|http://security.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g' /etc/apt/sources.list
# sudo:原地改系统源。已是 root → 去掉三行的 sudo
sudo apt update && sudo apt install -y asterisk
/etc/asterisk/pjsip.conf(核心配置,覆盖原文件或追加)
pjsip.conf在apt install asterisk后就已存在------它是 deb 包自带的样例配置 (dpkg 解包时从包内直接落到/etc/asterisk/,登记为 conffile,dpkg -S /etc/asterisk/pjsip.conf可查属主),不是 Asterisk 运行时生成的。默认内容是上游 sample,段落基本全被;注释,直接覆盖即可;动手前sudo cp pjsip.conf pjsip.conf.orig留底。整文件覆盖两种姿势:vi 里ggdG(或:%d)清空后:set paste再粘贴;或干脆不走 vi------sudo tee /etc/asterisk/pjsip.conf > /dev/null <<'EOF'换行粘贴全部内容、以EOF行收尾(>天然清空整文件,无自动缩进风险,长配置首选;期间终端逐行显示>续行符,属正常、不写入文件;结束时敲顶格无空格 的EOF行回车即整段落盘------EOF 带缩进不被识别会一直停在>;粘乱了 Ctrl+C 干净放弃:heredoc 未收完时 tee 尚未启动,文件原封未动)。ls /etc/asterisk可见的一百多个.conf同理,全是包铺的样例(每个模块一个,dpkg -L asterisk | grep -c /etc/asterisk可核对)------本教程只碰 pjsip.conf / extensions.conf / rtp.conf 三个;sip.conf是老 chan_sip 栈的配置、不用碰。Asterisk 找配置的路径链:/etc/asterisk/asterisk.conf的astetcdir => /etc/asterisk→res_pjsip.so启动时从该目录读pjsip.conf,文件缺失也不会自动重建。
ini
[global]
type=global
default_realm=asterisk ; 保持默认;App 侧下发的 realm 必须与之一致(见下)
;=============== 传输层:端口对齐 App 约定的 9060 ===============
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:9060
[transport-tcp]
type=transport
protocol=tcp
bind=0.0.0.0:9060
;=============== 终端模板 ===============
[endpoint-tpl](!)
type=endpoint
context=from-internal
disallow=all
allow=alaw ; ★ 音频基线 G.711A(PT=8)/8k 单声道(§1.6),必须
allow=ulaw
allow=h264 ; ★ H264 透传(Asterisk 不转码视频,只转发 RTP)
direct_media=no ; ★ 强制 RTP 经服务器转发,规避端到端直连不可达(App 未启用 ICE,§1.8)
force_rport=yes ; NAT 补救(§1.8)
rewrite_contact=yes ; 注册时改写 Contact 为实际来源地址
dtmf_mode=rfc4733
[auth-tpl](!)
type=auth
auth_type=userpass
[aor-tpl](!)
type=aor
max_contacts=2 ; 允许同号重复/多传输注册,新的顶替旧的
remove_existing=yes
; 账号段禁止手写:由 sip-lab-sync 从业务库生成(见下节)
#include "pjsip_accounts.conf"
模板段(
endpoint-tpl/auth-tpl/aor-tpl)留在本文件;[1000]这类账号段只出现在生成文件里。Asterisk 按 AOR 用户名 寻址,realm 不参与寻址,但客户端把 realm 同时用作 Digest 域和 AOR domain(sip:1000@asterisk)。default_realm=asterisk必须与库表sip_account.realm一致。
/etc/asterisk/extensions.conf(拨号计划)
四位分机通配,开通新号不必再改 Dialplan:
ini
[from-internal]
exten => _XXXX,1,Dial(PJSIP/${EXTEN},30)
same => n,Hangup()
/etc/asterisk/rtp.conf(默认即 10000-20000,确认未被改小)
ini
[general]
rtpstart=10000
rtpend=20000
业务库 + 首次同步到 Asterisk(账号唯一真相)
商业项目里号码和密码只存在业务库 。Java 下发读库;Asterisk 不手改账号,由同步脚本生成 #include 的那份文件。
bash
sudo apt install -y mariadb-server libmariadb-java
# sudo:装系统包(MariaDB 服务 + JDBC jar)
sudo systemctl enable --now mariadb
# sudo:立刻启动数据库,并写入开机自启。已是 root → 两行都去掉 sudo
已用 root 登录时(提示符 root@...#)下面命令可去掉 sudo;echo $HOME 应是 /root。详见 3.1 sudo 表。
先把仓库三份文件落到虚拟机(必做)
仓库在宿主机 ,虚拟机不会自动出现这些文件。目录里若只有旧的 ConfigServer.java,下一步 mysql < .../schema.sql 会报 No such file or directory。
| 容易混的路径 | 实际是 |
|---|---|
root 的 ~/sip-lab |
/root/sip-lab (~ = $HOME = /root) |
提示符停在桌面 root@...:/home/某人/Desktop# |
当前目录不是家目录 ;桌面上没有 schema.sql ≠ /root/sip-lab 里有 |
普通用户 ubuntu 的 ~/sip-lab |
/home/ubuntu/sip-lab |
本文统一放 /root/sip-lab 。落盘后必须先 ls,再导入:
bash
mkdir -p /root/sip-lab
echo "HOME=$HOME pwd=$(pwd)"
ls -l /root/sip-lab
# 应有 schema.sql、sip-lab-sync.sh;3.5 再补 ConfigServer.java
# 只有 ConfigServer.java → 还没拷/没 tee,不要急着 mysql <
方式 A:宿主机 scp(仓库根目录执行,IP 换成虚拟机):
bash
scp sip-lab/schema.sql sip-lab/sip-lab-sync.sh sip-lab/ConfigServer.java root@192.168.2.110:/root/sip-lab/
方式 B:虚拟机里 tee 粘贴 (VMware 控制台粘不进剪贴板时,改用宿主机 ssh 再粘;> 续行符不落盘;顶格 TEE 回车收尾,带缩进会一直停在 >)。无 -a,会覆盖整文件。
bash
tee /root/sip-lab/schema.sql > /dev/null <<'TEE'
-- 业务库:SIP 账号与集群参数的唯一真相。Asterisk / Java 都只消费这里。
CREATE DATABASE IF NOT EXISTS sip_lab CHARACTER SET utf8mb4;
CREATE USER IF NOT EXISTS 'sip'@'localhost' IDENTIFIED BY 'Siplab_pass1';
GRANT ALL PRIVILEGES ON sip_lab.* TO 'sip'@'localhost';
FLUSH PRIVILEGES;
USE sip_lab;
CREATE TABLE IF NOT EXISTS sip_account (
number VARCHAR(32) NOT NULL PRIMARY KEY,
password VARCHAR(64) NOT NULL,
realm VARCHAR(64) NOT NULL DEFAULT 'asterisk',
enabled TINYINT(1) NOT NULL DEFAULT 1,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sip_cluster (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
serverip VARCHAR(64) NOT NULL,
port VARCHAR(8) NOT NULL DEFAULT '9060',
tls VARCHAR(8) NOT NULL DEFAULT '',
access VARCHAR(8) NOT NULL DEFAULT 'tcp'
);
INSERT INTO sip_account (number, password, realm, enabled) VALUES
('1000', 'sip1000', 'asterisk', 1),
('1001', 'sip1001', 'asterisk', 1)
ON DUPLICATE KEY UPDATE password=VALUES(password), realm=VALUES(realm), enabled=VALUES(enabled);
INSERT INTO sip_cluster (id, serverip, port, tls, access) VALUES
(1, '192.168.2.110', '9060', '', 'tcp')
ON DUPLICATE KEY UPDATE serverip=VALUES(serverip);
TEE
bash
tee /root/sip-lab/sip-lab-sync.sh > /dev/null <<'TEE'
#!/bin/bash
# 从业务库生成 /etc/asterisk/pjsip_accounts.conf 并 pjsip reload。
# 必须 root。账号只改库、不手改这份生成文件。
set -euo pipefail
DB_USER="${SIP_DB_USER:-sip}"
DB_PASS="${SIP_DB_PASS:-Siplab_pass1}"
DB_NAME="${SIP_DB_NAME:-sip_lab}"
OUT="${SIP_PJSIP_ACCOUNTS:-/etc/asterisk/pjsip_accounts.conf}"
if [[ "$(id -u)" -ne 0 ]]; then
echo "sip-lab-sync: run as root (or sudo)" >&2
exit 1
fi
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
{
echo "; generated by sip-lab-sync --- do not edit"
echo "; source: ${DB_NAME}.sip_account"
echo
} > "$TMP"
mysql -N -u "$DB_USER" "-p${DB_PASS}" "$DB_NAME" \
-e "SELECT number, password FROM sip_account WHERE enabled=1 ORDER BY number" \
| while IFS=$'\t' read -r num pass; do
[[ -z "${num:-}" ]] && continue
cat >> "$TMP" <<EOF
[${num}](auth-tpl)
username=${num}
password=${pass}
[${num}](aor-tpl)
[${num}](endpoint-tpl)
auth=${num}
aors=${num}
EOF
done
install -m 640 -o asterisk -g asterisk "$TMP" "$OUT"
if asterisk -rx "core show version" >/dev/null 2>&1; then
asterisk -rx "pjsip reload"
echo "sip-lab-sync: wrote $OUT and pjsip reload"
else
echo "sip-lab-sync: wrote $OUT (asterisk not running, skip reload)"
fi
TEE
chmod 750 /root/sip-lab/sip-lab-sync.sh
schema.sql 里的 192.168.2.110 按 ip -4 addr 改掉(也可导入后再 UPDATE sip_cluster):
bash
ip -4 addr
# 若不是 .110:
sed -i "s/192.168.2.110/你的实际IP/" /root/sip-lab/schema.sql
导入并核对
bash
ls -l /root/sip-lab/schema.sql # 没有这个文件就不要 mysql <
mysql < /root/sip-lab/schema.sql # 非 root 加 sudo
mysql -e "SELECT number, password, realm, enabled FROM sip_lab.sip_account;"
mysql -e "SELECT * FROM sip_lab.sip_cluster;"
应看到 1000/sip1000、1001/sip1001,以及 sip_cluster.serverip。
安装同步脚本并生成 Asterisk 账号
bash
install -m 750 /root/sip-lab/sip-lab-sync.sh /usr/local/sbin/sip-lab-sync
touch /etc/asterisk/pjsip_accounts.conf
chown asterisk:asterisk /etc/asterisk/pjsip_accounts.conf
sip-lab-sync
# 应打印 wrote ... and pjsip reload(若 Asterisk 已在跑)
grep '^\[' /etc/asterisk/pjsip_accounts.conf # [1000] [1001] 各三段
非 root 跑 Java 开通时,给当前用户免密执行这一条(把 ubuntu 换成 whoami):
bash
echo 'ubuntu ALL=(root) NOPASSWD: /usr/local/sbin/sip-lab-sync' | sudo tee /etc/sudoers.d/sip-lab-sync
# sudo 只加在 tee 上:把规则写入 /etc/sudoers.d(只有 root 能改 sudo 自己的规则)
# 效果:用户 ubuntu 执行 sudo sip-lab-sync 时不再问密码(Java 开通用 sudo -n)
sudo chmod 440 /etc/sudoers.d/sip-lab-sync
# sudo:sudoers 必须 440,否则 sudo 拒读。把 ubuntu 换成 whoami
改密、加号:只 UPDATE/INSERT sip_account,再 sudo sip-lab-sync(sudo:脚本要求 uid=0;已是 root → sip-lab-sync)。不要 编辑 pjsip_accounts.conf(下次同步会覆盖)。
生效与自检
bash
sudo systemctl restart asterisk # sudo:重启系统服务。已是 root → systemctl restart asterisk
sudo asterisk -rvvv # sudo:进 CLI。已是 root → asterisk -rvvv
pjsip show transports # 应看到 udp/tcp 各绑 0.0.0.0:9060
pjsip show endpoints # 1000/1001 各带 InAuth/Aor;此刻 State=Unavailable 属正常(无终端注册;注册成功后变 Available 并出现 Contact 行)
pjsip set logger on # 控制台直接打印 SIP 报文(联调期常开)
quit # 退出 CLI 回 shell------只断控制台,Asterisk 仍在后台运行(停服务才 systemctl stop)
启动、停止与状态检查
apt install asterisk 完成后,systemd 通常已经把服务拉起来并设为开机自启。日常用下面几条即可(改完 pjsip.conf / extensions.conf / rtp.conf 后走 restart ,不要只 start)。
bash
# 下面 systemctl 一律 sudo:Asterisk 是系统服务,只有 root 能启停。已是 root → 去掉 sudo
sudo systemctl start asterisk # 启动(已在跑则无操作)
sudo systemctl stop asterisk # 停止(本教程的「暂停」= 停服务;注册与通话全部断开)
sudo systemctl restart asterisk # 停掉再拉起,配置整文件覆盖后用这条
sudo systemctl status asterisk # 看 Active: 一行;q 退出分页
sudo systemctl enable asterisk # 开机自启(虚拟机 reboot 后仍在跑)
sudo systemctl disable asterisk # 取消开机自启(不停止当前进程)
status 判读:active (running) = 已启动;inactive (dead) = 已停止;failed = 启动失败。再确认本教程端口真的在听:
bash
sudo ss -lntup | grep 9060 # sudo:-p 才能显示进程名;应见 udp/tcp 各一条 0.0.0.0:9060
连进已在跑的进程(不是再起一个 Asterisk):
bash
sudo asterisk -rvvv # sudo:连 /var/run/asterisk 控制口。出现 主机名*CLI> = 服务在跑
- 敲
quit(或exit)只断控制台,服务继续在后台跑。 - 报
Unable to connect to remote asterisk= 服务没起来,先systemctl start或看失败原因。 - CLI 清屏:提示符下
Ctrl+L,或执行!clear(!后是宿主机命令)。 - 联调期少刷屏:
core set verbose 0、pjsip set logger off;要看报文再开pjsip set logger on。
改配置不必每次重启整个服务(已有注册/通话不会断):
pjsip reload # 重载 pjsip.conf
dialplan reload # 重载 extensions.conf
启动失败时:
bash
sudo journalctl -u asterisk -n 50 --no-pager # sudo:读该服务的 systemd 日志
sudo asterisk -cvvvvv # sudo:前台以 root 拉起 Asterisk,语法错误打在屏幕上(Ctrl+C 退出)
前台调试完必须 Ctrl+C,再用 systemctl start;两个实例会抢 9060 和 /var/run/asterisk 的控制 socket。绝大多数启动失败是 pjsip.conf / extensions.conf 改错(段落名、缺 type=),前台那条最好定位。
3.4 第三步:先用标准软电话验证服务器(必做,先解耦再接 App)
原则:先用已知正确的客户端证明服务器没问题,再接 App------出问题时才能快速定位是哪一侧。
- 宿主机安装 Linphone (支持 H264,用于视频)和 MicroSIP (轻量,音频)------装的都是 Windows 桌面版 (linphone.org 下
.exe安装包 / microsip.org 下 installer 或 portable zip),不是 Android APK ;安卓端到 3.6 才有我们自写的 App 可装。MicroSIP 账号的 SIP Server 记得带端口192.168.2.110:9060(不带默认按 5060 发,必失败)。 官网下载慢属正常 :Linphone 官方下载站在欧洲、无国内 CDN,浏览器直连几十 KB/s 常见。绕行:① 微软商店 搜 Linphone 安装(走国内 CDN,秒下);② 不被它卡进度------MicroSIP 可加两个账号(1000/1001)互拨,先完成本步"互拨+双向语音"的验收,Linphone 到位后再补 H264 视频验证。 - 各注册一个账号(Linphone→1000,MicroSIP→1001):服务器
192.168.2.110:9060,传输选 TCP 或 UDP 均可,用户名/密码以业务库为准 (种子为sip1000/sip1001,与pjsip_accounts.conf生成结果一致,不要手改 Asterisk 账号段)。MicroSIP("添加账号"对话框,中文界面逐项) :账号名称=1001(本地备注,随意)、SIP服务器=192.168.2.110:9060(端口必带,默认按 5060 发必失败;冒号必须是 半角**:------中文输入法的全角:不被识别,端口丢失照样回落 5060)、SIP代理=留空 (★ MicroSIP 的 REGISTER 走"SIP服务器"、呼叫 INVITE 优先走"SIP代理" ------代理栏误填无端口地址时会出现"注册成功但 INVITE 打到 5060"的分裂现象,这是 3.9/3.12context 'public'行的第二种成因)、用户名=1001、域名=192.168.2.110:9060(端口必带------MicroSIP 未填代理时 呼叫 INVITE 按"域名"栏路由**:域名不带端口就回落默认 5060,而 REGISTER 走"SIP服务器"照样成功,形成"注册成功、呼叫打偏"的分裂现象;留空亦可,缺省沿用 SIP 服务器整串。另:realm 与 domain 是两回事,别填asterisk)、登录名=1001(鉴权用户名,对应 pjsip.conf 的 username)、密码=sip1001、显示名称=1001(来电显示名,随意)、语音信箱号码/拨号前缀/拨号方案=留空或默认 (本实验未配信箱;前缀会在号码前自动加字符)、传输=TCP,其余(STUN/Publish 等)全默认。账号管理入口 :主窗口左上 ☰ 菜单------顶部账号列表(带 ● 为当前激活,点选即切换,底部状态栏显示的就是激活账号)+ 添加/编辑/删除账号;编辑账号编辑的是当前激活账号 、拨号永远用激活账号发出 (实例里有残缺账号时,激活它拨号会把 INVITE 发去默认 5060------见 3.9/3.12 的context 'public'排查行),多账号互拨前先看状态栏确认用的是哪个号。Linphone (5.x 桌面版):首次启动向导选 "使用SIP账户" (别选 "Linphone 账户",那是它家云端服务),填 用户名=1000、密码=sip1000、SIP 域=192.168.2.110:9060(★ 域必带端口------Linphone 把"域"当注册服务器兼呼叫路由,不带端口 REGISTER 直发 5060 必失败;好在它注册/呼叫都走域,没有 MicroSIP 那种"服务器/域名分叉"问题) 、显示名称随意;传输默认 UDP 即可(代理留空自动=域);向导没弹或填错:设置→账户→添加账户→SIP 账户。MicroSIP 侧同步整理 :删掉 MicroSIP 里的 1000 账号(☰→先切到 1000→删除账号),保持 1000=Linphone、1001=MicroSIP 一一对应------两边同时注册 1000 时(aormax_contacts=2允许)呼 1000 会两处同时响铃,验收易乱。(实录最终把角色对调 为 Linphone=1001、MicroSIP=1000 ------哪个号配哪款软电话无所谓,但每次新增/重建/对调 MicroSIP 账号后,都要重新核对域名栏带:9060:对调后 1000 账号的域名栏又变回无端口 IP,REGISTER 照常成功、INVITE 却再落 5060,表现为"单向通"------详见 3.12 第 10 行。)注册成功判据:软电话显示在线/Available;CLI 里REGISTER → 401 → REGISTER(Authorization: Digest) → 200 OK,pjsip show endpoints对应端点变 Available 并出现 Contact 行。 - Asterisk CLI 观察:
<-- Registration success;pjsip show contacts应列出两条 Contact。 - MicroSIP ↔ Linphone 互拨:确认振铃、双向语音;Linphone 侧再开视频确认 H264 透传。
- 全程
pjsip set logger on的输出可与 §1.5 时序逐条对照(REGISTER→401→REGISTER→200;INVITE→100→180→200→ACK→RTP→BYE→200)。
✅ 本步通过 = 服务器侧(信令/鉴权/Dialplan/媒体转发)全部就绪。
3.5 第四步:Java 配置服务(读库下发 + 开通同步)
与 3.3 同一套库:Java 不写死账号 。GET 按 number 读 sip_account + sip_cluster;POST 写库后跑 sip-lab-sync。
| 项 | 值 |
|---|---|
| 拉号 | GET /api/v1/sip-server?number=1000 → 200 JSON(缺 number → 400;无此号 → 404) |
| 开通 | POST /api/v1/sip-accounts,{"number":"1002","password":"sip1002"} → 写库并 sync |
| 其它方法 | 405 |
源码:<sip-lab/ConfigServer.java>。依赖 3.3 已装的 libmariadb-java。serverip 改库,不改 Java。生产再加登录态 / HTTPS。
本节先走 Java 17(主路径,实验室已跑通) ,再走 Java 8(同一套 curl)。必须带 JDBC 的 -cp。
3.5.0 落盘 ConfigServer.java
与 schema.sql、sip-lab-sync.sh 同目录 /root/sip-lab。仓库在宿主机,虚拟机不会自动出现。
方式 A 宿主机仓库根目录:
bash
scp sip-lab/ConfigServer.java root@192.168.2.110:/root/sip-lab/ConfigServer.java
方式 B 虚拟机 tee(> 续行符不落盘;最后一行顶格 TEE;无 -a):
bash
mkdir -p /root/sip-lab
tee /root/sip-lab/ConfigServer.java > /dev/null <<'TEE'
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
/**
* 商业项目形态:只读写业务库,开通后触发 sip-lab-sync 写入 Asterisk。
* GET /api/v1/sip-server?number=1000
* POST /api/v1/sip-accounts body: {"number":"1002","password":"sip1002"}
*/
public class ConfigServer {
static final String JDBC =
System.getenv("SIP_JDBC") != null
? System.getenv("SIP_JDBC")
: "jdbc:mariadb://127.0.0.1:3306/sip_lab";
static final String DB_USER =
System.getenv("SIP_DB_USER") != null ? System.getenv("SIP_DB_USER") : "sip";
static final String DB_PASS =
System.getenv("SIP_DB_PASS") != null ? System.getenv("SIP_DB_PASS") : "Siplab_pass1";
static final String SYNC_CMD =
System.getenv("SIP_SYNC") != null
? System.getenv("SIP_SYNC")
: "/usr/local/sbin/sip-lab-sync";
public static void main(String[] args) throws Exception {
Class.forName("org.mariadb.jdbc.Driver");
int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/api/v1/sip-server", ConfigServer::handleSipServer);
server.createContext("/api/v1/sip-accounts", ConfigServer::handleSipAccounts);
server.start();
System.out.println("ConfigServer :" + port
+ " GET /api/v1/sip-server?number=1000"
+ " POST /api/v1/sip-accounts");
}
static void handleSipServer(HttpExchange ex) throws IOException {
if (!"GET".equalsIgnoreCase(ex.getRequestMethod())) {
send(ex, 405, "text/plain; charset=utf-8", "Method Not Allowed");
return;
}
String number = queryParam(ex, "number");
if (number == null || number.isEmpty()) {
send(ex, 400, "application/json; charset=utf-8",
"{\"error\":1,\"message\":\"number required\"}");
return;
}
try {
String json = loadSipServerJson(number);
if (json == null) {
send(ex, 404, "application/json; charset=utf-8",
"{\"error\":1,\"message\":\"account not found\"}");
return;
}
send(ex, 200, "application/json; charset=utf-8", json);
} catch (Exception e) {
send(ex, 500, "text/plain; charset=utf-8", String.valueOf(e.getMessage()));
}
}
static void handleSipAccounts(HttpExchange ex) throws IOException {
if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) {
send(ex, 405, "text/plain; charset=utf-8", "Method Not Allowed");
return;
}
String body = readBody(ex);
String number = jsonField(body, "number");
String password = jsonField(body, "password");
if (number == null || number.isEmpty() || password == null || password.isEmpty()) {
send(ex, 400, "application/json; charset=utf-8",
"{\"error\":1,\"message\":\"number and password required\"}");
return;
}
try {
upsertAccount(number, password);
int code = runSync();
if (code != 0) {
send(ex, 500, "application/json; charset=utf-8",
"{\"error\":1,\"message\":\"db ok, asterisk sync failed\",\"code\":"
+ code + "}");
return;
}
send(ex, 200, "application/json; charset=utf-8",
"{\"error\":0,\"number\":\"" + escape(number) + "\",\"synced\":true}");
} catch (Exception e) {
send(ex, 500, "text/plain; charset=utf-8", String.valueOf(e.getMessage()));
}
}
static String loadSipServerJson(String number) throws Exception {
try (Connection c = DriverManager.getConnection(JDBC, DB_USER, DB_PASS)) {
String accSql = "SELECT number, password, realm FROM sip_account"
+ " WHERE number=? AND enabled=1";
String num, pass, realm;
try (PreparedStatement ps = c.prepareStatement(accSql)) {
ps.setString(1, number);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
return null;
}
num = rs.getString("number");
pass = rs.getString("password");
realm = rs.getString("realm");
}
}
String ip, port, tls, access;
try (PreparedStatement ps = c.prepareStatement(
"SELECT serverip, port, tls, access FROM sip_cluster WHERE id=1")) {
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new IllegalStateException("sip_cluster id=1 missing");
}
ip = rs.getString("serverip");
port = rs.getString("port");
tls = rs.getString("tls");
access = rs.getString("access");
}
}
return "{\"error\":0,\"sip_server\":{"
+ "\"number\":\"" + escape(num) + "\","
+ "\"password\":\"" + escape(pass) + "\","
+ "\"serverip\":\"" + escape(ip) + "\","
+ "\"port\":\"" + escape(port) + "\","
+ "\"tls\":\"" + escape(tls == null ? "" : tls) + "\","
+ "\"realm\":\"" + escape(realm) + "\","
+ "\"access\":\"" + escape(access) + "\"}}";
}
}
static void upsertAccount(String number, String password) throws Exception {
String sql = "INSERT INTO sip_account (number, password, realm, enabled) VALUES (?,?, 'asterisk', 1)"
+ " ON DUPLICATE KEY UPDATE password=VALUES(password), enabled=1";
try (Connection c = DriverManager.getConnection(JDBC, DB_USER, DB_PASS);
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, number);
ps.setString(2, password);
ps.executeUpdate();
}
}
static int runSync() throws Exception {
boolean root = "root".equals(System.getProperty("user.name"));
ProcessBuilder pb = root
? new ProcessBuilder(SYNC_CMD)
: new ProcessBuilder("sudo", "-n", SYNC_CMD);
pb.redirectErrorStream(true);
Process p = pb.start();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (InputStream in = p.getInputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
}
int code = p.waitFor();
if (code != 0) {
System.err.println(bos.toString("UTF-8"));
}
return code;
}
static void send(HttpExchange ex, int code, String contentType, String body)
throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", contentType);
ex.sendResponseHeaders(code, bytes.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(bytes);
}
}
static String readBody(HttpExchange ex) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (InputStream in = ex.getRequestBody()) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
}
return bos.toString("UTF-8");
}
static String queryParam(HttpExchange ex, String key) {
String q = ex.getRequestURI().getQuery();
if (q == null) {
return null;
}
for (String part : q.split("&")) {
int eq = part.indexOf('=');
if (eq > 0 && key.equals(part.substring(0, eq))) {
return part.substring(eq + 1);
}
}
return null;
}
static String jsonField(String json, String key) {
if (json == null) {
return null;
}
String needle = "\"" + key + "\"";
int i = json.indexOf(needle);
if (i < 0) {
return null;
}
int colon = json.indexOf(':', i + needle.length());
int a = json.indexOf('"', colon);
int b = json.indexOf('"', a + 1);
if (a < 0 || b < 0) {
return null;
}
return json.substring(a + 1, b);
}
static String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
TEE
核对(没过不要启动):
bash
wc -l /root/sip-lab/ConfigServer.java # 约 229 行
grep -n 'org.mariadb.jdbc.Driver' /root/sip-lab/ConfigServer.java
ls -l /usr/share/java/mariadb-java-client.jar
# 没有 jar:apt install -y libmariadb-java
mysql -e "UPDATE sip_lab.sip_cluster SET serverip='192.168.2.110' WHERE id=1;"
# IP 换成 ip -4 addr 的实际值
8080 若被旧进程占着:
bash
ss -lntup | grep 8080
pkill -f 'java ConfigServer'
ss -lntup | grep 8080 # 应无输出
3.5.1 主路径:Java 17(先做这条)
bash
java -version
# 已是 17.0.x → 跳到「启动」
# 仍是 1.8.0_xxx 或没有 java → 往下装并切换
国内 apt 失败时先换源(与 3.3 相同:ping 网关/223.5.5.5,再改 /etc/apt/sources.list)。
bash
apt update
apt install -y openjdk-17-jdk-headless # Desktop 也可用 openjdk-17-jdk
# 找不到包:apt-add-repository universe 后再 update;或暂用 openjdk-11-jdk-headless(启动命令与 17 相同)
update-alternatives --config java
update-alternatives --config javac
# 两处都选 java-17 那一行的数字,不要一个 8 一个 17
java -version # 必须是 17.0.x
javac -version # javac 17.x
readlink -f $(which java)
# 应含 java-17-openjdk
启动(当前终端会停住):
bash
export JAR=/usr/share/java/mariadb-java-client.jar
cd /root/sip-lab
java -cp "$JAR" ConfigServer.java 8080
成功时打印:ConfigServer :8080 GET /api/v1/sip-server?number=1000 POST /api/v1/sip-accounts。这个窗口不要 Ctrl+C。另开终端做验证。
不要跑 java ConfigServer.java 8080(没有 -cp)。Address already in use → 上面 pkill 后再起。
后台(与前台二选一):
bash
export JAR=/usr/share/java/mariadb-java-client.jar
nohup java -cp "$JAR" /root/sip-lab/ConfigServer.java 8080 > /root/sip-lab/configserver.log 2>&1 &
tail -n 5 /root/sip-lab/configserver.log
验证(另开终端,整段跑):
bash
ss -lntup | grep 8080
pgrep -af ConfigServer
curl -i 'http://127.0.0.1:8080/api/v1/sip-server'
# HTTP/1.1 400 {"error":1,"message":"number required"}
curl -s 'http://127.0.0.1:8080/api/v1/sip-server?number=1000'
# {"error":0,"sip_server":{"number":"1000","password":"sip1000","serverip":"192.168.2.110",...}}
curl -i 'http://127.0.0.1:8080/api/v1/sip-server?number=1999'
# HTTP/1.1 404
curl -s -X POST 'http://127.0.0.1:8080/api/v1/sip-accounts' \
-H 'Content-Type: application/json' \
-d '{"number":"1002","password":"sip1002"}'
# {"error":0,"number":"1002","synced":true}
asterisk -rx "pjsip show endpoint 1002"
mysql -N -e "SELECT number,password FROM sip_lab.sip_account WHERE enabled=1;"
grep password /etc/asterisk/pjsip_accounts.conf
三处密码必须同一行(库 = HTTP = 生成文件)。宿主机:http://虚拟机IP:8080/api/v1/sip-server?number=1000。
✅ 到这里 = 从装 Asterisk 到 Java 17 服务端验证整条主线跑通。
3.5.2 备路径:Java 8(同一套接口再验一遍)
Ubuntu 20.04 预装常常是 8。可以不装 17,直接走本节;也可以 17 跑通后再切回来对照。
先停 17 那个进程(8080 只能有一份):
bash
pkill -f 'java ConfigServer'
ss -lntup | grep 8080 # 应无输出
若还没装 8,或 java -version 仍是 17:
bash
apt update
apt install -y openjdk-8-jdk-headless
update-alternatives --config java
update-alternatives --config javac
# 两处都选 java-8
java -version # 必须是 1.8.0_xxx
javac -version
启动(必须先 javac;classpath 是 $JAR:.):
bash
export JAR=/usr/share/java/mariadb-java-client.jar
cd /root/sip-lab
rm -f ConfigServer.class
javac -cp "$JAR" ConfigServer.java
java -cp "$JAR:." ConfigServer 8080
成功打印与 3.5.1 相同。不要在 8 上跑 java ConfigServer.java 8080(会 error: invalid flag / 找不到类)。
后台:nohup java -cp "$JAR:." ConfigServer 8080 > /root/sip-lab/configserver.log 2>&1 &(须已在 /root/sip-lab 里 javac 过)。
验证 :把 3.5.1「验证」整段再跑一遍,期望完全一样(400 / 200 / 404 / POST)。已开通的 1002 再 POST 一次是改密,synced:true 即可。
curl 验收与三方对账以 3.5.1「验证」 为准(Java 8 重复同一段)。
停止
| 当初怎么起的 | 怎么停 |
|---|---|
前台 java ConfigServer / java ConfigServer.java |
该终端 Ctrl+C |
nohup ... & 且写了 pid 文件 |
kill $(cat ~/sip-lab/configserver.pid) |
| 只记得在跑、没有 pid | pkill -f 'java ConfigServer'(会停掉所有匹配的 java) |
停完再 ss -lntup | grep 8080,应无输出。不要对这个进程 kill -9,除非普通 kill 无响应。
开机自启(可选)
实验够用 nohup。虚拟机常 reboot、想开机自动听 8080,用 systemd。User / WorkingDirectory 换成 whoami 与 echo $HOME(root 登录则 User=root、目录常是 /root/sip-lab)。
ExecStart 必须带 JDBC。Java 8 示例:
ini
ExecStart=/usr/bin/java -cp /usr/share/java/mariadb-java-client.jar:. ConfigServer 8080
Java 17:
ini
ExecStart=/usr/bin/java -cp /usr/share/java/mariadb-java-client.jar /root/sip-lab/ConfigServer.java 8080
After= 加上 mariadb.service。User / 目录按 whoami、$HOME 改。停:sudo systemctl stop sip-config(sudo:停系统服务;已是 root → systemctl stop sip-config)。
跑不起来时
| 现象 | 原因 | 处理 |
|---|---|---|
java: command not found |
未装运行时 | 装 openjdk-8-jdk-headless 或 openjdk-17-jdk-headless |
error: invalid flag / 找不到 ConfigServer.java 类 |
当前是 Java 8,却执行了 java ConfigServer.java |
改用 javac + java ConfigServer;或切到 17 |
ClassNotFoundException: org.mariadb.jdbc.Driver |
-cp 没带 JDBC jar |
ls 核对 libmariadb-java 的 jar 路径,启动命令加上 -cp |
| GET 一直 400 | 没带 ?number= |
curl '.../sip-server?number=1000' |
| GET 404 但库里有号 | enabled=0 或连错库 |
SELECT * FROM sip_lab.sip_account; |
POST 写库成功但 synced 失败 |
sip-lab-sync 不在 PATH / 无 sudo |
核对 /usr/local/sbin/sip-lab-sync;root 直跑或 sudoers |
| curl 密码与 REGISTER 401 | 改了库没 sip-lab-sync |
sudo sip-lab-sync 后再注册 |
| JSON 里 IP 不对 | sip_cluster.serverip 仍是示例 |
UPDATE sip_lab.sip_cluster SET serverip=...,不必重启 Java |
无 ?number= 仍 200 / JSON 密码不是库里的 |
还在跑旧写死版,或 Java 8 吃到旧 .class |
pkill -f 'java ConfigServer';rm -f /root/sip-lab/ConfigServer.class 再按 3.5.1 / 3.5.2 启动 |
换了 .java 但行为没变 |
没停旧进程就启动;或 Address already in use 其实是旧进程还在 |
pgrep -af ConfigServer;先停干净再起 |
HttpServer 使用 new InetSocketAddress(port),绑的是 0.0.0.0 (所有网卡),局域网设备用 http://192.168.2.110:8080/... 即可,不必改代码。
从旧实验室迁(可选)
若以前按旧教程在 pjsip.conf 手写了 [1000]/[1001],或 Java 把 JSON 写死在源码里:不要两套密码并存。
pkill -f 'java ConfigServer',确认 8080 空。- 按 3.3 做完库 +
sip-lab-sync;pjsip.conf删掉手写账号段 ,只留模板 +#include。 - 按 3.5.0 覆盖
ConfigServer.java(grep Driver必须有)。 - 按 3.5.1 Java 17 (或 3.5.2 的 8)启动;curl 无
number必须 400。 - 软电话密码改成库里的
sip1000/sip1001。
3.6 第五步:从零开发 Android SIP App(Java + pjsua2,关键步骤)
3.6.1 编译 pjproject,产出 pjsua2 Android 库
在 Ubuntu 虚拟机 (或宿主机 WSL)上交叉编译。依赖:Android NDK r25+ 、SWIG 4、Python3、JDK。./configure-android 靠环境变量 ANDROID_NDK_ROOT 找交叉编译器,NDK 不会随 apt install asterisk 出现,必须单独装。
安装 NDK(r26d,Linux x86_64)
本文固定 r26d (pjproject 2.15 常用;r25c / r27 LTS 一般也能编,目录名跟着改即可)。包大约 640 MB,解压后约 1.5 GB,装到 /opt/android-ndk-r26d。
- 系统依赖 (已是 root 去掉
sudo):
bash
apt update
apt install -y swig python3 openjdk-17-jdk unzip wget
# unzip/wget 解压与下载;swig 后面编 Java 绑定;JDK 给 SWIG 生成的 Java 用
which unzip wget
- 下载官方 zip (必须是 linux 包,不要 windows.zip / darwin.dmg):
bash
mkdir -p /opt && cd /tmp
# 官方(Google):
wget -c https://dl.google.com/android/repository/android-ndk-r26d-linux.zip
# 下载很慢或 Connection failed:在 Windows 宿主机浏览器打开同一 URL,
# 下完后 scp 到虚拟机 /tmp/,再继续解压。不要用 GitHub 上的 Source code 源码包。
ls -lh /tmp/android-ndk-r26d-linux.zip
# 完整包约 638M(668556491 字节)。只有几 MB = 下到了 HTML 错误页,不能解压
可选校验(与 NDK r26d 发布页 一致):
bash
sha1sum /tmp/android-ndk-r26d-linux.zip
# 应为 fcdad75a765a46a9cf6560353f480db251d14765
- 解压到 /opt,并钉死环境变量:
bash
unzip -q /tmp/android-ndk-r26d-linux.zip -d /opt
# 解压出 /opt/android-ndk-r26d/ (目录名带版本,不要再套一层)
ls /opt/android-ndk-r26d/ndk-build
ls /opt/android-ndk-r26d/toolchains/llvm/prebuilt/linux-x86_64/bin/clang
# 这两处都在 = 包是完整的
# 当前 shell 立刻生效:
export ANDROID_NDK_ROOT=/opt/android-ndk-r26d
# 开机后还要用,写入 root 的 bashrc(普通用户改 ~):
grep -q ANDROID_NDK_ROOT ~/.bashrc || \
echo 'export ANDROID_NDK_ROOT=/opt/android-ndk-r26d' >> ~/.bashrc
echo "$ANDROID_NDK_ROOT"
pjproject 的 configure-android 认 ANDROID_NDK_ROOT (有的文档写 ANDROID_NDK,本教程只用前者)。路径必须是解压后的根目录 (能直接看到 ndk-build),不要指到 toolchains 里面。
- 过关:
bash
test -x "$ANDROID_NDK_ROOT/ndk-build" && echo "NDK OK $ANDROID_NDK_ROOT"
打印 NDK OK /opt/android-ndk-r26d 再编 pjproject。没有这条不要往下 ./configure-android(会报找不到 NDK / compiler)。
| 现象 | 处理 |
|---|---|
wget 极慢或失败 |
宿主机下 zip,scp 到虚拟机 /tmp |
unzip 报 End-of-central-directory |
zip 不完整,删掉重下;ls -lh 先看是否约 638M |
解压后没有 ndk-build |
多套了一层目录,或下成了源码包;find /opt -name ndk-build |
configure-android 找不到 NDK |
当前终端没 export;新开的 SSH 要 source ~/.bashrc 或重登 |
| 想用 SDK Manager | 已有 Android SDK 时:sdkmanager "ndk;26.3.11579264",然后 export ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/26.3.11579264 |
| 在 Windows 上编 | 本步请在 Ubuntu/WSL 做;Windows 版 NDK 不能直接给 Linux 的 configure-android 用 |
不要用发行版里过时的 google-android-ndk-installer / 随机第三方「NDK 绿色包」。r27d(LTS)若已装:把上面路径里的 r26d 改成实际目录名即可。
编译 pjproject
ANDROID_NDK_ROOT 已按上一节导出:
bash
export ANDROID_NDK_ROOT=/opt/android-ndk-r26d # 新开终端再写一次
wget https://github.com/pjsip/pjproject/archive/refs/tags/2.15.1.tar.gz
tar xzf 2.15.1.tar.gz && cd pjproject-2.15.1 # 2.15.x;更新版本步骤相同
./configure-android # 默认只编 arm64-v8a(不是两个 ABI 一起出)
make dep && make -j$(nproc)
cd pjsip-apps/src/swig && make # SWIG 生成 Java 绑定 + libpjsua2.so
# 2.15 产物在 pjsua2 模块(不是示例 App 的 app/):
# java/android/pjsua2/src/main/jniLibs/arm64-v8a/libpjsua2.so
# 同目录可能还有 libc++_shared.so(一并拷进新工程)
# java/android/pjsua2/src/main/java/org/pjsip/pjsua2/*.java
只要 arm64 真机/模拟器:到这里即可,不必编 v7a。
再编 armeabi-v7a(32 位旧机;不能和 arm64「一次编出两个」,必须换 ABI 重配、先清干净):
bash
export ANDROID_NDK_ROOT=/opt/android-ndk-r26d
cd ~/Desktop/pjproject-2.15.1 # 换成你的源码根
# ① 清掉 arm64 的中间文件,否则 SWIG 会报 incompatible target
make clean
cd pjsip-apps/src/swig && make clean && cd ../../..
# ② 指定 ABI 后重新 configure(--use-ndk-cflags 按官方 2.15 文档)
TARGET_ABI=armeabi-v7a ./configure-android --use-ndk-cflags
make dep && make -j$(nproc)
# ③ 再出 so
cd pjsip-apps/src/swig && make
# ④ 2.15 有时仍把 so 写到 jniLibs/arm64-v8a/,要挪到 v7a
JNI=java/android/pjsua2/src/main/jniLibs
mkdir -p "$JNI/armeabi-v7a"
# 若 armeabi-v7a 里还没有 so,而 arm64 目录刚被这次覆盖:
# file "$JNI/arm64-v8a/libpjsua2.so" # 应显示 ARM(32-bit)/ EABI5,不是 ARM aarch64
# mv "$JNI/arm64-v8a/libpjsua2.so" "$JNI/armeabi-v7a/"
# 同目录的 libc++_shared.so 一并 mv
ls -l "$JNI"/*/libpjsua2.so
file "$JNI"/armeabi-v7a/libpjsua2.so
要两个 ABI 都留着:先按默认编完,把 arm64-v8a 整个目录拷走备份 ,再按上面编 v7a,最后把备份的 arm64-v8a 拷回 jniLibs/。不要在没备份时对同一棵树 make clean。
其它 ABI 同样换 TARGET_ABI:x86_64(x86 模拟器)、x86。编完用 file libpjsua2.so 看是 ARM aarch64 还是 ARM(32-bit),对不上就还在用上一套产物。
两条上手路线(本文走第二条):
| 路线 | 做法 | 适用 |
|---|---|---|
| 一:官方示例 | Android Studio 直接打开 pjsip-apps/src/swig/java/android,设置页填 3.3 账号(ID sip:1000@asterisk、Registrar sip:192.168.2.110:9060;transport=tcp) |
10 分钟跑通,快速验证编译产物 |
| 二:自写最小 App(推荐) | 新建空工程,只搬 so + 绑定源码,手写全部逻辑 | 吃透 pjsua2 API 与 §2.3 生命周期表的对应关系 |
3.6.2 新建空工程并搬运产物
Android Studio → New Project → Empty Views Activity (语言 Java ,minSdk 24,包名如 com.lab.sipcall):
pjsip-apps/src/swig/java/android/pjsua2/src/main/jniLibs/<ABI>/libpjsua2.so(及同目录libc++_shared.so)→ 拷到新工程app/src/main/jniLibs/<ABI>/。2.15 不要 再找java/android/app/src/main/jniLibs(那是示例 App,so 不在那里)。虚拟机编、Windows 开工程:用 3.2 第 6 步 共享目录或scp把jniLibs整目录拷到宿主机。- 绑定源码整包
.../pjsua2/src/main/java/org/pjsip/pjsua2/→ 拷到新工程app/src/main/java/org/pjsip/pjsua2/(保持包名org.pjsip.pjsua2,不要改成com.lab.sipcall)。必须整目录拷,不要按文件名挑选。 这些是 SWIG 对照libpjsua2.so一次生成的 JNI 层(Endpoint引用EpConfig,AccountConfig引用AuthCredInfo......互相 import)。少拷一个,编译期就是 cannot find symbol,或运行期NoClassDefFoundError。你自己只写 3.6.3 那 4 个类,真正用到的 API 只有十来个;其余文件躺在工程里即可,不要改、不必读。按需是「业务代码调哪些方法」,不是「绑定源码留哪些文件」。 app/build.gradle关键配置:
groovy
android {
defaultConfig {
minSdk 24
ndk { abiFilters "armeabi-v7a", "arm64-v8a" } // 与 jniLibs 一致
}
}
// 无任何第三方依赖:SIP/媒体/RTP 全部在 libpjsua2.so 内
AndroidManifest.xml权限:INTERNET、ACCESS_NETWORK_STATE、RECORD_AUDIO(运行时申请)。3.6.5 用明文 HTTP 拉号时,<application>加android:usesCleartextTraffic="true"(Android 9+ 默认拦明文;生产改 HTTPS 后去掉)。
3.6.3 核心 Java 代码(拉号 + 注册 + 呼叫)
不要写死密码。 点「拉号并注册」时先 GET /api/v1/sip-server?number=,再用返回的 JSON 填 AccountConfig。完整可抄文件(包名 com.lab.sipcall):
| 文件 | 职责 |
|---|---|
| <sip-lab/android/SipConfig.java> | JSON sip_server 快照 |
| <sip-lab/android/SipConfigClient.java> | HTTP GET + 退避 |
| <sip-lab/android/SipStack.java> | 拉号 → pjsua2 生命周期 |
| <sip-lab/android/MyAccount.java> | 注册 / 来电 |
| <sip-lab/android/MyCall.java> | 呼叫状态 / 媒体桥 |
| <sip-lab/android/MainActivity.java> | 号码输入 + 三个按钮 |
| <sip-lab/android/activity_main.xml> | 布局 |
把上述 .java 拷进 app/src/main/java/com/lab/sipcall/,xml 覆盖 res/layout/activity_main.xml。SipConfigClient.BASE 改成虚拟机 IP。<application android:usesCleartextTraffic="true">。
SipStack.java 关键路径(先 HTTP,再 libCreate;对应 §2.3):
java
public synchronized void start(String number) throws Exception {
sipConfig = SipConfigClient.fetch(number); // GET ?number= 主线程禁止
Log.i("SipLab", "pulled " + sipConfig.idUri() + " via " + sipConfig.registrarUri());
System.loadLibrary("pjsua2");
ep.libCreate();
EpConfig cfg = new EpConfig();
cfg.logConfig.level = 5;
ep.libInit(cfg);
ep.transportCreate(pjsip_transport_type.PJSIP_TRANSPORT_TCP, new TransportConfig());
ep.libStart();
AccountConfig ac = new AccountConfig();
ac.idUri = sipConfig.idUri();
ac.regConfig.registrarUri = sipConfig.registrarUri();
ac.regConfig.timeoutSec = 300;
ac.sipConfig.authCreds.add(new AuthCredInfo(
"digest", sipConfig.realm, sipConfig.number, 0, sipConfig.password));
account = new MyAccount();
account.create(ac); // 立即 REGISTER
}
public void call(String destNumber) throws Exception {
current = new MyCall(account, -1);
current.makeCall(sipConfig.peerUri(destNumber), new CallOpParam(true));
}
SipStack.get() 懒创建;Endpoint ep 包可见(MyCall 要拿去桥接)。2.15 SWIG 枚举是 pjsip_transport_type(没有 _e 后缀)。
其余生命周期(hangup / shutdown)见仓库 SipStack.java。
MyAccount.java------注册状态与来电分发 (对应 §2.3 的 on_reg_state / on_incoming_call):
java
package com.lab.sipcall;
import android.util.Log;
import org.pjsip.pjsua2.*;
public class MyAccount extends Account {
@Override
public void onRegState(OnRegStateParam prm) {
// code=200 且 status=Success 即注册成功(时序见 §1.5)
Log.i("SipLab", "REG status=" + prm.getStatus()
+ " code=" + prm.getCode() + " reason=" + prm.getReason());
}
@Override
public void onIncomingCall(OnIncomingCallParam prm) {
MyCall call = new MyCall(this, prm.getCallId());
try {
CallInfo ci = call.getInfo();
Log.i("SipLab", "Incoming from " + ci.getRemoteUri()); // From 头(§1.4)
} catch (Exception ignore) {}
SipStack.get().current = call;
try {
call.answer(new CallOpParam(true)); // 实验室简化:来电自动接听(200 OK)
} catch (Exception e) {
Log.e("SipLab", "answer", e);
}
}
}
MyCall.java------呼叫状态机与媒体桥接:
java
package com.lab.sipcall;
import android.util.Log;
import org.pjsip.pjsua2.*;
public class MyCall extends Call {
public MyCall(Account acc, int callId) { super(acc, callId); }
@Override
public void onCallState(OnCallStateParam prm) {
CallInfo ci;
try { ci = getInfo(); } catch (Exception e) { return; } // 析构后调用会抛异常
Log.i("SipLab", "CALL " + ci.getState()
+ " lastCode=" + ci.getLastStatusCode() // 180/200/404/603...
+ " remote=" + ci.getRemoteUri());
if (ci.getState() == pjsip_inv_state.PJSIP_INV_STATE_DISCONNECTED)
delete(); // 释放 native Call 对象,防泄漏
}
@Override
public void onCallMediaState(OnCallMediaStateParam prm) {
// §2.4 会议桥:把通话媒体与声音设备互连
CallInfo ci;
try { ci = getInfo(); } catch (Exception e) { return; }
for (int i = 0; i < ci.getMedia().size(); i++) {
Media m = ci.getMedia().get(i);
if (m.getType() == pjmedia_type.PJMEDIA_TYPE_AUDIO) {
AudioMedia am = AudioMedia.typecastFromMedia(m);
try {
Endpoint ep = SipStack.get().ep;
ep.audDevManager().getCaptureDevMedia().startTransmit(am); // mic → 通话
am.startTransmit(ep.audDevManager().getPlaybackDevMedia()); // 通话 → 扬声器
} catch (Exception e) { Log.e("SipLab", "bridge", e); }
}
}
}
}
MainActivity.java------输入本机号码 / 对方号码,点启动即拉号:
完整文件见 <sip-lab/android/MainActivity.java>。要点:
java
findViewById(R.id.btn_start).setOnClickListener(v -> {
String number = etNumber.getText().toString().trim();
sip(false, () -> SipStack.get().start(number)); // false:库尚未 libCreate,不能 registerThread
});
findViewById(R.id.btn_call).setOnClickListener(v -> {
String peer = etPeer.getText().toString().trim();
sip(true, () -> SipStack.get().call(peer)); // 只传号码,URI 用拉到的 serverip/port
});
sip(false, ...) 用于拉号+启动;sip(true, ...) 用于呼叫/挂断(此时才 libRegisterThread)。
布局 <sip-lab/android/activity_main.xml>:两个输入框(默认 1000 / 1001)+「拉号并注册 / 呼叫对方 / 挂断」。
运行注意:
- logcat 过滤
SipLab|pjsip。先出现pulled sip:1000@asterisk via sip:...:9060;transport=tcp,再REG status=Success code=200。 - 来电自动接听是实验室简化。
- 第二台设备:本机号码改成
1001,对方填1000,密码仍由 GET 下发。
3.6.4 第二终端(被叫 1001)
宿主机 Linphone / MicroSIP(3.4 已注册),或第二台安卓装同款新 App(账号改 1001)。
3.6.5 拉号自检(已包含在 3.6.3)
点「拉号并注册」= GET /api/v1/sip-server?number=。ConfigServer 必须在跑。SipConfigClient.BASE 的 IP 与虚拟机一致。
bash
ss -lntup | grep 8080
curl -s "http://127.0.0.1:8080/api/v1/sip-server?number=1000"
| 现象 | 处理 |
|---|---|
Cleartext HTTP traffic not permitted |
Manifest 未开 usesCleartextTraffic |
一直重试、failed to connect |
8080 没起 / BASE IP 不是 ip -4 addr |
HTTP 400 |
URL 漏了 ?number= |
HTTP 404 |
库里无此号或 enabled=0 |
REG 401 但 curl 密码对 |
改了库没 sip-lab-sync |
3.7 第六步:全流程联调(注册 → 来电 → 去电 → 挂断)
按序执行,每步给出两处观测点(新 App logcat / Asterisk CLI·抓包):
| # | 操作 | 新 App 侧预期(tag SipLab / pjsip) | 服务器/抓包预期 |
|---|---|---|---|
| 1 | 点「拉号并注册」 | 先 pulled sip:1000@asterisk,再 REG status=Success code=200 |
pjsip show contacts 出现 1000;tcpdump 见 REGISTER → 401(nonce/realm=asterisk) → REGISTER(Authorization: Digest) → 200 OK(§1.5) |
| 2 | 软电话(1001)拨 1000 | Incoming from <sip:1001@...> → 自动 answer → CALL CONFIRMED |
INVITE → 100 → 180 → 200(SDP answer) → ACK(§1.5) |
| 3 | 双方说话 | onCallMediaState 桥接后双向语音(PCMA 8 kHz,§1.6) | RTP 双向流;Wireshark 统计→RTP 流 可见 PCMA |
| 4 | 点"呼叫 1001" | CALLING → EARLY → CONFIRMED(lastCode 依次 180/200) |
INVITE(UA 含 pjsua2 版本号,§1.3)→ 180 → 200 → ACK |
| 5 | 点"挂断"或对端挂断 | CALL DISCONNECTED,Call 对象 delete |
BYE → 200 OK,RTP 流停止 |
| 6 | 故障码验证:软电话振铃后拒接 | lastCode=603(§1.5;本 App 看日志即可) | 603 Declined |
| 7 | 故障码验证:呼叫未注册的号码 | CALL DISCONNECTED lastCode=404 |
404 Not Found |
视频不在主链路:pjsua2 需
--enable-video+ 编解码插件 + Surface 挂 VideoPreview/VideoWindow,见 3.11。协商形态即 §1.6 的m=video ... a=rtpmap:96 H264/90000。
3.8 抓包与日志
bash
# 虚拟机上全量抓 SIP+RTP
sudo tcpdump -i any -w /tmp/sip.pcap 'port 9060 or (udp portrange 10000-20000)'
# sudo:抓原始网卡包需要 CAP_NET_RAW。已是 root → tcpdump ...
- Wireshark 分析 :
统计 → VoIP 呼叫(Telephony → VoIP Calls)可重组整条信令流程并绘制时序图,与 §1.5 逐步对照;统计 → RTP 流查看 PCMA/H264 的丢包/抖动------视频卡顿时先看这里。 - Asterisk CLI :
pjsip set logger on后所有 SIP 报文实时上屏,比抓包更快定位 401/404。 - 新 App 日志 :
adb logcat -v time | grep -E "SipLab|pjsip"------SipLab 是业务状态(REG/CALL/媒体桥接),pjsip 是EpConfig.logConfig.level=5打印的完整 SIP 报文(可与 §1.3 报文示例逐行对照)。
3.9 常见故障排查表
| 现象 | 高概率原因 | 处理 |
|---|---|---|
| 编译期:configure-android/make 报 NDK 错 | ANDROID_NDK_ROOT 未导出或版本过旧 |
echo $ANDROID_NDK_ROOT 确认;使用 NDK r25+ |
| 编译期:swig 阶段失败 | 未安装 SWIG | apt install swig;swig -version 确认 |
| 运行期:UnsatisfiedLinkError | jniLibs 缺当前 ABI 的 libpjsua2.so | abiFilters 与 jniLibs 目录一致;x86 模拟器需按 TARGET_ABI 重编或换 arm64 模拟器/真机 |
| 运行期:调 API 无响应/直接崩溃 | 工作线程未注册 | 非 pjsua2 创建的线程先 ep.libRegisterThread()(见 3.6.3 的 sip() 辅助方法) |
| 注册循环 401,永远不到 200 | 密码错 / realm 不一致 | AuthCredInfo 的 realm 必须 asterisk;核对 pjsip_accounts.conf 生成结果与业务库 ,不要手改 pjsip.conf 账号段;CLI 看 WWW-Authenticate |
| 注册 200 但呼叫 404 | extensions.conf 缺该号码 exten / URI 用户名与 endpoint 名不一致 | 补 [from-internal] 拨号计划;URI 应为 sip:1001@192.168.2.110:9060;transport=tcp |
呼叫被拒 Call from '' ... not found in context 'public'(NOTICE 出自 chan_sip.c) |
INVITE 打到了老 chan_sip 的 5060 。MicroSIP 路由规则:REGISTER 走"SIP服务器",未填代理时呼叫 INVITE 按"域名"栏路由 ------高频成因:① 域名栏填了不带端口的 IP (注册成功、端点 Reachable,INVITE 却回落 5060);② SIP 服务器栏漏 :9060 或用了全角冒号;③ 代理栏误填无端口地址。chan_sip 匹配不到 peer 按匿名处理,落进 Ubuntu 自带 sip.conf 的 context=public |
客户端账号:域名与 SIP 服务器都填 192.168.2.110:9060 (半角冒号;域名清空亦可)、代理清空;可选 modules.conf 加 noload => chan_sip.so 禁老栈,打错端口直接失败更醒目 |
| 接通后无声或单通 | onCallMediaState 未桥接 / RECORD_AUDIO 未授权 / direct_media 未关 / RTP 端口被拦 | 核对桥接代码与运行时权限;endpoint direct_media=no;ufw 放行 10000-20000/udp;Wireshark 看是否单向 RTP |
| UDP 软电话正常但新 App(TCP)不通 | transportCreate 用了 UDP / URI 缺 ;transport=tcp |
两处统一 tcp;pjsip show transports 核对 |
| 来电无反应 | App 进程被杀 / onIncomingCall 路径有误 | 保持 App 前台;看 logcat 是否出现 Incoming from |
| 虚拟机重启后全断 | asterisk 未自启 / 静态 IP 漂移 | systemctl enable asterisk;核对 IP |
| 模拟器测试无音 | 模拟器虚拟麦克风问题 | 模拟器设置开 mic;优先真机验证 |
mysql < .../schema.sql 报 No such file or directory |
虚拟机上还没有该文件;或把桌面路径当成了 ~/sip-lab |
见 3.3:echo $HOME、ls -l /root/sip-lab;root 的 ~ 是 /root,不是 /home/.../Desktop;scp 或 tee 落盘后再导入 |
ConfigServer:ClassNotFoundException: org.mariadb.jdbc.Driver |
启动未带 JDBC -cp |
见 3.5;apt install libmariadb-java |
| ConfigServer:GET 400 | 未带 ?number= |
curl '.../sip-server?number=1000' |
| 注册 401 但 curl 密码看着对 | 改了库未 sip-lab-sync |
sudo sip-lab-sync 后再 REGISTER |
ConfigServer:Address already in use |
上次 java 没停 | `ss -lntup |
App:Cleartext HTTP traffic not permitted |
Android 9+ 拦明文 | <application android:usesCleartextTraffic="true">(3.6.5) |
| App:拉号一直 retry / connect 失败 | 8080 未起或 BASE IP 错 | 虚拟机 ss 看 8080;SipConfigClient.BASE 与 ip -4 addr 一致 |
3.10 里程碑核对清单
text
□ 3.2 宿主机 ↔ 虚拟机 ping 通;nc -zv 192.168.2.110 9060 成功
□ 3.3 pjsip.conf 无手写账号;库有 1000/1001;sync 后 endpoints 正常
□ 3.4 两软电话互拨(密码以业务库为准)
□ 3.5.1 Java 17:无 number→400;?number=1000 与库/生成文件一致;POST 1002 后 endpoint 出现
□ 3.5.2 (可选)切 Java 8 编译启动,同一套 curl 再过一遍
□ 3.6 libpjsua2.so 编译产出;新工程装机无 UnsatisfiedLinkError
□ 3.6 点启动 → REG code=200;pjsip show contacts 见 1000
□ 3.6.5 logcat 有 pulled sip:1000@asterisk;密码与 GET ?number=1000 / 库表一致
□ 3.7 软电话→App 来电自动接听、双向语音
□ 3.7 App→软电话呼叫、回铃、接通
□ 3.7 双向 BYE 干净挂断(DISCONNECTED + 200 OK)
□ 3.8 tcpdump 抓包可在 Wireshark 重组完整呼叫流程
3.11 进阶方向
- 登录态拉号 :配置服务按当前登录用户查号,App 不再手传
?number=;HTTPS,密码不长期明文落盘。 - SIP over TLS :Asterisk 增加
[transport-tls] type=transport protocol=tls bind=0.0.0.0:9061 cert_file=... priv_key_file=...(自签证书 openssl 生成);App 把 registrarUri 改为sip:192.168.2.110:9061;transport=tls(TransportConfig.tlsConfig可配 CA)。 - SRTP :Asterisk 装
res_srtp,endpointmedia_encryption=sdes;pjsua2 开AccountMediaConfig.srtpUse(§2.6)。 - 视频通话 :
./configure-android --enable-video+ OpenH264 + Surface 挂 VideoPreview/VideoWindow;SDP 即 §1.6 的m=video ... H264/90000。 - 来电体验:ForegroundService + 振铃 / 亮屏 / 锁屏显示。
- 加固 :禁掉 chan_sip(3.9 / 3.12)、统一 TCP、打开
pjsip set logger on对照 §1.5。
3.12 实操踩坑实录(VMware 桥接 + Ubuntu 20.04 Desktop)
一次真实跑通 3.2→3.3 的完整记录(VMware Workstation 桥接并勾选"复制物理网络连接状态";宿主机局域网
192.168.2.0/23,网关192.168.2.1;虚拟机网卡ens33,桌面版归 NetworkManager 管)。桌面版配静态有 nmcli 与 netplan 文件两条路(见 3.2),本文坚持走了后者;服务器最终静态 IP 192.168.2.110(3.4 软电话、3.5 ConfigServer、3.6 App 的服务器地址均为它)。每行都是"现象 → 根因 → 解决",与上文各步行内提示互为印证。
| # | 阶段 | 现象 | 根因 | 解决 |
|---|---|---|---|---|
| 1 | 静态 IP | Desktop 上 netplan 怎么配都不生效;apply 报 Invalid YAML: did not find expected '-' indicator(按 文件:行:列 定位) |
目录混入 Server 安装器产物 00-installer-config.yaml;且 vi 未 :set paste,via: 行被自动缩进加深到 16 格------任何一份文件解析失败,整个 apply 就中止 |
rm 掉 00- 文件、只维护 01-network-manager-all.yaml 一份;:set paste 重粘;renderer: NetworkManager 写进 network: 块内;nmcli con show 删旧 DHCP 连接 "Wired connection 1" |
| 2 | 静态 IP | 静态地址已落(无 dynamic、valid_lft forever、默认路由 proto static),但虚拟机 ping 网关/外网全不通;宿主机↔虚拟机互 ping 却"正常";apt 报 Connection failed [IP: 91.189.91.x 80] |
IP 冲突 :所选静态 IP 与宿主机本机 IP 相同(实测 192.168.2.118 两边同配)。宿主机 ping 它是本机自答 ("ping 候选没人回=空闲"对宿主机本机 IP 失效);路由器 ARP 表在两机 MAC 间翻动,虚拟机回包全被宿主机截走;桥接下宿主↔虚拟机互 ping 走内部桥,证明不了物理链路;DHCP 时代一切正常也反证于此 | 选址前宿主机 ipconfig 排除自己 → 从宿主机 ping 候选(请求超时=空闲 )→ netplan 只改 addresses 一行重新 apply(实测换成 .110) |
| 3 | 静态 IP | 换地址后全部恢复 | --- | ping 网关 / ping 223.5.5.5 通 + apt update 成功 = 网络关通过 |
| 4 | 防火墙 | sudo ufw allow 9060/udp 9060/tcp 报 ERROR: Need 'to' or 'from' clause |
ufw 一条命令只认一个"端口/协议",第二个被当地址子句(本文早期笔误,已改) | 拆成两条(或 ufw allow 9060 = TCP+UDP);ufw status 显示 inactive 时防火墙本就没拦端口,放行规则 enable 后才生效 |
| 5 | 装机疑惑 | apt install asterisk 后 /etc/asterisk/ 就有一百多个 .conf |
deb 包 conffile 样例随包铺出(每个模块一个),不是运行时生成 ;dpkg -S 可查属主 |
只动 pjsip.conf(模板 + include)/ extensions.conf / rtp.conf;账号在生成文件 pjsip_accounts.conf;sip.conf 是老 chan_sip 栈、不碰 |
| 6 | 写配置 | VMware 控制台粘不进宿主机剪贴板;vi 粘长配置缩进易乱 | 控制台无剪贴板共享;vi 自动缩进 | 从宿主机 ssh 进虚拟机再粘贴;覆盖整文件用 sudo tee 文件 > /dev/null <<'EOF':> 续行符不落盘,顶格 EOF 回车即落盘(带缩进不识别、会一直停在 >);粘乱 Ctrl+C 干净放弃(heredoc 未收完时 tee 未启动,文件未动) |
| 7 | Asterisk CLI | 进了 ubuntu*CLI> 不知怎么退 |
asterisk -r 是连到运行中服务的远程控制台 |
敲 quit 回 shell,服务继续在后台跑 (停服务才 systemctl stop);Ubuntu 20.04 实装 Asterisk 16.2.1,res_pjsip 完备,满足本教程 |
| 8 | 自检 | pjsip show endpoints 里 1000/1001 都是 Unavailable |
无任何终端注册------本就是正常状态 | InAuth/Aor 各就位、无 WARNING 即配置正确;软电话/App 注册后变 Available 并出现 Contact 行;pjsip show transports 应见 udp/tcp 各绑 0.0.0.0:9060 |
| 9 | 软电话互拨(3.4) | CLI 报 Call from '' (宿主机IP:55051) to extension '1001' rejected ... context 'public',且 NOTICE 出自 chan_sip.c ;同屏却有 Endpoint 1000 is now Reachable,菜单确认激活账号正是 1000,编辑对话框可见 SIP 服务器 192.168.2.110:9060、代理为空------全对,呼叫仍打偏 |
INVITE 发到了 5060 的老 chan_sip 。MicroSIP 路由:REGISTER 走"SIP服务器"(带 :9060,故端点 Reachable),未填代理时呼叫 INVITE 按"域名"栏构造目标路由 ------域名栏填的是无端口 192.168.2.110,INVITE 落回默认 5060;chan_sip 匹配不到 peer 按匿名呼叫落进自带 sip.conf 的 context=public。排查路径:先疑激活账号不对(✓ 确在 1000,排除)→ 再疑代理栏误填(实为空,排除)→ 最后逐字段比对对话框才锁定域名栏 |
编辑账号:域名也填 192.168.2.110:9060 (或清空,缺省沿用 SIP 服务器整串),两个账号都改;(可选)modules.conf 加 noload => chan_sip.so 禁老栈让打错端口直接失败 |
| 10 | 软电话互拨(3.4) | 账号对调后(Linphone=1001、MicroSIP=1000 )出现单向通 :1001 呼 1000 正常,1000 呼 1001 秒断;CLI 依旧 NOTICE chan_sip.c handle_request_invite: Call from '' (宿主机IP:5061) to extension '1001' rejected ... context 'public',且没有 Executing [1001@from-internal...] 拨号计划行 |
第 9 行问题在对调账号后复发 :MicroSIP 现用的 1000 账号是重建/换用的,域名栏又填回无端口的 192.168.2.110 ------REGISTER 走"SIP服务器"(带端口)照常成功、端点照常 Available(所以能被 呼),INVITE 按"域名"路由又落 5060 被拒;反向通是因为 Linphone 的域带端口、注册呼叫全走 9060。判别要领:NOTICE 出自 chan_sip.c 且无 Executing 行 = 打错端口,与"对方不在线/防火墙拦被叫"等猜测无关 |
编辑激活账号 1000:域名=192.168.2.110:9060 (或清空);每次新增/重建/对调 MicroSIP 账号都重新核对域名栏 。同坑两次,建议直接加固:sudo sed -i '/^\[modules\]/a noload => chan_sip.so' /etc/asterisk/modules.conf && sudo systemctl restart asterisk------5060 彻底关闭,打错端口立刻无响应失败,不再有迷惑性报错(重启后 CLI 需重连、pjsip set logger on 重开) |
| 11 | 业务库(3.3) | mysql < /root/sip-lab/schema.sql 报 No such file or directory;ls ~/sip-lab 只有 ConfigServer.java |
仓库 sip-lab/ 在宿主机,虚拟机不会自动出现;root 提示符停在 /home/.../Desktop 时,~ 仍是 /root,桌面路径与家目录不是一回事 |
echo $HOME、ls -l /root/sip-lab;用宿主机 scp 或 3.3 的 tee ... <<'TEE' 写入 schema.sql / sip-lab-sync.sh(顶格 TEE 收尾),先 ls 再 mysql < |
| 12 | ConfigServer(3.5) | 源文件已覆盖,curl 无 ?number= 仍 200 |
旧 java 没停;或 Java 8 吃到旧 .class;或没带 -cp |
pkill → rm ConfigServer.class → 3.5.1/3.5.2 带 -cp 启动;无 number 必须 400 |