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


笔者调研的其他方案

多卡宝

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

GOIP设备

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

材料及成本

EC20及周边设备

ec20-taobao
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端口:

ttyUSB0
ttyUSB1 PCM语音,GPS信号
ttyUSB2 控制命令
ttyUSB3

使用minicom打开ttyUSB2端口:

minicom -D /dev/ttyUSB2


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

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

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

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

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

打开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

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设备。

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和一些依赖

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

下载asterisk-chan-quectel

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:

# 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(请不要直接照抄!根据自己的实际情况和驱动作者的帖子修改):

[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)

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

收短信

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

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:

[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):

from telegram import Update, ForceReply
from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, CallbackContext

# We'll use a dictionary to store each user's state.
user_data = {}

# List of allowed user/group IDs.
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()

收短信

import requests
import sys
import urllib.parse
import re

# extract verify code
def extract_verification_code(sms_text):
    # 定位到“验证码”关键词
    keyword_index = sms_text.find('验证码')
    if keyword_index == -1:
        return None

    # 从关键词后开始匹配4-6位的数字
    match = re.search(r'(\d{4,6})', sms_text[keyword_index:])
    if match:
        return match.group(1)
    return None

# tg
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
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虚拟机里的号码撞上了就行):

添加 sip extensions
添加 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)
名字随意,outbound CallerID改成asterisk虚拟机那边设置的数值(70)
SIP server要改成asterisk虚拟机的IP
SIP server要改成asterisk虚拟机的IP

路由

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

outbound-route
outbound-route

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

inbound-route
inbound-route

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

测试通话

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

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

端口转发,SIP push等

对于sip,除了需要转发sip的默认端口(5201,可以在freepbx的设置里查看(settings - asterisk sip setting),还需要转发RTP端口。如果只想转发一个端口,可以考虑参考EC20 模块+Issabel 实现网络电话 - Hanako Blog使用IAX2 extension。SIP Push(在接到电话时在手机上弹推送)目前只有zoiper提供了比较方便的配置方式,但需收费,具体可参考 如何把SIM卡从手机中取出 – IAM-LC

使用模块上网

如果使用的手机卡包含流量,我们也可以一并配置模块的上网功能,以便在机器的有线/无线网络挂掉后,还能正常的转发短信和通话。

首先,在连上串口后输入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这样方便管理的名字。

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上的电话会提示用户忙,需要使用前一节中提到的sip push的方式,或者保持客户端常开才能正常接到电话。

参考资料