update
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user