由于内地各种互联网服务与手机强绑定的前提下,每个人手上的手机号码变得越来越多。在互联网上,早已有包括telegram-sms ,SMS-forwarder 等不同的应用被用来解决不想随身带着某张手机卡,却还需要拿他接收发送短信的场景。不过美中不足的是,由于这些应用均需要安装在手机上,这些短信转发应用均存在因国产android系统严格的后台限制被休眠导致无法转发短信的情况。同时,将带电池的旧手机长期插电也有一些安全隐患(电池鼓包等)。最重要的是,这些短信转发转发软件无法转移呼入和呼出的电话。为了解决上述的这些问题,在本文中,笔者基于EC20和东拼西凑的软件,实现了通过telegram等即时通讯软件收发短信,并通过SIP客户端从互联网呼出和接听电话。
笔者调研的其他方案 多卡宝 一到两年前非常流行的SIM卡托管方案,可以插4张卡,并且同时可待机两张卡,使用自有app转发短信及通话。根据FCCID的PDF ,其使用了高通Snapdragon 210 处理器,其常见于一些4G老人机上,鉴于此,笔者怀疑多卡宝使用了魔改的android系统。但在2021年下半年,多卡宝疑似因监管原因(可能是被用于电信诈骗?)被国内电商下架,并且因短信语音均需要经过三方服务器,也有一些安全隐患。
GOIP设备 俗称猫池,设备太贵,笔者负担不起。
材料及成本 EC20及周边设备
移远出品的一款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自助收发短信 以下脚本都是用chatgpt写的,笔者用起来暂时没有遇到明显的问题:
发短信 Systemd service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 [Unit] Description=Outbound SMS service for TG-SMS Wants=network-online.target After=network-online.target [Service] Type=simple ExecStart=/opt/tg-sms/outbound/.venv/bin/python3 /opt/tg-sms/outbound/outbound-sms.py Restart=on-failure RestartSec=5 User=asterisk [Install] WantedBy=multi-user.target
脚本(需要安装老版本的python-telegram-bot pip install python-telegram-bot==13.15
):
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 from telegram import Update, ForceReplyfrom telegram.ext import Updater, CommandHandler, MessageHandler, Filters, CallbackContextuser_data = {} ALLOWED_IDS = [11111111 ] def send (update: Update, context: CallbackContext ) -> None : if update.message.chat_id not in ALLOWED_IDS: update.message.reply_text('您没有权限使用这个 bot。' ) return if len (context.args) != 1 : update.message.reply_text('请按照以下格式发送命令:/send <phone_number>' ) return user_data[update.message.chat_id] = {'phone_number' : context.args[0 ]} update.message.reply_text('请输入您要发送的信息:' , reply_markup=ForceReply()) def cancel (update: Update, context: CallbackContext ) -> None : user_data.pop(update.message.chat_id, None ) update.message.reply_text('操作已取消。' ) def handle_message (update: Update, context: CallbackContext ) -> None : if update.message.chat_id not in user_data: return if 'message' not in user_data[update.message.chat_id]: user_data[update.message.chat_id]['message' ] = update.message.text update.message.reply_text('请确认信息:\n手机号:{}\n信息:{}' .format (user_data[update.message.chat_id]['phone_number' ], user_data[update.message.chat_id]['message' ]), reply_markup=ForceReply()) else : confirmation = update.message.text if confirmation.lower() == 'yes' : import os result = os.popen('asterisk -rx \'quectel sms quectel0 {} "{}"\'' .format (user_data[update.message.chat_id]['phone_number' ], user_data[update.message.chat_id]['message' ])).read() update.message.reply_text('命令执行结果:\n{}' .format (result)) else : update.message.reply_text('操作已取消。' ) user_data.pop(update.message.chat_id, None ) def main () -> None : updater = Updater(token='your_token' , use_context=True ) dispatcher = updater.dispatcher dispatcher.add_handler(CommandHandler("send" , send)) dispatcher.add_handler(CommandHandler("cancel" , cancel)) dispatcher.add_handler(MessageHandler(Filters.text & ~Filters.command, handle_message)) updater.start_polling() updater.idle() if __name__ == '__main__' : main()
收短信 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 import requestsimport sysimport urllib.parseimport redef extract_verification_code (sms_text ): keyword_index = sms_text.find('验证码' ) if keyword_index == -1 : return None match = re.search(r'(\d{4,6})' , sms_text[keyword_index:]) if match : return match .group(1 ) return None tg_bot_token = "" 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" wecom_send_msg_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key" bark_send_msg_url = "https://api.day.app/bark-token/" def incoming_sms_tg (msg_text ): send_body = {} send_body['chat_id' ] = '11111111' send_body['text' ] = msg_text requests.post(tg_send_msg_url, json=send_body) def incoming_sms_wecom (msg_text ): send_body = {} send_body['msgtype' ] = 'text' send_body['text' ] = {} send_body['text' ]['content' ] = msg_text requests.post(wecom_send_msg_url, json=send_body) def incoming_sms_bark (msg_text ): requests.get(bark_send_msg_url + urllib.parse.quote_plus(msg_text) + "?copy=" + extract_verification_code(msg_text)) def incoming (): msg_text = sys.argv[1 ] incoming_sms_wecom(msg_text) incoming_sms_bark(msg_text) incoming_sms_tg(msg_text) incoming()
安装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虚拟机里的号码撞上了就行):
剩下部分保持默认,点submit,并点击一下右上角的apply config 。
添加Trunk 添加之前,先按照前面的帖子的指引,修改asterisk虚拟机里的/etc/asterisk/sip.conf
,把最底下70分机的host=192.168.x.x
改成freepbx虚拟机的IP,重启asterisk。
在freepbx的Connectivity-Trunks里添加一个SIP Trunk,配置如下,其他默认:
路由 在Connectivity-Outbound Routes里,新建路由,将出方向的路由都转发给前一步创建的SIP trunk:
在Connectivity-Inbound Routes里,新建路由,将入方向的路由都转发给extensions-上面设置的分机号:
如果未来连接了多个分机或者多个dongle,需要根据用户进行分流的话,可以详细配置上面的DID和CallerID来进行过滤。
测试通话 下载一个免费版的zoiper ,添加账户的时候用户名输入分机号@freepbx的IP
,密码即上面设置的密码(注意不要输错了,freepbx默认有打开fail2ban,输错SIP密码也会触发fail2ban,还需要手动去删除iptables规则)。
确认注册上了之后可以尝试通过zoiper呼出到10010或者是自己的电话,测试一下语音和按键的DTMF音有被识别到。如果是外部呼入dongle里的号码的电话,呼入到freepbx之后会被直接转移给分机,此时zeoiper会有提示,直接点接听即可。
使用模块上网 如果使用的手机卡包含流量,我们也可以一并配置模块的上网功能,以便在机器的有线/无线网络挂到后,还能正常的转发短信和通话。
首先,在连上串口后输入AT+QCFG="usbnet",1
,设置usbnet模式为ECM (1是ECM,2是NDIS,3是RNDIS)
接着,用AT+CFUN=1,1
重启一下模块,随后查看ip a
,应该能看到一个类似enxe2eeabcd123
的接口,在这个接口上直接运行dhclient即可获取v4或v6地址。如果怕interface的名字经常变化,可以参考这个问题 ,把interface的名字根据MAC地址重命名成quectel-usbX
这样方便管理的名字。
1 2 3 4 5 6 7 8 3: quectel-usb0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 1000 link/ether ff:ff:ff:ff:ff:ff brd ff:ff:ff:ff:ff:ff inet 192.168.225.41/24 brd 192.168.225.255 scope global dynamic quectel-usb0 valid_lft 28802sec preferred_lft 28802sec inet6 2408::/64 scope global dynamic mngtmpaddr valid_lft forever preferred_lft forever inet6 fe80::/64 scope link valid_lft forever preferred_lft forever
其他 在不打开SIP客户端时,打到dongle上的电话会提示用户忙,理论上我们可以在freepbx上设置等待时间,并通过dialplan让dongle在接打电话时调用System触发脚本通知用户,用户打开SIP客户端即可接听电话。 参考资料 EC20的AT指令集可以在https://www.quectel.com/ProductDownload/EC20.html 处下载。 asterisk上的Quectel驱动: https://github.com/IchthysMaranatha/asterisk-chan-quectel/ https://gao.md/blog/2014/10/05/sms-gateway-setup-huawei-e1750-asterisk-chan-dongle/ https://forums.quectel.com/t/ec25-e-mini-additional-mbn-files/13473/2