update
This commit is contained in:
@@ -18,3 +18,4 @@ 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
|
||||||
TAPM_TRUST_PROXY_HEADERS=true
|
TAPM_TRUST_PROXY_HEADERS=true
|
||||||
|
TAPM_DISPLAY_TIME_ZONE=America/Chicago
|
||||||
|
|||||||
+6
-2
@@ -13,6 +13,7 @@ Set the broker to listen on all addresses on both webservers:
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
TAPM_LISTEN_ADDR=0.0.0.0:8080
|
TAPM_LISTEN_ADDR=0.0.0.0:8080
|
||||||
|
TAPM_DISPLAY_TIME_ZONE=America/Chicago
|
||||||
```
|
```
|
||||||
|
|
||||||
The wildcard bind accepts connections through each node's physical address and
|
The wildcard bind accepts connections through each node's physical address and
|
||||||
@@ -206,8 +207,11 @@ technician's Gitea credentials for package operations.
|
|||||||
- Audit records are retained indefinitely unless an administrator establishes a
|
- Audit records are retained indefinitely unless an administrator establishes a
|
||||||
database retention policy. The Codes view previews the newest 15 records; the
|
database retention policy. The Codes view previews the newest 15 records; the
|
||||||
Audit view can filter retained history by event, customer/deployment label,
|
Audit view can filter retained history by event, customer/deployment label,
|
||||||
user, host, package, source IP, or details and displays up to the newest 250
|
user, host, package, LAN/WAN IP, or details and displays up to the newest 250
|
||||||
matching events.
|
matching events. Audit timestamps are stored in UTC and rendered in
|
||||||
|
`TAPM_DISPLAY_TIME_ZONE`. Deployment exchanges record both the device LAN
|
||||||
|
address reported by TAPM and the WAN/source address received through the
|
||||||
|
trusted proxy chain.
|
||||||
- Keep `.env` outside Git and readable only by the service administrator.
|
- Keep `.env` outside Git and readable only by the service administrator.
|
||||||
- Rotate both Gitea package tokens and the OAuth secret if either webserver is
|
- Rotate both Gitea package tokens and the OAuth secret if either webserver is
|
||||||
compromised.
|
compromised.
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ func TestAuditQueryUsesPlaceholders(t *testing.T) {
|
|||||||
"taiadmin",
|
"taiadmin",
|
||||||
"pve01",
|
"pve01",
|
||||||
"sentinelone-linux",
|
"sentinelone-linux",
|
||||||
"10.10.1.25",
|
|
||||||
"install-rmm",
|
"install-rmm",
|
||||||
|
"10.10.1.25",
|
||||||
|
"10.10.1.25",
|
||||||
250,
|
250,
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(arguments, expectedArguments) {
|
if !reflect.DeepEqual(arguments, expectedArguments) {
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ func (s *Server) listAuditEventTypes(r *http.Request) ([]string, error) {
|
|||||||
|
|
||||||
func auditQuery(filters auditFilters, limit int) (string, []any) {
|
func auditQuery(filters auditFilters, limit int) (string, []any) {
|
||||||
query := `SELECT ae.event_type, COALESCE(a.customer_label, ''), ae.actor,
|
query := `SELECT ae.event_type, COALESCE(a.customer_label, ''), ae.actor,
|
||||||
ae.hostname, ae.package_slug, ae.source_ip, ae.details, ae.created_at
|
ae.hostname, ae.package_slug, ae.source_ip, ae.lan_ip,
|
||||||
|
ae.details, ae.created_at
|
||||||
FROM audit_events ae
|
FROM audit_events ae
|
||||||
LEFT JOIN authorizations a ON a.id = ae.authorization_id
|
LEFT JOIN authorizations a ON a.id = ae.authorization_id
|
||||||
WHERE 1 = 1`
|
WHERE 1 = 1`
|
||||||
@@ -233,7 +234,6 @@ func auditQuery(filters auditFilters, limit int) (string, []any) {
|
|||||||
{"ae.actor", filters.User},
|
{"ae.actor", filters.User},
|
||||||
{"ae.hostname", filters.Hostname},
|
{"ae.hostname", filters.Hostname},
|
||||||
{"ae.package_slug", filters.PackageSlug},
|
{"ae.package_slug", filters.PackageSlug},
|
||||||
{"ae.source_ip", filters.SourceIP},
|
|
||||||
{"ae.details", filters.Details},
|
{"ae.details", filters.Details},
|
||||||
}
|
}
|
||||||
for _, filter := range containsFilters {
|
for _, filter := range containsFilters {
|
||||||
@@ -243,6 +243,10 @@ func auditQuery(filters auditFilters, limit int) (string, []any) {
|
|||||||
query += " AND LOCATE(?, " + filter.column + ") > 0"
|
query += " AND LOCATE(?, " + filter.column + ") > 0"
|
||||||
arguments = append(arguments, filter.value)
|
arguments = append(arguments, filter.value)
|
||||||
}
|
}
|
||||||
|
if filters.SourceIP != "" {
|
||||||
|
query += " AND (LOCATE(?, ae.source_ip) > 0 OR LOCATE(?, ae.lan_ip) > 0)"
|
||||||
|
arguments = append(arguments, filters.SourceIP, filters.SourceIP)
|
||||||
|
}
|
||||||
query += " ORDER BY ae.created_at DESC LIMIT ?"
|
query += " ORDER BY ae.created_at DESC LIMIT ?"
|
||||||
arguments = append(arguments, limit)
|
arguments = append(arguments, limit)
|
||||||
return query, arguments
|
return query, arguments
|
||||||
@@ -270,6 +274,7 @@ func (s *Server) listAuditEvents(r *http.Request, filters auditFilters, limit in
|
|||||||
&record.Hostname,
|
&record.Hostname,
|
||||||
&record.PackageSlug,
|
&record.PackageSlug,
|
||||||
&record.SourceIP,
|
&record.SourceIP,
|
||||||
|
&record.LANIP,
|
||||||
&record.Details,
|
&record.Details,
|
||||||
&record.CreatedAt,
|
&record.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -618,6 +623,23 @@ func (s *Server) audit(
|
|||||||
packageSlug string,
|
packageSlug string,
|
||||||
sourceIP string,
|
sourceIP string,
|
||||||
details string,
|
details string,
|
||||||
|
) error {
|
||||||
|
return s.auditWithNetwork(
|
||||||
|
ctx, eventType, user, authorizationID, hostname, packageSlug,
|
||||||
|
sourceIP, "", details,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) auditWithNetwork(
|
||||||
|
ctx context.Context,
|
||||||
|
eventType string,
|
||||||
|
user string,
|
||||||
|
authorizationID *uint64,
|
||||||
|
hostname string,
|
||||||
|
packageSlug string,
|
||||||
|
sourceIP string,
|
||||||
|
lanIP string,
|
||||||
|
details string,
|
||||||
) error {
|
) error {
|
||||||
var authID any
|
var authID any
|
||||||
if authorizationID != nil {
|
if authorizationID != nil {
|
||||||
@@ -626,9 +648,10 @@ func (s *Server) audit(
|
|||||||
_, err := s.db.ExecContext(
|
_, err := s.db.ExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
`INSERT INTO audit_events
|
`INSERT INTO audit_events
|
||||||
(event_type, actor, authorization_id, hostname, package_slug, source_ip, details)
|
(event_type, actor, authorization_id, hostname, package_slug,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
source_ip, lan_ip, details)
|
||||||
eventType, user, authID, hostname, packageSlug, sourceIP, details,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
eventType, user, authID, hostname, packageSlug, sourceIP, lanIP, details,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -28,6 +29,7 @@ type Config struct {
|
|||||||
MaxHostLimit int
|
MaxHostLimit int
|
||||||
MaxUploadBytes int64
|
MaxUploadBytes int64
|
||||||
TrustProxyHeaders bool
|
TrustProxyHeaders bool
|
||||||
|
DisplayTimeZone *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (Config, error) {
|
func LoadConfig() (Config, error) {
|
||||||
@@ -82,6 +84,12 @@ func LoadConfig() (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg, fmt.Errorf("TAPM_TRUST_PROXY_HEADERS: %w", err)
|
return cfg, fmt.Errorf("TAPM_TRUST_PROXY_HEADERS: %w", err)
|
||||||
}
|
}
|
||||||
|
cfg.DisplayTimeZone, err = time.LoadLocation(
|
||||||
|
envDefault("TAPM_DISPLAY_TIME_ZONE", "America/Chicago"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("TAPM_DISPLAY_TIME_ZONE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg.AllowedGiteaUsers = make(map[string]struct{})
|
cfg.AllowedGiteaUsers = make(map[string]struct{})
|
||||||
for _, user := range strings.Split(
|
for _, user := range strings.Split(
|
||||||
|
|||||||
+33
-12
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,6 +17,7 @@ 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"`
|
||||||
|
LANIP string `json:"lan_ip,omitempty"`
|
||||||
RequestedAction string `json:"requested_action,omitempty"`
|
RequestedAction string `json:"requested_action,omitempty"`
|
||||||
RequestedPackage string `json:"requested_package,omitempty"`
|
RequestedPackage string `json:"requested_package,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -57,8 +59,17 @@ 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.LANIP = strings.TrimSpace(request.LANIP)
|
||||||
request.RequestedAction = strings.TrimSpace(request.RequestedAction)
|
request.RequestedAction = strings.TrimSpace(request.RequestedAction)
|
||||||
request.RequestedPackage = strings.TrimSpace(request.RequestedPackage)
|
request.RequestedPackage = strings.TrimSpace(request.RequestedPackage)
|
||||||
|
if request.LANIP != "" {
|
||||||
|
parsedLANIP := net.ParseIP(request.LANIP)
|
||||||
|
if parsedLANIP == nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "lan_ip must be a valid IP address"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.LANIP = parsedLANIP.String()
|
||||||
|
}
|
||||||
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.RequestedAction != "" && !validSlug(request.RequestedAction)) ||
|
||||||
@@ -75,11 +86,12 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
|
|||||||
status = http.StatusForbidden
|
status = http.StatusForbidden
|
||||||
message = "authorization is invalid, expired, revoked, or at its host limit"
|
message = "authorization is invalid, expired, revoked, or at its host limit"
|
||||||
}
|
}
|
||||||
_ = s.audit(r.Context(), "code_exchange_failed", "", nil, request.Hostname, "", sourceIP, err.Error())
|
_ = s.auditWithNetwork(r.Context(), "code_exchange_failed", "", nil,
|
||||||
|
request.Hostname, "", sourceIP, request.LANIP, err.Error())
|
||||||
writeJSON(w, status, map[string]string{"error": message})
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.audit(
|
_ = s.auditWithNetwork(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
"code_exchanged",
|
"code_exchanged",
|
||||||
"",
|
"",
|
||||||
@@ -87,6 +99,7 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
|
|||||||
request.Hostname,
|
request.Hostname,
|
||||||
request.RequestedPackage,
|
request.RequestedPackage,
|
||||||
sourceIP,
|
sourceIP,
|
||||||
|
request.LANIP,
|
||||||
"requested_action="+request.RequestedAction,
|
"requested_action="+request.RequestedAction,
|
||||||
)
|
)
|
||||||
writeJSON(w, http.StatusOK, response)
|
writeJSON(w, http.StatusOK, response)
|
||||||
@@ -199,9 +212,9 @@ func (s *Server) exchangeDeploymentCode(
|
|||||||
result, err := tx.ExecContext(
|
result, err := tx.ExecContext(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`INSERT INTO authorization_hosts
|
`INSERT INTO authorization_hosts
|
||||||
(authorization_id, host_fingerprint, hostname)
|
(authorization_id, host_fingerprint, hostname, lan_ip)
|
||||||
VALUES (?, ?, ?)`,
|
VALUES (?, ?, ?, ?)`,
|
||||||
authorizationID, fingerprintHash[:], request.Hostname,
|
authorizationID, fingerprintHash[:], request.Hostname, request.LANIP,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response, 0, err
|
return response, 0, err
|
||||||
@@ -214,9 +227,11 @@ func (s *Server) exchangeDeploymentCode(
|
|||||||
_, err = tx.ExecContext(
|
_, err = tx.ExecContext(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`UPDATE authorization_hosts
|
`UPDATE authorization_hosts
|
||||||
SET hostname = ?, last_seen_at = UTC_TIMESTAMP(6)
|
SET hostname = ?,
|
||||||
|
lan_ip = COALESCE(NULLIF(?, ''), lan_ip),
|
||||||
|
last_seen_at = UTC_TIMESTAMP(6)
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
request.Hostname, hostID,
|
request.Hostname, request.LANIP, hostID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response, 0, err
|
return response, 0, err
|
||||||
@@ -328,11 +343,12 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
var packageInfo packageRecord
|
var packageInfo packageRecord
|
||||||
var authorizationID uint64
|
var authorizationID uint64
|
||||||
var hostname string
|
var hostname string
|
||||||
|
var lanIP 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, p.slug, p.display_name, p.package_name,
|
||||||
p.package_version, p.file_name, p.sha256, p.enabled,
|
p.package_version, p.file_name, p.sha256, p.enabled,
|
||||||
ds.authorization_id, ah.hostname
|
ds.authorization_id, ah.hostname, ah.lan_ip
|
||||||
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
|
||||||
JOIN authorization_hosts ah ON ah.id = ds.authorization_host_id
|
JOIN authorization_hosts ah ON ah.id = ds.authorization_host_id
|
||||||
@@ -357,6 +373,7 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
&packageInfo.Enabled,
|
&packageInfo.Enabled,
|
||||||
&authorizationID,
|
&authorizationID,
|
||||||
&hostname,
|
&hostname,
|
||||||
|
&lanIP,
|
||||||
)
|
)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "package is not authorized"})
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "package is not authorized"})
|
||||||
@@ -385,13 +402,15 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
upstreamRequest.SetBasicAuth(s.cfg.GiteaPackageUser, s.cfg.GiteaPackageToken)
|
upstreamRequest.SetBasicAuth(s.cfg.GiteaPackageUser, s.cfg.GiteaPackageToken)
|
||||||
upstreamResponse, err := s.packageClient.Do(upstreamRequest)
|
upstreamResponse, err := s.packageClient.Do(upstreamRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), err.Error())
|
_ = s.auditWithNetwork(r.Context(), "package_download_failed", "",
|
||||||
|
&authorizationID, hostname, slug, s.clientIP(r), lanIP, err.Error())
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry is unavailable"})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry is unavailable"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer upstreamResponse.Body.Close()
|
defer upstreamResponse.Body.Close()
|
||||||
if upstreamResponse.StatusCode != http.StatusOK {
|
if upstreamResponse.StatusCode != http.StatusOK {
|
||||||
_ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), upstreamResponse.Status)
|
_ = s.auditWithNetwork(r.Context(), "package_download_failed", "",
|
||||||
|
&authorizationID, hostname, slug, s.clientIP(r), lanIP, upstreamResponse.Status)
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry rejected the request"})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry rejected the request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -402,8 +421,10 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
if _, err := io.Copy(w, upstreamResponse.Body); err != nil {
|
if _, err := io.Copy(w, upstreamResponse.Body); err != nil {
|
||||||
_ = s.audit(r.Context(), "package_download_failed", "", &authorizationID, hostname, slug, s.clientIP(r), err.Error())
|
_ = s.auditWithNetwork(r.Context(), "package_download_failed", "",
|
||||||
|
&authorizationID, hostname, slug, s.clientIP(r), lanIP, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.audit(r.Context(), "package_downloaded", "", &authorizationID, hostname, slug, s.clientIP(r), "")
|
_ = s.auditWithNetwork(r.Context(), "package_downloaded", "",
|
||||||
|
&authorizationID, hostname, slug, s.clientIP(r), lanIP, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ type auditRecord struct {
|
|||||||
Hostname string
|
Hostname string
|
||||||
PackageSlug string
|
PackageSlug string
|
||||||
SourceIP string
|
SourceIP string
|
||||||
|
LANIP string
|
||||||
Details string
|
Details string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -125,7 +126,7 @@ func New(cfg Config) (*Server, error) {
|
|||||||
return nil, fmt.Errorf("database: %w", err)
|
return nil, fmt.Errorf("database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
templates, err := parseTemplates()
|
templates, err := parseTemplates(cfg.DisplayTimeZone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -162,10 +163,13 @@ func New(cfg Config) (*Server, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseTemplates() (*template.Template, error) {
|
func parseTemplates(displayTimeZone *time.Location) (*template.Template, error) {
|
||||||
|
if displayTimeZone == nil {
|
||||||
|
displayTimeZone = time.UTC
|
||||||
|
}
|
||||||
return template.New("").Funcs(template.FuncMap{
|
return template.New("").Funcs(template.FuncMap{
|
||||||
"formatTime": func(value time.Time) string {
|
"formatTime": func(value time.Time) string {
|
||||||
return value.Local().Format("Jan 2, 2006 3:04 PM")
|
return value.In(displayTimeZone).Format("Jan 2, 2006 3:04 PM MST")
|
||||||
},
|
},
|
||||||
"isActive": func(record authorizationRecord) bool {
|
"isActive": func(record authorizationRecord) bool {
|
||||||
return !record.RevokedAt.Valid && time.Now().Before(record.ExpiresAt)
|
return !record.RevokedAt.Valid && time.Now().Before(record.ExpiresAt)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPortalTemplateExecutes(t *testing.T) {
|
func TestPortalTemplateExecutes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
templates, err := parseTemplates()
|
templates, err := parseTemplates(time.UTC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ func TestPortalTemplateExecutes(t *testing.T) {
|
|||||||
|
|
||||||
func TestPackageAndAuditTemplatesExecute(t *testing.T) {
|
func TestPackageAndAuditTemplatesExecute(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
templates, err := parseTemplates()
|
templates, err := parseTemplates(time.UTC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -70,9 +72,42 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) {
|
|||||||
EventType: "package_downloaded",
|
EventType: "package_downloaded",
|
||||||
CustomerLabel: "Acme cluster refresh",
|
CustomerLabel: "Acme cluster refresh",
|
||||||
Hostname: "pve01",
|
Hostname: "pve01",
|
||||||
|
SourceIP: "203.0.113.10",
|
||||||
|
LANIP: "10.10.1.25",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}}
|
}}
|
||||||
if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil {
|
if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuditTemplateUsesConfiguredTimeZone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
location, err := time.LoadLocation("America/Chicago")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
templates, err := parseTemplates(location)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
err = templates.ExecuteTemplate(&output, "audit.html", pageData{
|
||||||
|
Title: "Test",
|
||||||
|
Technician: &technician{DisplayName: "Technician"},
|
||||||
|
CurrentView: "audit",
|
||||||
|
AuditFilters: auditFilters{
|
||||||
|
TimeRange: "30d",
|
||||||
|
},
|
||||||
|
AuditEvents: []auditRecord{{
|
||||||
|
EventType: "code_exchanged",
|
||||||
|
CreatedAt: time.Date(2026, time.January, 15, 12, 0, 0, 0, time.UTC),
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output.String(), "Jan 15, 2026 6:00 AM CST") {
|
||||||
|
t.Fatalf("audit timestamp was not rendered in America/Chicago: %s", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<label>User <input name="audit_user" value="{{.AuditFilters.User}}" placeholder="taiadmin"></label>
|
<label>User <input name="audit_user" value="{{.AuditFilters.User}}" placeholder="taiadmin"></label>
|
||||||
<label>Hostname <input name="audit_hostname" value="{{.AuditFilters.Hostname}}" placeholder="pve01"></label>
|
<label>Hostname <input name="audit_hostname" value="{{.AuditFilters.Hostname}}" placeholder="pve01"></label>
|
||||||
<label>Package ID <input name="audit_package" value="{{.AuditFilters.PackageSlug}}" placeholder="sentinelone-linux"></label>
|
<label>Package ID <input name="audit_package" value="{{.AuditFilters.PackageSlug}}" placeholder="sentinelone-linux"></label>
|
||||||
<label>Source IP <input name="audit_ip" value="{{.AuditFilters.SourceIP}}" placeholder="10.10.1.25"></label>
|
<label>LAN or WAN IP <input name="audit_ip" value="{{.AuditFilters.SourceIP}}" placeholder="10.10.1.25"></label>
|
||||||
<label>Details contain <input name="audit_details" value="{{.AuditFilters.Details}}" placeholder="customer or action"></label>
|
<label>Details contain <input name="audit_details" value="{{.AuditFilters.Details}}" placeholder="customer or action"></label>
|
||||||
<div class="audit-filter-actions">
|
<div class="audit-filter-actions">
|
||||||
<button class="button secondary" type="submit">Apply filters</button>
|
<button class="button secondary" type="submit">Apply filters</button>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<span>Event</span>
|
<span>Event</span>
|
||||||
<span>Customer / deployment</span>
|
<span>Customer / deployment</span>
|
||||||
<span>User / host / package</span>
|
<span>User / host / package</span>
|
||||||
<span>Source / details</span>
|
<span>WAN / LAN / details</span>
|
||||||
</div>
|
</div>
|
||||||
{{range .AuditEvents}}
|
{{range .AuditEvents}}
|
||||||
<div class="audit-row">
|
<div class="audit-row">
|
||||||
@@ -46,7 +46,9 @@
|
|||||||
{{if .PackageSlug}}<span>{{.PackageSlug}}</span>{{end}}
|
{{if .PackageSlug}}<span>{{.PackageSlug}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{{.SourceIP}}</span>
|
{{if .SourceIP}}<span>WAN: {{.SourceIP}}</span>{{end}}
|
||||||
|
{{if .LANIP}}<span>LAN: {{.LANIP}}</span>{{end}}
|
||||||
|
{{if and (not .SourceIP) (not .LANIP)}}<span>—</span>{{end}}
|
||||||
{{if .Details}}<small>{{.Details}}</small>{{end}}
|
{{if .Details}}<small>{{.Details}}</small>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE authorization_hosts
|
||||||
|
ADD COLUMN IF NOT EXISTS lan_ip VARCHAR(64) NOT NULL DEFAULT ''
|
||||||
|
AFTER hostname;
|
||||||
|
|
||||||
|
ALTER TABLE audit_events
|
||||||
|
ADD COLUMN IF NOT EXISTS lan_ip VARCHAR(64) NOT NULL DEFAULT ''
|
||||||
|
AFTER source_ip;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO schema_migrations (version) VALUES (3);
|
||||||
Reference in New Issue
Block a user