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"}, },