diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e27a8f7 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# 🚦 RoadSign + +A blazing fast reverse proxy with a lot of shining features. + +## Features + +1. Reverse proxy +2. Static file hosting +3. Analytics and Metrics +4. Integrate with CI/CD +5. Webhook integration +6. Web management panel +7. **Blazing fast ⚡** + +### How fast is it? \ No newline at end of file diff --git a/config/example.json b/config/example.json index 52fbc8c..6a8a34c 100644 --- a/config/example.json +++ b/config/example.json @@ -2,14 +2,27 @@ "name": "Example Site", "rules": [ { - "host": ["localhost"], - "path": ["/"] + "host": [ + "localhost" + ], + "path": [ + "/" + ] } ], "upstreams": [ { "name": "Example Upstream", - "uri": "https://example.com" + "uri": "https://disk.smartsheep.studio" + } + ], + "transformers": [ + { + "type": "replacePath", + "options": { + "pattern": "/p/123", + "value": "/p/%E5%B7%A5%E4%BD%9C%E5%AE%A4/icon.png" + } } ] } \ No newline at end of file diff --git a/pkg/administration/init.go b/pkg/administration/init.go new file mode 100644 index 0000000..ed527ff --- /dev/null +++ b/pkg/administration/init.go @@ -0,0 +1,20 @@ +package administration + +import ( + roadsign "code.smartsheep.studio/goatworks/roadsign/pkg" + "fmt" + "github.com/gofiber/fiber/v2" + "github.com/spf13/viper" +) + +func InitAdministration() *fiber.App { + app := fiber.New(fiber.Config{ + AppName: "RoadSign Administration", + ServerHeader: fmt.Sprintf("RoadSign Administration v%s", roadsign.AppVersion), + DisableStartupMessage: true, + EnableIPValidation: true, + TrustedProxies: viper.GetStringSlice("security.administration_trusted_proxies"), + }) + + return app +} diff --git a/pkg/cmd/main.go b/pkg/cmd/main.go index 7c73ddf..738aa4f 100644 --- a/pkg/cmd/main.go +++ b/pkg/cmd/main.go @@ -2,6 +2,7 @@ package main import ( roadsign "code.smartsheep.studio/goatworks/roadsign/pkg" + "code.smartsheep.studio/goatworks/roadsign/pkg/administration" "code.smartsheep.studio/goatworks/roadsign/pkg/configurator" "code.smartsheep.studio/goatworks/roadsign/pkg/hypertext" "github.com/rs/zerolog" @@ -37,7 +38,22 @@ func main() { } // Init hypertext server - hypertext.RunServer(hypertext.InitServer()) + hypertext.RunServer( + hypertext.InitServer(), + viper.GetStringSlice("hypertext.ports"), + viper.GetStringSlice("hypertext.secured_ports"), + viper.GetString("hypertext.certificate.pem"), + viper.GetString("hypertext.certificate.key"), + ) + + // Init administration server + hypertext.RunServer( + administration.InitAdministration(), + viper.GetStringSlice("hypertext.administration_ports"), + viper.GetStringSlice("hypertext.administration_secured_ports"), + viper.GetString("hypertext.certificate.administration_pem"), + viper.GetString("hypertext.certificate.administration_key"), + ) log.Info().Msgf("RoadSign v%s is started...", roadsign.AppVersion) diff --git a/pkg/configurator/deserializer.go b/pkg/configurator/deserializer.go new file mode 100644 index 0000000..66fd911 --- /dev/null +++ b/pkg/configurator/deserializer.go @@ -0,0 +1,10 @@ +package configurator + +import "encoding/json" + +func DeserializeOptions[T any](data any) T { + var out T + raw, _ := json.Marshal(data) + _ = json.Unmarshal(raw, &out) + return out +} diff --git a/pkg/configurator/router.go b/pkg/configurator/router.go new file mode 100644 index 0000000..754089a --- /dev/null +++ b/pkg/configurator/router.go @@ -0,0 +1,41 @@ +package configurator + +import ( + "math/rand" +) + +type AppConfig struct { + Sites []SiteConfig `json:"sites"` +} + +func (v *AppConfig) LoadBalance(site SiteConfig) *UpstreamConfig { + idx := rand.Intn(len(site.Upstreams)) + + switch site.Upstreams[idx].GetType() { + case UpstreamTypeHypertext: + return &site.Upstreams[idx] + case UpstreamTypeFile: + // TODO Make this into hypertext configuration + return &site.Upstreams[idx] + default: + // Give him the null value when this configuration is invalid. + // Then we can print a log in the console to warm him. + return nil + } +} + +type SiteConfig struct { + ID string `json:"id"` + + Name string `json:"name"` + Rules []RouterRuleConfig `json:"rules"` + Transformers []RequestTransformerConfig `json:"transformers"` + Upstreams []UpstreamConfig `json:"upstreams"` +} + +type RouterRuleConfig struct { + Host []string `json:"host"` + Path []string `json:"path"` + Queries map[string]string `json:"query"` + Headers map[string][]string `json:"headers"` +} diff --git a/pkg/configurator/struct.go b/pkg/configurator/struct.go deleted file mode 100644 index 71ba6a9..0000000 --- a/pkg/configurator/struct.go +++ /dev/null @@ -1,46 +0,0 @@ -package configurator - -import "strings" - -type AppConfig struct { - Sites []SiteConfig `json:"sites"` -} - -type SiteConfig struct { - ID string `json:"id"` - - Name string `json:"name"` - Rules []RouterRuleConfig `json:"rules"` - Upstreams []UpstreamConfig `json:"upstreams"` -} - -type RouterRuleConfig struct { - Host []string `json:"host"` - Path []string `json:"path"` - Queries map[string]string `json:"query"` - Headers map[string][]string `json:"headers"` -} - -const ( - UpstreamTypeFile = "file" - UpstreamTypeHypertext = "hypertext" - UpstreamTypeUnknown = "unknown" -) - -type UpstreamConfig struct { - Name string `json:"name"` - URI string `json:"uri"` -} - -func (v *UpstreamConfig) GetType() string { - protocol := strings.SplitN(v.URI, "://", 2)[0] - switch protocol { - case "file": - return UpstreamTypeFile - case "http": - case "https": - return UpstreamTypeHypertext - } - - return UpstreamTypeUnknown -} diff --git a/pkg/configurator/transformer.go b/pkg/configurator/transformer.go new file mode 100644 index 0000000..461f746 --- /dev/null +++ b/pkg/configurator/transformer.go @@ -0,0 +1,58 @@ +package configurator + +import ( + "github.com/gofiber/fiber/v2" + "regexp" + "strings" +) + +type RequestTransformer struct { + ModifyRequest func(options any, ctx *fiber.Ctx) + ModifyResponse func(options any, ctx *fiber.Ctx) +} + +type RequestTransformerConfig struct { + Type string `json:"type"` + Options any `json:"options"` +} + +func (v *RequestTransformerConfig) TransformRequest(ctx *fiber.Ctx) { + for k, f := range Transformers { + if k == v.Type { + if f.ModifyRequest != nil { + f.ModifyRequest(v.Options, ctx) + } + break + } + } +} + +func (v *RequestTransformerConfig) TransformResponse(ctx *fiber.Ctx) { + for k, f := range Transformers { + if k == v.Type { + if f.ModifyResponse != nil { + f.ModifyResponse(v.Options, ctx) + } + break + } + } +} + +var Transformers = map[string]RequestTransformer{ + "replacePath": { + ModifyRequest: func(options any, ctx *fiber.Ctx) { + opts := DeserializeOptions[struct { + Pattern string `json:"pattern"` + Value string `json:"value"` + Repl string `json:"repl"` // Use when complex mode(regexp) enabled + Complex bool `json:"complex"` + }](options) + path := string(ctx.Request().URI().Path()) + if !opts.Complex { + ctx.Path(strings.ReplaceAll(path, opts.Pattern, opts.Value)) + } else if ex := regexp.MustCompile(opts.Pattern); ex != nil { + ctx.Path(ex.ReplaceAllString(path, opts.Repl)) + } + }, + }, +} diff --git a/pkg/configurator/upstream.go b/pkg/configurator/upstream.go new file mode 100644 index 0000000..f7ba5d0 --- /dev/null +++ b/pkg/configurator/upstream.go @@ -0,0 +1,46 @@ +package configurator + +import ( + "fmt" + "github.com/gofiber/fiber/v2" + "github.com/samber/lo" + "strings" +) + +const ( + UpstreamTypeFile = "file" + UpstreamTypeHypertext = "hypertext" + UpstreamTypeUnknown = "unknown" +) + +type UpstreamConfig struct { + Name string `json:"name"` + URI string `json:"uri"` +} + +func (v *UpstreamConfig) GetType() string { + protocol := strings.SplitN(v.URI, "://", 2)[0] + switch protocol { + case "file": + return UpstreamTypeFile + case "http": + case "https": + return UpstreamTypeHypertext + } + + return UpstreamTypeUnknown +} + +func (v *UpstreamConfig) MakeURI(ctx *fiber.Ctx) string { + var queries []string + for k, v := range ctx.Queries() { + queries = append(queries, fmt.Sprintf("%s=%s", k, v)) + } + + path := string(ctx.Request().URI().Path()) + hash := string(ctx.Request().URI().Hash()) + + return v.URI + path + + lo.Ternary(len(queries) > 0, "?"+strings.Join(queries, "&"), "") + + lo.Ternary(len(hash) > 0, "#"+hash, "") +} diff --git a/pkg/hypertext/init.go b/pkg/hypertext/init.go index 06c99a0..517ed51 100644 --- a/pkg/hypertext/init.go +++ b/pkg/hypertext/init.go @@ -33,8 +33,8 @@ func InitServer() *fiber.App { return app } -func RunServer(app *fiber.App) { - for _, port := range viper.GetStringSlice("hypertext.ports") { +func RunServer(app *fiber.App, ports []string, securedPorts []string, pem string, key string) { + for _, port := range ports { port := port go func() { if err := app.Listen(port); err != nil { @@ -43,10 +43,8 @@ func RunServer(app *fiber.App) { }() } - for _, port := range viper.GetStringSlice("hypertext.secured_ports") { + for _, port := range securedPorts { port := port - pem := viper.GetString("hypertext.certificate.pem") - key := viper.GetString("hypertext.certificate.key") go func() { if err := app.ListenTLS(port, pem, key); err != nil { log.Panic().Err(err).Msg("An error occurred when listening hypertext tls ports.") diff --git a/pkg/hypertext/proxies.go b/pkg/hypertext/proxies.go index 8c16d97..1d6583c 100644 --- a/pkg/hypertext/proxies.go +++ b/pkg/hypertext/proxies.go @@ -2,33 +2,23 @@ package hypertext import ( "code.smartsheep.studio/goatworks/roadsign/pkg/configurator" - "fmt" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/proxy" "github.com/rs/zerolog/log" "github.com/samber/lo" "github.com/spf13/viper" "github.com/valyala/fasthttp" - "math/rand" "regexp" - "strings" "time" ) func UseProxies(app *fiber.App) { - app.All("/", func(ctx *fiber.Ctx) error { + app.All("/*", func(ctx *fiber.Ctx) error { host := ctx.Hostname() path := ctx.Path() queries := ctx.Queries() headers := ctx.GetReqHeaders() - log.Debug(). - Any("host", host). - Any("path", path). - Any("queries", queries). - Any("headers", headers). - Msg("A new request received") - // Filtering sites for _, site := range configurator.C.Sites { // Matching rules @@ -96,45 +86,30 @@ func UseProxies(app *fiber.App) { }) } -func doLoadBalance(site configurator.SiteConfig) *configurator.UpstreamConfig { - idx := rand.Intn(len(site.Upstreams)) - - switch site.Upstreams[idx].GetType() { - case configurator.UpstreamTypeHypertext: - return &site.Upstreams[idx] - case configurator.UpstreamTypeFile: - // TODO Make this into hypertext configuration - return &site.Upstreams[idx] - default: - // Give him the null value when this configuration is invalid. - // Then we can print a log in the console to warm him. - return nil - } -} - -func makeRequestUri(ctx *fiber.Ctx, upstream configurator.UpstreamConfig) string { - var queries []string - for k, v := range ctx.Queries() { - queries = append(queries, fmt.Sprintf("%s=%s", k, v)) - } - - hash := string(ctx.Request().URI().Hash()) - - return upstream.URI + - lo.Ternary(len(queries) > 0, "?"+strings.Join(queries, "&"), "") + - lo.Ternary(len(hash) > 0, "#"+hash, "") -} - func makeResponse(ctx *fiber.Ctx, site configurator.SiteConfig) error { - upstream := doLoadBalance(site) + // Load balance + upstream := configurator.C.LoadBalance(site) if upstream == nil { log.Warn().Str("id", site.ID).Msg("There is no available upstream for this request.") return fiber.ErrBadGateway } + // Modify request + for _, transformer := range site.Transformers { + transformer.TransformRequest(ctx) + } + + // Perform forward timeout := time.Duration(viper.GetInt64("performance.network_timeout")) * time.Millisecond - return proxy.Do(ctx, makeRequestUri(ctx, *upstream), &fasthttp.Client{ + err := proxy.Do(ctx, upstream.MakeURI(ctx), &fasthttp.Client{ ReadTimeout: timeout, WriteTimeout: timeout, }) + + // Modify response + for _, transformer := range site.Transformers { + transformer.TransformResponse(ctx) + } + + return err } diff --git a/settings.yml b/settings.yml index fad5daf..20cf3c5 100644 --- a/settings.yml +++ b/settings.yml @@ -1,16 +1,23 @@ debug: print_routes: true +security: + administration_trusted_proxies: [ "localhost" ] + performance: prefork: false network_timeout: 3000 hypertext: ports: [ ":80" ] - secured_ports: [] + secured_ports: [ ] + administration_ports: [ ":81" ] + administration_secured_ports: [ ] certificate: pem: "./cert.pem" key: "./cert.key" + administration_pem: "./cert.pem" + administration_key: "./cert.key" limitation: max_body_size: -1 max_qps: 30