|
@@ -7,15 +7,19 @@
|
|
|
package main
|
|
|
|
|
|
import (
|
|
|
- "bytes"
|
|
|
+ "context"
|
|
|
"encoding/json"
|
|
|
"fmt"
|
|
|
"io"
|
|
|
- "log"
|
|
|
+ "log/slog"
|
|
|
+ "net"
|
|
|
"net/http"
|
|
|
"os"
|
|
|
+ "regexp"
|
|
|
"sort"
|
|
|
+ "strconv"
|
|
|
"strings"
|
|
|
+ "sync"
|
|
|
"time"
|
|
|
|
|
|
"github.com/alecthomas/kong"
|
|
@@ -27,7 +31,7 @@ import (
|
|
|
|
|
|
type cli struct {
|
|
|
Listen string `default:":8080" help:"Listen address"`
|
|
|
- MetricsListen string `default:":8081" help:"Listen address for metrics"`
|
|
|
+ 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"`
|
|
@@ -36,6 +40,10 @@ type cli struct {
|
|
|
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)
|
|
@@ -46,24 +54,47 @@ 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() {
|
|
|
- log.Println("Listening for metrics on", params.MetricsListen)
|
|
|
- if err := http.ListenAndServe(params.MetricsListen, mux); err != nil {
|
|
|
- log.Fatalf("Failed to start metrics server: %v", err)
|
|
|
+ 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.Handle("/meta.json", httpcache.SinglePath(&githubReleases{url: params.URL}, params.CacheTime))
|
|
|
+ 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)
|
|
|
}
|
|
|
- log.Println("Forwarding", path, "to", url)
|
|
|
- mux.Handle(path, httpcache.SinglePath(&proxy{url: url}, params.CacheTime))
|
|
|
+ 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{
|
|
@@ -73,60 +104,76 @@ func server(params *cli) error {
|
|
|
WriteTimeout: 10 * time.Second,
|
|
|
}
|
|
|
srv.SetKeepAlivesEnabled(false)
|
|
|
- return srv.ListenAndServe()
|
|
|
+
|
|
|
+ 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 {
|
|
|
- url string
|
|
|
+ cache *cachedReleases
|
|
|
}
|
|
|
|
|
|
-func (p *githubReleases) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
|
|
- log.Println("Fetching", p.url)
|
|
|
- rels := upgrade.FetchLatestReleases(p.url, "")
|
|
|
- if rels == nil {
|
|
|
- http.Error(w, "no releases", http.StatusInternalServerError)
|
|
|
+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
|
|
|
}
|
|
|
|
|
|
- sort.Sort(upgrade.SortByRelease(rels))
|
|
|
- rels = filterForLatest(rels)
|
|
|
+ w.Header().Set("Syncthing-Num-Releases", strconv.Itoa(len(rels)))
|
|
|
+ w.WriteHeader(http.StatusOK)
|
|
|
+}
|
|
|
|
|
|
- // 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 = ""
|
|
|
- }
|
|
|
+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()
|
|
|
}
|
|
|
|
|
|
- buf := new(bytes.Buffer)
|
|
|
- _ = json.NewEncoder(buf).Encode(rels)
|
|
|
+ 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.Write(buf.Bytes())
|
|
|
+ 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 {
|
|
|
- url string
|
|
|
+ name string
|
|
|
+ url string
|
|
|
}
|
|
|
|
|
|
func (p *proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
|
- log.Println("Fetching", p.url)
|
|
|
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)
|
|
@@ -169,3 +216,136 @@ func filterForLatest(rels []upgrade.Release) []upgrade.Release {
|
|
|
}
|
|
|
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]
|
|
|
+
|
|
|
+ filtered := rels[:0]
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|