package http import ( "net/http" "path" "strings" "tug/internal/platform/config" ) type Handlers struct { IDP http.Handler Account http.Handler Admin http.Handler AuthZ http.Handler SSO http.Handler SSOAssets http.Handler } type hostClass int const ( classUnknown hostClass = iota classIDP classAccount classAdmin classApp ) type router struct { hosts map[string]hostClass h Handlers page ErrorPage limits *Limits observe Observer } func NewRouter(srv config.Server, apps []config.App, h Handlers, page ErrorPage, opts ...RouterOption) http.Handler { if page == nil { panic(errNilPage) } hosts := map[string]hostClass{ srv.DomainIDP: classIDP, srv.DomainAccount: classAccount, srv.DomainAdmin: classAdmin, } for _, a := range apps { hosts[a.Domain] = classApp } delete(hosts, "") rt := &router{hosts: hosts, h: h, page: page, observe: Observer{}.orDiscard()} for _, o := range opts { o(rt) } return rt } func CanonicalHost(host string) string { h := asciiLower(host) if strings.HasPrefix(h, "[") { return h } i := strings.LastIndexByte(h, ':') if i < 0 || strings.IndexByte(h[:i], ':') >= 0 || !validPort(h[i+1:]) { return h } return h[:i] } func asciiLower(s string) string { b := []byte(s) for i, c := range b { if 'A' <= c && c <= 'Z' { b[i] = c + ('a' - 'A') } } return string(b) } func validPort(p string) bool { if p == "" || len(p) > 5 { return false } n := 0 for _, c := range []byte(p) { if c < '0' || c > '9' { return false } n = n*10 + int(c-'0') } return n >= 1 && n <= 65535 } func (rt *router) route(w http.ResponseWriter, r *http.Request, class hostClass) { p := r.URL.Path if !strings.HasPrefix(p, "/") { rt.page(w, r, http.StatusNotFound) return } clean := path.Clean("/" + p) if isOperationsPath(p) || isOperationsPath(clean) { rt.page(w, r, http.StatusNotFound) return } if rt.limits != nil { release, ok := rt.limits.admit(w, r, class, rt.page, rt.observe) if !ok { return } defer release() } switch class { case classIDP: rt.service(w, r, rt.h.IDP, p, clean) case classAccount: rt.service(w, r, rt.h.Account, p, clean) case classAdmin: rt.service(w, r, rt.h.Admin, p, clean) case classApp: rt.app(w, r) default: rt.page(w, r, http.StatusNotFound) } } func isOperationsPath(p string) bool { return p == "/healthz" || p == "/readyz" || p == "/metrics" } func isForwardAuthPath(p string) bool { return p == "/authz" || strings.HasPrefix(p, "/.sso/") } func (rt *router) service(w http.ResponseWriter, r *http.Request, h http.Handler, p, clean string) { if isForwardAuthPath(p) || isForwardAuthPath(clean) { rt.page(w, r, http.StatusNotFound) return } rt.serve(w, r, h) } func (rt *router) app(w http.ResponseWriter, r *http.Request) { if r.URL.RawPath != "" { rt.page(w, r, http.StatusNotFound) return } switch p, m := r.URL.Path, r.Method; { case p == "/authz" && m == http.MethodGet: rt.serve(w, r, rt.h.AuthZ) case (p == "/.sso/login" || p == "/.sso/callback") && m == http.MethodGet: rt.serve(w, r, rt.h.SSO) case p == "/.sso/logout" && (m == http.MethodGet || m == http.MethodPost): rt.serve(w, r, rt.h.SSO) case isSSOAssetPath(p) && m == http.MethodGet: rt.serve(w, r, rt.h.SSOAssets) default: rt.page(w, r, http.StatusNotFound) } } const SSOAssetPrefix = "/.sso/static/" func isSSOAssetPath(p string) bool { name, ok := strings.CutPrefix(p, SSOAssetPrefix) return ok && name != "" && !strings.Contains(name, "/") && path.Clean(p) == p } func (rt *router) serve(w http.ResponseWriter, r *http.Request, h http.Handler) { if h == nil { rt.page(w, r, http.StatusServiceUnavailable) return } h.ServeHTTP(w, r) }