如何把SIM卡从手机中取出

近年来,手机与个人私隐形成的强绑定让人诟病。上至健康码,下至一个普通App,都可以通过手机号将一个人的身份同千丝万缕联系起来。研究如何通过解绑手机与人的直接关联,显得尤为重要。

手机运营商提供给用户的最主要的业务我认为有三个:语音、短信和数据。这里我们着重考虑前两种,即语音和短信业务的转发。

至于数据业务:“反正也不干不净,不要也罢。”

短信转发方案

不考虑语音转发的情况下,如果只需要做短信转发,方案可选性会更多而且更为简单。在考虑加入语音转发之前,我一直使用的是开源方案 Sms-Forwarder,使用一台闲置安卓手机做短信转发,可靠且稳定。

如果不想使用安卓手机,下文也提供基于PICe模块的方案,因和语音深度集成,放在一起讲。

语音+短信转发方案

最初的想法我其实是想提供USB上网卡的方案把SIM卡暴露给系统,再在系统层面进行一些操作。经过一番摸索,我发现现在能用的USB上网卡几乎已经没有了,即使有,USB也仅仅用作取电,不会过多交互数据。更要命的是大部分USB上网卡都是3G卡,随着3G退网这个方案想必无法持久使用。

经过一番摸索我确定用移远的方案,这是一个硬件提供商,提供各种成熟的LTE模块。而底层硬件我在这里用的是光影猫,无它,只因为闲置在工具箱里吃灰,而且正好带了一块LTE模块(购买原意是做4G软路由使用)。

部署完成回过头来看,涉及到的硬件如下:

  • 移远EM05模块(EM05CEFC-128-SGAS)
  • 光影猫 (安装 Debian 并安装 Asterisk)
  • 一台 VM 安装 FreePBX
  • 一台公网主机安装 FRP
  • 一台前置机用于转发FRP流量
  • 两台手机,用于调试语音业务

其中,EM05 安装于光影猫中,前置机、FreePBX 都从 homelab 中分配资源,公网主机也拿现成的直接用即可。但如果自己家里没有对应的基础设施配置,运行这个方案成本或许会比较高。

如果不想用光影猫的方案,通过购置4G模块转接板的MiniPCIe转USB卡座,将模块映射到虚拟机中,并在虚拟机中安装Debian,理论上也可以实现一样的作用。

配置移远EM05模块

首先我将光影猫刷为Debian系统并配置静态IP,接入内网,过程不表。值得一提的是机器不带时钟电池,如果没有成功同步时间可能会影响到apt和wget等操作。可以通过 date 命令检查时间并修正。

以下配置过程极大程度地借鉴了这篇文章,如果有不清楚的地方建议回原文查看。

进入系统后应可以识别到移远模块:

root@Photonicat  /opt
$ ll /dev/ttyUSB
ttyUSB0  ttyUSB1  ttyUSB2  ttyUSB3

安装 minicom:

apt install minicom

通过以下命令打开与EM05的交互终端:

minicom -D /dev/ttyUSB2

查看版本号:

ATI

重置并重启:

重置模块 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:

AT+QCFG="ims",1
AT+QMBNCFG="List"
AT+QMBNCFG="AutoSel",0
at+qmbncfg="deactivate"
AT+QMBNCFG="select","ROW_Generic_3GPP"
重启 AT+CFUN=1,1

重启后确认状态:

AT+QCFG="ims"
如果返回的是 +QCFG: "ims",1,1 即为激活,如果是+QCFG: "ims",1,0 说明没有激活
AT+QMBNCFG="List"
如果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

配置Asterisk和短信转发

安装Asterisk和依赖:

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

安装 asterisk-chan-quectel

通过移远提供的项目,可以使得Asterisk可以读取到移远模块。

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:

$ asterisk -rvvv
Asterisk 16.28.0~dfsg-0+deb10u4, Copyright (C) 1999 - 2021, Sangoma Technologies Corporation 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.28.0~dfsg-0+deb10u4 currently running on Photonicat (pid = 1545)
Photonicat*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     EM05       EM05CEFCR06A04M1G 867144039000000  460018660000000  Unknown 
Photonicat*CLI>

配置 Dialplan

直接照抄没啥问题:

$ cat /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)
exten => sms,n,System(echo '${BASE64_DECODE(${SMS_BASE64})} - ${CALLERID(num)} - ${STRFTIME(${EPOCH},,%Y-%m-%d %H:%M:%S)}' >> /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,Set(CALLERID(name)="")
exten => _.,1,Dial(SIP/70/100)
exten => s,n,Hangup()


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

炫一段GPT的解释:

这个Asterisk的dialplan包括两个上下文:[incoming-mobile]和[Outbound-1001],实现了以下功能:

[incoming-mobile] 上下文
处理呼入短信:

当收到短信时,该上下文会捕捉到sms扩展。
第一个优先级(1)使用Verbose应用程序将来自特定电话号码的短信内容解码并记录在日志中。
第二个优先级(n)使用System应用程序将短信内容、发件人电话号码以及当前时间追加到/var/log/asterisk/sms.txt文件中。
第三个优先级(n)挂断通话。
处理呼入USSD:

当收到USSD消息时,该上下文会捕捉到ussd扩展。
第一个优先级(1)使用Verbose应用程序将USSD消息内容解码并记录在日志中。
第二个优先级(n)使用System应用程序将USSD消息内容和发送时间追加到/var/log/asterisk/ussd.txt文件中。
第三个优先级(n)挂断通话。
处理其他呼入事件:

对于任何其他事件,该上下文捕捉到特殊的_扩展名。
第一个优先级(1)使用Dial应用程序从SIP设备70拨打到SIP设备100。
第二个优先级(s)挂断通话。
[Outbound-1001] 上下文
处理外拨电话:
该上下文捕捉到特殊的_扩展名。
第一个优先级(1)使用Dial应用程序从Quectel设备quectel0拨打到指定的电话号码。
第二个优先级(n)挂断通话。

建议重启服务器而不是重启Asterisk,确定没有问题后测试短信:

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

确定 /var/log/asterisk/sms.txt 可以收到即可。

配置短信转发

这里我用现成的shell脚本,丢给GPT直接做成一个python脚本,调用企业微信应用接口来发送短信。

脚本配置好ID、secret后摆到以下文件: /opt/send_notification.py

#!/usr/bin/env python3

import requests
import json
from pathlib import Path
import time

def get_json_value(json_obj, key, default):
    return json_obj.get(key, default)

# AgentId, 2 for HA, 3 for Aduit
AGID = "CHANGEME"

# AppSecret
ASECRET = "CHANGE-ME"

# CorpID
CORPID = "CHANGEME"

sms_file = Path('/var/log/asterisk/sms.txt')

def send_notification(new_content):
    print("Sending notification...")
    # Sending Context
    CONTEXT = f"{new_content}"

    # Obtain Token Json Array
    response = requests.get(f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={CORPID}&corpsecret={ASECRET}', timeout=5)
    JSON = response.json()

    TOKEN = get_json_value(JSON, 'access_token', None).strip('"')

    data = {
        "touser": "@all",
        "msgtype": "text",
        "agentid": AGID,
        "text": {"content": CONTEXT}
    }
    result = requests.post(f'https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={TOKEN}',
                  headers={"Content-Type": "application/json"},
                  data=json.dumps(data), timeout=5)
    if result.status_code == 200:
        print("Notification sent successfully.")
    else:
        print(f"Failed to send notification. Status code: {result.status_code}. Response: {result.text}")

def watch_sms_file(file_path):
    last_position = file_path.stat().st_size if file_path.exists() else 0  # Ignore the existing content by starting from the end of the file
    print("Starting to watch the sms file...")
    send_notification("SMS Monitoring System Started")
    while True:
        file_path.touch(exist_ok=True)  # Ensure the sms file exists
        with open(file_path, 'r') as sms_file:
            sms_file.seek(last_position)
            new_content = sms_file.read()
            if new_content:
                print(f"New content detected: {new_content.strip()}")
                send_notification(new_content)
                last_position = sms_file.tell()

        time.sleep(1)  # Time interval to check the file again


if __name__ == "__main__":
    watch_sms_file(sms_file)

新建一个服务:

$ systemctl cat sms-notify.service
# /etc/systemd/system/sms-notify.service
[Unit]
Description=SMS-Wechat Notification
After=network-online.target

[Service]
Type=simple
User=root
Restart=on-failure
RestartSec=15
ExecStart=/usr/bin/python3 /opt/send_notification.py

[Install]
WantedBy=multi-user.target

随后启用服务、开启自启动:

systemctl daemon-reload
systemctl enable --now sms-notify.service

一切正常的话你会收到一条提醒:
SMS Monitoring System Started

至此,短信部分配置完成。

配置 FreePBX和语音转发

添加分机号

下载安装 FreePBX,过程不表。值得一提的是如果Chrome没有弹出ISO下载,尝试用Edge。

完成初始设定后,添加两个分机号,一个用于接收端使用,一个用于测试手机调试使用。这里分别用801和802.

Applications – Extensions – Add Extension

注意Secret,这个相当于密码,后面在手机端注册需要用到。

其他保持默认设置即可。

添加Trunk

这里我们希望和光影猫的Asterisk建立trunk,而且不希望通过导入CID。如果自动导入CID,所有呼入来电均会变为移远的端口名。

进入FreePBX的SSH,增加以下配置:

[root@freepbx ~]# cat /etc/asterisk/extensions_custom.conf
[from-trunk-no-cidname]
exten => _X.,1,Set(CALLERID(name)="")
exten => _X.,n,Goto(from-trunk,${EXTEN},1)
[root@freepbx ~]#

回到网页端,点击 Connectivity – Trunks – Add Trunk

按如下设置

注意这里的Authentication被关闭,SIP Server为光影猫的地址,端口可以在这个文件查到:

$ cat /etc/asterisk/sip.conf
[general]
context=sip-default
udpbindaddr=0.0.0.0:46000
tcpbindaddr=0.0.0.0:47000
tlsbindaddr=0.0.0.0:5063
tlscipher=ALL
tlsclientmethod=tlsv1
accept_outofcall_message=yes
allow=!all,slin,ulaw,alaw
allowguest=no
allowtransfer=yes
alwaysauthreject=yes
.....

这里的 Context 我们设置为 from-trunk-no-cidname ,会进入上面的设置清洗掉CID。

配置路由

在 Connectivity – Outbound Routes设置呼出路由:

在Dial Patterns里我设置了两种外呼格式,分别是1开头的号码和9开头的号码。这样在呼出手机等1开头的号码时自动走移远模块。如果呼叫固话,则先打9再跟固话即可。(和之前一些酒店的设置一样)

在 Connectivity – Inbound Routes设置呼入路由:

配置FreePBX SIP

进入Settings – Asterisk SIP Settings,给RTP更改端口范围:

这里还把RTP Timeout改成3,原因是在外网呼叫电话时,挂断信号无法正常被转发,这里将设置为3秒无语音包后挂断电话。

进入 chan_pjsip页,开启TCP监听:

同时在下面把外网域名和外网IP写上。

保存,应用更改,重启服务器

我发现有很多变更无法通过Web重启生效。任何涉及Asterisk的变更我都建议直接重启FreePBX服务器。

测试通话

此时基本配置就已经完成了,手机下载Zoiper,新建一个SIP Account,Domain写FreePBX的IP,Username写801,密码为上面设置的Secret。另一台则按照802的配置,两边都注册成功后,你就可以通过801,802这两个号码互相拨打电话了。

值得一提的是目前几款VoIP软件,比如Zoiper,SessionTalk的实现逻辑差异很大。在内网使用时没有太大区别,但我们还需要保证软件没有打开在前台时能正常接到推送通知(这里以iPhone为例),Zoiper为订阅制软件,SessionTalk为买断制,但Zoiper可以将RTP语音流一并进行转发,这能修正很多因NAT无法转发SIP而导致的问题。

我经过长时间测试,在下文提到的FRP方案中,没有找到一个不修改FRP源码就可以成功转发语音流的方案。所以最终使用Zoiper的订阅服务进行操作。

端口转发

进入前置机配置frpc的转发如下:

[freepbx_sip_port_udp_10000]
type = udp
remote_port = 10000
local_port = 10000
local_ip = 192.168.1.241

[freepbx_sip_port_udp_10001]
type = udp
remote_port = 10001
local_port = 10001
local_ip = 192.168.1.241

[freepbx_sip_port_udp_10002]
type = udp
remote_port = 10002
local_port = 10002
local_ip = 192.168.1.241

[freepbx_sip_port_tcp]
type = tcp
remote_port = 5060
local_port = 5060
local_ip = 192.168.1.241

local_ip为FreePBX的IP地址。重启frpc服务后,如果一切正常,你就可以从公网的5060端口注册到你的PBX服务器了。

如果你尝试拨打电话,正常来说是可以接通但是没有语音的。这里进入Zoiper的Settings,进入Incoming Calls,将Proxy Protocols选择为 SIP + RTP,再尝试拨打电话,就可以了。

最后,完成一切配置后,重启光影猫和FreePBX服务器,确保所有组件在重启后都能正常启动。

如果有监控系统的话,同时对光影猫的46000端口和FreePBX的5060端口做一个监听和告警,以免服务宕机影响使用:

小结

这个配置繁琐,且要求已有的基础设施众多,可能不适合大部分人使用。但是读到这里的人,想必也无需多说什么了。

祝你我向上走,有一份热,发一分光。

Reference

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

关于sip server内网映射外网的一些记录

从0到1打造自己的VOIP网络电话系统(基于FreePBX)

Eliminate CallerID Name from inbound calls

基于coturn项目的stun/turn服务器搭建

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注