Add initial code

This commit is contained in:
Jacob Gunther
2022-07-30 22:13:16 -05:00
commit 70601daac0
14 changed files with 1213 additions and 0 deletions

28
src/config.go Normal file
View File

@@ -0,0 +1,28 @@
package main
import (
"io/ioutil"
"time"
"github.com/go-yaml/yaml"
)
type Configuration struct {
Environment string `yaml:"environment"`
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
Redis string `yaml:"redis"`
CacheEnable bool `yaml:"cache_enable"`
StatusCacheTTL time.Duration `yaml:"status_cache_ttl"`
FaviconCacheTTL time.Duration `yaml:"favicon_cache_ttl"`
}
func (c *Configuration) ReadFile(path string) error {
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
return yaml.Unmarshal(data, c)
}

BIN
src/default-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

125
src/main.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"github.com/buaazp/fasthttprouter"
"github.com/valyala/fasthttp"
)
var (
config *Configuration = &Configuration{}
r *Redis = &Redis{}
blockedServers []string = nil
blockedServersMutex *sync.Mutex = &sync.Mutex{}
)
func init() {
if err := config.ReadFile("config.yml"); err != nil {
log.Fatal(err)
}
if err := r.Connect(config.Redis); err != nil {
log.Fatal(err)
}
log.Println("Successfully connected to Redis")
if Contains(os.Args, "--flush-cache") {
keys, err := r.Keys("java:*")
if err != nil {
log.Fatal(err)
}
if err = r.Delete(keys...); err != nil {
log.Fatal(err)
}
keys, err = r.Keys("bedrock:*")
if err != nil {
log.Fatal(err)
}
if err = r.Delete(keys...); err != nil {
log.Fatal(err)
}
keys, err = r.Keys("favicon:*")
if err != nil {
log.Fatal(err)
}
if err = r.Delete(keys...); err != nil {
log.Fatal(err)
}
log.Println("Successfully flushed all cache keys")
os.Exit(0)
}
if instanceID := os.Getenv("INSTANCE_ID"); len(instanceID) > 0 {
value, err := strconv.ParseUint(instanceID, 10, 16)
if err != nil {
log.Fatal(err)
}
config.Port += uint16(value)
}
if err := GetBlockedServerList(); err != nil {
log.Fatal(err)
}
}
func middleware(next fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
if config.Environment == "development" {
log.Printf("GET %s - %s \"%s\"", ctx.Request.URI().Path(), ctx.RemoteAddr(), ctx.UserAgent())
}
ctx.Response.Header.Set("Access-Control-Allow-Headers", "*")
ctx.Response.Header.Set("Access-Control-Allow-Methods", "HEAD,GET,POST,OPTIONS")
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
ctx.Response.Header.Set("Access-Control-Expose-Headers", "X-Cache-Hit,X-Cache-Time-Remaining,X-Server-Status,Content-Disposition")
next(ctx)
}
}
func main() {
defer r.Close()
router := fasthttprouter.New()
router.GET("/ping", PingHandler)
router.GET("/status/java/:address", JavaStatusHandler)
router.GET("/status/bedrock/:address", BedrockStatusHandler)
router.GET("/favicon/:address", FaviconNoExtensionHandler)
router.GET("/favicon/:address/*filename", FaviconHandler)
router.PanicHandler = func(rc *fasthttp.RequestCtx, i interface{}) {
log.Println(i)
}
router.NotFound = func(ctx *fasthttp.RequestCtx) {
WriteError(ctx, nil, http.StatusNotFound)
}
log.Printf("Listening on %s:%d\n", config.Host, config.Port)
log.Fatal(fasthttp.ListenAndServe(fmt.Sprintf("%s:%d", config.Host, config.Port), middleware(router.Handler)))
s := make(chan os.Signal)
signal.Notify(s, os.Interrupt, syscall.SIGTERM)
<-s
}

155
src/redis.go Normal file
View File

@@ -0,0 +1,155 @@
package main
import (
"context"
"errors"
"time"
"github.com/go-redis/redis/v8"
)
type Redis struct {
Conn *redis.Client
}
func (r *Redis) Connect(uri string) error {
opts, err := redis.ParseURL(uri)
if err != nil {
return err
}
conn := redis.NewClient(opts)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
if err = conn.Ping(ctx).Err(); err != nil {
return err
}
r.Conn = conn
return nil
}
func (r *Redis) TTL(key string) (time.Duration, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
val, err := r.Conn.TTL(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return 0, nil
}
return 0, err
}
return val, nil
}
func (r *Redis) Exists(key string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
val, err := r.Conn.Exists(ctx, key).Result()
return val == 1, err
}
func (r *Redis) Get(key string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
val, err := r.Conn.Get(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return "", nil
}
return "", err
}
return val, nil
}
func (r *Redis) GetBytes(key string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
result := r.Conn.Get(ctx, key)
if err := result.Err(); err != nil {
if errors.Is(err, redis.Nil) {
return nil, nil
}
return nil, err
}
return result.Bytes()
}
func (r *Redis) Set(key string, value interface{}, ttl time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
return r.Conn.Set(ctx, key, value, ttl).Err()
}
func (r *Redis) GetValueAndTTL(key string) (bool, string, time.Duration, error) {
exists, err := r.Exists(key)
if err != nil {
return false, "", 0, err
}
if !exists {
return false, "", 0, nil
}
value, err := r.Get(key)
if err != nil {
return false, "", 0, err
}
ttl, err := r.TTL(key)
return true, value, ttl, err
}
func (r *Redis) Keys(pattern string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
res := r.Conn.Keys(ctx, pattern)
if err := res.Err(); err != nil {
return nil, err
}
return res.Result()
}
func (r *Redis) Delete(keys ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
return r.Conn.Del(ctx, keys...).Err()
}
func (r *Redis) Close() error {
return r.Conn.Close()
}

308
src/routes.go Normal file
View File

@@ -0,0 +1,308 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/valyala/fasthttp"
)
func PingHandler(ctx *fasthttp.RequestCtx) {
ctx.SetBodyString(http.StatusText(http.StatusOK))
}
func JavaStatusHandler(ctx *fasthttp.RequestCtx) {
host, port, err := ParseAddress(ctx.UserValue("address").(string), 25565)
if err != nil {
WriteError(ctx, nil, http.StatusBadRequest, "Invalid/malformed address")
return
}
cacheEnabled, cacheKey, err := IsCacheEnabled(ctx, "java", host, port)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if cacheEnabled {
exists, cache, ttl, err := r.GetValueAndTTL(cacheKey)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if exists {
ctx.Response.Header.Set("X-Cache-Hit", "TRUE")
ctx.Response.Header.Set("X-Cache-Time-Remaining", strconv.Itoa(int(ttl.Seconds())))
ctx.SetContentType("application/json")
ctx.SetBodyString(cache)
return
}
}
ctx.Response.Header.Set("X-Cache-Hit", "FALSE")
status := GetJavaStatus(host, port)
if status.Online && status.Response.Favicon != nil {
data, err := base64.StdEncoding.DecodeString(strings.Replace(*status.Response.Favicon, "data:image/png;base64,", "", 1))
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if err := r.Set(fmt.Sprintf("favicon:%s-%d", host, port), data, config.FaviconCacheTTL); err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
}
data, err := json.Marshal(status)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if err = r.Set(cacheKey, data, config.StatusCacheTTL); err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
ctx.SetContentType("application/json")
ctx.SetBody(data)
}
func BedrockStatusHandler(ctx *fasthttp.RequestCtx) {
host, port, err := ParseAddress(ctx.UserValue("address").(string), 19132)
if err != nil {
WriteError(ctx, nil, http.StatusBadRequest, "Invalid/malformed address")
return
}
cacheEnabled, cacheKey, err := IsCacheEnabled(ctx, "bedrock", host, port)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if cacheEnabled {
exists, cache, ttl, err := r.GetValueAndTTL(cacheKey)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if exists {
ctx.Response.Header.Set("X-Cache-Hit", "TRUE")
ctx.Response.Header.Set("X-Cache-Time-Remaining", strconv.Itoa(int(ttl.Seconds())))
ctx.SetContentType("application/json")
ctx.SetBodyString(cache)
return
}
}
ctx.Response.Header.Set("X-Cache-Hit", "FALSE")
data, err := json.Marshal(GetBedrockStatus(host, port))
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if err = r.Set(cacheKey, data, config.StatusCacheTTL); err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
ctx.SetContentType("application/json")
ctx.SetBody(data)
}
func FaviconNoExtensionHandler(ctx *fasthttp.RequestCtx) {
host, port, err := ParseAddress(ctx.UserValue("address").(string), 25565)
if err != nil {
WriteError(ctx, nil, http.StatusBadRequest, "Invalid/malformed address")
return
}
cacheKey := fmt.Sprintf("favicon:%s-%d", host, port)
exists, err := r.Exists(cacheKey)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if exists {
value, err := r.GetBytes(cacheKey)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
ctx.Response.Header.Set("X-Cache-Hit", "TRUE")
ctx.Response.Header.Set("X-Server-Status", "online-cache")
ctx.SetContentType("image/png")
ctx.SetBody(value)
return
}
ctx.Response.Header.Set("X-Cache-Hit", "FALSE")
status := GetJavaStatus(host, port)
if !status.Online {
ctx.Response.Header.Set("X-Server-Status", "offline")
ctx.SetContentType("image/png")
ctx.SetBody(defaultIconBytes)
return
}
if status.Response.Favicon == nil {
ctx.Response.Header.Set("X-Server-Status", "online-no-icon")
ctx.SetContentType("image/png")
ctx.SetBody(defaultIconBytes)
return
}
data, err := base64.StdEncoding.DecodeString(strings.Replace(*status.Response.Favicon, "data:image/png;base64,", "", 1))
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if err := r.Set(cacheKey, data, config.FaviconCacheTTL); err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
ctx.Response.Header.Set("X-Server-Status", "online")
ctx.SetContentType("image/png")
ctx.SetBody(data)
}
func FaviconHandler(ctx *fasthttp.RequestCtx) {
filename := ctx.UserValue("filename").(string)
if !strings.HasSuffix(filename, ".png") {
WriteError(ctx, nil, http.StatusBadRequest, "Filename must end with .png")
return
}
host, port, err := ParseAddress(ctx.UserValue("address").(string), 25565)
if err != nil {
WriteError(ctx, nil, http.StatusBadRequest, "Invalid/malformed address")
return
}
cacheKey := fmt.Sprintf("favicon:%s-%d", host, port)
exists, err := r.Exists(cacheKey)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if exists {
value, err := r.GetBytes(cacheKey)
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
ctx.Response.Header.Set("X-Cache-Hit", "TRUE")
ctx.Response.Header.Set("X-Server-Status", "online-cache")
ctx.Response.Header.Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
ctx.SetContentType("image/png")
ctx.SetBody(value)
return
}
ctx.Response.Header.Set("X-Cache-Hit", "FALSE")
status := GetJavaStatus(host, port)
if !status.Online {
ctx.Response.Header.Set("X-Server-Status", "offline")
ctx.Response.Header.Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
ctx.SetContentType("image/png")
ctx.SetBody(defaultIconBytes)
return
}
if status.Response.Favicon == nil {
ctx.Response.Header.Set("X-Server-Status", "online-no-icon")
ctx.Response.Header.Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
ctx.SetContentType("image/png")
ctx.SetBody(defaultIconBytes)
return
}
data, err := base64.StdEncoding.DecodeString(strings.Replace(*status.Response.Favicon, "data:image/png;base64,", "", 1))
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
if err := r.Set(cacheKey, data, config.FaviconCacheTTL); err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return
}
ctx.Response.Header.Set("X-Server-Status", "online")
ctx.Response.Header.Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
ctx.SetContentType("image/png")
ctx.SetBody(data)
}

251
src/status.go Normal file
View File

@@ -0,0 +1,251 @@
package main
import "github.com/PassTheMayo/mcstatus/v4"
type StatusResponse[T JavaStatus | BedrockStatus] struct {
Online bool `json:"online"`
Host string `json:"host"`
Port uint16 `json:"port"`
EULABlocked bool `json:"eula_blocked"`
Response *T `json:"response"`
}
type JavaStatus struct {
Version *Version `json:"version"`
Players Players `json:"players"`
MOTD MOTD `json:"motd"`
Favicon *string `json:"favicon"`
ModInfo *ModInfo `json:"mod_info"`
SRVRecord *SRVRecord `json:"srv_record"`
}
type Players struct {
Online int `json:"online"`
Max int `json:"max"`
Sample []SamplePlayer `json:"sample"`
}
type SamplePlayer struct {
ID string `json:"id"`
Name string `json:"name"`
Clean string `json:"clean"`
HTML string `json:"html"`
}
type ModInfo struct {
Type string `json:"type"`
Mods []Mod `json:"mods"`
}
type Mod struct {
ID string `json:"id"`
Version string `json:"version"`
}
type BedrockStatus struct {
ServerGUID int64 `json:"server_guid"`
Edition *string `json:"edition"`
MOTD *MOTD `json:"motd"`
ProtocolVersion *int64 `json:"protocol_version"`
Version *string `json:"version"`
OnlinePlayers *int64 `json:"online_players"`
MaxPlayers *int64 `json:"max_players"`
ServerID *string `json:"server_id"`
Gamemode *string `json:"gamemode"`
GamemodeID *int64 `json:"gamemode_id"`
PortIPv4 *uint16 `json:"port_ipv4"`
PortIPv6 *uint16 `json:"port_ipv6"`
SRVRecord *SRVRecord `json:"srv_record"`
}
type MOTD struct {
Raw string `json:"raw"`
Clean string `json:"clean"`
HTML string `json:"html"`
}
type Version struct {
Name string `json:"name"`
Protocol int `json:"protocol"`
}
type SRVRecord struct {
Host string `json:"host"`
Port uint16 `json:"port"`
}
func GetJavaStatus(host string, port uint16) (resp StatusResponse[JavaStatus]) {
status, err := mcstatus.Status(host, port)
if err != nil {
statusLegacy, err := mcstatus.StatusLegacy(host, port)
if err != nil {
resp = StatusResponse[JavaStatus]{
Online: false,
Host: host,
Port: port,
EULABlocked: IsBlockedAddress(host),
Response: nil,
}
return
}
resp = StatusResponse[JavaStatus]{
Online: true,
Host: host,
Port: port,
EULABlocked: IsBlockedAddress(host),
Response: &JavaStatus{
Version: nil,
Players: Players{
Online: statusLegacy.Players.Online,
Max: statusLegacy.Players.Max,
Sample: make([]SamplePlayer, 0),
},
MOTD: MOTD{
Raw: statusLegacy.MOTD.Raw,
Clean: statusLegacy.MOTD.Clean,
HTML: statusLegacy.MOTD.HTML,
},
Favicon: nil,
ModInfo: nil,
SRVRecord: nil,
},
}
if statusLegacy.Version != nil {
resp.Response.Version = &Version{
Name: statusLegacy.Version.Name,
Protocol: statusLegacy.Version.Protocol,
}
}
if statusLegacy.SRVResult != nil {
resp.Response.SRVRecord = &SRVRecord{
Host: statusLegacy.SRVResult.Host,
Port: statusLegacy.SRVResult.Port,
}
}
return
}
samplePlayers := make([]SamplePlayer, 0)
for _, player := range status.Players.Sample {
samplePlayers = append(samplePlayers, SamplePlayer{
ID: player.ID,
Name: player.Name,
Clean: player.Clean,
HTML: player.HTML,
})
}
resp = StatusResponse[JavaStatus]{
Online: true,
Host: host,
Port: port,
EULABlocked: IsBlockedAddress(host),
Response: &JavaStatus{
Version: &Version{
Name: status.Version.Name,
Protocol: status.Version.Protocol,
},
Players: Players{
Online: status.Players.Online,
Max: status.Players.Max,
Sample: samplePlayers,
},
MOTD: MOTD{
Raw: status.MOTD.Raw,
Clean: status.MOTD.Clean,
HTML: status.MOTD.HTML,
},
Favicon: status.Favicon,
ModInfo: nil,
SRVRecord: nil,
},
}
if status.ModInfo != nil {
mods := make([]Mod, 0)
for _, mod := range status.ModInfo.Mods {
mods = append(mods, Mod{
ID: mod.ID,
Version: mod.Version,
})
}
resp.Response.ModInfo = &ModInfo{
Type: status.ModInfo.Type,
Mods: mods,
}
}
if status.SRVResult != nil {
resp.Response.SRVRecord = &SRVRecord{
Host: status.SRVResult.Host,
Port: status.SRVResult.Port,
}
}
return
}
func GetBedrockStatus(host string, port uint16) (resp StatusResponse[BedrockStatus]) {
status, err := mcstatus.StatusBedrock(host, port)
if err != nil {
resp = StatusResponse[BedrockStatus]{
Online: false,
Host: host,
Port: port,
EULABlocked: IsBlockedAddress(host),
Response: nil,
}
return
}
resp = StatusResponse[BedrockStatus]{
Online: true,
Host: host,
Port: port,
EULABlocked: IsBlockedAddress(host),
Response: &BedrockStatus{
ServerGUID: status.ServerGUID,
Edition: status.Edition,
MOTD: nil,
ProtocolVersion: status.ProtocolVersion,
Version: status.Version,
OnlinePlayers: status.OnlinePlayers,
MaxPlayers: status.MaxPlayers,
ServerID: status.ServerID,
Gamemode: status.Gamemode,
GamemodeID: status.GamemodeID,
PortIPv4: status.PortIPv4,
PortIPv6: status.PortIPv6,
SRVRecord: nil,
},
}
if status.MOTD != nil {
resp.Response.MOTD = &MOTD{
Raw: status.MOTD.Raw,
Clean: status.MOTD.Clean,
HTML: status.MOTD.HTML,
}
}
if status.SRVResult != nil {
resp.Response.SRVRecord = &SRVRecord{
Host: status.SRVResult.Host,
Port: status.SRVResult.Port,
}
}
return
}

161
src/util.go Normal file
View File

@@ -0,0 +1,161 @@
package main
import (
"crypto/sha1"
_ "embed"
"encoding/hex"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/valyala/fasthttp"
)
var (
//go:embed default-icon.png
defaultIconBytes []byte
ipAddressRegExp = regexp.MustCompile("^\\d{1,3}(\\.\\d{1,3}){3}$")
)
var (
ErrNoAddressMatch = errors.New("address does not match any known format")
)
func GetBlockedServerList() error {
resp, err := http.Get("https://sessionserver.mojang.com/blockedservers")
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
blockedServersMutex.Lock()
blockedServers = strings.Split(string(body), "\n")
blockedServersMutex.Unlock()
return nil
}
func IsBlockedAddress(address string) bool {
split := strings.Split(strings.ToLower(address), ".")
isIPAddress := ipAddressRegExp.MatchString(address)
for k := range split {
newAddress := ""
switch k {
case 0:
{
newAddress = strings.Join(split, ".")
break
}
default:
{
if isIPAddress {
newAddress = fmt.Sprintf("%s.*", strings.Join(split[0:len(split)-k], "."))
} else {
newAddress = fmt.Sprintf("*.%s", strings.Join(split[k:], "."))
}
break
}
}
newAddressBytes := sha1.Sum([]byte(newAddress))
newAddressHash := hex.EncodeToString(newAddressBytes[:])
blockedServersMutex.Lock()
if Contains(blockedServers, newAddressHash) {
blockedServersMutex.Unlock()
return true
}
blockedServersMutex.Unlock()
}
return false
}
func ParseAddress(address string, defaultPort uint16) (string, uint16, error) {
result := strings.SplitN(address, ":", 2)
if len(result) < 1 {
return "", 0, ErrNoAddressMatch
}
if len(result) < 2 {
return result[0], defaultPort, nil
}
port, err := strconv.ParseUint(result[1], 10, 16)
if err != nil {
return "", 0, err
}
return result[0], uint16(port), nil
}
func WriteError(ctx *fasthttp.RequestCtx, err error, statusCode int, body ...string) {
ctx.SetStatusCode(statusCode)
if len(body) > 0 {
ctx.SetBodyString(body[0])
} else {
ctx.SetBodyString(http.StatusText(statusCode))
}
if err != nil {
log.Println(err)
}
}
func IsCacheEnabled(ctx *fasthttp.RequestCtx, cacheType, host string, port uint16) (bool, string, error) {
key := fmt.Sprintf("%s:%s-%d", cacheType, host, port)
if authKey := ctx.Request.Header.Peek("Authorization"); len(authKey) > 0 {
exists, err := r.Exists(fmt.Sprintf("auth_key:%s", authKey))
if err != nil {
WriteError(ctx, err, http.StatusInternalServerError)
return config.CacheEnable, key, err
}
if exists {
return false, key, nil
}
}
return config.CacheEnable, key, nil
}
func Contains[T comparable](arr []T, x T) bool {
for _, v := range arr {
if v == x {
return true
}
}
return false
}