update
This commit is contained in:
+9
-3
@@ -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.
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<div class="field-row">
|
||||
<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>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>Version <input name="package_version" placeholder="26.1.1.31" 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 class="toggle"><input type="checkbox" name="enabled" value="1"> Enable for new authorizations</label>
|
||||
|
||||
+140
-25
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user