initial upload
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
|
||||
tech, err := s.currentTechnician(r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
packages, err := s.listPackages(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to list packages", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
authorizations, err := s.listAuthorizations(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to list authorizations", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auditEvents, err := s.listAuditEvents(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to list audit events", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "portal.html", pageData{
|
||||
Title: "Deployment Access",
|
||||
Technician: tech,
|
||||
CSRFToken: tech.CSRFToken,
|
||||
Authorizations: authorizations,
|
||||
AuditEvents: auditEvents,
|
||||
Packages: packages,
|
||||
NewCode: r.URL.Query().Get("code"),
|
||||
DefaultHostLimit: s.cfg.DefaultHostLimit,
|
||||
DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())),
|
||||
Notice: r.URL.Query().Get("notice"),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) listAuditEvents(r *http.Request) ([]auditRecord, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
r.Context(),
|
||||
`SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at
|
||||
FROM audit_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []auditRecord
|
||||
for rows.Next() {
|
||||
var record auditRecord
|
||||
if err := rows.Scan(
|
||||
&record.EventType,
|
||||
&record.Actor,
|
||||
&record.Hostname,
|
||||
&record.PackageSlug,
|
||||
&record.SourceIP,
|
||||
&record.Details,
|
||||
&record.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) listPackages(r *http.Request) ([]packageRecord, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
r.Context(),
|
||||
`SELECT id, slug, display_name, package_name, package_version,
|
||||
file_name, sha256, enabled
|
||||
FROM packages
|
||||
ORDER BY display_name, package_version`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var records []packageRecord
|
||||
for rows.Next() {
|
||||
var record packageRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.Slug,
|
||||
&record.DisplayName,
|
||||
&record.PackageName,
|
||||
&record.PackageVersion,
|
||||
&record.FileName,
|
||||
&record.SHA256,
|
||||
&record.Enabled,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, error) {
|
||||
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 ', '), '')
|
||||
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`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var records []authorizationRecord
|
||||
for rows.Next() {
|
||||
var record authorizationRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.CodeHint,
|
||||
&record.CreatedBy,
|
||||
&record.CustomerLabel,
|
||||
&record.HostLimit,
|
||||
&record.HostCount,
|
||||
&record.CreatedAt,
|
||||
&record.ExpiresAt,
|
||||
&record.RevokedAt,
|
||||
&record.Packages,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
tech, _ := s.currentTechnician(r)
|
||||
hostLimit, err := strconv.Atoi(r.FormValue("host_limit"))
|
||||
if err != nil || hostLimit < 1 || hostLimit > s.cfg.MaxHostLimit {
|
||||
http.Error(w, "invalid host limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
durationHours, err := strconv.Atoi(r.FormValue("duration_hours"))
|
||||
if err != nil || durationHours < 1 || durationHours > 24 {
|
||||
http.Error(w, "duration must be between 1 and 24 hours", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
packageIDs := r.Form["package_id"]
|
||||
if len(packageIDs) == 0 {
|
||||
http.Error(w, "select at least one package", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code, err := deploymentCode()
|
||||
if err != nil {
|
||||
http.Error(w, "unable to generate code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
codeHash := hashValue(code)
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(durationHours) * time.Hour)
|
||||
customerLabel := strings.TrimSpace(r.FormValue("customer_label"))
|
||||
if len(customerLabel) > 255 {
|
||||
http.Error(w, "customer label is too long", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to create authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(
|
||||
r.Context(),
|
||||
`INSERT INTO authorizations
|
||||
(code_hash, code_hint, created_by, customer_label, host_limit, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
codeHash[:], code[len(code)-5:], tech.Login, customerLabel, hostLimit, expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to create authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
authorizationID, _ := result.LastInsertId()
|
||||
for _, rawID := range packageIDs {
|
||||
packageID, err := strconv.ParseUint(rawID, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid package selection", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := tx.ExecContext(
|
||||
r.Context(),
|
||||
`INSERT INTO authorization_packages (authorization_id, package_id)
|
||||
SELECT ?, id FROM packages WHERE id = ? AND enabled = TRUE`,
|
||||
authorizationID, packageID,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to authorize package", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected != 1 {
|
||||
http.Error(w, "selected package is unavailable", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id := uint64(authorizationID)
|
||||
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r),
|
||||
fmt.Sprintf("host_limit=%d duration_hours=%d customer=%q", hostLimit, durationHours, customerLabel))
|
||||
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
tech, _ := s.currentTechnician(r)
|
||||
id, err := parseUintPath(r, "id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid authorization", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := s.db.ExecContext(
|
||||
r.Context(),
|
||||
`UPDATE authorizations
|
||||
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(6))
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to revoke authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
http.Error(w, "authorization not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = s.db.ExecContext(
|
||||
r.Context(),
|
||||
`UPDATE download_sessions SET revoked_at = UTC_TIMESTAMP(6)
|
||||
WHERE authorization_id = ? AND revoked_at IS NULL`,
|
||||
id,
|
||||
)
|
||||
_ = s.audit(r.Context(), "authorization_revoked", tech.Login, &id, "", "", s.clientIP(r), "")
|
||||
http.Redirect(w, r, "/portal?notice=Authorization+revoked", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
values := make(map[string]string)
|
||||
for _, field := range fields {
|
||||
values[field] = strings.TrimSpace(r.FormValue(field))
|
||||
if values[field] == "" {
|
||||
http.Error(w, field+" is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !validSlug(values["slug"]) || !validSHA256(values["sha256"]) {
|
||||
http.Error(w, "invalid slug or SHA-256", 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)`,
|
||||
values["slug"],
|
||||
values["display_name"],
|
||||
values["package_name"],
|
||||
values["package_version"],
|
||||
values["file_name"],
|
||||
strings.ToLower(values["sha256"]),
|
||||
enabled,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to save package", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.audit(r.Context(), "package_saved", tech.Login, nil, "", values["slug"], s.clientIP(r),
|
||||
fmt.Sprintf("version=%s enabled=%t", values["package_version"], enabled))
|
||||
http.Redirect(w, r, "/portal?notice=Package+saved", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func validSlug(value string) bool {
|
||||
if value == "" || len(value) > 100 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if (character < 'a' || character > 'z') &&
|
||||
(character < '0' || character > '9') &&
|
||||
character != '-' && character != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, character := range strings.ToLower(value) {
|
||||
if (character < '0' || character > '9') &&
|
||||
(character < 'a' || character > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) audit(
|
||||
ctx context.Context,
|
||||
eventType string,
|
||||
actor string,
|
||||
authorizationID *uint64,
|
||||
hostname string,
|
||||
packageSlug string,
|
||||
sourceIP string,
|
||||
details string,
|
||||
) error {
|
||||
var authID any
|
||||
if authorizationID != nil {
|
||||
authID = *authorizationID
|
||||
}
|
||||
_, err := s.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO audit_events
|
||||
(event_type, actor, authorization_id, hostname, package_slug, source_ip, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
eventType, actor, authID, hostname, packageSlug, sourceIP, details,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
var errAuthorizationDenied = errors.New("authorization denied")
|
||||
Reference in New Issue
Block a user