diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml
new file mode 100644
index 0000000..20912ac
--- /dev/null
+++ b/.gitea/workflows/registry.yml
@@ -0,0 +1,51 @@
+name: release-tag
+on:
+ push:
+ branches:
+ - 'main'
+jobs:
+ release-image:
+ runs-on: ubuntu-fast
+ env:
+ DOCKER_ORG: ${{ vars.DOCKER_ORG }}
+ DOCKER_LATEST: latest
+ RUNNER_TOOL_CACHE: /toolcache
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+
+ - name: Set up Docker BuildX
+ uses: docker/setup-buildx-action@v2
+ with: # replace it with your local IP
+ config-inline: |
+ [registry."${{ vars.DOCKER_REGISTRY }}"]
+ http = true
+ insecure = true
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v2
+ with:
+ registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Get Meta
+ id: meta
+ run: |
+ echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
+ echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
+
+ - name: Build and push
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: |
+ linux/amd64
+ push: true
+ tags: | # replace it with your local IP and tags
+ ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
+ ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
\ No newline at end of file
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..c46ef9c
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,124 @@
+# Git(tea) Actions workflow: Build and publish standalone binaries **plus** bundled `static/` assets
+# ────────────────────────────────────────────────────────────────────
+# ✧ Builds the Go‑based WoL server for four targets **and** packt das Verzeichnis
+# `static` zusammen mit der Binary, sodass es relativ zur ausführbaren Datei
+# liegt (wichtig für die eingebauten Bootstrap‑Assets & favicon).
+#
+# • linux/amd64 → wol-server-linux-amd64.tar.gz
+# • linux/arm64 → wol-server-linux-arm64.tar.gz
+# • linux/arm/v7 → wol-server-linux-armv7.tar.gz
+# • windows/amd64 → wol-server-windows-amd64.zip
+#
+# ✧ Artefakte landen im Workflow und – bei Tag‑Push (vX.Y.Z) – als Release‑Assets.
+#
+# Secrets/variables:
+# GITEA_TOKEN – optional, falls default token keine Release‑Rechte hat.
+# ────────────────────────────────────────────────────────────────────
+
+name: build-binaries
+
+on:
+ push:
+ branches: [ "main" ]
+ tags: [ "v*" ]
+
+jobs:
+ build:
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-fast
+
+ strategy:
+ matrix:
+ include:
+ - goos: linux
+ goarch: amd64
+ ext: ""
+ - goos: linux
+ goarch: arm64
+ ext: ""
+ - goos: linux
+ goarch: arm
+ goarm: "7"
+ ext: ""
+ - goos: windows
+ goarch: amd64
+ ext: ".exe"
+
+ env:
+ GO_VERSION: "1.25"
+ BINARY_NAME: release-agent
+
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v3
+
+ - name: Set up Go ${{ env.GO_VERSION }}
+ uses: actions/setup-go@v4
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ cache: true
+
+ - name: Build ${{ matrix.goos }}/${{ matrix.goarch }}${{ matrix.goarm && format('/v{0}', matrix.goarm) || '' }}
+ shell: bash
+ run: |
+ set -e
+ mkdir -p dist/package
+ if [ -n "${{ matrix.goarm }}" ]; then export GOARM=${{ matrix.goarm }}; fi
+ CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -trimpath -ldflags "-s -w" \
+ -o "dist/package/${BINARY_NAME}${{ matrix.ext }}" .
+ # Assets: statisches Verzeichnis beilegen
+ # cp -r static dist/package/
+
+ - name: Package archive with static assets
+ shell: bash
+ run: |
+ set -e
+ cd dist
+ if [ "${{ matrix.goos }}" == "windows" ]; then
+ ZIP_NAME="${BINARY_NAME}-windows-amd64.zip"
+ (cd package && zip -r "../$ZIP_NAME" .)
+ else
+ ARCH_SUFFIX="${{ matrix.goarch }}"
+ if [ "${{ matrix.goarch }}" == "arm" ]; then ARCH_SUFFIX="armv${{ matrix.goarm }}"; fi
+ TAR_NAME="${BINARY_NAME}-${{ matrix.goos }}-${ARCH_SUFFIX}.tar.gz"
+ tar -czf "$TAR_NAME" -C package .
+ fi
+
+ - name: Upload workflow artifact
+ uses: actions/upload-artifact@v3
+ with:
+ name: ${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }}
+ path: dist/*.tar.gz
+ if-no-files-found: ignore
+ - uses: actions/upload-artifact@v3
+ with:
+ name: windows-amd64
+ path: dist/*.zip
+ if-no-files-found: ignore
+
+ # Release Schritt für Tag‑Pushes
+ release:
+ if: startsWith(github.ref, 'refs/tags/')
+ needs: build
+ runs-on: ubuntu-fast
+ permissions:
+ contents: write
+
+ steps:
+ - name: Download artifacts
+ uses: actions/download-artifact@v3
+ with:
+ path: ./dist
+
+ - name: Create / Update release
+ uses: softprops/action-gh-release@v2
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
+ with:
+ name: "Release ${{ github.ref_name }}"
+ tag_name: ${{ github.ref_name }}
+ draft: false
+ prerelease: false
+ files: |
+ dist/**/release-agent-*.tar.gz
+ dist/**/release-agent-*.zip
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1045fe5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,21 @@
+FROM golang:1.25.3 AS build
+WORKDIR /src
+COPY go.mod ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux go build -o /out/release-agent
+#FROM gcr.io/distroless/static:nonroot
+FROM alpine:3.22.2
+WORKDIR /
+RUN apk add --no-cache tzdata nano
+RUN mkdir /data
+VOLUME ["/data"]
+EXPOSE 8080
+ENV API_TOKEN="" \
+ HTTP_PUBLIC=":8080" \
+ MANIFEST_PATH="/data" \
+ APP_VENDOR="YourVendor" \
+ APP_PRODUCT="YourProduct"
+
+COPY --from=build /out/release-agent /release-agent
+ENTRYPOINT ["/release-agent"]
\ No newline at end of file
diff --git a/admin.html b/admin.html
new file mode 100644
index 0000000..f9ec102
--- /dev/null
+++ b/admin.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+Version Agent Admin
+
+
+
+
+
+
+ Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Maintenance: Set Latest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Manifest
+ ETag-aware GET /v1/manifest
+
+
+
+
+
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..790e5f0
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module git.send.nrw/patchping/release-agent
+
+go 1.25.3
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..459145c
--- /dev/null
+++ b/main.go
@@ -0,0 +1,634 @@
+package main
+
+import (
+ "crypto/sha256"
+ "embed"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// ---- Data model -------------------------------------------------------------
+
+// Asset describes a downloadable artifact for a release.
+// Keep it minimal and verifiable.
+// All fields are JSON-tagged for a stable API.
+type Asset struct {
+ URL string `json:"url"`
+ SHA256 string `json:"sha256"`
+ Size int64 `json:"size_bytes,omitempty"`
+ SignatureURL string `json:"signature_url,omitempty"`
+ ContentType string `json:"content_type,omitempty"`
+}
+
+// Release holds the metadata for a specific Branch→Channel→Arch→Bit→OS combo.
+// Channel is duplicated in the payload for clarity in responses.
+type Release struct {
+ Version string `json:"version"` // e.g. 12.3.1
+ Build string `json:"build,omitempty"` // optional build id
+ ReleasedAt time.Time `json:"released_at"` // RFC3339
+ NotesURL string `json:"notes_url,omitempty"`
+ Assets []Asset `json:"assets"`
+ Meta map[string]string `json:"meta,omitempty"` // optional free-form
+ ChannelHint string `json:"channel,omitempty"` // echoed by server
+}
+
+// Manifest nests by Branch→Channel→Arch→Bit→OS as requested.
+// The innermost value is the latest Release for that tuple.
+// Example path: Releases["12.x"]["stable"]["amd64"]["64"]["windows"]
+type Manifest struct {
+ Vendor string `json:"vendor"`
+ Product string `json:"product"`
+ DefaultBranch string `json:"default_branch,omitempty"`
+ DefaultChannel string `json:"default_channel,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Releases map[string]map[string]map[string]map[string]map[string]Release `json:"releases"`
+}
+
+// publishRequest is the payload for POST /v1/publish
+type publishRequest struct {
+ Branch string `json:"branch"`
+ Channel string `json:"channel"` // stable, beta, rc, nightly
+ Arch string `json:"arch"`
+ Bit string `json:"bit"` // "32" or "64"
+ OS string `json:"os"`
+ Release Release `json:"release"`
+}
+
+// latestResponse is returned by GET /v1/latest
+// Mirrors the request tuple alongside the release for clarity.
+type latestResponse struct {
+ Branch string `json:"branch"`
+ Channel string `json:"channel"`
+ Arch string `json:"arch"`
+ Bit string `json:"bit"`
+ OS string `json:"os"`
+ Release Release `json:"release"`
+}
+
+// ---- Store & persistence ----------------------------------------------------
+
+type store struct {
+ mu sync.RWMutex
+ manifest Manifest
+ path string
+}
+
+func newStore(path, vendor, product string) *store {
+ m := Manifest{
+ Vendor: vendor,
+ Product: product,
+ DefaultBranch: "",
+ DefaultChannel: "stable",
+ UpdatedAt: time.Now().UTC(),
+ Releases: make(map[string]map[string]map[string]map[string]map[string]Release),
+ }
+ return &store{manifest: m, path: path}
+}
+
+// oldManifest is used to migrate v1 manifests (without channels) → channels("stable").
+type oldManifest struct {
+ Vendor string `json:"vendor"`
+ Product string `json:"product"`
+ DefaultBranch string `json:"default_branch,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Releases map[string]map[string]map[string]map[string]Release `json:"releases"`
+}
+
+func (s *store) loadIfExists() error {
+ b, err := os.ReadFile(s.path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ var m Manifest
+ if err := json.Unmarshal(b, &m); err == nil && m.Releases != nil {
+ // Looks like v2 → accept.
+ s.mu.Lock()
+ s.manifest = m
+ s.mu.Unlock()
+ return nil
+ }
+ // Try v1 → migrate into channel "stable".
+ var ov1 oldManifest
+ if err := json.Unmarshal(b, &ov1); err != nil {
+ return fmt.Errorf("invalid manifest json: %w", err)
+ }
+ mig := Manifest{
+ Vendor: ov1.Vendor,
+ Product: ov1.Product,
+ DefaultBranch: ov1.DefaultBranch,
+ DefaultChannel: "stable",
+ UpdatedAt: time.Now().UTC(),
+ Releases: make(map[string]map[string]map[string]map[string]map[string]Release),
+ }
+ for br, archs := range ov1.Releases {
+ if _, ok := mig.Releases[br]; !ok {
+ mig.Releases[br] = make(map[string]map[string]map[string]map[string]Release)
+ }
+ ch := mig.Releases[br]
+ if _, ok := ch["stable"]; !ok {
+ ch["stable"] = make(map[string]map[string]map[string]Release)
+ }
+ for arch, bits := range archs {
+ if _, ok := ch["stable"][arch]; !ok {
+ ch["stable"][arch] = make(map[string]map[string]Release)
+ }
+ for bit, osmap := range bits {
+ if _, ok := ch["stable"][arch][bit]; !ok {
+ ch["stable"][arch][bit] = make(map[string]Release)
+ }
+ for osname, rel := range osmap {
+ ch["stable"][arch][bit][osname] = rel
+ }
+ }
+ }
+ }
+ s.mu.Lock()
+ s.manifest = mig
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *store) persistLocked() error {
+ // Caller must hold s.mu (write)
+ s.manifest.UpdatedAt = time.Now().UTC()
+ b, err := json.MarshalIndent(s.manifest, "", " ")
+ if err != nil {
+ return err
+ }
+ // Ensure dir
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
+ return err
+ }
+ // Write atomically
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, b, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}
+
+func (s *store) setLatest(pr publishRequest) error {
+ if err := validateTuple(pr.Branch, pr.Channel, pr.Arch, pr.Bit, pr.OS); err != nil {
+ return err
+ }
+ if err := validateRelease(pr.Release); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ // Create levels if missing
+ lvl1, ok := s.manifest.Releases[pr.Branch]
+ if !ok {
+ lvl1 = make(map[string]map[string]map[string]map[string]Release)
+ s.manifest.Releases[pr.Branch] = lvl1
+ }
+ lvlCh, ok := lvl1[pr.Channel]
+ if !ok {
+ lvlCh = make(map[string]map[string]map[string]Release)
+ lvl1[pr.Channel] = lvlCh
+ }
+ lvl2, ok := lvlCh[pr.Arch]
+ if !ok {
+ lvl2 = make(map[string]map[string]Release)
+ lvlCh[pr.Arch] = lvl2
+ }
+ lvl3, ok := lvl2[pr.Bit]
+ if !ok {
+ lvl3 = make(map[string]Release)
+ lvl2[pr.Bit] = lvl3
+ }
+ rel := pr.Release
+ rel.ChannelHint = pr.Channel
+ lvl3[pr.OS] = rel
+ return s.persistLocked()
+}
+
+func (s *store) getLatest(branch, channel, arch, bit, osname string) (Release, bool) {
+ if err := validateTuple(branch, channel, arch, bit, osname); err != nil {
+ return Release{}, false
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ lvl1, ok := s.manifest.Releases[branch]
+ if !ok {
+ return Release{}, false
+ }
+ lvlCh, ok := lvl1[channel]
+ if !ok {
+ return Release{}, false
+ }
+ lvl2, ok := lvlCh[arch]
+ if !ok {
+ return Release{}, false
+ }
+ lvl3, ok := lvl2[bit]
+ if !ok {
+ return Release{}, false
+ }
+ rel, ok := lvl3[osname]
+ return rel, ok
+}
+
+func (s *store) branches() []string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make([]string, 0, len(s.manifest.Releases))
+ for k := range s.manifest.Releases {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// ---- Validation -------------------------------------------------------------
+
+var (
+ allowedOS = map[string]struct{}{"windows": {}, "linux": {}, "macos": {}, "freebsd": {}}
+ allowedArch = map[string]struct{}{"amd64": {}, "386": {}, "arm64": {}, "armv7": {}, "ppc64le": {}}
+ allowedBit = map[string]struct{}{"64": {}, "32": {}}
+ allowedChannels = map[string]struct{}{"stable": {}, "beta": {}, "rc": {}, "nightly": {}}
+)
+
+func validateTuple(branch, channel, arch, bit, osname string) error {
+ if strings.TrimSpace(branch) == "" {
+ return errors.New("branch required, e.g. '12.x'")
+ }
+ if _, ok := allowedChannels[channel]; !ok {
+ return fmt.Errorf("invalid channel: %s", channel)
+ }
+ if _, ok := allowedArch[arch]; !ok {
+ return fmt.Errorf("invalid arch: %s", arch)
+ }
+ if _, ok := allowedBit[bit]; !ok {
+ return fmt.Errorf("invalid bit: %s (use '32' or '64')", bit)
+ }
+ if _, ok := allowedOS[osname]; !ok {
+ return fmt.Errorf("invalid os: %s", osname)
+ }
+ return nil
+}
+
+func validateRelease(r Release) error {
+ if strings.TrimSpace(r.Version) == "" {
+ return errors.New("release.version required")
+ }
+ if r.ReleasedAt.IsZero() {
+ return errors.New("release.released_at required (RFC3339)")
+ }
+ if len(r.Assets) == 0 {
+ return errors.New("release.assets must not be empty")
+ }
+ for i, a := range r.Assets {
+ if a.URL == "" {
+ return fmt.Errorf("assets[%d].url required", i)
+ }
+ if a.SHA256 == "" {
+ return fmt.Errorf("assets[%d].sha256 required", i)
+ }
+ }
+ return nil
+}
+
+// ---- HTTP helpers -----------------------------------------------------------
+
+func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ b, err := json.Marshal(v)
+ if err != nil {
+ http.Error(w, "json marshal error", http.StatusInternalServerError)
+ return
+ }
+ etag := sha256.Sum256(b)
+ etagStr := "\"" + hex.EncodeToString(etag[:]) + "\""
+ w.Header().Set("ETag", etagStr)
+ if inm := r.Header.Get("If-None-Match"); inm != "" && strings.Contains(inm, strings.Trim(etagStr, "\"")) {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ w.WriteHeader(status)
+ _, _ = w.Write(b)
+}
+
+func parseJSON(r *http.Request, dst any) error {
+ defer r.Body.Close()
+ lr := io.LimitReader(r.Body, 1<<20) // 1 MiB payload cap
+ dec := json.NewDecoder(lr)
+ dec.DisallowUnknownFields()
+ return dec.Decode(dst)
+}
+
+func cors(w http.ResponseWriter, r *http.Request) bool {
+ // Permissive CORS for simplicity (can be tightened later)
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Vary", "Origin")
+ if r.Method == http.MethodOptions {
+ w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
+ w.WriteHeader(http.StatusNoContent)
+ return true
+ }
+ return false
+}
+
+// ---- Handlers ---------------------------------------------------------------
+
+type server struct {
+ st *store
+ apiToken string // optional; if set, required for POST /v1/publish & /v1/config
+}
+
+func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
+ if cors(w, r) {
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) {
+ if cors(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ s.st.mu.RLock()
+ m := s.st.manifest
+ s.st.mu.RUnlock()
+ writeJSON(w, r, http.StatusOK, m)
+}
+
+func (s *server) handleBranches(w http.ResponseWriter, r *http.Request) {
+ if cors(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ writeJSON(w, r, http.StatusOK, map[string]any{"branches": s.st.branches()})
+}
+
+func (s *server) handleChannels(w http.ResponseWriter, r *http.Request) {
+ if cors(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ keys := make([]string, 0, len(allowedChannels))
+ for k := range allowedChannels {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ writeJSON(w, r, http.StatusOK, map[string]any{"channels": keys, "default": s.st.manifest.DefaultChannel})
+}
+
+func (s *server) handleValues(w http.ResponseWriter, r *http.Request) {
+ // returns allowed enums + defaults to drive the UI
+ if cors(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ archs := keysOf(allowedArch)
+ bits := keysOf(allowedBit)
+ oss := keysOf(allowedOS)
+ chs := keysOf(allowedChannels)
+ s.st.mu.RLock()
+ defBr, defCh := s.st.manifest.DefaultBranch, s.st.manifest.DefaultChannel
+ vendor, product := s.st.manifest.Vendor, s.st.manifest.Product
+ s.st.mu.RUnlock()
+ writeJSON(w, r, http.StatusOK, map[string]any{
+ "arch": archs, "bit": bits, "os": oss, "channels": chs,
+ "defaults": map[string]string{"branch": defBr, "channel": defCh},
+ "meta": map[string]string{"vendor": vendor, "product": product},
+ })
+}
+
+func keysOf(m map[string]struct{}) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func (s *server) handleLatest(w http.ResponseWriter, r *http.Request) {
+ if cors(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ q := r.URL.Query()
+ branch := firstNonEmpty(q.Get("branch"), s.st.manifest.DefaultBranch)
+ channel := firstNonEmpty(q.Get("channel"), s.st.manifest.DefaultChannel)
+ arch := q.Get("arch")
+ bit := q.Get("bit")
+ osname := q.Get("os")
+ if branch == "" || channel == "" || arch == "" || bit == "" || osname == "" {
+ http.Error(w, "missing query params: branch, channel, arch, bit, os", http.StatusBadRequest)
+ return
+ }
+ rel, ok := s.st.getLatest(branch, channel, arch, bit, osname)
+ if !ok {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ writeJSON(w, r, http.StatusOK, latestResponse{Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
+}
+
+func (s *server) handleLatestPath(w http.ResponseWriter, r *http.Request) {
+ // /v1/latest/{branch}/{channel}/{arch}/{bit}/{os}
+ if cors(w, r) {
+ return
+ }
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/latest/"), "/")
+ if len(parts) != 5 {
+ http.Error(w, "expected /v1/latest/{branch}/{channel}/{arch}/{bit}/{os}", http.StatusBadRequest)
+ return
+ }
+ branch, channel, arch, bit, osname := parts[0], parts[1], parts[2], parts[3], parts[4]
+ rel, ok := s.st.getLatest(branch, channel, arch, bit, osname)
+ if !ok {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ writeJSON(w, r, http.StatusOK, latestResponse{Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
+}
+
+func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodOptions {
+ cors(w, r)
+ return
+ }
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ // Auth (if token configured)
+ if s.apiToken != "" {
+ auth := r.Header.Get("Authorization")
+ if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.apiToken {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ }
+ var pr publishRequest
+ if err := parseJSON(r, &pr); err != nil {
+ http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest)
+ return
+ }
+ if err := s.st.setLatest(pr); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// handleConfig allows updating vendor/product/defaults (token required if set)
+func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodOptions {
+ cors(w, r)
+ return
+ }
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if s.apiToken != "" {
+ auth := r.Header.Get("Authorization")
+ if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.apiToken {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ }
+ var req struct {
+ Vendor string `json:"vendor"`
+ Product string `json:"product"`
+ DefaultBranch string `json:"default_branch"`
+ DefaultChannel string `json:"default_channel"`
+ }
+ if err := parseJSON(r, &req); err != nil {
+ http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest)
+ return
+ }
+ if req.DefaultChannel != "" {
+ if _, ok := allowedChannels[req.DefaultChannel]; !ok {
+ http.Error(w, "invalid default_channel", http.StatusBadRequest)
+ return
+ }
+ }
+ s.st.mu.Lock()
+ if req.Vendor != "" {
+ s.st.manifest.Vendor = req.Vendor
+ }
+ if req.Product != "" {
+ s.st.manifest.Product = req.Product
+ }
+ if req.DefaultBranch != "" {
+ s.st.manifest.DefaultBranch = req.DefaultBranch
+ }
+ if req.DefaultChannel != "" {
+ s.st.manifest.DefaultChannel = req.DefaultChannel
+ }
+ if err := s.st.persistLocked(); err != nil {
+ s.st.mu.Unlock()
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ s.st.mu.Unlock()
+ writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// ---- Admin UI ---------------------------------------------------------------
+
+//go:embed admin.html
+var adminHTML embed.FS
+
+func (s *server) handleAdmin(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ b, err := adminHTML.ReadFile("admin.html")
+ if err != nil {
+ http.Error(w, "admin.html not embedded; ensure //go:embed admin.html and file exists: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(b)
+}
+
+// ---- Main ------------------------------------------------------------------
+
+func main() {
+ addr := envOr("HTTP_PUBLIC", ":8080")
+ manifestPath := envOr("MANIFEST_PATH", "/data/manifest.json")
+ vendor := envOr("APP_VENDOR", "YourVendor")
+ product := envOr("APP_PRODUCT", "YourProduct")
+ token := os.Getenv("API_TOKEN") // optional; if set, required for POST
+
+ st := newStore(manifestPath, vendor, product)
+ if err := st.loadIfExists(); err != nil {
+ log.Fatalf("load manifest: %v", err)
+ }
+
+ srv := &server{st: st, apiToken: token}
+
+ http.HandleFunc("/healthz", srv.handleHealth)
+ http.HandleFunc("/admin", srv.handleAdmin)
+
+ // Data API
+ http.HandleFunc("/v1/manifest", srv.handleManifest)
+ http.HandleFunc("/v1/values", srv.handleValues)
+ http.HandleFunc("/v1/branches", srv.handleBranches)
+ http.HandleFunc("/v1/channels", srv.handleChannels)
+ http.HandleFunc("/v1/latest", srv.handleLatest)
+ http.HandleFunc("/v1/latest/", srv.handleLatestPath)
+ http.HandleFunc("/v1/publish", srv.handlePublish)
+ http.HandleFunc("/v1/config", srv.handleConfig)
+
+ log.Printf("agent listening on %s (admin UI at /admin)", addr)
+ log.Fatal(http.ListenAndServe(addr, nil))
+}
+
+// ---- Utils -----------------------------------------------------------------
+
+func envOr(k, def string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return def
+}
+
+func firstNonEmpty(vals ...string) string {
+ for _, v := range vals {
+ if strings.TrimSpace(v) != "" {
+ return v
+ }
+ }
+ return ""
+}