mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat: streamline docker agent onboarding
This commit is contained in:
@@ -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:
|
||||
|
||||
15
docs/development/HOME_ASSISTANT_NOTES.md
Normal file
15
docs/development/HOME_ASSISTANT_NOTES.md
Normal 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:23–00:33 BST.
|
||||
- Fixes applied:
|
||||
- Restored automation backups.
|
||||
- Replaced `pyscript.solis_set_charge_current` calls with `number.set_value` targeting `number.solis_rhi_time_charging_charge_current`.
|
||||
- Removed invalid `source:` attributes from `number.set_value` actions.
|
||||
- Validation:
|
||||
- Triggered key automations (`automation.free_electricity_session_maximum_charging`, `automation.intelligent_dispatch_solis_battery_charging`, `automation.emergency_low_soc_protection`, EV guard hold/refresh/release).
|
||||
- Observed expected charge-current adjustments (100 A car slots/free session, 25 A emergency, 5 A guard) and matching system logs.
|
||||
- Defaults restored: overnight slot `23:30–05:30`.
|
||||
- Backups: `/var/lib/docker/volumes/hass_config/_data/automations.yaml.codex_source_cleanup_20251010_090205` (do not delete).
|
||||
- Testing helpers: API token stored at `/tmp/ha_token.txt` inside VM; use `pct exec 101 -- bash -lc '...'` for commands.
|
||||
- Final state: charge current reset to 25 A; EV guard sensor `off`.
|
||||
@@ -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
|
||||
|
||||
11
docs/development/PIHOLE_SYNC.md
Normal file
11
docs/development/PIHOLE_SYNC.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
35
frontend-modern/src/api/security.ts
Normal file
35
frontend-modern/src/api/security.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
247
frontend-modern/src/components/Settings/APITokenManager.tsx
Normal file
247
frontend-modern/src/components/Settings/APITokenManager.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 agent—no 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -129,6 +129,10 @@ export interface DockerHost {
|
||||
intervalSeconds: number;
|
||||
agentVersion?: string;
|
||||
containers: DockerContainer[];
|
||||
tokenId?: string;
|
||||
tokenName?: string;
|
||||
tokenHint?: string;
|
||||
tokenLastUsedAt?: number;
|
||||
}
|
||||
|
||||
export interface DockerContainer {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ","))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
135
internal/api/security_tokens.go
Normal file
135
internal/api/security_tokens.go
Normal 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)
|
||||
}
|
||||
195
internal/config/api_tokens.go
Normal file
195
internal/config/api_tokens.go
Normal 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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
22
scripts/context-audit-claude.sh
Executable 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."
|
||||
Reference in New Issue
Block a user