update
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -242,10 +242,16 @@ 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 {
|
||||||
|
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)
|
http.Error(w, "invalid request token", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-26
@@ -26,6 +26,11 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "unable to list authorizations", http.StatusInternalServerError)
|
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
|
||||||
|
|||||||
@@ -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"),
|
||||||
@@ -91,6 +100,8 @@ func LoadConfig() (Config, error) {
|
|||||||
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,6 +180,24 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="package-forms">
|
||||||
|
<form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact" data-upload-form>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<p class="form-title">Upload a new version</p>
|
||||||
|
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
|
||||||
|
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Registry package <input name="package_name" placeholder="sentinelone-linux" required></label>
|
||||||
|
<label>New version <input name="package_version" placeholder="26.2.0.10" required></label>
|
||||||
|
</div>
|
||||||
|
<label class="toggle"><input type="checkbox" name="enabled" value="1" checked> Enable after upload</label>
|
||||||
|
<label>Installer file <input name="package_file" type="file" required></label>
|
||||||
|
<button class="button primary" type="submit">Upload and register package</button>
|
||||||
|
<p class="upload-status" data-upload-status aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<details class="manual-metadata">
|
||||||
|
<summary>Register package metadata manually</summary>
|
||||||
<form action="/portal/packages" method="post" class="stack compact">
|
<form action="/portal/packages" method="post" class="stack compact">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
|
<label>Slug <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
|
||||||
@@ -175,6 +211,8 @@
|
|||||||
<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"> Enable for new authorizations</label>
|
||||||
<button class="button secondary" type="submit">Save package metadata</button>
|
<button class="button secondary" type="submit">Save package metadata</button>
|
||||||
</form>
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadFormOverhead = 1 << 20
|
||||||
|
|
||||||
|
type byteCounter int64
|
||||||
|
|
||||||
|
func (counter *byteCounter) Write(value []byte) (int, error) {
|
||||||
|
*counter += byteCounter(len(value))
|
||||||
|
return len(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tech, _ := s.currentTechnician(r)
|
||||||
|
if r.ContentLength > s.cfg.MaxUploadBytes+uploadFormOverhead {
|
||||||
|
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "package exceeds the upload limit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+uploadFormOverhead)
|
||||||
|
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "multipart upload is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields := make(map[string]string)
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "package file is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unable to read upload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if part.FileName() == "" {
|
||||||
|
value, err := io.ReadAll(io.LimitReader(part, 4097))
|
||||||
|
_ = part.Close()
|
||||||
|
if err != nil || len(value) > 4096 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid upload field"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields[part.FormName()] = strings.TrimSpace(string(value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if part.FormName() != "package_file" {
|
||||||
|
_ = part.Close()
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unexpected uploaded file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := part.FileName()
|
||||||
|
if !validRegistrySegment(fileName, 255) {
|
||||||
|
_ = part.Close()
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid package filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateUploadFields(fields); err != nil {
|
||||||
|
_ = part.Close()
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checksum, size, status, err := s.streamPackageToGitea(r, fields, fileName, part)
|
||||||
|
_ = part.Close()
|
||||||
|
if err != nil {
|
||||||
|
var maxBytesError *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxBytesError) {
|
||||||
|
status = http.StatusRequestEntityTooLarge
|
||||||
|
}
|
||||||
|
_ = s.audit(r.Context(), "package_upload_failed", tech.Login, nil, "", fields["slug"], s.clientIP(r), err.Error())
|
||||||
|
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := fields["enabled"] == "1"
|
||||||
|
if err := s.savePackage(
|
||||||
|
r,
|
||||||
|
fields["slug"],
|
||||||
|
fields["display_name"],
|
||||||
|
fields["package_name"],
|
||||||
|
fields["package_version"],
|
||||||
|
fileName,
|
||||||
|
checksum,
|
||||||
|
enabled,
|
||||||
|
); err != nil {
|
||||||
|
_ = s.audit(r.Context(), "package_upload_failed", tech.Login, nil, "", fields["slug"], s.clientIP(r), err.Error())
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "package uploaded but catalog update failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.audit(
|
||||||
|
r.Context(),
|
||||||
|
"package_uploaded",
|
||||||
|
tech.Login,
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
fields["slug"],
|
||||||
|
s.clientIP(r),
|
||||||
|
fmt.Sprintf(
|
||||||
|
"version=%s filename=%s bytes=%d sha256=%s enabled=%t",
|
||||||
|
fields["package_version"], fileName, size, checksum, enabled,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"status": "uploaded",
|
||||||
|
"sha256": checksum,
|
||||||
|
"bytes": size,
|
||||||
|
"filename": fileName,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUploadFields(fields map[string]string) error {
|
||||||
|
if !validSlug(fields["slug"]) {
|
||||||
|
return errors.New("invalid package slug")
|
||||||
|
}
|
||||||
|
if fields["display_name"] == "" || len(fields["display_name"]) > 255 {
|
||||||
|
return errors.New("invalid display name")
|
||||||
|
}
|
||||||
|
if !validRegistrySegment(fields["package_name"], 255) {
|
||||||
|
return errors.New("invalid registry package name")
|
||||||
|
}
|
||||||
|
if !validRegistrySegment(fields["package_version"], 100) {
|
||||||
|
return errors.New("invalid package version")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRegistrySegment(value string, maxLength int) bool {
|
||||||
|
if value == "" || len(value) > maxLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if (character < 'a' || character > 'z') &&
|
||||||
|
(character < 'A' || character > 'Z') &&
|
||||||
|
(character < '0' || character > '9') &&
|
||||||
|
character != '.' && character != '-' &&
|
||||||
|
character != '+' && character != '_' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) streamPackageToGitea(
|
||||||
|
r *http.Request,
|
||||||
|
fields map[string]string,
|
||||||
|
fileName string,
|
||||||
|
source io.Reader,
|
||||||
|
) (string, int64, int, error) {
|
||||||
|
registryURL := joinURL(
|
||||||
|
s.cfg.GiteaURL,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"/api/packages/%s/generic/%s/%s/%s",
|
||||||
|
url.PathEscape(s.cfg.GiteaPackageOwner),
|
||||||
|
url.PathEscape(fields["package_name"]),
|
||||||
|
url.PathEscape(fields["package_version"]),
|
||||||
|
url.PathEscape(fileName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
var size byteCounter
|
||||||
|
body := io.TeeReader(source, io.MultiWriter(hasher, &size))
|
||||||
|
request, err := http.NewRequestWithContext(r.Context(), http.MethodPut, registryURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, http.StatusInternalServerError, errors.New("unable to prepare registry upload")
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
request.SetBasicAuth(s.cfg.GiteaWriteUser, s.cfg.GiteaWriteToken)
|
||||||
|
|
||||||
|
response, err := s.packageClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", int64(size), http.StatusBadGateway, errors.New("package registry upload failed")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8192))
|
||||||
|
|
||||||
|
switch response.StatusCode {
|
||||||
|
case http.StatusCreated:
|
||||||
|
case http.StatusConflict:
|
||||||
|
return "", int64(size), http.StatusConflict, errors.New("that package version and filename already exist")
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return "", int64(size), http.StatusBadGateway, errors.New("package registry rejected the publisher credentials")
|
||||||
|
default:
|
||||||
|
return "", int64(size), http.StatusBadGateway, fmt.Errorf("package registry returned %s", response.Status)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil)), int64(size), http.StatusCreated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidRegistrySegment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
valid := []string{"sentinelone-linux", "26.2.0.10", "agent_x86_64+release.deb"}
|
||||||
|
for _, value := range valid {
|
||||||
|
if !validRegistrySegment(value, 255) {
|
||||||
|
t.Errorf("expected %q to be valid", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalid := []string{"", "../agent.deb", "agent/name.deb", "agent name.deb"}
|
||||||
|
for _, value := range invalid {
|
||||||
|
if validRegistrySegment(value, 255) {
|
||||||
|
t.Errorf("expected %q to be invalid", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamPackageToGitea(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
content := "test package content"
|
||||||
|
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPut {
|
||||||
|
t.Errorf("method = %s, want PUT", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/packages/TAI/generic/sentinelone-linux/26.2.0.10/agent.deb" {
|
||||||
|
t.Errorf("path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok || username != "publisher" || password != "write-token" {
|
||||||
|
t.Errorf("unexpected registry credentials")
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(body) != content {
|
||||||
|
t.Errorf("body = %q", body)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
defer registry.Close()
|
||||||
|
|
||||||
|
registryURL, err := url.Parse(registry.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := &Server{
|
||||||
|
cfg: Config{
|
||||||
|
GiteaURL: registryURL,
|
||||||
|
GiteaPackageOwner: "TAI",
|
||||||
|
GiteaWriteUser: "publisher",
|
||||||
|
GiteaWriteToken: "write-token",
|
||||||
|
},
|
||||||
|
packageClient: registry.Client(),
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/portal/packages/upload", nil)
|
||||||
|
checksum, size, status, err := server.streamPackageToGitea(
|
||||||
|
request,
|
||||||
|
map[string]string{
|
||||||
|
"package_name": "sentinelone-linux",
|
||||||
|
"package_version": "26.2.0.10",
|
||||||
|
},
|
||||||
|
"agent.deb",
|
||||||
|
strings.NewReader(content),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected := sha256.Sum256([]byte(content))
|
||||||
|
if checksum != hex.EncodeToString(expected[:]) {
|
||||||
|
t.Errorf("checksum = %s", checksum)
|
||||||
|
}
|
||||||
|
if size != int64(len(content)) {
|
||||||
|
t.Errorf("size = %d", size)
|
||||||
|
}
|
||||||
|
if status != http.StatusCreated {
|
||||||
|
t.Errorf("status = %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user