initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/open-proxy
|
||||||
|
/_scratch/
|
||||||
|
/.direnv/
|
||||||
142
README.md
Normal file
142
README.md
Normal 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
108
client.go
Normal 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
61
flake.lock
generated
Normal 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
33
flake.nix
Normal 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
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
65
main.go
Normal file
65
main.go
Normal 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
102
opener.go
Normal 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
34
proto.go
Normal 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
119
server.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user