-- The page's half of the local control protocol. -- -- Spec 02 sends every LuCI business request to the Go daemon over its Unix -- socket, and spec 03 fixes the framing: one UTF-8 JSON object per line, one -- request and one response per connection. This file is the only place in the -- interface that knows any of that, so the controller and the CBI page never -- assemble a frame, a deadline or an error envelope themselves. -- -- Nothing here builds a shell command out of user input. The single external -- program this module may run is the fixed lifecycle helper, with a fixed -- argument list and no data from the request in it. local nixio = require "nixio" -- writeall and readall are not nixio methods: nixio.util adds them to the -- socket metatable. This module used to rely on something else having loaded -- it -- the controller and CBI page pull in nixio.fs, which does -- so it -- worked in the page and failed with "attempt to call method 'writeall'" from -- any caller that did not happen to load nixio.fs first. require "nixio.util" local jsonc = require "luci.jsonc" local M = {} M.SOCKET_PATH = "/var/run/smart-srun/control.sock" M.HELPER = "/usr/bin/srunnet" M.RPC_VERSION = 1 -- A response is bounded by the same limit the daemon writes under, so a peer -- that never sends a newline cannot make uhttpd buffer without end. local MAX_RESPONSE_BYTES = 1024 * 1024 local READ_CHUNK = 8192 -- Seconds. Well inside uhttpd's script timeout: a page that hangs for a minute -- is indistinguishable from a broken one, and every method reachable from here -- answers from memory. local IO_TIMEOUT = 8 -- The helper waits up to five seconds for the socket and then gives up, so this -- only has to be longer than that, not open-ended. local HELPER_TIMEOUT = 12 -- Two values the protocol needs and a Lua table cannot hold: an explicit JSON -- null, which is how an account clears its double_stack override, and an empty -- JSON object, which an empty Lua table encodes as an array instead. -- -- They travel as strings built from a control character, because that is what -- luci.jsonc escapes as  -- a form field cannot contain one, so no value -- a user types can be mistaken for a sentinel, and a request that somehow did -- carry one would be refused by the daemon's strict decoder rather than saved -- as something else. M.NULL = "\1null\1" M.EMPTY_OBJECT = "\1{}\1" local SENTINELS = { { '"\\u0001null\\u0001"', "null" }, { '"\\u0001{}\\u0001"', "{}" }, } local request_counter = 0 local function fail(code, message) return nil, { code = code, message = message, retryable = false } end local function next_request_id() request_counter = request_counter + 1 return string.format("luci-%d-%d", nixio.getpid(), request_counter) end -- connect returns a socket already carrying its deadlines. -- -- The timeouts are set before the connect, not after, so a socket that exists -- but is never accepted cannot hold the request open either. local function connect() local sock = nixio.socket("unix", "stream") if not sock then return fail("Internal", "无法创建本地套接字") end -- Old nixio builds without SO_RCVTIMEO would raise here rather than return; -- pcall keeps that from turning into a 500 page, and the daemon answering -- from memory is what keeps the remaining risk to a blocked read. pcall(function() sock:setopt("socket", "sndtimeo", IO_TIMEOUT) sock:setopt("socket", "rcvtimeo", IO_TIMEOUT) end) local ok = sock:connect(M.SOCKET_PATH) if not ok then sock:close() -- Not reaching the socket is the ordinary "the service is not running" -- case and gets the code the rest of the program uses for it, so the -- page can offer to start it instead of showing a transport fault. return nil, { code = "ServiceStopped", message = "认证服务未在运行", retryable = true, } end return sock end local function write_frame(sock, line) local payload = line .. "\n" local sent = sock:writeall(payload) if sent ~= #payload then return fail("ServiceStopped", "请求未能完整发送给认证服务") end return true end -- read_frame reads exactly one newline-terminated response. -- -- A short read is not a frame and neither is an unterminated one: the loop -- keeps reading until the newline arrives, and end-of-stream with bytes in hand -- is reported as a truncated response rather than parsed as a complete one. local function read_frame(sock) local parts, total = {}, 0 while true do local chunk = sock:read(READ_CHUNK) if chunk == nil or chunk == "" then if total == 0 then return fail("ServiceStopped", "认证服务没有返回响应") end return fail("ProtocolInvalid", "认证服务的响应在换行之前结束") end total = total + #chunk if total > MAX_RESPONSE_BYTES then return fail("ProtocolInvalid", "认证服务的响应超过大小上限") end parts[#parts + 1] = chunk local joined = table.concat(parts) local newline = joined:find("\n", 1, true) if newline then return joined:sub(1, newline - 1) end parts = { joined } end end -- call makes one request and returns its result. -- -- Two returns, never one: `result, nil` or `nil, err`. A caller that has to -- inspect a field of the result to find out whether the call failed eventually -- forgets to, which is how a failed save reports success. function M.call(method, params) local request = { rpc_version = M.RPC_VERSION, request_id = next_request_id(), method = method, } if params ~= nil then request.params = params end local line = jsonc.stringify(request) if type(line) ~= "string" then return fail("Internal", "无法编码请求参数") end for _, sentinel in ipairs(SENTINELS) do line = line:gsub(sentinel[1], sentinel[2]) end local sock, err = connect() if not sock then return nil, err end local ok, write_err = write_frame(sock, line) if not ok then sock:close() return nil, write_err end local response_line, read_err = read_frame(sock) sock:close() if not response_line then return nil, read_err end local response = jsonc.parse(response_line) if type(response) ~= "table" then return fail("ProtocolInvalid", "认证服务返回的不是合法 JSON") end if tonumber(response.rpc_version) ~= M.RPC_VERSION then return fail("ProtocolInvalid", "认证服务的协议版本与本页面不一致") end -- A mismatched id means the answer belongs to another call. One request per -- connection makes that impossible on a healthy socket, which is exactly -- why seeing it means the payload must not be trusted. A failure with no id -- at all is the documented exception: some refusals happen before the -- request has been read, so there is nothing to echo. if response.request_id ~= request.request_id and not (tostring(response.request_id or "") == "" and not response.ok) then return fail("ProtocolInvalid", "认证服务的响应与请求不匹配") end if not response.ok then local payload = type(response.error) == "table" and response.error or {} return nil, { code = tostring(payload.code or "Internal"), message = tostring(payload.message or "认证服务内部错误"), field = type(payload.details) == "table" and tostring(payload.details.field or "") or "", retryable = payload.retryable and true or false, } end -- Methods that answer with nothing come back as JSON null. An empty table -- keeps every caller's `result.x` lookup from erroring on a nil index. if type(response.result) ~= "table" then return {} end return response.result end function M.service_stopped(err) return type(err) == "table" and err.code == "ServiceStopped" end function M.message(err, fallback) if type(err) == "table" and tostring(err.message or "") ~= "" then return err.message end return fallback or "操作失败" end -- run_helper runs the fixed lifecycle helper with an argument list. -- -- fork and exec rather than a shell: spec 02 requires the page to invoke one -- fixed program with safe arguments, and there is no shell here to interpret an -- account name, a URL or a password even if a future caller tried to pass one. -- The arguments are checked against a whitelist anyway, because "no shell" and -- "no arbitrary subcommand" are two different guarantees. local ALLOWED_HELPER_ARGS = { ["service ensure-running"] = true, ["service stop"] = true, ["service status"] = true, } local function run_helper(argv) local joined = table.concat(argv, " ") local update_start = #argv == 4 and argv[1] == "update" and argv[2] == "run" and #argv[3] == 64 and argv[3]:match("^[0-9a-f]+$") and argv[4] == "--background" if not ALLOWED_HELPER_ARGS[joined] and not update_start then return fail("InvalidArgument", "不允许的服务命令") end local pid = nixio.fork() if not pid then return fail("Internal", "无法启动服务助手") end if pid == 0 then -- Child. Its output belongs in the log the daemon keeps, not in the -- JSON body this request is about to write. local null = nixio.open("/dev/null", "r+") if null then nixio.dup(null, nixio.stdin) nixio.dup(null, nixio.stdout) nixio.dup(null, nixio.stderr) end nixio.exec(M.HELPER, unpack(argv)) -- exec only returns when it failed. os.exit(127) end -- A helper that never exits must not hold the page open for longer than the -- helper's own budget, so the wait is polled rather than blocking. local waited = 0 while waited < HELPER_TIMEOUT * 10 do local done, _, status = nixio.waitpid(pid, "nohang") if done == pid then if status == 0 then return true end -- Not `return ok or fail(...)`: `or` would keep only the first of -- fail's two values and the caller would lose the message. return fail("ServiceStopped", "服务助手失败(退出码 " .. tostring(status) .. ")") end nixio.nanosleep(0, 100000000) waited = waited + 1 end return fail("DeadlineExceeded", "等待服务助手超时") end -- ensure_running starts the service for an explicit user action. -- -- Spec 02 allows exactly this: a deliberate save or manual action on a page -- whose service is stopped may start it and then submit its own request. It is -- not called from status polling, because a page left open in a tab must not -- keep restarting a service the user stopped. function M.ensure_running() return run_helper({ "service", "ensure-running" }) end function M.stop_service() return run_helper({ "service", "stop" }) end function M.start_update(plan_id) if type(plan_id) ~= "string" or #plan_id ~= 64 or not plan_id:match("^[0-9a-f]+$") then return fail("InvalidArgument", "更新计划无效,请重新检查更新") end return run_helper({ "update", "run", plan_id, "--background" }) end -- call_started is the mutating path: make sure the service is there, then make -- the call. Reads deliberately do not use it. function M.call_started(method, params) local ok, err = M.ensure_running() if not ok then return nil, err end return M.call(method, params) end return M