// Copyright (C) 2019 The Syncthing Authors. // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, // You can obtain one at https://mozilla.org/MPL/2.0/. package main import ( "context" "encoding/json" "fmt" "io" "log/slog" "net" "net/http" "os" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/alecthomas/kong" "github.com/prometheus/client_golang/prometheus/promhttp" _ "github.com/syncthing/syncthing/lib/automaxprocs" "github.com/syncthing/syncthing/lib/httpcache" "github.com/syncthing/syncthing/lib/upgrade" ) type cli struct { Listen string `default:":8080" help:"Listen address"` MetricsListen string `default:":8082" help:"Listen address for metrics"` URL string `short:"u" default:"https://api.github.com/repos/syncthing/syncthing/releases?per_page=25" help:"GitHub releases url"` Forward []string `short:"f" help:"Forwarded pages, format: /path->https://example/com/url"` CacheTime time.Duration `default:"15m" help:"Cache time"` } func main() { var params cli kong.Parse(¶ms) slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, }))) if err := server(¶ms); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } } func server(params *cli) error { if params.MetricsListen != "" { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) metricsListen, err := net.Listen("tcp", params.MetricsListen) if err != nil { return fmt.Errorf("metrics: %w", err) } slog.Info("Metrics listener started", "addr", params.MetricsListen) go func() { if err := http.Serve(metricsListen, mux); err != nil { slog.Warn("Metrics server returned", "error", err) } }() } cache := &cachedReleases{url: params.URL} if err := cache.Update(context.Background()); err != nil { return fmt.Errorf("initial cache update: %w", err) } else { slog.Info("Initial cache update done") } go func() { for range time.NewTicker(params.CacheTime).C { slog.Info("Refreshing cached releases", "url", params.URL) if err := cache.Update(context.Background()); err != nil { slog.Error("Failed to refresh cached releases", "url", params.URL, "error", err) } } }() ghRels := &githubReleases{cache: cache} mux := http.NewServeMux() mux.HandleFunc("/ping", ghRels.servePing) mux.HandleFunc("/meta.json", ghRels.serveReleases) for _, fwd := range params.Forward { path, url, ok := strings.Cut(fwd, "->") if !ok { return fmt.Errorf("invalid forward: %q", fwd) } slog.Info("Forwarding", "from", path, "to", url) name := strings.ReplaceAll(path, "/", "_") mux.Handle(path, httpcache.SinglePath(&proxy{name: name, url: url}, params.CacheTime)) } srv := &http.Server{ Addr: params.Listen, Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, } srv.SetKeepAlivesEnabled(false) srvListener, err := net.Listen("tcp", params.Listen) if err != nil { return fmt.Errorf("listen: %w", err) } slog.Info("Main listener started", "addr", params.Listen) return srv.Serve(srvListener) } type githubReleases struct { cache *cachedReleases } func (p *githubReleases) servePing(w http.ResponseWriter, req *http.Request) { rels := p.cache.Releases() if len(rels) == 0 { http.Error(w, "No releases available", http.StatusServiceUnavailable) return } w.Header().Set("Syncthing-Num-Releases", strconv.Itoa(len(rels))) w.WriteHeader(http.StatusOK) } func (p *githubReleases) serveReleases(w http.ResponseWriter, req *http.Request) { rels := p.cache.Releases() ua := req.Header.Get("User-Agent") osv := req.Header.Get("Syncthing-Os-Version") if ua != "" && osv != "" { // We should determine the compatibility of the releases. rels = filterForCompabitility(rels, ua, osv) } else { metricFilterCalls.WithLabelValues("no-ua-or-osversion").Inc() } rels = filterForLatest(rels) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET") w.Header().Set("Cache-Control", "public, max-age=900") w.Header().Set("Vary", "User-Agent, Syncthing-Os-Version") _ = json.NewEncoder(w).Encode(rels) metricUpgradeChecks.Inc() } type proxy struct { name string url string } func (p *proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { req, err := http.NewRequestWithContext(req.Context(), http.MethodGet, p.url, nil) if err != nil { metricHTTPRequests.WithLabelValues(p.name, "error").Inc() http.Error(w, err.Error(), http.StatusInternalServerError) return } resp, err := http.DefaultClient.Do(req) if err != nil { metricHTTPRequests.WithLabelValues(p.name, "error").Inc() http.Error(w, err.Error(), http.StatusInternalServerError) return } defer resp.Body.Close() metricHTTPRequests.WithLabelValues(p.name, "success").Inc() ct := resp.Header.Get("Content-Type") w.Header().Set("Content-Type", ct) if resp.StatusCode == http.StatusOK { w.Header().Set("Cache-Control", "public, max-age=900") w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET") } w.WriteHeader(resp.StatusCode) if strings.HasPrefix(ct, "application/json") { // Special JSON handling; clean it up a bit. var v interface{} if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(v) } else { _, _ = io.Copy(w, resp.Body) } } // filterForLatest returns the latest stable and prerelease only. If the // stable version is newer (comes first in the list) there is no need to go // looking for a prerelease at all. func filterForLatest(rels []upgrade.Release) []upgrade.Release { var filtered []upgrade.Release var havePre bool for _, rel := range rels { if !rel.Prerelease { // We found a stable version, we're good now. filtered = append(filtered, rel) break } if rel.Prerelease && !havePre { // We remember the first prerelease we find. filtered = append(filtered, rel) havePre = true } } return filtered } var userAgentOSArchExp = regexp.MustCompile(`^syncthing.*\(.+ (\w+)-(\w+)\)$`) func filterForCompabitility(rels []upgrade.Release, ua, osv string) []upgrade.Release { osArch := userAgentOSArchExp.FindStringSubmatch(ua) if len(osArch) != 3 { metricFilterCalls.WithLabelValues("bad-os-arch").Inc() return rels } os := osArch[1] var filtered []upgrade.Release for _, rel := range rels { if rel.Compatibility == nil { // No requirements means it's compatible with everything. filtered = append(filtered, rel) continue } req, ok := rel.Compatibility.Requirements[os] if !ok { // No entry for the current OS means it's compatible. filtered = append(filtered, rel) continue } if upgrade.CompareVersions(osv, req) >= 0 { filtered = append(filtered, rel) continue } } if len(filtered) != len(rels) { metricFilterCalls.WithLabelValues("filtered").Inc() } else { metricFilterCalls.WithLabelValues("unchanged").Inc() } return filtered } type cachedReleases struct { url string mut sync.RWMutex current []upgrade.Release } func (c *cachedReleases) Releases() []upgrade.Release { c.mut.RLock() defer c.mut.RUnlock() return c.current } func (c *cachedReleases) Update(ctx context.Context) error { rels, err := fetchGithubReleases(ctx, c.url) if err != nil { return err } c.mut.Lock() c.current = rels c.mut.Unlock() return nil } func fetchGithubReleases(ctx context.Context, url string) ([]upgrade.Release, error) { req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil) if err != nil { metricHTTPRequests.WithLabelValues("github-releases", "error").Inc() return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { metricHTTPRequests.WithLabelValues("github-releases", "error").Inc() return nil, err } defer resp.Body.Close() var rels []upgrade.Release if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil { metricHTTPRequests.WithLabelValues("github-releases", "error").Inc() return nil, err } metricHTTPRequests.WithLabelValues("github-releases", "success").Inc() // Move the URL used for browser downloads to the URL field, and remove // the browser URL field. This avoids going via the GitHub API for // downloads, since Syncthing uses the URL field. for _, rel := range rels { for j, asset := range rel.Assets { rel.Assets[j].URL = asset.BrowserURL rel.Assets[j].BrowserURL = "" } } addReleaseCompatibility(ctx, rels) sort.Sort(upgrade.SortByRelease(rels)) return rels, nil } func addReleaseCompatibility(ctx context.Context, rels []upgrade.Release) { for i := range rels { rel := &rels[i] for i, asset := range rel.Assets { if asset.Name != "compat.json" { continue } // Load compat.json into the Compatibility field req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil) if err != nil { metricHTTPRequests.WithLabelValues("compat-json", "error").Inc() break } resp, err := http.DefaultClient.Do(req) if err != nil { metricHTTPRequests.WithLabelValues("compat-json", "error").Inc() break } if resp.StatusCode != http.StatusOK { metricHTTPRequests.WithLabelValues("compat-json", "error").Inc() resp.Body.Close() break } _ = json.NewDecoder(io.LimitReader(resp.Body, 10<<10)).Decode(&rel.Compatibility) metricHTTPRequests.WithLabelValues("compat-json", "success").Inc() resp.Body.Close() // Remove compat.json from the asset list since it's been processed rel.Assets = append(rel.Assets[:i], rel.Assets[i+1:]...) break } } }