├── site ├── bun.lockb ├── public │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── site.webmanifest ├── types │ ├── wasm.d.ts │ └── wush_js.d.ts ├── postcss.config.js ├── lib │ └── utils.ts ├── next-env.d.ts ├── pages │ ├── index.tsx │ ├── _app.tsx │ ├── api │ │ └── iceConfig.ts │ ├── _document.tsx │ ├── access.tsx │ ├── receive.tsx │ ├── terminal.tsx │ └── send.tsx ├── build_wasm.sh ├── components.json ├── tsconfig.json ├── components │ ├── ui │ │ ├── progress.tsx │ │ ├── sonner.tsx │ │ ├── cli-command.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── table.tsx │ └── layout.tsx ├── next.config.ts ├── package.json ├── tailwind.config.ts ├── globals.css └── context │ └── wush.tsx ├── .gitignore ├── xssh ├── windowsize_js.go ├── windowsize_other.go ├── windowsize_windows.go └── client.go ├── .github ├── dependabot.yml └── workflows │ └── release.yaml ├── cmd └── wush │ ├── version.go │ ├── rsync.go │ ├── main.go │ ├── ssh.go │ ├── serve.go │ ├── cp.go │ └── portforward.go ├── flake.nix ├── overlay ├── overlay.go ├── auth.go ├── send.go ├── wasm_js.go └── receive.go ├── flake.lock ├── .goreleaser.yaml ├── cliui └── cliui.go ├── install.sh ├── README.md ├── LICENSE └── go.mod /site/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/wush/HEAD/site/bun.lockb -------------------------------------------------------------------------------- /site/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/wush/HEAD/site/public/favicon.ico -------------------------------------------------------------------------------- /site/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/wush/HEAD/site/public/favicon-16x16.png -------------------------------------------------------------------------------- /site/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/wush/HEAD/site/public/favicon-32x32.png -------------------------------------------------------------------------------- /site/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/wush/HEAD/site/public/apple-touch-icon.png -------------------------------------------------------------------------------- /site/types/wasm.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.wasm" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /site/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/wush/HEAD/site/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /site/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/wush/HEAD/site/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /site/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | test 3 | 4 | main.wasm 5 | test.txt 6 | .next/ 7 | .env 8 | site/wasm/*.js 9 | site/wasm/*.wasm 10 | site/node_modules/ 11 | .env.local 12 | 13 | .vercel 14 | -------------------------------------------------------------------------------- /site/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /xssh/windowsize_js.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package xssh 4 | 5 | import ( 6 | "context" 7 | "os" 8 | ) 9 | 10 | func ListenWindowSize(ctx context.Context) <-chan os.Signal { 11 | return nil 12 | } 13 | -------------------------------------------------------------------------------- /site/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /site/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useRouter } from "next/router"; 3 | 4 | export default function Component() { 5 | const router = useRouter(); 6 | useEffect(() => { 7 | const currentHash = window.location.hash.substring(1); 8 | if (currentHash) { 9 | router.push(`/send#${currentHash}`); 10 | } else { 11 | router.push("/send"); 12 | } 13 | }, [router]); 14 | } 15 | -------------------------------------------------------------------------------- /site/build_wasm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | cd "$(dirname "$0")" 6 | 7 | mkdir -p wasm 8 | 9 | echo "WARNING: make sure you're using 'nix develop' for the correct go version" 10 | 11 | GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o ./wasm/main.wasm ../cmd/wasm 12 | wasm-opt -Oz ./wasm/main.wasm -o ./wasm/main.wasm --enable-bulk-memory 13 | 14 | cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./wasm/wasm_exec.js && chmod 644 ./wasm/wasm_exec.js 15 | -------------------------------------------------------------------------------- /xssh/windowsize_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !js && !wasm 2 | // +build !windows,!js,!wasm 3 | 4 | package xssh 5 | 6 | import ( 7 | "context" 8 | "os" 9 | "os/signal" 10 | 11 | "golang.org/x/sys/unix" 12 | ) 13 | 14 | func ListenWindowSize(ctx context.Context) <-chan os.Signal { 15 | windowSize := make(chan os.Signal, 1) 16 | signal.Notify(windowSize, unix.SIGWINCH) 17 | go func() { 18 | <-ctx.Done() 19 | signal.Stop(windowSize) 20 | }() 21 | return windowSize 22 | } 23 | -------------------------------------------------------------------------------- /site/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "./globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /xssh/windowsize_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package xssh 5 | 6 | import ( 7 | "context" 8 | "os" 9 | "time" 10 | ) 11 | 12 | func ListenWindowSize(ctx context.Context) <-chan os.Signal { 13 | windowSize := make(chan os.Signal, 3) 14 | ticker := time.NewTicker(time.Second) 15 | go func() { 16 | defer ticker.Stop() 17 | for { 18 | select { 19 | case <-ctx.Done(): 20 | return 21 | case <-ticker.C: 22 | } 23 | windowSize <- nil 24 | } 25 | }() 26 | return windowSize 27 | } 28 | -------------------------------------------------------------------------------- /site/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wush web transfers and ssh", 3 | "short_name": "wush", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /cmd/wush/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/coder/serpent" 8 | ) 9 | 10 | func versionCmd() *serpent.Command { 11 | cmd := &serpent.Command{ 12 | Use: "version", 13 | Short: "Output the wush version.", 14 | Handler: func(inv *serpent.Invocation) error { 15 | bi := getBuildInfo() 16 | fmt.Printf("Wush %s-%s %s\n", bi.version, bi.commitHash[:7], bi.commitTime.Format(time.RFC1123)) 17 | fmt.Printf("https://github.com/coder/wush/commit/%s\n", commit) 18 | return nil 19 | }, 20 | Options: serpent.OptionSet{}, 21 | } 22 | 23 | return cmd 24 | } 25 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Dev shell for Go backend and React frontend (using pnpm)"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: 10 | let 11 | pkgs = import nixpkgs { inherit system; }; 12 | in 13 | { 14 | devShell = pkgs.mkShell 15 | { 16 | buildInputs = with pkgs; [ 17 | go 18 | nodejs 19 | pnpm 20 | binaryen # wasm-opt 21 | ]; 22 | 23 | shellHook = '' 24 | exec $SHELL 25 | ''; 26 | }; 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "next-env.d.ts", 4 | "**/*.ts", 5 | "**/*.tsx", 6 | "types/**/*.d.ts" 7 | ], 8 | "compilerOptions": { 9 | "lib": [ 10 | "DOM", 11 | "DOM.Iterable", 12 | "ES2022" 13 | ], 14 | "types": [ 15 | "golang-wasm-exec" 16 | ], 17 | "isolatedModules": true, 18 | "esModuleInterop": true, 19 | "jsx": "preserve", 20 | "module": "ESNext", 21 | "moduleResolution": "Bundler", 22 | "resolveJsonModule": true, 23 | "target": "ES2022", 24 | "strict": true, 25 | "allowJs": true, 26 | "skipLibCheck": true, 27 | "forceConsistentCasingInFileNames": true, 28 | "baseUrl": ".", 29 | "paths": { 30 | "@/*": ["./*"] 31 | }, 32 | "noEmit": true, 33 | "incremental": true, 34 | "plugins": [ 35 | { 36 | "name": "next" 37 | } 38 | ], 39 | "typeRoots": ["./types", "./node_modules/@types"] 40 | }, 41 | "exclude": [ 42 | "node_modules" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /site/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Progress = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef & { 9 | children?: React.ReactNode; 10 | } 11 | >(({ className, value, children, ...props }, ref) => ( 12 | 20 | 26 | {children} 27 | 28 | )); 29 | Progress.displayName = ProgressPrimitive.Root.displayName; 30 | 31 | export { Progress }; 32 | -------------------------------------------------------------------------------- /site/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement, ReactNode } from "react"; 2 | import type { NextPage } from "next"; 3 | import type { AppProps } from "next/app"; 4 | import { WasmProvider } from "@/context/wush"; 5 | import Layout from "@/components/layout"; 6 | import "tailwindcss/tailwind.css"; 7 | 8 | export type NextPageWithLayout

, IP = P> = NextPage< 9 | P, 10 | IP 11 | > & { 12 | getLayout?: (page: ReactElement) => ReactNode; 13 | }; 14 | 15 | type AppPropsWithLayout = AppProps & { 16 | Component: NextPageWithLayout; 17 | }; 18 | 19 | export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { 20 | // Use the layout defined at the page level, if available 21 | const getLayout = 22 | Component.getLayout ?? 23 | (() => ( 24 | 25 | 26 | 27 | 28 | 29 | )); 30 | 31 | return getLayout( 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /site/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes"; 2 | import { Toaster as Sonner } from "sonner"; 3 | 4 | type ToasterProps = React.ComponentProps; 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme(); 8 | 9 | return ( 10 | 27 | ); 28 | }; 29 | 30 | export { Toaster }; 31 | -------------------------------------------------------------------------------- /site/pages/api/iceConfig.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | type RTCIceServer = { 4 | urls: string[]; 5 | username?: string; 6 | credential?: string; 7 | }; 8 | 9 | type IceConfig = { 10 | iceServers: RTCIceServer[]; 11 | }; 12 | 13 | export default function handler( 14 | req: NextApiRequest, 15 | res: NextApiResponse, 16 | ) { 17 | if (req.method !== "GET") { 18 | return res.status(405).json({ error: "Method not allowed" } as any); 19 | } 20 | 21 | const iceServers: RTCIceServer[] = [ 22 | { 23 | urls: ["stun:stun.l.google.com:19302"], 24 | }, 25 | ]; 26 | 27 | // Add TURN server if credentials are configured 28 | if ( 29 | process.env.NEXT_PUBLIC_TURN_SERVER_URL && 30 | process.env.NEXT_PUBLIC_TURN_USERNAME && 31 | process.env.NEXT_PUBLIC_TURN_CREDENTIAL 32 | ) { 33 | iceServers.push({ 34 | urls: [process.env.NEXT_PUBLIC_TURN_SERVER_URL], 35 | username: process.env.NEXT_PUBLIC_TURN_USERNAME, 36 | credential: process.env.NEXT_PUBLIC_TURN_CREDENTIAL, 37 | }); 38 | } 39 | 40 | res.status(200).json({ 41 | iceServers, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /site/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | // Define an async function to create the config 4 | const createConfig = async (): Promise => { 5 | const githubStars = await fetch("https://api.github.com/repos/coder/wush") 6 | .then((r) => r.json()) 7 | .then((j) => j.stargazers_count.toString()); 8 | 9 | return { 10 | env: { 11 | GITHUB_STARS: githubStars, 12 | }, 13 | webpack: (config, { dev }) => { 14 | // Add WASM support 15 | config.experiments = { 16 | ...config.experiments, 17 | asyncWebAssembly: true, 18 | }; 19 | 20 | // Add rule for wasm files with content hashing 21 | config.module.rules.push({ 22 | test: /\.wasm$/, 23 | type: "asset/resource", 24 | generator: { 25 | filename: dev 26 | ? "static/wasm/[name].wasm" 27 | : "static/wasm/[name].[hash][ext]", 28 | }, 29 | }); 30 | 31 | return config; 32 | }, 33 | headers: async () => [ 34 | { 35 | source: "/:all*.wasm", 36 | headers: [ 37 | { 38 | key: "Cache-Control", 39 | value: "public, max-age=31536000, immutable", 40 | }, 41 | ], 42 | }, 43 | ], 44 | }; 45 | }; 46 | 47 | export default createConfig(); 48 | -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "site", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@radix-ui/react-icons": "^1.3.1", 12 | "@radix-ui/react-progress": "^1.1.0", 13 | "@radix-ui/react-slot": "^1.1.0", 14 | "@radix-ui/react-toast": "^1.2.2", 15 | "@types/ws": "^8.5.13", 16 | "@xterm/addon-canvas": "0.7.0", 17 | "@xterm/addon-fit": "0.10.0", 18 | "@xterm/xterm": "5.5.0", 19 | "class-variance-authority": "^0.7.0", 20 | "clsx": "^2.1.1", 21 | "lucide-react": "^0.454.0", 22 | "next": "^15.1.0", 23 | "next-themes": "^0.4.3", 24 | "react": "^19.0.0", 25 | "react-dom": "^19.0.0", 26 | "sonner": "^1.7.0", 27 | "tailwind-merge": "^2.5.4", 28 | "tailwind-scrollbar": "^3.1.0", 29 | "tailwindcss-animate": "^1.0.7", 30 | "use-async-effect": "^2.2.7", 31 | "ws": "^8.18.0" 32 | }, 33 | "devDependencies": { 34 | "@biomejs/biome": "^1.9.4", 35 | "@types/golang-wasm-exec": "^1.15.2", 36 | "@types/node": "^20.0.0", 37 | "@types/react": "^18.2.0", 38 | "autoprefixer": "^10.4.20", 39 | "postcss": "^8.4.47", 40 | "tailwindcss": "^3.4.14", 41 | "typescript": "^5.0.0" 42 | }, 43 | "engines": { 44 | "node": "22.x" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /overlay/overlay.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | "net/netip" 5 | 6 | "github.com/google/uuid" 7 | "github.com/pion/webrtc/v4" 8 | "tailscale.com/tailcfg" 9 | ) 10 | 11 | type Logf func(format string, args ...any) 12 | 13 | // Overlay specifies the mechanism by which senders and receivers exchange 14 | // Tailscale nodes over a sidechannel. 15 | type Overlay interface { 16 | // listenOverlay(ctx context.Context, kind string) error 17 | Recv() <-chan *tailcfg.Node 18 | SendTailscaleNodeUpdate(node *tailcfg.Node) 19 | IPs() []netip.Addr 20 | } 21 | 22 | type messageType int 23 | 24 | const ( 25 | messageTypePing messageType = 1 + iota 26 | messageTypePong 27 | messageTypeHello 28 | messageTypeHelloResponse 29 | messageTypeNodeUpdate 30 | 31 | messageTypeWebRTCOffer 32 | messageTypeWebRTCAnswer 33 | messageTypeWebRTCCandidate 34 | ) 35 | 36 | type overlayMessage struct { 37 | Typ messageType 38 | 39 | HostInfo HostInfo 40 | Node tailcfg.Node 41 | 42 | WebrtcDescription *webrtc.SessionDescription 43 | WebrtcCandidate *webrtc.ICECandidateInit 44 | } 45 | 46 | type HostInfo struct { 47 | Username string 48 | Hostname string 49 | } 50 | 51 | var TailscaleServicePrefix6 = [6]byte{0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0} 52 | 53 | func randv6() netip.Addr { 54 | uid := uuid.New() 55 | copy(uid[:], TailscaleServicePrefix6[:]) 56 | return netip.AddrFrom16(uid) 57 | } 58 | -------------------------------------------------------------------------------- /site/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | class MyDocument extends Document { 4 | render() { 5 | return ( 6 | 7 | 8 | 12 | 16 | 17 | 18 | {/* Google Analytics */} 19 |