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 + + + +
+

Version Agent Admin

+
+ + +
will be saved in localStorage
+
+
+ +
+

Configuration

+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+ +
+

Maintenance: Set Latest

+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

Assets

+
+ +
+
+
+ +
+

+
+ +
+

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 "" +}