Add AI monitoring enhancements and host metadata features

- Add host metadata API for custom URL editing on hosts page
- Enhance AI routing with unified resource provider lookup
- Add encryption key watcher script for debugging key issues
- Improve AI service with better command timeout handling
- Update dev environment workflow with key monitoring docs
- Fix resource store deduplication logic
This commit is contained in:
rcourtman
2025-12-09 16:27:46 +00:00
parent 927ac76bad
commit c8adbb7ae5
17 changed files with 1101 additions and 110 deletions

View File

@@ -44,3 +44,20 @@ journalctl -u pulse-hot-dev -f
- **Hot-dev script**: `/opt/pulse/scripts/hot-dev.sh`
- **Systemd service**: `/etc/systemd/system/pulse-hot-dev.service`
- **Makefile targets**: `make dev` or `make dev-hot`
## Encryption Key Monitoring
A watcher service monitors the encryption key file for any changes or deletions:
```bash
# Check if the watcher is running
systemctl status encryption-key-watcher
# View recent encryption key events
sudo journalctl -u encryption-key-watcher -n 50
# View events around a specific time
sudo journalctl -u encryption-key-watcher --since "2025-12-09 14:00" --until "2025-12-09 15:00"
```
If the key ever goes missing, the logs will show what event happened and which processes had files open in `/etc/pulse` at that time.

View File

@@ -0,0 +1,42 @@
// Host Metadata API
import { apiFetchJSON } from '@/utils/apiClient';
export interface HostMetadata {
id: string;
customUrl?: string;
description?: string;
tags?: string[];
notes?: string[]; // User annotations for AI context
}
export class HostMetadataAPI {
private static baseUrl = '/api/hosts/metadata';
// Get metadata for a specific host
static async getMetadata(hostId: string): Promise<HostMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`);
}
// Get all host metadata
static async getAllMetadata(): Promise<Record<string, HostMetadata>> {
return apiFetchJSON(this.baseUrl);
}
// Update metadata for a host
static async updateMetadata(
hostId: string,
metadata: Partial<HostMetadata>,
): Promise<HostMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`, {
method: 'PUT',
body: JSON.stringify(metadata),
});
}
// Delete metadata for a host
static async deleteMetadata(hostId: string): Promise<void> {
await apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`, {
method: 'DELETE',
});
}
}

View File

@@ -546,6 +546,19 @@ export const AIChat: Component<AIChatProps> = (props) => {
// Move from pending approvals to completed tool calls
const currentMessages = messages();
const targetMessage = currentMessages.find((m) => m.id === messageId);
const pendingCount = targetMessage?.pendingApprovals?.length || 0;
const remainingAfterThis = (targetMessage?.pendingApprovals?.filter((a) => a.toolId !== approval.toolId) || []).length;
logger.info('[AIChat] Approval processed', {
messageId,
toolId: approval.toolId,
pendingCount,
remainingAfterThis,
pendingApprovals: targetMessage?.pendingApprovals?.map(a => a.toolId)
});
setMessages((prev) =>
prev.map((m) => {
if (m.id !== messageId) return m;
@@ -570,10 +583,152 @@ export const AIChat: Component<AIChatProps> = (props) => {
})
);
if (result.success) {
notificationStore.success('Command executed successfully');
// No toast for success - the tool output shows the result inline
// Only show error toast for failures since they might need attention
if (!result.success && result.error) {
notificationStore.error(result.error);
}
// After the last approval is processed, automatically continue the conversation
// This lets the AI analyze the command output and provide a summary
if (remainingAfterThis === 0) {
logger.info('[AIChat] Last approval processed, triggering auto-continuation');
// Small delay to let the UI update first
setTimeout(async () => {
logger.info('[AIChat] Starting auto-continuation');
setIsLoading(true);
// Build history including the just-executed command
const currentMsgs = messages();
logger.debug('[AIChat] Building history for continuation', { messageCount: currentMsgs.length });
const historyForContinuation = currentMsgs
.filter((m) => !m.isStreaming)
.filter((m) => m.content || (m.toolCalls && m.toolCalls.length > 0))
.map((m) => {
let content = m.content || '';
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
const toolSummary = m.toolCalls
.map((tc) => `Command: ${tc.input}\nOutput: ${tc.output}`)
.join('\n\n');
content = toolSummary + (content ? '\n\n' + content : '');
}
return { role: m.role, content };
})
.filter((m) => m.content);
logger.debug('[AIChat] History for continuation built', { historyLength: historyForContinuation.length });
// Add a hidden continuation prompt - the AI will see it but user won't
const continuationPrompt = 'Continue analyzing the command output above and provide a summary.';
// Create the streaming assistant response message (no visible user message)
// Show "Analyzing..." as initial content so user sees inline feedback
const assistantId = generateId();
const streamingMessage: Message = {
id: assistantId,
role: 'assistant',
content: '*Analyzing results...*',
timestamp: new Date(),
isStreaming: true,
pendingTools: [],
pendingApprovals: [],
toolCalls: [],
};
setMessages((prev) => [...prev, streamingMessage]);
try {
logger.info('[AIChat] Calling executeStream for continuation');
await AIAPI.executeStream(
{
prompt: continuationPrompt,
target_type: targetType(),
target_id: targetId(),
context: contextData(),
history: historyForContinuation,
},
(event: AIStreamEvent) => {
logger.debug('[AIChat] Continuation event received', { type: event.type });
setMessages((prev) =>
prev.map((msg) => {
if (msg.id !== assistantId) return msg;
switch (event.type) {
case 'content':
return { ...msg, content: event.data as string, isStreaming: false };
case 'done':
return { ...msg, isStreaming: false };
case 'error':
return { ...msg, content: `Error: ${event.data}`, isStreaming: false };
case 'thinking':
// Ignore thinking events for now
return msg;
case 'processing':
// Ignore processing events
return msg;
case 'tool_start': {
const data = event.data as { name: string; input: string };
return {
...msg,
pendingTools: [...(msg.pendingTools || []), { name: data.name, input: data.input }],
};
}
case 'tool_end': {
const data = event.data as { name: string; input: string; output: string; success: boolean };
const pendingTools = msg.pendingTools || [];
const matchingIndex = pendingTools.findIndex((t) => t.name === data.name);
const updatedPending = matchingIndex >= 0
? [...pendingTools.slice(0, matchingIndex), ...pendingTools.slice(matchingIndex + 1)]
: pendingTools;
return {
...msg,
pendingTools: updatedPending,
toolCalls: [...(msg.toolCalls || []), {
name: data.name,
input: data.input,
output: data.output,
success: data.success,
}],
};
}
case 'approval_needed': {
const data = event.data as AIStreamApprovalNeededData;
logger.info('[AIChat] Approval needed in continuation', { command: data.command });
return {
...msg,
pendingApprovals: [...(msg.pendingApprovals || []), {
command: data.command,
toolId: data.tool_id,
toolName: data.tool_name,
runOnHost: data.run_on_host,
targetHost: data.target_host,
}],
isStreaming: false, // Stop streaming when approval is needed
};
}
default:
logger.debug('[AIChat] Unhandled continuation event', { type: event.type, event });
return msg;
}
})
);
}
);
logger.info('[AIChat] Continuation executeStream completed');
} catch (err) {
logger.error('[AIChat] Failed to continue after approval:', err);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantId
? { ...msg, content: 'Failed to analyze results.', isStreaming: false }
: msg
)
);
} finally {
setIsLoading(false);
}
}, 200);
} else {
notificationStore.error(result.error || 'Command failed');
logger.debug('[AIChat] Approvals remaining, not triggering continuation', { remainingAfterThis });
}
} catch (error) {
logger.error('[AIChat] Failed to execute approved command:', error);

View File

@@ -1195,13 +1195,10 @@ const DockerContainerRow: Component<{
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
<span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-text select-none truncate"
style="cursor: text;"
title={`${containerTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
data-resource-name-editable
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate"
title={containerTitle()}
>
{container.name || container.id}
</span>
@@ -1231,6 +1228,17 @@ const DockerContainerRow: Component<{
</svg>
</a>
</Show>
{/* Edit URL button - shows on hover */}
<button
type="button"
onClick={startEditingUrl}
class="flex-shrink-0 opacity-0 group-hover/name:opacity-100 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-all"
title={customUrl() ? 'Edit URL' : 'Add URL'}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<Show when={props.showHostContext}>
<span
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300 flex-shrink-0 max-w-[120px]"
@@ -2078,13 +2086,10 @@ const DockerServiceRow: Component<{
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
<span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-text select-none"
style="cursor: text;"
title={`${serviceTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
data-resource-name-editable
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none"
title={serviceTitle()}
>
{service.name || service.id || 'Service'}
</span>
@@ -2102,6 +2107,17 @@ const DockerServiceRow: Component<{
</svg>
</a>
</Show>
{/* Edit URL button - shows on hover */}
<button
type="button"
onClick={startEditingUrl}
class="flex-shrink-0 opacity-0 group-hover/name:opacity-100 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-all"
title={customUrl() ? 'Edit URL' : 'Add URL'}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<Show when={service.stack && !isEditingUrl()}>
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
Stack: {service.stack}

View File

@@ -1,5 +1,5 @@
import type { Component } from 'solid-js';
import { For, Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
import { For, Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { useNavigate } from '@solidjs/router';
import type { Host, HostRAIDArray } from '@/types/api';
@@ -19,6 +19,8 @@ import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { aiChatStore } from '@/stores/aiChat';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { useResourcesAsLegacy } from '@/hooks/useResources';
import { HostMetadataAPI, type HostMetadata } from '@/api/hostMetadata';
import { logger } from '@/utils/logger';
// Column definition for hosts table
export interface HostColumnDef {
@@ -492,6 +494,64 @@ export const HostsOverview: Component<HostsOverviewProps> = () => {
const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id));
const isColVisible = (colId: string) => visibleColumnIds().includes(colId);
// Host metadata management (for custom URLs)
const [hostMetadata, setHostMetadata] = createSignal<Record<string, HostMetadata>>({});
const [hostMetadataVersion, setHostMetadataVersion] = createSignal(0);
// Load host metadata on mount
createEffect(() => {
HostMetadataAPI.getAllMetadata()
.then(data => {
setHostMetadata(data || {});
logger.debug('Loaded host metadata', { count: Object.keys(data || {}).length });
})
.catch(err => {
logger.warn('Failed to load host metadata', { error: err });
});
});
// Get custom URL for a host
const getHostCustomUrl = (hostId: string): string | undefined => {
// Access version to trigger reactivity when metadata changes
hostMetadataVersion();
return hostMetadata()[hostId]?.customUrl;
};
// Update custom URL for a host
const updateHostCustomUrl = async (hostId: string, url: string): Promise<boolean> => {
try {
await HostMetadataAPI.updateMetadata(hostId, { customUrl: url });
setHostMetadata(prev => ({
...prev,
[hostId]: { ...prev[hostId], id: hostId, customUrl: url }
}));
setHostMetadataVersion(v => v + 1);
logger.info('Updated host custom URL', { hostId, url });
return true;
} catch (err) {
logger.error('Failed to update host custom URL', { hostId, url, error: err });
return false;
}
};
// Delete custom URL for a host
const deleteHostCustomUrl = async (hostId: string): Promise<boolean> => {
try {
await HostMetadataAPI.deleteMetadata(hostId);
setHostMetadata(prev => {
const next = { ...prev };
delete next[hostId];
return next;
});
setHostMetadataVersion(v => v + 1);
logger.info('Deleted host custom URL', { hostId });
return true;
} catch (err) {
logger.error('Failed to delete host custom URL', { hostId, error: err });
return false;
}
};
const handleSort = (key: SortKey) => {
if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
@@ -797,7 +857,7 @@ export const HostsOverview: Component<HostsOverviewProps> = () => {
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredHosts()}>
{(host) => <HostRow host={host} isColVisible={isColVisible} isMobile={isMobile} getDiskStats={getDiskStats} />}
{(host) => <HostRow host={host} isColVisible={isColVisible} isMobile={isMobile} getDiskStats={getDiskStats} customUrl={getHostCustomUrl(host.id)} onUpdateCustomUrl={updateHostCustomUrl} onDeleteCustomUrl={deleteHostCustomUrl} />}
</For>
</tbody>
</table>
@@ -846,8 +906,10 @@ interface HostRowProps {
host: Host;
isColVisible: (colId: string) => boolean;
isMobile: () => boolean;
getDiskStats: (host: Host) => { percent: number; used: number; total: number };
customUrl?: string;
onUpdateCustomUrl: (hostId: string, url: string) => Promise<boolean>;
onDeleteCustomUrl: (hostId: string) => Promise<boolean>;
}
const HostRow: Component<HostRowProps> = (props) => {
@@ -856,6 +918,43 @@ const HostRow: Component<HostRowProps> = (props) => {
// Check if this host is in AI context
const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(host.id));
// URL editing state
const [isEditingUrl, setIsEditingUrl] = createSignal(false);
const [editingUrlValue, setEditingUrlValue] = createSignal('');
const [isSavingUrl, setIsSavingUrl] = createSignal(false);
let urlInputRef: HTMLInputElement | undefined;
// Start editing URL
const startEditingUrl = (e: MouseEvent) => {
e.stopPropagation();
setEditingUrlValue(props.customUrl || '');
setIsEditingUrl(true);
// Focus input after render
setTimeout(() => urlInputRef?.focus(), 0);
};
// Save URL
const saveUrl = async () => {
const url = editingUrlValue().trim();
setIsSavingUrl(true);
try {
if (url) {
await props.onUpdateCustomUrl(host.id, url);
} else {
await props.onDeleteCustomUrl(host.id);
}
setIsEditingUrl(false);
} finally {
setIsSavingUrl(false);
}
};
// Cancel editing
const cancelEditingUrl = () => {
setIsEditingUrl(false);
setEditingUrlValue('');
};
// Build context for AI - includes routing fields
const buildHostContext = (): Record<string, unknown> => ({
hostName: host.displayName || host.hostname,
@@ -873,7 +972,7 @@ const HostRow: Component<HostRowProps> = (props) => {
// Handle row click - toggle AI context selection
const handleRowClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest('a, button, [data-prevent-toggle]')) {
if (target.closest('a, button, [data-prevent-toggle], [data-url-editor]')) {
return;
}
@@ -914,31 +1013,119 @@ const HostRow: Component<HostRowProps> = (props) => {
ariaLabel={hostStatus().label}
size="xs"
/>
<div class="min-w-0 flex items-center gap-1.5">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
{host.displayName || host.hostname || host.id}
</p>
<Show when={host.displayName && host.displayName !== host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{host.hostname}
</p>
</Show>
<Show when={host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
Updated {formatRelativeTime(host.lastSeen!)}
</p>
</Show>
<Show
when={isEditingUrl()}
fallback={
<div class="min-w-0 flex items-center gap-1.5 group/name">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
{host.displayName || host.hostname || host.id}
</p>
<Show when={host.displayName && host.displayName !== host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{host.hostname}
</p>
</Show>
<Show when={host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
Updated {formatRelativeTime(host.lastSeen!)}
</p>
</Show>
</div>
{/* Custom URL link */}
<Show when={props.customUrl}>
<a
href={props.customUrl}
target="_blank"
rel="noopener noreferrer"
class="flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
title={`Open ${props.customUrl}`}
onClick={(event) => event.stopPropagation()}
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
{/* Edit URL button - shows on hover */}
<button
type="button"
onClick={startEditingUrl}
class="flex-shrink-0 opacity-0 group-hover/name:opacity-100 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-all"
title={props.customUrl ? 'Edit URL' : 'Add URL'}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
{/* AI context indicator */}
<Show when={isInAIContext()}>
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
</svg>
</span>
</Show>
</div>
}
>
{/* URL editing mode */}
<div class="flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
onInput={(e) => setEditingUrlValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://192.168.1.100:8080"
class="w-40 px-2 py-0.5 text-xs border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={isSavingUrl()}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
disabled={isSavingUrl()}
class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
title="Save (Enter)"
>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
cancelEditingUrl();
}}
disabled={isSavingUrl()}
class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-xs bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors disabled:opacity-50"
title="Cancel (Esc)"
>
</button>
</div>
{/* AI context indicator */}
<Show when={isInAIContext()}>
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
</svg>
</span>
</Show>
</div>
</Show>
</div>
</td>

View File

@@ -22,6 +22,9 @@ type ResourceProvider interface {
GetTopByDisk(limit int, types []resources.ResourceType) []resources.Resource
GetRelated(resourceID string) map[string][]resources.Resource
GetResourceSummary() resources.ResourceSummary
// AI Routing support
FindContainerHost(containerNameOrID string) string
}
// SetResourceProvider sets the resource provider for unified infrastructure context.

View File

@@ -59,11 +59,10 @@ func (e *RoutingError) ForAI() string {
// This is the authoritative routing function that should be used for all command execution.
//
// Routing priority:
// 1. VMID lookup from state (most reliable for pct/qm commands)
// 2. Explicit "node" field in context
// 3. Explicit "guest_node" field in context
// 4. "hostname" field for host targets
// 5. VMID extracted from target ID (last resort)
// 1. VMID lookup from command (for pct/qm commands)
// 2. Unified ResourceProvider lookup (PRIMARY - uses the new infrastructure model)
// 3. Explicit context fields (FALLBACK - for backwards compatibility)
// 4. VMID extracted from target ID
//
// Agent matching is EXACT only - no substring matching to prevent false positives.
// If no direct match, cluster peer routing is attempted.
@@ -140,51 +139,58 @@ func (s *Service) routeToAgent(req ExecuteRequest, command string, agents []agen
}
}
// Step 2: Try context-based routing (explicit node information)
// Step 2: Try unified ResourceProvider lookup (PRIMARY method for workloads)
// This uses the new redesigned infrastructure model which knows the relationships
// between all resources (containers → hosts, VMs → nodes, etc.)
if result.TargetNode == "" {
if node, ok := req.Context["node"].(string); ok && node != "" {
result.TargetNode = strings.ToLower(node)
result.RoutingMethod = "context_node"
log.Debug().
Str("node", node).
Str("command", command).
Msg("Routing via explicit 'node' in context")
} else if node, ok := req.Context["guest_node"].(string); ok && node != "" {
result.TargetNode = strings.ToLower(node)
result.RoutingMethod = "context_guest_node"
log.Debug().
Str("guest_node", node).
Str("command", command).
Msg("Routing via 'guest_node' in context")
} else if req.TargetType == "host" {
// Check multiple possible keys for hostname - frontend uses host_name
hostname := ""
if h, ok := req.Context["hostname"].(string); ok && h != "" {
hostname = h
} else if h, ok := req.Context["host_name"].(string); ok && h != "" {
hostname = h
s.mu.RLock()
rp := s.resourceProvider
s.mu.RUnlock()
if rp != nil {
// Try to find the host for this workload
resourceName := ""
if name, ok := req.Context["containerName"].(string); ok && name != "" {
resourceName = name
} else if name, ok := req.Context["name"].(string); ok && name != "" {
resourceName = name
} else if name, ok := req.Context["guestName"].(string); ok && name != "" {
resourceName = name
}
if hostname != "" {
result.TargetNode = strings.ToLower(hostname)
result.RoutingMethod = "context_hostname"
log.Debug().
Str("hostname", hostname).
Str("command", command).
Msg("Routing via hostname in context")
} else {
// For host target type with no node info, log a warning
// This is a common source of routing issues
log.Warn().
Str("target_type", req.TargetType).
Str("target_id", req.TargetID).
Str("command", command).
Msg("Host command with no node/hostname in context - may route to wrong agent")
result.Warnings = append(result.Warnings,
"No target host specified in context. Use target_host parameter for reliable routing.")
if resourceName != "" {
if host := rp.FindContainerHost(resourceName); host != "" {
result.TargetNode = strings.ToLower(host)
result.RoutingMethod = "resource_provider_lookup"
log.Info().
Str("resource_name", resourceName).
Str("host", host).
Str("target_type", req.TargetType).
Str("command", command).
Msg("Routing via unified ResourceProvider")
}
}
}
}
// Step 3: Fallback to explicit context fields (backwards compatibility)
// These are checked in order of specificity
if result.TargetNode == "" {
// Try the most specific fields first
hostFields := []string{"node", "host", "guest_node", "hostname", "host_name", "target_host"}
for _, field := range hostFields {
if value, ok := req.Context[field].(string); ok && value != "" {
result.TargetNode = strings.ToLower(value)
result.RoutingMethod = "context_" + field
log.Debug().
Str("field", field).
Str("value", value).
Str("command", command).
Msg("Routing via context field (fallback)")
break
}
}
}
// Step 3: Extract VMID from target ID and look up in state
if result.TargetNode == "" && req.TargetID != "" {

View File

@@ -215,6 +215,7 @@ func (s *Service) LoadConfig() error {
log.Info().
Str("provider", cfg.Provider).
Str("model", cfg.GetModel()).
Bool("autonomous_mode", cfg.AutonomousMode).
Msg("AI service initialized")
return nil
@@ -397,6 +398,50 @@ func isDangerousCommand(cmd string) bool {
}
if dangerousCommands[baseCmd] {
// Special case: allow read-only apt/apt-get operations
if baseCmd == "apt" || baseCmd == "apt-get" {
// First, check if it's a dry-run/simulate command (safe even for upgrade/install)
for _, part := range parts {
if part == "--dry-run" || part == "-s" || part == "--simulate" || part == "--just-print" {
return false // Dry-run is always safe
}
}
// Check for inherently read-only operations
safeAptOps := []string{"update", "list", "show", "search", "policy", "madison", "depends", "rdepends", "changelog"}
for _, safeOp := range safeAptOps {
if len(parts) > 1 && parts[1] == safeOp {
return false // Safe read-only operation
}
// Also handle sudo apt <op>
if len(parts) > 2 && parts[0] == "sudo" && parts[2] == safeOp {
return false
}
}
}
// Special case: allow read-only systemctl operations
if baseCmd == "systemctl" {
safeSystemctlOps := []string{"status", "show", "list-units", "list-unit-files", "is-active", "is-enabled", "is-failed", "cat"}
for _, safeOp := range safeSystemctlOps {
if len(parts) > 1 && parts[1] == safeOp {
return false
}
if len(parts) > 2 && parts[0] == "sudo" && parts[2] == safeOp {
return false
}
}
}
// Special case: allow read-only dpkg operations
if baseCmd == "dpkg" {
safeDpkgOps := []string{"-l", "--list", "-L", "--listfiles", "-s", "--status", "-S", "--search", "-p", "--print-avail", "--get-selections"}
for _, safeOp := range safeDpkgOps {
if len(parts) > 1 && parts[1] == safeOp {
return false
}
if len(parts) > 2 && parts[0] == "sudo" && parts[2] == safeOp {
return false
}
}
}
return true
}
@@ -860,16 +905,30 @@ Always execute the commands rather than telling the user how to do it.`
})
// Execute each tool call and add results
// Track if any command needs approval - if so, we'll stop the loop after processing
anyNeedsApproval := false
for _, tc := range resp.ToolCalls {
toolInput := s.getToolInputDisplay(tc)
// Check if this command needs approval
// Check if this command needs approval
needsApproval := false
if tc.Name == "run_command" {
cmd, _ := tc.Input["command"].(string)
runOnHost, _ := tc.Input["run_on_host"].(bool)
targetHost, _ := tc.Input["target_host"].(string)
// If AI didn't specify target_host, try to get it from request context
// This is crucial for proper routing when the command is approved
if targetHost == "" {
if node, ok := req.Context["node"].(string); ok && node != "" {
targetHost = node
} else if node, ok := req.Context["hostname"].(string); ok && node != "" {
targetHost = node
} else if node, ok := req.Context["host_name"].(string); ok && node != "" {
targetHost = node
}
}
isAuto := s.IsAutonomous()
isReadOnly := isReadOnlyCommand(cmd)
isDangerous := isDangerousCommand(cmd)
@@ -881,10 +940,13 @@ Always execute the commands rather than telling the user how to do it.`
Str("target_host", targetHost).
Msg("Checking command approval")
// Dangerous commands ALWAYS need approval, even in autonomous mode
// In non-autonomous mode, non-read-only commands also need approval
if isDangerous || (!isAuto && !isReadOnly) {
// In autonomous mode, NO commands need approval - full trust
// In non-autonomous mode:
// - Dangerous commands always need approval
// - Non-read-only commands need approval
if !isAuto && (isDangerous || !isReadOnly) {
needsApproval = true
anyNeedsApproval = true
// Send approval needed event
callback(StreamEvent{
Type: "approval_needed",
@@ -904,20 +966,11 @@ Always execute the commands rather than telling the user how to do it.`
var execution ToolExecution
if needsApproval {
// Don't execute - tell the AI the command needs user approval
// The approval button has been sent to the frontend - tell AI to direct user to it
result = fmt.Sprintf("COMMAND_BLOCKED: This command (%s) requires user approval and was NOT executed. "+
"An approval button has been displayed to the user in the chat. "+
"DO NOT attempt to run this command again. "+
"Tell the user to click the 'Run' button that appeared above to execute the command, "+
"or explain what the command does if they need help deciding.", toolInput)
execution = ToolExecution{
Name: tc.Name,
Input: toolInput,
Output: result,
Success: false,
}
toolExecutions = append(toolExecutions, execution)
// Don't execute - command needs user approval
// We'll break out of the loop after processing all tool calls
// Note: We don't add to toolExecutions here because the approval_needed event
// already tells the frontend to show the approval UI
result = fmt.Sprintf("Awaiting user approval: %s", toolInput)
} else {
// Stream tool start event
callback(StreamEvent{
@@ -960,6 +1013,20 @@ Always execute the commands rather than telling the user how to do it.`
},
})
}
// If any command needed approval, stop the agentic loop here.
// Don't call the AI again with "COMMAND_BLOCKED" results - this causes duplicate
// approval requests and confusing "click the button" messages.
// The frontend will show approval buttons, and user action will continue the conversation.
if anyNeedsApproval {
log.Info().
Int("pending_approvals", len(resp.ToolCalls)).
Int("iteration", iteration).
Msg("Stopping AI loop - commands need user approval")
// Use the AI's current response as final content (if any)
// This preserves any explanation the AI provided before requesting the command
break
}
}
// Stream the final content

View File

@@ -469,9 +469,10 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R
// Flush headers immediately
flusher.Flush()
// Create context with timeout (5 minutes for complex analysis with multiple tool calls)
// Create context with timeout (15 minutes for complex analysis with multiple tool calls)
// Use background context to avoid browser disconnect canceling the request
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
// DeepSeek reasoning models + multiple tool executions can easily take 5+ minutes
ctx, cancel := context.WithTimeout(context.Background(), 900*time.Second)
defer cancel()
// Set up heartbeat to keep connection alive during long tool executions
@@ -660,8 +661,8 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
Str("target_host", req.TargetHost).
Msg("Executing approved command")
// Execute with timeout
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
// Execute with timeout (5 minutes for long-running commands)
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
defer cancel()
resp, err := h.aiService.RunCommand(ctx, ai.RunCommandRequest{

View File

@@ -0,0 +1,159 @@
package api
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log"
)
// HostMetadataHandler handles host metadata operations
type HostMetadataHandler struct {
store *config.HostMetadataStore
}
// NewHostMetadataHandler creates a new host metadata handler
func NewHostMetadataHandler(dataPath string) *HostMetadataHandler {
return &HostMetadataHandler{
store: config.NewHostMetadataStore(dataPath),
}
}
// HandleGetMetadata retrieves metadata for a specific host or all hosts
func (h *HostMetadataHandler) HandleGetMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check if requesting specific host
path := r.URL.Path
// Handle both /api/hosts/metadata and /api/hosts/metadata/
if path == "/api/hosts/metadata" || path == "/api/hosts/metadata/" {
// Get all metadata
w.Header().Set("Content-Type", "application/json")
allMeta := h.store.GetAll()
if allMeta == nil {
// Return empty object instead of null
json.NewEncoder(w).Encode(make(map[string]*config.HostMetadata))
} else {
json.NewEncoder(w).Encode(allMeta)
}
return
}
// Get specific host ID from path
hostID := strings.TrimPrefix(path, "/api/hosts/metadata/")
w.Header().Set("Content-Type", "application/json")
if hostID != "" {
// Get specific host metadata
meta := h.store.Get(hostID)
if meta == nil {
// Return empty metadata instead of 404
json.NewEncoder(w).Encode(&config.HostMetadata{ID: hostID})
} else {
json.NewEncoder(w).Encode(meta)
}
} else {
// This shouldn't happen with current routing, but handle it anyway
http.Error(w, "Invalid request path", http.StatusBadRequest)
}
}
// HandleUpdateMetadata updates metadata for a host
func (h *HostMetadataHandler) HandleUpdateMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
hostID := strings.TrimPrefix(r.URL.Path, "/api/hosts/metadata/")
if hostID == "" || hostID == "metadata" {
http.Error(w, "Host ID required", http.StatusBadRequest)
return
}
// Limit request body to 16KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
var meta config.HostMetadata
if err := json.NewDecoder(r.Body).Decode(&meta); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate URL if provided
if meta.CustomURL != "" {
// Parse and validate the URL
parsedURL, err := url.Parse(meta.CustomURL)
if err != nil {
http.Error(w, "Invalid URL format: "+err.Error(), http.StatusBadRequest)
return
}
// Check scheme
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
http.Error(w, "URL must use http:// or https:// scheme", http.StatusBadRequest)
return
}
// Check host is present and valid
if parsedURL.Host == "" {
http.Error(w, "Invalid URL: missing host/domain (e.g., use https://192.168.1.100:8006 or https://myhost.local)", http.StatusBadRequest)
return
}
// Check for incomplete URLs like "https://host."
if strings.HasSuffix(parsedURL.Host, ".") && !strings.Contains(parsedURL.Host, "..") {
http.Error(w, "Incomplete URL: '"+meta.CustomURL+"' - please enter a complete domain or IP address", http.StatusBadRequest)
return
}
}
if err := h.store.Set(hostID, &meta); err != nil {
log.Error().Err(err).Str("hostID", hostID).Msg("Failed to save host metadata")
// Provide more specific error message
errMsg := "Failed to save metadata"
if strings.Contains(err.Error(), "permission") {
errMsg = "Permission denied - check file permissions"
} else if strings.Contains(err.Error(), "no space") {
errMsg = "Disk full - cannot save metadata"
}
http.Error(w, errMsg, http.StatusInternalServerError)
return
}
log.Info().Str("hostID", hostID).Str("url", meta.CustomURL).Msg("Updated host metadata")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(&meta)
}
// HandleDeleteMetadata removes metadata for a host
func (h *HostMetadataHandler) HandleDeleteMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
hostID := strings.TrimPrefix(r.URL.Path, "/api/hosts/metadata/")
if hostID == "" || hostID == "metadata" {
http.Error(w, "Host ID required", http.StatusBadRequest)
return
}
if err := h.store.Delete(hostID); err != nil {
log.Error().Err(err).Str("hostID", hostID).Msg("Failed to delete host metadata")
http.Error(w, "Failed to delete metadata", http.StatusInternalServerError)
return
}
log.Info().Str("hostID", hostID).Msg("Deleted host metadata")
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -178,6 +178,7 @@ func (r *Router) setupRoutes() {
r.notificationQueueHandlers = NewNotificationQueueHandlers(r.monitor)
guestMetadataHandler := NewGuestMetadataHandler(r.config.DataPath)
dockerMetadataHandler := NewDockerMetadataHandler(r.config.DataPath)
hostMetadataHandler := NewHostMetadataHandler(r.config.DataPath)
r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings)
updateHandlers := NewUpdateHandlers(r.updateManager, r.updateHistory)
r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub)
@@ -276,6 +277,30 @@ func (r *Router) setupRoutes() {
}
}))
// Host metadata routes
r.mux.HandleFunc("/api/hosts/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, hostMetadataHandler.HandleGetMetadata)))
r.mux.HandleFunc("/api/hosts/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
hostMetadataHandler.HandleGetMetadata(w, req)
case http.MethodPut, http.MethodPost:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
hostMetadataHandler.HandleUpdateMetadata(w, req)
case http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
hostMetadataHandler.HandleDeleteMetadata(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
// Update routes
r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates)))
r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate)))

View File

@@ -43,7 +43,7 @@ const (
DefaultAIModelAnthropic = "claude-opus-4-5-20251101"
DefaultAIModelOpenAI = "gpt-4o"
DefaultAIModelOllama = "llama3"
DefaultAIModelDeepSeek = "deepseek-reasoner"
DefaultAIModelDeepSeek = "deepseek-chat" // V3.2 with tool-use support
DefaultOllamaBaseURL = "http://localhost:11434"
DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions"
)

View File

@@ -0,0 +1,179 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/rs/zerolog/log"
)
// HostMetadata holds additional metadata for a host
type HostMetadata struct {
ID string `json:"id"` // Host ID
CustomURL string `json:"customUrl"` // Custom URL for the host
Description string `json:"description"` // Optional description
Tags []string `json:"tags"` // Optional tags for categorization
Notes []string `json:"notes"` // User annotations for AI context
}
// HostMetadataStore manages host metadata
type HostMetadataStore struct {
mu sync.RWMutex
metadata map[string]*HostMetadata // keyed by host ID
dataPath string
}
// NewHostMetadataStore creates a new host metadata store
func NewHostMetadataStore(dataPath string) *HostMetadataStore {
store := &HostMetadataStore{
metadata: make(map[string]*HostMetadata),
dataPath: dataPath,
}
// Load existing metadata
if err := store.Load(); err != nil {
log.Warn().Err(err).Msg("Failed to load host metadata")
}
return store
}
// Get retrieves metadata for a host
func (s *HostMetadataStore) Get(hostID string) *HostMetadata {
s.mu.RLock()
defer s.mu.RUnlock()
if meta, exists := s.metadata[hostID]; exists {
return meta
}
return nil
}
// GetAll retrieves all host metadata
func (s *HostMetadataStore) GetAll() map[string]*HostMetadata {
s.mu.RLock()
defer s.mu.RUnlock()
// Return a copy to prevent external modifications
result := make(map[string]*HostMetadata)
for k, v := range s.metadata {
result[k] = v
}
return result
}
// Set updates or creates metadata for a host
func (s *HostMetadataStore) Set(hostID string, meta *HostMetadata) error {
s.mu.Lock()
defer s.mu.Unlock()
if meta == nil {
return fmt.Errorf("metadata cannot be nil")
}
meta.ID = hostID
s.metadata[hostID] = meta
// Save to disk
return s.save()
}
// Delete removes metadata for a host
func (s *HostMetadataStore) Delete(hostID string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.metadata, hostID)
// Save to disk
return s.save()
}
// ReplaceAll replaces all metadata entries and persists them to disk.
func (s *HostMetadataStore) ReplaceAll(metadata map[string]*HostMetadata) error {
s.mu.Lock()
defer s.mu.Unlock()
s.metadata = make(map[string]*HostMetadata)
for hostID, meta := range metadata {
if meta == nil {
continue
}
clone := *meta
clone.ID = hostID
// Ensure slice copy is not nil to allow JSON marshalling of empty tags
if clone.Tags == nil {
clone.Tags = []string{}
}
s.metadata[hostID] = &clone
}
return s.save()
}
// Load reads metadata from disk
func (s *HostMetadataStore) Load() error {
filePath := filepath.Join(s.dataPath, "host_metadata.json")
log.Debug().Str("path", filePath).Msg("Loading host metadata from disk")
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist yet, not an error
log.Debug().Str("path", filePath).Msg("Host metadata file does not exist yet")
return nil
}
return fmt.Errorf("failed to read metadata file: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
if err := json.Unmarshal(data, &s.metadata); err != nil {
return fmt.Errorf("failed to unmarshal metadata: %w", err)
}
log.Info().
Int("hostCount", len(s.metadata)).
Msg("Loaded host metadata")
return nil
}
// save writes metadata to disk (must be called with lock held)
func (s *HostMetadataStore) save() error {
filePath := filepath.Join(s.dataPath, "host_metadata.json")
log.Debug().Str("path", filePath).Msg("Saving host metadata to disk")
data, err := json.Marshal(s.metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
// Ensure directory exists
if err := os.MkdirAll(s.dataPath, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
// Write to temp file first for atomic operation
tempFile := filePath + ".tmp"
if err := os.WriteFile(tempFile, data, 0644); err != nil {
return fmt.Errorf("failed to write metadata file: %w", err)
}
// Rename temp file to actual file (atomic on most systems)
if err := os.Rename(tempFile, filePath); err != nil {
return fmt.Errorf("failed to rename metadata file: %w", err)
}
log.Debug().Str("path", filePath).Int("hosts", len(s.metadata)).Msg("Host metadata saved successfully")
return nil
}

View File

@@ -163,6 +163,54 @@ func (s *Store) GetChildren(parentID string) []Resource {
return result
}
// FindContainerHost looks up a Docker container by name or ID and returns the
// hostname of its parent DockerHost. This is used by AI routing to automatically
// determine which host should execute commands for a container.
// Returns empty string if not found.
func (s *Store) FindContainerHost(containerNameOrID string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if containerNameOrID == "" {
return ""
}
containerNameLower := strings.ToLower(containerNameOrID)
// Find the container
var container *Resource
for _, r := range s.resources {
if r.Type != ResourceTypeDockerContainer {
continue
}
// Match by name or ID (case-insensitive)
if strings.EqualFold(r.Name, containerNameOrID) ||
strings.EqualFold(r.ID, containerNameOrID) ||
strings.Contains(strings.ToLower(r.Name), containerNameLower) ||
strings.Contains(strings.ToLower(r.ID), containerNameLower) {
container = r
break
}
}
if container == nil || container.ParentID == "" {
return ""
}
// Find the parent DockerHost
parent := s.resources[container.ParentID]
if parent == nil {
return ""
}
// Return the hostname from identity, or the name
if parent.Identity != nil && parent.Identity.Hostname != "" {
return parent.Identity.Hostname
}
return parent.Name
}
// Remove removes a resource from the store.
func (s *Store) Remove(id string) {
s.mu.Lock()

View File

@@ -241,6 +241,26 @@ else
log_info "Production mode: Using dev config directory: ${PULSE_DATA_DIR}"
fi
# Auto-restore encryption key from backup if missing
if [[ ! -f "${PULSE_DATA_DIR}/.encryption.key" ]]; then
BACKUP_KEY=$(find "${PULSE_DATA_DIR}" -maxdepth 1 -name '.encryption.key.bak*' -type f 2>/dev/null | head -1)
if [[ -n "${BACKUP_KEY}" ]] && [[ -f "${BACKUP_KEY}" ]]; then
echo ""
log_error "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
log_error "!! ENCRYPTION KEY WAS MISSING - AUTO-RESTORING FROM BACKUP !!"
log_error "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
log_error "!! Backup used: ${BACKUP_KEY}"
log_error "!! "
log_error "!! To find out what deleted the key, run:"
log_error "!! sudo journalctl -u encryption-key-watcher -n 100"
log_error "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo ""
cp -f "${BACKUP_KEY}" "${PULSE_DATA_DIR}/.encryption.key"
chmod 600 "${PULSE_DATA_DIR}/.encryption.key"
log_info "Restored encryption key from backup"
fi
fi
if [[ -z ${PULSE_ENCRYPTION_KEY:-} ]]; then
if [[ -f "${PULSE_DATA_DIR}/.encryption.key" ]]; then
export PULSE_ENCRYPTION_KEY="$(<"${PULSE_DATA_DIR}/.encryption.key")"

View File

@@ -22,6 +22,29 @@ echo ""
HAVE_PROD_KEY=false
# CRITICAL: Always sync production encryption key to dev when it exists
# First, check if the key is missing but a backup exists - auto-restore it
if [ ! -f "$PROD_DIR/.encryption.key" ]; then
# Look for backup keys in production directory
BACKUP_KEY=$(find "$PROD_DIR" -maxdepth 1 -name '.encryption.key.bak*' -type f 2>/dev/null | head -1)
if [ -n "$BACKUP_KEY" ] && [ -f "$BACKUP_KEY" ]; then
echo ""
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo "!! ENCRYPTION KEY WAS MISSING - AUTO-RESTORING FROM BACKUP !!"
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo "!! Backup used: $BACKUP_KEY"
echo "!! "
echo "!! To find out what deleted the key, run:"
echo "!! sudo journalctl -u encryption-key-watcher -n 100"
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo ""
cp -f "$BACKUP_KEY" "$PROD_DIR/.encryption.key"
chmod 600 "$PROD_DIR/.encryption.key"
# Ensure proper ownership (may need root, so try but don't fail)
chown pulse:pulse "$PROD_DIR/.encryption.key" 2>/dev/null || true
echo "✓ Restored encryption key from backup"
fi
fi
if [ -f "$PROD_DIR/.encryption.key" ]; then
if [ ! -f "$DEV_DIR/.encryption.key" ]; then
cp -f "$PROD_DIR/.encryption.key" "$DEV_DIR/.encryption.key"

43
scripts/watch-encryption-key.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Watch the encryption key file for any modifications or deletions
# Logs to journald with full context about what process did it
LOG_TAG="encryption-key-watcher"
WATCH_DIR="/etc/pulse"
WATCH_FILE=".encryption.key"
log_event() {
local event="$1"
local file="$2"
# Get current process info
echo "[$LOG_TAG] EVENT: $event on $file at $(date '+%Y-%m-%d %H:%M:%S')"
# Try to capture what processes are accessing /etc/pulse
echo "[$LOG_TAG] Processes with open files in /etc/pulse:"
lsof +D /etc/pulse 2>/dev/null | head -20 || echo " (lsof failed)"
# Log the current state of the file
if [[ -f "$WATCH_DIR/$WATCH_FILE" ]]; then
echo "[$LOG_TAG] File still exists: $(ls -la "$WATCH_DIR/$WATCH_FILE")"
else
echo "[$LOG_TAG] *** FILE IS MISSING! ***"
echo "[$LOG_TAG] Contents of $WATCH_DIR:"
ls -la "$WATCH_DIR" | grep -i enc
fi
# Log recent sudo commands
echo "[$LOG_TAG] Recent sudo activity:"
journalctl -u sudo --since "2 minutes ago" --no-pager 2>/dev/null | tail -10 || true
}
echo "[$LOG_TAG] Starting encryption key watcher..."
echo "[$LOG_TAG] Monitoring: $WATCH_DIR/$WATCH_FILE"
# Watch for all relevant events on the directory
inotifywait -m -e delete,move,modify,attrib,create "$WATCH_DIR" --format '%e %f' 2>/dev/null | while read event file; do
# Only log events related to encryption key
if [[ "$file" == ".encryption.key"* ]]; then
log_event "$event" "$file"
fi
done