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 }