feat: open proxy token file

This commit is contained in:
2026-06-16 15:00:43 -04:00
parent 2cedcf448c
commit a589341214
6 changed files with 107 additions and 24 deletions

View File

@@ -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 **host** runs `open-proxy serve`, listening on `127.0.0.1:7777`.
- The **VM** runs the `open-proxy open` client, which connects to - 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 - 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. server, so nothing is exposed on the network.
Per argument, the client does the cheapest thing that works: Per argument, the client does the cheapest thing that works:
| Argument | Behavior | | 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. | | 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. | | **URL** (`https://…`, `mailto:…`) | Forwarded as a string; host opens it. |
| Anything else | Forwarded as a string; host's `open` decides. | | Anything else | Forwarded as a string; host's `open` decides. |
> **Best effort, by design.** A copied `report.html` that references sibling > **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 > 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 ```sh
# Optional but recommended if the VM is multi-user: require a shared secret. # 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 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 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. `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 2. If you set a token on the host, set the same one on the VM (e.g. via a
shell rc): secret-managed file):
```sh ```sh
export OPEN_PROXY_TOKEN=<same value as host> export OPEN_PROXY_TOKEN_FILE=~/.config/open-proxy-token
``` ```
## The tunnel ## 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 `xdg-open`/`open` that isn't this binary and runs that, so a shadowed VM still
behaves sanely offline. 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. 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 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. 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 ## Configuration
| Variable | Default | Meaning | | Variable | Default | Meaning |
| -------------------- | ------------------ | ---------------------------------------------------- | | ----------------------- | ------------------ | ------------------------------------------------------------------------- |
| `OPEN_PROXY_ADDR` | `127.0.0.1:7777` | Client target / server listen address. | | `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` | _(unset)_ | Shared secret; if set on the server, clients must match. |
| `OPEN_PROXY_MAXSIZE` | `104857600` (100M) | Max file transfer size in bytes (server-side). | | `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 ## Security notes

View File

@@ -29,9 +29,15 @@ func runClient(args []string, openerName string) int {
return 2 return 2
} }
token, err := envToken()
if err != nil {
fmt.Fprintf(os.Stderr, "open: %v\n", err)
return 1
}
c := &client{ c := &client{
base: "http://" + envAddr(), base: "http://" + envAddr(),
token: envToken(), token: token,
} }
exit := 0 exit := 0

View File

@@ -56,8 +56,9 @@ Usage:
Environment: Environment:
OPEN_PROXY_ADDR client target / server listen address (default 127.0.0.1:7777) 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_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_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. 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. When the host is unreachable, it falls back to the real opener on $PATH.

View File

@@ -1,8 +1,10 @@
package main package main
import ( import (
"fmt"
"os" "os"
"strconv" "strconv"
"strings"
) )
const ( const (
@@ -20,8 +22,26 @@ func envAddr() string {
return defaultAddr return defaultAddr
} }
func envToken() string { func envToken() (string, error) {
return os.Getenv("OPEN_PROXY_TOKEN") 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 { func envMaxSize() int64 {

48
proto_test.go Normal file
View File

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

View File

@@ -19,7 +19,13 @@ func runServer(argv []string) int {
return 2 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 := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {