diff --git a/frontend-modern/public/install-host-agent.sh b/frontend-modern/public/install-host-agent.sh index 233c4ba..cb7029e 100755 --- a/frontend-modern/public/install-host-agent.sh +++ b/frontend-modern/public/install-host-agent.sh @@ -404,9 +404,33 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then security delete-generic-password -s "pulse-host-agent" -a "$USER" 2>/dev/null || true # Add token to Keychain - if security add-generic-password -s "pulse-host-agent" -a "$USER" -w "$PULSE_TOKEN" -U 2>/dev/null; then - log_success "Token stored securely in macOS Keychain" - USE_KEYCHAIN=true + KEYCHAIN_SERVICE="pulse-host-agent" + KEYCHAIN_ACCOUNT="$USER" + + KEYCHAIN_APPS=( + "/usr/local/bin/pulse-host-agent" + "/usr/local/bin/pulse-host-agent-wrapper.sh" + "/usr/bin/security" + ) + KEYCHAIN_ARGS=() + for app in "${KEYCHAIN_APPS[@]}"; do + KEYCHAIN_ARGS+=(-T "$app") + done + + if security add-generic-password \ + -s "$KEYCHAIN_SERVICE" \ + -a "$KEYCHAIN_ACCOUNT" \ + -w "$PULSE_TOKEN" \ + -U \ + "${KEYCHAIN_ARGS[@]}" 2>/dev/null; then + if security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w >/dev/null 2>&1; then + log_success "Token stored securely in macOS Keychain" + USE_KEYCHAIN=true + else + log_warn "Token saved but Keychain denied non-interactive read access" + log_info "Will fall back to embedding token in the launchd plist" + USE_KEYCHAIN=false + fi else log_warn "Failed to store token in Keychain, will use plist instead" log_info "You may need to grant Keychain access permissions" @@ -419,14 +443,21 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then # Create wrapper script if using Keychain if [[ "$USE_KEYCHAIN" == true ]]; then WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh" + TMP_WRAPPER=$(mktemp) - cat > "$WRAPPER_SCRIPT" <<'WRAPPER_EOF' + cat > "$TMP_WRAPPER" <<'WRAPPER_EOF' #!/bin/bash # Pulse Host Agent Wrapper - Reads token from Keychain -set -e +set -u + +LOG_FILE="$HOME/Library/Logs/Pulse/host-agent-wrapper.log" +mkdir -p "$(dirname "$LOG_FILE")" # Read token from Keychain -PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -w 2>/dev/null || echo "") +if ! PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -a "$USER" -w 2>/dev/null); then + echo "$(date -Is) pulse-host-agent-wrapper: failed to read token from Keychain" >>"$LOG_FILE" + PULSE_TOKEN="" +fi # Export for agent to use export PULSE_TOKEN @@ -435,7 +466,23 @@ export PULSE_TOKEN exec /usr/local/bin/pulse-host-agent "$@" WRAPPER_EOF - chmod +x "$WRAPPER_SCRIPT" + if ! sudo mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT"; then + if ! mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT" 2>/dev/null; then + rm -f "$TMP_WRAPPER" + log_error "Failed to write Keychain wrapper to $WRAPPER_SCRIPT. Try re-running with sudo." + exit 1 + fi + fi + + if ! sudo chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null && ! chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null; then + log_error "Failed to set execute permissions on $WRAPPER_SCRIPT." + exit 1 + fi + + if command -v chown &>/dev/null; then + sudo chown root:wheel "$WRAPPER_SCRIPT" 2>/dev/null || sudo chown root:root "$WRAPPER_SCRIPT" 2>/dev/null || true + fi + log_success "Created Keychain wrapper script" # Create plist using wrapper (token not in plist!) @@ -502,8 +549,23 @@ EOF # Set restrictive permissions on plist chmod 600 "$LAUNCHD_PLIST" - launchctl load "$LAUNCHD_PLIST" - log_success "Launchd service enabled and started" + LAUNCH_TARGET="gui/$(id -u)" + + # Attempt to unload any existing service instance + if launchctl bootout "$LAUNCH_TARGET" "$LAUNCHD_PLIST" 2>/dev/null; then + log_info "Replaced existing launchd service definition" + fi + + if launchctl bootstrap "$LAUNCH_TARGET" "$LAUNCHD_PLIST"; then + launchctl enable "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true + launchctl kickstart -k "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true + log_success "Launchd service enabled and started" + else + log_error "Failed to load launchd service. Try running:" + echo " launchctl bootstrap $LAUNCH_TARGET $LAUNCHD_PLIST" + echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent" + exit 1 + fi else log_warn "Automatic service setup not available for this platform" log_info "To run the agent manually:" diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index e44d17d..ab6fa1e 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -999,18 +999,6 @@ function AppLayout(props: { > {platform.icon} {platform.label} - - - ); }} diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index 3f90cbd..89760f3 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -663,19 +663,9 @@ export const DockerHosts: Component = (props) => { {/* Left: Host List - Only show if more than 1 host */} 1}> -
-
-

Docker Hosts

-

{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}

-
- +
+

Docker Hosts

+

{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}

@@ -1054,29 +1044,6 @@ export const DockerHosts: Component = (props) => { Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically. } - actions={ - <> - - Copy install command - - - Read the Docker monitoring guide - - - } /> diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index 614a25a..3be762c 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -12,7 +12,6 @@ import { DOCKER_REPORT_SCOPE } from '@/constants/apiScopes'; export const DockerAgents: Component = () => { const { state } = useWebSocket(); - const [showInstructions, setShowInstructions] = createSignal(true); let hasLoggedSecurityStatusError = false; @@ -367,22 +366,6 @@ WantedBy=multi-user.target`; return (
-
-
-

Setup & Management

-

Deploy agents or manage existing Docker hosts

-
- -
- - {/* Deployment Instructions */} -

Add a Docker host

@@ -391,26 +374,6 @@ WantedBy=multi-user.target`;

-
- - What exactly gets installed? - - - - -
-
    -
  • A single self-contained Go binary (pulse-docker-agent, ~7 MB)
  • -
  • A systemd unit on Linux or Unraid startup script so the agent restarts after reboots
  • -
  • No extra dependencies: it talks directly to /var/run/docker.sock and sends metrics over HTTPS
  • -
  • Every report includes a control handshake so Pulse can issue constrained commands (e.g. stop) without running arbitrary shell
  • -
-

- Removing a host tears down the service and autostart hook automatically; keeping the binary for quick reinstalls is optional and called out in the dialog. -

-
-
-
@@ -573,7 +536,6 @@ WantedBy=multi-user.target`;
- {/* Remove Docker Host Modal */} diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx index ba2bd62..9e26105 100644 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ b/frontend-modern/src/components/Settings/HostAgents.tsx @@ -1,55 +1,21 @@ -import { type Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'; +import { type Component, For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'; import type { JSX } from 'solid-js'; import { useWebSocket } from '@/App'; import type { Host } from '@/types/api'; import { Card } from '@/components/shared/Card'; -import CopyButton from '@/components/shared/CopyButton'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { notificationStore } from '@/stores/notifications'; -import { showTokenReveal } from '@/stores/tokenReveal'; import { HOST_AGENT_SCOPE } from '@/constants/apiScopes'; import type { SecurityStatus } from '@/types/config'; import type { APITokenRecord } from '@/api/security'; -import { useScopedTokenManager } from '@/hooks/useScopedTokenManager'; -import SquareTerminal from 'lucide-solid/icons/square-terminal'; +import { SecurityAPI } from '@/api/security'; -type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows'; +type HostAgentVariant = 'linux' | 'macos' | 'windows'; interface HostAgentsProps { variant?: HostAgentVariant; } -type HostPlatform = 'linux' | 'macos' | 'windows'; - -const hostPlatformOptions: { id: HostPlatform; label: string; description: string; icon: typeof SquareTerminal | JSX.Element }[] = [ - { - id: 'linux', - label: 'Linux', - description: 'Download the static binary and enable the systemd service on Debian, Ubuntu, RHEL, Arch, and more.', - icon: SquareTerminal, - }, - { - id: 'macos', - label: 'macOS', - description: 'Use the universal binary with launchd to keep desktops and hosts reporting in the background.', - icon: ( - - - - ), - }, - { - id: 'windows', - label: 'Windows', - description: 'Native Windows service with automatic startup. PowerShell script handles binary download and service installation.', - icon: ( - - - - ), - }, -]; - const TOKEN_PLACEHOLDER = ''; const pulseUrl = () => { if (typeof window === 'undefined') return 'http://localhost:7655'; @@ -57,23 +23,14 @@ const pulseUrl = () => { return `${protocol}//${hostname}${port ? `:${port}` : ''}`; }; +const buildDefaultTokenName = () => { + const now = new Date(); + const iso = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM + const stamp = iso.replace('T', ' ').replace(/:/g, '-'); + return `Host agent ${stamp}`; +}; + const commandsByVariant: Record = { - all: { - title: 'Installation', - description: - 'Run the installer script to automatically download and configure the host agent on any supported platform.', - snippets: [ - { - label: 'Install host agent', - command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, - note: ( - - The script downloads the agent binary from Pulse and sets up systemd (Linux) or launchd (macOS) for automatic startup. - - ), - }, - ], - }, linux: { title: 'Install on Linux', description: @@ -133,49 +90,51 @@ const commandsByVariant: Record = { - all: null, +const platformFilters: Record = { linux: ['linux'], macos: ['macos'], windows: ['windows'], }; export const HostAgents: Component = (props) => { - const variant: HostAgentVariant = props.variant ?? 'all'; + const variant = () => props.variant ?? 'linux'; const { state } = useWebSocket(); let hasLoggedSecurityStatusError = false; - const [showInstructions, setShowInstructions] = createSignal(true); const [securityStatus, setSecurityStatus] = createSignal(null); - const [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false); - const [newTokenName, setNewTokenName] = createSignal(''); - const [generateError, setGenerateError] = createSignal(null); const [latestRecord, setLatestRecord] = createSignal(null); - const [stepTwoComplete, setStepTwoComplete] = createSignal(false); - - const { - token: apiToken, - setToken: setApiToken, - isGeneratingToken, - generateToken, - } = useScopedTokenManager({ - scope: HOST_AGENT_SCOPE, - storageKey: 'hostAgentToken', - legacyKeys: ['apiToken'], - }); + const [tokenName, setTokenName] = createSignal(''); + const [confirmedNoToken, setConfirmedNoToken] = createSignal(false); + const [currentToken, setCurrentToken] = createSignal(null); + const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); createEffect(() => { - if (!apiToken()) { - setStepTwoComplete(false); + if (requiresToken()) { + setConfirmedNoToken(false); + } else { + setCurrentToken(null); + setLatestRecord(null); } }); - const hosts = createMemo(() => { + + createEffect( + on( + variant, + () => { + setLatestRecord(null); + setCurrentToken(null); + setConfirmedNoToken(false); + setTokenName(''); + }, + { defer: true }, + ), + ); + + const allHosts = createMemo(() => { const list = state.hosts ?? []; - const filters = platformFilters[variant]; - const filtered = filters ? list.filter((host) => filters.includes((host.platform ?? '').toLowerCase())) : list; - return [...filtered].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')); + return [...list].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')); }); const renderTags = (host: Host) => { @@ -184,15 +143,7 @@ export const HostAgents: Component = (props) => { return tags.join(', '); }; - const [selectedPlatform, setSelectedPlatform] = createSignal('linux'); - - const effectiveVariant = createMemo(() => - variant === 'all' ? selectedPlatform() : variant, - ); - - const installMeta = createMemo(() => commandsByVariant[effectiveVariant()]); - const tokenStepLabel = () => `${variant === 'all' ? 'Step 2' : 'Step 1'} · Choose an API token`; - const commandStepLabel = () => `${variant === 'all' ? 'Step 3' : 'Step 2'} · Installation commands`; + const installMeta = createMemo(() => commandsByVariant[variant()]); onMount(() => { if (typeof window === 'undefined') { @@ -225,52 +176,38 @@ export const HostAgents: Component = (props) => { return true; }; - const tokenReady = () => !requiresToken() || Boolean(apiToken()); - const commandsUnlocked = () => tokenReady() && stepTwoComplete(); + const hasToken = () => Boolean(currentToken()); + const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken()); - const acknowledgeTokenUse = () => { - if (!requiresToken()) { - setStepTwoComplete(true); + const acknowledgeNoToken = () => { + if (requiresToken()) { + notificationStore.info('Generate or select a token before continuing.', 4000); return; } - if (apiToken()) { - setStepTwoComplete(true); - notificationStore.success('Token ready to embed in the install commands.', 3500); - } else { - notificationStore.info('Generate or select a token before continuing.', 4000); - } + setCurrentToken(null); + setLatestRecord(null); + setConfirmedNoToken(true); + notificationStore.success('Confirmed install commands without an API token.', 3500); }; - const openGenerateTokenModal = () => { - setGenerateError(null); - const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`; - setNewTokenName(defaultName); - setShowGenerateTokenModal(true); - setStepTwoComplete(false); - }; - - const handleCreateToken = async () => { + const handleGenerateToken = async () => { if (isGeneratingToken()) return; - setGenerateError(null); + setIsGeneratingToken(true); try { - const desiredName = newTokenName().trim() || `Host agent ${new Date().toISOString().slice(0, 10)}`; - const { token, record } = await generateToken(desiredName); + const desiredName = tokenName().trim() || buildDefaultTokenName(); + const { token, record } = await SecurityAPI.createToken(desiredName, [HOST_AGENT_SCOPE]); - setShowGenerateTokenModal(false); - setNewTokenName(''); + setCurrentToken(token); setLatestRecord(record); - showTokenReveal({ - token, - record, - source: 'host-agent', - note: `Copy this token into the host agent install command. Scope: ${HOST_AGENT_SCOPE}.`, - }); - notificationStore.success('Created host agent API token with reporting scope.', 6000); + setTokenName(''); + setConfirmedNoToken(false); + notificationStore.success('Token generated and inserted into the command below.', 4000); } catch (err) { console.error('Failed to generate host agent token', err); - setGenerateError('Failed to generate host agent token. Confirm you are signed in as an administrator.'); - notificationStore.error('Failed to generate API token', 6000); + notificationStore.error('Failed to generate host agent token. Confirm you are signed in as an administrator.', 6000); + } finally { + setIsGeneratingToken(false); } }; @@ -303,152 +240,67 @@ export const HostAgents: Component = (props) => { }; const resolvedToken = () => { - if (!requiresToken()) { - return 'disabled'; + if (requiresToken()) { + return currentToken() || TOKEN_PLACEHOLDER; } - return apiToken() || TOKEN_PLACEHOLDER; + return currentToken() || 'disabled'; }; return (
-
-
-

{installMeta().title}

-

{installMeta().description}

-
- +
+

Add a host agent

+

Run this command on your host to start monitoring.

+

{installMeta().description}

- -
- -
-
-

Step 1 · Choose the operating system

-

- Pick the platform you are onboarding. The install commands adapt automatically. -

-
-
- - {(option) => { - const isActive = () => selectedPlatform() === option.id; - const Icon = option.icon; - return ( - - ); - }} - -
+
+ +
+
+

Generate API token

+

+ Create a fresh token scoped to {HOST_AGENT_SCOPE}. +

- - - -
-
-

{tokenStepLabel()}

-

- Generate a scoped token for this host. Tokens minted here grant the {HOST_AGENT_SCOPE} permission only. -

-

- Need additional scopes? Visit Security → API tokens to create a bespoke credential. -

-
- - -
- {generateError()} -
-
- - -
- - - - - Token {latestRecord()?.name} created ({latestRecord()?.prefix}…{latestRecord()?.suffix}). Copy the full value from the pop-up and store it securely—this is the only time it is shown. - -
-
+
+ setTokenName(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isGeneratingToken()) { + handleGenerateToken(); + } + }} + placeholder="Token name (optional)" + class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" + /> - - -
-
- {stepTwoComplete() - ? 'Token inserted. Proceed to the install commands below.' - : 'Stored token detected. Press confirm to insert it into each command.'} -
- -
-
- + + +
+ + + + + Token {latestRecord()?.name} created ({latestRecord()?.prefix}…{latestRecord()?.suffix}). Commands below now include this credential. + +
+
+ +
+
@@ -457,147 +309,84 @@ export const HostAgents: Component = (props) => {
-
-

{commandStepLabel()}

- -
+

Install command

- {(snippet) => ( -
-
-
{snippet.label}
- - Copy command - + {(snippet) => { + const copyCommand = () => + snippet.command.replace( + TOKEN_PLACEHOLDER, + resolvedToken(), + ); + + return ( +
+
+
{snippet.label}
+ +
+
+                            {copyCommand()}
+                          
+ +

{snippet.note}

+
-
-                          
-                            {snippet.command.replace(
-                              TOKEN_PLACEHOLDER,
-                              resolvedToken(),
-                            )}
-                          
-                        
- -

{snippet.note}

-
-
- )} + ); + }}
- +

- Generate a new token or confirm the stored one to unlock the install commands. + Generate a new token to unlock the install commands.

- +

Confirm the no-token setup to continue.

-
-
- - - -
-
-
-

Generate a new host agent token

-

- Pulse will create a scoped token for this host and automatically insert it into the install commands. You can manage or revoke tokens anytime from Security settings. -

-
-
- - setNewTokenName(event.currentTarget.value)} - class="w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" - placeholder="Host agent token" - /> -

- Friendly names make it easier to audit tokens later (e.g. host-lab-01). -

-
-
- - -
-
-
+

Reporting hosts

- {hosts().length} connected + {allHosts().length} connected
0} + when={allHosts().length > 0} fallback={

- {variant === 'windows' - ? 'No Windows hosts have reported yet. Run the PowerShell installation script above as Administrator to deploy the agent.' - : 'No host agents are reporting yet. Deploy the binary using the commands above to see hosts listed here.'} + No host agents are reporting yet. Deploy the agent using the commands above to see hosts listed here.

} > @@ -615,7 +404,7 @@ export const HostAgents: Component = (props) => { - + {(host) => { const [isDeleting, setIsDeleting] = createSignal(false); diff --git a/frontend-modern/src/components/Settings/HostsSectionNav.tsx b/frontend-modern/src/components/Settings/HostsSectionNav.tsx new file mode 100644 index 0000000..b13a95a --- /dev/null +++ b/frontend-modern/src/components/Settings/HostsSectionNav.tsx @@ -0,0 +1,77 @@ +import type { Component } from 'solid-js'; +import { For, type JSX } from 'solid-js'; +import SquareTerminal from 'lucide-solid/icons/square-terminal'; + +type HostsSection = 'linux' | 'macos' | 'windows'; + +interface HostsSectionNavProps { + current: HostsSection; + onSelect: (section: HostsSection) => void; + class?: string; +} + +const AppleIcon = () => ( + + + +); + +const WindowsIcon = () => ( + + + +); + +const allSections: Array<{ + id: HostsSection; + label: string; + icon: Component<{ size?: number; 'stroke-width'?: number }>; +}> = [ + { + id: 'linux', + label: 'Linux', + icon: SquareTerminal, + }, + { + id: 'macos', + label: 'macOS', + icon: AppleIcon, + }, + { + id: 'windows', + label: 'Windows', + icon: WindowsIcon, + }, +]; + +export const HostsSectionNav: Component = (props) => { + const baseClasses = + 'inline-flex items-center gap-2 px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900'; + + return ( +
+ + {(section) => { + const isActive = () => section.id === props.current; + const classes = () => isActive() + ? `${baseClasses} text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400` + : `${baseClasses} hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50`; + + const Icon = section.icon; + + return ( + + ); + }} + +
+ ); +}; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index ea76ecb..1b1a99f 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -14,6 +14,7 @@ import { QuickSecuritySetup } from './QuickSecuritySetup'; import { SecurityPostureSummary } from './SecurityPostureSummary'; import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables'; import { SettingsSectionNav } from './SettingsSectionNav'; +import { HostsSectionNav } from './HostsSectionNav'; import { SettingsAPI } from '@/api/settings'; import { NodesAPI } from '@/api/nodes'; import { UpdatesAPI } from '@/api/updates'; @@ -395,6 +396,7 @@ const Settings: Component = (props) => { const activeTab = () => currentTab(); const [selectedAgent, setSelectedAgent] = createSignal('pve'); + const [selectedHostPlatform, setSelectedHostPlatform] = createSignal<'linux' | 'macos' | 'windows'>('linux'); const agentPaths: Record = { pve: '/settings/pve', @@ -417,6 +419,10 @@ const Settings: Component = (props) => { } }; + const handleSelectHostPlatform = (platform: 'linux' | 'macos' | 'windows') => { + setSelectedHostPlatform(platform); + }; + const setActiveTab = (tab: SettingsTab) => { if (tab === 'proxmox' && selectedAgent() === 'podman') { setSelectedAgent('pve'); @@ -2936,7 +2942,12 @@ const Settings: Component = (props) => { {/* Servers Platform Tab */} - + + {/* Podman Tab */} diff --git a/frontend-modern/src/components/shared/CopyButton.tsx b/frontend-modern/src/components/shared/CopyButton.tsx index 86ad571..8272c75 100644 --- a/frontend-modern/src/components/shared/CopyButton.tsx +++ b/frontend-modern/src/components/shared/CopyButton.tsx @@ -4,13 +4,47 @@ import type { JSX } from 'solid-js'; interface CopyButtonProps extends JSX.ButtonHTMLAttributes { text: string; children: JSX.Element; + onCopied?: () => void; } export function CopyButton(props: CopyButtonProps) { - const [local, others] = splitProps(props, ['text', 'children', 'class', 'onClick']); + const [local, others] = splitProps(props, ['text', 'children', 'class', 'onClick', 'onCopied']); const [copied, setCopied] = createSignal(false); let resetTimeout: number | undefined; + const copyText = async (text: string): Promise => { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (error) { + console.warn('Clipboard API copy failed, attempting fallback copy.', error); + } + } + + 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'); + } catch (error) { + console.error('Fallback copy failed', error); + return false; + } finally { + document.body.removeChild(textarea); + } + }; + const handleClick = async (event: MouseEvent) => { const handler = local.onClick as | ((event: MouseEvent) => void) @@ -26,13 +60,18 @@ export function CopyButton(props: CopyButtonProps) { return; } - try { - await navigator.clipboard.writeText(local.text); + const success = await copyText(local.text); + if (success) { setCopied(true); window.clearTimeout(resetTimeout); resetTimeout = window.setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy to clipboard', error); + if (typeof local.onCopied === 'function') { + try { + local.onCopied(); + } catch (error) { + console.error('onCopied handler failed', error); + } + } } }; diff --git a/scripts/install-host-agent.sh b/scripts/install-host-agent.sh index 233c4ba..248d428 100755 --- a/scripts/install-host-agent.sh +++ b/scripts/install-host-agent.sh @@ -404,9 +404,33 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then security delete-generic-password -s "pulse-host-agent" -a "$USER" 2>/dev/null || true # Add token to Keychain - if security add-generic-password -s "pulse-host-agent" -a "$USER" -w "$PULSE_TOKEN" -U 2>/dev/null; then - log_success "Token stored securely in macOS Keychain" - USE_KEYCHAIN=true + KEYCHAIN_SERVICE="pulse-host-agent" + KEYCHAIN_ACCOUNT="$USER" + + KEYCHAIN_APPS=( + "/usr/local/bin/pulse-host-agent" + "/usr/local/bin/pulse-host-agent-wrapper.sh" + "/usr/bin/security" + ) + KEYCHAIN_ARGS=() + for app in "${KEYCHAIN_APPS[@]}"; do + KEYCHAIN_ARGS+=(-T "$app") + done + + if security add-generic-password \ + -s "$KEYCHAIN_SERVICE" \ + -a "$KEYCHAIN_ACCOUNT" \ + -w "$PULSE_TOKEN" \ + -U \ + "${KEYCHAIN_ARGS[@]}" 2>/dev/null; then + if security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w >/dev/null 2>&1; then + log_success "Token stored securely in macOS Keychain" + USE_KEYCHAIN=true + else + log_warn "Token saved but Keychain denied non-interactive read access" + log_info "Will fall back to embedding token in the launchd plist" + USE_KEYCHAIN=false + fi else log_warn "Failed to store token in Keychain, will use plist instead" log_info "You may need to grant Keychain access permissions" @@ -419,14 +443,21 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then # Create wrapper script if using Keychain if [[ "$USE_KEYCHAIN" == true ]]; then WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh" + TMP_WRAPPER=$(mktemp) - cat > "$WRAPPER_SCRIPT" <<'WRAPPER_EOF' + cat > "$TMP_WRAPPER" <<'WRAPPER_EOF' #!/bin/bash # Pulse Host Agent Wrapper - Reads token from Keychain -set -e +set -u + +LOG_FILE="$HOME/Library/Logs/Pulse/host-agent-wrapper.log" +mkdir -p "$(dirname "$LOG_FILE")" # Read token from Keychain -PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -w 2>/dev/null || echo "") +if ! PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -a "$USER" -w 2>/dev/null); then + echo "$(date -Is) pulse-host-agent-wrapper: failed to read token from Keychain" >>"$LOG_FILE" + PULSE_TOKEN="" +fi # Export for agent to use export PULSE_TOKEN @@ -435,7 +466,22 @@ export PULSE_TOKEN exec /usr/local/bin/pulse-host-agent "$@" WRAPPER_EOF - chmod +x "$WRAPPER_SCRIPT" + if ! sudo mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT"; then + if ! mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT" 2>/dev/null; then + rm -f "$TMP_WRAPPER" + log_error "Failed to write Keychain wrapper to $WRAPPER_SCRIPT. Try re-running with sudo." + exit 1 + fi + fi + + if ! sudo chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null && ! chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null; then + log_error "Failed to set execute permissions on $WRAPPER_SCRIPT." + exit 1 + fi + + if command -v chown &>/dev/null; then + sudo chown root:wheel "$WRAPPER_SCRIPT" 2>/dev/null || sudo chown root:root "$WRAPPER_SCRIPT" 2>/dev/null || true + fi log_success "Created Keychain wrapper script" # Create plist using wrapper (token not in plist!) @@ -502,8 +548,23 @@ EOF # Set restrictive permissions on plist chmod 600 "$LAUNCHD_PLIST" - launchctl load "$LAUNCHD_PLIST" - log_success "Launchd service enabled and started" + LAUNCH_TARGET="gui/$(id -u)" + + # Attempt to unload any existing service instance + if launchctl bootout "$LAUNCH_TARGET" "$LAUNCHD_PLIST" 2>/dev/null; then + log_info "Replaced existing launchd service definition" + fi + + if launchctl bootstrap "$LAUNCH_TARGET" "$LAUNCHD_PLIST"; then + launchctl enable "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true + launchctl kickstart -k "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true + log_success "Launchd service enabled and started" + else + log_error "Failed to load launchd service. Try running:" + echo " launchctl bootstrap $LAUNCH_TARGET $LAUNCHD_PLIST" + echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent" + exit 1 + fi else log_warn "Automatic service setup not available for this platform" log_info "To run the agent manually:"