├── .gitignore ├── go.mod ├── Makefile ├── data └── pgsw.service ├── internal ├── events │ └── events.go ├── servers │ ├── helpers.go │ └── servers.go ├── misc │ ├── format.go │ ├── webhook.go │ └── options.go ├── query │ └── query.go ├── update │ └── update.go └── pterodactyl │ └── pterodactyl.go ├── pkg └── config │ ├── write_default.go │ ├── load.go │ └── def.go ├── tests ├── webhook.conf ├── webhook_slack.conf └── webhook_discord.conf ├── pterowatch.conf.example ├── LICENSE.md ├── main.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | Pterodactyl-Game-Server-Watch 2 | pgsw 3 | pterowatch 4 | go.sum -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gamemann/Pterodactyl-Game-Server-Watch 2 | 3 | go 1.13 4 | 5 | require github.com/gamemann/Rust-Auto-Wipe v0.0.0-20220819152534-6d34a4b8d827 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build -o pgsw 3 | install: 4 | mkdir -p /etc/pterowatch 5 | chmod +x ./pgsw 6 | cp ./pgsw /usr/bin/pgsw 7 | cp -n data/pgsw.service /etc/systemd/system/ 8 | .DEFAULT: build -------------------------------------------------------------------------------- /data/pgsw.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Pterodactyl Game Server Watch (PGSW). 3 | After=network-online.target 4 | Requires=network-online.target 5 | 6 | [Service] 7 | ExecStart=/usr/bin/pgsw 8 | Restart=always 9 | 10 | [Install] 11 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /internal/events/events.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/misc" 5 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 6 | ) 7 | 8 | func OnServerDown(cfg *config.Config, srv *config.Server, fails int, restarts int) { 9 | // Handle Misc options. 10 | misc.HandleMisc(cfg, srv, fails, restarts) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/config/write_default.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | ) 8 | 9 | func (cfg *Config) WriteDefaultsToFile(file string) error { 10 | var err error 11 | 12 | dir := path.Dir(file) 13 | 14 | err = os.MkdirAll(dir, 0755) 15 | 16 | // If we have an error and it doesn't look like an "already exist" error, return the error. 17 | if err != nil && !os.IsExist(err) { 18 | return err 19 | } 20 | 21 | fp, err := os.Create(file) 22 | 23 | if err != nil { 24 | return err 25 | } 26 | 27 | data, err := json.MarshalIndent(cfg, "", " ") 28 | 29 | if err != nil { 30 | // Close file. 31 | fp.Close() 32 | 33 | return err 34 | } 35 | 36 | _, err = fp.Write(data) 37 | 38 | if err != nil { 39 | // Close file. 40 | fp.Close() 41 | 42 | return err 43 | } 44 | 45 | return err 46 | } 47 | -------------------------------------------------------------------------------- /internal/servers/helpers.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "net" 5 | "time" 6 | 7 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 8 | ) 9 | 10 | type Tuple struct { 11 | IP string 12 | Port int 13 | UID string 14 | } 15 | 16 | type Stats struct { 17 | Fails *int 18 | Restarts *int 19 | NextScan *int64 20 | } 21 | 22 | type TickerHolder struct { 23 | Info Tuple 24 | Ticker *time.Ticker 25 | Conn *net.UDPConn 26 | ScanTime int 27 | Destroyer *chan bool 28 | Idx *int 29 | Stats Stats 30 | } 31 | 32 | func RemoveTicker(t *[]TickerHolder, idx int) { 33 | copy((*t)[idx:], (*t)[idx+1:]) 34 | *t = (*t)[:len(*t)-1] 35 | } 36 | 37 | func RemoveServer(cfg *config.Config, idx int) { 38 | copy(cfg.Servers[idx:], cfg.Servers[idx+1:]) 39 | cfg.Servers = cfg.Servers[:len(cfg.Servers)-1] 40 | } 41 | -------------------------------------------------------------------------------- /tests/webhook.conf: -------------------------------------------------------------------------------- 1 | { 2 | "apiURL": "https://panel.domain.com", 3 | "token": "mytoken", 4 | "addservers": true, 5 | 6 | "misc": [ 7 | { 8 | "type": "webhook", 9 | "data": { 10 | "app": "discord", 11 | "url": "https://discord.com/api/webhooks//" 12 | } 13 | }, 14 | { 15 | "type": "webhook", 16 | "data": { 17 | "app": "slack", 18 | "url": "https://hooks.slack.com/services///", 19 | "contents": "**{IP}:{PORT} DOWN!**\nFail count: {FAILS}" 20 | } 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /tests/webhook_slack.conf: -------------------------------------------------------------------------------- 1 | { 2 | "apiURL": "https://panel.domain.com", 3 | "token": "mytoken", 4 | "addservers": true, 5 | 6 | "misc": [ 7 | { 8 | "type": "webhook", 9 | "data": { 10 | "app": "slack", 11 | "url": "https://hooks.slack.com/services///" 12 | } 13 | }, 14 | { 15 | "type": "webhook", 16 | "data": { 17 | "app": "slack", 18 | "url": "https://hooks.slack.com/services///", 19 | "contents": "**{IP}:{PORT} DOWN!**\nFail count: {FAILS}" 20 | } 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /pkg/config/load.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | ) 7 | 8 | // Reads a config file based off of the file name (string) and returns a Config struct. 9 | func (cfg *Config) ReadConfig(path string) error { 10 | file, err := os.Open(path) 11 | 12 | if err != nil { 13 | return err 14 | } 15 | 16 | defer file.Close() 17 | 18 | stat, _ := file.Stat() 19 | 20 | data := make([]byte, stat.Size()) 21 | 22 | _, err = file.Read(data) 23 | 24 | if err != nil { 25 | return err 26 | } 27 | 28 | err = json.Unmarshal([]byte(data), cfg) 29 | 30 | return err 31 | } 32 | 33 | // Sets config's default values. 34 | func (cfg *Config) SetDefaults() { 35 | // Set config defaults. 36 | cfg.AddServers = false 37 | cfg.DebugLevel = 0 38 | cfg.ReloadTime = 500 39 | 40 | cfg.DefEnable = true 41 | cfg.DefScanTime = 5 42 | cfg.DefMaxFails = 10 43 | cfg.DefMaxRestarts = 2 44 | cfg.DefRestartInt = 120 45 | cfg.DefReportOnly = false 46 | cfg.DefA2STimeout = 1 47 | } 48 | -------------------------------------------------------------------------------- /pterowatch.conf.example: -------------------------------------------------------------------------------- 1 | { 2 | "apiURL": "https://panel.mydomain.com", 3 | "token": "12345", 4 | "addservers": true, 5 | 6 | "servers": [ 7 | { 8 | "enable": true, 9 | "ip": "172.20.0.10", 10 | "port": 27015, 11 | "uid": "testingUID", 12 | "scantime": 5, 13 | "maxfails": 5, 14 | "maxrestarts": 1, 15 | "restartint": 120 16 | }, 17 | { 18 | "enable": true, 19 | "ip": "172.20.0.11", 20 | "port": 27015, 21 | "uid": "testingUID2", 22 | "scantime": 5, 23 | "maxfails": 10, 24 | "maxrestarts": 2, 25 | "restartint": 120 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /tests/webhook_discord.conf: -------------------------------------------------------------------------------- 1 | { 2 | "apiURL": "https://panel.domain.com", 3 | "token": "mytoken", 4 | "addservers": true, 5 | 6 | "misc": [ 7 | { 8 | "type": "webhook", 9 | "data": { 10 | "app": "discord", 11 | "url": "https://discord.com/api/webhooks//" 12 | } 13 | }, 14 | { 15 | "type": "webhook", 16 | "data": { 17 | "app": "discord", 18 | "url": "https://discord.com/api/webhooks//", 19 | "username": "Custom Username", 20 | "avatarurl": "https://mydomain.com/image.png", 21 | "contents": "**{IP}:{PORT} DOWN!**\nFail count: {FAILS}" 22 | } 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Christian Deacon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/misc/format.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 8 | ) 9 | 10 | func FormatContents(app string, formatstr *string, fails int, restarts int, srv *config.Server, mentionstr string) { 11 | *formatstr = strings.ReplaceAll(*formatstr, "{IP}", srv.IP) 12 | *formatstr = strings.ReplaceAll(*formatstr, "{PORT}", strconv.Itoa(srv.Port)) 13 | *formatstr = strings.ReplaceAll(*formatstr, "{FAILS}", strconv.Itoa(fails)) 14 | *formatstr = strings.ReplaceAll(*formatstr, "{RESTARTS}", strconv.Itoa(restarts)) 15 | *formatstr = strings.ReplaceAll(*formatstr, "{MAXFAILS}", strconv.Itoa(srv.MaxFails)) 16 | *formatstr = strings.ReplaceAll(*formatstr, "{MAXRESTARTS}", strconv.Itoa(srv.MaxRestarts)) 17 | *formatstr = strings.ReplaceAll(*formatstr, "{UID}", srv.UID) 18 | *formatstr = strings.ReplaceAll(*formatstr, "{SCANTIME}", strconv.Itoa(srv.ScanTime)) 19 | *formatstr = strings.ReplaceAll(*formatstr, "{RESTARTINT}", strconv.Itoa(srv.RestartInt)) 20 | *formatstr = strings.ReplaceAll(*formatstr, "{NAME}", srv.Name) 21 | *formatstr = strings.ReplaceAll(*formatstr, "{MENTIONS}", mentionstr) 22 | } 23 | -------------------------------------------------------------------------------- /internal/query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 11 | ) 12 | 13 | // The A2S_INFO request. 14 | var query = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x54, 0x53, 0x6F, 0x75, 0x72, 0x63, 0x65, 0x20, 0x45, 0x6E, 0x67, 0x69, 0x6E, 0x65, 0x20, 0x51, 0x75, 0x65, 0x72, 0x79, 0x00} 15 | 16 | // Creates a UDP connection using the host and port. 17 | func CreateConnection(host string, port int) (*net.UDPConn, error) { 18 | var UDPC *net.UDPConn 19 | 20 | // Combine host and port. 21 | fullHost := host + ":" + strconv.Itoa(port) 22 | 23 | UDPAddr, err := net.ResolveUDPAddr("udp", fullHost) 24 | 25 | if err != nil { 26 | return UDPC, err 27 | } 28 | 29 | // Attempt to open a UDP connection. 30 | UDPC, err = net.DialUDP("udp", nil, UDPAddr) 31 | 32 | if err != nil { 33 | fmt.Println(err) 34 | 35 | return UDPC, errors.New("NoConnection") 36 | } 37 | 38 | return UDPC, nil 39 | } 40 | 41 | // Sends an A2S_INFO request to the host and port specified in the arguments. 42 | func SendRequest(conn *net.UDPConn) { 43 | conn.Write(query) 44 | } 45 | 46 | // Checks for A2S_INFO response. Returns true if it receives a response. Returns false otherwise. 47 | func CheckResponse(conn *net.UDPConn, srv config.Server) bool { 48 | buffer := make([]byte, 1024) 49 | 50 | // Set read timeout. 51 | conn.SetReadDeadline(time.Now().Add(time.Second * time.Duration(srv.A2STimeout))) 52 | 53 | _, _, err := conn.ReadFromUDP(buffer) 54 | 55 | if err != nil { 56 | return false 57 | } 58 | 59 | return true 60 | } 61 | -------------------------------------------------------------------------------- /pkg/config/def.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Server struct used for each server config. 4 | type Server struct { 5 | Name string `json:"name"` 6 | Enable bool `json:"enable"` 7 | IP string `json:"ip"` 8 | Port int `json:"port"` 9 | UID string `json:"uid"` 10 | ScanTime int `json:"scantime"` 11 | MaxFails int `json:"maxfails"` 12 | MaxRestarts int `json:"maxrestarts"` 13 | RestartInt int `json:"restartint"` 14 | ReportOnly bool `json:"reportonly"` 15 | A2STimeout int `json:"a2stimeout"` 16 | Mentions string `json:"mentions"` 17 | ViaAPI bool 18 | Delete bool 19 | } 20 | 21 | // Misc options. 22 | type Misc struct { 23 | Type string `json:"type"` 24 | Data interface{} `json:"data"` 25 | } 26 | 27 | // Config struct used for the general config. 28 | type Config struct { 29 | APIURL string `json:"apiurl"` 30 | Token string `json:"token"` 31 | AppToken string `json:"apptoken"` 32 | AddServers bool `json:"addservers"` 33 | DebugLevel int `json:"debug"` 34 | ReloadTime int `json:"reloadtime"` 35 | DefEnable bool `json:"defenable"` 36 | DefScanTime int `json:"defscantime"` 37 | DefMaxFails int `json:"defmaxfails"` 38 | DefMaxRestarts int `json:"defmaxrestarts"` 39 | DefRestartInt int `json:"defrestartint"` 40 | DefReportOnly bool `json:"defreportonly"` 41 | DefA2STimeout int `json:"defa2stimeout"` 42 | DefMentions string `json:"defmentions"` 43 | Servers []Server `json:"servers"` 44 | Misc []Misc `json:"misc"` 45 | ConfLoc string 46 | } 47 | -------------------------------------------------------------------------------- /internal/misc/webhook.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 11 | ) 12 | 13 | type AllowMentions struct { 14 | Roles bool `json:"roles"` 15 | Users bool `json:"users"` 16 | } 17 | 18 | type DiscordWH struct { 19 | Contents string `json:"content"` 20 | Username string `json:"username"` 21 | AvatarURL string `json:"avatarurl"` 22 | } 23 | 24 | type SlackWH struct { 25 | Text string `json:"text"` 26 | } 27 | 28 | func DiscordWebHook(url string, contents string, username string, avatarurl string, allowmentions AllowMentions, srv *config.Server) bool { 29 | var data DiscordWH 30 | 31 | // Build out JSON/form data for Discord web hook. 32 | data.Contents = contents 33 | data.Username = username 34 | data.AvatarURL = avatarurl 35 | 36 | // Convert interface to JSON data string. 37 | datajson, err := json.Marshal(data) 38 | 39 | if err != nil { 40 | fmt.Println(err) 41 | 42 | return false 43 | } 44 | 45 | // Setup HTTP POST request with form data. 46 | client := &http.Client{Timeout: time.Second * 5} 47 | req, _ := http.NewRequest("POST", url, bytes.NewBuffer(datajson)) 48 | 49 | // Set content type to JSON. 50 | req.Header.Set("Content-Type", "application/json") 51 | 52 | // Perform HTTP request and check for errors. 53 | resp, err := client.Do(req) 54 | 55 | if err != nil { 56 | fmt.Println(err) 57 | 58 | return false 59 | } 60 | 61 | resp.Body.Close() 62 | 63 | return true 64 | } 65 | 66 | func SlackWebHook(url string, contents string) bool { 67 | var data SlackWH 68 | 69 | // Build out JSON/form data for Slack web hook. 70 | data.Text = contents 71 | 72 | // Convert interface to JSON data string. 73 | datajson, err := json.Marshal(data) 74 | 75 | if err != nil { 76 | fmt.Println(err) 77 | 78 | return false 79 | } 80 | 81 | // Setup HTTP POST request with form data. 82 | client := &http.Client{Timeout: time.Second * 5} 83 | req, _ := http.NewRequest("POST", url, bytes.NewBuffer(datajson)) 84 | 85 | // Set content type to JSON. 86 | req.Header.Set("Content-Type", "application/json") 87 | 88 | // Perform HTTP request and check for errors. 89 | resp, err := client.Do(req) 90 | 91 | if err != nil { 92 | fmt.Println(err) 93 | 94 | return false 95 | } 96 | 97 | resp.Body.Close() 98 | 99 | return true 100 | } 101 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "strconv" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/pterodactyl" 13 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/servers" 14 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/update" 15 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 16 | ) 17 | 18 | func main() { 19 | // Look for 'cfg' flag in command line arguments (default path: /etc/pterowatch/pterowatch.conf). 20 | configFile := flag.String("cfg", "/etc/pterowatch/pterowatch.conf", "The path to the Pterowatch config file.") 21 | flag.Parse() 22 | 23 | // Create config struct. 24 | cfg := config.Config{} 25 | 26 | // Set config defaults. 27 | cfg.SetDefaults() 28 | 29 | // Attempt to read config. 30 | err := cfg.ReadConfig(*configFile) 31 | 32 | // If we have no config, create the file with the defaults. 33 | if err != nil { 34 | // If there's an error and it contains "no such file", try to create the file with defaults. 35 | if strings.Contains(err.Error(), "no such file") { 36 | err = cfg.WriteDefaultsToFile(*configFile) 37 | 38 | if err != nil { 39 | fmt.Println("Failed to open config file and cannot create file.") 40 | fmt.Println(err) 41 | 42 | os.Exit(1) 43 | } 44 | } 45 | 46 | fmt.Println("WARNING - No config file found. Created config file at " + *configFile + " with defaults.") 47 | } else { 48 | // Check if we want to automatically add servers. 49 | if cfg.AddServers { 50 | pterodactyl.AddServers(&cfg) 51 | } 52 | } 53 | 54 | // Level 1 debug. 55 | if cfg.DebugLevel > 0 { 56 | fmt.Println("[D1] Found config with API URL => " + cfg.APIURL + ". Token => " + cfg.Token + ". App Token => " + cfg.AppToken + ". Auto Add Servers => " + strconv.FormatBool(cfg.AddServers) + ". Debug level => " + strconv.Itoa(cfg.DebugLevel) + ". Reload time => " + strconv.Itoa(cfg.ReloadTime)) 57 | } 58 | 59 | // Level 2 debug. 60 | if cfg.DebugLevel > 1 { 61 | fmt.Println("[D2] Config default server values. Enable => " + strconv.FormatBool(cfg.DefEnable) + ". Scan time => " + strconv.Itoa(cfg.DefScanTime) + ". Max Fails => " + strconv.Itoa(cfg.DefMaxFails) + ". Max Restarts => " + strconv.Itoa(cfg.DefMaxRestarts) + ". Restart Interval => " + strconv.Itoa(cfg.DefRestartInt) + ". Report Only => " + strconv.FormatBool(cfg.DefReportOnly) + ". A2S Timeout => " + strconv.Itoa(cfg.DefA2STimeout) + ". Mentions => " + cfg.DefMentions + ".") 62 | } 63 | 64 | // Handle all servers (create timers, etc.). 65 | servers.HandleServers(&cfg, false) 66 | 67 | // Set config file for use later (e.g. updating/reloading). 68 | cfg.ConfLoc = *configFile 69 | 70 | // Initialize updater/reloader. 71 | update.Init(&cfg) 72 | 73 | // Signal. 74 | sigc := make(chan os.Signal, 1) 75 | signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) 76 | <-sigc 77 | } 78 | -------------------------------------------------------------------------------- /internal/misc/options.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "math/big" 7 | "strconv" 8 | 9 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 10 | ) 11 | 12 | func HandleMisc(cfg *config.Config, srv *config.Server, fails int, restarts int) { 13 | // Look for Misc options. 14 | if len(cfg.Misc) > 0 { 15 | for i, v := range cfg.Misc { 16 | // Level 2 debug. 17 | if cfg.DebugLevel > 1 { 18 | fmt.Println("[D2] Loading misc option #" + strconv.Itoa(i) + " with type " + v.Type + ".") 19 | } 20 | 21 | // Handle web hooks. 22 | if v.Type == "webhook" { 23 | // Set defaults. 24 | contentpre := "**SERVER DOWN**\n- **Name** => {NAME}\n- **IP** => {IP}:{PORT}\n- **Fail Count** => {FAILS}/{MAXFAILS}\n- **Restart Count** => {RESTARTS}/{MAXRESTARTS}\n\nScanning again in *{RESTARTINT}* seconds..." 25 | username := "Pterowatch" 26 | avatarurl := "" 27 | allowedmentions := AllowMentions{ 28 | Roles: false, 29 | Users: false, 30 | } 31 | app := "discord" 32 | 33 | // Look for app override. 34 | if v.Data.(map[string]interface{})["app"] != nil { 35 | app = v.Data.(map[string]interface{})["app"].(string) 36 | } 37 | 38 | // Check for webhook URL. 39 | if v.Data.(map[string]interface{})["url"] == nil { 40 | fmt.Println("[ERR] Web hook ID #" + strconv.Itoa(i) + " has no webhook URL.") 41 | 42 | continue 43 | } 44 | 45 | url := v.Data.(map[string]interface{})["url"].(string) 46 | 47 | // Look for contents override. 48 | if v.Data.(map[string]interface{})["contents"] != nil { 49 | contentpre = v.Data.(map[string]interface{})["contents"].(string) 50 | } 51 | 52 | // Look for username override. 53 | if v.Data.(map[string]interface{})["username"] != nil { 54 | username = v.Data.(map[string]interface{})["username"].(string) 55 | } 56 | 57 | // Look for avatar URL override. 58 | if v.Data.(map[string]interface{})["avatarurl"] != nil { 59 | avatarurl = v.Data.(map[string]interface{})["avatarurl"].(string) 60 | } 61 | 62 | // Look for allowed mentions override. 63 | if v.Data.(map[string]interface{})["mentions"] != nil { 64 | mentdata := v.Data.(map[string]interface{})["mentions"].(map[string]interface{}) 65 | 66 | roles := false 67 | users := false 68 | 69 | if mentdata["roles"] != nil { 70 | roles = mentdata["roles"].(bool) 71 | } 72 | 73 | if mentdata["users"] != nil { 74 | users = mentdata["users"].(bool) 75 | } 76 | 77 | allowedmentions.Roles = roles 78 | allowedmentions.Users = users 79 | } 80 | 81 | // Handle mentions. 82 | mentionstr := "" 83 | 84 | if (allowedmentions.Roles || allowedmentions.Users) && len(srv.Mentions) > 0 { 85 | if cfg.DebugLevel > 1 { 86 | fmt.Println("[D2] Parsing mention data for " + srv.UID + " ( " + srv.Name + ").") 87 | } 88 | 89 | var mentdata interface{} 90 | 91 | err := json.Unmarshal([]byte(srv.Mentions), &mentdata) 92 | 93 | if cfg.DebugLevel > 3 { 94 | fmt.Println("[D4] Mention JSON => " + srv.Mentions + ".") 95 | } 96 | 97 | if err != nil { 98 | fmt.Println("[ERR] Failed to parse JSON mention data for server " + srv.UID + " (" + srv.Name + ").") 99 | fmt.Println(err) 100 | 101 | goto skipment 102 | } 103 | 104 | if mentdata.(map[string]interface{})["data"] == nil { 105 | fmt.Println("[ERR] Mentions string missing data list for " + srv.UID + " (" + srv.Name + ").") 106 | 107 | goto skipment 108 | } 109 | 110 | if _, ok := mentdata.(map[string]interface{})["data"].([]interface{}); !ok { 111 | fmt.Println("[ERR] Mentions string's data item not a list for " + srv.UID + " (" + srv.Name + ").") 112 | 113 | goto skipment 114 | } 115 | 116 | len := len(mentdata.(map[string]interface{})["data"].([]interface{})) 117 | 118 | // Loop through each item. 119 | for i, m := range mentdata.(map[string]interface{})["data"].([]interface{}) { 120 | item := m.(map[string]interface{}) 121 | 122 | // Check to ensure we have both elements/items. 123 | if item["role"] == nil || item["id"] == nil { 124 | continue 125 | } 126 | 127 | // Check types. 128 | if _, ok := item["role"].(bool); !ok { 129 | fmt.Println("[ERR] Mentions string's role field is not a boolean for " + srv.UID + " (" + srv.Name + ").") 130 | 131 | continue 132 | } 133 | 134 | if _, ok := item["id"].(string); !ok { 135 | fmt.Println("[Err] Mentions string's id field is not a string for " + srv.UID + " (" + srv.Name + ").") 136 | 137 | continue 138 | } 139 | 140 | // For security, we want to parse the values as big integers (float64 is also too small for IDs). 141 | id := big.Int{} 142 | id.SetString(item["id"].(string), 10) 143 | 144 | // Check for role. 145 | if item["role"].(bool) && allowedmentions.Roles { 146 | mentionstr += "<@&" + id.String() + ">" 147 | } 148 | 149 | // Check for user. 150 | if !item["role"].(bool) && allowedmentions.Users { 151 | mentionstr += "<@" + id.String() + ">" 152 | } 153 | 154 | // Check to see if we need a comma. 155 | if i != (len - 1) { 156 | mentionstr += ", " 157 | } 158 | } 159 | 160 | if cfg.DebugLevel > 3 { 161 | fmt.Println("[D4] Mention string => " + mentionstr + " for " + srv.UID + " (" + srv.Name + ").") 162 | } 163 | } 164 | 165 | skipment: 166 | 167 | // Replace variables in strings. 168 | contents := contentpre 169 | FormatContents(app, &contents, fails, restarts, srv, mentionstr) 170 | 171 | // Level 3 debug. 172 | if cfg.DebugLevel > 2 { 173 | fmt.Println("[D3] Loaded web hook with App => " + app + ". URL => " + url + ". Contents => " + contents + ". Username => " + username + ". Avatar URL => " + avatarurl + ". Mentions => Roles:" + strconv.FormatBool(allowedmentions.Roles) + "; Users:" + strconv.FormatBool(allowedmentions.Users) + ".") 174 | } 175 | 176 | // Submit web hook. 177 | if app == "slack" { 178 | SlackWebHook(url, contents) 179 | } else { 180 | DiscordWebHook(url, contents, username, avatarurl, allowedmentions, srv) 181 | } 182 | } 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /internal/servers/servers.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/events" 10 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/pterodactyl" 11 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/query" 12 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 13 | ) 14 | 15 | var tickers []TickerHolder 16 | 17 | // Timer function. 18 | func ServerWatch(srv *config.Server, timer *time.Ticker, fails *int, restarts *int, nextscan *int64, conn *net.UDPConn, cfg *config.Config, destroy *chan bool) { 19 | for { 20 | select { 21 | case <-timer.C: 22 | // If the UDP connection or server is nil, break the timer. 23 | if conn == nil || srv == nil { 24 | *destroy <- true 25 | 26 | break 27 | } 28 | 29 | // Check if server is enabled. 30 | if !srv.Enable { 31 | continue 32 | } 33 | 34 | // Check if container status is 'on'. 35 | if !pterodactyl.CheckStatus(cfg, srv.UID) { 36 | continue 37 | } 38 | 39 | // Send A2S_INFO request. 40 | query.SendRequest(conn) 41 | 42 | if cfg.DebugLevel > 2 { 43 | fmt.Println("[D3][" + srv.IP + ":" + strconv.Itoa(srv.Port) + "] A2S_INFO sent (" + srv.Name + ").") 44 | } 45 | 46 | // Check for response. If no response, increase fail count. Otherwise, reset fail count to 0. 47 | if !query.CheckResponse(conn, *srv) { 48 | // Increase fail count. 49 | *fails++ 50 | 51 | if cfg.DebugLevel > 1 { 52 | fmt.Println("[D2][" + srv.IP + ":" + strconv.Itoa(srv.Port) + "] Fails => " + strconv.Itoa(*fails)) 53 | } 54 | 55 | // Check to see if we want to restart the server. 56 | if *fails >= srv.MaxFails && *restarts < srv.MaxRestarts && *nextscan < time.Now().Unix() { 57 | // Check if we want to restart the container. 58 | if !srv.ReportOnly { 59 | // Attempt to kill container. 60 | pterodactyl.KillServer(cfg, srv.UID) 61 | 62 | // Now attempt to start it again. 63 | pterodactyl.StartServer(cfg, srv.UID) 64 | } 65 | 66 | // Increment restarts count. 67 | *restarts++ 68 | 69 | // Set next scan time and ensure the restart interval is at least 1. 70 | restartint := srv.RestartInt 71 | 72 | if restartint < 1 { 73 | restartint = 120 74 | } 75 | 76 | // Get new scan time. 77 | *nextscan = time.Now().Unix() + int64(restartint) 78 | 79 | // Debug. 80 | if cfg.DebugLevel > 0 { 81 | fmt.Println("[D1][" + srv.IP + ":" + strconv.Itoa(srv.Port) + "] Server found down. Report Only => " + strconv.FormatBool(srv.ReportOnly) + ". Fail Count => " + strconv.Itoa(*fails) + ". Restart Count => " + strconv.Itoa(*restarts) + " (" + srv.Name + ").") 82 | } 83 | 84 | events.OnServerDown(cfg, srv, *fails, *restarts) 85 | } 86 | } else { 87 | // Reset everything. 88 | *fails = 0 89 | *restarts = 0 90 | *nextscan = 0 91 | } 92 | 93 | case <-*destroy: 94 | // Close UDP connection and check. 95 | err := conn.Close() 96 | 97 | if err != nil { 98 | fmt.Println("[ERR] Failed to close UDP connection.") 99 | fmt.Println(err) 100 | } 101 | 102 | // Stop timer/ticker. 103 | timer.Stop() 104 | 105 | // Stop function. 106 | return 107 | } 108 | } 109 | } 110 | 111 | func HandleServers(cfg *config.Config, update bool) { 112 | stats := make(map[Tuple]Stats) 113 | 114 | // Retrieve current server stats before removing tickers 115 | for _, srvticker := range tickers { 116 | for _, srv := range cfg.Servers { 117 | // Create tuple. 118 | var srvt Tuple 119 | srvt.IP = srv.IP 120 | srvt.Port = srv.Port 121 | srvt.UID = srv.UID 122 | 123 | if cfg.DebugLevel > 4 { 124 | fmt.Println("[D5] HandleServers :: Comparing " + srvt.IP + ":" + strconv.Itoa(srvt.Port) + ":" + srvt.UID + " == " + srv.IP + ":" + strconv.Itoa(srv.Port) + ":" + srv.UID + ".") 125 | } 126 | 127 | if srvt == srvticker.Info { 128 | if cfg.DebugLevel > 3 { 129 | fmt.Println("[D4] HandleServers :: Found match on " + srvt.IP + ":" + strconv.Itoa(srvt.Port) + ":" + srvt.UID + ".") 130 | } 131 | 132 | // Fill in stats. 133 | stats[srvt] = Stats{ 134 | Fails: srvticker.Stats.Fails, 135 | Restarts: srvticker.Stats.Restarts, 136 | NextScan: srvticker.Stats.NextScan, 137 | } 138 | 139 | } 140 | } 141 | 142 | // Destroy ticker. 143 | *srvticker.Destroyer <- true 144 | } 145 | 146 | // Remove servers that should be deleted. 147 | for i, srv := range cfg.Servers { 148 | if srv.Delete { 149 | if cfg.DebugLevel > 1 { 150 | fmt.Println("[D2] Found server that should be deleted UID => " + srv.UID + ". Name => " + srv.Name + ". IP => " + srv.IP + ". Port => " + strconv.Itoa(srv.Port) + ".") 151 | } 152 | 153 | RemoveServer(cfg, i) 154 | } 155 | } 156 | 157 | tickers = []TickerHolder{} 158 | 159 | // Loop through each container from the config. 160 | for i, srv := range cfg.Servers { 161 | // If we're not enabled, ignore. 162 | if !srv.Enable { 163 | continue 164 | } 165 | 166 | // Create tuple. 167 | var srvt Tuple 168 | srvt.IP = srv.IP 169 | srvt.Port = srv.Port 170 | srvt.UID = srv.UID 171 | 172 | // Specify server-specific variables 173 | var fails int = 0 174 | var restarts int = 0 175 | var nextscan int64 = 0 176 | 177 | // Replace stats with old ticker's stats. 178 | if stat, ok := stats[srvt]; ok { 179 | fails = *stat.Fails 180 | restarts = *stat.Restarts 181 | nextscan = *stat.NextScan 182 | } 183 | 184 | if cfg.DebugLevel > 0 && !update { 185 | fmt.Println("[D1] Adding server " + srv.IP + ":" + strconv.Itoa(srv.Port) + " with UID " + srv.UID + ". Auto Add => " + strconv.FormatBool(srv.ViaAPI) + ". Scan time => " + strconv.Itoa(srv.ScanTime) + ". Max Fails => " + strconv.Itoa(srv.MaxFails) + ". Max Restarts => " + strconv.Itoa(srv.MaxRestarts) + ". Restart Interval => " + strconv.Itoa(srv.RestartInt) + ". Report Only => " + strconv.FormatBool(srv.ReportOnly) + ". Enabled => " + strconv.FormatBool(srv.Enable) + ". Name => " + srv.Name + ". A2S Timeout => " + strconv.Itoa(srv.A2STimeout) + ". Mentions => " + srv.Mentions + ".") 186 | } 187 | 188 | // Get scan time. 189 | stime := srv.ScanTime 190 | 191 | if stime < 1 { 192 | stime = 5 193 | } 194 | 195 | // Let's create the connection now. 196 | conn, err := query.CreateConnection(srv.IP, srv.Port) 197 | 198 | if err != nil { 199 | fmt.Println("Error creating UDP connection for " + srv.IP + ":" + strconv.Itoa(srv.Port) + " ( " + srv.Name + ").") 200 | fmt.Println(err) 201 | 202 | continue 203 | } 204 | 205 | if cfg.DebugLevel > 3 { 206 | fmt.Println("[D4] Creating timer for " + srv.IP + ":" + strconv.Itoa(srv.Port) + ":" + srv.UID + " (" + srv.Name + ").") 207 | } 208 | 209 | // Create destroyer channel. 210 | destroyer := make(chan bool) 211 | 212 | // Create repeating timer. 213 | ticker := time.NewTicker(time.Duration(stime) * time.Second) 214 | go ServerWatch(&cfg.Servers[i], ticker, &fails, &restarts, &nextscan, conn, cfg, &destroyer) 215 | 216 | // Add ticker to global list. 217 | var newticker TickerHolder 218 | newticker.Info = srvt 219 | newticker.Ticker = ticker 220 | newticker.Conn = conn 221 | newticker.ScanTime = stime 222 | newticker.Destroyer = &destroyer 223 | newticker.Stats.Fails = &fails 224 | newticker.Stats.Restarts = &restarts 225 | newticker.Stats.NextScan = &nextscan 226 | 227 | tickers = append(tickers, newticker) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /internal/update/update.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/pterodactyl" 9 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/internal/servers" 10 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 11 | ) 12 | 13 | type Tuple struct { 14 | IP string 15 | Port int 16 | UID string 17 | } 18 | 19 | var updateticker *time.Ticker 20 | 21 | func AddNewServers(newcfg *config.Config, cfg *config.Config) { 22 | // Loop through all new servers. 23 | for i, newsrv := range newcfg.Servers { 24 | if cfg.DebugLevel > 3 { 25 | fmt.Println("[D4] Looking for " + newsrv.IP + ":" + strconv.Itoa(newsrv.Port) + ":" + newsrv.UID + " (" + strconv.Itoa(i) + ") inside of old configuration.") 26 | } 27 | 28 | toadd := true 29 | 30 | // Now loop through old servers. 31 | for j, oldsrv := range cfg.Servers { 32 | // Create new server tuple. 33 | var nt Tuple 34 | nt.IP = newsrv.IP 35 | nt.Port = newsrv.Port 36 | nt.UID = newsrv.UID 37 | 38 | // Create old server tuple. 39 | var ot Tuple 40 | ot.IP = oldsrv.IP 41 | ot.Port = oldsrv.Port 42 | ot.UID = oldsrv.UID 43 | 44 | if cfg.DebugLevel > 4 { 45 | fmt.Println("[D5] Comparing " + nt.IP + ":" + strconv.Itoa(nt.Port) + ":" + nt.UID + " == " + ot.IP + ":" + strconv.Itoa(ot.Port) + ":" + ot.UID + " (" + strconv.Itoa(j) + ").") 46 | } 47 | 48 | // Now compare. 49 | if nt == ot { 50 | // We don't have to insert this server into the slice. 51 | toadd = false 52 | 53 | if cfg.DebugLevel > 2 { 54 | fmt.Println("[D3] Found matching server (" + newsrv.IP + ":" + strconv.Itoa(newsrv.Port) + ":" + newsrv.UID + ") on Add Server check. Applying new configuration. Name => " + newsrv.Name + ". Enabled: " + strconv.FormatBool(oldsrv.Enable) + " => " + strconv.FormatBool(newsrv.Enable) + ". Max fails: " + strconv.Itoa(oldsrv.MaxFails) + " => " + strconv.Itoa(newsrv.MaxFails) + ". Max Restarts: " + strconv.Itoa(oldsrv.MaxRestarts) + " => " + strconv.Itoa(newsrv.MaxRestarts) + ". Restart Int: " + strconv.Itoa(oldsrv.RestartInt) + " => " + strconv.Itoa(newsrv.RestartInt) + ". Scan Time: " + strconv.Itoa(oldsrv.ScanTime) + " => " + strconv.Itoa(newsrv.ScanTime) + ". Report Only: " + strconv.FormatBool(oldsrv.ReportOnly) + " => " + strconv.FormatBool(newsrv.ReportOnly) + ". A2S Timeout: " + strconv.Itoa(oldsrv.A2STimeout) + " => " + strconv.Itoa(newsrv.A2STimeout) + ". Mentions: " + oldsrv.Mentions + " => " + newsrv.Mentions + ".") 55 | } 56 | 57 | // Update specific configuration. 58 | cfg.Servers[j].Enable = newsrv.Enable 59 | cfg.Servers[j].MaxFails = newsrv.MaxFails 60 | cfg.Servers[j].MaxRestarts = newsrv.MaxRestarts 61 | cfg.Servers[j].RestartInt = newsrv.RestartInt 62 | cfg.Servers[j].ScanTime = newsrv.ScanTime 63 | cfg.Servers[j].ReportOnly = newsrv.ReportOnly 64 | cfg.Servers[j].A2STimeout = newsrv.A2STimeout 65 | cfg.Servers[j].Mentions = newsrv.Mentions 66 | } 67 | } 68 | 69 | // If we're not inside of the current configuration, add the server. 70 | if toadd { 71 | if cfg.DebugLevel > 1 { 72 | fmt.Println("[D2] Adding server from update " + newsrv.IP + ":" + strconv.Itoa(newsrv.Port) + " with UID " + newsrv.UID + ". Name => " + newsrv.Name + ". Auto Add => " + strconv.FormatBool(newsrv.ViaAPI) + ". Scan time => " + strconv.Itoa(newsrv.ScanTime) + ". Max Fails => " + strconv.Itoa(newsrv.MaxFails) + ". Max Restarts => " + strconv.Itoa(newsrv.MaxRestarts) + ". Restart Interval => " + strconv.Itoa(newsrv.RestartInt) + ". Enabled => " + strconv.FormatBool(newsrv.Enable) + ". A2S Timeout => " + strconv.Itoa(newsrv.A2STimeout) + ". Mentions => " + newsrv.Mentions + ".") 73 | } 74 | 75 | cfg.Servers = append(cfg.Servers, newsrv) 76 | } 77 | } 78 | } 79 | 80 | func DelOldServers(newcfg *config.Config, cfg *config.Config) { 81 | // Loop through all old servers. 82 | for i, oldsrv := range cfg.Servers { 83 | if cfg.DebugLevel > 3 { 84 | fmt.Println("[D4] Looking for " + oldsrv.IP + ":" + strconv.Itoa(oldsrv.Port) + ":" + oldsrv.UID + " (" + strconv.Itoa(i) + ") inside of new configuration.") 85 | } 86 | 87 | todel := true 88 | 89 | // Now loop through new servers. 90 | for j, newsrv := range newcfg.Servers { 91 | // Create old server tuple. 92 | var ot Tuple 93 | ot.IP = oldsrv.IP 94 | ot.Port = oldsrv.Port 95 | ot.UID = oldsrv.UID 96 | 97 | // Create new server tuple. 98 | var nt Tuple 99 | nt.IP = newsrv.IP 100 | nt.Port = newsrv.Port 101 | nt.UID = newsrv.UID 102 | 103 | if cfg.DebugLevel > 4 { 104 | fmt.Println("[D5] Comparing " + ot.IP + ":" + strconv.Itoa(ot.Port) + ":" + ot.UID + " == " + nt.IP + ":" + strconv.Itoa(nt.Port) + ":" + nt.UID + " (" + strconv.Itoa(j) + ").") 105 | } 106 | 107 | // Now compare. 108 | if nt == ot { 109 | todel = false 110 | } 111 | } 112 | 113 | // If we're not inside of the new configuration, delete the server. 114 | if todel { 115 | if cfg.DebugLevel > 1 { 116 | fmt.Println("[D2] Deleting server from update " + oldsrv.IP + ":" + strconv.Itoa(oldsrv.Port) + " with UID " + oldsrv.UID + ". Name => " + oldsrv.Name + ".") 117 | } 118 | 119 | // Set Delete to true so we'll delete the server, close the connection, etc. on the next scan. 120 | cfg.Servers[i].Delete = true 121 | } 122 | } 123 | } 124 | 125 | func ReloadServers(timer *time.Ticker, cfg *config.Config) { 126 | destroy := make(chan struct{}) 127 | 128 | for { 129 | select { 130 | case <-timer.C: 131 | // First, we'll want to read the new config. 132 | newcfg := config.Config{} 133 | 134 | // Set default values. 135 | newcfg.SetDefaults() 136 | 137 | err := newcfg.ReadConfig(cfg.ConfLoc) 138 | 139 | if err != nil { 140 | fmt.Println(err) 141 | 142 | continue 143 | } 144 | 145 | if newcfg.AddServers { 146 | cont := pterodactyl.AddServers(&newcfg) 147 | 148 | if !cont { 149 | fmt.Println("[ERR] Not updating server list due to error.") 150 | 151 | continue 152 | } 153 | } 154 | 155 | // Assign new values. 156 | cfg.APIURL = newcfg.APIURL 157 | cfg.Token = newcfg.Token 158 | cfg.DebugLevel = newcfg.DebugLevel 159 | cfg.AddServers = newcfg.AddServers 160 | 161 | cfg.DefEnable = newcfg.DefEnable 162 | cfg.DefScanTime = newcfg.DefScanTime 163 | cfg.DefMaxFails = newcfg.DefMaxFails 164 | cfg.DefMaxRestarts = newcfg.DefMaxRestarts 165 | cfg.DefRestartInt = newcfg.DefRestartInt 166 | cfg.DefReportOnly = newcfg.DefReportOnly 167 | 168 | // If reload time is different, recreate reload timer. 169 | if cfg.ReloadTime != newcfg.ReloadTime { 170 | if updateticker != nil { 171 | updateticker.Stop() 172 | } 173 | 174 | if cfg.DebugLevel > 2 { 175 | fmt.Println("[D3] Recreating update timer due to updated reload time (" + strconv.Itoa(cfg.ReloadTime) + " => " + strconv.Itoa(newcfg.ReloadTime) + ").") 176 | } 177 | 178 | // Create repeating timer. 179 | updateticker = time.NewTicker(time.Duration(newcfg.ReloadTime) * time.Second) 180 | go ReloadServers(updateticker, cfg) 181 | } 182 | 183 | cfg.ReloadTime = newcfg.ReloadTime 184 | 185 | // Level 2 debug message. 186 | if cfg.DebugLevel > 1 { 187 | fmt.Println("[D2] Updating server list.") 188 | } 189 | 190 | // Add new servers. 191 | AddNewServers(&newcfg, cfg) 192 | 193 | // Remove servers that are not a part of new configuration. 194 | DelOldServers(&newcfg, cfg) 195 | 196 | // Now rehandle servers. 197 | servers.HandleServers(cfg, true) 198 | 199 | case <-destroy: 200 | timer.Stop() 201 | 202 | return 203 | } 204 | } 205 | } 206 | 207 | func Init(cfg *config.Config) { 208 | if cfg.ReloadTime < 1 { 209 | return 210 | } 211 | 212 | if cfg.DebugLevel > 0 { 213 | fmt.Println("[D1] Setting up reload timer for every " + strconv.Itoa(cfg.ReloadTime) + " seconds.") 214 | } 215 | 216 | // Create repeating timer. 217 | updateticker = time.NewTicker(time.Duration(cfg.ReloadTime) * time.Second) 218 | go ReloadServers(updateticker, cfg) 219 | } 220 | -------------------------------------------------------------------------------- /internal/pterodactyl/pterodactyl.go: -------------------------------------------------------------------------------- 1 | package pterodactyl 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/gamemann/Pterodactyl-Game-Server-Watch/pkg/config" 9 | pteroapi "github.com/gamemann/Rust-Auto-Wipe/pkg/pterodactyl" 10 | ) 11 | 12 | // Attributes struct from /api/client/servers/xxxx/resources. 13 | type Attributes struct { 14 | State string `json:"current_state"` 15 | } 16 | 17 | // Utilization struct from /api/client/servers/xxxx/resources. 18 | type Utilization struct { 19 | Attributes Attributes `json:"attributes"` 20 | } 21 | 22 | // Retrieves all servers/containers from Pterodactyl API and add them to the config. 23 | func AddServers(cfg *config.Config) bool { 24 | // Retrieve max page count. 25 | pagecount := 1 26 | maxpages := 1 27 | total := 0 28 | done := false 29 | 30 | for done != true { 31 | body, _, err := pteroapi.SendAPIRequest(cfg.APIURL, cfg.AppToken, "GET", "application/servers?page="+strconv.Itoa(pagecount)+"&include=allocations,variables", nil) 32 | 33 | if err != nil { 34 | fmt.Println(err) 35 | 36 | return false 37 | } 38 | 39 | // Create data interface. 40 | var dataobj interface{} 41 | 42 | // Parse JSON. 43 | err = json.Unmarshal([]byte(string(body)), &dataobj) 44 | 45 | if err != nil { 46 | fmt.Println(err) 47 | 48 | return false 49 | } 50 | 51 | // Look for object item before anything. 52 | if dataobj.(map[string]interface{})["object"] == nil { 53 | fmt.Println("[ERR] 'object' item not found when listing all servers.") 54 | 55 | fmt.Println(string(body)) 56 | 57 | return false 58 | } 59 | 60 | // Retrieve max page count and total count. 61 | maxpages = int(dataobj.(map[string]interface{})["meta"].(map[string]interface{})["pagination"].(map[string]interface{})["total_pages"].(float64)) 62 | total = int(dataobj.(map[string]interface{})["meta"].(map[string]interface{})["pagination"].(map[string]interface{})["total"].(float64)) 63 | 64 | // Loop through each data item (server). 65 | for _, j := range dataobj.(map[string]interface{})["data"].([]interface{}) { 66 | item := j.(map[string]interface{}) 67 | 68 | // Make sure we have a server object. 69 | if item["object"] == "server" { 70 | attr := item["attributes"].(map[string]interface{}) 71 | 72 | // Build new server structure. 73 | var sta config.Server 74 | 75 | // Set UID (in this case, identifier) and default values. 76 | sta.ViaAPI = true 77 | sta.UID = attr["identifier"].(string) 78 | sta.Name = attr["name"].(string) 79 | 80 | sta.Enable = cfg.DefEnable 81 | sta.ScanTime = cfg.DefScanTime 82 | sta.MaxFails = cfg.DefMaxFails 83 | sta.MaxRestarts = cfg.DefMaxRestarts 84 | sta.RestartInt = cfg.DefRestartInt 85 | sta.ReportOnly = cfg.DefReportOnly 86 | sta.A2STimeout = cfg.DefA2STimeout 87 | sta.Mentions = cfg.DefMentions 88 | 89 | if attr["relationships"] == nil { 90 | fmt.Println("[ERR] Server has invalid relationships.") 91 | 92 | continue 93 | } 94 | 95 | // Retrieve default IP/port. 96 | for _, i := range attr["relationships"].(map[string]interface{})["allocations"].(map[string]interface{})["data"].([]interface{}) { 97 | if i.(map[string]interface{})["object"].(string) != "allocation" { 98 | continue 99 | } 100 | 101 | alloc := i.(map[string]interface{})["attributes"].(map[string]interface{}) 102 | 103 | if alloc["assigned"].(bool) { 104 | sta.IP = alloc["ip"].(string) 105 | sta.Port = int(alloc["port"].(float64)) 106 | } 107 | } 108 | 109 | // Look for overrides. 110 | if attr["relationships"].(map[string]interface{})["variables"].(map[string]interface{})["data"] != nil { 111 | for _, i := range attr["relationships"].(map[string]interface{})["variables"].(map[string]interface{})["data"].([]interface{}) { 112 | if i.(map[string]interface{})["object"].(string) != "server_variable" { 113 | continue 114 | } 115 | 116 | vari := i.(map[string]interface{})["attributes"].(map[string]interface{}) 117 | 118 | // Check if we have a value. 119 | if vari["server_value"] == nil { 120 | continue 121 | } 122 | 123 | val := vari["server_value"].(string) 124 | 125 | // Override variables should always be at least one byte in length. 126 | if len(val) < 1 { 127 | continue 128 | } 129 | 130 | // Check for IP override. 131 | if vari["env_variable"].(string) == "PTEROWATCH_IP" { 132 | sta.IP = val 133 | } 134 | 135 | // Check for port override. 136 | if vari["env_variable"].(string) == "PTEROWATCH_PORT" { 137 | sta.Port, _ = strconv.Atoi(val) 138 | } 139 | 140 | // Check for scan override. 141 | if vari["env_variable"].(string) == "PTEROWATCH_SCANTIME" { 142 | sta.ScanTime, _ = strconv.Atoi(val) 143 | } 144 | 145 | // Check for max fails override. 146 | if vari["env_variable"].(string) == "PTEROWATCH_MAXFAILS" { 147 | sta.MaxFails, _ = strconv.Atoi(val) 148 | } 149 | 150 | // Check for max restarts override. 151 | if vari["env_variable"].(string) == "PTEROWATCH_MAXRESTARTS" { 152 | sta.MaxRestarts, _ = strconv.Atoi(val) 153 | } 154 | 155 | // Check for restart interval override. 156 | if vari["env_variable"].(string) == "PTEROWATCH_RESTARTINT" { 157 | sta.RestartInt, _ = strconv.Atoi(val) 158 | } 159 | 160 | // Check for A2S_INFO timeout override. 161 | if vari["env_variable"].(string) == "PTEROWATCH_A2STIMEOUT" { 162 | sta.A2STimeout, _ = strconv.Atoi(val) 163 | } 164 | 165 | // Check for mentions override. 166 | if vari["env_variable"].(string) == "PTEROWATCH_MENTIONS" { 167 | sta.Mentions = val 168 | } 169 | 170 | // Check for report only override. 171 | if vari["env_variable"].(string) == "PTEROWATCH_REPORTONLY" { 172 | reportonly, _ := strconv.Atoi(val) 173 | 174 | if reportonly > 0 { 175 | sta.ReportOnly = true 176 | } else { 177 | sta.ReportOnly = false 178 | } 179 | } 180 | 181 | // Check for disable override. 182 | if vari["env_variable"].(string) == "PTEROWATCH_DISABLE" { 183 | disable, _ := strconv.Atoi(val) 184 | 185 | if disable > 0 { 186 | sta.Enable = false 187 | } else { 188 | sta.Enable = true 189 | } 190 | } 191 | } 192 | } 193 | 194 | // Append to servers slice. 195 | cfg.Servers = append(cfg.Servers, sta) 196 | } 197 | } 198 | 199 | // Check page count. 200 | if pagecount >= maxpages { 201 | done = true 202 | 203 | break 204 | } 205 | 206 | pagecount++ 207 | } 208 | 209 | // Level 2 debug. 210 | if cfg.DebugLevel > 1 { 211 | fmt.Println("[D2] Found " + strconv.Itoa(total) + " servers from API (" + strconv.Itoa(maxpages) + " page(s)).") 212 | } 213 | 214 | return true 215 | } 216 | 217 | // Checks the status of a Pterodactyl server. Returns true if on and false if off. 218 | // DOES NOT INCLUDE IN "STARTING" MODE. 219 | func CheckStatus(cfg *config.Config, uid string) bool { 220 | body, _, err := pteroapi.SendAPIRequest(cfg.APIURL, cfg.AppToken, "GET", "client/servers/"+uid+"/resources", nil) 221 | 222 | if err != nil { 223 | fmt.Println(err) 224 | 225 | return false 226 | } 227 | 228 | // Create utilization struct. 229 | var util Utilization 230 | 231 | // Parse JSON. 232 | json.Unmarshal([]byte(string(body)), &util) 233 | 234 | // Check if the server's state isn't on. If not, return false. 235 | if util.Attributes.State != "running" { 236 | return false 237 | } 238 | 239 | // Otherwise, return true meaning the container is online. 240 | return true 241 | } 242 | 243 | // Kills the specified server. 244 | func KillServer(cfg *config.Config, uid string) bool { 245 | form_data := make(map[string]interface{}) 246 | form_data["signal"] = "kill" 247 | 248 | _, _, err := pteroapi.SendAPIRequest(cfg.APIURL, cfg.AppToken, "POST", "client/servers/"+uid+"/"+"power", form_data) 249 | 250 | if err != nil { 251 | fmt.Println(err) 252 | 253 | return false 254 | } 255 | 256 | return true 257 | } 258 | 259 | // Starts the specified server. 260 | func StartServer(cfg *config.Config, uid string) bool { 261 | form_data := make(map[string]interface{}) 262 | form_data["signal"] = "start" 263 | 264 | _, _, err := pteroapi.SendAPIRequest(cfg.APIURL, cfg.AppToken, "POST", "client/servers/"+uid+"/"+"power", form_data) 265 | 266 | if err != nil { 267 | fmt.Println(err) 268 | 269 | return false 270 | } 271 | 272 | return true 273 | } 274 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pterodactyl Game Server Watch 2 | 3 | ## Description 4 | A tool programmed in Go to automatically restart 'hung' (game) servers via the Pterodactyl API (working since version 1.4.2). This only supports servers that respond to the [A2S_INFO](https://developer.valvesoftware.com/wiki/Server_queries#A2S_INFO) query (a Valve Master Server query). I am currently looking for a better way to detect if a server is hung, though. 5 | 6 | ## Command Line Flags 7 | There is only one command line argument/flag and it is `-cfg=`. This argument/flag changes the path to the Pterowatch config file. The default value is `/etc/pterowatch/pterowatch.conf`. 8 | 9 | Examples include: 10 | 11 | ``` 12 | ./pterowatch -cfg=/home/cdeacon/myconf.conf 13 | ./pterowatch -cfg=~/myconf.conf 14 | ./pterowatch -cfg=myconf.conf 15 | ``` 16 | 17 | ## Config File 18 | The config file's default path is `/etc/pterowatch/pterowatch.conf` (this can be changed with a command line argument/flag as seen above). This should be a JSON array including the API URL, token, and an array of servers to check against. The main options are the following: 19 | 20 | * `apiurl` => The Pterodactyl API URL (do not include the `/` at the end). 21 | * `token` => The bearer token (from the client) to use when sending requests to the Pterodactyl API. 22 | * `apptoken` => The bearer token (from the application) to use when sending requests to the Pterodactyl API (this is only needed when `addservers` is set to `true`). 23 | * `debug` => The debug level (1-4). 24 | * `reloadtime` => If above 0, will reload the configuration file and retrieve servers from the API every *x* seconds. 25 | * `addservers` => Whether or not to automatically add servers to the config from the Pterodactyl API. 26 | * `defenable` => The default enable boolean of a server added via the Pterodactyl API. 27 | * `defscantime` => The default scan time of a server added via the Pterodactyl API. 28 | * `defmaxfails` => The default max fails of a server added via the Pterodactyl API. 29 | * `defmaxrestarts` => The default max restarts of a server added via the Pterodactyl API. 30 | * `defrestartint` => The default restart interval of a server added via the Pterodactyl API. 31 | * `defreportonly` => The default report only boolean of a server added via the Pterodactyl API. 32 | * `defmentions` => The default mentions JSON for servers added via the Pterodactyl API. 33 | * `servers` => An array of servers to watch (read below). 34 | * `misc` => An array of misc options (read below). 35 | 36 | ## Egg Variable Overrides 37 | If you have the `addservers` setting set to true (servers are automatically retrieved via the Pterodactyl API), you may use the following egg variables as overrides to the specific server's config. 38 | 39 | * `PTEROWATCH_DISABLE` => If set to above 0, will disable the specific server from the tool. 40 | * `PTEROWATCH_IP` => If not empty, will override the server IP to scan with this value for the specific server. 41 | * `PTEROWATCH_PORT` => If not empty, will override the server port to scan with this value for the specific server. 42 | * `PTEROWATCH_SCANTIME` => If not empty, will override the scan time with this value for the specific server. 43 | * `PTEROWATCH_MAXFAILS` => If not empty, will override the maximum fails with this value for the specific server. 44 | * `PTEROWATCH_MAXRESTARTS` => If not empty, will override the maximum restarts with this value for the specific server. 45 | * `PTEROWATCH_RESTARTINT` => If not empty, will override the restart interval with this value for the specific server. 46 | * `PTEROWATCH_REPORTONLY` => If not empty, will override report only with this value for the specific server. 47 | * `PTEROWATCH_MENTIONS` => If not empty, will override the mentions JSON string with this value for the specific server. 48 | 49 | ## Server Options/Array 50 | This array is used to manually add servers to watch. The `servers` array should contain the following items: 51 | 52 | * `name` => The server's name. 53 | * `enable` => If true, this server will be scanned. 54 | * `ip` => The IP to send A2S_INFO requests to. 55 | * `port` => The port to send A2S_INFO requests to. 56 | * `uid` => The server's Pterodactyl UID. 57 | * `scantime` => How often to scan a game server in seconds. 58 | * `maxfails` => The maximum amount of A2S_INFO response failures before attempting to restart the game server. 59 | * `maxrestarts` => The maximum amount of times we attempt to restart the server until A2S_INFO responses start coming back successfully. 60 | * `restartint` => When a game server is restarted, the program won't start scanning the server until *x* seconds later. 61 | * `reportonly` => If set, only debugging and misc options will be executed when a server is detected as down (e.g. no restart). 62 | * `mentions` => A JSON string that parses all custom role and user mentions inside of web hooks for this server. 63 | 64 | ## Server Mentions Array 65 | The server `mentions` JSON string's parsed JSON output includes a `data` list with each item including a `role` (boolean indicating whether we're mentioning a role) and `id` (the ID of the role or user in string format). 66 | 67 | Here are some examples: 68 | 69 | ```JSON 70 | { 71 | "data": [ 72 | { 73 | "role": true, 74 | "id": "1293959919293959192" 75 | }, 76 | { 77 | "role": false, 78 | "id": "1959192351293954123" 79 | } 80 | ] 81 | } 82 | ``` 83 | 84 | This is what it looks like inside of the mentions string. 85 | 86 | ```JSON 87 | { 88 | "servers": [ 89 | { 90 | "mentions": "{\"data\":[{\"role\": true,\"id\": \"1293959919293959192\"},{\"role\": false,\"id\": \"1959192351293954123\"}]}" 91 | } 92 | ] 93 | } 94 | ``` 95 | 96 | The above will replace the `{MENTIONS}` text inside of the web hook's contents with `<@&1293959919293959192>, <@1959192351293954123>`. 97 | 98 | ## Misc Options/Array 99 | This tool supports misc options which are configured under the `misc` array inside of the config file. The only event supported for this at the moment is when a server is restarted from the tool. However, other events may be added in the future. An example may be found below. 100 | 101 | ```JSON 102 | { 103 | "misc": [ 104 | { 105 | "type": "misctype", 106 | "data": { 107 | "option1": "val1", 108 | "option2": "val2" 109 | } 110 | } 111 | ] 112 | } 113 | ``` 114 | 115 | ### Web Hooks 116 | As of right now, the only misc option `type` is `webhook` which indicates a web hook. The `app` data item represents what type of application the web hook is for (the default value is `discord`). 117 | 118 | Please look at the following data items: 119 | 120 | * `app` => The web hook's application (either `discord` or `slack`). 121 | * `url` => The web hook's URL (**REQUIRED**). 122 | * `contents` => The contents of the web hook. 123 | * `username` => The username the web hook sends as (**only** Discord). 124 | * `avatarurl` => The avatar URL used with the web hook (**only** Discord). 125 | * `mentions` => An array including a `roles` item as a boolean allowing custom role mentions and `users` item as a boolean allowing custom user mentions. 126 | 127 | **Note** - Please copy the full web hook URL including `https://...`. 128 | 129 | #### Variable Replacements For Contents 130 | The following strings are replaced inside of the `contents` string before the web hook submission. 131 | 132 | * `{IP}` => The server's IP. 133 | * `{PORT}` => The server's port. 134 | * `{FAILS}` => The server's current fail count. 135 | * `{RESTARTS}` => The amount of times the server has been restarted since down. 136 | * `{MAXFAILS}` => The server's configured max fails. 137 | * `{MAXRESTARTS}` => The server's configured max restarts. 138 | * `{UID}` => The server's UID from the config file/Pterodactyl API. 139 | * `{SCANTIME}` => The server's configured scan time. 140 | * `{RESTARTINT}` => The server's configured restart interval. 141 | * `{NAME}` => The server's name. 142 | * `{MENTIONS}` => If there are mentions, it will print them in `, ...` format in this replacement. 143 | 144 | #### Defaults 145 | Here are the Discord web hook's default values. 146 | 147 | * `contents` => \*\*SERVER DOWN\*\*\\n- \*\*Name\*\* => {NAME}\\n- \*\*IP\*\* => {IP}:{PORT}\\n- \*\*Fail Count\*\* => {FAILS}/{MAXFAILS}\\n- \*\*Restart Count\*\* => {RESTARTS}/{MAXRESTARTS}\\n\\nScanning again in \*{RESTARTINT}\* seconds... 148 | * `username` => Pterowatch 149 | * `avatarurl` => *empty* (default) 150 | 151 | ## Configuration Example 152 | Here's an configuration example in JSON: 153 | 154 | ```JSON 155 | { 156 | "apiurl": "https://panel.mydomain.com", 157 | "token": "12345", 158 | "addservers": true, 159 | 160 | "servers": [ 161 | { 162 | "enable": true, 163 | "ip": "172.20.0.10", 164 | "port": 27015, 165 | "uid": "testingUID", 166 | "scantime": 5, 167 | "maxfails": 5, 168 | "maxrestarts": 1, 169 | "restartint": 120 170 | }, 171 | { 172 | "enable": true, 173 | "ip": "172.20.0.11", 174 | "port": 27015, 175 | "uid": "testingUID2", 176 | "scantime": 5, 177 | "maxfails": 10, 178 | "maxrestarts": 2, 179 | "restartint": 120 180 | } 181 | ] 182 | } 183 | ``` 184 | 185 | You may find other config examples in the [tests/](https://github.com/gamemann/Pterodactyl-Game-Server-Watch/tree/master/tests) directory. 186 | 187 | ## Building 188 | You may use `git` and `go build` to build this project and produce a binary. I'd suggest cloning this to `$GOPATH` so there aren't problems with linking modules. For example: 189 | 190 | ```bash 191 | # Clone repository. 192 | git clone https://github.com/gamemann/Pterodactyl-Game-Server-Watch.git 193 | 194 | # Change directory to respository. 195 | cd Pterodactyl-Game-Server-Watch/ 196 | 197 | # Build, which should automatically download needed files. 198 | go build -o pgsw 199 | ``` 200 | 201 | ## Using Makefile To Build And Install 202 | You may use `make` and `sudo make install` to build and install the project's executable to `/usr/bin` (in `$PATH` by default normally). This also copies a `Systemd` service called `pgsw.service`. 203 | 204 | See below for examples. 205 | 206 | ```bash 207 | # Build project into `./pgsw` executable. 208 | make 209 | 210 | # Install Systemd service and file to /usr/bin/. 211 | sudo make install 212 | 213 | # Enable and start service (will start on bootup). 214 | sudo systemctl enable --now pgsw 215 | ``` 216 | 217 | ## Credits 218 | * [Christian Deacon](https://github.com/gamemann) - Creator. --------------------------------------------------------------------------------