// Package node — /api/update-check endpoint. // // What this does // ────────────── // Polls the configured release source (typically a Gitea `/api/v1/repos/ // {owner}/{repo}/releases/latest` URL) and compares its answer with the // binary's own build-time version. Returns: // // { // "current": { "tag": "v0.5.0", "commit": "abc1234", "date": "..." }, // "latest": { "tag": "v0.5.1", "commit": "def5678", "url": "...", "published_at": "..." }, // "update_available": true, // "checked_at": "2026-04-17T09:45:12Z" // } // // When unconfigured (empty DCHAIN_UPDATE_SOURCE_URL), responds 503 with a // hint pointing at the env var. Never blocks for more than ~5 seconds // (HTTP timeout); upstream failures are logged and surfaced as 502. // // Cache // ───── // Hammering a public Gitea instance every time an operator curls this // endpoint would be rude. We cache the latest-release lookup for 15 minutes // in memory. The update script in deploy/single/update.sh honors this cache // by reading /api/update-check once per run — so a typical hourly timer + // 15-min cache means at most 4 upstream hits per node per hour, usually 1. // // Configuration // ───────────── // DCHAIN_UPDATE_SOURCE_URL — full URL of the Gitea latest-release API. // Example: // https://gitea.example.com/api/v1/repos/dchain/dchain/releases/latest // DCHAIN_UPDATE_SOURCE_TOKEN — optional Gitea PAT for private repos. // // These are read by cmd/node/main.go and handed to SetUpdateSource below. package node import ( "encoding/json" "fmt" "io" "net/http" "sync" "time" "go-blockchain/node/version" ) const ( updateCacheTTL = 15 * time.Minute updateTimeout = 5 * time.Second ) var ( updateMu sync.RWMutex updateSourceURL string updateSourceToken string updateCache *updateCheckResponse updateCacheAt time.Time ) // SetUpdateSource configures where /api/update-check should poll. Called // once at node startup from cmd/node/main.go after flag parsing. Empty url // leaves the endpoint in "unconfigured" mode (503). func SetUpdateSource(url, token string) { updateMu.Lock() defer updateMu.Unlock() updateSourceURL = url updateSourceToken = token // Clear cache on config change (mostly a test-time concern). updateCache = nil updateCacheAt = time.Time{} } // giteaRelease is the subset of the Gitea release JSON we care about. // Gitea's schema is a superset of GitHub's, so the same shape works for // github.com/api/v3 too — operators can point at either. type giteaRelease struct { TagName string `json:"tag_name"` TargetCommit string `json:"target_commitish"` HTMLURL string `json:"html_url"` PublishedAt string `json:"published_at"` Draft bool `json:"draft"` Prerelease bool `json:"prerelease"` } type updateCheckResponse struct { Current map[string]string `json:"current"` Latest *latestRef `json:"latest,omitempty"` UpdateAvailable bool `json:"update_available"` CheckedAt string `json:"checked_at"` Source string `json:"source,omitempty"` } type latestRef struct { Tag string `json:"tag"` Commit string `json:"commit,omitempty"` URL string `json:"url,omitempty"` PublishedAt string `json:"published_at,omitempty"` } func registerUpdateCheckAPI(mux *http.ServeMux, q ExplorerQuery) { mux.HandleFunc("/api/update-check", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonErr(w, fmt.Errorf("method not allowed"), 405) return } updateMu.RLock() src := updateSourceURL tok := updateSourceToken cached := updateCache cachedAt := updateCacheAt updateMu.RUnlock() if src == "" { jsonErr(w, fmt.Errorf("update source not configured — set DCHAIN_UPDATE_SOURCE_URL to a Gitea /api/v1/repos/{owner}/{repo}/releases/latest URL"), http.StatusServiceUnavailable) return } // Serve fresh cache without hitting upstream. if cached != nil && time.Since(cachedAt) < updateCacheTTL { jsonOK(w, cached) return } // Fetch upstream. latest, err := fetchLatestRelease(r.Context(), src, tok) if err != nil { jsonErr(w, fmt.Errorf("upstream check failed: %w", err), http.StatusBadGateway) return } resp := &updateCheckResponse{ Current: version.Info(), CheckedAt: time.Now().UTC().Format(time.RFC3339), Source: src, } if latest != nil { resp.Latest = &latestRef{ Tag: latest.TagName, Commit: latest.TargetCommit, URL: latest.HTMLURL, PublishedAt: latest.PublishedAt, } resp.UpdateAvailable = latest.TagName != "" && latest.TagName != version.Tag && !latest.Draft && !latest.Prerelease } updateMu.Lock() updateCache = resp updateCacheAt = time.Now() updateMu.Unlock() jsonOK(w, resp) }) } // fetchLatestRelease performs the actual HTTP call with a short timeout. // Returns nil, nil when the upstream replies 404 (no releases yet) — this // is not an error condition, the operator just hasn't published anything // yet. All other non-2xx are returned as errors. func fetchLatestRelease(ctx interface{ Deadline() (time.Time, bool) }, url, token string) (*giteaRelease, error) { // We only use ctx for cancellation type-matching; the actual deadline // comes from updateTimeout below. Tests pass a context.Background(). _ = ctx client := &http.Client{Timeout: updateTimeout} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } if token != "" { req.Header.Set("Authorization", "token "+token) } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "dchain-node/"+version.Tag) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, nil // no releases yet } if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(b)) } var rel giteaRelease if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return &rel, nil }