标签 routeros 下的文章

刚注意到我的一台路由器的 DDNS 功能失灵了,看了一下用的是一个从 github 上找的支持 cloudflare DNS 的脚本,现在一执行就报错。本想改改继续用,觉得这个脚本不够优雅,还要生成临时文件。于是就自己写了一个,特点是支持多路 PPPOE 拨号,不生成临时文件,也不定义临时的全局变量,并支持发送钉钉消息(如不需要把 dingtalk 值留空或相关语句屏蔽即可)。

脚本实际上分为两个,第一个脚本放在 /ppp/Profiles 那里,打开 pppoe 拨号使用的那个 profile 条目,在 scripts 标签页下的 On Up 编辑框里填写如下内容:

# 定义要调用的 DDNS 脚本名称
:local scrName "CloudFlare_DDNS"
# 获取拨号接口名称
:local ifname [/interface get $interface name];
# 获取公网IP
:local currentIP $"local-address";
# 定义要传输的变量
:local var1 "ifname=$ifname"
:local var2 "currentIP=$currentIP"
# 调用 DDNS 脚本并注入变量
:execute script="[[:parse \"[:parse [/system script get $scrName source]] $var1 $var2\"]]"

第二个脚本在 /system/scripts 处添加,命名为“CloudFlare_DDNS”,内容如下:

# =========================================================
# --- Cloudflare DDNS 全自动脚本 for RouterOS v7 ---
# --- 自动从On Up事件获取IP和接口 ---
# =========================================================

# ----------------------------------------------------
# --- 参数配置 ---
# ----------------------------------------------------
# CloudFlare Token(DNS 权限)
:local cfToken  "你的 Token 数据"
# CloudFlare 区域 ID
:local cfZoneId "你的区域 ID"

# --- 接口 -> 域名 映射关系 (在这里扩展) ---
# 如果只有一条拨号线路,只须将域名赋值给 cfDomain,将运营商名字赋值给 ISP;
# 后面两行 if 语句屏蔽即可。
:local cfDomain "lt.yourdomain.com"
:local ISP "联通"
# 多条线路时使用
# 如果有多条拨号线路,上面的 cfDomain 和 ISP 两个值留空(不可删除);
# 取消下面两条 if 语句前面的 #,并按实际情况填写 ifname(拨号接口名称)、cfDomain 和 ISP。
# if ($ifname = "pppoe-out-LT") do={:set cfDomain "lt.yourdomain.com";:set ISP "联通"}
# if ($ifname = "pppoe-out-YD") do={:set cfDomain "yd.yourdomain.com";:set ISP "移动"}
# 如果有第三个拨号,在这里继续添加。

# --- 调用钉钉发送通知(可选,关键字方式)---
# 不需要钉钉时可以将 url 变量留空,或者全部删除
:global dingtalk do={
    :local keyword "关键字";
    :local url  "https://oapi.dingtalk.com/robot/send?access_token=你的钉钉token"
    :local now ([/system/clock get date] ." ".[/system/clock get time])
    :local data "{\"msgtype\": \"text\", \"text\": {\"content\": \"$now $1 $keyword\"}}"
    :local header "Content-Type:application/json";
    :log info "data: $data"
     if ([:len $url] > 0) do={
    do {  
        /tool/fetch http-method=post mode=https http-header-field="$header" http-data="$data" url="$url"
    } on-error={log error "发送钉钉消息失败。"}
    }
}

# ----------------------------------------------------
# --- 合法性检查 ---
# 接口检查
if ([:len $ifname] = 0) do={
    :log warning "未获取到 PPPOE 接口名称,脚本退出。"
    :quit
}
# 域名检查
if ([:len $cfDomain] = 0) do={
    :log warning "域名为空,脚本退出。"
    :quit
}
# 公网 IP 检查
if ([:len $currentIP] = 0) do={
    :log warning "未获取到公网 IP 地址,脚本退出。"
    :quit
} else={:log info "$ISP 接口名称:$ifname,公网 IP 地址:$currentIP,匹配域名:$cfDomain"}

# ---  查询Cloudflare上的DNS记录信息 ---
:local recordId ""
:local dnsIP ""
:local recordQueryUrl "https://api.cloudflare.com/client/v4/zones/$cfZoneId/dns_records?name=$cfDomain&type=A"
:local queryResponse [/tool fetch url=$recordQueryUrl http-header-field="Authorization: Bearer $cfToken" as-value output=user]
if ([:find ($queryResponse->"data") "\"success\":true"] != nil) do={
    :local responseData ($queryResponse->"data")
    :local idStart [:find $responseData "\"id\":\""]
    if ($idStart != nil) do={
        :set idStart ($idStart + 6); :local idEnd [:find $responseData "\"" $idStart]; :set recordId [:pick $responseData $idStart $idEnd]
        :local contentStart ([:find $responseData "\"content\":\""] + 11); :local contentEnd [:find $responseData "\"" $contentStart]; :set dnsIP [:pick $responseData $contentStart $contentEnd]
        :log info "$ISP接口:域名 $cfDomain 当前解析记录为: $dnsIP"
    } else={ :log error "$ISP接口:API调用成功,但未找到域名 $cfDomain 的 A 类型记录。"; :error "未找到DNS记录" }
} else={ :log error "$ISP接口:查询 DNS 信息失败。"; :error "查询DNS信息失败" }

# --- 对比IP并决定是否更新 ---
if ([:len $recordId] > 0) do={
    if ($currentIP = $dnsIP) do={
        :log info "$ISP接口:IP地址 ($currentIP) 未变更,无需更新。"
    } else={
        :log warning "$ISP接口:IP地址已变更 ($dnsIP -> $currentIP)。准备更新..."
        # --- 执行更新操作 ---
        :local cfApiUrl "https://api.cloudflare.com/client/v4/zones/$cfZoneId/dns_records/$recordId"
        :local jsonData "{\"type\":\"A\",\"name\":\"$cfDomain\",\"content\":\"$currentIP\",\"ttl\":120,\"proxied\":false}"
        :local updateResponse [/tool fetch url=$cfApiUrl http-method=put http-header-field="Authorization: Bearer $cfToken,Content-Type: application/json" http-data=$jsonData as-value output=user]
        if ([:find ($updateResponse->"data") "\"success\":true"] != nil) do={
            :local msg "$ISP接口:公网 IP 变更为  $currentIP,域名 $cfDomain 的DNS记录更新成功。"
            :log info $msg
            # ---发送钉钉消息,可选 ---
            [$dingtalk $msg]
        } else={ :log error "$ISP接口:DNS记录更新失败。" }
    }
}

CloudFlare 的 Token 和 ZoneId 数据需要自行在 CloudFlare 官网上去设置或查找。
和其它脚本需要手工指定拨号接口、从 /ip/address 处查询 IP、甚至需要生成全局变量或临时文件不同,这个脚本的有趣之处在利用 On Up 事件时系统返回的端口 ID 号和 IP 地址,并巧妙地传递给了修改域名解析的脚本(RouterOS 脚本并不支持命令行方式传入参数)。

前言:
RouterOS 路由器单纯的使用脚本无法实现阿里云动态域名功能(无法给请求数据加签),需要借助其它代码写的转发代理。以前是在一个 Windows 版的工具里增加了相关功能代码,后来服务器要关掉,准备找个能部署到网站上的版本。网上找了一圈发现很多代码都是基于阿里云 SDK 的,需要在服务器上安装,而我用的那个网站空间只能上传文件不支持安装。于是自己写了一个基于阿里云 OpenAPI V3 版签名机制的 PHP 版本,只有一个 PHP 文件和一个 RouterOS 脚本,实测使用正常。顺带感慨:AI 辅助编程真的很方便。

RouterOSv7 For Aliyun DDNS
支持阿里云域名动态解析的 RouterOS V7 脚本及对应的转发代理。

说明:

  • 需要 RouterOS 脚本搭配一个转发代理程序使用。转发代理使用阿里云的 V3 版签名方案,只有一个 PHP 文件,不需要安装 SDK,可部署到各种 PHP 网站空间。
  • 只支持 RouterOS V7 以上版本。转发代理功能经过简化,只支持以 GET 方式进行域名查询和修改两个功能。
  • RouterOS 的开头部分为用户变量,需要自行修改,详见注释。
  • 域名的 DNS 服务器要指向阿里云。需要在阿里云上自行配置一个支持 DNS 解析权限的 AccessKey。
  • 为防止转发代理被滥用,使用了安全令牌(securityToken)和白名单,安全令牌需要 RouterOS 脚本和 PHP 代码中保持一致,白名单在 PHP 代码中添加。详见代码中的注释。
  • RouterOS 脚本中增加了一个通过钉钉机器人发送消息通知功能,若不需要可自行屏蔽。
  • RouterOS 脚本目前的设置是 IPV4,如要支持 IPV6,需要将 recordType 的值由 A 改为 AAAA,同时修改获取公网 IP 的方式:如从支持 IPV6 的路由器接口获取 IP,或者由 https://6.ipw.cn 之类的网站返回 IP。
  • 转发代理目前只有一个 PHP 版本,以后可能会增加其它语言版本。
  • PHP 端有日志功能,并可自行关闭,详见代码中用户配置部分。日志中不会记录用户的 AccessKeySecret。
  • RouterOS 脚本建议由 PPPOE 拨号 的 Profiles 配置中的 On Up 脚本中调用。最好是先用 delay 3s 命令延时几秒。
  • RouterDDNS.txt 为 RouterOS 脚本文件。
  • aliyun-ddns.php 为 PHP 转发代理。

下载:RouterOSv7-Aliyun-DDNS.zip

https://github.com/SilenceCCF/RouterOSv7-Aliyun-DDNS

第一个 get_datetime 脚本是规范日期格式的写法:

:global DateTime
:local Date [/system clock get date]
:local Time [/system clock get time]
:local Month [:tostr ([:find [:toarray "jan,feb,mar,apr,may,jun,jul,ago,sep,oct,nov,dec"] [:pick $Date 0 3]]+1)]
#if MM
#:if ([:len $Month]<2) do={:set Month "0$Month"}
# Format YYYY-M-D H:MM:SS
#\E5\B9\B4 \E6\9C\88 \E6\97\A5
:set DateTime ([:pick $Date 7 11]."-".$Month."-".[:tonum [:pick $Date 4 6]]." ".[:tonum [:pick $Time 0 2]].[:pick $Time 2 8])

第二个 Check_WAN_IP 脚本是检测到路由器的公网地址变化时自动发送钉钉机器人消息:
(此脚本使用的是钉钉消息的自定义关键字模式,指定的关键字是[路由器]即[\E8\B7\AF\E7\94\B1\E5\99\A8]这几个字符串,可自行修改。)

:global currentIP;
:global DateTime;
:execute "get_datetime"
:local newIP [/ip address get [find interface="pppoe-out1"] address];
:if ($newIP != $currentIP) do={
#    :put "ip address $currentIP changed to $newIP";
    :local header "Content-Type:application/json";
    :local dingtalk "https://oapi.dingtalk.com/robot/send?access_token=xxxxxx";
    :local data "{\"msgtype\":\"text\",\"text\": {\"content\":\"$DateTime \E5\AE\BD\E5\B8\A6\E5\85\AC\E7\BD\91 IP \E5\8F\98\E6\9B\B4\E4\B8\BA: $newIP, [\E8\B7\AF\E7\94\B1\E5\99\A8]\"}}";
    :log info [/tool/fetch http-method=post mode=https http-header-field="$header" http-data="$data" url="$dingtalk"];
    :log warning "公网地址由 $currentIP 变为 $newIP";
    :set currentIP $newIP;
};

说明:此脚本中宽带拨号的接口是 pppoe-out1,用法是打开 /ppp/Profiles 下拨号使用的 profile 条目,在 script 标签页下的 On Up 对话框中输入如下内容(延迟三秒执行):

delay 3s
:execute "Check_WAN_IP"

NAT 回流(Hairpin NAT),简单地说就是在客户机用公网IP+端口的方式访问位于同一内网网段的服务器映射端口。RouterOS 在升级 V7.x 版本之前,使用一条 srcnat 规则将内网IP段做个地址伪装或snat即可,如下所示:

chain=srcnat action=masquerade to-addresses=192.168.1.254 src-address=192.168.1.0/24 dst-address=192.168.1.0/24

或:

chain=srcnat action=src-nat to-addresses=192.168.1.254 src-address=192.168.1.0/24 dst-address=192.168.1.0/24

但是自从RouterOS升级到 V7.x版本之后,这条 srcnat 规则就失效了,研究了很久未果。

今天去逛官方论坛,发现在一个帖子里有网友 ivicask 提到需要在 bridge 设置里关闭防火墙:

/interface/bridge/settings
set use-ip-firewall=no

试了一下,果然有效。