This commit is contained in:
2026-07-25 14:51:48 -05:00
parent 1ea21c065e
commit 69cc236635
18 changed files with 770 additions and 106 deletions
+3
View File
@@ -7,9 +7,12 @@ TAPM_GITEA_CLIENT_SECRET=replace-me
TAPM_GITEA_PACKAGE_OWNER=TAI TAPM_GITEA_PACKAGE_OWNER=TAI
TAPM_GITEA_PACKAGE_USERNAME=tapm-packages TAPM_GITEA_PACKAGE_USERNAME=tapm-packages
TAPM_GITEA_PACKAGE_TOKEN=replace-me 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_ALLOWED_GITEA_USERS=taiadmin
TAPM_COOKIE_SECRET=replace-with-at-least-32-random-bytes TAPM_COOKIE_SECRET=replace-with-at-least-32-random-bytes
TAPM_DEFAULT_DURATION=3h TAPM_DEFAULT_DURATION=3h
TAPM_DEFAULT_HOST_LIMIT=3 TAPM_DEFAULT_HOST_LIMIT=3
TAPM_MAX_HOST_LIMIT=25 TAPM_MAX_HOST_LIMIT=25
TAPM_MAX_UPLOAD_BYTES=1073741824
TAPM_TRUST_PROXY_HEADERS=true TAPM_TRUST_PROXY_HEADERS=true
+5 -5
View File
@@ -1,9 +1,9 @@
# TAPM Deployment Access # TAPM Deployment Access
TAPM Deployment Access is a short-lived authorization broker for protected TAPM Deployment Access is a short-lived authorization broker for protected
deployment packages. Technicians authenticate with Gitea, create a deployment deployment packages and installer actions. Technicians authenticate with
code, and permit a limited number of hosts to download explicitly selected Gitea, create a deployment code, and permit a limited number of hosts to use
packages during a fixed authorization window. explicitly selected capabilities during a fixed authorization window.
Default policy: Default policy:
@@ -20,11 +20,11 @@ The ProxMenu integration contract is documented in
## Components ## 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 - `cmd/migrate`: ordered MariaDB schema migrations with an advisory lock
- `internal/app`: application, security, and registry proxy logic - `internal/app`: application, security, and registry proxy logic
- `deploy/nginx`: TLS reverse-proxy example - `deploy/nginx`: TLS reverse-proxy example
- `compose.yaml`: hardened, loopback-only container deployment - `compose.yaml`: hardened, host-networked container deployment
## Local checks ## Local checks
+2 -2
View File
@@ -29,8 +29,8 @@ func main() {
Addr: cfg.ListenAddr, Addr: cfg.ListenAddr,
Handler: server.Routes(), Handler: server.Routes(),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Minute,
WriteTimeout: 15 * time.Minute, WriteTimeout: 30 * time.Minute,
IdleTimeout: 90 * time.Second, IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 1 << 20, MaxHeaderBytes: 1 << 20,
} }
+1 -1
View File
@@ -22,7 +22,7 @@ services:
tmpfs: tmpfs:
- /tmp:size=16m,mode=1777 - /tmp:size=16m,mode=1777
healthcheck: 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
+1 -1
View File
@@ -5,7 +5,7 @@ server {
ssl_certificate /etc/nginx/ssl/tapm.scity.us/fullchain.pem; ssl_certificate /etc/nginx/ssl/tapm.scity.us/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/tapm.scity.us/privkey.pem; ssl_certificate_key /etc/nginx/ssl/tapm.scity.us/privkey.pem;
client_max_body_size 64k; client_max_body_size 1g;
location / { location / {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8080;
+18 -1
View File
@@ -11,7 +11,9 @@ credentials.
{ {
"code": "TAPM-ABCDE-FGHIJ", "code": "TAPM-ABCDE-FGHIJ",
"host_fingerprint": "sha256-of-the-host-machine-id", "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", "sha256": "64-lowercase-hexadecimal-characters",
"download_url": "https://tapm.scity.us/api/v1/packages/sentinelone-linux" "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 The first exchange enrolls that host against the authorization. Reusing the
same code and host fingerprint does not consume another host slot. A different same code and host fingerprint does not consume another host slot. A different
fingerprint consumes one slot until the configured limit is reached. 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 ## Download a package
+42 -39
View File
@@ -1,16 +1,22 @@
# Deployment # Deployment
This service is intended to run on both webserver VMs, with only the active This service runs active-active on both webserver VMs. Both installations use
webserver's broker container started. Both installations use the same MariaDB the same MariaDB Galera database, Gitea application, package registry, and
Galera database, Gitea application, package registry, and secrets. The secrets. The containers use Linux host networking so `127.0.0.1:3306` reaches
containers use Linux host networking so `127.0.0.1:3306` reaches each each webserver's local HAProxy listener.
webserver's local HAProxy listener.
## 1. DNS and TLS ## 1. DNS and TLS
Create `tapm.scity.us` in PowerDNS and point it at the existing HAProxy Create `tapm.scity.us` in PowerDNS and point it at the existing HAProxy
frontend. Install the certificate using the existing certificate automation. 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 An example virtual host is in
[`deploy/nginx/tapm.scity.us.conf`](../deploy/nginx/tapm.scity.us.conf). [`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 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 `TAI/files` package owner. Create a personal access token for this account
and store it only in the broker environment file. 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`. `https://tapm.scity.us/auth/callback`.
The OAuth client secret authenticates the portal to Gitea. The package token The OAuth client secret authenticates the portal to Gitea. The read token is
is used only server-side while proxying an authorized file. Neither value is used only while proxying an authorized file, and the write token is used only
returned to a technician or Proxmox host. The broker requests only Gitea's for authenticated uploads. None are returned to a technician or Proxmox host.
`read:user` OAuth scope, which it uses to confirm the signed-in username The broker requests only Gitea's `read:user` OAuth scope, which it uses to
against `TAPM_ALLOWED_GITEA_USERS`. confirm the signed-in username against `TAPM_ALLOWED_GITEA_USERS`.
## 4. Broker configuration ## 4. Broker configuration
@@ -68,8 +76,8 @@ cp .env.example .env
chmod 600 .env chmod 600 .env
``` ```
Edit `.env` and use the same values on both webservers. Generate the cookie Edit `.env` and use the same secrets on both webservers. The listen address is
secret with: the one node-specific value. Generate the cookie secret with:
```sh ```sh
openssl rand -base64 48 openssl rand -base64 48
@@ -97,29 +105,16 @@ docker compose run --rm migrate
Migrations use a MariaDB advisory lock, so an accidental simultaneous run Migrations use a MariaDB advisory lock, so an accidental simultaneous run
cannot apply the same migration twice. cannot apply the same migration twice.
## 6. Start the active instance ## 6. Start both instances
On the active webserver: On both webservers:
```sh ```sh
docker compose up -d broker 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 There is no local application state and no session affinity requirement.
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.
## 7. HAProxy health check ## 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; `GET /health/ready` returns HTTP 200. `/health/live` checks only the process;
`/health/ready` also verifies Galera connectivity. `/health/ready` also verifies Galera connectivity.
Only the active webserver should be eligible for traffic. Retaining a single Both healthy webservers can receive traffic. `leastconn` is useful because
active origin avoids duplicate service management while Galera preserves package uploads and downloads stay open longer than portal requests.
sessions and authorization state across failover.
## 8. Publish a protected package ## 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 ```sh
curl --user 'publisher:PACKAGE_WRITE_TOKEN' \ curl --user 'publisher:PACKAGE_WRITE_TOKEN' \
@@ -147,15 +150,15 @@ Calculate its checksum:
sha256sum SentinelAgent_linux_x86_64_v26_1_1_31.deb sha256sum SentinelAgent_linux_x86_64_v26_1_1_31.deb
``` ```
Sign in at `https://tapm.scity.us`, enter the matching registry package name, Sign in at `https://tapm.scity.us`, expand **Register package metadata
version, filename, and SHA-256, then enable it. The broker intentionally does manually**, enter the matching values, and enable it. The broker never uses the
not browse the registry with the technician's credentials. technician's Gitea credentials for package operations.
## 9. Backup and rotation ## 9. Backup and rotation
- Include `tapm_broker` in the existing Galera backup policy. - Include `tapm_broker` in the existing Galera backup policy.
- Keep `.env` outside Git and readable only by the service administrator. - 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. compromised.
- Changing `TAPM_COOKIE_SECRET` signs out portal users; it does not invalidate - Changing `TAPM_COOKIE_SECRET` signs out portal users; it does not invalidate
active deployment codes. active deployment codes.
+9 -3
View File
@@ -242,9 +242,15 @@ func (s *Server) requireTechnician(next http.HandlerFunc) http.HandlerFunc {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
if r.Method != http.MethodGet && r.FormValue("csrf_token") != tech.CSRFToken { if r.Method != http.MethodGet {
http.Error(w, "invalid request token", http.StatusForbidden) csrfToken := r.Header.Get("X-CSRF-Token")
return if csrfToken == "" {
csrfToken = r.FormValue("csrf_token")
}
if csrfToken != tech.CSRFToken {
http.Error(w, "invalid request token", http.StatusForbidden)
return
}
} }
next(w, r) next(w, r)
} }
+122 -26
View File
@@ -26,6 +26,11 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to list authorizations", http.StatusInternalServerError) http.Error(w, "unable to list authorizations", http.StatusInternalServerError)
return 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) auditEvents, err := s.listAuditEvents(r)
if err != nil { if err != nil {
http.Error(w, "unable to list audit events", http.StatusInternalServerError) 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, Authorizations: authorizations,
AuditEvents: auditEvents, AuditEvents: auditEvents,
Packages: packages, Packages: packages,
Actions: actions,
NewCode: r.URL.Query().Get("code"), NewCode: r.URL.Query().Get("code"),
DefaultHostLimit: s.cfg.DefaultHostLimit, DefaultHostLimit: s.cfg.DefaultHostLimit,
DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())), 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) { func (s *Server) listAuditEvents(r *http.Request) ([]auditRecord, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
r.Context(), r.Context(),
@@ -113,16 +142,25 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
r.Context(), r.Context(),
`SELECT a.id, a.code_hint, a.created_by, a.customer_label, `SELECT a.id, a.code_hint, a.created_by, a.customer_label,
a.host_limit, COUNT(DISTINCT h.id), a.created_at, a.host_limit,
a.expires_at, a.revoked_at, (SELECT COUNT(*) FROM authorization_hosts h
COALESCE(GROUP_CONCAT(DISTINCT p.display_name WHERE h.authorization_id = a.id),
ORDER BY p.display_name SEPARATOR ', '), '') 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 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 WHERE a.created_at > UTC_TIMESTAMP(6) - INTERVAL 30 DAY
GROUP BY a.id
ORDER BY a.created_at DESC ORDER BY a.created_at DESC
LIMIT 100`, LIMIT 100`,
) )
@@ -144,6 +182,7 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
&record.ExpiresAt, &record.ExpiresAt,
&record.RevokedAt, &record.RevokedAt,
&record.Packages, &record.Packages,
&record.Actions,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -165,8 +204,9 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return return
} }
packageIDs := r.Form["package_id"] packageIDs := r.Form["package_id"]
if len(packageIDs) == 0 { actionSlugs := r.Form["action_slug"]
http.Error(w, "select at least one package", http.StatusBadRequest) if len(packageIDs) == 0 && len(actionSlugs) == 0 {
http.Error(w, "select at least one package or installer action", http.StatusBadRequest)
return return
} }
code, err := deploymentCode() code, err := deploymentCode()
@@ -208,8 +248,13 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
} }
result, err := tx.ExecContext( result, err := tx.ExecContext(
r.Context(), r.Context(),
`INSERT INTO authorization_packages (authorization_id, package_id) `INSERT INTO authorization_packages
SELECT ?, id FROM packages WHERE id = ? AND enabled = TRUE`, (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, authorizationID, packageID,
) )
if err != nil { if err != nil {
@@ -222,6 +267,29 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return 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 { if err := tx.Commit(); err != nil {
http.Error(w, "unable to save authorization", http.StatusInternalServerError) http.Error(w, "unable to save authorization", http.StatusInternalServerError)
return return
@@ -278,23 +346,18 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if !validSlug(values["slug"]) || !validSHA256(values["sha256"]) { if !validSlug(values["slug"]) ||
http.Error(w, "invalid slug or SHA-256", http.StatusBadRequest) 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 return
} }
enabled := r.FormValue("enabled") == "1" enabled := r.FormValue("enabled") == "1"
_, err := s.db.ExecContext( err := s.savePackage(
r.Context(), r,
`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["slug"],
values["display_name"], values["display_name"],
values["package_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) 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 { func validSlug(value string) bool {
if value == "" || len(value) > 100 { if value == "" || len(value) > 100 {
return false return false
+25 -5
View File
@@ -19,11 +19,14 @@ type Config struct {
GiteaPackageOwner string GiteaPackageOwner string
GiteaPackageUser string GiteaPackageUser string
GiteaPackageToken string GiteaPackageToken string
GiteaWriteUser string
GiteaWriteToken string
AllowedGiteaUsers map[string]struct{} AllowedGiteaUsers map[string]struct{}
CookieSecret []byte CookieSecret []byte
DefaultDuration time.Duration DefaultDuration time.Duration
DefaultHostLimit int DefaultHostLimit int
MaxHostLimit int MaxHostLimit int
MaxUploadBytes int64
TrustProxyHeaders bool TrustProxyHeaders bool
} }
@@ -38,6 +41,8 @@ func LoadConfig() (Config, error) {
cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI") cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI")
cfg.GiteaPackageUser = os.Getenv("TAPM_GITEA_PACKAGE_USERNAME") cfg.GiteaPackageUser = os.Getenv("TAPM_GITEA_PACKAGE_USERNAME")
cfg.GiteaPackageToken = os.Getenv("TAPM_GITEA_PACKAGE_TOKEN") 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.CookieSecret = []byte(os.Getenv("TAPM_COOKIE_SECRET"))
cfg.PublicURL, err = parseAbsoluteURL("TAPM_PUBLIC_URL") cfg.PublicURL, err = parseAbsoluteURL("TAPM_PUBLIC_URL")
@@ -66,6 +71,10 @@ func LoadConfig() (Config, error) {
if cfg.DefaultHostLimit < 1 || cfg.MaxHostLimit < cfg.DefaultHostLimit { if cfg.DefaultHostLimit < 1 || cfg.MaxHostLimit < cfg.DefaultHostLimit {
return cfg, fmt.Errorf("invalid default or maximum host limit") 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( cfg.TrustProxyHeaders, err = strconv.ParseBool(
envDefault("TAPM_TRUST_PROXY_HEADERS", "false"), envDefault("TAPM_TRUST_PROXY_HEADERS", "false"),
@@ -86,11 +95,13 @@ func LoadConfig() (Config, error) {
} }
required := map[string]string{ required := map[string]string{
"TAPM_DATABASE_DSN": cfg.DatabaseDSN, "TAPM_DATABASE_DSN": cfg.DatabaseDSN,
"TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID, "TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID,
"TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret, "TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret,
"TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser, "TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser,
"TAPM_GITEA_PACKAGE_TOKEN": cfg.GiteaPackageToken, "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 { for name, value := range required {
if strings.TrimSpace(value) == "" { if strings.TrimSpace(value) == "" {
@@ -131,3 +142,12 @@ func envInt(name string, fallback int) (int, error) {
} }
return value, nil return value, nil
} }
func envInt64(name string, fallback int64) (int64, error) {
raw := envDefault(name, strconv.FormatInt(fallback, 10))
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s must be an integer", name)
}
return value, nil
}
+89 -7
View File
@@ -16,6 +16,8 @@ type exchangeRequest struct {
Code string `json:"code"` Code string `json:"code"`
HostFingerprint string `json:"host_fingerprint"` HostFingerprint string `json:"host_fingerprint"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
RequestedAction string `json:"requested_action,omitempty"`
RequestedPackage string `json:"requested_package,omitempty"`
} }
type exchangePackage struct { type exchangePackage struct {
@@ -30,6 +32,7 @@ type exchangeResponse struct {
SessionToken string `json:"session_token"` SessionToken string `json:"session_token"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
Packages []exchangePackage `json:"packages"` Packages []exchangePackage `json:"packages"`
Actions []string `json:"actions"`
} }
func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) { 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.Code = normalizeCode(request.Code)
request.HostFingerprint = strings.TrimSpace(request.HostFingerprint) request.HostFingerprint = strings.TrimSpace(request.HostFingerprint)
request.Hostname = strings.TrimSpace(request.Hostname) 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 || 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"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code and host identity are required"})
return return
} }
@@ -72,7 +79,16 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
writeJSON(w, status, map[string]string{"error": message}) writeJSON(w, status, map[string]string{"error": message})
return 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) writeJSON(w, http.StatusOK, response)
} }
@@ -124,6 +140,43 @@ func (s *Server) exchangeDeploymentCode(
return response, 0, err 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 var hostID uint64
err = tx.QueryRowContext( err = tx.QueryRowContext(
r.Context(), r.Context(),
@@ -188,7 +241,7 @@ func (s *Server) exchangeDeploymentCode(
rows, err := tx.QueryContext( rows, err := tx.QueryContext(
r.Context(), 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 FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = ? AND p.enabled = TRUE WHERE ap.authorization_id = ? AND p.enabled = TRUE
@@ -216,7 +269,36 @@ func (s *Server) exchangeDeploymentCode(
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return response, 0, err 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 return response, 0, errAuthorizationDenied
} }
@@ -248,8 +330,8 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
var hostname string var hostname string
err := s.db.QueryRowContext( err := s.db.QueryRowContext(
r.Context(), r.Context(),
`SELECT p.id, p.slug, p.display_name, p.package_name, `SELECT p.id, ap.package_slug, ap.display_name, ap.package_name,
p.package_version, p.file_name, p.sha256, p.enabled, ap.package_version, ap.file_name, ap.sha256, p.enabled,
ds.authorization_id, ah.hostname ds.authorization_id, ah.hostname
FROM download_sessions ds FROM download_sessions ds
JOIN authorizations a ON a.id = ds.authorization_id 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 ds.expires_at > UTC_TIMESTAMP(6)
AND a.revoked_at IS NULL AND a.revoked_at IS NULL
AND a.expires_at > UTC_TIMESTAMP(6) AND a.expires_at > UTC_TIMESTAMP(6)
AND p.slug = ? AND ap.package_slug = ?
AND p.enabled = TRUE`, AND p.enabled = TRUE`,
sessionHash[:], slug, sessionHash[:], slug,
).Scan( ).Scan(
+9
View File
@@ -48,6 +48,12 @@ type packageRecord struct {
Enabled bool Enabled bool
} }
type actionRecord struct {
Slug string
DisplayName string
Enabled bool
}
type authorizationRecord struct { type authorizationRecord struct {
ID uint64 ID uint64
CodeHint string CodeHint string
@@ -59,6 +65,7 @@ type authorizationRecord struct {
ExpiresAt time.Time ExpiresAt time.Time
RevokedAt sql.NullTime RevokedAt sql.NullTime
Packages string Packages string
Actions string
} }
type auditRecord struct { type auditRecord struct {
@@ -78,6 +85,7 @@ type pageData struct {
Authorizations []authorizationRecord Authorizations []authorizationRecord
AuditEvents []auditRecord AuditEvents []auditRecord
Packages []packageRecord Packages []packageRecord
Actions []actionRecord
NewCode string NewCode string
DefaultHostLimit int DefaultHostLimit int
DefaultDuration string 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", s.requireTechnician(s.handleCreateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization)) 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", s.requireTechnician(s.handleUpsertPackage))
mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage))
mux.HandleFunc("POST /api/v1/exchange", s.handleExchange) mux.HandleFunc("POST /api/v1/exchange", s.handleExchange)
mux.HandleFunc("GET /api/v1/packages/{slug}", s.handlePackageDownload) mux.HandleFunc("GET /api/v1/packages/{slug}", s.handlePackageDownload)
mux.HandleFunc("GET /", s.handleHome) mux.HandleFunc("GET /", s.handleHome)
+7
View File
@@ -25,6 +25,7 @@ body {
} }
button, input { font: inherit; } button, input { font: inherit; }
button:disabled { cursor: wait; opacity: .58; }
.topbar { .topbar {
position: sticky; position: sticky;
@@ -208,6 +209,12 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.package-row small { color: var(--muted); } .package-row small { color: var(--muted); }
.toggle { display: flex; align-items: center; gap: 9px; } .toggle { display: flex; align-items: center; gap: 9px; }
.empty, .fine-print { color: var(--muted); } .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-panel { margin-top: 24px; }
.audit-table { display: grid; max-height: 520px; overflow: auto; } .audit-table { display: grid; max-height: 520px; overflow: auto; }
+32
View File
@@ -14,3 +14,35 @@ document.addEventListener("click", async (event) => {
button.textContent = "Copy failed"; button.textContent = "Copy failed";
} }
}); });
document.addEventListener("submit", async (event) => {
const form = event.target.closest("[data-upload-form]");
if (!form) return;
event.preventDefault();
const button = form.querySelector("button[type=submit]");
const status = form.querySelector("[data-upload-status]");
const csrfToken = form.querySelector('input[name="csrf_token"]').value;
button.disabled = true;
status.textContent = "Uploading package… keep this page open.";
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: {"X-CSRF-Token": csrfToken},
credentials: "same-origin",
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || `Upload failed with HTTP ${response.status}`);
}
status.textContent = `Uploaded ${result.filename}; SHA-256 ${result.sha256}`;
window.setTimeout(() => {
window.location.assign("/portal?notice=Package+uploaded+and+registered");
}, 900);
} catch (error) {
status.textContent = error.message;
button.disabled = false;
}
});
+54 -16
View File
@@ -42,8 +42,8 @@
<p class="eyebrow">Controlled package delivery</p> <p class="eyebrow">Controlled package delivery</p>
<h1>Open a deployment window.</h1> <h1>Open a deployment window.</h1>
<p> <p>
Authorize a limited number of hosts for selected packages. Access Authorize a limited number of hosts for selected packages and
closes automatically at the end of the window. installer actions. Access closes automatically at the end of the window.
</p> </p>
</div> </div>
<div class="policy-card"> <div class="policy-card">
@@ -97,6 +97,23 @@
{{end}} {{end}}
</fieldset> </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> <button class="button primary" type="submit">Create 3-hour code</button>
</form> </form>
</section> </section>
@@ -122,7 +139,8 @@
<dl> <dl>
<div><dt>Hosts</dt><dd>{{.HostCount}} / {{.HostLimit}}</dd></div> <div><dt>Hosts</dt><dd>{{.HostCount}} / {{.HostLimit}}</dd></div>
<div><dt>Expires</dt><dd>{{formatTime .ExpiresAt}}</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> <div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div>
</dl> </dl>
{{if isActive .}} {{if isActive .}}
@@ -162,19 +180,39 @@
{{end}} {{end}}
</div> </div>
<form action="/portal/packages" method="post" class="stack compact"> <div class="package-forms">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact" data-upload-form>
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label> <p class="form-title">Upload a new version</p>
<div class="field-row"> <label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
<label>Registry package <input name="package_name" placeholder="sentinelone-linux" required></label> <label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
<label>Version <input name="package_version" placeholder="26.1.1.31" required></label> <div class="field-row">
</div> <label>Registry package <input name="package_name" placeholder="sentinelone-linux" required></label>
<label>Filename <input name="file_name" placeholder="SentinelAgent_linux_x86_64.deb" required></label> <label>New version <input name="package_version" placeholder="26.2.0.10" required></label>
<label>SHA-256 <input name="sha256" minlength="64" maxlength="64" placeholder="64 hexadecimal characters" required></label> </div>
<label class="toggle"><input type="checkbox" name="enabled" value="1"> Enable for new authorizations</label> <label class="toggle"><input type="checkbox" name="enabled" value="1" checked> Enable after upload</label>
<button class="button secondary" type="submit">Save package metadata</button> <label>Installer file <input name="package_file" type="file" required></label>
</form> <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> </div>
</section> </section>
+202
View File
@@ -0,0 +1,202 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
const uploadFormOverhead = 1 << 20
type byteCounter int64
func (counter *byteCounter) Write(value []byte) (int, error) {
*counter += byteCounter(len(value))
return len(value), nil
}
func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
if r.ContentLength > s.cfg.MaxUploadBytes+uploadFormOverhead {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "package exceeds the upload limit"})
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+uploadFormOverhead)
reader, err := r.MultipartReader()
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "multipart upload is required"})
return
}
fields := make(map[string]string)
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "package file is required"})
return
}
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unable to read upload"})
return
}
if part.FileName() == "" {
value, err := io.ReadAll(io.LimitReader(part, 4097))
_ = part.Close()
if err != nil || len(value) > 4096 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid upload field"})
return
}
fields[part.FormName()] = strings.TrimSpace(string(value))
continue
}
if part.FormName() != "package_file" {
_ = part.Close()
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unexpected uploaded file"})
return
}
fileName := part.FileName()
if !validRegistrySegment(fileName, 255) {
_ = part.Close()
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid package filename"})
return
}
if err := validateUploadFields(fields); err != nil {
_ = part.Close()
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
checksum, size, status, err := s.streamPackageToGitea(r, fields, fileName, part)
_ = part.Close()
if err != nil {
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
status = http.StatusRequestEntityTooLarge
}
_ = s.audit(r.Context(), "package_upload_failed", tech.Login, nil, "", fields["slug"], s.clientIP(r), err.Error())
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
enabled := fields["enabled"] == "1"
if err := s.savePackage(
r,
fields["slug"],
fields["display_name"],
fields["package_name"],
fields["package_version"],
fileName,
checksum,
enabled,
); err != nil {
_ = s.audit(r.Context(), "package_upload_failed", tech.Login, nil, "", fields["slug"], s.clientIP(r), err.Error())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "package uploaded but catalog update failed"})
return
}
_ = s.audit(
r.Context(),
"package_uploaded",
tech.Login,
nil,
"",
fields["slug"],
s.clientIP(r),
fmt.Sprintf(
"version=%s filename=%s bytes=%d sha256=%s enabled=%t",
fields["package_version"], fileName, size, checksum, enabled,
),
)
writeJSON(w, http.StatusCreated, map[string]any{
"status": "uploaded",
"sha256": checksum,
"bytes": size,
"filename": fileName,
})
return
}
}
func validateUploadFields(fields map[string]string) error {
if !validSlug(fields["slug"]) {
return errors.New("invalid package slug")
}
if fields["display_name"] == "" || len(fields["display_name"]) > 255 {
return errors.New("invalid display name")
}
if !validRegistrySegment(fields["package_name"], 255) {
return errors.New("invalid registry package name")
}
if !validRegistrySegment(fields["package_version"], 100) {
return errors.New("invalid package version")
}
return nil
}
func validRegistrySegment(value string, maxLength int) bool {
if value == "" || len(value) > maxLength {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') &&
(character < 'A' || character > 'Z') &&
(character < '0' || character > '9') &&
character != '.' && character != '-' &&
character != '+' && character != '_' {
return false
}
}
return true
}
func (s *Server) streamPackageToGitea(
r *http.Request,
fields map[string]string,
fileName string,
source io.Reader,
) (string, int64, int, error) {
registryURL := joinURL(
s.cfg.GiteaURL,
fmt.Sprintf(
"/api/packages/%s/generic/%s/%s/%s",
url.PathEscape(s.cfg.GiteaPackageOwner),
url.PathEscape(fields["package_name"]),
url.PathEscape(fields["package_version"]),
url.PathEscape(fileName),
),
)
hasher := sha256.New()
var size byteCounter
body := io.TeeReader(source, io.MultiWriter(hasher, &size))
request, err := http.NewRequestWithContext(r.Context(), http.MethodPut, registryURL, body)
if err != nil {
return "", 0, http.StatusInternalServerError, errors.New("unable to prepare registry upload")
}
request.Header.Set("Content-Type", "application/octet-stream")
request.SetBasicAuth(s.cfg.GiteaWriteUser, s.cfg.GiteaWriteToken)
response, err := s.packageClient.Do(request)
if err != nil {
return "", int64(size), http.StatusBadGateway, errors.New("package registry upload failed")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8192))
switch response.StatusCode {
case http.StatusCreated:
case http.StatusConflict:
return "", int64(size), http.StatusConflict, errors.New("that package version and filename already exist")
case http.StatusUnauthorized, http.StatusForbidden:
return "", int64(size), http.StatusBadGateway, errors.New("package registry rejected the publisher credentials")
default:
return "", int64(size), http.StatusBadGateway, fmt.Errorf("package registry returned %s", response.Status)
}
return hex.EncodeToString(hasher.Sum(nil)), int64(size), http.StatusCreated, nil
}
+91
View File
@@ -0,0 +1,91 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestValidRegistrySegment(t *testing.T) {
t.Parallel()
valid := []string{"sentinelone-linux", "26.2.0.10", "agent_x86_64+release.deb"}
for _, value := range valid {
if !validRegistrySegment(value, 255) {
t.Errorf("expected %q to be valid", value)
}
}
invalid := []string{"", "../agent.deb", "agent/name.deb", "agent name.deb"}
for _, value := range invalid {
if validRegistrySegment(value, 255) {
t.Errorf("expected %q to be invalid", value)
}
}
}
func TestStreamPackageToGitea(t *testing.T) {
t.Parallel()
content := "test package content"
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("method = %s, want PUT", r.Method)
}
if r.URL.Path != "/api/packages/TAI/generic/sentinelone-linux/26.2.0.10/agent.deb" {
t.Errorf("path = %s", r.URL.Path)
}
username, password, ok := r.BasicAuth()
if !ok || username != "publisher" || password != "write-token" {
t.Errorf("unexpected registry credentials")
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != content {
t.Errorf("body = %q", body)
}
w.WriteHeader(http.StatusCreated)
}))
defer registry.Close()
registryURL, err := url.Parse(registry.URL)
if err != nil {
t.Fatal(err)
}
server := &Server{
cfg: Config{
GiteaURL: registryURL,
GiteaPackageOwner: "TAI",
GiteaWriteUser: "publisher",
GiteaWriteToken: "write-token",
},
packageClient: registry.Client(),
}
request := httptest.NewRequest(http.MethodPost, "/portal/packages/upload", nil)
checksum, size, status, err := server.streamPackageToGitea(
request,
map[string]string{
"package_name": "sentinelone-linux",
"package_version": "26.2.0.10",
},
"agent.deb",
strings.NewReader(content),
)
if err != nil {
t.Fatal(err)
}
expected := sha256.Sum256([]byte(content))
if checksum != hex.EncodeToString(expected[:]) {
t.Errorf("checksum = %s", checksum)
}
if size != int64(len(content)) {
t.Errorf("size = %d", size)
}
if status != http.StatusCreated {
t.Errorf("status = %d", status)
}
}
+58
View File
@@ -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);