diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bb3c2b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dev.sh diff --git a/Dockerfile b/Dockerfile index 35016ea..0465e4e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,7 @@ WORKDIR /build # Copy all files from the cmd directory COPY go.mod ./go.mod COPY go.sum ./go.sum -COPY internal/server ./internal/server -COPY internal/utils ./internal/utils +COPY internal/routes ./internal/routes COPY internal/config ./internal/config COPY cmd/main.go ./main.go diff --git a/README.md b/README.md index 87646ba..4876026 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,97 @@ # palworld-query-api -query palworld with rcon! +Streamlined web API for effortlessly managing and querying Palworld game servers using the RCON protocol. + +## Features + +- Supports querying multiple Palworld game servers. +- Dynamic routing for retrieving server data by name. + +## Installation + +To install and run *palworld-query-api*, follow these steps: + +1. Clone the repository: `git clone https://github.com/xstar97/palworld-query-api.git` +2. Navigate to the project directory: `cd palworld-query-api` +3. Build the project: `go build cmd/main.go` +4. Run the compiled binary: `./palworld-query-api` + +Make sure you have Go installed and properly configured on your system before proceeding. + +### Command-Line Installation + +To install and run *palworld-query-api* from the command line, follow these steps: + +1. Clone the repository: `git clone https://github.com/xstar97/palworld-query-api.git` +2. Navigate to the project directory: `cd palworld-query-api` +3. Build the project: `go build` +4. Run the compiled binary: `./palworld-query-api` + +Make sure you have Go installed and properly configured on your system before proceeding. + +#### Command-Line Flags + +You can customize the behavior of *palworld-query-api* using the following command-line flags: + +| Flag | Description | Default Value | +|--------------------|---------------------------------------|--------------------| +| `-port` | Web port | `3000` | +| `-cli-root` | Root path to rcon file | `/app/rcon/rcon` | +| `-cli-config` | Root path to rcon.yaml | `/config/rcon.yaml`| +| `-logs-path` | Logs path | `/logs` | + +Replace the default values as needed when running the binary. + +### Docker Installation + +Alternatively, you can use the Docker image hosted on GitHub. Use the following `docker-compose.yml` file: + +```yaml +version: '3.8' + +services: + palworld-query-api: + image: ghcr.io/xstar97/palworld-query-api:latest + environment: + - PORT=3000 + # default values; really dont need to be changed! + # - CLI_ROOT=/app/rcon/rcon + # - CLI_CONFIG=/config/rcon.yaml + # - LOGS_PATH=/logs + # generates the yaml from this json array (optional, but recommended) + # - CONFIG_JSON='{"servers":[{"name":"default","address":"localhost:25575","password":"1234567890","type":"rcon","timeout":"10s"}]}' + ports: + - "3000:3000" + volumes: + - ./config:/config + - ./logs:/logs +``` + +an env variable `CONFIG_JSON` can be set to automatically create the rcon.yaml file needed for the rcon-cli tool. + +```json +{ + "servers": [ + { + "name": "default", + "address": "localhost:25575", + "password": "1234567890", + "type": "rcon", + "timeout": "60s" + } + ] +} +``` + +### License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. + +## Contributing + +Contributions are welcome! Please see the [CONTRIBUTING.md](./CONTRIBUTING.md) file for more details. + +## Acknowledgements + +- [rcon-cli](https://github.com/gorcon/rcon-cli) - The underlying CLI tool for RCON communication. +- [palworld](https://palworld.gg/) - The game server platform supported by this tool. diff --git a/cmd/main.go b/cmd/main.go index 9febb21..70d2d80 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,37 +1,22 @@ -// main.go package main import ( - "encoding/json" - "fmt" - "log" - "net/http" - "palworld-query-api/internal/config" - "palworld-query-api/internal/server" + "fmt" + "log" + "net/http" + "palworld-query-api/internal/config" + "palworld-query-api/internal/routes" ) func main() { - cfg := config.ParseFlags() + port := fmt.Sprintf(":%s", config.CONFIG.PORT) - http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - // Respond with 200 OK status - w.WriteHeader(http.StatusOK) - }) + // Register healthz route + http.HandleFunc(config.ROUTES.HEALTH, routes.HealthzHandler) - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - log.Println("Received API request") - serverData, err := server.GetServerData(cfg) - if err != nil { - log.Printf("Error getting server data: %v\n", err) - http.Error(w, "Error getting server data", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(serverData) - log.Println("Sent server data to client") - }) - - log.Printf("server listening on port %d\n", cfg.Port) - log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), nil)) + // Register servers route + http.HandleFunc(config.ROUTES.SERVERS, routes.IndexHandler) + + log.Printf("server listening on port %s\n", port) + log.Fatal(http.ListenAndServe(port, nil)) } diff --git a/go.mod b/go.mod index 1c9fc3b..628f721 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,8 @@ module palworld-query-api go 1.22 -require gopkg.in/yaml.v2 v2.4.0 // indirect +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + golang.org/x/sys v0.4.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum index 7534661..f292162 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/config/config.go b/internal/config/config.go index 77d5a21..3b168f3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,58 +1,111 @@ -// internal/config/config.go package config import ( "flag" "log" "os" - "palworld-query-api/internal/utils" - "strconv" ) -type Config struct { - RconCLIPath string - RconCLIConfig string - Port int - LogsPath string // New field for logs directory +type ConfigServer struct { + Address string `json:"address"` + Password string `json:"password"` + Type string `json:"type"` + Timeout string `json:"timeout"` } -func ParseFlags() *Config { - var config Config +type JsonServerConfig struct { + Name string `json:"name"` + Address string `json:"address"` + Password string `json:"password"` + Type string `json:"type"` + Timeout string `json:"timeout"` +} - // Check environment variables - config.RconCLIPath = os.Getenv("RCON_CLI_PATH") - config.RconCLIConfig = os.Getenv("RCON_CLI_CONFIG") - config.LogsPath = os.Getenv("LOGS_PATH") // Read the LOGS_PATH environment variable - portEnv := os.Getenv("PORT") - if portEnv != "" { - p, err := strconv.Atoi(portEnv) - if err != nil { - log.Fatalf("Invalid value for PORT environment variable: %v", err) - } - config.Port = p - } +type JsonConfigData struct { + Servers []JsonServerConfig `json:"servers"` +} - // Parse flags if environment variables not set - flag.StringVar(&config.RconCLIPath, "rcon-cli-path", "/app/rcon/rcon", "Path to the rcon-cli executable") - flag.StringVar(&config.RconCLIConfig, "rcon-cli-config", "/config/rcon.yaml", "Path to the rcon-cli config file") - flag.IntVar(&config.Port, "port", 3000, "server port") - flag.StringVar(&config.LogsPath, "logs-path", "/logs", "Path to the directory for log files") // Add logs-path flag - flag.Parse() +// Constants for routes +var ROUTES = struct { + SERVERS string + HEALTH string +}{ + SERVERS: "/servers/", + HEALTH: "/healthz", +} - // Check if RconCLIPath exists - if _, err := os.Stat(config.RconCLIPath); os.IsNotExist(err) { - log.Fatalf("RconCLIPath '%s' does not exist", config.RconCLIPath) - } +// Configuration constants +var CONFIG = struct { + // Web port + PORT string + // Root path to rcon file + CLI_ROOT string + // Root path to rcon.yaml + CLI_CONFIG string + // Default rcon env + CLI_DEFAULT_SERVER string + // Default rcon env + LOGS_PATH string +}{} + +// Constants for commands +var COMMANDS = struct { + ENV string + CONFIG string +}{ + ENV: "--env", + CONFIG: "--config", +} + +// Constants for commands +var PALWORLD_RCON_COMMANDS = struct { + INFO string + SHOW_PLAYERS string +}{ + INFO: "info", + SHOW_PLAYERS: "showplayers", +} + +// Function to set configuration from environment variables +func setConfigFromEnv() { + setIfNotEmpty := func(key string, value *string) { + if env := os.Getenv(key); env != "" { + *value = env + } + } + + setIfNotEmpty("PORT", &CONFIG.PORT) + setIfNotEmpty("CLI_ROOT", &CONFIG.CLI_ROOT) + setIfNotEmpty("CLI_CONFIG", &CONFIG.CLI_CONFIG) + setIfNotEmpty("CLI_DEFAULT_SERVER", &CONFIG.CLI_DEFAULT_SERVER) + setIfNotEmpty("LOGS_PATH", &CONFIG.LOGS_PATH) +} + +// Parse flags +func init() { + // Set configuration from environment variables + setConfigFromEnv() + + flag.StringVar(&CONFIG.PORT, "port", "3000", "Server port") + flag.StringVar(&CONFIG.CLI_ROOT, "cli-root", "/app/rcon/rcon", "Root path to rcon file") + flag.StringVar(&CONFIG.CLI_CONFIG, "cli-config", "/config/rcon.yaml", "Root path to rcon.yaml") + flag.StringVar(&CONFIG.CLI_DEFAULT_SERVER, "cli-def-server", "default", "Default rcon env") + flag.StringVar(&CONFIG.LOGS_PATH, "logs-path", "/logs", "Logs path") + flag.Parse() // Check if CONFIG_JSON is set configJSON := os.Getenv("CONFIG_JSON") if configJSON != "" { // Update the existing config file if it exists, otherwise create a new one - err := utils.GenerateConfigFromJSON(configJSON, config.RconCLIConfig, config.LogsPath) + err := GenerateConfigFromJSON(configJSON, CONFIG.CLI_CONFIG, CONFIG.LOGS_PATH) if err != nil { log.Fatalf("Error generating config from JSON: %v", err) } } - - return &config + // Log the set flags + log.Printf("Server port: %s", CONFIG.PORT) + log.Printf("Root path to rcon file: %s", CONFIG.CLI_ROOT) + log.Printf("Root path to rcon.yaml: %s", CONFIG.CLI_CONFIG) + log.Printf("Default rcon env: %s", CONFIG.CLI_DEFAULT_SERVER) + log.Printf("Logs path: %s", CONFIG.LOGS_PATH) } diff --git a/internal/server/server.go b/internal/config/server.go similarity index 68% rename from internal/server/server.go rename to internal/config/server.go index e25713d..6375b11 100644 --- a/internal/server/server.go +++ b/internal/config/server.go @@ -1,11 +1,8 @@ -// internal/server/server.go -package server +package config import ( "fmt" - "os/exec" "strings" - "palworld-query-api/internal/config" ) type ServerInfo struct { @@ -19,16 +16,16 @@ type Players struct { List []string `json:"list"` } -func GetServerData(config *config.Config) (*ServerInfo, error) { +func GetServerData(serverName string) (*ServerInfo, error) { serverInfo := &ServerInfo{} - infoOutput, err := runRCONCommand(config, "info") + infoOutput, err := runRCONCommand(serverName, "info") if err != nil { return nil, fmt.Errorf("error running 'rcon-cli info': %v", err) } parseServerInfo(infoOutput, serverInfo) - playersOutput, err := runRCONCommand(config, "showplayers") + playersOutput, err := runRCONCommand(serverName, "showplayers") if err != nil { return nil, fmt.Errorf("error running 'rcon-cli showplayers': %v", err) } @@ -37,13 +34,12 @@ func GetServerData(config *config.Config) (*ServerInfo, error) { return serverInfo, nil } -func runRCONCommand(config *config.Config, command string) (string, error) { - cmd := exec.Command(config.RconCLIPath, "-config", config.RconCLIConfig, command) - output, err := cmd.Output() - if err != nil { - return "", err - } - return string(output), nil +func runRCONCommand(serverName string, command string) (string, error) { + output, err := ExecuteShellCommand(CONFIG.CLI_ROOT, COMMANDS.CONFIG, CONFIG.CLI_CONFIG, COMMANDS.ENV, serverName, command) + if err != nil { + return "", fmt.Errorf("failed to run rcon-cli: %v", err) + } + return string(output), nil // Convert output to string before returning } func parseServerInfo(output string, serverInfo *ServerInfo) { @@ -62,7 +58,9 @@ func parseServerInfo(output string, serverInfo *ServerInfo) { func parsePlayerList(output string, serverInfo *ServerInfo) { lines := strings.Split(output, "\n") - players := Players{} + players := Players{ + List: make([]string, 0), // Initialize the list with an empty slice + } for _, line := range lines { if !strings.HasPrefix(line, "name,playeruid,steamid") && line != "" { playerData := strings.Split(line, ",") diff --git a/internal/config/utils.go b/internal/config/utils.go new file mode 100644 index 0000000..54ef52f --- /dev/null +++ b/internal/config/utils.go @@ -0,0 +1,147 @@ +package config + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "log" + "os/exec" + "gopkg.in/yaml.v2" + "github.com/fsnotify/fsnotify" +) + +func GenerateConfigFromJSON(configJSON string, outputPath string, logs string) error { + var JsonConfigData JsonConfigData + err := json.Unmarshal([]byte(configJSON), &JsonConfigData) + if err != nil { + return fmt.Errorf("error parsing JSON: %v", err) + } + + // Prepare YAML content + yamlContent := "" + for _, server := range JsonConfigData.Servers { + yamlContent += fmt.Sprintf("%s:\n", server.Name) + yamlContent += fmt.Sprintf(" address: \"%s\"\n", server.Address) + yamlContent += fmt.Sprintf(" password: \"%s\"\n", server.Password) + yamlContent += fmt.Sprintf(" log: \"%s/%s.log\"\n", logs, server.Name) + yamlContent += fmt.Sprintf(" type: \"%s\"\n", server.Type) + yamlContent += fmt.Sprintf(" timeout: \"%s\"\n", server.Timeout) + } + + // Write YAML content to file + err = ioutil.WriteFile(outputPath, []byte(yamlContent), 0644) + if err != nil { + return fmt.Errorf("error writing YAML to file: %v", err) + } + + fmt.Println("Config file generated successfully") + return nil +} + +func FileExists(filePath string) bool { + _, err := os.Stat(filePath) + return !os.IsNotExist(err) +} + +// Function to read the YAML config file and return the content +func ReadConfig() (map[string]ConfigServer, error) { + filePath := CONFIG.CLI_CONFIG + + // Log the file path + log.Printf("Reading config from file: %s", filePath) + + // Check if the file exists + _, err := os.Stat(filePath) + if os.IsNotExist(err) { + return nil, err + } + + // Read YAML file + yamlFile, err := ioutil.ReadFile(filePath) + if err != nil { + return nil, err + } + + // Unmarshal YAML into map + var data map[string]ConfigServer + err = yaml.Unmarshal(yamlFile, &data) + if err != nil { + return nil, err + } + + // Create a new watcher to monitor changes to the file + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + defer watcher.Close() + + // Watch for changes to the file + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op&fsnotify.Write == fsnotify.Write { + log.Println("Config file modified. Reloading config...") + // Reload config from file + reloadData, err := ReadConfig() + if err != nil { + log.Println("Error reloading config:", err) + continue + } + log.Println("Config reloaded successfully") + // Update the existing data with the reloaded data + data = reloadData + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("Error watching file:", err) + } + } + }() + + // Add the file to the watcher + err = watcher.Add(filePath) + if err != nil { + return nil, err + } + + log.Println("Config read successfully from file.") + return data, nil +} + +// reads the YAML config file and returns the configuration for a specific server +func GetServer(serverName string) (ConfigServer, error) { + data, err := ReadConfig() + if err != nil { + return ConfigServer{}, err + } + + // Check if the server name exists + config, ok := data[serverName] + if !ok { + return ConfigServer{}, fmt.Errorf("server '%s' not found", serverName) + } + + return config, nil +} + +// ExecuteShellCommand executes a shell command with provided arguments and returns its output +func ExecuteShellCommand(command string, args ...string) ([]byte, error) { + // Set the command to execute + cmd := exec.Command(command, args...) + + // Capture the output of the command + output, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + + return output, nil +} diff --git a/internal/routes/healthz.go b/internal/routes/healthz.go new file mode 100644 index 0000000..a9b7928 --- /dev/null +++ b/internal/routes/healthz.go @@ -0,0 +1,10 @@ +package routes + +import ( + "net/http" +) + +func HealthzHandler(w http.ResponseWriter, r *http.Request) { + // Respond with 200 OK status + w.WriteHeader(http.StatusOK) +} diff --git a/internal/routes/servers.go b/internal/routes/servers.go new file mode 100644 index 0000000..997fca2 --- /dev/null +++ b/internal/routes/servers.go @@ -0,0 +1,80 @@ +package routes + +import ( + "encoding/json" + "log" + "net/http" + "palworld-query-api/internal/config" +) + +func IndexHandler(w http.ResponseWriter, r *http.Request) { + servers, err := config.ReadConfig() + if err != nil { + http.Error(w, "Failed to read server configurations", http.StatusInternalServerError) + log.Println("Failed to read server configurations:", err) + return + } + + log.Printf("Received API request: %s\n", r.URL.Path) + + path := r.URL.Path + + // Check if the path is the servers route + if path == config.ROUTES.SERVERS { + // Index route for all servers - List all server data keyed by server names + serverDataMap, err := getAllServerData(servers) + if err != nil { + http.Error(w, "Error getting all server data", http.StatusInternalServerError) + log.Println("Error getting all server data:", err) + return + } + + // Encode and send the response + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(serverDataMap); err != nil { + http.Error(w, "Error encoding server data", http.StatusInternalServerError) + log.Println("Error encoding server data:", err) + return + } + log.Println("Sent all server data to client") + return + } + + // Extract server name from path + serverName := path[len(config.ROUTES.SERVERS):] + if serverName == "" { + // If no server name provided, return bad request + http.Error(w, "Invalid server name", http.StatusBadRequest) + log.Println("Invalid server name") + return + } + + // Get server data by name + serverDataInfo, err := config.GetServerData(serverName) + if err != nil { + log.Printf("Error getting server data for %s: %v\n", serverName, err) + http.Error(w, "Error getting server data", http.StatusInternalServerError) + return + } + + // Encode and send the response + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(serverDataInfo); err != nil { + http.Error(w, "Error encoding server data", http.StatusInternalServerError) + log.Println("Error encoding server data:", err) + return + } + log.Printf("Sent server data for %s to client", serverName) +} + +func getAllServerData(servers map[string]config.ConfigServer) (map[string]interface{}, error) { + serverDataMap := make(map[string]interface{}) + for name := range servers { + serverDataInfo, err := config.GetServerData(name) + if err != nil { + return nil, err + } + serverDataMap[name] = serverDataInfo + } + return serverDataMap, nil +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go deleted file mode 100644 index 5a3aab2..0000000 --- a/internal/utils/utils.go +++ /dev/null @@ -1,54 +0,0 @@ -// internal/utils/utils.go -package utils - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "os" -) - -type ServerConfig struct { - Name string `json:"name"` - Address string `json:"address"` - Password string `json:"password"` - Type string `json:"type"` - Timeout string `json:"timeout"` -} - -type ConfigData struct { - Servers []ServerConfig `json:"servers"` -} - -func GenerateConfigFromJSON(configJSON string, outputPath string, logs string) error { - var configData ConfigData - err := json.Unmarshal([]byte(configJSON), &configData) - if err != nil { - return fmt.Errorf("error parsing JSON: %v", err) - } - - // Prepare YAML content - yamlContent := "" - for _, server := range configData.Servers { - yamlContent += fmt.Sprintf("%s:\n", server.Name) - yamlContent += fmt.Sprintf(" address: \"%s\"\n", server.Address) - yamlContent += fmt.Sprintf(" password: \"%s\"\n", server.Password) - yamlContent += fmt.Sprintf(" log: \"%s/%s.log\"\n", logs, server.Name) - yamlContent += fmt.Sprintf(" type: \"%s\"\n", server.Type) - yamlContent += fmt.Sprintf(" timeout: \"%s\"\n", server.Timeout) - } - - // Write YAML content to file - err = ioutil.WriteFile(outputPath, []byte(yamlContent), 0644) - if err != nil { - return fmt.Errorf("error writing YAML to file: %v", err) - } - - fmt.Println("Config file generated successfully") - return nil -} - -func FileExists(filePath string) bool { - _, err := os.Stat(filePath) - return !os.IsNotExist(err) -}