Files

945 lines
40 KiB
Lua

-- Translation between the frozen interface and the Go configuration contract.
--
-- The page keeps the baseline's flat, UCI-style field names; the daemon owns a
-- typed config v2. One of them has to convert, and it is this file, in one
-- place, using the destinations recorded in the M00 field mapping. Doing it in
-- the controller and the CBI page separately is how two halves of one form end
-- up disagreeing about what "quiet_hours_enabled" means.
--
-- Everything above `-- calls` is pure: tables in, tables out. That is
-- deliberate, so the mapping can be exercised without a socket or a daemon.
local rpc = require "luci.smart_srun.rpc"
local M = {}
function M.config_revision(value)
if type(value) == "string" then
if value ~= "0" and not value:match("^[1-9]%d*$") then return nil end
value = tonumber(value)
end
if type(value) ~= "number" or value < 0 or value > 9007199254740991
or value ~= math.floor(value) then return nil end
return value
end
-- Read the local catalogue completely. Rendering and task polling never fetch
-- remote data; presets.refresh is the separate bounded network operation.
function M.presets()
local schools, offset, revision = {}, 0, nil
for page_number = 1, 128 do
local page, err = rpc.call("presets.list", { include_inactive = false, offset = offset, limit = 100 })
if not page then return nil, err end
if revision and revision ~= page.revision then
return nil, { code = "Conflict", message = "预设读取期间发生编辑,请刷新页面" }
end
revision = page.revision
for _, group in ipairs({ page.public or {}, page.user or {} }) do
for _, item in ipairs(group) do schools[#schools + 1] = item end
end
if page.next_offset == nil then return schools end
local next_offset = tonumber(page.next_offset)
if not next_offset or next_offset <= offset then
return nil, { code = "ProtocolInvalid", message = "预设分页响应无效" }
end
offset = next_offset
end
return nil, { code = "ProtocolInvalid", message = "预设超过分页上限" }
end
-- Baseline scalar -> config v2 destination, from
-- .codex/go-loop/baseline/field-mapping.json. Keys the mapping records as
-- dropped (developer_mode, the three backoff factors, the two superseded
-- durations) are absent on purpose: they have no v2 destination, and inventing
-- one here would resurrect a knob the decisions removed.
local SCALARS = {
enabled = { path = { "enabled" }, kind = "bool" },
multi_wan_enabled = { path = { "multi_wan_enabled" }, kind = "bool" },
school = { path = { "school" }, kind = "string" },
-- The one string the user may legitimately clear: no STA interface named
-- means "let the runtime pick", which is a choice, not a missing value.
sta_iface = { path = { "sta_iface" }, kind = "string", empty_ok = true },
quiet_hours_enabled = { path = { "quiet", "enabled" }, kind = "bool" },
quiet_start = { path = { "quiet", "start" }, kind = "string" },
quiet_end = { path = { "quiet", "end" }, kind = "string" },
preset_auto_update_enabled = { path = { "preset_updates", "enabled" }, kind = "bool" },
preset_update_time = { path = { "preset_updates", "time" }, kind = "string" },
force_logout_in_quiet = { path = { "quiet", "force_logout" }, kind = "bool" },
failover_enabled = { path = { "failover", "enabled" }, kind = "bool" },
hotspot_failback_enabled = { path = { "failover", "hotspot_failback_enabled" }, kind = "bool" },
backoff_enable = { path = { "retry", "enabled" }, kind = "bool" },
backoff_max_retries = { path = { "retry", "max_retries" }, kind = "int" },
retry_cooldown_seconds = { path = { "retry", "initial_seconds" }, kind = "number" },
retry_max_cooldown_seconds = { path = { "retry", "max_seconds" }, kind = "number" },
interval = { path = { "checks", "interval_seconds" }, kind = "int" },
connectivity_check_mode = { path = { "checks", "mode" }, kind = "string" },
switch_ready_timeout_seconds = { path = { "checks", "switch_timeout_seconds" }, kind = "int" },
manual_terminal_check_max_attempts = { path = { "checks", "terminal_attempts" }, kind = "int" },
manual_terminal_check_interval_seconds = { path = { "checks", "terminal_interval_seconds" }, kind = "int" },
log_level = { path = { "log", "level" }, kind = "string" },
log_file_omit_info = { path = { "log", "file_omit_info" }, kind = "bool" },
n = { path = { "login_defaults", "n" }, kind = "string" },
type = { path = { "login_defaults", "type" }, kind = "string" },
enc = { path = { "login_defaults", "enc" }, kind = "string" },
}
M.SCALARS = SCALARS
local POINTERS = {
active_campus_id = "active_campus_id",
default_campus_id = "default_campus_id",
active_hotspot_id = "active_hotspot_id",
default_hotspot_id = "default_hotspot_id",
}
local function trim(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end
local function truthy(value)
local text = trim(value):lower()
return text == "1" or text == "true" or text == "yes" or text == "on"
end
local function read_path(root, path)
local node = root
for _, key in ipairs(path) do
if type(node) ~= "table" then
return nil
end
node = node[key]
end
return node
end
local function write_path(root, path, value)
local node = root
for index = 1, #path - 1 do
local key = path[index]
if type(node[key]) ~= "table" then
node[key] = {}
end
node = node[key]
end
node[path[#path]] = value
end
-- to_display renders a typed v2 value in the string form the frozen page reads.
local function to_display(value, kind)
if kind == "bool" then
return value and "1" or "0"
end
if value == nil then
return ""
end
-- Lua 5.1 prints an integral double without a fractional part, so a whole
-- number of seconds reaches the form as "60" rather than "60.0".
return tostring(value)
end
-- to_typed is the other direction, and refuses rather than guesses: a number
-- field whose text is not a number keeps its stored value instead of silently
-- becoming zero, which the daemon would accept as a valid setting.
local function to_typed(text, kind)
if kind == "bool" then
return truthy(text)
end
if kind == "int" then
local number = tonumber(trim(text))
if not number then return nil end
return math.floor(number)
end
if kind == "number" then
return tonumber(trim(text))
end
return tostring(text or "")
end
-- account_view renders one v2 account as the baseline item the table, the edit
-- dialog and the wizard all read.
--
-- Secrets are not part of it. config.get redacts them, and the page has no use
-- for a credential it never displays: the edit dialog submits an empty password
-- field, and `campus_patch` reads that as "keep the stored one".
local function account_view(account)
local login = type(account.login) == "table" and account.login or {}
local double_stack = ""
if login.double_stack ~= nil then
double_stack = login.double_stack and "1" or "0"
end
return {
id = tostring(account.id or ""),
label = tostring(account.label or ""),
user_id = tostring(account.user_id or ""),
password = "",
operator = tostring(account.operator or ""),
operator_suffix = tostring(account.operator_suffix or ""),
access_mode = tostring(account.access_mode or "wired"),
wired_iface = tostring(account.wired_iface or ""),
auth_enabled = account.auth_enabled and "1" or "0",
base_url = tostring(account.base_url or ""),
ac_id = tostring(account.ac_id or ""),
ssid = tostring(account.ssid or ""),
radio = tostring(account.radio or ""),
encryption = tostring(account.encryption or ""),
key = "",
ap_selection = tostring(account.ap_selection or ""),
bssid = tostring(account.bssid or ""),
n = tostring(login.n or ""),
type = tostring(login.type or ""),
enc = tostring(login.enc or ""),
info_prefix = tostring(login.info_prefix or ""),
double_stack = double_stack,
login_os = tostring(login.os or ""),
login_name = tostring(login.name or ""),
preset_id = tostring(account.preset_id or ""),
}
end
local function hotspot_view(hotspot)
return {
id = tostring(hotspot.id or ""),
label = tostring(hotspot.label or ""),
ssid = tostring(hotspot.ssid or ""),
encryption = tostring(hotspot.encryption or ""),
key = "",
radio = tostring(hotspot.radio or ""),
}
end
-- flatten turns one config.get result into the baseline-shaped table the page
-- has always worked with.
function M.flatten(config)
local flat = {}
for key, spec in pairs(SCALARS) do
flat[key] = to_display(read_path(config, spec.path), spec.kind)
end
local selection = type(config.selection) == "table" and config.selection or {}
for legacy, field in pairs(POINTERS) do
flat[legacy] = tostring(selection[field] or "")
end
flat.campus_accounts = {}
for _, account in ipairs(config.campus_accounts or {}) do
flat.campus_accounts[#flat.campus_accounts + 1] = account_view(account)
end
flat.hotspot_profiles = {}
for _, hotspot in ipairs(config.hotspot_profiles or {}) do
flat.hotspot_profiles[#flat.hotspot_profiles + 1] = hotspot_view(hotspot)
end
flat.school_extra = type(config.school_extra) == "table" and config.school_extra or {}
flat.revision = M.config_revision(config.revision)
return flat
end
-- settings_patch builds the config.apply body for the fields a save touched.
--
-- Only the dirty ones. A settings form that sent every field would overwrite
-- values another page, the CLI or the wizard changed while this one was open,
-- and it would do it without the user having touched the control.
function M.settings_patch(flat, dirty)
local patch = {}
local touched = false
for key in pairs(dirty or {}) do
local spec = SCALARS[key]
if spec then
local value = to_typed(flat[key], spec.kind)
-- A field the form did not submit arrives here as an empty string,
-- because that is how the CBI reports "removed". For everything
-- except sta_iface an empty value is not a setting the daemon can
-- hold -- a clock with no time, an enum with no choice -- and
-- sending it would fail the whole save, including the fields the
-- user did change. Absent stays absent instead.
if value == "" and not spec.empty_ok then
value = nil
end
if value ~= nil then
write_path(patch, spec.path, value)
touched = true
end
end
end
if dirty and dirty.school_extra then
local extra = type(flat.school_extra) == "table" and flat.school_extra or {}
-- An empty Lua table encodes as a JSON array, which is not a map. The
-- sentinel survives encoding and becomes `{}` on the wire.
patch.school_extra = next(extra) == nil and rpc.EMPTY_OBJECT or extra
touched = true
end
if not touched then
return nil
end
return patch
end
-- campus_patch turns submitted form values into a v2 account patch.
--
-- Presence is the whole point: a field the dialog did not submit is left out,
-- so it keeps its stored value. The password is the case that matters. The
-- dialog cannot show it, so an empty box means "unchanged" on an edit, and only
-- a new account stores the empty string it was given.
function M.campus_patch(form, options)
options = options or {}
local creating = options.creating and true or false
local access_mode = trim(form.access_mode) == "wired" and "wired" or "wifi"
local patch = {
label = tostring(form.label or ""),
user_id = tostring(form.user_id or ""),
operator = tostring(form.operator or ""),
operator_suffix = tostring(form.operator_suffix or ""),
access_mode = access_mode,
base_url = tostring(form.base_url or ""),
ac_id = tostring(form.ac_id or ""),
login = {
n = tostring(form.n or ""),
type = tostring(form.type or ""),
enc = tostring(form.enc or ""),
info_prefix = tostring(form.info_prefix or ""),
os = tostring(form.login_os or ""),
name = tostring(form.login_name or ""),
},
}
if not creating then
patch.id = tostring(form.id or "")
end
local double_stack = trim(form.double_stack)
if double_stack == "" then
-- Explicit null, which the account patch accepts on this one path: it
-- clears the override so the strategy default applies again. Leaving the
-- field out would silently keep the previous override instead.
patch.login.double_stack = rpc.NULL
else
patch.login.double_stack = truthy(double_stack)
end
local password = tostring(form.password or "")
if creating or password ~= "" then
patch.password = password
end
if access_mode == "wired" then
local iface = trim(form.wired_iface)
if iface == "" then
-- PR #32 called this field network_interface. Read the alias, write
-- only wired_iface.
iface = trim(form.network_interface)
end
patch.wired_iface = iface ~= "" and iface or "wan"
patch.auth_enabled = truthy(form.auth_enabled)
-- The wireless half is cleared rather than left behind, so a stored
-- account never carries an effective setting for the mode it is not in.
patch.ssid, patch.bssid, patch.radio, patch.ap_selection = "", "", "", "auto"
else
patch.auth_enabled = false
patch.ssid = tostring(form.ssid or "")
patch.radio = tostring(form.radio or "")
patch.bssid = trim(form.bssid):lower()
patch.ap_selection = M.normalize_ap_selection(form.ap_selection, patch.bssid)
if options.encryption ~= nil then
patch.encryption = tostring(options.encryption)
end
if options.key ~= nil then
patch.key = tostring(options.key)
end
if creating and options.encryption == nil then
-- A new wireless account needs an encryption mode; the campus dialog
-- has never had that control, and the validator requires one.
patch.encryption = "none"
end
end
return patch
end
function M.hotspot_patch(form, options)
options = options or {}
local patch = {
label = tostring(form.label or ""),
ssid = tostring(form.ssid or ""),
encryption = tostring(form.encryption or ""),
radio = tostring(form.radio or ""),
}
if not options.creating then
patch.id = tostring(form.id or "")
end
local key = tostring(form.key or "")
if options.creating or key ~= "" then
patch.key = key
end
return patch
end
-- normalize_ap_selection is the baseline rule, kept here so the controller and
-- the CBI page share one copy: an unrecognised policy means "fixed" when a
-- BSSID was given and "auto" otherwise.
function M.normalize_ap_selection(value, bssid)
local policy = trim(value):lower()
if policy == "auto" or policy == "strongest" or policy == "fixed" then
return policy
end
return trim(bssid) ~= "" and "fixed" or "auto"
end
-- default_label reproduces the baseline's fallback naming.
function M.default_label(user_id, suffix, fallback)
local id, tail = trim(user_id), trim(suffix)
if id ~= "" and tail ~= "" then
return id .. "@" .. tail
end
if id ~= "" then
return id
end
return fallback
end
-- utc_seconds converts an RFC 3339 timestamp to a second count.
--
-- Its own civil-date arithmetic rather than os.time, which would interpret the
-- fields in the router's local zone. The absolute value is never displayed:
-- only differences between two of these are, and a wrong zone would make an
-- action look minutes old the moment it finished.
function M.utc_seconds(text)
local year, month, day, hour, minute, second =
tostring(text or ""):match("^(%d+)-(%d+)-(%d+)[Tt](%d+):(%d+):(%d+)")
if not year then
return nil
end
year, month, day = tonumber(year), tonumber(month), tonumber(day)
hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second)
-- Days from the civil date, shifting the year to start in March so the leap
-- day is the last day of it and no month-length table is needed.
local y = year - (month <= 2 and 1 or 0)
local era = math.floor(y / 400)
local year_of_era = y - era * 400
local day_of_year = math.floor((153 * (month + (month > 2 and -3 or 9)) + 2) / 5) + day - 1
local day_of_era = year_of_era * 365 + math.floor(year_of_era / 4)
- math.floor(year_of_era / 100) + day_of_year
local days = era * 146097 + day_of_era - 719468
return days * 86400 + hour * 3600 + minute * 60 + second
end
local AUTH_TEXT = {
Unknown = "状态未知",
Authenticating = "正在认证",
Accepted = "已提交认证",
VerifiedSelf = "已认证",
VerifiedOther = "线路上是其他账号",
Rejected = "认证被拒绝",
Offline = "未认证",
}
local LINK_TEXT = {
Missing = "接口不存在",
LinkDown = "链路未就绪",
AddressPending = "等待 IPv4 地址",
}
local CONNECTIVITY = {
InternetReachable = { text = "互联网可达", level = "online" },
PortalReachable = { text = "认证网关可达", level = "portal" },
Limited = { text = "已连接但受限", level = "limited" },
Offline = { text = "未连接", level = "offline" },
Unknown = { text = "未探测", level = "offline" },
}
local ACTION_RESULT = {
succeeded = "ok",
failed = "error",
-- A cancelled or interrupted action is what the frozen page calls "forced":
-- the force-stop button cancels, and a stopped service interrupts. Neither
-- is a failed attempt, and showing them as one would tell a user their
-- password was wrong when they pressed stop.
cancelled = "forced",
interrupted = "forced",
}
-- Only explicit login/logout/switch actions own the frozen progress dialog.
-- Maintenance, preset refresh and wizard jobs expose their results elsewhere.
local FEEDBACK_ACTION = {
manual_login = true, manual_logout = true, relogin = true,
switch_campus = true, switch_hotspot = true,
}
local function account_by_id(config, id)
for _, account in ipairs(config.campus_accounts or {}) do
if account.id == id then
return account
end
end
return nil
end
-- portal_origin reduces a configured gateway address to a bare HTTP(S)
-- origin, or "" when it is not one.
--
-- 1.6.1's rule, kept: scheme and host (IPv6 in brackets, or letters, digits,
-- dots and hyphens) with an optional port, nothing else. No userinfo, no path,
-- no whitespace, control characters or backslashes -- the last because browsers
-- disagree about what a backslash in an authority means.
local function portal_origin(value)
-- Schemes are case-insensitive; the answer is always written lower-case.
local scheme, rest = trim(value):match("^(%a+)://(.*)$")
scheme = scheme and scheme:lower()
if scheme ~= "http" and scheme ~= "https" then return "" end
local authority = rest:match("^([^/%?#]+)")
if not authority or authority:find("[%s%c\\@]") then return "" end
local host, port = authority:match("^(%[[%x:]+%])(.*)$")
if not host then host, port = authority:match("^([%w%.%-]+)(.*)$") end
if not host or not (port == "" or port:match("^:%d%d?%d?%d?%d?$")) then return "" end
return scheme:lower() .. "://" .. authority
end
-- failed_login_portal answers the page's "open the school's page" link.
--
-- Only after a failed manual login, as in 1.6.1, and only from the account that
-- action was for -- not whichever account is active by the time the page asks.
-- The address is the one the user configured. A gateway's response body never
-- supplies a URL here.
local function failed_login_portal(action, config)
if type(action) ~= "table" or tostring(action.kind or "") ~= "manual_login"
or tostring(action.state or "") ~= "failed" then
return ""
end
local account = account_by_id(config or {}, tostring(action.account_id or ""))
return account and portal_origin(account.base_url) or ""
end
M.portal_origin = portal_origin
local function view_by_id(snapshot, id)
for _, view in ipairs(snapshot.accounts or {}) do
if view.account_id == id then
return view
end
end
return nil
end
-- wired_session_view rebuilds the per-account badge input the account table
-- reads, from the projection the daemon publishes for every account.
local function wired_session_view(view)
if not view then
return { online = false, status = "" }
end
local auth = tostring(view.auth or "")
local status = ""
if auth == "VerifiedOther" then
status = "account_mismatch"
elseif auth == "Rejected" then
status = "error"
elseif tostring(view.link or "") == "Missing" then
status = "config_error"
end
return { online = auth == "VerifiedSelf", status = status }
end
-- mode_of decides campus or hotspot from what actually happened.
--
-- The last switch that succeeded, not the last one submitted: an attempt that
-- failed left the uplink where it was, and reporting the requested mode would
-- describe a network the device is not on.
local function mode_of(snapshot, account)
local wireless = type(snapshot.wireless) == "table" and snapshot.wireless or {}
if wireless.state == "associated" then
if tostring(wireless.hotspot_id or "") ~= "" then return "hotspot" end
if account and account.access_mode ~= "wired"
and wireless.radio == account.radio and wireless.ssid == account.ssid then
return "campus"
end
end
local mode = "campus"
for _, action in ipairs(snapshot.actions or {}) do
if action.state == "succeeded" and action.ended_at
and (action.kind == "switch_campus" or action.kind == "switch_hotspot") then
-- Retained terminals are ordered by retirement, including when
-- the router's wall clock moves backwards during a switch.
mode = action.kind == "switch_hotspot" and "hotspot" or "campus"
end
end
return mode
end
local function hotspot_status(wireless)
if wireless.state == "disconnected" then
return "热点未连接", CONNECTIVITY.Offline
elseif wireless.state == "unavailable" or wireless.state == "ambiguous" then
return "无法读取热点连接", CONNECTIVITY.Unknown
elseif wireless.state ~= "associated" then
return "正在读取热点状态", CONNECTIVITY.Unknown
elseif tostring(wireless.address or "") == "" then
return "热点等待 IP 地址", CONNECTIVITY.Unknown
end
local level = tostring(wireless.connectivity or "")
if level == "InternetReachable" then
return "热点已联网", CONNECTIVITY.InternetReachable
elseif level == "Limited" then
return "热点联网受限", CONNECTIVITY.Limited
elseif level == "Offline" then
return "热点联网检测失败", { text = "互联网探测未通过", level = "offline" }
end
return "热点已连接", { text = "等待联网检测", level = "offline" }
end
-- status_view is the one combined answer the page polls.
--
-- Connection details come from the daemon's bounded, passive wireless cache.
-- Configured SSIDs and BSSIDs are never used as proof of an association.
function M.status_view(snapshot, config, now)
now = now or os.time()
local running = tostring(snapshot.service or "") == "running"
local selection = type(config.selection) == "table" and config.selection or {}
local active_id = tostring(selection.active_campus_id or "")
local account = account_by_id(config, active_id)
local view = view_by_id(snapshot, active_id)
local wireless = type(snapshot.wireless) == "table" and snapshot.wireless or {}
local mode = mode_of(snapshot, account)
local wired = account ~= nil and tostring(account.access_mode or "") == "wired"
local mode_label = "未知模式"
local access_mode = ""
if mode == "hotspot" then
mode_label = "热点模式"
elseif account then
mode_label = wired and "校园网模式(有线)" or "校园网模式"
access_mode = wired and "wired" or "wifi"
end
local connectivity = CONNECTIVITY[tostring(view and view.connectivity or "")] or CONNECTIVITY.Unknown
local status = "未知"
if not running then
status = "认证服务已停止"
connectivity = CONNECTIVITY.Unknown
elseif mode == "hotspot" then
status, connectivity = hotspot_status(wireless)
elseif not account then
status = "尚未配置校园网账号"
elseif not view then
status = snapshot.enabled and "等待首次检测" or "自动认证已关闭"
else
status = LINK_TEXT[tostring(view.link or "")] or AUTH_TEXT[tostring(view.auth or "")] or "状态未知"
end
-- Keep background work visible in the overview without letting it replace
-- a manual result or hold that action's progress dialog open.
local pending, feedback_pending, last, last_ended = "", false, nil, nil
for _, action in ipairs(snapshot.actions or {}) do
local state = tostring(action.state or "")
local feedback = FEEDBACK_ACTION[tostring(action.kind or "")]
if state == "queued" or state == "running" then
if pending == "" then
pending = tostring(action.kind or "")
end
if feedback then feedback_pending = true end
elseif feedback and ACTION_RESULT[state] then
last, last_ended = action, M.utc_seconds(action.ended_at)
end
end
-- A new manual request clears the old terminal message until it completes.
if feedback_pending then last, last_ended = nil, nil end
local written = M.utc_seconds(snapshot.written_at)
local last_action_ts = 0
if last_ended and written then
-- Display only. Wall clocks can move backwards; the dialog uses the
-- receipt ID and never uses this timestamp as proof of completion.
last_action_ts = now - (written - last_ended)
end
local link_ready = view ~= nil and tostring(view.link or "") == "Ready"
local line = mode ~= "hotspot" and type(view) == "table" and type(view.line) == "table" and view.line or {}
-- Quiet hours suspend a service the user has switched on; the page has
-- always shown that separately from the switch itself.
local paused = {}
for _, reason in ipairs(snapshot.pause or {}) do
paused[tostring(reason)] = true
end
local payload = {
status = status,
enabled = snapshot.enabled and true or false,
service = running and "running" or "stopped",
mode = mode,
current_mode = mode,
mode_label = mode_label,
pending_action = pending,
current_campus_access_mode = access_mode,
campus_account_label = account and tostring(account.label or "") or "",
online_account_label = mode ~= "hotspot" and view and tostring(view.identity or "") or "",
campus_ssid = account and tostring(account.ssid or "") or "",
campus_bssid = account and tostring(account.bssid or "") or "",
current_ssid = "",
current_bssid = "",
current_wireless_ifname = "",
-- The line the last attempt actually reached the network on, not the
-- one the account is configured for.
current_iface = tostring(line.iface or ""),
current_ip = tostring(line.address or ""),
current_device = tostring(line.device or ""),
in_quiet = paused["QuietHours"] and true or false,
paused = next(paused) ~= nil,
ap_selection_policy = "",
ap_selection_reason = "",
connectivity = connectivity.text,
connectivity_level = connectivity.level,
hotspot_profile_label = "",
wired_auth_sessions = {},
last_action = last and tostring(last.kind or "") or "",
last_action_message = last and tostring(last.message or "") or "",
last_action_portal_url = failed_login_portal(last, config),
action_result = last and ACTION_RESULT[tostring(last.state or "")] or "",
last_action_ts = last_action_ts,
action_started_at = feedback_pending and now or 0,
config_revision = tonumber(snapshot.config_revision) or 0,
last_log = "",
updated_at = now,
ts = now,
}
if feedback_pending then
payload.action_result = "pending"
end
-- The observed line is preferred; the configured one only stands in while
-- the link is ready and nothing has reported a device yet, which is the
-- first moment after a save.
if mode ~= "hotspot" and payload.current_iface == "" and link_ready and account and wired then
payload.current_iface = tostring(account.wired_iface or "")
end
if running and (mode == "hotspot" or (account and not wired)) then
payload.current_wireless_ifname = tostring(wireless.device or "")
payload.ap_selection_policy = mode == "hotspot" and "auto"
or M.normalize_ap_selection(account.ap_selection, account.bssid)
local reasons = {
disconnected = "尚未连接无线网络",
unavailable = "暂时无法读取无线状态",
ambiguous = "检测到多个上联客户端,请检查无线配置",
}
payload.ap_selection_reason = reasons[wireless.state] or "等待无线状态检测"
if wireless.state == "associated" then
payload.current_ssid = tostring(wireless.ssid or "")
payload.current_bssid = tostring(wireless.bssid or "")
payload.current_iface = tostring(wireless.iface or "")
payload.current_device = tostring(wireless.device or "")
payload.current_ip = tostring(wireless.address or "")
local signal, channel = tonumber(wireless.signal), tonumber(wireless.channel)
payload.current_signal = signal and signal < 0 and signal >= -127 and signal or nil
payload.current_channel = channel and channel > 0 and channel or nil
if mode ~= "hotspot" and payload.current_ssid ~= tostring(account.ssid or "") then
payload.ap_selection_reason = "当前无线连接与所选校园网配置不同"
elseif payload.ap_selection_policy == "fixed" then
payload.ap_selection_reason = payload.current_bssid == tostring(account.bssid or ""):lower()
and "已连接指定接入点" or "当前接入点与固定 BSSID 不同"
elseif payload.ap_selection_policy == "strongest" then
payload.ap_selection_reason = "连接时优先信号较强的接入点,保持当前连接"
else
payload.ap_selection_reason = "由无线系统选择接入点"
end
end
end
local hotspot_id = tostring(selection.active_hotspot_id or "")
if mode == "hotspot" and tostring(wireless.hotspot_id or "") ~= "" then
hotspot_id = tostring(wireless.hotspot_id)
end
for _, hotspot in ipairs(config.hotspot_profiles or {}) do
if hotspot.id == hotspot_id then
payload.hotspot_profile_label = tostring(hotspot.label or "")
end
end
-- Both gates, as the daemon applies them: an account that opted in while
-- the global switch is off is not managed, and saying it is would show a
-- parallel-authentication badge for work nothing is doing.
for _, item in ipairs(config.multi_wan_enabled and config.campus_accounts or {}) do
if item.access_mode == "wired" and item.auth_enabled then
payload.wired_auth_sessions[tostring(item.id or "")] =
wired_session_view(view_by_id(snapshot, item.id))
end
end
return payload
end
-- offline_view is the same shape with nothing observed in it.
--
-- A stopped service is a state, not a page error: the overview must say so in
-- its own card rather than fall back to "状态读取失败", which is what the page
-- shows when it cannot reach LuCI at all.
function M.offline_view(message, now)
now = now or os.time()
return {
status = message or "认证服务未在运行",
enabled = false,
service = "stopped",
mode = "",
current_mode = "",
mode_label = "未知模式",
pending_action = "",
current_campus_access_mode = "",
campus_account_label = "",
online_account_label = "",
campus_ssid = "",
campus_bssid = "",
current_ssid = "",
current_bssid = "",
current_wireless_ifname = "",
current_iface = "",
current_ip = "",
current_device = "",
in_quiet = false,
paused = false,
ap_selection_policy = "",
ap_selection_reason = "",
connectivity = "未连接",
connectivity_level = "offline",
hotspot_profile_label = "",
wired_auth_sessions = {},
last_action = "",
last_action_message = "",
last_action_portal_url = "",
action_result = "",
last_action_ts = 0,
action_started_at = 0,
config_revision = 0,
last_log = "",
updated_at = now,
ts = now,
}
end
-- calls: everything below here talks to the daemon.
-- defaults answers the form's flat defaults from the daemon's own schema.
--
-- One source of defaults, as spec 02 requires: Go owns types, defaults, bounds
-- and choices, and the page keeps the labels. Deriving them here through the
-- same mapping the save path uses means a changed default cannot reach the
-- form as the old one, and a field nobody mapped cannot acquire a default that
-- nothing would ever apply.
-- school_extra_descriptors answers the selected strategy's private controls.
--
-- The page used to build these from parse_school_runtime_contract(""), a
-- literal empty string, so the list was always empty and every control below it
-- was unreachable whatever a strategy declared. The daemon publishes the
-- declarations through the same schema.get the defaults above come from; this
-- translates them into the shape the page's normaliser already expects.
--
-- Kind names differ on purpose: Go names them for the configuration contract
-- (number, select, multi), the page names them for its widgets (int, enum).
-- Translating here keeps both vocabularies honest. "multi" has no widget yet
-- and is passed through unmapped, which the page's supported-type filter drops.
local SCHOOL_EXTRA_KINDS = { string = "string", bool = "bool",
number = "int", select = "enum" }
function M.school_extra_descriptors()
local schema, err = rpc.call("schema.get", nil)
if not schema then
return nil, err
end
local out = {}
for _, field in ipairs(schema.school_extra or {}) do
if type(field) == "table" then
local kind = tostring(field.kind or "string")
local choices = {}
for _, choice in ipairs(field.choices or {}) do
if type(choice) == "table" and choice.value ~= nil then
choices[#choices + 1] = tostring(choice.value)
end
end
out[#out + 1] = {
key = tostring(field.key or ""),
type = SCHOOL_EXTRA_KINDS[kind] or kind,
label = tostring(field.label or ""),
description = tostring(field.help or ""),
choices = choices,
}
end
end
return out
end
function M.defaults()
local schema, err = rpc.call("schema.get", nil)
if not schema then
return nil, err
end
local by_path = {}
for legacy, spec in pairs(SCALARS) do
by_path[table.concat(spec.path, ".")] = { key = legacy, kind = spec.kind }
end
local out = {}
for _, field in ipairs(schema.global or {}) do
local target = by_path[tostring(field.path or "")]
-- A false or empty default is omitted on the wire. For a flag that is
-- unambiguous; for anything else an absent default is absent, and
-- inventing a zero would put a value in the form that nothing chose.
if target and (field.default ~= nil or target.kind == "bool") then
out[target.key] = to_display(field.default, target.kind)
end
end
return out
end
-- config reads the stored configuration in the page's own shape.
function M.config()
local result, err = rpc.call("config.get", nil)
if not result then
return nil, err
end
return M.flatten(result), nil, result
end
function M.snapshot()
return rpc.call("status.get", nil)
end
-- status answers the page's poll from two cached reads.
--
-- Two, because the projection names accounts by id and the labels live in the
-- configuration. Both are answered from memory: spec 03 forbids a status read
-- that authenticates or probes, and this path must stay cheap enough for a
-- browser tab to hold open.
function M.status()
local snapshot, err = rpc.call("status.get", nil)
if not snapshot then
return nil, err
end
local config, config_err = rpc.call("config.get", nil)
if not config then return nil, config_err end
return M.status_view(snapshot, config, os.time())
end
-- A dialog reads its own receipt, not the overview's latest manual action.
-- Do not start/replay work or expose discovery results through this channel.
function M.action_feedback(action_id)
local id = type(action_id) == "string" and action_id or ""
local payload = { action_id = id, action_result = "error", last_action = "",
last_action_message = "操作记录已失效,请查看当前状态", last_action_portal_url = "" }
if #id == 0 or #id > 64 or not id:match("^[%w_-]+$") then
payload.action_id = ""
payload.last_action_message = "操作编号无效"
return payload
end
-- session is a discovery owner guard, not a generic LuCI auth parameter.
-- Access to this controller already requires an authenticated LuCI user.
local action, err = rpc.call("action.get", { action_id = id })
if not action then
if err and err.code ~= "NotFound" then
payload.last_action_message = rpc.message(err, payload.last_action_message)
end
return payload
end
if action.id ~= id or not FEEDBACK_ACTION[action.kind] then return payload end
local result = ACTION_RESULT[action.state]
if action.state == "queued" or action.state == "running" then result = "pending" end
if not result then return payload end
payload.last_action = action.kind
payload.action_result = result
payload.last_action_message = tostring(action.message or "")
-- The configuration is read only for the one outcome that can use it, so
-- polling a running action costs no extra call.
if action.kind == "manual_login" and action.state == "failed" then
payload.last_action_portal_url = failed_login_portal(action, rpc.call("config.get", nil))
end
return payload
end
return M