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 ## 8. Publish a protected package
The normal workflow is **Protected packages → Upload a new version** in the The normal workflow is **Protected packages → Update a package** in the portal.
portal. The broker streams the file directly to Gitea, calculates SHA-256, The package ID is the stable name used by the broker, ProxMenu, and Gitea; a
updates the catalog, and writes an audit event without buffering the installer 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. 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 The load balancer or Nginx must allow request bodies up to
`TAPM_MAX_UPLOAD_BYTES` and use a sufficiently long request timeout. The `TAPM_MAX_UPLOAD_BYTES` and use a sufficiently long request timeout. The
default is 1 GiB and 30 minutes. 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) { func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r) tech, _ := s.currentTechnician(r)
fields := []string{ 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) values := make(map[string]string)
for _, field := range fields { for _, field := range fields {
@@ -348,7 +348,6 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
} }
if !validSlug(values["slug"]) || if !validSlug(values["slug"]) ||
len(values["display_name"]) > 255 || len(values["display_name"]) > 255 ||
!validRegistrySegment(values["package_name"], 255) ||
!validRegistrySegment(values["package_version"], 100) || !validRegistrySegment(values["package_version"], 100) ||
!validRegistrySegment(values["file_name"], 255) || !validRegistrySegment(values["file_name"], 255) ||
!validSHA256(values["sha256"]) { !validSHA256(values["sha256"]) {
@@ -360,7 +359,7 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
r, r,
values["slug"], values["slug"],
values["display_name"], values["display_name"],
values["package_name"], values["slug"],
values["package_version"], values["package_version"],
values["file_name"], values["file_name"],
strings.ToLower(values["sha256"]), strings.ToLower(values["sha256"]),
+4 -3
View File
@@ -24,7 +24,7 @@ body {
#091211; #091211;
} }
button, input { font: inherit; } button, input, select { font: inherit; }
button:disabled { cursor: wait; opacity: .58; } button:disabled { cursor: wait; opacity: .58; }
.topbar { .topbar {
@@ -110,7 +110,7 @@ h2 { margin-bottom: 0; font-size: 1.4rem; letter-spacing: -.02em; }
.stack.compact { padding: 0; } .stack.compact { padding: 0; }
label, legend { color: var(--muted); font-size: .78rem; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; } label, legend { color: var(--muted); font-size: .78rem; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; }
input { input, select {
width: 100%; width: 100%;
margin-top: 8px; margin-top: 8px;
padding: 12px 13px; padding: 12px 13px;
@@ -120,7 +120,7 @@ input {
outline: none; outline: none;
background: #0a1514; 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; } .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
fieldset { display: grid; gap: 10px; padding: 0; border: 0; } 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); } .empty, .fine-print { color: var(--muted); }
.package-forms { display: grid; gap: 26px; } .package-forms { display: grid; gap: 26px; }
.form-title { margin: 0; color: var(--ink); font-weight: 850; } .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; } .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 { padding-top: 22px; border-top: 1px solid var(--line); }
.manual-metadata summary { color: var(--accent); cursor: pointer; font-weight: 750; } .manual-metadata summary { color: var(--accent); cursor: pointer; font-weight: 750; }
+9 -4
View File
@@ -37,10 +37,15 @@ document.addEventListener("submit", async (event) => {
if (!response.ok) { if (!response.ok) {
throw new Error(result.error || `Upload failed with HTTP ${response.status}`); throw new Error(result.error || `Upload failed with HTTP ${response.status}`);
} }
status.textContent = `Uploaded ${result.filename}; SHA-256 ${result.sha256}`; if (result.warning) {
window.setTimeout(() => { status.textContent = `${result.warning} The replacement is active; see the audit trail for details.`;
window.location.assign("/portal?notice=Package+uploaded+and+registered"); button.disabled = false;
}, 900); } else {
status.textContent = `Uploaded ${result.filename}; SHA-256 ${result.sha256}`;
window.setTimeout(() => {
window.location.assign("/portal?notice=Package+uploaded+and+registered");
}, 900);
}
} catch (error) { } catch (error) {
status.textContent = error.message; status.textContent = error.message;
button.disabled = false; button.disabled = false;
+28 -12
View File
@@ -181,18 +181,37 @@
</div> </div>
<div class="package-forms"> <div class="package-forms">
{{if .Packages}}
<form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact" data-upload-form> <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="csrf_token" value="{{.CSRFToken}}">
<p class="form-title">Upload a new version</p> <input type="hidden" name="mode" value="update">
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label> <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>
<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>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
<div class="field-row"> <label>Version <input name="package_version" placeholder="26.2.0.10" required></label>
<label>Registry package <input name="package_name" placeholder="sentinelone-linux" required></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 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> <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> <p class="upload-status" data-upload-status aria-live="polite"></p>
</form> </form>
@@ -200,12 +219,9 @@
<summary>Register package metadata manually</summary> <summary>Register package metadata manually</summary>
<form action="/portal/packages" method="post" class="stack compact"> <form action="/portal/packages" method="post" class="stack compact">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <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> <label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
<div class="field-row"> <label>Version <input name="package_version" placeholder="26.1.1.31" required></label>
<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>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>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> <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 ( import (
"crypto/sha256" "crypto/sha256"
"database/sql"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -15,6 +16,17 @@ const uploadFormOverhead = 1 << 20
type byteCounter int64 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) { func (counter *byteCounter) Write(value []byte) (int, error) {
*counter += byteCounter(len(value)) *counter += byteCounter(len(value))
return len(value), nil 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"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid package filename"})
return return
} }
if err := validateUploadFields(fields); err != nil { target, status, err := s.preparePackageUpload(r, fields)
if err != nil {
_ = part.Close() _ = part.Close()
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSON(w, status, map[string]string{"error": err.Error()})
return 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() _ = part.Close()
if err != nil { if err != nil {
var maxBytesError *http.MaxBytesError var maxBytesError *http.MaxBytesError
@@ -85,32 +104,53 @@ func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) {
return return
} }
enabled := fields["enabled"] == "1"
if err := s.savePackage( if err := s.savePackage(
r, r,
fields["slug"], target.Slug,
fields["display_name"], target.DisplayName,
fields["package_name"], target.PackageName,
fields["package_version"], target.PackageVersion,
fileName, fileName,
checksum, checksum,
enabled, target.Enabled,
); err != nil { ); 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()) _ = 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"}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "package uploaded but catalog update failed"})
return 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( _ = s.audit(
r.Context(), r.Context(),
"package_uploaded", map[bool]string{true: "package_updated", false: "package_uploaded"}[target.Replacing],
tech.Login, tech.Login,
nil, nil,
"", "",
fields["slug"], target.Slug,
s.clientIP(r), s.clientIP(r),
fmt.Sprintf( fmt.Sprintf(
"version=%s filename=%s bytes=%d sha256=%s enabled=%t", "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{ writeJSON(w, http.StatusCreated, map[string]any{
@@ -118,25 +158,62 @@ func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) {
"sha256": checksum, "sha256": checksum,
"bytes": size, "bytes": size,
"filename": fileName, "filename": fileName,
"warning": warning,
}) })
return 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"]) { if !validSlug(fields["slug"]) {
return errors.New("invalid package slug") return target, http.StatusBadRequest, errors.New("invalid package ID")
}
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")
} }
if !validRegistrySegment(fields["package_version"], 100) { 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 { func validRegistrySegment(value string, maxLength int) bool {
@@ -157,7 +234,8 @@ func validRegistrySegment(value string, maxLength int) bool {
func (s *Server) streamPackageToGitea( func (s *Server) streamPackageToGitea(
r *http.Request, r *http.Request,
fields map[string]string, packageName string,
packageVersion string,
fileName string, fileName string,
source io.Reader, source io.Reader,
) (string, int64, int, error) { ) (string, int64, int, error) {
@@ -166,8 +244,8 @@ func (s *Server) streamPackageToGitea(
fmt.Sprintf( fmt.Sprintf(
"/api/packages/%s/generic/%s/%s/%s", "/api/packages/%s/generic/%s/%s/%s",
url.PathEscape(s.cfg.GiteaPackageOwner), url.PathEscape(s.cfg.GiteaPackageOwner),
url.PathEscape(fields["package_name"]), url.PathEscape(packageName),
url.PathEscape(fields["package_version"]), url.PathEscape(packageVersion),
url.PathEscape(fileName), url.PathEscape(fileName),
), ),
) )
@@ -204,3 +282,40 @@ func (s *Server) streamPackageToGitea(
} }
return hex.EncodeToString(hasher.Sum(nil)), int64(size), http.StatusCreated, nil 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) request := httptest.NewRequest(http.MethodPost, "/portal/packages/upload", nil)
checksum, size, status, err := server.streamPackageToGitea( checksum, size, status, err := server.streamPackageToGitea(
request, request,
map[string]string{ "sentinelone-linux",
"package_name": "sentinelone-linux", "26.2.0.10",
"package_version": "26.2.0.10",
},
"agent.deb", "agent.deb",
strings.NewReader(content), strings.NewReader(content),
) )
@@ -89,3 +87,39 @@ func TestStreamPackageToGitea(t *testing.T) {
t.Errorf("status = %d", status) 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)
}
}