diff --git a/.env.example b/.env.example index e78e74b..0f1780d 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,4 @@ TAPM_DEFAULT_HOST_LIMIT=3 TAPM_MAX_HOST_LIMIT=25 TAPM_MAX_UPLOAD_BYTES=1073741824 TAPM_TRUST_PROXY_HEADERS=true +TAPM_DISPLAY_TIME_ZONE=America/Chicago diff --git a/docs/deployment.md b/docs/deployment.md index 7d7d5d6..d3c93a6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,6 +13,7 @@ Set the broker to listen on all addresses on both webservers: ```dotenv 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 @@ -206,8 +207,11 @@ technician's Gitea credentials for package operations. - Audit records are retained indefinitely unless an administrator establishes a database retention policy. The Codes view previews the newest 15 records; the 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 - matching events. + user, host, package, LAN/WAN IP, or details and displays up to the newest 250 + 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. - Rotate both Gitea package tokens and the OAuth secret if either webserver is compromised. diff --git a/internal/app/audit_test.go b/internal/app/audit_test.go index 831e5af..ad156e7 100644 --- a/internal/app/audit_test.go +++ b/internal/app/audit_test.go @@ -86,8 +86,9 @@ func TestAuditQueryUsesPlaceholders(t *testing.T) { "taiadmin", "pve01", "sentinelone-linux", - "10.10.1.25", "install-rmm", + "10.10.1.25", + "10.10.1.25", 250, } if !reflect.DeepEqual(arguments, expectedArguments) { diff --git a/internal/app/authorization.go b/internal/app/authorization.go index 6d13458..90c08e9 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -207,7 +207,8 @@ func (s *Server) listAuditEventTypes(r *http.Request) ([]string, error) { func auditQuery(filters auditFilters, limit int) (string, []any) { 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 LEFT JOIN authorizations a ON a.id = ae.authorization_id WHERE 1 = 1` @@ -233,7 +234,6 @@ func auditQuery(filters auditFilters, limit int) (string, []any) { {"ae.actor", filters.User}, {"ae.hostname", filters.Hostname}, {"ae.package_slug", filters.PackageSlug}, - {"ae.source_ip", filters.SourceIP}, {"ae.details", filters.Details}, } for _, filter := range containsFilters { @@ -243,6 +243,10 @@ func auditQuery(filters auditFilters, limit int) (string, []any) { query += " AND LOCATE(?, " + filter.column + ") > 0" 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 ?" arguments = append(arguments, limit) return query, arguments @@ -270,6 +274,7 @@ func (s *Server) listAuditEvents(r *http.Request, filters auditFilters, limit in &record.Hostname, &record.PackageSlug, &record.SourceIP, + &record.LANIP, &record.Details, &record.CreatedAt, ); err != nil { @@ -618,6 +623,23 @@ func (s *Server) audit( packageSlug string, sourceIP 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 { var authID any if authorizationID != nil { @@ -626,9 +648,10 @@ func (s *Server) audit( _, err := s.db.ExecContext( ctx, `INSERT INTO audit_events - (event_type, actor, authorization_id, hostname, package_slug, source_ip, details) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - eventType, user, authID, hostname, packageSlug, sourceIP, details, + (event_type, actor, authorization_id, hostname, package_slug, + source_ip, lan_ip, details) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + eventType, user, authID, hostname, packageSlug, sourceIP, lanIP, details, ) return err } diff --git a/internal/app/config.go b/internal/app/config.go index 1afc7a8..a9cda3c 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "time" + _ "time/tzdata" ) type Config struct { @@ -28,6 +29,7 @@ type Config struct { MaxHostLimit int MaxUploadBytes int64 TrustProxyHeaders bool + DisplayTimeZone *time.Location } func LoadConfig() (Config, error) { @@ -82,6 +84,12 @@ func LoadConfig() (Config, error) { if err != nil { 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{}) for _, user := range strings.Split( diff --git a/internal/app/download.go b/internal/app/download.go index bdb0b43..2a26d43 100644 --- a/internal/app/download.go +++ b/internal/app/download.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "strings" @@ -16,6 +17,7 @@ type exchangeRequest struct { Code string `json:"code"` HostFingerprint string `json:"host_fingerprint"` Hostname string `json:"hostname"` + LANIP string `json:"lan_ip,omitempty"` RequestedAction string `json:"requested_action,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.HostFingerprint = strings.TrimSpace(request.HostFingerprint) request.Hostname = strings.TrimSpace(request.Hostname) + request.LANIP = strings.TrimSpace(request.LANIP) request.RequestedAction = strings.TrimSpace(request.RequestedAction) 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 || request.Hostname == "" || len(request.Hostname) > 255 || (request.RequestedAction != "" && !validSlug(request.RequestedAction)) || @@ -75,11 +86,12 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) { status = http.StatusForbidden 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}) return } - _ = s.audit( + _ = s.auditWithNetwork( r.Context(), "code_exchanged", "", @@ -87,6 +99,7 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) { request.Hostname, request.RequestedPackage, sourceIP, + request.LANIP, "requested_action="+request.RequestedAction, ) writeJSON(w, http.StatusOK, response) @@ -199,9 +212,9 @@ func (s *Server) exchangeDeploymentCode( result, err := tx.ExecContext( r.Context(), `INSERT INTO authorization_hosts - (authorization_id, host_fingerprint, hostname) - VALUES (?, ?, ?)`, - authorizationID, fingerprintHash[:], request.Hostname, + (authorization_id, host_fingerprint, hostname, lan_ip) + VALUES (?, ?, ?, ?)`, + authorizationID, fingerprintHash[:], request.Hostname, request.LANIP, ) if err != nil { return response, 0, err @@ -214,9 +227,11 @@ func (s *Server) exchangeDeploymentCode( _, err = tx.ExecContext( r.Context(), `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 = ?`, - request.Hostname, hostID, + request.Hostname, request.LANIP, hostID, ) if err != nil { return response, 0, err @@ -328,11 +343,12 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) { var packageInfo packageRecord var authorizationID uint64 var hostname string + var lanIP string err := s.db.QueryRowContext( r.Context(), `SELECT p.id, p.slug, p.display_name, p.package_name, 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 JOIN authorizations a ON a.id = ds.authorization_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, &authorizationID, &hostname, + &lanIP, ) if errors.Is(err, sql.ErrNoRows) { 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) upstreamResponse, err := s.packageClient.Do(upstreamRequest) 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"}) return } defer upstreamResponse.Body.Close() 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"}) return } @@ -402,8 +421,10 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) 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 } - _ = 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, "") } diff --git a/internal/app/server.go b/internal/app/server.go index 9204ee6..3a64aed 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -75,6 +75,7 @@ type auditRecord struct { Hostname string PackageSlug string SourceIP string + LANIP string Details string CreatedAt time.Time } @@ -125,7 +126,7 @@ func New(cfg Config) (*Server, error) { return nil, fmt.Errorf("database: %w", err) } - templates, err := parseTemplates() + templates, err := parseTemplates(cfg.DisplayTimeZone) if err != nil { _ = db.Close() return nil, err @@ -162,10 +163,13 @@ func New(cfg Config) (*Server, error) { }, 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{ "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 { return !record.RevokedAt.Valid && time.Now().Before(record.ExpiresAt) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 6befba4..4930ab9 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -1,14 +1,16 @@ package app import ( + "bytes" "io" + "strings" "testing" "time" ) func TestPortalTemplateExecutes(t *testing.T) { t.Parallel() - templates, err := parseTemplates() + templates, err := parseTemplates(time.UTC) if err != nil { t.Fatal(err) } @@ -42,7 +44,7 @@ func TestPortalTemplateExecutes(t *testing.T) { func TestPackageAndAuditTemplatesExecute(t *testing.T) { t.Parallel() - templates, err := parseTemplates() + templates, err := parseTemplates(time.UTC) if err != nil { t.Fatal(err) } @@ -70,9 +72,42 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) { EventType: "package_downloaded", CustomerLabel: "Acme cluster refresh", Hostname: "pve01", + SourceIP: "203.0.113.10", + LANIP: "10.10.1.25", CreatedAt: time.Now(), }} if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil { 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()) + } +} diff --git a/internal/app/templates/audit.html b/internal/app/templates/audit.html index ff6cfe7..9893a5e 100644 --- a/internal/app/templates/audit.html +++ b/internal/app/templates/audit.html @@ -42,7 +42,7 @@ - +