From 2632061335d8f51cea120eedcb0dce31c61d63de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <robin.braemer@web.de>
Date: Mon, 3 Jul 2023 17:03:28 +0200
Subject: [PATCH] feat: Gate Lite: Add fallback status for offline route (#217)

Co-authored-by: jnorman-us <jnormantransactions@gmail.com>
---
 .web/docs/guide/lite.md                  |  30 +++++-
 config-lite.yml                          |  17 ++++
 config.yml                               |  19 +++-
 go.mod                                   |  22 ++---
 go.sum                                   |  44 ++++-----
 pkg/edition/java/lite/config/config.go   |  77 ++++++++++++---
 pkg/edition/java/lite/forward.go         | 115 +++++++++++++++++++----
 pkg/edition/java/lite/match.go           |   8 +-
 pkg/edition/java/ping/pong.go            |  81 ++++++++++++----
 pkg/edition/java/ping/pong_test.go       |   2 +-
 pkg/edition/java/proxy/proxy.go          |  37 ++------
 pkg/edition/java/proxy/session_status.go |  17 ++--
 pkg/util/componentutil/componentutil.go  |  31 ++++++
 pkg/util/configutil/multivalue.go        |  29 +++++-
 pkg/util/favicon/favicon.go              |  57 ++++++++++-
 15 files changed, 447 insertions(+), 139 deletions(-)
 create mode 100644 pkg/util/componentutil/componentutil.go

diff --git a/.web/docs/guide/lite.md b/.web/docs/guide/lite.md
index 651ec775..a956fb1c 100644
--- a/.web/docs/guide/lite.md
+++ b/.web/docs/guide/lite.md
@@ -81,13 +81,41 @@ config:
         cachePingTTL: -1 // [!code ++]
 ```
 
+## Fallback status for offline backends
+
+If all backends of a route are unreachable, Gate Lite will return a fallback status response if configured.
+You can utilize all available status fields to customize the response. (See full sample config below.)
+
+::: code-group
+```yaml [config.yml]
+config:
+  lite:
+    enabled: true
+    routes:
+      - host: localhost
+        # The backend server to connect to if matched.
+        backend: localhost:25566
+        # The optional fallback status response when backend of this route are offline.
+        fallback:
+          motd: |
+            §cLocalhost server is offline.
+            §eCheck back later!
+          version:
+            name: '§cTry again later!'
+            protocol: -1
+```
+:::
+          
+
 ## Sample config
 
 The Lite configuration is located in the same Gate `config.yml` file under `lite`.
 
-```yaml config-lite.yml
+::: code-group
+```yaml [config-lite.yml on GitHub]
 <!--@include: ../../../config-lite.yml -->
 ```
+:::
 
 ## Proxy behind proxy
 
diff --git a/config-lite.yml b/config-lite.yml
index b7a458e1..4b881e5e 100644
--- a/config-lite.yml
+++ b/config-lite.yml
@@ -25,6 +25,17 @@ config:
       - host: localhost
         # The backend server to connect to if matched.
         backend: localhost:25566
+        # The optional fallback status response when backend of this route are offline.
+        fallback:
+          motd: |
+            §cLocalhost server is offline.
+            §eCheck back later!
+          version:
+            name: '§cTry again later!'
+            protocol: -1
+          # The optional favicon to show in the server list (optimal 64x64).
+          # Accepts a path of an image file or the base64 data uri.
+          favicon: data:image/png;base64,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
       # You can also use * wildcard to match any subdomain.
       - host: '*.example.com'
         backend: 172.16.0.12:25566
@@ -40,3 +51,9 @@ config:
       # Match all as last item routes any other host to a default backend.
       - host: '*'
         backend: 10.0.0.10:25565
+        fallback:
+          motd: §eThere is server for this host.
+          version:
+            name: §eTry example.com
+            protocol: -1
+          favicon: server-icon.png
diff --git a/config.yml b/config.yml
index 1a45dfd9..dd920512 100644
--- a/config.yml
+++ b/config.yml
@@ -133,6 +133,17 @@ config:
       - host: localhost
         # The backend server to connect to if matched.
         backend: localhost:25566
+        # The optional fallback status response when backend of this route are offline.
+        fallback:
+          motd: |
+            §cLocalhost server is offline.
+            §eCheck back later!
+          version:
+            name: '§cTry again later!'
+            protocol: -1
+          # The optional favicon to show in the server list (optimal 64x64).
+          # Accepts a path of an image file or the base64 data uri.
+          favicon: data:image/png;base64,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
       # You can also use * wildcard to match any subdomain.
       - host: '*.example.com'
         backend: 172.16.0.12:25566
@@ -141,13 +152,19 @@ config:
       # You can also match to multiple hosts to one or multiple random backends.
       - host: [ 127.0.0.1, localhost ]
         backend: [ 172.16.0.12:25566, backend.example.com:25566 ]
-        # Ping responses are cached address by default.
+        # Ping responses are cached per backend address by default.
         # To disable motd caching set it to -1.
         # Default: 10s
         cachePingTTL: 60s
       # Match all as last item routes any other host to a default backend.
       - host: '*'
         backend: 10.0.0.10:25565
+        fallback:
+          motd: §eThere is server for this host.
+          version:
+            name: §eTry example.com
+            protocol: -1
+          favicon: server-icon.png
 
 # Configuration for Connect, a network that organizes all Minecraft servers/proxies
 # and makes them universally accessible for all players.
diff --git a/go.mod b/go.mod
index d1143634..630bae7e 100644
--- a/go.mod
+++ b/go.mod
@@ -21,22 +21,22 @@ require (
 	github.com/sandertv/gophertunnel v1.30.0
 	github.com/spf13/viper v1.16.0
 	github.com/stretchr/testify v1.8.3
-	github.com/urfave/cli/v2 v2.25.6
+	github.com/urfave/cli/v2 v2.25.7
 	go.minekube.com/brigodier v0.0.1
 	go.minekube.com/common v0.0.5
 	go.minekube.com/connect v0.5.3
 	go.uber.org/atomic v1.11.0
 	go.uber.org/multierr v1.11.0
 	go.uber.org/zap v1.24.0
-	golang.org/x/text v0.9.0
+	golang.org/x/text v0.10.0
 	golang.org/x/time v0.3.0
-	google.golang.org/grpc v1.55.0
+	google.golang.org/grpc v1.56.1
 	gopkg.in/yaml.v3 v3.0.1
 	nhooyr.io/websocket v1.8.7
 )
 
 require (
-	buf.build/gen/go/minekube/connect/protocolbuffers/go v1.30.0-20230517110945-04c17e7d2fd9.1 // indirect
+	buf.build/gen/go/minekube/connect/protocolbuffers/go v1.31.0-20230517110945-04c17e7d2fd9.1 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/df-mc/atomic v1.10.0 // indirect
@@ -49,10 +49,10 @@ require (
 	github.com/golang/protobuf v1.5.3 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
-	github.com/klauspost/compress v1.16.5 // indirect
+	github.com/klauspost/compress v1.16.7 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
-	github.com/mitchellh/mapstructure v1.5.0 // indirect
+	github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 // indirect
 	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -64,10 +64,10 @@ require (
 	github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
 	go.uber.org/goleak v1.1.12 // indirect
-	golang.org/x/image v0.7.0 // indirect
-	golang.org/x/sync v0.2.0 // indirect
-	golang.org/x/sys v0.8.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect
-	google.golang.org/protobuf v1.30.0 // indirect
+	golang.org/x/image v0.8.0 // indirect
+	golang.org/x/sync v0.3.0 // indirect
+	golang.org/x/sys v0.9.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 // indirect
+	google.golang.org/protobuf v1.31.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 7312a83e..48611812 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-buf.build/gen/go/minekube/connect/protocolbuffers/go v1.30.0-20230517110945-04c17e7d2fd9.1 h1:NkkQjvgw7SPbh9/rkWY56YRwN7p46CpudaJ00ZeMUpA=
-buf.build/gen/go/minekube/connect/protocolbuffers/go v1.30.0-20230517110945-04c17e7d2fd9.1/go.mod h1:pY8nIBqtBexIBlNoBXG+3gviKiC4C9QLAIJ3FHwXUDs=
+buf.build/gen/go/minekube/connect/protocolbuffers/go v1.31.0-20230517110945-04c17e7d2fd9.1 h1:XtV2D6M20yq2ZQhCrrnY1BqmHCRyvtwD+jHQ8Erm4R0=
+buf.build/gen/go/minekube/connect/protocolbuffers/go v1.31.0-20230517110945-04c17e7d2fd9.1/go.mod h1:L0I6fIRZyNH1qlPNsoIWob7nBZeVQVYrbgoWItSaQJo=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
@@ -221,8 +221,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
-github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
+github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -243,8 +243,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
 github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
-github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
-github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 h1:BpfhmLKZf+SjVanKKhCgf3bg+511DmU9eDQTen7LLbY=
+github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -339,8 +339,8 @@ github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
 github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
 github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
 github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
-github.com/urfave/cli/v2 v2.25.6 h1:yuSkgDSZfH3L1CjF2/5fNNg2KbM47pY2EvjBq4ESQnU=
-github.com/urfave/cli/v2 v2.25.6/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
+github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
+github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
 github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU=
 github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM=
 github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
@@ -402,8 +402,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
-golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
+golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
+golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
 golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -494,8 +494,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
-golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -541,8 +541,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
+golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -555,8 +555,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
+golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -690,8 +690,8 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc h1:XSJ8Vk1SWuNr8S18z1NZSziL0CPIXLCCMDOEFtHBOFc=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 h1:DEH99RbiLZhMxrpEJCZ0A+wdTe0EOgou/poSLx9vWf4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
 google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
@@ -711,8 +711,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
 google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
 google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
-google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
+google.golang.org/grpc v1.56.1 h1:z0dNfjIl0VpaZ9iSVjA6daGatAYwPGstTjt5vkRMFkQ=
+google.golang.org/grpc v1.56.1/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -725,8 +725,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
-google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
diff --git a/pkg/edition/java/lite/config/config.go b/pkg/edition/java/lite/config/config.go
index 173e200d..148dfaaf 100644
--- a/pkg/edition/java/lite/config/config.go
+++ b/pkg/edition/java/lite/config/config.go
@@ -2,9 +2,17 @@ package config
 
 import (
 	"fmt"
+	"sync"
 	"time"
 
+	"go.minekube.com/common/minecraft/component"
+	"go.minekube.com/gate/pkg/edition/java/forge/modinfo"
+	"go.minekube.com/gate/pkg/edition/java/ping"
+	"go.minekube.com/gate/pkg/gate/proto"
+	"go.minekube.com/gate/pkg/util/componentutil"
 	"go.minekube.com/gate/pkg/util/configutil"
+	"go.minekube.com/gate/pkg/util/favicon"
+	"go.minekube.com/gate/pkg/util/netutil"
 )
 
 // DefaultConfig is the default configuration for Lite mode.
@@ -20,14 +28,57 @@ type (
 		Routes  []Route
 	}
 	Route struct {
-		Host          configutil.SingleOrMulti[string]
-		Backend       configutil.SingleOrMulti[string]
-		CachePingTTL  time.Duration // 0 = default, < 0 = disabled
-		ProxyProtocol bool
-		RealIP        bool
+		Host          configutil.SingleOrMulti[string] `json:"host" yaml:"host"`
+		Backend       configutil.SingleOrMulti[string] `json:"backend" yaml:"backend"`
+		CachePingTTL  time.Duration                    `json:"cachePingTTL,omitempty" yaml:"cachePingTTL,omitempty"` // 0 = default, < 0 = disabled
+		Fallback      *Status                          `json:"fallback,omitempty" yaml:"fallback,omitempty"`         // nil = disabled
+		ProxyProtocol bool                             `json:"proxyProtocol,omitempty" yaml:"proxyProtocol,omitempty"`
+		RealIP        bool                             `json:"realIP,omitempty" yaml:"realIP,omitempty"`
+	}
+	Status struct {
+		MOTD    string          `yaml:"motd,omitempty" json:"motd,omitempty"`
+		Version ping.Version    `yaml:"version,omitempty" json:"version,omitempty"`
+		Favicon favicon.Favicon `yaml:"favicon,omitempty" json:"favicon,omitempty"`
+		ModInfo modinfo.ModInfo `yaml:"modInfo,omitempty" json:"modInfo,omitempty"`
+
+		ParsedMOTD struct {
+			Text      *component.Text `yaml:"-" json:"-"`
+			sync.Once `yaml:"-" json:"-"`
+		} `yaml:"-" json:"-"`
 	}
 )
 
+// Response returns the configured status response.
+func (s *Status) Response(protocol proto.Protocol) (*ping.ServerPing, error) {
+	// Lazy parse MOTD
+	var err error
+	s.ParsedMOTD.Do(func() {
+		s.ParsedMOTD.Text, err = componentutil.ParseTextComponent(protocol, s.MOTD)
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &ping.ServerPing{
+		Version:     s.Version,
+		Description: s.ParsedMOTD.Text,
+		Favicon:     s.Favicon,
+		ModInfo:     &s.ModInfo,
+	}, nil
+}
+
+// GetCachePingTTL returns the configured ping cache TTL or a default duration if not set.
+func (r *Route) GetCachePingTTL() time.Duration {
+	const defaultTTL = time.Second * 10
+	if r.CachePingTTL == 0 {
+		return defaultTTL
+	}
+	return r.CachePingTTL
+}
+
+// CachePingEnabled returns true if the route has a ping cache enabled.
+func (r *Route) CachePingEnabled() bool { return r.GetCachePingTTL() > 0 }
+
 func (c Config) Validate() (warns []error, errs []error) {
 	e := func(m string, args ...any) { errs = append(errs, fmt.Errorf(m, args...)) }
 
@@ -43,17 +94,13 @@ func (c Config) Validate() (warns []error, errs []error) {
 		if len(ep.Backend) == 0 {
 			e("Route %d: no backend configured", i)
 		}
+		for i, addr := range ep.Backend {
+			_, err := netutil.Parse(addr, "tcp")
+			if err != nil {
+				e("Route %d: backend %d: failed to parse address: %w", i, err)
+			}
+		}
 	}
 
 	return
 }
-
-func (r *Route) GetCachePingTTL() time.Duration {
-	const defaultTTL = time.Second * 10
-	if r.CachePingTTL == 0 {
-		return defaultTTL
-	}
-	return r.CachePingTTL
-}
-
-func (r *Route) CachePingEnabled() bool { return r.GetCachePingTTL() > 0 }
diff --git a/pkg/edition/java/lite/forward.go b/pkg/edition/java/lite/forward.go
index a1fcb2f2..684ff8ae 100644
--- a/pkg/edition/java/lite/forward.go
+++ b/pkg/edition/java/lite/forward.go
@@ -3,6 +3,7 @@ package lite
 import (
 	"bytes"
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -36,15 +37,18 @@ func Forward(
 ) {
 	defer func() { _ = client.Close() }()
 
-	log, src, backendAddr, route, err := findRoute(routes, log, client, handshake)
+	log, src, route, nextBackend, err := findRoute(routes, log, client, handshake)
 	if err != nil {
 		errs.V(log, err).Info("failed to find route", "error", err)
 		return
 	}
 
-	dst, err := dialRoute(client.Context(), dialTimeout, src.RemoteAddr(), route, backendAddr, handshake, pc, false)
+	// Find a backend to dial successfully.
+	log, dst, err := tryBackends(nextBackend, func(log logr.Logger, backendAddr string) (logr.Logger, net.Conn, error) {
+		conn, err := dialRoute(client.Context(), dialTimeout, src.RemoteAddr(), route, backendAddr, handshake, pc, false)
+		return log, conn, err
+	})
 	if err != nil {
-		errs.V(log, err).Info("failed to dial route", "error", err)
 		return
 	}
 	defer func() { _ = dst.Close() }()
@@ -58,6 +62,27 @@ func Forward(
 	pipe(log, src, dst)
 }
 
+// errAllBackendsFailed is returned when all backends failed to dial.
+var errAllBackendsFailed = errors.New("all backends failed")
+
+// tryBackends tries backends until one succeeds or all fail.
+func tryBackends[T any](next nextBackendFunc, try func(log logr.Logger, backendAddr string) (logr.Logger, T, error)) (logr.Logger, T, error) {
+	for {
+		backendAddr, log, ok := next()
+		if !ok {
+			var zero T
+			return log, zero, errAllBackendsFailed
+		}
+
+		log, t, err := try(log, backendAddr)
+		if err != nil {
+			errs.V(log, err).Info("failed to try backend", "error", err)
+			continue
+		}
+		return log, t, nil
+	}
+}
+
 func emptyReadBuff(src netmc.MinecraftConn, dst net.Conn) error {
 	buff, ok := src.(interface{ ReadBuffered() ([]byte, error) })
 	if ok {
@@ -93,6 +118,8 @@ func pipe(log logr.Logger, src, dst net.Conn) {
 	}
 }
 
+type nextBackendFunc func() (backendAddr string, log logr.Logger, ok bool)
+
 func findRoute(
 	routes []config.Route,
 	log logr.Logger,
@@ -101,13 +128,13 @@ func findRoute(
 ) (
 	newLog logr.Logger,
 	src net.Conn,
-	backendAddr string,
 	route *config.Route,
+	nextBackend nextBackendFunc,
 	err error,
 ) {
 	srcConn, ok := netmc.Assert[interface{ Conn() net.Conn }](client)
 	if !ok {
-		return log, src, "", nil, errors.New("failed to assert connection as net.Conn")
+		return log, src, nil, nil, errors.New("failed to assert connection as net.Conn")
 	}
 	src = srcConn.Conn()
 
@@ -120,25 +147,37 @@ func findRoute(
 
 	host, route := FindRoute(clearedHost, routes...)
 	if route == nil {
-		return log.V(1), src, "", nil, fmt.Errorf("no route configured for host %s", clearedHost)
+		return log.V(1), src, nil, nil, fmt.Errorf("no route configured for host %s", clearedHost)
 	}
 	log = log.WithValues("route", host)
 
-	backend := route.Backend.Random()
-	if backend == "" {
-		return log, src, "", nil, errors.New("no backend configured for route")
+	if len(route.Backend) == 0 {
+		return log, src, route, nil, errors.New("no backend configured for route")
 	}
-	dstAddr, err := netutil.Parse(backend, src.RemoteAddr().Network())
-	if err != nil {
-		return log, src, "", nil, fmt.Errorf("failed to parse backend address: %w", err)
-	}
-	backendAddr = dstAddr.String()
-	if _, port := netutil.HostPort(dstAddr); port == 0 {
-		backendAddr = net.JoinHostPort(dstAddr.String(), "25565")
+
+	tryBackends := route.Backend.Copy()
+	nextBackend = func() (string, logr.Logger, bool) {
+		if len(tryBackends) == 0 {
+			return "", log, false
+		}
+		// Pop first backend
+		backend := tryBackends[0]
+		tryBackends = tryBackends[1:]
+
+		dstAddr, err := netutil.Parse(backend, src.RemoteAddr().Network())
+		if err != nil {
+			log.Info("failed to parse backend address", "wrongBackendAddr", backend, "error", err)
+			return "", log, false
+		}
+		backendAddr := dstAddr.String()
+		if _, port := netutil.HostPort(dstAddr); port == 0 {
+			backendAddr = net.JoinHostPort(dstAddr.String(), "25565")
+		}
+
+		return backendAddr, log.WithValues("backendAddr", backendAddr), true
 	}
-	log = log.WithValues("backendAddr", backendAddr)
 
-	return log, src, backendAddr, route, nil
+	return log, src, route, nextBackend, nil
 }
 
 func dialRoute(
@@ -239,11 +278,47 @@ func ResolveStatusResponse(
 	handshakeCtx *proto.PacketContext,
 	statusRequestCtx *proto.PacketContext,
 ) (logr.Logger, *packet.StatusResponse, error) {
-	log, src, backendAddr, route, err := findRoute(routes, log, client, handshake)
+	log, src, route, nextBackend, err := findRoute(routes, log, client, handshake)
 	if err != nil {
 		return log, nil, err
 	}
 
+	log, res, err := tryBackends(nextBackend, func(log logr.Logger, backendAddr string) (logr.Logger, *packet.StatusResponse, error) {
+		return resolveStatusResponse(src, dialTimeout, backendAddr, route, log, client, handshake, handshakeCtx, statusRequestCtx)
+	})
+	if err != nil && route.Fallback != nil {
+		log.Info("failed to resolve status response, will use fallback status response", "error", err)
+
+		// Fallback status response if configured
+		fallbackPong, err := route.Fallback.Response(handshakeCtx.Protocol)
+		if err != nil {
+			log.Info("failed to get fallback status response", "error", err)
+		}
+		if fallbackPong != nil {
+			status, err2 := json.Marshal(fallbackPong)
+			if err2 != nil {
+				return log, nil, fmt.Errorf("%w: failed to marshal fallback status response: %w", err, err2)
+			}
+			if log.V(1).Enabled() {
+				log.V(1).Info("using fallback status response", "status", string(status))
+			}
+			return log, &packet.StatusResponse{Status: string(status)}, nil
+		}
+	}
+	return log, res, err
+}
+
+func resolveStatusResponse(
+	src net.Conn,
+	dialTimeout time.Duration,
+	backendAddr string,
+	route *config.Route,
+	log logr.Logger,
+	client netmc.MinecraftConn,
+	handshake *packet.Handshake,
+	handshakeCtx *proto.PacketContext,
+	statusRequestCtx *proto.PacketContext,
+) (logr.Logger, *packet.StatusResponse, error) {
 	// fast path: use cache
 	if route.CachePingEnabled() {
 		item := pingCache.Get(backendAddr)
@@ -305,7 +380,7 @@ func ResolveStatusResponse(
 		return log, result.res, result.err
 	case <-client.Context().Done():
 		return log, nil, &errs.VerbosityError{
-			Err:       client.Context().Err(),
+			Err:       context.Cause(client.Context()),
 			Verbosity: 1,
 		}
 	}
diff --git a/pkg/edition/java/lite/match.go b/pkg/edition/java/lite/match.go
index a3564eeb..816ca8f0 100644
--- a/pkg/edition/java/lite/match.go
+++ b/pkg/edition/java/lite/match.go
@@ -9,12 +9,12 @@ import (
 )
 
 // FindRoute returns the first route that matches the given wildcard supporting pattern.
-func FindRoute(pattern string, routes ...config.Route) (host string, ep *config.Route) {
+func FindRoute(pattern string, routes ...config.Route) (host string, route *config.Route) {
 	for i := range routes {
-		ep = &routes[i]
-		for _, host = range ep.Host {
+		route = &routes[i]
+		for _, host = range route.Host {
 			if match(pattern, host) {
-				return host, ep
+				return host, route
 			}
 		}
 	}
diff --git a/pkg/edition/java/ping/pong.go b/pkg/edition/java/ping/pong.go
index 4baeb5e8..9b6d0a83 100644
--- a/pkg/edition/java/ping/pong.go
+++ b/pkg/edition/java/ping/pong.go
@@ -4,24 +4,37 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"strings"
 
 	"go.minekube.com/common/minecraft/component"
+	"go.minekube.com/common/minecraft/component/codec/legacy"
 	"go.minekube.com/gate/pkg/edition/java/forge/modinfo"
 	"go.minekube.com/gate/pkg/edition/java/proto/util"
 	"go.minekube.com/gate/pkg/gate/proto"
+	"go.minekube.com/gate/pkg/util/componentutil"
 	"go.minekube.com/gate/pkg/util/favicon"
 	"go.minekube.com/gate/pkg/util/uuid"
+	"gopkg.in/yaml.v3"
 )
 
 // ServerPing is a 1.7 and above server list ping response.
 type ServerPing struct {
-	Version     Version          `json:"version"`
-	Players     *Players         `json:"players"`
-	Description *component.Text  `json:"description"`
-	Favicon     favicon.Favicon  `json:"favicon,omitempty"`
-	ModInfo     *modinfo.ModInfo `json:"modinfo,omitempty"`
+	Version     Version          `json:"version,omitempty" yaml:"version,omitempty"`
+	Players     *Players         `json:"players,omitempty" yaml:"players,omitempty"`
+	Description *component.Text  `json:"description" yaml:"description"`
+	Favicon     favicon.Favicon  `json:"favicon,omitempty" yaml:"favicon,omitempty"`
+	ModInfo     *modinfo.ModInfo `json:"modinfo,omitempty" yaml:"modinfo,omitempty"`
 }
 
+// Make sure ServerPing implements the interfaces at compile time.
+var (
+	_ json.Marshaler   = (*ServerPing)(nil)
+	_ json.Unmarshaler = (*ServerPing)(nil)
+
+	_ yaml.Marshaler   = (*ServerPing)(nil)
+	_ yaml.Unmarshaler = (*ServerPing)(nil)
+)
+
 func (p *ServerPing) MarshalJSON() ([]byte, error) {
 	b := new(bytes.Buffer)
 	err := util.JsonCodec(p.Version.Protocol).Marshal(b, p.Description)
@@ -48,26 +61,58 @@ func (p *ServerPing) UnmarshalJSON(data []byte) error {
 		return fmt.Errorf("error decoding json: %w", err)
 	}
 
-	if string(out.Description) != "{}" {
-		description, err := util.JsonCodec(out.Version.Protocol).Unmarshal(out.Description)
-		if err != nil {
-			return fmt.Errorf("error decoding description: %w", err)
-		}
-
-		var ok bool
-		out.Alias.Description, ok = description.(*component.Text)
-		if !ok {
-			return fmt.Errorf("unmmarshalled description is not a TextComponent, but %T", description)
-		}
+	var err error
+	out.Alias.Description, err = componentutil.ParseTextComponent(out.Version.Protocol, string(out.Description))
+	if err != nil {
+		return fmt.Errorf("error decoding description: %w", err)
 	}
 
 	*p = ServerPing(out.Alias)
 	return nil
 }
 
+func (p *ServerPing) UnmarshalYAML(value *yaml.Node) error {
+	type Alias ServerPing
+	out := &struct {
+		Description string `yaml:"description"` // override description type
+		*Alias
+	}{
+		Alias: (*Alias)(p),
+	}
+	if err := value.Decode(out); err != nil {
+		return fmt.Errorf("error decoding yaml: %w", err)
+	}
+
+	var err error
+	p.Description, err = componentutil.ParseTextComponent(out.Version.Protocol, out.Description)
+	if err != nil {
+		return fmt.Errorf("error decoding description: %w", err)
+	}
+
+	*p = ServerPing(*out.Alias)
+	return nil
+}
+
+func (p *ServerPing) MarshalYAML() (interface{}, error) {
+	b := new(strings.Builder)
+	err := (&legacy.Legacy{}).Marshal(b, p.Description)
+	if err != nil {
+		return nil, fmt.Errorf("error encoding description: %w", err)
+	}
+
+	type Alias ServerPing
+	return &struct {
+		Description string
+		*Alias
+	}{
+		Description: b.String(),
+		Alias:       (*Alias)(p),
+	}, nil
+}
+
 type Version struct {
-	Protocol proto.Protocol `json:"protocol"`
-	Name     string         `json:"name"`
+	Protocol proto.Protocol `json:"protocol,omitempty" yaml:"protocol,omitempty"`
+	Name     string         `json:"name,omitempty" yaml:"name,omitempty"`
 }
 
 type Players struct {
diff --git a/pkg/edition/java/ping/pong_test.go b/pkg/edition/java/ping/pong_test.go
index 75416465..0418721f 100644
--- a/pkg/edition/java/ping/pong_test.go
+++ b/pkg/edition/java/ping/pong_test.go
@@ -29,7 +29,7 @@ func TestServerPing_JSON(t *testing.T) {
 		Description: &component.Text{
 			Content: "Hello",
 		},
-		Favicon: "favicon",
+		Favicon: "data:image/png;base64,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",
 	}
 
 	// Marshal to json.
diff --git a/pkg/edition/java/proxy/proxy.go b/pkg/edition/java/proxy/proxy.go
index 77bad4d8..5ffdb342 100644
--- a/pkg/edition/java/proxy/proxy.go
+++ b/pkg/edition/java/proxy/proxy.go
@@ -19,11 +19,12 @@ import (
 	"go.minekube.com/gate/pkg/edition/java/auth"
 	"go.minekube.com/gate/pkg/edition/java/config"
 	"go.minekube.com/gate/pkg/edition/java/netmc"
-	protoutil "go.minekube.com/gate/pkg/edition/java/proto/util"
+	"go.minekube.com/gate/pkg/edition/java/proto/version"
 	"go.minekube.com/gate/pkg/edition/java/proxy/message"
 	"go.minekube.com/gate/pkg/gate/proto"
 	"go.minekube.com/gate/pkg/internal/addrquota"
 	"go.minekube.com/gate/pkg/internal/connwrap"
+	"go.minekube.com/gate/pkg/util/componentutil"
 	"go.minekube.com/gate/pkg/util/errs"
 	"go.minekube.com/gate/pkg/util/favicon"
 	"go.minekube.com/gate/pkg/util/netutil"
@@ -265,7 +266,7 @@ func (p *Proxy) loadShutdownReason() (err error) {
 	if len(c.ShutdownReason) == 0 {
 		return nil
 	}
-	p.shutdownReason, err = parseTextComponentFromConfig(c.ShutdownReason)
+	p.shutdownReason, err = componentutil.ParseTextComponent(version.Legacy.Protocol, c.ShutdownReason)
 	return
 }
 
@@ -274,44 +275,18 @@ func (p *Proxy) loadMotd() (err error) {
 	if len(c.Status.Motd) == 0 {
 		return nil
 	}
-	p.motd, err = parseTextComponentFromConfig(c.Status.Motd)
+	p.motd, err = componentutil.ParseTextComponent(version.Legacy.Protocol, c.Status.Motd)
 	return
 }
 
-func parseTextComponentFromConfig(s string) (t *component.Text, err error) {
-	var c component.Component
-	if strings.HasPrefix(s, "{") {
-		c, err = protoutil.LatestJsonCodec().Unmarshal([]byte(s))
-	} else {
-		c, err = (&legacy.Legacy{}).Unmarshal([]byte(s))
-	}
-	if err != nil {
-		return nil, err
-	}
-	t, ok := c.(*component.Text)
-	if !ok {
-		return nil, errors.New("invalid text component")
-	}
-	return t, nil
-}
-
 // initializes favicon from the cfg
 func (p *Proxy) loadFavicon() (err error) {
 	c := p.cfg
 	if len(c.Status.Favicon) == 0 {
 		return nil
 	}
-	if strings.HasPrefix(c.Status.Favicon, "data:image/") {
-		p.favicon = favicon.Favicon(c.Status.Favicon)
-		p.log.Info("Using favicon from data uri", "length", len(p.favicon))
-	} else {
-		p.favicon, err = favicon.FromFile(c.Status.Favicon)
-		if err != nil {
-			return fmt.Errorf("error reading favicon file %q: %w", c.Status.Favicon, err)
-		}
-		p.log.Info("Using favicon file", "file", c.Status.Favicon)
-	}
-	return nil
+	p.favicon, err = favicon.Parse(c.Status.Favicon)
+	return err
 }
 
 func (p *Proxy) initPlugins(ctx context.Context) error {
diff --git a/pkg/edition/java/proxy/session_status.go b/pkg/edition/java/proxy/session_status.go
index 5540780f..6a944211 100644
--- a/pkg/edition/java/proxy/session_status.go
+++ b/pkg/edition/java/proxy/session_status.go
@@ -113,32 +113,37 @@ func (h *statusSessionHandler) handleStatusRequest(pc *proto.PacketContext) {
 		inbound: h.inbound,
 	}
 
-	if h.resolvePingResponse != nil {
-		log, res, err := h.resolvePingResponse(h.log, pc)
+	log := h.log
+	if h.resolvePingResponse == nil {
+		e.ping = newInitialPing(h.proxy, pc.Protocol)
+	} else {
+		var err error
+		var res *packet.StatusResponse
+		log, res, err = h.resolvePingResponse(h.log, pc)
 		if err != nil {
 			errs.V(log, err).Info("could not resolve ping", "error", err)
 			_ = h.conn.Close()
 			return
 		}
 		if !h.eventMgr.HasSubscriber(e) {
+			// Fast path: No event handler, just send response
 			_ = h.conn.WritePacket(res)
 			return
 		}
+		// Need to unmarshal status response to ping struct for event handlers
 		e.ping = new(ping.ServerPing)
 		if err = json.Unmarshal([]byte(res.Status), e.ping); err != nil {
 			h.log.V(1).Error(err, "failed to unmarshal status response")
 			_ = h.conn.Close()
 			return
 		}
-	} else {
-		e.ping = newInitialPing(h.proxy, h.conn.Protocol())
 	}
 
 	h.eventMgr.Fire(e)
 
 	if e.ping == nil {
 		_ = h.conn.Close()
-		h.log.V(1).Info("ping response was set to nil by an event handler, no response is sent")
+		log.V(1).Info("ping response was set to nil by an event handler, no response is sent")
 		return
 	}
 	if !h.inbound.Active() {
@@ -148,7 +153,7 @@ func (h *statusSessionHandler) handleStatusRequest(pc *proto.PacketContext) {
 	response, err := json.Marshal(e.ping)
 	if err != nil {
 		_ = h.conn.Close()
-		h.log.Error(err, "error marshaling ping response to json")
+		log.Error(err, "error marshaling ping response to json")
 		return
 	}
 	_ = h.conn.WritePacket(&packet.StatusResponse{
diff --git a/pkg/util/componentutil/componentutil.go b/pkg/util/componentutil/componentutil.go
new file mode 100644
index 00000000..196b6d01
--- /dev/null
+++ b/pkg/util/componentutil/componentutil.go
@@ -0,0 +1,31 @@
+package componentutil
+
+import (
+	"errors"
+	"strings"
+
+	"go.minekube.com/common/minecraft/component"
+	"go.minekube.com/common/minecraft/component/codec/legacy"
+	protoutil "go.minekube.com/gate/pkg/edition/java/proto/util"
+	"go.minekube.com/gate/pkg/gate/proto"
+)
+
+// ParseTextComponent parses a text component from a string.
+// The string can be either a legacy or json Minecraft text component.
+func ParseTextComponent(protocol proto.Protocol, s string) (t *component.Text, err error) {
+	s = strings.TrimSpace(s)
+	var c component.Component
+	if strings.HasPrefix(s, "{") {
+		c, err = protoutil.JsonCodec(protocol).Unmarshal([]byte(s))
+	} else {
+		c, err = (&legacy.Legacy{}).Unmarshal([]byte(s))
+	}
+	if err != nil {
+		return nil, err
+	}
+	t, ok := c.(*component.Text)
+	if !ok {
+		return nil, errors.New("invalid text component")
+	}
+	return t, nil
+}
diff --git a/pkg/util/configutil/multivalue.go b/pkg/util/configutil/multivalue.go
index f1275bdf..8a547372 100644
--- a/pkg/util/configutil/multivalue.go
+++ b/pkg/util/configutil/multivalue.go
@@ -11,14 +11,17 @@ import (
 // SingleOrMulti is a type that can be either a single value or a slice of values.
 type SingleOrMulti[T any] []T
 
+// Make sure SingleOrMulti implements the interfaces at compile time.
 var (
-	_ yaml.Marshaler   = (*SingleOrMulti[string])(nil)
-	_ yaml.Unmarshaler = (*SingleOrMulti[string])(nil)
+	_ yaml.Marshaler   = (*SingleOrMulti[any])(nil)
+	_ yaml.Unmarshaler = (*SingleOrMulti[any])(nil)
 
-	_ json.Marshaler   = (*SingleOrMulti[string])(nil)
-	_ json.Unmarshaler = (*SingleOrMulti[string])(nil)
+	_ json.Marshaler   = (*SingleOrMulti[any])(nil)
+	_ json.Unmarshaler = (*SingleOrMulti[any])(nil)
 )
 
+// UnmarshalYAML unmarshals the value as a YAML array if it is a slice of values.
+// Otherwise, it unmarshals the single value.
 func (a *SingleOrMulti[T]) UnmarshalYAML(value *yaml.Node) error {
 	var multi []T
 	err := value.Decode(&multi)
@@ -35,6 +38,8 @@ func (a *SingleOrMulti[T]) UnmarshalYAML(value *yaml.Node) error {
 	return nil
 }
 
+// UnmarshalJSON unmarshals the value as a JSON array if it is a slice of values.
+// Otherwise, it unmarshals the single value.
 func (a *SingleOrMulti[T]) UnmarshalJSON(bytes []byte) error {
 	var multi []T
 	err := json.Unmarshal(bytes, &multi)
@@ -51,6 +56,8 @@ func (a *SingleOrMulti[T]) UnmarshalJSON(bytes []byte) error {
 	return nil
 }
 
+// MarshalYAML marshals the value as a YAML array if it is a slice of values.
+// Otherwise, it marshals the single value.
 func (a SingleOrMulti[T]) MarshalYAML() (any, error) {
 	if a.IsMulti() {
 		return a.Multi(), nil
@@ -58,6 +65,8 @@ func (a SingleOrMulti[T]) MarshalYAML() (any, error) {
 	return a.Single(), nil
 }
 
+// MarshalJSON marshals the value as a JSON array if it is a slice of values.
+// Otherwise, it marshals the single value.
 func (a SingleOrMulti[T]) MarshalJSON() ([]byte, error) {
 	if a.IsMulti() {
 		return json.Marshal(a.Multi())
@@ -65,10 +74,12 @@ func (a SingleOrMulti[T]) MarshalJSON() ([]byte, error) {
 	return json.Marshal(a.Single())
 }
 
+// IsMulti returns true if the value is a slice of values.
 func (a SingleOrMulti[T]) IsMulti() bool {
-	return len(a) != 0
+	return len(a) > 1
 }
 
+// Single returns first value in the slice or zero value if the slice is empty.
 func (a SingleOrMulti[T]) Single() T {
 	if len(a) == 0 {
 		var zero T
@@ -77,10 +88,17 @@ func (a SingleOrMulti[T]) Single() T {
 	return a[0]
 }
 
+// Multi returns the slice of values.
 func (a SingleOrMulti[T]) Multi() []T {
 	return a
 }
 
+// Copy returns a copy of the SingleOrMulti.
+func (a SingleOrMulti[T]) Copy() SingleOrMulti[T] {
+	return append(SingleOrMulti[T]{}, a...)
+}
+
+// String returns the string representation of the SingleOrMulti.
 func (a SingleOrMulti[T]) String() string {
 	if a.IsMulti() {
 		return fmt.Sprint(a.Multi())
@@ -88,6 +106,7 @@ func (a SingleOrMulti[T]) String() string {
 	return fmt.Sprint(a.Single())
 }
 
+// Random returns a random value from the slice or zero value if the slice is empty.
 func (a SingleOrMulti[T]) Random() T {
 	if len(a) == 0 {
 		var zero T
diff --git a/pkg/util/favicon/favicon.go b/pkg/util/favicon/favicon.go
index e12f95b4..d0e82247 100644
--- a/pkg/util/favicon/favicon.go
+++ b/pkg/util/favicon/favicon.go
@@ -3,6 +3,8 @@ package favicon
 import (
 	"bytes"
 	"encoding/base64"
+	"encoding/json"
+	"fmt"
 	"image"
 	_ "image/jpeg"
 	"image/png"
@@ -10,6 +12,7 @@ import (
 	"strings"
 
 	"github.com/nfnt/resize"
+	"gopkg.in/yaml.v3"
 )
 
 // Favicon is 64x64 sized data uri image send in response to a server list ping.
@@ -17,6 +20,32 @@ import (
 // Example: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAABCAYAAABubagXAAAAEElEQVR42mP8z8BQzzCCAQB+lAGA+H8KEAAAAABJRU5ErkJggg=="
 type Favicon string
 
+// Make sure Favicon implements the interfaces at compile time.
+var (
+	_ yaml.Unmarshaler = (*Favicon)(nil)
+	_ json.Unmarshaler = (*Favicon)(nil)
+)
+
+// UnmarshalJSON implements json.Unmarshaler.
+func (f *Favicon) UnmarshalJSON(bytes []byte) (err error) {
+	var s string
+	if err := json.Unmarshal(bytes, &s); err != nil {
+		return err
+	}
+	*f, err = Parse(s)
+	return err
+}
+
+// UnmarshalYAML implements yaml.Unmarshaler.
+func (f *Favicon) UnmarshalYAML(value *yaml.Node) (err error) {
+	var s string
+	if err = value.Decode(&s); err != nil {
+		return err
+	}
+	*f, err = Parse(s)
+	return err
+}
+
 // FromImage converts an image.Image to Favicon.
 func FromImage(img image.Image) (Favicon, error) {
 	// Resize down to 64x64 if necessary
@@ -46,16 +75,36 @@ func FromFile(filename string) (Favicon, error) {
 	return FromImage(img)
 }
 
-const dataImagePrefix = "data:image/png;base64,"
+const (
+	dataImagePrefix = "data:image/"
+	dataFullPrefix  = dataImagePrefix + "png;base64,"
+)
+
+// Parse takes a data uri string or filename and converts it to Favicon.
+func Parse(s string) (Favicon, error) {
+	if strings.HasPrefix(s, dataImagePrefix) {
+		return Favicon(s), nil
+	}
+	if stat, err := os.Stat(s); err == nil && !stat.IsDir() {
+		f, err := FromFile(s)
+		if err != nil {
+			return "", fmt.Errorf("favicon: %w", err)
+		}
+		return f, nil
+	}
+	return "", fmt.Errorf("favicon: invalid format or file not found: %s", s)
+}
 
-// FromBytes takes the bytes encoding of an image and converts it to Favicon.
+// FromBytes takes base64 bytes encoding of an image and converts it to Favicon.
 func FromBytes(b []byte) Favicon {
+	b = bytes.TrimPrefix(b, []byte(dataFullPrefix))
 	b64 := base64.StdEncoding.EncodeToString(b)
-	return Favicon(dataImagePrefix + b64)
+	return Favicon(dataFullPrefix + b64)
 }
 
 // Bytes returns the bytes encoding of the favicon.
 func (f Favicon) Bytes() []byte {
-	b, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(string(f), dataImagePrefix))
+	s := strings.TrimPrefix(string(f), dataFullPrefix)
+	b, _ := base64.StdEncoding.DecodeString(s)
 	return b
 }