mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
151 lines
8.0 KiB
Lua
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
|