最近想实现在外面访问家里的迷你电脑，因为电脑的 IPv6 地址是通过 SLACC 分配的，不能在路由器上获得迷你电脑的 IPv6 地址，所以需要在电脑上实现 DDNS 功能。就写了一个脚本，实现了已下功能：从本地网卡获取 IPv6 的第一个首选地址，通过 cloudfalre api 获取 dns 记录的 ipv6 地址对比获取的 IPv6 地址与 cloudflare 中的 dns 记录是否一致如果比对失败则通过 cloudflare api 更新 dns 解析发生错误则发送 telegram 通知下面是脚本内容：## ------------------------------------------------------------------------------ # 脚本名称: UpdateDNS.ps1 # 描述: 遍历所有网卡（排除WiFi），获取本机 IPv6 公网地址，通过 Cloudflare API 更新 DNS 记录，并发送 Telegram 通知 # 作者: [will https://opswill.com] # 日期: [2024/12/31] # 版本: 1.3 # ------------------------------------------------------------------------------ # 注意: 请确保已正确配置 API Token 和其他变量 # ------------------------------------------------------------------------------ # 设置 PowerShell 执行策略（如果需要） # Set-ExecutionPolicy Bypass -Scope Process -Force # 建议仅在测试时使用，生产环境应避免使用 Bypass # 配置 Cloudflare API 和 Telegram Bot 信息 $apiToken = "" $zoneId = "" $recordId = "" $domain = "" $botToken = "" $chatId = "" # 日志文件路径 $logFilePath = "C:\data\DDNS.log" # 排除的网卡接口名称（正则表达式） $excludedInterfaces = "Wi-Fi|Wireless|WLAN|Loopback|Bluetooth" # 函数：记录日志 function Log-Message { param( [string]$Message ) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $logEntry = "$timestamp - $Message" Add-Content -Path $logFilePath -Value $logEntry } # 函数：发送 Telegram 消息 function Send-TelegramMessage { param( [string]$Message ) $telegramApiUrl = "https://api.telegram.org/bot$botToken/sendMessage" $params = @{ chat_id = $chatId text = $Message } try { Invoke-RestMethod -Uri $telegramApiUrl -Method Post -Body $params } catch { Log-Message "发送 Telegram 通知失败: $_" } } # 函数：获取公网 IPv6 地址 function Get-PublicIPv6Address { $ipv6AddressesWithInterface = Get-NetIPAddress -AddressFamily IPv6 | Where-Object { $_.InterfaceAlias -notmatch $excludedInterfaces -and $_.PrefixOrigin -in "RouterAdvertisement", "DHCPv6" -and $_.SuffixOrigin -ne "Random" -and $_.AddressState -eq "Preferred" # 确保地址是首选状态 } if (-not $ipv6AddressesWithInterface) { throw "未找到有效的公网 IPv6 地址。" } foreach ($ipv6AddressInfo in $ipv6AddressesWithInterface) { Log-Message "网卡 '$($ipv6AddressInfo.InterfaceAlias)' 获取到公网 IPv6 地址: $($ipv6AddressInfo.IPAddress)" } # 仍然选择第一个地址作为返回值，但保留了记录所有地址的逻辑 return $ipv6AddressesWithInterface[0].IPAddress } # 函数：获取当前 DNS 记录 function Get-CurrentDNSRecord { $uri = "https://api.cloudflare.com/client/v4/zones/$zoneId/dns_records/$recordId" $headers = @{ Authorization = "Bearer $apiToken" } try { $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers if ($response.success) { return $response.result.content } else { throw "获取 DNS 记录失败: $($response.errors | ConvertTo-Json)" } } catch { throw "获取当前 DNS 记录失败: $_" } } # 函数：更新 DNS 记录 function Update-DNSRecord { param( [string]$ipv6Address ) $uri = "https://api.cloudflare.com/client/v4/zones/$zoneId/dns_records/$recordId" $headers = @{ "Content-Type" = "application/json" Authorization = "Bearer $apiToken" } $body = @{ name = $domain ttl = 60 content = $ipv6Address type = "AAAA" } | ConvertTo-Json try { $response = Invoke-RestMethod -Uri $uri -Method Patch -Headers $headers -Body $body if ($response.success) { $message = "成功更新 DNS 记录: $domain -> $ipv6Address" Log-Message $message Send-TelegramMessage $message } else { $message = "更新 DNS 记录失败: $($response.errors | ConvertTo-Json)" Log-Message $message Send-TelegramMessage "Windows DDNS: $message" # 错误信息前缀 } } catch { $message = "更新 DNS 记录失败: $_" Log-Message $message Send-TelegramMessage "Windows DDNS: $message" } } # 脚本主逻辑 Log-Message "脚本开始执行" try { $ipv6Address = Get-PublicIPv6Address $currentDNSRecord = Get-CurrentDNSRecord Log-Message "当前 $domain 的 DNS 记录内容： $currentDNSRecord" if ($currentDNSRecord -ne $ipv6Address) { Log-Message "DNS 记录与本机 IPv6 地址不一致，准备更新 DNS 记录。" Update-DNSRecord -ipv6Address $ipv6Address } else { Log-Message "DNS 记录与本机 IPv6 地址一致，无需更新。" } } catch { $message = "Windows DDNS: $_" # 错误信息前缀 Send-TelegramMessage $message Log-Message $message } Log-Message "脚本执行结束"按 win + r 然后输入 taskschd.msc 创建一个新的计划任务，从而实现开机未登录和登录状态定期执行，这样就可以通过域名在外面访问家里的迷你电脑了。
不是程序员，作为运维平时写的比较多的是 Bash 和 Python 脚本。工作上运维过挺多的 PHP 站点，被动看过一些 PHP 代码，但从来没写过。最近有点时间，参考了 joyqi 的 Sitemap 插件，试着写了这个 CommentToTelegram 插件，竟然没有太大的阻碍。遇到最大的困难是对 Typecho 接口的理解。 我的需求就是通过 telegram 管理评论，现有的 tyepcho 插件推送到 telegram 都是通过第三方的服务，只能收到通知。唯一一个可以管理的插件已经不能用了，所以自己调用 telegram bot api 写了一个。下面是插件的介绍：介绍CommentToTelegram 是一个全新的 typecho 插件，使用 Telegram API 将评论推送到 Telegram Bot 通知并管理 github 地址 ： CommentToTelegram功能及特性支持 Telegram Inline Button 管理评论状态，可以通过 Telegram 将评论: 批准/删除/标记垃圾支持在Telegram通知上回复评论，在评论通知上点击回复，回复的内容会同步到博客，无需登录博客支持配置代理，支持使用 socks5、https、http 代理访问 Telegram支持异步回调，不会阻塞评论插件版本要求建议 Typecho 版本大于 1.2.1，其他版本未进行测试php: >=8.1.0, 本插件依赖 php-curl 和 php-json如果使用代理功能，建议使用新版本的 php-curl 扩展安装教程下载后将压缩包解压到 /usr/plugins 目录文件夹名改为 CommentToTelegram登录管理后台，激活插件配置插件 填写 Telegram Bot Token 及 Telegram Chat ID，并设置其他参数保存配置，并注意是否有错误提示信息插件升级禁用旧版本插件删除旧版本的文件，并上传然后传新版本解压，文件夹名改为 CommentToTelegram激活插件并设置参数使用教程一、 使用前提：申请 Telegram bot Token： 官方教程通过 Telegam bot api 获取 chat_id: getUpdates API如果看不懂，请自行百度谷歌相关教程二、可配置选项如果插件配置有错误，会在点击 保存设置 后提示错误信息。如网络错误、代理信息错误、用户uid错误等等三、通知详情及评论管理按钮3.1 评论通知每条评论只能管理一次，在点击管理按钮后，通知下方的管理按钮会消失并提示评论处理结果：3.2 评论回复启用 Telegram Inline Button管理评论后，即可以使用回复功能。需要先设置 Typecho 真实的用户 uid，查看 uid：在 Telegam 的评论通知上进行评论回复：回复成功会有提示成功：Typecho 博客上的评论状态：3.3 使用建议建议将所有评论设置为先需要审核（后台->设置->评论->评论提交->所有评论必须经过审核），然后通过本插件进行管理。建议使用typecho前台通过主题的评论框进行回复，本插件回复虽然使用 Typecho 内置接口实现，但被回复的访客无法收到评论提醒（如邮件提醒），查看 Tyepcho 代码怀疑是 bug，已经提了一个 issue 询问，目前暂时无法解决。更多问题可以通过 github issue 页面提交，或者通过 博客、邮件向我反馈感谢https://github.com/joyqi/typecho-plugin-sitemaphttps://github.com/Adoream/typecho-plugin-comment2telegram
家里网络环境比较简单，网络相关的硬件也比较少，所以也没啥能折腾的，只有以下几个设备：联通光猫型号是中兴的 F657GV9-C，在没有联通没有下发动态密码时改了桥接。本来还想破解一下开telnet，后来想想把桥接的配置备份出来就行了，没啥特别的需求就不折腾了。软路由彻视的N4100，4核4线程，基础频率1.1g，最高频率2.4g，性能比网上这几年流行的J4125还要差，但比arm架构的强多了。主机有4个 Intel I226 的 2.5G 网口，自己买了8G内存和128G的msata，又加了一个内置风扇，本身发热就不大，和光猫一起丢弱电箱夏天也不用担心散热。不打算折腾虚拟机，直接装一个路由系统足够带千兆的带宽了，重点是一套下来也才四百块。AP，是TP-Link XAP5400GC，单 AP 放在房子中间基本就完全覆盖了物理连接： 光猫（改桥接） --> N4100 --> TPlink AP XAP5400GC。由于想找个和 linux 贴近的开源路由系统，最好是和云也能沾点边，所有选择了 vyos，经过一段时间的使用，实现了以下功能：WAN/LAN 分别使用两个网口，网口绑定，实现盲插PPPoE 拨号 及 DHCP servercake qosZone Based firewall科学相关（略）一些远程访问相关的容器一、物理网卡配置这个小主机有四个 i226 的 2.5g 网口，网口分配 eth0/eth1 WAN 口，eth2/eth3 LAN 口，这个小机器的网卡顺序在系统中默认不是0/1/2/3，因此要绑定网卡名字和 mac 地址，防止重启后名字变化。1.1 绑定网卡名字及 mac 地址set interfaces ethernet eth0 hw-id '60:be:xx:xx:xx:xx' set interfaces ethernet eth1 hw-id '60:be:xx:xx:xx:yy'1.2 开启硬件 offloadoffload 简单理解就是网卡硬件中已经实现了一些数据包的处理，开启后不在使用 cpu 处理，能够提升性能。以 eth0 为例子，其他的网卡也需要设置：set interfaces ethernet eth0 offload gro set interfaces ethernet eth0 offload gso set interfaces ethernet eth0 offload lro set interfaces ethernet eth0 offload rfs set interfaces ethernet eth0 offload rps set interfaces ethernet eth0 offload sg set interfaces ethernet eth0 offload tso set interfaces ethernet eth0 ring-buffer rx '4096' set interfaces ethernet eth0 ring-buffer tx '4096'1.3 WAN/LAN 接口配置我要实现的是 eth0/eth1 绑定成一个 bond0 ，在 bond0 上在启用一个桥接的 br0 作为 wan 口；同理，eth2/eth3 绑定成一个 bond1，在 bond1 上在启用一个桥接的 br1 作为 Lan 口。这样做的好处是 eth0/eth1 和 eth2/eth3 可以随便插，有冗余的效果，稍加配置也可以实现两个端口的链路聚合，实现带宽叠加。配置 wan 和 Lan 口物理网卡绑定：set interfaces bonding bond0 description 'Bond Interface WAN' set interfaces bonding bond0 member interface 'eth0' set interfaces bonding bond0 member interface 'eth1' set interfaces bonding bond1 description 'Bond Interface LAN' set interfaces bonding bond1 member interface 'eth2' set interfaces bonding bond1 member interface 'eth3'设置WAN的桥接 br0 和 LAN 的桥接 br1，这里的 stp 其实开不开都用处不大。另外我给 Lan 口配置了一个10.0.0.254的ip，后续的容器都绑定到这个 ip 上。set interfaces bridge br0 description 'WAN bridge' set interfaces bridge br0 member interface bond0 set interfaces bridge br0 stp set interfaces bridge br1 address '10.0.0.1/24' set interfaces bridge br1 address '10.0.0.254/32' set interfaces bridge br1 description 'LAN bridge' set interfaces bridge br1 member interface bond1 set interfaces bridge br1 stp二、 WAN 口 PPPoE 拨号配置2.1 拨号配置前提是光猫要改桥接，并且有宽带的拨号信息，先从光猫的 LAN 口接一条网线到 vyos 的 eth0 或 eth1 接口，并启用一个pppoe0的接口， 自动配置 MTU ：set interfaces pppoe pppoe0 authentication password '宽带密码' set interfaces pppoe pppoe0 authentication username '宽带账号' set interfaces pppoe pppoe0 description 'China Unicom' set interfaces pppoe pppoe0 ip adjust-mss 'clamp-mss-to-pmtu' set interfaces pppoe pppoe0 source-interface 'br0'2.2 IPv6 PD 配置首先设置需要设置 prefix，我的运营商是给的是/60set interfaces pppoe pppoe0 dhcpv6-options pd 0 interface br1 address '1' set interfaces pppoe pppoe0 dhcpv6-options pd 0 interface br1 sla-id '0' set interfaces pppoe pppoe0 dhcpv6-options pd 0 length '60' set interfaces pppoe pppoe0 ipv6 address autoconf set interfaces pppoe pppoe0 ipv6 adjust-mss 'clamp-mss-to-pmtu'设置路由器公告RA：set service router-advert interface br1 link-mtu '1490' set service router-advert interface br1 prefix ::/64 valid-lifetime '172800' set service router-advert interface pppoe02.3 多拨多拨需要运营商支持，vyos 的多拨非常简单，新启用一个 pppoe 的接口拨号即可：set interfaces pppoe pppoe1 authentication password '宽带密码' set interfaces pppoe pppoe1 authentication username '宽带账号' set interfaces pppoe pppoe1 description 'China Unicom WAN multi-pppoe-2' set interfaces pppoe pppoe1 source-interface 'br0'至此 WAN 的配置全部结束。三 、 LAN 配置 DHCP3.1 启用 LAN 口的 DHCP serverLAN 的网段是 10.0.0.0/24，DHCP 的分配 IP 范围是 10.0.0.100-200，同时下发了 dns server 和 ntp server。set service dhcp-server dynamic-dns-update set service dhcp-server hostfile-update set service dhcp-server listen-interface 'br1' set service dhcp-server shared-network-name LAN_br1 authoritative set service dhcp-server shared-network-name LAN_br1 option ntp-server '10.0.0.1' set service dhcp-server shared-network-name LAN_br1 option time-zone 'Asia/Shanghai' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 subnet-id '1' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 lease '86400' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 option default-router '10.0.0.1' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 option domain-name 'home.local' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 option domain-search 'home.local' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 option name-server '10.0.0.1' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 range 100 start '10.0.0.100' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 range 100 stop '10.0.0.200'3.2 绑定静态 IP如果有固定的硬件，可以绑定固定的 IPset service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 static-mapping TPlink-AP ip-address '10.0.0.2' set service dhcp-server shared-network-name LAN_br1 subnet 10.0.0.0/24 static-mapping Sony-TV ip-address '10.0.0.21'绑定固定 IP 后，直接ping TPlink-AP或者 ping Sony-TV 还无法解析，还需要配置 DNS。3.3 局域网 DNS 配置Vyos 自带 Powerdns recursor ，不添加上游 dns 服务器也可以正常解析。set service dns forwarding allow-from '10.0.0.0/24' set service dns forwarding authoritative-domain home.local records a TPlink-AP address '10.0.0.2' set service dns forwarding authoritative-domain home.local records a Sony-TV address '10.0.0.21' set service dns forwarding listen-address '10.0.0.1' set service dns forwarding listen-address '::1' set service dns forwarding name-server 10.0.0.254vyos 的 pdns 为了保持简单，能设置的功能并不多，本例子中的上游是 dns server 是本机的 mosdns container ，绑定的是 10.0.0.254 udp 53端口。可以改成公共 dns 也可以设置多个。3.4 局域网 NTP 配置set service ntp allow-client address '10.0.0.0/24' set service ntp interface 'br1' set service ntp listen-address '10.0.0.1' set service ntp server asia.pool.ntp.org set service ntp server time.apple.com set service ntp server time.aws.com set service ntp server time.cloudflare.com到这一步基本的局域网配置就已经可以了，其他设备加入就可以自动获取 ip 并上网了。四、QoS 流量管理Vyos 提供了非常完善的 Qos Policy，常见的流量策略都支持。家用不用那么麻烦直接使用 Cake 仅需要设置一下带宽，要注意的是启用 Qos 会占用 CPU，同时可能会导致带宽跑不满。好处是多人占用带宽时，可以为每一个人合理分配宽带资源。set qos policy cake Qos_Cake_Lan bandwidth '1gbit' set qos policy cake Qos_Cake_Lan description 'Qos For Lan interface br1' set qos policy cake Qos_Cake_Lan flow-isolation dual-src-host set qos policy cake Qos_Cake_Lan rtt '100' set qos interface br1 egress 'Qos_Cake_Lan'五、防火墙配置Vyos rolling 版本已经实现了zone based 的防火墙。5.1 全局配置配置防火墙的全局配置和默认的 statefull 行为。set firewall global-options all-ping 'enable' set firewall global-options state-policy established action 'accept' set firewall global-options state-policy invalid action 'drop' set firewall global-options state-policy related action 'accept' set firewall global-options syn-cookies 'enable'5.2 创建 zone创建三个zone，分别是 LOCAL/LAN/WAN，在 Vyos 中，local zone指vyos自身。创建 zone 的同时指定与其他 zone 之间的访问规则，ipv4 和 ipv6 的规则要分开指定。set firewall zone LOCAL default-action 'drop' set firewall zone LOCAL from LAN firewall ipv6-name 'LAN_to_LOCAL-ipv6' set firewall zone LOCAL from LAN firewall name 'LAN_to_LOCAL' set firewall zone LOCAL from WAN firewall ipv6-name 'WAN_to_LOCAL-ipv6' set firewall zone LOCAL from WAN firewall name 'WAN_to_LOCAL' set firewall zone LOCAL local-zone set firewall zone LAN default-action 'drop' set firewall zone LAN from LOCAL firewall ipv6-name 'LOCAL_to_LAN-ipv6' set firewall zone LAN from LOCAL firewall name 'LOCAL_to_LAN' set firewall zone LAN from WAN firewall ipv6-name 'WAN_to_LAN-ipv6' set firewall zone LAN from WAN firewall name 'WAN_to_LAN' set firewall zone LAN interface 'br1' set firewall zone WAN default-action 'drop' set firewall zone WAN from LAN firewall ipv6-name 'LAN_to_WAN-ipv6' set firewall zone WAN from LAN firewall name 'LAN_to_WAN' set firewall zone WAN from LOCAL firewall ipv6-name 'LOCAL_to_WAN-ipv6' set firewall zone WAN from LOCAL firewall name 'LOCAL_to_WAN' set firewall zone WAN interface 'br0' set firewall zone WAN interface 'pppoe0'5.3 创建 zone 之间的访问规则为了方便调试，对于 drop 的规则启用日志。创建 zone LOCAL 到 WAN 和 LAN的 ipv4 和 ipv6 规则：set firewall ipv4 name LOCAL_to_LAN default-action 'accept' set firewall ipv4 name LOCAL_to_WAN default-action 'accept' set firewall ipv6 name LOCAL_to_LAN-ipv6 default-action 'accept' set firewall ipv6 name LOCAL_to_WAN-ipv6 default-action 'accept'创建 zone LAN 到 WAN 和 LOCAL的 ipv4 和 ipv6 规则：set firewall ipv4 name LAN_to_LOCAL default-action 'accept' set firewall ipv4 name LAN_to_WAN default-action 'accept' set firewall ipv4 name LAN_to_WAN rule 20 action 'return' set firewall ipv4 name LAN_to_WAN rule 20 description 'ipv4 stateful from LAN to WAN' set firewall ipv4 name LAN_to_WAN rule 20 state 'established' set firewall ipv4 name LAN_to_WAN rule 20 state 'related' set firewall ipv4 name LAN_to_WAN rule 20 state 'new' set firewall ipv4 name LAN_to_WAN rule 20 state 'invalid' set firewall ipv6 name LAN_to_LOCAL-ipv6 default-action 'accept' set firewall ipv6 name LAN_to_WAN-ipv6 default-action 'accept' set firewall ipv6 name LAN_to_WAN-ipv6 description 'ipv6 stateful from LAN to WAN' set firewall ipv6 name LAN_to_WAN-ipv6 rule 20 action 'return' set firewall ipv6 name LAN_to_WAN-ipv6 rule 20 state 'established' set firewall ipv6 name LAN_to_WAN-ipv6 rule 20 state 'new' set firewall ipv6 name LAN_to_WAN-ipv6 rule 20 state 'related' set firewall ipv6 name LAN_to_WAN-ipv6 rule 20 state 'invalid' 创建 zone WAN 到 LAN 和 LOCAL的 ipv4 和 ipv6 规则，需要注意的是要放行 WAN pppoe的ipv6 pd相关的 dhcp/icmp 等：set firewall ipv4 name WAN_to_LAN default-action 'drop' set firewall ipv4 name WAN_to_LAN default-log set firewall ipv4 name WAN_to_LOCAL default-action 'drop' set firewall ipv4 name WAN_to_LOCAL default-log set firewall ipv6 name WAN_to_LAN-ipv6 default-action 'drop' set firewall ipv6 name WAN_to_LAN-ipv6 default-log set firewall ipv6 name WAN_to_LOCAL-ipv6 default-action 'drop' set firewall ipv6 name WAN_to_LOCAL-ipv6 default-log set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 10 action 'drop' set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 10 icmpv6 type-name 'echo-request' set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 10 protocol 'ipv6-icmp' set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 20 action 'accept' set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 20 protocol 'ipv6-icmp' set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 30 action 'accept' set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 30 destination port '546' set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 30 protocol 'udp' set firewall ipv6 name WAN_to_LOCAL-ipv6 rule 30 source port '547' 六、科学上网个人一直反感写这类文章，这里仅提供思路。vyos 是专业的路由系统，又支持 podman， 实现科学功能相当容易。我个人使用的方案是 ospf over wireguard over websocket， 需要配置以下内容：无污染的 dns： 使用 mosdns 运行在 vyos 的容器中， 实现国内外域名分流，广告屏蔽等境外服务器： 1. 安装 bird 跑 ospf和bfd 2. wireguard 3. openresty + v2ray server 4. ospf 分流脚本Vyos： 1. ospf（non-broadcast）+ bfd 2. wireguard 3.v2ray容器（udp over websocket/load balancer）七、其他杂项配置set system host-name 'vyos' set system name-server '10.0.0.1' set system time-zone 'Asia/Shanghai' set system update-check auto-check set system update-check url 'https://raw.githubusercontent.com/vyos/vyos-rolling-nightly-builds/main/version.json' set service ssh access-control allow user 'vyos' set service ssh listen-address '10.0.0.1' set service ssh port '22' set system login user vyos authentication plaintext-password 'vyos.opswill.com' set system config-management commit-archive location 'https://user:pass@backup.opswill.com' set system config-management commit-archive source-address '10.0.0.1' set system config-management commit-revisions '100'另外还跑了一些容器：cloudflare-dns ：ddns，每五分钟更新 ipv4 和 ipv6 地址cloudflared：cloudflare tunnel，使用 zero trust 远程访问路由器tailscale： 手机等设备使用家里的网络上网mosdns： 上文有提到，提供无污染 dns serverv2ray： 提供udp over websocket 供wirguard使用八、总结虽然写了一大堆，但是整体配置起来非常快，感觉比其他有 web 管理的路由系统更容易配置和使用。家里已经稳定运行了将近一年，我基本上每个月升级两次，即使 rolling 版本也很少遇到问题，值得推荐和使用。
最近有个需求是通过 Harbor API 获取 image 的最新 tag，来发送提醒进行更新，于是简单研究了一下 Harbor 的API 用法。Harbor 是一个功能丰富、易用且安全的开源 Docker Registry 项目，专为企业级容器镜像管理而设计。主要特性包括：安全与合规性：Harbor 提供安全扫描和漏洞管理功能，自动扫描镜像中的漏洞并生成详细报告。多租户支持：为每个项目提供独立的命名空间，支持自定义角色，实现多租户镜像管理。多镜像源：支持镜像在多个 Registry 之间的复制、备份和迁移。直观界面：提供易用的图形界面，支持镜像的推送、拉取、删除、日志查询和配置管理。垃圾回收：定期清理不再使用的镜像，释放存储空间。可扩展性：架构设计支持功能扩展和插件添加。Harbor REST-API 的认证和用法Harbor的 REST-API 提供了灵活的接口，下面是API认证及简单用法的介绍：API认证Harbor API 采用基于 HTTP 的认证，可使用 curl 命令直接提供用户名和密码进行请求，示例：curl -X GET -u 'username:password' "https://{HarborURL.OPSWILL.COM}:{PORT}/api/v2.0/projects"可以通过 base64 算法加密用户名和密码，然后添加 Authorization 头进行认证：curl -H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" "https://{HarborURL.OPSWILL.COM}:{PORT}/api/v2.0/projects"API 的使用方法查看 Harbor 状态可以通过/health API查看 Harbor 组件状态，示例：curl -H 'Accept: application/json' "https://{HarborURL.OPSWILL.COM}:{PORT}/api/v2.0/health"列出全部 projectscurl -H "Accept: application/json" -H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" "https://{HarborURL.OPSWILL.COM}:{PORT}/api/v2.0/projects"列出某个 project 下的全部 repositoriescurl -H "Accept: application/json" -H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" "https://{HarborURL.OPSWILL.COM}:{PORT}/api/v2.0/projects/{PROJECT_NAME}/repositories"列出某个 repo 下的 artifactscurl -H "Accept: application/json" -H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" "https://{HarborURL.OPSWILL.COM}:{PORT}/api/v2.0/projects/{PROJECT_NAME}/repositories/{REPO_NAME}/artifacts"通过上述命令可获取镜像的详细信息，包括标签、漏洞情况、命令、环境变量、暴露端口等。全部 Harbor REST-API可通过 Swagger 查看和使用所有可用 API ，直接访问链接：Harbor REST-API Swagger总结Harbor API 使用起来还是不难的，配合脚本使用很方便。闲着没事又水了一篇文章 :) 。
AWS 会对每个 EKS 版本提供14个月支持，同时 AWS 会强制更新太旧的 EKS 集群，因此可能导致生产服务中断。要升级 EKS 集群，首先要注意以下事项：验证与应用程序关联的容器映像版本是否可用，主要是排除应用异常导致的干扰。检查集群所在子网有足够的可用 IP，可用IP不足也可能会导致 EKS 升级失败。检查 node group 配置中的最大不可用性节点数量，这个数量表示每次更新期间节点组中可以替换的节点数。在大规模集群中，设置为百分比更有利于提高更新的速度。一、升级EKS的检查工作EKS 不允许跨版本升级，在升级 EKS 集群之前需要做兼容性的检查：1.1 与 Kubernetes 相关的变更每一个版本的 EKS 都有一些 API 已被弃用。 因此需要检查特定版本更改的详细信息，需要参考 Kubernetes 的变更日志和 EKS 版本更新文档。https://github.com/kubernetes/kubernetes/tree/master/CHANGELOG 需要重点关注 Urgent Upgrade Notes 部分https://docs.amazonaws.cn/eks/latest/userguide/kubernetes-versions.html1.2 使用 kubent 检查 API 变更国际版的 EKS web console 中提供了 Upgrade insights 工具，可以检查 API 变动。但是中国区没有这个工具，可以使用第三方工具 kube-no-trouble 进行检查。下载 https://github.com/doitintl/kube-no-trouble/releases 并然后解压。确保 kubectl 当前的 context 是要升级的 EKS 集群，使用下面的cmd来检查：kubectl config current-context执行./kubent开始检查。 确保 >>> Deprecated APIs removed in 1.XX <<< 中没有任何内容。注意：要保证 aws cli 的版本应大于2.0，尽量使用新版本的 aws cli 。如果API版本发生变化，需要参考 https://docs.amazonaws.cn/en_us/eks/latest/userguide/update-cluster.html 进行更新。1.3 检查 addons 版本是否需要更新所有的 addon 都在 kube-system namesapce中，首先需要确定所有 addon 需要升级的目标版本号，供后续更新使用。1.3.1 检查兼容的版本使用以下命令可以列出与 EKS 1.22 版本兼容的所有插件版本：查看 kube-proxy 全部兼容的版本aws eks describe-addon-versions --kubernetes-version 1.22 --addon-name kube-proxy | grep addonVersion查看 coredns 兼容的版本aws eks describe-addon-versions --kubernetes-version 1.22 --addon-name coredns | grep addonVersion查看 vpc-cni 兼容的版本aws eks describe-addon-versions --kubernetes-version 1.22 --addon-name vpc-cni | grep addonVersion另外，建议结合 AWS 的文档确定最终要的 addon 版本：https://docs.aws.amazon.com/eks/latest/userguide/managing-kube-proxy.htmlhttps://docs.aws.amazon.com/eks/latest/userguide/managing-coredns.htmlhttps://docs.aws.amazon.com/eks/latest/userguide/managing-vpc-cni.html1.3.2 检查当前 addons 版本您可以使用下面的 AWS CLI 命令来查询 addon的 image 版本：#kube-proxy kubectl get daemonset kube-proxy -n kube-system -o yaml | grep image # coredns kubectl get deployment coredns -n kube-system -o yaml | grep image # vpc-cni kubectl get daemonset aws-node -n kube-system -o yaml | grep image对比一下当前的 addon 版本是否则在兼容列表中，如果不在就需要升级 addon 版本。如果使用了其他插件，也需要查看相关文档以确定兼容性。二、更新 EKS 版本EKS 不允许跨版本升级，所以在升级过程中，需要先升级到1.22版本，然后升级到1.23，最后升级到1.24，以此类推。按照 EKS Control Plane -> EKS addons -> EKS node group 的顺序进行升级。2.1 升级 EKS Control Plane升级的第一步是先升级 EKS Control Plane，这一步很简单，只需要在 eks web console 中点击 “update now” 更新即可。一旦升级成功就无法降级到以前的版本，单独升级 EKS Control Plane 不会重新调度集群中的 Pod，只有升级 node group 时 Pod 才会被重新调度。2.2 更新插件到兼容的版本通常需要更新包括 kube-proxy、coredns、vpc-cni 等 addon 。如果 addon 是通过 eks web console 安装的，也可以在 web console 中直接进行升级。如果插件是 self-managed 的，那么必须找到正确的版本，然后使用 kubectl set image 进行更新。2.2.1 更新 kube-proxy以中国区为例，首先打开 https://docs.amazonaws.cn/eks/latest/userguide/managing-kube-proxy.html ， 需要在文档中找到 kube-proxy 在 ecr 中的镜像地址和 tag 。例如中国北京区的地址是：918309763551.dkr.ecr.cn-north-1.amazonaws.com.cn/eks/kube-proxy tag 是： v1.25.16-minimal-eksbuild.1 查看当前使用的 kube-proxy image ：kubectl get daemonset kube-proxy --namespace kube-system -o=jsonpath='{$.spec.template.spec.containers[:1].image}'更新 image ：kubectl set image daemonset.apps/kube-proxy -n kube-system kube-proxy=918309763551.dkr.ecr.cn-north-1.amazonaws.com.cn/eks/kube-proxy:v1.25.16-minimal-eksbuild.1 命令执行后 kube-proxy 的 pod 就会使用新 image 重建，需要检查 pods 是否成功重建。kubectl get pods -n kube-system2.2.2 更新 coredns同 kube-proxy 类似，访问 https://docs.amazonaws.cn/eks/latest/userguide/managing-coredns.html 找到 ecr 中coredns 的地址和 tag 。查看当前的 image ：kubectl get deployment coredns --namespace kube-system -o=jsonpath='{$.spec.template.spec.containers[:1].image}'更新 coredns image：kubectl set image --namespace kube-system deployment.apps/coredns coredns=918309763551.dkr.ecr.region-code.amazonaws.com.cn/eks/coredns:v1.8.7-eksbuild.32.2.3 更新 vpc-cnivpc-cni的更新不能使用上面的方式，首先访问 https://github.com/aws/amazon-vpc-cni-k8s/releases ，找到要升级的版本，下载对应的 yaml 文件，以v1.16.3 为例：aws 中国区：https://raw.githubusercontent.com/aws/amazon-vpc-cni-k8s/v1.16.3/config/master/aws-k8s-cni-cn.yamlaws 国际区：https://raw.githubusercontent.com/aws/amazon-vpc-cni-k8s/v1.16.3/config/master/aws-k8s-cni.yaml需要确认 yaml 文件中： image 链接的 account numbmer/region code/release tag是否正确，如果下载了对应区域的 yaml 文件通常不需要修改。如果需要修改为防止出错，建议使用 sed 命令全部一次性修改：sed -i.bak -e 's|cn-northwest-1|cn-north-1|' aws-k8s-cni-cn.yaml查看 vpc-cni 插件的版本：kubectl describe daemonset aws-node --namespace kube-system | grep amazon-k8s-cni: | cut -d : -f 3更新 vpc-cni 插件：kubectl apply -f aws-k8s-cni-cn.yaml2.3 更新节点组通常使用的是 aws managed 的 node group ，在升级完 Control Plane 后，在 node group 设置页直接点击 “Update now” 即可。可以选择 rolling update 或者 force update ，rolling update 会先调度 pods 到新节点，因此不会中断业务，但是会更耗时。对 self-managed node group 需要创建新节点组，将应用程序/Pod 迁移到新节点组，然后删除旧节点组。3. EKS 升级后的检查需要检查：验证Control Plane 和节点组版本是否已更新。检查插件的状态。检查节点状态和版本。检查应用是否正常。至此 AWS EKS 的版本升级就结束了。总体来说对于简单的环境不是特别复杂，升级的风险总体可控，风险不是不大。
家里的路由器基本上折腾差不多了，用的是小厂家的x86工控机，硬盘也是捡的垃圾，想到自己时不时的升级/重启/异常断电，还是备份一下的配置文件比较稳妥。我用的路由系统的基于debian的VYOS rolling版本，折腾了半个月已经非常熟悉了，用起来比较爽也很稳定，基本满足自己的各种需求。系统自带了同步配置的功能,每次commit前会先同步配置，支持以下几种方式： http://<user>:<passwd>@<host>/<path> https://<user>:<passwd>@<host>/<path> ftp://<user>:<passwd>@<host>/<path> sftp://<user>:<passwd>@<host>/<path> scp://<user>:<passwd>@<host>/<path> tftp://<host>/<path> git+https://<user>:<passwd>@<host>/<path>以上方式中，最简单的就是http方式了，经过抓取日志，vyos 会使用 "POST /config.boot-vyos.20240205_171540" 方式上传配置，其中uri是配置文件名，包体是明文的配置内容，正好我的服务器使用的是openresty，就用lua写了一个简单的文件服务器，接收vyos POST过来的配置。lua脚本实现了以下功能：接收POST包体并保存到文件，文件名为POST请求的uri使用http basic authentication的用户名密码认证接收POST请求,直接接收POST包体然后使用io.open写入文件： local file local upload_path = '/var/www/html/vyos/' ngx.req.read_body() local file_name = ngx.var.uri local data = ngx.req.get_body_data() if file_name then file = io.open(upload_path .. file_name, 'a+') if not file then ngx.log(ngx.ERR, "failed to open file ", file_name) return end end if data then file:write(data) file:close() else ngx.log(ngx.ERR, "data is empty") return end用户名密码认证，使用nginx也能实现，但是认证文件要单独写进文件里。用lua就一个配置文件就能可以了。http basic auth主要是是解析http头Authorization，并使用base64解密用户名密码： local function split(str, delimiter) local result = {} if not str or not delimiter then return result end for match in (str..delimiter):gmatch("(.-)"..delimiter) do if match then table.insert(result, match) end end return result end local function parseHeader(authorization) local base64 = string.sub(authorization, 7) local data = split(ngx.decode_base64(base64), ":") return data[1], data[2] end local header = ngx.req.get_headers()["Authorization"] if header then local username, password = parseHeader(header) if username ~= "vyos.user" or password ~= "vyos.pass" then ngx.header["WWW-Authenticate"] = Basic realm="restricted" ngx.exit(401) end end以上就是核心逻辑，经过测试ngx.req.get_body_data() 读请求包体，会偶尔出现读取不到直接返回 nil 的情况。还需要额外设置client_max_body_size 和client_body_buffer_size, 强制在内存中保存请求体。完整的nginx配置文件如下：server { listen 80; server_name backup.opswill.com ; access_log /var/log/backup_access.log main; error_log /var/log/backup_error.log; client_max_body_size 100m; client_body_buffer_size 100m; location / { content_by_lua_block { local function split(str, delimiter) local result = {} if not str or not delimiter then return result end for match in (str..delimiter):gmatch("(.-)"..delimiter) do if match then table.insert(result, match) end end return result end local function parseHeader(authorization) local base64 = string.sub(authorization, 7) local data = split(ngx.decode_base64(base64), ":") return data[1], data[2] end local header = ngx.req.get_headers()["Authorization"] if header then local username, password = parseHeader(header) if username ~= "vyos.user" or password ~= "vyos.pass" then ngx.header["WWW-Authenticate"] = Basic realm="restricted" ngx.exit(401) end end local file local upload_path = '/var/www/html/vyos/' ngx.req.read_body() local file_name = ngx.var.uri local data = ngx.req.get_body_data() if file_name then file = io.open(upload_path .. file_name, 'a+') if not file then ngx.log(ngx.ERR, "failed to open file ", file_name) return end end if data then file:write(data) file:close() else ngx.log(ngx.ERR, "data is empty") return end } } }用curl测试文件上传： curl -X POST -d"body-abd-opswill.com" "https://vyos.user:vyos.pass@backup.ipip.dev/test.txt" -ivvvv会在/var/www/html/vyos/下生成 text.txt文件，内容是body-abd-opswill.com。至此，一个使用lua实现的简单的文件服务器就配置完成了，该文件服务器可以接收post上传文件到指定目录并实现了基本的用户名密码认证。
MongoDB的分布式架构是通过使用Sharding（分片）来实现的，使用MongoDB的sharding可以带来以下好处：横向扩展：通过添加更多的shard，MongoDB能够处理更大的数据集和更高的并发访问，提供更好的性能和可扩展性。高可用性：通过将数据复制到多个shard上，并使用复制集提供数据冗余和自动故障转移，MongoDB能够提供高可用性。负载均衡：Mongos路由请求到不同的shard上，从而实现负载均衡，避免单个shard成为瓶颈。一、分片集群组件在一个分片集群中，包含以下组件：分片(shards)，实际存储数据的节点。集群配置集(config)，存储整个分片集群的metadata信息。路由(mongos)，作为应用接入路由节点，是mongodb数据的入口。1.1 Shards每个shard是一个独立的MongoDB实例，负责存储一部分数据，多个独立的shard分片组成整个shards分片集群。每个shard都可以是一个独立的物理服务器或一个复制（Replica Set）。Shard可以动态地添加或删除，以适应数据量的变化。Shard Key用来将数据分发到不同shard。MongoDB根据Shard Key决定将数据存储在哪个shard上。1.2 Config servers（metadata）Config Server是存储集群的元数据（metadata）的特殊MongoDB实例。元数据包括分片键（shard key）的范围和每个分片负责的数据范围。Config Server通常以复制集的形式部署，官方推荐至少3个节点，以提供高可用性和数据冗余。作为config server的复制集必须采用 WiredTiger storage engine。副本节点中没有arbiter。副本节点中没有delayed members。副本节点中必须build indexes。1.3 Router（mongos）mongos是应用程序和分片集群之间的接口，所有对集群的操作及数据读写都连接到mongos进行。应用程序通过Mongos与集群交互，Mongos负责将请求路由到正确的shard上。Mongos还负责跟踪分片集群的状态和管理元数据。二、配置高可用mongodb分片集群MongoDB sharding 高可用集群配置要点：配置mongodb分片集群时，推荐对每个mongod组件使用用域名或hosts绑定。shard集群至少配置2个分片，每个分片都为复制集(1 Primary + 1 Replica + 1 Arbiter)，实现数据冗余。至少3个config server，实现集群高可用。启用多个mongos实例，通过在应用上配置，实现负载均衡。2.1 集群配置说明通常按以下顺序进行集群配置：分别创建shards、config、mongos配置文件确认mongodb各目录是否存在及权限正确按顺序配置并启动分片集群各个组件实例(依次启动config->shards->mongos)初始化复制集分片初始化config server复制集配置集群分片，对DB或Collection开启分片功能使用数据库都连接到mongos节点进行操作2.2 各组件的配置文件2.2.1 shard 配置文件 shard.conf多个shard节点配置相同，需注意dbPath是否存在且权限正确。processManagement: fork: true net: port: 27018 bindIp: 0.0.0.0 systemLog: destination: file path: /opt/mongodb.log logAppend: true logRotate: rename storage: journal: enabled: true dbPath: /opt/data/mongodb/ directoryPerDB: true engine: wiredTiger wiredTiger: engineConfig: cacheSizeGB: 10 directoryForIndexes: true collectionConfig: blockCompressor: zlib indexConfig: prefixCompression: true replication: oplogSizeMB: 20480 replSetName: shardN sharding: clusterRole: shardsvr 2.2.2 config server 配置文件 config.conf多个config节点配置相同，需注意dbPath是否存在且权限正确。processManagement: fork: true net: port: 27019 bindIp: 0.0.0.0 systemLog: destination: file path: /opt/configsrv.log logAppend: true logRotate: rename storage: journal: enabled: true dbPath: /opt/data/configsrv/ directoryPerDB: true engine: wiredTiger wiredTiger: engineConfig: cacheSizeGB: 1 directoryForIndexes: true collectionConfig: blockCompressor: zlib indexConfig: prefixCompression: true replication: replSetName: config sharding: clusterRole: configsvr 2.2.3 Router 配置文件 mongos.confprocessManagement: fork: true net: port: 27017 bindIp: 0.0.0.0 systemLog: destination: file path: /opt/mongos.log logAppend: true logRotate: rename sharding: configDB: config/config_server1:27019,config/config_server2:27019,config/config_server3:27019, 2.3 启动各个组件的实例需要注意目录权限及用户，一般这两都默认都是mongodb。shard和config的启动都是使用mongod启动:mongod --config /opt/shard.conf mongod --config /opt/config.conf启动mongos：mongos --config /opt/config.conf三、 初始化分片集群3.1 初始化shard连接到每个shard，分别执行以下命令进行初始化：mongo shard1:27018 use admin db.runCommand({ "replSetInitiate":{ "_id":"RsDBNAME", "members":[ {"_id":1,"host":"shard1:27018","priority": 2}, {"_id":2,"host":"shard2:27018"}, {"_id":3,"host":"shard3:27018","arbiterOnly":true} ] } })3.2 初始化config server复制集连接到每个config server，分别执行以下命令进行初始化：mongo config1:27019 use admin rs.initiate({ _id: "configReplSetNAME", configsvr: true, members: [ { _id: 1, host: "config1:27019", "priority": 2 }, { _id: 2, host: "config2:27019" }, { _id: 3, host: "config3:27019" } ] })3.3 连接到mongos进行集群分片的配置mongo mongos1:27017 use admin sh.addShard("shardN/shard1:27018") sh.addShard("shardN/shard2:27018") sh.addShard("shardN/shard3:27018")shardN为配置文件中replica_set的名字，按需进行修改。四、启用分片功能4.1 对某个库开启分片先连接mongos在执行：mongo mongos1:27017 use admin sh.enableSharding("DBtest")4.2 对某个Collection启用分片对Collection启用分片需先创建索引：db.MYcollection.createIndex({ field1: 1 }) sh.shardCollection("MYdatabase.MYcollection", { field1: 1 })五、日常使用及测试使用数据库都要连接到mongos节点进行操作:mongo mongos1:27017 use test db.MYcollection.insert({"test": 1}) db.MYcollection.find()参考文章：https://www.mongodb.com/docs/manual/sharding/https://vastxiao.github.io/article/2019/12/06/mongodb-setup-config-sharding-cluster/
之前写了一篇 借助 CloudWatch 和 WAF 缓解 DDOS 攻击,经过实践发现了一些问题：在受到攻击时 Cloudwatch 产生存储和查询的费用都高获取详细攻击信息使用了较多的 log 查询，查询次数多、时间长通过 Cloudwatch 中日志统计攻击 IP ，获取 IP 比较慢，且不够准确发生攻击时产生的请求已经非常多了，将请求转换成日志在分析，不是经济和有效率的做法。查了一下 boto3 的文档，发现 Waf 已经提供了一个获取被频率限制 ip 的 api : get_rate_based_statement_managed_keys而且通过分析 sampled request : get_sampled_requests 可以获取攻击的域名。既然可以直接获取攻击 IP 和域名，Cloudwatch 的日志存储和分析的费用就可以省下了。更新了一下 Lambda 代码，如下：import boto3 from collections import Counter from datetime import datetime, timedelta def lambda_handler(event, context): acl_name = 'webapi' acl_id = 'ada04a19-0fc9-5abd-c497-6646b2369d8e' arn = 'arn:aws:wafv2:us-east-1:849873481391:global/webacl/webapi/eda04a18-9fc8-4abc-b497-5646b2369d8g' rule_names = ['WAF-RateLimit-URI','WAF-RateLimit-All'] ip_set_name = 'Global_blacklist-auto' # 检查 CloudWatch Alarm 状态 print(f"Getting CloudWatch Alarm state...") cloudwatch_client = boto3.client('cloudwatch') alarm_name = 'WAF-ALL-BLOCKED-Req-5mins' response = cloudwatch_client.describe_alarms( AlarmNames=[alarm_name] ) alarm_state = response['MetricAlarms'][0]['StateValue'] print(f"CloudWatch Alarm state: {alarm_state}") if alarm_state == 'OK': print("CloudWatch Alarm is in OK state. Exiting Lambda function.") return merged_ip_addresses = [] wafv2_client = boto3.client('wafv2') # 从sampled requests获取被攻击域名，并告警 host_counter = Counter() print("Step 1: Getting all rate limited rule logs from WAF...") for rule in rule_names: response = wafv2_client.get_sampled_requests( WebAclArn=arn, RuleMetricName=rule, Scope='CLOUDFRONT', TimeWindow={ 'StartTime': (datetime.utcnow() - timedelta(hours=3)).strftime("%Y-%m-%dT%H:%MZ") , 'EndTime': datetime.utcnow().strftime("%Y-%m-%dT%H:%MZ") }, MaxItems=500 ) for request in response['SampledRequests']: headers = {header['Name']: header['Value'] for header in request['Request']['Headers']} if 'Host' in headers: host_counter[headers['Host']] += 1 most_common_host = host_counter.most_common(1) print(f'Most common host: {most_common_host[0][0]} with {most_common_host[0][1]} requests') print("Step 2: Getting all rate limited IPs from WAF...") for rule_name in rule_names: response = wafv2_client.get_rate_based_statement_managed_keys( Scope='CLOUDFRONT', WebACLName=acl_name, WebACLId=acl_id, RuleName=rule_name ) managed_keys_ipv4 = response['ManagedKeysIPV4']['Addresses'] print(f"Rule: {rule_name} has: {len(managed_keys_ipv4)} blocked IPs") print(managed_keys_ipv4) merged_ip_addresses.extend(managed_keys_ipv4) print(f"Total Rate limited ip: {len(merged_ip_addresses)}") print(merged_ip_addresses) print("Step 3: Getting existing IPs from ip set...") response = wafv2_client.list_ip_sets( Scope='CLOUDFRONT' ) ip_sets = response['IPSets'] ip_set_id = None lock_token = None for ip_set in ip_sets: if ip_set['Name'] == ip_set_name: ip_set_id = ip_set['Id'] lock_token = ip_set['LockToken'] break # 获取现有 IP 集的 IP 地址 response = wafv2_client.get_ip_set( Name=ip_set_name, Id=ip_set_id, Scope='CLOUDFRONT' ) existing_ip_addresses = response['IPSet']['Addresses'] print(f"Total existing IPs: {len(existing_ip_addresses)}") print(existing_ip_addresses) # 将 merged_ip_addresses 合并到现有 IP 地址中 merged_ip_addresses = list(set(existing_ip_addresses + merged_ip_addresses)) print(f"Merged IP addresses: {merged_ip_addresses}") cidr_addresses = [] for ip_address in merged_ip_addresses: ip, _ = ip_address.split('/') cidr_address = '.'.join(ip.split('.')[:-1]) + '.0/24' cidr_addresses.append(cidr_address) # 对CIDR地址进行去重 unique_cidr_addresses = list(set(cidr_addresses)) print(f"Block /24 ip range : {len(unique_cidr_addresses)}") print(unique_cidr_addresses) print(f"Updating IP Set {ip_set_name} in CloudFront WAF...") response = wafv2_client.update_ip_set( Name=ip_set_name, Scope='CLOUDFRONT', Id=ip_set_id, LockToken=lock_token, Description='IP blacklist for Global WAF add by Cron', Addresses=unique_cidr_addresses ) print(f"Total blocked IP ranges: {len(unique_cidr_addresses)}")其他的配置同上一篇文章，主要是 IAM role /超时时间/ EventBridge 定时执行 这三点设置。这个 Lambda 实现了以下功能：检测 Cloudwatch 中的 Alarm 的状态，In-Alarm 才会执行拉黑统计被攻击的域名，可以进行自定义告警，代码中只是打印到日志Cloudfront 只开启了 IPv4， 所以只获取 WAF 中被拦截的 IPv4 地址获取 IP 地址后去重，并拦截整个C段 /24，减少 WAF IP Set 中的规则数量每次执行先获取已经存在的 IP Set ，在加入新的攻击 IP 段这里发现了 boto3 waf api 的一个坑，文档里面说 rate limit 规则可以在 rule group 中，查询时只需要带上 RuleGroupRuleName 参数即可，但是实际上是查询会抛异常 WAFNonexistentItemException ，所以不要将 rate limit 相关的规则放入 rule group 中。
先来几张图说明一下做这个的原因：一、前提条件需要使用以下几个服务：AWS WAF 启用全局频率限制、全局黑名单、启用 logging 输出日志AWS CloudWatch 存储日志，攻击检测，发送告警到SNSAWS SNS 触发Lambda相关功能AWS Lambda 发送通知，更新 AWS WAF 全局黑名单Amazon EventBridge 定期执行 Lambda函数，更新 WAF 黑名单1.1 AWS WAF 配置需要在 WAF ACL 中设置以下几项：创建一个 IP Sets 作为全局黑名单创建一个全局黑名单规则，如果 IP 在全局黑名单中则 block，并设置优先级最高根据业务需求创建一个全局频率限制规则，优先级仅次于黑名单规则启用 WAF 的 logging，设置 Logging destination 为 CloudWatch，设置一个 filter ： rule action on request 是 block 就 keep in logs二、攻击检测及告警配置2.1 Cloudwatch 配置Cloudwatch 的告警基于 WAF ACL 中频率限制的规则。在 Cloudwatch 中设置一个告警，告警 Metric 基于 AWS/WAFV2 中 五分钟内的 BlockedRequests 总数，如果5分钟内被限制的请求大于 3000 则是 In alarm 状态，则触发 SNS Alert 告警通知；否则是 OK 状态，触发 SNS OK 通知 。2.2 Amazon SNS 配置SNS 需要配置两个 Standard 的 topic，一个是WAF-Ddos-Alert，一个是WAF-Ddos-Ok，用于触发 Lambda 发送攻击告警和攻击结束通知。2.3 编写 Lambda 实现攻击检测和告警新建一个 Lambda python function，代码放在下面，设置以下参数：trigger 设置为 SNS topic ： WAF-Ddos-AlertLambda 的 Execution role 需添加权限 ： CloudWatchReadOnlyAccess CloudWatchLogsReadOnlyAccess设置 telegram 相关的环境变量 ： BOT_TOKEN 和 USER_WILL设置超时时间为5分钟代码如下：import os import json import urllib3 import boto3 from datetime import datetime, timedelta import time def lambda_handler(event, context): # 从环境变量中获取 Telegram Bot Token 和聊天 ID bot_token = os.environ['BOT_TOKEN'] user_will_chat_id = os.environ['USER_WILL'] send_telegram_message(bot_token,user_will_chat_id, "请注意有DDos攻击！！！") #按实际情况修改 log_group_name = 'aws-waf-logs-All-Blocked-Logs' # 获取当前时间和五分钟前的时间 current_time = datetime.now() period = current_time - timedelta(minutes=15) # 创建 CloudWatch Logs Insights 客户端 logs_client = boto3.client('logs') queries = [ { 'name': 'Top5_Host', 'query': "fields @timestamp, @message | parse @message '{\"name\":\"Host\",\"value\":\"*\"}' as host | stats count(*) as requestCount by host | sort requestCount desc | limit 5" }, { 'name': 'Top5_IP', 'query': "fields httpRequest.clientIp | stats count(*) as requestCount by httpRequest.clientIp | sort requestCount desc | limit 5" }, { 'name': 'Top5_Country', 'query': "fields httpRequest.country | stats count(*) as requestCount by httpRequest.country | sort requestCount desc | limit 5" }, { 'name': 'Top5_Rule', 'query': "fields terminatingRuleId | stats count(*) as requestCount by terminatingRuleId | sort requestCount desc | limit 5" } ] msg = '\n' for query in queries: response = logs_client.start_query( logGroupName=log_group_name, startTime=int(period.timestamp()), endTime=int(current_time.timestamp()), queryString=query['query'] ) query_id = response['queryId'] print(f"Started query {query['name']}. Query ID: {query_id}") # 等待查询完成 retries = 0 max_retries = 5 while retries < max_retries: query_response = logs_client.get_query_results(queryId=query_id) if len(query_response['results']) > 0 and 'status' in query_response: status = query_response['status'] print(f"Query Status: {status}") print(query_response) if status == 'Complete': break retries += 1 if retries == max_retries: raise Exception("Query did not complete within the allowed time") else: print(f"Retrying {retries}...") time.sleep(10) msg += f"{query['name']}: \n" for result in query_response['results']: request_item = [item['value'] for item in result ][0] request_count = [item['value'] for item in result if item['field'] == 'requestCount'][0] msg += f"{request_item} --> {request_count}\n" print(msg) send_telegram_message(bot_token,user_will_chat_id, msg) def send_telegram_message(bot_token, chat_id, message): bot_api_url = f"https://api.telegram.org/bot{bot_token}/sendMessage" http = urllib3.PoolManager() # 准备请求数据 data = { 'chat_id': chat_id, 'text': message } # 发送 POST 请求到 Telegram 机器人 API msg_response = http.request('POST', bot_api_url, fields=data) # 根据响应内容判断是否成功发送消息 if json.loads(msg_response.data.decode('utf-8'))['ok']: print(f"Message sent to chat {chat_id}: {message}") else: print(f"Failed to send message to chat {chat_id}")该 Lambda 函数会在收到SNS通知时，先发送攻击通知，然后从 Cloudwatch 中所有被 block 请求中查询访问次数最多的5个域名/5个IP/5个国家/5个匹配到的waf规则。在发送 Cloudwatch 查询到的攻击信息。三、攻击缓解有了以上的准备工作，缓解攻击只需要统计一下 、攻击的 IP，然后更新 WAF 的黑名单即可，为了快速检测攻击 IP，可以在Amazon EventBridge 中设置一个定时器，每五分钟触发一下 Lambda 更新 WAF的黑名单。3.1 EventBridge 设置 Scheduler在 EventBridge -> Scheduler 中新建一个定时器，类型为：Recurring schedule，Schedule type是 Rate-based schedule 时间设置成5分钟，Target 设置为 Lambda 下面的函数。3.2 使用 Lambda 更新 WAF 黑名单在创建一个 Lambda函数，配置参数如下：trigger 可以不设置，如果需要手动拉黑，在可以设置为一个新的SNS topicLambda 的 Execution role 需添加权限 ： CloudWatchReadOnlyAccess、CloudWatchLogsReadOnlyAccess、AWSWAFFullAccess设置超时时间为5分钟Lambda 代码如下：import boto3 from datetime import datetime, timedelta import time def lambda_handler(event, context): log_group_name = 'aws-waf-logs-All-Blocked-Logs' ip_set_name = 'Global_blacklist-auto' # 获取当前时间和统计攻击的时间窗口 current_time = datetime.now() period = current_time - timedelta(minutes=10) # 创建 CloudWatch Logs Insights 客户端 logs_client = boto3.client('logs') print("Step 1: Executing query to filter blocked IP addresses...") # 执行查询语句，获取统计时间窗口内被 block 次数大于100次的 IP 地址列表 query = "fields httpRequest.clientIp | stats count(*) as requestCount by httpRequest.clientIp | filter requestCount > 100 | sort requestCount desc" response = logs_client.start_query( logGroupName=log_group_name, startTime=int(period.timestamp()), endTime=int(current_time.timestamp()), queryString=query ) query_id = response['queryId'] print(f"Started with Query ID: {query_id}") print("Step 2: Waiting for query to complete...") # 等待查询完成 retries = 0 max_retries = 10 while retries < max_retries: query_status = logs_client.get_query_results(queryId=query_id) if len(query_status['results']) > 0 and 'status' in query_status: status = query_status['status'] print(f"Query Status: {status}") print(query_status) if status == 'Complete': break retries += 1 if retries == max_retries: raise Exception("Query did not complete within the allowed time") else: print(f"Retrying {retries}...") time.sleep(10) print("Step 3: Getting all blocked ips from cloudwatch...") # 提取 IP 地址并整理为 WAF 可以使用的格式 ips = [result[0]['value'] for result in query_status['results']] # 去重 IP 地址 ips = list(set(ips)) print(f"taotal ip： {len(ips)}") print(ips) print("Step 4: Getting IP Addresses from ip_set in CloudFront WAF...") wafv2_client = boto3.client('wafv2') response = wafv2_client.list_ip_sets( Scope='CLOUDFRONT' ) ip_sets = response['IPSets'] ip_set_id = None lock_token = None for ip_set in ip_sets: if ip_set['Name'] == ip_set_name: ip_set_id = ip_set['Id'] lock_token = ip_set['LockToken'] break # 获取现有 IP 集的 IP 地址 response = wafv2_client.get_ip_set( Name=ip_set_name, Id=ip_set_id, Scope='CLOUDFRONT' ) existing_ip_addresses = response['IPSet']['Addresses'] print(f"tatal existing ips: {len(existing_ip_addresses)}") print(existing_ip_addresses) print(f"Step 5: Updating IP Set {ip_set_name} in CloudFront WAF...") # 将 cidr_addresses 合并到现有 IP 地址中 cidr_addresses = [ip + '/32' for ip in ips] merged_ip_addresses = list(set(existing_ip_addresses + cidr_addresses)) print(merged_ip_addresses) response = wafv2_client.update_ip_set( Name=ip_set_name, Scope='CLOUDFRONT', Id=ip_set_id, LockToken=lock_token, Description='IP blacklist for Global WAF', Addresses=merged_ip_addresses ) print(f"total blocked ips: {len(merged_ip_addresses)} ")3.3 其他设置其他措施主要是完善 WAF 规则，增强 lambda 脚本的功能 及 DNS 分流等，可以根据实际情况做很多配置。四、告警通知和效果攻击时需要注意一下 WAF/Cloudfront/Cloudwatch的费用，心里要有预期。两个 Lambda 函数都有非常详细的日志打印到 Cloudwatch 的 log group 中，可以从 Cloudwatch中查看。告警通知如下：
在使用Cloudflare CDN时，CDN传给后端服务器中只传递了有限的 http request hearder。其中只包含了非常简陋的 ip 信息，例如只有 CF-IPCountry ，不足以实现一个查询ip信息的api，要想实现查询ip详细信息需要借助 Cloudflare Workers 来实现。Cloudflare Workers 传入的 HTTP 请求都被称为 fetch 事件，fetch 事件中都包含一个 Request 接口实例,在这个 Request 实例中就包括了访问 ip 的详细信息通过访问 Request.cf 可以获得 Cloudflare 提供的请求信息，包含了 ip 的详细信息；访问 Request.headers 则可以获得访客的 HTTP headers，写一个简单的 Workers 处理一下 Request.cf 、Request.headers 中的信息，并返回 Json 格式即可。访问该 Cloudflare Workers 后返回包含访客 IP、Continent、Country、Region、ASN 等 ip 信息及请求的Header信息，同时也会把 Cloudflare 的 https 连接信息及 bot 识别的相关信息一起返回。代码如下：export default { async fetch(request) { const data = { Method: request.method, Url: request.url, IP: { IP: request.headers.get('CF-Connecting-IP'), Continent: request.cf.continent, Country: request.cf.country, IsEU: request.cf.isEUCountry, Region: request.cf.region, RegionCode: request.cf.regionCode, City: request.cf.city, Latitude: request.cf.latitude, Longitude: request.cf.longitude, PostalCode: request.cf.postalCode, MetroCode: request.cf.metroCode, Colo: request.cf.colo, ASN: request.cf.asn, ASOrganization: request.cf.asOrganization, Timezone: request.cf.timezone }, Headers: {}, Security: {} }; // 遍历并存储每个 HTTP 头，排除以 cf- 开头的 HTTP 头 request.headers.forEach((value, name) => { if (!name.toLowerCase().startsWith('cf-')) { data.Headers[name] = value; } }); // 遍历 request.cf 并存储所需对象的属性到 Security 中 for (const key in request.cf) { if ( key == 'clientTcpRtt' || key == 'tlsCipher' || key == 'tlsVersion' || key == 'httpProtocol' || key == 'clientHandshake' || key == 'clientFinished' || key == 'serverHandshake' || key == 'serverFinished' || key == 'corporateProxy' || key == 'verifiedBot' || key == 'score' ) { if (typeof request.cf[key] === 'object') { for (const innerKey in request.cf[key]) { data.Security[innerKey] = request.cf[key][innerKey]; } } else { data.Security[key] = request.cf[key]; } } } var dataJson = JSON.stringify(data, null, 4); console.log(dataJson); return new Response(dataJson, { headers: { "Content-Type": "application/json;charset=UTF-8" } }) } };需要注意 Cloudflare Workers 的域名被屏蔽了，建议绑定一个域名来访问。请求该 Workers 后返回如下 JSON ：{ "Method": "GET", "Url": "https://ip.ipip.dev/", "IP": { "IP": "149.234.194.181", "Continent": "NA", "Country": "US", "Region": "Missouri", "RegionCode": "MO", "Latitude": "38.57740", "Longitude": "-90.67090", "Colo": "IAD", "ASN": 23167, "ASOrganization": "Bayer-arch", "Timezone": "America/Chicago" }, "Headers": { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-encoding": "gzip", "accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", "connection": "Keep-Alive", "host": "ip.ipip.dev", "sec-ch-ua": "\"Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"115\", \"Chromium\";v=\"115\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "none", "sec-fetch-user": "?1", "upgrade-insecure-requests": "1", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", "x-forwarded-proto": "https", "x-real-ip": "149.234.194.181" }, "Security": { "clientTcpRtt": 1, "tlsCipher": "AEAD-AES128-GCM-SHA256", "tlsVersion": "TLSv1.3", "httpProtocol": "HTTP/2" } }