123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- package tunnel
- import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "os"
- "os/exec"
- "strings"
- )
- // Configurator is an interface with a single `Apply` method.
- // Available Configurators:
- // - Configuration{}
- // - WithServers
- //
- // See `Start` package-level function.
- type Configurator interface {
- Apply(*Configuration)
- }
- // ConfiguratorFunc a function signature that completes the `Configurator` interface.
- type ConfiguratorFunc func(*Configuration)
- // Apply should set the Configuration "tc".
- func (opt ConfiguratorFunc) Apply(tc *Configuration) {
- opt(tc)
- }
- // WithServers its a helper which returns a new Configuration
- // added one or more Tunnels based on `http.Server` instances.
- func WithServers(servers ...*http.Server) ConfiguratorFunc {
- return func(tc *Configuration) {
- for _, srv := range servers {
- tunnel := Tunnel{
- Addr: srv.Addr,
- }
- tc.Tunnels = append(tc.Tunnels, tunnel)
- srv.RegisterOnShutdown(func() {
- tc.StopTunnel(tunnel)
- })
- }
- }
- }
- type (
- // Configuration contains configuration
- // for the optional tunneling through ngrok feature.
- // Note that the ngrok should be already installed at the host machine.
- Configuration struct {
- // Client defaults to the http.DefaultClient,
- // callers can use this field to change it.
- Client *http.Client
- // AuthToken field is optionally and can be used
- // to authenticate the ngrok access.
- // ngrok authtoken <YOUR_AUTHTOKEN>
- AuthToken string `ini:"auth_token" json:"authToken,omitempty" yaml:"AuthToken" toml:"AuthToken"`
- // No:
- // Config is optionally and can be used
- // to load ngrok configuration from file system path.
- //
- // If you don't specify a location for a configuration file,
- // ngrok tries to read one from the default location $HOME/.ngrok2/ngrok.yml.
- // The configuration file is optional; no error is emitted if that path does not exist.
- // Config string `json:"config,omitempty" yaml:"Config" toml:"Config"`
- // Bin is the system binary path of the ngrok executable file.
- // If it's empty then it will try to find it through system env variables.
- Bin string `ini:"bin" json:"bin,omitempty" yaml:"Bin" toml:"Bin"`
- // WebUIAddr is the web interface address of an already-running ngrok instance.
- // The package will try to fetch the default web interface address(http://127.0.0.1:4040)
- // to determinate if a ngrok instance is running before try to start it manually.
- // However if a custom web interface address is used,
- // this field must be set e.g. http://127.0.0.1:5050.
- WebInterface string `ini:"web_interface" json:"webInterface,omitempty" yaml:"WebInterface" toml:"WebInterface"`
- // Region is optionally, can be used to set the region which defaults to "us".
- // Available values are:
- // "us" for United States
- // "eu" for Europe
- // "ap" for Asia/Pacific
- // "au" for Australia
- // "sa" for South America
- // "jp" forJapan
- // "in" for India
- Region string `ini:"region" json:"region,omitempty" yaml:"Region" toml:"Region"`
- // Tunnels the collection of the tunnels.
- // Most of the times you only need one.
- Tunnels []Tunnel `ini:"tunnels" json:"tunnels" yaml:"Tunnels" toml:"Tunnels"`
- }
- // Tunnel is the Tunnels field of the Configuration structure.
- Tunnel struct {
- // Name is the only one required field,
- // it is used to create and close tunnels, e.g. "MyApp".
- // If this field is not empty then ngrok tunnels will be created
- // when the app is up and running.
- Name string `ini:"name" json:"name" yaml:"Name" toml:"Name"`
- // Addr should be set of form 'hostname:port'.
- Addr string `ini:"addr" json:"addr,omitempty" yaml:"Addr" toml:"Addr"`
- // Hostname is a static subdomain that can be used instead of random URLs
- // when paid account.
- Hostname string `ini:"hostname" json:"hostname,omitempty" yaml:"Hostname" toml:"Hostname"`
- }
- )
- var _ Configurator = Configuration{}
- func getConfiguration(c Configurator) Configuration {
- cfg := Configuration{}
- c.Apply(&cfg)
- if cfg.Client == nil {
- cfg.Client = http.DefaultClient
- }
- if cfg.WebInterface == "" {
- cfg.WebInterface = DefaultWebInterface
- }
- return cfg
- }
- // Apply implements the Option on the Configuration structure.
- func (tc Configuration) Apply(c *Configuration) {
- *c = tc
- }
- func (tc Configuration) isEnabled() bool {
- return len(tc.Tunnels) > 0
- }
- func (tc Configuration) isNgrokRunning() bool {
- resp, err := tc.Client.Get(tc.WebInterface)
- if err != nil {
- return false
- }
- resp.Body.Close()
- return true
- }
- // https://ngrok.com/docs/ngrok-agent/api
- type ngrokTunnel struct {
- Name string `json:"name"`
- Addr string `json:"addr"`
- Proto string `json:"proto"`
- Auth string `json:"basic_auth,omitempty"`
- // BindTLS bool `json:"bind_tls"`
- Schemes []string `json:"schemes"`
- Hostname string `json:"hostname"`
- }
- // ErrExec returns when ngrok executable was not found in the PATH or NGROK environment variable.
- var ErrExec = errors.New(`"ngrok" executable not found, please install it from: https://ngrok.com/download`)
- // StartTunnel starts the ngrok, if not already running,
- // creates and starts a localhost tunnel. It binds the "publicAddr" pointer
- // to the value of the ngrok's output public address.
- func (tc Configuration) StartTunnel(t Tunnel, publicAddr *string) error {
- tunnelAPIRequest := ngrokTunnel{
- Name: t.Name,
- Addr: t.Addr,
- Hostname: t.Hostname,
- Proto: "http",
- Schemes: []string{"https"},
- // BindTLS: true,
- }
- if !tc.isNgrokRunning() {
- ngrokBin := "ngrok" // environment binary.
- if tc.Bin == "" {
- _, err := exec.LookPath(ngrokBin)
- if err != nil {
- ngrokEnvVar, found := os.LookupEnv("NGROK")
- if !found {
- return ErrExec
- }
- ngrokBin = ngrokEnvVar
- }
- } else {
- ngrokBin = tc.Bin
- }
- // if tc.AuthToken != "" {
- // cmd := exec.Command(ngrokBin, "config", "add-authtoken", tc.AuthToken)
- // err := cmd.Run()
- // if err != nil {
- // return err
- // }
- // }
- // start -none, start without tunnels.
- // and finally the -log stdout logs to the stdout otherwise the pipe will never be able to read from, spent a lot of time on this lol.
- cmd := exec.Command(ngrokBin, "start", "--none", "--log", "stdout")
- // if tc.Config != "" {
- // cmd.Args = append(cmd.Args, []string{"--config", tc.Config}...)
- // }
- if tc.AuthToken != "" {
- cmd.Args = append(cmd.Args, []string{"--authtoken", tc.AuthToken}...)
- }
- if tc.Region != "" {
- cmd.Args = append(cmd.Args, []string{"--region", tc.Region}...)
- }
- // cmd.Stdout = os.Stdout
- // cmd.Stderr = os.Stderr
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return err
- }
- // stderr, err := cmd.StderrPipe()
- // if err != nil {
- // return err
- // }
- if err := cmd.Start(); err != nil {
- return err
- }
- p := make([]byte, 256)
- okText := []byte("client session established")
- for {
- n, err := stdout.Read(p)
- if err != nil {
- // if errors.Is(err, io.EOF) {
- // return nil
- // }
- return err
- }
- // we need this one:
- // msg="client session established"
- // note that this will block if something terrible happens
- // but ngrok's errors are strong so the error is easy to be resolved without any logs.
- if bytes.Contains(p[:n], okText) {
- break
- }
- }
- }
- return tc.createTunnel(tunnelAPIRequest, publicAddr)
- }
- func (tc Configuration) createTunnel(tunnelAPIRequest ngrokTunnel, publicAddr *string) error {
- url := fmt.Sprintf("%s/api/tunnels", tc.WebInterface)
- requestData, err := json.Marshal(tunnelAPIRequest)
- if err != nil {
- return err
- }
- resp, err := tc.Client.Post(url, "application/json", bytes.NewBuffer(requestData))
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- type publicAddrOrErrResp struct {
- PublicAddr string `json:"public_url"`
- Details struct {
- ErrorText string `json:"err"` // when can't bind more addresses, status code was successful.
- } `json:"details"`
- ErrMsg string `json:"msg"` // when ngrok is not yet ready, status code was unsuccessful.
- }
- var apiResponse publicAddrOrErrResp
- err = json.NewDecoder(resp.Body).Decode(&apiResponse)
- if err != nil {
- return err
- }
- if errText := strings.Join([]string{apiResponse.ErrMsg, apiResponse.Details.ErrorText}, ": "); len(errText) > 2 {
- return errors.New(errText)
- }
- *publicAddr = apiResponse.PublicAddr
- return nil
- }
- // StopTunnel removes and stops a tunnel from a running ngrok instance.
- func (tc Configuration) StopTunnel(t Tunnel) error {
- url := fmt.Sprintf("%s/api/tunnels/%s", tc.WebInterface, t.Name)
- req, err := http.NewRequest(http.MethodDelete, url, nil)
- if err != nil {
- return err
- }
- resp, err := tc.Client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusNoContent {
- return fmt.Errorf("stopTunnel: unexpected status code: %d", resp.StatusCode)
- }
- return nil
- }
|