This commit is contained in:
David Schroeder
2026-07-25 14:51:48 -05:00
parent 1ea21c065e
commit 69cc236635
18 changed files with 770 additions and 106 deletions
+9 -3
View File
@@ -242,9 +242,15 @@ func (s *Server) requireTechnician(next http.HandlerFunc) http.HandlerFunc {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.Method != http.MethodGet && r.FormValue("csrf_token") != tech.CSRFToken {
http.Error(w, "invalid request token", http.StatusForbidden)
return
if r.Method != http.MethodGet {
csrfToken := r.Header.Get("X-CSRF-Token")
if csrfToken == "" {
csrfToken = r.FormValue("csrf_token")
}
if csrfToken != tech.CSRFToken {
http.Error(w, "invalid request token", http.StatusForbidden)
return
}
}
next(w, r)
}
+122 -26
View File
@@ -26,6 +26,11 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to list authorizations", http.StatusInternalServerError)
return
}
actions, err := s.listActions(r)
if err != nil {
http.Error(w, "unable to list installer actions", http.StatusInternalServerError)
return
}
auditEvents, err := s.listAuditEvents(r)
if err != nil {
http.Error(w, "unable to list audit events", http.StatusInternalServerError)
@@ -38,6 +43,7 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
Authorizations: authorizations,
AuditEvents: auditEvents,
Packages: packages,
Actions: actions,
NewCode: r.URL.Query().Get("code"),
DefaultHostLimit: s.cfg.DefaultHostLimit,
DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())),
@@ -45,6 +51,29 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) listActions(r *http.Request) ([]actionRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT slug, display_name, enabled
FROM installer_actions
ORDER BY sort_order, display_name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []actionRecord
for rows.Next() {
var record actionRecord
if err := rows.Scan(&record.Slug, &record.DisplayName, &record.Enabled); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (s *Server) listAuditEvents(r *http.Request) ([]auditRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
@@ -113,16 +142,25 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
rows, err := s.db.QueryContext(
r.Context(),
`SELECT a.id, a.code_hint, a.created_by, a.customer_label,
a.host_limit, COUNT(DISTINCT h.id), a.created_at,
a.expires_at, a.revoked_at,
COALESCE(GROUP_CONCAT(DISTINCT p.display_name
ORDER BY p.display_name SEPARATOR ', '), '')
a.host_limit,
(SELECT COUNT(*) FROM authorization_hosts h
WHERE h.authorization_id = a.id),
a.created_at, a.expires_at, a.revoked_at,
COALESCE((
SELECT GROUP_CONCAT(ap.display_name
ORDER BY ap.display_name SEPARATOR ', ')
FROM authorization_packages ap
WHERE ap.authorization_id = a.id
), ''),
COALESCE((
SELECT GROUP_CONCAT(ia.display_name
ORDER BY ia.sort_order, ia.display_name SEPARATOR ', ')
FROM authorization_actions aa
JOIN installer_actions ia ON ia.slug = aa.action_slug
WHERE aa.authorization_id = a.id
), '')
FROM authorizations a
LEFT JOIN authorization_hosts h ON h.authorization_id = a.id
LEFT JOIN authorization_packages ap ON ap.authorization_id = a.id
LEFT JOIN packages p ON p.id = ap.package_id
WHERE a.created_at > UTC_TIMESTAMP(6) - INTERVAL 30 DAY
GROUP BY a.id
ORDER BY a.created_at DESC
LIMIT 100`,
)
@@ -144,6 +182,7 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
&record.ExpiresAt,
&record.RevokedAt,
&record.Packages,
&record.Actions,
); err != nil {
return nil, err
}
@@ -165,8 +204,9 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return
}
packageIDs := r.Form["package_id"]
if len(packageIDs) == 0 {
http.Error(w, "select at least one package", http.StatusBadRequest)
actionSlugs := r.Form["action_slug"]
if len(packageIDs) == 0 && len(actionSlugs) == 0 {
http.Error(w, "select at least one package or installer action", http.StatusBadRequest)
return
}
code, err := deploymentCode()
@@ -208,8 +248,13 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
}
result, err := tx.ExecContext(
r.Context(),
`INSERT INTO authorization_packages (authorization_id, package_id)
SELECT ?, id FROM packages WHERE id = ? AND enabled = TRUE`,
`INSERT INTO authorization_packages
(authorization_id, package_id, package_slug, display_name,
package_name, package_version, file_name, sha256)
SELECT ?, id, slug, display_name, package_name, package_version,
file_name, sha256
FROM packages
WHERE id = ? AND enabled = TRUE`,
authorizationID, packageID,
)
if err != nil {
@@ -222,6 +267,29 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return
}
}
for _, actionSlug := range actionSlugs {
actionSlug = strings.TrimSpace(actionSlug)
if !validSlug(actionSlug) {
http.Error(w, "invalid installer action", http.StatusBadRequest)
return
}
result, err := tx.ExecContext(
r.Context(),
`INSERT INTO authorization_actions (authorization_id, action_slug)
SELECT ?, slug FROM installer_actions
WHERE slug = ? AND enabled = TRUE`,
authorizationID, actionSlug,
)
if err != nil {
http.Error(w, "unable to authorize installer action", http.StatusInternalServerError)
return
}
affected, _ := result.RowsAffected()
if affected != 1 {
http.Error(w, "selected installer action is unavailable", http.StatusBadRequest)
return
}
}
if err := tx.Commit(); err != nil {
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
return
@@ -278,23 +346,18 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
return
}
}
if !validSlug(values["slug"]) || !validSHA256(values["sha256"]) {
http.Error(w, "invalid slug or SHA-256", http.StatusBadRequest)
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"]) {
http.Error(w, "invalid package metadata", http.StatusBadRequest)
return
}
enabled := r.FormValue("enabled") == "1"
_, err := s.db.ExecContext(
r.Context(),
`INSERT INTO packages
(slug, display_name, package_name, package_version, file_name, sha256, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
package_name = VALUES(package_name),
package_version = VALUES(package_version),
file_name = VALUES(file_name),
sha256 = VALUES(sha256),
enabled = VALUES(enabled)`,
err := s.savePackage(
r,
values["slug"],
values["display_name"],
values["package_name"],
@@ -312,6 +375,39 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/portal?notice=Package+saved", http.StatusSeeOther)
}
func (s *Server) savePackage(
r *http.Request,
slug string,
displayName string,
packageName string,
packageVersion string,
fileName string,
sha256 string,
enabled bool,
) error {
_, err := s.db.ExecContext(
r.Context(),
`INSERT INTO packages
(slug, display_name, package_name, package_version, file_name, sha256, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
package_name = VALUES(package_name),
package_version = VALUES(package_version),
file_name = VALUES(file_name),
sha256 = VALUES(sha256),
enabled = VALUES(enabled)`,
slug,
displayName,
packageName,
packageVersion,
fileName,
sha256,
enabled,
)
return err
}
func validSlug(value string) bool {
if value == "" || len(value) > 100 {
return false
+25 -5
View File
@@ -19,11 +19,14 @@ type Config struct {
GiteaPackageOwner string
GiteaPackageUser string
GiteaPackageToken string
GiteaWriteUser string
GiteaWriteToken string
AllowedGiteaUsers map[string]struct{}
CookieSecret []byte
DefaultDuration time.Duration
DefaultHostLimit int
MaxHostLimit int
MaxUploadBytes int64
TrustProxyHeaders bool
}
@@ -38,6 +41,8 @@ func LoadConfig() (Config, error) {
cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI")
cfg.GiteaPackageUser = os.Getenv("TAPM_GITEA_PACKAGE_USERNAME")
cfg.GiteaPackageToken = os.Getenv("TAPM_GITEA_PACKAGE_TOKEN")
cfg.GiteaWriteUser = os.Getenv("TAPM_GITEA_PACKAGE_WRITE_USERNAME")
cfg.GiteaWriteToken = os.Getenv("TAPM_GITEA_PACKAGE_WRITE_TOKEN")
cfg.CookieSecret = []byte(os.Getenv("TAPM_COOKIE_SECRET"))
cfg.PublicURL, err = parseAbsoluteURL("TAPM_PUBLIC_URL")
@@ -66,6 +71,10 @@ func LoadConfig() (Config, error) {
if cfg.DefaultHostLimit < 1 || cfg.MaxHostLimit < cfg.DefaultHostLimit {
return cfg, fmt.Errorf("invalid default or maximum host limit")
}
cfg.MaxUploadBytes, err = envInt64("TAPM_MAX_UPLOAD_BYTES", 1<<30)
if err != nil || cfg.MaxUploadBytes < 1<<20 {
return cfg, fmt.Errorf("TAPM_MAX_UPLOAD_BYTES must be at least 1048576")
}
cfg.TrustProxyHeaders, err = strconv.ParseBool(
envDefault("TAPM_TRUST_PROXY_HEADERS", "false"),
@@ -86,11 +95,13 @@ func LoadConfig() (Config, error) {
}
required := map[string]string{
"TAPM_DATABASE_DSN": cfg.DatabaseDSN,
"TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID,
"TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret,
"TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser,
"TAPM_GITEA_PACKAGE_TOKEN": cfg.GiteaPackageToken,
"TAPM_DATABASE_DSN": cfg.DatabaseDSN,
"TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID,
"TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret,
"TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser,
"TAPM_GITEA_PACKAGE_TOKEN": cfg.GiteaPackageToken,
"TAPM_GITEA_PACKAGE_WRITE_USERNAME": cfg.GiteaWriteUser,
"TAPM_GITEA_PACKAGE_WRITE_TOKEN": cfg.GiteaWriteToken,
}
for name, value := range required {
if strings.TrimSpace(value) == "" {
@@ -131,3 +142,12 @@ func envInt(name string, fallback int) (int, error) {
}
return value, nil
}
func envInt64(name string, fallback int64) (int64, error) {
raw := envDefault(name, strconv.FormatInt(fallback, 10))
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s must be an integer", name)
}
return value, nil
}
+89 -7
View File
@@ -16,6 +16,8 @@ type exchangeRequest struct {
Code string `json:"code"`
HostFingerprint string `json:"host_fingerprint"`
Hostname string `json:"hostname"`
RequestedAction string `json:"requested_action,omitempty"`
RequestedPackage string `json:"requested_package,omitempty"`
}
type exchangePackage struct {
@@ -30,6 +32,7 @@ type exchangeResponse struct {
SessionToken string `json:"session_token"`
ExpiresAt time.Time `json:"expires_at"`
Packages []exchangePackage `json:"packages"`
Actions []string `json:"actions"`
}
func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
@@ -54,8 +57,12 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
request.Code = normalizeCode(request.Code)
request.HostFingerprint = strings.TrimSpace(request.HostFingerprint)
request.Hostname = strings.TrimSpace(request.Hostname)
request.RequestedAction = strings.TrimSpace(request.RequestedAction)
request.RequestedPackage = strings.TrimSpace(request.RequestedPackage)
if request.Code == "" || len(request.HostFingerprint) < 16 ||
request.Hostname == "" || len(request.Hostname) > 255 {
request.Hostname == "" || len(request.Hostname) > 255 ||
(request.RequestedAction != "" && !validSlug(request.RequestedAction)) ||
(request.RequestedPackage != "" && !validSlug(request.RequestedPackage)) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code and host identity are required"})
return
}
@@ -72,7 +79,16 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
writeJSON(w, status, map[string]string{"error": message})
return
}
_ = s.audit(r.Context(), "code_exchanged", "", &authorizationID, request.Hostname, "", sourceIP, "")
_ = s.audit(
r.Context(),
"code_exchanged",
"",
&authorizationID,
request.Hostname,
request.RequestedPackage,
sourceIP,
"requested_action="+request.RequestedAction,
)
writeJSON(w, http.StatusOK, response)
}
@@ -124,6 +140,43 @@ func (s *Server) exchangeDeploymentCode(
return response, 0, err
}
if request.RequestedAction != "" {
var authorized int
if err := tx.QueryRowContext(
r.Context(),
`SELECT COUNT(*)
FROM authorization_actions aa
JOIN installer_actions ia ON ia.slug = aa.action_slug
WHERE aa.authorization_id = ?
AND aa.action_slug = ?
AND ia.enabled = TRUE`,
authorizationID, request.RequestedAction,
).Scan(&authorized); err != nil {
return response, 0, err
}
if authorized != 1 {
return response, 0, errAuthorizationDenied
}
}
if request.RequestedPackage != "" {
var authorized int
if err := tx.QueryRowContext(
r.Context(),
`SELECT COUNT(*)
FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = ?
AND ap.package_slug = ?
AND p.enabled = TRUE`,
authorizationID, request.RequestedPackage,
).Scan(&authorized); err != nil {
return response, 0, err
}
if authorized != 1 {
return response, 0, errAuthorizationDenied
}
}
var hostID uint64
err = tx.QueryRowContext(
r.Context(),
@@ -188,7 +241,7 @@ func (s *Server) exchangeDeploymentCode(
rows, err := tx.QueryContext(
r.Context(),
`SELECT p.slug, p.display_name, p.package_version, p.sha256
`SELECT ap.package_slug, ap.display_name, ap.package_version, ap.sha256
FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = ? AND p.enabled = TRUE
@@ -216,7 +269,36 @@ func (s *Server) exchangeDeploymentCode(
if err := rows.Err(); err != nil {
return response, 0, err
}
if len(response.Packages) == 0 {
if err := rows.Close(); err != nil {
return response, 0, err
}
actionRows, err := tx.QueryContext(
r.Context(),
`SELECT ia.slug
FROM authorization_actions aa
JOIN installer_actions ia ON ia.slug = aa.action_slug
WHERE aa.authorization_id = ? AND ia.enabled = TRUE
ORDER BY ia.sort_order, ia.display_name`,
authorizationID,
)
if err != nil {
return response, 0, err
}
defer actionRows.Close()
for actionRows.Next() {
var slug string
if err := actionRows.Scan(&slug); err != nil {
return response, 0, err
}
response.Actions = append(response.Actions, slug)
}
if err := actionRows.Err(); err != nil {
return response, 0, err
}
if err := actionRows.Close(); err != nil {
return response, 0, err
}
if len(response.Packages) == 0 && len(response.Actions) == 0 {
return response, 0, errAuthorizationDenied
}
@@ -248,8 +330,8 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
var hostname string
err := s.db.QueryRowContext(
r.Context(),
`SELECT p.id, p.slug, p.display_name, p.package_name,
p.package_version, p.file_name, p.sha256, p.enabled,
`SELECT p.id, ap.package_slug, ap.display_name, ap.package_name,
ap.package_version, ap.file_name, ap.sha256, p.enabled,
ds.authorization_id, ah.hostname
FROM download_sessions ds
JOIN authorizations a ON a.id = ds.authorization_id
@@ -261,7 +343,7 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
AND ds.expires_at > UTC_TIMESTAMP(6)
AND a.revoked_at IS NULL
AND a.expires_at > UTC_TIMESTAMP(6)
AND p.slug = ?
AND ap.package_slug = ?
AND p.enabled = TRUE`,
sessionHash[:], slug,
).Scan(
+9
View File
@@ -48,6 +48,12 @@ type packageRecord struct {
Enabled bool
}
type actionRecord struct {
Slug string
DisplayName string
Enabled bool
}
type authorizationRecord struct {
ID uint64
CodeHint string
@@ -59,6 +65,7 @@ type authorizationRecord struct {
ExpiresAt time.Time
RevokedAt sql.NullTime
Packages string
Actions string
}
type auditRecord struct {
@@ -78,6 +85,7 @@ type pageData struct {
Authorizations []authorizationRecord
AuditEvents []auditRecord
Packages []packageRecord
Actions []actionRecord
NewCode string
DefaultHostLimit int
DefaultDuration string
@@ -161,6 +169,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization))
mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage))
mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage))
mux.HandleFunc("POST /api/v1/exchange", s.handleExchange)
mux.HandleFunc("GET /api/v1/packages/{slug}", s.handlePackageDownload)
mux.HandleFunc("GET /", s.handleHome)
+7
View File
@@ -25,6 +25,7 @@ body {
}
button, input { font: inherit; }
button:disabled { cursor: wait; opacity: .58; }
.topbar {
position: sticky;
@@ -208,6 +209,12 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.package-row small { color: var(--muted); }
.toggle { display: flex; align-items: center; gap: 9px; }
.empty, .fine-print { color: var(--muted); }
.package-forms { display: grid; gap: 26px; }
.form-title { margin: 0; color: var(--ink); font-weight: 850; }
.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; }
.manual-metadata[open] summary { margin-bottom: 22px; }
.audit-panel { margin-top: 24px; }
.audit-table { display: grid; max-height: 520px; overflow: auto; }
+32
View File
@@ -14,3 +14,35 @@ document.addEventListener("click", async (event) => {
button.textContent = "Copy failed";
}
});
document.addEventListener("submit", async (event) => {
const form = event.target.closest("[data-upload-form]");
if (!form) return;
event.preventDefault();
const button = form.querySelector("button[type=submit]");
const status = form.querySelector("[data-upload-status]");
const csrfToken = form.querySelector('input[name="csrf_token"]').value;
button.disabled = true;
status.textContent = "Uploading package… keep this page open.";
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: {"X-CSRF-Token": csrfToken},
credentials: "same-origin",
});
const result = await response.json().catch(() => ({}));
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);
} catch (error) {
status.textContent = error.message;
button.disabled = false;
}
});
+54 -16
View File
@@ -42,8 +42,8 @@
<p class="eyebrow">Controlled package delivery</p>
<h1>Open a deployment window.</h1>
<p>
Authorize a limited number of hosts for selected packages. Access
closes automatically at the end of the window.
Authorize a limited number of hosts for selected packages and
installer actions. Access closes automatically at the end of the window.
</p>
</div>
<div class="policy-card">
@@ -97,6 +97,23 @@
{{end}}
</fieldset>
<fieldset>
<legend>Allowed installer actions</legend>
{{range .Actions}}
{{if .Enabled}}
<label class="package-choice">
<input type="checkbox" name="action_slug" value="{{.Slug}}">
<span>
<strong>{{.DisplayName}}</strong>
<small>Authorization gate</small>
</span>
</label>
{{end}}
{{else}}
<p class="empty">No installer actions have been enabled.</p>
{{end}}
</fieldset>
<button class="button primary" type="submit">Create 3-hour code</button>
</form>
</section>
@@ -122,7 +139,8 @@
<dl>
<div><dt>Hosts</dt><dd>{{.HostCount}} / {{.HostLimit}}</dd></div>
<div><dt>Expires</dt><dd>{{formatTime .ExpiresAt}}</dd></div>
<div><dt>Packages</dt><dd>{{.Packages}}</dd></div>
<div><dt>Packages</dt><dd>{{if .Packages}}{{.Packages}}{{else}}None{{end}}</dd></div>
<div><dt>Actions</dt><dd>{{if .Actions}}{{.Actions}}{{else}}None{{end}}</dd></div>
<div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div>
</dl>
{{if isActive .}}
@@ -162,19 +180,39 @@
{{end}}
</div>
<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>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>
<button class="button secondary" type="submit">Save package metadata</button>
</form>
<div class="package-forms">
<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>
<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>Installer file <input name="package_file" type="file" required></label>
<button class="button primary" type="submit">Upload and register package</button>
<p class="upload-status" data-upload-status aria-live="polite"></p>
</form>
<details class="manual-metadata">
<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>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>
<button class="button secondary" type="submit">Save package metadata</button>
</form>
</details>
</div>
</div>
</section>
+202
View File
@@ -0,0 +1,202 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
const uploadFormOverhead = 1 << 20
type byteCounter int64
func (counter *byteCounter) Write(value []byte) (int, error) {
*counter += byteCounter(len(value))
return len(value), nil
}
func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
if r.ContentLength > s.cfg.MaxUploadBytes+uploadFormOverhead {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "package exceeds the upload limit"})
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+uploadFormOverhead)
reader, err := r.MultipartReader()
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "multipart upload is required"})
return
}
fields := make(map[string]string)
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "package file is required"})
return
}
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unable to read upload"})
return
}
if part.FileName() == "" {
value, err := io.ReadAll(io.LimitReader(part, 4097))
_ = part.Close()
if err != nil || len(value) > 4096 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid upload field"})
return
}
fields[part.FormName()] = strings.TrimSpace(string(value))
continue
}
if part.FormName() != "package_file" {
_ = part.Close()
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unexpected uploaded file"})
return
}
fileName := part.FileName()
if !validRegistrySegment(fileName, 255) {
_ = part.Close()
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid package filename"})
return
}
if err := validateUploadFields(fields); err != nil {
_ = part.Close()
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
checksum, size, status, err := s.streamPackageToGitea(r, fields, fileName, part)
_ = part.Close()
if err != nil {
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
status = http.StatusRequestEntityTooLarge
}
_ = s.audit(r.Context(), "package_upload_failed", tech.Login, nil, "", fields["slug"], s.clientIP(r), err.Error())
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
enabled := fields["enabled"] == "1"
if err := s.savePackage(
r,
fields["slug"],
fields["display_name"],
fields["package_name"],
fields["package_version"],
fileName,
checksum,
enabled,
); err != nil {
_ = 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
}
_ = s.audit(
r.Context(),
"package_uploaded",
tech.Login,
nil,
"",
fields["slug"],
s.clientIP(r),
fmt.Sprintf(
"version=%s filename=%s bytes=%d sha256=%s enabled=%t",
fields["package_version"], fileName, size, checksum, enabled,
),
)
writeJSON(w, http.StatusCreated, map[string]any{
"status": "uploaded",
"sha256": checksum,
"bytes": size,
"filename": fileName,
})
return
}
}
func validateUploadFields(fields map[string]string) error {
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")
}
if !validRegistrySegment(fields["package_version"], 100) {
return errors.New("invalid package version")
}
return nil
}
func validRegistrySegment(value string, maxLength int) bool {
if value == "" || len(value) > maxLength {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') &&
(character < 'A' || character > 'Z') &&
(character < '0' || character > '9') &&
character != '.' && character != '-' &&
character != '+' && character != '_' {
return false
}
}
return true
}
func (s *Server) streamPackageToGitea(
r *http.Request,
fields map[string]string,
fileName string,
source io.Reader,
) (string, int64, int, error) {
registryURL := joinURL(
s.cfg.GiteaURL,
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(fileName),
),
)
hasher := sha256.New()
var size byteCounter
body := io.TeeReader(source, io.MultiWriter(hasher, &size))
request, err := http.NewRequestWithContext(r.Context(), http.MethodPut, registryURL, body)
if err != nil {
return "", 0, http.StatusInternalServerError, errors.New("unable to prepare registry upload")
}
request.Header.Set("Content-Type", "application/octet-stream")
request.SetBasicAuth(s.cfg.GiteaWriteUser, s.cfg.GiteaWriteToken)
response, err := s.packageClient.Do(request)
if err != nil {
return "", int64(size), http.StatusBadGateway, errors.New("package registry upload failed")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8192))
switch response.StatusCode {
case http.StatusCreated:
case http.StatusConflict:
return "", int64(size), http.StatusConflict, errors.New("that package version and filename already exist")
case http.StatusUnauthorized, http.StatusForbidden:
return "", int64(size), http.StatusBadGateway, errors.New("package registry rejected the publisher credentials")
default:
return "", int64(size), http.StatusBadGateway, fmt.Errorf("package registry returned %s", response.Status)
}
return hex.EncodeToString(hasher.Sum(nil)), int64(size), http.StatusCreated, nil
}
+91
View File
@@ -0,0 +1,91 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestValidRegistrySegment(t *testing.T) {
t.Parallel()
valid := []string{"sentinelone-linux", "26.2.0.10", "agent_x86_64+release.deb"}
for _, value := range valid {
if !validRegistrySegment(value, 255) {
t.Errorf("expected %q to be valid", value)
}
}
invalid := []string{"", "../agent.deb", "agent/name.deb", "agent name.deb"}
for _, value := range invalid {
if validRegistrySegment(value, 255) {
t.Errorf("expected %q to be invalid", value)
}
}
}
func TestStreamPackageToGitea(t *testing.T) {
t.Parallel()
content := "test package content"
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("method = %s, want PUT", r.Method)
}
if r.URL.Path != "/api/packages/TAI/generic/sentinelone-linux/26.2.0.10/agent.deb" {
t.Errorf("path = %s", r.URL.Path)
}
username, password, ok := r.BasicAuth()
if !ok || username != "publisher" || password != "write-token" {
t.Errorf("unexpected registry credentials")
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != content {
t.Errorf("body = %q", body)
}
w.WriteHeader(http.StatusCreated)
}))
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)
checksum, size, status, err := server.streamPackageToGitea(
request,
map[string]string{
"package_name": "sentinelone-linux",
"package_version": "26.2.0.10",
},
"agent.deb",
strings.NewReader(content),
)
if err != nil {
t.Fatal(err)
}
expected := sha256.Sum256([]byte(content))
if checksum != hex.EncodeToString(expected[:]) {
t.Errorf("checksum = %s", checksum)
}
if size != int64(len(content)) {
t.Errorf("size = %d", size)
}
if status != http.StatusCreated {
t.Errorf("status = %d", status)
}
}