diff --git a/_examples/README.md b/_examples/README.md index 06d1d632..72c7ef39 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -78,6 +78,7 @@ Structuring depends on your own needs. We can't tell you how to design your own ### HTTP Listening - [Common, with address](http-listening/listen-addr/main.go) + * [public domain address](http-listening/listen-addr-public/main.go) **NEW** * [omit server errors](http-listening/listen-addr/omit-server-errors/main.go) - [UNIX socket file](http-listening/listen-unix/main.go) - [TLS](http-listening/listen-tls/main.go) diff --git a/_examples/http-listening/listen-addr-public/README.md b/_examples/http-listening/listen-addr-public/README.md new file mode 100644 index 00000000..c29e185d --- /dev/null +++ b/_examples/http-listening/listen-addr-public/README.md @@ -0,0 +1 @@ +![tunneling_screenshot.png](tunneling_screenshot.png) diff --git a/_examples/http-listening/listen-addr-public/main.go b/_examples/http-listening/listen-addr-public/main.go new file mode 100644 index 00000000..d0be9d44 --- /dev/null +++ b/_examples/http-listening/listen-addr-public/main.go @@ -0,0 +1,36 @@ +package main + +import "github.com/kataras/iris" + +func main() { + app := iris.New() + + app.Get("/", func(ctx iris.Context) { + ctx.HTML("

Hello World!

") + + // Will print the ngrok public domain + // that your app is using to be served online. + ctx.Writef("From: %s", + ctx.Application().ConfigurationReadOnly().GetVHost()) + }) + + app.Run(iris.Addr(":8080"), iris.WithTunneling) + + /* The full configuration can be set as: + app.Run(iris.Addr(":8080"), iris.WithConfiguration( + iris.Configuration{ + Tunneling: iris.TunnelingConfiguration{ + AuthToken: "my-ngrok-auth-client-token", + Bin: "/bin/path/for/ngrok", + Region: "eu", + WebInterface: "127.0.0.1:4040", + Tunnels: []iris.Tunnel{ + { + Name: "MyApp", + Addr: ":8080", + }, + }, + }, + })) + */ +} diff --git a/_examples/http-listening/listen-addr-public/tunneling_screenshot.png b/_examples/http-listening/listen-addr-public/tunneling_screenshot.png new file mode 100644 index 00000000..df44adf9 Binary files /dev/null and b/_examples/http-listening/listen-addr-public/tunneling_screenshot.png differ diff --git a/configuration.go b/configuration.go index 7ea82425..8901827c 100644 --- a/configuration.go +++ b/configuration.go @@ -1,8 +1,13 @@ package iris import ( + "bytes" + "encoding/json" + "fmt" "io/ioutil" + "net/http" "os" + "os/exec" "os/user" "path/filepath" "runtime" @@ -346,7 +351,7 @@ func WithoutRemoteAddrHeader(headerName string) Configurator { // WithOtherValue adds a value based on a key to the Other setting. // -// See `Configuration`. +// See `Configuration.Other`. func WithOtherValue(key string, val interface{}) Configurator { return func(app *Application) { if app.config.Other == nil { @@ -356,61 +361,216 @@ func WithOtherValue(key string, val interface{}) Configurator { } } -// WithTunnel is the `iris.Configurator` for the `iris.Configuration.Tunnel` field. -// It requires the "name" which is used to create on server ran and terminate on server shutdown -// the ngrok http(s) tunnel for an Iris app. -// Its second variadic input argument can accept one or more functions -// that accept a pointer to TunnelConfiguration value which can be used -// for further customization of the tunnel configuration one. -// Alternatively use the `iris.WithConfiguration(iris.Configuration{Tunnel: iris.TunnelConfiguration{ ...}}}`. -func WithTunnel(name string, tunnelConfigurator ...func(*TunnelConfiguration)) Configurator { - conf := &TunnelConfiguration{Name: name} - - for _, tc := range tunnelConfigurator { - if tc != nil { - tc(conf) - } +// WithTunneling is the `iris.Configurator` for the `iris.Configuration.Tunneling` field. +// It's used to enable http tunneling for an Iris Application, per registered host +// +// Alternatively use the `iris.WithConfiguration(iris.Configuration{Tunneling: iris.TunnelingConfiguration{ ...}}}`. +func WithTunneling(app *Application) { + conf := TunnelingConfiguration{ + Tunnels: []Tunnel{{}}, // create empty tunnel, its addr and name are set right before host serve. } - return func(app *Application) { - app.config.Tunnel = *conf - // TODO: do the work here if the vhost is set (when this configurator is set through app.Run) - // or find a way to do it when `app.Configure` is used instead, probably it will go inside app.Run though. - } + app.config.Tunneling = conf } -// TunnelConfiguration contains configuration -// for the optional tunneling through ngrok feature. -type TunnelConfiguration struct { +// Tunnel is the Tunnels field of the TunnelingConfiguration structure. +type 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 iris app is up and running. Name string `json:"name" yaml:"Name" toml:"Name"` - // Usernamem and Password fields are optionally and are used - // to authenticate the ngrok tunnel access. - Username string `json:"username,omitempty" yaml:"Username" toml:"Username"` - Password string `json:"password,omitemmpty" yaml:"Password" toml:"Password"` - - // Bin is the system binary path of the ngrok executable file. - // If it's empty then the framework will try to find it through system env variables. - Bin string `json:"bin,omitempty" yaml:"Bin" toml:"Bin"` // Addr is basically optionally as it will be set through // Iris built-in Runners, however, if `iris.Raw` is used // then this field should be set of form 'hostname:port' // because framework cannot be aware // of the address you used to run the server on this custom runner. Addr string `json:"addr,omitempty" yaml:"Addr" toml:"Addr"` +} + +// TunnelingConfiguration contains configuration +// for the optional tunneling through ngrok feature. +// Note that the ngrok should be already installed at the host machine. +type TunnelingConfiguration struct { + // AuthToken field is optionally and can be used + // to authenticate the ngrok access. + // ngrok authtoken + AuthToken string `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 the framework will try to find it through system env variables. + Bin string `json:"bin,omitempty" yaml:"Bin" toml:"Bin"` + // WebUIAddr is the web interface address of an already-running ngrok instance. // Iris 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 `json:"webInterface" yaml:"WebInterface" toml:"WebInterface"` + WebInterface string `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 `json:"region,omitempty" yaml:"Region" toml:"Region"` + + // Tunnels the collection of the tunnels. + // One tunnel per Iris Host per Application, usually you only need one. + Tunnels []Tunnel `json:"tunnels" yaml:"Tunnels" toml:"Tunnels"` } -func (tc *TunnelConfiguration) isEnabled() bool { - return tc != nil && tc.Name != "" +func (tc *TunnelingConfiguration) isEnabled() bool { + return tc != nil && len(tc.Tunnels) > 0 +} + +func (tc *TunnelingConfiguration) isNgrokRunning() bool { + _, err := http.Get(tc.WebInterface) + return err == nil +} + +// https://ngrok.com/docs +type ngrokTunnel struct { + Name string `json:"name"` + Addr string `json:"addr"` + Proto string `json:"proto"` + Auth string `json:"auth"` + BindTLS bool `json:"bind_tls"` +} + +func (tc TunnelingConfiguration) startTunnel(t Tunnel, publicAddr *string) error { + tunnelAPIRequest := ngrokTunnel{ + Name: t.Name, + Addr: t.Addr, + Proto: "http", + BindTLS: true, + } + + if !tc.isNgrokRunning() { + ngrokBin := "ngrok" // environment binary. + if tc.Bin != "" { + ngrokBin = tc.Bin + } + + if tc.AuthToken != "" { + cmd := exec.Command(ngrokBin, "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.Region != "" { + cmd.Args = append(cmd.Args, []string{"-region", tc.Region}...) + } + + stdout, err := cmd.StdoutPipe() + 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 { + 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 TunnelingConfiguration) 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 := http.DefaultClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != StatusNoContent { + return fmt.Errorf("stop return an unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +func (tc TunnelingConfiguration) 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 := http.Post(url, context.ContentJSONHeaderValue, 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 := apiResponse.ErrMsg; errText != "" { + return errors.New(errText) + } + + if errText := apiResponse.Details.ErrorText; errText != "" { + return errors.New(errText) + } + + *publicAddr = apiResponse.PublicAddr + return nil } // Configuration the whole configuration for an iris instance @@ -421,11 +581,9 @@ type Configuration struct { // It can be retrieved by the context if needed (i.e router for subdomains) vhost string - // TunnelConfiguration can be optionally set - // to enable ngrok http(s) tunneling for this Iris app instance. - // If iris logger's level is set to "debug" then it will print out - // ngrok tunneling info messages too. - Tunnel TunnelConfiguration `json:"tunnel,omitempty" yaml:"Tunnel" toml"Tunnel"` + // Tunneling can be optionally set to enable ngrok http(s) tunneling for this Iris app instance. + // See the `WithTunneling` Configurator too. + Tunneling TunnelingConfiguration `json:"tunneling,omitempty" yaml:"Tunneling" toml"Tunneling"` // IgnoreServerErrors will cause to ignore the matched "errors" // from the main application's `Run` function. @@ -740,8 +898,8 @@ func WithConfiguration(c Configuration) Configurator { return func(app *Application) { main := app.config - if c.Tunnel.isEnabled() { - main.Tunnel = c.Tunnel + if c.Tunneling.isEnabled() { + main.Tunneling = c.Tunneling } if v := c.IgnoreServerErrors; len(v) > 0 { diff --git a/go19.go b/go19.go index 5be3dafc..79cb6b76 100644 --- a/go19.go +++ b/go19.go @@ -62,7 +62,10 @@ type ( // Look the `core/router#APIBuilder` for its implementation. // // A shortcut for the `core/router#Party`, useful when `PartyFunc` is being used. - Party = router.Party + Party = router.Party + // DirOptions contains the optional settings that + // `FileServer` and `Party#HandleDir` can use to serve files and assets. + // A shortcut for the `router.DirOptions`, useful when `FileServer` or `HandleDir` is being used. DirOptions = router.DirOptions // ExecutionRules gives control to the execution of the route handlers outside of the handlers themselves. // Usage: diff --git a/iris.go b/iris.go index 2a445262..092968e2 100644 --- a/iris.go +++ b/iris.go @@ -2,11 +2,14 @@ package iris import ( // std packages + stdContext "context" + "fmt" "io" "log" "net" "net/http" + "strings" "sync" "time" @@ -365,7 +368,14 @@ var ( // // A shortcut for the `context#NewConditionalHandler`. NewConditionalHandler = context.NewConditionalHandler - + // FileServer returns a Handler which serves files from a specific system, phyisical, directory + // or an embedded one. + // The first parameter is the directory, relative to the executable program. + // The second optional parameter is any optional settings that the caller can use. + // + // See `Party#HandleDir` too. + // Examples can be found at: https://github.com/kataras/iris/tree/master/_examples/file-server + // A shortcut for the `router.FileServer`. FileServer = router.FileServer // StripPrefix returns a handler that serves HTTP requests // by removing the given prefix from the request URL's Path @@ -586,6 +596,14 @@ var RegisterOnInterrupt = host.RegisterOnInterrupt // Shutdown gracefully terminates all the application's server hosts. // Returns an error on the first failure, otherwise nil. func (app *Application) Shutdown(ctx stdContext.Context) error { + for _, t := range app.config.Tunneling.Tunnels { + if t.Name == "" { + continue + } + + app.config.Tunneling.stopTunnel(t) + } + for i, su := range app.Hosts { app.logger.Debugf("Host[%d]: Shutdown now", i) if err := su.Shutdown(ctx); err != nil { @@ -822,6 +840,8 @@ func (app *Application) Run(serve Runner, withOrWithout ...Configurator) error { } app.Configure(withOrWithout...) + app.tryStartTunneling() + app.logger.Debugf("Application: running using %d host(s)", len(app.Hosts)+1) // this will block until an error(unless supervisor's DeferFlow called from a Task). @@ -832,3 +852,55 @@ func (app *Application) Run(serve Runner, withOrWithout ...Configurator) error { return err } + +// https://ngrok.com/docs +func (app *Application) tryStartTunneling() { + if !app.config.Tunneling.isEnabled() { + return + } + + hostIndex := 0 + + app.ConfigureHost(func(su *host.Supervisor) { + su.RegisterOnServe(func(h host.TaskHost) { + tc := app.config.Tunneling + if tc.WebInterface == "" { + tc.WebInterface = "http://127.0.0.1:4040" + } + + if len(tc.Tunnels) > hostIndex { + t := tc.Tunnels[hostIndex] + if t.Name == "" { + t.Name = fmt.Sprintf("iris-app-%d-%s", hostIndex+1, time.Now().Format(app.config.TimeFormat)) + } + + if t.Addr == "" { + t.Addr = su.Server.Addr + } + + var publicAddr string + err := tc.startTunnel(t, &publicAddr) + if err != nil { + app.Logger().Errorf("Host: tunneling error: %v", err) + return + } + + // to make subdomains resolution still based on this new remote, public addresses. + app.config.vhost = publicAddr[strings.Index(publicAddr, "://")+3:] + + // app.logger.Debugf("Host: new virtual host is %s", app.config.vhost) + // app.Logger().Printer.Output.Write([]byte(")) + + // directLog := []byte(fmt.Sprintf("| Public Address: %s |\n", publicAddr)) + // box := bytes.Repeat([]byte("="), len(directLog)) + // directLog = append(append(box, []byte("\n")...), append(directLog, append(box, []byte("\n")...)...)...) + directLog := []byte(fmt.Sprintf("⬝ Public Address: %s\n", publicAddr)) + app.Logger().Printer.Output.Write(directLog) + + } + + hostIndex++ + }) + }) + +}