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