OpenWRT路由器设置界面中的Lua填坑

刚结束一个急活,主要是整理某个 OpenWRT 路由的设置界面,网页服务主要用的是 Nginx,网页用的是 BackBone 和 jQuery 配合,后端设置服务主要用的是 Lua(由 Nginx 代理)调用 OpenWRT 的 UCI 和 ubus。一开始我以为只需要前端稍微调整下就行了,后来发现后边跟着的 Lua 得一起整,顺带补了不少 OpenWRT 的基础知识,下边简单梳(还)理(债)……

UCI

基础知识

UCI 是“Unified Configuration Interface”(统一配置界面)的缩写,是 OpenWrt 系统的核心配置框架,它的主要作用是整合系统里不同的设置项,并提供一个统一的接口。OpenWrt 系统配置文件默认被集中放在了 /etc/config 这里(当然也可以放在其它地方),这些 UCI 文件有自己特殊的语法,比如一个典型的无线配置可能是:

config wifi-device 'radio0'
    option type 'mac80211'
    option channel 'auto'
    option hwmode '11g'
    option path 'platform/qca953x_wmac'
    option htmode 'HT20'
    option disabled '0'

config wifi-iface
    option ifname 'wlan0'
    option device 'radio0'
    option network 'lan'
    option mode 'ap'
    option encryption 'none'
    option ssid 'TestSSID'

这里以 config 开头的行代表了一个 config 节点,其格式为:
config 'section-type' 'section'
section-type 处的值是节点类型,而 section 则是节点名称。另外,config 节点允许匿名节点的存在(意即直接跳过'section',就像第二个的 config 节点那样,wifi-iface只是节点类型而不是节点名,这里要注意),引号在 UCI 文件中也不是必须的,严格来讲只有值里带有空格或制表符时才需要使用,使用时也要注意,其必须成对出现才有效(比如一对单引号或者一对双引号,交叉使用会导致语法错误)。
以 option 开头的是选项,格式为:
option 'key' 'value'
这是比较典型的 key-value 格式,就不再赘述了。除此之外,还有种 list 列表选项,被用来描述形如数组类的设置,格式与 option 非常相似:
list 'list-key' 'list-value'
如果 list-key 相同的话,那么这实际上就是个数组式的设置项,举个栗子,system 设置里的 NTP:

config timeserver 'ntp'
    option enabled '1'
    option enable_server '0'
    list server '0.openwrt.pool.ntp.org'
    list server '1.openwrt.pool.ntp.org'
    list server '2.openwrt.pool.ntp.org'
    list server '3.openwrt.pool.ntp.org'

这里的 NTP Server 设置实际上就是个数组。

UCI 的调用

在 OpenWRT 系统里调用 UCI 一般有两种方法,通过命令行或者是调用 Lua API。这里首先说命令行。
OpenWRT 官方文档里提到,使用awk、grep等命令来解析Openwrt的配置文件是低效和不明智的做法,并建议在类似的场景下,应该优先使用命令行形式调用。
UCI 命令行语法为(在命令行下直接输入 uci 即可看到):

用法: uci [<options>] <command> [<arguments>]

命令:
    batch
    export     [<config>]
    import     [<config>]
    changes    [<config>]
    commit     [<config>]
    add        <config> <section-type>
    add_list   <config>.<section>.<option>=<string>
    show       [<config>[.<section>[.<option>]]]
    get        <config>.<section>[.<option>]
    set        <config>.<section>[.<option>]=<value>
    delete     <config>[.<section[.<option>]]
    rename     <config>.<section>[.<option>]=<name>
    revert     <config>[.<section>[.<option>]]

参数:
    -c <path>  设置用于存储配置文件的文件夹 (默认位于: /etc/config)
    -d <str>   使用'uci show'命令时,为 list 类型的值设置分隔符
    -f <file>  使用指定的 <file> 作为输入,而不是默认的 stdin
    -m         导入时,合并数据到现有的设置中
    -n         导出时,命名匿名节 (默认)
    -N         不要命名匿名节
    -p <path>  添加一个配置文件的搜索路径
    -P <path>  添加一个配置文件的搜索路径并将其作为默认设置
    -q         安静默认 (不打印错误信息)
    -s         强制使用严格模式 (在解析出现错误时停止,默认)
    -S         关闭严格模式
    -X         在'show'命令上显示匿名节点ID (如果有的话)

平时(命令行下)常用的主要是 showgetsetchangescommit 这几个。
使用 UCI 时,需要特别注意下它的读写规则:UCI 在读取时,会首先读取内存中的缓存,而后才是文件;而写入则与此相反,增删改都是在操作缓存,需要手动提交才会将设置项写入到系统中。所以,在编写路由设置系统时,最后的提交操作是切不可忘的一步。
还有一种调用 UCI 的方法,是使用 Lua,文末的参考内容[3]中有详细的 API 列表(记得在开头用 local uci = require "los.uci".cursor() 语句引入)。
在使用 Lua 调用时,有个需要注意的点是匿名节点,比如上文中的无线配置里,有个 wifi-iface 类型的匿名节点,在命令行里使用 uci show wireless 可以看到:

wireless.radio1=wifi-device
wireless.radio1.type='mac80211'
wireless.radio1.channel='auto'
wireless.radio1.hwmode='11g'
wireless.radio1.path='platform/qca953x_wmac'
wireless.radio1.htmode='HT20'
wireless.radio1.disabled='0'
wireless.@wifi-iface[0]=wifi-iface
wireless.@wifi-iface[0].ifname='wlan0'
wireless.@wifi-iface[0].device='mt7620'
wireless.@wifi-iface[0].network='lan'
wireless.@wifi-iface[0].mode='ap'
wireless.@wifi-iface[0].encryption='none'
wireless.@wifi-iface[0].ssid='TestSSID'

这里可以看到很多键名类似 @wifi-iface[0] 的设置项,这就是匿名节点的设置项了。如果在命令行里加入 -X 参数变成 uci -X show wireless,则可以看到:

wireless.radio1=wifi-device
wireless.radio1.type='mac80211'
wireless.radio1.channel='auto'
wireless.radio1.hwmode='11g'
wireless.radio1.path='platform/qca953x_wmac'
wireless.radio1.htmode='HT20'
wireless.radio1.disabled='0'
wireless.cfg043579=wifi-iface
wireless.cfg043579.ifname='wlan1'
wireless.cfg043579.device='radio1'
wireless.cfg043579.network='lan'
wireless.cfg043579.mode='ap'
wireless.cfg043579.encryption='none'
wireless.cfg043579.ssid='TestSSID'

这时 @wifi-iface[0] 变成了 cfg043579,这才是这个匿名节点真实的引用名(系统自动生成的)。
而同样的,在撰写相对应的 Lua 语句时,也不能写成:

uci:get("wireless", "@wifi-iface[0]", "ssid", "NewSSID")

虽然可以在命令行执行 uci set wireless.@wifi-iface[0].ssid='NewSSID',但是在 Lua 上这么写系统是不会鸟你的(更何况还有个隐性的问题,是设置被改动过后,匿名节点的位置有可能会变,比如会跑到 @wifi-iface[1] 去,这可能会发生在拥有多个匿名节点的配置文件里)。所以这个时候,就需要使用 uci:foreach 去遍历某个设置类型的所有设置节点(注:返回 false 终止遍历),在遍历出的内容里,有几个特殊的、键名以英文字符 . 开头的成员:

  • [.index]: 设置节点的索引
  • [.name]: 设置节点的名称(即真实的引用名,cfg043579 这种)
  • [.type]: 设置节点的类型(如 wifi-iface
  • [.anonymous]: 指示该设置节点是否匿名

这样,通过遍历所有项目并筛选符合条件的配置项,将 [.name] 中的内容缓存下来,就可以用:

uci:get("wireless", "cfg043579", "ssid", "NewSSID")

这种方法去调用了。
这里放个自己写的用于遍历无线设置的函数(双频设备,每个频段只有一个信号,通过设备 ID 来识别):

function getWirelessInfo()
    local wifiConfig = {}
    uci:foreach(
        "wireless",
        "wifi-iface",
        function(s)
            if s.device == "mt7620" then
                if not wifiConfig.mt7620 then
                    wifiConfig["mt7620"] = {}
                end
                local key = ""
                if s.key then
                    key = s.key
                end
                wifiConfig.mt7620 = {
                    name = s[".name"],
                    ssid = s.ssid,
                    ency = s.encryption,
                    pass = key
                }
            elseif s.device == "mt7612" then
                if not wifiConfig.mt7612 then
                    wifiConfig["mt7612"] = {}
                end
                local key = ""
                if s.key then
                    key = s.key
                end
                wifiConfig.mt7612 = {
                    name = s[".name"],
                    ssid = s.ssid,
                    ency = s.encryption,
                    pass = key
                }
            end
        end
    )
    return wifiConfig
end

不过,在实践中,我认为最有效的手段是将匿名节点转化成普通的具名节点,这样 Lua 就可以直接调用,比写挨个遍历内容的逻辑要简单也清晰的多。

下边再说说 ubus。

ubus

ubus 即是 OpenWrt micro bus 架构,是 OpenWrt 为了提供守护进程和应用程序间的通讯而开发的项目。简单来说,想获取系统运行的一些状态,是可以用 ubus 来查看的,而且相比用 UCI 查询,由于 ubus 获取的直接是系统信息而不是设置项,所以可以避免由于错误配置带来的配置项与系统状态不符合的问题。也是因为这个原因,我推荐读取设置(状态)时用 ubus,写入设置时用 UCI。
当然 ubus 也并不是没有问题,目前比较通用的说法是,在数据内容超过 60k 时不建议用,另外如果有多线程、或者逻辑上有递归时也不建议用(指令发出以后,接受到的信息可能是另一条指令的返回内容)。

ubus 的调用

同 UCI 类似,调用 ubus 也分为命令行方式与 Lua 调用方式。而与 UCI 将设置文件命名为包(package)不同的是,ubus 将其调度单位称为“命名空间”(namespace),系统后台会默认驻留一个名为 ubusd 的守护进程,使用友好的 JSON 格式进行交互。
在命令行中输入 ubus list 就可以看到所有通过RPC服务器注册的命名空间:

dhcp
hostapd.wlan0
hostapd.wlan1
log
network
network.device
network.interface
network.interface.lan
network.interface.loopback
network.interface.wan
network.interface.wan6
network.wireless
service
session
system
uci

加个参数变成 ubus -v list,就可以详细列出这些命名空间所提供的方法了。调用方法用 call 关键字,比如,查看系统 WiFi 状态就可以用:

ubus call network.wireless status '{}'

(参数一定要带上,即使为空。格式为 JSON)

除此以外,还有:

  • 获取系统信息(上线时间、内存用量、SWAP信息等)
    ubus call system info '{}'
    
  • 获取设备信息(设备型号、固件版本等)
    ubus call system board '{}'
    
  • 获取 WiFi 上已连接的客户端
    ubus call hostapd.wlan0 get_clients '{}'
    
  • 获取路由物理设备信息(如 MAC 型号、工作状态等)
    ubus call network.device status '{"name":"eth0"}'
    
    等等。
    除了命令行直接调用外,ubus 也可以使用 Lua 调用,由于没有 UCI 那劳什子匿名节点的问题,所以直接用
    local ubus = require "ubus"
    
    引入,在调用前用
    local conn = ubus.connect()
    
    连接服务,在调用后用
    conn:close()
    
    关闭就好。
    比如我自己写的一段从 ubus 上拿 WiFi 信息的函数:
    local function getWirelessStatus()
      local conn = ubus.connect()
      if not conn then
          error("Failed to connect to ubusd")
      end
      local info = {}
      local status = conn:call("network.wireless", "status", {})
      for k, v in pairs(status) do
          info[k] = v
      end
      conn:close()    
      return info
    end
    

参考内容

  1. OpenWRT官网 - UCI系统
  2. OpenWRT官网 - UCI技术参考资料
  3. LuaDoc - luci.model.uci (英)
  4. OpenWRT官网 - ubus