From 81c40bed1a6b4d28b270b6ed193f4e5d1b405dc5 Mon Sep 17 00:00:00 2001 From: Ilya Grigoriev Date: Thu, 3 Oct 2024 15:49:34 -0700 Subject: [PATCH 01/34] docs: fix description of `wush port-forward` (#52) Signed-off-by: Ilya Grigoriev --- cmd/wush/portforward.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/wush/portforward.go b/cmd/wush/portforward.go index 6bed4b6..828ea2a 100644 --- a/cmd/wush/portforward.go +++ b/cmd/wush/portforward.go @@ -41,8 +41,8 @@ func portForwardCmd() *serpent.Command { ) return &serpent.Command{ Use: "port-forward", - Short: "Transfer files.", - Long: "Transfer files to a " + cliui.Code("wush") + " peer.\n" + formatExamples( + Short: "Forward TCP or UDP ports", + Long: "Redirect data between local ports and ports on the remote " + cliui.Code("wush") + " peer.\n" + formatExamples( example{ Description: "Port forward a single TCP port from 1234 in the peer to port 5678 on your local machine", Command: "wush port-forward --tcp 5678:1234", From f1dd762d68b362addc24615788c3dac377282451 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:05:55 -0500 Subject: [PATCH 02/34] chore(deps): bump actions/checkout from 4.2.0 to 4.2.1 (#60) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0d449cc..2671df5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,7 +16,7 @@ jobs: goreleaser: runs-on: ubuntu-latest-8-cores steps: - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: # Allow goreleaser to access older tag information. fetch-depth: 0 From 1ba20be5a4c70bff0915cd549a5c76dd46b74415 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:07:07 -0500 Subject: [PATCH 03/34] chore(deps): bump golang.org/x/sys from 0.25.0 to 0.26.0 (#59) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f3ab996..ca66d27 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( golang.org/x/crypto v0.27.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.29.0 - golang.org/x/sys v0.25.0 + golang.org/x/sys v0.26.0 golang.org/x/term v0.24.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da tailscale.com v1.70.0 diff --git a/go.sum b/go.sum index bb0787f..b242455 100644 --- a/go.sum +++ b/go.sum @@ -700,8 +700,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.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.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= From 6325ef4b5a97410971ab64ae8298783d6c00ff2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:07:30 -0500 Subject: [PATCH 04/34] chore(deps): bump github.com/schollz/progressbar/v3 from 3.16.0 to 3.16.1 (#58) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ca66d27..fefb5b2 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/pion/stun/v3 v3.0.0 github.com/prometheus/client_golang v1.20.4 github.com/puzpuzpuz/xsync/v3 v3.4.0 - github.com/schollz/progressbar/v3 v3.16.0 + github.com/schollz/progressbar/v3 v3.16.1 github.com/spf13/afero v1.11.0 github.com/valyala/fasthttp v1.56.0 go4.org/mem v0.0.0-20220726221520-4f986261bf13 diff --git a/go.sum b/go.sum index b242455..2c27946 100644 --- a/go.sum +++ b/go.sum @@ -481,8 +481,8 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= -github.com/schollz/progressbar/v3 v3.16.0 h1:+MbBim/cE9DqDb8UXRfLJ6RZdyDkXG1BDy/sWc5s0Mc= -github.com/schollz/progressbar/v3 v3.16.0/go.mod h1:lLiKjKJ9/yzc9Q8jk+sVLfxWxgXKsktvUf6TO+4Y2nw= +github.com/schollz/progressbar/v3 v3.16.1 h1:RnF1neWZFzLCoGx8yp1yF7SDl4AzNDI5y4I0aUJRrZQ= +github.com/schollz/progressbar/v3 v3.16.1/go.mod h1:I2ILR76gz5VXqYMIY/LdLecvMHDPVcQm3W/MSKi1TME= github.com/secure-systems-lab/go-securesystemslib v0.7.0 h1:OwvJ5jQf9LnIAS83waAjPbcMsODrTQUpJ02eNLUoxBg= github.com/secure-systems-lab/go-securesystemslib v0.7.0/go.mod h1:/2gYnlnHVQ6xeGtfIqFy7Do03K4cdCY0A/GlJLDKLHI= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= From 6270fa394a50538d76a18ec14defda1ec181d774 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:29:14 -0500 Subject: [PATCH 05/34] chore(deps): bump golang.org/x/net from 0.29.0 to 0.30.0 (#56) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index fefb5b2..6c6ff13 100644 --- a/go.mod +++ b/go.mod @@ -25,11 +25,11 @@ require ( github.com/spf13/afero v1.11.0 github.com/valyala/fasthttp v1.56.0 go4.org/mem v0.0.0-20220726221520-4f986261bf13 - golang.org/x/crypto v0.27.0 + golang.org/x/crypto v0.28.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 - golang.org/x/net v0.29.0 + golang.org/x/net v0.30.0 golang.org/x/sys v0.26.0 - golang.org/x/term v0.24.0 + golang.org/x/term v0.25.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da tailscale.com v1.70.0 ) @@ -206,7 +206,7 @@ require ( golang.org/x/mod v0.21.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.6.0 // indirect golang.org/x/tools v0.25.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index 2c27946..123507e 100644 --- a/go.sum +++ b/go.sum @@ -624,8 +624,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a h1:8qmSSA8Gz/1kTrCe0nqR0R3Gb/NDhykzWw2q2mWZydM= @@ -652,8 +652,8 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -709,8 +709,8 @@ golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -721,8 +721,8 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 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.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From cb58cbdf32671675d38ff1edd86779ef500d4752 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 14 Oct 2024 13:19:46 -0500 Subject: [PATCH 06/34] chore(site): fix wasm build, update build scripts --- cmd/wasm/main.go | 2 +- site/build_wasm.sh | 8 ++++++++ site/deploy.sh | 7 +++++-- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100755 site/build_wasm.sh diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index b0560e6..ea8251f 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -74,7 +74,7 @@ func newWush(jsConfig js.Value) map[string]any { panic(err) } - s, err := tsserver.NewServer(ctx, logger, send) + s, err := tsserver.NewServer(ctx, logger, send, dm) if err != nil { panic(err) } diff --git a/site/build_wasm.sh b/site/build_wasm.sh new file mode 100755 index 0000000..b977331 --- /dev/null +++ b/site/build_wasm.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -eux + +cd "$(dirname "$0")" + +GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o ./app/assets/main.wasm ../cmd/wasm +wasm-opt -Oz ./app/assets/main.wasm -o ./app/assets/main.wasm --enable-bulk-memory diff --git a/site/deploy.sh b/site/deploy.sh index d79f291..8c6c04e 100755 --- a/site/deploy.sh +++ b/site/deploy.sh @@ -4,15 +4,18 @@ set -eux cd "$(dirname "$0")" -GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o ./app/assets/main.wasm ../cmd/wasm -wasm-opt -Oz ./app/assets/main.wasm -o ./app/assets/main.wasm --enable-bulk-memory +./build_wasm.sh pnpm build +# The wasm artifact is too big uncompressed to serve directly from cf pages, so +# we manually serve the gzipped wasm from google storage. I would use r2 but +# it's broken and won't let us activate. gsutil -h "Content-Type:application/wasm" \ -h "Content-Encoding:gzip" \ -h "Cache-Control:public,max-age=31536000,immutable" \ cp ./build/client/assets/main-*.wasm.gz gs://wush-assets-prod/assets/ +# rm the wasm files so they don't get uploaded to cf pages. rm ./build/client/assets/main-*.wasm* wrangler pages deploy ./build/client From a2e0b4243d97488f37498813074d9458fd47c19f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 14 Oct 2024 20:31:52 +0200 Subject: [PATCH 07/34] chore: improve consistency of help output (#61) --- cmd/wush/cp.go | 6 +++--- cmd/wush/main.go | 10 +++++----- cmd/wush/portforward.go | 14 +++++++------- cmd/wush/rsync.go | 9 ++++----- cmd/wush/serve.go | 6 ++---- cmd/wush/ssh.go | 5 ++--- cmd/wush/version.go | 2 +- 7 files changed, 24 insertions(+), 28 deletions(-) diff --git a/cmd/wush/cp.go b/cmd/wush/cp.go index 41f1372..349e640 100644 --- a/cmd/wush/cp.go +++ b/cmd/wush/cp.go @@ -133,10 +133,10 @@ func cpCmd() *serpent.Command { ) return &serpent.Command{ Use: "cp ", - Short: "Transfer files.", - Long: "Transfer files to a " + cliui.Code("wush") + " peer.\n" + formatExamples( + Short: "Transfer files to a wush server.", + Long: formatExamples( example{ - Description: "Copy a local file to the remote", + Description: "Copy a local file to the server", Command: "wush cp local-file.txt", }, ), diff --git a/cmd/wush/main.go b/cmd/wush/main.go index cf935a6..90dd767 100644 --- a/cmd/wush/main.go +++ b/cmd/wush/main.go @@ -17,25 +17,25 @@ func main() { var ( showVersion bool - fmtLong = "wush %s - peer-to-peer file transfers and shells\n" + fmtLong = "wush %s - WireGuard-powered peer-to-peer file transfer and shell\n" ) cmd := &serpent.Command{ Use: "wush ", Long: fmt.Sprintf(fmtLong, getBuildInfo().version) + formatExamples( example{ - Description: "Start the wush server", + Description: "Start the wush server to accept incoming connections", Command: "wush serve", }, example{ - Description: "Open a shell to the wush host", + Description: "Open a shell to a wush server", Command: "wush ssh", }, example{ - Description: "Transfer files to the wush host using rsync", + Description: "Transfer files to a wush server with rsync", Command: "wush rsync local-file.txt :/path/to/remote/file", }, example{ - Description: "Copy a single file to the host", + Description: "Copy a single file to a wush server", Command: "wush cp local-file.txt", }, ), diff --git a/cmd/wush/portforward.go b/cmd/wush/portforward.go index 828ea2a..73d1204 100644 --- a/cmd/wush/portforward.go +++ b/cmd/wush/portforward.go @@ -41,26 +41,26 @@ func portForwardCmd() *serpent.Command { ) return &serpent.Command{ Use: "port-forward", - Short: "Forward TCP or UDP ports", - Long: "Redirect data between local ports and ports on the remote " + cliui.Code("wush") + " peer.\n" + formatExamples( + Short: "Forward ports from the wush server.", + Long: formatExamples( example{ - Description: "Port forward a single TCP port from 1234 in the peer to port 5678 on your local machine", + Description: "Forward a single TCP port from 1234 on the server to port 5678 on your local machine", Command: "wush port-forward --tcp 5678:1234", }, example{ - Description: "Port forward a single UDP port from port 9000 to port 9000 on your local machine", + Description: "Forward a single UDP port", Command: "wush port-forward --udp 9000", }, example{ - Description: "Port forward multiple TCP ports and a UDP port", + Description: "Forward multiple TCP ports and a UDP port", Command: "wush port-forward --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53", }, example{ - Description: "Port forward multiple ports (TCP or UDP) in condensed syntax", + Description: "Forward multiple ports (TCP or UDP) in condensed syntax", Command: "wush port-forward --tcp 8080,9000:3000,9090-9092,10000-10002:10010-10012", }, example{ - Description: "Port forward specifying the local address to bind to", + Description: "Forward specifying the local address to bind", Command: "wush port-forward --tcp 1.2.3.4:8080:8080", }, ), diff --git a/cmd/wush/rsync.go b/cmd/wush/rsync.go index 9e876a7..c64a02c 100644 --- a/cmd/wush/rsync.go +++ b/cmd/wush/rsync.go @@ -24,17 +24,16 @@ func rsyncCmd() *serpent.Command { ) return &serpent.Command{ Use: "rsync [flags] -- [rsync args]", - Short: "Transfer files over rsync.", - Long: "Runs rsync to transfer files to a " + cliui.Code("wush") + " peer. " + - "Use " + cliui.Code("wush serve") + " on the computer you would like to connect to." + + Short: "Transfer files with rsync to/from a wush server.", + Long: "Use " + cliui.Code("wush serve") + " on the computer you would like to transfer files to." + "\n\n" + formatExamples( example{ - Description: "Sync a local file to the remote", + Description: "Upload a local file", Command: "wush rsync /local/path :/remote/path", }, example{ - Description: "Download a remote file to the local computer", + Description: "Download a remote file", Command: "wush rsync :/remote/path /local/path", }, example{ diff --git a/cmd/wush/serve.go b/cmd/wush/serve.go index f82dce7..2dc2a92 100644 --- a/cmd/wush/serve.go +++ b/cmd/wush/serve.go @@ -44,10 +44,8 @@ func serveCmd() *serpent.Command { dm = new(tailcfg.DERPMap) ) return &serpent.Command{ - Use: "serve", - Aliases: []string{"host"}, - Short: "Run the wush server.", - Long: "Runs the wush server. Allows other wush CLIs to connect to this computer.", + Use: "serve", + Short: "Run the wush server. Allow wush clients to connect.", Middleware: serpent.Chain( derpMap(&derpmapFi, dm), ), diff --git a/cmd/wush/ssh.go b/cmd/wush/ssh.go index f972c59..8a51e9c 100644 --- a/cmd/wush/ssh.go +++ b/cmd/wush/ssh.go @@ -34,9 +34,8 @@ func sshCmd() *serpent.Command { return &serpent.Command{ Use: "ssh", Aliases: []string{}, - Short: "Open a shell.", - Long: "Opens an SSH connection to a " + cliui.Code("wush") + " peer. " + - "Use " + cliui.Code("wush serve") + " on the computer you would like to connect to.", + Short: "Open a SSH connection to a wush server.", + Long: "Use " + cliui.Code("wush serve") + " on the computer you would like to connect to.", Middleware: serpent.Chain( initLogger(&verbose, &quiet, logger, &logf), initAuth(&overlayOpts.authKey, &overlayOpts.clientAuth), diff --git a/cmd/wush/version.go b/cmd/wush/version.go index 263aace..9632258 100644 --- a/cmd/wush/version.go +++ b/cmd/wush/version.go @@ -10,7 +10,7 @@ import ( func versionCmd() *serpent.Command { cmd := &serpent.Command{ Use: "version", - Short: "Show wush version.", + Short: "Output the wush version.", Handler: func(inv *serpent.Invocation) error { bi := getBuildInfo() fmt.Printf("Wush %s-%s %s\n", bi.version, bi.commitHash[:7], bi.commitTime.Format(time.RFC1123)) From 7f337db3d28e0cd5939af0eca3142603d51176ee Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 18 Oct 2024 14:26:11 -0500 Subject: [PATCH 08/34] feat(web): add file transfer support (#62) --- cmd/wasm/main.go | 286 +++++++- flake.lock | 61 ++ flake.nix | 28 + go.mod | 4 +- overlay/overlay.go | 12 +- overlay/receive.go | 36 +- overlay/send.go | 21 +- overlay/wasm.go | 400 +++++++++++ site/app/assets/wasm_exec.js | 1213 ++++++++++++++++------------------ site/app/context/wush.ts | 2 +- site/app/root.tsx | 26 +- site/app/routes/_index.tsx | 114 +++- site/build_wasm.sh | 6 +- site/types/wush_js.d.ts | 76 ++- tsserver/server.go | 16 +- 15 files changed, 1540 insertions(+), 761 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 overlay/wasm.go diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index ea8251f..ed92f49 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -6,9 +6,12 @@ import ( "bytes" "context" "fmt" + "io" "log" "log/slog" "net" + "net/http" + "strings" "syscall/js" "time" @@ -52,13 +55,8 @@ func main() { <-make(chan struct{}, 0) } -func newWush(jsConfig js.Value) map[string]any { +func newWush(cfg js.Value) map[string]any { ctx := context.Background() - var authKey string - if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString { - authKey = jsAuthKey.String() - } - logger := slog.New(slog.NewTextHandler(jsConsoleWriter{}, nil)) hlog := func(format string, args ...any) { fmt.Printf(format+"\n", args...) @@ -68,18 +66,19 @@ func newWush(jsConfig js.Value) map[string]any { panic(err) } - send := overlay.NewSendOverlay(logger, dm) - err = send.Auth.Parse(authKey) + ov := overlay.NewWasmOverlay(log.Printf, dm, cfg.Get("onNewPeer")) + + err = ov.PickDERPHome(ctx) if err != nil { panic(err) } - s, err := tsserver.NewServer(ctx, logger, send, dm) + s, err := tsserver.NewServer(ctx, logger, ov, dm) if err != nil { panic(err) } - go send.ListenOverlayDERP(ctx) + go ov.ListenOverlayDERP(ctx) go s.ListenAndServe(ctx) netns.SetDialerOverride(s.Dialer()) @@ -94,12 +93,40 @@ func newWush(jsConfig js.Value) map[string]any { } hlog("WireGuard is ready") + cpListener, err := ts.Listen("tcp", ":4444") + if err != nil { + panic(err) + } + + go func() { + err := http.Serve(cpListener, http.HandlerFunc(cpH( + cfg.Get("onIncomingFile"), + cfg.Get("downloadFile"), + ))) + if err != nil { + hlog("File transfer server exited: " + err.Error()) + } + }() + return map[string]any{ + "auth_info": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 0 { + log.Printf("Usage: auth_info()") + return nil + } + + return map[string]any{ + "derp_id": ov.DerpRegionID, + "derp_name": ov.DerpMap.Regions[int(ov.DerpRegionID)].RegionName, + "auth_key": ov.ClientAuth().AuthKey(), + } + }), "stop": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 0 { log.Printf("Usage: stop()") return nil } + cpListener.Close() ts.Close() return nil }), @@ -127,6 +154,157 @@ func newWush(jsConfig js.Value) map[string]any { }), } }), + "connect": js.FuncOf(func(this js.Value, args []js.Value) any { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) any { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) != 1 { + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New("Usage: connect(authKey)") + reject.Invoke(errorObject) + return + } + + var authKey string + if args[0].Type() == js.TypeString { + authKey = args[0].String() + } else { + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New("Usage: connect(authKey)") + reject.Invoke(errorObject) + return + } + + var ca overlay.ClientAuth + err := ca.Parse(authKey) + if err != nil { + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New(fmt.Errorf("parse authkey: %w", err).Error()) + reject.Invoke(errorObject) + return + } + + ctx, cancel := context.WithCancel(context.Background()) + peer, err := ov.Connect(ctx, ca) + if err != nil { + cancel() + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New(fmt.Errorf("parse authkey: %w", err).Error()) + reject.Invoke(errorObject) + return + } + + resolve.Invoke(map[string]any{ + "id": js.ValueOf(peer.ID), + "name": js.ValueOf(peer.Name), + "ip": js.ValueOf(peer.IP.String()), + "cancel": js.FuncOf(func(this js.Value, args []js.Value) any { + cancel() + return nil + }), + }) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) + }), + "transfer": js.FuncOf(func(this js.Value, args []js.Value) any { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) any { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + if len(args) != 5 { + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New("Usage: transfer(peer, file)") + reject.Invoke(errorObject) + return nil + } + + peer := args[0] + ip := peer.Get("ip").String() + fileName := args[1].String() + sizeBytes := args[2].Int() + stream := args[3] + streamHelper := args[4] + + pr, pw := io.Pipe() + + goCallback := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(js.FuncOf(func(this js.Value, promiseArgs []js.Value) any { + resolve := promiseArgs[0] + _ = promiseArgs[1] + go func() { + if len(args) == 0 || args[0].IsNull() || args[0].IsUndefined() { + pw.Close() + resolve.Invoke() + return + } + + fmt.Println("in go callback") + // Convert the JavaScript Uint8Array to a Go byte slice + uint8Array := args[0] + fmt.Println("type is", uint8Array.Type().String()) + length := uint8Array.Get("length").Int() + buf := make([]byte, length) + js.CopyBytesToGo(buf, uint8Array) + + fmt.Println("sending data to channel") + // Send the data to the channel + if _, err := pw.Write(buf); err != nil { + pw.CloseWithError(err) + } + fmt.Println("callback finished") + + // Resolve the promise + resolve.Invoke() + }() + return nil + })) + }) + + go func() { + defer goCallback.Release() + + streamHelper.Invoke(stream, goCallback) + + hc := ts.HTTPClient() + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s:4444/%s", ip, fileName), pr) + if err != nil { + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New(err.Error()) + reject.Invoke(errorObject) + return + } + req.ContentLength = int64(sizeBytes) + + res, err := hc.Do(req) + if err != nil { + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New(err.Error()) + reject.Invoke(errorObject) + return + } + defer res.Body.Close() + + bod := bytes.NewBuffer(nil) + _, _ = io.Copy(bod, res.Body) + + fmt.Println(bod.String()) + resolve.Invoke() + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) + }), } } @@ -306,3 +484,91 @@ func newTSNet(direction string) (*tsnet.Server, error) { return srv, nil } + +func cpH(onIncomingFile js.Value, downloadFile js.Value) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + return + } + + fiName := strings.TrimPrefix(r.URL.Path, "/") + + // TODO: impl + peer := map[string]any{ + "id": js.ValueOf(0), + "name": js.ValueOf(""), + "ip": js.ValueOf(""), + "cancel": js.FuncOf(func(this js.Value, args []js.Value) any { + return nil + }), + } + + allow := onIncomingFile.Invoke(peer, fiName, r.ContentLength).Bool() + if !allow { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("File transfer was denied")) + r.Body.Close() + return + } + + underlyingSource := map[string]interface{}{ + // start method + "start": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + // The first and only arg is the controller object + controller := args[0] + + // Process the stream in yet another background goroutine, + // because we can't block on a goroutine invoked by JS in Wasm + // that is dealing with HTTP requests + go func() { + // Close the response body at the end of this method + defer r.Body.Close() + + // Read the entire stream and pass it to JavaScript + for { + // Read up to 16KB at a time + buf := make([]byte, 16384) + n, err := r.Body.Read(buf) + if err != nil && err != io.EOF { + // Tell the controller we have an error + // We're ignoring "EOF" however, which means the stream was done + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New(err.Error()) + controller.Call("error", errorObject) + return + } + if n > 0 { + // If we read anything, send it to JavaScript using the "enqueue" method on the controller + // We need to convert it to a Uint8Array first + arrayConstructor := js.Global().Get("Uint8Array") + dataJS := arrayConstructor.New(n) + js.CopyBytesToJS(dataJS, buf[0:n]) + controller.Call("enqueue", dataJS) + } + if err == io.EOF { + // Stream is done, so call the "close" method on the controller + controller.Call("close") + return + } + } + }() + + return nil + }), + // cancel method + "cancel": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + // If the request is canceled, just close the body + r.Body.Close() + + return nil + }), + } + + readableStreamConstructor := js.Global().Get("ReadableStream") + readableStream := readableStreamConstructor.New(underlyingSource) + + downloadFile.Invoke(peer, fiName, r.ContentLength, readableStream) + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f3cfe0f --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1728888510, + "narHash": "sha256-nsNdSldaAyu6PE3YUA+YQLqUDJh+gRbBooMMekZJwvI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a3c0b3b21515f74fd2665903d4ce6bc4dc81c77c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..0100a5d --- /dev/null +++ b/flake.nix @@ -0,0 +1,28 @@ +{ + description = "Dev shell for Go backend and React frontend (using pnpm)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + devShell = pkgs.mkShell + { + buildInputs = with pkgs; [ + go + nodejs + pnpm + binaryen # wasm-opt + ]; + + shellHook = '' + exec $SHELL + ''; + }; + }); +} diff --git a/go.mod b/go.mod index 6c6ff13..1badb5c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.22.6 replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20240926000438-059d0c1039af +// replace tailscale.com => /home/colin/Projects/coadler/tailscale + replace github.com/gliderlabs/ssh => github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 require ( @@ -14,6 +16,7 @@ require ( github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 github.com/coder/serpent v0.8.0 github.com/go-chi/chi/v5 v5.1.0 + github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.17.10 github.com/mattn/go-isatty v0.0.20 github.com/mitchellh/go-wordwrap v1.0.1 @@ -104,7 +107,6 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/csrf v1.7.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect diff --git a/overlay/overlay.go b/overlay/overlay.go index 0cfacab..698ea20 100644 --- a/overlay/overlay.go +++ b/overlay/overlay.go @@ -3,6 +3,7 @@ package overlay import ( "net/netip" + "github.com/google/uuid" "tailscale.com/tailcfg" ) @@ -12,7 +13,7 @@ type Overlay interface { // listenOverlay(ctx context.Context, kind string) error Recv() <-chan *tailcfg.Node Send() chan<- *tailcfg.Node - IP() netip.Addr + IPs() []netip.Addr } type messageType int @@ -29,7 +30,6 @@ type overlayMessage struct { Typ messageType HostInfo HostInfo - IP netip.Addr Node tailcfg.Node } @@ -37,3 +37,11 @@ type HostInfo struct { Username string Hostname string } + +var TailscaleServicePrefix6 = [6]byte{0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0} + +func randv6() netip.Addr { + uid := uuid.New() + copy(uid[:], TailscaleServicePrefix6[:]) + return netip.AddrFrom16(uid) +} diff --git a/overlay/receive.go b/overlay/receive.go index 9bed57e..41cf5be 100644 --- a/overlay/receive.go +++ b/overlay/receive.go @@ -2,7 +2,6 @@ package overlay import ( "context" - "encoding/binary" "encoding/json" "errors" "fmt" @@ -60,18 +59,20 @@ type Receive struct { // communication. derpRegionID uint16 - // nextPeerIP is a counter that assigns IP addresses to new peers in - // ascending order. It contains the last two bytes of an IPv4 address, - // 100.64.x.x. - nextPeerIP uint16 - lastNode atomic.Pointer[tailcfg.Node] - in chan *tailcfg.Node - out chan *tailcfg.Node + // in funnels node updates from other peers to us + in chan *tailcfg.Node + // out fans out our node updates to peers + out chan *tailcfg.Node } -func (r *Receive) IP() netip.Addr { - return netip.AddrFrom4([4]byte{100, 64, 0, 0}) +func (r *Receive) IPs() []netip.Addr { + i6 := [16]byte{0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0} + i6[15] = 0x01 + return []netip.Addr{ + // netip.AddrFrom4([4]byte{100, 64, 0, 0}), + netip.AddrFrom16(i6), + } } func (r *Receive) PickDERPHome(ctx context.Context) error { @@ -306,7 +307,7 @@ func (r *Receive) ListenOverlayDERP(ctx context.Context) error { case derp.ReceivedPacket: res, key, err := r.handleNextMessage(msg.Data, "DERP") if err != nil { - r.HumanLogf("Failed to handle overlay message: %s", err.Error()) + r.HumanLogf("Failed to handle overlay message from %s: %s", msg.Source.ShortString(), err.Error()) continue } @@ -343,7 +344,6 @@ func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, n // do nothing case messageTypeHello: res.Typ = messageTypeHelloResponse - res.IP = r.assignNextIP() username := "unknown" if u := ovMsg.HostInfo.Username; u != "" { username = u @@ -352,6 +352,9 @@ func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, n if h := ovMsg.HostInfo.Hostname; h != "" { hostname = h } + if lastNode := r.lastNode.Load(); lastNode != nil { + res.Node = *lastNode + } r.HumanLogf("%s Received connection request over %s from %s", cliui.Timestamp(time.Now()), system, cliui.Keyword(fmt.Sprintf("%s@%s", username, hostname))) case messageTypeNodeUpdate: r.HumanLogf("%s Received updated node from %s", cliui.Timestamp(time.Now()), cliui.Code(ovMsg.Node.Key.String())) @@ -374,12 +377,3 @@ func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, n sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw) return sealed, ovMsg.Node.Key, nil } - -func (r *Receive) assignNextIP() netip.Addr { - r.nextPeerIP += 1 - - addrBytes := [4]byte{100, 64, 0, 0} - binary.BigEndian.PutUint16(addrBytes[2:], r.nextPeerIP) - - return netip.AddrFrom4(addrBytes) -} diff --git a/overlay/send.go b/overlay/send.go index 2054040..be417fc 100644 --- a/overlay/send.go +++ b/overlay/send.go @@ -10,7 +10,6 @@ import ( "net/netip" "os" "os/user" - "sync" "time" "github.com/coder/wush/cliui" @@ -26,7 +25,7 @@ func NewSendOverlay(logger *slog.Logger, dm *tailcfg.DERPMap) *Send { derpMap: dm, in: make(chan *tailcfg.Node, 8), out: make(chan *tailcfg.Node, 8), - waitIP: make(chan struct{}), + SelfIP: randv6(), } } @@ -35,10 +34,7 @@ type Send struct { STUNIPOverride netip.Addr derpMap *tailcfg.DERPMap - // _ip is the ip we get from the receiver, which is our ip on the tailnet. - _ip netip.Addr - waitIP chan struct{} - waitIPOnce sync.Once + SelfIP netip.Addr Auth ClientAuth @@ -46,9 +42,8 @@ type Send struct { out chan *tailcfg.Node } -func (s *Send) IP() netip.Addr { - <-s.waitIP - return s._ip +func (s *Send) IPs() []netip.Addr { + return []netip.Addr{s.SelfIP} } func (s *Send) Recv() <-chan *tailcfg.Node { @@ -131,8 +126,6 @@ func (s *Send) ListenOverlaySTUN(ctx context.Context) error { s.Logger.Error("read from STUN; exiting", "err", err) return err } - _ = addr - fmt.Println("new UDP msg from", addr.String()) buf = buf[:n] @@ -270,11 +263,7 @@ func (s *Send) handleNextMessage(msg []byte) (resRaw []byte, _ error) { case messageTypePong: // do nothing case messageTypeHelloResponse: - s._ip = ovMsg.IP - s.waitIPOnce.Do(func() { - close(s.waitIP) - }) - // fmt.Println("Received IP from peer:", s._ip.String()) + s.in <- &ovMsg.Node case messageTypeNodeUpdate: s.in <- &ovMsg.Node } diff --git a/overlay/wasm.go b/overlay/wasm.go new file mode 100644 index 0000000..c129b22 --- /dev/null +++ b/overlay/wasm.go @@ -0,0 +1,400 @@ +//go:build js && wasm + +package overlay + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math/rand/v2" + "net/netip" + "sync" + "sync/atomic" + "syscall/js" + "time" + + "github.com/coder/wush/cliui" + "github.com/puzpuzpuz/xsync/v3" + "tailscale.com/derp" + "tailscale.com/derp/derphttp" + "tailscale.com/net/netcheck" + "tailscale.com/net/netmon" + "tailscale.com/net/portmapper" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +func NewWasmOverlay(hlog Logf, dm *tailcfg.DERPMap, onNewPeer js.Value) *Wasm { + return &Wasm{ + HumanLogf: hlog, + DerpMap: dm, + SelfPriv: key.NewNode(), + PeerPriv: key.NewNode(), + SelfIP: randv6(), + + peers: xsync.NewMapOf[int32, chan<- *tailcfg.Node](), + onNewPeer: onNewPeer, + in: make(chan *tailcfg.Node, 8), + out: make(chan *tailcfg.Node, 8), + } +} + +type Wasm struct { + Logger *slog.Logger + HumanLogf Logf + DerpMap *tailcfg.DERPMap + // SelfPriv is the private key that peers will encrypt overlay messages to. + // The public key of this is sent in the auth key. + SelfPriv key.NodePrivate + // PeerPriv is the main auth mechanism used to secure the overlay. Peers are + // sent this private key to encrypt node communication. Leaking this private + // key would allow anyone to connect. + PeerPriv key.NodePrivate + SelfIP netip.Addr + + // username is a randomly generated human-readable string displayed on + // wush.dev to identify clients. + username string + + // DerpRegionID is the DERP region that can be used for proxied overlay + // communication. + DerpRegionID uint16 + + // peers is a map of channels that notify peers of node updates. + peers *xsync.MapOf[int32, chan<- *tailcfg.Node] + onNewPeer js.Value + + lastNode atomic.Pointer[tailcfg.Node] + // in funnels node updates from other peers to us + in chan *tailcfg.Node + // out fans out our node updates to peers + out chan *tailcfg.Node +} + +func (r *Wasm) IPs() []netip.Addr { + return []netip.Addr{r.SelfIP} +} + +func (r *Wasm) PickDERPHome(ctx context.Context) error { + nm := netmon.NewStatic() + nc := netcheck.Client{ + NetMon: nm, + PortMapper: portmapper.NewClient(func(format string, args ...any) {}, nm, nil, nil, nil), + Logf: func(format string, args ...any) {}, + } + + report, err := nc.GetReport(ctx, r.DerpMap, nil) + if err != nil { + return err + } + + if report.PreferredDERP == 0 { + r.HumanLogf("Failed to determine overlay DERP region, defaulting to %s.", cliui.Code("NYC")) + r.DerpRegionID = 1 + } else { + r.HumanLogf("Picked DERP region %s as overlay home", cliui.Code(r.DerpMap.Regions[report.PreferredDERP].RegionName)) + r.DerpRegionID = uint16(report.PreferredDERP) + } + + return nil +} + +func (r *Wasm) ClientAuth() *ClientAuth { + return &ClientAuth{ + OverlayPrivateKey: r.PeerPriv, + ReceiverPublicKey: r.SelfPriv.Public(), + ReceiverDERPRegionID: r.DerpRegionID, + } +} + +func (r *Wasm) Recv() <-chan *tailcfg.Node { + return r.in +} + +func (r *Wasm) Send() chan<- *tailcfg.Node { + return r.out +} + +type Peer struct { + ID int32 + Name string + IP netip.Addr +} + +func (r *Wasm) Connect(ctx context.Context, ca ClientAuth) (Peer, error) { + derpPriv := key.NewNode() + c := derphttp.NewRegionClient(derpPriv, func(format string, args ...any) {}, netmon.NewStatic(), func() *tailcfg.DERPRegion { + return r.DerpMap.Regions[int(ca.ReceiverDERPRegionID)] + }) + + err := c.Connect(ctx) + if err != nil { + return Peer{}, err + } + + sealed := r.newHelloPacket(ca) + err = c.Send(ca.ReceiverPublicKey, sealed) + if err != nil { + return Peer{}, fmt.Errorf("send overlay hello over derp: %w", err) + } + + updates := make(chan *tailcfg.Node, 2) + + peerID := rand.Int32() + r.peers.Store(peerID, updates) + + go func() { + defer r.peers.Delete(peerID) + + for { + select { + case <-ctx.Done(): + return + case node := <-updates: + msg := overlayMessage{ + Typ: messageTypeNodeUpdate, + Node: *node, + } + raw, err := json.Marshal(msg) + if err != nil { + panic("marshal node: " + err.Error()) + } + + sealed := ca.OverlayPrivateKey.SealTo(ca.ReceiverPublicKey, raw) + err = c.Send(ca.ReceiverPublicKey, sealed) + if err != nil { + fmt.Printf("send response over derp: %s\n", err) + return + } + } + } + }() + + waitHello := make(chan struct{}) + closeOnce := sync.Once{} + helloResp := overlayMessage{} + + go func() { + for { + msg, err := c.Recv() + if err != nil { + fmt.Println("Recv derp:", err) + return + } + + switch msg := msg.(type) { + case derp.ReceivedPacket: + if ca.ReceiverPublicKey != msg.Source { + fmt.Printf("message from unknown peer %s\n", msg.Source.String()) + continue + } + + res, _, ovmsg, err := r.handleNextMessage(ca.OverlayPrivateKey, ca.ReceiverPublicKey, msg.Data) + if err != nil { + fmt.Println("Failed to handle overlay message:", err) + continue + } + + if res != nil { + err = c.Send(msg.Source, res) + if err != nil { + fmt.Println(cliui.Timestamp(time.Now()), "Failed to send overlay response over derp:", err.Error()) + return + } + } + + if ovmsg.Typ == messageTypeHelloResponse { + helloResp = ovmsg + closeOnce.Do(func() { + close(waitHello) + }) + } + } + } + }() + + select { + case <-time.After(10 * time.Second): + return Peer{}, errors.New("timed out waiting for peer to respond") + case <-waitHello: + updates <- r.lastNode.Load() + if len(helloResp.Node.Addresses) == 0 { + return Peer{}, fmt.Errorf("peer has no addresses") + } + ip := helloResp.Node.Addresses[0].Addr() + return Peer{ + ID: peerID, + IP: ip, + Name: helloResp.HostInfo.Username, + }, nil + } +} + +func (r *Wasm) ListenOverlayDERP(ctx context.Context) error { + c := derphttp.NewRegionClient(r.SelfPriv, func(format string, args ...any) {}, netmon.NewStatic(), func() *tailcfg.DERPRegion { + return r.DerpMap.Regions[int(r.DerpRegionID)] + }) + defer c.Close() + + err := c.Connect(ctx) + if err != nil { + return err + } + + // node priv -> derp priv + peers := xsync.NewMapOf[key.NodePublic, key.NodePublic]() + + go func() { + for { + + select { + case <-ctx.Done(): + return + case node := <-r.out: + r.lastNode.Store(node) + raw, err := json.Marshal(overlayMessage{ + Typ: messageTypeNodeUpdate, + Node: *node, + }) + if err != nil { + panic("marshal node: " + err.Error()) + } + + sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw) + // range over peers that have connected to us + peers.Range(func(_, derpKey key.NodePublic) bool { + fmt.Println("sending node to inbound peer") + err = c.Send(derpKey, sealed) + if err != nil { + r.HumanLogf("Send updated node over DERP: %s", err) + return false + } + return true + }) + // range over peers that we have connected to + r.peers.Range(func(key int32, value chan<- *tailcfg.Node) bool { + fmt.Println("sending node to outbound peer") + value <- node.Clone() + return true + }) + } + } + }() + + for { + msg, err := c.Recv() + if err != nil { + return err + } + + switch msg := msg.(type) { + case derp.ReceivedPacket: + res, key, _, err := r.handleNextMessage(r.SelfPriv, r.PeerPriv.Public(), msg.Data) + if err != nil { + r.HumanLogf("Failed to handle overlay message: %s", err.Error()) + continue + } + + peers.Store(key, msg.Source) + + if res != nil { + err = c.Send(msg.Source, res) + if err != nil { + r.HumanLogf("Failed to send overlay response over derp: %s", err.Error()) + return err + } + } + } + } +} + +func (r *Wasm) newHelloPacket(ca ClientAuth) []byte { + var ( + username string = r.username + hostname string = "wush.dev" + ) + + raw, err := json.Marshal(overlayMessage{ + Typ: messageTypeHello, + HostInfo: HostInfo{ + Username: username, + Hostname: hostname, + }, + Node: *r.lastNode.Load(), + }) + if err != nil { + panic("marshal node: " + err.Error()) + } + + sealed := ca.OverlayPrivateKey.SealTo(ca.ReceiverPublicKey, raw) + return sealed +} + +func (r *Wasm) handleNextMessage(selfPriv key.NodePrivate, peerPub key.NodePublic, msg []byte) (resRaw []byte, nodeKey key.NodePublic, _ overlayMessage, _ error) { + cleartext, ok := selfPriv.OpenFrom(peerPub, msg) + if !ok { + return nil, key.NodePublic{}, overlayMessage{}, errors.New("message failed decryption") + } + + var ovMsg overlayMessage + err := json.Unmarshal(cleartext, &ovMsg) + if err != nil { + panic("unmarshal node: " + err.Error()) + } + + res := overlayMessage{} + switch ovMsg.Typ { + case messageTypePing: + res.Typ = messageTypePong + case messageTypePong: + // do nothing + case messageTypeHello: + res.Typ = messageTypeHelloResponse + res.HostInfo.Username = r.username + res.HostInfo.Hostname = "wush.dev" + username := "unknown" + if u := ovMsg.HostInfo.Username; u != "" { + username = u + } + hostname := "unknown" + if h := ovMsg.HostInfo.Hostname; h != "" { + hostname = h + } + if node := r.lastNode.Load(); node != nil { + res.Node = *node + } + r.HumanLogf("%s Received connection request from %s", cliui.Timestamp(time.Now()), cliui.Keyword(fmt.Sprintf("%s@%s", username, hostname))) + // TODO: impl + r.onNewPeer.Invoke(map[string]any{ + "id": js.ValueOf(0), + "name": js.ValueOf(""), + "ip": js.ValueOf(""), + "cancel": js.FuncOf(func(this js.Value, args []js.Value) any { + return nil + }), + }) + case messageTypeHelloResponse: + if !ovMsg.Node.Key.IsZero() { + r.in <- &ovMsg.Node + } + case messageTypeNodeUpdate: + r.HumanLogf("%s Received updated node from %s", cliui.Timestamp(time.Now()), cliui.Code(ovMsg.Node.Key.String())) + if !ovMsg.Node.Key.IsZero() { + r.in <- &ovMsg.Node + } + } + + if res.Typ == 0 { + return nil, ovMsg.Node.Key, ovMsg, nil + } + + raw, err := json.Marshal(res) + if err != nil { + panic("marshal node: " + err.Error()) + } + + sealed := selfPriv.SealTo(peerPub, raw) + return sealed, ovMsg.Node.Key, ovMsg, nil +} diff --git a/site/app/assets/wasm_exec.js b/site/app/assets/wasm_exec.js index 8bc1520..bc6f210 100644 --- a/site/app/assets/wasm_exec.js +++ b/site/app/assets/wasm_exec.js @@ -5,664 +5,557 @@ "use strict"; (() => { - const enosys = () => { - const err = new Error("not implemented"); - err.code = "ENOSYS"; - return err; - }; - - if (!globalThis.fs) { - let outputBuf = ""; - globalThis.fs = { - constants: { - O_WRONLY: -1, - O_RDWR: -1, - O_CREAT: -1, - O_TRUNC: -1, - O_APPEND: -1, - O_EXCL: -1, - }, // unused - writeSync(fd, buf) { - outputBuf += decoder.decode(buf); - const nl = outputBuf.lastIndexOf("\n"); - if (nl != -1) { - console.log(outputBuf.substring(0, nl)); - outputBuf = outputBuf.substring(nl + 1); - } - return buf.length; - }, - write(fd, buf, offset, length, position, callback) { - if (offset !== 0 || length !== buf.length || position !== null) { - callback(enosys()); - return; - } - const n = this.writeSync(fd, buf); - callback(null, n); - }, - chmod(path, mode, callback) { - callback(enosys()); - }, - chown(path, uid, gid, callback) { - callback(enosys()); - }, - close(fd, callback) { - callback(enosys()); - }, - fchmod(fd, mode, callback) { - callback(enosys()); - }, - fchown(fd, uid, gid, callback) { - callback(enosys()); - }, - fstat(fd, callback) { - callback(enosys()); - }, - fsync(fd, callback) { - callback(null); - }, - ftruncate(fd, length, callback) { - callback(enosys()); - }, - lchown(path, uid, gid, callback) { - callback(enosys()); - }, - link(path, link, callback) { - callback(enosys()); - }, - lstat(path, callback) { - callback(enosys()); - }, - mkdir(path, perm, callback) { - callback(enosys()); - }, - open(path, flags, mode, callback) { - callback(enosys()); - }, - read(fd, buffer, offset, length, position, callback) { - callback(enosys()); - }, - readdir(path, callback) { - callback(enosys()); - }, - readlink(path, callback) { - callback(enosys()); - }, - rename(from, to, callback) { - callback(enosys()); - }, - rmdir(path, callback) { - callback(enosys()); - }, - stat(path, callback) { - callback(enosys()); - }, - symlink(path, link, callback) { - callback(enosys()); - }, - truncate(path, length, callback) { - callback(enosys()); - }, - unlink(path, callback) { - callback(enosys()); - }, - utimes(path, atime, mtime, callback) { - callback(enosys()); - }, - }; - } - - if (!globalThis.process) { - globalThis.process = { - getuid() { - return -1; - }, - getgid() { - return -1; - }, - geteuid() { - return -1; - }, - getegid() { - return -1; - }, - getgroups() { - throw enosys(); - }, - pid: -1, - ppid: -1, - umask() { - throw enosys(); - }, - cwd() { - throw enosys(); - }, - chdir() { - throw enosys(); - }, - }; - } - - if (!globalThis.crypto) { - throw new Error( - "globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)" - ); - } - - if (!globalThis.performance) { - throw new Error( - "globalThis.performance is not available, polyfill required (performance.now only)" - ); - } - - if (!globalThis.TextEncoder) { - throw new Error( - "globalThis.TextEncoder is not available, polyfill required" - ); - } - - if (!globalThis.TextDecoder) { - throw new Error( - "globalThis.TextDecoder is not available, polyfill required" - ); - } - - const encoder = new TextEncoder("utf-8"); - const decoder = new TextDecoder("utf-8"); - - globalThis.Go = class { - constructor() { - this.argv = ["js"]; - this.env = {}; - this.exit = (code) => { - if (code !== 0) { - console.warn("exit code:", code); - } - }; - this._exitPromise = new Promise((resolve) => { - this._resolveExitPromise = resolve; - }); - this._pendingEvent = null; - this._scheduledTimeouts = new Map(); - this._nextCallbackTimeoutID = 1; - - const setInt64 = (addr, v) => { - this.mem.setUint32(addr + 0, v, true); - this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); - }; - - const setInt32 = (addr, v) => { - this.mem.setUint32(addr + 0, v, true); - }; - - const getInt64 = (addr) => { - const low = this.mem.getUint32(addr + 0, true); - const high = this.mem.getInt32(addr + 4, true); - return low + high * 4294967296; - }; - - const loadValue = (addr) => { - const f = this.mem.getFloat64(addr, true); - if (f === 0) { - return undefined; - } - if (!isNaN(f)) { - return f; - } - - const id = this.mem.getUint32(addr, true); - return this._values[id]; - }; - - const storeValue = (addr, v) => { - const nanHead = 0x7ff80000; - - if (typeof v === "number" && v !== 0) { - if (isNaN(v)) { - this.mem.setUint32(addr + 4, nanHead, true); - this.mem.setUint32(addr, 0, true); - return; - } - this.mem.setFloat64(addr, v, true); - return; - } - - if (v === undefined) { - this.mem.setFloat64(addr, 0, true); - return; - } - - let id = this._ids.get(v); - if (id === undefined) { - id = this._idPool.pop(); - if (id === undefined) { - id = this._values.length; - } - this._values[id] = v; - this._goRefCounts[id] = 0; - this._ids.set(v, id); - } - this._goRefCounts[id]++; - let typeFlag = 0; - switch (typeof v) { - case "object": - if (v !== null) { - typeFlag = 1; - } - break; - case "string": - typeFlag = 2; - break; - case "symbol": - typeFlag = 3; - break; - case "function": - typeFlag = 4; - break; - } - this.mem.setUint32(addr + 4, nanHead | typeFlag, true); - this.mem.setUint32(addr, id, true); - }; - - const loadSlice = (addr) => { - const array = getInt64(addr + 0); - const len = getInt64(addr + 8); - return new Uint8Array(this._inst.exports.mem.buffer, array, len); - }; - - const loadSliceOfValues = (addr) => { - const array = getInt64(addr + 0); - const len = getInt64(addr + 8); - const a = new Array(len); - for (let i = 0; i < len; i++) { - a[i] = loadValue(array + i * 8); - } - return a; - }; - - const loadString = (addr) => { - const saddr = getInt64(addr + 0); - const len = getInt64(addr + 8); - return decoder.decode( - new DataView(this._inst.exports.mem.buffer, saddr, len) - ); - }; - - const timeOrigin = Date.now() - performance.now(); - this.importObject = { - _gotest: { - add: (a, b) => a + b, - }, - gojs: { - // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) - // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported - // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). - // This changes the SP, thus we have to update the SP used by the imported function. - - // func wasmExit(code int32) - "runtime.wasmExit": (sp) => { - sp >>>= 0; - const code = this.mem.getInt32(sp + 8, true); - this.exited = true; - delete this._inst; - delete this._values; - delete this._goRefCounts; - delete this._ids; - delete this._idPool; - this.exit(code); - }, - - // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) - "runtime.wasmWrite": (sp) => { - sp >>>= 0; - const fd = getInt64(sp + 8); - const p = getInt64(sp + 16); - const n = this.mem.getInt32(sp + 24, true); - fs.writeSync( - fd, - new Uint8Array(this._inst.exports.mem.buffer, p, n) - ); - }, - - // func resetMemoryDataView() - "runtime.resetMemoryDataView": (sp) => { - sp >>>= 0; - this.mem = new DataView(this._inst.exports.mem.buffer); - }, - - // func nanotime1() int64 - "runtime.nanotime1": (sp) => { - sp >>>= 0; - setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); - }, - - // func walltime() (sec int64, nsec int32) - "runtime.walltime": (sp) => { - sp >>>= 0; - const msec = new Date().getTime(); - setInt64(sp + 8, msec / 1000); - this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); - }, - - // func scheduleTimeoutEvent(delay int64) int32 - "runtime.scheduleTimeoutEvent": (sp) => { - sp >>>= 0; - const id = this._nextCallbackTimeoutID; - this._nextCallbackTimeoutID++; - this._scheduledTimeouts.set( - id, - setTimeout( - () => { - this._resume(); - while (this._scheduledTimeouts.has(id)) { - // for some reason Go failed to register the timeout event, log and try again - // (temporary workaround for https://github.com/golang/go/issues/28975) - console.warn("scheduleTimeoutEvent: missed timeout event"); - this._resume(); - } - }, - getInt64(sp + 8) - ) - ); - this.mem.setInt32(sp + 16, id, true); - }, - - // func clearTimeoutEvent(id int32) - "runtime.clearTimeoutEvent": (sp) => { - sp >>>= 0; - const id = this.mem.getInt32(sp + 8, true); - clearTimeout(this._scheduledTimeouts.get(id)); - this._scheduledTimeouts.delete(id); - }, - - // func getRandomData(r []byte) - "runtime.getRandomData": (sp) => { - sp >>>= 0; - crypto.getRandomValues(loadSlice(sp + 8)); - }, - - // func finalizeRef(v ref) - "syscall/js.finalizeRef": (sp) => { - sp >>>= 0; - const id = this.mem.getUint32(sp + 8, true); - this._goRefCounts[id]--; - if (this._goRefCounts[id] === 0) { - const v = this._values[id]; - this._values[id] = null; - this._ids.delete(v); - this._idPool.push(id); - } - }, - - // func stringVal(value string) ref - "syscall/js.stringVal": (sp) => { - sp >>>= 0; - storeValue(sp + 24, loadString(sp + 8)); - }, - - // func valueGet(v ref, p string) ref - "syscall/js.valueGet": (sp) => { - sp >>>= 0; - const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 32, result); - }, - - // func valueSet(v ref, p string, x ref) - "syscall/js.valueSet": (sp) => { - sp >>>= 0; - Reflect.set( - loadValue(sp + 8), - loadString(sp + 16), - loadValue(sp + 32) - ); - }, - - // func valueDelete(v ref, p string) - "syscall/js.valueDelete": (sp) => { - sp >>>= 0; - Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); - }, - - // func valueIndex(v ref, i int) ref - "syscall/js.valueIndex": (sp) => { - sp >>>= 0; - storeValue( - sp + 24, - Reflect.get(loadValue(sp + 8), getInt64(sp + 16)) - ); - }, - - // valueSetIndex(v ref, i int, x ref) - "syscall/js.valueSetIndex": (sp) => { - sp >>>= 0; - Reflect.set( - loadValue(sp + 8), - getInt64(sp + 16), - loadValue(sp + 24) - ); - }, - - // func valueCall(v ref, m string, args []ref) (ref, bool) - "syscall/js.valueCall": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const m = Reflect.get(v, loadString(sp + 16)); - const args = loadSliceOfValues(sp + 32); - const result = Reflect.apply(m, v, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 56, result); - this.mem.setUint8(sp + 64, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 56, err); - this.mem.setUint8(sp + 64, 0); - } - }, - - // func valueInvoke(v ref, args []ref) (ref, bool) - "syscall/js.valueInvoke": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const args = loadSliceOfValues(sp + 16); - const result = Reflect.apply(v, undefined, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, result); - this.mem.setUint8(sp + 48, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, err); - this.mem.setUint8(sp + 48, 0); - } - }, - - // func valueNew(v ref, args []ref) (ref, bool) - "syscall/js.valueNew": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const args = loadSliceOfValues(sp + 16); - const result = Reflect.construct(v, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, result); - this.mem.setUint8(sp + 48, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, err); - this.mem.setUint8(sp + 48, 0); - } - }, - - // func valueLength(v ref) int - "syscall/js.valueLength": (sp) => { - sp >>>= 0; - setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); - }, - - // valuePrepareString(v ref) (ref, int) - "syscall/js.valuePrepareString": (sp) => { - sp >>>= 0; - const str = encoder.encode(String(loadValue(sp + 8))); - storeValue(sp + 16, str); - setInt64(sp + 24, str.length); - }, - - // valueLoadString(v ref, b []byte) - "syscall/js.valueLoadString": (sp) => { - sp >>>= 0; - const str = loadValue(sp + 8); - loadSlice(sp + 16).set(str); - }, - - // func valueInstanceOf(v ref, t ref) bool - "syscall/js.valueInstanceOf": (sp) => { - sp >>>= 0; - this.mem.setUint8( - sp + 24, - loadValue(sp + 8) instanceof loadValue(sp + 16) ? 1 : 0 - ); - }, - - // func copyBytesToGo(dst []byte, src ref) (int, bool) - "syscall/js.copyBytesToGo": (sp) => { - sp >>>= 0; - const dst = loadSlice(sp + 8); - const src = loadValue(sp + 32); - if ( - !(src instanceof Uint8Array || src instanceof Uint8ClampedArray) - ) { - this.mem.setUint8(sp + 48, 0); - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - setInt64(sp + 40, toCopy.length); - this.mem.setUint8(sp + 48, 1); - }, - - // func copyBytesToJS(dst ref, src []byte) (int, bool) - "syscall/js.copyBytesToJS": (sp) => { - sp >>>= 0; - const dst = loadValue(sp + 8); - const src = loadSlice(sp + 16); - if ( - !(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray) - ) { - this.mem.setUint8(sp + 48, 0); - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - setInt64(sp + 40, toCopy.length); - this.mem.setUint8(sp + 48, 1); - }, - - debug: (value) => { - console.log(value); - }, - }, - }; - } - - async run(instance) { - if (!(instance instanceof WebAssembly.Instance)) { - throw new Error("Go.run: WebAssembly.Instance expected"); - } - this._inst = instance; - this.mem = new DataView(this._inst.exports.mem.buffer); - this._values = [ - // JS values that Go currently has references to, indexed by reference id - NaN, - 0, - null, - true, - false, - globalThis, - this, - ]; - this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id - this._ids = new Map([ - // mapping from JS values to reference ids - [0, 1], - [null, 2], - [true, 3], - [false, 4], - [globalThis, 5], - [this, 6], - ]); - this._idPool = []; // unused ids that have been garbage collected - this.exited = false; // whether the Go program has exited - - // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. - let offset = 4096; - - const strPtr = (str) => { - const ptr = offset; - const bytes = encoder.encode(str + "\0"); - new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); - offset += bytes.length; - if (offset % 8 !== 0) { - offset += 8 - (offset % 8); - } - return ptr; - }; - - const argc = this.argv.length; - - const argvPtrs = []; - this.argv.forEach((arg) => { - argvPtrs.push(strPtr(arg)); - }); - argvPtrs.push(0); - - const keys = Object.keys(this.env).sort(); - keys.forEach((key) => { - argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); - }); - argvPtrs.push(0); - - const argv = offset; - argvPtrs.forEach((ptr) => { - this.mem.setUint32(offset, ptr, true); - this.mem.setUint32(offset + 4, 0, true); - offset += 8; - }); - - // The linker guarantees global data starts from at least wasmMinDataAddr. - // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. - const wasmMinDataAddr = 4096 + 8192; - if (offset >= wasmMinDataAddr) { - throw new Error( - "total length of command line and environment variables exceeds limit" - ); - } - - this._inst.exports.run(argc, argv); - if (this.exited) { - this._resolveExitPromise(); - } - await this._exitPromise; - } - - _resume() { - if (this.exited) { - throw new Error("Go program has already exited"); - } - this._inst.exports.resume(); - if (this.exited) { - this._resolveExitPromise(); - } - } - - _makeFuncWrapper(id) { - const go = this; - return function () { - const event = { id: id, this: this, args: arguments }; - go._pendingEvent = event; - go._resume(); - return event.result; - }; - } - }; + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } })(); diff --git a/site/app/context/wush.ts b/site/app/context/wush.ts index e6759ec..7788d79 100644 --- a/site/app/context/wush.ts +++ b/site/app/context/wush.ts @@ -1,3 +1,3 @@ import React from "react"; -export const WushContext = React.createContext(false); +export const WushContext = React.createContext(null); diff --git a/site/app/root.tsx b/site/app/root.tsx index b4fb738..eda0305 100644 --- a/site/app/root.tsx +++ b/site/app/root.tsx @@ -62,7 +62,7 @@ export function HydrateFallback() { } export default function App() { - const [wushInitialized, setWushInitialized] = useState(false); + const [wushCtx, setWushCtx] = useState(null); useEffect(() => { // Check if not running on the client-side @@ -83,11 +83,27 @@ export default function App() { fetch(url), go.importObject ); + go.run(wasmModule.instance).then(() => { console.log("wasm exited"); - setWushInitialized(false); + setWushCtx(null); + }); + + newWush({ + onNewPeer: (peer: Peer) => void {}, + onIncomingFile: (peer, filename, sizeBytes): boolean => { + return false; + }, + downloadFile: async ( + peer, + filename, + sizeBytes, + stream + ): Promise => {}, + }).then((wush) => { + console.log(wush.auth_info()); + setWushCtx(wush); }); - setWushInitialized(true); } loadWasm(go); return () => { @@ -95,13 +111,13 @@ export default function App() { if (!go.exited) { exitWush(); } - setWushInitialized(false); + setWushCtx(null); }; }, []); return (
- +
diff --git a/site/app/routes/_index.tsx b/site/app/routes/_index.tsx index 4d26609..72b8291 100644 --- a/site/app/routes/_index.tsx +++ b/site/app/routes/_index.tsx @@ -1,36 +1,116 @@ import { Link } from "@remix-run/react"; import type React from "react"; -import { useState } from "react"; +import { useState, useContext } from "react"; +import { WushContext } from "~/context/wush"; export default function Component() { const [peerAuth, setPeerAuth] = useState(""); const handleConnect = (e: React.FormEvent) => { e.preventDefault(); }; + const wush = useContext(WushContext); return ( -
-
-
-

$ wush

- setPeerAuth(e.target.value)} - placeholder="Enter auth key" - className="w-full px-4 py-2 rounded bg-[#44475a] text-[#f8f8f2] placeholder-[#6272a4] focus:outline-none focus:ring-2 focus:ring-[#bd93f9]" - /> + wush && ( +
+ +
+

$ wush

+

{wush.auth_info().auth_key}

+ setPeerAuth(e.target.value)} + placeholder="Enter auth key" + className="w-full px-4 py-2 rounded bg-[#44475a] text-[#f8f8f2] placeholder-[#6272a4] focus:outline-none focus:ring-2 focus:ring-[#bd93f9]" + /> - + {/* */} - -
- -
+ {/* */} +
+ +
+ ) ); } + +// Assume 'stream' is your ReadableStream instance +async function readStreamToGo( + stream: ReadableStream, + goCallback: (bytes: Uint8Array | null) => Promise // The Go callback function exposed via syscall/js +): Promise { + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + // Signal EOF to Go by passing null + console.log("calling go callback EOF"); + goCallback(null); + break; + } + if (value) { + // Pass the chunk to Go + console.log("calling go callback"); + await goCallback(value); + } + } + } catch (error) { + console.error("Error reading stream:", error); + // Optionally handle errors and signal to Go + } finally { + reader.releaseLock(); + } +} diff --git a/site/build_wasm.sh b/site/build_wasm.sh index b977331..2ab0c4e 100755 --- a/site/build_wasm.sh +++ b/site/build_wasm.sh @@ -4,5 +4,9 @@ set -eux cd "$(dirname "$0")" +echo "WARNING: make sure you're using 'nix develop' for the correct go version" + GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o ./app/assets/main.wasm ../cmd/wasm -wasm-opt -Oz ./app/assets/main.wasm -o ./app/assets/main.wasm --enable-bulk-memory +# wasm-opt -Oz ./app/assets/main.wasm -o ./app/assets/main.wasm --enable-bulk-memory + +# cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./app/assets/wasm_exec.js diff --git a/site/types/wush_js.d.ts b/site/types/wush_js.d.ts index 77cd46b..79bbaec 100644 --- a/site/types/wush_js.d.ts +++ b/site/types/wush_js.d.ts @@ -3,34 +3,70 @@ declare global { function exitWush(): void; interface Wush { - run(callbacks: WushCallbacks): void; - ssh(termConfig: { - writeFn: (data: string) => void; - writeErrorFn: (err: string) => void; - setReadFn: (readFn: (data: string) => void) => void; - rows: number; - cols: number; - /** Defaults to 5 seconds */ - timeoutSeconds?: number; - onConnectionProgress: (message: string) => void; - onConnected: () => void; - onDone: () => void; - }): WushSSHSession; + auth_info(): AuthInfo; + connect(authKey: string): Promise; + ping(peer: Peer): Promise; + ssh( + peer: Peer, + termConfig: { + writeFn: (data: string) => void; + writeErrorFn: (err: string) => void; + setReadFn: (readFn: (data: string) => void) => void; + rows: number; + cols: number; + /** Defaults to 5 seconds */ + timeoutSeconds?: number; + onConnectionProgress: (message: string) => void; + onConnected: () => void; + onDone: () => void; + }, + ): WushSSHSession; + transfer( + peer: Peer, + filename: string, + sizeBytes: number, + data: ReadableStream, + helper: ( + stream: ReadableStream, + goCallback: (bytes: Uint8Array | null) => Promise, + ) => Promise, + ): Promise; + stop(): void; } + type AuthInfo = { + derp_id: number; + derp_name: string; + auth_key: string; + }; + + type Peer = { + id: number; + name: string; + ip: string; + cancel: () => void; + }; + interface WushSSHSession { resize(rows: number, cols: number): boolean; close(): boolean; } type WushConfig = { - authKey?: string; - }; - - type WushCallbacks = { - notifyState: (state: WushState) => void; - notifyNetMap: (netMapStr: string) => void; - notifyPanicRecover: (err: string) => void; + onNewPeer: (peer: Peer) => void; + // TODO: figure out what needs to be sent to the FE + // FE returns false if denying the file + onIncomingFile: ( + peer: Peer, + filename: string, + sizeBytes: number, + ) => boolean; + downloadFile: ( + peer: Peer, + filename: string, + sizeBytes: number, + stream: ReadableStream, + ) => Promise; }; } diff --git a/tsserver/server.go b/tsserver/server.go index d514059..1ae416c 100644 --- a/tsserver/server.go +++ b/tsserver/server.go @@ -243,7 +243,7 @@ func (s *server) NoiseUpgradeHandler(w http.ResponseWriter, r *http.Request) { peerUpdate: s.peerMapUpdate, node: &s.node, nodeUpdate: s.nodeUpdate, - getIP: s.overlay.IP, + getIPs: s.overlay.IPs, } noiseConn, err := controlhttp.AcceptHTTP( @@ -328,7 +328,7 @@ type noiseServer struct { machineKey key.MachinePublic nodeKey key.NodePublic derpMap *tailcfg.DERPMap - getIP func() netip.Addr + getIPs func() []netip.Addr peers *xsync.MapOf[tailcfg.NodeID, *tailcfg.Node] peerUpdate chan update @@ -357,7 +357,7 @@ func (ns *noiseServer) NoiseRegistrationHandler(w http.ResponseWriter, r *http.R sp := strings.SplitN(registerRequest.Auth.AuthKey, "-", 2) - ip := ns.getIP() + ips := ns.getIPs() resp := tailcfg.RegisterResponse{} resp.MachineAuthorized = true @@ -377,8 +377,10 @@ func (ns *noiseServer) NoiseRegistrationHandler(w http.ResponseWriter, r *http.R ns.nodeKey = registerRequest.NodeKey nodeID := tailcfg.NodeID(rand.Int64()) - - addr := netip.PrefixFrom(ip, 32) + addrs := []netip.Prefix{} + for _, ip := range ips { + addrs = append(addrs, netip.PrefixFrom(ip, ip.BitLen())) + } ns.storeNode(&tailcfg.Node{ ID: nodeID, @@ -390,8 +392,8 @@ func (ns *noiseServer) NoiseRegistrationHandler(w http.ResponseWriter, r *http.R Key: registerRequest.NodeKey, LastSeen: ptr.To(time.Now()), Cap: registerRequest.Version, - Addresses: []netip.Prefix{addr}, - AllowedIPs: []netip.Prefix{addr}, + Addresses: addrs, + AllowedIPs: addrs, CapMap: tailcfg.NodeCapMap{ tailcfg.CapabilityDebug: []tailcfg.RawMessage{"true"}, }, From 15432b0df9dd009e9105df784e6d08fec37c9cc4 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 18 Oct 2024 15:46:03 -0500 Subject: [PATCH 09/34] chore(web): make `onIncomingFile` async (#63) --- cmd/wasm/main.go | 12 +++++++++++- site/types/wush_js.d.ts | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index ed92f49..9baa029 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -505,7 +505,17 @@ func cpH(onIncomingFile js.Value, downloadFile js.Value) http.HandlerFunc { }), } - allow := onIncomingFile.Invoke(peer, fiName, r.ContentLength).Bool() + var allow bool + onIncomingFile.Invoke(peer, fiName, r.ContentLength). + Call("then", js.FuncOf(func(this js.Value, args []js.Value) any { + allow = args[0].Bool() + return nil + })). + Call("catch", js.FuncOf(func(this js.Value, args []js.Value) any { + fmt.Println("onIncomingFile failed:", args[0].String()) + allow = false + return nil + })) if !allow { w.WriteHeader(http.StatusForbidden) w.Write([]byte("File transfer was denied")) diff --git a/site/types/wush_js.d.ts b/site/types/wush_js.d.ts index 79bbaec..8b784db 100644 --- a/site/types/wush_js.d.ts +++ b/site/types/wush_js.d.ts @@ -60,7 +60,7 @@ declare global { peer: Peer, filename: string, sizeBytes: number, - ) => boolean; + ) => Promise; downloadFile: ( peer: Peer, filename: string, From 5f046697b5aa7e1214be375ffc5d20c10dbb003b Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 18 Oct 2024 16:30:24 -0500 Subject: [PATCH 10/34] chore(web): fix awaiting `onIncomingFile` --- cmd/wasm/main.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 9baa029..7bbe13a 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -505,18 +505,18 @@ func cpH(onIncomingFile js.Value, downloadFile js.Value) http.HandlerFunc { }), } - var allow bool + allow := make(chan bool) onIncomingFile.Invoke(peer, fiName, r.ContentLength). Call("then", js.FuncOf(func(this js.Value, args []js.Value) any { - allow = args[0].Bool() + allow <- args[0].Bool() return nil })). Call("catch", js.FuncOf(func(this js.Value, args []js.Value) any { fmt.Println("onIncomingFile failed:", args[0].String()) - allow = false + allow <- false return nil })) - if !allow { + if !<-allow { w.WriteHeader(http.StatusForbidden) w.Write([]byte("File transfer was denied")) r.Body.Close() From e45670cbd367edf8ec1e453034ba3f820ed56fb4 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 18 Oct 2024 17:04:39 -0500 Subject: [PATCH 11/34] fix(ssh): timeout stalled ssh connections after 15-30s (#64) --- cmd/wush/ssh.go | 2 +- go.mod | 4 ++-- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/wush/ssh.go b/cmd/wush/ssh.go index 8a51e9c..4e7b86a 100644 --- a/cmd/wush/ssh.go +++ b/cmd/wush/ssh.go @@ -86,7 +86,7 @@ func sshCmd() *serpent.Command { } } - return xssh.TailnetSSH(ctx, inv, ts, ip.String()+":3", quiet) + return xssh.TailnetSSH(ctx, inv, ts, netip.AddrPortFrom(ip, 3).String(), quiet) }, Options: []serpent.Option{ { diff --git a/go.mod b/go.mod index 1badb5c..c23fe34 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/coder/wush go 1.22.6 -replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20240926000438-059d0c1039af +replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20241018213052-0d22086da1b2 // replace tailscale.com => /home/colin/Projects/coadler/tailscale @@ -34,7 +34,7 @@ require ( golang.org/x/sys v0.26.0 golang.org/x/term v0.25.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da - tailscale.com v1.70.0 + tailscale.com v1.76.1 ) require ( diff --git a/go.sum b/go.sum index 123507e..0f2433c 100644 --- a/go.sum +++ b/go.sum @@ -145,8 +145,8 @@ github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= -github.com/coadler/tailscale v1.1.1-0.20240926000438-059d0c1039af h1:7h0hQxaizCT3u7Fu9b6k1NgGj4EHxx/K3H7YBAFanVE= -github.com/coadler/tailscale v1.1.1-0.20240926000438-059d0c1039af/go.mod h1:rRq+xvgprFys8sZbJgcAMMqpiP6r+Y75CJRhCRmXrd0= +github.com/coadler/tailscale v1.1.1-0.20241018213052-0d22086da1b2 h1:tPtLbP7XIycbAaHUW78XWWhnnGOe+/ejL7tT5wVfH50= +github.com/coadler/tailscale v1.1.1-0.20241018213052-0d22086da1b2/go.mod h1:rRq+xvgprFys8sZbJgcAMMqpiP6r+Y75CJRhCRmXrd0= github.com/coder/coder/v2 v2.16.0 h1:+IzbcLU7YFUp6knJJhS4xw8yphuqrIUKt7mIk7LwUQA= github.com/coder/coder/v2 v2.16.0/go.mod h1:/kiN4IfNwd5T7xGEyVbp7jiNOVaHtdiIDyLqN9OqboE= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= From 7a4d5d39943390154d0f5ae8493473eaf776e5b0 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 25 Oct 2024 17:40:12 -0500 Subject: [PATCH 12/34] chore: update README --- README.md | 80 +++++++++++++++++++++---------------------------------- 1 file changed, 30 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index bf8610a..1dd8a98 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,34 @@ shells over a peer-to-peer WireGuard connection. It's similar to 1. Automatic peer-to-peer connections over UDP. 1. Endless possibilities; rsync, ssh, etc. +## Basic Usage + +On the host machine: + +```bash +$ wush host +Picked DERP region Toronto as overlay home +Your auth key is: + > 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx +Use this key to authenticate other wush commands to this instance. +``` + +On the client machine: + +```bash +# Copy a file to the host +$ wush cp 1gb.txt +Uploading "1gb.txt" 100% |██████████████████████████████████████████████| (2.1/2.1 GB, 376 MB/s) + +# Open a shell to the host +$ wush ssh +┃ Enter the Auth key: +┃ > 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx +coder@colin:~$ +``` + +[![asciicast](https://asciinema.org/a/ZrCNiRRkeHUi5Lj3fqC3ovLqi.svg)](https://asciinema.org/a/ZrCNiRRkeHUi5Lj3fqC3ovLqi) + > [!NOTE] > `wush` uses Tailscale's [tsnet](https://tailscale.com/kb/1244/tsnet) package > under the hood, managed by an in-memory control server on each CLI. We utilize @@ -20,11 +48,13 @@ shells over a peer-to-peer WireGuard connection. It's similar to ## Install Using install script + ```bash curl -fsSL https://wush.dev/install.sh | sh ``` Using Homebrew + ```bash brew install wush ``` @@ -41,56 +71,6 @@ For a manual installation, see the [latest release](https://github.com/coder/wus > sudo setcap cap_net_admin=eip $(which wush) > ``` -## Basic Usage - -[![asciicast](https://asciinema.org/a/ZrCNiRRkeHUi5Lj3fqC3ovLqi.svg)](https://asciinema.org/a/ZrCNiRRkeHUi5Lj3fqC3ovLqi) - -On the host machine: - -```bash -$ wush host -Picked DERP region Toronto as overlay home -Your auth key is: - > 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx -Use this key to authenticate other wush commands to this instance. -05:18:59 WireGuard is ready -05:18:59 SSH server listening -``` - -On the client machine: - -```bash -# Copy a file to the receiver -$ wush cp 1gb.txt -Auth information: - > Server overlay STUN address: Disabled - > Server overlay DERP home: Toronto - > Server overlay public key: [NFWN0] - > Server overlay auth key: [mTbpN] -Bringing WireGuard up.. -WireGuard is ready! -Received peer -Peer active with relay nyc -Peer active over p2p 172.20.0.8:53768 -Uploading "1gb.txt" 100% |██████████████████████████████████████████████| (2.1/2.1 GB, 376 MB/s) - -# Open a shell to the receiver -$ wush ssh -┃ Enter the receiver's Auth key: -┃ > 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx -Auth information: - > Server overlay STUN address: Disabled - > Server overlay DERP home: Toronto - > Server overlay public key: [sEIS1] - > Server overlay auth key: [w/sYF] -Bringing WireGuard up.. -WireGuard is ready! -Received peer -Peer active with relay nyc -Peer active over p2p 172.20.0.8:44483 -coder@colin:~$ -``` - ## Technical Details `wush` doesn't require you to trust any 3rd party authentication or relay From 7955dfd1ba67b0f01a2463c1b5d01cf99450e053 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 25 Oct 2024 18:18:24 -0500 Subject: [PATCH 13/34] chore: cleanup logging --- cmd/wasm/{main.go => main_js.go} | 0 cmd/wush/rsync.go | 3 +-- overlay/receive.go | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) rename cmd/wasm/{main.go => main_js.go} (100%) diff --git a/cmd/wasm/main.go b/cmd/wasm/main_js.go similarity index 100% rename from cmd/wasm/main.go rename to cmd/wasm/main_js.go diff --git a/cmd/wush/rsync.go b/cmd/wush/rsync.go index c64a02c..3873601 100644 --- a/cmd/wush/rsync.go +++ b/cmd/wush/rsync.go @@ -61,8 +61,7 @@ func rsyncCmd() *serpent.Command { progPath, overlayOpts.clientAuth.AuthKey(), strings.Join(inv.Args, " "), ), } - fmt.Println(args) - fmt.Println("Running: rsync", strings.Join(inv.Args, " ")) + fmt.Println("Running rsync", strings.Join(inv.Args, " ")) cmd := exec.CommandContext(ctx, "sh", args...) cmd.Stdin = inv.Stdin cmd.Stdout = inv.Stdout diff --git a/overlay/receive.go b/overlay/receive.go index 41cf5be..2cabbd0 100644 --- a/overlay/receive.go +++ b/overlay/receive.go @@ -357,7 +357,7 @@ func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, n } r.HumanLogf("%s Received connection request over %s from %s", cliui.Timestamp(time.Now()), system, cliui.Keyword(fmt.Sprintf("%s@%s", username, hostname))) case messageTypeNodeUpdate: - r.HumanLogf("%s Received updated node from %s", cliui.Timestamp(time.Now()), cliui.Code(ovMsg.Node.Key.String())) + r.Logger.Debug("received updated node", slog.String("node_key", ovMsg.Node.Key.String())) r.in <- &ovMsg.Node res.Typ = messageTypeNodeUpdate if lastNode := r.lastNode.Load(); lastNode != nil { From b1a8855c78ea35b3c6acabaf1e49a2854c7c714b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Oct 2024 19:37:37 -0500 Subject: [PATCH 14/34] chore(deps): bump actions/setup-go from 5.0.2 to 5.1.0 (#69) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2671df5..9193c1d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,7 @@ jobs: with: # Allow goreleaser to access older tag information. fetch-depth: 0 - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: "go.mod" cache: true From e355b4d07f68e09cb2a2578cb109790b6f53a61e Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 3 Dec 2024 01:23:46 -0600 Subject: [PATCH 15/34] feat(site): convert to next, complete visual overhaul (#78) Co-authored-by: Kyle Carberry --- .gitignore | 6 + cmd/wasm/main_js.go | 224 +- cmd/wush/cp.go | 104 +- cmd/wush/serve.go | 8 +- go.mod | 46 +- go.sum | 92 +- overlay/auth.go | 27 + overlay/overlay.go | 12 +- overlay/receive.go | 217 +- overlay/send.go | 126 +- overlay/{wasm.go => wasm_js.go} | 223 +- site/.gitignore | 5 - site/app/assets/wasm_exec.js | 561 -- site/app/components/Terminal.client.tsx | 116 - site/app/context/wush.ts | 3 - site/app/entry.client.tsx | 7 - site/app/entry.server.tsx | 155 - site/app/root.tsx | 125 - site/app/routes/_index.tsx | 116 - site/app/routes/connect.tsx | 32 - site/app/tailwind.css | 10 - site/biome.json | 7 - site/build_wasm.sh | 6 +- site/bun.lockb | Bin 0 -> 86633 bytes site/components.json | 20 + site/components/layout.tsx | 201 + site/components/ui/button.tsx | 57 + site/components/ui/progress.tsx | 31 + site/components/ui/sonner.tsx | 30 + site/components/ui/table.tsx | 120 + site/context/wush.tsx | 494 ++ site/cors-config.json | 8 - site/deploy.sh | 21 - site/globals.css | 78 + site/lib/utils.ts | 6 + site/next-env.d.ts | 5 + site/next.config.ts | 16 + site/package.json | 70 +- site/pages/_app.tsx | 36 + site/pages/_document.tsx | 27 + site/pages/access.tsx | 41 + site/pages/index.tsx | 9 + site/pages/receive.tsx | 93 + site/pages/send.tsx | 352 ++ site/pages/terminal.tsx | 150 + site/pnpm-lock.yaml | 7614 ----------------------- site/postcss.config.js | 2 +- site/public/android-chrome-192x192.png | Bin 0 -> 3145 bytes site/public/android-chrome-512x512.png | Bin 0 -> 11714 bytes site/public/apple-touch-icon.png | Bin 0 -> 2769 bytes site/public/favicon-16x16.png | Bin 0 -> 386 bytes site/public/favicon-32x32.png | Bin 0 -> 540 bytes site/public/favicon.ico | Bin 1053 -> 15406 bytes site/public/site.webmanifest | 19 + site/remix.config.ts | 4 - site/tailwind.config.ts | 123 +- site/tsconfig.json | 35 +- site/types/wush_js.d.ts | 33 +- site/vite.config.ts | 31 - site/wrangler.toml | 85 - tsserver/server.go | 22 +- xssh/windowsize_js.go | 12 + xssh/windowsize_other.go | 4 +- 63 files changed, 2818 insertions(+), 9259 deletions(-) rename overlay/{wasm.go => wasm_js.go} (58%) delete mode 100644 site/.gitignore delete mode 100644 site/app/assets/wasm_exec.js delete mode 100644 site/app/components/Terminal.client.tsx delete mode 100644 site/app/context/wush.ts delete mode 100644 site/app/entry.client.tsx delete mode 100644 site/app/entry.server.tsx delete mode 100644 site/app/root.tsx delete mode 100644 site/app/routes/_index.tsx delete mode 100644 site/app/routes/connect.tsx delete mode 100644 site/app/tailwind.css delete mode 100644 site/biome.json create mode 100755 site/bun.lockb create mode 100644 site/components.json create mode 100644 site/components/layout.tsx create mode 100644 site/components/ui/button.tsx create mode 100644 site/components/ui/progress.tsx create mode 100644 site/components/ui/sonner.tsx create mode 100644 site/components/ui/table.tsx create mode 100644 site/context/wush.tsx delete mode 100644 site/cors-config.json delete mode 100755 site/deploy.sh create mode 100644 site/globals.css create mode 100644 site/lib/utils.ts create mode 100644 site/next-env.d.ts create mode 100644 site/next.config.ts create mode 100644 site/pages/_app.tsx create mode 100644 site/pages/_document.tsx create mode 100644 site/pages/access.tsx create mode 100644 site/pages/index.tsx create mode 100644 site/pages/receive.tsx create mode 100644 site/pages/send.tsx create mode 100644 site/pages/terminal.tsx delete mode 100644 site/pnpm-lock.yaml create mode 100644 site/public/android-chrome-192x192.png create mode 100644 site/public/android-chrome-512x512.png create mode 100644 site/public/apple-touch-icon.png create mode 100644 site/public/favicon-16x16.png create mode 100644 site/public/favicon-32x32.png create mode 100644 site/public/site.webmanifest delete mode 100644 site/remix.config.ts delete mode 100644 site/vite.config.ts delete mode 100644 site/wrangler.toml create mode 100644 xssh/windowsize_js.go diff --git a/.gitignore b/.gitignore index ea90573..358f71f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ test main.wasm test.txt +.next/ +.env +site/public/*.js +site/public/*.wasm +site/node_modules/ +.env.local diff --git a/cmd/wasm/main_js.go b/cmd/wasm/main_js.go index 7bbe13a..09451df 100644 --- a/cmd/wasm/main_js.go +++ b/cmd/wasm/main_js.go @@ -3,6 +3,7 @@ package main import ( + "bufio" "bytes" "context" "fmt" @@ -15,8 +16,10 @@ import ( "syscall/js" "time" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/wush/overlay" "github.com/coder/wush/tsserver" + "github.com/pion/webrtc/v4" "golang.org/x/crypto/ssh" "golang.org/x/xerrors" "tailscale.com/ipn/store" @@ -46,10 +49,6 @@ func main() { promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) })) - js.Global().Set("exitWush", js.FuncOf(func(this js.Value, args []js.Value) any { - // close(ch) - return nil - })) // Keep the main function running <-make(chan struct{}, 0) @@ -66,7 +65,12 @@ func newWush(cfg js.Value) map[string]any { panic(err) } - ov := overlay.NewWasmOverlay(log.Printf, dm, cfg.Get("onNewPeer")) + ov := overlay.NewWasmOverlay(log.Printf, dm, + cfg.Get("onNewPeer"), + cfg.Get("onWebrtcOffer"), + cfg.Get("onWebrtcAnswer"), + cfg.Get("onWebrtcCandidate"), + ) err = ov.PickDERPHome(ctx) if err != nil { @@ -116,9 +120,10 @@ func newWush(cfg js.Value) map[string]any { } return map[string]any{ - "derp_id": ov.DerpRegionID, - "derp_name": ov.DerpMap.Regions[int(ov.DerpRegionID)].RegionName, - "auth_key": ov.ClientAuth().AuthKey(), + "derp_id": ov.DerpRegionID, + "derp_name": ov.DerpMap.Regions[int(ov.DerpRegionID)].RegionName, + "derp_latency": ov.DerpLatency.Milliseconds(), + "auth_key": ov.ClientAuth().AuthKey(), } }), "stop": js.FuncOf(func(this js.Value, args []js.Value) any { @@ -131,14 +136,14 @@ func newWush(cfg js.Value) map[string]any { return nil }), "ssh": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 1 { - log.Printf("Usage: ssh({})") + if len(args) != 2 { + log.Printf("Usage: ssh(peer, config)") return nil } sess := &sshSession{ ts: ts, - cfg: args[0], + cfg: args[1], } go sess.Run() @@ -160,9 +165,9 @@ func newWush(cfg js.Value) map[string]any { reject := promiseArgs[1] go func() { - if len(args) != 1 { + if len(args) != 2 { errorConstructor := js.Global().Get("Error") - errorObject := errorConstructor.New("Usage: connect(authKey)") + errorObject := errorConstructor.New("Usage: connect(authKey, offer)") reject.Invoke(errorObject) return } @@ -172,7 +177,18 @@ func newWush(cfg js.Value) map[string]any { authKey = args[0].String() } else { errorConstructor := js.Global().Get("Error") - errorObject := errorConstructor.New("Usage: connect(authKey)") + errorObject := errorConstructor.New("Usage: connect(authKey, offer)") + reject.Invoke(errorObject) + return + } + + var offer webrtc.SessionDescription + if jsOffer := args[1]; jsOffer.Type() == js.TypeObject { + offer.SDP = jsOffer.Get("sdp").String() + offer.Type = webrtc.NewSDPType(jsOffer.Get("type").String()) + } else { + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New("Usage: connect(authKey, offer)") reject.Invoke(errorObject) return } @@ -187,11 +203,11 @@ func newWush(cfg js.Value) map[string]any { } ctx, cancel := context.WithCancel(context.Background()) - peer, err := ov.Connect(ctx, ca) + peer, err := ov.Connect(ctx, ca, offer) if err != nil { cancel() errorConstructor := js.Global().Get("Error") - errorObject := errorConstructor.New(fmt.Errorf("parse authkey: %w", err).Error()) + errorObject := errorConstructor.New(fmt.Errorf("connect to peer: %w", err).Error()) reject.Invoke(errorObject) return } @@ -200,6 +216,7 @@ func newWush(cfg js.Value) map[string]any { "id": js.ValueOf(peer.ID), "name": js.ValueOf(peer.Name), "ip": js.ValueOf(peer.IP.String()), + "type": js.ValueOf(peer.Type), "cancel": js.FuncOf(func(this js.Value, args []js.Value) any { cancel() return nil @@ -220,7 +237,7 @@ func newWush(cfg js.Value) map[string]any { if len(args) != 5 { errorConstructor := js.Global().Get("Error") - errorObject := errorConstructor.New("Usage: transfer(peer, file)") + errorObject := errorConstructor.New("Usage: transfer(peer, fileName, sizeBytes, stream, onProgress)") reject.Invoke(errorObject) return nil } @@ -228,53 +245,26 @@ func newWush(cfg js.Value) map[string]any { peer := args[0] ip := peer.Get("ip").String() fileName := args[1].String() - sizeBytes := args[2].Int() + sizeBytes := int64(args[2].Int()) stream := args[3] - streamHelper := args[4] - - pr, pw := io.Pipe() - - goCallback := js.FuncOf(func(this js.Value, args []js.Value) interface{} { - promiseConstructor := js.Global().Get("Promise") - return promiseConstructor.New(js.FuncOf(func(this js.Value, promiseArgs []js.Value) any { - resolve := promiseArgs[0] - _ = promiseArgs[1] - go func() { - if len(args) == 0 || args[0].IsNull() || args[0].IsUndefined() { - pw.Close() - resolve.Invoke() - return - } - - fmt.Println("in go callback") - // Convert the JavaScript Uint8Array to a Go byte slice - uint8Array := args[0] - fmt.Println("type is", uint8Array.Type().String()) - length := uint8Array.Get("length").Int() - buf := make([]byte, length) - js.CopyBytesToGo(buf, uint8Array) - - fmt.Println("sending data to channel") - // Send the data to the channel - if _, err := pw.Write(buf); err != nil { - pw.CloseWithError(err) - } - fmt.Println("callback finished") - - // Resolve the promise - resolve.Invoke() - }() - return nil - })) - }) + onProgress := args[4] go func() { - defer goCallback.Release() - - streamHelper.Invoke(stream, goCallback) - - hc := ts.HTTPClient() - req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s:4444/%s", ip, fileName), pr) + startTime := time.Now() + reader := &jsStreamReader{ + reader: stream.Call("getReader"), + onProgress: onProgress, + totalSize: sizeBytes, + } + bufferSize := 1024 * 1024 + hc := &http.Client{ + Transport: &http.Transport{ + DialContext: ts.Dial, + ReadBufferSize: bufferSize, + WriteBufferSize: bufferSize, + }, + } + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s:4444/%s", ip, fileName), bufio.NewReaderSize(reader, bufferSize)) if err != nil { errorConstructor := js.Global().Get("Error") errorObject := errorConstructor.New(err.Error()) @@ -283,6 +273,7 @@ func newWush(cfg js.Value) map[string]any { } req.ContentLength = int64(sizeBytes) + fmt.Printf("Starting transfer of %d bytes\n", sizeBytes) res, err := hc.Do(req) if err != nil { errorConstructor := js.Global().Get("Error") @@ -295,7 +286,10 @@ func newWush(cfg js.Value) map[string]any { bod := bytes.NewBuffer(nil) _, _ = io.Copy(bod, res.Body) - fmt.Println(bod.String()) + duration := time.Since(startTime) + speed := float64(sizeBytes) / duration.Seconds() / 1024 / 1024 // MB/s + fmt.Printf("Transfer completed in %v. Speed: %.2f MB/s\n", duration, speed) + resolve.Invoke() }() @@ -305,6 +299,36 @@ func newWush(cfg js.Value) map[string]any { promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) }), + + "sendWebrtcCandidate": js.FuncOf(func(this js.Value, args []js.Value) any { + peer := args[0].String() + candidate := args[1] + + ov.SendWebrtcCandidate(peer, webrtc.ICECandidateInit{ + Candidate: candidate.Get("candidate").String(), + SDPMLineIndex: ptr.Ref(uint16(candidate.Get("sdpMLineIndex").Int())), + SDPMid: ptr.Ref(candidate.Get("sdpMid").String()), + UsernameFragment: ptr.Ref(candidate.Get("sdpMid").String()), + }) + + return nil + }), + + "parseAuthKey": js.FuncOf(func(this js.Value, args []js.Value) any { + authKey := args[0].String() + + var ca overlay.ClientAuth + _ = ca.Parse(authKey) + typ := "cli" + if ca.Web { + typ = "web" + } + + return map[string]any{ + "id": js.ValueOf(ca.ReceiverPublicKey.String()), + "type": js.ValueOf(typ), + } + }), } } @@ -359,7 +383,7 @@ func (s *sshSession) Run() { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second))) defer cancel() reportProgress(fmt.Sprintf("Connecting...")) - c, err := s.ts.Dial(ctx, "tcp", net.JoinHostPort("100.64.0.0", "3")) + c, err := s.ts.Dial(ctx, "tcp", net.JoinHostPort("fd7a:115c:a1e0::1", "3")) if err != nil { writeError("Dial", err) return @@ -538,8 +562,8 @@ func cpH(onIncomingFile js.Value, downloadFile js.Value) http.HandlerFunc { // Read the entire stream and pass it to JavaScript for { - // Read up to 16KB at a time - buf := make([]byte, 16384) + // Read up to 1MB at a time + buf := make([]byte, 1024*1024) n, err := r.Body.Read(buf) if err != nil && err != io.EOF { // Tell the controller we have an error @@ -582,3 +606,73 @@ func cpH(onIncomingFile js.Value, downloadFile js.Value) http.HandlerFunc { downloadFile.Invoke(peer, fiName, r.ContentLength, readableStream) } } + +// jsStreamReader implements io.Reader for JavaScript streams +type jsStreamReader struct { + reader js.Value + onProgress js.Value + bytesRead int64 + totalSize int64 + buffer bytes.Buffer +} + +func (r *jsStreamReader) Read(p []byte) (n int, err error) { + if r.bytesRead >= r.totalSize { + return 0, io.EOF + } + + fmt.Printf("Read %d bytes\n", len(p)) + + // If we have buffered data, use it first + if r.buffer.Len() > 0 { + n, _ = r.buffer.Read(p) + r.bytesRead += int64(n) + + if r.onProgress.Truthy() { + r.onProgress.Invoke(r.bytesRead) + } + return n, nil + } + + // Only read from stream if buffer is empty + promise := r.reader.Call("read") + result := await(promise) + + if result.Get("done").Bool() { + if r.bytesRead < r.totalSize { + return 0, fmt.Errorf("stream ended prematurely at %d/%d bytes", r.bytesRead, r.totalSize) + } + return 0, io.EOF + } + + // Get the chunk from JavaScript and write it to our buffer + value := result.Get("value") + chunk := make([]byte, value.Length()) + js.CopyBytesToGo(chunk, value) + r.buffer.Write(chunk) + + // Now read what we can into p + n, _ = r.buffer.Read(p) + r.bytesRead += int64(n) + + if r.onProgress.Truthy() { + r.onProgress.Invoke(r.bytesRead) + } + + return n, nil +} + +// Helper function to await a JavaScript promise +func await(promise js.Value) js.Value { + done := make(chan js.Value) + promise.Call("then", js.FuncOf(func(_ js.Value, args []js.Value) interface{} { + done <- args[0] + return nil + })) + return <-done +} + +func (r *jsStreamReader) Close() error { + r.reader.Call("releaseLock") + return nil +} diff --git a/cmd/wush/cp.go b/cmd/wush/cp.go index 349e640..54ac6f5 100644 --- a/cmd/wush/cp.go +++ b/cmd/wush/cp.go @@ -12,12 +12,14 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/charmbracelet/huh" "github.com/coder/serpent" "github.com/coder/wush/cliui" "github.com/coder/wush/overlay" "github.com/coder/wush/tsserver" + "github.com/pion/webrtc/v4" "github.com/schollz/progressbar/v3" "tailscale.com/net/netns" "tailscale.com/tailcfg" @@ -164,6 +166,94 @@ func cpCmd() *serpent.Command { } go s.ListenAndServe(ctx) + + fiPath := inv.Args[0] + fiName := filepath.Base(inv.Args[0]) + + fi, err := os.Open(fiPath) + if err != nil { + return err + } + defer fi.Close() + + fiStat, err := fi.Stat() + if err != nil { + return err + } + + if send.Auth.Web { + meta := overlay.RtcMetadata{ + Type: overlay.RtcMetadataTypeFileMetadata, + FileMetadata: overlay.RtcFileMetadata{ + FileName: fiName, + FileSize: int(fiStat.Size()), + }, + } + + raw, err := json.Marshal(meta) + if err != nil { + panic(err) + } + + logf("Waiting for data channel to open...") + for { + if send.RtcDc.ReadyState() == webrtc.DataChannelStateOpen { + break + } + time.Sleep(100 * time.Millisecond) + } + logf("Data channel is open!") + + if err := send.RtcDc.SendText(string(raw)); err != nil { + panic(err) + } + + bar := progressbar.DefaultBytes( + fiStat.Size(), + fmt.Sprintf("Uploading %q", fiPath), + ) + barReader := progressbar.NewReader(fi, bar) + + buf := make([]byte, 16384) + + for { + n, err := barReader.Read(buf) + if err != nil && err != io.EOF { + return err + } + + if n > 0 { + if err := send.RtcDc.Send(buf[:n]); err != nil { + fmt.Println("failed to send file data: ", err) + return err + } + } + + if err == io.EOF { + break + } + } + + meta = overlay.RtcMetadata{ + Type: overlay.RtcMetadataTypeFileComplete, + } + + raw, err = json.Marshal(meta) + if err != nil { + panic(err) + } + + if err := send.RtcDc.SendText(string(raw)); err != nil { + fmt.Println("failed to send file complete message", err) + } + + select { + case <-send.WaitTransferDone: + logger.Info("received file transfer acknowledgment") + return nil + } + } + netns.SetDialerOverride(s.Dialer()) ts, err := newTSNet("send", verbose) if err != nil { @@ -191,20 +281,6 @@ func cpCmd() *serpent.Command { } } - fiPath := inv.Args[0] - fiName := filepath.Base(inv.Args[0]) - - fi, err := os.Open(fiPath) - if err != nil { - return err - } - defer fi.Close() - - fiStat, err := fi.Stat() - if err != nil { - return err - } - bar := progressbar.DefaultBytes( fiStat.Size(), fmt.Sprintf("Uploading %q", fiPath), diff --git a/cmd/wush/serve.go b/cmd/wush/serve.go index 2dc2a92..902aa42 100644 --- a/cmd/wush/serve.go +++ b/cmd/wush/serve.go @@ -106,7 +106,7 @@ func serveCmd() *serpent.Command { ts.Up(ctx) fs := afero.NewOsFs() - hlog("WireGuard is ready") + // hlog("WireGuard is ready") closers := []io.Closer{} @@ -129,7 +129,7 @@ func serveCmd() *serpent.Command { closers = append(closers, sshListener) // TODO: replace these logs with all of the options in the beginning. - hlog("SSH server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) + // hlog("SSH server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) go func() { err := sshSrv.Serve(sshListener) if err != nil { @@ -147,7 +147,7 @@ func serveCmd() *serpent.Command { } closers = append([]io.Closer{cpListener}, closers...) - hlog("File transfer server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) + // hlog("File transfer server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) go func() { err := http.Serve(cpListener, http.HandlerFunc(cpHandler)) if err != nil { @@ -171,7 +171,7 @@ func serveCmd() *serpent.Command { bicopy(ctx, src, dst) }, true }) - hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) + // hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) } else { hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) } diff --git a/go.mod b/go.mod index c23fe34..0fa8c4f 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,10 @@ module github.com/coder/wush -go 1.22.6 +go 1.23.1 -replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20241018213052-0d22086da1b2 +toolchain go1.23.2 + +replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20241122221419-49dfbfcd5e09 // replace tailscale.com => /home/colin/Projects/coadler/tailscale @@ -17,21 +19,25 @@ require ( github.com/coder/serpent v0.8.0 github.com/go-chi/chi/v5 v5.1.0 github.com/google/uuid v1.6.0 - github.com/klauspost/compress v1.17.10 + github.com/klauspost/compress v1.17.11 github.com/mattn/go-isatty v0.0.20 github.com/mitchellh/go-wordwrap v1.0.1 github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a + github.com/pion/datachannel v1.5.9 + github.com/pion/logging v0.2.2 github.com/pion/stun/v3 v3.0.0 + github.com/pion/webrtc/v4 v4.0.1 github.com/prometheus/client_golang v1.20.4 github.com/puzpuzpuz/xsync/v3 v3.4.0 github.com/schollz/progressbar/v3 v3.16.1 github.com/spf13/afero v1.11.0 github.com/valyala/fasthttp v1.56.0 + go.uber.org/atomic v1.11.0 go4.org/mem v0.0.0-20220726221520-4f986261bf13 golang.org/x/crypto v0.28.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.30.0 - golang.org/x/sys v0.26.0 + golang.org/x/sys v0.27.0 golang.org/x/term v0.25.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da tailscale.com v1.76.1 @@ -82,9 +88,10 @@ require ( github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/coder/terraform-provider-coder v1.0.2 // indirect + github.com/coder/websocket v1.8.12 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect github.com/coreos/go-oidc/v3 v3.11.0 // indirect - github.com/creack/pty v1.1.21 // indirect + github.com/creack/pty v1.1.23 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -123,7 +130,7 @@ require ( github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect - github.com/illarion/gonotify v1.0.1 // indirect + github.com/illarion/gonotify/v2 v2.0.3 // indirect github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect @@ -154,10 +161,19 @@ require ( github.com/outcaste-io/ristretto v0.2.3 // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/pion/dtls/v3 v3.0.1 // indirect - github.com/pion/logging v0.2.2 // indirect + github.com/pion/dtls/v3 v3.0.3 // indirect + github.com/pion/ice/v4 v4.0.2 // indirect + github.com/pion/interceptor v0.1.37 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.14 // indirect + github.com/pion/rtp v1.8.9 // indirect + github.com/pion/sctp v1.8.33 // indirect + github.com/pion/sdp/v3 v3.0.9 // indirect + github.com/pion/srtp/v3 v3.0.4 // indirect github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect github.com/pion/udp v0.1.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.13.6 // indirect @@ -178,15 +194,14 @@ require ( github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect - github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect - github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 // indirect + github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect github.com/tinylib/msgp v1.1.8 // indirect github.com/u-root/u-root v0.14.0 // indirect github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a // indirect - github.com/vishvananda/netlink v1.2.1-beta.2 // indirect github.com/vishvananda/netns v0.0.4 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect @@ -196,18 +211,17 @@ require ( github.com/zclconf/go-cty v1.15.0 // indirect github.com/zeebo/errs v1.3.0 // indirect go.nhat.io/otelsql v0.14.0 // indirect - go.opentelemetry.io/otel v1.30.0 // indirect + go.opentelemetry.io/otel v1.32.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect - go.opentelemetry.io/otel/metric v1.30.0 // indirect + go.opentelemetry.io/otel/metric v1.32.0 // indirect go.opentelemetry.io/otel/sdk v1.30.0 // indirect - go.opentelemetry.io/otel/trace v1.30.0 // indirect + go.opentelemetry.io/otel/trace v1.32.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - go.uber.org/atomic v1.11.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/sync v0.8.0 // indirect + golang.org/x/sync v0.9.0 // indirect golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.6.0 // indirect golang.org/x/tools v0.25.0 // indirect diff --git a/go.sum b/go.sum index 0f2433c..48aa9ee 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DataDog/appsec-internal-go v1.7.0 h1:iKRNLih83dJeVya3IoUfK+6HLD/hQsIbyBlfvLmAeb0= @@ -145,8 +145,8 @@ github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= -github.com/coadler/tailscale v1.1.1-0.20241018213052-0d22086da1b2 h1:tPtLbP7XIycbAaHUW78XWWhnnGOe+/ejL7tT5wVfH50= -github.com/coadler/tailscale v1.1.1-0.20241018213052-0d22086da1b2/go.mod h1:rRq+xvgprFys8sZbJgcAMMqpiP6r+Y75CJRhCRmXrd0= +github.com/coadler/tailscale v1.1.1-0.20241122221419-49dfbfcd5e09 h1:wPojSLHQAFdOWuM1qGslacPBy4G+2mBvrwasW5sRU1E= +github.com/coadler/tailscale v1.1.1-0.20241122221419-49dfbfcd5e09/go.mod h1:OQHJodEdJx4e8lKkSpYmK2H/B1D/VN0EU7g9imiGj2k= github.com/coder/coder/v2 v2.16.0 h1:+IzbcLU7YFUp6knJJhS4xw8yphuqrIUKt7mIk7LwUQA= github.com/coder/coder/v2 v2.16.0/go.mod h1:/kiN4IfNwd5T7xGEyVbp7jiNOVaHtdiIDyLqN9OqboE= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= @@ -157,12 +157,14 @@ github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuO github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= github.com/coder/terraform-provider-coder v1.0.2 h1:xKbnJF/XUxcUJlZoC3ZkNOj4PZvk5Stdkel2TCZluDQ= github.com/coder/terraform-provider-coder v1.0.2/go.mod h1:1f3EjO+DA9QcIbM7sBSk/Ffw3u7kh6vXNBIQfV59yUk= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= -github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= -github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= +github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -325,8 +327,8 @@ github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1 h1:jWoR2Yqg8tzM0v github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= -github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= +github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= +github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= @@ -345,8 +347,8 @@ github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4os github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= -github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0= -github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= @@ -434,10 +436,30 @@ github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pion/dtls/v3 v3.0.1 h1:0kmoaPYLAo0md/VemjcrAXQiSf8U+tuU3nDYVNpEKaw= -github.com/pion/dtls/v3 v3.0.1/go.mod h1:dfIXcFkKoujDQ+jtd8M6RgqKK3DuaUilm3YatAbGp5k= +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.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM= +github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU= +github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s= +github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg= +github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= +github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= +github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= +github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw= +github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= +github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= 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/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc= @@ -445,8 +467,12 @@ github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQp github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= github.com/pion/udp v0.1.4 h1:OowsTmu1Od3sD6i3fQUJxJn2fEvJO6L1TidgadtbTI8= github.com/pion/udp v0.1.4/go.mod h1:G8LDo56HsFwC24LIcnT4YIDU5qcB6NepqqjP0keL2us= +github.com/pion/webrtc/v4 v4.0.1 h1:6Unwc6JzoTsjxetcAIoWH81RUM4K5dBc1BbJGcF9WVE= +github.com/pion/webrtc/v4 v4.0.1/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= @@ -524,16 +550,16 @@ github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPx github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= -github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk= -github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w= github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU= github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g= github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 h1:RNpJrXfI5u6e+uzyIzvmnXbhmhdRkVf//90sMBH3lso= -github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ= +github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -552,8 +578,6 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U= github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI= -github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= -github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= @@ -585,10 +609,10 @@ github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtC go.nhat.io/otelsql v0.14.0 h1:Mz4xo+WVQLAOPZy6abxjVzZzNe8xoOUh/tOMJoxo3oo= go.nhat.io/otelsql v0.14.0/go.mod h1:iO9KfDBZO2WI6O7n+ippHe5OHdXQ5iiA2aIa3Kzywo8= go.opentelemetry.io/contrib v1.19.0 h1:rnYI7OEPMWFeM4QCqWQ3InMJ0arWMR1i0Cx9A5hcjYM= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts= -go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= @@ -597,14 +621,14 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.30.0 h1:IyFlqNsi8VT/nw go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.30.0/go.mod h1:bxiX8eUeKoAEQmbq/ecUT8UqZwCjZW52yJrXJUSozsk= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.30.0 h1:kn1BudCgwtE7PxLqcZkErpD8GKqLZ6BSzeW9QihQJeM= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.30.0/go.mod h1:ljkUDtAMdleoi9tIG1R6dJUpVwDcYjw3J2Q6Q/SuiC0= -go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w= -go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE= go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg= go.opentelemetry.io/otel/sdk/metric v1.30.0 h1:QJLT8Pe11jyHBHfSAgYH7kEmT24eX792jZO1bo4BXkM= go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y= -go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc= -go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -628,8 +652,8 @@ golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= -golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a h1:8qmSSA8Gz/1kTrCe0nqR0R3Gb/NDhykzWw2q2mWZydM= -golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -661,8 +685,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -700,8 +724,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.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.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -787,8 +811,8 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8= gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= -honnef.co/go/tools v0.4.6 h1:oFEHCKeID7to/3autwsWfnuv69j3NsfcXbvJKuIcep8= -honnef.co/go/tools v0.4.6/go.mod h1:+rnGS1THNh8zMwnd2oVOTL9QF6vmfyG6ZXBULae2uc0= +honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= +honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= diff --git a/overlay/auth.go b/overlay/auth.go index ccff82a..d337650 100644 --- a/overlay/auth.go +++ b/overlay/auth.go @@ -15,6 +15,7 @@ import ( ) type ClientAuth struct { + Web bool // OverlayPrivateKey is the main auth mechanism used to secure the overlay. // Peers are sent this private key to encrypt node communication to the // receiver. Leaking this private key would allow anyone to connect. @@ -49,6 +50,14 @@ func (ca *ClientAuth) PrintDebug(logf func(str string, args ...any), dm *tailcfg func (ca *ClientAuth) AuthKey() string { buf := bytes.NewBuffer(nil) + buf.WriteByte(1) + + if ca.Web { + buf.WriteByte(1) + } else { + buf.WriteByte(0) + } + buf.WriteByte(byte(ca.ReceiverStunAddr.Addr().BitLen() / 8)) if ca.ReceiverStunAddr.Addr().BitLen() > 0 { stunBytes, err := ca.ReceiverStunAddr.MarshalBinary() @@ -78,6 +87,24 @@ func (ca *ClientAuth) Parse(authKey string) error { decr := bytes.NewReader(base58.Decode(authKey)) + ver, err := decr.ReadByte() + if err != nil { + return errors.New("read authkey version") + } + + if ver != 1 { + return fmt.Errorf("unsupported authkey version %q", ver) + } + + typ, err := decr.ReadByte() + if err != nil { + return errors.New("read authkey peer type") + } + + if typ == 1 { + ca.Web = true + } + ipLenB, err := decr.ReadByte() if err != nil { return errors.New("read STUN ip len; invalid authkey") diff --git a/overlay/overlay.go b/overlay/overlay.go index 698ea20..66f6066 100644 --- a/overlay/overlay.go +++ b/overlay/overlay.go @@ -4,15 +4,18 @@ import ( "net/netip" "github.com/google/uuid" + "github.com/pion/webrtc/v4" "tailscale.com/tailcfg" ) +type Logf func(format string, args ...any) + // Overlay specifies the mechanism by which senders and receivers exchange // Tailscale nodes over a sidechannel. type Overlay interface { // listenOverlay(ctx context.Context, kind string) error Recv() <-chan *tailcfg.Node - Send() chan<- *tailcfg.Node + SendTailscaleNodeUpdate(node *tailcfg.Node) IPs() []netip.Addr } @@ -24,6 +27,10 @@ const ( messageTypeHello messageTypeHelloResponse messageTypeNodeUpdate + + messageTypeWebRTCOffer + messageTypeWebRTCAnswer + messageTypeWebRTCCandidate ) type overlayMessage struct { @@ -31,6 +38,9 @@ type overlayMessage struct { HostInfo HostInfo Node tailcfg.Node + + WebrtcDescription *webrtc.SessionDescription + WebrtcCandidate *webrtc.ICECandidateInit } type HostInfo struct { diff --git a/overlay/receive.go b/overlay/receive.go index 2cabbd0..8122bf3 100644 --- a/overlay/receive.go +++ b/overlay/receive.go @@ -1,3 +1,6 @@ +//go:build !js && !wasm +// +build !js,!wasm + package overlay import ( @@ -5,15 +8,19 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net" "net/netip" + "os" "sync" "sync/atomic" "time" "github.com/pion/stun/v3" + "github.com/pion/webrtc/v4" "github.com/puzpuzpuz/xsync/v3" + "github.com/schollz/progressbar/v3" "tailscale.com/derp" "tailscale.com/derp/derphttp" "tailscale.com/net/netcheck" @@ -26,17 +33,16 @@ import ( "github.com/coder/wush/cliui" ) -type Logf func(format string, args ...any) - func NewReceiveOverlay(logger *slog.Logger, hlog Logf, dm *tailcfg.DERPMap) *Receive { return &Receive{ - Logger: logger, - HumanLogf: hlog, - DerpMap: dm, - SelfPriv: key.NewNode(), - PeerPriv: key.NewNode(), - in: make(chan *tailcfg.Node, 8), - out: make(chan *tailcfg.Node, 8), + Logger: logger, + HumanLogf: hlog, + DerpMap: dm, + SelfPriv: key.NewNode(), + PeerPriv: key.NewNode(), + webrtcConns: xsync.NewMapOf[key.NodePublic, *webrtc.PeerConnection](), + in: make(chan *tailcfg.Node, 8), + out: make(chan *overlayMessage, 8), } } @@ -59,11 +65,13 @@ type Receive struct { // communication. derpRegionID uint16 + webrtcConns *xsync.MapOf[key.NodePublic, *webrtc.PeerConnection] + lastNode atomic.Pointer[tailcfg.Node] // in funnels node updates from other peers to us in chan *tailcfg.Node // out fans out our node updates to peers - out chan *tailcfg.Node + out chan *overlayMessage } func (r *Receive) IPs() []netip.Addr { @@ -75,7 +83,16 @@ func (r *Receive) IPs() []netip.Addr { } } +var webrtcConfig = webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{ + { + URLs: []string{"stun:stun.l.google.com:19302"}, + }, + }, +} + func (r *Receive) PickDERPHome(ctx context.Context) error { + nm := netmon.NewStatic() nc := netcheck.Client{ NetMon: nm, @@ -112,10 +129,15 @@ func (r *Receive) Recv() <-chan *tailcfg.Node { return r.in } -func (r *Receive) Send() chan<- *tailcfg.Node { - return r.out +func (r *Receive) SendTailscaleNodeUpdate(node *tailcfg.Node) { + r.out <- &overlayMessage{ + Typ: messageTypeNodeUpdate, + Node: *node.Clone(), + } } +// gonna have to do something special for per-peer webrtc connections + func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error) { srvAddr, err := net.ResolveUDPAddr("udp4", "stun.l.google.com:19302") if err != nil { @@ -161,14 +183,13 @@ func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error select { case <-ctx.Done(): return - case node := <-r.out: - r.lastNode.Store(node) - raw, err := json.Marshal(overlayMessage{ - Typ: messageTypeNodeUpdate, - Node: *node, - }) + case msg := <-r.out: + if msg.Typ == messageTypeNodeUpdate { + r.lastNode.Store(&msg.Node) + } + raw, err := json.Marshal(msg) if err != nil { - panic("marshal node: " + err.Error()) + panic("marshal overlay msg: " + err.Error()) } sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw) @@ -235,7 +256,7 @@ func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error continue } - res, key, err := r.handleNextMessage(buf, "STUN") + res, key, err := r.handleNextMessage(key.NodePublic{}, buf, "STUN") if err != nil { r.HumanLogf("Failed to handle overlay message: %s", err.Error()) continue @@ -274,14 +295,13 @@ func (r *Receive) ListenOverlayDERP(ctx context.Context) error { select { case <-ctx.Done(): return - case node := <-r.out: - r.lastNode.Store(node) - raw, err := json.Marshal(overlayMessage{ - Typ: messageTypeNodeUpdate, - Node: *node, - }) + case msg := <-r.out: + if msg.Typ == messageTypeNodeUpdate { + r.lastNode.Store(&msg.Node) + } + raw, err := json.Marshal(msg) if err != nil { - panic("marshal node: " + err.Error()) + panic("marshal overlay msg: " + err.Error()) } sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw) @@ -305,7 +325,7 @@ func (r *Receive) ListenOverlayDERP(ctx context.Context) error { switch msg := msg.(type) { case derp.ReceivedPacket: - res, key, err := r.handleNextMessage(msg.Data, "DERP") + res, key, err := r.handleNextMessage(msg.Source, msg.Data, "DERP") if err != nil { r.HumanLogf("Failed to handle overlay message from %s: %s", msg.Source.ShortString(), err.Error()) continue @@ -324,7 +344,7 @@ func (r *Receive) ListenOverlayDERP(ctx context.Context) error { } } -func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, nodeKey key.NodePublic, _ error) { +func (r *Receive) handleNextMessage(src key.NodePublic, msg []byte, system string) (resRaw []byte, nodeKey key.NodePublic, _ error) { cleartext, ok := r.SelfPriv.OpenFrom(r.PeerPriv.Public(), msg) if !ok { return nil, key.NodePublic{}, errors.New("message failed decryption") @@ -355,6 +375,11 @@ func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, n if lastNode := r.lastNode.Load(); lastNode != nil { res.Node = *lastNode } + + if ovMsg.WebrtcDescription != nil { + r.setupWebrtcConnection(src, &res, *ovMsg.WebrtcDescription) + } + r.HumanLogf("%s Received connection request over %s from %s", cliui.Timestamp(time.Now()), system, cliui.Keyword(fmt.Sprintf("%s@%s", username, hostname))) case messageTypeNodeUpdate: r.Logger.Debug("received updated node", slog.String("node_key", ovMsg.Node.Key.String())) @@ -363,6 +388,18 @@ func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, n if lastNode := r.lastNode.Load(); lastNode != nil { res.Node = *lastNode } + + case messageTypeWebRTCCandidate: + pc, ok := r.webrtcConns.Load(src) + if !ok { + fmt.Println("got candidate for unknown connection") + break + } + + err := pc.AddICECandidate(*ovMsg.WebrtcCandidate) + if err != nil { + fmt.Println("failed to add ice candidate:", err) + } } if res.Typ == 0 { @@ -377,3 +414,125 @@ func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, n sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw) return sealed, ovMsg.Node.Key, nil } + +func (r *Receive) setupWebrtcConnection(src key.NodePublic, res *overlayMessage, offer webrtc.SessionDescription) { + // Configure larger buffer sizes + settingEngine := webrtc.SettingEngine{} + // Set maximum message size to 16MB + settingEngine.SetSCTPMaxReceiveBufferSize(64 * 1024 * 1024) + + api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine)) + + // Use the custom API to create the peer connection + peerConnection, err := api.NewPeerConnection(webrtcConfig) + if err != nil { + panic(err) + } + + peerConnection.OnConnectionStateChange(func(s webrtc.PeerConnectionState) { + switch s { + case webrtc.PeerConnectionStateConnected: + case webrtc.PeerConnectionStateDisconnected: + case webrtc.PeerConnectionStateFailed: + case webrtc.PeerConnectionStateClosed: + } + }) + + peerConnection.OnDataChannel(func(d *webrtc.DataChannel) { + // Increase buffer sizes + d.SetBufferedAmountLowThreshold(65535) + + var ( + fi *os.File + bar *progressbar.ProgressBar + mw io.Writer + fiSize int + read int + ) + + d.OnMessage(func(msg webrtc.DataChannelMessage) { + if msg.IsString { + meta := RtcMetadata{} + + err := json.Unmarshal(msg.Data, &meta) + if err != nil { + fmt.Println("failed to unmarshal file metadata:") + d.Close() + return + } + + if meta.Type == RtcMetadataTypeFileMetadata { + fiSize = meta.FileMetadata.FileSize + fi, err = os.OpenFile(meta.FileMetadata.FileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) + if err != nil { + fmt.Println("failed to open file", err) + } + + bar = progressbar.DefaultBytes( + int64(fiSize), + fmt.Sprintf("Downloading %q", meta.FileMetadata.FileName), + ) + mw = io.MultiWriter(fi, bar) + } + + } else { + read += len(msg.Data) + if fi == nil { + fmt.Println("Error: Received binary data before file was opened") + d.Close() + return + } + + _, err := mw.Write(msg.Data) + if err != nil { + fmt.Printf("Failed to write file data: %v\n", err) + d.Close() + return + } + + if read >= fiSize { + bar.Close() + fmt.Printf("Successfully wrote file %s (%d bytes)\n", fi.Name(), read) + err := fi.Close() + if err != nil { + fmt.Printf("Error closing file: %v\n", err) + } + fi = nil + bar = nil + mw = nil + } + } + }) + }) + + peerConnection.OnICECandidate(func(i *webrtc.ICECandidate) { + if i == nil { + return + } + ic := i.ToJSON() + + r.out <- &overlayMessage{ + Typ: messageTypeWebRTCCandidate, + WebrtcCandidate: &ic, + } + }) + + err = peerConnection.SetRemoteDescription(offer) + if err != nil { + fmt.Println("failed to set remote description:", err) + } + + answer, err := peerConnection.CreateAnswer(nil) + if err != nil { + fmt.Println("failed to create answer:", err) + } + + err = peerConnection.SetLocalDescription(answer) + if err != nil { + fmt.Println("failed to set local description:", err) + } + + res.WebrtcDescription = &answer + + r.webrtcConns.Store(src, peerConnection) +} diff --git a/overlay/send.go b/overlay/send.go index be417fc..f4da132 100644 --- a/overlay/send.go +++ b/overlay/send.go @@ -1,3 +1,6 @@ +//go:build !js && !wasm +// +build !js,!wasm + package overlay import ( @@ -13,6 +16,7 @@ import ( "time" "github.com/coder/wush/cliui" + "github.com/pion/webrtc/v4" "tailscale.com/derp" "tailscale.com/derp/derphttp" "tailscale.com/net/netmon" @@ -21,12 +25,16 @@ import ( ) func NewSendOverlay(logger *slog.Logger, dm *tailcfg.DERPMap) *Send { - return &Send{ - derpMap: dm, - in: make(chan *tailcfg.Node, 8), - out: make(chan *tailcfg.Node, 8), - SelfIP: randv6(), + s := &Send{ + derpMap: dm, + in: make(chan *tailcfg.Node, 8), + out: make(chan *overlayMessage, 8), + waitIce: make(chan struct{}), + WaitTransferDone: make(chan struct{}), + SelfIP: randv6(), } + s.setupWebrtcConnection() + return s } type Send struct { @@ -38,8 +46,14 @@ type Send struct { Auth ClientAuth + RtcConn *webrtc.PeerConnection + RtcDc *webrtc.DataChannel + offer webrtc.SessionDescription + waitIce chan struct{} + WaitTransferDone chan struct{} + in chan *tailcfg.Node - out chan *tailcfg.Node + out chan *overlayMessage } func (s *Send) IPs() []netip.Addr { @@ -50,8 +64,11 @@ func (s *Send) Recv() <-chan *tailcfg.Node { return s.in } -func (s *Send) Send() chan<- *tailcfg.Node { - return s.out +func (s *Send) SendTailscaleNodeUpdate(node *tailcfg.Node) { + s.out <- &overlayMessage{ + Typ: messageTypeNodeUpdate, + Node: *node.Clone(), + } } func (s *Send) ListenOverlaySTUN(ctx context.Context) error { @@ -83,14 +100,10 @@ func (s *Send) ListenOverlaySTUN(ctx context.Context) error { select { case <-ctx.Done(): return - case node := <-s.out: - msg := overlayMessage{ - Typ: messageTypeNodeUpdate, - Node: *node, - } + case msg := <-s.out: raw, err := json.Marshal(msg) if err != nil { - panic("marshal node: " + err.Error()) + panic("marshal overlay msg: " + err.Error()) } sealed := s.Auth.OverlayPrivateKey.SealTo(s.Auth.ReceiverPublicKey, raw) @@ -167,14 +180,10 @@ func (s *Send) ListenOverlayDERP(ctx context.Context) error { select { case <-ctx.Done(): return - case node := <-s.out: - msg := overlayMessage{ - Typ: messageTypeNodeUpdate, - Node: *node, - } + case msg := <-s.out: raw, err := json.Marshal(msg) if err != nil { - panic("marshal node: " + err.Error()) + panic("marshal overlay msg: " + err.Error()) } sealed := s.Auth.OverlayPrivateKey.SealTo(s.Auth.ReceiverPublicKey, raw) @@ -235,6 +244,7 @@ func (s *Send) newHelloPacket() []byte { Username: username, Hostname: hostname, }, + WebrtcDescription: &s.offer, }) if err != nil { panic("marshal node: " + err.Error()) @@ -244,6 +254,21 @@ func (s *Send) newHelloPacket() []byte { return sealed } +const ( + RtcMetadataTypeFileMetadata = "file_metadata" + RtcMetadataTypeFileComplete = "file_complete" + RtcMetadataTypeFileAck = "file_ack" +) + +type RtcMetadata struct { + Type string `json:"type"` + FileMetadata RtcFileMetadata `json:"fileMetadata"` +} +type RtcFileMetadata struct { + FileName string `json:"fileName"` + FileSize int `json:"fileSize"` +} + func (s *Send) handleNextMessage(msg []byte) (resRaw []byte, _ error) { cleartext, ok := s.Auth.OverlayPrivateKey.OpenFrom(s.Auth.ReceiverPublicKey, msg) if !ok { @@ -264,8 +289,12 @@ func (s *Send) handleNextMessage(msg []byte) (resRaw []byte, _ error) { // do nothing case messageTypeHelloResponse: s.in <- &ovMsg.Node + close(s.waitIce) + s.RtcConn.SetRemoteDescription(*ovMsg.WebrtcDescription) case messageTypeNodeUpdate: s.in <- &ovMsg.Node + case messageTypeWebRTCCandidate: + s.RtcConn.AddICECandidate(*ovMsg.WebrtcCandidate) } if res.Typ == 0 { @@ -280,3 +309,60 @@ func (s *Send) handleNextMessage(msg []byte) (resRaw []byte, _ error) { sealed := s.Auth.OverlayPrivateKey.SealTo(s.Auth.ReceiverPublicKey, raw) return sealed, nil } + +func (s *Send) setupWebrtcConnection() { + var err error + s.RtcConn, err = webrtc.NewPeerConnection(webrtcConfig) + if err != nil { + panic("failed to create webrtc connection: " + err.Error()) + } + + s.RtcConn.OnICECandidate(func(i *webrtc.ICECandidate) { + if i == nil { + return + } + ic := i.ToJSON() + + <-s.waitIce + s.out <- &overlayMessage{ + Typ: messageTypeWebRTCCandidate, + WebrtcCandidate: &ic, + } + }) + + s.RtcDc, err = s.RtcConn.CreateDataChannel("fileTransfer", nil) + if err != nil { + fmt.Println("failed to create dc:", err) + } + + // Add message handler to our created data channel + s.RtcDc.OnMessage(func(msg webrtc.DataChannelMessage) { + if msg.IsString { + meta := RtcMetadata{} + + err := json.Unmarshal(msg.Data, &meta) + if err != nil { + fmt.Println("failed to unmarshal metadata:", err) + return + } + + if meta.Type == RtcMetadataTypeFileAck { + close(s.WaitTransferDone) + return + } + return + } + }) + + answer, err := s.RtcConn.CreateOffer(nil) + if err != nil { + fmt.Println("failed to create answer:", err) + } + + err = s.RtcConn.SetLocalDescription(answer) + if err != nil { + fmt.Println("failed to set local description:", err) + } + + s.offer = answer +} diff --git a/overlay/wasm.go b/overlay/wasm_js.go similarity index 58% rename from overlay/wasm.go rename to overlay/wasm_js.go index c129b22..c7293f7 100644 --- a/overlay/wasm.go +++ b/overlay/wasm_js.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "log/slog" - "math/rand/v2" "net/netip" "sync" "sync/atomic" @@ -16,6 +15,7 @@ import ( "time" "github.com/coder/wush/cliui" + "github.com/pion/webrtc/v4" "github.com/puzpuzpuz/xsync/v3" "tailscale.com/derp" "tailscale.com/derp/derphttp" @@ -24,9 +24,15 @@ import ( "tailscale.com/net/portmapper" "tailscale.com/tailcfg" "tailscale.com/types/key" + "tailscale.com/types/logger" ) -func NewWasmOverlay(hlog Logf, dm *tailcfg.DERPMap, onNewPeer js.Value) *Wasm { +func NewWasmOverlay(hlog Logf, dm *tailcfg.DERPMap, + onNewPeer js.Value, + onWebrtcOffer js.Value, + onWebrtcAnswer js.Value, + onWebrtcCandidate js.Value, +) *Wasm { return &Wasm{ HumanLogf: hlog, DerpMap: dm, @@ -34,10 +40,13 @@ func NewWasmOverlay(hlog Logf, dm *tailcfg.DERPMap, onNewPeer js.Value) *Wasm { PeerPriv: key.NewNode(), SelfIP: randv6(), - peers: xsync.NewMapOf[int32, chan<- *tailcfg.Node](), - onNewPeer: onNewPeer, - in: make(chan *tailcfg.Node, 8), - out: make(chan *tailcfg.Node, 8), + onNewPeer: onNewPeer, + onWebrtcOffer: onWebrtcOffer, + onWebrtcAnswer: onWebrtcAnswer, + onWebrtcCandidate: onWebrtcCandidate, + + in: make(chan *tailcfg.Node, 8), + out: make(chan *overlayMessage, 8), } } @@ -61,16 +70,21 @@ type Wasm struct { // DerpRegionID is the DERP region that can be used for proxied overlay // communication. DerpRegionID uint16 + DerpLatency time.Duration // peers is a map of channels that notify peers of node updates. - peers *xsync.MapOf[int32, chan<- *tailcfg.Node] - onNewPeer js.Value + activePeer atomic.Pointer[chan *overlayMessage] + onNewPeer js.Value + + onWebrtcOffer js.Value + onWebrtcAnswer js.Value + onWebrtcCandidate js.Value lastNode atomic.Pointer[tailcfg.Node] // in funnels node updates from other peers to us in chan *tailcfg.Node - // out fans out our node updates to peers - out chan *tailcfg.Node + // out fans out our node updates to peers connected to us + out chan *overlayMessage } func (r *Wasm) IPs() []netip.Addr { @@ -93,9 +107,11 @@ func (r *Wasm) PickDERPHome(ctx context.Context) error { if report.PreferredDERP == 0 { r.HumanLogf("Failed to determine overlay DERP region, defaulting to %s.", cliui.Code("NYC")) r.DerpRegionID = 1 + r.DerpLatency = report.RegionLatency[1] } else { r.HumanLogf("Picked DERP region %s as overlay home", cliui.Code(r.DerpMap.Regions[report.PreferredDERP].RegionName)) r.DerpRegionID = uint16(report.PreferredDERP) + r.DerpLatency = report.RegionLatency[report.PreferredDERP] } return nil @@ -103,6 +119,7 @@ func (r *Wasm) PickDERPHome(ctx context.Context) error { func (r *Wasm) ClientAuth() *ClientAuth { return &ClientAuth{ + Web: true, OverlayPrivateKey: r.PeerPriv, ReceiverPublicKey: r.SelfPriv.Public(), ReceiverDERPRegionID: r.DerpRegionID, @@ -113,19 +130,32 @@ func (r *Wasm) Recv() <-chan *tailcfg.Node { return r.in } -func (r *Wasm) Send() chan<- *tailcfg.Node { - return r.out +func (r *Wasm) SendTailscaleNodeUpdate(node *tailcfg.Node) { + r.out <- &overlayMessage{ + Typ: messageTypeNodeUpdate, + Node: *node.Clone(), + } +} + +func (r *Wasm) SendWebrtcCandidate(peer string, cand webrtc.ICECandidateInit) { + + fmt.Println("go: sending webrtc candidate") + r.out <- &overlayMessage{ + Typ: messageTypeWebRTCCandidate, + WebrtcCandidate: &cand, + } } type Peer struct { - ID int32 + ID string Name string IP netip.Addr + Type string } -func (r *Wasm) Connect(ctx context.Context, ca ClientAuth) (Peer, error) { +func (r *Wasm) Connect(ctx context.Context, ca ClientAuth, offer webrtc.SessionDescription) (Peer, error) { derpPriv := key.NewNode() - c := derphttp.NewRegionClient(derpPriv, func(format string, args ...any) {}, netmon.NewStatic(), func() *tailcfg.DERPRegion { + c := derphttp.NewRegionClient(derpPriv, logger.Logf(r.HumanLogf), netmon.NewStatic(), func() *tailcfg.DERPRegion { return r.DerpMap.Regions[int(ca.ReceiverDERPRegionID)] }) @@ -134,32 +164,36 @@ func (r *Wasm) Connect(ctx context.Context, ca ClientAuth) (Peer, error) { return Peer{}, err } - sealed := r.newHelloPacket(ca) + sealed := r.newHelloPacket(ca, offer) err = c.Send(ca.ReceiverPublicKey, sealed) if err != nil { return Peer{}, fmt.Errorf("send overlay hello over derp: %w", err) } - updates := make(chan *tailcfg.Node, 2) + updates := make(chan *overlayMessage, 8) - peerID := rand.Int32() - r.peers.Store(peerID, updates) + old := r.activePeer.Swap(&updates) + if old != nil { + close(*old) + } go func() { - defer r.peers.Delete(peerID) + defer r.activePeer.CompareAndSwap(&updates, nil) + defer c.Close() for { select { case <-ctx.Done(): return - case node := <-updates: - msg := overlayMessage{ - Typ: messageTypeNodeUpdate, - Node: *node, + case msg, ok := <-updates: + if !ok { + c.Close() + return } + raw, err := json.Marshal(msg) if err != nil { - panic("marshal node: " + err.Error()) + panic("marshal overlay msg: " + err.Error()) } sealed := ca.OverlayPrivateKey.SealTo(ca.ReceiverPublicKey, raw) @@ -175,6 +209,7 @@ func (r *Wasm) Connect(ctx context.Context, ca ClientAuth) (Peer, error) { waitHello := make(chan struct{}) closeOnce := sync.Once{} helloResp := overlayMessage{} + helloSrc := key.NodePublic{} go func() { for { @@ -191,7 +226,7 @@ func (r *Wasm) Connect(ctx context.Context, ca ClientAuth) (Peer, error) { continue } - res, _, ovmsg, err := r.handleNextMessage(ca.OverlayPrivateKey, ca.ReceiverPublicKey, msg.Data) + res, _, ovmsg, err := r.handleNextMessage(msg.Source, ca.OverlayPrivateKey, ca.ReceiverPublicKey, msg.Data) if err != nil { fmt.Println("Failed to handle overlay message:", err) continue @@ -207,6 +242,7 @@ func (r *Wasm) Connect(ctx context.Context, ca ClientAuth) (Peer, error) { if ovmsg.Typ == messageTypeHelloResponse { helloResp = ovmsg + helloSrc = msg.Source closeOnce.Do(func() { close(waitHello) }) @@ -217,17 +253,26 @@ func (r *Wasm) Connect(ctx context.Context, ca ClientAuth) (Peer, error) { select { case <-time.After(10 * time.Second): + c.Close() return Peer{}, errors.New("timed out waiting for peer to respond") case <-waitHello: - updates <- r.lastNode.Load() + updates <- &overlayMessage{ + Typ: messageTypeNodeUpdate, + Node: *r.lastNode.Load(), + } if len(helloResp.Node.Addresses) == 0 { return Peer{}, fmt.Errorf("peer has no addresses") } ip := helloResp.Node.Addresses[0].Addr() + typ := "cli" + if ca.Web { + typ = "web" + } return Peer{ - ID: peerID, + ID: helloSrc.String(), IP: ip, Name: helloResp.HostInfo.Username, + Type: typ, }, nil } } @@ -243,7 +288,7 @@ func (r *Wasm) ListenOverlayDERP(ctx context.Context) error { return err } - // node priv -> derp priv + // node pub -> derp pub peers := xsync.NewMapOf[key.NodePublic, key.NodePublic]() go func() { @@ -252,14 +297,13 @@ func (r *Wasm) ListenOverlayDERP(ctx context.Context) error { select { case <-ctx.Done(): return - case node := <-r.out: - r.lastNode.Store(node) - raw, err := json.Marshal(overlayMessage{ - Typ: messageTypeNodeUpdate, - Node: *node, - }) + case msg := <-r.out: + if msg.Typ == messageTypeNodeUpdate { + r.lastNode.Store(&msg.Node) + } + raw, err := json.Marshal(msg) if err != nil { - panic("marshal node: " + err.Error()) + panic("marshal overlay msg: " + err.Error()) } sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw) @@ -273,12 +317,14 @@ func (r *Wasm) ListenOverlayDERP(ctx context.Context) error { } return true }) - // range over peers that we have connected to - r.peers.Range(func(key int32, value chan<- *tailcfg.Node) bool { - fmt.Println("sending node to outbound peer") - value <- node.Clone() - return true - }) + if selectedPeer := r.activePeer.Load(); selectedPeer != nil { + // *selectedPeer <- &overlayMessage{ + // Typ: messageTypeNodeUpdate, + // Node: *msg.Node.Clone(), + // } + *selectedPeer <- msg + fmt.Println("sending message") + } } } }() @@ -291,13 +337,15 @@ func (r *Wasm) ListenOverlayDERP(ctx context.Context) error { switch msg := msg.(type) { case derp.ReceivedPacket: - res, key, _, err := r.handleNextMessage(r.SelfPriv, r.PeerPriv.Public(), msg.Data) + res, key, _, err := r.handleNextMessage(msg.Source, r.SelfPriv, r.PeerPriv.Public(), msg.Data) if err != nil { r.HumanLogf("Failed to handle overlay message: %s", err.Error()) continue } - peers.Store(key, msg.Source) + if !key.IsZero() { + peers.Store(key, msg.Source) + } if res != nil { err = c.Send(msg.Source, res) @@ -310,7 +358,7 @@ func (r *Wasm) ListenOverlayDERP(ctx context.Context) error { } } -func (r *Wasm) newHelloPacket(ca ClientAuth) []byte { +func (r *Wasm) newHelloPacket(ca ClientAuth, offer webrtc.SessionDescription) []byte { var ( username string = r.username hostname string = "wush.dev" @@ -322,7 +370,8 @@ func (r *Wasm) newHelloPacket(ca ClientAuth) []byte { Username: username, Hostname: hostname, }, - Node: *r.lastNode.Load(), + Node: *r.lastNode.Load(), + WebrtcDescription: &offer, }) if err != nil { panic("marshal node: " + err.Error()) @@ -332,15 +381,17 @@ func (r *Wasm) newHelloPacket(ca ClientAuth) []byte { return sealed } -func (r *Wasm) handleNextMessage(selfPriv key.NodePrivate, peerPub key.NodePublic, msg []byte) (resRaw []byte, nodeKey key.NodePublic, _ overlayMessage, _ error) { +func (r *Wasm) handleNextMessage(derpPub key.NodePublic, selfPriv key.NodePrivate, peerPub key.NodePublic, msg []byte) (resRaw []byte, nodeKey key.NodePublic, _ overlayMessage, _ error) { cleartext, ok := selfPriv.OpenFrom(peerPub, msg) if !ok { return nil, key.NodePublic{}, overlayMessage{}, errors.New("message failed decryption") } var ovMsg overlayMessage + fmt.Println(string(cleartext)) err := json.Unmarshal(cleartext, &ovMsg) if err != nil { + fmt.Printf("Unmarshal error: %#v\n", err) panic("unmarshal node: " + err.Error()) } @@ -368,22 +419,62 @@ func (r *Wasm) handleNextMessage(selfPriv key.NodePrivate, peerPub key.NodePubli r.HumanLogf("%s Received connection request from %s", cliui.Timestamp(time.Now()), cliui.Keyword(fmt.Sprintf("%s@%s", username, hostname))) // TODO: impl r.onNewPeer.Invoke(map[string]any{ - "id": js.ValueOf(0), - "name": js.ValueOf(""), - "ip": js.ValueOf(""), + "id": js.ValueOf(derpPub.String()), + "name": js.ValueOf("test"), + "ip": js.ValueOf("1.2.3.4"), "cancel": js.FuncOf(func(this js.Value, args []js.Value) any { return nil }), }) + + if ovMsg.WebrtcDescription != nil { + r.handleWebrtcOffer(derpPub, &res, *ovMsg.WebrtcDescription) + } + case messageTypeHelloResponse: if !ovMsg.Node.Key.IsZero() { r.in <- &ovMsg.Node } + + if ovMsg.WebrtcDescription != nil { + r.onWebrtcAnswer.Invoke(js.ValueOf(derpPub.String()), map[string]any{ + "type": js.ValueOf(ovMsg.WebrtcDescription.Type.String()), + "sdp": js.ValueOf(ovMsg.WebrtcDescription.SDP), + }) + } + case messageTypeNodeUpdate: r.HumanLogf("%s Received updated node from %s", cliui.Timestamp(time.Now()), cliui.Code(ovMsg.Node.Key.String())) if !ovMsg.Node.Key.IsZero() { r.in <- &ovMsg.Node } + + case messageTypeWebRTCOffer: + res.Typ = messageTypeWebRTCAnswer + r.handleWebrtcOffer(derpPub, &res, *ovMsg.WebrtcDescription) + + case messageTypeWebRTCAnswer: + r.onWebrtcAnswer.Invoke(js.ValueOf(derpPub.String()), js.ValueOf(map[string]any{ + "type": js.ValueOf(ovMsg.WebrtcDescription.Type.String()), + "sdp": js.ValueOf(ovMsg.WebrtcDescription.SDP), + })) + + case messageTypeWebRTCCandidate: + cand := map[string]any{ + "candidate": js.ValueOf(ovMsg.WebrtcCandidate.Candidate), + } + if ovMsg.WebrtcCandidate.SDPMLineIndex != nil { + cand["sdpMLineIndex"] = js.ValueOf(int(*ovMsg.WebrtcCandidate.SDPMLineIndex)) + } + if ovMsg.WebrtcCandidate.SDPMid != nil { + cand["sdpMid"] = js.ValueOf(*ovMsg.WebrtcCandidate.SDPMid) + } + if ovMsg.WebrtcCandidate.UsernameFragment != nil { + cand["usernameFragment"] = js.ValueOf(*ovMsg.WebrtcCandidate.UsernameFragment) + } + + r.onWebrtcCandidate.Invoke(derpPub.String(), cand) + } if res.Typ == 0 { @@ -398,3 +489,35 @@ func (r *Wasm) handleNextMessage(selfPriv key.NodePrivate, peerPub key.NodePubli sealed := selfPriv.SealTo(peerPub, raw) return sealed, ovMsg.Node.Key, ovMsg, nil } + +func (r *Wasm) handleWebrtcOffer(derpPub key.NodePublic, res *overlayMessage, offer webrtc.SessionDescription) { + wait := make(chan struct{}) + + then := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + defer close(wait) + desc := args[0] + + fmt.Printf("desc %#v\n", desc) + res.WebrtcDescription = &webrtc.SessionDescription{} + res.WebrtcDescription.Type = webrtc.NewSDPType(desc.Get("type").String()) + res.WebrtcDescription.SDP = desc.Get("sdp").String() + + return nil + }) + defer then.Release() + catch := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + defer close(wait) + err := args[0] + errStr := err.Call("toString").String() + + fmt.Println("rtc offer callback failed:", errStr) + return nil + }) + defer catch.Release() + + r.onWebrtcOffer.Invoke(js.ValueOf(derpPub.String()), map[string]any{ + "type": js.ValueOf(offer.Type.String()), + "sdp": js.ValueOf(offer.SDP), + }).Call("then", then).Call("catch", catch) + <-wait +} diff --git a/site/.gitignore b/site/.gitignore deleted file mode 100644 index 80ec311..0000000 --- a/site/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules - -/.cache -/build -.env diff --git a/site/app/assets/wasm_exec.js b/site/app/assets/wasm_exec.js deleted file mode 100644 index bc6f210..0000000 --- a/site/app/assets/wasm_exec.js +++ /dev/null @@ -1,561 +0,0 @@ -// Copyright 2018 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -"use strict"; - -(() => { - const enosys = () => { - const err = new Error("not implemented"); - err.code = "ENOSYS"; - return err; - }; - - if (!globalThis.fs) { - let outputBuf = ""; - globalThis.fs = { - constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused - writeSync(fd, buf) { - outputBuf += decoder.decode(buf); - const nl = outputBuf.lastIndexOf("\n"); - if (nl != -1) { - console.log(outputBuf.substring(0, nl)); - outputBuf = outputBuf.substring(nl + 1); - } - return buf.length; - }, - write(fd, buf, offset, length, position, callback) { - if (offset !== 0 || length !== buf.length || position !== null) { - callback(enosys()); - return; - } - const n = this.writeSync(fd, buf); - callback(null, n); - }, - chmod(path, mode, callback) { callback(enosys()); }, - chown(path, uid, gid, callback) { callback(enosys()); }, - close(fd, callback) { callback(enosys()); }, - fchmod(fd, mode, callback) { callback(enosys()); }, - fchown(fd, uid, gid, callback) { callback(enosys()); }, - fstat(fd, callback) { callback(enosys()); }, - fsync(fd, callback) { callback(null); }, - ftruncate(fd, length, callback) { callback(enosys()); }, - lchown(path, uid, gid, callback) { callback(enosys()); }, - link(path, link, callback) { callback(enosys()); }, - lstat(path, callback) { callback(enosys()); }, - mkdir(path, perm, callback) { callback(enosys()); }, - open(path, flags, mode, callback) { callback(enosys()); }, - read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, - readdir(path, callback) { callback(enosys()); }, - readlink(path, callback) { callback(enosys()); }, - rename(from, to, callback) { callback(enosys()); }, - rmdir(path, callback) { callback(enosys()); }, - stat(path, callback) { callback(enosys()); }, - symlink(path, link, callback) { callback(enosys()); }, - truncate(path, length, callback) { callback(enosys()); }, - unlink(path, callback) { callback(enosys()); }, - utimes(path, atime, mtime, callback) { callback(enosys()); }, - }; - } - - if (!globalThis.process) { - globalThis.process = { - getuid() { return -1; }, - getgid() { return -1; }, - geteuid() { return -1; }, - getegid() { return -1; }, - getgroups() { throw enosys(); }, - pid: -1, - ppid: -1, - umask() { throw enosys(); }, - cwd() { throw enosys(); }, - chdir() { throw enosys(); }, - } - } - - if (!globalThis.crypto) { - throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); - } - - if (!globalThis.performance) { - throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); - } - - if (!globalThis.TextEncoder) { - throw new Error("globalThis.TextEncoder is not available, polyfill required"); - } - - if (!globalThis.TextDecoder) { - throw new Error("globalThis.TextDecoder is not available, polyfill required"); - } - - const encoder = new TextEncoder("utf-8"); - const decoder = new TextDecoder("utf-8"); - - globalThis.Go = class { - constructor() { - this.argv = ["js"]; - this.env = {}; - this.exit = (code) => { - if (code !== 0) { - console.warn("exit code:", code); - } - }; - this._exitPromise = new Promise((resolve) => { - this._resolveExitPromise = resolve; - }); - this._pendingEvent = null; - this._scheduledTimeouts = new Map(); - this._nextCallbackTimeoutID = 1; - - const setInt64 = (addr, v) => { - this.mem.setUint32(addr + 0, v, true); - this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); - } - - const setInt32 = (addr, v) => { - this.mem.setUint32(addr + 0, v, true); - } - - const getInt64 = (addr) => { - const low = this.mem.getUint32(addr + 0, true); - const high = this.mem.getInt32(addr + 4, true); - return low + high * 4294967296; - } - - const loadValue = (addr) => { - const f = this.mem.getFloat64(addr, true); - if (f === 0) { - return undefined; - } - if (!isNaN(f)) { - return f; - } - - const id = this.mem.getUint32(addr, true); - return this._values[id]; - } - - const storeValue = (addr, v) => { - const nanHead = 0x7FF80000; - - if (typeof v === "number" && v !== 0) { - if (isNaN(v)) { - this.mem.setUint32(addr + 4, nanHead, true); - this.mem.setUint32(addr, 0, true); - return; - } - this.mem.setFloat64(addr, v, true); - return; - } - - if (v === undefined) { - this.mem.setFloat64(addr, 0, true); - return; - } - - let id = this._ids.get(v); - if (id === undefined) { - id = this._idPool.pop(); - if (id === undefined) { - id = this._values.length; - } - this._values[id] = v; - this._goRefCounts[id] = 0; - this._ids.set(v, id); - } - this._goRefCounts[id]++; - let typeFlag = 0; - switch (typeof v) { - case "object": - if (v !== null) { - typeFlag = 1; - } - break; - case "string": - typeFlag = 2; - break; - case "symbol": - typeFlag = 3; - break; - case "function": - typeFlag = 4; - break; - } - this.mem.setUint32(addr + 4, nanHead | typeFlag, true); - this.mem.setUint32(addr, id, true); - } - - const loadSlice = (addr) => { - const array = getInt64(addr + 0); - const len = getInt64(addr + 8); - return new Uint8Array(this._inst.exports.mem.buffer, array, len); - } - - const loadSliceOfValues = (addr) => { - const array = getInt64(addr + 0); - const len = getInt64(addr + 8); - const a = new Array(len); - for (let i = 0; i < len; i++) { - a[i] = loadValue(array + i * 8); - } - return a; - } - - const loadString = (addr) => { - const saddr = getInt64(addr + 0); - const len = getInt64(addr + 8); - return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); - } - - const timeOrigin = Date.now() - performance.now(); - this.importObject = { - _gotest: { - add: (a, b) => a + b, - }, - gojs: { - // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) - // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported - // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). - // This changes the SP, thus we have to update the SP used by the imported function. - - // func wasmExit(code int32) - "runtime.wasmExit": (sp) => { - sp >>>= 0; - const code = this.mem.getInt32(sp + 8, true); - this.exited = true; - delete this._inst; - delete this._values; - delete this._goRefCounts; - delete this._ids; - delete this._idPool; - this.exit(code); - }, - - // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) - "runtime.wasmWrite": (sp) => { - sp >>>= 0; - const fd = getInt64(sp + 8); - const p = getInt64(sp + 16); - const n = this.mem.getInt32(sp + 24, true); - fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); - }, - - // func resetMemoryDataView() - "runtime.resetMemoryDataView": (sp) => { - sp >>>= 0; - this.mem = new DataView(this._inst.exports.mem.buffer); - }, - - // func nanotime1() int64 - "runtime.nanotime1": (sp) => { - sp >>>= 0; - setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); - }, - - // func walltime() (sec int64, nsec int32) - "runtime.walltime": (sp) => { - sp >>>= 0; - const msec = (new Date).getTime(); - setInt64(sp + 8, msec / 1000); - this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); - }, - - // func scheduleTimeoutEvent(delay int64) int32 - "runtime.scheduleTimeoutEvent": (sp) => { - sp >>>= 0; - const id = this._nextCallbackTimeoutID; - this._nextCallbackTimeoutID++; - this._scheduledTimeouts.set(id, setTimeout( - () => { - this._resume(); - while (this._scheduledTimeouts.has(id)) { - // for some reason Go failed to register the timeout event, log and try again - // (temporary workaround for https://github.com/golang/go/issues/28975) - console.warn("scheduleTimeoutEvent: missed timeout event"); - this._resume(); - } - }, - getInt64(sp + 8), - )); - this.mem.setInt32(sp + 16, id, true); - }, - - // func clearTimeoutEvent(id int32) - "runtime.clearTimeoutEvent": (sp) => { - sp >>>= 0; - const id = this.mem.getInt32(sp + 8, true); - clearTimeout(this._scheduledTimeouts.get(id)); - this._scheduledTimeouts.delete(id); - }, - - // func getRandomData(r []byte) - "runtime.getRandomData": (sp) => { - sp >>>= 0; - crypto.getRandomValues(loadSlice(sp + 8)); - }, - - // func finalizeRef(v ref) - "syscall/js.finalizeRef": (sp) => { - sp >>>= 0; - const id = this.mem.getUint32(sp + 8, true); - this._goRefCounts[id]--; - if (this._goRefCounts[id] === 0) { - const v = this._values[id]; - this._values[id] = null; - this._ids.delete(v); - this._idPool.push(id); - } - }, - - // func stringVal(value string) ref - "syscall/js.stringVal": (sp) => { - sp >>>= 0; - storeValue(sp + 24, loadString(sp + 8)); - }, - - // func valueGet(v ref, p string) ref - "syscall/js.valueGet": (sp) => { - sp >>>= 0; - const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 32, result); - }, - - // func valueSet(v ref, p string, x ref) - "syscall/js.valueSet": (sp) => { - sp >>>= 0; - Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); - }, - - // func valueDelete(v ref, p string) - "syscall/js.valueDelete": (sp) => { - sp >>>= 0; - Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); - }, - - // func valueIndex(v ref, i int) ref - "syscall/js.valueIndex": (sp) => { - sp >>>= 0; - storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); - }, - - // valueSetIndex(v ref, i int, x ref) - "syscall/js.valueSetIndex": (sp) => { - sp >>>= 0; - Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); - }, - - // func valueCall(v ref, m string, args []ref) (ref, bool) - "syscall/js.valueCall": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const m = Reflect.get(v, loadString(sp + 16)); - const args = loadSliceOfValues(sp + 32); - const result = Reflect.apply(m, v, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 56, result); - this.mem.setUint8(sp + 64, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 56, err); - this.mem.setUint8(sp + 64, 0); - } - }, - - // func valueInvoke(v ref, args []ref) (ref, bool) - "syscall/js.valueInvoke": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const args = loadSliceOfValues(sp + 16); - const result = Reflect.apply(v, undefined, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, result); - this.mem.setUint8(sp + 48, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, err); - this.mem.setUint8(sp + 48, 0); - } - }, - - // func valueNew(v ref, args []ref) (ref, bool) - "syscall/js.valueNew": (sp) => { - sp >>>= 0; - try { - const v = loadValue(sp + 8); - const args = loadSliceOfValues(sp + 16); - const result = Reflect.construct(v, args); - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, result); - this.mem.setUint8(sp + 48, 1); - } catch (err) { - sp = this._inst.exports.getsp() >>> 0; // see comment above - storeValue(sp + 40, err); - this.mem.setUint8(sp + 48, 0); - } - }, - - // func valueLength(v ref) int - "syscall/js.valueLength": (sp) => { - sp >>>= 0; - setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); - }, - - // valuePrepareString(v ref) (ref, int) - "syscall/js.valuePrepareString": (sp) => { - sp >>>= 0; - const str = encoder.encode(String(loadValue(sp + 8))); - storeValue(sp + 16, str); - setInt64(sp + 24, str.length); - }, - - // valueLoadString(v ref, b []byte) - "syscall/js.valueLoadString": (sp) => { - sp >>>= 0; - const str = loadValue(sp + 8); - loadSlice(sp + 16).set(str); - }, - - // func valueInstanceOf(v ref, t ref) bool - "syscall/js.valueInstanceOf": (sp) => { - sp >>>= 0; - this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); - }, - - // func copyBytesToGo(dst []byte, src ref) (int, bool) - "syscall/js.copyBytesToGo": (sp) => { - sp >>>= 0; - const dst = loadSlice(sp + 8); - const src = loadValue(sp + 32); - if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { - this.mem.setUint8(sp + 48, 0); - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - setInt64(sp + 40, toCopy.length); - this.mem.setUint8(sp + 48, 1); - }, - - // func copyBytesToJS(dst ref, src []byte) (int, bool) - "syscall/js.copyBytesToJS": (sp) => { - sp >>>= 0; - const dst = loadValue(sp + 8); - const src = loadSlice(sp + 16); - if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { - this.mem.setUint8(sp + 48, 0); - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - setInt64(sp + 40, toCopy.length); - this.mem.setUint8(sp + 48, 1); - }, - - "debug": (value) => { - console.log(value); - }, - } - }; - } - - async run(instance) { - if (!(instance instanceof WebAssembly.Instance)) { - throw new Error("Go.run: WebAssembly.Instance expected"); - } - this._inst = instance; - this.mem = new DataView(this._inst.exports.mem.buffer); - this._values = [ // JS values that Go currently has references to, indexed by reference id - NaN, - 0, - null, - true, - false, - globalThis, - this, - ]; - this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id - this._ids = new Map([ // mapping from JS values to reference ids - [0, 1], - [null, 2], - [true, 3], - [false, 4], - [globalThis, 5], - [this, 6], - ]); - this._idPool = []; // unused ids that have been garbage collected - this.exited = false; // whether the Go program has exited - - // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. - let offset = 4096; - - const strPtr = (str) => { - const ptr = offset; - const bytes = encoder.encode(str + "\0"); - new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); - offset += bytes.length; - if (offset % 8 !== 0) { - offset += 8 - (offset % 8); - } - return ptr; - }; - - const argc = this.argv.length; - - const argvPtrs = []; - this.argv.forEach((arg) => { - argvPtrs.push(strPtr(arg)); - }); - argvPtrs.push(0); - - const keys = Object.keys(this.env).sort(); - keys.forEach((key) => { - argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); - }); - argvPtrs.push(0); - - const argv = offset; - argvPtrs.forEach((ptr) => { - this.mem.setUint32(offset, ptr, true); - this.mem.setUint32(offset + 4, 0, true); - offset += 8; - }); - - // The linker guarantees global data starts from at least wasmMinDataAddr. - // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. - const wasmMinDataAddr = 4096 + 8192; - if (offset >= wasmMinDataAddr) { - throw new Error("total length of command line and environment variables exceeds limit"); - } - - this._inst.exports.run(argc, argv); - if (this.exited) { - this._resolveExitPromise(); - } - await this._exitPromise; - } - - _resume() { - if (this.exited) { - throw new Error("Go program has already exited"); - } - this._inst.exports.resume(); - if (this.exited) { - this._resolveExitPromise(); - } - } - - _makeFuncWrapper(id) { - const go = this; - return function () { - const event = { id: id, this: this, args: arguments }; - go._pendingEvent = event; - go._resume(); - return event.result; - }; - } - } -})(); diff --git a/site/app/components/Terminal.client.tsx b/site/app/components/Terminal.client.tsx deleted file mode 100644 index b8a889c..0000000 --- a/site/app/components/Terminal.client.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useEffect, useRef, useState, useContext } from "react"; -import type React from "react"; -import { Terminal } from "@xterm/xterm"; -import { FitAddon } from "@xterm/addon-fit"; -import { CanvasAddon } from "@xterm/addon-canvas"; -// import { WebglAddon } from "@xterm/addon-webgl"; -import { WushContext } from "~/context/wush"; -import "@xterm/xterm/css/xterm.css"; - -interface WushTerminalProps { - authKey: string; -} - -const WushTerminal: React.FC = ({ authKey }) => { - const terminalRef = useRef(null); - const terminalInstance = useRef(null); - const fitAddonRef = useRef(); - const wushInitialized = useContext(WushContext); - const sshSessionRef = useRef(); - - useEffect(() => { - if (!wushInitialized) { - console.log("WASM not initialized, skipping terminal initialization"); - return; - } - - console.log("Terminal component mounted"); - - if (!terminalRef.current) { - console.log("Terminal ref is null, skipping terminal initialization"); - return; - } - - if (terminalInstance.current) { - console.log("Terminal already initialized, skipping"); - return; - } - - console.log("running wush"); - - console.log("Initializing terminal"); - - const term = new Terminal({ - cursorBlink: true, - theme: { - background: "#282a36", - foreground: "#f8f8f2", - }, - scrollback: 0, - }); - const fitAddon = new FitAddon(); - fitAddonRef.current = fitAddon; - term.loadAddon(fitAddon); - term.loadAddon(new CanvasAddon()); - // term.loadAddon(new WebglAddon()); - term.open(terminalRef.current); - fitAddon.fit(); - - let onDataHook: ((data: string) => void) | undefined; - term.onData((e) => { - onDataHook?.(e); - }); - - const resizeObserver = new window.ResizeObserver(() => fitAddon.fit()); - resizeObserver.observe(terminalRef.current); - - newWush({ authKey: authKey }).then((wush) => { - const sshSession = wush.ssh({ - writeFn(input) { - term.write(input); - }, - writeErrorFn(err) { - term.write(err); - }, - setReadFn(hook) { - onDataHook = hook; - }, - rows: term.rows, - cols: term.cols, - onConnectionProgress: (msg) => {}, - onConnected: () => {}, - onDone() { - resizeObserver?.disconnect(); - term.dispose(); - console.log("term done"); - sshSession.close(); - sshSessionRef.current = null; - }, - }); - - sshSessionRef.current = sshSession; - term.onResize(({ rows, cols }) => sshSession.resize(rows, cols)); - }); - - console.log("Terminal initialized and opened"); - terminalInstance.current = term; - fitAddon.fit(); - - return () => { - console.log("Disposing terminal"); - if (terminalInstance.current) { - resizeObserver.disconnect(); - terminalInstance.current.dispose(); - terminalInstance.current = null; - } - if (sshSessionRef.current) { - sshSessionRef.current.close(); - sshSessionRef.current = null; - } - }; - }, [authKey, wushInitialized]); - - return
; -}; - -export default WushTerminal; diff --git a/site/app/context/wush.ts b/site/app/context/wush.ts deleted file mode 100644 index 7788d79..0000000 --- a/site/app/context/wush.ts +++ /dev/null @@ -1,3 +0,0 @@ -import React from "react"; - -export const WushContext = React.createContext(null); diff --git a/site/app/entry.client.tsx b/site/app/entry.client.tsx deleted file mode 100644 index 1db62c7..0000000 --- a/site/app/entry.client.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { RemixBrowser } from "@remix-run/react"; -import { startTransition, StrictMode } from "react"; -import { hydrateRoot } from "react-dom/client"; - -startTransition(() => { - hydrateRoot(document, ); -}); diff --git a/site/app/entry.server.tsx b/site/app/entry.server.tsx deleted file mode 100644 index 65ad16e..0000000 --- a/site/app/entry.server.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { PassThrough } from "node:stream"; - -import type { AppLoadContext, EntryContext } from "@remix-run/node"; -import { createReadableStreamFromReadable } from "@remix-run/node"; -import { RemixServer } from "@remix-run/react"; -import * as isbotModule from "isbot"; -import { renderToPipeableStream } from "react-dom/server"; - -const ABORT_DELAY = 5_000; - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - loadContext: AppLoadContext -) { - let prohibitOutOfOrderStreaming = - isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode; - - return prohibitOutOfOrderStreaming - ? handleBotRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ) - : handleBrowserRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ); -} - -// We have some Remix apps in the wild already running with isbot@3 so we need -// to maintain backwards compatibility even though we want new apps to use -// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev. -function isBotRequest(userAgent: string | null) { - if (!userAgent) { - return false; - } - - // isbot >= 3.8.0, >4 - if ("isbot" in isbotModule && typeof isbotModule.isbot === "function") { - return isbotModule.isbot(userAgent); - } - - // isbot < 3.8.0 - if ("default" in isbotModule && typeof isbotModule.default === "function") { - return isbotModule.default(userAgent); - } - - return false; -} - -function handleBotRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onAllReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - } - ); - - setTimeout(abort, ABORT_DELAY); - }); -} - -function handleBrowserRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onShellReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - } - ); - - setTimeout(abort, ABORT_DELAY); - }); -} diff --git a/site/app/root.tsx b/site/app/root.tsx deleted file mode 100644 index eda0305..0000000 --- a/site/app/root.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, -} from "@remix-run/react"; -import type { LinksFunction, MetaFunction } from "@remix-run/node"; -import { useState, useEffect } from "react"; -import { WushContext } from "./context/wush"; -import wasmUrl from "~/assets/main.wasm?url"; -import goWasmUrl from "~/assets/wasm_exec.js?url"; - -import "./tailwind.css"; - -export const links: LinksFunction = () => [ - { - rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&display=swap", - crossOrigin: "anonymous", - }, - { rel: "preconnect", href: "https://fonts.googleapis.com" }, - { - rel: "preconnect", - href: "https://fonts.gstatic.com", - crossOrigin: "anonymous", - }, - { - rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", - }, -]; - -export const meta: MetaFunction = () => { - return [ - { title: "$ wush" }, - { name: "description", content: "wush - share terminals in the browser" }, - ]; -}; - -export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - -