fix(koreader): respect KOReader global HTTP proxy settings (#1562)

* fix(koreader): respect KOReader global HTTP proxy settings

Allow KOReader's Network -> Proxy settings (stored in G_reader_settings)
to be respected for both JSON API requests and binary downloads.
Also support explicit proxy in BookOrbitApi options, and route Tailscale
tailnet addresses (100.x.y.z and *.ts.net) through the local userspace
HTTP proxy (127.0.0.1:1056) when running on e-ink devices lacking TUN.

Fixes #753

* fix(koreader): address review feedback on host parsing and HTTPS proxy safety

* fix(koreader): simplify getProxy to respect KOReader proxy settings without vendor fallbacks

* fix: tunnel KOReader HTTPS through configured HTTP proxies

Route HTTPS requests through CONNECT so proxy-only servers stay reachable without exposing BookOrbit credentials to the proxy. Restrict redirects to the original host and allow secure upgrades for API calls and downloads. Bump the plugin version so installed clients discover the fix.

---------

Co-authored-by: Andrej <acikarski14@gmail.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
Irohoho
2026-09-26 19:15:47 +02:00
committed by GitHub
parent a0477f0d0b
commit 900514c2b3
6 changed files with 533 additions and 38 deletions
@@ -16,6 +16,7 @@ local util = require("util")
local TransferPolicy = require("bookorbit_transfer_policy")
local TransferProgress = require("bookorbit_transfer_progress")
local Proxy = require("bookorbit_proxy")
local MAX_BODY_BYTES = 900 * 1024 -- stays under the server's 1 MiB body limit
local MATCH_HASH_LENGTH = 32
@@ -136,6 +137,12 @@ local function absoluteUrl(base, location)
if location:match("^https?://") then return location end
local parsed = parseHttpUrl(base)
if not parsed then return nil end
if location:sub(1, 1) == "?" then
return base:gsub("[?#].*$", "") .. location
end
if location:sub(1, 1) == "#" then
return base:gsub("#.*$", "") .. location
end
if location:sub(1, 2) == "//" then
return parsed.scheme .. ":" .. location
end
@@ -145,6 +152,18 @@ local function absoluteUrl(base, location)
return directory .. "/" .. location
end
local function safeRequestRedirect(initial_url, current_url, location)
if type(location) ~= "string" or location == "" then return nil end
local resolved = absoluteUrl(current_url, location:gsub("%s", ""))
local initial = parseHttpUrl(initial_url)
local current = parseHttpUrl(current_url)
local target = parseHttpUrl(resolved)
if not initial or not current or not target or target.host ~= initial.host then return nil end
if target.scheme == current.scheme and target.port == current.port then return resolved end
if current.scheme == "http" and target.scheme == "https" then return resolved end
return nil
end
local function forkSafeServerUrl(value)
local scheme, authority, suffix = tostring(value or ""):match("^(https?)://([^/%?#]+)(.*)$")
if scheme ~= "http" or not authority then return value end
@@ -176,6 +195,7 @@ function BookOrbitApi.normalizeServerUrl(input)
end
function BookOrbitApi.new(opts)
opts = opts or {}
return setmetatable({
server_url = forkSafeServerUrl(opts.server_url),
username = opts.username,
@@ -184,9 +204,51 @@ function BookOrbitApi.new(opts)
device_model = opts.device_model,
plugin_version = opts.plugin_version,
background_requests = opts.background_requests == true,
proxy = opts.proxy,
}, BookOrbitApi)
end
-- Resolves the HTTP proxy for a target URL.
-- Prefers an explicit proxy option, then KOReader's enabled global HTTP proxy.
function BookOrbitApi:getProxy(target_url)
local url = (target_url and tostring(target_url):match("^https?://")) and tostring(target_url) or self.server_url
local parsed = parseHttpUrl(url)
if not parsed then return nil end
if self.proxy and self.proxy ~= "" then
return self.proxy
end
if G_reader_settings and G_reader_settings:isTrue("http_proxy_enabled") then
local p = G_reader_settings:readSetting("http_proxy")
if p and p ~= "" then
return p
end
end
return nil
end
local function sendHttpRequest(request)
if not request.url:lower():match("^https://") then
return http.request(request)
end
local transport_request = {}
for key, value in pairs(request) do transport_request[key] = value end
if request.proxy then
transport_request.create = Proxy.connectSocket(request.proxy)
transport_request.proxy = nil
end
-- LuaSocket's global proxy overrides a custom HTTPS socket unless cleared.
local global_proxy = http.PROXY
http.PROXY = nil
local ok, result, code, headers, status = pcall(http.request, transport_request)
http.PROXY = global_proxy
if not ok then return nil, tostring(result) end
return result, code, headers, status
end
function BookOrbitApi:isConfigured()
return self.server_url ~= nil and self.username ~= nil and self.userkey ~= nil
end
@@ -194,11 +256,11 @@ end
-- Returns decoded_body on success, or nil, err_code, decoded_error_body.
-- err_code is a number for HTTP errors and a string for transport errors.
function BookOrbitApi:requestBlocking(method, path, body, extra_headers)
local sink = {}
local initial_url = self.server_url .. path
local request = {
url = self.server_url .. path,
url = initial_url,
method = method,
sink = ltn12.sink.table(sink),
redirect = false,
headers = {
["accept"] = "application/json",
["x-auth-user"] = self.username,
@@ -223,26 +285,40 @@ function BookOrbitApi:requestBlocking(method, path, body, extra_headers)
request.headers["Content-Length"] = #body_json
end
socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
local code, _, status = socket.skip(1, http.request(request))
socketutil:reset_timeout()
local current_url = initial_url
for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do
local sink = {}
request.url = current_url
request.sink = ltn12.sink.table(sink)
request.proxy = self:getProxy(current_url)
if type(code) ~= "number" then
logger.dbg("BookOrbit: network error:", status or code)
return nil, tostring(status or code or "network_error")
socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
local code, headers, status = socket.skip(1, sendHttpRequest(request))
socketutil:reset_timeout()
if type(code) ~= "number" then
logger.dbg("BookOrbit: network error:", status or code)
return nil, tostring(status or code or "network_error")
end
if not request.source and (method == "GET" or method == "HEAD")
and (code == 301 or code == 302 or code == 303 or code == 307 or code == 308) then
if redirect_count >= MAX_DOWNLOAD_REDIRECTS then
return nil, "too_many_redirects"
end
local location = headers and (headers.location or headers.Location)
local redirected = safeRequestRedirect(initial_url, current_url, location)
if not redirected then return nil, "unsafe_redirect" end
current_url = redirected
else
local decoded, decode_err = decodeResponse(sink)
if code < 200 or code >= 300 then return nil, code, decoded end
if decode_err then return nil, decode_err end
return decoded or {}
end
end
local decoded, decode_err = decodeResponse(sink)
if code < 200 or code >= 300 then
return nil, code, decoded
end
if decode_err then
return nil, decode_err
end
return decoded or {}
return nil, "too_many_redirects"
end
-- True when this call may fork its own background worker: the client is in
@@ -342,8 +418,8 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts)
generation = opts.progress_generation,
total = tonumber(opts.expected_bytes) or 0,
})
local current_url = self.server_url .. path
local origin = parseHttpUrl(current_url)
local initial_url = self.server_url .. path
local current_url = initial_url
local total_received = 0
local function fail(out, err)
@@ -352,19 +428,6 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts)
return nil, err
end
local function sameOriginRedirect(location)
if type(location) ~= "string" or location == "" then return nil end
local resolved = absoluteUrl(current_url, location:gsub("%s", ""))
local parsed = parseHttpUrl(resolved)
if not origin or not parsed
or parsed.scheme ~= origin.scheme
or parsed.host ~= origin.host
or parsed.port ~= origin.port then
return nil
end
return resolved
end
for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do
local out, open_err = io.open(temp_path, "w")
if not out then
@@ -401,10 +464,12 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts)
},
}
request.proxy = self:getProxy(current_url)
socketutil:set_timeout(
opts.block_timeout or socketutil.FILE_BLOCK_TIMEOUT,
opts.total_timeout or socketutil.FILE_TOTAL_TIMEOUT)
local code, headers, status = socket.skip(1, http.request(request))
local code, headers, status = socket.skip(1, sendHttpRequest(request))
socketutil:reset_timeout()
if too_large then return fail(out, "response_too_large") end
@@ -419,7 +484,7 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts)
return nil, "too_many_redirects"
end
local location = headers and (headers.location or headers.Location)
local redirected = sameOriginRedirect(location)
local redirected = safeRequestRedirect(initial_url, current_url, location)
if not redirected then return nil, "unsafe_redirect" end
current_url = redirected
else
@@ -0,0 +1,118 @@
local Proxy = {}
local function proxyConfig(proxy_url)
if type(proxy_url) ~= "string" or proxy_url:find("[\r\n]") then return nil end
local url = require("socket.url")
local parsed = url.parse(proxy_url)
local port = parsed and tonumber(parsed.port or 3128)
if not parsed or parsed.scheme ~= "http" or not parsed.host
or not port or port < 1 or port > 65535 or port ~= math.floor(port) then
return nil
end
local authorization = ""
if parsed.user and parsed.password then
local user = url.unescape(parsed.user)
local password = url.unescape(parsed.password)
if user:find("[\r\n]") or password:find("[\r\n]") then return nil end
authorization = "Proxy-Authorization: Basic "
.. require("mime").b64(user .. ":" .. password) .. "\r\n"
end
return {
host = parsed.host:match("^%[(.*)%]$") or parsed.host,
port = port,
authorization = authorization,
}
end
local function readLine(sock, remaining)
local chars = {}
for _ = 1, remaining do
local char, err = sock:receive(1)
if not char then return nil, err end
if char == "\n" then
if chars[#chars] == "\r" then chars[#chars] = nil end
return table.concat(chars)
end
chars[#chars + 1] = char
end
return nil, "proxy_response_too_large"
end
function Proxy.connectSocket(proxy_url)
return function()
local config = proxyConfig(proxy_url)
if not config then error("invalid_http_proxy") end
local socket = require("socket")
local conn = { sock = assert(socket.tcp()) }
function conn:settimeout(...)
return self.sock:settimeout(...)
end
function conn:close()
return self.sock:close()
end
function conn:connect(host, port)
local target_port = tonumber(port)
if type(host) ~= "string" or host:find("[%c%s]")
or not target_port or target_port < 1 or target_port > 65535
or target_port ~= math.floor(target_port) then
return nil, "invalid_https_target"
end
local connected, connect_err = self.sock:connect(config.host, config.port)
if not connected then return nil, connect_err end
local tls_host = host:match("^%[(.*)%]$") or host
local authority = tls_host:find(":", 1, true)
and "[" .. tls_host .. "]:" .. tostring(target_port)
or tls_host .. ":" .. tostring(target_port)
local sent, send_err = self.sock:send("CONNECT " .. authority .. " HTTP/1.1\r\nHost: "
.. authority .. "\r\n" .. config.authorization .. "\r\n")
if not sent then return nil, send_err end
local status, status_err = readLine(self.sock, 8192)
if not status then return nil, status_err end
if status:match("^HTTP/%d+%.%d+%s+(%d%d%d)") ~= "200" then
return nil, "proxy_connect_failed"
end
local header_bytes = #status + 2
local headers_complete = false
for _ = 1, 64 do
local line, line_err = readLine(self.sock, 8192 - header_bytes)
if not line then return nil, line_err end
header_bytes = header_bytes + #line + 2
if header_bytes > 8192 then return nil, "proxy_response_too_large" end
if line == "" then
headers_complete = true
break
end
end
if not headers_complete then return nil, "proxy_response_too_large" end
-- Keep the same TLS policy as KOReader's LuaSec HTTPS transport.
local tls, tls_err = require("ssl").wrap(self.sock, {
protocol = "any",
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1" },
verify = "none",
mode = "client",
})
if not tls then return nil, tls_err end
tls:sni(tls_host)
tls:settimeout(require("ssl.https").TIMEOUT)
self.sock = tls
local handshook, handshake_err = tls:dohandshake()
if not handshook then return nil, handshake_err end
for name, method in pairs(getmetatable(tls).__index) do
if type(method) == "function" then
self[name] = function(_, ...)
return method(self.sock, ...)
end
end
end
return 1
end
return conn
end
end
return Proxy
+1 -1
View File
@@ -48,7 +48,7 @@ local BookOrbitProgressSync = require("bookorbit_progress_sync")
local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "1.5.4"
local PLUGIN_VERSION = "1.5.5"
local SYNC_STRATEGY = {
PROMPT = 1,
@@ -9,9 +9,11 @@ local response = {
on_chunk = nil,
}
local requests = {}
local last_global_proxy
package.loaded["socket.http"] = {
request = function(request)
last_global_proxy = package.loaded["socket.http"].PROXY
table.insert(requests, request)
local active = response
if response.queue and #response.queue > 0 then
@@ -335,6 +337,63 @@ assertEqual(cancelled, nil, "dismissed download fails")
assertEqual(cancel_err, "cancelled", "dismissed download reports cancellation")
assertEqual(exists(final_path), false, "dismissed download publishes no file")
local mock_http = package.loaded["socket.http"]
mock_http.PROXY = "http://127.0.0.1:8080"
assertEqual(api:downloadBlocking("/thumb", final_path, { temp_path = temp_path }), true,
"HTTPS download succeeds without the global HTTP proxy")
assertEqual(last_global_proxy, nil, "HTTPS download clears LuaSocket's global proxy")
assertEqual(mock_http.PROXY, "http://127.0.0.1:8080",
"HTTPS download restores LuaSocket's global proxy")
os.remove(final_path)
mock_http.PROXY = nil
local proxied_https_api = BookOrbitApi.new{
server_url = "https://books.example.com/api/v1",
username = "reader",
userkey = "key",
proxy = "http://127.0.0.1:8080",
}
assertEqual(proxied_https_api:downloadBlocking("/thumb", final_path, { temp_path = temp_path }), true,
"HTTPS download succeeds with a CONNECT proxy")
assertEqual(requests[#requests].proxy, nil, "HTTPS download keeps the proxy URL out of LuaSocket")
assertEqual(type(requests[#requests].create), "function", "HTTPS download uses a CONNECT socket")
os.remove(final_path)
G_reader_settings = {
isTrue = function(_, key) return key == "http_proxy_enabled" end,
readSetting = function(_, key)
if key == "http_proxy" then return "http://127.0.0.1:8080" end
end,
}
local http_api = BookOrbitApi.new{
server_url = "http://books.example.com/api/v1",
username = "reader",
userkey = "key",
}
assertEqual(http_api:downloadBlocking("/thumb", final_path, { temp_path = temp_path }), true,
"HTTP download succeeds with KOReader's configured proxy")
assertEqual(requests[#requests].proxy, "http://127.0.0.1:8080",
"HTTP download passes the configured proxy to LuaSocket")
os.remove(final_path)
mock_http.PROXY = "http://127.0.0.1:8080"
response.queue = {
{ code = 301, headers = { location = "https://books.example.com/api/v1/file" } },
{ code = 200, headers = { ["content-type"] = "image/jpeg" }, chunks = { "upgraded image" } },
}
assertEqual(http_api:downloadBlocking("/thumb", final_path, { temp_path = temp_path }), true,
"HTTP download follows a same-host HTTPS upgrade through CONNECT")
assertEqual(requests[#requests - 1].proxy, "http://127.0.0.1:8080",
"HTTP download starts through the configured proxy")
assertEqual(requests[#requests].proxy, nil, "HTTPS upgrade keeps proxy URL out of LuaSocket")
assertEqual(type(requests[#requests].create), "function", "HTTPS upgrade uses a CONNECT socket")
assertEqual(last_global_proxy, nil, "HTTPS upgrade clears LuaSocket's global proxy")
assertEqual(mock_http.PROXY, "http://127.0.0.1:8080", "HTTPS upgrade restores the global proxy")
os.remove(final_path)
response.queue = nil
mock_http.PROXY = nil
G_reader_settings = nil
os.execute("rm -rf '" .. temp_root .. "'")
print("bookorbit_api_download_test.lua: ok")
+152
View File
@@ -71,13 +71,33 @@ package.loaded["ltn12"] = {
local last_request_url
local last_request_headers
local last_request_proxy
local last_global_proxy
local mock_http_error = false
local mock_http_queue
local request_history = {}
local request_count = 0
package.loaded["socket.http"] = {
request = function(request)
last_global_proxy = package.loaded["socket.http"].PROXY
if mock_http_error then error("simulated transport failure") end
request_count = request_count + 1
request_ran_in_subprocess = in_subprocess
last_request_url = request.url
last_request_headers = request.headers
last_request_proxy = request.proxy
table.insert(request_history, {
url = request.url,
proxy = request.proxy,
global_proxy = last_global_proxy,
redirect = request.redirect,
create = request.create,
})
if mock_http_queue then
local response = table.remove(mock_http_queue, 1)
if response.body then request.sink(response.body) end
return 1, response.code, response.headers or {}, "HTTP " .. tostring(response.code)
end
if mock_http_body then
request.sink(mock_http_body)
end
@@ -321,6 +341,7 @@ local background_identity_client = BookOrbitApi.new{
background_requests = true,
}
body, err = background_identity_client:getPluginVersion()
assertEqual(body ~= nil, true, "background update check has a response body")
assertEqual(err, nil, "background update check succeeds")
assertEqual(subprocess_calls, 2, "background update check uses subprocess")
assertEqual(last_request_headers["x-bookorbit-device-id"], "background-device",
@@ -343,7 +364,138 @@ wrapped = false
request_ran_in_subprocess = false
body, err = background_client:auth()
assertEqual(body.ok, true, "unwrapped request falls back safely")
assertEqual(err, nil, "unwrapped request succeeds")
assertEqual(subprocess_calls, 4, "unwrapped request does not start subprocess")
assertEqual(request_ran_in_subprocess, false, "unwrapped fallback runs in current process")
-- Proxy resolution tests
assertEqual(client:getProxy(), nil, "default client has no proxy")
local explicit_proxy_client = BookOrbitApi.new{
server_url = "http://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
proxy = "http://127.0.0.1:8080",
}
assertEqual(explicit_proxy_client:getProxy(), "http://127.0.0.1:8080",
"explicit proxy is returned for HTTP server")
mock_http_body = "{\"ok\":true}"
mock_http_code = 200
explicit_proxy_client:auth()
assertEqual(last_request_proxy, "http://127.0.0.1:8080",
"explicit proxy is passed to request table for HTTP server")
local https_explicit_proxy_client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
proxy = "http://127.0.0.1:8080",
}
assertEqual(https_explicit_proxy_client:getProxy(), "http://127.0.0.1:8080",
"explicit proxy is returned for HTTPS server")
local mock_http = package.loaded["socket.http"]
mock_http.PROXY = "http://127.0.0.1:8080"
https_explicit_proxy_client:auth()
assertEqual(last_global_proxy, nil, "HTTPS request clears LuaSocket's global proxy")
assertEqual(last_request_proxy, nil, "HTTPS request does not send a proxy URL to LuaSocket")
assertEqual(type(request_history[#request_history].create), "function",
"HTTPS request uses a CONNECT socket")
assertEqual(mock_http.PROXY, "http://127.0.0.1:8080", "HTTPS request restores the global proxy")
mock_http_error = true
local _, transport_error = https_explicit_proxy_client:auth()
assertEqual(transport_error:find("simulated transport failure", 1, true) ~= nil, true,
"HTTPS transport failure is reported")
assertEqual(mock_http.PROXY, "http://127.0.0.1:8080",
"HTTPS transport failure restores the global proxy")
mock_http_error = false
mock_http.PROXY = nil
mock_http.PROXY = "http://127.0.0.1:8080"
mock_http_queue = {
{ code = 302, headers = { location = "https://attacker.example.com/api/v1" } },
}
local before_redirect = #request_history
local _, unsafe_redirect = https_explicit_proxy_client:auth()
assertEqual(unsafe_redirect, "unsafe_redirect", "cross-host API redirect is rejected")
assertEqual(#request_history, before_redirect + 1, "cross-host API redirect sends one request")
assertEqual(request_history[#request_history].redirect, false,
"LuaSocket cannot automatically forward API credentials")
assertEqual(request_history[#request_history].global_proxy, nil,
"HTTPS redirect response cannot use the global HTTP proxy")
mock_http_queue = {
{ code = 302, headers = { location = "/api/v1/redirected" } },
{ code = 200, body = "{\"ok\":true}" },
}
local same_origin_body, same_origin_error = https_explicit_proxy_client:auth()
assertEqual(same_origin_error, nil, "same-origin API redirect succeeds")
assertEqual(same_origin_body.ok, true, "same-origin API redirect decodes the final body")
assertEqual(request_history[#request_history].url,
"https://bookorbit.example.com/api/v1/redirected", "same-origin redirect uses the new URL")
assertEqual(request_history[#request_history].global_proxy, nil,
"same-origin HTTPS redirect stays off the global HTTP proxy")
mock_http_queue = {
{ code = 302, headers = { location = "?refresh=1" } },
{ code = 200, body = "{\"ok\":true}" },
}
https_explicit_proxy_client:auth()
assertEqual(request_history[#request_history].url,
"https://bookorbit.example.com/api/v1/koreader/users/auth?refresh=1",
"query-only redirect preserves the original path")
mock_http_queue = nil
mock_http.PROXY = nil
-- KOReader global settings proxy test
local http_client = BookOrbitApi.new{
server_url = "http://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
}
local mock_settings = {
http_proxy_enabled = true,
http_proxy = "http://192.168.1.1:3128",
}
G_reader_settings = {
isTrue = function(self, key) return mock_settings[key] == true end,
readSetting = function(self, key) return mock_settings[key] end,
}
assertEqual(http_client:getProxy(), "http://192.168.1.1:3128",
"G_reader_settings proxy is used when enabled for HTTP server")
http_client:auth()
assertEqual(last_request_proxy, "http://192.168.1.1:3128",
"G_reader_settings proxy is passed to request table")
assertEqual(last_global_proxy, nil, "HTTP requests do not change LuaSocket's global proxy")
mock_http.PROXY = "http://192.168.1.1:3128"
mock_http_queue = {
{ code = 301, headers = { location = "https://bookorbit.example.com/api/v1/redirected" } },
{ code = 200, body = "{\"ok\":true}" },
}
local upgraded_body, upgraded_error = http_client:auth()
assertEqual(upgraded_error, nil, "same-host HTTPS upgrade succeeds")
assertEqual(upgraded_body.ok, true, "same-host HTTPS upgrade decodes the final body")
assertEqual(request_history[#request_history - 1].proxy, "http://192.168.1.1:3128",
"HTTP upgrade starts through the configured proxy")
assertEqual(request_history[#request_history - 1].global_proxy, "http://192.168.1.1:3128",
"HTTP upgrade leaves KOReader's global proxy intact")
assertEqual(request_history[#request_history].proxy, nil,
"HTTPS upgrade keeps the proxy URL out of LuaSocket's request")
assertEqual(type(request_history[#request_history].create), "function",
"HTTPS upgrade uses a CONNECT socket")
assertEqual(request_history[#request_history].global_proxy, nil,
"HTTPS upgrade clears LuaSocket's global HTTP proxy")
assertEqual(mock_http.PROXY, "http://192.168.1.1:3128",
"HTTPS upgrade restores LuaSocket's global HTTP proxy")
mock_http_queue = nil
mock_http.PROXY = nil
mock_settings.http_proxy_enabled = false
assertEqual(http_client:getProxy(), nil,
"G_reader_settings proxy is ignored when disabled")
G_reader_settings = nil
print("bookorbit_api_test.lua: ok")
@@ -0,0 +1,101 @@
local url = {
parse = function(value)
local scheme, rest = value:match("^(%w+)://(.+)$")
if not scheme then return nil end
local credentials = rest:match("^([^@]+)@")
if credentials then rest = rest:sub(#credentials + 2) end
local host, port = rest:match("^([^:]+):(%d+)$")
return {
scheme = scheme,
host = host,
port = port,
user = credentials and credentials:match("^([^:]+):"),
password = credentials and credentials:match(":(.+)$"),
}
end,
unescape = function(value) return value end,
}
local mime = { b64 = function() return "encoded-credentials" end }
local response
local raw
local tls
package.loaded["socket"] = {
tcp = function()
raw = { sent = "", position = 1 }
function raw:settimeout(value)
self.timeout = value
return 1
end
function raw:connect(host, port)
self.host, self.port = host, port
return 1
end
function raw:send(value)
self.sent = self.sent .. value
return #value
end
function raw:receive()
if self.position > #response then return nil, "closed" end
local char = response:sub(self.position, self.position)
self.position = self.position + 1
return char
end
function raw:close() return 1 end
return raw
end,
}
package.loaded["socket.url"] = url
package.loaded["mime"] = mime
package.loaded["ssl.https"] = { TIMEOUT = 10 }
package.loaded["ssl"] = {
wrap = function()
tls = setmetatable({}, { __index = {
sni = function(self, host) self.host = host end,
settimeout = function(self, value) self.timeout = value end,
dohandshake = function() return 1 end,
send = function() return 1 end,
receive = function() return "ok" end,
close = function() return 1 end,
} })
return tls
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local Proxy = require("bookorbit_proxy")
local function assertEqual(actual, expected, label)
if actual ~= expected then error(label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) end
end
response = "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test\r\n\r\n"
local conn = Proxy.connectSocket("http://reader:password@127.0.0.1:3128")()
conn:settimeout(10)
assertEqual(conn:connect("books.example", 443), 1, "CONNECT succeeds")
assertEqual(raw.host, "127.0.0.1", "connects to proxy host")
assertEqual(raw.port, 3128, "connects to proxy port")
assertEqual(raw.sent:match("^CONNECT books%.example:443 HTTP/1%.1"),
"CONNECT books.example:443 HTTP/1.1", "sends tunnel request")
assertEqual(raw.sent:find("Proxy-Authorization: Basic ", 1, true) ~= nil, true,
"sends proxy credentials only in tunnel request")
assertEqual(raw.sent:find("x-auth-key", 1, true), nil, "never sends BookOrbit credentials in CONNECT")
assertEqual(tls.host, "books.example", "uses target host for TLS SNI")
assertEqual(tls.timeout, 10, "sets TLS timeout")
assertEqual(conn:receive(), "ok", "forwards socket methods to TLS")
response = "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"
conn = Proxy.connectSocket("http://127.0.0.1:3128")()
local success, err = conn:connect("books.example", 443)
assertEqual(success, nil, "rejects failed CONNECT")
assertEqual(err, "proxy_connect_failed", "reports failed CONNECT")
assertEqual(raw.sent:find("Proxy-Authorization", 1, true), nil,
"does not invent a proxy authorization header")
response = "HTTP/1.1 200 Connection Established\r\nX-Large: " .. string.rep("x", 8192)
conn = Proxy.connectSocket("http://127.0.0.1:3128")()
success, err = conn:connect("books.example", 443)
assertEqual(success, nil, "rejects oversized proxy response")
assertEqual(err, "proxy_response_too_large", "bounds proxy headers")
print("bookorbit_proxy_test.lua: ok")