Skip to content

Commit 27b2b40

Browse files
committed
cheeky 3AM rewrite in Go
1 parent bb2d6c7 commit 27b2b40

9 files changed

Lines changed: 268 additions & 727 deletions

File tree

.gitignore

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,29 @@
1+
# Created by https://www.toptal.com/developers/gitignore/api/go
2+
# Edit at https://www.toptal.com/developers/gitignore?templates=go
3+
4+
### Go ###
5+
# If you prefer the allow list template instead of the deny list, see community template:
6+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
7+
#
8+
# Binaries for programs and plugins
9+
*.exe
10+
*.exe~
11+
*.dll
12+
*.so
13+
*.dylib
14+
15+
# Test binary, built with `go test -c`
16+
*.test
17+
18+
# Output of the go coverage tool, specifically when used with LiteIDE
19+
*.out
20+
21+
# Dependency directories (remove the comment below to include it)
22+
# vendor/
23+
24+
# Go workspace file
25+
go.work
26+
27+
# End of https://www.toptal.com/developers/gitignore/api/go
28+
129
infra/
2-
__pycache__/
3-
*.py[cod]
4-
*$py.class

.vscode/launch.json

Lines changed: 0 additions & 20 deletions
This file was deleted.

Dockerfile

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
1-
FROM python:3.12-alpine AS build-env
1+
FROM golang:1.25 AS build
22

3-
ENV PYTHONFAULTHANDLER=1 \
4-
PYTHONUNBUFFERED=1 \
5-
PYTHONHASHSEED=random \
6-
PIP_NO_CACHE_DIR=off \
7-
PIP_DISABLE_PIP_VERSION_CHECK=on \
8-
PIP_DEFAULT_TIMEOUT=100 \
9-
POETRY_NO_INTERACTION=1
3+
WORKDIR /work
104

11-
RUN apk add --no-cache --virtual .python_deps build-base python3-dev libffi-dev gcc bash && \
12-
pip3 install poetry && \
13-
apk add --no-cache git && \
14-
mkdir -p /app/src /app /shared && \
15-
poetry config virtualenvs.create false
5+
COPY go.mod .
6+
COPY go.sum .
167

17-
ADD pyproject.toml /app/pyproject.toml
8+
RUN go mod download
189

19-
WORKDIR /app
10+
COPY . .
2011

21-
RUN poetry export --dev --without-hashes --no-interaction --no-ansi -f requirements.txt -o requirements.txt && \
22-
pip install --force-reinstall -r requirements.txt && \
23-
apk del .python_deps
12+
RUN go build
2413

25-
ADD src /app/src
14+
FROM golang:1.25
2615

2716
WORKDIR /app
2817

29-
ENTRYPOINT python3 /app/src/blockgametracker/main.py
18+
COPY --from=build /work/mcstatus-exporter /app/mcstatus-exporter
19+
20+
EXPOSE 8080
21+
CMD ["/app/mcstatus-exporter"]

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# blockgametracker
1+
# mcstatus-exporter
22

3-
A Python Prometheus exporter to query Minecraft servers for their current status and playercount.
3+
A Go Prometheus exporter to query Minecraft servers for their current status and playercount.
44
Used for https://blockgametracker.gg
55

66
Originally https://github.com/itzg/mc-monitor was used for this purpose but it ended up not suiting this project. Metric & label names were taken from this project.

go.mod

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
module mcstatus-exporter
2+
3+
go 1.25
4+
5+
require (
6+
github.com/charmbracelet/log v0.4.2
7+
github.com/eko/gocache/lib/v4 v4.2.2
8+
github.com/eko/gocache/store/go_cache/v4 v4.2.2
9+
github.com/gosimple/slug v1.15.0
10+
github.com/jamesog/iptoasn v0.1.0
11+
github.com/mcstatus-io/mcutil/v4 v4.0.1
12+
github.com/patrickmn/go-cache v2.1.0+incompatible
13+
github.com/prometheus/client_golang v1.23.2
14+
gopkg.in/yaml.v3 v3.0.1
15+
)
16+
17+
require (
18+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
19+
github.com/beorn7/perks v1.0.1 // indirect
20+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
21+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
22+
github.com/charmbracelet/lipgloss v1.1.0 // indirect
23+
github.com/charmbracelet/x/ansi v0.8.0 // indirect
24+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
25+
github.com/charmbracelet/x/term v0.2.1 // indirect
26+
github.com/go-logfmt/logfmt v0.6.0 // indirect
27+
github.com/golang/mock v1.6.0 // indirect
28+
github.com/gosimple/unidecode v1.0.1 // indirect
29+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
30+
github.com/mattn/go-isatty v0.0.20 // indirect
31+
github.com/mattn/go-runewidth v0.0.16 // indirect
32+
github.com/muesli/termenv v0.16.0 // indirect
33+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
34+
github.com/pkg/errors v0.8.1 // indirect
35+
github.com/prometheus/client_model v0.6.2 // indirect
36+
github.com/prometheus/common v0.66.1 // indirect
37+
github.com/prometheus/procfs v0.16.1 // indirect
38+
github.com/rivo/uniseg v0.4.7 // indirect
39+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
40+
go.uber.org/mock v0.6.0 // indirect
41+
go.yaml.in/yaml/v2 v2.4.2 // indirect
42+
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
43+
golang.org/x/sync v0.16.0 // indirect
44+
golang.org/x/sys v0.35.0 // indirect
45+
google.golang.org/protobuf v1.36.8 // indirect
46+
)

main.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"os"
10+
"strconv"
11+
"sync"
12+
"time"
13+
14+
"github.com/charmbracelet/log"
15+
"github.com/eko/gocache/lib/v4/cache"
16+
"github.com/eko/gocache/lib/v4/store"
17+
gocachestore "github.com/eko/gocache/store/go_cache/v4"
18+
"github.com/gosimple/slug"
19+
"github.com/jamesog/iptoasn"
20+
"github.com/mcstatus-io/mcutil/v4/status"
21+
gocache "github.com/patrickmn/go-cache"
22+
"github.com/prometheus/client_golang/prometheus"
23+
"github.com/prometheus/client_golang/prometheus/promhttp"
24+
"gopkg.in/yaml.v3"
25+
)
26+
27+
type Server struct {
28+
Name string `yaml:"name"`
29+
Address string `yaml:"address"`
30+
Disabled bool `yaml:"disabled"`
31+
}
32+
33+
type Config struct {
34+
Java []Server `yaml:"java"`
35+
Bedrock []Server `yaml:"bedrock"`
36+
}
37+
38+
var config Config
39+
40+
var asnLookupCacheClient = gocache.New(1*time.Hour, 10*time.Minute)
41+
var asnLookupCacheStore = gocachestore.NewGoCache(asnLookupCacheClient)
42+
var asnLookupCache = cache.New[iptoasn.IP](asnLookupCacheStore)
43+
44+
var promGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{
45+
Name: "minecraft_status_players_online_count",
46+
Help: "Minecraft server online player count",
47+
}, []string{"server_edition", "server_name", "server_slug", "server_host", "as_number", "as_name"})
48+
49+
func getEnv(key, fallback string) string {
50+
value, exists := os.LookupEnv(key)
51+
if !exists {
52+
value = fallback
53+
}
54+
return value
55+
}
56+
57+
func index(w http.ResponseWriter, r *http.Request) {
58+
_, err := fmt.Fprintf(w, "mcstatus-exporter")
59+
if err != nil {
60+
return
61+
}
62+
}
63+
64+
func query(edition string, name string, queryHostname string) {
65+
executionTimer := time.Now()
66+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
67+
defer cancel()
68+
69+
var resolvedHostname string = queryHostname
70+
var playercount *int64
71+
72+
switch edition {
73+
case "java":
74+
response, err := status.Modern(ctx, queryHostname, 25565)
75+
if err != nil {
76+
log.Error("failed to get response: "+err.Error(), "edition", edition, "hostname", queryHostname)
77+
return
78+
}
79+
playercount = response.Players.Online
80+
if response.SRVRecord != nil {
81+
resolvedHostname = response.SRVRecord.Host
82+
}
83+
case "bedrock":
84+
response, err := status.Bedrock(ctx, queryHostname, 19132)
85+
if err != nil {
86+
log.Error("failed to get response: "+err.Error(), "edition", edition, "hostname", queryHostname)
87+
return
88+
}
89+
playercount = response.OnlinePlayers
90+
// Bedrock doesn't have SRV records so no need to handle those
91+
default:
92+
log.Error(fmt.Errorf("unknown edition: %s", edition))
93+
panic("unknown edition")
94+
}
95+
96+
resolvedIP, err := net.LookupIP(resolvedHostname)
97+
if err != nil {
98+
log.Error(err.Error(), "hostname", resolvedHostname)
99+
return
100+
}
101+
102+
ip, err := asnLookupCache.Get(ctx, resolvedIP[0])
103+
if errors.Is(err, store.NotFound{}) {
104+
log.Info("performing uncached asn lookup", "ip", resolvedIP[0])
105+
ip, err = iptoasn.LookupIP(fmt.Sprint(resolvedIP[0]))
106+
// TODO: probably want to continue with fallback N/A AS values if this fails
107+
if err != nil {
108+
panic(err)
109+
}
110+
111+
err = asnLookupCache.Set(ctx, resolvedIP[0], ip)
112+
if err != nil {
113+
panic(err)
114+
}
115+
}
116+
117+
log.Debug("resolved", "hostname", resolvedHostname, "ip", ip.IP, "asn", ip.ASNum)
118+
119+
log.Info("finished querying server ", "edition", edition, "name", name, "players", strconv.FormatInt(*playercount, 10), "execTimeMs", time.Since(executionTimer).Milliseconds())
120+
121+
promGauge.WithLabelValues(edition, name, slug.Make(name), queryHostname, strconv.Itoa(int(ip.ASNum)), ip.ASName).Set(float64(*playercount))
122+
}
123+
124+
func queryServers(servers []Server, serverType string, wg *sync.WaitGroup) {
125+
for _, server := range servers {
126+
if !server.Disabled {
127+
wg.Add(1)
128+
go func(server Server) {
129+
defer wg.Done()
130+
query(serverType, server.Name, server.Address)
131+
}(server)
132+
}
133+
}
134+
}
135+
136+
func promMetrics(w http.ResponseWriter, r *http.Request) {
137+
var wg sync.WaitGroup
138+
139+
queryServers(config.Java, "java", &wg)
140+
queryServers(config.Bedrock, "bedrock", &wg)
141+
142+
wg.Wait()
143+
144+
promhttp.Handler().ServeHTTP(w, r)
145+
}
146+
147+
func updateConfig() {
148+
file, err := os.Open(getenv("CONFIG_FILE", "servers.yaml"))
149+
if err != nil {
150+
log.Fatalf("error opening YAML file: %v", err)
151+
panic(err)
152+
}
153+
defer file.Close()
154+
155+
decoder := yaml.NewDecoder(file)
156+
err = decoder.Decode(&config)
157+
if err != nil {
158+
log.Fatalf("error decoding YAML: %v", err)
159+
panic(err)
160+
}
161+
162+
log.Info("loaded config", "java", len(config.Java), "bedrock", len(config.Bedrock))
163+
}
164+
165+
func main() {
166+
log.Info("mcstatus-exporter")
167+
updateConfig()
168+
169+
http.HandleFunc("/", index)
170+
prometheus.MustRegister(promGauge)
171+
http.HandleFunc("/metrics", promMetrics)
172+
173+
var httpBindAddr string = getEnv("BIND", ":8080")
174+
log.Infof("listening on %s", httpBindAddr)
175+
err := http.ListenAndServe(httpBindAddr, nil)
176+
if err != nil {
177+
log.Error(err, "error starting HTTP server")
178+
panic(err)
179+
}
180+
}

0 commit comments

Comments
 (0)