This commit is contained in:
David Schroeder
2026-07-25 15:08:37 -05:00
parent d5faf998f4
commit e19b23de81
7 changed files with 230 additions and 54 deletions
+9 -3
View File
@@ -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.
+2 -3
View File
@@ -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"]),
+4 -3
View File
@@ -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; }
+5
View File
@@ -37,10 +37,15 @@ document.addEventListener("submit", async (event) => {
if (!response.ok) {
throw new Error(result.error || `Upload failed with HTTP ${response.status}`);
}
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;
+27 -11
View File
@@ -181,18 +181,37 @@
</div>
<div class="package-forms">
{{if .Packages}}
<form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact" data-upload-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<p class="form-title">Upload a new version</p>
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
<div class="field-row">
<label>Registry package <input name="package_name" placeholder="sentinelone-linux" required></label>
<input type="hidden" name="mode" value="update">
<p class="form-title">Update a package</p>
<label>Package
<select name="slug" required>
{{range .Packages}}
<option value="{{.Slug}}">{{.DisplayName}} ({{.Slug}} · {{.PackageVersion}})</option>
{{end}}
</select>
</label>
<label>New version <input name="package_version" placeholder="26.2.0.10" required></label>
</div>
<label class="toggle"><input type="checkbox" name="enabled" value="1" checked> Enable after upload</label>
<label>Replacement installer <input name="package_file" type="file" required></label>
<p class="form-help">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.</p>
<button class="button primary" type="submit">Replace current package</button>
<p class="upload-status" data-upload-status aria-live="polite"></p>
</form>
{{end}}
<form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact" data-upload-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="mode" value="create">
<p class="form-title">Add a protected package</p>
<label>Package ID <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
<label>Version <input name="package_version" placeholder="26.2.0.10" required></label>
<label class="toggle"><input type="checkbox" name="enabled" value="1" checked> Enable after upload</label>
<label>Installer file <input name="package_file" type="file" required></label>
<button class="button primary" type="submit">Upload and register package</button>
<button class="button secondary" type="submit">Upload new package</button>
<p class="upload-status" data-upload-status aria-live="polite"></p>
</form>
@@ -200,12 +219,9 @@
<summary>Register package metadata manually</summary>
<form action="/portal/packages" method="post" class="stack compact">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
<label>Package ID <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
<div class="field-row">
<label>Registry package <input name="package_name" placeholder="sentinelone-linux" required></label>
<label>Version <input name="package_version" placeholder="26.1.1.31" required></label>
</div>
<label>Filename <input name="file_name" placeholder="SentinelAgent_linux_x86_64.deb" required></label>
<label>SHA-256 <input name="sha256" minlength="64" maxlength="64" placeholder="64 hexadecimal characters" required></label>
<label class="toggle"><input type="checkbox" name="enabled" value="1"> Enable for new authorizations</label>
+140 -25
View File
@@ -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_uploaded",
"package_cleanup_failed",
tech.Login,
nil,
"",
fields["slug"],
target.Slug,
s.clientIP(r),
warning,
)
}
}
_ = s.audit(
r.Context(),
map[bool]string{true: "package_updated", false: "package_uploaded"}[target.Replacing],
tech.Login,
nil,
"",
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)
}
}
+38 -4
View File
@@ -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)
}
}