From 2cedcf448c984192d043b82ec9d614a349b0450b Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Tue, 9 Jun 2026 10:47:39 -0400 Subject: [PATCH] initial commit --- .gitignore | 3 ++ README.md | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++ client.go | 108 ++++++++++++++++++++++++++++++++++++++++ flake.lock | 61 +++++++++++++++++++++++ flake.nix | 33 +++++++++++++ go.mod | 3 ++ main.go | 65 ++++++++++++++++++++++++ opener.go | 102 ++++++++++++++++++++++++++++++++++++++ proto.go | 34 +++++++++++++ server.go | 119 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 670 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 main.go create mode 100644 opener.go create mode 100644 proto.go create mode 100644 server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47ef39e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/open-proxy +/_scratch/ +/.direnv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba7931b --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# open-proxy + +Forward `open` / `xdg-open` from a remote VM to the host machine that actually +has the browser and GUI apps. + +You SSH/mosh into a VM, run `open report.html` or `open https://…`, and it pops +open on your laptop instead of failing in a headless shell. + +## How it works + +``` + VM SSH reverse tunnel Host (your laptop) + ┌─────────────────┐ 127.0.0.1:7777 ───▶ 127.0.0.1:7777 + │ open │ HTTP POST ─────────────────────▶ │ open-proxy serve │ + │ (open-proxy) │ │ → xdg-open/open │ + └─────────────────┘ └──────────────────┘ +``` + +- The **host** runs `open-proxy serve`, listening on `127.0.0.1:7777`. +- The **VM** runs the `open-proxy open` client, which connects to + `127.0.0.1:7777` *on the VM*. +- An SSH **reverse tunnel** maps the VM's `127.0.0.1:7777` back to the host's + server, so nothing is exposed on the network. + +Per argument, the client does the cheapest thing that works: + +| Argument | Behavior | +| ---------------------------- | --------------------------------------------------------------- | +| Existing **regular file** | Streams the bytes to the host; host writes it to a temp dir (keeping the filename/extension) and opens that copy. | +| **URL** (`https://…`, `mailto:…`) | Forwarded as a string; host opens it. | +| Anything else | Forwarded as a string; host's `open` decides. | + +> **Best effort, by design.** A copied `report.html` that references sibling +> assets (`./style.css`, images) won't find them on the host — only the single +> file is copied. URLs and self-contained files work great. + +## Build + +```sh +go build -o open-proxy . +``` + +Produces one static binary used for both roles. + +## Host setup + +```sh +# Optional but recommended if the VM is multi-user: require a shared secret. +export OPEN_PROXY_TOKEN=$(openssl rand -hex 16) + +open-proxy serve # listens on 127.0.0.1:7777 +``` + +Run it under your login session (launchd/systemd user service) so it has access +to the GUI. + +## VM setup + +1. Drop the binary on the VM and symlink it **early on `$PATH`** under the + opener names so it shadows the system ones transparently: + + ```sh + install -m755 open-proxy ~/.local/bin/open-proxy + ln -sf open-proxy ~/.local/bin/xdg-open # most Linux apps call this + ln -sf open-proxy ~/.local/bin/open # convenience / macOS habit + ``` + + `~/.local/bin` must come *before* `/usr/bin` in `$PATH`. The binary checks + `argv[0]`, so a symlink named `xdg-open` or `open` runs in client mode. + +2. If you set a token on the host, set the same one on the VM (e.g. in your + shell rc): + + ```sh + export OPEN_PROXY_TOKEN= + ``` + +## The tunnel + +The client just talks to `127.0.0.1:7777` on the VM, so you need a reverse +tunnel from the host: + +```sh +ssh -R 7777:127.0.0.1:7777 vm +``` + +Or persist it in `~/.ssh/config`: + +``` +Host vm + RemoteForward 7777 127.0.0.1:7777 +``` + +### mosh + +mosh does **not** forward ports itself. Keep a plain SSH connection alive +alongside mosh to carry the tunnel: + +```sh +ssh -fN -R 7777:127.0.0.1:7777 vm # background the tunnel +mosh vm # then mosh as usual +``` + +(Or use `autossh` to keep the tunnel up.) + +## Fallback when the host is unreachable + +If the tunnel is down or the host server isn't running, the client doesn't just +fail — it falls back to the **real** opener. It walks `$PATH` for the next +`xdg-open`/`open` that isn't this binary and runs that, so a shadowed VM still +behaves sanely offline. + +Fallback fires only on a *connection* failure. An HTTP rejection (e.g. a 403 +from a token mismatch) is surfaced as an error, not silently opened locally. + +Both the fallback and the host's own opener resolve the *real* `xdg-open`/`open` +by skipping this binary on `$PATH`, so it's safe to shadow the opener names on +the host too (e.g. a box that is both host and VM) without recursing. + +## Usage + +```sh +open https://example.com # opens in host browser +open report.pdf # copied to host, opens in host PDF viewer +open ./diagram.png # copied to host, opens in host image viewer +``` + +## Configuration + +| Variable | Default | Meaning | +| -------------------- | ------------------ | ---------------------------------------------------- | +| `OPEN_PROXY_ADDR` | `127.0.0.1:7777` | Client target / server listen address. | +| `OPEN_PROXY_TOKEN` | _(unset)_ | Shared secret; if set on the server, clients must match. | +| `OPEN_PROXY_MAXSIZE` | `104857600` (100M) | Max file transfer size in bytes (server-side). | + +## Security notes + +- The server only binds loopback; the SSH tunnel is the trust boundary. +- On a shared VM, any local user can reach the forwarded port — set + `OPEN_PROXY_TOKEN` to gate it. +- The server writes received files to a temp dir and opens them. Only forward to + hosts/VMs you trust. diff --git a/client.go b/client.go new file mode 100644 index 0000000..b9ac931 --- /dev/null +++ b/client.go @@ -0,0 +1,108 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// errHostUnreachable marks a transport-level failure (tunnel down, host server +// not running) as distinct from an HTTP-level rejection, so the client can fall +// back to the local opener only when forwarding genuinely failed. +var errHostUnreachable = errors.New("host unreachable") + +var httpClient = &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, + }, +} + +func runClient(args []string, openerName string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "open: missing operand") + return 2 + } + + c := &client{ + base: "http://" + envAddr(), + token: envToken(), + } + + exit := 0 + for _, arg := range args { + err := c.openOne(arg) + if errors.Is(err, errHostUnreachable) { + if ferr := localOpen(openerName, arg); ferr != nil { + fmt.Fprintf(os.Stderr, "open: %s: host unreachable, local fallback failed: %v\n", arg, ferr) + exit = 1 + } + continue + } + if err != nil { + fmt.Fprintf(os.Stderr, "open: %s: %v\n", arg, err) + exit = 1 + } + } + return exit +} + +type client struct { + base string + token string +} + +func (c *client) openOne(arg string) error { + // Existing local file takes priority: copy it so self-contained files render + // on the host. Directories and missing paths fall through to string/URL. + if info, err := os.Stat(arg); err == nil && info.Mode().IsRegular() { + return c.sendFile(arg) + } + return c.sendString(arg) +} + +func (c *client) sendFile(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + req, err := http.NewRequest(http.MethodPost, c.base+"/file", f) + if err != nil { + return err + } + req.Header.Set(fileNameHeader, filepath.Base(path)) + req.Header.Set("Content-Type", "application/octet-stream") + return c.do(req) +} + +func (c *client) sendString(s string) error { + req, err := http.NewRequest(http.MethodPost, c.base+"/open", strings.NewReader(s)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "text/plain") + return c.do(req) +} + +func (c *client) do(req *http.Request) error { + if c.token != "" { + req.Header.Set(tokenHeader, c.token) + } + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("%w: %s: %v", errHostUnreachable, c.base, err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("host returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return nil +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..dedd329 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1777578337, + "narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "15f4ee454b1dce334612fa6843b3e05cf546efab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..caeec10 --- /dev/null +++ b/flake.nix @@ -0,0 +1,33 @@ +{ + description = "Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self + , nixpkgs + , flake-utils + , + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + go + gopls + golangci-lint + ]; + shellHook = '' + export PATH=$PATH:~/go/bin + ''; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c42dfb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module open-proxy + +go 1.26 diff --git a/main.go b/main.go new file mode 100644 index 0000000..6684177 --- /dev/null +++ b/main.go @@ -0,0 +1,65 @@ +// Command open-proxy forwards `open`/`xdg-open` requests from a remote VM to +// the host machine that actually has the browser/GUI. +// +// On the host: open-proxy serve +// On the VM: open-proxy open ... +// +// The VM reaches the host's server over an SSH reverse tunnel, e.g. +// +// ssh -R 7777:127.0.0.1:7777 vm +// +// so the VM's 127.0.0.1:7777 maps to the host's server. +// +// As a convenience, when the binary is invoked under the name "open" (e.g. via +// a symlink early on $PATH), it behaves exactly like `open-proxy open`. +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +func main() { + // Shadowed-name mode: when symlinked to `open` or `xdg-open`, act as the + // client and fall back to that same real opener if the host is unreachable. + if base := filepath.Base(os.Args[0]); base == "open" || base == "xdg-open" { + os.Exit(runClient(os.Args[1:], base)) + } + + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + switch os.Args[1] { + case "serve": + os.Exit(runServer(os.Args[2:])) + case "open": + os.Exit(runClient(os.Args[2:], defaultOpenerName())) + case "-h", "--help", "help": + usage() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "open-proxy: unknown command %q\n\n", os.Args[1]) + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `open-proxy - forward `+"`open`"+` from a remote VM to the host + +Usage: + open-proxy serve [-addr 127.0.0.1:7777] + open-proxy open ... + +Environment: + OPEN_PROXY_ADDR client target / server listen address (default 127.0.0.1:7777) + OPEN_PROXY_TOKEN shared secret; if set on the server, clients must match + OPEN_PROXY_MAXSIZE max file transfer size in bytes (default 104857600) + +Tip: symlink the binary to "open" and/or "xdg-open" early on the VM's $PATH. + When the host is unreachable, it falls back to the real opener on $PATH. +`) +} diff --git a/opener.go b/opener.go new file mode 100644 index 0000000..21a4904 --- /dev/null +++ b/opener.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// hostOpen launches the platform's "open" handler for target (a URL, file path, +// or opaque string) without blocking. Errors after launch are logged, not +// returned, since the GUI handler is fire-and-forget. +// +// It resolves the opener via realOpenerPath so a host that also shadows +// `xdg-open`/`open` with this binary doesn't recurse back into client mode. +func hostOpen(target string) error { + name, args := openCommand(target) + bin, err := realOpenerPath(name) + if err != nil { + return err + } + cmd := exec.Command(bin, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("start %s: %w", bin, err) + } + go func() { + if err := cmd.Wait(); err != nil { + log.Printf("open %q exited: %v", target, err) + } + }() + return nil +} + +func openCommand(target string) (string, []string) { + switch runtime.GOOS { + case "darwin": + return "open", []string{target} + case "windows": + return "rundll32", []string{"url.dll,FileProtocolHandler", target} + default: + return "xdg-open", []string{target} + } +} + +// defaultOpenerName is the platform's native opener, used as the fallback target +// when we were invoked as `open-proxy open` rather than under a shadowed name. +func defaultOpenerName() string { + if runtime.GOOS == "darwin" { + return "open" + } + return "xdg-open" +} + +// localOpen runs the real opener (the next `name` on $PATH that isn't us) for +// arg. Used as the client-side fallback when the host is unreachable. +func localOpen(name, arg string) error { + bin, err := realOpenerPath(name) + if err != nil { + return err + } + cmd := exec.Command(bin, arg) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} + +// realOpenerPath finds an executable named `name` on $PATH that is not this +// binary (so a symlink shadowing `xdg-open`/`open` doesn't recurse into us). +func realOpenerPath(name string) (string, error) { + self := selfFileInfo() + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + if dir == "" { + continue + } + cand := filepath.Join(dir, name) + info, err := os.Stat(cand) // follows symlinks + if err != nil || info.IsDir() || info.Mode()&0o111 == 0 { + continue + } + // Compare by file identity (device+inode), not path string: a symlink or + // hardlink pointing back at this binary is recognized as us regardless of + // how its path is spelled or whether $PATH entries are relative. + if self != nil && os.SameFile(self, info) { + continue + } + return cand, nil + } + return "", fmt.Errorf("no real %q found on PATH", name) +} + +func selfFileInfo() os.FileInfo { + exe, err := os.Executable() + if err != nil { + return nil + } + info, err := os.Stat(exe) + if err != nil { + return nil + } + return info +} diff --git a/proto.go b/proto.go new file mode 100644 index 0000000..be19b16 --- /dev/null +++ b/proto.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + "strconv" +) + +const ( + defaultAddr = "127.0.0.1:7777" + defaultMaxSize = 100 << 20 // 100 MiB + tokenHeader = "X-Open-Token" + fileNameHeader = "X-Open-Filename" + pathHeaderField = "X-Open-Host-Path" +) + +func envAddr() string { + if v := os.Getenv("OPEN_PROXY_ADDR"); v != "" { + return v + } + return defaultAddr +} + +func envToken() string { + return os.Getenv("OPEN_PROXY_TOKEN") +} + +func envMaxSize() int64 { + if v := os.Getenv("OPEN_PROXY_MAXSIZE"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + return n + } + } + return defaultMaxSize +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..fe4de9e --- /dev/null +++ b/server.go @@ -0,0 +1,119 @@ +package main + +import ( + "crypto/subtle" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" +) + +func runServer(argv []string) int { + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + addr := fs.String("addr", envAddr(), "listen address") + if err := fs.Parse(argv); err != nil { + return 2 + } + + srv := &server{token: envToken(), maxSize: envMaxSize()} + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "ok\n") + }) + mux.HandleFunc("POST /open", srv.handleOpen) + mux.HandleFunc("POST /file", srv.handleFile) + + log.Printf("open-proxy serving on %s (token=%v)", *addr, srv.token != "") + if err := http.ListenAndServe(*addr, srv.auth(mux)); err != nil { + fmt.Fprintf(os.Stderr, "open-proxy serve: %v\n", err) + return 1 + } + return 0 +} + +type server struct { + token string + maxSize int64 +} + +func (s *server) auth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.token != "" { + got := r.Header.Get(tokenHeader) + if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) +} + +func (s *server) handleOpen(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + target := strings.TrimSpace(string(body)) + if target == "" { + http.Error(w, "empty target", http.StatusBadRequest) + return + } + if err := hostOpen(target); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("open url/string: %s", target) + _, _ = io.WriteString(w, "ok\n") +} + +func (s *server) handleFile(w http.ResponseWriter, r *http.Request) { + name := sanitizeName(r.Header.Get(fileNameHeader)) + + dir, err := os.MkdirTemp("", "open-proxy-") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + dst := filepath.Join(dir, name) + + f, err := os.Create(dst) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + n, err := io.Copy(f, io.LimitReader(r.Body, s.maxSize)) + cerr := f.Close() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if cerr != nil { + http.Error(w, cerr.Error(), http.StatusInternalServerError) + return + } + + if err := hostOpen(dst); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("open file: %s (%d bytes)", dst, n) + w.Header().Set(pathHeaderField, dst) + _, _ = fmt.Fprintf(w, "%s\n", dst) +} + +// sanitizeName reduces a client-supplied filename to a safe basename, guarding +// against path traversal and empty values. +func sanitizeName(name string) string { + name = filepath.Base(filepath.FromSlash(name)) + if name == "" || name == "." || name == string(filepath.Separator) || name == ".." { + return "opened-file" + } + return name +}