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
233 lines
8.2 KiB
Go
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
|
|
}
|