feat: initial commit

This commit is contained in:
Iain Learmonth 2025-04-27 17:20:02 +01:00
commit 6179dea246
19 changed files with 693 additions and 0 deletions

9
src/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM openresty/openresty:alpine-fat
RUN /usr/local/openresty/luajit/bin/luarocks install lua-resty-http
RUN /usr/local/openresty/luajit/bin/luarocks install lua-resty-cookie
RUN /usr/local/openresty/luajit/bin/luarocks install lua-resty-iputils
COPY default.conf /etc/nginx/conf.d/default.conf
COPY env.main /etc/nginx/conf.d/env.main
COPY lua/* /opt/sitelen-tu/

42
src/default.conf Normal file
View file

@ -0,0 +1,42 @@
error_log /dev/stdout;
lua_shared_dict jasima_cache 20m;
lua_package_path "/opt/sitelen-tu/?.lua;;";
resolver 127.0.0.11 valid=60 ipv6=off;
upstream origin {
server 127.0.0.1;
balancer_by_lua_file /opt/sitelen-tu/balancer.lua;
}
server {
listen 80;
server_name localhost default;
location / {
# These variables are set in the access_by_lua stage
# TODO: These might be better to set with a set_by_lua_block
set $jasima_host "fallback.invalid";
set $jasima_host_header "fallback.invalid";
set $jasima_host_ssl "fallback.invalid";
access_by_lua_file /opt/sitelen-tu/access.lua;
proxy_pass https://origin;
proxy_ssl_server_name on;
proxy_ssl_name $jasima_host_ssl;
proxy_set_header Accept-Encoding "";
proxy_set_header Host $jasima_host_header;
sub_filter_once off;
sub_filter_types text/html text/css text/xml application/javascript application/rss+xml application/atom+xml application/vnd.mpegurl application/x-mpegurl;
sub_filter 'http://$jasima_host' '/';
sub_filter 'https://$jasima_host' '/';
sub_filter '//$jasima_host' '/';
sub_filter 'REWRITE_JASIMA_HOST_PLACEHOLDER' $jasima_host;
header_filter_by_lua_file /opt/sitelen-tu/header_filter.lua;
body_filter_by_lua_file /opt/sitelen-tu/body_filter.lua;
}
}

1
src/env.main Normal file
View file

@ -0,0 +1 @@
env JASIMA_MATOMO_HOST;

72
src/lua/access.lua Normal file
View file

@ -0,0 +1,72 @@
local http = require "resty.http"
local config = require "config"
local geo = require "geo"
local utils = require "utils"
local jasima_host = config.get_jasima_host()
ngx.ctx.jasima_host = jasima_host
if not jasima_host then
return ngx.exit(400)
end
local err
ngx.ctx.jasima_config, err = config.load_config(jasima_host)
if err then
ngx.status = 500
ngx.log(ngx.ERR, "Could not load config: " .. err)
return ngx.exit(500)
end
if not ngx.ctx.jasima_config then
ngx.status = 403
ngx.log(ngx.ERR, "Requested a canonical host that has no configuration specified: " .. jasima_host)
ngx.exit(403)
end
local country = geo.viewer_country()
if not ngx.ctx.jasima_config.geo_redirect_disable and not geo.needs_mirror(country) then
local request_uri = ngx.var.request_uri
local new_url = "https://" .. jasima_host .. request_uri
return ngx.redirect(new_url, ngx.HTTP_MOVED_TEMPORARILY)
end
-- Get jasima_pool (not critical)
local jasima_pool = config.get_jasima_pool()
ngx.ctx.jasima_pool_map, err = config.load_pool_mapping(jasima_pool)
if err then
ngx.status = 500
ngx.log(ngx.WARN, "Could not load pool mapping: " .. err)
return ngx.exit(500)
end
local headers = ngx.req.get_headers()
-- Remove the headers that should not be proxied to the origin
for k, v in pairs(headers) do
if k:lower():match("^jasima%-") then
ngx.req.clear_header(k)
end
end
-- Add additional headers that have been specified in the configuration
if ngx.ctx.jasima_config.headers then
for k, v in pairs(ngx.ctx.jasima_config.headers) do
ngx.req.set_header(k, v)
end
end
-- Look up the IP to connect to the origin
local host_connect = ngx.ctx.jasima_config.host_connect or jasima_host
local upstream_ips = utils.resolve_origin(host_connect)
ngx.ctx.upstream_ips = utils.filter_bogons(upstream_ips)
if #ngx.ctx.upstream_ips == 0 then
ngx.log(ngx.ERR, "no A records found")
return ngx.exit(500)
end
-- Set the nginx host variables
ngx.var.jasima_host = jasima_host
ngx.var.jasima_host_header = ngx.ctx.jasima_config.host_header or jasima_host
ngx.var.jasima_host_ssl = ngx.ctx.jasima_config.host_ssl or jasima_host

24
src/lua/balancer.lua Normal file
View file

@ -0,0 +1,24 @@
local balancer = require "ngx.balancer"
local upstream_ips = ngx.ctx.upstream_ips
if not upstream_ips then
ngx.log(ngx.ERR, "No upstream IPs in context")
return ngx.exit(500)
end
ngx.ctx.balancer_try = (ngx.ctx.balancer_try or 0) + 1
local try_index = ngx.ctx.balancer_try
if try_index > #upstream_ips then
ngx.log(ngx.ERR, "All upstream IPs tried, none succeeded")
return ngx.exit(500)
end
local ip = upstream_ips[try_index]
ngx.log(ngx.INFO, "Trying upstream IP: ", ip)
local ok, err = balancer.set_current_peer(ip, 443)
if not ok then
ngx.log(ngx.ERR, "failed to set peer: ", err)
return ngx.exit(500)
end

63
src/lua/body_filter.lua Normal file
View file

@ -0,0 +1,63 @@
local function matomo_tracking_code(site_id)
return [=[
<!-- Matomo -->
<script>
var _paq = window._paq = window._paq || [];
var p = "https://REWRITE_JASIMA_HOST_PLACEHOLDER" + window.location.pathname;
_paq.push(["setCustomUrl", p]);
_paq.push(["setExcludedQueryParams", ["roomName","account","accountnum","address","address1","address2","address3","addressline1","addressline2","adres","adresse","age","alter","auth","authpw","bic","billingaddress","billingaddress1","billingaddress2","calle","cardnumber","cc","ccc","cccsc","cccvc","cccvv","ccexpiry","ccexpmonth","ccexpyear","ccname","ccnumber","cctype","cell","cellphone","city","clientid","clientsecret","company","consumerkey","consumersecret","contrasenya","contrase\u00f1a","creditcard","creditcardnumber","cvc","cvv","dateofbirth","debitcard","direcci\u00f3n","dob","domain","ebost","email","emailaddress","emailadresse","epos","epost","eposta","exp","familyname","firma","firstname","formlogin","fullname","gender","geschlecht","gst","gstnumber","handynummer","has\u0142o","heslo","iban","ibanaccountnum","ibanaccountnumber","id","identifier","indirizzo","kartakredytowa","kennwort","keyconsumerkey","keyconsumersecret","konto","kontonr","kontonummer","kredietkaart","kreditkarte","kreditkort","lastname","login","mail","mobiili","mobile","mobilne","nachname","name","nickname","false","osoite","parole","pass","passord","password","passwort","pasword","paswort","paword","phone","pin","plz","postalcode","postcode","postleitzahl","privatekey","publickey","pw","pwd","pword","pwrd","rue","secret","secretq","secretquestion","shippingaddress","shippingaddress1","shippingaddress2","socialsec","socialsecuritynumber","socsec","sokak","ssn","steuernummer","strasse","street","surname","swift","tax","taxnumber","tel","telefon","telefonnr","telefonnummer","telefono","telephone","token","token_auth","tokenauth","t\u00e9l\u00e9phone","ulica","user","username","vat","vatnumber","via","vorname","wachtwoord","wagwoord","webhooksecret","website","zip","zipcode"]]);
_paq.push(["trackPageView", p]);
_paq.push(["enableLinkTracking"]);
(function() {
var u="//]=] .. os.getenv("JASIMA_MATOMO_HOST") .. [=[/";
_paq.push(["setTrackerUrl", u+"matomo.php"]);
_paq.push(["setSiteId", "]=] .. tostring(ngx.ctx.jasima_config.matomo_site_id) .. [=["]);
var d=document, g=d.createElement("script"), s=d.getElementsByTagName("script")[0];
g.async=true; g.src=u+"matomo.js"; s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo Code -->
</body>
]=]
end
local function rewrite_body(body, eof)
if not ngx.ctx.jasima_pool_map or ngx.ctx.jasima_config.rewrite_disable then
return body
end
for from, to in pairs(ngx.ctx.jasima_pool_map) do
if ngx.ctx.jasima_config.rewrite_case_insensitive then
local pattern = ngx.re.escape(from)
body = ngx.re.gsub(body, pattern, to, "ijo")
else
-- We expect that str:match("^[%w%-%.]+$") ~= nil
local pattern = from:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") -- escape Lua patterns
body = body:gsub(pattern, to)
end
end
if eof and ngx.ctx.jasima_config.matomo_site_id then
body = body:gsub("</body>", matomo_tracking_code(ngx.ctx.jasima_config.matomo_site_id))
-- TODO: Ensure that tracking code was added when it's HTML, but only for HTML
end
return body
end
if ngx.ctx.rewriting then
local chunk = ngx.arg[1]
local eof = ngx.arg[2]
ngx.ctx.buffered = (ngx.ctx.buffered or "") .. (chunk or "")
if #ngx.ctx.buffered > 5 * 1024 * 1024 and not eof then
-- Don't just consume memory forever
ngx.arg[1] = rewrite_body(ngx.ctx.buffered, eof) -- We still do our best
ngx.ctx.rewriting = false
return
end
if eof then
ngx.arg[1] = rewrite_body(ngx.ctx.buffered, eof)
else
ngx.arg[1] = nil
end
end

95
src/lua/config.lua Normal file
View file

@ -0,0 +1,95 @@
local ck = require "resty.cookie"
local cjson = require "cjson.safe"
local redis = require "resty.redis"
local _M = {}
function _M.get_jasima_host()
local headers = ngx.req.get_headers()
if headers["Jasima-Host"] then
return headers["Jasima-Host"]
end
local cookie, err = ck:new()
if not cookie then
ngx.log(ngx.ERR, "failed to get cookie: ", err)
return nil
end
local jasima_cookie, err = cookie:get("jasima_host")
if jasima_cookie then
return jasima_cookie
elseif err then
ngx.log(ngx.ERR, "failed to get jasima_host cookie: ", err)
end
return nil
end
function _M.get_jasima_pool()
local headers = ngx.req.get_headers()
if headers["Jasima-Pool"] then
return headers["Jasima-Pool"]
end
local cookie, err = ck:new()
if not cookie then
ngx.log(ngx.ERR, "failed to get cookie: ", err)
return nil
end
local jasima_cookie, err = cookie:get("jasima_pool")
if jasima_cookie then
return jasima_cookie
elseif err then
ngx.log(ngx.ERR, "failed to get jasima_pool cookie: ", err)
end
return nil
end
function _M.load_pool_mapping(pool_name)
if not pool_name then pool_name = "public" end
local cache = ngx.shared.jasima_cache
local cache_key = "poolmap:" .. pool_name
local cached = cache:get(cache_key)
if cached then return cjson.decode(cached) end
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("redis", 6379)
if not ok then return nil, "Redis connect failed: " .. err end
local key = "jasima:poolmap:" .. pool_name
local res, err = red:get(key)
if not res or res == ngx.null then return nil, "No pool mapping found" end
red:set_keepalive(10000, 100)
cache:set(cache_key, res, 60)
return cjson.decode(res)
end
function _M.load_config(jasima_host)
local cache = ngx.shared.jasima_cache
local cache_key = "config:" .. jasima_host
local cached = cache:get(cache_key)
if cached then return cjson.decode(cached) end
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("redis", 6379)
if not ok then return nil, "Redis connect failed: " .. err end
local key = "jasima:config:" .. jasima_host
local res, err = red:get(key)
if not res or res == ngx.null then return nil, "No config in Redis" end
red:set_keepalive(10000, 100)
cache:set(cache_key, res, 60)
return cjson.decode(res)
end
return _M

49
src/lua/geo.lua Normal file
View file

@ -0,0 +1,49 @@
local _M = {}
function _M.viewer_country()
-- Maybe the CDN was nice and gave this to us
local country = ngx.var.http_cloudfront_viewer_country or -- AWS CloudFront
ngx.var.http_fastly_client_country or -- Fastly
ngx.var.http_cf_ipcountry -- CloudFlare
if not country then return nil end
return country:upper()
-- TODO: Fallback to GeoIP lookup
end
function _M.needs_mirror(country)
if not country then return true end
-- TODO: Allow override of safe countries in host config
local safe_countries = {
US = true, -- United States
GB = true, -- United Kingdom
IE = true, -- Ireland
FR = true, -- France
DE = true, -- Germany
NL = true, -- Netherlands
BE = true, -- Belgium
CH = true, -- Switzerland
AT = true, -- Austria
LU = true, -- Luxembourg
LI = true, -- Liechtenstein
MC = true, -- Monaco
AD = true, -- Andorra
ES = true, -- Spain
PT = true, -- Portugal
IT = true, -- Italy
SM = true, -- San Marino
VA = true, -- Vatican City
MT = true, -- Malta
NO = true, -- Norway
SE = true, -- Sweden
DK = true, -- Denmark
FI = true, -- Finland
IS = true -- Iceland
}
if safe_countries[country] then return false end
return true
end
return _M

14
src/lua/header_filter.lua Normal file
View file

@ -0,0 +1,14 @@
if ngx.header["Content-Type"] then
local content_type = ngx.header["Content-Type"]
if content_type:find("text/html") or
content_type:find("text/css") or
content_type:find("text/xml") or
content_type:find("application/javascript") or
content_type:find("application/rss+xml") or
content_type:find("application/atom+xml") or
content_type:find("application/vnd.mpegurl") or
content_type:find("application/x-mpegurl") then
ngx.ctx.rewriting = true
ngx.header["Content-Length"] = nil
end
end

75
src/lua/utils.lua Normal file
View file

@ -0,0 +1,75 @@
local resolver = require "resty.dns.resolver"
local iputils = require "resty.iputils"
iputils.enable_lrucache()
local bogon_ips = iputils.parse_cidrs({
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.0.2.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"240.0.0.0/4"
})
local _M = {}
function _M.resolve_origin(origin_host)
local cache = ngx.shared.jasima_cache
local cache_key = "upstream_ips:" .. origin_host
local cached = cache:get(cache_key)
if cached then return cached end
local r, err = resolver:new{
nameservers = {"8.8.8.8", "8.8.4.4"},
retrans = 5,
timeout = 2000,
}
if not r then
ngx.log(ngx.ERR, "failed to instantiate resolver: ", err)
return ngx.exit(500)
end
local answers, err = r:query(origin_host, {qtype = r.TYPE_A})
if not answers then
ngx.log(ngx.ERR, "failed to query: ", err)
return ngx.exit(500)
end
if answers.errcode then
ngx.log(ngx.ERR, "DNS error code: ", answers.errcode, ": ", answers.errstr)
return ngx.exit(500)
end
local origin_ips = {}
for _, ans in pairs(answers) do
if ans.address then
table.insert(origin_ips, ans.address)
end
end
cache:set(cache_key, origin_ips, 60)
return origin_ips
end
function _M.filter_bogons(ip_list)
local filtered = {}
for _, ip in ipairs(ip_list) do
if not iputils.ip_in_cidrs(ip, bogon_ips) then
table.insert(filtered, ip)
end
end
return filtered
end
return _M