使用EC20模块配合asterisk及freepbx实现短信转发和网络电话

由于内地各种互联网服务与手机强绑定的前提下,每个人手上的手机号码变得越来越多。在互联网上,早已有包括telegram-smsSMS-forwarder等不同的应用被用来解决不想随身带着某张手机卡,却还需要拿他接收发送短信的场景。不过美中不足的是,由于这些应用均需要安装在手机上,这些短信转发应用均存在因国产android系统严格的后台限制被休眠导致无法转发短信的情况。同时,将带电池的旧手机长期插电也有一些安全隐患(电池鼓包等)。最重要的是,这些短信转发转发软件无法转移呼入和呼出的电话。为了解决上述的这些问题,在本文中,笔者基于EC20和东拼西凑的软件,实现了通过telegram等即时通讯软件收发短信,并通过SIP客户端从互联网呼出和接听电话。


笔者调研的其他方案

多卡宝

一到两年前非常流行的SIM卡托管方案,可以插4张卡,并且同时可待机两张卡,使用自有app转发短信及通话。根据FCCID的PDF,其使用了高通Snapdragon 210处理器,其常见于一些4G老人机上,鉴于此,笔者怀疑多卡宝使用了魔改的android系统。但在2021年下半年,多卡宝疑似因监管原因(可能是被用于电信诈骗?)被国内电商下架,并且因短信语音均需要经过三方服务器,也有一些安全隐患。

GOIP设备

俗称猫池,设备太贵,笔者负担不起。

材料及成本

EC20及周边设备

ec20-taobao

移远出品的一款4G卡,支持LTE Cat4,使用Snapdragon X5 LTE Modem,这个卡有很多个版本,有部分版本只带上网功能,不能接打电话和发短信。如果需要收发短信和打电话,请尽量购买最高级的EC20CEFAG-512-SGNS,买mini-pcie接口的 ,移远的淘宝店买大概200一片,闲鱼购买大约50-60一片。

每张卡还需要另外加大约25元买一张4G模块转接板Mini PCIE转USB的卡座,卡座上有插SIM的地方(卡座一般用的是Mini Sim,如果只有nanosim的话可以去买一个卡套)。在淘宝上搜索「4G模块转接板Mini PCIE转USB」即可。

除此之外,还需购买若干根IPEX转SMA转接线及SMA接口的4G天线,淘宝上搜相应关键词即可。

电脑主机

要求不高,一般只要有usb口和有线网口即可,建议安装pve或esxi等虚拟机管理系统。(树莓派不一定行,因为供电不足)。笔者单独去闲鱼上买了一台Optiplex 3050mff,配i3-6100T,8G内存,256G SSD,大概花费了600元。

配置EC20

确认EC20能够正常读取SIM卡

关闭SIM卡的PIN,插入卡座,把EC20接上天线并通电,此时应该可以在/dev里看到若干个ttyUSB端口:

1
2
3
4
ttyUSB0
ttyUSB1 PCM语音,GPS信号
ttyUSB2 控制命令
ttyUSB3

使用minicom打开ttyUSB2端口:

1
2
3
4
5
6
7
8
minicom -D /dev/ttyUSB2


# 输入ATI看一下EC20的版本号:
ATI
Quectel
EC20F
Revision: EC20CEFAGR06A15M4G

如果一切正常的话,可以先重置一遍EC20,以防上一个用户在卡内设置了错误的配置(但不要经常重置EC20,重置操作对dongle的闪存有损耗)。

1
2
重置模块 at+qprtpara=3
重启 AT+CFUN=1,1

重置并重启完后,可以通过以下命令检查一下SIM卡是否已经注册成功了(下面的例子是联通的,其他运营商同理):

1
2
3
4
5
6
7
8
AT+COPS? 
+COPS: 0,0,"CHN-UNICOM",7

AT+QNWINFO
+QNWINFO: "FDD LTE","46001","LTE BAND 3",1650

AT+QENG="servingcell"
+QENG: "servingcell","CONNECT","LTE","FDD",460,01

配置VoLTE

随着三大运营商开始逐步退网2G及3G,为dongle配置VoLTE也变得十分必要了。以下流程参考了此PDF

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
打开ims AT+QCFG="ims",1

查看dongle内的mbn文件 AT+QMBNCFG="List"
+QMBNCFG: "List",0,1,1,"ROW_Generic_3GPP",0x05010824,201806201
+QMBNCFG: "List",1,0,0,"OpenMkt-Commercial-CU",0x05011510,201911151
+QMBNCFG: "List",2,0,0,"OpenMkt-Commercial-CT",0x0501131C,201911141
+QMBNCFG: "List",3,0,0,"Volte_OpenMkt-Commercial-CMCC",0x05012011,201904261

# 尽管这里列出了移动联通电信的VoLTE配置文件,但使用默认的自动选择CU/CT/CMCC并不能注册VoLTE,在摸索很久之后,笔者发现需要强制选择ROW_Generic_3GPP才能成功注册VoLTE。

关闭自动选择mbn文件 AT+QMBNCFG="AutoSel",0
反激活当前的mbn at+qmbncfg="deactivate"

强制选择3gpp AT+QMBNCFG="select","ROW_Generic_3GPP"
重启 AT+CFUN=1,1

可以再确认一下mbn的选择状态,如果ROW_Generic_3GPP的第二位和第三位都是1的话,说明dongle目前选择了这个配置 AT+QMBNCFG="List"
+QMBNCFG: "List",0,1,1,"ROW_Generic_3GPP",0x05010824,201806201
+QMBNCFG: "List",1,0,0,"OpenMkt-Commercial-CU",0x05011510,201911151
+QMBNCFG: "List",2,0,0,"OpenMkt-Commercial-CT",0x0501131C,201911141
+QMBNCFG: "List",3,0,0,"Volte_OpenMkt-Commercial-CMCC",0x05012011,201904261

重启完后检查ims的状态 AT+QCFG="ims"

如果返回的是 +QCFG: "ims",1,1 即为激活,如果是+QCFG: "ims",1,0 说明没有激活

可选(激活UAC数字音频)

参考https://github.com/IchthysMaranatha/asterisk-chan-quectel/discussions/2

1
AT+QCFG="usbcfg",0x2C7C,0x0125,1,1,1,1,1,0,1

这个命令同时也会打开一个adb daemon,可以通过adb shell进到模块自带的一个android系统里。如果发现系统有密码的话(有无密码均不影响后续配置),可以用adb pull把dongle的/etc/passwd拉出来,去掉root的密码之后push回去。

激活之后可以通过aplay -L查看有没有一个android设备。

1
2
3
dmix:CARD=Android,DEV=0
Android, USB Audio
Direct sample mixing device

安装asterisk虚拟机

freepbx自带的asterisk耦合了很多freepbx相关的配置,令笔者无从下手,同时centos似乎没有办法读取dongle的UAC接口,因此笔者参考quectel channel驱动作者的一篇讨论配置了一个简易的asterisk系统。笔者在这里使用的是Debian11,安装的是包管理器里自带的asterisk 16。安装asterisk之后记得把dongle的USB接口直通进虚拟机。

安装asterisk和一些依赖

1
2
apt update
apt install asterisk asterisk-dev adb git autoconf automake libsqlite3-dev build-essential libasound2-dev alsa-utils

下载asterisk-chan-quectel

1
2
3
4
5
6
7
git clone https://github.com/IchthysMaranatha/asterisk-chan-quectel
cd asterisk-chan-quectel

./bootstrap
./configure --with-astversion=16
make
make install

随后把uac/quectel.conf复制到/etc/asterisk里。并通过systemctl restart asterisk重启asterisk。

输入asterisk -rvvv进入asterisk的cli界面并输入quectel show devices即可看到识别到的dongle了,也能看到dongle的imei和SIM卡的imsi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# asterisk -rvvv
Asterisk 16.16.1~dfsg-1+deb11u1, Copyright (C) 1999 - 2018, Digium, Inc. and others.
Created by Mark Spencer <[email protected]>
Asterisk comes with ABSOLUTELY NO WARRANTY; type 'core show warranty' for details.
This is free software, with components licensed under the GNU General Public
License version 2 and other licenses; you are welcome to redistribute it under
certain conditions. Type 'core show license' for details.
=========================================================================
Connected to Asterisk 16.16.1~dfsg-1+deb11u1 currently running on debian-asterisk (pid = 1403)
debian-asterisk*CLI> quectel show devices
ID Group State RSSI Mode Submode Provider Name Model Firmware IMEI IMSI Number
quectel0 0 Free 27 0 0 CHN-UNICOM EC20F EC20CEFAGR06A15M4 86XXXXX 46XXXXX Unknown
debian-asterisk*CLI>

配置dialplan

直接参考驱动作者写的帖子,下载帖子里的sipext.zip,解压后放到/etc/asterisk下,同时修改一下/etc/asterisk/extensions.conf(请不要直接照抄!根据自己的实际情况和驱动作者的帖子修改):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[incoming-mobile]
;exten => _.,1,Dial(SIP/70/100)
;same => n,Hangup()
exten => sms,1,Verbose(Incoming SMS from ${CALLERID(num)} ${BASE64_DECODE(${SMS_BASE64})})
;store
exten => sms,n,System(echo '${STRFTIME(${EPOCH},,%Y-%m-%d %H:%M:%S)} - ${QUECTELNAME} - ${CALLERID(num)}: ${BASE64_DECODE(${SMS_BASE64})}' >> /var/log/asterisk/sms.txt)
;for tg bot use
exten => sms,n,System(echo '${STRFTIME(${EPOCH},,%Y-%m-%d %H:%M:%S)} - ${QUECTELNAME} - ${CALLERID(num)}\n${BASE64_DECODE(${SMS_BASE64})}' >> /var/log/asterisk/unread_sms/${STRFTIME(${EPOCH},,%Y%m%d%H%M%S)}-${CALLERID(num)}.txt)
exten => sms,n,Hangup()

exten => ussd,1,Verbose(Incoming USSD: ${BASE64_DECODE(${USSD_BASE64})})
exten => ussd,n,System(echo '${STRFTIME(${EPOCH},,%Y-%m-%d %H:%M:%S)} - ${QUECTELNAME}: ${BASE64_DECODE(${USSD_BASE64})}' >> /var/log/asterisk/ussd.txt)
exten => ussd,n,Hangup()

exten => _.,1,Dial(SIP/70/100)
exten => s,n,Hangup()


[Outbound-1001]
exten => _.,1,Dial(Quectel/quectel0/${EXTEN})
same => n,Hangup()

修改完后再重启一次asterisk。

测试收发短信

此时可以尝试发个短信给10010,测试一下收发短信:

发短信(给10010发cxll)

1
asterisk -rx 'quectel sms quectel0 10010 "cxll"'

收短信

根据前面的dialplan,收到短信后,asterisk会直接把短信内容写进/var/log/asterisk/sms.txt,类似于这样:

1
2
3
tail -f /var/log/asterisk/sms.txt

2022-10-01 00:00:00 - quectel0 - 10010: 【权益领取提醒】尊敬的用户,您已获得2个月视频会员体验资格(原价15元/月),腾讯、爱奇艺、优酷、芒果TV、QQ音乐等20款会员每月任选1款,点击 https://u.10010.cn 即可参与(活动规则详见页面说明,限48小时内参与,短信转发无效,已办理请忽略)。如需屏蔽,请在“广东联通”官方公众号内回复“TD”,首次关注可领4GB流量【广东联通】

如果需要进一步转发短信,直接读取这个文件,或者修改dialplan中输出短信的格式即可。

以下是一个非常简陋的,用于通过telegram bot收发短信的python脚本(请自行修改token和chat id。脚本未经过完整测试,可能会有没有充分考虑的case,请谨慎使用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import os
import requests
import json
import time
import schedule

tg_bot_token = "TOKEN"
sms_log_dir = "/var/log/asterisk/unread_sms/"
tg_send_msg_url = "https://api.telegram.org/bot"+tg_bot_token+"/sendMessage"
tg_receive_msg_url = "https://api.telegram.org/bot"+tg_bot_token+"/getUpdates"
tg_receive_msg_init_count = 0


#iterate all files in sms_log_dir
def receive_sms():
print("recv poll start")
for file in os.listdir(sms_log_dir):
f = open(sms_log_dir+file, "r")
sms_text = f.read()
send_body = {}
send_body['chat_id'] = "CHAT ID"
send_body['text'] = sms_text
requests.post(tg_send_msg_url, json=send_body)
os.remove(sms_log_dir+file)


def update_tg_receive_msg_init_count():
new_msg = requests.get(tg_receive_msg_url).json()
if new_msg['result']:
with open('processed_update_id.txt', 'w') as f:
f.write(str(new_msg['result'][-1]['update_id']))

def send_sms():
print("send poll start")
f = open('processed_update_id.txt', "r")
processed_update_id = f.read()
new_msg = requests.get(tg_receive_msg_url).json()
if new_msg['result']:
for i in new_msg['result']:
if i['update_id'] > int(processed_update_id):
with open('processed_update_id.txt', 'w') as g:
g.write(str(i['update_id']))
if ('text' in i['message']):
command = str(i['message']['text'])
#/sendsms quectel0 10010 cxll
if command.startswith('/sendsms'):
command_list = command.split(' ')
if len(command_list) == 4:
dongle = command_list[1]
dest = command_list[2]
context = command_list[3]
#asterisk -rx 'quectel sms quectel0 10010 "cxll"'
asterisk_command = "asterisk -rx " + "'quectel sms " + dongle + " " + dest + " " + context + "'"
print(asterisk_command)
os.system(asterisk_command)

schedule.every(5).seconds.do(send_sms)
schedule.every(5).seconds.do(receive_sms)

update_tg_receive_msg_init_count()
while True:
schedule.run_pending()
time.sleep(1)

安装freepbx虚拟机

去freepbx官网上下载freepbx的iso镜像(看起来是一个CentOS7):https://www.freepbx.org/downloads/。使用镜像安装系统,安装时选择`freepbx 16 with asterisk 18`。安装完后用浏览器访问虚拟机的IP,设置初始的管理员密码(最开始可以暂不打开防火墙,方便配置)。

添加分机号

在 Applications-Extensions 里,点击add extension- SIP extension,加一个200的extension(号码随意,只要不和asterisk虚拟机里的号码撞上了就行):

添加 sip extensions

剩下部分保持默认,点submit,并点击一下右上角的apply config

添加Trunk

添加之前,先按照前面的帖子的指引,修改asterisk虚拟机里的/etc/asterisk/sip.conf,把最底下70分机的host=192.168.x.x改成freepbx虚拟机的IP,重启asterisk。

在freepbx的Connectivity-Trunks里添加一个SIP Trunk,配置如下,其他默认:

名字随意,outbound CallerID改成asterisk虚拟机那边设置的数值(70)
SIP server要改成asterisk虚拟机的IP

路由

在Connectivity-Outbound Routes里,新建路由,将出方向的路由都转发给前一步创建的SIP trunk:

outbound-route

在Connectivity-Inbound Routes里,新建路由,将入方向的路由都转发给extensions-上面设置的分机号:

inbound-route

如果未来连接了多个分机或者多个dongle,需要根据用户进行分流的话,可以详细配置上面的DID和CallerID来进行过滤。

测试通话

下载一个免费版的zoiper,添加账户的时候用户名输入分机号@freepbx的IP,密码即上面设置的密码(注意不要输错了,freepbx默认有打开fail2ban,输错SIP密码也会触发fail2ban,还需要手动去删除iptables规则)。

确认注册上了之后可以尝试通过zoiper呼出到10010或者是自己的电话,测试一下语音和按键的DTMF音有被识别到。如果是外部呼入dongle里的号码的电话,呼入到freepbx之后会被直接转移给分机,此时zeoiper会有提示,直接点接听即可。

其他

  • 在不打开SIP客户端时,打到dongle上的电话会提示用户忙,理论上我们可以在freepbx上设置等待时间,并通过dialplan让dongle在接打电话时调用System触发脚本通知用户,用户打开SIP客户端即可接听电话。

参考资料