diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..00483e3 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,74 @@ +name: CI + +on: + - push + - pull_request + +jobs: + Test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Go + uses: actions/setup-go@v2 + + - name: Run Test + run: make test + + Lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Run Lint + uses: golangci/golangci-lint-action@v2 + + Docker: + runs-on: ubuntu-latest + needs: + - Test + - Lint + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v3 + with: + images: armsnyder/a2s-exporter + + - name: Log in to Docker Hub + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: docker/login-action@v1 + with: + username: armsnyder + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Docker build + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/v') + uses: docker/build-push-action@v2 + with: + context: . + push: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Docker build and push + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Sync Docker readme + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: meeDamian/sync-readme@v1.0.6 + with: + pass: ${{ secrets.DOCKERHUB_PASSWORD }} + description: true diff --git a/.gitignore b/.gitignore index 66fd13c..6519542 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,3 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ +# IDE +.idea +*.iml diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..a4267ee --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,16 @@ +# https://golangci-lint.run/usage/configuration/ + +linters-settings: + goimports: + local-prefixes: github.com/armsnyder/a2s-exporter + +linters: + enable: + - gocritic + - gofmt + - goimports + - golint + - misspell + - testpackage + - unconvert + - whitespace diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..968bf84 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.16 AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY internal internal +COPY *.go . +RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o /bin/a2s-exporter . + +FROM scratch +COPY --from=builder /bin/a2s-exporter /bin/a2s-exporter +ENTRYPOINT ["/bin/a2s-exporter"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fbfacc7 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +default: build test + +lint: + golangci-lint run + +fix: + golangci-lint run --fix + +build: + go build ./... + +test: + go test -v -race ./... diff --git a/README.md b/README.md index a34ac44..3f2a297 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,69 @@ -# a2s-exporter -A Prometheus exporter for Steam game servers +[![CI](https://github.com/armsnyder/a2s-exporter/actions/workflows/ci.yaml/badge.svg)](https://github.com/armsnyder/a2s-exporter/actions/workflows/ci.yaml) + +# A2S Exporter + +A Prometheus exporter for Steam game server info. + +Supports all Steam game servers which speak the UDP-based A2S query protocol, for example: + +* Counter-Strike +* The Forrest +* Rust +* Team Fortress 2 +* Valheim + +## Usage + +The image is hosted on Docker Hub. + +``` +docker run --rm -p 9856:9856 armsnyder/a2s-exporter --address myserver.example.com:12345 +``` + +### Arguments + +Arguments may be provided using commandline flags or environment variables. + +#### Required + +Flag | Variable | Help +--- | --- | --- +--address | A2S_EXPORTER_QUERY_ADDRESS | Address of the A2S query server as host:port (This is a separate port from the main server port). + +#### Optional + +Flag | Variable | Default | Help +--- | --- | --- | --- +--port | A2S_EXPORTER_PORT | 9856 | Port for the metrics exporter. +--path | A2S_EXPORTER_PATH | /metrics | Path for the metrics exporter. +--namespace | A2S_EXPORTER_NAMESPACE | a2s | Namespace prefix for all exported a2s metrics. +--a2s-only-metrics | A2S_EXPORTER_A2S_ONLY_METRICS | false | If true, excludes Go runtime and promhttp metrics. + +## Exported Metrics + +Metrics names are prefixed with a namespace (default `a2s_`). + +Name | Help | Labels +--- | --- | --- +player_count | Total number of connected players. | server_name +player_duration | Time (in seconds) player has been connected to the server. | server_name player_name player_index +player_score | Player's score (usually \"frags\" or \"kills\"). | server_name player_name player_index +player_the_ship_deaths | Player's deaths in a The Ship server. | server_name player_name player_index +player_the_ship_money | Player's money in a The Ship server. | server_name player_name player_index +player_up | Was the last player info query successful. | +server_bots | Number of bots on the server. | server_name +server_info | Non-numerical server info, including server_steam_id and version. The value is 1, and info is in the labels. | server_name map folder game server_type server_os version server_id keywords server_game_id server_steam_id the_ship_mode source_tv_name +server_max_players | Maximum number of players the server reports it can hold. | server_name +server_players | Number of players on the server. | server_name +server_port | The server's game port number. | server_name +server_protocol | Protocol version used by the server. | server_name +server_source_tv_port | Spectator port number for SourceTV. | server_name +server_the_ship_duration | Time (in seconds) before a player is arrested while being witnessed in a The Ship server. | server_name +server_the_ship_witnesses | The number of witnesses necessary to have a player arrested in a The Ship server. | server_name +server_up | Was the last server info query successful. | +server_vac | Specifies whether the server uses VAC (0 for unsecured, 1 for secured). | server_name +server_visibility | Indicates whether the server requires a password (0 for public, 1 for private). | server_name + +## Credits + +This exporter depends on [rumblefrog/go-a2s](https://github.com/rumblefrog/go-a2s) (MIT). Big thanks to them! diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1ec514d --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/armsnyder/a2s-exporter + +go 1.16 + +require ( + github.com/prometheus/client_golang v1.11.0 + github.com/prometheus/client_model v0.2.0 + github.com/rumblefrog/go-a2s v1.0.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d376c92 --- /dev/null +++ b/go.sum @@ -0,0 +1,140 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/rumblefrog/go-a2s v1.0.0 h1:9IIVIOQ1bXZJeTilmzkJDeGa/9W1c089VciTbp+Wp1Y= +github.com/rumblefrog/go-a2s v1.0.0/go.mod h1:JwbTgMTRGZcWzr3T2MUfDusrJU5Bdg8biEeZzPtN0So= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 h1:JWgyZ1qgdTaF3N3oxC+MdTV7qvEEgHo3otj+HB5CM7Q= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/collector/collector.go b/internal/collector/collector.go new file mode 100644 index 0000000..e607344 --- /dev/null +++ b/internal/collector/collector.go @@ -0,0 +1,219 @@ +package collector + +import ( + "fmt" + "reflect" + + "github.com/prometheus/client_golang/prometheus" + "github.com/rumblefrog/go-a2s" +) + +type Collector struct { + addr string + client *a2s.Client + descs map[string]*prometheus.Desc +} + +type adder func(name string, value float64, labelValues ...string) + +func New(namespace, addr string) *Collector { + descs := make(map[string]*prometheus.Desc) + + fullDesc := func(name, help string, labels []string) { + descs[name] = prometheus.NewDesc(prometheus.BuildFQName(namespace, "", name), help, labels, nil) + } + basicDesc := func(name string, help string) { + fullDesc(name, help, []string{"server_name"}) + } + playerDesc := func(name string, help string) { + fullDesc(name, help, []string{"server_name", "player_name", "player_index"}) + } + + fullDesc("server_info", "Non-numerical server info, including server_steam_id and version. The value is 1, and info is in the labels.", + []string{"server_name", "map", "folder", "game", "server_type", "server_os", "version", "server_id", "keywords", "server_game_id", "server_steam_id", "the_ship_mode", "source_tv_name"}) + + fullDesc("server_up", "Was the last server info query successful.", nil) + fullDesc("player_up", "Was the last player info query successful.", nil) + + basicDesc("server_protocol", "Protocol version used by the server.") + basicDesc("server_players", "Number of players on the server.") + basicDesc("server_max_players", "Maximum number of players the server reports it can hold.") + basicDesc("server_bots", "Number of bots on the server.") + basicDesc("server_visibility", "Indicates whether the server requires a password (0 for public, 1 for private).") + basicDesc("server_vac", "Specifies whether the server uses VAC (0 for unsecured, 1 for secured).") + basicDesc("server_port", "The server's game port number.") + basicDesc("server_source_tv_port", "Spectator port number for SourceTV.") + basicDesc("server_the_ship_witnesses", "The number of witnesses necessary to have a player arrested in a The Ship server.") + basicDesc("server_the_ship_duration", "Time (in seconds) before a player is arrested while being witnessed in a The Ship server.") + + basicDesc("player_count", "Total number of connected players.") + playerDesc("player_duration", "Time (in seconds) player has been connected to the server.") + playerDesc("player_score", `Player's score (usually "frags" or "kills").`) + playerDesc("player_the_ship_deaths", "Player's deaths in a The Ship server.") + playerDesc("player_the_ship_money", "Player's money in a The Ship server.") + + return &Collector{ + addr: addr, + descs: descs, + } +} + +func (c *Collector) Describe(descs chan<- *prometheus.Desc) { + for _, desc := range c.descs { + descs <- desc + } +} + +func (c *Collector) Collect(metrics chan<- prometheus.Metric) { + serverInfo, playerInfo := c.queryInfo() + + truthyFloat := func(v interface{}) float64 { + if reflect.ValueOf(v).IsNil() { + return 0 + } + return 1 + } + + add := func(name string, value float64, labelValues ...string) { + metrics <- prometheus.MustNewConstMetric(c.descs[name], prometheus.GaugeValue, value, labelValues...) + } + + add("server_up", truthyFloat(serverInfo)) + add("player_up", truthyFloat(playerInfo)) + + addPreLabelled := func(name string, value float64, labelValues ...string) { + labelValues2 := []string{serverInfo.Name} + labelValues2 = append(labelValues2, labelValues...) + add(name, value, labelValues2...) + } + + c.collectServerInfo(serverInfo, addPreLabelled) + c.collectPlayerInfo(playerInfo, addPreLabelled) +} + +// queryInfo queries the A2S server over UDP. Failure will result in one or both of the return values being nil. +func (c *Collector) queryInfo() (serverInfo *a2s.ServerInfo, playerInfo *a2s.PlayerInfo) { + var err error + + // Lazy initialization of UDP client. + if c.client == nil { + c.client, err = a2s.NewClient(c.addr) + if err != nil { + fmt.Println("Could not create A2S client:", err) + return + } + } + + // Query server info. + serverInfo, err = c.client.QueryInfo() + if err != nil { + fmt.Println("Could not query server info:", err) + return + } + + // A quirk of the a2s-go client is that in order for The Ship player queries to succeed, the client must be + // constructed with The Ship App ID. + playerClient := c.client + if a2s.AppID(serverInfo.ID) == a2s.App_TheShip { + playerClient, err = a2s.NewClient(c.addr, a2s.SetAppID(int32(serverInfo.ID))) + if err != nil { + fmt.Println("Could not create A2S client for The Ship player query:", err) + return + } + } + + // Query player info. + // SourceTV does not respond to player queries. + if serverInfo.ServerType != a2s.ServerType_SourceTV { + playerInfo, err = playerClient.QueryPlayer() + if err != nil { + fmt.Println("Could not query player info:", err) + return + } + } + + return +} + +func (c *Collector) collectServerInfo(serverInfo *a2s.ServerInfo, add adder) { + if serverInfo == nil { + return + } + + nilSafe := func(check interface{}, do func() string) string { + if reflect.ValueOf(check).IsNil() { + return "" + } + return do() + } + + add("server_info", 1, + serverInfo.Map, + serverInfo.Folder, + serverInfo.Game, + serverInfo.ServerType.String(), + serverInfo.ServerOS.String(), + serverInfo.Version, + fmt.Sprintf("%d", serverInfo.ID), + nilSafe(serverInfo.ExtendedServerInfo, func() string { return serverInfo.ExtendedServerInfo.Keywords }), + nilSafe(serverInfo.ExtendedServerInfo, func() string { return fmt.Sprintf("%d", serverInfo.ExtendedServerInfo.GameID) }), + nilSafe(serverInfo.ExtendedServerInfo, func() string { return fmt.Sprintf("%d", serverInfo.ExtendedServerInfo.SteamID) }), + nilSafe(serverInfo.TheShip, func() string { return serverInfo.TheShip.Mode.String() }), + nilSafe(serverInfo.SourceTV, func() string { return serverInfo.SourceTV.Name }), + ) + + addPos := func(name string, value float64, labelValues ...string) { + if value <= 0 { + return + } + add(name, value, labelValues...) + } + + addBool := func(name string, value bool, labelValues ...string) { + var asFloat float64 + if value { + asFloat = 1 + } + add(name, asFloat, labelValues...) + } + + addPos("server_protocol", float64(serverInfo.Protocol)) + add("server_players", float64(serverInfo.Players)) + add("server_max_players", float64(serverInfo.MaxPlayers)) + add("server_bots", float64(serverInfo.Bots)) + addBool("server_visibility", serverInfo.Visibility) + addBool("server_vac", serverInfo.VAC) + + if serverInfo.ExtendedServerInfo != nil { + addPos("server_port", float64(serverInfo.ExtendedServerInfo.Port)) + } + + if serverInfo.SourceTV != nil { + addPos("server_source_tv_port", float64(serverInfo.SourceTV.Port)) + } + + if serverInfo.TheShip != nil { + add("server_the_ship_witnesses", float64(serverInfo.TheShip.Witnesses)) + add("server_the_ship_duration", float64(serverInfo.TheShip.Duration)) + } +} + +func (c *Collector) collectPlayerInfo(playerInfo *a2s.PlayerInfo, add adder) { + if playerInfo == nil { + return + } + + add("player_count", float64(playerInfo.Count)) + + for _, player := range playerInfo.Players { + labelValues := []string{player.Name, fmt.Sprintf("%d", player.Index)} + + add("player_duration", float64(player.Duration), labelValues...) + add("player_score", float64(player.Score), labelValues...) + + if player.TheShip != nil { + add("player_the_ship_deaths", float64(player.TheShip.Deaths), labelValues...) + add("player_the_ship_money", float64(player.TheShip.Money), labelValues...) + } + } +} diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go new file mode 100644 index 0000000..449c0a9 --- /dev/null +++ b/internal/collector/collector_test.go @@ -0,0 +1,170 @@ +package collector_test + +import ( + "fmt" + "net" + "regexp" + "sort" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + io_prometheus_client "github.com/prometheus/client_model/go" + "github.com/rumblefrog/go-a2s" + + "github.com/armsnyder/a2s-exporter/internal/collector" + "github.com/armsnyder/a2s-exporter/internal/testserver" +) + +// TestCollector_Describe_PrintTable tests the Describe function. +// It also prints a Markdown-formatted table of all registered metrics, which can be copied to the README. +func TestCollector_Describe_PrintTable(t *testing.T) { + c := collector.New("", "") + descs := testDescribe(c) + if len(descs) == 0 { + t.Error("expected Descs but got none") + } + + // HACK: The only exported method on Desc is String(). + pattern := regexp.MustCompile(`fqName: "([a-z_]+)", help: "(.+)", constLabels: .+, variableLabels: \[([^]]*)]`) + for _, desc := range descs { + match := pattern.FindStringSubmatch(desc.String()) + if match == nil { + t.Errorf("failed pattern match for Desc %s", desc) + continue + } + fmt.Println(strings.Join(match[1:], " | ")) + } +} + +// testDescribe returns all Descs from the provided Collector, sorted. +func testDescribe(c prometheus.Collector) (descs []*prometheus.Desc) { + ch := make(chan *prometheus.Desc) + done := make(chan bool) + + go func() { + for desc := range ch { + descs = append(descs, desc) + } + sort.Slice(descs, func(i, j int) bool { return descs[i].String() < descs[j].String() }) + close(done) + }() + + c.Describe(ch) + close(ch) + <-done + + return descs +} + +func TestCollector(t *testing.T) { + // Run a test A2S server. + conn, err := net.ListenUDP("udp", nil) + if err != nil { + t.Fatal(err) + } + srv := &testserver.TestServer{ + ServerInfo: &a2s.ServerInfo{ + Name: "foo", + Players: 2, + MaxPlayers: 6, + }, + PlayerInfo: &a2s.PlayerInfo{ + Count: 2, + Players: []*a2s.Player{ + { + Index: 0, + Name: "jon", + Duration: 32, + }, + { + Index: 1, + Name: "alice", + Duration: 64, + }, + }, + }, + } + go func() { + t.Error(srv.Serve(conn)) + }() + + // Set up the registry and gather metrics from the test A2S server. + registry := prometheus.NewPedanticRegistry() + registry.MustRegister(collector.New("", conn.LocalAddr().String())) + metrics, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + + // Spot check the gathered metrics. + testAssertGauge(t, metrics, "server_players", + expectGauge{value: 2, labels: map[string]string{"server_name": "foo"}}, + ) + testAssertGauge(t, metrics, "server_max_players", + expectGauge{value: 6, labels: map[string]string{"server_name": "foo"}}, + ) + testAssertGauge(t, metrics, "player_count", + expectGauge{value: 2, labels: map[string]string{"server_name": "foo"}}, + ) + testAssertGauge(t, metrics, "player_duration", + expectGauge{value: 32, labels: map[string]string{"server_name": "foo", "player_index": "0", "player_name": "jon"}}, + expectGauge{value: 64, labels: map[string]string{"server_name": "foo", "player_index": "1", "player_name": "alice"}}, + ) +} + +type expectGauge struct { + value float64 + labels map[string]string +} + +func testAssertGauge(t *testing.T, metricFamilies []*io_prometheus_client.MetricFamily, name string, expectGauges ...expectGauge) { + for _, family := range metricFamilies { + if family.GetName() != name { + continue + } + + metrics := family.GetMetric() + if len(metrics) != len(expectGauges) { + t.Errorf("metric %s count mismatch: wanted %d, got %d", name, len(expectGauges), len(metrics)) + return + } + + nextExpectedGauge: + for _, expectedGauge := range expectGauges { + for _, metric := range metrics { + if testMatchGauge(expectedGauge, metric) { + continue nextExpectedGauge + } + } + t.Errorf("metric %s did not contain an expected gauge %v", name, expectedGauge) + } + + return + } + + t.Errorf("exected metric %s not found", name) +} + +func testMatchGauge(expectedGauge expectGauge, metric *io_prometheus_client.Metric) bool { + gotGauge := metric.GetGauge() + if gotGauge == nil { + return false + } + + if expectedGauge.value != metric.GetGauge().GetValue() { + return false + } + +nextExpectedLabel: + for k, v := range expectedGauge.labels { + for _, label := range metric.GetLabel() { + if label.GetName() == k && label.GetValue() == v { + continue nextExpectedLabel + } + } + return false + } + + return true +} diff --git a/internal/testserver/testserver.go b/internal/testserver/testserver.go new file mode 100644 index 0000000..c4f6fdf --- /dev/null +++ b/internal/testserver/testserver.go @@ -0,0 +1,296 @@ +package testserver + +import ( + "bytes" + "encoding/binary" + "io" + "math" + "net" + + "github.com/rumblefrog/go-a2s" +) + +var challenge = uint32(1876276358) + +// TestServer implements the A2S server spec. +// See: https://developer.valvesoftware.com/wiki/Server_queries +type TestServer struct { + ServerInfo *a2s.ServerInfo + PlayerInfo *a2s.PlayerInfo +} + +// Serve runs the A2S server. +// The function blocks execution until an error is encountered. +func (t *TestServer) Serve(conn net.PacketConn) error { + var buf [a2s.MaxPacketSize]byte + + for { + // Read the next request packet. + _, remoteAddr, err := conn.ReadFrom(buf[:]) + if err != nil { + return err + } + + // Handle the request packet. + + // Validate packet header. + if binary.LittleEndian.Uint32(buf[:5]) != math.MaxUint32 { + continue + } + + queryType := buf[4] + out := &udpWriter{conn: conn, addr: remoteAddr} + + switch queryType { + // Server info query (no challenge). + case 'T': + err = t.writeServerInfo(out) + + // Player info query. + case 'U': + gotChallenge := binary.LittleEndian.Uint32(buf[5:9]) + + switch gotChallenge { + // No challenge. + case math.MaxUint32: + err = t.writeChallenge(out) + + // Correct challenge. + case challenge: + err = t.writePlayerInfo(out) + } + } + + if err != nil { + return err + } + } +} + +func (t *TestServer) writeChallenge(out io.Writer) error { + var outBuf [9]byte + binary.LittleEndian.PutUint32(outBuf[:4], math.MaxUint32) + outBuf[4] = 'A' + binary.LittleEndian.PutUint32(outBuf[5:], challenge) + _, err := out.Write(outBuf[:]) + return err +} + +func (t *TestServer) writeServerInfo(out io.Writer) error { + info := t.ServerInfo + if info == nil { + info = &a2s.ServerInfo{} + } + + // Response packet buffer. + buf := &packetBuffer{} + + // Header. + buf.WriteUInt32(math.MaxUint32) + buf.WriteByte('I') + + // Payload. + buf.WriteByte(info.Protocol) + buf.WriteCString(info.Name) + buf.WriteCString(info.Map) + buf.WriteCString(info.Folder) + buf.WriteCString(info.Game) + buf.WriteUInt16(info.ID) + buf.WriteByte(info.Players) + buf.WriteByte(info.MaxPlayers) + buf.WriteByte(info.Bots) + buf.WriteByte(formatServerType(info.ServerType)) + buf.WriteByte(formatServerOS(info.ServerOS)) + buf.WriteBool(info.Visibility) + buf.WriteBool(info.VAC) + + if a2s.AppID(info.ID) == a2s.App_TheShip { + theShip := info.TheShip + if info.TheShip == nil { + theShip = &a2s.TheShipInfo{} + } + buf.WriteByte(formatTheShipMode(theShip.Mode)) + buf.WriteByte(theShip.Witnesses) + buf.WriteByte(theShip.Duration) + } + + buf.WriteCString(info.Version) + + // We will add to the EDF flag as we read the source data. + var edf byte + + // Create a new buffer for EDF data so that it can be written after the EDF flag. + edfBuf := &packetBuffer{} + + if info.ExtendedServerInfo != nil { + if info.ExtendedServerInfo.Port != 0 { + edf |= 0x80 + edfBuf.WriteUInt16(info.ExtendedServerInfo.Port) + } + if info.ExtendedServerInfo.SteamID != 0 { + edf |= 0x10 + edfBuf.WriteUInt64(info.ExtendedServerInfo.SteamID) + } + } + + if info.SourceTV != nil { + edf |= 0x40 + edfBuf.WriteUInt16(info.SourceTV.Port) + edfBuf.WriteCString(info.SourceTV.Name) + } + + if info.ExtendedServerInfo != nil { + if info.ExtendedServerInfo.Keywords != "" { + edf |= 0x20 + edfBuf.WriteCString(info.ExtendedServerInfo.Keywords) + } + if info.ExtendedServerInfo.GameID != 0 { + edf |= 0x01 + edfBuf.WriteUInt64(info.ExtendedServerInfo.GameID) + } + } + + if edf != 0 { + // Write the EDF flag first. + buf.WriteByte(edf) + + // Write the EDF data. + buf.Write(edfBuf.Bytes()) + } + + // Write the packet out. + _, err := io.Copy(out, buf) + return err +} + +func (t *TestServer) writePlayerInfo(out io.Writer) error { + info := t.PlayerInfo + if info == nil { + info = &a2s.PlayerInfo{} + } + + // Response packet buffer. + buf := &packetBuffer{} + + // Header. + buf.WriteUInt32(math.MaxUint32) + buf.WriteByte('D') + + // Payload. + buf.WriteByte(info.Count) + + for _, player := range info.Players { + buf.WriteByte(player.Index) + buf.WriteCString(player.Name) + buf.WriteUInt32(player.Score) + buf.WriteFloat32(player.Duration) + + if t.ServerInfo != nil && a2s.AppID(t.ServerInfo.ID) == a2s.App_TheShip { + theShip := player.TheShip + if theShip == nil { + theShip = &a2s.TheShipPlayer{} + } + buf.WriteUInt32(theShip.Deaths) + buf.WriteUInt32(theShip.Money) + } + } + + // Write the packet out. + _, err := io.Copy(out, buf) + return err +} + +// packetBuffer extends bytes.Buffer to add more data types used by A2S. +type packetBuffer struct { + bytes.Buffer +} + +func (b *packetBuffer) WriteCString(s string) { + b.WriteString(s) + b.WriteByte(0) +} + +func (b *packetBuffer) WriteUInt16(v uint16) { + var buf [2]byte + binary.LittleEndian.PutUint16(buf[:], v) + b.Write(buf[:]) +} + +func (b *packetBuffer) WriteUInt32(v uint32) { + var buf [4]byte + binary.LittleEndian.PutUint32(buf[:], v) + b.Write(buf[:]) +} + +func (b *packetBuffer) WriteUInt64(v uint64) { + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], v) + b.Write(buf[:]) +} + +func (b *packetBuffer) WriteBool(v bool) { + var c byte + if v { + c = 1 + } + b.WriteByte(c) +} + +func (b *packetBuffer) WriteFloat32(v float32) { + b.WriteUInt32(math.Float32bits(v)) +} + +// udpWriter writes UDP packets to an address. +type udpWriter struct { + conn net.PacketConn + addr net.Addr +} + +func (w *udpWriter) Write(p []byte) (int, error) { + return w.conn.WriteTo(p, w.addr) +} + +func formatServerType(serverType a2s.ServerType) byte { + switch serverType { + case a2s.ServerType_Dedicated: + return 'd' + case a2s.ServerType_NonDedicated: + return 'l' + case a2s.ServerType_SourceTV: + return 'p' + default: + return 0 + } +} + +func formatServerOS(serverOS a2s.ServerOS) byte { + switch serverOS { + case a2s.ServerOS_Linux: + return 'l' + case a2s.ServerOS_Windows: + return 'w' + case a2s.ServerOS_Mac: + return 'm' + default: + return 0 + } +} + +func formatTheShipMode(mode a2s.TheShipMode) byte { + switch mode { + case a2s.TheShipMode_Hunt: + return 0 + case a2s.TheShipMode_Elimination: + return 1 + case a2s.TheShipMode_Duel: + return 2 + case a2s.TheShipMode_Deathmatch: + return 3 + case a2s.TheShipMode_VIP_Team: + return 4 + case a2s.TheShipMode_Team_Elimination: + return 5 + default: + return 0 + } +} diff --git a/internal/testserver/testserver_test.go b/internal/testserver/testserver_test.go new file mode 100644 index 0000000..1c03a79 --- /dev/null +++ b/internal/testserver/testserver_test.go @@ -0,0 +1,225 @@ +package testserver_test + +import ( + "bytes" + "encoding/json" + "net" + "testing" + + "github.com/rumblefrog/go-a2s" + + "github.com/armsnyder/a2s-exporter/internal/testserver" +) + +func TestTestServer_Serve(t *testing.T) { + type fields struct { + ServerInfo *a2s.ServerInfo + PlayerInfo *a2s.PlayerInfo + } + + tests := []struct { + name string + fields fields + }{ + { + name: "empty", + }, + { + name: "server info shallow", + fields: fields{ + ServerInfo: &a2s.ServerInfo{ + Protocol: 1, + Name: "foo", + Map: "map", + Folder: "folder", + Game: "game", + ID: 234, + Players: 22, + MaxPlayers: 33, + Bots: 4, + ServerType: a2s.ServerType_Dedicated, + ServerOS: a2s.ServerOS_Mac, + Visibility: true, + VAC: true, + Version: "ver", + }, + }, + }, + { + name: "server info the ship full", + fields: fields{ + ServerInfo: &a2s.ServerInfo{ + Protocol: 1, + Name: "foo", + Map: "map", + Folder: "folder", + Game: "game", + ID: uint16(a2s.App_TheShip), + Players: 22, + MaxPlayers: 33, + Bots: 4, + ServerType: a2s.ServerType_Dedicated, + ServerOS: a2s.ServerOS_Mac, + Visibility: true, + VAC: true, + TheShip: &a2s.TheShipInfo{ + Mode: a2s.TheShipMode_Elimination, + Witnesses: 3, + Duration: 45, + }, + Version: "ver", + ExtendedServerInfo: &a2s.ExtendedServerInfo{ + Port: 4572, + SteamID: 2367893276, + Keywords: "abc", + GameID: 12345, + }, + SourceTV: &a2s.SourceTVInfo{ + Port: 3463, + Name: "tv", + }, + }, + }, + }, + { + name: "player info", + fields: fields{ + PlayerInfo: &a2s.PlayerInfo{ + Count: 2, + Players: []*a2s.Player{ + { + Index: 0, + Name: "jon", + Score: 4, + Duration: 234, + }, + { + Index: 1, + Name: "alice", + Score: 3457, + Duration: 4564, + }, + }, + }, + }, + }, + { + name: "player info the ship", + fields: fields{ + ServerInfo: &a2s.ServerInfo{ + ID: uint16(a2s.App_TheShip), + TheShip: &a2s.TheShipInfo{ + Mode: a2s.TheShipMode_Duel, + }, + }, + PlayerInfo: &a2s.PlayerInfo{ + Count: 2, + Players: []*a2s.Player{ + { + Index: 0, + Name: "jon", + Score: 4, + Duration: 234, + TheShip: &a2s.TheShipPlayer{ + Deaths: 23, + Money: 3456, + }, + }, + { + Index: 1, + Name: "alice", + Score: 3457, + Duration: 4564, + TheShip: &a2s.TheShipPlayer{ + Deaths: 345, + Money: 123, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server listener. + conn, err := net.ListenUDP("udp", nil) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + // Initialize TestServer. + srv := &testserver.TestServer{ + ServerInfo: tt.fields.ServerInfo, + PlayerInfo: tt.fields.PlayerInfo, + } + + // Serve in background. + go func() { + _ = srv.Serve(conn) + }() + + // Construct the client. + client, err := a2s.NewClient(conn.LocalAddr().String()) + if err != nil { + t.Fatal(err) + } + + // Query the server info and check that it matches how the TestServer was initialized. + serverInfo, err := client.QueryInfo() + if err != nil { + t.Fatalf("Unexpected error while querying server info: %v", err) + } else { + serverInfo.EDF = 0 + want := &a2s.ServerInfo{} + testJSONCopy(t, want, tt.fields.ServerInfo) + testAssertJSONEqual(t, want, serverInfo) + } + + // Re-construct the client with the app ID from the server info (required for The Ship). + client, err = a2s.NewClient(conn.LocalAddr().String(), a2s.SetAppID(int32(serverInfo.ID))) + if err != nil { + t.Fatal(err) + } + + // Query the player info and check that it matches how the TestServer was initialized. + if playerInfo, err := client.QueryPlayer(); err != nil { + t.Errorf("Unexpected error while querying player info: %v", err) + } else { + want := &a2s.PlayerInfo{} + testJSONCopy(t, want, tt.fields.PlayerInfo) + testAssertJSONEqual(t, want, playerInfo) + } + }) + } +} + +// testJSONCopy unmarshalls into dest using the JSON encoding of src. +func testJSONCopy(t *testing.T, dest, src interface{}) { + t.Helper() + b, err := json.Marshal(src) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, dest); err != nil { + t.Fatal(err) + } +} + +// testAssertJSONEqual checks for JSON-encoded equality. +func testAssertJSONEqual(t *testing.T, want, got interface{}) { + t.Helper() + wantBytes, err := json.MarshalIndent(want, "", " ") + if err != nil { + t.Fatal(err) + } + gotBytes, err := json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotBytes, wantBytes) { + t.Errorf("not equal\n\nwanted:\n%s\n\ngot:\n%s\n", string(wantBytes), string(gotBytes)) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e924e07 --- /dev/null +++ b/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/armsnyder/a2s-exporter/internal/collector" +) + +func main() { + // Flags. + address := flag.String("address", envOrDefault("A2S_EXPORTER_QUERY_ADDRESS", ""), "Address of the A2S query server as host:port (This is a separate port from the main server port).") + port := flag.Int("port", envOrDefaultInt("A2S_EXPORTER_PORT", 9856), "Port for the metrics exporter.") + path := flag.String("path", envOrDefault("A2S_EXPORTER_PATH", "/metrics"), "Path for the metrics exporter.") + namespace := flag.String("namespace", envOrDefault("A2S_EXPORTER_NAMESPACE", "a2s"), "Namespace prefix for all exported a2s metrics.") + a2sOnlyMetrics := flag.Bool("a2s-only-metrics", envOrDefaultBool("A2S_EXPORTER_A2S_ONLY_METRICS", false), "If true, skips exporting Go runtime metrics.") + help := flag.Bool("h", false, "Show help.") + + flag.Parse() + + defer os.Exit(1) + + // Show help. + if *help || flag.NArg() > 0 { + flag.Usage() + return + } + + // Check required arguments. + if *address == "" { + fmt.Println("address argument is required") + flag.Usage() + return + } + + // Set up prometheus metrics registry. + var registry *prometheus.Registry + if *a2sOnlyMetrics { + registry = prometheus.NewRegistry() + } else { + registry = prometheus.DefaultRegisterer.(*prometheus.Registry) + } + + // Register A2S metrics. + registry.MustRegister(collector.New(*namespace, *address)) + + // Set up http handler. + handler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) + if !*a2sOnlyMetrics { + handler = promhttp.InstrumentMetricHandler(registry, handler) + } + + http.Handle(*path, handler) + + // Run http server. + fmt.Printf("Serving metrics at http://127.0.0.1:%d%s\n", *port, *path) + fmt.Println(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil)) +} + +func envOrDefault(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +func envOrDefaultInt(key string, def int) int { + if v, ok := os.LookupEnv(key); ok { + v2, _ := strconv.Atoi(v) + return v2 + } + return def +} + +func envOrDefaultBool(key string, def bool) bool { + if v, ok := os.LookupEnv(key); ok { + return !strings.EqualFold(v, "false") && !strings.EqualFold(v, "0") + } + return def +}