mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-15 12:42:55 +08:00
update 2026-10-07 18:47:42
This commit is contained in:
1 parent
617f7ca2a6
commit
bd061545ed
4832 files changed
+1693456
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# This is free software, licensed under the Apache License, Version 2.0 .
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=LuCI shared integration for LinkEase apps
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_VERSION:=1.2.1
|
||||
PKG_RELEASE:=1
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -0,0 +1,31 @@
|
||||
module("luci.controller.linkease_apps", package.seeall)
|
||||
|
||||
function index()
|
||||
local open = entry({"admin", "services", "linkease_apps", "open"}, call("open"))
|
||||
open.leaf = true
|
||||
open.dependent = false
|
||||
open.sysauth = false
|
||||
|
||||
local status = entry({"admin", "services", "linkease_apps", "status"}, call("status"))
|
||||
status.leaf = true
|
||||
status.dependent = false
|
||||
status.sysauth = "root"
|
||||
status.sysauth_authenticator = "htmlauth"
|
||||
end
|
||||
|
||||
local function handler()
|
||||
local dispatcher = require "luci.dispatcher"
|
||||
return require("luci.model.linkease.apps_http").new({
|
||||
http = require "luci.http",
|
||||
resolver = require("luci.model.linkease.apps_openwrt").new(),
|
||||
auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")
|
||||
})
|
||||
end
|
||||
|
||||
function open()
|
||||
handler():open()
|
||||
end
|
||||
|
||||
function status()
|
||||
handler():status()
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
module("luci.controller.linkease_auth", package.seeall)
|
||||
|
||||
function index()
|
||||
local auth = entry({"admin", "services", "linkease_auth", "auth"}, call("linkease_auth"))
|
||||
auth.leaf = true
|
||||
auth.dependent = false
|
||||
auth.sysauth = false
|
||||
|
||||
local auth_finish = entry({"admin", "services", "linkease_auth", "auth_finish"}, call("linkease_auth_finish"))
|
||||
auth_finish.leaf = true
|
||||
auth_finish.dependent = false
|
||||
auth_finish.sysauth = "root"
|
||||
auth_finish.sysauth_authenticator = "htmlauth"
|
||||
end
|
||||
|
||||
local function bridge()
|
||||
return require("luci.model.linkease.auth").new()
|
||||
end
|
||||
|
||||
function linkease_auth()
|
||||
bridge():auth()
|
||||
end
|
||||
|
||||
function linkease_auth_finish(state)
|
||||
bridge():auth_finish(state)
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
local M = {}
|
||||
local Engine = {}
|
||||
Engine.__index = Engine
|
||||
|
||||
local function valid_id(id)
|
||||
return type(id) == "string"
|
||||
and id:match("^[a-z0-9][a-z0-9_-]*$") ~= nil
|
||||
and #id <= 64
|
||||
end
|
||||
|
||||
local function safe_apps_url(value, id)
|
||||
if type(value) ~= "string" then
|
||||
return "/apps/" .. id .. "/"
|
||||
end
|
||||
local expected = "/apps/" .. id
|
||||
if value == expected or value:sub(1, #expected + 1) == expected .. "/" then
|
||||
return value
|
||||
end
|
||||
return "/apps/" .. id .. "/"
|
||||
end
|
||||
|
||||
local function safe_external_url(value)
|
||||
return type(value) == "string"
|
||||
and #value <= 2048
|
||||
and value:match("^https?://[^/@]+%f[/]") ~= nil
|
||||
and value:find("[%c]") == nil
|
||||
end
|
||||
|
||||
local function base_result(id, entry)
|
||||
entry = type(entry) == "table" and entry or {}
|
||||
return {
|
||||
schemaVersion = 1,
|
||||
id = id,
|
||||
registered = false,
|
||||
available = false,
|
||||
mode = "unavailable",
|
||||
worker = entry.worker or "unknown",
|
||||
proxy = {
|
||||
supported = entry.proxy_supported == true,
|
||||
enabled = entry.proxy_enabled == true
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
local function adapter_call(adapter, name, ...)
|
||||
local fn = adapter[name]
|
||||
if type(fn) ~= "function" then
|
||||
return nil
|
||||
end
|
||||
local ok, value = pcall(fn, ...)
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
function M.new(adapter)
|
||||
assert(type(adapter) == "table", "apps decision adapter is required")
|
||||
return setmetatable({ adapter = adapter }, Engine)
|
||||
end
|
||||
|
||||
function Engine:resolve(id)
|
||||
if not valid_id(id) then
|
||||
local result = base_result(type(id) == "string" and id or "", nil)
|
||||
result.reason = "invalid_id"
|
||||
return result
|
||||
end
|
||||
|
||||
local app = adapter_call(self.adapter, "lookup", id)
|
||||
if type(app) ~= "table" then
|
||||
local result = base_result(id, nil)
|
||||
result.reason = "not_registered"
|
||||
return result
|
||||
end
|
||||
|
||||
local entry = adapter_call(self.adapter, "entry_status")
|
||||
local result = base_result(id, entry)
|
||||
result.registered = true
|
||||
if type(entry) == "table"
|
||||
and app.entry_supported ~= false
|
||||
and entry.available == true
|
||||
and entry.proxy_supported == true
|
||||
and entry.proxy_enabled == true
|
||||
and (entry.worker == "gateway" or entry.worker == "linkeasefull") then
|
||||
result.available = true
|
||||
result.mode = "apps"
|
||||
result.url = safe_apps_url(app.url, id)
|
||||
result.reason = "entry_available"
|
||||
return result
|
||||
end
|
||||
|
||||
local external = adapter_call(self.adapter, "external_status", app)
|
||||
if type(external) == "table" and external.available == true and safe_external_url(external.url) then
|
||||
result.available = true
|
||||
result.mode = "external"
|
||||
result.url = external.url
|
||||
result.reason = "external_available"
|
||||
return result
|
||||
end
|
||||
|
||||
local entry_reason = app.entry_supported == false and "entry_unsupported" or "entry_unavailable"
|
||||
local external_reason = type(external) == "table" and external.reason or "external_unavailable"
|
||||
result.reason = entry_reason .. "_and_" .. tostring(external_reason)
|
||||
return result
|
||||
end
|
||||
|
||||
function Engine:status(id)
|
||||
return self:resolve(id)
|
||||
end
|
||||
|
||||
M.valid_id = valid_id
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,38 @@
|
||||
local M = {}
|
||||
local Compat = {}
|
||||
Compat.__index = Compat
|
||||
|
||||
function M.new(options)
|
||||
assert(type(options) == "table" and options.http and options.resolver, "apps compatibility options are required")
|
||||
return setmetatable({
|
||||
http = options.http,
|
||||
resolver = options.resolver,
|
||||
auth_url = assert(options.auth_url, "auth_url is required")
|
||||
}, Compat)
|
||||
end
|
||||
|
||||
function Compat:open(id)
|
||||
local decision = self.resolver:resolve(id)
|
||||
if decision.available ~= true then
|
||||
self.http.status(decision.registered == true and 503 or 404, "Service Unavailable")
|
||||
self.http.prepare_content("application/json")
|
||||
self.http.write_json(decision)
|
||||
return
|
||||
end
|
||||
self.http.redirect(self.auth_url .. "?return=" .. self.http.urlencode(decision.url))
|
||||
end
|
||||
|
||||
function Compat:legacy_status(id, fields)
|
||||
local decision = self.resolver:status(id)
|
||||
local response = fields or {}
|
||||
response.entry_url = decision.url or ""
|
||||
response.proxy_prefix_supported = decision.proxy and decision.proxy.supported == true or false
|
||||
response.proxy_prefix_enabled = decision.proxy and decision.proxy.enabled == true or false
|
||||
response.app_entry_running = decision.mode == "apps" and decision.available == true
|
||||
response.access = decision
|
||||
self.http.status(200, "OK")
|
||||
self.http.prepare_content("application/json")
|
||||
self.http.write_json(response)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,49 @@
|
||||
local M = {}
|
||||
local Handler = {}
|
||||
Handler.__index = Handler
|
||||
|
||||
local status_messages = {
|
||||
[200] = "OK",
|
||||
[400] = "Bad Request",
|
||||
[404] = "Not Found",
|
||||
[503] = "Service Unavailable"
|
||||
}
|
||||
|
||||
local function response_status(decision)
|
||||
if decision.reason == "invalid_id" then return 400 end
|
||||
if decision.registered ~= true then return 404 end
|
||||
if decision.available ~= true then return 503 end
|
||||
return 200
|
||||
end
|
||||
|
||||
function M.new(options)
|
||||
assert(type(options) == "table" and options.http and options.resolver, "apps HTTP options are required")
|
||||
return setmetatable({
|
||||
http = options.http,
|
||||
resolver = options.resolver,
|
||||
auth_url = assert(options.auth_url, "auth_url is required")
|
||||
}, Handler)
|
||||
end
|
||||
|
||||
function Handler:write(decision, code)
|
||||
self.http.status(code, status_messages[code])
|
||||
self.http.prepare_content("application/json")
|
||||
self.http.write_json(decision)
|
||||
end
|
||||
|
||||
function Handler:status()
|
||||
local decision = self.resolver:status(self.http.formvalue("id"))
|
||||
self:write(decision, response_status(decision))
|
||||
end
|
||||
|
||||
function Handler:open()
|
||||
local decision = self.resolver:resolve(self.http.formvalue("id"))
|
||||
local code = response_status(decision)
|
||||
if code ~= 200 then
|
||||
self:write(decision, code)
|
||||
return
|
||||
end
|
||||
self.http.redirect(self.auth_url .. "?return=" .. self.http.urlencode(decision.url))
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,189 @@
|
||||
local M = {}
|
||||
|
||||
local proxy_mapping = "/apps=http://127.0.0.1:19290"
|
||||
local registration_dirs = {
|
||||
"/usr/share/linkease/apps.d",
|
||||
"/usr/share/linkeasefull/desktop-apps.d"
|
||||
}
|
||||
|
||||
local function defaults()
|
||||
local sys = require "luci.sys"
|
||||
return {
|
||||
fs = require "nixio.fs",
|
||||
http = require "luci.http",
|
||||
json = require "luci.jsonc",
|
||||
sys = sys,
|
||||
read_uci = function(key)
|
||||
if type(key) ~= "string" or key:match("^[%w_@%[%].-]+$") == nil then return nil end
|
||||
local value = sys.exec("uci -q get " .. string.format("%q", key) .. " 2>/dev/null") or ""
|
||||
value = value:match("^%s*(.-)%s*$")
|
||||
if value == "" then return nil end
|
||||
return value
|
||||
end,
|
||||
uci = require("luci.model.uci").cursor()
|
||||
}
|
||||
end
|
||||
|
||||
local function normalize_path(value, id)
|
||||
local path = type(value) == "string" and value or "/apps/" .. id .. "/"
|
||||
if path:sub(1, 1) ~= "/" then path = "/" .. path end
|
||||
if path:sub(-1) ~= "/" then path = path .. "/" end
|
||||
return path
|
||||
end
|
||||
|
||||
local function authority_host(authority)
|
||||
if not authority or authority == "" then return "" end
|
||||
if authority:sub(1, 1) == "[" then return authority:match("^%[([^%]]+)%]") or "" end
|
||||
return authority:match("^([^:]+)") or authority
|
||||
end
|
||||
|
||||
local function url_host(host)
|
||||
if host:find(":") and host:sub(1, 1) ~= "[" then return "[" .. host .. "]" end
|
||||
return host
|
||||
end
|
||||
|
||||
local function uci_key(cursor, key, read_committed)
|
||||
if type(key) ~= "string" then return nil end
|
||||
local config, section_type, _, option = key:match("^([%w_-]+)%.@([%w_-]+)%[(%d+)%]%.([%w_-]+)$")
|
||||
if config and section_type and option then
|
||||
return cursor:get_first(config, section_type, option) or (read_committed and read_committed(key))
|
||||
end
|
||||
local direct_config, section, direct_option = key:match("^([%w_-]+)%.([%w_-]+)%.([%w_-]+)$")
|
||||
if direct_config then return cursor:get(direct_config, section, direct_option) or (read_committed and read_committed(key)) end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function configured_value(cursor, specification, read_committed)
|
||||
if type(specification) ~= "table" then return nil end
|
||||
local keys = specification.keys
|
||||
local value = type(keys) == "table" and uci_key(cursor, keys.uci, read_committed) or nil
|
||||
if value ~= nil then return value end
|
||||
return specification.default
|
||||
end
|
||||
|
||||
local function bool_value(value)
|
||||
return value == true or value == 1 or value == "1" or value == "true"
|
||||
end
|
||||
|
||||
local function standalone_external_path(app)
|
||||
local standalone = type(app.manifest.standalone) == "table" and app.manifest.standalone or {}
|
||||
local external = type(standalone.externalOpen) == "table" and standalone.externalOpen or {}
|
||||
if external.enabled == true and type(external.path) == "string" then
|
||||
return external.path
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function M.new(dependencies)
|
||||
local deps = dependencies or defaults()
|
||||
local function lookup(id)
|
||||
for _, directory in ipairs(deps.registration_dirs or registration_dirs) do
|
||||
local iterator = deps.fs.dir(directory)
|
||||
if iterator then
|
||||
for filename in iterator do
|
||||
if filename:match("%.json$") then
|
||||
local raw = deps.fs.readfile(directory .. "/" .. filename)
|
||||
local manifest = raw and deps.json.parse(raw) or nil
|
||||
if type(manifest) == "table" and manifest.id == id then
|
||||
local standalone = type(manifest.standalone) == "table" and manifest.standalone or {}
|
||||
local desktop = type(manifest.desktop) == "table" and manifest.desktop or {}
|
||||
return {
|
||||
id = id,
|
||||
url = normalize_path(standalone.url or standalone.basePath, id),
|
||||
entry_supported = desktop.mode ~= "builtin",
|
||||
manifest = manifest
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function entry_status()
|
||||
local mappings = deps.uci:get_list("uhttpd", "main", "proxy_prefix") or {}
|
||||
local enabled = false
|
||||
for _, mapping in ipairs(mappings) do
|
||||
if mapping == proxy_mapping then enabled = true end
|
||||
end
|
||||
-- A synthetic LuCI test session may make the session-aware UCI cursor
|
||||
-- hide list values. The committed config remains the source of truth.
|
||||
if not enabled then
|
||||
local config = deps.fs.readfile("/etc/config/uhttpd") or ""
|
||||
enabled = config:find(proxy_mapping, 1, true) ~= nil
|
||||
end
|
||||
local supported = deps.sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0
|
||||
local state_path = deps.uci:get("linkease_app_entry", "main", "state_file") or "/var/run/linkease-app-entry/state.json"
|
||||
local raw = deps.fs.readfile(state_path)
|
||||
local state = raw and deps.json.parse(raw) or {}
|
||||
local worker = type(state) == "table" and state.active or "stopped"
|
||||
local running = worker == "gateway" or worker == "linkeasefull"
|
||||
return {
|
||||
available = running and supported and enabled,
|
||||
worker = worker,
|
||||
proxy_supported = supported,
|
||||
proxy_enabled = enabled
|
||||
}
|
||||
end
|
||||
|
||||
local function external_status(app)
|
||||
local desktop = app.manifest.desktop or {}
|
||||
local target = type(desktop.target) == "table" and desktop.target or nil
|
||||
if desktop.mode == "iframe" and target and target.hostMode == "request-host"
|
||||
and (target.scheme == "http" or target.scheme == "https") then
|
||||
local port = tonumber(configured_value(deps.uci, target.port, deps.read_uci))
|
||||
if not port or port < 1 or port > 65535 or port % 1 ~= 0 then
|
||||
return { available = false, reason = "external_invalid" }
|
||||
end
|
||||
local host = authority_host(deps.http.getenv("HTTP_HOST") or "")
|
||||
if host == "" then host = deps.uci:get("network", "lan", "ipaddr") or "127.0.0.1" end
|
||||
local external_path = standalone_external_path(app)
|
||||
or (type(target.path) == "string" and target.path or "/")
|
||||
if external_path:sub(1, 1) ~= "/" or external_path:sub(1, 2) == "//"
|
||||
or external_path:find("[%c]") then
|
||||
external_path = "/"
|
||||
end
|
||||
return {
|
||||
available = true,
|
||||
url = target.scheme .. "://" .. url_host(host) .. ":" .. tostring(port) .. external_path
|
||||
}
|
||||
end
|
||||
|
||||
local backend = app.manifest.backend or {}
|
||||
local values = type(backend.values) == "table" and backend.values or {}
|
||||
local enabled
|
||||
local port
|
||||
if type(values.externalPortEnabled) == "table" then
|
||||
enabled = bool_value(configured_value(deps.uci, values.externalPortEnabled, deps.read_uci))
|
||||
port = configured_value(deps.uci, values.port, deps.read_uci)
|
||||
else
|
||||
enabled = backend.portFromUci ~= nil or backend.defaultPort ~= nil
|
||||
port = uci_key(deps.uci, backend.portFromUci, deps.read_uci) or backend.defaultPort
|
||||
end
|
||||
if not enabled then return { available = false, reason = "external_disabled" } end
|
||||
port = tonumber(port)
|
||||
if not port or port < 1 or port > 65535 or port % 1 ~= 0 then
|
||||
return { available = false, reason = "external_invalid" }
|
||||
end
|
||||
local host = authority_host(deps.http.getenv("HTTP_HOST") or "")
|
||||
if host == "" then host = deps.uci:get("network", "lan", "ipaddr") or "127.0.0.1" end
|
||||
local external_path = standalone_external_path(app) or backend.externalBasePath or app.url
|
||||
if type(external_path) ~= "string" or external_path:sub(1, 1) ~= "/"
|
||||
or external_path:sub(1, 2) == "//" or external_path:find("[%c]") then
|
||||
external_path = app.url
|
||||
end
|
||||
return {
|
||||
available = true,
|
||||
url = "http://" .. url_host(host) .. ":" .. tostring(port) .. external_path
|
||||
}
|
||||
end
|
||||
|
||||
return require("luci.model.linkease.apps").new({
|
||||
lookup = lookup,
|
||||
entry_status = entry_status,
|
||||
external_status = external_status
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,191 @@
|
||||
local M = {}
|
||||
local Bridge = {}
|
||||
Bridge.__index = Bridge
|
||||
|
||||
local pending_return_cookie = "linkease_openwrt_pending_return"
|
||||
local bridge_return_cookie = "linkease_openwrt_return"
|
||||
local pending_return_cookie_path = "/cgi-bin/luci/admin/services/linkease_auth"
|
||||
local bridge_return_cookie_path = "/cgi-bin/luci/admin/services/linkease_auth/auth"
|
||||
local max_return_length = 2048
|
||||
|
||||
local function encode_state(value)
|
||||
if type(value) ~= "string" or #value > max_return_length then return nil end
|
||||
local encoded = require("nixio").bin.b64encode(value)
|
||||
return (encoded:gsub("%+", "-"):gsub("/", "_"):gsub("=+$", ""))
|
||||
end
|
||||
|
||||
local function decode_state(value)
|
||||
if type(value) ~= "string" or #value == 0 or #value > 4096
|
||||
or value:match("^[A-Za-z0-9_-]+$") == nil then
|
||||
return nil
|
||||
end
|
||||
local encoded = value:gsub("-", "+"):gsub("_", "/")
|
||||
encoded = encoded .. string.rep("=", (4 - #encoded % 4) % 4)
|
||||
local ok, decoded = pcall(require("nixio").bin.b64decode, encoded)
|
||||
if not ok or type(decoded) ~= "string" or #decoded > max_return_length then return nil end
|
||||
return decoded
|
||||
end
|
||||
|
||||
local function default_dependencies()
|
||||
local http = require "luci.http"
|
||||
local util = require "luci.util"
|
||||
local dispatcher = require "luci.dispatcher"
|
||||
return {
|
||||
http = http,
|
||||
ubus = function(object, method, request) return util.ubus(object, method, request) end,
|
||||
lan_ip = function()
|
||||
return require("luci.model.uci").cursor():get("network", "lan", "ipaddr") or ""
|
||||
end,
|
||||
build_url = function(...) return dispatcher.build_url(...) end
|
||||
}
|
||||
end
|
||||
|
||||
local function authority_host(authority)
|
||||
if not authority or authority == "" then return "" end
|
||||
if authority:sub(1, 1) == "[" then return authority:match("^%[([^%]]+)%]") or "" end
|
||||
return authority:match("^([^:]+)") or authority
|
||||
end
|
||||
|
||||
local function authority_port(authority)
|
||||
if not authority then return nil end
|
||||
local value = authority:match("^%[[^%]]+%]:(%d+)$") or authority:match("^[^:]+:(%d+)$")
|
||||
value = tonumber(value)
|
||||
if value and value >= 1 and value <= 65535 then return value end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function valid_authority(authority)
|
||||
return authority and authority ~= "" and authority:match("^[A-Za-z0-9%._%-%[%]:]+$") ~= nil
|
||||
end
|
||||
|
||||
local function cookie_encode(value)
|
||||
return (tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char)
|
||||
return string.format("%%%02X", char:byte())
|
||||
end))
|
||||
end
|
||||
|
||||
local function cookie_decode(value)
|
||||
if not value or value == "" then return nil end
|
||||
return (value:gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end))
|
||||
end
|
||||
|
||||
local function valid_cookie_value(value)
|
||||
return value and value:match("^[A-Za-z0-9._%-_]+$") ~= nil
|
||||
end
|
||||
|
||||
function M.new(dependencies)
|
||||
return setmetatable({ dependencies = dependencies or default_dependencies() }, Bridge)
|
||||
end
|
||||
|
||||
function Bridge:request_authority()
|
||||
local http = self.dependencies.http
|
||||
local forwarded_host = http.getenv("HTTP_X_FORWARDED_HOST") or ""
|
||||
local request_host = http.getenv("HTTP_HOST") or ""
|
||||
if valid_authority(forwarded_host) then return forwarded_host end
|
||||
if valid_authority(request_host) then return request_host end
|
||||
return ""
|
||||
end
|
||||
|
||||
function Bridge:request_scheme()
|
||||
local http = self.dependencies.http
|
||||
if http.getenv("HTTP_X_FORWARDED_PROTO") == "https" or http.getenv("HTTPS") == "on" then return "https" end
|
||||
return "http"
|
||||
end
|
||||
|
||||
function Bridge:absolute_luci_url(path)
|
||||
if not path or path == "" or path:match("^https?://") then return path end
|
||||
local authority = self:request_authority()
|
||||
if authority == "" then return path end
|
||||
return self:request_scheme() .. "://" .. authority .. path
|
||||
end
|
||||
|
||||
function Bridge:valid_apps_return(value)
|
||||
if not value or value == "" then return false end
|
||||
if #value > max_return_length or value:find("[%z\1-\31\127]") then return false end
|
||||
local function valid_path(path)
|
||||
if path == "/apps" then return true end
|
||||
local prefix = path:sub(1, 6)
|
||||
return prefix == "/apps/" or prefix == "/apps?" or prefix == "/apps#"
|
||||
end
|
||||
if value:sub(1, 1) == "/" then return valid_path(value) end
|
||||
|
||||
local scheme, authority, path = value:match("^(https?://)([^/]+)(/.*)$")
|
||||
if not authority or not valid_authority(authority) then return false end
|
||||
local candidate = authority_host(authority)
|
||||
local same_device = candidate ~= "" and (
|
||||
candidate == authority_host(self:request_authority())
|
||||
or candidate == authority_host(self.dependencies.lan_ip())
|
||||
)
|
||||
if not same_device then return false end
|
||||
if valid_path(path) then return true end
|
||||
if path:sub(1, 1) ~= "/" or path:sub(1, 2) == "//" then return false end
|
||||
local target_port = authority_port(authority)
|
||||
local request_port = authority_port(self:request_authority())
|
||||
or (self:request_scheme() == "https" and 443 or 80)
|
||||
local target_default = scheme == "https://" and 443 or 80
|
||||
return target_port ~= nil and target_port ~= request_port and target_port ~= target_default
|
||||
end
|
||||
|
||||
function Bridge:safe_return_target(value)
|
||||
if self:valid_apps_return(value) then return value end
|
||||
return "/apps/"
|
||||
end
|
||||
|
||||
function Bridge:retrieve_luci_session()
|
||||
local http = self.dependencies.http
|
||||
for _, key in ipairs({"sysauth_https", "sysauth_http", "sysauth"}) do
|
||||
local sid = http.getcookie(key)
|
||||
if sid and sid ~= "" then
|
||||
local data = self.dependencies.ubus("session", "get", { ubus_rpc_session = sid })
|
||||
if data and type(data.values) == "table" then return sid end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Bridge:requested_return_target()
|
||||
local http = self.dependencies.http
|
||||
return self:safe_return_target(http.formvalue("return") or cookie_decode(http.getcookie(bridge_return_cookie)) or "/apps/")
|
||||
end
|
||||
|
||||
function Bridge:pending_return_target()
|
||||
return self:safe_return_target(cookie_decode(self.dependencies.http.getcookie(pending_return_cookie)))
|
||||
end
|
||||
|
||||
function Bridge:auth()
|
||||
local http = self.dependencies.http
|
||||
local sid = self:retrieve_luci_session()
|
||||
local target = self:requested_return_target()
|
||||
if valid_cookie_value(sid) then
|
||||
http.header("Set-Cookie", bridge_return_cookie .. "=; Path=" .. bridge_return_cookie_path .. "; Max-Age=0; HttpOnly; SameSite=Lax")
|
||||
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
|
||||
http.redirect(target)
|
||||
return
|
||||
end
|
||||
|
||||
http.header("Set-Cookie", pending_return_cookie .. "=" .. cookie_encode(target) .. "; Path=" .. pending_return_cookie_path .. "; Max-Age=300; HttpOnly; SameSite=Lax")
|
||||
local state = encode_state(target)
|
||||
local finish = self.dependencies.build_url("admin", "services", "linkease_auth", "auth_finish", state)
|
||||
http.redirect(self:absolute_luci_url(finish))
|
||||
end
|
||||
|
||||
function Bridge:auth_finish(state)
|
||||
local http = self.dependencies.http
|
||||
local sid = self:retrieve_luci_session()
|
||||
if not valid_cookie_value(sid) then
|
||||
http.status(403, "Forbidden")
|
||||
return
|
||||
end
|
||||
local decoded = decode_state(state)
|
||||
local target
|
||||
if state and state ~= "" then
|
||||
target = self:safe_return_target(decoded)
|
||||
else
|
||||
target = self:pending_return_target()
|
||||
end
|
||||
http.header("Set-Cookie", pending_return_cookie .. "=; Path=" .. pending_return_cookie_path .. "; Max-Age=0; HttpOnly; SameSite=Lax")
|
||||
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
|
||||
http.redirect(target)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
rm -f /tmp/luci-indexcache*
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,94 @@
|
||||
local source_root = assert(os.getenv("LINKEASE_APPS_SOURCE_ROOT"), "LINKEASE_APPS_SOURCE_ROOT is required")
|
||||
package.path = source_root .. "/?.lua;" .. package.path
|
||||
|
||||
local apps = require "luci.model.linkease.apps"
|
||||
|
||||
local function equal(actual, expected, message)
|
||||
if actual ~= expected then
|
||||
error((message or "values differ") .. ": got " .. tostring(actual) .. ", want " .. tostring(expected), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local function engine(options)
|
||||
options = options or {}
|
||||
local registrations = options.registrations or {
|
||||
dockermanager = { id = "dockermanager", url = "/apps/dockermanager/" }
|
||||
}
|
||||
return apps.new({
|
||||
lookup = function(id)
|
||||
return registrations[id]
|
||||
end,
|
||||
entry_status = function()
|
||||
return options.entry or {
|
||||
available = true,
|
||||
worker = "gateway",
|
||||
proxy_supported = true,
|
||||
proxy_enabled = true
|
||||
}
|
||||
end,
|
||||
external_status = function(app)
|
||||
if options.external then
|
||||
return options.external[app.id]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
local invalid = engine():resolve("../dockermanager")
|
||||
equal(invalid.reason, "invalid_id")
|
||||
equal(invalid.registered, false)
|
||||
equal(invalid.available, false)
|
||||
|
||||
local missing = engine():resolve("unknown")
|
||||
equal(missing.reason, "not_registered")
|
||||
equal(missing.registered, false)
|
||||
|
||||
local routed = engine():resolve("dockermanager")
|
||||
equal(routed.mode, "apps")
|
||||
equal(routed.url, "/apps/dockermanager/")
|
||||
equal(routed.worker, "gateway")
|
||||
equal(routed.available, true)
|
||||
equal(routed.proxy.supported, true)
|
||||
equal(routed.proxy.enabled, true)
|
||||
|
||||
local full = engine({ entry = {
|
||||
available = true,
|
||||
worker = "linkeasefull",
|
||||
proxy_supported = true,
|
||||
proxy_enabled = true
|
||||
} }):status("dockermanager")
|
||||
equal(full.mode, "apps")
|
||||
equal(full.worker, "linkeasefull")
|
||||
|
||||
local builtin = engine({
|
||||
registrations = {
|
||||
["openwrt-luci"] = {
|
||||
id = "openwrt-luci", url = "/apps/openwrt-luci/", entry_supported = false
|
||||
}
|
||||
}
|
||||
}):status("openwrt-luci")
|
||||
equal(builtin.registered, true)
|
||||
equal(builtin.available, false)
|
||||
equal(builtin.mode, "unavailable")
|
||||
equal(builtin.reason, "entry_unsupported_and_external_unavailable")
|
||||
|
||||
local external = engine({
|
||||
entry = { available = false, worker = "stopped", proxy_supported = false, proxy_enabled = false },
|
||||
external = { dockermanager = { available = true, url = "http://192.168.30.7:8192/apps/dockermanager/" } }
|
||||
}):resolve("dockermanager")
|
||||
equal(external.mode, "external")
|
||||
equal(external.available, true)
|
||||
equal(external.url, "http://192.168.30.7:8192/apps/dockermanager/")
|
||||
equal(external.reason, "external_available")
|
||||
|
||||
local unavailable = engine({
|
||||
entry = { available = false, worker = "stopped", proxy_supported = true, proxy_enabled = true },
|
||||
external = { dockermanager = { available = false, reason = "external_disabled" } }
|
||||
}):resolve("dockermanager")
|
||||
equal(unavailable.mode, "unavailable")
|
||||
equal(unavailable.available, false)
|
||||
equal(unavailable.registered, true)
|
||||
equal(unavailable.reason, "entry_unavailable_and_external_disabled")
|
||||
|
||||
print("linkease apps decision tests passed")
|
||||
@@ -0,0 +1,36 @@
|
||||
local source_root = assert(os.getenv("LINKEASE_APPS_SOURCE_ROOT"), "LINKEASE_APPS_SOURCE_ROOT is required")
|
||||
package.path = source_root .. "/?.lua;" .. package.path
|
||||
|
||||
local compat_module = require "luci.model.linkease.apps_compat"
|
||||
local output = {}
|
||||
local decision = {
|
||||
schemaVersion = 1, id = "dockermanager", registered = true, available = true,
|
||||
mode = "apps", url = "/apps/dockermanager/", worker = "gateway",
|
||||
proxy = { supported = true, enabled = true }, reason = "entry_available"
|
||||
}
|
||||
local http = {
|
||||
status = function(code) output.status = code end,
|
||||
prepare_content = function(value) output.content_type = value end,
|
||||
write_json = function(value) output.body = value end,
|
||||
redirect = function(value) output.redirect = value end,
|
||||
urlencode = function(value) return (value:gsub("/", "%%2F")) end
|
||||
}
|
||||
local compat = compat_module.new({
|
||||
http = http,
|
||||
resolver = { resolve = function() return decision end, status = function() return decision end },
|
||||
auth_url = "/auth"
|
||||
})
|
||||
|
||||
compat:open("dockermanager")
|
||||
assert(output.redirect == "/auth?return=%2Fapps%2Fdockermanager%2F")
|
||||
|
||||
compat:legacy_status("dockermanager", { running = true, external_port_enabled = false })
|
||||
assert(output.status == 200)
|
||||
assert(output.body.running == true)
|
||||
assert(output.body.entry_url == "/apps/dockermanager/")
|
||||
assert(output.body.proxy_prefix_supported == true)
|
||||
assert(output.body.proxy_prefix_enabled == true)
|
||||
assert(output.body.app_entry_running == true)
|
||||
assert(output.body.access == decision)
|
||||
|
||||
print("linkease apps compatibility tests passed")
|
||||
@@ -0,0 +1,64 @@
|
||||
local source_root = assert(os.getenv("LINKEASE_APPS_SOURCE_ROOT"), "LINKEASE_APPS_SOURCE_ROOT is required")
|
||||
package.path = source_root .. "/?.lua;" .. package.path
|
||||
|
||||
local apps_http = require "luci.model.linkease.apps_http"
|
||||
|
||||
local function equal(actual, expected, message)
|
||||
if actual ~= expected then
|
||||
error((message or "values differ") .. ": got " .. tostring(actual) .. ", want " .. tostring(expected), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local function invoke(decision, action, id)
|
||||
local output = { id = id }
|
||||
local http = {
|
||||
formvalue = function(name) if name == "id" then return output.id end end,
|
||||
status = function(code) output.status = code end,
|
||||
prepare_content = function(value) output.content_type = value end,
|
||||
write_json = function(value) output.body = value end,
|
||||
redirect = function(value) output.redirect = value end,
|
||||
urlencode = function(value)
|
||||
return (value:gsub("([^A-Za-z0-9._~-])", function(char)
|
||||
return string.format("%%%02X", char:byte())
|
||||
end))
|
||||
end
|
||||
}
|
||||
local resolver = {
|
||||
status = function() return decision end,
|
||||
resolve = function() return decision end
|
||||
}
|
||||
local handler = apps_http.new({
|
||||
http = http,
|
||||
resolver = resolver,
|
||||
auth_url = "/cgi-bin/luci/admin/services/linkease_auth/auth"
|
||||
})
|
||||
handler[action](handler)
|
||||
return output
|
||||
end
|
||||
|
||||
local valid = {
|
||||
schemaVersion = 1, id = "dockermanager", registered = true, available = true,
|
||||
mode = "apps", url = "/apps/dockermanager/", worker = "gateway",
|
||||
reason = "entry_available", proxy = { supported = true, enabled = true }
|
||||
}
|
||||
|
||||
local response = invoke(valid, "status", "dockermanager")
|
||||
equal(response.status, 200)
|
||||
equal(response.content_type, "application/json")
|
||||
equal(response.body.schemaVersion, 1)
|
||||
|
||||
response = invoke(valid, "open", "dockermanager")
|
||||
equal(response.status, nil)
|
||||
equal(response.redirect, "/cgi-bin/luci/admin/services/linkease_auth/auth?return=%2Fapps%2Fdockermanager%2F")
|
||||
|
||||
response = invoke({ reason = "invalid_id", registered = false, available = false }, "status", "../bad")
|
||||
equal(response.status, 400)
|
||||
|
||||
response = invoke({ reason = "not_registered", registered = false, available = false }, "status", "missing")
|
||||
equal(response.status, 404)
|
||||
|
||||
response = invoke({ reason = "entry_unavailable_and_external_disabled", registered = true, available = false }, "open", "dockermanager")
|
||||
equal(response.status, 503)
|
||||
equal(response.body.available, false)
|
||||
|
||||
print("linkease apps HTTP boundary tests passed")
|
||||
@@ -0,0 +1,158 @@
|
||||
local source_root = assert(os.getenv("LINKEASE_APPS_SOURCE_ROOT"), "LINKEASE_APPS_SOURCE_ROOT is required")
|
||||
package.path = source_root .. "/?.lua;" .. package.path
|
||||
|
||||
local function equal(actual, expected, message)
|
||||
if actual ~= expected then
|
||||
error((message or "values differ") .. ": got " .. tostring(actual) .. ", want " .. tostring(expected), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local manifests = {
|
||||
["fastnet.json"] = {
|
||||
id = "fastnet",
|
||||
standalone = { basePath = "/apps/fastnet/" },
|
||||
desktop = {
|
||||
mode = "iframe",
|
||||
target = {
|
||||
scheme = "http", hostMode = "request-host", path = "/",
|
||||
port = { default = 3200, keys = { uci = "fastnet.@fastnet[0].port" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
["kspeeder.json"] = {
|
||||
id = "kspeeder",
|
||||
standalone = {
|
||||
basePath = "/apps/kspeeder/",
|
||||
externalOpen = { enabled = true, path = "/" }
|
||||
},
|
||||
desktop = { mode = "module", entry = "desktop-entry.js" },
|
||||
backend = {
|
||||
type = "http", portFromUci = "istoreenhance.@istoreenhance[0].adminport",
|
||||
defaultPort = 5003
|
||||
}
|
||||
},
|
||||
["kai.json"] = {
|
||||
id = "kai",
|
||||
standalone = {
|
||||
basePath = "/apps/kai/",
|
||||
externalOpen = { enabled = true }
|
||||
},
|
||||
desktop = {
|
||||
mode = "iframe",
|
||||
target = {
|
||||
scheme = "http", hostMode = "request-host", path = "/apps/kai/",
|
||||
port = { default = 8197, keys = { uci = "kai.@kai[0].port" } }
|
||||
}
|
||||
},
|
||||
backend = { type = "app-base", basePath = "/apps/kai/" }
|
||||
},
|
||||
["dockermanager.json"] = {
|
||||
id = "dockermanager",
|
||||
standalone = { basePath = "/apps/dockermanager/" },
|
||||
desktop = { mode = "module", entry = "desktop-entry.js" },
|
||||
backend = {
|
||||
type = "http",
|
||||
values = {
|
||||
externalPortEnabled = {
|
||||
default = false,
|
||||
keys = { uci = "dockermanager.@dockermanager[0].external_port_enabled" }
|
||||
},
|
||||
port = {
|
||||
default = 8192,
|
||||
keys = { uci = "dockermanager.@dockermanager[0].port" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["openwrt.json"] = {
|
||||
id = "openwrt-luci",
|
||||
standalone = { url = "/cgi-bin/luci/" },
|
||||
desktop = { mode = "builtin", component = "LuciContainer" }
|
||||
}
|
||||
}
|
||||
|
||||
local function dependencies(filename, entry_available, uci_values)
|
||||
uci_values = uci_values or {}
|
||||
return {
|
||||
registration_dirs = { "/registrations" },
|
||||
fs = {
|
||||
dir = function()
|
||||
local emitted = false
|
||||
return function()
|
||||
if emitted then return nil end
|
||||
emitted = true
|
||||
return filename
|
||||
end
|
||||
end,
|
||||
readfile = function(path)
|
||||
if path == "/registrations/" .. filename then return "manifest" end
|
||||
if path == "/state.json" then return "state" end
|
||||
if path == "/etc/config/uhttpd" then return entry_available and "/apps=http://127.0.0.1:19290" or "" end
|
||||
end
|
||||
},
|
||||
json = { parse = function(raw)
|
||||
if raw == "manifest" then return manifests[filename] end
|
||||
if raw == "state" then return { active = entry_available and "gateway" or "stopped" } end
|
||||
end },
|
||||
sys = { call = function() return 0 end },
|
||||
http = { getenv = function(name) if name == "HTTP_HOST" then return "192.168.30.7:10000" end end },
|
||||
read_uci = function(key)
|
||||
if key == "fastnet.@fastnet[0].port" then return "3201" end
|
||||
return uci_values[key]
|
||||
end,
|
||||
uci = {
|
||||
get_list = function() return entry_available and { "/apps=http://127.0.0.1:19290" } or {} end,
|
||||
get = function(_, config, section, option)
|
||||
if config == "linkease_app_entry" and section == "main" and option == "state_file" then return "/state.json" end
|
||||
end,
|
||||
get_first = function(_, config, section_type, option)
|
||||
return uci_values[config .. ".@" .. section_type .. "[0]." .. option]
|
||||
end
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
package.loaded["luci.model.linkease.apps_openwrt"] = nil
|
||||
local apps_openwrt = require "luci.model.linkease.apps_openwrt"
|
||||
|
||||
local fastnet = apps_openwrt.new(dependencies("fastnet.json", false)):resolve("fastnet")
|
||||
equal(fastnet.available, true)
|
||||
equal(fastnet.mode, "external")
|
||||
equal(fastnet.url, "http://192.168.30.7:3201/")
|
||||
|
||||
local kspeeder = apps_openwrt.new(dependencies("kspeeder.json", false, {
|
||||
["istoreenhance.@istoreenhance[0].adminport"] = "5004"
|
||||
})):resolve("kspeeder")
|
||||
equal(kspeeder.available, true)
|
||||
equal(kspeeder.mode, "external")
|
||||
equal(kspeeder.url, "http://192.168.30.7:5004/", "standalone external path must win")
|
||||
|
||||
local kai = apps_openwrt.new(dependencies("kai.json", false, {
|
||||
["kai.@kai[0].port"] = "8198"
|
||||
})):resolve("kai")
|
||||
equal(kai.available, true)
|
||||
equal(kai.mode, "external")
|
||||
equal(kai.url, "http://192.168.30.7:8198/apps/kai/", "app-base external path must be preserved")
|
||||
|
||||
local kai_proxy = apps_openwrt.new(dependencies("kai.json", true)):resolve("kai")
|
||||
equal(kai_proxy.available, true)
|
||||
equal(kai_proxy.mode, "apps")
|
||||
equal(kai_proxy.url, "/apps/kai/", "shared entry must keep the public KAI root")
|
||||
|
||||
local docker_disabled = apps_openwrt.new(dependencies("dockermanager.json", false)):resolve("dockermanager")
|
||||
equal(docker_disabled.available, false)
|
||||
equal(docker_disabled.reason, "entry_unavailable_and_external_disabled")
|
||||
|
||||
local docker_enabled = apps_openwrt.new(dependencies("dockermanager.json", false, {
|
||||
["dockermanager.@dockermanager[0].external_port_enabled"] = "1",
|
||||
["dockermanager.@dockermanager[0].port"] = "8193"
|
||||
})):resolve("dockermanager")
|
||||
equal(docker_enabled.available, true)
|
||||
equal(docker_enabled.url, "http://192.168.30.7:8193/apps/dockermanager/")
|
||||
|
||||
local builtin = apps_openwrt.new(dependencies("openwrt.json", true)):resolve("openwrt-luci")
|
||||
equal(builtin.registered, true)
|
||||
equal(builtin.available, false)
|
||||
equal(builtin.reason, "entry_unsupported_and_external_disabled")
|
||||
|
||||
print("linkease apps OpenWrt adapter tests passed")
|
||||
@@ -0,0 +1,177 @@
|
||||
local source_root = assert(os.getenv("LINKEASE_AUTH_SOURCE_ROOT"), "LINKEASE_AUTH_SOURCE_ROOT is required")
|
||||
package.path = source_root .. "/?.lua;" .. package.path
|
||||
|
||||
local function equal(actual, expected, message)
|
||||
if actual ~= expected then
|
||||
error((message or "values differ") .. ": got " .. tostring(actual) .. ", want " .. tostring(expected), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local function has_header(headers, pattern)
|
||||
for _, header in ipairs(headers) do
|
||||
if header[1] == "Set-Cookie" and header[2]:match(pattern) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function scenario(options)
|
||||
options = options or {}
|
||||
local headers = {}
|
||||
local result = {}
|
||||
local cookies = options.cookies or {}
|
||||
local environment = options.environment or { HTTP_HOST = "192.168.30.7:10000" }
|
||||
local forms = options.forms or {}
|
||||
|
||||
package.loaded["luci.model.linkease.auth"] = nil
|
||||
local auth = require "luci.model.linkease.auth"
|
||||
local bridge = auth.new({
|
||||
http = {
|
||||
getcookie = function(name) return cookies[name] end,
|
||||
getenv = function(name) return environment[name] end,
|
||||
formvalue = function(name) return forms[name] end,
|
||||
header = function(name, value) headers[#headers + 1] = { name, value } end,
|
||||
redirect = function(value) result.redirect = value end,
|
||||
status = function(code, message) result.status = code; result.message = message end
|
||||
},
|
||||
ubus = function(object, method, request)
|
||||
if object == "session" and method == "get" and request.ubus_rpc_session == options.valid_sid then
|
||||
return { values = { username = "root" } }
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
lan_ip = function() return "192.168.30.7" end,
|
||||
build_url = function(...)
|
||||
local arguments = {...}
|
||||
local url = "/cgi-bin/luci/admin/services/linkease_auth/auth_finish"
|
||||
if arguments[5] then url = url .. "/" .. arguments[5] end
|
||||
return url
|
||||
end
|
||||
})
|
||||
return bridge, result, headers
|
||||
end
|
||||
|
||||
local bridge, result, headers = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session",
|
||||
forms = { ["return"] = "/apps/dockermanager/" }
|
||||
})
|
||||
bridge:auth()
|
||||
equal(result.redirect, "/apps/dockermanager/")
|
||||
equal(has_header(headers, "linkease_openwrt_sid=valid%-session"), true)
|
||||
equal(has_header(headers, "linkease_openwrt_sid=.*Path=/apps"), true)
|
||||
|
||||
bridge, result, headers = scenario({ forms = { ["return"] = "/apps/kaiplus/" } })
|
||||
bridge:auth()
|
||||
local state = result.redirect:match("/auth_finish/([A-Za-z0-9_-]+)$")
|
||||
equal(type(state), "string", "auth begin must encode return intent into the protected path")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session"
|
||||
})
|
||||
bridge:auth_finish(state)
|
||||
equal(result.redirect, "/apps/kaiplus/")
|
||||
|
||||
local first_bridge, first_result = scenario({
|
||||
forms = { ["return"] = "/apps/dockermanager/containers/abc?tab=logs&follow=1" }
|
||||
})
|
||||
first_bridge:auth()
|
||||
local first_state = first_result.redirect:match("/auth_finish/([A-Za-z0-9_-]+)$")
|
||||
equal(type(first_state), "string", "deep link state must be encoded")
|
||||
|
||||
local second_bridge, second_result = scenario({
|
||||
forms = { ["return"] = "/apps/kaiplus/?conversation=second" }
|
||||
})
|
||||
second_bridge:auth()
|
||||
local second_state = second_result.redirect:match("/auth_finish/([A-Za-z0-9_-]+)$")
|
||||
equal(type(second_state), "string", "second tab state must be encoded")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session", linkease_openwrt_pending_return = "%2Fapps%2Fwrong%2F" },
|
||||
valid_sid = "valid-session"
|
||||
})
|
||||
bridge:auth_finish(first_state)
|
||||
equal(result.redirect, "/apps/dockermanager/containers/abc?tab=logs&follow=1", "first tab must use its own path state")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session", linkease_openwrt_pending_return = "%2Fapps%2Fwrong%2F" },
|
||||
valid_sid = "valid-session"
|
||||
})
|
||||
bridge:auth_finish(second_state)
|
||||
equal(result.redirect, "/apps/kaiplus/?conversation=second", "second tab must use its own path state")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session"
|
||||
})
|
||||
bridge:auth_finish("not_base64url_@@")
|
||||
equal(result.redirect, "/apps/", "malformed state must fail closed")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session"
|
||||
})
|
||||
bridge:auth_finish(string.rep("A", 4097))
|
||||
equal(result.redirect, "/apps/", "oversized state must fail closed")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session",
|
||||
forms = { ["return"] = "http://192.168.30.7:8192/apps/dockermanager/" }
|
||||
})
|
||||
bridge:auth()
|
||||
equal(result.redirect, "http://192.168.30.7:8192/apps/dockermanager/")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session",
|
||||
forms = { ["return"] = "http://192.168.30.7:3200/" }
|
||||
})
|
||||
bridge:auth()
|
||||
equal(result.redirect, "http://192.168.30.7:3200/", "same-device external app port must be allowed")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session",
|
||||
forms = { ["return"] = "http://192.168.30.7:10000/cgi-bin/luci/" }
|
||||
})
|
||||
bridge:auth()
|
||||
equal(result.redirect, "/apps/", "same-authority non-app path must remain rejected")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session",
|
||||
forms = { ["return"] = "http://evil.example/apps/dockermanager/" }
|
||||
})
|
||||
bridge:auth()
|
||||
equal(result.redirect, "/apps/")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session",
|
||||
forms = { ["return"] = "/apps/dockermanager/%0d%0aLocation:%20http://evil.example/" }
|
||||
})
|
||||
bridge:auth()
|
||||
equal(result.redirect, "/apps/dockermanager/%0d%0aLocation:%20http://evil.example/")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session",
|
||||
forms = { ["return"] = "/apps/dockermanager/\r\nLocation: http://evil.example/" }
|
||||
})
|
||||
bridge:auth()
|
||||
equal(result.redirect, "/apps/", "raw control characters must be rejected")
|
||||
|
||||
bridge, result = scenario({
|
||||
cookies = { sysauth = "valid-session" },
|
||||
valid_sid = "valid-session",
|
||||
forms = { ["return"] = "http://192.168.30.7:8192@evil.example/apps/dockermanager/" }
|
||||
})
|
||||
bridge:auth()
|
||||
equal(result.redirect, "/apps/")
|
||||
|
||||
bridge, result = scenario()
|
||||
bridge:auth_finish()
|
||||
equal(result.status, 403)
|
||||
|
||||
print("linkease auth bridge tests passed")
|
||||
Reference in new issue
Block a user