From 7c42807688a124e15ade5af1b155736bb2e8af16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Dahl=20K=C3=A6rgaard?= Date: Mon, 20 Aug 2018 23:36:07 +0200 Subject: [PATCH] Reimagine as web API --- README.md | 31 ++-- app.go | 122 ++++++++++++++++ main.go | 382 ++---------------------------------------------- steam/README.md | 7 + steam/steam.go | 201 +++++++++++++++++++++++++ 5 files changed, 357 insertions(+), 386 deletions(-) create mode 100644 app.go create mode 100644 steam/README.md create mode 100644 steam/steam.go diff --git a/README.md b/README.md index 5d27060..630854d 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,37 @@ -# Steam Api Utility +# Steam Gameserver REST API -A commandline utility for managing Steam Gameserver Tokens through Steam Web API. +A REST API for pulling Steam Gameserver Tokens through Steamworks Web API. -Its primary target is the IGameServersService Interface, and the code has been built on knowledge from two sources. +Its wraps the IGameServersService Interface, and the code has been built on knowledge from two sources. A [community made API reference](http://steamwebapi.azurewebsites.net/). And the [Steamworks Documentation Website](https://partner.steamgames.com/doc/webapi/IGameServersService). -It currently outputs all returned API JSON data as unquoted Semicolon Separated Values with Headers. +It returns tokens as text/plain on the following URL: -The Utility currently supports three features. +> [GET] /token/{appID}/{memo} -* Create new Gameserver Account with accompanying Token -* List Gameserver Accounts -* Delete Gameserver Account by ID (SteamID) +* **appID** is the Steam Application ID (e.g. 740 for CSGO dedicated server) +* **memo** is a note that uniquely identifies a gameserver -If the utility receives an X-error_message Response Header, it will log the message to console, and exit. +The library it uses to communicate with Steamworks Web API is [nested in this project](steam/README.md). + +## Errors + +Errors from the Steamworks Web API will be forwarded as JSON objects. + +> { "error": "some error happened" } ## Build ```sh # Windows -GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o steam-api.exe main.go +GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o steam-api.exe main.go app.go # Linux -GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o steam-api main.go +GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o steam-api main.go app.go # OSX -GOOS=darwin go build -ldflags="-s -w" -o steam-api main.go +GOOS=darwin go build -ldflags="-s -w" -o steam-api main.go app.go ``` -All binary releases of steam-api are compressed with `upx --brute`. +Optionally, you can cut down binary size with `upx --brute`. diff --git a/app.go b/app.go new file mode 100644 index 0000000..20f4e67 --- /dev/null +++ b/app.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strconv" + + "github.com/gorilla/mux" + "github.com/npflan/steam-api/steam" +) + +// App contains references to global necessities +type App struct { + Router *mux.Router + Log Log +} + +// Log is a modifiable endpoint +type Log struct { + Error *log.Logger + Info *log.Logger +} + +const defaultLogFormat = log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile | log.LUTC + +// Run server on specific interface +func (a *App) Run(addr string) { + a.registerRoutes() + // set default log format if no custom format present + if a.Log.Info == nil { + log.New(os.Stdout, "INFO: ", defaultLogFormat) + } + if a.Log.Error == nil { + log.New(os.Stderr, "ERROR: ", defaultLogFormat) + } + log.Fatal(http.ListenAndServe(addr, a.Router)) +} + +func (a *App) registerRoutes() { + a.Router = mux.NewRouter().StrictSlash(true) + a.Router.HandleFunc("/", a.getHome).Methods("GET") + a.Router.HandleFunc("/token/{appID}/{memo}", a.pullToken).Methods("GET") +} + +// RespondWithJSON uses a struct, for a JSON response. +func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { + response, _ := json.Marshal(payload) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + w.Write(response) +} + +// RespondWithText returns text/plain. +func respondWithText(w http.ResponseWriter, code int, payload string) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(code) + w.Write([]byte(payload)) +} + +// RespondWithError standardizes error messages, through the use of RespondWithJSON. +func respondWithError(w http.ResponseWriter, code int, message string) { + respondWithJSON(w, code, map[string]string{"error": message}) +} + +func (a *App) getHome(w http.ResponseWriter, r *http.Request) { + +} + +func (a *App) pullToken(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + if _, ok := vars["appID"]; !ok { + respondWithError(w, http.StatusBadRequest, "Missing appID") + return + } + if _, ok := vars["memo"]; !ok { + respondWithError(w, http.StatusBadRequest, "Missing memo") + return + } + + appID, err := strconv.Atoi(vars["appID"]) + if err != nil { + respondWithError(w, http.StatusBadRequest, "bad appID") + return + } + + accounts, err := steam.GetAccountList() + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Unable to list existing tokens") + return + } + + // Check for existing account + var account steam.Account + for _, acct := range accounts { + if acct.Memo == vars["memo"] && int(acct.AppID) == appID { + account = acct + break + } + } + + // Create new if not found + if account.SteamID == "" { + account, err = steam.CreateAccount(appID, vars["memo"]) + } + if err != nil { + respondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Refresh token if found and expired + if account.IsExpired == true { + account, err = steam.ResetLoginToken(account.SteamID) + } + if err != nil { + respondWithError(w, http.StatusInternalServerError, err.Error()) + } + + respondWithText(w, http.StatusOK, account.LoginToken) +} diff --git a/main.go b/main.go index 689a18b..e259f79 100644 --- a/main.go +++ b/main.go @@ -1,386 +1,22 @@ package main import ( - "encoding/json" "fmt" - "io/ioutil" - "log" - "net/http" - "net/url" "os" - "reflect" - "time" - "github.com/urfave/cli" + _ "github.com/npflan/steam-api/steam" ) -// baseURL/interface/method/version?parameters -const location = "https://api.steampowered.com/IGameServersService/" -const version = "v1" - -var apiKey string -var requireAPIKey = func(c *cli.Context) error { - if len(apiKey) == 0 { - return cli.NewExitError("API key not provided", 1) - } - return nil -} - -// Steam returns a JSON { response: } object, which wraps all return values. -type steamResponse struct { - Response json.RawMessage `json:"response"` -} - -type response struct { - Servers []accountEntity `json:"servers,omitempty"` -} - -type accountEntity struct { - SteamID string `json:"steamid,omitempty"` - AppID uint16 `json:"appid,omitempty"` - LoginToken string `json:"login_token,omitempty"` - Memo string `json:"memo,omitempty"` - IsDeleted bool `json:"is_deleted,omitempty"` - IsExpired bool `json:"is_expired,omitempty"` - LastLogon int `json:"rt_last_logon,omitempty"` -} - -// Wrap requests for Steam Web API, to generalize insertion of API key, -// and handling of Response Header. -func querySteam(command string, method string, params map[string]string) (data []byte, err error) { - // ready request for location - req, err := http.NewRequest(method, location+command+"/"+version, nil) - if err != nil { - return nil, err - } - // Add API Key and extra parameters - q := url.Values{} - q.Add("key", apiKey) - for key, value := range params { - q.Add(key, value) - } - // Encode parameters and append them to the url - req.URL.RawQuery = q.Encode() - // Execute request - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - // Drop if Error Header present - if respErrState := resp.Header.Get("X-error_message"); respErrState != "" { - log.Fatal(respErrState) - } - // Decode response - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - return body, nil -} - -// Remove the { response: data } wrapper, and return inner json as byte array. -func unwrapResponse(response *[]byte) error { - resp := steamResponse{} - if err := json.Unmarshal(*response, &resp); err != nil { - log.Fatal(err) +func mayEnv(envVar string, deflt string) string { + env := os.Getenv(envVar) + if env == "" { + fmt.Printf("Environment variable '%s' is empty / undefined. Defaulting to '%s'", envVar, deflt) + return deflt } - *response = ([]byte)(resp.Response) - return nil -} - -// NotYetImplemented -func printCSV(arr interface{}) error { - arrValue := reflect.ValueOf(arr) - values := make([]interface{}, arrValue.NumField()) - for key, value := range values { - fmt.Printf("%v;%v", key, value) - } - - /*v := reflect.ValueOf(elements[0]) - values := make([]interface{}, v.NumField()) - for i := 0; i < v.NumField(); i++ { - fmt.Printf("%s", v.Field(i).Interface()) - }*/ - return nil + return env } func main() { - app := cli.NewApp() - app.Name = "Steam Gameserver Token Manager" - app.Usage = "making server management a little bit easier" - app.UsageText = "main [global options] command [command options]" - app.Version = "0.1.0" - app.Compiled = time.Now() - app.Authors = []cli.Author{ - cli.Author{ - Name: "Kristian Dahl Kærgaard", - Email: "hcand.dk@gmail.com", - }, - } - - // Customize version printer to include compile timestamp - cli.VersionPrinter = func(c *cli.Context) { - fmt.Printf("%s %s\nCompiled at %s\n", app.Name, app.Version, app.Compiled) - } - - // Define Global Flags - app.Flags = []cli.Flag{ - cli.StringFlag{ - Name: "key, k", - Value: "", - Usage: "API key (optionally from environment variable)", - EnvVar: "STEAM_WEB_API_KEY", - Destination: &apiKey, - }, - /*cli.StringFlag{ - Name: "format, f", - Value: "csv", - Usage: "Output format for returned data (csv, json)", - },*/ - } - - // Define Subcommands, each with its own set of flags - app.Commands = []cli.Command{ - { - Name: "GetAccountList", - Aliases: []string{"gal"}, - Usage: "Gets a list of game server accounts with their logon tokens", - Before: requireAPIKey, - Action: func(c *cli.Context) error { - params := make(map[string]string) - data, err := querySteam("GetAccountList", "GET", params) - if err != nil { - log.Fatal(err) - } - unwrapResponse(&data) - var response response - if err := json.Unmarshal(data, &response); err != nil { - log.Fatal(err) - } - fmt.Printf("%s;%s;%s;%s;%s;%s;%s\n", "AppID", "IsDeleted", "IsExpired", "LastLogon", "LoginToken", "Memo", "SteamID") - for _, server := range response.Servers { - fmt.Printf("%d;%t;%t;%d;%s;%s;%s\n", server.AppID, server.IsDeleted, server.IsExpired, server.LastLogon, server.LoginToken, server.Memo, server.SteamID) - } - return nil - }, - }, - { - Name: "CreateAccount", - Aliases: []string{"ca"}, - Usage: "Creates a persistent game server account", - Before: requireAPIKey, - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "a, appid", - Value: "730", - Usage: "The app to use the account for", - }, - cli.StringFlag{ - Name: "m, memo", - Value: "", - Usage: "The memo to set on the new account", - }, - }, - Action: func(c *cli.Context) error { - params := make(map[string]string) - params["appid"] = c.String("appid") - params["memo"] = c.String("memo") - data, err := querySteam("CreateAccount", "POST", params) - if err != nil { - log.Fatal(err) - } - unwrapResponse(&data) - var account accountEntity - if err := json.Unmarshal(data, &account); err != nil { - log.Fatal(err) - } - fmt.Printf("%s;%s\n", "SteamID", "LoginToken") - fmt.Printf("%s;%s\n", account.SteamID, account.LoginToken) - return nil - }, - }, - /*{ - Name: "SetMemo", - Aliases: []string{"sm"}, - Usage: "Change the memo associated with the game server account. Memos do not affect the account in any way. The memo shows up in the GetAccountList response and serves only as a reminder of what the account is used for.", - Before: requireAPIKey, - Flags: []cli.Flag{ - cli.Uint64Flag{ - Name: "s, steamid", - Value: 0, - Usage: "SteamID of the game server to set the memo on", - }, - cli.StringFlag{ - Name: "m, memo", - Value: "", - Usage: "Memo to set on the account", - }, - }, - Action: func(c *cli.Context) error { - if c.Uint64("steamid") == 0 { - return cli.NewExitError("steamid not provided", 1) - } - return nil - }, - }, - { - Name: "ResetLoginToken", - Aliases: []string{"rlt"}, - Usage: "Generate a new login token for the specified game server", - Before: requireAPIKey, - Flags: []cli.Flag{ - cli.Uint64Flag{ - Name: "s, steamid", - Value: 0, - Usage: "SteamID of the game server to reset the login token of", - }, - }, - Action: func(c *cli.Context) error { - if c.Uint64("steamid") == 0 { - return cli.NewExitError("steamid not provided", 1) - } - return nil - }, - },*/ - { - Name: "DeleteAccount", - Aliases: []string{"da"}, - Usage: "Delete a persistent game server account", - Before: requireAPIKey, - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "s, steamid", - Value: "0", - Usage: "SteamID of the game server account to delete", - }, - }, - Action: func(c *cli.Context) error { - params := make(map[string]string) - params["steamid"] = c.String("steamid") - _, err := querySteam("DeleteAccount", "POST", params) - if err != nil { - log.Fatal(err) - } - fmt.Printf("Deleted steamid %s\n", c.String("steamid")) - return nil - }, - }, - /*{ - Name: "GetAccountPublicInfo", - Aliases: []string{"gapi"}, - Usage: "Get public information about a given game server account", - Flags: []cli.Flag{ - cli.Uint64Flag{ - Name: "s, steamid", - Value: 0, - Usage: "SteamID of the game server to get info on", - }, - }, - }, - { - Name: "QueryLoginToken", - Aliases: []string{"qlt"}, - Usage: "Query the status of the specified token, which must be owned by you", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "t, token", - Value: "", - Usage: "Login token to query", - }, - }, - }, - { - Name: "GetServerSteamIDsByIP", - Aliases: []string{"gssibi"}, - Usage: "Get a list of server SteamIDs given a list of IPs", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "s, servers", - Value: "", - Usage: "List of server IPs to query", - }, - }, - }, - { - Name: "GetServerIPsBySteamID", - Aliases: []string{"gsibsi"}, - Usage: "Get a list of server IP addresses given a list of SteamIDs", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "s, steamids", - Value: "", - Usage: "List of Steam IDs to query", - }, - }, - },*/ - } - - app.Run(os.Args) - //parseFlags() - /* - q := url.Values{} - q.Add("key", apiKey) - q.Add("appid", "730") - - tokenURL, err := url.Parse(location) - if err != nil { - log.Fatal(err) - } - - client := &http.Client{} - - var data map[string]interface{} - - for server := 1; server <= serverCount; server++ { - for instance := 1; instance <= instanceCount; instance++ { - // build - q.Set("memo", fmt.Sprintf("%d:%d", server, instance)) - req, err := http.NewRequest("POST", tokenURL.String(), nil) - if err != nil { - log.Fatal(err) - } - req.URL.RawQuery = q.Encode() - // execute - resp, err := client.Do(req) - if err != nil { - log.Fatal(err) - } - // decode - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - log.Fatal(err) - } - err = json.Unmarshal(body, &data) - // print - fmt.Printf("%d:%d %s\n", server, instance, data["response"].(map[string]interface{})["login_token"]) - //fmt.Printf("%v\n", resp) - } - } - */ + a := App{} + a.Run(mayEnv("STEAM_WEB_API_BIND_ADDRESS", ":8000")) } - -/* - GetAccountList - key - CreateAccount - key - appid - memo - SetMemo - key - steamid - memo - ResetLoginToken - key - steamid - DeleteAccount - key - steamid - GetAccountPublicInfo - key - steamid - QueryLoginToken - key - login_token -*/ diff --git a/steam/README.md b/steam/README.md new file mode 100644 index 0000000..19aca63 --- /dev/null +++ b/steam/README.md @@ -0,0 +1,7 @@ +# Steam Gameserver API Library + +Small library which communicates with the iGameServersService interface on Steamworks Web API. + +## Requirements + +STEAM_WEB_API_KEY environment variable, which can be generated / found [here](https://steamcommunity.com/dev/apikey). \ No newline at end of file diff --git a/steam/steam.go b/steam/steam.go new file mode 100644 index 0000000..17f56df --- /dev/null +++ b/steam/steam.go @@ -0,0 +1,201 @@ +package steam + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "strconv" +) + +// baseURL/interface/method/version?parameters +const location = "https://api.steampowered.com/IGameServersService/" +const version = "v1" + +// Set log format for STDOUT and STDERR +// https://golang.org/pkg/log/#pkg-constants +var logFormat = log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile | log.LUTC +var e = log.New(os.Stderr, "ERROR: ", logFormat) + +var apiKey string + +func mustEnv(envVar string) (env string, err error) { + env = os.Getenv(envVar) + if env == "" { + return "", fmt.Errorf("need %s environment variable", envVar) + } + return env, nil +} + +func init() { + var err error + apiKey, err = mustEnv("STEAM_WEB_API_KEY") + if err != nil { + e.Fatal(err) + } +} + +// Steam returns a JSON { response: } object, which wraps all return values. +type steamResponse struct { + Response json.RawMessage `json:"response"` +} + +// FML +type serversResponse struct { + Servers []Account `json:"servers"` +} + +// Account is an abstraction around LoginToken, for use with SteamCMD dedicated servers. +type Account struct { + SteamID string `json:"steamid,omitempty"` + AppID uint16 `json:"appid,omitempty"` + LoginToken string `json:"login_token,omitempty"` + Memo string `json:"memo,omitempty"` + IsDeleted bool `json:"is_deleted,omitempty"` + IsExpired bool `json:"is_expired,omitempty"` + LastLogon int `json:"rt_last_logon,omitempty"` +} + +// Remove the { response: data } wrapper, and return inner json as byte array. +func unwrapResponse(response *[]byte) error { + resp := steamResponse{} + if err := json.Unmarshal(*response, &resp); err != nil { + return err + } + *response = ([]byte)(resp.Response) + return nil +} + +// Wraps requests for Steam Web API, to generalize insertion of API key, +// and handling of Response Header. +func querySteam(command string, method string, params map[string]string) (data []byte, err error) { + // Prep request + req, err := http.NewRequest(method, location+command+"/"+version, nil) + if err != nil { + return nil, err + } + + // Add API Key and extra parameters + q := url.Values{} + q.Add("key", apiKey) + for key, value := range params { + q.Add(key, value) + } + // Encode parameters and append them to the url + req.URL.RawQuery = q.Encode() + + // Execute request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + // Drop if Error Header present + if respErrState := resp.Header.Get("X-error_message"); respErrState != "" { + return nil, errors.New(respErrState) + } + + // Read response + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Remove wrapper + if err = unwrapResponse(&body); err != nil { + return nil, err + } + + return body, nil +} + +// CreateAccount creates an account with a token, for use with SteamCMD dedicated servers. +func CreateAccount(appID int, memo string) (account Account, err error) { + // Build query string + params := make(map[string]string) + params["appid"] = strconv.Itoa(appID) + params["memo"] = memo + + // Execute request + data, err := querySteam("CreateAccount", "POST", params) + if err != nil { + return account, err + } + + // Decode response + if err := json.Unmarshal(data, &account); err != nil { + return account, err + } + + return account, nil +} + +// GetAccountList returns a list of all accounts. +func GetAccountList() (accounts []Account, err error) { + data, err := querySteam("GetAccountList", "GET", nil) + if err != nil { + return accounts, err + } + + var list serversResponse + + if err := json.Unmarshal(data, &list); err != nil { + return accounts, err + } + + accounts = list.Servers + + return accounts, nil +} + +// DeleteAccount deletes an account, immediately expiring its LoginToken. +func DeleteAccount(steamID string) (err error) { + params := make(map[string]string) + params["steamid"] = steamID + + _, err = querySteam("DeleteAccount", "POST", params) + if err != nil { + return err + } + + return nil +} + +// DeleteAllAccounts deletes all accounts registered by the user. +func DeleteAllAccounts() (err error) { + accounts, err := GetAccountList() + if err != nil { + return err + } + + for _, account := range accounts { + err = DeleteAccount(account.SteamID) + if err != nil { + e.Println(err) + } + } + + return nil +} + +// ResetLoginToken generates a new LoginToken on an existing steamID. +func ResetLoginToken(steamID string) (account Account, err error) { + params := make(map[string]string) + params["steamID"] = steamID + + data, err := querySteam("ResetLoginToken", "POST", params) + if err != nil { + return account, err + } + + if err := json.Unmarshal(data, &account); err != nil { + return account, err + } + + return account, nil +}