Files
mg4/server/releases.go
T
nilsandClaude Opus 5.5 acb7e1969e Server 2.6.2: log update checks and downloads; Go 1.27.3
The update relay logs every check (car, number of releases, newest
pre-release) and every download (car, file, size, then bytes and time,
or that it was cut short); never the forge's address or token, tested.
Go 1.27.3 for the build image and toolchain: govulncheck had found
GO-2026-6809 in crypto/x509 under 1.26.9; source and binary now clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:40:23 +01:00

233 lines
8.2 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Releases relays the app's own releases from the private forge repository to the car, for
// the in-app updater. The repository stays private and the forge token stays here: the car
// asks this server with its own token and never holds a forge credential.
//
// GET /v1/app/releases the repo's releases, GitHub-shaped
// GET /v1/app/assets/{release}/{asset}/{name} one attachment, streamed through
//
// The car still checks everything itself (SHA-256 sidecar, package name, signature by the
// installer); this only carries bytes. Off unless FORGE_URL, FORGE_REPO and FORGE_TOKEN are set.
type Releases struct {
ForgeURL string // e.g. https://git.stinnesbeck.com
Repo string // owner/name
Token string // read-only on the repository
Client *http.Client
}
const (
maxReleaseListBytes = 1 << 20
maxAssetBytes = 64 << 20
// An APK over a slow mobile link takes longer than the server's general write timeout.
assetWriteTimeout = 10 * time.Minute
)
func (rel *Releases) enabled() bool {
return rel != nil && rel.ForgeURL != "" && rel.Repo != "" && rel.Token != ""
}
func (rel *Releases) client() *http.Client {
if rel.Client != nil {
return rel.Client
}
return &http.Client{Timeout: 5 * time.Minute, CheckRedirect: rel.sameHostOnly}
}
// sameHostOnly keeps the token on the forge: a redirect elsewhere is refused, not followed
// with the Authorization header.
func (rel *Releases) sameHostOnly(req *http.Request, via []*http.Request) error {
forge, err := url.Parse(rel.ForgeURL)
if err != nil || req.URL.Host != forge.Host || req.URL.Scheme != forge.Scheme {
return errors.New("redirect away from the forge refused")
}
if len(via) > 5 {
return errors.New("too many redirects")
}
return nil
}
func (rel *Releases) get(ctx context.Context, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(rel.ForgeURL, "/")+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "token "+rel.Token)
req.Header.Set("Accept", "application/json")
return rel.client().Do(req)
}
type forgeAsset struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
}
type forgeRelease struct {
ID int64 `json:"id"`
TagName string `json:"tag_name"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
Assets []forgeAsset `json:"assets"`
}
// carRelease is what the car's updater reads: GitHub's field names, with download URLs that
// point back at this server.
type carRelease struct {
TagName string `json:"tag_name"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
Assets []carAsset `json:"assets"`
}
type carAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func (a *API) releaseRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/app/releases", a.ingest(a.appReleases))
mux.HandleFunc("GET /v1/app/assets/{release}/{asset}/{name}", a.ingest(a.appAsset))
}
func (a *API) releasesOff(w http.ResponseWriter) bool {
if a.Releases.enabled() {
return false
}
writeJSON(w, http.StatusNotFound, map[string]string{"error": "app updates are not enabled"})
return true
}
// who names the car in the update log: its installation ID, or the shared ingest token.
func who(c caller) string {
if c.installationID == "" {
return "shared token"
}
return c.installationID
}
func (a *API) appReleases(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
if a.releasesOff(w) {
slog.Info("update check while app updates are off", "installation", who(c))
return nil
}
resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20")
if err != nil {
return fmt.Errorf("forge releases: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Warn("forge refused the release list", "installation", who(c), "status", resp.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil
}
var releases []forgeRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseListBytes)).Decode(&releases); err != nil {
return fmt.Errorf("forge releases: %w", err)
}
base := a.publicURL(r) + "/v1/app/assets/"
out := make([]carRelease, 0, len(releases))
newest := "none"
for _, rel := range releases {
cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}}
// The forge lists the newest first; the car takes pre-releases only.
if newest == "none" && rel.Prerelease && !rel.Draft {
newest = rel.TagName
}
for _, as := range rel.Assets {
cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size,
BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) +
"/" + url.PathEscape(as.Name)})
}
out = append(out, cr)
}
slog.Info("update check", "installation", who(c), "releases", len(out), "newest", newest)
writeJSON(w, http.StatusOK, out)
return nil
}
// appAsset streams one release attachment. The forge's own metadata names the file, so a car
// cannot ask for anything but an attachment of this repository's releases.
func (a *API) appAsset(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
if a.releasesOff(w) {
return nil
}
releaseID, err1 := strconv.ParseInt(r.PathValue("release"), 10, 64)
assetID, err2 := strconv.ParseInt(r.PathValue("asset"), 10, 64)
if err1 != nil || err2 != nil || releaseID <= 0 || assetID <= 0 {
return invalid("release or asset is invalid")
}
meta, err := a.Releases.get(r.Context(), fmt.Sprintf("/api/v1/repos/%s/releases/%d/assets/%d",
a.Releases.Repo, releaseID, assetID))
if err != nil {
return fmt.Errorf("forge asset: %w", err)
}
var asset struct {
Name string `json:"name"`
UUID string `json:"uuid"`
}
decodeErr := json.NewDecoder(io.LimitReader(meta.Body, 64<<10)).Decode(&asset)
meta.Body.Close()
if meta.StatusCode == http.StatusNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such asset"})
return nil
}
if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" {
slog.Warn("forge refused the asset", "installation", who(c), "status", meta.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil
}
if asset.Name != r.PathValue("name") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such asset"})
return nil
}
file, err := a.Releases.get(r.Context(), "/attachments/"+url.PathEscape(asset.UUID))
if err != nil {
return fmt.Errorf("forge download: %w", err)
}
defer file.Body.Close()
if file.StatusCode != http.StatusOK {
slog.Warn("forge refused the download", "installation", who(c), "asset", asset.Name, "status", file.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil
}
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(assetWriteTimeout)); err != nil {
slog.Warn("asset write deadline not extended", "error", err)
}
h := w.Header()
h.Set("Content-Type", "application/octet-stream")
h.Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(asset.Name, `"`, "")+`"`)
h.Set("Cache-Control", "no-store")
if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes {
h.Set("Content-Length", strconv.FormatInt(file.ContentLength, 10))
}
slog.Info("update download started", "installation", who(c), "asset", asset.Name,
"bytes", file.ContentLength)
started := time.Now()
w.WriteHeader(http.StatusOK)
sent, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes))
took := time.Since(started).Round(time.Millisecond)
if err != nil {
slog.Warn("update download cut short", "installation", who(c), "asset", asset.Name,
"sent", sent, "took", took, "error", err)
return nil
}
slog.Info("update handed out", "installation", who(c), "asset", asset.Name, "sent", sent, "took", took)
return nil
}