feat: support newline and comma-whitespace external host lists for auto-discovery (#468)

This commit is contained in:
Supratim Ghose
2025-10-25 03:18:34 +05:30
committed by GitHub
parent 4b5cb125e4
commit 22ec39b805
6 changed files with 113 additions and 10 deletions
+15 -3
View File
@@ -161,7 +161,7 @@ When using in Docker, make sure to volume mount the Docker socket into the conta
These are the labels scanned:
- `mc-router.host`: Used to configure the hostname the Minecraft clients would use to connect to the server. The container/service endpoint will be used as the routed backend. You can use more than one hostname by splitting it with a comma.
- `mc-router.host`: Used to configure the hostname the Minecraft clients would use to connect to the server. The container/service endpoint will be used as the routed backend. You can use more than one hostname by splitting it with a comma or newline. Whitespace around commas is automatically trimmed. For example: `"host1.com,host2.com"`, `"host1.com, host2.com"`, or `"host1.com\nhost2.com"`.
- `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 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.
@@ -234,7 +234,7 @@ For more information on the allow/deny list configuration, see the [json schema]
### 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 is used as the routed backend. You can use more hostnames by splitting them with comma.
- `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 is used as the routed backend. You can use more hostnames by splitting them with comma or newline. Whitespace around commas is automatically trimmed. For example: `"host1.com,host2.com"`, `"host1.com, host2.com"`, or multi-line values.
- `mc-router.itzg.me/defaultServer` : When set to "true", the service is used as the default if no other `externalServiceName` annotations applies.
By default, the router will watch all namespaces for those services; however, a specific namespace can be specified using the `KUBE_NAMESPACE` environment variable. The pod's own namespace could be set using:
@@ -275,6 +275,18 @@ metadata:
annotations:
"mc-router.itzg.me/externalServerName": "external.host.name,other.host.name"
```
or with newlines and optional whitespace:
```yaml
apiVersion: v1
kind: Service
metadata:
name: mc-forge
annotations:
"mc-router.itzg.me/externalServerName": |
external.host.name
other.host.name
```
The `Role` or `ClusterRole` bound to the service account should have the rules:
@@ -625,4 +637,4 @@ docker run -it --rm \
## Related Projects
* https://github.com/haveachin/infrared
* https://github.com/haveachin/infrared
+1 -1
View File
@@ -199,7 +199,7 @@ func (w *dockerWatcherImpl) parseContainerData(container *dockertypes.Container)
Warnf("ignoring container with duplicate %s label", DockerRouterLabelHost)
return
}
data.hosts = strings.Split(value, ",")
data.hosts = SplitExternalHosts(value)
}
if key == DockerRouterLabelPort {
+1 -1
View File
@@ -247,7 +247,7 @@ func (w *dockerSwarmWatcherImpl) parseServiceData(service *swarm.Service, networ
Warnf("ignoring service with duplicate %s", DockerRouterLabelHost)
return
}
data.hosts = strings.Split(value, ",")
data.hosts = SplitExternalHosts(value)
}
if key == DockerRouterLabelPort {
if data.port != 0 {
+5 -5
View File
@@ -3,6 +3,10 @@ package server
import (
"context"
"fmt"
"net"
"strconv"
"sync"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
apps "k8s.io/api/apps/v1"
@@ -14,10 +18,6 @@ import (
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/clientcmd"
"net"
"strconv"
"strings"
"sync"
)
const (
@@ -238,7 +238,7 @@ func (w *K8sWatcher) extractRoutableServices(obj interface{}) []*routableService
routableServices := make([]*routableService, 0)
if externalServiceName, exists := service.Annotations[AnnotationExternalServerName]; exists {
serviceNames := strings.Split(externalServiceName, ",")
serviceNames := SplitExternalHosts(externalServiceName)
for _, serviceName := range serviceNames {
routableServices = append(routableServices, w.buildDetails(service, serviceName))
}
+53
View File
@@ -117,6 +117,59 @@ func TestK8sWatcherImpl_handleAddThenUpdate(t *testing.T) {
},
},
},
{
name: "comma with spaces",
initial: svcAndScenarios{
svc: ` {"metadata": {"annotations": {"mc-router.itzg.me/externalServerName": "a.com, b.com"}}, "spec":{"clusterIP": "1.1.1.1"}}`,
scenarios: []scenario{
{server: "a.com", backend: "1.1.1.1:25565"},
{server: "b.com", backend: "1.1.1.1:25565"},
},
},
update: svcAndScenarios{
svc: ` {"metadata": {"annotations": {"mc-router.itzg.me/externalServerName": "b.com"}}, "spec":{"clusterIP": "1.1.1.1"}}`,
scenarios: []scenario{
{server: "a.com", backend: ""},
{server: "b.com", backend: "1.1.1.1:25565"},
},
},
},
{
name: "newline separated",
initial: svcAndScenarios{
svc: ` {"metadata": {"annotations": {"mc-router.itzg.me/externalServerName": "a.com\nb.com"}}, "spec":{"clusterIP": "1.1.1.1"}}`,
scenarios: []scenario{
{server: "a.com", backend: "1.1.1.1:25565"},
{server: "b.com", backend: "1.1.1.1:25565"},
},
},
update: svcAndScenarios{
svc: ` {"metadata": {"annotations": {"mc-router.itzg.me/externalServerName": "b.com"}}, "spec":{"clusterIP": "1.1.1.1"}}`,
scenarios: []scenario{
{server: "a.com", backend: ""},
{server: "b.com", backend: "1.1.1.1:25565"},
},
},
},
{
name: "mixed comma and newline with spaces",
initial: svcAndScenarios{
svc: ` {"metadata": {"annotations": {"mc-router.itzg.me/externalServerName": "a.com, \nb.com, c.com"}}, "spec":{"clusterIP": "1.1.1.1"}}`,
scenarios: []scenario{
{server: "a.com", backend: "1.1.1.1:25565"},
{server: "b.com", backend: "1.1.1.1:25565"},
{server: "c.com", backend: "1.1.1.1:25565"},
},
},
update: svcAndScenarios{
svc: ` {"metadata": {"annotations": {"mc-router.itzg.me/externalServerName": "b.com"}}, "spec":{"clusterIP": "1.1.1.1"}}`,
scenarios: []scenario{
{server: "a.com", backend: ""},
{server: "b.com", backend: "1.1.1.1:25565"},
{server: "c.com", backend: ""},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
+38
View File
@@ -0,0 +1,38 @@
package server
import (
"regexp"
"strings"
)
// splitPattern is the regex pattern used to split external host definitions.
// kept as a const so the literal is easy to see and reuse in tests or docs.
const splitPattern = ",|\n"
// splitRe is the compiled regexp for splitPattern. Compiling once at package
// initialization avoids repeated compilation on every call to
// SplitExternalHosts and is slightly more efficient.
var splitRe = regexp.MustCompile(splitPattern)
// SplitExternalHosts splits a string containing external hostnames by comma and/or newline delimiters.
// It trims whitespace around each hostname and filters out empty strings.
// Examples:
// - "host1.com,host2.com" -> ["host1.com", "host2.com"]
// - "host1.com, host2.com" -> ["host1.com", "host2.com"]
// - "host1.com\nhost2.com" -> ["host1.com", "host2.com"]
// - "host1.com,\nhost2.com" -> ["host1.com", "host2.com"]
func SplitExternalHosts(s string) []string {
// Use regexp to split on either comma or newline
parts := splitRe.Split(s, -1)
// Trim whitespace and filter out empty strings
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}