diff --git a/.github/workflows/merge-main-into-compat.yml b/.github/workflows/merge-main-into-compat.yml new file mode 100644 index 00000000..a9c4027f --- /dev/null +++ b/.github/workflows/merge-main-into-compat.yml @@ -0,0 +1,39 @@ +name: Cherry-pick into Compat + +on: + push: + tags: + - v* + paths: + - ".github/workflows/merge-main-into-compat.yml" + +jobs: + cherry-pick: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Configure git user + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Cherry-pick commits from last tag + run: | + git fetch origin compat + git checkout compat + CURRENT_TAG=${{ github.ref_name }} + PREV_TAG=$(git describe --tags --abbrev=0 $CURRENT_TAG^ 2>/dev/null || echo "") + + if [ -z "$PREV_TAG" ]; then + echo "No previous tag found. Cherry-picking all commits up to $CURRENT_TAG" + git rev-list --reverse --no-merges $CURRENT_TAG | xargs -r git cherry-pick + else + echo "Cherry-picking commits from $PREV_TAG to $CURRENT_TAG" + git rev-list --reverse --no-merges $PREV_TAG..$CURRENT_TAG | xargs -r git cherry-pick + fi + - name: Push compat + run: | + git push origin compat diff --git a/goutils b/goutils index 6c698b1d..cb0f79b5 160000 --- a/goutils +++ b/goutils @@ -1 +1 @@ -Subproject commit 6c698b1d55d04591f362db585a6fbfe1a8893f56 +Subproject commit cb0f79b51ce2c38c55615ff7b2b1fba731ebfee5 diff --git a/internal/docker/container_helper.go b/internal/docker/container_helper.go index cea49859..171e55e7 100644 --- a/internal/docker/container_helper.go +++ b/internal/docker/container_helper.go @@ -31,6 +31,9 @@ func (c containerHelper) getAliases() []string { } func (c containerHelper) getName() string { + if len(c.Names) == 0 { // Why did it happen? Every container must have a name. + return "" + } return strings.TrimPrefix(c.Names[0], "/") } diff --git a/internal/idlewatcher/watcher.go b/internal/idlewatcher/watcher.go index 90a5030b..0be101b1 100644 --- a/internal/idlewatcher/watcher.go +++ b/internal/idlewatcher/watcher.go @@ -200,7 +200,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) depCfg = new(types.IdlewatcherConfig) depCfg.IdlewatcherConfigBase = cfg.IdlewatcherConfigBase depCfg.IdleTimeout = neverTick // disable auto sleep for dependencies - } else if depCfg.IdleTimeout > 0 { + } else if depCfg.IdleTimeout > 0 && depCfg.IdleTimeout != neverTick { depErrors.Addf("dependency %q has positive idle timeout %s", dep, depCfg.IdleTimeout) continue } diff --git a/internal/route/fileserver.go b/internal/route/fileserver.go index 80c531a6..e5b06281 100644 --- a/internal/route/fileserver.go +++ b/internal/route/fileserver.go @@ -2,6 +2,7 @@ package route import ( "net/http" + "os" "path" "path/filepath" @@ -27,8 +28,25 @@ type ( var _ types.FileServerRoute = (*FileServer)(nil) -func handler(root string) http.Handler { - return http.FileServer(http.Dir(root)) +func handler(root string, spa bool, index string) http.Handler { + if !spa { + return http.FileServer(http.Dir(root)) + } + indexPath := filepath.Join(root, index) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + urlPath := path.Clean(r.URL.Path) + if urlPath == "/" { + http.ServeFile(w, r, indexPath) + return + } + fullPath := filepath.Join(root, filepath.FromSlash(urlPath)) + stat, err := os.Stat(fullPath) + if err == nil && !stat.IsDir() { + http.ServeFile(w, r, fullPath) + return + } + http.ServeFile(w, r, indexPath) + }) } func NewFileServer(base *Route) (*FileServer, gperr.Error) { @@ -39,7 +57,12 @@ func NewFileServer(base *Route) (*FileServer, gperr.Error) { return nil, gperr.New("`root` must be an absolute path") } - s.handler = handler(s.Root) + if s.Index == "" { + s.Index = "/index.html" + } else if s.Index[0] != '/' { + s.Index = "/" + s.Index + } + s.handler = handler(s.Root, s.SPA, s.Index) if len(s.Middlewares) > 0 { mid, err := middleware.BuildMiddlewareFromMap(s.Alias, s.Middlewares) diff --git a/internal/route/reverse_proxy.go b/internal/route/reverse_proxy.go index fba3bc4e..50a803e2 100755 --- a/internal/route/reverse_proxy.go +++ b/internal/route/reverse_proxy.go @@ -13,6 +13,7 @@ import ( "github.com/yusing/godoxy/internal/net/gphttp/middleware" nettypes "github.com/yusing/godoxy/internal/net/types" "github.com/yusing/godoxy/internal/route/routes" + route "github.com/yusing/godoxy/internal/route/types" "github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/watcher/health/monitor" gperr "github.com/yusing/goutils/errs" @@ -60,6 +61,28 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) { service := base.Name() rp := reverseproxy.NewReverseProxy(service, &proxyURL.URL, trans) + scheme := base.Scheme + retried := false + retryLock := sync.Mutex{} + rp.OnSchemeMisMatch = func() (retry bool) { // switch scheme and retry + retryLock.Lock() + defer retryLock.Unlock() + + if retried { + return false + } + + retried = true + + if scheme == route.SchemeHTTP { + rp.TargetURL.Scheme = "https" + } else { + rp.TargetURL.Scheme = "http" + } + rp.Info().Msgf("scheme mismatch detected, retrying with %s", rp.TargetURL.Scheme) + return true + } + if len(base.Middlewares) > 0 { err := middleware.PatchReverseProxy(rp, base.Middlewares) if err != nil { diff --git a/internal/route/route.go b/internal/route/route.go index bc97bc7d..4795f2fc 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -44,7 +44,10 @@ type ( Scheme route.Scheme `json:"scheme,omitempty" swaggertype:"string" enums:"http,https,tcp,udp,fileserver"` Host string `json:"host,omitempty"` Port route.Port `json:"port"` - Root string `json:"root,omitempty"` + + Root string `json:"root,omitempty"` + SPA bool `json:"spa,omitempty"` // Single-page app mode: serves index for non-existent paths + Index string `json:"index,omitempty"` // Index file to serve for single-page app mode route.HTTPConfig PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"` diff --git a/internal/watcher/health/monitor/http.go b/internal/watcher/health/monitor/http.go index 58c596b9..8f628aa3 100644 --- a/internal/watcher/health/monitor/http.go +++ b/internal/watcher/health/monitor/http.go @@ -40,6 +40,8 @@ func NewHTTPHealthMonitor(url *url.URL, config types.HealthCheckConfig) *HTTPHea return mon } +var userAgent = "GoDoxy/" + version.Get().String() + func (mon *HTTPHealthMonitor) CheckHealth() (types.HealthCheckResult, error) { req := fasthttp.AcquireRequest() defer fasthttp.ReleaseRequest(req) @@ -49,7 +51,7 @@ func (mon *HTTPHealthMonitor) CheckHealth() (types.HealthCheckResult, error) { req.SetRequestURI(mon.url.Load().JoinPath(mon.config.Path).String()) req.Header.SetMethod(mon.method) - req.Header.Set("User-Agent", "GoDoxy/"+version.Get().String()) + req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "text/plain,text/html,*/*;q=0.8") req.Header.Set("Accept-Encoding", "identity") req.Header.Set("Cache-Control", "no-cache") diff --git a/internal/watcher/health/monitor/monitor.go b/internal/watcher/health/monitor/monitor.go index 6afcac01..2266780e 100644 --- a/internal/watcher/health/monitor/monitor.go +++ b/internal/watcher/health/monitor/monitor.go @@ -74,7 +74,11 @@ func NewMonitor(r types.Route) types.HealthMonCheck { } func newMonitor(u *url.URL, cfg types.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { - cfg.ApplyDefaults(config.WorkingState.Load().Value().Defaults.HealthCheck) + if state := config.WorkingState.Load(); state != nil { + cfg.ApplyDefaults(state.Value().Defaults.HealthCheck) + } else { + cfg.ApplyDefaults(types.HealthCheckConfig{}) // use defaults from constants + } mon := &monitor{ config: cfg, checkHealth: healthCheckFunc,