diff --git a/docs/DOCKER_MONITORING.md b/docs/DOCKER_MONITORING.md index f41f289..00fccd2 100644 --- a/docs/DOCKER_MONITORING.md +++ b/docs/DOCKER_MONITORING.md @@ -36,13 +36,15 @@ Copy the binary to your Docker host (e.g. `/usr/local/bin/pulse-docker-agent`) a ### Quick install from your Pulse server -Use the bundled installation script (ships with Pulse v4.22.0+) to deploy and manage the agent. Replace the token placeholder with an API token generated in **Settings → Security**. +Use the bundled installation script (ships with Pulse v4.22.0+) to deploy and manage the agent. Replace the token placeholder with an API token generated in **Settings → Security**. Create a dedicated token for each Docker host so you can revoke individual credentials without touching others—sharing one token across many hosts makes incident response much harder. ```bash curl -fsSL http://pulse.example.com/install-docker-agent.sh \ | sudo bash -s -- --url http://pulse.example.com --token ``` +> **Why sudo?** The installer needs to drop binaries under `/usr/local/bin`, create a systemd service, and start it—actions that require root privileges. Piping to `sudo bash …` saves you from retrying if you run the command as an unprivileged user. + Running the one-liner again from another Pulse server (with its own URL/token) will merge that server into the same agent automatically—no extra flags required. To report to more than one Pulse instance from the same Docker host, repeat the `--target` flag (format: `https://pulse.example.com|`) or export `PULSE_TARGETS` before running the script: diff --git a/docs/development/HOME_ASSISTANT_NOTES.md b/docs/development/HOME_ASSISTANT_NOTES.md new file mode 100644 index 0000000..06c35c1 --- /dev/null +++ b/docs/development/HOME_ASSISTANT_NOTES.md @@ -0,0 +1,15 @@ +# Home Assistant Battery Automation Notes (2025-10-10) + +- Work performed on delly host, LXC VMID 101 (Home Assistant). Pulse codebase unaffected. +- Root cause: malformed `automations.yaml` caused five restarts between 00:23–00:33 BST. +- Fixes applied: + - Restored automation backups. + - Replaced `pyscript.solis_set_charge_current` calls with `number.set_value` targeting `number.solis_rhi_time_charging_charge_current`. + - Removed invalid `source:` attributes from `number.set_value` actions. +- Validation: + - Triggered key automations (`automation.free_electricity_session_maximum_charging`, `automation.intelligent_dispatch_solis_battery_charging`, `automation.emergency_low_soc_protection`, EV guard hold/refresh/release). + - Observed expected charge-current adjustments (100 A car slots/free session, 25 A emergency, 5 A guard) and matching system logs. +- Defaults restored: overnight slot `23:30–05:30`. +- Backups: `/var/lib/docker/volumes/hass_config/_data/automations.yaml.codex_source_cleanup_20251010_090205` (do not delete). +- Testing helpers: API token stored at `/tmp/ha_token.txt` inside VM; use `pct exec 101 -- bash -lc '...'` for commands. +- Final state: charge current reset to 25 A; EV guard sensor `off`. diff --git a/docs/development/MOCK_MODE.md b/docs/development/MOCK_MODE.md index cdfce23..8de7b2d 100644 --- a/docs/development/MOCK_MODE.md +++ b/docs/development/MOCK_MODE.md @@ -72,10 +72,13 @@ The toggle script exports `PULSE_DATA_DIR` before launching the backend, creates ## Hot-Dev Workflow -1. **Start hot-dev mode:** +Hot reload now runs as a long-lived systemd service so it is always ready when you log in. + +1. **Check the hot-dev service:** ```bash - scripts/hot-dev.sh + systemctl status pulse-hot-dev ``` + The service is enabled by default; use `sudo systemctl restart pulse-hot-dev` if you need a clean rebuild. 2. **Toggle mock mode as needed:** ```bash @@ -93,6 +96,20 @@ The toggle script exports `PULSE_DATA_DIR` before launching the backend, creates **No port changes. No manual restarts. Everything just works!** +> **Note:** The legacy `pulse-backend.service` is intentionally disabled on this dev box. All backend/API traffic comes from the hot-dev service, so you never need to run the production binary locally. + +### Default credentials + +Authentication is enabled for the dev stack so security-focused features behave exactly like production. Use the shared credentials below when the UI prompts for a login: + +``` +Username: dev +Password: dev +``` + +You can change them at any time by editing `.env` at the repo root (the backend watcher loads it automatically on restart). + + ## Mock Data Generation Mock mode generates: @@ -186,7 +203,7 @@ When `mock.env` changes: ### Backend not reloading -1. Ensure hot-dev mode is running (not systemd service) +1. Ensure the `pulse-hot-dev` systemd service is active 2. Check for errors in backend logs 3. Verify file watcher started successfully 4. Fall back to manual restart if needed diff --git a/docs/development/PIHOLE_SYNC.md b/docs/development/PIHOLE_SYNC.md new file mode 100644 index 0000000..7f97107 --- /dev/null +++ b/docs/development/PIHOLE_SYNC.md @@ -0,0 +1,11 @@ +# Pi-hole Nebula Sync Notes + +- Primary Pi-hole: delly CT 114 (192.168.0.102) + Secondary: minipc CT 202 (192.168.0.101) + Virtual IP: 192.168.0.100 +- Runs on the delly host (not inside containers). +- Binary: `/usr/local/bin/nebula-sync`; wrapper script: `/usr/local/bin/pihole-sync.sh`. +- Cron job (`root@delly`): `*/30 * * * *` → logs written to `/var/log/nebula-sync.log`. +- Credentials: `/root/.pihole-sync-credentials` on delly (chmod 600). Request the password from the user if needed. +- Both Pi-holes require `app_sudo = true` inside `/etc/pihole/pihole.toml`. +- When debugging, coordinate with the user before touching production Pi-hole instances. diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 0232d27..65d5675 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -858,15 +858,17 @@ function AppLayout(props: { diff --git a/frontend-modern/src/api/security.ts b/frontend-modern/src/api/security.ts new file mode 100644 index 0000000..1e4b405 --- /dev/null +++ b/frontend-modern/src/api/security.ts @@ -0,0 +1,35 @@ +import { apiFetchJSON } from '@/utils/apiClient'; + +export interface APITokenRecord { + id: string; + name: string; + prefix: string; + suffix: string; + createdAt: string; + lastUsedAt?: string; +} + +export interface CreateAPITokenResponse { + token: string; + record: APITokenRecord; +} + +export class SecurityAPI { + static async listTokens(): Promise { + const response = await apiFetchJSON<{ tokens: APITokenRecord[] }>('/api/security/tokens'); + return response.tokens ?? []; + } + + static async createToken(name?: string): Promise { + return apiFetchJSON('/api/security/tokens', { + method: 'POST', + body: JSON.stringify({ name }), + }); + } + + static async deleteToken(id: string): Promise { + await apiFetchJSON(`/api/security/tokens/${id}`, { + method: 'DELETE', + }); + } +} diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx new file mode 100644 index 0000000..308c2da --- /dev/null +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -0,0 +1,247 @@ +import { Component, For, Show, createSignal, 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 { copyToClipboard } from '@/utils/clipboard'; +import { formatRelativeTime } from '@/utils/format'; + +interface APITokenManagerProps { + currentTokenHint?: string; +} + +export const APITokenManager: Component = (props) => { + const [tokens, setTokens] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [isGenerating, setIsGenerating] = createSignal(false); + const [newTokenValue, setNewTokenValue] = createSignal(null); + const [newTokenRecord, setNewTokenRecord] = createSignal(null); + const [copied, setCopied] = createSignal(false); + const [nameInput, setNameInput] = createSignal(''); + + const loadTokens = async () => { + setLoading(true); + try { + const list = await SecurityAPI.listTokens(); + setTokens(list); + } catch (err) { + console.error('Failed to load API tokens', err); + showError('Failed to load API tokens'); + } finally { + setLoading(false); + } + }; + + onMount(() => { + void loadTokens(); + }); + + const handleGenerate = async () => { + setIsGenerating(true); + setCopied(false); + try { + const trimmedName = nameInput().trim() || undefined; + const { token, record } = await SecurityAPI.createToken(trimmedName); + setTokens((prev) => [record, ...prev]); + setNewTokenValue(token); + setNewTokenRecord(record); + setNameInput(''); + showSuccess('New API token generated! Save it now – it will not be shown again.'); + + try { + window.localStorage.setItem('apiToken', token); + // Fire a storage event so other listeners update immediately + window.dispatchEvent( + new StorageEvent('storage', { key: 'apiToken', newValue: token }), + ); + } 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'); + } finally { + setIsGenerating(false); + } + }; + + const handleCopy = async () => { + const value = newTokenValue(); + if (!value) return; + + const success = await copyToClipboard(value); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } else { + showError('Failed to copy to clipboard'); + } + }; + + const handleDelete = async (record: APITokenRecord) => { + const confirmed = window.confirm( + `Revoke token "${record.name}"? Any agents or integrations using it will stop working.`, + ); + if (!confirmed) return; + + try { + await SecurityAPI.deleteToken(record.id); + setTokens((prev) => prev.filter((token) => token.id !== record.id)); + showSuccess('Token revoked'); + + const current = newTokenRecord(); + if (current && current.id === record.id) { + setNewTokenValue(null); + setNewTokenRecord(null); + } + } catch (err) { + console.error('Failed to revoke API token', err); + showError('Failed to revoke API token'); + } + }; + + const tokenHint = (record: APITokenRecord) => { + if (record.prefix && record.suffix) { + return `${record.prefix}…${record.suffix}`; + } + if (record.prefix) { + return `${record.prefix}…`; + } + return '—'; + }; + + return ( + +
+
+
+ + + +
+ +
+
+ +
+
+ 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. +
+ +
+ + setNameInput(event.currentTarget.value)} + class="w-full 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" + /> + +
+ + +

+ Current token hint: {props.currentTokenHint} +

+
+ + +
+
+

✅ New API token generated

+

+ Save this value now – it is only shown once. Update your automation or agents immediately. +

+
+ +
+
+ + {newTokenValue()} + + +
+
+
+
+ +
+

Active tokens

+ 0} + fallback={ +

+ No API tokens yet. Generate one above to get started. +

+ } + > +
+ + + + + + + + + + + + + {(token) => ( + + + + + + + + )} + + +
LabelToken hintCreatedLast usedActions
{token.name || 'Untitled token'}{tokenHint(token)} + {formatRelativeTime(token.createdAt)} + + {token.lastUsedAt ? formatRelativeTime(token.lastUsedAt) : 'Never'} + + +
+
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index 127e372..e9a54f1 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, Show, For, onCleanup, onMount } from 'solid-js'; +import { Component, createSignal, Show, For, onCleanup, onMount, createEffect } from 'solid-js'; import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; @@ -7,16 +7,32 @@ import { MonitoringAPI } from '@/api/monitoring'; import { notificationStore } from '@/stores/notifications'; import type { SecurityStatus } from '@/types/config'; import { CommandBuilder } from './CommandBuilder'; +import { SecurityAPI, type APITokenRecord } from '@/api/security'; export const DockerAgents: Component = () => { const { state } = useWebSocket(); - const [showInstructions, setShowInstructions] = createSignal(false); + const [showInstructions, setShowInstructions] = createSignal(true); const dockerHosts = () => state.dockerHosts || []; const [removingHostId, setRemovingHostId] = createSignal(null); const [apiToken, setApiToken] = createSignal(null); const [securityStatus, setSecurityStatus] = createSignal(null); + const [availableTokens, setAvailableTokens] = createSignal([]); + const [loadingTokens, setLoadingTokens] = createSignal(false); + const [tokensLoaded, setTokensLoaded] = createSignal(false); + const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); + const [newTokenValue, setNewTokenValue] = createSignal(null); + const [newTokenRecord, setNewTokenRecord] = createSignal(null); + const [copiedGeneratedToken, setCopiedGeneratedToken] = createSignal(false); + + const tokenDisplayLabel = (token: APITokenRecord) => { + if (token.name) return token.name; + if (token.prefix && token.suffix) return `${token.prefix}…${token.suffix}`; + if (token.prefix) return `${token.prefix}…`; + if (token.suffix) return `…${token.suffix}`; + return 'Untitled token'; + }; const pulseUrl = () => { if (typeof window !== 'undefined') { @@ -64,6 +80,103 @@ export const DockerAgents: Component = () => { fetchSecurityStatus(); }); + const loadTokens = async () => { + if (tokensLoaded() || loadingTokens()) return; + setLoadingTokens(true); + try { + const tokens = await SecurityAPI.listTokens(); + const sorted = [...tokens].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + setAvailableTokens(sorted); + } catch (err) { + console.error('Failed to load API tokens', err); + notificationStore.error('Failed to load API tokens', 6000); + } finally { + setTokensLoaded(true); + setLoadingTokens(false); + } + }; + + createEffect(() => { + if (showInstructions()) { + loadTokens(); + } + }); + + const copyToClipboard = async (text: string): Promise => { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + + if (typeof document === 'undefined') { + return false; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-999999px'; + textarea.style.top = '-999999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + return document.execCommand('copy'); + } finally { + document.body.removeChild(textarea); + } + } catch (err) { + console.error('Failed to copy to clipboard', err); + return false; + } + }; + + const handleCreateToken = async () => { + if (isGeneratingToken()) return; + setIsGeneratingToken(true); + try { + const defaultName = `Docker host ${availableTokens().length + 1}`; + const { token, record } = await SecurityAPI.createToken(defaultName); + setAvailableTokens((prev) => [record, ...prev]); + setNewTokenValue(token); + setNewTokenRecord(record); + setApiToken(token); + setCopiedGeneratedToken(false); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem('apiToken', token); + window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: token })); + } catch (err) { + console.warn('Unable to persist API token in localStorage', err); + } + } + notificationStore.success('New API token generated. Copy it into the install command immediately.', 6000); + } catch (err) { + console.error('Failed to generate API token', err); + notificationStore.error('Failed to generate API token', 6000); + } finally { + setIsGeneratingToken(false); + } + }; + + const handleCopyGeneratedToken = async () => { + const value = newTokenValue(); + if (!value) return; + + const success = await copyToClipboard(value); + if (success) { + setCopiedGeneratedToken(true); + setTimeout(() => setCopiedGeneratedToken(false), 2000); + if (typeof window !== 'undefined' && window.showToast) { + window.showToast('success', 'Copied to clipboard'); + } + } else if (typeof window !== 'undefined' && window.showToast) { + window.showToast('error', 'Failed to copy to clipboard'); + } + }; + const requiresToken = () => { const status = securityStatus(); if (status) { @@ -76,14 +189,14 @@ export const DockerAgents: Component = () => { const getInstallCommandTemplate = () => { const url = pulseUrl(); if (!requiresToken()) { - return `curl -fsSL ${url}/install-docker-agent.sh | bash -s -- --url ${url} --token disabled`; + return `curl -fsSL ${url}/install-docker-agent.sh | sudo bash -s -- --url ${url} --token disabled`; } - return `curl -fsSL ${url}/install-docker-agent.sh | bash -s -- --url ${url} --token ${TOKEN_PLACEHOLDER}`; + return `curl -fsSL ${url}/install-docker-agent.sh | sudo bash -s -- --url ${url} --token ${TOKEN_PLACEHOLDER}`; }; const getUninstallCommand = () => { const url = pulseUrl(); - return `curl -fsSL ${url}/install-docker-agent.sh | bash -s -- --uninstall`; + return `curl -fsSL ${url}/install-docker-agent.sh | sudo bash -s -- --uninstall`; }; const getSystemdService = () => { @@ -104,41 +217,6 @@ User=root WantedBy=multi-user.target`; }; - const copyToClipboard = async (text: string) => { - try { - // Try modern clipboard API first - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - window.showToast('success', 'Copied to clipboard'); - return; - } - - // Fallback for non-secure contexts (http://) - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.left = '-999999px'; - textarea.style.top = '-999999px'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - - try { - const successful = document.execCommand('copy'); - if (successful) { - window.showToast('success', 'Copied to clipboard'); - } else { - window.showToast('error', 'Failed to copy to clipboard'); - } - } finally { - document.body.removeChild(textarea); - } - } catch (err) { - console.error('Failed to copy:', err); - window.showToast('error', 'Failed to copy to clipboard'); - } - }; - const isRemovingHost = (hostId: string) => removingHostId() === hostId; const handleRemoveHost = async (hostId: string, displayName: string) => { @@ -178,19 +256,101 @@ WantedBy=multi-user.target`; {/* Deployment Instructions */} - -

- Deploy the Pulse Docker agent -

-

- Run this command on your Docker host. If you're not root (most cases), add sudo before bash. If you're already root (e.g., in a container), the command works as-is. -

+ +
+

Deploy the Pulse Docker agent

+

+ Follow the steps below to create a token, build the install command, and confirm the host is reporting. +

+
- {/* Quick Install - One-liner */} -
-

- Quick install (one command) -

+
+

Step 1 · Token

+
+
+
+

Generate or reuse a host token

+

+ Use one API token per host. If that host is ever compromised you can revoke it without touching other machines. +

+
+ +
+ + +
+
+
+

Token generated

+

+ “{newTokenRecord()?.name || 'Untitled token'}” will only be shown once. Copy it into the install command below. +

+
+ +
+ + {newTokenValue()} + +
+
+ + +
+ Existing token hint: {securityStatus()?.apiTokenHint}. Paste the full value if you want to reuse it. +
+
+ + 0}> +
+ + View saved tokens + +
+ + + + + + + + + + + {(token) => ( + + + + + + )} + + +
NameHintLast used
{tokenDisplayLabel(token)} + {token.prefix && token.suffix ? `${token.prefix}…${token.suffix}` : '—'} + + {token.lastUsedAt ? formatRelativeTime(token.lastUsedAt) : 'Never'} +
+
+
+
+
+
+ +
+

Step 2 · Build the command

{ setApiToken(token); - // If user already had a token in localStorage, save the new one too if (typeof window !== 'undefined' && window.localStorage.getItem('apiToken')) { window.localStorage.setItem('apiToken', token); } }} /> -

- The script downloads the agent, creates a systemd service, and starts monitoring automatically. - - Authentication is disabled, so the agent runs without an API token. - -

- If you already installed the agent from another Pulse instance, running its command again on this host simply adds that server to the same agent—no duplicate processes to clean up. + Run the command as root (prepend sudo when needed). The installer downloads the agent, creates a systemd service, and starts reporting automatically.

-
+ - {/* Uninstall */} -
-
-

- Uninstall the agent -

- -
-
- {getUninstallCommand()} -
-

- This will stop the agent, remove the binary, service file, and all configuration. -

-
+
+ Step 3 · Within one or two heartbeats (default 30 seconds) the host should appear in the table below. If not, check the agent logs with{' '} + journalctl -u pulse-docker-agent -f. +
- {/* Manual Installation */} -
- - Manual installation (advanced) +
+ + Advanced options (uninstall & manual install) -
- {/* Step 1: Build or download */} +
-

- 1. Build the agent binary -

-
- - cd /opt/pulse -
- GOOS=linux GOARCH=amd64 go build -o pulse-docker-agent ./cmd/pulse-docker-agent +

Uninstall

+
+ + {getUninstallCommand()} -
-
- - {/* Step 2: Copy to host */} -
-

- 2. Copy to Docker host -

-
- - scp pulse-docker-agent user@docker-host:/usr/local/bin/ -
- ssh user@docker-host chmod +x /usr/local/bin/pulse-docker-agent -
-
-
- - {/* Step 3: Create systemd service */} -
-

- 3. Create systemd service file -

-
-
-
{getSystemdService()}
-
-

- Save to /etc/systemd/system/pulse-docker-agent.service and replace{' '} - {TOKEN_PLACEHOLDER} with a valid API token. +

+ Stops the agent, removes the binary, the systemd unit, and related files.

- {/* Step 4: Enable and start */}
-

- 4. Enable and start -

-
- - systemctl daemon-reload -
- systemctl enable --now pulse-docker-agent -
+

Manual installation

+
+

1. Build the binary

+
+ + cd /opt/pulse +
+ GOOS=linux GOARCH=amd64 go build -o pulse-docker-agent ./cmd/pulse-docker-agent +
+
+

2. Copy to host

+
+ + scp pulse-docker-agent user@docker-host:/usr/local/bin/ +
+ ssh user@docker-host chmod +x /usr/local/bin/pulse-docker-agent +
+
+

3. Systemd template

+
+ +
+
{getSystemdService()}
+
+
+

4. Enable & start

+
+ + systemctl daemon-reload +
+ systemctl enable --now pulse-docker-agent +
+
diff --git a/frontend-modern/src/components/Settings/GenerateAPIToken.tsx b/frontend-modern/src/components/Settings/GenerateAPIToken.tsx deleted file mode 100644 index 14fa4c6..0000000 --- a/frontend-modern/src/components/Settings/GenerateAPIToken.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { Component, createSignal, Show, createEffect } from 'solid-js'; -import { showSuccess, showError } from '@/utils/toast'; -import { copyToClipboard } from '@/utils/clipboard'; -import { apiFetch } from '@/utils/apiClient'; -import { SectionHeader } from '@/components/shared/SectionHeader'; -import { formField, labelClass, formHelpText } from '@/components/shared/Form'; - -interface GenerateAPITokenProps { - currentTokenHint?: string; -} - -export const GenerateAPIToken: Component = (props) => { - const [isGenerating, setIsGenerating] = createSignal(false); - const [newToken, setNewToken] = createSignal(null); - const [showToken, setShowToken] = createSignal(false); - const [copied, setCopied] = createSignal(false); - const [currentHint, setCurrentHint] = createSignal(props.currentTokenHint || ''); - const [showConfirm, setShowConfirm] = createSignal(false); - - // Update hint when props change - createEffect(() => { - if (props.currentTokenHint) { - setCurrentHint(props.currentTokenHint); - } - }); - - const generateNewToken = async () => { - setIsGenerating(true); - setShowConfirm(false); - - try { - const response = await apiFetch('/api/security/regenerate-token', { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(error || 'Failed to generate token'); - } - - const data = await response.json(); - setNewToken(data.token); - // Update the current hint with the new token - if (data.token && data.token.length >= 20) { - setCurrentHint(data.token.slice(0, 8) + '...' + data.token.slice(-4)); - } - setShowToken(true); - showSuccess("New API token generated! Save it now - it won't be shown again."); - } catch (error) { - showError(`Failed to generate token: ${error}`); - } finally { - setIsGenerating(false); - } - }; - - const handleCopy = async () => { - if (!newToken()) return; - - const success = await copyToClipboard(newToken()!); - if (success) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } else { - showError('Failed to copy to clipboard'); - } - }; - - return ( -
- -
-

- API Token Active -

- 0}> -
- Current token: {currentHint()} -
-
-

- An API token is configured for this instance. Use it with the X-API-Token header for - automation. -

- - -
-
- - -
-
-

- ✅ New API Token Generated! -

-

- Save this token now - it will never be shown again! -

-
- -
-
- -
- - {newToken()} - - -
-
-
- -
-
- - - -
-

Token Active Immediately!

-

Your new API token is active and ready to use.

-

- The old token (if any) has been invalidated. -

-
-
-
- - -
-
- - -
-
- -

- This will generate a new API token and{' '} - - immediately invalidate the current token - - . Any scripts or integrations using the old token will stop working. -

-
- - -
-
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 4931b19..b74f0c8 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -4,7 +4,7 @@ import { useNavigate, useLocation } from '@solidjs/router'; import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; import { NodeModal } from './NodeModal'; -import { GenerateAPIToken } from './GenerateAPIToken'; +import { APITokenManager } from './APITokenManager'; import { ChangePasswordModal } from './ChangePasswordModal'; import { GuestURLs } from './GuestURLs'; import { DockerAgents } from './DockerAgents'; @@ -3588,7 +3588,7 @@ const Settings: Component = (props) => { {/* Content */}
- +
diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index bc31954..2b9f627 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -129,6 +129,10 @@ export interface DockerHost { intervalSeconds: number; agentVersion?: string; containers: DockerContainer[]; + tokenId?: string; + tokenName?: string; + tokenHint?: string; + tokenLastUsedAt?: number; } export interface DockerContainer { diff --git a/internal/api/auth.go b/internal/api/auth.go index 0959148..bc14862 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -1,6 +1,7 @@ package api import ( + "context" cryptorand "crypto/rand" "encoding/base64" "encoding/hex" @@ -15,6 +16,12 @@ import ( "github.com/rs/zerolog/log" ) +type contextKey string + +const ( + contextKeyAPIToken contextKey = "apiTokenRecord" +) + // Global session store instance var ( sessionStore *SessionStore @@ -211,7 +218,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool } // If no auth is configured at all, allow access unless OIDC is enabled - if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.APIToken == "" && cfg.ProxyAuthSecret == "" { + if cfg.AuthUser == "" && cfg.AuthPass == "" && !cfg.HasAPITokens() && cfg.ProxyAuthSecret == "" { if cfg.OIDC != nil && cfg.OIDC.Enabled { log.Debug().Msg("OIDC enabled without local credentials, authentication required") } else { @@ -222,7 +229,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool // API-only mode: when only API token is configured (no password auth) // Allow read-only endpoints for the UI to work - if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.APIToken != "" { + if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() { // Check if an API token was provided providedToken := r.Header.Get("X-API-Token") if providedToken == "" { @@ -231,8 +238,8 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool // If a token was provided, validate it if providedToken != "" { - // Use secure token comparison - if internalauth.CompareAPIToken(providedToken, cfg.APIToken) { + if record, ok := cfg.ValidateAPIToken(providedToken); ok { + attachAPITokenRecord(r, record) return true } // Invalid token provided @@ -278,34 +285,35 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool log.Debug(). Str("configured_user", cfg.AuthUser). Bool("has_pass", cfg.AuthPass != ""). - Bool("has_token", cfg.APIToken != ""). + Bool("has_token", cfg.HasAPITokens()). Str("url", r.URL.Path). Msg("Checking authentication") - // Check API token first (for backward compatibility) - if cfg.APIToken != "" { - // Check header - if token := r.Header.Get("X-API-Token"); token != "" { - // Config always has hashed token now (auto-hashed on load) - if internalauth.CompareAPIToken(token, cfg.APIToken) { - return true - } + validateToken := func(token string) bool { + if token == "" { + return false + } + if record, ok := cfg.ValidateAPIToken(token); ok { + attachAPITokenRecord(r, record) + return true + } + return false + } + + // Check API tokens (header, bearer, query) before other auth methods + if cfg.HasAPITokens() { + if validateToken(r.Header.Get("X-API-Token")) { + return true } - // Support Authorization: Bearer for environments that strip custom headers if authHeader := r.Header.Get("Authorization"); authHeader != "" { if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { - bearerToken := strings.TrimSpace(authHeader[7:]) - if bearerToken != "" && internalauth.CompareAPIToken(bearerToken, cfg.APIToken) { + if validateToken(strings.TrimSpace(authHeader[7:])) { return true } } } - // Check query parameter (for export/import) - if token := r.URL.Query().Get("token"); token != "" { - // Config always has hashed token now (auto-hashed on load) - if internalauth.CompareAPIToken(token, cfg.APIToken) { - return true - } + if validateToken(r.URL.Query().Get("token")) { + return true } } @@ -585,3 +593,25 @@ func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc handler(w, r) } } + +func attachAPITokenRecord(r *http.Request, record *config.APITokenRecord) { + if record == nil { + return + } + clone := record.Clone() + ctx := context.WithValue(r.Context(), contextKeyAPIToken, clone) + *r = *r.WithContext(ctx) +} + +func getAPITokenRecordFromRequest(r *http.Request) *config.APITokenRecord { + value := r.Context().Value(contextKeyAPIToken) + if value == nil { + return nil + } + record, ok := value.(config.APITokenRecord) + if !ok { + return nil + } + clone := record.Clone() + return &clone +} diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 0168a0f..246c82c 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -45,19 +45,19 @@ type SetupCode struct { // ConfigHandlers handles configuration-related API endpoints type ConfigHandlers struct { - config *config.Config - persistence *config.ConfigPersistence - monitor *monitoring.Monitor - reloadFunc func() error - reloadSystemSettingsFunc func() // Function to reload cached system settings - wsHub *websocket.Hub - guestMetadataHandler *GuestMetadataHandler - setupCodes map[string]*SetupCode // Map of code hash -> setup code details - codeMutex sync.RWMutex // Mutex for thread-safe code access - clusterDetectMutex sync.Mutex - lastClusterDetection map[string]time.Time - recentAutoRegistered map[string]time.Time - recentAutoRegMutex sync.Mutex + config *config.Config + persistence *config.ConfigPersistence + monitor *monitoring.Monitor + reloadFunc func() error + reloadSystemSettingsFunc func() // Function to reload cached system settings + wsHub *websocket.Hub + guestMetadataHandler *GuestMetadataHandler + setupCodes map[string]*SetupCode // Map of code hash -> setup code details + codeMutex sync.RWMutex // Mutex for thread-safe code access + clusterDetectMutex sync.Mutex + lastClusterDetection map[string]time.Time + recentAutoRegistered map[string]time.Time + recentAutoRegMutex sync.Mutex } // NewConfigHandlers creates a new ConfigHandlers instance @@ -2871,7 +2871,7 @@ func (h *ConfigHandlers) HandleSetupScript(w http.ResponseWriter, r *http.Reques log.Info(). Str("type", serverType). Str("host", serverHost). - Bool("has_auth", h.config.AuthUser != "" || h.config.AuthPass != "" || h.config.APIToken != ""). + Bool("has_auth", h.config.AuthUser != "" || h.config.AuthPass != "" || h.config.HasAPITokens()). Msg("HandleSetupScript called") // The setup script is now public - authentication happens via setup code @@ -4374,19 +4374,24 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque log.Debug(). Str("authToken", req.AuthToken). Str("authCode", authCode). - Bool("hasConfigToken", h.config.APIToken != ""). + Bool("hasConfigToken", h.config.HasAPITokens()). Msg("Checking authentication for auto-register") // First check for setup code/auth token in the request if authCode != "" { - // First check if it's the actual API token (for direct authentication) - if h.config.APIToken != "" && internalauth.CompareAPIToken(authCode, h.config.APIToken) { - authenticated = true - log.Info(). - Str("type", req.Type). - Str("host", req.Host). - Msg("Auto-register authenticated via direct API token") - } else { + matchedAPIToken := false + if h.config.HasAPITokens() { + if _, ok := h.config.ValidateAPIToken(authCode); ok { + authenticated = true + matchedAPIToken = true + log.Info(). + Str("type", req.Type). + Str("host", req.Host). + Msg("Auto-register authenticated via direct API token") + } + } + + if !matchedAPIToken { // Not the API token, check if it's a temporary setup code codeHash := internalauth.HashAPIToken(authCode) log.Debug(). @@ -4429,10 +4434,9 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque } // If not authenticated via setup code, check API token if configured - if !authenticated && h.config.APIToken != "" { + if !authenticated && h.config.HasAPITokens() { apiToken := r.Header.Get("X-API-Token") - // Config always has hashed token now (auto-hashed on load) - if apiToken != "" && internalauth.CompareAPIToken(apiToken, h.config.APIToken) { + if _, ok := h.config.ValidateAPIToken(apiToken); ok { authenticated = true log.Info().Msg("Auto-register authenticated via API token") } @@ -4441,11 +4445,11 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque // If still not authenticated and auth is required, reject // BUT: Always allow if a valid setup code/auth token was provided (even if expired/used) // This ensures the error message is accurate - if !authenticated && h.config.APIToken != "" && authCode == "" { + if !authenticated && h.config.HasAPITokens() && authCode == "" { log.Warn().Str("ip", r.RemoteAddr).Msg("Unauthorized auto-register attempt - no authentication provided") http.Error(w, "Pulse requires authentication", http.StatusUnauthorized) return - } else if !authenticated && h.config.APIToken != "" { + } else if !authenticated && h.config.HasAPITokens() { // Had a code but it didn't validate log.Warn().Str("ip", r.RemoteAddr).Msg("Unauthorized auto-register attempt - invalid or expired setup code") http.Error(w, "Invalid or expired setup code", http.StatusUnauthorized) diff --git a/internal/api/docker_agents.go b/internal/api/docker_agents.go index 0ce35e8..7727fb8 100644 --- a/internal/api/docker_agents.go +++ b/internal/api/docker_agents.go @@ -48,7 +48,9 @@ func (h *DockerAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reques report.Timestamp = time.Now() } - host, err := h.monitor.ApplyDockerReport(report) + tokenRecord := getAPITokenRecordFromRequest(r) + + host, err := h.monitor.ApplyDockerReport(report, tokenRecord) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil) return diff --git a/internal/api/router.go b/internal/api/router.go index 1540a7d..0a188cb 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -232,6 +232,17 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/security/oidc", RequireAdmin(r.config, 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: + r.handleListAPITokens(w, req) + case http.MethodPost: + 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/status", func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") @@ -250,16 +261,13 @@ func (r *Router) setupRoutes() { } // Even with auth disabled, report API token status for API access - var apiTokenHint string - if r.config.APIToken != "" && len(r.config.APIToken) >= 8 { - apiTokenHint = r.config.APIToken[:4] + "..." + r.config.APIToken[len(r.config.APIToken)-4:] - } + apiTokenHint := r.config.PrimaryAPITokenHint() response := map[string]interface{}{ "configured": false, "disabled": true, "message": "Authentication is disabled via DISABLE_AUTH environment variable", - "apiTokenConfigured": r.config.APIToken != "", + "apiTokenConfigured": r.config.HasAPITokens(), "apiTokenHint": apiTokenHint, "hasAuthentication": false, } @@ -288,7 +296,7 @@ func (r *Router) setupRoutes() { r.config.AuthUser != "" || r.config.AuthPass != "" || (oidcCfg != nil && oidcCfg.Enabled) || - r.config.APIToken != "" || + r.config.HasAPITokens() || r.config.ProxyAuthSecret != "" // Check if .env file exists but hasn't been loaded yet (pending restart) @@ -328,10 +336,7 @@ func (r *Router) setupRoutes() { isTrustedNetwork := utils.IsTrustedNetwork(clientIP, trustedNetworks) // Create token hint if token exists - var apiTokenHint string - if r.config.APIToken != "" && len(r.config.APIToken) >= 8 { - apiTokenHint = r.config.APIToken[:4] + "..." + r.config.APIToken[len(r.config.APIToken)-4:] - } + apiTokenHint := r.config.PrimaryAPITokenHint() // Check for proxy auth hasProxyAuth := r.config.ProxyAuthSecret != "" @@ -355,16 +360,16 @@ func (r *Router) setupRoutes() { } } - requiresAuth := r.config.APIToken != "" || + requiresAuth := r.config.HasAPITokens() || (r.config.AuthUser != "" && r.config.AuthPass != "") || (r.config.OIDC != nil && r.config.OIDC.Enabled) || r.config.ProxyAuthSecret != "" status := map[string]interface{}{ - "apiTokenConfigured": r.config.APIToken != "", + "apiTokenConfigured": r.config.HasAPITokens(), "apiTokenHint": apiTokenHint, "requiresAuth": requiresAuth, - "exportProtected": r.config.APIToken != "" || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true", + "exportProtected": r.config.HasAPITokens() || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true", "unprotectedExportAllowed": os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true", "hasAuthentication": hasAuthentication, "configuredButPendingRestart": configuredButPendingRestart, @@ -606,25 +611,22 @@ func (r *Router) setupRoutes() { hasValidSession = ValidateSession(cookie.Value) } - hasValidAPIToken := false - if r.config.APIToken != "" { - authHeader := req.Header.Get("X-API-Token") - // Check if stored token is hashed or plain text - if auth.IsAPITokenHashed(r.config.APIToken) { - // Compare against hash - hasValidAPIToken = auth.CompareAPIToken(authHeader, r.config.APIToken) - } else { - // Plain text comparison (legacy) - hasValidAPIToken = (authHeader == r.config.APIToken) + validateAPIToken := func(token string) bool { + if token == "" || !r.config.HasAPITokens() { + return false } + _, ok := r.config.ValidateAPIToken(token) + return ok } + hasValidAPIToken := validateAPIToken(req.Header.Get("X-API-Token")) + // Check if any valid auth method is present hasValidAuth := hasValidProxyAuth || hasValidSession || hasValidAPIToken // Determine if auth is required authRequired := r.config.AuthUser != "" && r.config.AuthPass != "" || - r.config.APIToken != "" || + r.config.HasAPITokens() || r.config.ProxyAuthSecret != "" // Check admin privileges for proxy auth users @@ -705,25 +707,22 @@ func (r *Router) setupRoutes() { hasValidSession = ValidateSession(cookie.Value) } - hasValidAPIToken := false - if r.config.APIToken != "" { - authHeader := req.Header.Get("X-API-Token") - // Check if stored token is hashed or plain text - if auth.IsAPITokenHashed(r.config.APIToken) { - // Compare against hash - hasValidAPIToken = auth.CompareAPIToken(authHeader, r.config.APIToken) - } else { - // Plain text comparison (legacy) - hasValidAPIToken = (authHeader == r.config.APIToken) + validateAPIToken := func(token string) bool { + if token == "" || !r.config.HasAPITokens() { + return false } + _, ok := r.config.ValidateAPIToken(token) + return ok } + hasValidAPIToken := validateAPIToken(req.Header.Get("X-API-Token")) + // Check if any valid auth method is present hasValidAuth := hasValidProxyAuth || hasValidSession || hasValidAPIToken // Determine if auth is required authRequired := r.config.AuthUser != "" && r.config.AuthPass != "" || - r.config.APIToken != "" || + r.config.HasAPITokens() || r.config.ProxyAuthSecret != "" // Check admin privileges for proxy auth users @@ -956,13 +955,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { } // If a valid API token is provided, allow access even with DisableAuth - if providedToken != "" && r.config.APIToken != "" { - if auth.CompareAPIToken(providedToken, r.config.APIToken) { - // Valid API token provided, allow access + if providedToken != "" && r.config.HasAPITokens() { + if _, ok := r.config.ValidateAPIToken(providedToken); ok { needsAuth = false w.Header().Set("X-Auth-Method", "api-token") } else { - // Invalid API token - reject even with DisableAuth http.Error(w, "Invalid API token", http.StatusUnauthorized) return } @@ -1157,9 +1154,9 @@ func (r *Router) detectLegacySSH() (legacyDetected, recommendProxy bool) { if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { cgroupStr := string(data) if strings.Contains(cgroupStr, "docker") || - strings.Contains(cgroupStr, "lxc") || - strings.Contains(cgroupStr, "/docker/") || - strings.Contains(cgroupStr, "/lxc/") { + strings.Contains(cgroupStr, "lxc") || + strings.Contains(cgroupStr, "/docker/") || + strings.Contains(cgroupStr, "/lxc/") { inContainer = true } } @@ -1177,8 +1174,8 @@ func (r *Router) detectLegacySSH() (legacyDetected, recommendProxy bool) { if data, err := os.ReadFile("/proc/1/environ"); err == nil { environStr := string(data) if strings.Contains(environStr, "container=") || - strings.Contains(environStr, "DOCKER") || - strings.Contains(environStr, "LXC") { + strings.Contains(environStr, "DOCKER") || + strings.Contains(environStr, "LXC") { inContainer = true } } @@ -1253,11 +1250,11 @@ func (r *Router) handleHealth(w http.ResponseWriter, req *http.Request) { } response := HealthResponse{ - Status: "healthy", - Timestamp: time.Now().Unix(), - Uptime: time.Since(r.monitor.GetStartTime()).Seconds(), - LegacySSHDetected: legacySSH, - RecommendProxyUpgrade: recommendProxy, + Status: "healthy", + Timestamp: time.Now().Unix(), + Uptime: time.Since(r.monitor.GetStartTime()).Seconds(), + LegacySSHDetected: legacySSH, + RecommendProxyUpgrade: recommendProxy, ProxyInstallScriptAvailable: true, // Install script is always available } @@ -1418,8 +1415,13 @@ PULSE_AUTH_PASS='%s' `, time.Now().Format(time.RFC3339), r.config.AuthUser, hashedPassword) // Include API token if configured - if r.config.APIToken != "" { - envContent += fmt.Sprintf("API_TOKEN='%s'\n", r.config.APIToken) + if r.config.HasAPITokens() { + hashes := make([]string, len(r.config.APITokens)) + for i, t := range r.config.APITokens { + hashes[i] = t.Hash + } + envContent += fmt.Sprintf("API_TOKEN='%s'\n", r.config.PrimaryAPITokenHash()) + envContent += fmt.Sprintf("API_TOKENS='%s'\n", strings.Join(hashes, ",")) } } @@ -1483,8 +1485,13 @@ PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' `, time.Now().Format(time.RFC3339), r.config.AuthUser, hashedPassword) - if r.config.APIToken != "" { - envContent += fmt.Sprintf("API_TOKEN='%s'\n", r.config.APIToken) + if r.config.HasAPITokens() { + hashes := make([]string, len(r.config.APITokens)) + for i, t := range r.config.APITokens { + hashes[i] = t.Hash + } + envContent += fmt.Sprintf("API_TOKEN='%s'\n", r.config.PrimaryAPITokenHash()) + envContent += fmt.Sprintf("API_TOKENS='%s'\n", strings.Join(hashes, ",")) } } diff --git a/internal/api/router_integration_test.go b/internal/api/router_integration_test.go index db384ce..57df9c6 100644 --- a/internal/api/router_integration_test.go +++ b/internal/api/router_integration_test.go @@ -286,7 +286,12 @@ func TestAuthenticatedEndpointsRequireToken(t *testing.T) { srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) { cfg.DisableAuth = false cfg.APITokenEnabled = true - cfg.APIToken = internalauth.HashAPIToken(apiToken) + record, err := config.NewAPITokenRecord(apiToken, "Integration test token") + if err != nil { + t.Fatalf("create API token record: %v", err) + } + cfg.APITokens = []config.APITokenRecord{*record} + cfg.SortAPITokens() hashedPass, err := internalauth.HashPassword("super-secure-pass") if err != nil { t.Fatalf("hash password: %v", err) diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 52eae62..6de447d 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -129,10 +129,15 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { // Store the raw API token for displaying to the user rawAPIToken := setupRequest.APIToken - // Hash the API token for storage - hashedAPIToken := internalauth.HashAPIToken(rawAPIToken) + tokenRecord, err := config.NewAPITokenRecord(rawAPIToken, "Primary token") + if err != nil { + log.Error().Err(err).Msg("Failed to construct API token record") + http.Error(w, "Failed to process API token", http.StatusInternalServerError) + return + } + primaryTokenHash := tokenRecord.Hash - if r.config.APIToken != "" && r.config.AuthUser == "" && r.config.AuthPass == "" { + if r.config.HasAPITokens() && r.config.AuthUser == "" && r.config.AuthPass == "" { // We had API-only access before, now replacing with full security log.Info().Msg("Replacing API-only token with new secure token") } @@ -140,8 +145,15 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { // Update runtime config immediately with hashed token - no restart needed! r.config.AuthUser = setupRequest.Username r.config.AuthPass = hashedPassword - r.config.APIToken = hashedAPIToken + r.config.APITokens = []config.APITokenRecord{*tokenRecord} + r.config.SortAPITokens() r.config.APITokenEnabled = true + + if r.persistence != nil { + if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { + log.Warn().Err(err).Msg("Failed to persist API tokens during security setup") + } + } log.Info().Msg("Runtime config updated with new security settings - active immediately") // Save system settings to system.json @@ -177,9 +189,10 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { # IMPORTANT: Do not remove the single quotes around the password hash! PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' -API_TOKEN=%s +API_TOKEN='%s' +API_TOKENS='%s' PULSE_AUDIT_LOG=true -`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, primaryTokenHash, primaryTokenHash) // Ensure directory exists if err := os.MkdirAll(r.config.ConfigPath, 0755); err != nil { @@ -217,9 +230,10 @@ PULSE_AUDIT_LOG=true # Generated on %s PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' -API_TOKEN=%s +API_TOKEN='%s' +API_TOKENS='%s' PULSE_AUDIT_LOG=true -`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, primaryTokenHash, primaryTokenHash) // Save to config directory (usually /etc/pulse) if err := os.MkdirAll(r.config.ConfigPath, 0755); err != nil { @@ -277,8 +291,9 @@ PULSE_AUDIT_LOG=true Environment="PULSE_AUTH_USER=%s" Environment="PULSE_AUTH_PASS=%s" Environment="API_TOKEN=%s" +Environment="API_TOKENS=%s" Environment="PULSE_AUDIT_LOG=true" -`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, primaryTokenHash, primaryTokenHash) if err := os.WriteFile(overridePath, []byte(overrideContent), 0644); err != nil { log.Error().Err(err).Msg("Failed to write systemd override") @@ -316,9 +331,10 @@ Environment="PULSE_AUDIT_LOG=true" # Generated on %s PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' -API_TOKEN=%s +API_TOKEN='%s' +API_TOKENS='%s' PULSE_AUDIT_LOG=true -`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken) +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, primaryTokenHash, primaryTokenHash) // Try to create directory if needed if err := os.MkdirAll(filepath.Dir(envPath), 0755); err != nil { @@ -413,13 +429,23 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques return } - // Hash the token for storage - hashedToken := internalauth.HashAPIToken(rawToken) + tokenRecord, err := config.NewAPITokenRecord(rawToken, "Regenerated token") + if err != nil { + log.Error().Err(err).Msg("Failed to construct API token record") + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } - // Update runtime config immediately with hashed token - no restart needed! - r.config.APIToken = hashedToken + r.config.APITokens = []config.APITokenRecord{*tokenRecord} + r.config.SortAPITokens() r.config.APITokenEnabled = true - log.Info().Msg("Runtime config updated with new hashed API token - active immediately") + log.Info().Msg("Runtime config updated with new API token - active immediately") + + if r.persistence != nil { + if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { + log.Warn().Err(err).Msg("Failed to persist regenerated API token") + } + } // Determine env file path envPath := filepath.Join(r.config.ConfigPath, ".env") @@ -440,20 +466,27 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques return } - // Update the API_TOKEN line with the hashed token + // Update the API_TOKEN / API_TOKENS lines with the hashed token lines := strings.Split(string(content), "\n") - var updated bool + var updatedPrimary bool + var updatedList bool for i, line := range lines { if strings.HasPrefix(line, "API_TOKEN=") { - lines[i] = fmt.Sprintf("API_TOKEN=%s", hashedToken) - updated = true - break + lines[i] = fmt.Sprintf("API_TOKEN=%s", tokenRecord.Hash) + updatedPrimary = true + } + if strings.HasPrefix(line, "API_TOKENS=") { + lines[i] = fmt.Sprintf("API_TOKENS=%s", tokenRecord.Hash) + updatedList = true } } - if !updated { + if !updatedPrimary { // API_TOKEN line not found, add it - lines = append(lines, fmt.Sprintf("API_TOKEN=%s", hashedToken)) + lines = append(lines, fmt.Sprintf("API_TOKEN=%s", tokenRecord.Hash)) + } + if !updatedList { + lines = append(lines, fmt.Sprintf("API_TOKENS=%s", tokenRecord.Hash)) } // Write updated content back @@ -556,7 +589,7 @@ func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request) } // Check if API token auth is enabled - if !r.config.APITokenEnabled || r.config.APIToken == "" { + if !r.config.APITokenEnabled || !r.config.HasAPITokens() { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "valid": false, @@ -566,8 +599,7 @@ func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request) } // Validate the token (compare hash) - hashedProvidedToken := internalauth.HashAPIToken(validateRequest.Token) - isValid := hashedProvidedToken == r.config.APIToken + _, isValid := r.config.ValidateAPIToken(validateRequest.Token) // Log validation attempt without logging the token itself if isValid { diff --git a/internal/api/security_tokens.go b/internal/api/security_tokens.go new file mode 100644 index 0000000..be56b9f --- /dev/null +++ b/internal/api/security_tokens.go @@ -0,0 +1,135 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rs/zerolog/log" +) + +type apiTokenDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` +} + +func toAPITokenDTO(record config.APITokenRecord) apiTokenDTO { + return apiTokenDTO{ + ID: record.ID, + Name: record.Name, + Prefix: record.Prefix, + Suffix: record.Suffix, + CreatedAt: record.CreatedAt, + LastUsedAt: record.LastUsedAt, + } +} + +// handleListAPITokens returns all configured API tokens (metadata only). +func (r *Router) handleListAPITokens(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + tokens := make([]apiTokenDTO, 0, len(r.config.APITokens)) + for _, record := range r.config.APITokens { + tokens = append(tokens, toAPITokenDTO(record)) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "tokens": tokens, + }) +} + +type createTokenRequest struct { + Name string `json:"name"` +} + +// handleCreateAPIToken generates and stores a new API token. +func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var payload createTokenRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil && err != io.EOF { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + name := strings.TrimSpace(payload.Name) + if name == "" { + name = "API token" + } + + rawToken, err := internalauth.GenerateAPIToken() + if err != nil { + log.Error().Err(err).Msg("Failed to generate API token") + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + record, err := config.NewAPITokenRecord(rawToken, name) + if err != nil { + log.Error().Err(err).Msg("Failed to construct API token record") + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + r.config.APITokens = append(r.config.APITokens, *record) + r.config.SortAPITokens() + r.config.APITokenEnabled = true + + if r.persistence != nil { + if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { + log.Warn().Err(err).Msg("Failed to persist API tokens after creation") + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "token": rawToken, + "record": toAPITokenDTO(*record), + }) +} + +// handleDeleteAPIToken removes an API token by ID. +func (r *Router) handleDeleteAPIToken(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(req.URL.Path, "/api/security/tokens/") + if id == "" { + http.Error(w, "Token ID required", http.StatusBadRequest) + return + } + + removed := r.config.RemoveAPIToken(id) + if !removed { + http.Error(w, "Token not found", http.StatusNotFound) + return + } + + r.config.SortAPITokens() + r.config.APITokenEnabled = r.config.HasAPITokens() + + if r.persistence != nil { + if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { + log.Warn().Err(err).Msg("Failed to persist API tokens after deletion") + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/config/api_tokens.go b/internal/config/api_tokens.go new file mode 100644 index 0000000..0c65f34 --- /dev/null +++ b/internal/config/api_tokens.go @@ -0,0 +1,195 @@ +package config + +import ( + "errors" + "sort" + "time" + + "github.com/google/uuid" + "github.com/rcourtman/pulse-go-rewrite/internal/auth" +) + +// ErrInvalidToken is returned when a token value is empty or malformed. +var ErrInvalidToken = errors.New("invalid API token") + +// APITokenRecord stores hashed token metadata. +type APITokenRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Hash string `json:"hash"` + Prefix string `json:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` +} + +// Clone returns a copy of the record with duplicated pointer fields. +func (r *APITokenRecord) Clone() APITokenRecord { + clone := *r + if r.LastUsedAt != nil { + t := *r.LastUsedAt + clone.LastUsedAt = &t + } + return clone +} + +// NewAPITokenRecord constructs a metadata record from the provided raw token. +func NewAPITokenRecord(rawToken, name string) (*APITokenRecord, error) { + if rawToken == "" { + return nil, ErrInvalidToken + } + + now := time.Now().UTC() + record := &APITokenRecord{ + ID: uuid.NewString(), + Name: name, + Hash: auth.HashAPIToken(rawToken), + Prefix: tokenPrefix(rawToken), + Suffix: tokenSuffix(rawToken), + CreatedAt: now, + } + return record, nil +} + +// NewHashedAPITokenRecord constructs a record from an already hashed token. +func NewHashedAPITokenRecord(hashedToken, name string, createdAt time.Time) (*APITokenRecord, error) { + if hashedToken == "" { + return nil, ErrInvalidToken + } + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + + return &APITokenRecord{ + ID: uuid.NewString(), + Name: name, + Hash: hashedToken, + Prefix: tokenPrefix(hashedToken), + Suffix: tokenSuffix(hashedToken), + CreatedAt: createdAt, + }, nil +} + +// tokenPrefix returns the first six characters suitable for hints. +func tokenPrefix(value string) string { + if len(value) >= 6 { + return value[:6] + } + return value +} + +// tokenSuffix returns the last four characters suitable for hints. +func tokenSuffix(value string) string { + if len(value) >= 4 { + return value[len(value)-4:] + } + return value +} + +// HasAPITokens reports whether any API tokens are configured. +func (c *Config) HasAPITokens() bool { + return len(c.APITokens) > 0 +} + +// APITokenCount returns the number of configured tokens. +func (c *Config) APITokenCount() int { + return len(c.APITokens) +} + +// ActiveAPITokenHashes returns all stored token hashes. +func (c *Config) ActiveAPITokenHashes() []string { + hashes := make([]string, 0, len(c.APITokens)) + for _, record := range c.APITokens { + if record.Hash != "" { + hashes = append(hashes, record.Hash) + } + } + return hashes +} + +// HasAPITokenHash returns true when the hash already exists. +func (c *Config) HasAPITokenHash(hash string) bool { + for _, record := range c.APITokens { + if record.Hash == hash { + return true + } + } + return false +} + +// PrimaryAPITokenHash returns the newest token hash, if any. +func (c *Config) PrimaryAPITokenHash() string { + if len(c.APITokens) == 0 { + return "" + } + return c.APITokens[0].Hash +} + +// PrimaryAPITokenHint provides a human-friendly token hint for UI display. +func (c *Config) PrimaryAPITokenHint() string { + if len(c.APITokens) == 0 { + return "" + } + token := c.APITokens[0] + if token.Prefix != "" && token.Suffix != "" { + return token.Prefix + "..." + token.Suffix + } + if len(token.Hash) >= 8 { + return token.Hash[:4] + "..." + token.Hash[len(token.Hash)-4:] + } + return "" +} + +// ValidateAPIToken compares the raw token against stored hashes and updates metadata. +func (c *Config) ValidateAPIToken(rawToken string) (*APITokenRecord, bool) { + if rawToken == "" { + return nil, false + } + + for idx, record := range c.APITokens { + if auth.CompareAPIToken(rawToken, record.Hash) { + now := time.Now().UTC() + c.APITokens[idx].LastUsedAt = &now + return &c.APITokens[idx], true + } + } + return nil, false +} + +// UpsertAPIToken inserts or replaces a record by ID. +func (c *Config) UpsertAPIToken(record APITokenRecord) { + for idx, existing := range c.APITokens { + if existing.ID == record.ID { + c.APITokens[idx] = record + c.SortAPITokens() + return + } + } + c.APITokens = append(c.APITokens, record) + c.SortAPITokens() +} + +// RemoveAPIToken removes a token by ID. +func (c *Config) RemoveAPIToken(id string) bool { + for idx, record := range c.APITokens { + if record.ID == id { + c.APITokens = append(c.APITokens[:idx], c.APITokens[idx+1:]...) + return true + } + } + return false +} + +// SortAPITokens keeps tokens ordered newest-first and syncs the legacy APIToken field. +func (c *Config) SortAPITokens() { + sort.SliceStable(c.APITokens, func(i, j int) bool { + return c.APITokens[i].CreatedAt.After(c.APITokens[j].CreatedAt) + }) + + if len(c.APITokens) > 0 { + c.APIToken = c.APITokens[0].Hash + c.APITokenEnabled = true + } else { + c.APIToken = "" + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5c0e04b..82e4067 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/joho/godotenv" "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rs/zerolog" @@ -89,14 +90,15 @@ type Config struct { LogCompress bool `envconfig:"LOG_COMPRESS" default:"true"` // Security settings - APIToken string `envconfig:"API_TOKEN"` - APITokenEnabled bool `envconfig:"API_TOKEN_ENABLED" default:"false"` - AuthUser string `envconfig:"PULSE_AUTH_USER"` - AuthPass string `envconfig:"PULSE_AUTH_PASS"` - DisableAuth bool `envconfig:"DISABLE_AUTH" default:"false"` - DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode - AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"` - IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"` + APIToken string `envconfig:"API_TOKEN"` + APITokenEnabled bool `envconfig:"API_TOKEN_ENABLED" default:"false"` + APITokens []APITokenRecord `json:"-"` + AuthUser string `envconfig:"PULSE_AUTH_USER"` + AuthPass string `envconfig:"PULSE_AUTH_PASS"` + DisableAuth bool `envconfig:"DISABLE_AUTH" default:"false"` + DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode + AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"` + IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"` // Proxy authentication settings ProxyAuthSecret string `envconfig:"PROXY_AUTH_SECRET"` @@ -347,6 +349,15 @@ func Load() (*Config, error) { } } + // Load API tokens + if tokens, err := persistence.LoadAPITokens(); err == nil { + cfg.APITokens = tokens + cfg.SortAPITokens() + log.Info().Int("count", len(tokens)).Msg("Loaded API tokens from persistence") + } else if err != nil { + log.Warn().Err(err).Msg("Failed to load API tokens from persistence") + } + // Ensure PBS polling interval has default if not set // Note: PVE polling is hardcoded to 10s in monitor.go if cfg.PBSPollingInterval == 0 { @@ -372,26 +383,73 @@ func Load() (*Config, error) { log.Info().Int("port", p).Msg("Overriding frontend port from PORT env var (legacy)") } } - if apiToken := os.Getenv("API_TOKEN"); apiToken != "" { - // Auto-hash plain text tokens for security - if !auth.IsAPITokenHashed(apiToken) { - // Plain text token - hash it immediately - cfg.APIToken = auth.HashAPIToken(apiToken) - log.Info().Msg("Auto-hashed plain text API token from environment variable") - } else { - // Already hashed - cfg.APIToken = apiToken - log.Debug().Msg("Loaded pre-hashed API token from env var") + envTokens := make([]string, 0, 4) + if list := strings.TrimSpace(os.Getenv("API_TOKENS")); list != "" { + for _, part := range strings.Split(list, ",") { + part = strings.TrimSpace(part) + if part != "" { + envTokens = append(envTokens, part) + } } } + if token := strings.TrimSpace(os.Getenv("API_TOKEN")); token != "" { + envTokens = append(envTokens, token) + } + + if len(envTokens) > 0 { + cfg.EnvOverrides["API_TOKEN"] = true + cfg.EnvOverrides["API_TOKENS"] = true + for _, tokenValue := range envTokens { + if tokenValue == "" { + continue + } + + hashed := tokenValue + prefix := tokenPrefix(tokenValue) + suffix := tokenSuffix(tokenValue) + + if !auth.IsAPITokenHashed(tokenValue) { + hashed = auth.HashAPIToken(tokenValue) + prefix = tokenPrefix(tokenValue) + suffix = tokenSuffix(tokenValue) + log.Info().Msg("Auto-hashed plain text API token from environment variable") + } else { + log.Debug().Msg("Loaded pre-hashed API token from env var") + } + + if cfg.HasAPITokenHash(hashed) { + continue + } + + record := APITokenRecord{ + ID: uuid.NewString(), + Name: "Environment token", + Hash: hashed, + Prefix: prefix, + Suffix: suffix, + CreatedAt: time.Now().UTC(), + } + cfg.APITokens = append(cfg.APITokens, record) + } + cfg.SortAPITokens() + } + // Check if API token is enabled if apiTokenEnabled := os.Getenv("API_TOKEN_ENABLED"); apiTokenEnabled != "" { cfg.APITokenEnabled = apiTokenEnabled == "true" || apiTokenEnabled == "1" log.Debug().Bool("enabled", cfg.APITokenEnabled).Msg("API token enabled status from env var") - } else if cfg.APIToken != "" { - // If token exists but no explicit enabled flag, assume enabled for backwards compatibility + } else if cfg.HasAPITokens() { cfg.APITokenEnabled = true - log.Debug().Msg("API token exists without explicit enabled flag, assuming enabled for backwards compatibility") + log.Debug().Msg("API tokens exist without explicit enabled flag, assuming enabled for backwards compatibility") + } + + // 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 { + cfg.APITokens = []APITokenRecord{*record} + cfg.SortAPITokens() + log.Info().Msg("Migrated legacy API token into token record store") + } } // Check if auth is disabled disableAuthEnv := os.Getenv("DISABLE_AUTH") diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 21b0722..717914a 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -19,15 +19,16 @@ import ( // ConfigPersistence handles saving and loading configuration type ConfigPersistence struct { - mu sync.RWMutex - configDir string - alertFile string - emailFile string - webhookFile string - nodesFile string - systemFile string - oidcFile string - crypto *crypto.CryptoManager + mu sync.RWMutex + configDir string + alertFile string + emailFile string + webhookFile string + nodesFile string + systemFile string + oidcFile string + apiTokensFile string + crypto *crypto.CryptoManager } // NewConfigPersistence creates a new config persistence manager @@ -44,14 +45,15 @@ func NewConfigPersistence(configDir string) *ConfigPersistence { } cp := &ConfigPersistence{ - configDir: configDir, - alertFile: filepath.Join(configDir, "alerts.json"), - emailFile: filepath.Join(configDir, "email.enc"), - webhookFile: filepath.Join(configDir, "webhooks.enc"), - nodesFile: filepath.Join(configDir, "nodes.enc"), - systemFile: filepath.Join(configDir, "system.json"), - oidcFile: filepath.Join(configDir, "oidc.enc"), - crypto: cryptoMgr, + configDir: configDir, + alertFile: filepath.Join(configDir, "alerts.json"), + emailFile: filepath.Join(configDir, "email.enc"), + webhookFile: filepath.Join(configDir, "webhooks.enc"), + nodesFile: filepath.Join(configDir, "nodes.enc"), + systemFile: filepath.Join(configDir, "system.json"), + oidcFile: filepath.Join(configDir, "oidc.enc"), + apiTokensFile: filepath.Join(configDir, "api_tokens.json"), + crypto: cryptoMgr, } log.Debug(). @@ -69,6 +71,65 @@ func (c *ConfigPersistence) EnsureConfigDir() error { return os.MkdirAll(c.configDir, 0700) } +// LoadAPITokens loads API token metadata from disk. +func (c *ConfigPersistence) LoadAPITokens() ([]APITokenRecord, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + data, err := os.ReadFile(c.apiTokensFile) + if err != nil { + if os.IsNotExist(err) { + return []APITokenRecord{}, nil + } + return nil, err + } + + if len(data) == 0 { + return []APITokenRecord{}, nil + } + + var tokens []APITokenRecord + if err := json.Unmarshal(data, &tokens); err != nil { + return nil, err + } + + return tokens, nil +} + +// SaveAPITokens persists API token metadata to disk. +func (c *ConfigPersistence) SaveAPITokens(tokens []APITokenRecord) error { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.EnsureConfigDir(); err != nil { + return err + } + + // Backup previous state (best effort). + if existing, err := os.ReadFile(c.apiTokensFile); err == nil && len(existing) > 0 { + if err := os.WriteFile(c.apiTokensFile+".backup", existing, 0600); err != nil { + log.Warn().Err(err).Msg("Failed to create API token backup file") + } + } + + data, err := json.MarshalIndent(tokens, "", " ") + if err != nil { + return err + } + + tmp := c.apiTokensFile + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return err + } + + if err := os.Rename(tmp, c.apiTokensFile); err != nil { + os.Remove(tmp) + return err + } + + return nil +} + // SaveAlertConfig saves alert configuration to file func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error { c.mu.Lock() diff --git a/internal/config/watcher.go b/internal/config/watcher.go index 080f375..bbd17f2 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -3,26 +3,31 @@ package config import ( "os" "path/filepath" + "reflect" "strings" "sync" "time" "github.com/fsnotify/fsnotify" + "github.com/google/uuid" "github.com/joho/godotenv" + "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rs/zerolog/log" ) // ConfigWatcher monitors the .env file for changes and updates runtime config type ConfigWatcher struct { - config *Config - envPath string - mockEnvPath string - watcher *fsnotify.Watcher - stopChan chan struct{} - lastModTime time.Time - mockLastModTime time.Time - mu sync.RWMutex - onMockReload func() // Callback to trigger backend restart + config *Config + envPath string + mockEnvPath string + apiTokensPath string + watcher *fsnotify.Watcher + stopChan chan struct{} + lastModTime time.Time + mockLastModTime time.Time + apiTokensLastModTime time.Time + mu sync.RWMutex + onMockReload func() // Callback to trigger backend restart } // NewConfigWatcher creates a new config watcher @@ -53,12 +58,15 @@ func NewConfigWatcher(config *Config) (*ConfigWatcher, error) { return nil, err } + apiTokensPath := filepath.Join(filepath.Dir(envPath), "api_tokens.json") + cw := &ConfigWatcher{ - config: config, - envPath: envPath, - mockEnvPath: mockEnvPath, - watcher: watcher, - stopChan: make(chan struct{}), + config: config, + envPath: envPath, + mockEnvPath: mockEnvPath, + apiTokensPath: apiTokensPath, + watcher: watcher, + stopChan: make(chan struct{}), } // Get initial mod times @@ -70,6 +78,9 @@ func NewConfigWatcher(config *Config) (*ConfigWatcher, error) { cw.mockLastModTime = stat.ModTime() } } + if stat, err := os.Stat(apiTokensPath); err == nil { + cw.apiTokensLastModTime = stat.ModTime() + } return cw, nil } @@ -105,7 +116,7 @@ func (cw *ConfigWatcher) Start() error { } go cw.watchForChanges() - logEvent := log.Info().Str("env_path", cw.envPath) + logEvent := log.Info().Str("env_path", cw.envPath).Str("api_tokens_path", cw.apiTokensPath) if cw.mockEnvPath != "" { logEvent = logEvent.Str("mock_env_path", cw.mockEnvPath) } @@ -150,6 +161,16 @@ func (cw *ConfigWatcher) watchForChanges() { } } + if cw.apiTokensPath != "" && (filepath.Base(event.Name) == filepath.Base(cw.apiTokensPath) || event.Name == cw.apiTokensPath) { + // Debounce + time.Sleep(100 * time.Millisecond) + + if event.Op&(fsnotify.Write|fsnotify.Create) != 0 { + log.Info().Str("event", event.Op.String()).Msg("Detected API token file change") + cw.reloadAPITokens() + } + } + // Check if the event is for mock.env (only if mock.env watching is enabled) if cw.mockEnvPath != "" && (filepath.Base(event.Name) == "mock.env" || event.Name == cw.mockEnvPath) { // Debounce - wait a bit for write to complete @@ -201,6 +222,16 @@ func (cw *ConfigWatcher) pollForChanges() { } } + if cw.apiTokensPath != "" { + if stat, err := os.Stat(cw.apiTokensPath); err == nil { + if stat.ModTime().After(cw.apiTokensLastModTime) { + log.Info().Msg("Detected API token file change via polling") + cw.apiTokensLastModTime = stat.ModTime() + cw.reloadAPITokens() + } + } + } + case <-cw.stopChan: return } @@ -229,7 +260,11 @@ func (cw *ConfigWatcher) reloadConfig() { // Update auth settings oldAuthUser := cw.config.AuthUser oldAuthPass := cw.config.AuthPass - oldAPIToken := cw.config.APIToken + oldTokenHashes := cw.config.ActiveAPITokenHashes() + existingByHash := make(map[string]APITokenRecord, len(cw.config.APITokens)) + for _, record := range cw.config.APITokens { + existingByHash[record.Hash] = record.Clone() + } // Apply auth user newUser := strings.Trim(envMap["PULSE_AUTH_USER"], "'\"") @@ -257,17 +292,85 @@ func (cw *ConfigWatcher) reloadConfig() { } } - // Apply API token - newToken := strings.Trim(envMap["API_TOKEN"], "'\"") - if newToken != oldAPIToken { - cw.config.APIToken = newToken - cw.config.APITokenEnabled = (newToken != "") - if newToken == "" { - changes = append(changes, "API token removed") - } else if oldAPIToken == "" { - changes = append(changes, "API token added") + // Apply API tokens if present in .env (legacy support) + rawTokens := make([]string, 0, 4) + if raw, ok := envMap["API_TOKENS"]; ok { + raw = strings.Trim(raw, "'\"") + if raw != "" { + parts := strings.Split(raw, ",") + for _, part := range parts { + token := strings.TrimSpace(part) + if token != "" { + rawTokens = append(rawTokens, token) + } + } } else { - changes = append(changes, "API token updated") + // Explicit empty list clears tokens + rawTokens = []string{} + } + } + if raw, ok := envMap["API_TOKEN"]; ok { + raw = strings.Trim(raw, "'\"") + rawTokens = append(rawTokens, raw) + } + + if len(rawTokens) > 0 { + seen := make(map[string]struct{}, len(rawTokens)) + newRecords := make([]APITokenRecord, 0, len(rawTokens)) + for _, tokenValue := range rawTokens { + tokenValue = strings.TrimSpace(tokenValue) + if tokenValue == "" { + continue + } + + hashed := tokenValue + prefix := tokenPrefix(tokenValue) + suffix := tokenSuffix(tokenValue) + if !auth.IsAPITokenHashed(tokenValue) { + hashed = auth.HashAPIToken(tokenValue) + prefix = tokenPrefix(tokenValue) + suffix = tokenSuffix(tokenValue) + } + + if _, exists := seen[hashed]; exists { + continue + } + seen[hashed] = struct{}{} + + if existing, ok := existingByHash[hashed]; ok { + newRecords = append(newRecords, existing) + } else { + newRecords = append(newRecords, APITokenRecord{ + ID: uuid.NewString(), + Name: "Environment token", + Hash: hashed, + Prefix: prefix, + Suffix: suffix, + CreatedAt: time.Now().UTC(), + }) + } + } + + cw.config.APITokens = newRecords + cw.config.SortAPITokens() + cw.config.APITokenEnabled = len(newRecords) > 0 + + newHashes := cw.config.ActiveAPITokenHashes() + if !reflect.DeepEqual(oldTokenHashes, newHashes) { + switch { + case len(newHashes) == 0: + changes = append(changes, "API tokens removed") + case len(oldTokenHashes) == 0: + changes = append(changes, "API tokens added") + default: + changes = append(changes, "API tokens updated") + } + + if globalPersistence != nil { + if err := globalPersistence.SaveAPITokens(cw.config.APITokens); err != nil { + log.Error().Err(err).Msg("Failed to persist API tokens from .env reload") + } + } } } @@ -279,13 +382,41 @@ func (cw *ConfigWatcher) reloadConfig() { log.Info(). Strs("changes", changes). Bool("has_auth", cw.config.AuthUser != "" && cw.config.AuthPass != ""). - Bool("has_token", cw.config.APIToken != ""). + Bool("has_token", cw.config.HasAPITokens()). Msg("Applied .env file changes to runtime config") } else { log.Debug().Msg("No relevant changes detected in .env file") } } +func (cw *ConfigWatcher) reloadAPITokens() { + cw.mu.Lock() + defer cw.mu.Unlock() + + if globalPersistence == nil { + log.Warn().Msg("Config persistence unavailable; cannot reload API tokens") + return + } + + tokens, err := globalPersistence.LoadAPITokens() + if err != nil { + log.Error().Err(err).Msg("Failed to reload API tokens") + return + } + + cw.config.APITokens = tokens + cw.config.SortAPITokens() + cw.config.APITokenEnabled = len(tokens) > 0 + + if cw.apiTokensPath != "" { + if stat, err := os.Stat(cw.apiTokensPath); err == nil { + cw.apiTokensLastModTime = stat.ModTime() + } + } + + log.Info().Int("count", len(tokens)).Msg("Reloaded API tokens from disk") +} + // reloadMockConfig handles mock.env file changes func (cw *ConfigWatcher) reloadMockConfig() { // Skip if mock.env watching is disabled (Docker environment) diff --git a/internal/models/converters.go b/internal/models/converters.go index dc693f3..d769551 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -205,6 +205,16 @@ func (d DockerHost) ToFrontend() DockerHostFrontend { h.DisplayName = h.Hostname } + if d.TokenID != "" { + h.TokenID = d.TokenID + h.TokenName = d.TokenName + h.TokenHint = d.TokenHint + if d.TokenLastUsedAt != nil && !d.TokenLastUsedAt.IsZero() { + ts := d.TokenLastUsedAt.Unix() * 1000 + h.TokenLastUsedAt = &ts + } + } + for i, ct := range d.Containers { h.Containers[i] = ct.ToFrontend() } diff --git a/internal/models/models.go b/internal/models/models.go index 4684e69..4e029b8 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -154,6 +154,10 @@ type DockerHost struct { IntervalSeconds int `json:"intervalSeconds"` AgentVersion string `json:"agentVersion,omitempty"` Containers []DockerContainer `json:"containers"` + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` } // DockerContainer represents the state of a Docker container on a monitored host. diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index fc1d07e..bbbf181 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -111,6 +111,10 @@ type DockerHostFrontend struct { IntervalSeconds int `json:"intervalSeconds"` AgentVersion string `json:"agentVersion,omitempty"` Containers []DockerContainerFrontend `json:"containers"` + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"` } // DockerContainerFrontend represents a Docker container for the frontend diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 6e37b56..9ddef92 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -344,8 +344,24 @@ func (m *Monitor) RemoveDockerHost(hostID string) (models.DockerHost, error) { return host, nil } +func tokenHintFromRecord(record *config.APITokenRecord) string { + if record == nil { + return "" + } + switch { + case record.Prefix != "" && record.Suffix != "": + return fmt.Sprintf("%s…%s", record.Prefix, record.Suffix) + case record.Prefix != "": + return record.Prefix + "…" + case record.Suffix != "": + return "…" + record.Suffix + default: + return "" + } +} + // ApplyDockerReport ingests a docker agent report into the shared state. -func (m *Monitor) ApplyDockerReport(report agentsdocker.Report) (models.DockerHost, error) { +func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *config.APITokenRecord) (models.DockerHost, error) { identifier := strings.TrimSpace(report.AgentKey()) if identifier == "" { return models.DockerHost{}, fmt.Errorf("docker report missing agent identifier") @@ -371,6 +387,16 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report) (models.DockerHo displayName = hostname } + var previous models.DockerHost + var hasPrevious bool + for _, existing := range m.state.GetDockerHosts() { + if existing.ID == identifier { + previous = existing + hasPrevious = true + break + } + } + containers := make([]models.DockerContainer, 0, len(report.Containers)) for _, payload := range report.Containers { container := models.DockerContainer{ @@ -448,6 +474,24 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report) (models.DockerHo Containers: containers, } + if tokenRecord != nil { + host.TokenID = tokenRecord.ID + host.TokenName = tokenRecord.Name + host.TokenHint = tokenHintFromRecord(tokenRecord) + if tokenRecord.LastUsedAt != nil { + t := tokenRecord.LastUsedAt.UTC() + host.TokenLastUsedAt = &t + } else { + t := time.Now().UTC() + host.TokenLastUsedAt = &t + } + } else if hasPrevious { + host.TokenID = previous.TokenID + host.TokenName = previous.TokenName + host.TokenHint = previous.TokenHint + host.TokenLastUsedAt = previous.TokenLastUsedAt + } + m.state.UpsertDockerHost(host) m.state.SetConnectionHealth(dockerConnectionPrefix+host.ID, true) @@ -2207,173 +2251,173 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie // Enabled by default (when nil or true) // Determine polling interval (default 5 minutes to avoid spinning up HDDs too frequently) pollingInterval := 5 * time.Minute - if instanceCfg.PhysicalDiskPollingMinutes > 0 { - pollingInterval = time.Duration(instanceCfg.PhysicalDiskPollingMinutes) * time.Minute - } - - // Check if enough time has elapsed since last poll - m.mu.Lock() - lastPoll, exists := m.lastPhysicalDiskPoll[instanceName] - shouldPoll := !exists || time.Since(lastPoll) >= pollingInterval - if shouldPoll { - m.lastPhysicalDiskPoll[instanceName] = time.Now() - } - m.mu.Unlock() - - if !shouldPoll { - log.Debug(). - Str("instance", instanceName). - Dur("sinceLastPoll", time.Since(lastPoll)). - Dur("interval", pollingInterval). - Msg("Skipping physical disk poll - interval not elapsed") - // Refresh NVMe temperatures using the latest sensor data even when we skip the disk poll - currentState := m.state.GetSnapshot() - existing := make([]models.PhysicalDisk, 0) - for _, disk := range currentState.PhysicalDisks { - if disk.Instance == instanceName { - existing = append(existing, disk) - } - } - if len(existing) > 0 { - updated := mergeNVMeTempsIntoDisks(existing, modelNodes) - m.state.UpdatePhysicalDisks(instanceName, updated) - } - } else { - log.Debug(). - Int("nodeCount", len(nodes)). - Dur("interval", pollingInterval). - Msg("Starting disk health polling") - - // Get existing disks from state to preserve data for offline nodes - currentState := m.state.GetSnapshot() - existingDisksMap := make(map[string]models.PhysicalDisk) - for _, disk := range currentState.PhysicalDisks { - if disk.Instance == instanceName { - existingDisksMap[disk.ID] = disk - } + if instanceCfg.PhysicalDiskPollingMinutes > 0 { + pollingInterval = time.Duration(instanceCfg.PhysicalDiskPollingMinutes) * time.Minute } - var allDisks []models.PhysicalDisk - polledNodes := make(map[string]bool) // Track which nodes we successfully polled - - for _, node := range nodes { - // Skip offline nodes but preserve their existing disk data - if node.Status != "online" { - log.Debug().Str("node", node.Node).Msg("Skipping disk poll for offline node - preserving existing data") - continue - } - - // Get disk list for this node - log.Debug().Str("node", node.Node).Msg("Getting disk list for node") - disks, err := client.GetDisks(ctx, node.Node) - if err != nil { - // Check if it's a permission error or if the endpoint doesn't exist - if strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "403") { - log.Warn(). - Str("node", node.Node). - Err(err). - Msg("Insufficient permissions to access disk information - check API token permissions") - } else if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "501") { - log.Info(). - Str("node", node.Node). - Msg("Disk monitoring not available on this node (may be using non-standard storage)") - } else { - log.Warn(). - Str("node", node.Node). - Err(err). - Msg("Failed to get disk list") - } - continue - } + // Check if enough time has elapsed since last poll + m.mu.Lock() + lastPoll, exists := m.lastPhysicalDiskPoll[instanceName] + shouldPoll := !exists || time.Since(lastPoll) >= pollingInterval + if shouldPoll { + m.lastPhysicalDiskPoll[instanceName] = time.Now() + } + m.mu.Unlock() + if !shouldPoll { log.Debug(). - Str("node", node.Node). - Int("diskCount", len(disks)). - Msg("Got disk list for node") + Str("instance", instanceName). + Dur("sinceLastPoll", time.Since(lastPoll)). + Dur("interval", pollingInterval). + Msg("Skipping physical disk poll - interval not elapsed") + // Refresh NVMe temperatures using the latest sensor data even when we skip the disk poll + currentState := m.state.GetSnapshot() + existing := make([]models.PhysicalDisk, 0) + for _, disk := range currentState.PhysicalDisks { + if disk.Instance == instanceName { + existing = append(existing, disk) + } + } + if len(existing) > 0 { + updated := mergeNVMeTempsIntoDisks(existing, modelNodes) + m.state.UpdatePhysicalDisks(instanceName, updated) + } + } else { + log.Debug(). + Int("nodeCount", len(nodes)). + Dur("interval", pollingInterval). + Msg("Starting disk health polling") - // Mark this node as successfully polled - polledNodes[node.Node] = true + // Get existing disks from state to preserve data for offline nodes + currentState := m.state.GetSnapshot() + existingDisksMap := make(map[string]models.PhysicalDisk) + for _, disk := range currentState.PhysicalDisks { + if disk.Instance == instanceName { + existingDisksMap[disk.ID] = disk + } + } - // Check each disk for health issues and add to state - for _, disk := range disks { - // Create PhysicalDisk model - diskID := fmt.Sprintf("%s-%s-%s", instanceName, node.Node, strings.ReplaceAll(disk.DevPath, "/", "-")) - physicalDisk := models.PhysicalDisk{ - ID: diskID, - Node: node.Node, - Instance: instanceName, - DevPath: disk.DevPath, - Model: disk.Model, - Serial: disk.Serial, - Type: disk.Type, - Size: disk.Size, - Health: disk.Health, - Wearout: disk.Wearout, - RPM: disk.RPM, - Used: disk.Used, - LastChecked: time.Now(), + var allDisks []models.PhysicalDisk + polledNodes := make(map[string]bool) // Track which nodes we successfully polled + + for _, node := range nodes { + // Skip offline nodes but preserve their existing disk data + if node.Status != "online" { + log.Debug().Str("node", node.Node).Msg("Skipping disk poll for offline node - preserving existing data") + continue } - allDisks = append(allDisks, physicalDisk) + // Get disk list for this node + log.Debug().Str("node", node.Node).Msg("Getting disk list for node") + disks, err := client.GetDisks(ctx, node.Node) + if err != nil { + // Check if it's a permission error or if the endpoint doesn't exist + if strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "403") { + log.Warn(). + Str("node", node.Node). + Err(err). + Msg("Insufficient permissions to access disk information - check API token permissions") + } else if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "501") { + log.Info(). + Str("node", node.Node). + Msg("Disk monitoring not available on this node (may be using non-standard storage)") + } else { + log.Warn(). + Str("node", node.Node). + Err(err). + Msg("Failed to get disk list") + } + continue + } log.Debug(). Str("node", node.Node). - Str("disk", disk.DevPath). - Str("model", disk.Model). - Str("health", disk.Health). - Int("wearout", disk.Wearout). - Msg("Checking disk health") + Int("diskCount", len(disks)). + Msg("Got disk list for node") - normalizedHealth := strings.ToUpper(strings.TrimSpace(disk.Health)) - if normalizedHealth != "" && normalizedHealth != "UNKNOWN" && normalizedHealth != "PASSED" && normalizedHealth != "OK" { - // Disk has failed or is failing - alert manager will handle this - log.Warn(). + // Mark this node as successfully polled + polledNodes[node.Node] = true + + // Check each disk for health issues and add to state + for _, disk := range disks { + // Create PhysicalDisk model + diskID := fmt.Sprintf("%s-%s-%s", instanceName, node.Node, strings.ReplaceAll(disk.DevPath, "/", "-")) + physicalDisk := models.PhysicalDisk{ + ID: diskID, + Node: node.Node, + Instance: instanceName, + DevPath: disk.DevPath, + Model: disk.Model, + Serial: disk.Serial, + Type: disk.Type, + Size: disk.Size, + Health: disk.Health, + Wearout: disk.Wearout, + RPM: disk.RPM, + Used: disk.Used, + LastChecked: time.Now(), + } + + allDisks = append(allDisks, physicalDisk) + + log.Debug(). Str("node", node.Node). Str("disk", disk.DevPath). Str("model", disk.Model). Str("health", disk.Health). Int("wearout", disk.Wearout). - Msg("Disk health issue detected") + Msg("Checking disk health") - // Pass disk info to alert manager - m.alertManager.CheckDiskHealth(instanceName, node.Node, disk) - } else if disk.Wearout > 0 && disk.Wearout < 10 { - // Low wearout warning (less than 10% life remaining) - log.Warn(). - Str("node", node.Node). - Str("disk", disk.DevPath). - Str("model", disk.Model). - Int("wearout", disk.Wearout). - Msg("SSD wearout critical - less than 10% life remaining") + normalizedHealth := strings.ToUpper(strings.TrimSpace(disk.Health)) + if normalizedHealth != "" && normalizedHealth != "UNKNOWN" && normalizedHealth != "PASSED" && normalizedHealth != "OK" { + // Disk has failed or is failing - alert manager will handle this + log.Warn(). + Str("node", node.Node). + Str("disk", disk.DevPath). + Str("model", disk.Model). + Str("health", disk.Health). + Int("wearout", disk.Wearout). + Msg("Disk health issue detected") - // Pass to alert manager for wearout alert - m.alertManager.CheckDiskHealth(instanceName, node.Node, disk) + // Pass disk info to alert manager + m.alertManager.CheckDiskHealth(instanceName, node.Node, disk) + } else if disk.Wearout > 0 && disk.Wearout < 10 { + // Low wearout warning (less than 10% life remaining) + log.Warn(). + Str("node", node.Node). + Str("disk", disk.DevPath). + Str("model", disk.Model). + Int("wearout", disk.Wearout). + Msg("SSD wearout critical - less than 10% life remaining") + + // Pass to alert manager for wearout alert + m.alertManager.CheckDiskHealth(instanceName, node.Node, disk) + } } } - } - // Preserve existing disk data for nodes that weren't polled (offline or error) - for _, existingDisk := range existingDisksMap { - // Only preserve if we didn't poll this node - if !polledNodes[existingDisk.Node] { - // Keep the existing disk data but update the LastChecked to indicate it's stale - allDisks = append(allDisks, existingDisk) - log.Debug(). - Str("node", existingDisk.Node). - Str("disk", existingDisk.DevPath). - Msg("Preserving existing disk data for unpolled node") + // Preserve existing disk data for nodes that weren't polled (offline or error) + for _, existingDisk := range existingDisksMap { + // Only preserve if we didn't poll this node + if !polledNodes[existingDisk.Node] { + // Keep the existing disk data but update the LastChecked to indicate it's stale + allDisks = append(allDisks, existingDisk) + log.Debug(). + Str("node", existingDisk.Node). + Str("disk", existingDisk.DevPath). + Msg("Preserving existing disk data for unpolled node") + } } - } - allDisks = mergeNVMeTempsIntoDisks(allDisks, modelNodes) + allDisks = mergeNVMeTempsIntoDisks(allDisks, modelNodes) - // Update physical disks in state - log.Debug(). - Str("instance", instanceName). - Int("diskCount", len(allDisks)). - Int("preservedCount", len(existingDisksMap)-len(polledNodes)). - Msg("Updating physical disks in state") - m.state.UpdatePhysicalDisks(instanceName, allDisks) + // Update physical disks in state + log.Debug(). + Str("instance", instanceName). + Int("diskCount", len(allDisks)). + Int("preservedCount", len(existingDisksMap)-len(polledNodes)). + Msg("Updating physical disks in state") + m.state.UpdatePhysicalDisks(instanceName, allDisks) } } // Note: Physical disk monitoring is now enabled by default with a 5-minute polling interval. diff --git a/scripts/context-audit-claude.sh b/scripts/context-audit-claude.sh new file mode 100755 index 0000000..6d87044 --- /dev/null +++ b/scripts/context-audit-claude.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Identify files that will bloat Claude Code's context window. + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT_DIR" + +echo "Scanning modified files that exceed size thresholds..." +echo + +git status --short | awk '{print $2}' | while read -r file; do + [ -f "$file" ] || continue + bytes=$(wc -c < "$file") + if [ "$bytes" -ge 65536 ]; then + lines=$(wc -l < "$file") + printf "%8d bytes %7d lines %s\n" "$bytes" "$lines" "$file" + fi +done + +echo +echo "Tip: stash or split these files when you do not need Claude to inspect them directly."