diff --git a/.env.example b/.env.example index 8032f08..f8e8007 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,12 @@ TAPM_GITEA_CLIENT_SECRET=replace-me TAPM_GITEA_PACKAGE_OWNER=TAI TAPM_GITEA_PACKAGE_USERNAME=tapm-packages TAPM_GITEA_PACKAGE_TOKEN=replace-me +TAPM_GITEA_PACKAGE_WRITE_USERNAME=tapm-publisher +TAPM_GITEA_PACKAGE_WRITE_TOKEN=replace-me TAPM_ALLOWED_GITEA_USERS=taiadmin TAPM_COOKIE_SECRET=replace-with-at-least-32-random-bytes TAPM_DEFAULT_DURATION=3h TAPM_DEFAULT_HOST_LIMIT=3 TAPM_MAX_HOST_LIMIT=25 +TAPM_MAX_UPLOAD_BYTES=1073741824 TAPM_TRUST_PROXY_HEADERS=true diff --git a/README.md b/README.md index 64b490f..5e3a442 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # TAPM Deployment Access TAPM Deployment Access is a short-lived authorization broker for protected -deployment packages. Technicians authenticate with Gitea, create a deployment -code, and permit a limited number of hosts to download explicitly selected -packages during a fixed authorization window. +deployment packages and installer actions. Technicians authenticate with +Gitea, create a deployment code, and permit a limited number of hosts to use +explicitly selected capabilities during a fixed authorization window. Default policy: @@ -20,11 +20,11 @@ The ProxMenu integration contract is documented in ## Components -- `cmd/server`: portal, OAuth flow, authorization API, and protected downloads +- `cmd/server`: portal, OAuth flow, authorization API, uploads, and downloads - `cmd/migrate`: ordered MariaDB schema migrations with an advisory lock - `internal/app`: application, security, and registry proxy logic - `deploy/nginx`: TLS reverse-proxy example -- `compose.yaml`: hardened, loopback-only container deployment +- `compose.yaml`: hardened, host-networked container deployment ## Local checks diff --git a/cmd/server/main.go b/cmd/server/main.go index 1acd7dc..ca92e17 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,8 +29,8 @@ func main() { Addr: cfg.ListenAddr, Handler: server.Routes(), ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 30 * time.Second, - WriteTimeout: 15 * time.Minute, + ReadTimeout: 30 * time.Minute, + WriteTimeout: 30 * time.Minute, IdleTimeout: 90 * time.Second, MaxHeaderBytes: 1 << 20, } diff --git a/compose.yaml b/compose.yaml index a4782b9..49e34e5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,7 +22,7 @@ services: tmpfs: - /tmp:size=16m,mode=1777 healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/health/ready"] + test: ["CMD-SHELL", "wget -q -O - \"http://${TAPM_LISTEN_ADDR}/health/ready\""] interval: 30s timeout: 5s retries: 3 diff --git a/deploy/nginx/tapm.scity.us.conf b/deploy/nginx/tapm.scity.us.conf index d262281..ed2cebb 100644 --- a/deploy/nginx/tapm.scity.us.conf +++ b/deploy/nginx/tapm.scity.us.conf @@ -5,7 +5,7 @@ server { ssl_certificate /etc/nginx/ssl/tapm.scity.us/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/tapm.scity.us/privkey.pem; - client_max_body_size 64k; + client_max_body_size 1g; location / { proxy_pass http://127.0.0.1:8080; diff --git a/docs/client-api.md b/docs/client-api.md index 19482f4..7a32a14 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -11,7 +11,9 @@ credentials. { "code": "TAPM-ABCDE-FGHIJ", "host_fingerprint": "sha256-of-the-host-machine-id", - "hostname": "pve01" + "hostname": "pve01", + "requested_action": "install-rmm", + "requested_package": "" } ``` @@ -29,6 +31,11 @@ Successful response: "sha256": "64-lowercase-hexadecimal-characters", "download_url": "https://tapm.scity.us/api/v1/packages/sentinelone-linux" } + ], + "actions": [ + "install-rmm", + "install-acronis", + "install-screenconnect" ] } ``` @@ -36,6 +43,16 @@ Successful response: The first exchange enrolls that host against the authorization. Reusing the same code and host fingerprint does not consume another host slot. A different fingerprint consumes one slot until the configured limit is reached. +When `requested_action` or `requested_package` is present, the broker validates +that entitlement before enrolling the host. Older clients may omit both fields, +but current clients should always identify what they intend to use. + +An installer must proceed only when its required package slug or action slug is +present in this response. The current action slugs are: + +- `install-rmm` +- `install-acronis` +- `install-screenconnect` ## Download a package diff --git a/docs/deployment.md b/docs/deployment.md index e3f1665..4fdb4a0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,16 +1,22 @@ # Deployment -This service is intended to run on both webserver VMs, with only the active -webserver's broker container started. Both installations use the same MariaDB -Galera database, Gitea application, package registry, and secrets. The -containers use Linux host networking so `127.0.0.1:3306` reaches each -webserver's local HAProxy listener. +This service runs active-active on both webserver VMs. Both installations use +the same MariaDB Galera database, Gitea application, package registry, and +secrets. The containers use Linux host networking so `127.0.0.1:3306` reaches +each webserver's local HAProxy listener. ## 1. DNS and TLS Create `tapm.scity.us` in PowerDNS and point it at the existing HAProxy frontend. Install the certificate using the existing certificate automation. -The broker only listens on `127.0.0.1:8080`; Nginx terminates TLS. +Set `TAPM_LISTEN_ADDR` to each node's physical address when the load balancer +connects directly to port 8080: + +- Webserver-Node1: `10.10.1.121:8080` +- Webserver-Node2: `10.10.1.122:8080` + +Restrict port 8080 to the load-balancer addresses. If Nginx terminates TLS on +each webserver instead, keep the broker on `127.0.0.1:8080`. An example virtual host is in [`deploy/nginx/tapm.scity.us.conf`](../deploy/nginx/tapm.scity.us.conf). @@ -48,14 +54,16 @@ Create these separately: 2. A `tapm-packages` service account with read access only to the private `TAI/files` package owner. Create a personal access token for this account and store it only in the broker environment file. -3. An OAuth2 application with callback URL +3. A `tapm-publisher` service account with `write:package` access. Its token is + used only by authenticated portal uploads. +4. An OAuth2 application with callback URL `https://tapm.scity.us/auth/callback`. -The OAuth client secret authenticates the portal to Gitea. The package token -is used only server-side while proxying an authorized file. Neither value is -returned to a technician or Proxmox host. The broker requests only Gitea's -`read:user` OAuth scope, which it uses to confirm the signed-in username -against `TAPM_ALLOWED_GITEA_USERS`. +The OAuth client secret authenticates the portal to Gitea. The read token is +used only while proxying an authorized file, and the write token is used only +for authenticated uploads. None are returned to a technician or Proxmox host. +The broker requests only Gitea's `read:user` OAuth scope, which it uses to +confirm the signed-in username against `TAPM_ALLOWED_GITEA_USERS`. ## 4. Broker configuration @@ -68,8 +76,8 @@ cp .env.example .env chmod 600 .env ``` -Edit `.env` and use the same values on both webservers. Generate the cookie -secret with: +Edit `.env` and use the same secrets on both webservers. The listen address is +the one node-specific value. Generate the cookie secret with: ```sh openssl rand -base64 48 @@ -97,29 +105,16 @@ docker compose run --rm migrate Migrations use a MariaDB advisory lock, so an accidental simultaneous run cannot apply the same migration twice. -## 6. Start the active instance +## 6. Start both instances -On the active webserver: +On both webservers: ```sh docker compose up -d broker -curl --fail http://127.0.0.1:8080/health/ready +curl --fail "http://${TAPM_LISTEN_ADDR}/health/ready" ``` -On the standby webserver, keep the image and `.env` current but leave the -broker stopped. The existing failover automation can run: - -```sh -docker compose stop broker -``` - -on the old active VM, followed by: - -```sh -docker compose up -d broker -``` - -on the new active VM. There is no local application state to transfer. +There is no local application state and no session affinity requirement. ## 7. HAProxy health check @@ -127,13 +122,21 @@ The load balancer should consider an origin healthy only when `GET /health/ready` returns HTTP 200. `/health/live` checks only the process; `/health/ready` also verifies Galera connectivity. -Only the active webserver should be eligible for traffic. Retaining a single -active origin avoids duplicate service management while Galera preserves -sessions and authorization state across failover. +Both healthy webservers can receive traffic. `leastconn` is useful because +package uploads and downloads stay open longer than portal requests. ## 8. Publish a protected package -Publish files to the private Gitea Generic Package Registry: +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 +on local disk. + +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. + +For recovery, a package can still be published directly: ```sh curl --user 'publisher:PACKAGE_WRITE_TOKEN' \ @@ -147,15 +150,15 @@ Calculate its checksum: sha256sum SentinelAgent_linux_x86_64_v26_1_1_31.deb ``` -Sign in at `https://tapm.scity.us`, enter the matching registry package name, -version, filename, and SHA-256, then enable it. The broker intentionally does -not browse the registry with the technician's credentials. +Sign in at `https://tapm.scity.us`, expand **Register package metadata +manually**, enter the matching values, and enable it. The broker never uses the +technician's Gitea credentials for package operations. ## 9. Backup and rotation - Include `tapm_broker` in the existing Galera backup policy. - Keep `.env` outside Git and readable only by the service administrator. -- Rotate the Gitea package token and OAuth secret if either webserver is +- Rotate both Gitea package tokens and the OAuth secret if either webserver is compromised. - Changing `TAPM_COOKIE_SECRET` signs out portal users; it does not invalidate active deployment codes. diff --git a/internal/app/auth.go b/internal/app/auth.go index 7b32c5a..8dba1d7 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -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) } diff --git a/internal/app/authorization.go b/internal/app/authorization.go index 2bd6f3c..ca91a43 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -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 diff --git a/internal/app/config.go b/internal/app/config.go index a54fabf..1afc7a8 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -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 +} diff --git a/internal/app/download.go b/internal/app/download.go index dd2bd2b..2df3ccf 100644 --- a/internal/app/download.go +++ b/internal/app/download.go @@ -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( diff --git a/internal/app/server.go b/internal/app/server.go index 53d5049..58dc4f0 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -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) diff --git a/internal/app/static/app.css b/internal/app/static/app.css index d903a4a..293b2c4 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -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; } diff --git a/internal/app/static/app.js b/internal/app/static/app.js index eb6dcde..ed2c7ae 100644 --- a/internal/app/static/app.js +++ b/internal/app/static/app.js @@ -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; + } +}); diff --git a/internal/app/templates/portal.html b/internal/app/templates/portal.html index ff5b24b..05ba258 100644 --- a/internal/app/templates/portal.html +++ b/internal/app/templates/portal.html @@ -42,8 +42,8 @@
Controlled package delivery
- 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.