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
This commit is contained in:
2026-10-18 18:08:08 +02:00
co-authored by Claude Opus 5.5
parent f88249427f
commit e5549dddb0
17 changed files with 677 additions and 306 deletions
+9
View File
@@ -22,6 +22,7 @@ Environment only; the server refuses to start when a required value is missing.
| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` |
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
| `FORGE_URL`, `FORGE_REPO`, `FORGE_TOKEN` | optional; app updates (below). The token only needs to read the repository |
| `LISTEN_ADDR` | default `:8080` |
## Endpoints
@@ -43,6 +44,14 @@ with (403 otherwise); the shared token works for any.
A malformed request is a 400 naming the field; a database failure is a 500 without detail.
## App updates
`GET /v1/app/releases` and `GET /v1/app/assets/{release}/{asset}/{name}`, with the car's token:
the repository's releases, read from the forge with `FORGE_TOKEN`, in GitHub's field names
with download URLs pointing back at this server, and their attachments streamed through. The
repository stays private and the forge token never reaches the car; redirects away from the
forge are not followed. The car verifies everything itself (`OTA.md`). 404 when unset.
## Settings from a phone
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
+4
View File
@@ -19,6 +19,7 @@ import (
// GET /v1/consumption/history/{installationId} the car reads its own totals back
// GET /v1/health unauthenticated liveness probe
// /v1/settings/…, /settings settings typed on a phone (handoff_http.go)
// GET /v1/app/… app updates from the forge (releases.go)
//
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
// (pairing_http.go).
@@ -28,6 +29,8 @@ type API struct {
Pairing *Pairing
// Handoff carries settings typed on a phone to the car (handoff.go).
Handoff *Handoff
// Releases relays the app's releases from the private forge to the car (releases.go).
Releases *Releases
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
// derived from the request when empty.
PublicURL string
@@ -46,6 +49,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
a.pairingRoutes(mux)
a.handoffRoutes(mux)
a.releaseRoutes(mux)
return mux
}
+15 -2
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
@@ -30,6 +31,9 @@ type config struct {
ingestToken string
approvalPass string
publicURL string
forgeURL string
forgeRepo string
forgeToken string
}
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
@@ -44,6 +48,9 @@ func loadConfig(getenv func(string) string) (config, error) {
ingestToken: getenv("INGEST_TOKEN"),
approvalPass: getenv("APPROVAL_PASSWORD"),
publicURL: getenv("PUBLIC_URL"),
forgeURL: getenv("FORGE_URL"),
forgeRepo: getenv("FORGE_REPO"),
forgeToken: getenv("FORGE_TOKEN"),
}
if c.listenAddr == "" {
c.listenAddr = ":8080"
@@ -61,6 +68,11 @@ func loadConfig(getenv func(string) string) (config, error) {
}
// Pairing is optional; when it is on, its password guards token issuance and must not
// be guessable.
// App updates are optional; when the forge is named it must be https, so the token
// never crosses the network in the clear.
if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") {
return config{}, errors.New("FORGE_URL must be an https URL")
}
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
}
@@ -87,8 +99,9 @@ func main() {
os.Exit(1)
}
pairing := NewPairing(cfg.approvalPass, devices)
releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken}
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
PublicURL: cfg.publicURL}
Releases: releases, PublicURL: cfg.publicURL}
srv := &http.Server{
Addr: cfg.listenAddr,
@@ -103,7 +116,7 @@ func main() {
defer stop()
go func() {
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
"pairing", pairing.Enabled(), "pairedDevices", len(devices))
"pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled())
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "error", err)
os.Exit(1)
+209
View File
@@ -0,0 +1,209 @@
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
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const testForgeToken = "forge-read-token-0123456789"
// fakeForge answers like Gitea for one pre-release with an APK and its sidecar, and only to
// the right token.
func fakeForge(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
auth := func(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token "+testForgeToken {
http.NotFound(w, r) // a private repository looks absent without a login
return
}
h(w, r)
}
}
mux.HandleFunc("GET /api/v1/repos/nils/mg4/releases", auth(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"id":7,"tag_name":"car-2.16.0","prerelease":true,"draft":false,"assets":[
{"id":11,"name":"NilsMG4-unstable-2.16.0.apk","size":4},
{"id":12,"name":"NilsMG4-unstable-2.16.0.apk.sha256","size":64}]}]`))
}))
mux.HandleFunc("GET /api/v1/repos/nils/mg4/releases/7/assets/11", auth(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"id":11,"name":"NilsMG4-unstable-2.16.0.apk","uuid":"abc-uuid"}`))
}))
mux.HandleFunc("GET /attachments/abc-uuid", auth(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("APK!"))
}))
return httptest.NewTLSServer(mux)
}
func releasesAPI(t *testing.T, forge *httptest.Server) http.Handler {
t.Helper()
api, _ := newTestAPI()
api.Releases = &Releases{ForgeURL: forge.URL, Repo: "nils/mg4", Token: testForgeToken, Client: forge.Client()}
return api.Routes()
}
func TestReleasesAreRelayedWithDownloadsThroughTheServer(t *testing.T) {
forge := fakeForge(t)
defer forge.Close()
h := releasesAPI(t, forge)
if rec := do(t, h, "GET", "/v1/app/releases", "", ""); rec.Code != 401 {
t.Fatalf("without the car's token: %d", rec.Code)
}
rec := do(t, h, "GET", "/v1/app/releases", testToken, "")
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
var got []carRelease
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got) != 1 || !got[0].Prerelease || len(got[0].Assets) != 2 ||
got[0].Assets[0].BrowserDownloadURL != "https://car.example.com/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk" {
t.Fatalf("releases %+v", got)
}
if strings.Contains(rec.Body.String(), testForgeToken) || strings.Contains(rec.Body.String(), forge.URL) {
t.Fatal("the forge or its token leaked to the car")
}
file := do(t, h, "GET", "/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk", testToken, "")
if file.Code != 200 || file.Body.String() != "APK!" {
t.Fatalf("download %d %q", file.Code, file.Body)
}
// The name must be the asset's own: no asking for one file under another's name.
if rec := do(t, h, "GET", "/v1/app/assets/7/11/other.apk", testToken, ""); rec.Code != 404 {
t.Fatalf("wrong name: %d", rec.Code)
}
}
func TestAWrongForgeTokenIsABadGatewayNotALeak(t *testing.T) {
forge := fakeForge(t)
defer forge.Close()
api, _ := newTestAPI()
api.Releases = &Releases{ForgeURL: forge.URL, Repo: "nils/mg4", Token: "wrong", Client: forge.Client()}
if rec := do(t, api.Routes(), "GET", "/v1/app/releases", testToken, ""); rec.Code != 502 {
t.Fatalf("%d", rec.Code)
}
}
func TestAppUpdatesAreOffWithoutAForge(t *testing.T) {
api, _ := newTestAPI()
if rec := do(t, api.Routes(), "GET", "/v1/app/releases", testToken, ""); rec.Code != 404 {
t.Fatalf("%d", rec.Code)
}
}
func TestRedirectsAwayFromTheForgeAreRefused(t *testing.T) {
rel := &Releases{ForgeURL: "https://git.example.com"}
away, _ := http.NewRequest("GET", "https://elsewhere.example.com/x", nil)
if rel.sameHostOnly(away, nil) == nil {
t.Fatal("redirect to another host followed")
}
same, _ := http.NewRequest("GET", "https://git.example.com/attachments/x", nil)
if err := rel.sameHostOnly(same, nil); err != nil {
t.Fatal(err)
}
}
func TestForgeURLMustBeHTTPS(t *testing.T) {
env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
"INGEST_TOKEN": "0123456789abcdef01234567", "FORGE_URL": "http://git.example.com"}
if _, err := loadConfig(func(k string) string { return env[k] }); err == nil {
t.Fatal("plain http forge accepted")
}
}