Files
OwnCord/Server/api/client_update.go
T
J3vbandClaude Fable 5 f3a89e0e09 fix(updater): make client auto-update work end-to-end and Linux server self-update verifiable
- client: update endpoint now sends {{target}}-{{arch}}-{{bundle_type}} so the
  server-echoed platforms key matches the updater plugin's
  {os}-{arch}-{installer} lookup (previously bare {{target}} produced a key
  the plugin never matches, so no update was ever surfaced)
- client: TOFU cert pin is scoped to the OwnCord server host via
  HostScopedVerifier; the GitHub installer download validates against web PKI
  instead of failing the pinned-fingerprint check on every install
- client: check/install share one build_updater helper so the two paths cannot
  diverge; tauri-plugin-updater minor-pinned per its configure_client guidance
- server: client-update endpoint serves target-specific artifacts (NSIS,
  per-arch AppImage) and returns 204 for targets without a published updater
  artifact (deb, darwin) instead of always serving the Windows NSIS installer
- release: server-update-manifest.json now binds both OS assets (legacy
  top-level pair kept pointing at the Windows binary so deployed servers still
  verify); VerifyReleaseManifest resolves the entry matching the downloaded
  asset, fixing Linux server self-update
- release: ARM64 staging renames installer, tar.gz and .sig consistently so
  signatures keep pairing and arch-less names cannot collide with x86_64 assets
- ci: run cargo test --lib (Rust #[cfg(test)] code was never compiled in CI);
  merge the two ptt tests that raced on the global PTT_VKEY atomic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:38:22 +02:00

104 lines
3.2 KiB
Go

// Package api provides the HTTP router and handlers for the OwnCord server.
//
// client_update.go serves Tauri-compatible update metadata so the desktop
// client can check for new versions and self-update.
package api
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/updater"
"golang.org/x/mod/semver"
)
// tauriPlatformResponse is the per-platform entry in the Tauri updater JSON.
type tauriPlatformResponse struct {
Signature string `json:"signature"`
URL string `json:"url"`
}
// tauriUpdateResponse is the JSON shape the Tauri updater plugin expects.
type tauriUpdateResponse struct {
Version string `json:"version"`
Notes string `json:"notes,omitempty"`
PubDate string `json:"pub_date,omitempty"`
Platforms map[string]tauriPlatformResponse `json:"platforms"`
}
// MountClientUpdateRoute adds the unauthenticated client-update endpoint.
// The route is outside the auth middleware because the client needs to check
// for updates before (or without) logging in.
func MountClientUpdateRoute(r chi.Router, u *updater.Updater) {
r.Get("/api/v1/client-update/{target}/{current_version}", handleClientUpdate(u))
}
func handleClientUpdate(u *updater.Updater) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
target := chi.URLParam(r, "target")
currentVersion := chi.URLParam(r, "current_version")
if target == "" || currentVersion == "" {
http.Error(w, "missing target or current_version", http.StatusBadRequest)
return
}
info, err := u.CheckForUpdate(r.Context())
if err != nil {
http.Error(w, "failed to check for updates", http.StatusBadGateway)
return
}
// Compare versions — return 204 if no update available.
cv := ensureV(currentVersion)
lv := ensureV(info.Latest)
if semver.Compare(cv, lv) >= 0 {
w.WriteHeader(http.StatusNoContent)
return
}
// Find the updater artifact and its signature for the requested
// target ("{os}-{arch}-{installer}", e.g. "windows-x86_64-nsis").
// Targets without a published updater artifact get 204 — never a
// foreign OS's or foreign installer's artifact.
clientAssets := u.FindClientAssets(target)
installerURL := clientAssets.InstallerURL
sigURL := clientAssets.SignatureURL
if installerURL == "" || sigURL == "" {
w.WriteHeader(http.StatusNoContent)
return
}
// Fetch the signature file content (small text file). Cached with the
// same TTL as the release info so this unauthenticated endpoint does not
// perform an outbound fetch on every request (DoS hardening).
sigContent, err := u.FetchTextAssetCached(r.Context(), sigURL)
if err != nil {
http.Error(w, "failed to fetch signature", http.StatusBadGateway)
return
}
resp := tauriUpdateResponse{
Version: strings.TrimPrefix(info.Latest, "v"),
Notes: info.ReleaseNotes,
Platforms: map[string]tauriPlatformResponse{
target: {
Signature: strings.TrimSpace(sigContent),
URL: installerURL,
},
},
}
writeJSON(w, http.StatusOK, resp)
}
}
// ensureV returns a version string with a "v" prefix for semver comparison.
func ensureV(v string) string {
if strings.HasPrefix(v, "v") {
return v
}
return "v" + v
}