From b3bfa1a79314b3e3c9302d8487ad0d1797f4ea4b Mon Sep 17 00:00:00 2001 From: 8go <17750857+8go@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:30:45 +0000 Subject: [PATCH 01/67] trim spaces from auth token (#10) This is useful because sometimes on copy/pasting the token there is a leading or trailing space. This is more tolerant and accepts tokens that inadvertently have leading or trailing spaces. Signed-off-by: 8go <17750857+8go@users.noreply.github.com> --- cmd/wush/cp.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/wush/cp.go b/cmd/wush/cp.go index 5956252..4dfe1d2 100644 --- a/cmd/wush/cp.go +++ b/cmd/wush/cp.go @@ -10,6 +10,7 @@ import ( "net/netip" "os" "path/filepath" + "strings" "github.com/charmbracelet/huh" "github.com/coder/serpent" @@ -54,7 +55,7 @@ func initAuth(authFlag *string, ca *overlay.ClientAuth) serpent.MiddlewareFunc { } } - err := ca.Parse(*authFlag) + err := ca.Parse(strings.TrimSpace(*authFlag)) if err != nil { return fmt.Errorf("parse auth key: %w", err) } From fb4306074587f52a1e4c2a03567918249560ddb8 Mon Sep 17 00:00:00 2001 From: Vijay Date: Tue, 3 Sep 2024 23:29:28 +0530 Subject: [PATCH 02/67] fix(install.sh): remove bashism (#11) Make the install.sh POSIX compliant and remove reference to syntax only available in bash and are not backward compatible with POSIX sh --- install.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 2c1aa3c..4ac429e 100755 --- a/install.sh +++ b/install.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh set -eu @@ -56,7 +56,7 @@ detect_platform() { select_archive_format() { PLATFORM_ARCH=$1 - if [[ "$PLATFORM_ARCH" == windows-* ]]; then + if [ "$PLATFORM_ARCH" = windows-* ]; then echo "zip" else if command -v tar >/dev/null 2>&1; then @@ -98,9 +98,9 @@ main() { # Extract the archive echo "Extracting $BINARY_NAME..." - if [ "$FILE_EXT" == "zip" ]; then + if [ "$FILE_EXT" = "zip" ]; then unzip -d "$TMP_DIR" "$ARCHIVE_PATH" - elif [ "$FILE_EXT" == "tar.gz" ]; then + elif [ "$FILE_EXT" = "tar.gz" ]; then tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR" else echo "Unsupported file extension: $FILE_EXT" @@ -120,7 +120,7 @@ main() { mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME.exe" else sudo su < Date: Tue, 3 Sep 2024 19:02:44 +0000 Subject: [PATCH 03/67] Improve platform detection and handling in install.sh Refactor `select_archive_format` to use a case statement for better readability and extend functionality for more robust platform detection. Ensure `sudo` is correctly handled only when required in the main installation block. Changes: - Use case statement in `select_archive_format` for clarity - Add a root check before using `sudo` in the installation block - Maintain compatibility for both `tar` and `unzip` commands --- install.sh | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/install.sh b/install.sh index 4ac429e..01a4b6f 100755 --- a/install.sh +++ b/install.sh @@ -56,18 +56,21 @@ detect_platform() { select_archive_format() { PLATFORM_ARCH=$1 - if [ "$PLATFORM_ARCH" = windows-* ]; then - echo "zip" - else - if command -v tar >/dev/null 2>&1; then - echo "tar.gz" - elif command -v unzip >/dev/null 2>&1; then + case "$PLATFORM_ARCH" in + windows-*) echo "zip" - else - echo "Unsupported: neither tar nor unzip are available." - exit 1 - fi - fi + ;; + *) + if command -v tar >/dev/null 2>&1; then + echo "tar.gz" + elif command -v unzip >/dev/null 2>&1; then + echo "zip" + else + echo "Unsupported: neither tar nor unzip are available." + exit 1 + fi + ;; + esac } main() { @@ -119,10 +122,16 @@ main() { mkdir -p "$INSTALL_DIR" mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME.exe" else - sudo su < Date: Tue, 3 Sep 2024 19:05:31 +0000 Subject: [PATCH 04/67] fix(install.sh): check for setcap command availability Ensure that `setcap` is available before attempting to use it during installation. This prevents potential errors on Linux systems where `setcap` is not installed by skipping the capability setting step. - Added a check for `setcap` command availability - Updated both root and non-root execution paths accordingly --- install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 01a4b6f..7169a72 100755 --- a/install.sh +++ b/install.sh @@ -125,11 +125,11 @@ main() { # Run using sudo if not root if [ "$(id -u)" -ne 0 ]; then sudo sh </dev/null 2>&1 && setcap cap_net_admin=eip "$BINARY_PATH" mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME" EOF else - [ "$(uname -s)" = "Linux" ] && setcap cap_net_admin=eip "$BINARY_PATH" + [ "$(uname -s)" = "Linux" ] && command -v setcap >/dev/null 2>&1 && setcap cap_net_admin=eip "$BINARY_PATH" mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME" fi fi From 075982e0bcd74b06a3419780091017da8388bdf2 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 3 Sep 2024 19:22:10 +0000 Subject: [PATCH 05/67] fix(install.sh): improve setcap command handling Enhance handling of the 'setcap' command to ensure better user experience during installation on Linux systems. - Add a check for 'setcap' command availability. - Display a warning if 'setcap' is not available, indicating potential slower transfer speeds. This ensures the script fails gracefully and informs the user when 'CAP_NET_ADMIN' capability cannot be set. --- install.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 7169a72..17ca593 100755 --- a/install.sh +++ b/install.sh @@ -125,11 +125,23 @@ main() { # Run using sudo if not root if [ "$(id -u)" -ne 0 ]; then sudo sh </dev/null 2>&1 && setcap cap_net_admin=eip "$BINARY_PATH" + if [ "$(uname -s)" = "Linux" ]; then + if command -v setcap >/dev/null 2>&1; then + setcap cap_net_admin=eip "$BINARY_PATH" + else + echo "Warning: 'setcap' command is not available. Transfer speeds may be slower." + fi + fi mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME" EOF else - [ "$(uname -s)" = "Linux" ] && command -v setcap >/dev/null 2>&1 && setcap cap_net_admin=eip "$BINARY_PATH" + if [ "$(uname -s)" = "Linux" ]; then + if command -v setcap >/dev/null 2>&1; then + setcap cap_net_admin=eip "$BINARY_PATH" + else + echo "Warning: 'setcap' command is not available. Transfer speeds may be slower." + fi + fi mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME" fi fi From 2afd218de593a3be71a98274b586a8446908ffed Mon Sep 17 00:00:00 2001 From: Vijay Date: Wed, 4 Sep 2024 19:59:20 +0530 Subject: [PATCH 06/67] feat(readme): add Homebrew install method (#12) Wush was added to Homebrew/homebrew-core repo. See: https://github.com/Homebrew/homebrew-core/pull/183296 Signed-off-by: Vijay Co-authored-by: Muhammad Atif Ali --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index c7bcd5c..07c6a1a 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,16 @@ 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 +``` + For a manual installation, see the [latest release](https://github.com/coder/wush/releases/latest). > [!TIP] From 2f69825da8650f490b6905186c27bac73cd05687 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 6 Sep 2024 15:53:26 -0500 Subject: [PATCH 07/67] feat: add port forwarding (#15) This enhancement allows users to forward network ports through `wush`, expanding its functionality for various use cases. --- cmd/wush/main.go | 1 + cmd/wush/portforward.go | 440 ++++++++++++++++++++++++++++++++++++++++ cmd/wush/serve.go | 64 +++++- 3 files changed, 502 insertions(+), 3 deletions(-) create mode 100644 cmd/wush/portforward.go diff --git a/cmd/wush/main.go b/cmd/wush/main.go index 942abcf..cf935a6 100644 --- a/cmd/wush/main.go +++ b/cmd/wush/main.go @@ -51,6 +51,7 @@ func main() { serveCmd(), rsyncCmd(), cpCmd(), + portForwardCmd(), }, Options: []serpent.Option{ { diff --git a/cmd/wush/portforward.go b/cmd/wush/portforward.go new file mode 100644 index 0000000..7e333a1 --- /dev/null +++ b/cmd/wush/portforward.go @@ -0,0 +1,440 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/netip" + "os" + "os/signal" + "strconv" + "strings" + "sync" + + "golang.org/x/xerrors" + "tailscale.com/net/netns" + "tailscale.com/tsnet" + "tailscale.com/types/ptr" + + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/serpent" + "github.com/coder/wush/cliui" + "github.com/coder/wush/overlay" + "github.com/coder/wush/tsserver" +) + +func portForwardCmd() *serpent.Command { + var ( + verbose bool + logger = new(slog.Logger) + logf = func(str string, args ...any) {} + + overlayOpts = new(sendOverlayOpts) + send = new(overlay.Send) + tcpForwards []string // : + udpForwards []string // : + ) + return &serpent.Command{ + Use: "port-forward", + Short: "Transfer files.", + Long: "Transfer files to a " + 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", + }, + example{ + Description: "Port forward a single UDP port from port 9000 to port 9000 on your local machine", + Command: "wush port-forward --udp 9000", + }, + example{ + Description: "Port 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", + 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", + Command: "wush port-forward --tcp 1.2.3.4:8080:8080", + }, + ), + Middleware: serpent.Chain( + initLogger(&verbose, ptr.To(false), logger, &logf), + initAuth(&overlayOpts.authKey, &overlayOpts.clientAuth), + sendOverlayMW(overlayOpts, &send, logger, &logf), + ), + Handler: func(inv *serpent.Invocation) error { + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + + specs, err := parsePortForwards(tcpForwards, udpForwards) + if err != nil { + return fmt.Errorf("parse port-forward specs: %w", err) + } + if len(specs) == 0 { + return errors.New("no port-forwards requested") + } + + s, err := tsserver.NewServer(ctx, logger, send) + if err != nil { + return err + } + + if send.Auth.ReceiverDERPRegionID != 0 { + go send.ListenOverlayDERP(ctx) + } else if send.Auth.ReceiverStunAddr.IsValid() { + go send.ListenOverlaySTUN(ctx) + } else { + return errors.New("auth key provided neither DERP nor STUN") + } + + go s.ListenAndServe(ctx) + netns.SetDialerOverride(s.Dialer()) + ts, err := newTSNet("send") + if err != nil { + return err + } + ts.Logf = func(string, ...any) {} + ts.UserLogf = func(string, ...any) {} + + logf("Bringing WireGuard up..") + ts.Up(ctx) + logf("WireGuard is ready!") + + lc, err := ts.LocalClient() + if err != nil { + return err + } + + ip, err := waitUntilHasPeerHasIP(ctx, logf, lc) + if err != nil { + return err + } + + if overlayOpts.waitP2P { + err := waitUntilHasP2P(ctx, logf, lc) + if err != nil { + return err + } + } + + var ( + wg = new(sync.WaitGroup) + listeners = make([]net.Listener, len(specs)) + closeAllListeners = func() { + logger.Debug("closing all listeners") + for _, l := range listeners { + if l == nil { + continue + } + _ = l.Close() + } + } + ) + defer closeAllListeners() + + for i, spec := range specs { + l, err := listenAndPortForward(ctx, inv, ts, ip, wg, spec, logger) + if err != nil { + logger.Error("failed to listen", "spec", spec, "err", err) + return err + } + listeners[i] = l + } + + // Wait for the context to be canceled or for a signal and close + // all listeners. + var closeErr error + wg.Add(1) + go func() { + defer wg.Done() + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt) + + select { + case <-ctx.Done(): + logger.Debug("command context expired waiting for signal", "err", ctx.Err()) + closeErr = ctx.Err() + case sig := <-sigs: + logger.Debug("received signal", "signal", sig) + _, _ = fmt.Fprintln(inv.Stderr, "\nReceived signal, closing all listeners and active connections") + } + + cancel() + closeAllListeners() + }() + + wg.Wait() + return closeErr + }, + Options: []serpent.Option{ + { + Flag: "auth-key", + Env: "WUSH_AUTH_key", + Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Default: "", + Value: serpent.StringOf(&overlayOpts.authKey), + }, + { + Flag: "stun-ip-override", + Default: "", + Value: serpent.StringOf(&overlayOpts.stunAddrOverride), + }, + { + Flag: "wait-p2p", + Description: "Waits for the connection to be p2p.", + Default: "false", + Value: serpent.BoolOf(&overlayOpts.waitP2P), + }, + { + Flag: "verbose", + FlagShorthand: "v", + Description: "Enable verbose logging.", + Default: "false", + Value: serpent.BoolOf(&verbose), + }, + { + Flag: "tcp", + FlagShorthand: "p", + Env: "WUSH_PORT_FORWARD_TCP", + Description: "Forward TCP port(s) from the peer to the local machine.", + Value: serpent.StringArrayOf(&tcpForwards), + }, + { + Flag: "udp", + Env: "WUSH_PORT_FORWARD_UDP", + Description: "Forward UDP port(s) from the peer to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols.", + Value: serpent.StringArrayOf(&udpForwards), + }, + }, + } +} + +func listenAndPortForward( + ctx context.Context, + inv *serpent.Invocation, + ts *tsnet.Server, + remoteIP netip.Addr, + wg *sync.WaitGroup, + spec portForwardSpec, + logger *slog.Logger, +) (net.Listener, error) { + logger = logger.With("network", spec.listenNetwork, "address", spec.listenAddress) + _, _ = fmt.Fprintf(inv.Stderr, "Forwarding '%v://%v' locally to '%v://%v' in the peer\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress) + + l, err := inv.Net.Listen(spec.listenNetwork, spec.listenAddress.String()) + if err != nil { + return nil, xerrors.Errorf("listen '%v://%v': %w", spec.listenNetwork, spec.listenAddress, err) + } + logger.Debug("listening") + + wg.Add(1) + go func(spec portForwardSpec) { + defer wg.Done() + for { + netConn, err := l.Accept() + if err != nil { + // Silently ignore net.ErrClosed errors. + if errors.Is(err, net.ErrClosed) { + logger.Debug("listener closed") + return + } + _, _ = fmt.Fprintf(inv.Stderr, "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err) + _, _ = fmt.Fprintln(inv.Stderr, "Killing listener") + return + } + logger.Debug("accepted connection", "remote_addr", netConn.RemoteAddr()) + + go func(netConn net.Conn) { + defer netConn.Close() + addr := netip.AddrPortFrom(remoteIP, spec.dialAddress.Port()) + remoteConn, err := ts.Dial(ctx, spec.dialNetwork, addr.String()) + if err != nil { + _, _ = fmt.Fprintf(inv.Stderr, "Failed to dial '%v://%v' in peer: %s\n", spec.dialNetwork, addr, err) + return + } + defer remoteConn.Close() + logger.Debug("dialed remote", "remote_addr", netConn.RemoteAddr()) + + agentssh.Bicopy(ctx, netConn, remoteConn) + logger.Debug("connection closing", "remote_addr", netConn.RemoteAddr()) + }(netConn) + } + }(spec) + + return l, nil +} + +type portForwardSpec struct { + listenNetwork string // tcp, udp + listenAddress netip.AddrPort + + dialNetwork string // tcp, udp + dialAddress netip.AddrPort +} + +func parsePortForwards(tcpSpecs, udpSpecs []string) ([]portForwardSpec, error) { + specs := []portForwardSpec{} + + for _, specEntry := range tcpSpecs { + for _, spec := range strings.Split(specEntry, ",") { + ports, err := parseSrcDestPorts(spec) + if err != nil { + return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err) + } + + for _, port := range ports { + specs = append(specs, portForwardSpec{ + listenNetwork: "tcp", + listenAddress: port.local, + dialNetwork: "tcp", + dialAddress: port.remote, + }) + } + } + } + + for _, specEntry := range udpSpecs { + for _, spec := range strings.Split(specEntry, ",") { + ports, err := parseSrcDestPorts(spec) + if err != nil { + return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err) + } + + for _, port := range ports { + specs = append(specs, portForwardSpec{ + listenNetwork: "udp", + listenAddress: port.local, + dialNetwork: "udp", + dialAddress: port.remote, + }) + } + } + } + + // Check for duplicate entries. + locals := map[string]struct{}{} + for _, spec := range specs { + localStr := fmt.Sprintf("%v:%v", spec.listenNetwork, spec.listenAddress) + if _, ok := locals[localStr]; ok { + return nil, xerrors.Errorf("local %v %v is specified twice", spec.listenNetwork, spec.listenAddress) + } + locals[localStr] = struct{}{} + } + + return specs, nil +} + +func parsePort(in string) (uint16, error) { + port, err := strconv.ParseUint(strings.TrimSpace(in), 10, 16) + if err != nil { + return 0, xerrors.Errorf("parse port %q: %w", in, err) + } + if port == 0 { + return 0, xerrors.New("port cannot be 0") + } + + return uint16(port), nil +} + +type parsedSrcDestPort struct { + local, remote netip.AddrPort +} + +func parseSrcDestPorts(in string) ([]parsedSrcDestPort, error) { + var ( + err error + parts = strings.Split(in, ":") + localAddr = netip.AddrFrom4([4]byte{127, 0, 0, 1}) + remoteAddr = netip.AddrFrom4([4]byte{127, 0, 0, 1}) + ) + + switch len(parts) { + case 1: + // Duplicate the single part + parts = append(parts, parts[0]) + case 2: + // Check to see if the first part is an IP address. + _localAddr, err := netip.ParseAddr(parts[0]) + if err != nil { + break + } + // The first part is the local address, so duplicate the port. + localAddr = _localAddr + parts = []string{parts[1], parts[1]} + + case 3: + _localAddr, err := netip.ParseAddr(parts[0]) + if err != nil { + return nil, xerrors.Errorf("invalid port specification %q; invalid ip %q: %w", in, parts[0], err) + } + localAddr = _localAddr + parts = parts[1:] + + default: + return nil, xerrors.Errorf("invalid port specification %q", in) + } + + if !strings.Contains(parts[0], "-") { + localPort, err := parsePort(parts[0]) + if err != nil { + return nil, xerrors.Errorf("parse local port from %q: %w", in, err) + } + remotePort, err := parsePort(parts[1]) + if err != nil { + return nil, xerrors.Errorf("parse remote port from %q: %w", in, err) + } + + return []parsedSrcDestPort{{ + local: netip.AddrPortFrom(localAddr, localPort), + remote: netip.AddrPortFrom(remoteAddr, remotePort), + }}, nil + } + + local, err := parsePortRange(parts[0]) + if err != nil { + return nil, xerrors.Errorf("parse local port range from %q: %w", in, err) + } + remote, err := parsePortRange(parts[1]) + if err != nil { + return nil, xerrors.Errorf("parse remote port range from %q: %w", in, err) + } + if len(local) != len(remote) { + return nil, xerrors.Errorf("port ranges must be the same length, got %d ports forwarded to %d ports", len(local), len(remote)) + } + var out []parsedSrcDestPort + for i := range local { + out = append(out, parsedSrcDestPort{ + local: netip.AddrPortFrom(localAddr, local[i]), + remote: netip.AddrPortFrom(remoteAddr, remote[i]), + }) + } + return out, nil +} + +func parsePortRange(in string) ([]uint16, error) { + parts := strings.Split(in, "-") + if len(parts) != 2 { + return nil, xerrors.Errorf("invalid port range specification %q", in) + } + start, err := parsePort(parts[0]) + if err != nil { + return nil, xerrors.Errorf("parse range start port from %q: %w", in, err) + } + end, err := parsePort(parts[1]) + if err != nil { + return nil, xerrors.Errorf("parse range end port from %q: %w", in, err) + } + if end < start { + return nil, xerrors.Errorf("range end port %v is less than start port %v", end, start) + } + var ports []uint16 + for i := start; i <= end; i++ { + ports = append(ports, i) + } + return ports, nil +} diff --git a/cmd/wush/serve.go b/cmd/wush/serve.go index 4fa69c4..a4d1ac4 100644 --- a/cmd/wush/serve.go +++ b/cmd/wush/serve.go @@ -1,12 +1,16 @@ package main import ( + "context" "fmt" "io" "log/slog" + "net" "net/http" + "net/netip" "os" "strings" + "sync" "time" "github.com/prometheus/client_golang/prometheus" @@ -142,6 +146,23 @@ func serveCmd() *serpent.Command { fmt.Println(cliui.Timestamp(time.Now()), "File transfer server "+pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) } + if xslices.Contains(enabled, "port-forward") && !xslices.Contains(disabled, "port-forward") { + ts.RegisterFallbackTCPHandler(func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { + return func(src net.Conn) { + dst, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", dst.Port())) + if err != nil { + fmt.Println("failed to dial forwarded connection:", err.Error()) + src.Close() + return + } + + bicopy(ctx, src, dst) + }, true + }) + } else { + fmt.Println(cliui.Timestamp(time.Now()), "Port-forward server "+pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) + } + ctx, ctxCancel := inv.SignalNotifyContext(ctx, os.Interrupt) defer ctxCancel() @@ -168,14 +189,14 @@ func serveCmd() *serpent.Command { { Flag: "enable", Description: "Server options to enable.", - Default: "ssh,cp", - Value: serpent.EnumArrayOf(&enabled, "ssh", "cp"), + Default: "ssh,cp,port-forward", + Value: serpent.EnumArrayOf(&enabled, "ssh", "cp", "port-forward"), }, { Flag: "disable", Description: "Server options to disable.", Default: "", - Value: serpent.EnumArrayOf(&disabled, "ssh", "cp"), + Value: serpent.EnumArrayOf(&disabled, "ssh", "cp", "port-forward"), }, }, } @@ -207,6 +228,43 @@ func newTSNet(direction string) (*tsnet.Server, error) { return srv, nil } +func bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + defer func() { + _ = c1.Close() + _ = c2.Close() + }() + + var wg sync.WaitGroup + copyFunc := func(dst io.WriteCloser, src io.Reader) { + defer func() { + wg.Done() + // If one side of the copy fails, ensure the other one exits as + // well. + cancel() + }() + _, _ = io.Copy(dst, src) + } + + wg.Add(2) + go copyFunc(c1, c2) + go copyFunc(c2, c1) + + // Convert waitgroup to a channel so we can also wait on the context. + done := make(chan struct{}) + go func() { + defer close(done) + wg.Wait() + }() + + select { + case <-ctx.Done(): + case <-done: + } +} + func cpHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { w.WriteHeader(http.StatusOK) From a6b929ecfd1c360eb935b58d81ca35b501bbb678 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:15:18 -0500 Subject: [PATCH 08/67] chore(deps): bump golang.org/x/sys from 0.24.0 to 0.25.0 (#17) Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.24.0 to 0.25.0. - [Commits](https://github.com/golang/sys/compare/v0.24.0...v0.25.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] 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 4bb3d9d..10b58d5 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( golang.org/x/crypto v0.26.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.28.0 - golang.org/x/sys v0.24.0 + golang.org/x/sys v0.25.0 golang.org/x/term v0.23.0 golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 tailscale.com v1.70.0 diff --git a/go.sum b/go.sum index d8004ed..bbd6e5e 100644 --- a/go.sum +++ b/go.sum @@ -693,8 +693,8 @@ 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.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/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/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 58edbbf5825324793988b3dde80efe9f247c02be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:58:26 -0500 Subject: [PATCH 09/67] chore(deps): bump github.com/prometheus/client_golang from 1.20.2 to 1.20.3 (#18) Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.2 to 1.20.3. - [Release notes](https://github.com/prometheus/client_golang/releases) - [Changelog](https://github.com/prometheus/client_golang/blob/v1.20.3/CHANGELOG.md) - [Commits](https://github.com/prometheus/client_golang/compare/v1.20.2...v1.20.3) --- updated-dependencies: - dependency-name: github.com/prometheus/client_golang dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 10b58d5..11823aa 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a github.com/pion/stun/v3 v3.0.0 - github.com/prometheus/client_golang v1.20.2 + github.com/prometheus/client_golang v1.20.3 github.com/puzpuzpuz/xsync/v3 v3.4.0 github.com/schollz/progressbar/v3 v3.14.6 github.com/spf13/afero v1.11.0 diff --git a/go.sum b/go.sum index bbd6e5e..eb2c409 100644 --- a/go.sum +++ b/go.sum @@ -448,8 +448,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= -github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg= -github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4= +github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= From 47d58e4ae39faefdace95677bfcc4e247da2f43b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:58:46 -0500 Subject: [PATCH 10/67] chore(deps): bump github.com/charmbracelet/huh from 0.5.3 to 0.6.0 (#19) Bumps [github.com/charmbracelet/huh](https://github.com/charmbracelet/huh) from 0.5.3 to 0.6.0. - [Release notes](https://github.com/charmbracelet/huh/releases) - [Commits](https://github.com/charmbracelet/huh/compare/v0.5.3...v0.6.0) --- updated-dependencies: - dependency-name: github.com/charmbracelet/huh dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 11823aa..a6094f4 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ replace github.com/gliderlabs/ssh => github.com/coder/ssh v0.0.0-20231128192721- require ( cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 github.com/btcsuite/btcd/btcutil v1.1.6 - github.com/charmbracelet/huh v0.5.3 + github.com/charmbracelet/huh v0.6.0 github.com/coder/coder/v2 v2.14.2 github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 github.com/coder/serpent v0.8.0 @@ -72,10 +72,10 @@ require ( github.com/catppuccin/go v0.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.19.0 // indirect - github.com/charmbracelet/bubbletea v0.27.0 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/bubbletea v1.1.0 // indirect github.com/charmbracelet/lipgloss v0.13.0 // indirect - github.com/charmbracelet/x/ansi v0.2.2 // indirect + github.com/charmbracelet/x/ansi v0.2.3 // indirect 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 v0.23.0 // indirect @@ -205,7 +205,7 @@ require ( golang.org/x/mod v0.20.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/text v0.18.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.24.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index eb2c409..c388904 100644 --- a/go.sum +++ b/go.sum @@ -127,16 +127,16 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= -github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= -github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU= -github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y= -github.com/charmbracelet/huh v0.5.3 h1:3KLP4a/K1/S4dq4xFMTNMt3XWhgMl/yx8NYtygQ0bmg= -github.com/charmbracelet/huh v0.5.3/go.mod h1:OZC3lshuF+/y8laj//DoZdFSHxC51OrtXLJI8xWVouQ= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= +github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= +github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.2.2 h1:BC7xzaVpfWIYZRNE8NhO9zo8KA4eGUL6L/JWXDh3GF0= -github.com/charmbracelet/x/ansi v0.2.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= +github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= @@ -715,8 +715,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.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 5bddd8580ee32e5bf08d46f4b6ca6c4e9869cb20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:59:11 -0500 Subject: [PATCH 11/67] chore(deps): bump golang.org/x/crypto from 0.26.0 to 0.27.0 (#20) Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.26.0 to 0.27.0. - [Commits](https://github.com/golang/crypto/compare/v0.26.0...v0.27.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index a6094f4..006c371 100644 --- a/go.mod +++ b/go.mod @@ -25,11 +25,11 @@ require ( github.com/spf13/afero v1.11.0 github.com/valyala/fasthttp v1.55.0 go4.org/mem v0.0.0-20220726221520-4f986261bf13 - golang.org/x/crypto v0.26.0 + golang.org/x/crypto v0.27.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.28.0 golang.org/x/sys v0.25.0 - golang.org/x/term v0.23.0 + golang.org/x/term v0.24.0 golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 tailscale.com v1.70.0 ) diff --git a/go.sum b/go.sum index c388904..d9ddda2 100644 --- a/go.sum +++ b/go.sum @@ -614,8 +614,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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +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/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= @@ -703,8 +703,8 @@ 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.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +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/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= From 5bf34dcf4c345868942841035a6df412c2736eae Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Thu, 12 Sep 2024 15:48:50 -0500 Subject: [PATCH 12/67] Refactor logging to use stderr, auth key to stdout (#23) Separate logging to stderr to ensure normal logs don't interfere with processing output that is expected on stdout, specifically the auth key. Closes https://github.com/coder/wush/issues/16 --- cmd/wush/serve.go | 38 ++++++++++++++++++++------------- overlay/receive.go | 52 ++++++++++++++++++++++++---------------------- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/cmd/wush/serve.go b/cmd/wush/serve.go index a4d1ac4..1f33d08 100644 --- a/cmd/wush/serve.go +++ b/cmd/wush/serve.go @@ -11,8 +11,8 @@ import ( "os" "strings" "sync" - "time" + "github.com/mattn/go-isatty" "github.com/prometheus/client_golang/prometheus" "github.com/schollz/progressbar/v3" "github.com/spf13/afero" @@ -51,11 +51,14 @@ func serveCmd() *serpent.Command { logSink = inv.Stderr } logger := slog.New(slog.NewTextHandler(logSink, nil)) + hlog := func(format string, args ...any) { + fmt.Fprintf(inv.Stderr, format+"\n", args...) + } dm, err := tsserver.DERPMapTailscale(ctx) if err != nil { return err } - r := overlay.NewReceiveOverlay(logger, dm) + r := overlay.NewReceiveOverlay(logger, hlog, dm) switch overlayType { case "derp": @@ -76,9 +79,15 @@ func serveCmd() *serpent.Command { return fmt.Errorf("unknown overlay type: %s", overlayType) } - fmt.Println("Your auth key is:") - fmt.Println("\t>", cliui.Code(r.ClientAuth().AuthKey())) - fmt.Println("Use this key to authenticate other", cliui.Code("wush"), "commands to this instance.") + // Ensure we always print the auth key on stdout + if isatty.IsTerminal(os.Stdout.Fd()) { + hlog("Your auth key is:") + fmt.Println("\t>", cliui.Code(r.ClientAuth().AuthKey())) + hlog("Use this key to authenticate other " + cliui.Code("wush") + " commands to this instance.") + } else { + fmt.Println(cliui.Code(r.ClientAuth().AuthKey())) + hlog("The auth key has been printed to stdout") + } s, err := tsserver.NewServer(ctx, logger, r) if err != nil { @@ -95,7 +104,7 @@ func serveCmd() *serpent.Command { ts.Up(ctx) fs := afero.NewOsFs() - fmt.Println(cliui.Timestamp(time.Now()), "WireGuard is ready") + hlog("WireGuard is ready") closers := []io.Closer{} @@ -117,15 +126,16 @@ func serveCmd() *serpent.Command { } closers = append(closers, sshListener) - fmt.Println(cliui.Timestamp(time.Now()), "SSH server "+pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) + // TODO: replace these logs with all of the options in the beginning. + hlog("SSH server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) go func() { err := sshSrv.Serve(sshListener) if err != nil { - fmt.Println(cliui.Timestamp(time.Now()), "SSH server exited: "+err.Error()) + hlog("SSH server exited: " + err.Error()) } }() } else { - fmt.Println(cliui.Timestamp(time.Now()), "SSH server "+pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) + hlog("SSH server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) } if xslices.Contains(enabled, "cp") && !xslices.Contains(disabled, "cp") { @@ -135,15 +145,15 @@ func serveCmd() *serpent.Command { } closers = append([]io.Closer{cpListener}, closers...) - fmt.Println(cliui.Timestamp(time.Now()), "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 { - fmt.Println(cliui.Timestamp(time.Now()), "File transfer server exited: "+err.Error()) + hlog("File transfer server exited: " + err.Error()) } }() } else { - fmt.Println(cliui.Timestamp(time.Now()), "File transfer server "+pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) + hlog("File transfer server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) } if xslices.Contains(enabled, "port-forward") && !xslices.Contains(disabled, "port-forward") { @@ -151,7 +161,7 @@ func serveCmd() *serpent.Command { return func(src net.Conn) { dst, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", dst.Port())) if err != nil { - fmt.Println("failed to dial forwarded connection:", err.Error()) + hlog(pretty.Sprint(cliui.DefaultStyles.Warn, "Failed to dial forwarded connection:", err.Error())) src.Close() return } @@ -160,7 +170,7 @@ func serveCmd() *serpent.Command { }, true }) } else { - fmt.Println(cliui.Timestamp(time.Now()), "Port-forward server "+pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) + hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) } ctx, ctxCancel := inv.SignalNotifyContext(ctx, os.Interrupt) diff --git a/overlay/receive.go b/overlay/receive.go index 57092dd..9bed57e 100644 --- a/overlay/receive.go +++ b/overlay/receive.go @@ -23,23 +23,28 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/key" + "github.com/coder/pretty" "github.com/coder/wush/cliui" ) -func NewReceiveOverlay(logger *slog.Logger, dm *tailcfg.DERPMap) *Receive { +type Logf func(format string, args ...any) + +func NewReceiveOverlay(logger *slog.Logger, hlog Logf, dm *tailcfg.DERPMap) *Receive { return &Receive{ - Logger: logger, - 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(), + in: make(chan *tailcfg.Node, 8), + out: make(chan *tailcfg.Node, 8), } } type Receive struct { - Logger *slog.Logger - DerpMap *tailcfg.DERPMap + 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 @@ -83,10 +88,10 @@ func (r *Receive) PickDERPHome(ctx context.Context) error { } if report.PreferredDERP == 0 { - fmt.Println("Failed to determine overlay DERP region, defaulting to", cliui.Code("NYC"), ".") + r.HumanLogf("Failed to determine overlay DERP region, defaulting to %s.", cliui.Code("NYC")) r.derpRegionID = 1 } else { - fmt.Println("Picked DERP region", cliui.Code(r.DerpMap.Regions[report.PreferredDERP].RegionName), "as overlay home") + r.HumanLogf("Picked DERP region %s as overlay home", cliui.Code(r.DerpMap.Regions[report.PreferredDERP].RegionName)) r.derpRegionID = uint16(report.PreferredDERP) } @@ -139,7 +144,7 @@ func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error case <-restun.C: _, err = conn.WriteToUDP(m.Raw, srvAddr) if err != nil { - fmt.Println(cliui.Timestamp(time.Now()), "Failed to write STUN request on overlay:", err) + r.HumanLogf("%s Failed to write STUN request on overlay: %s", cliui.Timestamp(time.Now()), err) } restun.Reset(30 * time.Second) } @@ -169,7 +174,7 @@ func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error peers.Range(func(_ key.NodePublic, addr netip.AddrPort) bool { _, err := conn.WriteToUDPAddrPort(sealed, addr) if err != nil { - fmt.Println("send response over udp:", err) + r.HumanLogf("%s Failed to send updated node over udp: %s", cliui.Timestamp(time.Now()), err) return false } return true @@ -216,14 +221,11 @@ func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error // our first STUN response if !r.stunIP.IsValid() { - fmt.Println(cliui.Timestamp(time.Now()), "STUN address is", cliui.Code(stunAddrPort.String())) + r.HumanLogf("STUN address is %s", cliui.Code(stunAddrPort.String())) } if r.stunIP.IsValid() && r.stunIP.Compare(stunAddrPort) != 0 { - r.Logger.Warn("STUN address changed, this may cause issues", - "old_ip", r.stunIP.String(), - "new_ip", stunAddrPort.String(), - ) + r.HumanLogf(pretty.Sprintf(cliui.DefaultStyles.Warn, "STUN address changed, this may cause issues; %s->%s", r.stunIP.String(), stunAddrPort.String())) } r.stunIP = stunAddrPort closeIPChanOnce.Do(func() { @@ -234,7 +236,7 @@ func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error res, key, err := r.handleNextMessage(buf, "STUN") if err != nil { - fmt.Println(cliui.Timestamp(time.Now()), "Failed to handle overlay message:", err.Error()) + r.HumanLogf("Failed to handle overlay message: %s", err.Error()) continue } @@ -243,7 +245,7 @@ func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error if res != nil { _, err = conn.WriteToUDPAddrPort(res, addr) if err != nil { - fmt.Println(cliui.Timestamp(time.Now()), "Failed to send overlay response over STUN:", err.Error()) + r.HumanLogf("Failed to send overlay response over STUN: %s", err.Error()) return } } @@ -285,7 +287,7 @@ func (r *Receive) ListenOverlayDERP(ctx context.Context) error { peers.Range(func(_, derpKey key.NodePublic) bool { err = c.Send(derpKey, sealed) if err != nil { - fmt.Println("send response over derp:", err) + r.HumanLogf("Send updated node over DERP: %s", err) return false } return true @@ -304,7 +306,7 @@ func (r *Receive) ListenOverlayDERP(ctx context.Context) error { case derp.ReceivedPacket: res, key, err := r.handleNextMessage(msg.Data, "DERP") if err != nil { - fmt.Println(cliui.Timestamp(time.Now()), "Failed to handle overlay message:", err.Error()) + r.HumanLogf("Failed to handle overlay message: %s", err.Error()) continue } @@ -313,7 +315,7 @@ func (r *Receive) ListenOverlayDERP(ctx context.Context) error { 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()) + r.HumanLogf("Failed to send overlay response over derp: %s", err.Error()) return err } } @@ -350,9 +352,9 @@ func (r *Receive) handleNextMessage(msg []byte, system string) (resRaw []byte, n if h := ovMsg.HostInfo.Hostname; h != "" { hostname = h } - fmt.Println(cliui.Timestamp(time.Now()), "Received connection request over", system, "from", cliui.Keyword(fmt.Sprintf("%s@%s", username, hostname))) + 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: - fmt.Println(cliui.Timestamp(time.Now()), "Received updated node from", cliui.Code(ovMsg.Node.Key.String())) + r.HumanLogf("%s Received updated node from %s", cliui.Timestamp(time.Now()), cliui.Code(ovMsg.Node.Key.String())) r.in <- &ovMsg.Node res.Typ = messageTypeNodeUpdate if lastNode := r.lastNode.Load(); lastNode != nil { From b13f0553933bbac94d7290b284905adc9ac0f523 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Thu, 12 Sep 2024 16:09:22 -0500 Subject: [PATCH 13/67] Add OpenBSD build, refine archive formats (#24) Closes https://github.com/coder/wush/issues/13 --- .goreleaser.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e4dc2bd..40190a3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -10,6 +10,10 @@ builds: - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.commitDate={{.CommitTimestamp}}" goos: - freebsd + - openbsd + # netbsd and dragonfly do not currently build due to wireguard-go. + # - netbsd + # - dragonfly - windows - linux - darwin @@ -41,10 +45,13 @@ nfpms: - apk - deb archives: - - id: "zip" - format: zip - - id: "tarball" + - id: "tar_or_zip" format: tar.gz + format_overrides: + - goos: windows + format: zip + - goos: darwin + format: zip checksum: name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" algorithm: sha256 From 978134cd834edb045ccebb302fead9dee2a80b9d Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Thu, 12 Sep 2024 21:27:43 +0000 Subject: [PATCH 14/67] Log when port-forward server is enabled --- cmd/wush/serve.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/wush/serve.go b/cmd/wush/serve.go index 1f33d08..f28d135 100644 --- a/cmd/wush/serve.go +++ b/cmd/wush/serve.go @@ -169,6 +169,7 @@ func serveCmd() *serpent.Command { bicopy(ctx, src, dst) }, true }) + hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) } else { hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) } From 68257a6d6f17be086dbdf252f86b51fa4e9a5a3f Mon Sep 17 00:00:00 2001 From: Ilya Grigoriev Date: Sun, 15 Sep 2024 17:28:01 -0700 Subject: [PATCH 15/67] Use `WUSH_AUTH_KEY` instead of `WUSH_AUTH_key`, remove mentions of `wush receive` (#29) * cp.go & portforward.go: use `WUSH_AUTH_KEY`, not `WUSH_AUTH_key` * Replace `wush receive` with `wush serve` --- README.md | 2 +- cmd/wush/cp.go | 4 ++-- cmd/wush/portforward.go | 4 ++-- cmd/wush/rsync.go | 2 +- cmd/wush/ssh.go | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 07c6a1a..bf8610a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ coder@colin:~$ `wush` doesn't require you to trust any 3rd party authentication or relay servers, instead using x25519 keys to authenticate incoming connections. Auth -keys generated by `wush receive` are separated into a couple parts: +keys generated by `wush serve` are separated into a couple parts: ```text 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx diff --git a/cmd/wush/cp.go b/cmd/wush/cp.go index 4dfe1d2..42eaa8e 100644 --- a/cmd/wush/cp.go +++ b/cmd/wush/cp.go @@ -212,8 +212,8 @@ func cpCmd() *serpent.Command { Options: []serpent.Option{ { Flag: "auth-key", - Env: "WUSH_AUTH_key", - Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Env: "WUSH_AUTH_KEY", + Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.", Default: "", Value: serpent.StringOf(&overlayOpts.authKey), }, diff --git a/cmd/wush/portforward.go b/cmd/wush/portforward.go index 7e333a1..2965e48 100644 --- a/cmd/wush/portforward.go +++ b/cmd/wush/portforward.go @@ -174,8 +174,8 @@ func portForwardCmd() *serpent.Command { Options: []serpent.Option{ { Flag: "auth-key", - Env: "WUSH_AUTH_key", - Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Env: "WUSH_AUTH_KEY", + Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.", Default: "", Value: serpent.StringOf(&overlayOpts.authKey), }, diff --git a/cmd/wush/rsync.go b/cmd/wush/rsync.go index c54c4e1..9e876a7 100644 --- a/cmd/wush/rsync.go +++ b/cmd/wush/rsync.go @@ -75,7 +75,7 @@ func rsyncCmd() *serpent.Command { { Flag: "auth-key", Env: "WUSH_AUTH_KEY", - Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.", Default: "", Value: serpent.StringOf(&overlayOpts.authKey), }, diff --git a/cmd/wush/ssh.go b/cmd/wush/ssh.go index 42a3bec..8117c24 100644 --- a/cmd/wush/ssh.go +++ b/cmd/wush/ssh.go @@ -33,7 +33,7 @@ func sshCmd() *serpent.Command { Aliases: []string{}, Short: "Open a shell.", Long: "Opens an SSH connection to a " + cliui.Code("wush") + " peer. " + - "Use " + cliui.Code("wush receive") + " on the computer you would like to connect to.", + "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), @@ -91,7 +91,7 @@ func sshCmd() *serpent.Command { { Flag: "auth-key", Env: "WUSH_AUTH_KEY", - Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.", Default: "", Value: serpent.StringOf(&overlayOpts.authKey), }, From 3dac91e6d59676054e477316d678db54bfe00fd2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:32:10 -0500 Subject: [PATCH 16/67] chore(deps): bump golang.org/x/net from 0.28.0 to 0.29.0 (#30) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.28.0 to 0.29.0. - [Commits](https://github.com/golang/net/compare/v0.28.0...v0.29.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] 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 006c371..6619304 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( go4.org/mem v0.0.0-20220726221520-4f986261bf13 golang.org/x/crypto v0.27.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 - golang.org/x/net v0.28.0 + golang.org/x/net v0.29.0 golang.org/x/sys v0.25.0 golang.org/x/term v0.24.0 golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 diff --git a/go.sum b/go.sum index d9ddda2..cb67a8a 100644 --- a/go.sum +++ b/go.sum @@ -644,8 +644,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.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +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/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From d6723bd04d116d4a578047cbfd6aeeb398ff0421 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:32:35 -0500 Subject: [PATCH 17/67] chore(deps): bump github.com/schollz/progressbar/v3 from 3.14.6 to 3.15.0 (#31) Bumps [github.com/schollz/progressbar/v3](https://github.com/schollz/progressbar) from 3.14.6 to 3.15.0. - [Release notes](https://github.com/schollz/progressbar/releases) - [Commits](https://github.com/schollz/progressbar/compare/v3.14.6...v3.15.0) --- updated-dependencies: - dependency-name: github.com/schollz/progressbar/v3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 6619304..36bea7a 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.3 github.com/puzpuzpuz/xsync/v3 v3.4.0 - github.com/schollz/progressbar/v3 v3.14.6 + github.com/schollz/progressbar/v3 v3.15.0 github.com/spf13/afero v1.11.0 github.com/valyala/fasthttp v1.55.0 go4.org/mem v0.0.0-20220726221520-4f986261bf13 diff --git a/go.sum b/go.sum index cb67a8a..8967d53 100644 --- a/go.sum +++ b/go.sum @@ -469,8 +469,8 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 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.14.6 h1:GyjwcWBAf+GFDMLziwerKvpuS7ZF+mNTAXIB2aspiZs= -github.com/schollz/progressbar/v3 v3.14.6/go.mod h1:Nrzpuw3Nl0srLY0VlTvC4V6RL50pcEymjy6qyJAaLa0= +github.com/schollz/progressbar/v3 v3.15.0 h1:cNZmcNiVyea6oofBTg80ZhVXxf3wG/JoAhqCCwopkQo= +github.com/schollz/progressbar/v3 v3.15.0/go.mod h1:ncBdc++eweU0dQoeZJ3loXoAc+bjaallHRIm8pVVeQM= 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= @@ -692,7 +692,6 @@ 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.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -702,7 +701,6 @@ 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.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= From c43ea6c3c32e70ee43657e25c0472d2a38e8aec7 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 25 Sep 2024 19:14:14 -0500 Subject: [PATCH 18/67] feat: init wush.dev (#37) --- .gitignore | 3 + cmd/wasm/main.go | 308 + go.mod | 2 +- go.sum | 4 +- site/.gitignore | 5 + site/app/assets/wasm_exec.js | 668 +++ 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 | 109 + site/app/routes/_index.tsx | 36 + site/app/routes/connect.tsx | 32 + site/app/tailwind.css | 10 + site/biome.json | 7 + site/cors-config.json | 8 + site/deploy.sh | 18 + site/package.json | 54 + site/pnpm-lock.yaml | 7002 +++++++++++++++++++++++ site/postcss.config.js | 6 + site/public/favicon.ico | Bin 0 -> 1053 bytes site/remix.config.ts | 4 + site/tailwind.config.ts | 66 + site/tsconfig.json | 32 + site/types/wush_js.d.ts | 37 + site/vite.config.ts | 31 + site/wrangler.toml | 85 + 27 files changed, 8805 insertions(+), 3 deletions(-) create mode 100644 cmd/wasm/main.go create mode 100644 site/.gitignore create mode 100644 site/app/assets/wasm_exec.js create mode 100644 site/app/components/Terminal.client.tsx create mode 100644 site/app/context/wush.ts create mode 100644 site/app/entry.client.tsx create mode 100644 site/app/entry.server.tsx create mode 100644 site/app/root.tsx create mode 100644 site/app/routes/_index.tsx create mode 100644 site/app/routes/connect.tsx create mode 100644 site/app/tailwind.css create mode 100644 site/biome.json create mode 100644 site/cors-config.json create mode 100755 site/deploy.sh create mode 100644 site/package.json create mode 100644 site/pnpm-lock.yaml create mode 100644 site/postcss.config.js create mode 100644 site/public/favicon.ico create mode 100644 site/remix.config.ts create mode 100644 site/tailwind.config.ts create mode 100644 site/tsconfig.json create mode 100644 site/types/wush_js.d.ts create mode 100644 site/vite.config.ts create mode 100644 site/wrangler.toml diff --git a/.gitignore b/.gitignore index ecfc81b..ea90573 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ dist test + +main.wasm +test.txt diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 0000000..b0560e6 --- /dev/null +++ b/cmd/wasm/main.go @@ -0,0 +1,308 @@ +//go:build js && wasm + +package main + +import ( + "bytes" + "context" + "fmt" + "log" + "log/slog" + "net" + "syscall/js" + "time" + + "github.com/coder/wush/overlay" + "github.com/coder/wush/tsserver" + "golang.org/x/crypto/ssh" + "golang.org/x/xerrors" + "tailscale.com/ipn/store" + "tailscale.com/net/netns" + "tailscale.com/tsnet" +) + +func main() { + fmt.Println("WebAssembly module initialized") + defer fmt.Println("WebAssembly module exited") + + js.Global().Set("newWush", js.FuncOf(func(this js.Value, args []js.Value) any { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) any { + if len(args) != 1 { + log.Fatal("Usage: newWush(config)") + return nil + } + + go func() { + w := newWush(args[0]) + promiseArgs[0].Invoke(w) + }() + + return nil + }) + + 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) +} + +func newWush(jsConfig 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...) + } + dm, err := tsserver.DERPMapTailscale(ctx) + if err != nil { + panic(err) + } + + send := overlay.NewSendOverlay(logger, dm) + err = send.Auth.Parse(authKey) + if err != nil { + panic(err) + } + + s, err := tsserver.NewServer(ctx, logger, send) + if err != nil { + panic(err) + } + + go send.ListenOverlayDERP(ctx) + go s.ListenAndServe(ctx) + netns.SetDialerOverride(s.Dialer()) + + ts, err := newTSNet("send") + if err != nil { + panic(err) + } + + _, err = ts.Up(ctx) + if err != nil { + panic(err) + } + hlog("WireGuard is ready") + + return map[string]any{ + "stop": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 0 { + log.Printf("Usage: stop()") + return nil + } + ts.Close() + return nil + }), + "ssh": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + log.Printf("Usage: ssh({})") + return nil + } + + sess := &sshSession{ + ts: ts, + cfg: args[0], + } + + go sess.Run() + + return map[string]any{ + "close": js.FuncOf(func(this js.Value, args []js.Value) any { + return sess.Close() != nil + }), + "resize": js.FuncOf(func(this js.Value, args []js.Value) any { + rows := args[0].Int() + cols := args[1].Int() + return sess.Resize(rows, cols) != nil + }), + } + }), + } +} + +type sshSession struct { + ts *tsnet.Server + cfg js.Value + + session *ssh.Session + pendingResizeRows int + pendingResizeCols int +} + +func (s *sshSession) Close() error { + if s.session == nil { + // We never had a chance to open the session, ignore the close request. + return nil + } + return s.session.Close() +} + +func (s *sshSession) Resize(rows, cols int) error { + if s.session == nil { + s.pendingResizeRows = rows + s.pendingResizeCols = cols + return nil + } + return s.session.WindowChange(rows, cols) +} + +func (s *sshSession) Run() { + writeFn := s.cfg.Get("writeFn") + writeErrorFn := s.cfg.Get("writeErrorFn") + setReadFn := s.cfg.Get("setReadFn") + rows := s.cfg.Get("rows").Int() + cols := s.cfg.Get("cols").Int() + timeoutSeconds := 5.0 + if jsTimeoutSeconds := s.cfg.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber { + timeoutSeconds = jsTimeoutSeconds.Float() + } + onConnectionProgress := s.cfg.Get("onConnectionProgress") + onConnected := s.cfg.Get("onConnected") + onDone := s.cfg.Get("onDone") + defer onDone.Invoke() + + writeError := func(label string, err error) { + writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err)) + } + reportProgress := func(message string) { + onConnectionProgress.Invoke(message) + } + + 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")) + if err != nil { + writeError("Dial", err) + return + } + defer c.Close() + + config := &ssh.ClientConfig{ + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + // Host keys are not used with Tailscale SSH, but we can use this + // callback to know that the connection has been established. + reportProgress("SSH connection established…") + return nil + }, + } + + reportProgress("Starting SSH client…") + sshConn, _, _, err := ssh.NewClientConn(c, "100.64.0.0:3", config) + if err != nil { + writeError("SSH Connection", err) + return + } + defer sshConn.Close() + + sshClient := ssh.NewClient(sshConn, nil, nil) + defer sshClient.Close() + + session, err := sshClient.NewSession() + if err != nil { + writeError("SSH Session", err) + return + } + s.session = session + defer session.Close() + + stdin, err := session.StdinPipe() + if err != nil { + writeError("SSH Stdin", err) + return + } + + session.Stdout = termWriter{writeFn} + session.Stderr = termWriter{writeFn} + + setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any { + input := args[0].String() + _, err := stdin.Write([]byte(input)) + if err != nil { + writeError("Write Input", err) + } + return nil + })) + + // We might have gotten a resize notification since we started opening the + // session, pick up the latest size. + if s.pendingResizeRows != 0 { + rows = s.pendingResizeRows + } + if s.pendingResizeCols != 0 { + cols = s.pendingResizeCols + } + err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{}) + + if err != nil { + writeError("Pseudo Terminal", err) + return + } + + err = session.Shell() + if err != nil { + writeError("Shell", err) + return + } + + onConnected.Invoke() + err = session.Wait() + if err != nil { + writeError("Wait", err) + return + } +} + +type termWriter struct { + f js.Value +} + +func (w termWriter) Write(p []byte) (n int, err error) { + r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1) + w.f.Invoke(string(r)) + return len(p), nil +} + +type jsConsoleWriter struct{} + +func (w jsConsoleWriter) Write(p []byte) (n int, err error) { + js.Global().Get("console").Call("log", string(p)) + return len(p), nil +} + +func newTSNet(direction string) (*tsnet.Server, error) { + var err error + // tmp := os.TempDir() + srv := new(tsnet.Server) + // srv.Dir = tmp + srv.Hostname = "wush-" + direction + srv.Ephemeral = true + srv.AuthKey = direction + srv.ControlURL = "http://127.0.0.1:8080" + // srv.Logf = func(format string, args ...any) {} + srv.Logf = func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + } + // srv.UserLogf = func(format string, args ...any) {} + srv.UserLogf = func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + } + // netns.SetEnabled(false) + + srv.Store, err = store.New(func(format string, args ...any) {}, "mem:wush") + if err != nil { + return nil, xerrors.Errorf("create state store: %w", err) + } + + return srv, nil +} diff --git a/go.mod b/go.mod index 36bea7a..f2c17c4 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/coder/wush go 1.22.5 -replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20240815205130-c39ab8bcc2a9 +replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20240926000438-059d0c1039af replace github.com/gliderlabs/ssh => github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 diff --git a/go.sum b/go.sum index 8967d53..4ca95a4 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.20240815205130-c39ab8bcc2a9 h1:XLbLUULAjNzo8QOTqDPOIHegRNga3cgJg95srOmYM2Q= -github.com/coadler/tailscale v1.1.1-0.20240815205130-c39ab8bcc2a9/go.mod h1:rRq+xvgprFys8sZbJgcAMMqpiP6r+Y75CJRhCRmXrd0= +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/coder/coder/v2 v2.14.2 h1:RNNDDwjNK5D1XMQlK7LWrS4niVclkl1FXoaOaW7N5rs= github.com/coder/coder/v2 v2.14.2/go.mod h1:dO79BI5XlP8rrtne1JpRcVehe27bNMXdZKyn1NsWbjA= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..80ec311 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env diff --git a/site/app/assets/wasm_exec.js b/site/app/assets/wasm_exec.js new file mode 100644 index 0000000..8bc1520 --- /dev/null +++ b/site/app/assets/wasm_exec.js @@ -0,0 +1,668 @@ +// 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 new file mode 100644 index 0000000..b8a889c --- /dev/null +++ b/site/app/components/Terminal.client.tsx @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000..e6759ec --- /dev/null +++ b/site/app/context/wush.ts @@ -0,0 +1,3 @@ +import React from "react"; + +export const WushContext = React.createContext(false); diff --git a/site/app/entry.client.tsx b/site/app/entry.client.tsx new file mode 100644 index 0000000..1db62c7 --- /dev/null +++ b/site/app/entry.client.tsx @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..65ad16e --- /dev/null +++ b/site/app/entry.server.tsx @@ -0,0 +1,155 @@ +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 new file mode 100644 index 0000000..b4fb738 --- /dev/null +++ b/site/app/root.tsx @@ -0,0 +1,109 @@ +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 ( + + + + + + +