Server 2.6.2: log update checks and downloads; Go 1.27.3

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
This commit is contained in:
2026-10-25 22:40:23 +01:00
co-authored by Claude Opus 5.5
parent 76a6213126
commit acb7e1969e
7 changed files with 81 additions and 10 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ MG4 head unit (Android 9, AAOS, MT2712)
- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the - **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the
`EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the `EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the
server and to ABRP, and updates itself. server and to ABRP, and updates itself.
- **server/** — Go 1.26 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption - **server/** — Go 1.27 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`. app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`.
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned - **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
+8
View File
@@ -138,6 +138,14 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-25 — Server 2.6.2: **the update relay logs what it hands out**, per car (installation
ID, or "shared token"): `update check` with the number of releases and the newest
pre-release, `update download started` with file and size, then `update handed out` with
bytes and time, or `update download cut short`; refusals by the forge name the car too. No
forge address or token in the log (tested: `TestUpdatesAreLogged`). Go 1.27.3 (build image
and `go.mod` toolchain), up from 1.26.9: `govulncheck` had found GO-2026-6809 (compromised
intermediate certificates, `crypto/x509`, reached through the server's TLS); now no
vulnerabilities, in the source and in the built binary. go vet, tests, podman build.
- 2026-10-25 — `k8s/influxdb.yaml`: the values Kubernetes fills in itself are written out — the - 2026-10-25 — `k8s/influxdb.yaml`: the values Kubernetes fills in itself are written out — the
NetworkPolicy port's `protocol: TCP`, and `apiVersion`, `kind` and `volumeMode: Filesystem` on NetworkPolicy port's `protocol: TCP`, and `apiVersion`, `kind` and `volumeMode: Filesystem` on
the StatefulSet's volume claim template. Without them every `kubectl apply -k k8s/` reported the StatefulSet's volume claim template. Without them every `kubectl apply -k k8s/` reported
+1 -1
View File
@@ -1,7 +1,7 @@
# The car server: one static Go binary on distroless, running as non-root. # The car server: one static Go binary on distroless, running as non-root.
# Configuration comes from the environment at runtime; nothing secret is baked in. # Configuration comes from the environment at runtime; nothing secret is baked in.
FROM docker.io/library/golang:1.26.9-alpine AS build FROM docker.io/library/golang:1.27.3-alpine AS build
WORKDIR /src WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath GOTOOLCHAIN=local ENV CGO_ENABLED=0 GOFLAGS=-trimpath GOTOOLCHAIN=local
COPY go.mod go.sum ./ COPY go.mod go.sum ./
+5
View File
@@ -52,6 +52,11 @@ with download URLs pointing back at this server, and their attachments streamed
repository stays private and the forge token never reaches the car; redirects away from 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. forge are not followed. The car verifies everything itself (`OTA.md`). 404 when unset.
Each check and each download is logged with the car's installation ID ("shared token" for the
shared ingest token): `update check` (how many releases, the newest pre-release), `update
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
download cut short`. The forge's address and token never appear in the log.
## Settings from a phone ## Settings from a phone
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token) The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
+1 -1
View File
@@ -2,7 +2,7 @@ module git.stinnesbeck.com/nils/mg4/server
go 1.26.0 go 1.26.0
toolchain go1.26.9 toolchain go1.27.3
require github.com/influxdata/influxdb-client-go/v2 v2.14.0 require github.com/influxdata/influxdb-client-go/v2 v2.14.0
+30 -7
View File
@@ -113,8 +113,17 @@ func (a *API) releasesOff(w http.ResponseWriter) bool {
return true return true
} }
func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { // 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) { if a.releasesOff(w) {
slog.Info("update check while app updates are off", "installation", who(c))
return nil return nil
} }
resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20") resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20")
@@ -123,7 +132,7 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
slog.Warn("forge refused the release list", "status", resp.StatusCode) 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"}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil return nil
} }
@@ -133,8 +142,13 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
} }
base := a.publicURL(r) + "/v1/app/assets/" base := a.publicURL(r) + "/v1/app/assets/"
out := make([]carRelease, 0, len(releases)) out := make([]carRelease, 0, len(releases))
newest := "none"
for _, rel := range releases { for _, rel := range releases {
cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}} 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 { for _, as := range rel.Assets {
cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size, cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size,
BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) + BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) +
@@ -142,13 +156,14 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
} }
out = append(out, cr) out = append(out, cr)
} }
slog.Info("update check", "installation", who(c), "releases", len(out), "newest", newest)
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil return nil
} }
// appAsset streams one release attachment. The forge's own metadata names the file, so a car // 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. // 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 { func (a *API) appAsset(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
if a.releasesOff(w) { if a.releasesOff(w) {
return nil return nil
} }
@@ -173,7 +188,7 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
return nil return nil
} }
if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" { if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" {
slog.Warn("forge refused the asset", "status", meta.StatusCode) 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"}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil return nil
} }
@@ -187,7 +202,7 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
} }
defer file.Body.Close() defer file.Body.Close()
if file.StatusCode != http.StatusOK { if file.StatusCode != http.StatusOK {
slog.Warn("forge refused the download", "status", file.StatusCode) 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"}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil return nil
} }
@@ -201,9 +216,17 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes { if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes {
h.Set("Content-Length", strconv.FormatInt(file.ContentLength, 10)) 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) w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes)); err != nil { sent, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes))
slog.Warn("asset transfer cut short", "asset", asset.Name, "error", err) 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 return nil
} }
+35
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -113,3 +115,36 @@ func TestForgeURLMustBeHTTPS(t *testing.T) {
t.Fatal("plain http forge accepted") t.Fatal("plain http forge accepted")
} }
} }
// The server's log says who checked for and fetched an update, and how it went — the car's
// installation (here the shared token), the newest pre-release, the file and the bytes — and
// never the forge's token or address.
func TestUpdatesAreLogged(t *testing.T) {
var logged bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
defer slog.SetDefault(previous)
forge := fakeForge(t)
defer forge.Close()
h := releasesAPI(t, forge)
if rec := do(t, h, "GET", "/v1/app/releases", testToken, ""); rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
if rec := do(t, h, "GET", "/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk", testToken, ""); rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
out := logged.String()
for _, want := range []string{
`msg="update check" installation="shared token" releases=1 newest=car-2.16.0`,
`msg="update download started" installation="shared token" asset=NilsMG4-unstable-2.16.0.apk`,
`msg="update handed out" installation="shared token" asset=NilsMG4-unstable-2.16.0.apk sent=4`,
} {
if !strings.Contains(out, want) {
t.Errorf("log lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, testForgeToken) || strings.Contains(out, forge.URL) {
t.Fatal("the forge or its token leaked into the log")
}
}