Skip to content

Instantly share code, notes, and snippets.

@dionrhys
Created August 14, 2017 16:57
Show Gist options
  • Save dionrhys/b6c1fa4488ba408b84e242c276757e5e to your computer and use it in GitHub Desktop.
Save dionrhys/b6c1fa4488ba408b84e242c276757e5e to your computer and use it in GitHub Desktop.
Simple Quake 3 "Update" (MOTD) Server Implementation
--------------------------------------------------
-- Simple Quake 3 Update Server Implementation
-- Author: Dion Williams
-- Copyright: 2013 Dion Williams
-- Licence: MIT/X11; Please see README.md
-- Requires: LuaSocket, LuaLogging
--------------------------------------------------
local _VERSION = "LuaQ3Update r2"
-- CONFIGURATION
local hostname = "*" -- "*" means any interface
local port = 29061
-- Load library namespaces
local logging = require("logging")
local socket = require("socket")
local logger = require("logging.console")("%date [%level] %message\n")
logger:setLevel(logging.DEBUG)
-- Setup the local UDP server socket
local function setupSocket()
if mysock ~= nil then
-- Release the socket and binding immediately instead of waiting for GC
mysock:close()
mysock = nil
end
mysock = assert( socket.udp() )
assert( mysock:setsockname(hostname, port) )
assert( mysock:settimeout(1) )
logger:info("Listening socket established on " .. hostname .. ":" .. port)
end
-- Initialize the psuedo-random number generator
local function setupRNG()
math.randomseed(os.time())
end
-- Return a table of tokens for the given Q3 command string
local function cmdTokenize(text)
local i = 1
local t = {}
while true do
-- Skip white-space
i = text:find("%S", i)
-- If end is reached, return the table
if i == nil then
return t
end
-- Check if this is a quoted argument
local thisarg = text:match("^\"[^\"]*\"?", i)
if thisarg ~= nil then
i = i + thisarg:len()
-- Strip the quotes from the argument
table.insert(t, (thisarg:gsub("[\"]", "")))
else
-- Else try to parse an unquoted argument
thisarg = text:match("[^\"%s]+", i)
if thisarg ~= nil then
i = i + thisarg:len()
table.insert(t, thisarg)
end
end
end
end
-- Command Tokenizer tests
local function testCmdTokenize()
local cmds = cmdTokenize("foo bar \"abc\" \"test test\" \"herp\"derp \"one\"\"two\" \"broccoli")
assert(cmds[1] == "foo", "TEST #1: Unquoted argument")
assert(cmds[2] == "bar", "TEST #2: Unquoted argument")
assert(cmds[3] == "abc", "TEST #3: Quoted non-spaced argument")
assert(cmds[4] == "test test", "TEST #4: Quoted spaced argument")
assert(cmds[5] == "herp", "TEST #5: Quoted argument joined to unquoted argument")
assert(cmds[6] == "derp", "TEST #6: Unquoted argument joined to quoted argument")
assert(cmds[7] == "one", "TEST #7: Quoted argument joined to quoted argument")
assert(cmds[8] == "two", "TEST #8: Quoted argument joined to quoted argument")
assert(cmds[9] == "broccoli", "TEST #9: Semi-quoted argument cut-off at the end")
print("Command Tokenizer tests completed")
end
-- Return the key/value pairs of an Info String as a table
local function infoStringToTable(text)
local i = 1
local t = {}
while true do
-- Seek to the first backslash
i = text:find("\\", i)
-- If end is reached, return the table
if i == nil then
return t
end
-- Read in this key
local key = text:match("^\\[^\\]*", i)
if key ~= nil then
i = i + key:len()
-- Strip the preceding backslash
key = key:sub(2)
-- Now read the value for this key
local value = text:match("^\\[^\\]*", i)
if value ~= nil then
i = i + value:len()
-- Strip the preceding backslash
value = value:sub(2)
-- Add it into the table
t[key] = value
else
-- Malformed sequence, return nil
return nil
end
else
-- Malformed sequence, return nil
return nil
end
end
end
-- Info Table tests
local function testInfoStringToTable()
local t = infoStringToTable("\\a_key\\a_value")
assert(t["a_key"] ~= nil, "TEST #1: a_key doesn't exist")
assert(t["a_key"] == "a_value", "TEST #2: a_key's value isn't \"a_value\"")
t = infoStringToTable("\\\\test")
assert(t[""] ~= nil, "TEST #3: Empty key doesn't exist")
assert(t[""] == "test", "TEST #4: Empty key's value isn't \"test\"")
t = infoStringToTable("\\foo\\")
assert(t["foo"] ~= nil, "TEST #3: foo doesn't exist")
assert(t["foo"] == "", "TEST #4: foo's value isn't \"\"")
t = infoStringToTable("\\k1\\v1\\k2")
assert(t == nil, "TEST #7: Orphaned key didn't abort parsing")
t = infoStringToTable("")
assert(t ~= nil, "TEST #8: Empty info string didn't return empty table")
print("Info Table tests completed")
end
-- Print keys/values of a table with fixed columns
local function printTable(t)
local biglen = 0
if t ~= nil then
for k,v in pairs(t) do
if biglen < string.len(k) then
biglen = string.len(k)
end
end
biglen = biglen + 4
for k,v in pairs(t) do
local len = string.len(k)
local line = k .. string.rep(" ", biglen-len) .. v
print(line)
end
else
print("nil")
end
end
-- Read a line of packet text
local function parseMsgLine(msg, i)
i = i or 1
local line = msg:match("^[^%z\255\n]*", i)
if line ~= nil then
-- Replace all '%' characters with '.' (only because JKA does this)
line = line:gsub("[%%]", ".")
return line
else
return nil
end
end
-- Handle incoming getmotd request packets from clients
local function incomingGetMotd(cmd, fromaddr, fromport)
if #cmd < 2 then
return
end
local infotable = infoStringToTable(cmd[2])
if infotable == nil then
return
end
if infotable["challenge"] == nil then
logger:debug(fromaddr .. ":" .. fromport .. " GETMOTD, no challenge given")
else
logger:debug(fromaddr .. ":" .. fromport .. " GETMOTD, challenge = '" .. infotable["challenge"] .. "'")
mysock:sendto("\255\255\255\255motd \"\\challenge\\" .. infotable["challenge"] .. "\\motd\\Source FAQ & Interesting Article - JKHub.org\"", fromaddr, fromport)
end
end
local function main()
logger:info(_VERSION .. " starting up...")
setupRNG()
setupSocket()
while true do
local msg,fromaddr,fromport = mysock:receivefrom()
-- Ignore any errors/timeouts
if msg ~= nil then
-- Ensure packet begins with \255\255\255\255 and at least one more letter,
-- and doesn't contain a NUL character
if msg:find("^\255\255\255\255%a") and not msg:find("%z") then
--print("Valid packet from " .. fromaddr .. ":" .. fromport .. ", Length: " .. msg:len())
msg = msg:sub(5)
-- Parse the first line as a command string
local line = parseMsgLine(msg)
if line ~= nil then
local cmd = cmdTokenize(line)
if #cmd >= 1 then
if cmd[1]:lower() == "getmotd" then
incomingGetMotd(cmd, fromaddr, fromport)
end
end
end
end
end
end
end
-- Run!
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment