& {
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 |
23 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
43 | export default MyDocument;
44 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | # This GitHub action creates a release when a tag that matches the pattern
4 | # "v*" (e.g. v0.1.0) is created.
5 | on:
6 | push:
7 | tags:
8 | - "v*"
9 |
10 | # Releases need permissions to read and write the repository contents.
11 | # GitHub considers creating releases and uploading assets as writing contents.
12 | permissions:
13 | contents: write
14 |
15 | jobs:
16 | goreleaser:
17 | runs-on: ubuntu-latest-8-cores
18 | steps:
19 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
20 | with:
21 | # Allow goreleaser to access older tag information.
22 | fetch-depth: 0
23 | - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
24 | with:
25 | go-version-file: "go.mod"
26 | cache: true
27 | - name: Import GPG key
28 | uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
29 | id: import_gpg
30 | with:
31 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
32 | passphrase: ${{ secrets.GPG_PASSPHRASE }}
33 | - name: Run GoReleaser
34 | uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0
35 | with:
36 | args: release --clean
37 | env:
38 | # GitHub sets the GITHUB_TOKEN secret automatically.
39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
41 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-utils": {
4 | "inputs": {
5 | "systems": "systems"
6 | },
7 | "locked": {
8 | "lastModified": 1726560853,
9 | "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
10 | "owner": "numtide",
11 | "repo": "flake-utils",
12 | "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
13 | "type": "github"
14 | },
15 | "original": {
16 | "owner": "numtide",
17 | "repo": "flake-utils",
18 | "type": "github"
19 | }
20 | },
21 | "nixpkgs": {
22 | "locked": {
23 | "lastModified": 1728888510,
24 | "narHash": "sha256-nsNdSldaAyu6PE3YUA+YQLqUDJh+gRbBooMMekZJwvI=",
25 | "owner": "NixOS",
26 | "repo": "nixpkgs",
27 | "rev": "a3c0b3b21515f74fd2665903d4ce6bc4dc81c77c",
28 | "type": "github"
29 | },
30 | "original": {
31 | "owner": "NixOS",
32 | "ref": "nixos-unstable",
33 | "repo": "nixpkgs",
34 | "type": "github"
35 | }
36 | },
37 | "root": {
38 | "inputs": {
39 | "flake-utils": "flake-utils",
40 | "nixpkgs": "nixpkgs"
41 | }
42 | },
43 | "systems": {
44 | "locked": {
45 | "lastModified": 1681028828,
46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47 | "owner": "nix-systems",
48 | "repo": "default",
49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50 | "type": "github"
51 | },
52 | "original": {
53 | "owner": "nix-systems",
54 | "repo": "default",
55 | "type": "github"
56 | }
57 | }
58 | },
59 | "root": "root",
60 | "version": 7
61 | }
62 |
--------------------------------------------------------------------------------
/site/pages/access.tsx:
--------------------------------------------------------------------------------
1 | import { FileUp, TerminalIcon } from "lucide-react";
2 | import { useWasm } from "@/context/wush";
3 | import Link from "next/link";
4 |
5 | export default function Component() {
6 | const wasm = useWasm();
7 | const isSubmitDisabled =
8 | !wasm.connectedPeer || wasm.connectedPeer.type !== "cli";
9 | const currentFragment = (() => {
10 | // Check if we're in the browser
11 | return typeof window !== "undefined"
12 | ? window.location.hash.substring(1)
13 | : "";
14 | })();
15 |
16 | console.log(wasm.connectedPeer);
17 | return (
18 | <>
19 |
20 | Access a remote machine by running{" "}
21 |
22 |
23 | wush serve
24 |
25 | {" "}
26 | and pasting the key above.
27 |
28 |
29 | {wasm.connectedPeer?.type === "web" && (
30 |
31 | Terminals are only available when connected to a CLI. Use{" "}
32 | wush serve instead.
33 |
34 | )}
35 |
36 |
48 |
49 |
50 | >
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/site/components/ui/cli-command.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Card, CardContent } from "@/components/ui/card";
3 | import { Button } from "@/components/ui/button";
4 | import { toast } from "sonner";
5 | import { Check, Copy } from "lucide-react";
6 |
7 | interface CliCommandCardProps {
8 | command: string;
9 | }
10 |
11 | export function CliCommandCard({ command }: CliCommandCardProps) {
12 | const [isCopied, setIsCopied] = useState(false);
13 |
14 | const copyToClipboard = async () => {
15 | try {
16 | await navigator.clipboard.writeText(command);
17 | setIsCopied(true);
18 | toast.success("Command copied to clipboard");
19 | setTimeout(() => setIsCopied(false), 2000);
20 | } catch (err) {
21 | toast.error("Failed to copy command");
22 | }
23 | };
24 |
25 | return (
26 |
27 |
28 |
29 | {command}
30 |
31 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 | builds:
3 | - main: "./cmd/wush"
4 | env:
5 | - CGO_ENABLED=0
6 | mod_timestamp: "{{ .CommitTimestamp }}"
7 | flags:
8 | - -trimpath
9 | ldflags:
10 | - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.commitDate={{.CommitTimestamp}}"
11 | goos:
12 | - freebsd
13 | - openbsd
14 | # netbsd and dragonfly do not currently build due to wireguard-go.
15 | # - netbsd
16 | # - dragonfly
17 | - windows
18 | - linux
19 | - darwin
20 | goarch:
21 | - amd64
22 | - "386"
23 | - arm
24 | - arm64
25 | goarm:
26 | - "7"
27 | ignore:
28 | - goos: darwin
29 | goarch: "386"
30 | - goos: windows
31 | goarch: "arm"
32 | binary: "{{ .ProjectName }}"
33 | nfpms:
34 | - vendor: Coder Technologies Inc.
35 | homepage: https://coder.com/
36 | maintainer: Colin Adler
37 | description: |-
38 | Wush installer package.
39 | Wush creates secure WireGuard tunnels between two devices.
40 | license: CC0-1.0
41 | contents:
42 | - src: LICENSE
43 | dst: "/usr/share/doc/{{ .ProjectName }}/copyright"
44 | formats:
45 | - apk
46 | - deb
47 | archives:
48 | - id: "tar_or_zip"
49 | format: tar.gz
50 | format_overrides:
51 | - goos: windows
52 | format: zip
53 | - goos: darwin
54 | format: zip
55 | checksum:
56 | name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS"
57 | algorithm: sha256
58 | signs:
59 | - artifacts: checksum
60 | args:
61 | # if you are using this in a GitHub action or some other automated pipeline, you
62 | # need to pass the batch flag to indicate its not interactive.
63 | - "--batch"
64 | - "--local-user"
65 | - "{{ .Env.GPG_FINGERPRINT }}"
66 | - "--output"
67 | - "${signature}"
68 | - "--detach-sign"
69 | - "${artifact}"
70 | # release:
71 | # draft: true
72 | changelog:
73 | use: github-native
74 |
--------------------------------------------------------------------------------
/site/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/site/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/site/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | fontFamily: {
12 | sans: [
13 | // "Inter",
14 | // "ui-sans-serif",
15 | // "system-ui",
16 | // "sans-serif",
17 | "Fira Code",
18 | "monospace",
19 | "Apple Color Emoji",
20 | "Segoe UI Emoji",
21 | "Segoe UI Symbol",
22 | "Noto Color Emoji",
23 | ],
24 | },
25 | borderRadius: {
26 | lg: "var(--radius)",
27 | md: "calc(var(--radius) - 2px)",
28 | sm: "calc(var(--radius) - 4px)",
29 | },
30 | colors: {
31 | background: "hsl(var(--background))",
32 | foreground: "hsl(var(--foreground))",
33 | card: {
34 | DEFAULT: "hsl(var(--card))",
35 | foreground: "hsl(var(--card-foreground))",
36 | },
37 | popover: {
38 | DEFAULT: "hsl(var(--popover))",
39 | foreground: "hsl(var(--popover-foreground))",
40 | },
41 | primary: {
42 | DEFAULT: "hsl(var(--primary))",
43 | foreground: "hsl(var(--primary-foreground))",
44 | },
45 | secondary: {
46 | DEFAULT: "hsl(var(--secondary))",
47 | foreground: "hsl(var(--secondary-foreground))",
48 | },
49 | muted: {
50 | DEFAULT: "hsl(var(--muted))",
51 | foreground: "hsl(var(--muted-foreground))",
52 | },
53 | accent: {
54 | DEFAULT: "hsl(var(--accent))",
55 | foreground: "hsl(var(--accent-foreground))",
56 | },
57 | destructive: {
58 | DEFAULT: "hsl(var(--destructive))",
59 | foreground: "hsl(var(--destructive-foreground))",
60 | },
61 | border: "hsl(var(--border))",
62 | input: "hsl(var(--input))",
63 | ring: "hsl(var(--ring))",
64 | chart: {
65 | "1": "hsl(var(--chart-1))",
66 | "2": "hsl(var(--chart-2))",
67 | "3": "hsl(var(--chart-3))",
68 | "4": "hsl(var(--chart-4))",
69 | "5": "hsl(var(--chart-5))",
70 | },
71 | },
72 | },
73 | },
74 | plugins: [require("tailwindcss-animate"), require("tailwind-scrollbar")],
75 | };
76 |
77 | export default config;
78 |
--------------------------------------------------------------------------------
/site/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | @import "xterm/css/xterm.css";
5 | @import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;700&display=swap");
6 |
7 | html,
8 | body {
9 | @media (prefers-color-scheme: dark) {
10 | color-scheme: dark;
11 | }
12 | @apply font-mono;
13 | }
14 |
15 | @layer base {
16 | :root {
17 | --background: 0 0% 100%;
18 | --foreground: 0 0% 3.9%;
19 | --card: 0 0% 100%;
20 | --card-foreground: 0 0% 3.9%;
21 | --popover: 0 0% 100%;
22 | --popover-foreground: 0 0% 3.9%;
23 | --primary: 0 0% 9%;
24 | --primary-foreground: 0 0% 98%;
25 | --secondary: 0 0% 96.1%;
26 | --secondary-foreground: 0 0% 9%;
27 | --muted: 0 0% 96.1%;
28 | --muted-foreground: 0 0% 45.1%;
29 | --accent: 0 0% 96.1%;
30 | --accent-foreground: 0 0% 9%;
31 | --destructive: 0 84.2% 60.2%;
32 | --destructive-foreground: 0 0% 98%;
33 | --border: 0 0% 89.8%;
34 | --input: 0 0% 89.8%;
35 | --ring: 0 0% 3.9%;
36 | --chart-1: 12 76% 61%;
37 | --chart-2: 173 58% 39%;
38 | --chart-3: 197 37% 24%;
39 | --chart-4: 43 74% 66%;
40 | --chart-5: 27 87% 67%;
41 | --radius: 0.5rem;
42 | }
43 | .dark {
44 | --background: 0 0% 3.9%;
45 | --foreground: 0 0% 98%;
46 | --card: 0 0% 3.9%;
47 | --card-foreground: 0 0% 98%;
48 | --popover: 0 0% 3.9%;
49 | --popover-foreground: 0 0% 98%;
50 | --primary: 0 0% 98%;
51 | --primary-foreground: 0 0% 9%;
52 | --secondary: 0 0% 14.9%;
53 | --secondary-foreground: 0 0% 98%;
54 | --muted: 0 0% 14.9%;
55 | --muted-foreground: 0 0% 63.9%;
56 | --accent: 0 0% 14.9%;
57 | --accent-foreground: 0 0% 98%;
58 | --destructive: 0 62.8% 30.6%;
59 | --destructive-foreground: 0 0% 98%;
60 | --border: 0 0% 14.9%;
61 | --input: 0 0% 14.9%;
62 | --ring: 0 0% 83.1%;
63 | --chart-1: 220 70% 50%;
64 | --chart-2: 160 60% 45%;
65 | --chart-3: 30 80% 55%;
66 | --chart-4: 280 65% 60%;
67 | --chart-5: 340 75% 55%;
68 | }
69 | }
70 |
71 | @layer base {
72 | * {
73 | @apply border-border;
74 | }
75 | body {
76 | @apply bg-background text-foreground;
77 | }
78 | code {
79 | @apply bg-gray-900 text-gray-200 px-1.5 py-0.5 rounded text-sm;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/site/types/wush_js.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | function newWush(config: WushConfig): Promise;
3 |
4 | interface Wush {
5 | auth_info(): AuthInfo;
6 | connect(authKey: string, offer: RTCSessionDescriptionInit): Promise;
7 | ping(peer: Peer): Promise;
8 | ssh(
9 | peer: Peer,
10 | termConfig: {
11 | writeFn: (data: string) => void;
12 | writeErrorFn: (err: string) => void;
13 | setReadFn: (readFn: (data: string) => void) => void;
14 | rows: number;
15 | cols: number;
16 | /** Defaults to 5 seconds */
17 | timeoutSeconds?: number;
18 | onConnectionProgress: (message: string) => void;
19 | onConnected: () => void;
20 | onDone: () => void;
21 | },
22 | ): WushSSHSession;
23 | transfer(
24 | peer: Peer,
25 | filename: string,
26 | sizeBytes: number,
27 | data: ReadableStream,
28 | helper: (bytesRead: number) => void,
29 | ): Promise;
30 | stop(): void;
31 |
32 | sendWebrtcCandidate(peer: string, candidate: RTCIceCandidate);
33 | parseAuthKey(authKey: string): PeerAuthInfo;
34 | }
35 |
36 | type PeerAuthInfo = {
37 | id: string;
38 | type: "cli" | "web";
39 | };
40 |
41 | type AuthInfo = {
42 | derp_id: number;
43 | derp_name: string;
44 | derp_latency: number;
45 | auth_key: string;
46 | };
47 |
48 | type Peer = {
49 | id: string;
50 | name: string;
51 | ip: string;
52 | type: "cli" | "web";
53 | cancel: () => void;
54 | };
55 |
56 | interface WushSSHSession {
57 | resize(rows: number, cols: number): boolean;
58 | close(): boolean;
59 | }
60 |
61 | type WushConfig = {
62 | onNewPeer: (peer: Peer) => void;
63 | // TODO: figure out what needs to be sent to the FE
64 | // FE returns false if denying the file
65 | onIncomingFile: (
66 | peer: Peer,
67 | filename: string,
68 | sizeBytes: number,
69 | ) => Promise;
70 | downloadFile: (
71 | peer: Peer,
72 | filename: string,
73 | sizeBytes: number,
74 | stream: ReadableStream,
75 | ) => Promise;
76 |
77 | onWebrtcOffer: (
78 | id: string,
79 | offer: RTCSessionDescriptionInit,
80 | ) => Promise;
81 | onWebrtcAnswer: (
82 | id: string,
83 | answer: RTCSessionDescriptionInit,
84 | ) => Promise;
85 | onWebrtcCandidate: (
86 | id: string,
87 | candidate: RTCIceCandidateInit,
88 | ) => Promise;
89 | };
90 | }
91 |
92 | export type {};
93 |
--------------------------------------------------------------------------------
/xssh/client.go:
--------------------------------------------------------------------------------
1 | package xssh
2 |
3 | import (
4 | "context"
5 | "os"
6 | "strings"
7 |
8 | "github.com/coder/coder/v2/pty"
9 | "github.com/coder/serpent"
10 | "github.com/mattn/go-isatty"
11 | "golang.org/x/crypto/ssh"
12 | "golang.org/x/term"
13 | "golang.org/x/xerrors"
14 | "tailscale.com/tsnet"
15 | )
16 |
17 | func TailnetSSH(ctx context.Context, inv *serpent.Invocation, ts *tsnet.Server, addr string, stdio bool) error {
18 | conn, err := ts.Dial(ctx, "tcp", addr)
19 | if err != nil {
20 | return err
21 | }
22 |
23 | // if stdio {
24 | // gnConn, ok := conn.(*gonet.TCPConn)
25 | // if !ok {
26 | // panic("ssh tcp conn is not *gonet.TCPConn")
27 | // }
28 | // }
29 |
30 | sshConn, channels, requests, err := ssh.NewClientConn(conn, "127.0.0.1:22", &ssh.ClientConfig{
31 | HostKeyCallback: ssh.InsecureIgnoreHostKey(),
32 | })
33 | if err != nil {
34 | return err
35 | }
36 |
37 | sshClient := ssh.NewClient(sshConn, channels, requests)
38 | sshSession, err := sshClient.NewSession()
39 | if err != nil {
40 | return err
41 | }
42 |
43 | sshSession.Stdin = inv.Stdin
44 | sshSession.Stdout = inv.Stdout
45 | sshSession.Stderr = inv.Stderr
46 |
47 | if len(inv.Args) > 1 {
48 | return sshSession.Run(strings.Join(inv.Args, " "))
49 | }
50 |
51 | stdinFile, validIn := inv.Stdin.(*os.File)
52 | stdoutFile, validOut := inv.Stdout.(*os.File)
53 | if validIn && validOut && isatty.IsTerminal(stdinFile.Fd()) && isatty.IsTerminal(stdoutFile.Fd()) {
54 | inState, err := pty.MakeInputRaw(stdinFile.Fd())
55 | if err != nil {
56 | return err
57 | }
58 | defer func() {
59 | _ = pty.RestoreTerminal(stdinFile.Fd(), inState)
60 | }()
61 | outState, err := pty.MakeOutputRaw(stdoutFile.Fd())
62 | if err != nil {
63 | return err
64 | }
65 | defer func() {
66 | _ = pty.RestoreTerminal(stdoutFile.Fd(), outState)
67 | }()
68 |
69 | windowChange := ListenWindowSize(ctx)
70 | go func() {
71 | for {
72 | select {
73 | case <-ctx.Done():
74 | return
75 | case <-windowChange:
76 | }
77 | width, height, err := term.GetSize(int(stdoutFile.Fd()))
78 | if err != nil {
79 | continue
80 | }
81 | _ = sshSession.WindowChange(height, width)
82 | }
83 | }()
84 | }
85 |
86 | err = sshSession.RequestPty("xterm-256color", 128, 128, ssh.TerminalModes{})
87 | if err != nil {
88 | return xerrors.Errorf("request pty: %w", err)
89 | }
90 |
91 | err = sshSession.Shell()
92 | if err != nil {
93 | return xerrors.Errorf("start shell: %w", err)
94 | }
95 |
96 | if validOut {
97 | // Set initial window size.
98 | width, height, err := term.GetSize(int(stdoutFile.Fd()))
99 | if err == nil {
100 | _ = sshSession.WindowChange(height, width)
101 | }
102 | }
103 |
104 | return sshSession.Wait()
105 | }
106 |
--------------------------------------------------------------------------------
/cmd/wush/rsync.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "os"
7 | "os/exec"
8 | "strings"
9 |
10 | "tailscale.com/types/ptr"
11 |
12 | "github.com/coder/serpent"
13 | "github.com/coder/wush/cliui"
14 | "github.com/coder/wush/tsserver"
15 | )
16 |
17 | func rsyncCmd() *serpent.Command {
18 | var (
19 | verbose bool
20 | logger = new(slog.Logger)
21 | logf = func(str string, args ...any) {}
22 |
23 | overlayOpts = new(sendOverlayOpts)
24 | )
25 | return &serpent.Command{
26 | Use: "rsync [flags] -- [rsync args]",
27 | Short: "Transfer files with rsync to/from a wush server.",
28 | Long: "Use " + cliui.Code("wush serve") + " on the computer you would like to transfer files to." +
29 | "\n\n" +
30 | formatExamples(
31 | example{
32 | Description: "Upload a local file",
33 | Command: "wush rsync /local/path :/remote/path",
34 | },
35 | example{
36 | Description: "Download a remote file",
37 | Command: "wush rsync :/remote/path /local/path",
38 | },
39 | example{
40 | Description: "Add rsync flags",
41 | Command: "wush rsync /local/path :/remote/path -- --progress --stats -avz --human-readable",
42 | },
43 | ),
44 | Middleware: serpent.Chain(
45 | initLogger(&verbose, ptr.To(false), logger, &logf),
46 | initAuth(&overlayOpts.authKey, &overlayOpts.clientAuth),
47 | ),
48 | Handler: func(inv *serpent.Invocation) error {
49 | ctx := inv.Context()
50 |
51 | dm, err := tsserver.DERPMapTailscale(inv.Context())
52 | if err != nil {
53 | return err
54 | }
55 | overlayOpts.clientAuth.PrintDebug(logf, dm)
56 |
57 | progPath := os.Args[0]
58 | args := []string{
59 | "-c",
60 | fmt.Sprintf(`rsync -e "%s ssh --auth-key %s --quiet --" %s`,
61 | progPath, overlayOpts.clientAuth.AuthKey(), strings.Join(inv.Args, " "),
62 | ),
63 | }
64 | fmt.Println("Running rsync", strings.Join(inv.Args, " "))
65 | cmd := exec.CommandContext(ctx, "sh", args...)
66 | cmd.Stdin = inv.Stdin
67 | cmd.Stdout = inv.Stdout
68 | cmd.Stderr = inv.Stderr
69 |
70 | return cmd.Run()
71 | },
72 | Options: []serpent.Option{
73 | {
74 | Flag: "auth-key",
75 | Env: "WUSH_AUTH_KEY",
76 | Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.",
77 | Default: "",
78 | Value: serpent.StringOf(&overlayOpts.authKey),
79 | },
80 | {
81 | Flag: "stun-ip-override",
82 | Default: "",
83 | Value: serpent.StringOf(&overlayOpts.stunAddrOverride),
84 | },
85 | {
86 | Flag: "wait-p2p",
87 | Description: "Waits for the connection to be p2p.",
88 | Default: "false",
89 | Value: serpent.BoolOf(&overlayOpts.waitP2P),
90 | },
91 | {
92 | Flag: "verbose",
93 | FlagShorthand: "v",
94 | Description: "Enable verbose logging.",
95 | Default: "false",
96 | Value: serpent.BoolOf(&verbose),
97 | },
98 | },
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/site/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ));
17 | Table.displayName = "Table";
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ));
25 | TableHeader.displayName = "TableHeader";
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ));
37 | TableBody.displayName = "TableBody";
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ));
52 | TableFooter.displayName = "TableFooter";
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ));
67 | TableRow.displayName = "TableRow";
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 | [role=checkbox]]:translate-y-[2px]",
77 | className
78 | )}
79 | {...props}
80 | />
81 | ));
82 | TableHead.displayName = "TableHead";
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ));
97 | TableCell.displayName = "TableCell";
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | TableCaption.displayName = "TableCaption";
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | };
121 |
--------------------------------------------------------------------------------
/site/pages/receive.tsx:
--------------------------------------------------------------------------------
1 | import { Copy } from "lucide-react";
2 | import { useWasm } from "@/context/wush";
3 | import { toast } from "sonner";
4 | import { Progress } from "@/components/ui/progress";
5 |
6 | export default function Component() {
7 | const wasm = useWasm();
8 |
9 | const handleCopyAuthKey = () => {
10 | navigator.clipboard.writeText(
11 | `https://wush.dev#${wasm.wush.current?.auth_info().auth_key || ""}`
12 | );
13 | toast.info("Successfully copied auth key", {
14 | duration: 1500,
15 | });
16 | };
17 |
18 | return (
19 |
20 |
21 | Share this authentication key to receive a file. Use{" "}
22 |
23 |
24 | wush cp {""}
25 |
26 | {" "}
27 | or{" "}
28 |
29 |
30 | https://wush.dev/send
31 |
32 | {" "}
33 | to send a file to this machine.
34 |
35 |
36 |
37 |
43 |
50 |
51 |
52 | {wasm.incomingFiles.map((file) => (
53 |
54 |
55 | {file.filename}
56 |
57 |
58 | {formatSpeed(file.bytesPerSecond)} •{" "}
59 | {formatETA(
60 | (file.sizeBytes - (file.sizeBytes * file.progress) / 100) /
61 | file.bytesPerSecond
62 | )}
63 |
64 |
71 |
72 |
73 |
77 |
78 | ))}
79 |
80 | );
81 | }
82 |
83 | const formatSpeed = (bytesPerSecond: number): string => {
84 | let speed = bytesPerSecond;
85 | let unit = "B/s";
86 |
87 | if (speed > 1024) {
88 | speed /= 1024;
89 | unit = "KB/s";
90 | }
91 | if (speed > 1024) {
92 | speed /= 1024;
93 | unit = "MB/s";
94 | }
95 |
96 | return `${speed.toFixed(2)} ${unit}`;
97 | };
98 |
99 | const formatETA = (seconds: number): string => {
100 | if (seconds === Number.POSITIVE_INFINITY || Number.isNaN(seconds))
101 | return "calculating...";
102 | if (seconds < 60) return `${Math.round(seconds)}s`;
103 | const minutes = Math.floor(seconds / 60);
104 | const remainingSeconds = Math.round(seconds % 60);
105 | return `${minutes}m ${remainingSeconds}s`;
106 | };
107 |
--------------------------------------------------------------------------------
/cmd/wush/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "strconv"
7 | "strings"
8 | "time"
9 |
10 | "github.com/coder/pretty"
11 | "github.com/coder/serpent"
12 | "github.com/coder/wush/cliui"
13 | "github.com/mitchellh/go-wordwrap"
14 | )
15 |
16 | func main() {
17 | var (
18 | showVersion bool
19 |
20 | fmtLong = "wush %s - WireGuard-powered peer-to-peer file transfer and shell\n"
21 | )
22 | cmd := &serpent.Command{
23 | Use: "wush ",
24 | Long: fmt.Sprintf(fmtLong, getBuildInfo().version) + formatExamples(
25 | example{
26 | Description: "Start the wush server to accept incoming connections",
27 | Command: "wush serve",
28 | },
29 | example{
30 | Description: "Open a shell to a wush server",
31 | Command: "wush ssh",
32 | },
33 | example{
34 | Description: "Transfer files to a wush server with rsync",
35 | Command: "wush rsync local-file.txt :/path/to/remote/file",
36 | },
37 | example{
38 | Description: "Copy a single file to a wush server",
39 | Command: "wush cp local-file.txt",
40 | },
41 | ),
42 | Handler: func(i *serpent.Invocation) error {
43 | if showVersion {
44 | return versionCmd().Handler(i)
45 | }
46 | return serpent.DefaultHelpFn()(i)
47 | },
48 | Children: []*serpent.Command{
49 | versionCmd(),
50 | sshCmd(),
51 | serveCmd(),
52 | rsyncCmd(),
53 | cpCmd(),
54 | portForwardCmd(),
55 | },
56 | Options: []serpent.Option{
57 | {
58 | Flag: "version",
59 | Description: "Print the version and exit.",
60 | Value: serpent.BoolOf(&showVersion),
61 | },
62 | },
63 | }
64 |
65 | err := cmd.Invoke().WithOS().Run()
66 | if err != nil {
67 | fmt.Fprintf(os.Stderr, "error: %v\n", err)
68 | os.Exit(1)
69 | }
70 | }
71 |
72 | // example represents a standard example for command usage, to be used
73 | // with formatExamples.
74 | type example struct {
75 | Description string
76 | Command string
77 | }
78 |
79 | // formatExamples formats the examples as width wrapped bulletpoint
80 | // descriptions with the command underneath.
81 | func formatExamples(examples ...example) string {
82 | var sb strings.Builder
83 |
84 | padStyle := cliui.DefaultStyles.Wrap.With(pretty.XPad(4, 0))
85 | for i, e := range examples {
86 | if len(e.Description) > 0 {
87 | wordwrap.WrapString(e.Description, 80)
88 | _, _ = sb.WriteString(
89 | " - " + pretty.Sprint(padStyle, e.Description+":")[4:] + "\n\n ",
90 | )
91 | }
92 | // We add 1 space here because `cliui.DefaultStyles.Code` adds an extra
93 | // space. This makes the code block align at an even 2 or 6
94 | // spaces for symmetry.
95 | _, _ = sb.WriteString(" " + pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("$ %s", e.Command)))
96 | if i < len(examples)-1 {
97 | _, _ = sb.WriteString("\n\n")
98 | }
99 | }
100 | return sb.String()
101 | }
102 |
103 | var (
104 | version string
105 | commit string
106 | commitDate string
107 | )
108 |
109 | type buildInfo struct {
110 | version string
111 | commitHash string
112 | commitTime time.Time
113 | }
114 |
115 | func getBuildInfo() buildInfo {
116 | bi := buildInfo{
117 | version: "v0.0.0-devel",
118 | commitHash: "0000000000000000000000000000000000000000",
119 | commitTime: time.Now(),
120 | }
121 |
122 | if version != "" {
123 | bi.version = version
124 | }
125 | if commit != "" {
126 | bi.commitHash = commit
127 | }
128 | if commitDate != "" {
129 | dateUnix, err := strconv.ParseInt(commitDate, 10, 64)
130 | if err != nil {
131 | panic("invalid commitDate: " + err.Error())
132 | }
133 | bi.commitTime = time.Unix(dateUnix, 0)
134 | }
135 |
136 | return bi
137 | }
138 |
--------------------------------------------------------------------------------
/cliui/cliui.go:
--------------------------------------------------------------------------------
1 | package cliui
2 |
3 | import (
4 | "flag"
5 | "os"
6 | "sync"
7 | "time"
8 |
9 | "github.com/muesli/termenv"
10 | "golang.org/x/xerrors"
11 |
12 | "github.com/coder/pretty"
13 | )
14 |
15 | var Canceled = xerrors.New("canceled")
16 |
17 | // DefaultStyles compose visual elements of the UI.
18 | var DefaultStyles Styles
19 |
20 | type Styles struct {
21 | Code,
22 | DateTimeStamp,
23 | Error,
24 | Field,
25 | Keyword,
26 | Placeholder,
27 | Prompt,
28 | FocusedPrompt,
29 | Fuchsia,
30 | Warn,
31 | Wrap,
32 | Disabled,
33 | Enabled pretty.Style
34 | }
35 |
36 | var (
37 | color termenv.Profile
38 | colorOnce sync.Once
39 | )
40 |
41 | var (
42 | Green = Color("#04B575")
43 | Red = Color("#ED567A")
44 | Fuchsia = Color("#EE6FF8")
45 | Yellow = Color("#ECFD65")
46 | Blue = Color("#5000ff")
47 | )
48 |
49 | // Color returns a color for the given string.
50 | func Color(s string) termenv.Color {
51 | colorOnce.Do(func() {
52 | color = termenv.NewOutput(os.Stdout).ColorProfile()
53 | if _, exists := os.LookupEnv("NO_COLOR"); exists || flag.Lookup("test.v") != nil {
54 | // Use a consistent colorless profile in tests so that results
55 | // are deterministic.
56 | color = termenv.Ascii
57 | }
58 | })
59 | return color.Color(s)
60 | }
61 |
62 | func isTerm() bool {
63 | return color != termenv.Ascii
64 | }
65 |
66 | // Bold returns a formatter that renders text in bold
67 | // if the terminal supports it.
68 | func Bold(s string) string {
69 | if !isTerm() {
70 | return s
71 | }
72 | return pretty.Sprint(pretty.Bold(), s)
73 | }
74 |
75 | // BoldFmt returns a formatter that renders text in bold
76 | // if the terminal supports it.
77 | func BoldFmt() pretty.Formatter {
78 | if !isTerm() {
79 | return pretty.Style{}
80 | }
81 | return pretty.Bold()
82 | }
83 |
84 | // Timestamp formats a timestamp for display.
85 | func Timestamp(t time.Time) string {
86 | return pretty.Sprint(DefaultStyles.DateTimeStamp, t.Format(time.TimeOnly))
87 | }
88 |
89 | // Keyword formats a keyword for display.
90 | func Keyword(s string) string {
91 | return pretty.Sprint(DefaultStyles.Keyword, s)
92 | }
93 |
94 | // Placeholder formats a placeholder for display.
95 | func Placeholder(s string) string {
96 | return pretty.Sprint(DefaultStyles.Placeholder, s)
97 | }
98 |
99 | // Wrap prevents the text from overflowing the terminal.
100 | func Wrap(s string) string {
101 | return pretty.Sprint(DefaultStyles.Wrap, s)
102 | }
103 |
104 | // Code formats code for display.
105 | func Code(s string) string {
106 | return pretty.Sprint(DefaultStyles.Code, s)
107 | }
108 |
109 | // Field formats a field for display.
110 | func Field(s string) string {
111 | return pretty.Sprint(DefaultStyles.Field, s)
112 | }
113 |
114 | func ifTerm(fmt pretty.Formatter) pretty.Formatter {
115 | if !isTerm() {
116 | return pretty.Nop
117 | }
118 | return fmt
119 | }
120 |
121 | func init() {
122 | // We do not adapt the color based on whether the terminal is light or dark.
123 | // Doing so would require a round-trip between the program and the terminal
124 | // due to the OSC query and response.
125 | DefaultStyles = Styles{
126 | Code: pretty.Style{
127 | ifTerm(pretty.XPad(1, 1)),
128 | pretty.FgColor(Red),
129 | pretty.BgColor(color.Color("#2c2c2c")),
130 | },
131 | DateTimeStamp: pretty.Style{
132 | pretty.FgColor(color.Color("#7571F9")),
133 | },
134 | Error: pretty.Style{
135 | pretty.FgColor(Red),
136 | },
137 | Field: pretty.Style{
138 | pretty.XPad(1, 1),
139 | pretty.FgColor(color.Color("#FFFFFF")),
140 | pretty.BgColor(color.Color("#2b2a2a")),
141 | },
142 | Keyword: pretty.Style{
143 | pretty.FgColor(Green),
144 | },
145 | Placeholder: pretty.Style{
146 | pretty.FgColor(color.Color("#4d46b3")),
147 | },
148 | Prompt: pretty.Style{
149 | pretty.FgColor(color.Color("#5C5C5C")),
150 | pretty.Wrap("> ", ""),
151 | },
152 | Warn: pretty.Style{
153 | pretty.FgColor(Yellow),
154 | },
155 | Wrap: pretty.Style{
156 | pretty.LineWrap(80),
157 | },
158 | Disabled: pretty.Style{
159 | pretty.FgColor(Red),
160 | },
161 | Enabled: pretty.Style{
162 | pretty.FgColor(Green),
163 | },
164 | }
165 |
166 | DefaultStyles.FocusedPrompt = append(
167 | DefaultStyles.Prompt,
168 | pretty.FgColor(Blue),
169 | )
170 | }
171 |
172 | // ValidateNotEmpty is a helper function to disallow empty inputs!
173 | func ValidateNotEmpty(s string) error {
174 | if s == "" {
175 | return xerrors.New("Must be provided!")
176 | }
177 | return nil
178 | }
179 |
--------------------------------------------------------------------------------
/overlay/auth.go:
--------------------------------------------------------------------------------
1 | package overlay
2 |
3 | import (
4 | "bytes"
5 | "encoding/binary"
6 | "errors"
7 | "fmt"
8 | "net/netip"
9 |
10 | "github.com/btcsuite/btcd/btcutil/base58"
11 | "github.com/coder/wush/cliui"
12 | "go4.org/mem"
13 | "tailscale.com/tailcfg"
14 | "tailscale.com/types/key"
15 | )
16 |
17 | type ClientAuth struct {
18 | Web bool
19 | // OverlayPrivateKey is the main auth mechanism used to secure the overlay.
20 | // Peers are sent this private key to encrypt node communication to the
21 | // receiver. Leaking this private key would allow anyone to connect.
22 | OverlayPrivateKey key.NodePrivate
23 | // ReceiverPublicKey is the public key of the receiver. Node messages are
24 | // encrypted to this public key.
25 | ReceiverPublicKey key.NodePublic
26 | // ReceiverStunAddr is the address that the receiver is reachable over UDP
27 | // when the overlay is running in P2P mode.
28 | ReceiverStunAddr netip.AddrPort
29 | // ReceiverDERPRegionID is the region id that the receiver is reachable over
30 | // DERP when the overlay is running in DERP mode.
31 | ReceiverDERPRegionID uint16
32 | }
33 |
34 | func (ca *ClientAuth) PrintDebug(logf func(str string, args ...any), dm *tailcfg.DERPMap) {
35 | logf("Auth information:")
36 | stunStr := ca.ReceiverStunAddr.String()
37 | if !ca.ReceiverStunAddr.IsValid() {
38 | stunStr = "Disabled"
39 | }
40 | logf("\t> Server overlay STUN address: %s", cliui.Code(stunStr))
41 | derpStr := "Disabled"
42 | if ca.ReceiverDERPRegionID > 0 {
43 | derpStr = dm.Regions[int(ca.ReceiverDERPRegionID)].RegionName
44 | }
45 | logf("\t> Server overlay DERP home: %s", cliui.Code(derpStr))
46 | logf("\t> Server overlay public key: %s", cliui.Code(ca.ReceiverPublicKey.ShortString()))
47 | logf("\t> Server overlay auth key: %s", cliui.Code(ca.OverlayPrivateKey.Public().ShortString()))
48 | }
49 |
50 | func (ca *ClientAuth) AuthKey() string {
51 | buf := bytes.NewBuffer(nil)
52 |
53 | buf.WriteByte(1)
54 |
55 | if ca.Web {
56 | buf.WriteByte(1)
57 | } else {
58 | buf.WriteByte(0)
59 | }
60 |
61 | buf.WriteByte(byte(ca.ReceiverStunAddr.Addr().BitLen() / 8))
62 | if ca.ReceiverStunAddr.Addr().BitLen() > 0 {
63 | stunBytes, err := ca.ReceiverStunAddr.MarshalBinary()
64 | if err != nil {
65 | panic(fmt.Sprint("failed to marshal stun addr:", err))
66 | }
67 | buf.Write(stunBytes)
68 | }
69 |
70 | derpBuf := [2]byte{}
71 | binary.BigEndian.PutUint16(derpBuf[:], ca.ReceiverDERPRegionID)
72 | buf.Write(derpBuf[:])
73 |
74 | pub := ca.ReceiverPublicKey.Raw32()
75 | buf.Write(pub[:])
76 |
77 | priv := ca.OverlayPrivateKey.Raw32()
78 | buf.Write(priv[:])
79 |
80 | return base58.Encode(buf.Bytes())
81 | }
82 |
83 | func (ca *ClientAuth) Parse(authKey string) error {
84 | if len(authKey) == 0 {
85 | return errors.New("auth key should not be empty")
86 | }
87 |
88 | decr := bytes.NewReader(base58.Decode(authKey))
89 |
90 | ver, err := decr.ReadByte()
91 | if err != nil {
92 | return errors.New("read authkey version")
93 | }
94 |
95 | if ver != 1 {
96 | return fmt.Errorf("unsupported authkey version %q", ver)
97 | }
98 |
99 | typ, err := decr.ReadByte()
100 | if err != nil {
101 | return errors.New("read authkey peer type")
102 | }
103 |
104 | if typ == 1 {
105 | ca.Web = true
106 | }
107 |
108 | ipLenB, err := decr.ReadByte()
109 | if err != nil {
110 | return errors.New("read STUN ip len; invalid authkey")
111 | }
112 |
113 | ipLen := int(ipLenB)
114 | if ipLen > 0 {
115 | stunIPBytes := make([]byte, ipLen+2)
116 | n, err := decr.Read(stunIPBytes)
117 | if n != len(stunIPBytes) || err != nil {
118 | return errors.New("read STUN ip; invalid authkey")
119 | }
120 |
121 | err = ca.ReceiverStunAddr.UnmarshalBinary(stunIPBytes)
122 | if err != nil {
123 | return fmt.Errorf("unmarshal receiver stun address: %w", err)
124 | }
125 | }
126 |
127 | derpRegionBytes := make([]byte, 2)
128 | n, err := decr.Read(derpRegionBytes)
129 | if n != len(derpRegionBytes) || err != nil {
130 | return errors.New("read derp region; invalid authkey")
131 | }
132 | ca.ReceiverDERPRegionID = binary.BigEndian.Uint16(derpRegionBytes)
133 |
134 | pubKeyBytes := make([]byte, 32)
135 | n, err = decr.Read(pubKeyBytes)
136 | if n != len(pubKeyBytes) || err != nil {
137 | return errors.New("read receiver pubkey; invalid authkey")
138 | }
139 | ca.ReceiverPublicKey = key.NodePublicFromRaw32(mem.B(pubKeyBytes))
140 |
141 | privKeyBytes := make([]byte, 32)
142 | n, err = decr.Read(privKeyBytes)
143 | if n != len(privKeyBytes) || err != nil {
144 | return errors.New("read overlay privkey; invalid authkey")
145 | }
146 | ca.OverlayPrivateKey = key.NodePrivateFromRaw32(mem.B(privKeyBytes))
147 | return nil
148 | }
149 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | set -eu
4 |
5 | GITHUB_REPO="coder/wush"
6 | BINARY_NAME="wush"
7 | INSTALL_DIR="/usr/local/bin"
8 |
9 | # Function to determine the platform
10 | detect_platform() {
11 | OS=$(uname -s)
12 | ARCH=$(uname -m)
13 |
14 | case $OS in
15 | Linux)
16 | PLATFORM="linux"
17 | ;;
18 | Darwin)
19 | PLATFORM="darwin"
20 | ;;
21 | FreeBSD)
22 | PLATFORM="freebsd"
23 | ;;
24 | CYGWIN*|MINGW*|MSYS*)
25 | PLATFORM="windows"
26 | ;;
27 | *)
28 | echo "Unsupported OS: $OS"
29 | exit 1
30 | ;;
31 | esac
32 |
33 | case $ARCH in
34 | x86_64|amd64)
35 | ARCH="amd64"
36 | ;;
37 | i386|i686)
38 | ARCH="386"
39 | ;;
40 | armv7l|armv6l)
41 | ARCH="armv7"
42 | ;;
43 | aarch64|arm64)
44 | ARCH="arm64"
45 | ;;
46 | *)
47 | echo "Unsupported architecture: $ARCH"
48 | exit 1
49 | ;;
50 | esac
51 |
52 | echo "${PLATFORM}_${ARCH}"
53 | }
54 |
55 | # Function to determine the preferred archive format
56 | select_archive_format() {
57 | PLATFORM_ARCH=$1
58 |
59 | case "$PLATFORM_ARCH" in
60 | darwin_*)
61 | # Check if Homebrew is installed
62 | if command -v brew >/dev/null 2>&1; then
63 | >&2 echo "Using Homebrew for installation."
64 | brew install "$BINARY_NAME" # Install using Homebrew
65 | exit 0 # Exit after installation
66 | else
67 | echo "zip" # We only ship .zip archives for macOS
68 | fi
69 | ;;
70 | windows_*)
71 | echo "zip" # We only ship .zip archives for Windows
72 | ;;
73 | *)
74 | if command -v tar >/dev/null 2>&1; then
75 | echo "tar.gz"
76 | elif command -v unzip >/dev/null 2>&1; then
77 | echo "zip"
78 | else
79 | echo "Unsupported: neither tar nor unzip are available."
80 | exit 1
81 | fi
82 | ;;
83 | esac
84 | }
85 |
86 | main() {
87 | PLATFORM_ARCH=$(detect_platform)
88 |
89 | # Determine preferred archive format
90 | FILE_EXT=$(select_archive_format "$PLATFORM_ARCH")
91 |
92 | # Get the latest release download URL from GitHub API
93 | LATEST_RELEASE_URL=$(curl -fsSL \
94 | "https://api.github.com/repos/$GITHUB_REPO/releases/latest" \
95 | | grep "browser_download_url" \
96 | | grep "$PLATFORM_ARCH.$FILE_EXT" \
97 | | cut -d '"' -f 4 | head -n 1)
98 |
99 | if [ -z "$LATEST_RELEASE_URL" ]; then
100 | echo "No release found for platform $PLATFORM_ARCH with format $FILE_EXT"
101 | exit 1
102 | fi
103 |
104 | # Download the release archive
105 | TMP_DIR=$(mktemp -d)
106 | ARCHIVE_PATH="$TMP_DIR/$BINARY_NAME.$FILE_EXT"
107 |
108 | echo "Downloading $BINARY_NAME from $LATEST_RELEASE_URL..."
109 | curl -L -o "$ARCHIVE_PATH" "$LATEST_RELEASE_URL"
110 |
111 |
112 | # Extract the archive
113 | echo "Extracting $BINARY_NAME..."
114 | if [ "$FILE_EXT" = "zip" ]; then
115 | unzip -d "$TMP_DIR" "$ARCHIVE_PATH"
116 | elif [ "$FILE_EXT" = "tar.gz" ]; then
117 | tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR"
118 | else
119 | echo "Unsupported file extension: $FILE_EXT"
120 | exit 1
121 | fi
122 |
123 | # Find the binary (assuming it's in the extracted files)
124 | BINARY_PATH=$(find "$TMP_DIR" -type f -name "$BINARY_NAME")
125 |
126 | # Make the binary executable
127 | chmod +x "$BINARY_PATH"
128 |
129 | # Install the binary
130 | if [ "$PLATFORM_ARCH" = "windows-amd64" ] || [ "$PLATFORM_ARCH" = "windows-386" ]; then
131 | INSTALL_DIR="$HOME/bin"
132 | mkdir -p "$INSTALL_DIR"
133 | mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME.exe"
134 | else
135 | # Run using sudo if not root
136 | if [ "$(id -u)" -ne 0 ]; then
137 | sudo sh </dev/null 2>&1; then
140 | setcap cap_net_admin=eip "$BINARY_PATH"
141 | else
142 | echo "Warning: 'setcap' command is not available. Transfer speeds may be slower."
143 | fi
144 | fi
145 | mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME"
146 | EOF
147 | else
148 | if [ "$(uname -s)" = "Linux" ]; then
149 | if command -v setcap >/dev/null 2>&1; then
150 | setcap cap_net_admin=eip "$BINARY_PATH"
151 | else
152 | echo "Warning: 'setcap' command is not available. Transfer speeds may be slower."
153 | fi
154 | fi
155 | mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME"
156 | fi
157 | fi
158 |
159 | # Clean up
160 | rm -rf "$TMP_DIR"
161 |
162 | echo "$BINARY_NAME installed successfully!"
163 | }
164 |
165 | # Run the installation
166 | main
167 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # wush
2 |
3 | [](https://pkg.go.dev/github.com/coder/wush)
4 |
5 | `wush` is a command line tool that lets you easily transfer files and open
6 | shells over a peer-to-peer WireGuard connection. It's similar to
7 | [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole) but:
8 |
9 | 1. No requirement to set up or trust a relay server for authentication.
10 | 1. Powered by WireGuard for secure, fast, and reliable connections.
11 | 1. Automatic peer-to-peer connections over UDP.
12 | 1. Endless possibilities; rsync, ssh, etc.
13 |
14 | ## Basic Usage
15 |
16 | On the host machine:
17 |
18 | ```bash
19 | $ wush serve
20 | Picked DERP region Toronto as overlay home
21 | Your auth key is:
22 | > 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx
23 | Use this key to authenticate other wush commands to this instance.
24 | ```
25 |
26 | On the client machine:
27 |
28 | ```bash
29 | # Copy a file to the host
30 | $ wush cp 1gb.txt
31 | Uploading "1gb.txt" 100% |██████████████████████████████████████████████| (2.1/2.1 GB, 376 MB/s)
32 |
33 | # Open a shell to the host
34 | $ wush ssh
35 | ┃ Enter the Auth key:
36 | ┃ > 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx
37 | coder@colin:~$
38 | ```
39 |
40 | [](https://asciinema.org/a/ZrCNiRRkeHUi5Lj3fqC3ovLqi)
41 |
42 | > [!NOTE]
43 | > `wush` uses Tailscale's [tsnet](https://tailscale.com/kb/1244/tsnet) package
44 | > under the hood, managed by an in-memory control server on each CLI. We utilize
45 | > Tailscale's public [DERP relays](https://tailscale.com/kb/1232/derp-servers),
46 | > but no Tailscale account is required.
47 |
48 | ## Install
49 |
50 | Using install script
51 |
52 | ```bash
53 | curl -fsSL https://github.com/coder/wush/raw/refs/heads/main/install.sh | sh
54 | ```
55 |
56 | Using Homebrew
57 |
58 | ```bash
59 | brew install wush
60 | ```
61 |
62 | For a manual installation, see the [latest release](https://github.com/coder/wush/releases/latest).
63 |
64 | > [!TIP]
65 | > To increase transfer speeds, `wush` attempts to increase the buffer size of
66 | > its UDP sockets. For best performance, ensure `wush` has `CAP_NET_ADMIN`. When
67 | > using the installer script, this is done automatically for you.
68 | >
69 | > ```bash
70 | > # Linux only
71 | > sudo setcap cap_net_admin=eip $(which wush)
72 | > ```
73 |
74 | ## Technical Details
75 |
76 | `wush` doesn't require you to trust any 3rd party authentication or relay
77 | servers, instead using x25519 keys to authenticate incoming connections. Auth
78 | keys generated by `wush serve` are separated into a couple parts:
79 |
80 | ```text
81 | 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx
82 |
83 | +---------------------+------------------+---------------------------+----------------------------+
84 | | UDP Address (1-19B) | DERP Region (2B) | Server Public Key (32B) | Sender Private Key (32B) |
85 | +---------------------+------------------+---------------------------+----------------------------+
86 | | 203.128.89.74:57321 | 21 | QPGoX1GY......488YNqsyWM= | o/FXVnOn.....llrKg5bqxlgY= |
87 | +---------------------+------------------+---------------------------+----------------------------+
88 | ```
89 |
90 | Senders and receivers communicate over what we call an "overlay". An overlay
91 | runs over one of two currently implemented mediums; UDP or DERP. Each message
92 | over the relay is encrypted with the sender's private key.
93 |
94 | **UDP**: The receiver creates a NAT holepunch to allow senders to connect
95 | directly. WireGuard nodes are exchanged peer-to-peer. This mode will only work
96 | if the receiver doesn't have hard NAT.
97 |
98 | **DERP**: The receiver connects to the closet DERP relay server. WireGuard nodes
99 | are exchanged through the relay.
100 |
101 | In both cases auth is handled the same way. The receiver will only accept
102 | messages encrypted from the sender's private key, to the server's public key.
103 |
104 | ## Why create another file transfer tool?
105 |
106 | Lots of great file tranfer tools exist, but they all have some limitations:
107 |
108 | 1. Slow speeds due to relay servers.
109 | 1. Trusting a 3rd party server for authentication.
110 | 1. Limited to only file transfers.
111 |
112 | We sought to utilize advancements in userspace networking brought about by
113 | Tailscale to create a tool that could solve all of these problems, and provide
114 | way more functionality.
115 |
116 | ## Acknowledgements
117 |
118 | 1. [Tailscale](https://tailscale.com)
119 | 1. [Headscale](https://github.com/juanfont/headscale)
120 | 1. [WireGuard-go](https://github.com/WireGuard/wireguard-go)
121 |
--------------------------------------------------------------------------------
/site/pages/terminal.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useCallback } from "react";
2 | import type { ReactElement } from "react";
3 | import type { NextPageWithLayout } from "@/pages/_app";
4 | import { useWasm } from "@/context/wush";
5 | import { useRouter } from "next/router";
6 | import { LogOut } from "lucide-react";
7 | import "@xterm/xterm/css/xterm.css";
8 |
9 | const TerminalPage: NextPageWithLayout = () => {
10 | const wasm = useWasm();
11 | const router = useRouter();
12 | const terminalRef = useRef(null);
13 | const terminalInstance = useRef(null);
14 | const fitAddonRef = useRef(null);
15 | const sshSessionRef = useRef(null);
16 | const isInitializing = useRef(false);
17 |
18 | const handleDisconnect = useCallback(() => {
19 | if (sshSessionRef.current) {
20 | sshSessionRef.current.close();
21 | }
22 | router.push(`/access${window.location.hash}`);
23 | }, [router]);
24 |
25 | useEffect(() => {
26 | if (isInitializing.current) return;
27 | isInitializing.current = true;
28 |
29 | const initializeTerminal = async () => {
30 | // Dynamically import xterm.js and its addons
31 | const { Terminal } = await import("@xterm/xterm");
32 | const { FitAddon } = await import("@xterm/addon-fit");
33 | const { CanvasAddon } = await import("@xterm/addon-canvas");
34 |
35 | if (!terminalRef.current) {
36 | console.log("Terminal ref is null, skipping terminal initialization");
37 | return;
38 | }
39 |
40 | console.log("Initializing terminal");
41 |
42 | const term = new Terminal({
43 | cursorBlink: true,
44 | theme: {
45 | background: "#282a36",
46 | foreground: "#f8f8f2",
47 | },
48 | scrollback: 0,
49 | });
50 | const fitAddon = new FitAddon();
51 | fitAddonRef.current = fitAddon;
52 | term.loadAddon(fitAddon);
53 | term.loadAddon(new CanvasAddon());
54 | term.open(terminalRef.current);
55 | fitAddon.fit();
56 |
57 | let onDataHook: ((data: string) => void) | undefined;
58 | term.onData((e) => {
59 | onDataHook?.(e);
60 | });
61 |
62 | const resizeObserver = new ResizeObserver(() => fitAddon.fit());
63 | resizeObserver.observe(terminalRef.current);
64 |
65 | if (wasm.wush.current && wasm.connectedPeer) {
66 | const sshSession = wasm.wush.current.ssh(wasm.connectedPeer, {
67 | writeFn(input) {
68 | term.write(input);
69 | },
70 | writeErrorFn(err) {
71 | term.write(err);
72 | },
73 | setReadFn(hook) {
74 | onDataHook = hook;
75 | },
76 | rows: term.rows,
77 | cols: term.cols,
78 | onConnectionProgress: (msg) => {},
79 | onConnected: () => {},
80 | onDone() {
81 | resizeObserver.disconnect();
82 | term.dispose();
83 | sshSession.close();
84 | sshSessionRef.current = null;
85 | router.push(`/access${window.location.hash}`);
86 | },
87 | });
88 | sshSessionRef.current = sshSession;
89 | term.onResize(({ rows, cols }) => sshSession.resize(rows, cols));
90 | }
91 |
92 | term.focus();
93 | terminalInstance.current = term;
94 | isInitializing.current = false;
95 | };
96 |
97 | initializeTerminal();
98 |
99 | return () => {
100 | console.log("Disposing terminal");
101 | if (terminalInstance.current) {
102 | terminalInstance.current.dispose();
103 | terminalInstance.current = null;
104 | }
105 | if (sshSessionRef.current) {
106 | sshSessionRef.current.close();
107 | sshSessionRef.current = null;
108 | }
109 | };
110 | }, [wasm.wush.current, wasm.connectedPeer, router]);
111 |
112 | return (
113 |
114 |
118 |
119 |
120 | {/* Left side items can go here */}
121 |
122 |
130 |
131 |
132 | );
133 | };
134 |
135 | TerminalPage.getLayout = function getLayout(page: ReactElement) {
136 | return page;
137 | };
138 |
139 | export default TerminalPage;
140 |
--------------------------------------------------------------------------------
/cmd/wush/ssh.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log/slog"
8 | "net/netip"
9 | "time"
10 |
11 | "tailscale.com/client/tailscale"
12 | "tailscale.com/net/netns"
13 | "tailscale.com/tailcfg"
14 |
15 | "github.com/coder/serpent"
16 | "github.com/coder/wush/cliui"
17 | "github.com/coder/wush/overlay"
18 | "github.com/coder/wush/tsserver"
19 | xssh "github.com/coder/wush/xssh"
20 | )
21 |
22 | func sshCmd() *serpent.Command {
23 | var (
24 | verbose bool
25 | quiet bool
26 | derpmapFi string
27 | logger = new(slog.Logger)
28 | logf = func(str string, args ...any) {}
29 |
30 | dm = new(tailcfg.DERPMap)
31 | overlayOpts = new(sendOverlayOpts)
32 | send = new(overlay.Send)
33 | )
34 | return &serpent.Command{
35 | Use: "ssh",
36 | Aliases: []string{},
37 | Short: "Open a SSH connection to a wush server.",
38 | Long: "Use " + cliui.Code("wush serve") + " on the computer you would like to connect to.",
39 | Middleware: serpent.Chain(
40 | initLogger(&verbose, &quiet, logger, &logf),
41 | initAuth(&overlayOpts.authKey, &overlayOpts.clientAuth),
42 | derpMap(&derpmapFi, dm),
43 | sendOverlayMW(overlayOpts, &send, logger, dm, &logf),
44 | ),
45 | Handler: func(inv *serpent.Invocation) error {
46 | ctx := inv.Context()
47 |
48 | s, err := tsserver.NewServer(ctx, logger, send, dm)
49 | if err != nil {
50 | return err
51 | }
52 |
53 | if send.Auth.ReceiverDERPRegionID != 0 {
54 | go send.ListenOverlayDERP(ctx)
55 | } else if send.Auth.ReceiverStunAddr.IsValid() {
56 | go send.ListenOverlaySTUN(ctx)
57 | } else {
58 | return errors.New("auth key provided neither DERP nor STUN")
59 | }
60 |
61 | go s.ListenAndServe(ctx)
62 | netns.SetDialerOverride(s.Dialer())
63 | ts, err := newTSNet("send", verbose)
64 | if err != nil {
65 | return err
66 | }
67 |
68 | logf("Bringing WireGuard up..")
69 | ts.Up(ctx)
70 | logf("WireGuard is ready!")
71 |
72 | lc, err := ts.LocalClient()
73 | if err != nil {
74 | return err
75 | }
76 |
77 | ip, err := waitUntilHasPeerHasIP(ctx, logf, lc)
78 | if err != nil {
79 | return err
80 | }
81 |
82 | if overlayOpts.waitP2P {
83 | err := waitUntilHasP2P(ctx, logf, lc)
84 | if err != nil {
85 | return err
86 | }
87 | }
88 |
89 | return xssh.TailnetSSH(ctx, inv, ts, netip.AddrPortFrom(ip, 3).String(), quiet)
90 | },
91 | Options: []serpent.Option{
92 | {
93 | Flag: "auth-key",
94 | Env: "WUSH_AUTH_KEY",
95 | Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.",
96 | Default: "",
97 | Value: serpent.StringOf(&overlayOpts.authKey),
98 | },
99 | {
100 | Flag: "derp-config-file",
101 | Description: "File which specifies the DERP config to use. In the structure of https://pkg.go.dev/tailscale.com@v1.74.1/tailcfg#DERPMap.",
102 | Default: "",
103 | Value: serpent.StringOf(&derpmapFi),
104 | },
105 | {
106 | Flag: "stun-ip-override",
107 | Default: "",
108 | Value: serpent.StringOf(&overlayOpts.stunAddrOverride),
109 | },
110 | {
111 | Flag: "quiet",
112 | Description: "Silences all output.",
113 | Default: "false",
114 | Value: serpent.BoolOf(&quiet),
115 | },
116 | {
117 | Flag: "wait-p2p",
118 | Description: "Waits for the connection to be p2p.",
119 | Default: "false",
120 | Value: serpent.BoolOf(&overlayOpts.waitP2P),
121 | },
122 | {
123 | Flag: "verbose",
124 | FlagShorthand: "v",
125 | Description: "Enable verbose logging.",
126 | Default: "false",
127 | Value: serpent.BoolOf(&verbose),
128 | },
129 | },
130 | }
131 | }
132 |
133 | func waitUntilHasPeerHasIP(ctx context.Context, logF func(str string, args ...any), lc *tailscale.LocalClient) (netip.Addr, error) {
134 | for {
135 | select {
136 | case <-ctx.Done():
137 | return netip.Addr{}, ctx.Err()
138 | case <-time.After(time.Second):
139 | }
140 |
141 | stat, err := lc.Status(ctx)
142 | if err != nil {
143 | fmt.Println("error getting lc status:", err)
144 | continue
145 | }
146 |
147 | peers := stat.Peers()
148 | if len(peers) == 0 {
149 | logF("No peer yet")
150 | continue
151 | }
152 |
153 | logF("Received peer")
154 |
155 | peer, ok := stat.Peer[peers[0]]
156 | if !ok {
157 | logF("have peers but not found in map (developer error)")
158 | continue
159 | }
160 |
161 | if peer.Relay == "" {
162 | logF("peer no relay")
163 | continue
164 | }
165 |
166 | logF("Peer active with relay %s", cliui.Code(peer.Relay))
167 |
168 | if len(peer.TailscaleIPs) == 0 {
169 | logF("peer has no ips (developer error)")
170 | continue
171 | }
172 |
173 | return peer.TailscaleIPs[0], nil
174 | }
175 | }
176 |
177 | func waitUntilHasP2P(ctx context.Context, logF func(str string, args ...any), lc *tailscale.LocalClient) error {
178 | for {
179 | select {
180 | case <-ctx.Done():
181 | return ctx.Err()
182 | case <-time.After(time.Second):
183 | }
184 |
185 | stat, err := lc.Status(ctx)
186 | if err != nil {
187 | logF("error getting lc status: %s", err)
188 | continue
189 | }
190 |
191 | peers := stat.Peers()
192 | peer, ok := stat.Peer[peers[0]]
193 | if !ok {
194 | logF("no peer found in map while waiting p2p (developer error)")
195 | continue
196 | }
197 |
198 | if peer.Relay == "" {
199 | logF("peer no relay")
200 | continue
201 | }
202 |
203 | if len(peer.TailscaleIPs) == 0 {
204 | logF("peer has no ips (developer error)")
205 | continue
206 | }
207 |
208 | pingCancel, cancel := context.WithTimeout(ctx, time.Second)
209 | pong, err := lc.Ping(pingCancel, peer.TailscaleIPs[0], tailcfg.PingDisco)
210 | cancel()
211 | if err != nil {
212 | logF("ping failed: %s", err)
213 | continue
214 | }
215 |
216 | if pong.Endpoint == "" {
217 | logF("Not p2p yet")
218 | continue
219 | }
220 |
221 | logF("Peer active over p2p %s", cliui.Code(pong.Endpoint))
222 | return nil
223 | }
224 | }
225 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Creative Commons Legal Code
2 |
3 | CC0 1.0 Universal
4 |
5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
12 | HEREUNDER.
13 |
14 | Statement of Purpose
15 |
16 | The laws of most jurisdictions throughout the world automatically confer
17 | exclusive Copyright and Related Rights (defined below) upon the creator
18 | and subsequent owner(s) (each and all, an "owner") of an original work of
19 | authorship and/or a database (each, a "Work").
20 |
21 | Certain owners wish to permanently relinquish those rights to a Work for
22 | the purpose of contributing to a commons of creative, cultural and
23 | scientific works ("Commons") that the public can reliably and without fear
24 | of later claims of infringement build upon, modify, incorporate in other
25 | works, reuse and redistribute as freely as possible in any form whatsoever
26 | and for any purposes, including without limitation commercial purposes.
27 | These owners may contribute to the Commons to promote the ideal of a free
28 | culture and the further production of creative, cultural and scientific
29 | works, or to gain reputation or greater distribution for their Work in
30 | part through the use and efforts of others.
31 |
32 | For these and/or other purposes and motivations, and without any
33 | expectation of additional consideration or compensation, the person
34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she
35 | is an owner of Copyright and Related Rights in the Work, voluntarily
36 | elects to apply CC0 to the Work and publicly distribute the Work under its
37 | terms, with knowledge of his or her Copyright and Related Rights in the
38 | Work and the meaning and intended legal effect of CC0 on those rights.
39 |
40 | 1. Copyright and Related Rights. A Work made available under CC0 may be
41 | protected by copyright and related or neighboring rights ("Copyright and
42 | Related Rights"). Copyright and Related Rights include, but are not
43 | limited to, the following:
44 |
45 | i. the right to reproduce, adapt, distribute, perform, display,
46 | communicate, and translate a Work;
47 | ii. moral rights retained by the original author(s) and/or performer(s);
48 | iii. publicity and privacy rights pertaining to a person's image or
49 | likeness depicted in a Work;
50 | iv. rights protecting against unfair competition in regards to a Work,
51 | subject to the limitations in paragraph 4(a), below;
52 | v. rights protecting the extraction, dissemination, use and reuse of data
53 | in a Work;
54 | vi. database rights (such as those arising under Directive 96/9/EC of the
55 | European Parliament and of the Council of 11 March 1996 on the legal
56 | protection of databases, and under any national implementation
57 | thereof, including any amended or successor version of such
58 | directive); and
59 | vii. other similar, equivalent or corresponding rights throughout the
60 | world based on applicable law or treaty, and any national
61 | implementations thereof.
62 |
63 | 2. Waiver. To the greatest extent permitted by, but not in contravention
64 | of, applicable law, Affirmer hereby overtly, fully, permanently,
65 | irrevocably and unconditionally waives, abandons, and surrenders all of
66 | Affirmer's Copyright and Related Rights and associated claims and causes
67 | of action, whether now known or unknown (including existing as well as
68 | future claims and causes of action), in the Work (i) in all territories
69 | worldwide, (ii) for the maximum duration provided by applicable law or
70 | treaty (including future time extensions), (iii) in any current or future
71 | medium and for any number of copies, and (iv) for any purpose whatsoever,
72 | including without limitation commercial, advertising or promotional
73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
74 | member of the public at large and to the detriment of Affirmer's heirs and
75 | successors, fully intending that such Waiver shall not be subject to
76 | revocation, rescission, cancellation, termination, or any other legal or
77 | equitable action to disrupt the quiet enjoyment of the Work by the public
78 | as contemplated by Affirmer's express Statement of Purpose.
79 |
80 | 3. Public License Fallback. Should any part of the Waiver for any reason
81 | be judged legally invalid or ineffective under applicable law, then the
82 | Waiver shall be preserved to the maximum extent permitted taking into
83 | account Affirmer's express Statement of Purpose. In addition, to the
84 | extent the Waiver is so judged Affirmer hereby grants to each affected
85 | person a royalty-free, non transferable, non sublicensable, non exclusive,
86 | irrevocable and unconditional license to exercise Affirmer's Copyright and
87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the
88 | maximum duration provided by applicable law or treaty (including future
89 | time extensions), (iii) in any current or future medium and for any number
90 | of copies, and (iv) for any purpose whatsoever, including without
91 | limitation commercial, advertising or promotional purposes (the
92 | "License"). The License shall be deemed effective as of the date CC0 was
93 | applied by Affirmer to the Work. Should any part of the License for any
94 | reason be judged legally invalid or ineffective under applicable law, such
95 | partial invalidity or ineffectiveness shall not invalidate the remainder
96 | of the License, and in such case Affirmer hereby affirms that he or she
97 | will not (i) exercise any of his or her remaining Copyright and Related
98 | Rights in the Work or (ii) assert any associated claims and causes of
99 | action with respect to the Work, in either case contrary to Affirmer's
100 | express Statement of Purpose.
101 |
102 | 4. Limitations and Disclaimers.
103 |
104 | a. No trademark or patent rights held by Affirmer are waived, abandoned,
105 | surrendered, licensed or otherwise affected by this document.
106 | b. Affirmer offers the Work as-is and makes no representations or
107 | warranties of any kind concerning the Work, express, implied,
108 | statutory or otherwise, including without limitation warranties of
109 | title, merchantability, fitness for a particular purpose, non
110 | infringement, or the absence of latent or other defects, accuracy, or
111 | the present or absence of errors, whether or not discoverable, all to
112 | the greatest extent permissible under applicable law.
113 | c. Affirmer disclaims responsibility for clearing rights of other persons
114 | that may apply to the Work or any use thereof, including without
115 | limitation any person's Copyright and Related Rights in the Work.
116 | Further, Affirmer disclaims responsibility for obtaining any necessary
117 | consents, permissions or other rights required for any use of the
118 | Work.
119 | d. Affirmer understands and acknowledges that Creative Commons is not a
120 | party to this document and has no duty or obligation with respect to
121 | this CC0 or use of the Work.
122 |
--------------------------------------------------------------------------------
/cmd/wush/serve.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "log/slog"
8 | "net"
9 | "net/http"
10 | "net/netip"
11 | "os"
12 | "strings"
13 | "sync"
14 |
15 | "github.com/mattn/go-isatty"
16 | "github.com/prometheus/client_golang/prometheus"
17 | "github.com/schollz/progressbar/v3"
18 | "github.com/spf13/afero"
19 | xslices "golang.org/x/exp/slices"
20 | "golang.org/x/xerrors"
21 | "tailscale.com/ipn/store"
22 | "tailscale.com/net/netns"
23 | "tailscale.com/tailcfg"
24 | "tailscale.com/tsnet"
25 |
26 | cslog "cdr.dev/slog"
27 | csloghuman "cdr.dev/slog/sloggers/sloghuman"
28 | "github.com/coder/coder/v2/agent/agentssh"
29 | "github.com/coder/pretty"
30 | "github.com/coder/serpent"
31 | "github.com/coder/wush/cliui"
32 | "github.com/coder/wush/overlay"
33 | "github.com/coder/wush/tsserver"
34 | )
35 |
36 | func serveCmd() *serpent.Command {
37 | var (
38 | overlayType string
39 | verbose bool
40 | enabled = []string{}
41 | disabled = []string{}
42 | derpmapFi string
43 |
44 | dm = new(tailcfg.DERPMap)
45 | )
46 | return &serpent.Command{
47 | Use: "serve",
48 | Short: "Run the wush server. Allow wush clients to connect.",
49 | Middleware: serpent.Chain(
50 | derpMap(&derpmapFi, dm),
51 | ),
52 | Handler: func(inv *serpent.Invocation) error {
53 | ctx := inv.Context()
54 | var logSink io.Writer = io.Discard
55 | if verbose {
56 | logSink = inv.Stderr
57 | }
58 | logger := slog.New(slog.NewTextHandler(logSink, nil))
59 | hlog := func(format string, args ...any) {
60 | fmt.Fprintf(inv.Stderr, format+"\n", args...)
61 | }
62 | r := overlay.NewReceiveOverlay(logger, hlog, dm)
63 |
64 | var err error
65 | switch overlayType {
66 | case "derp":
67 | err = r.PickDERPHome(ctx)
68 | if err != nil {
69 | return err
70 | }
71 | go r.ListenOverlayDERP(ctx)
72 |
73 | case "stun":
74 | waitStun, err := r.ListenOverlaySTUN(ctx)
75 | if err != nil {
76 | return fmt.Errorf("get stun addr: %w", err)
77 | }
78 | <-waitStun
79 |
80 | default:
81 | return fmt.Errorf("unknown overlay type: %s", overlayType)
82 | }
83 |
84 | // Ensure we always print the auth key on stdout
85 | if isatty.IsTerminal(os.Stdout.Fd()) {
86 | hlog("Your auth key is:")
87 | fmt.Println(" >", cliui.Code(r.ClientAuth().AuthKey()))
88 | hlog("Use this key to authenticate other " + cliui.Code("wush") + " commands to this instance.")
89 | hlog("Visit the following link to connect via the browser:")
90 | fmt.Println(" >", cliui.Code("https://wush.dev#"+r.ClientAuth().AuthKey()))
91 | } else {
92 | fmt.Println(cliui.Code(r.ClientAuth().AuthKey()))
93 | hlog("The auth key has been printed to stdout")
94 | }
95 |
96 | s, err := tsserver.NewServer(ctx, logger, r, dm)
97 | if err != nil {
98 | return err
99 | }
100 |
101 | go s.ListenAndServe(ctx)
102 | netns.SetDialerOverride(s.Dialer())
103 | ts, err := newTSNet("receive", verbose)
104 | if err != nil {
105 | return err
106 | }
107 |
108 | _, err = ts.Up(ctx)
109 | if err != nil {
110 | return fmt.Errorf("bring wireguard up: %w", err)
111 | }
112 | fs := afero.NewOsFs()
113 |
114 | // hlog("WireGuard is ready")
115 |
116 | closers := []io.Closer{}
117 |
118 | if xslices.Contains(enabled, "ssh") && !xslices.Contains(disabled, "ssh") {
119 | sshSrv, err := agentssh.NewServer(ctx,
120 | cslog.Make(csloghuman.Sink(logSink)),
121 | prometheus.NewRegistry(),
122 | fs,
123 | nil,
124 | )
125 | if err != nil {
126 | return err
127 | }
128 | closers = append(closers, sshSrv)
129 |
130 | sshListener, err := ts.Listen("tcp", ":3")
131 | if err != nil {
132 | return err
133 | }
134 | closers = append(closers, sshListener)
135 |
136 | // TODO: replace these logs with all of the options in the beginning.
137 | // hlog("SSH server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled"))
138 | go func() {
139 | err := sshSrv.Serve(sshListener)
140 | if err != nil {
141 | hlog("SSH server exited: " + err.Error())
142 | }
143 | }()
144 | } else {
145 | hlog("SSH server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled"))
146 | }
147 |
148 | if xslices.Contains(enabled, "cp") && !xslices.Contains(disabled, "cp") {
149 | cpListener, err := ts.Listen("tcp", ":4444")
150 | if err != nil {
151 | return err
152 | }
153 | closers = append([]io.Closer{cpListener}, closers...)
154 |
155 | // hlog("File transfer server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled"))
156 | go func() {
157 | err := http.Serve(cpListener, http.HandlerFunc(cpHandler))
158 | if err != nil {
159 | hlog("File transfer server exited: " + err.Error())
160 | }
161 | }()
162 | } else {
163 | hlog("File transfer server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled"))
164 | }
165 |
166 | if xslices.Contains(enabled, "port-forward") && !xslices.Contains(disabled, "port-forward") {
167 | ts.RegisterFallbackTCPHandler(func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
168 | return func(src net.Conn) {
169 | dst, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", dst.Port()))
170 | if err != nil {
171 | hlog(pretty.Sprint(cliui.DefaultStyles.Warn, "Failed to dial forwarded connection:", err.Error()))
172 | src.Close()
173 | return
174 | }
175 |
176 | bicopy(ctx, src, dst)
177 | }, true
178 | })
179 | // hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled"))
180 | } else {
181 | hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled"))
182 | }
183 |
184 | ctx, ctxCancel := inv.SignalNotifyContext(ctx, os.Interrupt)
185 | defer ctxCancel()
186 |
187 | closers = append(closers, ts)
188 | <-ctx.Done()
189 | for _, closer := range closers {
190 | closer.Close()
191 | }
192 | return nil
193 | },
194 | Options: []serpent.Option{
195 | {
196 | Flag: "overlay-type",
197 | Default: "derp",
198 | Value: serpent.EnumOf(&overlayType, "derp", "stun"),
199 | },
200 | {
201 | Flag: "verbose",
202 | FlagShorthand: "v",
203 | Description: "Enable verbose logging.",
204 | Default: "false",
205 | Value: serpent.BoolOf(&verbose),
206 | },
207 | {
208 | Flag: "enable",
209 | Description: "Server options to enable.",
210 | Default: "ssh,cp,port-forward",
211 | Value: serpent.EnumArrayOf(&enabled, "ssh", "cp", "port-forward"),
212 | },
213 | {
214 | Flag: "disable",
215 | Description: "Server options to disable.",
216 | Default: "",
217 | Value: serpent.EnumArrayOf(&disabled, "ssh", "cp", "port-forward"),
218 | },
219 | {
220 | Flag: "derp-config-file",
221 | Description: "File which specifies the DERP config to use. In the structure of https://pkg.go.dev/tailscale.com@v1.74.1/tailcfg#DERPMap.",
222 | Default: "",
223 | Value: serpent.StringOf(&derpmapFi),
224 | },
225 | },
226 | }
227 | }
228 |
229 | func newTSNet(direction string, verbose bool) (*tsnet.Server, error) {
230 | var err error
231 | tmp := os.TempDir()
232 | srv := new(tsnet.Server)
233 | srv.Dir = tmp
234 | srv.Hostname = "wush-" + direction
235 | srv.Ephemeral = true
236 | srv.AuthKey = direction
237 | srv.ControlURL = "http://127.0.0.1:8080"
238 | srv.Logf = func(format string, args ...any) {}
239 | srv.UserLogf = func(format string, args ...any) {}
240 | if verbose {
241 | logf := func(format string, args ...any) {
242 | fmt.Fprintf(os.Stderr, format+"\n", args...)
243 | }
244 | srv.Logf = logf
245 | srv.UserLogf = logf
246 | }
247 |
248 | srv.Store, err = store.New(func(format string, args ...any) {}, "mem:wush")
249 | if err != nil {
250 | return nil, xerrors.Errorf("create state store: %w", err)
251 | }
252 |
253 | return srv, nil
254 | }
255 |
256 | func bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) {
257 | ctx, cancel := context.WithCancel(ctx)
258 | defer cancel()
259 |
260 | defer func() {
261 | _ = c1.Close()
262 | _ = c2.Close()
263 | }()
264 |
265 | var wg sync.WaitGroup
266 | copyFunc := func(dst io.WriteCloser, src io.Reader) {
267 | defer func() {
268 | wg.Done()
269 | // If one side of the copy fails, ensure the other one exits as
270 | // well.
271 | cancel()
272 | }()
273 | _, _ = io.Copy(dst, src)
274 | }
275 |
276 | wg.Add(2)
277 | go copyFunc(c1, c2)
278 | go copyFunc(c2, c1)
279 |
280 | // Convert waitgroup to a channel so we can also wait on the context.
281 | done := make(chan struct{})
282 | go func() {
283 | defer close(done)
284 | wg.Wait()
285 | }()
286 |
287 | select {
288 | case <-ctx.Done():
289 | case <-done:
290 | }
291 | }
292 |
293 | func cpHandler(w http.ResponseWriter, r *http.Request) {
294 | if r.Method != "POST" {
295 | w.WriteHeader(http.StatusOK)
296 | w.Write([]byte("OK"))
297 | return
298 | }
299 |
300 | fiName := strings.TrimPrefix(r.URL.Path, "/")
301 | defer r.Body.Close()
302 |
303 | fi, err := os.OpenFile(fiName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
304 | if err != nil {
305 | http.Error(w, err.Error(), http.StatusInternalServerError)
306 | return
307 | }
308 |
309 | bar := progressbar.DefaultBytes(
310 | r.ContentLength,
311 | fmt.Sprintf("Downloading %q", fiName),
312 | )
313 | _, err = io.Copy(io.MultiWriter(fi, bar), r.Body)
314 | if err != nil {
315 | http.Error(w, err.Error(), http.StatusInternalServerError)
316 | return
317 | }
318 | fi.Close()
319 | bar.Close()
320 |
321 | w.WriteHeader(http.StatusOK)
322 | w.Write([]byte(fmt.Sprintf("File %q written", fiName)))
323 | fmt.Printf("Received file %s from %s\n", fiName, r.RemoteAddr)
324 | }
325 |
--------------------------------------------------------------------------------
/overlay/send.go:
--------------------------------------------------------------------------------
1 | //go:build !js && !wasm
2 | // +build !js,!wasm
3 |
4 | package overlay
5 |
6 | import (
7 | "context"
8 | "encoding/json"
9 | "errors"
10 | "fmt"
11 | "log/slog"
12 | "net"
13 | "net/netip"
14 | "os"
15 | "os/user"
16 | "time"
17 |
18 | "github.com/coder/wush/cliui"
19 | "github.com/pion/webrtc/v4"
20 | "tailscale.com/derp"
21 | "tailscale.com/derp/derphttp"
22 | "tailscale.com/net/netmon"
23 | "tailscale.com/tailcfg"
24 | "tailscale.com/types/key"
25 | )
26 |
27 | func NewSendOverlay(logger *slog.Logger, dm *tailcfg.DERPMap) *Send {
28 | s := &Send{
29 | derpMap: dm,
30 | in: make(chan *tailcfg.Node, 8),
31 | out: make(chan *overlayMessage, 8),
32 | waitIce: make(chan struct{}),
33 | WaitTransferDone: make(chan struct{}),
34 | SelfIP: randv6(),
35 | }
36 | s.setupWebrtcConnection()
37 | return s
38 | }
39 |
40 | type Send struct {
41 | Logger *slog.Logger
42 | STUNIPOverride netip.Addr
43 | derpMap *tailcfg.DERPMap
44 |
45 | SelfIP netip.Addr
46 |
47 | Auth ClientAuth
48 |
49 | RtcConn *webrtc.PeerConnection
50 | RtcDc *webrtc.DataChannel
51 | offer webrtc.SessionDescription
52 | waitIce chan struct{}
53 | WaitTransferDone chan struct{}
54 |
55 | in chan *tailcfg.Node
56 | out chan *overlayMessage
57 | }
58 |
59 | func (s *Send) IPs() []netip.Addr {
60 | return []netip.Addr{s.SelfIP}
61 | }
62 |
63 | func (s *Send) Recv() <-chan *tailcfg.Node {
64 | return s.in
65 | }
66 |
67 | func (s *Send) SendTailscaleNodeUpdate(node *tailcfg.Node) {
68 | s.out <- &overlayMessage{
69 | Typ: messageTypeNodeUpdate,
70 | Node: *node.Clone(),
71 | }
72 | }
73 |
74 | func (s *Send) ListenOverlaySTUN(ctx context.Context) error {
75 | conn, err := net.ListenUDP("udp4", nil)
76 | if err != nil {
77 | return fmt.Errorf("listen STUN: %w", err)
78 | }
79 |
80 | go func() {
81 | <-ctx.Done()
82 | _ = conn.Close()
83 | }()
84 |
85 | sealed := s.newHelloPacket()
86 | receiverAddr := s.Auth.ReceiverStunAddr
87 | if s.STUNIPOverride.IsValid() {
88 | receiverAddr = netip.AddrPortFrom(s.STUNIPOverride, s.Auth.ReceiverStunAddr.Port())
89 | }
90 |
91 | _, err = conn.WriteToUDPAddrPort(sealed, receiverAddr)
92 | if err != nil {
93 | return fmt.Errorf("send overlay hello over STUN: %w", err)
94 | }
95 |
96 | keepAlive := time.NewTicker(30 * time.Second)
97 |
98 | go func() {
99 | for {
100 | select {
101 | case <-ctx.Done():
102 | return
103 | case msg := <-s.out:
104 | raw, err := json.Marshal(msg)
105 | if err != nil {
106 | panic("marshal overlay msg: " + err.Error())
107 | }
108 |
109 | sealed := s.Auth.OverlayPrivateKey.SealTo(s.Auth.ReceiverPublicKey, raw)
110 | _, err = conn.WriteToUDPAddrPort(sealed, receiverAddr)
111 | if err != nil {
112 | fmt.Printf("send response over STUN: %s\n", err)
113 | return
114 | }
115 |
116 | case <-keepAlive.C:
117 | msg := overlayMessage{
118 | Typ: messageTypePing,
119 | }
120 | raw, err := json.Marshal(msg)
121 | if err != nil {
122 | panic("marshal node: " + err.Error())
123 | }
124 |
125 | sealed := s.Auth.OverlayPrivateKey.SealTo(s.Auth.ReceiverPublicKey, raw)
126 | _, err = conn.WriteToUDPAddrPort(sealed, receiverAddr)
127 | if err != nil {
128 | fmt.Printf("send ping message over STUN: %s\n", err)
129 | return
130 | }
131 | }
132 | }
133 | }()
134 |
135 | for {
136 | buf := make([]byte, 4<<10)
137 | n, addr, err := conn.ReadFromUDPAddrPort(buf)
138 | if err != nil {
139 | s.Logger.Error("read from STUN; exiting", "err", err)
140 | return err
141 | }
142 |
143 | buf = buf[:n]
144 |
145 | res, err := s.handleNextMessage(buf)
146 | if err != nil {
147 | fmt.Println(cliui.Timestamp(time.Now()), "Failed to handle overlay message:", err.Error())
148 | continue
149 | }
150 |
151 | if res != nil {
152 | _, err = conn.WriteToUDPAddrPort(res, addr)
153 | if err != nil {
154 | fmt.Println(cliui.Timestamp(time.Now()), "Failed to send overlay response over STUN:", err.Error())
155 | return err
156 | }
157 | }
158 | }
159 | }
160 |
161 | func (s *Send) ListenOverlayDERP(ctx context.Context) error {
162 | derpPriv := key.NewNode()
163 | c := derphttp.NewRegionClient(derpPriv, func(format string, args ...any) {}, netmon.NewStatic(), func() *tailcfg.DERPRegion {
164 | return s.derpMap.Regions[int(s.Auth.ReceiverDERPRegionID)]
165 | })
166 |
167 | err := c.Connect(ctx)
168 | if err != nil {
169 | return err
170 | }
171 |
172 | sealed := s.newHelloPacket()
173 | err = c.Send(s.Auth.ReceiverPublicKey, sealed)
174 | if err != nil {
175 | return fmt.Errorf("send overlay hello over derp: %w", err)
176 | }
177 |
178 | go func() {
179 | for {
180 | select {
181 | case <-ctx.Done():
182 | return
183 | case msg := <-s.out:
184 | raw, err := json.Marshal(msg)
185 | if err != nil {
186 | panic("marshal overlay msg: " + err.Error())
187 | }
188 |
189 | sealed := s.Auth.OverlayPrivateKey.SealTo(s.Auth.ReceiverPublicKey, raw)
190 | err = c.Send(s.Auth.ReceiverPublicKey, sealed)
191 | if err != nil {
192 | fmt.Printf("send response over derp: %s\n", err)
193 | return
194 | }
195 | }
196 | }
197 | }()
198 |
199 | for {
200 | msg, err := c.Recv()
201 | if err != nil {
202 | return err
203 | }
204 |
205 | switch msg := msg.(type) {
206 | case derp.ReceivedPacket:
207 | if s.Auth.ReceiverPublicKey != msg.Source {
208 | fmt.Printf("message from unknown peer %s\n", msg.Source.String())
209 | continue
210 | }
211 |
212 | res, err := s.handleNextMessage(msg.Data)
213 | if err != nil {
214 | fmt.Println("Failed to handle overlay message", err)
215 | continue
216 | }
217 |
218 | if res != nil {
219 | err = c.Send(msg.Source, res)
220 | if err != nil {
221 | fmt.Println(cliui.Timestamp(time.Now()), "Failed to send overlay response over derp:", err.Error())
222 | return err
223 | }
224 | }
225 | }
226 | }
227 | }
228 |
229 | func (s *Send) newHelloPacket() []byte {
230 | var (
231 | username,
232 | hostname string
233 | )
234 |
235 | cu, _ := user.Current()
236 | if cu != nil {
237 | username = cu.Username
238 | }
239 | hostname, _ = os.Hostname()
240 |
241 | raw, err := json.Marshal(overlayMessage{
242 | Typ: messageTypeHello,
243 | HostInfo: HostInfo{
244 | Username: username,
245 | Hostname: hostname,
246 | },
247 | WebrtcDescription: &s.offer,
248 | })
249 | if err != nil {
250 | panic("marshal node: " + err.Error())
251 | }
252 |
253 | sealed := s.Auth.OverlayPrivateKey.SealTo(s.Auth.ReceiverPublicKey, raw)
254 | return sealed
255 | }
256 |
257 | const (
258 | RtcMetadataTypeFileMetadata = "file_metadata"
259 | RtcMetadataTypeFileComplete = "file_complete"
260 | RtcMetadataTypeFileAck = "file_ack"
261 | )
262 |
263 | type RtcMetadata struct {
264 | Type string `json:"type"`
265 | FileMetadata RtcFileMetadata `json:"fileMetadata"`
266 | }
267 | type RtcFileMetadata struct {
268 | FileName string `json:"fileName"`
269 | FileSize int `json:"fileSize"`
270 | }
271 |
272 | func (s *Send) handleNextMessage(msg []byte) (resRaw []byte, _ error) {
273 | cleartext, ok := s.Auth.OverlayPrivateKey.OpenFrom(s.Auth.ReceiverPublicKey, msg)
274 | if !ok {
275 | return nil, errors.New("message failed decryption")
276 | }
277 |
278 | var ovMsg overlayMessage
279 | err := json.Unmarshal(cleartext, &ovMsg)
280 | if err != nil {
281 | panic("unmarshal node: " + err.Error())
282 | }
283 |
284 | res := overlayMessage{}
285 | switch ovMsg.Typ {
286 | case messageTypePing:
287 | res.Typ = messageTypePong
288 | case messageTypePong:
289 | // do nothing
290 | case messageTypeHelloResponse:
291 | s.in <- &ovMsg.Node
292 | close(s.waitIce)
293 | s.RtcConn.SetRemoteDescription(*ovMsg.WebrtcDescription)
294 | case messageTypeNodeUpdate:
295 | s.in <- &ovMsg.Node
296 | case messageTypeWebRTCCandidate:
297 | s.RtcConn.AddICECandidate(*ovMsg.WebrtcCandidate)
298 | }
299 |
300 | if res.Typ == 0 {
301 | return nil, nil
302 | }
303 |
304 | raw, err := json.Marshal(res)
305 | if err != nil {
306 | panic("marshal node: " + err.Error())
307 | }
308 |
309 | sealed := s.Auth.OverlayPrivateKey.SealTo(s.Auth.ReceiverPublicKey, raw)
310 | return sealed, nil
311 | }
312 |
313 | func (s *Send) setupWebrtcConnection() {
314 | var err error
315 | s.RtcConn, err = webrtc.NewPeerConnection(getWebRTCConfig())
316 | if err != nil {
317 | panic("failed to create webrtc connection: " + err.Error())
318 | }
319 |
320 | s.RtcConn.OnICECandidate(func(i *webrtc.ICECandidate) {
321 | if i == nil {
322 | return
323 | }
324 | ic := i.ToJSON()
325 |
326 | <-s.waitIce
327 | s.out <- &overlayMessage{
328 | Typ: messageTypeWebRTCCandidate,
329 | WebrtcCandidate: &ic,
330 | }
331 | })
332 |
333 | s.RtcDc, err = s.RtcConn.CreateDataChannel("fileTransfer", nil)
334 | if err != nil {
335 | fmt.Println("failed to create dc:", err)
336 | }
337 |
338 | // Add message handler to our created data channel
339 | s.RtcDc.OnMessage(func(msg webrtc.DataChannelMessage) {
340 | if msg.IsString {
341 | meta := RtcMetadata{}
342 |
343 | err := json.Unmarshal(msg.Data, &meta)
344 | if err != nil {
345 | fmt.Println("failed to unmarshal metadata:", err)
346 | return
347 | }
348 |
349 | if meta.Type == RtcMetadataTypeFileAck {
350 | close(s.WaitTransferDone)
351 | return
352 | }
353 | return
354 | }
355 | })
356 |
357 | answer, err := s.RtcConn.CreateOffer(nil)
358 | if err != nil {
359 | fmt.Println("failed to create answer:", err)
360 | }
361 |
362 | err = s.RtcConn.SetLocalDescription(answer)
363 | if err != nil {
364 | fmt.Println("failed to set local description:", err)
365 | }
366 |
367 | s.offer = answer
368 | }
369 |
--------------------------------------------------------------------------------
/cmd/wush/cp.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "log/slog"
9 | "net/http"
10 | "net/http/httputil"
11 | "net/netip"
12 | "net/url"
13 | "os"
14 | "path/filepath"
15 | "strings"
16 | "time"
17 |
18 | "github.com/charmbracelet/huh"
19 | "github.com/coder/serpent"
20 | "github.com/coder/wush/cliui"
21 | "github.com/coder/wush/overlay"
22 | "github.com/coder/wush/tsserver"
23 | "github.com/pion/webrtc/v4"
24 | "github.com/schollz/progressbar/v3"
25 | "tailscale.com/net/netns"
26 | "tailscale.com/tailcfg"
27 | "tailscale.com/types/ptr"
28 | )
29 |
30 | func initLogger(verbose, quiet *bool, slogger *slog.Logger, logf *func(str string, args ...any)) serpent.MiddlewareFunc {
31 | return func(next serpent.HandlerFunc) serpent.HandlerFunc {
32 | return func(i *serpent.Invocation) error {
33 | if *verbose {
34 | *slogger = *slog.New(slog.NewTextHandler(i.Stderr, nil))
35 | } else {
36 | *slogger = *slog.New(slog.NewTextHandler(io.Discard, nil))
37 | }
38 |
39 | *logf = func(str string, args ...any) {
40 | if !*quiet {
41 | fmt.Fprintf(i.Stderr, str+"\n", args...)
42 | }
43 | }
44 |
45 | return next(i)
46 | }
47 | }
48 | }
49 |
50 | func initAuth(authFlag *string, ca *overlay.ClientAuth) serpent.MiddlewareFunc {
51 | return func(next serpent.HandlerFunc) serpent.HandlerFunc {
52 | return func(i *serpent.Invocation) error {
53 | if *authFlag == "" {
54 | err := huh.NewInput().
55 | Title("Enter your Auth ID:").
56 | Value(authFlag).
57 | Run()
58 | if err != nil {
59 | return fmt.Errorf("get auth id: %w", err)
60 | }
61 | }
62 |
63 | // If the user provided a URL, extract the auth key from the fragment.
64 | authKey := *authFlag
65 | if u, err := url.Parse(*authFlag); err == nil && u.Fragment != "" {
66 | authKey = u.Fragment
67 | }
68 |
69 | err := ca.Parse(strings.TrimSpace(authKey))
70 | if err != nil {
71 | return fmt.Errorf("parse auth key: %w", err)
72 | }
73 |
74 | return next(i)
75 | }
76 | }
77 | }
78 |
79 | func sendOverlayMW(opts *sendOverlayOpts, send **overlay.Send, logger *slog.Logger, dm *tailcfg.DERPMap, logf *func(str string, args ...any)) serpent.MiddlewareFunc {
80 | return func(next serpent.HandlerFunc) serpent.HandlerFunc {
81 | return func(i *serpent.Invocation) error {
82 | var err error
83 |
84 | newSend := overlay.NewSendOverlay(logger, dm)
85 | newSend.Auth = opts.clientAuth
86 | if opts.stunAddrOverride != "" {
87 | newSend.STUNIPOverride, err = netip.ParseAddr(opts.stunAddrOverride)
88 | if err != nil {
89 | return fmt.Errorf("parse stun addr override: %w", err)
90 | }
91 | }
92 |
93 | newSend.Auth.PrintDebug(*logf, dm)
94 |
95 | *send = newSend
96 | return next(i)
97 | }
98 | }
99 | }
100 |
101 | func derpMap(fi *string, dm *tailcfg.DERPMap) serpent.MiddlewareFunc {
102 | return func(next serpent.HandlerFunc) serpent.HandlerFunc {
103 | return func(i *serpent.Invocation) error {
104 | if *fi == "" {
105 | _dm, err := tsserver.DERPMapTailscale(i.Context())
106 | if err != nil {
107 | return fmt.Errorf("request derpmap from tailscale: %w", err)
108 | }
109 | *dm = *_dm
110 | } else {
111 | data, err := os.ReadFile(*fi)
112 | if err != nil {
113 | return fmt.Errorf("read derp config file: %w", err)
114 | }
115 | if err := json.Unmarshal(data, dm); err != nil {
116 | return fmt.Errorf("unmarshal derp config: %w", err)
117 | }
118 | }
119 |
120 | return next(i)
121 | }
122 | }
123 | }
124 |
125 | type sendOverlayOpts struct {
126 | authKey string
127 | clientAuth overlay.ClientAuth
128 | waitP2P bool
129 | stunAddrOverride string
130 | }
131 |
132 | func cpCmd() *serpent.Command {
133 | var (
134 | verbose bool
135 | derpmapFi string
136 | logger = new(slog.Logger)
137 | logf = func(str string, args ...any) {}
138 |
139 | dm = new(tailcfg.DERPMap)
140 | overlayOpts = new(sendOverlayOpts)
141 | send = new(overlay.Send)
142 | )
143 | return &serpent.Command{
144 | Use: "cp ",
145 | Short: "Transfer files to a wush server.",
146 | Long: formatExamples(
147 | example{
148 | Description: "Copy a local file to the server",
149 | Command: "wush cp local-file.txt",
150 | },
151 | ),
152 | Middleware: serpent.Chain(
153 | serpent.RequireNArgs(1),
154 | initLogger(&verbose, ptr.To(false), logger, &logf),
155 | initAuth(&overlayOpts.authKey, &overlayOpts.clientAuth),
156 | derpMap(&derpmapFi, dm),
157 | sendOverlayMW(overlayOpts, &send, logger, dm, &logf),
158 | ),
159 | Handler: func(inv *serpent.Invocation) error {
160 | ctx := inv.Context()
161 |
162 | s, err := tsserver.NewServer(ctx, logger, send, dm)
163 | if err != nil {
164 | return err
165 | }
166 |
167 | if send.Auth.ReceiverDERPRegionID != 0 {
168 | go send.ListenOverlayDERP(ctx)
169 | } else if send.Auth.ReceiverStunAddr.IsValid() {
170 | go send.ListenOverlaySTUN(ctx)
171 | } else {
172 | return errors.New("auth key provided neither DERP nor STUN")
173 | }
174 |
175 | go s.ListenAndServe(ctx)
176 |
177 | fiPath := inv.Args[0]
178 | fiName := filepath.Base(inv.Args[0])
179 |
180 | fi, err := os.Open(fiPath)
181 | if err != nil {
182 | return err
183 | }
184 | defer fi.Close()
185 |
186 | fiStat, err := fi.Stat()
187 | if err != nil {
188 | return err
189 | }
190 |
191 | if send.Auth.Web {
192 | meta := overlay.RtcMetadata{
193 | Type: overlay.RtcMetadataTypeFileMetadata,
194 | FileMetadata: overlay.RtcFileMetadata{
195 | FileName: fiName,
196 | FileSize: int(fiStat.Size()),
197 | },
198 | }
199 |
200 | raw, err := json.Marshal(meta)
201 | if err != nil {
202 | panic(err)
203 | }
204 |
205 | logf("Waiting for data channel to open...")
206 | for {
207 | if send.RtcDc.ReadyState() == webrtc.DataChannelStateOpen {
208 | break
209 | }
210 | time.Sleep(100 * time.Millisecond)
211 | }
212 | logf("Data channel is open!")
213 |
214 | if err := send.RtcDc.SendText(string(raw)); err != nil {
215 | panic(err)
216 | }
217 |
218 | bar := progressbar.DefaultBytes(
219 | fiStat.Size(),
220 | fmt.Sprintf("Uploading %q", fiPath),
221 | )
222 | barReader := progressbar.NewReader(fi, bar)
223 |
224 | buf := make([]byte, 16384)
225 |
226 | for {
227 | n, err := barReader.Read(buf)
228 | if err != nil && err != io.EOF {
229 | return err
230 | }
231 |
232 | if n > 0 {
233 | if err := send.RtcDc.Send(buf[:n]); err != nil {
234 | fmt.Println("failed to send file data: ", err)
235 | return err
236 | }
237 | }
238 |
239 | if err == io.EOF {
240 | break
241 | }
242 | }
243 |
244 | meta = overlay.RtcMetadata{
245 | Type: overlay.RtcMetadataTypeFileComplete,
246 | }
247 |
248 | raw, err = json.Marshal(meta)
249 | if err != nil {
250 | panic(err)
251 | }
252 |
253 | if err := send.RtcDc.SendText(string(raw)); err != nil {
254 | fmt.Println("failed to send file complete message", err)
255 | }
256 |
257 | select {
258 | case <-send.WaitTransferDone:
259 | logger.Info("received file transfer acknowledgment")
260 | return nil
261 | }
262 | }
263 |
264 | netns.SetDialerOverride(s.Dialer())
265 | ts, err := newTSNet("send", verbose)
266 | if err != nil {
267 | return err
268 | }
269 |
270 | logf("Bringing WireGuard up..")
271 | ts.Up(ctx)
272 | logf("WireGuard is ready!")
273 |
274 | lc, err := ts.LocalClient()
275 | if err != nil {
276 | return err
277 | }
278 |
279 | ip, err := waitUntilHasPeerHasIP(ctx, logf, lc)
280 | if err != nil {
281 | return err
282 | }
283 |
284 | if overlayOpts.waitP2P {
285 | err := waitUntilHasP2P(ctx, logf, lc)
286 | if err != nil {
287 | return err
288 | }
289 | }
290 |
291 | bar := progressbar.DefaultBytes(
292 | fiStat.Size(),
293 | fmt.Sprintf("Uploading %q", fiPath),
294 | )
295 | barReader := progressbar.NewReader(fi, bar)
296 |
297 | hc := ts.HTTPClient()
298 | req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s:4444/%s", ip.String(), fiName), &barReader)
299 | if err != nil {
300 | return err
301 | }
302 | req.ContentLength = fiStat.Size()
303 |
304 | res, err := hc.Do(req)
305 | if err != nil {
306 | return err
307 | }
308 | defer res.Body.Close()
309 |
310 | out, err := httputil.DumpResponse(res, true)
311 | if err != nil {
312 | return err
313 | }
314 | bar.Close()
315 | fmt.Println(string(out))
316 |
317 | return nil
318 | },
319 | Options: []serpent.Option{
320 | {
321 | Flag: "auth-key",
322 | Env: "WUSH_AUTH_KEY",
323 | Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.",
324 | Default: "",
325 | Value: serpent.StringOf(&overlayOpts.authKey),
326 | },
327 | {
328 | Flag: "derp-config-file",
329 | Description: "File which specifies the DERP config to use. In the structure of https://pkg.go.dev/tailscale.com@v1.74.1/tailcfg#DERPMap. By default, https://controlplane.tailscale.com/derpmap/default is used.",
330 | Default: "",
331 | Value: serpent.StringOf(&derpmapFi),
332 | },
333 | {
334 | Flag: "stun-ip-override",
335 | Default: "",
336 | Value: serpent.StringOf(&overlayOpts.stunAddrOverride),
337 | },
338 | {
339 | Flag: "wait-p2p",
340 | Description: "Waits for the connection to be p2p.",
341 | Default: "false",
342 | Value: serpent.BoolOf(&overlayOpts.waitP2P),
343 | },
344 | {
345 | Flag: "verbose",
346 | FlagShorthand: "v",
347 | Description: "Enable verbose logging.",
348 | Default: "false",
349 | Value: serpent.BoolOf(&verbose),
350 | },
351 | },
352 | }
353 | }
354 |
--------------------------------------------------------------------------------
/site/components/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 | import { useState } from "react";
3 | import { Star, Plug, PlugZap, Download } from "lucide-react";
4 | import Link from "next/link";
5 | import { useRouter } from "next/router";
6 | import { useWasm } from "@/context/wush";
7 | import { Toaster } from "@/components/ui/sonner";
8 | import Head from "next/head";
9 |
10 | const LoadingSpinner = ({ className }: { className?: string }) => (
11 |
34 | );
35 |
36 | export default function Layout({ children }: { children: ReactNode }) {
37 | const router = useRouter();
38 | const activeTab = router.pathname.substring(1);
39 | const wasm = useWasm();
40 | const currentFragment = (() => {
41 | // Check if we're in the browser
42 | return typeof window !== "undefined"
43 | ? window.location.hash.substring(1)
44 | : "";
45 | })();
46 |
47 | const [pendingPeer, setPendingPeer] = useState(currentFragment);
48 |
49 | return (
50 | <>
51 |
52 | wush
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | ⧉ wush
61 |
62 | v0.4.0
63 |
64 |
65 |
72 |
76 | {wasm.wush.current?.auth_info()?.derp_name || "Connecting..."}
77 |
78 |
79 |
80 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | Send, Receive, Access
108 |
109 |
110 | WireGuard-powered peer-to-peer file transfer and remote access
111 |
112 |
113 | Unlimited File Size
114 | •
115 | E2E Encrypted
116 | •
117 | Command Line ↔ Browser
118 |
119 |
120 |
121 |
122 | Current peer
123 |
124 | {
129 | // If pasting a URL, extract the fragment
130 | if (e.target.value.includes("#")) {
131 | try {
132 | const url = new URL(e.target.value);
133 | setPendingPeer(url.hash.slice(1));
134 | } catch {
135 | // If URL parsing fails, just use the value as-is
136 | setPendingPeer(e.target.value);
137 | }
138 | } else {
139 | setPendingPeer(e.target.value);
140 | }
141 | }}
142 | readOnly={Boolean(wasm.connectedPeer)}
143 | placeholder="Enter auth key"
144 | />
145 |
146 |
187 | {wasm.error}
188 |
189 |
190 |
191 |
192 | {["send", "receive", "access"].map((tab) => (
193 |
198 |
213 |
214 | ))}
215 |
224 |
225 |
226 | {children}
227 |
228 |
229 | {/*
230 | Connected peers
231 |
232 |
233 |
234 |
235 | Name
236 | Wireguard IP
237 |
238 |
239 |
240 | {wasm.peers?.map((peer) => (
241 |
242 | {peer.name}
243 | {peer.ip}
244 |
245 | ))}
246 |
247 |
248 |
249 | */}
250 |
251 |
252 |
253 |
256 |
257 |
258 |
259 | >
260 | );
261 | }
262 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/coder/wush
2 |
3 | go 1.23.1
4 |
5 | toolchain go1.23.2
6 |
7 | replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20241122221419-49dfbfcd5e09
8 |
9 | // replace tailscale.com => /home/colin/Projects/coadler/tailscale
10 |
11 | replace github.com/gliderlabs/ssh => github.com/coder/ssh v0.0.0-20231128192721-70855dedb788
12 |
13 | require (
14 | cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6
15 | github.com/btcsuite/btcd/btcutil v1.1.6
16 | github.com/charmbracelet/huh v0.6.0
17 | github.com/coder/coder/v2 v2.16.0
18 | github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0
19 | github.com/coder/serpent v0.8.0
20 | github.com/go-chi/chi/v5 v5.1.0
21 | github.com/google/uuid v1.6.0
22 | github.com/klauspost/compress v1.17.11
23 | github.com/mattn/go-isatty v0.0.20
24 | github.com/mitchellh/go-wordwrap v1.0.1
25 | github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a
26 | github.com/pion/stun/v3 v3.0.0
27 | github.com/pion/webrtc/v4 v4.0.1
28 | github.com/prometheus/client_golang v1.20.5
29 | github.com/puzpuzpuz/xsync/v3 v3.4.0
30 | github.com/schollz/progressbar/v3 v3.16.1
31 | github.com/spf13/afero v1.11.0
32 | github.com/valyala/fasthttp v1.58.0
33 | go4.org/mem v0.0.0-20220726221520-4f986261bf13
34 | golang.org/x/crypto v0.31.0
35 | golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
36 | golang.org/x/net v0.31.0
37 | golang.org/x/sys v0.28.0
38 | golang.org/x/term v0.27.0
39 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
40 | tailscale.com v1.76.1
41 | )
42 |
43 | require (
44 | filippo.io/edwards25519 v1.1.0 // indirect
45 | github.com/DataDog/appsec-internal-go v1.7.0 // indirect
46 | github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0 // indirect
47 | github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1 // indirect
48 | github.com/DataDog/datadog-go/v5 v5.3.0 // indirect
49 | github.com/DataDog/go-libddwaf/v3 v3.3.0 // indirect
50 | github.com/DataDog/go-tuf v1.0.2-0.5.2 // indirect
51 | github.com/DataDog/gostackparse v0.7.0 // indirect
52 | github.com/DataDog/sketches-go v1.4.5 // indirect
53 | github.com/Microsoft/go-winio v0.6.2 // indirect
54 | github.com/agext/levenshtein v1.2.3 // indirect
55 | github.com/akutz/memconn v0.1.0 // indirect
56 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
57 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
58 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
59 | github.com/atotto/clipboard v0.1.4 // indirect
60 | github.com/aws/aws-sdk-go-v2 v1.31.0 // indirect
61 | github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
62 | github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
63 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
64 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
65 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
66 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
67 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
68 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
69 | github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4 // indirect
70 | github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
71 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
72 | github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
73 | github.com/aws/smithy-go v1.21.0 // indirect
74 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
75 | github.com/beorn7/perks v1.0.1 // indirect
76 | github.com/bits-and-blooms/bitset v1.13.0 // indirect
77 | github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 // indirect
78 | github.com/catppuccin/go v0.2.0 // indirect
79 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect
80 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
81 | github.com/charmbracelet/bubbles v0.20.0 // indirect
82 | github.com/charmbracelet/bubbletea v1.1.0 // indirect
83 | github.com/charmbracelet/lipgloss v0.13.0 // indirect
84 | github.com/charmbracelet/x/ansi v0.2.3 // indirect
85 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
86 | github.com/charmbracelet/x/term v0.2.0 // indirect
87 | github.com/coder/terraform-provider-coder v1.0.2 // indirect
88 | github.com/coder/websocket v1.8.12 // indirect
89 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
90 | github.com/coreos/go-oidc/v3 v3.11.0 // indirect
91 | github.com/creack/pty v1.1.23 // indirect
92 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
93 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
94 | github.com/dustin/go-humanize v1.0.1 // indirect
95 | github.com/ebitengine/purego v0.6.0-alpha.5 // indirect
96 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
97 | github.com/fatih/color v1.17.0 // indirect
98 | github.com/fxamacker/cbor/v2 v2.6.0 // indirect
99 | github.com/gaissmai/bart v0.11.1 // indirect
100 | github.com/gliderlabs/ssh v0.3.7 // indirect
101 | github.com/go-jose/go-jose/v4 v4.0.2 // indirect
102 | github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 // indirect
103 | github.com/go-logr/logr v1.4.2 // indirect
104 | github.com/go-logr/stdr v1.2.2 // indirect
105 | github.com/go-ole/go-ole v1.3.0 // indirect
106 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
107 | github.com/gofrs/flock v0.12.0 // indirect
108 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
109 | github.com/golang/protobuf v1.5.4 // indirect
110 | github.com/google/btree v1.1.2 // indirect
111 | github.com/google/go-cmp v0.6.0 // indirect
112 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
113 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect
114 | github.com/gorilla/csrf v1.7.2 // indirect
115 | github.com/gorilla/securecookie v1.1.2 // indirect
116 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
117 | github.com/hashicorp/errwrap v1.1.0 // indirect
118 | github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect
119 | github.com/hashicorp/go-hclog v1.6.3 // indirect
120 | github.com/hashicorp/go-multierror v1.1.1 // indirect
121 | github.com/hashicorp/go-uuid v1.0.3 // indirect
122 | github.com/hashicorp/go-version v1.7.0 // indirect
123 | github.com/hashicorp/hcl/v2 v2.22.0 // indirect
124 | github.com/hashicorp/logutils v1.0.0 // indirect
125 | github.com/hashicorp/terraform-plugin-go v0.23.0 // indirect
126 | github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect
127 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 // indirect
128 | github.com/hashicorp/yamux v0.1.1 // indirect
129 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect
130 | github.com/illarion/gonotify/v2 v2.0.3 // indirect
131 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
132 | github.com/jmespath/go-jmespath v0.4.0 // indirect
133 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect
134 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect
135 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
136 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
137 | github.com/kr/fs v0.1.0 // indirect
138 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
139 | github.com/mattn/go-colorable v0.1.13 // indirect
140 | github.com/mattn/go-localereader v0.0.1 // indirect
141 | github.com/mattn/go-runewidth v0.0.16 // indirect
142 | github.com/mdlayher/genetlink v1.3.2 // indirect
143 | github.com/mdlayher/netlink v1.7.2 // indirect
144 | github.com/mdlayher/sdnotify v1.0.0 // indirect
145 | github.com/mdlayher/socket v0.5.0 // indirect
146 | github.com/miekg/dns v1.1.58 // indirect
147 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
148 | github.com/mitchellh/copystructure v1.2.0 // indirect
149 | github.com/mitchellh/go-ps v1.0.0 // indirect
150 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect
151 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
152 | github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
153 | github.com/mitchellh/reflectwalk v1.0.2 // indirect
154 | github.com/moby/moby v27.3.1+incompatible // indirect
155 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
156 | github.com/muesli/cancelreader v0.2.2 // indirect
157 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
158 | github.com/outcaste-io/ristretto v0.2.3 // indirect
159 | github.com/philhofer/fwd v1.1.2 // indirect
160 | github.com/pierrec/lz4/v4 v4.1.21 // indirect
161 | github.com/pion/datachannel v1.5.9 // indirect
162 | github.com/pion/dtls/v3 v3.0.3 // indirect
163 | github.com/pion/ice/v4 v4.0.2 // indirect
164 | github.com/pion/interceptor v0.1.37 // indirect
165 | github.com/pion/logging v0.2.2 // indirect
166 | github.com/pion/mdns/v2 v2.0.7 // indirect
167 | github.com/pion/randutil v0.1.0 // indirect
168 | github.com/pion/rtcp v1.2.14 // indirect
169 | github.com/pion/rtp v1.8.9 // indirect
170 | github.com/pion/sctp v1.8.33 // indirect
171 | github.com/pion/sdp/v3 v3.0.9 // indirect
172 | github.com/pion/srtp/v3 v3.0.4 // indirect
173 | github.com/pion/transport/v2 v2.2.10 // indirect
174 | github.com/pion/transport/v3 v3.0.7 // indirect
175 | github.com/pion/turn/v4 v4.0.0 // indirect
176 | github.com/pion/udp v0.1.4 // indirect
177 | github.com/pkg/errors v0.9.1 // indirect
178 | github.com/pkg/sftp v1.13.6 // indirect
179 | github.com/prometheus-community/pro-bing v0.4.0 // indirect
180 | github.com/prometheus/client_model v0.6.1 // indirect
181 | github.com/prometheus/common v0.59.1 // indirect
182 | github.com/prometheus/procfs v0.15.1 // indirect
183 | github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 // indirect
184 | github.com/rivo/uniseg v0.4.7 // indirect
185 | github.com/robfig/cron/v3 v3.0.1 // indirect
186 | github.com/safchain/ethtool v0.3.0 // indirect
187 | github.com/secure-systems-lab/go-securesystemslib v0.7.0 // indirect
188 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
189 | github.com/spaolacci/murmur3 v1.1.0 // indirect
190 | github.com/spf13/pflag v1.0.5 // indirect
191 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
192 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
193 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect
194 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
195 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
196 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
197 | github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect
198 | github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect
199 | github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 // indirect
200 | github.com/tcnksm/go-httpstat v0.2.0 // indirect
201 | github.com/tinylib/msgp v1.1.8 // indirect
202 | github.com/u-root/u-root v0.14.0 // indirect
203 | github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a // indirect
204 | github.com/vishvananda/netns v0.0.4 // indirect
205 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
206 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
207 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
208 | github.com/wlynxg/anet v0.0.3 // indirect
209 | github.com/x448/float16 v0.8.4 // indirect
210 | github.com/zclconf/go-cty v1.15.0 // indirect
211 | github.com/zeebo/errs v1.3.0 // indirect
212 | go.nhat.io/otelsql v0.14.0 // indirect
213 | go.opentelemetry.io/otel v1.32.0 // indirect
214 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
215 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect
216 | go.opentelemetry.io/otel/metric v1.32.0 // indirect
217 | go.opentelemetry.io/otel/sdk v1.30.0 // indirect
218 | go.opentelemetry.io/otel/trace v1.32.0 // indirect
219 | go.opentelemetry.io/proto/otlp v1.3.1 // indirect
220 | go.uber.org/atomic v1.11.0 // indirect
221 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
222 | golang.org/x/mod v0.21.0 // indirect
223 | golang.org/x/oauth2 v0.23.0 // indirect
224 | golang.org/x/sync v0.10.0 // indirect
225 | golang.org/x/text v0.21.0 // indirect
226 | golang.org/x/time v0.6.0 // indirect
227 | golang.org/x/tools v0.25.0 // indirect
228 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
229 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
230 | google.golang.org/appengine v1.6.8 // indirect
231 | google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect
232 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
233 | google.golang.org/grpc v1.67.0 // indirect
234 | google.golang.org/protobuf v1.34.2 // indirect
235 | gopkg.in/DataDog/dd-trace-go.v1 v1.67.0 // indirect
236 | gopkg.in/yaml.v3 v3.0.1 // indirect
237 | gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // indirect
238 | nhooyr.io/websocket v1.8.10 // indirect
239 | storj.io/drpc v0.0.33 // indirect
240 | )
241 |
--------------------------------------------------------------------------------
/cmd/wush/portforward.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log/slog"
8 | "net"
9 | "net/netip"
10 | "os"
11 | "os/signal"
12 | "strconv"
13 | "strings"
14 | "sync"
15 |
16 | "golang.org/x/xerrors"
17 | "tailscale.com/net/netns"
18 | "tailscale.com/tailcfg"
19 | "tailscale.com/tsnet"
20 | "tailscale.com/types/ptr"
21 |
22 | "github.com/coder/coder/v2/agent/agentssh"
23 | "github.com/coder/serpent"
24 | "github.com/coder/wush/cliui"
25 | "github.com/coder/wush/overlay"
26 | "github.com/coder/wush/tsserver"
27 | )
28 |
29 | func portForwardCmd() *serpent.Command {
30 | var (
31 | verbose bool
32 | derpmapFi string
33 | logger = new(slog.Logger)
34 | logf = func(str string, args ...any) {}
35 |
36 | dm = new(tailcfg.DERPMap)
37 | overlayOpts = new(sendOverlayOpts)
38 | send = new(overlay.Send)
39 | tcpForwards []string // :
40 | udpForwards []string // :
41 | )
42 | return &serpent.Command{
43 | Use: "port-forward",
44 | Short: "Forward ports from the wush server.",
45 | Long: formatExamples(
46 | example{
47 | Description: "Forward a single TCP port from 1234 on the server to port 5678 on your local machine",
48 | Command: "wush port-forward --tcp 5678:1234",
49 | },
50 | example{
51 | Description: "Forward a single UDP port",
52 | Command: "wush port-forward --udp 9000",
53 | },
54 | example{
55 | Description: "Forward multiple TCP ports and a UDP port",
56 | Command: "wush port-forward --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53",
57 | },
58 | example{
59 | Description: "Forward multiple ports (TCP or UDP) in condensed syntax",
60 | Command: "wush port-forward --tcp 8080,9000:3000,9090-9092,10000-10002:10010-10012",
61 | },
62 | example{
63 | Description: "Forward specifying the local address to bind",
64 | Command: "wush port-forward --tcp 1.2.3.4:8080:8080",
65 | },
66 | ),
67 | Middleware: serpent.Chain(
68 | initLogger(&verbose, ptr.To(false), logger, &logf),
69 | initAuth(&overlayOpts.authKey, &overlayOpts.clientAuth),
70 | derpMap(&derpmapFi, dm),
71 | sendOverlayMW(overlayOpts, &send, logger, dm, &logf),
72 | ),
73 | Handler: func(inv *serpent.Invocation) error {
74 | ctx, cancel := context.WithCancel(inv.Context())
75 | defer cancel()
76 |
77 | specs, err := parsePortForwards(tcpForwards, udpForwards)
78 | if err != nil {
79 | return fmt.Errorf("parse port-forward specs: %w", err)
80 | }
81 | if len(specs) == 0 {
82 | return errors.New("no port-forwards requested")
83 | }
84 |
85 | s, err := tsserver.NewServer(ctx, logger, send, dm)
86 | if err != nil {
87 | return err
88 | }
89 |
90 | if send.Auth.ReceiverDERPRegionID != 0 {
91 | go send.ListenOverlayDERP(ctx)
92 | } else if send.Auth.ReceiverStunAddr.IsValid() {
93 | go send.ListenOverlaySTUN(ctx)
94 | } else {
95 | return errors.New("auth key provided neither DERP nor STUN")
96 | }
97 |
98 | go s.ListenAndServe(ctx)
99 | netns.SetDialerOverride(s.Dialer())
100 | ts, err := newTSNet("send", verbose)
101 | if err != nil {
102 | return err
103 | }
104 |
105 | logf("Bringing WireGuard up..")
106 | ts.Up(ctx)
107 | logf("WireGuard is ready!")
108 |
109 | lc, err := ts.LocalClient()
110 | if err != nil {
111 | return err
112 | }
113 |
114 | ip, err := waitUntilHasPeerHasIP(ctx, logf, lc)
115 | if err != nil {
116 | return err
117 | }
118 |
119 | if overlayOpts.waitP2P {
120 | err := waitUntilHasP2P(ctx, logf, lc)
121 | if err != nil {
122 | return err
123 | }
124 | }
125 |
126 | var (
127 | wg = new(sync.WaitGroup)
128 | listeners = make([]net.Listener, len(specs))
129 | closeAllListeners = func() {
130 | logger.Debug("closing all listeners")
131 | for _, l := range listeners {
132 | if l == nil {
133 | continue
134 | }
135 | _ = l.Close()
136 | }
137 | }
138 | )
139 | defer closeAllListeners()
140 |
141 | for i, spec := range specs {
142 | l, err := listenAndPortForward(ctx, inv, ts, ip, wg, spec, logger)
143 | if err != nil {
144 | logger.Error("failed to listen", "spec", spec, "err", err)
145 | return err
146 | }
147 | listeners[i] = l
148 | }
149 |
150 | // Wait for the context to be canceled or for a signal and close
151 | // all listeners.
152 | var closeErr error
153 | wg.Add(1)
154 | go func() {
155 | defer wg.Done()
156 |
157 | sigs := make(chan os.Signal, 1)
158 | signal.Notify(sigs, os.Interrupt)
159 |
160 | select {
161 | case <-ctx.Done():
162 | logger.Debug("command context expired waiting for signal", "err", ctx.Err())
163 | closeErr = ctx.Err()
164 | case sig := <-sigs:
165 | logger.Debug("received signal", "signal", sig)
166 | _, _ = fmt.Fprintln(inv.Stderr, "\nReceived signal, closing all listeners and active connections")
167 | }
168 |
169 | cancel()
170 | closeAllListeners()
171 | }()
172 |
173 | wg.Wait()
174 | return closeErr
175 | },
176 | Options: []serpent.Option{
177 | {
178 | Flag: "auth-key",
179 | Env: "WUSH_AUTH_KEY",
180 | Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.",
181 | Default: "",
182 | Value: serpent.StringOf(&overlayOpts.authKey),
183 | },
184 | {
185 | Flag: "derp-config-file",
186 | Description: "File which specifies the DERP config to use. In the structure of https://pkg.go.dev/tailscale.com@v1.74.1/tailcfg#DERPMap.",
187 | Default: "",
188 | Value: serpent.StringOf(&derpmapFi),
189 | },
190 | {
191 | Flag: "stun-ip-override",
192 | Default: "",
193 | Value: serpent.StringOf(&overlayOpts.stunAddrOverride),
194 | },
195 | {
196 | Flag: "wait-p2p",
197 | Description: "Waits for the connection to be p2p.",
198 | Default: "false",
199 | Value: serpent.BoolOf(&overlayOpts.waitP2P),
200 | },
201 | {
202 | Flag: "verbose",
203 | FlagShorthand: "v",
204 | Description: "Enable verbose logging.",
205 | Default: "false",
206 | Value: serpent.BoolOf(&verbose),
207 | },
208 | {
209 | Flag: "tcp",
210 | FlagShorthand: "p",
211 | Env: "WUSH_PORT_FORWARD_TCP",
212 | Description: "Forward TCP port(s) from the peer to the local machine.",
213 | Value: serpent.StringArrayOf(&tcpForwards),
214 | },
215 | {
216 | Flag: "udp",
217 | Env: "WUSH_PORT_FORWARD_UDP",
218 | Description: "Forward UDP port(s) from the peer to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols.",
219 | Value: serpent.StringArrayOf(&udpForwards),
220 | },
221 | },
222 | }
223 | }
224 |
225 | func listenAndPortForward(
226 | ctx context.Context,
227 | inv *serpent.Invocation,
228 | ts *tsnet.Server,
229 | remoteIP netip.Addr,
230 | wg *sync.WaitGroup,
231 | spec portForwardSpec,
232 | logger *slog.Logger,
233 | ) (net.Listener, error) {
234 | logger = logger.With("network", spec.listenNetwork, "address", spec.listenAddress)
235 | _, _ = fmt.Fprintf(inv.Stderr, "Forwarding '%v://%v' locally to '%v://%v' in the peer\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress)
236 |
237 | l, err := inv.Net.Listen(spec.listenNetwork, spec.listenAddress.String())
238 | if err != nil {
239 | return nil, xerrors.Errorf("listen '%v://%v': %w", spec.listenNetwork, spec.listenAddress, err)
240 | }
241 | logger.Debug("listening")
242 |
243 | wg.Add(1)
244 | go func(spec portForwardSpec) {
245 | defer wg.Done()
246 | for {
247 | netConn, err := l.Accept()
248 | if err != nil {
249 | // Silently ignore net.ErrClosed errors.
250 | if errors.Is(err, net.ErrClosed) {
251 | logger.Debug("listener closed")
252 | return
253 | }
254 | _, _ = fmt.Fprintf(inv.Stderr, "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err)
255 | _, _ = fmt.Fprintln(inv.Stderr, "Killing listener")
256 | return
257 | }
258 | logger.Debug("accepted connection", "remote_addr", netConn.RemoteAddr())
259 |
260 | go func(netConn net.Conn) {
261 | defer netConn.Close()
262 | addr := netip.AddrPortFrom(remoteIP, spec.dialAddress.Port())
263 | remoteConn, err := ts.Dial(ctx, spec.dialNetwork, addr.String())
264 | if err != nil {
265 | _, _ = fmt.Fprintf(inv.Stderr, "Failed to dial '%v://%v' in peer: %s\n", spec.dialNetwork, addr, err)
266 | return
267 | }
268 | defer remoteConn.Close()
269 | logger.Debug("dialed remote", "remote_addr", netConn.RemoteAddr())
270 |
271 | agentssh.Bicopy(ctx, netConn, remoteConn)
272 | logger.Debug("connection closing", "remote_addr", netConn.RemoteAddr())
273 | }(netConn)
274 | }
275 | }(spec)
276 |
277 | return l, nil
278 | }
279 |
280 | type portForwardSpec struct {
281 | listenNetwork string // tcp, udp
282 | listenAddress netip.AddrPort
283 |
284 | dialNetwork string // tcp, udp
285 | dialAddress netip.AddrPort
286 | }
287 |
288 | func parsePortForwards(tcpSpecs, udpSpecs []string) ([]portForwardSpec, error) {
289 | specs := []portForwardSpec{}
290 |
291 | for _, specEntry := range tcpSpecs {
292 | for _, spec := range strings.Split(specEntry, ",") {
293 | ports, err := parseSrcDestPorts(spec)
294 | if err != nil {
295 | return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err)
296 | }
297 |
298 | for _, port := range ports {
299 | specs = append(specs, portForwardSpec{
300 | listenNetwork: "tcp",
301 | listenAddress: port.local,
302 | dialNetwork: "tcp",
303 | dialAddress: port.remote,
304 | })
305 | }
306 | }
307 | }
308 |
309 | for _, specEntry := range udpSpecs {
310 | for _, spec := range strings.Split(specEntry, ",") {
311 | ports, err := parseSrcDestPorts(spec)
312 | if err != nil {
313 | return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err)
314 | }
315 |
316 | for _, port := range ports {
317 | specs = append(specs, portForwardSpec{
318 | listenNetwork: "udp",
319 | listenAddress: port.local,
320 | dialNetwork: "udp",
321 | dialAddress: port.remote,
322 | })
323 | }
324 | }
325 | }
326 |
327 | // Check for duplicate entries.
328 | locals := map[string]struct{}{}
329 | for _, spec := range specs {
330 | localStr := fmt.Sprintf("%v:%v", spec.listenNetwork, spec.listenAddress)
331 | if _, ok := locals[localStr]; ok {
332 | return nil, xerrors.Errorf("local %v %v is specified twice", spec.listenNetwork, spec.listenAddress)
333 | }
334 | locals[localStr] = struct{}{}
335 | }
336 |
337 | return specs, nil
338 | }
339 |
340 | func parsePort(in string) (uint16, error) {
341 | port, err := strconv.ParseUint(strings.TrimSpace(in), 10, 16)
342 | if err != nil {
343 | return 0, xerrors.Errorf("parse port %q: %w", in, err)
344 | }
345 | if port == 0 {
346 | return 0, xerrors.New("port cannot be 0")
347 | }
348 |
349 | return uint16(port), nil
350 | }
351 |
352 | type parsedSrcDestPort struct {
353 | local, remote netip.AddrPort
354 | }
355 |
356 | func parseSrcDestPorts(in string) ([]parsedSrcDestPort, error) {
357 | var (
358 | err error
359 | parts = strings.Split(in, ":")
360 | localAddr = netip.AddrFrom4([4]byte{127, 0, 0, 1})
361 | remoteAddr = netip.AddrFrom4([4]byte{127, 0, 0, 1})
362 | )
363 |
364 | switch len(parts) {
365 | case 1:
366 | // Duplicate the single part
367 | parts = append(parts, parts[0])
368 | case 2:
369 | // Check to see if the first part is an IP address.
370 | _localAddr, err := netip.ParseAddr(parts[0])
371 | if err != nil {
372 | break
373 | }
374 | // The first part is the local address, so duplicate the port.
375 | localAddr = _localAddr
376 | parts = []string{parts[1], parts[1]}
377 |
378 | case 3:
379 | _localAddr, err := netip.ParseAddr(parts[0])
380 | if err != nil {
381 | return nil, xerrors.Errorf("invalid port specification %q; invalid ip %q: %w", in, parts[0], err)
382 | }
383 | localAddr = _localAddr
384 | parts = parts[1:]
385 |
386 | default:
387 | return nil, xerrors.Errorf("invalid port specification %q", in)
388 | }
389 |
390 | if !strings.Contains(parts[0], "-") {
391 | localPort, err := parsePort(parts[0])
392 | if err != nil {
393 | return nil, xerrors.Errorf("parse local port from %q: %w", in, err)
394 | }
395 | remotePort, err := parsePort(parts[1])
396 | if err != nil {
397 | return nil, xerrors.Errorf("parse remote port from %q: %w", in, err)
398 | }
399 |
400 | return []parsedSrcDestPort{{
401 | local: netip.AddrPortFrom(localAddr, localPort),
402 | remote: netip.AddrPortFrom(remoteAddr, remotePort),
403 | }}, nil
404 | }
405 |
406 | local, err := parsePortRange(parts[0])
407 | if err != nil {
408 | return nil, xerrors.Errorf("parse local port range from %q: %w", in, err)
409 | }
410 | remote, err := parsePortRange(parts[1])
411 | if err != nil {
412 | return nil, xerrors.Errorf("parse remote port range from %q: %w", in, err)
413 | }
414 | if len(local) != len(remote) {
415 | return nil, xerrors.Errorf("port ranges must be the same length, got %d ports forwarded to %d ports", len(local), len(remote))
416 | }
417 | var out []parsedSrcDestPort
418 | for i := range local {
419 | out = append(out, parsedSrcDestPort{
420 | local: netip.AddrPortFrom(localAddr, local[i]),
421 | remote: netip.AddrPortFrom(remoteAddr, remote[i]),
422 | })
423 | }
424 | return out, nil
425 | }
426 |
427 | func parsePortRange(in string) ([]uint16, error) {
428 | parts := strings.Split(in, "-")
429 | if len(parts) != 2 {
430 | return nil, xerrors.Errorf("invalid port range specification %q", in)
431 | }
432 | start, err := parsePort(parts[0])
433 | if err != nil {
434 | return nil, xerrors.Errorf("parse range start port from %q: %w", in, err)
435 | }
436 | end, err := parsePort(parts[1])
437 | if err != nil {
438 | return nil, xerrors.Errorf("parse range end port from %q: %w", in, err)
439 | }
440 | if end < start {
441 | return nil, xerrors.Errorf("range end port %v is less than start port %v", end, start)
442 | }
443 | var ports []uint16
444 | for i := start; i <= end; i++ {
445 | ports = append(ports, i)
446 | }
447 | return ports, nil
448 | }
449 |
--------------------------------------------------------------------------------
/overlay/wasm_js.go:
--------------------------------------------------------------------------------
1 | //go:build js && wasm
2 |
3 | package overlay
4 |
5 | import (
6 | "context"
7 | "encoding/json"
8 | "errors"
9 | "fmt"
10 | "log/slog"
11 | "net/netip"
12 | "sync"
13 | "sync/atomic"
14 | "syscall/js"
15 | "time"
16 |
17 | "github.com/coder/wush/cliui"
18 | "github.com/pion/webrtc/v4"
19 | "github.com/puzpuzpuz/xsync/v3"
20 | "tailscale.com/derp"
21 | "tailscale.com/derp/derphttp"
22 | "tailscale.com/net/netcheck"
23 | "tailscale.com/net/netmon"
24 | "tailscale.com/net/portmapper"
25 | "tailscale.com/tailcfg"
26 | "tailscale.com/types/key"
27 | "tailscale.com/types/logger"
28 | )
29 |
30 | func NewWasmOverlay(hlog Logf, dm *tailcfg.DERPMap,
31 | onNewPeer js.Value,
32 | onWebrtcOffer js.Value,
33 | onWebrtcAnswer js.Value,
34 | onWebrtcCandidate js.Value,
35 | ) *Wasm {
36 | return &Wasm{
37 | HumanLogf: hlog,
38 | DerpMap: dm,
39 | SelfPriv: key.NewNode(),
40 | PeerPriv: key.NewNode(),
41 | SelfIP: randv6(),
42 |
43 | onNewPeer: onNewPeer,
44 | onWebrtcOffer: onWebrtcOffer,
45 | onWebrtcAnswer: onWebrtcAnswer,
46 | onWebrtcCandidate: onWebrtcCandidate,
47 |
48 | in: make(chan *tailcfg.Node, 8),
49 | out: make(chan *overlayMessage, 8),
50 | }
51 | }
52 |
53 | type Wasm struct {
54 | Logger *slog.Logger
55 | HumanLogf Logf
56 | DerpMap *tailcfg.DERPMap
57 | // SelfPriv is the private key that peers will encrypt overlay messages to.
58 | // The public key of this is sent in the auth key.
59 | SelfPriv key.NodePrivate
60 | // PeerPriv is the main auth mechanism used to secure the overlay. Peers are
61 | // sent this private key to encrypt node communication. Leaking this private
62 | // key would allow anyone to connect.
63 | PeerPriv key.NodePrivate
64 | SelfIP netip.Addr
65 |
66 | // username is a randomly generated human-readable string displayed on
67 | // wush.dev to identify clients.
68 | username string
69 |
70 | // DerpRegionID is the DERP region that can be used for proxied overlay
71 | // communication.
72 | DerpRegionID uint16
73 | DerpLatency time.Duration
74 |
75 | activePeer atomic.Pointer[chan *overlayMessage]
76 | onNewPeer js.Value
77 |
78 | onWebrtcOffer js.Value
79 | onWebrtcAnswer js.Value
80 | onWebrtcCandidate js.Value
81 |
82 | lastNode atomic.Pointer[tailcfg.Node]
83 | // in funnels node updates from other peers to us
84 | in chan *tailcfg.Node
85 | // out fans out our node updates to peers connected to us
86 | out chan *overlayMessage
87 | }
88 |
89 | func (r *Wasm) IPs() []netip.Addr {
90 | return []netip.Addr{r.SelfIP}
91 | }
92 |
93 | func (r *Wasm) PickDERPHome(ctx context.Context) error {
94 | nm := netmon.NewStatic()
95 | nc := netcheck.Client{
96 | NetMon: nm,
97 | PortMapper: portmapper.NewClient(func(format string, args ...any) {}, nm, nil, nil, nil),
98 | Logf: func(format string, args ...any) {},
99 | }
100 |
101 | report, err := nc.GetReport(ctx, r.DerpMap, nil)
102 | if err != nil {
103 | return err
104 | }
105 |
106 | if report.PreferredDERP == 0 {
107 | r.HumanLogf("Failed to determine overlay DERP region, defaulting to %s.", cliui.Code("NYC"))
108 | r.DerpRegionID = 1
109 | r.DerpLatency = report.RegionLatency[1]
110 | } else {
111 | r.HumanLogf("Picked DERP region %s as overlay home", cliui.Code(r.DerpMap.Regions[report.PreferredDERP].RegionName))
112 | r.DerpRegionID = uint16(report.PreferredDERP)
113 | r.DerpLatency = report.RegionLatency[report.PreferredDERP]
114 | }
115 |
116 | return nil
117 | }
118 |
119 | func (r *Wasm) ClientAuth() *ClientAuth {
120 | return &ClientAuth{
121 | Web: true,
122 | OverlayPrivateKey: r.PeerPriv,
123 | ReceiverPublicKey: r.SelfPriv.Public(),
124 | ReceiverDERPRegionID: r.DerpRegionID,
125 | }
126 | }
127 |
128 | func (r *Wasm) Recv() <-chan *tailcfg.Node {
129 | return r.in
130 | }
131 |
132 | func (r *Wasm) SendTailscaleNodeUpdate(node *tailcfg.Node) {
133 | r.out <- &overlayMessage{
134 | Typ: messageTypeNodeUpdate,
135 | Node: *node.Clone(),
136 | }
137 | }
138 |
139 | func (r *Wasm) SendWebrtcCandidate(peer string, cand webrtc.ICECandidateInit) {
140 | fmt.Println("go: sending webrtc candidate")
141 | r.out <- &overlayMessage{
142 | Typ: messageTypeWebRTCCandidate,
143 | WebrtcCandidate: &cand,
144 | }
145 | }
146 |
147 | type Peer struct {
148 | ID string
149 | Name string
150 | IP netip.Addr
151 | Type string
152 | }
153 |
154 | func (r *Wasm) Connect(ctx context.Context, ca ClientAuth, offer webrtc.SessionDescription) (Peer, error) {
155 | derpPriv := key.NewNode()
156 | c := derphttp.NewRegionClient(derpPriv, logger.Logf(r.HumanLogf), netmon.NewStatic(), func() *tailcfg.DERPRegion {
157 | return r.DerpMap.Regions[int(ca.ReceiverDERPRegionID)]
158 | })
159 |
160 | err := c.Connect(ctx)
161 | if err != nil {
162 | return Peer{}, err
163 | }
164 |
165 | sealed := r.newHelloPacket(ca, offer)
166 | err = c.Send(ca.ReceiverPublicKey, sealed)
167 | if err != nil {
168 | return Peer{}, fmt.Errorf("send overlay hello over derp: %w", err)
169 | }
170 |
171 | updates := make(chan *overlayMessage, 8)
172 |
173 | old := r.activePeer.Swap(&updates)
174 | if old != nil {
175 | close(*old)
176 | }
177 |
178 | go func() {
179 | defer r.activePeer.CompareAndSwap(&updates, nil)
180 | defer c.Close()
181 | defer fmt.Println("closing send goroutine")
182 |
183 | for {
184 | select {
185 | case <-ctx.Done():
186 | return
187 | case msg, ok := <-updates:
188 | if !ok {
189 | return
190 | }
191 |
192 | raw, err := json.Marshal(msg)
193 | if err != nil {
194 | panic("marshal overlay msg: " + err.Error())
195 | }
196 |
197 | sealed := ca.OverlayPrivateKey.SealTo(ca.ReceiverPublicKey, raw)
198 | err = c.Send(ca.ReceiverPublicKey, sealed)
199 | if err != nil {
200 | fmt.Println("send response over derp:", err)
201 | return
202 | }
203 | fmt.Println("sent message to connected peer")
204 | }
205 | }
206 | }()
207 |
208 | waitHello := make(chan struct{})
209 | closeOnce := sync.Once{}
210 | helloResp := overlayMessage{}
211 | helloSrc := key.NodePublic{}
212 |
213 | go func() {
214 | for {
215 | msg, err := c.Recv()
216 | if err != nil {
217 | fmt.Println("Recv derp:", err)
218 | return
219 | }
220 |
221 | switch msg := msg.(type) {
222 | case derp.ReceivedPacket:
223 | if ca.ReceiverPublicKey != msg.Source {
224 | fmt.Printf("message from unknown peer %s\n", msg.Source.String())
225 | continue
226 | }
227 |
228 | res, _, ovmsg, err := r.handleNextMessage(msg.Source, ca.OverlayPrivateKey, ca.ReceiverPublicKey, msg.Data)
229 | if err != nil {
230 | fmt.Println("Failed to handle overlay message:", err)
231 | continue
232 | }
233 |
234 | if res != nil {
235 | err = c.Send(msg.Source, res)
236 | if err != nil {
237 | fmt.Println(cliui.Timestamp(time.Now()), "Failed to send overlay response over derp:", err.Error())
238 | return
239 | }
240 | }
241 |
242 | if ovmsg.Typ == messageTypeHelloResponse {
243 | helloResp = ovmsg
244 | helloSrc = msg.Source
245 | closeOnce.Do(func() {
246 | close(waitHello)
247 | })
248 | }
249 | }
250 | }
251 | }()
252 |
253 | select {
254 | case <-time.After(10 * time.Second):
255 | c.Close()
256 | return Peer{}, errors.New("timed out waiting for peer to respond")
257 | case <-waitHello:
258 | updates <- &overlayMessage{
259 | Typ: messageTypeNodeUpdate,
260 | Node: *r.lastNode.Load(),
261 | }
262 | if len(helloResp.Node.Addresses) == 0 {
263 | return Peer{}, fmt.Errorf("peer has no addresses")
264 | }
265 | ip := helloResp.Node.Addresses[0].Addr()
266 | typ := "cli"
267 | if ca.Web {
268 | typ = "web"
269 | }
270 | return Peer{
271 | ID: helloSrc.String(),
272 | IP: ip,
273 | Name: helloResp.HostInfo.Username,
274 | Type: typ,
275 | }, nil
276 | }
277 | }
278 |
279 | func (r *Wasm) ListenOverlayDERP(ctx context.Context) error {
280 | c := derphttp.NewRegionClient(r.SelfPriv, func(format string, args ...any) {}, netmon.NewStatic(), func() *tailcfg.DERPRegion {
281 | return r.DerpMap.Regions[int(r.DerpRegionID)]
282 | })
283 | defer c.Close()
284 |
285 | err := c.Connect(ctx)
286 | if err != nil {
287 | return err
288 | }
289 |
290 | // node pub -> derp pub
291 | peers := xsync.NewMapOf[key.NodePublic, key.NodePublic]()
292 |
293 | go func() {
294 | for {
295 |
296 | select {
297 | case <-ctx.Done():
298 | return
299 | case msg := <-r.out:
300 | if msg.Typ == messageTypeNodeUpdate {
301 | r.lastNode.Store(&msg.Node)
302 | }
303 | raw, err := json.Marshal(msg)
304 | if err != nil {
305 | panic("marshal overlay msg: " + err.Error())
306 | }
307 |
308 | sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw)
309 | // range over peers that have connected to us
310 | peers.Range(func(_, derpKey key.NodePublic) bool {
311 | fmt.Println("sending node to inbound peer")
312 | err = c.Send(derpKey, sealed)
313 | if err != nil {
314 | r.HumanLogf("Send updated node over DERP: %s", err)
315 | return false
316 | }
317 | return true
318 | })
319 | if selectedPeer := r.activePeer.Load(); selectedPeer != nil {
320 | *selectedPeer <- msg
321 | fmt.Println("sending message")
322 | }
323 | }
324 | }
325 | }()
326 |
327 | for {
328 | msg, err := c.Recv()
329 | if err != nil {
330 | fmt.Println("Recv derp:", err)
331 | return err
332 | }
333 |
334 | switch msg := msg.(type) {
335 | case derp.ReceivedPacket:
336 | res, key, _, err := r.handleNextMessage(msg.Source, r.SelfPriv, r.PeerPriv.Public(), msg.Data)
337 | if err != nil {
338 | r.HumanLogf("Failed to handle overlay message: %s", err.Error())
339 | continue
340 | }
341 |
342 | if !key.IsZero() {
343 | peers.Store(key, msg.Source)
344 | }
345 |
346 | if res != nil {
347 | err = c.Send(msg.Source, res)
348 | if err != nil {
349 | r.HumanLogf("Failed to send overlay response over derp: %s", err.Error())
350 | return err
351 | }
352 | }
353 | }
354 | }
355 | }
356 |
357 | func (r *Wasm) newHelloPacket(ca ClientAuth, offer webrtc.SessionDescription) []byte {
358 | var (
359 | username string = r.username
360 | hostname string = "wush.dev"
361 | )
362 |
363 | raw, err := json.Marshal(overlayMessage{
364 | Typ: messageTypeHello,
365 | HostInfo: HostInfo{
366 | Username: username,
367 | Hostname: hostname,
368 | },
369 | Node: *r.lastNode.Load(),
370 | WebrtcDescription: &offer,
371 | })
372 | if err != nil {
373 | panic("marshal node: " + err.Error())
374 | }
375 |
376 | sealed := ca.OverlayPrivateKey.SealTo(ca.ReceiverPublicKey, raw)
377 | return sealed
378 | }
379 |
380 | func (r *Wasm) handleNextMessage(derpPub key.NodePublic, selfPriv key.NodePrivate, peerPub key.NodePublic, msg []byte) (resRaw []byte, nodeKey key.NodePublic, _ overlayMessage, _ error) {
381 | cleartext, ok := selfPriv.OpenFrom(peerPub, msg)
382 | if !ok {
383 | return nil, key.NodePublic{}, overlayMessage{}, errors.New("message failed decryption")
384 | }
385 |
386 | var ovMsg overlayMessage
387 | fmt.Println(string(cleartext))
388 | err := json.Unmarshal(cleartext, &ovMsg)
389 | if err != nil {
390 | fmt.Printf("Unmarshal error: %#v\n", err)
391 | panic("unmarshal node: " + err.Error())
392 | }
393 |
394 | res := overlayMessage{}
395 | switch ovMsg.Typ {
396 | case messageTypePing:
397 | res.Typ = messageTypePong
398 | case messageTypePong:
399 | // do nothing
400 | case messageTypeHello:
401 | res.Typ = messageTypeHelloResponse
402 | res.HostInfo.Username = r.username
403 | res.HostInfo.Hostname = "wush.dev"
404 | username := "unknown"
405 | if u := ovMsg.HostInfo.Username; u != "" {
406 | username = u
407 | }
408 | hostname := "unknown"
409 | if h := ovMsg.HostInfo.Hostname; h != "" {
410 | hostname = h
411 | }
412 | if node := r.lastNode.Load(); node != nil {
413 | res.Node = *node
414 | }
415 | r.HumanLogf("%s Received connection request from %s", cliui.Timestamp(time.Now()), cliui.Keyword(fmt.Sprintf("%s@%s", username, hostname)))
416 | // TODO: impl
417 | r.onNewPeer.Invoke(map[string]any{
418 | "id": js.ValueOf(derpPub.String()),
419 | "name": js.ValueOf("test"),
420 | "ip": js.ValueOf("1.2.3.4"),
421 | "cancel": js.FuncOf(func(this js.Value, args []js.Value) any {
422 | return nil
423 | }),
424 | })
425 |
426 | if ovMsg.WebrtcDescription != nil {
427 | r.handleWebrtcOffer(derpPub, &res, *ovMsg.WebrtcDescription)
428 | }
429 |
430 | case messageTypeHelloResponse:
431 | if !ovMsg.Node.Key.IsZero() {
432 | r.in <- &ovMsg.Node
433 | }
434 |
435 | if ovMsg.WebrtcDescription != nil {
436 | r.onWebrtcAnswer.Invoke(js.ValueOf(derpPub.String()), map[string]any{
437 | "type": js.ValueOf(ovMsg.WebrtcDescription.Type.String()),
438 | "sdp": js.ValueOf(ovMsg.WebrtcDescription.SDP),
439 | })
440 | }
441 |
442 | case messageTypeNodeUpdate:
443 | r.HumanLogf("%s Received updated node from %s", cliui.Timestamp(time.Now()), cliui.Code(ovMsg.Node.Key.String()))
444 | if !ovMsg.Node.Key.IsZero() {
445 | r.in <- &ovMsg.Node
446 | }
447 |
448 | case messageTypeWebRTCOffer:
449 | res.Typ = messageTypeWebRTCAnswer
450 | r.handleWebrtcOffer(derpPub, &res, *ovMsg.WebrtcDescription)
451 |
452 | case messageTypeWebRTCAnswer:
453 | r.onWebrtcAnswer.Invoke(js.ValueOf(derpPub.String()), js.ValueOf(map[string]any{
454 | "type": js.ValueOf(ovMsg.WebrtcDescription.Type.String()),
455 | "sdp": js.ValueOf(ovMsg.WebrtcDescription.SDP),
456 | }))
457 |
458 | case messageTypeWebRTCCandidate:
459 | cand := map[string]any{
460 | "candidate": js.ValueOf(ovMsg.WebrtcCandidate.Candidate),
461 | }
462 | if ovMsg.WebrtcCandidate.SDPMLineIndex != nil {
463 | cand["sdpMLineIndex"] = js.ValueOf(int(*ovMsg.WebrtcCandidate.SDPMLineIndex))
464 | }
465 | if ovMsg.WebrtcCandidate.SDPMid != nil {
466 | cand["sdpMid"] = js.ValueOf(*ovMsg.WebrtcCandidate.SDPMid)
467 | }
468 | if ovMsg.WebrtcCandidate.UsernameFragment != nil {
469 | cand["usernameFragment"] = js.ValueOf(*ovMsg.WebrtcCandidate.UsernameFragment)
470 | }
471 |
472 | r.onWebrtcCandidate.Invoke(derpPub.String(), cand)
473 |
474 | }
475 |
476 | if res.Typ == 0 {
477 | return nil, ovMsg.Node.Key, ovMsg, nil
478 | }
479 |
480 | raw, err := json.Marshal(res)
481 | if err != nil {
482 | panic("marshal node: " + err.Error())
483 | }
484 |
485 | sealed := selfPriv.SealTo(peerPub, raw)
486 | return sealed, ovMsg.Node.Key, ovMsg, nil
487 | }
488 |
489 | func (r *Wasm) handleWebrtcOffer(derpPub key.NodePublic, res *overlayMessage, offer webrtc.SessionDescription) {
490 | wait := make(chan struct{})
491 |
492 | then := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
493 | defer close(wait)
494 | desc := args[0]
495 |
496 | fmt.Printf("desc %#v\n", desc)
497 | res.WebrtcDescription = &webrtc.SessionDescription{}
498 | res.WebrtcDescription.Type = webrtc.NewSDPType(desc.Get("type").String())
499 | res.WebrtcDescription.SDP = desc.Get("sdp").String()
500 |
501 | return nil
502 | })
503 | defer then.Release()
504 | catch := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
505 | defer close(wait)
506 | err := args[0]
507 | errStr := err.Call("toString").String()
508 |
509 | fmt.Println("rtc offer callback failed:", errStr)
510 | return nil
511 | })
512 | defer catch.Release()
513 |
514 | r.onWebrtcOffer.Invoke(js.ValueOf(derpPub.String()), map[string]any{
515 | "type": js.ValueOf(offer.Type.String()),
516 | "sdp": js.ValueOf(offer.SDP),
517 | }).Call("then", then).Call("catch", catch)
518 | <-wait
519 | }
520 |
--------------------------------------------------------------------------------
/site/pages/send.tsx:
--------------------------------------------------------------------------------
1 | import { useWasm } from "@/context/wush";
2 | import type { FileTransferState, RtcMetadata } from "@/context/wush";
3 | import { FileUp, Info, X } from "lucide-react";
4 | import { useState, useRef, useEffect, useCallback } from "react";
5 | import { Progress } from "@/components/ui/progress";
6 |
7 | const MAX_BUFFER_SIZE = 16 * 1024 * 1024; // 16 MB
8 | const CHUNK_SIZE = 16 * 1024; // 64 KB
9 | const SPEED_WINDOW_MS = 2000; // 2 second window for averaging
10 |
11 | const formatSpeed = (bytesPerSecond: number): string => {
12 | let speed = bytesPerSecond;
13 | let unit = "B/s";
14 |
15 | if (speed > 1024) {
16 | speed /= 1024;
17 | unit = "KB/s";
18 | }
19 | if (speed > 1024) {
20 | speed /= 1024;
21 | unit = "MB/s";
22 | }
23 |
24 | return `${speed.toFixed(2)} ${unit}`;
25 | };
26 |
27 | const formatETA = (seconds: number): string => {
28 | if (seconds === Number.POSITIVE_INFINITY || Number.isNaN(seconds))
29 | return "calculating...";
30 | if (seconds < 60) return `${Math.round(seconds)}s`;
31 | const minutes = Math.floor(seconds / 60);
32 | const remainingSeconds = Math.round(seconds % 60);
33 | return `${minutes}m ${remainingSeconds}s`;
34 | };
35 |
36 | type RTCStatsReport = {
37 | bytesSent?: number;
38 | timestamp: number;
39 | };
40 |
41 | const FileTransfer: React.FC = () => {
42 | const wasm = useWasm();
43 | const { activeTransfers, setActiveTransfers } = wasm;
44 | const [file, setFile] = useState(null);
45 | const fileInputRef = useRef(null);
46 |
47 | const isSubmitDisabled = !wasm.wush || !wasm.connectedPeer || !file;
48 |
49 | const handleFileInput = (event: React.ChangeEvent) => {
50 | if (event.target.files?.[0]) {
51 | setFile(event.target.files[0]);
52 | }
53 | };
54 |
55 | const [isDragging, setIsDragging] = useState(false);
56 | const [isTransferring, setIsTransferring] = useState(false);
57 |
58 | const handleDragOver = (e: React.DragEvent) => {
59 | e.preventDefault();
60 | setIsDragging(true);
61 | };
62 |
63 | const handleDragLeave = (e: React.DragEvent) => {
64 | e.preventDefault();
65 | setIsDragging(false);
66 | };
67 |
68 | const handleDrop = (e: React.DragEvent) => {
69 | e.preventDefault();
70 | setIsDragging(false);
71 |
72 | if (e.dataTransfer.files?.[0]) {
73 | setFile(e.dataTransfer.files[0]);
74 | }
75 | };
76 |
77 | const dataChannelRef = useRef(null);
78 | const statsIntervalRef = useRef();
79 | const lastStatsRef = useRef();
80 |
81 | const cleanupStatsInterval = useCallback(() => {
82 | if (statsIntervalRef.current) {
83 | setIsTransferring(false);
84 | clearInterval(statsIntervalRef.current);
85 | statsIntervalRef.current = undefined;
86 | }
87 | }, []);
88 |
89 | const sendFile = async (e: React.FormEvent) => {
90 | e.preventDefault();
91 | if (!file) return;
92 |
93 | if (!wasm.rtc.current || !wasm.connectedPeer) {
94 | console.error("No peer");
95 | return;
96 | }
97 |
98 | const rtc = wasm.rtc.current.get(wasm.connectedPeer.id);
99 | if (!rtc || rtc.connectionState !== "connected") {
100 | console.error("No peer");
101 | return;
102 | }
103 |
104 | const transferId = `file_transfer_${file.name}_${Date.now()}`;
105 | const dc = rtc.createDataChannel(transferId);
106 | dataChannelRef.current = dc;
107 | const startingStats = await rtc.getStats();
108 | let startingBytesSent = 0;
109 | // biome-ignore lint/complexity/noForEach: the api requires this
110 | startingStats.forEach((report) => {
111 | if (report.type === "transport") {
112 | startingBytesSent = report.bytesSent || 0;
113 | }
114 | });
115 |
116 | // Create new transfer state
117 | const newTransfer: FileTransferState = {
118 | id: transferId,
119 | file,
120 | progress: 0,
121 | bytesPerSecond: 0,
122 | eta: "calculating...",
123 | dc,
124 | completed: false,
125 | };
126 |
127 | setActiveTransfers((prev) => [...prev, newTransfer]);
128 | setFile(null); // Clear the file input
129 | if (fileInputRef.current) {
130 | fileInputRef.current.value = "";
131 | }
132 | setIsTransferring(true);
133 |
134 | // Setup stats monitoring
135 | cleanupStatsInterval();
136 | statsIntervalRef.current = setInterval(async () => {
137 | const stats = await rtc.getStats();
138 | let dataChannelReport: RTCStatsReport | undefined;
139 |
140 | // biome-ignore lint/complexity/noForEach: the api requires this
141 | stats.forEach((report) => {
142 | if (report.type === "transport") {
143 | dataChannelReport = report;
144 | }
145 | });
146 |
147 | if (!dataChannelReport) {
148 | console.log("no data channel report");
149 | return;
150 | }
151 |
152 | const bytesSent = dataChannelReport.bytesSent || 0;
153 | const timestamp = dataChannelReport.timestamp;
154 |
155 | if (lastStatsRef.current) {
156 | const bytesDiff = bytesSent - (lastStatsRef.current.bytesSent || 0);
157 | const timeDiff = (timestamp - lastStatsRef.current.timestamp) / 1000;
158 |
159 | if (timeDiff > 0 && bytesDiff >= 0) {
160 | const bytesPerSecond = bytesDiff / timeDiff;
161 | const progress = Math.round(
162 | ((bytesSent - startingBytesSent) / file.size) * 100
163 | );
164 | const remainingBytes = file.size - bytesSent;
165 | const eta = formatETA(remainingBytes / bytesPerSecond);
166 |
167 | setActiveTransfers((prev) =>
168 | prev.map((t) =>
169 | t.id === transferId ? { ...t, progress, bytesPerSecond, eta } : t
170 | )
171 | );
172 | }
173 | }
174 |
175 | lastStatsRef.current = { bytesSent, timestamp };
176 | }, 1000);
177 |
178 | // Wait for data channel to open
179 | await new Promise((resolve, reject) => {
180 | const timeout = setTimeout(
181 | () => reject(new Error("Data channel open timeout")),
182 | 10000
183 | );
184 | dc.onopen = () => {
185 | clearTimeout(timeout);
186 | resolve();
187 | };
188 | dc.onerror = (err) => {
189 | clearTimeout(timeout);
190 | reject(err);
191 | };
192 | });
193 |
194 | // Send file metadata
195 | const fileMetadata = {
196 | fileName: file.name,
197 | fileSize: file.size,
198 | };
199 | dc.send(
200 | JSON.stringify({ type: "file_metadata", fileMetadata } as RtcMetadata)
201 | );
202 |
203 | dc.bufferedAmountLowThreshold = 65536;
204 | let offset = 0;
205 | const startTime = performance.now();
206 |
207 | const fileReader = new FileReader();
208 | fileReader.onerror = (error) => console.error("File reading error:", error);
209 |
210 | const sendNextChunk = () => {
211 | if (offset >= file.size) {
212 | return;
213 | }
214 |
215 | const slice = file.slice(offset, offset + CHUNK_SIZE);
216 | fileReader.readAsArrayBuffer(slice);
217 | };
218 |
219 | fileReader.onload = () => {
220 | if (!fileReader.result) {
221 | console.error("FileReader result is null");
222 | return;
223 | }
224 |
225 | const chunk = fileReader.result as ArrayBuffer;
226 | const canSend = () =>
227 | dc.readyState === "open" &&
228 | dc.bufferedAmount + chunk.byteLength < MAX_BUFFER_SIZE;
229 |
230 | const sendChunk = () => {
231 | try {
232 | if (dc.readyState !== "open") {
233 | console.log("Data channel no longer open, stopping transfer");
234 | cleanupStatsInterval();
235 | setActiveTransfers((prev) =>
236 | prev.filter((t) => t.id !== transferId)
237 | );
238 | fileReader.abort();
239 | return;
240 | }
241 |
242 | dc.send(chunk);
243 | offset += chunk.byteLength;
244 |
245 | if (offset >= file.size) {
246 | // Send file complete message before closing the channel
247 | const completeMessage: RtcMetadata = {
248 | type: "file_complete",
249 | fileMetadata: {
250 | fileName: file.name,
251 | fileSize: file.size,
252 | },
253 | };
254 | dc.send(JSON.stringify(completeMessage));
255 |
256 | cleanupStatsInterval();
257 | const endTime = performance.now();
258 | const totalSeconds = (endTime - startTime) / 1000;
259 |
260 | // Get final stats
261 | rtc.getStats().then((stats) => {
262 | const averageSpeed = file.size / totalSeconds;
263 |
264 | setActiveTransfers((prev) =>
265 | prev.map((t) =>
266 | t.id === transferId
267 | ? {
268 | ...t,
269 | progress: 100,
270 | completed: true,
271 | bytesPerSecond: averageSpeed,
272 | eta: `Completed in ${totalSeconds.toFixed(1)}s`,
273 | finalStats: {
274 | duration: totalSeconds,
275 | averageSpeed: averageSpeed / (1024 * 1024), // Convert to MB/s
276 | },
277 | }
278 | : t
279 | )
280 | );
281 | });
282 |
283 | dc.close();
284 | } else {
285 | // Proceed to next chunk
286 | sendNextChunk();
287 | }
288 | } catch (error) {
289 | console.error("Error sending chunk:", error);
290 | cleanupStatsInterval();
291 | setActiveTransfers((prev) => prev.filter((t) => t.id !== transferId));
292 | }
293 | };
294 |
295 | if (canSend()) {
296 | sendChunk();
297 | } else if (dc.readyState === "open") {
298 | // Only set up bufferedamountlow listener if channel is still open
299 | const onBufferedAmountLow = () => {
300 | dc.removeEventListener("bufferedamountlow", onBufferedAmountLow);
301 | if (canSend()) {
302 | sendChunk();
303 | } else {
304 | console.error(
305 | "Buffered amount still too high after 'bufferedamountlow' event"
306 | );
307 | }
308 | };
309 | dc.addEventListener("bufferedamountlow", onBufferedAmountLow);
310 | } else {
311 | console.log("Data channel no longer open, stopping transfer");
312 | cleanupStatsInterval();
313 | setActiveTransfers((prev) => prev.filter((t) => t.id !== transferId));
314 | fileReader.abort();
315 | }
316 | };
317 |
318 | // Start sending
319 | sendNextChunk();
320 | };
321 |
322 | const clearFile = (e: React.MouseEvent) => {
323 | e.stopPropagation(); // Prevent triggering the file input click
324 | setFile(null);
325 | if (fileInputRef.current) {
326 | fileInputRef.current.value = "";
327 | }
328 | };
329 |
330 | const cancelTransfer = (transferId: string) => {
331 | const transfer = activeTransfers.find((t) => t.id === transferId);
332 | if (transfer) {
333 | cleanupStatsInterval();
334 | dataChannelRef.current = null;
335 | try {
336 | console.log("Closing data channel");
337 | transfer.dc.close();
338 | } catch (error) {
339 | console.error("Error closing data channel:", error);
340 | }
341 | setActiveTransfers((prev) => prev.filter((t) => t.id !== transferId));
342 | }
343 | };
344 |
345 | return (
346 |
440 | );
441 | };
442 |
443 | export default FileTransfer;
444 |
--------------------------------------------------------------------------------
/overlay/receive.go:
--------------------------------------------------------------------------------
1 | //go:build !js && !wasm
2 | // +build !js,!wasm
3 |
4 | package overlay
5 |
6 | import (
7 | "context"
8 | "encoding/json"
9 | "errors"
10 | "fmt"
11 | "io"
12 | "log/slog"
13 | "net"
14 | "net/http"
15 | "net/netip"
16 | "os"
17 | "sync"
18 | "sync/atomic"
19 | "time"
20 |
21 | "github.com/pion/stun/v3"
22 | "github.com/pion/webrtc/v4"
23 | "github.com/puzpuzpuz/xsync/v3"
24 | "github.com/schollz/progressbar/v3"
25 | "tailscale.com/derp"
26 | "tailscale.com/derp/derphttp"
27 | "tailscale.com/net/netcheck"
28 | "tailscale.com/net/netmon"
29 | "tailscale.com/net/portmapper"
30 | "tailscale.com/tailcfg"
31 | "tailscale.com/types/key"
32 |
33 | "github.com/coder/pretty"
34 | "github.com/coder/wush/cliui"
35 | )
36 |
37 | func NewReceiveOverlay(logger *slog.Logger, hlog Logf, dm *tailcfg.DERPMap) *Receive {
38 | return &Receive{
39 | Logger: logger,
40 | HumanLogf: hlog,
41 | DerpMap: dm,
42 | SelfPriv: key.NewNode(),
43 | PeerPriv: key.NewNode(),
44 | webrtcConns: xsync.NewMapOf[key.NodePublic, *webrtc.PeerConnection](),
45 | in: make(chan *tailcfg.Node, 8),
46 | out: make(chan *overlayMessage, 8),
47 | }
48 | }
49 |
50 | type Receive struct {
51 | Logger *slog.Logger
52 | HumanLogf Logf
53 | DerpMap *tailcfg.DERPMap
54 | // SelfPriv is the private key that peers will encrypt overlay messages to.
55 | // The public key of this is sent in the auth key.
56 | SelfPriv key.NodePrivate
57 | // PeerPriv is the main auth mechanism used to secure the overlay. Peers are
58 | // sent this private key to encrypt node communication. Leaking this private
59 | // key would allow anyone to connect.
60 | PeerPriv key.NodePrivate
61 |
62 | // stunIP is the STUN address that can be used for P2P overlay
63 | // communication.
64 | stunIP netip.AddrPort
65 | // derpRegionID is the DERP region that can be used for proxied overlay
66 | // communication.
67 | derpRegionID uint16
68 |
69 | webrtcConns *xsync.MapOf[key.NodePublic, *webrtc.PeerConnection]
70 |
71 | lastNode atomic.Pointer[tailcfg.Node]
72 | // in funnels node updates from other peers to us
73 | in chan *tailcfg.Node
74 | // out fans out our node updates to peers
75 | out chan *overlayMessage
76 | }
77 |
78 | func (r *Receive) IPs() []netip.Addr {
79 | i6 := [16]byte{0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0}
80 | i6[15] = 0x01
81 | return []netip.Addr{
82 | // netip.AddrFrom4([4]byte{100, 64, 0, 0}),
83 | netip.AddrFrom16(i6),
84 | }
85 | }
86 |
87 | func getWebRTCConfig() webrtc.Configuration {
88 | defaultConfig := webrtc.Configuration{
89 | ICEServers: []webrtc.ICEServer{
90 | {
91 | URLs: []string{"stun:stun.l.google.com:19302"},
92 | },
93 | },
94 | }
95 |
96 | resp, err := http.Get("https://wush.dev/api/iceConfig")
97 | if err != nil {
98 | fmt.Println("failed to get ice config:", err)
99 | return defaultConfig
100 | }
101 | defer resp.Body.Close()
102 |
103 | var iceConfig struct {
104 | IceServers []struct {
105 | URLs []string `json:"urls"`
106 | Username string `json:"username"`
107 | Credential string `json:"credential"`
108 | } `json:"iceServers"`
109 | }
110 |
111 | if err := json.NewDecoder(resp.Body).Decode(&iceConfig); err != nil {
112 | return defaultConfig
113 | }
114 |
115 | config := webrtc.Configuration{
116 | ICEServers: make([]webrtc.ICEServer, len(iceConfig.IceServers)),
117 | }
118 | for i, server := range iceConfig.IceServers {
119 | config.ICEServers[i] = webrtc.ICEServer{
120 | URLs: server.URLs,
121 | Username: server.Username,
122 | Credential: server.Credential,
123 | CredentialType: webrtc.ICECredentialTypePassword,
124 | }
125 | }
126 |
127 | return config
128 | }
129 |
130 | func (r *Receive) PickDERPHome(ctx context.Context) error {
131 |
132 | nm := netmon.NewStatic()
133 | nc := netcheck.Client{
134 | NetMon: nm,
135 | PortMapper: portmapper.NewClient(func(format string, args ...any) {}, nm, nil, nil, nil),
136 | Logf: func(format string, args ...any) {},
137 | }
138 |
139 | report, err := nc.GetReport(ctx, r.DerpMap, nil)
140 | if err != nil {
141 | return err
142 | }
143 |
144 | if report.PreferredDERP == 0 {
145 | r.HumanLogf("Failed to determine overlay DERP region, defaulting to %s.", cliui.Code("NYC"))
146 | r.derpRegionID = 1
147 | } else {
148 | r.HumanLogf("Picked DERP region %s as overlay home", cliui.Code(r.DerpMap.Regions[report.PreferredDERP].RegionName))
149 | r.derpRegionID = uint16(report.PreferredDERP)
150 | }
151 |
152 | return nil
153 | }
154 |
155 | func (r *Receive) ClientAuth() *ClientAuth {
156 | return &ClientAuth{
157 | OverlayPrivateKey: r.PeerPriv,
158 | ReceiverPublicKey: r.SelfPriv.Public(),
159 | ReceiverStunAddr: r.stunIP,
160 | ReceiverDERPRegionID: r.derpRegionID,
161 | }
162 | }
163 |
164 | func (r *Receive) Recv() <-chan *tailcfg.Node {
165 | return r.in
166 | }
167 |
168 | func (r *Receive) SendTailscaleNodeUpdate(node *tailcfg.Node) {
169 | r.out <- &overlayMessage{
170 | Typ: messageTypeNodeUpdate,
171 | Node: *node.Clone(),
172 | }
173 | }
174 |
175 | // gonna have to do something special for per-peer webrtc connections
176 |
177 | func (r *Receive) ListenOverlaySTUN(ctx context.Context) (<-chan struct{}, error) {
178 | srvAddr, err := net.ResolveUDPAddr("udp4", "stun.l.google.com:19302")
179 | if err != nil {
180 | return nil, fmt.Errorf("resolve google STUN: %w", err)
181 | }
182 |
183 | conn, err := net.ListenUDP("udp4", nil)
184 | if err != nil {
185 | return nil, fmt.Errorf("listen STUN: %w", err)
186 | }
187 |
188 | go func() {
189 | <-ctx.Done()
190 | _ = conn.Close()
191 | }()
192 |
193 | m := stun.MustBuild(stun.TransactionID, stun.BindingRequest)
194 |
195 | restun := time.NewTicker(time.Nanosecond)
196 |
197 | go func() {
198 | for {
199 | select {
200 | case <-ctx.Done():
201 | return
202 |
203 | case <-restun.C:
204 | _, err = conn.WriteToUDP(m.Raw, srvAddr)
205 | if err != nil {
206 | r.HumanLogf("%s Failed to write STUN request on overlay: %s", cliui.Timestamp(time.Now()), err)
207 | }
208 | restun.Reset(30 * time.Second)
209 | }
210 | }
211 | }()
212 |
213 | // node priv -> udp addr
214 | peers := xsync.NewMapOf[key.NodePublic, netip.AddrPort]()
215 |
216 | go func() {
217 | for {
218 |
219 | select {
220 | case <-ctx.Done():
221 | return
222 | case msg := <-r.out:
223 | if msg.Typ == messageTypeNodeUpdate {
224 | r.lastNode.Store(&msg.Node)
225 | }
226 | raw, err := json.Marshal(msg)
227 | if err != nil {
228 | panic("marshal overlay msg: " + err.Error())
229 | }
230 |
231 | sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw)
232 | peers.Range(func(_ key.NodePublic, addr netip.AddrPort) bool {
233 | _, err := conn.WriteToUDPAddrPort(sealed, addr)
234 | if err != nil {
235 | r.HumanLogf("%s Failed to send updated node over udp: %s", cliui.Timestamp(time.Now()), err)
236 | return false
237 | }
238 | return true
239 | })
240 | }
241 | }
242 | }()
243 |
244 | ipChan := make(chan struct{})
245 |
246 | go func() {
247 | var closeIPChanOnce sync.Once
248 |
249 | for {
250 | buf := make([]byte, 4<<10)
251 | n, addr, err := conn.ReadFromUDPAddrPort(buf)
252 | if err != nil {
253 | r.Logger.Error("read from STUN; exiting", "err", err)
254 | return
255 | }
256 |
257 | buf = buf[:n]
258 | if stun.IsMessage(buf) {
259 | m := new(stun.Message)
260 | m.Raw = buf
261 |
262 | if err := m.Decode(); err != nil {
263 | r.Logger.Error("decode STUN message; exiting", "err", err)
264 | return
265 | }
266 |
267 | var xorAddr stun.XORMappedAddress
268 | if err := xorAddr.GetFrom(m); err != nil {
269 | r.Logger.Error("decode STUN xor mapped addr; exiting", "err", err)
270 | return
271 | }
272 |
273 | stunAddr, ok := netip.AddrFromSlice(xorAddr.IP)
274 | if !ok {
275 | r.Logger.Error("convert STUN xor mapped addr", "ip", xorAddr.IP.String())
276 | continue
277 | }
278 | stunAddrPort := netip.AddrPortFrom(stunAddr, uint16(xorAddr.Port))
279 |
280 | // our first STUN response
281 | if !r.stunIP.IsValid() {
282 | r.HumanLogf("STUN address is %s", cliui.Code(stunAddrPort.String()))
283 | }
284 |
285 | if r.stunIP.IsValid() && r.stunIP.Compare(stunAddrPort) != 0 {
286 | r.HumanLogf(pretty.Sprintf(cliui.DefaultStyles.Warn, "STUN address changed, this may cause issues; %s->%s", r.stunIP.String(), stunAddrPort.String()))
287 | }
288 | r.stunIP = stunAddrPort
289 | closeIPChanOnce.Do(func() {
290 | close(ipChan)
291 | })
292 | continue
293 | }
294 |
295 | res, key, err := r.handleNextMessage(key.NodePublic{}, buf, "STUN")
296 | if err != nil {
297 | r.HumanLogf("Failed to handle overlay message: %s", err.Error())
298 | continue
299 | }
300 |
301 | peers.Store(key, addr)
302 |
303 | if res != nil {
304 | _, err = conn.WriteToUDPAddrPort(res, addr)
305 | if err != nil {
306 | r.HumanLogf("Failed to send overlay response over STUN: %s", err.Error())
307 | return
308 | }
309 | }
310 | }
311 | }()
312 | return ipChan, nil
313 | }
314 |
315 | func (r *Receive) ListenOverlayDERP(ctx context.Context) error {
316 | c := derphttp.NewRegionClient(r.SelfPriv, func(format string, args ...any) {}, netmon.NewStatic(), func() *tailcfg.DERPRegion {
317 | return r.DerpMap.Regions[int(r.derpRegionID)]
318 | })
319 |
320 | err := c.Connect(ctx)
321 | if err != nil {
322 | return err
323 | }
324 |
325 | // node priv -> derp priv
326 | peers := xsync.NewMapOf[key.NodePublic, key.NodePublic]()
327 |
328 | go func() {
329 | for {
330 |
331 | select {
332 | case <-ctx.Done():
333 | return
334 | case msg := <-r.out:
335 | if msg.Typ == messageTypeNodeUpdate {
336 | r.lastNode.Store(&msg.Node)
337 | }
338 | raw, err := json.Marshal(msg)
339 | if err != nil {
340 | panic("marshal overlay msg: " + err.Error())
341 | }
342 |
343 | sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw)
344 | peers.Range(func(_, derpKey key.NodePublic) bool {
345 | err = c.Send(derpKey, sealed)
346 | if err != nil {
347 | r.HumanLogf("Send updated node over DERP: %s", err)
348 | return false
349 | }
350 | return true
351 | })
352 | }
353 | }
354 | }()
355 |
356 | for {
357 | msg, err := c.Recv()
358 | if err != nil {
359 | return err
360 | }
361 |
362 | switch msg := msg.(type) {
363 | case derp.ReceivedPacket:
364 | res, key, err := r.handleNextMessage(msg.Source, msg.Data, "DERP")
365 | if err != nil {
366 | r.HumanLogf("Failed to handle overlay message from %s: %s", msg.Source.ShortString(), err.Error())
367 | continue
368 | }
369 |
370 | peers.Store(key, msg.Source)
371 |
372 | if res != nil {
373 | err = c.Send(msg.Source, res)
374 | if err != nil {
375 | r.HumanLogf("Failed to send overlay response over derp: %s", err.Error())
376 | return err
377 | }
378 | }
379 | }
380 | }
381 | }
382 |
383 | func (r *Receive) handleNextMessage(src key.NodePublic, msg []byte, system string) (resRaw []byte, nodeKey key.NodePublic, _ error) {
384 | cleartext, ok := r.SelfPriv.OpenFrom(r.PeerPriv.Public(), msg)
385 | if !ok {
386 | return nil, key.NodePublic{}, errors.New("message failed decryption")
387 | }
388 |
389 | var ovMsg overlayMessage
390 | err := json.Unmarshal(cleartext, &ovMsg)
391 | if err != nil {
392 | panic("unmarshal node: " + err.Error())
393 | }
394 |
395 | res := overlayMessage{}
396 | switch ovMsg.Typ {
397 | case messageTypePing:
398 | res.Typ = messageTypePong
399 | case messageTypePong:
400 | // do nothing
401 | case messageTypeHello:
402 | res.Typ = messageTypeHelloResponse
403 | username := "unknown"
404 | if u := ovMsg.HostInfo.Username; u != "" {
405 | username = u
406 | }
407 | hostname := "unknown"
408 | if h := ovMsg.HostInfo.Hostname; h != "" {
409 | hostname = h
410 | }
411 | if lastNode := r.lastNode.Load(); lastNode != nil {
412 | res.Node = *lastNode
413 | }
414 |
415 | if ovMsg.WebrtcDescription != nil {
416 | r.setupWebrtcConnection(src, &res, *ovMsg.WebrtcDescription)
417 | }
418 |
419 | r.HumanLogf("%s Received connection request over %s from %s", cliui.Timestamp(time.Now()), system, cliui.Keyword(fmt.Sprintf("%s@%s", username, hostname)))
420 | case messageTypeNodeUpdate:
421 | r.Logger.Debug("received updated node", slog.String("node_key", ovMsg.Node.Key.String()))
422 | r.in <- &ovMsg.Node
423 | res.Typ = messageTypeNodeUpdate
424 | if lastNode := r.lastNode.Load(); lastNode != nil {
425 | res.Node = *lastNode
426 | }
427 |
428 | case messageTypeWebRTCCandidate:
429 | pc, ok := r.webrtcConns.Load(src)
430 | if !ok {
431 | fmt.Println("got candidate for unknown connection")
432 | break
433 | }
434 |
435 | err := pc.AddICECandidate(*ovMsg.WebrtcCandidate)
436 | if err != nil {
437 | fmt.Println("failed to add ice candidate:", err)
438 | }
439 | }
440 |
441 | if res.Typ == 0 {
442 | return nil, ovMsg.Node.Key, nil
443 | }
444 |
445 | raw, err := json.Marshal(res)
446 | if err != nil {
447 | panic("marshal node: " + err.Error())
448 | }
449 |
450 | sealed := r.SelfPriv.SealTo(r.PeerPriv.Public(), raw)
451 | return sealed, ovMsg.Node.Key, nil
452 | }
453 |
454 | func (r *Receive) setupWebrtcConnection(src key.NodePublic, res *overlayMessage, offer webrtc.SessionDescription) {
455 | // Configure larger buffer sizes
456 | settingEngine := webrtc.SettingEngine{}
457 | // Set maximum message size to 16MB
458 | settingEngine.SetSCTPMaxReceiveBufferSize(64 * 1024 * 1024)
459 |
460 | api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
461 |
462 | // Use the custom API to create the peer connection
463 | peerConnection, err := api.NewPeerConnection(getWebRTCConfig())
464 | if err != nil {
465 | panic(err)
466 | }
467 |
468 | peerConnection.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
469 | switch s {
470 | case webrtc.PeerConnectionStateConnected:
471 | case webrtc.PeerConnectionStateDisconnected:
472 | case webrtc.PeerConnectionStateFailed:
473 | case webrtc.PeerConnectionStateClosed:
474 | }
475 | })
476 |
477 | peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
478 | // Increase buffer sizes
479 | d.SetBufferedAmountLowThreshold(65535)
480 |
481 | var (
482 | fi *os.File
483 | bar *progressbar.ProgressBar
484 | mw io.Writer
485 | fiSize int
486 | read int
487 | )
488 |
489 | d.OnMessage(func(msg webrtc.DataChannelMessage) {
490 | if msg.IsString {
491 | meta := RtcMetadata{}
492 |
493 | err := json.Unmarshal(msg.Data, &meta)
494 | if err != nil {
495 | fmt.Println("failed to unmarshal file metadata:")
496 | d.Close()
497 | return
498 | }
499 |
500 | if meta.Type == RtcMetadataTypeFileMetadata {
501 | fiSize = meta.FileMetadata.FileSize
502 | fi, err = os.OpenFile(meta.FileMetadata.FileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
503 | if err != nil {
504 | fmt.Println("failed to open file", err)
505 | }
506 |
507 | bar = progressbar.DefaultBytes(
508 | int64(fiSize),
509 | fmt.Sprintf("Downloading %q", meta.FileMetadata.FileName),
510 | )
511 | mw = io.MultiWriter(fi, bar)
512 | }
513 |
514 | } else {
515 | read += len(msg.Data)
516 | if fi == nil {
517 | fmt.Println("Error: Received binary data before file was opened")
518 | d.Close()
519 | return
520 | }
521 |
522 | _, err := mw.Write(msg.Data)
523 | if err != nil {
524 | fmt.Printf("Failed to write file data: %v\n", err)
525 | d.Close()
526 | return
527 | }
528 |
529 | if read >= fiSize {
530 | bar.Close()
531 | fmt.Printf("Successfully wrote file %s (%d bytes)\n", fi.Name(), read)
532 | err := fi.Close()
533 | if err != nil {
534 | fmt.Printf("Error closing file: %v\n", err)
535 | }
536 | fi = nil
537 | bar = nil
538 | mw = nil
539 | }
540 | }
541 | })
542 | })
543 |
544 | peerConnection.OnICECandidate(func(i *webrtc.ICECandidate) {
545 | if i == nil {
546 | return
547 | }
548 | ic := i.ToJSON()
549 |
550 | r.out <- &overlayMessage{
551 | Typ: messageTypeWebRTCCandidate,
552 | WebrtcCandidate: &ic,
553 | }
554 | })
555 |
556 | err = peerConnection.SetRemoteDescription(offer)
557 | if err != nil {
558 | fmt.Println("failed to set remote description:", err)
559 | }
560 |
561 | answer, err := peerConnection.CreateAnswer(nil)
562 | if err != nil {
563 | fmt.Println("failed to create answer:", err)
564 | }
565 |
566 | err = peerConnection.SetLocalDescription(answer)
567 | if err != nil {
568 | fmt.Println("failed to set local description:", err)
569 | }
570 |
571 | res.WebrtcDescription = &answer
572 |
573 | r.webrtcConns.Store(src, peerConnection)
574 | }
575 |
--------------------------------------------------------------------------------
/site/context/wush.tsx:
--------------------------------------------------------------------------------
1 | import "../wasm/wasm_exec.js";
2 | import wasmModule from "../wasm/main.wasm";
3 |
4 | import type React from "react";
5 | import type { ReactNode } from "react";
6 | import { createContext, useContext, useEffect, useState, useRef } from "react";
7 |
8 | const iceServers = [
9 | {
10 | urls: "stun:stun.l.google.com:19302",
11 | },
12 | ...(process.env.NEXT_PUBLIC_TURN_SERVER_URL
13 | ? [
14 | {
15 | urls: process.env.NEXT_PUBLIC_TURN_SERVER_URL,
16 | username: process.env.NEXT_PUBLIC_TURN_USERNAME ?? "",
17 | credential: process.env.NEXT_PUBLIC_TURN_CREDENTIAL ?? "",
18 | credentialType: "password",
19 | },
20 | ]
21 | : []),
22 | ];
23 |
24 | interface WasmContextProps {
25 | wush: React.MutableRefObject;
26 | loading: boolean;
27 | connecting: boolean;
28 | error?: string;
29 |
30 | connectedPeer?: Peer;
31 | peers: Peer[];
32 |
33 | rtc: React.MutableRefObject | |