Add API token scopes and standalone host agent

Introduces granular permission scopes for API tokens (docker:report, docker:manage, host-agent:report, monitoring:read/write, settings:read/write) allowing tokens to be restricted to minimum required access. Legacy tokens default to full access until scopes are explicitly configured.

Adds standalone host agent for monitoring Linux, macOS, and Windows servers outside Proxmox/Docker estates. New Servers workspace in UI displays uptime, OS metadata, and capacity metrics from enrolled agents.

Includes comprehensive token management UI overhaul with scope presets, inline editing, and visual scope indicators.
This commit is contained in:
rcourtman 2025-10-23 11:40:31 +00:00
parent e76ab5eec0
commit 5c54685f04
39 changed files with 3475 additions and 332 deletions

View file

@ -50,6 +50,7 @@ Pulse is built by a solo developer in evenings and weekends. Your support helps:
- **Interactive Backup Explorer**: Cross-highlighted bar chart + grid with quick time-range pivots (24h/7d/30d/custom) and contextual tooltips for the busiest jobs
- Proxmox Mail Gateway analytics: mail volume, spam/virus trends, quarantine health, and cluster node status
- Optional Docker container monitoring via lightweight agent
- Standalone host agent for Linux, macOS, and Windows servers to capture uptime, OS metadata, and capacity metrics
- Config export/import with encryption and authentication
- Automatic stable updates with safe rollback (opt-in)
- Dark/light themes, responsive design
@ -110,7 +111,7 @@ helm install pulse oci://ghcr.io/rcourtman/pulse-chart \
1. Open `http://<your-server>:7655`
2. **Complete the mandatory security setup** (first-time only)
3. Create your admin username and password
4. Use **Settings → Security → API tokens** to mint dedicated tokens for automation (issue one token per integration so you can revoke credentials individually)
4. Use **Settings → Security → API tokens** to mint dedicated tokens for automation. Assign scopes so each token only has the permissions it needs (e.g. `docker:report`, `host-agent:report`). Legacy tokens default to full access until you edit and save new scopes.
**Option B: Automated Setup (No UI)**
For automated deployments, configure authentication via environment variables:
@ -168,6 +169,10 @@ The script handles user creation, permissions, token generation, and registratio
Deploy the lightweight [Pulse Docker agent](docs/DOCKER_MONITORING.md) on any host running Docker to stream container status and resource data back to Pulse. Install the agent alongside your stack, point it at your Pulse URL and API token, and the **Docker** workspace lights up with host summaries, restart loop detection, per-container CPU/memory charts, and quick filters for stacks and unhealthy workloads.
### Monitor Standalone Servers (optional)
Install the [Pulse host agent](docs/HOST_AGENT.md) on Linux, macOS, or Windows machines that sit outside your Proxmox or Docker estate. Generate an API token scoped to `host-agent:report`, drop it into the install command, and the **Servers** workspace will populate with uptime, OS metadata, and capacity metrics.
## Docker
### Basic

View file

@ -0,0 +1,147 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rs/zerolog"
)
type multiValue []string
func (m *multiValue) String() string {
return strings.Join(*m, ",")
}
func (m *multiValue) Set(value string) error {
*m = append(*m, value)
return nil
}
func main() {
cfg := loadConfig()
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
cfg.Logger = &logger
agent, err := hostagent.New(cfg)
if err != nil {
logger.Fatal().Err(err).Msg("failed to initialise host agent")
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
logger.Info().
Str("pulse_url", cfg.PulseURL).
Str("agent_id", cfg.AgentID).
Dur("interval", cfg.Interval).
Msg("Starting Pulse host agent")
if err := agent.Run(ctx); err != nil && err != context.Canceled {
logger.Fatal().Err(err).Msg("host agent terminated with error")
}
logger.Info().Msg("Host agent stopped")
}
func loadConfig() hostagent.Config {
envURL := strings.TrimSpace(os.Getenv("PULSE_URL"))
envToken := strings.TrimSpace(os.Getenv("PULSE_TOKEN"))
envInterval := strings.TrimSpace(os.Getenv("PULSE_INTERVAL"))
envHostname := strings.TrimSpace(os.Getenv("PULSE_HOSTNAME"))
envAgentID := strings.TrimSpace(os.Getenv("PULSE_AGENT_ID"))
envInsecure := strings.TrimSpace(os.Getenv("PULSE_INSECURE_SKIP_VERIFY"))
envTags := strings.TrimSpace(os.Getenv("PULSE_TAGS"))
envRunOnce := strings.TrimSpace(os.Getenv("PULSE_ONCE"))
defaultInterval := 30 * time.Second
if envInterval != "" {
if parsed, err := time.ParseDuration(envInterval); err == nil {
defaultInterval = parsed
}
}
urlFlag := flag.String("url", envURL, "Pulse server URL (e.g. https://pulse.example.com)")
tokenFlag := flag.String("token", envToken, "Pulse API token (required)")
intervalFlag := flag.Duration("interval", defaultInterval, "Reporting interval (e.g. 30s, 1m)")
hostnameFlag := flag.String("hostname", envHostname, "Override hostname reported to Pulse")
agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier")
insecureFlag := flag.Bool("insecure", parseBool(envInsecure), "Skip TLS certificate verification")
runOnceFlag := flag.Bool("once", parseBool(envRunOnce), "Collect and send a single report, then exit")
showVersion := flag.Bool("version", false, "Print the agent version and exit")
var tagFlags multiValue
flag.Var(&tagFlags, "tag", "Tag to apply to this host (repeatable)")
flag.Parse()
if *showVersion {
fmt.Println(hostagent.Version)
os.Exit(0)
}
pulseURL := strings.TrimSpace(*urlFlag)
if pulseURL == "" {
pulseURL = "http://localhost:7655"
}
token := strings.TrimSpace(*tokenFlag)
if token == "" {
fmt.Fprintln(os.Stderr, "error: Pulse API token is required (via --token or PULSE_TOKEN)")
os.Exit(1)
}
interval := *intervalFlag
if interval <= 0 {
interval = 30 * time.Second
}
tags := gatherTags(envTags, tagFlags)
return hostagent.Config{
PulseURL: pulseURL,
APIToken: token,
Interval: interval,
HostnameOverride: strings.TrimSpace(*hostnameFlag),
AgentID: strings.TrimSpace(*agentIDFlag),
Tags: tags,
InsecureSkipVerify: *insecureFlag,
RunOnce: *runOnceFlag,
}
}
func gatherTags(env string, flags []string) []string {
tags := make([]string, 0)
if env != "" {
for _, tag := range strings.Split(env, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
}
for _, tag := range flags {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
return tags
}
func parseBool(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}

View file

@ -470,7 +470,25 @@ curl -H "X-API-Token: $ANSIBLE_TOKEN" http://localhost:7655/api/nodes
# API_TOKEN=your-secure-api-token ./pulse
```
> **Tip:** Generate a distinct token for each automation workflow (Ansible, Docker agents, CI runners, etc.) so you can revoke one credential without affecting the others.
> **Tip:** Generate a distinct token for each automation workflow (Ansible, Docker agents, host agents, CI runners, etc.) so you can revoke one credential without affecting the others.
### Token Scopes
API tokens created in the UI can be restricted to the smallest set of permissions required by an integration:
| Scope | Typical use |
|-------|-------------|
| `docker:report` | Docker agent submitting host/container telemetry |
| `docker:manage` | Docker agent lifecycle commands (restart, stop, etc.) |
| `host-agent:report` | Pulse host agent reporting OS metrics |
| `monitoring:read` | Read-only access to dashboards, state API, and alert history |
| `monitoring:write` | Acknowledge, silence, or clear alerts |
| `settings:read` | Fetch configuration snapshots and diagnostics |
| `settings:write` | Modify configuration, manage tokens, trigger updates |
Leaving the scope list empty (or legacy tokens without scopes) grants full access. Tokens generated from specific panels (e.g. **Settings → Agents → Host agents**) automatically apply the relevant scope presets.
> **Upgrade note:** After upgrading, existing tokens are treated as full-access (`*`). Visit **Settings → Security** to edit each legacy token and assign narrower scopes.
**Option 2: Basic Authentication**
```bash

101
docs/HOST_AGENT.md Normal file
View file

@ -0,0 +1,101 @@
# Pulse Host Agent
The Pulse host agent extends monitoring to standalone servers that do not expose
Proxmox or Docker APIs. With it you can surface uptime, OS metadata, CPU load,
memory/disk utilisation, and connection health for any Linux, macOS, or Windows
machine alongside the rest of your infrastructure.
## Prerequisites
- Pulse `main` (or a release that includes `/api/agents/host/report`)
- An API token with the `host-agent:report` scope (create under **Settings → Security**)
- Outbound HTTP/HTTPS connectivity from the host back to Pulse
> The agent only initiates outbound connections; no inbound firewall rules are required.
## Quick Start
> Replace `<api-token>` with a Pulse API token limited to the `host-agent:report` scope. Tokens generated from **Settings → Agents → Host agents** already apply this scope.
### Linux (systemd)
```bash
sudo curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/pulse-host-agent-linux-amd64 \
-o /usr/local/bin/pulse-host-agent
sudo chmod +x /usr/local/bin/pulse-host-agent
sudo /usr/local/bin/pulse-host-agent \
--url http://pulse.example.local:7655 \
--token <api-token> \
--interval 30s
```
For persistence, drop a systemd unit (e.g. `/etc/systemd/system/pulse-host-agent.service`) referencing the same command and enable it with `systemctl enable --now pulse-host-agent`.
### macOS (launchd)
```bash
sudo curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/pulse-host-agent-darwin-arm64 \
-o /usr/local/bin/pulse-host-agent
sudo chmod +x /usr/local/bin/pulse-host-agent
sudo /usr/local/bin/pulse-host-agent \
--url http://pulse.example.local:7655 \
--token <api-token> \
--interval 30s
```
Create `~/Library/LaunchAgents/com.pulse.host-agent.plist` to keep the agent running between logins:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.pulse.host-agent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/pulse-host-agent</string>
<string>--url</string>
<string>http://pulse.example.local:7655</string>
<string>--token</string>
<string>&lt;api-token&gt;</string>
<string>--interval</string>
<string>30s</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/Users/your-user/Library/Logs/pulse-host-agent.log</string>
<key>StandardErrorPath</key><string>/Users/your-user/Library/Logs/pulse-host-agent.log</string>
</dict>
</plist>
```
Load it with `launchctl load ~/Library/LaunchAgents/com.pulse.host-agent.plist`.
### Windows
A Windows build will ship shortly. In the meantime run the Linux/WSL binary or compile from source (`GOOS=windows GOARCH=amd64`).
## Command Flags
| Flag | Description |
|------|-------------|
| `--url` | Pulse base URL (defaults to `http://localhost:7655`) |
| `--token` | API token with monitoring write access |
| `--interval` | Polling interval (`30s` default) |
| `--hostname` | Override reported hostname |
| `--agent-id` | Override agent identifier (used as dedupe key) |
| `--tag` | Optional tag(s) to annotate the host (repeatable) |
| `--insecure` | Skip TLS verification (development/testing only) |
| `--once` | Send a single report and exit |
Run `pulse-host-agent --help` for the full list.
## Viewing Hosts
- **Settings → Agents → Host agents** lists every reporting host and provides ready-made install commands.
- The **Servers** tab surfaces host telemetry alongside Proxmox/Docker data in the main dashboard.
## Updating
Since the agent is a single static binary, updates are as simple as replacing the file and restarting your launchd/systemd unit. The Settings pane always links to the latest release artefacts.

View file

@ -0,0 +1,98 @@
# API Token Scope Design Brief
## Objective
Introduce scoped API tokens so administrators can grant the minimum necessary permissions to each integration (Docker agent, host agent, future platform agents, automation scripts, etc.). This replaces todays “all-or-nothing” tokens and provides safer rotation/revocation paths.
## Security Rationale
- Agent and automation tokens are often deployed on hosts or third-party services we do not fully trust. If one leaks today, the attacker inherits full administrator powers (issuing new tokens, mutating settings, triggering installs/updates, etc.).
- Constraining tokens to the minimal scope limits the blast radius: a compromised reporting agent can only submit telemetry, not reconfigure Pulse or other integrations.
- Customers operating in regulated or multi-team environments increasingly ask for auditable least-privilege controls. Scopes give us the primitives to surface warnings on over-privileged tokens and eventually add rotation workflows.
- The feature still has to earn adoption, so pair the technical work with UI nudges and reporting that highlight “Full access” tokens and encourage admins to narrow permissions.
## Requirements Overview
1. **Token Model Changes**
- Each API token record stores a list of scopes (strings) or a bitmask. Recommended canonical strings:
- `monitoring:read`
- `monitoring:write`
- `docker:report`
- `docker:manage`
- `host-agent:report`
- `settings:read`
- `settings:write`
- `*` (legacy full-access sentinel; backend accepts for migration/edit flows but the UI should not expose it)
- Existing tokens must remain valid. Treat missing scopes as `["*"]` (full access) until the admin edits them.
2. **Persistence & Migration**
- Extend the token persistence layer (currently BoltDB JSON) to include `scopes: []string`.
- On startup, detect tokens without the new field and default to `["*"]`.
- Expose the complete scope list when returning token metadata (internal API used by Settings UI).
3. **Middleware Enforcement**
- Add a helper `RequireScope(scope string)` that checks the requests token record for `scope` or `*`.
- Apply the helper according to the table below:
| Endpoint (or group) | HTTP verbs | Required scope(s) | Notes |
|-------------------------------------------------|-------------------------------------|-----------------------------|------------------------------------------------------|
| `/api/agents/docker/report` | `POST` | `docker:report` | Docker agent heartbeat payloads |
| `/api/agents/docker/commands/*` | `POST` | `docker:manage` (optional) | If we expose command ack/management over tokens |
| `/api/agents/docker/hosts/*` | `DELETE`, `PUT`, `POST` | `docker:manage` | Admin actions for Docker hosts |
| `/api/agents/host/report` | `POST` | `host-agent:report` | Host agent reporting |
| `/api/state` | `GET` | `monitoring:read` | General state polling (if token-authenticated) |
| `/api/alerts/*` | `GET` | `monitoring:read` | Alerts reading APIs |
| `/api/alerts/*` (mutations) | `POST`, `PUT`, `DELETE` | `monitoring:write` | Acknowledge, silence, etc. |
| `/api/settings/*` | `GET` | `settings:read` | Settings reads via API |
| `/api/settings/*` | `POST`, `PUT`, `DELETE`, `PATCH` | `settings:write` | Any settings mutation |
| `/api/security/tokens*` | all verbs | _n/a (session only)_ | Leave browser-session only; do not allow API tokens yet |
| `/api/install/*`, `/api/updates/*` (mutations) | `POST`, `PUT` | `settings:write` | Sensitive operational endpoints |
(More endpoints can be added as required; start with the rows above and expand during implementation.)
- Maintain compatibility for admin sessions (browser login) which continue to bypass token checks.
4. **Token Generation API**
- Update `POST /api/security/tokens` to accept a `scopes` array.
- Validation rules:
- Reject unknown scope identifiers (except the `*` sentinel described above).
- If the array is omitted (legacy callers), default to `['*']` (full access) to preserve backward compatibility.
- If the array is provided but empty, reject with a 400 (“select at least one scope or delete the token”).
- Reject mixed arrays that contain both `'*'` and explicit scopes; if the UI submits such a payload, return a 400 with guidance (“either all scopes or full access”).
- Include the scope list in the response payload so the UI can display it.
5. **UI/UX Adjustments**
- **Settings → Security** panel:
- When generating or editing a token, show a multi-select with friendly labels (“Docker agent reporting”, “Host agent reporting”, etc.).
- Display the scope summary in the token list (e.g. badges).
- For legacy tokens (implicit `*`), show “Full access” and allow editing to reduce scope.
- **Docker Agents / Host Agents screens:**
- When requesting a token:
- Docker: pre-select both `docker:report` (always) and `docker:manage` if the user needs lifecycle commands (hide manage behind a toggle if desired).
- Host agent: pre-select `host-agent:report`.
- Warn if the stored token lacks the required scope (fallback to showing `<api-token>` placeholder).
6. **Testing**
- Unit tests covering:
- Scope parsing/migration
- Middleware checks (token with/without required scope)
- Integration tests for agent endpoints verifying 403 on missing scope.
7. **Documentation**
- Update `README.md` and relevant docs (e.g. `docs/CONFIGURATION.md`, `docs/HOST_AGENT.md`, Docker docs) to explain scoped tokens.
- Provide an upgrade note for existing installations (“legacy tokens default to full access; edit to restrict scope”).
## Implementation Notes
- Use constants for scope strings to avoid typos throughout the codebase.
- Token middleware already retrieves `APITokenRecord`; that struct should grow a `Scopes []string` field with helper methods (`HasScope`).
- For future extensibility, keep the scope checks granular but simple (string equality) rather than regex matching.
- Ensure the Settings UI gracefully handles lack of admin privileges (disable scope selection, show hint).
- Update agent commands (Docker/Host) to mention required scope in their description.
- Guardrails: the backend should never auto-insert `*` once a scoped token exists, and any admin edit that clears all scopes should surface a clear “delete token or assign scopes” decision.
## Acceptance Criteria
- Scoped tokens persisted and surfaced via API.
- Middleware rejects tokens missing required scope.
- UI can create, edit, and display scoped tokens; agent panels auto-fill only when valid.
- Documentation updated; existing tokens remain functional without manual migration.
Once implemented delete this doc.

View file

@ -21,6 +21,7 @@ import Replication from './components/Replication/Replication';
import Settings from './components/Settings/Settings';
import { Alerts } from './pages/Alerts';
import { DockerHosts } from './components/Docker/DockerHosts';
import { ServersOverview } from './components/Hosts/ServersOverview';
import { ToastContainer } from './components/Toast/Toast';
import NotificationContainer from './components/NotificationContainer';
import { ErrorBoundary } from './components/ErrorBoundary';
@ -42,6 +43,7 @@ import type { State } from '@/types/api';
import MailGateway from './components/PMG/MailGateway';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import { DockerIcon } from '@/components/icons/DockerIcon';
import { ServersIcon } from '@/components/icons/ServersIcon';
import { AlertsIcon } from '@/components/icons/AlertsIcon';
import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon';
import { TokenRevealDialog } from './components/TokenRevealDialog';
@ -81,6 +83,17 @@ function DockerRoute() {
return <DockerHosts hosts={hosts()} activeAlerts={activeAlerts} />;
}
function HostsRoute() {
const wsContext = useContext(WebSocketContext);
if (!wsContext) {
return <div>Loading...</div>;
}
const { state } = wsContext;
return (
<ServersOverview hosts={state.hosts ?? []} connectionHealth={state.connectionHealth ?? {}} />
);
}
function App() {
const TooltipRoot = createTooltipSystem();
const owner = getOwner();
@ -616,6 +629,7 @@ function App() {
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
<Route path="/docker" component={DockerRoute} />
<Route path="/servers" component={HostsRoute} />
<Route path="/alerts/*" component={Alerts} />
<Route
path="/settings/*"
@ -685,11 +699,13 @@ function AppLayout(props: {
const path = location.pathname;
if (path.startsWith('/proxmox')) return 'proxmox';
if (path.startsWith('/docker')) return 'docker';
if (path.startsWith('/servers')) return 'servers';
if (path.startsWith('/alerts')) return 'alerts';
if (path.startsWith('/settings')) return 'settings';
return 'proxmox';
};
const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0);
const hasServers = createMemo(() => (props.state().hosts?.length ?? 0) > 0);
const hasProxmoxHosts = createMemo(
() =>
(props.state().nodes?.length ?? 0) > 0 ||
@ -709,6 +725,12 @@ function AppLayout(props: {
}
});
createEffect(() => {
if (hasServers()) {
markPlatformSeen('servers');
}
});
const platformTabs = createMemo(() => {
return [
{
@ -735,6 +757,18 @@ function AppLayout(props: {
<DockerIcon class="w-4 h-4 shrink-0" />
),
},
{
id: 'servers' as const,
label: 'Servers',
route: '/servers',
settingsRoute: '/settings',
tooltip: 'Monitor standalone servers with the host agent',
enabled: hasServers() || !!seenPlatforms()['servers'],
live: hasServers(),
icon: (
<ServersIcon class="w-4 h-4 shrink-0" />
),
},
];
});

View file

@ -7,6 +7,7 @@ export interface APITokenRecord {
suffix: string;
createdAt: string;
lastUsedAt?: string;
scopes?: string[];
}
export interface CreateAPITokenResponse {
@ -20,10 +21,18 @@ export class SecurityAPI {
return response.tokens ?? [];
}
static async createToken(name?: string): Promise<CreateAPITokenResponse> {
static async createToken(name?: string, scopes?: string[]): Promise<CreateAPITokenResponse> {
const payload: Record<string, unknown> = {};
if (name) {
payload.name = name;
}
if (scopes) {
payload.scopes = scopes;
}
return apiFetchJSON<CreateAPITokenResponse>('/api/security/tokens', {
method: 'POST',
body: JSON.stringify({ name }),
body: JSON.stringify(payload),
});
}

View file

@ -0,0 +1,156 @@
import { For, Show, createMemo } from 'solid-js';
import type { Component } from 'solid-js';
import type { Host } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { formatBytes } from '@/utils/format';
interface ServersOverviewProps {
hosts: Host[];
connectionHealth: Record<string, boolean>;
}
const statusClass: Record<string, string> = {
online: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20',
degraded: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20',
offline: 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20',
};
const formatStatus = (status: string | undefined) => {
if (!status) return 'unknown';
const normalized = status.toLowerCase();
if (normalized === 'online' || normalized === 'degraded' || normalized === 'offline') {
return normalized;
}
return status;
};
export const ServersOverview: Component<ServersOverviewProps> = (props) => {
const sortedHosts = createMemo(() =>
[...props.hosts].sort((a, b) => a.hostname.localeCompare(b.hostname)),
);
return (
<div class="space-y-6">
<header class="flex flex-col gap-2">
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">Servers</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Unified view of standalone hosts reporting via the Pulse host agent.
</p>
</header>
<Show
when={sortedHosts().length > 0}
fallback={
<EmptyState
title="No servers reporting yet"
description="Install the pulse-host-agent on a Linux, Windows, or macOS machine to have it appear here."
/>
}
>
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
<For each={sortedHosts()}>
{(host) => {
const status = formatStatus(host.status);
const statusClasses =
statusClass[status] ??
'bg-slate-500/10 text-slate-600 dark:text-slate-300 border border-slate-500/20';
const lastSeen = new Date(host.lastSeen || Date.now());
const connectionKey = `host-${host.id}`;
const isHealthy = props.connectionHealth[connectionKey] ?? status !== 'offline';
const memoryUsage =
typeof host.memory?.usage === 'number'
? Math.round((host.memory.usage + Number.EPSILON) * 10) / 10
: undefined;
return (
<Card class="flex flex-col gap-4 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-slate-500 uppercase tracking-wide">
{host.platform ?? 'unknown'}
</p>
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
{host.displayName || host.hostname}
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
{host.osName}
{host.osVersion ? ` ${host.osVersion}` : ''}
</p>
</div>
<span class={`rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}>
{status}
</span>
</div>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-slate-500 dark:text-slate-400">CPU Usage</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{typeof host.cpuUsage === 'number' ? `${host.cpuUsage.toFixed(1)}%` : '—'}
</dd>
</div>
<div>
<dt class="text-slate-500 dark:text-slate-400">Memory</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{host.memory?.total
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}${
memoryUsage !== undefined ? ` (${memoryUsage.toFixed(1)}%)` : ''
}`
: '—'}
</dd>
</div>
<div>
<dt class="text-slate-500 dark:text-slate-400">Architecture</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{host.architecture ?? '—'}
</dd>
</div>
<div>
<dt class="text-slate-500 dark:text-slate-400">Last Seen</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{lastSeen.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</dd>
</div>
<div class="col-span-2">
<dt class="text-slate-500 dark:text-slate-400">Connection</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{isHealthy ? 'Healthy' : 'Unreachable'}
</dd>
</div>
</dl>
<Show when={host.disks && host.disks.length > 0}>
<div class="rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-700/70 dark:bg-slate-900/70">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Storage
</p>
<ul class="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-300">
<For each={host.disks}>
{(disk) => (
<li class="flex items-center justify-between">
<span class="truncate">
{disk.mountpoint || disk.device || 'disk'} {' '}
{disk.type ? disk.type.toUpperCase() : '—'}
</span>
<span>
{formatBytes(disk.used ?? 0)} / {formatBytes(disk.total ?? 0)}
{typeof disk.usage === 'number'
? ` (${disk.usage.toFixed(1)}%)`
: ''}
</span>
</li>
)}
</For>
</ul>
</div>
</Show>
</Card>
);
}}
</For>
</div>
</Show>
</div>
);
};

View file

@ -1,12 +1,20 @@
import { Component, For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import { showError, showSuccess } from '@/utils/toast';
import { formatRelativeTime } from '@/utils/format';
import { useWebSocket } from '@/App';
import type { DockerHost } from '@/types/api';
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
import {
API_SCOPE_LABELS,
API_SCOPE_OPTIONS,
DOCKER_MANAGE_SCOPE,
DOCKER_REPORT_SCOPE,
HOST_AGENT_SCOPE,
SETTINGS_READ_SCOPE,
SETTINGS_WRITE_SCOPE,
} from '@/constants/apiScopes';
interface APITokenManagerProps {
currentTokenHint?: string;
@ -14,6 +22,10 @@ interface APITokenManagerProps {
refreshing?: boolean;
}
const SCOPES_DOC_URL =
'https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes';
const WILDCARD_SCOPE = '*';
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const { state } = useWebSocket();
const dockerHosts = createMemo<DockerHost[]>(() => state.dockerHosts ?? []);
@ -23,11 +35,11 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const tokenId = host.tokenId;
if (!tokenId) continue;
const displayName = host.displayName?.trim() || host.hostname || host.id;
const existing = usage.get(tokenId);
if (existing) {
const previous = usage.get(tokenId);
if (previous) {
usage.set(tokenId, {
count: existing.count + 1,
hosts: [...existing.hosts, displayName],
count: previous.count + 1,
hosts: [...previous.hosts, displayName],
});
} else {
usage.set(tokenId, { count: 1, hosts: [displayName] });
@ -37,12 +49,133 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
});
const [tokens, setTokens] = createSignal<APITokenRecord[]>([]);
const sortedTokens = createMemo(() =>
[...tokens()].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
);
const totalTokens = createMemo(() => sortedTokens().length);
const wildcardCount = createMemo(() =>
sortedTokens().filter((token) => {
const scopes = token.scopes;
return !scopes || scopes.length === 0 || scopes.includes('*');
}).length,
);
const scopedTokenCount = createMemo(() => totalTokens() - wildcardCount());
const hasWildcardTokens = createMemo(() => wildcardCount() > 0);
const mostRecentLabel = createMemo(() => {
const first = sortedTokens()[0];
if (!first) return '—';
const timestamp = new Date(first.createdAt).getTime();
return Number.isFinite(timestamp) ? formatRelativeTime(timestamp) : '—';
});
const [loading, setLoading] = createSignal(true);
const [isGenerating, setIsGenerating] = createSignal(false);
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
const [nameInput, setNameInput] = createSignal('');
const tokenRevealState = useTokenRevealState();
const [selectedScopes, setSelectedScopes] = createSignal<string[]>([]);
type ScopeGroup = (typeof API_SCOPE_OPTIONS)[number]['group'];
type ScopeOption = (typeof API_SCOPE_OPTIONS)[number];
const scopeGroupOrder: ScopeGroup[] = ['Monitoring', 'Agents', 'Settings'];
const scopeGroups = createMemo<[ScopeGroup, ScopeOption[]][]>(() => {
const grouped: Record<ScopeGroup, ScopeOption[]> = {
Monitoring: [],
Agents: [],
Settings: [],
};
for (const option of API_SCOPE_OPTIONS) {
grouped[option.group].push(option);
}
return scopeGroupOrder
.map((group) => [group, grouped[group]] as [ScopeGroup, ScopeOption[]])
.filter(([, options]) => options.length > 0);
});
const isFullAccessSelected = () =>
selectedScopes().length === 0 || selectedScopes().includes(WILDCARD_SCOPE);
const scopePresets: { label: string; scopes: string[]; description: string }[] = [
{
label: 'Host agent',
scopes: [HOST_AGENT_SCOPE],
description: 'Allow pulse-host-agent to submit OS, CPU, and disk metrics.',
},
{
label: 'Docker report',
scopes: [DOCKER_REPORT_SCOPE],
description: 'Permits Docker agents to stream host and container telemetry only.',
},
{
label: 'Docker manage',
scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE],
description: 'Extends Docker reporting with lifecycle actions (restart, stop, etc.).',
},
{
label: 'Settings read',
scopes: [SETTINGS_READ_SCOPE],
description: 'Read configuration snapshots and diagnostics without modifying anything.',
},
{
label: 'Settings admin',
scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE],
description: 'Full settings read/write equivalent to automation with admin privileges.',
},
];
const presetMatchesSelection = (presetScopes: string[]) => {
const selection = [...selectedScopes()]
.filter((scope) => scope !== WILDCARD_SCOPE)
.sort();
const target = [...presetScopes].sort();
if (target.length === 0) {
return isFullAccessSelected();
}
if (selection.length !== target.length) {
return false;
}
return target.every((scope) => selection.includes(scope));
};
const presetButtonBase =
'flex w-full items-start justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors';
const presetButtonActive =
'border-blue-400 ring-1 ring-blue-300 bg-blue-50/70 dark:border-blue-500 dark:ring-blue-400/40 dark:bg-blue-900/20';
const presetButtonInactive =
'border-gray-300 bg-white hover:border-blue-400 dark:border-gray-600 dark:bg-gray-900/60 dark:hover:border-blue-500';
const selectedScopeChips = createMemo(() =>
selectedScopes()
.filter((scope) => scope !== WILDCARD_SCOPE)
.map((scope) => ({
value: scope,
label: API_SCOPE_LABELS[scope] ?? scope,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
);
const [advancedScopesOpen, setAdvancedScopesOpen] = createSignal(false);
const applyScopePreset = (scopes: string[]) => {
const unique = Array.from(new Set(scopes)).filter(Boolean);
setSelectedScopes(unique);
};
const clearScopes = () => setSelectedScopes([]);
let createSectionRef: HTMLDivElement | undefined;
const [createHighlight, setCreateHighlight] = createSignal(false);
let highlightTimer: number | undefined;
const focusCreateSection = () => {
if (!createSectionRef) return;
createSectionRef.scrollIntoView({ behavior: 'smooth', block: 'start' });
setCreateHighlight(true);
window.clearTimeout(highlightTimer);
highlightTimer = window.setTimeout(() => setCreateHighlight(false), 1600);
};
onCleanup(() => {
if (highlightTimer) window.clearTimeout(highlightTimer);
});
const loadTokens = async () => {
setLoading(true);
@ -65,7 +198,9 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
setIsGenerating(true);
try {
const trimmedName = nameInput().trim() || undefined;
const { token, record } = await SecurityAPI.createToken(trimmedName);
const scopeSelection = [...selectedScopes()].sort();
const scopePayload = scopeSelection.length > 0 ? scopeSelection : undefined;
const { token, record } = await SecurityAPI.createToken(trimmedName, scopePayload);
setTokens((prev) => [record, ...prev]);
setNewTokenValue(token);
@ -89,7 +224,6 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
} catch (storageErr) {
console.warn('Unable to persist API token in localStorage', storageErr);
}
} catch (err) {
console.error('Failed to generate API token', err);
showError('Failed to generate API token');
@ -112,7 +246,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
if (record.name?.trim()) return record.name.trim();
if (record.prefix && record.suffix) return `${record.prefix}${record.suffix}`;
if (record.prefix) return `${record.prefix}`;
return 'unnamed token';
return 'untitled token';
};
const handleDelete = async (record: APITokenRecord) => {
@ -120,7 +254,6 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const displayName = tokenNameForDialog(record);
let message = `Revoke token "${displayName}"? Any agents or integrations using it will stop working.`;
if (usage) {
const hostListPreview = usage.hosts.slice(0, 5).join(', ');
const extraCount = usage.hosts.length - 5;
@ -131,8 +264,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
message = `Token "${displayName}" is currently used by ${hostCountLabel}.\nHosts: ${hostSummary}\n\nRevoking it will cause those agents to stop reporting until you update them with a new token.\n\nContinue?`;
}
const confirmed = window.confirm(message);
if (!confirmed) return;
if (!window.confirm(message)) return;
try {
await SecurityAPI.deleteToken(record.id);
@ -170,45 +302,197 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
};
return (
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-lg">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<SectionHeader
title="API tokens"
description="Generate or revoke access tokens for automation and agents"
size="sm"
class="flex-1"
/>
<div class="space-y-6">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">API tokens</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Authenticate host agents, Docker integrations, and automation pipelines with scoped access.
</p>
</div>
<button
type="button"
onClick={focusCreateSection}
class="inline-flex items-center gap-2 self-start rounded-md border border-blue-200 bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Generate token
</button>
</div>
<div class="p-6 space-y-6">
<Show when={props.refreshing}>
<div class="flex items-center gap-2 rounded-md bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 px-3 py-2 text-xs text-blue-800 dark:text-blue-200">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4" stroke="currentColor" />
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round" stroke="currentColor" />
</svg>
<span>Refreshing security status</span>
</div>
</Show>
<Show when={props.refreshing}>
<div class="flex items-center gap-2 rounded-md border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/30 px-3 py-2 text-xs text-blue-800 dark:text-blue-200">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4" stroke="currentColor" />
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round" stroke="currentColor" />
</svg>
<span>Refreshing security status</span>
</div>
</Show>
<div class="space-y-6">
<Card padding="lg" class="space-y-6">
<div class="space-y-2">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">Active tokens</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Rotate tokens regularly and scope them to the minimum access required.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/40 p-3">
<p class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Active tokens
</p>
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">{totalTokens()}</p>
</div>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/40 p-3">
<p class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Scoped tokens
</p>
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">{scopedTokenCount()}</p>
</div>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/40 p-3">
<p class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{hasWildcardTokens() ? 'Full access tokens' : 'Last generated'}
</p>
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">
{hasWildcardTokens()
? wildcardCount()
: totalTokens() > 0
? mostRecentLabel()
: '—'}
</p>
</div>
</div>
<Show
when={!loading() && totalTokens() > 0}
fallback={
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-700/70 bg-white/60 dark:bg-gray-900/30 p-5 text-sm text-gray-600 dark:text-gray-400">
<p class="mb-3">
No tokens yet. Generate one to authenticate agents and automation.
</p>
<button
type="button"
onClick={focusCreateSection}
class="inline-flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200"
>
Generate token
</button>
</div>
}
>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Label</th>
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Token hint</th>
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Scopes</th>
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Created</th>
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Last used</th>
<th class="py-2 px-3 text-right font-medium text-gray-600 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedTokens()}>
{(token) => {
const usage = dockerTokenUsage().get(token.id);
const hostTitle = usage ? usage.hosts.join(', ') : undefined;
const hostPreview = usage ? usage.hosts.slice(0, 2).join(', ') : '';
const extraCount = usage ? usage.hosts.length - 2 : 0;
const hostSummary =
usage && usage.count === 1
? usage.hosts[0]
: usage
? `${hostPreview}${extraCount > 0 ? `, +${extraCount} more` : ''}`
: '';
const hostCountLabel =
usage && usage.count === 1 ? 'host' : usage ? 'hosts' : '';
const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*'];
const scopeBadges = rawScopes.includes('*')
? [{ value: '*', label: 'Full access' }]
: rawScopes.map((scope) => ({
value: scope,
label: API_SCOPE_LABELS[scope] ?? scope,
}));
const rowIsWildcard = scopeBadges.some((scope) => scope.value === '*');
return (
<tr
class={`transition-colors ${rowIsWildcard ? 'bg-amber-50/60 dark:bg-amber-900/15' : 'bg-white dark:bg-gray-900/20'} hover:bg-gray-50 dark:hover:bg-gray-800/60`}
>
<td class="py-3 px-3 text-gray-900 dark:text-gray-100">
<div class="flex items-center gap-2">
<span class="font-medium">{token.name || 'Untitled token'}</span>
<Show when={usage}>
<span class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Docker
</span>
</Show>
</div>
<Show when={usage}>
<div
class="mt-1 text-xs text-blue-700 dark:text-blue-300"
title={hostTitle}
>
Used by Docker {hostCountLabel}: {hostSummary}
</div>
</Show>
</td>
<td class="py-3 px-3 font-mono text-xs text-gray-600 dark:text-gray-400">
{tokenHint(token)}
</td>
<td class="py-3 px-3">
<div class="flex flex-wrap gap-1">
<For each={scopeBadges}>
{(scope) => {
const isWildcard = scope.value === '*';
const badgeClass = isWildcard
? 'inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-200'
: 'inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-[11px] font-medium text-gray-700 dark:text-gray-200';
return (
<span class={badgeClass} title={scope.value}>
{scope.label}
</span>
);
}}
</For>
</div>
</td>
<td class="py-3 px-3 text-gray-600 dark:text-gray-400">
{formatRelativeTime(new Date(token.createdAt).getTime())}
</td>
<td class="py-3 px-3 text-gray-600 dark:text-gray-400">
{token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'}
</td>
<td class="py-3 px-3 text-right">
<button
type="button"
onClick={() => handleDelete(token)}
class="inline-flex items-center rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50"
>
Revoke
</button>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</Show>
</Card>
{/* Generated token reminder - only show when dialog is not already visible */}
<Show when={newTokenValue() && !isRevealActiveForCurrentToken()}>
<div class="space-y-3 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/30 p-4">
<Card padding="lg" tone="success" class="space-y-3 border border-green-300 dark:border-green-700">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 p-2">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
@ -216,8 +500,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<h3 class="text-base font-semibold text-green-900 dark:text-green-100">
Token ready to copy
</h3>
<p class="text-sm text-green-800 dark:text-green-200 leading-snug">
We keep the full token inside a secure dialog. Reopen it if you still need to copy the value before navigating away.
<p class="text-sm leading-snug text-green-800 dark:text-green-200">
Tokens are only shown once. Copy it now or store it securely before you leave this page.
</p>
<Show when={newTokenRecord()}>
<p class="text-xs text-green-900/80 dark:text-green-200/80">
@ -237,9 +521,9 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<button
type="button"
onClick={reopenTokenDialog}
class="inline-flex items-center gap-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-sm font-semibold px-4 py-2 transition-colors shadow-sm"
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-green-700"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7h4m0 0v4m0-4l-6 6-4-4-6 6" />
</svg>
Show token dialog
@ -250,125 +534,300 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
setNewTokenValue(null);
setNewTokenRecord(null);
}}
class="inline-flex items-center rounded-md border border-green-400 dark:border-green-700 px-4 py-2 text-sm font-medium text-green-800 dark:text-green-200 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors"
class="inline-flex items-center rounded-md border border-green-500 px-4 py-2 text-sm font-medium text-green-800 transition-colors hover:bg-green-100 dark:border-green-600 dark:text-green-200 dark:hover:bg-green-900/40"
>
Dismiss reminder
Dismiss
</button>
</div>
</div>
</Card>
</Show>
<div class="text-xs text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
Issue a dedicated token for each host or automation. That way, if a system is compromised, you can revoke just its token without disrupting anything else.
</div>
<Card
padding="lg"
class={`space-y-6 transition-shadow duration-300 ${createHighlight() ? 'ring-2 ring-blue-500/60 dark:ring-blue-400/60 shadow-lg' : ''}`}
ref={(el: HTMLDivElement) => {
createSectionRef = el;
}}
>
<div class="space-y-6">
<div class="space-y-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">Generate new token</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Tokens are only displayed once. Follow the steps below to create a scoped credential.
</p>
</div>
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate new token</h3>
<div class="flex gap-2">
<input
id="api-token-name"
type="text"
value={nameInput()}
onInput={(event) => setNameInput(event.currentTarget.value)}
placeholder="e.g., docker-host-1"
class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<ol class="space-y-6 text-sm text-gray-700 dark:text-gray-300">
<li class="flex gap-3">
<div class="mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold uppercase text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
1
</div>
<div class="flex-1 space-y-2">
<p class="font-medium text-gray-900 dark:text-gray-100">Name the token</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Use something descriptive so you can identify the integration later.
</p>
<input
id="api-token-name"
type="text"
value={nameInput()}
onInput={(event) => setNameInput(event.currentTarget.value)}
placeholder="e.g., docker-host-1"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
</li>
<li class="flex gap-3">
<div class="mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold uppercase text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
2
</div>
<div class="flex-1 space-y-2">
<p class="font-medium text-gray-900 dark:text-gray-100">Set a baseline scope</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Start with a preset that matches the integration, or choose full access if you plan to trim later.
</p>
<div class="space-y-2">
<button
type="button"
class={`${presetButtonBase} ${isFullAccessSelected() ? presetButtonActive : presetButtonInactive}`}
onClick={clearScopes}
>
<div>
<p class="font-semibold text-gray-900 dark:text-gray-100">Full access</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Legacy wildcard token grants every permission.</p>
</div>
<span class={`text-xs font-semibold uppercase ${isFullAccessSelected() ? 'text-blue-600 dark:text-blue-300' : 'text-gray-500 dark:text-gray-400'}`}>
{isFullAccessSelected() ? 'Selected' : 'Reset'}
</span>
</button>
<For each={scopePresets}>
{(preset) => (
<button
type="button"
class={`${presetButtonBase} ${presetMatchesSelection(preset.scopes) ? presetButtonActive : presetButtonInactive}`}
onClick={() => applyScopePreset(preset.scopes)}
>
<div>
<p class="font-semibold text-gray-900 dark:text-gray-100">{preset.label}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{preset.description}</p>
</div>
<span class={`text-xs font-semibold uppercase ${presetMatchesSelection(preset.scopes) ? 'text-blue-600 dark:text-blue-300' : 'text-blue-500 dark:text-blue-300/80'}`}>
{presetMatchesSelection(preset.scopes) ? 'Selected' : 'Apply'}
</span>
</button>
)}
</For>
</div>
<div class="rounded-md border border-dashed border-gray-300 bg-white px-3 py-2 text-xs text-gray-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-300">
<span class="block text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Current selection
</span>
<Show
when={!isFullAccessSelected() && selectedScopeChips().length > 0}
fallback={<span class="block pt-1 text-xs text-gray-700 dark:text-gray-200">Full access (wildcard)</span>}
>
<div class="pt-1 flex flex-wrap gap-1.5">
<For each={selectedScopeChips()}>
{(chip) => (
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{chip.label}
</span>
)}
</For>
</div>
</Show>
</div>
</div>
</li>
<li class="flex gap-3">
<div class="mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold uppercase text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
3
</div>
<div class="flex-1 space-y-3">
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">Fine-tune permissions</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Toggle advanced scopes if the integration needs additional access beyond the preset.
</p>
</div>
<div class="space-y-2">
<Show when={!isFullAccessSelected() && selectedScopeChips().length > 0}>
<div class="flex flex-wrap gap-1.5">
<For each={selectedScopeChips()}>
{(chip) => (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300">
{chip.value}
</span>
)}
</For>
</div>
</Show>
<button
type="button"
onClick={() => setAdvancedScopesOpen((open) => !open)}
class={`inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-wide transition-colors ${advancedScopesOpen() ? 'text-blue-600 dark:text-blue-300' : 'text-gray-600 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-300'}`}
>
<svg
class={`h-3 w-3 transition-transform ${advancedScopesOpen() ? 'rotate-180' : ''}`}
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M3 4.5L6 7.5L9 4.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{advancedScopesOpen() ? 'Hide advanced scopes' : 'Add or remove individual scopes'}
</button>
</div>
<Show when={advancedScopesOpen()}>
<div class="space-y-4">
<For each={scopeGroups()}>
{([group, options]) => (
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{group}
</p>
<div class="grid gap-2 sm:grid-cols-2">
<For each={options}>
{(option) => {
const inputId = `scope-${option.value.replace(/[:]/g, '-')}`;
const checked = () => selectedScopes().includes(option.value);
return (
<label
for={inputId}
class={`flex items-start gap-3 rounded-lg border px-3 py-2 text-sm transition-colors ${checked() ? 'border-blue-400 bg-blue-50/70 ring-1 ring-blue-300 dark:border-blue-500 dark:bg-blue-900/30 dark:ring-blue-400/40' : 'border-gray-200 bg-white hover:border-blue-400 dark:border-gray-600 dark:bg-gray-900/50 dark:hover:border-blue-500'}`}
>
<input
id={inputId}
type="checkbox"
class="mt-1 h-4 w-4 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
checked={checked()}
onInput={(event) => {
const isChecked = event.currentTarget.checked;
setSelectedScopes((prev) => {
if (isChecked) {
if (prev.includes(option.value)) return prev;
return [...prev, option.value];
}
return prev.filter((value) => value !== option.value);
});
}}
/>
<div class="space-y-1">
<p class="font-medium text-gray-900 dark:text-gray-100">
{option.label}
</p>
<Show when={option.description}>
<p class="text-xs leading-snug text-gray-500 dark:text-gray-400">
{option.description}
</p>
</Show>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300">
{option.value}
</span>
</div>
</label>
);
}}
</For>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</li>
</ol>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
class="inline-flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200 dark:hover:bg-blue-900/50"
onClick={handleGenerate}
disabled={isGenerating()}
>
{isGenerating() ? 'Generating…' : 'Generate'}
{isGenerating() ? (
<>
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4" stroke="currentColor" />
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round" stroke="currentColor" />
</svg>
Generating
</>
) : (
<>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Generate token
</>
)}
</button>
<Show when={newTokenValue() && !isRevealActiveForCurrentToken()}>
<button
type="button"
onClick={reopenTokenDialog}
class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600"
>
View last token
</button>
</Show>
</div>
</div>
</Card>
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Active tokens</h3>
<Show
when={!loading() && tokens().length > 0}
fallback={
<p class="text-sm text-gray-600 dark:text-gray-400">
No API tokens yet. Generate one above to get started.
</p>
}
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Label</th>
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Token hint</th>
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Created</th>
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Last used</th>
<th class="py-2 px-3 text-right font-medium text-gray-600 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={tokens()}>
{(token) => {
const usage = dockerTokenUsage().get(token.id);
const hostTitle = usage ? usage.hosts.join(', ') : undefined;
const hostPreview = usage ? usage.hosts.slice(0, 2).join(', ') : '';
const extraCount = usage ? usage.hosts.length - 2 : 0;
const hostSummary =
usage && usage.count === 1
? usage.hosts[0]
: usage
? `${hostPreview}${extraCount > 0 ? `, +${extraCount} more` : ''}`
: '';
const hostCountLabel =
usage && usage.count === 1 ? 'host' : usage ? 'hosts' : '';
<Show when={!loading() && hasWildcardTokens()}>
<Card padding="lg" tone="warning" class="space-y-3 border border-amber-300 dark:border-amber-700">
<h4 class="text-sm font-semibold text-amber-900 dark:text-amber-200">
Full access tokens detected
</h4>
<p class="text-xs text-amber-900/90 dark:text-amber-100/90">
Edit existing tokens to assign scopes, or generate replacements with the presets above so compromised credentials cant control everything.
</p>
<button
type="button"
onClick={focusCreateSection}
class="inline-flex w-fit items-center gap-2 rounded-md border border-amber-300 bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-200 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-100"
>
Review scopes
</button>
</Card>
</Show>
return (
<tr>
<td class="py-2 px-3 text-gray-900 dark:text-gray-100">
<div class="flex items-center gap-2">
<span>{token.name || 'Untitled token'}</span>
<Show when={usage}>
<span class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Docker
</span>
</Show>
</div>
<Show when={usage}>
<div
class="mt-1 text-xs text-blue-700 dark:text-blue-300"
title={hostTitle}
>
Used by Docker {hostCountLabel}: {hostSummary}
</div>
</Show>
</td>
<td class="py-2 px-3 font-mono text-xs text-gray-600 dark:text-gray-400">
{tokenHint(token)}
</td>
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
{formatRelativeTime(new Date(token.createdAt).getTime())}
</td>
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
{token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'}
</td>
<td class="py-2 px-3 text-right">
<button
type="button"
onClick={() => handleDelete(token)}
class="inline-flex items-center px-3 py-1 text-xs font-medium text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
>
Revoke
</button>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</Show>
</div>
<Card padding="lg" tone="muted" class="space-y-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Good practices</h4>
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-300">
<li class="flex gap-3">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
<span>Issue separate tokens for Docker agents, host agents, and automation pipelines so you can revoke them independently.</span>
</li>
<li class="flex gap-3">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
<span>Rotate tokens on a schedule and remove ones that havent been used recently.</span>
</li>
<li class="flex gap-3">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
<span>
View the{' '}
<a
class="font-medium text-blue-600 underline decoration-transparent transition-colors hover:decoration-blue-500 dark:text-blue-300"
href={SCOPES_DOC_URL}
target="_blank"
rel="noreferrer"
>
scoped token guide
</a>{' '}
for the full list of available permissions.
</span>
</li>
</ul>
</Card>
</div>
</Card>
</div>
);
};
export default APITokenManager;

View file

@ -0,0 +1,378 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import type { JSX } from 'solid-js';
import { useWebSocket } from '@/App';
import type { Host } from '@/types/api';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Card } from '@/components/shared/Card';
import CopyButton from '@/components/shared/CopyButton';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { SecurityAPI } from '@/api/security';
import { notificationStore } from '@/stores/notifications';
import { showTokenReveal } from '@/stores/tokenReveal';
import { HOST_AGENT_SCOPE } from '@/constants/apiScopes';
type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows';
interface HostAgentsProps {
variant?: HostAgentVariant;
}
const RELEASE_BASE = 'https://github.com/rcourtman/Pulse/releases/latest/download';
const TOKEN_PLACEHOLDER = '<api-token>';
const pulseUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:7655';
const { protocol, hostname, port } = window.location;
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
};
const commandsByVariant: Record<HostAgentVariant, { title: string; description: string; snippets: { label: string; command: string; note?: string | JSX.Element }[] }> = {
all: {
title: 'Installation quick start',
description:
'Generate an API token from Settings → Security with the host agent reporting scope, then replace the highlighted token placeholder. Agents only require outbound HTTP(S) access to Pulse.',
snippets: [
{
label: 'Linux (systemd)',
command: [
`curl -fsSL ${RELEASE_BASE}/pulse-host-agent-linux-amd64 -o /usr/local/bin/pulse-host-agent`,
'sudo chmod +x /usr/local/bin/pulse-host-agent',
`sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
].join(' && '),
},
{
label: 'macOS (launchd)',
command: [
`curl -fsSL ${RELEASE_BASE}/pulse-host-agent-darwin-arm64 -o /usr/local/bin/pulse-host-agent`,
'sudo chmod +x /usr/local/bin/pulse-host-agent',
`sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
].join(' && '),
note: (
<span>
Create <code>~/Library/LaunchAgents/com.pulse.host-agent.plist</code> to keep the agent running between logins.
</span>
),
},
{
label: 'Ad-hoc execution',
command: `/usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
},
],
},
linux: {
title: 'Install on Linux',
description:
'Download the static binary, make it executable, and (optionally) register it as a systemd service. Replace the token placeholder with an API token scoped for host agent reporting.',
snippets: [
{
label: 'Install + enable (systemd)',
command: [
`curl -fsSL ${RELEASE_BASE}/pulse-host-agent-linux-amd64 -o /usr/local/bin/pulse-host-agent`,
'sudo chmod +x /usr/local/bin/pulse-host-agent',
`sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
].join(' && '),
note: (
<span>
For persistence, create <code>/etc/systemd/system/pulse-host-agent.service</code> and enable it with{' '}
<code>systemctl enable --now pulse-host-agent</code>.
</span>
),
},
],
},
macos: {
title: 'Install on macOS',
description:
'Use the universal macOS build (arm64) with an API token that grants the host agent reporting scope, then register it via launchd for continuous reporting.',
snippets: [
{
label: 'Install binary',
command: [
`curl -fsSL ${RELEASE_BASE}/pulse-host-agent-darwin-arm64 -o /usr/local/bin/pulse-host-agent`,
'sudo chmod +x /usr/local/bin/pulse-host-agent',
].join(' && '),
},
{
label: 'Launchd service',
command: `launchctl load ~/Library/LaunchAgents/com.pulse.host-agent.plist`,
note: (
<span>
Create a plist pointing to{' '}
<code>/usr/local/bin/pulse-host-agent --url {pulseUrl()} --token {TOKEN_PLACEHOLDER} --interval 30s</code> to run at login.
</span>
),
},
],
},
windows: {
title: 'Install on Windows',
description:
'Native Windows builds are coming soon. In the interim you can run the Linux binary under WSL or compile from source using an API token scoped for host agent reporting.',
snippets: [
{
label: 'Compile from source (PowerShell)',
command: [
'git clone https://github.com/rcourtman/Pulse.git',
'cd Pulse',
'go build -o pulse-host-agent.exe ./cmd/pulse-host-agent',
`./pulse-host-agent.exe --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
].join(' && '),
note: (
<span>
Consider registering the executable as a Windows Service via <code>sc.exe</code> or NSSM once native artefacts ship.
</span>
),
},
],
},
};
const platformFilters: Record<HostAgentVariant, string[] | null> = {
all: null,
linux: ['linux'],
macos: ['macos'],
windows: ['windows'],
};
export const HostAgents: Component<HostAgentsProps> = (props) => {
const variant: HostAgentVariant = props.variant ?? 'all';
const { state } = useWebSocket();
const [apiToken, setApiToken] = createSignal('');
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
const [tokenAccessDenied, setTokenAccessDenied] = createSignal(false);
const hosts = createMemo(() => {
const list = state.hosts ?? [];
const filters = platformFilters[variant];
const filtered = filters ? list.filter((host) => filters.includes((host.platform ?? '').toLowerCase())) : list;
return [...filtered].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''));
});
const renderTags = (host: Host) => {
const tags = host.tags ?? [];
if (!tags.length) return '—';
return tags.join(', ');
};
const installMeta = commandsByVariant[variant];
onMount(() => {
if (typeof window === 'undefined') return;
try {
const stored = window.localStorage.getItem('apiToken');
if (stored) {
setApiToken(stored);
}
} catch (err) {
console.warn('Unable to read API token from localStorage', err);
}
});
createEffect(() => {
if (typeof window === 'undefined') return;
const token = apiToken();
try {
if (token) {
window.localStorage.setItem('apiToken', token);
} else {
window.localStorage.removeItem('apiToken');
}
} catch (err) {
console.warn('Unable to persist API token in localStorage', err);
}
});
const generateToken = async () => {
if (isGeneratingToken()) return;
if (tokenAccessDenied()) {
notificationStore.error('Administrator access required to generate host agent tokens.', 6000);
return;
}
setIsGeneratingToken(true);
try {
const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`;
const { token, record } = await SecurityAPI.createToken(defaultName, [HOST_AGENT_SCOPE]);
setApiToken(token);
showTokenReveal({
token,
record,
source: 'host-agent',
note: 'Copy this token into the host agent install command or store it securely for automation.',
});
notificationStore.success('Created host agent API token with reporting scope.', 6000);
} catch (err) {
console.error('Failed to create host agent token', err);
if (err instanceof Error && /authentication required|forbidden/i.test(err.message)) {
setTokenAccessDenied(true);
notificationStore.error('Sign in with an administrator account to generate tokens here.', 6000);
} else {
notificationStore.error('Failed to generate API token', 6000);
}
} finally {
setIsGeneratingToken(false);
}
};
const cardTitle = () => {
switch (variant) {
case 'linux':
return 'Linux servers';
case 'macos':
return 'macOS devices';
case 'windows':
return 'Windows servers';
default:
return 'Host agents';
}
};
const cardDescription = () => {
switch (variant) {
case 'linux':
return 'Install the Pulse host agent on Debian, Ubuntu, RHEL, Arch, or other Linux hosts to surface uptime and capacity metrics.';
case 'macos':
return 'Deploy the lightweight host agent via launchd to keep macOS hardware in view alongside your Proxmox estate.';
case 'windows':
return 'Track Windows Server hosts through a native Pulse agent. A first-party build is on the roadmap—compile from source today or watch this space.';
default:
return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.';
}
};
return (
<div class="space-y-6">
<SectionHeader title={cardTitle()} description={cardDescription()} />
<Card padding="lg" class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">API token</h3>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
Manage tokens via <strong>Settings Security</strong>
</div>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="text"
value={apiToken()}
onInput={(e) => setApiToken(e.currentTarget.value.trim())}
placeholder="Paste API token (leave blank to keep <api-token> placeholder)"
class="flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={generateToken}
disabled={isGeneratingToken()}
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
<Show when={apiToken()}>
<span class="text-xs text-gray-500 dark:text-gray-400">
Token will be embedded in the commands below.
</span>
</Show>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Tokens generated here automatically include the host agent reporting scope (<code>host-agent:report</code>).
</p>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{installMeta.title}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{installMeta.description}</p>
<div class="space-y-3">
<For each={installMeta.snippets}>
{(snippet) => (
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h4>
<CopyButton
text={snippet.command.replace(
TOKEN_PLACEHOLDER,
apiToken() || TOKEN_PLACEHOLDER,
)}
>
Copy command
</CopyButton>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>
{snippet.command.replace(
TOKEN_PLACEHOLDER,
apiToken() || TOKEN_PLACEHOLDER,
)}
</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div>
)}
</For>
</div>
</Card>
<Card padding="lg" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Reporting hosts</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">{hosts().length} connected</span>
</div>
<Show
when={hosts().length > 0}
fallback={
<p class="text-sm text-gray-600 dark:text-gray-400">
{variant === 'windows'
? 'No Windows hosts have reported yet. Compile the agent from source or check back when native artefacts are published.'
: 'No host agents are reporting yet. Deploy the binary using the commands above to see hosts listed here.'}
</p>
}
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Hostname</th>
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Platform</th>
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Uptime</th>
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Memory</th>
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Last seen</th>
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Tags</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
<For each={hosts()}>
{(host) => (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/60">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
{host.displayName || host.hostname || host.id}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300 capitalize">
{host.platform || '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.memory?.total
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}`
: '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{renderTags(host)}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</Card>
</div>
);
};
export default HostAgents;

View file

@ -5,10 +5,11 @@ import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { NodeModal } from './NodeModal';
import { APITokenManager } from './APITokenManager';
import { ChangePasswordModal } from './ChangePasswordModal';
import { GuestURLs } from './GuestURLs';
import { DockerAgents } from './DockerAgents';
import { HostAgents } from './HostAgents';
import APITokenManager from './APITokenManager';
import { OIDCPanel } from './OIDCPanel';
import { QuickSecuritySetup } from './QuickSecuritySetup';
import { SecurityPostureSummary } from './SecurityPostureSummary';
@ -23,12 +24,17 @@ import { formField, labelClass, controlClass, formHelpText } from '@/components/
import Server from 'lucide-solid/icons/server';
import HardDrive from 'lucide-solid/icons/hard-drive';
import Mail from 'lucide-solid/icons/mail';
import Link from 'lucide-solid/icons/link';
import Container from 'lucide-solid/icons/container';
import SettingsIcon from 'lucide-solid/icons/settings';
import Shield from 'lucide-solid/icons/shield';
import Activity from 'lucide-solid/icons/activity';
import Loader from 'lucide-solid/icons/loader';
import Boxes from 'lucide-solid/icons/boxes';
import Network from 'lucide-solid/icons/network';
import Terminal from 'lucide-solid/icons/terminal';
import Monitor from 'lucide-solid/icons/monitor';
import Laptop from 'lucide-solid/icons/laptop';
import { ApiIcon } from '@/components/icons/ApiIcon';
import type { NodeConfig } from '@/types/nodes';
import type { UpdateInfo, VersionInfo } from '@/api/updates';
import type { APITokenRecord } from '@/api/security';
@ -222,8 +228,13 @@ type SettingsTab =
| 'pbs'
| 'pmg'
| 'docker'
| 'podman'
| 'kubernetes'
| 'linuxServers'
| 'windowsServers'
| 'macServers'
| 'system'
| 'urls'
| 'api'
| 'security'
| 'diagnostics'
| 'updates';
@ -245,13 +256,33 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
title: 'Docker Agents',
description: 'Configure Docker hosts, agent tokens, and polling behaviour for container insights.',
},
podman: {
title: 'Podman (container runtime)',
description: 'Auto-discovery and agent-based monitoring for Podman hosts is on the roadmap.',
},
kubernetes: {
title: 'Kubernetes',
description: 'Cluster-wide monitoring via Pulse is coming soon. Watch this space for Helm charts and operators.',
},
linuxServers: {
title: 'Linux Servers',
description: 'Install the host agent on Debian, Ubuntu, RHEL, or other Linux distributions to surface capacity metrics.',
},
windowsServers: {
title: 'Windows Servers',
description: 'Native Windows support is planned; compile from source or track development updates here.',
},
macServers: {
title: 'macOS Devices',
description: 'Monitor macOS hosts via launchd-backed agents for uptime and temperature insights.',
},
system: {
title: 'System Settings',
description: 'Adjust Pulse core behaviour, discovery preferences, and software update cadence.',
},
urls: {
title: 'Guest URLs',
description: 'Define per-guest deep links to console, dashboards, or third-party tooling.',
api: {
title: 'API access',
description: 'Generate scoped tokens and manage automation credentials for agents and integrations.',
},
security: {
title: 'Security & Access',
@ -300,10 +331,15 @@ const Settings: Component<SettingsProps> = (props) => {
if (path.includes('/settings/pbs')) return 'pbs';
if (path.includes('/settings/pmg')) return 'pmg';
if (path.includes('/settings/docker')) return 'docker';
if (path.includes('/settings/podman')) return 'podman';
if (path.includes('/settings/kubernetes')) return 'kubernetes';
if (path.includes('/settings/linuxServers')) return 'linuxServers';
if (path.includes('/settings/windowsServers')) return 'windowsServers';
if (path.includes('/settings/macServers')) return 'macServers';
if (path.includes('/settings/system')) return 'system';
if (path.includes('/settings/api')) return 'api';
if (path.includes('/settings/security')) return 'security';
if (path.includes('/settings/diagnostics')) return 'diagnostics';
if (path.includes('/settings/urls')) return 'urls';
if (path.includes('/settings/updates')) return 'updates';
return 'pve';
};
@ -312,12 +348,13 @@ const Settings: Component<SettingsProps> = (props) => {
const activeTab = () => currentTab();
const setActiveTab = (tab: SettingsTab) => {
if (currentTab() !== tab) {
setCurrentTab(tab);
}
const targetPath = `/settings/${tab}`;
if (location.pathname !== targetPath) {
navigate(targetPath);
return;
}
if (currentTab() !== tab) {
setCurrentTab(tab);
}
};
@ -693,30 +730,31 @@ const Settings: Component<SettingsProps> = (props) => {
};
const tabGroups: {
id: 'proxmox' | 'docker' | 'administration';
id: 'platforms' | 'administration';
label: string;
items: { id: SettingsTab; label: string; icon: JSX.Element }[];
items: { id: SettingsTab; label: string; icon: JSX.Element; disabled?: boolean }[];
}[] = [
{
id: 'proxmox',
label: 'Proxmox',
id: 'platforms',
label: 'Platforms',
items: [
{ id: 'pve', label: 'Proxmox VE nodes', icon: <Server class="w-4 h-4" strokeWidth={2} /> },
{ id: 'pbs', label: 'Proxmox Backup Server', icon: <HardDrive class="w-4 h-4" strokeWidth={2} /> },
{ id: 'pmg', label: 'Proxmox Mail Gateway', icon: <Mail class="w-4 h-4" strokeWidth={2} /> },
{ id: 'urls', label: 'Guest URLs', icon: <Link class="w-4 h-4" strokeWidth={2} /> },
{ id: 'docker', label: 'Docker hosts', icon: <Container class="w-4 h-4" strokeWidth={2} /> },
{ id: 'podman', label: 'Podman hosts', icon: <Boxes class="w-4 h-4" strokeWidth={2} />, disabled: true },
{ id: 'kubernetes', label: 'Kubernetes', icon: <Network class="w-4 h-4" strokeWidth={2} />, disabled: true },
{ id: 'linuxServers', label: 'Linux servers', icon: <Terminal class="w-4 h-4" strokeWidth={2} /> },
{ id: 'windowsServers', label: 'Windows servers', icon: <Monitor class="w-4 h-4" strokeWidth={2} /> },
{ id: 'macServers', label: 'macOS devices', icon: <Laptop class="w-4 h-4" strokeWidth={2} /> },
],
},
{
id: 'docker',
label: 'Docker',
items: [{ id: 'docker', label: 'Docker hosts', icon: <Container class="w-4 h-4" strokeWidth={2} /> }],
},
{
id: 'administration',
label: 'Administration',
items: [
{ id: 'system', label: 'System', icon: <SettingsIcon class="w-4 h-4" strokeWidth={2} /> },
{ id: 'api', label: 'API access', icon: <ApiIcon class="w-4 h-4" /> },
{ id: 'security', label: 'Security', icon: <Shield class="w-4 h-4" strokeWidth={2} /> },
{ id: 'diagnostics', label: 'Diagnostics', icon: <Activity class="w-4 h-4" strokeWidth={2} /> },
],
@ -1802,7 +1840,7 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
<Card padding="none" class="relative lg:flex">
<Card padding="none" class="relative lg:flex overflow-hidden">
<div
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-300`}
onMouseEnter={() => setSidebarCollapsed(false)}
@ -1822,16 +1860,26 @@ const Settings: Component<SettingsProps> = (props) => {
</Show>
<div class="space-y-1.5">
<For each={group.items}>
{(item) => (
{(item) => {
const isActive = () => activeTab() === item.id;
return (
<button
type="button"
aria-current={activeTab() === item.id ? 'page' : undefined}
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${
activeTab() === item.id
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
aria-current={isActive() ? 'page' : undefined}
disabled={item.disabled}
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${
sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'
} text-sm font-medium transition-colors ${
item.disabled
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600'
: isActive()
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
}`}
onClick={() => setActiveTab(item.id)}
onClick={() => {
if (item.disabled) return;
setActiveTab(item.id);
}}
title={sidebarCollapsed() ? item.label : undefined}
>
{item.icon}
@ -1839,7 +1887,8 @@ const Settings: Component<SettingsProps> = (props) => {
<span class="truncate">{item.label}</span>
</Show>
</button>
)}
);
}}
</For>
</div>
</div>
@ -1849,7 +1898,7 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</div>
<div class="flex-1">
<div class="flex-1 overflow-hidden">
<Show when={flatTabs.length > 0}>
<div class="lg:hidden border-b border-gray-200 dark:border-gray-700">
<div class="p-1">
@ -1858,26 +1907,36 @@ const Settings: Component<SettingsProps> = (props) => {
style="-webkit-overflow-scrolling: touch;"
>
<For each={flatTabs}>
{(tab) => (
<button
type="button"
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
activeTab() === tab.id
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
)}
{(tab) => {
const isActive = activeTab() === tab.id;
const disabled = tab.disabled;
return (
<button
type="button"
disabled={disabled}
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
disabled
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600'
: isActive
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
onClick={() => {
if (disabled) return;
setActiveTab(tab.id);
}}
>
{tab.label}
</button>
);
}}
</For>
</div>
</div>
</div>
</Show>
<div class="p-3 sm:p-6">
<div class="p-3 sm:p-6 overflow-x-auto">
{/* PVE Nodes Tab */}
<Show when={activeTab() === 'pve'}>
<div class="space-y-4">
@ -2394,6 +2453,13 @@ const Settings: Component<SettingsProps> = (props) => {
</For>
</div>
</Show>
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<GuestURLs
hasUnsavedChanges={hasUnsavedChanges}
setHasUnsavedChanges={setHasUnsavedChanges}
/>
</div>
</div>
</Show>
</div>
@ -3214,6 +3280,37 @@ const Settings: Component<SettingsProps> = (props) => {
<DockerAgents />
</Show>
{/* Podman Tab */}
<Show when={activeTab() === 'podman'}>
<PlatformComingSoon
name="Podman"
description="Pulse will support Podman hosts via the same lightweight agent workflow as Docker. Keep an eye on the release notes for availability."
/>
</Show>
{/* Kubernetes Tab */}
<Show when={activeTab() === 'kubernetes'}>
<PlatformComingSoon
name="Kubernetes"
description="Native Kubernetes monitoring (agents, Helm chart, RBAC templates) is in design. Join the GitHub discussions to weigh in."
/>
</Show>
{/* Linux Host Agents */}
<Show when={activeTab() === 'linuxServers'}>
<HostAgents variant="linux" />
</Show>
{/* Windows Host Agents */}
<Show when={activeTab() === 'windowsServers'}>
<HostAgents variant="windows" />
</Show>
{/* macOS Host Agents */}
<Show when={activeTab() === 'macServers'}>
<HostAgents variant="macos" />
</Show>
{/* System Settings Tab */}
<Show when={activeTab() === 'system'}>
<div class="space-y-6">
@ -4273,6 +4370,36 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
{/* API Access */}
<Show when={activeTab() === 'api'}>
<div class="space-y-6">
<SectionHeader title="API access" size="md" class="mb-2" />
<Card tone="muted" padding="lg" class="space-y-3">
<p class="text-sm text-gray-600 dark:text-gray-400">
Generate scoped tokens for Docker agents, host agents, and automation pipelines.
Tokens are shown oncestore them securely and rotate when infrastructure changes.
</p>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes"
target="_blank"
rel="noreferrer"
class="inline-flex w-fit items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200"
>
View scope reference
</a>
</Card>
<APITokenManager
currentTokenHint={securityStatus()?.apiTokenHint}
onTokensChanged={() => {
void loadSecurityStatus();
}}
refreshing={securityStatusLoading()}
/>
</div>
</Show>
{/* Security Tab */}
<Show when={activeTab() === 'security'}>
<div class="space-y-6">
@ -4610,14 +4737,18 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
{/* Content */}
<div class="p-6">
<APITokenManager
currentTokenHint={securityStatus()?.apiTokenHint}
onTokensChanged={() => {
void loadSecurityStatus();
}}
refreshing={securityStatusLoading()}
/>
<div class="p-6 space-y-3">
<p class="text-sm text-gray-600 dark:text-gray-400">
API tokens now live under the dedicated API workspace. Generate new scoped tokens,
review existing access, and rotate credentials from the API menu.
</p>
<button
type="button"
onClick={() => setActiveTab('api')}
class="inline-flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200"
>
Open API tab
</button>
</div>
</Card>
@ -6189,13 +6320,6 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
{/* Guest URLs Tab */}
<Show when={activeTab() === 'urls'}>
<GuestURLs
hasUnsavedChanges={hasUnsavedChanges}
setHasUnsavedChanges={setHasUnsavedChanges}
/>
</Show>
</div>
</div>
</Card>
@ -6737,4 +6861,22 @@ const Settings: Component<SettingsProps> = (props) => {
);
};
const PlatformComingSoon: Component<{ name: string; description?: string }> = (props) => {
const description =
props.description ??
`Support for ${props.name} is on the roadmap. Track progress on GitHub or join our community discussions to weigh in on requirements.`;
return (
<div class="space-y-6">
<SectionHeader title={`${props.name} integration`} description={description} />
<Card padding="lg">
<p class="text-sm text-gray-600 dark:text-gray-400">
Were collecting feedback and prioritising the engineering work for this platform. If youd like to influence
the roadmap or volunteer for early testing, please open a discussion on GitHub or reach out via Discord.
</p>
</Card>
</div>
);
};
export default Settings;

View file

@ -0,0 +1,27 @@
import type { Component } from 'solid-js';
interface ApiIconProps {
class?: string;
}
export const ApiIcon: Component<ApiIconProps> = (props) => (
<svg
class={props.class}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 7v10" />
<path d="M20 7v10" />
<path d="M9 12h6" />
<path d="M12 9v6" />
<rect x="2.5" y="5" width="19" height="14" rx="2" />
</svg>
);
export default ApiIcon;

View file

@ -0,0 +1,21 @@
import type { Component } from 'solid-js';
interface ServersIconProps {
class?: string;
}
export const ServersIcon: Component<ServersIconProps> = (props) => (
<svg
class={props.class}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M4.5 4.125C4.5 3.504 5.004 3 5.625 3h12.75c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125H5.625A1.125 1.125 0 0 1 4.5 7.875v-3.75Zm1.875.75a.375.375 0 0 0-.375.375v1.125c0 .207.168.375.375.375h2.25a.375.375 0 0 0 .375-.375V5.25a.375.375 0 0 0-.375-.375h-2.25Zm5.625.375a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm3 0a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm-8.25 5.25c0-.621.504-1.125 1.125-1.125h12.75c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125H7.875A1.125 1.125 0 0 1 6.75 14.25v-3.75Zm1.875.75a.375.375 0 0 0-.375.375v1.125c0 .207.168.375.375.375h2.25a.375.375 0 0 0 .375-.375V11.25a.375.375 0 0 0-.375-.375h-2.25Zm5.625.375a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm3 0a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0ZM6.75 18.375c0-.621.504-1.125 1.125-1.125h12.75c.621 0 1.125.504 1.125 1.125v1.5A1.125 1.125 0 0 1 20.625 21H7.875a1.125 1.125 0 0 1-1.125-1.125v-1.5Zm1.5.375a.375.375 0 0 0-.375.375v.375c0 .207.168.375.375.375h1.5a.375.375 0 0 0 .375-.375v-.375a.375.375 0 0 0-.375-.375h-1.5Zm4.875.375a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm3 0a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Z"
clip-rule="evenodd"
/>
</svg>
);

View file

@ -0,0 +1,64 @@
export interface APIScopeOption {
value: string;
label: string;
description?: string;
group: 'Monitoring' | 'Agents' | 'Settings';
}
export const HOST_AGENT_SCOPE = 'host-agent:report';
export const DOCKER_REPORT_SCOPE = 'docker:report';
export const DOCKER_MANAGE_SCOPE = 'docker:manage';
export const MONITORING_READ_SCOPE = 'monitoring:read';
export const MONITORING_WRITE_SCOPE = 'monitoring:write';
export const SETTINGS_READ_SCOPE = 'settings:read';
export const SETTINGS_WRITE_SCOPE = 'settings:write';
export const API_SCOPE_OPTIONS: APIScopeOption[] = [
{
value: MONITORING_READ_SCOPE,
label: 'Dashboards & alerts (read)',
description: 'View monitoring data, dashboards, and alert history.',
group: 'Monitoring',
},
{
value: MONITORING_WRITE_SCOPE,
label: 'Alert actions (write)',
description: 'Acknowledge, silence, and clear alerts.',
group: 'Monitoring',
},
{
value: DOCKER_REPORT_SCOPE,
label: 'Docker agent reporting',
description: 'Allow the Docker agent to submit host and container telemetry.',
group: 'Agents',
},
{
value: DOCKER_MANAGE_SCOPE,
label: 'Docker lifecycle management',
description: 'Enable agent-triggered container commands and host actions.',
group: 'Agents',
},
{
value: HOST_AGENT_SCOPE,
label: 'Host agent reporting',
description: 'Allow the host agent to send OS, CPU, and disk metrics.',
group: 'Agents',
},
{
value: SETTINGS_READ_SCOPE,
label: 'Settings (read)',
description: 'Fetch configuration snapshots such as nodes and security posture.',
group: 'Settings',
},
{
value: SETTINGS_WRITE_SCOPE,
label: 'Settings (write)',
description: 'Modify configuration, manage tokens, and trigger updates.',
group: 'Settings',
},
];
export const API_SCOPE_LABELS = API_SCOPE_OPTIONS.reduce<Record<string, string>>((acc, option) => {
acc[option.value] = option.label;
return acc;
}, {});

View file

@ -194,6 +194,49 @@ export interface DockerContainerNetwork {
ipv6?: string;
}
export interface Host {
id: string;
hostname: string;
displayName: string;
platform?: string;
osName?: string;
osVersion?: string;
kernelVersion?: string;
architecture?: string;
cpuCount?: number;
cpuUsage?: number;
loadAverage?: number[];
memory: Memory;
disks?: Disk[];
networkInterfaces?: HostNetworkInterface[];
sensors?: HostSensorSummary;
status: string;
uptimeSeconds?: number;
lastSeen: number;
intervalSeconds?: number;
agentVersion?: string;
tokenId?: string;
tokenName?: string;
tokenHint?: string;
tokenLastUsedAt?: number;
tags?: string[];
}
export interface HostNetworkInterface {
name: string;
mac?: string;
addresses?: string[];
rxBytes?: number;
txBytes?: number;
speedMbps?: number;
}
export interface HostSensorSummary {
temperatureCelsius?: Record<string, number>;
fanRpm?: Record<string, number>;
additional?: Record<string, number>;
}
export interface ReplicationJob {
id: string;
instance: string;

12
go.mod
View file

@ -12,11 +12,14 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/oklog/ulid/v2 v2.1.1
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
github.com/rs/zerolog v1.34.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.42.0
golang.org/x/oauth2 v0.31.0
golang.org/x/sys v0.36.0
golang.org/x/term v0.35.0
golang.org/x/time v0.13.0
gopkg.in/yaml.v3 v3.0.1
@ -36,7 +39,9 @@ require (
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@ -46,11 +51,15 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
@ -58,7 +67,6 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.36.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

BIN
go.sum

Binary file not shown.

View file

@ -9,6 +9,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
@ -681,26 +682,59 @@ func (h *AlertHandlers) HandleAlerts(w http.ResponseWriter, r *http.Request) {
switch {
case path == "config" && r.Method == http.MethodGet:
if !ensureScope(w, r, config.ScopeMonitoringRead) {
return
}
h.GetAlertConfig(w, r)
case path == "config" && r.Method == http.MethodPut:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.UpdateAlertConfig(w, r)
case path == "activate" && r.Method == http.MethodPost:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.ActivateAlerts(w, r)
case path == "active" && r.Method == http.MethodGet:
if !ensureScope(w, r, config.ScopeMonitoringRead) {
return
}
h.GetActiveAlerts(w, r)
case path == "history" && r.Method == http.MethodGet:
if !ensureScope(w, r, config.ScopeMonitoringRead) {
return
}
h.GetAlertHistory(w, r)
case path == "history" && r.Method == http.MethodDelete:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.ClearAlertHistory(w, r)
case path == "bulk/acknowledge" && r.Method == http.MethodPost:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.BulkAcknowledgeAlerts(w, r)
case path == "bulk/clear" && r.Method == http.MethodPost:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.BulkClearAlerts(w, r)
case strings.HasSuffix(path, "/acknowledge") && r.Method == http.MethodPost:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.AcknowledgeAlert(w, r)
case strings.HasSuffix(path, "/unacknowledge") && r.Method == http.MethodPost:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.UnacknowledgeAlert(w, r)
case strings.HasSuffix(path, "/clear") && r.Method == http.MethodPost:
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
return
}
h.ClearAlert(w, r)
default:
http.Error(w, "Not found", http.StatusNotFound)

View file

@ -5,6 +5,7 @@ import (
cryptorand "crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
@ -618,6 +619,63 @@ func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
}
}
// RequireScope ensures that token-authenticated requests include the specified scope.
// Session-based (browser) requests bypass the scope check.
func RequireScope(scope string, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if scope == "" {
handler(w, r)
return
}
record := getAPITokenRecordFromRequest(r)
if record == nil {
// Session-authenticated request
handler(w, r)
return
}
if record.HasScope(scope) {
handler(w, r)
return
}
log.Warn().
Str("token_id", record.ID).
Str("required_scope", scope).
Msg("API token missing required scope")
respondMissingScope(w, scope)
}
}
func respondMissingScope(w http.ResponseWriter, scope string) {
if w == nil {
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "missing_scope",
"requiredScope": scope,
})
}
// ensureScope enforces that the request either originates from a session or a token
// possessing the specified scope. Returns true when access should continue.
func ensureScope(w http.ResponseWriter, r *http.Request, scope string) bool {
if scope == "" {
return true
}
record := getAPITokenRecordFromRequest(r)
if record == nil || record.HasScope(scope) {
return true
}
respondMissingScope(w, scope)
return false
}
func attachAPITokenRecord(r *http.Request, record *config.APITokenRecord) {
if record == nil {
return

View file

@ -0,0 +1,58 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestRequireScopeAllowsSession(t *testing.T) {
handler := RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
handler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status 200 for session request, got %d", rr.Code)
}
}
func TestRequireScopeRejectsMissingScope(t *testing.T) {
handler := RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
record := config.APITokenRecord{ID: "token-1", Scopes: []string{config.ScopeMonitoringRead}}
attachAPITokenRecord(req, &record)
rr := httptest.NewRecorder()
handler(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected status 403 when scope missing, got %d", rr.Code)
}
}
func TestRequireScopeAllowsMatchingScope(t *testing.T) {
handler := RequireScope(config.ScopeDockerReport, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
record := config.APITokenRecord{ID: "token-2", Scopes: []string{config.ScopeDockerReport}}
attachAPITokenRecord(req, &record)
rr := httptest.NewRecorder()
handler(rr, req)
if rr.Code != http.StatusAccepted {
t.Fatalf("expected status 202 when scope present, got %d", rr.Code)
}
}

View file

@ -0,0 +1,78 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rs/zerolog/log"
)
// HostAgentHandlers manages ingest from the pulse-host-agent.
type HostAgentHandlers struct {
monitor *monitoring.Monitor
wsHub *websocket.Hub
}
// NewHostAgentHandlers constructs a new handler set for host agents.
func NewHostAgentHandlers(m *monitoring.Monitor, hub *websocket.Hub) *HostAgentHandlers {
return &HostAgentHandlers{monitor: m, wsHub: hub}
}
// SetMonitor updates the monitor reference for host agent handlers.
func (h *HostAgentHandlers) SetMonitor(m *monitoring.Monitor) {
h.monitor = m
}
// HandleReport ingests host agent reports.
func (h *HostAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
defer r.Body.Close()
var report agentshost.Report
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
if report.Timestamp.IsZero() {
report.Timestamp = time.Now().UTC()
}
tokenRecord := getAPITokenRecordFromRequest(r)
host, err := h.monitor.ApplyHostReport(report, tokenRecord)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil)
return
}
log.Debug().
Str("hostId", host.ID).
Str("hostname", host.Hostname).
Str("platform", host.Platform).
Msg("Host agent report processed")
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
resp := map[string]any{
"success": true,
"hostId": host.ID,
"lastSeen": host.LastSeen,
"platform": host.Platform,
"osName": host.OSName,
"osVersion": host.OSVersion,
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize host agent response")
}
}

View file

@ -39,6 +39,7 @@ type Router struct {
configHandlers *ConfigHandlers
notificationHandlers *NotificationHandlers
dockerAgentHandlers *DockerAgentHandlers
hostAgentHandlers *HostAgentHandlers
systemSettingsHandler *SystemSettingsHandler
wsHub *websocket.Hub
reloadFunc func() error
@ -120,21 +121,23 @@ func (r *Router) setupRoutes() {
r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings)
updateHandlers := NewUpdateHandlers(r.updateManager, r.config.DataPath)
r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub)
r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub)
// API routes
r.mux.HandleFunc("/api/health", r.handleHealth)
r.mux.HandleFunc("/api/monitoring/scheduler/health", RequireAuth(r.config, r.handleSchedulerHealth))
r.mux.HandleFunc("/api/state", r.handleState)
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, r.dockerAgentHandlers.HandleReport))
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, r.dockerAgentHandlers.HandleCommandAck))
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, r.dockerAgentHandlers.HandleDockerHostActions))
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck)))
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
r.mux.HandleFunc("/api/version", r.handleVersion)
r.mux.HandleFunc("/api/storage/", r.handleStorage)
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
r.mux.HandleFunc("/api/charts", r.handleCharts)
r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics))
r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, r.handleDiagnosticsRegisterProxyNodes))
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, r.handleDiagnosticsDockerPrepareToken))
r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsRegisterProxyNodes)))
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken)))
r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy)
r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript)
r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript)
@ -173,9 +176,9 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/config/nodes", func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.configHandlers.HandleGetNodes(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsRead, r.configHandlers.HandleGetNodes))(w, req)
case http.MethodPost:
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleAddNode)(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleAddNode))(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
@ -184,7 +187,7 @@ func (r *Router) setupRoutes() {
// Test node configuration endpoint (for new nodes)
r.mux.HandleFunc("/api/config/nodes/test-config", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
r.configHandlers.HandleTestNodeConfig(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleTestNodeConfig))(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
@ -193,7 +196,7 @@ func (r *Router) setupRoutes() {
// Test connection endpoint
r.mux.HandleFunc("/api/config/nodes/test-connection", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
r.configHandlers.HandleTestConnection(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleTestConnection))(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
@ -201,13 +204,13 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/config/nodes/", func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodPut:
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleUpdateNode)(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleUpdateNode))(w, req)
case http.MethodDelete:
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleDeleteNode)(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleDeleteNode))(w, req)
case http.MethodPost:
// Handle test endpoint
if strings.HasSuffix(req.URL.Path, "/test") {
r.configHandlers.HandleTestNode(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleTestNode))(w, req)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}
@ -220,10 +223,10 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/config/system", func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.configHandlers.HandleGetSystemSettings(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsRead, r.configHandlers.HandleGetSystemSettings))(w, req)
case http.MethodPut:
// DEPRECATED - use /api/system/settings/update instead
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleUpdateSystemSettingsOLD)(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleUpdateSystemSettingsOLD))(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
@ -233,9 +236,9 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/system/mock-mode", func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.configHandlers.HandleGetMockMode(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsRead, r.configHandlers.HandleGetMockMode))(w, req)
case http.MethodPost, http.MethodPut:
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleUpdateMockMode)(w, req)
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleUpdateMockMode))(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
@ -248,20 +251,31 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/logout", r.handleLogout)
r.mux.HandleFunc("/api/login", r.handleLogin)
r.mux.HandleFunc("/api/security/reset-lockout", r.handleResetLockout)
r.mux.HandleFunc("/api/security/oidc", RequireAdmin(r.config, r.handleOIDCConfig))
r.mux.HandleFunc("/api/security/oidc", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleOIDCConfig)))
r.mux.HandleFunc("/api/oidc/login", r.handleOIDCLogin)
r.mux.HandleFunc(config.DefaultOIDCCallbackPath, r.handleOIDCCallback)
r.mux.HandleFunc("/api/security/tokens", RequireAdmin(r.config, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeSettingsRead) {
return
}
r.handleListAPITokens(w, req)
case http.MethodPost:
if !ensureScope(w, req, config.ScopeSettingsWrite) {
return
}
r.handleCreateAPIToken(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
r.mux.HandleFunc("/api/security/tokens/", RequireAdmin(r.config, r.handleDeleteAPIToken))
r.mux.HandleFunc("/api/security/tokens/", RequireAdmin(r.config, func(w http.ResponseWriter, req *http.Request) {
if !ensureScope(w, req, config.ScopeSettingsWrite) {
return
}
r.handleDeleteAPIToken(w, req)
}))
r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
@ -847,13 +861,13 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/notifications/", r.notificationHandlers.HandleNotifications)
// Settings routes
r.mux.HandleFunc("/api/settings", getSettings)
r.mux.HandleFunc("/api/settings/update", updateSettings)
r.mux.HandleFunc("/api/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, getSettings)))
r.mux.HandleFunc("/api/settings/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateSettings)))
// System settings and API token management
r.systemSettingsHandler = NewSystemSettingsHandler(r.config, r.persistence, r.wsHub, r.monitor, r.reloadSystemSettings)
r.mux.HandleFunc("/api/system/settings", r.systemSettingsHandler.HandleGetSystemSettings)
r.mux.HandleFunc("/api/system/settings/update", r.systemSettingsHandler.HandleUpdateSystemSettings)
r.mux.HandleFunc("/api/system/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.systemSettingsHandler.HandleGetSystemSettings)))
r.mux.HandleFunc("/api/system/settings/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.systemSettingsHandler.HandleUpdateSystemSettings)))
r.mux.HandleFunc("/api/system/ssh-config", r.handleSSHConfig)
r.mux.HandleFunc("/api/system/verify-temperature-ssh", r.handleVerifyTemperatureSSH)
r.mux.HandleFunc("/api/system/proxy-public-key", r.handleProxyPublicKey)
@ -1040,6 +1054,9 @@ func (r *Router) SetMonitor(m *monitoring.Monitor) {
if r.dockerAgentHandlers != nil {
r.dockerAgentHandlers.SetMonitor(m)
}
if r.hostAgentHandlers != nil {
r.hostAgentHandlers.SetMonitor(m)
}
if r.systemSettingsHandler != nil {
r.systemSettingsHandler.SetMonitor(m)
}
@ -2231,6 +2248,11 @@ func (r *Router) handleState(w http.ResponseWriter, req *http.Request) {
return
}
if record := getAPITokenRecordFromRequest(req); record != nil && !record.HasScope(config.ScopeMonitoringRead) {
respondMissingScope(w, config.ScopeMonitoringRead)
return
}
log.Debug().Msg("[DEBUG] handleState: Before GetState")
state := r.monitor.GetState()
log.Debug().Msg("[DEBUG] handleState: After GetState, before ToFrontend")
@ -3257,7 +3279,7 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req
return
}
record, err := config.NewAPITokenRecord(rawToken, name)
record, err := config.NewAPITokenRecord(rawToken, name, []string{config.ScopeDockerReport})
if err != nil {
log.Error().Err(err).Msg("Failed to construct token record for docker migration")
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil)

View file

@ -338,7 +338,7 @@ func TestAuthenticatedEndpointsRequireToken(t *testing.T) {
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
cfg.DisableAuth = false
cfg.APITokenEnabled = true
record, err := config.NewAPITokenRecord(apiToken, "Integration test token")
record, err := config.NewAPITokenRecord(apiToken, "Integration test token", nil)
if err != nil {
t.Fatalf("create API token record: %v", err)
}

View file

@ -129,7 +129,7 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
// Store the raw API token for displaying to the user
rawAPIToken := setupRequest.APIToken
tokenRecord, err := config.NewAPITokenRecord(rawAPIToken, "Primary token")
tokenRecord, err := config.NewAPITokenRecord(rawAPIToken, "Primary token", nil)
if err != nil {
log.Error().Err(err).Msg("Failed to construct API token record")
http.Error(w, "Failed to process API token", http.StatusInternalServerError)
@ -156,14 +156,14 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
}
log.Info().Msg("Runtime config updated with new security settings - active immediately")
// Save system settings to system.json
systemSettings := config.DefaultSystemSettings()
systemSettings.ConnectionTimeout = 10 // Default seconds
systemSettings.AutoUpdateEnabled = false // Default disabled
if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")
// Continue anyway - not critical for auth setup
}
// Save system settings to system.json
systemSettings := config.DefaultSystemSettings()
systemSettings.ConnectionTimeout = 10 // Default seconds
systemSettings.AutoUpdateEnabled = false // Default disabled
if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")
// Continue anyway - not critical for auth setup
}
// Detect environment
isSystemd := os.Getenv("INVOCATION_ID") != ""
@ -428,7 +428,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
return
}
tokenRecord, err := config.NewAPITokenRecord(rawToken, "Regenerated token")
tokenRecord, err := config.NewAPITokenRecord(rawToken, "Regenerated token", nil)
if err != nil {
log.Error().Err(err).Msg("Failed to construct API token record")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)

View file

@ -2,8 +2,10 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
@ -19,6 +21,7 @@ type apiTokenDTO struct {
Suffix string `json:"suffix"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
Scopes []string `json:"scopes"`
}
func toAPITokenDTO(record config.APITokenRecord) apiTokenDTO {
@ -29,9 +32,58 @@ func toAPITokenDTO(record config.APITokenRecord) apiTokenDTO {
Suffix: record.Suffix,
CreatedAt: record.CreatedAt,
LastUsedAt: record.LastUsedAt,
Scopes: append([]string{}, record.Scopes...),
}
}
func normalizeRequestedScopes(raw *[]string) ([]string, error) {
if raw == nil {
return []string{config.ScopeWildcard}, nil
}
requested := *raw
if len(requested) == 0 {
return nil, fmt.Errorf("select at least one scope or omit the field for full access")
}
seen := make(map[string]struct{}, len(requested))
normalized := make([]string, 0, len(requested))
hasWildcard := false
for _, scope := range requested {
scope = strings.TrimSpace(scope)
if scope == "" {
return nil, fmt.Errorf("scope identifiers cannot be blank")
}
if scope == config.ScopeWildcard {
hasWildcard = true
continue
}
if !config.IsKnownScope(scope) {
return nil, fmt.Errorf("unknown scope %q", scope)
}
if _, exists := seen[scope]; exists {
continue
}
seen[scope] = struct{}{}
normalized = append(normalized, scope)
}
if hasWildcard {
if len(normalized) > 0 {
return nil, fmt.Errorf("wildcard '*' cannot be combined with other scopes")
}
return []string{config.ScopeWildcard}, nil
}
if len(normalized) == 0 {
return nil, fmt.Errorf("select at least one scope")
}
sort.Strings(normalized)
return normalized, nil
}
// handleListAPITokens returns all configured API tokens (metadata only).
func (r *Router) handleListAPITokens(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
@ -51,7 +103,8 @@ func (r *Router) handleListAPITokens(w http.ResponseWriter, req *http.Request) {
}
type createTokenRequest struct {
Name string `json:"name"`
Name string `json:"name"`
Scopes *[]string `json:"scopes"`
}
// handleCreateAPIToken generates and stores a new API token.
@ -73,6 +126,13 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request)
name = "API token"
}
scopes, err := normalizeRequestedScopes(payload.Scopes)
if err != nil {
log.Warn().Err(err).Msg("Invalid scopes provided for API token creation")
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
log.Error().Err(err).Msg("Failed to generate API token")
@ -80,7 +140,7 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request)
return
}
record, err := config.NewAPITokenRecord(rawToken, name)
record, err := config.NewAPITokenRecord(rawToken, name, scopes)
if err != nil {
log.Error().Err(err).Str("token_name", name).Msg("Failed to construct API token record")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)

View file

@ -0,0 +1,52 @@
package api
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestNormalizeRequestedScopesDefaultsToWildcard(t *testing.T) {
scopes, err := normalizeRequestedScopes(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(scopes) != 1 || scopes[0] != config.ScopeWildcard {
t.Fatalf("expected wildcard scope, got %#v", scopes)
}
}
func TestNormalizeRequestedScopesValidList(t *testing.T) {
raw := []string{"docker:report", "docker:report", "monitoring:read"}
scopes, err := normalizeRequestedScopes(&raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(scopes) != 2 {
t.Fatalf("expected 2 scopes, got %d", len(scopes))
}
if scopes[0] != config.ScopeDockerReport || scopes[1] != config.ScopeMonitoringRead {
t.Fatalf("unexpected scopes order: %#v", scopes)
}
}
func TestNormalizeRequestedScopesRejectsMixedWildcard(t *testing.T) {
raw := []string{"*", "docker:report"}
if _, err := normalizeRequestedScopes(&raw); err == nil {
t.Fatal("expected error when mixing wildcard with explicit scopes")
}
}
func TestNormalizeRequestedScopesRejectsUnknown(t *testing.T) {
raw := []string{"unknown"}
if _, err := normalizeRequestedScopes(&raw); err == nil {
t.Fatal("expected error for unknown scope")
}
}
func TestNormalizeRequestedScopesRejectsEmpty(t *testing.T) {
raw := []string{}
if _, err := normalizeRequestedScopes(&raw); err == nil {
t.Fatal("expected error for empty scopes array")
}
}

View file

@ -57,6 +57,7 @@ type StateResponse struct {
VMs []models.VM `json:"vms"`
Containers []models.Container `json:"containers"`
DockerHosts []models.DockerHostFrontend `json:"dockerHosts"`
Hosts []models.HostFrontend `json:"hosts"`
Storage []models.Storage `json:"storage"`
CephClusters []models.CephCluster `json:"cephClusters"`
PBSInstances []models.PBSInstance `json:"pbs"`

View file

@ -9,6 +9,37 @@ import (
"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"
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,
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")
@ -21,6 +52,20 @@ type APITokenRecord struct {
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.
@ -30,11 +75,12 @@ func (r *APITokenRecord) Clone() APITokenRecord {
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) (*APITokenRecord, error) {
func NewAPITokenRecord(rawToken, name string, scopes []string) (*APITokenRecord, error) {
if rawToken == "" {
return nil, ErrInvalidToken
}
@ -47,12 +93,13 @@ func NewAPITokenRecord(rawToken, name string) (*APITokenRecord, error) {
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) (*APITokenRecord, error) {
func NewHashedAPITokenRecord(hashedToken, name string, createdAt time.Time, scopes []string) (*APITokenRecord, error) {
if hashedToken == "" {
return nil, ErrInvalidToken
}
@ -67,6 +114,7 @@ func NewHashedAPITokenRecord(hashedToken, name string, createdAt time.Time) (*AP
Prefix: tokenPrefix(hashedToken),
Suffix: tokenSuffix(hashedToken),
CreatedAt: createdAt,
Scopes: normalizeScopes(scopes),
}, nil
}
@ -150,6 +198,7 @@ func (c *Config) ValidateAPIToken(rawToken string) (*APITokenRecord, bool) {
if auth.CompareAPIToken(rawToken, record.Hash) {
now := time.Now().UTC()
c.APITokens[idx].LastUsedAt = &now
c.APITokens[idx].ensureScopes()
return &c.APITokens[idx], true
}
}
@ -158,6 +207,7 @@ func (c *Config) ValidateAPIToken(rawToken string) (*APITokenRecord, bool) {
// 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
@ -182,6 +232,9 @@ func (c *Config) RemoveAPIToken(id string) bool {
// 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)
})
@ -193,3 +246,36 @@ func (c *Config) SortAPITokens() {
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
}

View file

@ -0,0 +1,58 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestAPITokenRecordHasScope(t *testing.T) {
record := APITokenRecord{Scopes: []string{ScopeMonitoringRead}}
if !record.HasScope(ScopeMonitoringRead) {
t.Fatalf("expected scope %q to be granted", ScopeMonitoringRead)
}
if record.HasScope(ScopeSettingsWrite) {
t.Fatalf("did not expect scope %q to be granted", ScopeSettingsWrite)
}
record.Scopes = nil // legacy tokens with no scopes should default to wildcard
if !record.HasScope(ScopeSettingsWrite) {
t.Fatalf("expected wildcard to grant %q", ScopeSettingsWrite)
}
if !IsKnownScope(ScopeMonitoringRead) {
t.Fatalf("expected %q to be known scope", ScopeMonitoringRead)
}
if IsKnownScope("unknown:scope") {
t.Fatalf("unexpected scope recognised")
}
}
func TestLoadAPITokensAppliesLegacyScopes(t *testing.T) {
if len(AllKnownScopes) == 0 {
t.Fatal("expected known scopes to be defined")
}
dir := t.TempDir()
persistence := NewConfigPersistence(dir)
if err := persistence.EnsureConfigDir(); err != nil {
t.Fatalf("EnsureConfigDir: %v", err)
}
payload := `[{"id":"legacy","name":"legacy","hash":"abc","createdAt":"2024-01-01T00:00:00Z"}]`
if err := os.WriteFile(filepath.Join(dir, "api_tokens.json"), []byte(payload), 0600); err != nil {
t.Fatalf("write api_tokens.json: %v", err)
}
tokens, err := persistence.LoadAPITokens()
if err != nil {
t.Fatalf("LoadAPITokens: %v", err)
}
if len(tokens) != 1 {
t.Fatalf("expected 1 token, got %d", len(tokens))
}
if len(tokens[0].Scopes) != 1 || tokens[0].Scopes[0] != ScopeWildcard {
t.Fatalf("expected legacy token to default to wildcard scope, got %#v", tokens[0].Scopes)
}
}

View file

@ -76,15 +76,15 @@ type Config struct {
// Monitoring settings
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
BackupPollingInterval time.Duration `envconfig:"BACKUP_POLLING_INTERVAL"`
EnableBackupPolling bool `envconfig:"ENABLE_BACKUP_POLLING" default:"true"`
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
BackupPollingInterval time.Duration `envconfig:"BACKUP_POLLING_INTERVAL"`
EnableBackupPolling bool `envconfig:"ENABLE_BACKUP_POLLING" default:"true"`
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
AdaptivePollingEnabled bool `envconfig:"ADAPTIVE_POLLING_ENABLED" default:"false"`
AdaptivePollingBaseInterval time.Duration `envconfig:"ADAPTIVE_POLLING_BASE_INTERVAL" default:"10s"`
AdaptivePollingMinInterval time.Duration `envconfig:"ADAPTIVE_POLLING_MIN_INTERVAL" default:"5s"`
@ -485,36 +485,36 @@ func Load() (*Config, error) {
// Initialize config with defaults
cfg := &Config{
BackendHost: "0.0.0.0",
BackendPort: 3000,
FrontendHost: "0.0.0.0",
FrontendPort: 7655,
ConfigPath: dataDir,
DataPath: dataDir,
ConcurrentPolling: true,
ConnectionTimeout: 60 * time.Second,
MetricsRetentionDays: 7,
BackupPollingCycles: 10,
BackupPollingInterval: 0,
EnableBackupPolling: true,
WebhookBatchDelay: 10 * time.Second,
BackendHost: "0.0.0.0",
BackendPort: 3000,
FrontendHost: "0.0.0.0",
FrontendPort: 7655,
ConfigPath: dataDir,
DataPath: dataDir,
ConcurrentPolling: true,
ConnectionTimeout: 60 * time.Second,
MetricsRetentionDays: 7,
BackupPollingCycles: 10,
BackupPollingInterval: 0,
EnableBackupPolling: true,
WebhookBatchDelay: 10 * time.Second,
AdaptivePollingEnabled: false,
AdaptivePollingBaseInterval: 10 * time.Second,
AdaptivePollingMinInterval: 5 * time.Second,
AdaptivePollingMaxInterval: 5 * time.Minute,
LogLevel: "info",
LogFormat: "auto",
LogMaxSize: 100,
LogMaxAge: 30,
LogCompress: true,
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
IframeEmbeddingAllow: "SAMEORIGIN",
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
DiscoveryEnabled: false,
DiscoverySubnet: "auto",
EnvOverrides: make(map[string]bool),
OIDC: NewOIDCConfig(),
LogLevel: "info",
LogFormat: "auto",
LogMaxSize: 100,
LogMaxAge: 30,
LogCompress: true,
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
IframeEmbeddingAllow: "SAMEORIGIN",
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
DiscoveryEnabled: false,
DiscoverySubnet: "auto",
EnvOverrides: make(map[string]bool),
OIDC: NewOIDCConfig(),
}
cfg.Discovery = DefaultDiscoveryConfig()
@ -548,26 +548,26 @@ func Load() (*Config, error) {
cfg.PMGPollingInterval = time.Duration(systemSettings.PMGPollingInterval) * time.Second
}
if systemSettings.BackupPollingInterval > 0 {
cfg.BackupPollingInterval = time.Duration(systemSettings.BackupPollingInterval) * time.Second
} else if systemSettings.BackupPollingInterval == 0 {
cfg.BackupPollingInterval = 0
}
if systemSettings.BackupPollingEnabled != nil {
cfg.EnableBackupPolling = *systemSettings.BackupPollingEnabled
}
if systemSettings.AdaptivePollingEnabled != nil {
cfg.AdaptivePollingEnabled = *systemSettings.AdaptivePollingEnabled
}
if systemSettings.AdaptivePollingBaseInterval > 0 {
cfg.AdaptivePollingBaseInterval = time.Duration(systemSettings.AdaptivePollingBaseInterval) * time.Second
}
if systemSettings.AdaptivePollingMinInterval > 0 {
cfg.AdaptivePollingMinInterval = time.Duration(systemSettings.AdaptivePollingMinInterval) * time.Second
}
if systemSettings.AdaptivePollingMaxInterval > 0 {
cfg.AdaptivePollingMaxInterval = time.Duration(systemSettings.AdaptivePollingMaxInterval) * time.Second
}
if systemSettings.BackupPollingInterval > 0 {
cfg.BackupPollingInterval = time.Duration(systemSettings.BackupPollingInterval) * time.Second
} else if systemSettings.BackupPollingInterval == 0 {
cfg.BackupPollingInterval = 0
}
if systemSettings.BackupPollingEnabled != nil {
cfg.EnableBackupPolling = *systemSettings.BackupPollingEnabled
}
if systemSettings.AdaptivePollingEnabled != nil {
cfg.AdaptivePollingEnabled = *systemSettings.AdaptivePollingEnabled
}
if systemSettings.AdaptivePollingBaseInterval > 0 {
cfg.AdaptivePollingBaseInterval = time.Duration(systemSettings.AdaptivePollingBaseInterval) * time.Second
}
if systemSettings.AdaptivePollingMinInterval > 0 {
cfg.AdaptivePollingMinInterval = time.Duration(systemSettings.AdaptivePollingMinInterval) * time.Second
}
if systemSettings.AdaptivePollingMaxInterval > 0 {
cfg.AdaptivePollingMaxInterval = time.Duration(systemSettings.AdaptivePollingMaxInterval) * time.Second
}
if systemSettings.UpdateChannel != "" {
cfg.UpdateChannel = systemSettings.UpdateChannel
@ -602,11 +602,11 @@ func Load() (*Config, error) {
} else {
// No system.json exists - create default one
log.Info().Msg("No system.json found, creating default")
defaultSettings := DefaultSystemSettings()
defaultSettings.ConnectionTimeout = int(cfg.ConnectionTimeout.Seconds())
if err := persistence.SaveSystemSettings(*defaultSettings); err != nil {
log.Warn().Err(err).Msg("Failed to create default system.json")
}
defaultSettings := DefaultSystemSettings()
defaultSettings.ConnectionTimeout = int(cfg.ConnectionTimeout.Seconds())
if err := persistence.SaveSystemSettings(*defaultSettings); err != nil {
log.Warn().Err(err).Msg("Failed to create default system.json")
}
}
if oidcSettings, err := persistence.LoadOIDCConfig(); err == nil && oidcSettings != nil {
@ -783,6 +783,7 @@ func Load() (*Config, error) {
Prefix: prefix,
Suffix: suffix,
CreatedAt: time.Now().UTC(),
Scopes: []string{ScopeWildcard},
}
cfg.APITokens = append(cfg.APITokens, record)
}
@ -800,7 +801,7 @@ func Load() (*Config, error) {
// Legacy migration: if a single token is present without metadata, wrap it.
if !cfg.HasAPITokens() && cfg.APIToken != "" {
if record, err := NewHashedAPITokenRecord(cfg.APIToken, "Legacy token", time.Now().UTC()); err == nil {
if record, err := NewHashedAPITokenRecord(cfg.APIToken, "Legacy token", time.Now().UTC(), nil); err == nil {
cfg.APITokens = []APITokenRecord{*record}
cfg.SortAPITokens()
log.Info().Msg("Migrated legacy API token into token record store")
@ -1121,20 +1122,20 @@ func SaveConfig(cfg *Config) error {
adaptiveEnabled := cfg.AdaptivePollingEnabled
systemSettings := SystemSettings{
// Note: PVE polling is hardcoded to 10s
UpdateChannel: cfg.UpdateChannel,
AutoUpdateEnabled: cfg.AutoUpdateEnabled,
AutoUpdateCheckInterval: int(cfg.AutoUpdateCheckInterval.Hours()),
AutoUpdateTime: cfg.AutoUpdateTime,
AllowedOrigins: cfg.AllowedOrigins,
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
LogLevel: cfg.LogLevel,
DiscoveryEnabled: cfg.DiscoveryEnabled,
DiscoverySubnet: cfg.DiscoverySubnet,
DiscoveryConfig: CloneDiscoveryConfig(cfg.Discovery),
AdaptivePollingEnabled: &adaptiveEnabled,
AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second),
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
UpdateChannel: cfg.UpdateChannel,
AutoUpdateEnabled: cfg.AutoUpdateEnabled,
AutoUpdateCheckInterval: int(cfg.AutoUpdateCheckInterval.Hours()),
AutoUpdateTime: cfg.AutoUpdateTime,
AllowedOrigins: cfg.AllowedOrigins,
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
LogLevel: cfg.LogLevel,
DiscoveryEnabled: cfg.DiscoveryEnabled,
DiscoverySubnet: cfg.DiscoverySubnet,
DiscoveryConfig: CloneDiscoveryConfig(cfg.Discovery),
AdaptivePollingEnabled: &adaptiveEnabled,
AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second),
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
// APIToken removed - now handled via .env only
}
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {

View file

@ -126,6 +126,10 @@ func (c *ConfigPersistence) LoadAPITokens() ([]APITokenRecord, error) {
return nil, err
}
for i := range tokens {
tokens[i].ensureScopes()
}
return tokens, nil
}
@ -145,7 +149,14 @@ func (c *ConfigPersistence) SaveAPITokens(tokens []APITokenRecord) error {
}
}
data, err := json.MarshalIndent(tokens, "", " ")
sanitized := make([]APITokenRecord, len(tokens))
for i := range tokens {
record := tokens[i]
record.ensureScopes()
sanitized[i] = record
}
data, err := json.MarshalIndent(sanitized, "", " ")
if err != nil {
return err
}

View file

@ -292,6 +292,7 @@ func TestExportConfigIncludesAPITokens(t *testing.T) {
Prefix: "hash-1",
Suffix: "-0001",
CreatedAt: createdAt,
Scopes: []string{config.ScopeWildcard},
},
{
ID: "token-2",
@ -300,6 +301,7 @@ func TestExportConfigIncludesAPITokens(t *testing.T) {
Prefix: "hash-2",
Suffix: "-0002",
CreatedAt: createdAt.Add(time.Hour),
Scopes: []string{config.ScopeMonitoringRead},
},
}
@ -401,6 +403,7 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
Prefix: "hashn1",
Suffix: "n1",
CreatedAt: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Scopes: []string{config.ScopeMonitoringRead, config.ScopeMonitoringWrite},
},
}
if err := source.SaveAPITokens(newTokens); err != nil {
@ -467,6 +470,7 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
Prefix: "hasho1",
Suffix: "o1",
CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
Scopes: []string{config.ScopeWildcard},
},
}
if err := target.SaveAPITokens(oldTokens); err != nil {
@ -569,6 +573,7 @@ func TestImportConfigRollbackOnFailure(t *testing.T) {
Prefix: "hashn",
Suffix: "-n",
CreatedAt: time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC),
Scopes: []string{config.ScopeDockerReport},
},
}
if err := source.SaveAPITokens(newTokens); err != nil {
@ -619,6 +624,7 @@ func TestImportConfigRollbackOnFailure(t *testing.T) {
Prefix: "hasho",
Suffix: "-o",
CreatedAt: time.Date(2023, 3, 3, 12, 0, 0, 0, time.UTC),
Scopes: []string{config.ScopeWildcard},
},
}
if err := target.SaveAPITokens(baselineTokens); err != nil {
@ -763,6 +769,7 @@ func TestImportAcceptsVersion40Bundle(t *testing.T) {
Prefix: "hashk",
Suffix: "-k",
CreatedAt: time.Date(2022, 4, 4, 12, 0, 0, 0, time.UTC),
Scopes: []string{config.ScopeWildcard},
},
}
if err := target.SaveAPITokens(baselineTokens); err != nil {

View file

@ -347,6 +347,7 @@ func (cw *ConfigWatcher) reloadConfig() {
Prefix: prefix,
Suffix: suffix,
CreatedAt: time.Now().UTC(),
Scopes: []string{ScopeWildcard},
})
}
}

474
internal/hostagent/agent.go Normal file
View file

@ -0,0 +1,474 @@
package hostagent
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net/http"
"runtime"
"sort"
"strings"
"time"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rs/zerolog"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
gohost "github.com/shirou/gopsutil/v3/host"
goload "github.com/shirou/gopsutil/v3/load"
gomem "github.com/shirou/gopsutil/v3/mem"
gonet "github.com/shirou/gopsutil/v3/net"
)
// Config controls the behaviour of the host agent.
type Config struct {
PulseURL string
APIToken string
Interval time.Duration
HostnameOverride string
AgentID string
Tags []string
InsecureSkipVerify bool
RunOnce bool
Logger *zerolog.Logger
}
// Agent is responsible for collecting host metrics and shipping them to Pulse.
type Agent struct {
cfg Config
logger zerolog.Logger
httpClient *http.Client
hostInfo *gohost.InfoStat
hostname string
displayName string
platform string
osName string
osVersion string
kernelVersion string
architecture string
machineID string
agentID string
interval time.Duration
trimmedPulseURL string
prevCPUTimes *gocpu.TimesStat
}
const defaultInterval = 30 * time.Second
// New constructs a fully initialised host Agent.
func New(cfg Config) (*Agent, error) {
if cfg.Interval <= 0 {
cfg.Interval = defaultInterval
}
if cfg.Logger == nil {
defaultLogger := zerolog.New(zerolog.NewConsoleWriter()).With().Timestamp().Logger()
cfg.Logger = &defaultLogger
}
logger := cfg.Logger.With().Str("component", "host-agent").Logger()
if strings.TrimSpace(cfg.APIToken) == "" {
return nil, fmt.Errorf("api token is required")
}
pulseURL := cfg.PulseURL
if strings.TrimSpace(pulseURL) == "" {
pulseURL = "http://localhost:7655"
}
pulseURL = strings.TrimRight(pulseURL, "/")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
info, err := gohost.InfoWithContext(ctx)
if err != nil {
return nil, fmt.Errorf("fetch host info: %w", err)
}
hostname := strings.TrimSpace(cfg.HostnameOverride)
if hostname == "" {
hostname = strings.TrimSpace(info.Hostname)
}
if hostname == "" {
hostname = "unknown-host"
}
displayName := hostname
machineID := strings.TrimSpace(info.HostID)
agentID := strings.TrimSpace(cfg.AgentID)
if agentID == "" {
agentID = machineID
}
if agentID == "" {
agentID = hostname
}
platform := normalisePlatform(info.Platform)
osName := strings.TrimSpace(info.PlatformFamily)
if osName == "" {
osName = strings.TrimSpace(info.Platform)
}
osVersion := strings.TrimSpace(info.PlatformVersion)
kernelVersion := strings.TrimSpace(info.KernelVersion)
arch := strings.TrimSpace(info.KernelArch)
if arch == "" {
arch = runtime.GOARCH
}
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
if cfg.InsecureSkipVerify {
//nolint:gosec // Insecure mode is explicitly user-controlled.
tlsConfig.InsecureSkipVerify = true
}
client := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: tlsConfig,
},
}
trimmedTags := make([]string, 0, len(cfg.Tags))
seenTags := make(map[string]struct{}, len(cfg.Tags))
for _, tag := range cfg.Tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
if _, exists := seenTags[tag]; exists {
continue
}
seenTags[tag] = struct{}{}
trimmedTags = append(trimmedTags, tag)
}
cfg.Tags = trimmedTags
return &Agent{
cfg: cfg,
logger: logger,
httpClient: client,
hostInfo: info,
hostname: hostname,
displayName: displayName,
platform: platform,
osName: osName,
osVersion: osVersion,
kernelVersion: kernelVersion,
architecture: arch,
machineID: machineID,
agentID: agentID,
interval: cfg.Interval,
trimmedPulseURL: pulseURL,
}, nil
}
// Run executes the agent until the context is cancelled.
func (a *Agent) Run(ctx context.Context) error {
if a.cfg.RunOnce {
return a.runOnce(ctx)
}
ticker := time.NewTicker(a.interval)
defer ticker.Stop()
if err := a.process(ctx); err != nil && !errors.Is(err, context.Canceled) {
a.logger.Error().Err(err).Msg("initial report failed")
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := a.process(ctx); err != nil {
if errors.Is(err, context.Canceled) {
return err
}
a.logger.Error().Err(err).Msg("failed to send report")
}
}
}
}
func (a *Agent) runOnce(ctx context.Context) error {
return a.process(ctx)
}
func (a *Agent) process(ctx context.Context) error {
report, err := a.buildReport(ctx)
if err != nil {
return fmt.Errorf("build report: %w", err)
}
if err := a.sendReport(ctx, report); err != nil {
return fmt.Errorf("send report: %w", err)
}
a.logger.Debug().
Str("hostname", report.Host.Hostname).
Str("platform", report.Host.Platform).
Msg("host report sent")
return nil
}
func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
collectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
uptime, _ := gohost.UptimeWithContext(collectCtx)
loadAvg, _ := goload.AvgWithContext(collectCtx)
cpuCount, _ := gocpu.CountsWithContext(collectCtx, true)
cpuUsage, err := a.calculateCPUUsage(collectCtx)
if err != nil {
a.logger.Debug().Err(err).Msg("failed to compute cpu usage")
}
memStats, err := gomem.VirtualMemoryWithContext(collectCtx)
if err != nil {
return agentshost.Report{}, fmt.Errorf("memory stats: %w", err)
}
disks := a.collectDisks(collectCtx)
network := a.collectNetwork(collectCtx)
var loadValues []float64
if loadAvg != nil {
loadValues = []float64{loadAvg.Load1, loadAvg.Load5, loadAvg.Load15}
}
swapUsed := int64(0)
if memStats.SwapTotal > memStats.SwapFree {
swapUsed = int64(memStats.SwapTotal - memStats.SwapFree)
}
report := agentshost.Report{
Agent: agentshost.AgentInfo{
ID: a.agentID,
Version: Version,
IntervalSeconds: int(a.interval / time.Second),
Hostname: a.hostname,
},
Host: agentshost.HostInfo{
ID: a.machineID,
Hostname: a.hostname,
DisplayName: a.displayName,
MachineID: a.machineID,
Platform: a.platform,
OSName: a.osName,
OSVersion: a.osVersion,
KernelVersion: a.kernelVersion,
Architecture: a.architecture,
CPUModel: "",
CPUCount: cpuCount,
UptimeSeconds: int64(uptime),
LoadAverage: loadValues,
},
Metrics: agentshost.Metrics{
CPUUsagePercent: cpuUsage,
Memory: agentshost.MemoryMetric{
TotalBytes: int64(memStats.Total),
UsedBytes: int64(memStats.Used),
FreeBytes: int64(memStats.Free),
Usage: memStats.UsedPercent,
SwapTotal: int64(memStats.SwapTotal),
SwapUsed: swapUsed,
},
},
Disks: disks,
Network: network,
Sensors: agentshost.Sensors{},
Tags: append([]string(nil), a.cfg.Tags...),
Timestamp: time.Now().UTC(),
}
return report, nil
}
func (a *Agent) calculateCPUUsage(ctx context.Context) (float64, error) {
times, err := gocpu.TimesWithContext(ctx, false)
if err != nil {
return 0, err
}
if len(times) == 0 {
return 0, nil
}
current := times[0]
if a.prevCPUTimes == nil {
a.prevCPUTimes = &current
return 0, nil
}
prev := a.prevCPUTimes
a.prevCPUTimes = &current
deltaTotal := current.Total() - prev.Total()
if deltaTotal <= 0 {
return 0, nil
}
deltaIdle := current.Idle - prev.Idle
if deltaIdle < 0 {
deltaIdle = 0
}
usage := (1 - (deltaIdle / deltaTotal)) * 100
if usage < 0 {
usage = 0
}
if usage > 100 {
usage = 100
}
return usage, nil
}
func (a *Agent) collectDisks(ctx context.Context) []agentshost.Disk {
partitions, err := godisk.PartitionsWithContext(ctx, true)
if err != nil {
a.logger.Debug().Err(err).Msg("failed to fetch disk partitions")
return nil
}
disks := make([]agentshost.Disk, 0, len(partitions))
seen := make(map[string]struct{}, len(partitions))
for _, part := range partitions {
if part.Mountpoint == "" {
continue
}
if _, ok := seen[part.Mountpoint]; ok {
continue
}
seen[part.Mountpoint] = struct{}{}
usage, err := godisk.UsageWithContext(ctx, part.Mountpoint)
if err != nil {
continue
}
if usage.Total == 0 {
continue
}
disks = append(disks, agentshost.Disk{
Device: part.Device,
Mountpoint: part.Mountpoint,
Filesystem: part.Fstype,
Type: part.Fstype,
TotalBytes: int64(usage.Total),
UsedBytes: int64(usage.Used),
FreeBytes: int64(usage.Free),
Usage: usage.UsedPercent,
})
}
sort.Slice(disks, func(i, j int) bool { return disks[i].Mountpoint < disks[j].Mountpoint })
return disks
}
func (a *Agent) collectNetwork(ctx context.Context) []agentshost.NetworkInterface {
ifaces, err := gonet.InterfacesWithContext(ctx)
if err != nil {
a.logger.Debug().Err(err).Msg("failed to fetch network interfaces")
return nil
}
ioCounters, err := gonet.IOCountersWithContext(ctx, true)
if err != nil {
a.logger.Debug().Err(err).Msg("failed to fetch network counters")
}
ioMap := make(map[string]gonet.IOCountersStat, len(ioCounters))
for _, stat := range ioCounters {
ioMap[stat.Name] = stat
}
interfaces := make([]agentshost.NetworkInterface, 0, len(ifaces))
for _, iface := range ifaces {
if len(iface.Addrs) == 0 {
continue
}
if isLoopback(iface.Flags) {
continue
}
addresses := make([]string, 0, len(iface.Addrs))
for _, addr := range iface.Addrs {
if addr.Addr != "" {
addresses = append(addresses, addr.Addr)
}
}
if len(addresses) == 0 {
continue
}
counter := ioMap[iface.Name]
ifaceEntry := agentshost.NetworkInterface{
Name: iface.Name,
MAC: iface.HardwareAddr,
Addresses: addresses,
RXBytes: counter.BytesRecv,
TXBytes: counter.BytesSent,
}
interfaces = append(interfaces, ifaceEntry)
}
sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name })
return interfaces
}
func (a *Agent) sendReport(ctx context.Context, report agentshost.Report) error {
payload, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("marshal report: %w", err)
}
url := fmt.Sprintf("%s/api/agents/host/report", a.trimmedPulseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.cfg.APIToken)
req.Header.Set("X-API-Token", a.cfg.APIToken)
req.Header.Set("User-Agent", "pulse-host-agent/"+Version)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("pulse responded with status %s", resp.Status)
}
return nil
}
func normalisePlatform(platform string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
switch platform {
case "darwin":
return "macos"
default:
return platform
}
}
func isLoopback(flags []string) bool {
for _, flag := range flags {
if strings.EqualFold(flag, "loopback") {
return true
}
}
return false
}

View file

@ -0,0 +1,5 @@
package hostagent
// Version is the semantic version of the Pulse host agent binary. It can be
// overridden at build time via -ldflags when producing release artefacts.
var Version = "0.1.0-dev"

View file

@ -18,12 +18,16 @@ type MockConfig struct {
LXCsPerNode int
DockerHostCount int
DockerContainersPerHost int
GenericHostCount int
RandomMetrics bool
HighLoadNodes []string // Specific nodes to simulate high load
StoppedPercent float64 // Percentage of guests that should be stopped
}
const dockerConnectionPrefix = "docker-"
const (
dockerConnectionPrefix = "docker-"
hostConnectionPrefix = "host-"
)
var DefaultConfig = MockConfig{
NodeCount: 7, // Test the 5-9 node range by default
@ -31,6 +35,7 @@ var DefaultConfig = MockConfig{
LXCsPerNode: 8,
DockerHostCount: 3,
DockerContainersPerHost: 12,
GenericHostCount: 4,
RandomMetrics: true,
StoppedPercent: 0.2,
}
@ -80,6 +85,35 @@ var dockerAgentVersions = []string{
"0.1.0-dev",
}
var genericHostProfiles = []struct {
Platform string
OSName string
OSVersion string
Kernel string
Architecture string
}{
{"linux", "Debian GNU/Linux", "12 (bookworm)", "6.8.12-1-amd64", "x86_64"},
{"linux", "Ubuntu Server", "24.04 LTS", "6.8.0-31-generic", "x86_64"},
{"linux", "Rocky Linux", "9.3", "5.14.0-427.22.1.el9_4.x86_64", "x86_64"},
{"linux", "Alpine Linux", "3.20.1", "6.6.32-0-lts", "x86_64"},
{"windows", "Windows Server", "2022 Datacenter", "10.0.20348.2244", "x86_64"},
{"windows", "Windows 11 Pro", "23H2", "10.0.22631.3737", "x86_64"},
{"macos", "macOS Ventura", "13.6.8", "22.6.0", "arm64"},
{"macos", "macOS Sonoma", "14.6.1", "23G93", "arm64"},
}
var genericHostPrefixes = []string{
"apollo", "centauri", "ceres", "europa", "hyperion",
"kepler", "meridian", "orion", "polaris", "spectrum",
"vega", "zenith", "halcyon", "icarus", "rigel",
}
var hostAgentVersions = []string{
"0.1.0",
"0.1.1",
"0.2.0-alpha",
}
// Common tags used for VMs and containers
var commonTags = []string{
"production", "staging", "development", "testing",
@ -170,6 +204,7 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
data := models.StateSnapshot{
Nodes: generateNodes(config),
DockerHosts: generateDockerHosts(config),
Hosts: generateHosts(config),
VMs: []models.VM{},
Containers: []models.Container{},
PhysicalDisks: []models.PhysicalDisk{},
@ -189,6 +224,10 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = host.Status != "offline"
}
for _, host := range data.Hosts {
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
}
// Generate VMs and containers for each node
vmidCounter := 100
for nodeIdx, node := range data.Nodes {
@ -1066,6 +1105,192 @@ func generateDockerHosts(config MockConfig) []models.DockerHost {
return hosts
}
func generateHosts(config MockConfig) []models.Host {
count := config.GenericHostCount
if count <= 0 {
return nil
}
now := time.Now()
hosts := make([]models.Host, 0, count)
usedNames := make(map[string]struct{}, count)
for i := 0; i < count; i++ {
profile := genericHostProfiles[rand.Intn(len(genericHostProfiles))]
baseName := genericHostPrefixes[rand.Intn(len(genericHostPrefixes))]
suffix := 1 + rand.Intn(900)
hostname := fmt.Sprintf("%s-%d", baseName, suffix)
for {
if _, exists := usedNames[hostname]; !exists {
usedNames[hostname] = struct{}{}
break
}
suffix++
hostname = fmt.Sprintf("%s-%d", baseName, suffix)
}
displayName := strings.ToUpper(hostname[:1]) + hostname[1:]
cpuCount := 4 + rand.Intn(28) // 4-32 cores
if profile.Platform == "macos" {
cpuCount = 8 + rand.Intn(10)
}
cpuUsage := clampFloat(10+rand.Float64()*55, 4, 94)
memTotalGiB := 16 + rand.Intn(192)
if profile.Platform == "macos" {
memTotalGiB = 16 + rand.Intn(64)
}
memTotal := int64(memTotalGiB) << 30
memUsage := clampFloat(30+rand.Float64()*50, 12, 96)
memUsed := int64(float64(memTotal) * (memUsage / 100.0))
memFree := memTotal - memUsed
swapTotal := int64(rand.Intn(32)) << 30
swapUsed := int64(float64(swapTotal) * rand.Float64())
rootDiskTotal := int64(120+rand.Intn(680)) << 30
rootDiskUsage := clampFloat(25+rand.Float64()*55, 8, 95)
rootDiskUsed := int64(float64(rootDiskTotal) * (rootDiskUsage / 100.0))
rootDisk := models.Disk{
Total: rootDiskTotal,
Used: rootDiskUsed,
Free: rootDiskTotal - rootDiskUsed,
Usage: rootDiskUsage,
Mountpoint: "/",
Type: "ext4",
Device: "/dev/sda1",
}
if profile.Platform == "windows" {
rootDisk.Mountpoint = "C:"
rootDisk.Type = "ntfs"
rootDisk.Device = `\\.\PHYSICALDRIVE0`
}
if profile.Platform == "macos" {
rootDisk.Type = "apfs"
rootDisk.Device = "/dev/disk1s1"
}
disks := []models.Disk{rootDisk}
if rand.Float64() < 0.45 {
dataDiskTotal := int64(200+rand.Intn(1400)) << 30
dataDiskUsage := clampFloat(35+rand.Float64()*45, 6, 97)
dataDiskUsed := int64(float64(dataDiskTotal) * (dataDiskUsage / 100.0))
mount := "/data"
device := "/dev/sdb1"
fsType := "xfs"
if profile.Platform == "windows" {
mount = "D:"
device = `\\.\PHYSICALDRIVE1`
fsType = "ntfs"
}
if profile.Platform == "macos" {
mount = "/Volumes/Data"
device = "/dev/disk3s1"
fsType = "apfs"
}
disks = append(disks, models.Disk{
Total: dataDiskTotal,
Used: dataDiskUsed,
Free: dataDiskTotal - dataDiskUsed,
Usage: dataDiskUsage,
Mountpoint: mount,
Type: fsType,
Device: device,
})
}
primaryIP := fmt.Sprintf("192.168.%d.%d", 10+rand.Intn(60), 10+rand.Intn(200))
network := []models.HostNetworkInterface{
{
Name: "eth0",
MAC: fmt.Sprintf("02:42:%02x:%02x:%02x:%02x", rand.Intn(256), rand.Intn(256), rand.Intn(256), rand.Intn(256)),
Addresses: []string{primaryIP},
RXBytes: uint64(256+rand.Intn(4096)) * 1024 * 1024,
TXBytes: uint64(256+rand.Intn(4096)) * 1024 * 1024,
},
}
if rand.Float64() < 0.32 {
network[0].Addresses = append(network[0].Addresses, fmt.Sprintf("10.%d.%d.%d", 10+rand.Intn(90), rand.Intn(200), rand.Intn(200)))
}
var loadAverage []float64
if profile.Platform == "linux" {
loadAverage = []float64{
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
}
}
sensors := models.HostSensorSummary{}
if profile.Platform == "linux" || profile.Platform == "macos" {
sensors.TemperatureCelsius = map[string]float64{
"cpu.package": clampFloat(38+rand.Float64()*22, 30, 85),
}
if rand.Float64() < 0.4 {
sensors.Additional = map[string]float64{
"nvme0": clampFloat(40+rand.Float64()*20, 30, 90),
}
}
}
status := "online"
if rand.Float64() < 0.1 {
status = "offline"
} else if rand.Float64() < 0.12 {
status = "degraded"
}
lastSeen := now.Add(-time.Duration(rand.Intn(60)) * time.Second)
if status == "offline" {
lastSeen = now.Add(-time.Duration(300+rand.Intn(2400)) * time.Second)
}
uptimeSeconds := int64(3600*(12+rand.Intn(720))) + int64(rand.Intn(3600))
intervalSeconds := 30 + rand.Intn(45)
tags := make([]string, 0, 2)
for _, candidate := range []string{"production", "lab", "edge", "backup", "database", "web"} {
if rand.Float64() < 0.18 {
tags = append(tags, candidate)
}
}
host := models.Host{
ID: fmt.Sprintf("host-%s-%d", profile.Platform, i+1),
Hostname: hostname,
DisplayName: displayName,
Platform: profile.Platform,
OSName: profile.OSName,
OSVersion: profile.OSVersion,
KernelVersion: profile.Kernel,
Architecture: profile.Architecture,
CPUCount: cpuCount,
CPUUsage: cpuUsage,
LoadAverage: loadAverage,
Memory: models.Memory{Total: memTotal, Used: memUsed, Free: memFree, Usage: memUsage, SwapTotal: swapTotal, SwapUsed: swapUsed},
Disks: disks,
NetworkInterfaces: network,
Sensors: sensors,
Status: status,
UptimeSeconds: uptimeSeconds,
IntervalSeconds: intervalSeconds,
LastSeen: lastSeen,
AgentVersion: hostAgentVersions[rand.Intn(len(hostAgentVersions))],
Tags: tags,
}
hosts = append(hosts, host)
}
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Hostname < hosts[j].Hostname
})
return hosts
}
func generateDockerContainers(hostName string, hostIdx int, config MockConfig) []models.DockerContainer {
base := config.DockerContainersPerHost
if base < 1 {
@ -2669,6 +2894,7 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
// UpdateMetrics simulates changing metrics over time
func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
updateDockerHosts(data, config)
updateHosts(data, config)
if !config.RandomMetrics {
return
@ -3018,6 +3244,70 @@ func updateDockerHosts(data *models.StateSnapshot, config MockConfig) {
}
}
func updateHosts(data *models.StateSnapshot, config MockConfig) {
if len(data.Hosts) == 0 {
return
}
now := time.Now()
step := int64(updateInterval.Seconds())
if step <= 0 {
step = 2
}
for i := range data.Hosts {
host := &data.Hosts[i]
if data.ConnectionHealth != nil {
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
}
if host.Status == "offline" {
if config.RandomMetrics && rand.Float64() < 0.02 {
host.Status = "online"
host.LastSeen = now
host.UptimeSeconds = int64(120 + rand.Intn(3600))
}
continue
}
host.LastSeen = now.Add(-time.Duration(rand.Intn(25)) * time.Second)
host.UptimeSeconds += step
if !config.RandomMetrics {
continue
}
host.CPUUsage = clampFloat(host.CPUUsage+(rand.Float64()-0.5)*5, 4, 97)
memUsage := clampFloat(host.Memory.Usage+(rand.Float64()-0.5)*3, 12, 96)
host.Memory.Usage = memUsage
host.Memory.Used = int64(float64(host.Memory.Total) * (memUsage / 100.0))
host.Memory.Free = host.Memory.Total - host.Memory.Used
for j := range host.Disks {
change := (rand.Float64() - 0.5) * 1.2
host.Disks[j].Usage = clampFloat(host.Disks[j].Usage+change, 5, 98)
host.Disks[j].Used = int64(float64(host.Disks[j].Total) * (host.Disks[j].Usage / 100.0))
host.Disks[j].Free = host.Disks[j].Total - host.Disks[j].Used
}
if len(host.LoadAverage) == 3 {
for j := range host.LoadAverage {
host.LoadAverage[j] = clampFloat(host.LoadAverage[j]+(rand.Float64()-0.5)*0.4, 0.05, float64(host.CPUCount))
}
}
if host.Status == "degraded" {
if rand.Float64() < 0.25 {
host.Status = "online"
}
} else if rand.Float64() < 0.05 {
host.Status = "degraded"
}
}
}
func fluctuateFloat(value, variance, min, max float64) float64 {
change := (rand.Float64()*2 - 1) * variance
newValue := value * (1 + change)

View file

@ -8,6 +8,7 @@ type StateSnapshot struct {
VMs []VM `json:"vms"`
Containers []Container `json:"containers"`
DockerHosts []DockerHost `json:"dockerHosts"`
Hosts []Host `json:"hosts"`
Storage []Storage `json:"storage"`
CephClusters []CephCluster `json:"cephClusters"`
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
@ -46,6 +47,7 @@ func (s *State) GetSnapshot() StateSnapshot {
VMs: append([]VM{}, s.VMs...),
Containers: append([]Container{}, s.Containers...),
DockerHosts: append([]DockerHost{}, s.DockerHosts...),
Hosts: append([]Host{}, s.Hosts...),
Storage: append([]Storage{}, s.Storage...),
CephClusters: append([]CephCluster{}, s.CephClusters...),
PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...),
@ -102,6 +104,11 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
dockerHosts[i] = host.ToFrontend()
}
hosts := make([]HostFrontend, len(s.Hosts))
for i, host := range s.Hosts {
hosts[i] = host.ToFrontend()
}
// Convert storage
storage := make([]StorageFrontend, len(s.Storage))
for i, st := range s.Storage {
@ -124,6 +131,7 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
VMs: vms,
Containers: containers,
DockerHosts: dockerHosts,
Hosts: hosts,
Storage: storage,
CephClusters: cephClusters,
PhysicalDisks: s.PhysicalDisks,

86
pkg/agents/host/report.go Normal file
View file

@ -0,0 +1,86 @@
package host
import "time"
// Report represents the payload sent by the pulse-host-agent.
type Report struct {
Agent AgentInfo `json:"agent"`
Host HostInfo `json:"host"`
Metrics Metrics `json:"metrics"`
Disks []Disk `json:"disks,omitempty"`
Network []NetworkInterface `json:"network,omitempty"`
Sensors Sensors `json:"sensors,omitempty"`
Tags []string `json:"tags,omitempty"`
Timestamp time.Time `json:"timestamp"`
SequenceID string `json:"sequenceId,omitempty"`
}
// AgentInfo describes the reporting agent.
type AgentInfo struct {
ID string `json:"id"`
Version string `json:"version,omitempty"`
IntervalSeconds int `json:"intervalSeconds,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
// HostInfo contains platform and identification details about the monitored host.
type HostInfo struct {
ID string `json:"id,omitempty"`
Hostname string `json:"hostname"`
DisplayName string `json:"displayName,omitempty"`
MachineID string `json:"machineId,omitempty"`
Platform string `json:"platform,omitempty"`
OSName string `json:"osName,omitempty"`
OSVersion string `json:"osVersion,omitempty"`
KernelVersion string `json:"kernelVersion,omitempty"`
Architecture string `json:"architecture,omitempty"`
CPUModel string `json:"cpuModel,omitempty"`
CPUCount int `json:"cpuCount,omitempty"`
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
LoadAverage []float64 `json:"loadAverage,omitempty"`
}
// Metrics encapsulates primary resource metrics for a host.
type Metrics struct {
CPUUsagePercent float64 `json:"cpuUsagePercent,omitempty"`
Memory MemoryMetric `json:"memory,omitempty"`
}
// MemoryMetric captures memory usage statistics in bytes.
type MemoryMetric struct {
TotalBytes int64 `json:"totalBytes,omitempty"`
UsedBytes int64 `json:"usedBytes,omitempty"`
FreeBytes int64 `json:"freeBytes,omitempty"`
Usage float64 `json:"usage,omitempty"`
SwapTotal int64 `json:"swapTotalBytes,omitempty"`
SwapUsed int64 `json:"swapUsedBytes,omitempty"`
}
// Disk represents disk utilisation metrics.
type Disk struct {
Device string `json:"device,omitempty"`
Mountpoint string `json:"mountpoint,omitempty"`
Filesystem string `json:"filesystem,omitempty"`
Type string `json:"type,omitempty"`
TotalBytes int64 `json:"totalBytes,omitempty"`
UsedBytes int64 `json:"usedBytes,omitempty"`
FreeBytes int64 `json:"freeBytes,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
// NetworkInterface summarises network adapter statistics.
type NetworkInterface struct {
Name string `json:"name"`
MAC string `json:"mac,omitempty"`
Addresses []string `json:"addresses,omitempty"`
RXBytes uint64 `json:"rxBytes,omitempty"`
TXBytes uint64 `json:"txBytes,omitempty"`
SpeedMbps *int64 `json:"speedMbps,omitempty"`
}
// Sensors captures optional sensor readings reported by the agent.
type Sensors struct {
TemperatureCelsius map[string]float64 `json:"temperatureCelsius,omitempty"`
FanRPM map[string]float64 `json:"fanRpm,omitempty"`
Additional map[string]float64 `json:"additional,omitempty"`
}

View file

@ -66,6 +66,13 @@ for build_name in "${!builds[@]}"; do
-o "$BUILD_DIR/pulse-docker-agent-$build_name" \
./cmd/pulse-docker-agent
# Build host agent binary
env $build_env go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \
-trimpath \
-o "$BUILD_DIR/pulse-host-agent-$build_name" \
./cmd/pulse-host-agent
# Build temperature proxy binary
env $build_env go build \
-ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit}" \
@ -85,6 +92,7 @@ for build_name in "${!builds[@]}"; do
# Copy binaries and VERSION file
cp "$BUILD_DIR/pulse-$build_name" "$staging_dir/bin/pulse"
cp "$BUILD_DIR/pulse-docker-agent-$build_name" "$staging_dir/bin/pulse-docker-agent"
cp "$BUILD_DIR/pulse-host-agent-$build_name" "$staging_dir/bin/pulse-host-agent"
cp "$BUILD_DIR/pulse-sensor-proxy-$build_name" "$staging_dir/bin/pulse-sensor-proxy"
cp "scripts/install-docker-agent.sh" "$staging_dir/scripts/install-docker-agent.sh"
chmod 755 "$staging_dir/scripts/install-docker-agent.sh"
@ -112,6 +120,7 @@ mkdir -p "$universal_dir/scripts"
for build_name in "${!builds[@]}"; do
cp "$BUILD_DIR/pulse-$build_name" "$universal_dir/bin/pulse-${build_name}"
cp "$BUILD_DIR/pulse-docker-agent-$build_name" "$universal_dir/bin/pulse-docker-agent-${build_name}"
cp "$BUILD_DIR/pulse-host-agent-$build_name" "$universal_dir/bin/pulse-host-agent-${build_name}"
cp "$BUILD_DIR/pulse-sensor-proxy-$build_name" "$universal_dir/bin/pulse-sensor-proxy-${build_name}"
done
@ -188,9 +197,43 @@ esac
EOF
chmod +x "$universal_dir/bin/pulse-sensor-proxy"
cat > "$universal_dir/bin/pulse-host-agent" << 'EOF'
#!/bin/sh
# Auto-detect architecture and run appropriate pulse-host-agent binary
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64)
exec "$(dirname "$0")/pulse-host-agent-linux-amd64" "$@"
;;
aarch64|arm64)
exec "$(dirname "$0")/pulse-host-agent-linux-arm64" "$@"
;;
armv7l|armhf)
exec "$(dirname "$0")/pulse-host-agent-linux-armv7" "$@"
;;
*)
echo "Unsupported architecture: $ARCH" >&2
exit 1
;;
esac
EOF
chmod +x "$universal_dir/bin/pulse-host-agent"
# Add VERSION file
echo "$VERSION" > "$universal_dir/VERSION"
# Build host agent for macOS arm64
echo "Building host agent for macOS arm64..."
env GOOS=darwin GOARCH=arm64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \
-trimpath \
-o "$BUILD_DIR/pulse-host-agent-darwin-arm64" \
./cmd/pulse-host-agent
# Package macOS host agent
tar -czf "$RELEASE_DIR/pulse-host-agent-v${VERSION}-darwin-arm64.tar.gz" -C "$BUILD_DIR" pulse-host-agent-darwin-arm64
# Create universal tarball
cd "$universal_dir"
tar -czf "../../$RELEASE_DIR/pulse-v${VERSION}.tar.gz" .