update gui

This commit is contained in:
2026-07-28 20:45:34 -05:00
parent 8dcee4121b
commit 510d5490f9
13 changed files with 606 additions and 40 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ TAPM_GITEA_PACKAGE_WRITE_USERNAME=
TAPM_GITEA_PACKAGE_WRITE_TOKEN= TAPM_GITEA_PACKAGE_WRITE_TOKEN=
TAPM_ALLOWED_GITEA_USERS=taiadmin TAPM_ALLOWED_GITEA_USERS=taiadmin
TAPM_COOKIE_SECRET= TAPM_COOKIE_SECRET=
TAPM_DEFAULT_DURATION=3h TAPM_DEFAULT_DURATION=7d
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_MAX_UPLOAD_BYTES=1073741824
+6
View File
@@ -37,6 +37,12 @@ HTTP-only Nginx test configuration. It does not invent Gitea OAuth or package
credentials. After creating those resources in Gitea, run credentials. After creating those resources in Gitea, run
`./manage.sh configure-broker` and `./manage.sh start`. `./manage.sh configure-broker` and `./manage.sh start`.
New deployment codes default to seven days. While a code is active, the Codes
page can raise or lower its host limit, add or remove packages and installer
actions, and extend its expiration in whole days. The host limit cannot be
reduced below the number of hosts already enrolled, and every change is
recorded in the audit trail.
Once public DNS and TCP 80/443 forwarding are ready, switch the same persistent Once public DNS and TCP 80/443 forwarding are ready, switch the same persistent
deployment to Let's Encrypt: deployment to Let's Encrypt:
+13
View File
@@ -124,6 +124,19 @@ launch the broker until all required credentials are present.
OAuth token exchange and package traffic stay on the private Compose network. OAuth token exchange and package traffic stay on the private Compose network.
Do not publish Gitea's internal port to make broker authentication work. Do not publish Gitea's internal port to make broker authentication work.
Deployment codes default to seven days:
```dotenv
TAPM_DEFAULT_DURATION=7d
```
The setting accepts day values such as `7d` and legacy Go duration values such
as `168h`. Existing deployments retain their current `.env`; change a previous
`TAPM_DEFAULT_DURATION=3h` value to `7d` and run `./manage.sh start` to adopt
the new default. Individual active codes can be extended from the Codes page.
The same editor can change the maximum host count and add or remove protected
packages and installer actions without issuing a replacement code.
For troubleshooting, use `./manage.sh diagnostics`. Do not share unredacted For troubleshooting, use `./manage.sh diagnostics`. Do not share unredacted
`docker compose config` output: Compose resolves the broker's `.env` file and `docker compose config` output: Compose resolves the broker's `.env` file and
can print cookie, OAuth, and package secrets. If configuration output is can print cookie, OAuth, and package secrets. If configuration output is
+1 -1
View File
@@ -124,7 +124,7 @@ umask 077
printf 'TAPM_GITEA_PACKAGE_WRITE_TOKEN=\n' printf 'TAPM_GITEA_PACKAGE_WRITE_TOKEN=\n'
printf 'TAPM_ALLOWED_GITEA_USERS=taiadmin\n' printf 'TAPM_ALLOWED_GITEA_USERS=taiadmin\n'
printf 'TAPM_COOKIE_SECRET=%s\n' "$cookie_secret" printf 'TAPM_COOKIE_SECRET=%s\n' "$cookie_secret"
printf 'TAPM_DEFAULT_DURATION=3h\n' printf 'TAPM_DEFAULT_DURATION=7d\n'
printf 'TAPM_DEFAULT_HOST_LIMIT=3\n' printf 'TAPM_DEFAULT_HOST_LIMIT=3\n'
printf 'TAPM_MAX_HOST_LIMIT=25\n' printf 'TAPM_MAX_HOST_LIMIT=25\n'
printf 'TAPM_MAX_UPLOAD_BYTES=1073741824\n' printf 'TAPM_MAX_UPLOAD_BYTES=1073741824\n'
+257 -25
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -10,6 +11,8 @@ import (
"time" "time"
) )
const maxAuthorizationDays = 90
func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
if hasAuditQuery(r) { if hasAuditQuery(r) {
http.Redirect(w, r, "/portal/audit?"+r.URL.RawQuery, http.StatusSeeOther) http.Redirect(w, r, "/portal/audit?"+r.URL.RawQuery, http.StatusSeeOther)
@@ -50,12 +53,18 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
Actions: actions, 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())), DefaultDays: durationDays(s.cfg.DefaultDuration),
MaxHostLimit: s.cfg.MaxHostLimit,
Notice: r.URL.Query().Get("notice"), Notice: r.URL.Query().Get("notice"),
CurrentView: "codes", CurrentView: "codes",
}) })
} }
func durationDays(duration time.Duration) int {
const day = 24 * time.Hour
return int((duration + day - 1) / day)
}
func hasAuditQuery(r *http.Request) bool { func hasAuditQuery(r *http.Request) bool {
query := r.URL.Query() query := r.URL.Query()
for _, field := range []string{ for _, field := range []string{
@@ -342,16 +351,19 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
), '') ), '')
FROM authorizations a FROM authorizations a
WHERE a.created_at > datetime('now', '-30 days') WHERE a.created_at > datetime('now', '-30 days')
OR (a.revoked_at IS NULL AND a.expires_at > CURRENT_TIMESTAMP)
ORDER BY a.created_at DESC ORDER BY a.created_at DESC
LIMIT 100`, LIMIT 100`,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var records []authorizationRecord var records []authorizationRecord
for rows.Next() { for rows.Next() {
var record authorizationRecord record := authorizationRecord{
PackageIDs: make(map[uint64]bool),
ActionSlugs: make(map[string]bool),
}
if err := rows.Scan( if err := rows.Scan(
&record.ID, &record.ID,
&record.CodeHint, &record.CodeHint,
@@ -369,7 +381,76 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
} }
records = append(records, record) records = append(records, record)
} }
return records, rows.Err() if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := s.loadAuthorizationSelections(r.Context(), records); err != nil {
return nil, err
}
return records, nil
}
func (s *Server) loadAuthorizationSelections(
ctx context.Context,
records []authorizationRecord,
) error {
indices := make(map[uint64]int, len(records))
for index := range records {
indices[records[index].ID] = index
}
if len(indices) == 0 {
return nil
}
packageRows, err := s.db.QueryContext(
ctx,
`SELECT authorization_id, package_id FROM authorization_packages`,
)
if err != nil {
return err
}
for packageRows.Next() {
var authorizationID uint64
var packageID uint64
if err := packageRows.Scan(&authorizationID, &packageID); err != nil {
_ = packageRows.Close()
return err
}
if index, ok := indices[authorizationID]; ok {
records[index].PackageIDs[packageID] = true
}
}
if err := packageRows.Err(); err != nil {
_ = packageRows.Close()
return err
}
if err := packageRows.Close(); err != nil {
return err
}
actionRows, err := s.db.QueryContext(
ctx,
`SELECT authorization_id, action_slug FROM authorization_actions`,
)
if err != nil {
return err
}
defer actionRows.Close()
for actionRows.Next() {
var authorizationID uint64
var actionSlug string
if err := actionRows.Scan(&authorizationID, &actionSlug); err != nil {
return err
}
if index, ok := indices[authorizationID]; ok {
records[index].ActionSlugs[actionSlug] = true
}
}
return actionRows.Err()
} }
func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) {
@@ -379,9 +460,9 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
http.Error(w, "invalid host limit", http.StatusBadRequest) http.Error(w, "invalid host limit", http.StatusBadRequest)
return return
} }
durationHours, err := strconv.Atoi(r.FormValue("duration_hours")) durationDays, err := strconv.Atoi(r.FormValue("duration_days"))
if err != nil || durationHours < 1 || durationHours > 24 { if err != nil || durationDays < 1 || durationDays > maxAuthorizationDays {
http.Error(w, "duration must be between 1 and 24 hours", http.StatusBadRequest) http.Error(w, "duration must be between 1 and 90 days", http.StatusBadRequest)
return return
} }
packageIDs := r.Form["package_id"] packageIDs := r.Form["package_id"]
@@ -396,7 +477,7 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return return
} }
codeHash := hashValue(code) codeHash := hashValue(code)
expiresAt := time.Now().UTC().Add(time.Duration(durationHours) * time.Hour) expiresAt := time.Now().UTC().Add(time.Duration(durationDays) * 24 * time.Hour)
customerLabel := strings.TrimSpace(r.FormValue("customer_label")) customerLabel := strings.TrimSpace(r.FormValue("customer_label"))
if len(customerLabel) > 255 { if len(customerLabel) > 255 {
http.Error(w, "customer label is too long", http.StatusBadRequest) http.Error(w, "customer label is too long", http.StatusBadRequest)
@@ -421,14 +502,41 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return return
} }
authorizationID, _ := result.LastInsertId() authorizationID, _ := result.LastInsertId()
if status, err := saveAuthorizationSelections(
r.Context(), tx, uint64(authorizationID), packageIDs, actionSlugs,
); err != nil {
http.Error(w, err.Error(), status)
return
}
if err := tx.Commit(); err != nil {
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
return
}
id := uint64(authorizationID)
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r),
fmt.Sprintf("host_limit=%d duration_days=%d customer=%q", hostLimit, durationDays, customerLabel))
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
}
func saveAuthorizationSelections(
ctx context.Context,
tx *sql.Tx,
authorizationID uint64,
packageIDs []string,
actionSlugs []string,
) (int, error) {
seenPackages := make(map[uint64]bool)
for _, rawID := range packageIDs { for _, rawID := range packageIDs {
packageID, err := strconv.ParseUint(rawID, 10, 64) packageID, err := strconv.ParseUint(rawID, 10, 64)
if err != nil { if err != nil {
http.Error(w, "invalid package selection", http.StatusBadRequest) return http.StatusBadRequest, errors.New("invalid package selection")
return
} }
if seenPackages[packageID] {
continue
}
seenPackages[packageID] = true
result, err := tx.ExecContext( result, err := tx.ExecContext(
r.Context(), ctx,
`INSERT INTO authorization_packages `INSERT INTO authorization_packages
(authorization_id, package_id, package_slug, display_name, (authorization_id, package_id, package_slug, display_name,
package_name, package_version, file_name, sha256) package_name, package_version, file_name, sha256)
@@ -439,35 +547,148 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
authorizationID, packageID, authorizationID, packageID,
) )
if err != nil { if err != nil {
http.Error(w, "unable to authorize package", http.StatusInternalServerError) return http.StatusInternalServerError, errors.New("unable to authorize package")
return
} }
affected, _ := result.RowsAffected() affected, _ := result.RowsAffected()
if affected != 1 { if affected != 1 {
http.Error(w, "selected package is unavailable", http.StatusBadRequest) return http.StatusBadRequest, errors.New("selected package is unavailable")
return
} }
} }
seenActions := make(map[string]bool)
for _, actionSlug := range actionSlugs { for _, actionSlug := range actionSlugs {
actionSlug = strings.TrimSpace(actionSlug) actionSlug = strings.TrimSpace(actionSlug)
if !validSlug(actionSlug) { if !validSlug(actionSlug) {
http.Error(w, "invalid installer action", http.StatusBadRequest) return http.StatusBadRequest, errors.New("invalid installer action")
return
} }
if seenActions[actionSlug] {
continue
}
seenActions[actionSlug] = true
result, err := tx.ExecContext( result, err := tx.ExecContext(
r.Context(), ctx,
`INSERT INTO authorization_actions (authorization_id, action_slug) `INSERT INTO authorization_actions (authorization_id, action_slug)
SELECT ?, slug FROM installer_actions SELECT ?, slug FROM installer_actions
WHERE slug = ? AND enabled = TRUE`, WHERE slug = ? AND enabled = TRUE`,
authorizationID, actionSlug, authorizationID, actionSlug,
) )
if err != nil { if err != nil {
http.Error(w, "unable to authorize installer action", http.StatusInternalServerError) return http.StatusInternalServerError, errors.New("unable to authorize installer action")
return
} }
affected, _ := result.RowsAffected() affected, _ := result.RowsAffected()
if affected != 1 { if affected != 1 {
http.Error(w, "selected installer action is unavailable", http.StatusBadRequest) return http.StatusBadRequest, errors.New("selected installer action is unavailable")
}
}
return 0, nil
}
func (s *Server) handleUpdateAuthorization(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
id, err := parseUintPath(r, "id")
if err != nil {
http.Error(w, "invalid authorization", http.StatusBadRequest)
return
}
hostLimit, err := strconv.Atoi(r.FormValue("host_limit"))
if err != nil || hostLimit < 1 || hostLimit > s.cfg.MaxHostLimit {
http.Error(w, "invalid host limit", http.StatusBadRequest)
return
}
extendDays, err := strconv.Atoi(r.FormValue("extend_days"))
if err != nil || extendDays < 0 || extendDays > maxAuthorizationDays {
http.Error(w, "extension must be between 0 and 90 days", http.StatusBadRequest)
return
}
packageIDs := r.Form["package_id"]
actionSlugs := r.Form["action_slug"]
if len(packageIDs) == 0 && len(actionSlugs) == 0 {
http.Error(w, "select at least one package or installer action", http.StatusBadRequest)
return
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
return
}
defer tx.Rollback()
var oldHostLimit int
var hostCount int
var expiresAt time.Time
var revokedAt sql.NullTime
err = tx.QueryRowContext(
r.Context(),
`SELECT a.host_limit,
(SELECT COUNT(*) FROM authorization_hosts h
WHERE h.authorization_id = a.id),
a.expires_at, a.revoked_at
FROM authorizations a
WHERE a.id = ?`,
id,
).Scan(&oldHostLimit, &hostCount, &expiresAt, &revokedAt)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "authorization not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
return
}
if revokedAt.Valid || !time.Now().Before(expiresAt) {
http.Error(w, "closed authorizations cannot be edited", http.StatusBadRequest)
return
}
if hostLimit < hostCount {
http.Error(
w,
fmt.Sprintf("host limit cannot be lower than %d enrolled hosts", hostCount),
http.StatusBadRequest,
)
return
}
newExpiresAt := expiresAt.Add(time.Duration(extendDays) * 24 * time.Hour)
if _, err := tx.ExecContext(
r.Context(),
`DELETE FROM authorization_packages WHERE authorization_id = ?`,
id,
); err != nil {
http.Error(w, "unable to update authorized packages", http.StatusInternalServerError)
return
}
if _, err := tx.ExecContext(
r.Context(),
`DELETE FROM authorization_actions WHERE authorization_id = ?`,
id,
); err != nil {
http.Error(w, "unable to update installer actions", http.StatusInternalServerError)
return
}
if status, err := saveAuthorizationSelections(
r.Context(), tx, id, packageIDs, actionSlugs,
); err != nil {
http.Error(w, err.Error(), status)
return
}
if _, err := tx.ExecContext(
r.Context(),
`UPDATE authorizations SET host_limit = ?, expires_at = ? WHERE id = ?`,
hostLimit, newExpiresAt, id,
); err != nil {
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
return
}
if extendDays > 0 {
if _, err := tx.ExecContext(
r.Context(),
`UPDATE download_sessions
SET expires_at = ?
WHERE authorization_id = ? AND revoked_at IS NULL`,
newExpiresAt, id,
); err != nil {
http.Error(w, "unable to extend download sessions", http.StatusInternalServerError)
return return
} }
} }
@@ -475,10 +696,21 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
http.Error(w, "unable to save authorization", http.StatusInternalServerError) http.Error(w, "unable to save authorization", http.StatusInternalServerError)
return return
} }
id := uint64(authorizationID)
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r), _ = s.audit(
fmt.Sprintf("host_limit=%d duration_hours=%d customer=%q", hostLimit, durationHours, customerLabel)) r.Context(),
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther) "authorization_updated",
tech.Login,
&id,
"",
"",
s.clientIP(r),
fmt.Sprintf(
"host_limit=%d->%d extend_days=%d packages=%d actions=%d",
oldHostLimit, hostLimit, extendDays, len(packageIDs), len(actionSlugs),
),
)
http.Redirect(w, r, "/portal?notice=Authorization+updated", http.StatusSeeOther)
} }
func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) {
+209
View File
@@ -0,0 +1,209 @@
package app
import (
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
)
func newAuthorizationTestServer(t *testing.T) (*Server, string) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
migration, err := os.ReadFile("../../migrations/001_initial.sql")
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(string(migration)); err != nil {
t.Fatal(err)
}
sessionToken := "authorization-test-session"
sessionHash := hashValue(sessionToken)
if _, err := db.Exec(
`INSERT INTO technician_sessions
(token_hash, csrf_token, gitea_login, display_name, expires_at)
VALUES (?, 'csrf-token', 'taiadmin', 'TAI Admin', ?)`,
sessionHash[:], time.Now().UTC().Add(time.Hour),
); err != nil {
t.Fatal(err)
}
return &Server{
db: db,
cfg: Config{
MaxHostLimit: 25,
CookieSecret: []byte("0123456789abcdef0123456789abcdef"),
},
}, sessionToken
}
func seedAuthorizationForUpdate(t *testing.T, server *Server) time.Time {
t.Helper()
for _, values := range [][]any{
{1, "sentinelone-linux", "SentinelOne", "sentinelone-linux", "1.0", "s1.deb", strings.Repeat("a", 64), true},
{2, "support-tool", "Support Tool", "support-tool", "2.0", "support.deb", strings.Repeat("b", 64), true},
} {
if _, err := server.db.Exec(
`INSERT INTO packages
(id, slug, display_name, package_name, package_version,
file_name, sha256, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
values...,
); err != nil {
t.Fatal(err)
}
}
expiresAt := time.Now().UTC().Add(48 * time.Hour).Truncate(time.Second)
if _, err := server.db.Exec(
`INSERT INTO authorizations
(id, code_hash, code_hint, created_by, customer_label, host_limit, expires_at)
VALUES (1, ?, 'ABCDE', 'taiadmin', 'Test migration', 3, ?)`,
make([]byte, 32), expiresAt,
); err != nil {
t.Fatal(err)
}
if _, err := server.db.Exec(
`INSERT INTO authorization_packages
(authorization_id, package_id, package_slug, display_name,
package_name, package_version, file_name, sha256)
SELECT 1, id, slug, display_name, package_name, package_version,
file_name, sha256
FROM packages WHERE id = 1`,
); err != nil {
t.Fatal(err)
}
if _, err := server.db.Exec(
`INSERT INTO authorization_hosts
(id, authorization_id, host_fingerprint, hostname)
VALUES
(1, 1, ?, 'pve01'),
(2, 1, ?, 'pve02')`,
make([]byte, 32), bytesOf(1, 32),
); err != nil {
t.Fatal(err)
}
if _, err := server.db.Exec(
`INSERT INTO download_sessions
(token_hash, authorization_id, authorization_host_id, expires_at)
VALUES (?, 1, 1, ?)`,
make([]byte, 32), expiresAt,
); err != nil {
t.Fatal(err)
}
return expiresAt
}
func authorizationUpdateRequest(
t *testing.T,
server *Server,
sessionToken string,
values url.Values,
) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(
http.MethodPost,
"/portal/authorizations/1/update",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
request.SetPathValue("id", "1")
response := httptest.NewRecorder()
server.handleUpdateAuthorization(response, request)
return response
}
func TestUpdateAuthorizationExtendsAndReplacesAccess(t *testing.T) {
server, sessionToken := newAuthorizationTestServer(t)
oldExpiresAt := seedAuthorizationForUpdate(t, server)
response := authorizationUpdateRequest(t, server, sessionToken, url.Values{
"csrf_token": {"csrf-token"},
"host_limit": {"6"},
"extend_days": {"7"},
"package_id": {"2"},
"action_slug": {"install-rmm"},
})
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
var hostLimit int
var expiresAt time.Time
if err := server.db.QueryRow(
`SELECT host_limit, expires_at FROM authorizations WHERE id = 1`,
).Scan(&hostLimit, &expiresAt); err != nil {
t.Fatal(err)
}
if hostLimit != 6 {
t.Fatalf("host limit = %d, want 6", hostLimit)
}
wantExpiresAt := oldExpiresAt.Add(7 * 24 * time.Hour)
if !expiresAt.Equal(wantExpiresAt) {
t.Fatalf("expiration = %s, want %s", expiresAt, wantExpiresAt)
}
var selectedPackage uint64
if err := server.db.QueryRow(
`SELECT package_id FROM authorization_packages WHERE authorization_id = 1`,
).Scan(&selectedPackage); err != nil {
t.Fatal(err)
}
if selectedPackage != 2 {
t.Fatalf("selected package = %d, want 2", selectedPackage)
}
var selectedAction string
if err := server.db.QueryRow(
`SELECT action_slug FROM authorization_actions WHERE authorization_id = 1`,
).Scan(&selectedAction); err != nil {
t.Fatal(err)
}
if selectedAction != "install-rmm" {
t.Fatalf("selected action = %q, want install-rmm", selectedAction)
}
var sessionExpiresAt time.Time
if err := server.db.QueryRow(
`SELECT expires_at FROM download_sessions WHERE authorization_id = 1`,
).Scan(&sessionExpiresAt); err != nil {
t.Fatal(err)
}
if !sessionExpiresAt.Equal(wantExpiresAt) {
t.Fatalf("session expiration = %s, want %s", sessionExpiresAt, wantExpiresAt)
}
}
func TestUpdateAuthorizationRejectsLimitBelowEnrolledHosts(t *testing.T) {
server, sessionToken := newAuthorizationTestServer(t)
seedAuthorizationForUpdate(t, server)
response := authorizationUpdateRequest(t, server, sessionToken, url.Values{
"csrf_token": {"csrf-token"},
"host_limit": {"1"},
"extend_days": {"0"},
"package_id": {"1"},
})
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func bytesOf(value byte, size int) []byte {
result := make([]byte, size)
for index := range result {
result[index] = value
}
return result
}
+14 -2
View File
@@ -76,8 +76,8 @@ func LoadConfig() (Config, error) {
} }
cfg.CookieSecure = cfg.PublicURL.Scheme == "https" cfg.CookieSecure = cfg.PublicURL.Scheme == "https"
cfg.DefaultDuration, err = time.ParseDuration( cfg.DefaultDuration, err = parseAuthorizationDuration(
envDefault("TAPM_DEFAULT_DURATION", "3h"), envDefault("TAPM_DEFAULT_DURATION", "7d"),
) )
if err != nil || cfg.DefaultDuration <= 0 { if err != nil || cfg.DefaultDuration <= 0 {
return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive") return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive")
@@ -145,6 +145,18 @@ func LoadConfig() (Config, error) {
return cfg, nil return cfg, nil
} }
func parseAuthorizationDuration(value string) (time.Duration, error) {
value = strings.TrimSpace(value)
if strings.HasSuffix(value, "d") {
days, err := strconv.Atoi(strings.TrimSuffix(value, "d"))
if err != nil || days < 1 {
return 0, fmt.Errorf("invalid day duration")
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(value)
}
func parseAbsoluteURL(name string, allowInsecureHTTP bool) (*url.URL, error) { func parseAbsoluteURL(name string, allowInsecureHTTP bool) (*url.URL, error) {
return parseURLValue(name, os.Getenv(name), allowInsecureHTTP) return parseURLValue(name, os.Getenv(name), allowInsecureHTTP)
} }
+25
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"strings" "strings"
"testing" "testing"
"time"
) )
func setRequiredConfigEnvironment(t *testing.T, scheme string) { func setRequiredConfigEnvironment(t *testing.T, scheme string) {
@@ -63,3 +64,27 @@ func TestConfigAcceptsExplicitInternalGiteaURL(t *testing.T) {
t.Fatalf("GiteaInternalURL = %q, want configured private URL", got) t.Fatalf("GiteaInternalURL = %q, want configured private URL", got)
} }
} }
func TestConfigAcceptsDayBasedAuthorizationDuration(t *testing.T) {
setRequiredConfigEnvironment(t, "https")
t.Setenv("TAPM_DEFAULT_DURATION", "7d")
cfg, err := LoadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.DefaultDuration != 7*24*time.Hour {
t.Fatalf("DefaultDuration = %s, want 7 days", cfg.DefaultDuration)
}
}
func TestConfigKeepsLegacyHourDurationCompatibility(t *testing.T) {
setRequiredConfigEnvironment(t, "https")
t.Setenv("TAPM_DEFAULT_DURATION", "3h")
cfg, err := LoadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.DefaultDuration != 3*time.Hour {
t.Fatalf("DefaultDuration = %s, want 3 hours", cfg.DefaultDuration)
}
}
+5 -1
View File
@@ -66,6 +66,8 @@ type authorizationRecord struct {
RevokedAt sql.NullTime RevokedAt sql.NullTime
Packages string Packages string
Actions string Actions string
PackageIDs map[uint64]bool
ActionSlugs map[string]bool
} }
type auditRecord struct { type auditRecord struct {
@@ -103,7 +105,8 @@ type pageData struct {
FleetHosts []fleetHostRecord FleetHosts []fleetHostRecord
NewCode string NewCode string
DefaultHostLimit int DefaultHostLimit int
DefaultDuration string DefaultDays int
MaxHostLimit int
Error string Error string
Notice string Notice string
GiteaLoginURL string GiteaLoginURL string
@@ -200,6 +203,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal)) mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal))
mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal)) mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal))
mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization)) mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization))
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 /portal/packages/upload", s.requireTechnician(s.handleUploadPackage))
+9 -5
View File
@@ -30,12 +30,16 @@ func TestPortalTemplateExecutes(t *testing.T) {
Enabled: true, Enabled: true,
}}, }},
Authorizations: []authorizationRecord{{ Authorizations: []authorizationRecord{{
ID: 1, ID: 1,
CreatedBy: "taiadmin", CreatedBy: "taiadmin",
ExpiresAt: time.Now().Add(time.Hour), ExpiresAt: time.Now().Add(time.Hour),
Packages: "SentinelOne Linux Agent", Packages: "SentinelOne Linux Agent",
Actions: "Install RMM", Actions: "Install RMM",
PackageIDs: map[uint64]bool{1: true},
ActionSlugs: map[string]bool{"install-rmm": true},
}}, }},
DefaultDays: 7,
MaxHostLimit: 25,
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+6
View File
@@ -212,6 +212,12 @@ legend { margin-bottom: 8px; }
} }
.authorization.active .status, .status.enabled { color: var(--accent-dark); border-color: rgba(105, 145, 64, .42); background: rgba(185, 217, 130, .25); } .authorization.active .status, .status.enabled { color: var(--accent-dark); border-color: rgba(105, 145, 64, .42); background: rgba(185, 217, 130, .25); }
.code-hint { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .code-hint { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.authorization-editor { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
.authorization-editor summary { color: var(--accent-dark); cursor: pointer; font-weight: 800; }
.authorization-editor[open] summary { margin-bottom: 18px; }
.authorization-editor fieldset { padding: 14px; }
.authorization-editor .button { justify-self: start; }
.authorization-revoke { margin-top: 14px; }
dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; margin: 18px 0; } dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; margin: 18px 0; }
dl div { min-width: 0; } dl div { min-width: 0; }
dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; } dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; }
+59 -5
View File
@@ -37,7 +37,7 @@
<div class="policy-card"> <div class="policy-card">
<span>Default policy</span> <span>Default policy</span>
<strong>{{.DefaultHostLimit}} hosts</strong> <strong>{{.DefaultHostLimit}} hosts</strong>
<strong>{{.DefaultDuration}} hours</strong> <strong>{{.DefaultDays}} days</strong>
</div> </div>
</section> </section>
@@ -60,11 +60,11 @@
<div class="field-row"> <div class="field-row">
<label> <label>
Maximum hosts Maximum hosts
<input name="host_limit" type="number" min="1" max="25" value="{{.DefaultHostLimit}}" required> <input name="host_limit" type="number" min="1" max="{{.MaxHostLimit}}" value="{{.DefaultHostLimit}}" required>
</label> </label>
<label> <label>
Duration in hours Duration in days
<input name="duration_hours" type="number" min="1" max="24" value="{{.DefaultDuration}}" required> <input name="duration_days" type="number" min="1" max="90" value="{{.DefaultDays}}" required>
</label> </label>
</div> </div>
@@ -116,6 +116,7 @@
<div class="authorization-list"> <div class="authorization-list">
{{range .Authorizations}} {{range .Authorizations}}
{{$authorization := .}}
<article class="authorization {{if isActive .}}active{{else}}closed{{end}}"> <article class="authorization {{if isActive .}}active{{else}}closed{{end}}">
<div class="authorization-top"> <div class="authorization-top">
<div> <div>
@@ -132,7 +133,60 @@
<div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div> <div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div>
</dl> </dl>
{{if isActive .}} {{if isActive .}}
<form action="/portal/authorizations/{{.ID}}/revoke" method="post"> <details class="authorization-editor">
<summary>Edit access</summary>
<form action="/portal/authorizations/{{.ID}}/update" method="post" class="stack compact">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<div class="field-row">
<label>
Maximum hosts
<input name="host_limit" type="number" min="1" max="{{$.MaxHostLimit}}" value="{{.HostLimit}}" required>
</label>
<label>
Extend by days
<input name="extend_days" type="number" min="0" max="90" value="0" required>
</label>
</div>
<small class="form-help">Use 0 to keep the current expiration. Extensions are added to the existing deadline.</small>
<fieldset>
<legend>Allowed packages</legend>
{{range $.Packages}}
{{if .Enabled}}
<label class="package-choice">
<input type="checkbox" name="package_id" value="{{.ID}}" {{if index $authorization.PackageIDs .ID}}checked{{end}}>
<span>
<strong>{{.DisplayName}}</strong>
<small>{{.PackageVersion}}</small>
</span>
</label>
{{end}}
{{else}}
<p class="empty">No packages have been enabled yet.</p>
{{end}}
</fieldset>
<fieldset>
<legend>Allowed installer actions</legend>
{{range $.Actions}}
{{if .Enabled}}
<label class="package-choice">
<input type="checkbox" name="action_slug" value="{{.Slug}}" {{if index $authorization.ActionSlugs .Slug}}checked{{end}}>
<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 secondary" type="submit">Save authorization</button>
</form>
</details>
<form class="authorization-revoke" action="/portal/authorizations/{{.ID}}/revoke" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="text-button danger" type="submit">Revoke now</button> <button class="text-button danger" type="submit">Revoke now</button>
</form> </form>
+1
View File
@@ -79,6 +79,7 @@ assert_env 'TAPM_PUBLIC_URL=http://broker.test:8780'
assert_env 'TAPM_GITEA_URL=http://git.test:8780' assert_env 'TAPM_GITEA_URL=http://git.test:8780'
assert_env 'TAPM_GITEA_INTERNAL_URL=http://gitea:3000' assert_env 'TAPM_GITEA_INTERNAL_URL=http://gitea:3000'
assert_env 'GITEA_PUBLIC_URL=http://git.test:8780/' assert_env 'GITEA_PUBLIC_URL=http://git.test:8780/'
assert_env 'TAPM_DEFAULT_DURATION=7d'
grep -F -- '-f compose.yaml -f compose.http.yaml up -d gitea nginx' \ grep -F -- '-f compose.yaml -f compose.http.yaml up -d gitea nginx' \
"$MOCK_DOCKER_LOG" >/dev/null || { "$MOCK_DOCKER_LOG" >/dev/null || {
printf 'FAIL: HTTP bootstrap did not select compose.http.yaml\n' >&2 printf 'FAIL: HTTP bootstrap did not select compose.http.yaml\n' >&2