feat: streamline docker agent onboarding

This commit is contained in:
rcourtman
2025-10-14 09:45:32 +00:00
parent d3d4b9811a
commit 5c79d2516d
27 changed files with 1708 additions and 682 deletions

View File

@@ -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 <api-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|<api-token>`) or export `PULSE_TARGETS` before running the script:

View File

@@ -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:2300:33BST.
- 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 (100A car slots/free session, 25A emergency, 5A guard) and matching system logs.
- Defaults restored: overnight slot `23:3005: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 25A; EV guard sensor `off`.

View File

@@ -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

View File

@@ -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.

View File

@@ -858,15 +858,17 @@ function AppLayout(props: {
<button
type="button"
onClick={props.handleLogout}
class="text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors flex items-center gap-1"
class="group relative flex h-7 items-center justify-center gap-1 rounded-full bg-gray-200 px-2 text-xs text-gray-700 transition-all duration-500 ease-in-out hover:bg-gray-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
title="Logout"
aria-label="Logout"
>
<svg
class="h-3 w-3"
class="h-3 w-3 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
@@ -874,7 +876,11 @@ function AppLayout(props: {
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span>Logout</span>
<span
class="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-500 ease-in-out group-hover:ml-1 group-hover:max-w-[80px] group-hover:opacity-100 group-focus-visible:ml-1 group-focus-visible:max-w-[80px] group-focus-visible:opacity-100"
>
Logout
</span>
</button>
</Show>
</div>

View File

@@ -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<APITokenRecord[]> {
const response = await apiFetchJSON<{ tokens: APITokenRecord[] }>('/api/security/tokens');
return response.tokens ?? [];
}
static async createToken(name?: string): Promise<CreateAPITokenResponse> {
return apiFetchJSON<CreateAPITokenResponse>('/api/security/tokens', {
method: 'POST',
body: JSON.stringify({ name }),
});
}
static async deleteToken(id: string): Promise<void> {
await apiFetchJSON(`/api/security/tokens/${id}`, {
method: 'DELETE',
});
}
}

View File

@@ -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<APITokenManagerProps> = (props) => {
const [tokens, setTokens] = createSignal<APITokenRecord[]>([]);
const [loading, setLoading] = createSignal(true);
const [isGenerating, setIsGenerating] = createSignal(false);
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
const [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 (
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-lg">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<SectionHeader
title="API tokens"
description="Generate or revoke access tokens for automation and agents"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-6">
<div class="text-xs text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
Issue a dedicated token for each host or automation. That way, if a system is compromised, you can revoke just its token without disrupting anything else.
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300" for="api-token-name">
Token name
</label>
<input
id="api-token-name"
type="text"
value={nameInput()}
onInput={(event) => 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"
/>
<button
type="button"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleGenerate}
disabled={isGenerating()}
>
{isGenerating() ? 'Generating…' : 'Generate API token'}
</button>
</div>
<Show when={props.currentTokenHint && !tokens().length}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Current token hint: <span class="font-mono">{props.currentTokenHint}</span>
</p>
</Show>
<Show when={newTokenValue()}>
<div class="space-y-3">
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<h4 class="text-sm font-semibold text-green-800 dark:text-green-200 mb-2"> New API token generated</h4>
<p class="text-xs text-green-700 dark:text-green-300">
Save this value now it is only shown once. Update your automation or agents immediately.
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<div class="flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{newTokenValue()}
</code>
<button
type="button"
onClick={handleCopy}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
</div>
</Show>
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Active tokens</h3>
<Show
when={!loading() && tokens().length > 0}
fallback={
<p class="text-sm text-gray-600 dark:text-gray-400">
No API tokens yet. Generate one above to get started.
</p>
}
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Label</th>
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Token hint</th>
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Created</th>
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Last used</th>
<th class="py-2 px-3 text-right font-medium text-gray-600 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={tokens()}>
{(token) => (
<tr>
<td class="py-2 px-3 text-gray-900 dark:text-gray-100">{token.name || 'Untitled token'}</td>
<td class="py-2 px-3 font-mono text-xs text-gray-600 dark:text-gray-400">{tokenHint(token)}</td>
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
{formatRelativeTime(token.createdAt)}
</td>
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
{token.lastUsedAt ? formatRelativeTime(token.lastUsedAt) : 'Never'}
</td>
<td class="py-2 px-3 text-right">
<button
type="button"
onClick={() => handleDelete(token)}
class="inline-flex items-center px-3 py-1 text-xs font-medium text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
>
Revoke
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</div>
</Card>
);
};

View File

@@ -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<string | null>(null);
const [apiToken, setApiToken] = createSignal<string | null>(null);
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatus | null>(null);
const [availableTokens, setAvailableTokens] = createSignal<APITokenRecord[]>([]);
const [loadingTokens, setLoadingTokens] = createSignal(false);
const [tokensLoaded, setTokensLoaded] = createSignal(false);
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(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<boolean> => {
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 */}
<Show when={showInstructions()}>
<Card class="space-y-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
Deploy the Pulse Docker agent
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Run this command on your Docker host. If you're not root (most cases), add <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">sudo</code> before <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">bash</code>. If you're already root (e.g., in a container), the command works as-is.
</p>
<Card class="space-y-6">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Deploy the Pulse Docker agent</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Follow the steps below to create a token, build the install command, and confirm the host is reporting.
</p>
</div>
{/* Quick Install - One-liner */}
<div class="space-y-2">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Quick install (one command)
</h4>
<section class="space-y-3">
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Step 1 · Token</p>
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">Generate or reuse a host token</p>
<p class="text-xs text-gray-600 dark:text-gray-400">
Use one API token per host. If that host is ever compromised you can revoke it without touching other machines.
</p>
</div>
<button
type="button"
onClick={handleCreateToken}
disabled={isGeneratingToken()}
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
</div>
<Show when={newTokenValue() && newTokenRecord()}>
<div class="space-y-2 rounded-lg border border-green-200 bg-green-50/80 p-4 dark:border-green-800 dark:bg-green-900/10">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-semibold text-green-800 dark:text-green-200">Token generated</p>
<p class="text-xs text-green-700 dark:text-green-300">
{newTokenRecord()?.name || 'Untitled token'} will only be shown once. Copy it into the install command below.
</p>
</div>
<button
type="button"
onClick={handleCopyGeneratedToken}
class="inline-flex items-center justify-center rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700"
>
{copiedGeneratedToken() ? 'Copied!' : 'Copy token'}
</button>
</div>
<code class="block break-all rounded border border-green-200 bg-white px-3 py-2 font-mono text-sm dark:border-green-800 dark:bg-green-900/40">
{newTokenValue()}
</code>
</div>
</Show>
<Show when={securityStatus()?.apiTokenConfigured && securityStatus()?.apiTokenHint}>
<div class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
Existing token hint: <span class="font-mono">{securityStatus()?.apiTokenHint}</span>. Paste the full value if you want to reuse it.
</div>
</Show>
<Show when={availableTokens().length > 0}>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
View saved tokens
</summary>
<div class="mt-3 overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="py-2 px-2 text-left font-medium text-gray-600 dark:text-gray-400">Name</th>
<th class="py-2 px-2 text-left font-medium text-gray-600 dark:text-gray-400">Hint</th>
<th class="py-2 px-2 text-left font-medium text-gray-600 dark:text-gray-400">Last used</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={availableTokens()}>
{(token) => (
<tr>
<td class="py-2 px-2 text-gray-900 dark:text-gray-100">{tokenDisplayLabel(token)}</td>
<td class="py-2 px-2 font-mono text-gray-600 dark:text-gray-400">
{token.prefix && token.suffix ? `${token.prefix}${token.suffix}` : '—'}
</td>
<td class="py-2 px-2 text-gray-600 dark:text-gray-400">
{token.lastUsedAt ? formatRelativeTime(token.lastUsedAt) : 'Never'}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</details>
</Show>
</div>
</section>
<section class="space-y-3">
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Step 2 · Build the command</p>
<CommandBuilder
command={getInstallCommandTemplate()}
placeholder={TOKEN_PLACEHOLDER}
@@ -200,115 +360,95 @@ WantedBy=multi-user.target`;
hasExistingToken={Boolean(securityStatus()?.apiTokenConfigured)}
onTokenGenerated={(token) => {
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);
}
}}
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
The script downloads the agent, creates a systemd service, and starts monitoring automatically.
<Show when={!requiresToken()}>
<span class="ml-1 font-medium">Authentication is disabled, so the agent runs without an API token.</span>
</Show>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
If you already installed the agent from another Pulse instance, running its command again on this host simply adds that server to the same agentno duplicate processes to clean up.
Run the command as root (prepend <code class="rounded bg-gray-100 px-1 dark:bg-gray-800">sudo</code> when needed). The installer downloads the agent, creates a systemd service, and starts reporting automatically.
</p>
</div>
</section>
{/* Uninstall */}
<div class="space-y-2 border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Uninstall the agent
</h4>
<button
type="button"
onClick={() => copyToClipboard(getUninstallCommand())}
class="px-3 py-1 text-xs font-medium text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
title="Copy to clipboard"
>
Copy command
</button>
</div>
<div class="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<code class="text-sm text-red-400 font-mono">{getUninstallCommand()}</code>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
This will stop the agent, remove the binary, service file, and all configuration.
</p>
</div>
<section class="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
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{' '}
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono dark:bg-blue-900/40">journalctl -u pulse-docker-agent -f</code>.
</section>
{/* Manual Installation */}
<details class="border-t border-gray-200 dark:border-gray-700 pt-4">
<summary class="text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400">
Manual installation (advanced)
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
Advanced options (uninstall & manual install)
</summary>
<div class="mt-4 space-y-4">
{/* Step 1: Build or download */}
<div class="mt-3 space-y-4">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
1. Build the agent binary
</h4>
<div class="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<code class="text-sm text-gray-100 font-mono">
cd /opt/pulse
<br />
GOOS=linux GOARCH=amd64 go build -o pulse-docker-agent ./cmd/pulse-docker-agent
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Uninstall</p>
<div class="mt-2 flex items-center gap-2">
<code class="flex-1 break-all rounded bg-gray-900 px-3 py-2 font-mono text-xs text-red-400 dark:bg-gray-950">
{getUninstallCommand()}
</code>
</div>
</div>
{/* Step 2: Copy to host */}
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
2. Copy to Docker host
</h4>
<div class="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<code class="text-sm text-gray-100 font-mono">
scp pulse-docker-agent user@docker-host:/usr/local/bin/
<br />
ssh user@docker-host chmod +x /usr/local/bin/pulse-docker-agent
</code>
</div>
</div>
{/* Step 3: Create systemd service */}
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
3. Create systemd service file
</h4>
<div class="relative">
<button
type="button"
onClick={() => copyToClipboard(getSystemdService())}
class="absolute top-2 right-2 px-3 py-1 text-xs font-medium text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded transition-colors z-10"
title="Copy to clipboard"
onClick={async () => {
const success = await copyToClipboard(getUninstallCommand());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
class="rounded bg-red-50 px-3 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50"
>
Copy
</button>
<div class="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm text-gray-100 font-mono">{getSystemdService()}</pre>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Save to <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">/etc/systemd/system/pulse-docker-agent.service</code> and replace{' '}
<code class="px-1 bg-gray-100 dark:bg-gray-800 rounded">{TOKEN_PLACEHOLDER}</code> with a valid API token.
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Stops the agent, removes the binary, the systemd unit, and related files.
</p>
</div>
{/* Step 4: Enable and start */}
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
4. Enable and start
</h4>
<div class="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<code class="text-sm text-gray-100 font-mono">
systemctl daemon-reload
<br />
systemctl enable --now pulse-docker-agent
</code>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manual installation</p>
<div class="mt-2 space-y-3 rounded-lg border border-gray-200 bg-white p-3 text-xs dark:border-gray-700 dark:bg-gray-900">
<p class="font-medium text-gray-900 dark:text-gray-100">1. Build the binary</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
cd /opt/pulse
<br />
GOOS=linux GOARCH=amd64 go build -o pulse-docker-agent ./cmd/pulse-docker-agent
</code>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">2. Copy to host</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
scp pulse-docker-agent user@docker-host:/usr/local/bin/
<br />
ssh user@docker-host chmod +x /usr/local/bin/pulse-docker-agent
</code>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">3. Systemd template</p>
<div class="relative">
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(getSystemdService());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
class="absolute right-2 top-2 rounded bg-gray-700 px-3 py-1 text-xs font-medium text-gray-200 transition-colors hover:bg-gray-600"
>
Copy
</button>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<pre>{getSystemdService()}</pre>
</div>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">4. Enable & start</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
systemctl daemon-reload
<br />
systemctl enable --now pulse-docker-agent
</code>
</div>
</div>
</div>
</div>

View File

@@ -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<GenerateAPITokenProps> = (props) => {
const [isGenerating, setIsGenerating] = createSignal(false);
const [newToken, setNewToken] = createSignal<string | null>(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 (
<div class="space-y-4">
<Show when={!showToken()}>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
API Token Active
</h4>
<Show when={currentHint() && currentHint().length > 0}>
<div class="mb-3 px-3 py-2 bg-gray-800 dark:bg-gray-950 rounded">
<code class="text-xs text-gray-300 font-mono">Current token: {currentHint()}</code>
</div>
</Show>
<p class={`${formHelpText} mb-4`}>
An API token is configured for this instance. Use it with the X-API-Token header for
automation.
</p>
<button
type="button"
onClick={() => setShowConfirm(true)}
disabled={isGenerating()}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isGenerating() ? 'Generating...' : 'Generate New Token'}
</button>
</div>
</Show>
<Show when={showToken() && newToken()}>
<div class="space-y-4">
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<h4 class="text-sm font-semibold text-green-800 dark:text-green-200 mb-2">
New API Token Generated!
</h4>
<p class="text-xs text-green-700 dark:text-green-300">
Save this token now - it will never be shown again!
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<div class={formField}>
<label class={labelClass('text-xs')}>Your new API token</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{newToken()}
</code>
<button
type="button"
onClick={handleCopy}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div class="flex items-start space-x-2">
<svg
class="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="text-xs text-blue-700 dark:text-blue-300">
<p class="font-semibold">Token Active Immediately!</p>
<p class="mt-1">Your new API token is active and ready to use.</p>
<p class="mt-1 text-blue-600 dark:text-blue-400">
The old token (if any) has been invalidated.
</p>
</div>
</div>
</div>
<button
type="button"
onClick={() => {
setShowToken(false);
setNewToken(null);
}}
class="px-4 py-2 bg-gray-600 text-white text-sm rounded-lg hover:bg-gray-700 transition-colors"
>
Done
</button>
</div>
</Show>
<Show when={showConfirm()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<SectionHeader title="Generate new API token?" size="md" class="mb-4" />
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
This will generate a new API token and{' '}
<span class="font-semibold text-red-600 dark:text-red-400">
immediately invalidate the current token
</span>
. Any scripts or integrations using the old token will stop working.
</p>
<div class="flex gap-3 justify-end">
<button
type="button"
onClick={() => setShowConfirm(false)}
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={generateNewToken}
class="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
Generate New Token
</button>
</div>
</div>
</div>
</Show>
</div>
);
};

View File

@@ -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<SettingsProps> = (props) => {
{/* Content */}
<div class="p-6">
<GenerateAPIToken currentTokenHint={securityStatus()?.apiTokenHint} />
<APITokenManager currentTokenHint={securityStatus()?.apiTokenHint} />
</div>
</Card>
</Show>

View File

@@ -129,6 +129,10 @@ export interface DockerHost {
intervalSeconds: number;
agentVersion?: string;
containers: DockerContainer[];
tokenId?: string;
tokenName?: string;
tokenHint?: string;
tokenLastUsedAt?: number;
}
export interface DockerContainer {

View File

@@ -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 <token> 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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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, ","))
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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 = ""
}
}

View File

@@ -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")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

22
scripts/context-audit-claude.sh Executable file
View File

@@ -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."