1package http
2
3import (
4 "net/http"
5 "path"
6 "strings"
7
8 "tug/internal/platform/config"
9)
10
11type Handlers struct {
12 IDP http.Handler
13 Account http.Handler
14 Admin http.Handler
15 AuthZ http.Handler
16 SSO http.Handler
17 SSOAssets http.Handler
18}
19
20type hostClass int
21
22const (
23 classUnknown hostClass = iota
24 classIDP
25 classAccount
26 classAdmin
27 classApp
28)
29
30type router struct {
31 hosts map[string]hostClass
32 h Handlers
33 page ErrorPage
34 limits *Limits
35 observe Observer
36}
37
38func NewRouter(srv config.Server, apps []config.App, h Handlers, page ErrorPage, opts ...RouterOption) http.Handler {
39 if page == nil {
40 panic(errNilPage)
41 }
42 hosts := map[string]hostClass{
43 srv.DomainIDP: classIDP,
44 srv.DomainAccount: classAccount,
45 srv.DomainAdmin: classAdmin,
46 }
47 for _, a := range apps {
48 hosts[a.Domain] = classApp
49 }
50 delete(hosts, "")
51 rt := &router{hosts: hosts, h: h, page: page, observe: Observer{}.orDiscard()}
52 for _, o := range opts {
53 o(rt)
54 }
55 return rt
56}
57
58func CanonicalHost(host string) string {
59 h := asciiLower(host)
60 if strings.HasPrefix(h, "[") {
61 return h
62 }
63 i := strings.LastIndexByte(h, ':')
64 if i < 0 || strings.IndexByte(h[:i], ':') >= 0 || !validPort(h[i+1:]) {
65 return h
66 }
67 return h[:i]
68}
69
70func asciiLower(s string) string {
71 b := []byte(s)
72 for i, c := range b {
73 if 'A' <= c && c <= 'Z' {
74 b[i] = c + ('a' - 'A')
75 }
76 }
77 return string(b)
78}
79
80func validPort(p string) bool {
81 if p == "" || len(p) > 5 {
82 return false
83 }
84 n := 0
85 for _, c := range []byte(p) {
86 if c < '0' || c > '9' {
87 return false
88 }
89 n = n*10 + int(c-'0')
90 }
91 return n >= 1 && n <= 65535
92}
93
94func (rt *router) route(w http.ResponseWriter, r *http.Request, class hostClass) {
95 p := r.URL.Path
96 if !strings.HasPrefix(p, "/") {
97 rt.page(w, r, http.StatusNotFound)
98 return
99 }
100 clean := path.Clean("/" + p)
101 if isOperationsPath(p) || isOperationsPath(clean) {
102 rt.page(w, r, http.StatusNotFound)
103 return
104 }
105 if rt.limits != nil {
106 release, ok := rt.limits.admit(w, r, class, rt.page, rt.observe)
107 if !ok {
108 return
109 }
110 defer release()
111 }
112 switch class {
113 case classIDP:
114 rt.service(w, r, rt.h.IDP, p, clean)
115 case classAccount:
116 rt.service(w, r, rt.h.Account, p, clean)
117 case classAdmin:
118 rt.service(w, r, rt.h.Admin, p, clean)
119 case classApp:
120 rt.app(w, r)
121 default:
122 rt.page(w, r, http.StatusNotFound)
123 }
124}
125
126func isOperationsPath(p string) bool {
127 return p == "/healthz" || p == "/readyz" || p == "/metrics"
128}
129
130func isForwardAuthPath(p string) bool {
131 return p == "/authz" || strings.HasPrefix(p, "/.sso/")
132}
133
134func (rt *router) service(w http.ResponseWriter, r *http.Request, h http.Handler, p, clean string) {
135 if isForwardAuthPath(p) || isForwardAuthPath(clean) {
136 rt.page(w, r, http.StatusNotFound)
137 return
138 }
139 rt.serve(w, r, h)
140}
141
142func (rt *router) app(w http.ResponseWriter, r *http.Request) {
143 if r.URL.RawPath != "" {
144 rt.page(w, r, http.StatusNotFound)
145 return
146 }
147 switch p, m := r.URL.Path, r.Method; {
148 case p == "/authz" && m == http.MethodGet:
149 rt.serve(w, r, rt.h.AuthZ)
150 case (p == "/.sso/login" || p == "/.sso/callback") && m == http.MethodGet:
151 rt.serve(w, r, rt.h.SSO)
152 case p == "/.sso/logout" && (m == http.MethodGet || m == http.MethodPost):
153 rt.serve(w, r, rt.h.SSO)
154 case isSSOAssetPath(p) && m == http.MethodGet:
155 rt.serve(w, r, rt.h.SSOAssets)
156 default:
157 rt.page(w, r, http.StatusNotFound)
158 }
159}
160
161const SSOAssetPrefix = "/.sso/static/"
162
163func isSSOAssetPath(p string) bool {
164 name, ok := strings.CutPrefix(p, SSOAssetPrefix)
165 return ok && name != "" && !strings.Contains(name, "/") && path.Clean(p) == p
166}
167
168func (rt *router) serve(w http.ResponseWriter, r *http.Request, h http.Handler) {
169 if h == nil {
170 rt.page(w, r, http.StatusServiceUnavailable)
171 return
172 }
173 h.ServeHTTP(w, r)
174}