从零搭建 SIP 全栈通话系统:SIP 协议 + PJSIP + Asterisk + Android 实战

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 服务端挑战:realmnonceqop
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.confdefault_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 带 offerm= 列出 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:
  • 本教程主链路为明文 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_rportrewrite_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 PLISIP 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 installapt 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 才是被提升的一侧) 文件写不进 去掉 sudotee /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(whoamiroot)则删掉行首 sudo

  1. 环境:VirtualBox 7.x 或 VMware Workstation + Ubuntu Server 20.04/22.04 LTS(最小安装,2C/2G/20G 足够)。

  2. 网络模式选择

    模式 真机可达 模拟器可达 宿主机软电话可达 建议
    桥接 Bridged ✅(同一 WiFi) 推荐
    仅主机 Host-Only ✅(经宿主机栈) 仅模拟器/宿主机测试
    NAT ❌(需逐端口转发) 不推荐

    VMware 桥接模式要点(虚拟机设置 → 网络适配器):

    • 网络连接选 "桥接模式(B):直接连接物理网络"
    • 必须勾选"复制物理网络连接状态§"------该复选框只在选中桥接模式后才可点击(未选桥接时灰显)。作用:主机在有线网卡与无线 WiFi 之间切换(如笔记本拔网线转 WiFi)时,虚拟机自动跟随绑定当前活跃的物理网卡;不勾的话桥接可能绑到已断开的网卡上,虚拟机拿不到 DHCP/不通网;
    • "设备状态"区确认 "已连接©""启动时连接(O)" 均已勾选------"已连接"未勾时虚拟机内根本看不到网卡设备;
    • 如需固定桥接到某块物理网卡(多网卡主机):选"自定义(U)"→ VMnet0,再点"高级(V)..."设置,或用"虚拟网络编辑器"(vmnetcfg)指定桥接网卡。
  3. 静态 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: falseaddresses 会覆盖默认文件的同键)。
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 routedefault via 后的地址填进 routes),否则虚拟机上不了外网、后续 apt install asterisk 失败。网关属于网段不属于设备------桥接下虚拟机与宿主机同网段,网关就是同一台局域网路由器 :虚拟机 ip routedefault via 与宿主机 ipconfig 的"默认网关"应完全一致,两处互相核对(宿主机多网卡时看 IPv4 与虚拟机同网段的那块);④ 宿主机换网(公司↔家)后虚拟机要同步改 addresses 与网关。Host-Only 则固定用虚拟网段(VMware VMnet1 / VirtualBox 192.168.56.x),无需网关,不受宿主机换网影响------这也是它适合当"稳定实验台"的原因。

  1. 防火墙 (实验环境最省事 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 后才生效)
  1. 连通性自检 (宿主机执行):ping 192.168.2.110nc -zv 192.168.2.110 9060;模拟器内用 adb shell 同样测试。

  2. 宿主机 ↔ 虚拟机传文件 (仓库 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 窗口:

  1. 虚拟机 → 设置选项共享文件夹
  2. 总是启用
  3. 添加 :宿主机目录例如 D:\Android\progect\iConvNet_Android(或单独建 D:\vm-share),名称填 share(不要空格)
  4. 勾选 启用此共享 、建议再勾 在客户机中映射为网络驱动器(若有此项)
  5. 确定。虚拟机若已开机,有时要 挂起再恢复 或重启后 /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 -rcp -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-toolsvmhgfs-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 生成 #includepjsip_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.5ping 不通 = 本机网络问题 (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.confapt 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.confastetcdir => /etc/asteriskres_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@...#)下面命令可去掉 sudoecho $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.110ip -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 0pjsip 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------出问题时才能快速定位是哪一侧。

  1. 宿主机安装 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 视频验证。
  2. 各注册一个账号(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.12 context '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、密码=sip1000SIP 域=192.168.2.110:9060(★ 域必带端口------Linphone 把"域"当注册服务器兼呼叫路由,不带端口 REGISTER 直发 5060 必失败;好在它注册/呼叫都走域,没有 MicroSIP 那种"服务器/域名分叉"问题) 、显示名称随意;传输默认 UDP 即可(代理留空自动=域);向导没弹或填错:设置→账户→添加账户→SIP 账户。MicroSIP 侧同步整理 :删掉 MicroSIP 里的 1000 账号(☰→先切到 1000→删除账号),保持 1000=Linphone、1001=MicroSIP 一一对应------两边同时注册 1000 时(aor max_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 OKpjsip show endpoints 对应端点变 Available 并出现 Contact 行。
  3. Asterisk CLI 观察:<-- Registration successpjsip show contacts 应列出两条 Contact。
  4. MicroSIP ↔ Linphone 互拨:确认振铃、双向语音;Linphone 侧再开视频确认 H264 透传。
  5. 全程 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 按 numbersip_account + sip_cluster;POST 写库后跑 sip-lab-sync

拉号 GET /api/v1/sip-server?number=1000200 JSON(缺 number → 400;无此号 → 404)
开通 POST /api/v1/sip-accounts{"number":"1002","password":"sip1002"} → 写库并 sync
其它方法 405

源码:<sip-lab/ConfigServer.java>。依赖 3.3 已装的 libmariadb-javaserverip 改库,不改 Java。生产再加登录态 / HTTPS。

本节先走 Java 17(主路径,实验室已跑通) ,再走 Java 8(同一套 curl)。必须带 JDBC 的 -cp

3.5.0 落盘 ConfigServer.java

schema.sqlsip-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 换成 whoamiecho $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.serviceUser / 目录按 whoami$HOME 改。停:sudo systemctl stop sip-config(sudo:停系统服务;已是 root → systemctl stop sip-config)。

跑不起来时

现象 原因 处理
java: command not found 未装运行时 openjdk-8-jdk-headlessopenjdk-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 写死在源码里:不要两套密码并存。

  1. pkill -f 'java ConfigServer',确认 8080 空。
  2. 按 3.3 做完库 + sip-lab-syncpjsip.conf 删掉手写账号段 ,只留模板 + #include
  3. 按 3.5.0 覆盖 ConfigServer.javagrep Driver 必须有)。
  4. 3.5.1 Java 17 (或 3.5.2 的 8)启动;curl 无 number 必须 400
  5. 软电话密码改成库里的 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

  1. 系统依赖 (已是 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
  1. 下载官方 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
  1. 解压到 /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"

pjprojectconfigure-androidANDROID_NDK_ROOT (有的文档写 ANDROID_NDK,本教程只用前者)。路径必须是解压后的根目录 (能直接看到 ndk-build),不要指到 toolchains 里面。

  1. 过关
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
unzipEnd-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_ABIx86_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):

  1. 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 步 共享目录或 scpjniLibs 整目录拷到宿主机。
  2. 绑定源码整包 .../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 引用 EpConfigAccountConfig 引用 AuthCredInfo......互相 import)。少拷一个,编译期就是 cannot find symbol,或运行期 NoClassDefFoundError。你自己只写 3.6.3 那 4 个类,真正用到的 API 只有十来个;其余文件躺在工程里即可,不要改、不必读。按需是「业务代码调哪些方法」,不是「绑定源码留哪些文件」。
  3. app/build.gradle 关键配置:
groovy 复制代码
android {
    defaultConfig {
        minSdk 24
        ndk { abiFilters "armeabi-v7a", "arm64-v8a" }   // 与 jniLibs 一致
    }
}
// 无任何第三方依赖:SIP/媒体/RTP 全部在 libpjsua2.so 内
  1. AndroidManifest.xml 权限:INTERNETACCESS_NETWORK_STATERECORD_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.xmlSipConfigClient.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 CLIpjsip 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 swigswig -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.confnoload => 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.sqlNo such file or directory 虚拟机上还没有该文件;或把桌面路径当成了 ~/sip-lab 见 3.3:echo $HOMEls -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.BASEip -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 进阶方向

  1. 登录态拉号 :配置服务按当前登录用户查号,App 不再手传 ?number=;HTTPS,密码不长期明文落盘。
  2. 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=tlsTransportConfig.tlsConfig 可配 CA)。
  3. SRTP :Asterisk 装 res_srtp,endpoint media_encryption=sdes;pjsua2 开 AccountMediaConfig.srtpUse(§2.6)。
  4. 视频通话./configure-android --enable-video + OpenH264 + Surface 挂 VideoPreview/VideoWindow;SDP 即 §1.6 的 m=video ... H264/90000
  5. 来电体验:ForegroundService + 振铃 / 亮屏 / 锁屏显示。
  6. 加固 :禁掉 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 pastevia: 行被自动缩进加深到 16 格------任何一份文件解析失败,整个 apply 就中止 rm 掉 00- 文件、只维护 01-network-manager-all.yaml 一份;:set paste 重粘;renderer: NetworkManager 写进 network: 块内;nmcli con show 删旧 DHCP 连接 "Wired connection 1"
2 静态 IP 静态地址已落(无 dynamicvalid_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/tcpERROR: 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.confsip.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.confnoload => 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.sqlNo such file or directoryls ~/sip-lab 只有 ConfigServer.java 仓库 sip-lab/ 在宿主机,虚拟机不会自动出现;root 提示符停在 /home/.../Desktop 时,~ 仍是 /root,桌面路径与家目录不是一回事 echo $HOMEls -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 pkillrm ConfigServer.class → 3.5.1/3.5.2 带 -cp 启动;无 number 必须 400
相关推荐
猿与禅16 小时前
Linux(Ubuntu)入门到实战:系统管理、常用命令与权限体系完整指南
linux·ubuntu·apt·进程管理·系统运维·用户权限·vi/vim
cui_hao_nan1 天前
从零搭建 Kubernetes 集群:Ubuntu + kubeadm 完整实战
ubuntu·容器·kubernetes
金色熊族1 天前
windows11 + wsl2 上安装ubuntu20.04
ubuntu·windows11·wsl2
WX _ jishuwu19901 天前
esmfold gpu模式 批量预测蛋白三级结构实况记录 - 供参考
人工智能·ubuntu·三代测序·亚细胞定位,·deeploc·protcompv9
微软技术分享1 天前
基于 HuggingFace Tokenizers 训练自定义分词器
python·ubuntu·大模型·huggingface
皓月盈江1 天前
Linux系统使用VSCode搭建GO开发环境
linux·vscode·ubuntu·golang·debian
CHHH_HHH1 天前
【Linux系统篇】进程间通信揭秘:从管道到共享内存
linux·服务器·c语言·开发语言·后端·ubuntu
小此方2 天前
告别云服务器连接困扰——Windows下利用VirtualBox安装配置Ubuntu虚拟机详细教程
服务器·windows·ubuntu
Soari3 天前
Linux 内核对比:PREEMPT_DYNAMIC vs PREEMPT_RT
linux·运维·ubuntu