├── .gitignore ├── src ├── redbean-badge.png ├── ip.lua ├── score.lua ├── .lua │ ├── log_request_origin.lua │ └── board_worker.lua ├── user.html ├── about.html ├── claim.lua ├── .init.lua └── index.html ├── schema.sql ├── turfwar.service ├── README.md ├── Makefile └── deploy.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite3* 2 | *.log 3 | *.com 4 | -------------------------------------------------------------------------------- /src/redbean-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shamblesides/turfwar/HEAD/src/redbean-badge.png -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "land" ( 2 | ip INTEGER PRIMARY KEY, 3 | nick TEXT NOT NULL 4 | ); 5 | 6 | CREATE INDEX "land_by_name" ON "land" (nick); 7 | 8 | CREATE TABLE cache ( 9 | key TEXT PRIMARY KEY, 10 | val TEXT NOT NULL 11 | ); 12 | -------------------------------------------------------------------------------- /turfwar.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=TurfWar web server 3 | 4 | [Service] 5 | Restart=on-failure 6 | WorkingDirectory=/srv 7 | ExecStart=/srv/turfwar -C /etc/letsencrypt/live/ipv4.games/fullchain.pem -K /etc/letsencrypt/live/ipv4.games/privkey.pem -p 80 -p 443 -s -L /srv/access.log 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IPv4 Turf War 2 | 3 | It's a game. There's a little webserver. The goal is to access it from 4 | as many IP addresses as you can. 5 | 6 | Get creative! 7 | 8 | Canonical instance is here: https://ipv4.games 9 | 10 | ### How it works 11 | 12 | It's a [redbean server](https://redbean.dev/). 13 | 14 | ```sh 15 | make dev 16 | ``` 17 | -------------------------------------------------------------------------------- /src/ip.lua: -------------------------------------------------------------------------------- 1 | if not EnforceMethod({'GET', 'HEAD'}) then return end 2 | if not EnforceParams({}) then return end 3 | 4 | local ip = GetRemoteAddr() 5 | 6 | if ip then 7 | SetHeader("Cache-Control", "private; max-age=3600; must-revalidate") 8 | SetHeader("Content-Type", "text/plain") 9 | Write(FormatIp(ip)) 10 | return 11 | else 12 | return ClientError("IPv4 Games only supports IPv4 right now") 13 | end 14 | -------------------------------------------------------------------------------- /src/score.lua: -------------------------------------------------------------------------------- 1 | if not EnforceMethod({'GET', 'HEAD'}) then return end 2 | if not EnforceParams({}) then return end 3 | 4 | local stmt, err = db:prepare[[SELECT val FROM cache WHERE key = '/board']] 5 | 6 | if not stmt then 7 | return InternalError("Failed to prepare board query: %s / %s" % {err or "(null)", db:errmsg()}) 8 | elseif stmt:step() ~= sqlite3.ROW then 9 | stmt:finalize() 10 | return InternalError("Internal error (stmt:step): %s" % {db:errmsg()}) 11 | end 12 | 13 | SetHeader("Content-Type", "application/json") 14 | SetHeader("Content-Encoding", "deflate") 15 | SetHeader("Cache-Control", "public, max-age=5, must-revalidate") 16 | Write(stmt:get_value(0)) 17 | 18 | stmt:finalize() 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REDBEAN_VERSION = redbean-asan-2.0.19.com 2 | 3 | default: server.com 4 | 5 | .PHONY: clean 6 | clean: 7 | rm -f *.com db.sqlite3* *.log 8 | 9 | .PHONY: dev 10 | dev: server.com db.sqlite3 11 | ./server.com -D src 12 | 13 | .PHONY: update 14 | update: 15 | cd src && zip -r ../server.com . 16 | 17 | server.com: $(REDBEAN_VERSION) $(shell find src) 18 | cp $(REDBEAN_VERSION) server.com 19 | cd src && zip -r ../server.com . 20 | 21 | $(REDBEAN_VERSION): 22 | wget https://redbean.dev/$(REDBEAN_VERSION) -O $(REDBEAN_VERSION) && chmod +x $(REDBEAN_VERSION) 23 | 24 | sqlite3.com: 25 | wget https://redbean.dev/sqlite3.com -O sqlite3.com && chmod +x sqlite3.com 26 | 27 | db.sqlite3: sqlite3.com 28 | ./sqlite3.com db.sqlite3 < schema.sql 29 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | DOMAIN=${1:-ipv4.games} 5 | 6 | echo "Building" 7 | make 8 | 9 | echo "Copy binary to server" 10 | scp ./server.com $DOMAIN:/tmp/turfwar 11 | 12 | echo "Assimilate cosmopolitan binary" 13 | ssh $DOMAIN /tmp/turfwar --assimilate 14 | 15 | echo "Stop systemd service" 16 | ssh $DOMAIN sudo systemctl stop turfwar.service 17 | 18 | echo "Replacing old binary" 19 | ssh $DOMAIN sudo cp /tmp/turfwar /srv/turfwar 20 | 21 | echo "Setting low-port-binding permissions on binary" 22 | ssh $DOMAIN sudo setcap CAP_NET_BIND_SERVICE=+eip /srv/turfwar 23 | 24 | echo "Enabling and starting systemd service" 25 | ssh $DOMAIN sudo systemctl enable turfwar.service 26 | ssh $DOMAIN sudo systemctl restart turfwar.service 27 | 28 | echo "OK!" 29 | -------------------------------------------------------------------------------- /src/.lua/log_request_origin.lua: -------------------------------------------------------------------------------- 1 | local maxmind = require "maxmind" 2 | 3 | local geodb, asndb 4 | 5 | if unix.stat('/usr/local/share/maxmind') then 6 | geodb = maxmind.open('/usr/local/share/maxmind/GeoLite2-City.mmdb') 7 | asndb = maxmind.open('/usr/local/share/maxmind/GeoLite2-ASN.mmdb') 8 | else 9 | Log(kLogWarn, "Maxmind database missing") 10 | end 11 | 12 | local function GetAsn(ip) 13 | local as = asndb:lookup(ip) 14 | if as then 15 | local asnum = as:get("autonomous_system_number") 16 | local asorg = as:get("autonomous_system_organization") 17 | if asnum and asorg then 18 | return '%s[%d]' % {asorg, asnum} 19 | end 20 | end 21 | return 'unknown' 22 | end 23 | 24 | local function GetGeo(ip) 25 | local g = geodb:lookup(ip) 26 | if g then 27 | local country = g:get("country", "names", "en") 28 | if country then 29 | local city = g:get("city", "names", "en") or '' 30 | local region = g:get("subdivisions", "0", "names", "en") or '' 31 | local accuracy = g:get('location', 'accuracy_radius') or 9999 32 | return '%s %s %s (%d km)' % {city, region, country, accuracy} 33 | end 34 | end 35 | return 'unknown' 36 | end 37 | 38 | return function (path, ip) 39 | if geodb and asndb then 40 | Log(kLogInfo, '%s requested by %s from %s %s' % {path, FormatIp(ip), GetAsn(ip), GetGeo(ip)}) 41 | end 42 | end -------------------------------------------------------------------------------- /src/.lua/board_worker.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | We could be generating this fresh every time someone 3 | hits the /score route, but since it's a bit expensive 4 | (it has to process the entire claim table!) we just 5 | update it periodically and cache it instead. 6 | ]] 7 | 8 | local function UpdateBoardImpl(db) 9 | local stmt, err, scores, output, success 10 | local scores = {} 11 | err = db:exec([[SELECT nick, (ip >> 24), COUNT(*) FROM land GROUP BY nick, (ip >> 24)]], function (udata, cols, vals, names) 12 | scores[vals[1]] = scores[vals[1]] or {} 13 | table.insert(scores[vals[1]], {tonumber(vals[2]), tonumber(vals[3])}) 14 | return 0 15 | end) 16 | if err ~= sqlite3.OK then 17 | Log(kLogWarn, "BOARD select query: %s / %s" % {err or "(null)", db:errmsg()}) 18 | return 19 | end 20 | 21 | output = EncodeJson({["score"]=scores, ["now"]={os.time()}}) 22 | output = Deflate(output) 23 | stmt, err = db:prepare([[ 24 | INSERT INTO cache (key, val) VALUES ('/board', ?1) 25 | ON CONFLICT (key) DO UPDATE SET (val) = (?1) 26 | ]]) 27 | if not stmt then 28 | Log(kLogWarn, "BOARD prepare insert: %s / %s" % {err or "(null)", db:errmsg()}) 29 | return 30 | elseif stmt:bind_values(output) ~= sqlite3.OK then 31 | Log(kLogWarn, "BOARD insert bind: %s" % {db:errmsg()}) 32 | elseif stmt:step() ~= sqlite3.DONE then 33 | Log(kLogWarn, "BOARD insert step: %s" % {db:errmsg()}) 34 | else 35 | Log(kLogInfo, "BOARD successfully updated") 36 | end 37 | stmt:finalize() 38 | end 39 | 40 | local gotterm = false 41 | 42 | return function(db) 43 | assert(unix.sigaction(unix.SIGINT, function() gotterm = true; end)) 44 | assert(unix.sigaction(unix.SIGTERM, function() gotterm = true; end)) 45 | while not gotterm do 46 | UpdateBoardImpl(db) 47 | unix.nanosleep(10) 48 | end 49 | Log(kLogInfo, "UpdateBoardWorker() terminating") 50 | end 51 | -------------------------------------------------------------------------------- /src/user.html: -------------------------------------------------------------------------------- 1 | 2 | IPv4 Turf War 3 | 4 | 5 | 6 |

Stats for

7 | 8 | 9 | 12 | 13 | Back to homepage 14 | 15 | 48 | -------------------------------------------------------------------------------- /src/about.html: -------------------------------------------------------------------------------- 1 | 2 | About | IPv4 Turf War 3 | 4 | 5 | 11 | 12 |

Welcome to the IPv4 Games

13 |

I have a simple challenge for you: 14 |

Send requests to my web server from lots of different IP addresses. 15 | 16 | 17 |

How Scoring Works

18 |

The server keeps a record of what name was last sent to it from each IPv4 address. 19 |

If you hold more IPv4 addresses in a 20 | /8 address block 21 | than anyone else, that means you control that block. 22 |

Each block you control gives you 1 point. 23 | 24 | 25 |

Who made this?

26 |

This was a weekend project made by me, ClayLoam! 27 | 28 | 29 |

How did you make this?

30 |

The frontend is plain HTML and JavaScript. (Just view the page's source!) 31 | The backend is a few hundred of Lua running inside a 32 | redbean server 33 | (which gives us this convenient 34 | server health endpoint for free.) 35 |

Source is available here! 36 |

37 | 38 | 39 |

Discussion

40 | 44 | 45 |

Support Turf War

46 |

47 | We're amazed at how much love and affection this service has attracted 48 | from system operators worldwide, and we need your help to keep it 49 | online. Your support is what makes free games like Turf War possible. 50 |

51 | Turf War was developed by ClayLoam, who's built other in-browser games 52 | too, such as familiars.io. 53 | Your support will thank him for the contribution he made to the 54 | Internet in creating Turf War, and enable him to keep sharing more 55 | awesome projects. 56 |

59 |

60 | Turf War is operated by Justine Tunney. Supporting her will help 61 | contribute to operational expenses of Turf War, such as server costs, 62 | in addition to supporting the development of the redbean web server. 63 |

67 | -------------------------------------------------------------------------------- /src/claim.lua: -------------------------------------------------------------------------------- 1 | if not EnforceMethod({'GET', 'POST', 'HEAD'}) then return end 2 | if not EnforceParams({'name'}) then return end 3 | 4 | local ip = GetRemoteAddr() 5 | if not ip then 6 | return ClientError("IPv4 Games only supports IPv4 right now") 7 | end 8 | 9 | local ip_str = FormatIp(ip) 10 | local name = GetParam("name") 11 | local escaped_name = EscapeHtml(name) 12 | 13 | if name == nil or name == "" then 14 | return ClientError("Name query param was blank") 15 | elseif #name > 40 then 16 | return ClientError("name must be no more than 40 characters") 17 | else 18 | local invalid_index = name:find("[^!-~]") 19 | if invalid_index ~= nil then 20 | local is_valid_utf8, codepoint = pcall(utf8.codepoint, name, invalid_index) 21 | if is_valid_utf8 then 22 | Log(kLogWarn, "Invalid character in name (codepoint %d)" % {codepoint}) 23 | return ClientError("Invalid character in name at index %d" % {invalid_index}) 24 | else 25 | return ClientError("name is not valid utf8", kLogWarn) 26 | end 27 | end 28 | end 29 | 30 | local stmt, err = db:prepare[[SELECT nick FROM land WHERE ip = ?1]] 31 | 32 | if not stmt then 33 | return InternalError(string.format("Failed to prepare select query: %s / %s", err or "(null)", db:errmsg())) 34 | end 35 | 36 | if stmt:bind_values(ip) ~= sqlite3.OK then 37 | stmt:finalize() 38 | return InternalError(string.format("Internal error (stmt:bind_values): %s", db:errmsg())) 39 | end 40 | 41 | local res = stmt:step() 42 | local already = false 43 | local prev_name = nil 44 | if res == sqlite3.ROW then 45 | prev_name = stmt:get_value(0) 46 | elseif res ~= sqlite3.DONE then 47 | stmt:finalize() 48 | return InternalError(string.format("Internal error (stmt:step): %s", db:errmsg())) 49 | end 50 | stmt:finalize() 51 | 52 | if prev_name ~= name then 53 | -- record exists and should be updated 54 | local stmt = db:prepare([[ 55 | INSERT INTO land (ip, nick) VALUES (?1, ?2) 56 | ON CONFLICT (ip) DO UPDATE SET (nick) = (?2) WHERE nick != ?2 57 | ]]) 58 | if stmt:bind_values(ip, name) ~= sqlite3.OK then 59 | stmt:finalize() 60 | return InternalError(string.format("Internal error (stmt:bind_values): %s", db:errmsg())) 61 | elseif stmt:step() ~= sqlite3.DONE then 62 | stmt:finalize() 63 | return InternalError(string.format("Internal error (stmt:step): %s", db:errmsg())) 64 | end 65 | stmt:finalize() 66 | 67 | local time, nanos = unix.clock_gettime() 68 | local timestamp = string.format("%s.%.3dZ", os.date("!%Y-%m-%dT%H:%M:%S", time), math.floor(nanos / 1000000)) 69 | local log_line = string.format("%s\t%s\t%s\n", timestamp, ip_str, name) 70 | unix.write(claims_log, log_line) 71 | end 72 | 73 | SetHeader("Content-Type", "text/html") 74 | SetHeader("Cache-Control", "private") 75 | Write(string.format([[ 76 | 77 | The land at %s was claimed for %s. 78 | 79 | The land at %s was claimed for %s. 80 |

81 | Back to homepage 82 | ]], ip_str, escaped_name, ip_str, EscapeHtml(EscapeParam(name)), escaped_name)) 83 | -------------------------------------------------------------------------------- /src/.init.lua: -------------------------------------------------------------------------------- 1 | local log_request_origin = require "log_request_origin" 2 | sqlite3 = require "lsqlite3" 3 | 4 | TrustProxy(ParseIp("127.0.0.0"), 8); 5 | 6 | -- Cloudflare proxy ranges 7 | TrustProxy(ParseIp("103.21.244.0"), 22); 8 | TrustProxy(ParseIp("103.22.200.0"), 22); 9 | TrustProxy(ParseIp("103.31.4.0"), 22); 10 | TrustProxy(ParseIp("104.16.0.0"), 13); 11 | TrustProxy(ParseIp("104.24.0.0"), 14); 12 | TrustProxy(ParseIp("108.162.192.0"), 18); 13 | TrustProxy(ParseIp("131.0.72.0"), 22); 14 | TrustProxy(ParseIp("141.101.64.0"), 18); 15 | TrustProxy(ParseIp("162.158.0.0"), 15); 16 | TrustProxy(ParseIp("172.64.0.0"), 13); 17 | TrustProxy(ParseIp("173.245.48.0"), 20); 18 | TrustProxy(ParseIp("188.114.96.0"), 20); 19 | TrustProxy(ParseIp("190.93.240.0"), 20); 20 | TrustProxy(ParseIp("197.234.240.0"), 22); 21 | TrustProxy(ParseIp("198.41.128.0"), 17); 22 | assert(IsTrustedProxy(ParseIp("103.21.244.0"))) 23 | assert(not IsTrustedProxy(ParseIp("166.21.244.0"))) 24 | 25 | if IsDaemon() then 26 | assert(unix.chdir('/opt/turfwar')) 27 | ProgramPort(80) 28 | ProgramPort(443) 29 | ProgramUid(65534) 30 | ProgramGid(65534) 31 | ProgramLogPath('redbean.log') 32 | ProgramPidPath('redbean.pid') 33 | ProgramPrivateKey(Slurp('/home/jart/mykey.key')) 34 | ProgramCertificate(Slurp('/home/jart/mykey.crt')) 35 | end 36 | 37 | local function ConnectDb() 38 | local db = sqlite3.open("db.sqlite3") 39 | db:busy_timeout(1000) 40 | db:exec[[PRAGMA journal_mode=WAL]] 41 | db:exec[[PRAGMA synchronous=NORMAL]] 42 | db:exec[[SELECT ip FROM land WHERE ip = 0x7f000001]] -- We have to do this warmup query for SQLite to work after doing unveil 43 | return db 44 | end 45 | 46 | local function Lockdown() 47 | assert(unix.unveil("/var/tmp", "rwc")) 48 | assert(unix.unveil("/tmp", "rwc")) 49 | assert(unix.unveil(nil, nil)) 50 | assert(unix.pledge("stdio flock rpath wpath cpath", nil, unix.PLEDGE_PENALTY_RETURN_EPERM)) 51 | end 52 | 53 | function ClientError(msg, loglevel) 54 | if loglevel ~= nil then 55 | Log(loglevel, string.format(msg)) 56 | end 57 | SetStatus(400, msg) 58 | SetHeader('Content-Type', 'text/plain') 59 | Write(msg..'\r\n') 60 | return msg 61 | end 62 | 63 | function InternalError(msg) 64 | Log(kLogWarn, msg) 65 | SetHeader('Connection', 'close') 66 | return ServeError(500) 67 | end 68 | 69 | function EnforceMethod(allowed_methods) 70 | local method = GetMethod() 71 | for i,val in ipairs(allowed_methods) do 72 | if method == val then 73 | return true 74 | end 75 | end 76 | Log(kLogWarn, "got %s request from %s" % {method, FormatIp(GetRemoteAddr() or "0.0.0.0")}) 77 | ServeError(405) 78 | SetHeader("Cache-Control", "private") 79 | SetHeader('Allow', table.concat(allowed_methods, ', ')) 80 | return false 81 | end 82 | 83 | function EnforceParams(exact_params) 84 | local params = GetParams() 85 | if #params > #exact_params then 86 | ClientError('too many params') 87 | return false 88 | end 89 | for i,val in ipairs(exact_params) do 90 | if GetParam(val) == nil then 91 | ClientError('Missing query param: %s' % {val}) 92 | return false 93 | end 94 | end 95 | return true 96 | end 97 | 98 | function OnServerStart() 99 | if assert(unix.fork()) == 0 then 100 | local worker = require("board_worker") 101 | local db = ConnectDb() 102 | Lockdown() 103 | worker(db) 104 | db:close() 105 | unix.exit(0) 106 | end 107 | 108 | local err 109 | claims_log, err = unix.open("claims.log", unix.O_WRONLY | unix.O_APPEND | unix.O_CREAT, 0644) 110 | if err ~= nil then 111 | Log(kLogFatal, string.format("error opening claim log: %s", err)) 112 | end 113 | end 114 | 115 | function OnWorkerStart() 116 | assert(unix.setrlimit(unix.RLIMIT_RSS, 100 * 1024 * 1024)) 117 | assert(unix.setrlimit(unix.RLIMIT_CPU, 4)) 118 | Lockdown() 119 | db = ConnectDb() 120 | end 121 | 122 | function StartsWith(str, start) 123 | return str:sub(1, #start) == start 124 | end 125 | 126 | function EndsWith(str, ending) 127 | return ending == "" or str:sub(-#ending) == ending 128 | end 129 | 130 | -- Redbean's global route handler 131 | function OnHttpRequest() 132 | local ip = GetRemoteAddr() 133 | local path = GetPath() 134 | 135 | if ip then 136 | log_request_origin(path, ip) 137 | end 138 | 139 | if path == "/ip" then 140 | Route(GetHost(), "/ip.lua") 141 | elseif path == "/claim" then 142 | Route(GetHost(), "/claim.lua") 143 | elseif path == "/score" then 144 | Route(GetHost(), "/score.lua") 145 | else 146 | if #GetParams() > 1 then 147 | return ClientError('too many params') 148 | end 149 | -- Default redbean route handling 150 | Route() 151 | if EndsWith(path, ".html") then 152 | SetHeader("Cache-Control", "public, max-age=300, must-revalidate") 153 | elseif EndsWith(path, ".png") then 154 | SetHeader("Cache-Control", "public, max-age=3600, must-revalidate") 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | IPv4 Turf War 3 | 4 | 5 | 40 | 41 |

42 |

Claim The Land At Your IP

43 |
44 | 45 | 46 | 47 |
48 |
49 | 50 | 57 | 58 |

(What is this?)

59 | 60 |

Top Players

61 | 62 | 63 |
    64 |
  1. Loading top players...
  2. 65 |
66 | 67 | 68 |

/8 Block Leaders

69 |
Loading segments...
70 | 77 | 78 | 206 | --------------------------------------------------------------------------------