main.go 11 KB

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