mirror of
https://github.com/yusing/godoxy.git
synced 2025-05-31 00:52:35 +02:00
fix: rules escaped backslash
This commit is contained in:
parent
694219c50a
commit
2639c2a836
62 changed files with 2620 additions and 496 deletions
|
@ -23,10 +23,10 @@ lint:
|
|||
enabled:
|
||||
- hadolint@2.12.1-beta
|
||||
- actionlint@1.7.6
|
||||
- checkov@3.2.347
|
||||
- checkov@3.2.350
|
||||
- git-diff-check
|
||||
- gofmt@1.20.4
|
||||
- golangci-lint@1.62.2
|
||||
- golangci-lint@1.63.4
|
||||
- osv-scanner@1.9.2
|
||||
- oxipng@9.1.3
|
||||
- prettier@3.4.2
|
||||
|
|
|
@ -87,8 +87,10 @@ Setup DNS Records point to machine which runs `GoDoxy`, e.g.
|
|||
|
||||
- change username and password for WebUI authentication
|
||||
```shell
|
||||
sed -i "s|API_USERNAME=.*|API_USERNAME=admin|g" .env
|
||||
sed -i "s|API_PASSWORD=.*|API_PASSWORD=some-strong-password|g" .env
|
||||
USERNAME=admin
|
||||
PASSWORD=some-password
|
||||
sed -i "s|API_USERNAME=.*|API_USERNAME=${USERNAME}|g" .env
|
||||
sed -i "s|API_PASSWORD=.*|API_PASSWORD=${PASSWORD}|g" .env
|
||||
```
|
||||
|
||||
4. _(Optional)_ setup `docker-socket-proxy` other docker nodes (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) then add them inside `config.yml`
|
||||
|
|
|
@ -87,8 +87,10 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
|
|||
|
||||
- 更改網頁介面認證的使用者名稱和密碼
|
||||
```shell
|
||||
sed -i "s|API_USERNAME=.*|API_USERNAME=admin|g" .env
|
||||
sed -i "s|API_PASSWORD=.*|API_PASSWORD=some-strong-password|g" .env
|
||||
USERNAME=admin
|
||||
PASSWORD=some-password
|
||||
sed -i "s|API_USERNAME=.*|API_USERNAME=${USERNAME}|g" .env
|
||||
sed -i "s|API_PASSWORD=.*|API_PASSWORD=${PASSWORD}|g" .env
|
||||
```
|
||||
|
||||
4. _(可選)_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
|
||||
|
|
62
cmd/main.go
62
cmd/main.go
|
@ -3,24 +3,19 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal"
|
||||
"github.com/yusing/go-proxy/internal/api"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
||||
"github.com/yusing/go-proxy/internal/api/v1/query"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
"github.com/yusing/go-proxy/internal/entrypoint"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/metrics"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/net/http/server"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/pkg"
|
||||
)
|
||||
|
@ -98,16 +93,16 @@ func main() {
|
|||
switch args.Command {
|
||||
case common.CommandListRoutes:
|
||||
cfg.StartProxyProviders()
|
||||
printJSON(config.RoutesByAlias())
|
||||
printJSON(routes.RoutesByAlias())
|
||||
return
|
||||
case common.CommandListConfigs:
|
||||
printJSON(config.Value())
|
||||
printJSON(cfg.Value())
|
||||
return
|
||||
case common.CommandDebugListEntries:
|
||||
printJSON(config.DumpEntries())
|
||||
printJSON(cfg.DumpEntries())
|
||||
return
|
||||
case common.CommandDebugListProviders:
|
||||
printJSON(config.DumpProviders())
|
||||
printJSON(cfg.DumpProviders())
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -115,58 +110,25 @@ func main() {
|
|||
logging.Warn().Msg("API JWT secret is empty, authentication is disabled")
|
||||
}
|
||||
|
||||
cfg.StartProxyProviders()
|
||||
cfg.Start()
|
||||
config.WatchChanges()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT)
|
||||
signal.Notify(sig, syscall.SIGTERM)
|
||||
signal.Notify(sig, syscall.SIGHUP)
|
||||
|
||||
autocert := config.GetAutoCertProvider()
|
||||
if autocert != nil {
|
||||
if err := autocert.Setup(); err != nil {
|
||||
E.LogFatal("autocert setup error", err)
|
||||
}
|
||||
} else {
|
||||
logging.Info().Msg("autocert not configured")
|
||||
}
|
||||
|
||||
server.StartServer(server.Options{
|
||||
Name: "proxy",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.ProxyHTTPAddr,
|
||||
HTTPSAddr: common.ProxyHTTPSAddr,
|
||||
Handler: http.HandlerFunc(entrypoint.Handler),
|
||||
})
|
||||
|
||||
// Initialize authentication providers
|
||||
if err := auth.Initialize(); err != nil {
|
||||
logging.Warn().Err(err).Msg("Failed to initialize authentication providers")
|
||||
}
|
||||
|
||||
server.StartServer(server.Options{
|
||||
Name: "api",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(),
|
||||
})
|
||||
|
||||
if common.PrometheusEnabled {
|
||||
server.StartServer(server.Options{
|
||||
Name: "metrics",
|
||||
CertProvider: autocert,
|
||||
HTTPAddr: common.MetricsHTTPAddr,
|
||||
Handler: metrics.NewHandler(),
|
||||
})
|
||||
}
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT)
|
||||
signal.Notify(sig, syscall.SIGTERM)
|
||||
signal.Notify(sig, syscall.SIGHUP)
|
||||
|
||||
// wait for signal
|
||||
<-sig
|
||||
|
||||
// gracefully shutdown
|
||||
// grafully shutdown
|
||||
logging.Info().Msg("shutting down")
|
||||
_ = task.GracefulShutdown(time.Second * time.Duration(config.Value().TimeoutShutdown))
|
||||
_ = task.GracefulShutdown(time.Second * time.Duration(cfg.Value().TimeoutShutdown))
|
||||
}
|
||||
|
||||
func prepareDirectory(dir string) {
|
||||
|
|
1
go.mod
1
go.mod
|
@ -69,6 +69,7 @@ require (
|
|||
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/oauth2 v0.25.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/tools v0.29.0 // indirect
|
||||
|
|
|
@ -8,20 +8,17 @@ import (
|
|||
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
||||
. "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
)
|
||||
|
||||
type ServeMux struct{ *http.ServeMux }
|
||||
|
||||
func NewServeMux() ServeMux {
|
||||
return ServeMux{http.NewServeMux()}
|
||||
}
|
||||
|
||||
func (mux ServeMux) HandleFunc(method, endpoint string, handler http.HandlerFunc) {
|
||||
mux.ServeMux.HandleFunc(method+" "+endpoint, checkHost(handler))
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
mux := NewServeMux()
|
||||
func NewHandler(cfg config.ConfigInstance) http.Handler {
|
||||
mux := ServeMux{http.NewServeMux()}
|
||||
mux.HandleFunc("GET", "/v1", v1.Index)
|
||||
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
|
||||
mux.HandleFunc("POST", "/v1/login", auth.LoginHandler)
|
||||
|
@ -30,19 +27,25 @@ func NewHandler() http.Handler {
|
|||
mux.HandleFunc("GET", "/v1/auth/callback", auth.OIDCCallbackHandler)
|
||||
mux.HandleFunc("GET", "/v1/logout", auth.LogoutHandler)
|
||||
mux.HandleFunc("POST", "/v1/logout", auth.LogoutHandler)
|
||||
mux.HandleFunc("POST", "/v1/reload", v1.Reload)
|
||||
mux.HandleFunc("GET", "/v1/list", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}/{which}", auth.RequireAuth(v1.List))
|
||||
mux.HandleFunc("POST", "/v1/reload", useCfg(cfg, v1.Reload))
|
||||
mux.HandleFunc("GET", "/v1/list", auth.RequireAuth(useCfg(cfg, v1.List)))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}", auth.RequireAuth(useCfg(cfg, v1.List)))
|
||||
mux.HandleFunc("GET", "/v1/list/{what}/{which}", auth.RequireAuth(useCfg(cfg, v1.List)))
|
||||
mux.HandleFunc("GET", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.GetFileContent))
|
||||
mux.HandleFunc("POST", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("PUT", "/v1/file/{type}/{filename}", auth.RequireAuth(v1.SetFileContent))
|
||||
mux.HandleFunc("GET", "/v1/schema/{filename...}", v1.GetSchemaFile)
|
||||
mux.HandleFunc("GET", "/v1/stats", v1.Stats)
|
||||
mux.HandleFunc("GET", "/v1/stats/ws", v1.StatsWS)
|
||||
mux.HandleFunc("GET", "/v1/stats", useCfg(cfg, v1.Stats))
|
||||
mux.HandleFunc("GET", "/v1/stats/ws", useCfg(cfg, v1.StatsWS))
|
||||
return mux
|
||||
}
|
||||
|
||||
func useCfg(cfg config.ConfigInstance, handler func(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
handler(cfg, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// allow only requests to API server with localhost.
|
||||
func checkHost(f http.HandlerFunc) http.HandlerFunc {
|
||||
if common.IsDebug {
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route/provider"
|
||||
|
|
|
@ -6,9 +6,10 @@ import (
|
|||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
@ -24,7 +25,7 @@ const (
|
|||
ListTasks = "tasks"
|
||||
)
|
||||
|
||||
func List(w http.ResponseWriter, r *http.Request) {
|
||||
func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
what := r.PathValue("what")
|
||||
if what == "" {
|
||||
what = ListRoutes
|
||||
|
@ -40,7 +41,7 @@ func List(w http.ResponseWriter, r *http.Request) {
|
|||
U.RespondJSON(w, r, route)
|
||||
}
|
||||
case ListRoutes:
|
||||
U.RespondJSON(w, r, config.RoutesByAlias(route.RouteType(r.FormValue("type"))))
|
||||
U.RespondJSON(w, r, routes.RoutesByAlias(route.RouteType(r.FormValue("type"))))
|
||||
case ListFiles:
|
||||
listFiles(w, r)
|
||||
case ListMiddlewares:
|
||||
|
@ -48,9 +49,9 @@ func List(w http.ResponseWriter, r *http.Request) {
|
|||
case ListMiddlewareTraces:
|
||||
U.RespondJSON(w, r, middleware.GetAllTrace())
|
||||
case ListMatchDomains:
|
||||
U.RespondJSON(w, r, config.Value().MatchDomains)
|
||||
U.RespondJSON(w, r, cfg.Value().MatchDomains)
|
||||
case ListHomepageConfig:
|
||||
U.RespondJSON(w, r, config.HomepageConfig())
|
||||
U.RespondJSON(w, r, routes.HomepageConfig(cfg.Value().Homepage.UseDefaultCategories))
|
||||
case ListTasks:
|
||||
U.RespondJSON(w, r, task.DebugTaskList())
|
||||
default:
|
||||
|
@ -60,9 +61,9 @@ func List(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func listRoute(which string) any {
|
||||
if which == "" || which == "all" {
|
||||
return config.RoutesByAlias()
|
||||
return routes.RoutesByAlias()
|
||||
}
|
||||
routes := config.RoutesByAlias()
|
||||
routes := routes.RoutesByAlias()
|
||||
route, ok := routes[which]
|
||||
if !ok {
|
||||
return nil
|
||||
|
|
|
@ -4,11 +4,11 @@ import (
|
|||
"net/http"
|
||||
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
)
|
||||
|
||||
func Reload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := config.Reload(); err != nil {
|
||||
func Reload(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
if err := cfg.Reload(); err != nil {
|
||||
U.HandleErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -9,25 +9,25 @@ import (
|
|||
"github.com/coder/websocket/wsjson"
|
||||
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config"
|
||||
config "github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func Stats(w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, getStats())
|
||||
func Stats(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
U.RespondJSON(w, r, getStats(cfg))
|
||||
}
|
||||
|
||||
func StatsWS(w http.ResponseWriter, r *http.Request) {
|
||||
func StatsWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||
var originPats []string
|
||||
|
||||
localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"}
|
||||
|
||||
if len(config.Value().MatchDomains) == 0 {
|
||||
if len(cfg.Value().MatchDomains) == 0 {
|
||||
U.LogWarn(r).Msg("no match domains configured, accepting websocket API request from all origins")
|
||||
originPats = []string{"*"}
|
||||
} else {
|
||||
originPats = make([]string, len(config.Value().MatchDomains))
|
||||
for i, domain := range config.Value().MatchDomains {
|
||||
originPats = make([]string, len(cfg.Value().MatchDomains))
|
||||
for i, domain := range cfg.Value().MatchDomains {
|
||||
originPats[i] = "*" + domain
|
||||
}
|
||||
originPats = append(originPats, localAddresses...)
|
||||
|
@ -52,7 +52,7 @@ func StatsWS(w http.ResponseWriter, r *http.Request) {
|
|||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
stats := getStats()
|
||||
stats := getStats(cfg)
|
||||
if err := wsjson.Write(ctx, conn, stats); err != nil {
|
||||
U.LogError(r).Msg("failed to write JSON")
|
||||
return
|
||||
|
@ -62,9 +62,9 @@ func StatsWS(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var startTime = time.Now()
|
||||
|
||||
func getStats() map[string]any {
|
||||
func getStats(cfg config.ConfigInstance) map[string]any {
|
||||
return map[string]any{
|
||||
"proxies": config.Statistics(),
|
||||
"proxies": cfg.Statistics(),
|
||||
"uptime": strutils.FormatDuration(time.Since(startTime)),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
|
@ -148,28 +149,40 @@ func (p *Provider) ShouldRenewOn() time.Time {
|
|||
panic("no certificate available")
|
||||
}
|
||||
|
||||
func (p *Provider) ScheduleRenewal() {
|
||||
func (p *Provider) ScheduleRenewal(parent task.Parent) {
|
||||
if p.GetName() == ProviderLocal {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
task := task.RootTask("cert-renew-scheduler", true)
|
||||
lastErrOn := time.Time{}
|
||||
renewalTime := p.ShouldRenewOn()
|
||||
timer := time.NewTimer(time.Until(renewalTime))
|
||||
defer timer.Stop()
|
||||
|
||||
task := parent.Subtask("cert-renew-scheduler")
|
||||
defer task.Finish(nil)
|
||||
|
||||
for {
|
||||
renewalTime := p.ShouldRenewOn()
|
||||
timer := time.NewTimer(time.Until(renewalTime))
|
||||
|
||||
select {
|
||||
case <-task.Context().Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
// Retry after 1 hour on failure
|
||||
if time.Now().Before(lastErrOn.Add(time.Hour)) {
|
||||
continue
|
||||
}
|
||||
if err := p.renewIfNeeded(); err != nil {
|
||||
E.LogWarn("cert renew failed", err, &logger)
|
||||
// Retry after 1 hour on failure
|
||||
time.Sleep(time.Hour)
|
||||
lastErrOn = time.Now()
|
||||
continue
|
||||
}
|
||||
// Reset on success
|
||||
lastErrOn = time.Time{}
|
||||
renewalTime = p.ShouldRenewOn()
|
||||
timer.Reset(time.Until(renewalTime))
|
||||
default:
|
||||
// Allow other tasks to run
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -18,8 +18,6 @@ func (p *Provider) Setup() (err E.Error) {
|
|||
}
|
||||
}
|
||||
|
||||
p.ScheduleRenewal()
|
||||
|
||||
for _, expiry := range p.GetExpiries() {
|
||||
logger.Info().Msg("certificate expire on " + strutils.FormatTime(expiry))
|
||||
break
|
||||
|
|
|
@ -11,6 +11,105 @@ import (
|
|||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
prefixes = []string{"GODOXY_", "GOPROXY_", ""}
|
||||
|
||||
IsTest = GetEnvBool("TEST", false) || strings.HasSuffix(os.Args[0], ".test")
|
||||
IsDebug = GetEnvBool("DEBUG", IsTest)
|
||||
IsDebugSkipAuth = GetEnvBool("DEBUG_SKIP_AUTH", false)
|
||||
IsTrace = GetEnvBool("TRACE", false) && IsDebug
|
||||
IsProduction = !IsTest && !IsDebug
|
||||
|
||||
ProxyHTTPAddr,
|
||||
ProxyHTTPHost,
|
||||
ProxyHTTPPort,
|
||||
ProxyHTTPURL = GetAddrEnv("HTTP_ADDR", ":80", "http")
|
||||
|
||||
ProxyHTTPSAddr,
|
||||
ProxyHTTPSHost,
|
||||
ProxyHTTPSPort,
|
||||
ProxyHTTPSURL = GetAddrEnv("HTTPS_ADDR", ":443", "https")
|
||||
|
||||
APIHTTPAddr,
|
||||
APIHTTPHost,
|
||||
APIHTTPPort,
|
||||
APIHTTPURL = GetAddrEnv("API_ADDR", "127.0.0.1:8888", "http")
|
||||
|
||||
MetricsHTTPAddr,
|
||||
MetricsHTTPHost,
|
||||
MetricsHTTPPort,
|
||||
MetricsHTTPURL = GetAddrEnv("PROMETHEUS_ADDR", "", "http")
|
||||
PrometheusEnabled = MetricsHTTPURL != ""
|
||||
|
||||
APIJWTSecret = decodeJWTKey(GetEnvString("API_JWT_SECRET", ""))
|
||||
APIJWTTokenTTL = GetDurationEnv("API_JWT_TOKEN_TTL", time.Hour)
|
||||
APIUser = GetEnvString("API_USER", "admin")
|
||||
APIPasswordHash = HashPassword(GetEnvString("API_PASSWORD", "password"))
|
||||
)
|
||||
|
||||
func GetEnv[T any](key string, defaultValue T, parser func(string) (T, error)) T {
|
||||
var value string
|
||||
var ok bool
|
||||
for _, prefix := range prefixes {
|
||||
value, ok = os.LookupEnv(prefix + key)
|
||||
if ok && value != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok || value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
parsed, err := parser(value)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
log.Fatal().Err(err).Msgf("env %s: invalid %T value: %s", key, parsed, value)
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func GetEnvString(key string, defaultValue string) string {
|
||||
return GetEnv(key, defaultValue, func(s string) (string, error) {
|
||||
return s, nil
|
||||
})
|
||||
}
|
||||
|
||||
func GetEnvBool(key string, defaultValue bool) bool {
|
||||
return GetEnv(key, defaultValue, strconv.ParseBool)
|
||||
}
|
||||
|
||||
func GetAddrEnv(key, defaultValue, scheme string) (addr, host, port, fullURL string) {
|
||||
addr = GetEnvString(key, defaultValue)
|
||||
if addr == "" {
|
||||
return
|
||||
}
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
log.Fatal().Msgf("env %s: invalid address: %s", key, addr)
|
||||
}
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
fullURL = fmt.Sprintf("%s://%s:%s", scheme, host, port)
|
||||
return
|
||||
}
|
||||
|
||||
func GetDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||
return GetEnv(key, defaultValue, time.ParseDuration)
|
||||
}
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
prefixes = []string{"GODOXY_", "GOPROXY_", ""}
|
||||
|
||||
|
|
|
@ -7,12 +7,15 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/api"
|
||||
"github.com/yusing/go-proxy/internal/autocert"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/config/types"
|
||||
"github.com/yusing/go-proxy/internal/entrypoint"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/metrics"
|
||||
"github.com/yusing/go-proxy/internal/net/http/server"
|
||||
"github.com/yusing/go-proxy/internal/notif"
|
||||
proxy "github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
|
@ -26,7 +29,9 @@ type Config struct {
|
|||
value *types.Config
|
||||
providers F.Map[string, *proxy.Provider]
|
||||
autocertProvider *autocert.Provider
|
||||
task *task.Task
|
||||
entrypoint *entrypoint.Entrypoint
|
||||
|
||||
task *task.Task
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -45,15 +50,18 @@ Make sure you rename it back before next time you start.`
|
|||
You may run "ls-config" to show or dump the current config.`
|
||||
)
|
||||
|
||||
var Validate = types.Validate
|
||||
|
||||
func GetInstance() *Config {
|
||||
return instance
|
||||
}
|
||||
|
||||
func newConfig() *Config {
|
||||
return &Config{
|
||||
value: types.DefaultConfig(),
|
||||
providers: F.NewMapOf[string, *proxy.Provider](),
|
||||
task: task.RootTask("config", false),
|
||||
value: types.DefaultConfig(),
|
||||
providers: F.NewMapOf[string, *proxy.Provider](),
|
||||
entrypoint: entrypoint.NewEntrypoint(),
|
||||
task: task.RootTask("config", false),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -66,11 +74,6 @@ func Load() (*Config, E.Error) {
|
|||
return instance, instance.load()
|
||||
}
|
||||
|
||||
func Validate(data []byte) E.Error {
|
||||
var model types.Config
|
||||
return utils.DeserializeYAML(data, &model)
|
||||
}
|
||||
|
||||
func MatchDomains() []string {
|
||||
return instance.value.MatchDomains
|
||||
}
|
||||
|
@ -101,6 +104,7 @@ func OnConfigChange(ev []events.Event) {
|
|||
}
|
||||
|
||||
if err := Reload(); err != nil {
|
||||
logger.Warn().Msg("using last config")
|
||||
// recovered in event queue
|
||||
panic(err)
|
||||
}
|
||||
|
@ -122,15 +126,19 @@ func Reload() E.Error {
|
|||
// -> replace config -> start new subtasks
|
||||
instance.task.Finish("config changed")
|
||||
instance = newCfg
|
||||
instance.StartProxyProviders()
|
||||
instance.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Value() types.Config {
|
||||
return *instance.value
|
||||
func (cfg *Config) Value() *types.Config {
|
||||
return instance.value
|
||||
}
|
||||
|
||||
func GetAutoCertProvider() *autocert.Provider {
|
||||
func (cfg *Config) Reload() E.Error {
|
||||
return Reload()
|
||||
}
|
||||
|
||||
func (cfg *Config) AutoCertProvider() *autocert.Provider {
|
||||
return instance.autocertProvider
|
||||
}
|
||||
|
||||
|
@ -138,6 +146,26 @@ func (cfg *Config) Task() *task.Task {
|
|||
return cfg.task
|
||||
}
|
||||
|
||||
func (cfg *Config) Start() {
|
||||
cfg.StartAutoCert()
|
||||
cfg.StartProxyProviders()
|
||||
cfg.StartServers()
|
||||
}
|
||||
|
||||
func (cfg *Config) StartAutoCert() {
|
||||
autocert := cfg.autocertProvider
|
||||
if autocert == nil {
|
||||
logging.Info().Msg("autocert not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := autocert.Setup(); err != nil {
|
||||
E.LogFatal("autocert setup error", err)
|
||||
} else {
|
||||
autocert.ScheduleRenewal(cfg.task)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) StartProxyProviders() {
|
||||
errs := cfg.providers.CollectErrorsParallel(
|
||||
func(_ string, p *proxy.Provider) error {
|
||||
|
@ -149,6 +177,30 @@ func (cfg *Config) StartProxyProviders() {
|
|||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) StartServers() {
|
||||
server.StartServer(cfg.task, server.Options{
|
||||
Name: "proxy",
|
||||
CertProvider: cfg.AutoCertProvider(),
|
||||
HTTPAddr: common.ProxyHTTPAddr,
|
||||
HTTPSAddr: common.ProxyHTTPSAddr,
|
||||
Handler: cfg.entrypoint,
|
||||
})
|
||||
server.StartServer(cfg.task, server.Options{
|
||||
Name: "api",
|
||||
CertProvider: cfg.AutoCertProvider(),
|
||||
HTTPAddr: common.APIHTTPAddr,
|
||||
Handler: api.NewHandler(cfg),
|
||||
})
|
||||
if common.PrometheusEnabled {
|
||||
server.StartServer(cfg.task, server.Options{
|
||||
Name: "metrics",
|
||||
CertProvider: cfg.AutoCertProvider(),
|
||||
HTTPAddr: common.MetricsHTTPAddr,
|
||||
Handler: metrics.NewHandler(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) load() E.Error {
|
||||
const errMsg = "config load error"
|
||||
|
||||
|
@ -164,8 +216,8 @@ func (cfg *Config) load() E.Error {
|
|||
|
||||
// errors are non fatal below
|
||||
errs := E.NewBuilder(errMsg)
|
||||
errs.Add(entrypoint.SetMiddlewares(model.Entrypoint.Middlewares))
|
||||
errs.Add(entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog))
|
||||
errs.Add(cfg.entrypoint.SetMiddlewares(model.Entrypoint.Middlewares))
|
||||
errs.Add(cfg.entrypoint.SetAccessLogger(cfg.task, model.Entrypoint.AccessLog))
|
||||
errs.Add(cfg.initNotification(model.Providers.Notification))
|
||||
errs.Add(cfg.initAutoCert(model.AutoCert))
|
||||
errs.Add(cfg.loadRouteProviders(&model.Providers))
|
||||
|
@ -176,7 +228,8 @@ func (cfg *Config) load() E.Error {
|
|||
model.MatchDomains[i] = "." + domain
|
||||
}
|
||||
}
|
||||
entrypoint.SetFindRouteDomains(model.MatchDomains)
|
||||
cfg.entrypoint.SetFindRouteDomains(model.MatchDomains)
|
||||
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,20 +1,14 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
route "github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/entry"
|
||||
proxy "github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
"github.com/yusing/go-proxy/internal/route/provider"
|
||||
"github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func DumpEntries() map[string]*types.RawEntry {
|
||||
func (cfg *Config) DumpEntries() map[string]*types.RawEntry {
|
||||
entries := make(map[string]*types.RawEntry)
|
||||
instance.providers.RangeAll(func(_ string, p *proxy.Provider) {
|
||||
cfg.providers.RangeAll(func(_ string, p *provider.Provider) {
|
||||
p.RangeRoutes(func(alias string, r *route.Route) {
|
||||
entries[alias] = r.Entry
|
||||
})
|
||||
|
@ -22,107 +16,20 @@ func DumpEntries() map[string]*types.RawEntry {
|
|||
return entries
|
||||
}
|
||||
|
||||
func DumpProviders() map[string]*proxy.Provider {
|
||||
entries := make(map[string]*proxy.Provider)
|
||||
instance.providers.RangeAll(func(name string, p *proxy.Provider) {
|
||||
func (cfg *Config) DumpProviders() map[string]*provider.Provider {
|
||||
entries := make(map[string]*provider.Provider)
|
||||
cfg.providers.RangeAll(func(name string, p *provider.Provider) {
|
||||
entries[name] = p
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func HomepageConfig() homepage.Config {
|
||||
hpCfg := homepage.NewHomePageConfig()
|
||||
routes.GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
|
||||
en := r.RawEntry()
|
||||
item := en.Homepage
|
||||
if item == nil {
|
||||
item = new(homepage.Item)
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.IsEmpty() {
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.Show {
|
||||
return
|
||||
}
|
||||
|
||||
item.Alias = alias
|
||||
|
||||
if item.Name == "" {
|
||||
item.Name = strutils.Title(
|
||||
strings.ReplaceAll(
|
||||
strings.ReplaceAll(alias, "-", " "),
|
||||
"_", " ",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if instance.value.Homepage.UseDefaultCategories {
|
||||
if en.Container != nil && item.Category == "" {
|
||||
if category, ok := homepage.PredefinedCategories[en.Container.ImageName]; ok {
|
||||
item.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
if item.Category == "" {
|
||||
if category, ok := homepage.PredefinedCategories[strings.ToLower(alias)]; ok {
|
||||
item.Category = category
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.IsDocker(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Docker"
|
||||
}
|
||||
item.SourceType = string(proxy.ProviderTypeDocker)
|
||||
case entry.UseLoadBalance(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Load-balanced"
|
||||
}
|
||||
item.SourceType = "loadbalancer"
|
||||
default:
|
||||
if item.Category == "" {
|
||||
item.Category = "Others"
|
||||
}
|
||||
item.SourceType = string(proxy.ProviderTypeFile)
|
||||
}
|
||||
|
||||
item.AltURL = r.TargetURL().String()
|
||||
hpCfg.Add(item)
|
||||
})
|
||||
return hpCfg
|
||||
}
|
||||
|
||||
func RoutesByAlias(typeFilter ...route.RouteType) map[string]any {
|
||||
rts := make(map[string]any)
|
||||
if len(typeFilter) == 0 || typeFilter[0] == "" {
|
||||
typeFilter = []route.RouteType{route.RouteTypeReverseProxy, route.RouteTypeStream}
|
||||
}
|
||||
for _, t := range typeFilter {
|
||||
switch t {
|
||||
case route.RouteTypeReverseProxy:
|
||||
routes.GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
|
||||
rts[alias] = r
|
||||
})
|
||||
case route.RouteTypeStream:
|
||||
routes.GetStreamRoutes().RangeAll(func(alias string, r types.StreamRoute) {
|
||||
rts[alias] = r
|
||||
})
|
||||
}
|
||||
}
|
||||
return rts
|
||||
}
|
||||
|
||||
func Statistics() map[string]any {
|
||||
func (cfg *Config) Statistics() map[string]any {
|
||||
nTotalStreams := 0
|
||||
nTotalRPs := 0
|
||||
providerStats := make(map[string]proxy.ProviderStats)
|
||||
providerStats := make(map[string]provider.ProviderStats)
|
||||
|
||||
instance.providers.RangeAll(func(name string, p *proxy.Provider) {
|
||||
cfg.providers.RangeAll(func(name string, p *provider.Provider) {
|
||||
stats := p.Statistics()
|
||||
providerStats[name] = stats
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@ package types
|
|||
import (
|
||||
"github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -24,6 +26,12 @@ type (
|
|||
AccessLog *accesslog.Config `json:"access_log" validate:"omitempty"`
|
||||
}
|
||||
NotificationConfig map[string]any
|
||||
|
||||
ConfigInstance interface {
|
||||
Value() *Config
|
||||
Reload() E.Error
|
||||
Statistics() map[string]any
|
||||
}
|
||||
)
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
@ -35,6 +43,11 @@ func DefaultConfig() *Config {
|
|||
}
|
||||
}
|
||||
|
||||
func Validate(data []byte) E.Error {
|
||||
var model Config
|
||||
return utils.DeserializeYAML(data, &model)
|
||||
}
|
||||
|
||||
func init() {
|
||||
utils.RegisterDefaultValueFactory(DefaultConfig)
|
||||
}
|
||||
|
|
|
@ -28,16 +28,17 @@ type (
|
|||
PrivateIP string `json:"private_ip"`
|
||||
NetworkMode string `json:"network_mode"`
|
||||
|
||||
Aliases []string `json:"aliases"`
|
||||
IsExcluded bool `json:"is_excluded"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
IsDatabase bool `json:"is_database"`
|
||||
IdleTimeout string `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout string `json:"wake_timeout,omitempty"`
|
||||
StopMethod string `json:"stop_method,omitempty"`
|
||||
StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only
|
||||
StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only
|
||||
Running bool `json:"running"`
|
||||
Aliases []string `json:"aliases"`
|
||||
IsExcluded bool `json:"is_excluded"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
IsDatabase bool `json:"is_database"`
|
||||
IdleTimeout string `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout string `json:"wake_timeout,omitempty"`
|
||||
StopMethod string `json:"stop_method,omitempty"`
|
||||
StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only
|
||||
StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only
|
||||
StartEndpoint string `json:"start_endpoint,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -58,16 +59,17 @@ func FromDocker(c *types.Container, dockerHost string) (res *Container) {
|
|||
PrivatePortMapping: helper.getPrivatePortMapping(),
|
||||
NetworkMode: c.HostConfig.NetworkMode,
|
||||
|
||||
Aliases: helper.getAliases(),
|
||||
IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)),
|
||||
IsExplicit: isExplicit,
|
||||
IsDatabase: helper.isDatabase(),
|
||||
IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout),
|
||||
WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout),
|
||||
StopMethod: helper.getDeleteLabel(LabelStopMethod),
|
||||
StopTimeout: helper.getDeleteLabel(LabelStopTimeout),
|
||||
StopSignal: helper.getDeleteLabel(LabelStopSignal),
|
||||
Running: c.Status == "running" || c.State == "running",
|
||||
Aliases: helper.getAliases(),
|
||||
IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)),
|
||||
IsExplicit: isExplicit,
|
||||
IsDatabase: helper.isDatabase(),
|
||||
IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout),
|
||||
WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout),
|
||||
StopMethod: helper.getDeleteLabel(LabelStopMethod),
|
||||
StopTimeout: helper.getDeleteLabel(LabelStopTimeout),
|
||||
StopSignal: helper.getDeleteLabel(LabelStopSignal),
|
||||
StartEndpoint: helper.getDeleteLabel(LabelStartEndpoint),
|
||||
Running: c.Status == "running" || c.State == "running",
|
||||
}
|
||||
res.setPrivateIP(helper)
|
||||
res.setPublicIP()
|
||||
|
|
|
@ -2,6 +2,8 @@ package types
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/docker"
|
||||
|
@ -10,11 +12,12 @@ import (
|
|||
|
||||
type (
|
||||
Config struct {
|
||||
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
|
||||
StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument
|
||||
StopMethod StopMethod `json:"stop_method,omitempty"`
|
||||
StopSignal Signal `json:"stop_signal,omitempty"`
|
||||
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
|
||||
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
|
||||
StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument
|
||||
StopMethod StopMethod `json:"stop_method,omitempty"`
|
||||
StopSignal Signal `json:"stop_signal,omitempty"`
|
||||
StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container
|
||||
|
||||
DockerHost string `json:"docker_host,omitempty"`
|
||||
ContainerName string `json:"container_name,omitempty"`
|
||||
|
@ -58,17 +61,19 @@ func ValidateConfig(cont *docker.Container) (*Config, E.Error) {
|
|||
stopTimeout := E.Collect(errs, validateDurationPostitive, cont.StopTimeout)
|
||||
stopMethod := E.Collect(errs, validateStopMethod, cont.StopMethod)
|
||||
signal := E.Collect(errs, validateSignal, cont.StopSignal)
|
||||
startEndpoint := E.Collect(errs, validateStartEndpoint, cont.StartEndpoint)
|
||||
|
||||
if errs.HasError() {
|
||||
return nil, errs.Error()
|
||||
}
|
||||
|
||||
return &Config{
|
||||
IdleTimeout: idleTimeout,
|
||||
WakeTimeout: wakeTimeout,
|
||||
StopTimeout: int(stopTimeout.Seconds()),
|
||||
StopMethod: stopMethod,
|
||||
StopSignal: signal,
|
||||
IdleTimeout: idleTimeout,
|
||||
WakeTimeout: wakeTimeout,
|
||||
StopTimeout: int(stopTimeout.Seconds()),
|
||||
StopMethod: stopMethod,
|
||||
StopSignal: signal,
|
||||
StartEndpoint: startEndpoint,
|
||||
|
||||
DockerHost: cont.DockerHost,
|
||||
ContainerName: cont.ContainerName,
|
||||
|
@ -104,3 +109,21 @@ func validateStopMethod(s string) (StopMethod, error) {
|
|||
return "", errors.New("invalid stop method " + s)
|
||||
}
|
||||
}
|
||||
|
||||
func validateStartEndpoint(s string) (string, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
// checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195
|
||||
// emulate browser and strip the '#' suffix prior to validation. see issue-#237
|
||||
if i := strings.Index(s, "#"); i > -1 {
|
||||
s = s[:i]
|
||||
}
|
||||
if len(s) == 0 {
|
||||
return "", errors.New("start endpoint must not be empty if defined")
|
||||
}
|
||||
if _, err := url.ParseRequestURI(s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
|
47
internal/docker/idlewatcher/types/config_test.go
Normal file
47
internal/docker/idlewatcher/types/config_test.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestValidateStartEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
input: "/start",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
input: "../foo",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "single fragment",
|
||||
input: "#",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, err := validateStartEndpoint(tc.input)
|
||||
if err == nil {
|
||||
ExpectEqual(t, s, tc.input)
|
||||
}
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ import (
|
|||
"github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/metrics"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
|
||||
net "github.com/yusing/go-proxy/internal/net/types"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
|
@ -22,7 +22,7 @@ type (
|
|||
waker struct {
|
||||
_ U.NoCopy
|
||||
|
||||
rp *gphttp.ReverseProxy
|
||||
rp *reverseproxy.ReverseProxy
|
||||
stream net.Stream
|
||||
hc health.HealthChecker
|
||||
metric *metrics.Gauge
|
||||
|
@ -38,7 +38,7 @@ const (
|
|||
|
||||
// TODO: support stream
|
||||
|
||||
func newWaker(parent task.Parent, entry route.Entry, rp *gphttp.ReverseProxy, stream net.Stream) (Waker, E.Error) {
|
||||
func newWaker(parent task.Parent, entry route.Entry, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, E.Error) {
|
||||
hcCfg := entry.RawEntry().HealthCheck
|
||||
hcCfg.Timeout = idleWakerCheckTimeout
|
||||
|
||||
|
@ -71,7 +71,7 @@ func newWaker(parent task.Parent, entry route.Entry, rp *gphttp.ReverseProxy, st
|
|||
}
|
||||
|
||||
// lifetime should follow route provider.
|
||||
func NewHTTPWaker(parent task.Parent, entry route.Entry, rp *gphttp.ReverseProxy) (Waker, E.Error) {
|
||||
func NewHTTPWaker(parent task.Parent, entry route.Entry, rp *reverseproxy.ReverseProxy) (Waker, E.Error) {
|
||||
return newWaker(parent, entry, rp, nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -34,6 +34,12 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
|
|||
return true
|
||||
}
|
||||
|
||||
// Check if start endpoint is configured and request path matches
|
||||
if w.StartEndpoint != "" && r.URL.Path != w.StartEndpoint {
|
||||
http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
}
|
||||
|
|
|
@ -5,11 +5,12 @@ const (
|
|||
|
||||
NSProxy = "proxy"
|
||||
|
||||
LabelAliases = NSProxy + ".aliases"
|
||||
LabelExclude = NSProxy + ".exclude"
|
||||
LabelIdleTimeout = NSProxy + ".idle_timeout"
|
||||
LabelWakeTimeout = NSProxy + ".wake_timeout"
|
||||
LabelStopMethod = NSProxy + ".stop_method"
|
||||
LabelStopTimeout = NSProxy + ".stop_timeout"
|
||||
LabelStopSignal = NSProxy + ".stop_signal"
|
||||
LabelAliases = NSProxy + ".aliases"
|
||||
LabelExclude = NSProxy + ".exclude"
|
||||
LabelIdleTimeout = NSProxy + ".idle_timeout"
|
||||
LabelWakeTimeout = NSProxy + ".wake_timeout"
|
||||
LabelStopMethod = NSProxy + ".stop_method"
|
||||
LabelStopTimeout = NSProxy + ".stop_timeout"
|
||||
LabelStopSignal = NSProxy + ".stop_signal"
|
||||
LabelStartEndpoint = NSProxy + ".start_endpoint"
|
||||
)
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
|
@ -17,32 +16,31 @@ import (
|
|||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
var findRouteFunc = findRouteAnyDomain
|
||||
|
||||
var (
|
||||
epMiddleware *middleware.Middleware
|
||||
epMiddlewareMu sync.Mutex
|
||||
|
||||
epAccessLogger *accesslog.AccessLogger
|
||||
epAccessLoggerMu sync.Mutex
|
||||
)
|
||||
type Entrypoint struct {
|
||||
middleware *middleware.Middleware
|
||||
accessLogger *accesslog.AccessLogger
|
||||
findRouteFunc func(host string) (route.HTTPRoute, error)
|
||||
}
|
||||
|
||||
var ErrNoSuchRoute = errors.New("no such route")
|
||||
|
||||
func SetFindRouteDomains(domains []string) {
|
||||
if len(domains) == 0 {
|
||||
findRouteFunc = findRouteAnyDomain
|
||||
} else {
|
||||
findRouteFunc = findRouteByDomains(domains)
|
||||
func NewEntrypoint() *Entrypoint {
|
||||
return &Entrypoint{
|
||||
findRouteFunc: findRouteAnyDomain,
|
||||
}
|
||||
}
|
||||
|
||||
func SetMiddlewares(mws []map[string]any) error {
|
||||
epMiddlewareMu.Lock()
|
||||
defer epMiddlewareMu.Unlock()
|
||||
func (ep *Entrypoint) SetFindRouteDomains(domains []string) {
|
||||
if len(domains) == 0 {
|
||||
ep.findRouteFunc = findRouteAnyDomain
|
||||
} else {
|
||||
ep.findRouteFunc = findRouteByDomains(domains)
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error {
|
||||
if len(mws) == 0 {
|
||||
epMiddleware = nil
|
||||
ep.middleware = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -50,22 +48,19 @@ func SetMiddlewares(mws []map[string]any) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
epMiddleware = mid
|
||||
ep.middleware = mid
|
||||
|
||||
logger.Debug().Msg("entrypoint middleware loaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetAccessLogger(parent task.Parent, cfg *accesslog.Config) (err error) {
|
||||
epAccessLoggerMu.Lock()
|
||||
defer epAccessLoggerMu.Unlock()
|
||||
|
||||
func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.Config) (err error) {
|
||||
if cfg == nil {
|
||||
epAccessLogger = nil
|
||||
ep.accessLogger = nil
|
||||
return
|
||||
}
|
||||
|
||||
epAccessLogger, err = accesslog.NewFileAccessLogger(parent, cfg)
|
||||
ep.accessLogger, err = accesslog.NewFileAccessLogger(parent, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -73,28 +68,18 @@ func SetAccessLogger(parent task.Parent, cfg *accesslog.Config) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
mux, err := findRouteFunc(r.Host)
|
||||
func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
mux, err := ep.findRouteFunc(r.Host)
|
||||
if err == nil {
|
||||
if epAccessLogger != nil {
|
||||
epMiddlewareMu.Lock()
|
||||
if epAccessLogger != nil {
|
||||
w = gphttp.NewModifyResponseWriter(w, r, func(resp *http.Response) error {
|
||||
epAccessLogger.Log(r, resp)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
epMiddlewareMu.Unlock()
|
||||
if ep.accessLogger != nil {
|
||||
w = gphttp.NewModifyResponseWriter(w, r, func(resp *http.Response) error {
|
||||
ep.accessLogger.Log(r, resp)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if epMiddleware != nil {
|
||||
epMiddlewareMu.Lock()
|
||||
if epMiddleware != nil {
|
||||
mid := epMiddleware
|
||||
epMiddlewareMu.Unlock()
|
||||
mid.ServeHTTP(mux.ServeHTTP, w, r)
|
||||
return
|
||||
}
|
||||
epMiddlewareMu.Unlock()
|
||||
if ep.middleware != nil {
|
||||
ep.middleware.ServeHTTP(mux.ServeHTTP, w, r)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
return
|
||||
|
|
|
@ -8,18 +8,19 @@ import (
|
|||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
var r route.HTTPRoute
|
||||
var (
|
||||
r route.HTTPRoute
|
||||
ep = NewEntrypoint()
|
||||
)
|
||||
|
||||
func run(t *testing.T, match []string, noMatch []string) {
|
||||
t.Helper()
|
||||
t.Cleanup(routes.TestClear)
|
||||
t.Cleanup(func() {
|
||||
SetFindRouteDomains(nil)
|
||||
})
|
||||
t.Cleanup(func() { ep.SetFindRouteDomains(nil) })
|
||||
|
||||
for _, test := range match {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
found, err := findRouteFunc(test)
|
||||
found, err := ep.findRouteFunc(test)
|
||||
ExpectNoError(t, err)
|
||||
ExpectTrue(t, found == &r)
|
||||
})
|
||||
|
@ -27,7 +28,7 @@ func run(t *testing.T, match []string, noMatch []string) {
|
|||
|
||||
for _, test := range noMatch {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
_, err := findRouteFunc(test)
|
||||
_, err := ep.findRouteFunc(test)
|
||||
ExpectError(t, ErrNoSuchRoute, err)
|
||||
})
|
||||
}
|
||||
|
@ -72,7 +73,7 @@ func TestFindRouteExactHostMatch(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFindRouteByDomains(t *testing.T) {
|
||||
SetFindRouteDomains([]string{
|
||||
ep.SetFindRouteDomains([]string{
|
||||
".domain.com",
|
||||
".sub.domain.com",
|
||||
})
|
||||
|
@ -97,7 +98,7 @@ func TestFindRouteByDomains(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFindRouteByDomainsExactMatch(t *testing.T) {
|
||||
SetFindRouteDomains([]string{
|
||||
ep.SetFindRouteDomains([]string{
|
||||
".domain.com",
|
||||
".sub.domain.com",
|
||||
})
|
||||
|
|
|
@ -8,8 +8,8 @@ import (
|
|||
|
||||
//nolint:errname
|
||||
type withSubject struct {
|
||||
Subject string `json:"subject"`
|
||||
Err error `json:"err"`
|
||||
Subjects []string `json:"subjects"`
|
||||
Err error `json:"err"`
|
||||
}
|
||||
|
||||
const subjectSep = " > "
|
||||
|
@ -30,13 +30,18 @@ func PrependSubject(subject string, err error) error {
|
|||
case Error:
|
||||
return err.Subject(subject)
|
||||
}
|
||||
return &withSubject{subject, err}
|
||||
return &withSubject{[]string{subject}, err}
|
||||
}
|
||||
|
||||
func (err *withSubject) Prepend(subject string) *withSubject {
|
||||
clone := *err
|
||||
if subject != "" {
|
||||
clone.Subject = subject + subjectSep + clone.Subject
|
||||
switch subject[0] {
|
||||
case '[', '(', '{':
|
||||
clone.Subjects[len(clone.Subjects)-1] += subject
|
||||
default:
|
||||
clone.Subjects = append(clone.Subjects, subject)
|
||||
}
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
@ -50,7 +55,22 @@ func (err *withSubject) Unwrap() error {
|
|||
}
|
||||
|
||||
func (err *withSubject) Error() string {
|
||||
subjects := strings.Split(err.Subject, subjectSep)
|
||||
subjects[len(subjects)-1] = highlight(subjects[len(subjects)-1])
|
||||
return strings.Join(subjects, subjectSep) + ": " + err.Err.Error()
|
||||
// subject is in reversed order
|
||||
n := len(err.Subjects)
|
||||
size := 0
|
||||
errStr := err.Err.Error()
|
||||
var sb strings.Builder
|
||||
for _, s := range err.Subjects {
|
||||
size += len(s)
|
||||
}
|
||||
sb.Grow(size + 2 + n*len(subjectSep) + len(errStr))
|
||||
|
||||
for i := n - 1; i > 0; i-- {
|
||||
sb.WriteString(err.Subjects[i])
|
||||
sb.WriteString(subjectSep)
|
||||
}
|
||||
sb.WriteString(highlight(err.Subjects[0]))
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(errStr)
|
||||
return sb.String()
|
||||
}
|
||||
|
|
|
@ -129,7 +129,6 @@ func (l *AccessLogger) Flush(force bool) {
|
|||
l.write(l.buf.Bytes())
|
||||
l.buf.Reset()
|
||||
l.bufMu.Unlock()
|
||||
logger.Debug().Msg("access log flushed to " + l.io.Name())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -170,5 +169,7 @@ func (l *AccessLogger) write(data []byte) {
|
|||
l.io.Unlock()
|
||||
if err != nil {
|
||||
l.handleErr(err)
|
||||
} else {
|
||||
logger.Debug().Msg("access log flushed to " + l.io.Name())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,36 +3,66 @@ package accesslog
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
*os.File
|
||||
sync.Mutex
|
||||
|
||||
// os.File.Name() may not equal to key of `openedFiles`.
|
||||
// Store it for later delete from `openedFiles`.
|
||||
path string
|
||||
|
||||
refCount *utils.RefCount
|
||||
}
|
||||
|
||||
var (
|
||||
openedFiles = make(map[string]AccessLogIO)
|
||||
openedFiles = make(map[string]*File)
|
||||
openedFilesMu sync.Mutex
|
||||
)
|
||||
|
||||
func NewFileAccessLogger(parent task.Parent, cfg *Config) (*AccessLogger, error) {
|
||||
openedFilesMu.Lock()
|
||||
|
||||
var io AccessLogIO
|
||||
if opened, ok := openedFiles[cfg.Path]; ok {
|
||||
io = opened
|
||||
var file *File
|
||||
path := path.Clean(cfg.Path)
|
||||
if opened, ok := openedFiles[path]; ok {
|
||||
opened.refCount.Add()
|
||||
file = opened
|
||||
} else {
|
||||
f, err := os.OpenFile(cfg.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
f, err := os.OpenFile(cfg.Path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
openedFilesMu.Unlock()
|
||||
return nil, fmt.Errorf("access log open error: %w", err)
|
||||
}
|
||||
io = &File{File: f}
|
||||
openedFiles[cfg.Path] = io
|
||||
file = &File{File: f, path: path, refCount: utils.NewRefCounter()}
|
||||
openedFiles[path] = file
|
||||
go file.closeOnZero()
|
||||
}
|
||||
|
||||
openedFilesMu.Unlock()
|
||||
return NewAccessLogger(parent, io, cfg), nil
|
||||
return NewAccessLogger(parent, file, cfg), nil
|
||||
}
|
||||
|
||||
func (f *File) Close() error {
|
||||
f.refCount.Sub()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *File) closeOnZero() {
|
||||
defer logger.Debug().
|
||||
Str("path", f.path).
|
||||
Msg("access log closed")
|
||||
|
||||
<-f.refCount.Zero()
|
||||
|
||||
openedFilesMu.Lock()
|
||||
delete(openedFiles, f.path)
|
||||
openedFilesMu.Unlock()
|
||||
f.File.Close()
|
||||
}
|
||||
|
|
|
@ -2,6 +2,10 @@ package http
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -22,6 +26,48 @@ const (
|
|||
HeaderContentLength = "Content-Length"
|
||||
)
|
||||
|
||||
// Hop-by-hop headers. These are removed when sent to the backend.
|
||||
// As of RFC 7230, hop-by-hop headers are required to appear in the
|
||||
// Connection header field. These are the headers defined by the
|
||||
// obsoleted RFC 2616 (section 13.5.1) and are used for backward
|
||||
// compatibility.
|
||||
var hopHeaders = []string{
|
||||
"Connection",
|
||||
"Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"Te", // canonicalized version of "TE"
|
||||
"Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
|
||||
func UpgradeType(h http.Header) string {
|
||||
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
|
||||
return ""
|
||||
}
|
||||
return h.Get("Upgrade")
|
||||
}
|
||||
|
||||
// RemoveHopByHopHeaders removes hop-by-hop headers.
|
||||
func RemoveHopByHopHeaders(h http.Header) {
|
||||
// RFC 7230, section 6.1: Remove headers listed in the "Connection" header.
|
||||
for _, f := range h["Connection"] {
|
||||
for _, sf := range strutils.SplitComma(f) {
|
||||
if sf = textproto.TrimString(sf); sf != "" {
|
||||
h.Del(sf)
|
||||
}
|
||||
}
|
||||
}
|
||||
// RFC 2616, section 13.5.1: Remove a set of known hop-by-hop headers.
|
||||
// This behavior is superseded by the RFC 7230 Connection header, but
|
||||
// preserve it for backwards compatibility.
|
||||
for _, f := range hopHeaders {
|
||||
h.Del(f)
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveHop(h http.Header) {
|
||||
reqUpType := UpgradeType(h)
|
||||
RemoveHopByHopHeaders(h)
|
||||
|
|
20
internal/net/http/methods.go
Normal file
20
internal/net/http/methods.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package http
|
||||
|
||||
import "net/http"
|
||||
|
||||
var validMethods = map[string]struct{}{
|
||||
http.MethodGet: {},
|
||||
http.MethodHead: {},
|
||||
http.MethodPost: {},
|
||||
http.MethodPut: {},
|
||||
http.MethodPatch: {},
|
||||
http.MethodDelete: {},
|
||||
http.MethodConnect: {},
|
||||
http.MethodOptions: {},
|
||||
http.MethodTrace: {},
|
||||
}
|
||||
|
||||
func IsMethodValid(method string) bool {
|
||||
_, ok := validMethods[method]
|
||||
return ok
|
||||
}
|
|
@ -4,7 +4,10 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||
)
|
||||
|
||||
|
@ -16,7 +19,7 @@ type (
|
|||
}
|
||||
CIDRWhitelistOpts struct {
|
||||
Allow []*types.CIDR `validate:"min=1"`
|
||||
StatusCode int `json:"status_code" aliases:"status" validate:"omitempty,gte=400,lte=599"`
|
||||
StatusCode int `json:"status_code" aliases:"status" validate:"omitempty,status_code"`
|
||||
Message string
|
||||
}
|
||||
)
|
||||
|
@ -30,6 +33,13 @@ var (
|
|||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
utils.MustRegisterValidation("status_code", func(fl validator.FieldLevel) bool {
|
||||
statusCode := fl.Field().Int()
|
||||
return gphttp.IsStatusCodeValid(int(statusCode))
|
||||
})
|
||||
}
|
||||
|
||||
// setup implements MiddlewareWithSetup.
|
||||
func (wl *cidrWhitelist) setup() {
|
||||
wl.CIDRWhitelistOpts = cidrWhitelistDefaults
|
||||
|
|
|
@ -24,6 +24,18 @@ func TestCIDRWhitelistValidation(t *testing.T) {
|
|||
"message": testMessage,
|
||||
})
|
||||
ExpectNoError(t, err)
|
||||
_, err = CIDRWhiteList.New(OptionsRaw{
|
||||
"allow": []string{"192.168.2.100/32"},
|
||||
"message": testMessage,
|
||||
"status": 403,
|
||||
})
|
||||
ExpectNoError(t, err)
|
||||
_, err = CIDRWhiteList.New(OptionsRaw{
|
||||
"allow": []string{"192.168.2.100/32"},
|
||||
"message": testMessage,
|
||||
"status_code": 403,
|
||||
})
|
||||
ExpectNoError(t, err)
|
||||
})
|
||||
t.Run("missing allow", func(t *testing.T) {
|
||||
_, err := CIDRWhiteList.New(OptionsRaw{
|
||||
|
|
|
@ -9,14 +9,15 @@ import (
|
|||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
|
||||
"github.com/yusing/go-proxy/internal/utils"
|
||||
)
|
||||
|
||||
type (
|
||||
Error = E.Error
|
||||
|
||||
ReverseProxy = gphttp.ReverseProxy
|
||||
ProxyRequest = gphttp.ProxyRequest
|
||||
ReverseProxy = reverseproxy.ReverseProxy
|
||||
ProxyRequest = reverseproxy.ProxyRequest
|
||||
|
||||
ImplNewFunc = func() any
|
||||
OptionsRaw = map[string]any
|
||||
|
@ -93,9 +94,9 @@ func (m *Middleware) finalize() {
|
|||
}
|
||||
|
||||
func (m *Middleware) New(optsRaw OptionsRaw) (*Middleware, E.Error) {
|
||||
if m.construct == nil {
|
||||
if optsRaw != nil {
|
||||
panic("bug: middleware already constructed")
|
||||
if m.construct == nil { // likely a middleware from compose
|
||||
if len(optsRaw) != 0 {
|
||||
return nil, E.New("additional options not allowed for middleware ").Subject(m.name)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
|
|
@ -61,17 +61,38 @@ func LoadComposeFiles() {
|
|||
logger.Err(err).Msg("failed to list middleware definitions")
|
||||
return
|
||||
}
|
||||
for _, defFile := range middlewareDefs {
|
||||
voidErrs := E.NewBuilder("") // ignore these errors, will be added in next step
|
||||
mws := BuildMiddlewaresFromComposeFile(defFile, voidErrs)
|
||||
if len(mws) == 0 {
|
||||
continue
|
||||
}
|
||||
for name, m := range mws {
|
||||
name = strutils.ToLowerNoSnake(name)
|
||||
if _, ok := allMiddlewares[name]; ok {
|
||||
errs.Add(ErrDuplicatedMiddleware.Subject(name))
|
||||
continue
|
||||
}
|
||||
allMiddlewares[name] = m
|
||||
logger.Info().
|
||||
Str("src", path.Base(defFile)).
|
||||
Str("name", name).
|
||||
Msg("middleware loaded")
|
||||
}
|
||||
}
|
||||
// build again to resolve cross references
|
||||
for _, defFile := range middlewareDefs {
|
||||
mws := BuildMiddlewaresFromComposeFile(defFile, errs)
|
||||
if len(mws) == 0 {
|
||||
continue
|
||||
}
|
||||
for name, m := range mws {
|
||||
name = strutils.ToLowerNoSnake(name)
|
||||
if _, ok := allMiddlewares[name]; ok {
|
||||
errs.Add(ErrDuplicatedMiddleware.Subject(name))
|
||||
// already loaded above
|
||||
continue
|
||||
}
|
||||
allMiddlewares[strutils.ToLowerNoSnake(name)] = m
|
||||
allMiddlewares[name] = m
|
||||
logger.Info().
|
||||
Str("src", path.Base(defFile)).
|
||||
Str("name", name).
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
|
||||
)
|
||||
|
||||
// internal use only.
|
||||
|
@ -13,7 +14,7 @@ type setUpstreamHeaders struct {
|
|||
|
||||
var suh = NewMiddleware[setUpstreamHeaders]()
|
||||
|
||||
func newSetUpstreamHeaders(rp *gphttp.ReverseProxy) *Middleware {
|
||||
func newSetUpstreamHeaders(rp *reverseproxy.ReverseProxy) *Middleware {
|
||||
m, err := suh.New(OptionsRaw{
|
||||
"name": rp.TargetName,
|
||||
"scheme": rp.TargetURL.Scheme,
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
)
|
||||
|
||||
|
@ -139,7 +139,7 @@ func newMiddlewareTest(middleware *Middleware, args *testArgs) (*TestResult, E.E
|
|||
rr.parent = http.DefaultTransport
|
||||
}
|
||||
|
||||
rp := gphttp.NewReverseProxy(middleware.name, args.upstreamURL, rr)
|
||||
rp := reverseproxy.NewReverseProxy(middleware.name, args.upstreamURL, rr)
|
||||
|
||||
mid, setOptErr := middleware.New(args.middlewareOpt)
|
||||
if setOptErr != nil {
|
||||
|
|
577
internal/net/http/reverseproxy/reverse_proxy_mod.go
Normal file
577
internal/net/http/reverseproxy/reverse_proxy_mod.go
Normal file
|
@ -0,0 +1,577 @@
|
|||
// Copyright 2011 The Go Authors.
|
||||
// Modified from the Go project under the a BSD-style License (https://cs.opensource.google/go/go/+/refs/tags/go1.23.1:src/net/http/httputil/reverseproxy.go)
|
||||
// https://cs.opensource.google/go/go/+/master:LICENSE
|
||||
|
||||
package reverseproxy
|
||||
|
||||
// This is a small mod on net/http/httputil/reverseproxy.go
|
||||
// that boosts performance in some cases
|
||||
// and compatible to other modules of this project
|
||||
// Copyright (c) 2024 yusing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yusing/go-proxy/internal/common"
|
||||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/metrics"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// A ProxyRequest contains a request to be rewritten by a [ReverseProxy].
|
||||
type ProxyRequest struct {
|
||||
// In is the request received by the proxy.
|
||||
// The Rewrite function must not modify In.
|
||||
In *http.Request
|
||||
|
||||
// Out is the request which will be sent by the proxy.
|
||||
// The Rewrite function may modify or replace this request.
|
||||
// Hop-by-hop headers are removed from this request
|
||||
// before Rewrite is called.
|
||||
Out *http.Request
|
||||
}
|
||||
|
||||
// SetXForwarded sets the X-Forwarded-For, X-Forwarded-Host, and
|
||||
// X-Forwarded-Proto headers of the outbound request.
|
||||
//
|
||||
// - The X-Forwarded-For header is set to the client IP address.
|
||||
// - The X-Forwarded-Host header is set to the host name requested
|
||||
// by the client.
|
||||
// - The X-Forwarded-Proto header is set to "http" or "https", depending
|
||||
// on whether the inbound request was made on a TLS-enabled connection.
|
||||
//
|
||||
// If the outbound request contains an existing X-Forwarded-For header,
|
||||
// SetXForwarded appends the client IP address to it. To append to the
|
||||
// inbound request's X-Forwarded-For header (the default behavior of
|
||||
// [ReverseProxy] when using a Director function), copy the header
|
||||
// from the inbound request before calling SetXForwarded:
|
||||
//
|
||||
// rewriteFunc := func(r *httputil.ProxyRequest) {
|
||||
// r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
||||
// r.SetXForwarded()
|
||||
// }
|
||||
|
||||
// ReverseProxy is an HTTP Handler that takes an incoming request and
|
||||
// sends it to another server, proxying the response back to the
|
||||
// client.
|
||||
//
|
||||
// 1xx responses are forwarded to the client if the underlying
|
||||
// transport supports ClientTrace.Got1xxResponse.
|
||||
type ReverseProxy struct {
|
||||
zerolog.Logger
|
||||
|
||||
// The transport used to perform proxy requests.
|
||||
Transport http.RoundTripper
|
||||
|
||||
// ModifyResponse is an optional function that modifies the
|
||||
// Response from the backend. It is called if the backend
|
||||
// returns a response at all, with any HTTP status code.
|
||||
// If the backend is unreachable, the optional ErrorHandler is
|
||||
// called before ModifyResponse.
|
||||
//
|
||||
// If ModifyResponse returns an error, ErrorHandler is called
|
||||
// with its error value. If ErrorHandler is nil, its default
|
||||
// implementation is used.
|
||||
ModifyResponse func(*http.Response) error
|
||||
AccessLogger *accesslog.AccessLogger
|
||||
|
||||
HandlerFunc http.HandlerFunc
|
||||
|
||||
TargetName string
|
||||
TargetURL types.URL
|
||||
}
|
||||
|
||||
type httpMetricLogger struct {
|
||||
http.ResponseWriter
|
||||
timestamp time.Time
|
||||
labels *metrics.HTTPRouteMetricLabels
|
||||
}
|
||||
|
||||
var logger = logging.With().Str("module", "reverse_proxy").Logger()
|
||||
|
||||
// WriteHeader implements http.ResponseWriter.
|
||||
func (l *httpMetricLogger) WriteHeader(status int) {
|
||||
l.ResponseWriter.WriteHeader(status)
|
||||
duration := time.Since(l.timestamp)
|
||||
go func() {
|
||||
m := metrics.GetRouteMetrics()
|
||||
m.HTTPReqTotal.Inc()
|
||||
m.HTTPReqElapsed.With(l.labels).Set(float64(duration.Milliseconds()))
|
||||
|
||||
// ignore 1xx
|
||||
switch {
|
||||
case status >= 500:
|
||||
m.HTTP5xx.With(l.labels).Inc()
|
||||
case status >= 400:
|
||||
m.HTTP4xx.With(l.labels).Inc()
|
||||
case status >= 200:
|
||||
m.HTTP2xx3xx.With(l.labels).Inc()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (l *httpMetricLogger) Unwrap() http.ResponseWriter {
|
||||
return l.ResponseWriter
|
||||
}
|
||||
|
||||
func singleJoiningSlash(a, b string) string {
|
||||
aslash := strings.HasSuffix(a, "/")
|
||||
bslash := strings.HasPrefix(b, "/")
|
||||
switch {
|
||||
case aslash && bslash:
|
||||
return a + b[1:]
|
||||
case !aslash && !bslash:
|
||||
return a + "/" + b
|
||||
}
|
||||
return a + b
|
||||
}
|
||||
|
||||
func joinURLPath(a, b *url.URL) (path, rawpath string) {
|
||||
if a.RawPath == "" && b.RawPath == "" {
|
||||
return singleJoiningSlash(a.Path, b.Path), ""
|
||||
}
|
||||
// Same as singleJoiningSlash, but uses EscapedPath to determine
|
||||
// whether a slash should be added
|
||||
apath := a.EscapedPath()
|
||||
bpath := b.EscapedPath()
|
||||
|
||||
aslash := strings.HasSuffix(apath, "/")
|
||||
bslash := strings.HasPrefix(bpath, "/")
|
||||
|
||||
switch {
|
||||
case aslash && bslash:
|
||||
return a.Path + b.Path[1:], apath + bpath[1:]
|
||||
case !aslash && !bslash:
|
||||
return a.Path + "/" + b.Path, apath + "/" + bpath
|
||||
}
|
||||
return a.Path + b.Path, apath + bpath
|
||||
}
|
||||
|
||||
// NewReverseProxy returns a new [ReverseProxy] that routes
|
||||
// URLs to the scheme, host, and base path provided in target. If the
|
||||
// target's path is "/base" and the incoming request was for "/dir",
|
||||
// the target request will be for /base/dir.
|
||||
func NewReverseProxy(name string, target types.URL, transport http.RoundTripper) *ReverseProxy {
|
||||
if transport == nil {
|
||||
panic("nil transport")
|
||||
}
|
||||
rp := &ReverseProxy{
|
||||
Logger: logger.With().Str("name", name).Logger(),
|
||||
Transport: transport,
|
||||
TargetName: name,
|
||||
TargetURL: target,
|
||||
}
|
||||
rp.HandlerFunc = rp.handler
|
||||
return rp
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) UnregisterMetrics() {
|
||||
metrics.GetRouteMetrics().UnregisterService(p.TargetName)
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) rewriteRequestURL(req *http.Request) {
|
||||
targetQuery := p.TargetURL.RawQuery
|
||||
req.URL.Scheme = p.TargetURL.Scheme
|
||||
req.URL.Host = p.TargetURL.Host
|
||||
req.URL.Path, req.URL.RawPath = joinURLPath(p.TargetURL.URL, req.URL)
|
||||
if targetQuery == "" || req.URL.RawQuery == "" {
|
||||
req.URL.RawQuery = targetQuery + req.URL.RawQuery
|
||||
} else {
|
||||
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
|
||||
}
|
||||
}
|
||||
|
||||
func copyHeader(dst, src http.Header) {
|
||||
for k, vv := range src {
|
||||
for _, v := range vv {
|
||||
dst.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) errorHandler(rw http.ResponseWriter, r *http.Request, err error, writeHeader bool) {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled),
|
||||
errors.Is(err, io.EOF):
|
||||
logger.Debug().Err(err).Str("url", r.URL.String()).Msg("http proxy error")
|
||||
default:
|
||||
logger.Err(err).Str("url", r.URL.String()).Msg("http proxy error")
|
||||
}
|
||||
if writeHeader {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
if p.AccessLogger != nil {
|
||||
p.AccessLogger.LogError(r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// modifyResponse conditionally runs the optional ModifyResponse hook
|
||||
// and reports whether the request should proceed.
|
||||
func (p *ReverseProxy) modifyResponse(rw http.ResponseWriter, res *http.Response, origReq, req *http.Request) bool {
|
||||
if p.ModifyResponse == nil {
|
||||
return true
|
||||
}
|
||||
res.Request = origReq
|
||||
err := p.ModifyResponse(res)
|
||||
res.Request = req
|
||||
if err != nil {
|
||||
res.Body.Close()
|
||||
p.errorHandler(rw, req, err, true)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
p.HandlerFunc(rw, req)
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) handler(rw http.ResponseWriter, req *http.Request) {
|
||||
visitorIP, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||
if err != nil {
|
||||
visitorIP = req.RemoteAddr
|
||||
}
|
||||
|
||||
if common.PrometheusEnabled {
|
||||
t := time.Now()
|
||||
// req.RemoteAddr had been modified by middleware (if any)
|
||||
lbls := &metrics.HTTPRouteMetricLabels{
|
||||
Service: p.TargetName,
|
||||
Method: req.Method,
|
||||
Host: req.Host,
|
||||
Visitor: visitorIP,
|
||||
Path: req.URL.Path,
|
||||
}
|
||||
rw = &httpMetricLogger{
|
||||
ResponseWriter: rw,
|
||||
timestamp: t,
|
||||
labels: lbls,
|
||||
}
|
||||
}
|
||||
|
||||
transport := p.Transport
|
||||
|
||||
ctx := req.Context()
|
||||
/* trunk-ignore(golangci-lint/revive) */
|
||||
if ctx.Done() != nil {
|
||||
// CloseNotifier predates context.Context, and has been
|
||||
// entirely superseded by it. If the request contains
|
||||
// a Context that carries a cancellation signal, don't
|
||||
// bother spinning up a goroutine to watch the CloseNotify
|
||||
// channel (if any).
|
||||
//
|
||||
// If the request Context has a nil Done channel (which
|
||||
// means it is either context.Background, or a custom
|
||||
// Context implementation with no cancellation signal),
|
||||
// then consult the CloseNotifier if available.
|
||||
} else if cn, ok := rw.(http.CloseNotifier); ok {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
notifyChan := cn.CloseNotify()
|
||||
go func() {
|
||||
select {
|
||||
case <-notifyChan:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
outreq := req.Clone(ctx)
|
||||
if req.ContentLength == 0 {
|
||||
outreq.Body = nil // Issue 16036: nil Body for http.Transport retries
|
||||
}
|
||||
if outreq.Body != nil {
|
||||
// Reading from the request body after returning from a handler is not
|
||||
// allowed, and the RoundTrip goroutine that reads the Body can outlive
|
||||
// this handler. This can lead to a crash if the handler panics (see
|
||||
// Issue 46866). Although calling Close doesn't guarantee there isn't
|
||||
// any Read in flight after the handle returns, in practice it's safe to
|
||||
// read after closing it.
|
||||
defer outreq.Body.Close()
|
||||
}
|
||||
if outreq.Header == nil {
|
||||
outreq.Header = make(http.Header) // Issue 33142: historical behavior was to always allocate
|
||||
}
|
||||
|
||||
p.rewriteRequestURL(outreq)
|
||||
outreq.Close = false
|
||||
|
||||
reqUpType := gphttp.UpgradeType(outreq.Header)
|
||||
if !IsPrint(reqUpType) {
|
||||
p.errorHandler(rw, req, fmt.Errorf("client tried to switch to invalid protocol %q", reqUpType), true)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Del("Forwarded")
|
||||
gphttp.RemoveHopByHopHeaders(outreq.Header)
|
||||
|
||||
// Issue 21096: tell backend applications that care about trailer support
|
||||
// that we support trailers. (We do, but we don't go out of our way to
|
||||
// advertise that unless the incoming client request thought it was worth
|
||||
// mentioning.) Note that we look at req.Header, not outreq.Header, since
|
||||
// the latter has passed through removeHopByHopHeaders.
|
||||
if httpguts.HeaderValuesContainsToken(req.Header["Te"], "trailers") {
|
||||
outreq.Header.Set("Te", "trailers")
|
||||
}
|
||||
|
||||
// After stripping all the hop-by-hop connection headers above, add back any
|
||||
// necessary for protocol upgrades, such as for websockets.
|
||||
if reqUpType != "" {
|
||||
outreq.Header.Set("Connection", "Upgrade")
|
||||
outreq.Header.Set("Upgrade", reqUpType)
|
||||
|
||||
if strings.EqualFold(reqUpType, "websocket") {
|
||||
cleanWebsocketHeaders(outreq)
|
||||
}
|
||||
}
|
||||
|
||||
// If we aren't the first proxy retain prior
|
||||
// X-Forwarded-For information as a comma+space
|
||||
// separated list and fold multiple headers into one.
|
||||
prior, ok := outreq.Header[gphttp.HeaderXForwardedFor]
|
||||
omit := ok && prior == nil // Issue 38079: nil now means don't populate the header
|
||||
xff := visitorIP
|
||||
if len(prior) > 0 {
|
||||
xff = strings.Join(prior, ", ") + ", " + xff
|
||||
}
|
||||
if !omit {
|
||||
outreq.Header.Set(gphttp.HeaderXForwardedFor, xff)
|
||||
}
|
||||
|
||||
var reqScheme string
|
||||
if req.TLS != nil {
|
||||
reqScheme = "https"
|
||||
} else {
|
||||
reqScheme = "http"
|
||||
}
|
||||
|
||||
outreq.Header.Set(gphttp.HeaderXForwardedMethod, req.Method)
|
||||
outreq.Header.Set(gphttp.HeaderXForwardedProto, reqScheme)
|
||||
outreq.Header.Set(gphttp.HeaderXForwardedHost, req.Host)
|
||||
outreq.Header.Set(gphttp.HeaderXForwardedURI, req.RequestURI)
|
||||
|
||||
if _, ok := outreq.Header["User-Agent"]; !ok {
|
||||
// If the outbound request doesn't have a User-Agent header set,
|
||||
// don't send the default Go HTTP client User-Agent.
|
||||
outreq.Header.Set("User-Agent", "")
|
||||
}
|
||||
|
||||
var (
|
||||
roundTripMutex sync.Mutex
|
||||
roundTripDone bool
|
||||
)
|
||||
trace := &httptrace.ClientTrace{
|
||||
Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
|
||||
roundTripMutex.Lock()
|
||||
defer roundTripMutex.Unlock()
|
||||
if roundTripDone {
|
||||
// If RoundTrip has returned, don't try to further modify
|
||||
// the ResponseWriter's header map.
|
||||
return nil
|
||||
}
|
||||
h := rw.Header()
|
||||
copyHeader(h, http.Header(header))
|
||||
rw.WriteHeader(code)
|
||||
|
||||
// Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses
|
||||
clear(h)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
outreq = outreq.WithContext(httptrace.WithClientTrace(outreq.Context(), trace))
|
||||
|
||||
res, err := transport.RoundTrip(outreq)
|
||||
|
||||
roundTripMutex.Lock()
|
||||
roundTripDone = true
|
||||
roundTripMutex.Unlock()
|
||||
if err != nil {
|
||||
p.errorHandler(rw, outreq, err, false)
|
||||
res = &http.Response{
|
||||
Status: http.StatusText(http.StatusBadGateway),
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Proto: req.Proto,
|
||||
ProtoMajor: req.ProtoMajor,
|
||||
ProtoMinor: req.ProtoMinor,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader([]byte("Origin server is not reachable."))),
|
||||
Request: req,
|
||||
TLS: req.TLS,
|
||||
}
|
||||
}
|
||||
|
||||
if p.AccessLogger != nil {
|
||||
defer func() {
|
||||
p.AccessLogger.Log(req, res)
|
||||
}()
|
||||
}
|
||||
|
||||
// Deal with 101 Switching Protocols responses: (WebSocket, h2c, etc)
|
||||
if res.StatusCode == http.StatusSwitchingProtocols {
|
||||
if !p.modifyResponse(rw, res, req, outreq) {
|
||||
return
|
||||
}
|
||||
p.handleUpgradeResponse(rw, outreq, res)
|
||||
return
|
||||
}
|
||||
|
||||
gphttp.RemoveHopByHopHeaders(res.Header)
|
||||
|
||||
if !p.modifyResponse(rw, res, req, outreq) {
|
||||
return
|
||||
}
|
||||
|
||||
copyHeader(rw.Header(), res.Header)
|
||||
|
||||
// The "Trailer" header isn't included in the Transport's response,
|
||||
// at least for *http.Transport. Build it up from Trailer.
|
||||
announcedTrailers := len(res.Trailer)
|
||||
if announcedTrailers > 0 {
|
||||
trailerKeys := make([]string, 0, len(res.Trailer))
|
||||
for k := range res.Trailer {
|
||||
trailerKeys = append(trailerKeys, k)
|
||||
}
|
||||
rw.Header().Add("Trailer", strings.Join(trailerKeys, ", "))
|
||||
}
|
||||
|
||||
rw.WriteHeader(res.StatusCode)
|
||||
|
||||
_, err = io.Copy(rw, res.Body)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
p.errorHandler(rw, req, err, true)
|
||||
}
|
||||
res.Body.Close()
|
||||
return
|
||||
}
|
||||
res.Body.Close() // close now, instead of defer, to populate res.Trailer
|
||||
|
||||
if len(res.Trailer) > 0 {
|
||||
// Force chunking if we saw a response trailer.
|
||||
// This prevents net/http from calculating the length for short
|
||||
// bodies and adding a Content-Length.
|
||||
http.NewResponseController(rw).Flush()
|
||||
}
|
||||
|
||||
if len(res.Trailer) == announcedTrailers {
|
||||
copyHeader(rw.Header(), res.Trailer)
|
||||
return
|
||||
}
|
||||
|
||||
for k, vv := range res.Trailer {
|
||||
k = http.TrailerPrefix + k
|
||||
for _, v := range vv {
|
||||
rw.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reference: https://github.com/traefik/traefik/blob/master/pkg/proxy/httputil/proxy.go
|
||||
// https://tools.ietf.org/html/rfc6455#page-20
|
||||
func cleanWebsocketHeaders(req *http.Request) {
|
||||
req.Header["Sec-WebSocket-Key"] = req.Header["Sec-Websocket-Key"]
|
||||
delete(req.Header, "Sec-Websocket-Key")
|
||||
|
||||
req.Header["Sec-WebSocket-Extensions"] = req.Header["Sec-Websocket-Extensions"]
|
||||
delete(req.Header, "Sec-Websocket-Extensions")
|
||||
|
||||
req.Header["Sec-WebSocket-Accept"] = req.Header["Sec-Websocket-Accept"]
|
||||
delete(req.Header, "Sec-Websocket-Accept")
|
||||
|
||||
req.Header["Sec-WebSocket-Protocol"] = req.Header["Sec-Websocket-Protocol"]
|
||||
delete(req.Header, "Sec-Websocket-Protocol")
|
||||
|
||||
req.Header["Sec-WebSocket-Version"] = req.Header["Sec-Websocket-Version"]
|
||||
delete(req.Header, "Sec-Websocket-Version")
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.Request, res *http.Response) {
|
||||
reqUpType := gphttp.UpgradeType(req.Header)
|
||||
resUpType := gphttp.UpgradeType(res.Header)
|
||||
if !IsPrint(resUpType) { // We know reqUpType is ASCII, it's checked by the caller.
|
||||
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch to invalid protocol %q", resUpType), true)
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(reqUpType, resUpType) {
|
||||
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType), true)
|
||||
return
|
||||
}
|
||||
|
||||
backConn, ok := res.Body.(io.ReadWriteCloser)
|
||||
if !ok {
|
||||
p.errorHandler(rw, req, errors.New("internal error: 101 switching protocols response with non-writable body"), true)
|
||||
return
|
||||
}
|
||||
|
||||
rc := http.NewResponseController(rw)
|
||||
conn, brw, hijackErr := rc.Hijack()
|
||||
if errors.Is(hijackErr, http.ErrNotSupported) {
|
||||
p.errorHandler(rw, req, fmt.Errorf("can't switch protocols using non-Hijacker ResponseWriter type %T", rw), true)
|
||||
return
|
||||
}
|
||||
|
||||
backConnCloseCh := make(chan bool)
|
||||
go func() {
|
||||
// Ensure that the cancellation of a request closes the backend.
|
||||
// See issue https://golang.org/issue/35559.
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-backConnCloseCh:
|
||||
}
|
||||
backConn.Close()
|
||||
}()
|
||||
defer close(backConnCloseCh)
|
||||
|
||||
if hijackErr != nil {
|
||||
p.errorHandler(rw, req, fmt.Errorf("hijack failed on protocol switch: %w", hijackErr), true)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
copyHeader(rw.Header(), res.Header)
|
||||
|
||||
res.Header = rw.Header()
|
||||
res.Body = nil // so res.Write only writes the headers; we have res.Body in backConn above
|
||||
if err := res.Write(brw); err != nil {
|
||||
/* trunk-ignore(golangci-lint/errorlint) */
|
||||
p.errorHandler(rw, req, fmt.Errorf("response write: %s", err), true)
|
||||
return
|
||||
}
|
||||
if err := brw.Flush(); err != nil {
|
||||
/* trunk-ignore(golangci-lint/errorlint) */
|
||||
p.errorHandler(rw, req, fmt.Errorf("response flush: %s", err), true)
|
||||
return
|
||||
}
|
||||
|
||||
bdp := U.NewBidirectionalPipe(req.Context(), conn, backConn)
|
||||
/* trunk-ignore(golangci-lint/errcheck) */
|
||||
bdp.Start()
|
||||
}
|
||||
|
||||
func IsPrint(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < ' ' || r > '~' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
|
@ -35,9 +35,9 @@ type Options struct {
|
|||
Handler http.Handler
|
||||
}
|
||||
|
||||
func StartServer(opt Options) (s *Server) {
|
||||
func StartServer(parent task.Parent, opt Options) (s *Server) {
|
||||
s = NewServer(opt)
|
||||
s.Start()
|
||||
s.Start(parent)
|
||||
return s
|
||||
}
|
||||
|
||||
|
@ -83,11 +83,13 @@ func NewServer(opt Options) (s *Server) {
|
|||
// If both are not set, this does nothing.
|
||||
//
|
||||
// Start() is non-blocking.
|
||||
func (s *Server) Start() {
|
||||
func (s *Server) Start(parent task.Parent) {
|
||||
if s.http == nil && s.https == nil {
|
||||
return
|
||||
}
|
||||
|
||||
task := parent.Subtask("server."+s.Name, false)
|
||||
|
||||
s.startTime = time.Now()
|
||||
if s.http != nil {
|
||||
go func() {
|
||||
|
@ -105,7 +107,7 @@ func (s *Server) Start() {
|
|||
s.l.Info().Str("addr", s.https.Addr).Msgf("server started")
|
||||
}
|
||||
|
||||
task.OnProgramExit("server."+s.Name+".stop", s.stop)
|
||||
task.OnCancel("stop", s.stop)
|
||||
}
|
||||
|
||||
func (s *Server) stop() {
|
||||
|
@ -113,14 +115,19 @@ func (s *Server) stop() {
|
|||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(task.RootContext(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if s.http != nil && s.httpStarted {
|
||||
s.handleErr("http", s.http.Shutdown(task.RootContext()))
|
||||
s.handleErr("http", s.http.Shutdown(ctx))
|
||||
s.httpStarted = false
|
||||
s.l.Info().Str("addr", s.http.Addr).Msgf("server stopped")
|
||||
}
|
||||
|
||||
if s.https != nil && s.httpsStarted {
|
||||
s.handleErr("https", s.https.Shutdown(task.RootContext()))
|
||||
s.handleErr("https", s.https.Shutdown(ctx))
|
||||
s.httpsStarted = false
|
||||
s.l.Info().Str("addr", s.https.Addr).Msgf("server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,3 +5,7 @@ import "net/http"
|
|||
func IsSuccess(status int) bool {
|
||||
return status >= http.StatusOK && status < http.StatusMultipleChoices
|
||||
}
|
||||
|
||||
func IsStatusCodeValid(status int) bool {
|
||||
return http.StatusText(status) != ""
|
||||
}
|
||||
|
|
|
@ -8,6 +8,11 @@ import (
|
|||
//nolint:recvcheck
|
||||
type CIDR net.IPNet
|
||||
|
||||
func ParseCIDR(v string) (cidr CIDR, err error) {
|
||||
err = cidr.Parse(v)
|
||||
return
|
||||
}
|
||||
|
||||
func (cidr *CIDR) Parse(v string) error {
|
||||
if !strings.Contains(v, "/") {
|
||||
v += "/32" // single IP
|
||||
|
|
|
@ -49,10 +49,7 @@ func jsonIfTemplateNotUsed(fl validator.FieldLevel) bool {
|
|||
|
||||
func init() {
|
||||
utils.RegisterDefaultValueFactory(DefaultValue)
|
||||
err := utils.Validator().RegisterValidation("jsonIfTemplateNotUsed", jsonIfTemplateNotUsed)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
utils.MustRegisterValidation("jsonIfTemplateNotUsed", jsonIfTemplateNotUsed)
|
||||
}
|
||||
|
||||
// Name implements Provider.
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/yusing/go-proxy/internal/net/http/loadbalancer"
|
||||
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
|
||||
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
|
||||
"github.com/yusing/go-proxy/internal/route/entry"
|
||||
"github.com/yusing/go-proxy/internal/route/routes"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
|
@ -30,7 +31,7 @@ type (
|
|||
loadBalancer *loadbalancer.LoadBalancer
|
||||
server *loadbalancer.Server
|
||||
handler http.Handler
|
||||
rp *gphttp.ReverseProxy
|
||||
rp *reverseproxy.ReverseProxy
|
||||
|
||||
task *task.Task
|
||||
|
||||
|
@ -49,7 +50,7 @@ func NewHTTPRoute(entry *entry.ReverseProxyEntry) (impl, E.Error) {
|
|||
}
|
||||
|
||||
service := entry.TargetName()
|
||||
rp := gphttp.NewReverseProxy(service, entry.URL, trans)
|
||||
rp := reverseproxy.NewReverseProxy(service, entry.URL, trans)
|
||||
|
||||
if len(entry.Raw.Middlewares) > 0 {
|
||||
err := middleware.PatchReverseProxy(rp, entry.Raw.Middlewares)
|
||||
|
@ -138,7 +139,7 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error {
|
|||
}
|
||||
|
||||
if len(r.Raw.Rules) > 0 {
|
||||
r.handler = r.Raw.Rules.BuildHandler(r.rp)
|
||||
r.handler = r.Raw.Rules.BuildHandler(r.handler)
|
||||
}
|
||||
|
||||
if r.HealthMon != nil {
|
||||
|
|
|
@ -72,9 +72,9 @@ proxy.app1.host: 10.0.0.254
|
|||
proxy.app1.port: 80
|
||||
proxy.app1.path_patterns:
|
||||
| # Check https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for syntax
|
||||
GET / # accept any GET request
|
||||
POST /auth # for /auth and /auth/* accept only POST
|
||||
GET /home/{$} # for exactly /home
|
||||
- GET / # accept any GET request
|
||||
- POST /auth # for /auth and /auth/* accept only POST
|
||||
- GET /home/{$} # for exactly /home
|
||||
proxy.app1.healthcheck.disabled: false
|
||||
proxy.app1.healthcheck.path: /
|
||||
proxy.app1.healthcheck.interval: 5s
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/entry"
|
||||
"github.com/yusing/go-proxy/internal/route/provider/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
"github.com/yusing/go-proxy/internal/watcher"
|
||||
)
|
||||
|
@ -87,10 +88,10 @@ func (handler *EventHandler) matchAny(events []watcher.Event, route *route.Route
|
|||
|
||||
func (handler *EventHandler) match(event watcher.Event, route *route.Route) bool {
|
||||
switch handler.provider.GetType() {
|
||||
case ProviderTypeDocker:
|
||||
case types.ProviderTypeDocker:
|
||||
return route.Entry.Container.ContainerID == event.ActorID ||
|
||||
route.Entry.Container.ContainerName == event.ActorName
|
||||
case ProviderTypeFile:
|
||||
case types.ProviderTypeFile:
|
||||
return true
|
||||
}
|
||||
// should never happen
|
||||
|
|
|
@ -10,6 +10,8 @@ import (
|
|||
"github.com/rs/zerolog"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
R "github.com/yusing/go-proxy/internal/route"
|
||||
"github.com/yusing/go-proxy/internal/route/provider/types"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/task"
|
||||
W "github.com/yusing/go-proxy/internal/watcher"
|
||||
"github.com/yusing/go-proxy/internal/watcher/events"
|
||||
|
@ -20,7 +22,7 @@ type (
|
|||
ProviderImpl `json:"-"`
|
||||
|
||||
name string
|
||||
t ProviderType
|
||||
t types.ProviderType
|
||||
routes R.Routes
|
||||
|
||||
watcher W.Watcher
|
||||
|
@ -31,24 +33,20 @@ type (
|
|||
NewWatcher() W.Watcher
|
||||
Logger() *zerolog.Logger
|
||||
}
|
||||
ProviderType string
|
||||
ProviderStats struct {
|
||||
NumRPs int `json:"num_reverse_proxies"`
|
||||
NumStreams int `json:"num_streams"`
|
||||
Type ProviderType `json:"type"`
|
||||
NumRPs int `json:"num_reverse_proxies"`
|
||||
NumStreams int `json:"num_streams"`
|
||||
Type types.ProviderType `json:"type"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderTypeDocker ProviderType = "docker"
|
||||
ProviderTypeFile ProviderType = "file"
|
||||
|
||||
providerEventFlushInterval = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
var ErrEmptyProviderName = errors.New("empty provider name")
|
||||
|
||||
func newProvider(name string, t ProviderType) *Provider {
|
||||
func newProvider(name string, t types.ProviderType) *Provider {
|
||||
return &Provider{
|
||||
name: name,
|
||||
t: t,
|
||||
|
@ -61,7 +59,7 @@ func NewFileProvider(filename string) (p *Provider, err error) {
|
|||
if name == "" {
|
||||
return nil, ErrEmptyProviderName
|
||||
}
|
||||
p = newProvider(strings.ReplaceAll(name, ".", "_"), ProviderTypeFile)
|
||||
p = newProvider(strings.ReplaceAll(name, ".", "_"), types.ProviderTypeFile)
|
||||
p.ProviderImpl, err = FileProviderImpl(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -75,7 +73,7 @@ func NewDockerProvider(name string, dockerHost string) (p *Provider, err error)
|
|||
return nil, ErrEmptyProviderName
|
||||
}
|
||||
|
||||
p = newProvider(name, ProviderTypeDocker)
|
||||
p = newProvider(name, types.ProviderTypeDocker)
|
||||
p.ProviderImpl, err = DockerProviderImpl(name, dockerHost, p.IsExplicitOnly())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -92,7 +90,7 @@ func (p *Provider) GetName() string {
|
|||
return p.name
|
||||
}
|
||||
|
||||
func (p *Provider) GetType() ProviderType {
|
||||
func (p *Provider) GetType() types.ProviderType {
|
||||
return p.t
|
||||
}
|
||||
|
||||
|
@ -111,7 +109,7 @@ func (p *Provider) startRoute(parent task.Parent, r *R.Route) E.Error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Start implements*task.TaskStarter.
|
||||
// Start implements task.TaskStarter.
|
||||
func (p *Provider) Start(parent task.Parent) E.Error {
|
||||
t := parent.Subtask("provider."+p.name, false)
|
||||
|
||||
|
@ -171,9 +169,9 @@ func (p *Provider) Statistics() ProviderStats {
|
|||
numStreams := 0
|
||||
p.routes.RangeAll(func(_ string, r *R.Route) {
|
||||
switch r.Type {
|
||||
case R.RouteTypeReverseProxy:
|
||||
case route.RouteTypeReverseProxy:
|
||||
numRPs++
|
||||
case R.RouteTypeStream:
|
||||
case route.RouteTypeStream:
|
||||
numStreams++
|
||||
}
|
||||
})
|
||||
|
|
8
internal/route/provider/types/provider_type.go
Normal file
8
internal/route/provider/types/provider_type.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package types
|
||||
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderTypeDocker ProviderType = "docker"
|
||||
ProviderTypeFile ProviderType = "file"
|
||||
)
|
|
@ -14,11 +14,10 @@ import (
|
|||
)
|
||||
|
||||
type (
|
||||
RouteType string
|
||||
Route struct {
|
||||
Route struct {
|
||||
_ U.NoCopy
|
||||
impl
|
||||
Type RouteType
|
||||
Type types.RouteType
|
||||
Entry *RawEntry
|
||||
}
|
||||
Routes = F.Map[string, *Route]
|
||||
|
@ -34,11 +33,6 @@ type (
|
|||
RawEntries = types.RawEntries
|
||||
)
|
||||
|
||||
const (
|
||||
RouteTypeStream RouteType = "stream"
|
||||
RouteTypeReverseProxy RouteType = "reverse_proxy"
|
||||
)
|
||||
|
||||
// function alias.
|
||||
var (
|
||||
NewRoutes = F.NewMap[Routes]
|
||||
|
@ -59,15 +53,15 @@ func NewRoute(raw *RawEntry) (*Route, E.Error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
var t RouteType
|
||||
var t types.RouteType
|
||||
var rt impl
|
||||
|
||||
switch e := en.(type) {
|
||||
case *entry.StreamEntry:
|
||||
t = RouteTypeStream
|
||||
t = types.RouteTypeStream
|
||||
rt, err = NewStreamRoute(e)
|
||||
case *entry.ReverseProxyEntry:
|
||||
t = RouteTypeReverseProxy
|
||||
t = types.RouteTypeReverseProxy
|
||||
rt, err = NewHTTPRoute(e)
|
||||
default:
|
||||
panic("bug: should not reach here")
|
||||
|
|
99
internal/route/routes/query.go
Normal file
99
internal/route/routes/query.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/yusing/go-proxy/internal/homepage"
|
||||
"github.com/yusing/go-proxy/internal/route/entry"
|
||||
provider "github.com/yusing/go-proxy/internal/route/provider/types"
|
||||
"github.com/yusing/go-proxy/internal/route/types"
|
||||
route "github.com/yusing/go-proxy/internal/route/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
func HomepageConfig(useDefaultCategories bool) homepage.Config {
|
||||
hpCfg := homepage.NewHomePageConfig()
|
||||
GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
|
||||
en := r.RawEntry()
|
||||
item := en.Homepage
|
||||
if item == nil {
|
||||
item = new(homepage.Item)
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.IsEmpty() {
|
||||
item.Show = true
|
||||
}
|
||||
|
||||
if !item.Show {
|
||||
return
|
||||
}
|
||||
|
||||
item.Alias = alias
|
||||
|
||||
if item.Name == "" {
|
||||
item.Name = strutils.Title(
|
||||
strings.ReplaceAll(
|
||||
strings.ReplaceAll(alias, "-", " "),
|
||||
"_", " ",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if useDefaultCategories {
|
||||
if en.Container != nil && item.Category == "" {
|
||||
if category, ok := homepage.PredefinedCategories[en.Container.ImageName]; ok {
|
||||
item.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
if item.Category == "" {
|
||||
if category, ok := homepage.PredefinedCategories[strings.ToLower(alias)]; ok {
|
||||
item.Category = category
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.IsDocker(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Docker"
|
||||
}
|
||||
item.SourceType = string(provider.ProviderTypeDocker)
|
||||
case entry.UseLoadBalance(r):
|
||||
if item.Category == "" {
|
||||
item.Category = "Load-balanced"
|
||||
}
|
||||
item.SourceType = "loadbalancer"
|
||||
default:
|
||||
if item.Category == "" {
|
||||
item.Category = "Others"
|
||||
}
|
||||
item.SourceType = string(provider.ProviderTypeFile)
|
||||
}
|
||||
|
||||
item.AltURL = r.TargetURL().String()
|
||||
hpCfg.Add(item)
|
||||
})
|
||||
return hpCfg
|
||||
}
|
||||
|
||||
func RoutesByAlias(typeFilter ...route.RouteType) map[string]any {
|
||||
rts := make(map[string]any)
|
||||
if len(typeFilter) == 0 || typeFilter[0] == "" {
|
||||
typeFilter = []route.RouteType{route.RouteTypeReverseProxy, route.RouteTypeStream}
|
||||
}
|
||||
for _, t := range typeFilter {
|
||||
switch t {
|
||||
case route.RouteTypeReverseProxy:
|
||||
GetHTTPRoutes().RangeAll(func(alias string, r types.HTTPRoute) {
|
||||
rts[alias] = r
|
||||
})
|
||||
case route.RouteTypeStream:
|
||||
GetStreamRoutes().RangeAll(func(alias string, r types.StreamRoute) {
|
||||
rts[alias] = r
|
||||
})
|
||||
}
|
||||
}
|
||||
return rts
|
||||
}
|
248
internal/route/rules/do.go
Normal file
248
internal/route/rules/do.go
Normal file
|
@ -0,0 +1,248 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/http/reverseproxy"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
Command struct {
|
||||
raw string
|
||||
exec *CommandExecutor
|
||||
}
|
||||
CommandExecutor struct {
|
||||
directive string
|
||||
http.HandlerFunc
|
||||
proceed bool
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
CommandRewrite = "rewrite"
|
||||
CommandServe = "serve"
|
||||
CommandProxy = "proxy"
|
||||
CommandRedirect = "redirect"
|
||||
CommandError = "error"
|
||||
CommandBypass = "bypass"
|
||||
)
|
||||
|
||||
var commands = map[string]struct {
|
||||
help Help
|
||||
validate ValidateFunc
|
||||
build func(args any) *CommandExecutor
|
||||
}{
|
||||
CommandRewrite: {
|
||||
help: Help{
|
||||
command: CommandRewrite,
|
||||
args: map[string]string{
|
||||
"from": "the path to rewrite, must start with /",
|
||||
"to": "the path to rewrite to, must start with /",
|
||||
},
|
||||
},
|
||||
validate: func(args []string) (any, E.Error) {
|
||||
if len(args) != 2 {
|
||||
return nil, ErrExpectTwoArgs
|
||||
}
|
||||
return validateURLPaths(args)
|
||||
},
|
||||
build: func(args any) *CommandExecutor {
|
||||
a := args.([]string)
|
||||
orig, repl := a[0], a[1]
|
||||
return &CommandExecutor{
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if len(path) > 0 && path[0] != '/' {
|
||||
path = "/" + path
|
||||
}
|
||||
if !strings.HasPrefix(path, orig) {
|
||||
return
|
||||
}
|
||||
path = repl + path[len(orig):]
|
||||
r.URL.Path = path
|
||||
r.URL.RawPath = r.URL.EscapedPath()
|
||||
r.RequestURI = r.URL.RequestURI()
|
||||
},
|
||||
proceed: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
CommandServe: {
|
||||
help: Help{
|
||||
command: CommandServe,
|
||||
args: map[string]string{
|
||||
"root": "the file system path to serve, must be an existing directory",
|
||||
},
|
||||
},
|
||||
validate: validateFSPath,
|
||||
build: func(args any) *CommandExecutor {
|
||||
root := args.(string)
|
||||
return &CommandExecutor{
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, path.Join(root, path.Clean(r.URL.Path)))
|
||||
},
|
||||
proceed: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
CommandRedirect: {
|
||||
help: Help{
|
||||
command: CommandRedirect,
|
||||
args: map[string]string{
|
||||
"to": "the url to redirect to, can be relative or absolute URL",
|
||||
},
|
||||
},
|
||||
validate: validateURL,
|
||||
build: func(args any) *CommandExecutor {
|
||||
target := args.(types.URL).String()
|
||||
return &CommandExecutor{
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
|
||||
},
|
||||
proceed: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
CommandError: {
|
||||
help: Help{
|
||||
command: CommandError,
|
||||
args: map[string]string{
|
||||
"code": "the http status code to return",
|
||||
"text": "the error message to return",
|
||||
},
|
||||
},
|
||||
validate: func(args []string) (any, E.Error) {
|
||||
if len(args) != 2 {
|
||||
return nil, ErrExpectTwoArgs
|
||||
}
|
||||
codeStr, text := args[0], args[1]
|
||||
code, err := strconv.Atoi(codeStr)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
if !gphttp.IsStatusCodeValid(code) {
|
||||
return nil, ErrInvalidArguments.Subject(codeStr)
|
||||
}
|
||||
return []any{code, text}, nil
|
||||
},
|
||||
build: func(args any) *CommandExecutor {
|
||||
a := args.([]any)
|
||||
code, text := a[0].(int), a[1].(string)
|
||||
return &CommandExecutor{
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, text, code)
|
||||
},
|
||||
proceed: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
CommandProxy: {
|
||||
help: Help{
|
||||
command: CommandProxy,
|
||||
args: map[string]string{
|
||||
"to": "the url to proxy to, must be an absolute URL",
|
||||
},
|
||||
},
|
||||
validate: validateAbsoluteURL,
|
||||
build: func(args any) *CommandExecutor {
|
||||
target := args.(types.URL)
|
||||
if target.Scheme == "" {
|
||||
target.Scheme = "http"
|
||||
}
|
||||
rp := reverseproxy.NewReverseProxy("", target, gphttp.DefaultTransport)
|
||||
return &CommandExecutor{
|
||||
HandlerFunc: rp.ServeHTTP,
|
||||
proceed: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Parse implements strutils.Parser.
|
||||
func (cmd *Command) Parse(v string) error {
|
||||
cmd.raw = v
|
||||
|
||||
lines := strutils.SplitLine(v)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
executors := make([]*CommandExecutor, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
directive, args, err := parse(line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if directive == CommandBypass {
|
||||
if len(args) != 0 {
|
||||
return ErrInvalidArguments.Subject(directive)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
builder, ok := commands[directive]
|
||||
if !ok {
|
||||
return ErrUnknownDirective.Subject(directive)
|
||||
}
|
||||
validArgs, err := builder.validate(args)
|
||||
if err != nil {
|
||||
return err.Subject(directive).Withf("%s", builder.help.String())
|
||||
}
|
||||
|
||||
exec := builder.build(validArgs)
|
||||
exec.directive = directive
|
||||
executors = append(executors, exec)
|
||||
}
|
||||
|
||||
exec, err := buildCmd(executors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.exec = exec
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCmd(executors []*CommandExecutor) (*CommandExecutor, error) {
|
||||
for i, exec := range executors {
|
||||
if !exec.proceed && i != len(executors)-1 {
|
||||
return nil, ErrInvalidCommandSequence.
|
||||
Withf("%s cannot follow %s", exec, executors[i+1])
|
||||
}
|
||||
}
|
||||
return &CommandExecutor{
|
||||
HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, exec := range executors {
|
||||
exec.HandlerFunc(w, r)
|
||||
}
|
||||
},
|
||||
proceed: executors[len(executors)-1].proceed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cmd *Command) isBypass() bool {
|
||||
return cmd.exec == nil
|
||||
}
|
||||
|
||||
func (cmd *Command) String() string {
|
||||
return cmd.raw
|
||||
}
|
||||
|
||||
func (cmd *Command) MarshalJSON() ([]byte, error) {
|
||||
return []byte("\"" + cmd.String() + "\""), nil
|
||||
}
|
||||
|
||||
func (exec *CommandExecutor) String() string {
|
||||
return exec.directive
|
||||
}
|
15
internal/route/rules/errors.go
Normal file
15
internal/route/rules/errors.go
Normal file
|
@ -0,0 +1,15 @@
|
|||
package rules
|
||||
|
||||
import E "github.com/yusing/go-proxy/internal/error"
|
||||
|
||||
var (
|
||||
ErrUnterminatedQuotes = E.New("unterminated quotes")
|
||||
ErrUnsupportedEscapeChar = E.New("unsupported escape char")
|
||||
ErrUnknownDirective = E.New("unknown directive")
|
||||
ErrInvalidArguments = E.New("invalid arguments")
|
||||
ErrInvalidOnTarget = E.New("invalid `rule.on` target")
|
||||
ErrInvalidCommandSequence = E.New("invalid command sequence")
|
||||
|
||||
ErrExpectOneArg = ErrInvalidArguments.Withf("expect 1 arg")
|
||||
ErrExpectTwoArgs = ErrInvalidArguments.Withf("expect 2 args")
|
||||
)
|
41
internal/route/rules/help.go
Normal file
41
internal/route/rules/help.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package rules
|
||||
|
||||
import "strings"
|
||||
|
||||
type Help struct {
|
||||
command string
|
||||
description string
|
||||
args map[string]string // args[arg] -> description
|
||||
}
|
||||
|
||||
/*
|
||||
Generate help string, e.g.
|
||||
|
||||
rewrite <from> <to>
|
||||
from: the path to rewrite, must start with /
|
||||
to: the path to rewrite to, must start with /
|
||||
*/
|
||||
func (h *Help) String() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(h.command)
|
||||
sb.WriteString(" ")
|
||||
for arg := range h.args {
|
||||
sb.WriteRune('<')
|
||||
sb.WriteString(arg)
|
||||
sb.WriteString("> ")
|
||||
}
|
||||
if h.description != "" {
|
||||
sb.WriteString("\n\t")
|
||||
sb.WriteString(h.description)
|
||||
sb.WriteRune('\n')
|
||||
}
|
||||
sb.WriteRune('\n')
|
||||
for arg, desc := range h.args {
|
||||
sb.WriteRune('\t')
|
||||
sb.WriteString(arg)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(desc)
|
||||
sb.WriteRune('\n')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
254
internal/route/rules/on.go
Normal file
254
internal/route/rules/on.go
Normal file
|
@ -0,0 +1,254 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
)
|
||||
|
||||
type (
|
||||
RuleOn struct {
|
||||
raw string
|
||||
check CheckFulfill
|
||||
}
|
||||
CheckFulfill func(r *http.Request) bool
|
||||
Checkers []CheckFulfill
|
||||
)
|
||||
|
||||
const (
|
||||
OnHeader = "header"
|
||||
OnQuery = "query"
|
||||
OnCookie = "cookie"
|
||||
OnForm = "form"
|
||||
OnPostForm = "postform"
|
||||
OnMethod = "method"
|
||||
OnPath = "path"
|
||||
OnRemote = "remote"
|
||||
)
|
||||
|
||||
var checkers = map[string]struct {
|
||||
help Help
|
||||
validate ValidateFunc
|
||||
check func(r *http.Request, args any) bool
|
||||
}{
|
||||
OnHeader: {
|
||||
help: Help{
|
||||
command: OnHeader,
|
||||
args: map[string]string{
|
||||
"key": "the header key",
|
||||
"value": "the header value",
|
||||
},
|
||||
},
|
||||
validate: toStrTuple,
|
||||
check: func(r *http.Request, args any) bool {
|
||||
return r.Header.Get(args.(StrTuple).First) == args.(StrTuple).Second
|
||||
},
|
||||
},
|
||||
OnQuery: {
|
||||
help: Help{
|
||||
command: OnQuery,
|
||||
args: map[string]string{
|
||||
"key": "the query key",
|
||||
"value": "the query value",
|
||||
},
|
||||
},
|
||||
validate: toStrTuple,
|
||||
check: func(r *http.Request, args any) bool {
|
||||
return r.URL.Query().Get(args.(StrTuple).First) == args.(StrTuple).Second
|
||||
},
|
||||
},
|
||||
OnCookie: {
|
||||
help: Help{
|
||||
command: OnCookie,
|
||||
args: map[string]string{
|
||||
"key": "the cookie key",
|
||||
"value": "the cookie value",
|
||||
},
|
||||
},
|
||||
validate: toStrTuple,
|
||||
check: func(r *http.Request, args any) bool {
|
||||
cookies := r.CookiesNamed(args.(StrTuple).First)
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Value == args.(StrTuple).Second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
OnForm: {
|
||||
help: Help{
|
||||
command: OnForm,
|
||||
args: map[string]string{
|
||||
"key": "the form key",
|
||||
"value": "the form value",
|
||||
},
|
||||
},
|
||||
validate: toStrTuple,
|
||||
check: func(r *http.Request, args any) bool {
|
||||
return r.FormValue(args.(StrTuple).First) == args.(StrTuple).Second
|
||||
},
|
||||
},
|
||||
OnPostForm: {
|
||||
help: Help{
|
||||
command: OnPostForm,
|
||||
args: map[string]string{
|
||||
"key": "the form key",
|
||||
"value": "the form value",
|
||||
},
|
||||
},
|
||||
validate: toStrTuple,
|
||||
check: func(r *http.Request, args any) bool {
|
||||
return r.PostFormValue(args.(StrTuple).First) == args.(StrTuple).Second
|
||||
},
|
||||
},
|
||||
OnMethod: {
|
||||
help: Help{
|
||||
command: OnMethod,
|
||||
args: map[string]string{
|
||||
"method": "the http method",
|
||||
},
|
||||
},
|
||||
validate: validateMethod,
|
||||
check: func(r *http.Request, method any) bool {
|
||||
return r.Method == method.(string)
|
||||
},
|
||||
},
|
||||
OnPath: {
|
||||
help: Help{
|
||||
command: OnPath,
|
||||
description: `The path can be a glob pattern, e.g.:
|
||||
/path/to
|
||||
/path/to/*`,
|
||||
args: map[string]string{
|
||||
"path": "the request path, must start with /",
|
||||
},
|
||||
},
|
||||
validate: validateURLPath,
|
||||
check: func(r *http.Request, globPath any) bool {
|
||||
reqPath := r.URL.Path
|
||||
if len(reqPath) > 0 && reqPath[0] != '/' {
|
||||
reqPath = "/" + reqPath
|
||||
}
|
||||
return strutils.GlobMatch(globPath.(string), reqPath)
|
||||
},
|
||||
},
|
||||
OnRemote: {
|
||||
help: Help{
|
||||
command: OnRemote,
|
||||
args: map[string]string{
|
||||
"ip|cidr": "the remote ip or cidr",
|
||||
},
|
||||
},
|
||||
validate: validateCIDR,
|
||||
check: func(r *http.Request, cidr any) bool {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return cidr.(*net.IPNet).Contains(ip)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Parse implements strutils.Parser.
|
||||
func (on *RuleOn) Parse(v string) error {
|
||||
on.raw = v
|
||||
|
||||
lines := strutils.SplitLine(v)
|
||||
checks := make(Checkers, 0, len(lines))
|
||||
|
||||
errs := E.NewBuilder("rule.on syntax errors")
|
||||
for i, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := parseOn(line)
|
||||
if err != nil {
|
||||
errs.Add(err.Subjectf("line %d", i+1))
|
||||
continue
|
||||
}
|
||||
checks = append(checks, parsed.matchOne())
|
||||
}
|
||||
|
||||
on.check = checks.matchAll()
|
||||
return errs.Error()
|
||||
}
|
||||
|
||||
func (on *RuleOn) String() string {
|
||||
return on.raw
|
||||
}
|
||||
|
||||
func (on *RuleOn) MarshalJSON() ([]byte, error) {
|
||||
return []byte("\"" + on.String() + "\""), nil
|
||||
}
|
||||
|
||||
func parseOn(line string) (Checkers, E.Error) {
|
||||
ors := strutils.SplitRune(line, '|')
|
||||
|
||||
if len(ors) > 1 {
|
||||
errs := E.NewBuilder("rule.on syntax errors")
|
||||
checks := make([]CheckFulfill, len(ors))
|
||||
for i, or := range ors {
|
||||
curCheckers, err := parseOn(or)
|
||||
if err != nil {
|
||||
errs.Add(err)
|
||||
continue
|
||||
}
|
||||
checks[i] = curCheckers[0]
|
||||
}
|
||||
if err := errs.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return checks, nil
|
||||
}
|
||||
|
||||
subject, args, err := parse(line)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checker, ok := checkers[subject]
|
||||
if !ok {
|
||||
return nil, ErrInvalidOnTarget.Subject(subject)
|
||||
}
|
||||
|
||||
validArgs, err := checker.validate(args)
|
||||
if err != nil {
|
||||
return nil, err.Subject(subject).Withf("%s", checker.help.String())
|
||||
}
|
||||
|
||||
return Checkers{
|
||||
func(r *http.Request) bool {
|
||||
return checker.check(r, validArgs)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (checkers Checkers) matchOne() CheckFulfill {
|
||||
return func(r *http.Request) bool {
|
||||
for _, checker := range checkers {
|
||||
if checker(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (checkers Checkers) matchAll() CheckFulfill {
|
||||
return func(r *http.Request) bool {
|
||||
for _, checker := range checkers {
|
||||
if !checker(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
79
internal/route/rules/parser.go
Normal file
79
internal/route/rules/parser.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
)
|
||||
|
||||
var escapedChars = map[rune]rune{
|
||||
'n': '\n',
|
||||
't': '\t',
|
||||
'r': '\r',
|
||||
'\'': '\'',
|
||||
'"': '"',
|
||||
'\\': '\\',
|
||||
' ': ' ',
|
||||
}
|
||||
|
||||
// parse expression to subject and args
|
||||
// with support for quotes and escaped chars, e.g.
|
||||
//
|
||||
// error 403 "Forbidden 'foo' 'bar'"
|
||||
// error 403 Forbidden\ \"foo\"\ \"bar\".
|
||||
func parse(v string) (subject string, args []string, err E.Error) {
|
||||
v = strings.TrimSpace(v)
|
||||
var buf strings.Builder
|
||||
escaped := false
|
||||
quotes := make([]rune, 0, 4)
|
||||
flush := func() {
|
||||
if subject == "" {
|
||||
subject = buf.String()
|
||||
} else {
|
||||
args = append(args, buf.String())
|
||||
}
|
||||
buf.Reset()
|
||||
}
|
||||
for _, r := range v {
|
||||
if escaped {
|
||||
if ch, ok := escapedChars[r]; ok {
|
||||
buf.WriteRune(ch)
|
||||
} else {
|
||||
err = ErrUnsupportedEscapeChar.Subjectf("\\%c", r)
|
||||
return
|
||||
}
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '\\':
|
||||
escaped = true
|
||||
continue
|
||||
case '"', '\'':
|
||||
switch {
|
||||
case len(quotes) > 0 && quotes[len(quotes)-1] == r:
|
||||
quotes = quotes[:len(quotes)-1]
|
||||
if len(quotes) == 0 {
|
||||
flush()
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
case len(quotes) == 0:
|
||||
quotes = append(quotes, r)
|
||||
default:
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
case ' ':
|
||||
flush()
|
||||
default:
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
if len(quotes) > 0 {
|
||||
err = ErrUnterminatedQuotes
|
||||
} else {
|
||||
flush()
|
||||
}
|
||||
return
|
||||
}
|
103
internal/route/rules/rules.go
Normal file
103
internal/route/rules/rules.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type (
|
||||
/*
|
||||
Example:
|
||||
|
||||
proxy.app1.rules: |
|
||||
- name: default
|
||||
do: |
|
||||
rewrite / /index.html
|
||||
serve /var/www/goaccess
|
||||
- name: ws
|
||||
on: |
|
||||
header Connection Upgrade
|
||||
header Upgrade websocket
|
||||
do: bypass
|
||||
|
||||
proxy.app2.rules: |
|
||||
- name: default
|
||||
do: bypass
|
||||
- name: block POST and PUT
|
||||
on: method POST | method PUT
|
||||
do: error 403 Forbidden
|
||||
*/
|
||||
Rules []Rule
|
||||
/*
|
||||
Rule is a rule for a reverse proxy.
|
||||
It do `Do` when `On` matches.
|
||||
|
||||
A rule can have multiple lines of on.
|
||||
|
||||
All lines of on must match,
|
||||
but each line can have multiple checks that
|
||||
one match means this line is matched.
|
||||
*/
|
||||
Rule struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
On RuleOn `json:"on"`
|
||||
Do Command `json:"do"`
|
||||
}
|
||||
)
|
||||
|
||||
// BuildHandler returns a http.HandlerFunc that implements the rules.
|
||||
//
|
||||
// if a bypass rule matches,
|
||||
// the request is passed to the upstream and no more rules are executed.
|
||||
//
|
||||
// if no rule matches, the default rule is executed
|
||||
// if no rule matches and default rule is not set,
|
||||
// the request is passed to the upstream.
|
||||
func (rules Rules) BuildHandler(up http.Handler) http.HandlerFunc {
|
||||
var (
|
||||
defaultRule Rule
|
||||
defaultRuleIndex int
|
||||
)
|
||||
|
||||
for i, rule := range rules {
|
||||
if rule.Name == "default" {
|
||||
defaultRule = rule
|
||||
defaultRuleIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
rules = append(rules[:defaultRuleIndex], rules[defaultRuleIndex+1:]...)
|
||||
|
||||
// free allocated empty slices
|
||||
// before encapsulating them into the handlerFunc.
|
||||
if len(rules) == 0 {
|
||||
if defaultRule.Do.isBypass() {
|
||||
return up.ServeHTTP
|
||||
}
|
||||
rules = []Rule{}
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
hasMatch := false
|
||||
for _, rule := range rules {
|
||||
if rule.On.check(r) {
|
||||
if rule.Do.isBypass() {
|
||||
up.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
rule.Do.exec.HandlerFunc(w, r)
|
||||
if !rule.Do.exec.proceed {
|
||||
return
|
||||
}
|
||||
hasMatch = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasMatch || defaultRule.Do.isBypass() {
|
||||
up.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
defaultRule.Do.exec.HandlerFunc(w, r)
|
||||
}
|
||||
}
|
251
internal/route/rules/rules_test.go
Normal file
251
internal/route/rules/rules_test.go
Normal file
|
@ -0,0 +1,251 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestParseSubjectArgs(t *testing.T) {
|
||||
t.Run("basic", func(t *testing.T) {
|
||||
subject, args, err := parse("rewrite / /foo/bar")
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, subject, "rewrite")
|
||||
ExpectDeepEqual(t, args, []string{"/", "/foo/bar"})
|
||||
})
|
||||
t.Run("with quotes", func(t *testing.T) {
|
||||
subject, args, err := parse(`error 403 "Forbidden 'foo' 'bar'."`)
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, subject, "error")
|
||||
ExpectDeepEqual(t, args, []string{"403", "Forbidden 'foo' 'bar'."})
|
||||
})
|
||||
t.Run("with escaped", func(t *testing.T) {
|
||||
subject, args, err := parse(`error 403 Forbidden\ \"foo\"\ \"bar\".`)
|
||||
ExpectNoError(t, err)
|
||||
ExpectEqual(t, subject, "error")
|
||||
ExpectDeepEqual(t, args, []string{"403", "Forbidden \"foo\" \"bar\"."})
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr error
|
||||
}{
|
||||
// bypass tests
|
||||
{
|
||||
name: "bypass_valid",
|
||||
input: "bypass",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "bypass_invalid_with_args",
|
||||
input: "bypass /",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// rewrite tests
|
||||
{
|
||||
name: "rewrite_valid",
|
||||
input: "rewrite / /foo/bar",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "rewrite_missing_target",
|
||||
input: "rewrite /",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "rewrite_too_many_args",
|
||||
input: "rewrite / / /",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "rewrite_no_leading_slash",
|
||||
input: "rewrite abc /",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// serve tests
|
||||
{
|
||||
name: "serve_valid",
|
||||
input: "serve /var/www",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "serve_missing_path",
|
||||
input: "serve ",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "serve_too_many_args",
|
||||
input: "serve / / /",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// redirect tests
|
||||
{
|
||||
name: "redirect_valid",
|
||||
input: "redirect /",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "redirect_too_many_args",
|
||||
input: "redirect / /",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// error directive tests
|
||||
{
|
||||
name: "error_valid",
|
||||
input: "error 404 Not\\ Found",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "error_missing_status_code",
|
||||
input: "error Not\\ Found",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "error_too_many_args",
|
||||
input: "error 404 Not\\ Found extra",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "error_no_escaped_space",
|
||||
input: "error 404 Not Found",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "error_invalid_status_code",
|
||||
input: "error 123 abc",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// proxy directive tests
|
||||
{
|
||||
name: "proxy_valid",
|
||||
input: "proxy localhost:8080",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "proxy_missing_target",
|
||||
input: "proxy",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "proxy_too_many_args",
|
||||
input: "proxy localhost:8080 extra",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "proxy_invalid_url",
|
||||
input: "proxy :invalid_url",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// unknown directive test
|
||||
{
|
||||
name: "unknown_directive",
|
||||
input: "unknown /",
|
||||
wantErr: ErrUnknownDirective,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := Command{}
|
||||
err := cmd.Parse(tt.input)
|
||||
if tt.wantErr != nil {
|
||||
ExpectError(t, tt.wantErr, err)
|
||||
} else {
|
||||
ExpectNoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr E.Error
|
||||
}{
|
||||
// header
|
||||
{
|
||||
name: "header_valid",
|
||||
input: "header Connection Upgrade",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "header_invalid",
|
||||
input: "header Connection",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// query
|
||||
{
|
||||
name: "query_valid",
|
||||
input: "query key value",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "query_invalid",
|
||||
input: "query key",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// method
|
||||
{
|
||||
name: "method_valid",
|
||||
input: "method GET",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "method_invalid",
|
||||
input: "method",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// path
|
||||
{
|
||||
name: "path_valid",
|
||||
input: "path /home",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "path_invalid",
|
||||
input: "path",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
// remote
|
||||
{
|
||||
name: "remote_valid",
|
||||
input: "remote 127.0.0.1",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "remote_invalid",
|
||||
input: "remote",
|
||||
wantErr: ErrInvalidArguments,
|
||||
},
|
||||
{
|
||||
name: "unknown_target",
|
||||
input: "unknown",
|
||||
wantErr: ErrInvalidOnTarget,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
on := &RuleOn{}
|
||||
err := on.Parse(tt.input)
|
||||
if tt.wantErr != nil {
|
||||
ExpectError(t, tt.wantErr, err)
|
||||
} else {
|
||||
ExpectNoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRule(t *testing.T) {
|
||||
// test := map[string]any{
|
||||
// "name": "test",
|
||||
// "on": "method GET",
|
||||
// "do": "bypass",
|
||||
// }
|
||||
}
|
125
internal/route/rules/validate.go
Normal file
125
internal/route/rules/validate.go
Normal file
|
@ -0,0 +1,125 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
gphttp "github.com/yusing/go-proxy/internal/net/http"
|
||||
"github.com/yusing/go-proxy/internal/net/types"
|
||||
)
|
||||
|
||||
type (
|
||||
ValidateFunc func(args []string) (any, E.Error)
|
||||
StrTuple struct {
|
||||
First, Second string
|
||||
}
|
||||
)
|
||||
|
||||
func toStrTuple(args []string) (any, E.Error) {
|
||||
if len(args) != 2 {
|
||||
return nil, ErrExpectTwoArgs
|
||||
}
|
||||
return StrTuple{args[0], args[1]}, nil
|
||||
}
|
||||
|
||||
func validateURL(args []string) (any, E.Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, ErrExpectOneArg
|
||||
}
|
||||
u, err := types.ParseURL(args[0])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func validateAbsoluteURL(args []string) (any, E.Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, ErrExpectOneArg
|
||||
}
|
||||
u, err := types.ParseURL(args[0])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "http"
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, ErrInvalidArguments.Withf("missing host")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func validateCIDR(args []string) (any, E.Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, ErrExpectOneArg
|
||||
}
|
||||
if !strings.Contains(args[0], "/") {
|
||||
args[0] += "/32"
|
||||
}
|
||||
cidr, err := types.ParseCIDR(args[0])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
return cidr, nil
|
||||
}
|
||||
|
||||
func validateURLPath(args []string) (any, E.Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, ErrExpectOneArg
|
||||
}
|
||||
p := args[0]
|
||||
trailingSlash := len(p) > 1 && p[len(p)-1] == '/'
|
||||
p, _, _ = strings.Cut(p, "#")
|
||||
p = path.Clean(p)
|
||||
if len(p) == 0 {
|
||||
return nil, ErrInvalidArguments.Withf("empty path")
|
||||
}
|
||||
if trailingSlash {
|
||||
p += "/"
|
||||
}
|
||||
if p[0] != '/' {
|
||||
return nil, ErrInvalidArguments.Withf("must start with /")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func validateURLPaths(paths []string) (any, E.Error) {
|
||||
errs := E.NewBuilder("invalid url paths")
|
||||
for i, p := range paths {
|
||||
val, err := validateURLPath([]string{p})
|
||||
if err != nil {
|
||||
errs.Add(err.Subject(p))
|
||||
continue
|
||||
}
|
||||
paths[i] = val.(string)
|
||||
}
|
||||
if err := errs.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func validateFSPath(args []string) (any, E.Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, ErrExpectOneArg
|
||||
}
|
||||
p := path.Clean(args[0])
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func validateMethod(args []string) (any, E.Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, ErrExpectOneArg
|
||||
}
|
||||
method := strings.ToUpper(args[0])
|
||||
if !gphttp.IsMethodValid(method) {
|
||||
return nil, ErrInvalidArguments.Subject(method)
|
||||
}
|
||||
return method, nil
|
||||
}
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/yusing/go-proxy/internal/logging"
|
||||
"github.com/yusing/go-proxy/internal/net/http/accesslog"
|
||||
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
|
||||
"github.com/yusing/go-proxy/internal/route/rules"
|
||||
U "github.com/yusing/go-proxy/internal/utils"
|
||||
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||
|
@ -30,7 +31,7 @@ type (
|
|||
Port string `json:"port,omitempty"`
|
||||
NoTLSVerify bool `json:"no_tls_verify,omitempty"`
|
||||
PathPatterns []string `json:"path_patterns,omitempty"`
|
||||
Rules Rules `json:"rules,omitempty"`
|
||||
Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"`
|
||||
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
|
||||
LoadBalance *loadbalance.Config `json:"load_balance,omitempty"`
|
||||
Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"`
|
||||
|
|
8
internal/route/types/route_type.go
Normal file
8
internal/route/types/route_type.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package types
|
||||
|
||||
type RouteType string
|
||||
|
||||
const (
|
||||
RouteTypeStream RouteType = "stream"
|
||||
RouteTypeReverseProxy RouteType = "reverse_proxy"
|
||||
)
|
|
@ -9,7 +9,6 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
E "github.com/yusing/go-proxy/internal/error"
|
||||
|
@ -48,82 +47,6 @@ func New(t reflect.Type) reflect.Value {
|
|||
return reflect.New(t)
|
||||
}
|
||||
|
||||
// Serialize converts the given data into a map[string]any representation.
|
||||
//
|
||||
// It uses reflection to inspect the data type and handle different kinds of data.
|
||||
// For a struct, it extracts the fields using the json tag if present, or the field name if not.
|
||||
// For an embedded struct, it recursively converts its fields into the result map.
|
||||
// For any other type, it returns an error.
|
||||
//
|
||||
// Parameters:
|
||||
// - data: The data to be converted into a map.
|
||||
//
|
||||
// Returns:
|
||||
// - result: The resulting map[string]any representation of the data.
|
||||
// - error: An error if the data type is unsupported or if there is an error during conversion.
|
||||
func Serialize(data any) (SerializedObject, error) {
|
||||
result := make(map[string]any)
|
||||
|
||||
// Use reflection to inspect the data type
|
||||
value := reflect.ValueOf(data)
|
||||
|
||||
// Check if the value is valid
|
||||
if !value.IsValid() {
|
||||
return nil, ErrInvalidType.Subjectf("%T", data)
|
||||
}
|
||||
|
||||
// Dereference pointers if necessary
|
||||
if value.Kind() == reflect.Ptr {
|
||||
value = value.Elem()
|
||||
}
|
||||
|
||||
// Handle different kinds of data
|
||||
switch value.Kind() {
|
||||
case reflect.Map:
|
||||
for _, key := range value.MapKeys() {
|
||||
result[key.String()] = value.MapIndex(key).Interface()
|
||||
}
|
||||
case reflect.Struct:
|
||||
for i := range value.NumField() {
|
||||
field := value.Type().Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
jsonTag := field.Tag.Get("json") // Get the json tag
|
||||
if jsonTag == "-" {
|
||||
continue // Ignore this field if the tag is "-"
|
||||
}
|
||||
if strings.Contains(jsonTag, ",omitempty") {
|
||||
if value.Field(i).IsZero() {
|
||||
continue
|
||||
}
|
||||
jsonTag = strings.Replace(jsonTag, ",omitempty", "", 1)
|
||||
}
|
||||
|
||||
// If the json tag is not empty, use it as the key
|
||||
switch {
|
||||
case jsonTag != "":
|
||||
result[jsonTag] = value.Field(i).Interface()
|
||||
case field.Anonymous:
|
||||
// If the field is an embedded struct, add its fields to the result
|
||||
fieldMap, err := Serialize(value.Field(i).Interface())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range fieldMap {
|
||||
result[k] = v
|
||||
}
|
||||
default:
|
||||
result[field.Name] = value.Field(i).Interface()
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("serialize: unsupported data type " + value.Kind().String())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func extractFields(t reflect.Type) []reflect.StructField {
|
||||
for t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
|
@ -203,9 +126,8 @@ func Deserialize(src SerializedObject, dst any) E.Error {
|
|||
mapping[key] = dstV.FieldByName(field.Name)
|
||||
fieldName[field.Name] = key
|
||||
|
||||
_, ok := field.Tag.Lookup("validate")
|
||||
if ok {
|
||||
needValidate = true
|
||||
if !needValidate {
|
||||
_, needValidate = field.Tag.Lookup("validate")
|
||||
}
|
||||
|
||||
aliases, ok := field.Tag.Lookup("aliases")
|
||||
|
@ -258,7 +180,7 @@ func Deserialize(src SerializedObject, dst any) E.Error {
|
|||
}
|
||||
return errs.Error()
|
||||
default:
|
||||
return ErrUnsupportedConversion.Subject("deserialize to " + dstT.String())
|
||||
return ErrUnsupportedConversion.Subject("mapping to " + dstT.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -355,7 +277,7 @@ func Convert(src reflect.Value, dst reflect.Value) E.Error {
|
|||
if dstT.Kind() != reflect.Slice {
|
||||
return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String())
|
||||
}
|
||||
newSlice := reflect.MakeSlice(dstT, 0, src.Len())
|
||||
newSlice := reflect.MakeSlice(dstT, src.Len(), src.Len())
|
||||
i := 0
|
||||
for _, v := range src.Seq2() {
|
||||
tmp := New(dstT.Elem()).Elem()
|
||||
|
@ -363,7 +285,7 @@ func Convert(src reflect.Value, dst reflect.Value) E.Error {
|
|||
if err != nil {
|
||||
return err.Subjectf("[%d]", i)
|
||||
}
|
||||
newSlice = reflect.Append(newSlice, tmp)
|
||||
newSlice.Index(i).Set(tmp)
|
||||
i++
|
||||
}
|
||||
dst.Set(newSlice)
|
||||
|
@ -424,10 +346,11 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr E.E
|
|||
return true, E.From(parser.Parse(src))
|
||||
}
|
||||
// yaml like
|
||||
isMultiline := strings.ContainsRune(src, '\n')
|
||||
var tmp any
|
||||
switch dst.Kind() {
|
||||
case reflect.Slice:
|
||||
src = strings.TrimSpace(src)
|
||||
isMultiline := strings.ContainsRune(src, '\n')
|
||||
// one liner is comma separated list
|
||||
if !isMultiline {
|
||||
values := strutils.CommaSeperatedList(src)
|
||||
|
@ -444,16 +367,10 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr E.E
|
|||
}
|
||||
return
|
||||
}
|
||||
lines := strutils.SplitLine(src)
|
||||
sl := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimLeftFunc(line, func(r rune) bool {
|
||||
return r == '-' || unicode.IsSpace(r)
|
||||
})
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
sl = append(sl, line)
|
||||
sl := make([]any, 0)
|
||||
err := yaml.Unmarshal([]byte(src), &sl)
|
||||
if err != nil {
|
||||
return true, E.From(err)
|
||||
}
|
||||
tmp = sl
|
||||
case reflect.Map, reflect.Struct:
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestSerializeDeserialize(t *testing.T) {
|
||||
func TestDeserialize(t *testing.T) {
|
||||
type S struct {
|
||||
I int
|
||||
S string
|
||||
|
@ -37,12 +37,6 @@ func TestSerializeDeserialize(t *testing.T) {
|
|||
}
|
||||
)
|
||||
|
||||
t.Run("serialize", func(t *testing.T) {
|
||||
s, err := Serialize(testStruct)
|
||||
ExpectNoError(t, err)
|
||||
ExpectDeepEqual(t, s, testStructSerialized)
|
||||
})
|
||||
|
||||
t.Run("deserialize", func(t *testing.T) {
|
||||
var s2 S
|
||||
err := Deserialize(testStructSerialized, &s2)
|
||||
|
@ -174,7 +168,7 @@ func TestStringToSlice(t *testing.T) {
|
|||
})
|
||||
t.Run("multiline", func(t *testing.T) {
|
||||
dst := make([]string, 0)
|
||||
convertible, err := ConvertString(" a\n b\n c", reflect.ValueOf(&dst))
|
||||
convertible, err := ConvertString("- a\n- b\n- c", reflect.ValueOf(&dst))
|
||||
ExpectTrue(t, convertible)
|
||||
ExpectNoError(t, err)
|
||||
ExpectDeepEqual(t, dst, []string{"a", "b", "c"})
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package strutils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
|
@ -22,14 +21,6 @@ func Title(s string) string {
|
|||
return cases.Title(language.AmericanEnglish).String(s)
|
||||
}
|
||||
|
||||
func ExtractPort(fullURL string) (int, error) {
|
||||
url, err := url.Parse(fullURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return Atoi(url.Port())
|
||||
}
|
||||
|
||||
func ToLowerNoSnake(s string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(s, "_", ""))
|
||||
}
|
||||
|
|
|
@ -12,3 +12,10 @@ var ErrValidationError = E.New("validation error")
|
|||
func Validator() *validator.Validate {
|
||||
return validate
|
||||
}
|
||||
|
||||
func MustRegisterValidation(tag string, fn validator.Func) {
|
||||
err := validate.RegisterValidation(tag, fn)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
118
next-release.md
Normal file
118
next-release.md
Normal file
|
@ -0,0 +1,118 @@
|
|||
GoDoxy v0.8.2 expected changes
|
||||
|
||||
- **Thanks [polds](https://github.com/polds)**
|
||||
Optionally allow a user to specify a “warm-up” endpoint to start the container, returning a 403 if the endpoint isn’t hit and the container has been stopped.
|
||||
|
||||
This can help prevent bots from starting random containers, or allow health check systems to run some probes. Or potentially lock the start endpoints behind a different authentication mechanism, etc.
|
||||
|
||||
Sample service showing this:
|
||||
|
||||
```yaml
|
||||
hello-world:
|
||||
image: nginxdemos/hello
|
||||
container_name: hello-world
|
||||
restart: "no"
|
||||
ports:
|
||||
- "9100:80"
|
||||
labels:
|
||||
proxy.aliases: hello-world
|
||||
proxy.#1.port: 9100
|
||||
proxy.idle_timeout: 45s
|
||||
proxy.wake_timeout: 30s
|
||||
proxy.stop_method: stop
|
||||
proxy.stop_timeout: 10s
|
||||
proxy.stop_signal: SIGTERM
|
||||
proxy.start_endpoint: "/start"
|
||||
```
|
||||
|
||||
Hitting `/` on this service when the container is down:
|
||||
|
||||
```curl
|
||||
$ curl -sv -X GET -H "Host: hello-world.godoxy.local" http://localhost/
|
||||
* Host localhost:80 was resolved.
|
||||
* IPv6: ::1
|
||||
* IPv4: 127.0.0.1
|
||||
* Trying [::1]:80...
|
||||
* Connected to localhost (::1) port 80
|
||||
> GET / HTTP/1.1
|
||||
> Host: hello-world.godoxy.local
|
||||
> User-Agent: curl/8.7.1
|
||||
> Accept: */*
|
||||
>
|
||||
* Request completely sent off
|
||||
< HTTP/1.1 403 Forbidden
|
||||
< Content-Type: text/plain; charset=utf-8
|
||||
< X-Content-Type-Options: nosniff
|
||||
< Date: Wed, 08 Jan 2025 02:04:51 GMT
|
||||
< Content-Length: 71
|
||||
<
|
||||
Forbidden: Container can only be started via configured start endpoint
|
||||
* Connection #0 to host localhost left intact
|
||||
```
|
||||
|
||||
Hitting `/start` when the container is down:
|
||||
|
||||
```curl
|
||||
curl -sv -X GET -H "Host: hello-world.godoxy.local" -H "X-Goproxy-Check-Redirect: skip" http://localhost/start
|
||||
* Host localhost:80 was resolved.
|
||||
* IPv6: ::1
|
||||
* IPv4: 127.0.0.1
|
||||
* Trying [::1]:80...
|
||||
* Connected to localhost (::1) port 80
|
||||
> GET /start HTTP/1.1
|
||||
> Host: hello-world.godoxy.local
|
||||
> User-Agent: curl/8.7.1
|
||||
> Accept: */*
|
||||
> X-Goproxy-Check-Redirect: skip
|
||||
>
|
||||
* Request completely sent off
|
||||
< HTTP/1.1 200 OK
|
||||
< Date: Wed, 08 Jan 2025 02:13:39 GMT
|
||||
< Content-Length: 0
|
||||
<
|
||||
* Connection #0 to host localhost left intact
|
||||
```
|
||||
|
||||
- Caddyfile like rules
|
||||
|
||||
```yaml
|
||||
proxy.goaccess.rules: |
|
||||
- name: default
|
||||
do: |
|
||||
rewrite / /index.html
|
||||
serve /var/www/goaccess
|
||||
- name: ws
|
||||
on: |
|
||||
header Connection Upgrade
|
||||
header Upgrade websocket
|
||||
do: bypass # do nothing, pass to reverse proxy
|
||||
|
||||
proxy.app.rules: |
|
||||
- name: default
|
||||
do: bypass # do nothing, pass to reverse proxy
|
||||
- name: block POST and PUT
|
||||
on: method POST | method PUT
|
||||
do: error 403 Forbidden
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
- config reload will now cause all servers to fully restart (i.e. proxy, api, prometheus, etc)
|
||||
- multiline-string as list now treated as YAML list, which requires hyphen prefix `-`, i.e.
|
||||
```yaml
|
||||
proxy.app.middlewares.request.hide_headers:
|
||||
- X-Header1
|
||||
- X-Header2
|
||||
````
|
||||
- autocert now supports hot-reload
|
||||
- middleware compose now supports cross-referencing, e.g.
|
||||
```yaml
|
||||
foo:
|
||||
- use: RedirectHTTP
|
||||
bar: # in the same file or different file
|
||||
- use: foo@file
|
||||
```
|
||||
|
||||
- Fixes
|
||||
- bug: cert renewal failure no longer causes renew schdueler to stuck forever
|
||||
- bug: access log writes to closed file after config reload
|
Loading…
Add table
Reference in a new issue