module("luci.controller.smart_srun", package.seeall) local http = require "luci.http" local jsonc = require "luci.jsonc" local sys = require "luci.sys" local util = require "luci.util" local fs = require "nixio.fs" local rpc = require "luci.smart_srun.rpc" local bridge = require "luci.smart_srun.bridge" local LOG_TAIL_SOURCE_LINES = 2000 local NETWORK_EVENTS = { bind_ip_resolved = true, http_bind_device = true, http_fetch = true, http_fetch_result = true, connectivity_probe_begin = true, connectivity_probe_result = true, dns_probe_failed = true, srun_challenge = true, srun_challenge_result = true, srun_login_submit = true, srun_login_response = true, srun_logout_response = true, srun_online_query = true, srun_online_result = true, ip_wait_progress = true, ip_wait_result = true, dhcp_kick = true, sta_iface_up = true, wifi_reload = true, sta_section_disabled = true, uci_wireless_update = true, } local SYSLOG_TAGS = { netifd = true, wpa_supplicant = true, hostapd = true, udhcpc = true, } local SYSLOG_KEYWORDS = { "authentication", "disconnected", "dhcp", "no lease", } local SYSLOG_LEVELS = { err = "ERROR", crit = "ERROR", alert = "ERROR", emerg = "ERROR", warn = "WARN", warning = "WARN", debug = "DEBUG", } local MONTH_MAP = { Jan = 1, Feb = 2, Mar = 3, Apr = 4, May = 5, Jun = 6, Jul = 7, Aug = 8, Sep = 9, Oct = 10, Nov = 11, Dec = 12, } function index() entry({"admin", "services", "smart_srun", "config_export"}, call("action_config_export")).leaf = true entry({"admin", "services", "smart_srun", "config_import"}, call("action_config_import")).leaf = true entry({"admin", "services", "smart_srun"}, cbi("smart_srun"), _("SMART SRun"), 80).dependent = true entry({"admin", "services", "smart_srun", "status"}, call("action_status")).leaf = true entry({"admin", "services", "smart_srun", "enqueue"}, call("action_enqueue")).leaf = true entry({"admin", "services", "smart_srun", "log_tail"}, call("action_log_tail")).leaf = true entry({"admin", "services", "smart_srun", "log_clear"}, call("action_log_clear")).leaf = true entry({"admin", "services", "smart_srun", "update_check"}, call("action_update_check")).leaf = true entry({"admin", "services", "smart_srun", "update_start"}, call("action_update_start")).leaf = true entry({"admin", "services", "smart_srun", "update_status"}, call("action_update_status")).leaf = true entry({"admin", "services", "smart_srun", "presets_refresh"}, call("action_presets_refresh")).leaf = true entry({"admin", "services", "smart_srun", "user_presets_set"}, call("action_user_presets_set")).leaf = true entry({"admin", "services", "smart_srun", "detect_acid"}, call("action_detect_acid")).leaf = true entry({"admin", "services", "smart_srun", "detect_env"}, call("action_detect_env")).leaf = true entry({"admin", "services", "smart_srun", "detect_operator"}, call("action_detect_operator")).leaf = true entry({"admin", "services", "smart_srun", "discover_operators"}, call("action_discover_operators")).leaf = true entry({"admin", "services", "smart_srun", "setup_wifi"}, call("action_setup_wifi")).leaf = true end local function write_json_response(payload) http.prepare_content("application/json") http.write(jsonc.stringify(payload or {}) or "{}") end local function run_srunnet_json(args) -- opkg briefly removes execute permission while replacing /usr/bin/srunnet. -- The private worker copy remains executable throughout that interval and -- reads exactly the same fixed, bounded status files without starting RPC. local program = "/usr/bin/srunnet" if args == "update status" and fs.access("/var/run/smart-srun/update-worker", "x") then program = "/var/run/smart-srun/update-worker" end local output = sys.exec(program .. " " .. args .. " 2>&1") or "" local parsed = jsonc.parse(output) if type(parsed) == "table" then return parsed end return { ok = false, message = util.trim(output) ~= "" and util.trim(output) or "命令返回非 JSON 输出", } end function action_update_check() -- A cold page must not start a stopped service just to check a release. local payload, err = rpc.call("update.check", nil) write_json_response(payload or { ok = false, message = rpc.message(err, "无法检查更新") }) end function action_update_start() local dispatcher = require "luci.dispatcher" if not dispatcher.test_post_security() then return end local ok, err = rpc.start_update(tostring(http.formvalue("plan_id") or "")) if not ok then write_json_response({ ok = false, message = rpc.message(err, "无法提交更新") }) return end write_json_response(run_srunnet_json("update status")) end function action_update_status() local job_id = tostring(http.formvalue("job_id") or "") if job_id ~= "" then if #job_id ~= 32 or not job_id:match("^[0-9a-f]+$") then write_json_response({ ok = false, message = "更新任务编号无效" }) return end local payload, err = rpc.call("update.status", { job_id = job_id }) write_json_response(payload or { ok = false, message = rpc.message(err, "无法读取检查结果") }) return end -- The helper only reads bounded, validated fixed-path files. It does not -- call ensure-running while the worker owns installation and restart. write_json_response(run_srunnet_json("update status")) end -- 状态改为从 Go 守护进程的组合快照读取。 -- 轮询只读缓存:不启动服务,也不触发认证或同步探测(规范 02/03)。 function action_status() local action_id = http.formvalue("action_id") if action_id ~= nil then write_json_response(bridge.action_feedback(action_id)) return end local payload, err = bridge.status() if not payload then payload = bridge.offline_view(rpc.message(err, "无法读取认证服务状态"), os.time()) payload.ok = false if not rpc.service_stopped(err) then payload.service = "unknown" end end write_json_response(payload) end local function fv(name) return tostring(http.formvalue(name) or ""):match("^%s*(.-)%s*$") end -- 用户自定义预设/运营商存储由守护进程持有(独立 revision + CAS)。 -- 浏览器整份提交,这里只补齐 schema_version/revision 并转交;校验、去重、 -- 上限和与公共目录的冲突检查都在 Go 侧完成,页面不再自己落盘。 local function sanitize_user_operator(raw) if type(raw) ~= "table" then return nil end local item = { suffix = tostring(raw.suffix or ""), label = tostring(raw.label or ""), } if item.suffix == "" and item.label == "" then return nil end return item end local function sanitize_user_preset(raw) if type(raw) ~= "table" then return nil end local name = util.trim(tostring(raw.name or "")) local short_name = util.trim(tostring(raw.short_name or "")) if name == "" or not short_name:match("^custom%-[%w%-]+$") then return nil end local defaults_raw = type(raw.defaults) == "table" and raw.defaults or {} local shape_raw = type(raw.observed_login_shape) == "table" and raw.observed_login_shape or {} local item = { short_name = short_name, name = name, custom = true, defaults = { base_url = tostring(defaults_raw.base_url or ""), ac_id = tostring(defaults_raw.ac_id or ""), ssid = tostring(defaults_raw.ssid or ""), access_mode = tostring(defaults_raw.access_mode or ""), wired_iface = tostring(defaults_raw.wired_iface or ""), }, observed_login_shape = { n = tostring(shape_raw.n or ""), type = tostring(shape_raw.type or ""), enc = tostring(shape_raw.enc or ""), info_prefix = tostring(shape_raw.info_prefix or ""), double_stack = tostring(shape_raw.double_stack or ""), os = tostring(shape_raw.os or ""), name = tostring(shape_raw.name or ""), }, operators = {}, } if type(raw.operators) == "table" then for _, op in ipairs(raw.operators) do if #item.operators >= USER_PRESETS_MAX_ITEMS then break end local so = sanitize_user_operator(op) if so then item.operators[#item.operators + 1] = so end end end return item end -- 浏览器提交的整份存储只做形状归整:条目原样转交,未知字段保留。 -- schema_version/revision 由本次读到的版本决定,CAS 冲突时由守护进程拒绝。 local function shape_user_preset_store(raw, revision) local store = { schema_version = 2, revision = revision, presets = {}, operators = {} } if type(raw) ~= "table" then return store end if type(raw.presets) == "table" then for _, item in ipairs(raw.presets) do -- The shaping keeps every nested object non-empty, which an empty -- Lua table could not express: it would encode as [] and the -- daemon would refuse a preset whose defaults were an array. local shaped = sanitize_user_preset(item) if shaped then store.presets[#store.presets + 1] = shaped end end end if type(raw.operators) == "table" then for _, operator in ipairs(raw.operators) do local shaped = sanitize_user_operator(operator) if shaped then store.operators[#store.operators + 1] = shaped end end end return store end function action_user_presets_set() local dispatcher = require "luci.dispatcher" if not dispatcher.test_post_security() then return end local parsed = jsonc.parse(tostring(http.formvalue("data") or "")) if type(parsed) ~= "table" then write_json_response({ ok = false, message = "数据格式错误" }) return end -- Read the current version first: the store has its own revision, and the -- daemon refuses a save carrying a stale one rather than overwriting an -- edit made from another tab. local current, err = rpc.call("user_presets.get", nil) if not current then write_json_response({ ok = false, message = rpc.message(err, "无法读取用户预设") }) return end local store = shape_user_preset_store(parsed, tonumber(current.revision) or 0) local saved, save_err = rpc.call_started("user_presets.set", { expected_revision = store.revision, document = store, }) if not saved then write_json_response({ ok = false, message = rpc.message(save_err, "保存用户预设失败") }) return end write_json_response({ ok = true, presets = #store.presets, operators = #store.operators, }) end local function discovery_job(method) local dispatcher = require "luci.dispatcher" if not dispatcher.test_post_security() then return end local session = dispatcher.context.authsession if type(session) ~= "string" or session == "" then write_json_response({ ok = false, message = "LuCI 登录已失效,请重新登录" }) return end local action = fv("action") local payload, err if action == "status" or action == "cancel" then payload, err = rpc.call(action == "status" and "action.get" or "action.cancel", { action_id = fv("action_id"), session = session, }) elseif action == "" or action == "start" then local params = { base_url = fv("base_url"), access_mode = fv("access_mode"), iface = fv("iface"), ssid = fv("ssid"), school = fv("school"), ac_id = fv("ac_id"), idempotency_key = fv("idempotency_key"), session = session, } if method == "detect.identity" or method == "detect.verify" then params.user_id = fv("user_id") end if method == "detect.verify" then params.password = tostring(http.formvalue("password") or "") params.candidates = jsonc.parse(fv("candidates")) params.max_attempts = tonumber(fv("max_attempts")) or 5 params.login = { n = fv("n"), type = fv("type"), enc = fv("enc"), info_prefix = fv("info_prefix"), os = fv("login_os"), name = fv("login_name"), } if fv("double_stack") ~= "" then params.login.double_stack = fv("double_stack") == "1" end end if method == "presets.refresh" then local iface = fv("iface") if iface == "" then local status = bridge.status() iface = type(status) == "table" and tostring(status.current_iface or "") or "" end params = { iface = iface, idempotency_key = fv("idempotency_key"), session = session } end payload, err = rpc.call_started(method, params) else write_json_response({ ok = false, message = "探测操作无效" }) return end if not payload then write_json_response({ ok = false, code = err and err.code, message = rpc.message(err, "探测失败") }) return end if method == "presets.refresh" and action == "status" and payload.state == "succeeded" then local schools, list_err = bridge.presets() if not schools then write_json_response({ ok = false, message = rpc.message(list_err, "无法读取刷新后的预设") }) return end payload.result = { ok = true, schools = schools, message = payload.message } end payload.ok = true if type(payload.result) == "table" then payload.result.value = tostring(payload.result.acid or "") payload.result.ac_id = tostring(payload.result.acid or "") end write_json_response(payload) end function action_config_export() if not require("luci.dispatcher").test_post_security() then return end http.header("Cache-Control", "no-store") local backup, err = rpc.call("config.export", { include_secrets = true, as_json = true }) if not backup or type(backup.data) ~= "string" or #backup.data > 524288 then write_json_response({ ok = false, message = rpc.message(err, "导出失败") }) return end http.header("Content-Disposition", 'attachment; filename="smart-srun-config.json"') -- Preserve {} versus [] (and every credential byte) across Lua's JSON -- table representation, just as the import path preserves uploaded text. http.prepare_content("application/json") http.write(backup.data) end function action_config_import() if not require("luci.dispatcher").test_post_security() then return end http.header("Cache-Control", "no-store") local data = http.formvalue("data") local preview = http.formvalue("check") == "1" local revision = http.formvalue("expected_revision") if type(data) ~= "string" or #data > 524288 or (not preview and (type(revision) ~= "string" or not revision:match("^%d+$") or #revision > 15)) then write_json_response({ ok = false, message = "备份或配置版本无效,请重新选择备份" }) return end -- Preserve the original JSON string for Go's duplicate/type/depth checks. local result, err = rpc.call("config.import", { data = data, check_only = preview, expected_revision = not preview and tonumber(revision) or nil, }) write_json_response(result or { ok = false, message = rpc.message(err, "导入失败") }) end function action_presets_refresh() discovery_job("presets.refresh") end function action_detect_acid() discovery_job("detect.acid") end -- 出口自检:绑定所选接口;已在线时仍可检查预设、已有账号及本线路网关。 function action_detect_env() discovery_job("detect.environment") end -- 显式区分只读身份查询和主动验证。凭据仅经 socket 传给有界任务。 function action_detect_operator() discovery_job(fv("read_only") == "1" and "detect.identity" or "detect.verify") end function action_setup_wifi() local dispatcher = require "luci.dispatcher" if not dispatcher.test_post_security() then return end local session = dispatcher.context.authsession if type(session) ~= "string" or session == "" then write_json_response({ ok = false, message = "LuCI 登录已失效,请重新登录" }) return end local job = fv("job") if #job ~= 32 or not job:match("^[a-f0-9]+$") then write_json_response({ ok = false, message = "无线任务编号无效" }) return end local action = fv("action") if action ~= "start" and action ~= "status" and action ~= "cancel" then write_json_response({ ok = false, message = "无线操作无效" }) return end local params = { job = job, session = session } if action == "start" then params.ssid = tostring(http.formvalue("ssid") or "") params.key = tostring(http.formvalue("key") or "") params.encryption = fv("encryption") params.iface = fv("iface") params.radio = fv("radio") end local invoke = action == "start" and rpc.call_started or rpc.call local result, err = invoke("setup_wifi." .. action, params) write_json_response(result or { ok = false, state = action == "status" and "missing" or "failed", code = err and err.code, message = rpc.message(err, "无线连接操作失败") }) end function action_discover_operators() discovery_job("detect.operators") end local function normalize_base_url(value) local text = tostring(value or ""):match("^%s*(.-)%s*$") if text == "" then return "" end local origin = text:match("^(https?://[^/%?#]+)") if origin then return origin end if not text:match("^%a[%w+.-]*://") then local host = text:match("^([^/%?#]+)") if host and host ~= "" then return "http://" .. host end end return (text:gsub("/+$", "")) end -- 手动动作交给 Go 协调器排队,配置写入走 campus/hotspot 的 CAS 提交。 -- Lua 不再写 config.json / action.json,也不再重启服务:配置提交后守护进程 -- 自行重新观测,服务生命周期只经固定 helper。 local DAEMON_ACTIONS = { switch_hotspot = "已提交切到热点请求,自动守护已暂停", switch_campus = "已提交切回校园网请求", manual_login = "已提交手动登录请求", manual_logout = "已提交手动登出请求", } -- Go owns priority, cancellation and per-line serialization. Background work -- must not stop a higher-priority user request at the LuCI preflight. local BACKGROUND_ACTIONS = { maintain = true, presets_refresh = true, forced_logout = true, quiet_hotspot = true, quiet_campus = true, } -- 一次点击一个幂等键。同一秒内的重复提交会被协调器认成同一个动作, -- 这正是双击应该发生的事——不是第二次登录。 local function idempotency_key(action, requested_at) local nixio = require "nixio" return string.format("luci-%s-%d-%d", action, requested_at, nixio.getpid()) end local function running_action(snapshot) for _, action in ipairs(type(snapshot) == "table" and snapshot.actions or {}) do local state = tostring(action.state or "") if (state == "queued" or state == "running") and not BACKGROUND_ACTIONS[tostring(action.kind or "")] then return tostring(action.kind or "") end end return "" end local function submit_daemon_action(action) local requested_at = os.time() -- 只在快照可读时挡:服务停着时读不到,本来就没有在执行的动作。 local pending = running_action(rpc.call("status.get", nil)) if pending ~= "" then write_json_response({ ok = false, message = "已有动作正在执行: " .. pending .. ",请等待完成后再试", action = action, pending_action = pending, ts = requested_at, }) return end -- 明确的用户动作可以先拉起服务,再提交自己的请求(规范 02)。 local config, err = rpc.call_started("config.get", nil) if not config then write_json_response({ ok = false, message = rpc.message(err, "无法读取配置"), action = action }) return end local selection = type(config.selection) == "table" and config.selection or {} local params = { kind = action, idempotency_key = idempotency_key(action, requested_at), expected_revision = tonumber(config.revision) or 0, } if action == "switch_hotspot" then params.hotspot_id = tostring(selection.default_hotspot_id or "") if params.hotspot_id == "" then write_json_response({ ok = false, message = "尚未配置热点,请先添加", action = action }) return end else -- 切回校园网用默认账号,登录/登出用当前账号,与 CLI 同一套选择规则。 params.account_id = tostring(selection.active_campus_id or "") if action == "switch_campus" then params.account_id = tostring(selection.default_campus_id or "") end if params.account_id == "" then write_json_response({ ok = false, message = "尚未配置校园网账号,请先添加", action = action }) return end end local receipt, submit_err = rpc.call("action.submit", params) if not receipt then write_json_response({ ok = false, message = rpc.message(submit_err, "提交动作失败"), action = action }) return end write_json_response({ ok = true, message = DAEMON_ACTIONS[action], action = action, action_id = tostring(receipt.action_id or ""), requested_at = requested_at, }) end -- 强停映射固定的 `srunnet service stop`:先取消本项目进行中的动作再停服务, -- 不改用户的自动认证开关,也不再遍历进程表逐个杀。 local function handle_force_stop() local ok, err = rpc.stop_service() if not ok then return false, rpc.message(err, "停止认证服务失败") end return true, "已强制关闭插件并停止服务" end local function campus_form() return { id = fv("id"), label = fv("label"), user_id = fv("user_id"), operator = fv("operator"), operator_suffix = fv("operator_suffix"), password = fv("password"), access_mode = fv("access_mode"), wired_iface = fv("wired_iface"), network_interface = fv("network_interface"), auth_enabled = fv("auth_enabled"), base_url = normalize_base_url(fv("base_url")), ac_id = fv("ac_id"), ssid = fv("ssid"), bssid = fv("bssid"), radio = fv("radio"), ap_selection = fv("ap_selection"), n = fv("n"), type = fv("type"), enc = fv("enc"), info_prefix = fv("info_prefix"), double_stack = fv("double_stack"), login_os = fv("login_os"), login_name = fv("login_name"), } end -- 字段级校验只有 Go 一处:这里把它的中文说明原样交给弹窗。 local function config_write(method, params, success_message) local result, err = rpc.call_started(method, params) if not result then return false, rpc.message(err, "保存失败") end return true, success_message end function action_enqueue() local dispatcher = require "luci.dispatcher" if not dispatcher.test_post_security() then return end local action = fv("action") if action == "force_stop" then local ok_force, message_force = handle_force_stop() write_json_response({ ok = ok_force, message = message_force, action = action, ts = os.time() }) return end if DAEMON_ACTIONS[action] then submit_daemon_action(action) return end local setup_job = fv("setup_job") local setup_session if setup_job ~= "" then setup_session = dispatcher.context.authsession if action ~= "add_campus" or type(setup_session) ~= "string" or setup_session == "" then write_json_response({ ok = false, message = "无线向导会话或保存操作无效", action = action }) return end end local revision = bridge.config_revision(http.formvalue("expected_revision")) if revision == nil then write_json_response({ ok = false, message = "配置版本无效,请刷新页面后再保存", action = action }) return end local ok, message = false, "不支持的动作" local id = fv("id") if action == "add_campus" or action == "edit_campus" then local creating = action == "add_campus" local patch = bridge.campus_patch(campus_form(), { creating = creating }) if patch.label == "" then patch.label = bridge.default_label(patch.user_id, patch.operator_suffix, "未命名账号") end if not creating and tostring(patch.id or "") == "" then ok, message = false, "未找到 ID: " .. id else local params = { expected_revision = revision, account = patch } local method = "campus.upsert" if setup_job ~= "" then method = "setup_wifi.account" params.job, params.session = setup_job, setup_session end ok, message = config_write(method, params, creating and "已添加" or "已更新") end elseif action == "add_hotspot" or action == "edit_hotspot" then local creating = action == "add_hotspot" local patch = bridge.hotspot_patch({ id = id, label = fv("label"), ssid = fv("ssid"), encryption = fv("encryption"), key = fv("key"), radio = fv("radio"), }, { creating = creating }) if patch.label == "" then patch.label = patch.ssid ~= "" and patch.ssid or "未命名热点" end if not creating and tostring(patch.id or "") == "" then ok, message = false, "未找到 ID: " .. id else ok, message = config_write("hotspot.upsert", { expected_revision = revision, profile = patch }, creating and "已添加" or "已更新") end elseif action == "delete_campus" or action == "delete_hotspot" then local method = action == "delete_campus" and "campus.remove" or "hotspot.remove" ok, message = config_write(method, { expected_revision = revision, id = id }, "已删除") elseif action == "set_default_campus" or action == "set_default_hotspot" then local method = action == "set_default_campus" and "campus.set_default" or "hotspot.set_default" local done = action == "set_default_campus" and "已设为当前默认账号" or "已设为当前默认热点" ok, message = config_write(method, { expected_revision = revision, id = id }, done) end write_json_response({ ok = ok, message = message, action = action, ts = os.time() }) end -- Structured log translation table (event -> Chinese) local event_zh = { login_success = "登录成功", login_failed = "登录失败", retry_scheduled = "即将重试", retry_interrupted = "重试被中断", retry_success = "重试成功", retry_failed = "重试失败", retry_stopped = "停止重试", disconnect_detected = "检测到断线", status_check_error = "状态检测异常", online_account_mismatch = "在线账号与配置不符", multi_wan_session = "有线账号认证状态", logout_request = "正在登出", logout_success = "登出成功", logout_failed = "登出失败", logout_verify_failed = "登出校验失败", manual_login_start = "开始手动登录", manual_login_success = "手动登录成功", manual_login_failed = "手动登录失败", manual_preclean = "登录预清理", manual_preclean_done = "预清理完成", action_result = "操作结果", action_started = "开始执行动作", action_unknown = "未知操作", action_requeued = "动作重新排队", action_interrupted = "动作已中断", switch_progress = "切换进度", ap_selection = "AP 选择", ap_association = "AP 关联", quiet_enter = "进入夜间停用", quiet_exit = "退出夜间停用", daemon_tick = "状态更新", daemon_start = "守护进程启动", daemon_stop = "守护进程停止", switch_campus_done = "已切换到校园网", switch_campus_no_ip = "校园网切换未获取IP", switch_hotspot_done = "已切换到热点", switch_hotspot_no_ip = "热点未获取IP", hotspot_failback = "热点回退", switch_guard_restored = "已恢复自动守护", stale_session_rebuild = "清理残留会话并重建连接", config_migrated = "配置已迁移", config_legacy_fix = "修复遗留状态", config_default_applied = "应用默认配置", config_action_queued = "操作已入队", config_action_consumed = "操作已出队", config_loaded = "配置已加载", config_applied = "配置已保存", -- Go 事件目录新增的条目;每个都必须在这里有中文,见 tests/test_log_event_translations.py action_phase = "动作进度", maintain_queued = "已排队自动认证", pause_changed = "自动认证暂停状态", quiet_logout_queued = "静默时段排队下线", line_conflict = "线路冲突", internal_error = "内部错误", log_cleared = "日志已清空", detect_probe = "探测认证页面", status_query = "状态查询", update_status = "更新状态", presets_refresh = "学校预设刷新", -- 重试与生命周期 retry_cycle_start = "进入重试循环", retry_cycle_end = "重试循环结束", tick_begin = "守护进程心跳", -- 网络底层 bind_ip_resolved = "绑定 IP 已解析", http_bind_device = "认证连接已绑定到设备", http_fetch = "发起 HTTP 请求", http_fetch_result = "HTTP 请求结果", connectivity_probe_begin = "开始连通性探测", connectivity_probe_result = "连通性探测结果", dns_probe_failed = "DNS 解析失败", -- 无线模块 wifi_reload = "重载无线配置", sta_section_disabled = "已禁用 STA 配置段", ip_wait_progress = "等待 IPv4 中", ip_wait_result = "IPv4 等待结果", dhcp_kick = "重新触发 DHCP", sta_iface_up = "拉起 STA 网络接口", runtime_mode_detect = "检测运行模式", profile_rebuild = "重建无线配置", uci_wireless_update = "更新 UCI 无线配置", switch_evaluate = "评估切换决策", -- SRun 认证 srun_challenge = "请求 SRun 挑战码", srun_challenge_result = "SRun 挑战码结果", srun_login_submit = "提交 SRun 登录", srun_login_response = "SRun 登录响应", srun_logout_response = "SRun 登出响应", srun_online_query = "查询 SRun 在线状态", srun_online_result = "SRun 在线状态结果", -- 学校运行时 runtime_resolved = "学校运行时已加载", runtime_hook = "学校运行时钩子", runtime_dispatch = "学校运行时派发", } -- Error reason translation for raw structured log codes. local reason_zh = { username_or_password_error = "用户名或密码错误", ip_already_online_error = "IP已在线", challenge_expire_error = "挑战码已过期", sign_error = "签名错误", radius_error = "RADIUS认证失败", login_error = "认证失败", no_response_data_error = "网关未返回认证数据,请核对认证地址、AC_ID 和账号参数", not_online_error = "网关显示当前 IP 未在线", portal_intercept_error = "认证接口返回了网页,请检查认证地址或尝试网页登录", auth_html_response_error = "认证接口返回了网页,请检查认证地址或尝试网页登录", auth_response_parse_error = "认证接口返回的数据格式异常", } -- Parse structured log line: "[ts] LEVEL EVENT k=v ... | msg" local function parse_structured(line) local ts, rest = line:match("^(%[.-%]) (.+)$") if not rest then return nil end local level, event = rest:match("^(%u+) ([%w_]+)") if not level or not event then return nil end return ts, level, event, rest end -- Extract a key=value pair from structured log rest string. -- Supports both unquoted (key=val) and quoted (key="val with spaces"). local function extract_kv(rest, key) local quoted = rest:match(key .. '="(.-)"') if quoted then return quoted end return rest:match(key .. "=(%S+)") end local function extract_structured_suffix(rest, level, event) local prefix = tostring(level or "") .. " " .. tostring(event or "") local text = tostring(rest or "") if text:sub(1, #prefix) ~= prefix then return "" end return util.trim(text:sub(#prefix + 1)) end local function parse_quoted_log_value(text, idx) local out = {} idx = idx + 1 while idx <= #text do local ch = text:sub(idx, idx) if ch == "\\" and idx < #text then local next_ch = text:sub(idx + 1, idx + 1) if next_ch == '"' or next_ch == "\\" then out[#out + 1] = next_ch elseif next_ch == "n" or next_ch == "r" or next_ch == "t" then out[#out + 1] = "\\" .. next_ch else out[#out + 1] = next_ch end idx = idx + 2 elseif ch == '"' then return table.concat(out), idx + 1 else out[#out + 1] = ch idx = idx + 1 end end return table.concat(out), idx end local function parse_structured_fields(rest, level, event) local text = extract_structured_suffix(rest, level, event) local pipe_pos = text:find("%s|%s*") if pipe_pos then text = text:sub(1, pipe_pos - 1) end local fields = {} local by_key = {} local idx = 1 while idx <= #text do while idx <= #text and text:sub(idx, idx):match("%s") do idx = idx + 1 end if idx > #text or text:sub(idx, idx) == "|" then break end local remaining = text:sub(idx) local key = remaining:match("^([%w_]+)=") if not key then local next_space = text:find("%s+", idx) if not next_space then break end idx = next_space + 1 else idx = idx + #key + 1 local value = "" if text:sub(idx, idx) == '"' then value, idx = parse_quoted_log_value(text, idx) else value = text:match("^(%S+)", idx) or "" idx = idx + #value end fields[#fields + 1] = { key = key, value = value } if by_key[key] == nil then by_key[key] = value end end end return fields, by_key end local friendly_field_order = { "action", "result", "ok", "duration_ms", "queue_lag_ms", "username", "expected_username", "username_reported", "ip", "resolved_ip", "bind_ip", "host", "url", "method", "status_code", "outcome", "bytes_received", "errors", "error", "error_code", "attempts", "delay", "failures", "target", "ssid", "radio", "band", "portal", "section", "network", "iface", "source", "runtime_type", "hook", "school", "mode", "pid", "interval", "log_level", } local friendly_field_labels = { action = "动作", result = "结果", ok = "成功", duration_ms = "耗时", queue_lag_ms = "排队", username = "账号", expected_username = "期望账号", username_reported = "在线账号", ip = "IP", resolved_ip = "客户端IP", bind_ip = "绑定IP", host = "主机", url = "URL", method = "方法", status_code = "状态码", outcome = "结果", bytes_received = "字节", errors = "错误数", error = "错误", error_code = "错误码", attempts = "次数", delay = "延迟", failures = "失败数", target = "目标", ssid = "SSID", radio = "频段", band = "频段", portal = "认证页", section = "配置段", network = "网络", iface = "接口", source = "来源", runtime_type = "运行时", hook = "钩子", account_id = "账号ID", wired_iface = "有线接口", bind_device = "绑定设备", response_type = "响应类型", strict = "严格绑定", school = "学校", mode = "模式", pid = "PID", interval = "间隔", log_level = "日志等级", } local hidden_friendly_fields = { op_id = true, cycle_id = true, } local sensitive_friendly_key_parts = { "password", "passwd", "secret", "token", "chksum", "checksum", "hmd5", "credential", "authorization", "cookie", "session", "private_key", "campus_key", "hotspot_key", "wifi_key", "psk", } local function is_hidden_friendly_field(key) local name = tostring(key or ""):lower() if hidden_friendly_fields[name] then return true end for _, part in ipairs(sensitive_friendly_key_parts) do if name:find(part, 1, true) then return true end end return false end local function friendly_field_value(key, value) local text = tostring(value or "") if key == "reason" or key == "error_code" then text = reason_zh[text] or text elseif text == "True" or text == "true" then text = "是" elseif text == "False" or text == "false" then text = "否" end if key == "duration_ms" or key == "queue_lag_ms" then text = text .. "ms" end return text end local function append_friendly_fields(parts, field_list, field_map, skipped) local rendered = {} local used = {} skipped = skipped or {} local function add_field(key, value) if used[key] or skipped[key] or is_hidden_friendly_field(key) then return end if value == nil or tostring(value) == "" then return end used[key] = true rendered[#rendered + 1] = (friendly_field_labels[key] or key) .. "=" .. friendly_field_value(key, value) end for _, key in ipairs(friendly_field_order) do add_field(key, field_map[key]) end for _, item in ipairs(field_list) do add_field(item.key, item.value) end if #rendered > 0 then parts[#parts + 1] = "(" .. table.concat(rendered, ",") .. ")" end end local function append_message_detail(parts, rest, has_prior_detail) local detail = rest:match("|%s*(.+)$") if detail and detail ~= "" then parts[#parts + 1] = (has_prior_detail and " · " or ": ") .. detail end end local function skip_fields(...) local skipped = {} for i = 1, select("#", ...) do skipped[tostring(select(i, ...))] = true end return skipped end local switch_stage_zh = { applying = "应用无线配置", wait_ip = "等待获取 IPv4", probe = "检测连通性", } -- Translate a structured log line to user-friendly Chinese function friendly_line(line) local ts, level, event, rest = parse_structured(line) if not ts then return line end local zh = event_zh[event] local parts = { ts, " " } if level == "ERROR" then parts[#parts + 1] = "[错误] " elseif level == "WARN" then parts[#parts + 1] = "[警告] " elseif level == "DEBUG" then parts[#parts + 1] = "[调试] " else parts[#parts + 1] = "[信息] " end parts[#parts + 1] = zh or event if not zh then local suffix = extract_structured_suffix(rest, level, event) if suffix ~= "" then parts[#parts + 1] = " " .. suffix end return table.concat(parts) end local field_list, field_map = parse_structured_fields(rest, level, event) if event == "switch_progress" then local stage = field_map.stage or extract_kv(rest, "stage") local stage_zh = stage and switch_stage_zh[stage] if stage_zh then parts[#parts + 1] = " · " .. stage_zh end local target = field_map.target or extract_kv(rest, "target") if target then parts[#parts + 1] = "(" .. target .. ")" end append_message_detail(parts, rest, false) append_friendly_fields(parts, field_list, field_map, skip_fields("stage", "target")) return table.concat(parts) end if event == "action_started" then local action = field_map.action or extract_kv(rest, "action") if action then parts[#parts + 1] = ":" .. action end append_message_detail(parts, rest, false) append_friendly_fields(parts, field_list, field_map, skip_fields("action")) return table.concat(parts) end local account = field_map.account or extract_kv(rest, "account") if account then parts[#parts + 1] = " [" .. account .. "]" end local reason = field_map.reason or extract_kv(rest, "reason") local has_detail = false if reason then local rzh = reason_zh[reason] parts[#parts + 1] = ": " .. (rzh or reason) has_detail = true end local attempt = field_map.attempt or rest:match("attempt=(%d+)") if attempt then parts[#parts + 1] = " (第" .. attempt .. "次)" end append_message_detail(parts, rest, has_detail) append_friendly_fields(parts, field_list, field_map, skip_fields("account", "reason", "attempt")) return table.concat(parts) end function friendly_log_text(text) if not text or text == "" then return text or "" end local translated = {} for line in tostring(text):gmatch("[^\n]+") do translated[#translated + 1] = friendly_line(line) end return table.concat(translated, "\n") end local function structured_unix_ts(line) local y, m, d, hh, mm, ss = line:match("^%[(%d+)%-(%d+)%-(%d+) (%d+):(%d+):(%d+)%]") if not y then return nil end return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = tonumber(hh), min = tonumber(mm), sec = tonumber(ss), }) end local function tail_text(text, lines) lines = tonumber(lines) or 0 if lines <= 0 or text == "" then return text end local entries = {} for line in text:gmatch("[^\n]+") do entries[#entries + 1] = line end if #entries <= lines then return table.concat(entries, "\n") end local kept = {} for idx = #entries - lines + 1, #entries do kept[#kept + 1] = entries[idx] end return table.concat(kept, "\n") end -- 插件日志由守护进程持有(/tmp 下有界的结构化事件日志),这里只读取。 -- 解析和中文渲染仍在下面的 friendly_* 里:事件码由 Go 的目录定义, -- 文案留在页面这一层。 local function read_plugin_log_text(lines, since) local page = rpc.call("log.tail", { lines = tonumber(lines) or 0, since = tonumber(since) or 0, }) if type(page) ~= "table" or type(page.lines) ~= "table" then return "" end return table.concat(page.lines, "\n") end local function read_plugin_full_log_text() local result = rpc.call("log.download", nil) if type(result) ~= "table" then return "" end return tostring(result.text or "") end local function filter_text_since(text, since) if since <= 0 or text == "" then return text end local kept = {} for line in text:gmatch("[^\n]+") do local ts = structured_unix_ts(line) if ts and ts >= since then kept[#kept + 1] = line end end return table.concat(kept, "\n") end local function read_system_log_text(lines) lines = tonumber(lines) or LOG_TAIL_SOURCE_LINES local ok, text = pcall(sys.exec, "logread -l " .. lines .. " 2>/dev/null") if ok and text and text ~= "" then return text end ok, text = pcall(sys.exec, "logread 2>/dev/null") if ok and text then return tail_text(text, lines) end return "" end local function parse_syslog_timestamp(line) local now = os.date("*t") local month_name, day, hh, mm, ss, year = line:match( "^%a+ (%a+) +(%d+) (%d+):(%d+):(%d+) (%d%d%d%d)%s+" ) if not month_name then month_name, day, hh, mm, ss = line:match( "^%a+ (%a+) +(%d+) (%d+):(%d+):(%d+)%s+" ) year = tostring(now.year) end if not month_name then return os.time(), true end local month = MONTH_MAP[month_name] if not month then return os.time(), true end local ts = os.time({ year = tonumber(year), month = month, day = tonumber(day), hour = tonumber(hh), min = tonumber(mm), sec = tonumber(ss), }) if not ts then return os.time(), true end return ts, false end local function parse_syslog_payload(line) local facility, severity, raw_tag, message = line:match( "^.- ([%w_%-]+)%.([%w_%-]+) ([^:]+):%s*(.*)$" ) if not facility or not severity or not raw_tag then return nil end local tag = tostring(raw_tag):match("^([%w_%-]+)") if not tag or not SYSLOG_TAGS[tag] then return nil end return facility, severity, tag, message or "" end local function syslog_matches_context(line, state) local line_lower = tostring(line or ""):lower() local matched_context = false local values = { util.trim(tostring((state or {}).current_ssid or "")), util.trim(tostring((state or {}).current_bssid or "")), util.trim(tostring((state or {}).current_iface or "")), } for _, value in ipairs(values) do if value ~= "" then matched_context = true if line_lower:find(value:lower(), 1, true) then return true end end end if matched_context then return false end for _, keyword in ipairs(SYSLOG_KEYWORDS) do if line_lower:find(keyword, 1, true) then return true end end return false end local function extract_syslog_iface(message, state) local iface = tostring(message or ""):match("^([%w_.%-]+):") if iface and iface ~= "" then return iface end iface = tostring(message or ""):match("Interface '([%w_.%-]+)'") if iface and iface ~= "" then return iface end local current_iface = util.trim(tostring((state or {}).current_iface or "")) if current_iface ~= "" and tostring(message or ""):find(current_iface, 1, true) then return current_iface end return nil end local function build_system_log_entry(line, state, order) local _, severity, tag, message = parse_syslog_payload(line) if not tag or not syslog_matches_context(line, state) then return nil end local ts, unparsed = parse_syslog_timestamp(line) local parts = { "[", os.date("%Y-%m-%d %H:%M:%S", ts), "] ", SYSLOG_LEVELS[tostring(severity or ""):lower()] or "INFO", " syslog_", tag, } local iface = extract_syslog_iface(message, state) if iface and iface ~= "" then parts[#parts + 1] = " iface=" .. iface end parts[#parts + 1] = " source=system" if unparsed then parts[#parts + 1] = " unparsed=1" end parts[#parts + 1] = " | " .. tostring(message or "") return { line = table.concat(parts), ts = ts, source_priority = 1, order = order, } end local function trim_entries(entries, lines) if #entries <= lines then return entries end local trimmed = {} for idx = #entries - lines + 1, #entries do trimmed[#trimmed + 1] = entries[idx] end return trimmed end local function resolve_network_source_lines(lines, download_mode) if download_mode then return LOG_TAIL_SOURCE_LINES end local requested = tonumber(lines) or 0 if requested < 10 then requested = 10 end local source_lines = requested * 4 if source_lines < 200 then source_lines = 200 elseif source_lines > LOG_TAIL_SOURCE_LINES then source_lines = LOG_TAIL_SOURCE_LINES end return source_lines end local function build_network_log_text(lines, since, source_lines) local entries = {} local order_counter = 0 local plugin_text = read_plugin_log_text(source_lines) for line in plugin_text:gmatch("[^\n]+") do local _, _, event = parse_structured(line) if event and NETWORK_EVENTS[event] then order_counter = order_counter + 1 entries[#entries + 1] = { line = line, ts = structured_unix_ts(line) or os.time(), source_priority = 0, order = order_counter, } end end -- 系统日志按当前线路的上下文过滤;上下文来自守护进程快照, -- 读不到(服务停止)就退回关键词匹配,不再读旧的运行态文件。 local state = bridge.status() or {} local system_text = read_system_log_text(source_lines) for line in system_text:gmatch("[^\n]+") do order_counter = order_counter + 1 local entry = build_system_log_entry(line, state, order_counter) if entry then entries[#entries + 1] = entry end end table.sort(entries, function(left, right) if left.ts ~= right.ts then return left.ts < right.ts end if left.source_priority ~= right.source_priority then return left.source_priority < right.source_priority end return (left.order or 0) < (right.order or 0) end) if since > 0 then local kept = {} for _, entry in ipairs(entries) do if (entry.ts or 0) >= since then kept[#kept + 1] = entry end end entries = kept end entries = trim_entries(entries, lines) local output = {} for _, entry in ipairs(entries) do output[#output + 1] = entry.line end return table.concat(output, "\n") end local function build_log_text(channel, lines, since, download_mode) if download_mode then if channel == "network" then return build_network_log_text( LOG_TAIL_SOURCE_LINES, since, resolve_network_source_lines(lines, download_mode) ) end return read_plugin_full_log_text() end if channel == "network" then return build_network_log_text( lines, since, resolve_network_source_lines(lines, download_mode) ) end -- since is applied by the daemon, which holds the records with their own -- timestamps; the local filter stays for the network channel, where the -- system log's lines are merged in here. return read_plugin_log_text(lines, since) end function action_log_tail() local since = tonumber(http.formvalue("since")) or 0 local lines = tonumber(http.formvalue("lines")) or 1000 local fmt = http.formvalue("format") or "raw" local channel = http.formvalue("channel") or "plugin" local download_mode = tostring(http.formvalue("download") or "") == "1" channel = channel == "network" and "network" or "plugin" if not download_mode then if lines < 10 then lines = 10 elseif lines > 1000 then lines = 1000 end end local text = build_log_text(channel, lines, since, download_mode) if fmt == "friendly" and text ~= "" then text = friendly_log_text(text) end local empty = (text == "") if empty then text = "No logs yet." end http.prepare_content("application/json") http.write(jsonc.stringify({ log = text, empty = empty, channel = channel, ts = os.time(), })) end function action_log_clear() local dispatcher = require "luci.dispatcher" if not dispatcher.test_post_security() then return end local channel = http.formvalue("channel") or "plugin" if channel ~= "plugin" then write_json_response({ ok = false, message = "系统网络日志不能由插件清空", channel = channel }) return end -- 只清本项目的日志。系统全局日志不属于这里,守护进程也不会去动它。 local result, err = rpc.call_started("log.clear", nil) write_json_response({ ok = result and true or false, message = result and "日志已清空" or rpc.message(err, "清空日志失败"), channel = "plugin", ts = os.time(), }) end