diff --git a/go.mod b/go.mod index fe3ce02f..2f93df03 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/sandertv/gophertunnel -go 1.22 - -toolchain go1.22.1 +go 1.23.0 require ( + github.com/coder/websocket v1.8.12 + github.com/df-mc/go-nethernet v0.0.0-20240902102242-528de5c8686f + github.com/df-mc/go-playfab v0.0.0-20240902102459-2f8b5cd02173 + github.com/df-mc/go-xsapi v0.0.0-20240902102602-e7c4bffb955f github.com/go-gl/mathgl v1.1.0 github.com/go-jose/go-jose/v3 v3.0.3 github.com/golang/snappy v0.0.4 @@ -12,14 +14,37 @@ require ( github.com/klauspost/compress v1.17.9 github.com/muhammadmuzzammil1998/jsonc v1.0.0 github.com/pelletier/go-toml v1.9.5 + github.com/pion/logging v0.2.2 + github.com/pion/webrtc/v4 v4.0.0-beta.29.0.20240826201411-3147b45f9db5 github.com/sandertv/go-raknet v1.14.1 - golang.org/x/net v0.26.0 + golang.org/x/net v0.27.0 golang.org/x/oauth2 v0.21.0 - golang.org/x/text v0.16.0 + golang.org/x/text v0.17.0 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - golang.org/x/crypto v0.24.0 // indirect + github.com/pion/datachannel v1.5.9 // indirect + github.com/pion/dtls/v3 v3.0.2 // indirect + github.com/pion/ice/v4 v4.0.1 // indirect + github.com/pion/interceptor v0.1.30 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.14 // indirect + github.com/pion/rtp v1.8.9 // indirect + github.com/pion/sctp v1.8.33 // indirect + github.com/pion/sdp/v3 v3.0.9 // indirect + github.com/pion/srtp/v3 v3.0.3 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/wlynxg/anet v0.0.3 // indirect + golang.org/x/crypto v0.26.0 // indirect golang.org/x/image v0.17.0 // indirect + golang.org/x/sys v0.24.0 // indirect +) + +replace ( + github.com/df-mc/go-nethernet => github.com/lactyy/go-nethernet v0.0.0-20240918151603-8274a4680204 + github.com/df-mc/go-playfab => github.com/lactyy/go-playfab v0.0.0-20240911042657-037f6afe426f + github.com/df-mc/go-xsapi => github.com/lactyy/go-xsapi v0.0.0-20240911052022-1b9dffef64ab ) diff --git a/go.sum b/go.sum index 359f81c3..a79efa79 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,25 +15,70 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/lactyy/go-nethernet v0.0.0-20240918151603-8274a4680204 h1:hDn9ED04xKDoEJql0kyGQ9zrXWdlPkCli6A0gKXmZmk= +github.com/lactyy/go-nethernet v0.0.0-20240918151603-8274a4680204/go.mod h1:/pGUz0nwAHcpKynNyRz1sXVsF0klaevDsMkPXsdP7mM= +github.com/lactyy/go-playfab v0.0.0-20240911042657-037f6afe426f h1:0emwbOsvMyx3A+cTBvkBH6WqdnY4CuQo//MYkHNuhts= +github.com/lactyy/go-playfab v0.0.0-20240911042657-037f6afe426f/go.mod h1:nGOlE+JFGOH5Z0iidEgJapHhndFi/oNk17RN9pKCF+k= +github.com/lactyy/go-xsapi v0.0.0-20240911052022-1b9dffef64ab h1:Nl88ngY62OyM0ukw/0c+EYeRN8MDnDrDINEHhc2UdBM= +github.com/lactyy/go-xsapi v0.0.0-20240911052022-1b9dffef64ab/go.mod h1:uKC/a/2/JOamgRDezvgVe7OmXdqERUfmCcIWAOp9hPA= github.com/muhammadmuzzammil1998/jsonc v1.0.0 h1:8o5gBQn4ZA3NBA9DlTujCj2a4w0tqWrPVjDwhzkgTIs= github.com/muhammadmuzzammil1998/jsonc v1.0.0/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA= +github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE= +github.com/pion/dtls/v3 v3.0.2 h1:425DEeJ/jfuTTghhUDW0GtYZYIwwMtnKKJNMcWccTX0= +github.com/pion/dtls/v3 v3.0.2/go.mod h1:dfIXcFkKoujDQ+jtd8M6RgqKK3DuaUilm3YatAbGp5k= +github.com/pion/ice/v4 v4.0.1 h1:2d3tPoTR90F3TcGYeXUwucGlXI3hds96cwv4kjZmb9s= +github.com/pion/ice/v4 v4.0.1/go.mod h1:2dpakjpd7+74L5j3TAe6gvkbI5UIzOgAnkimm9SuHvA= +github.com/pion/interceptor v0.1.30 h1:au5rlVHsgmxNi+v/mjOPazbW1SHzfx7/hYOEYQnUcxA= +github.com/pion/interceptor v0.1.30/go.mod h1:RQuKT5HTdkP2Fi0cuOS5G5WNymTjzXaGF75J4k7z2nc= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= +github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= +github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw= +github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/srtp/v3 v3.0.3 h1:tRtEOpmR8NtsB/KndlKXFOj/AIIs6aPrCq4TlAatC4M= +github.com/pion/srtp/v3 v3.0.3/go.mod h1:Bp9ztzPCoE0ETca/R+bTVTO5kBgaQMiQkTmZWwazDTc= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/webrtc/v4 v4.0.0-beta.29.0.20240826201411-3147b45f9db5 h1:2Amcu+bkbGtMMHioFoUCApjbdo5DFjRjpCTQp4sbr9o= +github.com/pion/webrtc/v4 v4.0.0-beta.29.0.20240826201411-3147b45f9db5/go.mod h1:LF4fxCsaZ5gvAlI7k75UgJY0vvtuSWqqLzUglnTARtU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sandertv/go-raknet v1.14.0 h1:2vtO1m1DFLFszeCcV7mVZfVgkDcAbSxcjM2BlrVrEGs= -github.com/sandertv/go-raknet v1.14.0/go.mod h1:/yysjwfCXm2+2OY8mBazLzcxJ3irnylKCyG3FLgUPVU= github.com/sandertv/go-raknet v1.14.1 h1:V2Gslo+0x4jfj+p0PM48mWxmMbYkxSlgeKy//y3ZrzI= github.com/sandertv/go-raknet v1.14.1/go.mod h1:/yysjwfCXm2+2OY8mBazLzcxJ3irnylKCyG3FLgUPVU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.17.0 h1:nTRVVdajgB8zCMZVsViyzhnMKPwYeroEERRC64JuLco= golang.org/x/image v0.17.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= @@ -42,8 +89,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -57,6 +104,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= @@ -68,13 +117,14 @@ 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/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/minecraft/auth/xal/token_source.go b/minecraft/auth/xal/token_source.go new file mode 100644 index 00000000..d0759d4b --- /dev/null +++ b/minecraft/auth/xal/token_source.go @@ -0,0 +1,45 @@ +package xal + +import ( + "context" + "fmt" + "github.com/df-mc/go-xsapi" + "github.com/sandertv/gophertunnel/minecraft/auth" + "golang.org/x/oauth2" + "sync" +) + +func RefreshTokenSource(underlying oauth2.TokenSource, relyingParty string) xsapi.TokenSource { + return &refreshTokenSource{ + underlying: underlying, + + relyingParty: relyingParty, + } +} + +type refreshTokenSource struct { + underlying oauth2.TokenSource + + relyingParty string + + t *oauth2.Token + x *auth.XBLToken + mu sync.Mutex +} + +func (r *refreshTokenSource) Token() (_ xsapi.Token, err error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.t == nil || !r.t.Valid() || r.x == nil { + r.t, err = r.underlying.Token() + if err != nil { + return nil, fmt.Errorf("request underlying token: %w", err) + } + r.x, err = auth.RequestXBLToken(context.Background(), r.t, r.relyingParty) + if err != nil { + return nil, fmt.Errorf("request xbox live token: %w", err) + } + } + return r.x, nil +} diff --git a/minecraft/auth/xbox.go b/minecraft/auth/xbox.go index 157de725..0339f091 100644 --- a/minecraft/auth/xbox.go +++ b/minecraft/auth/xbox.go @@ -12,6 +12,7 @@ import ( "encoding/binary" "encoding/json" "fmt" + "github.com/df-mc/go-xsapi" "net/http" "time" @@ -31,12 +32,35 @@ type XBLToken struct { } Token string } + + // key is the private key used as 'ProofKey' for authentication. + // It is used for signing requests in [XBLToken.SetAuthHeader]. + key *ecdsa.PrivateKey } -// SetAuthHeader returns a string that may be used for the 'Authorization' header used for Minecraft +// String returns a string that may be used for the 'Authorization' header used for Minecraft // related endpoints that need an XBOX Live authenticated caller. +func (t XBLToken) String() string { + return fmt.Sprintf("XBL3.0 x=%s;%s", t.AuthorizationToken.DisplayClaims.UserInfo[0].UserHash, t.AuthorizationToken.Token) +} + +// DisplayClaims returns a [xsapi.DisplayClaims] from the token. It can be used by the XSAPI +// package to include display claims in requests that require them. +func (t XBLToken) DisplayClaims() xsapi.DisplayClaims { + return t.AuthorizationToken.DisplayClaims.UserInfo[0] +} + +// SetAuthHeader sets an 'Authorization' header to the request using [XBLToken.String]. It also +// signs the request with a 'Signature' header using the private key if [http.Request.Body] implements +// the Bytes() method to return its bytes to sign (typically [bytes.Buffer] or similar). func (t XBLToken) SetAuthHeader(r *http.Request) { - r.Header.Set("Authorization", fmt.Sprintf("XBL3.0 x=%v;%v", t.AuthorizationToken.DisplayClaims.UserInfo[0].UserHash, t.AuthorizationToken.Token)) + r.Header.Set("Authorization", t.String()) + + if b, ok := r.Body.(interface { + Bytes() []byte + }); ok { + sign(r, b.Bytes(), t.key) + } } // RequestXBLToken requests an XBOX Live auth token using the passed Live token pair. @@ -100,7 +124,7 @@ func obtainXBLToken(ctx context.Context, c *http.Client, key *ecdsa.PrivateKey, } return nil, fmt.Errorf("POST %v: %v", "https://sisu.xboxlive.com/authorize", resp.Status) } - info := new(XBLToken) + info := &XBLToken{key: key} return info, json.NewDecoder(resp.Body).Decode(info) } @@ -163,7 +187,7 @@ func sign(request *http.Request, body []byte, key *ecdsa.PrivateKey) { hash.Write(buf.Bytes()) // HTTP method, generally POST + 0 byte. - hash.Write([]byte("POST")) + hash.Write([]byte(request.Method)) hash.Write([]byte{0}) // Request uri path + raw query + 0 byte. hash.Write([]byte(request.URL.Path + request.URL.RawQuery)) @@ -220,4 +244,3 @@ func parseXboxErrorCode(code string) string { return fmt.Sprintf("unknown error code: %v", code) } } - diff --git a/minecraft/conn.go b/minecraft/conn.go index 58c21c96..1db5e921 100644 --- a/minecraft/conn.go +++ b/minecraft/conn.go @@ -79,7 +79,8 @@ type Conn struct { privateKey *ecdsa.PrivateKey // salt is a 16 byte long randomly generated byte slice which is only used if the Conn is a server sided // connection. It is otherwise left unused. - salt []byte + salt []byte + disableEncryption bool // packets is a channel of byte slices containing serialised packets that are coming in from the other // side of the connection. @@ -148,10 +149,10 @@ type Conn struct { // Minecraft packets to that net.Conn. // newConn accepts a private key which will be used to identify the connection. If a nil key is passed, the // key is generated. -func newConn(netConn net.Conn, key *ecdsa.PrivateKey, log *log.Logger, proto Protocol, flushRate time.Duration, limits bool) *Conn { +func newConn(netConn net.Conn, key *ecdsa.PrivateKey, log *log.Logger, proto Protocol, flushRate time.Duration, limits bool, batchHeader []byte) *Conn { conn := &Conn{ - enc: packet.NewEncoder(netConn), - dec: packet.NewDecoder(netConn), + enc: packet.NewEncoder(netConn, batchHeader), + dec: packet.NewDecoder(netConn, batchHeader), salt: make([]byte, 16), packets: make(chan *packetData, 8), additional: make(chan packet.Packet, 16), @@ -820,15 +821,17 @@ func (conn *Conn) handleServerToClientHandshake(pk *packet.ServerToClientHandsha return fmt.Errorf("decode ServerToClientHandshake salt: %w", err) } - x, _ := pub.Curve.ScalarMult(pub.X, pub.Y, conn.privateKey.D.Bytes()) - // Make sure to pad the shared secret up to 96 bytes. - sharedSecret := append(bytes.Repeat([]byte{0}, 48-len(x.Bytes())), x.Bytes()...) + if !conn.disableEncryption { + x, _ := pub.Curve.ScalarMult(pub.X, pub.Y, conn.privateKey.D.Bytes()) + // Make sure to pad the shared secret up to 96 bytes. + sharedSecret := append(bytes.Repeat([]byte{0}, 48-len(x.Bytes())), x.Bytes()...) - keyBytes := sha256.Sum256(append(salt, sharedSecret...)) + keyBytes := sha256.Sum256(append(salt, sharedSecret...)) - // Finally we enable encryption for the enc and dec using the secret pubKey bytes we produced. - conn.enc.EnableEncryption(keyBytes) - conn.dec.EnableEncryption(keyBytes) + // Finally we enable encryption for the enc and dec using the secret pubKey bytes we produced. + conn.enc.EnableEncryption(keyBytes) + conn.dec.EnableEncryption(keyBytes) + } // We write a ClientToServerHandshake packet (which has no payload) as a response. _ = conn.WritePacket(&packet.ClientToServerHandshake{}) @@ -1029,6 +1032,7 @@ func (conn *Conn) startGame() { CommandsEnabled: true, WorldName: data.WorldName, LANBroadcastEnabled: true, + XBLBroadcastMode: data.GamePublishSetting, PlayerMovementSettings: data.PlayerMovementSettings, WorldGameMode: data.WorldGameMode, Hardcore: data.Hardcore, @@ -1381,16 +1385,18 @@ func (conn *Conn) enableEncryption(clientPublicKey *ecdsa.PublicKey) error { // Flush immediately as we'll enable encryption after this. _ = conn.Flush() - // We first compute the shared secret. - x, _ := clientPublicKey.Curve.ScalarMult(clientPublicKey.X, clientPublicKey.Y, conn.privateKey.D.Bytes()) + if !conn.disableEncryption { + // We first compute the shared secret. + x, _ := clientPublicKey.Curve.ScalarMult(clientPublicKey.X, clientPublicKey.Y, conn.privateKey.D.Bytes()) - sharedSecret := append(bytes.Repeat([]byte{0}, 48-len(x.Bytes())), x.Bytes()...) + sharedSecret := append(bytes.Repeat([]byte{0}, 48-len(x.Bytes())), x.Bytes()...) - keyBytes := sha256.Sum256(append(conn.salt, sharedSecret...)) + keyBytes := sha256.Sum256(append(conn.salt, sharedSecret...)) - // Finally we enable encryption for the encoder and decoder using the secret key bytes we produced. - conn.enc.EnableEncryption(keyBytes) - conn.dec.EnableEncryption(keyBytes) + // Finally we enable encryption for the encoder and decoder using the secret key bytes we produced. + conn.enc.EnableEncryption(keyBytes) + conn.dec.EnableEncryption(keyBytes) + } return nil } diff --git a/minecraft/dial.go b/minecraft/dial.go index 854c7aff..e080d2ec 100644 --- a/minecraft/dial.go +++ b/minecraft/dial.go @@ -184,7 +184,7 @@ func (d Dialer) DialContext(ctx context.Context, network, address string) (conn return nil, err } - conn = newConn(netConn, key, d.ErrorLog, d.Protocol, d.FlushRate, false) + conn = newConn(netConn, key, d.ErrorLog, d.Protocol, d.FlushRate, false, n.BatchHeader()) conn.pool = conn.proto.Packets(false) conn.identityData = d.IdentityData conn.clientData = d.ClientData @@ -194,6 +194,8 @@ func (d Dialer) DialContext(ctx context.Context, network, address string) (conn conn.disconnectOnInvalidPacket = d.DisconnectOnInvalidPackets conn.disconnectOnUnknownPacket = d.DisconnectOnUnknownPackets + conn.disableEncryption = n.DisableEncryption() + defaultIdentityData(&conn.identityData) defaultClientData(address, conn.identityData.DisplayName, &conn.clientData) diff --git a/minecraft/franchise/discovery.go b/minecraft/franchise/discovery.go new file mode 100644 index 00000000..87c00534 --- /dev/null +++ b/minecraft/franchise/discovery.go @@ -0,0 +1,105 @@ +package franchise + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal" + "net/http" + "net/url" +) + +// Discover obtains a Discovery for the specific version, which includes environments for various franchise services. +// It sends a GET request using [http.DefaultClient]. The version is typically [protocol.CurrentVersion] to ensure +// compatibility with the current version of the protocol package. +func Discover(build string) (*Discovery, error) { + req, err := http.NewRequest(http.MethodGet, discoveryURL.JoinPath(build).String(), nil) + if err != nil { + return nil, fmt.Errorf("make request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", internal.UserAgent) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) + } + var result internal.Result[*Discovery] + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response body: %w", err) + } + if result.Data == nil { + return nil, errors.New("minecraft/franchise: Discover: result.Data is nil") + } + return result.Data, nil +} + +// Discovery provides access to environments for various franchise services based on the game +// version. It can be obtained from Discover using a specific game version. +// +// Example usage: +// +// discovery, err := franchise.Discover(protocol.CurrentVersion) +// if err != nil { +// log.Fatalf("Error obtaining discovery: %s", err) +// } +// +// // Look up and decode an environment for authorization. +// auth := new(franchise.AuthorizationEnvironment) +// if err := discovery.Environment(auth, franchise.EnvironmentTypeProduction); err != nil { +// log.Fatalf("Error reading environment for %q: %s", a.EnvironmentName(), err) +// } +// +// // Use discovery and auth for further use. +type Discovery struct { + // ServiceEnvironments is a map where each key represents a service name. Each value is another map where keys are environment + // types and values are environment-specific data represented as [json.RawMessage]. [Discovery.Environment] can be used to look + // up and decode an Environment by its name and type. + ServiceEnvironments map[string]map[string]json.RawMessage `json:"serviceEnvironments"` + + // SupportedEnvironments is a map where each key is the version of the game and + // each value is a slice of supported environments types for that version. + SupportedEnvironments map[string][]string `json:"supportedEnvironments"` +} + +// Environment looks up for a value in [Discovery.ServiceEnvironments] with the name of the Environment and the environment type. +// If a value is found, which is a data represented in [json.RawMessage], it then decodes the data into the Environment. An error +// may be returned if the value cannot be found or during decoding the JSON data into the Environment. +func (d *Discovery) Environment(env Environment, typ string) error { + e, ok := d.ServiceEnvironments[env.EnvironmentName()] + if !ok { + return errors.New("minecraft/franchise: environment not found") + } + data, ok := e[typ] + if !ok { + return errors.New("minecraft/franchise: environment with type not found") + } + if err := json.Unmarshal(data, &env); err != nil { + return fmt.Errorf("decode environment: %w", err) + } + return nil +} + +// Environment represents an environment for Discovery. +type Environment interface { + // EnvironmentName returns the name of the environment. + EnvironmentName() string +} + +const ( + EnvironmentTypeProduction = "prod" + EnvironmentTypeDevelopment = "dev" + EnvironmentTypeStaging = "stage" +) + +// discoveryURL is the base URL to make a GET request for obtaining a Discovery +// from Discover with a specific game version. +var discoveryURL = &url.URL{ + Scheme: "https", + Host: "client.discovery.minecraft-services.net", + Path: "/api/v1.0/discovery/MinecraftPE/builds/", +} diff --git a/minecraft/franchise/discovery_test.go b/minecraft/franchise/discovery_test.go new file mode 100644 index 00000000..bbcd70bc --- /dev/null +++ b/minecraft/franchise/discovery_test.go @@ -0,0 +1,20 @@ +package franchise + +import ( + "github.com/sandertv/gophertunnel/minecraft/protocol" + "testing" +) + +func TestDiscover(t *testing.T) { + d, err := Discover(protocol.CurrentVersion) + if err != nil { + t.Fatal(err) + } + t.Logf("%#v", d) + + a := new(AuthorizationEnvironment) + if err := d.Environment(a, EnvironmentTypeProduction); err != nil { + t.Fatal(err) + } + t.Logf("%#v", a) +} diff --git a/minecraft/franchise/internal/attr.go b/minecraft/franchise/internal/attr.go new file mode 100644 index 00000000..0a1d146f --- /dev/null +++ b/minecraft/franchise/internal/attr.go @@ -0,0 +1,7 @@ +package internal + +import "log/slog" + +const errorKey = "error" + +func ErrAttr(err error) slog.Attr { return slog.Any(errorKey, err) } diff --git a/minecraft/franchise/internal/result.go b/minecraft/franchise/internal/result.go new file mode 100644 index 00000000..f9f8ee7b --- /dev/null +++ b/minecraft/franchise/internal/result.go @@ -0,0 +1,5 @@ +package internal + +type Result[T any] struct { + Data T `json:"result"` +} diff --git a/minecraft/franchise/internal/test/.gitignore b/minecraft/franchise/internal/test/.gitignore new file mode 100644 index 00000000..38d7cba3 --- /dev/null +++ b/minecraft/franchise/internal/test/.gitignore @@ -0,0 +1 @@ +/auth.tok \ No newline at end of file diff --git a/minecraft/franchise/internal/test/token_source.go b/minecraft/franchise/internal/test/token_source.go new file mode 100644 index 00000000..7e6ab914 --- /dev/null +++ b/minecraft/franchise/internal/test/token_source.go @@ -0,0 +1,37 @@ +package test + +import ( + "encoding/json" + "fmt" + "golang.org/x/oauth2" + "os" +) + +func ReadToken(path string, src oauth2.TokenSource) (t *oauth2.Token, err error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + t, err = src.Token() + if err != nil { + return nil, fmt.Errorf("obtain token: %w", err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm) + if err != nil { + return nil, err + } + defer f.Close() + if err := json.NewEncoder(f).Encode(t); err != nil { + return nil, fmt.Errorf("encode: %w", err) + } + return t, nil + } else if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + if err := json.NewDecoder(f).Decode(&t); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return t, nil +} diff --git a/minecraft/franchise/internal/user_agent.go b/minecraft/franchise/internal/user_agent.go new file mode 100644 index 00000000..48ec74ff --- /dev/null +++ b/minecraft/franchise/internal/user_agent.go @@ -0,0 +1,3 @@ +package internal + +const UserAgent = "libhttpclient/1.0.0.0" diff --git a/minecraft/franchise/playfab.go b/minecraft/franchise/playfab.go new file mode 100644 index 00000000..077e4e5f --- /dev/null +++ b/minecraft/franchise/playfab.go @@ -0,0 +1,79 @@ +package franchise + +import ( + "errors" + "fmt" + "github.com/df-mc/go-playfab" + "github.com/df-mc/go-playfab/title" +) + +// PlayFabIdentityProvider implements IdentityProvider for PlayFab, a primary +// platform used for authentication and authorization with franchise services. +// +// It is implemented to integrate with PlayFab's authentication, providing methods for +// obtaining identity tokens specific to PlayFab. It leverages PlayFab's external identity +// platform to facilitate sign-in and authorization. +type PlayFabIdentityProvider struct { + // Environment represents the environment used for authorization with franchise services, including various fields + // such as the base URI for making requests. It is essential for setting up the authorization context and directing + // requests to the appropriate URL. + Environment *AuthorizationEnvironment + + // IdentityProvider is an implementation of [playfab.IdentityProvider] from an external platform that supports + // signing in to PlayFab with its own token, such as Xbox Live using [playfab.XBLIdentityProvider]. + IdentityProvider playfab.IdentityProvider + + // LoginConfig contains the base [playfab.LoginConfig] used to obtain a [playfab.Identity] from the + // IdentityProvider. If the [playfab.LoginConfig.PlayFabTitleID] field is left nil, it will be set + // automatically from [AuthorizationEnvironment.PlayFabTitleID]. It includes parameters for signing + // in to PlayFab, such as options for creating a new account if one does not already exist. + LoginConfig playfab.LoginConfig + + // DeviceConfig is an optional [DeviceConfig] to be set as [TokenConfig.Device]. If left nil, a default [DeviceConfig] + // will be set and used. It provides device-specific details that may influence the authorization for franchise services. + DeviceConfig *DeviceConfig + + // UserConfig is an optional [UserConfig] to be set as [TokenConfig.User]. If left nil, a default [UserConfig] + // will be set. It provides user-specific details required for authorization, such as language preferences. + // Note that the [UserConfig.Token] and [UserConfig.TokenType] fields will be overridden to use PlayFab as the + // identity provider. + UserConfig *UserConfig +} + +// TokenConfig signs in to PlayFab using [PlayFabIdentityProvider.IdentityProvider] to authenticate and authorize +// with franchise services. After a successful sign-in, the [playfab.Identity.SessionTicket] obtained from PlayFab +// will be set into [UserConfig.Token] with [UserConfig.TokenType] set to TokenTypePlayFab. +func (i PlayFabIdentityProvider) TokenConfig() (*TokenConfig, error) { + if i.Environment == nil { + return nil, errors.New("minecraft/franchise: PlayFabIdentityProvider: Environment is nil") + } + if i.IdentityProvider == nil { + return nil, errors.New("minecraft/franchise: PlayFabIdentityProvider: IdentityProvider is nil") + } + if i.DeviceConfig == nil { + i.DeviceConfig = defaultDeviceConfig(i.Environment) + } + if i.UserConfig == nil { + i.UserConfig = defaultUserConfig() + } + + config := i.LoginConfig + if config.Title == "" { + config.Title = title.Title(i.Environment.PlayFabTitleID) + } + identity, err := i.IdentityProvider.Login(config) + if err != nil { + return nil, fmt.Errorf("login: %w", err) + } + + user := *i.UserConfig + user.Token = identity.SessionTicket + user.TokenType = TokenTypePlayFab + + return &TokenConfig{ + Device: *i.DeviceConfig, + User: user, + + Environment: i.Environment, + }, nil +} diff --git a/minecraft/franchise/signaling/conn.go b/minecraft/franchise/signaling/conn.go new file mode 100644 index 00000000..dc7f61d9 --- /dev/null +++ b/minecraft/franchise/signaling/conn.go @@ -0,0 +1,200 @@ +package signaling + +import ( + "context" + "encoding/json" + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/df-mc/go-nethernet" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal" + "log/slog" + "net" + "strconv" + "sync" + "sync/atomic" + "time" +) + +// Conn implements a [nethernet.Signaling] over a WebSocket connection. +// +// A Conn may be established using the methods of Dialer with either +// a [franchise.IdentityProvider] and an [Environment] or an [oauth2.TokenSource] +// for authorization. +// +// A Conn can be utilized with [nethernet.ListenConfig.Listen] or [nethernet.Dialer.DialContext]. +type Conn struct { + conn *websocket.Conn + d Dialer + + credentials atomic.Pointer[nethernet.Credentials] + credentialsReceived chan struct{} + + once sync.Once + closed chan struct{} + + notifyCount uint32 + notifiers map[uint32]nethernet.Notifier + notifiersMu sync.Mutex +} + +// Signal sends a [nethernet.Signal] to a network. +func (c *Conn) Signal(signal *nethernet.Signal) error { + return c.write(Message{ + Type: MessageTypeSignal, + To: signal.NetworkID, + Data: signal.String(), + }) +} + +// Notify registers a [nethernet.Notifier] to receive notifications of signals and errors. It returns +// a function to stop receiving notifications on the [nethernet.Notifier]. +func (c *Conn) Notify(n nethernet.Notifier) (stop func()) { + c.notifiersMu.Lock() + i := c.notifyCount + c.notifiers[i] = n + c.notifyCount++ + c.notifiersMu.Unlock() + + return c.stopFunc(i, n) +} + +// stopFunc returns a function to be returned by [Conn.Notify], which stops receiving notifications +// on the Notifier by unregistering them on the Conn with notifying [nethernet.ErrSignalingStopped] +// as an error through [nethernet.Notifier.NotifyError]. +func (c *Conn) stopFunc(i uint32, n nethernet.Notifier) func() { + return func() { + n.NotifyError(nethernet.ErrSignalingStopped) + + c.notifiersMu.Lock() + delete(c.notifiers, i) + c.notifiersMu.Unlock() + } +} + +// Credentials blocks until [nethernet.Credentials] are received from the server or the [context.Context] +// is done. It returns a [nethernet.Credentials] or an error if the Conn is closed or the [context.Context] +// is canceled or exceeded a deadline. +func (c *Conn) Credentials(ctx context.Context) (*nethernet.Credentials, error) { + select { + case <-c.closed: + return nil, net.ErrClosed + case <-ctx.Done(): + return nil, ctx.Err() + default: + return c.credentials.Load(), nil + } +} + +// NetworkID returns the network ID of the Conn. It may be specified from [Dialer.NetworkID], otherwise a random +// value will be automatically set from [rand.Uint64] in set up during [Dialer.DialContext]. It is utilized by +// [nethernet.Listener] and [nethernet.Dialer] to obtain its local network ID to listen. +func (c *Conn) NetworkID() uint64 { + return c.d.NetworkID +} + +// Close closes the Conn and unregisters any notifiers. It ensures that the Conn is closed only once. +// It unregisters all notifiers registered on the Conn with notifying [nethernet.ErrSignalingStopped]. +func (c *Conn) Close() (err error) { + c.once.Do(func() { + c.notifiersMu.Lock() + for _, n := range c.notifiers { + n.NotifyError(nethernet.ErrSignalingStopped) + } + clear(c.notifiers) + c.notifiersMu.Unlock() + + close(c.closed) + err = c.conn.Close(websocket.StatusNormalClosure, "") + }) + return err +} + +// read continuously reads messages from the WebSocket connection and handles them. +// It also sends a Message of MessageTypePing at 15 seconds intervals to keep the +// Conn alive. It goes as a background goroutine of the Conn and handles different +// types of messages: credentials, signals, and errors. It closes the Conn if it +// encounters an error or when the Conn is closed. +func (c *Conn) read() { + go func() { + ticker := time.NewTicker(time.Second * 15) + defer ticker.Stop() + + for { + select { + case <-c.closed: + return + case <-ticker.C: + if err := c.write(Message{ + Type: MessageTypePing, + }); err != nil { + c.d.Log.Error("error writing ping", internal.ErrAttr(err)) + return + } + } + } + }() + defer c.Close() + + for { + var message Message + if err := wsjson.Read(context.Background(), c.conn, &message); err != nil { + return + } + switch message.Type { + case MessageTypeCredentials: + if message.From != "Server" { + c.d.Log.Warn("received credentials from non-Server", slog.Any("message", message)) + continue + } + var credentials nethernet.Credentials + if err := json.Unmarshal([]byte(message.Data), &credentials); err != nil { + c.d.Log.Error("error decoding credentials", internal.ErrAttr(err)) + continue + } + notifyCredentials := c.credentials.Load() == nil + c.credentials.Store(&credentials) + if notifyCredentials { + close(c.credentialsReceived) + } + case MessageTypeSignal: + signal := &nethernet.Signal{} + if err := signal.UnmarshalText([]byte(message.Data)); err != nil { + c.d.Log.Error("error decoding signal", internal.ErrAttr(err)) + continue + } + var err error + signal.NetworkID, err = strconv.ParseUint(message.From, 10, 64) + if err != nil { + c.d.Log.Error("error parsing network ID of signal", internal.ErrAttr(err)) + continue + } + + c.notifiersMu.Lock() + for _, n := range c.notifiers { + n.NotifySignal(signal) + } + c.notifiersMu.Unlock() + case MessageTypeError: + var err Error + if err2 := json.Unmarshal([]byte(message.Data), &err); err2 != nil { + c.d.Log.Error("error decoding error", internal.ErrAttr(err2)) + continue + } + + c.notifiersMu.Lock() + for _, n := range c.notifiers { + n.NotifyError(&err) + } + c.notifiersMu.Unlock() + default: + c.d.Log.Warn("received message for unknown type", slog.Any("message", message)) + } + } +} + +// write encodes the given Message and sends it over the WebSocket connection. It uses a background context +// to avoid issues with context cancellation affecting the connection. An error may be returned if the message +// could not be sent. +func (c *Conn) write(message Message) error { + return wsjson.Write(context.Background(), c.conn, message) +} diff --git a/minecraft/franchise/signaling/dial.go b/minecraft/franchise/signaling/dial.go new file mode 100644 index 00000000..7f5d5ec9 --- /dev/null +++ b/minecraft/franchise/signaling/dial.go @@ -0,0 +1,121 @@ +package signaling + +import ( + "context" + "fmt" + "github.com/coder/websocket" + "github.com/df-mc/go-nethernet" + "github.com/df-mc/go-playfab" + "github.com/sandertv/gophertunnel/minecraft/auth/xal" + "github.com/sandertv/gophertunnel/minecraft/franchise" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "golang.org/x/oauth2" + "log/slog" + "math/rand" + "net/http" + "net/url" + "strconv" +) + +// Dialer provides methods and fields to establish a Conn to a signaling service. +// It allows specifying options for the connection and handles various authentication +// and environment configuration. +type Dialer struct { + // Options specifies the options for dialing the signaling service over + // a WebSocket connection. If nil, a new *websocket.DialOptions will be + // created. Note that the [websocket.DialOptions.HTTPClient] and its Transport + // will be overridden with a [franchise.Transport] for authorization. + Options *websocket.DialOptions + + // NetworkID specifies a unique ID for the network. If zero, a random value will + // be automatically set from [rand.Uint64]. It is included in the URI for establishing + // a WebSocket connection. + NetworkID uint64 + + // Log is used to logging messages at various levels. If nil, the default + // [slog.Logger] will be set from [slog.Default]. + Log *slog.Logger +} + +// DialContext establishes a Conn to the signaling service using the [oauth2.TokenSource] for +// authentication and authorization with franchise services. It obtains the necessary [franchise.Discovery] +// and [Environment] needed, then calls DialWithIdentityAndEnvironment internally. It is the +// method that is typically used when no configuration of identity and environment is required. +func (d Dialer) DialContext(ctx context.Context, src oauth2.TokenSource) (*Conn, error) { + discovery, err := franchise.Discover(protocol.CurrentVersion) + if err != nil { + return nil, fmt.Errorf("obtain discovery: %w", err) + } + a := new(franchise.AuthorizationEnvironment) + if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { + return nil, fmt.Errorf("obtain environment for %s: %w", a.EnvironmentName(), err) + } + s := new(Environment) + if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { + return nil, fmt.Errorf("obtain environment for %s: %w", s.EnvironmentName(), err) + } + + return d.DialWithIdentityAndEnvironment(ctx, franchise.PlayFabIdentityProvider{ + Environment: a, + IdentityProvider: playfab.XBLIdentityProvider{ + TokenSource: xal.RefreshTokenSource(src, playfab.RelyingParty), + }, + }, s) +} + +// DialWithIdentityAndEnvironment establishes a Conn to the signaling service using the [franchise.IdentityProvider] +// for authorization and the [Environment] for creating the URI of an internal WebSocket connection. It appends 'ws/v1.0/signaling' +// with the NetworkID to the service URI from the Environment. It sets up necessary options and logging if not provided, and +// dials a [websocket.Conn] using [websocket.Dial]. The [context.Context] may be used to cancel the connection if necessary as +// soon as possible. +func (d Dialer) DialWithIdentityAndEnvironment(ctx context.Context, i franchise.IdentityProvider, env *Environment) (*Conn, error) { + if d.Options == nil { + d.Options = &websocket.DialOptions{} + } + if d.Options.HTTPClient == nil { + d.Options.HTTPClient = &http.Client{} + } + if d.NetworkID == 0 { + d.NetworkID = rand.Uint64() + } + if d.Log == nil { + d.Log = slog.Default() + } + + var ( + hasTransport bool + base = d.Options.HTTPClient.Transport + ) + if base != nil { + _, hasTransport = base.(*franchise.Transport) + } + if !hasTransport { + d.Options.HTTPClient.Transport = &franchise.Transport{ + IdentityProvider: i, + Base: base, + } + } + + u, err := url.Parse(env.ServiceURI) + if err != nil { + return nil, fmt.Errorf("parse service URI: %w", err) + } + + c, _, err := websocket.Dial(ctx, u.JoinPath("/ws/v1.0/signaling", strconv.FormatUint(d.NetworkID, 10)).String(), d.Options) + if err != nil { + return nil, err + } + + conn := &Conn{ + conn: c, + d: d, + + credentialsReceived: make(chan struct{}), + + closed: make(chan struct{}), + + notifiers: make(map[uint32]nethernet.Notifier), + } + go conn.read() + return conn, nil +} diff --git a/minecraft/franchise/signaling/dial_test.go b/minecraft/franchise/signaling/dial_test.go new file mode 100644 index 00000000..f3b74944 --- /dev/null +++ b/minecraft/franchise/signaling/dial_test.go @@ -0,0 +1,92 @@ +package signaling + +import ( + "context" + "encoding/json" + "fmt" + "github.com/df-mc/go-nethernet" + "github.com/sandertv/gophertunnel/minecraft/auth" + "golang.org/x/oauth2" + "math/rand" + "os" + "testing" + "time" +) + +// TestDial demonstrates dialing a Conn using [Dialer.DialContext] and ensures that the notification is working correctly. +func TestDial(t *testing.T) { + tok, err := readToken("../internal/test/auth.tok", auth.TokenSource) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + src := auth.RefreshTokenSource(tok) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + + var d Dialer + conn, err := d.DialContext(ctx, src) + if err != nil { + t.Fatalf("error dialing: %s", err) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Fatalf("error closing connection: %s", err) + } + }) + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + + conn.Notify(ctx, testNotifier{t}) + if err := conn.Signal(&nethernet.Signal{ + Type: nethernet.SignalTypeOffer, + ConnectionID: rand.Uint64(), + NetworkID: 100, // Try signaling an offer to invalid network, We hopefully notify an Error. + }); err != nil { + t.Fatalf("error signaling offer: %s", err) + } + + <-ctx.Done() +} + +type testNotifier struct { + testing.TB +} + +func (n testNotifier) NotifySignal(signal *nethernet.Signal) { + n.Logf("NotifySignal(%s)", signal) +} + +func (n testNotifier) NotifyError(err error) { + n.Logf("NotifyError(%s)", err) +} + +func readToken(path string, src oauth2.TokenSource) (t *oauth2.Token, err error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + t, err = src.Token() + if err != nil { + return nil, fmt.Errorf("obtain token: %w", err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm) + if err != nil { + return nil, err + } + defer f.Close() + if err := json.NewEncoder(f).Encode(t); err != nil { + return nil, fmt.Errorf("encode: %w", err) + } + return t, nil + } else if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + if err := json.NewDecoder(f).Decode(&t); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return t, nil +} diff --git a/minecraft/franchise/signaling/environment.go b/minecraft/franchise/signaling/environment.go new file mode 100644 index 00000000..eb65dda9 --- /dev/null +++ b/minecraft/franchise/signaling/environment.go @@ -0,0 +1,36 @@ +package signaling + +// Environment represents an environment configuration for establishing a Conn with signaling services in Dialer. +// It contains fields necessary for connecting to the appropriate URL, and can be obtained easily from a [franchise.Discovery] +// (which can be also obtained from [franchise.Discover] with a specific version) using [franchise.Discovery.Environment] with +// an *Environment. +// +// Example usage: +// +// discovery, err := franchise.Discover(protocol.CurrentVersion) +// if err != nil { +// panic(err) +// } +// +// environment := new(Environment) +// if err := discovery.Environment(environment); err != nil { +// panic(err) +// } +// +// // Use discovery and environment for further uses. +type Environment struct { + // ServiceURI is the URI of the service where connections should be directed. + // It is the base URL used for dialing a WebSocket connection of a Conn. + ServiceURI string `json:"serviceUri"` + // StunURI is the URI of a STUN server available to connect. It seems unused as it is always + // provided in a [nethernet.Credentials] received from a Conn. + StunURI string `json:"stunUri"` + // TurnURI is the URI of a TURN server available to connect. It seems unused as it is always + // provided in a credentials received from a Conn. + TurnURI string `json:"turnUri"` +} + +// EnvironmentName implements a [franchise.Environment] so that may be obtained using [franchise.Discovery.Environment]. +func (env *Environment) EnvironmentName() string { + return "signaling" +} diff --git a/minecraft/franchise/signaling/message.go b/minecraft/franchise/signaling/message.go new file mode 100644 index 00000000..eedc3372 --- /dev/null +++ b/minecraft/franchise/signaling/message.go @@ -0,0 +1,84 @@ +package signaling + +import ( + "strconv" + "strings" +) + +// Message represents a message sent or received over a Conn. +// It encapsulates the details of the message including its type, sender, +// recipient, and the actual data contained within the message. +type Message struct { + // Type indicates the type of the Message. It corresponds to one of the + // constants defined below. + Type int `json:"Type"` + + // From indicates the sender of the message. It can be either a fixed string + // 'Server' or the network ID from which the message was received. It is included + // only received from the server. + From string `json:"From,omitempty"` + + // To specifies the recipient of the message, which is the ID of remote network + // to which the message is being sent. It is included only sent from client. + To uint64 `json:"To,omitempty"` + + // Data contains the actual payload of the message, which holds the data being transmitted. + // It is optional and may be omitted if no data is being sent. + Data string `json:"Message,omitempty"` +} + +// Error represents the data included in a Message of MessageTypeError received from the server. +// +// It is notified by Conn to its registered nethernet.Notifier to negotiator to notify an error +// has occurred while sending a signal. +type Error struct { + // Code is the code of the Error. It indicates the type of error and is may be one + // of the constants below. + Code int `json:"Code"` + // Message represents the Error in a string. + Message string `json:"Message"` +} + +// Error returns a string representing code and message of the Error. It implements an error. +func (err *Error) Error() string { + b := &strings.Builder{} + b.WriteString("franchise/signaling: code ") + b.WriteString(strconv.Itoa(err.Code)) + if err.Message != "" { + b.WriteByte(':') + b.WriteByte(' ') + b.WriteString(strconv.Quote(err.Message)) + } + return b.String() +} + +const ( + // ErrorCodePlayerNotFound indicates that the remote network ID specified + // in [Message.To] is not found. Meaning that a [nethernet.Signal] signaled + // to the server is no longer valid. + ErrorCodePlayerNotFound = 1 +) + +// MessageTypeError is sent by server to notify that an error occurred +// in Conn. Messages of MessageTypeError usually contain a JSON string +// represented by Error. +const MessageTypeError = 0 + +const ( + // MessageTypePing is sent by client to ping the server at some interval. + // Messages of MessageTypePing usually does not contain data. + MessageTypePing = iota // RequestType::Ping + + // MessageTypeSignal is sent by both server and client to notify or send + // a signal to a remote network. Messages of MessageTypeSignal usually + // contain a data represented in [nethernet.Signal]. A Conn allows sending + // a [nethernet.Signal] to a network using [Conn.Signal]. + MessageTypeSignal // RequestType::WebRTC + + // MessageTypeCredentials is sent by server to update credentials used for + // gathering ICE candidates using STUN or TURN server specified on the fields. + // Messages of MessageTypeCredentials usually contain a JSON string represented + // in [nethernet.Credentials]. When received from the server, [Message.From] must + // be 'Server' to ensure correct credentials are used. + MessageTypeCredentials // RequestType::Credentials +) diff --git a/minecraft/franchise/token.go b/minecraft/franchise/token.go new file mode 100644 index 00000000..2fd5d091 --- /dev/null +++ b/minecraft/franchise/token.go @@ -0,0 +1,268 @@ +package franchise + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "golang.org/x/text/language" + "net/http" + "net/url" + "strconv" + "time" +) + +// Token represents an authorization token used for franchise services. +// +// A Token encapsulates the fields required for authenticating and authorizing requests. +// +// Token can be obtained through [TokenConfig.Token] or an implementation of IdentityProvider. +// As each Token has expiration, it is recommended to use an IdentityProvider to refresh the token +// subsequently when it becomes invalid. +type Token struct { + // AuthorizationHeader is the JWT string that is used to authorize and authenticate requests. + // It should be included in the 'Authorization' header of requests for services that requires + // authentication. It can be set to a [http.Request] using the [Token.SetAuthHeader] method. + AuthorizationHeader string `json:"authorizationHeader"` + + // ValidUntil specifies the expiration time of the Token. Once the current time surpasses the expiration + // time, the Token is no longer valid, and it needs to be refreshed to maintain access. + ValidUntil time.Time `json:"validUntil"` + + // Treatments is a list of treatments that have been applied to the Token. Treatments may be specific + // to certain services and can be overridden using [DeviceConfig.TreatmentOverrides]. These treatments + // could affect how the Token is validated or used in various services. + Treatments []string `json:"treatments"` + + // Configurations is a map of configurations related to different services. It is unknown what it is used for. + Configurations map[string]Configuration `json:"configurations"` + + // TreatmentContext provides additional context for the treatments applied to the Token. It is unknown how it is used. + TreatmentContext string `json:"treatmentContext"` +} + +// SetAuthHeader sets an 'Authorization' header to the [http.Request] using the [Token.AuthorizationHeader]. +func (t *Token) SetAuthHeader(req *http.Request) { + req.Header.Set("Authorization", t.AuthorizationHeader) +} + +const ( + ConfigurationMinecraft = "minecraft" + ConfigurationValidation = "validation" +) + +// Token obtains a Token by making a POST request to the service URI specified in the Environment of TokenConfig. +// +// It creates the request URL by appending '/api/v1.0/session/start' to the base service URI defined in the +// [TokenConfig.Environment]. It then encodes the TokenConfig as a JSON string and sends it in the body of the request. +func (conf TokenConfig) Token() (*Token, error) { + if conf.Environment == nil { + return nil, errors.New("minecraft/franchise: TokenConfig: Environment is nil") + } + u, err := url.Parse(conf.Environment.ServiceURI) + if err != nil { + return nil, fmt.Errorf("parse service URI: %w", err) + } + u = u.JoinPath("/api/v1.0/session/start") + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(conf); err != nil { + return nil, fmt.Errorf("encode request body: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, u.String(), buf) + if err != nil { + return nil, fmt.Errorf("make request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", internal.UserAgent) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) + } + + var result internal.Result[*Token] + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response body: %w", err) + } + if result.Data == nil { + return nil, errors.New("minecraft/franchise: TokenConfig: result.Data is nil") + } + return result.Data, nil +} + +// Configuration represents a configuration set for the ID by service. +type Configuration struct { + ID string `json:"id"` + Parameters map[string]string `json:"parameters"` +} + +// AuthorizationEnvironment represents an environment configuration used for authorization purposes. +// It holds essential fields required for accessing with authorization services and can be retrieved +// from a Discovery using the [Discovery.Environment] method. +type AuthorizationEnvironment struct { + // ServiceURI is the URI of the service where requests related to authorization should be directed. + // It is the base URL used for making authorization requests. + ServiceURI string `json:"serviceUri"` + Issuer string `json:"issuer"` + // PlayFabTitleID is the title ID specific for PlayFab. + PlayFabTitleID string `json:"playFabTitleId"` + EduPlayFabTitleID string `json:"eduPlayFabTitleId"` +} + +func (*AuthorizationEnvironment) EnvironmentName() string { return "auth" } + +// IdentityProvider implements a TokenConfig method, which provides a TokenConfig used for authorization. +// +// IdentityProvider is implemented by various platforms that support identity-based authentication and +// authorization. Platforms implementing IdentityProvider can provide a TokenConfig, which contains the +// necessary configuration to obtain Tokens required for accessing with franchise services. +type IdentityProvider interface { + // TokenConfig should return a TokenConfig that includes the necessary configuration for authorizing with + // franchise services. An error may be returned during authenticating with external platforms. + TokenConfig() (*TokenConfig, error) +} + +// TokenConfig defines the configuration required to obtain a Token through the [TokenConfig.Token] method for a +// specified source. It is essential to set the Environment field to ensure proper functionality. +type TokenConfig struct { + // Device holds configuration details related to the device for which the Token is being obtained. + // It can include device-specific settings that might be required by the authorization service. + Device DeviceConfig `json:"device,omitempty"` + + // User contains user identity details encapsulated in a UserConfig. It is used to specify an identity token + // authenticated with external platform, such as PlayFab. + User UserConfig `json:"user,omitempty"` + + // Environment specifies the environment in which the TokenConfig will be used. It contains the service URI and + // other environment-specific details necessary for creating the request URL to obtain a Token. Environment is + // crucial for the Token retrieval and is not included in the request body. + Environment *AuthorizationEnvironment `json:"-"` +} + +// defaultDeviceConfig returns a default DeviceConfig based on the AuthorizationEnvironment. +// It is called by the [TokenConfig.Token] method to set a default device configuration when +// the [TokenConfig.Device] field is not set. +func defaultDeviceConfig(env *AuthorizationEnvironment) *DeviceConfig { + return &DeviceConfig{ + ApplicationType: ApplicationTypeMinecraftPE, + Capabilities: []string{}, + GameVersion: protocol.CurrentVersion, + ID: uuid.New(), + Memory: strconv.FormatUint(16*(1<<30), 10), + Platform: PlatformWindows10, + PlayFabTitleID: env.PlayFabTitleID, + StorePlatform: StorePlatformUWPStore, + Type: DeviceTypeWindows10, + } +} + +// DeviceConfig holds the details for the device used in authorization. It contains several fields that defines the +// features and capabilities of the device, which are associated with the Token being obtained. +type DeviceConfig struct { + // ApplicationType indicates the type of the application associated with the device. It could be + // one of constants defined below and is typically 'MinecraftPE' for most cases. + ApplicationType string `json:"applicationType,omitempty"` + + // Capabilities is a list of features and functionalities supported by the device. Example might + // include graphics capabilities like 'RayTracing' or other hardware or software features. + Capabilities []string `json:"capabilities,omitempty"` + + // GameVersion indicates the version of the game running on the device. It is typically [protocol.CurrentVersion] + // to ensure compatibility with the current version of the protocol package. + GameVersion string `json:"gameVersion,omitempty"` + + // ID is a unique ID for the device, represented as a UUID. + ID uuid.UUID `json:"id,omitempty"` + + // Memory is the total amount of memory available on the device, represented as a string. + Memory string `json:"memory,omitempty"` + + // Platform denotes the platform on which the device operates. It could be one of constants defined below, such as 'Windows10'. + Platform string `json:"platform,omitempty"` + + // PlayFabTitleID is a unique ID for the PlayFab title associated with the device. It is used to reference a specific + // PlayFab title and is typically set from [AuthorizationEnvironment.PlayFabTitleID] to ensure proper association with + // correct title. + PlayFabTitleID string `json:"playFabTitleId,omitempty"` + + // StorePlatform represents the digital store platform where the application can be downloaded or purchased from. + // It could be one of constants defined below, such as 'UWPStore', that indicates the source of the application. + StorePlatform string `json:"storePlatform,omitempty" +` + // TreatmentOverrides specifies any custom treatments that should be applied to the Token. These treatments may affect + // how the Token is handled or used across various services available through Discovery. + TreatmentOverrides []string `json:"treatmentOverrides,omitempty"` + + // Type defines the general category or type of the device. It could be one of constants defined below, such as 'Windows10'. + Type string `json:"type,omitempty"` +} + +const ( + ApplicationTypeMinecraftPE = "MinecraftPE" +) + +const ( + // CapabilityRayTracing indicates that the device is capable for ray tracing. + CapabilityRayTracing = "RayTracing" +) + +const ( + PlatformWindows10 = "Windows10" +) + +const ( + StorePlatformUWPStore = "uwp.store" +) + +const ( + DeviceTypeWindows10 = "Windows10" +) + +func defaultUserConfig() *UserConfig { + base, _ := language.AmericanEnglish.Base() + region, _ := language.AmericanEnglish.Region() + + return &UserConfig{ + Language: language.AmericanEnglish, + LanguageCode: base.String(), + RegionCode: region.String(), + } +} + +// UserConfig represents the configuration details for a user whose Token is being obtained for authorization. +// It includes various fields related to the identity and language preferences of the user. +type UserConfig struct { + // Language is the [language.Tag] representing the language the user is currently using. + // It is typically set based on the game settings. + Language language.Tag `json:"language,omitempty"` + + // Language represents the base language derived from [language.Tag.Base]. + // It provides the primary language without any regional variations. + LanguageCode string `json:"languageCode,omitempty"` + + // RegionCode denotes the specific region associated with the language. + // It is typically derived from [language.Tag.Region] and provides regional context. + RegionCode string `json:"regionCode,omitempty"` + + // Token is the identity token used for authorization. It is a crucial field that holds the actual + // token required to authenticate the user. + Token string `json:"token,omitempty"` + + // TokenType specifies the type or provider of the identity represented by the Token. It indicates the + // source or type of authentication. It could be one of constants defined below, such as 'PlayFab' for + // common token type. + TokenType string `json:"tokenType,omitempty"` +} + +const ( + TokenTypePlayFab = "PlayFab" +) diff --git a/minecraft/franchise/token_test.go b/minecraft/franchise/token_test.go new file mode 100644 index 00000000..23a99dc5 --- /dev/null +++ b/minecraft/franchise/token_test.go @@ -0,0 +1,46 @@ +package franchise + +import ( + "github.com/df-mc/go-playfab" + "github.com/sandertv/gophertunnel/minecraft/auth" + "github.com/sandertv/gophertunnel/minecraft/auth/xal" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "testing" +) + +func TestToken(t *testing.T) { + discovery, err := Discover(protocol.CurrentVersion) + if err != nil { + t.Fatalf("error retrieving discovery: %s", err) + } + a := new(AuthorizationEnvironment) + if err := discovery.Environment(a, EnvironmentTypeProduction); err != nil { + t.Fatalf("error reading environment for authorization: %s", err) + } + + tok, err := test.ReadToken("internal/test/auth.tok", auth.TokenSource) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + src := auth.RefreshTokenSource(tok) + + prov := PlayFabIdentityProvider{ + Environment: a, + IdentityProvider: playfab.XBLIdentityProvider{ + TokenSource: xal.RefreshTokenSource(src, "http://playfab.xboxlive.com/"), + }, + } + + conf, err := prov.TokenConfig() + if err != nil { + t.Fatalf("error requesting token config: %s", err) + } + + token, err := conf.Token() + if err != nil { + t.Fatalf("error requesting token: %s", err) + } + + t.Logf("%#v", token) +} diff --git a/minecraft/franchise/transport.go b/minecraft/franchise/transport.go new file mode 100644 index 00000000..9dd42db4 --- /dev/null +++ b/minecraft/franchise/transport.go @@ -0,0 +1,62 @@ +package franchise + +import ( + "errors" + "fmt" + "net/http" +) + +type Transport struct { + IdentityProvider IdentityProvider + Base http.RoundTripper +} + +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + reqBodyClosed := false + if req.Body != nil { + defer func() { + if !reqBodyClosed { + _ = req.Body.Close() + } + }() + } + + if t.IdentityProvider == nil { + return nil, errors.New("minecraft/franchise: Transport: IdentityProvider is nil") + } + config, err := t.IdentityProvider.TokenConfig() + if err != nil { + return nil, fmt.Errorf("request token config: %w", err) + } + token, err := config.Token() + if err != nil { + return nil, fmt.Errorf("request token: %w", err) + } + + req2 := cloneRequest(req) + token.SetAuthHeader(req2) + + reqBodyClosed = true + return t.base().RoundTrip(req2) +} + +func (t *Transport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +// cloneRequest returns a clone of the provided *http.Request. +// The clone is a shallow copy of the struct and its Header map. +func cloneRequest(r *http.Request) *http.Request { + // shallow copy of the struct + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} diff --git a/minecraft/game_data.go b/minecraft/game_data.go index d05222ab..be124084 100644 --- a/minecraft/game_data.go +++ b/minecraft/game_data.go @@ -65,6 +65,9 @@ type GameData struct { // WorldGameMode is the game mode that a player gets when it first spawns in the world. It is shown in the // settings and is used if the PlayerGameMode is set to 5. WorldGameMode int32 + // GamePublishSetting specifies the multiplayer setting of the game. It is a value from 0-4, with 0 being + // no multiplayer enabled, 1 being invited only, 2 being friends only, 3 being friends of friends, and 4 being public. + GamePublishSetting int32 // Hardcore is if the world is in hardcore mode. In hardcore mode, the player cannot respawn after dying. Hardcore bool // GameRules defines game rules currently active with their respective values. The value of these game diff --git a/minecraft/listener.go b/minecraft/listener.go index 001860bb..477c3be2 100644 --- a/minecraft/listener.go +++ b/minecraft/listener.go @@ -101,6 +101,9 @@ type Listener struct { close chan struct{} key *ecdsa.PrivateKey + + disableEncryption bool + batchHeader []byte } // Listen announces on the local network address. The network is typically "raknet". @@ -131,12 +134,14 @@ func (cfg ListenConfig) Listen(network string, address string) (*Listener, error } key, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) listener := &Listener{ - cfg: cfg, - listener: netListener, - packs: slices.Clone(cfg.ResourcePacks), - incoming: make(chan *Conn), - close: make(chan struct{}), - key: key, + cfg: cfg, + listener: netListener, + packs: slices.Clone(cfg.ResourcePacks), + incoming: make(chan *Conn), + close: make(chan struct{}), + key: key, + disableEncryption: n.DisableEncryption(), + batchHeader: n.BatchHeader(), } // Actually start listening. @@ -208,13 +213,26 @@ func (listener *Listener) Close() error { // updatePongData updates the pong data of the listener using the current only players, maximum players and // server name of the listener, provided the listener isn't currently hijacking the pong of another server. +// If NetworkListener of the listener supports updating the server status directly with ServerStatus(ServerStatus) +// method, it will directly call the method after updating its pong data. func (listener *Listener) updatePongData() { + var port uint16 + if addr, ok := listener.Addr().(*net.UDPAddr); ok { + port = uint16(addr.Port) + } + s := listener.status() listener.listener.PongData([]byte(fmt.Sprintf("MCPE;%v;%v;%v;%v;%v;%v;%v;%v;%v;%v;%v;%v;", s.ServerName, protocol.CurrentProtocol, protocol.CurrentVersion, s.PlayerCount, s.MaxPlayers, - listener.listener.ID(), s.ServerSubName, "Creative", 1, listener.Addr().(*net.UDPAddr).Port, listener.Addr().(*net.UDPAddr).Port, + listener.listener.ID(), s.ServerSubName, "Creative", 1, port, port, 0, ))) + + if status, ok := listener.listener.(interface { + ServerStatus(status ServerStatus) + }); ok { + status.ServerStatus(s) + } } // listen starts listening for incoming connections and packets. When a player is fully connected, it submits @@ -256,11 +274,13 @@ func (listener *Listener) createConn(netConn net.Conn) { packs := slices.Clone(listener.packs) listener.packsMu.RUnlock() - conn := newConn(netConn, listener.key, listener.cfg.ErrorLog, proto{}, listener.cfg.FlushRate, true) + conn := newConn(netConn, listener.key, listener.cfg.ErrorLog, proto{}, listener.cfg.FlushRate, true, listener.batchHeader) conn.acceptedProto = append(listener.cfg.AcceptedProtocols, proto{}) conn.compression = listener.cfg.Compression conn.pool = conn.proto.Packets(true) + conn.disableEncryption = listener.disableEncryption + conn.packetFunc = listener.cfg.PacketFunc conn.texturePacksRequired = listener.cfg.TexturePacksRequired conn.resourcePacks = packs diff --git a/minecraft/nethernet.go b/minecraft/nethernet.go new file mode 100644 index 00000000..08c1290e --- /dev/null +++ b/minecraft/nethernet.go @@ -0,0 +1,51 @@ +package minecraft + +import ( + "context" + "errors" + "fmt" + "github.com/df-mc/go-nethernet" + "net" + "strconv" +) + +// NetherNet is an implementation of NetherNet network. Unlike RakNet, it needs to be registered manually with a Signaling. +type NetherNet struct { + Signaling nethernet.Signaling + + // Dialer specifies options for establishing a connection with DialContext. + Dialer nethernet.Dialer + // ListenConfig specifies options for listening for connections with Listen. + ListenConfig nethernet.ListenConfig +} + +// DialContext ... +func (n NetherNet) DialContext(ctx context.Context, address string) (net.Conn, error) { + if n.Signaling == nil { + return nil, errors.New("minecraft: NetherNet.DialContext: Signaling is nil") + } + networkID, err := strconv.ParseUint(address, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse network ID: %w", err) + } + return n.Dialer.DialContext(ctx, networkID, n.Signaling) +} + +// PingContext ... +func (n NetherNet) PingContext(context.Context, string) ([]byte, error) { + return nil, errors.New("minecraft: NetherNet.PingContext: not supported") +} + +// Listen ... +func (n NetherNet) Listen(string) (NetworkListener, error) { + if n.Signaling == nil { + return nil, errors.New("minecraft: NetherNet.Listen: Signaling is nil") + } + return n.ListenConfig.Listen(n.Signaling) +} + +// DisableEncryption ... +func (NetherNet) DisableEncryption() bool { return true } + +// BatchHeader ... +func (NetherNet) BatchHeader() []byte { return nil } diff --git a/minecraft/network.go b/minecraft/network.go index e0c8f91c..b72c3ed3 100644 --- a/minecraft/network.go +++ b/minecraft/network.go @@ -24,6 +24,12 @@ type Network interface { // Specific features of the listener may be modified once it is returned, such as the used log and/or the // accepted protocol. Listen(address string) (NetworkListener, error) + // DisableEncryption indicates that encryption performed on Conn should be disabled. + // For security reasons, most implementations should return false. + DisableEncryption() bool + // BatchHeader returns the header of compressed 'batches' from Minecraft. It is used + // for encoding/decoding packets in Conn. + BatchHeader() []byte } // NetworkListener represents a listening connection to a remote server. It is the equivalent of net.Listener, but with extra diff --git a/minecraft/protocol/packet/decoder.go b/minecraft/protocol/packet/decoder.go index 44047a02..9d283022 100644 --- a/minecraft/protocol/packet/decoder.go +++ b/minecraft/protocol/packet/decoder.go @@ -25,6 +25,8 @@ type Decoder struct { encrypt *encrypt checkPacketLimit bool + + header []byte } // packetReader is used to read packets immediately instead of copying them in a buffer first. This is a @@ -35,14 +37,15 @@ type packetReader interface { // NewDecoder returns a new decoder decoding data from the io.Reader passed. One read call from the reader is // assumed to consume an entire packet. -func NewDecoder(reader io.Reader) *Decoder { +func NewDecoder(reader io.Reader, header []byte) *Decoder { if pr, ok := reader.(packetReader); ok { - return &Decoder{checkPacketLimit: true, pr: pr} + return &Decoder{checkPacketLimit: true, pr: pr, header: header} } return &Decoder{ r: reader, buf: make([]byte, 1024*1024*3), checkPacketLimit: true, + header: header, } } @@ -91,10 +94,14 @@ func (decoder *Decoder) Decode() (packets [][]byte, err error) { if len(data) == 0 { return nil, nil } - if data[0] != header { - return nil, fmt.Errorf("decode batch: invalid header %x, expected %x", data[0], header) + h := len(decoder.header) + if len(data) < h { + return nil, io.ErrUnexpectedEOF + } + if bytes.Compare(data[:h], decoder.header) != 0 { + return nil, fmt.Errorf("decode batch: invalid header %x, expected %x", data[:h], decoder.header) } - data = data[1:] + data = data[h:] if decoder.encrypt != nil { decoder.encrypt.decrypt(data) if err := decoder.encrypt.verify(data); err != nil { diff --git a/minecraft/protocol/packet/encoder.go b/minecraft/protocol/packet/encoder.go index f3ccef8a..f471412b 100644 --- a/minecraft/protocol/packet/encoder.go +++ b/minecraft/protocol/packet/encoder.go @@ -16,12 +16,13 @@ type Encoder struct { compression Compression encrypt *encrypt + header []byte } // NewEncoder returns a new Encoder for the io.Writer passed. Each final packet produced by the Encoder is // sent with a single call to io.Writer.Write(). -func NewEncoder(w io.Writer) *Encoder { - return &Encoder{w: w} +func NewEncoder(w io.Writer, header []byte) *Encoder { + return &Encoder{w: w, header: header} } // EnableEncryption enables encryption for the Encoder using the secret key bytes passed. Each packet sent @@ -60,7 +61,7 @@ func (encoder *Encoder) Encode(packets [][]byte) error { } data := buf.Bytes() - prepend := []byte{header} + prepend := encoder.header if encoder.compression != nil { prepend = append(prepend, byte(encoder.compression.EncodeCompression())) var err error diff --git a/minecraft/protocol/packet/start_game.go b/minecraft/protocol/packet/start_game.go index f20d117b..47e28b0a 100644 --- a/minecraft/protocol/packet/start_game.go +++ b/minecraft/protocol/packet/start_game.go @@ -12,6 +12,14 @@ const ( SpawnBiomeTypeUserDefined ) +const ( + XBLBroadcastModeNoMultiPlay = iota + XBLBroadcastModeInviteOnly + XBLBroadcastModeFriendsOnly + XBLBroadcastModeFriendsOfFriends + XBLBroadcastModePublic +) + const ( ChatRestrictionLevelNone = 0 ChatRestrictionLevelDropped = 1 diff --git a/minecraft/raknet.go b/minecraft/raknet.go index 2412846a..67e10f24 100644 --- a/minecraft/raknet.go +++ b/minecraft/raknet.go @@ -24,6 +24,12 @@ func (r RakNet) Listen(address string) (NetworkListener, error) { return raknet.Listen(address) } +// Encrypted ... +func (r RakNet) DisableEncryption() bool { return false } + +// BatchHeader ... +func (r RakNet) BatchHeader() []byte { return []byte{0xfe} } + // init registers the RakNet network. func init() { RegisterNetwork("raknet", RakNet{}) diff --git a/minecraft/room/announce.go b/minecraft/room/announce.go new file mode 100644 index 00000000..7036ad7f --- /dev/null +++ b/minecraft/room/announce.go @@ -0,0 +1,17 @@ +package room + +import "context" + +// Announcer announces the Status of a Listener to an external service. Implementations of Announcer +// should define how to report the status using the provided Announce method. +// +// Example implementations might include XBLAnnouncer, which uses the Multiplayer Session Directory (MPSD) +// of Xbox Live for announcing the Status. +type Announcer interface { + // Announce sends the given Status to an external service for reporting. + // The [context.Context] may be used to control the deadline and cancellation + // of announcement. An error may be returned, if the Status could not be announced. + Announce(ctx context.Context, status Status) error + + Close() error +} diff --git a/minecraft/room/internal/attr.go b/minecraft/room/internal/attr.go new file mode 100644 index 00000000..9c826ab9 --- /dev/null +++ b/minecraft/room/internal/attr.go @@ -0,0 +1,9 @@ +package internal + +import "log/slog" + +const errorKey = "error" + +func ErrAttr(err error) slog.Attr { + return slog.Any(errorKey, err) +} diff --git a/minecraft/room/listener.go b/minecraft/room/listener.go new file mode 100644 index 00000000..e87a5d7a --- /dev/null +++ b/minecraft/room/listener.go @@ -0,0 +1,161 @@ +package room + +import ( + "errors" + "github.com/df-mc/go-nethernet" + "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/room/internal" + "log/slog" + "net" + "strconv" + "sync" + "time" +) + +// ListenConfig holds the configuration for wrapping a [minecraft.NetworkListener] with additional functionality. +// It provides the ability to announce server status and custom the behavior of status reporting. +type ListenConfig struct { + // Announcer announces the Status of Listener. It is called from [Listener.ServerStatus] to report the status + // to external services like Xbox Live and LAN discovery. If nil, the Wrap method will panic. + Announcer Announcer + + // StatusProvider provides the Status for announcing using the Announcer. It will be called by [Listener.ServerStatus] + // at some intervals. If nil, a default StatusProvider reporting DefaultStatus will be set. + StatusProvider StatusProvider + + // DisableServerStatusOverride indicates that fields of the Status provided by the StatusProvider should be modified + // to sync with the [minecraft.ServerStatus] reported from [minecraft.Listener]. It includes fields like [Status.MemberCount], + // [Status.MaxMemberCount], [Status.WorldName], and [Status.HostName]. + DisableServerStatusOverride bool // TODO: Find a good name + + // Log is used for logging messages at various log levels. If nil, the default [slog.Logger] + // will be set from [slog.Default]. + Log *slog.Logger +} + +// Wrap wraps the [minecraft.NetworkListener] with additional functionality provided by Listener. It returns +// a new [Listener] that hijacks the [minecraft.ServerStatus] of the underlying listener and announces it using +// the [Announcer] and the [Status] provided by the [StatusProvider]. +func (conf ListenConfig) Wrap(n minecraft.NetworkListener) *Listener { + if conf.Announcer == nil { + panic("minecraft/room: ListenConfig.Wrap: Announcer is nil") + } + if conf.StatusProvider == nil { + conf.StatusProvider = NewStatusProvider(DefaultStatus()) + } + + return &Listener{ + conf: conf, + + n: n, + + closed: make(chan struct{}, 1), + } +} + +// Listener wraps a [minecraft.NetworkListener], allowing it to announce [minecraft.ServerStatus] using +// an Announcer. It can be created using [ListenConfig.Wrap]. +type Listener struct { + conf ListenConfig + + n minecraft.NetworkListener + + closed chan struct{} // Notifies that the Listener has been closed. + once sync.Once // Closes Listener only once. +} + +// Accept waits for and returns the next [net.Conn] to the underlying [minecraft.NetworkListener]. +// An error may be returned if the Listener has been closed. +func (l *Listener) Accept() (net.Conn, error) { return l.n.Accept() } + +// Addr returns the [net.Addr] of the underlying [minecraft.NetworkListener]. +func (l *Listener) Addr() net.Addr { return l.n.Addr() } + +// ID returns the unique ID of the underlying [minecraft.NetworkListener]. +func (l *Listener) ID() int64 { return l.n.ID() } + +// PongData updates the pong data on the underlying [minecraft.NetworkListener]. +func (l *Listener) PongData(data []byte) { l.n.PongData(data) } + +// ServerStatus reports the [minecraft.ServerStatus] to the Announcer with a Status provided by +// the StatusProvider. If [ListenConfig.DisableServerStatusOverride] is false, the fields of the +// Status will be modified to sync with the [minecraft.ServerStatus]. This includes updating member +// counts, world names, host names, and connections based on the address type of the [minecraft.NetworkListener]. +func (l *Listener) ServerStatus(server minecraft.ServerStatus) { + status := l.conf.StatusProvider.RoomStatus() + if !l.conf.DisableServerStatusOverride { + status.MemberCount = server.PlayerCount + status.MaxMemberCount = server.MaxPlayers + + status.WorldName = server.ServerName + status.HostName = server.ServerSubName + + switch addr := l.n.Addr().(type) { + case *nethernet.Addr: + if status.TransportLayer == 0 { + status.TransportLayer = TransportLayerNetherNet + } + status.SupportedConnections = append(status.SupportedConnections, Connection{ + ConnectionType: ConnectionTypeWebSocketsWebRTCSignaling, + NetherNetID: addr.NetworkID, + }) + case *net.UDPAddr: + if status.TransportLayer == 0 { + status.TransportLayer = TransportLayerRakNet + } + if status.RakNetGUID == "" { + status.RakNetGUID = strconv.FormatInt(l.n.ID(), 10) + } + status.SupportedConnections = append(status.SupportedConnections, Connection{ + ConnectionType: ConnectionTypeUPNP, + HostIPAddress: addr.IP.String(), + HostPort: uint16(addr.Port), + RakNetGUID: strconv.FormatInt(l.n.ID(), 10), + }) + default: + l.conf.Log.Debug("unsupported address type", slog.Any("address", addr)) + } + } + + if err := l.conf.Announcer.Announce(&listenerContext{closed: l.closed}, status); err != nil { + if !errors.Is(err, net.ErrClosed) { + l.conf.Log.Error("error announcing status", internal.ErrAttr(err)) + } + } +} + +// Close closes the Listener. Any blocking methods will be canceled through its internal context. +func (l *Listener) Close() (err error) { + l.once.Do(func() { + close(l.closed) + err = errors.Join( + l.n.Close(), + l.conf.Announcer.Close(), + ) + }) + return err +} + +// listenerContext implements [context.Context] for a Listener. +type listenerContext struct{ closed <-chan struct{} } + +// Deadline returns the zero time and false, indicating that deadlines are not used. +func (*listenerContext) Deadline() (zero time.Time, _ bool) { + return zero, false +} + +// Done returns a channel that is closed when the Listener is closed. +func (ctx *listenerContext) Done() <-chan struct{} { return ctx.closed } + +// Err returns net.ErrClosed if the Listener has been closed. Returns nil otherwise. +func (ctx *listenerContext) Err() error { + select { + case <-ctx.closed: + return net.ErrClosed + default: + return nil + } +} + +// Value returns nil for any key, as no values are associated with the context. +func (*listenerContext) Value(any) any { return nil } diff --git a/minecraft/room/listener_test.go b/minecraft/room/listener_test.go new file mode 100644 index 00000000..09ad92f6 --- /dev/null +++ b/minecraft/room/listener_test.go @@ -0,0 +1,154 @@ +package room + +import ( + "context" + "encoding/json" + "fmt" + "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/auth" + "github.com/sandertv/gophertunnel/minecraft/auth/xal" + "github.com/sandertv/gophertunnel/minecraft/franchise/signaling" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "golang.org/x/oauth2" + "math/rand" + "net" + "os" + "strings" + "testing" + "time" +) + +// TestListen demonstrates a world displayed in the friend list. +func TestListen(t *testing.T) { + tok, err := readToken("../franchise/internal/test/auth.tok", auth.TokenSource) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + src := auth.RefreshTokenSource(tok) + + d := signaling.Dialer{ + NetworkID: rand.Uint64(), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + signals, err := d.DialContext(ctx, src) + if err != nil { + t.Fatalf("error dialing signaling: %s", err) + } + t.Cleanup(func() { + if err := signals.Close(); err != nil { + t.Errorf("error closing signaling: %s", err) + } + }) + + x, err := xal.RefreshTokenSource(src, "http://xboxlive.com").Token() + if err != nil { + t.Fatal(err) + } + + status := DefaultStatus() + status.OwnerID = x.DisplayClaims().XUID + minecraft.RegisterNetwork("room", Network{ + Network: minecraft.NetherNet{ + Signaling: signals, + }, + ListenConfig: ListenConfig{ + Announcer: &XBLAnnouncer{ + TokenSource: xal.RefreshTokenSource(src, "http://xboxlive.com"), + }, + StatusProvider: NewStatusProvider(status), + }, + }) + + l, err := minecraft.Listen("room", "") + if err != nil { + t.Fatalf("error listening: %s", err) + } + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Errorf("error closing listener: %s", err) + } + }) + + for { + n, err := l.Accept() + if err != nil { + return + } + + conn := n.(*minecraft.Conn) + if err := conn.StartGame(minecraft.GameData{ + WorldName: "NetherNet - room.TestListen", + WorldSeed: rand.Int63(), + EntityUniqueID: rand.Int63(), + EntityRuntimeID: rand.Uint64(), + PlayerGameMode: 1, + WorldGameMode: 1, + // Allow inviting players to the world. + GamePublishSetting: status.BroadcastSetting, + Time: rand.Int63(), + }); err != nil { + t.Errorf("error starting game: %s", err) + } + + t.Log(conn.ClientData().ServerAddress) + + // Try reading and decoding deferred packets. + go func() { + for { + pk, err := conn.ReadPacket() + if err != nil { + if !strings.Contains(err.Error(), net.ErrClosed.Error()) { + t.Errorf("error decoding packet: %s", err) + } + if err := conn.Close(); err != nil { + t.Errorf("error closing connection: %s", err) + } + return + } + + switch pk := pk.(type) { + case *packet.Text: + if pk.TextType == packet.TextTypeChat && strings.EqualFold(pk.Message, "Close") { + if err := conn.Close(); err != nil { + t.Errorf("error closing connection: %s", err) + } + if err := l.Close(); err != nil { + t.Errorf("error closing listener: %s", err) + } + return + } + } + } + }() + } +} + +func readToken(path string, src oauth2.TokenSource) (t *oauth2.Token, err error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + t, err = src.Token() + if err != nil { + return nil, fmt.Errorf("obtain token: %w", err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm) + if err != nil { + return nil, err + } + defer f.Close() + if err := json.NewEncoder(f).Encode(t); err != nil { + return nil, fmt.Errorf("encode: %w", err) + } + return t, nil + } else if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + if err := json.NewDecoder(f).Decode(&t); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return t, nil +} diff --git a/minecraft/room/mpsd.go b/minecraft/room/mpsd.go new file mode 100644 index 00000000..55b441a4 --- /dev/null +++ b/minecraft/room/mpsd.go @@ -0,0 +1,116 @@ +package room + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/df-mc/go-xsapi" + "github.com/df-mc/go-xsapi/mpsd" + "github.com/google/uuid" + "strings" + "sync" +) + +// XBLAnnouncer announces a Status through the Multiplayer Session Directory (MPSD) of Xbox Live. +type XBLAnnouncer struct { + // TokenSource provides the [xsapi.Token] required to publish a session when the Session is nil. + TokenSource xsapi.TokenSource + + // SessionReference specifies the internal ID of the session being published when the Session is nil. + SessionReference mpsd.SessionReference + + // PublishConfig specifies custom configuration for publishing a session when the Session is nil. + PublishConfig mpsd.PublishConfig + + // Session is the session where the Status will be committed. If nil, a [mpsd.Session] will be published + // using the PublishConfig. + Session *mpsd.Session + + // custom properties are encoded from Status for comparison in announcements. + custom []byte + + // Mutex ensures atomic read/write access to the fields. + sync.Mutex +} + +// Announce commits or publishes a [mpsd.Session] with the given Status. The status will be encoded as custom properties +// of the session description. The [context.Context] may be used to control the deadline or cancellation of announcement. +// +// If the Status has not changed since the last announcement, the method will return immediately. +func (a *XBLAnnouncer) Announce(ctx context.Context, status Status) error { + a.Lock() + defer a.Unlock() + + custom, err := json.Marshal(status) + if err != nil { + return fmt.Errorf("encode: %w", err) + } + if bytes.Compare(custom, a.custom) == 0 { + return nil + } else { + a.custom = custom + } + + if a.Session == nil { + if a.PublishConfig.Description == nil { + a.PublishConfig.Description = a.description(status) + } + a.PublishConfig.Description.Properties.Custom = custom + + if a.SessionReference.ServiceConfigID == uuid.Nil { + a.SessionReference.ServiceConfigID = uuid.MustParse("4fc10100-5f7a-4470-899b-280835760c07") + } + if a.SessionReference.TemplateName == "" { + a.SessionReference.TemplateName = "MinecraftLobby" + } + if a.SessionReference.Name == "" { + a.SessionReference.Name = strings.ToUpper(uuid.NewString()) + } + + a.Session, err = a.PublishConfig.PublishContext(ctx, a.TokenSource, a.SessionReference) + if err != nil { + return fmt.Errorf("publish: %w", err) + } + return nil + } + _, err = a.Session.Commit(ctx, a.description(status)) + return err +} + +// description returns a [mpsd.SessionDescription] to be committed or published on the Session. +// It uses custom properties encoded from the Status in [XBLAnnouncer.Announce]. +func (a *XBLAnnouncer) description(status Status) *mpsd.SessionDescription { + read, join := a.restrictions(status.BroadcastSetting) + return &mpsd.SessionDescription{ + Properties: &mpsd.SessionProperties{ + System: &mpsd.SessionPropertiesSystem{ + ReadRestriction: read, + JoinRestriction: join, + }, + Custom: a.custom, + }, + } +} + +// restrictions determines the read and join restrictions for the session based on [Status.BroadcastSetting]. +func (a *XBLAnnouncer) restrictions(setting int32) (read, join string) { + switch setting { + case BroadcastSettingFriendsOfFriends, BroadcastSettingFriendsOnly: + return mpsd.SessionRestrictionFollowed, mpsd.SessionRestrictionFollowed + case BroadcastSettingInviteOnly: + return mpsd.SessionRestrictionLocal, mpsd.SessionRestrictionFollowed + default: + return mpsd.SessionRestrictionFollowed, mpsd.SessionRestrictionFollowed + } +} + +func (a *XBLAnnouncer) Close() (err error) { + a.Lock() + defer a.Unlock() + + if a.Session != nil { + return a.Session.Close() + } + return nil +} diff --git a/minecraft/room/network.go b/minecraft/room/network.go new file mode 100644 index 00000000..05d57352 --- /dev/null +++ b/minecraft/room/network.go @@ -0,0 +1,48 @@ +package room + +import ( + "context" + "github.com/sandertv/gophertunnel/minecraft" + "net" +) + +// Network wraps the [minecraft.Network], extending its functionality by hijacking some methods +// to add some features that room package would provide. It provides the ability to customize +// listener through [ListenConfig]. +// +// It must be registered manually using [minecraft.RegisterNetwork] with an ID. +type Network struct { + // Network is the underlying [minecraft.Network] that will be extended. + Network minecraft.Network + + // ListenConfig specifies the configuration used to customize listeners. + ListenConfig ListenConfig +} + +// DialContext ... +func (n Network) DialContext(ctx context.Context, address string) (net.Conn, error) { + return n.Network.DialContext(ctx, address) +} + +// PingContext ... +func (n Network) PingContext(ctx context.Context, address string) ([]byte, error) { + return n.Network.PingContext(ctx, address) +} + +// Listen listens on the specified address using the underlying [minecraft.Network.Listen] method +// and wraps the returned [minecraft.NetworkListener] to extend its functionality with [ListenConfig.Wrap]. +// This wrapping allows for additioanl features, such as hijacking the [minecraft.ServerStatus] for reporting +// it with Status on Announcer. +func (n Network) Listen(address string) (minecraft.NetworkListener, error) { + l, err := n.Network.Listen(address) + if err != nil { + return nil, err + } + return n.ListenConfig.Wrap(l), nil +} + +// DisableEncryption ... +func (n Network) DisableEncryption() bool { return n.Network.DisableEncryption() } + +// BatchHeader ... +func (n Network) BatchHeader() []byte { return n.Network.BatchHeader() } diff --git a/minecraft/room/status.go b/minecraft/room/status.go new file mode 100644 index 00000000..4546cc70 --- /dev/null +++ b/minecraft/room/status.go @@ -0,0 +1,108 @@ +package room + +import ( + "crypto/rand" + "encoding/base64" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +type Status struct { + Joinability string `json:"Joinability,omitempty"` + HostName string `json:"hostName,omitempty"` + OwnerID string `json:"ownerId,omitempty"` + RakNetGUID string `json:"rakNetGUID"` + Version string `json:"version"` + LevelID string `json:"levelId"` + WorldName string `json:"worldName"` + WorldType string `json:"worldType"` + Protocol int32 `json:"protocol"` + MemberCount int `json:"MemberCount"` + MaxMemberCount int `json:"MaxMemberCount"` + BroadcastSetting int32 `json:"BroadcastSetting"` + LanGame bool `json:"LanGame"` + IsEditorWorld bool `json:"isEditorWorld"` + TransportLayer int32 `json:"TransportLayer"` + OnlineCrossPlatformGame bool `json:"OnlineCrossPlatformGame"` + CrossPlayDisabled bool `json:"CrossPlayDisabled"` + TitleID int64 `json:"TitleId"` + SupportedConnections []Connection `json:"SupportedConnections"` +} + +type Connection struct { + ConnectionType uint32 `json:"ConnectionType"` + HostIPAddress string `json:"HostIpAddress"` + HostPort uint16 `json:"HostPort"` + NetherNetID uint64 `json:"NetherNetId"` + RakNetGUID string `json:"RakNetGUID,omitempty"` +} + +const ( + JoinabilityInviteOnly = "invite_only" + JoinabilityJoinableByFriends = "joinable_by_friends" +) + +const ( + WorldTypeCreative = "Creative" +) + +const ( + BroadcastSettingInviteOnly int32 = iota + 1 + BroadcastSettingFriendsOnly + BroadcastSettingFriendsOfFriends +) + +const ( + TransportLayerRakNet int32 = iota + _ + TransportLayerNetherNet +) + +const ( + ConnectionTypeWebSocketsWebRTCSignaling uint32 = 3 + ConnectionTypeUPNP uint32 = 6 +) + +type StatusProvider interface { + RoomStatus() Status +} + +func NewStatusProvider(status Status) StatusProvider { + return statusProvider{status: status} +} + +type statusProvider struct{ status Status } + +func (p statusProvider) RoomStatus() Status { + return p.status +} + +func DefaultStatus() Status { + levelID := make([]byte, 8) + _, _ = rand.Read(levelID) + + return Status{ + Joinability: JoinabilityJoinableByFriends, + HostName: "Gophertunnel", + Version: protocol.CurrentVersion, + LevelID: base64.StdEncoding.EncodeToString(levelID), + WorldName: "Room Listener", + WorldType: WorldTypeCreative, + Protocol: protocol.CurrentProtocol, + BroadcastSetting: BroadcastSettingFriendsOfFriends, + LanGame: true, + OnlineCrossPlatformGame: true, + CrossPlayDisabled: false, + TitleID: 0, + } +} + +func NetherNetID(status Status) (uint64, bool) { + for _, c := range status.SupportedConnections { + if c.ConnectionType == ConnectionTypeWebSocketsWebRTCSignaling { + if c.NetherNetID != 0 { + return c.NetherNetID, true + } + } + } + return 0, false +}