main.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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/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", slogutil.Address(params.MetricsListen))
  55. go func() {
  56. if err := http.Serve(metricsListen, mux); err != nil {
  57. slog.Warn("Metrics server returned", slogutil.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", slogutil.URI(params.URL))
  70. if err := cache.Update(context.Background()); err != nil {
  71. slog.Error("Failed to refresh cached releases", slogutil.URI(params.URL), slogutil.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", slogutil.Address(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 = filterForCompatibility(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. havePre := make(map[string]bool)
  178. haveStable := make(map[string]bool)
  179. for _, rel := range rels {
  180. major, _, _ := strings.Cut(rel.Tag, ".")
  181. if !rel.Prerelease && !haveStable[major] {
  182. // Remember the first non-pre for each major
  183. filtered = append(filtered, rel)
  184. haveStable[major] = true
  185. continue
  186. }
  187. if rel.Prerelease && !havePre[major] && !haveStable[major] {
  188. // We remember the first prerelease we find, unless we've
  189. // already found a non-pre of the same major.
  190. filtered = append(filtered, rel)
  191. havePre[major] = true
  192. }
  193. }
  194. return filtered
  195. }
  196. var userAgentOSArchExp = regexp.MustCompile(`^syncthing.*\(.+ (\w+)-(\w+)\)$`)
  197. func filterForCompatibility(rels []upgrade.Release, ua, osv string) []upgrade.Release {
  198. osArch := userAgentOSArchExp.FindStringSubmatch(ua)
  199. if len(osArch) != 3 {
  200. metricFilterCalls.WithLabelValues("bad-os-arch").Inc()
  201. return rels
  202. }
  203. os := osArch[1]
  204. var filtered []upgrade.Release
  205. for _, rel := range rels {
  206. if rel.Compatibility == nil {
  207. // No requirements means it's compatible with everything.
  208. filtered = append(filtered, rel)
  209. continue
  210. }
  211. req, ok := rel.Compatibility.Requirements[os]
  212. if !ok {
  213. // No entry for the current OS means it's compatible.
  214. filtered = append(filtered, rel)
  215. continue
  216. }
  217. if upgrade.CompareVersions(osv, req) >= 0 {
  218. filtered = append(filtered, rel)
  219. continue
  220. }
  221. }
  222. if len(filtered) != len(rels) {
  223. metricFilterCalls.WithLabelValues("filtered").Inc()
  224. } else {
  225. metricFilterCalls.WithLabelValues("unchanged").Inc()
  226. }
  227. return filtered
  228. }
  229. type cachedReleases struct {
  230. url string
  231. mut sync.RWMutex
  232. current []upgrade.Release
  233. latestRel, latestPre string
  234. }
  235. func (c *cachedReleases) Releases() []upgrade.Release {
  236. c.mut.RLock()
  237. defer c.mut.RUnlock()
  238. return c.current
  239. }
  240. func (c *cachedReleases) Update(ctx context.Context) error {
  241. rels, err := fetchGithubReleases(ctx, c.url)
  242. if err != nil {
  243. return err
  244. }
  245. latestRel, latestPre := "", ""
  246. for _, rel := range rels {
  247. if !rel.Prerelease && latestRel == "" {
  248. latestRel = rel.Tag
  249. }
  250. if rel.Prerelease && latestPre == "" {
  251. latestPre = rel.Tag
  252. }
  253. if latestRel != "" && latestPre != "" {
  254. break
  255. }
  256. }
  257. c.mut.Lock()
  258. c.current = rels
  259. if latestRel != c.latestRel || latestPre != c.latestPre {
  260. metricLatestReleaseInfo.DeleteLabelValues(c.latestRel, c.latestPre)
  261. metricLatestReleaseInfo.WithLabelValues(latestRel, latestPre).Set(1)
  262. c.latestRel = latestRel
  263. c.latestPre = latestPre
  264. }
  265. c.mut.Unlock()
  266. return nil
  267. }
  268. func fetchGithubReleases(ctx context.Context, url string) ([]upgrade.Release, error) {
  269. req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil)
  270. if err != nil {
  271. metricHTTPRequests.WithLabelValues("github-releases", "error").Inc()
  272. return nil, err
  273. }
  274. resp, err := http.DefaultClient.Do(req)
  275. if err != nil {
  276. metricHTTPRequests.WithLabelValues("github-releases", "error").Inc()
  277. return nil, err
  278. }
  279. defer resp.Body.Close()
  280. var rels []upgrade.Release
  281. if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil {
  282. metricHTTPRequests.WithLabelValues("github-releases", "error").Inc()
  283. return nil, err
  284. }
  285. metricHTTPRequests.WithLabelValues("github-releases", "success").Inc()
  286. // Move the URL used for browser downloads to the URL field, and remove
  287. // the browser URL field. This avoids going via the GitHub API for
  288. // downloads, since Syncthing uses the URL field.
  289. for _, rel := range rels {
  290. for j, asset := range rel.Assets {
  291. rel.Assets[j].URL = asset.BrowserURL
  292. rel.Assets[j].BrowserURL = ""
  293. }
  294. }
  295. addReleaseCompatibility(ctx, rels)
  296. sort.Sort(upgrade.SortByRelease(rels))
  297. return rels, nil
  298. }
  299. func addReleaseCompatibility(ctx context.Context, rels []upgrade.Release) {
  300. for i := range rels {
  301. rel := &rels[i]
  302. for i, asset := range rel.Assets {
  303. if asset.Name != "compat.json" {
  304. continue
  305. }
  306. // Load compat.json into the Compatibility field
  307. req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil)
  308. if err != nil {
  309. metricHTTPRequests.WithLabelValues("compat-json", "error").Inc()
  310. break
  311. }
  312. resp, err := http.DefaultClient.Do(req)
  313. if err != nil {
  314. metricHTTPRequests.WithLabelValues("compat-json", "error").Inc()
  315. break
  316. }
  317. if resp.StatusCode != http.StatusOK {
  318. metricHTTPRequests.WithLabelValues("compat-json", "error").Inc()
  319. resp.Body.Close()
  320. break
  321. }
  322. _ = json.NewDecoder(io.LimitReader(resp.Body, 10<<10)).Decode(&rel.Compatibility)
  323. metricHTTPRequests.WithLabelValues("compat-json", "success").Inc()
  324. resp.Body.Close()
  325. // Remove compat.json from the asset list since it's been processed
  326. rel.Assets = append(rel.Assets[:i], rel.Assets[i+1:]...)
  327. break
  328. }
  329. }
  330. }