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
132 lines
4.3 KiB
Go
132 lines
4.3 KiB
Go
// Command server is the Nils' MG4 car server: the API the car writes its telemetry,
|
|
// consumption and charging sessions to, stored in InfluxDB 2. The car sends live data to ABRP
|
|
// itself.
|
|
// Dashboards are Grafana's job; Grafana reads the same bucket directly.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
minIngestTokenLength = 24
|
|
minApprovalPasswordLength = 12
|
|
)
|
|
|
|
type config struct {
|
|
listenAddr string
|
|
influxURL string
|
|
influxToken string
|
|
influxOrg string
|
|
influxBucket string
|
|
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
|
|
// short ingest token stops the server from starting rather than serving something broken.
|
|
func loadConfig(getenv func(string) string) (config, error) {
|
|
c := config{
|
|
listenAddr: getenv("LISTEN_ADDR"),
|
|
influxURL: getenv("INFLUX_URL"),
|
|
influxToken: getenv("INFLUX_TOKEN"),
|
|
influxOrg: getenv("INFLUX_ORG"),
|
|
influxBucket: getenv("INFLUX_BUCKET"),
|
|
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"
|
|
}
|
|
if c.influxBucket == "" {
|
|
c.influxBucket = "mg4"
|
|
}
|
|
for name, value := range map[string]string{"INFLUX_URL": c.influxURL, "INFLUX_TOKEN": c.influxToken, "INFLUX_ORG": c.influxOrg} {
|
|
if value == "" {
|
|
return config{}, fmt.Errorf("%s is not configured", name)
|
|
}
|
|
}
|
|
if len(c.ingestToken) < minIngestTokenLength {
|
|
return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength)
|
|
}
|
|
// 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)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func main() {
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
|
cfg, err := loadConfig(os.Getenv)
|
|
if err != nil {
|
|
slog.Error("invalid configuration", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
store := NewInfluxStore(cfg.influxURL, cfg.influxToken, cfg.influxOrg, cfg.influxBucket)
|
|
defer store.Close()
|
|
// Paired cars' tokens must be known before the first request; starting without them
|
|
// would answer every paired car with 401, so a failed load stops the start instead.
|
|
loadCtx, cancelLoad := context.WithTimeout(context.Background(), 30*time.Second)
|
|
devices, err := store.LoadDevices(loadCtx)
|
|
cancelLoad()
|
|
if err != nil {
|
|
slog.Error("paired devices could not be loaded", "error", err)
|
|
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(),
|
|
Releases: releases, PublicURL: cfg.publicURL}
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.listenAddr,
|
|
Handler: api.Routes(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
go func() {
|
|
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
|
|
"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)
|
|
}
|
|
}()
|
|
<-ctx.Done()
|
|
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdown); err != nil {
|
|
slog.Error("shutdown", "error", err)
|
|
}
|
|
}
|