mirror of
https://github.com/itzg/mc-router.git
synced 2024-09-26 03:42:35 +02:00
Compare commits
90 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
cba2a08db6 | ||
|
377f7d33bc | ||
|
d93564670f | ||
|
5402099fdb | ||
|
cab6ba1e71 | ||
|
39bdcfc3cc | ||
|
33d0c5c56a | ||
|
5d7063f73e | ||
|
8b3180d852 | ||
|
beae573d81 | ||
|
2a643f71ab | ||
|
ed7ee144ad | ||
|
e38a054c46 | ||
|
f32dfa3800 | ||
|
2c2ddabf2d | ||
|
d68b141c66 | ||
|
732283d1c5 | ||
|
ff1709ef3b | ||
|
28a78380d0 | ||
|
e17d5d6188 | ||
|
5f79ad653e | ||
|
f34b6e85b0 | ||
|
09ed21d467 | ||
|
c4d2918053 | ||
|
392c64f7fb | ||
|
e15c0d8110 | ||
|
3cc4f3c861 | ||
|
e441a72d90 | ||
|
8ca88f416e | ||
|
b762c1b04e | ||
|
705ffbf093 | ||
|
5dc576fc2e | ||
|
555033b54d | ||
|
f85b23b271 | ||
|
fad894e773 | ||
|
1d6df79ba3 | ||
|
b2153f89cb | ||
|
51b8d44c77 | ||
|
ac3e315a1f | ||
|
f9572965f9 | ||
|
ffacdb5155 | ||
|
952e298c87 | ||
|
aa4b27c8f5 | ||
|
a96cde55d5 | ||
|
4f54002231 | ||
|
ccc0b6f9f4 | ||
|
1e1428c6bd | ||
|
2cbb998aff | ||
|
5fb718df2d | ||
|
35aaae5bcb | ||
|
a73a1689c9 | ||
|
b4b97f51df | ||
|
84907ef8cd | ||
|
33f393f1b9 | ||
|
afdca5646e | ||
|
25601c65a6 | ||
|
4435b7cb55 | ||
|
ff7ef24f4c | ||
|
302b68400b | ||
|
f6dc9cd03a | ||
|
88d2e8a013 | ||
|
7f4a6843ea | ||
|
1803ecfe04 | ||
|
8fab072e18 | ||
|
c046edec8b | ||
|
c548fda65e | ||
|
059ee78c5c | ||
|
de655ee432 | ||
|
087a325720 | ||
|
426aebeadb | ||
|
ae24356102 | ||
|
80a9d38471 | ||
|
a3cd1598bf | ||
|
a1067acfa1 | ||
|
e39d4049eb | ||
|
da057f5b34 | ||
|
1f8236ad92 | ||
|
7fb3cbd7bf | ||
|
dc5e5cac31 | ||
|
137bd9c8ec | ||
|
4d93eeaa0d | ||
|
28695b3bba | ||
|
730a8d3d89 | ||
|
787ab54ca4 | ||
|
e1bb4980bb | ||
|
c2b23d40c7 | ||
|
298ea8eef9 | ||
|
7117562215 | ||
|
085bb6e133 | ||
|
a7229355bf |
15
.github/dependabot.yml
vendored
15
.github/dependabot.yml
vendored
@ -5,7 +5,20 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
updates:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- patch
|
||||||
|
- minor
|
||||||
- package-ecosystem: "gomod"
|
- package-ecosystem: "gomod"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
patches:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- patch
|
||||||
|
56
.github/workflows/pr.yml
vendored
56
.github/workflows/pr.yml
vendored
@ -1,56 +0,0 @@
|
|||||||
name: Validate PR
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [ master ]
|
|
||||||
types: [assigned, opened, synchronize, labeled]
|
|
||||||
|
|
||||||
env:
|
|
||||||
IMAGE_TO_TEST: itzg/mc-router:test-${{ github.repository_owner }}-${{ github.run_id }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
uses: docker/build-push-action@v3.2.0
|
|
||||||
with:
|
|
||||||
tags: ${{ env.IMAGE_TO_TEST }}
|
|
||||||
load: true
|
|
||||||
cache-from: type=gha
|
|
||||||
target: builder
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
docker run --rm --entrypoint go ${{ env.IMAGE_TO_TEST }} test ./...
|
|
||||||
|
|
||||||
- name: Gather Docker metadata
|
|
||||||
if: contains(github.event.pull_request.labels.*.name, 'ci/push-image')
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v4
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
itzg/mc-router
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
if: contains(github.event.pull_request.labels.*.name, 'ci/push-image')
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Push
|
|
||||||
if: contains(github.event.pull_request.labels.*.name, 'ci/push-image')
|
|
||||||
uses: docker/build-push-action@v3.2.0
|
|
||||||
with:
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
cache-from: type=gha
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
38
.github/workflows/release.yml
vendored
38
.github/workflows/release.yml
vendored
@ -8,35 +8,9 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-20.04
|
uses: itzg/github-workflows/.github/workflows/go-with-releaser-image.yml@main
|
||||||
env:
|
with:
|
||||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
go-version: "1.22.5"
|
||||||
steps:
|
secrets:
|
||||||
- name: Checkout
|
image-registry-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
uses: actions/checkout@v3
|
image-registry-password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v3
|
|
||||||
with:
|
|
||||||
go-version: 1.18
|
|
||||||
|
|
||||||
- name: Setup docker buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
with:
|
|
||||||
install: true
|
|
||||||
|
|
||||||
- name: Docker login
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v3
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
args: release --rm-dist
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
26
.github/workflows/test.yml
vendored
26
.github/workflows/test.yml
vendored
@ -1,28 +1,14 @@
|
|||||||
name: test
|
name: Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-20.04
|
uses: itzg/github-workflows/.github/workflows/go-test.yml@main
|
||||||
|
with:
|
||||||
steps:
|
go-version: "1.22.5"
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v3
|
|
||||||
with:
|
|
||||||
go-version: 1.17
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: go test ./...
|
|
||||||
|
|
||||||
- name: Run GoReleaser Snapshot
|
|
||||||
uses: goreleaser/goreleaser-action@v3
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
args: build --snapshot --rm-dist
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.17 as builder
|
FROM golang:1.22 AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@ -6,7 +6,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build ./cmd/mc-router
|
RUN CGO_ENABLED=0 go build -buildvcs=false ./cmd/mc-router
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
ENTRYPOINT ["/mc-router"]
|
ENTRYPOINT ["/mc-router"]
|
||||||
|
334
README.md
334
README.md
@ -7,116 +7,167 @@
|
|||||||
|
|
||||||
Routes Minecraft client connections to backend servers based upon the requested server address.
|
Routes Minecraft client connections to backend servers based upon the requested server address.
|
||||||
|
|
||||||
# Usage
|
## Usage
|
||||||
|
|
||||||
```text
|
```text
|
||||||
-api-binding host:port
|
-api-binding host:port
|
||||||
The host:port bound for servicing API requests (env API_BINDING)
|
The host:port bound for servicing API requests (env API_BINDING)
|
||||||
-auto-scale-up
|
-auto-scale-up
|
||||||
Increase Kubernetes StatefulSet Replicas (only) from 0 to 1 on respective backend servers when accessed (env AUTO_SCALE_UP)
|
Increase Kubernetes StatefulSet Replicas (only) from 0 to 1 on respective backend servers when accessed (env AUTO_SCALE_UP)
|
||||||
-connection-rate-limit int
|
-connection-rate-limit int
|
||||||
Max number of connections to allow per second (env CONNECTION_RATE_LIMIT) (default 1)
|
Max number of connections to allow per second (env CONNECTION_RATE_LIMIT) (default 1)
|
||||||
-cpu-profile string
|
-cpu-profile string
|
||||||
Enables CPU profiling and writes to given path (env CPU_PROFILE)
|
Enables CPU profiling and writes to given path (env CPU_PROFILE)
|
||||||
-debug
|
-debug
|
||||||
Enable debug logs (env DEBUG)
|
Enable debug logs (env DEBUG)
|
||||||
-in-kube-cluster
|
-default string
|
||||||
Use in-cluster Kubernetes config (env IN_KUBE_CLUSTER)
|
host:port of a default Minecraft server to use when mapping not found (env DEFAULT)
|
||||||
-kube-config string
|
-docker-socket
|
||||||
The path to a Kubernetes configuration file (env KUBE_CONFIG)
|
Path to Docker socket to use (env DOCKER_SOCKET) (default "unix:///var/run/docker.sock")
|
||||||
|
-docker-refresh-interval int
|
||||||
|
Refresh interval in seconds for the Docker Swarm integration (env DOCKER_REFRESH_INTERVAL) (default 15)
|
||||||
|
-docker-timeout int
|
||||||
|
Timeout configuration in seconds for the Docker Swarm integration (env DOCKER_TIMEOUT)
|
||||||
|
-in-docker
|
||||||
|
Use Docker service discovery (env IN_DOCKER)
|
||||||
-in-docker-swarm
|
-in-docker-swarm
|
||||||
Use in-swarm Docker config (env IN_DOCKER_SWARM)
|
Use Docker Swarm service discovery (env IN_DOCKER_SWARM)
|
||||||
-docker-timeout
|
-in-kube-cluster
|
||||||
Timeout configuration in seconds for the Docker Swarm integration (env DOCKER_TIMEOUT) (default 0)
|
Use in-cluster Kubernetes config (env IN_KUBE_CLUSTER)
|
||||||
-docker-refresh-interval
|
-kube-config string
|
||||||
Refresh interval in seconds for the Docker Swarm integration (env DOCKER_REFRESH_INTERVAL) (default 15)
|
The path to a Kubernetes configuration file (env KUBE_CONFIG)
|
||||||
-mapping string
|
-mapping value
|
||||||
Comma-separated mappings of externalHostname=host:port (env MAPPING)
|
Comma-separated or repeated mappings of externalHostname=host:port (env MAPPING)
|
||||||
-metrics-backend string
|
-metrics-backend string
|
||||||
Backend to use for metrics exposure/publishing: discard,expvar,influxdb (env METRICS_BACKEND) (default "discard")
|
Backend to use for metrics exposure/publishing: discard,expvar,influxdb (env METRICS_BACKEND) (default "discard")
|
||||||
-metrics-backend-config-influxdb-addr string
|
-metrics-backend-config-influxdb-addr string
|
||||||
(env METRICS_BACKEND_CONFIG_INFLUXDB_ADDR)
|
(env METRICS_BACKEND_CONFIG_INFLUXDB_ADDR)
|
||||||
-metrics-backend-config-influxdb-database string
|
-metrics-backend-config-influxdb-database string
|
||||||
(env METRICS_BACKEND_CONFIG_INFLUXDB_DATABASE)
|
(env METRICS_BACKEND_CONFIG_INFLUXDB_DATABASE)
|
||||||
-metrics-backend-config-influxdb-interval duration
|
-metrics-backend-config-influxdb-interval duration
|
||||||
(env METRICS_BACKEND_CONFIG_INFLUXDB_INTERVAL) (default 1m0s)
|
(env METRICS_BACKEND_CONFIG_INFLUXDB_INTERVAL) (default 1m0s)
|
||||||
-metrics-backend-config-influxdb-password string
|
-metrics-backend-config-influxdb-password string
|
||||||
(env METRICS_BACKEND_CONFIG_INFLUXDB_PASSWORD)
|
(env METRICS_BACKEND_CONFIG_INFLUXDB_PASSWORD)
|
||||||
-metrics-backend-config-influxdb-retention-policy string
|
-metrics-backend-config-influxdb-retention-policy string
|
||||||
(env METRICS_BACKEND_CONFIG_INFLUXDB_RETENTION_POLICY)
|
(env METRICS_BACKEND_CONFIG_INFLUXDB_RETENTION_POLICY)
|
||||||
-metrics-backend-config-influxdb-tags value
|
-metrics-backend-config-influxdb-tags value
|
||||||
any extra tags to be included with all reported metrics (env METRICS_BACKEND_CONFIG_INFLUXDB_TAGS)
|
any extra tags to be included with all reported metrics (env METRICS_BACKEND_CONFIG_INFLUXDB_TAGS)
|
||||||
-metrics-backend-config-influxdb-username string
|
-metrics-backend-config-influxdb-username string
|
||||||
(env METRICS_BACKEND_CONFIG_INFLUXDB_USERNAME)
|
(env METRICS_BACKEND_CONFIG_INFLUXDB_USERNAME)
|
||||||
|
-ngrok-token string
|
||||||
|
If set, an ngrok tunnel will be established. It is HIGHLY recommended to pass as an environment variable. (env NGROK_TOKEN)
|
||||||
-port port
|
-port port
|
||||||
The port bound to listen for Minecraft client connections (env PORT) (default 25565)
|
The port bound to listen for Minecraft client connections (env PORT) (default 25565)
|
||||||
|
-receive-proxy-protocol
|
||||||
|
Receive PROXY protocol from backend servers, by default trusts every proxy header that it receives, combine with -trusted-proxies to specify a list of trusted proxies (env RECEIVE_PROXY_PROTOCOL)
|
||||||
|
-routes-config string
|
||||||
|
Name or full path to routes config file (env ROUTES_CONFIG)
|
||||||
|
-simplify-srv
|
||||||
|
Simplify fully qualified SRV records for mapping (env SIMPLIFY_SRV)
|
||||||
|
-trusted-proxies value
|
||||||
|
Comma delimited list of CIDR notation IP blocks to trust when receiving PROXY protocol (env TRUSTED_PROXIES)
|
||||||
|
-use-proxy-protocol
|
||||||
|
Send PROXY protocol to backend servers (env USE_PROXY_PROTOCOL)
|
||||||
-version
|
-version
|
||||||
Output version and exit (env VERSION)
|
Output version and exit (env VERSION)
|
||||||
```
|
```
|
||||||
|
|
||||||
# REST API
|
|
||||||
|
|
||||||
* `GET /routes` (with `Accept: application/json`)
|
## Docker Multi-Architecture Image
|
||||||
|
|
||||||
Retrieves the currently configured routes
|
|
||||||
|
|
||||||
* `POST /routes` (with `Content-Type: application/json`)
|
|
||||||
|
|
||||||
Registers a route given a JSON body structured like:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"serverAddress": "CLIENT REQUESTED SERVER ADDRESS",
|
|
||||||
"backend": "HOST:PORT"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* `POST /defaultRoute` (with `Content-Type: application/json`)
|
|
||||||
|
|
||||||
Registers a default route to the given backend. JSON body is structured as:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"backend": "HOST:PORT"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* `DELETE /routes/{serverAddress}`
|
|
||||||
|
|
||||||
Deletes an existing route for the given `serverAddress`
|
|
||||||
|
|
||||||
# Docker Multi-Architecture Image
|
|
||||||
|
|
||||||
The [multi-architecture image published at Docker Hub](https://hub.docker.com/repository/docker/itzg/mc-router) supports amd64, arm64, and arm32v6 (i.e. RaspberryPi).
|
The [multi-architecture image published at Docker Hub](https://hub.docker.com/repository/docker/itzg/mc-router) supports amd64, arm64, and arm32v6 (i.e. RaspberryPi).
|
||||||
|
|
||||||
# Docker Compose Usage
|
## Docker Compose Usage
|
||||||
|
|
||||||
The following diagram shows how [the example docker-compose.yml](docs/docker-compose.yml)
|
The diagram below shows how this `docker-compose.yml` configures two Minecraft server services named `vanilla` and `forge`, which also become the internal network aliases. _Notice those services don't need their ports exposed since the internal networking allows for the inter-container access._
|
||||||
configures two Minecraft server services named `vanilla` and `forge`, which also become the internal
|
|
||||||
network aliases. _Notice those services don't need their ports exposed since the internal
|
|
||||||
networking allows for the inter-container access._
|
|
||||||
|
|
||||||
The `router` service is only one of the services that needs to exposed on the external
|
```yaml
|
||||||
network. The `--mapping` declares how the hostname users will enter into their Minecraft client
|
version: "3.8"
|
||||||
will map to the internal services.
|
|
||||||
|
services:
|
||||||
|
vanilla:
|
||||||
|
image: itzg/minecraft-server
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
forge:
|
||||||
|
image: itzg/minecraft-server
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
TYPE: FORGE
|
||||||
|
router:
|
||||||
|
image: ${MC_ROUTER_IMAGE:-itzg/mc-router}
|
||||||
|
depends_on:
|
||||||
|
- forge
|
||||||
|
- vanilla
|
||||||
|
environment:
|
||||||
|
MAPPING: |
|
||||||
|
vanilla.example.com=vanilla:25565
|
||||||
|
forge.example.com=forge:25565
|
||||||
|
ports:
|
||||||
|
- "25565:25565"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `router` service is only one of the services that needs to exposed on the external network. The `MAPPING` declares how the hostname users will enter into their Minecraft client will map to the internal services.
|
||||||
|
|
||||||
![](docs/compose-diagram.png)
|
![](docs/compose-diagram.png)
|
||||||
|
|
||||||
To test out this example, I added these two entries to my "hosts" file:
|
To test out this example, add these two entries to my "hosts" file:
|
||||||
|
|
||||||
```
|
```
|
||||||
127.0.0.1 vanilla.example.com
|
127.0.0.1 vanilla.example.com
|
||||||
127.0.0.1 forge.example.com
|
127.0.0.1 forge.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
# Kubernetes Usage
|
### Using Docker auto-discovery
|
||||||
|
|
||||||
## Using Kubernetes Service auto-discovery
|
When running `mc-router` in a Docker environment you can pass the `--in-docker` or `--in-docker-swarm`
|
||||||
|
command-line argument and it will poll the Docker API periodically to find all the running
|
||||||
|
containers/services for Minecraft instances. To enable discovery you have to set the `mc-router.host`
|
||||||
|
label on the container. These are the labels scanned:
|
||||||
|
|
||||||
When running `mc-router` as a Kubernetes Pod and you pass the `--in-kube-cluster` command-line argument, then
|
- `mc-router.host`: Used to configure the hostname the Minecraft clients would use to
|
||||||
it will automatically watch for any services annotated with
|
connect to the server. The container/service endpoint will be used as the routed backend. You can
|
||||||
- `mc-router.itzg.me/externalServerName` : The value of the annotation will be registered as the external hostname Minecraft clients would used to connect to the
|
use more than one hostname by splitting it with a comma.
|
||||||
routed service. The service's clusterIP and target port are used as the routed backend. You can use more hostnames by splitting them with comma.
|
- `mc-router.port`: This value must be set to the port the Minecraft server is listening on.
|
||||||
- `mc-router.itzg.me/defaultServer` : The service's clusterIP and target port are used as the default if
|
The default value is 25565.
|
||||||
no other `externalServiceName` annotations applies.
|
- `mc-router.default`: Set this to a truthy value to make this server the default backend.
|
||||||
|
Please note that `mc-router.host` is still required to be set.
|
||||||
|
- `mc-router.network`: Specify the network you are using for the router if multiple are
|
||||||
|
present in the container/service. You can either use the network ID, it's full name or an alias.
|
||||||
|
|
||||||
|
#### Example Docker deployment
|
||||||
|
|
||||||
|
Refer to [this example docker-compose.yml](docs/sd-docker.docker-compose.yml) to see how to
|
||||||
|
configure two different Minecraft servers and a `mc-router` instance for use with Docker service discovery.
|
||||||
|
|
||||||
|
#### Example Docker Swarm deployment
|
||||||
|
|
||||||
|
Refer to [this example docker-compose.yml](docs/swarm.docker-compose.yml) to see how to
|
||||||
|
configure two different Minecraft servers and a `mc-router` instance for use with Docker Swarm service discovery.
|
||||||
|
|
||||||
|
## Routing Configuration
|
||||||
|
|
||||||
|
The routing configuration allows routing via a config file rather than a command.
|
||||||
|
You need to set `-routes-config` or `ROUTES_CONFIG` env variable.
|
||||||
|
The following shows a JSON file for routes config, where `default-server` can also be `null` or omitted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default-server": "vanilla:25565",
|
||||||
|
"mappings": {
|
||||||
|
"vanilla.example.com": "vanilla:25565",
|
||||||
|
"forge.example.com": "forge:25565"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kubernetes Usage
|
||||||
|
|
||||||
|
### Using Kubernetes Service auto-discovery
|
||||||
|
|
||||||
|
When running `mc-router` as a Kubernetes Pod and you pass the `--in-kube-cluster` command-line argument, then it will automatically watch for any services annotated with
|
||||||
|
- `mc-router.itzg.me/externalServerName` : The value of the annotation will be registered as the external hostname Minecraft clients would used to connect to the routed service. The service's clusterIP and target port are used as the routed backend. You can use more hostnames by splitting them with comma.
|
||||||
|
- `mc-router.itzg.me/defaultServer` : The service's clusterIP and target port are used as the default if no other `externalServiceName` annotations applies.
|
||||||
|
|
||||||
For example, start `mc-router`'s container spec with
|
For example, start `mc-router`'s container spec with
|
||||||
|
|
||||||
@ -148,7 +199,9 @@ metadata:
|
|||||||
"mc-router.itzg.me/externalServerName": "external.host.name,other.host.name"
|
"mc-router.itzg.me/externalServerName": "external.host.name,other.host.name"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example Kubernetes deployment
|
mc-router will pick the service port named either `minecraft` or `mc-router`. If neither port names exist, it will use port value 25565.
|
||||||
|
|
||||||
|
### Example Kubernetes deployment
|
||||||
|
|
||||||
[This example deployment](docs/k8s-example-auto.yaml)
|
[This example deployment](docs/k8s-example-auto.yaml)
|
||||||
* Declares an `mc-router` service that exposes a node port 25565
|
* Declares an `mc-router` service that exposes a node port 25565
|
||||||
@ -163,12 +216,12 @@ kubectl apply -f https://raw.githubusercontent.com/itzg/mc-router/master/docs/k8
|
|||||||
|
|
||||||
![](docs/example-deployment-auto.drawio.png)
|
![](docs/example-deployment-auto.drawio.png)
|
||||||
|
|
||||||
#### Notes
|
##### Notes
|
||||||
* This deployment assumes two persistent volume claims: `mc-stable` and `mc-snapshot`
|
* This deployment assumes two persistent volume claims: `mc-stable` and `mc-snapshot`
|
||||||
* I extended the allowed node port range by adding `--service-node-port-range=25000-32767`
|
* I extended the allowed node port range by adding `--service-node-port-range=25000-32767`
|
||||||
to `/etc/kubernetes/manifests/kube-apiserver.yaml`
|
to `/etc/kubernetes/manifests/kube-apiserver.yaml`
|
||||||
|
|
||||||
#### Auto Scale Up
|
##### Auto Scale Up
|
||||||
|
|
||||||
The `-auto-scale-up` flag argument makes the router "wake up" any stopped backend servers, by changing `replicas: 0` to `replicas: 1`.
|
The `-auto-scale-up` flag argument makes the router "wake up" any stopped backend servers, by changing `replicas: 0` to `replicas: 1`.
|
||||||
|
|
||||||
@ -191,39 +244,120 @@ rules:
|
|||||||
verbs: ["watch","list","get","update"]
|
verbs: ["watch","list","get","update"]
|
||||||
```
|
```
|
||||||
|
|
||||||
# Docker Swarm Usage
|
Make sure to set `StatefulSet.metadata.name` and `StatefulSet.spec.serviceName` to the same value;
|
||||||
|
otherwise, autoscaling will not trigger:
|
||||||
|
|
||||||
## Using Docker Swarm Service auto-discovery
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: mc-forge
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
annotations:
|
||||||
|
"mc-router.itzg.me/defaultServer": "true"
|
||||||
|
"mc-router.itzg.me/externalServerName": "external.host.name"
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: mc-forge
|
||||||
|
spec:
|
||||||
|
serviceName: mc-forge
|
||||||
|
```
|
||||||
|
|
||||||
When running `mc-router` in a Docker Swarm environment you can pass the `--in-docker-swarm`
|
## REST API
|
||||||
command-line argument and it will poll the Docker API periodically to find all the running
|
|
||||||
services for minecraft instances. To enable discovery you have to set the `mc-router.host`
|
|
||||||
label on the service. These are the labels scanned:
|
|
||||||
|
|
||||||
- `mc-router.host`: Used to configure the hostname the Minecraft clients would use to
|
* `GET /routes` (with `Accept: application/json`)
|
||||||
connect to the server. The service endpoint will be used as the routed backend. You can
|
|
||||||
use more than one hostname by splitting it with a comma.
|
|
||||||
- `mc-router.port`: This value must be set to the port the Minecraft server is listening on.
|
|
||||||
The default value is 25565.
|
|
||||||
- `mc-router.default`: Set this to a truthy value to make this server the deafult backend.
|
|
||||||
Please note that `mc-router.host` is still required to be set.
|
|
||||||
|
|
||||||
## Example Docker Swarm deployment
|
Retrieves the currently configured routes
|
||||||
|
|
||||||
Refer to [this example docker-compose.yml](docs/swarm.docker-compose.yml) to see how to
|
* `POST /routes` (with `Content-Type: application/json`)
|
||||||
configure two different Minecraft servers and a `mc-router` instance. Notice how you don't
|
|
||||||
have to expose the Minecraft instances ports, but all the containers are required to be in
|
|
||||||
the same network.
|
|
||||||
|
|
||||||
# Development
|
Registers a route given a JSON body structured like:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"serverAddress": "CLIENT REQUESTED SERVER ADDRESS",
|
||||||
|
"backend": "HOST:PORT"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Building locally with Docker
|
* `POST /defaultRoute` (with `Content-Type: application/json`)
|
||||||
|
|
||||||
|
Registers a default route to the given backend. JSON body is structured as:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backend": "HOST:PORT"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `DELETE /routes/{serverAddress}`
|
||||||
|
|
||||||
|
Deletes an existing route for the given `serverAddress`
|
||||||
|
|
||||||
|
## ngrok
|
||||||
|
|
||||||
|
mc-router has built-in support to run as an [ngrok agent](https://ngrok.com/docs/secure-tunnels/ngrok-agent/). To enable this support, pass [an ngrok authtoken](https://ngrok.com/docs/secure-tunnels/ngrok-agent/tunnel-authtokens/#per-agent-authtokens) to the command-line argument or environment variable, [shown above](#usage).
|
||||||
|
|
||||||
|
### Ngrok Quick Start
|
||||||
|
|
||||||
|
Create/access an ngrok account and [allocate an agent authtoken from the dashboard](https://dashboard.ngrok.com/tunnels/authtokens).
|
||||||
|
|
||||||
|
In a new directory, create a file called `.env` with the allocated token
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NGROK_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
In the same directory, create the following compose file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
mc:
|
||||||
|
image: itzg/minecraft-server
|
||||||
|
environment:
|
||||||
|
EULA: true
|
||||||
|
volumes:
|
||||||
|
- mc-data:/data
|
||||||
|
# No port mapping since mc-router connects over compose network
|
||||||
|
router:
|
||||||
|
image: itzg/mc-router
|
||||||
|
environment:
|
||||||
|
DEFAULT: mc:25565
|
||||||
|
NGROK_TOKEN: ${NGROK_TOKEN}
|
||||||
|
# No port mapping needed since it routes through ngrok tunnel
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mc-data: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the compose project:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Grab the mc-router logs using:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose logs router
|
||||||
|
```
|
||||||
|
|
||||||
|
From those logs, locate the `ngrokUrl` parameter from the "Listening" info log message, such as `tcp://8.tcp.ngrok.io:99999`.
|
||||||
|
|
||||||
|
In the Minecraft client, the server address will be the part after the "tcp://" prefix, such as `8.tcp.ngrok.io:99999`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Building locally with Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t mc-router .
|
docker build -t mc-router .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build locally without Docker
|
### Build locally without Docker
|
||||||
|
|
||||||
After [installing Go](https://go.dev/doc/install) and doing a `go mod download` to install all required prerequisites, just like the [Dockerfile](Dockerfile) does, you can:
|
After [installing Go](https://go.dev/doc/install) and doing a `go mod download` to install all required prerequisites, just like the [Dockerfile](Dockerfile) does, you can:
|
||||||
|
|
||||||
@ -232,7 +366,7 @@ make test # go test -v ./...
|
|||||||
go build ./cmd/mc-router/
|
go build ./cmd/mc-router/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Skaffold
|
### Skaffold
|
||||||
|
|
||||||
For "in-cluster development" it's convenient to use https://skaffold.dev. Any changes to Go source code
|
For "in-cluster development" it's convenient to use https://skaffold.dev. Any changes to Go source code
|
||||||
will trigger a go build, new container image pushed to registry with a new tag, and refresh in Kubernetes
|
will trigger a go build, new container image pushed to registry with a new tag, and refresh in Kubernetes
|
||||||
@ -246,7 +380,7 @@ then add the _Artifact Registry Reader_ Role to the _Compute Engine default serv
|
|||||||
then use e.g. `gcloud auth configure-docker europe-docker.pkg.dev` or equivalent one time (to create a `~/.docker/config.json`),
|
then use e.g. `gcloud auth configure-docker europe-docker.pkg.dev` or equivalent one time (to create a `~/.docker/config.json`),
|
||||||
and then use e.g. `--default-repo=europe-docker.pkg.dev/YOUR-PROJECT/YOUR-ARTIFACT-REGISTRY` option for `skaffold dev`.
|
and then use e.g. `--default-repo=europe-docker.pkg.dev/YOUR-PROJECT/YOUR-ARTIFACT-REGISTRY` option for `skaffold dev`.
|
||||||
|
|
||||||
## Performing snapshot release with Docker
|
### Performing snapshot release with Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -it --rm \
|
docker run -it --rm \
|
||||||
@ -256,6 +390,6 @@ docker run -it --rm \
|
|||||||
release --snapshot --rm-dist
|
release --snapshot --rm-dist
|
||||||
```
|
```
|
||||||
|
|
||||||
# Related Projects
|
## Related Projects
|
||||||
|
|
||||||
* https://github.com/haveachin/infrared
|
* https://github.com/haveachin/infrared
|
||||||
|
@ -8,7 +8,6 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime/pprof"
|
"runtime/pprof"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -30,22 +29,29 @@ type MetricsBackendConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int `default:"25565" usage:"The [port] bound to listen for Minecraft client connections"`
|
Port int `default:"25565" usage:"The [port] bound to listen for Minecraft client connections"`
|
||||||
Mapping []string `usage:"Comma-separated or repeated mappings of externalHostname=host:port"`
|
Default string `usage:"host:port of a default Minecraft server to use when mapping not found"`
|
||||||
ApiBinding string `usage:"The [host:port] bound for servicing API requests"`
|
Mapping map[string]string `usage:"Comma or newline delimited or repeated mappings of externalHostname=host:port"`
|
||||||
Version bool `usage:"Output version and exit"`
|
ApiBinding string `usage:"The [host:port] bound for servicing API requests"`
|
||||||
CpuProfile string `usage:"Enables CPU profiling and writes to given path"`
|
Version bool `usage:"Output version and exit"`
|
||||||
Debug bool `usage:"Enable debug logs"`
|
CpuProfile string `usage:"Enables CPU profiling and writes to given path"`
|
||||||
ConnectionRateLimit int `default:"1" usage:"Max number of connections to allow per second"`
|
Debug bool `usage:"Enable debug logs"`
|
||||||
InKubeCluster bool `usage:"Use in-cluster Kubernetes config"`
|
ConnectionRateLimit int `default:"1" usage:"Max number of connections to allow per second"`
|
||||||
KubeConfig string `usage:"The path to a Kubernetes configuration file"`
|
InKubeCluster bool `usage:"Use in-cluster Kubernetes config"`
|
||||||
AutoScaleUp bool `usage:"Increase Kubernetes StatefulSet Replicas (only) from 0 to 1 on respective backend servers when accessed"`
|
KubeConfig string `usage:"The path to a Kubernetes configuration file"`
|
||||||
InDockerSwarm bool `usage:"Use in-swarm Docker config"`
|
AutoScaleUp bool `usage:"Increase Kubernetes StatefulSet Replicas (only) from 0 to 1 on respective backend servers when accessed"`
|
||||||
DockerTimeout int `default:"0" usage:"Timeout configuration in seconds for the Docker Swarm integration"`
|
InDocker bool `usage:"Use Docker service discovery"`
|
||||||
DockerRefreshInterval int `default:"15" usage:"Refresh interval in seconds for the Docker Swarm integration"`
|
InDockerSwarm bool `usage:"Use Docker Swarm service discovery"`
|
||||||
MetricsBackend string `default:"discard" usage:"Backend to use for metrics exposure/publishing: discard,expvar,influxdb"`
|
DockerSocket string `default:"unix:///var/run/docker.sock" usage:"Path to Docker socket to use"`
|
||||||
UseProxyProtocol bool `default:"false" usage:"Send PROXY protocol to backend servers"`
|
DockerTimeout int `default:"0" usage:"Timeout configuration in seconds for the Docker integrations"`
|
||||||
|
DockerRefreshInterval int `default:"15" usage:"Refresh interval in seconds for the Docker integrations"`
|
||||||
|
MetricsBackend string `default:"discard" usage:"Backend to use for metrics exposure/publishing: discard,expvar,influxdb"`
|
||||||
|
UseProxyProtocol bool `default:"false" usage:"Send PROXY protocol to backend servers"`
|
||||||
|
ReceiveProxyProtocol bool `default:"false" usage:"Receive PROXY protocol from backend servers, by default trusts every proxy header that it receives, combine with -trusted-proxies to specify a list of trusted proxies"`
|
||||||
|
TrustedProxies []string `usage:"Comma delimited list of CIDR notation IP blocks to trust when receiving PROXY protocol"`
|
||||||
MetricsBackendConfig MetricsBackendConfig
|
MetricsBackendConfig MetricsBackendConfig
|
||||||
|
RoutesConfig string `usage:"Name or full path to routes config file"`
|
||||||
|
NgrokToken string `usage:"If set, an ngrok tunnel will be established. It is HIGHLY recommended to pass as an environment variable."`
|
||||||
|
|
||||||
SimplifySRV bool `default:"false" usage:"Simplify fully qualified SRV records for mapping"`
|
SimplifySRV bool `default:"false" usage:"Simplify fully qualified SRV records for mapping"`
|
||||||
}
|
}
|
||||||
@ -100,12 +106,35 @@ func main() {
|
|||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
server.Routes.RegisterAll(parseMappings(config.Mapping))
|
if config.RoutesConfig != "" {
|
||||||
|
err := server.RoutesConfig.ReadRoutesConfig(config.RoutesConfig)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Unable to load routes from config file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.Routes.RegisterAll(config.Mapping)
|
||||||
|
if config.Default != "" {
|
||||||
|
server.Routes.SetDefaultRoute(config.Default)
|
||||||
|
}
|
||||||
|
|
||||||
if config.ConnectionRateLimit < 1 {
|
if config.ConnectionRateLimit < 1 {
|
||||||
config.ConnectionRateLimit = 1
|
config.ConnectionRateLimit = 1
|
||||||
}
|
}
|
||||||
connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol)
|
|
||||||
|
trustedIpNets := make([]*net.IPNet, 0)
|
||||||
|
for _, ip := range config.TrustedProxies {
|
||||||
|
_, ipNet, err := net.ParseCIDR(ip)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Fatal("Unable to parse trusted proxy CIDR block")
|
||||||
|
}
|
||||||
|
trustedIpNets = append(trustedIpNets, ipNet)
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets)
|
||||||
|
if config.NgrokToken != "" {
|
||||||
|
connector.UseNgrok(config.NgrokToken)
|
||||||
|
}
|
||||||
err = connector.StartAcceptingConnections(ctx,
|
err = connector.StartAcceptingConnections(ctx,
|
||||||
net.JoinHostPort("", strconv.Itoa(config.Port)),
|
net.JoinHostPort("", strconv.Itoa(config.Port)),
|
||||||
config.ConnectionRateLimit,
|
config.ConnectionRateLimit,
|
||||||
@ -134,12 +163,21 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.InDocker {
|
||||||
|
err = server.DockerWatcher.Start(config.DockerSocket, config.DockerTimeout, config.DockerRefreshInterval)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Fatal("Unable to start docker integration")
|
||||||
|
} else {
|
||||||
|
defer server.DockerWatcher.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.InDockerSwarm {
|
if config.InDockerSwarm {
|
||||||
err = server.DockerWatcher.StartInSwarm(config.DockerTimeout, config.DockerRefreshInterval)
|
err = server.DockerSwarmWatcher.Start(config.DockerSocket, config.DockerTimeout, config.DockerRefreshInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).Fatal("Unable to start docker swarm integration")
|
logrus.WithError(err).Fatal("Unable to start docker swarm integration")
|
||||||
} else {
|
} else {
|
||||||
defer server.DockerWatcher.Stop()
|
defer server.DockerSwarmWatcher.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,17 +195,3 @@ func main() {
|
|||||||
connector.WaitForConnections()
|
connector.WaitForConnections()
|
||||||
logrus.Info("Stopped")
|
logrus.Info("Stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseMappings(vals []string) map[string]string {
|
|
||||||
result := make(map[string]string)
|
|
||||||
for _, part := range vals {
|
|
||||||
keyValue := strings.Split(part, "=")
|
|
||||||
if len(keyValue) == 2 {
|
|
||||||
result[keyValue[0]] = keyValue[1]
|
|
||||||
} else {
|
|
||||||
logrus.WithField("part", part).Fatal("Invalid part of mapping")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
1
docs/.gitignore
vendored
Normal file
1
docs/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.env
|
@ -1,5 +1,3 @@
|
|||||||
version: '3.4'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
vanilla:
|
vanilla:
|
||||||
image: itzg/minecraft-server
|
image: itzg/minecraft-server
|
||||||
@ -18,9 +16,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# enable API
|
# enable API
|
||||||
API_BINDING: ":25564"
|
API_BINDING: ":25564"
|
||||||
|
DEBUG: true
|
||||||
|
MAPPING: |
|
||||||
|
vanilla.example.com=vanilla:25565
|
||||||
|
forge.example.com=forge:25565
|
||||||
ports:
|
ports:
|
||||||
- 25565:25565
|
- "25565:25565"
|
||||||
# bind the API port to only loopback to avoid external exposure
|
# bind the API port to only loopback to avoid external exposure
|
||||||
- 127.0.0.1:25564:25564
|
- "127.0.0.1:25564:25564"
|
||||||
command: --mapping=vanilla.example.com=vanilla:25565,forge.example.com=forge:25565
|
|
||||||
|
|
||||||
|
278
docs/k8s-autoscale.yaml
Normal file
278
docs/k8s-autoscale.yaml
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
|
||||||
|
# This YAML is an example and is not intended to be directly applied.
|
||||||
|
# It consists of 3 parts
|
||||||
|
# 1. the mc-router with service account an service
|
||||||
|
# 2. the shutdown cronjob with service account
|
||||||
|
# 3. the actual server with service and storage
|
||||||
|
|
||||||
|
# part 3 is the only part you need to replicate for the number of servers you want
|
||||||
|
|
||||||
|
# mc-router
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: mc-router
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: services-watcher
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["services"]
|
||||||
|
verbs: ["watch","list"]
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["statefulsets", "statefulsets/scale"]
|
||||||
|
verbs: ["watch","list","get","update"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: mc-router-services-watcher
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: mc-router
|
||||||
|
namespace: default
|
||||||
|
roleRef:
|
||||||
|
kind: ClusterRole
|
||||||
|
name: services-watcher
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
# Use whatever tcp ingress method you want I just used a node port here for simplicity
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: mc-router
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
externalIPs:
|
||||||
|
- 192.168.1.100
|
||||||
|
ports:
|
||||||
|
- targetPort: web
|
||||||
|
name: web
|
||||||
|
port: 8080
|
||||||
|
nodePort: 30001
|
||||||
|
- targetPort: proxy
|
||||||
|
name: proxy
|
||||||
|
port: 25565
|
||||||
|
nodePort: 30000
|
||||||
|
selector:
|
||||||
|
run: mc-router
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
run: mc-router
|
||||||
|
name: mc-router
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
run: mc-router
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
run: mc-router
|
||||||
|
spec:
|
||||||
|
serviceAccountName: mc-router
|
||||||
|
containers:
|
||||||
|
- image: aapjeisbaas/mc-router:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
name: mc-router
|
||||||
|
args: ["--api-binding", ":8080", "--in-kube-cluster","--auto-scale-up", "--debug"]
|
||||||
|
env:
|
||||||
|
- name: AUTO_SCALE_UP
|
||||||
|
value: "true"
|
||||||
|
ports:
|
||||||
|
- name: proxy
|
||||||
|
containerPort: 25565
|
||||||
|
- name: web
|
||||||
|
containerPort: 8080
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 50Mi
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: 100Mi
|
||||||
|
cpu: "250m"
|
||||||
|
|
||||||
|
|
||||||
|
# Cron job for stopping empty servers
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: mc-shutdown
|
||||||
|
rules:
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["statefulsets", "statefulsets/scale"]
|
||||||
|
verbs: ["list","get","update", "patch"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods", "pods/log"]
|
||||||
|
verbs: ["get", "list"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods/exec"]
|
||||||
|
verbs: ["create"]
|
||||||
|
---
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: mc-shutdown
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: mc-shutdown
|
||||||
|
namespace: default
|
||||||
|
roleRef:
|
||||||
|
kind: ClusterRole
|
||||||
|
name: mc-shutdown
|
||||||
|
apiGroup: "rbac.authorization.k8s.io"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: mc-shutdown
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: mc-shutdown
|
||||||
|
spec:
|
||||||
|
schedule: "*/5 * * * *"
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: mc-shutdown
|
||||||
|
restartPolicy: OnFailure
|
||||||
|
containers:
|
||||||
|
- name: shutdown
|
||||||
|
image: bitnami/kubectl:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- source shutdown-script.sh
|
||||||
|
volumeMounts:
|
||||||
|
- name: shutdown-script
|
||||||
|
mountPath: /shutdown-script.sh
|
||||||
|
subPath: shutdown-script.sh
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- name: shutdown-script
|
||||||
|
configMap:
|
||||||
|
name: shutdown-script
|
||||||
|
items:
|
||||||
|
- key: shutdown-script.sh
|
||||||
|
path: shutdown-script.sh
|
||||||
|
# uses container label containertype=minecraft-server to find running servers
|
||||||
|
# TODO: get ownerReferences link to StatefulSet/name from pod metadate instead of sed string manipulation
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: shutdown-script
|
||||||
|
data:
|
||||||
|
shutdown-script.sh: |
|
||||||
|
#!/bin/bash
|
||||||
|
MC_PODS=$(kubectl get pods -l containertype=minecraft-server -o=jsonpath="{range .items[*]}{.metadata.name},"| sed 's/,/\n/g')
|
||||||
|
for p in $MC_PODS; do
|
||||||
|
echo "found minecraft pod $p, sleeping 120 seconds to prevent shutdown before login"
|
||||||
|
sleep 120
|
||||||
|
deployment=$(echo $p |sed 's/-0//g')
|
||||||
|
# check online player count in the mc server
|
||||||
|
if [[ $(kubectl exec -i $p -- /usr/local/bin/mc-monitor status) == *"online=0"* ]] ;then
|
||||||
|
kubectl scale statefulset $deployment --replicas=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
|
# The actual minecraft servers, services and storage, repeat this block for as many servers as you want
|
||||||
|
# make sure you have the label containertype=minecraft-server this is used to find running servers
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: minecraft-servername-deployment
|
||||||
|
labels:
|
||||||
|
app: minecraft-servername-container
|
||||||
|
spec:
|
||||||
|
serviceName: minecraft-servername-deployment
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: minecraft-servername-container
|
||||||
|
replicas: 0
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: minecraft-servername-container
|
||||||
|
containertype: minecraft-server
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: minecraft-servername-deployment
|
||||||
|
image: itzg/minecraft-server:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "2048Mi"
|
||||||
|
env:
|
||||||
|
# Use secret in real usage
|
||||||
|
- name: EULA
|
||||||
|
value: "true"
|
||||||
|
# let the JVM figure out mem management
|
||||||
|
- name: "MEMORY"
|
||||||
|
value: ""
|
||||||
|
- name: JVM_XX_OPTS
|
||||||
|
value: "-XX:MaxRAMPercentage=75"
|
||||||
|
ports:
|
||||||
|
- containerPort: 25565
|
||||||
|
name: main
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: [ "/usr/local/bin/mc-monitor", "status", "--host", "localhost" ]
|
||||||
|
# Give it i + p * f seconds to be ready, so 120 seconds
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 20
|
||||||
|
# Monitor ongoing liveness
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["/usr/local/bin/mc-monitor", "status", "--host", "localhost"]
|
||||||
|
initialDelaySeconds: 120
|
||||||
|
periodSeconds: 60
|
||||||
|
volumeMounts:
|
||||||
|
- name: mc-data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: mc-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: minecraft-servername-pvc
|
||||||
|
readOnly: false
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: minecraft-servername-pvc
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteMany
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: minecraft-servername-deployment
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
"mc-router.itzg.me/externalServerName": "your-awesome-server.public-domain.com"
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 25565
|
||||||
|
selector:
|
||||||
|
app: minecraft-servername-container
|
19
docs/ngrok/docker-compose.yml
Normal file
19
docs/ngrok/docker-compose.yml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
mc:
|
||||||
|
image: itzg/minecraft-server
|
||||||
|
environment:
|
||||||
|
EULA: true
|
||||||
|
volumes:
|
||||||
|
- mc-data:/data
|
||||||
|
# No port mapping since mc-router connects over compose network
|
||||||
|
router:
|
||||||
|
image: itzg/mc-router
|
||||||
|
environment:
|
||||||
|
DEFAULT: mc:25565
|
||||||
|
NGROK_TOKEN: ${NGROK_TOKEN}
|
||||||
|
# No port mapping needed since it routes through ngrok tunnel
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mc-data: {}
|
34
docs/sd-docker.docker-compose.yml
Normal file
34
docs/sd-docker.docker-compose.yml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
mcfoo:
|
||||||
|
image: itzg/minecraft-server
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
MOTD: "foo"
|
||||||
|
volumes:
|
||||||
|
- mcfoodata:/data
|
||||||
|
labels:
|
||||||
|
mc-router.host: "foo.localhost"
|
||||||
|
mc-router.default: true
|
||||||
|
mcbar:
|
||||||
|
image: itzg/minecraft-server
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
MOTD: "bar"
|
||||||
|
volumes:
|
||||||
|
- mcbardata:/data
|
||||||
|
labels:
|
||||||
|
mc-router.host: "bar.localhost"
|
||||||
|
router:
|
||||||
|
image: itzg/mc-router:latest
|
||||||
|
command: "-debug -in-docker"
|
||||||
|
depends_on:
|
||||||
|
- mcfoo
|
||||||
|
- mcbar
|
||||||
|
ports:
|
||||||
|
- "25565:25565"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mcfoodata:
|
||||||
|
mcbardata:
|
@ -23,6 +23,7 @@ services:
|
|||||||
replicas: 1
|
replicas: 1
|
||||||
labels:
|
labels:
|
||||||
- "mc-router.host=foo.host.name,bar.host.name"
|
- "mc-router.host=foo.host.name,bar.host.name"
|
||||||
|
- "mc-router.network=minecraft" # not required in this case
|
||||||
volumes:
|
volumes:
|
||||||
- mcfoobardata:/data
|
- mcfoobardata:/data
|
||||||
networks:
|
networks:
|
||||||
@ -39,6 +40,9 @@ services:
|
|||||||
replicas: 1
|
replicas: 1
|
||||||
labels:
|
labels:
|
||||||
- "mc-router.host=baz.host.name"
|
- "mc-router.host=baz.host.name"
|
||||||
|
- "mc-router.network=minecraft" # required since we are exposing a port
|
||||||
|
ports:
|
||||||
|
- "25575:25575" # RCON
|
||||||
volumes:
|
volumes:
|
||||||
- mcbazdata:/data
|
- mcbazdata:/data
|
||||||
networks:
|
networks:
|
||||||
|
102
go.mod
102
go.mod
@ -1,68 +1,98 @@
|
|||||||
module github.com/itzg/mc-router
|
module github.com/itzg/mc-router
|
||||||
|
|
||||||
go 1.17
|
go 1.21
|
||||||
|
|
||||||
|
toolchain go1.22.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-kit/kit v0.12.0
|
github.com/go-kit/kit v0.13.0
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab
|
github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c
|
||||||
github.com/itzg/go-flagsfiller v1.9.1
|
github.com/itzg/go-flagsfiller v1.14.0
|
||||||
github.com/juju/ratelimit v1.0.2
|
github.com/juju/ratelimit v1.0.2
|
||||||
github.com/pires/go-proxyproto v0.6.2
|
github.com/pires/go-proxyproto v0.7.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/sirupsen/logrus v1.9.0
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/stretchr/testify v1.8.0
|
github.com/stretchr/testify v1.9.0
|
||||||
golang.org/x/text v0.4.0
|
golang.ngrok.com/ngrok v1.10.0
|
||||||
k8s.io/api v0.18.5
|
golang.org/x/text v0.18.0
|
||||||
k8s.io/apimachinery v0.18.5
|
k8s.io/api v0.28.3
|
||||||
k8s.io/client-go v0.18.5
|
k8s.io/apimachinery v0.28.3
|
||||||
|
k8s.io/client-go v0.28.3
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
|
github.com/distribution/reference v0.5.0 // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.9.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.1 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.22.3 // indirect
|
||||||
|
github.com/go-stack/stack v1.8.1 // indirect
|
||||||
|
github.com/google/gnostic-models v0.6.8 // indirect
|
||||||
|
github.com/inconshreveable/log15 v3.0.0-testing.3+incompatible // indirect
|
||||||
|
github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/jpillora/backoff v1.0.0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.22.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.22.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.22.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.22.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.ngrok.com/muxado/v2 v2.0.0 // indirect
|
||||||
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
|
k8s.io/klog/v2 v2.100.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.5.2 // indirect
|
github.com/Microsoft/go-winio v0.5.2 // indirect
|
||||||
github.com/VividCortex/gohistogram v1.0.0 // indirect
|
github.com/VividCortex/gohistogram v1.0.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/docker/distribution v2.8.1+incompatible // indirect
|
github.com/docker/docker v27.3.1+incompatible
|
||||||
github.com/docker/docker v20.10.19+incompatible
|
|
||||||
github.com/docker/go-connections v0.4.0 // indirect
|
github.com/docker/go-connections v0.4.0 // indirect
|
||||||
github.com/docker/go-units v0.4.0 // indirect
|
github.com/docker/go-units v0.4.0 // indirect
|
||||||
github.com/go-kit/log v0.2.0 // indirect
|
github.com/go-kit/log v0.2.0 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.5.1 // indirect
|
github.com/go-logfmt/logfmt v0.5.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.3 // indirect
|
||||||
github.com/google/go-cmp v0.5.6 // indirect
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
github.com/google/gofuzz v1.1.0 // indirect
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
github.com/google/uuid v1.1.2 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/googleapis/gnostic v0.2.0 // indirect
|
github.com/iancoleman/strcase v0.3.0 // indirect
|
||||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
|
||||||
github.com/iancoleman/strcase v0.2.0 // indirect
|
|
||||||
github.com/imdario/mergo v0.3.7 // indirect
|
github.com/imdario/mergo v0.3.7 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
|
||||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
|
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.0.2 // indirect
|
github.com/opencontainers/image-spec v1.0.2 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 // indirect
|
golang.org/x/net v0.23.0 // indirect
|
||||||
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf // indirect
|
golang.org/x/oauth2 v0.8.0 // indirect
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 // indirect
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
|
golang.org/x/term v0.18.0 // indirect
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
|
golang.org/x/time v0.3.0 // indirect
|
||||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/appengine v1.6.6 // indirect
|
google.golang.org/protobuf v1.33.0 // indirect
|
||||||
google.golang.org/protobuf v1.27.1 // indirect
|
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gotest.tools/v3 v3.3.0 // indirect
|
gotest.tools/v3 v3.3.0 // indirect
|
||||||
k8s.io/klog v1.0.0 // indirect
|
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
|
||||||
k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89 // indirect
|
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||||
sigs.k8s.io/structured-merge-diff/v3 v3.0.0 // indirect
|
|
||||||
sigs.k8s.io/yaml v1.2.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
@ -160,6 +160,12 @@ func ReadFrame(reader io.Reader, addr net.Addr) (*Frame, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limit frame length to 2^21 - 1
|
||||||
|
if frame.Length > 2097151 {
|
||||||
|
return nil, errors.Errorf("frame length %d too large", frame.Length)
|
||||||
|
}
|
||||||
|
|
||||||
logrus.
|
logrus.
|
||||||
WithField("client", addr).
|
WithField("client", addr).
|
||||||
WithField("length", frame.Length).
|
WithField("length", frame.Length).
|
||||||
|
@ -5,11 +5,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.ngrok.com/ngrok"
|
||||||
|
"golang.ngrok.com/ngrok/config"
|
||||||
|
|
||||||
"github.com/go-kit/kit/metrics"
|
"github.com/go-kit/kit/metrics"
|
||||||
"github.com/itzg/mc-router/mcproto"
|
"github.com/itzg/mc-router/mcproto"
|
||||||
"github.com/juju/ratelimit"
|
"github.com/juju/ratelimit"
|
||||||
@ -30,38 +32,94 @@ type ConnectorMetrics struct {
|
|||||||
ActiveConnections metrics.Gauge
|
ActiveConnections metrics.Gauge
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool) *Connector {
|
func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool, receiveProxyProto bool, trustedProxyNets []*net.IPNet) *Connector {
|
||||||
|
|
||||||
return &Connector{
|
return &Connector{
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
sendProxyProto: sendProxyProto,
|
sendProxyProto: sendProxyProto,
|
||||||
connectionsCond: sync.NewCond(&sync.Mutex{}),
|
connectionsCond: sync.NewCond(&sync.Mutex{}),
|
||||||
|
receiveProxyProto: receiveProxyProto,
|
||||||
|
trustedProxyNets: trustedProxyNets,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Connector struct {
|
type Connector struct {
|
||||||
state mcproto.State
|
state mcproto.State
|
||||||
metrics *ConnectorMetrics
|
metrics *ConnectorMetrics
|
||||||
sendProxyProto bool
|
sendProxyProto bool
|
||||||
|
receiveProxyProto bool
|
||||||
|
trustedProxyNets []*net.IPNet
|
||||||
|
|
||||||
activeConnections int32
|
activeConnections int32
|
||||||
connectionsCond *sync.Cond
|
connectionsCond *sync.Cond
|
||||||
|
ngrokToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Connector) StartAcceptingConnections(ctx context.Context, listenAddress string, connRateLimit int) error {
|
func (c *Connector) StartAcceptingConnections(ctx context.Context, listenAddress string, connRateLimit int) error {
|
||||||
|
ln, err := c.createListener(ctx, listenAddress)
|
||||||
ln, err := net.Listen("tcp", listenAddress)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).Fatal("Unable to start listening")
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logrus.WithField("listenAddress", listenAddress).Info("Listening for Minecraft client connections")
|
|
||||||
|
|
||||||
go c.acceptConnections(ctx, ln, connRateLimit)
|
go c.acceptConnections(ctx, ln, connRateLimit)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Connector) createListener(ctx context.Context, listenAddress string) (net.Listener, error) {
|
||||||
|
if c.ngrokToken != "" {
|
||||||
|
ngrokTun, err := ngrok.Listen(ctx,
|
||||||
|
config.TCPEndpoint(),
|
||||||
|
ngrok.WithAuthtoken(c.ngrokToken),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Fatal("Unable to start ngrok tunnel")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logrus.WithField("ngrokUrl", ngrokTun.URL()).Info("Listening for Minecraft client connections via ngrok tunnel")
|
||||||
|
return ngrokTun, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", listenAddress)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Fatal("Unable to start listening")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logrus.WithField("listenAddress", listenAddress).Info("Listening for Minecraft client connections")
|
||||||
|
|
||||||
|
if c.receiveProxyProto {
|
||||||
|
proxyListener := &proxyproto.Listener{
|
||||||
|
Listener: listener,
|
||||||
|
Policy: c.createProxyProtoPolicy(),
|
||||||
|
}
|
||||||
|
logrus.Info("Using PROXY protocol listener")
|
||||||
|
return proxyListener, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return listener, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connector) createProxyProtoPolicy() func(upstream net.Addr) (proxyproto.Policy, error) {
|
||||||
|
return func(upstream net.Addr) (proxyproto.Policy, error) {
|
||||||
|
trustedIpNets := c.trustedProxyNets
|
||||||
|
|
||||||
|
if len(trustedIpNets) == 0 {
|
||||||
|
logrus.Debug("No trusted proxy networks configured, using the PROXY header by default")
|
||||||
|
return proxyproto.USE, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamIP := upstream.(*net.TCPAddr).IP
|
||||||
|
for _, ipNet := range trustedIpNets {
|
||||||
|
if ipNet.Contains(upstreamIP) {
|
||||||
|
logrus.WithField("upstream", upstream).Debug("IP is in trusted proxies, using the PROXY header")
|
||||||
|
return proxyproto.USE, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.WithField("upstream", upstream).Debug("IP is not in trusted proxies, discarding PROXY header")
|
||||||
|
return proxyproto.IGNORE, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Connector) WaitForConnections() {
|
func (c *Connector) WaitForConnections() {
|
||||||
c.connectionsCond.L.Lock()
|
c.connectionsCond.L.Lock()
|
||||||
defer c.connectionsCond.L.Unlock()
|
defer c.connectionsCond.L.Unlock()
|
||||||
@ -230,21 +288,29 @@ func (c *Connector) findAndConnectBackend(ctx context.Context, frontendConn net.
|
|||||||
|
|
||||||
// PROXY protocol implementation
|
// PROXY protocol implementation
|
||||||
if c.sendProxyProto {
|
if c.sendProxyProto {
|
||||||
remoteHostStr, _, _ := net.SplitHostPort(backendHostPort)
|
|
||||||
sourceAddrStr, sourcePortStr, _ := net.SplitHostPort(clientAddr.String())
|
// Determine transport protocol for the PROXY header by "analyzing" the frontend connection's address
|
||||||
sourcePort, _ := strconv.Atoi(sourcePortStr)
|
transportProtocol := proxyproto.TCPv4
|
||||||
|
ourHostIpPart, _, err := net.SplitHostPort(frontendConn.LocalAddr().String())
|
||||||
|
if err != nil {
|
||||||
|
logrus.
|
||||||
|
WithError(err).
|
||||||
|
WithField("localAddr", frontendConn.LocalAddr()).
|
||||||
|
Error("Failed to extract host part of our address")
|
||||||
|
_ = backendConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ourFrontendIp := net.ParseIP(ourHostIpPart)
|
||||||
|
if ourFrontendIp.To4() == nil {
|
||||||
|
transportProtocol = proxyproto.TCPv6
|
||||||
|
}
|
||||||
|
|
||||||
header := &proxyproto.Header{
|
header := &proxyproto.Header{
|
||||||
Version: 2,
|
Version: 2,
|
||||||
Command: proxyproto.PROXY,
|
Command: proxyproto.PROXY,
|
||||||
TransportProtocol: proxyproto.TCPv4,
|
TransportProtocol: transportProtocol,
|
||||||
SourceAddr: &net.TCPAddr{
|
SourceAddr: clientAddr,
|
||||||
IP: net.ParseIP(sourceAddrStr),
|
DestinationAddr: frontendConn.LocalAddr(), // our end of the client's connection
|
||||||
Port: sourcePort,
|
|
||||||
},
|
|
||||||
DestinationAddr: &net.TCPAddr{
|
|
||||||
IP: net.ParseIP(remoteHostStr),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = header.WriteTo(backendConn)
|
_, err = header.WriteTo(backendConn)
|
||||||
@ -322,3 +388,7 @@ func (c *Connector) pumpFrames(incoming io.Reader, outgoing io.Writer, errors ch
|
|||||||
errors <- io.EOF
|
errors <- io.EOF
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Connector) UseNgrok(token string) {
|
||||||
|
c.ngrokToken = token
|
||||||
|
}
|
||||||
|
77
server/connector_test.go
Normal file
77
server/connector_test.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pires/go-proxyproto"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrustedProxyNetworkPolicy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
trustedNets []string
|
||||||
|
upstreamIP string
|
||||||
|
expectedPolicy proxyproto.Policy
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "trusted IP",
|
||||||
|
trustedNets: []string{"10.0.0.0/8"},
|
||||||
|
upstreamIP: "10.0.0.1",
|
||||||
|
expectedPolicy: proxyproto.USE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "untrusted IP",
|
||||||
|
trustedNets: []string{"10.0.0.0/8"},
|
||||||
|
upstreamIP: "192.168.1.1",
|
||||||
|
expectedPolicy: proxyproto.IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple trusted nets",
|
||||||
|
trustedNets: []string{"10.0.0.0/8", "172.16.0.0/12"},
|
||||||
|
upstreamIP: "172.16.0.1",
|
||||||
|
expectedPolicy: proxyproto.USE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no trusted nets",
|
||||||
|
trustedNets: []string{},
|
||||||
|
upstreamIP: "148.184.129.202",
|
||||||
|
expectedPolicy: proxyproto.USE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote trusted IP",
|
||||||
|
trustedNets: []string{"203.0.113.0/24"},
|
||||||
|
upstreamIP: "203.0.113.10",
|
||||||
|
expectedPolicy: proxyproto.USE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote untrusted IP",
|
||||||
|
trustedNets: []string{"203.0.113.0/24"},
|
||||||
|
upstreamIP: "198.51.100.1",
|
||||||
|
expectedPolicy: proxyproto.IGNORE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
c := &Connector{
|
||||||
|
trustedProxyNets: parseTrustedProxyNets(test.trustedNets),
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := c.createProxyProtoPolicy()
|
||||||
|
upstreamAddr := &net.TCPAddr{IP: net.ParseIP(test.upstreamIP)}
|
||||||
|
policyResult, _ := policy(upstreamAddr)
|
||||||
|
assert.Equal(t, test.expectedPolicy, policyResult, "Unexpected policy result for %s", test.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTrustedProxyNets(nets []string) []*net.IPNet {
|
||||||
|
parsedNets := make([]*net.IPNet, 0, len(nets))
|
||||||
|
for _, n := range nets {
|
||||||
|
_, ipNet, _ := net.ParseCIDR(n)
|
||||||
|
parsedNets = append(parsedNets, ipNet)
|
||||||
|
}
|
||||||
|
return parsedNets
|
||||||
|
}
|
244
server/docker.go
244
server/docker.go
@ -3,26 +3,30 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
dockertypes "github.com/docker/docker/api/types"
|
dockertypes "github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/swarm"
|
|
||||||
swarmtypes "github.com/docker/docker/api/types/swarm"
|
|
||||||
"github.com/docker/docker/api/types/versions"
|
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IDockerWatcher interface {
|
type IDockerWatcher interface {
|
||||||
StartInSwarm(timeoutSeconds int, refreshIntervalSeconds int) error
|
Start(socket string, timeoutSeconds int, refreshIntervalSeconds int) error
|
||||||
Stop()
|
Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
DockerAPIVersion = "1.24"
|
||||||
|
DockerRouterLabelHost = "mc-router.host"
|
||||||
|
DockerRouterLabelPort = "mc-router.port"
|
||||||
|
DockerRouterLabelDefault = "mc-router.default"
|
||||||
|
DockerRouterLabelNetwork = "mc-router.network"
|
||||||
|
)
|
||||||
|
|
||||||
var DockerWatcher IDockerWatcher = &dockerWatcherImpl{}
|
var DockerWatcher IDockerWatcher = &dockerWatcherImpl{}
|
||||||
|
|
||||||
type dockerWatcherImpl struct {
|
type dockerWatcherImpl struct {
|
||||||
@ -31,28 +35,20 @@ type dockerWatcherImpl struct {
|
|||||||
contextCancel context.CancelFunc
|
contextCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
func (w *dockerWatcherImpl) makeWakerFunc(_ *routableContainer) func(ctx context.Context) error {
|
||||||
DockerConfigHost = "unix:///var/run/docker.sock"
|
|
||||||
DockerAPIVersion = "1.24"
|
|
||||||
DockerRouterLabelHost = "mc-router.host"
|
|
||||||
DockerRouterLabelPort = "mc-router.port"
|
|
||||||
DockerRouterLabelDefault = "mc-router.default"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (w *dockerWatcherImpl) makeWakerFunc(service *routableService) func(ctx context.Context) error {
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *dockerWatcherImpl) StartInSwarm(timeoutSeconds int, refreshIntervalSeconds int) error {
|
func (w *dockerWatcherImpl) Start(socket string, timeoutSeconds int, refreshIntervalSeconds int) error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
timeout := time.Duration(timeoutSeconds) * time.Second
|
timeout := time.Duration(timeoutSeconds) * time.Second
|
||||||
refreshInterval := time.Duration(refreshIntervalSeconds) * time.Second
|
refreshInterval := time.Duration(refreshIntervalSeconds) * time.Second
|
||||||
|
|
||||||
opts := []client.Opt{
|
opts := []client.Opt{
|
||||||
client.WithHost(DockerConfigHost),
|
client.WithHost(socket),
|
||||||
client.WithTimeout(timeout),
|
client.WithTimeout(timeout),
|
||||||
client.WithHTTPHeaders(map[string]string{
|
client.WithHTTPHeaders(map[string]string{
|
||||||
"User-Agent": "mc-router ",
|
"User-Agent": "mc-router ",
|
||||||
@ -66,22 +62,22 @@ func (w *dockerWatcherImpl) StartInSwarm(timeoutSeconds int, refreshIntervalSeco
|
|||||||
}
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(refreshInterval)
|
ticker := time.NewTicker(refreshInterval)
|
||||||
serviceMap := map[string]*routableService{}
|
containerMap := map[string]*routableContainer{}
|
||||||
|
|
||||||
var ctx context.Context
|
var ctx context.Context
|
||||||
ctx, w.contextCancel = context.WithCancel(context.Background())
|
ctx, w.contextCancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
initialServices, err := w.listServices(ctx)
|
initialContainers, err := w.listContainers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range initialServices {
|
for _, c := range initialContainers {
|
||||||
serviceMap[s.externalServiceName] = s
|
containerMap[c.externalContainerName] = c
|
||||||
if s.externalServiceName != "" {
|
if c.externalContainerName != "" {
|
||||||
Routes.CreateMapping(s.externalServiceName, s.containerEndpoint, w.makeWakerFunc(s))
|
Routes.CreateMapping(c.externalContainerName, c.containerEndpoint, w.makeWakerFunc(c))
|
||||||
} else {
|
} else {
|
||||||
Routes.SetDefaultRoute(s.containerEndpoint)
|
Routes.SetDefaultRoute(c.containerEndpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,43 +85,43 @@ func (w *dockerWatcherImpl) StartInSwarm(timeoutSeconds int, refreshIntervalSeco
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
services, err := w.listServices(ctx)
|
containers, err := w.listContainers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).Error("Docker failed to list services")
|
logrus.WithError(err).Error("Docker failed to list containers")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
visited := map[string]struct{}{}
|
visited := map[string]struct{}{}
|
||||||
for _, rs := range services {
|
for _, rs := range containers {
|
||||||
if oldRs, ok := serviceMap[rs.externalServiceName]; !ok {
|
if oldRs, ok := containerMap[rs.externalContainerName]; !ok {
|
||||||
serviceMap[rs.externalServiceName] = rs
|
containerMap[rs.externalContainerName] = rs
|
||||||
logrus.WithField("routableService", rs).Debug("ADD")
|
logrus.WithField("routableContainer", rs).Debug("ADD")
|
||||||
if rs.externalServiceName != "" {
|
if rs.externalContainerName != "" {
|
||||||
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, w.makeWakerFunc(rs))
|
Routes.CreateMapping(rs.externalContainerName, rs.containerEndpoint, w.makeWakerFunc(rs))
|
||||||
} else {
|
} else {
|
||||||
Routes.SetDefaultRoute(rs.containerEndpoint)
|
Routes.SetDefaultRoute(rs.containerEndpoint)
|
||||||
}
|
}
|
||||||
} else if oldRs.containerEndpoint != rs.containerEndpoint {
|
} else if oldRs.containerEndpoint != rs.containerEndpoint {
|
||||||
serviceMap[rs.externalServiceName] = rs
|
containerMap[rs.externalContainerName] = rs
|
||||||
if rs.externalServiceName != "" {
|
if rs.externalContainerName != "" {
|
||||||
Routes.DeleteMapping(rs.externalServiceName)
|
Routes.DeleteMapping(rs.externalContainerName)
|
||||||
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, w.makeWakerFunc(rs))
|
Routes.CreateMapping(rs.externalContainerName, rs.containerEndpoint, w.makeWakerFunc(rs))
|
||||||
} else {
|
} else {
|
||||||
Routes.SetDefaultRoute(rs.containerEndpoint)
|
Routes.SetDefaultRoute(rs.containerEndpoint)
|
||||||
}
|
}
|
||||||
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
|
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
|
||||||
}
|
}
|
||||||
visited[rs.externalServiceName] = struct{}{}
|
visited[rs.externalContainerName] = struct{}{}
|
||||||
}
|
}
|
||||||
for _, rs := range serviceMap {
|
for _, rs := range containerMap {
|
||||||
if _, ok := visited[rs.externalServiceName]; !ok {
|
if _, ok := visited[rs.externalContainerName]; !ok {
|
||||||
delete(serviceMap, rs.externalServiceName)
|
delete(containerMap, rs.externalContainerName)
|
||||||
if rs.externalServiceName != "" {
|
if rs.externalContainerName != "" {
|
||||||
Routes.DeleteMapping(rs.externalServiceName)
|
Routes.DeleteMapping(rs.externalContainerName)
|
||||||
} else {
|
} else {
|
||||||
Routes.SetDefaultRoute("")
|
Routes.SetDefaultRoute("")
|
||||||
}
|
}
|
||||||
logrus.WithField("routableService", rs).Debug("DELETE")
|
logrus.WithField("routableContainer", rs).Debug("DELETE")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,64 +132,33 @@ func (w *dockerWatcherImpl) StartInSwarm(timeoutSeconds int, refreshIntervalSeco
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logrus.Info("Monitoring Docker for Minecraft services")
|
logrus.Info("Monitoring Docker for Minecraft containers")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *dockerWatcherImpl) listServices(ctx context.Context) ([]*routableService, error) {
|
func (w *dockerWatcherImpl) listContainers(ctx context.Context) ([]*routableContainer, error) {
|
||||||
services, err := w.client.ServiceList(ctx, dockertypes.ServiceListOptions{})
|
containers, err := w.client.ContainerList(ctx, container.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
serverVersion, err := w.client.ServerVersion(ctx)
|
var result []*routableContainer
|
||||||
if err != nil {
|
for _, container := range containers {
|
||||||
return nil, err
|
data, ok := w.parseContainerData(&container)
|
||||||
}
|
|
||||||
|
|
||||||
networkListArgs := filters.NewArgs()
|
|
||||||
// https://docs.docker.com/engine/api/v1.29/#tag/Network (Docker 17.06)
|
|
||||||
if versions.GreaterThanOrEqualTo(serverVersion.APIVersion, "1.29") {
|
|
||||||
networkListArgs.Add("scope", "swarm")
|
|
||||||
} else {
|
|
||||||
networkListArgs.Add("driver", "overlay")
|
|
||||||
}
|
|
||||||
|
|
||||||
networkList, err := w.client.NetworkList(ctx, dockertypes.NetworkListOptions{Filters: networkListArgs})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
networkMap := make(map[string]*dockertypes.NetworkResource)
|
|
||||||
for _, network := range networkList {
|
|
||||||
networkToAdd := network
|
|
||||||
networkMap[network.ID] = &networkToAdd
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []*routableService
|
|
||||||
for _, service := range services {
|
|
||||||
if service.Spec.EndpointSpec.Mode != swarmtypes.ResolutionModeVIP {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(service.Endpoint.VirtualIPs) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
data, ok := w.parseServiceData(&service)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, host := range data.hosts {
|
for _, host := range data.hosts {
|
||||||
result = append(result, &routableService{
|
result = append(result, &routableContainer{
|
||||||
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
|
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
|
||||||
externalServiceName: host,
|
externalContainerName: host,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if data.def != nil && *data.def {
|
if data.def != nil && *data.def {
|
||||||
result = append(result, &routableService{
|
result = append(result, &routableContainer{
|
||||||
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
|
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
|
||||||
externalServiceName: "",
|
externalContainerName: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -201,47 +166,44 @@ func (w *dockerWatcherImpl) listServices(ctx context.Context) ([]*routableServic
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type parsedDockerServiceData struct {
|
type parsedDockerContainerData struct {
|
||||||
hosts []string
|
hosts []string
|
||||||
port uint64
|
port uint64
|
||||||
def *bool
|
def *bool
|
||||||
ip string
|
network *string
|
||||||
|
ip string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *dockerWatcherImpl) parseServiceData(service *swarm.Service) (data parsedDockerServiceData, ok bool) {
|
func (w *dockerWatcherImpl) parseContainerData(container *dockertypes.Container) (data parsedDockerContainerData, ok bool) {
|
||||||
ok = true
|
for key, value := range container.Labels {
|
||||||
for key, value := range service.Spec.Labels {
|
|
||||||
if key == DockerRouterLabelHost {
|
if key == DockerRouterLabelHost {
|
||||||
if data.hosts != nil {
|
if data.hosts != nil {
|
||||||
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
|
||||||
Warnf("ignoring service with duplicate %s", DockerRouterLabelHost)
|
Warnf("ignoring container with duplicate %s label", DockerRouterLabelHost)
|
||||||
ok = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.hosts = strings.Split(value, ",")
|
data.hosts = strings.Split(value, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
if key == DockerRouterLabelPort {
|
if key == DockerRouterLabelPort {
|
||||||
if data.port != 0 {
|
if data.port != 0 {
|
||||||
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
|
||||||
Warnf("ignoring service with duplicate %s", DockerRouterLabelPort)
|
Warnf("ignoring container with duplicate %s label", DockerRouterLabelPort)
|
||||||
ok = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
data.port, err = strconv.ParseUint(value, 10, 32)
|
data.port, err = strconv.ParseUint(value, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
|
||||||
WithError(err).
|
WithError(err).
|
||||||
Warnf("ignoring service with invalid %s", DockerRouterLabelPort)
|
Warnf("ignoring container with invalid %s label", DockerRouterLabelPort)
|
||||||
ok = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if key == DockerRouterLabelDefault {
|
if key == DockerRouterLabelDefault {
|
||||||
if data.def != nil {
|
if data.def != nil {
|
||||||
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
|
||||||
Warnf("ignoring service with duplicate %s", DockerRouterLabelDefault)
|
Warnf("ignoring container with duplicate %s label", DockerRouterLabelDefault)
|
||||||
ok = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.def = new(bool)
|
data.def = new(bool)
|
||||||
@ -249,18 +211,75 @@ func (w *dockerWatcherImpl) parseServiceData(service *swarm.Service) (data parse
|
|||||||
lowerValue := strings.TrimSpace(strings.ToLower(value))
|
lowerValue := strings.TrimSpace(strings.ToLower(value))
|
||||||
*data.def = lowerValue != "" && lowerValue != "0" && lowerValue != "false" && lowerValue != "no"
|
*data.def = lowerValue != "" && lowerValue != "0" && lowerValue != "false" && lowerValue != "no"
|
||||||
}
|
}
|
||||||
|
if key == DockerRouterLabelNetwork {
|
||||||
|
if data.network != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
|
||||||
|
Warnf("ignoring container with duplicate %s label", DockerRouterLabelNetwork)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.network = new(string)
|
||||||
|
*data.network = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// probably not minecraft related
|
||||||
if len(data.hosts) == 0 {
|
if len(data.hosts) == 0 {
|
||||||
ok = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(container.NetworkSettings.Networks) == 0 {
|
||||||
|
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
|
||||||
|
Warnf("ignoring container, no networks found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if data.port == 0 {
|
if data.port == 0 {
|
||||||
data.port = 25565
|
data.port = 25565
|
||||||
}
|
}
|
||||||
|
|
||||||
virtualIP := service.Endpoint.VirtualIPs[0]
|
if data.network != nil {
|
||||||
ip, _, _ := net.ParseCIDR(virtualIP.Addr)
|
// Loop through all the container's networks and attempt to find one whose Network ID, Name, or Aliases match the
|
||||||
data.ip = ip.String()
|
// specified network
|
||||||
|
for name, endpoint := range container.NetworkSettings.Networks {
|
||||||
|
if name == endpoint.NetworkID {
|
||||||
|
data.ip = endpoint.IPAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == *data.network {
|
||||||
|
data.ip = endpoint.IPAddress
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, alias := range endpoint.Aliases {
|
||||||
|
if alias == name {
|
||||||
|
data.ip = endpoint.IPAddress
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If there's no endpoint specified we can just assume the only one is the network we should use. One caveat is
|
||||||
|
// if there's more than one network on this container, we should require that the user specifies a network to avoid
|
||||||
|
// weird problems.
|
||||||
|
if len(container.NetworkSettings.Networks) > 1 {
|
||||||
|
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
|
||||||
|
Warnf("ignoring container, multiple networks found and none specified using label %s", DockerRouterLabelNetwork)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range container.NetworkSettings.Networks {
|
||||||
|
data.ip = endpoint.IPAddress
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.ip == "" {
|
||||||
|
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
|
||||||
|
Warnf("ignoring container, unable to find accessible ip address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ok = true
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -270,3 +289,8 @@ func (w *dockerWatcherImpl) Stop() {
|
|||||||
w.contextCancel()
|
w.contextCancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type routableContainer struct {
|
||||||
|
externalContainerName string
|
||||||
|
containerEndpoint string
|
||||||
|
}
|
||||||
|
321
server/docker_swarm.go
Normal file
321
server/docker_swarm.go
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
dockertypes "github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/docker/api/types/swarm"
|
||||||
|
swarmtypes "github.com/docker/docker/api/types/swarm"
|
||||||
|
"github.com/docker/docker/api/types/versions"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DockerSwarmWatcher IDockerWatcher = &dockerSwarmWatcherImpl{}
|
||||||
|
|
||||||
|
type dockerSwarmWatcherImpl struct {
|
||||||
|
sync.RWMutex
|
||||||
|
client *client.Client
|
||||||
|
contextCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dockerSwarmWatcherImpl) makeWakerFunc(_ *routableService) func(ctx context.Context) error {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dockerSwarmWatcherImpl) Start(socket string, timeoutSeconds int, refreshIntervalSeconds int) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
timeout := time.Duration(timeoutSeconds) * time.Second
|
||||||
|
refreshInterval := time.Duration(refreshIntervalSeconds) * time.Second
|
||||||
|
|
||||||
|
opts := []client.Opt{
|
||||||
|
client.WithHost(socket),
|
||||||
|
client.WithTimeout(timeout),
|
||||||
|
client.WithHTTPHeaders(map[string]string{
|
||||||
|
"User-Agent": "mc-router ",
|
||||||
|
}),
|
||||||
|
client.WithVersion(DockerAPIVersion),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.client, err = client.NewClientWithOpts(opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(refreshInterval)
|
||||||
|
serviceMap := map[string]*routableService{}
|
||||||
|
|
||||||
|
var ctx context.Context
|
||||||
|
ctx, w.contextCancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
initialServices, err := w.listServices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range initialServices {
|
||||||
|
serviceMap[s.externalServiceName] = s
|
||||||
|
if s.externalServiceName != "" {
|
||||||
|
Routes.CreateMapping(s.externalServiceName, s.containerEndpoint, w.makeWakerFunc(s))
|
||||||
|
} else {
|
||||||
|
Routes.SetDefaultRoute(s.containerEndpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
services, err := w.listServices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Docker failed to list services")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visited := map[string]struct{}{}
|
||||||
|
for _, rs := range services {
|
||||||
|
if oldRs, ok := serviceMap[rs.externalServiceName]; !ok {
|
||||||
|
serviceMap[rs.externalServiceName] = rs
|
||||||
|
logrus.WithField("routableService", rs).Debug("ADD")
|
||||||
|
if rs.externalServiceName != "" {
|
||||||
|
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, w.makeWakerFunc(rs))
|
||||||
|
} else {
|
||||||
|
Routes.SetDefaultRoute(rs.containerEndpoint)
|
||||||
|
}
|
||||||
|
} else if oldRs.containerEndpoint != rs.containerEndpoint {
|
||||||
|
serviceMap[rs.externalServiceName] = rs
|
||||||
|
if rs.externalServiceName != "" {
|
||||||
|
Routes.DeleteMapping(rs.externalServiceName)
|
||||||
|
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, w.makeWakerFunc(rs))
|
||||||
|
} else {
|
||||||
|
Routes.SetDefaultRoute(rs.containerEndpoint)
|
||||||
|
}
|
||||||
|
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
|
||||||
|
}
|
||||||
|
visited[rs.externalServiceName] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, rs := range serviceMap {
|
||||||
|
if _, ok := visited[rs.externalServiceName]; !ok {
|
||||||
|
delete(serviceMap, rs.externalServiceName)
|
||||||
|
if rs.externalServiceName != "" {
|
||||||
|
Routes.DeleteMapping(rs.externalServiceName)
|
||||||
|
} else {
|
||||||
|
Routes.SetDefaultRoute("")
|
||||||
|
}
|
||||||
|
logrus.WithField("routableService", rs).Debug("DELETE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
ticker.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
logrus.Info("Monitoring Docker Swarm for Minecraft services")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dockerSwarmWatcherImpl) listServices(ctx context.Context) ([]*routableService, error) {
|
||||||
|
services, err := w.client.ServiceList(ctx, dockertypes.ServiceListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
serverVersion, err := w.client.ServerVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
networkListArgs := filters.NewArgs()
|
||||||
|
// https://docs.docker.com/engine/api/v1.29/#tag/Network (Docker 17.06)
|
||||||
|
if versions.GreaterThanOrEqualTo(serverVersion.APIVersion, "1.29") {
|
||||||
|
networkListArgs.Add("scope", "swarm")
|
||||||
|
} else {
|
||||||
|
networkListArgs.Add("driver", "overlay")
|
||||||
|
}
|
||||||
|
|
||||||
|
networkList, err := w.client.NetworkList(ctx, network.ListOptions{Filters: networkListArgs})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
networkMap := make(map[string]*network.Inspect)
|
||||||
|
for _, network := range networkList {
|
||||||
|
networkToAdd := network
|
||||||
|
networkMap[network.ID] = &networkToAdd
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*routableService
|
||||||
|
for _, service := range services {
|
||||||
|
if service.Spec.EndpointSpec.Mode != swarmtypes.ResolutionModeVIP {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(service.Endpoint.VirtualIPs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := w.parseServiceData(&service, networkMap)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range data.hosts {
|
||||||
|
result = append(result, &routableService{
|
||||||
|
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
|
||||||
|
externalServiceName: host,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if data.def != nil && *data.def {
|
||||||
|
result = append(result, &routableService{
|
||||||
|
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
|
||||||
|
externalServiceName: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerCheckNetworkName(id string, name string, networkMap map[string]*network.Inspect, networkAliases map[string][]string) (bool, error) {
|
||||||
|
// we allow to specify the id instead
|
||||||
|
if id == name {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if network := networkMap[id]; network != nil {
|
||||||
|
if network.Name == name {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
aliases := networkAliases[id]
|
||||||
|
for _, alias := range aliases {
|
||||||
|
if alias == name {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, fmt.Errorf("network not found %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedDockerServiceData struct {
|
||||||
|
hosts []string
|
||||||
|
port uint64
|
||||||
|
def *bool
|
||||||
|
network *string
|
||||||
|
ip string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dockerSwarmWatcherImpl) parseServiceData(service *swarm.Service, networkMap map[string]*network.Inspect) (data parsedDockerServiceData, ok bool) {
|
||||||
|
networkAliases := map[string][]string{}
|
||||||
|
for _, network := range service.Spec.TaskTemplate.Networks {
|
||||||
|
networkAliases[network.Target] = network.Aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range service.Spec.Labels {
|
||||||
|
if key == DockerRouterLabelHost {
|
||||||
|
if data.hosts != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
||||||
|
Warnf("ignoring service with duplicate %s", DockerRouterLabelHost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.hosts = strings.Split(value, ",")
|
||||||
|
}
|
||||||
|
if key == DockerRouterLabelPort {
|
||||||
|
if data.port != 0 {
|
||||||
|
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
||||||
|
Warnf("ignoring service with duplicate %s", DockerRouterLabelPort)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
data.port, err = strconv.ParseUint(value, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
||||||
|
WithError(err).
|
||||||
|
Warnf("ignoring service with invalid %s", DockerRouterLabelPort)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key == DockerRouterLabelDefault {
|
||||||
|
if data.def != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
||||||
|
Warnf("ignoring service with duplicate %s", DockerRouterLabelDefault)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.def = new(bool)
|
||||||
|
|
||||||
|
lowerValue := strings.TrimSpace(strings.ToLower(value))
|
||||||
|
*data.def = lowerValue != "" && lowerValue != "0" && lowerValue != "false" && lowerValue != "no"
|
||||||
|
}
|
||||||
|
if key == DockerRouterLabelNetwork {
|
||||||
|
if data.network != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
||||||
|
Warnf("ignoring service with duplicate %s", DockerRouterLabelNetwork)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.network = new(string)
|
||||||
|
*data.network = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probably not minecraft related
|
||||||
|
if len(data.hosts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(service.Endpoint.VirtualIPs) == 0 {
|
||||||
|
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
||||||
|
Warnf("ignoring service, no VirtualIPs found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.port == 0 {
|
||||||
|
data.port = 25565
|
||||||
|
}
|
||||||
|
|
||||||
|
vipIndex := -1
|
||||||
|
if data.network != nil {
|
||||||
|
for i, vip := range service.Endpoint.VirtualIPs {
|
||||||
|
if ok, err := dockerCheckNetworkName(vip.NetworkID, *data.network, networkMap, networkAliases); ok {
|
||||||
|
vipIndex = i
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
// we intentionally ignore name check errors
|
||||||
|
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
||||||
|
Debugf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vipIndex == -1 {
|
||||||
|
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
|
||||||
|
Warnf("ignoring service, network %s not found", *data.network)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if network isn't specified assume it's the first one
|
||||||
|
vipIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualIP := service.Endpoint.VirtualIPs[vipIndex]
|
||||||
|
ip, _, _ := net.ParseCIDR(virtualIP.Addr)
|
||||||
|
data.ip = ip.String()
|
||||||
|
ok = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dockerSwarmWatcherImpl) Stop() {
|
||||||
|
if w.contextCancel != nil {
|
||||||
|
w.contextCancel()
|
||||||
|
}
|
||||||
|
}
|
@ -232,7 +232,7 @@ func (w *k8sWatcherImpl) buildDetails(service *core.Service, externalServiceName
|
|||||||
clusterIp := service.Spec.ClusterIP
|
clusterIp := service.Spec.ClusterIP
|
||||||
port := "25565"
|
port := "25565"
|
||||||
for _, p := range service.Spec.Ports {
|
for _, p := range service.Spec.Ports {
|
||||||
if p.Name == "mc-router" {
|
if p.Name == "mc-router" || p.Name == "minecraft" {
|
||||||
port = strconv.Itoa(int(p.Port))
|
port = strconv.Itoa(int(p.Port))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@ -11,6 +12,8 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var tcpShieldPattern = regexp.MustCompile("///.*")
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
apiRoutes.Path("/routes").Methods("GET").
|
apiRoutes.Path("/routes").Methods("GET").
|
||||||
Headers("Accept", "application/json").
|
Headers("Accept", "application/json").
|
||||||
@ -24,7 +27,7 @@ func init() {
|
|||||||
apiRoutes.Path("/routes/{serverAddress}").Methods("DELETE").HandlerFunc(routesDeleteHandler)
|
apiRoutes.Path("/routes/{serverAddress}").Methods("DELETE").HandlerFunc(routesDeleteHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func routesListHandler(writer http.ResponseWriter, request *http.Request) {
|
func routesListHandler(writer http.ResponseWriter, _ *http.Request) {
|
||||||
mappings := Routes.GetMappings()
|
mappings := Routes.GetMappings()
|
||||||
bytes, err := json.Marshal(mappings)
|
bytes, err := json.Marshal(mappings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -32,11 +35,15 @@ func routesListHandler(writer http.ResponseWriter, request *http.Request) {
|
|||||||
writer.WriteHeader(http.StatusInternalServerError)
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writer.Write(bytes)
|
_, err = writer.Write(bytes)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Failed to write response")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func routesDeleteHandler(writer http.ResponseWriter, request *http.Request) {
|
func routesDeleteHandler(writer http.ResponseWriter, request *http.Request) {
|
||||||
serverAddress := mux.Vars(request)["serverAddress"]
|
serverAddress := mux.Vars(request)["serverAddress"]
|
||||||
|
RoutesConfig.DeleteMapping(serverAddress)
|
||||||
if serverAddress != "" {
|
if serverAddress != "" {
|
||||||
if Routes.DeleteMapping(serverAddress) {
|
if Routes.DeleteMapping(serverAddress) {
|
||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
@ -52,6 +59,7 @@ func routesCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
|||||||
Backend string
|
Backend string
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
|
//goland:noinspection GoUnhandledErrorResult
|
||||||
defer request.Body.Close()
|
defer request.Body.Close()
|
||||||
|
|
||||||
decoder := json.NewDecoder(request.Body)
|
decoder := json.NewDecoder(request.Body)
|
||||||
@ -63,6 +71,7 @@ func routesCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Routes.CreateMapping(definition.ServerAddress, definition.Backend, func(ctx context.Context) error { return nil })
|
Routes.CreateMapping(definition.ServerAddress, definition.Backend, func(ctx context.Context) error { return nil })
|
||||||
|
RoutesConfig.AddMapping(definition.ServerAddress, definition.Backend)
|
||||||
writer.WriteHeader(http.StatusCreated)
|
writer.WriteHeader(http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,6 +80,7 @@ func routesSetDefault(writer http.ResponseWriter, request *http.Request) {
|
|||||||
Backend string
|
Backend string
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
|
//goland:noinspection GoUnhandledErrorResult
|
||||||
defer request.Body.Close()
|
defer request.Body.Close()
|
||||||
|
|
||||||
decoder := json.NewDecoder(request.Body)
|
decoder := json.NewDecoder(request.Body)
|
||||||
@ -82,6 +92,7 @@ func routesSetDefault(writer http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Routes.SetDefaultRoute(body.Backend)
|
Routes.SetDefaultRoute(body.Backend)
|
||||||
|
RoutesConfig.SetDefaultRoute(body.Backend)
|
||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,16 +154,22 @@ func (r *routesImpl) SimplifySRV(srvEnabled bool) {
|
|||||||
r.simplifySRV = srvEnabled
|
r.simplifySRV = srvEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *routesImpl) FindBackendForServerAddress(ctx context.Context, serverAddress string) (string, string, func(ctx context.Context) error) {
|
func (r *routesImpl) FindBackendForServerAddress(_ context.Context, serverAddress string) (string, string, func(ctx context.Context) error) {
|
||||||
r.RLock()
|
r.RLock()
|
||||||
defer r.RUnlock()
|
defer r.RUnlock()
|
||||||
|
|
||||||
|
// Trim off Forge null-delimited address parts like \x00FML3\x00
|
||||||
|
serverAddress = strings.Split(serverAddress, "\x00")[0]
|
||||||
|
|
||||||
|
serverAddress = strings.ToLower(
|
||||||
|
// trim the root zone indicator, see https://en.wikipedia.org/wiki/Fully_qualified_domain_name
|
||||||
|
strings.TrimSuffix(serverAddress, "."))
|
||||||
|
|
||||||
logrus.WithFields(logrus.Fields{
|
logrus.WithFields(logrus.Fields{
|
||||||
"serverAddress": serverAddress,
|
"serverAddress": serverAddress,
|
||||||
}).Debug("Finding backend for server address")
|
}).Debug("Finding backend for server address")
|
||||||
|
|
||||||
if r.simplifySRV {
|
if r.simplifySRV {
|
||||||
serverAddress = strings.TrimSuffix(serverAddress, ".")
|
|
||||||
parts := strings.Split(serverAddress, ".")
|
parts := strings.Split(serverAddress, ".")
|
||||||
tcpIndex := -1
|
tcpIndex := -1
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
@ -168,18 +185,15 @@ func (r *routesImpl) FindBackendForServerAddress(ctx context.Context, serverAddr
|
|||||||
serverAddress = strings.Join(parts, ".")
|
serverAddress = strings.Join(parts, ".")
|
||||||
}
|
}
|
||||||
|
|
||||||
addressParts := strings.Split(serverAddress, "\x00")
|
// Strip suffix of TCP Shield
|
||||||
|
serverAddress = tcpShieldPattern.ReplaceAllString(serverAddress, "")
|
||||||
address := strings.ToLower(
|
|
||||||
// trim the root zone indicator, see https://en.wikipedia.org/wiki/Fully_qualified_domain_name
|
|
||||||
strings.TrimSuffix(addressParts[0], "."))
|
|
||||||
|
|
||||||
if r.mappings != nil {
|
if r.mappings != nil {
|
||||||
if mapping, exists := r.mappings[address]; exists {
|
if mapping, exists := r.mappings[serverAddress]; exists {
|
||||||
return mapping.backend, address, mapping.waker
|
return mapping.backend, serverAddress, mapping.waker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return r.defaultRoute, address, nil
|
return r.defaultRoute, serverAddress, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *routesImpl) GetMappings() map[string]string {
|
func (r *routesImpl) GetMappings() map[string]string {
|
||||||
|
173
server/routes_config.go
Normal file
173
server/routes_config.go
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IRoutesConfig interface {
|
||||||
|
ReadRoutesConfig(routesConfig string)
|
||||||
|
AddMapping(serverAddress string, backend string)
|
||||||
|
DeleteMapping(serverAddress string)
|
||||||
|
SetDefaultRoute(backend string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RoutesConfig = &routesConfigImpl{}
|
||||||
|
|
||||||
|
type routesConfigImpl struct {
|
||||||
|
sync.RWMutex
|
||||||
|
fileName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type routesConfigStructure struct {
|
||||||
|
DefaultServer string `json:"default-server"`
|
||||||
|
Mappings map[string]string `json:"mappings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *routesConfigImpl) ReadRoutesConfig(routesConfig string) error {
|
||||||
|
r.fileName = routesConfig
|
||||||
|
|
||||||
|
logrus.WithField("routesConfig", r.fileName).Info("Loading routes config file")
|
||||||
|
|
||||||
|
config, readErr := r.readRoutesConfigFile()
|
||||||
|
|
||||||
|
if readErr != nil {
|
||||||
|
if errors.Is(readErr, fs.ErrNotExist) {
|
||||||
|
logrus.WithField("routesConfig", r.fileName).Info("Routes config file doses not exist, skipping reading it")
|
||||||
|
// File doesn't exist -> ignore it
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Wrap(readErr, "Could not load the routes config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
Routes.RegisterAll(config.Mappings)
|
||||||
|
Routes.SetDefaultRoute(config.DefaultServer)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *routesConfigImpl) AddMapping(serverAddress string, backend string) {
|
||||||
|
if !r.isRoutesConfigEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, readErr := r.readRoutesConfigFile()
|
||||||
|
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
|
||||||
|
logrus.WithError(readErr).Error("Could not read the routes config file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config.Mappings == nil {
|
||||||
|
config.Mappings = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Mappings[serverAddress] = backend
|
||||||
|
|
||||||
|
writeErr := r.writeRoutesConfigFile(config)
|
||||||
|
if writeErr != nil {
|
||||||
|
logrus.WithError(writeErr).Error("Could not write to the routes config file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"serverAddress": serverAddress,
|
||||||
|
"backend": backend,
|
||||||
|
}).Info("Added route to routes config")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *routesConfigImpl) SetDefaultRoute(backend string) {
|
||||||
|
if !r.isRoutesConfigEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, readErr := r.readRoutesConfigFile()
|
||||||
|
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
|
||||||
|
logrus.WithError(readErr).Error("Could not read the routes config file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DefaultServer = backend
|
||||||
|
|
||||||
|
writeErr := r.writeRoutesConfigFile(config)
|
||||||
|
if writeErr != nil {
|
||||||
|
logrus.WithError(writeErr).Error("Could not write to the routes config file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"backend": backend,
|
||||||
|
}).Info("Set default route in routes config")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *routesConfigImpl) DeleteMapping(serverAddress string) {
|
||||||
|
if !r.isRoutesConfigEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, readErr := r.readRoutesConfigFile()
|
||||||
|
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
|
||||||
|
logrus.WithError(readErr).Error("Could not read the routes config file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(config.Mappings, serverAddress)
|
||||||
|
|
||||||
|
writeErr := r.writeRoutesConfigFile(config)
|
||||||
|
if writeErr != nil {
|
||||||
|
logrus.WithError(writeErr).Error("Could not write to the routes config file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.WithField("serverAddress", serverAddress).Info("Deleted route in routes config")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *routesConfigImpl) isRoutesConfigEnabled() bool {
|
||||||
|
return r.fileName != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *routesConfigImpl) readRoutesConfigFile() (routesConfigStructure, error) {
|
||||||
|
r.RLock()
|
||||||
|
defer r.RUnlock()
|
||||||
|
|
||||||
|
config := routesConfigStructure{
|
||||||
|
"",
|
||||||
|
make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
file, fileErr := os.ReadFile(r.fileName)
|
||||||
|
if fileErr != nil {
|
||||||
|
return config, errors.Wrap(fileErr, "Could not load the routes config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
parseErr := json.Unmarshal(file, &config)
|
||||||
|
if parseErr != nil {
|
||||||
|
return config, errors.Wrap(parseErr, "Could not parse the json routes config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *routesConfigImpl) writeRoutesConfigFile(config routesConfigStructure) error {
|
||||||
|
r.Lock()
|
||||||
|
defer r.Unlock()
|
||||||
|
|
||||||
|
newFileContent, parseErr := json.Marshal(config)
|
||||||
|
if parseErr != nil {
|
||||||
|
return errors.Wrap(parseErr, "Could not parse the routes to json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileErr := os.WriteFile(r.fileName, newFileContent, 0664)
|
||||||
|
if fileErr != nil {
|
||||||
|
return errors.Wrap(fileErr, "Could not write to the routes config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user