Repository: superbrothers/opener (language: go)

Issue:
Support tcp instead Unix socket
Thanks for making this awesome tool! I was trying to get this work by having a Docker container open a webpage on the host machine. Because I’m on macOS docker is unable to share a Unix socket (https://github.com/docker/for-mac/issues/483#issuecomment-620229892), but can communicate through regular TCP. 

Therefore I’m wondering if it’s possible to add support for running the opener daemon on a TCP port? 


Discussion / hints:
Thanks for trying my tool.

I can provide support for TCP, but first I want to clarify what you are trying to achieve. Do you want to use `open` command in a container to open a URL in the host machine's browser?

```
┌────────────────────┐
│                    │
│ ┌────────────────┐ │
│ │   Web Browser  │ │
│ └─▲──────────────┘ │
│   │ Open URL       │
│ ┌─┴──────────────┐ │
│ │  opener daemon │ │                 ┌────────────────────┐
│ └─┬──────────────┘ │                 │                    │
│   │                │                 │ ┌────────────────┐ │
│ ┌─┴──────────────┐ │   TCP request   │ │  open command  │ │
│ │  0.0.0.0:8080  │ ◄─────────────────┼─┤     (fake)     │ │
│ └────────────────┘ │                 │ └────────────────┘ │
│                    │                 │                    │
└────────────────────┘                 └────────────────────┘
       localhost                             container
```

Indeed, it seems that TCP support is needed in this case. If the container is used on host network, opener can listen on `127.0.0.1`. Listening on `0.0.0.0` seems risky (although there will be FW).
> I can provide support for TCP, but first I want to clarify what you are trying to achieve. Do you want to use open command in a container to open a URL in the host machine's browser?

Yes! 

> Indeed, it seems that TCP support is needed in this case. If the container is used on host network, opener can listen on 127.0.0.1. Listening on 0.0.0.0 seems risky (although there will be FW).

Not very familiar with Go, but when opening a TCP socket it's often possible to provide a bind address. So you could also provide (an optional) bind parameter in the CLI which defaults to `127.0.0.1`, in case someone needs `0.0.0.0` or another address. 

BTW I have to say I'm impressed by your ASCI diagram 😱. 
You too can easily create ASCII diagrams from today 😊

- https://asciiflow.com

Fix patch:
diff --git a/README.md b/README.md
index 6ab6880..16142bd 100644
--- a/README.md
+++ b/README.md
@@ -25,8 +25,8 @@ You remotely forward the socket file of the opener daemon, `~/.opener.sock`, whe
 │ │ ~/.opener.sock │ ├─────────────────► │ ~/.opener.sock │ │
 │ └────────────────┘ │ Remote forward  │ └────────────────┘ │
 │                    │                 │                    │
+│      localhost     │                 │    remote server   │
 └────────────────────┘                 └────────────────────┘
-       localhost                            remote server
 ```
 
 ## Setup
@@ -88,6 +88,68 @@ If set up correctly, the following command in a remote environment will send the
 $ 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 on macOS
+
+If you want to open a URL from inside a container on macOS, 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
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..6fa8228
--- /dev/null
+++ b/config.go
@@ -0,0 +1,38 @@
+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
+}
diff --git a/go.mod b/go.mod
index 5600380..6885d20 100644
--- a/go.mod
+++ b/go.mod
@@ -6,4 +6,5 @@ require (
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2
 	github.com/spf13/cobra v1.2.1
+	sigs.k8s.io/yaml v1.3.0
 )
diff --git a/go.sum b/go.sum
index 829f1c7..a6bf913 100644
--- a/go.sum
+++ b/go.sum
@@ -57,6 +57,7 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -167,8 +168,10 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -551,12 +554,14 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -570,3 +575,5 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
+sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
diff --git a/opener.go b/opener.go
index a26608b..912e175 100644
--- a/opener.go
+++ b/opener.go
@@ -3,6 +3,7 @@ package main
 import (
 	"bufio"
 	"bytes"
+	"errors"
 	"fmt"
 	"io"
 	"net"
@@ -22,13 +23,17 @@ var commit string
 var date string
 
 type OpenerOptions struct {
-	Address string
+	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,
 	}
@@ -36,6 +41,10 @@ func NewOpenerCmd(errOut io.Writer) *cobra.Command {
 	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
 			}
@@ -44,31 +53,38 @@ func NewOpenerCmd(errOut io.Writer) *cobra.Command {
 		},
 	}
 
+	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 {
-	address, err := homedir.Expand(o.Address)
-	if err != nil {
-		return err
+	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("allowd network are: unix,tcp")
 	}
-	o.Address = address
 
 	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)
 
-	syscall.Umask(0077)
-
-	if err := os.RemoveAll(o.Address); err != nil {
-		return err
-	}
-
-	fmt.Fprintf(o.ErrOut, "starting UNIX domain socket server at %s\n", o.Address)
-
-	ln, err := net.Listen("unix", o.Address)
+	ln, err := net.Listen(o.Network, o.Address)
 	if err != nil {
 		return err
 	}


Test patch:
diff --git a/config_test.go b/config_test.go
new file mode 100644
index 0000000..71d8260
--- /dev/null
+++ b/config_test.go
@@ -0,0 +1,67 @@
+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)
+			}
+		})
+	}
+}
diff --git a/opener_test.go b/opener_test.go
index b2fd951..1c4ded3 100644
--- a/opener_test.go
+++ b/opener_test.go
@@ -7,6 +7,54 @@ import (
 	"testing"
 )
 
+func TestOpenerOptionsValidate(t *testing.T) {
+	tt := []struct {
+		test        string
+		o           *OpenerOptions
+		expectedErr string
+	}{
+		{
+			"unix domain socket can be used",
+			&OpenerOptions{
+				Network: "unix",
+				Address: "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",
+			},
+			"allowd 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
diff --git a/testdata/config/empty.yaml b/testdata/config/empty.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/testdata/config/tcp.yaml b/testdata/config/tcp.yaml
new file mode 100644
index 0000000..b6f8de8
--- /dev/null
+++ b/testdata/config/tcp.yaml
@@ -0,0 +1,2 @@
+network: tcp
+address: 127.0.0.1:9000
diff --git a/testdata/config/unix.yaml b/testdata/config/unix.yaml
new file mode 100644
index 0000000..dd2d91a
--- /dev/null
+++ b/testdata/config/unix.yaml
@@ -0,0 +1,2 @@
+network: unix
+address: ~/.opener.sock