Files
nilsandClaude Opus 5.5 e5549dddb0 App 2.16.0, server 2.6.0: over-the-air updates from our own repository
The car updates itself again, from this fork's own releases, while the
repository stays private:

- publish-car (flake) creates the pre-release car-<version> on the
  forge and attaches the APK and its .sha256. It needs a forge token
  that may write releases, from ~/.config/nilsmg4/forge-token or
  $FORGE_TOKEN; it is sent in a header and never printed.
- The server relays releases and attachments (/v1/app/releases,
  /v1/app/assets/...), read with a read-only FORGE_TOKEN from its
  Secret, GitHub-shaped with download URLs back at itself. Redirects
  away from the forge are not followed; off (404) without a token.
- The app asks its server with its own paired token, allows exactly
  the server's host, downloads the APK itself instead of through the
  DownloadManager (so the token stays in the app), verifies SHA-256 and
  package name, and tries a PackageInstaller session (it may install
  silently as the system user) before falling back to Downloads.

OTA.md is rewritten for this flow; the k8s and server READMEs explain
the new token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 18:08:08 +02:00

210 lines
7.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
}
func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error {
if a.releasesOff(w) {
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", "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))
for _, rel := range releases {
cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}}
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)
}
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, _ 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", "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", "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))
}
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes)); err != nil {
slog.Warn("asset transfer cut short", "asset", asset.Name, "error", err)
}
return nil
}