├── .gitattributes ├── .gitignore ├── LICENSE.md ├── README.md ├── a2sapi-swagger.json ├── a2sapi-swagger.yaml ├── a2sapi.sublime-project ├── build ├── nix │ ├── build.sh │ ├── build_and_run.sh │ └── run_tests.sh └── win │ ├── build.bat │ ├── build_and_run.bat │ └── run_tests.bat ├── getfiles ├── get_countrydb.bat └── get_countrydb.sh └── src ├── a2sapi.go ├── config ├── config.go ├── debugconfig.go ├── logconfig.go ├── steamconfig.go └── webconfig.go ├── constants ├── config_constants.go ├── db_constants.go ├── logger_constants.go ├── misc_constants.go └── test_constants.go ├── db ├── country.go ├── country_test.go ├── database.go ├── servers.go └── servers_test.go ├── logger └── logger.go ├── models ├── api_serverlist.go ├── db_country.go ├── db_serverid.go ├── steam_playerinfo.go └── steam_serverinfo.go ├── steam ├── filters │ ├── filters.go │ └── game.go ├── gametype.go ├── listbuilder.go ├── listbuilder_test.go ├── query.go ├── steam.go ├── steamerrors.go ├── steaminfo.go ├── steaminfo_test.go ├── steammaster.go ├── steammaster_test.go ├── steammasterrweb.go ├── steamplayer.go ├── steamplayer_test.go ├── steamrules.go ├── steamrules_test.go └── timedgrabber.go ├── test └── test_funcs.go ├── util └── util.go └── web ├── gzip.go ├── gzip_test.go ├── handlers.go ├── handlers_test.go ├── querystring.go ├── querystring_test.go ├── retrievers.go ├── router.go ├── routes.go ├── server.go ├── serverfilter.go └── serverfilter_test.go /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go ### 2 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 3 | *.o 4 | *.a 5 | *.so 6 | 7 | # Folders 8 | _obj 9 | _test 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | .DS_Store 24 | *.exe 25 | *.test 26 | *.prof 27 | 28 | #sublime text stuff 29 | *.sublime-workspace 30 | 31 | # a2sapi specific 32 | bin 33 | conf 34 | *.user 35 | servers.sqlite 36 | servers.json 37 | a2sapi.exe 38 | a2sapi 39 | logs 40 | *.mmdb 41 | *servers-*.json 42 | api-test-servers.json 43 | test-api-servers.json 44 | test_temp 45 | serverdump.json -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # **The MIT License (MIT)** 2 | 3 | Copyright (c) 2016 syncore (syncore@syncore.org) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### ⚠️ 🚨 This project is deprecated and unmaintained. It will be archived and completely re-written _soon_. 2 | 3 | # a2sapi 4 | 5 | a2sapi is a RESTful API for receiving [**master server information**](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) and for querying [**A2S information**](https://developer.valvesoftware.com/wiki/Server_queries) from servers running on the Steam (Source) platform. It can be configured to query the master server at timed intervals and expose the data via an API endpoint. 6 | 7 | This back end service was written to provide information to a number of sites, for example: [here](https://ql.syncore.org) and [here](https://reflex.syncore.org) for which I needed this specific information. 8 | 9 | *Please note, this is the first project that I have written in the Go programming language.* :scream: Pull requests are welcome! 10 | 11 | # Installation 12 | 13 | ### Installation: Binaries 14 | - Grab one of the binaries for your platform from [releases](https://github.com/syncore/a2sapi/releases). 15 | - Extract the archive. 16 | - Change directory to `getfiles` and run the appropriate `get_countrydb` script to grab the geolocation database. 17 | - This is the GeoLite2 City free database [provided by MaxMind](http://dev.maxmind.com/geoip/geoip2/geolite2/). 18 | - MaxMind updates this database on the first Tuesday of every month, so you can run the script again at that time, if you'd like. 19 | - If you are on Windows, you will need [wget](http://nebm.ist.utl.pt/~glopes/wget/) and [gzip](http://www.gzip.org) to use the `get_countrydb` script. Or, alternatively, simply download the database [here](http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz), extract the `GeoLite2-City.mmdb` file into a directory called `db` in the same location as the `a2sapi` executable. 20 | - Change back to the directory where the `a2sapi` executable is located. 21 | - Linux/OSX users: you must generate the configuration file with `./a2sapi --config` 22 | - Windows users: you must generate the configuration file with `a2sapi.exe --config` 23 | 24 | ### Steam Web API 25 | If you wish to use the faster method of retrieving the list of all servers without having to make queries to Valve's master server, this can now be done using the Steam Web API. This method of retrieval is more reliable than querying the master server, which is sometimes offline without explanation from Valve. To use this method of server retrieval, you will need a Steam Web API key, which you can get for free at https://steamcommunity.com/dev/apikey 26 | 27 | 28 | ### Configuration (binaries and source) 29 | The configuration is handled interactively by passing the `--config` flag to the a2sapi executable. The configuration file will be stored in the `conf` directory. Any existing configuration will be overwritten. 30 | 31 | ### Launching: Binaries 32 | - Linux/OSX: Launch with: `./a2sapi` 33 | - Windows: Launch by running the `a2sapi.exe` executable. 34 | - You can pass the `--h` flag to the executable to see a few command-line options. 35 | 36 | 37 | ### Build from Source 38 | 39 | - Alternatively, you can build from source. This assumes that you have a working Go environment. If not, check out the [Golang Getting Started guide](https://golang.org/doc/install). 40 | - Extract the archive. 41 | - Change directory to `build/nix` if you're on Linux/OSX or `build\win` if you're on Windows and launch the appropriate `build.sh` or `build.bat` script. 42 | - Change back to the root directory, then change directory to `getfiles` and run the appropriate `get_countrydb` script to get the geolocation database file, which is the GeoLite2 City free database [provided by MaxMind](http://dev.maxmind.com/geoip/geoip2/geolite2/). 43 | - Note: if you're on Windows you'll need `wget` and `gzip`. For more info, see the discussion above for the binary installation. 44 | - Updates for this geolocation database are provided by MaxMind on the first Tuesday of every month, so you can run the script again at that time to get the updates. 45 | 46 | ### Launching: Source 47 | - After building, the resulting executable will be located in the `bin` directory. 48 | - On first run, you will need to generate the configuration file by passing the `--config` flag to the executable. 49 | - After generating the configuration file, launch the application by running the `a2sapi` executable in the `bin` directory. If you'd like to see a few command-line options, then pass the `--h` flag to the executable. 50 | 51 | ### Running Tests 52 | - Linux/OSX: In the build/nix directory: `./run_tests.sh` 53 | - Windows: In the build\win directory: `run_tests.bat` 54 | 55 | # Usage 56 | :book: For interactive documentation and more detail, see the a2sapi Swagger UI documentation in use [on one of my pages that uses this API](https://ql.syncore.org/apidoc/) or you can use the included a2sapi-swagger files with Swagger UI/Editor. 57 | 58 | The API ships with three endpoints: 59 | - /servers 60 | - /serverIDs 61 | - /query 62 | 63 | 64 | ### `GET: /servers` 65 | The `servers` endpoint provides a list of the most recent servers returned from the Valve master server. Data from this endpoint is only available if the application has been configured to retrieve servers from Valve's master server. This list can be filtered by specifying one or more of the filter parameters below. Separate multiple parameter values with commas. Multiple filters can be combined after the first filter by using the & character before any additional filters, for example: `/servers?countries=US,SE&maps=overkill&hasPlayers=true&serverOS=Linux` 66 | 67 | ### String parameters (filters): 68 | - ***countries*** 69 | - Filter by 2-letter ISO 3166-1 country code. 70 | - `/servers?countries=US,SE,NL` 71 | - ***regions*** 72 | - Filter by region. Possible regions: `Africa, Antarctica, Asia, Europe, Oceania, North America, South America` 73 | - `/servers?regions=North America,Oceania` 74 | - ***states*** 75 | - Filter by 2-letter US state. United States of America only. 76 | - `/servers?states=NY,TX` 77 | - ***serverNames*** 78 | - Filter by server name. Results are loosely matched. 79 | - `/servers?serverNames=Newbies,practice,fun server` 80 | - ***maps*** 81 | - Filter by map. Results are loosely matched. 82 | - `/servers?maps=bdm3,cpm22,dp6` 83 | - ***games*** 84 | - Filter by game. 85 | - `/servers?games=Reflex` 86 | - ***gametypes*** 87 | - Filter by gametype. 88 | - `/servers?gametypes=CA,CTF` 89 | - ***serverTypes*** 90 | - Filter by server types. Possible types: `dedicated, listen` 91 | - `/servers?serverTypes=dedicated` 92 | - ***serverOS*** 93 | - Filter by server operating system. Possible types: `Linux, Windows, Mac` 94 | - `/servers?serverOS=Linux` 95 | - ***serverVersions*** 96 | - Filter by server version. 97 | - `/servers?serverVersions=1.33,1.66,2.02` 98 | - ***serverKeywords*** 99 | - Filter by server keywords. Results are loosely matched. 100 | - `/servers?serverKeywords=minqlx,clanarena,stats` 101 | 102 | ### Boolean parameters (filters): 103 | - ***hasPlayers*** 104 | - Filter by whether server has players (true) or is empty (false). 105 | - `/servers?hasPlayers=true` 106 | - ***hasBots*** 107 | - Filter by whether server has bots (true) or not (false). 108 | - `/servers?hasBots=false` 109 | - ***hasPassword*** 110 | - Filter by whether server has a password (true) or not (false). 111 | - `/servers?hasPassword=false` 112 | - ***hasAntiCheat*** 113 | - Filter by whether server is secured by anti-cheat (true) or not (false). 114 | - `/servers?hasAntiCheat=true` 115 | - ***isNotFull*** 116 | - Filter by whether server is full (true) or not (false). 117 | - `/servers?isNotFull=true` 118 | 119 | ### `GET: /serverIDs` 120 | The `serverIDs` endpoint retrieves servers' internal ID numbers. The ID number(s) will be used with the `ids` parameter of the `query` endpoint to retrieve a server's real-time information. Separate multiple parameter values with commas. 121 | 122 | ### Parameters: 123 | - ***hosts*** 124 | - The host in the format of IP:port to retrieve the ID for. Multiple IP:ports can separated with commas. 125 | - `/serverIDs?hosts=54.93.46.254:25801,46.101.8.188:27960` 126 | 127 | 128 | ### `GET: /query` 129 | The `query` endpoint retrieves servers' real-time information. Depending on how the application is configured, this can be done via server ID numbers (retrieved via the `serverIDs` endpoint) and/or directly from IP addresses and ports. Separate multiple parameter values with commas. 130 | 131 | ### Parameters for querying by server ID: 132 | - ***ids*** 133 | - The server ID(s) whose information should be retrieved. 134 | - `/query?ids=123,456,999,10340` 135 | 136 | ### Parameters for directly querying by address: 137 | - ***hosts*** 138 | - The host in the format of IP:port whose information should be retrieved. :warning: Note, address queries might be disabled, depending on the application configuration. If so, you must use the server ID. 139 | - `/query?hosts=54.93.46.254:25801,46.101.8.188:27960` 140 | 141 | 142 | # Quick Examples 143 | **`/servers` endpoint:** 144 | 145 | These examples for the `/servers` endpoint will assume that a2api is configured to retrieve Quake Live servers from Valve's master server at timed intervals (that is, data is available for the `/servers` endpoint): 146 | 147 | *Get all clan arena servers in North America that have players, do not have bots, and are running the minqlx server extension:* 148 | 149 | - `http://some-webserver.com/servers?gametypes=CA®ions=North America&hasPlayers=true&hasBots=false&serverKeywords=minqlx` 150 | 151 | *Get all servers in Germany that contain the word "fun" in their name that are running the map overkill or thunderstruck:* 152 | 153 | - `http://some-webserver.com/servers?countries=DE&serverNames=fun&maps=overkill,thunderstruck` 154 | 155 | **`/serverIDs` endpoint:** 156 | 157 | *Get the ID for the server with address: 127.0.0.1:27960* 158 | - `http://some-webserver.com/serverIDs?hosts=127.0.0.1:27960` 159 | 160 | *Get the IDs for the servers with addresses: 127.0.0.1:27960, 10.0.0.1:27597, and 172.16.0.1:27015* 161 | - `http://some-webserver.com/serverIDs?hosts=127.0.0.1:27960,10.0.0.1:27597,172.16.0.1:27015` 162 | 163 | **`/query` endpoint:** 164 | 165 | *Get the real-time information for the servers with IDs 100, 200, 300, and 400* 166 | - `http://some-webserver.com/query?ids=100,200,300,400` 167 | 168 | *Get the real-time information for the servers with addresses 127.0.0.1:27960, 10.0.0.1:27597, and 172.16.0.1:27015* 169 | - `http://some-webserver.com/query?hosts=127.0.0.1:27960,10.0.0.1:27597,172.16.0.1:27015` 170 | - :warning: The API administrator may have direct server address queries disabled, in which case this would not work! 171 | 172 | 173 | # Issues 174 | 175 | The preferable method of contact would be for you to open up an [issue on Github](https://github.com/syncore/a2sapi/issues). 176 | Alternatively, I usually can be found under the name "syncore" on QuakeNet IRC - irc.quakenet.org 177 | 178 | 179 | # License 180 | 181 | See [LICENSE.md] 182 | 183 | [LICENSE.md]:https://github.com/syncore/a2sapi/blob/master/LICENSE.md 184 | -------------------------------------------------------------------------------- /a2sapi.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "path": "src" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /build/nix/build.sh: -------------------------------------------------------------------------------- 1 | mkdir -p ../../bin/ 2 | rm -rf ../../bin/a2sapi 3 | go get -u github.com/fatih/color 4 | go get -u github.com/gorilla/mux 5 | go get -u github.com/mattn/go-sqlite3 6 | go get -u github.com/oschwald/maxminddb-golang 7 | go get -u github.com/stretchr/testify/assert 8 | go build -i ../../src/a2sapi.go 9 | mv a2sapi ../../bin/ 10 | cd ../../bin/ 11 | -------------------------------------------------------------------------------- /build/nix/build_and_run.sh: -------------------------------------------------------------------------------- 1 | mkdir -p ../../bin/ 2 | rm -rf ../../bin/a2sapi 3 | go get -u github.com/fatih/color 4 | go get -u github.com/gorilla/mux 5 | go get -u github.com/mattn/go-sqlite3 6 | go get -u github.com/oschwald/maxminddb-golang 7 | go get -u github.com/stretchr/testify/assert 8 | go build -i ../../src/a2sapi.go 9 | mv a2sapi ../../bin/ 10 | cd ../../bin/ 11 | ./a2sapi 12 | -------------------------------------------------------------------------------- /build/nix/run_tests.sh: -------------------------------------------------------------------------------- 1 | cd ../../src/db 2 | go test 3 | cd ../../src/steam 4 | go test 5 | cd ../../src/web 6 | go test 7 | rm -rf ../../bin/test_temp 8 | cd ../../build/nix 9 | -------------------------------------------------------------------------------- /build/win/build.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | mkdir %cd%\..\..\bin 3 | del %cd%\..\..\bin\a2sapi.exe 4 | go get github.com/fatih/color 5 | go get github.com/gorilla/mux 6 | go get github.com/mattn/go-sqlite3 7 | go get github.com/oschwald/maxminddb-golang 8 | go get github.com/stretchr/testify/assert 9 | go build -i %cd%\..\..\src\a2sapi.go 10 | move /Y a2sapi.exe %cd%\..\..\bin\ 11 | cd %cd%\..\..\bin -------------------------------------------------------------------------------- /build/win/build_and_run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | mkdir %cd%\..\..\bin 3 | del %cd%\..\..\bin\a2sapi.exe 4 | go get github.com/fatih/color 5 | go get github.com/gorilla/mux 6 | go get github.com/mattn/go-sqlite3 7 | go get github.com/oschwald/maxminddb-golang 8 | go get github.com/stretchr/testify/assert 9 | go build -i %cd%\..\..\src\a2sapi.go 10 | move /Y a2sapi.exe %cd%\..\..\bin\ 11 | cd %cd%\..\..\bin 12 | a2sapi.exe -------------------------------------------------------------------------------- /build/win/run_tests.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | cd %cd%\..\..\src\db 3 | go test 4 | cd %cd%\..\..\src\steam 5 | go test 6 | cd %cd%\..\..\src\web 7 | go test 8 | rmdir /S /Q %cd%\..\..\bin\test_temp 9 | cd %cd%\..\..\build\win -------------------------------------------------------------------------------- /getfiles/get_countrydb.bat: -------------------------------------------------------------------------------- 1 | :: wget for windows can be downloaded at http://nebm.ist.utl.pt/~glopes/wget/ 2 | :: gizp for windows can be downloaded at http://www.gzip.org 3 | @echo off 4 | wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz 5 | gzip -d GeoLite2-City.mmdb.gz 6 | mkdir %cd%\..\bin\db 7 | move /Y GeoLite2-City.mmdb %cd%\..\bin\db\ -------------------------------------------------------------------------------- /getfiles/get_countrydb.sh: -------------------------------------------------------------------------------- 1 | wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz 2 | gzip -d GeoLite2-City.mmdb.gz 3 | mkdir -p ../bin/db/ 4 | mv GeoLite2-City.mmdb ../bin/db/ -------------------------------------------------------------------------------- /src/a2sapi.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/syncore/a2sapi/src/config" 9 | "github.com/syncore/a2sapi/src/constants" 10 | "github.com/syncore/a2sapi/src/db" 11 | "github.com/syncore/a2sapi/src/steam" 12 | "github.com/syncore/a2sapi/src/steam/filters" 13 | "github.com/syncore/a2sapi/src/util" 14 | "github.com/syncore/a2sapi/src/web" 15 | ) 16 | 17 | var ( 18 | doConfig bool 19 | useDebugConfig bool 20 | runSilent bool 21 | ) 22 | 23 | const ( 24 | configFlag = "config" 25 | debugFlag = "debug" 26 | silentFlag = "silent" 27 | ) 28 | 29 | func init() { 30 | flag.BoolVar(&doConfig, configFlag, false, "Generate the configuration file") 31 | flag.BoolVar(&useDebugConfig, debugFlag, false, "Use debug mode configuration file") 32 | flag.BoolVar(&runSilent, silentFlag, false, 33 | "Launch without displaying startup information") 34 | } 35 | 36 | func main() { 37 | flag.Parse() 38 | 39 | if doConfig { 40 | if !util.FileExists(constants.GameFileFullPath) { 41 | filters.DumpDefaultGames() 42 | } 43 | config.CreateConfig() 44 | os.Exit(0) 45 | } 46 | 47 | if useDebugConfig { 48 | config.CreateDebugConfig() 49 | constants.IsDebug = true 50 | launch(true) 51 | } else { 52 | launch(false) 53 | } 54 | } 55 | 56 | func launch(isDebug bool) { 57 | if !util.FileExists(constants.GameFileFullPath) { 58 | filters.DumpDefaultGames() 59 | } 60 | if !isDebug { 61 | if !util.FileExists(constants.ConfigFilePath) { 62 | fmt.Printf("Could not read configuration file '%s' in the '%s' directory.\n", 63 | constants.ConfigFilename, constants.ConfigDirectory) 64 | fmt.Printf("You must generate the configuration file with: %s --%s\n", 65 | os.Args[0], configFlag) 66 | os.Exit(1) 67 | } 68 | } 69 | // Initialize the application-wide configuration 70 | config.InitConfig() 71 | // Initialize the application-wide database connections (panic on failure) 72 | db.InitDBs() 73 | 74 | if !runSilent { 75 | printStartInfo() 76 | } 77 | 78 | if config.Config.SteamConfig.AutoQueryMaster { 79 | autoQueryGame := filters.GetGameByName( 80 | config.Config.SteamConfig.AutoQueryGame) 81 | if autoQueryGame == filters.GameUnspecified { 82 | fmt.Println("Invalid game specified for automatic timed query!") 83 | fmt.Printf( 84 | "You may need to delete: '%s' and/or recreate the config with: %s --%s", 85 | constants.GameFileFullPath, os.Args[0], configFlag) 86 | os.Exit(1) 87 | } 88 | // HTTP server + API + Steam auto-querier 89 | go web.Start(runSilent) 90 | filter := filters.NewFilter(autoQueryGame, filters.SrAll, nil) 91 | stop := make(chan bool, 1) 92 | go steam.StartMasterRetrieval(stop, filter, 7, 93 | config.Config.SteamConfig.TimeBetweenMasterQueries) 94 | <-stop 95 | } else { 96 | // HTTP server + API standalone 97 | web.Start(runSilent) 98 | } 99 | } 100 | 101 | func printStartInfo() { 102 | fmt.Printf("%s\n", constants.AppInfo) 103 | if useDebugConfig { 104 | fmt.Println("NOTE: We're currently using debug the configuration!") 105 | } 106 | if config.Config.SteamConfig.AutoQueryMaster { 107 | fmt.Println("Automatic timed master server queries: enabled") 108 | fmt.Printf("Automatic timed master server queries every %d seconds\n", 109 | config.Config.SteamConfig.TimeBetweenMasterQueries) 110 | fmt.Printf("Automatic timed master server query game: %s\n", 111 | config.Config.SteamConfig.AutoQueryGame) 112 | fmt.Printf("Automatic timed master server query max hosts to receive: %d\n", 113 | config.Config.SteamConfig.MaximumHostsToReceive) 114 | } else { 115 | fmt.Println("Automatic timed master server queries: disabled") 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // config.go - configuration operations 4 | 5 | import ( 6 | "bufio" 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | "runtime" 11 | 12 | "github.com/syncore/a2sapi/src/constants" 13 | "github.com/syncore/a2sapi/src/steam/filters" 14 | "github.com/syncore/a2sapi/src/util" 15 | 16 | "github.com/fatih/color" 17 | ) 18 | 19 | var promptColor = color.New(color.FgHiGreen).SprintfFunc() 20 | var errorColor = color.New(color.FgHiRed).PrintlnFunc() 21 | var newline = getNewLineForOS() 22 | 23 | // Config represents the application-wide configuration. 24 | var Config *Cfg 25 | 26 | // Cfg represents logging, steam-related, and API-related options. 27 | type Cfg struct { 28 | LogConfig CfgLog `json:"logConfig"` 29 | SteamConfig CfgSteam `json:"steamConfig"` 30 | WebConfig CfgWeb `json:"webConfig"` 31 | DebugConfig CfgDebug `json:"debugConfig"` 32 | } 33 | 34 | func getNewLineForOS() string { 35 | if runtime.GOOS == "windows" { 36 | return "\r\n" 37 | } 38 | return "\n" 39 | } 40 | 41 | // InitConfig reads the configuration file from disk and if successful, sets the 42 | // application wide-configuration. Otherwise, it will panic. 43 | func InitConfig() { 44 | if Config != nil { 45 | return 46 | } 47 | 48 | f, err := os.Open(constants.GetCfgPath()) 49 | if err != nil { 50 | panic(fmt.Sprintf(` 51 | "Error reading config file. You might need to recreate it by using 52 | the --config switch. Error: %s`, err)) 53 | } 54 | defer f.Close() 55 | r := bufio.NewReader(f) 56 | d := json.NewDecoder(r) 57 | cfg := &Cfg{} 58 | if err := d.Decode(cfg); err != nil { 59 | panic(fmt.Sprintf(` 60 | "Error decoding config file. You might need to recreate it by using 61 | the --config switch. Error: %s`, err)) 62 | } 63 | // Set the configuration which will live throughout the application's lifetime 64 | Config = cfg 65 | } 66 | 67 | func getBoolString(b bool) string { 68 | if b { 69 | return "yes" 70 | } 71 | return "no" 72 | } 73 | 74 | // CreateConfig initiates the configuration creation process by collecting user 75 | // input for various configuration values and then writes the configuration file 76 | // to disk if successful, otherwise panics. 77 | func CreateConfig() { 78 | reader := bufio.NewReader(os.Stdin) 79 | cfg := &Cfg{ 80 | LogConfig: CfgLog{}, 81 | SteamConfig: CfgSteam{}, 82 | WebConfig: CfgWeb{}, 83 | DebugConfig: CfgDebug{}, 84 | } 85 | color.Set(color.FgHiYellow) 86 | fmt.Printf(` 87 | %s - configuration file creation 88 | Type a value and press 'ENTER'. Leave a value empty and press 'ENTER' to use the 89 | default value. 90 | 91 | `, constants.AppInfo) 92 | color.Unset() 93 | 94 | // Logging configuration 95 | // Determine if application, Steam, and/or web API logging should be enabled 96 | cfg.LogConfig.EnableAppLogging = configureLoggingEnable(reader, constants.LTypeApp) 97 | cfg.LogConfig.EnableSteamLogging = configureLoggingEnable(reader, constants.LTypeSteam) 98 | cfg.LogConfig.EnableWebLogging = configureLoggingEnable(reader, constants.LTypeWeb) 99 | // Configure max log size and max log count if logging is enabled 100 | if cfg.LogConfig.EnableAppLogging || cfg.LogConfig.EnableSteamLogging || 101 | cfg.LogConfig.EnableWebLogging { 102 | cfg.LogConfig.MaximumLogSize = configureMaxLogSize(reader) 103 | cfg.LogConfig.MaximumLogCount = configureMaxLogCount(reader) 104 | } else { 105 | cfg.LogConfig.MaximumLogSize = defaultMaxLogSize 106 | cfg.LogConfig.MaximumLogCount = defaultMaxLogCount 107 | } 108 | 109 | // Steam configuration 110 | // Query the master server (master server or server list from steam api) automatically at timed intervals 111 | cfg.SteamConfig.AutoQueryMaster = configureTimedMasterQuery(reader) 112 | if cfg.SteamConfig.AutoQueryMaster { 113 | // Use the server list provided by Steam Web API (MUCH faster) instead of master server queries 114 | cfg.SteamConfig.UseWebServerList = configureUseSteamWebAPIList(reader) 115 | if cfg.SteamConfig.UseWebServerList { 116 | // The Steam WebAPI key to use to get the web server list 117 | cfg.SteamConfig.SteamWebAPIKey = configureSteamWebAPIKey(reader) 118 | } 119 | // The game to automatically query the master server for at timed intervals 120 | cfg.SteamConfig.AutoQueryGame = configureTimedQueryGame(reader) 121 | // Time between Steam Master server queries 122 | cfg.SteamConfig.TimeBetweenMasterQueries = configureTimeBetweenQueries(reader, 123 | cfg.SteamConfig.AutoQueryGame) 124 | // Maximum # of servers to retrieve from Steam Master server 125 | cfg.SteamConfig.MaximumHostsToReceive = configureMaxServersToRetrieve(reader) 126 | } else { 127 | cfg.SteamConfig.AutoQueryGame = filters.GameQuakeLive.Name 128 | cfg.SteamConfig.TimeBetweenMasterQueries = defaultTimeBetweenMasterQueries 129 | cfg.SteamConfig.MaximumHostsToReceive = defaultMaxHostsToReceive 130 | } 131 | 132 | // Web API configuration 133 | // Direct queries: whether users can query any host (not just those with IDs) 134 | cfg.WebConfig.AllowDirectUserQueries = configureDirectQueries(reader, 135 | cfg.SteamConfig.AutoQueryMaster) 136 | // Maximum number of servers to allow users to query via API 137 | cfg.WebConfig.MaximumHostsPerAPIQuery = configureMaxHostsPerAPIQuery(reader) 138 | // Time in seconds before HTTP requests time out 139 | cfg.WebConfig.APIWebTimeout = configureWebTimeout(reader) 140 | // Port that API's web server will listen on 141 | cfg.WebConfig.APIWebPort = configureWebServerPort(reader) 142 | // Enable or disable gzip compression of responses 143 | cfg.WebConfig.CompressResponses = configureResponseCompression(reader) 144 | 145 | // Debug configuration (not user-selectable. for debug/development purposes) 146 | // Print a few "debug" messages to stdout 147 | cfg.DebugConfig.EnableDebugMessages = defaultEnableDebugMessages 148 | // Dump the retrieved server information to a JSON file on disk 149 | cfg.DebugConfig.EnableServerDump = defaultEnableServerDump 150 | // Use a pre-defined JSON file as disk as the master server list for the API 151 | cfg.DebugConfig.ServerDumpFileAsMasterList = defaultServerDumpFileAsMasterList 152 | // Name of the pre-defined JSON file to use as the master server list for API 153 | cfg.DebugConfig.ServerDumpFilename = defaultServerDumpFile 154 | 155 | if err := util.WriteJSONConfig(cfg, constants.ConfigDirectory, 156 | constants.ConfigFilePath); err != nil { 157 | panic(err) 158 | } 159 | } 160 | 161 | // CreateDebugConfig creates the configuration file that is used when running the 162 | // applciation in debug mode. 163 | func CreateDebugConfig() { 164 | cfg := &Cfg{} 165 | cfg.LogConfig.EnableAppLogging = true 166 | cfg.LogConfig.EnableSteamLogging = false // even in debug mode; disable 167 | cfg.LogConfig.EnableWebLogging = true 168 | cfg.LogConfig.MaximumLogCount = defaultMaxLogCount 169 | cfg.LogConfig.MaximumLogSize = defaultMaxLogSize 170 | cfg.SteamConfig.AutoQueryMaster = false 171 | cfg.SteamConfig.SteamWebAPIKey = "none" 172 | cfg.SteamConfig.UseWebServerList = defaultUseWebServerList 173 | cfg.SteamConfig.AutoQueryGame = "QuakeLive" 174 | cfg.SteamConfig.TimeBetweenMasterQueries = defaultTimeBetweenMasterQueries 175 | cfg.SteamConfig.MaximumHostsToReceive = defaultMaxHostsToReceive 176 | cfg.WebConfig.AllowDirectUserQueries = true 177 | cfg.WebConfig.APIWebPort = defaultAPIWebPort 178 | cfg.WebConfig.APIWebTimeout = defaultAPIWebTimeout 179 | cfg.WebConfig.CompressResponses = defaultCompressResponses 180 | cfg.WebConfig.MaximumHostsPerAPIQuery = defaultMaxHostsPerAPIQuery 181 | cfg.DebugConfig.EnableDebugMessages = true 182 | cfg.DebugConfig.EnableServerDump = true 183 | cfg.DebugConfig.ServerDumpFileAsMasterList = true 184 | cfg.DebugConfig.ServerDumpFilename = defaultServerDumpFile 185 | if err := util.WriteJSONConfig(cfg, constants.ConfigDirectory, 186 | constants.DebugConfigFilePath); err != nil { 187 | panic(err) 188 | } 189 | // Set the configuration which will live throughout the application's lifetime 190 | Config = cfg 191 | } 192 | 193 | // CreateTestConfig creates the configuration that is used when running automated 194 | // testing. 195 | func CreateTestConfig() { 196 | // boolean values intentionally default to false and are omitted unless 197 | // otherwise specified, which is different from the normal configuration 198 | cfg := &Cfg{} 199 | cfg.LogConfig.MaximumLogCount = defaultMaxLogCount 200 | cfg.LogConfig.MaximumLogSize = defaultMaxLogSize 201 | cfg.SteamConfig.AutoQueryGame = "QuakeLive" 202 | cfg.SteamConfig.TimeBetweenMasterQueries = defaultTimeBetweenMasterQueries 203 | cfg.SteamConfig.MaximumHostsToReceive = defaultMaxHostsToReceive 204 | cfg.WebConfig.AllowDirectUserQueries = true 205 | cfg.WebConfig.APIWebPort = 40081 206 | cfg.WebConfig.APIWebTimeout = defaultAPIWebTimeout 207 | cfg.WebConfig.CompressResponses = defaultCompressResponses 208 | cfg.WebConfig.MaximumHostsPerAPIQuery = defaultMaxHostsPerAPIQuery 209 | cfg.DebugConfig.ServerDumpFileAsMasterList = true 210 | cfg.DebugConfig.ServerDumpFilename = "test-api-servers.json" 211 | if err := util.WriteJSONConfig(cfg, constants.TestTempDirectory, 212 | constants.TestConfigFilePath); err != nil { 213 | panic(err) 214 | } 215 | // Set the configuration which will live throughout the application's lifetime 216 | Config = cfg 217 | } 218 | -------------------------------------------------------------------------------- /src/config/debugconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // debugconfig.go - Options for debugging/development; not user-selectable 4 | 5 | const ( 6 | defaultEnableDebugMessages = false 7 | defaultEnableServerDump = false 8 | defaultServerDumpFileAsMasterList = false 9 | defaultServerDumpFile = "serverdump.json" 10 | ) 11 | 12 | // CfgDebug represents options for debugging and development. 13 | type CfgDebug struct { 14 | // stdout "debug" msgs 15 | EnableDebugMessages bool `json:"debugMessages"` 16 | // dump server JSON to disk 17 | EnableServerDump bool `json:"dumpServers"` 18 | // use a pre-defined server JSON file as the master list for API (for testing) 19 | ServerDumpFileAsMasterList bool `json:"useServerDumpAsMaster"` 20 | // name of the pre-defined server JSON file to use as master list 21 | ServerDumpFilename string `json:"serverDumpFilename"` 22 | } 23 | -------------------------------------------------------------------------------- /src/config/logconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | "github.com/syncore/a2sapi/src/constants" 11 | ) 12 | 13 | const ( 14 | defaultEnableAppLogging = false 15 | defaultEnableSteamLogging = false 16 | defaultEnableWebLogging = false 17 | defaultMaxLogSize = 5120 18 | defaultMaxLogCount = 5 19 | ) 20 | 21 | // CfgLog represents logging-related configuration options. 22 | type CfgLog struct { 23 | EnableAppLogging bool `json:"enableAppLogging"` 24 | EnableSteamLogging bool `json:"enableSteamLogging"` 25 | EnableWebLogging bool `json:"enableWebLogging"` 26 | MaximumLogSize int64 `json:"maxLogFilesize"` 27 | MaximumLogCount int `json:"maxLogCount"` 28 | } 29 | 30 | func configureLoggingEnable(reader *bufio.Reader, logt constants.LogType) bool { 31 | valid, val, defaultVal := false, false, false 32 | var prompt string 33 | 34 | switch logt { 35 | case constants.LTypeApp: 36 | defaultVal = defaultEnableAppLogging 37 | prompt = fmt.Sprintf(` 38 | Log application-related info and error messages to disk? 39 | %s`, promptColor("> 'yes' or 'no' [default: %s]: ", 40 | getBoolString(defaultEnableAppLogging))) 41 | 42 | case constants.LTypeSteam: 43 | defaultVal = defaultEnableSteamLogging 44 | prompt = fmt.Sprintf(` 45 | Log Steam connection info and error messages to disk? 46 | NOTE: this can dramatically increase resource usage and should only be 47 | used for debugging. 48 | %s`, promptColor("> 'yes' or 'no' [default: %s]: ", 49 | getBoolString(defaultEnableSteamLogging))) 50 | 51 | case constants.LTypeWeb: 52 | defaultVal = defaultEnableWebLogging 53 | prompt = fmt.Sprintf(` 54 | Should API web-related info and error messages be 55 | logged to disk? 56 | %s`, promptColor("> 'yes' or 'no' [default: %s]: ", 57 | getBoolString(defaultEnableWebLogging))) 58 | } 59 | input := func(r *bufio.Reader, lt constants.LogType) (bool, error) { 60 | enable, rserr := r.ReadString('\n') 61 | if rserr != nil { 62 | return defaultVal, fmt.Errorf("Unable to read respone: %s", rserr) 63 | } 64 | if enable == newline { 65 | return defaultVal, nil 66 | } 67 | response := strings.Trim(enable, newline) 68 | if strings.EqualFold(response, "y") || strings.EqualFold(response, "yes") { 69 | return true, nil 70 | } else if strings.EqualFold(response, "n") || strings.EqualFold(response, 71 | "no") { 72 | return false, nil 73 | } else { 74 | return defaultVal, 75 | fmt.Errorf("[ERROR] Invalid response. Valid responses: y, yes, n, no") 76 | } 77 | } 78 | var err error 79 | for !valid { 80 | fmt.Fprintf(color.Output, prompt) 81 | val, err = input(reader, logt) 82 | if err != nil { 83 | errorColor(err) 84 | } else { 85 | valid = true 86 | } 87 | } 88 | return val 89 | } 90 | 91 | func configureMaxLogSize(reader *bufio.Reader) int64 { 92 | valid := false 93 | var val int64 94 | prompt := fmt.Sprintf(` 95 | Enter the maximum file size for log files in Kilobytes. 96 | By default this is %d, or %d megabyte(s). 97 | %s`, defaultMaxLogSize, defaultMaxLogSize/1024, 98 | promptColor("> [default: %d]: ", defaultMaxLogSize)) 99 | 100 | input := func(r *bufio.Reader) (int64, error) { 101 | sizeval, rserr := r.ReadString('\n') 102 | if rserr != nil { 103 | return defaultMaxLogSize, fmt.Errorf("Unable to read response: %s", rserr) 104 | } 105 | if sizeval == newline { 106 | return defaultMaxLogSize, nil 107 | } 108 | response, rserr := strconv.Atoi(strings.Trim(sizeval, newline)) 109 | if rserr != nil { 110 | return defaultMaxLogSize, 111 | fmt.Errorf("[ERROR] Maximum log file size must be a positive number") 112 | } 113 | if response <= 0 { 114 | return defaultMaxLogSize, 115 | fmt.Errorf("[ERROR] Maximum log file size must be a positive number") 116 | } 117 | return int64(response), nil 118 | } 119 | var err error 120 | for !valid { 121 | fmt.Fprintf(color.Output, prompt) 122 | val, err = input(reader) 123 | if err != nil { 124 | errorColor(err) 125 | } else { 126 | valid = true 127 | } 128 | } 129 | return val 130 | } 131 | 132 | func configureMaxLogCount(reader *bufio.Reader) int { 133 | valid := false 134 | var val int 135 | prompt := fmt.Sprintf(` 136 | Enter the maximum number of log files to keep. 137 | %s`, promptColor("> [default: %d]: ", defaultMaxLogCount)) 138 | 139 | input := func(r *bufio.Reader) (int, error) { 140 | sizeval, rserr := r.ReadString('\n') 141 | if rserr != nil { 142 | return defaultMaxLogCount, fmt.Errorf("Unable to read response: %s", rserr) 143 | } 144 | if sizeval == newline { 145 | return defaultMaxLogCount, nil 146 | } 147 | response, rserr := strconv.Atoi(strings.Trim(sizeval, newline)) 148 | if rserr != nil { 149 | return defaultMaxLogCount, 150 | fmt.Errorf("[ERROR] Maximum log count must be a positive number") 151 | } 152 | if response <= 0 { 153 | return defaultMaxLogCount, 154 | fmt.Errorf("[ERROR] Maximum log count must be a positive number") 155 | } 156 | return response, nil 157 | } 158 | var err error 159 | for !valid { 160 | fmt.Fprintf(color.Output, prompt) 161 | val, err = input(reader) 162 | if err != nil { 163 | errorColor(err) 164 | } else { 165 | valid = true 166 | } 167 | } 168 | return val 169 | } 170 | -------------------------------------------------------------------------------- /src/config/steamconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | "github.com/syncore/a2sapi/src/constants" 11 | "github.com/syncore/a2sapi/src/steam/filters" 12 | ) 13 | 14 | const ( 15 | defaultMaxHostsToReceive = 4000 16 | defaultAutoQueryMaster = true 17 | defaultTimeBetweenMasterQueries = 90 18 | defaultUseWebServerList = true 19 | // defaultTimeForHighServerCount: not used in JSON, only in the config dialog 20 | defaultTimeForHighServerCount = 120 21 | ) 22 | 23 | // CfgSteam represents Steam-related configuration options. 24 | type CfgSteam struct { 25 | AutoQueryMaster bool `json:"timedMasterServerQuery"` 26 | SteamWebAPIKey string `json:"steamWebAPIKey"` 27 | UseWebServerList bool `json:"useWebServerList"` 28 | AutoQueryGame string `json:"gameForTimedMasterQuery"` 29 | TimeBetweenMasterQueries int `json:"timeBetweenMasterQueries"` 30 | MaximumHostsToReceive int `json:"maxHostsToReceive"` 31 | } 32 | 33 | func configureTimedMasterQuery(reader *bufio.Reader) bool { 34 | valid, val := false, false 35 | prompt := fmt.Sprintf(` 36 | Perform an automatic retrieval of game servers from the Steam master server at 37 | timed intervals? This is necessary if you want the API to maintain a filterable / searchable 38 | list of game servers. 39 | %s`, promptColor("> 'yes' or 'no' [default: %s]: ", 40 | getBoolString(defaultAutoQueryMaster))) 41 | 42 | input := func(r *bufio.Reader) (bool, error) { 43 | enable, rserr := r.ReadString('\n') 44 | if rserr != nil { 45 | return defaultAutoQueryMaster, 46 | fmt.Errorf("Unable to read respone: %s", rserr) 47 | } 48 | if enable == newline { 49 | return defaultAutoQueryMaster, nil 50 | } 51 | response := strings.Trim(enable, newline) 52 | if strings.EqualFold(response, "y") || strings.EqualFold(response, "yes") { 53 | return true, nil 54 | } else if strings.EqualFold(response, "n") || strings.EqualFold(response, 55 | "no") { 56 | return false, nil 57 | } else { 58 | return defaultAutoQueryMaster, 59 | fmt.Errorf("[ERROR] Invalid response. Valid responses: y, yes, n, no") 60 | } 61 | } 62 | var err error 63 | for !valid { 64 | fmt.Fprintf(color.Output, prompt) 65 | val, err = input(reader) 66 | if err != nil { 67 | errorColor(err) 68 | } else { 69 | valid = true 70 | } 71 | } 72 | return val 73 | } 74 | 75 | func configureUseSteamWebAPIList(reader *bufio.Reader) bool { 76 | valid, val := false, false 77 | prompt := fmt.Sprintf(` 78 | Use the game server list provided by the Steam Web API? This can be considerably faster than having 79 | the application perform queries to the master server, since it will use the most up-to-date 80 | server list that has already been processed by Valve. This is also preferable because the master 81 | server at times has random reliability and downtime issues on Valve's end. Note, without this method 82 | Valve will throttle your requests if more than 30 UDP packets sent (game has 6930 or more servers) 83 | within 60 seconds. 84 | %s`, promptColor("> 'yes' or 'no' [default: %s]: ", 85 | getBoolString(defaultUseWebServerList))) 86 | 87 | input := func(r *bufio.Reader) (bool, error) { 88 | enable, rserr := r.ReadString('\n') 89 | if rserr != nil { 90 | return defaultUseWebServerList, 91 | fmt.Errorf("Unable to read respone: %s", rserr) 92 | } 93 | if enable == newline { 94 | return defaultUseWebServerList, nil 95 | } 96 | response := strings.Trim(enable, newline) 97 | if strings.EqualFold(response, "y") || strings.EqualFold(response, "yes") { 98 | return true, nil 99 | } else if strings.EqualFold(response, "n") || strings.EqualFold(response, 100 | "no") { 101 | return false, nil 102 | } else { 103 | return defaultUseWebServerList, 104 | fmt.Errorf("[ERROR] Invalid response. Valid responses: y, yes, n, no") 105 | } 106 | } 107 | var err error 108 | for !valid { 109 | fmt.Fprintf(color.Output, prompt) 110 | val, err = input(reader) 111 | if err != nil { 112 | errorColor(err) 113 | } else { 114 | valid = true 115 | } 116 | } 117 | return val 118 | } 119 | 120 | func configureSteamWebAPIKey(reader *bufio.Reader) string { 121 | valid := false 122 | var val string 123 | prompt := fmt.Sprintf(` 124 | Enter your Steam Web API key for querying Valve's server list. You can receive a Web 125 | API key for free from http://steamcommunity.com/dev/apikey 126 | %s`, promptColor("> [default: NONE]: ")) 127 | 128 | input := func(r *bufio.Reader) (string, error) { 129 | keyval, rserr := r.ReadString('\n') 130 | if rserr != nil { 131 | return "", fmt.Errorf("Unable to read respone: %s", rserr) 132 | } 133 | if keyval == newline { 134 | return "", fmt.Errorf("[ERROR] Invalid response. Valid response is an API key") 135 | } 136 | response := strings.Trim(keyval, newline) 137 | return response, nil 138 | } 139 | var err error 140 | for !valid { 141 | fmt.Fprintf(color.Output, prompt) 142 | val, err = input(reader) 143 | if err != nil { 144 | errorColor(err) 145 | } else { 146 | valid = true 147 | } 148 | } 149 | return val 150 | } 151 | 152 | func configureTimedQueryGame(reader *bufio.Reader) string { 153 | valid := false 154 | var val string 155 | games := strings.Join(filters.GetGameNames(), "\n") 156 | prompt := fmt.Sprintf(` 157 | Choose the game you would like to automatically retrieve servers for at timed 158 | intervals. Possible choices are: 159 | %s 160 | More games can be added via the %s file. 161 | %s`, games, constants.GameFileFullPath, promptColor("> [default: NONE]: ")) 162 | 163 | input := func(r *bufio.Reader) (string, error) { 164 | gameval, rserr := r.ReadString('\n') 165 | if rserr != nil { 166 | return "", fmt.Errorf("Unable to read respone: %s", rserr) 167 | } 168 | if gameval == newline { 169 | return "", fmt.Errorf("[ERROR] Invalid response. Valid responses:\n%s", games) 170 | } 171 | response := strings.Trim(gameval, newline) 172 | if filters.IsValidGame(response) { 173 | // format the capitalization 174 | return filters.GetGameByName(response).Name, nil 175 | } 176 | return "", fmt.Errorf("[ERROR] Invalid response. Valid responses: %s", games) 177 | } 178 | var err error 179 | for !valid { 180 | fmt.Fprintf(color.Output, prompt) 181 | val, err = input(reader) 182 | if err != nil { 183 | errorColor(err) 184 | } else { 185 | valid = true 186 | } 187 | } 188 | return val 189 | } 190 | 191 | func configureMaxServersToRetrieve(reader *bufio.Reader) int { 192 | valid := false 193 | var val int 194 | prompt := fmt.Sprintf(` 195 | Enter the maximum number of servers to retrieve from the Steam Master Server at 196 | a time. This can be no more than 6930. 197 | %s`, promptColor("> [default: %d]: ", defaultMaxHostsToReceive)) 198 | 199 | input := func(r *bufio.Reader) (int, error) { 200 | hostsval, rserr := r.ReadString('\n') 201 | if rserr != nil { 202 | return defaultMaxHostsToReceive, fmt.Errorf("Unable to read response: %s", rserr) 203 | } 204 | if hostsval == newline { 205 | return defaultMaxHostsToReceive, nil 206 | } 207 | response, rserr := strconv.Atoi(strings.Trim(hostsval, newline)) 208 | if rserr != nil { 209 | return defaultMaxHostsToReceive, 210 | fmt.Errorf("[ERROR] Maximum hosts to receive from master server must be between 500 and 6930") 211 | } 212 | if response < 500 || response > 6930 { 213 | return defaultMaxHostsToReceive, 214 | fmt.Errorf("[ERROR] Maximum hosts to receive from master server must be between 500 and 6930") 215 | } 216 | return response, nil 217 | } 218 | var err error 219 | for !valid { 220 | fmt.Fprintf(color.Output, prompt) 221 | val, err = input(reader) 222 | if err != nil { 223 | errorColor(err) 224 | } else { 225 | valid = true 226 | } 227 | } 228 | return val 229 | } 230 | 231 | func configureTimeBetweenQueries(reader *bufio.Reader, game string) int { 232 | valid := false 233 | var val int 234 | defaultVal := defaultTimeBetweenMasterQueries 235 | highServerCountGame := filters.HasHighServerCount(game) 236 | if highServerCountGame { 237 | defaultVal = defaultTimeForHighServerCount 238 | } 239 | retrievalMethodMsg := `Note: if the game returns more than 6930 servers/min, 240 | Valve will throttle future requests for 1 min.` 241 | if defaultUseWebServerList { 242 | retrievalMethodMsg = "" 243 | } 244 | prompt := fmt.Sprintf(` 245 | Enter the time, in seconds, between requests to grab all servers from the master 246 | server. For many games this needs to be at least 60. For some games this will 247 | need to be even higher. %s 248 | %s `, retrievalMethodMsg, promptColor("> [default: %d]: ", defaultVal)) 249 | 250 | input := func(r *bufio.Reader) (int, error) { 251 | timeval, rserr := r.ReadString('\n') 252 | if rserr != nil { 253 | return defaultVal, 254 | fmt.Errorf("Unable to read response: %s", rserr) 255 | } 256 | if timeval == newline { 257 | return defaultVal, nil 258 | } 259 | response, rserr := strconv.Atoi(strings.Trim(timeval, newline)) 260 | if rserr != nil { 261 | return defaultVal, 262 | fmt.Errorf("[ERROR] Time between Steam aster server queries must be at least 60") 263 | } 264 | if response < 60 { 265 | return defaultVal, 266 | fmt.Errorf("[ERROR] Time between Steam master server queries must be at least 60") 267 | } 268 | if highServerCountGame && response < defaultTimeForHighServerCount { 269 | return defaultVal, fmt.Errorf(` 270 | [ERROR] Game %s typically returns more than 6930 servers so the time between 271 | Steam master server queries will need to be at least %d`, game, 272 | defaultTimeForHighServerCount) 273 | } 274 | return response, nil 275 | } 276 | var err error 277 | for !valid { 278 | fmt.Fprintf(color.Output, prompt) 279 | val, err = input(reader) 280 | if err != nil { 281 | errorColor(err) 282 | } else { 283 | valid = true 284 | } 285 | } 286 | return val 287 | } 288 | -------------------------------------------------------------------------------- /src/config/webconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | ) 11 | 12 | const ( 13 | defaultAllowDirectUserQueries = false 14 | defaultMaxHostsPerAPIQuery = 12 15 | defaultAPIWebTimeout = 7 16 | defaultAPIWebPort = 40080 17 | defaultCompressResponses = true 18 | ) 19 | 20 | // CfgWeb represents web-related API configuration options. 21 | type CfgWeb struct { 22 | AllowDirectUserQueries bool `json:"allowDirectUserQueries"` 23 | APIWebPort int `json:"apiWebPort"` 24 | APIWebTimeout int `json:"apiWebTimeout"` 25 | CompressResponses bool `json:"compressResponses"` 26 | MaximumHostsPerAPIQuery int `json:"maxHostsPerAPIQuery"` 27 | } 28 | 29 | func configureDirectQueries(reader *bufio.Reader, timedEnabled bool) bool { 30 | valid, val := false, false 31 | note := "" 32 | if !timedEnabled { 33 | note = ` 34 | Note: you disabled timed master queries in the previous option, so 35 | if you do not enable this option then there will be no way for users to make 36 | queries (if your server ID database is empty.)` 37 | } 38 | prompt := fmt.Sprintf(` 39 | Allow users to directly query *any* IP address, not just those in the serverID 40 | database? This may have some issues for some games and it also may have abuse 41 | implications since your server could query unknown, user-specified IP addresses.%s 42 | %s`, note, promptColor("> 'yes' or 'no' [default: %s]: ", 43 | getBoolString(defaultAllowDirectUserQueries))) 44 | 45 | input := func(r *bufio.Reader) (bool, error) { 46 | enable, rserr := r.ReadString('\n') 47 | if rserr != nil { 48 | return defaultAllowDirectUserQueries, 49 | fmt.Errorf("Unable to read respone: %s", rserr) 50 | } 51 | if enable == newline { 52 | return defaultAllowDirectUserQueries, nil 53 | } 54 | response := strings.Trim(enable, newline) 55 | if strings.EqualFold(response, "y") || strings.EqualFold(response, "yes") { 56 | return true, nil 57 | } else if strings.EqualFold(response, "n") || strings.EqualFold(response, 58 | "no") { 59 | return false, nil 60 | } else { 61 | return defaultAllowDirectUserQueries, 62 | fmt.Errorf("[ERROR] Invalid response. Valid responses: y, yes, n, no") 63 | } 64 | } 65 | var err error 66 | for !valid { 67 | fmt.Fprintf(color.Output, prompt) 68 | val, err = input(reader) 69 | if err != nil { 70 | errorColor(err) 71 | } else { 72 | valid = true 73 | } 74 | } 75 | return val 76 | } 77 | 78 | func configureMaxHostsPerAPIQuery(reader *bufio.Reader) int { 79 | valid := false 80 | var val int 81 | prompt := fmt.Sprintf(` 82 | Enter the maximum number of servers that users may query at a time via the API. 83 | %s`, promptColor("> [default: %d]: ", defaultMaxHostsPerAPIQuery)) 84 | 85 | input := func(r *bufio.Reader) (int, error) { 86 | hostsval, rserr := r.ReadString('\n') 87 | if rserr != nil { 88 | return defaultMaxHostsPerAPIQuery, fmt.Errorf("Unable to read response: %s", 89 | rserr) 90 | } 91 | if hostsval == newline { 92 | return defaultMaxHostsPerAPIQuery, nil 93 | } 94 | response, rserr := strconv.Atoi(strings.Trim(hostsval, newline)) 95 | if rserr != nil { 96 | return defaultMaxHostsPerAPIQuery, 97 | fmt.Errorf("[ERROR] Maximum hosts to allow per API query must be a positive number") 98 | } 99 | if response <= 0 { 100 | return defaultMaxHostsPerAPIQuery, 101 | fmt.Errorf("[ERROR] Maximum hosts to allow per API query must be a positive number") 102 | } 103 | return response, nil 104 | } 105 | var err error 106 | for !valid { 107 | fmt.Fprintf(color.Output, prompt) 108 | val, err = input(reader) 109 | if err != nil { 110 | errorColor(err) 111 | } else { 112 | valid = true 113 | } 114 | } 115 | return val 116 | } 117 | 118 | func configureWebServerPort(reader *bufio.Reader) int { 119 | valid := false 120 | var val int 121 | prompt := fmt.Sprintf(` 122 | Enter the port number on which the API web server will listen. 123 | %s`, promptColor("> [default: %d]: ", defaultAPIWebPort)) 124 | 125 | input := func(r *bufio.Reader) (int, error) { 126 | portval, rserr := r.ReadString('\n') 127 | if rserr != nil { 128 | return defaultAPIWebPort, fmt.Errorf("Unable to read response: %s", rserr) 129 | } 130 | if portval == newline { 131 | return defaultAPIWebPort, nil 132 | } 133 | response, rserr := strconv.Atoi(strings.Trim(portval, newline)) 134 | if rserr != nil { 135 | return defaultAPIWebPort, 136 | fmt.Errorf("[ERROR] API webserver port must be between 1 and 65535") 137 | } 138 | if response < 1 || response > 65535 { 139 | return defaultAPIWebPort, 140 | fmt.Errorf("[ERROR] API webserver port must be between 1 and 65535") 141 | } 142 | return response, nil 143 | } 144 | var err error 145 | for !valid { 146 | fmt.Fprintf(color.Output, prompt) 147 | val, err = input(reader) 148 | if err != nil { 149 | errorColor(err) 150 | } else { 151 | valid = true 152 | } 153 | } 154 | return val 155 | } 156 | 157 | func configureWebTimeout(reader *bufio.Reader) int { 158 | valid := false 159 | var val int 160 | prompt := fmt.Sprintf(` 161 | Enter the time in seconds before an HTTP request times out. This must be at 162 | least 5 seconds; don't set this too low or the response will not be returned 163 | to the user. 164 | %s`, promptColor("> [default: %d]: ", defaultAPIWebTimeout)) 165 | 166 | input := func(r *bufio.Reader) (int, error) { 167 | timeoutval, rserr := r.ReadString('\n') 168 | if rserr != nil { 169 | return defaultAPIWebTimeout, fmt.Errorf("Unable to read response: %s", 170 | rserr) 171 | } 172 | if timeoutval == newline { 173 | return defaultAPIWebTimeout, nil 174 | } 175 | response, rserr := strconv.Atoi(strings.Trim(timeoutval, newline)) 176 | if rserr != nil { 177 | return defaultAPIWebTimeout, 178 | fmt.Errorf("[ERROR] API timeout cannot be less than 5 seconds") 179 | } 180 | if response < 5 { 181 | return defaultAPIWebTimeout, 182 | fmt.Errorf("[ERROR] API timeout cannot be less than 5 seconds") 183 | } 184 | return response, nil 185 | } 186 | var err error 187 | for !valid { 188 | fmt.Fprintf(color.Output, prompt) 189 | val, err = input(reader) 190 | if err != nil { 191 | errorColor(err) 192 | } else { 193 | valid = true 194 | } 195 | } 196 | return val 197 | } 198 | 199 | func configureResponseCompression(reader *bufio.Reader) bool { 200 | valid, val := false, false 201 | prompt := fmt.Sprintf(` 202 | Should HTTP responses be compressed (with gzip)? You should disable this if you 203 | have a reverse proxy such as nginx or another server in front of the API that 204 | already handles compression for you. 205 | %s`, promptColor("> 'yes' or 'no' [default: %s]: ", 206 | getBoolString(defaultCompressResponses))) 207 | 208 | input := func(r *bufio.Reader) (bool, error) { 209 | enable, rserr := r.ReadString('\n') 210 | if rserr != nil { 211 | return defaultCompressResponses, 212 | fmt.Errorf("Unable to read respone: %s", rserr) 213 | } 214 | if enable == newline { 215 | return defaultCompressResponses, nil 216 | } 217 | response := strings.Trim(enable, newline) 218 | if strings.EqualFold(response, "y") || strings.EqualFold(response, "yes") { 219 | return true, nil 220 | } else if strings.EqualFold(response, "n") || strings.EqualFold(response, 221 | "no") { 222 | return false, nil 223 | } else { 224 | return defaultCompressResponses, 225 | fmt.Errorf("[ERROR] Invalid response. Valid responses: y, yes, n, no") 226 | } 227 | } 228 | var err error 229 | for !valid { 230 | fmt.Fprintf(color.Output, prompt) 231 | val, err = input(reader) 232 | if err != nil { 233 | errorColor(err) 234 | } else { 235 | valid = true 236 | } 237 | } 238 | return val 239 | } 240 | -------------------------------------------------------------------------------- /src/constants/config_constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // config_constants.go - Configuration-related constants (and a few variables) 4 | 5 | import "path" 6 | 7 | const ( 8 | // ConfigDirectory specifies the directory in which to store the config file. 9 | ConfigDirectory = "conf" 10 | // ConfigFilename specifies the name of the configuration file. 11 | ConfigFilename = "config.conf" 12 | // DebugConfigFilename specifies the name of the configuration file to use when 13 | // debug mode is set 14 | DebugConfigFilename = "debug.conf" 15 | ) 16 | 17 | var ( 18 | // IsDebug will determine whether the debug configuration is used. This is 19 | // set on application startup. 20 | IsDebug = false 21 | // IsTest will determine whether the test configuration is used when running 22 | // tests. This variable is only set when running tests. 23 | IsTest = false 24 | // ConfigFilePath represents the OS-independent full path to the config file. 25 | ConfigFilePath = path.Join(ConfigDirectory, ConfigFilename) 26 | // DebugConfigFilePath represents the OS-independent full path to the debug 27 | // configuration file. 28 | DebugConfigFilePath = path.Join(ConfigDirectory, DebugConfigFilename) 29 | ) 30 | 31 | // GetCfgPath returns the full OS-independent path to the configuration file. 32 | func GetCfgPath() string { 33 | if IsTest { 34 | return path.Join(TestTempDirectory, TestConfigFilename) 35 | } 36 | if IsDebug { 37 | return path.Join(ConfigDirectory, DebugConfigFilename) 38 | } 39 | return path.Join(ConfigDirectory, ConfigFilename) 40 | } 41 | -------------------------------------------------------------------------------- /src/constants/db_constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // db_constants.go - Database-related constants (and a few variables) 4 | 5 | import "path" 6 | 7 | const ( 8 | // DbDirectory specifies the directory in which to store the database files. 9 | DbDirectory = "db" 10 | // ServerDbFilename specifies the name of the server database file. 11 | ServerDbFilename = "servers.sqlite" 12 | // CountryMMDbFilename specifies the name of geolocation database file. 13 | CountryMMDbFilename = "GeoLite2-City.mmdb" 14 | ) 15 | 16 | var ( 17 | // CountryDbFilePath represents the OS-independent full path to the geolocation DB file. 18 | CountryDbFilePath = path.Join(DbDirectory, CountryMMDbFilename) 19 | // ServerDbFilePath represents the OS-independent full path to the server DB file. 20 | ServerDbFilePath = path.Join(DbDirectory, ServerDbFilename) 21 | ) 22 | 23 | // GetServerDBPath returns the full OS-independent path to the server DB file. 24 | func GetServerDBPath() string { 25 | if IsTest { 26 | return path.Join(TestTempDirectory, TestServerDbFilename) 27 | } 28 | if IsDebug { 29 | return path.Join(DbDirectory, ServerDbFilename) 30 | } 31 | return path.Join(DbDirectory, ServerDbFilename) 32 | } 33 | -------------------------------------------------------------------------------- /src/constants/logger_constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // logger_constants.go - Logger-related constants (and a few variables) 4 | 5 | import "path" 6 | 7 | const ( 8 | // LogDirectory specifies the directory in which to store the log files. 9 | LogDirectory = "logs" 10 | // AppLogFilename specifies the name of the application log file. 11 | AppLogFilename = "app.log" 12 | // SteamLogFilename specifies the name of the Steam log file. 13 | SteamLogFilename = "steam.log" 14 | // WebLogFilename specifies the name of the web log file. 15 | WebLogFilename = "web.log" 16 | ) 17 | 18 | // LogType represents the type of log. 19 | type LogType int 20 | 21 | const ( 22 | // LTypeApp represents the Application-related log type. 23 | LTypeApp LogType = iota 24 | // LTypeDebug represents the Debug-related log type. 25 | LTypeDebug 26 | // LTypeSteam represents the Steam-related log type. 27 | LTypeSteam 28 | // LTypeWeb represents the Web-related log type. 29 | LTypeWeb 30 | ) 31 | 32 | var ( 33 | // AppLogFilePath represents the OS-independent full path to app log file. 34 | AppLogFilePath = path.Join(LogDirectory, AppLogFilename) 35 | // SteamLogFilePath represents the OS-independent full path to Steam log file. 36 | SteamLogFilePath = path.Join(LogDirectory, SteamLogFilename) 37 | // WebLogFilePath represents the OS-independent full path to web log file. 38 | WebLogFilePath = path.Join(LogDirectory, WebLogFilename) 39 | ) 40 | -------------------------------------------------------------------------------- /src/constants/misc_constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // misc_constants.go - Miscellaneous-related constants (and a few variables) 4 | 5 | import ( 6 | "fmt" 7 | "path" 8 | ) 9 | 10 | const ( 11 | // DumpDirectory represents the directory name used for server dump JSON files. 12 | DumpDirectory = "dump" 13 | // GameFile specifies the name of the Steam games file. 14 | GameFile = "games.conf" 15 | ) 16 | 17 | var ( 18 | // DumpFileFullPath represents the OS-independent full path to the server dump 19 | // JSON file. 20 | DumpFileFullPath = func(dumpfile string) string { 21 | return path.Join(DumpDirectory, dumpfile) 22 | } 23 | // GameFileFullPath represents the OS-independent full path to the game file. 24 | GameFileFullPath = path.Join(ConfigDirectory, GameFile) 25 | // Version is the version number of the application. 26 | Version = "0.1.8" 27 | // AppInfo contains the application information. 28 | AppInfo = fmt.Sprintf("a2sapi v%s by syncore ", Version) 29 | ) 30 | -------------------------------------------------------------------------------- /src/constants/test_constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // test_constants.go - Test-related constants (and a few variables) 4 | 5 | import "path" 6 | 7 | const ( 8 | // TestTempDirectory specifies the temporary directory for test-related files. 9 | TestTempDirectory = "test_temp" 10 | // TestConfigFilename specifies the name of the test configuration file. 11 | TestConfigFilename = "test.conf" 12 | // TestServerDbFilename specifies the name of the server database file used in 13 | // tests. 14 | TestServerDbFilename = "servers_test.sqlite" 15 | ) 16 | 17 | var ( 18 | // TestConfigFilePath represents the OS-independent full path to the config file. 19 | TestConfigFilePath = path.Join(TestTempDirectory, TestConfigFilename) 20 | // TestServerDbFilePath represents the OS-independent full path to the server 21 | // database file used in tests. 22 | TestServerDbFilePath = path.Join(TestTempDirectory, TestServerDbFilename) 23 | 24 | // TestServerDumpJSON is the JSON used for the server dump when performing 25 | // tests. 26 | TestServerDumpJSON = []byte(` 27 | {"retrievalDate":"Sat Dec 26 23:08:14 2015 EST","timestamp":1451189294, 28 | "serverCount":3,"servers":[{"serverId":1029,"address":"54.172.5.67:25801", 29 | "ip":"54.172.5.67","port":25801,"location":{"countryName":"United States", 30 | "countryCode":"US","region":"North America","state":"VA"}, 31 | "info":{"protocol":17,"serverName":"TurboPixel Appreciation Society (Official) #1", 32 | "map":"xfdm2","gameDir":"base","game":"Reflex","players":5,"maxPlayers":8, 33 | "serverType":"dedicated","serverOS":"Windows","antiCheat":1,"serverVersion":"0.38.2", 34 | "extra":{"gamePort":25800,"serverSteamId":90098615517053960,"sourceTvProxyPort":0, 35 | "sourceTvProxyName":"","keywords":"atdm||62|1","steamAppId":328070}}, 36 | "players":[{"name":"KovaaK","score":92,"secsConnected":4317.216, 37 | "totalConnected":"1h11m57s"},{"name":"Sharqosity","score":42, 38 | "secsConnected":3428.6987,"totalConnected":"57m8s"},{"name":"dhaK","score":42, 39 | "secsConnected":1730.0668, 40 | "totalConnected":"28m50s"},{"name":"yoo","score":45,"secsConnected":467.6571, 41 | "totalConnected":"7m47s"},{"name":"twitch.tv/liveanton - SANE","score":75, 42 | "secsConnected":452.20792,"totalConnected":"7m32s"}], 43 | "realPlayers":{"count":5,"players":[{"name":"KovaaK","score":92, 44 | "secsConnected":4317.216,"totalConnected":"1h11m57s"},{"name":"Sharqosity", 45 | "score":42,"secsConnected":3428.6987,"totalConnected":"57m8s"},{"name":"dhaK", 46 | "score":42,"secsConnected":1730.0668,"totalConnected":"28m50s"},{"name":"yoo", 47 | "score":45,"secsConnected":467.6571,"totalConnected":"7m47s"}, 48 | {"name":"twitch.tv/liveanton - SANE","score":75,"secsConnected":452.20792, 49 | "totalConnected":"7m32s"}]},"rules":{}},{"serverId":360,"address":"192.211.62.11:27960", 50 | "ip":"192.211.62.11","port":27960,"location":{"countryName":"United States", 51 | "countryCode":"US","region":"North America","state":"TX"},"info":{"protocol":17, 52 | "serverName":"exile.syncore.org | US-Central #1 | Competitive","map":"overkill", 53 | "gameDir":"baseq3","game":"Clan Arena","maxPlayers":16,"serverType":"dedicated", 54 | "serverOS":"Linux","antiCheat":1,"serverVersion":"1066","extra":{"gamePort":27960, 55 | "serverSteamId":90098677041473542,"sourceTvProxyPort":0,"sourceTvProxyName":"", 56 | "keywords":"clanarena,minqlx,syncore,texas,central,newmaps","steamAppId":282440}}, 57 | "players":[],"realPlayers":{"count":0,"players":[]},"rules":{"capturelimit":"8", 58 | "dmflags":"28","fraglimit":"50","g_adCaptureScoreBonus":"3","g_adElimScoreBonus":"2", 59 | "g_adTouchScoreBonus":"1","g_blueScore":"","g_customSettings":"0","g_factory":"ca", 60 | "g_factoryTitle":"Clan Arena","g_freezeRoundDelay":"4000","g_gameState":"PRE_GAME", 61 | "g_gametype":"4","g_gravity":"800", 62 | "g_instaGib":"0","g_itemHeight":"35","g_itemTimers":"1","g_levelStartTime":"1451179049", 63 | "g_loadout":"0","g_needpass":"0","g_overtime":"0","g_quadDamageFactor":"3","g_redScore":"", 64 | "g_roundWarmupDelay":"10000","g_startingHealth":"200","g_teamForceBalance":"1", 65 | "g_teamSizeMin":"1","g_timeoutCount":"0","g_voteFlags":"0","g_weaponRespawn":"5", 66 | "mapname":"overkill","mercylimit":"0","protocol":"91","roundlimit":"10", 67 | "roundtimelimit":"180","scorelimit":"150", 68 | "sv_hostname":"exile.syncore.org | US-Central #1 | Competitive", 69 | "sv_maxclients":"16","sv_privateClients":"0","teamsize":"4","timelimit":"0", 70 | "version":"1066 linux-x64 Dec 17 2015 15:36:49"}},{"serverId":746, 71 | "address":"45.55.168.160:27960","ip":"45.55.168.160","port":27960, 72 | "location":{"countryName":"United States","countryCode":"US","region":"North America", 73 | "state":"NY"},"info":{"protocol":17, 74 | "serverName":"triton.syncore.org | US-East #1 | Competitive","map":"overkill", 75 | "gameDir":"baseq3","game":"Clan Arena","maxPlayers":16,"serverType":"dedicated", 76 | "serverOS":"Linux","antiCheat":1,"serverVersion":"1066","extra":{"gamePort":27960, 77 | "serverSteamId":90098677079644165, 78 | "sourceTvProxyPort":0,"sourceTvProxyName":"", 79 | "keywords":"clanarena,minqlx,syncore,newyork,east,newmaps","steamAppId":282440}}, 80 | "players":[],"realPlayers":{"count":0,"players":[]},"rules":{"capturelimit":"8", 81 | "dmflags":"28","fraglimit":"50","g_adCaptureScoreBonus":"3","g_adElimScoreBonus":"2", 82 | "g_adTouchScoreBonus":"1","g_blueScore":"","g_customSettings":"0","g_factory":"ca", 83 | "g_factoryTitle":"Clan Arena","g_freezeRoundDelay":"4000","g_gameState":"PRE_GAME", 84 | "g_gametype":"4", 85 | "g_gravity":"800","g_instaGib":"0","g_itemHeight":"35","g_itemTimers":"1", 86 | "g_levelStartTime":"1451179064","g_loadout":"0","g_needpass":"0","g_overtime":"0", 87 | "g_quadDamageFactor":"3","g_redScore":"","g_roundWarmupDelay":"10000", 88 | "g_startingHealth":"200","g_teamForceBalance":"1","g_teamSizeMin":"1", 89 | "g_timeoutCount":"0","g_voteFlags":"0","g_weaponRespawn":"5","mapname":"overkill", 90 | "mercylimit":"0","protocol":"91","roundlimit":"10","roundtimelimit":"180", 91 | "scorelimit":"150","sv_hostname":"triton.syncore.org | US-East #1 | Competitive", 92 | "sv_maxclients":"16","sv_privateClients":"0","teamsize":"4","timelimit":"0", 93 | "version":"1066 linux-x64 Dec 17 2015 15:36:49"}}],"failedCount":0,"failedServers":[]} 94 | `) 95 | ) 96 | -------------------------------------------------------------------------------- /src/db/country.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | // country.go - Country geolocation database lookup. 4 | 5 | import ( 6 | "fmt" 7 | "net" 8 | "runtime" 9 | 10 | "github.com/syncore/a2sapi/src/constants" 11 | "github.com/syncore/a2sapi/src/logger" 12 | "github.com/syncore/a2sapi/src/models" 13 | 14 | "github.com/oschwald/maxminddb-golang" 15 | ) 16 | 17 | // CDB represents a database containing geolocation information. 18 | type CDB struct { 19 | db *maxminddb.Reader 20 | } 21 | 22 | // This is an intermediate struct to represent the MaxMind DB format, not for JSON 23 | type mmdbformat struct { 24 | Country struct { 25 | IsoCode string `maxminddb:"iso_code"` 26 | Names map[string]string `maxminddb:"names"` 27 | } `maxminddb:"country"` 28 | Continent struct { 29 | Names map[string]string `maxminddb:"names"` 30 | } `maxminddb:"continent"` 31 | Subdivisions []struct { 32 | IsoCode string `maxminddb:"iso_code"` 33 | } `maxminddb:"subdivisions"` 34 | } 35 | 36 | func getDefaultCountryData() models.DbCountry { 37 | return models.DbCountry{ 38 | CountryName: "Unknown", 39 | CountryCode: "Unknown", 40 | Continent: "Unknown", 41 | State: "Unknown", 42 | } 43 | } 44 | 45 | // OpenCountryDB opens the country lookup database for reading. The caller of 46 | // this function will be responsinble for calling .Close(). 47 | func OpenCountryDB() (*CDB, error) { 48 | // Note: the caller of this function needs to handle .Close() 49 | conn, err := maxminddb.Open(constants.CountryDbFilePath) 50 | if err != nil { 51 | dir := "build/nix" 52 | if runtime.GOOS == "windows" { 53 | dir = "build\\win" 54 | } 55 | logger.LogAppError(err) 56 | panic( 57 | fmt.Sprintf( 58 | `Unable to open country database! Use the get_countrydb script in the %s 59 | directory to get the country DB file, or download it from: 60 | http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz and 61 | extract the "GeoLite2-City.mmdb" file into a directory called "db" in the same 62 | directory as the a2sapi executable. Error: %s`, dir, err)) 63 | } 64 | return &CDB{db: conn}, nil 65 | } 66 | 67 | // Close closes the country geolocation database. 68 | func (cdb *CDB) Close() { 69 | err := cdb.db.Close() 70 | if err != nil { 71 | logger.LogAppErrorf("Error closing country database: %s", err) 72 | } 73 | } 74 | 75 | // GetCountryInfo attempts to retrieve the country information for a given IP, 76 | // returning the result as a country model object over the corresponding result channel. 77 | func (cdb *CDB) GetCountryInfo(ch chan<- models.DbCountry, ipstr string) { 78 | ip := net.ParseIP(ipstr) 79 | c := &mmdbformat{} 80 | err := cdb.db.Lookup(ip, c) 81 | if err != nil { 82 | ch <- getDefaultCountryData() 83 | return 84 | } 85 | if c.Country.Names["en"] == "" || c.Country.IsoCode == "" { 86 | ch <- getDefaultCountryData() 87 | return 88 | } 89 | 90 | countrydata := models.DbCountry{ 91 | CountryName: c.Country.Names["en"], 92 | CountryCode: c.Country.IsoCode, 93 | Continent: c.Continent.Names["en"], 94 | } 95 | if c.Country.IsoCode == "US" { 96 | if len(c.Subdivisions) > 0 { 97 | countrydata.State = c.Subdivisions[0].IsoCode 98 | } else { 99 | countrydata.State = "Unknown" 100 | } 101 | } else { 102 | countrydata.State = "None" 103 | } 104 | ch <- countrydata 105 | } 106 | -------------------------------------------------------------------------------- /src/db/country_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/syncore/a2sapi/src/models" 8 | "github.com/syncore/a2sapi/src/test" 9 | ) 10 | 11 | func init() { 12 | test.SetupEnvironment() 13 | } 14 | 15 | func TestOpenCountryDB(t *testing.T) { 16 | db, err := OpenCountryDB() 17 | // Will panic anyway 18 | if err != nil { 19 | t.Fatalf("Error opening country database: %s", err) 20 | } 21 | defer db.Close() 22 | } 23 | 24 | func TestGetCountryInfo(t *testing.T) { 25 | cdb, err := OpenCountryDB() 26 | if err != nil { 27 | t.Fatalf("Error opening country database: %s", err) 28 | } 29 | defer cdb.Close() 30 | c := make(chan models.DbCountry, 1) 31 | ip := "192.211.62.11" 32 | cinfo := models.DbCountry{} 33 | go cdb.GetCountryInfo(c, ip) 34 | cinfo = <-c 35 | if !strings.EqualFold(cinfo.CountryCode, "US") { 36 | t.Fatalf("Expected country code to be US for IP: %s, got: %s", 37 | ip, cinfo.CountryCode) 38 | } 39 | ip = "89.20.244.197" 40 | cinfo = models.DbCountry{} 41 | go cdb.GetCountryInfo(c, ip) 42 | cinfo = <-c 43 | if !strings.EqualFold(cinfo.CountryCode, "NO") { 44 | t.Fatalf("Expected country code to be NO for IP: %s, got: %s", 45 | ip, cinfo.CountryCode) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/db/database.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | // database.go - Database initilization. 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/syncore/a2sapi/src/constants" 9 | "github.com/syncore/a2sapi/src/logger" 10 | "github.com/syncore/a2sapi/src/util" 11 | ) 12 | 13 | // CountryDB is a package-level variable that contains a country 14 | // geoelocation database connection. It is initialized once for re-usability 15 | // when building server lists. 16 | var CountryDB *CDB 17 | 18 | // ServerDB is a package-level variable that contains a server information 19 | // database connection. It is initialized once for re-usability when building 20 | // server lists. 21 | var ServerDB *SDB 22 | 23 | // InitDBs initializes the geolocation and server information databases for 24 | // re-use across server list builds. Panics on failure to initialize. 25 | func InitDBs() { 26 | if CountryDB != nil && ServerDB != nil { 27 | return 28 | } 29 | 30 | cdb, err := OpenCountryDB() 31 | if err != nil { 32 | panic(fmt.Sprintf("Unable to initialize country database connection: %s", 33 | err)) 34 | } 35 | sdb, err := OpenServerDB() 36 | if err != nil { 37 | panic(fmt.Sprintf( 38 | "Unable to initialize server information database connection: %s", err)) 39 | } 40 | // Set package-level variables 41 | CountryDB = cdb 42 | ServerDB = sdb 43 | } 44 | 45 | func verifyServerDbPath() error { 46 | if err := util.CreateDirectory(constants.DbDirectory); err != nil { 47 | logger.LogAppError(err) 48 | panic(fmt.Sprintf("Unable to create database directory %s: %s", 49 | constants.DbDirectory, err)) 50 | } 51 | if err := createServerDBtable(constants.GetServerDBPath()); err != nil { 52 | logger.LogAppErrorf("Unable to verify database path: %s", err) 53 | panic("Unable to verify database path") 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /src/db/servers.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | // servers.go - server identification database 4 | 5 | import ( 6 | "database/sql" 7 | "fmt" 8 | 9 | "github.com/syncore/a2sapi/src/constants" 10 | "github.com/syncore/a2sapi/src/logger" 11 | "github.com/syncore/a2sapi/src/models" 12 | "github.com/syncore/a2sapi/src/steam/filters" 13 | "github.com/syncore/a2sapi/src/util" 14 | // blank import for sqlite3 driver 15 | _ "github.com/mattn/go-sqlite3" 16 | ) 17 | 18 | // SDB represents a database containing the server ID and game information. 19 | type SDB struct { 20 | db *sql.DB 21 | } 22 | 23 | func createServerDBtable(dbfile string) error { 24 | create := `CREATE TABLE servers ( 25 | server_id INTEGER NOT NULL, 26 | host TEXT NOT NULL, 27 | game TEXT NOT NULL, 28 | PRIMARY KEY(server_id) 29 | )` 30 | 31 | if util.FileExists(dbfile) { 32 | // already exists, so verify integrity 33 | db, err := sql.Open("sqlite3", dbfile) 34 | if err != nil { 35 | return logger.LogAppErrorf( 36 | "Unable to open server DB file for verification: %s", err) 37 | } 38 | defer db.Close() 39 | var name string 40 | err = db.QueryRow( 41 | "SELECT name from sqlite_master where type='table' and name='servers'").Scan(&name) 42 | switch { 43 | case err == sql.ErrNoRows: 44 | if _, err = db.Exec(create); err != nil { 45 | return logger.LogAppErrorf("Unable to create servers table in DB: %s", err) 46 | } 47 | case err != nil: 48 | return logger.LogAppErrorf("Server DB table verification error: %s", err) 49 | } 50 | return nil 51 | } 52 | 53 | err := util.CreateEmptyFile(dbfile, true) 54 | if err != nil { 55 | return logger.LogAppErrorf("Unable to create server DB: %s", err) 56 | } 57 | 58 | db, err := sql.Open("sqlite3", dbfile) 59 | if err != nil { 60 | return logger.LogAppErrorf( 61 | "Unable to open server DB file for table creation: %s", err) 62 | } 63 | defer db.Close() 64 | _, err = db.Exec(create) 65 | if err != nil { 66 | return logger.LogAppErrorf("Unable to create servers table in servers DB: %s", 67 | err) 68 | } 69 | return nil 70 | } 71 | 72 | func (sdb *SDB) serverExists(host string, game string) (bool, error) { 73 | rows, err := sdb.db.Query( 74 | "SELECT host, game FROM servers WHERE host =? AND GAME =? LIMIT 1", 75 | host, game) 76 | if err != nil { 77 | return false, logger.LogAppErrorf( 78 | "serverExists: Error querying database for host %s and game %s: %s", 79 | host, game, err) 80 | } 81 | 82 | defer rows.Close() 83 | h, g := "", "" 84 | for rows.Next() { 85 | if err := rows.Scan(&h, &g); err != nil { 86 | return false, logger.LogAppErrorf( 87 | "serverExists: Error querying database for host %s and game %s: %s", 88 | host, game, err) 89 | } 90 | } 91 | if h != "" && g != "" { 92 | return true, nil 93 | } 94 | return false, nil 95 | } 96 | 97 | func (sdb *SDB) getHostAndGame(id string) (host, game string, err error) { 98 | rows, err := sdb.db.Query("SELECT host, game FROM servers WHERE server_id =? LIMIT 1", 99 | id) 100 | if err != nil { 101 | return host, game, 102 | logger.LogAppErrorf("getHostAndGame: Error querying database for id %s: %s", 103 | id, err) 104 | } 105 | defer rows.Close() 106 | for rows.Next() { 107 | if err := rows.Scan(&host, &game); err != nil { 108 | return host, game, 109 | logger.LogAppErrorf("getHostAndGame: Error querying database for id %s: %s", 110 | id, err) 111 | } 112 | } 113 | return host, game, nil 114 | } 115 | 116 | // OpenServerDB Opens a database connection to the server database file or if 117 | // that file does not exists, creates it and then opens a database connection to it. 118 | func OpenServerDB() (*SDB, error) { 119 | if err := verifyServerDbPath(); err != nil { 120 | // will panic if not verified 121 | return nil, logger.LogAppError(err) 122 | } 123 | conn, err := sql.Open("sqlite3", constants.GetServerDBPath()) 124 | if err != nil { 125 | return nil, logger.LogAppError(err) 126 | } 127 | return &SDB{db: conn}, nil 128 | } 129 | 130 | // Close closes the server database's underlying connection. 131 | func (sdb *SDB) Close() { 132 | err := sdb.db.Close() 133 | if err != nil { 134 | logger.LogAppErrorf("Error closing server DB: %s", err) 135 | } 136 | } 137 | 138 | // AddServersToDB inserts a specified host and port with its game name into the 139 | // server database. 140 | func (sdb *SDB) AddServersToDB(hostsgames map[string]string) { 141 | toInsert := make(map[string]string, len(hostsgames)) 142 | for host, game := range hostsgames { 143 | // If direct queries are enabled, don't add 'Unspecified' game to server DB 144 | if game == filters.GameUnspecified.String() { 145 | continue 146 | } 147 | exists, err := sdb.serverExists(host, game) 148 | if err != nil { 149 | continue 150 | } 151 | if exists { 152 | continue 153 | } 154 | toInsert[host] = game 155 | } 156 | tx, err := sdb.db.Begin() 157 | if err != nil { 158 | logger.LogAppErrorf("AddServersToDB error creating tx: %s", err) 159 | return 160 | } 161 | var txexecerr error 162 | for host, game := range toInsert { 163 | _, txexecerr = tx.Exec("INSERT INTO servers (host, game) VALUES ($1, $2)", 164 | host, game) 165 | if txexecerr != nil { 166 | logger.LogAppErrorf( 167 | "AddServersToDB exec error for host %s and game %s: %s", host, game, err) 168 | break 169 | } 170 | } 171 | if txexecerr != nil { 172 | if err = tx.Rollback(); err != nil { 173 | logger.LogAppErrorf("AddServersToDB error rolling back tx: %s", err) 174 | return 175 | } 176 | } 177 | if err = tx.Commit(); err != nil { 178 | logger.LogAppErrorf("AddServersToDB error committing tx: %s", err) 179 | return 180 | } 181 | } 182 | 183 | // GetIDsForServerList retrieves the server ID numbers for a given set of hosts, 184 | // from the server database file, in response to a request to build the master 185 | // server detail list or the list of server details in response to a request 186 | // coming in over the API. It sends its results over a map channel consisting of 187 | // a host to id mapping. 188 | func (sdb *SDB) GetIDsForServerList(result chan map[string]int64, 189 | hosts map[string]string) { 190 | m := make(map[string]int64, len(hosts)) 191 | for host, game := range hosts { 192 | rows, err := sdb.db.Query( 193 | "SELECT server_id FROM servers WHERE host =? AND game =? LIMIT 1", 194 | host, game) 195 | if err != nil { 196 | logger.LogAppErrorf( 197 | "GetIDsForServerList: Error querying database to retrieve ID for host %s and game %s: %s", 198 | host, game, err) 199 | return 200 | } 201 | defer rows.Close() 202 | var id int64 203 | for rows.Next() { 204 | if err := rows.Scan(&id); err != nil { 205 | logger.LogAppErrorf( 206 | "GetIDsForServerList: Error querying database to retrieve ID for host %s: %s", 207 | host, err) 208 | return 209 | } 210 | } 211 | m[host] = id 212 | } 213 | result <- m 214 | } 215 | 216 | // GetIDsAPIQuery Retrieves the server ID numbers, hosts, and game name for a given 217 | // set of hosts (represented by query string values) from the server database 218 | // file in response to a query from the API. Sends the results over a DbServerID 219 | // channel for consumption. 220 | func (sdb *SDB) GetIDsAPIQuery(result chan *models.DbServerID, hosts []string) { 221 | m := &models.DbServerID{} 222 | for _, h := range hosts { 223 | logger.WriteDebug("DB: GetIDsAPIQuery, host: %s", h) 224 | rows, err := sdb.db.Query( 225 | "SELECT server_id, host, game FROM servers WHERE host LIKE ?", 226 | fmt.Sprintf("%%%s%%", h)) 227 | if err != nil { 228 | logger.LogAppErrorf( 229 | "GetIDsAPIQuery: Error querying database to retrieve ID for host %s: %s", 230 | h, err) 231 | return 232 | } 233 | defer rows.Close() 234 | var id int64 235 | host, game := "", "" 236 | 237 | for rows.Next() { 238 | sid := models.DbServer{} 239 | if err := rows.Scan(&id, &host, &game); err != nil { 240 | logger.LogAppErrorf( 241 | "GetIDsAPIQuery: Error querying database to retrieve ID for host %s: %s", 242 | h, err) 243 | return 244 | } 245 | sid.ID = id 246 | sid.Host = host 247 | sid.Game = game 248 | m.Servers = append(m.Servers, sid) 249 | } 250 | } 251 | m.ServerCount = len(m.Servers) 252 | result <- m 253 | } 254 | 255 | // GetHostsAndGameFromIDAPIQuery Retrieves the hosts and game names from the 256 | // server database file in response to a user-specified API query for a given 257 | // set of server ID numbers. Sends the results over a channel consisting of a 258 | // host to game name string mapping. 259 | func (sdb *SDB) GetHostsAndGameFromIDAPIQuery(result chan map[string]string, 260 | ids []string) { 261 | hosts := make(map[string]string, len(ids)) 262 | for _, id := range ids { 263 | host, game, err := sdb.getHostAndGame(id) 264 | if err != nil { 265 | logger.LogAppErrorf("Error getting host from ID for API query: %s", err) 266 | return 267 | } 268 | if host == "" && game == "" { 269 | continue 270 | } 271 | hosts[host] = game 272 | } 273 | result <- hosts 274 | } 275 | -------------------------------------------------------------------------------- /src/db/servers_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/syncore/a2sapi/src/constants" 8 | "github.com/syncore/a2sapi/src/models" 9 | 10 | _ "github.com/mattn/go-sqlite3" 11 | ) 12 | 13 | var testData map[string]string 14 | 15 | func init() { 16 | testData = make(map[string]string, 2) 17 | testData["10.0.0.10"] = "Reflex" 18 | testData["172.16.0.1"] = "QuakeLive" 19 | } 20 | 21 | func TestCreateServerDBtable(t *testing.T) { 22 | err := createServerDBtable(constants.TestServerDbFilePath) 23 | if err != nil { 24 | t.Fatalf("Unable to create test DB file: %s", err) 25 | } 26 | } 27 | 28 | func TestAddServersToDB(t *testing.T) { 29 | db, err := OpenServerDB() 30 | if err != nil { 31 | t.Fatalf("Unable to open test database: %s", err) 32 | } 33 | defer db.Close() 34 | db.AddServersToDB(testData) 35 | } 36 | 37 | func TestGetIDsForServerList(t *testing.T) { 38 | c := make(chan map[string]int64, 2) 39 | db, err := OpenServerDB() 40 | if err != nil { 41 | t.Fatalf("Unable to open test database: %s", err) 42 | } 43 | defer db.Close() 44 | db.GetIDsForServerList(c, testData) 45 | result := <-c 46 | if len(result) != 2 { 47 | t.Fatalf("Expected 2 results, got: %d", len(result)) 48 | } 49 | if _, ok := result["10.0.0.10"]; !ok { 50 | t.Fatalf("Expected value 10.0.0.10 to exist.") 51 | } 52 | if _, ok := result["172.16.0.1"]; !ok { 53 | t.Fatalf("Expected value 172.16.0.1 to exist.") 54 | } 55 | } 56 | 57 | func TestGetIDsAPIQuery(t *testing.T) { 58 | c1 := make(chan *models.DbServerID, 1) 59 | c2 := make(chan *models.DbServerID, 1) 60 | db, err := OpenServerDB() 61 | if err != nil { 62 | t.Fatalf("Unable to open test database: %s", err) 63 | } 64 | defer db.Close() 65 | h1 := []string{"10.0.0.10"} 66 | h2 := []string{"172.16.0.1"} 67 | db.GetIDsAPIQuery(c1, h1) 68 | r1 := <-c1 69 | if len(r1.Servers) != 1 { 70 | t.Fatalf("Expected 1 server, got: %d", len(r1.Servers)) 71 | } 72 | if !strings.EqualFold(r1.Servers[0].Game, "Reflex") { 73 | t.Fatalf("Expected result 1 to be Reflex, got: %v", r1.Servers[0].Game) 74 | } 75 | db.GetIDsAPIQuery(c2, h2) 76 | r2 := <-c2 77 | if len(r2.Servers) != 1 { 78 | t.Fatalf("Expected 1 server, got: %d", len(r2.Servers)) 79 | } 80 | if !strings.EqualFold(r2.Servers[0].Game, "QuakeLive") { 81 | t.Fatalf("Expected result 2 to be QuakeLive, got: %v", r2.Servers[0].Game) 82 | } 83 | } 84 | 85 | func TestGetHostsAndGameFromIDAPIQuery(t *testing.T) { 86 | c := make(chan map[string]string, 2) 87 | db, err := OpenServerDB() 88 | if err != nil { 89 | t.Fatalf("Unable to open test database: %s", err) 90 | } 91 | defer db.Close() 92 | ids := []string{"1", "2"} 93 | db.GetHostsAndGameFromIDAPIQuery(c, ids) 94 | result := <-c 95 | if len(result) != 2 { 96 | t.Fatalf("Expected 2 results, got: %d", len(result)) 97 | } 98 | if !strings.EqualFold(result["10.0.0.10"], "Reflex") { 99 | t.Fatalf("Expected result Reflex, got: %v", result["10.0.0.10"]) 100 | } 101 | if !strings.EqualFold(result["172.16.0.1"], "QuakeLive") { 102 | t.Fatalf("Expected result QuakeLive, got: %v", result["1172.16.0.1"]) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/models/api_serverlist.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // api_serverlist.go - Model for building list of server details 4 | 5 | import "time" 6 | 7 | // APIServerList represents the server detail list returned in response to 8 | // building the master list or in response to building the list of server details 9 | // via a user's API request. 10 | type APIServerList struct { 11 | RetrievedAt string `json:"retrievalDate"` 12 | RetrievedTimeStamp int64 `json:"timestamp"` 13 | ServerCount int `json:"serverCount"` 14 | Servers []APIServer `json:"servers"` 15 | FailedCount int `json:"failedCount"` 16 | FailedServers []string `json:"failedServers"` 17 | } 18 | 19 | // APIServer represents an individual game server's information, including its 20 | // A2S information as well as its geographical data. if available. 21 | type APIServer struct { 22 | ID int64 `json:"serverID"` 23 | Host string `json:"address"` 24 | Game string `json:"game"` 25 | IP string `json:"ip"` 26 | Port int `json:"port"` 27 | CountryInfo DbCountry `json:"location"` 28 | Info SteamServerInfo `json:"info"` 29 | Players []SteamPlayerInfo `json:"players"` 30 | FilteredPlayers FilteredPlayerInfo `json:"filteredPlayers"` 31 | Rules map[string]string `json:"rules"` 32 | } 33 | 34 | // MasterList represents the list of all servers returned from the master server 35 | // and directly exposed to the user via queries if timed auto queries are enabled. 36 | var MasterList *APIServerList 37 | 38 | // GetDefaultServerList Returns a default, empty, server list with the current 39 | // date and time in response to a server detail list request that failed for 40 | // whatever reason. 41 | func GetDefaultServerList() *APIServerList { 42 | return &APIServerList{ 43 | RetrievedAt: time.Now().Format("Mon Jan 2 15:04:05 2006 EST"), 44 | RetrievedTimeStamp: time.Now().Unix(), 45 | ServerCount: 0, 46 | Servers: make([]APIServer, 0), 47 | FailedCount: 0, 48 | FailedServers: make([]string, 0), 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/models/db_country.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // db_country.go - Model for country information returned by database 4 | 5 | // DbCountry This struct is for the JSON representation displayed by the API 6 | type DbCountry struct { 7 | CountryName string `json:"countryName"` 8 | CountryCode string `json:"countryCode"` 9 | Continent string `json:"region"` 10 | State string `json:"state"` 11 | } 12 | -------------------------------------------------------------------------------- /src/models/db_serverid.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // db_serverid.go - Model for host, id, and game returned by server DB 4 | 5 | // DbServer represents an individual server's internal ID information. 6 | type DbServer struct { 7 | ID int64 `json:"serverID"` 8 | Game string `json:"game"` 9 | Host string `json:"host"` 10 | } 11 | 12 | // DbServerID represents the outer struct that is retrieved from the server ID 13 | // database. 14 | type DbServerID struct { 15 | ServerCount int `json:"serverCount"` 16 | Servers []DbServer `json:"servers"` 17 | } 18 | 19 | // GetDefaultServerID returns the default DbServerID outer struct when a given 20 | // host does not have an ID that was found in the server ID database. 21 | func GetDefaultServerID() DbServerID { 22 | return DbServerID{ 23 | ServerCount: 0, 24 | Servers: make([]DbServer, 0), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/models/steam_playerinfo.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // steam_playerinfo.go - Model for player info returned by a steam A2S_PLAYER query 4 | 5 | // SteamPlayerInfo represents a player returned by a Steam A2S_PLAYER query 6 | type SteamPlayerInfo struct { 7 | Name string `json:"name"` 8 | Score int32 `json:"score"` 9 | TimeConnectedSecs float32 `json:"secsConnected"` 10 | TimeConnectedTot string `json:"totalConnected"` 11 | } 12 | 13 | // FilteredPlayerInfo is a collection of all players on a server that actually 14 | // exist on the server and are not bugged or stuck due to the Steam de-auth 15 | // bug that exists in game servers for certain games (such as Quake Live) 16 | type FilteredPlayerInfo struct { 17 | FilteredPlayerCount int `json:"count"` 18 | FilteredPlayers []SteamPlayerInfo `json:"players"` 19 | } 20 | -------------------------------------------------------------------------------- /src/models/steam_serverinfo.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // steam_serverinfo.go - Model for server info returned by an A2S_INFO query 4 | 5 | // SteamServerInfo represents the original information returned by a direct 6 | // A2S_INFO query of a given host. 7 | type SteamServerInfo struct { 8 | Protocol int `json:"protocol"` 9 | Name string `json:"serverName"` 10 | Map string `json:"map"` 11 | Folder string `json:"gameDir"` 12 | Game string `json:"game"` 13 | GameTypeShort string `json:"gameTypeShort"` // custom field for sorting 14 | GameTypeFull string `json:"gameTypeFull"` // custom field for sorting 15 | ID int16 `json:"steamApp"` 16 | Players int16 `json:"players"` 17 | MaxPlayers int16 `json:"maxPlayers"` 18 | Bots int16 `json:"bots"` 19 | ServerType string `json:"serverType"` 20 | Environment string `json:"serverOS"` 21 | Visibility int16 `json:"private"` 22 | VAC int16 `json:"antiCheat"` 23 | Version string `json:"serverVersion"` 24 | ExtraData SteamExtraData `json:"extra"` 25 | } 26 | 27 | // SteamExtraData represents the original extra data field, if present returned 28 | // by a direct A2S_INFO query of a given host. 29 | type SteamExtraData struct { 30 | Port int16 `json:"gamePort"` 31 | SteamID uint64 `json:"serverSteamID"` 32 | SourceTVPort int16 `json:"sourceTvProxyPort"` 33 | SourceTVName string `json:"sourceTvProxyName"` 34 | Keywords string `json:"keywords"` 35 | GameID uint64 `json:"steamAppID"` 36 | } 37 | -------------------------------------------------------------------------------- /src/steam/filters/filters.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | // filters.go - steam master server filters 4 | // See: https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | ) 10 | 11 | // SrvRegion represents a Master server region code filter 12 | type SrvRegion []byte 13 | 14 | // SrvFilter represents a Master server filter 15 | type SrvFilter []byte 16 | 17 | // Filter is our internal wrapper for a specified game, and its Master server 18 | // region code and Master server filters 19 | type Filter struct { 20 | Game Game 21 | Region SrvRegion 22 | Filters []SrvFilter 23 | } 24 | 25 | // Regions and filters 26 | var ( 27 | SrUsEastCoast SrvRegion = []byte{0x00} 28 | SrUsWestCoast SrvRegion = []byte{0x01} 29 | SrSouthAmerica SrvRegion = []byte{0x02} 30 | SrEurope SrvRegion = []byte{0x03} 31 | SrAsia SrvRegion = []byte{0x04} 32 | SrAustralia SrvRegion = []byte{0x05} 33 | SrMiddleEast SrvRegion = []byte{0x06} 34 | SrAfrica SrvRegion = []byte{0x07} 35 | SrAll SrvRegion = []byte{0xFF} 36 | 37 | // --------------------- "Constant" filters --------------------- 38 | // Dedicated servers 39 | SfDedicated SrvFilter = []byte("\\dedicated\\1") 40 | // Servers using anti-cheat technology (VAC, but maybe others as well) 41 | SfSecure SrvFilter = []byte("\\secure\\1") 42 | // Servers running on a Linux platform 43 | SfLinux SrvFilter = []byte("\\linux\\1") 44 | // Servers that are not empty 45 | SfNotEmpty SrvFilter = []byte("\\empty\\1") 46 | // Servers that are not full 47 | SfNotFull SrvFilter = []byte("\\full\\1") 48 | // Servers that spectator proxies 49 | SfSpectatorProxy SrvFilter = []byte("\\proxy\\1") 50 | // Servers that are empty 51 | SfEmpty SrvFilter = []byte("\\noplayers\\1") 52 | // Servers that are whitelisted 53 | SfWhitelisted SrvFilter = []byte("\\white\\1") 54 | // Return only one server for each unique IP address matched 55 | SfOneUniquePerIP SrvFilter = []byte("\\collapse_addr_hash\\1") 56 | // ALL servers 57 | SfAll SrvFilter = []byte{0x00} 58 | 59 | // ----------------Filters the take variable input ---------------- 60 | // \appid\[appid] - Servers that are running game [appid] 61 | AppIDFilter = func(val string) SrvFilter { 62 | return []byte(fmt.Sprintf("\\appid\\%s", val)) 63 | } 64 | // \gameaddr\[ip]Return only servers on the specified IP address 65 | // (port supported and optional) 66 | GameAddrFilter = func(val string) SrvFilter { 67 | return []byte(fmt.Sprintf("\\gameaddr\\%s", val)) 68 | } 69 | // \gamedata\[tag,...] - Servers with all of the given tag(s) in their 70 | //'hidden' tags (L4D2) 71 | GameDataFilter = func(val string) SrvFilter { 72 | return []byte(fmt.Sprintf("\\gamedata\\%s", val)) 73 | } 74 | // \gamedataor\[tag,...] - Servers with any of the given tag(s) in their 75 | // 'hidden' tags (L4D2) 76 | GameDataOrFilter = func(val string) SrvFilter { 77 | return []byte(fmt.Sprintf("\\gamedataor\\%s", val)) 78 | } 79 | // \gamedir\[mod] - Servers running the specified modification (ex. cstrike) 80 | GameDirFilter = func(val string) SrvFilter { 81 | return []byte(fmt.Sprintf("\\gamedir\\%s", val)) 82 | } 83 | // \gametype\[tag,...] - Servers with all of the given tag(s) in sv_tags 84 | GameTypeFilter = func(val string) SrvFilter { 85 | return []byte(fmt.Sprintf("\\gametype\\%s", val)) 86 | } 87 | // \name_match\[hostname] - Servers with their hostname matching [hostname] 88 | // (can use * as a wildcard) 89 | NameMatchFilter = func(val string) SrvFilter { 90 | return []byte(fmt.Sprintf("\\name_match\\%s", val)) 91 | } 92 | // \nand\[x] - A special filter, specifies that servers matching all of the 93 | // following [x] conditions should not be returned 94 | NAndFilter = func(val string) SrvFilter { 95 | return []byte(fmt.Sprintf("\\nand\\%s", val)) 96 | } 97 | // \nor\[x] - A special filter, specifies that servers matching any of the 98 | //following [x] conditions should not be returned 99 | NOrFilter = func(val string) SrvFilter { 100 | return []byte(fmt.Sprintf("\\nor\\%s", val)) 101 | } 102 | // \napp\[appid] - Servers that are NOT running game [appid] 103 | // (This was introduced to block Left 4 Dead games from the Steam Server Browser 104 | NAppIDFilter = func(val string) SrvFilter { 105 | return []byte(fmt.Sprintf("\\nappid\\%s", val)) 106 | } 107 | // \map\[map] - Servers running the specified map (ex. cs_italy) 108 | MapFilter = func(val string) SrvFilter { 109 | return []byte(fmt.Sprintf("\\map\\%s", val)) 110 | } 111 | // \version_match\[version] - Servers running version [version] 112 | // (can use * as a wildcard) 113 | VersionMatchFilter = func(val string) SrvFilter { 114 | return []byte(fmt.Sprintf("\\version_match\\%s", val)) 115 | } 116 | ) 117 | 118 | // NewFilter creates a new filter for use with a master server query based on 119 | // a game to query, its region code, and any other additional master server filters 120 | // that should be sent with the request to the master server. 121 | func NewFilter(game Game, region SrvRegion, filters []SrvFilter) Filter { 122 | if filters != nil { 123 | for i, f := range filters { 124 | if bytes.HasPrefix(f, []byte("\\appid\\")) { 125 | filters[i] = AppIDFilter(fmt.Sprintf("%d", game.AppID)) 126 | break 127 | } else { 128 | filters = append(filters, AppIDFilter(fmt.Sprintf("%d", game.AppID))) 129 | break 130 | } 131 | } 132 | } else { 133 | filters = append(filters, AppIDFilter(fmt.Sprintf("%d", game.AppID))) 134 | } 135 | return Filter{ 136 | Game: game, 137 | Region: region, 138 | Filters: filters, 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/steam/filters/game.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | // game.go - Steam game-to-appid operations, A2S ignore mappings, and game list. 4 | 5 | import ( 6 | "bufio" 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | "strings" 11 | 12 | "github.com/syncore/a2sapi/src/constants" 13 | "github.com/syncore/a2sapi/src/util" 14 | ) 15 | 16 | // Game represents a queryable Steam game, including its application ID 17 | // and whether particular A2S requests need to be ignored when querying. 18 | type Game struct { 19 | Name string `json:"name"` 20 | AppID uint64 `json:"appID"` 21 | // Some games (i.e. newer/beta ones) do not have all 3 of A2S_INFO,PLAYER,RULES 22 | // any of these ignore values set to true will skip that request when querying 23 | IgnoreRules bool `json:"ignoreRules"` 24 | IgnorePlayers bool `json:"ignorePlayers"` 25 | IgnoreInfo bool `json:"ignoreInfo"` 26 | } 27 | 28 | // GameList represents the list of games. 29 | type GameList struct { 30 | Games []Game `json:"games"` 31 | } 32 | 33 | // A few default games, additional games can be added from https://steamdb.info/apps/ 34 | var ( 35 | // GameAlienSwarm Alien Swarm 36 | GameAlienSwarm = Game{ 37 | Name: "AlienSwarm", 38 | AppID: 630, 39 | IgnoreRules: false, 40 | IgnorePlayers: false, 41 | IgnoreInfo: false, 42 | } 43 | // GameARMA3 ARMA 3 44 | GameARMA3 = Game{ 45 | Name: "ARMA3", 46 | AppID: 107410, 47 | IgnoreRules: false, 48 | IgnorePlayers: false, 49 | IgnoreInfo: false, 50 | } 51 | // GameARKSurvivalEvolved ARK: Survival Evolved 52 | GameARKSurvivalEvolved = Game{ 53 | Name: "ARKSurvivalEvolved", 54 | AppID: 346110, 55 | IgnoreRules: false, 56 | IgnorePlayers: false, 57 | IgnoreInfo: false, 58 | } 59 | // GameCsGo Counter-Strike: GO 60 | GameCsGo = Game{ 61 | Name: "CSGO", 62 | AppID: 730, 63 | IgnoreRules: true, // CSGO no longer sends rules as of 1.32.3.0 (02/21/14) 64 | IgnorePlayers: false, 65 | IgnoreInfo: false, 66 | } 67 | // GameCSSource Counter-Strike: Source 68 | GameCSSource = Game{ 69 | Name: "CSSource", 70 | AppID: 240, 71 | IgnoreRules: false, 72 | IgnorePlayers: false, 73 | IgnoreInfo: false, 74 | } 75 | // GameDayZ DayZ 76 | GameDayZ = Game{ 77 | Name: "DayZ", 78 | AppID: 221100, 79 | IgnoreRules: false, 80 | IgnorePlayers: false, 81 | IgnoreInfo: false, 82 | } 83 | // GameGarrysMod Garry's Mod 84 | GameGarrysMod = Game{ 85 | Name: "GarrysMod", 86 | AppID: 4000, 87 | IgnoreRules: false, 88 | IgnorePlayers: false, 89 | IgnoreInfo: false, 90 | } 91 | // GameHL2DM Half-Life 2: Deathmatch 92 | GameHL2DM = Game{ 93 | Name: "HL2DM", 94 | AppID: 320, 95 | IgnoreRules: false, 96 | IgnorePlayers: false, 97 | IgnoreInfo: false, 98 | } 99 | // GameL4D2 Left 4 Dead 2 100 | GameL4D2 = Game{ 101 | Name: "L4D2", 102 | AppID: 550, 103 | IgnoreRules: false, 104 | IgnorePlayers: false, 105 | IgnoreInfo: false, 106 | } 107 | // GameOpposingForce Half-Life: Opposing Force 108 | GameOpposingForce = Game{ 109 | Name: "OpposingForce", 110 | AppID: 50, 111 | IgnoreRules: false, 112 | IgnorePlayers: false, 113 | IgnoreInfo: false, 114 | } 115 | // GameQuakeLive Quake Live 116 | GameQuakeLive = Game{ 117 | Name: "QuakeLive", 118 | AppID: 282440, 119 | IgnoreRules: false, 120 | IgnorePlayers: false, 121 | IgnoreInfo: false, 122 | } 123 | // GameReflex Reflex 124 | GameReflex = Game{ 125 | Name: "Reflex", 126 | AppID: 328070, 127 | IgnoreRules: true, // Reflex does not implement A2S_RULES 128 | IgnorePlayers: false, 129 | IgnoreInfo: false, 130 | } 131 | // GameRust Rust 132 | GameRust = Game{ 133 | Name: "Rust", 134 | AppID: 252490, 135 | IgnoreRules: false, 136 | IgnorePlayers: false, 137 | IgnoreInfo: false, 138 | } 139 | // GameTF2 Team Fortress 2 140 | GameTF2 = Game{ 141 | Name: "TF2", 142 | AppID: 440, 143 | IgnoreRules: false, 144 | IgnorePlayers: false, 145 | IgnoreInfo: false, 146 | } 147 | // GameUnspecified Unspecified game for direct server queries, if enabled; 148 | // if unspecified games actually ignore some A2S requests there will be issues. 149 | // This is intentionally left out of the defaultGames GameList struct so it 150 | //is not user-selectable in the configuration creation. 151 | GameUnspecified = Game{ 152 | Name: "Unspecified", 153 | AppID: 0, 154 | IgnoreRules: false, 155 | IgnorePlayers: false, 156 | IgnoreInfo: false, 157 | } 158 | 159 | defaultGames = GameList{ 160 | Games: []Game{ 161 | GameAlienSwarm, 162 | GameARMA3, 163 | GameARKSurvivalEvolved, 164 | GameCsGo, 165 | GameCSSource, 166 | GameDayZ, 167 | GameGarrysMod, 168 | GameHL2DM, 169 | GameL4D2, 170 | GameOpposingForce, 171 | GameQuakeLive, 172 | GameReflex, 173 | GameRust, 174 | GameTF2, 175 | }, 176 | } 177 | highServerCountGames = GameList{ 178 | Games: []Game{ 179 | GameARKSurvivalEvolved, 180 | GameARMA3, 181 | GameCsGo, 182 | GameGarrysMod, 183 | GameL4D2, 184 | GameTF2, 185 | }, 186 | } 187 | ) 188 | 189 | func (g *Game) String() string { 190 | return g.Name 191 | } 192 | 193 | // GetGameNames returns a slice of strings containing the games' names. 194 | func GetGameNames() []string { 195 | var names []string 196 | for _, g := range ReadGames() { 197 | names = append(names, g.Name) 198 | } 199 | return names 200 | } 201 | 202 | // GetGameByName searches the list of pre-defined games and returns a a Game 203 | // struct based on the name of the game. 204 | func GetGameByName(name string) Game { 205 | for _, g := range ReadGames() { 206 | if strings.EqualFold(name, g.Name) { 207 | return g 208 | } 209 | } 210 | return GameUnspecified 211 | } 212 | 213 | // GetGameByAppID searches the list of pre-defined games and returns a Game struct 214 | // based on the AppID of the game. 215 | func GetGameByAppID(appid uint64) Game { 216 | for _, g := range ReadGames() { 217 | if appid == g.AppID { 218 | return g 219 | } 220 | } 221 | return GameUnspecified 222 | } 223 | 224 | // NewGame specifies a new game, including its name, Steam application-ID, and 225 | // whether A2S_RULES, A2S_PLAYERS, and/or AS2_INFO requests should be ignored 226 | // when performing a query. 227 | func NewGame(name string, appid uint64, ignoreRules, ignorePlayers, 228 | ignoreInfo bool) Game { 229 | return Game{ 230 | Name: name, 231 | AppID: appid, 232 | IgnoreRules: ignoreRules, 233 | IgnorePlayers: ignorePlayers, 234 | IgnoreInfo: ignoreInfo, 235 | } 236 | } 237 | 238 | // ReadGames reads the game file from disk and returns a slice to a pointer of 239 | // Game structs if successful, otherwise panics. 240 | func ReadGames() []Game { 241 | var f *os.File 242 | var err error 243 | f, err = os.Open(constants.GameFileFullPath) 244 | if err != nil { 245 | // try to create 246 | DumpDefaultGames() 247 | // re-open 248 | f, err = os.Open(constants.GameFileFullPath) 249 | if err != nil { 250 | panic(fmt.Sprintf("Error reading games file file: %s\n", err)) 251 | } 252 | } 253 | defer f.Close() 254 | r := bufio.NewReader(f) 255 | d := json.NewDecoder(r) 256 | games := GameList{} 257 | if err := d.Decode(&games); err != nil { 258 | panic(fmt.Sprintf("Error decoding games file file: %s\n", err)) 259 | } 260 | return games.Games 261 | } 262 | 263 | // DumpDefaultGames writes the default struct containing the default games to disk 264 | // on success, otherwise panics. 265 | func DumpDefaultGames() { 266 | if err := util.WriteJSONConfig(defaultGames, constants.ConfigDirectory, 267 | constants.GameFileFullPath); err != nil { 268 | panic(err) 269 | } 270 | } 271 | 272 | // IsValidGame determines whether the specified game exists within the list of 273 | // games and returns true if it does, otherwise false. 274 | func IsValidGame(name string) bool { 275 | for _, g := range ReadGames() { 276 | if strings.EqualFold(name, g.Name) { 277 | return true 278 | } 279 | } 280 | return false 281 | } 282 | 283 | // HasHighServerCount determines if the specified game is in the list of games 284 | // that are known to return more than 6930 servers, which is the value at which 285 | // Valve begins to throttle future responses from the master server. 286 | func HasHighServerCount(name string) bool { 287 | for _, g := range highServerCountGames.Games { 288 | if strings.EqualFold(name, g.Name) { 289 | return true 290 | } 291 | } 292 | return false 293 | } 294 | -------------------------------------------------------------------------------- /src/steam/gametype.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/syncore/a2sapi/src/models" 7 | "github.com/syncore/a2sapi/src/steam/filters" 8 | ) 9 | 10 | type gtype struct { 11 | ShortName string 12 | LongName string 13 | } 14 | 15 | var qlGameTypes = map[string]gtype{ 16 | "0": {"FFA", "Free For All"}, 17 | "1": {"Duel", "Duel"}, 18 | "2": {"Race", "Race"}, 19 | "3": {"TDM", "Team Deathmatch"}, 20 | "4": {"CA", "Clan Arena"}, 21 | "5": {"CTF", "Capture The Flag"}, 22 | "6": {"FCTF", "1-Flag Capture The Flag"}, 23 | "8": {"HAR", "Harvester"}, 24 | "9": {"FT", "Freeze Tag"}, 25 | "10": {"DOM", "Domination"}, 26 | "11": {"AD", "Attack & Defend"}, 27 | "12": {"RR", "Red Rover"}, 28 | } 29 | 30 | var reflexGameTypes = map[string]gtype{ 31 | "1v1": {"1v1", "Duel"}, 32 | "a1v1": {"a1v1", "Arena Duel"}, 33 | "affa": {"affa", "Arena Free For All"}, 34 | "atdm": {"atdm", "Arena Team Deathmatch"}, 35 | "ctf": {"ctf", "Capture The Flag"}, 36 | "ffa": {"ffa", "Free For All"}, 37 | "race": {"race", "Race"}, 38 | "tdm": {"tdm", "Team Deathmatch"}, 39 | } 40 | 41 | func getGameType(game filters.Game, server models.APIServer) (shortname, 42 | longname string) { 43 | // Quake Live 44 | if strings.EqualFold(game.Name, filters.GameQuakeLive.Name) { 45 | if _, ok := server.Rules["g_gametype"]; !ok { 46 | return 47 | } 48 | if _, ok := qlGameTypes[server.Rules["g_gametype"]]; !ok { 49 | return 50 | } 51 | return qlGameTypes[server.Rules["g_gametype"]].ShortName, 52 | qlGameTypes[server.Rules["g_gametype"]].LongName 53 | } 54 | // Reflex 55 | if strings.EqualFold(game.Name, filters.GameReflex.Name) { 56 | sep := "," // version 0.49 and every version in the future use comma to separate keywords 57 | sepidx := strings.Index(server.Info.ExtraData.Keywords, sep) 58 | if sepidx == -1 { 59 | // v < 0.49 uses pipe character to separate keywords; eventually this wont be needed 60 | sep = "|" 61 | } 62 | k := strings.Split(server.Info.ExtraData.Keywords, sep) 63 | if _, ok := reflexGameTypes[strings.ToLower(k[0])]; !ok { 64 | return 65 | } 66 | return reflexGameTypes[k[0]].ShortName, reflexGameTypes[k[0]].LongName 67 | } 68 | return 69 | } 70 | -------------------------------------------------------------------------------- /src/steam/listbuilder.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | // listbuilder.go - Functions for building the list of servers & their details 4 | // in resposne to a retrieval of all servers from the Steam Master server 5 | // or in response to a user's specific query from the API. 6 | 7 | import ( 8 | "net" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/syncore/a2sapi/src/db" 14 | "github.com/syncore/a2sapi/src/logger" 15 | "github.com/syncore/a2sapi/src/models" 16 | "github.com/syncore/a2sapi/src/steam/filters" 17 | ) 18 | 19 | func buildServerList(data a2sData, addtoServerDB bool) (*models.APIServerList, 20 | error) { 21 | // Cannot ignore all three requests 22 | for _, g := range data.HostsGames { 23 | if g.IgnoreInfo && g.IgnorePlayers && g.IgnoreRules { 24 | return nil, logger.LogAppErrorf("Cannot ignore all three A2S_ requests!") 25 | } 26 | } 27 | successcount := 0 28 | var success bool 29 | srvDBhosts := make(map[string]string, len(data.HostsGames)) 30 | sl := &models.APIServerList{ 31 | Servers: make([]models.APIServer, 0), 32 | FailedServers: make([]string, 0), 33 | } 34 | 35 | for host, game := range data.HostsGames { 36 | info, iok := data.Info[host] 37 | players, pok := data.Players[host] 38 | if players == nil { 39 | // return empty array instead of nil pointers (null) in json 40 | players = make([]models.SteamPlayerInfo, 0) 41 | } 42 | rules, rok := data.Rules[host] 43 | success = iok && rok && pok 44 | 45 | if game.IgnoreInfo { 46 | success = pok && rok 47 | } 48 | if game.IgnorePlayers { 49 | success = iok && rok 50 | } 51 | if game.IgnoreRules { 52 | rules = make(map[string]string, 0) 53 | success = iok && pok 54 | } 55 | if game.IgnoreInfo && game.IgnorePlayers { 56 | success = rok 57 | } 58 | if game.IgnoreInfo && game.IgnoreRules { 59 | success = pok 60 | } 61 | if game.IgnorePlayers && game.IgnoreRules { 62 | success = iok 63 | } 64 | 65 | if success { 66 | srv := models.APIServer{ 67 | Game: game.Name, 68 | Players: players, 69 | FilteredPlayers: removeBuggedPlayers(players), 70 | Rules: rules, 71 | Info: info, 72 | } 73 | // Gametype support: gametype can be found in rules, info, or not 74 | // at all depending on the game (currently just for QuakeLive & Reflex) 75 | srv.Info.GameTypeShort, srv.Info.GameTypeFull = getGameType(game, srv) 76 | 77 | ip, port, serr := net.SplitHostPort(host) 78 | if serr == nil { 79 | srv.IP = ip 80 | srv.Host = host 81 | p, perr := strconv.Atoi(port) 82 | if perr == nil { 83 | srv.Port = p 84 | } 85 | if !strings.EqualFold(game.Name, filters.GameUnspecified.String()) { 86 | srvDBhosts[host] = game.Name 87 | } 88 | loc := make(chan models.DbCountry, 1) 89 | go db.CountryDB.GetCountryInfo(loc, ip) 90 | srv.CountryInfo = <-loc 91 | } 92 | sl.Servers = append(sl.Servers, srv) 93 | successcount++ 94 | } else { 95 | sl.FailedServers = append(sl.FailedServers, host) 96 | } 97 | } 98 | 99 | sl.RetrievedAt = time.Now().Format("Mon Jan 2 15:04:05 2006 EST") 100 | sl.RetrievedTimeStamp = time.Now().Unix() 101 | sl.ServerCount = len(sl.Servers) 102 | sl.FailedCount = len(sl.FailedServers) 103 | 104 | if len(srvDBhosts) != 0 { 105 | go db.ServerDB.AddServersToDB(srvDBhosts) 106 | sl.Servers = setServerIDsForList(sl.Servers) 107 | } 108 | 109 | logger.LogAppInfo( 110 | "Successfully queried (%d/%d) servers. %d timed out or otherwise failed.", 111 | successcount, len(data.HostsGames), sl.FailedCount) 112 | logger.WriteDebug("Server Queries: Successful: (%d/%d) servers\tFailed: %d servers", 113 | successcount, len(data.HostsGames), sl.FailedCount) 114 | return sl, nil 115 | } 116 | 117 | // removeBuggedPlayers filters the players to remove "bugged" or stuck players 118 | // from the player list in games like Quake Live where certain servers do not 119 | // correctly send the Steam de-auth message, causing "ghost" or phantom players 120 | // to exist on servers. 121 | func removeBuggedPlayers(players []models.SteamPlayerInfo) models.FilteredPlayerInfo { 122 | rpi := models.FilteredPlayerInfo{ 123 | FilteredPlayerCount: len(players), 124 | FilteredPlayers: players, 125 | } 126 | var filtered []models.SteamPlayerInfo 127 | // 4 hour threshold with no score; also can leave bots intact in list 128 | for _, p := range players { 129 | if p.Score == 0 && int(p.TimeConnectedSecs) > (3600*4) { 130 | continue 131 | } 132 | filtered = append(filtered, p) 133 | } 134 | // Empty players (nil) displayed as empty array in JSON, not null 135 | if len(filtered) == 0 { 136 | rpi.FilteredPlayerCount = 0 137 | rpi.FilteredPlayers = make([]models.SteamPlayerInfo, 0) 138 | } else { 139 | rpi.FilteredPlayerCount = len(filtered) 140 | rpi.FilteredPlayers = filtered 141 | } 142 | return rpi 143 | } 144 | 145 | func setServerIDsForList(servers []models.APIServer) []models.APIServer { 146 | toSet := make(map[string]string, len(servers)) 147 | for _, s := range servers { 148 | toSet[s.Host] = s.Game 149 | } 150 | result := make(chan map[string]int64, 1) 151 | go db.ServerDB.GetIDsForServerList(result, toSet) 152 | m := <-result 153 | var srvswithids []models.APIServer 154 | 155 | for _, s := range servers { 156 | if m[s.Host] != 0 { 157 | s.ID = m[s.Host] 158 | } 159 | srvswithids = append(srvswithids, s) 160 | } 161 | return srvswithids 162 | } 163 | -------------------------------------------------------------------------------- /src/steam/listbuilder_test.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/syncore/a2sapi/src/db" 8 | "github.com/syncore/a2sapi/src/models" 9 | "github.com/syncore/a2sapi/src/steam/filters" 10 | "github.com/syncore/a2sapi/src/test" 11 | ) 12 | 13 | var testData a2sData 14 | 15 | func init() { 16 | test.SetupEnvironment() 17 | db.InitDBs() 18 | hostsgames := make(map[string]filters.Game, 2) 19 | hostsgames["54.172.5.67:25801"] = filters.GameReflex 20 | hostsgames["192.211.62.11:27960"] = filters.GameQuakeLive 21 | 22 | info := make(map[string]models.SteamServerInfo, 2) 23 | info["54.172.5.67:25801"] = models.SteamServerInfo{ 24 | Protocol: 17, 25 | Name: "TurboPixel Appreciation Society (Official) #1", 26 | Map: "xfdm2", 27 | Folder: "base", 28 | Game: "Reflex", 29 | Players: 6, 30 | MaxPlayers: 8, 31 | Bots: 0, 32 | ServerType: "dedicated", 33 | Environment: "Windows", 34 | VAC: 1, 35 | Version: "0.38.2", 36 | ExtraData: models.SteamExtraData{ 37 | Port: 25800, 38 | SteamID: 90098615517053960, 39 | SourceTVPort: 0, 40 | SourceTVName: "", 41 | Keywords: "atdm||62|1", 42 | GameID: 328070, 43 | }, 44 | } 45 | info["192.211.62.11:27960"] = models.SteamServerInfo{ 46 | Protocol: 17, 47 | Name: "exile.syncore.org | US-Central #1 | Competitive", 48 | Map: "overkill", 49 | Folder: "baseq3", 50 | Game: "Clan Arena", 51 | Players: 0, 52 | MaxPlayers: 16, 53 | Bots: 0, 54 | ServerType: "dedicated", 55 | Environment: "Linux", 56 | VAC: 1, 57 | Version: "1066", 58 | ExtraData: models.SteamExtraData{ 59 | Port: 27960, 60 | SteamID: 90098677041473542, 61 | SourceTVPort: 0, 62 | SourceTVName: "", 63 | Keywords: "clanarena,minqlx,syncore,texas,central,newmaps", 64 | GameID: 282440, 65 | }, 66 | } 67 | rules := make(map[string]map[string]string, 2) 68 | rules["54.172.5.67:25801"] = nil 69 | r := make(map[string]string, 42) 70 | r["dmflags"] = "28" 71 | r["fraglimit"] = "50" 72 | r["g_adCaptureScoreBonus"] = "3" 73 | r["g_adElimScoreBonus"] = "2" 74 | r["g_adTouchScoreBonus"] = "1" 75 | r["g_blueScore"] = "" 76 | r["g_customSettings"] = "0" 77 | r["g_factory"] = "ca" 78 | r["g_factoryTitle"] = "Clan Arena" 79 | r["g_freezeRoundDelay"] = "4000" 80 | r["g_gameState"] = "PRE_GAME" 81 | r["g_gametype"] = "4" 82 | r["g_gravity"] = "800" 83 | r["g_instaGib"] = "0" 84 | r["g_itemHeight"] = "35" 85 | r["g_itemTimers"] = "1" 86 | r["g_levelStartTime"] = "1451179049" 87 | r["g_loadout"] = "0" 88 | r["g_needpass"] = "0" 89 | r["g_overtime"] = "0" 90 | r["g_quadDamageFactor"] = "3" 91 | r["g_redScore"] = "" 92 | r["g_roundWarmupDelay"] = "10000" 93 | r["g_startingHealth"] = "200" 94 | r["g_teamForceBalance"] = "1" 95 | r["g_teamSizeMin"] = "1" 96 | r["g_timeoutCount"] = "0" 97 | r["g_voteFlags"] = "0" 98 | r["g_weaponRespawn"] = "5" 99 | r["mapname"] = "overkill" 100 | r["mercylimit"] = "0" 101 | r["protocol"] = "91" 102 | r["roundlimit"] = "10" 103 | r["roundtimelimit"] = "180" 104 | r["scorelimit"] = "150" 105 | r["sv_hostname"] = "exile.syncore.org | US-Central #1 | Competitive" 106 | r["sv_maxclients"] = "16" 107 | r["sv_privateClients"] = "0" 108 | r["teamsize"] = "4" 109 | r["timelimit"] = "0" 110 | r["version"] = "1066 linux-x64 Dec 17 2015 15:36:49" 111 | rules["192.211.62.11:27960"] = r 112 | 113 | players := make(map[string][]models.SteamPlayerInfo, 2) 114 | players["54.172.5.67:25801"] = []models.SteamPlayerInfo{ 115 | models.SteamPlayerInfo{ 116 | Name: "KovaaK", 117 | Score: 92, 118 | TimeConnectedSecs: 4317.216, 119 | TimeConnectedTot: "1h11m57s", 120 | }, 121 | models.SteamPlayerInfo{ 122 | Name: "Sharqosity", 123 | Score: 42, 124 | TimeConnectedSecs: 3428.6987, 125 | TimeConnectedTot: "57m8s", 126 | }, 127 | models.SteamPlayerInfo{ 128 | Name: "dhaK", 129 | Score: 42, 130 | TimeConnectedSecs: 1730.0668, 131 | TimeConnectedTot: "28m50s", 132 | }, 133 | models.SteamPlayerInfo{ 134 | Name: "yoo", 135 | Score: 45, 136 | TimeConnectedSecs: 467.6571, 137 | TimeConnectedTot: "7m47s", 138 | }, 139 | models.SteamPlayerInfo{ 140 | Name: "twitch.tv/liveanton - SANE", 141 | Score: 75, 142 | TimeConnectedSecs: 452.20792, 143 | TimeConnectedTot: "7m32s", 144 | }, 145 | models.SteamPlayerInfo{ 146 | Name: "ObviouslyBuggedPlayer", 147 | Score: 0, 148 | TimeConnectedSecs: 24120.2000, 149 | TimeConnectedTot: "6h42m2s", 150 | }, 151 | } 152 | players["192.211.62.11:27960"] = nil 153 | testData = a2sData{ 154 | HostsGames: hostsgames, 155 | Info: info, 156 | Rules: rules, 157 | Players: players, 158 | } 159 | } 160 | 161 | func TestBuildServerList(t *testing.T) { 162 | asl, err := buildServerList(testData, false) 163 | if err != nil { 164 | t.Fatalf("Unexpected error occurred when building server list.") 165 | } 166 | if len(asl.Servers) != 2 { 167 | t.Fatalf("Expected 2 servers, got: %d", len(asl.Servers)) 168 | } 169 | // Slice not guaranteed to be in order 170 | var reflexServer models.APIServer 171 | var qlServer models.APIServer 172 | if asl.Servers[0].Info.ExtraData.GameID == 282440 { 173 | qlServer = asl.Servers[0] 174 | reflexServer = asl.Servers[1] 175 | } else { 176 | qlServer = asl.Servers[1] 177 | reflexServer = asl.Servers[0] 178 | } 179 | if reflexServer.Info.Players != 6 { 180 | t.Fatalf("Expected Reflex server to contain 6 players, got: %d", 181 | reflexServer.Info.Players) 182 | } 183 | if qlServer.Info.ExtraData.GameID != 282440 { 184 | t.Fatalf("Expected Quake Live server's steam game id to be 282440, got: %d", 185 | qlServer.Info.ExtraData.GameID) 186 | } 187 | if len(qlServer.Players) != 0 { 188 | t.Fatalf("Expected Quake Live server to contain no players, got: %v", 189 | len(qlServer.Players)) 190 | } 191 | } 192 | 193 | func TestRemoveBuggedPlayers(t *testing.T) { 194 | buggedRemoved := removeBuggedPlayers(testData.Players["54.172.5.67:25801"]) 195 | if len(buggedRemoved.FilteredPlayers) != 5 { 196 | t.Fatalf("Expected 5 players after bugged player removal, got: %d", 197 | len(buggedRemoved.FilteredPlayers)) 198 | } 199 | for _, player := range buggedRemoved.FilteredPlayers { 200 | if strings.EqualFold(player.Name, "ObviouslyBuggedPlayer") { 201 | t.Fatalf("Filtered player list should not contain bugged player") 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/steam/query.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | // query.go - Used for querying individual game servers to retrieve their info 4 | // for building a list to return to the API 5 | 6 | import ( 7 | "sync" 8 | 9 | "github.com/syncore/a2sapi/src/logger" 10 | "github.com/syncore/a2sapi/src/models" 11 | "github.com/syncore/a2sapi/src/steam/filters" 12 | ) 13 | 14 | type a2sData struct { 15 | HostsGames map[string]filters.Game 16 | Info map[string]models.SteamServerInfo 17 | Rules map[string]map[string]string 18 | Players map[string][]models.SteamPlayerInfo 19 | } 20 | 21 | func batchInfoQuery(servers []string) map[string]models.SteamServerInfo { 22 | m := make(map[string]models.SteamServerInfo) 23 | var wg sync.WaitGroup 24 | var mut sync.Mutex 25 | var failed []string 26 | 27 | for _, h := range servers { 28 | wg.Add(1) 29 | go func(host string) { 30 | serverinfo, err := GetInfoForServer(host, QueryTimeout) 31 | if err != nil { 32 | mut.Lock() 33 | failed = append(failed, host) 34 | mut.Unlock() 35 | wg.Done() 36 | return 37 | } 38 | mut.Lock() 39 | m[host] = serverinfo 40 | mut.Unlock() 41 | wg.Done() 42 | }(h) 43 | } 44 | wg.Wait() 45 | retried := RetryFailedInfoReq(failed, 3) 46 | for k, v := range retried { 47 | m[k] = v 48 | } 49 | return m 50 | } 51 | 52 | func batchPlayerQuery(servers []string) map[string][]models.SteamPlayerInfo { 53 | m := make(map[string][]models.SteamPlayerInfo) 54 | var wg sync.WaitGroup 55 | var mut sync.Mutex 56 | var failed []string 57 | 58 | for _, h := range servers { 59 | wg.Add(1) 60 | go func(host string) { 61 | players, err := GetPlayersForServer(host, QueryTimeout) 62 | if err != nil { 63 | // server could just be empty 64 | if err != ErrNoPlayers { 65 | mut.Lock() 66 | failed = append(failed, host) 67 | mut.Unlock() 68 | wg.Done() 69 | return 70 | } 71 | } 72 | mut.Lock() 73 | m[host] = players 74 | mut.Unlock() 75 | wg.Done() 76 | }(h) 77 | } 78 | wg.Wait() 79 | retried := RetryFailedPlayersReq(failed, QueryRetryCount) 80 | for k, v := range retried { 81 | m[k] = v 82 | } 83 | return m 84 | } 85 | 86 | func batchRuleQuery(servers []string) map[string]map[string]string { 87 | m := make(map[string]map[string]string) 88 | var wg sync.WaitGroup 89 | var mut sync.Mutex 90 | var failed []string 91 | for _, h := range servers { 92 | wg.Add(1) 93 | go func(host string) { 94 | rules, err := GetRulesForServer(host, QueryTimeout) 95 | if err != nil { 96 | // server might have no rules 97 | if err != ErrNoRules { 98 | mut.Lock() 99 | failed = append(failed, host) 100 | mut.Unlock() 101 | wg.Done() 102 | return 103 | } 104 | } 105 | mut.Lock() 106 | m[host] = rules 107 | mut.Unlock() 108 | wg.Done() 109 | }(h) 110 | } 111 | wg.Wait() 112 | retried := RetryFailedRulesReq(failed, QueryRetryCount) 113 | for k, v := range retried { 114 | m[k] = v 115 | } 116 | return m 117 | } 118 | 119 | // DirectQuery allows a user to query any host even if it is not in the internal 120 | // server ID database. It is primarily intended for testing as it has two main 121 | // issues: 1) obvious security implications, 2) determining which game a user- 122 | // supplied host represents rests on potentially unreliable assumptions, which if 123 | // not true would cause games with incomplete support for all three A2S queries 124 | // (e.g. Reflex) to always fail. A production environment should use Query() instead. 125 | func DirectQuery(hosts []string) (*models.APIServerList, error) { 126 | hg := make(map[string]filters.Game, len(hosts)) 127 | 128 | // Try to account for the fact that we can't determine the game ahead of time 129 | // for user-specified direct host queries -- a number of assumptions: 130 | // (1) A2S_INFO for game/host, (2) extra data A2S_INFO flag & field w/ appid, 131 | //(3) game has been defined in game.go with the correct AppID and A2S ignore flags 132 | info := batchInfoQuery(hosts) 133 | needsRules := make([]string, len(hosts)) 134 | needsPlayers := make([]string, len(hosts)) 135 | 136 | for _, h := range hosts { 137 | logger.WriteDebug("direct query for %s. will try to figure out needed queries", h) 138 | if (info[h] != models.SteamServerInfo{}) { 139 | logger.WriteDebug("A2S_INFO not empty. got gameid: %d", info[h].ExtraData.GameID) 140 | fg := filters.GetGameByAppID(info[h].ExtraData.GameID) 141 | hg[h] = fg 142 | if !fg.IgnoreRules { 143 | logger.WriteDebug("based on game %s for %s, will need to get A2S_RULES", 144 | fg.Name, h) 145 | needsRules = append(needsRules, h) 146 | } 147 | if !fg.IgnorePlayers { 148 | logger.WriteDebug("based on game %s for %s, will need to get A2S_PLAYERS", 149 | fg.Name, h) 150 | needsPlayers = append(needsPlayers, h) 151 | } 152 | } else { 153 | logger.WriteDebug("A2S_INFO is nil. game will be unspecified; results may vary") 154 | hg[h] = filters.GameUnspecified 155 | } 156 | } 157 | data := a2sData{ 158 | HostsGames: hg, 159 | Info: info, 160 | Rules: batchRuleQuery(needsRules), 161 | Players: batchPlayerQuery(needsPlayers), 162 | } 163 | sl, err := buildServerList(data, true) 164 | if err != nil { 165 | return models.GetDefaultServerList(), logger.LogAppError(err) 166 | } 167 | return sl, nil 168 | } 169 | 170 | // Query retrieves the server information for a given set of host to game pairs 171 | // and returns it in a format that is presented to the API. It takes a map consisting 172 | // of host(s) and their corresponding game names (i.e: k:127.0.0.1:27960, v:"QuakeLive") 173 | func Query(hostsgames map[string]string) (*models.APIServerList, error) { 174 | hg := make(map[string]filters.Game, len(hostsgames)) 175 | needsPlayers := make([]string, len(hostsgames)) 176 | needsRules := make([]string, len(hostsgames)) 177 | needsInfo := make([]string, len(hostsgames)) 178 | 179 | for host, game := range hostsgames { 180 | fg := filters.GetGameByName(game) 181 | hg[host] = fg 182 | if !fg.IgnoreRules { 183 | needsRules = append(needsRules, host) 184 | } 185 | if !fg.IgnorePlayers { 186 | needsPlayers = append(needsPlayers, host) 187 | } 188 | if !fg.IgnoreInfo { 189 | needsInfo = append(needsInfo, host) 190 | } 191 | } 192 | data := a2sData{ 193 | HostsGames: hg, 194 | Info: batchInfoQuery(needsInfo), 195 | Rules: batchRuleQuery(needsRules), 196 | Players: batchPlayerQuery(needsPlayers), 197 | } 198 | 199 | sl, err := buildServerList(data, true) 200 | if err != nil { 201 | return models.GetDefaultServerList(), logger.LogAppError(err) 202 | } 203 | return sl, nil 204 | } 205 | -------------------------------------------------------------------------------- /src/steam/steam.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | const ( 4 | headerStr = "\xFF\xFF\xFF\xFF" 5 | maxPacketSize = 1400 // specified by steam protocol 6 | // QueryTimeout is the connect, read, and write timeout in seconds. It should 7 | // be greater than 1. 8 | QueryTimeout = 3 9 | // QueryRetryCount is the number of times to re-request rules, players, and info 10 | // on failure. 11 | QueryRetryCount = 3 12 | ) 13 | 14 | var ( 15 | // Multi-packet response header 16 | multiPacketRespHeader = []byte{0xFE, 0xFF, 0xFF, 0xFF} 17 | 18 | // A2S_INFO: challenge request packet 19 | infoChallengeReq = []byte{ 20 | 0xFF, 0xFF, 0xFF, 0xFF, 21 | 0x54, 0x53, 0x6F, 0x75, 0x72, 22 | 0x63, 0x65, 0x20, 0x45, 0x6E, 23 | 0x67, 0x69, 0x6E, 0x65, 0x20, 24 | 0x51, 0x75, 0x65, 0x72, 0x79, 25 | 0x00} 26 | // A2S_INFO: expected challenge response header 27 | expectedInfoRespHeader = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x49} 28 | 29 | // A2S_PLAYER: challenge request packet 30 | playerChallengeReq = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x55, 0xFF, 0xFF, 31 | 0xFF, 0xFF} 32 | // A2S_PLAYER: expected challenge response header 33 | expectedPlayerRespHeader = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x41} 34 | // A2S_PLAYER: expected player chunk 35 | expectedPlayerChunkHeader = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x44} 36 | 37 | // A2S_RULES: challenge request packet 38 | rulesChallengeReq = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x56, 0xFF, 0xFF, 0xFF, 39 | 0xFF} 40 | // A2S_RULES: expected challenge response header 41 | expectedRulesRespHeader = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x41} 42 | // A2S_RULES: expected rule chunk 43 | expectedRuleChunkHeader = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x45} 44 | 45 | // Steam master server: expected response header 46 | expectedMasterRespHeader = []byte{0xFF, 0xFF, 0xFF, 0xFF, 47 | 0x66, 0x0A} 48 | ) 49 | 50 | func removeFailedHost(failed []string, host string) []string { 51 | for i, v := range failed { 52 | if v == host { 53 | failed = append(failed[:i], failed[i+1:]...) 54 | break 55 | } 56 | } 57 | return failed 58 | } 59 | -------------------------------------------------------------------------------- /src/steam/steamerrors.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // Errors 9 | var ( 10 | // ErrHostConnection is an error related to the establishment of a connection. 11 | ErrHostConnection = func(msg string) error { 12 | return fmt.Errorf("Steam: host connection error: %s", msg) 13 | } 14 | // ErrDataTransmit is an error related to sending data to a connection. 15 | ErrDataTransmit = func(msg string) error { 16 | return fmt.Errorf("Steam: data transmission error: %s", msg) 17 | } 18 | // ErrMultiPacketTransmit is an error related to sending data to a connection 19 | // in the multi-packet context of A2S_RULES. 20 | ErrMultiPacketTransmit = func(msg string) error { 21 | return fmt.Errorf("Steam: multi-packet data transmission error: %s", msg) 22 | } 23 | // ErrChallengeResponse is an error thrown for an invalid challense response 24 | // header. 25 | ErrChallengeResponse = errors.New("Steam: invalid challenge response header") 26 | 27 | // ErrPacketHeader is an error thrown upon detection of an invalid packet header. 28 | ErrPacketHeader = errors.New("Steam: invalid packet header") 29 | 30 | // ErrMultiPacketDuplicate is an error thrown when a duplicate packet is 31 | // detected int he multi-packet context of A2S_RULES. 32 | ErrMultiPacketDuplicate = errors.New( 33 | "Steam: multi-packet: duplicate packet detected") 34 | 35 | // ErrMultiPacketIDMismatch is an error thrown in the context of multi-packet 36 | // A2S_RULES when the current packet ID does match the packet ID for the batch 37 | // of multiple packets currently being processed. 38 | ErrMultiPacketIDMismatch = errors.New( 39 | "Steam: multi-packet error: packet ID mismatch") 40 | 41 | // ErrMultiPacketNumExceeded is an error thrown in the A2S_RULES multi-packet 42 | // context when the current packet's number is greater than the total number of 43 | // packets to be parsed within the current batch. 44 | ErrMultiPacketNumExceeded = errors.New( 45 | "Steam: multi-packet error: packet number greater than total") 46 | 47 | // ErrNoPlayers is a generic error thrown when a server is empty. 48 | ErrNoPlayers = errors.New("Steam: server contains no players") 49 | 50 | // ErrNoRules is a generic error thrown when no A2S_RULES data could be parsed 51 | // for the given server. 52 | ErrNoRules = errors.New("Steam: no A2S_RULES for server") 53 | 54 | // ErrNoInfo is a generic error thrown when no A2S_INFO could be parsed for the 55 | // given server. 56 | ErrNoInfo = errors.New("Steam: no A2S_INFO for server") 57 | ) 58 | -------------------------------------------------------------------------------- /src/steam/steaminfo.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | // steaminfo.go - steam server query for info (A2S_INFO) 4 | 5 | import ( 6 | "bytes" 7 | "encoding/binary" 8 | "net" 9 | "sync" 10 | "time" 11 | 12 | "github.com/syncore/a2sapi/src/logger" 13 | "github.com/syncore/a2sapi/src/models" 14 | "github.com/syncore/a2sapi/src/util" 15 | ) 16 | 17 | func getServerInfo(host string, timeout int) ([]byte, error) { 18 | conn, err := net.DialTimeout("udp", host, time.Duration(timeout)*time.Second) 19 | if err != nil { 20 | logger.LogSteamError(ErrHostConnection(err.Error())) 21 | return nil, ErrHostConnection(err.Error()) 22 | } 23 | defer conn.Close() 24 | conn.SetDeadline(time.Now().Add(time.Duration(timeout-1) * time.Second)) 25 | 26 | _, err = conn.Write(infoChallengeReq) 27 | if err != nil { 28 | logger.LogSteamError(ErrDataTransmit(err.Error())) 29 | return nil, ErrDataTransmit(err.Error()) 30 | } 31 | 32 | var buf [maxPacketSize]byte 33 | numread, err := conn.Read(buf[:maxPacketSize]) 34 | if err != nil { 35 | logger.LogSteamError(ErrDataTransmit(err.Error())) 36 | return nil, ErrDataTransmit(err.Error()) 37 | } 38 | serverInfo := make([]byte, numread) 39 | copy(serverInfo, buf[:numread]) 40 | 41 | if !bytes.HasPrefix(serverInfo, expectedInfoRespHeader) { 42 | logger.LogSteamError(ErrPacketHeader) 43 | return nil, ErrPacketHeader 44 | } 45 | 46 | return serverInfo, nil 47 | } 48 | 49 | func parseServerInfo(serverinfo []byte) (models.SteamServerInfo, error) { 50 | if !bytes.HasPrefix(serverinfo, expectedInfoRespHeader) { 51 | logger.LogSteamError(ErrPacketHeader) 52 | return models.SteamServerInfo{}, ErrPacketHeader 53 | } 54 | 55 | serverinfo = bytes.TrimLeft(serverinfo, headerStr) 56 | 57 | // no info (should usually not happen) 58 | if len(serverinfo) <= 1 { 59 | logger.LogSteamError(ErrNoInfo) 60 | return models.SteamServerInfo{}, ErrNoInfo 61 | } 62 | 63 | serverinfo = serverinfo[1:] // 0x49 64 | protocol := int(serverinfo[0]) 65 | serverinfo = serverinfo[1:] 66 | 67 | name := util.ReadTillNul(serverinfo) 68 | serverinfo = serverinfo[len(name)+1:] 69 | mapname := util.ReadTillNul(serverinfo) 70 | serverinfo = serverinfo[len(mapname)+1:] 71 | folder := util.ReadTillNul(serverinfo) 72 | serverinfo = serverinfo[len(folder)+1:] 73 | game := util.ReadTillNul(serverinfo) 74 | serverinfo = serverinfo[len(game)+1:] 75 | id := int16(binary.LittleEndian.Uint16(serverinfo[:2])) 76 | serverinfo = serverinfo[2:] 77 | if id >= 2400 && id <= 2412 { 78 | return models.SteamServerInfo{}, 79 | logger.LogSteamErrorf("The Ship servers are not supported") 80 | } 81 | players := int16(serverinfo[0]) 82 | serverinfo = serverinfo[1:] 83 | maxplayers := int16(serverinfo[0]) 84 | serverinfo = serverinfo[1:] 85 | bots := int16(serverinfo[0]) 86 | serverinfo = serverinfo[1:] 87 | servertype := string(serverinfo[0]) 88 | serverinfo = serverinfo[1:] 89 | environment := string(serverinfo[0]) 90 | serverinfo = serverinfo[1:] 91 | visibility := int16(serverinfo[0]) 92 | serverinfo = serverinfo[1:] 93 | vac := int16(serverinfo[0]) 94 | serverinfo = serverinfo[1:] 95 | version := util.ReadTillNul(serverinfo) 96 | serverinfo = serverinfo[len(version)+1:] 97 | 98 | // extra data flags 99 | var port int16 100 | var steamid uint64 101 | var sourcetvport int16 102 | var sourcetvname string 103 | var keywords string 104 | var gameid uint64 105 | edf := serverinfo[0] 106 | serverinfo = serverinfo[1:] 107 | if edf != 0x00 { 108 | if edf&0x80 > 0 { 109 | port = int16(binary.LittleEndian.Uint16(serverinfo[:2])) 110 | serverinfo = serverinfo[2:] 111 | } 112 | if edf&0x10 > 0 { 113 | steamid = binary.LittleEndian.Uint64(serverinfo[:8]) 114 | serverinfo = serverinfo[8:] 115 | } 116 | if edf&0x40 > 0 { 117 | sourcetvport = int16(binary.LittleEndian.Uint16(serverinfo[:2])) 118 | serverinfo = serverinfo[2:] 119 | sourcetvname = util.ReadTillNul(serverinfo) 120 | serverinfo = serverinfo[len(sourcetvname)+1:] 121 | } 122 | if edf&0x20 > 0 { 123 | keywords = util.ReadTillNul(serverinfo) 124 | serverinfo = serverinfo[len(keywords)+1:] 125 | } 126 | if edf&0x01 > 0 { 127 | gameid = binary.LittleEndian.Uint64(serverinfo[:8]) 128 | serverinfo = serverinfo[len(serverinfo):] 129 | } 130 | } 131 | 132 | // format a few ambiguous values 133 | if environment == "l" { 134 | environment = "Linux" 135 | } 136 | if environment == "w" { 137 | environment = "Windows" 138 | } 139 | if environment == "m" || environment == "o" { 140 | environment = "Mac" 141 | } 142 | if servertype == "d" { 143 | servertype = "dedicated" 144 | } 145 | if servertype == "l" { 146 | servertype = "listen" 147 | } 148 | if servertype == "p" { 149 | servertype = "sourcetv" 150 | } 151 | 152 | return models.SteamServerInfo{ 153 | Protocol: protocol, 154 | Name: name, 155 | Map: mapname, 156 | Folder: folder, 157 | Game: game, 158 | ID: id, 159 | Players: players, 160 | MaxPlayers: maxplayers, 161 | Bots: bots, 162 | ServerType: servertype, 163 | Environment: environment, 164 | Visibility: visibility, 165 | VAC: vac, 166 | Version: version, 167 | ExtraData: models.SteamExtraData{ 168 | Port: port, 169 | SteamID: steamid, 170 | SourceTVPort: sourcetvport, 171 | SourceTVName: sourcetvname, 172 | Keywords: keywords, 173 | GameID: gameid, 174 | }, 175 | }, nil 176 | } 177 | 178 | // RetryFailedInfoReq retries a failed A2S_INFO request for a specified group of 179 | // failed hosts for a total of retrycount times, returning a host to A2S_INFO 180 | // mapping for any hosts that were successfully retried. 181 | func RetryFailedInfoReq(failed []string, 182 | retrycount int) map[string]models.SteamServerInfo { 183 | m := make(map[string]models.SteamServerInfo) 184 | var f []string 185 | var wg sync.WaitGroup 186 | var mut sync.Mutex 187 | for i := 0; i < retrycount; i++ { 188 | if i == 0 { 189 | f = failed 190 | } 191 | wg.Add(len(f)) 192 | for _, host := range f { 193 | go func(h string) { 194 | defer wg.Done() 195 | r, err := GetInfoForServer(h, QueryTimeout) 196 | if err != nil { 197 | if err != ErrNoInfo { 198 | return 199 | } 200 | } 201 | mut.Lock() 202 | m[h] = r 203 | f = removeFailedHost(f, h) 204 | mut.Unlock() 205 | }(host) 206 | } 207 | wg.Wait() 208 | } 209 | return m 210 | } 211 | 212 | // GetInfoForServer requests A2S_INFO for a given host within timeout seconds. 213 | func GetInfoForServer(host string, timeout int) (models.SteamServerInfo, error) { 214 | // Caller will log. Return err instead of wrapped logger.LogSteamError so as not 215 | // to interfere with custom error types that need to be analyzed when 216 | // determining if retry needs to be done. 217 | si, err := getServerInfo(host, timeout) 218 | if err != nil { 219 | return models.SteamServerInfo{}, err 220 | } 221 | 222 | serverinfo, err := parseServerInfo(si) 223 | if err != nil { 224 | return models.SteamServerInfo{}, err 225 | } 226 | return serverinfo, nil 227 | } 228 | -------------------------------------------------------------------------------- /src/steam/steaminfo_test.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestParseServerInfo(t *testing.T) { 9 | data := []byte{ 10 | 0xFF, 0xFF, 0xFF, 0xFF, 0x49, 0x11, 0x71, 0x6C, 0x2E, 0x73, 0x79, 0x6E, 11 | 0x63, 0x6F, 0x72, 0x65, 0x2E, 0x6F, 0x72, 0x67, 0x20, 0x2D, 0x20, 0x55, 12 | 0x53, 0x20, 0x43, 0x45, 0x4E, 0x54, 0x52, 0x41, 0x4C, 0x20, 0x23, 0x31, 13 | 0x00, 0x74, 0x68, 0x75, 0x6E, 0x64, 0x65, 0x72, 0x73, 0x74, 0x72, 0x75, 14 | 0x63, 0x6B, 0x00, 0x62, 0x61, 0x73, 0x65, 0x71, 0x33, 0x00, 0x43, 0x6C, 15 | 0x61, 0x6E, 0x20, 0x41, 0x72, 0x65, 0x6E, 0x61, 0x00, 0x00, 0x00, 0x02, 16 | 0x10, 0x00, 0x64, 0x6C, 0x00, 0x01, 0x31, 0x30, 0x36, 0x33, 0x00, 0xB1, 17 | 0x38, 0x6D, 0x02, 0xF8, 0xC1, 0x4D, 0x7B, 0x17, 0x40, 0x01, 0x63, 0x6C, 18 | 0x61, 0x6E, 0x61, 0x72, 0x65, 0x6E, 0x61, 0x2C, 0x73, 0x79, 0x6E, 0x63, 19 | 0x6F, 0x72, 0x65, 0x2C, 0x74, 0x65, 0x78, 0x61, 0x73, 0x00, 0x48, 0x4F, 20 | 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 21 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 22 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} 23 | sinfo, err := parseServerInfo(data) 24 | if err != nil { 25 | t.Fatalf("Unexpected error when parsing server info") 26 | } 27 | if !strings.EqualFold(sinfo.Name, "ql.syncore.org - US CENTRAL #1") { 28 | t.Fatalf("Expected server name: ql.syncore.org - US CENTRAL #1 got: %s", 29 | sinfo.Name) 30 | } 31 | if !strings.EqualFold(sinfo.Environment, "Linux") { 32 | t.Fatalf("Expected server environment: Linux got: %s", 33 | sinfo.Environment) 34 | } 35 | if sinfo.Players != 2 { 36 | t.Fatalf("Expected server to contain 2 players, got: %d", sinfo.Players) 37 | } 38 | if !strings.EqualFold(sinfo.Folder, "baseq3") { 39 | t.Fatalf("Expected server's game folder to be baseq3, got: %s", sinfo.Folder) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/steam/steammaster.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | // steammaster.go - steam master server query 4 | // This method of retrieval involves querying Valve's master server to get the server lists. 5 | // Note that this retrieval process is subject to random reliability and downtime issues on Valve's end. 6 | // For example, starting on Nov. 8, 2016 the Valve master server was offline for several days with no 7 | // explanation or expectation of its return. Because of this, the default method of retrieval now uses a 8 | // more reliable web endpoint to receive the servers directly from Valve. See steammasterweb.go for this 9 | // updated web retrieval method. 10 | 11 | import ( 12 | "bytes" 13 | "fmt" 14 | "net" 15 | "time" 16 | 17 | "github.com/syncore/a2sapi/src/config" 18 | "github.com/syncore/a2sapi/src/logger" 19 | "github.com/syncore/a2sapi/src/steam/filters" 20 | ) 21 | 22 | // MasterQuery contains the servers returned by a query to the Steam master server. 23 | type MasterQuery struct { 24 | Servers []string 25 | } 26 | 27 | const masterServerHost = "hl2master.steampowered.com:27011" 28 | 29 | func getServers(filter filters.Filter) ([]string, error) { 30 | maxHosts := config.Config.SteamConfig.MaximumHostsToReceive 31 | var serverlist []string 32 | var c net.Conn 33 | var err error 34 | retrieved := 0 35 | addr := "0.0.0.0:0" 36 | 37 | c, err = net.DialTimeout("udp", masterServerHost, 38 | time.Duration(QueryTimeout)*time.Second) 39 | if err != nil { 40 | logger.LogSteamError(ErrHostConnection(err.Error())) 41 | return nil, ErrHostConnection(err.Error()) 42 | } 43 | 44 | defer c.Close() 45 | c.SetDeadline(time.Now().Add(time.Duration(QueryTimeout) * time.Second)) 46 | 47 | for { 48 | s, err := queryMasterServer(c, addr, filter) 49 | if err != nil { 50 | // usually timeout - Valve throttles >30 UDP packets (>6930 servers) per min 51 | logger.WriteDebug("Master query error, likely due to Valve throttle/timeout :%s", 52 | err) 53 | break 54 | } 55 | // get hosts:ports beginning after header (0xFF, 0xFF, 0xFF, 0xFF, 0x66, 0x0A) 56 | ips, total, err := extractHosts(s[6:]) 57 | if err != nil { 58 | return nil, logger.LogAppErrorf("Error when extracting addresses: %s", 59 | err) 60 | } 61 | retrieved = retrieved + total 62 | if retrieved >= maxHosts { 63 | logger.LogSteamInfo("Max host limit of %d reached!", maxHosts) 64 | logger.WriteDebug("Max host limit of %d reached!", maxHosts) 65 | break 66 | } 67 | logger.LogSteamInfo("%d hosts retrieved so far from master.", retrieved) 68 | logger.WriteDebug("%d hosts retrieved so far from master.", retrieved) 69 | for _, ip := range ips { 70 | serverlist = append(serverlist, ip) 71 | } 72 | 73 | if (serverlist[len(serverlist)-1]) != "0.0.0.0:0" { 74 | logger.LogSteamInfo("More hosts need to be retrieved. Last IP was: %s", 75 | serverlist[len(serverlist)-1]) 76 | logger.WriteDebug("More hosts need to be retrieved. Last IP was: %s", 77 | serverlist[len(serverlist)-1]) 78 | addr = serverlist[len(serverlist)-1] 79 | } else { 80 | logger.LogSteamInfo("IP retrieval complete!") 81 | logger.WriteDebug("IP retrieval complete!") 82 | break 83 | } 84 | } 85 | // remove 0.0.0.0:0 86 | if len(serverlist) != 0 { 87 | if serverlist[len(serverlist)-1] == "0.0.0.0:0" { 88 | serverlist = serverlist[:len(serverlist)-1] 89 | } 90 | } 91 | return serverlist, nil 92 | } 93 | 94 | func extractHosts(hbs []byte) ([]string, int, error) { 95 | var sl []string 96 | pos, total := 0, 0 97 | for i := 0; i < len(hbs); i++ { 98 | if len(sl) > 0 && sl[len(sl)-1] == "0.0.0.0:0" { 99 | logger.LogSteamInfo("0.0.0.0:0 detected. Got %d total hosts.", total-1) 100 | break 101 | } 102 | if pos+6 > len(hbs) { 103 | logger.LogSteamInfo("Got %d total hosts.", total) 104 | break 105 | } 106 | 107 | host, err := parseIP(hbs[pos : pos+6]) 108 | if err != nil { 109 | logger.LogAppErrorf("Error parsing host: %s", err) 110 | } else { 111 | sl = append(sl, host) 112 | total++ 113 | } 114 | // host:port = 6 bytes 115 | pos = pos + 6 116 | } 117 | return sl, total, nil 118 | } 119 | 120 | func parseIP(k []byte) (string, error) { 121 | if len(k) != 6 { 122 | return "", logger.LogSteamErrorf("Invalid IP byte size. Got: %d, expected 6", 123 | len(k)) 124 | } 125 | port := int16(k[5]) | int16(k[4])<<8 126 | return fmt.Sprintf("%d.%d.%d.%d:%d", int(k[0]), int(k[1]), int(k[2]), 127 | int(k[3]), port), nil 128 | } 129 | 130 | func queryMasterServer(conn net.Conn, startaddress string, 131 | filter filters.Filter) ([]byte, error) { 132 | // Note: the connection is closed by the caller, do not close here, otherwise 133 | // Steam will continue to send the first batch of IPs and won't progress to the next batch 134 | startaddress = fmt.Sprintf("%s\x00", startaddress) 135 | addr := []byte(startaddress) 136 | request := []byte{0x31} 137 | request = append(request, filter.Region...) 138 | request = append(request, addr...) 139 | 140 | for i, f := range filter.Filters { 141 | for _, b := range f { 142 | request = append(request, b) 143 | } 144 | if i == len(filter.Filters)-1 { 145 | request = append(request, 0x00) 146 | } 147 | } 148 | 149 | _, err := conn.Write(request) 150 | if err != nil { 151 | logger.LogSteamError(ErrDataTransmit(err.Error())) 152 | return nil, ErrDataTransmit(err.Error()) 153 | } 154 | 155 | var buf [maxPacketSize]byte 156 | numread, err := conn.Read(buf[:maxPacketSize]) 157 | if err != nil { 158 | logger.LogSteamError(ErrDataTransmit(err.Error())) 159 | return nil, ErrDataTransmit(err.Error()) 160 | } 161 | 162 | masterResponse := make([]byte, numread) 163 | copy(masterResponse, buf[:numread]) 164 | 165 | if !bytes.HasPrefix(masterResponse, expectedMasterRespHeader) { 166 | logger.LogSteamError(ErrPacketHeader) 167 | return nil, ErrPacketHeader 168 | } 169 | 170 | return masterResponse, nil 171 | } 172 | 173 | // NewMasterQuery initiates a new Steam Master server query for a given filter, 174 | // returning a MasterQuery struct containing the hosts retrieved in the event of 175 | // success or an empty struct and an error in the event of failure. 176 | func NewMasterQuery(filter filters.Filter) (MasterQuery, error) { 177 | sl, err := getServers(filter) 178 | if err != nil { 179 | return MasterQuery{}, err 180 | } 181 | logger.LogSteamInfo("*** Retrieved %d %s servers.", len(sl), filter.Game.Name) 182 | 183 | return MasterQuery{Servers: sl}, nil 184 | } 185 | -------------------------------------------------------------------------------- /src/steam/steammaster_test.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestExtractHosts(t *testing.T) { 9 | data := []byte{ 10 | 0xC0, 0xD3, 0x3E, 0x0B, 0x6D, 0x38, 0x2D, 0x37, 0xA8, 0xA0, 0x6D, 0x38, 11 | 0x68, 0xEC, 0x89, 0x14, 0x6D, 0x38, 0xD0, 0x43, 0x01, 0x43, 0x64, 0xC5, 12 | 0x59, 0xC5, 0x30, 0xB6, 0x64, 0xC5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} 13 | hosts, total, err := extractHosts(data) 14 | if err != nil { 15 | t.Fatalf("Unexpected error when extracting hosts") 16 | } 17 | // last host terminates list as 0.0.0.0:0 so total = total-1 18 | total = total - 1 19 | if total != 5 { 20 | t.Fatalf("Expected extraction of 5 total hosts, got: %d", total) 21 | } 22 | 23 | hoststrings := []string{ 24 | "192.211.62.11:27960", 25 | "45.55.168.160:27960", 26 | "104.236.137.20:27960", 27 | "208.67.1.67:25797", 28 | "89.197.48.182:25797", 29 | } 30 | 31 | found, expected := 0, 5 32 | for _, h := range hosts { 33 | for _, hs := range hoststrings { 34 | if strings.EqualFold(h, hs) { 35 | found++ 36 | } 37 | } 38 | } 39 | if found != expected { 40 | t.Fatalf("Expected 5 hosts to have been extracted, only got: %d", found) 41 | } 42 | } 43 | 44 | func TestParseIP(t *testing.T) { 45 | parsed, err := parseIP([]byte{0x59, 0xC5, 0x30, 0xB6, 0x64, 0xC5}) 46 | if err != nil { 47 | t.Fatalf("Unexpected error when parsing IP") 48 | } 49 | if !strings.EqualFold(parsed, "89.197.48.182:25797") { 50 | t.Fatalf("Expected IP: 89.197.48.182:25797, got: %s", parsed) 51 | } 52 | parsed, err = parseIP([]byte{0x2D, 0x37, 0xA8, 0xA0, 0x6D, 0x38}) 53 | if err != nil { 54 | t.Fatalf("Unexpected error when parsing IP") 55 | } 56 | if !strings.EqualFold(parsed, "45.55.168.160:27960") { 57 | t.Fatalf("Expected IP: 45.55.168.160:27960, got: %s", parsed) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/steam/steammasterrweb.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | // steammasterweb.go - Valve web "master" server list 4 | // This default method of retrieval involves accessing an undocumented web API endpoint to directly receive 5 | // the list of servers. This is preferable to the old method of querying Valve's master server 6 | // (hl2master.steampowered.com) which was prone to reliability and downtime issues on Valve's end. 7 | // If neccessary, the old method can still be used; for more information see steammaster.go. 8 | 9 | import ( 10 | "encoding/json" 11 | "fmt" 12 | "net/http" 13 | "strings" 14 | 15 | "github.com/syncore/a2sapi/src/config" 16 | "github.com/syncore/a2sapi/src/logger" 17 | "github.com/syncore/a2sapi/src/steam/filters" 18 | ) 19 | 20 | var steamWebAPIURL = func(webAPIKey, filter string, limit int) string { 21 | return fmt.Sprintf("https://api.steampowered.com/IGameServersService/GetServerList/v1/?key=%s&format=json&filter=%s&limit=%d", 22 | webAPIKey, filter, limit) 23 | } 24 | 25 | // webGameServerList repersents the response returned from the Steam Web API that includes the 26 | // server addresses (and some extra information that we are not interested in) 27 | type webGameServerList struct { 28 | Response struct { 29 | Servers []struct { 30 | Addr string `json:"addr"` 31 | Gameport int `json:"gameport"` 32 | Steamid string `json:"steamid"` 33 | Name string `json:"name"` 34 | Appid int `json:"appid"` 35 | Gamedir string `json:"gamedir"` 36 | Version string `json:"version"` 37 | Product string `json:"product"` 38 | Region int `json:"region"` 39 | Players int `json:"players"` 40 | MaxPlayers int `json:"maxPlayers"` 41 | Bots int `json:"bots"` 42 | Map string `json:"map"` 43 | Secure bool `json:"secure"` 44 | Dedicated bool `json:"dedicated"` 45 | Os string `json:"os"` 46 | Gametype string `json:"gametype"` 47 | } `json:"servers"` 48 | } `json:"response"` 49 | } 50 | 51 | func getServersWeb(filter filters.Filter) ([]string, error) { 52 | var fsl []string 53 | for _, f := range filter.Filters { 54 | fsl = append(fsl, string(f)) 55 | } 56 | filterStr := strings.Join(fsl, "") 57 | response, err := http.Get(steamWebAPIURL(config.Config.SteamConfig.SteamWebAPIKey, filterStr, 58 | config.Config.SteamConfig.MaximumHostsToReceive)) 59 | if err != nil { 60 | return nil, err 61 | } 62 | defer response.Body.Close() 63 | var webAPIResponseModel webGameServerList 64 | var servers []string 65 | apiResult := json.NewDecoder(response.Body) 66 | if err := apiResult.Decode(&webAPIResponseModel); err != nil { 67 | logger.WriteDebug("Error decoding Steam Web API response: %s", err) 68 | return nil, err 69 | } 70 | for _, server := range webAPIResponseModel.Response.Servers { 71 | servers = append(servers, server.Addr) 72 | } 73 | return servers, nil 74 | } 75 | 76 | // NewMasterWebQuery initiates a new Steam "Master" server query using the Steam Web API for a 77 | // given filter, returning a MasterQuery struct containing the hosts retrieved in the event of 78 | // success or an empty struct and an error in the event of failure. 79 | func NewMasterWebQuery(filter filters.Filter) (MasterQuery, error) { 80 | sl, err := getServersWeb(filter) 81 | if err != nil { 82 | return MasterQuery{}, err 83 | } 84 | logger.LogSteamInfo("*** Retrieved %d %s servers.", len(sl), filter.Game.Name) 85 | 86 | return MasterQuery{Servers: sl}, nil 87 | } 88 | -------------------------------------------------------------------------------- /src/steam/steamplayer.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | // steamplayer.go - steam server query for players (A2S_PLAYER) 4 | 5 | import ( 6 | "bytes" 7 | "encoding/binary" 8 | "math" 9 | "net" 10 | "sync" 11 | "time" 12 | 13 | "github.com/syncore/a2sapi/src/logger" 14 | "github.com/syncore/a2sapi/src/models" 15 | ) 16 | 17 | func getPlayerInfo(host string, timeout int) ([]byte, error) { 18 | conn, err := net.DialTimeout("udp", host, time.Duration(timeout)*time.Second) 19 | if err != nil { 20 | logger.LogSteamError(ErrHostConnection(err.Error())) 21 | return nil, ErrHostConnection(err.Error()) 22 | } 23 | 24 | defer conn.Close() 25 | conn.SetDeadline(time.Now().Add(time.Duration(timeout-1) * time.Second)) 26 | 27 | _, err = conn.Write(playerChallengeReq) 28 | if err != nil { 29 | logger.LogSteamError(ErrDataTransmit(err.Error())) 30 | return nil, ErrDataTransmit(err.Error()) 31 | } 32 | 33 | challengeNumResp := make([]byte, maxPacketSize) 34 | _, err = conn.Read(challengeNumResp) 35 | if err != nil { 36 | logger.LogSteamError(ErrDataTransmit(err.Error())) 37 | return nil, ErrDataTransmit(err.Error()) 38 | } 39 | if !bytes.HasPrefix(challengeNumResp, expectedPlayerRespHeader) { 40 | logger.LogSteamError(ErrChallengeResponse) 41 | return nil, ErrChallengeResponse 42 | } 43 | challengeNum := bytes.TrimLeft(challengeNumResp, headerStr) 44 | challengeNum = challengeNum[1:5] 45 | request := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x55} 46 | request = append(request, challengeNum...) 47 | 48 | _, err = conn.Write(request) 49 | if err != nil { 50 | logger.LogSteamError(ErrDataTransmit(err.Error())) 51 | return nil, ErrDataTransmit(err.Error()) 52 | } 53 | var buf [maxPacketSize]byte 54 | numread, err := conn.Read(buf[:maxPacketSize]) 55 | if err != nil { 56 | logger.LogSteamError(ErrDataTransmit(err.Error())) 57 | return nil, ErrDataTransmit(err.Error()) 58 | } 59 | pi := make([]byte, numread) 60 | copy(pi, buf[:numread]) 61 | 62 | return pi, nil 63 | } 64 | 65 | func parsePlayerInfo(unparsed []byte) ([]models.SteamPlayerInfo, error) { 66 | if !bytes.HasPrefix(unparsed, expectedPlayerChunkHeader) { 67 | logger.LogSteamError(ErrPacketHeader) 68 | return nil, ErrPacketHeader 69 | } 70 | unparsed = bytes.TrimLeft(unparsed, headerStr) 71 | numplayers := int(unparsed[1]) 72 | 73 | if numplayers == 0 { 74 | return nil, ErrNoPlayers 75 | } 76 | 77 | players := []models.SteamPlayerInfo{} 78 | 79 | // index 0 = '44' | 1 = 'numplayers' byte | 2 = player 1 separator byte '00' 80 | // | 3 = start of player 1 name; additional player start indexes are player separator + 1 81 | startidx := 3 82 | var b []byte 83 | for i := 0; i < numplayers; i++ { 84 | if i == 0 { 85 | b = unparsed[startidx:] 86 | } else { 87 | b = b[startidx+1:] 88 | } 89 | nul := bytes.IndexByte(b, 0x00) 90 | name := b[:nul] // string (variable length) 91 | score := b[nul+1 : nul+5] // long (4 bytes) 92 | duration := b[nul+5 : nul+9] // float (4 bytes) 93 | startidx = nul + 9 94 | 95 | seconds, timeformatted := getDuration(duration) 96 | players = append(players, models.SteamPlayerInfo{ 97 | Name: string(name), 98 | Score: int32(binary.LittleEndian.Uint32(score)), 99 | TimeConnectedSecs: seconds, 100 | TimeConnectedTot: timeformatted, 101 | }) 102 | } 103 | 104 | return players, nil 105 | } 106 | 107 | func getDuration(bytes []byte) (float32, string) { 108 | bits := binary.LittleEndian.Uint32(bytes) 109 | f := math.Float32frombits(bits) 110 | s := time.Duration(int64(f)) * time.Second 111 | return f, s.String() 112 | } 113 | 114 | // RetryFailedPlayersReq retries a failed A2S_PLAYER request for a specified group of 115 | // failed hosts for a total of retrycount times, returning a host to A2S_PLAYER 116 | // mapping for any hosts that were successfully retried. 117 | func RetryFailedPlayersReq(failed []string, 118 | retrycount int) map[string][]models.SteamPlayerInfo { 119 | 120 | m := make(map[string][]models.SteamPlayerInfo) 121 | var f []string 122 | var wg sync.WaitGroup 123 | var mut sync.Mutex 124 | for i := 0; i < retrycount; i++ { 125 | if i == 0 { 126 | f = failed 127 | } 128 | wg.Add(len(f)) 129 | for _, host := range f { 130 | go func(h string) { 131 | defer wg.Done() 132 | r, err := GetPlayersForServer(h, QueryTimeout) 133 | if err != nil { 134 | if err != ErrNoPlayers { 135 | return 136 | } 137 | } 138 | mut.Lock() 139 | m[h] = r 140 | f = removeFailedHost(f, h) 141 | mut.Unlock() 142 | }(host) 143 | } 144 | wg.Wait() 145 | } 146 | return m 147 | } 148 | 149 | // GetPlayersForServer requests A2S_PLAYER info for a given host within timeout seconds. 150 | func GetPlayersForServer(host string, timeout int) ([]models.SteamPlayerInfo, error) { 151 | // Caller will log. Return err instead of wrapped logger.LogSteamError so as not 152 | // to interfere with custom error types that need to be analyzed when 153 | // determining if retry needs to be done. 154 | pi, err := getPlayerInfo(host, timeout) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | players, err := parsePlayerInfo(pi) 160 | if err != nil { 161 | return nil, err 162 | } 163 | return players, nil 164 | } 165 | -------------------------------------------------------------------------------- /src/steam/steamplayer_test.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestParsePlayerInfo(t *testing.T) { 9 | data := []byte{ 10 | 0xFF, 0xFF, 0xFF, 0xFF, 0x44, 0x07, 0x00, 0x53, 0x68, 0x57, 0x69, 0x56, 11 | 0x65, 0x4C, 0x00, 0x03, 0x00, 0x00, 0x00, 0xEC, 0x37, 0x92, 0x45, 0x00, 12 | 0x73, 0x74, 0x75, 0x6D, 0x70, 0x79, 0x00, 0x05, 0x00, 0x00, 0x00, 0x83, 13 | 0xE4, 0xF3, 0x44, 0x00, 0x69, 0x74, 0x61, 0x6C, 0x6B, 0x74, 0x6F, 0x77, 14 | 0x61, 0x6C, 0x6C, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x75, 0x0E, 0xE5, 15 | 0x44, 0x00, 0x5A, 0x61, 0x63, 0x6B, 0x41, 0x74, 0x74, 0x61, 0x63, 0x6B, 16 | 0x48, 0x61, 0x6C, 0x6C, 0x00, 0x04, 0x00, 0x00, 0x00, 0x75, 0xB3, 0xCD, 17 | 0x44, 0x00, 0x53, 0x70, 0x78, 0x74, 0x72, 0x61, 0x00, 0x02, 0x00, 0x00, 18 | 0x00, 0x0A, 0xED, 0x74, 0x44, 0x00, 0x50, 0x6F, 0x72, 0x6B, 0x6E, 0x74, 19 | 0x61, 0x74, 0x65, 0x72, 0x73, 0x00, 0x01, 0x00, 0x00, 0x00, 0xBB, 0x2D, 20 | 0xC0, 0x43, 0x00, 0x53, 0x70, 0x68, 0x69, 0x6E, 0x78, 0x4E, 0x75, 0x6D, 21 | 0x62, 0x65, 0x72, 0x4E, 0x69, 0x6E, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 22 | 0x11, 0x91, 0xF7, 0x42} 23 | 24 | pinfo, err := parsePlayerInfo(data) 25 | if err != nil { 26 | t.Fatalf("Unexpected error when parsing players") 27 | } 28 | if len(pinfo) != 7 { 29 | t.Fatalf("Expected 7 players, got: %d", len(pinfo)) 30 | } 31 | playerstrings := []string{ 32 | "stumpy", "ZackAttackHall", "ShWiVeL", "Spxtra", "Porkntaters", 33 | "SphinxNumberNine", "italktowalls"} 34 | 35 | found, expected := 0, 7 36 | for _, p := range pinfo { 37 | for _, ps := range playerstrings { 38 | if strings.EqualFold(p.Name, ps) { 39 | found++ 40 | } 41 | } 42 | } 43 | if found != expected { 44 | t.Fatalf("%d players are missing.", expected-found) 45 | } 46 | } 47 | 48 | func TestGetDuration(t *testing.T) { 49 | durfloat, durstring := getDuration([]byte{0xEC, 0x37, 0x92, 0x45}) 50 | if durfloat != 4678.990234 { 51 | t.Fatalf("Expected duration floating point value to be 4678.990234, got: %f", 52 | durfloat) 53 | } 54 | if !strings.EqualFold(durstring, "1h17m58s") { 55 | t.Fatalf("Expected duration string to be 1h17m58s, got: %s", durstring) 56 | } 57 | durfloat, durstring = getDuration([]byte{0x11, 0x91, 0xF7, 0x42}) 58 | if durfloat != 123.783333 { 59 | t.Fatalf("Expected duration floating point value to be 4678.990234, got: %f", 60 | durfloat) 61 | } 62 | if !strings.EqualFold(durstring, "2m3s") { 63 | t.Fatalf("Expected duration string to be 2m3s, got: %s", durstring) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/steam/steamrules.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | // steamrules.go - steam server query for server information (A2S_RULES) 4 | 5 | import ( 6 | "bytes" 7 | "encoding/binary" 8 | "net" 9 | "sort" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/syncore/a2sapi/src/logger" 15 | ) 16 | 17 | func getRulesInfo(host string, timeout int) ([]byte, error) { 18 | conn, err := net.DialTimeout("udp", host, time.Duration(timeout)*time.Second) 19 | if err != nil { 20 | logger.LogSteamError(ErrHostConnection(err.Error())) 21 | return nil, ErrHostConnection(err.Error()) 22 | } 23 | 24 | conn.SetDeadline(time.Now().Add(time.Duration(timeout-1) * time.Second)) 25 | defer conn.Close() 26 | 27 | _, err = conn.Write(rulesChallengeReq) 28 | if err != nil { 29 | logger.LogSteamError(ErrDataTransmit(err.Error())) 30 | return nil, ErrDataTransmit(err.Error()) 31 | } 32 | 33 | challengeNumResp := make([]byte, maxPacketSize) 34 | _, err = conn.Read(challengeNumResp) 35 | if err != nil { 36 | logger.LogSteamError(ErrDataTransmit(err.Error())) 37 | return nil, ErrDataTransmit(err.Error()) 38 | } 39 | if !bytes.HasPrefix(challengeNumResp, expectedRulesRespHeader) { 40 | logger.LogSteamError(ErrChallengeResponse) 41 | return nil, ErrChallengeResponse 42 | } 43 | 44 | challengeNum := bytes.TrimLeft(challengeNumResp, headerStr) 45 | challengeNum = challengeNum[1:5] 46 | request := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x56} 47 | request = append(request, challengeNum...) 48 | 49 | _, err = conn.Write(request) 50 | if err != nil { 51 | logger.LogSteamError(ErrDataTransmit(err.Error())) 52 | return nil, ErrDataTransmit(err.Error()) 53 | } 54 | 55 | var buf [maxPacketSize]byte 56 | numread, err := conn.Read(buf[:maxPacketSize]) 57 | if err != nil { 58 | logger.LogSteamError(ErrDataTransmit(err.Error())) 59 | return nil, ErrDataTransmit(err.Error()) 60 | } 61 | var rulesInfo []byte 62 | if bytes.HasPrefix(buf[:maxPacketSize], multiPacketRespHeader) { 63 | // handle multi-packet response 64 | first := buf[:maxPacketSize] 65 | first = first[:numread] 66 | rulesInfo, err = handleMultiPacketResponse(conn, first) 67 | if err != nil { 68 | logger.LogSteamError(ErrDataTransmit(err.Error())) 69 | return nil, ErrDataTransmit(err.Error()) 70 | } 71 | } else { 72 | rulesInfo = make([]byte, numread) 73 | copy(rulesInfo, buf[:numread]) 74 | } 75 | return rulesInfo, nil 76 | } 77 | 78 | // Handle multi-packet responses for Source engine games. 79 | func handleMultiPacketResponse(c net.Conn, firstReceived []byte) ([]byte, 80 | error) { 81 | // header: 4 bytes, 0xFFFFFFFE (already verified in caller) 82 | // ID: 4 bytes, signed 83 | // total # of packets: 1 byte, unsigned 84 | // current packet #, starts at zero: 1 byte, unsigned 85 | // size: 2 bytes, only for Orange Box Engine and Newer, signed 86 | // size & CRC32 sum for bzip2 compressed packets; but no longer used since late 2005 87 | 88 | // first 4 bytes [0:4] determine if split; we've already determined that it is 89 | id := int32(binary.LittleEndian.Uint32(firstReceived[4:8])) 90 | total := uint32(firstReceived[8]) 91 | curNum := uint32(firstReceived[9]) 92 | // note: size won't exist for 4 ancient appids (215,17550,17700,240 w/protocol 7) 93 | //size := int16(binary.LittleEndian.Uint16(firstReceived[10:12])) 94 | packets := make(map[uint32][]byte, total) 95 | packets[0] = firstReceived[12:] 96 | var buf [maxPacketSize]byte 97 | prevNum := curNum 98 | for { 99 | if curNum+1 == total { 100 | break 101 | } 102 | numread, err := c.Read(buf[:maxPacketSize]) 103 | if err != nil { 104 | logger.LogSteamError(ErrMultiPacketTransmit(err.Error())) 105 | return nil, ErrMultiPacketTransmit(err.Error()) 106 | } 107 | packet := buf[:maxPacketSize] 108 | packet = packet[:numread] 109 | curNum = uint32(packet[9]) 110 | 111 | if prevNum == curNum { 112 | return nil, ErrMultiPacketDuplicate 113 | } 114 | prevNum = curNum 115 | 116 | if int32(binary.LittleEndian.Uint32(packet[4:8])) != id { 117 | logger.LogSteamError(ErrMultiPacketIDMismatch) 118 | return nil, ErrMultiPacketIDMismatch 119 | } 120 | if uint32(packet[9]) > total { 121 | logger.LogSteamError(ErrMultiPacketNumExceeded) 122 | return nil, ErrMultiPacketNumExceeded 123 | } 124 | // skip the header 125 | p := make([]byte, numread-12) 126 | copy(p, packet[12:]) 127 | packets[curNum] = p 128 | 129 | } 130 | // sort packet keys 131 | var pnums u32slice 132 | for key := range packets { 133 | pnums = append(pnums, key) 134 | } 135 | pnums.Sort() 136 | 137 | var rules []byte 138 | for _, pn := range pnums { 139 | rules = append(rules, packets[pn]...) 140 | } 141 | return rules, nil 142 | } 143 | 144 | func parseRuleInfo(ruleinfo []byte) (map[string]string, error) { 145 | if !bytes.HasPrefix(ruleinfo, expectedRuleChunkHeader) { 146 | logger.LogSteamError(ErrPacketHeader) 147 | return nil, ErrPacketHeader 148 | } 149 | 150 | ruleinfo = bytes.TrimLeft(ruleinfo, headerStr) 151 | numrules := int(binary.LittleEndian.Uint16(ruleinfo[1:3])) 152 | 153 | if numrules == 0 { 154 | return nil, ErrNoRules 155 | } 156 | 157 | b := bytes.Split(ruleinfo[3:], []byte{0x00}) 158 | m := make(map[string]string) 159 | 160 | var key string 161 | for i, y := range b { 162 | if i%2 != 1 { 163 | key = strings.TrimRight(string(y), "\x00") 164 | } else { 165 | m[key] = strings.TrimRight(string(b[i]), "\x00") 166 | } 167 | } 168 | 169 | return m, nil 170 | } 171 | 172 | // RetryFailedRulesReq retries a failed A2S_RULES request for a specified group of 173 | // failed hosts for a total of retrycount times, returning a host to A2S_RULES 174 | // mapping for any hosts that were successfully retried. 175 | func RetryFailedRulesReq(failed []string, 176 | retrycount int) map[string]map[string]string { 177 | 178 | m := make(map[string]map[string]string) 179 | var f []string 180 | var wg sync.WaitGroup 181 | var mut sync.Mutex 182 | for i := 0; i < retrycount; i++ { 183 | if i == 0 { 184 | f = failed 185 | } 186 | wg.Add(len(f)) 187 | for _, host := range f { 188 | go func(h string) { 189 | defer wg.Done() 190 | r, err := GetRulesForServer(h, QueryTimeout) 191 | if err != nil { 192 | if err != ErrNoRules { 193 | return 194 | } 195 | } 196 | mut.Lock() 197 | m[h] = r 198 | f = removeFailedHost(f, h) 199 | mut.Unlock() 200 | }(host) 201 | } 202 | wg.Wait() 203 | } 204 | return m 205 | } 206 | 207 | // GetRulesForServer requests A2S_RULES info for a given host within timeout seconds. 208 | func GetRulesForServer(host string, timeout int) (map[string]string, error) { 209 | // Caller will log. Return err instead of wrapped logger.LogSteamError so as not 210 | // to interfere with custom error types that need to be analyzed when 211 | // determining if retry needs to be done. 212 | ri, err := getRulesInfo(host, timeout) 213 | if err != nil { 214 | return nil, err 215 | } 216 | rules, err := parseRuleInfo(ri) 217 | if err != nil { 218 | return nil, err 219 | } 220 | return rules, nil 221 | } 222 | 223 | // u32slice attaches the methods of sort.Interface to []uint32, sorting in 224 | // increasing order. 225 | type u32slice []uint32 226 | 227 | func (s u32slice) Len() int { return len(s) } 228 | func (s u32slice) Less(i, j int) bool { return s[i] < s[j] } 229 | func (s u32slice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 230 | 231 | // Sort is a convenience method. 232 | func (s u32slice) Sort() { 233 | sort.Sort(s) 234 | } 235 | -------------------------------------------------------------------------------- /src/steam/steamrules_test.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestParseRuleInfo(t *testing.T) { 9 | data := []byte{ 10 | 0xFF, 0xFF, 0xFF, 0xFF, 0x45, 0x2A, 0x00, 0x63, 0x61, 0x70, 0x74, 0x75, 11 | 0x72, 0x65, 0x6C, 0x69, 0x6D, 0x69, 0x74, 0x00, 0x38, 0x00, 0x64, 0x6D, 12 | 0x66, 0x6C, 0x61, 0x67, 0x73, 0x00, 0x32, 0x38, 0x00, 0x66, 0x72, 0x61, 13 | 0x67, 0x6C, 0x69, 0x6D, 0x69, 0x74, 0x00, 0x35, 0x30, 0x00, 0x67, 0x5F, 14 | 0x61, 0x64, 0x43, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x53, 0x63, 0x6F, 15 | 0x72, 0x65, 0x42, 0x6F, 0x6E, 0x75, 0x73, 0x00, 0x33, 0x00, 0x67, 0x5F, 16 | 0x61, 0x64, 0x45, 0x6C, 0x69, 0x6D, 0x53, 0x63, 0x6F, 0x72, 0x65, 0x42, 17 | 0x6F, 0x6E, 0x75, 0x73, 0x00, 0x32, 0x00, 0x67, 0x5F, 0x61, 0x64, 0x54, 18 | 0x6F, 0x75, 0x63, 0x68, 0x53, 0x63, 0x6F, 0x72, 0x65, 0x42, 0x6F, 0x6E, 19 | 0x75, 0x73, 0x00, 0x31, 0x00, 0x67, 0x5F, 0x62, 0x6C, 0x75, 0x65, 0x53, 20 | 0x63, 0x6F, 0x72, 0x65, 0x00, 0x30, 0x00, 0x67, 0x5F, 0x63, 0x75, 0x73, 21 | 0x74, 0x6F, 0x6D, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6E, 0x67, 0x73, 0x00, 22 | 0x30, 0x00, 0x67, 0x5F, 0x66, 0x61, 0x63, 0x74, 0x6F, 0x72, 0x79, 0x00, 23 | 0x63, 0x61, 0x00, 0x67, 0x5F, 0x66, 0x61, 0x63, 0x74, 0x6F, 0x72, 0x79, 24 | 0x54, 0x69, 0x74, 0x6C, 0x65, 0x00, 0x43, 0x6C, 0x61, 0x6E, 0x20, 0x41, 25 | 0x72, 0x65, 0x6E, 0x61, 0x00, 0x67, 0x5F, 0x66, 0x72, 0x65, 0x65, 0x7A, 26 | 0x65, 0x52, 0x6F, 0x75, 0x6E, 0x64, 0x44, 0x65, 0x6C, 0x61, 0x79, 0x00, 27 | 0x34, 0x30, 0x30, 0x30, 0x00, 0x67, 0x5F, 0x67, 0x61, 0x6D, 0x65, 0x53, 28 | 0x74, 0x61, 0x74, 0x65, 0x00, 0x50, 0x52, 0x45, 0x5F, 0x47, 0x41, 0x4D, 29 | 0x45, 0x00, 0x67, 0x5F, 0x67, 0x61, 0x6D, 0x65, 0x74, 0x79, 0x70, 0x65, 30 | 0x00, 0x34, 0x00, 0x67, 0x5F, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x79, 31 | 0x00, 0x38, 0x30, 0x30, 0x00, 0x67, 0x5F, 0x69, 0x6E, 0x73, 0x74, 0x61, 32 | 0x47, 0x69, 0x62, 0x00, 0x30, 0x00, 0x67, 0x5F, 0x69, 0x74, 0x65, 0x6D, 33 | 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, 0x00, 0x33, 0x35, 0x00, 0x67, 0x5F, 34 | 0x69, 0x74, 0x65, 0x6D, 0x54, 0x69, 0x6D, 0x65, 0x72, 0x73, 0x00, 0x31, 35 | 0x00, 0x67, 0x5F, 0x6C, 0x65, 0x76, 0x65, 0x6C, 0x53, 0x74, 0x61, 0x72, 36 | 0x74, 0x54, 0x69, 0x6D, 0x65, 0x00, 0x31, 0x34, 0x35, 0x31, 0x39, 0x34, 37 | 0x38, 0x30, 0x34, 0x34, 0x00, 0x67, 0x5F, 0x6C, 0x6F, 0x61, 0x64, 0x6F, 38 | 0x75, 0x74, 0x00, 0x30, 0x00, 0x67, 0x5F, 0x6E, 0x65, 0x65, 0x64, 0x70, 39 | 0x61, 0x73, 0x73, 0x00, 0x30, 0x00, 0x67, 0x5F, 0x6F, 0x76, 0x65, 0x72, 40 | 0x74, 0x69, 0x6D, 0x65, 0x00, 0x30, 0x00, 0x67, 0x5F, 0x71, 0x75, 0x61, 41 | 0x64, 0x44, 0x61, 0x6D, 0x61, 0x67, 0x65, 0x46, 0x61, 0x63, 0x74, 0x6F, 42 | 0x72, 0x00, 0x33, 0x00, 0x67, 0x5F, 0x72, 0x65, 0x64, 0x53, 0x63, 0x6F, 43 | 0x72, 0x65, 0x00, 0x30, 0x00, 0x67, 0x5F, 0x72, 0x6F, 0x75, 0x6E, 0x64, 44 | 0x57, 0x61, 0x72, 0x6D, 0x75, 0x70, 0x44, 0x65, 0x6C, 0x61, 0x79, 0x00, 45 | 0x31, 0x30, 0x30, 0x30, 0x30, 0x00, 0x67, 0x5F, 0x73, 0x74, 0x61, 0x72, 46 | 0x74, 0x69, 0x6E, 0x67, 0x48, 0x65, 0x61, 0x6C, 0x74, 0x68, 0x00, 0x32, 47 | 0x30, 0x30, 0x00, 0x67, 0x5F, 0x74, 0x65, 0x61, 0x6D, 0x46, 0x6F, 0x72, 48 | 0x63, 0x65, 0x42, 0x61, 0x6C, 0x61, 0x6E, 0x63, 0x65, 0x00, 0x31, 0x00, 49 | 0x67, 0x5F, 0x74, 0x65, 0x61, 0x6D, 0x53, 0x69, 0x7A, 0x65, 0x4D, 0x69, 50 | 0x6E, 0x00, 0x31, 0x00, 0x67, 0x5F, 0x74, 0x69, 0x6D, 0x65, 0x6F, 0x75, 51 | 0x74, 0x43, 0x6F, 0x75, 0x6E, 0x74, 0x00, 0x30, 0x00, 0x67, 0x5F, 0x76, 52 | 0x6F, 0x74, 0x65, 0x46, 0x6C, 0x61, 0x67, 0x73, 0x00, 0x30, 0x00, 0x67, 53 | 0x5F, 0x77, 0x65, 0x61, 0x70, 0x6F, 0x6E, 0x52, 0x65, 0x73, 0x70, 0x61, 54 | 0x77, 0x6E, 0x00, 0x35, 0x00, 0x6D, 0x61, 0x70, 0x6E, 0x61, 0x6D, 0x65, 55 | 0x00, 0x6F, 0x76, 0x65, 0x72, 0x6B, 0x69, 0x6C, 0x6C, 0x00, 0x6D, 0x65, 56 | 0x72, 0x63, 0x79, 0x6C, 0x69, 0x6D, 0x69, 0x74, 0x00, 0x30, 0x00, 0x70, 57 | 0x72, 0x6F, 0x74, 0x6F, 0x63, 0x6F, 0x6C, 0x00, 0x39, 0x31, 0x00, 0x72, 58 | 0x6F, 0x75, 0x6E, 0x64, 0x6C, 0x69, 0x6D, 0x69, 0x74, 0x00, 0x31, 0x30, 59 | 0x00, 0x72, 0x6F, 0x75, 0x6E, 0x64, 0x74, 0x69, 0x6D, 0x65, 0x6C, 0x69, 60 | 0x6D, 0x69, 0x74, 0x00, 0x31, 0x38, 0x30, 0x00, 0x73, 0x63, 0x6F, 0x72, 61 | 0x65, 0x6C, 0x69, 0x6D, 0x69, 0x74, 0x00, 0x31, 0x35, 0x30, 0x00, 0x73, 62 | 0x76, 0x5F, 0x68, 0x6F, 0x73, 0x74, 0x6E, 0x61, 0x6D, 0x65, 0x00, 0x72, 63 | 0x61, 0x7A, 0x6F, 0x72, 0x2E, 0x73, 0x79, 0x6E, 0x63, 0x6F, 0x72, 0x65, 64 | 0x2E, 0x6F, 0x72, 0x67, 0x20, 0x7C, 0x20, 0x55, 0x53, 0x2D, 0x57, 0x65, 65 | 0x73, 0x74, 0x20, 0x23, 0x31, 0x20, 0x7C, 0x20, 0x43, 0x6F, 0x6D, 0x70, 66 | 0x65, 0x74, 0x69, 0x74, 0x69, 0x76, 0x65, 0x00, 0x73, 0x76, 0x5F, 0x6D, 67 | 0x61, 0x78, 0x63, 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x73, 0x00, 0x31, 0x36, 68 | 0x00, 0x73, 0x76, 0x5F, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x43, 69 | 0x6C, 0x69, 0x65, 0x6E, 0x74, 0x73, 0x00, 0x30, 0x00, 0x74, 0x65, 0x61, 70 | 0x6D, 0x73, 0x69, 0x7A, 0x65, 0x00, 0x34, 0x00, 0x74, 0x69, 0x6D, 0x65, 71 | 0x6C, 0x69, 0x6D, 0x69, 0x74, 0x00, 0x30, 0x00, 0x76, 0x65, 0x72, 0x73, 72 | 0x69, 0x6F, 0x6E, 0x00, 0x31, 0x30, 0x36, 0x36, 0x20, 0x6C, 0x69, 0x6E, 73 | 0x75, 0x78, 0x2D, 0x78, 0x36, 0x34, 0x20, 0x44, 0x65, 0x63, 0x20, 0x31, 74 | 0x37, 0x20, 0x32, 0x30, 0x31, 0x35, 0x20, 0x31, 0x35, 0x3A, 0x33, 0x36, 75 | 0x3A, 0x34, 0x39, 0x00} 76 | 77 | rules, err := parseRuleInfo(data) 78 | if err != nil { 79 | t.Fatalf("Unexpected error when parsing rule info") 80 | } 81 | if len(rules) != 42 { 82 | t.Fatalf("Expected 42 rules, got: %d", len(rules)) 83 | } 84 | if _, ok := rules["protocol"]; !ok { 85 | t.Fatalf("Expected protocol rule to exist") 86 | } 87 | if !strings.EqualFold(rules["protocol"], "91") { 88 | t.Fatalf("Expected protocol to be 91, got: %s", rules["protocol"]) 89 | } 90 | if _, ok := rules["g_factory"]; !ok { 91 | t.Fatalf("Expected g_factory rule to exist") 92 | } 93 | if !strings.EqualFold(rules["g_factory"], "ca") { 94 | t.Fatalf("Expected g_factory to be ca, got: %s", rules["g_factory"]) 95 | } 96 | if _, ok := rules["sv_hostname"]; !ok { 97 | t.Fatalf("Expected sv_hostname rule to exist") 98 | } 99 | if !strings.EqualFold(rules["sv_hostname"], 100 | "razor.syncore.org | US-West #1 | Competitive") { 101 | t.Fatalf( 102 | "Expected sv_hostname to be razor.syncore.org | US-West #1 | Competitive, got: %s", 103 | rules["sv_hostname"]) 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/steam/timedgrabber.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | // timedgrabber.go - Timed retrieval of servers from the Steam Master server. 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/syncore/a2sapi/src/config" 11 | "github.com/syncore/a2sapi/src/constants" 12 | "github.com/syncore/a2sapi/src/logger" 13 | "github.com/syncore/a2sapi/src/models" 14 | "github.com/syncore/a2sapi/src/steam/filters" 15 | "github.com/syncore/a2sapi/src/util" 16 | ) 17 | 18 | func retrieve(filter filters.Filter) (*models.APIServerList, error) { 19 | var mq MasterQuery 20 | var err error 21 | if config.Config.SteamConfig.UseWebServerList { 22 | mq, err = NewMasterWebQuery(filter) 23 | } else { 24 | mq, err = NewMasterQuery(filter) 25 | } 26 | 27 | if err != nil { 28 | return nil, logger.LogSteamErrorf("Master server error: %s", err) 29 | } 30 | 31 | if filter.Game.IgnoreInfo && filter.Game.IgnorePlayers && filter.Game.IgnoreRules { 32 | return nil, logger.LogAppErrorf("Cannot ignore all three AS2 requests!") 33 | } 34 | 35 | data := a2sData{} 36 | hg := make(map[string]filters.Game, len(mq.Servers)) 37 | for _, h := range mq.Servers { 38 | hg[h] = filter.Game 39 | } 40 | data.HostsGames = hg 41 | 42 | // Order of retrieval is by amount of work that must be done (generally 1, 2, 3) 43 | // 1. rules (request chal #, recv chal #, req rules, recv rules) 44 | // games with multi-packet A2S_RULES replies do the most work; otherwise 1 = 2, 3 45 | // 2. players (request chal #, recv chal #, req players, recv players) 46 | // 3. info: just request info & receive info 47 | // Note: some servers (i.e. new beta games) don't have all 3 of AS2_RULES/PLAYER/INFO 48 | if !filter.Game.IgnoreRules { 49 | data.Rules = batchRuleQuery(mq.Servers) 50 | } 51 | if !filter.Game.IgnorePlayers { 52 | data.Players = batchPlayerQuery(mq.Servers) 53 | } 54 | if !filter.Game.IgnoreInfo { 55 | data.Info = batchInfoQuery(mq.Servers) 56 | } 57 | 58 | serverlist, err := buildServerList(data, true) 59 | if err != nil { 60 | return nil, logger.LogAppError(err) 61 | } 62 | 63 | if config.Config.DebugConfig.EnableServerDump { 64 | if err := dumpServersToDisk(filter.Game.Name, serverlist); err != nil { 65 | logger.LogAppError(err) 66 | } 67 | } 68 | 69 | return serverlist, nil 70 | } 71 | 72 | func dumpServersToDisk(gamename string, sl *models.APIServerList) error { 73 | j, err := json.Marshal(sl) 74 | if err != nil { 75 | return logger.LogAppErrorf("Error marshaling json: %s", err) 76 | } 77 | t := time.Now() 78 | if err := util.CreateDirectory(constants.DumpDirectory); err != nil { 79 | return logger.LogAppErrorf("Couldn't create '%s' dir: %s\n", 80 | constants.DumpDirectory, err) 81 | } 82 | // Windows doesn't allow ":" in filename so use '-' separators for time 83 | err = util.CreateByteFile(j, constants.DumpFileFullPath( 84 | fmt.Sprintf("%s-servers-%d-%02d-%02d.%02d-%02d-%02d.json", 85 | gamename, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())), 86 | true) 87 | if err != nil { 88 | return logger.LogAppErrorf("Error creating server dump file: %s", err) 89 | } 90 | return nil 91 | } 92 | 93 | // StartMasterRetrieval starts a timed retrieval of servers specified by a given 94 | // filter from the Steam Master server after an initial delay of initialDelay 95 | // seconds. It retrieves the list every timeBetweenQueries seconds thereafter. 96 | // A bool can be sent to the stop channel to cancel all timed retrievals. 97 | func StartMasterRetrieval(stop chan bool, filter filters.Filter, 98 | initialDelay int, timeBetweenQueries int) { 99 | retrticker := time.NewTicker(time.Duration(timeBetweenQueries) * time.Second) 100 | 101 | logger.WriteDebug( 102 | "Waiting %d seconds before grabbing %s servers. Will retrieve servers every %d secs afterwards.", initialDelay, filter.Game.Name, timeBetweenQueries) 103 | 104 | logger.LogAppInfo( 105 | "Waiting %d seconds before grabbing %s servers from master. Will retrieve every %d secs afterwards.", initialDelay, filter.Game.Name, timeBetweenQueries) 106 | 107 | firstretrieval := time.NewTimer(time.Duration(initialDelay) * time.Second) 108 | <-firstretrieval.C 109 | logger.WriteDebug("Starting first retrieval of %s servers from master.", 110 | filter.Game.Name) 111 | sl, err := retrieve(filter) 112 | if err != nil { 113 | logger.LogAppErrorf("Error when performing timed master retrieval: %s", err) 114 | } 115 | models.MasterList = sl 116 | 117 | for { 118 | select { 119 | case <-retrticker.C: 120 | go func(filters.Filter) { 121 | logger.WriteDebug("%s: Starting %s master server query", time.Now().Format( 122 | "Mon Jan 2 15:04:05 2006 EST"), filter.Game.Name) 123 | logger.LogAppInfo("%s: Starting %s master server query", time.Now().Format( 124 | "Mon Jan 2 15:04:05 2006 EST"), filter.Game.Name) 125 | sl, err := retrieve(filter) 126 | if err != nil { 127 | logger.LogAppErrorf("Error when performing timed master retrieval: %s", 128 | err) 129 | } 130 | models.MasterList = sl 131 | }(filter) 132 | case <-stop: 133 | retrticker.Stop() 134 | return 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/test/test_funcs.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/syncore/a2sapi/src/config" 8 | "github.com/syncore/a2sapi/src/constants" 9 | ) 10 | 11 | // SetupEnvironment sets up the environment for tests. This should only be 12 | // called once per package and only in the first _test.go file of the package 13 | // that needs it. 14 | func SetupEnvironment() { 15 | fmt.Println("Setting up test environment...") 16 | // Need base directory for config and other files 17 | err := os.Chdir("../../bin") 18 | if err != nil { 19 | panic("Unable to change directory for tests") 20 | } 21 | // Remove old test files 22 | deleteFiles(constants.TestTempDirectory) 23 | 24 | // Use testing configuration 25 | config.CreateTestConfig() 26 | constants.IsTest = true 27 | 28 | // Dump is not in test directory and needs config access 29 | deleteFiles(constants.DumpFileFullPath( 30 | config.Config.DebugConfig.ServerDumpFilename)) 31 | } 32 | 33 | func deleteFiles(filepaths ...string) { 34 | fmt.Println("Running pre-test cleanup...") 35 | for _, fps := range filepaths { 36 | err := os.RemoveAll(fps) 37 | if err != nil { 38 | fmt.Printf("Error running test cleanup; unable to remove %s: %s", fps, err) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // util.go - Various convenience and utility functions 4 | 5 | import ( 6 | "bufio" 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | ) 11 | 12 | // FileExists returns true if the file of name exists; otherwise returns false. 13 | func FileExists(name string) bool { 14 | if _, err := os.Stat(name); err != nil { 15 | if os.IsNotExist(err) { 16 | return false 17 | } 18 | } 19 | return true 20 | } 21 | 22 | // DirExists returns true if the directory dir exists; otherwise returns false. 23 | func DirExists(dir string) bool { 24 | f, err := os.Stat(dir) 25 | return err == nil && f.IsDir() 26 | } 27 | 28 | // CreateDirectory attempts to create a directory with given dirname if it does 29 | // not already exist. If it already exists, then the function does nothing. 30 | func CreateDirectory(dirname string) error { 31 | if DirExists(dirname) { 32 | return nil 33 | } 34 | if err := os.Mkdir(dirname, os.ModePerm); err != nil { 35 | return err 36 | } 37 | return nil 38 | } 39 | 40 | // CreateEmptyFile wraps os.Create and creates and empty file with at fullpath if 41 | // it does not exist. If the file at fullpath exists, and allowOverwrite is true 42 | // then it will overwrite the existing file. 43 | func CreateEmptyFile(fullpath string, allowOverwrite bool) error { 44 | if FileExists(fullpath) && !allowOverwrite { 45 | return fmt.Errorf("File %s exists and allowOverwrite is false", fullpath) 46 | } 47 | f, err := os.Create(fullpath) 48 | if err != nil { 49 | return fmt.Errorf("Couldn't create empty file '%s': %s\n", fullpath, err) 50 | } 51 | defer f.Close() 52 | return nil 53 | } 54 | 55 | // CreateByteFile takes a slice of bytes & writes its contents to disk at 56 | // fullpath using an io.Writer if the file does not already exist. If the 57 | // file exists and allowOverwrite is true then it will overwrite the existing file. 58 | func CreateByteFile(data []byte, fullpath string, allowOverwrite bool) error { 59 | if FileExists(fullpath) && !allowOverwrite { 60 | return fmt.Errorf("File %s exists and allowOverwrite is false", fullpath) 61 | } 62 | f, err := os.Create(fullpath) 63 | if err != nil { 64 | return fmt.Errorf("Couldn't create '%s' file: %s\n", fullpath, err) 65 | } 66 | defer f.Close() 67 | w := bufio.NewWriter(f) 68 | _, err = w.Write(data) 69 | if err != nil { 70 | return fmt.Errorf("Error writing file to disk: %s\n", err) 71 | } 72 | f.Sync() 73 | w.Flush() 74 | return nil 75 | } 76 | 77 | // WriteJSONConfig takes an empty interface cfg, which is meant to be a 78 | // *config.Config struct or a *GameList and writes it as JSON to disk at 79 | // fullpath, overwriting any existing file. 80 | func WriteJSONConfig(cfg interface{}, directory, fullpath string) error { 81 | if err := CreateDirectory(directory); err != nil { 82 | return fmt.Errorf("Couldn't create '%s' dir: %s\n", directory, err) 83 | } 84 | c, err := json.Marshal(cfg) 85 | if err != nil { 86 | return fmt.Errorf("Error marshaling JSON for '%s' file: %s\n", fullpath, err) 87 | } 88 | err = CreateByteFile(c, fullpath, true) 89 | if err != nil { 90 | return fmt.Errorf(err.Error()) 91 | } 92 | fmt.Printf("Successfuly wrote config file to %s\n\n", fullpath) 93 | return nil 94 | } 95 | 96 | // ReadTillNul takes a slice of bytes b and reads up until it encounters the 97 | // first null terminator, returning the bytes read up until that point as a string. 98 | func ReadTillNul(b []byte) string { 99 | end := 0 100 | for i := 0; i < len(b); i++ { 101 | if b[i] == 0 { 102 | end = i 103 | break 104 | } 105 | } 106 | return string(b[:end]) 107 | } 108 | -------------------------------------------------------------------------------- /src/web/gzip.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // This is taken from The New York Times's gzip middleware, available at: 4 | // https://github.com/NYTimes/gziphandler 5 | 6 | import ( 7 | "compress/gzip" 8 | "fmt" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | ) 14 | 15 | const ( 16 | vary = "Vary" 17 | acceptEncoding = "Accept-Encoding" 18 | contentEncoding = "Content-Encoding" 19 | ) 20 | 21 | type codings map[string]float64 22 | 23 | // DefaultQValue is the default qvalue to assign to an encoding if no explicit 24 | // qvalue is set. This is actually kind of ambiguous in RFC 2616, so hopefully 25 | // it's correct. The examples seem to indicate that it is. 26 | const DefaultQValue = 1.0 27 | 28 | var gzipWriterPool = sync.Pool{ 29 | New: func() interface{} { return gzip.NewWriter(nil) }, 30 | } 31 | 32 | // GzipResponseWriter provides an http.ResponseWriter interface, which gzips 33 | // bytes before writing them to the underlying response. This doesn't set the 34 | // Content-Encoding header, nor close the writers, so don't forget to do that. 35 | type GzipResponseWriter struct { 36 | gw *gzip.Writer 37 | http.ResponseWriter 38 | } 39 | 40 | // Write appends data to the gzip writer. 41 | func (w GzipResponseWriter) Write(b []byte) (int, error) { 42 | return w.gw.Write(b) 43 | } 44 | 45 | // Flush flushes the underlying *gzip.Writer and then the underlying 46 | // http.ResponseWriter if it is an http.Flusher. This makes GzipResponseWriter 47 | // an http.Flusher. 48 | func (w GzipResponseWriter) Flush() { 49 | w.gw.Flush() 50 | if fw, ok := w.ResponseWriter.(http.Flusher); ok { 51 | fw.Flush() 52 | } 53 | } 54 | 55 | // GzipHandler wraps an HTTP handler, to transparently gzip the response body if 56 | // the client supports it (via the Accept-Encoding header). 57 | func GzipHandler(h http.Handler) http.Handler { 58 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 | w.Header().Add(vary, acceptEncoding) 60 | 61 | if acceptsGzip(r) { 62 | // Bytes written during ServeHTTP are redirected to this gzip writer 63 | // before being written to the underlying response. 64 | gzw := gzipWriterPool.Get().(*gzip.Writer) 65 | defer gzipWriterPool.Put(gzw) 66 | gzw.Reset(w) 67 | defer gzw.Close() 68 | 69 | w.Header().Set(contentEncoding, "gzip") 70 | h.ServeHTTP(GzipResponseWriter{gzw, w}, r) 71 | } else { 72 | h.ServeHTTP(w, r) 73 | } 74 | }) 75 | } 76 | 77 | // acceptsGzip returns true if the given HTTP request indicates that it will 78 | // accept a gzippped response. 79 | func acceptsGzip(r *http.Request) bool { 80 | acceptedEncodings, _ := parseEncodings(r.Header.Get(acceptEncoding)) 81 | return acceptedEncodings["gzip"] > 0.0 82 | } 83 | 84 | // parseEncodings attempts to parse a list of codings, per RFC 2616, as might 85 | // appear in an Accept-Encoding header. It returns a map of content-codings to 86 | // quality values, and an error containing the errors encounted. It's probably 87 | // safe to ignore those, because silently ignoring errors is how the internet 88 | // works. 89 | // 90 | // See: http://tools.ietf.org/html/rfc2616#section-14.3 91 | func parseEncodings(s string) (codings, error) { 92 | c := make(codings) 93 | e := make([]string, 0) 94 | 95 | for _, ss := range strings.Split(s, ",") { 96 | coding, qvalue, err := parseCoding(ss) 97 | 98 | if err != nil { 99 | e = append(e, err.Error()) 100 | 101 | } else { 102 | c[coding] = qvalue 103 | } 104 | } 105 | 106 | // TODO (adammck): Use a proper multi-error struct, so the individual errors 107 | // can be extracted if anyone cares. 108 | if len(e) > 0 { 109 | return c, fmt.Errorf("errors while parsing encodings: %s", strings.Join(e, ", ")) 110 | } 111 | 112 | return c, nil 113 | } 114 | 115 | // parseCoding parses a single conding (content-coding with an optional qvalue), 116 | // as might appear in an Accept-Encoding header. It attempts to forgive minor 117 | // formatting errors. 118 | func parseCoding(s string) (coding string, qvalue float64, err error) { 119 | for n, part := range strings.Split(s, ";") { 120 | part = strings.TrimSpace(part) 121 | qvalue = DefaultQValue 122 | 123 | if n == 0 { 124 | coding = strings.ToLower(part) 125 | 126 | } else if strings.HasPrefix(part, "q=") { 127 | qvalue, err = strconv.ParseFloat(strings.TrimPrefix(part, "q="), 64) 128 | 129 | if qvalue < 0.0 { 130 | qvalue = 0.0 131 | 132 | } else if qvalue > 1.0 { 133 | qvalue = 1.0 134 | } 135 | } 136 | } 137 | 138 | if coding == "" { 139 | err = fmt.Errorf("empty content-coding") 140 | } 141 | 142 | return 143 | } 144 | -------------------------------------------------------------------------------- /src/web/gzip_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // This is taken from The New York Times's gzip middleware, available at: 4 | // https://github.com/NYTimes/gziphandler 5 | 6 | import ( 7 | "bytes" 8 | "compress/gzip" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "net/http/httptest" 13 | "testing" 14 | 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestParseEncodings(t *testing.T) { 19 | 20 | examples := map[string]codings{ 21 | 22 | // Examples from RFC 2616 23 | "compress, gzip": codings{"compress": 1.0, "gzip": 1.0}, 24 | "": codings{}, 25 | "*": codings{"*": 1.0}, 26 | "compress;q=0.5, gzip;q=1.0": codings{"compress": 0.5, "gzip": 1.0}, 27 | "gzip;q=1.0, identity; q=0.5, *;q=0": codings{"gzip": 1.0, "identity": 0.5, "*": 0.0}, 28 | 29 | // More random stuff 30 | "AAA;q=1": codings{"aaa": 1.0}, 31 | "BBB ; q = 2": codings{"bbb": 1.0}, 32 | } 33 | 34 | for eg, exp := range examples { 35 | act, _ := parseEncodings(eg) 36 | assert.Equal(t, exp, act) 37 | } 38 | } 39 | 40 | func TestGzipHandler(t *testing.T) { 41 | testBody := "aaabbbccc" 42 | 43 | // This just exists to provide something for GzipHandler to wrap. 44 | handler := newTestHandler(testBody) 45 | 46 | // requests without accept-encoding are passed along as-is 47 | 48 | req1, _ := http.NewRequest("GET", "/whatever", nil) 49 | res1 := httptest.NewRecorder() 50 | handler.ServeHTTP(res1, req1) 51 | 52 | assert.Equal(t, 200, res1.Code) 53 | assert.Equal(t, "", res1.Header().Get("Content-Encoding")) 54 | assert.Equal(t, "Accept-Encoding", res1.Header().Get("Vary")) 55 | assert.Equal(t, testBody, res1.Body.String()) 56 | 57 | // but requests with accept-encoding:gzip are compressed if possible 58 | 59 | req2, _ := http.NewRequest("GET", "/whatever", nil) 60 | req2.Header.Set("Accept-Encoding", "gzip") 61 | res2 := httptest.NewRecorder() 62 | handler.ServeHTTP(res2, req2) 63 | 64 | assert.Equal(t, 200, res2.Code) 65 | assert.Equal(t, "gzip", res2.Header().Get("Content-Encoding")) 66 | assert.Equal(t, "Accept-Encoding", res2.Header().Get("Vary")) 67 | assert.Equal(t, gzipStr(testBody), res2.Body.Bytes()) 68 | } 69 | 70 | // -------------------------------------------------------------------- 71 | 72 | func BenchmarkGzipHandler_S2k(b *testing.B) { benchmark(b, false, 2048) } 73 | func BenchmarkGzipHandler_S20k(b *testing.B) { benchmark(b, false, 20480) } 74 | func BenchmarkGzipHandler_S100k(b *testing.B) { benchmark(b, false, 102400) } 75 | func BenchmarkGzipHandler_P2k(b *testing.B) { benchmark(b, true, 2048) } 76 | func BenchmarkGzipHandler_P20k(b *testing.B) { benchmark(b, true, 20480) } 77 | func BenchmarkGzipHandler_P100k(b *testing.B) { benchmark(b, true, 102400) } 78 | 79 | // -------------------------------------------------------------------- 80 | 81 | func gzipStr(s string) []byte { 82 | var b bytes.Buffer 83 | w := gzip.NewWriter(&b) 84 | io.WriteString(w, s) 85 | w.Close() 86 | return b.Bytes() 87 | } 88 | 89 | func benchmark(b *testing.B, parallel bool, size int) { 90 | bin, err := ioutil.ReadFile("testdata/benchmark.json") 91 | if err != nil { 92 | b.Fatal(err) 93 | } 94 | 95 | req, _ := http.NewRequest("GET", "/whatever", nil) 96 | req.Header.Set("Accept-Encoding", "gzip") 97 | handler := newTestHandler(string(bin[:size])) 98 | 99 | if parallel { 100 | b.ResetTimer() 101 | b.RunParallel(func(pb *testing.PB) { 102 | for pb.Next() { 103 | runBenchmark(b, req, handler) 104 | } 105 | }) 106 | } else { 107 | b.ResetTimer() 108 | for i := 0; i < b.N; i++ { 109 | runBenchmark(b, req, handler) 110 | } 111 | } 112 | } 113 | 114 | func runBenchmark(b *testing.B, req *http.Request, handler http.Handler) { 115 | res := httptest.NewRecorder() 116 | handler.ServeHTTP(res, req) 117 | if code := res.Code; code != 200 { 118 | b.Fatalf("Expected 200 but got %d", code) 119 | } else if blen := res.Body.Len(); blen < 500 { 120 | b.Fatalf("Expected complete response body, but got %d bytes", blen) 121 | } 122 | } 123 | 124 | func newTestHandler(body string) http.Handler { 125 | return GzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 126 | w.Header().Set("Content-Type", "text/plain") 127 | io.WriteString(w, body) 128 | })) 129 | } 130 | -------------------------------------------------------------------------------- /src/web/handlers.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // handlers.go - Handler functions for API 4 | 5 | import ( 6 | "bufio" 7 | "encoding/json" 8 | "fmt" 9 | "net" 10 | "net/http" 11 | "os" 12 | 13 | "github.com/syncore/a2sapi/src/config" 14 | "github.com/syncore/a2sapi/src/constants" 15 | "github.com/syncore/a2sapi/src/logger" 16 | "github.com/syncore/a2sapi/src/models" 17 | ) 18 | 19 | func compressGzip(hf http.HandlerFunc, shouldCompress bool) http.Handler { 20 | if !shouldCompress { 21 | return hf 22 | } 23 | return GzipHandler(hf) 24 | } 25 | 26 | func useDumpFileAsMasterList(dumppath string) *models.APIServerList { 27 | f, err := os.Open(dumppath) 28 | if err != nil { 29 | logger.LogAppErrorf("Unable to open test API server dump file: %s", err) 30 | return nil 31 | } 32 | defer f.Close() 33 | r := bufio.NewReader(f) 34 | d := json.NewDecoder(r) 35 | ml := &models.APIServerList{} 36 | if err := d.Decode(ml); err != nil { 37 | logger.LogAppErrorf("Unable to decode test API server dump as json: %s", err) 38 | return nil 39 | } 40 | return ml 41 | } 42 | 43 | func getServers(w http.ResponseWriter, r *http.Request) { 44 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 45 | var asl *models.APIServerList 46 | 47 | if config.Config.DebugConfig.ServerDumpFileAsMasterList { 48 | asl = useDumpFileAsMasterList(constants.DumpFileFullPath( 49 | config.Config.DebugConfig.ServerDumpFilename)) 50 | } else { 51 | asl = models.MasterList 52 | } 53 | // Empty (i.e. during first retrieval/startup) 54 | if asl == nil { 55 | writeJSONResponse(w, models.GetDefaultServerList()) 56 | return 57 | } 58 | srvfilters := getSrvFilterFromQString(r.URL.Query(), getServersQueryStrings) 59 | logger.WriteDebug("server list will be filtered with: %v", srvfilters) 60 | list := filterServers(srvfilters, asl) 61 | writeJSONResponse(w, list) 62 | } 63 | 64 | func getServerIDs(w http.ResponseWriter, r *http.Request) { 65 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 66 | 67 | hosts := getQStringValues(r.URL.Query(), qsGetServerIDs) 68 | for _, v := range hosts { 69 | logger.WriteDebug("host slice values: %s", v) 70 | // basically require at least 2 octets 71 | if len(v) < 4 { 72 | w.WriteHeader(http.StatusBadRequest) 73 | writeJSONResponse(w, models.GetDefaultServerID()) 74 | return 75 | } 76 | } 77 | getServerIDRetriever(w, hosts) 78 | } 79 | 80 | func queryServerIDs(w http.ResponseWriter, r *http.Request) { 81 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 82 | ids := getQStringValues(r.URL.Query(), qsQueryServerIDs) 83 | logger.WriteDebug("queryServerID: ids length: %d", len(ids)) 84 | logger.WriteDebug("queryServerID: ids are: %s", ids) 85 | 86 | if ids == nil { 87 | w.WriteHeader(http.StatusOK) 88 | logger.WriteDebug("queryServerID: Got empty query. Ignoring.") 89 | writeJSONResponse(w, models.GetDefaultServerList()) 90 | return 91 | } 92 | if len(ids) > config.Config.WebConfig.MaximumHostsPerAPIQuery { 93 | logger.WriteDebug("Maximum number of allowed API query hosts exceeded, truncating") 94 | ids = ids[:config.Config.WebConfig.MaximumHostsPerAPIQuery] 95 | } 96 | 97 | queryServerIDRetriever(w, ids) 98 | } 99 | 100 | func queryServerAddrs(w http.ResponseWriter, r *http.Request) { 101 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 102 | 103 | if !config.Config.WebConfig.AllowDirectUserQueries { 104 | w.WriteHeader(http.StatusBadRequest) 105 | fmt.Fprintf(w, 106 | `{"error": {"code": 400,"message": "Direct server queries are disabled. Use the %s parameter."}}`, 107 | qsQueryServerIDs) 108 | return 109 | } 110 | addresses := getQStringValues(r.URL.Query(), qsQueryServerAddrs) 111 | logger.WriteDebug("addresses length: %d", len(addresses)) 112 | logger.WriteDebug("addresses are: %s", addresses) 113 | 114 | if addresses == nil { 115 | w.WriteHeader(http.StatusOK) 116 | logger.WriteDebug("queryServerAddr: Got empty address query. Ignoring.") 117 | writeJSONResponse(w, models.GetDefaultServerList()) 118 | return 119 | } 120 | 121 | var parsedaddresses []string 122 | for _, addr := range addresses { 123 | host, err := net.ResolveTCPAddr("tcp4", addr) 124 | if err != nil { 125 | continue 126 | } 127 | parsedaddresses = append(parsedaddresses, fmt.Sprintf("%s:%d", host.IP, host.Port)) 128 | } 129 | 130 | if len(parsedaddresses) == 0 { 131 | w.WriteHeader(http.StatusOK) 132 | logger.WriteDebug("queryServerAddr: No valid addresses for query. Ignoring.") 133 | writeJSONResponse(w, models.GetDefaultServerList()) 134 | return 135 | } 136 | 137 | if len(parsedaddresses) > config.Config.WebConfig.MaximumHostsPerAPIQuery { 138 | logger.WriteDebug("Maximum number of allowed API query hosts exceeded, truncating") 139 | parsedaddresses = parsedaddresses[:config.Config.WebConfig.MaximumHostsPerAPIQuery] 140 | } 141 | queryServerAddrRetriever(w, parsedaddresses) 142 | } 143 | 144 | // writeJSONResponse encodes data as JSON and writes it to w; if unsuccessful, 145 | // the error will be logged and a generic error message will be displayed to the user. 146 | func writeJSONResponse(w http.ResponseWriter, data interface{}) { 147 | if err := json.NewEncoder(w).Encode(data); err != nil { 148 | writeJSONEncodeError(w, err) 149 | } 150 | } 151 | 152 | // setNotFoundAndLog sets the error code of the underlying writer to 404 (not found) 153 | // and internally logs the error. 154 | func setNotFoundAndLog(w http.ResponseWriter, err error) { 155 | w.WriteHeader(http.StatusNotFound) 156 | logger.LogWebError(err) 157 | } 158 | 159 | // writeJSONEncodeError displays a generic error message, returns an error code 160 | // of 404 not found, and logs an error related to unsuccessful JSON encoding. 161 | func writeJSONEncodeError(w http.ResponseWriter, err error) { 162 | setNotFoundAndLog(w, err) 163 | fmt.Fprintf(w, `{"error": {"code": 400,"message": "JSON encoding error."}}`) 164 | } 165 | -------------------------------------------------------------------------------- /src/web/handlers_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // Tests for handler functions for API 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "reflect" 11 | "testing" 12 | "time" 13 | 14 | "github.com/syncore/a2sapi/src/config" 15 | "github.com/syncore/a2sapi/src/constants" 16 | "github.com/syncore/a2sapi/src/db" 17 | "github.com/syncore/a2sapi/src/models" 18 | "github.com/syncore/a2sapi/src/test" 19 | "github.com/syncore/a2sapi/src/util" 20 | 21 | "github.com/gorilla/mux" 22 | ) 23 | 24 | var testURLBase string 25 | 26 | // ResponseRecorder is an extension of httptest.ResponseRecoder which is an 27 | // implementation of http.ResponseWriter that records its mutations for later 28 | // inspection in tests. Credit to ome on freenode #go-nuts. 29 | type ResponseRecoder struct { 30 | *httptest.ResponseRecorder 31 | } 32 | 33 | func init() { 34 | test.SetupEnvironment() 35 | testURLBase = fmt.Sprintf("http://:%d", config.Config.WebConfig.APIWebPort) 36 | db.InitDBs() 37 | 38 | // create dump server file 39 | err := util.CreateDirectory(constants.DumpDirectory) 40 | if err != nil { 41 | panic("Unable to create dump directory used in tests") 42 | } 43 | err = util.CreateByteFile(constants.TestServerDumpJSON, constants.DumpFileFullPath( 44 | config.Config.DebugConfig.ServerDumpFilename), true) 45 | if err != nil { 46 | panic(fmt.Sprintf("Test dump file creation error: %s", err)) 47 | } 48 | 49 | // launch server 50 | go func() { 51 | r := mux.NewRouter().StrictSlash(true) 52 | for _, ar := range apiRoutes { 53 | var handler http.Handler 54 | handler = compressGzip(ar.handlerFunc, config.Config.WebConfig.CompressResponses) 55 | 56 | r.Methods(ar.method). 57 | MatcherFunc(pathQStrToLowerMatcherFunc(r, ar.path, ar.queryStrings, 58 | getRequiredQryStringCount(ar.queryStrings))). 59 | Name(ar.name). 60 | Handler(http.TimeoutHandler(handler, 61 | time.Duration(config.Config.WebConfig.APIWebTimeout)*time.Second, 62 | `{"error":"Timeout"}`)) 63 | } 64 | err := http.ListenAndServe(fmt.Sprintf(":%d", config.Config.WebConfig.APIWebPort), r) 65 | if err != nil { 66 | panic("Unable to start web server") 67 | } 68 | }() 69 | } 70 | 71 | func formatURL(path string) string { 72 | return fmt.Sprintf("%s/%s", testURLBase, path) 73 | } 74 | 75 | // newRecorder returns an initialized ResponseRecorder, it's compatiable with 76 | //the official httptest.ResponseRecoder by embedding it. 77 | func newRecorder() *ResponseRecoder { 78 | return &ResponseRecoder{httptest.NewRecorder()} 79 | } 80 | 81 | // ExpectJSON checks if decoding the body to `model` will match the `expect` 82 | // object. 83 | func (r *ResponseRecoder) ExpectJSON(model, expect interface{}) ([]byte, bool) { 84 | mt := reflect.TypeOf(model) 85 | me := reflect.TypeOf(expect) 86 | if me != mt { 87 | return nil, false 88 | } 89 | err := json.Unmarshal(r.Body.Bytes(), model) 90 | return r.Body.Bytes(), err == nil && reflect.DeepEqual(model, expect) 91 | } 92 | 93 | // TestGetServers tests the GetServers HTTP handler 94 | func TestGetServers(t *testing.T) { 95 | r, _ := http.NewRequest("GET", formatURL("servers"), nil) 96 | w := newRecorder() 97 | getServers(w, r) 98 | // body json test 99 | m := &models.APIServerList{} 100 | _, modelMatches := w.ExpectJSON(m, m) 101 | if !modelMatches { 102 | t.Errorf("getServers: expected and actual models do not match.") 103 | } 104 | if w.Code != http.StatusOK { 105 | t.Errorf("Expected status code: %v for GetServers handler; got: %v", 106 | http.StatusOK, w.Code) 107 | } 108 | if len(w.Body.Bytes()) == 0 { 109 | t.Errorf("Response body should not be empty") 110 | } 111 | } 112 | 113 | // TestGetServerID tests the GetServerID HTTP handler 114 | func TestGetServerIDs(t *testing.T) { 115 | r, _ := http.NewRequest("GET", formatURL("serverIDs?hosts=127.0.0.1:65534"), 116 | nil) 117 | w := newRecorder() 118 | getServerIDs(w, r) 119 | // body json test 120 | m := &models.DbServerID{} 121 | _, modelMatches := w.ExpectJSON(m, m) 122 | if !modelMatches { 123 | t.Errorf("getServerID: expected and actual models do not match.") 124 | } 125 | if w.Code != http.StatusOK { 126 | t.Errorf("Expected status code: %v for GetServerID handler; got: %v", 127 | http.StatusOK, w.Code) 128 | } 129 | if len(w.Body.Bytes()) == 0 { 130 | t.Errorf("GetServerID handler body should not be empty") 131 | } 132 | } 133 | 134 | // TestQueryServerID tests the QueryServerID HTTP handler 135 | func TestQueryServerID(t *testing.T) { 136 | r, _ := http.NewRequest("GET", formatURL("query?ids=788593993848"), 137 | nil) 138 | w := newRecorder() 139 | queryServerIDs(w, r) 140 | // body json test 141 | m := &models.APIServerList{} 142 | _, modelMatches := w.ExpectJSON(m, m) 143 | if !modelMatches { 144 | t.Errorf("queryServerID: expected and actual models do not match.") 145 | } 146 | if w.Code != http.StatusOK { 147 | t.Errorf("Expected status code %v for queryServerID handler; got: %v", 148 | http.StatusOK, w.Code) 149 | } 150 | if len(w.Body.Bytes()) == 0 { 151 | t.Errorf("queryServerID handler body should not be empty") 152 | } 153 | } 154 | 155 | // TestQueryServerAddr tests the QueryServerAddr handler 156 | func TestQueryServerAddr(t *testing.T) { 157 | r1, _ := http.NewRequest("GET", 158 | formatURL("query?hosts=127.0.0.1:65534"), nil) 159 | w1 := newRecorder() 160 | queryServerAddrs(w1, r1) 161 | // 200 - default server list 162 | if w1.Code != http.StatusOK { 163 | t.Errorf("Expected status code %v for queryServerAddr handler; got: %v", 164 | http.StatusOK, w1.Code) 165 | } 166 | if len(w1.Body.Bytes()) == 0 { 167 | t.Errorf("queryServerAddr handler body should not be empty") 168 | } 169 | // body 1 json test 170 | m1 := &models.APIServerList{} 171 | _, modelMatches := w1.ExpectJSON(m1, m1) 172 | if !modelMatches { 173 | t.Errorf("queryServerAddr: expected and actual models do not match.") 174 | } 175 | // no address specified 176 | r2, _ := http.NewRequest("GET", formatURL("query?hosts="), nil) 177 | w2 := newRecorder() 178 | queryServerAddrs(w2, r2) 179 | // body 2 json test 180 | m2 := &models.APIServerList{} 181 | _, modelMatches2 := w2.ExpectJSON(m2, m2) 182 | if !modelMatches2 { 183 | t.Errorf("queryServerAddr: expected and actual models do not match.") 184 | } 185 | if w2.Code != http.StatusOK { 186 | t.Errorf("Expected status code %v for queryServerAddr handler; got: %v", 187 | http.StatusOK, w2.Code) 188 | } 189 | if len(w2.Body.Bytes()) == 0 { 190 | t.Errorf("queryServerAddr handler body should not be empty") 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/web/querystring.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import "strings" 4 | 5 | // querystring.go - URL query string definitions and helper functions 6 | 7 | type querystring struct { 8 | name string 9 | boolonly bool 10 | required bool 11 | } 12 | 13 | type slQueryFilter struct { 14 | name string 15 | needsbool bool 16 | values []string 17 | } 18 | 19 | // query string names 20 | const ( 21 | // serverIDs: 22 | // ?hosts= 23 | qsGetServerIDs = "hosts" 24 | 25 | // query - based on IDs: 26 | // ?ids= 27 | qsQueryServerIDs = "ids" 28 | 29 | // /query - based on hosts: 30 | // ?hosts 31 | qsQueryServerAddrs = "hosts" 32 | 33 | // getServers: 34 | // ?country= 35 | qsGetServersCountry = "countries" 36 | // ?region= 37 | qsGetServersRegion = "regions" 38 | // ?state= 39 | qsGetServersState = "states" 40 | // info filtering 41 | // ?serverName= 42 | qsGetServersName = "serverNames" 43 | // ?map= 44 | qsGetServersMap = "maps" 45 | // ?game= 46 | qsGetServersGame = "games" 47 | // gametype= 48 | qsGetServersGameType = "gametypes" 49 | // ?serverType= 50 | qsGetServersType = "serverTypes" 51 | // ?serverOS= 52 | qsGetServersOS = "serverOS" 53 | // ?serverVersion= 54 | qsGetServersVersion = "serverVersions" 55 | // ?serverKeywords= 56 | qsGetServersKeywords = "serverKeywords" 57 | // ?hasPlayers= (bool) 58 | qsGetServersHasPlayers = "hasPlayers" 59 | // ?hasBots= (bool) 60 | qsGetServersHasBots = "hasBots" 61 | // ?hasPassword= (bool) 62 | qsGetServersHasPassword = "hasPassword" 63 | // ?hasAntiCheat= (bool) 64 | qsGetServersHasAntiCheat = "hasAntiCheat" 65 | // ?isNotFull= (bool) 66 | qsGetServersIsNotFull = "isNotFull" 67 | ) 68 | 69 | // getServerIDs query strings 70 | var getServerIDsQueryStrings = []querystring{ 71 | querystring{ 72 | name: qsGetServerIDs, 73 | required: true, 74 | }, 75 | } 76 | 77 | // queryServerID query strings 78 | var queryServerIDQueryStrings = []querystring{ 79 | querystring{ 80 | name: qsQueryServerIDs, 81 | required: true, 82 | }, 83 | } 84 | 85 | // queryServerAddr query strings 86 | var queryServerAddrQueryStrings = []querystring{ 87 | querystring{ 88 | name: qsQueryServerAddrs, 89 | required: true, 90 | }, 91 | } 92 | 93 | // getServers query strings 94 | var getServersQueryStrings = []querystring{ 95 | querystring{ 96 | name: qsGetServersCountry, 97 | }, 98 | querystring{ 99 | name: qsGetServersRegion, 100 | }, 101 | querystring{ 102 | name: qsGetServersState, 103 | }, 104 | querystring{ 105 | name: qsGetServersName, 106 | }, 107 | querystring{ 108 | name: qsGetServersMap, 109 | }, 110 | querystring{ 111 | name: qsGetServersGame, 112 | }, 113 | querystring{ 114 | name: qsGetServersGameType, 115 | }, 116 | querystring{ 117 | name: qsGetServersType, 118 | }, 119 | querystring{ 120 | name: qsGetServersOS, 121 | }, 122 | querystring{ 123 | name: qsGetServersVersion, 124 | }, 125 | querystring{ 126 | name: qsGetServersKeywords, 127 | }, 128 | querystring{ 129 | name: qsGetServersHasPlayers, 130 | boolonly: true, 131 | }, 132 | querystring{ 133 | name: qsGetServersHasBots, 134 | boolonly: true, 135 | }, 136 | querystring{ 137 | name: qsGetServersHasPassword, 138 | boolonly: true, 139 | }, 140 | querystring{ 141 | name: qsGetServersHasAntiCheat, 142 | boolonly: true, 143 | }, 144 | querystring{ 145 | name: qsGetServersIsNotFull, 146 | boolonly: true, 147 | }, 148 | } 149 | 150 | // getQStringValues takes the map returned by a *http.Request URL.Query(), 151 | // extracts and returns the values of a key defined in that map which is 152 | // specified as a known querystring value to match. 153 | func getQStringValues(m map[string][]string, querystring string) []string { 154 | var vals []string 155 | for k := range m { 156 | if strings.EqualFold(k, querystring) { 157 | vals = strings.Split(m[k][0], ",") 158 | break 159 | } 160 | } 161 | if vals == nil { 162 | return nil 163 | } 164 | // case where there's no value after query string (i.e: ?querystring=) 165 | if vals[0] == "" { 166 | return nil 167 | } 168 | return vals 169 | } 170 | -------------------------------------------------------------------------------- /src/web/querystring_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // Tests for query strings 4 | 5 | import ( 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | // TestGetQStringValues tests the getQStringValues query string extraction 11 | // function. 12 | func TestGetQStringValues(t *testing.T) { 13 | getsrvid := make(map[string][]string, 1) 14 | getsrvid["hosts"] = []string{ 15 | "127.0.0.1,172.16.0.1,%2010.0.0.1", 16 | } 17 | result := getQStringValues(getsrvid, qsGetServerIDs) 18 | if len(result) != 3 { 19 | t.Fatalf("Expected 3 address strings in result, got: %d", len(result)) 20 | } 21 | if !strings.EqualFold(result[0], "127.0.0.1") { 22 | t.Fatalf("Expected address to be: %s, got: %s", 23 | getsrvid["address"][0], result[0]) 24 | } 25 | if !strings.EqualFold(result[1], "172.16.0.1") { 26 | t.Fatalf("Expected address to be: %s, got: %s", 27 | getsrvid["address"][0], result[1]) 28 | } 29 | if !strings.EqualFold(result[2], "%2010.0.0.1") { 30 | t.Fatalf("Expected address to be: %s, got: %s", 31 | getsrvid["address"][0], result[2]) 32 | } 33 | 34 | getsrvcountry := make(map[string][]string, 1) 35 | getsrvcountry["countries"] = []string{"US"} 36 | result = getQStringValues(getsrvcountry, qsGetServersCountry) 37 | if len(result) != 1 { 38 | t.Fatalf("Expected 1 country string in result, got: %d", len(result)) 39 | } 40 | if !strings.EqualFold(result[0], "us") { 41 | t.Fatalf("Expected country to be: %s, got: %s", 42 | getsrvcountry["country"][0], result[1]) 43 | } 44 | empty := make(map[string][]string, 1) 45 | empty["abc"] = []string{""} 46 | result = getQStringValues(empty, "abc") 47 | if result != nil { 48 | t.Fatal("Expected nil for result") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/web/retrievers.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // retrievers.go - Bridge between http requests and database (and potentially other) layers 4 | 5 | import ( 6 | "encoding/json" 7 | "net/http" 8 | 9 | "github.com/syncore/a2sapi/src/db" 10 | "github.com/syncore/a2sapi/src/logger" 11 | "github.com/syncore/a2sapi/src/models" 12 | "github.com/syncore/a2sapi/src/steam" 13 | ) 14 | 15 | func getServerIDRetriever(w http.ResponseWriter, hosts []string) { 16 | m := make(chan *models.DbServerID, 1) 17 | go db.ServerDB.GetIDsAPIQuery(m, hosts) 18 | ids := <-m 19 | if len(ids.Servers) > 0 { 20 | if err := json.NewEncoder(w).Encode(ids); err != nil { 21 | writeJSONEncodeError(w, err) 22 | return 23 | } 24 | } else { 25 | w.WriteHeader(http.StatusOK) 26 | if err := json.NewEncoder(w).Encode(models.GetDefaultServerID()); err != nil { 27 | writeJSONEncodeError(w, err) 28 | return 29 | } 30 | } 31 | } 32 | 33 | func queryServerIDRetriever(w http.ResponseWriter, ids []string) { 34 | s := make(chan map[string]string, len(ids)) 35 | db.ServerDB.GetHostsAndGameFromIDAPIQuery(s, ids) 36 | hostsgames := <-s 37 | if len(hostsgames) == 0 { 38 | w.WriteHeader(http.StatusOK) 39 | if err := json.NewEncoder(w).Encode(models.GetDefaultServerList()); err != nil { 40 | writeJSONEncodeError(w, err) 41 | } 42 | return 43 | } 44 | serverlist, err := steam.Query(hostsgames) 45 | if err != nil { 46 | setNotFoundAndLog(w, err) 47 | if err := json.NewEncoder(w).Encode(models.GetDefaultServerList()); err != nil { 48 | writeJSONEncodeError(w, err) 49 | return 50 | } 51 | return 52 | } 53 | if err := json.NewEncoder(w).Encode(serverlist); err != nil { 54 | writeJSONEncodeError(w, err) 55 | logger.LogWebError(err) 56 | } 57 | } 58 | 59 | func queryServerAddrRetriever(w http.ResponseWriter, addresses []string) { 60 | serverlist, err := steam.DirectQuery(addresses) 61 | if err != nil { 62 | setNotFoundAndLog(w, err) 63 | if err := json.NewEncoder(w).Encode(models.GetDefaultServerList()); err != nil { 64 | writeJSONEncodeError(w, err) 65 | return 66 | } 67 | return 68 | } 69 | if err := json.NewEncoder(w).Encode(serverlist); err != nil { 70 | writeJSONEncodeError(w, err) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/web/router.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // router.go - request router 4 | 5 | import ( 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/syncore/a2sapi/src/config" 11 | 12 | "github.com/syncore/a2sapi/src/logger" 13 | 14 | "github.com/gorilla/mux" 15 | ) 16 | 17 | func newRouter() *mux.Router { 18 | r := mux.NewRouter().StrictSlash(true) 19 | for _, ar := range apiRoutes { 20 | handler := http.TimeoutHandler(compressGzip(ar.handlerFunc, config.Config.WebConfig.CompressResponses), 21 | time.Duration(config.Config.WebConfig.APIWebTimeout)*time.Second, 22 | `{"error": {"code": 503,"message": "Request timeout."}}`) 23 | handler = logger.LogWebRequest(handler, ar.name) 24 | 25 | r.Methods(ar.method). 26 | MatcherFunc(pathQStrToLowerMatcherFunc(r, ar.path, ar.queryStrings, 27 | getRequiredQryStringCount(ar.queryStrings))). 28 | Name(ar.name). 29 | Handler(handler) 30 | } 31 | return r 32 | } 33 | 34 | // Provide case-insensitive matching for URL paths and query strings 35 | func pathQStrToLowerMatcherFunc(router *mux.Router, 36 | routepath string, querystrings []querystring, 37 | requiredQsCount int) func(req *http.Request, 38 | rt *mux.RouteMatch) bool { 39 | return func(req *http.Request, rt *mux.RouteMatch) bool { 40 | pathok, qstrok := false, false 41 | // case-insensitive paths 42 | if strings.HasPrefix(strings.ToLower(req.URL.Path), strings.ToLower(routepath)) { 43 | logger.WriteDebug("PATH: %s matches route path: %s", req.URL.Path, routepath) 44 | pathok = true 45 | } 46 | //case-insensitive query strings 47 | // not all API routes will make use of query strings 48 | if len(querystrings) == 0 { 49 | qstrok = true 50 | } else { 51 | qry := req.URL.Query() 52 | truecount := 0 53 | for key := range qry { 54 | logger.WriteDebug("URL query string key is: %s", key) 55 | for _, qs := range querystrings { 56 | if strings.EqualFold(key, qs.name) && qs.required { 57 | logger.WriteDebug("KEY: %s matches query string: %s", key, qs.name) 58 | truecount++ 59 | break 60 | } 61 | } 62 | } 63 | if truecount == requiredQsCount { 64 | qstrok = true 65 | } 66 | } 67 | return pathok && qstrok 68 | } 69 | } 70 | 71 | func getRequiredQryStringCount(querystrings []querystring) int { 72 | reqcount := 0 73 | for _, q := range querystrings { 74 | if q.required { 75 | reqcount++ 76 | } 77 | } 78 | return reqcount 79 | } 80 | -------------------------------------------------------------------------------- /src/web/routes.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // routes.go - http routes for API 4 | 5 | import "net/http" 6 | 7 | type route struct { 8 | name string 9 | method string 10 | path string 11 | queryStrings []querystring 12 | handlerFunc http.HandlerFunc 13 | } 14 | 15 | var apiRoutes = []route{ 16 | // servers 17 | route{ 18 | name: "GetServers", 19 | method: "GET", 20 | path: "/servers", 21 | queryStrings: getServersQueryStrings, 22 | handlerFunc: getServers, 23 | }, 24 | // serverID 25 | route{ 26 | name: "GetServerIDs", 27 | method: "GET", 28 | path: "/serverIDs", 29 | queryStrings: getServerIDsQueryStrings, 30 | handlerFunc: getServerIDs, 31 | }, 32 | // query - by ID 33 | route{ 34 | name: "QueryServerID", 35 | method: "GET", 36 | path: "/query", 37 | queryStrings: queryServerIDQueryStrings, 38 | handlerFunc: queryServerIDs, 39 | }, 40 | // query - by address 41 | route{ 42 | name: "QueryServerAddr", 43 | method: "GET", 44 | path: "/query", 45 | queryStrings: queryServerAddrQueryStrings, 46 | handlerFunc: queryServerAddrs, 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /src/web/server.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // server.go - Web server for API 4 | 5 | import ( 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/syncore/a2sapi/src/config" 11 | "github.com/syncore/a2sapi/src/logger" 12 | ) 13 | 14 | // Start listening for and responding to HTTP requests via the web server. Panics 15 | // if unable to start. 16 | func Start(runSilent bool) { 17 | r := newRouter() 18 | 19 | if !runSilent { 20 | printStartInfo() 21 | } 22 | 23 | logger.LogAppInfo("Starting HTTP server on port %d", 24 | config.Config.WebConfig.APIWebPort) 25 | 26 | srv := http.Server{ 27 | Addr: fmt.Sprintf(":%d", config.Config.WebConfig.APIWebPort), 28 | Handler: r, 29 | ReadTimeout: 30 * time.Second, 30 | WriteTimeout: 30 * time.Second, 31 | MaxHeaderBytes: 1 << 20} 32 | 33 | err := srv.ListenAndServe() 34 | if err != nil { 35 | logger.LogAppError(err) 36 | panic(fmt.Sprintf("Unable to start HTTP server, error: %s\n", err)) 37 | } 38 | } 39 | 40 | func printStartInfo() { 41 | endpoints := make([]string, len(apiRoutes)) 42 | for _, e := range apiRoutes { 43 | endpoints = append(endpoints, fmt.Sprintf("%s ", e.path)) 44 | } 45 | fmt.Printf("Starting HTTP server on port %d\n", config.Config.WebConfig.APIWebPort) 46 | fmt.Printf("Available endpoints: %s\n", endpoints) 47 | 48 | if config.Config.WebConfig.AllowDirectUserQueries { 49 | fmt.Println("Direct (non-ID based) server API queries: enabled") 50 | } else { 51 | fmt.Println("Direct (non-ID based) server API queries: disabled") 52 | } 53 | 54 | fmt.Printf("HTTP request timeout: %d seconds\n", 55 | config.Config.WebConfig.APIWebTimeout) 56 | fmt.Printf("Maximum servers allowed per user API call: %d servers\n", 57 | config.Config.WebConfig.MaximumHostsPerAPIQuery) 58 | } 59 | -------------------------------------------------------------------------------- /src/web/serverfilter.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // serverfilter.go - operations for filtering the server list based on query 4 | // string data. 5 | 6 | import ( 7 | "strings" 8 | 9 | "github.com/syncore/a2sapi/src/models" 10 | ) 11 | 12 | // getSrvFilterFromQString determines whether a query string has values and 13 | // builds a filter that will be used on the server list. 14 | func getSrvFilterFromQString(m map[string][]string, qs []querystring) []slQueryFilter { 15 | var qfilters []slQueryFilter 16 | for key := range m { 17 | for _, q := range qs { 18 | if strings.EqualFold(key, q.name) { 19 | vals := getQStringValues(m, key) 20 | if len(vals) > 0 { 21 | qfilters = append(qfilters, slQueryFilter{name: q.name, 22 | needsbool: q.boolonly, values: vals}) 23 | } 24 | } 25 | } 26 | } 27 | return qfilters 28 | } 29 | 30 | func findMatches(sqf slQueryFilter, 31 | servers []models.APIServer) []models.APIServer { 32 | var matched []models.APIServer 33 | var ssearch string 34 | bsearcht, bsearchf, useContains := false, false, false 35 | 36 | for _, srv := range servers { 37 | switch sqf.name { 38 | // location-based 39 | case qsGetServersRegion: 40 | ssearch = srv.CountryInfo.Continent 41 | case qsGetServersCountry: 42 | ssearch = srv.CountryInfo.CountryCode 43 | case qsGetServersState: 44 | ssearch = srv.CountryInfo.State 45 | // info-based 46 | case qsGetServersName: 47 | useContains = true 48 | ssearch = srv.Info.Name 49 | case qsGetServersMap: 50 | useContains = true 51 | ssearch = srv.Info.Map 52 | case qsGetServersGame: 53 | ssearch = srv.Info.Game 54 | case qsGetServersGameType: 55 | ssearch = srv.Info.GameTypeShort 56 | case qsGetServersType: 57 | ssearch = srv.Info.ServerType 58 | case qsGetServersOS: 59 | ssearch = srv.Info.Environment 60 | case qsGetServersVersion: 61 | ssearch = srv.Info.Version 62 | case qsGetServersKeywords: 63 | useContains = true 64 | ssearch = srv.Info.ExtraData.Keywords 65 | case qsGetServersIsNotFull: 66 | if strings.EqualFold(sqf.values[0], "true") { 67 | bsearcht = srv.Info.Players != srv.Info.MaxPlayers 68 | } else { 69 | bsearchf = srv.Info.Players <= srv.Info.MaxPlayers 70 | } 71 | case qsGetServersHasPlayers: 72 | if strings.EqualFold(sqf.values[0], "true") { 73 | bsearcht = srv.Info.Players > 0 74 | } else { 75 | bsearchf = srv.Info.Players == 0 76 | } 77 | case qsGetServersHasBots: 78 | if strings.EqualFold(sqf.values[0], "true") { 79 | bsearcht = srv.Info.Bots > 0 80 | } else { 81 | bsearchf = srv.Info.Bots == 0 82 | } 83 | case qsGetServersHasPassword: 84 | if strings.EqualFold(sqf.values[0], "true") { 85 | bsearcht = srv.Info.Visibility == 1 86 | } else { 87 | bsearchf = srv.Info.Visibility == 0 88 | } 89 | case qsGetServersHasAntiCheat: 90 | if strings.EqualFold(sqf.values[0], "true") { 91 | bsearcht = srv.Info.VAC == 1 92 | } else { 93 | bsearchf = srv.Info.VAC == 0 94 | } 95 | } 96 | if sqf.needsbool { 97 | if strings.EqualFold(sqf.values[0], "true") && bsearcht { 98 | matched = append(matched, srv) 99 | } else if strings.EqualFold(sqf.values[0], "false") && bsearchf { 100 | matched = append(matched, srv) 101 | } 102 | } else { 103 | for _, val := range sqf.values { 104 | if useContains { 105 | val, ssearch = strings.ToLower(val), strings.ToLower(ssearch) 106 | if strings.Contains(ssearch, val) { 107 | matched = append(matched, srv) 108 | } 109 | } else { 110 | if strings.EqualFold(ssearch, val) { 111 | matched = append(matched, srv) 112 | } 113 | } 114 | } 115 | } 116 | } 117 | return matched 118 | } 119 | 120 | // filterServers takes the server filters and the last retrieved server list and 121 | // returns a new, filtered server list based on the matched filters. 122 | func filterServers(sqf []slQueryFilter, 123 | a *models.APIServerList) *models.APIServerList { 124 | if a == nil { 125 | return models.GetDefaultServerList() 126 | } 127 | filtered := make([]models.APIServer, len(a.Servers)) 128 | copy(filtered, a.Servers) 129 | 130 | for _, s := range sqf { 131 | filtered = findMatches(s, filtered) 132 | } 133 | if filtered == nil { 134 | // JSON empty array instead of null 135 | filtered = make([]models.APIServer, 0) 136 | } 137 | return &models.APIServerList{ 138 | RetrievedAt: a.RetrievedAt, 139 | RetrievedTimeStamp: a.RetrievedTimeStamp, 140 | Servers: filtered, 141 | ServerCount: len(filtered), 142 | FailedCount: 0, 143 | FailedServers: make([]string, 0), 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/web/serverfilter_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | // Tests for server filtering for getServers API endpoint 4 | 5 | import ( 6 | "encoding/json" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/syncore/a2sapi/src/constants" 11 | "github.com/syncore/a2sapi/src/models" 12 | ) 13 | 14 | func TestGetSrvFilterFromQString(t *testing.T) { 15 | query := make(map[string][]string, 3) 16 | query["regions"] = []string{"North America,Europe"} 17 | query["serverVersions"] = []string{ 18 | "1066", 19 | } 20 | query["hasPlayers"] = []string{ 21 | "true", 22 | } 23 | sqf := getSrvFilterFromQString(query, getServersQueryStrings) 24 | if len(sqf) != 3 { 25 | t.Fatalf("Expected server list query filters length of 3, got %d", 26 | len(sqf)) 27 | } 28 | // Names 29 | found, expectedfound := 0, 3 30 | for _, s := range sqf { 31 | for key := range query { 32 | if strings.EqualFold(s.name, key) { 33 | found++ 34 | } 35 | } 36 | } 37 | if found != expectedfound { 38 | t.Fatalf("Expected server list query filter to match %d elements, got: %d", 39 | expectedfound, found) 40 | } 41 | // Values 42 | found, expectedfound = 0, 4 43 | for _, s := range sqf { 44 | for _ = range s.values { 45 | found++ 46 | } 47 | } 48 | if found != expectedfound { 49 | t.Fatalf("Expected server list query filter to contain %d values, got: %d", 50 | expectedfound, found) 51 | } 52 | // Boolean 53 | for _, s := range sqf { 54 | if strings.EqualFold(s.name, qsGetServersHasPlayers) { 55 | if s.needsbool { 56 | break 57 | } else { 58 | t.Fatalf("Expected that %v query string filter required bool", 59 | qsGetServersHasPlayers) 60 | } 61 | } 62 | } 63 | } 64 | 65 | func TestFindMatches(t *testing.T) { 66 | hasPlayersFilter := slQueryFilter{ 67 | name: qsGetServersHasPlayers, 68 | needsbool: true, 69 | values: []string{"true"}, 70 | } 71 | serverNameFilter := slQueryFilter{ 72 | name: qsGetServersName, 73 | needsbool: false, 74 | values: []string{"syncore"}, 75 | } 76 | stateFilter := slQueryFilter{ 77 | name: qsGetServersState, 78 | needsbool: false, 79 | values: []string{"TX", "NY"}, 80 | } 81 | src := &models.APIServerList{} 82 | err := json.Unmarshal(constants.TestServerDumpJSON, src) 83 | if err != nil { 84 | t.Fatalf("Failed to read test server data: %s", err) 85 | } 86 | // ?hasPlayers=true 87 | matches := findMatches(hasPlayersFilter, src.Servers) 88 | expected := 1 89 | if matches == nil { 90 | t.Fatal("Matches should not be nil") 91 | } 92 | if len(matches) != expected { 93 | t.Fatalf("Expected %d match(es), got: %d", expected, len(matches)) 94 | } 95 | // ?serverName=syncore 96 | matches = findMatches(serverNameFilter, src.Servers) 97 | expected = 2 98 | if matches == nil { 99 | t.Fatal("Matches should not be nil") 100 | } 101 | if len(matches) != expected { 102 | t.Fatalf("Expected %d match(es), got: %d", expected, len(matches)) 103 | } 104 | // ?state=TX,NY 105 | matches = findMatches(stateFilter, src.Servers) 106 | expected = 2 107 | if matches == nil { 108 | t.Fatal("Matches should not be nil") 109 | } 110 | if len(matches) != expected { 111 | t.Fatalf("Expected %d match(es), got: %d", expected, len(matches)) 112 | } 113 | } 114 | 115 | func TestFilterServers(t *testing.T) { 116 | filters := []slQueryFilter{ 117 | slQueryFilter{ 118 | name: qsGetServersHasPlayers, 119 | needsbool: true, 120 | values: []string{"true"}, 121 | }, 122 | slQueryFilter{ 123 | name: qsGetServersName, 124 | needsbool: false, 125 | values: []string{"pixel"}, 126 | }, 127 | slQueryFilter{ 128 | name: qsGetServersState, 129 | needsbool: false, 130 | values: []string{"VA"}, 131 | }, 132 | } 133 | src := &models.APIServerList{} 134 | err := json.Unmarshal(constants.TestServerDumpJSON, src) 135 | if err != nil { 136 | t.Fatalf("Failed to read test server data: %s", err) 137 | } 138 | servers := filterServers(filters, src) 139 | if servers == nil { 140 | t.Fatal("Servers returned should not be nil") 141 | } 142 | if len(servers.Servers) != 1 { 143 | t.Fatalf("Expected 1 match, got: %d", len(servers.Servers)) 144 | } 145 | for _, s := range servers.Servers { 146 | if !strings.EqualFold("54.172.5.67:25801", s.Host) { 147 | t.Fatalf("Expected matched host to be: 54.172.5.67:25801, got: %s", 148 | s.Host) 149 | } 150 | } 151 | } 152 | --------------------------------------------------------------------------------