mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-01 04:50:16 +00:00
Windows Host Agent Enhancements: - Implement native Windows service support using golang.org/x/sys/windows/svc - Add Windows Event Log integration for troubleshooting - Create professional PowerShell installation/uninstallation scripts - Add process termination and retry logic to handle Windows file locking - Register uninstall endpoint at /uninstall-host-agent.ps1 Host Agent UI Improvements: - Add expandable drawer to Hosts page (click row to view details) - Display system info, network interfaces, disks, and temperatures in cards - Replace status badges with subtle colored indicators - Remove redundant master-detail sidebar layout - Add search filtering for hosts Technical Details: - service_windows.go: Windows service lifecycle management with graceful shutdown - service_stub.go: Cross-platform compatibility for non-Windows builds - install-host-agent.ps1: Full Windows installation with validation - uninstall-host-agent.ps1: Clean removal with process termination and retries - HostsOverview.tsx: Expandable row pattern matching Docker/Proxmox pages Files Added: - cmd/pulse-host-agent/service_windows.go - cmd/pulse-host-agent/service_stub.go - scripts/install-host-agent.ps1 - scripts/uninstall-host-agent.ps1 - frontend-modern/src/components/Hosts/HostsOverview.tsx - frontend-modern/src/components/Hosts/HostsFilter.tsx The Windows service now starts reliably with automatic restart on failure, and the uninstall script handles file locking gracefully without requiring reboots.
283 lines
7.1 KiB
Go
283 lines
7.1 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
|
)
|
|
|
|
// Canonical API token scope strings.
|
|
const (
|
|
ScopeWildcard = "*"
|
|
ScopeMonitoringRead = "monitoring:read"
|
|
ScopeMonitoringWrite = "monitoring:write"
|
|
ScopeDockerReport = "docker:report"
|
|
ScopeDockerManage = "docker:manage"
|
|
ScopeHostReport = "host-agent:report"
|
|
ScopeHostManage = "host-agent:manage"
|
|
ScopeSettingsRead = "settings:read"
|
|
ScopeSettingsWrite = "settings:write"
|
|
)
|
|
|
|
// AllKnownScopes enumerates scopes recognized by the backend (excluding the wildcard sentinel).
|
|
var AllKnownScopes = []string{
|
|
ScopeMonitoringRead,
|
|
ScopeMonitoringWrite,
|
|
ScopeDockerReport,
|
|
ScopeDockerManage,
|
|
ScopeHostReport,
|
|
ScopeHostManage,
|
|
ScopeSettingsRead,
|
|
ScopeSettingsWrite,
|
|
}
|
|
|
|
var scopeLookup = func() map[string]struct{} {
|
|
lookup := make(map[string]struct{}, len(AllKnownScopes))
|
|
for _, scope := range AllKnownScopes {
|
|
lookup[scope] = struct{}{}
|
|
}
|
|
return lookup
|
|
}()
|
|
|
|
// ErrInvalidToken is returned when a token value is empty or malformed.
|
|
var ErrInvalidToken = errors.New("invalid API token")
|
|
|
|
// APITokenRecord stores hashed token metadata.
|
|
type APITokenRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Hash string `json:"hash"`
|
|
Prefix string `json:"prefix,omitempty"`
|
|
Suffix string `json:"suffix,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
}
|
|
|
|
// ensureScopes normalizes the scope slice, applying legacy defaults.
|
|
func (r *APITokenRecord) ensureScopes() {
|
|
if len(r.Scopes) == 0 {
|
|
r.Scopes = []string{ScopeWildcard}
|
|
return
|
|
}
|
|
|
|
// Copy to avoid shared underlying slice if this record is reused.
|
|
scopes := make([]string, len(r.Scopes))
|
|
copy(scopes, r.Scopes)
|
|
r.Scopes = scopes
|
|
}
|
|
|
|
// Clone returns a copy of the record with duplicated pointer fields.
|
|
func (r *APITokenRecord) Clone() APITokenRecord {
|
|
clone := *r
|
|
if r.LastUsedAt != nil {
|
|
t := *r.LastUsedAt
|
|
clone.LastUsedAt = &t
|
|
}
|
|
clone.ensureScopes()
|
|
return clone
|
|
}
|
|
|
|
// NewAPITokenRecord constructs a metadata record from the provided raw token.
|
|
func NewAPITokenRecord(rawToken, name string, scopes []string) (*APITokenRecord, error) {
|
|
if rawToken == "" {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
record := &APITokenRecord{
|
|
ID: uuid.NewString(),
|
|
Name: name,
|
|
Hash: auth.HashAPIToken(rawToken),
|
|
Prefix: tokenPrefix(rawToken),
|
|
Suffix: tokenSuffix(rawToken),
|
|
CreatedAt: now,
|
|
Scopes: normalizeScopes(scopes),
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
// NewHashedAPITokenRecord constructs a record from an already hashed token.
|
|
func NewHashedAPITokenRecord(hashedToken, name string, createdAt time.Time, scopes []string) (*APITokenRecord, error) {
|
|
if hashedToken == "" {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
if createdAt.IsZero() {
|
|
createdAt = time.Now().UTC()
|
|
}
|
|
|
|
return &APITokenRecord{
|
|
ID: uuid.NewString(),
|
|
Name: name,
|
|
Hash: hashedToken,
|
|
Prefix: tokenPrefix(hashedToken),
|
|
Suffix: tokenSuffix(hashedToken),
|
|
CreatedAt: createdAt,
|
|
Scopes: normalizeScopes(scopes),
|
|
}, nil
|
|
}
|
|
|
|
// tokenPrefix returns the first six characters suitable for hints.
|
|
func tokenPrefix(value string) string {
|
|
if len(value) >= 6 {
|
|
return value[:6]
|
|
}
|
|
return value
|
|
}
|
|
|
|
// tokenSuffix returns the last four characters suitable for hints.
|
|
func tokenSuffix(value string) string {
|
|
if len(value) >= 4 {
|
|
return value[len(value)-4:]
|
|
}
|
|
return value
|
|
}
|
|
|
|
// HasAPITokens reports whether any API tokens are configured.
|
|
func (c *Config) HasAPITokens() bool {
|
|
return len(c.APITokens) > 0
|
|
}
|
|
|
|
// APITokenCount returns the number of configured tokens.
|
|
func (c *Config) APITokenCount() int {
|
|
return len(c.APITokens)
|
|
}
|
|
|
|
// ActiveAPITokenHashes returns all stored token hashes.
|
|
func (c *Config) ActiveAPITokenHashes() []string {
|
|
hashes := make([]string, 0, len(c.APITokens))
|
|
for _, record := range c.APITokens {
|
|
if record.Hash != "" {
|
|
hashes = append(hashes, record.Hash)
|
|
}
|
|
}
|
|
return hashes
|
|
}
|
|
|
|
// HasAPITokenHash returns true when the hash already exists.
|
|
func (c *Config) HasAPITokenHash(hash string) bool {
|
|
for _, record := range c.APITokens {
|
|
if record.Hash == hash {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PrimaryAPITokenHash returns the newest token hash, if any.
|
|
func (c *Config) PrimaryAPITokenHash() string {
|
|
if len(c.APITokens) == 0 {
|
|
return ""
|
|
}
|
|
return c.APITokens[0].Hash
|
|
}
|
|
|
|
// PrimaryAPITokenHint provides a human-friendly token hint for UI display.
|
|
func (c *Config) PrimaryAPITokenHint() string {
|
|
if len(c.APITokens) == 0 {
|
|
return ""
|
|
}
|
|
token := c.APITokens[0]
|
|
if token.Prefix != "" && token.Suffix != "" {
|
|
return token.Prefix + "..." + token.Suffix
|
|
}
|
|
if len(token.Hash) >= 8 {
|
|
return token.Hash[:4] + "..." + token.Hash[len(token.Hash)-4:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ValidateAPIToken compares the raw token against stored hashes and updates metadata.
|
|
func (c *Config) ValidateAPIToken(rawToken string) (*APITokenRecord, bool) {
|
|
if rawToken == "" {
|
|
return nil, false
|
|
}
|
|
|
|
for idx, record := range c.APITokens {
|
|
if auth.CompareAPIToken(rawToken, record.Hash) {
|
|
now := time.Now().UTC()
|
|
c.APITokens[idx].LastUsedAt = &now
|
|
c.APITokens[idx].ensureScopes()
|
|
return &c.APITokens[idx], true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// UpsertAPIToken inserts or replaces a record by ID.
|
|
func (c *Config) UpsertAPIToken(record APITokenRecord) {
|
|
record.ensureScopes()
|
|
for idx, existing := range c.APITokens {
|
|
if existing.ID == record.ID {
|
|
c.APITokens[idx] = record
|
|
c.SortAPITokens()
|
|
return
|
|
}
|
|
}
|
|
c.APITokens = append(c.APITokens, record)
|
|
c.SortAPITokens()
|
|
}
|
|
|
|
// RemoveAPIToken removes a token by ID.
|
|
func (c *Config) RemoveAPIToken(id string) bool {
|
|
for idx, record := range c.APITokens {
|
|
if record.ID == id {
|
|
c.APITokens = append(c.APITokens[:idx], c.APITokens[idx+1:]...)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SortAPITokens keeps tokens ordered newest-first and syncs the legacy APIToken field.
|
|
func (c *Config) SortAPITokens() {
|
|
for i := range c.APITokens {
|
|
c.APITokens[i].ensureScopes()
|
|
}
|
|
sort.SliceStable(c.APITokens, func(i, j int) bool {
|
|
return c.APITokens[i].CreatedAt.After(c.APITokens[j].CreatedAt)
|
|
})
|
|
|
|
if len(c.APITokens) > 0 {
|
|
c.APIToken = c.APITokens[0].Hash
|
|
c.APITokenEnabled = true
|
|
} else {
|
|
c.APIToken = ""
|
|
}
|
|
}
|
|
|
|
// normalizeScopes applies defaults and returns a safe copy of the input slice.
|
|
func normalizeScopes(scopes []string) []string {
|
|
if len(scopes) == 0 {
|
|
return []string{ScopeWildcard}
|
|
}
|
|
result := make([]string, len(scopes))
|
|
copy(result, scopes)
|
|
return result
|
|
}
|
|
|
|
// HasScope reports whether the record grants the requested scope or wildcard access.
|
|
func (r *APITokenRecord) HasScope(scope string) bool {
|
|
if scope == "" {
|
|
return true
|
|
}
|
|
r.ensureScopes()
|
|
for _, candidate := range r.Scopes {
|
|
if candidate == ScopeWildcard || candidate == scope {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsKnownScope reports whether the provided string matches a supported scope identifier.
|
|
func IsKnownScope(scope string) bool {
|
|
if scope == ScopeWildcard {
|
|
return true
|
|
}
|
|
_, ok := scopeLookup[scope]
|
|
return ok
|
|
}
|