Skip to content

[Example] A query endpoint with cache support #1

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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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