--[[ LuCI - Filesystem tools Description: A module offering often needed filesystem manipulation functions FileId: $Id$ License: Copyright 2008 Steven Barth Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ]]-- local io = require "io" local os = require "os" local ltn12 = require "luci.ltn12" local fs = require "nixio.fs" local nutil = require "nixio.util" local uci = require "luci.model.uci".cursor() local SYS = require "luci.sys" local HTTP = require "luci.http" local type = type local string = string local tostring = tostring local table = table local math = math local b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' --- LuCI filesystem library. module "luci.openclash" --- Test for file access permission on given path. -- @class function -- @name access -- @param str String value containing the path -- @return Number containing the return code, 0 on sucess or nil on error -- @return String containing the error description (if any) -- @return Number containing the os specific errno (if any) access = fs.access --- Evaluate given shell glob pattern and return a table containing all matching -- file and directory entries. -- @class function -- @name glob -- @param filename String containing the path of the file to read -- @return Table containing file and directory entries or nil if no matches -- @return String containing the error description (if no matches) -- @return Number containing the os specific errno (if no matches) function glob(...) local iter, code, msg = fs.glob(...) if iter then return nutil.consume(iter) else return nil, code, msg end end --- Checks wheather the given path exists and points to a regular file. -- @param filename String containing the path of the file to test -- @return Boolean indicating wheather given path points to regular file function isfile(filename) return fs.stat(filename, "type") == "reg" end --- Checks wheather the given path exists and points to a directory. -- @param dirname String containing the path of the directory to test -- @return Boolean indicating wheather given path points to directory function isdirectory(dirname) return fs.stat(dirname, "type") == "dir" end --- Read the whole content of the given file into memory. -- @param filename String containing the path of the file to read -- @return String containing the file contents or nil on error -- @return String containing the error message on error -- Wrapped readfile: if file is age-encrypted, try to decrypt with matching UCI secret first function readfile(filename) local content, err = fs.readfile(filename) if not content then return nil, err end if content:find("BEGIN AGE ENCRYPTED FILE") then local keys = get_age_keys(filename) if keys and keys.secret and keys.secret ~= "" then return age_decrypt(keys.secret, content) or content else return content end end return content end --- Write the contents of given string to given file. -- @param filename String containing the path of the file to read -- @param data String containing the data to write -- @return Boolean containing true on success or nil on error -- @return String containing the error message on error -- Wrapped writefile: if public key exists for filename, encrypt before writing function writefile(filename, data) local keys = get_age_keys(filename) if keys and keys.public and keys.public ~= "" then return fs.writefile(filename, age_encrypt(keys.public, data) or data) end return fs.writefile(filename, data) end --- Copies a file. -- @param source Source file -- @param dest Destination -- @return Boolean containing true on success or nil on error copy = fs.datacopy --- Renames a file. -- @param source Source file -- @param dest Destination -- @return Boolean containing true on success or nil on error rename = fs.move --- Get the last modification time of given file path in Unix epoch format. -- @param path String containing the path of the file or directory to read -- @return Number containing the epoch time or nil on error -- @return String containing the error description (if any) -- @return Number containing the os specific errno (if any) function mtime(path) return fs.stat(path, "mtime") end --- Set the last modification time of given file path in Unix epoch format. -- @param path String containing the path of the file or directory to read -- @param mtime Last modification timestamp -- @param atime Last accessed timestamp -- @return 0 in case of success nil on error -- @return String containing the error description (if any) -- @return Number containing the os specific errno (if any) function utime(path, mtime, atime) return fs.utimes(path, atime, mtime) end --- Return the last element - usually the filename - from the given path with -- the directory component stripped. -- @class function -- @name basename -- @param path String containing the path to strip -- @return String containing the base name of given path -- @see dirname basename = fs.basename --- Return the directory component of the given path with the last element -- stripped of. -- @class function -- @name dirname -- @param path String containing the path to strip -- @return String containing the directory component of given path -- @see basename dirname = fs.dirname --- Return a table containing all entries of the specified directory. -- @class function -- @name dir -- @param path String containing the path of the directory to scan -- @return Table containing file and directory entries or nil on error -- @return String containing the error description on error -- @return Number containing the os specific errno on error function dir(...) local iter, code, msg = fs.dir(...) if iter then local t = nutil.consume(iter) t[#t+1] = "." t[#t+1] = ".." return t else return nil, code, msg end end --- Create a new directory, recursively on demand. -- @param path String with the name or path of the directory to create -- @param recursive Create multiple directory levels (optional, default is true) -- @return Number with the return code, 0 on sucess or nil on error -- @return String containing the error description on error -- @return Number containing the os specific errno on error function mkdir(path, recursive) return recursive and fs.mkdirr(path) or fs.mkdir(path) end --- Remove the given empty directory. -- @class function -- @name rmdir -- @param path String containing the path of the directory to remove -- @return Number with the return code, 0 on sucess or nil on error -- @return String containing the error description on error -- @return Number containing the os specific errno on error rmdir = fs.rmdir local stat_tr = { reg = "regular", dir = "directory", lnk = "link", chr = "character device", blk = "block device", fifo = "fifo", sock = "socket" } --- Get information about given file or directory. -- @class function -- @name stat -- @param path String containing the path of the directory to query -- @return Table containing file or directory properties or nil on error -- @return String containing the error description on error -- @return Number containing the os specific errno on error function stat(path, key) local data, code, msg = fs.stat(path) if data then data.mode = data.modestr data.type = stat_tr[data.type] or "?" end return key and data and data[key] or data, code, msg end --- Set permissions on given file or directory. -- @class function -- @name chmod -- @param path String containing the path of the directory -- @param perm String containing the permissions to set ([ugoa][+-][rwx]) -- @return Number with the return code, 0 on sucess or nil on error -- @return String containing the error description on error -- @return Number containing the os specific errno on error chmod = fs.chmod --- Create a hard- or symlink from given file (or directory) to specified target -- file (or directory) path. -- @class function -- @name link -- @param path1 String containing the source path to link -- @param path2 String containing the destination path for the link -- @param symlink Boolean indicating wheather to create a symlink (optional) -- @return Number with the return code, 0 on sucess or nil on error -- @return String containing the error description on error -- @return Number containing the os specific errno on error function link(src, dest, sym) return sym and fs.symlink(src, dest) or fs.link(src, dest) end --- Remove the given file. -- @class function -- @name unlink -- @param path String containing the path of the file to remove -- @return Number with the return code, 0 on sucess or nil on error -- @return String containing the error description on error -- @return Number containing the os specific errno on error unlink = fs.unlink --- Retrieve target of given symlink. -- @class function -- @name readlink -- @param path String containing the path of the symlink to read -- @return String containing the link target or nil on error -- @return String containing the error description on error -- @return Number containing the os specific errno on error readlink = fs.readlink function filename(str) if not str then return nil end local idx = str:match(".+()%.%w+$") if idx then return str:sub(1, idx-1) else return str end end function filesize(e) local t=0 local a={' KB',' MB',' GB',' TB',' PB'} if e < 0 then e = -e end repeat e=e/1024 t=t+1 until(e<=1024) return string.format("%.1f",e)..a[t] or "0.0 KB" end function lanip() local lan_int_name = uci:get("openclash", "@overwrite[0]", "lan_interface_name") or uci:get("openclash", "config", "lan_interface_name") or "0" local lan_ip if lan_int_name == "0" then lan_ip = SYS.exec("uci -q get network.lan.ipaddr 2>/dev/null |awk -F '/' '{print $1}' 2>/dev/null |tr -d '\n'") else lan_ip = SYS.exec(string.format("ip address show %s 2>/dev/null | grep -w 'inet' 2>/dev/null | grep -Eo 'inet [0-9\.]+' | awk '{print $2}' | head -1 | tr -d '\n'", lan_int_name)) end if not lan_ip or lan_ip == "" then lan_ip = SYS.exec("ip address show $(uci -q -p /tmp/state get network.lan.device || uci -q -p /tmp/state get network.lan.device) | grep -w 'inet' 2>/dev/null | grep -Eo 'inet [0-9\.]+' | awk '{print $2}' | head -1 | tr -d '\n'") end if not lan_ip or lan_ip == "" then lan_ip = SYS.exec("ip addr show 2>/dev/null | grep -w 'inet' | grep 'global' | grep 'brd' | grep -Eo 'inet [0-9\.]+' | awk '{print $2}' | head -n 1 | tr -d '\n'") end return lan_ip end function find_case_insensitive_path(path) local dir = dirname(path) local base = basename(path) local files = dir and fs.dir(dir) if not files then return nil end for f in files do if f:lower() == base:lower() then return dir .. "/" .. f end end return nil end function get_resourse_mtime(path) local real_path = path if not fs.access(path) then local found = find_case_insensitive_path(path) if found then real_path = found elseif uci_get_config("config", "small_flash_memory") == "1" then local fallback_path = path:gsub("^/etc/openclash/", "/tmp/etc/openclash/") local fallback_found = find_case_insensitive_path(fallback_path) if fallback_found then real_path = fallback_found else return "File Not Exist" end else return "File Not Exist" end end local file = fs.readlink(real_path) or real_path local resourse_etag_version = SYS.exec(string.format("source /usr/share/openclash/openclash_etag.sh && GET_ETAG_TIMESTAMP_BY_PATH '%s'", file)) if resourse_etag_version and resourse_etag_version ~= "" then return resourse_etag_version end local resourse_version = os.date("%Y-%m-%d %H:%M:%S", mtime(file)) if resourse_version and resourse_version ~= "" then return resourse_version end return "Unknown" end function uci_get_config(section, key) local val if section == "config" then val = uci:get("openclash", "@overwrite[0]", key) end if val == nil then val = uci:get("openclash", section, key) end return val end function get_file_path_from_request() local file_path local referer = HTTP.getenv("HTTP_REFERER") if referer then local _, _, file_value = referer:find("file=([^&]*)$") if file_value and file_value ~= "" then file_path = HTTP.urldecode(file_value) end end if not file_path or file_path == "/" then file_path = HTTP.formvalue("file") if not file_path then file_path = HTTP.urldecode(file_path) end end return file_path end function get_age_keys(file) local name = filename(basename(file)) local pub, sec uci:foreach("openclash", "config_age_secret", function(s) if s and s.name then if s.name == name and (not s.hidden or (s.hidden ~= "true")) then if not pub and s.public then pub = s.public end if not sec and s.secret then sec = s.secret end end return false end end) return { public = pub, secret = sec } end function age_encrypt(public, content) if not public or public == "" or not content then return nil end local tmp_in = os.tmpname() local tmp_out = os.tmpname() local f = io.open(tmp_in, "w") if not f then return nil end f:write(content) f:close() local cmd = string.format( "/etc/openclash/core/clash_meta age encrypt %s %s %s 2>/dev/null", public, tmp_in, tmp_out ) os.execute(cmd) local out = nil local f_out = io.open(tmp_out, "r") if f_out then out = f_out:read("*a") or "" f_out:close() end os.remove(tmp_in) os.remove(tmp_out) return out end function age_decrypt(secret, content) if not secret or secret == "" or not content then return nil end local tmp_in = os.tmpname() local tmp_out = os.tmpname() local f = io.open(tmp_in, "w") if not f then return nil end f:write(content) f:close() local cmd = string.format( "/etc/openclash/core/clash_meta age decrypt %s %s %s 2>/dev/null", secret, tmp_in, tmp_out ) os.execute(cmd) local out = nil local f_out = io.open(tmp_out, "r") if f_out then out = f_out:read("*a") or "" f_out:close() end os.remove(tmp_in) os.remove(tmp_out) return out end function decode64(data) if not data then return nil end data = data:gsub('[%s]', '') if #data % 4 ~= 0 then return nil end local out = {} for i = 1, #data, 4 do local c1, c2, c3, c4 = data:byte(i, i+3) local i1 = b64chars:find(string.char(c1), 1, true) - 1 local i2 = b64chars:find(string.char(c2), 1, true) - 1 local i3 = (c3 == 61) and -1 or (b64chars:find(string.char(c3), 1, true) - 1) local i4 = (c4 == 61) and -1 or (b64chars:find(string.char(c4), 1, true) - 1) if not i1 or not i2 or (c3 ~= 61 and not i3) or (c4 ~= 61 and not i4) then return nil end local x = i1 * 0x40000 + i2 * 0x1000 if i3 >= 0 then x = x + i3 * 0x40 end if i4 >= 0 then x = x + i4 end table.insert(out, string.char(math.floor(x / 0x10000) % 0x100)) if i3 >= 0 then table.insert(out, string.char(math.floor(x / 0x100) % 0x100)) end if i4 >= 0 then table.insert(out, string.char(x % 0x100)) end end return table.concat(out) end function config_refs(old_file_name, new_file_name) if not old_file_name or old_file_name == "" then return end local old_name_no_ext = filename(basename(old_file_name)) local old_file_full = basename(old_file_name) local is_rename = new_file_name and new_file_name ~= "" local new_name_no_ext, new_file_full if is_rename then new_name_no_ext = filename(basename(new_file_name)) new_file_full = basename(new_file_name) end uci:foreach("openclash", "config_subscribe", function(s) if s.name == old_name_no_ext then if is_rename and new_name_no_ext ~= new_file_name then uci:set("openclash", s[".name"], "name", new_name_no_ext) else uci:delete("openclash", s[".name"]) end return false end end ) uci:foreach("openclash", "subscribe_info", function(s) if s.name == old_name_no_ext then if is_rename and new_name_no_ext ~= new_file_name then uci:set("openclash", s[".name"], "name", new_name_no_ext) else uci:delete("openclash", s[".name"]) end return false end end ) uci:foreach("openclash", "groups", function(s) if s.config == old_file_full then if is_rename and new_name_no_ext ~= new_file_name then uci:set("openclash", s[".name"], "config", new_file_full) else uci:delete("openclash", s[".name"]) end end end ) uci:foreach("openclash", "proxy-provider", function(s) if s.config == old_file_full then if is_rename and new_name_no_ext ~= new_file_name then uci:set("openclash", s[".name"], "config", new_file_full) else uci:delete("openclash", s[".name"]) end end end ) uci:foreach("openclash", "servers", function(s) if s.config == old_file_full then if is_rename and new_name_no_ext ~= new_file_name then uci:set("openclash", s[".name"], "config", new_file_full) else uci:delete("openclash", s[".name"]) end end end ) uci:foreach("openclash", "config_age_secret", function(s) if s.name == old_name_no_ext then if is_rename and new_name_no_ext ~= new_file_name then uci:set("openclash", s[".name"], "name", new_name_no_ext) else uci:delete("openclash", s[".name"]) end return false end end ) uci:commit("openclash") end --- Returns the appropriate ps command string for the system's ps implementation. -- Detects procps-ng (ps -efw) vs busybox (ps -w). -- @return String containing the ps command prefix function ps_cmd() local ps_version = SYS.exec("ps --version 2>&1 |grep -c procps-ng |tr -d '\n'") if ps_version == "1" then return "ps -efw" else return "ps -w" end end --- Returns the package manager type (opkg or apk). -- @return String "opkg" or "apk" function pkg_type() if fs.access("/usr/bin/apk") then return "apk" else return "opkg" end end --- Reads the CDN proxy address list from /usr/share/openclash/res/cdn.list. -- Lines starting with "#" are treated as comments and skipped. -- @return Table of CDN address strings (deduplicated, in file order) function cdn_list() local list = {} local seen = {} local raw = fs.readfile("/usr/share/openclash/res/cdn.list") if raw then for line in raw:gmatch("[^\r\n]+") do line = line:gsub("^%s+", ""):gsub("%s+$", "") if line ~= "" and line:sub(1, 1) ~= "#" and not seen[line] then seen[line] = true list[#list + 1] = line end end end return list end --- Read a field of an installed package directly from the package database -- (/usr/lib/opkg/status or /lib/apk/db/installed), avoiding the opkg/apk -- binaries and their lock files (/var/lock/opkg.lock, /lib/apk/db/lock). -- Safe to call repeatedly/concurrently and never blocks on a lock. -- @param pkg Package name, e.g. "luci-app-openclash" or "libc" -- @param field Field name; opkg: "Version"/"Architecture", apk: "V"/"A" -- @return String field value, or "" when not found function read_pkg_field(pkg, field) local text if pkg_type() == "opkg" then text = fs.readfile("/usr/lib/opkg/status") else text = fs.readfile("/lib/apk/db/installed") end if not text then return "" end text = text:gsub("\r\n", "\n") local prefix = (pkg_type() == "opkg") and ("Package: " .. pkg .. "\n") or ("P:" .. pkg .. "\n") local start = text:find(prefix, 1, true) if not start then return "" end local block_end = text:find("\n\n", start + #prefix, true) or #text local block = text:sub(start + #prefix, block_end - 1) local colon = (pkg_type() == "opkg") and ": " or ":" local val = block:match("\n" .. field .. colon .. "([^\r\n]+)") if not val then val = block:match("^" .. field .. colon .. "([^\r\n]+)") end return val or "" end function oc_version() local v = read_pkg_field("luci-app-openclash", pkg_type() == "opkg" and "Version" or "V") if v == "" then v = "0" end return v end function IsYamlExt(e) e = e or "" local lower = string.lower(e) return lower:sub(-5) == ".yaml" or lower:sub(-4) == ".yml" end