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
+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)