From a589341214a1e035b6ce2b2d79870e591a25ccca Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Tue, 16 Jun 2026 15:00:43 -0400 Subject: [PATCH] feat: open proxy token file --- README.md | 38 ++++++++++++++++++++------------------ client.go | 8 +++++++- main.go | 5 +++-- proto.go | 24 ++++++++++++++++++++++-- proto_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ server.go | 8 +++++++- 6 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 proto_test.go diff --git a/README.md b/README.md index ba7931b..c911e07 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,17 @@ open on your laptop instead of failing in a headless shell. - 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*. + `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. | +| 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 @@ -46,7 +46,8 @@ Produces one static binary used for both roles. ```sh # Optional but recommended if the VM is multi-user: require a shared secret. -export OPEN_PROXY_TOKEN=$(openssl rand -hex 16) +openssl rand -hex 16 > ~/.config/open-proxy-token +export OPEN_PROXY_TOKEN_FILE=~/.config/open-proxy-token open-proxy serve # listens on 127.0.0.1:7777 ``` @@ -65,14 +66,14 @@ to the GUI. ln -sf open-proxy ~/.local/bin/open # convenience / macOS habit ``` - `~/.local/bin` must come *before* `/usr/bin` in `$PATH`. The binary checks + `~/.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): +2. If you set a token on the host, set the same one on the VM (e.g. via a + secret-managed file): ```sh - export OPEN_PROXY_TOKEN= + export OPEN_PROXY_TOKEN_FILE=~/.config/open-proxy-token ``` ## The tunnel @@ -110,10 +111,10 @@ 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 +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` +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. @@ -127,11 +128,12 @@ 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). | +| 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_TOKEN_FILE` | _(unset)_ | File containing the shared secret, used when `OPEN_PROXY_TOKEN` is unset. | +| `OPEN_PROXY_MAXSIZE` | `104857600` (100M) | Max file transfer size in bytes (server-side). | ## Security notes diff --git a/client.go b/client.go index b9ac931..98360f0 100644 --- a/client.go +++ b/client.go @@ -29,9 +29,15 @@ func runClient(args []string, openerName string) int { return 2 } + token, err := envToken() + if err != nil { + fmt.Fprintf(os.Stderr, "open: %v\n", err) + return 1 + } + c := &client{ base: "http://" + envAddr(), - token: envToken(), + token: token, } exit := 0 diff --git a/main.go b/main.go index 6684177..e01627e 100644 --- a/main.go +++ b/main.go @@ -56,8 +56,9 @@ Usage: 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) + OPEN_PROXY_TOKEN shared secret; if set on the server, clients must match + OPEN_PROXY_TOKEN_FILE file containing the shared secret, used when OPEN_PROXY_TOKEN is unset + 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/proto.go b/proto.go index be19b16..ef5b977 100644 --- a/proto.go +++ b/proto.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "os" "strconv" + "strings" ) const ( @@ -20,8 +22,26 @@ func envAddr() string { return defaultAddr } -func envToken() string { - return os.Getenv("OPEN_PROXY_TOKEN") +func envToken() (string, error) { + if v := os.Getenv("OPEN_PROXY_TOKEN"); v != "" { + return v, nil + } + + path := os.Getenv("OPEN_PROXY_TOKEN_FILE") + if path == "" { + return "", nil + } + + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read OPEN_PROXY_TOKEN_FILE %q: %w", path, err) + } + + token := strings.TrimRight(string(b), "\r\n") + if token == "" { + return "", fmt.Errorf("OPEN_PROXY_TOKEN_FILE %q is empty", path) + } + return token, nil } func envMaxSize() int64 { diff --git a/proto_test.go b/proto_test.go new file mode 100644 index 0000000..0504872 --- /dev/null +++ b/proto_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnvTokenPrefersTokenEnv(t *testing.T) { + t.Setenv("OPEN_PROXY_TOKEN", "env-token") + t.Setenv("OPEN_PROXY_TOKEN_FILE", filepath.Join(t.TempDir(), "missing")) + + token, err := envToken() + if err != nil { + t.Fatalf("envToken() error = %v", err) + } + if token != "env-token" { + t.Fatalf("envToken() = %q, want %q", token, "env-token") + } +} + +func TestEnvTokenReadsTokenFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte("file-token\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OPEN_PROXY_TOKEN_FILE", path) + + token, err := envToken() + if err != nil { + t.Fatalf("envToken() error = %v", err) + } + if token != "file-token" { + t.Fatalf("envToken() = %q, want %q", token, "file-token") + } +} + +func TestEnvTokenErrorsOnEmptyTokenFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte("\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OPEN_PROXY_TOKEN_FILE", path) + + if token, err := envToken(); err == nil { + t.Fatalf("envToken() = %q, nil error; want error", token) + } +} diff --git a/server.go b/server.go index fe4de9e..6a61fc4 100644 --- a/server.go +++ b/server.go @@ -19,7 +19,13 @@ func runServer(argv []string) int { return 2 } - srv := &server{token: envToken(), maxSize: envMaxSize()} + token, err := envToken() + if err != nil { + fmt.Fprintf(os.Stderr, "open-proxy serve: %v\n", err) + return 1 + } + + srv := &server{token: token, maxSize: envMaxSize()} mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {