From e19b23de81fcbd6e3f06a42cecc5a4b4491503fc Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 15:08:37 -0500 Subject: [PATCH] update --- docs/deployment.md | 12 ++- internal/app/authorization.go | 5 +- internal/app/static/app.css | 7 +- internal/app/static/app.js | 13 ++- internal/app/templates/portal.html | 40 ++++--- internal/app/upload.go | 165 ++++++++++++++++++++++++----- internal/app/upload_test.go | 42 +++++++- 7 files changed, 230 insertions(+), 54 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 4fdb4a0..c866acf 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -127,11 +127,17 @@ package uploads and downloads stay open longer than portal requests. ## 8. Publish a protected package -The normal workflow is **Protected packages → Upload a new version** in the -portal. The broker streams the file directly to Gitea, calculates SHA-256, -updates the catalog, and writes an audit event without buffering the installer +The normal workflow is **Protected packages → Update a package** in the portal. +The package ID is the stable name used by the broker, ProxMenu, and Gitea; a +separate registry package name is not required. The broker streams the +replacement directly to Gitea, calculates SHA-256, updates the catalog, removes +the prior Gitea version, and writes audit events without buffering the installer on local disk. +An authorization records the exact version and checksum selected when its code +is created. Revoke and reissue any active code that included a package after +replacing that package, because the superseded registry version is removed. + The load balancer or Nginx must allow request bodies up to `TAPM_MAX_UPLOAD_BYTES` and use a sufficiently long request timeout. The default is 1 GiB and 30 minutes. diff --git a/internal/app/authorization.go b/internal/app/authorization.go index ca91a43..fc94917 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -336,7 +336,7 @@ func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Reques func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) { tech, _ := s.currentTechnician(r) fields := []string{ - "slug", "display_name", "package_name", "package_version", "file_name", "sha256", + "slug", "display_name", "package_version", "file_name", "sha256", } values := make(map[string]string) for _, field := range fields { @@ -348,7 +348,6 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) { } if !validSlug(values["slug"]) || len(values["display_name"]) > 255 || - !validRegistrySegment(values["package_name"], 255) || !validRegistrySegment(values["package_version"], 100) || !validRegistrySegment(values["file_name"], 255) || !validSHA256(values["sha256"]) { @@ -360,7 +359,7 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) { r, values["slug"], values["display_name"], - values["package_name"], + values["slug"], values["package_version"], values["file_name"], strings.ToLower(values["sha256"]), diff --git a/internal/app/static/app.css b/internal/app/static/app.css index 293b2c4..4e54d84 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -24,7 +24,7 @@ body { #091211; } -button, input { font: inherit; } +button, input, select { font: inherit; } button:disabled { cursor: wait; opacity: .58; } .topbar { @@ -110,7 +110,7 @@ h2 { margin-bottom: 0; font-size: 1.4rem; letter-spacing: -.02em; } .stack.compact { padding: 0; } label, legend { color: var(--muted); font-size: .78rem; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; } -input { +input, select { width: 100%; margin-top: 8px; padding: 12px 13px; @@ -120,7 +120,7 @@ input { outline: none; background: #0a1514; } -input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(88, 224, 173, .12); } +input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(88, 224, 173, .12); } .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } fieldset { display: grid; gap: 10px; padding: 0; border: 0; } @@ -211,6 +211,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .empty, .fine-print { color: var(--muted); } .package-forms { display: grid; gap: 26px; } .form-title { margin: 0; color: var(--ink); font-weight: 850; } +.form-help { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.55; } .upload-status { min-height: 1.2em; margin: 0; color: var(--amber); overflow-wrap: anywhere; } .manual-metadata { padding-top: 22px; border-top: 1px solid var(--line); } .manual-metadata summary { color: var(--accent); cursor: pointer; font-weight: 750; } diff --git a/internal/app/static/app.js b/internal/app/static/app.js index ed2c7ae..b149e36 100644 --- a/internal/app/static/app.js +++ b/internal/app/static/app.js @@ -37,10 +37,15 @@ document.addEventListener("submit", async (event) => { if (!response.ok) { throw new Error(result.error || `Upload failed with HTTP ${response.status}`); } - status.textContent = `Uploaded ${result.filename}; SHA-256 ${result.sha256}`; - window.setTimeout(() => { - window.location.assign("/portal?notice=Package+uploaded+and+registered"); - }, 900); + if (result.warning) { + status.textContent = `${result.warning} The replacement is active; see the audit trail for details.`; + button.disabled = false; + } else { + status.textContent = `Uploaded ${result.filename}; SHA-256 ${result.sha256}`; + window.setTimeout(() => { + window.location.assign("/portal?notice=Package+uploaded+and+registered"); + }, 900); + } } catch (error) { status.textContent = error.message; button.disabled = false; diff --git a/internal/app/templates/portal.html b/internal/app/templates/portal.html index 05ba258..4c04572 100644 --- a/internal/app/templates/portal.html +++ b/internal/app/templates/portal.html @@ -181,18 +181,37 @@
+ {{if .Packages}}
-

Upload a new version

- + +

Update a package

+ + + + +

The package ID stays the same. After the replacement is registered, its prior Gitea version is removed. Codes created for the prior version must be revoked and reissued.

+ +

+
+ {{end}} + +
+ + +

Add a protected package

+ -
- - -
+ - +

@@ -200,12 +219,9 @@ Register package metadata manually
- + -
- - -
+ diff --git a/internal/app/upload.go b/internal/app/upload.go index 1017f49..6cca3e7 100644 --- a/internal/app/upload.go +++ b/internal/app/upload.go @@ -2,6 +2,7 @@ package app import ( "crypto/sha256" + "database/sql" "encoding/hex" "errors" "fmt" @@ -15,6 +16,17 @@ const uploadFormOverhead = 1 << 20 type byteCounter int64 +type packageUploadTarget struct { + Slug string + DisplayName string + PackageName string + PackageVersion string + PreviousName string + PreviousVersion string + Enabled bool + Replacing bool +} + func (counter *byteCounter) Write(value []byte) (int, error) { *counter += byteCounter(len(value)) return len(value), nil @@ -67,13 +79,20 @@ func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid package filename"}) return } - if err := validateUploadFields(fields); err != nil { + target, status, err := s.preparePackageUpload(r, fields) + if err != nil { _ = part.Close() - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeJSON(w, status, map[string]string{"error": err.Error()}) return } - checksum, size, status, err := s.streamPackageToGitea(r, fields, fileName, part) + checksum, size, status, err := s.streamPackageToGitea( + r, + target.PackageName, + target.PackageVersion, + fileName, + part, + ) _ = part.Close() if err != nil { var maxBytesError *http.MaxBytesError @@ -85,32 +104,53 @@ func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) { return } - enabled := fields["enabled"] == "1" if err := s.savePackage( r, - fields["slug"], - fields["display_name"], - fields["package_name"], - fields["package_version"], + target.Slug, + target.DisplayName, + target.PackageName, + target.PackageVersion, fileName, checksum, - enabled, + target.Enabled, ); err != nil { + _ = s.deletePackageVersion(r, target.PackageName, target.PackageVersion) _ = s.audit(r.Context(), "package_upload_failed", tech.Login, nil, "", fields["slug"], s.clientIP(r), err.Error()) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "package uploaded but catalog update failed"}) return } + + warning := "" + if target.Replacing { + if err := s.deletePackageVersion(r, target.PreviousName, target.PreviousVersion); err != nil { + warning = fmt.Sprintf( + "Package updated, but Gitea could not remove superseded version %s: %s", + target.PreviousVersion, + err, + ) + _ = s.audit( + r.Context(), + "package_cleanup_failed", + tech.Login, + nil, + "", + target.Slug, + s.clientIP(r), + warning, + ) + } + } _ = s.audit( r.Context(), - "package_uploaded", + map[bool]string{true: "package_updated", false: "package_uploaded"}[target.Replacing], tech.Login, nil, "", - fields["slug"], + target.Slug, s.clientIP(r), fmt.Sprintf( "version=%s filename=%s bytes=%d sha256=%s enabled=%t", - fields["package_version"], fileName, size, checksum, enabled, + target.PackageVersion, fileName, size, checksum, target.Enabled, ), ) writeJSON(w, http.StatusCreated, map[string]any{ @@ -118,25 +158,62 @@ func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) { "sha256": checksum, "bytes": size, "filename": fileName, + "warning": warning, }) return } } -func validateUploadFields(fields map[string]string) error { +func (s *Server) preparePackageUpload( + r *http.Request, + fields map[string]string, +) (packageUploadTarget, int, error) { + var target packageUploadTarget if !validSlug(fields["slug"]) { - return errors.New("invalid package slug") - } - if fields["display_name"] == "" || len(fields["display_name"]) > 255 { - return errors.New("invalid display name") - } - if !validRegistrySegment(fields["package_name"], 255) { - return errors.New("invalid registry package name") + return target, http.StatusBadRequest, errors.New("invalid package ID") } if !validRegistrySegment(fields["package_version"], 100) { - return errors.New("invalid package version") + return target, http.StatusBadRequest, errors.New("invalid package version") } - return nil + + target.Slug = fields["slug"] + target.PackageVersion = fields["package_version"] + target.Enabled = fields["enabled"] == "1" + + err := s.db.QueryRowContext( + r.Context(), + `SELECT display_name, package_name, package_version + FROM packages + WHERE slug = ?`, + target.Slug, + ).Scan(&target.DisplayName, &target.PackageName, &target.PreviousVersion) + switch { + case err == nil: + if fields["mode"] == "create" { + return target, http.StatusConflict, errors.New("that package ID already exists; use Update package") + } + if target.PackageVersion == target.PreviousVersion { + return target, http.StatusConflict, errors.New("new version must differ from the current version") + } + target.PreviousName = target.PackageName + target.Replacing = true + case errors.Is(err, sql.ErrNoRows): + if fields["mode"] == "update" { + return target, http.StatusNotFound, errors.New("package ID was not found") + } + target.DisplayName = fields["display_name"] + if target.DisplayName == "" || len(target.DisplayName) > 255 { + return target, http.StatusBadRequest, errors.New("invalid display name") + } + target.PackageName = target.Slug + default: + return target, http.StatusInternalServerError, errors.New("unable to read package catalog") + } + + if !validRegistrySegment(target.PackageName, 255) { + return target, http.StatusInternalServerError, errors.New("catalog contains an invalid registry package name") + } + return target, http.StatusOK, nil } func validRegistrySegment(value string, maxLength int) bool { @@ -157,7 +234,8 @@ func validRegistrySegment(value string, maxLength int) bool { func (s *Server) streamPackageToGitea( r *http.Request, - fields map[string]string, + packageName string, + packageVersion string, fileName string, source io.Reader, ) (string, int64, int, error) { @@ -166,8 +244,8 @@ func (s *Server) streamPackageToGitea( fmt.Sprintf( "/api/packages/%s/generic/%s/%s/%s", url.PathEscape(s.cfg.GiteaPackageOwner), - url.PathEscape(fields["package_name"]), - url.PathEscape(fields["package_version"]), + url.PathEscape(packageName), + url.PathEscape(packageVersion), url.PathEscape(fileName), ), ) @@ -204,3 +282,40 @@ func (s *Server) streamPackageToGitea( } return hex.EncodeToString(hasher.Sum(nil)), int64(size), http.StatusCreated, nil } + +func (s *Server) deletePackageVersion( + r *http.Request, + packageName string, + packageVersion string, +) error { + registryURL := joinURL( + s.cfg.GiteaURL, + fmt.Sprintf( + "/api/packages/%s/generic/%s/%s", + url.PathEscape(s.cfg.GiteaPackageOwner), + url.PathEscape(packageName), + url.PathEscape(packageVersion), + ), + ) + request, err := http.NewRequestWithContext(r.Context(), http.MethodDelete, registryURL, nil) + if err != nil { + return errors.New("unable to prepare registry cleanup") + } + request.SetBasicAuth(s.cfg.GiteaWriteUser, s.cfg.GiteaWriteToken) + + response, err := s.packageClient.Do(request) + if err != nil { + return errors.New("package registry cleanup failed") + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8192)) + + switch response.StatusCode { + case http.StatusNoContent, http.StatusNotFound: + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return errors.New("package registry rejected the publisher credentials") + default: + return fmt.Errorf("package registry returned %s", response.Status) + } +} diff --git a/internal/app/upload_test.go b/internal/app/upload_test.go index ee7aa82..a967688 100644 --- a/internal/app/upload_test.go +++ b/internal/app/upload_test.go @@ -68,10 +68,8 @@ func TestStreamPackageToGitea(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/portal/packages/upload", nil) checksum, size, status, err := server.streamPackageToGitea( request, - map[string]string{ - "package_name": "sentinelone-linux", - "package_version": "26.2.0.10", - }, + "sentinelone-linux", + "26.2.0.10", "agent.deb", strings.NewReader(content), ) @@ -89,3 +87,39 @@ func TestStreamPackageToGitea(t *testing.T) { t.Errorf("status = %d", status) } } + +func TestDeletePackageVersion(t *testing.T) { + t.Parallel() + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("method = %s, want DELETE", r.Method) + } + if r.URL.Path != "/api/packages/TAI/generic/sentinelone-linux/26.1.1.31" { + t.Errorf("path = %s", r.URL.Path) + } + username, password, ok := r.BasicAuth() + if !ok || username != "publisher" || password != "write-token" { + t.Errorf("unexpected registry credentials") + } + w.WriteHeader(http.StatusNoContent) + })) + defer registry.Close() + + registryURL, err := url.Parse(registry.URL) + if err != nil { + t.Fatal(err) + } + server := &Server{ + cfg: Config{ + GiteaURL: registryURL, + GiteaPackageOwner: "TAI", + GiteaWriteUser: "publisher", + GiteaWriteToken: "write-token", + }, + packageClient: registry.Client(), + } + request := httptest.NewRequest(http.MethodPost, "/portal/packages/upload", nil) + if err := server.deletePackageVersion(request, "sentinelone-linux", "26.1.1.31"); err != nil { + t.Fatal(err) + } +}