Repository: superbrothers/opener Branch: master Commit: 95903db0f9b8 Files: 22 Total size: 20.6 KB Directory structure: gitextract_ih7yev_u/ ├── .dockerignore ├── .github/ │ ├── renovate.json │ └── workflows/ │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin/ │ ├── open │ └── xdg-open ├── config.go ├── config_test.go ├── go.mod ├── go.sum ├── main.go ├── opener.go ├── opener_test.go └── testdata/ └── config/ ├── empty.yaml ├── tcp.yaml └── unix.yaml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ /dist ================================================ FILE: .github/renovate.json ================================================ { "extends": ["config:base"], "labels": ["renovate"], "enabledManagers": ["dockerfile", "regex", "github-actions"], "regexManagers": [ { "fileMatch": ["(^|/)Makefile$"], "matchStrings": [ "#\\s*renovate:\\s*datasource=(?.*?)\\s+depName=(?.*?)(\\s+versioning=(?.*?))?(\\s+registry=(?.*?))?\\s.*?_VERSION\\s+[^=]?=\\s+(?.*)\\s" ], "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" } ] } ================================================ FILE: .github/workflows/ci.yaml ================================================ name: CI on: push: branches: [master] paths-ignore: ['**.md'] pull_request: types: [opened, synchronize] paths-ignore: ['**.md'] jobs: run: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Ensure go.mod is already tidied run: go mod tidy && git diff -s --exit-code go.sum - run: make lint - run: make test - run: make build ================================================ FILE: .github/workflows/release.yaml ================================================ name: Release on: push: tags: ["v*"] jobs: run: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod - run: make release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ /dist /hack/tools/bin ================================================ FILE: .goreleaser.yaml ================================================ version: 2 before: hooks: - go mod tidy project_name: opener builds: - env: - CGO_ENABLED=0 goos: - linux - darwin goarch: - amd64 - arm - arm64 archives: - name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" formats: - zip files: - LICENSE - README.md wrap_in_directory: false checksum: name_template: 'checksums.txt' ================================================ FILE: Dockerfile ================================================ FROM --platform=${BUILDPLATFORM} golang:1.24 AS base WORKDIR /src ENV CGO_ENABLED=0 COPY go.* . RUN --mount=type=cache,target=/go/pkg/mod \ go mod download FROM base AS build ARG TARGETOS ARG TARGETARCH RUN --mount=target=. \ --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/opener . FROM base AS test RUN --mount=target=. \ --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go test -v ./... FROM golangci/golangci-lint:v1.64.8 AS lint-base FROM base AS lint RUN --mount=target=. \ --mount=from=lint-base,src=/usr/bin/golangci-lint,target=/usr/bin/golangci-lint \ --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/golangci-lint \ go vet ./... && \ go fmt ./... && \ golangci-lint run FROM scratch AS bin-unix COPY --from=build /out/opener / FROM bin-unix AS bin-linux FROM bin-unix AS bin-darwin FROM bin-${TARGETOS} as bin ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Kazuki Suda Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ NAME := opener DIST_DIR := dist GO ?= go VERSION ?= $(shell git describe --tags --always --dirty) PLATFORM ?= local DOCKER ?= DOCKER_BUILDKIT=1 docker .PHONY: build build: $(DOCKER) build --target bin --output $(DIST_DIR) --platform $(PLATFORM) . TOOLS_BIN_DIR := $(CURDIR)/hack/tools/bin $(shell mkdir -p $(TOOLS_BIN_DIR)) # renovate: datasource=github-releases depName=goreleaser/goreleaser GORELEASER_VERSION ?= v2.8.1 GORELEASER := $(TOOLS_BIN_DIR)/goreleaser $(GORELEASER): GOBIN=$(TOOLS_BIN_DIR) $(GO) install github.com/goreleaser/goreleaser/v2@$(GORELEASER_VERSION) .PHONY: build-cross build-cross: $(GORELEASER) $(GORELEASER) build --snapshot --clean .PHONY: test test: $(DOCKER) build --target test . .PHONY: lint lint: $(DOCKER) build --target lint . .PHONY: dist dist: $(GORELEASER) $(GORELEASER) release --clean --skip=publish --snapshot .PHONY: release release: $(GORELEASER) $(GORELEASER) release --clean --skip=validate .PHONY: clean clean: clean-tools clean-dist .PHONY: clean-tools clean-tools: rm -rf $(TOOLS_BIN_DIR) .PHONY: clean-dist clean-dist: rm -rf $(DIST_DIR) ================================================ FILE: README.md ================================================ # opener ![Logo](./sennuki.png) Open URL in your local web browser from the SSH-connected remote environment. ## How does opener work? opener is a daemon process that runs locally. When you send a URL to the process, it will execute a command tailored to your local environment (`open` on macOS, `xdg-open` on Linux) with the URL as an argument. As a result, the URL will be opened in your favorite web browser. You remotely forward the socket file of the opener daemon, `~/.opener.sock`, when you log in to the remote environment via SSH. In a remote environment, you use fake `open` command or` xdg-open` command to send the URL to `~/.opener.sock` being forwarded from your local environment. The result is as if URL was sent to the local opener daemon, which opens the URL in your local web browser. ``` ┌────────────────────┐ ┌────────────────────┐ │ │ │ │ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ │ Web Browser │ │ │ │ open command │ │ │ └─▲──────────────┘ │ │ │ (fake) │ │ │ │ Open URL │ │ └─┬──────────────┘ │ │ ┌─┴──────────────┐ │ │ │ │ │ │ opener daemon │ │ │ │ Send URL │ │ └─┬──────────────┘ │ │ │ │ │ │ │ │ │ │ │ ┌─┴──────────────┐ │ SSH connection │ ┌─▼──────────────┐ │ │ │ ~/.opener.sock │ ├─────────────────► │ ~/.opener.sock │ │ │ └────────────────┘ │ Remote forward │ └────────────────┘ │ │ │ │ │ │ localhost │ │ remote server │ └────────────────────┘ └────────────────────┘ ``` ## Setup ### Local environment You can install opener with Homebrew. Since opener is a daemon, it is managed by Homebrew-services. ``` $ brew install superbrothers/opener/opener $ brew services start opener ``` Set ssh config to forward `~/.opener.sock` to the remote environment. ``` Host host.example.org RemoteForward /home/me/.opener.sock /Users/me/.opener.sock ``` ### Remote environment Install a fake `open` or` xdg-open` command. Please choose your preference either way. ```sh $ mkdir ~/bin # open command $ curl -L -o ~/bin/open https://raw.githubusercontent.com/superbrothers/opener/master/bin/open $ chmod 755 ~/bin/open # xdg-open command $ curl -L -o ~/bin/xdg-open https://raw.githubusercontent.com/superbrothers/opener/master/bin/xdg-open $ chmod 755 ~/bin/xdg-open # Add ~/bin to $PATH and enable it $ echo 'export PATH="$HOME/bin:$PATH"' >>~/.bashrc $ source ~/.bashrc ``` Fake commands use `nc` command, so install it if you don't have it. ```sh # Ubuntu 20.04 $ sudo apt install netcat ``` Add the following settings to sshd. This is an option to delete the socket file when you lose the connection to the remote environment. ```sh # Add a configuration file $ echo "StreamLocalBindUnlink yes" | sudo tee /etc/ssh/sshd_config.d/opener.conf # Restart ssh service $ sudo systemctl restart ssh ``` ## How to use it If set up correctly, the following command in a remote environment will send the URL through opener and open the URL in your local web browser. ``` $ open https://www.google.com/ ``` ## Configuration You can configure opener with a config file. By default, it should be located at `~/.config/opener/config.yaml`. You can also specify a config file with `--config` option. ```yaml # The network to use opener daemon. # Allowed networks are: unix or tcp. (defaults to unix) network: unix # The address to listen on. (defaults to ~/.opener.sock) address: ~/.opener.sock ``` ### Example: Open a URL from inside a container If you want to open a URL from inside a container, you can use `tcp` network instead of `unix`. ``` ┌─────────────────────────────────────────────────────────┐ │ │ │ ┌────────────────┐ │ │ │ Web Browser │ │ │ └─▲──────────────┘ ┌──────────────────┐ │ │ │ │ container │ │ │ │ Open URL │ │ │ │ │ │ ┌──────────────┐ │ │ │ ┌─┴──────────────┐ Send a URL │ │ open command │ │ │ │ │ opener daemon │◄────────────────┼─┤ (fake) │ │ │ │ └────────────────┘ (TCP request) │ └──────────────┘ │ │ │ 127.0.0.1:9999 │ │ │ │ └──────────────────┘ │ │ localhost │ └─────────────────────────────────────────────────────────┘ ``` Create the following config at `~/.config/opener/config.yaml`: ```yaml network: tcp address: 127.0.0.1:9999 ``` Restart the opener daemon: ``` $ brew services restart opener ``` Send a URL to the opener daemon from inside a container: ``` $ docker run --rm -it busybox /bin/sh # echo https://www.google.com/ | nc host.docker.internal 9999 ``` The following script is useful as a fake `open` command. ```sh #!/bin/sh echo "$@" | nc host.docker.internal 9999 ``` ## License MIT License ================================================ FILE: bin/open ================================================ #!/bin/bash set -eu # get data either form stdin or from file if (( $# == 0 )) ; then # if no argument, read from standard input from pipe buf=$(cat "$@") else # otherwise read from all arguments buf=$@ fi echo "$buf" | nc -U "$HOME/.opener.sock" ================================================ FILE: bin/xdg-open ================================================ #!/bin/bash set -eu # get data either form stdin or from file if (( $# == 0 )) ; then # if no argument, read from standard input from pipe buf=$(cat "$@") else # otherwise read from all arguments buf=$@ fi echo "$buf" | nc -U "$HOME/.opener.sock" ================================================ FILE: config.go ================================================ package main import ( "os" "path/filepath" "sigs.k8s.io/yaml" ) func LoadOpenerOptionsFromConfig(configPath string, o *OpenerOptions) error { if configPath == "" { dir, err := os.UserHomeDir() if err != nil { return err } configPath = filepath.Join(dir, ".config", "opener", "config.yaml") if _, err := os.Stat(configPath); err != nil { // The config file does not exist in the default path. return nil } } else { if _, err := os.Stat(configPath); err != nil { return err } } b, err := os.ReadFile(configPath) if err != nil { return err } if err := yaml.Unmarshal(b, o); err != nil { return err } return nil } ================================================ FILE: config_test.go ================================================ package main import ( "path/filepath" "reflect" "testing" ) func TestLoadOptionsFromConfig(t *testing.T) { tt := []struct { test string configPath string expected *OpenerOptions expectedErr string }{ { "unix", filepath.Join("testdata", "config", "unix.yaml"), &OpenerOptions{ Network: "unix", Address: "~/.opener.sock", }, "", }, { "tcp", filepath.Join("testdata", "config", "tcp.yaml"), &OpenerOptions{ Network: "tcp", Address: "127.0.0.1:9000", }, "", }, { "empty", filepath.Join("testdata", "config", "empty.yaml"), &OpenerOptions{}, "", }, { "no such file", filepath.Join("testdata", "config", "no-such-file.yaml"), &OpenerOptions{}, "stat testdata/config/no-such-file.yaml: no such file or directory", }, } for _, tc := range tt { t.Run(tc.test, func(t *testing.T) { o := &OpenerOptions{} err := LoadOpenerOptionsFromConfig(tc.configPath, o) if err == nil { if tc.expectedErr != "" { t.Errorf("expected err nil, but %q", err) } } else { if tc.expectedErr != err.Error() { t.Errorf("expected err %q, but %q", tc.expectedErr, err) } } if !reflect.DeepEqual(tc.expected, o) { t.Errorf("expected %#v, but %#v", tc.expected, o) } }) } } ================================================ FILE: go.mod ================================================ module github.com/superbrothers/opener go 1.24.1 require ( github.com/mitchellh/go-homedir v1.1.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.9.1 sigs.k8s.io/yaml v1.4.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.6 // indirect golang.org/x/sys v0.31.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= ================================================ FILE: main.go ================================================ package main import ( "log" "os" ) func main() { cmd := NewOpenerCmd(os.Stderr) if err := cmd.Execute(); err != nil { log.Fatal(err) } } ================================================ FILE: opener.go ================================================ package main import ( "bufio" "bytes" "errors" "fmt" "io" "net" "os" "os/signal" "strings" "sync" "syscall" "github.com/mitchellh/go-homedir" "github.com/pkg/browser" "github.com/spf13/cobra" ) var version string var commit string var date string type OpenerOptions struct { Network string `yaml:"network"` Address string `yaml:"address"` ErrOut io.Writer } func NewOpenerCmd(errOut io.Writer) *cobra.Command { var configPath string o := &OpenerOptions{ Network: "unix", Address: "~/.opener.sock", ErrOut: errOut, } cmd := &cobra.Command{ Use: "opener", RunE: func(_ *cobra.Command, args []string) error { if err := LoadOpenerOptionsFromConfig(configPath, o); err != nil { return err } if err := o.Validate(); err != nil { return err } return o.Run() }, } cmd.Flags().StringVar(&configPath, "config", configPath, "Path to the opener config file (defaults to ~/.config/opener/config.yaml)") return cmd } func (o *OpenerOptions) Validate() error { switch o.Network { case "unix": address, err := homedir.Expand(o.Address) if err != nil { return err } o.Address = address syscall.Umask(0077) if err := os.RemoveAll(o.Address); err != nil { return err } case "tcp": default: return errors.New("allowed network are: unix,tcp") } return nil } func (o *OpenerOptions) Run() error { fmt.Fprintf(o.ErrOut, "version: %s, commit: %s, date: %s\n", version, commit, date) fmt.Fprintf(o.ErrOut, "starting a server at %s\n", o.Address) ln, err := net.Listen(o.Network, o.Address) if err != nil { return err } defer ln.Close() go func() { for { conn, err := ln.Accept() if err != nil { fmt.Fprintln(o.ErrOut, err) return } go handleConnection(conn, o.ErrOut) } }() c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) sig := <-c fmt.Fprintf(o.ErrOut, "got signal %s\n", sig) return nil } var browserMu sync.Mutex var openURL = func(line string) (string, error) { // We try out best avoiding race-condition on swapping browser.{Stdout,Stderr}. // This works in a case when there are two or more consumers exist for this package. // // Fingers-crossed when github.com/pkg/browser is used concurrently outside of this package... browserMu.Lock() stdout, stderr := browser.Stdout, browser.Stderr defer func() { browser.Stdout = stdout browser.Stderr = stderr browserMu.Unlock() }() var buf bytes.Buffer browser.Stdout = &buf browser.Stderr = &buf err := browser.OpenURL(line) return buf.String(), err } func handleConnection(conn net.Conn, errOut io.Writer) { defer conn.Close() line, err := bufio.NewReader(conn).ReadString('\n') line = strings.TrimRight(line, "\n") fmt.Fprintf(errOut, "received %q\n", line) if err != nil { if err != io.EOF { fmt.Fprintln(errOut, err) return } } logs, err := openURL(line) if logs != "" { fmt.Fprint(errOut, logs) } if err != nil { fmt.Fprintf(errOut, "failed to open %q: %v\n", line, err) // Send back the logs from `open` to the client over e.g. the unix domain socket, so that // `open` on the client machine would work more like that on the server. // // Note that this works only when the client selected the protocol of SOCK_STREAM rather than e.g. SOCK_DGRAM. // `socat`, for example, negotiates the protocol to prefer SOCK_STREAM so you won't usually care. if _, err := conn.Write([]byte(logs)); err != nil { fmt.Fprintf(errOut, "failed to send error to client: %v\n", err) } return } } ================================================ FILE: opener_test.go ================================================ package main import ( "errors" "fmt" "io" "math/rand" "net" "path/filepath" "testing" ) func TestOpenerOptionsValidate(t *testing.T) { tt := []struct { test string o *OpenerOptions expectedErr string }{ { "unix domain socket can be used", &OpenerOptions{ Network: "unix", Address: filepath.Join("/", "tmp", fmt.Sprintf("%03d", rand.Intn(1000)), "opener.sock"), }, "", }, { "tcp can be used", &OpenerOptions{ Network: "tcp", Address: "127.0.0.1:8888", }, "", }, { "udp cannot be used", &OpenerOptions{ Network: "udp", Address: "127.0.0.1:8888", }, "allowed network are: unix,tcp", }, } for _, tc := range tt { t.Run(tc.test, func(t *testing.T) { err := tc.o.Validate() if err == nil { if tc.expectedErr != "" { t.Errorf("expect err nil, but actual %q", err) } } else { if tc.expectedErr != err.Error() { t.Errorf("expect err %q, but actual %q", tc.expectedErr, err) } } }) } } func TestHandleConnection(t *testing.T) { tt := []struct { test string openURLFunc func(string) (string, error) data string err error }{ { "Say nothing when successful", func(line string) (string, error) { return "pong\n", nil }, "", io.EOF, }, { "Sending back the logs when failure", func(line string) (string, error) { return "pong\n", errors.New("exit status 1") }, "pong\n", nil, }, } ln, _ := net.Listen("tcp", "127.0.0.1:0") defer ln.Close() for _, tc := range tt { t.Run(tc.test, func(t *testing.T) { openURL = tc.openURLFunc go func() { conn, _ := ln.Accept() go handleConnection(conn, io.Discard) }() client, err := net.Dial("tcp", ln.Addr().String()) if err != nil { t.Fatal(err) } defer client.Close() if _, err := client.Write([]byte("ping\n")); err != nil { t.Fatal(err) } buf := make([]byte, 1024) n, err := client.Read(buf) data := string(buf[:n]) if tc.data != data { t.Errorf("expect %q, but actual %q", tc.data, data) } if tc.err != err { t.Errorf("expect %v, but actual %v", tc.err, err) } }) } } ================================================ FILE: testdata/config/empty.yaml ================================================ ================================================ FILE: testdata/config/tcp.yaml ================================================ network: tcp address: 127.0.0.1:9000 ================================================ FILE: testdata/config/unix.yaml ================================================ network: unix address: ~/.opener.sock