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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user