main.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. // Copyright (C) 2019 The Syncthing Authors.
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this file,
  5. // You can obtain one at https://mozilla.org/MPL/2.0/.
  6. package main
  7. import (
  8. "context"
  9. "encoding/json"
  10. "fmt"
  11. "io"
  12. "log/slog"
  13. "net"
  14. "net/http"
  15. "os"
  16. "regexp"
  17. "sort"
  18. "strconv"
  19. "strings"
  20. "sync"
  21. "time"
  22. "github.com/alecthomas/kong"
  23. "github.com/prometheus/client_golang/prometheus/promhttp"
  24. _ "github.com/syncthing/syncthing/lib/automaxprocs"
  25. "github.com/syncthing/syncthing/lib/httpcache"
  26. "github.com/syncthing/syncthing/lib/upgrade"
  27. )
  28. type cli struct {
  29. Listen string `default:":8080" help:"Listen address"`
  30. MetricsListen string `default:":8082" help:"Listen address for metrics"`
  31. URL string `short:"u" default:"https://api.github.com/repos/syncthing/syncthing/releases?per_page=25" help:"GitHub releases url"`
  32. Forward []string `short:"f" help:"Forwarded pages, format: /path->https://example/com/url"`
  33. CacheTime time.Duration `default:"15m" help:"Cache time"`
  34. }
  35. func main() {
  36. var params cli
  37. kong.Parse(&params)
  38. slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
  39. Level: slog.LevelInfo,
  40. })))
  41. if err := server(&params); err != nil {
  42. fmt.Printf("Error: %v\n", err)
  43. os.Exit(1)
  44. }
  45. }
  46. func server(params *cli) error {
  47. if params.MetricsListen != "" {
  48. mux := http.NewServeMux()
  49. mux.Handle("/metrics", promhttp.Handler())
  50. metricsListen, err := net.Listen("tcp", params.MetricsListen)
  51. if err != nil {
  52. return fmt.Errorf("metrics: %w", err)
  53. }
  54. slog.Info("Metrics listener started", "addr", params.MetricsListen)
  55. go func() {
  56. if err := http.Serve(metricsListen, mux); err != nil {
  57. slog.Warn("Metrics server returned", "error", err)
  58. }
  59. }()
  60. }
  61. cache := &cachedReleases{url: params.URL}
  62. if err := cache.Update(context.Background()); err != nil {
  63. return fmt.Errorf("initial cache update: %w", err)
  64. } else {
  65. slog.Info("Initial cache update done")
  66. }
  67. go func() {
  68. for range time.NewTicker(params.CacheTime).C {
  69. slog.Info("Refreshing cached releases", "url", params.URL)
  70. if err := cache.Update(context.Background()); err != nil {
  71. slog.Error("Failed to refresh cached releases", "url", params.URL, "error", err)
  72. }
  73. }
  74. }()
  75. ghRels := &githubReleases{cache: cache}
  76. mux := http.NewServeMux()
  77. mux.HandleFunc("/ping", ghRels.servePing)
  78. mux.HandleFunc("/meta.json", ghRels.serveReleases)
  79. for _, fwd := range params.Forward {
  80. path, url, ok := strings.Cut(fwd, "->")
  81. if !ok {
  82. return fmt.Errorf("invalid forward: %q", fwd)
  83. }
  84. slog.Info("Forwarding", "from", path, "to", url)
  85. name := strings.ReplaceAll(path, "/", "_")
  86. mux.Handle(path, httpcache.SinglePath(&proxy{name: name, url: url}, params.CacheTime))
  87. }
  88. srv := &http.Server{
  89. Addr: params.Listen,
  90. Handler: mux,
  91. ReadTimeout: 5 * time.Second,
  92. WriteTimeout: 10 * time.Second,
  93. }
  94. srv.SetKeepAlivesEnabled(false)
  95. srvListener, err := net.Listen("tcp", params.Listen)
  96. if err != nil {
  97. return fmt.Errorf("listen: %w", err)
  98. }
  99. slog.Info("Main listener started", "addr", params.Listen)
  100. return srv.Serve(srvListener)
  101. }
  102. type githubReleases struct {
  103. cache *cachedReleases
  104. }
  105. func (p *githubReleases) servePing(w http.ResponseWriter, req *http.Request) {
  106. rels := p.cache.Releases()
  107. if len(rels) == 0 {
  108. http.Error(w, "No releases available", http.StatusServiceUnavailable)
  109. return
  110. }
  111. w.Header().Set("Syncthing-Num-Releases", strconv.Itoa(len(rels)))
  112. w.WriteHeader(http.StatusOK)
  113. }
  114. func (p *githubReleases) serveReleases(w http.ResponseWriter, req *http.Request) {
  115. rels := p.cache.Releases()
  116. ua := req.Header.Get("User-Agent")
  117. osv := req.Header.Get("Syncthing-Os-Version")
  118. if ua != "" && osv != "" {
  119. // We should determine the compatibility of the releases.
  120. rels = filterForCompabitility(rels, ua, osv)
  121. } else {
  122. metricFilterCalls.WithLabelValues("no-ua-or-osversion").Inc()
  123. }
  124. rels = filterForLatest(rels)
  125. w.Header().Set("Content-Type", "application/json; charset=utf-8")
  126. w.Header().Set("Access-Control-Allow-Origin", "*")
  127. w.Header().Set("Access-Control-Allow-Methods", "GET")
  128. w.Header().Set("Cache-Control", "public, max-age=900")
  129. w.Header().Set("Vary", "User-Agent, Syncthing-Os-Version")
  130. _ = json.NewEncoder(w).Encode(rels)
  131. metricUpgradeChecks.Inc()
  132. }
  133. type proxy struct {
  134. name string
  135. url string
  136. }
  137. func (p *proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  138. req, err := http.NewRequestWithContext(req.Context(), http.MethodGet, p.url, nil)
  139. if err != nil {
  140. metricHTTPRequests.WithLabelValues(p.name, "error").Inc()
  141. http.Error(w, err.Error(), http.StatusInternalServerError)
  142. return
  143. }
  144. resp, err := http.DefaultClient.Do(req)
  145. if err != nil {
  146. metricHTTPRequests.WithLabelValues(p.name, "error").Inc()
  147. http.Error(w, err.Error(), http.StatusInternalServerError)
  148. return
  149. }
  150. defer resp.Body.Close()
  151. metricHTTPRequests.WithLabelValues(p.name, "success").Inc()
  152. ct := resp.Header.Get("Content-Type")
  153. w.Header().Set("Content-Type", ct)
  154. if resp.StatusCode == http.StatusOK {
  155. w.Header().Set("Cache-Control", "public, max-age=900")
  156. w.Header().Set("Access-Control-Allow-Origin", "*")
  157. w.Header().Set("Access-Control-Allow-Methods", "GET")
  158. }
  159. w.WriteHeader(resp.StatusCode)
  160. if strings.HasPrefix(ct, "application/json") {
  161. // Special JSON handling; clean it up a bit.
  162. var v interface{}
  163. if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
  164. http.Error(w, err.Error(), http.StatusInternalServerError)
  165. return
  166. }
  167. _ = json.NewEncoder(w).Encode(v)
  168. } else {
  169. _, _ = io.Copy(w, resp.Body)
  170. }
  171. }
  172. // filterForLatest returns the latest stable and prerelease only. If the
  173. // stable version is newer (comes first in the list) there is no need to go
  174. // looking for a prerelease at all.
  175. func filterForLatest(rels []upgrade.Release) []upgrade.Release {
  176. var filtered []upgrade.Release
  177. var havePre bool
  178. for _, rel := range rels {
  179. if !rel.Prerelease {
  180. // We found a stable version, we're good now.
  181. filtered = append(filtered, rel)
  182. break
  183. }
  184. if rel.Prerelease && !havePre {
  185. // We remember the first prerelease we find.
  186. filtered = append(filtered, rel)
  187. havePre = true
  188. }
  189. }
  190. return filtered
  191. }
  192. var userAgentOSArchExp = regexp.MustCompile(`^syncthing.*\(.+ (\w+)-(\w+)\)$`)
  193. func filterForCompabitility(rels []upgrade.Release, ua, osv string) []upgrade.Release {
  194. osArch := userAgentOSArchExp.FindStringSubmatch(ua)
  195. if len(osArch) != 3 {
  196. metricFilterCalls.WithLabelValues("bad-os-arch").Inc()
  197. return rels
  198. }
  199. os := osArch[1]
  200. filtered := rels[:0]
  201. for _, rel := range rels {
  202. if rel.Compatibility == nil {
  203. // No requirements means it's compatible with everything.
  204. filtered = append(filtered, rel)
  205. continue
  206. }
  207. req, ok := rel.Compatibility.Requirements[os]
  208. if !ok {
  209. // No entry for the current OS means it's compatible.
  210. filtered = append(filtered, rel)
  211. continue
  212. }
  213. if upgrade.CompareVersions(osv, req) >= 0 {
  214. filtered = append(filtered, rel)
  215. continue
  216. }
  217. }
  218. if len(filtered) != len(rels) {
  219. metricFilterCalls.WithLabelValues("filtered").Inc()
  220. } else {
  221. metricFilterCalls.WithLabelValues("unchanged").Inc()
  222. }
  223. return filtered
  224. }
  225. type cachedReleases struct {
  226. url string
  227. mut sync.RWMutex
  228. current []upgrade.Release
  229. }
  230. func (c *cachedReleases) Releases() []upgrade.Release {
  231. c.mut.RLock()
  232. defer c.mut.RUnlock()
  233. return c.current
  234. }
  235. func (c *cachedReleases) Update(ctx context.Context) error {
  236. rels, err := fetchGithubReleases(ctx, c.url)
  237. if err != nil {
  238. return err
  239. }
  240. c.mut.Lock()
  241. c.current = rels
  242. c.mut.Unlock()
  243. return nil
  244. }
  245. func fetchGithubReleases(ctx context.Context, url string) ([]upgrade.Release, error) {
  246. req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil)
  247. if err != nil {
  248. metricHTTPRequests.WithLabelValues("github-releases", "error").Inc()
  249. return nil, err
  250. }
  251. resp, err := http.DefaultClient.Do(req)
  252. if err != nil {
  253. metricHTTPRequests.WithLabelValues("github-releases", "error").Inc()
  254. return nil, err
  255. }
  256. defer resp.Body.Close()
  257. var rels []upgrade.Release
  258. if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil {
  259. metricHTTPRequests.WithLabelValues("github-releases", "error").Inc()
  260. return nil, err
  261. }
  262. metricHTTPRequests.WithLabelValues("github-releases", "success").Inc()
  263. // Move the URL used for browser downloads to the URL field, and remove
  264. // the browser URL field. This avoids going via the GitHub API for
  265. // downloads, since Syncthing uses the URL field.
  266. for _, rel := range rels {
  267. for j, asset := range rel.Assets {
  268. rel.Assets[j].URL = asset.BrowserURL
  269. rel.Assets[j].BrowserURL = ""
  270. }
  271. }
  272. addReleaseCompatibility(ctx, rels)
  273. sort.Sort(upgrade.SortByRelease(rels))
  274. return rels, nil
  275. }
  276. func addReleaseCompatibility(ctx context.Context, rels []upgrade.Release) {
  277. for i := range rels {
  278. rel := &rels[i]
  279. for i, asset := range rel.Assets {
  280. if asset.Name != "compat.json" {
  281. continue
  282. }
  283. // Load compat.json into the Compatibility field
  284. req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil)
  285. if err != nil {
  286. metricHTTPRequests.WithLabelValues("compat-json", "error").Inc()
  287. break
  288. }
  289. resp, err := http.DefaultClient.Do(req)
  290. if err != nil {
  291. metricHTTPRequests.WithLabelValues("compat-json", "error").Inc()
  292. break
  293. }
  294. if resp.StatusCode != http.StatusOK {
  295. metricHTTPRequests.WithLabelValues("compat-json", "error").Inc()
  296. resp.Body.Close()
  297. break
  298. }
  299. _ = json.NewDecoder(io.LimitReader(resp.Body, 10<<10)).Decode(&rel.Compatibility)
  300. metricHTTPRequests.WithLabelValues("compat-json", "success").Inc()
  301. resp.Body.Close()
  302. // Remove compat.json from the asset list since it's been processed
  303. rel.Assets = append(rel.Assets[:i], rel.Assets[i+1:]...)
  304. break
  305. }
  306. }
  307. }