initial commit

This commit is contained in:
2026-06-09 10:47:39 -04:00
commit 2cedcf448c
10 changed files with 670 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/open-proxy
/_scratch/
/.direnv/

142
README.md Normal file
View File

@@ -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 <arg> │ 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=<same value as host>
```
## 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.

108
client.go Normal file
View File

@@ -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
}

61
flake.lock generated Normal file
View File

@@ -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
}

33
flake.nix Normal file
View File

@@ -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
'';
};
}
);
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module open-proxy
go 1.26

65
main.go Normal file
View File

@@ -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 <file|url|string>...
//
// 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 <file|url|string>...
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.
`)
}

102
opener.go Normal file
View File

@@ -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
}

34
proto.go Normal file
View File

@@ -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
}

119
server.go Normal file
View File

@@ -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
}