mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-09-12 19:34:23 +08:00
1120 lines
32 KiB
Lua
1120 lines
32 KiB
Lua
--[[
|
||
DDNSTO LuCI Controller + JSON API
|
||
=================================
|
||
|
||
----
|
||
为 ddnsto 的 LuCI 页面(可用原生 JS/React/Vue)提供稳定的后端接口:
|
||
1) 读取/更新 UCI 配置:/etc/config/ddnsto
|
||
2) 控制 init.d 服务:/etc/init.d/ddnsto start|stop|restart|reload
|
||
3) 查询运行状态:ddnstod 是否在运行、PID、enabled/token 是否就绪
|
||
4) (可选)读取最近日志:logread 过滤 ddnsto/ddnstod
|
||
|
||
路由说明
|
||
--------
|
||
默认挂载在:
|
||
/cgi-bin/luci/admin/services/ddnsto/page -- LuCI 页面入口(模板)
|
||
/cgi-bin/luci/admin/services/ddnsto/api/config -- GET/POST 配置
|
||
/c.../ddnsto/api/service -- POST 服务控制
|
||
/c.../ddnsto/api/status -- GET 状态
|
||
/c.../ddnsto/api/logs -- GET 日志(可选)
|
||
|
||
CSRF 说明
|
||
---------
|
||
LuCI 对 POST 通常要求 token 校验。这里提供两种方式(二选一):
|
||
- Header: X-LuCI-Token: <luci.dispatcher.context.token>
|
||
- Form字段: token=<...>(application/x-www-form-urlencoded 时常用)
|
||
|
||
对于前端(React)最佳实践是:
|
||
- 在 LuCI 模板里注入 window.ddnstoCsrfToken = "<%=luci.dispatcher.context.token%>"
|
||
- 所有 POST 带上该 token
|
||
|
||
前端对接建议
|
||
------------
|
||
- GET config/status: fetch(url, {credentials: 'same-origin'})
|
||
- POST config/service: JSON body + X-LuCI-Token,或表单 token
|
||
|
||
开发/调试注意
|
||
------------
|
||
1) 修改 controller 后,LuCI 可能缓存索引:
|
||
- rm -f /tmp/luci-indexcache
|
||
- /etc/init.d/uhttpd restart (或重启设备)
|
||
2) 确保 /etc/config/ddnsto 存在;否则 index() 会直接 return。
|
||
3) 若想扩展更多字段(如 address),建议在 GET 返回里带出,但 POST 仅允许白名单字段写入。
|
||
|
||
安全边界
|
||
--------
|
||
本接口位于 LuCI admin 路径下,默认需要登录 LuCI。
|
||
此外:
|
||
- service action 做了白名单限制,避免命令注入
|
||
- config 写入做了基本校验(bool/number)
|
||
--]]
|
||
|
||
module("luci.controller.ddnsto", package.seeall)
|
||
|
||
-- ==========
|
||
-- Utilities
|
||
-- ==========
|
||
|
||
local function write_json(tbl)
|
||
local http = require "luci.http"
|
||
local jsonc = require "luci.jsonc"
|
||
http.prepare_content("application/json")
|
||
http.write(jsonc.stringify(tbl))
|
||
end
|
||
|
||
local function bad_request(msg)
|
||
write_json({ ok = false, error = msg or "bad request" })
|
||
end
|
||
|
||
local function method_not_allowed()
|
||
write_json({ ok = false, error = "method not allowed" })
|
||
end
|
||
|
||
local function conflict_request(msg, detail, code)
|
||
local http = require "luci.http"
|
||
http.status(409, msg or "conflict")
|
||
write_json({
|
||
ok = false,
|
||
error = msg or "conflict",
|
||
detail = detail or "",
|
||
code = code or "conflict",
|
||
})
|
||
end
|
||
|
||
local function read_json_body()
|
||
local http = require "luci.http"
|
||
local jsonc = require "luci.jsonc"
|
||
local ctype = http.getenv("CONTENT_TYPE") or ""
|
||
if not ctype:match("^application/json") then
|
||
return nil
|
||
end
|
||
local raw = http.content() or ""
|
||
if #raw == 0 then
|
||
return nil
|
||
end
|
||
local obj = jsonc.parse(raw)
|
||
if type(obj) ~= "table" then
|
||
return nil
|
||
end
|
||
return obj
|
||
end
|
||
|
||
local function get_command(cmd)
|
||
local handle = io.popen(cmd, "r")
|
||
if handle then
|
||
local res = string.gsub(handle:read("*a"), "\n", "")
|
||
handle:close()
|
||
return res
|
||
end
|
||
return ""
|
||
|
||
end
|
||
|
||
local function parse_device_id(raw)
|
||
local cleaned = tostring(raw or "")
|
||
cleaned = cleaned:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
||
if cleaned == "" then
|
||
return ""
|
||
end
|
||
local _, did = cleaned:match("^(%S+)%s+(%S+)$")
|
||
return did or cleaned
|
||
end
|
||
|
||
local function normalize_index(index)
|
||
local idx = index
|
||
if not (idx and tostring(idx):match("^%d+$")) then
|
||
idx = "0"
|
||
end
|
||
return idx
|
||
end
|
||
|
||
local function fetch_device_id(index)
|
||
local idx = normalize_index(index)
|
||
local cmd = string.format("/usr/sbin/ddnstod -x %s -w", idx)
|
||
return parse_device_id(get_command(cmd))
|
||
end
|
||
|
||
local function shell_quote(value)
|
||
local raw = tostring(value or "")
|
||
return "'" .. raw:gsub("'", [['"'"']]) .. "'"
|
||
end
|
||
|
||
local function read_file(path, binary)
|
||
local mode = binary and "rb" or "r"
|
||
local fp = io.open(path, mode)
|
||
if not fp then
|
||
return nil
|
||
end
|
||
local data = fp:read("*a")
|
||
fp:close()
|
||
return data
|
||
end
|
||
|
||
local function remove_file(path)
|
||
if path and #path > 0 then
|
||
os.remove(path)
|
||
end
|
||
end
|
||
|
||
local function temp_path(suffix)
|
||
local random = tostring(math.random(100000, 999999))
|
||
return string.format("/tmp/ddnsto-luci-%d-%s%s", os.time(), random, suffix or "")
|
||
end
|
||
|
||
local function file_exists(path)
|
||
local fp = io.open(path, "rb")
|
||
if fp then
|
||
fp:close()
|
||
return true
|
||
end
|
||
return false
|
||
end
|
||
|
||
local function service_running()
|
||
local sys = require "luci.sys"
|
||
local jsonc = require "luci.jsonc"
|
||
local raw = sys.exec([[ubus call service list '{"name":"ddnsto"}' 2>/dev/null]]) or ""
|
||
local ok, obj = pcall(jsonc.parse, raw)
|
||
if ok and type(obj) == "table" and type(obj.ddnsto) == "table" and type(obj.ddnsto.instances) == "table" then
|
||
for _, inst in pairs(obj.ddnsto.instances) do
|
||
if type(inst) == "table" and inst.running == true then
|
||
return true
|
||
end
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
local function read_identity_state()
|
||
local uci = require "luci.model.uci".cursor()
|
||
local state = {
|
||
router_id = "",
|
||
identity_mode = "",
|
||
device_uuid = "",
|
||
}
|
||
uci:foreach("ddnsto", "ddnsto", function(s)
|
||
state.router_id = s.router_id or state.router_id
|
||
state.identity_mode = s.identity_mode or state.identity_mode
|
||
state.device_uuid = s.device_uuid or state.device_uuid
|
||
end)
|
||
return state
|
||
end
|
||
|
||
local function run_init_action(action)
|
||
local sys = require "luci.sys"
|
||
return sys.call(string.format("/etc/init.d/ddnsto %s >/dev/null 2>&1", shell_quote(action)))
|
||
end
|
||
|
||
local function run_capture(cmd)
|
||
local sys = require "luci.sys"
|
||
local stdout_path = temp_path(".stdout")
|
||
local stderr_path = temp_path(".stderr")
|
||
local wrapped = string.format("%s >%s 2>%s", cmd, shell_quote(stdout_path), shell_quote(stderr_path))
|
||
local rc = sys.call(wrapped)
|
||
local stdout = read_file(stdout_path, true) or ""
|
||
local stderr = read_file(stderr_path, true) or ""
|
||
remove_file(stdout_path)
|
||
remove_file(stderr_path)
|
||
return rc, stdout, stderr
|
||
end
|
||
|
||
local function diagnostics_logs_via_cli(lines)
|
||
local jsonc = require "luci.jsonc"
|
||
local cmd = string.format("/usr/sbin/ddnsto diagnostics logs --tail %d", tonumber(lines) or 200)
|
||
local rc, stdout = run_capture(cmd)
|
||
if rc ~= 0 or stdout == "" then
|
||
return nil
|
||
end
|
||
local ok, parsed = pcall(jsonc.parse, stdout)
|
||
if not ok or type(parsed) ~= "table" or type(parsed.lines) ~= "table" then
|
||
return nil
|
||
end
|
||
return parsed.lines
|
||
end
|
||
|
||
local function diagnostics_bundle_via_cli(output_path)
|
||
local cmd = string.format(
|
||
"/usr/sbin/ddnsto diagnostics bundle --output %s --reason %s",
|
||
shell_quote(output_path),
|
||
shell_quote("luci-support")
|
||
)
|
||
local rc, stdout, stderr = run_capture(cmd)
|
||
if rc == 0 and file_exists(output_path) then
|
||
return true, stdout
|
||
end
|
||
return false, stderr ~= "" and stderr or stdout
|
||
end
|
||
|
||
local function diagnostics_bundle_via_http(output_path)
|
||
local jsonc = require "luci.jsonc"
|
||
local request_body = [[{"reason":"luci-support","tail":500}]]
|
||
local request_cmd = string.format(
|
||
"curl -fsS -X POST -H 'Content-Type: application/json' --data %s %s",
|
||
shell_quote(request_body),
|
||
shell_quote("http://127.0.0.1:18333/diagnostics/bundle")
|
||
)
|
||
local rc, stdout, stderr = run_capture(request_cmd)
|
||
if rc ~= 0 or stdout == "" then
|
||
return false, stderr ~= "" and stderr or stdout
|
||
end
|
||
|
||
local ok, parsed = pcall(jsonc.parse, stdout)
|
||
local download_path = ok and type(parsed) == "table" and parsed.downloadPath or nil
|
||
if type(download_path) ~= "string" or download_path == "" then
|
||
return false, "missing downloadPath"
|
||
end
|
||
|
||
local download_cmd = string.format(
|
||
"curl -fsS %s -o %s",
|
||
shell_quote("http://127.0.0.1:18333" .. download_path),
|
||
shell_quote(output_path)
|
||
)
|
||
local download_rc, _, download_stderr = run_capture(download_cmd)
|
||
if download_rc == 0 and file_exists(output_path) then
|
||
return true, "http"
|
||
end
|
||
|
||
return false, download_stderr ~= "" and download_stderr or "bundle download failed"
|
||
end
|
||
|
||
local function offline_diagnosis_via_http()
|
||
local jsonc = require "luci.jsonc"
|
||
local cmd = string.format("curl -fsS %s", shell_quote("http://127.0.0.1:18333/offline-diagnosis"))
|
||
local rc, stdout, stderr = run_capture(cmd)
|
||
if rc ~= 0 or stdout == "" then
|
||
return nil, stderr ~= "" and stderr or stdout
|
||
end
|
||
|
||
local ok, parsed = pcall(jsonc.parse, stdout)
|
||
if not ok or type(parsed) ~= "table" then
|
||
return nil, "invalid offline diagnosis response"
|
||
end
|
||
return parsed, "http_status"
|
||
end
|
||
|
||
local function offline_diagnosis_via_cli()
|
||
local jsonc = require "luci.jsonc"
|
||
local rc, stdout, stderr = run_capture("/usr/sbin/ddnsto offline-diagnosis --json")
|
||
if rc ~= 0 or stdout == "" then
|
||
return nil, stderr ~= "" and stderr or stdout
|
||
end
|
||
|
||
local ok, parsed = pcall(jsonc.parse, stdout)
|
||
if not ok or type(parsed) ~= "table" then
|
||
return nil, "invalid offline diagnosis response"
|
||
end
|
||
return parsed, "cli_fallback"
|
||
end
|
||
|
||
local function stream_download(path, filename, content_type)
|
||
local http = require "luci.http"
|
||
local fp = io.open(path, "rb")
|
||
if not fp then
|
||
http.status(500, "open failed")
|
||
http.prepare_content("application/json")
|
||
http.write('{"ok":false,"error":"bundle open failed"}')
|
||
return
|
||
end
|
||
|
||
http.header("Content-Disposition", string.format('attachment; filename="%s"', filename))
|
||
http.header("Cache-Control", "no-store")
|
||
http.prepare_content(content_type or "application/octet-stream")
|
||
|
||
while true do
|
||
local chunk = fp:read(8192)
|
||
if not chunk then
|
||
break
|
||
end
|
||
http.write(chunk)
|
||
end
|
||
|
||
fp:close()
|
||
end
|
||
|
||
local function param(body, key)
|
||
local http = require "luci.http"
|
||
if type(body) == "table" and body[key] ~= nil then
|
||
return tostring(body[key])
|
||
end
|
||
return http.formvalue(key)
|
||
end
|
||
|
||
local function is_bool01(v)
|
||
return v == "0" or v == "1"
|
||
end
|
||
|
||
local function is_uint(v)
|
||
return v ~= nil and tostring(v):match("^%d+$") ~= nil
|
||
end
|
||
|
||
local function is_empty(v)
|
||
return v == nil or tostring(v):match("^%s*$") ~= nil
|
||
end
|
||
|
||
local function has_space(v)
|
||
return v ~= nil and tostring(v):find("%s") ~= nil
|
||
end
|
||
|
||
-- LuCI CSRF Check
|
||
local function require_csrf()
|
||
local http = require "luci.http"
|
||
local disp = require "luci.dispatcher"
|
||
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
if method ~= "POST" then
|
||
return true
|
||
end
|
||
|
||
local ctx = disp.context
|
||
|
||
-- 1. Ensure user is authenticated (session exists)
|
||
if not (ctx and ctx.authsession) then
|
||
write_json({ ok = false, error = "auth session missing" })
|
||
return false
|
||
end
|
||
|
||
local expected = ctx.token
|
||
|
||
local header_token = http.getenv("HTTP_X_LUCI_TOKEN")
|
||
local form_token = http.formvalue("token")
|
||
local body = read_json_body()
|
||
local body_token = (type(body) == "table") and body["token"] or nil
|
||
|
||
local provided = header_token or form_token or body_token
|
||
|
||
-- 2. If server has a token, enforce strict match
|
||
if expected then
|
||
if provided ~= expected then
|
||
write_json({ ok = false, error = "bad csrf token" })
|
||
return false
|
||
end
|
||
else
|
||
-- 3. If server lost the token (common in some envs),
|
||
-- just ensure the client sent *something* (e.g. via custom header)
|
||
-- This protects against basic CSRF because attackers can't easily set custom headers.
|
||
if not provided or #provided == 0 then
|
||
write_json({ ok = false, error = "csrf token missing" })
|
||
return false
|
||
end
|
||
end
|
||
|
||
return true
|
||
end
|
||
|
||
local function ensure_ddnsto_section()
|
||
local uci = require "luci.model.uci".cursor()
|
||
local sid = nil
|
||
uci:foreach("ddnsto", "ddnsto", function(s) sid = s[".name"] end)
|
||
if not sid then
|
||
sid = uci:add("ddnsto", "ddnsto")
|
||
end
|
||
return sid
|
||
end
|
||
|
||
local function read_config()
|
||
local uci = require "luci.model.uci".cursor()
|
||
local sys = require "luci.sys"
|
||
local cfg = {
|
||
enabled = "1",
|
||
token = "",
|
||
index = "0",
|
||
logger = "0",
|
||
feat_enabled = "0",
|
||
feat_port = "3033",
|
||
feat_username = "",
|
||
feat_password = "",
|
||
feat_disk_path_selected = "",
|
||
address = "",
|
||
mounts = {},
|
||
device_id = "",
|
||
deviceId = "",
|
||
}
|
||
|
||
uci:foreach("ddnsto", "ddnsto", function(s)
|
||
cfg.enabled = s.enabled or cfg.enabled
|
||
cfg.token = s.token or cfg.token
|
||
cfg.index = s.index or cfg.index
|
||
cfg.logger = s.logger or cfg.logger
|
||
cfg.feat_enabled = s.feat_enabled or cfg.feat_enabled
|
||
cfg.feat_port = s.feat_port or cfg.feat_port
|
||
cfg.feat_username = s.feat_username or cfg.feat_username
|
||
cfg.feat_password = s.feat_password or cfg.feat_password
|
||
cfg.feat_disk_path_selected = s.feat_disk_path_selected or cfg.feat_disk_path_selected
|
||
cfg.address = s.address or cfg.address
|
||
end)
|
||
|
||
do
|
||
local did = fetch_device_id(cfg.index)
|
||
cfg.device_id = did
|
||
cfg.deviceId = did
|
||
end
|
||
|
||
-- Get mounts (via block info)
|
||
local mounts = {}
|
||
local block = io.popen("/sbin/block info", "r")
|
||
if block then
|
||
while true do
|
||
local ln = block:read("*l")
|
||
if not ln then break end
|
||
|
||
local dev = ln:match("^/dev/(.-):")
|
||
if dev then
|
||
for key, val in ln:gmatch([[(%w+)="(.-)"]]) do
|
||
if key:lower() == "mount" then
|
||
table.insert(mounts, val)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
block:close()
|
||
end
|
||
cfg.mounts = mounts
|
||
|
||
return cfg
|
||
end
|
||
|
||
-- ==========
|
||
-- LuCI index
|
||
-- ==========
|
||
|
||
function index()
|
||
local ok_fs, fs = pcall(require, "nixio.fs")
|
||
if not (ok_fs and fs) then
|
||
local ok_lfs, lfs = pcall(require, "luci.fs")
|
||
if ok_lfs then fs = lfs end
|
||
end
|
||
|
||
local has_config = true
|
||
if fs and fs.access then
|
||
has_config = fs.access("/etc/config/ddnsto")
|
||
end
|
||
if has_config == false then return end
|
||
|
||
entry({"admin", "services", "ddnsto"}, firstchild(), _("DDNSTO 远程控制"), 60).dependent = false
|
||
entry({"admin", "services", "ddnsto", "page"}, call("action_page"), _("Settings"), 10).leaf = true
|
||
-- entry({"admin", "ddnsto_dev"}, call("action_ddnsto_dev"), _("DDNSTO (Dev)"), 99).leaf = true
|
||
|
||
entry({"admin", "services", "ddnsto", "api", "config"}, call("api_config")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "migrate_identity"}, call("api_migrate_identity")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "service"}, call("api_service")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "run"}, call("api_run")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "restart"}, call("api_restart")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "stop"}, call("api_stop")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "onboarding", "start"}, call("api_onboarding_start")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "onboarding", "address"}, call("api_onboarding_address")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "connectivity"}, call("api_connectivity")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "status"}, call("api_status")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "logs"}, call("api_logs")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "offline_diagnosis"}, call("api_offline_diagnosis")).leaf = true
|
||
entry({"admin", "services", "ddnsto", "api", "support_bundle"}, call("api_support_bundle")).leaf = true
|
||
end
|
||
|
||
function action_page()
|
||
local template = require "luci.template"
|
||
local dsp = require "luci.dispatcher"
|
||
local i18n = require "luci.i18n"
|
||
local ctx = dsp.context or {}
|
||
|
||
local data = {
|
||
token = ctx.token or "",
|
||
prefix = dsp.build_url("admin", "services", "ddnsto"),
|
||
api_base = dsp.build_url(),
|
||
lang = i18n.context.lang or "zh-cn"
|
||
}
|
||
template.render("ddnsto/main", data)
|
||
end
|
||
|
||
-- ==========
|
||
-- API: config
|
||
-- ==========
|
||
|
||
function api_config()
|
||
local http = require "luci.http"
|
||
local uci = require "luci.model.uci".cursor()
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
|
||
if method == "GET" then
|
||
write_json({ ok = true, data = read_config() })
|
||
return
|
||
end
|
||
|
||
if method ~= "POST" then
|
||
method_not_allowed()
|
||
return
|
||
end
|
||
|
||
if not require_csrf() then return end
|
||
|
||
local body = read_json_body()
|
||
|
||
local enabled = param(body, "enabled")
|
||
local ddnsto_token = param(body, "ddnsto_token")
|
||
|
||
local index = param(body, "index")
|
||
local logger = param(body, "logger")
|
||
local feat_enabled = param(body, "feat_enabled")
|
||
local feat_port = param(body, "feat_port")
|
||
local feat_username = param(body, "feat_username")
|
||
local feat_password = param(body, "feat_password")
|
||
local feat_disk_path_selected = param(body, "feat_disk_path_selected")
|
||
|
||
-- 基本校验(按需扩展)
|
||
if enabled and not is_bool01(enabled) then return bad_request("bad enabled") end
|
||
if logger and not is_bool01(logger) then return bad_request("bad logger") end
|
||
if feat_enabled and not is_bool01(feat_enabled) then return bad_request("bad feat_enabled") end
|
||
|
||
local has_payload = enabled ~= nil or ddnsto_token ~= nil or index ~= nil or logger ~= nil
|
||
or feat_enabled ~= nil or feat_port ~= nil or feat_username ~= nil or feat_password ~= nil
|
||
or feat_disk_path_selected ~= nil
|
||
if not has_payload then
|
||
return bad_request("invalid request")
|
||
end
|
||
|
||
local enabled_on = enabled == "1"
|
||
local feat_on = feat_enabled == "1"
|
||
|
||
if enabled_on and is_empty(ddnsto_token) then
|
||
return bad_request("请填写正确用户Token(令牌)")
|
||
end
|
||
|
||
if ddnsto_token ~= nil and has_space(ddnsto_token) then
|
||
return bad_request("令牌勿包含空格")
|
||
end
|
||
|
||
if not is_uint(index) then
|
||
return bad_request("请填写正确的设备编号,仅允许数字")
|
||
end
|
||
local index_num = tonumber(index)
|
||
if index_num < 0 or index_num > 99 then
|
||
return bad_request("请填写正确的设备编号,仅允许数字")
|
||
end
|
||
|
||
if feat_on then
|
||
if not is_uint(feat_port) then
|
||
return bad_request("请填写正确的端口")
|
||
end
|
||
|
||
local port_num = tonumber(feat_port)
|
||
if not port_num or port_num == 0 or port_num > 65535 then
|
||
return bad_request("请填写正确的端口")
|
||
end
|
||
|
||
if is_empty(feat_username) then
|
||
return bad_request("请填写授权用户名")
|
||
end
|
||
if has_space(feat_username) then
|
||
return bad_request("用户名请勿包含空格")
|
||
end
|
||
if is_empty(feat_password) then
|
||
return bad_request("请填写授权用户密码")
|
||
end
|
||
if has_space(feat_password) then
|
||
return bad_request("用户密码请勿包含空格")
|
||
end
|
||
if is_empty(feat_disk_path_selected) then
|
||
return bad_request("请填写共享磁盘路径")
|
||
end
|
||
end
|
||
|
||
local sid = ensure_ddnsto_section()
|
||
|
||
-- 白名单写入:只写我们明确允许前端控制的字段
|
||
if enabled then uci:set("ddnsto", sid, "enabled", enabled) end
|
||
if ddnsto_token ~= nil then uci:set("ddnsto", sid, "token", ddnsto_token) end
|
||
if index then uci:set("ddnsto", sid, "index", index) end
|
||
if logger then uci:set("ddnsto", sid, "logger", logger) end
|
||
if feat_enabled then uci:set("ddnsto", sid, "feat_enabled", feat_enabled) end
|
||
if feat_port then uci:set("ddnsto", sid, "feat_port", feat_port) end
|
||
if feat_username then uci:set("ddnsto", sid, "feat_username", feat_username) end
|
||
if feat_password then uci:set("ddnsto", sid, "feat_password", feat_password) end
|
||
if feat_disk_path_selected then uci:set("ddnsto", sid, "feat_disk_path_selected", feat_disk_path_selected) end
|
||
|
||
uci:commit("ddnsto")
|
||
|
||
-- Restart service to apply changes
|
||
local sys = require "luci.sys"
|
||
sys.call("/etc/init.d/ddnsto restart >/dev/null 2>&1")
|
||
|
||
write_json({ ok = true })
|
||
end
|
||
|
||
function api_migrate_identity()
|
||
local http = require "luci.http"
|
||
local uci = require "luci.model.uci".cursor()
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
|
||
if method ~= "POST" then
|
||
method_not_allowed()
|
||
return
|
||
end
|
||
|
||
if not require_csrf() then return end
|
||
|
||
local body = read_json_body()
|
||
local enabled = param(body, "enabled")
|
||
local ddnsto_token = param(body, "ddnsto_token")
|
||
local index = param(body, "index")
|
||
local logger = param(body, "logger")
|
||
local feat_enabled = param(body, "feat_enabled")
|
||
local feat_port = param(body, "feat_port")
|
||
local feat_username = param(body, "feat_username")
|
||
local feat_password = param(body, "feat_password")
|
||
local feat_disk_path_selected = param(body, "feat_disk_path_selected")
|
||
local confirm_reidentity = param(body, "confirm_reidentity")
|
||
|
||
if confirm_reidentity ~= "1" then
|
||
return bad_request("missing identity migration confirmation")
|
||
end
|
||
if enabled and not is_bool01(enabled) then return bad_request("bad enabled") end
|
||
if logger and not is_bool01(logger) then return bad_request("bad logger") end
|
||
if feat_enabled and not is_bool01(feat_enabled) then return bad_request("bad feat_enabled") end
|
||
if ddnsto_token ~= nil and has_space(ddnsto_token) then
|
||
return bad_request("令牌勿包含空格")
|
||
end
|
||
if not is_uint(index) then
|
||
return bad_request("请填写正确的设备编号,仅允许数字")
|
||
end
|
||
local index_num = tonumber(index)
|
||
if index_num < 0 or index_num > 99 then
|
||
return bad_request("请填写正确的设备编号,仅允许数字")
|
||
end
|
||
|
||
local enabled_on = enabled == "1"
|
||
local feat_on = feat_enabled == "1"
|
||
if enabled_on and is_empty(ddnsto_token) then
|
||
return bad_request("请填写正确用户Token(令牌)")
|
||
end
|
||
if feat_on then
|
||
if not is_uint(feat_port) then
|
||
return bad_request("请填写正确的端口")
|
||
end
|
||
local port_num = tonumber(feat_port)
|
||
if not port_num or port_num == 0 or port_num > 65535 then
|
||
return bad_request("请填写正确的端口")
|
||
end
|
||
if is_empty(feat_username) then
|
||
return bad_request("请填写授权用户名")
|
||
end
|
||
if has_space(feat_username) then
|
||
return bad_request("用户名请勿包含空格")
|
||
end
|
||
if is_empty(feat_password) then
|
||
return bad_request("请填写授权用户密码")
|
||
end
|
||
if has_space(feat_password) then
|
||
return bad_request("用户密码请勿包含空格")
|
||
end
|
||
if is_empty(feat_disk_path_selected) then
|
||
return bad_request("请填写共享磁盘路径")
|
||
end
|
||
end
|
||
|
||
local old_identity = read_identity_state()
|
||
local current = read_config()
|
||
local old_index = tostring(current.index or "0")
|
||
local identity_missing = is_empty(old_identity.router_id)
|
||
if tostring(index) == old_index and not identity_missing then
|
||
return conflict_request("identity migration not required", "index unchanged", "identity_migration_not_required")
|
||
end
|
||
local sid = ensure_ddnsto_section()
|
||
|
||
run_init_action("stop")
|
||
|
||
if enabled then uci:set("ddnsto", sid, "enabled", enabled) end
|
||
if ddnsto_token ~= nil then uci:set("ddnsto", sid, "token", ddnsto_token) end
|
||
if index then uci:set("ddnsto", sid, "index", index) end
|
||
if logger then uci:set("ddnsto", sid, "logger", logger) end
|
||
if feat_enabled then uci:set("ddnsto", sid, "feat_enabled", feat_enabled) end
|
||
if feat_port then uci:set("ddnsto", sid, "feat_port", feat_port) end
|
||
if feat_username then uci:set("ddnsto", sid, "feat_username", feat_username) end
|
||
if feat_password then uci:set("ddnsto", sid, "feat_password", feat_password) end
|
||
if feat_disk_path_selected then uci:set("ddnsto", sid, "feat_disk_path_selected", feat_disk_path_selected) end
|
||
|
||
uci:delete("ddnsto", sid, "router_id")
|
||
uci:delete("ddnsto", sid, "identity_mode")
|
||
uci:delete("ddnsto", sid, "device_uuid")
|
||
uci:commit("ddnsto")
|
||
|
||
local restart_rc = 0
|
||
if enabled ~= "0" then
|
||
restart_rc = run_init_action("start")
|
||
end
|
||
|
||
write_json({
|
||
ok = true,
|
||
data = {
|
||
old_index = old_index,
|
||
new_index = tostring(index),
|
||
old_router_id = old_identity.router_id or "",
|
||
service_started = restart_rc == 0 and enabled ~= "0" or false,
|
||
message = "设备身份已重置,服务正在使用新的设备编号重新生成设备 ID",
|
||
}
|
||
})
|
||
end
|
||
|
||
-- ==========
|
||
-- API: service
|
||
-- ==========
|
||
|
||
local function run_service_action(action, allow_reload)
|
||
local http = require "luci.http"
|
||
local sys = require "luci.sys"
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
|
||
if method ~= "POST" then
|
||
method_not_allowed()
|
||
return
|
||
end
|
||
|
||
if not require_csrf() then return end
|
||
|
||
if not action then
|
||
local body = read_json_body()
|
||
action = param(body, "action") or ""
|
||
end
|
||
|
||
local allow = {
|
||
start = true,
|
||
stop = true,
|
||
restart = true,
|
||
reload = allow_reload == true,
|
||
}
|
||
|
||
if not allow[action] then
|
||
return bad_request("bad action")
|
||
end
|
||
|
||
local cmd = string.format("/etc/init.d/ddnsto %s >/dev/null 2>&1", action)
|
||
local rc = sys.call(cmd)
|
||
write_json({ ok = (rc == 0), rc = rc })
|
||
end
|
||
|
||
function api_service()
|
||
return run_service_action(nil, true)
|
||
end
|
||
|
||
function api_run()
|
||
return run_service_action("start")
|
||
end
|
||
|
||
function api_restart()
|
||
return run_service_action("restart")
|
||
end
|
||
|
||
function api_stop()
|
||
return run_service_action("stop")
|
||
end
|
||
|
||
-- ==========
|
||
-- API: onboarding helpers
|
||
-- ==========
|
||
|
||
function api_onboarding_start()
|
||
local http = require "luci.http"
|
||
local uci = require "luci.model.uci".cursor()
|
||
local sys = require "luci.sys"
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
|
||
if method ~= "POST" then
|
||
method_not_allowed()
|
||
return
|
||
end
|
||
|
||
if not require_csrf() then return end
|
||
|
||
local body = read_json_body()
|
||
local token = param(body, "token")
|
||
|
||
if is_empty(token) then
|
||
return bad_request("token required")
|
||
end
|
||
if has_space(token) then
|
||
return bad_request("token must not contain spaces")
|
||
end
|
||
|
||
local sid = ensure_ddnsto_section()
|
||
uci:set("ddnsto", sid, "token", token)
|
||
uci:set("ddnsto", sid, "enabled", "1")
|
||
uci:set("ddnsto", sid, "feat_enabled", "0")
|
||
uci:commit("ddnsto")
|
||
|
||
local rc = sys.call("/etc/init.d/ddnsto restart >/dev/null 2>&1")
|
||
write_json({ ok = (rc == 0), rc = rc })
|
||
end
|
||
|
||
function api_onboarding_address()
|
||
local http = require "luci.http"
|
||
local uci = require "luci.model.uci".cursor()
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
|
||
if method ~= "POST" then
|
||
method_not_allowed()
|
||
return
|
||
end
|
||
|
||
if not require_csrf() then return end
|
||
|
||
local body = read_json_body()
|
||
local url = param(body, "url") or param(body, "address")
|
||
|
||
if is_empty(url) then
|
||
return bad_request("address required")
|
||
end
|
||
|
||
local sid = ensure_ddnsto_section()
|
||
uci:set("ddnsto", sid, "address", url)
|
||
uci:commit("ddnsto")
|
||
|
||
write_json({ ok = true })
|
||
end
|
||
|
||
-- ==========
|
||
-- API: status
|
||
-- ==========
|
||
|
||
function api_status()
|
||
local sys = require "luci.sys"
|
||
local uci = require "luci.model.uci".cursor()
|
||
local jsonc = require "luci.jsonc"
|
||
|
||
local enabled, token = "0", ""
|
||
local address, index = "", "0"
|
||
uci:foreach("ddnsto", "ddnsto", function(s)
|
||
enabled = s.enabled or "0"
|
||
token = s.token or ""
|
||
address = s.address or ""
|
||
index = s.index or index
|
||
end)
|
||
|
||
local raw = sys.exec([[ubus call service list '{"name":"ddnsto"}' 2>/dev/null]]) or ""
|
||
local pid, running = "", false
|
||
|
||
local ok, obj = pcall(jsonc.parse, raw)
|
||
if ok and type(obj) == "table" and type(obj.ddnsto) == "table" and type(obj.ddnsto.instances) == "table" then
|
||
for _, inst in pairs(obj.ddnsto.instances) do
|
||
if type(inst) == "table" and inst.running == true then
|
||
running = true
|
||
pid = tostring(inst.pid or "")
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
local board_raw = sys.exec("ubus call system board 2>/dev/null") or ""
|
||
local hostname = "OpenWrt"
|
||
local ok_board, board_obj = pcall(jsonc.parse, board_raw)
|
||
if ok_board and type(board_obj) == "table" and board_obj.hostname then
|
||
hostname = board_obj.hostname
|
||
end
|
||
|
||
local version = get_command("/usr/sbin/ddnstod -v")
|
||
|
||
local did = ""
|
||
do
|
||
did = fetch_device_id(index)
|
||
end
|
||
|
||
write_json({
|
||
ok = true,
|
||
data = {
|
||
enabled = enabled,
|
||
running = running,
|
||
pid = pid,
|
||
token_set = (token and #token > 0) or false,
|
||
address = address,
|
||
device_id = did,
|
||
deviceId = did,
|
||
hostname = hostname,
|
||
version = version,
|
||
}
|
||
})
|
||
end
|
||
|
||
-- ==========
|
||
-- API: connectivity (tunnel server reachability)
|
||
-- ==========
|
||
|
||
function api_connectivity()
|
||
local sys = require "luci.sys"
|
||
|
||
local function resolve_host(host)
|
||
local out = sys.exec(string.format("nslookup %s 223.5.5.5 2>/dev/null", host)) or ""
|
||
if out == "" then
|
||
out = sys.exec(string.format("nslookup %s 8.8.8.8 2>/dev/null", host)) or ""
|
||
end
|
||
if out == "" then
|
||
out = sys.exec(string.format("nslookup %s 2>/dev/null", host)) or ""
|
||
end
|
||
local ip = out:match("Address 1:%s*([%d%.]+)") or out:match("Address:%s*([%d%.]+)")
|
||
return ip or ""
|
||
end
|
||
|
||
local tunnel_targets = {}
|
||
local resolved_ip = resolve_host("tunnel.kooldns.cn")
|
||
if resolved_ip ~= "" then table.insert(tunnel_targets, resolved_ip) end
|
||
table.insert(tunnel_targets, "tunnel.kooldns.cn")
|
||
table.insert(tunnel_targets, "125.39.21.43")
|
||
|
||
do
|
||
local seen = {}
|
||
local uniq = {}
|
||
for _, t in ipairs(tunnel_targets) do
|
||
if not seen[t] then
|
||
seen[t] = true
|
||
table.insert(uniq, t)
|
||
end
|
||
end
|
||
tunnel_targets = uniq
|
||
end
|
||
|
||
local function connect_target(target)
|
||
local ret = sys.call(string.format("ping -c 1 -W 2 %s >/dev/null 2>&1", target))
|
||
if ret == 0 then
|
||
return 0, nil
|
||
end
|
||
return ret, string.format("ping exit %d to %s", ret, target)
|
||
end
|
||
|
||
local tunnel_ok = false
|
||
local tunnel_err = nil
|
||
|
||
if #tunnel_targets == 0 then
|
||
tunnel_err = "resolve tunnel.kooldns.cn failed"
|
||
else
|
||
for _, target in ipairs(tunnel_targets) do
|
||
local ret, err = connect_target(target)
|
||
if ret == 0 then
|
||
tunnel_ok = true
|
||
tunnel_err = nil
|
||
break
|
||
else
|
||
tunnel_err = err
|
||
end
|
||
end
|
||
end
|
||
|
||
write_json({
|
||
ok = true,
|
||
data = {
|
||
tunnel_ok = tunnel_ok,
|
||
tunnel_ret = tunnel_ok and nil or tunnel_err,
|
||
targets = tunnel_targets,
|
||
}
|
||
})
|
||
end
|
||
|
||
-- ==========
|
||
-- API: logs
|
||
-- ==========
|
||
|
||
function api_logs()
|
||
local http = require "luci.http"
|
||
local sys = require "luci.sys"
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
|
||
if method ~= "GET" then
|
||
method_not_allowed()
|
||
return
|
||
end
|
||
|
||
local lines = tonumber(http.formvalue("lines") or "200") or 200
|
||
if lines < 10 then lines = 10 end
|
||
if lines > 2000 then lines = 2000 end
|
||
|
||
local cli_lines = diagnostics_logs_via_cli(lines)
|
||
if cli_lines ~= nil then
|
||
write_json({ ok = true, data = { lines = cli_lines, total = #cli_lines, source = "diagnostics_cli" } })
|
||
return
|
||
end
|
||
|
||
local cmd = string.format("logread 2>/dev/null | grep -E 'ddnsto|ddnstod' | tail -n %d", lines)
|
||
local out = sys.exec(cmd) or ""
|
||
local arr = {}
|
||
|
||
for line in out:gmatch("([^\n]*)\n?") do
|
||
if line and #line > 0 then
|
||
arr[#arr + 1] = line
|
||
end
|
||
end
|
||
|
||
write_json({ ok = true, data = { lines = arr, total = #arr, source = "logread_fallback" } })
|
||
end
|
||
|
||
function api_offline_diagnosis()
|
||
local http = require "luci.http"
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
|
||
if method ~= "GET" then
|
||
method_not_allowed()
|
||
return
|
||
end
|
||
|
||
if not service_running() then
|
||
write_json({
|
||
ok = false,
|
||
error = "offline diagnosis unavailable",
|
||
detail = "ddnsto not running",
|
||
code = "ddnsto_not_running",
|
||
})
|
||
return
|
||
end
|
||
|
||
local data, source = offline_diagnosis_via_http()
|
||
if data ~= nil then
|
||
write_json({ ok = true, data = data, source = source })
|
||
return
|
||
end
|
||
|
||
write_json({ ok = false, error = "offline diagnosis unavailable" })
|
||
end
|
||
|
||
function api_support_bundle()
|
||
local http = require "luci.http"
|
||
local method = http.getenv("REQUEST_METHOD") or ""
|
||
|
||
if method ~= "GET" then
|
||
method_not_allowed()
|
||
return
|
||
end
|
||
|
||
if not service_running() then
|
||
http.status(409, "ddnsto not running")
|
||
write_json({
|
||
ok = false,
|
||
error = "diagnostics bundle unavailable",
|
||
detail = "ddnsto not running",
|
||
code = "ddnsto_not_running",
|
||
})
|
||
return
|
||
end
|
||
|
||
local output_path = temp_path(".zip")
|
||
local ok, err = diagnostics_bundle_via_http(output_path)
|
||
if not ok then
|
||
remove_file(output_path)
|
||
http.status(500, "bundle failed")
|
||
write_json({ ok = false, error = "diagnostics bundle unavailable", detail = err or "" })
|
||
return
|
||
end
|
||
|
||
local filename = string.format("ddnsto-openwrt-support-%d.zip", os.time())
|
||
stream_download(output_path, filename, "application/zip")
|
||
remove_file(output_path)
|
||
end
|
||
|
||
function action_ddnsto_dev()
|
||
local dsp = require "luci.dispatcher"
|
||
local i18n = require "luci.i18n"
|
||
local template = require "luci.template"
|
||
local ctx = dsp.context or {}
|
||
|
||
local data = {
|
||
token = ctx.token or "",
|
||
prefix = dsp.build_url("admin", "ddnsto_dev"),
|
||
api_base= dsp.build_url(),
|
||
lang = i18n.context.lang or "zh-cn"
|
||
}
|
||
|
||
template.render("ddnsto/dev", data)
|
||
end
|