Files
op-packages/luci-app-fastnet/luasrc/model/fastnet/card.lua
T

151 lines
8.0 KiB
Lua

-- The public authenticated adapter. No shell commands, core tokens or probes.
local M = {}
local SOCKET = "/var/run/fastnet-card.sock"
local STATE = "/etc/fastnet/card-state.json"
local MAX_BODY, MAX_RESPONSE = 1024, 8192
local function socket_request(method, path, body)
local nixio = require "nixio"
local socket = nixio.socket("unix", "stream")
if not socket then return nil, nil, "service_unavailable" end
local deadline = os.time() + 6
local function timeout()
local remaining = deadline - os.time()
if remaining <= 0 then return false end
socket:setopt("socket", "sndtimeo", remaining)
socket:setopt("socket", "rcvtimeo", remaining)
return true
end
local function fail(reason) socket:close(); return nil, nil, reason end
timeout()
if not socket:connect(SOCKET) then return fail("service_unavailable") end
body = body or ""
local request = method .. " /api/card/" .. path .. " HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: " .. #body .. "\r\n\r\n" .. body
local offset = 1
while offset <= #request do
if not timeout() then return fail("upstream_timeout") end
local sent = socket:send(request:sub(offset))
if not sent or sent <= 0 then return fail("upstream_timeout") end
offset = offset + sent
end
local chunks, length = {}, 0
while true do
if not timeout() then return fail("upstream_timeout") end
local chunk = socket:recv(2048)
if not chunk then return fail("upstream_timeout") end
if chunk == "" then break end
length = length + #chunk
if length > MAX_RESPONSE + 4096 then return fail("upstream_too_large") end
chunks[#chunks + 1] = chunk
end
socket:close()
local raw = table.concat(chunks)
local headers, response = raw:match("^(.-)\r\n\r\n(.*)$")
local status = headers and tonumber(headers:match("^HTTP/1%.[01]%s+(%d%d%d)"))
if not status or not headers:lower():match("content%-type:%s*application/json") or #response > MAX_RESPONSE then return nil, nil, "invalid_upstream" end
return status, response
end
local function defaults()
local nixio, fs = require "nixio", require "nixio.fs"
local function read(path, maximum)
local file = nixio.open(path, "r")
if not file then return nil end
local parts, length = {}, 0
while true do
local part=file:read(4096)
if not part then file:close();return nil end
if part=="" then break end
length=length+#part
if length>maximum then file:close();return nil end
parts[#parts+1]=part
end
file:close();return table.concat(parts)
end
return {http=require "luci.http",ctx=require("luci.dispatcher").context,json=require "luci.jsonc",fs=fs,
enabled=function() return require("luci.model.uci").cursor():get_first("fastnet", "fastnet", "enabled") ~= "0" end,
state=function() return read(STATE, 128*1024) end,request=socket_request}
end
function M.new(deps)
local d = deps or defaults()
local http, ctx, json = d.http, d.ctx, d.json
local function reply(status, value)
http.status(status)
http.prepare_content("application/json")
http.header("Cache-Control", "no-store")
http.header("X-Content-Type-Options", "nosniff")
http.write(type(value)=="string" and value or json.stringify(value))
end
local function error_reply(status, code) return reply(status, {error=code}) end
-- jsonc drops null object members when decoding. Forward live core JSON as
-- bytes; reconstruct nullable fields explicitly only for a stopped service.
local function atom(value) return value==nil and "null" or json.stringify(value) end
local function measurement(value)
if type(value)~="table" then return '{"status":"not_tested","value":null,"measuredAt":null,"error":null}' end
return '{"status":'..atom(value.status)..',"value":'..atom(value.value)..',"measuredAt":'..atom(value.measuredAt)..',"error":'..atom(value.error)..',"runId":'..atom(value.runId)..'}'
end
local function stopped_summary(reason)
local raw=d.state();local disk=raw and json.parse(raw) or nil
local latest=type(disk)=="table" and (disk.schemaVersion==1 or disk.schemaVersion==2) and disk.latest or nil
local result="null"
if type(latest)=="table" then
result='{"runId":'..atom(latest.runId)..',"measuredAt":'..atom(latest.measuredAt)..',"download":'..measurement(latest.download)..',"upload":'..measurement(latest.upload)..',"nat":'..measurement(latest.nat)..',"ipv6":'..measurement(latest.ipv6)
if type(latest.bandwidth)=="table" then result=result..',"bandwidth":'..json.stringify(latest.bandwidth) end
result=result..'}'
end
local state = d.enabled() and "unavailable" or "disabled"
return reply(200,'{"schemaVersion":1,"instanceId":"","service":{"state":'..atom(state)..',"reason":'..atom(reason)..'},"latest":'..result..',"latestResults":'..json.stringify(type(disk)=="table" and disk.latestResults or {})..',"task":null}')
end
return {handle=function(_, action)
if type(ctx.authsession)~="string" or ctx.authsession=="" then return error_reply(401,"unauthenticated") end
if ctx.authuser~="root" then return error_reply(403,"forbidden") end
local writing=action=="run" or action=="cancel"
local method=http.getenv("REQUEST_METHOD")
if method~=(writing and "POST" or "GET") then return error_reply(405,"method") end
if not d.fs.access("/etc/config/fastnet") then return error_reply(503,"installation_incomplete") end
if not d.fs.access("/usr/sbin/FastNet") then return error_reply(503,"core_missing") end
local body, path = nil, action
if writing then
local site=http.getenv("HTTP_SEC_FETCH_SITE")
if site and site~="same-origin" and site~="none" then return error_reply(403,"cross_origin") end
local origin=http.getenv("HTTP_ORIGIN")
local secure=http.getenv("HTTPS");local scheme=(secure=="on" or secure=="1") and "https" or "http"
if origin and origin~=scheme.."://"..(http.getenv("HTTP_HOST") or "") then return error_reply(403,"cross_origin") end
-- Some uhttpd builds omit custom CGI headers. The standard LuCI query
-- token is read without consuming the JSON request body.
local csrf=http.getenv("HTTP_X_CSRF_TOKEN") or http.getenv("HTTP_X-CSRF-TOKEN") or http.formvalue("token",true)
if type(ctx.authtoken)~="string" or ctx.authtoken=="" or csrf~=ctx.authtoken then return error_reply(403,"csrf") end
local kind=(http.getenv("CONTENT_TYPE") or ""):match("^%s*([^;]+)")
if kind~="application/json" then return error_reply(422,"content_type") end
local size=tonumber(http.getenv("CONTENT_LENGTH"))
if size and size>MAX_BODY then return error_reply(413,"body_too_large") end
body=http.content()
if type(body)~="string" or #body>MAX_BODY then return error_reply(413,"body_too_large") end
local key=action=="run" and "idempotencyKey" or "id"
local value=body:match('^%s*{%s*"'..key..'"%s*:%s*"([A-Za-z0-9_-]+)"%s*}%s*$')
if not value or #value>(action=="run" and 64 or 96) or #value<(action=="run" and 16 or 1) then return error_reply(422,"invalid_request") end
elseif action=="task" then
local id=http.formvalue("id")
if type(id)~="string" or #id<1 or #id>96 or not id:match("^[A-Za-z0-9_-]+$") then return error_reply(422,"invalid_id") end
path="task?id="..id
elseif action~="summary" then return error_reply(404,"not_found") end
local status, raw, reason=d.request(method,path,body)
if not status then
if action=="summary" and reason=="service_unavailable" then return stopped_summary(reason) end
return error_reply(reason=="service_unavailable" and 503 or 502,reason or "invalid_upstream")
end
local value=type(raw)=="string" and #raw<=MAX_RESPONSE and json.parse(raw) or nil
if type(value)~="table" then return error_reply(502,"invalid_upstream") end
if status>=400 then
local allowed={busy=true,not_found=true,invalid_id=true,invalid_request=true,persistence_failed=true,local_only=true,method=true}
return error_reply(status,allowed[value.error] and value.error or "core_error")
end
if action=="summary" then
if value.schemaVersion~=1 or type(value.service)~="table" then return error_reply(502,"invalid_upstream") end
elseif type(value.id)~="string" or type(value.state)~="string" then return error_reply(502,"invalid_upstream") end
return reply(status,raw)
end}
end
return M