From 6c78e42757f0ed41a465863ca0da75b211dce353 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Sun, 11 Aug 2024 12:22:00 +0900 Subject: [PATCH 01/14] minecraft: Implement publishing/listening worlds --- go.mod | 30 +- go.sum | 89 +++- minecraft/conn.go | 35 +- minecraft/franchise/discovery.go | 72 ++++ minecraft/franchise/discovery_test.go | 20 + minecraft/franchise/internal/attr.go | 7 + minecraft/franchise/internal/result.go | 5 + minecraft/franchise/internal/test/.gitignore | 1 + .../franchise/internal/test/token_source.go | 54 +++ minecraft/franchise/internal/user_agent.go | 3 + minecraft/franchise/signaling/conn.go | 121 ++++++ minecraft/franchise/signaling/conn_test.go | 88 ++++ minecraft/franchise/signaling/dial.go | 88 ++++ minecraft/franchise/signaling/environment.go | 9 + minecraft/franchise/signaling/message.go | 17 + minecraft/franchise/token.go | 138 ++++++ minecraft/franchise/token_test.go | 73 ++++ minecraft/listener.go | 17 +- minecraft/nethernet/conn.go | 231 ++++++++++ minecraft/nethernet/credentials.go | 12 + minecraft/nethernet/dial.go | 158 +++++++ minecraft/nethernet/internal/attr.go | 7 + minecraft/nethernet/internal/test/.gitignore | 1 + .../nethernet/internal/test/token_source.go | 54 +++ minecraft/nethernet/listener.go | 387 +++++++++++++++++ minecraft/nethernet/message.go | 38 ++ minecraft/nethernet/signal.go | 162 +++++++ minecraft/network.go | 4 + minecraft/raknet.go | 3 + minecraft/world_test.go | 299 +++++++++++++ playfab/catalog/dictionary.go | 97 +++++ playfab/catalog/item.go | 195 +++++++++ playfab/catalog/query.go | 26 ++ playfab/catalog/search_items.go | 63 +++ playfab/entity/exchange.go | 22 + playfab/entity/token.go | 31 ++ playfab/entity/token_source.go | 75 ++++ playfab/identity.go | 404 ++++++++++++++++++ playfab/internal/body.go | 105 +++++ playfab/internal/http.go | 55 +++ playfab/login.go | 72 ++++ playfab/title/title.go | 9 + playfab/types.go | 21 + xsapi/internal/attr.go | 7 + xsapi/mpsd/activity.go | 46 ++ xsapi/mpsd/commit.go | 81 ++++ xsapi/mpsd/member.go | 57 +++ xsapi/mpsd/publish.go | 127 ++++++ xsapi/mpsd/session.go | 141 ++++++ xsapi/rta/conn.go | 245 +++++++++++ xsapi/rta/dial.go | 84 ++++ xsapi/rta/handshake.go | 91 ++++ xsapi/token.go | 23 + xsapi/transport.go | 58 +++ xsapi/xal/token_source.go | 59 +++ 55 files changed, 4385 insertions(+), 32 deletions(-) create mode 100644 minecraft/franchise/discovery.go create mode 100644 minecraft/franchise/discovery_test.go create mode 100644 minecraft/franchise/internal/attr.go create mode 100644 minecraft/franchise/internal/result.go create mode 100644 minecraft/franchise/internal/test/.gitignore create mode 100644 minecraft/franchise/internal/test/token_source.go create mode 100644 minecraft/franchise/internal/user_agent.go create mode 100644 minecraft/franchise/signaling/conn.go create mode 100644 minecraft/franchise/signaling/conn_test.go create mode 100644 minecraft/franchise/signaling/dial.go create mode 100644 minecraft/franchise/signaling/environment.go create mode 100644 minecraft/franchise/signaling/message.go create mode 100644 minecraft/franchise/token.go create mode 100644 minecraft/franchise/token_test.go create mode 100644 minecraft/nethernet/conn.go create mode 100644 minecraft/nethernet/credentials.go create mode 100644 minecraft/nethernet/dial.go create mode 100644 minecraft/nethernet/internal/attr.go create mode 100644 minecraft/nethernet/internal/test/.gitignore create mode 100644 minecraft/nethernet/internal/test/token_source.go create mode 100644 minecraft/nethernet/listener.go create mode 100644 minecraft/nethernet/message.go create mode 100644 minecraft/nethernet/signal.go create mode 100644 minecraft/world_test.go create mode 100644 playfab/catalog/dictionary.go create mode 100644 playfab/catalog/item.go create mode 100644 playfab/catalog/query.go create mode 100644 playfab/catalog/search_items.go create mode 100644 playfab/entity/exchange.go create mode 100644 playfab/entity/token.go create mode 100644 playfab/entity/token_source.go create mode 100644 playfab/identity.go create mode 100644 playfab/internal/body.go create mode 100644 playfab/internal/http.go create mode 100644 playfab/login.go create mode 100644 playfab/title/title.go create mode 100644 playfab/types.go create mode 100644 xsapi/internal/attr.go create mode 100644 xsapi/mpsd/activity.go create mode 100644 xsapi/mpsd/commit.go create mode 100644 xsapi/mpsd/member.go create mode 100644 xsapi/mpsd/publish.go create mode 100644 xsapi/mpsd/session.go create mode 100644 xsapi/rta/conn.go create mode 100644 xsapi/rta/dial.go create mode 100644 xsapi/rta/handshake.go create mode 100644 xsapi/token.go create mode 100644 xsapi/transport.go create mode 100644 xsapi/xal/token_source.go diff --git a/go.mod b/go.mod index fe3ce02f..ddc6b9b8 100644 --- a/go.mod +++ b/go.mod @@ -10,16 +10,40 @@ require ( github.com/golang/snappy v0.0.4 github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.17.9 + github.com/kr/pretty v0.1.0 github.com/muhammadmuzzammil1998/jsonc v1.0.0 github.com/pelletier/go-toml v1.9.5 + github.com/pion/ice/v3 v3.0.16 + github.com/pion/logging v0.2.2 + github.com/pion/sdp/v3 v3.0.9 + github.com/pion/webrtc/v4 v4.0.0-beta.27.0.20240806193753-4ba98f5921a6 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 + nhooyr.io/websocket v1.8.11 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - golang.org/x/crypto v0.24.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pion/datachannel v1.5.8 // indirect + github.com/pion/dtls/v2 v2.2.12 // indirect + github.com/pion/dtls/v3 v3.0.0 // 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.8 // indirect + github.com/pion/sctp v1.8.20 // indirect + github.com/pion/srtp/v3 v3.0.3 // indirect + github.com/pion/stun/v2 v2.0.0 // indirect + github.com/pion/transport/v2 v2.2.8 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v3 v3.0.3 // indirect + github.com/wlynxg/anet v0.0.3 // indirect + golang.org/x/crypto v0.25.0 // indirect golang.org/x/image v0.17.0 // indirect + golang.org/x/sys v0.22.0 // indirect ) + +replace github.com/pion/sctp => github.com/lactyy/sctp v0.0.0-20240806210006-9a1eff46ee7b diff --git a/go.sum b/go.sum index 359f81c3..93f8e685 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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 +14,82 @@ 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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lactyy/sctp v0.0.0-20240806210006-9a1eff46ee7b h1:tVE+MVXKEh0UZUOVZ8FW/6pc3OL543RTEuV5QEGCOiU= +github.com/lactyy/sctp v0.0.0-20240806210006-9a1eff46ee7b/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= 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.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo= +github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= +github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= +github.com/pion/dtls/v3 v3.0.0 h1:m2hzwPkzqoBjVKXm5ymNuX01OAjht82TdFL6LoTzgi4= +github.com/pion/dtls/v3 v3.0.0/go.mod h1:tiX7NaneB0wNoRaUpaMVP7igAlkMCTQkbpiY+OfeIi0= +github.com/pion/ice/v3 v3.0.16 h1:YoPlNg3jU1UT/DDTa9v/g1vH6A2/pAzehevI1o66H8E= +github.com/pion/ice/v3 v3.0.16/go.mod h1:SdmubtIsCcvdb1ZInrTUz7Iaqi90/rYd1pzbzlMxsZg= +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.8 h1:EtYFHI0rpUEjT/RMnGfb1vdJhbYmPG77szD72uUnSxs= +github.com/pion/rtp v1.8.8/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +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/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.8 h1:HzsqGBChgtF4Cj47gu51l5hONuK/NwgbZL17CMSuwS0= +github.com/pion/transport/v2 v2.2.8/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +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/v3 v3.0.3 h1:1e3GVk8gHZLPBA5LqadWYV60lmaKUaHCkm9DX9CkGcE= +github.com/pion/turn/v3 v3.0.3/go.mod h1:vw0Dz420q7VYAF3J4wJKzReLHIo2LGp4ev8nXQexYsc= +github.com/pion/webrtc/v4 v4.0.0-beta.27.0.20240806193753-4ba98f5921a6 h1:Hr6Qmk2WPPpBAv74thidM5HRcv1bJdg8XibyX1ueJL8= +github.com/pion/webrtc/v4 v4.0.0-beta.27.0.20240806193753-4ba98f5921a6/go.mod h1:ZOnztLYCdXE1sMCFrOWJfAXw0EBgXSjHDB+ISNEIwE8= 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.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 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.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 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= @@ -41,9 +99,12 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 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.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +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= @@ -55,18 +116,27 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.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= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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= @@ -76,5 +146,8 @@ 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= +nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= +nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/minecraft/conn.go b/minecraft/conn.go index dec8b8d4..a535c1da 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. @@ -828,15 +829,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{}) @@ -1412,16 +1415,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/franchise/discovery.go b/minecraft/franchise/discovery.go new file mode 100644 index 00000000..35983b05 --- /dev/null +++ b/minecraft/franchise/discovery.go @@ -0,0 +1,72 @@ +package franchise + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal" + "net/http" + "net/url" +) + +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 +} + +type Discovery struct { + ServiceEnvironments map[string]map[string]json.RawMessage `json:"serviceEnvironments"` + SupportedEnvironments map[string][]string `json:"supportedEnvironments"` +} + +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 +} + +type Environment interface { + EnvironmentName() string +} + +const ( + EnvironmentTypeProduction = "prod" + EnvironmentTypeDevelopment = "dev" + EnvironmentTypeStaging = "stage" +) + +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..f15943aa --- /dev/null +++ b/minecraft/franchise/internal/test/token_source.go @@ -0,0 +1,54 @@ +package test + +import ( + "encoding/json" + "fmt" + "golang.org/x/oauth2" + "os" + "testing" +) + +func TokenSource(t *testing.T, path string, src oauth2.TokenSource, hooks ...RefreshTokenFunc) *oauth2.Token { + tok, err := readTokenSource(path, src) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + for _, h := range hooks { + tok, err = h(tok) + if err != nil { + t.Fatalf("error refreshing token: %s", err) + } + } + return tok +} + +type RefreshTokenFunc func(old *oauth2.Token) (new *oauth2.Token, err error) + +func readTokenSource(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/signaling/conn.go b/minecraft/franchise/signaling/conn.go new file mode 100644 index 00000000..144cd249 --- /dev/null +++ b/minecraft/franchise/signaling/conn.go @@ -0,0 +1,121 @@ +package signaling + +import ( + "context" + "encoding/json" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal" + "github.com/sandertv/gophertunnel/minecraft/nethernet" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + "strconv" + "sync" + "sync/atomic" + "time" +) + +type Conn struct { + conn *websocket.Conn + ctx context.Context + d Dialer + + credentials atomic.Pointer[nethernet.Credentials] + ready chan struct{} + + once sync.Once + + signals chan *nethernet.Signal +} + +func (c *Conn) WriteSignal(signal *nethernet.Signal) error { + return c.write(Message{ + Type: MessageTypeSignal, + To: json.Number(strconv.FormatUint(signal.NetworkID, 10)), + Data: signal.String(), + }) +} + +func (c *Conn) ReadSignal() (*nethernet.Signal, error) { + select { + case s := <-c.signals: + return s, nil + case <-c.ctx.Done(): + return nil, context.Cause(c.ctx) + } +} + +func (c *Conn) Credentials() (*nethernet.Credentials, error) { + select { + case <-c.ctx.Done(): + return nil, context.Cause(c.ctx) + case <-c.ready: + return c.credentials.Load(), nil + } +} + +func (c *Conn) ping() { + ticker := time.NewTicker(time.Second * 15) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := c.write(Message{ + Type: MessageTypeRequestPing, + }); err != nil { + c.d.Log.Error("error writing ping", internal.ErrAttr(err)) + } + case <-c.ctx.Done(): + return + } + } +} + +func (c *Conn) read(cancel context.CancelCauseFunc) { + for { + var message Message + if err := wsjson.Read(context.Background(), c.conn, &message); err != nil { + cancel(err) + return + } + switch message.Type { + case MessageTypeCredentials: + if message.From != "Server" { + c.d.Log.Warn("received credentials from non-Server", "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 + } + c.credentials.Store(&credentials) + close(c.ready) + case MessageTypeSignal: + s := &nethernet.Signal{} + if err := s.UnmarshalText([]byte(message.Data)); err != nil { + c.d.Log.Error("error decoding signal", internal.ErrAttr(err)) + continue + } + var err error + s.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.signals <- s + default: + c.d.Log.Warn("received message for unknown type", "message", message) + } + } +} + +func (c *Conn) write(m Message) error { + return wsjson.Write(context.Background(), c.conn, m) +} + +func (c *Conn) Close() (err error) { + c.once.Do(func() { + err = c.conn.Close(websocket.StatusNormalClosure, "") + }) + return err +} diff --git a/minecraft/franchise/signaling/conn_test.go b/minecraft/franchise/signaling/conn_test.go new file mode 100644 index 00000000..964791c6 --- /dev/null +++ b/minecraft/franchise/signaling/conn_test.go @@ -0,0 +1,88 @@ +package signaling + +import ( + "context" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/auth" + "github.com/sandertv/gophertunnel/minecraft/franchise" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/playfab" + "golang.org/x/oauth2" + "golang.org/x/text/language" + "math/rand" + "strconv" + "testing" +) + +func TestDial(t *testing.T) { + discovery, err := franchise.Discover(protocol.CurrentVersion) + if err != nil { + t.Fatalf("discover environments: %s", err) + } + a := new(franchise.AuthorizationEnvironment) + if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("decode environment: %s", err) + } + + src := test.TokenSource(t, "../internal/test/auth.tok", auth.TokenSource, func(old *oauth2.Token) (new *oauth2.Token, err error) { + return auth.RefreshTokenSource(old).Token() + }) + x, err := auth.RequestXBLToken(context.Background(), src, "http://playfab.xboxlive.com/") + if err != nil { + t.Fatalf("error requesting XBL token: %s", err) + } + + identity, err := playfab.Login{ + Title: "20CA2", + CreateAccount: true, + }.WithXBLToken(x).Login() + if err != nil { + t.Fatalf("error logging in to playfab: %s", err) + } + + region, _ := language.English.Region() + + conf := &franchise.TokenConfig{ + Device: &franchise.DeviceConfig{ + ApplicationType: franchise.ApplicationTypeMinecraftPE, + Capabilities: []string{franchise.CapabilityRayTracing}, + GameVersion: protocol.CurrentVersion, + ID: uuid.New(), + Memory: strconv.FormatUint(rand.Uint64(), 10), + Platform: franchise.PlatformWindows10, + PlayFabTitleID: a.PlayFabTitleID, + StorePlatform: franchise.StorePlatformUWPStore, + Type: franchise.DeviceTypeWindows10, + }, + User: &franchise.UserConfig{ + Language: language.English, + LanguageCode: language.AmericanEnglish, + RegionCode: region.String(), + Token: identity.SessionTicket, + TokenType: franchise.TokenTypePlayFab, + }, + Environment: a, + } + + s := new(Environment) + if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("decode environment: %s", err) + } + var d Dialer + conn, err := d.DialContext(context.Background(), tokenConfigSource(func() (*franchise.TokenConfig, error) { + return conf, nil + }), s) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Errorf("clean up: error closing: %s", err) + } + }) +} + +type tokenConfigSource func() (*franchise.TokenConfig, error) + +func (f tokenConfigSource) TokenConfig() (*franchise.TokenConfig, error) { return f() } diff --git a/minecraft/franchise/signaling/dial.go b/minecraft/franchise/signaling/dial.go new file mode 100644 index 00000000..9aeb98ee --- /dev/null +++ b/minecraft/franchise/signaling/dial.go @@ -0,0 +1,88 @@ +package signaling + +import ( + "context" + "fmt" + "github.com/sandertv/gophertunnel/minecraft/franchise" + "github.com/sandertv/gophertunnel/minecraft/nethernet" + "log/slog" + "math/rand" + "net/http" + "net/url" + "nhooyr.io/websocket" + "strconv" +) + +type Dialer struct { + Options *websocket.DialOptions + NetworkID uint64 + Log *slog.Logger +} + +func (d Dialer) DialContext(ctx context.Context, src franchise.TokenConfigSource, env *Environment) (*Conn, error) { + if d.Options == nil { + d.Options = &websocket.DialOptions{} + } + if d.Options.HTTPClient == nil { + d.Options.HTTPClient = &http.Client{} + } + if d.Options.HTTPHeader == nil { + d.Options.HTTPHeader = make(http.Header) // TODO(lactyy): Move to *franchise.Transport + } + if d.NetworkID == 0 { + d.NetworkID = rand.Uint64() + } + if d.Log == nil { + d.Log = slog.Default() + } + /*var hasTransport bool + if base := d.Options.HTTPClient.Transport; base != nil { + _, hasTransport = base.(*franchise.Transport) + } + if !hasTransport { + d.Options.HTTPClient.Transport = &franchise.Transport{ + Source: src, + Base: d.Options.HTTPClient.Transport, + } + }*/ + + // TODO(lactyy): Move to *franchise.Transport + conf, err := src.TokenConfig() + if err != nil { + return nil, fmt.Errorf("request token config: %w", err) + } + t, err := conf.Token() + if err != nil { + return nil, fmt.Errorf("request token: %w", err) + } + d.Options.HTTPHeader.Set("Authorization", t.AuthorizationHeader) + + 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, + signals: make(chan *nethernet.Signal), + ready: make(chan struct{}), + } + var cancel context.CancelCauseFunc + conn.ctx, cancel = context.WithCancelCause(context.Background()) + + go conn.read(cancel) + go conn.ping() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-conn.ready: + return conn, nil + } +} diff --git a/minecraft/franchise/signaling/environment.go b/minecraft/franchise/signaling/environment.go new file mode 100644 index 00000000..de300b34 --- /dev/null +++ b/minecraft/franchise/signaling/environment.go @@ -0,0 +1,9 @@ +package signaling + +type Environment struct { + ServiceURI string `json:"serviceUri"` + StunURI string `json:"stunUri"` + TurnURI string `json:"turnUri"` +} + +func (e *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..b605fe54 --- /dev/null +++ b/minecraft/franchise/signaling/message.go @@ -0,0 +1,17 @@ +package signaling + +import "encoding/json" + +type Message struct { + Type uint32 `json:"Type"` + // From is either a unique ID of remote network, or a string "Server". + From string `json:"From,omitempty"` + To json.Number `json:"To,omitempty"` + Data string `json:"Message,omitempty"` +} + +const ( + MessageTypeRequestPing uint32 = iota // RequestType::Ping + MessageTypeSignal // RequestType::Message + MessageTypeCredentials // RequestType::TurnAuth +) diff --git a/minecraft/franchise/token.go b/minecraft/franchise/token.go new file mode 100644 index 00000000..09936267 --- /dev/null +++ b/minecraft/franchise/token.go @@ -0,0 +1,138 @@ +package franchise + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal" + "golang.org/x/text/language" + "net/http" + "net/url" + "time" +) + +type Token struct { + AuthorizationHeader string `json:"authorizationHeader"` + ValidUntil time.Time `json:"validUntil"` + Treatments []string `json:"treatments"` + Configurations map[string]Configuration `json:"configurations"` + TreatmentContext string `json:"treatmentContext"` +} + +const ( + ConfigurationMinecraft = "minecraft" + ConfigurationValidation = "validation" +) + +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 +} + +type Configuration struct { + ID string `json:"id"` + Parameters map[string]string `json:"parameters"` +} + +type AuthorizationEnvironment struct { + ServiceURI string `json:"serviceUri"` + Issuer string `json:"issuer"` + PlayFabTitleID string `json:"playFabTitleId"` + EduPlayFabTitleID string `json:"eduPlayFabTitleId"` +} + +func (*AuthorizationEnvironment) EnvironmentName() string { return "auth" } + +type TokenConfigSource interface { + TokenConfig() (*TokenConfig, error) +} + +type TokenConfig struct { + Device *DeviceConfig `json:"device,omitempty"` + User *UserConfig `json:"user,omitempty"` + + Environment *AuthorizationEnvironment `json:"-"` +} + +type DeviceConfig struct { + ApplicationType string `json:"applicationType,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + GameVersion string `json:"gameVersion,omitempty"` + ID uuid.UUID `json:"id,omitempty"` + Memory string `json:"memory,omitempty"` + Platform string `json:"platform,omitempty"` + PlayFabTitleID string `json:"playFabTitleId,omitempty"` + StorePlatform string `json:"storePlatform,omitempty"` + TreatmentOverrides []string `json:"treatmentOverrides,omitempty"` + Type string `json:"type,omitempty"` +} + +const ( + ApplicationTypeMinecraftPE = "MinecraftPE" +) + +const ( + CapabilityRayTracing = "RayTracing" +) + +const ( + PlatformWindows10 = "Windows10" +) + +const ( + StorePlatformUWPStore = "uwp.store" +) + +const ( + DeviceTypeWindows10 = "Windows10" +) + +type UserConfig struct { + Language language.Tag `json:"language,omitempty"` + LanguageCode language.Tag `json:"languageCode,omitempty"` + RegionCode string `json:"regionCode,omitempty"` + Token string `json:"token,omitempty"` + 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..d422596b --- /dev/null +++ b/minecraft/franchise/token_test.go @@ -0,0 +1,73 @@ +package franchise + +import ( + "context" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/minecraft/auth" + "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/playfab" + "golang.org/x/oauth2" + "golang.org/x/text/language" + "math/rand" + "strconv" + "testing" +) + +func TestToken(t *testing.T) { + d, err := Discover(protocol.CurrentVersion) + if err != nil { + t.Fatalf("discover environments: %s", err) + } + a := new(AuthorizationEnvironment) + if err := d.Environment(a, EnvironmentTypeProduction); err != nil { + t.Fatalf("decode environment: %s", err) + } + + src := test.TokenSource(t, "internal/test/auth.tok", auth.TokenSource, func(old *oauth2.Token) (new *oauth2.Token, err error) { + return auth.RefreshTokenSource(old).Token() + }) + x, err := auth.RequestXBLToken(context.Background(), src, "http://playfab.xboxlive.com/") + if err != nil { + t.Fatalf("error requesting XBL token: %s", err) + } + + identity, err := playfab.Login{ + Title: "20CA2", + CreateAccount: true, + }.WithXBLToken(x).Login() + if err != nil { + t.Fatalf("error logging in to playfab: %s", err) + } + + region, _ := language.English.Region() + + conf := &TokenConfig{ + Device: &DeviceConfig{ + ApplicationType: ApplicationTypeMinecraftPE, + Capabilities: []string{CapabilityRayTracing}, + GameVersion: protocol.CurrentVersion, + ID: uuid.New(), + Memory: strconv.FormatUint(rand.Uint64(), 10), + Platform: PlatformWindows10, + PlayFabTitleID: a.PlayFabTitleID, + StorePlatform: StorePlatformUWPStore, + Type: DeviceTypeWindows10, + }, + User: &UserConfig{ + Language: language.English, + LanguageCode: language.AmericanEnglish, + RegionCode: region.String(), + Token: identity.SessionTicket, + TokenType: TokenTypePlayFab, + }, + Environment: a, + } + + tok, err := conf.Token() + if err != nil { + t.Fatal(err) + } + + t.Logf("%#v", tok) +} diff --git a/minecraft/listener.go b/minecraft/listener.go index 001860bb..3862bd47 100644 --- a/minecraft/listener.go +++ b/minecraft/listener.go @@ -101,6 +101,8 @@ type Listener struct { close chan struct{} key *ecdsa.PrivateKey + + disableEncryption bool } // Listen announces on the local network address. The network is typically "raknet". @@ -131,12 +133,13 @@ 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.Encrypted(), } // Actually start listening. @@ -261,6 +264,8 @@ func (listener *Listener) createConn(netConn net.Conn) { 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/conn.go b/minecraft/nethernet/conn.go new file mode 100644 index 00000000..b14576c9 --- /dev/null +++ b/minecraft/nethernet/conn.go @@ -0,0 +1,231 @@ +package nethernet + +import ( + "bytes" + "errors" + "fmt" + "github.com/pion/ice/v3" + "github.com/pion/webrtc/v4" + "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" + "log/slog" + "net" + "sync" + "sync/atomic" + "time" +) + +type Conn struct { + ice *webrtc.ICETransport + dtls *webrtc.DTLSTransport + sctp *webrtc.SCTPTransport + + // Remote parameters for starting ICE, DTLS, and SCTP transport. + iceParams webrtc.ICEParameters + dtlsParams webrtc.DTLSParameters + sctpCapabilities webrtc.SCTPCapabilities + + candidatesReceived atomic.Uint32 // Total amount of candidates received. + api *webrtc.API // WebRTC API to create new data channels on SCTP transport. + + reliable, unreliable *webrtc.DataChannel // ReliableDataChannel and UnreliableDataChannel + ready chan struct{} // Notifies when reliable and unreliable are ready. + + packets chan []byte + + buf *bytes.Buffer + promisedSegments uint8 + + log *slog.Logger + + id, networkID uint64 +} + +func (c *Conn) Read(b []byte) (n int, err error) { + select { + //case <-c.closed: + // return n, net.ErrClosed + case pk := <-c.packets: + if len(pk) > 0 && pk[0] != 0xfe { + // WORKAROUND: Append batch header, may be this is specific to RakNet? + // ;-; + pk = append([]byte{0xfe}, pk...) + } + return copy(b, pk), nil + } +} + +func (c *Conn) Write(b []byte) (n int, err error) { + select { + //case <-c.closed: + // return n, net.ErrClosed + default: + if len(b) > 0 && b[0] == 0xfe { + // WORKAROUND: Discard batch header, may be this is specific to RakNet? + b = b[1:] + } + + // TODO: Clean up... + if len(b) > maxMessageSize { + segments := uint8(len(b) / maxMessageSize) + if len(b)%maxMessageSize != 0 { + segments++ // If there's a remainder, we need an additional segment. + } + + for i := 0; i < len(b); i += maxMessageSize { + end := i + maxMessageSize + if end > len(b) { + end = len(b) + } + chunk := b[i:end] + if err := c.reliable.Send(append([]byte{segments}, chunk...)); err != nil { + return n, fmt.Errorf("send segment #%d: %w", segments, err) + } + n += len(chunk) + segments-- + } + + // TODO + if segments != 0 { + panic("minecraft/nethernet: Conn: segments != 0") + } + } else { + if err := c.reliable.Send(append([]byte{0}, b...)); err != nil { + return n, err + } + n = len(b) + } + return n, nil + } +} + +func (*Conn) SetDeadline(time.Time) error { + return errors.New("minecraft/nethernet: Conn: not implemented (yet)") +} + +func (*Conn) SetReadDeadline(time.Time) error { + return errors.New("minecraft/nethernet: Conn: not implemented (yet)") +} + +func (*Conn) SetWriteDeadline(time.Time) error { + return errors.New("minecraft/nethernet: Conn: not implemented (yet)") +} + +// LocalAddr currently returns a dummy address. +// TODO: Return something a valid address. +func (c *Conn) LocalAddr() net.Addr { + dummy, _ := net.ResolveUDPAddr("udp", ":19132") + return dummy +} + +// RemoteAddr currently returns a dummy address. +// TODO: Return something a valid address. +func (c *Conn) RemoteAddr() net.Addr { + dummy, _ := net.ResolveUDPAddr("udp", ":19132") + return dummy +} + +func (c *Conn) Close() error { + errs := make([]error, 0, 5) + if c.reliable != nil { + if err := c.reliable.Close(); err != nil { + errs = append(errs, err) + } + } + if c.unreliable != nil { + if err := c.unreliable.Close(); err != nil { + errs = append(errs, err) + } + } + + if err := c.sctp.Stop(); err != nil { + errs = append(errs, err) + } + if err := c.dtls.Stop(); err != nil { + errs = append(errs, err) + } + if err := c.ice.Stop(); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +func (c *Conn) startTransports() error { + c.log.Debug("starting ICE transport") + iceRole := webrtc.ICERoleControlled + if err := c.ice.Start(nil, c.iceParams, &iceRole); err != nil { + return fmt.Errorf("start ICE transport: %w", err) + } + + c.log.Debug("starting DTLS transport") + c.dtlsParams.Role = webrtc.DTLSRoleServer + if err := c.dtls.Start(c.dtlsParams); err != nil { + return fmt.Errorf("start DTLS transport: %w", err) + } + c.log.Debug("starting SCTP transport") + + var once sync.Once + c.sctp.OnDataChannelOpened(func(channel *webrtc.DataChannel) { + switch channel.Label() { + case "ReliableDataChannel": + c.reliable = channel + case "UnreliableDataChannel": + c.unreliable = channel + } + if c.reliable != nil && c.unreliable != nil { + once.Do(func() { + close(c.ready) + }) + } + }) + if err := c.sctp.Start(c.sctpCapabilities); err != nil { + return fmt.Errorf("start SCTP transport: %w", err) + } + + <-c.ready + c.reliable.OnMessage(c.handleRemoteMessage) + return nil +} + +func (c *Conn) handleSignal(signal *Signal) error { + if signal.Type == SignalTypeCandidate { + candidate, err := ice.UnmarshalCandidate(signal.Data) + if err != nil { + return fmt.Errorf("decode candidate: %w", err) + } + protocol, err := webrtc.NewICEProtocol(candidate.NetworkType().NetworkShort()) + if err != nil { + return fmt.Errorf("parse ICE protocol: %w", err) + } + i := &webrtc.ICECandidate{ + Foundation: candidate.Foundation(), + Priority: candidate.Priority(), + Address: candidate.Address(), + Protocol: protocol, + Port: uint16(candidate.Port()), + Component: candidate.Component(), + Typ: webrtc.ICECandidateType(candidate.Type()), + TCPType: candidate.TCPType().String(), + } + + if r := candidate.RelatedAddress(); r != nil { + i.RelatedAddress, i.RelatedPort = r.Address, uint16(r.Port) + } + + if err := c.ice.AddRemoteCandidate(i); err != nil { + return fmt.Errorf("add remote candidate: %w", err) + } + + if c.candidatesReceived.Add(1) == 1 { + c.log.Debug("received first candidate, starting transports") + go func() { + if err := c.startTransports(); err != nil { + c.log.Error("error starting transports", internal.ErrAttr(err)) + } + }() + } + } + return nil +} + +const maxMessageSize = 10000 diff --git a/minecraft/nethernet/credentials.go b/minecraft/nethernet/credentials.go new file mode 100644 index 00000000..2286488d --- /dev/null +++ b/minecraft/nethernet/credentials.go @@ -0,0 +1,12 @@ +package nethernet + +type Credentials struct { + ExpirationInSeconds int `json:"ExpirationInSeconds"` + ICEServers []ICEServer `json:"TurnAuthServers"` +} + +type ICEServer struct { + Username string `json:"Username"` + Password string `json:"Password"` + URLs []string `json:"Urls"` +} diff --git a/minecraft/nethernet/dial.go b/minecraft/nethernet/dial.go new file mode 100644 index 00000000..3d4ba3b3 --- /dev/null +++ b/minecraft/nethernet/dial.go @@ -0,0 +1,158 @@ +package nethernet + +import ( + "context" + "errors" + "fmt" + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v4" + "math/rand" + "strconv" +) + +type Dialer struct { + NetworkID, ConnectionID uint64 + API *webrtc.API +} + +func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Signaling) (*Conn, error) { + if d.NetworkID == 0 { + d.NetworkID = rand.Uint64() + } + if d.ConnectionID == 0 { + d.ConnectionID = rand.Uint64() + } + if d.API == nil { + d.API = webrtc.NewAPI() + } + credentials, err := signaling.Credentials() + if err != nil { + return nil, fmt.Errorf("obtain credentials: %w", err) + } + var gatherOptions webrtc.ICEGatherOptions + if credentials != nil && len(credentials.ICEServers) > 0 { + gatherOptions.ICEServers = make([]webrtc.ICEServer, len(credentials.ICEServers)) + for i, server := range credentials.ICEServers { + gatherOptions.ICEServers[i] = webrtc.ICEServer{ + Username: server.Username, + Credential: server.Password, + CredentialType: webrtc.ICECredentialTypePassword, + URLs: server.URLs, + } + } + } + gatherer, err := d.API.NewICEGatherer(gatherOptions) + if err != nil { + return nil, fmt.Errorf("create ICE gatherer: %w", err) + } + + var ( + candidates []*webrtc.ICECandidate + gatherFinished = make(chan struct{}) + ) + gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + close(gatherFinished) + return + } + candidates = append(candidates, candidate) + }) + if err := gatherer.Gather(); err != nil { + return nil, fmt.Errorf("gather local ICE candidates: %w", err) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-gatherFinished: + ice := d.API.NewICETransport(gatherer) + dtls, err := d.API.NewDTLSTransport(ice, nil) + if err != nil { + return nil, fmt.Errorf("create DTLS transport: %w", err) + } + sctp := d.API.NewSCTPTransport(dtls) + + iceParams, err := ice.GetLocalParameters() + if err != nil { + return nil, fmt.Errorf("obtain local ICE parameters: %w", err) + } + dtlsParams, err := dtls.GetLocalParameters() + if err != nil { + return nil, fmt.Errorf("obtain local DTLS parameters: %w", err) + } + if len(dtlsParams.Fingerprints) == 0 { + return nil, errors.New("local DTLS parameters has no fingerprints") + } + fingerprint := dtlsParams.Fingerprints[0] + sctpCapabilities := sctp.GetCapabilities() + + description := &sdp.SessionDescription{ + Version: 0x0, + Origin: sdp.Origin{ + Username: "-", + SessionID: rand.Uint64(), + SessionVersion: 0x2, + NetworkType: "IN", + AddressType: "IP4", + UnicastAddress: "127.0.0.1", + }, + SessionName: "-", + TimeDescriptions: []sdp.TimeDescription{ + {}, + }, + Attributes: []sdp.Attribute{ + {Key: "group", Value: "BUNDLE 0"}, + {Key: "extmap-allow-mixed", Value: ""}, + {Key: "msid-semantic", Value: " WMS"}, + }, + MediaDescriptions: []*sdp.MediaDescription{ + { + MediaName: sdp.MediaName{ + Media: "application", + Port: sdp.RangedPort{Value: 9}, + Protos: []string{"UDP", "DTLS", "SCTP"}, + Formats: []string{"webrtc-datachannel"}, + }, + ConnectionInformation: &sdp.ConnectionInformation{ + NetworkType: "IN", + AddressType: "IP4", + Address: &sdp.Address{Address: "0.0.0.0"}, + }, + Attributes: []sdp.Attribute{ + {Key: "ice-ufrag", Value: iceParams.UsernameFragment}, + {Key: "ice-pwd", Value: iceParams.Password}, + {Key: "ice-options", Value: "trickle"}, + {Key: "fingerprint", Value: fmt.Sprintf("%s %s", fingerprint.Algorithm, fingerprint.Value)}, + {Key: "setup", Value: "actpass"}, + {Key: "mid", Value: "0"}, + {Key: "sctp-port", Value: "5000"}, + {Key: "max-message-size", Value: strconv.FormatUint(uint64(sctpCapabilities.MaxMessageSize), 10)}, + }, + }, + }, + } + + offer, err := description.Marshal() + if err != nil { + return nil, fmt.Errorf("encode offer: %w", err) + } + if err := signaling.WriteSignal(&Signal{ + Type: SignalTypeOffer, + Data: string(offer), + ConnectionID: d.ConnectionID, + NetworkID: networkID, + }); err != nil { + return nil, fmt.Errorf("signal offer: %w", err) + } + for i, candidate := range candidates { + if err := signaling.WriteSignal(&Signal{ + Type: SignalTypeCandidate, + Data: formatICECandidate(i, candidate, iceParams), + ConnectionID: d.ConnectionID, + NetworkID: networkID, + }); err != nil { + return nil, fmt.Errorf("signal candidate: %w", err) + } + } + } + return nil, nil // TODO: Implement a way to dial. +} diff --git a/minecraft/nethernet/internal/attr.go b/minecraft/nethernet/internal/attr.go new file mode 100644 index 00000000..0a1d146f --- /dev/null +++ b/minecraft/nethernet/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/nethernet/internal/test/.gitignore b/minecraft/nethernet/internal/test/.gitignore new file mode 100644 index 00000000..38d7cba3 --- /dev/null +++ b/minecraft/nethernet/internal/test/.gitignore @@ -0,0 +1 @@ +/auth.tok \ No newline at end of file diff --git a/minecraft/nethernet/internal/test/token_source.go b/minecraft/nethernet/internal/test/token_source.go new file mode 100644 index 00000000..f15943aa --- /dev/null +++ b/minecraft/nethernet/internal/test/token_source.go @@ -0,0 +1,54 @@ +package test + +import ( + "encoding/json" + "fmt" + "golang.org/x/oauth2" + "os" + "testing" +) + +func TokenSource(t *testing.T, path string, src oauth2.TokenSource, hooks ...RefreshTokenFunc) *oauth2.Token { + tok, err := readTokenSource(path, src) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + for _, h := range hooks { + tok, err = h(tok) + if err != nil { + t.Fatalf("error refreshing token: %s", err) + } + } + return tok +} + +type RefreshTokenFunc func(old *oauth2.Token) (new *oauth2.Token, err error) + +func readTokenSource(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/nethernet/listener.go b/minecraft/nethernet/listener.go new file mode 100644 index 00000000..47a21528 --- /dev/null +++ b/minecraft/nethernet/listener.go @@ -0,0 +1,387 @@ +package nethernet + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/pion/logging" + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v4" + "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" + "log/slog" + "math/rand" + "net" + "strconv" + "strings" + "sync" +) + +// TODO: Under in construction! + +type ListenConfig struct { + Log *slog.Logger + API *webrtc.API +} + +func (conf ListenConfig) Listen(networkID uint64, signaling Signaling) (*Listener, error) { + if conf.Log == nil { + conf.Log = slog.Default() + } + if conf.API == nil { + var ( + setting webrtc.SettingEngine + factory = logging.NewDefaultLoggerFactory() + ) + factory.DefaultLogLevel = logging.LogLevelDebug + setting.LoggerFactory = factory + + conf.API = webrtc.NewAPI(webrtc.WithSettingEngine(setting)) + } + l := &Listener{ + conf: conf, + signaling: signaling, + networkID: networkID, + + incoming: make(chan *Conn), + } + var cancel context.CancelCauseFunc + l.ctx, cancel = context.WithCancelCause(context.Background()) + go l.startListening(cancel) + return l, nil +} + +type Listener struct { + conf ListenConfig + + ctx context.Context + signaling Signaling + networkID uint64 + + connections sync.Map + + incoming chan *Conn + once sync.Once +} + +func (l *Listener) Accept() (net.Conn, error) { + select { + case <-l.ctx.Done(): + return nil, context.Cause(l.ctx) + case conn := <-l.incoming: + return conn, nil + } +} + +// Addr currently returns a dummy address. +// TODO: Return something a valid address. +func (l *Listener) Addr() net.Addr { + dummy, _ := net.ResolveUDPAddr("udp", ":19132") + return dummy +} + +// ID returns the network ID of listener. +func (l *Listener) ID() int64 { return int64(l.networkID) } + +// PongData is currently a stub. +// TODO: Do something. +func (l *Listener) PongData([]byte) {} + +func (l *Listener) startListening(cancel context.CancelCauseFunc) { + for { + signal, err := l.signaling.ReadSignal() + if err != nil { + cancel(err) + close(l.incoming) + return + } + switch signal.Type { + case SignalTypeOffer: + err = l.handleOffer(signal) + case SignalTypeCandidate: + err = l.handleCandidate(signal) + default: + l.conf.Log.Debug("received signal for unknown type", "signal", signal) + } + if err != nil { + var s *signalError + if errors.As(err, &s) { + // Additionally, we write a Signal back with SignalTypeError using the code wrapped on it. + if err := l.signaling.WriteSignal(&Signal{ + Type: SignalTypeError, + ConnectionID: signal.ConnectionID, + Data: strconv.FormatUint(uint64(s.code), 10), + NetworkID: signal.NetworkID, + }); err != nil { + l.conf.Log.Error("error signaling error", internal.ErrAttr(err)) + } + } + l.conf.Log.Error("error handling signal", "signal", signal, internal.ErrAttr(err)) + } + } +} + +// handleOffer handles an incoming Signal of SignalTypeOffer. An answer will be +// encoded and the listener will prepare a connection for handling the signals incoming that has the same ID. +func (l *Listener) handleOffer(signal *Signal) error { + d := &sdp.SessionDescription{} + if err := d.UnmarshalString(signal.Data); err != nil { + return wrapSignalError(fmt.Errorf("decode description: %w", err), ErrorCodeFailedToSetRemoteDescription) + } + if len(d.MediaDescriptions) != 1 { + return wrapSignalError(fmt.Errorf("unexpected number of media descriptions: %d, expected 1", len(d.MediaDescriptions)), ErrorCodeFailedToSetRemoteDescription) + } + m := d.MediaDescriptions[0] + + ufrag, ok := m.Attribute("ice-ufrag") + if !ok { + return wrapSignalError(errors.New("missing ice-ufrag attribute"), ErrorCodeFailedToSetRemoteDescription) + } + pwd, ok := m.Attribute("ice-pwd") + if !ok { + return wrapSignalError(errors.New("missing ice-pwd attribute"), ErrorCodeFailedToSetRemoteDescription) + } + + attr, ok := m.Attribute("fingerprint") + if !ok { + return wrapSignalError(errors.New("missing fingerprint attribute"), ErrorCodeFailedToSetRemoteDescription) + } + fingerprint := strings.Split(attr, " ") + if len(fingerprint) != 2 { + return wrapSignalError(fmt.Errorf("invalid fingerprint: %s", attr), ErrorCodeFailedToSetRemoteDescription) + } + fingerprintAlgorithm, fingerprintValue := fingerprint[0], fingerprint[1] + + attr, ok = m.Attribute("max-message-size") + if !ok { + return wrapSignalError(errors.New("missing max-message-size attribute"), ErrorCodeFailedToSetRemoteDescription) + } + maxMessageSize, err := strconv.ParseUint(attr, 10, 32) + if err != nil { + return wrapSignalError(fmt.Errorf("parse max-message-size attribute as uint32: %w", err), ErrorCodeFailedToSetRemoteDescription) + } + + credentials, err := l.signaling.Credentials() + if err != nil { + return wrapSignalError(fmt.Errorf("obtain credentials: %w", err), ErrorCodeSignalingTurnAuthFailed) + } + + var gatherOptions webrtc.ICEGatherOptions + if credentials != nil && len(credentials.ICEServers) > 0 { + gatherOptions.ICEServers = make([]webrtc.ICEServer, len(credentials.ICEServers)) + for i, server := range credentials.ICEServers { + gatherOptions.ICEServers[i] = webrtc.ICEServer{ + Username: server.Username, + Credential: server.Password, + CredentialType: webrtc.ICECredentialTypePassword, + URLs: server.URLs, + } + } + } + + gatherer, err := l.conf.API.NewICEGatherer(gatherOptions) + if err != nil { + return wrapSignalError(fmt.Errorf("create ICE gatherer: %w", err), ErrorCodeFailedToCreatePeerConnection) + } + + var ( + // Local candidates gathered by webrtc.ICEGatherer + candidates []*webrtc.ICECandidate + // Notifies that gathering for local candidates has finished. + gatherFinished = make(chan struct{}) + ) + gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + close(gatherFinished) + return + } + candidates = append(candidates, candidate) + }) + if err := gatherer.Gather(); err != nil { + return wrapSignalError(fmt.Errorf("gather local candidates: %w", err), ErrorCodeFailedToCreatePeerConnection) + } + + select { + case <-l.ctx.Done(): + return nil + case <-gatherFinished: + ice := l.conf.API.NewICETransport(gatherer) + dtls, err := l.conf.API.NewDTLSTransport(ice, nil) + if err != nil { + return wrapSignalError(fmt.Errorf("create DTLS transport: %w", err), ErrorCodeFailedToCreatePeerConnection) + } + sctp := l.conf.API.NewSCTPTransport(dtls) + + iceParams, err := ice.GetLocalParameters() + if err != nil { + return wrapSignalError(fmt.Errorf("obtain local ICE parameters: %w", err), ErrorCodeFailedToCreateAnswer) + } + dtlsParams, err := dtls.GetLocalParameters() + if err != nil { + return wrapSignalError(fmt.Errorf("obtain local DTLS parameters: %w", err), ErrorCodeFailedToCreateAnswer) + } + if len(dtlsParams.Fingerprints) == 0 { + return wrapSignalError(errors.New("local DTLS parameters has no fingerprints"), ErrorCodeFailedToCreateAnswer) + } + sctpCapabilities := sctp.GetCapabilities() + + // Encode an answer using the local parameters! + d = &sdp.SessionDescription{ + Version: 0x0, + Origin: sdp.Origin{ + Username: "-", + SessionID: rand.Uint64(), + SessionVersion: 0x2, + NetworkType: "IN", + AddressType: "IP4", + UnicastAddress: "127.0.0.1", + }, + SessionName: "-", + TimeDescriptions: []sdp.TimeDescription{ + {}, + }, + Attributes: []sdp.Attribute{ + {Key: "group", Value: "BUNDLE 0"}, + {Key: "extmap-allow-mixed", Value: ""}, + {Key: "msid-semantic", Value: " WMS"}, + }, + MediaDescriptions: []*sdp.MediaDescription{ + { + MediaName: sdp.MediaName{ + Media: "application", + Port: sdp.RangedPort{ + Value: 9, + }, + Protos: []string{"UDP", "DTLS", "SCTP"}, + Formats: []string{"webrtc-datachannel"}, + }, + ConnectionInformation: &sdp.ConnectionInformation{ + NetworkType: "IN", + AddressType: "IP4", + Address: &sdp.Address{ + Address: "0.0.0.0", + }, + }, + Attributes: []sdp.Attribute{ + {Key: "ice-ufrag", Value: iceParams.UsernameFragment}, + {Key: "ice-pwd", Value: iceParams.Password}, + {Key: "ice-options", Value: "trickle"}, + {Key: "fingerprint", Value: fmt.Sprintf("%s %s", + dtlsParams.Fingerprints[0].Algorithm, + dtlsParams.Fingerprints[0].Value, + )}, + {Key: "setup", Value: "active"}, + {Key: "mid", Value: "0"}, + {Key: "sctp-port", Value: "5000"}, + {Key: "max-message-size", Value: strconv.FormatUint(uint64(sctpCapabilities.MaxMessageSize), 10)}, + }, + }, + }, + } + answer, err := d.Marshal() + if err != nil { + return wrapSignalError(fmt.Errorf("encode answer: %w", err), ErrorCodeFailedToCreateAnswer) + } + if err := l.signaling.WriteSignal(&Signal{ + Type: SignalTypeAnswer, + ConnectionID: signal.ConnectionID, + Data: string(answer), + NetworkID: signal.NetworkID, + }); err != nil { + return wrapSignalError(fmt.Errorf("signal answer: %w", err), ErrorCodeSignalingFailedToSend) + } + for i, candidate := range candidates { + if err := l.signaling.WriteSignal(&Signal{ + Type: SignalTypeCandidate, + ConnectionID: signal.ConnectionID, + Data: formatICECandidate(i, candidate, iceParams), + NetworkID: signal.NetworkID, + }); err != nil { + return wrapSignalError(fmt.Errorf("signal candidate: %w", err), ErrorCodeSignalingFailedToSend) + } + } + + c := &Conn{ + ice: ice, + dtls: dtls, + sctp: sctp, + + iceParams: webrtc.ICEParameters{ + UsernameFragment: ufrag, + Password: pwd, + }, + dtlsParams: webrtc.DTLSParameters{ + Fingerprints: []webrtc.DTLSFingerprint{ + { + Algorithm: fingerprintAlgorithm, + Value: fingerprintValue, + }, + }, + }, + sctpCapabilities: webrtc.SCTPCapabilities{ + MaxMessageSize: uint32(maxMessageSize), + }, + + api: l.conf.API, // This is mostly unused in server connections. + + ready: make(chan struct{}), + + packets: make(chan []byte), + buf: bytes.NewBuffer(nil), + + log: l.conf.Log, + + id: signal.ConnectionID, + networkID: signal.NetworkID, + } + + l.connections.Store(signal.ConnectionID, c) + go l.prepareConn(c) + + return nil + } +} + +func (l *Listener) prepareConn(conn *Conn) { + // TODO: Cleanup + select { + case <-l.ctx.Done(): + // Quit the goroutine when the listener closes. + return + case <-conn.ready: + // When it is ready, send them into Accept! + l.incoming <- conn + } +} + +// handleCandidate handles an incoming Signal of SignalTypeCandidate. It looks up for a connection that has the same ID, and +// call the [Conn.handleSignal] method, which adds a remote candidate into its ICE transport. +func (l *Listener) handleCandidate(signal *Signal) error { + conn, ok := l.connections.Load(signal.ConnectionID) + if !ok { + return fmt.Errorf("no connection found for ID %d", signal.ConnectionID) + } + return conn.(*Conn).handleSignal(signal) +} + +func (l *Listener) Close() error { + l.once.Do(func() { + + }) + return nil +} + +type signalError struct { + code uint32 + underlying error +} + +func (e *signalError) Error() string { + return fmt.Sprintf("minecraft/nethernet: %s [signaling with code %d]", e.underlying, e.code) +} + +func (e *signalError) Unwrap() error { return e.underlying } + +func wrapSignalError(err error, code uint32) *signalError { + return &signalError{code: code, underlying: err} +} diff --git a/minecraft/nethernet/message.go b/minecraft/nethernet/message.go new file mode 100644 index 00000000..87951d79 --- /dev/null +++ b/minecraft/nethernet/message.go @@ -0,0 +1,38 @@ +package nethernet + +import ( + "fmt" + "github.com/pion/webrtc/v4" + "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" + "io" +) + +func (c *Conn) handleRemoteMessage(message webrtc.DataChannelMessage) { + if err := c.handleMessage(message.Data); err != nil { + c.log.Error("error handling remote message", internal.ErrAttr(err)) + } +} + +func (c *Conn) handleMessage(b []byte) error { + if len(b) < 2 { + return io.ErrUnexpectedEOF + } + segments := b[0] + data := b[1:] + + if c.promisedSegments > 0 && c.promisedSegments-1 != segments { + return fmt.Errorf("invalid promised segments: expected %d, got %d", c.promisedSegments-1, segments) + } + c.promisedSegments = segments + + c.buf.Write(data) + + if c.promisedSegments > 0 { + return nil + } + + c.packets <- c.buf.Bytes() + c.buf.Reset() + + return nil +} diff --git a/minecraft/nethernet/signal.go b/minecraft/nethernet/signal.go new file mode 100644 index 00000000..6da7f89a --- /dev/null +++ b/minecraft/nethernet/signal.go @@ -0,0 +1,162 @@ +package nethernet + +import ( + "bytes" + "fmt" + "github.com/pion/webrtc/v4" + "strconv" + "strings" +) + +// TODO: Improve documentations written in my poor English ;-; +// TODO: We need an implementation of Signaling that is usable under multiple goroutines. +// I want to use one Signaling connection in both Listen and Dial, because it should work. + +type Signaling interface { + ReadSignal() (*Signal, error) + WriteSignal(signal *Signal) error + + // Credentials will currently block until a credentials is received from the signaling service. This is usually + // present in WebSocket signaling connection. A nil *Credentials may be returned if no credentials or + // the implementation is not capable to do that. + Credentials() (*Credentials, error) +} + +const ( + // SignalTypeOffer is sent by client to request a connection to the remote host. Signals that have + // SignalTypeOffer usually has a data of local description of its connection. + SignalTypeOffer = "CONNECTREQUEST" + // SignalTypeAnswer is sent by server to respond to Signals that have SignalTypeOffer. Signals that + // have SignalTypeAnswer usually has a data of local description of the host. + SignalTypeAnswer = "CONNECTRESPONSE" + // SignalTypeCandidate is sent by both server and client to notify a local candidate to + // remote connection. This is usually sent after SignalTypeOffer or SignalTypeAnswer by server/client. + // Signals that have SignalTypeCandidate usually has a data of local candidate gathered with additional + // credentials received from the Signaling implementation. + SignalTypeCandidate = "CANDIDATEADD" + // SignalTypeError is sent by both server and client to notify an error has occurred. + // Signals that have SignalTypeError has a Data of the code of error occurred, which is listed + // on the following constants. + SignalTypeError = "CONNECTERROR" +) + +type Signal struct { + // Type is the type of Signal. It is one of the constants defined above. + Type string + // ConnectionID is the unique ID of the connection that has sent the Signal. + // It is encoded in String as a second segment to identify a connection uniquely. + ConnectionID uint64 + // Data is the actual data of the Signal. + Data string + + // NetworkID is used internally by the implementations of Signaling type + // to reference a remote network with a number. + NetworkID uint64 +} + +func (s *Signal) MarshalText() ([]byte, error) { + return []byte(s.String()), nil +} + +func (s *Signal) UnmarshalText(b []byte) (err error) { + segments := bytes.SplitN(b, []byte{' '}, 3) + if len(segments) != 3 { + return fmt.Errorf("unexpected segmentations: %d", len(segments)) + } + s.Type = string(segments[0]) + s.ConnectionID, err = strconv.ParseUint(string(segments[1]), 10, 64) + if err != nil { + return fmt.Errorf("parse ConnectionID: %w", err) + } + s.Data = string(segments[2]) + return nil +} + +func (s *Signal) String() string { + b := &strings.Builder{} + b.WriteString(s.Type) + b.WriteByte(' ') + b.WriteString(strconv.FormatUint(s.ConnectionID, 10)) + b.WriteByte(' ') + b.WriteString(s.Data) + return b.String() +} + +func formatICECandidate(id int, candidate *webrtc.ICECandidate, iceParams webrtc.ICEParameters) string { + b := &strings.Builder{} + b.WriteString("candidate:") + b.WriteString(candidate.Foundation) + b.WriteByte(' ') + b.WriteByte('1') + b.WriteByte(' ') + b.WriteString("udp") + b.WriteByte(' ') + b.WriteString(strconv.FormatUint(uint64(candidate.Priority), 10)) + b.WriteByte(' ') + b.WriteString(candidate.Address) + b.WriteByte(' ') + b.WriteString(strconv.FormatUint(uint64(candidate.Port), 10)) + b.WriteByte(' ') + b.WriteString("typ") + b.WriteByte(' ') + b.WriteString(candidate.Typ.String()) + b.WriteByte(' ') + if candidate.Typ == webrtc.ICECandidateTypeRelay || candidate.Typ == webrtc.ICECandidateTypeSrflx { + b.WriteString("raddr") + b.WriteByte(' ') + b.WriteString(candidate.RelatedAddress) + b.WriteByte(' ') + b.WriteString("rport") + b.WriteByte(' ') + b.WriteString(strconv.FormatUint(uint64(candidate.RelatedPort), 10)) + b.WriteByte(' ') + } + b.WriteString("generation") + b.WriteByte(' ') + b.WriteByte('0') + b.WriteByte(' ') + b.WriteString("ufrag") + b.WriteByte(' ') + b.WriteString(iceParams.UsernameFragment) + b.WriteByte(' ') + b.WriteString("network-id") + b.WriteByte(' ') + b.WriteString(strconv.Itoa(id)) + b.WriteByte(' ') + b.WriteString("network-cost") + b.WriteByte(' ') + b.WriteByte('0') + return b.String() +} + +// These constants are sent as a data of Signal with SignalTypeError, to notify an error to the remote connection. +// TODO: These codes has been extracted from dedicated server (v1.21.2). We need to properly write a documentation for these constants. +const ( + ErrorCodeNone = iota + ErrorCodeDestinationNotLoggedIn + ErrorCodeNegotiationTimeout + ErrorCodeWrongTransportVersion + ErrorCodeFailedToCreatePeerConnection + ErrorCodeICE + ErrorCodeConnectRequest + ErrorCodeConnectResponse + ErrorCodeCandidateAdd + ErrorCodeInactivityTimeout + ErrorCodeFailedToCreateOffer + ErrorCodeFailedToCreateAnswer + ErrorCodeFailedToSetLocalDescription + ErrorCodeFailedToSetRemoteDescription + ErrorCodeNegotiationTimeoutWaitingForResponse + ErrorCodeNegotiationTimeoutWaitingForAccept + ErrorCodeIncomingConnectionIgnored + ErrorCodeSignalingParsingFailure + ErrorCodeSignalingUnknownError + ErrorCodeSignalingUnicastMessageDeliveryFailed + ErrorCodeSignalingBroadcastDeliveryFailed + ErrorCodeSignalingMessageDeliveryFailed + ErrorCodeSignalingTurnAuthFailed + ErrorCodeSignalingFallbackToBestEffortDelivery + ErrorCodeNoSignalingChannel + ErrorCodeNotLoggedIn + ErrorCodeSignalingFailedToSend +) diff --git a/minecraft/network.go b/minecraft/network.go index e0c8f91c..fc9b47da 100644 --- a/minecraft/network.go +++ b/minecraft/network.go @@ -24,6 +24,10 @@ 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) + + // Encrypted returns a bool indicating whether an encryption has already been done on the Network side, and no + // encryption is needed on Conn side. + Encrypted() bool } // NetworkListener represents a listening connection to a remote server. It is the equivalent of net.Listener, but with extra diff --git a/minecraft/raknet.go b/minecraft/raknet.go index 2412846a..cd45d2a1 100644 --- a/minecraft/raknet.go +++ b/minecraft/raknet.go @@ -24,6 +24,9 @@ func (r RakNet) Listen(address string) (NetworkListener, error) { return raknet.Listen(address) } +// Encrypted ... +func (r RakNet) Encrypted() bool { return false } + // init registers the RakNet network. func init() { RegisterNetwork("raknet", RakNet{}) diff --git a/minecraft/world_test.go b/minecraft/world_test.go new file mode 100644 index 00000000..50e79ba4 --- /dev/null +++ b/minecraft/world_test.go @@ -0,0 +1,299 @@ +package minecraft + +import ( + "context" + "encoding/json" + "fmt" + "github.com/go-gl/mathgl/mgl32" + "github.com/google/uuid" + "github.com/kr/pretty" + "github.com/pion/sdp/v3" + "github.com/sandertv/gophertunnel/minecraft/auth" + "github.com/sandertv/gophertunnel/minecraft/franchise" + "github.com/sandertv/gophertunnel/minecraft/franchise/signaling" + "github.com/sandertv/gophertunnel/minecraft/nethernet" + "net" + "os" + + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/playfab" + "github.com/sandertv/gophertunnel/xsapi" + "github.com/sandertv/gophertunnel/xsapi/mpsd" + "golang.org/x/oauth2" + "golang.org/x/text/language" + "math/rand" + "strconv" + "strings" + "testing" +) + +// TestListen demonstrates a world displayed in the friend list. +func TestWorld(t *testing.T) { + discovery, err := franchise.Discover(protocol.CurrentVersion) + if err != nil { + t.Fatalf("discover: %s", err) + } + a := new(franchise.AuthorizationEnvironment) + if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("decode environment: %s", err) + } + + src := TokenSource(t, "franchise/internal/test/auth.tok", auth.TokenSource, func(old *oauth2.Token) (new *oauth2.Token, err error) { + return auth.RefreshTokenSource(old).Token() + }) + x, err := auth.RequestXBLToken(context.Background(), src, "http://xboxlive.com") + if err != nil { + t.Fatalf("error requesting XBL token: %s", err) + } + playfabXBL, err := auth.RequestXBLToken(context.Background(), src, "http://playfab.xboxlive.com/") + if err != nil { + t.Fatalf("error requesting XBL token: %s", err) + } + + identity, err := playfab.Login{ + Title: "20CA2", + CreateAccount: true, + }.WithXBLToken(playfabXBL).Login() + if err != nil { + t.Fatalf("error logging in to playfab: %s", err) + } + + region, _ := language.English.Region() + + conf := &franchise.TokenConfig{ + Device: &franchise.DeviceConfig{ + ApplicationType: franchise.ApplicationTypeMinecraftPE, + Capabilities: []string{franchise.CapabilityRayTracing}, + GameVersion: protocol.CurrentVersion, + ID: uuid.New(), + Memory: strconv.FormatUint(rand.Uint64(), 10), + Platform: franchise.PlatformWindows10, + PlayFabTitleID: a.PlayFabTitleID, + StorePlatform: franchise.StorePlatformUWPStore, + Type: franchise.DeviceTypeWindows10, + }, + User: &franchise.UserConfig{ + Language: language.English, + LanguageCode: language.AmericanEnglish, + RegionCode: region.String(), + Token: identity.SessionTicket, + TokenType: franchise.TokenTypePlayFab, + }, + Environment: a, + } + + s := new(signaling.Environment) + if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("decode environment: %s", err) + } + sd := signaling.Dialer{ + NetworkID: rand.Uint64(), + } + signalingConn, err := sd.DialContext(context.Background(), tokenConfigSource(func() (*franchise.TokenConfig, error) { + return conf, nil + }), s) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := signalingConn.Close(); err != nil { + t.Errorf("clean up: error closing: %s", err) + } + }) + + var ( + displayClaims = x.AuthorizationToken.DisplayClaims.UserInfo[0] + name = strings.ToUpper(uuid.NewString()) // The name of the session. + ) + custom, err := json.Marshal(map[string]any{ + "Joinability": "joinable_by_friends", + "hostName": displayClaims.GamerTag, + "ownerId": displayClaims.XUID, + "rakNetGUID": "", + "version": "1.21.2", + "levelId": "lhhPZjgNAQA=", + "worldName": name, + "worldType": "Creative", + "protocol": 686, + "MemberCount": 1, + "MaxMemberCount": 8, + "BroadcastSetting": 3, + "LanGame": true, + "isEditorWorld": false, + "TransportLayer": 2, // Zero means RakNet, and two means NetherNet. + "WebRTCNetworkId": sd.NetworkID, + "OnlineCrossPlatformGame": true, + "CrossPlayDisabled": false, + "TitleId": 0, + "SupportedConnections": []map[string]any{ + { + "ConnectionType": 3, + "HostIpAddress": "", + "HostPort": 0, + "NetherNetId": sd.NetworkID, + "WebRTCNetworkId": sd.NetworkID, + "RakNetGUID": "UNASSIGNED_RAKNET_GUID", + }, + }, + }) + if err != nil { + t.Fatalf("error encoding custom properties: %s", err) + } + pub := mpsd.PublishConfig{ + Description: &mpsd.SessionDescription{ + Properties: &mpsd.SessionProperties{ + System: &mpsd.SessionPropertiesSystem{ + JoinRestriction: mpsd.SessionRestrictionFollowed, + ReadRestriction: mpsd.SessionRestrictionFollowed, + }, + Custom: custom, + }, + }, + } + session, err := pub.PublishContext(context.Background(), &tokenSource{ + x: x, + }, mpsd.SessionReference{ + ServiceConfigID: uuid.MustParse("4fc10100-5f7a-4470-899b-280835760c07"), + TemplateName: "MinecraftLobby", + Name: name, + }) + if err != nil { + t.Fatalf("error publishing session: %s", err) + } + t.Cleanup(func() { + if err := session.Close(); err != nil { + t.Errorf("error closing session: %s", err) + } + }) + + t.Logf("Network ID: %d", sd.NetworkID) + t.Logf("Session Name: %q", name) + + RegisterNetwork("nethernet", &network{ + networkID: sd.NetworkID, + signaling: signalingConn, + }) + + l, err := Listen("nethernet", "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Fatal(err) + } + }) + + for { + conn, err := l.Accept() + if err != nil { + t.Fatal(err) + } + c := conn.(*Conn) + _ = c.StartGame(GameData{ + WorldName: "NetherNet", + WorldSeed: 0, + Difficulty: 0, + EntityUniqueID: rand.Int63(), + EntityRuntimeID: rand.Uint64(), + PlayerGameMode: 1, + PlayerPosition: mgl32.Vec3{}, + WorldSpawn: protocol.BlockPos{}, + WorldGameMode: 1, + Time: rand.Int63(), + PlayerPermissions: 2, + }) + } +} + +func TestDecodeOffer(t *testing.T) { + d := &sdp.SessionDescription{} + if err := d.UnmarshalString("v=0\r\no=- 8735254407289596231 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:gMX+\r\na=ice-pwd:4SN4mwDq5k9Q2LwCiMqxacaM\r\na=ice-options:trickle\r\na=fingerprint:sha-256 B2:35:F2:64:66:B3:73:B3:BB:8D:EE:AF:D8:96:6C:29:9C:A9:E8:94:B3:67:E1:B9:77:8C:18:19:EA:29:7D:12\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"); err != nil { + t.Fatal(err) + } + pretty.Println(d) +} + +type network struct { + networkID uint64 + signaling nethernet.Signaling +} + +func (network) DialContext(context.Context, string) (net.Conn, error) { + panic("not implemented (yet)") +} + +func (network) PingContext(context.Context, string) ([]byte, error) { + panic("not implemented (yet)") +} + +func (n network) Listen(string) (NetworkListener, error) { + var c nethernet.ListenConfig + return c.Listen(n.networkID, n.signaling) +} + +func (network) Encrypted() bool { return true } + +// tokenSource is an implementation of xsapi.TokenSource that simply returns a *auth.XBLToken. +type tokenSource struct{ x *auth.XBLToken } + +func (t *tokenSource) Token() (xsapi.Token, error) { + return &token{t.x}, nil +} + +type token struct { + *auth.XBLToken +} + +func (t *token) DisplayClaims() xsapi.DisplayClaims { + return t.AuthorizationToken.DisplayClaims.UserInfo[0] +} + +type tokenConfigSource func() (*franchise.TokenConfig, error) + +func (f tokenConfigSource) TokenConfig() (*franchise.TokenConfig, error) { return f() } + +func TokenSource(t *testing.T, path string, src oauth2.TokenSource, hooks ...RefreshTokenFunc) *oauth2.Token { + tok, err := readTokenSource(path, src) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + for _, h := range hooks { + tok, err = h(tok) + if err != nil { + t.Fatalf("error refreshing token: %s", err) + } + } + return tok +} + +type RefreshTokenFunc func(old *oauth2.Token) (new *oauth2.Token, err error) + +func readTokenSource(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/playfab/catalog/dictionary.go b/playfab/catalog/dictionary.go new file mode 100644 index 00000000..8e00b229 --- /dev/null +++ b/playfab/catalog/dictionary.go @@ -0,0 +1,97 @@ +package catalog + +import ( + "encoding/json" + "errors" + "golang.org/x/text/language" + "sort" + "strings" +) + +type Dictionary[T comparable] struct{ self map[string]T } + +func (dict *Dictionary[T]) Message(tag language.Tag) (zero T) { + msg, ok := dict.Lookup(tag.String()) + if !ok || msg == zero { + return dict.Neutral() + } + return msg +} + +func (dict *Dictionary[T]) Lookup(key string) (zero T, ok bool) { + for compare, msg := range dict.self { + if strings.EqualFold(compare, key) { + return msg, true + } + } + return zero, false +} + +const neutralKey = "NEUTRAL" + +func (dict *Dictionary[T]) Neutral() (zero T) { + for key, msg := range dict.self { + if strings.EqualFold(key, neutralKey) && msg != zero { + return msg + } + } + return +} + +func (dict *Dictionary[T]) Map() map[string]T { return dict.self } + +func (dict *Dictionary[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(dict.self) +} + +func (dict *Dictionary[T]) UnmarshalJSON(b []byte) error { + if dict == nil { + return errors.New("playfab/catalog: cannot unmarshal a nil *Dictionary") + } + return json.Unmarshal(b, &dict.self) +} + +var Languages []language.Tag + +var unsortedLanguages = []string{ + "en-US", + "ja-JP", + "ko-KR", + "ru-RU", + "en-GB", +} + +var languages = []string{ + "hu-HU", + "pl-PL", + "fr-CA", + "nl-NL", + "tr-TR", + "uk-UA", + "zh-CN", + "es-MX", + "id-ConnectionID", + "sk-SK", + "pt-BR", + "sv-SE", + "de-DE", + "fi-FI", + "fr-FR", + "nb-NO", + "bg-BG", + "cs-CZ", + "pt-PT", + "da-DK", + "it-IT", + "el-GR", + "es-ES", + "zh-TW", +} + +func init() { + sort.Strings(languages) + + for _, key := range append(unsortedLanguages, languages...) { + Languages = append(Languages, language.MustParse(key)) + } +} diff --git a/playfab/catalog/item.go b/playfab/catalog/item.go new file mode 100644 index 00000000..ef2cc02d --- /dev/null +++ b/playfab/catalog/item.go @@ -0,0 +1,195 @@ +package catalog + +import ( + "encoding/json" + "github.com/sandertv/gophertunnel/playfab/entity" + "time" +) + +type Item struct { + AlternateIDs []AlternateID `json:"AlternateIds,omitempty"` + ContentType string `json:"ContentType,omitempty"` + Contents []Content `json:"Contents,omitempty"` + CreationDate time.Time `json:"CreationDate,omitempty"` + CreatorEntity entity.Key `json:"CreatorEntity,omitempty"` + DeepLinks []DeepLink `json:"DeepLinks,omitempty"` + DefaultStackID string `json:"DefaultStackId,omitempty"` // new? + Description Dictionary[string] `json:"Description,omitempty"` + DisplayProperties map[string]json.RawMessage `json:"DisplayProperties,omitempty"` + DisplayVersion string `json:"DisplayVersion,omitempty"` + ETag string `json:"ETag,omitempty"` + EndDate time.Time `json:"EndDate,omitempty"` + ID string `json:"Id,omitempty"` + Images []Image `json:"Images,omitempty"` + Hidden *bool `json:"IsHidden,omitempty"` + ItemReferences []ItemReference `json:"ItemReferences,omitempty"` + Keywords Dictionary[*Keyword] `json:"Keywords,omitempty"` + LastModifiedDate time.Time `json:"LastModifiedDate,omitempty"` + Moderation ModerationState `json:"Moderation,omitempty"` + Platforms []string `json:"Platforms,omitempty"` + PriceOptions PriceOptions `json:"PriceOptions,omitempty"` + Rating Rating `json:"Rating,omitempty"` + StartDate time.Time `json:"StartDate,omitempty"` + StoreDetails StoreDetails `json:"StoreDetails,omitempty"` + Tags []string `json:"Tags,omitempty"` + Title Dictionary[string] `json:"Title,omitempty"` + Type string `json:"Type,omitempty"` +} + +type StoreReference struct { + AlternateID AlternateID `json:"AlternateId,omitempty"` + ID string `json:"Id,omitempty"` +} + +type AlternateID struct { + Type string `json:"Type,omitempty"` + Value string `json:"Value,omitempty"` +} + +type Content struct { + ID string `json:"Id,omitempty"` + MaxClientVersion string `json:"MaxClientVersion,omitempty"` + MinClientVersion string `json:"MinClientVersion,omitempty"` + Tags []string `json:"Tags,omitempty"` + Type string `json:"Type,omitempty"` + URL string `json:"Url,omitempty"` +} + +type DeepLink struct { + Platform string `json:"Platform,omitempty"` + URL string `json:"Url,omitempty"` +} + +type Image struct { + ID string `json:"Id,omitempty"` + Tag string `json:"Tag,omitempty"` + Type string `json:"Type,omitempty"` + URL string `json:"Url,omitempty"` +} + +type ItemReference struct { + Amount int `json:"Amount,omitempty"` + ID string `json:"Id,omitempty"` + PriceOptions PriceOptions `json:"PriceOptions,omitempty"` +} + +type PriceOptions []Price + +func (opts PriceOptions) MarshalJSON() ([]byte, error) { + type raw struct { + Prices []Price `json:"Prices,omitempty"` + } + return json.Marshal(raw{Prices: opts}) +} + +func (opts *PriceOptions) UnmarshalJSON(b []byte) error { + var raw struct { + Prices []Price `json:"Prices,omitempty"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + *opts = raw.Prices + return nil +} + +type Price struct { + Amounts []PriceAmount `json:"Amounts,omitempty"` + UnitDurationInSeconds int `json:"UnitDurationInSeconds,omitempty"` +} + +type PriceAmount struct { + Amount int `json:"Amount,omitempty"` + ItemID string `json:"ItemId,omitempty"` +} + +type Keyword []string + +func (k *Keyword) MarshalJSON() ([]byte, error) { + type raw struct { + Values []string `json:"Values,omitempty"` + } + return json.Marshal(raw{Values: *k}) +} + +func (k *Keyword) UnmarshalJSON(b []byte) error { + var raw struct { + Values []string `json:"Values,omitempty"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + *k = raw.Values + return nil +} + +type ModerationState struct { + LastModifiedDate time.Time `json:"LastModifiedDate,omitempty"` + Reason string `json:"Reason,omitempty"` + Status string `json:"Status,omitempty"` +} + +const ( + ModerationStatusApproved string = "Approved" + ModerationStatusAwaitingModeration string = "AwaitingModeration" + ModerationStatusRejected string = "Rejected" + ModerationStatusUnknown string = "Unknown" +) + +type Rating struct { + Average float32 `json:"Average,omitempty"` + Count1Star int `json:"Count1Star,omitempty"` + Count2Star int `json:"Count2Star,omitempty"` + Count3Star int `json:"Count3Star,omitempty"` + Count4Star int `json:"Count4Star,omitempty"` + Count5Star int `json:"Count5Star,omitempty"` + TotalCount int `json:"TotalCount,omitempty"` +} + +type StoreDetails struct { + FilterOptions FilterOptions `json:"FilterOptions,omitempty"` + PriceOptionsOverride PriceOptionsOverride `json:"PriceOptionsOverride,omitempty"` +} + +type FilterOptions struct { + Filter string `json:"Filter,omitempty"` + IncludeAllItems bool `json:"IncludeAllItems,omitempty"` +} + +type PriceOptionsOverride []PriceOverride + +func (opts PriceOptionsOverride) MarshalJSON() ([]byte, error) { + type raw struct { + Prices []PriceOverride `json:"Prices,omitempty"` + } + return json.Marshal(raw{Prices: opts}) +} + +func (opts *PriceOptionsOverride) UnmarshalJSON(b []byte) error { + var raw struct { + Prices []PriceOverride `json:"Prices,omitempty"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + *opts = raw.Prices + return nil +} + +type PriceOverride struct { + Amounts []PriceAmountOverride `json:"Amounts,omitempty"` +} + +type PriceAmountOverride struct { + FixedValue int `json:"FixedValue,omitempty"` + ItemID string `json:"ItemId,omitempty"` + Multiplier int `json:"Multiplier,omitempty"` +} + +const ( + ItemTypeBundle = "bundle" + ItemTypeCatalogItem = "catalogItem" + ItemTypeCurrency = "currency" + ItemTypeStore = "store" + ItemTypeUGC = "ugc" +) diff --git a/playfab/catalog/query.go b/playfab/catalog/query.go new file mode 100644 index 00000000..d5190d83 --- /dev/null +++ b/playfab/catalog/query.go @@ -0,0 +1,26 @@ +package catalog + +import ( + "github.com/sandertv/gophertunnel/playfab/entity" + "github.com/sandertv/gophertunnel/playfab/internal" + "github.com/sandertv/gophertunnel/playfab/title" +) + +type Query struct { + AlternateID *AlternateID `json:"AlternateId,omitempty"` + CustomTags map[string]any `json:"CustomTags,omitempty"` + Entity *entity.Key `json:"Entity,omitempty"` + ID string `json:"Id,omitempty"` +} + +func (q Query) Item(t title.Title, tok *entity.Token) (zero Item, err error) { + res, err := internal.Post[*queryResponse](t, "/Catalog/GetItem", q, tok.SetAuthHeader) + if err != nil { + return zero, err + } + return res.Item, nil +} + +type queryResponse struct { + Item Item `json:"Item,omitempty"` +} diff --git a/playfab/catalog/search_items.go b/playfab/catalog/search_items.go new file mode 100644 index 00000000..11da52d5 --- /dev/null +++ b/playfab/catalog/search_items.go @@ -0,0 +1,63 @@ +package catalog + +import ( + "fmt" + "github.com/sandertv/gophertunnel/playfab/entity" + "github.com/sandertv/gophertunnel/playfab/internal" + "github.com/sandertv/gophertunnel/playfab/title" + "golang.org/x/text/language" + "net/http" + "slices" +) + +type Filter struct { + // Count is the number of returned items included in the SearchResult. The maximum value is 50 and is stored + // to 10 service-side by default. + Count int `json:"Count,omitempty"` + // ContinuationToken is the opaque token used for continuing the query of Search, if any are available. It is + // normally filled from SearchResult.ContinuationToken. + ContinuationToken string `json:"ContinuationToken,omitempty"` + // CustomTags is the optional properties associated with the request. + CustomTags map[string]any `json:"CustomTags,omitempty"` + // Entity is the nullable entity.Key to perform any actions. + Entity *entity.Key `json:"Entity,omitempty"` + // Filter is an OData query for filtering the SearchResult. + Filter string `json:"Filter,omitempty"` + // OrderBy is an OData sort query for sorting the index of SearchResult. Defaulted to relevance. + OrderBy string `json:"OrderBy,omitempty"` + // Term is the string terms to be searched. + Term string `json:"Search,omitempty"` + // Select is an OData selection query for filtering the fields of returned items included in the SearchResult. + Select string `json:"Select,omitempty"` + // Store ... + Store *StoreReference `json:"Store,omitempty"` + + // Language is used as the `Accept-Language` header of the request and is generally used to display + // a localized dictionaries catalog items. It must be one of the supported Languages, otherwise it + // will be ignored by the request hook. + Language language.Tag `json:"-"` +} + +// Search will perform the search query in the catalog using the title. An authorization entity is optionally required in the service-side. +func (f Filter) Search(t title.Title, tok *entity.Token) (*SearchResult, error) { + if f.Count > 50 { + return nil, fmt.Errorf("playfab/catalog: Filter: count must be <= 50, got %d", f.Count) + } + if f.Entity == nil && tok != nil { + f.Entity = &tok.Entity + } + + return internal.Post[*SearchResult](t, "/Catalog/SearchItems", f, func(req *http.Request) { + if tok != nil { + tok.SetAuthHeader(req) + } + if f.Language != language.Und && slices.ContainsFunc(Languages, func(cmp language.Tag) bool { return f.Language == cmp }) { + req.Header.Set("Accept-Language", f.Language.String()) + } + }) +} + +type SearchResult struct { + ContinuationToken string `json:"ContinuationToken,omitempty"` + Items []Item `json:"Items,omitempty"` +} diff --git a/playfab/entity/exchange.go b/playfab/entity/exchange.go new file mode 100644 index 00000000..38f19a56 --- /dev/null +++ b/playfab/entity/exchange.go @@ -0,0 +1,22 @@ +package entity + +import ( + "github.com/sandertv/gophertunnel/playfab/internal" + "github.com/sandertv/gophertunnel/playfab/title" +) + +func (tok *Token) Exchange(t title.Title, id string) (_ *Token, err error) { + r := exchange{ + Entity: Key{ + Type: TypeMasterPlayerAccount, + ID: id, + }, + } + + return internal.Post[*Token](t, "/Authentication/GetEntityToken", r, tok.SetAuthHeader) +} + +type exchange struct { + CustomTags map[string]any `json:"CustomTags,omitempty"` + Entity Key `json:"Entity,omitempty"` +} diff --git a/playfab/entity/token.go b/playfab/entity/token.go new file mode 100644 index 00000000..65c0d10f --- /dev/null +++ b/playfab/entity/token.go @@ -0,0 +1,31 @@ +package entity + +import ( + "net/http" + "time" +) + +type Token struct { + Entity Key `json:"Entity,omitempty"` + Token string `json:"EntityToken,omitempty"` + Expiration time.Time `json:"TokenExpiration,omitempty"` +} + +func (tok *Token) Expired() bool { return time.Now().After(tok.Expiration) } +func (tok *Token) SetAuthHeader(req *http.Request) { req.Header.Set("X-EntityToken", tok.Token) } + +type Key struct { + ID string `json:"Id,omitempty"` + Type Type `json:"Type,omitempty"` +} + +type Type string + +const ( + TypeNamespace Type = "namespace" + TypeTitle Type = "title" + TypeMasterPlayerAccount Type = "master_player_account" + TypeTitlePlayerAccount Type = "title_player_account" + TypeCharacter Type = "character" + TypeGroup Type = "group" +) diff --git a/playfab/entity/token_source.go b/playfab/entity/token_source.go new file mode 100644 index 00000000..66c57022 --- /dev/null +++ b/playfab/entity/token_source.go @@ -0,0 +1,75 @@ +package entity + +import ( + "context" + "fmt" + "github.com/sandertv/gophertunnel/playfab/title" + "sync" + "time" +) + +type TokenSource interface { + Token() (*Token, error) +} + +func ExchangeTokenSource(ctx context.Context, tok *Token, t title.Title, masterID string) TokenSource { + src := &exchangeTokenSource{ + tok: tok, + + ctx: ctx, + title: t, + masterID: masterID, + } + go src.background() + return src +} + +const exchangeInterval = time.Minute * 15 + +type exchangeTokenSource struct { + tok *Token + err error + + mux sync.Mutex + ctx context.Context + title title.Title + masterID string +} + +func (src *exchangeTokenSource) background() { + t := time.NewTicker(exchangeInterval) + defer t.Stop() + for { + select { + case <-t.C: + src.mux.Lock() + src.tok, src.err = src.tok.Exchange(src.title, src.masterID) + if src.err != nil { + src.mux.Unlock() + return + } + src.mux.Unlock() + case <-src.ctx.Done(): + src.mux.Lock() + src.err = src.ctx.Err() + src.mux.Unlock() + } + } +} + +func (src *exchangeTokenSource) Token() (tok *Token, err error) { + src.mux.Lock() + defer src.mux.Unlock() + if src.err != nil { + return nil, fmt.Errorf("exchange token in background: %w", err) + } + + if src.tok.Expired() || src.tok.Entity.Type != TypeMasterPlayerAccount { + tok, err = src.tok.Exchange(src.title, src.masterID) + if err != nil { + return nil, fmt.Errorf("exchange: %w", err) + } + src.tok = tok + } + return src.tok, nil +} diff --git a/playfab/identity.go b/playfab/identity.go new file mode 100644 index 00000000..831d279f --- /dev/null +++ b/playfab/identity.go @@ -0,0 +1,404 @@ +package playfab + +import ( + "encoding/json" + "github.com/sandertv/gophertunnel/playfab/entity" + "github.com/sandertv/gophertunnel/playfab/title" + "time" +) + +type Identity struct { + EntityToken *entity.Token `json:"EntityToken,omitempty"` + ResponseParameters ResponseParameters `json:"InfoResultPayload,omitempty"` + LastLoginTime time.Time `json:"LastLoginTime,omitempty"` + NewlyCreated bool `json:"NewlyCreated,omitempty"` + PlayFabID string `json:"PlayFabId,omitempty"` + SessionTicket string `json:"SessionTicket,omitempty"` + SettingsForUser UserSettings `json:"SettingsForUser,omitempty"` + TreatmentAssignment TreatmentAssignment `json:"TreatmentAssignment,omitempty"` +} + +type ResponseParameters struct { + Account UserAccount `json:"AccountInfo,omitempty"` + CharacterInventories []CharacterInventory `json:"CharacterInventories,omitempty"` + CharacterList []Character `json:"CharacterList,omitempty"` + PlayerProfile PlayerProfile `json:"PlayerProfile,omitempty"` + PlayerStatistics []StatisticValue `json:"PlayerStatistics,omitempty"` + TitleData map[string]json.RawMessage `json:"TitleData,omitempty"` + UserData UserDataRecord `json:"UserData,omitempty"` + UserDataVersion int `json:"UserDataVersion,omitempty"` + UserInventory []ItemInstance `json:"UserInventory,omitempty"` + UserReadOnlyData UserDataRecord `json:"UserReadOnlyData,omitempty"` + UserReadOnlyDataVersion int `json:"UserReadOnlyDataVersion,omitempty"` + UserVirtualCurrency map[string]json.RawMessage `json:"UserVirtualCurrency,omitempty"` + UserVirtualCurrencyRechargeTime VirtualCurrencyRechargeTime `json:"UserVirtualCurrencyRechargeTimes"` +} + +type UserAccount struct { + AndroidDevice UserAndroidDevice `json:"AndroidDeviceInfo,omitempty"` + AppleAccount UserAppleAccount `json:"AppleAccountInfo,omitempty"` + Created time.Time `json:"Created,omitempty"` + CustomID UserCustomID `json:"CustomIdInfo,omitempty"` + Facebook UserFacebook `json:"FacebookInfo,omitempty"` + FacebookInstantGamesID UserFacebookInstantGamesID `json:"FacebookInstantGamesIdInfo,omitempty"` + GameCenter UserGameCenter `json:"GameCenterInfo,omitempty"` + Google UserGoogle `json:"GoogleInfo,omitempty"` + GooglePlayGames UserGooglePlayGames `json:"GooglePlayGamesInfo,omitempty"` + IOSDevice UserIOSDevice `json:"IosDeviceInfo,omitempty"` + Kongregate UserKongregate `json:"KongregateInfo,omitempty"` + NintendoSwitchAccount UserNintendoSwitchAccount `json:"NintendoSwitchAccountInfo,omitempty"` + NintendoSwitchDeviceID UserNintendoSwitchDeviceID `json:"NintendoSwitchDeviceIdInfo,omitempty"` + OpenID UserOpenID `json:"OpenIdInfo,omitempty"` + PlayFabID string `json:"PlayFabId,omitempty"` + Private UserPrivate `json:"PrivateInfo,omitempty"` + PSN UserPSN `json:"PsnInfo,omitempty"` + Steam UserSteam `json:"SteamInfo,omitempty"` + Title UserTitle `json:"TitleInfo,omitempty"` + Twitch UserTwitch `json:"TwitchInfo,omitempty"` + Username string `json:"Username,omitempty"` + Xbox UserXbox `json:"Xbox,omitempty"` +} + +type UserAndroidDevice struct { + DeviceID string `json:"AndroidDeviceId,omitempty"` +} + +type UserAppleAccount struct { + SubjectID string `json:"AppleSubjectId,omitempty"` +} + +type UserCustomID struct { + ID string `json:"CustomId,omitempty"` +} + +type UserFacebook struct { + ID string `json:"FacebookId,omitempty"` + FullName string `json:"FullName,omitempty"` +} + +type UserFacebookInstantGamesID struct { + ID string `json:"FacebookInstantGamesId,omitempty"` +} + +type UserGameCenter struct { + ID string `json:"GameCenterId,omitempty"` +} + +type UserGoogle struct { + Email string `json:"GoogleEmail,omitempty"` + Gender string `json:"GoogleGender,omitempty"` + ID string `json:"GoogleId,omitempty"` + Locale string `json:"GoogleLocale,omitempty"` + Name string `json:"GoogleName,omitempty"` +} + +type UserGooglePlayGames struct { + PlayerAvatarImageURL string `json:"GooglePlayGamesPlayerAvatarImageUrl,omitempty"` + PlayerDisplayName string `json:"GooglePlayGamesPlayerDisplayName,omitempty"` + PlayerID string `json:"GooglePlayGamesPlayerId,omitempty"` +} + +type UserIOSDevice struct { + ID string `json:"IosDeviceId,omitempty"` +} + +type UserKongregate struct { + ID string `json:"KongregateId,omitempty"` + Name string `json:"KongregateName,omitempty"` +} + +type UserNintendoSwitchAccount struct { + SubjectID string `json:"NintendoSwitchAccountSubjectId,omitempty"` +} + +type UserNintendoSwitchDeviceID struct { + ID string `json:"NintendoSwitchDeviceId,omitempty"` +} + +type UserOpenID struct { + ConnectionID string `json:"ConnectionId,omitempty"` + Issuer string `json:"Issuer,omitempty"` + Subject string `json:"Subject,omitempty"` +} + +type UserPrivate struct { + Email string `json:"Email,omitempty"` +} + +type UserPSN struct { + AccountID string `json:"PsnAccountId,omitempty"` + OnlineID string `json:"PsnOnlineId,omitempty"` +} + +type UserSteam struct { + ActivationStatus string `json:"SteamActivationStatus,omitempty"` + Country string `json:"SteamCountry,omitempty"` + Currency string `json:"Currency,omitempty"` + ID string `json:"SteamId,omitempty"` + Name string `json:"SteamName,omitempty"` +} + +const ( + TitleActivationStatusActivatedSteam = "ActivatedSteam" + TitleActivationStatusActivatedTitleKey = "ActivatedTitleKey" + TitleActivationStatusNone = "None" + TitleActivationStatusPendingSteam = "PendingSteam" + TitleActivationStatusRevokedSteam = "RevokedSteam" +) + +type UserTitle struct { + AvatarURL string `json:"AvatarUrl,omitempty"` + Created time.Time `json:"Created,omitempty"` + DisplayName string `json:"DisplayName,omitempty"` + FirstLogin time.Time `json:"FirstLogin,omitempty"` + LastLogin time.Time `json:"LastLogin,omitempty"` + Origination string `json:"Origination,omitempty"` + TitlePlayerAccount entity.Key `json:"TitlePlayerAccount,omitempty"` + Banned bool `json:"isBanned,omitempty"` +} + +const ( + UserOriginationAmazon = "Amazon" + UserOriginationAndroid = "Android" + UserOriginationApple = "Apple" + UserOriginationCustomID = "CustomId" + UserOriginationFacebook = "Facebook" + UserOriginationFacebookInstantGamesID = "FacebookInstantGamesId" + UserOriginationGameCenter = "GameCenter" + UserOriginationGamersFirst = "GamersFirst" + UserOriginationGoogle = "Google" + UserOriginationGooglePlayGames = "GooglePlayGames" + UserOriginationIOS = "IOS" + UserOriginationKongregate = "Kongregate" + UserOriginationLoadTest = "LoadTest" + UserOriginationNintendoSwitchAccount = "NintendoSwitchAccount" + UserOriginationNintendoSwitchDeviceID = "NintendoSwitchDeviceID" + UserOriginationOpenIDConnect = "OpenIdConnect" + UserOriginationOrganic = "Organic" + UserOriginationPSN = "PSN" + UserOriginationParse = "Parse" + UserOriginationServerCustomID = "ServerCustomId" + UserOriginationSteam = "Steam" + UserOriginationTwitch = "Twitch" + UserOriginationUnknown = "Unknown" + UserOriginationXboxLive = "XboxLive" +) + +type UserTwitch struct { + ID string `json:"TwitchId,omitempty"` + UserName string `json:"TwitchUserName,omitempty"` +} + +type UserXbox struct { + UserID string `json:"XboxUserId,omitempty"` + UserSandbox string `json:"XboxUserSandbox,omitempty"` +} + +type CharacterInventory struct { + ID string `json:"CharacterId,omitempty"` + Inventory []ItemInstance `json:"Inventory,omitempty"` +} + +type ItemInstance struct { + Annotation string `json:"Annotation,omitempty"` + BundleContents []string `json:"BundleContents,omitempty"` + BundleParent string `json:"BundleParent,omitempty"` + CatalogVersion string `json:"CatalogVersion,omitempty"` + CustomData map[string]json.RawMessage `json:"CustomData,omitempty"` + DisplayName string `json:"DisplayName,omitempty"` + Expiration time.Time `json:"Expiration,omitempty"` + Class string `json:"ItemClass,omitempty"` + ID string `json:"ItemId,omitempty"` + InstanceID string `json:"ItemInstanceId,omitempty"` + PurchaseDate time.Time `json:"PurchaseDate,omitempty"` + RemainingUses int `json:"RemainingUses,omitempty"` + UnitCurrency string `json:"UnitCurrency,omitempty"` + UnitPrice int `json:"UnitPrice,omitempty"` + UsesIncrementedBy int `json:"UsesIncrementedBy,omitempty"` +} + +type Character struct { + ID string `json:"CharacterId,omitempty"` + Name string `json:"CharacterName,omitempty"` + Type string `json:"CharacterType,omitempty"` +} + +type PlayerProfile struct { + AdCampaignAttributions []AdCampaignAttribution `json:"AdCampaignAttributions,omitempty"` + AvatarURL string `json:"AvatarUrl,omitempty"` + BannedUntil time.Time `json:"BannedUntil,omitempty"` + ContactEmailAddresses []ContactEmailAddress `json:"ContactEmailAddresses,omitempty"` + Created time.Time `json:"Created,omitempty"` + DisplayName string `json:"DisplayName,omitempty"` + ExperimentVariants []string `json:"ExperimentVariants,omitempty"` + LastLogin time.Time `json:"LastLogin,omitempty"` + LinkedAccounts []LinkedPlatformAccount `json:"LinkedAccounts,omitempty"` + Locations []Location `json:"Locations,omitempty"` + Memberships []Membership `json:"Memberships,omitempty"` + Origination IdentityProvider `json:"Origination,omitempty"` + PlayerID string `json:"PlayerId,omitempty"` + PublisherID string `json:"PublisherId,omitempty"` + PushNotificationRegistrations []PushNotificationRegistration `json:"PushNotificationRegistrations,omitempty"` + Statistics []Statistic `json:"Statistics,omitempty"` + Tags []Tag `json:"Tags,omitempty"` + Title title.Title `json:"TitleId,omitempty"` + TotalValueToDateInUSD int `json:"TotalValueToDateInUSD,omitempty"` + ValuesToDates []ValuesToDate `json:"ValuesToDate,omitempty"` +} + +type AdCampaignAttribution struct { + AttributedAt time.Time `json:"AttributedAt,omitempty"` + CampaignID string `json:"CampaignId,omitempty"` + Platform string `json:"Platform,omitempty"` +} + +type ContactEmailAddress struct { + Address string `json:"EmailAddress,omitempty"` + Name string `json:"Name,omitempty"` + VerificationStatus EmailVerificationStatus `json:"VerificationStatus,omitempty"` +} + +type EmailVerificationStatus string + +const ( + EmailVerificationStatusConfirmed EmailVerificationStatus = "Confirmed" + EmailVerificationStatusPending EmailVerificationStatus = "Pending" + EmailVerificationStatusUnverified EmailVerificationStatus = "Unverified" +) + +type LinkedPlatformAccount struct { + Email string `json:"Email,omitempty"` + Platform IdentityProvider `json:"Platform,omitempty"` + PlatformUserID string `json:"PlatformUserId,omitempty"` + Username string `json:"Username,omitempty"` +} + +type IdentityProvider string + +const ( + IdentityProviderAndroidDevice IdentityProvider = "AndroidDevice" + IdentityProviderApple IdentityProvider = "Apple" + IdentityProviderCustom IdentityProvider = "Custom" + IdentityProviderCustomServer IdentityProvider = "CustomServer" + IdentityProviderFacebook IdentityProvider = "Facebook" + IdentityProviderFacebookInstantGames IdentityProvider = "FacebookInstantGames" + IdentityProviderGameCenter IdentityProvider = "GameCenter" + IdentityProviderGameServer IdentityProvider = "GameServer" + IdentityProviderGooglePlay IdentityProvider = "GooglePlay" + IdentityProviderGooglePlayGames IdentityProvider = "GooglePlayerGames" + IdentityProviderIOSDevice IdentityProvider = "IOSDevice" + IdentityProviderKongregate IdentityProvider = "Kongregate" + IdentityProviderNintendoSwitch IdentityProvider = "NintendoSwitch" + IdentityProviderNintendoSwitchAccount IdentityProvider = "NintendoSwitchAccount" + IdentityProviderOpenIDConnect IdentityProvider = "OpenIdConnect" + IdentityProviderPSN IdentityProvider = "PSN" + IdentityProviderPlayFab IdentityProvider = "PlayFab" + IdentityProviderSteam IdentityProvider = "Steam" + IdentityProviderTwitch IdentityProvider = "Twitch" + IdentityProviderUnknown IdentityProvider = "Unknown" + IdentityProviderWindowsHello IdentityProvider = "WindowsHello" + IdentityProviderXboxLive IdentityProvider = "XBoxLive" +) + +type Location struct { + City string `json:"City,omitempty"` + ContinentCode string `json:"ContinentCode,omitempty"` + CountryCode string `json:"CountryCode,omitempty"` + Latitude int `json:"Latitude,omitempty"` + Longitude int `json:"Longitude,omitempty"` +} + +type Membership struct { + Active bool `json:"IsActive,omitempty"` + Expiration time.Time `json:"MembershipExpiration,omitempty"` + ID string `json:"MembershipId,omitempty"` + OverrideExpiration time.Time `json:"OverrideExpiration,omitempty"` + OverrideSet bool `json:"OverrideIsSet,omitempty"` + Subscriptions []Subscription `json:"Subscriptions,omitempty"` +} + +type Subscription struct { + Expiration time.Time `json:"Expiration,omitempty"` + InitialSubscriptionTime time.Time `json:"InitialSubscriptionTime,omitempty"` + Active bool `json:"IsActive,omitempty"` + Status string `json:"Status,omitempty"` + ID string `json:"SubscriptionId,omitempty"` + ItemID string `json:"SubscriptionItemId,omitempty"` + Provider string `json:"SubscriptionProvider,omitempty"` +} + +const ( + SubscriptionStatusBillingError = "BillingError" + SubscriptionStatusCancelled = "Cancelled" + SubscriptionStatusCustomerDidNotAcceptPriceChange = "CustomerDidNotAcceptPriceChange" + SubscriptionStatusFreeTrial = "FreeTrial" + SubscriptionStatusNoError = "NoError" + SubscriptionStatusPaymentPending = "PaymentPending" + SubscriptionStatusProductUnavailable = "ProductUnavailable" + SubscriptionStatusUnknownError = "UnknownError" +) + +type PushNotificationRegistration struct { + NotificationEndpointARN string `json:"NotificationEndpointARN,omitempty"` + Platform string `json:"Platform,omitempty"` +} + +const ( + PushNotificationPlatformApplePushNotificationService = "ApplePushNotificationService" + PushNotificationPlatformGoogleCloudMessaging = "GoogleCloudMessaging" +) + +type Statistic struct { + Name string `json:"Name,omitempty"` + Value int `json:"Value,omitempty"` + Version int `json:"Version,omitempty"` +} + +type Tag struct { + Value string `json:"TagValue,omitempty"` +} + +type ValuesToDate struct { + Currency string `json:"Currency,omitempty"` + TotalValue int `json:"TotalValue,omitempty"` + TotalValueAsDecimal string `json:"TotalValueAsDecimal,omitempty"` +} + +type StatisticValue struct { + Name string `json:"StatisticName"` + Value int `json:"Value,omitempty"` + Version int `json:"Version,omitempty"` +} + +type UserDataRecord struct { + LastUpdated time.Time `json:"LastUpdated,omitempty"` + Permission string `json:"Permission,omitempty"` + Value string `json:"Value,omitempty"` +} + +const ( + UserDataPermissionPrivate = "Private" + UserDataPermissionPublic = "Public" +) + +type VirtualCurrencyRechargeTime struct { + Max int `json:"RechargeMax,omitempty"` + Time time.Time `json:"RechargeTime,omitempty"` + SecondsToRecharge int `json:"SecondsToRecharge,omitempty"` +} + +type UserSettings struct { + GatherDevice bool `json:"GatherDeviceInfo,omitempty"` + GatherFocus bool `json:"GatherFocusInfo,omitempty"` + NeedsAttribution bool `json:"NeedsAttribution,omitempty"` +} + +type TreatmentAssignment struct { + Variables []Variable `json:"Variables,omitempty"` + Variants []string `json:"Variants,omitempty"` +} + +type Variable struct { + Name string `json:"Name,omitempty"` + Value string `json:"Value,omitempty"` +} diff --git a/playfab/internal/body.go b/playfab/internal/body.go new file mode 100644 index 00000000..39a177f4 --- /dev/null +++ b/playfab/internal/body.go @@ -0,0 +1,105 @@ +package internal + +import ( + "strconv" + "strings" +) + +type Result[T any] struct { + StatusCode int `json:"code,omitempty"` + Data T `json:"data,omitempty"` + Status string `json:"status,omitempty"` +} + +type Error struct { + StatusCode int `json:"code,omitempty"` + Type string `json:"error,omitempty"` + Code int `json:"errorCode,omitempty"` + Details map[string][]string `json:"errorDetails,omitempty"` + Message string `json:"errorMessage,omitempty"` + Status string `json:"status,omitempty"` +} + +func (err Error) Error() string { + b := &strings.Builder{} + b.WriteString(errorHeader) + b.WriteByte(errorSeparator) + + b.WriteByte(' ') + b.WriteString(strconv.Itoa(err.Code)) + + if err.Type != "" { + b.WriteByte(' ') + b.WriteByte(errorLeftBracket) + b.WriteString(err.Type) + b.WriteByte(errorRightBracket) + } + if err.Message != "" && err.Message != err.Type { + // In some cases, message are the equal to the type, so we're trimming some unnecessary fields here... + // tl;dl avoid returning `1041 (InvalidRequest): "InvalidRequest"` + b.WriteByte(errorSeparator) + b.WriteByte(' ') + b.WriteString(strconv.Quote(err.Message)) + } + if err.Details != nil { + b.WriteByte(' ') + b.WriteByte(errorLeftBracket) + + var index int + for key, messages := range err.Details { + b.WriteString(strconv.Quote(key)) + b.WriteByte(errorSeparator) + b.WriteByte(' ') + b.WriteByte(errorLeftSquareBracket) + + var elementIndex int + for _, msg := range messages { + b.WriteString(strconv.Quote(msg)) + if elementIndex++; elementIndex < len(messages) { + b.WriteByte(errorBracketSeparator) + b.WriteByte(' ') + } + } + + b.WriteByte(errorRightSquareBracket) + + if index > errorMaxDetails { + b.WriteByte(errorBracketSeparator) + b.WriteByte(' ') + b.WriteString(errorDetailsSuffix) + break + } + + if index++; index < len(err.Details) { + b.WriteByte(errorBracketSeparator) + b.WriteByte(' ') + } + } + b.WriteByte(errorRightBracket) + } + return b.String() +} + +const ( + errorHeader = "playfab" + errorSeparator = ':' + errorBracketSeparator = ',' + + errorLeftBracket = '(' + errorRightBracket = ')' + + errorLeftSquareBracket = '[' + errorRightSquareBracket = ']' + + errorDetailsSuffix = "..." + + errorMaxDetails = 5 + + // playfab: 0001 + // playfab: 0001 (Foo) + // playfab: 0001 (Foo): "..." + // + // playfab: 0001 ("fuga": ["hoge", "huga"]) + // playfab: 0001 (Foo) ("fuga": ["hoge", "huga"]) + // playfab: 0001 (Foo): "..." ("fuga": ["hoge", "huga"]) +) diff --git a/playfab/internal/http.go b/playfab/internal/http.go new file mode 100644 index 00000000..b7e4f7c7 --- /dev/null +++ b/playfab/internal/http.go @@ -0,0 +1,55 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/sandertv/gophertunnel/playfab/title" + "io" + "net/http" +) + +func Post[T any](t title.Title, route string, r any, hooks ...func(req *http.Request)) (zero T, err error) { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(r); err != nil { + return zero, fmt.Errorf("encode: %w", err) + } + req, err := http.NewRequest(http.MethodPost, t.URL(route), buf) + if err != nil { + return zero, fmt.Errorf("make request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + for _, hook := range hooks { + hook(req) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return zero, fmt.Errorf("POST %s: %w", route, err) + } + switch { + case StatusRange(resp.StatusCode, http.StatusOK): + var body Result[T] + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return zero, fmt.Errorf("decode: %w", err) + } + return body.Data, nil + default: + b, err := io.ReadAll(resp.Body) + if err != nil { + return zero, fmt.Errorf("POST %s: %s", route, resp.Status) + } + var body Error + if err := json.Unmarshal(b, &body); err != nil { + return zero, fmt.Errorf("POST %s: %s: %s (%w)", route, resp.Status, b, err) + } + return zero, body + } +} + +func StatusRange(code, region int) bool { + if region%100 != 0 { + panic(fmt.Sprintf("playfab/internal: StatusRange: invalid http status region: %d", region)) + } + return code >= region && code < region+100 +} diff --git a/playfab/login.go b/playfab/login.go new file mode 100644 index 00000000..7ebf06af --- /dev/null +++ b/playfab/login.go @@ -0,0 +1,72 @@ +package playfab + +import ( + "github.com/sandertv/gophertunnel/minecraft/auth" + "github.com/sandertv/gophertunnel/playfab/internal" + "github.com/sandertv/gophertunnel/playfab/title" +) + +type Login struct { + Title title.Title `json:"TitleId,omitempty"` + CreateAccount bool `json:"CreateAccount,omitempty"` + CustomTags map[string]any `json:"CustomTags,omitempty"` + EncryptedRequest []byte `json:"EncryptedRequest,omitempty"` + InfoRequestParameters *RequestParameters `json:"InfoRequestParameters,omitempty"` + PlayerSecret string `json:"PlayerSecret,omitempty"` + XboxToken string `json:"XboxToken,omitempty"` + + Route string `json:"-"` +} + +type RequestParameters struct { + CharacterInventories bool `json:"GetCharacterInventories,omitempty"` + CharacterList bool `json:"GetCharacterList,omitempty"` + PlayerProfile bool `json:"GetPlayerProfile,omitempty"` + PlayerStatistics bool `json:"GetPlayerStatistics,omitempty"` + TitleData bool `json:"GetTitleData,omitempty"` + UserAccountInfo bool `json:"GetUserAccountInfo,omitempty"` + UserData bool `json:"GetUserData,omitempty"` + UserInventory bool `json:"GetUserInventory,omitempty"` + UserReadOnlyData bool `json:"GetUserReadOnlyData,omitempty"` + UserVirtualCurrency bool `json:"GetUserVirtualCurrency,omitempty"` + PlayerStatisticNames []string `json:"PlayerStatisticNames,omitempty"` + ProfileConstraints ProfileConstraints `json:"ProfileConstraints,omitempty"` + TitleDataKeys []string `json:"TitleDataKeys,omitempty"` + UserDataKeys []string `json:"UserDataKeys,omitempty"` + UserReadOnlyDataKeys []string `json:"UserReadOnlyDataKeys,omitempty"` +} + +type ProfileConstraints struct { + ShowAvatarURL bool `json:"ShowAvatarUrl,omitempty"` + ShowBannedUntil bool `json:"ShowBannedUntil,omitempty"` + ShowCampaignAttributions bool `json:"ShowCampaignAttributions,omitempty"` + ShowContactEmailAddresses bool `json:"ShowContactEmailAddresses,omitempty"` + ShowCreated bool `json:"ShowCreated,omitempty"` + ShowDisplayName bool `json:"ShowDisplayName,omitempty"` + ShowExperimentVariants bool `json:"ShowExperimentVariants,omitempty"` + ShowLastLogin bool `json:"ShowLastLogin,omitempty"` + ShowLinkedAccounts bool `json:"ShowLinkedAccounts,omitempty"` + ShowLocations bool `json:"ShowLocations,omitempty"` + ShowMemberships bool `json:"ShowMemberships,omitempty"` + ShowOrigination bool `json:"ShowOrigination,omitempty"` + ShowPushNotificationRegistrations bool `json:"ShowPushNotificationRegistrations,omitempty"` + ShowStatistics bool `json:"ShowStatistics,omitempty"` + ShowTags bool `json:"ShowTags,omitempty"` + ShowTotalValueToDateInUSD bool `json:"ShowTotalValueToDateInUsd,omitempty"` + ShowValuesToDate bool `json:"ShowValuesToDate,omitempty"` +} + +func (l Login) WithXBLToken(x *auth.XBLToken) Login { + if l.Route == "" { + l.Route = "/Client/LoginWithXbox" + } + l.XboxToken = "XBL3.0 x=" + x.AuthorizationToken.DisplayClaims.UserInfo[0].UserHash + ";" + x.AuthorizationToken.Token + return l +} + +func (l Login) Login() (*Identity, error) { + if l.Route == "" { + panic("playfab/login: must provide a method/route") + } + return internal.Post[*Identity](l.Title, l.Route, l) +} diff --git a/playfab/title/title.go b/playfab/title/title.go new file mode 100644 index 00000000..e08e16a9 --- /dev/null +++ b/playfab/title/title.go @@ -0,0 +1,9 @@ +package title + +type Title string + +func (t Title) URL(path string) string { + return "https://" + t.String() + ".playfabapi.com" + path +} + +func (t Title) String() string { return string(t) } diff --git a/playfab/types.go b/playfab/types.go new file mode 100644 index 00000000..d6ecbebe --- /dev/null +++ b/playfab/types.go @@ -0,0 +1,21 @@ +package playfab + +import "github.com/sandertv/gophertunnel/playfab/internal" + +type Body[T any] internal.Result[T] + +type Error = internal.Error + +const ( + ErrorCodeSuccess = iota + ErrorCodeUnknown + ErrorCodeConnectionError + ErrorCodeJSONParseError +) + +const ( + ErrorCodeInvalidRequest = 1071 + ErrorCodeItemNotFound = 1047 + ErrorCodeDatabaseThroughputExceeded = 1113 + NotImplemented = 1515 +) diff --git a/xsapi/internal/attr.go b/xsapi/internal/attr.go new file mode 100644 index 00000000..0a1d146f --- /dev/null +++ b/xsapi/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/xsapi/mpsd/activity.go b/xsapi/mpsd/activity.go new file mode 100644 index 00000000..b7b09b83 --- /dev/null +++ b/xsapi/mpsd/activity.go @@ -0,0 +1,46 @@ +package mpsd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +func (conf PublishConfig) commitActivity(ctx context.Context, ref SessionReference) error { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(map[string]any{ + "type": "activity", + "sessionRef": ref, + "version": 1, + }); err != nil { + return fmt.Errorf("encode request body: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, handlesURL.String(), buf) + if err != nil { + return fmt.Errorf("make request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) + + resp, err := conf.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated: + return nil + default: + return fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) + } +} + +var handlesURL = &url.URL{ + Scheme: "https", + Host: "sessiondirectory.xboxlive.com", + Path: "/handles", +} diff --git a/xsapi/mpsd/commit.go b/xsapi/mpsd/commit.go new file mode 100644 index 00000000..bb887a6e --- /dev/null +++ b/xsapi/mpsd/commit.go @@ -0,0 +1,81 @@ +package mpsd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/google/uuid" + "net/http" + "net/url" + "path" + "strconv" + "time" +) + +func (s *Session) CommitContext(ctx context.Context, d *SessionDescription) (*Commitment, error) { + return s.conf.commit(ctx, s.ref, d) +} + +func (conf PublishConfig) commit(ctx context.Context, ref SessionReference, d *SessionDescription) (*Commitment, error) { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(d); err != nil { + return nil, fmt.Errorf("encode request body: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, ref.URL().String(), buf) + if err != nil { + return nil, fmt.Errorf("make request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) + + resp, err := conf.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated: + var commitment *Commitment + if err := json.NewDecoder(resp.Body).Decode(&commitment); err != nil { + return nil, fmt.Errorf("decode response body: %w", err) + } + return commitment, nil + case http.StatusNoContent: + return nil, nil + default: + return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) + } +} + +type SessionReference struct { + ServiceConfigID uuid.UUID `json:"scid,omitempty"` + TemplateName string `json:"templateName,omitempty"` + Name string `json:"name,omitempty"` +} + +func (ref SessionReference) URL() *url.URL { + return &url.URL{ + Scheme: "https", + Host: "sessiondirectory.xboxlive.com", + Path: path.Join( + "/serviceconfigs/", ref.ServiceConfigID.String(), + "/sessionTemplates/", ref.TemplateName, + "/sessions/", ref.Name, + ), + } +} + +type Commitment struct { + ContractVersion uint32 `json:"contractVersion,omitempty"` + CorrelationID uuid.UUID `json:"correlationId,omitempty"` + SearchHandle uuid.UUID `json:"searchHandle,omitempty"` + Branch uuid.UUID `json:"branch,omitempty"` + ChangeNumber uint64 `json:"changeNumber,omitempty"` + StartTime time.Time `json:"startTime,omitempty"` + NextTimer time.Time `json:"nextTimer,omitempty"` + + *SessionDescription +} + +const contractVersion = 107 diff --git a/xsapi/mpsd/member.go b/xsapi/mpsd/member.go new file mode 100644 index 00000000..0e5ff749 --- /dev/null +++ b/xsapi/mpsd/member.go @@ -0,0 +1,57 @@ +package mpsd + +import ( + "encoding/json" + "github.com/google/uuid" +) + +type MemberDescription struct { + Constants *MemberConstants `json:"constants,omitempty"` + Properties *MemberProperties `json:"properties,omitempty"` + Roles json.RawMessage `json:"roles,omitempty"` +} + +type MemberProperties struct { + System *MemberPropertiesSystem `json:"system,omitempty"` + Custom json.RawMessage `json:"custom,omitempty"` +} + +type MemberPropertiesSystem struct { + Active bool `json:"active,omitempty"` + Ready bool `json:"ready,omitempty"` + Connection uuid.UUID `json:"connection,omitempty"` + Subscription *MemberPropertiesSystemSubscription `json:"subscription,omitempty"` + SecureDeviceAddress []byte `json:"secureDeviceAddress,omitempty"` + InitializationGroup []uint32 `json:"initializationGroup,omitempty"` + Groups []string `json:"groups,omitempty"` + Encounters []string `json:"encounters,omitempty"` + Measurements json.RawMessage `json:"measurements,omitempty"` + ServerMeasurements json.RawMessage `json:"serverMeasurements,omitempty"` +} + +type MemberPropertiesSystemSubscription struct { + ID string `json:"id,omitempty"` + ChangeTypes []string `json:"changeTypes,omitempty"` +} + +const ( + ChangeTypeEverything = "everything" + ChangeTypeHost = "host" + ChangeTypeInitialization = "initialization" + ChangeTypeMatchmakingStatus = "matchmakingStatus" + ChangeTypeMembersList = "membersList" + ChangeTypeMembersStatus = "membersStatus" + ChangeTypeJoinability = "joinability" + ChangeTypeCustomProperty = "customProperty" + ChangeTypeMembersCustomProperty = "membersCustomProperty" +) + +type MemberConstants struct { + System *MemberConstantsSystem `json:"system,omitempty"` + Custom json.RawMessage `json:"custom,omitempty"` +} + +type MemberConstantsSystem struct { + XUID string `json:"xuid,omitempty"` + Initialize bool `json:"initialize,omitempty"` +} diff --git a/xsapi/mpsd/publish.go b/xsapi/mpsd/publish.go new file mode 100644 index 00000000..080ab415 --- /dev/null +++ b/xsapi/mpsd/publish.go @@ -0,0 +1,127 @@ +package mpsd + +import ( + "context" + "encoding/json" + "fmt" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/xsapi" + "github.com/sandertv/gophertunnel/xsapi/rta" + "log/slog" + "net/http" + "strings" +) + +type PublishConfig struct { + RTADialer *rta.Dialer + RTAConn *rta.Conn + + Description *SessionDescription + + Client *http.Client + Logger *slog.Logger +} + +func (conf PublishConfig) PublishContext(ctx context.Context, src xsapi.TokenSource, ref SessionReference) (s *Session, err error) { + if conf.Logger == nil { + conf.Logger = slog.Default() + } + if conf.Client == nil { + conf.Client = &http.Client{} + } + var hasTransport bool + if conf.Client.Transport != nil { + _, hasTransport = conf.Client.Transport.(*xsapi.Transport) + } + if !hasTransport { + conf.Client.Transport = &xsapi.Transport{ + Source: src, + Base: conf.Client.Transport, + } + } + + if conf.RTAConn == nil { + if conf.RTADialer == nil { + conf.RTADialer = &rta.Dialer{} + } + conf.RTAConn, err = conf.RTADialer.DialContext(ctx, src) + if err != nil { + return nil, fmt.Errorf("dial rta: %w", err) + } + } + + tok, err := src.Token() + if err != nil { + return nil, fmt.Errorf("obtain token: %w", err) + } + + sub, err := conf.RTAConn.Subscribe(ctx, resourceURI) + if err != nil { + return nil, fmt.Errorf("subscribe with rta: %w", err) + } + var custom subscription + if err := json.Unmarshal(sub.Custom, &custom); err != nil { + return nil, fmt.Errorf("decode subscription custom: %w", err) + } + + if conf.Description == nil { + conf.Description = &SessionDescription{} + } + if conf.Description.Members == nil { + conf.Description.Members = make(map[string]*MemberDescription, 1) + } + + me, ok := conf.Description.Members["me"] + if !ok { + me = &MemberDescription{} + } + if me.Constants == nil { + me.Constants = &MemberConstants{} + } + if me.Constants.System == nil { + me.Constants.System = &MemberConstantsSystem{} + } + me.Constants.System.Initialize = true + if claimer, ok := tok.(xsapi.DisplayClaimer); ok { + me.Constants.System.XUID = claimer.DisplayClaims().XUID + } + if me.Properties == nil { + me.Properties = &MemberProperties{} + } + if me.Properties.System == nil { + me.Properties.System = &MemberPropertiesSystem{} + } + me.Properties.System.Active = true + me.Properties.System.Connection = custom.ConnectionID + if me.Properties.System.Subscription == nil { + me.Properties.System.Subscription = &MemberPropertiesSystemSubscription{} + } + if me.Properties.System.Subscription.ID == "" { + me.Properties.System.Subscription.ID = strings.ToUpper(uuid.NewString()) + } + me.Properties.System.Subscription.ChangeTypes = []string{ + ChangeTypeEverything, + } + conf.Description.Members["me"] = me + + if _, err := conf.commit(ctx, ref, conf.Description); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + if err := conf.commitActivity(ctx, ref); err != nil { + return nil, fmt.Errorf("commit activity: %w", err) + } + + return &Session{ + ref: ref, + conf: conf, + rta: conf.RTAConn, + log: conf.Logger, + sub: sub, + }, nil +} + +const resourceURI = "https://sessiondirectory.xboxlive.com/connections/" + +type subscription struct { + ConnectionID uuid.UUID `json:"ConnectionId,omitempty"` +} diff --git a/xsapi/mpsd/session.go b/xsapi/mpsd/session.go new file mode 100644 index 00000000..ba104453 --- /dev/null +++ b/xsapi/mpsd/session.go @@ -0,0 +1,141 @@ +package mpsd + +import ( + "context" + "encoding/json" + "github.com/sandertv/gophertunnel/xsapi/rta" + "log/slog" +) + +type Session struct { + ref SessionReference + conf PublishConfig + rta *rta.Conn + sub *rta.Subscription + log *slog.Logger +} + +func (s *Session) Close() error { + if err := s.rta.Unsubscribe(context.Background(), s.sub); err != nil { + s.log.Error("error unsubscribing with RTA", "err", err) + } + _, err := s.CommitContext(context.Background(), &SessionDescription{ + Members: map[string]*MemberDescription{ + "me": nil, + }, + }) + return err +} + +type SessionDescription struct { + Constants *SessionConstants `json:"constants,omitempty"` + RoleTypes json.RawMessage `json:"roleTypes,omitempty"` + Properties *SessionProperties `json:"properties,omitempty"` + Members map[string]*MemberDescription `json:"members,omitempty"` +} + +type SessionProperties struct { + System *SessionPropertiesSystem `json:"system,omitempty"` + Custom json.RawMessage `json:"custom,omitempty"` +} + +type SessionPropertiesSystem struct { + Keywords []string `json:"keywords,omitempty"` + Turn []uint32 `json:"turn,omitempty"` + JoinRestriction SessionRestriction `json:"joinRestriction,omitempty"` + ReadRestriction SessionRestriction `json:"readRestriction,omitempty"` + Closed bool `json:"closed"` + Locked bool `json:"locked,omitempty"` + Matchmaking *SessionPropertiesSystemMatchmaking `json:"matchmaking,omitempty"` + MatchmakingResubmit bool `json:"matchmakingResubmit,omitempty"` + InitializationSucceeded bool `json:"initializationSucceeded,omitempty"` + Host string `json:"host,omitempty"` + ServerConnectionStringCandidates json.RawMessage `json:"serverConnectionStringCandidates,omitempty"` +} + +type SessionPropertiesSystemMatchmaking struct { + TargetSessionConstants json.RawMessage `json:"targetSessionConstants,omitempty"` + ServerConnectionString string `json:"serverConnectionString,omitempty"` +} + +type SessionRestriction string + +const ( + SessionRestrictionNone SessionRestriction = "none" + SessionRestrictionLocal SessionRestriction = "local" + SessionRestrictionFollowed SessionRestriction = "followed" +) + +type SessionConstants struct { + System *SessionConstantsSystem `json:"system,omitempty"` + Custom json.RawMessage `json:"custom,omitempty"` +} + +type SessionConstantsSystem struct { + MaxMembersCount uint32 `json:"maxMembersCount,omitempty"` + Capabilities *SessionCapabilities `json:"capabilities,omitempty"` + Visibility string `json:"visibility,omitempty"` + Initiators []string `json:"initiators,omitempty"` + ReservedRemovalTimeout uint64 `json:"reservedRemovalTimeout,omitempty"` + InactiveRemovalTimeout uint64 `json:"inactiveRemovalTimeout,omitempty"` + ReadyRemovalTimeout uint64 `json:"readyRemovalTimeout,omitempty"` + SessionEmptyTimeout uint64 `json:"sessionEmptyTimeout,omitempty"` + Metrics *SessionConstantsSystemMetrics `json:"metrics,omitempty"` + MemberInitialization *MemberInitialization `json:"memberInitialization,omitempty"` + PeerToPeerRequirements *PeerToPeerRequirements `json:"peerToPeerRequirements,omitempty"` + PeerToHostRequirements *PeerToHostRequirements `json:"peerToHostRequirements,omitempty"` + MeasurementServerAddresses json.RawMessage `json:"measurementServerAddresses,omitempty"` + CloudComputePackage json.RawMessage `json:"cloudComputePackage,omitempty"` +} + +type PeerToHostRequirements struct { + LatencyMaximum uint64 `json:"latencyMaximum,omitempty"` + BandwidthDownMinimum uint64 `json:"bandwidthDownMinimum,omitempty"` + BandwidthUpMinimum uint64 `json:"bandwidthUpMinimum,omitempty"` + HostSelectionMetric string `json:"hostSelectionMetric,omitempty"` +} + +const ( + HostSelectionMetricBandwidthUp = "bandwidthUp" + HostSelectionMetricBandwidthDown = "bandwidthDown" + HostSelectionMetricBandwidth = "bandwidth" + HostSelectionMetricLatency = "latency" +) + +type PeerToPeerRequirements struct { + LatencyMaximum uint64 `json:"latencyMaximum,omitempty"` + BandwidthMinimum uint64 `json:"bandwidthMinimum,omitempty"` +} + +type MemberInitialization struct { + JoinTimeout uint64 `json:"joinTimeout,omitempty"` + MeasurementTimeout uint64 `json:"measurementTimeout,omitempty"` + EvaluationTimeout uint64 `json:"evaluationTimeout,omitempty"` + ExternalEvaluation bool `json:"externalEvaluation,omitempty"` + MembersNeededToStart uint32 `json:"membersNeededToStart,omitempty"` +} + +type SessionConstantsSystemMetrics struct { + Latency bool `json:"latency,omitempty"` + BandwidthDown bool `json:"bandwidthDown,omitempty"` + BandwidthUp bool `json:"bandwidthUp,omitempty"` + Custom bool `json:"custom,omitempty"` +} + +type SessionCapabilities struct { + Connectivity bool `json:"connectivity,omitempty"` + SuppressPresenceActivityCheck bool `json:"suppressPresenceActivityCheck,omitempty"` + Gameplay bool `json:"gameplay,omitempty"` + Large bool `json:"large,omitempty"` + UserAuthorizationStyle bool `json:"userAuthorizationStyle,omitempty"` + ConnectionRequiredForActiveMembers bool `json:"connectionRequiredForActiveMembers,omitempty"` + CrossPlay bool `json:"crossPlay,omitempty"` + Searchable bool `json:"searchable,omitempty"` + HasOwners bool `json:"hasOwners,omitempty"` +} + +const ( + SessionVisibilityPrivate = "private" + SessionVisibilityVisible = "visible" + SessionVisibilityOpen = "open" +) diff --git a/xsapi/rta/conn.go b/xsapi/rta/conn.go new file mode 100644 index 00000000..d4ef753f --- /dev/null +++ b/xsapi/rta/conn.go @@ -0,0 +1,245 @@ +package rta + +import ( + "context" + "encoding/json" + "fmt" + "github.com/sandertv/gophertunnel/xsapi/internal" + "log/slog" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + "sync" + "sync/atomic" +) + +// Conn represents a connection between the real-time activity services. It can +// be established from Dialer with an authorization token that relies on the +// party 'https://xboxlive.com/'. +// +// A Conn controls subscriptions real-timely under a websocket connection. An +// index-specific JSON array is used for the communication. Conn is safe for +// concurrent use in multiple goroutines. +// +// SubscriptionHandlers are useful to handle any events that may occur in the subscriptions +// controlled by Conn, and can be stored atomically to a Conn from Handle. +type Conn struct { + conn *websocket.Conn + + sequences [operationCapacity]atomic.Uint32 + expected [operationCapacity]map[uint32]chan<- *handshake + expectedMu sync.RWMutex + + subscriptions map[uint32]*Subscription + subscriptionsMu sync.RWMutex + + log *slog.Logger + ctx context.Context +} + +// Subscribe attempts to subscribe with the specific resource URI, with the context +// to be used during the handshakes. A Subscription may be returned with fields decoded +// from the service. +func (c *Conn) Subscribe(ctx context.Context, resourceURI string) (*Subscription, error) { + sequence := c.sequences[operationSubscribe].Add(1) + hand, err := c.shake(operationSubscribe, sequence, []any{resourceURI}) + if err != nil { + return nil, err + } + defer c.release(operationSubscribe, sequence) + select { + case h := <-hand: + switch h.status { + case StatusOK: + if len(h.payload) < 2 { + return nil, &OutOfRangeError{ + Payload: h.payload, + Index: 1, + } + } + sub := &Subscription{} + if err := json.Unmarshal(h.payload[0], &sub.ID); err != nil { + return nil, fmt.Errorf("decode subscription ConnectionID: %w", err) + } + sub.Custom = h.payload[1] + + c.subscriptionsMu.Lock() + c.subscriptions[sub.ID] = sub + c.subscriptionsMu.Unlock() + return sub, nil + default: + return nil, unexpectedStatusCode(h.status, h.payload) + } + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.ctx.Done(): + return nil, context.Cause(c.ctx) + } +} + +func (c *Conn) Unsubscribe(ctx context.Context, sub *Subscription) error { + sequence := c.sequences[operationUnsubscribe].Add(1) + hand, err := c.shake(operationUnsubscribe, sequence, []any{sub.ID}) + if err != nil { + return err + } + defer c.release(operationUnsubscribe, sequence) + select { + case h := <-hand: + if h.status != StatusOK { + return unexpectedStatusCode(h.status, h.payload) + } + return nil + case <-ctx.Done(): + return ctx.Err() + case <-c.ctx.Done(): + return context.Cause(c.ctx) + } +} + +// Subscription represents a subscription contracted with the resource URI available through +// the real-time activity service. A Subscription may be contracted via Conn.Subscribe. +type Subscription struct { + ID uint32 + Custom json.RawMessage + + h SubscriptionHandler + mu sync.Mutex +} + +func (s *Subscription) Handle(h SubscriptionHandler) { + s.mu.Lock() + s.h = h + s.mu.Unlock() +} + +func (s *Subscription) handler() SubscriptionHandler { + s.mu.Lock() + defer s.mu.Unlock() + if s.h == nil { + return NopSubscriptionHandler{} + } + return s.h +} + +type SubscriptionHandler interface { + HandleEvent(custom json.RawMessage) +} + +type NopSubscriptionHandler struct{} + +func (NopSubscriptionHandler) HandleEvent(json.RawMessage) {} + +// write attempts to write a JSON array with header and the body. A background context is +// used as no context perceived by the parent goroutine should be used to a websocket method +// to avoid closing the connection if it has cancelled or exceeded a deadline. +func (c *Conn) write(typ uint32, payload []any) error { + return wsjson.Write(context.Background(), c.conn, append([]any{typ}, payload...)) +} + +// read goes as a background goroutine of Conn, reading a JSON array from the websocket +// connection and decoding a header needed to indicate which message should be handled. +// +// read cancels the parent context of Conn with cause from context.CancelCauseFunc, if an +// unrecoverable error has occurred while reading a JSON array from the websocket connection. +func (c *Conn) read(cause context.CancelCauseFunc) { + for { + var payload []json.RawMessage + if err := wsjson.Read(context.Background(), c.conn, &payload); err != nil { + cause(err) + return + } + typ, err := readHeader(payload) + if err != nil { + c.log.Error("error reading header", internal.ErrAttr(err)) + continue + } + go c.handleMessage(typ, payload[1:]) + } +} + +// Close closes the websocket connection with websocket.StatusNormalClosure. +func (c *Conn) Close() error { return c.conn.Close(websocket.StatusNormalClosure, "") } + +// handleMessage handles a message received in read with the type. +func (c *Conn) handleMessage(typ uint32, payload []json.RawMessage) { + switch typ { + case typeSubscribe, typeUnsubscribe: // Subscribe & Unsubscribe handshake response + h, err := readHandshake(payload) + if err != nil { + c.log.Error("error reading handshake response", internal.ErrAttr(err)) + return + } + op := typeToOperation(typ) + c.expectedMu.RLock() + defer c.expectedMu.RUnlock() + hand, ok := c.expected[op][h.sequence] + if !ok { + c.log.Debug("unexpected handshake response", slog.Group("message", "type", typ, "sequence", h.sequence)) + return + } + hand <- h + case typeEvent: + if len(payload) < 2 { + c.log.Debug("event message has no custom") + return + } + var subscriptionID uint32 + if err := json.Unmarshal(payload[0], &subscriptionID); err != nil { + c.log.Error("error decoding subscription ID", internal.ErrAttr(err)) + } + c.subscriptionsMu.Lock() + defer c.subscriptionsMu.Unlock() + sub, ok := c.subscriptions[subscriptionID] + if ok { + go sub.handler().HandleEvent(payload[1]) + } + c.log.Debug("received event", slog.Group("message", "type", typ, "custom", payload[0])) + default: + c.log.Debug("received an unexpected message", slog.Group("message", "type", typ)) + } +} + +type OutOfRangeError struct { + Payload []json.RawMessage + Index int +} + +func (e *OutOfRangeError) Error() string { + return fmt.Sprintf("xsapi/rta: index out of range [%d] with length %d", e.Index, len(e.Payload)) +} + +func readHeader(payload []json.RawMessage) (typ uint32, err error) { + if len(payload) < 1 { + return typ, &OutOfRangeError{ + Payload: payload, + Index: 0, + } + } + return typ, json.Unmarshal(payload[0], &typ) +} + +func readHandshake(payload []json.RawMessage) (*handshake, error) { + if len(payload) < 2 { + return nil, &OutOfRangeError{ + Payload: payload, + Index: 2, + } + } + h := &handshake{} + if err := json.Unmarshal(payload[0], &h.sequence); err != nil { + return nil, fmt.Errorf("decode sequence: %w", err) + } + if err := json.Unmarshal(payload[1], &h.status); err != nil { + return nil, fmt.Errorf("decode status code: %w", err) + } + h.payload = payload[2:] + return h, nil +} + +func unexpectedStatusCode(status int32, payload []json.RawMessage) error { + err := &UnexpectedStatusError{Code: status} + if len(payload) >= 1 { + _ = json.Unmarshal(payload[0], &err.Message) + } + return err +} diff --git a/xsapi/rta/dial.go b/xsapi/rta/dial.go new file mode 100644 index 00000000..ef8f2b3e --- /dev/null +++ b/xsapi/rta/dial.go @@ -0,0 +1,84 @@ +package rta + +import ( + "context" + "github.com/sandertv/gophertunnel/xsapi" + "log/slog" + "net/http" + "nhooyr.io/websocket" + "slices" + "time" +) + +// Dialer represents the options for establishing a Conn with real-time activity services with DialContext or Dial. +type Dialer struct { + Options *websocket.DialOptions + ErrorLog *slog.Logger +} + +// Dial calls DialContext with a 15 seconds timeout. +func (d Dialer) Dial(src xsapi.TokenSource) (*Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + return d.DialContext(ctx, src) +} + +// DialContext establishes a connection with real-time activity service. A context.Context is used to control the +// scene real-timely. An authorization token may be used for configuring an HTTP header to Options. An error may be +// returned during the dial of websocket connection. +func (d Dialer) DialContext(ctx context.Context, src xsapi.TokenSource) (*Conn, error) { + if d.ErrorLog == nil { + d.ErrorLog = slog.Default() + } + if d.Options == nil { + d.Options = &websocket.DialOptions{} + } + if !slices.Contains(d.Options.Subprotocols, subprotocol) { + d.Options.Subprotocols = append(d.Options.Subprotocols, subprotocol) + } + if d.Options.HTTPHeader == nil { + d.Options.HTTPHeader = make(http.Header) + } + + if d.Options.HTTPClient == nil { + d.Options.HTTPClient = &http.Client{} + } + var ( + hasTransport bool + base = d.Options.HTTPClient.Transport + ) + if base != nil { + _, hasTransport = base.(*xsapi.Transport) + } + if !hasTransport { + d.Options.HTTPClient.Transport = &xsapi.Transport{ + Source: src, + Base: base, + } + } + + c, _, err := websocket.Dial(ctx, connectURL, d.Options) + if err != nil { + return nil, err + } + background, cancel := context.WithCancelCause(context.Background()) + conn := &Conn{ + conn: c, + log: d.ErrorLog, + ctx: background, + subscriptions: make(map[uint32]*Subscription), + } + for i := 0; i < cap(conn.expected); i++ { + conn.expected[i] = make(map[uint32]chan<- *handshake) + } + go conn.read(cancel) + return conn, nil +} + +const ( + // connectURL is the URL used to establish a websocket connection with real-time activity services. It is + // generally present at websocket.Dial with other websocket.DialOptions, specifically along with subprotocol. + connectURL = "wss://rta.xboxlive.com/connect" + // subprotocol is the subprotocol used with connectURL, to establish a websocket connection. + subprotocol = "rta.xboxlive.com.V2" +) diff --git a/xsapi/rta/handshake.go b/xsapi/rta/handshake.go new file mode 100644 index 00000000..b54a9dac --- /dev/null +++ b/xsapi/rta/handshake.go @@ -0,0 +1,91 @@ +package rta + +import ( + "encoding/json" + "strconv" + "strings" +) + +type handshake struct { + sequence uint32 + status int32 + payload []json.RawMessage +} + +const ( + typeSubscribe uint32 = iota + 1 + typeUnsubscribe + typeEvent + typeResync +) + +const ( + operationSubscribe uint8 = iota + operationUnsubscribe + operationCapacity // The capacity of expected handshake uses. +) + +func typeToOperation(typ uint32) uint8 { + switch typ { + case typeSubscribe: + return operationSubscribe + case typeUnsubscribe: + return operationUnsubscribe + default: + panic("unreachable") + } +} + +func operationToType(op uint8) uint32 { + switch op { + case operationSubscribe: + return typeSubscribe + case operationUnsubscribe: + return typeUnsubscribe + default: + panic("unreachable") + } +} + +func (c *Conn) shake(op uint8, sequence uint32, payload []any) (<-chan *handshake, error) { + if err := c.write(operationToType(op), append([]any{sequence}, payload...)); err != nil { + return nil, err + } + hand := make(chan *handshake) + c.expectedMu.Lock() + c.expected[op][sequence] = hand + c.expectedMu.Unlock() + return hand, nil +} + +func (c *Conn) release(op uint8, sequence uint32) { + c.expectedMu.Lock() + delete(c.expected[op], sequence) + c.expectedMu.Unlock() +} + +type UnexpectedStatusError struct { + Code int32 + Message string +} + +func (e *UnexpectedStatusError) Error() string { + b := &strings.Builder{} + b.WriteString("rta: code ") + b.WriteString(strconv.FormatInt(int64(e.Code), 10)) + if e.Message != "" { + b.WriteByte(':') + b.WriteByte(' ') + b.WriteString(e.Message) + } + return b.String() +} + +const ( + StatusOK int32 = iota + StatusUnknownResource + StatusSubscriptionLimitReached + StatusNoResourceData + StatusThrottled = 1001 + StatusServiceUnavailable = 1002 +) diff --git a/xsapi/token.go b/xsapi/token.go new file mode 100644 index 00000000..182a8564 --- /dev/null +++ b/xsapi/token.go @@ -0,0 +1,23 @@ +package xsapi + +import ( + "net/http" +) + +type Token interface { + SetAuthHeader(req *http.Request) +} + +type TokenSource interface { + Token() (Token, error) +} + +type DisplayClaimer interface { + DisplayClaims() DisplayClaims +} + +type DisplayClaims struct { + GamerTag string `json:"gtg"` + XUID string `json:"xid"` + UserHash string `json:"uhs"` +} diff --git a/xsapi/transport.go b/xsapi/transport.go new file mode 100644 index 00000000..603a55cb --- /dev/null +++ b/xsapi/transport.go @@ -0,0 +1,58 @@ +package xsapi + +import ( + "errors" + "net/http" +) + +type Transport struct { + Source TokenSource + + 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.Source == nil { + return nil, errors.New("xsapi: Transport's Source is nil") + } + token, err := t.Source.Token() + if err != nil { + return nil, 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/xsapi/xal/token_source.go b/xsapi/xal/token_source.go new file mode 100644 index 00000000..d88912e9 --- /dev/null +++ b/xsapi/xal/token_source.go @@ -0,0 +1,59 @@ +package xal + +import ( + "context" + "fmt" + "github.com/sandertv/gophertunnel/minecraft/auth" + "github.com/sandertv/gophertunnel/xsapi" + "golang.org/x/oauth2" + "sync" +) + +func RefreshTokenSource(underlying oauth2.TokenSource, relyingParty string) xsapi.TokenSource { + return RefreshTokenSourceContext(context.Background(), underlying, relyingParty) +} + +func RefreshTokenSourceContext(ctx context.Context, underlying oauth2.TokenSource, relyingParty string) xsapi.TokenSource { + return &refreshTokenSource{ + underlying: underlying, + + relyingParty: relyingParty, + ctx: ctx, + } +} + +type refreshTokenSource struct { + underlying oauth2.TokenSource + + relyingParty string + ctx context.Context + + 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(r.ctx, r.t, r.relyingParty) + if err != nil { + return nil, fmt.Errorf("request xbox live token: %w", err) + } + } + return &token{r.x}, nil +} + +type token struct { + *auth.XBLToken +} + +func (t *token) DisplayClaims() xsapi.DisplayClaims { + return t.AuthorizationToken.DisplayClaims.UserInfo[0] +} From d02f324ada39675c800b916fd31f660bbfaa1f56 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Mon, 12 Aug 2024 14:56:43 +0900 Subject: [PATCH 02/14] minecraft: Fixed segments not written correctly, implemented a way to disable batching packets --- minecraft/conn.go | 6 +-- minecraft/dial.go | 2 +- minecraft/listener.go | 5 +- minecraft/nethernet/conn.go | 21 +++----- minecraft/nethernet/listener.go | 5 +- minecraft/network.go | 2 + minecraft/protocol/packet/decoder.go | 76 ++++++++++++++++++---------- minecraft/protocol/packet/encoder.go | 56 ++++++++++++++------ minecraft/raknet.go | 3 ++ minecraft/world_test.go | 2 + 10 files changed, 113 insertions(+), 65 deletions(-) diff --git a/minecraft/conn.go b/minecraft/conn.go index a535c1da..fa3fca4e 100644 --- a/minecraft/conn.go +++ b/minecraft/conn.go @@ -149,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, batched bool) *Conn { conn := &Conn{ - enc: packet.NewEncoder(netConn), - dec: packet.NewDecoder(netConn), + enc: packet.NewEncoder(netConn, batched), + dec: packet.NewDecoder(netConn, batched), salt: make([]byte, 16), packets: make(chan *packetData, 8), additional: make(chan packet.Packet, 16), diff --git a/minecraft/dial.go b/minecraft/dial.go index 854c7aff..83ca2543 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.Batched()) conn.pool = conn.proto.Packets(false) conn.identityData = d.IdentityData conn.clientData = d.ClientData diff --git a/minecraft/listener.go b/minecraft/listener.go index 3862bd47..6d8ae4f3 100644 --- a/minecraft/listener.go +++ b/minecraft/listener.go @@ -102,7 +102,7 @@ type Listener struct { key *ecdsa.PrivateKey - disableEncryption bool + disableEncryption, batched bool } // Listen announces on the local network address. The network is typically "raknet". @@ -140,6 +140,7 @@ func (cfg ListenConfig) Listen(network string, address string) (*Listener, error close: make(chan struct{}), key: key, disableEncryption: n.Encrypted(), + batched: n.Batched(), } // Actually start listening. @@ -259,7 +260,7 @@ 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.batched) conn.acceptedProto = append(listener.cfg.AcceptedProtocols, proto{}) conn.compression = listener.cfg.Compression conn.pool = conn.proto.Packets(true) diff --git a/minecraft/nethernet/conn.go b/minecraft/nethernet/conn.go index b14576c9..474ebebe 100644 --- a/minecraft/nethernet/conn.go +++ b/minecraft/nethernet/conn.go @@ -45,11 +45,6 @@ func (c *Conn) Read(b []byte) (n int, err error) { //case <-c.closed: // return n, net.ErrClosed case pk := <-c.packets: - if len(pk) > 0 && pk[0] != 0xfe { - // WORKAROUND: Append batch header, may be this is specific to RakNet? - // ;-; - pk = append([]byte{0xfe}, pk...) - } return copy(b, pk), nil } } @@ -59,11 +54,6 @@ func (c *Conn) Write(b []byte) (n int, err error) { //case <-c.closed: // return n, net.ErrClosed default: - if len(b) > 0 && b[0] == 0xfe { - // WORKAROUND: Discard batch header, may be this is specific to RakNet? - b = b[1:] - } - // TODO: Clean up... if len(b) > maxMessageSize { segments := uint8(len(b) / maxMessageSize) @@ -72,16 +62,17 @@ func (c *Conn) Write(b []byte) (n int, err error) { } for i := 0; i < len(b); i += maxMessageSize { + segments-- + end := i + maxMessageSize if end > len(b) { end = len(b) } - chunk := b[i:end] - if err := c.reliable.Send(append([]byte{segments}, chunk...)); err != nil { - return n, fmt.Errorf("send segment #%d: %w", segments, err) + frag := b[i:end] + if err := c.reliable.Send(append([]byte{segments}, frag...)); err != nil { + return n, fmt.Errorf("write segment #%d: %w", segments, err) } - n += len(chunk) - segments-- + n += len(frag) } // TODO diff --git a/minecraft/nethernet/listener.go b/minecraft/nethernet/listener.go index 47a21528..f3931df1 100644 --- a/minecraft/nethernet/listener.go +++ b/minecraft/nethernet/listener.go @@ -336,14 +336,13 @@ func (l *Listener) handleOffer(signal *Signal) error { } l.connections.Store(signal.ConnectionID, c) - go l.prepareConn(c) + go l.handleConn(c) return nil } } -func (l *Listener) prepareConn(conn *Conn) { - // TODO: Cleanup +func (l *Listener) handleConn(conn *Conn) { select { case <-l.ctx.Done(): // Quit the goroutine when the listener closes. diff --git a/minecraft/network.go b/minecraft/network.go index fc9b47da..6ff9a1c6 100644 --- a/minecraft/network.go +++ b/minecraft/network.go @@ -28,6 +28,8 @@ type Network interface { // Encrypted returns a bool indicating whether an encryption has already been done on the Network side, and no // encryption is needed on Conn side. Encrypted() bool + // Batched returns a bool indicating whether packets should be batched when received/sent. + Batched() bool } // 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..3ec2cfa5 100644 --- a/minecraft/protocol/packet/decoder.go +++ b/minecraft/protocol/packet/decoder.go @@ -25,6 +25,8 @@ type Decoder struct { encrypt *encrypt checkPacketLimit bool + + batched bool } // packetReader is used to read packets immediately instead of copying them in a buffer first. This is a @@ -35,7 +37,7 @@ 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, batched bool) *Decoder { if pr, ok := reader.(packetReader); ok { return &Decoder{checkPacketLimit: true, pr: pr} } @@ -43,6 +45,7 @@ func NewDecoder(reader io.Reader) *Decoder { r: reader, buf: make([]byte, 1024*1024*3), checkPacketLimit: true, + batched: batched, } } @@ -67,8 +70,8 @@ func (decoder *Decoder) DisableBatchPacketLimit() { } const ( - // header is the header of compressed 'batches' from Minecraft. - header = 0xfe + // batchHeader is the batchHeader of compressed 'batches' from Minecraft. + batchHeader = 0xfe // maximumInBatch is the maximum amount of packets that may be found in a batch. If a compressed batch has // more than this amount, decoding will fail. maximumInBatch = 812 @@ -86,20 +89,53 @@ func (decoder *Decoder) Decode() (packets [][]byte, err error) { data, err = decoder.pr.ReadPacket() } if err != nil { - return nil, fmt.Errorf("read batch: %w", err) - } - if len(data) == 0 { - return nil, nil + return nil, fmt.Errorf("read: %w", err) } - if data[0] != header { - return nil, fmt.Errorf("decode batch: invalid header %x, expected %x", data[0], header) + if decoder.batched { + if len(data) == 0 { + return nil, nil + } + if data[0] != batchHeader { + return nil, fmt.Errorf("decode batch: invalid batchHeader %x, expected %x", data[0], batchHeader) + } + data, err = decoder.decodePacket(data[1:]) + if err != nil { + return nil, fmt.Errorf("decode batch: %w", err) + } + + b := bytes.NewBuffer(data) + for b.Len() != 0 { + var length uint32 + if err := protocol.Varuint32(b, &length); err != nil { + return nil, fmt.Errorf("decode batch: read packet length: %w", err) + } + packets = append(packets, b.Next(int(length))) + } + if len(packets) > maximumInBatch && decoder.checkPacketLimit { + return nil, fmt.Errorf("decode batch: number of packets %v exceeds max=%v", len(packets), maximumInBatch) + } + return packets, nil + } else { + data, err = decoder.decodePacket(data) + if err != nil { + return nil, fmt.Errorf("decode single: %w", err) + } + + b := bytes.NewBuffer(data) + var length uint32 + if err := protocol.Varuint32(b, &length); err != nil { + return nil, fmt.Errorf("decode single: read packet single: %w", err) + } + return [][]byte{b.Next(int(length))}, nil } - data = data[1:] +} + +func (decoder *Decoder) decodePacket(data []byte) (packet []byte, err error) { if decoder.encrypt != nil { decoder.encrypt.decrypt(data) if err := decoder.encrypt.verify(data); err != nil { // The packet did not have a correct checksum. - return nil, fmt.Errorf("verify batch: %w", err) + return nil, fmt.Errorf("verify: %w", err) } data = data[:len(data)-8] } @@ -110,25 +146,13 @@ func (decoder *Decoder) Decode() (packets [][]byte, err error) { } else { compression, ok := CompressionByID(uint16(data[0])) if !ok { - return nil, fmt.Errorf("decompress batch: unknown compression algorithm %v", data[0]) + return nil, fmt.Errorf("decompress: unknown compression algorithm %v", data[0]) } data, err = compression.Decompress(data[1:]) if err != nil { - return nil, fmt.Errorf("decompress batch: %w", err) + return nil, fmt.Errorf("decompress: %w", err) } } } - - b := bytes.NewBuffer(data) - for b.Len() != 0 { - var length uint32 - if err := protocol.Varuint32(b, &length); err != nil { - return nil, fmt.Errorf("decode batch: read packet length: %w", err) - } - packets = append(packets, b.Next(int(length))) - } - if len(packets) > maximumInBatch && decoder.checkPacketLimit { - return nil, fmt.Errorf("decode batch: number of packets %v exceeds max=%v", len(packets), maximumInBatch) - } - return packets, nil + return data, nil } diff --git a/minecraft/protocol/packet/encoder.go b/minecraft/protocol/packet/encoder.go index f3ccef8a..16913db3 100644 --- a/minecraft/protocol/packet/encoder.go +++ b/minecraft/protocol/packet/encoder.go @@ -16,12 +16,13 @@ type Encoder struct { compression Compression encrypt *encrypt + batched bool } // 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, batched bool) *Encoder { + return &Encoder{w: w, batched: batched} } // EnableEncryption enables encryption for the Encoder using the secret key bytes passed. Each packet sent @@ -44,40 +45,65 @@ func (encoder *Encoder) Encode(packets [][]byte) error { buf := internal.BufferPool.Get().(*bytes.Buffer) defer func() { // Reset the buffer, so we can return it to the buffer pool safely. - buf.Reset() + if encoder.batched { + buf.Reset() + } internal.BufferPool.Put(buf) }() l := make([]byte, 5) - for _, packet := range packets { - // Each packet is prefixed with a varuint32 specifying the length of the packet. - if err := writeVaruint32(buf, uint32(len(packet)), l); err != nil { - return fmt.Errorf("encode batch: write packet length: %w", err) + if encoder.batched { + for _, packet := range packets { + // Each packet is prefixed with a varuint32 specifying the length of the packet. + if err := writeVaruint32(buf, uint32(len(packet)), l); err != nil { + return fmt.Errorf("encode batch: write packet length: %w", err) + } + if _, err := buf.Write(packet); err != nil { + return fmt.Errorf("encode batch: write packet payload: %w", err) + } + } + if err := encoder.encodePacket(buf.Bytes()); err != nil { + return fmt.Errorf("encode batch: %w", err) } - if _, err := buf.Write(packet); err != nil { - return fmt.Errorf("encode batch: write packet payload: %w", err) + } else { + // Encode packets individually + for _, packet := range packets { + if err := writeVaruint32(buf, uint32(len(packet)), l); err != nil { + return fmt.Errorf("encode single: write packet length: %w", err) + } + if _, err := buf.Write(packet); err != nil { + return fmt.Errorf("encode single: write packet payload: %w", err) + } + + if err := encoder.encodePacket(buf.Bytes()); err != nil { + return fmt.Errorf("encode single: %w", err) + } + buf.Reset() } } + return nil +} - data := buf.Bytes() - prepend := []byte{header} +func (encoder *Encoder) encodePacket(data []byte) error { + var prepend []byte + if encoder.batched { + prepend = []byte{batchHeader} + } if encoder.compression != nil { prepend = append(prepend, byte(encoder.compression.EncodeCompression())) var err error data, err = encoder.compression.Compress(data) if err != nil { - return fmt.Errorf("compress batch: %w", err) + return fmt.Errorf("compress: %w", err) } } data = append(prepend, data...) if encoder.encrypt != nil { - // If the encryption session is not nil, encryption is enabled, meaning we should encrypt the - // compressed data of this packet. data = encoder.encrypt.encrypt(data) } if _, err := encoder.w.Write(data); err != nil { - return fmt.Errorf("write batch: %w", err) + return fmt.Errorf("write: %w", err) } return nil } diff --git a/minecraft/raknet.go b/minecraft/raknet.go index cd45d2a1..205c3e6e 100644 --- a/minecraft/raknet.go +++ b/minecraft/raknet.go @@ -27,6 +27,9 @@ func (r RakNet) Listen(address string) (NetworkListener, error) { // Encrypted ... func (r RakNet) Encrypted() bool { return false } +// Batched ... +func (r RakNet) Batched() bool { return true } + // init registers the RakNet network. func init() { RegisterNetwork("raknet", RakNet{}) diff --git a/minecraft/world_test.go b/minecraft/world_test.go index 50e79ba4..0ea438bc 100644 --- a/minecraft/world_test.go +++ b/minecraft/world_test.go @@ -234,6 +234,8 @@ func (n network) Listen(string) (NetworkListener, error) { func (network) Encrypted() bool { return true } +func (network) Batched() bool { return false } + // tokenSource is an implementation of xsapi.TokenSource that simply returns a *auth.XBLToken. type tokenSource struct{ x *auth.XBLToken } From 14e153ee3d2f0abcf7a05af192fc4994d963e853 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Mon, 12 Aug 2024 15:09:34 +0900 Subject: [PATCH 03/14] minecraft: Add a field for multiplayer setting in GameData, and constants for value present in packet --- minecraft/conn.go | 1 + minecraft/game_data.go | 3 +++ minecraft/protocol/packet/start_game.go | 8 ++++++++ 3 files changed, 12 insertions(+) diff --git a/minecraft/conn.go b/minecraft/conn.go index fa3fca4e..bef8d689 100644 --- a/minecraft/conn.go +++ b/minecraft/conn.go @@ -1063,6 +1063,7 @@ func (conn *Conn) startGame() { CommandsEnabled: true, WorldName: data.WorldName, LANBroadcastEnabled: true, + XBLBroadcastMode: data.GamePublishSetting, PlayerMovementSettings: data.PlayerMovementSettings, WorldGameMode: data.WorldGameMode, Hardcore: data.Hardcore, 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/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 From 0f58f4bd1c4a170856a3e968286923b18fe8c0d4 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Mon, 12 Aug 2024 15:38:12 +0900 Subject: [PATCH 04/14] minecraft/room: Start working on updating world status --- minecraft/room/announce.go | 5 +++ minecraft/room/status.go | 53 +++++++++++++++++++++++++++++++ minecraft/room/status_provider.go | 5 +++ 3 files changed, 63 insertions(+) create mode 100644 minecraft/room/announce.go create mode 100644 minecraft/room/status.go create mode 100644 minecraft/room/status_provider.go diff --git a/minecraft/room/announce.go b/minecraft/room/announce.go new file mode 100644 index 00000000..e647e4c3 --- /dev/null +++ b/minecraft/room/announce.go @@ -0,0 +1,5 @@ +package room + +type Announcer interface { + Announce(status Status) error +} diff --git a/minecraft/room/status.go b/minecraft/room/status.go new file mode 100644 index 00000000..80b703d3 --- /dev/null +++ b/minecraft/room/status.go @@ -0,0 +1,53 @@ +package room + +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 uint32 `json:"MemberCount"` + MaxMemberCount uint32 `json:"MaxMemberCount"` + BroadcastSetting uint32 `json:"BroadcastSetting"` + LanGame bool `json:"LanGame"` + IsEditorWorld bool `json:"isEditorWorld"` + TransportLayer int32 `json:"TransportLayer"` + WebRTCNetworkID uint64 `json:"WebRTCNetworkId"` + 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"` + WebRTCNetworkID uint64 `json:"WebRTCNetworkId"` +} + +const ( + JoinabilityInviteOnly = "invite_only" + JoinabilityJoinableByFriends = "joinable_by_friends" +) + +const ( + WorldTypeCreative = "Creative" +) + +const ( + BroadcastSettingInviteOnly uint32 = iota + 1 + BroadcastSettingFriendsOnly + BroadcastSettingFriendsOfFriends +) + +const ( + TransportLayerRakNet int32 = iota + _ + TransportLayerNetherNet +) diff --git a/minecraft/room/status_provider.go b/minecraft/room/status_provider.go new file mode 100644 index 00000000..5d291e63 --- /dev/null +++ b/minecraft/room/status_provider.go @@ -0,0 +1,5 @@ +package room + +type StatusProvider interface { + RoomStatus() Status +} From 6745821a1cd5dc4c0b0a0b1523cadc741d239b74 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Wed, 14 Aug 2024 16:52:45 +0900 Subject: [PATCH 05/14] minecraft/nethernet: Implement Dial --- minecraft/dial.go | 2 + minecraft/nethernet/conn.go | 198 ++++++++++++++++++--------- minecraft/nethernet/dial.go | 150 ++++++++++++++++++-- minecraft/nethernet/listener.go | 131 +++++++++--------- minecraft/nethernet/message.go | 9 +- minecraft/nethernet/signal.go | 2 +- minecraft/protocol/packet/decoder.go | 14 +- minecraft/world_test.go | 127 ++++++++++++++++- playfab/login.go | 2 +- 9 files changed, 476 insertions(+), 159 deletions(-) diff --git a/minecraft/dial.go b/minecraft/dial.go index 83ca2543..70b1d62b 100644 --- a/minecraft/dial.go +++ b/minecraft/dial.go @@ -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.Encrypted() + defaultIdentityData(&conn.identityData) defaultClientData(address, conn.identityData.DisplayName, &conn.clientData) diff --git a/minecraft/nethernet/conn.go b/minecraft/nethernet/conn.go index 474ebebe..b6db6e8b 100644 --- a/minecraft/nethernet/conn.go +++ b/minecraft/nethernet/conn.go @@ -5,12 +5,14 @@ import ( "errors" "fmt" "github.com/pion/ice/v3" + "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" "log/slog" "net" + "strconv" + "strings" "sync" - "sync/atomic" "time" ) @@ -19,22 +21,21 @@ type Conn struct { dtls *webrtc.DTLSTransport sctp *webrtc.SCTPTransport - // Remote parameters for starting ICE, DTLS, and SCTP transport. - iceParams webrtc.ICEParameters - dtlsParams webrtc.DTLSParameters - sctpCapabilities webrtc.SCTPCapabilities + remote *description - candidatesReceived atomic.Uint32 // Total amount of candidates received. - api *webrtc.API // WebRTC API to create new data channels on SCTP transport. + closeCandidateReceived sync.Once // A sync.Once that closes candidateReceived only once. + candidateReceived chan struct{} // Notifies that a first candidate is received from the other end, and the Conn is ready to start its transports. reliable, unreliable *webrtc.DataChannel // ReliableDataChannel and UnreliableDataChannel - ready chan struct{} // Notifies when reliable and unreliable are ready. packets chan []byte buf *bytes.Buffer promisedSegments uint8 + once sync.Once + closed chan struct{} + log *slog.Logger id, networkID uint64 @@ -42,8 +43,8 @@ type Conn struct { func (c *Conn) Read(b []byte) (n int, err error) { select { - //case <-c.closed: - // return n, net.ErrClosed + case <-c.closed: + return n, net.ErrClosed case pk := <-c.packets: return copy(b, pk), nil } @@ -51,8 +52,8 @@ func (c *Conn) Read(b []byte) (n int, err error) { func (c *Conn) Write(b []byte) (n int, err error) { select { - //case <-c.closed: - // return n, net.ErrClosed + case <-c.closed: + return n, net.ErrClosed default: // TODO: Clean up... if len(b) > maxMessageSize { @@ -116,7 +117,7 @@ func (c *Conn) RemoteAddr() net.Addr { } func (c *Conn) Close() error { - errs := make([]error, 0, 5) + errs := make([]error, 0, 3) if c.reliable != nil { if err := c.reliable.Close(); err != nil { errs = append(errs, err) @@ -128,54 +129,49 @@ func (c *Conn) Close() error { } } - if err := c.sctp.Stop(); err != nil { - errs = append(errs, err) - } - if err := c.dtls.Stop(); err != nil { - errs = append(errs, err) - } - if err := c.ice.Stop(); err != nil { - errs = append(errs, err) - } - - return errors.Join(errs...) + return errors.Join(append(errs, c.closeTransports())...) } -func (c *Conn) startTransports() error { - c.log.Debug("starting ICE transport") - iceRole := webrtc.ICERoleControlled - if err := c.ice.Start(nil, c.iceParams, &iceRole); err != nil { - return fmt.Errorf("start ICE transport: %w", err) - } +func (c *Conn) handleTransports() { + c.reliable.OnMessage(func(msg webrtc.DataChannelMessage) { + if err := c.handleMessage(msg.Data); err != nil { + c.log.Error("error handling remote message", internal.ErrAttr(err)) + } + }) - c.log.Debug("starting DTLS transport") - c.dtlsParams.Role = webrtc.DTLSRoleServer - if err := c.dtls.Start(c.dtlsParams); err != nil { - return fmt.Errorf("start DTLS transport: %w", err) - } - c.log.Debug("starting SCTP transport") - - var once sync.Once - c.sctp.OnDataChannelOpened(func(channel *webrtc.DataChannel) { - switch channel.Label() { - case "ReliableDataChannel": - c.reliable = channel - case "UnreliableDataChannel": - c.unreliable = channel + c.ice.OnConnectionStateChange(func(state webrtc.ICETransportState) { + switch state { + case webrtc.ICETransportStateClosed, webrtc.ICETransportStateDisconnected, webrtc.ICETransportStateFailed: + _ = c.closeTransports() // We need to make sure that all transports has been closed + default: } - if c.reliable != nil && c.unreliable != nil { - once.Do(func() { - close(c.ready) - }) + }) + c.dtls.OnStateChange(func(state webrtc.DTLSTransportState) { + switch state { + case webrtc.DTLSTransportStateClosed, webrtc.DTLSTransportStateFailed: + _ = c.closeTransports() // We need to make sure that all transports has been closed + default: } }) - if err := c.sctp.Start(c.sctpCapabilities); err != nil { - return fmt.Errorf("start SCTP transport: %w", err) - } +} - <-c.ready - c.reliable.OnMessage(c.handleRemoteMessage) - return nil +func (c *Conn) closeTransports() (err error) { + c.once.Do(func() { + errs := make([]error, 0, 3) + + if err := c.sctp.Stop(); err != nil { + errs = append(errs, err) + } + if err := c.dtls.Stop(); err != nil { + errs = append(errs, err) + } + if err := c.ice.Stop(); err != nil { + errs = append(errs, err) + } + err = errors.Join(errs...) + close(c.closed) + }) + return err } func (c *Conn) handleSignal(signal *Signal) error { @@ -207,16 +203,94 @@ func (c *Conn) handleSignal(signal *Signal) error { return fmt.Errorf("add remote candidate: %w", err) } - if c.candidatesReceived.Add(1) == 1 { - c.log.Debug("received first candidate, starting transports") - go func() { - if err := c.startTransports(); err != nil { - c.log.Error("error starting transports", internal.ErrAttr(err)) - } - }() - } + c.closeCandidateReceived.Do(func() { + close(c.candidateReceived) + }) } return nil } const maxMessageSize = 10000 + +func parseDescription(d *sdp.SessionDescription) (*description, error) { + if len(d.MediaDescriptions) != 1 { + return nil, fmt.Errorf("unexpected number of media descriptions: %d, expected 1", len(d.MediaDescriptions)) + } + m := d.MediaDescriptions[0] + + ufrag, ok := m.Attribute("ice-ufrag") + if !ok { + return nil, errors.New("missing ice-ufrag attribute") + } + pwd, ok := m.Attribute("ice-pwd") + if !ok { + return nil, errors.New("missing ice-pwd attribute") + } + + attr, ok := m.Attribute("fingerprint") + if !ok { + return nil, errors.New("missing fingerprint attribute") + } + fingerprint := strings.Split(attr, " ") + if len(fingerprint) != 2 { + return nil, fmt.Errorf("invalid fingerprint: %s", attr) + } + fingerprintAlgorithm, fingerprintValue := fingerprint[0], fingerprint[1] + + attr, ok = m.Attribute("max-message-size") + if !ok { + return nil, errors.New("missing max-message-size attribute") + } + maxMessageSize, err := strconv.ParseUint(attr, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse max-message-size attribute as uint32: %w", err) + } + + return &description{ + ice: webrtc.ICEParameters{ + UsernameFragment: ufrag, + Password: pwd, + }, + dtls: webrtc.DTLSParameters{ + Fingerprints: []webrtc.DTLSFingerprint{ + { + Algorithm: fingerprintAlgorithm, + Value: fingerprintValue, + }, + }, + }, + sctp: webrtc.SCTPCapabilities{ + MaxMessageSize: uint32(maxMessageSize), + }, + }, nil +} + +type description struct { + ice webrtc.ICEParameters + dtls webrtc.DTLSParameters + sctp webrtc.SCTPCapabilities +} + +func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc.SCTPTransport, d *description, log *slog.Logger, id, networkID uint64) *Conn { + return &Conn{ + ice: ice, + dtls: dtls, + sctp: sctp, + + remote: d, + + candidateReceived: make(chan struct{}, 1), + + packets: make(chan []byte), + buf: bytes.NewBuffer(nil), + + closed: make(chan struct{}, 1), + + log: log.With(slog.Group("connection", + slog.Uint64("id", id), + slog.Uint64("networkID", networkID))), + + id: id, + networkID: networkID, + } +} diff --git a/minecraft/nethernet/dial.go b/minecraft/nethernet/dial.go index 3d4ba3b3..baa0b463 100644 --- a/minecraft/nethernet/dial.go +++ b/minecraft/nethernet/dial.go @@ -4,8 +4,11 @@ import ( "context" "errors" "fmt" + "github.com/pion/logging" "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" + "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" + "log/slog" "math/rand" "strconv" ) @@ -13,6 +16,7 @@ import ( type Dialer struct { NetworkID, ConnectionID uint64 API *webrtc.API + Log *slog.Logger } func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Signaling) (*Conn, error) { @@ -23,11 +27,21 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig d.ConnectionID = rand.Uint64() } if d.API == nil { - d.API = webrtc.NewAPI() + var ( + setting webrtc.SettingEngine + factory = logging.NewDefaultLoggerFactory() + ) + factory.DefaultLogLevel = logging.LogLevelDebug + setting.LoggerFactory = factory + + d.API = webrtc.NewAPI(webrtc.WithSettingEngine(setting)) + } + if d.Log == nil { + d.Log = slog.Default() } credentials, err := signaling.Credentials() if err != nil { - return nil, fmt.Errorf("obtain credentials: %w", err) + return nil, wrapSignalError(fmt.Errorf("obtain credentials: %w", err), ErrorCodeFailedToCreatePeerConnection) } var gatherOptions webrtc.ICEGatherOptions if credentials != nil && len(credentials.ICEServers) > 0 { @@ -43,7 +57,7 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig } gatherer, err := d.API.NewICEGatherer(gatherOptions) if err != nil { - return nil, fmt.Errorf("create ICE gatherer: %w", err) + return nil, wrapSignalError(fmt.Errorf("create ICE gatherer: %w", err), ErrorCodeFailedToCreatePeerConnection) } var ( @@ -58,7 +72,7 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig candidates = append(candidates, candidate) }) if err := gatherer.Gather(); err != nil { - return nil, fmt.Errorf("gather local ICE candidates: %w", err) + return nil, wrapSignalError(fmt.Errorf("gather local candidates: %w", err), ErrorCodeFailedToCreatePeerConnection) } select { case <-ctx.Done(): @@ -67,24 +81,24 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig ice := d.API.NewICETransport(gatherer) dtls, err := d.API.NewDTLSTransport(ice, nil) if err != nil { - return nil, fmt.Errorf("create DTLS transport: %w", err) + return nil, wrapSignalError(fmt.Errorf("create DTLS transport: %w", err), ErrorCodeFailedToCreatePeerConnection) } sctp := d.API.NewSCTPTransport(dtls) iceParams, err := ice.GetLocalParameters() if err != nil { - return nil, fmt.Errorf("obtain local ICE parameters: %w", err) + return nil, wrapSignalError(fmt.Errorf("obtain local ICE parameters: %w", err), ErrorCodeFailedToCreatePeerConnection) } dtlsParams, err := dtls.GetLocalParameters() if err != nil { - return nil, fmt.Errorf("obtain local DTLS parameters: %w", err) + return nil, wrapSignalError(fmt.Errorf("obtain local DTLS parameters: %w", err), ErrorCodeFailedToCreateAnswer) } if len(dtlsParams.Fingerprints) == 0 { - return nil, errors.New("local DTLS parameters has no fingerprints") + return nil, wrapSignalError(errors.New("local DTLS parameters has no fingerprints"), ErrorCodeFailedToCreateAnswer) } - fingerprint := dtlsParams.Fingerprints[0] sctpCapabilities := sctp.GetCapabilities() + // Encode an offer using the local parameters! description := &sdp.SessionDescription{ Version: 0x0, Origin: sdp.Origin{ @@ -121,7 +135,10 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig {Key: "ice-ufrag", Value: iceParams.UsernameFragment}, {Key: "ice-pwd", Value: iceParams.Password}, {Key: "ice-options", Value: "trickle"}, - {Key: "fingerprint", Value: fmt.Sprintf("%s %s", fingerprint.Algorithm, fingerprint.Value)}, + {Key: "fingerprint", Value: fmt.Sprintf("%s %s", + dtlsParams.Fingerprints[0].Algorithm, + dtlsParams.Fingerprints[0].Value, + )}, {Key: "setup", Value: "actpass"}, {Key: "mid", Value: "0"}, {Key: "sctp-port", Value: "5000"}, @@ -133,7 +150,7 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig offer, err := description.Marshal() if err != nil { - return nil, fmt.Errorf("encode offer: %w", err) + return nil, wrapSignalError(fmt.Errorf("encode offer: %w", err), ErrorCodeFailedToCreateAnswer) } if err := signaling.WriteSignal(&Signal{ Type: SignalTypeOffer, @@ -141,7 +158,8 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig ConnectionID: d.ConnectionID, NetworkID: networkID, }); err != nil { - return nil, fmt.Errorf("signal offer: %w", err) + // I don't think the error code will be signaled back to the remote connection, but just in case. + return nil, wrapSignalError(fmt.Errorf("signal offer: %w", err), ErrorCodeSignalingFailedToSend) } for i, candidate := range candidates { if err := signaling.WriteSignal(&Signal{ @@ -150,9 +168,113 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig ConnectionID: d.ConnectionID, NetworkID: networkID, }); err != nil { - return nil, fmt.Errorf("signal candidate: %w", err) + // I don't think the error code will be signaled back to the remote connection, but just in case. + return nil, wrapSignalError(fmt.Errorf("signal candidate: %w", err), ErrorCodeSignalingFailedToSend) + } + } + + signals := make(chan *Signal) + go d.notifySignals(ctx, d.ConnectionID, networkID, signaling, signals) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case signal := <-signals: + if signal.Type != SignalTypeAnswer { + return nil, fmt.Errorf("received signal for non-answer: %s", signal.String()) + } + + description = &sdp.SessionDescription{} + if err := description.UnmarshalString(signal.Data); err != nil { + return nil, fmt.Errorf("decode answer: %w", err) + } + desc, err := parseDescription(description) + if err != nil { + return nil, fmt.Errorf("parse offer: %w", err) + } + + c := newConn(ice, dtls, sctp, desc, d.Log, d.ConnectionID, networkID) + go d.handleConn(ctx, c, signals) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.candidateReceived: + c.log.Debug("received first candidate") + if err := d.startTransports(c); err != nil { + return nil, fmt.Errorf("start transports: %w", err) + } + c.handleTransports() + return c, nil + } + } + } +} + +func (d Dialer) startTransports(conn *Conn) error { + conn.log.Debug("starting ICE transport as controller") + iceRole := webrtc.ICERoleControlling + if err := conn.ice.Start(nil, conn.remote.ice, &iceRole); err != nil { + return fmt.Errorf("start ICE: %w", err) + } + + conn.log.Debug("starting DTLS transport as client") + dtlsParams := conn.remote.dtls + dtlsParams.Role = webrtc.DTLSRoleClient + if err := conn.dtls.Start(dtlsParams); err != nil { + return fmt.Errorf("start DTLS: %w", err) + } + + conn.log.Debug("starting SCTP transport") + if err := conn.sctp.Start(conn.remote.sctp); err != nil { + return fmt.Errorf("start SCTP: %w", err) + } + var err error + conn.reliable, err = d.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ + Label: "ReliableDataChannel", + }) + if err != nil { + return fmt.Errorf("create ReliableDataChannel: %w", err) + } + conn.unreliable, err = d.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ + Label: "UnreliableDataChannel", + Ordered: false, + }) + if err != nil { + return fmt.Errorf("create UnreliableDataChannel: %w", err) + } + return nil +} + +func (d Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Signal) { + for { + select { + case <-ctx.Done(): + return + case signal := <-signals: + if signal.Type == SignalTypeCandidate { + if err := conn.handleSignal(signal); err != nil { + conn.log.Error("error handling signal", internal.ErrAttr(err)) + } } } } - return nil, nil // TODO: Implement a way to dial. +} + +func (d Dialer) notifySignals(ctx context.Context, id, networkID uint64, signaling Signaling, c chan<- *Signal) { + for { + if ctx.Err() != nil { + return + } + signal, err := signaling.ReadSignal() + if err != nil { + d.Log.Error("error reading signal", internal.ErrAttr(err)) + return + } + if signal.ConnectionID != id || signal.NetworkID != networkID { + d.Log.Error("unexpected connection ID or network ID", slog.Group("signal", signal)) + continue + } + c <- signal + } } diff --git a/minecraft/nethernet/listener.go b/minecraft/nethernet/listener.go index f3931df1..2dfcca94 100644 --- a/minecraft/nethernet/listener.go +++ b/minecraft/nethernet/listener.go @@ -1,7 +1,6 @@ package nethernet import ( - "bytes" "context" "errors" "fmt" @@ -13,7 +12,6 @@ import ( "math/rand" "net" "strconv" - "strings" "sync" ) @@ -95,6 +93,11 @@ func (l *Listener) startListening(cancel context.CancelCauseFunc) { close(l.incoming) return } + + // Um... It seems the game has a bug that doesn't even send an offer if joining worlds too many times. + // This is not a bug of this code because you may not join any worlds if the bug has occurred. + // Once the bug has occurred, you need to restart the game. + l.conf.Log.Debug(signal.String()) switch signal.Type { case SignalTypeOffer: err = l.handleOffer(signal) @@ -126,39 +129,11 @@ func (l *Listener) startListening(cancel context.CancelCauseFunc) { func (l *Listener) handleOffer(signal *Signal) error { d := &sdp.SessionDescription{} if err := d.UnmarshalString(signal.Data); err != nil { - return wrapSignalError(fmt.Errorf("decode description: %w", err), ErrorCodeFailedToSetRemoteDescription) - } - if len(d.MediaDescriptions) != 1 { - return wrapSignalError(fmt.Errorf("unexpected number of media descriptions: %d, expected 1", len(d.MediaDescriptions)), ErrorCodeFailedToSetRemoteDescription) - } - m := d.MediaDescriptions[0] - - ufrag, ok := m.Attribute("ice-ufrag") - if !ok { - return wrapSignalError(errors.New("missing ice-ufrag attribute"), ErrorCodeFailedToSetRemoteDescription) - } - pwd, ok := m.Attribute("ice-pwd") - if !ok { - return wrapSignalError(errors.New("missing ice-pwd attribute"), ErrorCodeFailedToSetRemoteDescription) - } - - attr, ok := m.Attribute("fingerprint") - if !ok { - return wrapSignalError(errors.New("missing fingerprint attribute"), ErrorCodeFailedToSetRemoteDescription) - } - fingerprint := strings.Split(attr, " ") - if len(fingerprint) != 2 { - return wrapSignalError(fmt.Errorf("invalid fingerprint: %s", attr), ErrorCodeFailedToSetRemoteDescription) + return wrapSignalError(fmt.Errorf("decode offer: %w", err), ErrorCodeFailedToSetRemoteDescription) } - fingerprintAlgorithm, fingerprintValue := fingerprint[0], fingerprint[1] - - attr, ok = m.Attribute("max-message-size") - if !ok { - return wrapSignalError(errors.New("missing max-message-size attribute"), ErrorCodeFailedToSetRemoteDescription) - } - maxMessageSize, err := strconv.ParseUint(attr, 10, 32) + desc, err := parseDescription(d) if err != nil { - return wrapSignalError(fmt.Errorf("parse max-message-size attribute as uint32: %w", err), ErrorCodeFailedToSetRemoteDescription) + return wrapSignalError(fmt.Errorf("parse offer: %w", err), ErrorCodeFailedToSetRemoteDescription) } credentials, err := l.signaling.Credentials() @@ -282,12 +257,14 @@ func (l *Listener) handleOffer(signal *Signal) error { if err != nil { return wrapSignalError(fmt.Errorf("encode answer: %w", err), ErrorCodeFailedToCreateAnswer) } + if err := l.signaling.WriteSignal(&Signal{ Type: SignalTypeAnswer, ConnectionID: signal.ConnectionID, Data: string(answer), NetworkID: signal.NetworkID, }); err != nil { + // I don't think the error code will be signaled back to the remote connection, but just in case. return wrapSignalError(fmt.Errorf("signal answer: %w", err), ErrorCodeSignalingFailedToSend) } for i, candidate := range candidates { @@ -297,43 +274,12 @@ func (l *Listener) handleOffer(signal *Signal) error { Data: formatICECandidate(i, candidate, iceParams), NetworkID: signal.NetworkID, }); err != nil { + // I don't think the error code will be signaled back to the remote connection, but just in case. return wrapSignalError(fmt.Errorf("signal candidate: %w", err), ErrorCodeSignalingFailedToSend) } } - c := &Conn{ - ice: ice, - dtls: dtls, - sctp: sctp, - - iceParams: webrtc.ICEParameters{ - UsernameFragment: ufrag, - Password: pwd, - }, - dtlsParams: webrtc.DTLSParameters{ - Fingerprints: []webrtc.DTLSFingerprint{ - { - Algorithm: fingerprintAlgorithm, - Value: fingerprintValue, - }, - }, - }, - sctpCapabilities: webrtc.SCTPCapabilities{ - MaxMessageSize: uint32(maxMessageSize), - }, - - api: l.conf.API, // This is mostly unused in server connections. - - ready: make(chan struct{}), - - packets: make(chan []byte), - buf: bytes.NewBuffer(nil), - - log: l.conf.Log, - - id: signal.ConnectionID, - networkID: signal.NetworkID, - } + c := newConn(ice, dtls, sctp, desc, l.conf.Log, signal.ConnectionID, signal.NetworkID) l.connections.Store(signal.ConnectionID, c) go l.handleConn(c) @@ -347,12 +293,61 @@ func (l *Listener) handleConn(conn *Conn) { case <-l.ctx.Done(): // Quit the goroutine when the listener closes. return - case <-conn.ready: - // When it is ready, send them into Accept! + case <-conn.candidateReceived: + conn.log.Debug("received first candidate") + if err := l.startTransports(conn); err != nil { + conn.log.Error("error starting transports", internal.ErrAttr(err)) + return + } + conn.handleTransports() l.incoming <- conn } } +func (l *Listener) startTransports(conn *Conn) error { + conn.log.Debug("starting ICE transport as controlled") + iceRole := webrtc.ICERoleControlled + if err := conn.ice.Start(nil, conn.remote.ice, &iceRole); err != nil { + return fmt.Errorf("start ICE: %w", err) + } + + conn.log.Debug("starting DTLS transport as server") + dtlsParams := conn.remote.dtls + dtlsParams.Role = webrtc.DTLSRoleServer + if err := conn.dtls.Start(dtlsParams); err != nil { + return fmt.Errorf("start DTLS: %w", err) + } + + conn.log.Debug("starting SCTP transport") + var ( + once = new(sync.Once) + bothOpen = make(chan struct{}, 1) + ) + conn.sctp.OnDataChannelOpened(func(channel *webrtc.DataChannel) { + switch channel.Label() { + case "ReliableDataChannel": + conn.reliable = channel + case "UnreliableDataChannel": + conn.unreliable = channel + } + if conn.reliable != nil && conn.unreliable != nil { + once.Do(func() { + close(bothOpen) + }) + } + }) + if err := conn.sctp.Start(conn.remote.sctp); err != nil { + return fmt.Errorf("start SCTP: %w", err) + } + + select { + case <-l.ctx.Done(): + return l.ctx.Err() + case <-bothOpen: + return nil + } +} + // handleCandidate handles an incoming Signal of SignalTypeCandidate. It looks up for a connection that has the same ID, and // call the [Conn.handleSignal] method, which adds a remote candidate into its ICE transport. func (l *Listener) handleCandidate(signal *Signal) error { diff --git a/minecraft/nethernet/message.go b/minecraft/nethernet/message.go index 87951d79..bd861612 100644 --- a/minecraft/nethernet/message.go +++ b/minecraft/nethernet/message.go @@ -2,16 +2,11 @@ package nethernet import ( "fmt" - "github.com/pion/webrtc/v4" - "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" "io" ) -func (c *Conn) handleRemoteMessage(message webrtc.DataChannelMessage) { - if err := c.handleMessage(message.Data); err != nil { - c.log.Error("error handling remote message", internal.ErrAttr(err)) - } -} +// TODO: Probably the structure of remote messages sent in both ReliableDataChannel and UnreliableDataChannel +// are changed since whenever, and the specification might be outdated. We need to reverse that too. func (c *Conn) handleMessage(b []byte) error { if len(b) < 2 { diff --git a/minecraft/nethernet/signal.go b/minecraft/nethernet/signal.go index 6da7f89a..f75d95b1 100644 --- a/minecraft/nethernet/signal.go +++ b/minecraft/nethernet/signal.go @@ -16,7 +16,7 @@ type Signaling interface { ReadSignal() (*Signal, error) WriteSignal(signal *Signal) error - // Credentials will currently block until a credentials is received from the signaling service. This is usually + // Credentials will currently block until a credentials has received from the signaling service. This is usually // present in WebSocket signaling connection. A nil *Credentials may be returned if no credentials or // the implementation is not capable to do that. Credentials() (*Credentials, error) diff --git a/minecraft/protocol/packet/decoder.go b/minecraft/protocol/packet/decoder.go index 3ec2cfa5..5e383ce5 100644 --- a/minecraft/protocol/packet/decoder.go +++ b/minecraft/protocol/packet/decoder.go @@ -122,11 +122,23 @@ func (decoder *Decoder) Decode() (packets [][]byte, err error) { } b := bytes.NewBuffer(data) + for b.Len() != 0 { + var length uint32 + if err := protocol.Varuint32(b, &length); err != nil { + return nil, fmt.Errorf("decode batch: read packet length: %w", err) + } + packets = append(packets, b.Next(int(length))) + } + if len(packets) > maximumInBatch && decoder.checkPacketLimit { + return nil, fmt.Errorf("decode batch: number of packets %v exceeds max=%v", len(packets), maximumInBatch) + } + return packets, nil + /*b := bytes.NewBuffer(data) var length uint32 if err := protocol.Varuint32(b, &length); err != nil { return nil, fmt.Errorf("decode single: read packet single: %w", err) } - return [][]byte{b.Next(int(length))}, nil + return [][]byte{b.Next(int(length))}, nil*/ } } diff --git a/minecraft/world_test.go b/minecraft/world_test.go index 0ea438bc..7cb663ad 100644 --- a/minecraft/world_test.go +++ b/minecraft/world_test.go @@ -3,6 +3,7 @@ package minecraft import ( "context" "encoding/json" + "errors" "fmt" "github.com/go-gl/mathgl/mgl32" "github.com/google/uuid" @@ -12,6 +13,8 @@ import ( "github.com/sandertv/gophertunnel/minecraft/franchise" "github.com/sandertv/gophertunnel/minecraft/franchise/signaling" "github.com/sandertv/gophertunnel/minecraft/nethernet" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "log/slog" "net" "os" @@ -27,8 +30,8 @@ import ( "testing" ) -// TestListen demonstrates a world displayed in the friend list. -func TestWorld(t *testing.T) { +// TestWorldListen demonstrates a world displayed in the friend list. +func TestWorldListen(t *testing.T) { discovery, err := franchise.Discover(protocol.CurrentVersion) if err != nil { t.Fatalf("discover: %s", err) @@ -174,6 +177,10 @@ func TestWorld(t *testing.T) { signaling: signalingConn, }) + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + l, err := Listen("nethernet", "") if err != nil { t.Fatal(err) @@ -206,6 +213,111 @@ func TestWorld(t *testing.T) { } } +func TestWorldDial(t *testing.T) { + // TODO: Implement looking up sessions and find a network ID from the response. + // You need to fill in this field before running the test. + const remoteNetworkID = 0 + + discovery, err := franchise.Discover(protocol.CurrentVersion) + if err != nil { + t.Fatalf("discover: %s", err) + } + a := new(franchise.AuthorizationEnvironment) + if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("decode environment: %s", err) + } + + src := TokenSource(t, "franchise/internal/test/auth.tok", auth.TokenSource, func(old *oauth2.Token) (new *oauth2.Token, err error) { + return auth.RefreshTokenSource(old).Token() + }) + playfabXBL, err := auth.RequestXBLToken(context.Background(), src, "http://playfab.xboxlive.com/") + if err != nil { + t.Fatalf("error requesting XBL token: %s", err) + } + + identity, err := playfab.Login{ + Title: "20CA2", + CreateAccount: true, + }.WithXBLToken(playfabXBL).Login() + if err != nil { + t.Fatalf("error logging in to playfab: %s", err) + } + + region, _ := language.English.Region() + + conf := &franchise.TokenConfig{ + Device: &franchise.DeviceConfig{ + ApplicationType: franchise.ApplicationTypeMinecraftPE, + Capabilities: []string{franchise.CapabilityRayTracing}, + GameVersion: protocol.CurrentVersion, + ID: uuid.New(), + Memory: strconv.FormatUint(rand.Uint64(), 10), + Platform: franchise.PlatformWindows10, + PlayFabTitleID: a.PlayFabTitleID, + StorePlatform: franchise.StorePlatformUWPStore, + Type: franchise.DeviceTypeWindows10, + }, + User: &franchise.UserConfig{ + Language: language.English, + LanguageCode: language.AmericanEnglish, + RegionCode: region.String(), + Token: identity.SessionTicket, + TokenType: franchise.TokenTypePlayFab, + }, + Environment: a, + } + + s := new(signaling.Environment) + if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("decode environment: %s", err) + } + sd := signaling.Dialer{ + NetworkID: rand.Uint64(), + } + signalingConn, err := sd.DialContext(context.Background(), tokenConfigSource(func() (*franchise.TokenConfig, error) { + return conf, nil + }), s) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := signalingConn.Close(); err != nil { + t.Errorf("clean up: error closing: %s", err) + } + }) + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + + RegisterNetwork("nethernet", &network{ + networkID: sd.NetworkID, + signaling: signalingConn, + }) + + conn, err := Dialer{ + TokenSource: auth.RefreshTokenSource(src), + }.Dial("nethernet", strconv.FormatUint(remoteNetworkID, 10)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Fatal(err) + } + }) + + if err := conn.DoSpawn(); err != nil { + t.Fatalf("error spawning in: %s", err) + } + _ = conn.WritePacket(&packet.Text{ + TextType: packet.TextTypeChat, + SourceName: conn.IdentityData().DisplayName, + Message: "Successful", + XUID: conn.IdentityData().XUID, + }) +} + func TestDecodeOffer(t *testing.T) { d := &sdp.SessionDescription{} if err := d.UnmarshalString("v=0\r\no=- 8735254407289596231 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:gMX+\r\na=ice-pwd:4SN4mwDq5k9Q2LwCiMqxacaM\r\na=ice-options:trickle\r\na=fingerprint:sha-256 B2:35:F2:64:66:B3:73:B3:BB:8D:EE:AF:D8:96:6C:29:9C:A9:E8:94:B3:67:E1:B9:77:8C:18:19:EA:29:7D:12\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"); err != nil { @@ -219,12 +331,17 @@ type network struct { signaling nethernet.Signaling } -func (network) DialContext(context.Context, string) (net.Conn, error) { - panic("not implemented (yet)") +func (n network) DialContext(ctx context.Context, addr string) (net.Conn, error) { + networkID, err := strconv.ParseUint(addr, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse network ID: %w", err) + } + var d nethernet.Dialer + return d.DialContext(ctx, networkID, n.signaling) } func (network) PingContext(context.Context, string) ([]byte, error) { - panic("not implemented (yet)") + return nil, errors.New("not supported") } func (n network) Listen(string) (NetworkListener, error) { diff --git a/playfab/login.go b/playfab/login.go index 7ebf06af..9812bde8 100644 --- a/playfab/login.go +++ b/playfab/login.go @@ -66,7 +66,7 @@ func (l Login) WithXBLToken(x *auth.XBLToken) Login { func (l Login) Login() (*Identity, error) { if l.Route == "" { - panic("playfab/login: must provide a method/route") + panic("playfab/login: must provide an identity provider/route to login") } return internal.Post[*Identity](l.Title, l.Route, l) } From 89ea55bc02fe9522a80fd54a4e4023bcf4726e24 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Sat, 17 Aug 2024 11:00:51 +0900 Subject: [PATCH 06/14] minecraft: Start working on LAN discovery --- go.mod | 1 + go.sum | 2 + minecraft/discovery_test.go | 441 ++++++++++++++++++++++++++++++++++++ 3 files changed, 444 insertions(+) create mode 100644 minecraft/discovery_test.go diff --git a/go.mod b/go.mod index ddc6b9b8..7457188c 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( ) require ( + github.com/andreburgaud/crypt2go v1.6.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/pion/datachannel v1.5.8 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect diff --git a/go.sum b/go.sum index 93f8e685..6b783651 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/andreburgaud/crypt2go v1.6.0 h1:fZd6AWPYWLwzGQpk2MqiUgcax7Mh86uba+ZiOZw51lg= +github.com/andreburgaud/crypt2go v1.6.0/go.mod h1:6kn17HKUqQtbTUHwYcyTHmvZYVLYUqA5jwiCQE6h5N4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= diff --git a/minecraft/discovery_test.go b/minecraft/discovery_test.go new file mode 100644 index 00000000..a0fa65c3 --- /dev/null +++ b/minecraft/discovery_test.go @@ -0,0 +1,441 @@ +package minecraft + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "github.com/andreburgaud/crypt2go/ecb" + "github.com/andreburgaud/crypt2go/padding" + "github.com/go-gl/mathgl/mgl32" + "github.com/sandertv/gophertunnel/minecraft/nethernet" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "io" + "log/slog" + "math/rand" + "net" + "os" + "testing" +) + +// TestDiscovery is a messed up test for LAN discovery. Its purpose is to +// debug encoding/decoding packets sent for discovery. +func TestDiscovery(t *testing.T) { + // Please fill in this constant before running the test. + const discoveryAddress = ":7551" + + l, err := net.ListenPacket("udp", discoveryAddress) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Fatalf("error closing discovery conn: %s", err) + } + }) + + conn := &lan{ + networkID: rand.Uint64(), + conn: l, + signals: make(chan *nethernet.Signal), + t: t, + } + var cancel context.CancelCauseFunc + conn.ctx, cancel = context.WithCancelCause(context.Background()) + go conn.background(cancel) + + RegisterNetwork("nethernet", &network{ + networkID: conn.networkID, + signaling: conn, + }) + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + + listener, err := Listen("nethernet", "") + if err != nil { + t.Fatalf("error listening: %s", err) + } + t.Cleanup(func() { + if err := listener.Close(); err != nil { + t.Fatalf("error closing listener: %s", err) + } + }) + + for { + netConn, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + minecraftConn := netConn.(*Conn) + if err := minecraftConn.StartGame(GameData{ + WorldName: "NetherNet", + WorldSeed: 0, + Difficulty: 0, + EntityUniqueID: rand.Int63(), + EntityRuntimeID: rand.Uint64(), + PlayerGameMode: 1, + PlayerPosition: mgl32.Vec3{}, + WorldSpawn: protocol.BlockPos{}, + WorldGameMode: 1, + Time: rand.Int63(), + PlayerPermissions: 2, + }); err != nil { + t.Fatalf("error starting game: %s", err) + } + } +} + +type lan struct { + networkID uint64 + conn net.PacketConn + signals chan *nethernet.Signal + ctx context.Context + t testing.TB +} + +func (s *lan) WriteSignal(sig *nethernet.Signal) error { + select { + case <-s.ctx.Done(): + return context.Cause(s.ctx) + default: + } + + msg, err := encodePacket(s.networkID, &messagePacket{ + recipientID: sig.NetworkID, + data: sig.String(), + }) + if err != nil { + return fmt.Errorf("encode packet: %w", err) + } + _, err = s.conn.WriteTo(msg, &net.UDPAddr{ + IP: net.IPv4bcast, + Port: 7551, + }) + return err +} + +func (s *lan) ReadSignal() (*nethernet.Signal, error) { + select { + case <-s.ctx.Done(): + return nil, context.Cause(s.ctx) + case signal := <-s.signals: + return signal, nil + } +} + +func (s *lan) Credentials() (*nethernet.Credentials, error) { + select { + case <-s.ctx.Done(): + return nil, context.Cause(s.ctx) + default: + return nil, nil + } +} + +func (s *lan) background(cancel context.CancelCauseFunc) { + for { + b := make([]byte, 1024) + n, _, err := s.conn.ReadFrom(b) + if err != nil { + cancel(err) + return + } + senderID, pk, err := decodePacket(b[:n]) + if err != nil { + s.t.Errorf("error decoding packet: %s", err) + continue + } + if senderID == s.networkID { + continue + } + switch pk := pk.(type) { + case *requestPacket: + err = s.handleRequest() + case *messagePacket: + err = s.handleMessage(senderID, pk) + default: + s.t.Logf("unhandled packet: %#v", pk) + } + if err != nil { + s.t.Errorf("error handling packet (%#v): %s", pk, err) + } + } +} + +func (s *lan) handleRequest() error { + resp, err := encodePacket(s.networkID, &responsePacket{ + version: 0x2, + serverName: "Da1z981?", + levelName: "LAN Debugging", + gameType: 2, + playerCount: 1, + maxPlayerCount: 30, + editorWorld: false, + transportLayer: 2, + }) + if err != nil { + return fmt.Errorf("encode response: %w", err) + } + if _, err := s.conn.WriteTo(resp, &net.UDPAddr{ + IP: net.IPv4bcast, + Port: 7551, + }); err != nil { + return fmt.Errorf("write response: %w", err) + } + return nil +} + +func (s *lan) handleMessage(senderID uint64, pk *messagePacket) error { + signal := &nethernet.Signal{} + if err := signal.UnmarshalText([]byte(pk.data)); err != nil { + return fmt.Errorf("decode signal: %w", err) + } + signal.NetworkID = senderID + s.signals <- signal + return nil +} + +func encodePacket(senderID uint64, pk discoveryPacket) ([]byte, error) { + buf := &bytes.Buffer{} + pk.write(buf) + + headerBuf := &bytes.Buffer{} + h := &packetHeader{ + length: uint16(20 + buf.Len()), + packetID: pk.id(), + senderID: senderID, + } + h.write(headerBuf) + payload := append(headerBuf.Bytes(), buf.Bytes()...) + data, err := encryptECB(payload) + if err != nil { + return nil, fmt.Errorf("encrypt: %w", err) + } + + hm := hmac.New(sha256.New, key[:]) + hm.Write(payload) + data = append(append(hm.Sum(nil), data...)) + return data, nil +} + +func decodePacket(b []byte) (uint64, discoveryPacket, error) { + if len(b) < 32 { + return 0, nil, io.ErrUnexpectedEOF + } + data, err := decryptECB(b[32:]) + if err != nil { + return 0, nil, fmt.Errorf("decrypt: %w", err) + } + + hm := hmac.New(sha256.New, key[:]) + hm.Write(data) + if checksum := hm.Sum(nil); !bytes.Equal(b[:32], checksum) { + return 0, nil, fmt.Errorf("checksum mismatch: %x != %x", b[:32], checksum) + } + buf := bytes.NewBuffer(data) + + h := &packetHeader{} + if err := h.read(buf); err != nil { + return 0, nil, fmt.Errorf("decode header: %w", err) + } + var pk discoveryPacket + switch h.packetID { + case idRequest: + pk = &requestPacket{} + case idResponse: + pk = &responsePacket{} + case idMessage: + pk = &messagePacket{} + default: + return h.senderID, nil, fmt.Errorf("unknown packet ID: %d", h.packetID) + } + if err := pk.read(buf); err != nil { + return h.senderID, nil, fmt.Errorf("read payload: %w", err) + } + return h.senderID, pk, nil +} + +const ( + idRequest uint16 = iota + idResponse + idMessage +) + +type discoveryPacket interface { + id() uint16 + read(buf *bytes.Buffer) error + write(buf *bytes.Buffer) +} + +type requestPacket struct{} + +func (*requestPacket) id() uint16 { return idRequest } +func (*requestPacket) read(*bytes.Buffer) error { return nil } +func (*requestPacket) write(*bytes.Buffer) {} + +type responsePacket struct { + version uint8 + serverName string + levelName string + gameType int32 + playerCount int32 + maxPlayerCount int32 + editorWorld bool + transportLayer int32 +} + +func (*responsePacket) id() uint16 { return idResponse } +func (pk *responsePacket) read(buf *bytes.Buffer) error { + var applicationDataLength uint32 + if err := binary.Read(buf, binary.LittleEndian, &applicationDataLength); err != nil { + return fmt.Errorf("read application data length: %w", err) + } + data := buf.Next(int(applicationDataLength)) + n, err := hex.Decode(data, data) + if err != nil { + return fmt.Errorf("decode application data: %w", err) + } + + a := bytes.NewBuffer(data[:n]) + + if err := binary.Read(a, binary.LittleEndian, &pk.version); err != nil { + return fmt.Errorf("read version: %w", err) + } + var length uint8 + if err := binary.Read(a, binary.LittleEndian, &length); err != nil { + return fmt.Errorf("read server name length: %w", err) + } + pk.serverName = string(a.Next(int(length))) + if err := binary.Read(a, binary.LittleEndian, &length); err != nil { + return fmt.Errorf("read level name length: %w", err) + } + pk.levelName = string(a.Next(int(length))) + if err := binary.Read(a, binary.LittleEndian, &pk.gameType); err != nil { + return fmt.Errorf("read game type: %w", err) + } + if err := binary.Read(a, binary.LittleEndian, &pk.playerCount); err != nil { + return fmt.Errorf("read player count: %w", err) + } + if err := binary.Read(a, binary.LittleEndian, &pk.maxPlayerCount); err != nil { + return fmt.Errorf("read max player count: %w", err) + } + if err := binary.Read(a, binary.LittleEndian, &pk.editorWorld); err != nil { + return fmt.Errorf("read editor world: %w", err) + } + if err := binary.Read(a, binary.LittleEndian, &pk.transportLayer); err != nil { + return fmt.Errorf("read transport layer: %w", err) + } + + return nil +} +func (pk *responsePacket) write(buf *bytes.Buffer) { + a := &bytes.Buffer{} + + _ = binary.Write(a, binary.LittleEndian, pk.version) + _ = binary.Write(a, binary.LittleEndian, uint8(len(pk.serverName))) + a.WriteString(pk.serverName) + _ = binary.Write(a, binary.LittleEndian, uint8(len(pk.levelName))) + a.WriteString(pk.levelName) + _ = binary.Write(a, binary.LittleEndian, pk.gameType) + _ = binary.Write(a, binary.LittleEndian, pk.playerCount) + _ = binary.Write(a, binary.LittleEndian, pk.maxPlayerCount) + _ = binary.Write(a, binary.LittleEndian, pk.editorWorld) + _ = binary.Write(a, binary.LittleEndian, pk.transportLayer) + + applicationData := make([]byte, hex.EncodedLen(a.Len())) + hex.Encode(applicationData, a.Bytes()) + _ = binary.Write(buf, binary.LittleEndian, uint32(len(applicationData))) + _, _ = buf.Write(applicationData) +} + +type messagePacket struct { + recipientID uint64 + data string +} + +func (*messagePacket) id() uint16 { return idMessage } +func (pk *messagePacket) read(buf *bytes.Buffer) error { + if err := binary.Read(buf, binary.LittleEndian, &pk.recipientID); err != nil { + return fmt.Errorf("read recipient ID: %w", err) + } + var length uint32 + if err := binary.Read(buf, binary.LittleEndian, &length); err != nil { + return fmt.Errorf("read data length: %w", err) + } + pk.data = string(buf.Next(int(length))) + return nil +} +func (pk *messagePacket) write(buf *bytes.Buffer) { + _ = binary.Write(buf, binary.LittleEndian, pk.recipientID) + _ = binary.Write(buf, binary.LittleEndian, uint32(len(pk.data))) + _, _ = buf.WriteString(pk.data) +} + +type packetHeader struct { + length uint16 + packetID uint16 + senderID uint64 +} + +func (h *packetHeader) write(w io.Writer) { + _ = binary.Write(w, binary.LittleEndian, h.length) + _ = binary.Write(w, binary.LittleEndian, h.packetID) + _ = binary.Write(w, binary.LittleEndian, h.senderID) + _, _ = w.Write(make([]byte, 8)) +} + +func (h *packetHeader) read(r io.Reader) error { + if err := binary.Read(r, binary.LittleEndian, &h.length); err != nil { + return fmt.Errorf("read length: %w", err) + } + if err := binary.Read(r, binary.LittleEndian, &h.packetID); err != nil { + return fmt.Errorf("read packet ID: %w", err) + } + if err := binary.Read(r, binary.LittleEndian, &h.senderID); err != nil { + return fmt.Errorf("read sender ID: %w", err) + } + if n, err := r.Read(make([]byte, 8)); err != nil || n != 8 { + return fmt.Errorf("discard padding: %w", err) + } + return nil +} + +var key = sha256.Sum256(binary.LittleEndian.AppendUint64(nil, 0xdeadbeef)) + +func encryptECB(src []byte) ([]byte, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, fmt.Errorf("make block: %w", err) + } + mode := ecb.NewECBEncrypter(block) + p := padding.NewPkcs7Padding(mode.BlockSize()) + src, err = p.Pad(src) + if err != nil { + return nil, fmt.Errorf("pad: %w", err) + } + dst := make([]byte, len(src)) + mode.CryptBlocks(dst, src) + return dst, nil +} + +func decryptECB(src []byte) ([]byte, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, fmt.Errorf("make block: %w", err) + } + mode := ecb.NewECBDecrypter(block) + dst := make([]byte, len(src)) + mode.CryptBlocks(dst, src) + p := padding.NewPkcs7Padding(mode.BlockSize()) + dst, err = p.Unpad(dst) + if err != nil { + return nil, fmt.Errorf("unpad: %w", err) + } + return dst, nil +} From ef60a06bd78a18a938c1785b05c3580070b12de8 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Sat, 17 Aug 2024 11:03:29 +0900 Subject: [PATCH 07/14] minecraft/protocol/packet/decoder.go: Revert internal changes --- minecraft/protocol/packet/decoder.go | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/minecraft/protocol/packet/decoder.go b/minecraft/protocol/packet/decoder.go index 5e383ce5..3ec2cfa5 100644 --- a/minecraft/protocol/packet/decoder.go +++ b/minecraft/protocol/packet/decoder.go @@ -122,23 +122,11 @@ func (decoder *Decoder) Decode() (packets [][]byte, err error) { } b := bytes.NewBuffer(data) - for b.Len() != 0 { - var length uint32 - if err := protocol.Varuint32(b, &length); err != nil { - return nil, fmt.Errorf("decode batch: read packet length: %w", err) - } - packets = append(packets, b.Next(int(length))) - } - if len(packets) > maximumInBatch && decoder.checkPacketLimit { - return nil, fmt.Errorf("decode batch: number of packets %v exceeds max=%v", len(packets), maximumInBatch) - } - return packets, nil - /*b := bytes.NewBuffer(data) var length uint32 if err := protocol.Varuint32(b, &length); err != nil { return nil, fmt.Errorf("decode single: read packet single: %w", err) } - return [][]byte{b.Next(int(length))}, nil*/ + return [][]byte{b.Next(int(length))}, nil } } From 604fb1e99997cf5da694bbe51142fd9fc0b764c0 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Fri, 23 Aug 2024 05:19:13 +0900 Subject: [PATCH 08/14] minecraft: Clean up code --- minecraft/conn.go | 6 +- minecraft/dial.go | 2 +- minecraft/discovery_test.go | 3 +- .../franchise/internal/test/token_source.go | 19 +- minecraft/franchise/playfab.go | 64 +++ minecraft/franchise/signaling/conn.go | 5 +- minecraft/franchise/signaling/conn_test.go | 83 ++-- minecraft/franchise/signaling/dial.go | 2 +- minecraft/franchise/token.go | 18 +- minecraft/franchise/token_test.go | 65 +-- minecraft/listener.go | 14 +- minecraft/nethernet/conn.go | 239 +++++++---- minecraft/nethernet/dial.go | 167 +++----- minecraft/nethernet/listener.go | 124 +++--- minecraft/nethernet/message.go | 38 +- minecraft/nethernet/network.go | 50 +++ minecraft/nethernet/signal.go | 4 +- minecraft/network.go | 4 +- minecraft/protocol/packet/decoder.go | 83 ++-- minecraft/protocol/packet/encoder.go | 57 +-- minecraft/raknet.go | 4 +- minecraft/room/status.go | 1 + minecraft/world_test.go | 393 +++++++----------- playfab/login.go | 10 +- xsapi/token.go | 1 + xsapi/xal/token_source.go | 4 + 26 files changed, 736 insertions(+), 724 deletions(-) create mode 100644 minecraft/franchise/playfab.go create mode 100644 minecraft/nethernet/network.go diff --git a/minecraft/conn.go b/minecraft/conn.go index bef8d689..9468d69a 100644 --- a/minecraft/conn.go +++ b/minecraft/conn.go @@ -149,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, batched 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, batched), - dec: packet.NewDecoder(netConn, batched), + 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), diff --git a/minecraft/dial.go b/minecraft/dial.go index 70b1d62b..8bec1af5 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, n.Batched()) + 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 diff --git a/minecraft/discovery_test.go b/minecraft/discovery_test.go index a0fa65c3..907ca65f 100644 --- a/minecraft/discovery_test.go +++ b/minecraft/discovery_test.go @@ -1,6 +1,6 @@ package minecraft -import ( +/*import ( "bytes" "context" "crypto/aes" @@ -439,3 +439,4 @@ func decryptECB(src []byte) ([]byte, error) { } return dst, nil } +*/ diff --git a/minecraft/franchise/internal/test/token_source.go b/minecraft/franchise/internal/test/token_source.go index f15943aa..7e6ab914 100644 --- a/minecraft/franchise/internal/test/token_source.go +++ b/minecraft/franchise/internal/test/token_source.go @@ -5,26 +5,9 @@ import ( "fmt" "golang.org/x/oauth2" "os" - "testing" ) -func TokenSource(t *testing.T, path string, src oauth2.TokenSource, hooks ...RefreshTokenFunc) *oauth2.Token { - tok, err := readTokenSource(path, src) - if err != nil { - t.Fatalf("error reading token: %s", err) - } - for _, h := range hooks { - tok, err = h(tok) - if err != nil { - t.Fatalf("error refreshing token: %s", err) - } - } - return tok -} - -type RefreshTokenFunc func(old *oauth2.Token) (new *oauth2.Token, err error) - -func readTokenSource(path string, src oauth2.TokenSource) (t *oauth2.Token, err error) { +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 { diff --git a/minecraft/franchise/playfab.go b/minecraft/franchise/playfab.go new file mode 100644 index 00000000..6098e45c --- /dev/null +++ b/minecraft/franchise/playfab.go @@ -0,0 +1,64 @@ +package franchise + +import ( + "errors" + "fmt" + "github.com/sandertv/gophertunnel/playfab" + "github.com/sandertv/gophertunnel/playfab/title" + "github.com/sandertv/gophertunnel/xsapi" + "golang.org/x/text/language" +) + +type PlayFabXBLIdentityProvider struct { + Environment *AuthorizationEnvironment + TokenSource xsapi.TokenSource + + DeviceConfig *DeviceConfig + UserConfig *UserConfig +} + +func (i PlayFabXBLIdentityProvider) TokenConfig() (*TokenConfig, error) { + if i.Environment == nil { + return nil, errors.New("minecraft/franchise: PlayFabXBLIdentityProvider: Environment is nil") + } + if i.TokenSource == nil { + return nil, errors.New("minecraft/franchise: PlayFabXBLIdentityProvider: TokenSource is nil") + } + if i.DeviceConfig == nil { + i.DeviceConfig = defaultDeviceConfig(i.Environment) + } + if i.UserConfig == nil { + region, _ := language.English.Region() + + i.UserConfig = &UserConfig{ + Language: language.English, + LanguageCode: language.AmericanEnglish, + RegionCode: region.String(), + } + } + + x, err := i.TokenSource.Token() + if err != nil { + return nil, fmt.Errorf("request xbox live token: %w", err) + } + + cfg := playfab.LoginConfig{ + Title: title.Title(i.Environment.PlayFabTitleID), + CreateAccount: true, + }.WithXbox(x) + identity, err := cfg.Login() + 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 index 144cd249..08efc8e6 100644 --- a/minecraft/franchise/signaling/conn.go +++ b/minecraft/franchise/signaling/conn.go @@ -5,6 +5,7 @@ import ( "encoding/json" "github.com/sandertv/gophertunnel/minecraft/franchise/internal" "github.com/sandertv/gophertunnel/minecraft/nethernet" + "net" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" "strconv" @@ -34,8 +35,10 @@ func (c *Conn) WriteSignal(signal *nethernet.Signal) error { }) } -func (c *Conn) ReadSignal() (*nethernet.Signal, error) { +func (c *Conn) ReadSignal(cancel <-chan struct{}) (*nethernet.Signal, error) { select { + case <-cancel: + return nil, net.ErrClosed case s := <-c.signals: return s, nil case <-c.ctx.Done(): diff --git a/minecraft/franchise/signaling/conn_test.go b/minecraft/franchise/signaling/conn_test.go index 964791c6..7cc27756 100644 --- a/minecraft/franchise/signaling/conn_test.go +++ b/minecraft/franchise/signaling/conn_test.go @@ -2,87 +2,62 @@ package signaling import ( "context" - "github.com/google/uuid" "github.com/sandertv/gophertunnel/minecraft/auth" "github.com/sandertv/gophertunnel/minecraft/franchise" "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" "github.com/sandertv/gophertunnel/minecraft/protocol" - "github.com/sandertv/gophertunnel/playfab" - "golang.org/x/oauth2" - "golang.org/x/text/language" - "math/rand" - "strconv" + "github.com/sandertv/gophertunnel/xsapi/xal" "testing" + "time" ) func TestDial(t *testing.T) { discovery, err := franchise.Discover(protocol.CurrentVersion) if err != nil { - t.Fatalf("discover environments: %s", err) + t.Fatalf("error retrieving discovery: %s", err) } + a := new(franchise.AuthorizationEnvironment) if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("decode environment: %s", err) + t.Fatalf("error reading environment for authorization: %s", err) } - - src := test.TokenSource(t, "../internal/test/auth.tok", auth.TokenSource, func(old *oauth2.Token) (new *oauth2.Token, err error) { - return auth.RefreshTokenSource(old).Token() - }) - x, err := auth.RequestXBLToken(context.Background(), src, "http://playfab.xboxlive.com/") - if err != nil { - t.Fatalf("error requesting XBL token: %s", err) + s := new(Environment) + if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("error reading environment for signaling: %s", err) } - identity, err := playfab.Login{ - Title: "20CA2", - CreateAccount: true, - }.WithXBLToken(x).Login() + tok, err := test.ReadToken("../internal/test/auth.tok", auth.TokenSource) if err != nil { - t.Fatalf("error logging in to playfab: %s", err) + t.Fatalf("error reading token: %s", err) } + src := auth.RefreshTokenSource(tok) - region, _ := language.English.Region() - - conf := &franchise.TokenConfig{ - Device: &franchise.DeviceConfig{ - ApplicationType: franchise.ApplicationTypeMinecraftPE, - Capabilities: []string{franchise.CapabilityRayTracing}, - GameVersion: protocol.CurrentVersion, - ID: uuid.New(), - Memory: strconv.FormatUint(rand.Uint64(), 10), - Platform: franchise.PlatformWindows10, - PlayFabTitleID: a.PlayFabTitleID, - StorePlatform: franchise.StorePlatformUWPStore, - Type: franchise.DeviceTypeWindows10, - }, - User: &franchise.UserConfig{ - Language: language.English, - LanguageCode: language.AmericanEnglish, - RegionCode: region.String(), - Token: identity.SessionTicket, - TokenType: franchise.TokenTypePlayFab, - }, + refresh, cancel := context.WithCancel(context.Background()) + defer cancel() + prov := franchise.PlayFabXBLIdentityProvider{ Environment: a, + TokenSource: xal.RefreshTokenSourceContext(refresh, src, "http://playfab.xboxlive.com/"), } - s := new(Environment) - if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("decode environment: %s", err) - } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() var d Dialer - conn, err := d.DialContext(context.Background(), tokenConfigSource(func() (*franchise.TokenConfig, error) { - return conf, nil - }), s) + conn, err := d.DialContext(ctx, prov, s) if err != nil { - t.Fatal(err) + t.Fatalf("error dialing: %s", err) } t.Cleanup(func() { if err := conn.Close(); err != nil { - t.Errorf("clean up: error closing: %s", err) + t.Fatalf("error closing conn: %s", err) } }) -} - -type tokenConfigSource func() (*franchise.TokenConfig, error) -func (f tokenConfigSource) TokenConfig() (*franchise.TokenConfig, error) { return f() } + credentials, err := conn.Credentials() + if err != nil { + t.Fatalf("error obtaining credentials: %s", err) + } + if credentials == nil { + t.Fatal("credentials is nil") + } + t.Logf("credentials obtained: %#v", credentials) +} diff --git a/minecraft/franchise/signaling/dial.go b/minecraft/franchise/signaling/dial.go index 9aeb98ee..a0109a45 100644 --- a/minecraft/franchise/signaling/dial.go +++ b/minecraft/franchise/signaling/dial.go @@ -19,7 +19,7 @@ type Dialer struct { Log *slog.Logger } -func (d Dialer) DialContext(ctx context.Context, src franchise.TokenConfigSource, env *Environment) (*Conn, error) { +func (d Dialer) DialContext(ctx context.Context, src franchise.IdentityProvider, env *Environment) (*Conn, error) { if d.Options == nil { d.Options = &websocket.DialOptions{} } diff --git a/minecraft/franchise/token.go b/minecraft/franchise/token.go index 09936267..259dde51 100644 --- a/minecraft/franchise/token.go +++ b/minecraft/franchise/token.go @@ -7,9 +7,11 @@ import ( "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" ) @@ -81,7 +83,7 @@ type AuthorizationEnvironment struct { func (*AuthorizationEnvironment) EnvironmentName() string { return "auth" } -type TokenConfigSource interface { +type IdentityProvider interface { TokenConfig() (*TokenConfig, error) } @@ -92,6 +94,20 @@ type TokenConfig struct { Environment *AuthorizationEnvironment `json:"-"` } +func defaultDeviceConfig(env *AuthorizationEnvironment) *DeviceConfig { + return &DeviceConfig{ + ApplicationType: ApplicationTypeMinecraftPE, + Capabilities: nil, // TODO: Should this be an empty slice? + GameVersion: protocol.CurrentVersion, + ID: uuid.New(), + Memory: strconv.FormatUint(16*(1<<30), 10), + Platform: PlatformWindows10, + PlayFabTitleID: env.PlayFabTitleID, + StorePlatform: StorePlatformUWPStore, + Type: DeviceTypeWindows10, + } +} + type DeviceConfig struct { ApplicationType string `json:"applicationType,omitempty"` Capabilities []string `json:"capabilities,omitempty"` diff --git a/minecraft/franchise/token_test.go b/minecraft/franchise/token_test.go index d422596b..58f1417b 100644 --- a/minecraft/franchise/token_test.go +++ b/minecraft/franchise/token_test.go @@ -2,72 +2,45 @@ package franchise import ( "context" - "github.com/google/uuid" "github.com/sandertv/gophertunnel/minecraft/auth" "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" "github.com/sandertv/gophertunnel/minecraft/protocol" - "github.com/sandertv/gophertunnel/playfab" - "golang.org/x/oauth2" - "golang.org/x/text/language" - "math/rand" - "strconv" + "github.com/sandertv/gophertunnel/xsapi/xal" "testing" ) func TestToken(t *testing.T) { - d, err := Discover(protocol.CurrentVersion) + discovery, err := Discover(protocol.CurrentVersion) if err != nil { - t.Fatalf("discover environments: %s", err) + t.Fatalf("error retrieving discovery: %s", err) } a := new(AuthorizationEnvironment) - if err := d.Environment(a, EnvironmentTypeProduction); err != nil { - t.Fatalf("decode environment: %s", err) + if err := discovery.Environment(a, EnvironmentTypeProduction); err != nil { + t.Fatalf("error reading environment for authorization: %s", err) } - src := test.TokenSource(t, "internal/test/auth.tok", auth.TokenSource, func(old *oauth2.Token) (new *oauth2.Token, err error) { - return auth.RefreshTokenSource(old).Token() - }) - x, err := auth.RequestXBLToken(context.Background(), src, "http://playfab.xboxlive.com/") + tok, err := test.ReadToken("internal/test/auth.tok", auth.TokenSource) if err != nil { - t.Fatalf("error requesting XBL token: %s", err) + t.Fatalf("error reading token: %s", err) } + src := auth.RefreshTokenSource(tok) - identity, err := playfab.Login{ - Title: "20CA2", - CreateAccount: true, - }.WithXBLToken(x).Login() - if err != nil { - t.Fatalf("error logging in to playfab: %s", err) + refresh, cancel := context.WithCancel(context.Background()) + defer cancel() + prov := PlayFabXBLIdentityProvider{ + Environment: a, + TokenSource: xal.RefreshTokenSourceContext(refresh, src, "http://playfab.xboxlive.com/"), } - region, _ := language.English.Region() - - conf := &TokenConfig{ - Device: &DeviceConfig{ - ApplicationType: ApplicationTypeMinecraftPE, - Capabilities: []string{CapabilityRayTracing}, - GameVersion: protocol.CurrentVersion, - ID: uuid.New(), - Memory: strconv.FormatUint(rand.Uint64(), 10), - Platform: PlatformWindows10, - PlayFabTitleID: a.PlayFabTitleID, - StorePlatform: StorePlatformUWPStore, - Type: DeviceTypeWindows10, - }, - User: &UserConfig{ - Language: language.English, - LanguageCode: language.AmericanEnglish, - RegionCode: region.String(), - Token: identity.SessionTicket, - TokenType: TokenTypePlayFab, - }, - Environment: a, + conf, err := prov.TokenConfig() + if err != nil { + t.Fatalf("error requesting token config: %s", err) } - tok, err := conf.Token() + token, err := conf.Token() if err != nil { - t.Fatal(err) + t.Fatalf("error requesting token: %s", err) } - t.Logf("%#v", tok) + t.Logf("%#v", token) } diff --git a/minecraft/listener.go b/minecraft/listener.go index 6d8ae4f3..e17cf41e 100644 --- a/minecraft/listener.go +++ b/minecraft/listener.go @@ -102,7 +102,8 @@ type Listener struct { key *ecdsa.PrivateKey - disableEncryption, batched bool + disableEncryption bool + batchHeader []byte } // Listen announces on the local network address. The network is typically "raknet". @@ -140,7 +141,7 @@ func (cfg ListenConfig) Listen(network string, address string) (*Listener, error close: make(chan struct{}), key: key, disableEncryption: n.Encrypted(), - batched: n.Batched(), + batchHeader: n.BatchHeader(), } // Actually start listening. @@ -213,10 +214,15 @@ 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. 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, ))) } @@ -260,7 +266,7 @@ 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, listener.batched) + 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) diff --git a/minecraft/nethernet/conn.go b/minecraft/nethernet/conn.go index b6db6e8b..b9ca4119 100644 --- a/minecraft/nethernet/conn.go +++ b/minecraft/nethernet/conn.go @@ -1,14 +1,15 @@ package nethernet import ( - "bytes" "errors" "fmt" "github.com/pion/ice/v3" "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" + "io" "log/slog" + "math/rand" "net" "strconv" "strings" @@ -23,15 +24,18 @@ type Conn struct { remote *description - closeCandidateReceived sync.Once // A sync.Once that closes candidateReceived only once. - candidateReceived chan struct{} // Notifies that a first candidate is received from the other end, and the Conn is ready to start its transports. + candidateReceived chan struct{} // Notifies that a first candidate is received from the other end, and the Conn is ready to start its transports. + candidates []webrtc.ICECandidate + candidatesMu sync.Mutex + + localCandidates []webrtc.ICECandidate + localNetworkID uint64 reliable, unreliable *webrtc.DataChannel // ReliableDataChannel and UnreliableDataChannel packets chan []byte - buf *bytes.Buffer - promisedSegments uint8 + message *message once sync.Once closed chan struct{} @@ -50,41 +54,46 @@ func (c *Conn) Read(b []byte) (n int, err error) { } } +func (c *Conn) ReadPacket() ([]byte, error) { + select { + case <-c.closed: + return nil, net.ErrClosed + case pk := <-c.packets: + return pk, nil + } +} + func (c *Conn) Write(b []byte) (n int, err error) { select { case <-c.closed: return n, net.ErrClosed default: // TODO: Clean up... - if len(b) > maxMessageSize { - segments := uint8(len(b) / maxMessageSize) - if len(b)%maxMessageSize != 0 { - segments++ // If there's a remainder, we need an additional segment. - } + segments := uint8(len(b) / maxMessageSize) + if len(b)%maxMessageSize != 0 { + segments++ // If there's a remainder, we need an additional segment. + } - for i := 0; i < len(b); i += maxMessageSize { - segments-- + for i := 0; i < len(b); i += maxMessageSize { + segments-- - end := i + maxMessageSize - if end > len(b) { - end = len(b) - } - frag := b[i:end] - if err := c.reliable.Send(append([]byte{segments}, frag...)); err != nil { - return n, fmt.Errorf("write segment #%d: %w", segments, err) + end := i + maxMessageSize + if end > len(b) { + end = len(b) + } + frag := b[i:end] + if err := c.reliable.Send(append([]byte{segments}, frag...)); err != nil { + if errors.Is(err, io.ErrClosedPipe) { + return n, net.ErrClosed } - n += len(frag) + return n, fmt.Errorf("write segment #%d: %w", segments, err) } + n += len(frag) + } - // TODO - if segments != 0 { - panic("minecraft/nethernet: Conn: segments != 0") - } - } else { - if err := c.reliable.Send(append([]byte{0}, b...)); err != nil { - return n, err - } - n = len(b) + // TODO + if segments != 0 { + panic("minecraft/nethernet: Conn: segments != 0") } return n, nil } @@ -102,34 +111,38 @@ func (*Conn) SetWriteDeadline(time.Time) error { return errors.New("minecraft/nethernet: Conn: not implemented (yet)") } -// LocalAddr currently returns a dummy address. -// TODO: Return something a valid address. func (c *Conn) LocalAddr() net.Addr { - dummy, _ := net.ResolveUDPAddr("udp", ":19132") - return dummy + return &Addr{ + NetworkID: c.localNetworkID, + ConnectionID: c.id, + Candidates: c.localCandidates, + } } -// RemoteAddr currently returns a dummy address. -// TODO: Return something a valid address. func (c *Conn) RemoteAddr() net.Addr { - dummy, _ := net.ResolveUDPAddr("udp", ":19132") - return dummy -} + c.candidatesMu.Lock() + defer c.candidatesMu.Unlock() -func (c *Conn) Close() error { - errs := make([]error, 0, 3) - if c.reliable != nil { - if err := c.reliable.Close(); err != nil { - errs = append(errs, err) - } - } - if c.unreliable != nil { - if err := c.unreliable.Close(); err != nil { - errs = append(errs, err) - } + return &Addr{ + NetworkID: c.networkID, + ConnectionID: c.id, + Candidates: c.candidates, } +} - return errors.Join(append(errs, c.closeTransports())...) +func (c *Conn) Close() (err error) { + c.once.Do(func() { + close(c.closed) + + errs := make([]error, 0, 5) + errs = append(errs, c.reliable.Close()) + errs = append(errs, c.unreliable.Close()) + errs = append(errs, c.sctp.Stop()) + errs = append(errs, c.dtls.Stop()) + errs = append(errs, c.ice.Stop()) + err = errors.Join(errs...) + }) + return err } func (c *Conn) handleTransports() { @@ -139,43 +152,35 @@ func (c *Conn) handleTransports() { } }) + c.reliable.OnClose(func() { + c.Close() + }) + + c.unreliable.OnClose(func() { + _ = c.Close() + }) + c.ice.OnConnectionStateChange(func(state webrtc.ICETransportState) { switch state { case webrtc.ICETransportStateClosed, webrtc.ICETransportStateDisconnected, webrtc.ICETransportStateFailed: - _ = c.closeTransports() // We need to make sure that all transports has been closed + // This handler function itself is holding the lock, call Close in a goroutine. + go c.Close() // We need to make sure that all transports has been closed default: } }) c.dtls.OnStateChange(func(state webrtc.DTLSTransportState) { switch state { case webrtc.DTLSTransportStateClosed, webrtc.DTLSTransportStateFailed: - _ = c.closeTransports() // We need to make sure that all transports has been closed + // This handler function itself is holding the lock, call Close in a goroutine. + go c.Close() // We need to make sure that all transports has been closed default: } }) } -func (c *Conn) closeTransports() (err error) { - c.once.Do(func() { - errs := make([]error, 0, 3) - - if err := c.sctp.Stop(); err != nil { - errs = append(errs, err) - } - if err := c.dtls.Stop(); err != nil { - errs = append(errs, err) - } - if err := c.ice.Stop(); err != nil { - errs = append(errs, err) - } - err = errors.Join(errs...) - close(c.closed) - }) - return err -} - func (c *Conn) handleSignal(signal *Signal) error { - if signal.Type == SignalTypeCandidate { + switch signal.Type { + case SignalTypeCandidate: candidate, err := ice.UnmarshalCandidate(signal.Data) if err != nil { return fmt.Errorf("decode candidate: %w", err) @@ -184,7 +189,7 @@ func (c *Conn) handleSignal(signal *Signal) error { if err != nil { return fmt.Errorf("parse ICE protocol: %w", err) } - i := &webrtc.ICECandidate{ + i := webrtc.ICECandidate{ Foundation: candidate.Foundation(), Priority: candidate.Priority(), Address: candidate.Address(), @@ -199,13 +204,25 @@ func (c *Conn) handleSignal(signal *Signal) error { i.RelatedAddress, i.RelatedPort = r.Address, uint16(r.Port) } - if err := c.ice.AddRemoteCandidate(i); err != nil { + if err := c.ice.AddRemoteCandidate(&i); err != nil { return fmt.Errorf("add remote candidate: %w", err) } - c.closeCandidateReceived.Do(func() { + c.candidatesMu.Lock() + if len(c.candidates) == 0 { close(c.candidateReceived) - }) + } + c.candidates = append(c.candidates, i) + c.candidatesMu.Unlock() + case SignalTypeError: + code, err := strconv.ParseUint(signal.Data, 10, 32) + if err != nil { + return fmt.Errorf("parse error code: %w", err) + } + c.log.Error("connection failed with error", slog.Uint64("code", code)) + if err := c.Close(); err != nil { + return fmt.Errorf("close: %w", err) + } } return nil } @@ -271,7 +288,69 @@ type description struct { sctp webrtc.SCTPCapabilities } -func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc.SCTPTransport, d *description, log *slog.Logger, id, networkID uint64) *Conn { +func (desc description) encode() ([]byte, error) { + d := &sdp.SessionDescription{ + Version: 0x0, + Origin: sdp.Origin{ + Username: "-", + SessionID: rand.Uint64(), + SessionVersion: 0x2, + NetworkType: "IN", + AddressType: "IP4", + UnicastAddress: "127.0.0.1", + }, + SessionName: "-", + TimeDescriptions: []sdp.TimeDescription{ + {}, + }, + Attributes: []sdp.Attribute{ + {Key: "group", Value: "BUNDLE 0"}, + {Key: "extmap-allow-mixed", Value: ""}, + {Key: "msid-semantic", Value: " WMS"}, + }, + MediaDescriptions: []*sdp.MediaDescription{ + { + MediaName: sdp.MediaName{ + Media: "application", + Port: sdp.RangedPort{Value: 9}, + Protos: []string{"UDP", "DTLS", "SCTP"}, + Formats: []string{"webrtc-datachannel"}, + }, + ConnectionInformation: &sdp.ConnectionInformation{ + NetworkType: "IN", + AddressType: "IP4", + Address: &sdp.Address{Address: "0.0.0.0"}, + }, + Attributes: []sdp.Attribute{ + {Key: "ice-ufrag", Value: desc.ice.UsernameFragment}, + {Key: "ice-pwd", Value: desc.ice.Password}, + {Key: "ice-options", Value: "trickle"}, + {Key: "fingerprint", Value: fmt.Sprintf("%s %s", + desc.dtls.Fingerprints[0].Algorithm, + desc.dtls.Fingerprints[0].Value, + )}, + desc.setupAttribute(), + {Key: "mid", Value: "0"}, + {Key: "sctp-port", Value: "5000"}, + {Key: "max-message-size", Value: strconv.FormatUint(uint64(desc.sctp.MaxMessageSize), 10)}, + }, + }, + }, + } + return d.Marshal() +} + +func (desc description) setupAttribute() sdp.Attribute { + attr := sdp.Attribute{Key: "setup"} + if desc.dtls.Role == webrtc.DTLSRoleServer { + attr.Value = "actpass" + } else { + attr.Value = "active" + } + return attr +} + +func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc.SCTPTransport, d *description, log *slog.Logger, id, networkID, localNetworkID uint64, candidates []webrtc.ICECandidate) *Conn { return &Conn{ ice: ice, dtls: dtls, @@ -281,8 +360,12 @@ func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc. candidateReceived: make(chan struct{}, 1), + localNetworkID: localNetworkID, + localCandidates: candidates, + packets: make(chan []byte), - buf: bytes.NewBuffer(nil), + + message: &message{}, closed: make(chan struct{}, 1), diff --git a/minecraft/nethernet/dial.go b/minecraft/nethernet/dial.go index baa0b463..d806f96a 100644 --- a/minecraft/nethernet/dial.go +++ b/minecraft/nethernet/dial.go @@ -19,14 +19,14 @@ type Dialer struct { Log *slog.Logger } -func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Signaling) (*Conn, error) { - if d.NetworkID == 0 { - d.NetworkID = rand.Uint64() +func (dialer Dialer) DialContext(ctx context.Context, networkID uint64, signaling Signaling) (*Conn, error) { + if dialer.NetworkID == 0 { + dialer.NetworkID = rand.Uint64() } - if d.ConnectionID == 0 { - d.ConnectionID = rand.Uint64() + if dialer.ConnectionID == 0 { + dialer.ConnectionID = rand.Uint64() } - if d.API == nil { + if dialer.API == nil { var ( setting webrtc.SettingEngine factory = logging.NewDefaultLoggerFactory() @@ -34,14 +34,14 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig factory.DefaultLogLevel = logging.LogLevelDebug setting.LoggerFactory = factory - d.API = webrtc.NewAPI(webrtc.WithSettingEngine(setting)) + dialer.API = webrtc.NewAPI(webrtc.WithSettingEngine(setting)) } - if d.Log == nil { - d.Log = slog.Default() + if dialer.Log == nil { + dialer.Log = slog.Default() } credentials, err := signaling.Credentials() if err != nil { - return nil, wrapSignalError(fmt.Errorf("obtain credentials: %w", err), ErrorCodeFailedToCreatePeerConnection) + return nil, fmt.Errorf("obtain credentials: %w", err) } var gatherOptions webrtc.ICEGatherOptions if credentials != nil && len(credentials.ICEServers) > 0 { @@ -55,13 +55,13 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig } } } - gatherer, err := d.API.NewICEGatherer(gatherOptions) + gatherer, err := dialer.API.NewICEGatherer(gatherOptions) if err != nil { - return nil, wrapSignalError(fmt.Errorf("create ICE gatherer: %w", err), ErrorCodeFailedToCreatePeerConnection) + return nil, fmt.Errorf("create ICE gatherer: %w", err) } var ( - candidates []*webrtc.ICECandidate + candidates []webrtc.ICECandidate gatherFinished = make(chan struct{}) ) gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) { @@ -69,139 +69,103 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig close(gatherFinished) return } - candidates = append(candidates, candidate) + candidates = append(candidates, *candidate) }) if err := gatherer.Gather(); err != nil { - return nil, wrapSignalError(fmt.Errorf("gather local candidates: %w", err), ErrorCodeFailedToCreatePeerConnection) + return nil, fmt.Errorf("gather local candidates: %w", err) } select { case <-ctx.Done(): return nil, ctx.Err() case <-gatherFinished: - ice := d.API.NewICETransport(gatherer) - dtls, err := d.API.NewDTLSTransport(ice, nil) + ice := dialer.API.NewICETransport(gatherer) + dtls, err := dialer.API.NewDTLSTransport(ice, nil) if err != nil { - return nil, wrapSignalError(fmt.Errorf("create DTLS transport: %w", err), ErrorCodeFailedToCreatePeerConnection) + return nil, fmt.Errorf("create DTLS transport: %w", err) } - sctp := d.API.NewSCTPTransport(dtls) + sctp := dialer.API.NewSCTPTransport(dtls) iceParams, err := ice.GetLocalParameters() if err != nil { - return nil, wrapSignalError(fmt.Errorf("obtain local ICE parameters: %w", err), ErrorCodeFailedToCreatePeerConnection) + return nil, fmt.Errorf("obtain local ICE parameters: %w", err) } dtlsParams, err := dtls.GetLocalParameters() if err != nil { - return nil, wrapSignalError(fmt.Errorf("obtain local DTLS parameters: %w", err), ErrorCodeFailedToCreateAnswer) + return nil, fmt.Errorf("obtain local DTLS parameters: %w", err) } if len(dtlsParams.Fingerprints) == 0 { - return nil, wrapSignalError(errors.New("local DTLS parameters has no fingerprints"), ErrorCodeFailedToCreateAnswer) + return nil, errors.New("local DTLS parameters has no fingerprints") } sctpCapabilities := sctp.GetCapabilities() - // Encode an offer using the local parameters! - description := &sdp.SessionDescription{ - Version: 0x0, - Origin: sdp.Origin{ - Username: "-", - SessionID: rand.Uint64(), - SessionVersion: 0x2, - NetworkType: "IN", - AddressType: "IP4", - UnicastAddress: "127.0.0.1", - }, - SessionName: "-", - TimeDescriptions: []sdp.TimeDescription{ - {}, - }, - Attributes: []sdp.Attribute{ - {Key: "group", Value: "BUNDLE 0"}, - {Key: "extmap-allow-mixed", Value: ""}, - {Key: "msid-semantic", Value: " WMS"}, - }, - MediaDescriptions: []*sdp.MediaDescription{ - { - MediaName: sdp.MediaName{ - Media: "application", - Port: sdp.RangedPort{Value: 9}, - Protos: []string{"UDP", "DTLS", "SCTP"}, - Formats: []string{"webrtc-datachannel"}, - }, - ConnectionInformation: &sdp.ConnectionInformation{ - NetworkType: "IN", - AddressType: "IP4", - Address: &sdp.Address{Address: "0.0.0.0"}, - }, - Attributes: []sdp.Attribute{ - {Key: "ice-ufrag", Value: iceParams.UsernameFragment}, - {Key: "ice-pwd", Value: iceParams.Password}, - {Key: "ice-options", Value: "trickle"}, - {Key: "fingerprint", Value: fmt.Sprintf("%s %s", - dtlsParams.Fingerprints[0].Algorithm, - dtlsParams.Fingerprints[0].Value, - )}, - {Key: "setup", Value: "actpass"}, - {Key: "mid", Value: "0"}, - {Key: "sctp-port", Value: "5000"}, - {Key: "max-message-size", Value: strconv.FormatUint(uint64(sctpCapabilities.MaxMessageSize), 10)}, - }, - }, - }, - } + dtlsParams.Role = webrtc.DTLSRoleServer - offer, err := description.Marshal() + // Encode an offer using the local parameters! + offer, err := description{ + ice: iceParams, + dtls: dtlsParams, + sctp: sctpCapabilities, + }.encode() if err != nil { - return nil, wrapSignalError(fmt.Errorf("encode offer: %w", err), ErrorCodeFailedToCreateAnswer) + return nil, fmt.Errorf("encode offer: %w", err) } if err := signaling.WriteSignal(&Signal{ Type: SignalTypeOffer, Data: string(offer), - ConnectionID: d.ConnectionID, + ConnectionID: dialer.ConnectionID, NetworkID: networkID, }); err != nil { - // I don't think the error code will be signaled back to the remote connection, but just in case. - return nil, wrapSignalError(fmt.Errorf("signal offer: %w", err), ErrorCodeSignalingFailedToSend) + return nil, fmt.Errorf("signal offer: %w", err) } for i, candidate := range candidates { if err := signaling.WriteSignal(&Signal{ Type: SignalTypeCandidate, Data: formatICECandidate(i, candidate, iceParams), - ConnectionID: d.ConnectionID, + ConnectionID: dialer.ConnectionID, NetworkID: networkID, }); err != nil { - // I don't think the error code will be signaled back to the remote connection, but just in case. - return nil, wrapSignalError(fmt.Errorf("signal candidate: %w", err), ErrorCodeSignalingFailedToSend) + return nil, fmt.Errorf("signal candidate: %w", err) } } signals := make(chan *Signal) - go d.notifySignals(ctx, d.ConnectionID, networkID, signaling, signals) + go dialer.notifySignals(ctx, dialer.ConnectionID, networkID, signaling, signals) select { case <-ctx.Done(): + if errors.Is(err, context.DeadlineExceeded) { + dialer.signalError(signaling, networkID, ErrorCodeNegotiationTimeoutWaitingForResponse) + } return nil, ctx.Err() case signal := <-signals: if signal.Type != SignalTypeAnswer { + dialer.signalError(signaling, networkID, ErrorCodeIncomingConnectionIgnored) return nil, fmt.Errorf("received signal for non-answer: %s", signal.String()) } - description = &sdp.SessionDescription{} - if err := description.UnmarshalString(signal.Data); err != nil { + d := &sdp.SessionDescription{} + if err := d.UnmarshalString(signal.Data); err != nil { + dialer.signalError(signaling, networkID, ErrorCodeFailedToSetRemoteDescription) return nil, fmt.Errorf("decode answer: %w", err) } - desc, err := parseDescription(description) + desc, err := parseDescription(d) if err != nil { + dialer.signalError(signaling, networkID, ErrorCodeFailedToSetRemoteDescription) return nil, fmt.Errorf("parse offer: %w", err) } - c := newConn(ice, dtls, sctp, desc, d.Log, d.ConnectionID, networkID) - go d.handleConn(ctx, c, signals) + c := newConn(ice, dtls, sctp, desc, dialer.Log, dialer.ConnectionID, networkID, dialer.NetworkID, candidates) + go dialer.handleConn(ctx, c, signals) select { case <-ctx.Done(): + if errors.Is(err, context.DeadlineExceeded) { + dialer.signalError(signaling, networkID, ErrorCodeInactivityTimeout) + } return nil, ctx.Err() case <-c.candidateReceived: c.log.Debug("received first candidate") - if err := d.startTransports(c); err != nil { + if err := dialer.startTransports(c); err != nil { return nil, fmt.Errorf("start transports: %w", err) } c.handleTransports() @@ -211,7 +175,16 @@ func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Sig } } -func (d Dialer) startTransports(conn *Conn) error { +func (dialer Dialer) signalError(signaling Signaling, networkID uint64, code int) { + _ = signaling.WriteSignal(&Signal{ + Type: SignalTypeError, + Data: strconv.Itoa(code), + ConnectionID: dialer.ConnectionID, + NetworkID: networkID, + }) +} + +func (dialer Dialer) startTransports(conn *Conn) error { conn.log.Debug("starting ICE transport as controller") iceRole := webrtc.ICERoleControlling if err := conn.ice.Start(nil, conn.remote.ice, &iceRole); err != nil { @@ -230,13 +203,13 @@ func (d Dialer) startTransports(conn *Conn) error { return fmt.Errorf("start SCTP: %w", err) } var err error - conn.reliable, err = d.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ + conn.reliable, err = dialer.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ Label: "ReliableDataChannel", }) if err != nil { return fmt.Errorf("create ReliableDataChannel: %w", err) } - conn.unreliable, err = d.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ + conn.unreliable, err = dialer.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ Label: "UnreliableDataChannel", Ordered: false, }) @@ -246,13 +219,14 @@ func (d Dialer) startTransports(conn *Conn) error { return nil } -func (d Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Signal) { +func (dialer Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Signal) { for { select { case <-ctx.Done(): return case signal := <-signals: - if signal.Type == SignalTypeCandidate { + switch signal.Type { + case SignalTypeCandidate, SignalTypeError: if err := conn.handleSignal(signal); err != nil { conn.log.Error("error handling signal", internal.ErrAttr(err)) } @@ -261,18 +235,15 @@ func (d Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Sign } } -func (d Dialer) notifySignals(ctx context.Context, id, networkID uint64, signaling Signaling, c chan<- *Signal) { +func (dialer Dialer) notifySignals(ctx context.Context, id, networkID uint64, signaling Signaling, c chan<- *Signal) { for { - if ctx.Err() != nil { - return - } - signal, err := signaling.ReadSignal() + signal, err := signaling.ReadSignal(ctx.Done()) if err != nil { - d.Log.Error("error reading signal", internal.ErrAttr(err)) + dialer.Log.Error("error reading signal", internal.ErrAttr(err)) return } if signal.ConnectionID != id || signal.NetworkID != networkID { - d.Log.Error("unexpected connection ID or network ID", slog.Group("signal", signal)) + dialer.Log.Error("unexpected connection ID or network ID", slog.Group("signal", signal)) continue } c <- signal diff --git a/minecraft/nethernet/listener.go b/minecraft/nethernet/listener.go index 2dfcca94..53a7f48a 100644 --- a/minecraft/nethernet/listener.go +++ b/minecraft/nethernet/listener.go @@ -9,9 +9,9 @@ import ( "github.com/pion/webrtc/v4" "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" "log/slog" - "math/rand" "net" "strconv" + "strings" "sync" ) @@ -42,10 +42,12 @@ func (conf ListenConfig) Listen(networkID uint64, signaling Signaling) (*Listene networkID: networkID, incoming: make(chan *Conn), + + closed: make(chan struct{}), } var cancel context.CancelCauseFunc l.ctx, cancel = context.WithCancelCause(context.Background()) - go l.startListening(cancel) + go l.listen(cancel) return l, nil } @@ -59,11 +61,15 @@ type Listener struct { connections sync.Map incoming chan *Conn - once sync.Once + + closed chan struct{} + once sync.Once } func (l *Listener) Accept() (net.Conn, error) { select { + case <-l.closed: + return nil, net.ErrClosed case <-l.ctx.Done(): return nil, context.Cause(l.ctx) case conn := <-l.incoming: @@ -71,26 +77,42 @@ func (l *Listener) Accept() (net.Conn, error) { } } -// Addr currently returns a dummy address. -// TODO: Return something a valid address. func (l *Listener) Addr() net.Addr { - dummy, _ := net.ResolveUDPAddr("udp", ":19132") - return dummy + return &Addr{NetworkID: l.networkID} +} + +type Addr struct { + ConnectionID uint64 + NetworkID uint64 + Candidates []webrtc.ICECandidate +} + +func (addr *Addr) String() string { + b := &strings.Builder{} + b.WriteString(strconv.FormatUint(addr.NetworkID, 10)) + b.WriteByte(' ') + if addr.ConnectionID == 0 { + b.WriteByte('(') + b.WriteString(strconv.FormatUint(addr.ConnectionID, 10)) + b.WriteByte(')') + } + return b.String() } +func (addr *Addr) Network() string { return "nethernet" } + // ID returns the network ID of listener. func (l *Listener) ID() int64 { return int64(l.networkID) } -// PongData is currently a stub. -// TODO: Do something. +// PongData is a stub. func (l *Listener) PongData([]byte) {} -func (l *Listener) startListening(cancel context.CancelCauseFunc) { +func (l *Listener) listen(cancel context.CancelCauseFunc) { for { - signal, err := l.signaling.ReadSignal() + signal, err := l.signaling.ReadSignal(l.closed) if err != nil { - cancel(err) close(l.incoming) + cancel(err) return } @@ -103,6 +125,8 @@ func (l *Listener) startListening(cancel context.CancelCauseFunc) { err = l.handleOffer(signal) case SignalTypeCandidate: err = l.handleCandidate(signal) + case SignalTypeError: + err = l.handleError(signal) default: l.conf.Log.Debug("received signal for unknown type", "signal", signal) } @@ -161,7 +185,7 @@ func (l *Listener) handleOffer(signal *Signal) error { var ( // Local candidates gathered by webrtc.ICEGatherer - candidates []*webrtc.ICECandidate + candidates []webrtc.ICECandidate // Notifies that gathering for local candidates has finished. gatherFinished = make(chan struct{}) ) @@ -170,7 +194,7 @@ func (l *Listener) handleOffer(signal *Signal) error { close(gatherFinished) return } - candidates = append(candidates, candidate) + candidates = append(candidates, *candidate) }) if err := gatherer.Gather(); err != nil { return wrapSignalError(fmt.Errorf("gather local candidates: %w", err), ErrorCodeFailedToCreatePeerConnection) @@ -201,59 +225,11 @@ func (l *Listener) handleOffer(signal *Signal) error { sctpCapabilities := sctp.GetCapabilities() // Encode an answer using the local parameters! - d = &sdp.SessionDescription{ - Version: 0x0, - Origin: sdp.Origin{ - Username: "-", - SessionID: rand.Uint64(), - SessionVersion: 0x2, - NetworkType: "IN", - AddressType: "IP4", - UnicastAddress: "127.0.0.1", - }, - SessionName: "-", - TimeDescriptions: []sdp.TimeDescription{ - {}, - }, - Attributes: []sdp.Attribute{ - {Key: "group", Value: "BUNDLE 0"}, - {Key: "extmap-allow-mixed", Value: ""}, - {Key: "msid-semantic", Value: " WMS"}, - }, - MediaDescriptions: []*sdp.MediaDescription{ - { - MediaName: sdp.MediaName{ - Media: "application", - Port: sdp.RangedPort{ - Value: 9, - }, - Protos: []string{"UDP", "DTLS", "SCTP"}, - Formats: []string{"webrtc-datachannel"}, - }, - ConnectionInformation: &sdp.ConnectionInformation{ - NetworkType: "IN", - AddressType: "IP4", - Address: &sdp.Address{ - Address: "0.0.0.0", - }, - }, - Attributes: []sdp.Attribute{ - {Key: "ice-ufrag", Value: iceParams.UsernameFragment}, - {Key: "ice-pwd", Value: iceParams.Password}, - {Key: "ice-options", Value: "trickle"}, - {Key: "fingerprint", Value: fmt.Sprintf("%s %s", - dtlsParams.Fingerprints[0].Algorithm, - dtlsParams.Fingerprints[0].Value, - )}, - {Key: "setup", Value: "active"}, - {Key: "mid", Value: "0"}, - {Key: "sctp-port", Value: "5000"}, - {Key: "max-message-size", Value: strconv.FormatUint(uint64(sctpCapabilities.MaxMessageSize), 10)}, - }, - }, - }, - } - answer, err := d.Marshal() + answer, err := description{ + ice: iceParams, + dtls: dtlsParams, + sctp: sctpCapabilities, + }.encode() if err != nil { return wrapSignalError(fmt.Errorf("encode answer: %w", err), ErrorCodeFailedToCreateAnswer) } @@ -279,7 +255,7 @@ func (l *Listener) handleOffer(signal *Signal) error { } } - c := newConn(ice, dtls, sctp, desc, l.conf.Log, signal.ConnectionID, signal.NetworkID) + c := newConn(ice, dtls, sctp, desc, l.conf.Log, signal.ConnectionID, signal.NetworkID, l.networkID, candidates) l.connections.Store(signal.ConnectionID, c) go l.handleConn(c) @@ -358,9 +334,19 @@ func (l *Listener) handleCandidate(signal *Signal) error { return conn.(*Conn).handleSignal(signal) } +// handleError handles an incoming Signal of SignalTypeError. It looks up for a connection that has the same ID, and +// call the [Conn.handleSignal] method, which parses the data into error code and closes the connection as failed. +func (l *Listener) handleError(signal *Signal) error { + conn, ok := l.connections.Load(signal.ConnectionID) + if !ok { + return fmt.Errorf("no connection found for ID %d", signal.ConnectionID) + } + return conn.(*Conn).handleSignal(signal) +} + func (l *Listener) Close() error { l.once.Do(func() { - + close(l.closed) }) return nil } diff --git a/minecraft/nethernet/message.go b/minecraft/nethernet/message.go index bd861612..bc348920 100644 --- a/minecraft/nethernet/message.go +++ b/minecraft/nethernet/message.go @@ -5,29 +5,41 @@ import ( "io" ) -// TODO: Probably the structure of remote messages sent in both ReliableDataChannel and UnreliableDataChannel -// are changed since whenever, and the specification might be outdated. We need to reverse that too. +// message represents the structure of remote messages sent in ReliableDataChannel. +type message struct { + segments uint8 + data []byte +} -func (c *Conn) handleMessage(b []byte) error { +func parseMessage(b []byte) (*message, error) { if len(b) < 2 { - return io.ErrUnexpectedEOF + return nil, io.ErrUnexpectedEOF + } + return &message{ + segments: b[0], + data: b[1:], + }, nil +} + +func (c *Conn) handleMessage(b []byte) error { + msg, err := parseMessage(b) + if err != nil { + return fmt.Errorf("parse: %w", err) } - segments := b[0] - data := b[1:] - if c.promisedSegments > 0 && c.promisedSegments-1 != segments { - return fmt.Errorf("invalid promised segments: expected %d, got %d", c.promisedSegments-1, segments) + if c.message.segments > 0 && c.message.segments-1 != msg.segments { + return fmt.Errorf("invalid promised segments: expected %d, got %d", c.message.segments-1, msg.segments) } - c.promisedSegments = segments + c.message.segments = msg.segments - c.buf.Write(data) + c.message.data = append(c.message.data, msg.data...) - if c.promisedSegments > 0 { + if c.message.segments > 0 { return nil } - c.packets <- c.buf.Bytes() - c.buf.Reset() + c.packets <- c.message.data + c.message.data = nil return nil } diff --git a/minecraft/nethernet/network.go b/minecraft/nethernet/network.go new file mode 100644 index 00000000..61867f61 --- /dev/null +++ b/minecraft/nethernet/network.go @@ -0,0 +1,50 @@ +package nethernet + +import ( + "context" + "errors" + "fmt" + "github.com/sandertv/gophertunnel/minecraft" + "net" + "strconv" +) + +type Network struct { + Signaling Signaling +} + +func (n Network) DialContext(ctx context.Context, address string) (net.Conn, error) { + if n.Signaling == nil { + return nil, errors.New("minecraft/nethernet: Network.DialContext: Signaling is nil") + } + networkID, err := strconv.ParseUint(address, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse network ID: %w", err) + } + var d Dialer + return d.DialContext(ctx, networkID, n.Signaling) +} + +func (n Network) PingContext(context.Context, string) ([]byte, error) { + return nil, errors.New("minecraft/nethernet: Network.PingContext: not supported") +} + +func (n Network) Listen(address string) (minecraft.NetworkListener, error) { + if n.Signaling == nil { + return nil, errors.New("minecraft/nethernet: Network.Listen: Signaling is nil") + } + networkID, err := strconv.ParseUint(address, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse network ID: %w", err) + } + var cfg ListenConfig + return cfg.Listen(networkID, n.Signaling) +} + +func (Network) Encrypted() bool { return true } + +func (Network) BatchHeader() []byte { return nil } + +func NetworkAddress(networkID uint64) string { + return strconv.FormatUint(networkID, 10) +} diff --git a/minecraft/nethernet/signal.go b/minecraft/nethernet/signal.go index f75d95b1..b6814da7 100644 --- a/minecraft/nethernet/signal.go +++ b/minecraft/nethernet/signal.go @@ -13,7 +13,7 @@ import ( // I want to use one Signaling connection in both Listen and Dial, because it should work. type Signaling interface { - ReadSignal() (*Signal, error) + ReadSignal(cancel <-chan struct{}) (*Signal, error) WriteSignal(signal *Signal) error // Credentials will currently block until a credentials has received from the signaling service. This is usually @@ -82,7 +82,7 @@ func (s *Signal) String() string { return b.String() } -func formatICECandidate(id int, candidate *webrtc.ICECandidate, iceParams webrtc.ICEParameters) string { +func formatICECandidate(id int, candidate webrtc.ICECandidate, iceParams webrtc.ICEParameters) string { b := &strings.Builder{} b.WriteString("candidate:") b.WriteString(candidate.Foundation) diff --git a/minecraft/network.go b/minecraft/network.go index 6ff9a1c6..d12f1c35 100644 --- a/minecraft/network.go +++ b/minecraft/network.go @@ -28,8 +28,8 @@ type Network interface { // Encrypted returns a bool indicating whether an encryption has already been done on the Network side, and no // encryption is needed on Conn side. Encrypted() bool - // Batched returns a bool indicating whether packets should be batched when received/sent. - Batched() bool + // BatchHeader returns the header of compressed 'batches' from Minecraft, used for encoding/decoding packets. + 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 3ec2cfa5..9d283022 100644 --- a/minecraft/protocol/packet/decoder.go +++ b/minecraft/protocol/packet/decoder.go @@ -26,7 +26,7 @@ type Decoder struct { checkPacketLimit bool - batched bool + header []byte } // packetReader is used to read packets immediately instead of copying them in a buffer first. This is a @@ -37,15 +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, batched bool) *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, - batched: batched, + header: header, } } @@ -70,8 +70,8 @@ func (decoder *Decoder) DisableBatchPacketLimit() { } const ( - // batchHeader is the batchHeader of compressed 'batches' from Minecraft. - batchHeader = 0xfe + // header is the header of compressed 'batches' from Minecraft. + header = 0xfe // maximumInBatch is the maximum amount of packets that may be found in a batch. If a compressed batch has // more than this amount, decoding will fail. maximumInBatch = 812 @@ -89,53 +89,24 @@ func (decoder *Decoder) Decode() (packets [][]byte, err error) { data, err = decoder.pr.ReadPacket() } if err != nil { - return nil, fmt.Errorf("read: %w", err) + return nil, fmt.Errorf("read batch: %w", err) } - if decoder.batched { - if len(data) == 0 { - return nil, nil - } - if data[0] != batchHeader { - return nil, fmt.Errorf("decode batch: invalid batchHeader %x, expected %x", data[0], batchHeader) - } - data, err = decoder.decodePacket(data[1:]) - if err != nil { - return nil, fmt.Errorf("decode batch: %w", err) - } - - b := bytes.NewBuffer(data) - for b.Len() != 0 { - var length uint32 - if err := protocol.Varuint32(b, &length); err != nil { - return nil, fmt.Errorf("decode batch: read packet length: %w", err) - } - packets = append(packets, b.Next(int(length))) - } - if len(packets) > maximumInBatch && decoder.checkPacketLimit { - return nil, fmt.Errorf("decode batch: number of packets %v exceeds max=%v", len(packets), maximumInBatch) - } - return packets, nil - } else { - data, err = decoder.decodePacket(data) - if err != nil { - return nil, fmt.Errorf("decode single: %w", err) - } - - b := bytes.NewBuffer(data) - var length uint32 - if err := protocol.Varuint32(b, &length); err != nil { - return nil, fmt.Errorf("decode single: read packet single: %w", err) - } - return [][]byte{b.Next(int(length))}, nil + if len(data) == 0 { + return nil, nil } -} - -func (decoder *Decoder) decodePacket(data []byte) (packet []byte, err error) { + 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[h:] if decoder.encrypt != nil { decoder.encrypt.decrypt(data) if err := decoder.encrypt.verify(data); err != nil { // The packet did not have a correct checksum. - return nil, fmt.Errorf("verify: %w", err) + return nil, fmt.Errorf("verify batch: %w", err) } data = data[:len(data)-8] } @@ -146,13 +117,25 @@ func (decoder *Decoder) decodePacket(data []byte) (packet []byte, err error) { } else { compression, ok := CompressionByID(uint16(data[0])) if !ok { - return nil, fmt.Errorf("decompress: unknown compression algorithm %v", data[0]) + return nil, fmt.Errorf("decompress batch: unknown compression algorithm %v", data[0]) } data, err = compression.Decompress(data[1:]) if err != nil { - return nil, fmt.Errorf("decompress: %w", err) + return nil, fmt.Errorf("decompress batch: %w", err) } } } - return data, nil + + b := bytes.NewBuffer(data) + for b.Len() != 0 { + var length uint32 + if err := protocol.Varuint32(b, &length); err != nil { + return nil, fmt.Errorf("decode batch: read packet length: %w", err) + } + packets = append(packets, b.Next(int(length))) + } + if len(packets) > maximumInBatch && decoder.checkPacketLimit { + return nil, fmt.Errorf("decode batch: number of packets %v exceeds max=%v", len(packets), maximumInBatch) + } + return packets, nil } diff --git a/minecraft/protocol/packet/encoder.go b/minecraft/protocol/packet/encoder.go index 16913db3..f471412b 100644 --- a/minecraft/protocol/packet/encoder.go +++ b/minecraft/protocol/packet/encoder.go @@ -16,13 +16,13 @@ type Encoder struct { compression Compression encrypt *encrypt - batched bool + 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, batched bool) *Encoder { - return &Encoder{w: w, batched: batched} +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 @@ -45,65 +45,40 @@ func (encoder *Encoder) Encode(packets [][]byte) error { buf := internal.BufferPool.Get().(*bytes.Buffer) defer func() { // Reset the buffer, so we can return it to the buffer pool safely. - if encoder.batched { - buf.Reset() - } + buf.Reset() internal.BufferPool.Put(buf) }() l := make([]byte, 5) - if encoder.batched { - for _, packet := range packets { - // Each packet is prefixed with a varuint32 specifying the length of the packet. - if err := writeVaruint32(buf, uint32(len(packet)), l); err != nil { - return fmt.Errorf("encode batch: write packet length: %w", err) - } - if _, err := buf.Write(packet); err != nil { - return fmt.Errorf("encode batch: write packet payload: %w", err) - } - } - if err := encoder.encodePacket(buf.Bytes()); err != nil { - return fmt.Errorf("encode batch: %w", err) + for _, packet := range packets { + // Each packet is prefixed with a varuint32 specifying the length of the packet. + if err := writeVaruint32(buf, uint32(len(packet)), l); err != nil { + return fmt.Errorf("encode batch: write packet length: %w", err) } - } else { - // Encode packets individually - for _, packet := range packets { - if err := writeVaruint32(buf, uint32(len(packet)), l); err != nil { - return fmt.Errorf("encode single: write packet length: %w", err) - } - if _, err := buf.Write(packet); err != nil { - return fmt.Errorf("encode single: write packet payload: %w", err) - } - - if err := encoder.encodePacket(buf.Bytes()); err != nil { - return fmt.Errorf("encode single: %w", err) - } - buf.Reset() + if _, err := buf.Write(packet); err != nil { + return fmt.Errorf("encode batch: write packet payload: %w", err) } } - return nil -} -func (encoder *Encoder) encodePacket(data []byte) error { - var prepend []byte - if encoder.batched { - prepend = []byte{batchHeader} - } + data := buf.Bytes() + prepend := encoder.header if encoder.compression != nil { prepend = append(prepend, byte(encoder.compression.EncodeCompression())) var err error data, err = encoder.compression.Compress(data) if err != nil { - return fmt.Errorf("compress: %w", err) + return fmt.Errorf("compress batch: %w", err) } } data = append(prepend, data...) if encoder.encrypt != nil { + // If the encryption session is not nil, encryption is enabled, meaning we should encrypt the + // compressed data of this packet. data = encoder.encrypt.encrypt(data) } if _, err := encoder.w.Write(data); err != nil { - return fmt.Errorf("write: %w", err) + return fmt.Errorf("write batch: %w", err) } return nil } diff --git a/minecraft/raknet.go b/minecraft/raknet.go index 205c3e6e..2d33eaef 100644 --- a/minecraft/raknet.go +++ b/minecraft/raknet.go @@ -27,8 +27,8 @@ func (r RakNet) Listen(address string) (NetworkListener, error) { // Encrypted ... func (r RakNet) Encrypted() bool { return false } -// Batched ... -func (r RakNet) Batched() bool { return true } +// BatchHeader ... +func (r RakNet) BatchHeader() []byte { return []byte{0xfe} } // init registers the RakNet network. func init() { diff --git a/minecraft/room/status.go b/minecraft/room/status.go index 80b703d3..521efc91 100644 --- a/minecraft/room/status.go +++ b/minecraft/room/status.go @@ -29,6 +29,7 @@ type Connection struct { HostPort uint16 `json:"HostPort"` NetherNetID uint64 `json:"NetherNetId"` WebRTCNetworkID uint64 `json:"WebRTCNetworkId"` + RakNetGUID string `json:"RakNetGUID"` } const ( diff --git a/minecraft/world_test.go b/minecraft/world_test.go index 7cb663ad..75d4791a 100644 --- a/minecraft/world_test.go +++ b/minecraft/world_test.go @@ -1,31 +1,32 @@ -package minecraft +package minecraft_test import ( "context" + rand2 "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" "github.com/go-gl/mathgl/mgl32" "github.com/google/uuid" - "github.com/kr/pretty" - "github.com/pion/sdp/v3" + "github.com/sandertv/gophertunnel/minecraft" "github.com/sandertv/gophertunnel/minecraft/auth" "github.com/sandertv/gophertunnel/minecraft/franchise" "github.com/sandertv/gophertunnel/minecraft/franchise/signaling" "github.com/sandertv/gophertunnel/minecraft/nethernet" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "github.com/sandertv/gophertunnel/minecraft/room" + "github.com/sandertv/gophertunnel/xsapi/xal" "log/slog" "net" "os" + "time" "github.com/sandertv/gophertunnel/minecraft/protocol" - "github.com/sandertv/gophertunnel/playfab" "github.com/sandertv/gophertunnel/xsapi" "github.com/sandertv/gophertunnel/xsapi/mpsd" "golang.org/x/oauth2" - "golang.org/x/text/language" "math/rand" - "strconv" "strings" "testing" ) @@ -34,115 +35,103 @@ import ( func TestWorldListen(t *testing.T) { discovery, err := franchise.Discover(protocol.CurrentVersion) if err != nil { - t.Fatalf("discover: %s", err) + t.Fatalf("error retrieving discovery: %s", err) } a := new(franchise.AuthorizationEnvironment) if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("decode environment: %s", err) + t.Fatalf("error reading environment for authorization: %s", err) } - - src := TokenSource(t, "franchise/internal/test/auth.tok", auth.TokenSource, func(old *oauth2.Token) (new *oauth2.Token, err error) { - return auth.RefreshTokenSource(old).Token() - }) - x, err := auth.RequestXBLToken(context.Background(), src, "http://xboxlive.com") - if err != nil { - t.Fatalf("error requesting XBL token: %s", err) - } - playfabXBL, err := auth.RequestXBLToken(context.Background(), src, "http://playfab.xboxlive.com/") - if err != nil { - t.Fatalf("error requesting XBL token: %s", err) + s := new(signaling.Environment) + if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("error reading environment for signaling: %s", err) } - identity, err := playfab.Login{ - Title: "20CA2", - CreateAccount: true, - }.WithXBLToken(playfabXBL).Login() + tok, err := readToken("franchise/internal/test/auth.tok", auth.TokenSource) if err != nil { - t.Fatalf("error logging in to playfab: %s", err) + t.Fatalf("error reading token: %s", err) } + src := auth.RefreshTokenSource(tok) - region, _ := language.English.Region() - - conf := &franchise.TokenConfig{ - Device: &franchise.DeviceConfig{ - ApplicationType: franchise.ApplicationTypeMinecraftPE, - Capabilities: []string{franchise.CapabilityRayTracing}, - GameVersion: protocol.CurrentVersion, - ID: uuid.New(), - Memory: strconv.FormatUint(rand.Uint64(), 10), - Platform: franchise.PlatformWindows10, - PlayFabTitleID: a.PlayFabTitleID, - StorePlatform: franchise.StorePlatformUWPStore, - Type: franchise.DeviceTypeWindows10, - }, - User: &franchise.UserConfig{ - Language: language.English, - LanguageCode: language.AmericanEnglish, - RegionCode: region.String(), - Token: identity.SessionTicket, - TokenType: franchise.TokenTypePlayFab, - }, + refresh, cancel := context.WithCancel(context.Background()) + defer cancel() + prov := franchise.PlayFabXBLIdentityProvider{ Environment: a, + TokenSource: xal.RefreshTokenSourceContext(refresh, src, "http://playfab.xboxlive.com/"), } - s := new(signaling.Environment) - if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("decode environment: %s", err) - } - sd := signaling.Dialer{ + d := signaling.Dialer{ NetworkID: rand.Uint64(), } - signalingConn, err := sd.DialContext(context.Background(), tokenConfigSource(func() (*franchise.TokenConfig, error) { - return conf, nil - }), s) + + dial, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + conn, err := d.DialContext(dial, prov, s) if err != nil { - t.Fatal(err) + t.Fatalf("error dialing signaling: %s", err) } t.Cleanup(func() { - if err := signalingConn.Close(); err != nil { - t.Errorf("clean up: error closing: %s", err) + if err := conn.Close(); err != nil { + t.Fatalf("error closing signaling: %s", err) } }) - var ( - displayClaims = x.AuthorizationToken.DisplayClaims.UserInfo[0] - name = strings.ToUpper(uuid.NewString()) // The name of the session. - ) - custom, err := json.Marshal(map[string]any{ - "Joinability": "joinable_by_friends", - "hostName": displayClaims.GamerTag, - "ownerId": displayClaims.XUID, - "rakNetGUID": "", - "version": "1.21.2", - "levelId": "lhhPZjgNAQA=", - "worldName": name, - "worldType": "Creative", - "protocol": 686, - "MemberCount": 1, - "MaxMemberCount": 8, - "BroadcastSetting": 3, - "LanGame": true, - "isEditorWorld": false, - "TransportLayer": 2, // Zero means RakNet, and two means NetherNet. - "WebRTCNetworkId": sd.NetworkID, - "OnlineCrossPlatformGame": true, - "CrossPlayDisabled": false, - "TitleId": 0, - "SupportedConnections": []map[string]any{ + // A token source that refreshes a token used for generic Xbox Live services. + x := xal.RefreshTokenSourceContext(refresh, src, "http://xboxlive.com") + xt, err := x.Token() + if err != nil { + t.Fatalf("error refreshing xbox live token: %s", err) + } + claimer, ok := xt.(xsapi.DisplayClaimer) + if !ok { + t.Fatalf("xbox live token %T does not implement xsapi.DisplayClaimer", xt) + } + displayClaims := claimer.DisplayClaims() + + // The name of the session being published. This seems always to be generated + // randomly, referenced as "GUID" of the session. + name := strings.ToUpper(uuid.NewString()) + + levelID := make([]byte, 8) + _, _ = rand2.Read(levelID) + + custom, err := json.Marshal(room.Status{ + Joinability: room.JoinabilityJoinableByFriends, + HostName: displayClaims.GamerTag, + OwnerID: displayClaims.XUID, + RakNetGUID: "", + // This is displayed as the suffix of the world name. + Version: protocol.CurrentVersion, + LevelID: base64.StdEncoding.EncodeToString(levelID), + WorldName: "TestWorldListen: " + name, + WorldType: room.WorldTypeCreative, + // The game seems checking this field before joining a session, causes + // RequestNetworkSettings packet not being even sent to the remote host. + Protocol: protocol.CurrentProtocol, + MemberCount: 1, + MaxMemberCount: 8, + BroadcastSetting: room.BroadcastSettingFriendsOfFriends, + LanGame: true, + IsEditorWorld: false, + TransportLayer: 2, + WebRTCNetworkID: d.NetworkID, + OnlineCrossPlatformGame: true, + CrossPlayDisabled: false, + TitleID: 0, + SupportedConnections: []room.Connection{ { - "ConnectionType": 3, - "HostIpAddress": "", - "HostPort": 0, - "NetherNetId": sd.NetworkID, - "WebRTCNetworkId": sd.NetworkID, - "RakNetGUID": "UNASSIGNED_RAKNET_GUID", + ConnectionType: 3, // WebSocketsWebRTCSignaling + HostIPAddress: "", + HostPort: 0, + NetherNetID: d.NetworkID, + WebRTCNetworkID: d.NetworkID, + RakNetGUID: "UNASSIGNED_RAKNET_GUID", }, }, }) if err != nil { t.Fatalf("error encoding custom properties: %s", err) } - pub := mpsd.PublishConfig{ + cfg := mpsd.PublishConfig{ Description: &mpsd.SessionDescription{ Properties: &mpsd.SessionProperties{ System: &mpsd.SessionPropertiesSystem{ @@ -153,10 +142,11 @@ func TestWorldListen(t *testing.T) { }, }, } - session, err := pub.PublishContext(context.Background(), &tokenSource{ - x: x, - }, mpsd.SessionReference{ - ServiceConfigID: uuid.MustParse("4fc10100-5f7a-4470-899b-280835760c07"), + + publish, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + session, err := cfg.PublishContext(publish, x, mpsd.SessionReference{ + ServiceConfigID: serviceConfigID, TemplateName: "MinecraftLobby", Name: name, }) @@ -165,39 +155,40 @@ func TestWorldListen(t *testing.T) { } t.Cleanup(func() { if err := session.Close(); err != nil { - t.Errorf("error closing session: %s", err) + t.Fatalf("error closing session: %s", err) } }) - t.Logf("Network ID: %d", sd.NetworkID) t.Logf("Session Name: %q", name) - - RegisterNetwork("nethernet", &network{ - networkID: sd.NetworkID, - signaling: signalingConn, - }) + t.Logf("Network ID: %d", d.NetworkID) slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }))) - l, err := Listen("nethernet", "") + minecraft.RegisterNetwork("nethernet", &nethernet.Network{ + Signaling: conn, + }) + + l, err := minecraft.Listen("nethernet", nethernet.NetworkAddress(d.NetworkID)) if err != nil { - t.Fatal(err) + t.Fatalf("error listening: %s", err) } t.Cleanup(func() { if err := l.Close(); err != nil { - t.Fatal(err) + t.Fatalf("error closing listener: %s", err) } }) for { - conn, err := l.Accept() + netConn, err := l.Accept() if err != nil { - t.Fatal(err) + if !errors.Is(err, net.ErrClosed) { + t.Fatalf("error accepting conn: %s", err) + } } - c := conn.(*Conn) - _ = c.StartGame(GameData{ + c := netConn.(*minecraft.Conn) + if err := c.StartGame(minecraft.GameData{ WorldName: "NetherNet", WorldSeed: 0, Difficulty: 0, @@ -209,186 +200,120 @@ func TestWorldListen(t *testing.T) { WorldGameMode: 1, Time: rand.Int63(), PlayerPermissions: 2, - }) + // Allow inviting player into the world. + GamePublishSetting: 3, + }); err != nil { + t.Fatalf("error starting game: %s", err) + } } } +var serviceConfigID = uuid.MustParse("4fc10100-5f7a-4470-899b-280835760c07") + +// TestWorldDial connects to a world. Before running the test, you need to capture the network ID of +// the world to join, and fill in the constant below. func TestWorldDial(t *testing.T) { // TODO: Implement looking up sessions and find a network ID from the response. - // You need to fill in this field before running the test. - const remoteNetworkID = 0 + const remoteNetworkID = 9511338490860978050 discovery, err := franchise.Discover(protocol.CurrentVersion) if err != nil { - t.Fatalf("discover: %s", err) + t.Fatalf("error retrieving discovery: %s", err) } a := new(franchise.AuthorizationEnvironment) if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("decode environment: %s", err) + t.Fatalf("error reading environment for authorization: %s", err) } - - src := TokenSource(t, "franchise/internal/test/auth.tok", auth.TokenSource, func(old *oauth2.Token) (new *oauth2.Token, err error) { - return auth.RefreshTokenSource(old).Token() - }) - playfabXBL, err := auth.RequestXBLToken(context.Background(), src, "http://playfab.xboxlive.com/") - if err != nil { - t.Fatalf("error requesting XBL token: %s", err) + s := new(signaling.Environment) + if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("error reading environment for signaling: %s", err) } - identity, err := playfab.Login{ - Title: "20CA2", - CreateAccount: true, - }.WithXBLToken(playfabXBL).Login() + tok, err := readToken("franchise/internal/test/auth.tok", auth.TokenSource) if err != nil { - t.Fatalf("error logging in to playfab: %s", err) + t.Fatalf("error reading token: %s", err) } + src := auth.RefreshTokenSource(tok) - region, _ := language.English.Region() - - conf := &franchise.TokenConfig{ - Device: &franchise.DeviceConfig{ - ApplicationType: franchise.ApplicationTypeMinecraftPE, - Capabilities: []string{franchise.CapabilityRayTracing}, - GameVersion: protocol.CurrentVersion, - ID: uuid.New(), - Memory: strconv.FormatUint(rand.Uint64(), 10), - Platform: franchise.PlatformWindows10, - PlayFabTitleID: a.PlayFabTitleID, - StorePlatform: franchise.StorePlatformUWPStore, - Type: franchise.DeviceTypeWindows10, - }, - User: &franchise.UserConfig{ - Language: language.English, - LanguageCode: language.AmericanEnglish, - RegionCode: region.String(), - Token: identity.SessionTicket, - TokenType: franchise.TokenTypePlayFab, - }, + refresh, cancel := context.WithCancel(context.Background()) + defer cancel() + prov := franchise.PlayFabXBLIdentityProvider{ Environment: a, + TokenSource: xal.RefreshTokenSourceContext(refresh, src, "http://playfab.xboxlive.com/"), } - s := new(signaling.Environment) - if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("decode environment: %s", err) - } - sd := signaling.Dialer{ + d := signaling.Dialer{ NetworkID: rand.Uint64(), } - signalingConn, err := sd.DialContext(context.Background(), tokenConfigSource(func() (*franchise.TokenConfig, error) { - return conf, nil - }), s) + + dial, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + sig, err := d.DialContext(dial, prov, s) if err != nil { - t.Fatal(err) + t.Fatalf("error dialing signaling: %s", err) } t.Cleanup(func() { - if err := signalingConn.Close(); err != nil { - t.Errorf("clean up: error closing: %s", err) + if err := sig.Close(); err != nil { + t.Fatalf("error closing signaling: %s", err) } }) + // TODO: Implement joining a session. + // A token source that refreshes a token used for generic Xbox Live services. + //x := xal.RefreshTokenSourceContext(refresh, src, "http://xboxlive.com") + + t.Logf("Network ID: %d", d.NetworkID) + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }))) - RegisterNetwork("nethernet", &network{ - networkID: sd.NetworkID, - signaling: signalingConn, + minecraft.RegisterNetwork("nethernet", &nethernet.Network{ + Signaling: sig, }) - conn, err := Dialer{ - TokenSource: auth.RefreshTokenSource(src), - }.Dial("nethernet", strconv.FormatUint(remoteNetworkID, 10)) + conn, err := minecraft.Dialer{ + TokenSource: src, + }.DialTimeout("nethernet", nethernet.NetworkAddress(remoteNetworkID), time.Second*15) if err != nil { - t.Fatal(err) + t.Fatalf("error dialing: %s", err) } t.Cleanup(func() { if err := conn.Close(); err != nil { - t.Fatal(err) + t.Fatalf("error closing session: %s", err) } }) if err := conn.DoSpawn(); err != nil { - t.Fatalf("error spawning in: %s", err) + t.Fatalf("error spawning: %s", err) } - _ = conn.WritePacket(&packet.Text{ + if err := conn.WritePacket(&packet.Text{ TextType: packet.TextTypeChat, SourceName: conn.IdentityData().DisplayName, Message: "Successful", XUID: conn.IdentityData().XUID, - }) -} - -func TestDecodeOffer(t *testing.T) { - d := &sdp.SessionDescription{} - if err := d.UnmarshalString("v=0\r\no=- 8735254407289596231 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:gMX+\r\na=ice-pwd:4SN4mwDq5k9Q2LwCiMqxacaM\r\na=ice-options:trickle\r\na=fingerprint:sha-256 B2:35:F2:64:66:B3:73:B3:BB:8D:EE:AF:D8:96:6C:29:9C:A9:E8:94:B3:67:E1:B9:77:8C:18:19:EA:29:7D:12\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"); err != nil { - t.Fatal(err) - } - pretty.Println(d) -} - -type network struct { - networkID uint64 - signaling nethernet.Signaling -} - -func (n network) DialContext(ctx context.Context, addr string) (net.Conn, error) { - networkID, err := strconv.ParseUint(addr, 10, 64) - if err != nil { - return nil, fmt.Errorf("parse network ID: %w", err) + }); err != nil { + t.Fatalf("error writing packet: %s", err) } - var d nethernet.Dialer - return d.DialContext(ctx, networkID, n.signaling) -} - -func (network) PingContext(context.Context, string) ([]byte, error) { - return nil, errors.New("not supported") -} - -func (n network) Listen(string) (NetworkListener, error) { - var c nethernet.ListenConfig - return c.Listen(n.networkID, n.signaling) -} - -func (network) Encrypted() bool { return true } -func (network) Batched() bool { return false } - -// tokenSource is an implementation of xsapi.TokenSource that simply returns a *auth.XBLToken. -type tokenSource struct{ x *auth.XBLToken } - -func (t *tokenSource) Token() (xsapi.Token, error) { - return &token{t.x}, nil -} - -type token struct { - *auth.XBLToken -} - -func (t *token) DisplayClaims() xsapi.DisplayClaims { - return t.AuthorizationToken.DisplayClaims.UserInfo[0] -} - -type tokenConfigSource func() (*franchise.TokenConfig, error) - -func (f tokenConfigSource) TokenConfig() (*franchise.TokenConfig, error) { return f() } - -func TokenSource(t *testing.T, path string, src oauth2.TokenSource, hooks ...RefreshTokenFunc) *oauth2.Token { - tok, err := readTokenSource(path, src) - if err != nil { - t.Fatalf("error reading token: %s", err) - } - for _, h := range hooks { - tok, err = h(tok) - if err != nil { - t.Fatalf("error refreshing token: %s", err) + // Try decoding deferred packets received from the connection. + go func() { + for { + pk, err := conn.ReadPacket() + if err != nil { + if !strings.Contains(err.Error(), "use of closed network connection") { + t.Errorf("error reading packet: %s", err) + } + return + } + _ = pk } - } - return tok -} + }() -type RefreshTokenFunc func(old *oauth2.Token) (new *oauth2.Token, err error) + time.Sleep(time.Second * 15) +} -func readTokenSource(path string, src oauth2.TokenSource) (t *oauth2.Token, err error) { +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 { diff --git a/playfab/login.go b/playfab/login.go index 9812bde8..d9695fe4 100644 --- a/playfab/login.go +++ b/playfab/login.go @@ -1,12 +1,12 @@ package playfab import ( - "github.com/sandertv/gophertunnel/minecraft/auth" "github.com/sandertv/gophertunnel/playfab/internal" "github.com/sandertv/gophertunnel/playfab/title" + "github.com/sandertv/gophertunnel/xsapi" ) -type Login struct { +type LoginConfig struct { Title title.Title `json:"TitleId,omitempty"` CreateAccount bool `json:"CreateAccount,omitempty"` CustomTags map[string]any `json:"CustomTags,omitempty"` @@ -56,15 +56,15 @@ type ProfileConstraints struct { ShowValuesToDate bool `json:"ShowValuesToDate,omitempty"` } -func (l Login) WithXBLToken(x *auth.XBLToken) Login { +func (l LoginConfig) WithXbox(t xsapi.Token) LoginConfig { if l.Route == "" { l.Route = "/Client/LoginWithXbox" } - l.XboxToken = "XBL3.0 x=" + x.AuthorizationToken.DisplayClaims.UserInfo[0].UserHash + ";" + x.AuthorizationToken.Token + l.XboxToken = t.String() return l } -func (l Login) Login() (*Identity, error) { +func (l LoginConfig) Login() (*Identity, error) { if l.Route == "" { panic("playfab/login: must provide an identity provider/route to login") } diff --git a/xsapi/token.go b/xsapi/token.go index 182a8564..645ca9dc 100644 --- a/xsapi/token.go +++ b/xsapi/token.go @@ -6,6 +6,7 @@ import ( type Token interface { SetAuthHeader(req *http.Request) + String() string } type TokenSource interface { diff --git a/xsapi/xal/token_source.go b/xsapi/xal/token_source.go index d88912e9..d798a0b3 100644 --- a/xsapi/xal/token_source.go +++ b/xsapi/xal/token_source.go @@ -57,3 +57,7 @@ type token struct { func (t *token) DisplayClaims() xsapi.DisplayClaims { return t.AuthorizationToken.DisplayClaims.UserInfo[0] } + +func (t *token) String() string { + return fmt.Sprintf("XBL3.0 x=%s;%s", t.AuthorizationToken.DisplayClaims.UserInfo[0].UserHash, t.AuthorizationToken.Token) +} From e4fd4152ec74f2eae759da89134c2b892d3d6ff5 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Fri, 23 Aug 2024 05:30:49 +0900 Subject: [PATCH 09/14] minecraft/nethernet/conn.go: Revert debugging changes --- minecraft/nethernet/conn.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/minecraft/nethernet/conn.go b/minecraft/nethernet/conn.go index b9ca4119..a92c4241 100644 --- a/minecraft/nethernet/conn.go +++ b/minecraft/nethernet/conn.go @@ -153,7 +153,7 @@ func (c *Conn) handleTransports() { }) c.reliable.OnClose(func() { - c.Close() + _ = c.Close() }) c.unreliable.OnClose(func() { @@ -290,7 +290,7 @@ type description struct { func (desc description) encode() ([]byte, error) { d := &sdp.SessionDescription{ - Version: 0x0, + Version: 0x2, Origin: sdp.Origin{ Username: "-", SessionID: rand.Uint64(), From 53315af3329c5898a2da82fb6556f2bb9397c103 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Tue, 27 Aug 2024 01:07:01 +0900 Subject: [PATCH 10/14] minecraft, playfab, xsapi: Clean up code. Allow looking up sessions, inviting a user, and joining a world. --- minecraft/discovery_test.go | 442 ------------------ minecraft/franchise/playfab.go | 30 +- minecraft/franchise/signaling/conn.go | 31 +- minecraft/franchise/signaling/conn_test.go | 9 +- minecraft/franchise/signaling/dial.go | 45 +- minecraft/franchise/token.go | 4 + minecraft/franchise/token_test.go | 10 +- minecraft/franchise/transport.go | 62 +++ .../{nethernet/network.go => nethernet.go} | 27 +- minecraft/nethernet/conn.go | 26 +- minecraft/nethernet/dial.go | 82 ++-- minecraft/nethernet/discovery/crypto.go | 38 ++ minecraft/nethernet/discovery/listener.go | 293 ++++++++++++ .../nethernet/discovery/listener_test.go | 53 +++ minecraft/nethernet/discovery/packet.go | 134 ++++++ .../nethernet/discovery/packet_message.go | 31 ++ .../nethernet/discovery/packet_request.go | 11 + .../nethernet/discovery/packet_response.go | 32 ++ minecraft/nethernet/discovery/room.go | 53 +++ minecraft/nethernet/discovery/server_data.go | 68 +++ minecraft/nethernet/listener.go | 49 +- minecraft/nethernet/signal.go | 5 +- minecraft/world_test.go | 217 ++++++--- playfab/identity.go | 56 ++- playfab/login.go | 21 +- playfab/xbox_live.go | 31 ++ xsapi/internal/transport.go | 22 + xsapi/mpsd/activity.go | 125 ++++- xsapi/mpsd/commit.go | 6 +- xsapi/mpsd/handler.go | 22 + xsapi/mpsd/invite.go | 66 +++ xsapi/mpsd/join.go | 14 + xsapi/mpsd/publish.go | 42 +- xsapi/mpsd/session.go | 40 +- xsapi/mpsd/subscription.go | 58 +++ xsapi/rta/conn.go | 28 +- xsapi/rta/dial.go | 19 +- xsapi/xal/token_source.go | 8 +- 38 files changed, 1534 insertions(+), 776 deletions(-) delete mode 100644 minecraft/discovery_test.go create mode 100644 minecraft/franchise/transport.go rename minecraft/{nethernet/network.go => nethernet.go} (53%) create mode 100644 minecraft/nethernet/discovery/crypto.go create mode 100644 minecraft/nethernet/discovery/listener.go create mode 100644 minecraft/nethernet/discovery/listener_test.go create mode 100644 minecraft/nethernet/discovery/packet.go create mode 100644 minecraft/nethernet/discovery/packet_message.go create mode 100644 minecraft/nethernet/discovery/packet_request.go create mode 100644 minecraft/nethernet/discovery/packet_response.go create mode 100644 minecraft/nethernet/discovery/room.go create mode 100644 minecraft/nethernet/discovery/server_data.go create mode 100644 playfab/xbox_live.go create mode 100644 xsapi/internal/transport.go create mode 100644 xsapi/mpsd/handler.go create mode 100644 xsapi/mpsd/invite.go create mode 100644 xsapi/mpsd/join.go create mode 100644 xsapi/mpsd/subscription.go diff --git a/minecraft/discovery_test.go b/minecraft/discovery_test.go deleted file mode 100644 index 907ca65f..00000000 --- a/minecraft/discovery_test.go +++ /dev/null @@ -1,442 +0,0 @@ -package minecraft - -/*import ( - "bytes" - "context" - "crypto/aes" - "crypto/hmac" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "fmt" - "github.com/andreburgaud/crypt2go/ecb" - "github.com/andreburgaud/crypt2go/padding" - "github.com/go-gl/mathgl/mgl32" - "github.com/sandertv/gophertunnel/minecraft/nethernet" - "github.com/sandertv/gophertunnel/minecraft/protocol" - "io" - "log/slog" - "math/rand" - "net" - "os" - "testing" -) - -// TestDiscovery is a messed up test for LAN discovery. Its purpose is to -// debug encoding/decoding packets sent for discovery. -func TestDiscovery(t *testing.T) { - // Please fill in this constant before running the test. - const discoveryAddress = ":7551" - - l, err := net.ListenPacket("udp", discoveryAddress) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := l.Close(); err != nil { - t.Fatalf("error closing discovery conn: %s", err) - } - }) - - conn := &lan{ - networkID: rand.Uint64(), - conn: l, - signals: make(chan *nethernet.Signal), - t: t, - } - var cancel context.CancelCauseFunc - conn.ctx, cancel = context.WithCancelCause(context.Background()) - go conn.background(cancel) - - RegisterNetwork("nethernet", &network{ - networkID: conn.networkID, - signaling: conn, - }) - - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug, - }))) - - listener, err := Listen("nethernet", "") - if err != nil { - t.Fatalf("error listening: %s", err) - } - t.Cleanup(func() { - if err := listener.Close(); err != nil { - t.Fatalf("error closing listener: %s", err) - } - }) - - for { - netConn, err := listener.Accept() - if err != nil { - t.Fatal(err) - } - minecraftConn := netConn.(*Conn) - if err := minecraftConn.StartGame(GameData{ - WorldName: "NetherNet", - WorldSeed: 0, - Difficulty: 0, - EntityUniqueID: rand.Int63(), - EntityRuntimeID: rand.Uint64(), - PlayerGameMode: 1, - PlayerPosition: mgl32.Vec3{}, - WorldSpawn: protocol.BlockPos{}, - WorldGameMode: 1, - Time: rand.Int63(), - PlayerPermissions: 2, - }); err != nil { - t.Fatalf("error starting game: %s", err) - } - } -} - -type lan struct { - networkID uint64 - conn net.PacketConn - signals chan *nethernet.Signal - ctx context.Context - t testing.TB -} - -func (s *lan) WriteSignal(sig *nethernet.Signal) error { - select { - case <-s.ctx.Done(): - return context.Cause(s.ctx) - default: - } - - msg, err := encodePacket(s.networkID, &messagePacket{ - recipientID: sig.NetworkID, - data: sig.String(), - }) - if err != nil { - return fmt.Errorf("encode packet: %w", err) - } - _, err = s.conn.WriteTo(msg, &net.UDPAddr{ - IP: net.IPv4bcast, - Port: 7551, - }) - return err -} - -func (s *lan) ReadSignal() (*nethernet.Signal, error) { - select { - case <-s.ctx.Done(): - return nil, context.Cause(s.ctx) - case signal := <-s.signals: - return signal, nil - } -} - -func (s *lan) Credentials() (*nethernet.Credentials, error) { - select { - case <-s.ctx.Done(): - return nil, context.Cause(s.ctx) - default: - return nil, nil - } -} - -func (s *lan) background(cancel context.CancelCauseFunc) { - for { - b := make([]byte, 1024) - n, _, err := s.conn.ReadFrom(b) - if err != nil { - cancel(err) - return - } - senderID, pk, err := decodePacket(b[:n]) - if err != nil { - s.t.Errorf("error decoding packet: %s", err) - continue - } - if senderID == s.networkID { - continue - } - switch pk := pk.(type) { - case *requestPacket: - err = s.handleRequest() - case *messagePacket: - err = s.handleMessage(senderID, pk) - default: - s.t.Logf("unhandled packet: %#v", pk) - } - if err != nil { - s.t.Errorf("error handling packet (%#v): %s", pk, err) - } - } -} - -func (s *lan) handleRequest() error { - resp, err := encodePacket(s.networkID, &responsePacket{ - version: 0x2, - serverName: "Da1z981?", - levelName: "LAN Debugging", - gameType: 2, - playerCount: 1, - maxPlayerCount: 30, - editorWorld: false, - transportLayer: 2, - }) - if err != nil { - return fmt.Errorf("encode response: %w", err) - } - if _, err := s.conn.WriteTo(resp, &net.UDPAddr{ - IP: net.IPv4bcast, - Port: 7551, - }); err != nil { - return fmt.Errorf("write response: %w", err) - } - return nil -} - -func (s *lan) handleMessage(senderID uint64, pk *messagePacket) error { - signal := &nethernet.Signal{} - if err := signal.UnmarshalText([]byte(pk.data)); err != nil { - return fmt.Errorf("decode signal: %w", err) - } - signal.NetworkID = senderID - s.signals <- signal - return nil -} - -func encodePacket(senderID uint64, pk discoveryPacket) ([]byte, error) { - buf := &bytes.Buffer{} - pk.write(buf) - - headerBuf := &bytes.Buffer{} - h := &packetHeader{ - length: uint16(20 + buf.Len()), - packetID: pk.id(), - senderID: senderID, - } - h.write(headerBuf) - payload := append(headerBuf.Bytes(), buf.Bytes()...) - data, err := encryptECB(payload) - if err != nil { - return nil, fmt.Errorf("encrypt: %w", err) - } - - hm := hmac.New(sha256.New, key[:]) - hm.Write(payload) - data = append(append(hm.Sum(nil), data...)) - return data, nil -} - -func decodePacket(b []byte) (uint64, discoveryPacket, error) { - if len(b) < 32 { - return 0, nil, io.ErrUnexpectedEOF - } - data, err := decryptECB(b[32:]) - if err != nil { - return 0, nil, fmt.Errorf("decrypt: %w", err) - } - - hm := hmac.New(sha256.New, key[:]) - hm.Write(data) - if checksum := hm.Sum(nil); !bytes.Equal(b[:32], checksum) { - return 0, nil, fmt.Errorf("checksum mismatch: %x != %x", b[:32], checksum) - } - buf := bytes.NewBuffer(data) - - h := &packetHeader{} - if err := h.read(buf); err != nil { - return 0, nil, fmt.Errorf("decode header: %w", err) - } - var pk discoveryPacket - switch h.packetID { - case idRequest: - pk = &requestPacket{} - case idResponse: - pk = &responsePacket{} - case idMessage: - pk = &messagePacket{} - default: - return h.senderID, nil, fmt.Errorf("unknown packet ID: %d", h.packetID) - } - if err := pk.read(buf); err != nil { - return h.senderID, nil, fmt.Errorf("read payload: %w", err) - } - return h.senderID, pk, nil -} - -const ( - idRequest uint16 = iota - idResponse - idMessage -) - -type discoveryPacket interface { - id() uint16 - read(buf *bytes.Buffer) error - write(buf *bytes.Buffer) -} - -type requestPacket struct{} - -func (*requestPacket) id() uint16 { return idRequest } -func (*requestPacket) read(*bytes.Buffer) error { return nil } -func (*requestPacket) write(*bytes.Buffer) {} - -type responsePacket struct { - version uint8 - serverName string - levelName string - gameType int32 - playerCount int32 - maxPlayerCount int32 - editorWorld bool - transportLayer int32 -} - -func (*responsePacket) id() uint16 { return idResponse } -func (pk *responsePacket) read(buf *bytes.Buffer) error { - var applicationDataLength uint32 - if err := binary.Read(buf, binary.LittleEndian, &applicationDataLength); err != nil { - return fmt.Errorf("read application data length: %w", err) - } - data := buf.Next(int(applicationDataLength)) - n, err := hex.Decode(data, data) - if err != nil { - return fmt.Errorf("decode application data: %w", err) - } - - a := bytes.NewBuffer(data[:n]) - - if err := binary.Read(a, binary.LittleEndian, &pk.version); err != nil { - return fmt.Errorf("read version: %w", err) - } - var length uint8 - if err := binary.Read(a, binary.LittleEndian, &length); err != nil { - return fmt.Errorf("read server name length: %w", err) - } - pk.serverName = string(a.Next(int(length))) - if err := binary.Read(a, binary.LittleEndian, &length); err != nil { - return fmt.Errorf("read level name length: %w", err) - } - pk.levelName = string(a.Next(int(length))) - if err := binary.Read(a, binary.LittleEndian, &pk.gameType); err != nil { - return fmt.Errorf("read game type: %w", err) - } - if err := binary.Read(a, binary.LittleEndian, &pk.playerCount); err != nil { - return fmt.Errorf("read player count: %w", err) - } - if err := binary.Read(a, binary.LittleEndian, &pk.maxPlayerCount); err != nil { - return fmt.Errorf("read max player count: %w", err) - } - if err := binary.Read(a, binary.LittleEndian, &pk.editorWorld); err != nil { - return fmt.Errorf("read editor world: %w", err) - } - if err := binary.Read(a, binary.LittleEndian, &pk.transportLayer); err != nil { - return fmt.Errorf("read transport layer: %w", err) - } - - return nil -} -func (pk *responsePacket) write(buf *bytes.Buffer) { - a := &bytes.Buffer{} - - _ = binary.Write(a, binary.LittleEndian, pk.version) - _ = binary.Write(a, binary.LittleEndian, uint8(len(pk.serverName))) - a.WriteString(pk.serverName) - _ = binary.Write(a, binary.LittleEndian, uint8(len(pk.levelName))) - a.WriteString(pk.levelName) - _ = binary.Write(a, binary.LittleEndian, pk.gameType) - _ = binary.Write(a, binary.LittleEndian, pk.playerCount) - _ = binary.Write(a, binary.LittleEndian, pk.maxPlayerCount) - _ = binary.Write(a, binary.LittleEndian, pk.editorWorld) - _ = binary.Write(a, binary.LittleEndian, pk.transportLayer) - - applicationData := make([]byte, hex.EncodedLen(a.Len())) - hex.Encode(applicationData, a.Bytes()) - _ = binary.Write(buf, binary.LittleEndian, uint32(len(applicationData))) - _, _ = buf.Write(applicationData) -} - -type messagePacket struct { - recipientID uint64 - data string -} - -func (*messagePacket) id() uint16 { return idMessage } -func (pk *messagePacket) read(buf *bytes.Buffer) error { - if err := binary.Read(buf, binary.LittleEndian, &pk.recipientID); err != nil { - return fmt.Errorf("read recipient ID: %w", err) - } - var length uint32 - if err := binary.Read(buf, binary.LittleEndian, &length); err != nil { - return fmt.Errorf("read data length: %w", err) - } - pk.data = string(buf.Next(int(length))) - return nil -} -func (pk *messagePacket) write(buf *bytes.Buffer) { - _ = binary.Write(buf, binary.LittleEndian, pk.recipientID) - _ = binary.Write(buf, binary.LittleEndian, uint32(len(pk.data))) - _, _ = buf.WriteString(pk.data) -} - -type packetHeader struct { - length uint16 - packetID uint16 - senderID uint64 -} - -func (h *packetHeader) write(w io.Writer) { - _ = binary.Write(w, binary.LittleEndian, h.length) - _ = binary.Write(w, binary.LittleEndian, h.packetID) - _ = binary.Write(w, binary.LittleEndian, h.senderID) - _, _ = w.Write(make([]byte, 8)) -} - -func (h *packetHeader) read(r io.Reader) error { - if err := binary.Read(r, binary.LittleEndian, &h.length); err != nil { - return fmt.Errorf("read length: %w", err) - } - if err := binary.Read(r, binary.LittleEndian, &h.packetID); err != nil { - return fmt.Errorf("read packet ID: %w", err) - } - if err := binary.Read(r, binary.LittleEndian, &h.senderID); err != nil { - return fmt.Errorf("read sender ID: %w", err) - } - if n, err := r.Read(make([]byte, 8)); err != nil || n != 8 { - return fmt.Errorf("discard padding: %w", err) - } - return nil -} - -var key = sha256.Sum256(binary.LittleEndian.AppendUint64(nil, 0xdeadbeef)) - -func encryptECB(src []byte) ([]byte, error) { - block, err := aes.NewCipher(key[:]) - if err != nil { - return nil, fmt.Errorf("make block: %w", err) - } - mode := ecb.NewECBEncrypter(block) - p := padding.NewPkcs7Padding(mode.BlockSize()) - src, err = p.Pad(src) - if err != nil { - return nil, fmt.Errorf("pad: %w", err) - } - dst := make([]byte, len(src)) - mode.CryptBlocks(dst, src) - return dst, nil -} - -func decryptECB(src []byte) ([]byte, error) { - block, err := aes.NewCipher(key[:]) - if err != nil { - return nil, fmt.Errorf("make block: %w", err) - } - mode := ecb.NewECBDecrypter(block) - dst := make([]byte, len(src)) - mode.CryptBlocks(dst, src) - p := padding.NewPkcs7Padding(mode.BlockSize()) - dst, err = p.Unpad(dst) - if err != nil { - return nil, fmt.Errorf("unpad: %w", err) - } - return dst, nil -} -*/ diff --git a/minecraft/franchise/playfab.go b/minecraft/franchise/playfab.go index 6098e45c..d2b3ca32 100644 --- a/minecraft/franchise/playfab.go +++ b/minecraft/franchise/playfab.go @@ -5,24 +5,25 @@ import ( "fmt" "github.com/sandertv/gophertunnel/playfab" "github.com/sandertv/gophertunnel/playfab/title" - "github.com/sandertv/gophertunnel/xsapi" "golang.org/x/text/language" ) -type PlayFabXBLIdentityProvider struct { - Environment *AuthorizationEnvironment - TokenSource xsapi.TokenSource +type PlayFabIdentityProvider struct { + Environment *AuthorizationEnvironment + IdentityProvider playfab.IdentityProvider + + LoginConfig playfab.LoginConfig DeviceConfig *DeviceConfig UserConfig *UserConfig } -func (i PlayFabXBLIdentityProvider) TokenConfig() (*TokenConfig, error) { +func (i PlayFabIdentityProvider) TokenConfig() (*TokenConfig, error) { if i.Environment == nil { - return nil, errors.New("minecraft/franchise: PlayFabXBLIdentityProvider: Environment is nil") + return nil, errors.New("minecraft/franchise: PlayFabIdentityProvider: Environment is nil") } - if i.TokenSource == nil { - return nil, errors.New("minecraft/franchise: PlayFabXBLIdentityProvider: TokenSource 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) @@ -37,16 +38,11 @@ func (i PlayFabXBLIdentityProvider) TokenConfig() (*TokenConfig, error) { } } - x, err := i.TokenSource.Token() - if err != nil { - return nil, fmt.Errorf("request xbox live token: %w", err) + config := i.LoginConfig + if config.Title == "" { + config.Title = title.Title(i.Environment.PlayFabTitleID) } - - cfg := playfab.LoginConfig{ - Title: title.Title(i.Environment.PlayFabTitleID), - CreateAccount: true, - }.WithXbox(x) - identity, err := cfg.Login() + identity, err := i.IdentityProvider.Login(config) if err != nil { return nil, fmt.Errorf("login: %w", err) } diff --git a/minecraft/franchise/signaling/conn.go b/minecraft/franchise/signaling/conn.go index 08efc8e6..cdd9f836 100644 --- a/minecraft/franchise/signaling/conn.go +++ b/minecraft/franchise/signaling/conn.go @@ -16,13 +16,13 @@ import ( type Conn struct { conn *websocket.Conn - ctx context.Context d Dialer - credentials atomic.Pointer[nethernet.Credentials] - ready chan struct{} + credentials atomic.Pointer[nethernet.Credentials] + credentialsReceived chan struct{} - once sync.Once + once sync.Once + closed chan struct{} signals chan *nethernet.Signal } @@ -38,19 +38,19 @@ func (c *Conn) WriteSignal(signal *nethernet.Signal) error { func (c *Conn) ReadSignal(cancel <-chan struct{}) (*nethernet.Signal, error) { select { case <-cancel: + return nil, nethernet.ErrSignalingCanceled + case <-c.closed: return nil, net.ErrClosed case s := <-c.signals: return s, nil - case <-c.ctx.Done(): - return nil, context.Cause(c.ctx) } } func (c *Conn) Credentials() (*nethernet.Credentials, error) { select { - case <-c.ctx.Done(): - return nil, context.Cause(c.ctx) - case <-c.ready: + case <-c.closed: + return nil, net.ErrClosed + default: return c.credentials.Load(), nil } } @@ -67,17 +67,17 @@ func (c *Conn) ping() { }); err != nil { c.d.Log.Error("error writing ping", internal.ErrAttr(err)) } - case <-c.ctx.Done(): + case <-c.closed: return } } } -func (c *Conn) read(cancel context.CancelCauseFunc) { +func (c *Conn) read() { for { var message Message if err := wsjson.Read(context.Background(), c.conn, &message); err != nil { - cancel(err) + _ = c.Close() return } switch message.Type { @@ -91,8 +91,11 @@ func (c *Conn) read(cancel context.CancelCauseFunc) { c.d.Log.Error("error decoding credentials", internal.ErrAttr(err)) continue } + previous := c.credentials.Load() c.credentials.Store(&credentials) - close(c.ready) + if previous == nil { + close(c.credentialsReceived) + } case MessageTypeSignal: s := &nethernet.Signal{} if err := s.UnmarshalText([]byte(message.Data)); err != nil { @@ -118,6 +121,8 @@ func (c *Conn) write(m Message) error { func (c *Conn) Close() (err error) { c.once.Do(func() { + close(c.closed) + close(c.signals) err = c.conn.Close(websocket.StatusNormalClosure, "") }) return err diff --git a/minecraft/franchise/signaling/conn_test.go b/minecraft/franchise/signaling/conn_test.go index 7cc27756..66174cbc 100644 --- a/minecraft/franchise/signaling/conn_test.go +++ b/minecraft/franchise/signaling/conn_test.go @@ -6,6 +6,7 @@ import ( "github.com/sandertv/gophertunnel/minecraft/franchise" "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/playfab" "github.com/sandertv/gophertunnel/xsapi/xal" "testing" "time" @@ -32,11 +33,11 @@ func TestDial(t *testing.T) { } src := auth.RefreshTokenSource(tok) - refresh, cancel := context.WithCancel(context.Background()) - defer cancel() - prov := franchise.PlayFabXBLIdentityProvider{ + prov := franchise.PlayFabIdentityProvider{ Environment: a, - TokenSource: xal.RefreshTokenSourceContext(refresh, src, "http://playfab.xboxlive.com/"), + IdentityProvider: playfab.XBLIdentityProvider{ + TokenSource: xal.RefreshTokenSource(src, "http://playfab.xboxlive.com/"), + }, } ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) diff --git a/minecraft/franchise/signaling/dial.go b/minecraft/franchise/signaling/dial.go index a0109a45..7538aeb4 100644 --- a/minecraft/franchise/signaling/dial.go +++ b/minecraft/franchise/signaling/dial.go @@ -19,43 +19,33 @@ type Dialer struct { Log *slog.Logger } -func (d Dialer) DialContext(ctx context.Context, src franchise.IdentityProvider, env *Environment) (*Conn, error) { +func (d Dialer) DialContext(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.Options.HTTPHeader == nil { - d.Options.HTTPHeader = make(http.Header) // TODO(lactyy): Move to *franchise.Transport - } if d.NetworkID == 0 { d.NetworkID = rand.Uint64() } if d.Log == nil { d.Log = slog.Default() } - /*var hasTransport bool - if base := d.Options.HTTPClient.Transport; base != nil { + + var ( + hasTransport bool + base = d.Options.HTTPClient.Transport + ) + if base != nil { _, hasTransport = base.(*franchise.Transport) } if !hasTransport { d.Options.HTTPClient.Transport = &franchise.Transport{ - Source: src, - Base: d.Options.HTTPClient.Transport, + IdentityProvider: i, + Base: base, } - }*/ - - // TODO(lactyy): Move to *franchise.Transport - conf, err := src.TokenConfig() - if err != nil { - return nil, fmt.Errorf("request token config: %w", err) - } - t, err := conf.Token() - if err != nil { - return nil, fmt.Errorf("request token: %w", err) } - d.Options.HTTPHeader.Set("Authorization", t.AuthorizationHeader) u, err := url.Parse(env.ServiceURI) if err != nil { @@ -68,21 +58,22 @@ func (d Dialer) DialContext(ctx context.Context, src franchise.IdentityProvider, } conn := &Conn{ - conn: c, - d: d, + conn: c, + d: d, + + credentialsReceived: make(chan struct{}), + + closed: make(chan struct{}), + signals: make(chan *nethernet.Signal), - ready: make(chan struct{}), } - var cancel context.CancelCauseFunc - conn.ctx, cancel = context.WithCancelCause(context.Background()) - - go conn.read(cancel) + go conn.read() go conn.ping() select { case <-ctx.Done(): return nil, ctx.Err() - case <-conn.ready: + case <-conn.credentialsReceived: return conn, nil } } diff --git a/minecraft/franchise/token.go b/minecraft/franchise/token.go index 259dde51..6eaf130c 100644 --- a/minecraft/franchise/token.go +++ b/minecraft/franchise/token.go @@ -23,6 +23,10 @@ type Token struct { TreatmentContext string `json:"treatmentContext"` } +func (t *Token) SetAuthHeader(req *http.Request) { + req.Header.Set("Authorization", t.AuthorizationHeader) +} + const ( ConfigurationMinecraft = "minecraft" ConfigurationValidation = "validation" diff --git a/minecraft/franchise/token_test.go b/minecraft/franchise/token_test.go index 58f1417b..e2fa5049 100644 --- a/minecraft/franchise/token_test.go +++ b/minecraft/franchise/token_test.go @@ -1,10 +1,10 @@ package franchise import ( - "context" "github.com/sandertv/gophertunnel/minecraft/auth" "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/playfab" "github.com/sandertv/gophertunnel/xsapi/xal" "testing" ) @@ -25,11 +25,11 @@ func TestToken(t *testing.T) { } src := auth.RefreshTokenSource(tok) - refresh, cancel := context.WithCancel(context.Background()) - defer cancel() - prov := PlayFabXBLIdentityProvider{ + prov := PlayFabIdentityProvider{ Environment: a, - TokenSource: xal.RefreshTokenSourceContext(refresh, src, "http://playfab.xboxlive.com/"), + IdentityProvider: playfab.XBLIdentityProvider{ + TokenSource: xal.RefreshTokenSource(src, "http://playfab.xboxlive.com/"), + }, } conf, err := prov.TokenConfig() 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/nethernet/network.go b/minecraft/nethernet.go similarity index 53% rename from minecraft/nethernet/network.go rename to minecraft/nethernet.go index 61867f61..a8073148 100644 --- a/minecraft/nethernet/network.go +++ b/minecraft/nethernet.go @@ -1,19 +1,20 @@ -package nethernet +package minecraft import ( "context" "errors" "fmt" - "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/nethernet" "net" "strconv" ) -type Network struct { - Signaling Signaling +// NetherNet is an implementation of NetherNet network. Unlike RakNet, it needs to be registered manually with a Signaling. +type NetherNet struct { + Signaling nethernet.Signaling } -func (n Network) DialContext(ctx context.Context, address string) (net.Conn, error) { +func (n NetherNet) DialContext(ctx context.Context, address string) (net.Conn, error) { if n.Signaling == nil { return nil, errors.New("minecraft/nethernet: Network.DialContext: Signaling is nil") } @@ -21,15 +22,15 @@ func (n Network) DialContext(ctx context.Context, address string) (net.Conn, err if err != nil { return nil, fmt.Errorf("parse network ID: %w", err) } - var d Dialer + var d nethernet.Dialer return d.DialContext(ctx, networkID, n.Signaling) } -func (n Network) PingContext(context.Context, string) ([]byte, error) { +func (n NetherNet) PingContext(context.Context, string) ([]byte, error) { return nil, errors.New("minecraft/nethernet: Network.PingContext: not supported") } -func (n Network) Listen(address string) (minecraft.NetworkListener, error) { +func (n NetherNet) Listen(address string) (NetworkListener, error) { if n.Signaling == nil { return nil, errors.New("minecraft/nethernet: Network.Listen: Signaling is nil") } @@ -37,14 +38,10 @@ func (n Network) Listen(address string) (minecraft.NetworkListener, error) { if err != nil { return nil, fmt.Errorf("parse network ID: %w", err) } - var cfg ListenConfig + var cfg nethernet.ListenConfig return cfg.Listen(networkID, n.Signaling) } -func (Network) Encrypted() bool { return true } +func (NetherNet) Encrypted() bool { return true } -func (Network) BatchHeader() []byte { return nil } - -func NetworkAddress(networkID uint64) string { - return strconv.FormatUint(networkID, 10) -} +func (NetherNet) BatchHeader() []byte { return nil } diff --git a/minecraft/nethernet/conn.go b/minecraft/nethernet/conn.go index a92c4241..f72bf83f 100644 --- a/minecraft/nethernet/conn.go +++ b/minecraft/nethernet/conn.go @@ -28,6 +28,8 @@ type Conn struct { candidates []webrtc.ICECandidate candidatesMu sync.Mutex + handler handler + localCandidates []webrtc.ICECandidate localNetworkID uint64 @@ -68,7 +70,6 @@ func (c *Conn) Write(b []byte) (n int, err error) { case <-c.closed: return n, net.ErrClosed default: - // TODO: Clean up... segments := uint8(len(b) / maxMessageSize) if len(b)%maxMessageSize != 0 { segments++ // If there's a remainder, we need an additional segment. @@ -90,11 +91,6 @@ func (c *Conn) Write(b []byte) (n int, err error) { } n += len(frag) } - - // TODO - if segments != 0 { - panic("minecraft/nethernet: Conn: segments != 0") - } return n, nil } } @@ -134,6 +130,8 @@ func (c *Conn) Close() (err error) { c.once.Do(func() { close(c.closed) + c.handler.handleClose(c) + errs := make([]error, 0, 5) errs = append(errs, c.reliable.Close()) errs = append(errs, c.unreliable.Close()) @@ -350,7 +348,11 @@ func (desc description) setupAttribute() sdp.Attribute { return attr } -func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc.SCTPTransport, d *description, log *slog.Logger, id, networkID, localNetworkID uint64, candidates []webrtc.ICECandidate) *Conn { +func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc.SCTPTransport, d *description, log *slog.Logger, id, networkID, localNetworkID uint64, candidates []webrtc.ICECandidate, h handler) *Conn { + if h == nil { + h = nopHandler{} + } + return &Conn{ ice: ice, dtls: dtls, @@ -360,6 +362,8 @@ func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc. candidateReceived: make(chan struct{}, 1), + handler: h, + localNetworkID: localNetworkID, localCandidates: candidates, @@ -377,3 +381,11 @@ func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc. networkID: networkID, } } + +type handler interface { + handleClose(conn *Conn) +} + +type nopHandler struct{} + +func (nopHandler) handleClose(*Conn) {} diff --git a/minecraft/nethernet/dial.go b/minecraft/nethernet/dial.go index d806f96a..8a5650b1 100644 --- a/minecraft/nethernet/dial.go +++ b/minecraft/nethernet/dial.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "github.com/pion/logging" "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" @@ -19,25 +18,18 @@ type Dialer struct { Log *slog.Logger } -func (dialer Dialer) DialContext(ctx context.Context, networkID uint64, signaling Signaling) (*Conn, error) { - if dialer.NetworkID == 0 { - dialer.NetworkID = rand.Uint64() +func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Signaling) (*Conn, error) { + if d.NetworkID == 0 { + d.NetworkID = rand.Uint64() } - if dialer.ConnectionID == 0 { - dialer.ConnectionID = rand.Uint64() + if d.ConnectionID == 0 { + d.ConnectionID = rand.Uint64() } - if dialer.API == nil { - var ( - setting webrtc.SettingEngine - factory = logging.NewDefaultLoggerFactory() - ) - factory.DefaultLogLevel = logging.LogLevelDebug - setting.LoggerFactory = factory - - dialer.API = webrtc.NewAPI(webrtc.WithSettingEngine(setting)) + if d.API == nil { + d.API = webrtc.NewAPI() } - if dialer.Log == nil { - dialer.Log = slog.Default() + if d.Log == nil { + d.Log = slog.Default() } credentials, err := signaling.Credentials() if err != nil { @@ -55,7 +47,7 @@ func (dialer Dialer) DialContext(ctx context.Context, networkID uint64, signalin } } } - gatherer, err := dialer.API.NewICEGatherer(gatherOptions) + gatherer, err := d.API.NewICEGatherer(gatherOptions) if err != nil { return nil, fmt.Errorf("create ICE gatherer: %w", err) } @@ -78,12 +70,12 @@ func (dialer Dialer) DialContext(ctx context.Context, networkID uint64, signalin case <-ctx.Done(): return nil, ctx.Err() case <-gatherFinished: - ice := dialer.API.NewICETransport(gatherer) - dtls, err := dialer.API.NewDTLSTransport(ice, nil) + ice := d.API.NewICETransport(gatherer) + dtls, err := d.API.NewDTLSTransport(ice, nil) if err != nil { return nil, fmt.Errorf("create DTLS transport: %w", err) } - sctp := dialer.API.NewSCTPTransport(dtls) + sctp := d.API.NewSCTPTransport(dtls) iceParams, err := ice.GetLocalParameters() if err != nil { @@ -112,7 +104,7 @@ func (dialer Dialer) DialContext(ctx context.Context, networkID uint64, signalin if err := signaling.WriteSignal(&Signal{ Type: SignalTypeOffer, Data: string(offer), - ConnectionID: dialer.ConnectionID, + ConnectionID: d.ConnectionID, NetworkID: networkID, }); err != nil { return nil, fmt.Errorf("signal offer: %w", err) @@ -121,7 +113,7 @@ func (dialer Dialer) DialContext(ctx context.Context, networkID uint64, signalin if err := signaling.WriteSignal(&Signal{ Type: SignalTypeCandidate, Data: formatICECandidate(i, candidate, iceParams), - ConnectionID: dialer.ConnectionID, + ConnectionID: d.ConnectionID, NetworkID: networkID, }); err != nil { return nil, fmt.Errorf("signal candidate: %w", err) @@ -129,43 +121,43 @@ func (dialer Dialer) DialContext(ctx context.Context, networkID uint64, signalin } signals := make(chan *Signal) - go dialer.notifySignals(ctx, dialer.ConnectionID, networkID, signaling, signals) + go d.notifySignals(ctx, d.ConnectionID, networkID, signaling, signals) select { case <-ctx.Done(): if errors.Is(err, context.DeadlineExceeded) { - dialer.signalError(signaling, networkID, ErrorCodeNegotiationTimeoutWaitingForResponse) + d.signalError(signaling, networkID, ErrorCodeNegotiationTimeoutWaitingForResponse) } return nil, ctx.Err() case signal := <-signals: if signal.Type != SignalTypeAnswer { - dialer.signalError(signaling, networkID, ErrorCodeIncomingConnectionIgnored) + d.signalError(signaling, networkID, ErrorCodeIncomingConnectionIgnored) return nil, fmt.Errorf("received signal for non-answer: %s", signal.String()) } - d := &sdp.SessionDescription{} - if err := d.UnmarshalString(signal.Data); err != nil { - dialer.signalError(signaling, networkID, ErrorCodeFailedToSetRemoteDescription) + s := &sdp.SessionDescription{} + if err := s.UnmarshalString(signal.Data); err != nil { + d.signalError(signaling, networkID, ErrorCodeFailedToSetRemoteDescription) return nil, fmt.Errorf("decode answer: %w", err) } - desc, err := parseDescription(d) + desc, err := parseDescription(s) if err != nil { - dialer.signalError(signaling, networkID, ErrorCodeFailedToSetRemoteDescription) + d.signalError(signaling, networkID, ErrorCodeFailedToSetRemoteDescription) return nil, fmt.Errorf("parse offer: %w", err) } - c := newConn(ice, dtls, sctp, desc, dialer.Log, dialer.ConnectionID, networkID, dialer.NetworkID, candidates) - go dialer.handleConn(ctx, c, signals) + c := newConn(ice, dtls, sctp, desc, d.Log, d.ConnectionID, networkID, d.NetworkID, candidates, nil) + go d.handleConn(ctx, c, signals) select { case <-ctx.Done(): if errors.Is(err, context.DeadlineExceeded) { - dialer.signalError(signaling, networkID, ErrorCodeInactivityTimeout) + d.signalError(signaling, networkID, ErrorCodeInactivityTimeout) } return nil, ctx.Err() case <-c.candidateReceived: c.log.Debug("received first candidate") - if err := dialer.startTransports(c); err != nil { + if err := d.startTransports(c); err != nil { return nil, fmt.Errorf("start transports: %w", err) } c.handleTransports() @@ -175,16 +167,16 @@ func (dialer Dialer) DialContext(ctx context.Context, networkID uint64, signalin } } -func (dialer Dialer) signalError(signaling Signaling, networkID uint64, code int) { +func (d Dialer) signalError(signaling Signaling, networkID uint64, code int) { _ = signaling.WriteSignal(&Signal{ Type: SignalTypeError, Data: strconv.Itoa(code), - ConnectionID: dialer.ConnectionID, + ConnectionID: d.ConnectionID, NetworkID: networkID, }) } -func (dialer Dialer) startTransports(conn *Conn) error { +func (d Dialer) startTransports(conn *Conn) error { conn.log.Debug("starting ICE transport as controller") iceRole := webrtc.ICERoleControlling if err := conn.ice.Start(nil, conn.remote.ice, &iceRole); err != nil { @@ -203,13 +195,13 @@ func (dialer Dialer) startTransports(conn *Conn) error { return fmt.Errorf("start SCTP: %w", err) } var err error - conn.reliable, err = dialer.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ + conn.reliable, err = d.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ Label: "ReliableDataChannel", }) if err != nil { return fmt.Errorf("create ReliableDataChannel: %w", err) } - conn.unreliable, err = dialer.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ + conn.unreliable, err = d.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ Label: "UnreliableDataChannel", Ordered: false, }) @@ -219,7 +211,7 @@ func (dialer Dialer) startTransports(conn *Conn) error { return nil } -func (dialer Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Signal) { +func (d Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Signal) { for { select { case <-ctx.Done(): @@ -235,15 +227,17 @@ func (dialer Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan } } -func (dialer Dialer) notifySignals(ctx context.Context, id, networkID uint64, signaling Signaling, c chan<- *Signal) { +func (d Dialer) notifySignals(ctx context.Context, id, networkID uint64, signaling Signaling, c chan<- *Signal) { for { signal, err := signaling.ReadSignal(ctx.Done()) if err != nil { - dialer.Log.Error("error reading signal", internal.ErrAttr(err)) + if !errors.Is(err, ErrSignalingCanceled) { + d.Log.Error("error reading signal", internal.ErrAttr(err)) + } return } if signal.ConnectionID != id || signal.NetworkID != networkID { - dialer.Log.Error("unexpected connection ID or network ID", slog.Group("signal", signal)) + d.Log.Error("unexpected connection ID or network ID", slog.Group("signal", signal)) continue } c <- signal diff --git a/minecraft/nethernet/discovery/crypto.go b/minecraft/nethernet/discovery/crypto.go new file mode 100644 index 00000000..6052e216 --- /dev/null +++ b/minecraft/nethernet/discovery/crypto.go @@ -0,0 +1,38 @@ +package discovery + +import ( + "crypto/aes" + "crypto/sha256" + "encoding/binary" + "fmt" + "github.com/andreburgaud/crypt2go/ecb" + "github.com/andreburgaud/crypt2go/padding" +) + +var key = sha256.Sum256(binary.LittleEndian.AppendUint64(nil, 0xdeadbeef)) // 0xdeadbeef is also referenced as Application ID + +func encrypt(src []byte) []byte { + block, _ := aes.NewCipher(key[:]) + mode := ecb.NewECBEncrypter(block) + pkcs7 := padding.NewPkcs7Padding(block.BlockSize()) + src, _ = pkcs7.Pad(src) + dst := make([]byte, len(src)) + mode.CryptBlocks(dst, src) + return dst +} + +func decrypt(src []byte) ([]byte, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, fmt.Errorf("make block: %w", err) + } + mode := ecb.NewECBDecrypter(block) + dst := make([]byte, len(src)) + mode.CryptBlocks(dst, src) + pkcs7 := padding.NewPkcs7Padding(block.BlockSize()) + dst, err = pkcs7.Unpad(dst) + if err != nil { + return nil, fmt.Errorf("unpad: %w", err) + } + return dst, nil +} diff --git a/minecraft/nethernet/discovery/listener.go b/minecraft/nethernet/discovery/listener.go new file mode 100644 index 00000000..f5b9a429 --- /dev/null +++ b/minecraft/nethernet/discovery/listener.go @@ -0,0 +1,293 @@ +package discovery + +import ( + "context" + "errors" + "fmt" + "github.com/sandertv/gophertunnel/minecraft/nethernet" + "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" + "log/slog" + "math/rand" + "net" + "sync" + "sync/atomic" + "time" +) + +type ListenConfig struct { + NetworkID uint64 + BroadcastAddress net.Addr + Log *slog.Logger +} + +func (conf ListenConfig) Listen(network string, addr string) (*Listener, error) { + if conf.Log == nil { + conf.Log = slog.Default() + } + if conf.NetworkID == 0 { + conf.NetworkID = rand.Uint64() + } + conn, err := net.ListenPacket(network, addr) + if err != nil { + return nil, err + } + + l := &Listener{ + conn: conn, + + conf: conf, + + signals: make(chan *nethernet.Signal), + + addresses: make(map[uint64]net.Addr), + + closed: make(chan struct{}), + } + go l.listen() + + if conf.BroadcastAddress == nil { + conf.BroadcastAddress, err = broadcastAddress(conn.LocalAddr()) + if err != nil { + conf.Log.Error("error resolving address for broadcast: local rooms may not be returned") + } + } + if conf.BroadcastAddress != nil { + go l.broadcast(conf.BroadcastAddress) + } + + return l, nil +} + +func broadcastAddress(addr net.Addr) (net.Addr, error) { + switch addr := addr.(type) { + case *net.UDPAddr: + ip := addr.IP.To4() + if ip == nil { + return nil, fmt.Errorf("address %q is not an IPv4 address; broadcasting on non-IPv4 address is currently not supported", addr) + } + return &net.UDPAddr{ + IP: broadcastIP4(ip), + Port: addr.Port, + }, nil + case *net.TCPAddr: + ip := addr.IP.To4() + if ip == nil { + return nil, fmt.Errorf("address %q is not an IPv4 address; broadcasting on non-IPv4 address is currently not supported", addr) + } + return &net.TCPAddr{ + IP: broadcastIP4(ip), + Port: addr.Port, + }, nil + default: + return nil, fmt.Errorf("unsupported address type %T", addr) + } +} + +func broadcastIP4(ip net.IP) net.IP { + mask := ip.DefaultMask() + bcast := make(net.IP, len(ip)) + for i := 0; i < len(bcast); i++ { + bcast[i] = ip[i] | ^mask[i] + } + return bcast +} + +type Listener struct { + conn net.PacketConn + + conf ListenConfig + + pongData atomic.Pointer[[]byte] + + signals chan *nethernet.Signal + + addressesMu sync.RWMutex + addresses map[uint64]net.Addr + + responsesMu sync.RWMutex + responses map[uint64][]byte + + closed chan struct{} + once sync.Once +} + +func (l *Listener) ReadSignal(cancel <-chan struct{}) (*nethernet.Signal, error) { + select { + case <-cancel: + return nil, context.Canceled + case <-l.closed: + return nil, net.ErrClosed + case signal := <-l.signals: + return signal, nil + } +} + +func (l *Listener) WriteSignal(signal *nethernet.Signal) error { + select { + case <-l.closed: + return net.ErrClosed + default: + l.addressesMu.RLock() + addr, ok := l.addresses[signal.NetworkID] + l.addressesMu.RUnlock() + + if !ok { + return fmt.Errorf("no address found for network ID: %d", signal.NetworkID) + } + + _, err := l.write(Marshal(&MessagePacket{ + RecipientID: signal.NetworkID, + Data: signal.String(), + }, l.conf.NetworkID), addr) + return err + } +} + +func (l *Listener) Credentials() (*nethernet.Credentials, error) { + select { + case <-l.closed: + return nil, net.ErrClosed + default: + return nil, nil + } +} + +func (l *Listener) listen() { + for { + b := make([]byte, 1024) + n, addr, err := l.conn.ReadFrom(b) + if err != nil { + if !errors.Is(err, net.ErrClosed) { + l.conf.Log.Error("error reading from conn", internal.ErrAttr(err)) + } + close(l.closed) + return + } + if err := l.handlePacket(b[:n], addr); err != nil { + l.conf.Log.Error("error handling packet", internal.ErrAttr(err), "from", addr) + } + } +} + +func (l *Listener) handlePacket(data []byte, addr net.Addr) error { + pk, senderID, err := Unmarshal(data) + if err != nil { + return fmt.Errorf("decode: %w", err) + } + + if senderID == l.conf.NetworkID { + return nil + } + + l.addressesMu.Lock() + l.addresses[senderID] = addr + l.addressesMu.Unlock() + + switch pk := pk.(type) { + case *RequestPacket: + err = l.handleRequest(addr) + case *ResponsePacket: + err = l.handleResponse(pk, senderID) + case *MessagePacket: + err = l.handleMessage(pk, senderID, addr) + default: + err = fmt.Errorf("unknown packet: %T", pk) + } + + return err +} + +func (l *Listener) handleRequest(addr net.Addr) error { + data := l.pongData.Load() + if data == nil { + return errors.New("application data not set yet") + } + if _, err := l.write(Marshal(&ResponsePacket{ + ApplicationData: *data, + }, l.conf.NetworkID), addr); err != nil { + return fmt.Errorf("write response: %w", err) + } + return nil +} + +func (l *Listener) handleResponse(pk *ResponsePacket, senderID uint64) error { + l.responsesMu.Lock() + l.responses[senderID] = pk.ApplicationData + l.responsesMu.Unlock() + + return nil +} + +func (l *Listener) handleMessage(pk *MessagePacket, senderID uint64, addr net.Addr) error { + if pk.Data == "Ping" { + return nil + } + + signal := &nethernet.Signal{} + if err := signal.UnmarshalText([]byte(pk.Data)); err != nil { + return fmt.Errorf("decode signal: %w", err) + } + signal.NetworkID = senderID + l.signals <- signal + + return nil +} + +func (l *Listener) ServerData(d *ServerData) { + b, _ := d.MarshalBinary() + l.PongData(b) +} + +func (l *Listener) PongData(b []byte) { l.pongData.Store(&b) } + +func (l *Listener) Close() (err error) { + l.once.Do(func() { + err = l.conn.Close() + }) + return err +} + +func (l *Listener) broadcast(addr net.Addr) { + ticker := time.NewTicker(time.Second * 2) + defer ticker.Stop() + + request := Marshal(&RequestPacket{}, l.conf.NetworkID) + + for { + select { + case <-l.closed: + return + case <-ticker.C: + if _, err := l.conn.WriteTo(request, addr); err != nil { + if !errors.Is(err, net.ErrClosed) { + l.conf.Log.Error("error broadcasting request", internal.ErrAttr(err)) + } + return + } + } + } +} + +func (l *Listener) write(b []byte, addr net.Addr) (n int, err error) { + localIP, remoteIP := l.ip(addr), l.ip(l.conn.LocalAddr()) + if localIP != nil && remoteIP != nil && localIP.Equal(remoteIP) { + bcast, err := broadcastAddress(addr) + if err != nil { + l.conf.Log.Error("error resolving broadcast address", slog.Any("addr", addr), internal.ErrAttr(err)) + } else { + addr = bcast + } + } + return l.conn.WriteTo(b, addr) +} + +func (l *Listener) ip(addr net.Addr) net.IP { + switch addr := addr.(type) { + case *net.UDPAddr: + return addr.IP + case *net.TCPAddr: + return addr.IP + default: + return nil + } +} diff --git a/minecraft/nethernet/discovery/listener_test.go b/minecraft/nethernet/discovery/listener_test.go new file mode 100644 index 00000000..1b749396 --- /dev/null +++ b/minecraft/nethernet/discovery/listener_test.go @@ -0,0 +1,53 @@ +package discovery + +import ( + "context" + "errors" + "github.com/sandertv/gophertunnel/minecraft/room" + "log/slog" + "os" + "testing" + "time" +) + +func TestListen(t *testing.T) { + cfg := ListenConfig{ + Log: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })), + } + + l, err := cfg.Listen("udp", ":7551") + if err != nil { + t.Fatalf("error listening: %s", err) + } + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Fatalf("error closing: %s", err) + } + }) + + _ = l.Announce(room.Status{ + HostName: "Da1z981?", + WorldName: "LAN のデバッグ", + WorldType: room.WorldTypeCreative, + MemberCount: 1, + MaxMemberCount: 30, + IsEditorWorld: false, + TransportLayer: 2, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + + for { + signal, err := l.ReadSignal(ctx.Done()) + if err != nil { + if !errors.Is(err, context.Canceled) { + t.Fatalf("error reading signal: %s", err) + } + return + } + t.Logf("%#v", signal) + } +} diff --git a/minecraft/nethernet/discovery/packet.go b/minecraft/nethernet/discovery/packet.go new file mode 100644 index 00000000..54745ccc --- /dev/null +++ b/minecraft/nethernet/discovery/packet.go @@ -0,0 +1,134 @@ +package discovery + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "fmt" + "io" +) + +type Packet interface { + ID() uint16 + + Read(r io.Reader) error + Write(w io.Writer) +} + +func Marshal(pk Packet, senderID uint64) []byte { + buf := &bytes.Buffer{} + + h := &Header{ + PacketID: pk.ID(), + SenderID: senderID, + } + h.Write(buf) + + pk.Write(buf) + + payload := append( + binary.LittleEndian.AppendUint16(nil, uint16(buf.Len())), + buf.Bytes()..., + ) + b := encrypt(payload) + + hash := hmac.New(sha256.New, key[:]) + hash.Write(payload) + b = append(hash.Sum(nil), b...) + return b +} + +func Unmarshal(b []byte) (Packet, uint64, error) { + if len(b) < 32 { + return nil, 0, io.ErrUnexpectedEOF + } + payload, err := decrypt(b[32:]) + if err != nil { + return nil, 0, fmt.Errorf("decrypt: %w", err) + } + + hash := hmac.New(sha256.New, key[:]) + hash.Write(payload) + if checksum := hash.Sum(nil); bytes.Compare(b[:32], checksum) != 0 { + return nil, 0, fmt.Errorf("checksum mismatch: %x != %x", b[:32], checksum) + } + buf := bytes.NewBuffer(payload) + + var length uint16 + if err := binary.Read(buf, binary.LittleEndian, &length); err != nil { + return nil, 0, fmt.Errorf("read length: %w", err) + } + h := &Header{} + if err := h.Read(buf); err != nil { + return nil, 0, fmt.Errorf("read header: %w", err) + } + + var pk Packet + switch h.PacketID { + case IDRequestPacket: + pk = &RequestPacket{} + case IDResponsePacket: + pk = &ResponsePacket{} + case IDMessagePacket: + pk = &MessagePacket{} + default: + return nil, h.SenderID, fmt.Errorf("unknown packet ID: %d", h.PacketID) + } + if err := pk.Read(buf); err != nil { + return nil, h.SenderID, err + } + return pk, h.SenderID, nil +} + +func readBytes[L ~uint32 | ~uint8](r io.Reader) ([]byte, error) { + var length L + if err := binary.Read(r, binary.LittleEndian, &length); err != nil { + return nil, fmt.Errorf("read length: %w", err) + } + b := make([]byte, length) + if n, err := r.Read(b); err != nil { + return nil, err + } else if n != int(length) { + return nil, fmt.Errorf("invalid length: %d, expected %d", n, length) + } + return b, nil +} + +func writeBytes[L ~uint32 | ~uint8](w io.Writer, b []byte) { + _ = binary.Write(w, binary.LittleEndian, (L)(len(b))) + _, _ = w.Write(b) +} + +const ( + IDRequestPacket uint16 = iota + IDResponsePacket + IDMessagePacket +) + +type Header struct { + PacketID uint16 + SenderID uint64 +} + +func (h *Header) Read(r io.Reader) error { + if err := binary.Read(r, binary.LittleEndian, &h.PacketID); err != nil { + return fmt.Errorf("read packet ID: %w", err) + } + if err := binary.Read(r, binary.LittleEndian, &h.SenderID); err != nil { + return fmt.Errorf("read sender ID: %w", err) + } + if n, err := r.Read(make([]byte, 8)); err != nil { + return fmt.Errorf("discard padding: %w", err) + } else if n != 8 { + return fmt.Errorf("%d != 8", n) + } + + return nil +} + +func (h *Header) Write(w io.Writer) { + _ = binary.Write(w, binary.LittleEndian, h.PacketID) + _ = binary.Write(w, binary.LittleEndian, h.SenderID) + _, _ = w.Write(make([]byte, 8)) +} diff --git a/minecraft/nethernet/discovery/packet_message.go b/minecraft/nethernet/discovery/packet_message.go new file mode 100644 index 00000000..817d4ff1 --- /dev/null +++ b/minecraft/nethernet/discovery/packet_message.go @@ -0,0 +1,31 @@ +package discovery + +import ( + "encoding/binary" + "fmt" + "io" +) + +type MessagePacket struct { + RecipientID uint64 + Data string +} + +func (*MessagePacket) ID() uint16 { return IDMessagePacket } + +func (pk *MessagePacket) Read(r io.Reader) error { + if err := binary.Read(r, binary.LittleEndian, &pk.RecipientID); err != nil { + return fmt.Errorf("read recipient ID: %w", err) + } + data, err := readBytes[uint32](r) + if err != nil { + return fmt.Errorf("read data: %w", err) + } + pk.Data = string(data) + return nil +} + +func (pk *MessagePacket) Write(w io.Writer) { + _ = binary.Write(w, binary.LittleEndian, pk.RecipientID) + writeBytes[uint32](w, []byte(pk.Data)) +} diff --git a/minecraft/nethernet/discovery/packet_request.go b/minecraft/nethernet/discovery/packet_request.go new file mode 100644 index 00000000..8fe21a98 --- /dev/null +++ b/minecraft/nethernet/discovery/packet_request.go @@ -0,0 +1,11 @@ +package discovery + +import "io" + +type RequestPacket struct{} + +func (*RequestPacket) ID() uint16 { return IDRequestPacket } + +func (*RequestPacket) Read(io.Reader) error { return nil } + +func (*RequestPacket) Write(io.Writer) {} diff --git a/minecraft/nethernet/discovery/packet_response.go b/minecraft/nethernet/discovery/packet_response.go new file mode 100644 index 00000000..967b55c7 --- /dev/null +++ b/minecraft/nethernet/discovery/packet_response.go @@ -0,0 +1,32 @@ +package discovery + +import ( + "encoding/hex" + "fmt" + "io" +) + +type ResponsePacket struct { + ApplicationData []byte +} + +func (*ResponsePacket) ID() uint16 { return IDResponsePacket } + +func (pk *ResponsePacket) Read(r io.Reader) error { + data, err := readBytes[uint32](r) + if err != nil { + return fmt.Errorf("read application data: %w", err) + } + n, err := hex.Decode(data, data) + if err != nil { + return fmt.Errorf("decode application data: %w", err) + } + pk.ApplicationData = data[:n] + return nil +} + +func (pk *ResponsePacket) Write(w io.Writer) { + data := make([]byte, hex.EncodedLen(len(pk.ApplicationData))) + hex.Encode(data, pk.ApplicationData) + writeBytes[uint32](w, data) +} diff --git a/minecraft/nethernet/discovery/room.go b/minecraft/nethernet/discovery/room.go new file mode 100644 index 00000000..56cf0c25 --- /dev/null +++ b/minecraft/nethernet/discovery/room.go @@ -0,0 +1,53 @@ +package discovery + +import ( + "github.com/sandertv/gophertunnel/minecraft/room" +) + +func (l *Listener) Announce(status room.Status) error { + l.ServerData(statusToServerData(status)) + return nil +} + +func statusToServerData(status room.Status) *ServerData { + return &ServerData{ + Version: 0x2, + ServerName: status.HostName, + LevelName: status.WorldName, + GameType: worldTypeToGameType(status.WorldType), + PlayerCount: int32(status.MemberCount), + MaxPlayerCount: int32(status.MaxMemberCount), + IsEditorWorld: status.IsEditorWorld, + TransportLayer: status.TransportLayer, + } +} + +func serverDataToStatus(d *ServerData) room.Status { + return room.Status{ + HostName: d.ServerName, + WorldName: d.LevelName, + WorldType: gameTypeToWorldType(d.GameType), + MemberCount: uint32(d.PlayerCount), + MaxMemberCount: uint32(d.MaxPlayerCount), + IsEditorWorld: d.IsEditorWorld, + TransportLayer: d.TransportLayer, + } +} + +func gameTypeToWorldType(typ int32) string { + switch typ { + case 2: + return room.WorldTypeCreative + default: + return room.WorldTypeCreative + } +} + +func worldTypeToGameType(typ string) int32 { + switch typ { + case room.WorldTypeCreative: + return 2 + default: + return 2 + } +} diff --git a/minecraft/nethernet/discovery/server_data.go b/minecraft/nethernet/discovery/server_data.go new file mode 100644 index 00000000..e7eff42a --- /dev/null +++ b/minecraft/nethernet/discovery/server_data.go @@ -0,0 +1,68 @@ +package discovery + +import ( + "bytes" + "encoding/binary" + "fmt" +) + +type ServerData struct { + Version uint8 + ServerName string + LevelName string + GameType int32 + PlayerCount int32 + MaxPlayerCount int32 + IsEditorWorld bool + TransportLayer int32 +} + +func (d *ServerData) MarshalBinary() ([]byte, error) { + buf := &bytes.Buffer{} + + _ = binary.Write(buf, binary.LittleEndian, d.Version) + writeBytes[uint8](buf, []byte(d.ServerName)) + writeBytes[uint8](buf, []byte(d.LevelName)) + _ = binary.Write(buf, binary.LittleEndian, d.GameType) + _ = binary.Write(buf, binary.LittleEndian, d.PlayerCount) + _ = binary.Write(buf, binary.LittleEndian, d.MaxPlayerCount) + _ = binary.Write(buf, binary.LittleEndian, d.IsEditorWorld) + _ = binary.Write(buf, binary.LittleEndian, d.TransportLayer) + + return buf.Bytes(), nil +} + +func (d *ServerData) UnmarshalBinary(data []byte) error { + buf := bytes.NewBuffer(data) + + if err := binary.Read(buf, binary.LittleEndian, &d.Version); err != nil { + return fmt.Errorf("read version: %w", err) + } + serverName, err := readBytes[uint8](buf) + if err != nil { + return fmt.Errorf("read server name: %w", err) + } + d.ServerName = string(serverName) + levelName, err := readBytes[uint8](buf) + if err != nil { + return fmt.Errorf("read level name: %w", err) + } + d.LevelName = string(levelName) + if err := binary.Read(buf, binary.LittleEndian, &d.GameType); err != nil { + return fmt.Errorf("read game type: %w", err) + } + if err := binary.Read(buf, binary.LittleEndian, &d.PlayerCount); err != nil { + return fmt.Errorf("read player count: %w", err) + } + if err := binary.Read(buf, binary.LittleEndian, &d.MaxPlayerCount); err != nil { + return fmt.Errorf("read max player count: %w", err) + } + if err := binary.Read(buf, binary.LittleEndian, &d.IsEditorWorld); err != nil { + return fmt.Errorf("read editor world: %w", err) + } + if err := binary.Read(buf, binary.LittleEndian, &d.TransportLayer); err != nil { + return fmt.Errorf("read transport layer: %w", err) + } + + return nil +} diff --git a/minecraft/nethernet/listener.go b/minecraft/nethernet/listener.go index 53a7f48a..10cca081 100644 --- a/minecraft/nethernet/listener.go +++ b/minecraft/nethernet/listener.go @@ -1,10 +1,8 @@ package nethernet import ( - "context" "errors" "fmt" - "github.com/pion/logging" "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" @@ -15,8 +13,6 @@ import ( "sync" ) -// TODO: Under in construction! - type ListenConfig struct { Log *slog.Logger API *webrtc.API @@ -27,14 +23,7 @@ func (conf ListenConfig) Listen(networkID uint64, signaling Signaling) (*Listene conf.Log = slog.Default() } if conf.API == nil { - var ( - setting webrtc.SettingEngine - factory = logging.NewDefaultLoggerFactory() - ) - factory.DefaultLogLevel = logging.LogLevelDebug - setting.LoggerFactory = factory - - conf.API = webrtc.NewAPI(webrtc.WithSettingEngine(setting)) + conf.API = webrtc.NewAPI() } l := &Listener{ conf: conf, @@ -45,16 +34,13 @@ func (conf ListenConfig) Listen(networkID uint64, signaling Signaling) (*Listene closed: make(chan struct{}), } - var cancel context.CancelCauseFunc - l.ctx, cancel = context.WithCancelCause(context.Background()) - go l.listen(cancel) + go l.listen() return l, nil } type Listener struct { conf ListenConfig - ctx context.Context signaling Signaling networkID uint64 @@ -70,8 +56,6 @@ func (l *Listener) Accept() (net.Conn, error) { select { case <-l.closed: return nil, net.ErrClosed - case <-l.ctx.Done(): - return nil, context.Cause(l.ctx) case conn := <-l.incoming: return conn, nil } @@ -91,7 +75,7 @@ func (addr *Addr) String() string { b := &strings.Builder{} b.WriteString(strconv.FormatUint(addr.NetworkID, 10)) b.WriteByte(' ') - if addr.ConnectionID == 0 { + if addr.ConnectionID != 0 { b.WriteByte('(') b.WriteString(strconv.FormatUint(addr.ConnectionID, 10)) b.WriteByte(')') @@ -107,12 +91,14 @@ func (l *Listener) ID() int64 { return int64(l.networkID) } // PongData is a stub. func (l *Listener) PongData([]byte) {} -func (l *Listener) listen(cancel context.CancelCauseFunc) { +func (l *Listener) listen() { for { signal, err := l.signaling.ReadSignal(l.closed) if err != nil { - close(l.incoming) - cancel(err) + if !errors.Is(err, net.ErrClosed) { + l.conf.Log.Error("error reading signal", internal.ErrAttr(err)) + } + _ = l.Close() return } @@ -201,7 +187,7 @@ func (l *Listener) handleOffer(signal *Signal) error { } select { - case <-l.ctx.Done(): + case <-l.closed: return nil case <-gatherFinished: ice := l.conf.API.NewICETransport(gatherer) @@ -255,7 +241,7 @@ func (l *Listener) handleOffer(signal *Signal) error { } } - c := newConn(ice, dtls, sctp, desc, l.conf.Log, signal.ConnectionID, signal.NetworkID, l.networkID, candidates) + c := newConn(ice, dtls, sctp, desc, l.conf.Log, signal.ConnectionID, signal.NetworkID, l.networkID, candidates, l) l.connections.Store(signal.ConnectionID, c) go l.handleConn(c) @@ -264,15 +250,21 @@ func (l *Listener) handleOffer(signal *Signal) error { } } +func (l *Listener) handleClose(conn *Conn) { + l.connections.Delete(conn.id) +} + func (l *Listener) handleConn(conn *Conn) { select { - case <-l.ctx.Done(): + case <-l.closed: // Quit the goroutine when the listener closes. return case <-conn.candidateReceived: conn.log.Debug("received first candidate") if err := l.startTransports(conn); err != nil { - conn.log.Error("error starting transports", internal.ErrAttr(err)) + if !errors.Is(err, net.ErrClosed) { + conn.log.Error("error starting transports", internal.ErrAttr(err)) + } return } conn.handleTransports() @@ -317,8 +309,8 @@ func (l *Listener) startTransports(conn *Conn) error { } select { - case <-l.ctx.Done(): - return l.ctx.Err() + case <-l.closed: + return net.ErrClosed case <-bothOpen: return nil } @@ -347,6 +339,7 @@ func (l *Listener) handleError(signal *Signal) error { func (l *Listener) Close() error { l.once.Do(func() { close(l.closed) + close(l.incoming) }) return nil } diff --git a/minecraft/nethernet/signal.go b/minecraft/nethernet/signal.go index b6814da7..550f03f6 100644 --- a/minecraft/nethernet/signal.go +++ b/minecraft/nethernet/signal.go @@ -2,15 +2,14 @@ package nethernet import ( "bytes" + "errors" "fmt" "github.com/pion/webrtc/v4" "strconv" "strings" ) -// TODO: Improve documentations written in my poor English ;-; -// TODO: We need an implementation of Signaling that is usable under multiple goroutines. -// I want to use one Signaling connection in both Listen and Dial, because it should work. +var ErrSignalingCanceled = errors.New("minecraft/nethernet: canceled") type Signaling interface { ReadSignal(cancel <-chan struct{}) (*Signal, error) diff --git a/minecraft/world_test.go b/minecraft/world_test.go index 75d4791a..6e4372f3 100644 --- a/minecraft/world_test.go +++ b/minecraft/world_test.go @@ -1,4 +1,4 @@ -package minecraft_test +package minecraft import ( "context" @@ -9,17 +9,16 @@ import ( "fmt" "github.com/go-gl/mathgl/mgl32" "github.com/google/uuid" - "github.com/sandertv/gophertunnel/minecraft" "github.com/sandertv/gophertunnel/minecraft/auth" "github.com/sandertv/gophertunnel/minecraft/franchise" "github.com/sandertv/gophertunnel/minecraft/franchise/signaling" - "github.com/sandertv/gophertunnel/minecraft/nethernet" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" "github.com/sandertv/gophertunnel/minecraft/room" + "github.com/sandertv/gophertunnel/playfab" "github.com/sandertv/gophertunnel/xsapi/xal" "log/slog" - "net" "os" + "strconv" "time" "github.com/sandertv/gophertunnel/minecraft/protocol" @@ -52,11 +51,11 @@ func TestWorldListen(t *testing.T) { } src := auth.RefreshTokenSource(tok) - refresh, cancel := context.WithCancel(context.Background()) - defer cancel() - prov := franchise.PlayFabXBLIdentityProvider{ + prov := franchise.PlayFabIdentityProvider{ Environment: a, - TokenSource: xal.RefreshTokenSourceContext(refresh, src, "http://playfab.xboxlive.com/"), + IdentityProvider: playfab.XBLIdentityProvider{ + TokenSource: xal.RefreshTokenSource(src, "http://playfab.xboxlive.com/"), + }, } d := signaling.Dialer{ @@ -76,7 +75,7 @@ func TestWorldListen(t *testing.T) { }) // A token source that refreshes a token used for generic Xbox Live services. - x := xal.RefreshTokenSourceContext(refresh, src, "http://xboxlive.com") + x := xal.RefreshTokenSource(src, "http://xboxlive.com") xt, err := x.Token() if err != nil { t.Fatalf("error refreshing xbox live token: %s", err) @@ -166,11 +165,11 @@ func TestWorldListen(t *testing.T) { Level: slog.LevelDebug, }))) - minecraft.RegisterNetwork("nethernet", &nethernet.Network{ + RegisterNetwork("nethernet", &NetherNet{ Signaling: conn, }) - l, err := minecraft.Listen("nethernet", nethernet.NetworkAddress(d.NetworkID)) + l, err := Listen("nethernet", strconv.FormatUint(d.NetworkID, 10)) if err != nil { t.Fatalf("error listening: %s", err) } @@ -183,12 +182,10 @@ func TestWorldListen(t *testing.T) { for { netConn, err := l.Accept() if err != nil { - if !errors.Is(err, net.ErrClosed) { - t.Fatalf("error accepting conn: %s", err) - } + return } - c := netConn.(*minecraft.Conn) - if err := c.StartGame(minecraft.GameData{ + c := netConn.(*Conn) + if err := c.StartGame(GameData{ WorldName: "NetherNet", WorldSeed: 0, Difficulty: 0, @@ -205,17 +202,153 @@ func TestWorldListen(t *testing.T) { }); err != nil { t.Fatalf("error starting game: %s", err) } + + go func() { + defer func() { + if err := c.Close(); err != nil { + t.Errorf("error closing connection: %s", err) + } + }() + for { + pk, err := c.ReadPacket() + if err != nil { + if !errors.Is(err, errClosed) { + t.Errorf("error reading packet: %s", err) + } + return + } + switch pk := pk.(type) { + case *packet.Text: + if pk.Message == "Close" { + if err := l.Disconnect(c, "Connection closed"); err != nil { + t.Errorf("error closing connection: %s", err) + } + if err := l.Close(); err != nil { + t.Errorf("error closing listener: %s", err) + } + } + } + } + }() } } var serviceConfigID = uuid.MustParse("4fc10100-5f7a-4470-899b-280835760c07") -// TestWorldDial connects to a world. Before running the test, you need to capture the network ID of -// the world to join, and fill in the constant below. +// TestWorldDial connects to a world. It retrieves the sessions available, and join the first session returned +// from the response. func TestWorldDial(t *testing.T) { - // TODO: Implement looking up sessions and find a network ID from the response. - const remoteNetworkID = 9511338490860978050 + tok, err := readToken("franchise/internal/test/auth.tok", auth.TokenSource) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + src := auth.RefreshTokenSource(tok) + + // A token source that refreshes a token used for generic Xbox Live services. + x := xal.RefreshTokenSource(src, "http://xboxlive.com") + + handles, err := mpsd.QueryConfig{ + SocialGroup: "people", + }.Query(x, serviceConfigID) + if err != nil { + t.Fatalf("error querying handles: %s", err) + } else if len(handles) == 0 { + t.Fatalf("no handles found") + } + // Join the first session we've got. + handle := handles[0] + + t.Logf("Joining session: URL: %s, owner XUID: %s", handle.URL().JoinPath("session"), handle.OwnerXUID) + + var status room.Status + if err := json.Unmarshal(handle.CustomProperties, &status); err != nil { + t.Fatalf("error decoding custom properties from handle: %s", err) + } + + var networkID uint64 + for _, connection := range status.SupportedConnections { + if connection.ConnectionType == 3 { + if connection.WebRTCNetworkID != 0 { + networkID = connection.WebRTCNetworkID + break + } + if connection.NetherNetID != 0 { + networkID = connection.NetherNetID + break + } + } + } + if networkID == 0 { + t.Fatal("no remote network ID found in custom properties") + } + + join, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + var cfg mpsd.JoinConfig + session, err := cfg.JoinContext(join, x, handle) + if err != nil { + t.Fatalf("error joining session: %s", err) + } + t.Cleanup(func() { + if err := session.Close(); err != nil { + t.Fatalf("error leaving session: %s", err) + } + }) + + conn := testWorldDial(t, networkID, src) + + // Try decoding deferred packets received from the connection. + for { + pk, err := conn.ReadPacket() + if err != nil { + if !errors.Is(err, errClosed) { + t.Errorf("error reading packet: %s", err) + } + return + } + switch pk := pk.(type) { + case *packet.Text: + if pk.TextType == packet.TextTypeChat && pk.XUID == handle.OwnerXUID && pk.Message == "Close" { + if err := conn.Close(); err != nil { + t.Errorf("error closing connection: %s", err) + } + } + } + } +} +func TestWorldDialByNetworkID(t *testing.T) { + const networkID = 0 // Fill in this constant before running the test. + + tok, err := readToken("franchise/internal/test/auth.tok", auth.TokenSource) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + src := auth.RefreshTokenSource(tok) + + conn := testWorldDial(t, networkID, src) + + // Try decoding deferred packets received from the connection. + for { + pk, err := conn.ReadPacket() + if err != nil { + if !errors.Is(err, errClosed) { + t.Errorf("error reading packet: %s", err) + } + return + } + switch pk := pk.(type) { + case *packet.Text: + if pk.TextType == packet.TextTypeChat && pk.Message == "Close" { + if err := conn.Close(); err != nil { + t.Errorf("error closing connection: %s", err) + } + } + } + } +} + +func testWorldDial(t *testing.T, networkID uint64, src oauth2.TokenSource) *Conn { discovery, err := franchise.Discover(protocol.CurrentVersion) if err != nil { t.Fatalf("error retrieving discovery: %s", err) @@ -229,17 +362,11 @@ func TestWorldDial(t *testing.T) { t.Fatalf("error reading environment for signaling: %s", err) } - tok, err := readToken("franchise/internal/test/auth.tok", auth.TokenSource) - if err != nil { - t.Fatalf("error reading token: %s", err) - } - src := auth.RefreshTokenSource(tok) - - refresh, cancel := context.WithCancel(context.Background()) - defer cancel() - prov := franchise.PlayFabXBLIdentityProvider{ + i := franchise.PlayFabIdentityProvider{ Environment: a, - TokenSource: xal.RefreshTokenSourceContext(refresh, src, "http://playfab.xboxlive.com/"), + IdentityProvider: playfab.XBLIdentityProvider{ + TokenSource: xal.RefreshTokenSource(src, "http://playfab.xboxlive.com/"), + }, } d := signaling.Dialer{ @@ -248,7 +375,7 @@ func TestWorldDial(t *testing.T) { dial, cancel := context.WithTimeout(context.Background(), time.Second*15) defer cancel() - sig, err := d.DialContext(dial, prov, s) + sig, err := d.DialContext(dial, i, s) if err != nil { t.Fatalf("error dialing signaling: %s", err) } @@ -258,29 +385,25 @@ func TestWorldDial(t *testing.T) { } }) - // TODO: Implement joining a session. - // A token source that refreshes a token used for generic Xbox Live services. - //x := xal.RefreshTokenSourceContext(refresh, src, "http://xboxlive.com") - t.Logf("Network ID: %d", d.NetworkID) slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }))) - minecraft.RegisterNetwork("nethernet", &nethernet.Network{ + RegisterNetwork("nethernet", &NetherNet{ Signaling: sig, }) - conn, err := minecraft.Dialer{ + conn, err := Dialer{ TokenSource: src, - }.DialTimeout("nethernet", nethernet.NetworkAddress(remoteNetworkID), time.Second*15) + }.DialTimeout("nethernet", strconv.FormatUint(networkID, 10), time.Second*15) if err != nil { t.Fatalf("error dialing: %s", err) } t.Cleanup(func() { if err := conn.Close(); err != nil { - t.Fatalf("error closing session: %s", err) + t.Fatalf("error closing connection: %s", err) } }) @@ -296,21 +419,7 @@ func TestWorldDial(t *testing.T) { t.Fatalf("error writing packet: %s", err) } - // Try decoding deferred packets received from the connection. - go func() { - for { - pk, err := conn.ReadPacket() - if err != nil { - if !strings.Contains(err.Error(), "use of closed network connection") { - t.Errorf("error reading packet: %s", err) - } - return - } - _ = pk - } - }() - - time.Sleep(time.Second * 15) + return conn } func readToken(path string, src oauth2.TokenSource) (t *oauth2.Token, err error) { diff --git a/playfab/identity.go b/playfab/identity.go index 831d279f..c83164a7 100644 --- a/playfab/identity.go +++ b/playfab/identity.go @@ -235,7 +235,7 @@ type PlayerProfile struct { LinkedAccounts []LinkedPlatformAccount `json:"LinkedAccounts,omitempty"` Locations []Location `json:"Locations,omitempty"` Memberships []Membership `json:"Memberships,omitempty"` - Origination IdentityProvider `json:"Origination,omitempty"` + Origination string `json:"Origination,omitempty"` PlayerID string `json:"PlayerId,omitempty"` PublisherID string `json:"PublisherId,omitempty"` PushNotificationRegistrations []PushNotificationRegistration `json:"PushNotificationRegistrations,omitempty"` @@ -267,37 +267,35 @@ const ( ) type LinkedPlatformAccount struct { - Email string `json:"Email,omitempty"` - Platform IdentityProvider `json:"Platform,omitempty"` - PlatformUserID string `json:"PlatformUserId,omitempty"` - Username string `json:"Username,omitempty"` + Email string `json:"Email,omitempty"` + Platform string `json:"Platform,omitempty"` + PlatformUserID string `json:"PlatformUserId,omitempty"` + Username string `json:"Username,omitempty"` } -type IdentityProvider string - const ( - IdentityProviderAndroidDevice IdentityProvider = "AndroidDevice" - IdentityProviderApple IdentityProvider = "Apple" - IdentityProviderCustom IdentityProvider = "Custom" - IdentityProviderCustomServer IdentityProvider = "CustomServer" - IdentityProviderFacebook IdentityProvider = "Facebook" - IdentityProviderFacebookInstantGames IdentityProvider = "FacebookInstantGames" - IdentityProviderGameCenter IdentityProvider = "GameCenter" - IdentityProviderGameServer IdentityProvider = "GameServer" - IdentityProviderGooglePlay IdentityProvider = "GooglePlay" - IdentityProviderGooglePlayGames IdentityProvider = "GooglePlayerGames" - IdentityProviderIOSDevice IdentityProvider = "IOSDevice" - IdentityProviderKongregate IdentityProvider = "Kongregate" - IdentityProviderNintendoSwitch IdentityProvider = "NintendoSwitch" - IdentityProviderNintendoSwitchAccount IdentityProvider = "NintendoSwitchAccount" - IdentityProviderOpenIDConnect IdentityProvider = "OpenIdConnect" - IdentityProviderPSN IdentityProvider = "PSN" - IdentityProviderPlayFab IdentityProvider = "PlayFab" - IdentityProviderSteam IdentityProvider = "Steam" - IdentityProviderTwitch IdentityProvider = "Twitch" - IdentityProviderUnknown IdentityProvider = "Unknown" - IdentityProviderWindowsHello IdentityProvider = "WindowsHello" - IdentityProviderXboxLive IdentityProvider = "XBoxLive" + IdentityProviderAndroidDevice = "AndroidDevice" + IdentityProviderApple = "Apple" + IdentityProviderCustom = "Custom" + IdentityProviderCustomServer = "CustomServer" + IdentityProviderFacebook = "Facebook" + IdentityProviderFacebookInstantGames = "FacebookInstantGames" + IdentityProviderGameCenter = "GameCenter" + IdentityProviderGameServer = "GameServer" + IdentityProviderGooglePlay = "GooglePlay" + IdentityProviderGooglePlayGames = "GooglePlayerGames" + IdentityProviderIOSDevice = "IOSDevice" + IdentityProviderKongregate = "Kongregate" + IdentityProviderNintendoSwitch = "NintendoSwitch" + IdentityProviderNintendoSwitchAccount = "NintendoSwitchAccount" + IdentityProviderOpenIDConnect = "OpenIdConnect" + IdentityProviderPSN = "PSN" + IdentityProviderPlayFab = "PlayFab" + IdentityProviderSteam = "Steam" + IdentityProviderTwitch = "Twitch" + IdentityProviderUnknown = "Unknown" + IdentityProviderWindowsHello = "WindowsHello" + IdentityProviderXboxLive = "XBoxLive" ) type Location struct { diff --git a/playfab/login.go b/playfab/login.go index d9695fe4..7755d277 100644 --- a/playfab/login.go +++ b/playfab/login.go @@ -3,7 +3,6 @@ package playfab import ( "github.com/sandertv/gophertunnel/playfab/internal" "github.com/sandertv/gophertunnel/playfab/title" - "github.com/sandertv/gophertunnel/xsapi" ) type LoginConfig struct { @@ -13,9 +12,10 @@ type LoginConfig struct { EncryptedRequest []byte `json:"EncryptedRequest,omitempty"` InfoRequestParameters *RequestParameters `json:"InfoRequestParameters,omitempty"` PlayerSecret string `json:"PlayerSecret,omitempty"` - XboxToken string `json:"XboxToken,omitempty"` +} - Route string `json:"-"` +type IdentityProvider interface { + Login(config LoginConfig) (*Identity, error) } type RequestParameters struct { @@ -56,17 +56,6 @@ type ProfileConstraints struct { ShowValuesToDate bool `json:"ShowValuesToDate,omitempty"` } -func (l LoginConfig) WithXbox(t xsapi.Token) LoginConfig { - if l.Route == "" { - l.Route = "/Client/LoginWithXbox" - } - l.XboxToken = t.String() - return l -} - -func (l LoginConfig) Login() (*Identity, error) { - if l.Route == "" { - panic("playfab/login: must provide an identity provider/route to login") - } - return internal.Post[*Identity](l.Title, l.Route, l) +func (l LoginConfig) login(path string, r any) (*Identity, error) { + return internal.Post[*Identity](l.Title, path, r) } diff --git a/playfab/xbox_live.go b/playfab/xbox_live.go new file mode 100644 index 00000000..90a8da0e --- /dev/null +++ b/playfab/xbox_live.go @@ -0,0 +1,31 @@ +package playfab + +import ( + "errors" + "fmt" + "github.com/sandertv/gophertunnel/xsapi" +) + +type XBLIdentityProvider struct { + TokenSource xsapi.TokenSource +} + +func (prov XBLIdentityProvider) Login(config LoginConfig) (*Identity, error) { + if prov.TokenSource == nil { + return nil, errors.New("playfab: XBLIdentityProvider: TokenSource is nil") + } + + tok, err := prov.TokenSource.Token() + if err != nil { + return nil, fmt.Errorf("request xbox live token: %w", err) + } + + type loginConfig struct { + LoginConfig + XboxToken string `json:"XboxToken"` + } + return config.login("/Client/LoginWithXbox", loginConfig{ + LoginConfig: config, + XboxToken: tok.String(), + }) +} diff --git a/xsapi/internal/transport.go b/xsapi/internal/transport.go new file mode 100644 index 00000000..0d2c4827 --- /dev/null +++ b/xsapi/internal/transport.go @@ -0,0 +1,22 @@ +package internal + +import ( + "github.com/sandertv/gophertunnel/xsapi" + "net/http" +) + +func SetTransport(client *http.Client, src xsapi.TokenSource) { + var ( + hasTransport bool + base = client.Transport + ) + if base != nil { + _, hasTransport = base.(*xsapi.Transport) + } + if !hasTransport { + client.Transport = &xsapi.Transport{ + Source: src, + Base: base, + } + } +} diff --git a/xsapi/mpsd/activity.go b/xsapi/mpsd/activity.go index b7b09b83..10cbeb69 100644 --- a/xsapi/mpsd/activity.go +++ b/xsapi/mpsd/activity.go @@ -5,17 +5,86 @@ import ( "context" "encoding/json" "fmt" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/xsapi" + "github.com/sandertv/gophertunnel/xsapi/internal" "net/http" "net/url" "strconv" + "time" ) -func (conf PublishConfig) commitActivity(ctx context.Context, ref SessionReference) error { +type QueryConfig struct { + Client *http.Client + + SocialGroup string + SocialGroupXUID string +} + +func (conf QueryConfig) Query(src xsapi.TokenSource, serviceConfigID uuid.UUID) ([]ActivityHandle, error) { + if conf.Client == nil { + conf.Client = &http.Client{} + } + internal.SetTransport(conf.Client, src) + + owners := make(map[string]any) + if conf.SocialGroup != "" && conf.SocialGroupXUID == "" { + tok, err := src.Token() + if err != nil { + return nil, fmt.Errorf("request token: %w", err) + } + if claimer, ok := tok.(xsapi.DisplayClaimer); ok { + conf.SocialGroupXUID = claimer.DisplayClaims().XUID + } + owners["people"] = map[string]any{ + "moniker": conf.SocialGroup, + "monikerXuid": conf.SocialGroupXUID, + } + } + buf := &bytes.Buffer{} if err := json.NewEncoder(buf).Encode(map[string]any{ - "type": "activity", - "sessionRef": ref, - "version": 1, + "type": "activity", + "scid": serviceConfigID, + "owners": owners, + }); err != nil { + return nil, fmt.Errorf("encode request body: %w", err) + } + req, err := http.NewRequest(http.MethodPost, queryURL.String(), buf) + if err != nil { + return nil, fmt.Errorf("make request: %w", err) + } + req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) + + resp, err := conf.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + b := &bytes.Buffer{} + if _, err := b.ReadFrom(resp.Body); err != nil { + return nil, err + } + var data struct { + Results []ActivityHandle `json:"results"` + } + if err := json.NewDecoder(b).Decode(&data); err != nil { + return nil, fmt.Errorf("decode response body: %w", err) + } + return data.Results, nil + default: + return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) + } +} + +func (conf PublishConfig) commitActivity(ctx context.Context, ref SessionReference) error { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(activityHandle{ + Type: "activity", + SessionReference: ref, + Version: 1, }); err != nil { return fmt.Errorf("encode request body: %w", err) } @@ -39,8 +108,48 @@ func (conf PublishConfig) commitActivity(ctx context.Context, ref SessionReferen } } -var handlesURL = &url.URL{ - Scheme: "https", - Host: "sessiondirectory.xboxlive.com", - Path: "/handles", +var ( + handlesURL = &url.URL{ + Scheme: "https", + Host: "sessiondirectory.xboxlive.com", + Path: "/handles", + } + + queryURL = &url.URL{ + Scheme: "https", + Host: "sessiondirectory.xboxlive.com", + Path: "/handles/query", + RawQuery: url.Values{ + "include": []string{"relatedInfo,customProperties"}, + }.Encode(), + } +) + +type activityHandle struct { + Type string `json:"type"` // Always "activity". + SessionReference SessionReference `json:"sessionRef,omitempty"` + Version int `json:"version"` // Always 1. + OwnerXUID string `json:"ownerXuid,omitempty"` +} + +type ActivityHandle struct { + activityHandle + CreateTime time.Time `json:"createTime,omitempty"` + CustomProperties json.RawMessage `json:"customProperties,omitempty"` + GameTypes json.RawMessage `json:"gameTypes,omitempty"` + ID uuid.UUID `json:"id,omitempty"` + InviteProtocol string `json:"inviteProtocol,omitempty"` + RelatedInfo *ActivityHandleRelatedInfo `json:"relatedInfo,omitempty"` + TitleID string `json:"titleId,omitempty"` +} + +func (h ActivityHandle) URL() *url.URL { return handlesURL.JoinPath(h.ID.String()) } + +type ActivityHandleRelatedInfo struct { + Closed bool `json:"closed,omitempty"` + InviteProtocol string `json:"inviteProtocol,omitempty"` + JoinRestriction string `json:"joinRestriction,omitempty"` + MaxMembersCount uint32 `json:"maxMembersCount,omitempty"` + PostedTime time.Time `json:"postedTime,omitempty"` + Visibility string `json:"visibility,omitempty"` } diff --git a/xsapi/mpsd/commit.go b/xsapi/mpsd/commit.go index bb887a6e..12f0b5ce 100644 --- a/xsapi/mpsd/commit.go +++ b/xsapi/mpsd/commit.go @@ -14,15 +14,15 @@ import ( ) func (s *Session) CommitContext(ctx context.Context, d *SessionDescription) (*Commitment, error) { - return s.conf.commit(ctx, s.ref, d) + return s.conf.commit(ctx, s.ref.URL(), d) } -func (conf PublishConfig) commit(ctx context.Context, ref SessionReference, d *SessionDescription) (*Commitment, error) { +func (conf PublishConfig) commit(ctx context.Context, u *url.URL, d *SessionDescription) (*Commitment, error) { buf := &bytes.Buffer{} if err := json.NewEncoder(buf).Encode(d); err != nil { return nil, fmt.Errorf("encode request body: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPut, ref.URL().String(), buf) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), buf) if err != nil { return nil, fmt.Errorf("make request: %w", err) } diff --git a/xsapi/mpsd/handler.go b/xsapi/mpsd/handler.go new file mode 100644 index 00000000..9932fc6e --- /dev/null +++ b/xsapi/mpsd/handler.go @@ -0,0 +1,22 @@ +package mpsd + +import "github.com/google/uuid" + +type Handler interface { + HandleSessionChange(ref SessionReference, branch uuid.UUID, changeNumber uint64) +} + +type NopHandler struct{} + +func (NopHandler) HandleSessionChange(SessionReference, uuid.UUID, uint64) {} + +func (s *Session) Handle(h Handler) { + if h == nil { + h = NopHandler{} + } + s.h.Store(&h) +} + +func (s *Session) handler() Handler { + return *s.h.Load() +} diff --git a/xsapi/mpsd/invite.go b/xsapi/mpsd/invite.go new file mode 100644 index 00000000..8415b3c2 --- /dev/null +++ b/xsapi/mpsd/invite.go @@ -0,0 +1,66 @@ +package mpsd + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/google/uuid" + "net/http" + "strconv" + "time" +) + +func (s *Session) Invite(xuid string, titleID int) (*InviteHandle, error) { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(&inviteHandle{ + Type: "invite", + SessionReference: s.ref, + Version: 1, + InvitedXUID: xuid, + InviteAttributes: map[string]any{ + "titleId": strconv.Itoa(titleID), + }, + }); err != nil { + return nil, fmt.Errorf("encode request body: %w", err) + } + req, err := http.NewRequest(http.MethodPost, handlesURL.String(), buf) + if err != nil { + return nil, fmt.Errorf("make request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) + + resp, err := s.conf.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusCreated: + // It seems the C++ implementation only decodes "id" field from the response. + var handle *InviteHandle + if err := json.NewDecoder(resp.Body).Decode(&handle); err != nil { + return nil, fmt.Errorf("decode response body: %w", err) + } + return handle, nil + default: + return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) + } +} + +type inviteHandle struct { + Type string `json:"type,omitempty"` // Always "invite". + Version int `json:"version,omitempty"` // Always 1. + InviteAttributes map[string]any `json:"inviteAttributes,omitempty"` + InvitedXUID string `json:"invitedXuid,omitempty"` + SessionReference SessionReference `json:"sessionRef,omitempty"` +} + +type InviteHandle struct { + inviteHandle + Expiration time.Time `json:"expiration,omitempty"` + ID uuid.UUID `json:"id,omitempty"` + InviteProtocol string `json:"inviteProtocol,omitempty"` + SenderXUID string `json:"senderXuid,omitempty"` + GameTypes json.RawMessage `json:"gameTypes,omitempty"` +} diff --git a/xsapi/mpsd/join.go b/xsapi/mpsd/join.go new file mode 100644 index 00000000..7add5cf0 --- /dev/null +++ b/xsapi/mpsd/join.go @@ -0,0 +1,14 @@ +package mpsd + +import ( + "context" + "github.com/sandertv/gophertunnel/xsapi" +) + +type JoinConfig struct { + PublishConfig +} + +func (conf JoinConfig) JoinContext(ctx context.Context, src xsapi.TokenSource, handle ActivityHandle) (*Session, error) { + return conf.publish(ctx, src, handle.URL().JoinPath("session"), handle.SessionReference) +} diff --git a/xsapi/mpsd/publish.go b/xsapi/mpsd/publish.go index 080ab415..c91dda48 100644 --- a/xsapi/mpsd/publish.go +++ b/xsapi/mpsd/publish.go @@ -6,9 +6,11 @@ import ( "fmt" "github.com/google/uuid" "github.com/sandertv/gophertunnel/xsapi" + "github.com/sandertv/gophertunnel/xsapi/internal" "github.com/sandertv/gophertunnel/xsapi/rta" "log/slog" "net/http" + "net/url" "strings" ) @@ -22,31 +24,23 @@ type PublishConfig struct { Logger *slog.Logger } -func (conf PublishConfig) PublishContext(ctx context.Context, src xsapi.TokenSource, ref SessionReference) (s *Session, err error) { +func (conf PublishConfig) publish(ctx context.Context, src xsapi.TokenSource, u *url.URL, ref SessionReference) (*Session, error) { if conf.Logger == nil { conf.Logger = slog.Default() } if conf.Client == nil { conf.Client = &http.Client{} } - var hasTransport bool - if conf.Client.Transport != nil { - _, hasTransport = conf.Client.Transport.(*xsapi.Transport) - } - if !hasTransport { - conf.Client.Transport = &xsapi.Transport{ - Source: src, - Base: conf.Client.Transport, - } - } + internal.SetTransport(conf.Client, src) if conf.RTAConn == nil { if conf.RTADialer == nil { conf.RTADialer = &rta.Dialer{} } + var err error conf.RTAConn, err = conf.RTADialer.DialContext(ctx, src) if err != nil { - return nil, fmt.Errorf("dial rta: %w", err) + return nil, fmt.Errorf("prepare subscription: dial: %w", err) } } @@ -57,11 +51,11 @@ func (conf PublishConfig) PublishContext(ctx context.Context, src xsapi.TokenSou sub, err := conf.RTAConn.Subscribe(ctx, resourceURI) if err != nil { - return nil, fmt.Errorf("subscribe with rta: %w", err) + return nil, fmt.Errorf("prepare subscription: subscribe: %w", err) } var custom subscription if err := json.Unmarshal(sub.Custom, &custom); err != nil { - return nil, fmt.Errorf("decode subscription custom: %w", err) + return nil, fmt.Errorf("prepare subscription: decode: %w", err) } if conf.Description == nil { @@ -71,6 +65,10 @@ func (conf PublishConfig) PublishContext(ctx context.Context, src xsapi.TokenSou conf.Description.Members = make(map[string]*MemberDescription, 1) } + if ref.Name == "" { + ref.Name = strings.ToUpper(uuid.NewString()) + } + me, ok := conf.Description.Members["me"] if !ok { me = &MemberDescription{} @@ -104,24 +102,24 @@ func (conf PublishConfig) PublishContext(ctx context.Context, src xsapi.TokenSou } conf.Description.Members["me"] = me - if _, err := conf.commit(ctx, ref, conf.Description); err != nil { + if _, err := conf.commit(ctx, u, conf.Description); err != nil { return nil, fmt.Errorf("commit: %w", err) } if err := conf.commitActivity(ctx, ref); err != nil { return nil, fmt.Errorf("commit activity: %w", err) } - return &Session{ + s := &Session{ ref: ref, conf: conf, rta: conf.RTAConn, - log: conf.Logger, sub: sub, - }, nil + } + s.Handle(nil) + sub.Handle(&subscriptionHandler{s}) + return s, nil } -const resourceURI = "https://sessiondirectory.xboxlive.com/connections/" - -type subscription struct { - ConnectionID uuid.UUID `json:"ConnectionId,omitempty"` +func (conf PublishConfig) PublishContext(ctx context.Context, src xsapi.TokenSource, ref SessionReference) (s *Session, err error) { + return conf.publish(ctx, src, ref.URL(), ref) } diff --git a/xsapi/mpsd/session.go b/xsapi/mpsd/session.go index ba104453..4044bb62 100644 --- a/xsapi/mpsd/session.go +++ b/xsapi/mpsd/session.go @@ -3,21 +3,51 @@ package mpsd import ( "context" "encoding/json" + "fmt" "github.com/sandertv/gophertunnel/xsapi/rta" - "log/slog" + "net/http" + "strconv" + "sync/atomic" ) type Session struct { ref SessionReference conf PublishConfig - rta *rta.Conn - sub *rta.Subscription - log *slog.Logger + + rta *rta.Conn + + sub *rta.Subscription + + h atomic.Pointer[Handler] +} + +func (s *Session) Commitment() (*Commitment, error) { + req, err := http.NewRequest(http.MethodGet, s.ref.URL().String(), nil) + if err != nil { + return nil, fmt.Errorf("make request: %w", err) + } + req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) + + resp, err := s.conf.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + var c *Commitment + if err := json.NewDecoder(resp.Body).Decode(&c); err != nil { + return nil, fmt.Errorf("decode response body: %w", err) + } + return c, nil + default: + return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) + } } func (s *Session) Close() error { if err := s.rta.Unsubscribe(context.Background(), s.sub); err != nil { - s.log.Error("error unsubscribing with RTA", "err", err) + s.conf.Logger.Error("error unsubscribing with RTA", "err", err) } _, err := s.CommitContext(context.Background(), &SessionDescription{ Members: map[string]*MemberDescription{ diff --git a/xsapi/mpsd/subscription.go b/xsapi/mpsd/subscription.go new file mode 100644 index 00000000..56266959 --- /dev/null +++ b/xsapi/mpsd/subscription.go @@ -0,0 +1,58 @@ +package mpsd + +import ( + "encoding/json" + "fmt" + "github.com/google/uuid" + "github.com/sandertv/gophertunnel/xsapi/internal" + "strings" +) + +const resourceURI = "https://sessiondirectory.xboxlive.com/connections/" + +type subscription struct { + ConnectionID uuid.UUID `json:"ConnectionId,omitempty"` +} + +type subscriptionHandler struct { + *Session +} + +func (h *subscriptionHandler) HandleEvent(data json.RawMessage) { + var event subscriptionEvent + if err := json.Unmarshal(data, &event); err != nil { + h.conf.Logger.Error("error decoding subscription event", internal.ErrAttr(err)) + } + for _, tap := range event.ShoulderTaps { + ref, err := h.parseReference(tap.Resource) + if err != nil { + h.conf.Logger.Error("handle subscription event: error parsing shoulder tap", internal.ErrAttr(err)) + continue + } + h.handler().HandleSessionChange(ref, tap.Branch, tap.ChangeNumber) + } +} + +func (h *subscriptionHandler) parseReference(s string) (ref SessionReference, err error) { + segments := strings.Split(s, "~") + if len(segments) != 3 { + return ref, fmt.Errorf("unexpected segmentations: %s", s) + } + ref.ServiceConfigID, err = uuid.Parse(segments[0]) + if err != nil { + return ref, fmt.Errorf("parse service config ID: %w", err) + } + ref.TemplateName = segments[1] + ref.Name = segments[2] + return ref, nil +} + +type subscriptionEvent struct { + ShoulderTaps []shoulderTap `json:"shoulderTaps"` +} + +type shoulderTap struct { + Resource string `json:"resource"` + ChangeNumber uint64 `json:"changeNumber"` + Branch uuid.UUID `json:"branch"` +} diff --git a/xsapi/rta/conn.go b/xsapi/rta/conn.go index d4ef753f..aed54b91 100644 --- a/xsapi/rta/conn.go +++ b/xsapi/rta/conn.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/sandertv/gophertunnel/xsapi/internal" "log/slog" + "net" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" "sync" @@ -33,7 +34,9 @@ type Conn struct { subscriptionsMu sync.RWMutex log *slog.Logger - ctx context.Context + + once sync.Once + closed chan struct{} } // Subscribe attempts to subscribe with the specific resource URI, with the context @@ -71,8 +74,8 @@ func (c *Conn) Subscribe(ctx context.Context, resourceURI string) (*Subscription } case <-ctx.Done(): return nil, ctx.Err() - case <-c.ctx.Done(): - return nil, context.Cause(c.ctx) + case <-c.closed: + return nil, net.ErrClosed } } @@ -91,8 +94,8 @@ func (c *Conn) Unsubscribe(ctx context.Context, sub *Subscription) error { return nil case <-ctx.Done(): return ctx.Err() - case <-c.ctx.Done(): - return context.Cause(c.ctx) + case <-c.closed: + return net.ErrClosed } } @@ -138,14 +141,11 @@ func (c *Conn) write(typ uint32, payload []any) error { // read goes as a background goroutine of Conn, reading a JSON array from the websocket // connection and decoding a header needed to indicate which message should be handled. -// -// read cancels the parent context of Conn with cause from context.CancelCauseFunc, if an -// unrecoverable error has occurred while reading a JSON array from the websocket connection. -func (c *Conn) read(cause context.CancelCauseFunc) { +func (c *Conn) read() { for { var payload []json.RawMessage if err := wsjson.Read(context.Background(), c.conn, &payload); err != nil { - cause(err) + _ = c.Close() return } typ, err := readHeader(payload) @@ -158,7 +158,13 @@ func (c *Conn) read(cause context.CancelCauseFunc) { } // Close closes the websocket connection with websocket.StatusNormalClosure. -func (c *Conn) Close() error { return c.conn.Close(websocket.StatusNormalClosure, "") } +func (c *Conn) Close() (err error) { + c.once.Do(func() { + close(c.closed) + err = c.conn.Close(websocket.StatusNormalClosure, "") + }) + return err +} // handleMessage handles a message received in read with the type. func (c *Conn) handleMessage(typ uint32, payload []json.RawMessage) { diff --git a/xsapi/rta/dial.go b/xsapi/rta/dial.go index ef8f2b3e..9186e40d 100644 --- a/xsapi/rta/dial.go +++ b/xsapi/rta/dial.go @@ -3,6 +3,7 @@ package rta import ( "context" "github.com/sandertv/gophertunnel/xsapi" + "github.com/sandertv/gophertunnel/xsapi/internal" "log/slog" "net/http" "nhooyr.io/websocket" @@ -43,35 +44,21 @@ func (d Dialer) DialContext(ctx context.Context, src xsapi.TokenSource) (*Conn, if d.Options.HTTPClient == nil { d.Options.HTTPClient = &http.Client{} } - var ( - hasTransport bool - base = d.Options.HTTPClient.Transport - ) - if base != nil { - _, hasTransport = base.(*xsapi.Transport) - } - if !hasTransport { - d.Options.HTTPClient.Transport = &xsapi.Transport{ - Source: src, - Base: base, - } - } + internal.SetTransport(d.Options.HTTPClient, src) c, _, err := websocket.Dial(ctx, connectURL, d.Options) if err != nil { return nil, err } - background, cancel := context.WithCancelCause(context.Background()) conn := &Conn{ conn: c, log: d.ErrorLog, - ctx: background, subscriptions: make(map[uint32]*Subscription), } for i := 0; i < cap(conn.expected); i++ { conn.expected[i] = make(map[uint32]chan<- *handshake) } - go conn.read(cancel) + go conn.read() return conn, nil } diff --git a/xsapi/xal/token_source.go b/xsapi/xal/token_source.go index d798a0b3..5d94d952 100644 --- a/xsapi/xal/token_source.go +++ b/xsapi/xal/token_source.go @@ -10,15 +10,10 @@ import ( ) func RefreshTokenSource(underlying oauth2.TokenSource, relyingParty string) xsapi.TokenSource { - return RefreshTokenSourceContext(context.Background(), underlying, relyingParty) -} - -func RefreshTokenSourceContext(ctx context.Context, underlying oauth2.TokenSource, relyingParty string) xsapi.TokenSource { return &refreshTokenSource{ underlying: underlying, relyingParty: relyingParty, - ctx: ctx, } } @@ -26,7 +21,6 @@ type refreshTokenSource struct { underlying oauth2.TokenSource relyingParty string - ctx context.Context t *oauth2.Token x *auth.XBLToken @@ -42,7 +36,7 @@ func (r *refreshTokenSource) Token() (_ xsapi.Token, err error) { if err != nil { return nil, fmt.Errorf("request underlying token: %w", err) } - r.x, err = auth.RequestXBLToken(r.ctx, r.t, r.relyingParty) + r.x, err = auth.RequestXBLToken(context.Background(), r.t, r.relyingParty) if err != nil { return nil, fmt.Errorf("request xbox live token: %w", err) } From 22a73746073f02ac3ff208de5fcf0674f1403cc8 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Tue, 27 Aug 2024 01:08:05 +0900 Subject: [PATCH 11/14] minecraft/nethernet.go: Moved from minecraft/nethernet/network.go --- minecraft/nethernet.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/minecraft/nethernet.go b/minecraft/nethernet.go index a8073148..2b71bd61 100644 --- a/minecraft/nethernet.go +++ b/minecraft/nethernet.go @@ -16,7 +16,7 @@ type NetherNet struct { func (n NetherNet) DialContext(ctx context.Context, address string) (net.Conn, error) { if n.Signaling == nil { - return nil, errors.New("minecraft/nethernet: Network.DialContext: Signaling is nil") + return nil, errors.New("minecraft: NetherNet.DialContext: Signaling is nil") } networkID, err := strconv.ParseUint(address, 10, 64) if err != nil { @@ -27,12 +27,12 @@ func (n NetherNet) DialContext(ctx context.Context, address string) (net.Conn, e } func (n NetherNet) PingContext(context.Context, string) ([]byte, error) { - return nil, errors.New("minecraft/nethernet: Network.PingContext: not supported") + return nil, errors.New("minecraft: NetherNet.PingContext: not supported") } func (n NetherNet) Listen(address string) (NetworkListener, error) { if n.Signaling == nil { - return nil, errors.New("minecraft/nethernet: Network.Listen: Signaling is nil") + return nil, errors.New("minecraft: NetherNet.Listen: Signaling is nil") } networkID, err := strconv.ParseUint(address, 10, 64) if err != nil { From 0a81225cbda9421312663bd3709370a1f31aec76 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:17:34 +0900 Subject: [PATCH 12/14] minecraft/room: Add draft implementation --- go.mod | 47 +- go.sum | 85 ++-- minecraft/{world_test.go => _world_test.go} | 0 {xsapi => minecraft/auth}/xal/token_source.go | 2 +- minecraft/auth/xbox.go | 23 +- minecraft/franchise/playfab.go | 4 +- minecraft/franchise/signaling/conn.go | 46 +- minecraft/franchise/signaling/conn_test.go | 4 +- minecraft/franchise/signaling/dial.go | 4 +- minecraft/franchise/token_test.go | 4 +- minecraft/listener.go | 6 + minecraft/nethernet.go | 2 +- minecraft/nethernet/conn.go | 391 ----------------- minecraft/nethernet/credentials.go | 12 - minecraft/nethernet/dial.go | 245 ----------- minecraft/nethernet/discovery/crypto.go | 38 -- minecraft/nethernet/discovery/listener.go | 293 ------------- .../nethernet/discovery/listener_test.go | 53 --- minecraft/nethernet/discovery/packet.go | 134 ------ .../nethernet/discovery/packet_message.go | 31 -- .../nethernet/discovery/packet_request.go | 11 - .../nethernet/discovery/packet_response.go | 32 -- minecraft/nethernet/discovery/room.go | 53 --- minecraft/nethernet/discovery/server_data.go | 68 --- minecraft/nethernet/internal/test/.gitignore | 1 - .../nethernet/internal/test/token_source.go | 54 --- minecraft/nethernet/listener.go | 360 ---------------- minecraft/nethernet/message.go | 45 -- minecraft/nethernet/signal.go | 161 ------- minecraft/room/_dial.go | 81 ++++ minecraft/room/announce.go | 9 +- minecraft/room/discovery.go | 39 ++ .../{nethernet => room}/internal/attr.go | 0 minecraft/room/listener.go | 116 +++++ minecraft/room/listener_test.go | 187 ++++++++ minecraft/room/mpsd.go | 118 +++++ minecraft/room/network.go | 45 ++ minecraft/room/status.go | 63 ++- minecraft/room/status_provider.go | 5 - playfab/catalog/dictionary.go | 97 ----- playfab/catalog/item.go | 195 --------- playfab/catalog/query.go | 26 -- playfab/catalog/search_items.go | 63 --- playfab/entity/exchange.go | 22 - playfab/entity/token.go | 31 -- playfab/entity/token_source.go | 75 ---- playfab/identity.go | 402 ------------------ playfab/internal/body.go | 105 ----- playfab/internal/http.go | 55 --- playfab/login.go | 61 --- playfab/title/title.go | 9 - playfab/types.go | 21 - playfab/xbox_live.go | 31 -- xsapi/internal/attr.go | 7 - xsapi/internal/transport.go | 22 - xsapi/mpsd/activity.go | 155 ------- xsapi/mpsd/commit.go | 81 ---- xsapi/mpsd/handler.go | 22 - xsapi/mpsd/invite.go | 66 --- xsapi/mpsd/join.go | 14 - xsapi/mpsd/member.go | 57 --- xsapi/mpsd/publish.go | 125 ------ xsapi/mpsd/session.go | 171 -------- xsapi/mpsd/subscription.go | 58 --- xsapi/rta/conn.go | 251 ----------- xsapi/rta/dial.go | 71 ---- xsapi/rta/handshake.go | 91 ---- xsapi/token.go | 24 -- xsapi/transport.go | 58 --- 69 files changed, 780 insertions(+), 4558 deletions(-) rename minecraft/{world_test.go => _world_test.go} (100%) rename {xsapi => minecraft/auth}/xal/token_source.go (96%) delete mode 100644 minecraft/nethernet/conn.go delete mode 100644 minecraft/nethernet/credentials.go delete mode 100644 minecraft/nethernet/dial.go delete mode 100644 minecraft/nethernet/discovery/crypto.go delete mode 100644 minecraft/nethernet/discovery/listener.go delete mode 100644 minecraft/nethernet/discovery/listener_test.go delete mode 100644 minecraft/nethernet/discovery/packet.go delete mode 100644 minecraft/nethernet/discovery/packet_message.go delete mode 100644 minecraft/nethernet/discovery/packet_request.go delete mode 100644 minecraft/nethernet/discovery/packet_response.go delete mode 100644 minecraft/nethernet/discovery/room.go delete mode 100644 minecraft/nethernet/discovery/server_data.go delete mode 100644 minecraft/nethernet/internal/test/.gitignore delete mode 100644 minecraft/nethernet/internal/test/token_source.go delete mode 100644 minecraft/nethernet/listener.go delete mode 100644 minecraft/nethernet/message.go delete mode 100644 minecraft/nethernet/signal.go create mode 100644 minecraft/room/_dial.go create mode 100644 minecraft/room/discovery.go rename minecraft/{nethernet => room}/internal/attr.go (100%) create mode 100644 minecraft/room/listener.go create mode 100644 minecraft/room/listener_test.go create mode 100644 minecraft/room/mpsd.go create mode 100644 minecraft/room/network.go delete mode 100644 minecraft/room/status_provider.go delete mode 100644 playfab/catalog/dictionary.go delete mode 100644 playfab/catalog/item.go delete mode 100644 playfab/catalog/query.go delete mode 100644 playfab/catalog/search_items.go delete mode 100644 playfab/entity/exchange.go delete mode 100644 playfab/entity/token.go delete mode 100644 playfab/entity/token_source.go delete mode 100644 playfab/identity.go delete mode 100644 playfab/internal/body.go delete mode 100644 playfab/internal/http.go delete mode 100644 playfab/login.go delete mode 100644 playfab/title/title.go delete mode 100644 playfab/types.go delete mode 100644 playfab/xbox_live.go delete mode 100644 xsapi/internal/attr.go delete mode 100644 xsapi/internal/transport.go delete mode 100644 xsapi/mpsd/activity.go delete mode 100644 xsapi/mpsd/commit.go delete mode 100644 xsapi/mpsd/handler.go delete mode 100644 xsapi/mpsd/invite.go delete mode 100644 xsapi/mpsd/join.go delete mode 100644 xsapi/mpsd/member.go delete mode 100644 xsapi/mpsd/publish.go delete mode 100644 xsapi/mpsd/session.go delete mode 100644 xsapi/mpsd/subscription.go delete mode 100644 xsapi/rta/conn.go delete mode 100644 xsapi/rta/dial.go delete mode 100644 xsapi/rta/handshake.go delete mode 100644 xsapi/token.go delete mode 100644 xsapi/transport.go diff --git a/go.mod b/go.mod index 7457188c..3e6cfd62 100644 --- a/go.mod +++ b/go.mod @@ -1,50 +1,53 @@ module github.com/sandertv/gophertunnel -go 1.22 - -toolchain go1.22.1 +go 1.23.0 require ( + 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 github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.17.9 - github.com/kr/pretty v0.1.0 github.com/muhammadmuzzammil1998/jsonc v1.0.0 github.com/pelletier/go-toml v1.9.5 - github.com/pion/ice/v3 v3.0.16 - github.com/pion/logging v0.2.2 - github.com/pion/sdp/v3 v3.0.9 - github.com/pion/webrtc/v4 v4.0.0-beta.27.0.20240806193753-4ba98f5921a6 github.com/sandertv/go-raknet v1.14.1 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 nhooyr.io/websocket v1.8.11 ) require ( - github.com/andreburgaud/crypt2go v1.6.0 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/pion/datachannel v1.5.8 // indirect - github.com/pion/dtls/v2 v2.2.12 // indirect - github.com/pion/dtls/v3 v3.0.0 // indirect + github.com/andreburgaud/crypt2go v1.8.0 // indirect + github.com/coder/websocket v1.8.12 // 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/logging v0.2.2 // 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.8 // indirect - github.com/pion/sctp v1.8.20 // 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/v2 v2.0.0 // indirect - github.com/pion/transport/v2 v2.2.8 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v3 v3.0.3 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/pion/webrtc/v4 v4.0.0-beta.29.0.20240826201411-3147b45f9db5 // indirect github.com/wlynxg/anet v0.0.3 // indirect - golang.org/x/crypto v0.25.0 // indirect + golang.org/x/crypto v0.26.0 // indirect golang.org/x/image v0.17.0 // indirect - golang.org/x/sys v0.22.0 // indirect + golang.org/x/sys v0.24.0 // indirect ) -replace github.com/pion/sctp => github.com/lactyy/sctp v0.0.0-20240806210006-9a1eff46ee7b +replace ( + github.com/df-mc/go-nethernet => github.com/lactyy/go-nethernet v0.0.0-20240902104417-681fd9263f4a + github.com/df-mc/go-playfab => github.com/lactyy/go-playfab v0.0.0-20240906070923-01f9987eafb6 + github.com/df-mc/go-xsapi => github.com/lactyy/go-xsapi v0.0.0-20240902120723-5a844e61607e + github.com/pion/sctp => github.com/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3 +) diff --git a/go.sum b/go.sum index 6b783651..34e920c8 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ -github.com/andreburgaud/crypt2go v1.6.0 h1:fZd6AWPYWLwzGQpk2MqiUgcax7Mh86uba+ZiOZw51lg= -github.com/andreburgaud/crypt2go v1.6.0/go.mod h1:6kn17HKUqQtbTUHwYcyTHmvZYVLYUqA5jwiCQE6h5N4= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/andreburgaud/crypt2go v1.8.0 h1:J73vGTb1P6XL69SSuumbKs0DWn3ulbl9L92ZXBjw6pc= +github.com/andreburgaud/crypt2go v1.8.0/go.mod h1:L5nfShQ91W78hOWhUH2tlGRPO+POAPJAF5fKOLB9SXg= +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= @@ -16,27 +17,24 @@ 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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lactyy/sctp v0.0.0-20240806210006-9a1eff46ee7b h1:tVE+MVXKEh0UZUOVZ8FW/6pc3OL543RTEuV5QEGCOiU= -github.com/lactyy/sctp v0.0.0-20240806210006-9a1eff46ee7b/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= +github.com/lactyy/go-nethernet v0.0.0-20240902104417-681fd9263f4a h1:Y8LbIx1RsSIUDvdVHkoBuK7/eP/U4E6IXpeGz0Ng+iY= +github.com/lactyy/go-nethernet v0.0.0-20240902104417-681fd9263f4a/go.mod h1:/pGUz0nwAHcpKynNyRz1sXVsF0klaevDsMkPXsdP7mM= +github.com/lactyy/go-playfab v0.0.0-20240906070923-01f9987eafb6 h1:5+LTlXf9yVTVP1ooZ0U+AXEn9Kbxlx98yfFdrADGoMQ= +github.com/lactyy/go-playfab v0.0.0-20240906070923-01f9987eafb6/go.mod h1:/beD0DtQFxxslNr1iKt3+SrCjW2HNpwJYPOmM/277eQ= +github.com/lactyy/go-xsapi v0.0.0-20240902120723-5a844e61607e h1:6Jp7yaAMJJl8Vz6soxCz1ZVUPYBpaxVTksgzwRBWPps= +github.com/lactyy/go-xsapi v0.0.0-20240902120723-5a844e61607e/go.mod h1:yMOOWhg3JL/t3si+6jb+UZgYS/E2RU4A6oanupqk3iI= +github.com/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3 h1:Nikw9jHHbZZgeN+YugbVOldbv+0PoZVm4ZSMSGItpeU= +github.com/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= 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.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo= -github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI= -github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= -github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/dtls/v3 v3.0.0 h1:m2hzwPkzqoBjVKXm5ymNuX01OAjht82TdFL6LoTzgi4= -github.com/pion/dtls/v3 v3.0.0/go.mod h1:tiX7NaneB0wNoRaUpaMVP7igAlkMCTQkbpiY+OfeIi0= -github.com/pion/ice/v3 v3.0.16 h1:YoPlNg3jU1UT/DDTa9v/g1vH6A2/pAzehevI1o66H8E= -github.com/pion/ice/v3 v3.0.16/go.mod h1:SdmubtIsCcvdb1ZInrTUz7Iaqi90/rYd1pzbzlMxsZg= +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= @@ -47,25 +45,20 @@ 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.8 h1:EtYFHI0rpUEjT/RMnGfb1vdJhbYmPG77szD72uUnSxs= -github.com/pion/rtp v1.8.8/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +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/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/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= -github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= -github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= -github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.8 h1:HzsqGBChgtF4Cj47gu51l5hONuK/NwgbZL17CMSuwS0= -github.com/pion/transport/v2 v2.2.8/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= -github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +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/v3 v3.0.3 h1:1e3GVk8gHZLPBA5LqadWYV60lmaKUaHCkm9DX9CkGcE= -github.com/pion/turn/v3 v3.0.3/go.mod h1:vw0Dz420q7VYAF3J4wJKzReLHIo2LGp4ev8nXQexYsc= -github.com/pion/webrtc/v4 v4.0.0-beta.27.0.20240806193753-4ba98f5921a6 h1:Hr6Qmk2WPPpBAv74thidM5HRcv1bJdg8XibyX1ueJL8= -github.com/pion/webrtc/v4 v4.0.0-beta.27.0.20240806193753-4ba98f5921a6/go.mod h1:ZOnztLYCdXE1sMCFrOWJfAXw0EBgXSjHDB+ISNEIwE8= +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.1 h1:V2Gslo+0x4jfj+p0PM48mWxmMbYkxSlgeKy//y3ZrzI= @@ -77,7 +70,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ 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.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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= @@ -86,12 +78,9 @@ github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguH 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.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +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= @@ -101,10 +90,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 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= @@ -118,30 +104,23 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.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= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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= diff --git a/minecraft/world_test.go b/minecraft/_world_test.go similarity index 100% rename from minecraft/world_test.go rename to minecraft/_world_test.go diff --git a/xsapi/xal/token_source.go b/minecraft/auth/xal/token_source.go similarity index 96% rename from xsapi/xal/token_source.go rename to minecraft/auth/xal/token_source.go index 5d94d952..e7af700b 100644 --- a/xsapi/xal/token_source.go +++ b/minecraft/auth/xal/token_source.go @@ -3,8 +3,8 @@ package xal import ( "context" "fmt" + "github.com/df-mc/go-xsapi" "github.com/sandertv/gophertunnel/minecraft/auth" - "github.com/sandertv/gophertunnel/xsapi" "golang.org/x/oauth2" "sync" ) diff --git a/minecraft/auth/xbox.go b/minecraft/auth/xbox.go index 157de725..dc1468df 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,29 @@ type XBLToken struct { } Token string } + + // key is the private key used to sign requests. + key *ecdsa.PrivateKey +} + +func (t XBLToken) String() string { + return fmt.Sprintf("XBL3.0 x=%s;%s", t.AuthorizationToken.DisplayClaims.UserInfo[0].UserHash, t.AuthorizationToken.Token) +} + +func (t XBLToken) DisplayClaims() xsapi.DisplayClaims { + return t.AuthorizationToken.DisplayClaims.UserInfo[0] } // SetAuthHeader 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) SetAuthHeader(r *http.Request) { r.Header.Set("Authorization", fmt.Sprintf("XBL3.0 x=%v;%v", t.AuthorizationToken.DisplayClaims.UserInfo[0].UserHash, t.AuthorizationToken.Token)) + + 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 +118,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 +181,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 +238,3 @@ func parseXboxErrorCode(code string) string { return fmt.Sprintf("unknown error code: %v", code) } } - diff --git a/minecraft/franchise/playfab.go b/minecraft/franchise/playfab.go index d2b3ca32..f621da25 100644 --- a/minecraft/franchise/playfab.go +++ b/minecraft/franchise/playfab.go @@ -3,8 +3,8 @@ package franchise import ( "errors" "fmt" - "github.com/sandertv/gophertunnel/playfab" - "github.com/sandertv/gophertunnel/playfab/title" + "github.com/df-mc/go-playfab" + "github.com/df-mc/go-playfab/title" "golang.org/x/text/language" ) diff --git a/minecraft/franchise/signaling/conn.go b/minecraft/franchise/signaling/conn.go index cdd9f836..c9e038fd 100644 --- a/minecraft/franchise/signaling/conn.go +++ b/minecraft/franchise/signaling/conn.go @@ -3,8 +3,8 @@ package signaling import ( "context" "encoding/json" + "github.com/df-mc/go-nethernet" "github.com/sandertv/gophertunnel/minecraft/franchise/internal" - "github.com/sandertv/gophertunnel/minecraft/nethernet" "net" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" @@ -24,10 +24,12 @@ type Conn struct { once sync.Once closed chan struct{} - signals chan *nethernet.Signal + notifyCount uint32 + notifiers map[uint32]nethernet.Notifier + notifiersMu sync.Mutex } -func (c *Conn) WriteSignal(signal *nethernet.Signal) error { +func (c *Conn) Signal(signal *nethernet.Signal) error { return c.write(Message{ Type: MessageTypeSignal, To: json.Number(strconv.FormatUint(signal.NetworkID, 10)), @@ -35,15 +37,27 @@ func (c *Conn) WriteSignal(signal *nethernet.Signal) error { }) } -func (c *Conn) ReadSignal(cancel <-chan struct{}) (*nethernet.Signal, error) { +func (c *Conn) Notify(cancel <-chan struct{}, n nethernet.Notifier) { + c.notifiersMu.Lock() + i := c.notifyCount + c.notifiers[i] = n + c.notifyCount++ + c.notifiersMu.Unlock() + + go c.notify(cancel, n, i) +} + +func (c *Conn) notify(cancel <-chan struct{}, n nethernet.Notifier, i uint32) { select { - case <-cancel: - return nil, nethernet.ErrSignalingCanceled case <-c.closed: - return nil, net.ErrClosed - case s := <-c.signals: - return s, nil + n.NotifyError(net.ErrClosed) + case <-cancel: + n.NotifyError(nethernet.ErrSignalingCanceled) } + + c.notifiersMu.Lock() + delete(c.notifiers, i) + c.notifiersMu.Unlock() } func (c *Conn) Credentials() (*nethernet.Credentials, error) { @@ -97,18 +111,23 @@ func (c *Conn) read() { close(c.credentialsReceived) } case MessageTypeSignal: - s := &nethernet.Signal{} - if err := s.UnmarshalText([]byte(message.Data)); err != nil { + 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 - s.NetworkID, err = strconv.ParseUint(message.From, 10, 64) + 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.signals <- s + + c.notifiersMu.Lock() + for _, n := range c.notifiers { + n.NotifySignal(signal) + } + c.notifiersMu.Unlock() default: c.d.Log.Warn("received message for unknown type", "message", message) } @@ -122,7 +141,6 @@ func (c *Conn) write(m Message) error { func (c *Conn) Close() (err error) { c.once.Do(func() { close(c.closed) - close(c.signals) err = c.conn.Close(websocket.StatusNormalClosure, "") }) return err diff --git a/minecraft/franchise/signaling/conn_test.go b/minecraft/franchise/signaling/conn_test.go index 66174cbc..fbb2fc9d 100644 --- a/minecraft/franchise/signaling/conn_test.go +++ b/minecraft/franchise/signaling/conn_test.go @@ -2,12 +2,12 @@ package signaling import ( "context" + "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" "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" "github.com/sandertv/gophertunnel/minecraft/protocol" - "github.com/sandertv/gophertunnel/playfab" - "github.com/sandertv/gophertunnel/xsapi/xal" "testing" "time" ) diff --git a/minecraft/franchise/signaling/dial.go b/minecraft/franchise/signaling/dial.go index 7538aeb4..88cb446b 100644 --- a/minecraft/franchise/signaling/dial.go +++ b/minecraft/franchise/signaling/dial.go @@ -3,8 +3,8 @@ package signaling import ( "context" "fmt" + "github.com/df-mc/go-nethernet" "github.com/sandertv/gophertunnel/minecraft/franchise" - "github.com/sandertv/gophertunnel/minecraft/nethernet" "log/slog" "math/rand" "net/http" @@ -65,7 +65,7 @@ func (d Dialer) DialContext(ctx context.Context, i franchise.IdentityProvider, e closed: make(chan struct{}), - signals: make(chan *nethernet.Signal), + notifiers: make(map[uint32]nethernet.Notifier), } go conn.read() go conn.ping() diff --git a/minecraft/franchise/token_test.go b/minecraft/franchise/token_test.go index e2fa5049..23a99dc5 100644 --- a/minecraft/franchise/token_test.go +++ b/minecraft/franchise/token_test.go @@ -1,11 +1,11 @@ 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" - "github.com/sandertv/gophertunnel/playfab" - "github.com/sandertv/gophertunnel/xsapi/xal" "testing" ) diff --git a/minecraft/listener.go b/minecraft/listener.go index e17cf41e..f934cae1 100644 --- a/minecraft/listener.go +++ b/minecraft/listener.go @@ -225,6 +225,12 @@ func (listener *Listener) updatePongData() { 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 diff --git a/minecraft/nethernet.go b/minecraft/nethernet.go index 2b71bd61..575e6981 100644 --- a/minecraft/nethernet.go +++ b/minecraft/nethernet.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "github.com/sandertv/gophertunnel/minecraft/nethernet" + "github.com/df-mc/go-nethernet" "net" "strconv" ) diff --git a/minecraft/nethernet/conn.go b/minecraft/nethernet/conn.go deleted file mode 100644 index f72bf83f..00000000 --- a/minecraft/nethernet/conn.go +++ /dev/null @@ -1,391 +0,0 @@ -package nethernet - -import ( - "errors" - "fmt" - "github.com/pion/ice/v3" - "github.com/pion/sdp/v3" - "github.com/pion/webrtc/v4" - "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" - "io" - "log/slog" - "math/rand" - "net" - "strconv" - "strings" - "sync" - "time" -) - -type Conn struct { - ice *webrtc.ICETransport - dtls *webrtc.DTLSTransport - sctp *webrtc.SCTPTransport - - remote *description - - candidateReceived chan struct{} // Notifies that a first candidate is received from the other end, and the Conn is ready to start its transports. - candidates []webrtc.ICECandidate - candidatesMu sync.Mutex - - handler handler - - localCandidates []webrtc.ICECandidate - localNetworkID uint64 - - reliable, unreliable *webrtc.DataChannel // ReliableDataChannel and UnreliableDataChannel - - packets chan []byte - - message *message - - once sync.Once - closed chan struct{} - - log *slog.Logger - - id, networkID uint64 -} - -func (c *Conn) Read(b []byte) (n int, err error) { - select { - case <-c.closed: - return n, net.ErrClosed - case pk := <-c.packets: - return copy(b, pk), nil - } -} - -func (c *Conn) ReadPacket() ([]byte, error) { - select { - case <-c.closed: - return nil, net.ErrClosed - case pk := <-c.packets: - return pk, nil - } -} - -func (c *Conn) Write(b []byte) (n int, err error) { - select { - case <-c.closed: - return n, net.ErrClosed - default: - segments := uint8(len(b) / maxMessageSize) - if len(b)%maxMessageSize != 0 { - segments++ // If there's a remainder, we need an additional segment. - } - - for i := 0; i < len(b); i += maxMessageSize { - segments-- - - end := i + maxMessageSize - if end > len(b) { - end = len(b) - } - frag := b[i:end] - if err := c.reliable.Send(append([]byte{segments}, frag...)); err != nil { - if errors.Is(err, io.ErrClosedPipe) { - return n, net.ErrClosed - } - return n, fmt.Errorf("write segment #%d: %w", segments, err) - } - n += len(frag) - } - return n, nil - } -} - -func (*Conn) SetDeadline(time.Time) error { - return errors.New("minecraft/nethernet: Conn: not implemented (yet)") -} - -func (*Conn) SetReadDeadline(time.Time) error { - return errors.New("minecraft/nethernet: Conn: not implemented (yet)") -} - -func (*Conn) SetWriteDeadline(time.Time) error { - return errors.New("minecraft/nethernet: Conn: not implemented (yet)") -} - -func (c *Conn) LocalAddr() net.Addr { - return &Addr{ - NetworkID: c.localNetworkID, - ConnectionID: c.id, - Candidates: c.localCandidates, - } -} - -func (c *Conn) RemoteAddr() net.Addr { - c.candidatesMu.Lock() - defer c.candidatesMu.Unlock() - - return &Addr{ - NetworkID: c.networkID, - ConnectionID: c.id, - Candidates: c.candidates, - } -} - -func (c *Conn) Close() (err error) { - c.once.Do(func() { - close(c.closed) - - c.handler.handleClose(c) - - errs := make([]error, 0, 5) - errs = append(errs, c.reliable.Close()) - errs = append(errs, c.unreliable.Close()) - errs = append(errs, c.sctp.Stop()) - errs = append(errs, c.dtls.Stop()) - errs = append(errs, c.ice.Stop()) - err = errors.Join(errs...) - }) - return err -} - -func (c *Conn) handleTransports() { - c.reliable.OnMessage(func(msg webrtc.DataChannelMessage) { - if err := c.handleMessage(msg.Data); err != nil { - c.log.Error("error handling remote message", internal.ErrAttr(err)) - } - }) - - c.reliable.OnClose(func() { - _ = c.Close() - }) - - c.unreliable.OnClose(func() { - _ = c.Close() - }) - - c.ice.OnConnectionStateChange(func(state webrtc.ICETransportState) { - switch state { - case webrtc.ICETransportStateClosed, webrtc.ICETransportStateDisconnected, webrtc.ICETransportStateFailed: - // This handler function itself is holding the lock, call Close in a goroutine. - go c.Close() // We need to make sure that all transports has been closed - default: - } - }) - c.dtls.OnStateChange(func(state webrtc.DTLSTransportState) { - switch state { - case webrtc.DTLSTransportStateClosed, webrtc.DTLSTransportStateFailed: - // This handler function itself is holding the lock, call Close in a goroutine. - go c.Close() // We need to make sure that all transports has been closed - default: - } - }) -} - -func (c *Conn) handleSignal(signal *Signal) error { - switch signal.Type { - case SignalTypeCandidate: - candidate, err := ice.UnmarshalCandidate(signal.Data) - if err != nil { - return fmt.Errorf("decode candidate: %w", err) - } - protocol, err := webrtc.NewICEProtocol(candidate.NetworkType().NetworkShort()) - if err != nil { - return fmt.Errorf("parse ICE protocol: %w", err) - } - i := webrtc.ICECandidate{ - Foundation: candidate.Foundation(), - Priority: candidate.Priority(), - Address: candidate.Address(), - Protocol: protocol, - Port: uint16(candidate.Port()), - Component: candidate.Component(), - Typ: webrtc.ICECandidateType(candidate.Type()), - TCPType: candidate.TCPType().String(), - } - - if r := candidate.RelatedAddress(); r != nil { - i.RelatedAddress, i.RelatedPort = r.Address, uint16(r.Port) - } - - if err := c.ice.AddRemoteCandidate(&i); err != nil { - return fmt.Errorf("add remote candidate: %w", err) - } - - c.candidatesMu.Lock() - if len(c.candidates) == 0 { - close(c.candidateReceived) - } - c.candidates = append(c.candidates, i) - c.candidatesMu.Unlock() - case SignalTypeError: - code, err := strconv.ParseUint(signal.Data, 10, 32) - if err != nil { - return fmt.Errorf("parse error code: %w", err) - } - c.log.Error("connection failed with error", slog.Uint64("code", code)) - if err := c.Close(); err != nil { - return fmt.Errorf("close: %w", err) - } - } - return nil -} - -const maxMessageSize = 10000 - -func parseDescription(d *sdp.SessionDescription) (*description, error) { - if len(d.MediaDescriptions) != 1 { - return nil, fmt.Errorf("unexpected number of media descriptions: %d, expected 1", len(d.MediaDescriptions)) - } - m := d.MediaDescriptions[0] - - ufrag, ok := m.Attribute("ice-ufrag") - if !ok { - return nil, errors.New("missing ice-ufrag attribute") - } - pwd, ok := m.Attribute("ice-pwd") - if !ok { - return nil, errors.New("missing ice-pwd attribute") - } - - attr, ok := m.Attribute("fingerprint") - if !ok { - return nil, errors.New("missing fingerprint attribute") - } - fingerprint := strings.Split(attr, " ") - if len(fingerprint) != 2 { - return nil, fmt.Errorf("invalid fingerprint: %s", attr) - } - fingerprintAlgorithm, fingerprintValue := fingerprint[0], fingerprint[1] - - attr, ok = m.Attribute("max-message-size") - if !ok { - return nil, errors.New("missing max-message-size attribute") - } - maxMessageSize, err := strconv.ParseUint(attr, 10, 32) - if err != nil { - return nil, fmt.Errorf("parse max-message-size attribute as uint32: %w", err) - } - - return &description{ - ice: webrtc.ICEParameters{ - UsernameFragment: ufrag, - Password: pwd, - }, - dtls: webrtc.DTLSParameters{ - Fingerprints: []webrtc.DTLSFingerprint{ - { - Algorithm: fingerprintAlgorithm, - Value: fingerprintValue, - }, - }, - }, - sctp: webrtc.SCTPCapabilities{ - MaxMessageSize: uint32(maxMessageSize), - }, - }, nil -} - -type description struct { - ice webrtc.ICEParameters - dtls webrtc.DTLSParameters - sctp webrtc.SCTPCapabilities -} - -func (desc description) encode() ([]byte, error) { - d := &sdp.SessionDescription{ - Version: 0x2, - Origin: sdp.Origin{ - Username: "-", - SessionID: rand.Uint64(), - SessionVersion: 0x2, - NetworkType: "IN", - AddressType: "IP4", - UnicastAddress: "127.0.0.1", - }, - SessionName: "-", - TimeDescriptions: []sdp.TimeDescription{ - {}, - }, - Attributes: []sdp.Attribute{ - {Key: "group", Value: "BUNDLE 0"}, - {Key: "extmap-allow-mixed", Value: ""}, - {Key: "msid-semantic", Value: " WMS"}, - }, - MediaDescriptions: []*sdp.MediaDescription{ - { - MediaName: sdp.MediaName{ - Media: "application", - Port: sdp.RangedPort{Value: 9}, - Protos: []string{"UDP", "DTLS", "SCTP"}, - Formats: []string{"webrtc-datachannel"}, - }, - ConnectionInformation: &sdp.ConnectionInformation{ - NetworkType: "IN", - AddressType: "IP4", - Address: &sdp.Address{Address: "0.0.0.0"}, - }, - Attributes: []sdp.Attribute{ - {Key: "ice-ufrag", Value: desc.ice.UsernameFragment}, - {Key: "ice-pwd", Value: desc.ice.Password}, - {Key: "ice-options", Value: "trickle"}, - {Key: "fingerprint", Value: fmt.Sprintf("%s %s", - desc.dtls.Fingerprints[0].Algorithm, - desc.dtls.Fingerprints[0].Value, - )}, - desc.setupAttribute(), - {Key: "mid", Value: "0"}, - {Key: "sctp-port", Value: "5000"}, - {Key: "max-message-size", Value: strconv.FormatUint(uint64(desc.sctp.MaxMessageSize), 10)}, - }, - }, - }, - } - return d.Marshal() -} - -func (desc description) setupAttribute() sdp.Attribute { - attr := sdp.Attribute{Key: "setup"} - if desc.dtls.Role == webrtc.DTLSRoleServer { - attr.Value = "actpass" - } else { - attr.Value = "active" - } - return attr -} - -func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc.SCTPTransport, d *description, log *slog.Logger, id, networkID, localNetworkID uint64, candidates []webrtc.ICECandidate, h handler) *Conn { - if h == nil { - h = nopHandler{} - } - - return &Conn{ - ice: ice, - dtls: dtls, - sctp: sctp, - - remote: d, - - candidateReceived: make(chan struct{}, 1), - - handler: h, - - localNetworkID: localNetworkID, - localCandidates: candidates, - - packets: make(chan []byte), - - message: &message{}, - - closed: make(chan struct{}, 1), - - log: log.With(slog.Group("connection", - slog.Uint64("id", id), - slog.Uint64("networkID", networkID))), - - id: id, - networkID: networkID, - } -} - -type handler interface { - handleClose(conn *Conn) -} - -type nopHandler struct{} - -func (nopHandler) handleClose(*Conn) {} diff --git a/minecraft/nethernet/credentials.go b/minecraft/nethernet/credentials.go deleted file mode 100644 index 2286488d..00000000 --- a/minecraft/nethernet/credentials.go +++ /dev/null @@ -1,12 +0,0 @@ -package nethernet - -type Credentials struct { - ExpirationInSeconds int `json:"ExpirationInSeconds"` - ICEServers []ICEServer `json:"TurnAuthServers"` -} - -type ICEServer struct { - Username string `json:"Username"` - Password string `json:"Password"` - URLs []string `json:"Urls"` -} diff --git a/minecraft/nethernet/dial.go b/minecraft/nethernet/dial.go deleted file mode 100644 index 8a5650b1..00000000 --- a/minecraft/nethernet/dial.go +++ /dev/null @@ -1,245 +0,0 @@ -package nethernet - -import ( - "context" - "errors" - "fmt" - "github.com/pion/sdp/v3" - "github.com/pion/webrtc/v4" - "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" - "log/slog" - "math/rand" - "strconv" -) - -type Dialer struct { - NetworkID, ConnectionID uint64 - API *webrtc.API - Log *slog.Logger -} - -func (d Dialer) DialContext(ctx context.Context, networkID uint64, signaling Signaling) (*Conn, error) { - if d.NetworkID == 0 { - d.NetworkID = rand.Uint64() - } - if d.ConnectionID == 0 { - d.ConnectionID = rand.Uint64() - } - if d.API == nil { - d.API = webrtc.NewAPI() - } - if d.Log == nil { - d.Log = slog.Default() - } - credentials, err := signaling.Credentials() - if err != nil { - return nil, fmt.Errorf("obtain credentials: %w", err) - } - var gatherOptions webrtc.ICEGatherOptions - if credentials != nil && len(credentials.ICEServers) > 0 { - gatherOptions.ICEServers = make([]webrtc.ICEServer, len(credentials.ICEServers)) - for i, server := range credentials.ICEServers { - gatherOptions.ICEServers[i] = webrtc.ICEServer{ - Username: server.Username, - Credential: server.Password, - CredentialType: webrtc.ICECredentialTypePassword, - URLs: server.URLs, - } - } - } - gatherer, err := d.API.NewICEGatherer(gatherOptions) - if err != nil { - return nil, fmt.Errorf("create ICE gatherer: %w", err) - } - - var ( - candidates []webrtc.ICECandidate - gatherFinished = make(chan struct{}) - ) - gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) { - if candidate == nil { - close(gatherFinished) - return - } - candidates = append(candidates, *candidate) - }) - if err := gatherer.Gather(); err != nil { - return nil, fmt.Errorf("gather local candidates: %w", err) - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-gatherFinished: - ice := d.API.NewICETransport(gatherer) - dtls, err := d.API.NewDTLSTransport(ice, nil) - if err != nil { - return nil, fmt.Errorf("create DTLS transport: %w", err) - } - sctp := d.API.NewSCTPTransport(dtls) - - iceParams, err := ice.GetLocalParameters() - if err != nil { - return nil, fmt.Errorf("obtain local ICE parameters: %w", err) - } - dtlsParams, err := dtls.GetLocalParameters() - if err != nil { - return nil, fmt.Errorf("obtain local DTLS parameters: %w", err) - } - if len(dtlsParams.Fingerprints) == 0 { - return nil, errors.New("local DTLS parameters has no fingerprints") - } - sctpCapabilities := sctp.GetCapabilities() - - dtlsParams.Role = webrtc.DTLSRoleServer - - // Encode an offer using the local parameters! - offer, err := description{ - ice: iceParams, - dtls: dtlsParams, - sctp: sctpCapabilities, - }.encode() - if err != nil { - return nil, fmt.Errorf("encode offer: %w", err) - } - if err := signaling.WriteSignal(&Signal{ - Type: SignalTypeOffer, - Data: string(offer), - ConnectionID: d.ConnectionID, - NetworkID: networkID, - }); err != nil { - return nil, fmt.Errorf("signal offer: %w", err) - } - for i, candidate := range candidates { - if err := signaling.WriteSignal(&Signal{ - Type: SignalTypeCandidate, - Data: formatICECandidate(i, candidate, iceParams), - ConnectionID: d.ConnectionID, - NetworkID: networkID, - }); err != nil { - return nil, fmt.Errorf("signal candidate: %w", err) - } - } - - signals := make(chan *Signal) - go d.notifySignals(ctx, d.ConnectionID, networkID, signaling, signals) - - select { - case <-ctx.Done(): - if errors.Is(err, context.DeadlineExceeded) { - d.signalError(signaling, networkID, ErrorCodeNegotiationTimeoutWaitingForResponse) - } - return nil, ctx.Err() - case signal := <-signals: - if signal.Type != SignalTypeAnswer { - d.signalError(signaling, networkID, ErrorCodeIncomingConnectionIgnored) - return nil, fmt.Errorf("received signal for non-answer: %s", signal.String()) - } - - s := &sdp.SessionDescription{} - if err := s.UnmarshalString(signal.Data); err != nil { - d.signalError(signaling, networkID, ErrorCodeFailedToSetRemoteDescription) - return nil, fmt.Errorf("decode answer: %w", err) - } - desc, err := parseDescription(s) - if err != nil { - d.signalError(signaling, networkID, ErrorCodeFailedToSetRemoteDescription) - return nil, fmt.Errorf("parse offer: %w", err) - } - - c := newConn(ice, dtls, sctp, desc, d.Log, d.ConnectionID, networkID, d.NetworkID, candidates, nil) - go d.handleConn(ctx, c, signals) - - select { - case <-ctx.Done(): - if errors.Is(err, context.DeadlineExceeded) { - d.signalError(signaling, networkID, ErrorCodeInactivityTimeout) - } - return nil, ctx.Err() - case <-c.candidateReceived: - c.log.Debug("received first candidate") - if err := d.startTransports(c); err != nil { - return nil, fmt.Errorf("start transports: %w", err) - } - c.handleTransports() - return c, nil - } - } - } -} - -func (d Dialer) signalError(signaling Signaling, networkID uint64, code int) { - _ = signaling.WriteSignal(&Signal{ - Type: SignalTypeError, - Data: strconv.Itoa(code), - ConnectionID: d.ConnectionID, - NetworkID: networkID, - }) -} - -func (d Dialer) startTransports(conn *Conn) error { - conn.log.Debug("starting ICE transport as controller") - iceRole := webrtc.ICERoleControlling - if err := conn.ice.Start(nil, conn.remote.ice, &iceRole); err != nil { - return fmt.Errorf("start ICE: %w", err) - } - - conn.log.Debug("starting DTLS transport as client") - dtlsParams := conn.remote.dtls - dtlsParams.Role = webrtc.DTLSRoleClient - if err := conn.dtls.Start(dtlsParams); err != nil { - return fmt.Errorf("start DTLS: %w", err) - } - - conn.log.Debug("starting SCTP transport") - if err := conn.sctp.Start(conn.remote.sctp); err != nil { - return fmt.Errorf("start SCTP: %w", err) - } - var err error - conn.reliable, err = d.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ - Label: "ReliableDataChannel", - }) - if err != nil { - return fmt.Errorf("create ReliableDataChannel: %w", err) - } - conn.unreliable, err = d.API.NewDataChannel(conn.sctp, &webrtc.DataChannelParameters{ - Label: "UnreliableDataChannel", - Ordered: false, - }) - if err != nil { - return fmt.Errorf("create UnreliableDataChannel: %w", err) - } - return nil -} - -func (d Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Signal) { - for { - select { - case <-ctx.Done(): - return - case signal := <-signals: - switch signal.Type { - case SignalTypeCandidate, SignalTypeError: - if err := conn.handleSignal(signal); err != nil { - conn.log.Error("error handling signal", internal.ErrAttr(err)) - } - } - } - } -} - -func (d Dialer) notifySignals(ctx context.Context, id, networkID uint64, signaling Signaling, c chan<- *Signal) { - for { - signal, err := signaling.ReadSignal(ctx.Done()) - if err != nil { - if !errors.Is(err, ErrSignalingCanceled) { - d.Log.Error("error reading signal", internal.ErrAttr(err)) - } - return - } - if signal.ConnectionID != id || signal.NetworkID != networkID { - d.Log.Error("unexpected connection ID or network ID", slog.Group("signal", signal)) - continue - } - c <- signal - } -} diff --git a/minecraft/nethernet/discovery/crypto.go b/minecraft/nethernet/discovery/crypto.go deleted file mode 100644 index 6052e216..00000000 --- a/minecraft/nethernet/discovery/crypto.go +++ /dev/null @@ -1,38 +0,0 @@ -package discovery - -import ( - "crypto/aes" - "crypto/sha256" - "encoding/binary" - "fmt" - "github.com/andreburgaud/crypt2go/ecb" - "github.com/andreburgaud/crypt2go/padding" -) - -var key = sha256.Sum256(binary.LittleEndian.AppendUint64(nil, 0xdeadbeef)) // 0xdeadbeef is also referenced as Application ID - -func encrypt(src []byte) []byte { - block, _ := aes.NewCipher(key[:]) - mode := ecb.NewECBEncrypter(block) - pkcs7 := padding.NewPkcs7Padding(block.BlockSize()) - src, _ = pkcs7.Pad(src) - dst := make([]byte, len(src)) - mode.CryptBlocks(dst, src) - return dst -} - -func decrypt(src []byte) ([]byte, error) { - block, err := aes.NewCipher(key[:]) - if err != nil { - return nil, fmt.Errorf("make block: %w", err) - } - mode := ecb.NewECBDecrypter(block) - dst := make([]byte, len(src)) - mode.CryptBlocks(dst, src) - pkcs7 := padding.NewPkcs7Padding(block.BlockSize()) - dst, err = pkcs7.Unpad(dst) - if err != nil { - return nil, fmt.Errorf("unpad: %w", err) - } - return dst, nil -} diff --git a/minecraft/nethernet/discovery/listener.go b/minecraft/nethernet/discovery/listener.go deleted file mode 100644 index f5b9a429..00000000 --- a/minecraft/nethernet/discovery/listener.go +++ /dev/null @@ -1,293 +0,0 @@ -package discovery - -import ( - "context" - "errors" - "fmt" - "github.com/sandertv/gophertunnel/minecraft/nethernet" - "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" - "log/slog" - "math/rand" - "net" - "sync" - "sync/atomic" - "time" -) - -type ListenConfig struct { - NetworkID uint64 - BroadcastAddress net.Addr - Log *slog.Logger -} - -func (conf ListenConfig) Listen(network string, addr string) (*Listener, error) { - if conf.Log == nil { - conf.Log = slog.Default() - } - if conf.NetworkID == 0 { - conf.NetworkID = rand.Uint64() - } - conn, err := net.ListenPacket(network, addr) - if err != nil { - return nil, err - } - - l := &Listener{ - conn: conn, - - conf: conf, - - signals: make(chan *nethernet.Signal), - - addresses: make(map[uint64]net.Addr), - - closed: make(chan struct{}), - } - go l.listen() - - if conf.BroadcastAddress == nil { - conf.BroadcastAddress, err = broadcastAddress(conn.LocalAddr()) - if err != nil { - conf.Log.Error("error resolving address for broadcast: local rooms may not be returned") - } - } - if conf.BroadcastAddress != nil { - go l.broadcast(conf.BroadcastAddress) - } - - return l, nil -} - -func broadcastAddress(addr net.Addr) (net.Addr, error) { - switch addr := addr.(type) { - case *net.UDPAddr: - ip := addr.IP.To4() - if ip == nil { - return nil, fmt.Errorf("address %q is not an IPv4 address; broadcasting on non-IPv4 address is currently not supported", addr) - } - return &net.UDPAddr{ - IP: broadcastIP4(ip), - Port: addr.Port, - }, nil - case *net.TCPAddr: - ip := addr.IP.To4() - if ip == nil { - return nil, fmt.Errorf("address %q is not an IPv4 address; broadcasting on non-IPv4 address is currently not supported", addr) - } - return &net.TCPAddr{ - IP: broadcastIP4(ip), - Port: addr.Port, - }, nil - default: - return nil, fmt.Errorf("unsupported address type %T", addr) - } -} - -func broadcastIP4(ip net.IP) net.IP { - mask := ip.DefaultMask() - bcast := make(net.IP, len(ip)) - for i := 0; i < len(bcast); i++ { - bcast[i] = ip[i] | ^mask[i] - } - return bcast -} - -type Listener struct { - conn net.PacketConn - - conf ListenConfig - - pongData atomic.Pointer[[]byte] - - signals chan *nethernet.Signal - - addressesMu sync.RWMutex - addresses map[uint64]net.Addr - - responsesMu sync.RWMutex - responses map[uint64][]byte - - closed chan struct{} - once sync.Once -} - -func (l *Listener) ReadSignal(cancel <-chan struct{}) (*nethernet.Signal, error) { - select { - case <-cancel: - return nil, context.Canceled - case <-l.closed: - return nil, net.ErrClosed - case signal := <-l.signals: - return signal, nil - } -} - -func (l *Listener) WriteSignal(signal *nethernet.Signal) error { - select { - case <-l.closed: - return net.ErrClosed - default: - l.addressesMu.RLock() - addr, ok := l.addresses[signal.NetworkID] - l.addressesMu.RUnlock() - - if !ok { - return fmt.Errorf("no address found for network ID: %d", signal.NetworkID) - } - - _, err := l.write(Marshal(&MessagePacket{ - RecipientID: signal.NetworkID, - Data: signal.String(), - }, l.conf.NetworkID), addr) - return err - } -} - -func (l *Listener) Credentials() (*nethernet.Credentials, error) { - select { - case <-l.closed: - return nil, net.ErrClosed - default: - return nil, nil - } -} - -func (l *Listener) listen() { - for { - b := make([]byte, 1024) - n, addr, err := l.conn.ReadFrom(b) - if err != nil { - if !errors.Is(err, net.ErrClosed) { - l.conf.Log.Error("error reading from conn", internal.ErrAttr(err)) - } - close(l.closed) - return - } - if err := l.handlePacket(b[:n], addr); err != nil { - l.conf.Log.Error("error handling packet", internal.ErrAttr(err), "from", addr) - } - } -} - -func (l *Listener) handlePacket(data []byte, addr net.Addr) error { - pk, senderID, err := Unmarshal(data) - if err != nil { - return fmt.Errorf("decode: %w", err) - } - - if senderID == l.conf.NetworkID { - return nil - } - - l.addressesMu.Lock() - l.addresses[senderID] = addr - l.addressesMu.Unlock() - - switch pk := pk.(type) { - case *RequestPacket: - err = l.handleRequest(addr) - case *ResponsePacket: - err = l.handleResponse(pk, senderID) - case *MessagePacket: - err = l.handleMessage(pk, senderID, addr) - default: - err = fmt.Errorf("unknown packet: %T", pk) - } - - return err -} - -func (l *Listener) handleRequest(addr net.Addr) error { - data := l.pongData.Load() - if data == nil { - return errors.New("application data not set yet") - } - if _, err := l.write(Marshal(&ResponsePacket{ - ApplicationData: *data, - }, l.conf.NetworkID), addr); err != nil { - return fmt.Errorf("write response: %w", err) - } - return nil -} - -func (l *Listener) handleResponse(pk *ResponsePacket, senderID uint64) error { - l.responsesMu.Lock() - l.responses[senderID] = pk.ApplicationData - l.responsesMu.Unlock() - - return nil -} - -func (l *Listener) handleMessage(pk *MessagePacket, senderID uint64, addr net.Addr) error { - if pk.Data == "Ping" { - return nil - } - - signal := &nethernet.Signal{} - if err := signal.UnmarshalText([]byte(pk.Data)); err != nil { - return fmt.Errorf("decode signal: %w", err) - } - signal.NetworkID = senderID - l.signals <- signal - - return nil -} - -func (l *Listener) ServerData(d *ServerData) { - b, _ := d.MarshalBinary() - l.PongData(b) -} - -func (l *Listener) PongData(b []byte) { l.pongData.Store(&b) } - -func (l *Listener) Close() (err error) { - l.once.Do(func() { - err = l.conn.Close() - }) - return err -} - -func (l *Listener) broadcast(addr net.Addr) { - ticker := time.NewTicker(time.Second * 2) - defer ticker.Stop() - - request := Marshal(&RequestPacket{}, l.conf.NetworkID) - - for { - select { - case <-l.closed: - return - case <-ticker.C: - if _, err := l.conn.WriteTo(request, addr); err != nil { - if !errors.Is(err, net.ErrClosed) { - l.conf.Log.Error("error broadcasting request", internal.ErrAttr(err)) - } - return - } - } - } -} - -func (l *Listener) write(b []byte, addr net.Addr) (n int, err error) { - localIP, remoteIP := l.ip(addr), l.ip(l.conn.LocalAddr()) - if localIP != nil && remoteIP != nil && localIP.Equal(remoteIP) { - bcast, err := broadcastAddress(addr) - if err != nil { - l.conf.Log.Error("error resolving broadcast address", slog.Any("addr", addr), internal.ErrAttr(err)) - } else { - addr = bcast - } - } - return l.conn.WriteTo(b, addr) -} - -func (l *Listener) ip(addr net.Addr) net.IP { - switch addr := addr.(type) { - case *net.UDPAddr: - return addr.IP - case *net.TCPAddr: - return addr.IP - default: - return nil - } -} diff --git a/minecraft/nethernet/discovery/listener_test.go b/minecraft/nethernet/discovery/listener_test.go deleted file mode 100644 index 1b749396..00000000 --- a/minecraft/nethernet/discovery/listener_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package discovery - -import ( - "context" - "errors" - "github.com/sandertv/gophertunnel/minecraft/room" - "log/slog" - "os" - "testing" - "time" -) - -func TestListen(t *testing.T) { - cfg := ListenConfig{ - Log: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug, - })), - } - - l, err := cfg.Listen("udp", ":7551") - if err != nil { - t.Fatalf("error listening: %s", err) - } - t.Cleanup(func() { - if err := l.Close(); err != nil { - t.Fatalf("error closing: %s", err) - } - }) - - _ = l.Announce(room.Status{ - HostName: "Da1z981?", - WorldName: "LAN のデバッグ", - WorldType: room.WorldTypeCreative, - MemberCount: 1, - MaxMemberCount: 30, - IsEditorWorld: false, - TransportLayer: 2, - }) - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - - for { - signal, err := l.ReadSignal(ctx.Done()) - if err != nil { - if !errors.Is(err, context.Canceled) { - t.Fatalf("error reading signal: %s", err) - } - return - } - t.Logf("%#v", signal) - } -} diff --git a/minecraft/nethernet/discovery/packet.go b/minecraft/nethernet/discovery/packet.go deleted file mode 100644 index 54745ccc..00000000 --- a/minecraft/nethernet/discovery/packet.go +++ /dev/null @@ -1,134 +0,0 @@ -package discovery - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/binary" - "fmt" - "io" -) - -type Packet interface { - ID() uint16 - - Read(r io.Reader) error - Write(w io.Writer) -} - -func Marshal(pk Packet, senderID uint64) []byte { - buf := &bytes.Buffer{} - - h := &Header{ - PacketID: pk.ID(), - SenderID: senderID, - } - h.Write(buf) - - pk.Write(buf) - - payload := append( - binary.LittleEndian.AppendUint16(nil, uint16(buf.Len())), - buf.Bytes()..., - ) - b := encrypt(payload) - - hash := hmac.New(sha256.New, key[:]) - hash.Write(payload) - b = append(hash.Sum(nil), b...) - return b -} - -func Unmarshal(b []byte) (Packet, uint64, error) { - if len(b) < 32 { - return nil, 0, io.ErrUnexpectedEOF - } - payload, err := decrypt(b[32:]) - if err != nil { - return nil, 0, fmt.Errorf("decrypt: %w", err) - } - - hash := hmac.New(sha256.New, key[:]) - hash.Write(payload) - if checksum := hash.Sum(nil); bytes.Compare(b[:32], checksum) != 0 { - return nil, 0, fmt.Errorf("checksum mismatch: %x != %x", b[:32], checksum) - } - buf := bytes.NewBuffer(payload) - - var length uint16 - if err := binary.Read(buf, binary.LittleEndian, &length); err != nil { - return nil, 0, fmt.Errorf("read length: %w", err) - } - h := &Header{} - if err := h.Read(buf); err != nil { - return nil, 0, fmt.Errorf("read header: %w", err) - } - - var pk Packet - switch h.PacketID { - case IDRequestPacket: - pk = &RequestPacket{} - case IDResponsePacket: - pk = &ResponsePacket{} - case IDMessagePacket: - pk = &MessagePacket{} - default: - return nil, h.SenderID, fmt.Errorf("unknown packet ID: %d", h.PacketID) - } - if err := pk.Read(buf); err != nil { - return nil, h.SenderID, err - } - return pk, h.SenderID, nil -} - -func readBytes[L ~uint32 | ~uint8](r io.Reader) ([]byte, error) { - var length L - if err := binary.Read(r, binary.LittleEndian, &length); err != nil { - return nil, fmt.Errorf("read length: %w", err) - } - b := make([]byte, length) - if n, err := r.Read(b); err != nil { - return nil, err - } else if n != int(length) { - return nil, fmt.Errorf("invalid length: %d, expected %d", n, length) - } - return b, nil -} - -func writeBytes[L ~uint32 | ~uint8](w io.Writer, b []byte) { - _ = binary.Write(w, binary.LittleEndian, (L)(len(b))) - _, _ = w.Write(b) -} - -const ( - IDRequestPacket uint16 = iota - IDResponsePacket - IDMessagePacket -) - -type Header struct { - PacketID uint16 - SenderID uint64 -} - -func (h *Header) Read(r io.Reader) error { - if err := binary.Read(r, binary.LittleEndian, &h.PacketID); err != nil { - return fmt.Errorf("read packet ID: %w", err) - } - if err := binary.Read(r, binary.LittleEndian, &h.SenderID); err != nil { - return fmt.Errorf("read sender ID: %w", err) - } - if n, err := r.Read(make([]byte, 8)); err != nil { - return fmt.Errorf("discard padding: %w", err) - } else if n != 8 { - return fmt.Errorf("%d != 8", n) - } - - return nil -} - -func (h *Header) Write(w io.Writer) { - _ = binary.Write(w, binary.LittleEndian, h.PacketID) - _ = binary.Write(w, binary.LittleEndian, h.SenderID) - _, _ = w.Write(make([]byte, 8)) -} diff --git a/minecraft/nethernet/discovery/packet_message.go b/minecraft/nethernet/discovery/packet_message.go deleted file mode 100644 index 817d4ff1..00000000 --- a/minecraft/nethernet/discovery/packet_message.go +++ /dev/null @@ -1,31 +0,0 @@ -package discovery - -import ( - "encoding/binary" - "fmt" - "io" -) - -type MessagePacket struct { - RecipientID uint64 - Data string -} - -func (*MessagePacket) ID() uint16 { return IDMessagePacket } - -func (pk *MessagePacket) Read(r io.Reader) error { - if err := binary.Read(r, binary.LittleEndian, &pk.RecipientID); err != nil { - return fmt.Errorf("read recipient ID: %w", err) - } - data, err := readBytes[uint32](r) - if err != nil { - return fmt.Errorf("read data: %w", err) - } - pk.Data = string(data) - return nil -} - -func (pk *MessagePacket) Write(w io.Writer) { - _ = binary.Write(w, binary.LittleEndian, pk.RecipientID) - writeBytes[uint32](w, []byte(pk.Data)) -} diff --git a/minecraft/nethernet/discovery/packet_request.go b/minecraft/nethernet/discovery/packet_request.go deleted file mode 100644 index 8fe21a98..00000000 --- a/minecraft/nethernet/discovery/packet_request.go +++ /dev/null @@ -1,11 +0,0 @@ -package discovery - -import "io" - -type RequestPacket struct{} - -func (*RequestPacket) ID() uint16 { return IDRequestPacket } - -func (*RequestPacket) Read(io.Reader) error { return nil } - -func (*RequestPacket) Write(io.Writer) {} diff --git a/minecraft/nethernet/discovery/packet_response.go b/minecraft/nethernet/discovery/packet_response.go deleted file mode 100644 index 967b55c7..00000000 --- a/minecraft/nethernet/discovery/packet_response.go +++ /dev/null @@ -1,32 +0,0 @@ -package discovery - -import ( - "encoding/hex" - "fmt" - "io" -) - -type ResponsePacket struct { - ApplicationData []byte -} - -func (*ResponsePacket) ID() uint16 { return IDResponsePacket } - -func (pk *ResponsePacket) Read(r io.Reader) error { - data, err := readBytes[uint32](r) - if err != nil { - return fmt.Errorf("read application data: %w", err) - } - n, err := hex.Decode(data, data) - if err != nil { - return fmt.Errorf("decode application data: %w", err) - } - pk.ApplicationData = data[:n] - return nil -} - -func (pk *ResponsePacket) Write(w io.Writer) { - data := make([]byte, hex.EncodedLen(len(pk.ApplicationData))) - hex.Encode(data, pk.ApplicationData) - writeBytes[uint32](w, data) -} diff --git a/minecraft/nethernet/discovery/room.go b/minecraft/nethernet/discovery/room.go deleted file mode 100644 index 56cf0c25..00000000 --- a/minecraft/nethernet/discovery/room.go +++ /dev/null @@ -1,53 +0,0 @@ -package discovery - -import ( - "github.com/sandertv/gophertunnel/minecraft/room" -) - -func (l *Listener) Announce(status room.Status) error { - l.ServerData(statusToServerData(status)) - return nil -} - -func statusToServerData(status room.Status) *ServerData { - return &ServerData{ - Version: 0x2, - ServerName: status.HostName, - LevelName: status.WorldName, - GameType: worldTypeToGameType(status.WorldType), - PlayerCount: int32(status.MemberCount), - MaxPlayerCount: int32(status.MaxMemberCount), - IsEditorWorld: status.IsEditorWorld, - TransportLayer: status.TransportLayer, - } -} - -func serverDataToStatus(d *ServerData) room.Status { - return room.Status{ - HostName: d.ServerName, - WorldName: d.LevelName, - WorldType: gameTypeToWorldType(d.GameType), - MemberCount: uint32(d.PlayerCount), - MaxMemberCount: uint32(d.MaxPlayerCount), - IsEditorWorld: d.IsEditorWorld, - TransportLayer: d.TransportLayer, - } -} - -func gameTypeToWorldType(typ int32) string { - switch typ { - case 2: - return room.WorldTypeCreative - default: - return room.WorldTypeCreative - } -} - -func worldTypeToGameType(typ string) int32 { - switch typ { - case room.WorldTypeCreative: - return 2 - default: - return 2 - } -} diff --git a/minecraft/nethernet/discovery/server_data.go b/minecraft/nethernet/discovery/server_data.go deleted file mode 100644 index e7eff42a..00000000 --- a/minecraft/nethernet/discovery/server_data.go +++ /dev/null @@ -1,68 +0,0 @@ -package discovery - -import ( - "bytes" - "encoding/binary" - "fmt" -) - -type ServerData struct { - Version uint8 - ServerName string - LevelName string - GameType int32 - PlayerCount int32 - MaxPlayerCount int32 - IsEditorWorld bool - TransportLayer int32 -} - -func (d *ServerData) MarshalBinary() ([]byte, error) { - buf := &bytes.Buffer{} - - _ = binary.Write(buf, binary.LittleEndian, d.Version) - writeBytes[uint8](buf, []byte(d.ServerName)) - writeBytes[uint8](buf, []byte(d.LevelName)) - _ = binary.Write(buf, binary.LittleEndian, d.GameType) - _ = binary.Write(buf, binary.LittleEndian, d.PlayerCount) - _ = binary.Write(buf, binary.LittleEndian, d.MaxPlayerCount) - _ = binary.Write(buf, binary.LittleEndian, d.IsEditorWorld) - _ = binary.Write(buf, binary.LittleEndian, d.TransportLayer) - - return buf.Bytes(), nil -} - -func (d *ServerData) UnmarshalBinary(data []byte) error { - buf := bytes.NewBuffer(data) - - if err := binary.Read(buf, binary.LittleEndian, &d.Version); err != nil { - return fmt.Errorf("read version: %w", err) - } - serverName, err := readBytes[uint8](buf) - if err != nil { - return fmt.Errorf("read server name: %w", err) - } - d.ServerName = string(serverName) - levelName, err := readBytes[uint8](buf) - if err != nil { - return fmt.Errorf("read level name: %w", err) - } - d.LevelName = string(levelName) - if err := binary.Read(buf, binary.LittleEndian, &d.GameType); err != nil { - return fmt.Errorf("read game type: %w", err) - } - if err := binary.Read(buf, binary.LittleEndian, &d.PlayerCount); err != nil { - return fmt.Errorf("read player count: %w", err) - } - if err := binary.Read(buf, binary.LittleEndian, &d.MaxPlayerCount); err != nil { - return fmt.Errorf("read max player count: %w", err) - } - if err := binary.Read(buf, binary.LittleEndian, &d.IsEditorWorld); err != nil { - return fmt.Errorf("read editor world: %w", err) - } - if err := binary.Read(buf, binary.LittleEndian, &d.TransportLayer); err != nil { - return fmt.Errorf("read transport layer: %w", err) - } - - return nil -} diff --git a/minecraft/nethernet/internal/test/.gitignore b/minecraft/nethernet/internal/test/.gitignore deleted file mode 100644 index 38d7cba3..00000000 --- a/minecraft/nethernet/internal/test/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/auth.tok \ No newline at end of file diff --git a/minecraft/nethernet/internal/test/token_source.go b/minecraft/nethernet/internal/test/token_source.go deleted file mode 100644 index f15943aa..00000000 --- a/minecraft/nethernet/internal/test/token_source.go +++ /dev/null @@ -1,54 +0,0 @@ -package test - -import ( - "encoding/json" - "fmt" - "golang.org/x/oauth2" - "os" - "testing" -) - -func TokenSource(t *testing.T, path string, src oauth2.TokenSource, hooks ...RefreshTokenFunc) *oauth2.Token { - tok, err := readTokenSource(path, src) - if err != nil { - t.Fatalf("error reading token: %s", err) - } - for _, h := range hooks { - tok, err = h(tok) - if err != nil { - t.Fatalf("error refreshing token: %s", err) - } - } - return tok -} - -type RefreshTokenFunc func(old *oauth2.Token) (new *oauth2.Token, err error) - -func readTokenSource(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/nethernet/listener.go b/minecraft/nethernet/listener.go deleted file mode 100644 index 10cca081..00000000 --- a/minecraft/nethernet/listener.go +++ /dev/null @@ -1,360 +0,0 @@ -package nethernet - -import ( - "errors" - "fmt" - "github.com/pion/sdp/v3" - "github.com/pion/webrtc/v4" - "github.com/sandertv/gophertunnel/minecraft/nethernet/internal" - "log/slog" - "net" - "strconv" - "strings" - "sync" -) - -type ListenConfig struct { - Log *slog.Logger - API *webrtc.API -} - -func (conf ListenConfig) Listen(networkID uint64, signaling Signaling) (*Listener, error) { - if conf.Log == nil { - conf.Log = slog.Default() - } - if conf.API == nil { - conf.API = webrtc.NewAPI() - } - l := &Listener{ - conf: conf, - signaling: signaling, - networkID: networkID, - - incoming: make(chan *Conn), - - closed: make(chan struct{}), - } - go l.listen() - return l, nil -} - -type Listener struct { - conf ListenConfig - - signaling Signaling - networkID uint64 - - connections sync.Map - - incoming chan *Conn - - closed chan struct{} - once sync.Once -} - -func (l *Listener) Accept() (net.Conn, error) { - select { - case <-l.closed: - return nil, net.ErrClosed - case conn := <-l.incoming: - return conn, nil - } -} - -func (l *Listener) Addr() net.Addr { - return &Addr{NetworkID: l.networkID} -} - -type Addr struct { - ConnectionID uint64 - NetworkID uint64 - Candidates []webrtc.ICECandidate -} - -func (addr *Addr) String() string { - b := &strings.Builder{} - b.WriteString(strconv.FormatUint(addr.NetworkID, 10)) - b.WriteByte(' ') - if addr.ConnectionID != 0 { - b.WriteByte('(') - b.WriteString(strconv.FormatUint(addr.ConnectionID, 10)) - b.WriteByte(')') - } - return b.String() -} - -func (addr *Addr) Network() string { return "nethernet" } - -// ID returns the network ID of listener. -func (l *Listener) ID() int64 { return int64(l.networkID) } - -// PongData is a stub. -func (l *Listener) PongData([]byte) {} - -func (l *Listener) listen() { - for { - signal, err := l.signaling.ReadSignal(l.closed) - if err != nil { - if !errors.Is(err, net.ErrClosed) { - l.conf.Log.Error("error reading signal", internal.ErrAttr(err)) - } - _ = l.Close() - return - } - - // Um... It seems the game has a bug that doesn't even send an offer if joining worlds too many times. - // This is not a bug of this code because you may not join any worlds if the bug has occurred. - // Once the bug has occurred, you need to restart the game. - l.conf.Log.Debug(signal.String()) - switch signal.Type { - case SignalTypeOffer: - err = l.handleOffer(signal) - case SignalTypeCandidate: - err = l.handleCandidate(signal) - case SignalTypeError: - err = l.handleError(signal) - default: - l.conf.Log.Debug("received signal for unknown type", "signal", signal) - } - if err != nil { - var s *signalError - if errors.As(err, &s) { - // Additionally, we write a Signal back with SignalTypeError using the code wrapped on it. - if err := l.signaling.WriteSignal(&Signal{ - Type: SignalTypeError, - ConnectionID: signal.ConnectionID, - Data: strconv.FormatUint(uint64(s.code), 10), - NetworkID: signal.NetworkID, - }); err != nil { - l.conf.Log.Error("error signaling error", internal.ErrAttr(err)) - } - } - l.conf.Log.Error("error handling signal", "signal", signal, internal.ErrAttr(err)) - } - } -} - -// handleOffer handles an incoming Signal of SignalTypeOffer. An answer will be -// encoded and the listener will prepare a connection for handling the signals incoming that has the same ID. -func (l *Listener) handleOffer(signal *Signal) error { - d := &sdp.SessionDescription{} - if err := d.UnmarshalString(signal.Data); err != nil { - return wrapSignalError(fmt.Errorf("decode offer: %w", err), ErrorCodeFailedToSetRemoteDescription) - } - desc, err := parseDescription(d) - if err != nil { - return wrapSignalError(fmt.Errorf("parse offer: %w", err), ErrorCodeFailedToSetRemoteDescription) - } - - credentials, err := l.signaling.Credentials() - if err != nil { - return wrapSignalError(fmt.Errorf("obtain credentials: %w", err), ErrorCodeSignalingTurnAuthFailed) - } - - var gatherOptions webrtc.ICEGatherOptions - if credentials != nil && len(credentials.ICEServers) > 0 { - gatherOptions.ICEServers = make([]webrtc.ICEServer, len(credentials.ICEServers)) - for i, server := range credentials.ICEServers { - gatherOptions.ICEServers[i] = webrtc.ICEServer{ - Username: server.Username, - Credential: server.Password, - CredentialType: webrtc.ICECredentialTypePassword, - URLs: server.URLs, - } - } - } - - gatherer, err := l.conf.API.NewICEGatherer(gatherOptions) - if err != nil { - return wrapSignalError(fmt.Errorf("create ICE gatherer: %w", err), ErrorCodeFailedToCreatePeerConnection) - } - - var ( - // Local candidates gathered by webrtc.ICEGatherer - candidates []webrtc.ICECandidate - // Notifies that gathering for local candidates has finished. - gatherFinished = make(chan struct{}) - ) - gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) { - if candidate == nil { - close(gatherFinished) - return - } - candidates = append(candidates, *candidate) - }) - if err := gatherer.Gather(); err != nil { - return wrapSignalError(fmt.Errorf("gather local candidates: %w", err), ErrorCodeFailedToCreatePeerConnection) - } - - select { - case <-l.closed: - return nil - case <-gatherFinished: - ice := l.conf.API.NewICETransport(gatherer) - dtls, err := l.conf.API.NewDTLSTransport(ice, nil) - if err != nil { - return wrapSignalError(fmt.Errorf("create DTLS transport: %w", err), ErrorCodeFailedToCreatePeerConnection) - } - sctp := l.conf.API.NewSCTPTransport(dtls) - - iceParams, err := ice.GetLocalParameters() - if err != nil { - return wrapSignalError(fmt.Errorf("obtain local ICE parameters: %w", err), ErrorCodeFailedToCreateAnswer) - } - dtlsParams, err := dtls.GetLocalParameters() - if err != nil { - return wrapSignalError(fmt.Errorf("obtain local DTLS parameters: %w", err), ErrorCodeFailedToCreateAnswer) - } - if len(dtlsParams.Fingerprints) == 0 { - return wrapSignalError(errors.New("local DTLS parameters has no fingerprints"), ErrorCodeFailedToCreateAnswer) - } - sctpCapabilities := sctp.GetCapabilities() - - // Encode an answer using the local parameters! - answer, err := description{ - ice: iceParams, - dtls: dtlsParams, - sctp: sctpCapabilities, - }.encode() - if err != nil { - return wrapSignalError(fmt.Errorf("encode answer: %w", err), ErrorCodeFailedToCreateAnswer) - } - - if err := l.signaling.WriteSignal(&Signal{ - Type: SignalTypeAnswer, - ConnectionID: signal.ConnectionID, - Data: string(answer), - NetworkID: signal.NetworkID, - }); err != nil { - // I don't think the error code will be signaled back to the remote connection, but just in case. - return wrapSignalError(fmt.Errorf("signal answer: %w", err), ErrorCodeSignalingFailedToSend) - } - for i, candidate := range candidates { - if err := l.signaling.WriteSignal(&Signal{ - Type: SignalTypeCandidate, - ConnectionID: signal.ConnectionID, - Data: formatICECandidate(i, candidate, iceParams), - NetworkID: signal.NetworkID, - }); err != nil { - // I don't think the error code will be signaled back to the remote connection, but just in case. - return wrapSignalError(fmt.Errorf("signal candidate: %w", err), ErrorCodeSignalingFailedToSend) - } - } - - c := newConn(ice, dtls, sctp, desc, l.conf.Log, signal.ConnectionID, signal.NetworkID, l.networkID, candidates, l) - - l.connections.Store(signal.ConnectionID, c) - go l.handleConn(c) - - return nil - } -} - -func (l *Listener) handleClose(conn *Conn) { - l.connections.Delete(conn.id) -} - -func (l *Listener) handleConn(conn *Conn) { - select { - case <-l.closed: - // Quit the goroutine when the listener closes. - return - case <-conn.candidateReceived: - conn.log.Debug("received first candidate") - if err := l.startTransports(conn); err != nil { - if !errors.Is(err, net.ErrClosed) { - conn.log.Error("error starting transports", internal.ErrAttr(err)) - } - return - } - conn.handleTransports() - l.incoming <- conn - } -} - -func (l *Listener) startTransports(conn *Conn) error { - conn.log.Debug("starting ICE transport as controlled") - iceRole := webrtc.ICERoleControlled - if err := conn.ice.Start(nil, conn.remote.ice, &iceRole); err != nil { - return fmt.Errorf("start ICE: %w", err) - } - - conn.log.Debug("starting DTLS transport as server") - dtlsParams := conn.remote.dtls - dtlsParams.Role = webrtc.DTLSRoleServer - if err := conn.dtls.Start(dtlsParams); err != nil { - return fmt.Errorf("start DTLS: %w", err) - } - - conn.log.Debug("starting SCTP transport") - var ( - once = new(sync.Once) - bothOpen = make(chan struct{}, 1) - ) - conn.sctp.OnDataChannelOpened(func(channel *webrtc.DataChannel) { - switch channel.Label() { - case "ReliableDataChannel": - conn.reliable = channel - case "UnreliableDataChannel": - conn.unreliable = channel - } - if conn.reliable != nil && conn.unreliable != nil { - once.Do(func() { - close(bothOpen) - }) - } - }) - if err := conn.sctp.Start(conn.remote.sctp); err != nil { - return fmt.Errorf("start SCTP: %w", err) - } - - select { - case <-l.closed: - return net.ErrClosed - case <-bothOpen: - return nil - } -} - -// handleCandidate handles an incoming Signal of SignalTypeCandidate. It looks up for a connection that has the same ID, and -// call the [Conn.handleSignal] method, which adds a remote candidate into its ICE transport. -func (l *Listener) handleCandidate(signal *Signal) error { - conn, ok := l.connections.Load(signal.ConnectionID) - if !ok { - return fmt.Errorf("no connection found for ID %d", signal.ConnectionID) - } - return conn.(*Conn).handleSignal(signal) -} - -// handleError handles an incoming Signal of SignalTypeError. It looks up for a connection that has the same ID, and -// call the [Conn.handleSignal] method, which parses the data into error code and closes the connection as failed. -func (l *Listener) handleError(signal *Signal) error { - conn, ok := l.connections.Load(signal.ConnectionID) - if !ok { - return fmt.Errorf("no connection found for ID %d", signal.ConnectionID) - } - return conn.(*Conn).handleSignal(signal) -} - -func (l *Listener) Close() error { - l.once.Do(func() { - close(l.closed) - close(l.incoming) - }) - return nil -} - -type signalError struct { - code uint32 - underlying error -} - -func (e *signalError) Error() string { - return fmt.Sprintf("minecraft/nethernet: %s [signaling with code %d]", e.underlying, e.code) -} - -func (e *signalError) Unwrap() error { return e.underlying } - -func wrapSignalError(err error, code uint32) *signalError { - return &signalError{code: code, underlying: err} -} diff --git a/minecraft/nethernet/message.go b/minecraft/nethernet/message.go deleted file mode 100644 index bc348920..00000000 --- a/minecraft/nethernet/message.go +++ /dev/null @@ -1,45 +0,0 @@ -package nethernet - -import ( - "fmt" - "io" -) - -// message represents the structure of remote messages sent in ReliableDataChannel. -type message struct { - segments uint8 - data []byte -} - -func parseMessage(b []byte) (*message, error) { - if len(b) < 2 { - return nil, io.ErrUnexpectedEOF - } - return &message{ - segments: b[0], - data: b[1:], - }, nil -} - -func (c *Conn) handleMessage(b []byte) error { - msg, err := parseMessage(b) - if err != nil { - return fmt.Errorf("parse: %w", err) - } - - if c.message.segments > 0 && c.message.segments-1 != msg.segments { - return fmt.Errorf("invalid promised segments: expected %d, got %d", c.message.segments-1, msg.segments) - } - c.message.segments = msg.segments - - c.message.data = append(c.message.data, msg.data...) - - if c.message.segments > 0 { - return nil - } - - c.packets <- c.message.data - c.message.data = nil - - return nil -} diff --git a/minecraft/nethernet/signal.go b/minecraft/nethernet/signal.go deleted file mode 100644 index 550f03f6..00000000 --- a/minecraft/nethernet/signal.go +++ /dev/null @@ -1,161 +0,0 @@ -package nethernet - -import ( - "bytes" - "errors" - "fmt" - "github.com/pion/webrtc/v4" - "strconv" - "strings" -) - -var ErrSignalingCanceled = errors.New("minecraft/nethernet: canceled") - -type Signaling interface { - ReadSignal(cancel <-chan struct{}) (*Signal, error) - WriteSignal(signal *Signal) error - - // Credentials will currently block until a credentials has received from the signaling service. This is usually - // present in WebSocket signaling connection. A nil *Credentials may be returned if no credentials or - // the implementation is not capable to do that. - Credentials() (*Credentials, error) -} - -const ( - // SignalTypeOffer is sent by client to request a connection to the remote host. Signals that have - // SignalTypeOffer usually has a data of local description of its connection. - SignalTypeOffer = "CONNECTREQUEST" - // SignalTypeAnswer is sent by server to respond to Signals that have SignalTypeOffer. Signals that - // have SignalTypeAnswer usually has a data of local description of the host. - SignalTypeAnswer = "CONNECTRESPONSE" - // SignalTypeCandidate is sent by both server and client to notify a local candidate to - // remote connection. This is usually sent after SignalTypeOffer or SignalTypeAnswer by server/client. - // Signals that have SignalTypeCandidate usually has a data of local candidate gathered with additional - // credentials received from the Signaling implementation. - SignalTypeCandidate = "CANDIDATEADD" - // SignalTypeError is sent by both server and client to notify an error has occurred. - // Signals that have SignalTypeError has a Data of the code of error occurred, which is listed - // on the following constants. - SignalTypeError = "CONNECTERROR" -) - -type Signal struct { - // Type is the type of Signal. It is one of the constants defined above. - Type string - // ConnectionID is the unique ID of the connection that has sent the Signal. - // It is encoded in String as a second segment to identify a connection uniquely. - ConnectionID uint64 - // Data is the actual data of the Signal. - Data string - - // NetworkID is used internally by the implementations of Signaling type - // to reference a remote network with a number. - NetworkID uint64 -} - -func (s *Signal) MarshalText() ([]byte, error) { - return []byte(s.String()), nil -} - -func (s *Signal) UnmarshalText(b []byte) (err error) { - segments := bytes.SplitN(b, []byte{' '}, 3) - if len(segments) != 3 { - return fmt.Errorf("unexpected segmentations: %d", len(segments)) - } - s.Type = string(segments[0]) - s.ConnectionID, err = strconv.ParseUint(string(segments[1]), 10, 64) - if err != nil { - return fmt.Errorf("parse ConnectionID: %w", err) - } - s.Data = string(segments[2]) - return nil -} - -func (s *Signal) String() string { - b := &strings.Builder{} - b.WriteString(s.Type) - b.WriteByte(' ') - b.WriteString(strconv.FormatUint(s.ConnectionID, 10)) - b.WriteByte(' ') - b.WriteString(s.Data) - return b.String() -} - -func formatICECandidate(id int, candidate webrtc.ICECandidate, iceParams webrtc.ICEParameters) string { - b := &strings.Builder{} - b.WriteString("candidate:") - b.WriteString(candidate.Foundation) - b.WriteByte(' ') - b.WriteByte('1') - b.WriteByte(' ') - b.WriteString("udp") - b.WriteByte(' ') - b.WriteString(strconv.FormatUint(uint64(candidate.Priority), 10)) - b.WriteByte(' ') - b.WriteString(candidate.Address) - b.WriteByte(' ') - b.WriteString(strconv.FormatUint(uint64(candidate.Port), 10)) - b.WriteByte(' ') - b.WriteString("typ") - b.WriteByte(' ') - b.WriteString(candidate.Typ.String()) - b.WriteByte(' ') - if candidate.Typ == webrtc.ICECandidateTypeRelay || candidate.Typ == webrtc.ICECandidateTypeSrflx { - b.WriteString("raddr") - b.WriteByte(' ') - b.WriteString(candidate.RelatedAddress) - b.WriteByte(' ') - b.WriteString("rport") - b.WriteByte(' ') - b.WriteString(strconv.FormatUint(uint64(candidate.RelatedPort), 10)) - b.WriteByte(' ') - } - b.WriteString("generation") - b.WriteByte(' ') - b.WriteByte('0') - b.WriteByte(' ') - b.WriteString("ufrag") - b.WriteByte(' ') - b.WriteString(iceParams.UsernameFragment) - b.WriteByte(' ') - b.WriteString("network-id") - b.WriteByte(' ') - b.WriteString(strconv.Itoa(id)) - b.WriteByte(' ') - b.WriteString("network-cost") - b.WriteByte(' ') - b.WriteByte('0') - return b.String() -} - -// These constants are sent as a data of Signal with SignalTypeError, to notify an error to the remote connection. -// TODO: These codes has been extracted from dedicated server (v1.21.2). We need to properly write a documentation for these constants. -const ( - ErrorCodeNone = iota - ErrorCodeDestinationNotLoggedIn - ErrorCodeNegotiationTimeout - ErrorCodeWrongTransportVersion - ErrorCodeFailedToCreatePeerConnection - ErrorCodeICE - ErrorCodeConnectRequest - ErrorCodeConnectResponse - ErrorCodeCandidateAdd - ErrorCodeInactivityTimeout - ErrorCodeFailedToCreateOffer - ErrorCodeFailedToCreateAnswer - ErrorCodeFailedToSetLocalDescription - ErrorCodeFailedToSetRemoteDescription - ErrorCodeNegotiationTimeoutWaitingForResponse - ErrorCodeNegotiationTimeoutWaitingForAccept - ErrorCodeIncomingConnectionIgnored - ErrorCodeSignalingParsingFailure - ErrorCodeSignalingUnknownError - ErrorCodeSignalingUnicastMessageDeliveryFailed - ErrorCodeSignalingBroadcastDeliveryFailed - ErrorCodeSignalingMessageDeliveryFailed - ErrorCodeSignalingTurnAuthFailed - ErrorCodeSignalingFallbackToBestEffortDelivery - ErrorCodeNoSignalingChannel - ErrorCodeNotLoggedIn - ErrorCodeSignalingFailedToSend -) diff --git a/minecraft/room/_dial.go b/minecraft/room/_dial.go new file mode 100644 index 00000000..0a4b9c74 --- /dev/null +++ b/minecraft/room/_dial.go @@ -0,0 +1,81 @@ +package room + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "sync" + "time" +) + +type Dialer struct { + Log *slog.Logger +} + +func (d Dialer) DialContext(ctx context.Context, a ConnAnnouncer, n net.Conn, ref Reference) (*Conn, error) { + if err := a.Join(ctx, ref); err != nil { + return nil, fmt.Errorf("join: %w", err) + } + + return &Conn{ + d: d, + + announcer: a, + conn: n, + + closed: make(chan struct{}), + }, nil +} + +type Conn struct { + d Dialer + + announcer ConnAnnouncer + conn net.Conn + + closed chan struct{} + once sync.Once +} + +func (c *Conn) Read(b []byte) (int, error) { + return c.conn.Read(b) +} + +func (c *Conn) Write(b []byte) (int, error) { + return c.conn.Write(b) +} + +func (c *Conn) Close() (err error) { + c.once.Do(func() { + close(c.closed) + + errs := []error{c.conn.Close()} + if err := c.announcer.Close(); err != nil { + errs = append(errs, fmt.Errorf("close announcer: %w", err)) + } + err = errors.Join(errs...) + }) + return err +} + +func (c *Conn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +func (c *Conn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +func (c *Conn) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *Conn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +func (c *Conn) SetWriteDeadline(t time.Time) error { + return c.conn.SetWriteDeadline(t) +} diff --git a/minecraft/room/announce.go b/minecraft/room/announce.go index e647e4c3..e75033c5 100644 --- a/minecraft/room/announce.go +++ b/minecraft/room/announce.go @@ -1,5 +1,12 @@ package room +import "context" + +type Reference interface { + String() string +} + type Announcer interface { - Announce(status Status) error + Announce(ctx context.Context, status Status) error + Close() error } diff --git a/minecraft/room/discovery.go b/minecraft/room/discovery.go new file mode 100644 index 00000000..039c507a --- /dev/null +++ b/minecraft/room/discovery.go @@ -0,0 +1,39 @@ +package room + +import ( + "context" + "github.com/df-mc/go-nethernet/discovery" +) + +type DiscoveryAnnouncer struct { + Listener *discovery.Listener +} + +func (a DiscoveryAnnouncer) Announce(_ context.Context, status Status) { + a.Listener.ServerData(statusToServerData(status)) +} + +func (a DiscoveryAnnouncer) Close() error { + return a.Listener.Close() +} + +func statusToServerData(status Status) *discovery.ServerData { + return &discovery.ServerData{ + Version: 0x2, + ServerName: status.HostName, + LevelName: status.WorldName, + GameType: worldTypeToGameType(status.WorldType), + PlayerCount: int32(status.MemberCount), + MaxPlayerCount: int32(status.MaxMemberCount), + TransportLayer: status.TransportLayer, + } +} + +func worldTypeToGameType(typ string) int32 { + switch typ { + case WorldTypeCreative: + return 2 + default: + return 2 + } +} diff --git a/minecraft/nethernet/internal/attr.go b/minecraft/room/internal/attr.go similarity index 100% rename from minecraft/nethernet/internal/attr.go rename to minecraft/room/internal/attr.go diff --git a/minecraft/room/listener.go b/minecraft/room/listener.go new file mode 100644 index 00000000..cb59993e --- /dev/null +++ b/minecraft/room/listener.go @@ -0,0 +1,116 @@ +package room + +import ( + "context" + "errors" + "fmt" + "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/room/internal" + "log/slog" + "net" + "sync" +) + +type ListenConfig struct { + StatusProvider StatusProvider + Log *slog.Logger +} + +func (conf ListenConfig) Listen(a Announcer, n minecraft.NetworkListener) (*Listener, error) { + if conf.StatusProvider == nil { + conf.StatusProvider = NewStatusProvider(DefaultStatus()) + } + if conf.Log == nil { + conf.Log = slog.Default() + } + + l := &Listener{ + conf: conf, + + announcer: a, + listener: n, + + closed: make(chan struct{}), + } + + return l, nil +} + +type Listener struct { + conf ListenConfig + + announcer Announcer + listener minecraft.NetworkListener + + closed chan struct{} + once sync.Once +} + +func (l *Listener) ID() int64 { + return l.listener.ID() +} + +func (l *Listener) PongData(data []byte) { + l.listener.PongData(data) +} + +func (l *Listener) Accept() (net.Conn, error) { + return l.listener.Accept() +} + +func (l *Listener) Addr() net.Addr { + return l.listener.Addr() +} + +func (l *Listener) ServerStatus(serverStatus minecraft.ServerStatus) { + status := l.conf.StatusProvider.RoomStatus() + + status.HostName = serverStatus.ServerSubName + status.WorldName = serverStatus.ServerName + + status.MemberCount = serverStatus.PlayerCount + status.MaxMemberCount = serverStatus.MaxPlayers + + // TODO + status.SupportedConnections = []Connection{ + { + ConnectionType: ConnectionTypeWebSocketsWebRTCSignaling, // ... + NetherNetID: uint64(l.listener.ID()), + WebRTCNetworkID: uint64(l.listener.ID()), + }, + } + status.WebRTCNetworkID = uint64(l.listener.ID()) + + go l.announce(status) + return +} + +func (l *Listener) announce(status Status) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-l.closed: + cancel() + } + }() + + if err := l.announcer.Announce(ctx, status); err != nil { + l.conf.Log.Error("error announcing status", internal.ErrAttr(err)) + } +} + +func (l *Listener) Close() (err error) { + l.once.Do(func() { + close(l.closed) + + fmt.Println("close called") + + errs := []error{l.listener.Close()} + if err := l.announcer.Close(); err != nil { + errs = append(errs, fmt.Errorf("close announcer: %w", err)) + } + err = errors.Join(errs...) + }) + return err +} diff --git a/minecraft/room/listener_test.go b/minecraft/room/listener_test.go new file mode 100644 index 00000000..7aaf02a0 --- /dev/null +++ b/minecraft/room/listener_test.go @@ -0,0 +1,187 @@ +package room + +import ( + "context" + "encoding/json" + "fmt" + "github.com/df-mc/go-playfab" + "github.com/go-gl/mathgl/mgl32" + "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/auth" + "github.com/sandertv/gophertunnel/minecraft/auth/xal" + "github.com/sandertv/gophertunnel/minecraft/franchise" + "github.com/sandertv/gophertunnel/minecraft/franchise/signaling" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "golang.org/x/oauth2" + "math/rand" + "os" + "strconv" + "testing" + "time" +) + +func TestListen(t *testing.T) { + discovery, err := franchise.Discover(protocol.CurrentVersion) + if err != nil { + t.Fatalf("error retrieving discovery: %s", err) + } + a := new(franchise.AuthorizationEnvironment) + if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("error reading environment for authorization: %s", err) + } + s := new(signaling.Environment) + if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { + t.Fatalf("error reading environment for signaling: %s", err) + } + + tok, err := readToken("../franchise/internal/test/auth.tok", auth.TokenSource) + if err != nil { + t.Fatalf("error reading token: %s", err) + } + src := auth.RefreshTokenSource(tok) + + i := franchise.PlayFabIdentityProvider{ + Environment: a, + IdentityProvider: playfab.XBLIdentityProvider{ + TokenSource: xal.RefreshTokenSource(src, playfab.RelyingParty), + }, + } + + d := signaling.Dialer{ + NetworkID: rand.Uint64(), + } + + dial, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + signals, err := d.DialContext(dial, i, s) + if err != nil { + t.Fatalf("error dialing signaling: %s", err) + } + t.Cleanup(func() { + if err := signals.Close(); err != nil { + t.Fatalf("error closing signaling: %s", err) + } + }) + + x := xal.RefreshTokenSource(src, "http://xboxlive.com") + xt, err := x.Token() + if err != nil { + t.Fatalf("error requesting token: %s", err) + } + + var p SessionPublishConfig + announcer := p.New(x) + + status := DefaultStatus() + status.OwnerID = xt.DisplayClaims().XUID + + minecraft.RegisterNetwork("room", Network{ + Network: minecraft.NetherNet{ + Signaling: signals, + }, + ListenConfig: ListenConfig{ + StatusProvider: NewStatusProvider(status), + }, + Announcer: announcer, + }) + + // The most of the code below has been copied from minecraft/example_listener_test.go. + + // Create a minecraft.Listener with a specific name to be displayed as MOTD in the server list. + name := "MOTD of this server" + cfg := minecraft.ListenConfig{ + StatusProvider: minecraft.NewStatusProvider(name, "Gophertunnel"), + } + + listener, err := cfg.Listen("room", strconv.FormatUint(d.NetworkID, 10)) + if err != nil { + t.Fatalf("error listening: %s", err) + } + t.Cleanup(func() { + if err := listener.Close(); err != nil { + t.Fatalf("error closing listener: %s", err) + } + }) + + for { + netConn, err := listener.Accept() + if err != nil { + return + } + c := netConn.(*minecraft.Conn) + if err := c.StartGame(minecraft.GameData{ + WorldName: "NetherNet", + WorldSeed: 0, + Difficulty: 0, + EntityUniqueID: rand.Int63(), + EntityRuntimeID: rand.Uint64(), + PlayerGameMode: 1, + PlayerPosition: mgl32.Vec3{}, + WorldSpawn: protocol.BlockPos{}, + WorldGameMode: 1, + Time: rand.Int63(), + PlayerPermissions: 2, + // Allow inviting player into the world. + GamePublishSetting: 3, + }); err != nil { + t.Fatalf("error starting game: %s", err) + } + + go func() { + defer func() { + if err := c.Close(); err != nil { + t.Errorf("error closing connection: %s", err) + } + }() + for { + pk, err := c.ReadPacket() + if err != nil { + // No output for errors which has occurred during decoding a packet, + // since minecraft.Conn does not return net.ErrClosed. + return + } + switch pk := pk.(type) { + case *packet.Text: + if pk.Message == "Close" { + if err := listener.Disconnect(c, "Connection closed"); err != nil { + t.Errorf("error closing connection: %s", err) + } + if err := listener.Close(); err != nil { + t.Errorf("error closing listener: %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/room/mpsd.go b/minecraft/room/mpsd.go new file mode 100644 index 00000000..c43527c5 --- /dev/null +++ b/minecraft/room/mpsd.go @@ -0,0 +1,118 @@ +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" +) + +var serviceConfigID = uuid.MustParse("4fc10100-5f7a-4470-899b-280835760c07") + +func NewSessionAnnouncer(s *mpsd.Session) *SessionAnnouncer { + return &SessionAnnouncer{ + s: s, + } +} + +type SessionPublishConfig struct { + PublishConfig mpsd.PublishConfig + Reference mpsd.SessionReference +} + +func (conf SessionPublishConfig) New(src xsapi.TokenSource) *SessionAnnouncer { + return &SessionAnnouncer{ + p: conf, + src: src, + } +} + +func (conf SessionPublishConfig) publish(ctx context.Context, src xsapi.TokenSource) (*mpsd.Session, error) { + if conf.Reference.ServiceConfigID == uuid.Nil { + conf.Reference.ServiceConfigID = serviceConfigID + } + if conf.Reference.TemplateName == "" { + conf.Reference.TemplateName = "MinecraftLobby" + } + if conf.Reference.Name == "" { + conf.Reference.Name = strings.ToUpper(uuid.NewString()) + } + + s, err := conf.PublishConfig.PublishContext(ctx, src, conf.Reference) + if err != nil { + return nil, err + } + + return s, nil +} + +type SessionAnnouncer struct { + p SessionPublishConfig + + src xsapi.TokenSource + + s *mpsd.Session + description *mpsd.SessionDescription + mu sync.Mutex +} + +func (a *SessionAnnouncer) Announce(ctx context.Context, status Status) error { + a.mu.Lock() + defer a.mu.Unlock() + + custom, err := json.Marshal(status) + if err != nil { + return fmt.Errorf("encode status: %w", err) + } + a.updateDescription(status) + if bytes.Compare(a.description.Properties.Custom, custom) == 0 { + return nil // Avoid committing same properties + } + a.description.Properties.Custom = custom + + if a.s == nil { + a.p.PublishConfig.Description = a.description + s, err := a.p.publish(ctx, a.src) + if err != nil { + return fmt.Errorf("publish: %w", err) + } + a.s = s + return nil + } + + commit, err := a.s.Commit(ctx, a.description) + if err == nil { + a.description = commit.SessionDescription + } + return err +} + +func (a *SessionAnnouncer) Close() error { + return a.s.Close() +} + +func (a *SessionAnnouncer) updateDescription(status Status) { + if a.description == nil { + a.description = &mpsd.SessionDescription{} + } + if a.description.Properties == nil { + a.description.Properties = &mpsd.SessionProperties{} + } + if a.description.Properties.System == nil { + a.description.Properties.System = &mpsd.SessionPropertiesSystem{} + } + + switch status.BroadcastSetting { + case BroadcastSettingFriendsOfFriends, BroadcastSettingFriendsOnly: + a.description.Properties.System.JoinRestriction = mpsd.SessionRestrictionFollowed + a.description.Properties.System.ReadRestriction = mpsd.SessionRestrictionFollowed + case BroadcastSettingInviteOnly: + a.description.Properties.System.JoinRestriction = mpsd.SessionRestrictionLocal + a.description.Properties.System.ReadRestriction = mpsd.SessionRestrictionLocal + } +} diff --git a/minecraft/room/network.go b/minecraft/room/network.go new file mode 100644 index 00000000..b4b4e30c --- /dev/null +++ b/minecraft/room/network.go @@ -0,0 +1,45 @@ +package room + +import ( + "context" + "github.com/sandertv/gophertunnel/minecraft" + "net" +) + +type Network struct { + Network minecraft.Network + + Announcer Announcer + + ListenConfig ListenConfig +} + +func (n Network) DialContext(ctx context.Context, address string) (net.Conn, error) { + return n.Network.DialContext(ctx, address) +} + +func (n Network) PingContext(ctx context.Context, address string) (response []byte, err error) { + return n.Network.PingContext(ctx, address) +} + +func (n Network) Listen(address string) (minecraft.NetworkListener, error) { + listener, err := n.Network.Listen(address) + if err != nil { + return nil, err + } + + l, err := n.ListenConfig.Listen(n.Announcer, listener) + if err != nil { + return nil, err + } + + return l, nil +} + +func (n Network) Encrypted() bool { + return n.Network.Encrypted() +} + +func (n Network) BatchHeader() []byte { + return n.Network.BatchHeader() +} diff --git a/minecraft/room/status.go b/minecraft/room/status.go index 521efc91..d72bc0cf 100644 --- a/minecraft/room/status.go +++ b/minecraft/room/status.go @@ -1,5 +1,11 @@ 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"` @@ -10,8 +16,8 @@ type Status struct { WorldName string `json:"worldName"` WorldType string `json:"worldType"` Protocol int32 `json:"protocol"` - MemberCount uint32 `json:"MemberCount"` - MaxMemberCount uint32 `json:"MaxMemberCount"` + MemberCount int `json:"MemberCount"` + MaxMemberCount int `json:"MaxMemberCount"` BroadcastSetting uint32 `json:"BroadcastSetting"` LanGame bool `json:"LanGame"` IsEditorWorld bool `json:"isEditorWorld"` @@ -52,3 +58,56 @@ const ( _ TransportLayerNetherNet ) + +const ( + ConnectionTypeWebSocketsWebRTCSignaling uint32 = 3 +) + +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, + TransportLayer: TransportLayerNetherNet, + OnlineCrossPlatformGame: true, + CrossPlayDisabled: false, + TitleID: 0, + } +} + +func NetherNetID(status Status) (uint64, bool) { + for _, c := range status.SupportedConnections { + if c.ConnectionType == ConnectionTypeWebSocketsWebRTCSignaling { + if c.WebRTCNetworkID != 0 { + return c.WebRTCNetworkID, true + } + if c.NetherNetID != 0 { + return c.NetherNetID, true + } + } + } + return 0, false +} diff --git a/minecraft/room/status_provider.go b/minecraft/room/status_provider.go deleted file mode 100644 index 5d291e63..00000000 --- a/minecraft/room/status_provider.go +++ /dev/null @@ -1,5 +0,0 @@ -package room - -type StatusProvider interface { - RoomStatus() Status -} diff --git a/playfab/catalog/dictionary.go b/playfab/catalog/dictionary.go deleted file mode 100644 index 8e00b229..00000000 --- a/playfab/catalog/dictionary.go +++ /dev/null @@ -1,97 +0,0 @@ -package catalog - -import ( - "encoding/json" - "errors" - "golang.org/x/text/language" - "sort" - "strings" -) - -type Dictionary[T comparable] struct{ self map[string]T } - -func (dict *Dictionary[T]) Message(tag language.Tag) (zero T) { - msg, ok := dict.Lookup(tag.String()) - if !ok || msg == zero { - return dict.Neutral() - } - return msg -} - -func (dict *Dictionary[T]) Lookup(key string) (zero T, ok bool) { - for compare, msg := range dict.self { - if strings.EqualFold(compare, key) { - return msg, true - } - } - return zero, false -} - -const neutralKey = "NEUTRAL" - -func (dict *Dictionary[T]) Neutral() (zero T) { - for key, msg := range dict.self { - if strings.EqualFold(key, neutralKey) && msg != zero { - return msg - } - } - return -} - -func (dict *Dictionary[T]) Map() map[string]T { return dict.self } - -func (dict *Dictionary[T]) MarshalJSON() ([]byte, error) { - return json.Marshal(dict.self) -} - -func (dict *Dictionary[T]) UnmarshalJSON(b []byte) error { - if dict == nil { - return errors.New("playfab/catalog: cannot unmarshal a nil *Dictionary") - } - return json.Unmarshal(b, &dict.self) -} - -var Languages []language.Tag - -var unsortedLanguages = []string{ - "en-US", - "ja-JP", - "ko-KR", - "ru-RU", - "en-GB", -} - -var languages = []string{ - "hu-HU", - "pl-PL", - "fr-CA", - "nl-NL", - "tr-TR", - "uk-UA", - "zh-CN", - "es-MX", - "id-ConnectionID", - "sk-SK", - "pt-BR", - "sv-SE", - "de-DE", - "fi-FI", - "fr-FR", - "nb-NO", - "bg-BG", - "cs-CZ", - "pt-PT", - "da-DK", - "it-IT", - "el-GR", - "es-ES", - "zh-TW", -} - -func init() { - sort.Strings(languages) - - for _, key := range append(unsortedLanguages, languages...) { - Languages = append(Languages, language.MustParse(key)) - } -} diff --git a/playfab/catalog/item.go b/playfab/catalog/item.go deleted file mode 100644 index ef2cc02d..00000000 --- a/playfab/catalog/item.go +++ /dev/null @@ -1,195 +0,0 @@ -package catalog - -import ( - "encoding/json" - "github.com/sandertv/gophertunnel/playfab/entity" - "time" -) - -type Item struct { - AlternateIDs []AlternateID `json:"AlternateIds,omitempty"` - ContentType string `json:"ContentType,omitempty"` - Contents []Content `json:"Contents,omitempty"` - CreationDate time.Time `json:"CreationDate,omitempty"` - CreatorEntity entity.Key `json:"CreatorEntity,omitempty"` - DeepLinks []DeepLink `json:"DeepLinks,omitempty"` - DefaultStackID string `json:"DefaultStackId,omitempty"` // new? - Description Dictionary[string] `json:"Description,omitempty"` - DisplayProperties map[string]json.RawMessage `json:"DisplayProperties,omitempty"` - DisplayVersion string `json:"DisplayVersion,omitempty"` - ETag string `json:"ETag,omitempty"` - EndDate time.Time `json:"EndDate,omitempty"` - ID string `json:"Id,omitempty"` - Images []Image `json:"Images,omitempty"` - Hidden *bool `json:"IsHidden,omitempty"` - ItemReferences []ItemReference `json:"ItemReferences,omitempty"` - Keywords Dictionary[*Keyword] `json:"Keywords,omitempty"` - LastModifiedDate time.Time `json:"LastModifiedDate,omitempty"` - Moderation ModerationState `json:"Moderation,omitempty"` - Platforms []string `json:"Platforms,omitempty"` - PriceOptions PriceOptions `json:"PriceOptions,omitempty"` - Rating Rating `json:"Rating,omitempty"` - StartDate time.Time `json:"StartDate,omitempty"` - StoreDetails StoreDetails `json:"StoreDetails,omitempty"` - Tags []string `json:"Tags,omitempty"` - Title Dictionary[string] `json:"Title,omitempty"` - Type string `json:"Type,omitempty"` -} - -type StoreReference struct { - AlternateID AlternateID `json:"AlternateId,omitempty"` - ID string `json:"Id,omitempty"` -} - -type AlternateID struct { - Type string `json:"Type,omitempty"` - Value string `json:"Value,omitempty"` -} - -type Content struct { - ID string `json:"Id,omitempty"` - MaxClientVersion string `json:"MaxClientVersion,omitempty"` - MinClientVersion string `json:"MinClientVersion,omitempty"` - Tags []string `json:"Tags,omitempty"` - Type string `json:"Type,omitempty"` - URL string `json:"Url,omitempty"` -} - -type DeepLink struct { - Platform string `json:"Platform,omitempty"` - URL string `json:"Url,omitempty"` -} - -type Image struct { - ID string `json:"Id,omitempty"` - Tag string `json:"Tag,omitempty"` - Type string `json:"Type,omitempty"` - URL string `json:"Url,omitempty"` -} - -type ItemReference struct { - Amount int `json:"Amount,omitempty"` - ID string `json:"Id,omitempty"` - PriceOptions PriceOptions `json:"PriceOptions,omitempty"` -} - -type PriceOptions []Price - -func (opts PriceOptions) MarshalJSON() ([]byte, error) { - type raw struct { - Prices []Price `json:"Prices,omitempty"` - } - return json.Marshal(raw{Prices: opts}) -} - -func (opts *PriceOptions) UnmarshalJSON(b []byte) error { - var raw struct { - Prices []Price `json:"Prices,omitempty"` - } - if err := json.Unmarshal(b, &raw); err != nil { - return err - } - *opts = raw.Prices - return nil -} - -type Price struct { - Amounts []PriceAmount `json:"Amounts,omitempty"` - UnitDurationInSeconds int `json:"UnitDurationInSeconds,omitempty"` -} - -type PriceAmount struct { - Amount int `json:"Amount,omitempty"` - ItemID string `json:"ItemId,omitempty"` -} - -type Keyword []string - -func (k *Keyword) MarshalJSON() ([]byte, error) { - type raw struct { - Values []string `json:"Values,omitempty"` - } - return json.Marshal(raw{Values: *k}) -} - -func (k *Keyword) UnmarshalJSON(b []byte) error { - var raw struct { - Values []string `json:"Values,omitempty"` - } - if err := json.Unmarshal(b, &raw); err != nil { - return err - } - *k = raw.Values - return nil -} - -type ModerationState struct { - LastModifiedDate time.Time `json:"LastModifiedDate,omitempty"` - Reason string `json:"Reason,omitempty"` - Status string `json:"Status,omitempty"` -} - -const ( - ModerationStatusApproved string = "Approved" - ModerationStatusAwaitingModeration string = "AwaitingModeration" - ModerationStatusRejected string = "Rejected" - ModerationStatusUnknown string = "Unknown" -) - -type Rating struct { - Average float32 `json:"Average,omitempty"` - Count1Star int `json:"Count1Star,omitempty"` - Count2Star int `json:"Count2Star,omitempty"` - Count3Star int `json:"Count3Star,omitempty"` - Count4Star int `json:"Count4Star,omitempty"` - Count5Star int `json:"Count5Star,omitempty"` - TotalCount int `json:"TotalCount,omitempty"` -} - -type StoreDetails struct { - FilterOptions FilterOptions `json:"FilterOptions,omitempty"` - PriceOptionsOverride PriceOptionsOverride `json:"PriceOptionsOverride,omitempty"` -} - -type FilterOptions struct { - Filter string `json:"Filter,omitempty"` - IncludeAllItems bool `json:"IncludeAllItems,omitempty"` -} - -type PriceOptionsOverride []PriceOverride - -func (opts PriceOptionsOverride) MarshalJSON() ([]byte, error) { - type raw struct { - Prices []PriceOverride `json:"Prices,omitempty"` - } - return json.Marshal(raw{Prices: opts}) -} - -func (opts *PriceOptionsOverride) UnmarshalJSON(b []byte) error { - var raw struct { - Prices []PriceOverride `json:"Prices,omitempty"` - } - if err := json.Unmarshal(b, &raw); err != nil { - return err - } - *opts = raw.Prices - return nil -} - -type PriceOverride struct { - Amounts []PriceAmountOverride `json:"Amounts,omitempty"` -} - -type PriceAmountOverride struct { - FixedValue int `json:"FixedValue,omitempty"` - ItemID string `json:"ItemId,omitempty"` - Multiplier int `json:"Multiplier,omitempty"` -} - -const ( - ItemTypeBundle = "bundle" - ItemTypeCatalogItem = "catalogItem" - ItemTypeCurrency = "currency" - ItemTypeStore = "store" - ItemTypeUGC = "ugc" -) diff --git a/playfab/catalog/query.go b/playfab/catalog/query.go deleted file mode 100644 index d5190d83..00000000 --- a/playfab/catalog/query.go +++ /dev/null @@ -1,26 +0,0 @@ -package catalog - -import ( - "github.com/sandertv/gophertunnel/playfab/entity" - "github.com/sandertv/gophertunnel/playfab/internal" - "github.com/sandertv/gophertunnel/playfab/title" -) - -type Query struct { - AlternateID *AlternateID `json:"AlternateId,omitempty"` - CustomTags map[string]any `json:"CustomTags,omitempty"` - Entity *entity.Key `json:"Entity,omitempty"` - ID string `json:"Id,omitempty"` -} - -func (q Query) Item(t title.Title, tok *entity.Token) (zero Item, err error) { - res, err := internal.Post[*queryResponse](t, "/Catalog/GetItem", q, tok.SetAuthHeader) - if err != nil { - return zero, err - } - return res.Item, nil -} - -type queryResponse struct { - Item Item `json:"Item,omitempty"` -} diff --git a/playfab/catalog/search_items.go b/playfab/catalog/search_items.go deleted file mode 100644 index 11da52d5..00000000 --- a/playfab/catalog/search_items.go +++ /dev/null @@ -1,63 +0,0 @@ -package catalog - -import ( - "fmt" - "github.com/sandertv/gophertunnel/playfab/entity" - "github.com/sandertv/gophertunnel/playfab/internal" - "github.com/sandertv/gophertunnel/playfab/title" - "golang.org/x/text/language" - "net/http" - "slices" -) - -type Filter struct { - // Count is the number of returned items included in the SearchResult. The maximum value is 50 and is stored - // to 10 service-side by default. - Count int `json:"Count,omitempty"` - // ContinuationToken is the opaque token used for continuing the query of Search, if any are available. It is - // normally filled from SearchResult.ContinuationToken. - ContinuationToken string `json:"ContinuationToken,omitempty"` - // CustomTags is the optional properties associated with the request. - CustomTags map[string]any `json:"CustomTags,omitempty"` - // Entity is the nullable entity.Key to perform any actions. - Entity *entity.Key `json:"Entity,omitempty"` - // Filter is an OData query for filtering the SearchResult. - Filter string `json:"Filter,omitempty"` - // OrderBy is an OData sort query for sorting the index of SearchResult. Defaulted to relevance. - OrderBy string `json:"OrderBy,omitempty"` - // Term is the string terms to be searched. - Term string `json:"Search,omitempty"` - // Select is an OData selection query for filtering the fields of returned items included in the SearchResult. - Select string `json:"Select,omitempty"` - // Store ... - Store *StoreReference `json:"Store,omitempty"` - - // Language is used as the `Accept-Language` header of the request and is generally used to display - // a localized dictionaries catalog items. It must be one of the supported Languages, otherwise it - // will be ignored by the request hook. - Language language.Tag `json:"-"` -} - -// Search will perform the search query in the catalog using the title. An authorization entity is optionally required in the service-side. -func (f Filter) Search(t title.Title, tok *entity.Token) (*SearchResult, error) { - if f.Count > 50 { - return nil, fmt.Errorf("playfab/catalog: Filter: count must be <= 50, got %d", f.Count) - } - if f.Entity == nil && tok != nil { - f.Entity = &tok.Entity - } - - return internal.Post[*SearchResult](t, "/Catalog/SearchItems", f, func(req *http.Request) { - if tok != nil { - tok.SetAuthHeader(req) - } - if f.Language != language.Und && slices.ContainsFunc(Languages, func(cmp language.Tag) bool { return f.Language == cmp }) { - req.Header.Set("Accept-Language", f.Language.String()) - } - }) -} - -type SearchResult struct { - ContinuationToken string `json:"ContinuationToken,omitempty"` - Items []Item `json:"Items,omitempty"` -} diff --git a/playfab/entity/exchange.go b/playfab/entity/exchange.go deleted file mode 100644 index 38f19a56..00000000 --- a/playfab/entity/exchange.go +++ /dev/null @@ -1,22 +0,0 @@ -package entity - -import ( - "github.com/sandertv/gophertunnel/playfab/internal" - "github.com/sandertv/gophertunnel/playfab/title" -) - -func (tok *Token) Exchange(t title.Title, id string) (_ *Token, err error) { - r := exchange{ - Entity: Key{ - Type: TypeMasterPlayerAccount, - ID: id, - }, - } - - return internal.Post[*Token](t, "/Authentication/GetEntityToken", r, tok.SetAuthHeader) -} - -type exchange struct { - CustomTags map[string]any `json:"CustomTags,omitempty"` - Entity Key `json:"Entity,omitempty"` -} diff --git a/playfab/entity/token.go b/playfab/entity/token.go deleted file mode 100644 index 65c0d10f..00000000 --- a/playfab/entity/token.go +++ /dev/null @@ -1,31 +0,0 @@ -package entity - -import ( - "net/http" - "time" -) - -type Token struct { - Entity Key `json:"Entity,omitempty"` - Token string `json:"EntityToken,omitempty"` - Expiration time.Time `json:"TokenExpiration,omitempty"` -} - -func (tok *Token) Expired() bool { return time.Now().After(tok.Expiration) } -func (tok *Token) SetAuthHeader(req *http.Request) { req.Header.Set("X-EntityToken", tok.Token) } - -type Key struct { - ID string `json:"Id,omitempty"` - Type Type `json:"Type,omitempty"` -} - -type Type string - -const ( - TypeNamespace Type = "namespace" - TypeTitle Type = "title" - TypeMasterPlayerAccount Type = "master_player_account" - TypeTitlePlayerAccount Type = "title_player_account" - TypeCharacter Type = "character" - TypeGroup Type = "group" -) diff --git a/playfab/entity/token_source.go b/playfab/entity/token_source.go deleted file mode 100644 index 66c57022..00000000 --- a/playfab/entity/token_source.go +++ /dev/null @@ -1,75 +0,0 @@ -package entity - -import ( - "context" - "fmt" - "github.com/sandertv/gophertunnel/playfab/title" - "sync" - "time" -) - -type TokenSource interface { - Token() (*Token, error) -} - -func ExchangeTokenSource(ctx context.Context, tok *Token, t title.Title, masterID string) TokenSource { - src := &exchangeTokenSource{ - tok: tok, - - ctx: ctx, - title: t, - masterID: masterID, - } - go src.background() - return src -} - -const exchangeInterval = time.Minute * 15 - -type exchangeTokenSource struct { - tok *Token - err error - - mux sync.Mutex - ctx context.Context - title title.Title - masterID string -} - -func (src *exchangeTokenSource) background() { - t := time.NewTicker(exchangeInterval) - defer t.Stop() - for { - select { - case <-t.C: - src.mux.Lock() - src.tok, src.err = src.tok.Exchange(src.title, src.masterID) - if src.err != nil { - src.mux.Unlock() - return - } - src.mux.Unlock() - case <-src.ctx.Done(): - src.mux.Lock() - src.err = src.ctx.Err() - src.mux.Unlock() - } - } -} - -func (src *exchangeTokenSource) Token() (tok *Token, err error) { - src.mux.Lock() - defer src.mux.Unlock() - if src.err != nil { - return nil, fmt.Errorf("exchange token in background: %w", err) - } - - if src.tok.Expired() || src.tok.Entity.Type != TypeMasterPlayerAccount { - tok, err = src.tok.Exchange(src.title, src.masterID) - if err != nil { - return nil, fmt.Errorf("exchange: %w", err) - } - src.tok = tok - } - return src.tok, nil -} diff --git a/playfab/identity.go b/playfab/identity.go deleted file mode 100644 index c83164a7..00000000 --- a/playfab/identity.go +++ /dev/null @@ -1,402 +0,0 @@ -package playfab - -import ( - "encoding/json" - "github.com/sandertv/gophertunnel/playfab/entity" - "github.com/sandertv/gophertunnel/playfab/title" - "time" -) - -type Identity struct { - EntityToken *entity.Token `json:"EntityToken,omitempty"` - ResponseParameters ResponseParameters `json:"InfoResultPayload,omitempty"` - LastLoginTime time.Time `json:"LastLoginTime,omitempty"` - NewlyCreated bool `json:"NewlyCreated,omitempty"` - PlayFabID string `json:"PlayFabId,omitempty"` - SessionTicket string `json:"SessionTicket,omitempty"` - SettingsForUser UserSettings `json:"SettingsForUser,omitempty"` - TreatmentAssignment TreatmentAssignment `json:"TreatmentAssignment,omitempty"` -} - -type ResponseParameters struct { - Account UserAccount `json:"AccountInfo,omitempty"` - CharacterInventories []CharacterInventory `json:"CharacterInventories,omitempty"` - CharacterList []Character `json:"CharacterList,omitempty"` - PlayerProfile PlayerProfile `json:"PlayerProfile,omitempty"` - PlayerStatistics []StatisticValue `json:"PlayerStatistics,omitempty"` - TitleData map[string]json.RawMessage `json:"TitleData,omitempty"` - UserData UserDataRecord `json:"UserData,omitempty"` - UserDataVersion int `json:"UserDataVersion,omitempty"` - UserInventory []ItemInstance `json:"UserInventory,omitempty"` - UserReadOnlyData UserDataRecord `json:"UserReadOnlyData,omitempty"` - UserReadOnlyDataVersion int `json:"UserReadOnlyDataVersion,omitempty"` - UserVirtualCurrency map[string]json.RawMessage `json:"UserVirtualCurrency,omitempty"` - UserVirtualCurrencyRechargeTime VirtualCurrencyRechargeTime `json:"UserVirtualCurrencyRechargeTimes"` -} - -type UserAccount struct { - AndroidDevice UserAndroidDevice `json:"AndroidDeviceInfo,omitempty"` - AppleAccount UserAppleAccount `json:"AppleAccountInfo,omitempty"` - Created time.Time `json:"Created,omitempty"` - CustomID UserCustomID `json:"CustomIdInfo,omitempty"` - Facebook UserFacebook `json:"FacebookInfo,omitempty"` - FacebookInstantGamesID UserFacebookInstantGamesID `json:"FacebookInstantGamesIdInfo,omitempty"` - GameCenter UserGameCenter `json:"GameCenterInfo,omitempty"` - Google UserGoogle `json:"GoogleInfo,omitempty"` - GooglePlayGames UserGooglePlayGames `json:"GooglePlayGamesInfo,omitempty"` - IOSDevice UserIOSDevice `json:"IosDeviceInfo,omitempty"` - Kongregate UserKongregate `json:"KongregateInfo,omitempty"` - NintendoSwitchAccount UserNintendoSwitchAccount `json:"NintendoSwitchAccountInfo,omitempty"` - NintendoSwitchDeviceID UserNintendoSwitchDeviceID `json:"NintendoSwitchDeviceIdInfo,omitempty"` - OpenID UserOpenID `json:"OpenIdInfo,omitempty"` - PlayFabID string `json:"PlayFabId,omitempty"` - Private UserPrivate `json:"PrivateInfo,omitempty"` - PSN UserPSN `json:"PsnInfo,omitempty"` - Steam UserSteam `json:"SteamInfo,omitempty"` - Title UserTitle `json:"TitleInfo,omitempty"` - Twitch UserTwitch `json:"TwitchInfo,omitempty"` - Username string `json:"Username,omitempty"` - Xbox UserXbox `json:"Xbox,omitempty"` -} - -type UserAndroidDevice struct { - DeviceID string `json:"AndroidDeviceId,omitempty"` -} - -type UserAppleAccount struct { - SubjectID string `json:"AppleSubjectId,omitempty"` -} - -type UserCustomID struct { - ID string `json:"CustomId,omitempty"` -} - -type UserFacebook struct { - ID string `json:"FacebookId,omitempty"` - FullName string `json:"FullName,omitempty"` -} - -type UserFacebookInstantGamesID struct { - ID string `json:"FacebookInstantGamesId,omitempty"` -} - -type UserGameCenter struct { - ID string `json:"GameCenterId,omitempty"` -} - -type UserGoogle struct { - Email string `json:"GoogleEmail,omitempty"` - Gender string `json:"GoogleGender,omitempty"` - ID string `json:"GoogleId,omitempty"` - Locale string `json:"GoogleLocale,omitempty"` - Name string `json:"GoogleName,omitempty"` -} - -type UserGooglePlayGames struct { - PlayerAvatarImageURL string `json:"GooglePlayGamesPlayerAvatarImageUrl,omitempty"` - PlayerDisplayName string `json:"GooglePlayGamesPlayerDisplayName,omitempty"` - PlayerID string `json:"GooglePlayGamesPlayerId,omitempty"` -} - -type UserIOSDevice struct { - ID string `json:"IosDeviceId,omitempty"` -} - -type UserKongregate struct { - ID string `json:"KongregateId,omitempty"` - Name string `json:"KongregateName,omitempty"` -} - -type UserNintendoSwitchAccount struct { - SubjectID string `json:"NintendoSwitchAccountSubjectId,omitempty"` -} - -type UserNintendoSwitchDeviceID struct { - ID string `json:"NintendoSwitchDeviceId,omitempty"` -} - -type UserOpenID struct { - ConnectionID string `json:"ConnectionId,omitempty"` - Issuer string `json:"Issuer,omitempty"` - Subject string `json:"Subject,omitempty"` -} - -type UserPrivate struct { - Email string `json:"Email,omitempty"` -} - -type UserPSN struct { - AccountID string `json:"PsnAccountId,omitempty"` - OnlineID string `json:"PsnOnlineId,omitempty"` -} - -type UserSteam struct { - ActivationStatus string `json:"SteamActivationStatus,omitempty"` - Country string `json:"SteamCountry,omitempty"` - Currency string `json:"Currency,omitempty"` - ID string `json:"SteamId,omitempty"` - Name string `json:"SteamName,omitempty"` -} - -const ( - TitleActivationStatusActivatedSteam = "ActivatedSteam" - TitleActivationStatusActivatedTitleKey = "ActivatedTitleKey" - TitleActivationStatusNone = "None" - TitleActivationStatusPendingSteam = "PendingSteam" - TitleActivationStatusRevokedSteam = "RevokedSteam" -) - -type UserTitle struct { - AvatarURL string `json:"AvatarUrl,omitempty"` - Created time.Time `json:"Created,omitempty"` - DisplayName string `json:"DisplayName,omitempty"` - FirstLogin time.Time `json:"FirstLogin,omitempty"` - LastLogin time.Time `json:"LastLogin,omitempty"` - Origination string `json:"Origination,omitempty"` - TitlePlayerAccount entity.Key `json:"TitlePlayerAccount,omitempty"` - Banned bool `json:"isBanned,omitempty"` -} - -const ( - UserOriginationAmazon = "Amazon" - UserOriginationAndroid = "Android" - UserOriginationApple = "Apple" - UserOriginationCustomID = "CustomId" - UserOriginationFacebook = "Facebook" - UserOriginationFacebookInstantGamesID = "FacebookInstantGamesId" - UserOriginationGameCenter = "GameCenter" - UserOriginationGamersFirst = "GamersFirst" - UserOriginationGoogle = "Google" - UserOriginationGooglePlayGames = "GooglePlayGames" - UserOriginationIOS = "IOS" - UserOriginationKongregate = "Kongregate" - UserOriginationLoadTest = "LoadTest" - UserOriginationNintendoSwitchAccount = "NintendoSwitchAccount" - UserOriginationNintendoSwitchDeviceID = "NintendoSwitchDeviceID" - UserOriginationOpenIDConnect = "OpenIdConnect" - UserOriginationOrganic = "Organic" - UserOriginationPSN = "PSN" - UserOriginationParse = "Parse" - UserOriginationServerCustomID = "ServerCustomId" - UserOriginationSteam = "Steam" - UserOriginationTwitch = "Twitch" - UserOriginationUnknown = "Unknown" - UserOriginationXboxLive = "XboxLive" -) - -type UserTwitch struct { - ID string `json:"TwitchId,omitempty"` - UserName string `json:"TwitchUserName,omitempty"` -} - -type UserXbox struct { - UserID string `json:"XboxUserId,omitempty"` - UserSandbox string `json:"XboxUserSandbox,omitempty"` -} - -type CharacterInventory struct { - ID string `json:"CharacterId,omitempty"` - Inventory []ItemInstance `json:"Inventory,omitempty"` -} - -type ItemInstance struct { - Annotation string `json:"Annotation,omitempty"` - BundleContents []string `json:"BundleContents,omitempty"` - BundleParent string `json:"BundleParent,omitempty"` - CatalogVersion string `json:"CatalogVersion,omitempty"` - CustomData map[string]json.RawMessage `json:"CustomData,omitempty"` - DisplayName string `json:"DisplayName,omitempty"` - Expiration time.Time `json:"Expiration,omitempty"` - Class string `json:"ItemClass,omitempty"` - ID string `json:"ItemId,omitempty"` - InstanceID string `json:"ItemInstanceId,omitempty"` - PurchaseDate time.Time `json:"PurchaseDate,omitempty"` - RemainingUses int `json:"RemainingUses,omitempty"` - UnitCurrency string `json:"UnitCurrency,omitempty"` - UnitPrice int `json:"UnitPrice,omitempty"` - UsesIncrementedBy int `json:"UsesIncrementedBy,omitempty"` -} - -type Character struct { - ID string `json:"CharacterId,omitempty"` - Name string `json:"CharacterName,omitempty"` - Type string `json:"CharacterType,omitempty"` -} - -type PlayerProfile struct { - AdCampaignAttributions []AdCampaignAttribution `json:"AdCampaignAttributions,omitempty"` - AvatarURL string `json:"AvatarUrl,omitempty"` - BannedUntil time.Time `json:"BannedUntil,omitempty"` - ContactEmailAddresses []ContactEmailAddress `json:"ContactEmailAddresses,omitempty"` - Created time.Time `json:"Created,omitempty"` - DisplayName string `json:"DisplayName,omitempty"` - ExperimentVariants []string `json:"ExperimentVariants,omitempty"` - LastLogin time.Time `json:"LastLogin,omitempty"` - LinkedAccounts []LinkedPlatformAccount `json:"LinkedAccounts,omitempty"` - Locations []Location `json:"Locations,omitempty"` - Memberships []Membership `json:"Memberships,omitempty"` - Origination string `json:"Origination,omitempty"` - PlayerID string `json:"PlayerId,omitempty"` - PublisherID string `json:"PublisherId,omitempty"` - PushNotificationRegistrations []PushNotificationRegistration `json:"PushNotificationRegistrations,omitempty"` - Statistics []Statistic `json:"Statistics,omitempty"` - Tags []Tag `json:"Tags,omitempty"` - Title title.Title `json:"TitleId,omitempty"` - TotalValueToDateInUSD int `json:"TotalValueToDateInUSD,omitempty"` - ValuesToDates []ValuesToDate `json:"ValuesToDate,omitempty"` -} - -type AdCampaignAttribution struct { - AttributedAt time.Time `json:"AttributedAt,omitempty"` - CampaignID string `json:"CampaignId,omitempty"` - Platform string `json:"Platform,omitempty"` -} - -type ContactEmailAddress struct { - Address string `json:"EmailAddress,omitempty"` - Name string `json:"Name,omitempty"` - VerificationStatus EmailVerificationStatus `json:"VerificationStatus,omitempty"` -} - -type EmailVerificationStatus string - -const ( - EmailVerificationStatusConfirmed EmailVerificationStatus = "Confirmed" - EmailVerificationStatusPending EmailVerificationStatus = "Pending" - EmailVerificationStatusUnverified EmailVerificationStatus = "Unverified" -) - -type LinkedPlatformAccount struct { - Email string `json:"Email,omitempty"` - Platform string `json:"Platform,omitempty"` - PlatformUserID string `json:"PlatformUserId,omitempty"` - Username string `json:"Username,omitempty"` -} - -const ( - IdentityProviderAndroidDevice = "AndroidDevice" - IdentityProviderApple = "Apple" - IdentityProviderCustom = "Custom" - IdentityProviderCustomServer = "CustomServer" - IdentityProviderFacebook = "Facebook" - IdentityProviderFacebookInstantGames = "FacebookInstantGames" - IdentityProviderGameCenter = "GameCenter" - IdentityProviderGameServer = "GameServer" - IdentityProviderGooglePlay = "GooglePlay" - IdentityProviderGooglePlayGames = "GooglePlayerGames" - IdentityProviderIOSDevice = "IOSDevice" - IdentityProviderKongregate = "Kongregate" - IdentityProviderNintendoSwitch = "NintendoSwitch" - IdentityProviderNintendoSwitchAccount = "NintendoSwitchAccount" - IdentityProviderOpenIDConnect = "OpenIdConnect" - IdentityProviderPSN = "PSN" - IdentityProviderPlayFab = "PlayFab" - IdentityProviderSteam = "Steam" - IdentityProviderTwitch = "Twitch" - IdentityProviderUnknown = "Unknown" - IdentityProviderWindowsHello = "WindowsHello" - IdentityProviderXboxLive = "XBoxLive" -) - -type Location struct { - City string `json:"City,omitempty"` - ContinentCode string `json:"ContinentCode,omitempty"` - CountryCode string `json:"CountryCode,omitempty"` - Latitude int `json:"Latitude,omitempty"` - Longitude int `json:"Longitude,omitempty"` -} - -type Membership struct { - Active bool `json:"IsActive,omitempty"` - Expiration time.Time `json:"MembershipExpiration,omitempty"` - ID string `json:"MembershipId,omitempty"` - OverrideExpiration time.Time `json:"OverrideExpiration,omitempty"` - OverrideSet bool `json:"OverrideIsSet,omitempty"` - Subscriptions []Subscription `json:"Subscriptions,omitempty"` -} - -type Subscription struct { - Expiration time.Time `json:"Expiration,omitempty"` - InitialSubscriptionTime time.Time `json:"InitialSubscriptionTime,omitempty"` - Active bool `json:"IsActive,omitempty"` - Status string `json:"Status,omitempty"` - ID string `json:"SubscriptionId,omitempty"` - ItemID string `json:"SubscriptionItemId,omitempty"` - Provider string `json:"SubscriptionProvider,omitempty"` -} - -const ( - SubscriptionStatusBillingError = "BillingError" - SubscriptionStatusCancelled = "Cancelled" - SubscriptionStatusCustomerDidNotAcceptPriceChange = "CustomerDidNotAcceptPriceChange" - SubscriptionStatusFreeTrial = "FreeTrial" - SubscriptionStatusNoError = "NoError" - SubscriptionStatusPaymentPending = "PaymentPending" - SubscriptionStatusProductUnavailable = "ProductUnavailable" - SubscriptionStatusUnknownError = "UnknownError" -) - -type PushNotificationRegistration struct { - NotificationEndpointARN string `json:"NotificationEndpointARN,omitempty"` - Platform string `json:"Platform,omitempty"` -} - -const ( - PushNotificationPlatformApplePushNotificationService = "ApplePushNotificationService" - PushNotificationPlatformGoogleCloudMessaging = "GoogleCloudMessaging" -) - -type Statistic struct { - Name string `json:"Name,omitempty"` - Value int `json:"Value,omitempty"` - Version int `json:"Version,omitempty"` -} - -type Tag struct { - Value string `json:"TagValue,omitempty"` -} - -type ValuesToDate struct { - Currency string `json:"Currency,omitempty"` - TotalValue int `json:"TotalValue,omitempty"` - TotalValueAsDecimal string `json:"TotalValueAsDecimal,omitempty"` -} - -type StatisticValue struct { - Name string `json:"StatisticName"` - Value int `json:"Value,omitempty"` - Version int `json:"Version,omitempty"` -} - -type UserDataRecord struct { - LastUpdated time.Time `json:"LastUpdated,omitempty"` - Permission string `json:"Permission,omitempty"` - Value string `json:"Value,omitempty"` -} - -const ( - UserDataPermissionPrivate = "Private" - UserDataPermissionPublic = "Public" -) - -type VirtualCurrencyRechargeTime struct { - Max int `json:"RechargeMax,omitempty"` - Time time.Time `json:"RechargeTime,omitempty"` - SecondsToRecharge int `json:"SecondsToRecharge,omitempty"` -} - -type UserSettings struct { - GatherDevice bool `json:"GatherDeviceInfo,omitempty"` - GatherFocus bool `json:"GatherFocusInfo,omitempty"` - NeedsAttribution bool `json:"NeedsAttribution,omitempty"` -} - -type TreatmentAssignment struct { - Variables []Variable `json:"Variables,omitempty"` - Variants []string `json:"Variants,omitempty"` -} - -type Variable struct { - Name string `json:"Name,omitempty"` - Value string `json:"Value,omitempty"` -} diff --git a/playfab/internal/body.go b/playfab/internal/body.go deleted file mode 100644 index 39a177f4..00000000 --- a/playfab/internal/body.go +++ /dev/null @@ -1,105 +0,0 @@ -package internal - -import ( - "strconv" - "strings" -) - -type Result[T any] struct { - StatusCode int `json:"code,omitempty"` - Data T `json:"data,omitempty"` - Status string `json:"status,omitempty"` -} - -type Error struct { - StatusCode int `json:"code,omitempty"` - Type string `json:"error,omitempty"` - Code int `json:"errorCode,omitempty"` - Details map[string][]string `json:"errorDetails,omitempty"` - Message string `json:"errorMessage,omitempty"` - Status string `json:"status,omitempty"` -} - -func (err Error) Error() string { - b := &strings.Builder{} - b.WriteString(errorHeader) - b.WriteByte(errorSeparator) - - b.WriteByte(' ') - b.WriteString(strconv.Itoa(err.Code)) - - if err.Type != "" { - b.WriteByte(' ') - b.WriteByte(errorLeftBracket) - b.WriteString(err.Type) - b.WriteByte(errorRightBracket) - } - if err.Message != "" && err.Message != err.Type { - // In some cases, message are the equal to the type, so we're trimming some unnecessary fields here... - // tl;dl avoid returning `1041 (InvalidRequest): "InvalidRequest"` - b.WriteByte(errorSeparator) - b.WriteByte(' ') - b.WriteString(strconv.Quote(err.Message)) - } - if err.Details != nil { - b.WriteByte(' ') - b.WriteByte(errorLeftBracket) - - var index int - for key, messages := range err.Details { - b.WriteString(strconv.Quote(key)) - b.WriteByte(errorSeparator) - b.WriteByte(' ') - b.WriteByte(errorLeftSquareBracket) - - var elementIndex int - for _, msg := range messages { - b.WriteString(strconv.Quote(msg)) - if elementIndex++; elementIndex < len(messages) { - b.WriteByte(errorBracketSeparator) - b.WriteByte(' ') - } - } - - b.WriteByte(errorRightSquareBracket) - - if index > errorMaxDetails { - b.WriteByte(errorBracketSeparator) - b.WriteByte(' ') - b.WriteString(errorDetailsSuffix) - break - } - - if index++; index < len(err.Details) { - b.WriteByte(errorBracketSeparator) - b.WriteByte(' ') - } - } - b.WriteByte(errorRightBracket) - } - return b.String() -} - -const ( - errorHeader = "playfab" - errorSeparator = ':' - errorBracketSeparator = ',' - - errorLeftBracket = '(' - errorRightBracket = ')' - - errorLeftSquareBracket = '[' - errorRightSquareBracket = ']' - - errorDetailsSuffix = "..." - - errorMaxDetails = 5 - - // playfab: 0001 - // playfab: 0001 (Foo) - // playfab: 0001 (Foo): "..." - // - // playfab: 0001 ("fuga": ["hoge", "huga"]) - // playfab: 0001 (Foo) ("fuga": ["hoge", "huga"]) - // playfab: 0001 (Foo): "..." ("fuga": ["hoge", "huga"]) -) diff --git a/playfab/internal/http.go b/playfab/internal/http.go deleted file mode 100644 index b7e4f7c7..00000000 --- a/playfab/internal/http.go +++ /dev/null @@ -1,55 +0,0 @@ -package internal - -import ( - "bytes" - "encoding/json" - "fmt" - "github.com/sandertv/gophertunnel/playfab/title" - "io" - "net/http" -) - -func Post[T any](t title.Title, route string, r any, hooks ...func(req *http.Request)) (zero T, err error) { - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(r); err != nil { - return zero, fmt.Errorf("encode: %w", err) - } - req, err := http.NewRequest(http.MethodPost, t.URL(route), buf) - if err != nil { - return zero, fmt.Errorf("make request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - for _, hook := range hooks { - hook(req) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return zero, fmt.Errorf("POST %s: %w", route, err) - } - switch { - case StatusRange(resp.StatusCode, http.StatusOK): - var body Result[T] - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return zero, fmt.Errorf("decode: %w", err) - } - return body.Data, nil - default: - b, err := io.ReadAll(resp.Body) - if err != nil { - return zero, fmt.Errorf("POST %s: %s", route, resp.Status) - } - var body Error - if err := json.Unmarshal(b, &body); err != nil { - return zero, fmt.Errorf("POST %s: %s: %s (%w)", route, resp.Status, b, err) - } - return zero, body - } -} - -func StatusRange(code, region int) bool { - if region%100 != 0 { - panic(fmt.Sprintf("playfab/internal: StatusRange: invalid http status region: %d", region)) - } - return code >= region && code < region+100 -} diff --git a/playfab/login.go b/playfab/login.go deleted file mode 100644 index 7755d277..00000000 --- a/playfab/login.go +++ /dev/null @@ -1,61 +0,0 @@ -package playfab - -import ( - "github.com/sandertv/gophertunnel/playfab/internal" - "github.com/sandertv/gophertunnel/playfab/title" -) - -type LoginConfig struct { - Title title.Title `json:"TitleId,omitempty"` - CreateAccount bool `json:"CreateAccount,omitempty"` - CustomTags map[string]any `json:"CustomTags,omitempty"` - EncryptedRequest []byte `json:"EncryptedRequest,omitempty"` - InfoRequestParameters *RequestParameters `json:"InfoRequestParameters,omitempty"` - PlayerSecret string `json:"PlayerSecret,omitempty"` -} - -type IdentityProvider interface { - Login(config LoginConfig) (*Identity, error) -} - -type RequestParameters struct { - CharacterInventories bool `json:"GetCharacterInventories,omitempty"` - CharacterList bool `json:"GetCharacterList,omitempty"` - PlayerProfile bool `json:"GetPlayerProfile,omitempty"` - PlayerStatistics bool `json:"GetPlayerStatistics,omitempty"` - TitleData bool `json:"GetTitleData,omitempty"` - UserAccountInfo bool `json:"GetUserAccountInfo,omitempty"` - UserData bool `json:"GetUserData,omitempty"` - UserInventory bool `json:"GetUserInventory,omitempty"` - UserReadOnlyData bool `json:"GetUserReadOnlyData,omitempty"` - UserVirtualCurrency bool `json:"GetUserVirtualCurrency,omitempty"` - PlayerStatisticNames []string `json:"PlayerStatisticNames,omitempty"` - ProfileConstraints ProfileConstraints `json:"ProfileConstraints,omitempty"` - TitleDataKeys []string `json:"TitleDataKeys,omitempty"` - UserDataKeys []string `json:"UserDataKeys,omitempty"` - UserReadOnlyDataKeys []string `json:"UserReadOnlyDataKeys,omitempty"` -} - -type ProfileConstraints struct { - ShowAvatarURL bool `json:"ShowAvatarUrl,omitempty"` - ShowBannedUntil bool `json:"ShowBannedUntil,omitempty"` - ShowCampaignAttributions bool `json:"ShowCampaignAttributions,omitempty"` - ShowContactEmailAddresses bool `json:"ShowContactEmailAddresses,omitempty"` - ShowCreated bool `json:"ShowCreated,omitempty"` - ShowDisplayName bool `json:"ShowDisplayName,omitempty"` - ShowExperimentVariants bool `json:"ShowExperimentVariants,omitempty"` - ShowLastLogin bool `json:"ShowLastLogin,omitempty"` - ShowLinkedAccounts bool `json:"ShowLinkedAccounts,omitempty"` - ShowLocations bool `json:"ShowLocations,omitempty"` - ShowMemberships bool `json:"ShowMemberships,omitempty"` - ShowOrigination bool `json:"ShowOrigination,omitempty"` - ShowPushNotificationRegistrations bool `json:"ShowPushNotificationRegistrations,omitempty"` - ShowStatistics bool `json:"ShowStatistics,omitempty"` - ShowTags bool `json:"ShowTags,omitempty"` - ShowTotalValueToDateInUSD bool `json:"ShowTotalValueToDateInUsd,omitempty"` - ShowValuesToDate bool `json:"ShowValuesToDate,omitempty"` -} - -func (l LoginConfig) login(path string, r any) (*Identity, error) { - return internal.Post[*Identity](l.Title, path, r) -} diff --git a/playfab/title/title.go b/playfab/title/title.go deleted file mode 100644 index e08e16a9..00000000 --- a/playfab/title/title.go +++ /dev/null @@ -1,9 +0,0 @@ -package title - -type Title string - -func (t Title) URL(path string) string { - return "https://" + t.String() + ".playfabapi.com" + path -} - -func (t Title) String() string { return string(t) } diff --git a/playfab/types.go b/playfab/types.go deleted file mode 100644 index d6ecbebe..00000000 --- a/playfab/types.go +++ /dev/null @@ -1,21 +0,0 @@ -package playfab - -import "github.com/sandertv/gophertunnel/playfab/internal" - -type Body[T any] internal.Result[T] - -type Error = internal.Error - -const ( - ErrorCodeSuccess = iota - ErrorCodeUnknown - ErrorCodeConnectionError - ErrorCodeJSONParseError -) - -const ( - ErrorCodeInvalidRequest = 1071 - ErrorCodeItemNotFound = 1047 - ErrorCodeDatabaseThroughputExceeded = 1113 - NotImplemented = 1515 -) diff --git a/playfab/xbox_live.go b/playfab/xbox_live.go deleted file mode 100644 index 90a8da0e..00000000 --- a/playfab/xbox_live.go +++ /dev/null @@ -1,31 +0,0 @@ -package playfab - -import ( - "errors" - "fmt" - "github.com/sandertv/gophertunnel/xsapi" -) - -type XBLIdentityProvider struct { - TokenSource xsapi.TokenSource -} - -func (prov XBLIdentityProvider) Login(config LoginConfig) (*Identity, error) { - if prov.TokenSource == nil { - return nil, errors.New("playfab: XBLIdentityProvider: TokenSource is nil") - } - - tok, err := prov.TokenSource.Token() - if err != nil { - return nil, fmt.Errorf("request xbox live token: %w", err) - } - - type loginConfig struct { - LoginConfig - XboxToken string `json:"XboxToken"` - } - return config.login("/Client/LoginWithXbox", loginConfig{ - LoginConfig: config, - XboxToken: tok.String(), - }) -} diff --git a/xsapi/internal/attr.go b/xsapi/internal/attr.go deleted file mode 100644 index 0a1d146f..00000000 --- a/xsapi/internal/attr.go +++ /dev/null @@ -1,7 +0,0 @@ -package internal - -import "log/slog" - -const errorKey = "error" - -func ErrAttr(err error) slog.Attr { return slog.Any(errorKey, err) } diff --git a/xsapi/internal/transport.go b/xsapi/internal/transport.go deleted file mode 100644 index 0d2c4827..00000000 --- a/xsapi/internal/transport.go +++ /dev/null @@ -1,22 +0,0 @@ -package internal - -import ( - "github.com/sandertv/gophertunnel/xsapi" - "net/http" -) - -func SetTransport(client *http.Client, src xsapi.TokenSource) { - var ( - hasTransport bool - base = client.Transport - ) - if base != nil { - _, hasTransport = base.(*xsapi.Transport) - } - if !hasTransport { - client.Transport = &xsapi.Transport{ - Source: src, - Base: base, - } - } -} diff --git a/xsapi/mpsd/activity.go b/xsapi/mpsd/activity.go deleted file mode 100644 index 10cbeb69..00000000 --- a/xsapi/mpsd/activity.go +++ /dev/null @@ -1,155 +0,0 @@ -package mpsd - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "github.com/google/uuid" - "github.com/sandertv/gophertunnel/xsapi" - "github.com/sandertv/gophertunnel/xsapi/internal" - "net/http" - "net/url" - "strconv" - "time" -) - -type QueryConfig struct { - Client *http.Client - - SocialGroup string - SocialGroupXUID string -} - -func (conf QueryConfig) Query(src xsapi.TokenSource, serviceConfigID uuid.UUID) ([]ActivityHandle, error) { - if conf.Client == nil { - conf.Client = &http.Client{} - } - internal.SetTransport(conf.Client, src) - - owners := make(map[string]any) - if conf.SocialGroup != "" && conf.SocialGroupXUID == "" { - tok, err := src.Token() - if err != nil { - return nil, fmt.Errorf("request token: %w", err) - } - if claimer, ok := tok.(xsapi.DisplayClaimer); ok { - conf.SocialGroupXUID = claimer.DisplayClaims().XUID - } - owners["people"] = map[string]any{ - "moniker": conf.SocialGroup, - "monikerXuid": conf.SocialGroupXUID, - } - } - - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(map[string]any{ - "type": "activity", - "scid": serviceConfigID, - "owners": owners, - }); err != nil { - return nil, fmt.Errorf("encode request body: %w", err) - } - req, err := http.NewRequest(http.MethodPost, queryURL.String(), buf) - if err != nil { - return nil, fmt.Errorf("make request: %w", err) - } - req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) - - resp, err := conf.Client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusOK: - b := &bytes.Buffer{} - if _, err := b.ReadFrom(resp.Body); err != nil { - return nil, err - } - var data struct { - Results []ActivityHandle `json:"results"` - } - if err := json.NewDecoder(b).Decode(&data); err != nil { - return nil, fmt.Errorf("decode response body: %w", err) - } - return data.Results, nil - default: - return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) - } -} - -func (conf PublishConfig) commitActivity(ctx context.Context, ref SessionReference) error { - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(activityHandle{ - Type: "activity", - SessionReference: ref, - Version: 1, - }); err != nil { - return fmt.Errorf("encode request body: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, handlesURL.String(), buf) - if err != nil { - return fmt.Errorf("make request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) - - resp, err := conf.Client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusOK, http.StatusCreated: - return nil - default: - return fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) - } -} - -var ( - handlesURL = &url.URL{ - Scheme: "https", - Host: "sessiondirectory.xboxlive.com", - Path: "/handles", - } - - queryURL = &url.URL{ - Scheme: "https", - Host: "sessiondirectory.xboxlive.com", - Path: "/handles/query", - RawQuery: url.Values{ - "include": []string{"relatedInfo,customProperties"}, - }.Encode(), - } -) - -type activityHandle struct { - Type string `json:"type"` // Always "activity". - SessionReference SessionReference `json:"sessionRef,omitempty"` - Version int `json:"version"` // Always 1. - OwnerXUID string `json:"ownerXuid,omitempty"` -} - -type ActivityHandle struct { - activityHandle - CreateTime time.Time `json:"createTime,omitempty"` - CustomProperties json.RawMessage `json:"customProperties,omitempty"` - GameTypes json.RawMessage `json:"gameTypes,omitempty"` - ID uuid.UUID `json:"id,omitempty"` - InviteProtocol string `json:"inviteProtocol,omitempty"` - RelatedInfo *ActivityHandleRelatedInfo `json:"relatedInfo,omitempty"` - TitleID string `json:"titleId,omitempty"` -} - -func (h ActivityHandle) URL() *url.URL { return handlesURL.JoinPath(h.ID.String()) } - -type ActivityHandleRelatedInfo struct { - Closed bool `json:"closed,omitempty"` - InviteProtocol string `json:"inviteProtocol,omitempty"` - JoinRestriction string `json:"joinRestriction,omitempty"` - MaxMembersCount uint32 `json:"maxMembersCount,omitempty"` - PostedTime time.Time `json:"postedTime,omitempty"` - Visibility string `json:"visibility,omitempty"` -} diff --git a/xsapi/mpsd/commit.go b/xsapi/mpsd/commit.go deleted file mode 100644 index 12f0b5ce..00000000 --- a/xsapi/mpsd/commit.go +++ /dev/null @@ -1,81 +0,0 @@ -package mpsd - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "github.com/google/uuid" - "net/http" - "net/url" - "path" - "strconv" - "time" -) - -func (s *Session) CommitContext(ctx context.Context, d *SessionDescription) (*Commitment, error) { - return s.conf.commit(ctx, s.ref.URL(), d) -} - -func (conf PublishConfig) commit(ctx context.Context, u *url.URL, d *SessionDescription) (*Commitment, error) { - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(d); err != nil { - return nil, fmt.Errorf("encode request body: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), buf) - if err != nil { - return nil, fmt.Errorf("make request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) - - resp, err := conf.Client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusOK, http.StatusCreated: - var commitment *Commitment - if err := json.NewDecoder(resp.Body).Decode(&commitment); err != nil { - return nil, fmt.Errorf("decode response body: %w", err) - } - return commitment, nil - case http.StatusNoContent: - return nil, nil - default: - return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) - } -} - -type SessionReference struct { - ServiceConfigID uuid.UUID `json:"scid,omitempty"` - TemplateName string `json:"templateName,omitempty"` - Name string `json:"name,omitempty"` -} - -func (ref SessionReference) URL() *url.URL { - return &url.URL{ - Scheme: "https", - Host: "sessiondirectory.xboxlive.com", - Path: path.Join( - "/serviceconfigs/", ref.ServiceConfigID.String(), - "/sessionTemplates/", ref.TemplateName, - "/sessions/", ref.Name, - ), - } -} - -type Commitment struct { - ContractVersion uint32 `json:"contractVersion,omitempty"` - CorrelationID uuid.UUID `json:"correlationId,omitempty"` - SearchHandle uuid.UUID `json:"searchHandle,omitempty"` - Branch uuid.UUID `json:"branch,omitempty"` - ChangeNumber uint64 `json:"changeNumber,omitempty"` - StartTime time.Time `json:"startTime,omitempty"` - NextTimer time.Time `json:"nextTimer,omitempty"` - - *SessionDescription -} - -const contractVersion = 107 diff --git a/xsapi/mpsd/handler.go b/xsapi/mpsd/handler.go deleted file mode 100644 index 9932fc6e..00000000 --- a/xsapi/mpsd/handler.go +++ /dev/null @@ -1,22 +0,0 @@ -package mpsd - -import "github.com/google/uuid" - -type Handler interface { - HandleSessionChange(ref SessionReference, branch uuid.UUID, changeNumber uint64) -} - -type NopHandler struct{} - -func (NopHandler) HandleSessionChange(SessionReference, uuid.UUID, uint64) {} - -func (s *Session) Handle(h Handler) { - if h == nil { - h = NopHandler{} - } - s.h.Store(&h) -} - -func (s *Session) handler() Handler { - return *s.h.Load() -} diff --git a/xsapi/mpsd/invite.go b/xsapi/mpsd/invite.go deleted file mode 100644 index 8415b3c2..00000000 --- a/xsapi/mpsd/invite.go +++ /dev/null @@ -1,66 +0,0 @@ -package mpsd - -import ( - "bytes" - "encoding/json" - "fmt" - "github.com/google/uuid" - "net/http" - "strconv" - "time" -) - -func (s *Session) Invite(xuid string, titleID int) (*InviteHandle, error) { - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(&inviteHandle{ - Type: "invite", - SessionReference: s.ref, - Version: 1, - InvitedXUID: xuid, - InviteAttributes: map[string]any{ - "titleId": strconv.Itoa(titleID), - }, - }); err != nil { - return nil, fmt.Errorf("encode request body: %w", err) - } - req, err := http.NewRequest(http.MethodPost, handlesURL.String(), buf) - if err != nil { - return nil, fmt.Errorf("make request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) - - resp, err := s.conf.Client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusCreated: - // It seems the C++ implementation only decodes "id" field from the response. - var handle *InviteHandle - if err := json.NewDecoder(resp.Body).Decode(&handle); err != nil { - return nil, fmt.Errorf("decode response body: %w", err) - } - return handle, nil - default: - return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) - } -} - -type inviteHandle struct { - Type string `json:"type,omitempty"` // Always "invite". - Version int `json:"version,omitempty"` // Always 1. - InviteAttributes map[string]any `json:"inviteAttributes,omitempty"` - InvitedXUID string `json:"invitedXuid,omitempty"` - SessionReference SessionReference `json:"sessionRef,omitempty"` -} - -type InviteHandle struct { - inviteHandle - Expiration time.Time `json:"expiration,omitempty"` - ID uuid.UUID `json:"id,omitempty"` - InviteProtocol string `json:"inviteProtocol,omitempty"` - SenderXUID string `json:"senderXuid,omitempty"` - GameTypes json.RawMessage `json:"gameTypes,omitempty"` -} diff --git a/xsapi/mpsd/join.go b/xsapi/mpsd/join.go deleted file mode 100644 index 7add5cf0..00000000 --- a/xsapi/mpsd/join.go +++ /dev/null @@ -1,14 +0,0 @@ -package mpsd - -import ( - "context" - "github.com/sandertv/gophertunnel/xsapi" -) - -type JoinConfig struct { - PublishConfig -} - -func (conf JoinConfig) JoinContext(ctx context.Context, src xsapi.TokenSource, handle ActivityHandle) (*Session, error) { - return conf.publish(ctx, src, handle.URL().JoinPath("session"), handle.SessionReference) -} diff --git a/xsapi/mpsd/member.go b/xsapi/mpsd/member.go deleted file mode 100644 index 0e5ff749..00000000 --- a/xsapi/mpsd/member.go +++ /dev/null @@ -1,57 +0,0 @@ -package mpsd - -import ( - "encoding/json" - "github.com/google/uuid" -) - -type MemberDescription struct { - Constants *MemberConstants `json:"constants,omitempty"` - Properties *MemberProperties `json:"properties,omitempty"` - Roles json.RawMessage `json:"roles,omitempty"` -} - -type MemberProperties struct { - System *MemberPropertiesSystem `json:"system,omitempty"` - Custom json.RawMessage `json:"custom,omitempty"` -} - -type MemberPropertiesSystem struct { - Active bool `json:"active,omitempty"` - Ready bool `json:"ready,omitempty"` - Connection uuid.UUID `json:"connection,omitempty"` - Subscription *MemberPropertiesSystemSubscription `json:"subscription,omitempty"` - SecureDeviceAddress []byte `json:"secureDeviceAddress,omitempty"` - InitializationGroup []uint32 `json:"initializationGroup,omitempty"` - Groups []string `json:"groups,omitempty"` - Encounters []string `json:"encounters,omitempty"` - Measurements json.RawMessage `json:"measurements,omitempty"` - ServerMeasurements json.RawMessage `json:"serverMeasurements,omitempty"` -} - -type MemberPropertiesSystemSubscription struct { - ID string `json:"id,omitempty"` - ChangeTypes []string `json:"changeTypes,omitempty"` -} - -const ( - ChangeTypeEverything = "everything" - ChangeTypeHost = "host" - ChangeTypeInitialization = "initialization" - ChangeTypeMatchmakingStatus = "matchmakingStatus" - ChangeTypeMembersList = "membersList" - ChangeTypeMembersStatus = "membersStatus" - ChangeTypeJoinability = "joinability" - ChangeTypeCustomProperty = "customProperty" - ChangeTypeMembersCustomProperty = "membersCustomProperty" -) - -type MemberConstants struct { - System *MemberConstantsSystem `json:"system,omitempty"` - Custom json.RawMessage `json:"custom,omitempty"` -} - -type MemberConstantsSystem struct { - XUID string `json:"xuid,omitempty"` - Initialize bool `json:"initialize,omitempty"` -} diff --git a/xsapi/mpsd/publish.go b/xsapi/mpsd/publish.go deleted file mode 100644 index c91dda48..00000000 --- a/xsapi/mpsd/publish.go +++ /dev/null @@ -1,125 +0,0 @@ -package mpsd - -import ( - "context" - "encoding/json" - "fmt" - "github.com/google/uuid" - "github.com/sandertv/gophertunnel/xsapi" - "github.com/sandertv/gophertunnel/xsapi/internal" - "github.com/sandertv/gophertunnel/xsapi/rta" - "log/slog" - "net/http" - "net/url" - "strings" -) - -type PublishConfig struct { - RTADialer *rta.Dialer - RTAConn *rta.Conn - - Description *SessionDescription - - Client *http.Client - Logger *slog.Logger -} - -func (conf PublishConfig) publish(ctx context.Context, src xsapi.TokenSource, u *url.URL, ref SessionReference) (*Session, error) { - if conf.Logger == nil { - conf.Logger = slog.Default() - } - if conf.Client == nil { - conf.Client = &http.Client{} - } - internal.SetTransport(conf.Client, src) - - if conf.RTAConn == nil { - if conf.RTADialer == nil { - conf.RTADialer = &rta.Dialer{} - } - var err error - conf.RTAConn, err = conf.RTADialer.DialContext(ctx, src) - if err != nil { - return nil, fmt.Errorf("prepare subscription: dial: %w", err) - } - } - - tok, err := src.Token() - if err != nil { - return nil, fmt.Errorf("obtain token: %w", err) - } - - sub, err := conf.RTAConn.Subscribe(ctx, resourceURI) - if err != nil { - return nil, fmt.Errorf("prepare subscription: subscribe: %w", err) - } - var custom subscription - if err := json.Unmarshal(sub.Custom, &custom); err != nil { - return nil, fmt.Errorf("prepare subscription: decode: %w", err) - } - - if conf.Description == nil { - conf.Description = &SessionDescription{} - } - if conf.Description.Members == nil { - conf.Description.Members = make(map[string]*MemberDescription, 1) - } - - if ref.Name == "" { - ref.Name = strings.ToUpper(uuid.NewString()) - } - - me, ok := conf.Description.Members["me"] - if !ok { - me = &MemberDescription{} - } - if me.Constants == nil { - me.Constants = &MemberConstants{} - } - if me.Constants.System == nil { - me.Constants.System = &MemberConstantsSystem{} - } - me.Constants.System.Initialize = true - if claimer, ok := tok.(xsapi.DisplayClaimer); ok { - me.Constants.System.XUID = claimer.DisplayClaims().XUID - } - if me.Properties == nil { - me.Properties = &MemberProperties{} - } - if me.Properties.System == nil { - me.Properties.System = &MemberPropertiesSystem{} - } - me.Properties.System.Active = true - me.Properties.System.Connection = custom.ConnectionID - if me.Properties.System.Subscription == nil { - me.Properties.System.Subscription = &MemberPropertiesSystemSubscription{} - } - if me.Properties.System.Subscription.ID == "" { - me.Properties.System.Subscription.ID = strings.ToUpper(uuid.NewString()) - } - me.Properties.System.Subscription.ChangeTypes = []string{ - ChangeTypeEverything, - } - conf.Description.Members["me"] = me - - if _, err := conf.commit(ctx, u, conf.Description); err != nil { - return nil, fmt.Errorf("commit: %w", err) - } - if err := conf.commitActivity(ctx, ref); err != nil { - return nil, fmt.Errorf("commit activity: %w", err) - } - - s := &Session{ - ref: ref, - conf: conf, - rta: conf.RTAConn, - sub: sub, - } - s.Handle(nil) - sub.Handle(&subscriptionHandler{s}) - return s, nil -} - -func (conf PublishConfig) PublishContext(ctx context.Context, src xsapi.TokenSource, ref SessionReference) (s *Session, err error) { - return conf.publish(ctx, src, ref.URL(), ref) -} diff --git a/xsapi/mpsd/session.go b/xsapi/mpsd/session.go deleted file mode 100644 index 4044bb62..00000000 --- a/xsapi/mpsd/session.go +++ /dev/null @@ -1,171 +0,0 @@ -package mpsd - -import ( - "context" - "encoding/json" - "fmt" - "github.com/sandertv/gophertunnel/xsapi/rta" - "net/http" - "strconv" - "sync/atomic" -) - -type Session struct { - ref SessionReference - conf PublishConfig - - rta *rta.Conn - - sub *rta.Subscription - - h atomic.Pointer[Handler] -} - -func (s *Session) Commitment() (*Commitment, error) { - req, err := http.NewRequest(http.MethodGet, s.ref.URL().String(), nil) - if err != nil { - return nil, fmt.Errorf("make request: %w", err) - } - req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion)) - - resp, err := s.conf.Client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusOK: - var c *Commitment - if err := json.NewDecoder(resp.Body).Decode(&c); err != nil { - return nil, fmt.Errorf("decode response body: %w", err) - } - return c, nil - default: - return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status) - } -} - -func (s *Session) Close() error { - if err := s.rta.Unsubscribe(context.Background(), s.sub); err != nil { - s.conf.Logger.Error("error unsubscribing with RTA", "err", err) - } - _, err := s.CommitContext(context.Background(), &SessionDescription{ - Members: map[string]*MemberDescription{ - "me": nil, - }, - }) - return err -} - -type SessionDescription struct { - Constants *SessionConstants `json:"constants,omitempty"` - RoleTypes json.RawMessage `json:"roleTypes,omitempty"` - Properties *SessionProperties `json:"properties,omitempty"` - Members map[string]*MemberDescription `json:"members,omitempty"` -} - -type SessionProperties struct { - System *SessionPropertiesSystem `json:"system,omitempty"` - Custom json.RawMessage `json:"custom,omitempty"` -} - -type SessionPropertiesSystem struct { - Keywords []string `json:"keywords,omitempty"` - Turn []uint32 `json:"turn,omitempty"` - JoinRestriction SessionRestriction `json:"joinRestriction,omitempty"` - ReadRestriction SessionRestriction `json:"readRestriction,omitempty"` - Closed bool `json:"closed"` - Locked bool `json:"locked,omitempty"` - Matchmaking *SessionPropertiesSystemMatchmaking `json:"matchmaking,omitempty"` - MatchmakingResubmit bool `json:"matchmakingResubmit,omitempty"` - InitializationSucceeded bool `json:"initializationSucceeded,omitempty"` - Host string `json:"host,omitempty"` - ServerConnectionStringCandidates json.RawMessage `json:"serverConnectionStringCandidates,omitempty"` -} - -type SessionPropertiesSystemMatchmaking struct { - TargetSessionConstants json.RawMessage `json:"targetSessionConstants,omitempty"` - ServerConnectionString string `json:"serverConnectionString,omitempty"` -} - -type SessionRestriction string - -const ( - SessionRestrictionNone SessionRestriction = "none" - SessionRestrictionLocal SessionRestriction = "local" - SessionRestrictionFollowed SessionRestriction = "followed" -) - -type SessionConstants struct { - System *SessionConstantsSystem `json:"system,omitempty"` - Custom json.RawMessage `json:"custom,omitempty"` -} - -type SessionConstantsSystem struct { - MaxMembersCount uint32 `json:"maxMembersCount,omitempty"` - Capabilities *SessionCapabilities `json:"capabilities,omitempty"` - Visibility string `json:"visibility,omitempty"` - Initiators []string `json:"initiators,omitempty"` - ReservedRemovalTimeout uint64 `json:"reservedRemovalTimeout,omitempty"` - InactiveRemovalTimeout uint64 `json:"inactiveRemovalTimeout,omitempty"` - ReadyRemovalTimeout uint64 `json:"readyRemovalTimeout,omitempty"` - SessionEmptyTimeout uint64 `json:"sessionEmptyTimeout,omitempty"` - Metrics *SessionConstantsSystemMetrics `json:"metrics,omitempty"` - MemberInitialization *MemberInitialization `json:"memberInitialization,omitempty"` - PeerToPeerRequirements *PeerToPeerRequirements `json:"peerToPeerRequirements,omitempty"` - PeerToHostRequirements *PeerToHostRequirements `json:"peerToHostRequirements,omitempty"` - MeasurementServerAddresses json.RawMessage `json:"measurementServerAddresses,omitempty"` - CloudComputePackage json.RawMessage `json:"cloudComputePackage,omitempty"` -} - -type PeerToHostRequirements struct { - LatencyMaximum uint64 `json:"latencyMaximum,omitempty"` - BandwidthDownMinimum uint64 `json:"bandwidthDownMinimum,omitempty"` - BandwidthUpMinimum uint64 `json:"bandwidthUpMinimum,omitempty"` - HostSelectionMetric string `json:"hostSelectionMetric,omitempty"` -} - -const ( - HostSelectionMetricBandwidthUp = "bandwidthUp" - HostSelectionMetricBandwidthDown = "bandwidthDown" - HostSelectionMetricBandwidth = "bandwidth" - HostSelectionMetricLatency = "latency" -) - -type PeerToPeerRequirements struct { - LatencyMaximum uint64 `json:"latencyMaximum,omitempty"` - BandwidthMinimum uint64 `json:"bandwidthMinimum,omitempty"` -} - -type MemberInitialization struct { - JoinTimeout uint64 `json:"joinTimeout,omitempty"` - MeasurementTimeout uint64 `json:"measurementTimeout,omitempty"` - EvaluationTimeout uint64 `json:"evaluationTimeout,omitempty"` - ExternalEvaluation bool `json:"externalEvaluation,omitempty"` - MembersNeededToStart uint32 `json:"membersNeededToStart,omitempty"` -} - -type SessionConstantsSystemMetrics struct { - Latency bool `json:"latency,omitempty"` - BandwidthDown bool `json:"bandwidthDown,omitempty"` - BandwidthUp bool `json:"bandwidthUp,omitempty"` - Custom bool `json:"custom,omitempty"` -} - -type SessionCapabilities struct { - Connectivity bool `json:"connectivity,omitempty"` - SuppressPresenceActivityCheck bool `json:"suppressPresenceActivityCheck,omitempty"` - Gameplay bool `json:"gameplay,omitempty"` - Large bool `json:"large,omitempty"` - UserAuthorizationStyle bool `json:"userAuthorizationStyle,omitempty"` - ConnectionRequiredForActiveMembers bool `json:"connectionRequiredForActiveMembers,omitempty"` - CrossPlay bool `json:"crossPlay,omitempty"` - Searchable bool `json:"searchable,omitempty"` - HasOwners bool `json:"hasOwners,omitempty"` -} - -const ( - SessionVisibilityPrivate = "private" - SessionVisibilityVisible = "visible" - SessionVisibilityOpen = "open" -) diff --git a/xsapi/mpsd/subscription.go b/xsapi/mpsd/subscription.go deleted file mode 100644 index 56266959..00000000 --- a/xsapi/mpsd/subscription.go +++ /dev/null @@ -1,58 +0,0 @@ -package mpsd - -import ( - "encoding/json" - "fmt" - "github.com/google/uuid" - "github.com/sandertv/gophertunnel/xsapi/internal" - "strings" -) - -const resourceURI = "https://sessiondirectory.xboxlive.com/connections/" - -type subscription struct { - ConnectionID uuid.UUID `json:"ConnectionId,omitempty"` -} - -type subscriptionHandler struct { - *Session -} - -func (h *subscriptionHandler) HandleEvent(data json.RawMessage) { - var event subscriptionEvent - if err := json.Unmarshal(data, &event); err != nil { - h.conf.Logger.Error("error decoding subscription event", internal.ErrAttr(err)) - } - for _, tap := range event.ShoulderTaps { - ref, err := h.parseReference(tap.Resource) - if err != nil { - h.conf.Logger.Error("handle subscription event: error parsing shoulder tap", internal.ErrAttr(err)) - continue - } - h.handler().HandleSessionChange(ref, tap.Branch, tap.ChangeNumber) - } -} - -func (h *subscriptionHandler) parseReference(s string) (ref SessionReference, err error) { - segments := strings.Split(s, "~") - if len(segments) != 3 { - return ref, fmt.Errorf("unexpected segmentations: %s", s) - } - ref.ServiceConfigID, err = uuid.Parse(segments[0]) - if err != nil { - return ref, fmt.Errorf("parse service config ID: %w", err) - } - ref.TemplateName = segments[1] - ref.Name = segments[2] - return ref, nil -} - -type subscriptionEvent struct { - ShoulderTaps []shoulderTap `json:"shoulderTaps"` -} - -type shoulderTap struct { - Resource string `json:"resource"` - ChangeNumber uint64 `json:"changeNumber"` - Branch uuid.UUID `json:"branch"` -} diff --git a/xsapi/rta/conn.go b/xsapi/rta/conn.go deleted file mode 100644 index aed54b91..00000000 --- a/xsapi/rta/conn.go +++ /dev/null @@ -1,251 +0,0 @@ -package rta - -import ( - "context" - "encoding/json" - "fmt" - "github.com/sandertv/gophertunnel/xsapi/internal" - "log/slog" - "net" - "nhooyr.io/websocket" - "nhooyr.io/websocket/wsjson" - "sync" - "sync/atomic" -) - -// Conn represents a connection between the real-time activity services. It can -// be established from Dialer with an authorization token that relies on the -// party 'https://xboxlive.com/'. -// -// A Conn controls subscriptions real-timely under a websocket connection. An -// index-specific JSON array is used for the communication. Conn is safe for -// concurrent use in multiple goroutines. -// -// SubscriptionHandlers are useful to handle any events that may occur in the subscriptions -// controlled by Conn, and can be stored atomically to a Conn from Handle. -type Conn struct { - conn *websocket.Conn - - sequences [operationCapacity]atomic.Uint32 - expected [operationCapacity]map[uint32]chan<- *handshake - expectedMu sync.RWMutex - - subscriptions map[uint32]*Subscription - subscriptionsMu sync.RWMutex - - log *slog.Logger - - once sync.Once - closed chan struct{} -} - -// Subscribe attempts to subscribe with the specific resource URI, with the context -// to be used during the handshakes. A Subscription may be returned with fields decoded -// from the service. -func (c *Conn) Subscribe(ctx context.Context, resourceURI string) (*Subscription, error) { - sequence := c.sequences[operationSubscribe].Add(1) - hand, err := c.shake(operationSubscribe, sequence, []any{resourceURI}) - if err != nil { - return nil, err - } - defer c.release(operationSubscribe, sequence) - select { - case h := <-hand: - switch h.status { - case StatusOK: - if len(h.payload) < 2 { - return nil, &OutOfRangeError{ - Payload: h.payload, - Index: 1, - } - } - sub := &Subscription{} - if err := json.Unmarshal(h.payload[0], &sub.ID); err != nil { - return nil, fmt.Errorf("decode subscription ConnectionID: %w", err) - } - sub.Custom = h.payload[1] - - c.subscriptionsMu.Lock() - c.subscriptions[sub.ID] = sub - c.subscriptionsMu.Unlock() - return sub, nil - default: - return nil, unexpectedStatusCode(h.status, h.payload) - } - case <-ctx.Done(): - return nil, ctx.Err() - case <-c.closed: - return nil, net.ErrClosed - } -} - -func (c *Conn) Unsubscribe(ctx context.Context, sub *Subscription) error { - sequence := c.sequences[operationUnsubscribe].Add(1) - hand, err := c.shake(operationUnsubscribe, sequence, []any{sub.ID}) - if err != nil { - return err - } - defer c.release(operationUnsubscribe, sequence) - select { - case h := <-hand: - if h.status != StatusOK { - return unexpectedStatusCode(h.status, h.payload) - } - return nil - case <-ctx.Done(): - return ctx.Err() - case <-c.closed: - return net.ErrClosed - } -} - -// Subscription represents a subscription contracted with the resource URI available through -// the real-time activity service. A Subscription may be contracted via Conn.Subscribe. -type Subscription struct { - ID uint32 - Custom json.RawMessage - - h SubscriptionHandler - mu sync.Mutex -} - -func (s *Subscription) Handle(h SubscriptionHandler) { - s.mu.Lock() - s.h = h - s.mu.Unlock() -} - -func (s *Subscription) handler() SubscriptionHandler { - s.mu.Lock() - defer s.mu.Unlock() - if s.h == nil { - return NopSubscriptionHandler{} - } - return s.h -} - -type SubscriptionHandler interface { - HandleEvent(custom json.RawMessage) -} - -type NopSubscriptionHandler struct{} - -func (NopSubscriptionHandler) HandleEvent(json.RawMessage) {} - -// write attempts to write a JSON array with header and the body. A background context is -// used as no context perceived by the parent goroutine should be used to a websocket method -// to avoid closing the connection if it has cancelled or exceeded a deadline. -func (c *Conn) write(typ uint32, payload []any) error { - return wsjson.Write(context.Background(), c.conn, append([]any{typ}, payload...)) -} - -// read goes as a background goroutine of Conn, reading a JSON array from the websocket -// connection and decoding a header needed to indicate which message should be handled. -func (c *Conn) read() { - for { - var payload []json.RawMessage - if err := wsjson.Read(context.Background(), c.conn, &payload); err != nil { - _ = c.Close() - return - } - typ, err := readHeader(payload) - if err != nil { - c.log.Error("error reading header", internal.ErrAttr(err)) - continue - } - go c.handleMessage(typ, payload[1:]) - } -} - -// Close closes the websocket connection with websocket.StatusNormalClosure. -func (c *Conn) Close() (err error) { - c.once.Do(func() { - close(c.closed) - err = c.conn.Close(websocket.StatusNormalClosure, "") - }) - return err -} - -// handleMessage handles a message received in read with the type. -func (c *Conn) handleMessage(typ uint32, payload []json.RawMessage) { - switch typ { - case typeSubscribe, typeUnsubscribe: // Subscribe & Unsubscribe handshake response - h, err := readHandshake(payload) - if err != nil { - c.log.Error("error reading handshake response", internal.ErrAttr(err)) - return - } - op := typeToOperation(typ) - c.expectedMu.RLock() - defer c.expectedMu.RUnlock() - hand, ok := c.expected[op][h.sequence] - if !ok { - c.log.Debug("unexpected handshake response", slog.Group("message", "type", typ, "sequence", h.sequence)) - return - } - hand <- h - case typeEvent: - if len(payload) < 2 { - c.log.Debug("event message has no custom") - return - } - var subscriptionID uint32 - if err := json.Unmarshal(payload[0], &subscriptionID); err != nil { - c.log.Error("error decoding subscription ID", internal.ErrAttr(err)) - } - c.subscriptionsMu.Lock() - defer c.subscriptionsMu.Unlock() - sub, ok := c.subscriptions[subscriptionID] - if ok { - go sub.handler().HandleEvent(payload[1]) - } - c.log.Debug("received event", slog.Group("message", "type", typ, "custom", payload[0])) - default: - c.log.Debug("received an unexpected message", slog.Group("message", "type", typ)) - } -} - -type OutOfRangeError struct { - Payload []json.RawMessage - Index int -} - -func (e *OutOfRangeError) Error() string { - return fmt.Sprintf("xsapi/rta: index out of range [%d] with length %d", e.Index, len(e.Payload)) -} - -func readHeader(payload []json.RawMessage) (typ uint32, err error) { - if len(payload) < 1 { - return typ, &OutOfRangeError{ - Payload: payload, - Index: 0, - } - } - return typ, json.Unmarshal(payload[0], &typ) -} - -func readHandshake(payload []json.RawMessage) (*handshake, error) { - if len(payload) < 2 { - return nil, &OutOfRangeError{ - Payload: payload, - Index: 2, - } - } - h := &handshake{} - if err := json.Unmarshal(payload[0], &h.sequence); err != nil { - return nil, fmt.Errorf("decode sequence: %w", err) - } - if err := json.Unmarshal(payload[1], &h.status); err != nil { - return nil, fmt.Errorf("decode status code: %w", err) - } - h.payload = payload[2:] - return h, nil -} - -func unexpectedStatusCode(status int32, payload []json.RawMessage) error { - err := &UnexpectedStatusError{Code: status} - if len(payload) >= 1 { - _ = json.Unmarshal(payload[0], &err.Message) - } - return err -} diff --git a/xsapi/rta/dial.go b/xsapi/rta/dial.go deleted file mode 100644 index 9186e40d..00000000 --- a/xsapi/rta/dial.go +++ /dev/null @@ -1,71 +0,0 @@ -package rta - -import ( - "context" - "github.com/sandertv/gophertunnel/xsapi" - "github.com/sandertv/gophertunnel/xsapi/internal" - "log/slog" - "net/http" - "nhooyr.io/websocket" - "slices" - "time" -) - -// Dialer represents the options for establishing a Conn with real-time activity services with DialContext or Dial. -type Dialer struct { - Options *websocket.DialOptions - ErrorLog *slog.Logger -} - -// Dial calls DialContext with a 15 seconds timeout. -func (d Dialer) Dial(src xsapi.TokenSource) (*Conn, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - return d.DialContext(ctx, src) -} - -// DialContext establishes a connection with real-time activity service. A context.Context is used to control the -// scene real-timely. An authorization token may be used for configuring an HTTP header to Options. An error may be -// returned during the dial of websocket connection. -func (d Dialer) DialContext(ctx context.Context, src xsapi.TokenSource) (*Conn, error) { - if d.ErrorLog == nil { - d.ErrorLog = slog.Default() - } - if d.Options == nil { - d.Options = &websocket.DialOptions{} - } - if !slices.Contains(d.Options.Subprotocols, subprotocol) { - d.Options.Subprotocols = append(d.Options.Subprotocols, subprotocol) - } - if d.Options.HTTPHeader == nil { - d.Options.HTTPHeader = make(http.Header) - } - - if d.Options.HTTPClient == nil { - d.Options.HTTPClient = &http.Client{} - } - internal.SetTransport(d.Options.HTTPClient, src) - - c, _, err := websocket.Dial(ctx, connectURL, d.Options) - if err != nil { - return nil, err - } - conn := &Conn{ - conn: c, - log: d.ErrorLog, - subscriptions: make(map[uint32]*Subscription), - } - for i := 0; i < cap(conn.expected); i++ { - conn.expected[i] = make(map[uint32]chan<- *handshake) - } - go conn.read() - return conn, nil -} - -const ( - // connectURL is the URL used to establish a websocket connection with real-time activity services. It is - // generally present at websocket.Dial with other websocket.DialOptions, specifically along with subprotocol. - connectURL = "wss://rta.xboxlive.com/connect" - // subprotocol is the subprotocol used with connectURL, to establish a websocket connection. - subprotocol = "rta.xboxlive.com.V2" -) diff --git a/xsapi/rta/handshake.go b/xsapi/rta/handshake.go deleted file mode 100644 index b54a9dac..00000000 --- a/xsapi/rta/handshake.go +++ /dev/null @@ -1,91 +0,0 @@ -package rta - -import ( - "encoding/json" - "strconv" - "strings" -) - -type handshake struct { - sequence uint32 - status int32 - payload []json.RawMessage -} - -const ( - typeSubscribe uint32 = iota + 1 - typeUnsubscribe - typeEvent - typeResync -) - -const ( - operationSubscribe uint8 = iota - operationUnsubscribe - operationCapacity // The capacity of expected handshake uses. -) - -func typeToOperation(typ uint32) uint8 { - switch typ { - case typeSubscribe: - return operationSubscribe - case typeUnsubscribe: - return operationUnsubscribe - default: - panic("unreachable") - } -} - -func operationToType(op uint8) uint32 { - switch op { - case operationSubscribe: - return typeSubscribe - case operationUnsubscribe: - return typeUnsubscribe - default: - panic("unreachable") - } -} - -func (c *Conn) shake(op uint8, sequence uint32, payload []any) (<-chan *handshake, error) { - if err := c.write(operationToType(op), append([]any{sequence}, payload...)); err != nil { - return nil, err - } - hand := make(chan *handshake) - c.expectedMu.Lock() - c.expected[op][sequence] = hand - c.expectedMu.Unlock() - return hand, nil -} - -func (c *Conn) release(op uint8, sequence uint32) { - c.expectedMu.Lock() - delete(c.expected[op], sequence) - c.expectedMu.Unlock() -} - -type UnexpectedStatusError struct { - Code int32 - Message string -} - -func (e *UnexpectedStatusError) Error() string { - b := &strings.Builder{} - b.WriteString("rta: code ") - b.WriteString(strconv.FormatInt(int64(e.Code), 10)) - if e.Message != "" { - b.WriteByte(':') - b.WriteByte(' ') - b.WriteString(e.Message) - } - return b.String() -} - -const ( - StatusOK int32 = iota - StatusUnknownResource - StatusSubscriptionLimitReached - StatusNoResourceData - StatusThrottled = 1001 - StatusServiceUnavailable = 1002 -) diff --git a/xsapi/token.go b/xsapi/token.go deleted file mode 100644 index 645ca9dc..00000000 --- a/xsapi/token.go +++ /dev/null @@ -1,24 +0,0 @@ -package xsapi - -import ( - "net/http" -) - -type Token interface { - SetAuthHeader(req *http.Request) - String() string -} - -type TokenSource interface { - Token() (Token, error) -} - -type DisplayClaimer interface { - DisplayClaims() DisplayClaims -} - -type DisplayClaims struct { - GamerTag string `json:"gtg"` - XUID string `json:"xid"` - UserHash string `json:"uhs"` -} diff --git a/xsapi/transport.go b/xsapi/transport.go deleted file mode 100644 index 603a55cb..00000000 --- a/xsapi/transport.go +++ /dev/null @@ -1,58 +0,0 @@ -package xsapi - -import ( - "errors" - "net/http" -) - -type Transport struct { - Source TokenSource - - 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.Source == nil { - return nil, errors.New("xsapi: Transport's Source is nil") - } - token, err := t.Source.Token() - if err != nil { - return nil, 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 -} From de180d341c743ef53d1e3fa43c052a48a9a2013b Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Mon, 16 Sep 2024 02:54:31 +0900 Subject: [PATCH 13/14] minecraft: Add documentation --- go.mod | 10 +- go.sum | 16 +- minecraft/_world_test.go | 452 ------------------- minecraft/auth/xal/token_source.go | 14 +- minecraft/auth/xbox.go | 14 +- minecraft/dial.go | 2 +- minecraft/franchise/discovery.go | 37 +- minecraft/franchise/playfab.go | 43 +- minecraft/franchise/signaling/conn.go | 123 +++-- minecraft/franchise/signaling/conn_test.go | 64 --- minecraft/franchise/signaling/dial.go | 68 ++- minecraft/franchise/signaling/dial_test.go | 92 ++++ minecraft/franchise/signaling/environment.go | 33 +- minecraft/franchise/signaling/message.go | 85 +++- minecraft/franchise/token.go | 160 ++++++- minecraft/listener.go | 4 +- minecraft/nethernet.go | 7 +- minecraft/network.go | 10 +- minecraft/raknet.go | 2 +- minecraft/room/_dial.go | 81 ---- minecraft/room/announce.go | 13 +- minecraft/room/discovery.go | 39 -- minecraft/room/internal/attr.go | 4 +- minecraft/room/listener.go | 185 +++++--- minecraft/room/listener_test.go | 118 ++--- minecraft/room/mpsd.go | 150 +++--- minecraft/room/network.go | 37 +- minecraft/room/status.go | 8 +- 28 files changed, 844 insertions(+), 1027 deletions(-) delete mode 100644 minecraft/_world_test.go delete mode 100644 minecraft/franchise/signaling/conn_test.go create mode 100644 minecraft/franchise/signaling/dial_test.go delete mode 100644 minecraft/room/_dial.go delete mode 100644 minecraft/room/discovery.go diff --git a/go.mod b/go.mod index 3e6cfd62..4dc831ce 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/sandertv/gophertunnel 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 @@ -17,12 +18,9 @@ require ( golang.org/x/net v0.27.0 golang.org/x/oauth2 v0.21.0 golang.org/x/text v0.17.0 - nhooyr.io/websocket v1.8.11 ) require ( - github.com/andreburgaud/crypt2go v1.8.0 // indirect - github.com/coder/websocket v1.8.12 // 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 @@ -46,8 +44,8 @@ require ( ) replace ( - github.com/df-mc/go-nethernet => github.com/lactyy/go-nethernet v0.0.0-20240902104417-681fd9263f4a - github.com/df-mc/go-playfab => github.com/lactyy/go-playfab v0.0.0-20240906070923-01f9987eafb6 - github.com/df-mc/go-xsapi => github.com/lactyy/go-xsapi v0.0.0-20240902120723-5a844e61607e + github.com/df-mc/go-nethernet => github.com/lactyy/go-nethernet v0.0.0-20240911083526-16e64f38dc39 + 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 github.com/pion/sctp => github.com/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3 ) diff --git a/go.sum b/go.sum index 34e920c8..9b41fcea 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/andreburgaud/crypt2go v1.8.0 h1:J73vGTb1P6XL69SSuumbKs0DWn3ulbl9L92ZXBjw6pc= -github.com/andreburgaud/crypt2go v1.8.0/go.mod h1:L5nfShQ91W78hOWhUH2tlGRPO+POAPJAF5fKOLB9SXg= 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= @@ -17,12 +15,12 @@ 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-20240902104417-681fd9263f4a h1:Y8LbIx1RsSIUDvdVHkoBuK7/eP/U4E6IXpeGz0Ng+iY= -github.com/lactyy/go-nethernet v0.0.0-20240902104417-681fd9263f4a/go.mod h1:/pGUz0nwAHcpKynNyRz1sXVsF0klaevDsMkPXsdP7mM= -github.com/lactyy/go-playfab v0.0.0-20240906070923-01f9987eafb6 h1:5+LTlXf9yVTVP1ooZ0U+AXEn9Kbxlx98yfFdrADGoMQ= -github.com/lactyy/go-playfab v0.0.0-20240906070923-01f9987eafb6/go.mod h1:/beD0DtQFxxslNr1iKt3+SrCjW2HNpwJYPOmM/277eQ= -github.com/lactyy/go-xsapi v0.0.0-20240902120723-5a844e61607e h1:6Jp7yaAMJJl8Vz6soxCz1ZVUPYBpaxVTksgzwRBWPps= -github.com/lactyy/go-xsapi v0.0.0-20240902120723-5a844e61607e/go.mod h1:yMOOWhg3JL/t3si+6jb+UZgYS/E2RU4A6oanupqk3iI= +github.com/lactyy/go-nethernet v0.0.0-20240911083526-16e64f38dc39 h1:AilC/4ePyYxmXpSVaEX2zt0snQX6RG7FXkv6MHU5XUI= +github.com/lactyy/go-nethernet v0.0.0-20240911083526-16e64f38dc39/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/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3 h1:Nikw9jHHbZZgeN+YugbVOldbv+0PoZVm4ZSMSGItpeU= github.com/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= github.com/muhammadmuzzammil1998/jsonc v1.0.0 h1:8o5gBQn4ZA3NBA9DlTujCj2a4w0tqWrPVjDwhzkgTIs= @@ -130,5 +128,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 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= -nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= -nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/minecraft/_world_test.go b/minecraft/_world_test.go deleted file mode 100644 index 6e4372f3..00000000 --- a/minecraft/_world_test.go +++ /dev/null @@ -1,452 +0,0 @@ -package minecraft - -import ( - "context" - rand2 "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "github.com/go-gl/mathgl/mgl32" - "github.com/google/uuid" - "github.com/sandertv/gophertunnel/minecraft/auth" - "github.com/sandertv/gophertunnel/minecraft/franchise" - "github.com/sandertv/gophertunnel/minecraft/franchise/signaling" - "github.com/sandertv/gophertunnel/minecraft/protocol/packet" - "github.com/sandertv/gophertunnel/minecraft/room" - "github.com/sandertv/gophertunnel/playfab" - "github.com/sandertv/gophertunnel/xsapi/xal" - "log/slog" - "os" - "strconv" - "time" - - "github.com/sandertv/gophertunnel/minecraft/protocol" - "github.com/sandertv/gophertunnel/xsapi" - "github.com/sandertv/gophertunnel/xsapi/mpsd" - "golang.org/x/oauth2" - "math/rand" - "strings" - "testing" -) - -// TestWorldListen demonstrates a world displayed in the friend list. -func TestWorldListen(t *testing.T) { - discovery, err := franchise.Discover(protocol.CurrentVersion) - if err != nil { - t.Fatalf("error retrieving discovery: %s", err) - } - a := new(franchise.AuthorizationEnvironment) - if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("error reading environment for authorization: %s", err) - } - s := new(signaling.Environment) - if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("error reading environment for signaling: %s", err) - } - - tok, err := readToken("franchise/internal/test/auth.tok", auth.TokenSource) - if err != nil { - t.Fatalf("error reading token: %s", err) - } - src := auth.RefreshTokenSource(tok) - - prov := franchise.PlayFabIdentityProvider{ - Environment: a, - IdentityProvider: playfab.XBLIdentityProvider{ - TokenSource: xal.RefreshTokenSource(src, "http://playfab.xboxlive.com/"), - }, - } - - d := signaling.Dialer{ - NetworkID: rand.Uint64(), - } - - dial, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - conn, err := d.DialContext(dial, prov, s) - if err != nil { - t.Fatalf("error dialing signaling: %s", err) - } - t.Cleanup(func() { - if err := conn.Close(); err != nil { - t.Fatalf("error closing signaling: %s", err) - } - }) - - // A token source that refreshes a token used for generic Xbox Live services. - x := xal.RefreshTokenSource(src, "http://xboxlive.com") - xt, err := x.Token() - if err != nil { - t.Fatalf("error refreshing xbox live token: %s", err) - } - claimer, ok := xt.(xsapi.DisplayClaimer) - if !ok { - t.Fatalf("xbox live token %T does not implement xsapi.DisplayClaimer", xt) - } - displayClaims := claimer.DisplayClaims() - - // The name of the session being published. This seems always to be generated - // randomly, referenced as "GUID" of the session. - name := strings.ToUpper(uuid.NewString()) - - levelID := make([]byte, 8) - _, _ = rand2.Read(levelID) - - custom, err := json.Marshal(room.Status{ - Joinability: room.JoinabilityJoinableByFriends, - HostName: displayClaims.GamerTag, - OwnerID: displayClaims.XUID, - RakNetGUID: "", - // This is displayed as the suffix of the world name. - Version: protocol.CurrentVersion, - LevelID: base64.StdEncoding.EncodeToString(levelID), - WorldName: "TestWorldListen: " + name, - WorldType: room.WorldTypeCreative, - // The game seems checking this field before joining a session, causes - // RequestNetworkSettings packet not being even sent to the remote host. - Protocol: protocol.CurrentProtocol, - MemberCount: 1, - MaxMemberCount: 8, - BroadcastSetting: room.BroadcastSettingFriendsOfFriends, - LanGame: true, - IsEditorWorld: false, - TransportLayer: 2, - WebRTCNetworkID: d.NetworkID, - OnlineCrossPlatformGame: true, - CrossPlayDisabled: false, - TitleID: 0, - SupportedConnections: []room.Connection{ - { - ConnectionType: 3, // WebSocketsWebRTCSignaling - HostIPAddress: "", - HostPort: 0, - NetherNetID: d.NetworkID, - WebRTCNetworkID: d.NetworkID, - RakNetGUID: "UNASSIGNED_RAKNET_GUID", - }, - }, - }) - if err != nil { - t.Fatalf("error encoding custom properties: %s", err) - } - cfg := mpsd.PublishConfig{ - Description: &mpsd.SessionDescription{ - Properties: &mpsd.SessionProperties{ - System: &mpsd.SessionPropertiesSystem{ - JoinRestriction: mpsd.SessionRestrictionFollowed, - ReadRestriction: mpsd.SessionRestrictionFollowed, - }, - Custom: custom, - }, - }, - } - - publish, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - session, err := cfg.PublishContext(publish, x, mpsd.SessionReference{ - ServiceConfigID: serviceConfigID, - TemplateName: "MinecraftLobby", - Name: name, - }) - if err != nil { - t.Fatalf("error publishing session: %s", err) - } - t.Cleanup(func() { - if err := session.Close(); err != nil { - t.Fatalf("error closing session: %s", err) - } - }) - - t.Logf("Session Name: %q", name) - t.Logf("Network ID: %d", d.NetworkID) - - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug, - }))) - - RegisterNetwork("nethernet", &NetherNet{ - Signaling: conn, - }) - - l, err := Listen("nethernet", strconv.FormatUint(d.NetworkID, 10)) - if err != nil { - t.Fatalf("error listening: %s", err) - } - t.Cleanup(func() { - if err := l.Close(); err != nil { - t.Fatalf("error closing listener: %s", err) - } - }) - - for { - netConn, err := l.Accept() - if err != nil { - return - } - c := netConn.(*Conn) - if err := c.StartGame(GameData{ - WorldName: "NetherNet", - WorldSeed: 0, - Difficulty: 0, - EntityUniqueID: rand.Int63(), - EntityRuntimeID: rand.Uint64(), - PlayerGameMode: 1, - PlayerPosition: mgl32.Vec3{}, - WorldSpawn: protocol.BlockPos{}, - WorldGameMode: 1, - Time: rand.Int63(), - PlayerPermissions: 2, - // Allow inviting player into the world. - GamePublishSetting: 3, - }); err != nil { - t.Fatalf("error starting game: %s", err) - } - - go func() { - defer func() { - if err := c.Close(); err != nil { - t.Errorf("error closing connection: %s", err) - } - }() - for { - pk, err := c.ReadPacket() - if err != nil { - if !errors.Is(err, errClosed) { - t.Errorf("error reading packet: %s", err) - } - return - } - switch pk := pk.(type) { - case *packet.Text: - if pk.Message == "Close" { - if err := l.Disconnect(c, "Connection closed"); err != nil { - t.Errorf("error closing connection: %s", err) - } - if err := l.Close(); err != nil { - t.Errorf("error closing listener: %s", err) - } - } - } - } - }() - } -} - -var serviceConfigID = uuid.MustParse("4fc10100-5f7a-4470-899b-280835760c07") - -// TestWorldDial connects to a world. It retrieves the sessions available, and join the first session returned -// from the response. -func TestWorldDial(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) - - // A token source that refreshes a token used for generic Xbox Live services. - x := xal.RefreshTokenSource(src, "http://xboxlive.com") - - handles, err := mpsd.QueryConfig{ - SocialGroup: "people", - }.Query(x, serviceConfigID) - if err != nil { - t.Fatalf("error querying handles: %s", err) - } else if len(handles) == 0 { - t.Fatalf("no handles found") - } - // Join the first session we've got. - handle := handles[0] - - t.Logf("Joining session: URL: %s, owner XUID: %s", handle.URL().JoinPath("session"), handle.OwnerXUID) - - var status room.Status - if err := json.Unmarshal(handle.CustomProperties, &status); err != nil { - t.Fatalf("error decoding custom properties from handle: %s", err) - } - - var networkID uint64 - for _, connection := range status.SupportedConnections { - if connection.ConnectionType == 3 { - if connection.WebRTCNetworkID != 0 { - networkID = connection.WebRTCNetworkID - break - } - if connection.NetherNetID != 0 { - networkID = connection.NetherNetID - break - } - } - } - if networkID == 0 { - t.Fatal("no remote network ID found in custom properties") - } - - join, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - var cfg mpsd.JoinConfig - session, err := cfg.JoinContext(join, x, handle) - if err != nil { - t.Fatalf("error joining session: %s", err) - } - t.Cleanup(func() { - if err := session.Close(); err != nil { - t.Fatalf("error leaving session: %s", err) - } - }) - - conn := testWorldDial(t, networkID, src) - - // Try decoding deferred packets received from the connection. - for { - pk, err := conn.ReadPacket() - if err != nil { - if !errors.Is(err, errClosed) { - t.Errorf("error reading packet: %s", err) - } - return - } - switch pk := pk.(type) { - case *packet.Text: - if pk.TextType == packet.TextTypeChat && pk.XUID == handle.OwnerXUID && pk.Message == "Close" { - if err := conn.Close(); err != nil { - t.Errorf("error closing connection: %s", err) - } - } - } - } -} - -func TestWorldDialByNetworkID(t *testing.T) { - const networkID = 0 // Fill in this constant before running the test. - - tok, err := readToken("franchise/internal/test/auth.tok", auth.TokenSource) - if err != nil { - t.Fatalf("error reading token: %s", err) - } - src := auth.RefreshTokenSource(tok) - - conn := testWorldDial(t, networkID, src) - - // Try decoding deferred packets received from the connection. - for { - pk, err := conn.ReadPacket() - if err != nil { - if !errors.Is(err, errClosed) { - t.Errorf("error reading packet: %s", err) - } - return - } - switch pk := pk.(type) { - case *packet.Text: - if pk.TextType == packet.TextTypeChat && pk.Message == "Close" { - if err := conn.Close(); err != nil { - t.Errorf("error closing connection: %s", err) - } - } - } - } -} - -func testWorldDial(t *testing.T, networkID uint64, src oauth2.TokenSource) *Conn { - discovery, err := franchise.Discover(protocol.CurrentVersion) - if err != nil { - t.Fatalf("error retrieving discovery: %s", err) - } - a := new(franchise.AuthorizationEnvironment) - if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("error reading environment for authorization: %s", err) - } - s := new(signaling.Environment) - if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("error reading environment for signaling: %s", err) - } - - i := franchise.PlayFabIdentityProvider{ - Environment: a, - IdentityProvider: playfab.XBLIdentityProvider{ - TokenSource: xal.RefreshTokenSource(src, "http://playfab.xboxlive.com/"), - }, - } - - d := signaling.Dialer{ - NetworkID: rand.Uint64(), - } - - dial, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - sig, err := d.DialContext(dial, i, s) - if err != nil { - t.Fatalf("error dialing signaling: %s", err) - } - t.Cleanup(func() { - if err := sig.Close(); err != nil { - t.Fatalf("error closing signaling: %s", err) - } - }) - - t.Logf("Network ID: %d", d.NetworkID) - - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug, - }))) - - RegisterNetwork("nethernet", &NetherNet{ - Signaling: sig, - }) - - conn, err := Dialer{ - TokenSource: src, - }.DialTimeout("nethernet", strconv.FormatUint(networkID, 10), time.Second*15) - 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) - } - }) - - if err := conn.DoSpawn(); err != nil { - t.Fatalf("error spawning: %s", err) - } - if err := conn.WritePacket(&packet.Text{ - TextType: packet.TextTypeChat, - SourceName: conn.IdentityData().DisplayName, - Message: "Successful", - XUID: conn.IdentityData().XUID, - }); err != nil { - t.Fatalf("error writing packet: %s", err) - } - - return conn -} - -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/auth/xal/token_source.go b/minecraft/auth/xal/token_source.go index e7af700b..d0759d4b 100644 --- a/minecraft/auth/xal/token_source.go +++ b/minecraft/auth/xal/token_source.go @@ -41,17 +41,5 @@ func (r *refreshTokenSource) Token() (_ xsapi.Token, err error) { return nil, fmt.Errorf("request xbox live token: %w", err) } } - return &token{r.x}, nil -} - -type token struct { - *auth.XBLToken -} - -func (t *token) DisplayClaims() xsapi.DisplayClaims { - return t.AuthorizationToken.DisplayClaims.UserInfo[0] -} - -func (t *token) String() string { - return fmt.Sprintf("XBL3.0 x=%s;%s", t.AuthorizationToken.DisplayClaims.UserInfo[0].UserHash, t.AuthorizationToken.Token) + return r.x, nil } diff --git a/minecraft/auth/xbox.go b/minecraft/auth/xbox.go index dc1468df..aa0cf9fe 100644 --- a/minecraft/auth/xbox.go +++ b/minecraft/auth/xbox.go @@ -33,22 +33,28 @@ type XBLToken struct { Token string } - // key is the private key used to sign requests. + // key is the private key used as 'ProofKey' for authorization. + // It is used for signing requests in [XBLToken.SetAuthHeader]. key *ecdsa.PrivateKey } +// 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 returns a string that may be used for the 'Authorization' header used for Minecraft -// related endpoints that need an XBOX Live authenticated caller. +// 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 diff --git a/minecraft/dial.go b/minecraft/dial.go index 8bec1af5..e080d2ec 100644 --- a/minecraft/dial.go +++ b/minecraft/dial.go @@ -194,7 +194,7 @@ func (d Dialer) DialContext(ctx context.Context, network, address string) (conn conn.disconnectOnInvalidPacket = d.DisconnectOnInvalidPackets conn.disconnectOnUnknownPacket = d.DisconnectOnUnknownPackets - conn.disableEncryption = n.Encrypted() + 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 index 35983b05..87c00534 100644 --- a/minecraft/franchise/discovery.go +++ b/minecraft/franchise/discovery.go @@ -9,6 +9,9 @@ import ( "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 { @@ -35,11 +38,37 @@ func Discover(build string) (*Discovery, error) { 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 map[string]map[string]json.RawMessage `json:"serviceEnvironments"` - SupportedEnvironments map[string][]string `json:"supportedEnvironments"` + // 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 { @@ -55,7 +84,9 @@ func (d *Discovery) Environment(env Environment, typ string) error { return nil } +// Environment represents an environment for Discovery. type Environment interface { + // EnvironmentName returns the name of the environment. EnvironmentName() string } @@ -65,6 +96,8 @@ const ( 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", diff --git a/minecraft/franchise/playfab.go b/minecraft/franchise/playfab.go index f621da25..077e4e5f 100644 --- a/minecraft/franchise/playfab.go +++ b/minecraft/franchise/playfab.go @@ -5,19 +5,44 @@ import ( "fmt" "github.com/df-mc/go-playfab" "github.com/df-mc/go-playfab/title" - "golang.org/x/text/language" ) +// 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 *AuthorizationEnvironment + // 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 *UserConfig + + // 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") @@ -29,13 +54,7 @@ func (i PlayFabIdentityProvider) TokenConfig() (*TokenConfig, error) { i.DeviceConfig = defaultDeviceConfig(i.Environment) } if i.UserConfig == nil { - region, _ := language.English.Region() - - i.UserConfig = &UserConfig{ - Language: language.English, - LanguageCode: language.AmericanEnglish, - RegionCode: region.String(), - } + i.UserConfig = defaultUserConfig() } config := i.LoginConfig @@ -52,8 +71,8 @@ func (i PlayFabIdentityProvider) TokenConfig() (*TokenConfig, error) { user.TokenType = TokenTypePlayFab return &TokenConfig{ - Device: i.DeviceConfig, - User: &user, + Device: *i.DeviceConfig, + User: user, Environment: i.Environment, }, nil diff --git a/minecraft/franchise/signaling/conn.go b/minecraft/franchise/signaling/conn.go index c9e038fd..c5d73489 100644 --- a/minecraft/franchise/signaling/conn.go +++ b/minecraft/franchise/signaling/conn.go @@ -3,17 +3,25 @@ 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" - "nhooyr.io/websocket" - "nhooyr.io/websocket/wsjson" "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 @@ -29,30 +37,36 @@ type Conn struct { 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: json.Number(strconv.FormatUint(signal.NetworkID, 10)), + To: signal.NetworkID, Data: signal.String(), }) } -func (c *Conn) Notify(cancel <-chan struct{}, n nethernet.Notifier) { +// Notify registers a [nethernet.Notifier] to receive notifications of signals and errors. +// The [context.Context] may be used to stop receiving notifications. +func (c *Conn) Notify(ctx context.Context, n nethernet.Notifier) { c.notifiersMu.Lock() i := c.notifyCount c.notifiers[i] = n c.notifyCount++ c.notifiersMu.Unlock() - go c.notify(cancel, n, i) + go c.notify(ctx, n, i) } -func (c *Conn) notify(cancel <-chan struct{}, n nethernet.Notifier, i uint32) { +// notify goes as a background goroutine of [Conn.Notify], which notifies an error based on the +// [context.Context] or the Conn. The [nethernet.Notifier] is removed if the context is done or +// the Conn is closed. +func (c *Conn) notify(ctx context.Context, n nethernet.Notifier, i uint32) { select { case <-c.closed: n.NotifyError(net.ErrClosed) - case <-cancel: - n.NotifyError(nethernet.ErrSignalingCanceled) + case <-ctx.Done(): + n.NotifyError(ctx.Err()) } c.notifiersMu.Lock() @@ -60,44 +74,68 @@ func (c *Conn) notify(cancel <-chan struct{}, n nethernet.Notifier, i uint32) { c.notifiersMu.Unlock() } -func (c *Conn) Credentials() (*nethernet.Credentials, error) { +// Credentials blocks until a [nethernet.Credentials] is received by the Conn. +// The [context.Context] may be used to return immediately when it has been canceled or +// exceeded a deadline. An error may be returned from [context.Context] or if the Conn has been closed. + +// 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 } } -func (c *Conn) ping() { - ticker := time.NewTicker(time.Second * 15) - defer ticker.Stop() +// Close closes the Conn and unregisters any notifiers. It ensures that the Conn is closed only once. +func (c *Conn) Close() (err error) { + c.once.Do(func() { + close(c.closed) + err = c.conn.Close(websocket.StatusNormalClosure, "") + }) + return err +} - for { - select { - case <-ticker.C: - if err := c.write(Message{ - Type: MessageTypeRequestPing, - }); err != nil { - c.d.Log.Error("error writing ping", internal.ErrAttr(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 + } } - case <-c.closed: - return } - } -} + }() + defer c.Close() -func (c *Conn) read() { for { var message Message if err := wsjson.Read(context.Background(), c.conn, &message); err != nil { - _ = c.Close() return } switch message.Type { case MessageTypeCredentials: if message.From != "Server" { - c.d.Log.Warn("received credentials from non-Server", "message", message) + c.d.Log.Warn("received credentials from non-Server", slog.Any("message", message)) continue } var credentials nethernet.Credentials @@ -105,9 +143,9 @@ func (c *Conn) read() { c.d.Log.Error("error decoding credentials", internal.ErrAttr(err)) continue } - previous := c.credentials.Load() + notifyCredentials := c.credentials.Load() == nil c.credentials.Store(&credentials) - if previous == nil { + if notifyCredentials { close(c.credentialsReceived) } case MessageTypeSignal: @@ -128,20 +166,27 @@ func (c *Conn) read() { 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", "message", message) + c.d.Log.Warn("received message for unknown type", slog.Any("message", message)) } } } -func (c *Conn) write(m Message) error { - return wsjson.Write(context.Background(), c.conn, m) -} - -func (c *Conn) Close() (err error) { - c.once.Do(func() { - close(c.closed) - err = c.conn.Close(websocket.StatusNormalClosure, "") - }) - return err +// 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/conn_test.go b/minecraft/franchise/signaling/conn_test.go deleted file mode 100644 index fbb2fc9d..00000000 --- a/minecraft/franchise/signaling/conn_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package signaling - -import ( - "context" - "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" - "github.com/sandertv/gophertunnel/minecraft/franchise/internal/test" - "github.com/sandertv/gophertunnel/minecraft/protocol" - "testing" - "time" -) - -func TestDial(t *testing.T) { - discovery, err := franchise.Discover(protocol.CurrentVersion) - if err != nil { - t.Fatalf("error retrieving discovery: %s", err) - } - - a := new(franchise.AuthorizationEnvironment) - if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("error reading environment for authorization: %s", err) - } - s := new(Environment) - if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("error reading environment for signaling: %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 := franchise.PlayFabIdentityProvider{ - Environment: a, - IdentityProvider: playfab.XBLIdentityProvider{ - TokenSource: xal.RefreshTokenSource(src, "http://playfab.xboxlive.com/"), - }, - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - var d Dialer - conn, err := d.DialContext(ctx, prov, s) - if err != nil { - t.Fatalf("error dialing: %s", err) - } - t.Cleanup(func() { - if err := conn.Close(); err != nil { - t.Fatalf("error closing conn: %s", err) - } - }) - - credentials, err := conn.Credentials() - if err != nil { - t.Fatalf("error obtaining credentials: %s", err) - } - if credentials == nil { - t.Fatal("credentials is nil") - } - t.Logf("credentials obtained: %#v", credentials) -} diff --git a/minecraft/franchise/signaling/dial.go b/minecraft/franchise/signaling/dial.go index 88cb446b..f1e96d8c 100644 --- a/minecraft/franchise/signaling/dial.go +++ b/minecraft/franchise/signaling/dial.go @@ -3,23 +3,72 @@ 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" - "nhooyr.io/websocket" "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 *websocket.DialOptions + // 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 set to 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 *slog.Logger + + // Log is used to logging messages at various levels. If nil, the default + // [slog.Logger] will be set from [slog.Default]. + Log *slog.Logger } -func (d Dialer) DialContext(ctx context.Context, i franchise.IdentityProvider, env *Environment) (*Conn, error) { +// 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{} } @@ -52,7 +101,7 @@ func (d Dialer) DialContext(ctx context.Context, i franchise.IdentityProvider, e 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) + 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 } @@ -68,12 +117,5 @@ func (d Dialer) DialContext(ctx context.Context, i franchise.IdentityProvider, e notifiers: make(map[uint32]nethernet.Notifier), } go conn.read() - go conn.ping() - - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-conn.credentialsReceived: - return conn, nil - } + 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 index de300b34..eb65dda9 100644 --- a/minecraft/franchise/signaling/environment.go +++ b/minecraft/franchise/signaling/environment.go @@ -1,9 +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 string `json:"stunUri"` - TurnURI string `json:"turnUri"` + // 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"` } -func (e *Environment) EnvironmentName() string { return "signaling" } +// 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 index b605fe54..eedc3372 100644 --- a/minecraft/franchise/signaling/message.go +++ b/minecraft/franchise/signaling/message.go @@ -1,17 +1,84 @@ package signaling -import "encoding/json" +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 uint32 `json:"Type"` - // From is either a unique ID of remote network, or a string "Server". - From string `json:"From,omitempty"` - To json.Number `json:"To,omitempty"` - Data string `json:"Message,omitempty"` + // 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 ( - MessageTypeRequestPing uint32 = iota // RequestType::Ping - MessageTypeSignal // RequestType::Message - MessageTypeCredentials // RequestType::TurnAuth + // 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 index 6eaf130c..2fd5d091 100644 --- a/minecraft/franchise/token.go +++ b/minecraft/franchise/token.go @@ -15,14 +15,36 @@ import ( "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 string `json:"authorizationHeader"` - ValidUntil time.Time `json:"validUntil"` - Treatments []string `json:"treatments"` - Configurations map[string]Configuration `json:"configurations"` - TreatmentContext string `json:"treatmentContext"` + // 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) } @@ -32,6 +54,10 @@ const ( 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") @@ -73,35 +99,62 @@ func (conf TokenConfig) Token() (*Token, error) { 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 string `json:"serviceUri"` - Issuer string `json:"issuer"` + // 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 *DeviceConfig `json:"device,omitempty"` - User *UserConfig `json:"user,omitempty"` + // 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: nil, // TODO: Should this be an empty slice? + Capabilities: []string{}, GameVersion: protocol.CurrentVersion, ID: uuid.New(), Memory: strconv.FormatUint(16*(1<<30), 10), @@ -112,17 +165,45 @@ func defaultDeviceConfig(env *AuthorizationEnvironment) *DeviceConfig { } } +// 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 string `json:"applicationType,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - GameVersion string `json:"gameVersion,omitempty"` - ID uuid.UUID `json:"id,omitempty"` - Memory string `json:"memory,omitempty"` - Platform string `json:"platform,omitempty"` - PlayFabTitleID string `json:"playFabTitleId,omitempty"` - StorePlatform string `json:"storePlatform,omitempty"` - TreatmentOverrides []string `json:"treatmentOverrides,omitempty"` - Type string `json:"type,omitempty"` + // 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 ( @@ -130,6 +211,7 @@ const ( ) const ( + // CapabilityRayTracing indicates that the device is capable for ray tracing. CapabilityRayTracing = "RayTracing" ) @@ -145,12 +227,40 @@ 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 language.Tag `json:"language,omitempty"` - LanguageCode language.Tag `json:"languageCode,omitempty"` - RegionCode string `json:"regionCode,omitempty"` - Token string `json:"token,omitempty"` - TokenType string `json:"tokenType,omitempty"` + // 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 ( diff --git a/minecraft/listener.go b/minecraft/listener.go index f934cae1..477c3be2 100644 --- a/minecraft/listener.go +++ b/minecraft/listener.go @@ -140,7 +140,7 @@ func (cfg ListenConfig) Listen(network string, address string) (*Listener, error incoming: make(chan *Conn), close: make(chan struct{}), key: key, - disableEncryption: n.Encrypted(), + disableEncryption: n.DisableEncryption(), batchHeader: n.BatchHeader(), } @@ -213,6 +213,8 @@ 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 { diff --git a/minecraft/nethernet.go b/minecraft/nethernet.go index 575e6981..445fad38 100644 --- a/minecraft/nethernet.go +++ b/minecraft/nethernet.go @@ -14,6 +14,7 @@ type NetherNet struct { Signaling nethernet.Signaling } +// 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") @@ -26,10 +27,12 @@ func (n NetherNet) DialContext(ctx context.Context, address string) (net.Conn, e return d.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(address string) (NetworkListener, error) { if n.Signaling == nil { return nil, errors.New("minecraft: NetherNet.Listen: Signaling is nil") @@ -42,6 +45,8 @@ func (n NetherNet) Listen(address string) (NetworkListener, error) { return cfg.Listen(networkID, n.Signaling) } -func (NetherNet) Encrypted() bool { return true } +// 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 d12f1c35..b72c3ed3 100644 --- a/minecraft/network.go +++ b/minecraft/network.go @@ -24,11 +24,11 @@ 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) - - // Encrypted returns a bool indicating whether an encryption has already been done on the Network side, and no - // encryption is needed on Conn side. - Encrypted() bool - // BatchHeader returns the header of compressed 'batches' from Minecraft, used for encoding/decoding packets. + // 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 } diff --git a/minecraft/raknet.go b/minecraft/raknet.go index 2d33eaef..67e10f24 100644 --- a/minecraft/raknet.go +++ b/minecraft/raknet.go @@ -25,7 +25,7 @@ func (r RakNet) Listen(address string) (NetworkListener, error) { } // Encrypted ... -func (r RakNet) Encrypted() bool { return false } +func (r RakNet) DisableEncryption() bool { return false } // BatchHeader ... func (r RakNet) BatchHeader() []byte { return []byte{0xfe} } diff --git a/minecraft/room/_dial.go b/minecraft/room/_dial.go deleted file mode 100644 index 0a4b9c74..00000000 --- a/minecraft/room/_dial.go +++ /dev/null @@ -1,81 +0,0 @@ -package room - -import ( - "context" - "errors" - "fmt" - "log/slog" - "net" - "sync" - "time" -) - -type Dialer struct { - Log *slog.Logger -} - -func (d Dialer) DialContext(ctx context.Context, a ConnAnnouncer, n net.Conn, ref Reference) (*Conn, error) { - if err := a.Join(ctx, ref); err != nil { - return nil, fmt.Errorf("join: %w", err) - } - - return &Conn{ - d: d, - - announcer: a, - conn: n, - - closed: make(chan struct{}), - }, nil -} - -type Conn struct { - d Dialer - - announcer ConnAnnouncer - conn net.Conn - - closed chan struct{} - once sync.Once -} - -func (c *Conn) Read(b []byte) (int, error) { - return c.conn.Read(b) -} - -func (c *Conn) Write(b []byte) (int, error) { - return c.conn.Write(b) -} - -func (c *Conn) Close() (err error) { - c.once.Do(func() { - close(c.closed) - - errs := []error{c.conn.Close()} - if err := c.announcer.Close(); err != nil { - errs = append(errs, fmt.Errorf("close announcer: %w", err)) - } - err = errors.Join(errs...) - }) - return err -} - -func (c *Conn) LocalAddr() net.Addr { - return c.conn.LocalAddr() -} - -func (c *Conn) RemoteAddr() net.Addr { - return c.conn.RemoteAddr() -} - -func (c *Conn) SetDeadline(t time.Time) error { - return c.conn.SetDeadline(t) -} - -func (c *Conn) SetReadDeadline(t time.Time) error { - return c.conn.SetReadDeadline(t) -} - -func (c *Conn) SetWriteDeadline(t time.Time) error { - return c.conn.SetWriteDeadline(t) -} diff --git a/minecraft/room/announce.go b/minecraft/room/announce.go index e75033c5..7036ad7f 100644 --- a/minecraft/room/announce.go +++ b/minecraft/room/announce.go @@ -2,11 +2,16 @@ package room import "context" -type Reference interface { - String() string -} - +// 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/discovery.go b/minecraft/room/discovery.go deleted file mode 100644 index 039c507a..00000000 --- a/minecraft/room/discovery.go +++ /dev/null @@ -1,39 +0,0 @@ -package room - -import ( - "context" - "github.com/df-mc/go-nethernet/discovery" -) - -type DiscoveryAnnouncer struct { - Listener *discovery.Listener -} - -func (a DiscoveryAnnouncer) Announce(_ context.Context, status Status) { - a.Listener.ServerData(statusToServerData(status)) -} - -func (a DiscoveryAnnouncer) Close() error { - return a.Listener.Close() -} - -func statusToServerData(status Status) *discovery.ServerData { - return &discovery.ServerData{ - Version: 0x2, - ServerName: status.HostName, - LevelName: status.WorldName, - GameType: worldTypeToGameType(status.WorldType), - PlayerCount: int32(status.MemberCount), - MaxPlayerCount: int32(status.MaxMemberCount), - TransportLayer: status.TransportLayer, - } -} - -func worldTypeToGameType(typ string) int32 { - switch typ { - case WorldTypeCreative: - return 2 - default: - return 2 - } -} diff --git a/minecraft/room/internal/attr.go b/minecraft/room/internal/attr.go index 0a1d146f..9c826ab9 100644 --- a/minecraft/room/internal/attr.go +++ b/minecraft/room/internal/attr.go @@ -4,4 +4,6 @@ import "log/slog" const errorKey = "error" -func ErrAttr(err error) slog.Attr { return slog.Any(errorKey, err) } +func ErrAttr(err error) slog.Attr { + return slog.Any(errorKey, err) +} diff --git a/minecraft/room/listener.go b/minecraft/room/listener.go index cb59993e..ee59c16f 100644 --- a/minecraft/room/listener.go +++ b/minecraft/room/listener.go @@ -1,116 +1,165 @@ package room import ( - "context" "errors" - "fmt" + "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 - Log *slog.Logger + + // 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 } -func (conf ListenConfig) Listen(a Announcer, n minecraft.NetworkListener) (*Listener, error) { +// 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()) } - if conf.Log == nil { - conf.Log = slog.Default() - } - l := &Listener{ + return &Listener{ conf: conf, - announcer: a, - listener: n, + n: n, - closed: make(chan struct{}), + closed: make(chan struct{}, 1), } - - return l, nil } +// 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 - announcer Announcer - listener minecraft.NetworkListener + n minecraft.NetworkListener - closed chan struct{} - once sync.Once + closed chan struct{} // Notifies that the Listener has been closed. + once sync.Once // Closes Listener only once. } -func (l *Listener) ID() int64 { - return l.listener.ID() -} +// 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() } -func (l *Listener) PongData(data []byte) { - l.listener.PongData(data) -} +// Addr returns the [net.Addr] of the underlying [minecraft.NetworkListener]. +func (l *Listener) Addr() net.Addr { return l.n.Addr() } -func (l *Listener) Accept() (net.Conn, error) { - return l.listener.Accept() -} +// ID returns the unique ID of the underlying [minecraft.NetworkListener]. +func (l *Listener) ID() int64 { return l.n.ID() } -func (l *Listener) Addr() net.Addr { - return l.listener.Addr() -} +// PongData updates the pong data on the underlying [minecraft.NetworkListener]. +func (l *Listener) PongData(data []byte) { l.n.PongData(data) } -func (l *Listener) ServerStatus(serverStatus minecraft.ServerStatus) { +// 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() - - status.HostName = serverStatus.ServerSubName - status.WorldName = serverStatus.ServerName - - status.MemberCount = serverStatus.PlayerCount - status.MaxMemberCount = serverStatus.MaxPlayers - - // TODO - status.SupportedConnections = []Connection{ - { - ConnectionType: ConnectionTypeWebSocketsWebRTCSignaling, // ... - NetherNetID: uint64(l.listener.ID()), - WebRTCNetworkID: uint64(l.listener.ID()), - }, + 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 + } + if status.WebRTCNetworkID == 0 { + status.WebRTCNetworkID = addr.NetworkID + } + status.SupportedConnections = append(status.SupportedConnections, Connection{ + ConnectionType: ConnectionTypeWebSocketsWebRTCSignaling, + NetherNetID: addr.NetworkID, + WebRTCNetworkID: 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)) + } } - status.WebRTCNetworkID = uint64(l.listener.ID()) - go l.announce(status) - return -} - -func (l *Listener) announce(status Status) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - select { - case <-l.closed: - cancel() + 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)) } - }() - - if err := l.announcer.Announce(ctx, status); err != nil { - 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) - - fmt.Println("close called") - - errs := []error{l.listener.Close()} - if err := l.announcer.Close(); err != nil { - errs = append(errs, fmt.Errorf("close announcer: %w", err)) - } - err = errors.Join(errs...) + 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 index 7aaf02a0..24ab3f80 100644 --- a/minecraft/room/listener_test.go +++ b/minecraft/room/listener_test.go @@ -4,152 +4,118 @@ import ( "context" "encoding/json" "fmt" - "github.com/df-mc/go-playfab" - "github.com/go-gl/mathgl/mgl32" "github.com/sandertv/gophertunnel/minecraft" "github.com/sandertv/gophertunnel/minecraft/auth" "github.com/sandertv/gophertunnel/minecraft/auth/xal" - "github.com/sandertv/gophertunnel/minecraft/franchise" "github.com/sandertv/gophertunnel/minecraft/franchise/signaling" - "github.com/sandertv/gophertunnel/minecraft/protocol" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" "golang.org/x/oauth2" "math/rand" + "net" "os" "strconv" + "strings" "testing" "time" ) +// TestListen demonstrates a world displayed in the friend list. func TestListen(t *testing.T) { - discovery, err := franchise.Discover(protocol.CurrentVersion) - if err != nil { - t.Fatalf("error retrieving discovery: %s", err) - } - a := new(franchise.AuthorizationEnvironment) - if err := discovery.Environment(a, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("error reading environment for authorization: %s", err) - } - s := new(signaling.Environment) - if err := discovery.Environment(s, franchise.EnvironmentTypeProduction); err != nil { - t.Fatalf("error reading environment for signaling: %s", err) - } - tok, err := readToken("../franchise/internal/test/auth.tok", auth.TokenSource) if err != nil { t.Fatalf("error reading token: %s", err) } src := auth.RefreshTokenSource(tok) - i := franchise.PlayFabIdentityProvider{ - Environment: a, - IdentityProvider: playfab.XBLIdentityProvider{ - TokenSource: xal.RefreshTokenSource(src, playfab.RelyingParty), - }, - } - d := signaling.Dialer{ NetworkID: rand.Uint64(), } - - dial, cancel := context.WithTimeout(context.Background(), time.Second*15) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) defer cancel() - signals, err := d.DialContext(dial, i, s) + 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.Fatalf("error closing signaling: %s", err) + t.Errorf("error closing signaling: %s", err) } }) - x := xal.RefreshTokenSource(src, "http://xboxlive.com") - xt, err := x.Token() + x, err := xal.RefreshTokenSource(src, "http://xboxlive.com").Token() if err != nil { - t.Fatalf("error requesting token: %s", err) + t.Fatal(err) } - var p SessionPublishConfig - announcer := p.New(x) - status := DefaultStatus() - status.OwnerID = xt.DisplayClaims().XUID - + 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), }, - Announcer: announcer, }) - // The most of the code below has been copied from minecraft/example_listener_test.go. - - // Create a minecraft.Listener with a specific name to be displayed as MOTD in the server list. - name := "MOTD of this server" - cfg := minecraft.ListenConfig{ - StatusProvider: minecraft.NewStatusProvider(name, "Gophertunnel"), - } - - listener, err := cfg.Listen("room", strconv.FormatUint(d.NetworkID, 10)) + l, err := minecraft.Listen("room", strconv.FormatUint(d.NetworkID, 10)) if err != nil { t.Fatalf("error listening: %s", err) } t.Cleanup(func() { - if err := listener.Close(); err != nil { - t.Fatalf("error closing listener: %s", err) + if err := l.Close(); err != nil { + t.Errorf("error closing listener: %s", err) } }) for { - netConn, err := listener.Accept() + n, err := l.Accept() if err != nil { return } - c := netConn.(*minecraft.Conn) - if err := c.StartGame(minecraft.GameData{ - WorldName: "NetherNet", - WorldSeed: 0, - Difficulty: 0, - EntityUniqueID: rand.Int63(), - EntityRuntimeID: rand.Uint64(), - PlayerGameMode: 1, - PlayerPosition: mgl32.Vec3{}, - WorldSpawn: protocol.BlockPos{}, - WorldGameMode: 1, - Time: rand.Int63(), - PlayerPermissions: 2, - // Allow inviting player into the world. - GamePublishSetting: 3, + + 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.Fatalf("error starting game: %s", err) + t.Errorf("error starting game: %s", err) } + // Try reading and decoding deferred packets. go func() { - defer func() { - if err := c.Close(); err != nil { - t.Errorf("error closing connection: %s", err) - } - }() for { - pk, err := c.ReadPacket() + pk, err := conn.ReadPacket() if err != nil { - // No output for errors which has occurred during decoding a packet, - // since minecraft.Conn does not return net.ErrClosed. + 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.Message == "Close" { - if err := listener.Disconnect(c, "Connection closed"); err != nil { + 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 := listener.Close(); err != nil { + if err := l.Close(); err != nil { t.Errorf("error closing listener: %s", err) } + return } } } diff --git a/minecraft/room/mpsd.go b/minecraft/room/mpsd.go index c43527c5..55b441a4 100644 --- a/minecraft/room/mpsd.go +++ b/minecraft/room/mpsd.go @@ -12,107 +12,105 @@ import ( "sync" ) -var serviceConfigID = uuid.MustParse("4fc10100-5f7a-4470-899b-280835760c07") +// 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 -func NewSessionAnnouncer(s *mpsd.Session) *SessionAnnouncer { - return &SessionAnnouncer{ - s: s, - } -} + // SessionReference specifies the internal ID of the session being published when the Session is nil. + SessionReference mpsd.SessionReference -type SessionPublishConfig struct { + // PublishConfig specifies custom configuration for publishing a session when the Session is nil. PublishConfig mpsd.PublishConfig - Reference mpsd.SessionReference -} -func (conf SessionPublishConfig) New(src xsapi.TokenSource) *SessionAnnouncer { - return &SessionAnnouncer{ - p: conf, - src: src, - } -} + // Session is the session where the Status will be committed. If nil, a [mpsd.Session] will be published + // using the PublishConfig. + Session *mpsd.Session -func (conf SessionPublishConfig) publish(ctx context.Context, src xsapi.TokenSource) (*mpsd.Session, error) { - if conf.Reference.ServiceConfigID == uuid.Nil { - conf.Reference.ServiceConfigID = serviceConfigID - } - if conf.Reference.TemplateName == "" { - conf.Reference.TemplateName = "MinecraftLobby" - } - if conf.Reference.Name == "" { - conf.Reference.Name = strings.ToUpper(uuid.NewString()) - } + // custom properties are encoded from Status for comparison in announcements. + custom []byte - s, err := conf.PublishConfig.PublishContext(ctx, src, conf.Reference) - if err != nil { - return nil, err - } - - return s, nil -} - -type SessionAnnouncer struct { - p SessionPublishConfig - - src xsapi.TokenSource - - s *mpsd.Session - description *mpsd.SessionDescription - mu sync.Mutex + // Mutex ensures atomic read/write access to the fields. + sync.Mutex } -func (a *SessionAnnouncer) Announce(ctx context.Context, status Status) error { - a.mu.Lock() - defer a.mu.Unlock() +// 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 status: %w", err) + return fmt.Errorf("encode: %w", err) } - a.updateDescription(status) - if bytes.Compare(a.description.Properties.Custom, custom) == 0 { - return nil // Avoid committing same properties + if bytes.Compare(custom, a.custom) == 0 { + return nil + } else { + a.custom = custom } - a.description.Properties.Custom = custom - if a.s == nil { - a.p.PublishConfig.Description = a.description - s, err := a.p.publish(ctx, a.src) + 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) } - a.s = s return nil } - - commit, err := a.s.Commit(ctx, a.description) - if err == nil { - a.description = commit.SessionDescription - } + _, err = a.Session.Commit(ctx, a.description(status)) return err } -func (a *SessionAnnouncer) Close() error { - return a.s.Close() -} - -func (a *SessionAnnouncer) updateDescription(status Status) { - if a.description == nil { - a.description = &mpsd.SessionDescription{} - } - if a.description.Properties == nil { - a.description.Properties = &mpsd.SessionProperties{} - } - if a.description.Properties.System == nil { - a.description.Properties.System = &mpsd.SessionPropertiesSystem{} +// 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, + }, } +} - switch status.BroadcastSetting { +// 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: - a.description.Properties.System.JoinRestriction = mpsd.SessionRestrictionFollowed - a.description.Properties.System.ReadRestriction = mpsd.SessionRestrictionFollowed + return mpsd.SessionRestrictionFollowed, mpsd.SessionRestrictionFollowed case BroadcastSettingInviteOnly: - a.description.Properties.System.JoinRestriction = mpsd.SessionRestrictionLocal - a.description.Properties.System.ReadRestriction = mpsd.SessionRestrictionLocal + 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 index b4b4e30c..05d57352 100644 --- a/minecraft/room/network.go +++ b/minecraft/room/network.go @@ -6,40 +6,43 @@ import ( "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 - Announcer Announcer - + // 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) } -func (n Network) PingContext(ctx context.Context, address string) (response []byte, err error) { +// 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) { - listener, err := n.Network.Listen(address) - if err != nil { - return nil, err - } - - l, err := n.ListenConfig.Listen(n.Announcer, listener) + l, err := n.Network.Listen(address) if err != nil { return nil, err } - - return l, nil + return n.ListenConfig.Wrap(l), nil } -func (n Network) Encrypted() bool { - return n.Network.Encrypted() -} +// DisableEncryption ... +func (n Network) DisableEncryption() bool { return n.Network.DisableEncryption() } -func (n Network) BatchHeader() []byte { - return n.Network.BatchHeader() -} +// BatchHeader ... +func (n Network) BatchHeader() []byte { return n.Network.BatchHeader() } diff --git a/minecraft/room/status.go b/minecraft/room/status.go index d72bc0cf..039ca4ee 100644 --- a/minecraft/room/status.go +++ b/minecraft/room/status.go @@ -18,7 +18,7 @@ type Status struct { Protocol int32 `json:"protocol"` MemberCount int `json:"MemberCount"` MaxMemberCount int `json:"MaxMemberCount"` - BroadcastSetting uint32 `json:"BroadcastSetting"` + BroadcastSetting int32 `json:"BroadcastSetting"` LanGame bool `json:"LanGame"` IsEditorWorld bool `json:"isEditorWorld"` TransportLayer int32 `json:"TransportLayer"` @@ -35,7 +35,7 @@ type Connection struct { HostPort uint16 `json:"HostPort"` NetherNetID uint64 `json:"NetherNetId"` WebRTCNetworkID uint64 `json:"WebRTCNetworkId"` - RakNetGUID string `json:"RakNetGUID"` + RakNetGUID string `json:"RakNetGUID,omitempty"` } const ( @@ -48,7 +48,7 @@ const ( ) const ( - BroadcastSettingInviteOnly uint32 = iota + 1 + BroadcastSettingInviteOnly int32 = iota + 1 BroadcastSettingFriendsOnly BroadcastSettingFriendsOfFriends ) @@ -61,6 +61,7 @@ const ( const ( ConnectionTypeWebSocketsWebRTCSignaling uint32 = 3 + ConnectionTypeUPNP uint32 = 6 ) type StatusProvider interface { @@ -91,7 +92,6 @@ func DefaultStatus() Status { Protocol: protocol.CurrentProtocol, BroadcastSetting: BroadcastSettingFriendsOfFriends, LanGame: true, - TransportLayer: TransportLayerNetherNet, OnlineCrossPlatformGame: true, CrossPlayDisabled: false, TitleID: 0, From 599061478c76f2430ffceb156d66ca8f4b6e52c4 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Thu, 19 Sep 2024 03:27:33 +0900 Subject: [PATCH 14/14] minecraft: Support 1.21.30 --- go.mod | 7 ++-- go.sum | 8 ++--- minecraft/auth/xbox.go | 2 +- minecraft/franchise/signaling/conn.go | 50 ++++++++++++++++----------- minecraft/franchise/signaling/dial.go | 6 ++-- minecraft/nethernet.go | 17 +++++---- minecraft/room/listener.go | 8 ++--- minecraft/room/listener_test.go | 5 +-- minecraft/room/status.go | 15 +++----- 9 files changed, 58 insertions(+), 60 deletions(-) diff --git a/go.mod b/go.mod index 4dc831ce..2f93df03 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ 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.27.0 golang.org/x/oauth2 v0.21.0 @@ -25,7 +27,6 @@ require ( 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/logging v0.2.2 // 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 @@ -36,7 +37,6 @@ require ( 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/pion/webrtc/v4 v4.0.0-beta.29.0.20240826201411-3147b45f9db5 // 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 @@ -44,8 +44,7 @@ require ( ) replace ( - github.com/df-mc/go-nethernet => github.com/lactyy/go-nethernet v0.0.0-20240911083526-16e64f38dc39 + 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 - github.com/pion/sctp => github.com/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3 ) diff --git a/go.sum b/go.sum index 9b41fcea..a79efa79 100644 --- a/go.sum +++ b/go.sum @@ -15,14 +15,12 @@ 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-20240911083526-16e64f38dc39 h1:AilC/4ePyYxmXpSVaEX2zt0snQX6RG7FXkv6MHU5XUI= -github.com/lactyy/go-nethernet v0.0.0-20240911083526-16e64f38dc39/go.mod h1:/pGUz0nwAHcpKynNyRz1sXVsF0klaevDsMkPXsdP7mM= +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/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3 h1:Nikw9jHHbZZgeN+YugbVOldbv+0PoZVm4ZSMSGItpeU= -github.com/lactyy/sctp v0.0.0-20240822210319-2eae0bcbc9f3/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= 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= @@ -45,6 +43,8 @@ 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= diff --git a/minecraft/auth/xbox.go b/minecraft/auth/xbox.go index aa0cf9fe..0339f091 100644 --- a/minecraft/auth/xbox.go +++ b/minecraft/auth/xbox.go @@ -33,7 +33,7 @@ type XBLToken struct { Token string } - // key is the private key used as 'ProofKey' for authorization. + // key is the private key used as 'ProofKey' for authentication. // It is used for signing requests in [XBLToken.SetAuthHeader]. key *ecdsa.PrivateKey } diff --git a/minecraft/franchise/signaling/conn.go b/minecraft/franchise/signaling/conn.go index c5d73489..dc7f61d9 100644 --- a/minecraft/franchise/signaling/conn.go +++ b/minecraft/franchise/signaling/conn.go @@ -46,38 +46,31 @@ func (c *Conn) Signal(signal *nethernet.Signal) error { }) } -// Notify registers a [nethernet.Notifier] to receive notifications of signals and errors. -// The [context.Context] may be used to stop receiving notifications. -func (c *Conn) Notify(ctx context.Context, n nethernet.Notifier) { +// 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() - go c.notify(ctx, n, i) + return c.stopFunc(i, n) } -// notify goes as a background goroutine of [Conn.Notify], which notifies an error based on the -// [context.Context] or the Conn. The [nethernet.Notifier] is removed if the context is done or -// the Conn is closed. -func (c *Conn) notify(ctx context.Context, n nethernet.Notifier, i uint32) { - select { - case <-c.closed: - n.NotifyError(net.ErrClosed) - case <-ctx.Done(): - n.NotifyError(ctx.Err()) - } +// 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() + c.notifiersMu.Lock() + delete(c.notifiers, i) + c.notifiersMu.Unlock() + } } -// Credentials blocks until a [nethernet.Credentials] is received by the Conn. -// The [context.Context] may be used to return immediately when it has been canceled or -// exceeded a deadline. An error may be returned from [context.Context] or if the Conn has been closed. - // 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. @@ -92,9 +85,24 @@ func (c *Conn) Credentials(ctx context.Context) (*nethernet.Credentials, error) } } +// 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, "") }) diff --git a/minecraft/franchise/signaling/dial.go b/minecraft/franchise/signaling/dial.go index f1e96d8c..7f5d5ec9 100644 --- a/minecraft/franchise/signaling/dial.go +++ b/minecraft/franchise/signaling/dial.go @@ -27,9 +27,9 @@ type Dialer struct { // will be overridden with a [franchise.Transport] for authorization. Options *websocket.DialOptions - // NetworkID specifies a unique ID for the network. If set to zero, a random - // value will be automatically set from [rand.Uint64]. It is included in the URI - // for establishing a WebSocket connection. + // 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 diff --git a/minecraft/nethernet.go b/minecraft/nethernet.go index 445fad38..08c1290e 100644 --- a/minecraft/nethernet.go +++ b/minecraft/nethernet.go @@ -12,6 +12,11 @@ import ( // 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 ... @@ -23,8 +28,7 @@ func (n NetherNet) DialContext(ctx context.Context, address string) (net.Conn, e if err != nil { return nil, fmt.Errorf("parse network ID: %w", err) } - var d nethernet.Dialer - return d.DialContext(ctx, networkID, n.Signaling) + return n.Dialer.DialContext(ctx, networkID, n.Signaling) } // PingContext ... @@ -33,16 +37,11 @@ func (n NetherNet) PingContext(context.Context, string) ([]byte, error) { } // Listen ... -func (n NetherNet) Listen(address string) (NetworkListener, error) { +func (n NetherNet) Listen(string) (NetworkListener, error) { if n.Signaling == nil { return nil, errors.New("minecraft: NetherNet.Listen: Signaling is nil") } - networkID, err := strconv.ParseUint(address, 10, 64) - if err != nil { - return nil, fmt.Errorf("parse network ID: %w", err) - } - var cfg nethernet.ListenConfig - return cfg.Listen(networkID, n.Signaling) + return n.ListenConfig.Listen(n.Signaling) } // DisableEncryption ... diff --git a/minecraft/room/listener.go b/minecraft/room/listener.go index ee59c16f..e87a5d7a 100644 --- a/minecraft/room/listener.go +++ b/minecraft/room/listener.go @@ -95,13 +95,9 @@ func (l *Listener) ServerStatus(server minecraft.ServerStatus) { if status.TransportLayer == 0 { status.TransportLayer = TransportLayerNetherNet } - if status.WebRTCNetworkID == 0 { - status.WebRTCNetworkID = addr.NetworkID - } status.SupportedConnections = append(status.SupportedConnections, Connection{ - ConnectionType: ConnectionTypeWebSocketsWebRTCSignaling, - NetherNetID: addr.NetworkID, - WebRTCNetworkID: addr.NetworkID, + ConnectionType: ConnectionTypeWebSocketsWebRTCSignaling, + NetherNetID: addr.NetworkID, }) case *net.UDPAddr: if status.TransportLayer == 0 { diff --git a/minecraft/room/listener_test.go b/minecraft/room/listener_test.go index 24ab3f80..09ad92f6 100644 --- a/minecraft/room/listener_test.go +++ b/minecraft/room/listener_test.go @@ -13,7 +13,6 @@ import ( "math/rand" "net" "os" - "strconv" "strings" "testing" "time" @@ -61,7 +60,7 @@ func TestListen(t *testing.T) { }, }) - l, err := minecraft.Listen("room", strconv.FormatUint(d.NetworkID, 10)) + l, err := minecraft.Listen("room", "") if err != nil { t.Fatalf("error listening: %s", err) } @@ -92,6 +91,8 @@ func TestListen(t *testing.T) { t.Errorf("error starting game: %s", err) } + t.Log(conn.ClientData().ServerAddress) + // Try reading and decoding deferred packets. go func() { for { diff --git a/minecraft/room/status.go b/minecraft/room/status.go index 039ca4ee..4546cc70 100644 --- a/minecraft/room/status.go +++ b/minecraft/room/status.go @@ -22,7 +22,6 @@ type Status struct { LanGame bool `json:"LanGame"` IsEditorWorld bool `json:"isEditorWorld"` TransportLayer int32 `json:"TransportLayer"` - WebRTCNetworkID uint64 `json:"WebRTCNetworkId"` OnlineCrossPlatformGame bool `json:"OnlineCrossPlatformGame"` CrossPlayDisabled bool `json:"CrossPlayDisabled"` TitleID int64 `json:"TitleId"` @@ -30,12 +29,11 @@ type Status struct { } type Connection struct { - ConnectionType uint32 `json:"ConnectionType"` - HostIPAddress string `json:"HostIpAddress"` - HostPort uint16 `json:"HostPort"` - NetherNetID uint64 `json:"NetherNetId"` - WebRTCNetworkID uint64 `json:"WebRTCNetworkId"` - RakNetGUID string `json:"RakNetGUID,omitempty"` + ConnectionType uint32 `json:"ConnectionType"` + HostIPAddress string `json:"HostIpAddress"` + HostPort uint16 `json:"HostPort"` + NetherNetID uint64 `json:"NetherNetId"` + RakNetGUID string `json:"RakNetGUID,omitempty"` } const ( @@ -101,9 +99,6 @@ func DefaultStatus() Status { func NetherNetID(status Status) (uint64, bool) { for _, c := range status.SupportedConnections { if c.ConnectionType == ConnectionTypeWebSocketsWebRTCSignaling { - if c.WebRTCNetworkID != 0 { - return c.WebRTCNetworkID, true - } if c.NetherNetID != 0 { return c.NetherNetID, true }