probe.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. package appproxy
  2. import (
  3. "io"
  4. "net/http"
  5. "net/url"
  6. "regexp"
  7. "strings"
  8. "time"
  9. "golang.org/x/net/html"
  10. )
  11. /*
  12. probe.go
  13. Detection: before an admin publishes an app, fetch its start page and
  14. guess whether it can live under /app/<slug>/.
  15. - root references (src="/x") in the page -> needs rewriting
  16. - relative references only -> works under a path
  17. - <base href="/"> or a framework that hardcodes
  18. its asset root (Next.js, Nuxt, SvelteKit) -> prefer subdomain
  19. - root URL strings inside the first bundles -> runtime requests need
  20. the rewrite script
  21. - a small table of well known images overrides the guess
  22. The runtime complement is the leak counter in Stats: every root request
  23. the redirect catches is counted, so an admin sees how "leaky" an app is.
  24. */
  25. const (
  26. VerdictPath = "path" //Works under /app/<slug>/ as is
  27. VerdictPathRewrite = "path-rewrite" //Needs root path rewriting under /app/<slug>/
  28. VerdictSubdomain = "subdomain" //Give it its own hostname
  29. probeTimeout = 8 * time.Second
  30. maxProbePage = 2 << 20
  31. maxProbeScript = 3 << 20
  32. maxProbeScripts = 4
  33. )
  34. // ProbeResult describes how an app references its own resources
  35. type ProbeResult struct {
  36. Reachable bool
  37. Status int
  38. Error string
  39. Title string
  40. RootRefs int //src/href/action values starting with "/"
  41. RelativeRefs int //Relative src/href/action values
  42. SelfRefs int //Absolute URLs pointing at the upstream address
  43. BaseHrefRoot bool //<base href="/">
  44. Frameworks []string //Recognised front-end frameworks
  45. ScriptHints int //Root URL strings found in the first scripts
  46. FrameBlocked bool //X-Frame-Options / frame-ancestors forbid a desktop window
  47. KnownApp string //Name of a recognised image
  48. Verdict string
  49. Reasons []string
  50. }
  51. type knownApp struct {
  52. match []string
  53. name string
  54. verdict string
  55. note string
  56. }
  57. // Images whose sub-path behaviour is well known. note may use {prefix}
  58. var knownApps = []knownApp{
  59. {[]string{"home-assistant", "homeassistant"}, "Home Assistant", VerdictSubdomain, "Home Assistant cannot run under a sub-path. Use subdomain mode."},
  60. {[]string{"uptime-kuma"}, "Uptime Kuma", VerdictSubdomain, "Uptime Kuma does not support a sub-path. Use subdomain mode."},
  61. {[]string{"nextcloud"}, "Nextcloud", VerdictSubdomain, "Nextcloud needs overwritewebroot for a sub-path, subdomain mode is simpler."},
  62. {[]string{"grafana"}, "Grafana", VerdictPath, "Set GF_SERVER_ROOT_URL to <your ArozOS URL>{prefix}/ and GF_SERVER_SERVE_FROM_SUB_PATH=true."},
  63. {[]string{"jellyfin"}, "Jellyfin", VerdictPath, "Set Dashboard > Networking > Base URL to {prefix}."},
  64. {[]string{"sonarr", "radarr", "lidarr", "prowlarr", "readarr"}, "*arr", VerdictPath, "Set Settings > General > URL Base to {prefix}."},
  65. {[]string{"filebrowser"}, "File Browser", VerdictPath, "Start it with --baseurl {prefix}."},
  66. {[]string{"code-server"}, "code-server", VerdictPath, "code-server works under a sub-path as is."},
  67. {[]string{"syncthing"}, "Syncthing", VerdictPath, "The Syncthing GUI works under a sub-path as is."},
  68. {[]string{"qbittorrent"}, "qBittorrent", VerdictPath, "The qBittorrent Web UI works under a sub-path as is."},
  69. }
  70. var (
  71. scriptRootHint = regexp.MustCompile("[\"'`]/(api|static|assets|socket\\.io|ws|graphql|auth|login|_next|_nuxt|_app)[/\"'`?]")
  72. frameworkHints = []struct {
  73. name string
  74. pattern string
  75. rooted bool //Hardcodes its asset root, rewriting is fragile
  76. }{
  77. {"Next.js", "/_next/", true},
  78. {"Nuxt", "/_nuxt/", true},
  79. {"SvelteKit", "/_app/immutable/", true},
  80. {"Vite", "/assets/index-", false},
  81. {"Create React App", "/static/js/main.", false},
  82. {"Angular", "ng-version", false},
  83. }
  84. )
  85. // matchKnownApp finds the well known entry of an image, or nil
  86. func matchKnownApp(image string) *knownApp {
  87. image = strings.ToLower(image)
  88. if image == "" {
  89. return nil
  90. }
  91. for i := range knownApps {
  92. for _, m := range knownApps[i].match {
  93. if strings.Contains(image, m) {
  94. return &knownApps[i]
  95. }
  96. }
  97. }
  98. return nil
  99. }
  100. // classifyRef sorts one URL found in the page
  101. func classifyRef(v string, upstreamHost string, res *ProbeResult) {
  102. v = strings.TrimSpace(v)
  103. lower := strings.ToLower(v)
  104. switch {
  105. case v == "" || strings.HasPrefix(v, "#"):
  106. case strings.HasPrefix(lower, "data:"), strings.HasPrefix(lower, "javascript:"), strings.HasPrefix(lower, "mailto:"), strings.HasPrefix(lower, "blob:"):
  107. case strings.HasPrefix(v, "//"), strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
  108. if u, err := url.Parse(v); err == nil && strings.EqualFold(u.Host, upstreamHost) {
  109. res.SelfRefs++
  110. }
  111. case strings.HasPrefix(v, "/"):
  112. res.RootRefs++
  113. default:
  114. res.RelativeRefs++
  115. }
  116. }
  117. // analyzePage fills the page level findings and returns the script URLs worth scanning
  118. func analyzePage(page string, upstreamHost string, res *ProbeResult) []string {
  119. scripts := []string{}
  120. z := html.NewTokenizer(strings.NewReader(page))
  121. inTitle := false
  122. for {
  123. tt := z.Next()
  124. if tt == html.ErrorToken {
  125. break
  126. }
  127. switch tt {
  128. case html.StartTagToken, html.SelfClosingTagToken:
  129. t := z.Token()
  130. inTitle = t.Data == "title" && tt == html.StartTagToken
  131. for _, a := range t.Attr {
  132. key := strings.ToLower(a.Key)
  133. switch key {
  134. case "src", "href", "action", "poster":
  135. if t.Data == "base" && key == "href" {
  136. if strings.TrimSpace(a.Val) == "/" {
  137. res.BaseHrefRoot = true
  138. }
  139. continue
  140. }
  141. classifyRef(a.Val, upstreamHost, res)
  142. if t.Data == "script" && key == "src" && len(scripts) < maxProbeScripts {
  143. scripts = append(scripts, a.Val)
  144. }
  145. }
  146. }
  147. case html.TextToken:
  148. if inTitle && res.Title == "" {
  149. res.Title = strings.TrimSpace(string(z.Text()))
  150. }
  151. case html.EndTagToken:
  152. inTitle = false
  153. }
  154. }
  155. for _, f := range frameworkHints {
  156. if strings.Contains(page, f.pattern) {
  157. res.Frameworks = append(res.Frameworks, f.name)
  158. }
  159. }
  160. return scripts
  161. }
  162. // decideVerdict turns the findings into a recommendation
  163. func decideVerdict(res *ProbeResult, known *knownApp, prefix string) {
  164. if known != nil {
  165. res.KnownApp = known.name
  166. res.Verdict = known.verdict
  167. res.Reasons = append(res.Reasons, strings.ReplaceAll(known.note, "{prefix}", prefix))
  168. }
  169. if res.FrameBlocked {
  170. res.Reasons = append(res.Reasons, "The app forbids being framed. Enable \"Allow in desktop window\" or open it in a new tab.")
  171. }
  172. if !res.Reachable {
  173. if res.Verdict == "" {
  174. res.Reasons = append(res.Reasons, "The app did not answer, so it could not be analysed.")
  175. }
  176. return
  177. }
  178. if res.Verdict != "" {
  179. return
  180. }
  181. rooted := false
  182. for _, f := range frameworkHints {
  183. for _, found := range res.Frameworks {
  184. if f.rooted && f.name == found {
  185. rooted = true
  186. }
  187. }
  188. }
  189. switch {
  190. case res.BaseHrefRoot || rooted:
  191. res.Verdict = VerdictSubdomain
  192. if res.BaseHrefRoot {
  193. res.Reasons = append(res.Reasons, "The page pins its base URL to \"/\" (single page app router).")
  194. }
  195. if rooted {
  196. res.Reasons = append(res.Reasons, "The framework ("+strings.Join(res.Frameworks, ", ")+") hardcodes its asset root.")
  197. }
  198. res.Reasons = append(res.Reasons, "Subdomain mode is recommended; path mode with rewriting may still work.")
  199. case res.RootRefs == 0 && res.ScriptHints == 0 && res.SelfRefs == 0:
  200. res.Verdict = VerdictPath
  201. res.Reasons = append(res.Reasons, "The page only uses relative URLs, it works under a path as is.")
  202. default:
  203. res.Verdict = VerdictPathRewrite
  204. if res.RootRefs > 0 {
  205. res.Reasons = append(res.Reasons, "The page references resources from \"/\". Enable root path rewriting.")
  206. }
  207. if res.ScriptHints > 0 {
  208. res.Reasons = append(res.Reasons, "Its scripts build URLs from \"/\" at runtime. Enable root path rewriting.")
  209. }
  210. if res.SelfRefs > 0 {
  211. res.Reasons = append(res.Reasons, "The page links to its own internal address.")
  212. }
  213. }
  214. }
  215. // Probe fetches the start page of an upstream and recommends a mode
  216. func (m *Manager) Probe(target string, targetTLS bool, skipVerify bool, image string, slug string) ProbeResult {
  217. res := ProbeResult{Frameworks: []string{}, Reasons: []string{}}
  218. prefix := "/app/" + slug
  219. known := matchKnownApp(image)
  220. if err := validateTarget(target); err != nil {
  221. res.Error = err.Error()
  222. decideVerdict(&res, known, prefix)
  223. return res
  224. }
  225. base := (&Endpoint{Target: target, TargetTLS: targetTLS}).UpstreamURL()
  226. transport := m.transport
  227. if targetTLS && skipVerify {
  228. transport = m.insecureTransport
  229. }
  230. client := &http.Client{
  231. Transport: transport,
  232. Timeout: probeTimeout,
  233. CheckRedirect: func(req *http.Request, via []*http.Request) error {
  234. if len(via) >= 5 || !strings.EqualFold(req.URL.Host, base.Host) {
  235. return http.ErrUseLastResponse
  236. }
  237. return nil
  238. },
  239. }
  240. resp, err := client.Get(base.String() + "/")
  241. if err != nil {
  242. res.Error = err.Error()
  243. decideVerdict(&res, known, prefix)
  244. return res
  245. }
  246. defer resp.Body.Close()
  247. res.Reachable = true
  248. res.Status = resp.StatusCode
  249. if resp.Header.Get("X-Frame-Options") != "" || strings.Contains(strings.ToLower(resp.Header.Get("Content-Security-Policy")), "frame-ancestors") {
  250. res.FrameBlocked = true
  251. }
  252. page, _ := io.ReadAll(io.LimitReader(resp.Body, maxProbePage))
  253. scripts := analyzePage(string(page), base.Host, &res)
  254. pageURL := resp.Request.URL
  255. for _, s := range scripts {
  256. ref, err := url.Parse(strings.TrimSpace(s))
  257. if err != nil {
  258. continue
  259. }
  260. abs := pageURL.ResolveReference(ref)
  261. if !strings.EqualFold(abs.Host, base.Host) {
  262. continue
  263. }
  264. res.ScriptHints += countScriptHints(client, abs.String())
  265. }
  266. decideVerdict(&res, known, prefix)
  267. return res
  268. }
  269. func countScriptHints(client *http.Client, u string) int {
  270. resp, err := client.Get(u)
  271. if err != nil {
  272. return 0
  273. }
  274. defer resp.Body.Close()
  275. if resp.StatusCode != http.StatusOK {
  276. return 0
  277. }
  278. data, _ := io.ReadAll(io.LimitReader(resp.Body, maxProbeScript))
  279. return len(scriptRootHint.FindAllIndex(data, -1))
  280. }