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_ALLOWED_GITEA_USERS=taiadmin
TAPM_COOKIE_SECRET=
TAPM_DEFAULT_DURATION=3h
TAPM_DEFAULT_DURATION=7d
TAPM_DEFAULT_HOST_LIMIT=3
TAPM_MAX_HOST_LIMIT=25
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
`./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
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.
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
`docker compose config` output: Compose resolves the broker's `.env` file and
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_ALLOWED_GITEA_USERS=taiadmin\n'
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_MAX_HOST_LIMIT=25\n'
printf 'TAPM_MAX_UPLOAD_BYTES=1073741824\n'
+257 -25
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
@@ -10,6 +11,8 @@ import (
"time"
)
const maxAuthorizationDays = 90
func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
if hasAuditQuery(r) {
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,
NewCode: r.URL.Query().Get("code"),
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"),
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 {
query := r.URL.Query()
for _, field := range []string{
@@ -342,16 +351,19 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
), '')
FROM authorizations a
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
LIMIT 100`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []authorizationRecord
for rows.Next() {
var record authorizationRecord
record := authorizationRecord{
PackageIDs: make(map[uint64]bool),
ActionSlugs: make(map[string]bool),
}
if err := rows.Scan(
&record.ID,
&record.CodeHint,
@@ -369,7 +381,76 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
}
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) {
@@ -379,9 +460,9 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
http.Error(w, "invalid host limit", http.StatusBadRequest)
return
}
durationHours, err := strconv.Atoi(r.FormValue("duration_hours"))
if err != nil || durationHours < 1 || durationHours > 24 {
http.Error(w, "duration must be between 1 and 24 hours", http.StatusBadRequest)
durationDays, err := strconv.Atoi(r.FormValue("duration_days"))
if err != nil || durationDays < 1 || durationDays > maxAuthorizationDays {
http.Error(w, "duration must be between 1 and 90 days", http.StatusBadRequest)
return
}
packageIDs := r.Form["package_id"]
@@ -396,7 +477,7 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return
}
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"))
if len(customerLabel) > 255 {
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
}
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 {
packageID, err := strconv.ParseUint(rawID, 10, 64)
if err != nil {
http.Error(w, "invalid package selection", http.StatusBadRequest)
return
return http.StatusBadRequest, errors.New("invalid package selection")
}
if seenPackages[packageID] {
continue
}
seenPackages[packageID] = true
result, err := tx.ExecContext(
r.Context(),
ctx,
`INSERT INTO authorization_packages
(authorization_id, package_id, package_slug, display_name,
package_name, package_version, file_name, sha256)
@@ -439,35 +547,148 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
authorizationID, packageID,
)
if err != nil {
http.Error(w, "unable to authorize package", http.StatusInternalServerError)
return
return http.StatusInternalServerError, errors.New("unable to authorize package")
}
affected, _ := result.RowsAffected()
if affected != 1 {
http.Error(w, "selected package is unavailable", http.StatusBadRequest)
return
return http.StatusBadRequest, errors.New("selected package is unavailable")
}
}
seenActions := make(map[string]bool)
for _, actionSlug := range actionSlugs {
actionSlug = strings.TrimSpace(actionSlug)
if !validSlug(actionSlug) {
http.Error(w, "invalid installer action", http.StatusBadRequest)
return
return http.StatusBadRequest, errors.New("invalid installer action")
}
if seenActions[actionSlug] {
continue
}
seenActions[actionSlug] = true
result, err := tx.ExecContext(
r.Context(),
ctx,
`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
return http.StatusInternalServerError, errors.New("unable to authorize installer action")
}
affected, _ := result.RowsAffected()
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
}
}
@@ -475,10 +696,21 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
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_hours=%d customer=%q", hostLimit, durationHours, customerLabel))
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
_ = s.audit(
r.Context(),
"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) {
+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.DefaultDuration, err = time.ParseDuration(
envDefault("TAPM_DEFAULT_DURATION", "3h"),
cfg.DefaultDuration, err = parseAuthorizationDuration(
envDefault("TAPM_DEFAULT_DURATION", "7d"),
)
if err != nil || cfg.DefaultDuration <= 0 {
return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive")
@@ -145,6 +145,18 @@ func LoadConfig() (Config, error) {
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) {
return parseURLValue(name, os.Getenv(name), allowInsecureHTTP)
}
+25
View File
@@ -3,6 +3,7 @@ package app
import (
"strings"
"testing"
"time"
)
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)
}
}
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
Packages string
Actions string
PackageIDs map[uint64]bool
ActionSlugs map[string]bool
}
type auditRecord struct {
@@ -103,7 +105,8 @@ type pageData struct {
FleetHosts []fleetHostRecord
NewCode string
DefaultHostLimit int
DefaultDuration string
DefaultDays int
MaxHostLimit int
Error string
Notice 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/hosts", s.requireTechnician(s.handleFleetPortal))
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/packages", s.requireTechnician(s.handleUpsertPackage))
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,
}},
Authorizations: []authorizationRecord{{
ID: 1,
CreatedBy: "taiadmin",
ExpiresAt: time.Now().Add(time.Hour),
Packages: "SentinelOne Linux Agent",
Actions: "Install RMM",
ID: 1,
CreatedBy: "taiadmin",
ExpiresAt: time.Now().Add(time.Hour),
Packages: "SentinelOne Linux Agent",
Actions: "Install RMM",
PackageIDs: map[uint64]bool{1: true},
ActionSlugs: map[string]bool{"install-rmm": true},
}},
DefaultDays: 7,
MaxHostLimit: 25,
})
if err != nil {
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); }
.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 div { min-width: 0; }
dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; }
+59 -5
View File
@@ -37,7 +37,7 @@
<div class="policy-card">
<span>Default policy</span>
<strong>{{.DefaultHostLimit}} hosts</strong>
<strong>{{.DefaultDuration}} hours</strong>
<strong>{{.DefaultDays}} days</strong>
</div>
</section>
@@ -60,11 +60,11 @@
<div class="field-row">
<label>
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>
Duration in hours
<input name="duration_hours" type="number" min="1" max="24" value="{{.DefaultDuration}}" required>
Duration in days
<input name="duration_days" type="number" min="1" max="90" value="{{.DefaultDays}}" required>
</label>
</div>
@@ -116,6 +116,7 @@
<div class="authorization-list">
{{range .Authorizations}}
{{$authorization := .}}
<article class="authorization {{if isActive .}}active{{else}}closed{{end}}">
<div class="authorization-top">
<div>
@@ -132,7 +133,60 @@
<div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div>
</dl>
{{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}}">
<button class="text-button danger" type="submit">Revoke now</button>
</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_INTERNAL_URL=http://gitea:3000'
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' \
"$MOCK_DOCKER_LOG" >/dev/null || {
printf 'FAIL: HTTP bootstrap did not select compose.http.yaml\n' >&2