-
Notifications
You must be signed in to change notification settings - Fork 3
Open
Description
Implement a query endpoint. Support cache to reduce the number of queries sent to the game server.
Endpoint usage: https://mydomain.com/srcds_query?server=gameserver.com:27015
Install dependencies
opm get xiaooloong/lua-resty-sourcequeryMay need to check out xiaooloong/lua-resty-bzlib#1 to fix bz2 not found problem.
http{} block
...
# Explicitly create the dictionary
lua_shared_dict steam_cache 10m;
server {
...
# Define endpoint
location = /srcds_query {
add_header Access-Control-Allow-Origin *;
content_by_lua_file /ABSOLUTE/PATH/TO/srcds_query.lua;
}
}
LUA implementation
Save as srcds_query.lua:
local sq = require "sourcequery"
local cjson = require "cjson.safe"
local restylock = require "resty.lock"
local dns = require "resty.dns.resolver"
-- Constants
local TTL_SECONDS = 3
local QUERY_TIMEOUT_S = 2.0
local LOCK_TIMEOUT_S = QUERY_TIMEOUT_S + 1
-- val must be the table returned by make_value()
local function reply_json(cached, val)
ngx.status = val.code or 200
ngx.header["Content-Type"] = "application/json; charset=utf-8"
ngx.say(cjson.encode({
ok = val.ok,
cached = cached and true or false,
timestamp = val.timestamp,
code = val.code,
msg = val.msg,
result = val.result,
}))
end
local function log_err(...) ngx.log(ngx.ERR, ...) end
-- build a cache payload & response body
local function make_value(ok, code, msg, result)
return {
ok = ok,
timestamp = ngx.now(),
code = code,
msg = msg or nil,
result = result or ngx.null,
}
end
-- ===== utils =====
local function is_ipv4(s)
local a,b,c,d = s:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
a,b,c,d = tonumber(a),tonumber(b),tonumber(c),tonumber(d)
if not (a and b and c and d) then return false end
return (a>=0 and a<=255) and (b>=0 and b<=255) and (c>=0 and c<=255) and (d>=0 and d<=255)
end
local function parse_server(s)
if not s or s == "" then return nil, "missing server parameter" end
local host, port = s:match("^([^:]+):?(%d*)$")
if not host or host == "" then return nil, "invalid server parameter" end
if port == "" then port = 27015 else port = tonumber(port) end
if not port or port < 1 or port > 65535 then return nil, "invalid port" end
return { host = host, port = port }, nil
end
local function resolve_ipv4(host)
if is_ipv4(host) then return host, nil end
local r, err = dns:new{
nameservers = { "127.0.0.53", "1.1.1.1", "8.8.8.8" },
retrans = 2, timeout = 2000
}
if not r then return nil, "dns init failed: " .. (err or "unknown") end
local answers, qerr = r:query(host, { qtype = r.TYPE_A })
if not answers then return nil, "dns query failed: " .. (qerr or "unknown") end
if answers.errcode then
return nil, ("dns error %s: %s"):format(answers.errcode, answers.errstr or "")
end
for _, ans in ipairs(answers) do
if ans.address and is_ipv4(ans.address) then
return ans.address, nil
end
end
return nil, "no A record found"
end
-- ===== main =====
-- ensure in nginx.conf (http {}): lua_shared_dict steam_cache 10m;
local dict = ngx.shared.steam_cache
-- parse input
local server_arg = ngx.var.arg_server
local srv, perr = parse_server(server_arg)
if not srv then
return reply_json(false, make_value(false, 400, perr, nil))
end
-- resolve to IPv4
local ip, derr = resolve_ipv4(srv.host)
if not ip then
log_err("DNS resolve failed for ", srv.host, ": ", derr or "unknown")
-- not caching DNS failures by default; flip if desired
return reply_json(false, make_value(false, 400, "dns resolve failed: " .. (derr or "unknown"), nil))
end
local key = ip .. ":" .. tostring(srv.port)
-- 1) cache hit (success or failure)
local cached = dict:get(key)
if cached then
local obj = cjson.decode(cached)
if obj then
return reply_json(true, obj)
end
end
-- 2) acquire lock (wait; prevents thundering herd)
local lock = restylock:new("steam_cache", { timeout = LOCK_TIMEOUT_S })
local elapsed, lerr = lock:lock("lock:" .. key)
if not elapsed then
-- try cache again before giving up
local cached2 = dict:get(key)
if cached2 then
local obj2 = cjson.decode(cached2)
if obj2 then
return reply_json(true, obj2)
end
end
return reply_json(false, make_value(false, 504, "upstream busy (lock timeout): " .. (lerr or "unknown"), nil))
end
-- 3) double-check cache inside lock
local cached3 = dict:get(key)
if cached3 then
local obj3 = cjson.decode(cached3)
if obj3 then
lock:unlock()
return reply_json(true, obj3)
end
end
-- 4) query (cache both success and failure)
local q, qerr = sq:new(ip, srv.port, math.floor(QUERY_TIMEOUT_S*1000), "source")
if not q then
local val = make_value(false, 500, "init failed: " .. (qerr or "unknown"), nil)
dict:set(key, cjson.encode(val), TTL_SECONDS)
lock:unlock()
return reply_json(false, val)
end
local info = q:getinfo()
if not info then
local val = make_value(false, 502, "getinfo failed: " .. (info or "unknown"), nil)
dict:set(key, cjson.encode(val), TTL_SECONDS)
lock:unlock()
return reply_json(false, val)
end
-- Map to Steam WebAPI GetServerList style
local mapped_info = {
addr = (ip and srv.port) and (ip .. ":" .. tostring(srv.port)) or ngx.null,
gameport = info.Port,
steamid = info.SteamID,
name = info.Name,
appid = appid,
gamedir = info.Folder,
version = info.Version,
product = info.Folder,
-- region = <not available from A2S_INFO>,
players = info.Players,
max_players = info.MaxPlayers,
bots = info.Bots,
map = info.Map,
secure = secure,
dedicated = dedicated,
os = info.Environment, -- 'l' (linux) / 'w' (windows)
gametype = info.Keywords, -- a.k.a. "Keywords" e.g. coop,insecure
}
-- 5) cache success and respond
local val = make_value(true, 200, nil, mapped_info)
dict:set(key, cjson.encode(val), TTL_SECONDS)
lock:unlock()
return reply_json(false, val)Metadata
Metadata
Assignees
Labels
No labels