Skip to content

[Example] A query endpoint with cache support #1

@rikka0w0

Description

@rikka0w0

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-sourcequery

May 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

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions