From 69cc2366358ad42b31991a6bf6d76f2ba579ea6a Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 14:51:48 -0500 Subject: [PATCH] update --- .env.example | 3 + README.md | 10 +- cmd/server/main.go | 4 +- compose.yaml | 2 +- deploy/nginx/tapm.scity.us.conf | 2 +- docs/client-api.md | 19 ++- docs/deployment.md | 81 +++++------ internal/app/auth.go | 12 +- internal/app/authorization.go | 148 ++++++++++++++++---- internal/app/config.go | 30 +++- internal/app/download.go | 96 ++++++++++++- internal/app/server.go | 9 ++ internal/app/static/app.css | 7 + internal/app/static/app.js | 32 +++++ internal/app/templates/portal.html | 70 +++++++--- internal/app/upload.go | 202 +++++++++++++++++++++++++++ internal/app/upload_test.go | 91 ++++++++++++ migrations/002_installer_actions.sql | 58 ++++++++ 18 files changed, 770 insertions(+), 106 deletions(-) create mode 100644 internal/app/upload.go create mode 100644 internal/app/upload_test.go create mode 100644 migrations/002_installer_actions.sql 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

Open a deployment window.

- 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.

@@ -97,6 +97,23 @@ {{end}} +
+ Allowed installer actions + {{range .Actions}} + {{if .Enabled}} + + {{end}} + {{else}} +

No installer actions have been enabled.

+ {{end}} +
+ @@ -122,7 +139,8 @@
Hosts
{{.HostCount}} / {{.HostLimit}}
Expires
{{formatTime .ExpiresAt}}
-
Packages
{{.Packages}}
+
Packages
{{if .Packages}}{{.Packages}}{{else}}None{{end}}
+
Actions
{{if .Actions}}{{.Actions}}{{else}}None{{end}}
Created by
{{.CreatedBy}}
{{if isActive .}} @@ -162,19 +180,39 @@ {{end}}
-
- - - -
- - -
- - - - -
+
+
+ +

Upload a new version

+ + +
+ + +
+ + + +

+
+ + +
diff --git a/internal/app/upload.go b/internal/app/upload.go new file mode 100644 index 0000000..75bc376 --- /dev/null +++ b/internal/app/upload.go @@ -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 +} diff --git a/internal/app/upload_test.go b/internal/app/upload_test.go new file mode 100644 index 0000000..ee7aa82 --- /dev/null +++ b/internal/app/upload_test.go @@ -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) + } +} diff --git a/migrations/002_installer_actions.sql b/migrations/002_installer_actions.sql new file mode 100644 index 0000000..c0b99a0 --- /dev/null +++ b/migrations/002_installer_actions.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS installer_actions ( + slug VARCHAR(100) NOT NULL PRIMARY KEY, + display_name VARCHAR(255) NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) + ON UPDATE CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS authorization_actions ( + authorization_id BIGINT UNSIGNED NOT NULL, + action_slug VARCHAR(100) NOT NULL, + PRIMARY KEY (authorization_id, action_slug), + CONSTRAINT fk_authorization_actions_authorization + FOREIGN KEY (authorization_id) REFERENCES authorizations(id) + ON DELETE CASCADE, + CONSTRAINT fk_authorization_actions_action + FOREIGN KEY (action_slug) REFERENCES installer_actions(slug) + ON DELETE RESTRICT +) ENGINE=InnoDB; + +ALTER TABLE authorization_packages + ADD COLUMN IF NOT EXISTS package_slug VARCHAR(100) NULL, + ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS package_name VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS package_version VARCHAR(100) NULL, + ADD COLUMN IF NOT EXISTS file_name VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS sha256 CHAR(64) NULL; + +UPDATE authorization_packages ap +JOIN packages p ON p.id = ap.package_id +SET ap.package_slug = p.slug, + ap.display_name = p.display_name, + ap.package_name = p.package_name, + ap.package_version = p.package_version, + ap.file_name = p.file_name, + ap.sha256 = p.sha256 +WHERE ap.package_slug IS NULL; + +ALTER TABLE authorization_packages + MODIFY package_slug VARCHAR(100) NOT NULL, + MODIFY display_name VARCHAR(255) NOT NULL, + MODIFY package_name VARCHAR(255) NOT NULL, + MODIFY package_version VARCHAR(100) NOT NULL, + MODIFY file_name VARCHAR(255) NOT NULL, + MODIFY sha256 CHAR(64) NOT NULL; + +INSERT INTO installer_actions (slug, display_name, sort_order, enabled) +VALUES + ('install-rmm', 'Install ConnectWise RMM agent', 10, TRUE), + ('install-acronis', 'Install Acronis agent', 20, TRUE), + ('install-screenconnect', 'Install ScreenConnect agent', 30, TRUE) +ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), + sort_order = VALUES(sort_order); + +INSERT IGNORE INTO schema_migrations (version) VALUES (2);