commit 2cedcf448c984192d043b82ec9d614a349b0450b Author: Evan Reichard Date: Tue Jun 9 10:47:39 2026 -0400 initial commit 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 +}