mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Refine security settings UI and credential rotation flow
This commit is contained in:
@@ -41,7 +41,7 @@ API_TOKEN=abc123... # API token (plain text, not hashed)
|
||||
|
||||
# Security settings
|
||||
DISABLE_AUTH=true # Disable authentication entirely
|
||||
ENABLE_AUDIT_LOG=true # Enable security audit logging
|
||||
PULSE_AUDIT_LOG=true # Enable security audit logging
|
||||
|
||||
# Proxy/SSO Authentication (see docs/PROXY_AUTH.md for full details)
|
||||
PROXY_AUTH_SECRET=secret123 # Shared secret between proxy and Pulse
|
||||
|
||||
@@ -12,15 +12,19 @@ interface SecurityCredentials {
|
||||
|
||||
interface QuickSecuritySetupProps {
|
||||
onConfigured?: () => void;
|
||||
defaultUsername?: string;
|
||||
mode?: 'initial' | 'rotate';
|
||||
}
|
||||
|
||||
export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) => {
|
||||
const mode = props.mode ?? 'initial';
|
||||
const isRotation = mode === 'rotate';
|
||||
const [isSettingUp, setIsSettingUp] = createSignal(false);
|
||||
const [credentials, setCredentials] = createSignal<SecurityCredentials | null>(null);
|
||||
const [showCredentials, setShowCredentials] = createSignal(false);
|
||||
const [copied, setCopied] = createSignal<'username' | 'password' | 'token' | null>(null);
|
||||
const [useCustomPassword, setUseCustomPassword] = createSignal(false);
|
||||
const [customUsername, setCustomUsername] = createSignal('admin');
|
||||
const [customUsername, setCustomUsername] = createSignal(props.defaultUsername ?? 'admin');
|
||||
const [customPassword, setCustomPassword] = createSignal('');
|
||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||
|
||||
@@ -83,7 +87,10 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newCredentials),
|
||||
body: JSON.stringify({
|
||||
...newCredentials,
|
||||
force: isRotation,
|
||||
}),
|
||||
credentials: 'include' // Include cookies for CSRF
|
||||
});
|
||||
|
||||
@@ -97,7 +104,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
|
||||
|
||||
if (result.skipped) {
|
||||
// Security was already configured, don't show credentials
|
||||
showError('Security is already configured. Please remove existing security first if you want to reconfigure.');
|
||||
showError(result.message || 'Security is already configured. Please remove existing security first if you want to reconfigure.');
|
||||
if (props.onConfigured) {
|
||||
props.onConfigured();
|
||||
}
|
||||
@@ -109,7 +116,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
|
||||
setShowCredentials(true);
|
||||
|
||||
// Show success message
|
||||
showSuccess('Security configured! Save your credentials before continuing.');
|
||||
showSuccess(isRotation ? 'Admin credentials generated. Save them before continuing.' : 'Security configured. Save your credentials before continuing.');
|
||||
|
||||
// DON'T notify parent yet - wait until user dismisses credentials
|
||||
// if (props.onConfigured) {
|
||||
@@ -125,7 +132,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
|
||||
const downloadCredentials = () => {
|
||||
if (!credentials()) return;
|
||||
|
||||
const content = `Pulse Security Credentials
|
||||
const content = `Pulse Admin Credentials ${isRotation ? '(Rotated)' : ''}
|
||||
Generated: ${new Date().toISOString()}
|
||||
|
||||
Basic Authentication:
|
||||
@@ -162,8 +169,8 @@ Important:
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<SectionHeader
|
||||
title="Quick security setup"
|
||||
description="Enable authentication with one click. This will:"
|
||||
title={isRotation ? 'Generate new admin credentials' : 'Quick security setup'}
|
||||
description={isRotation ? 'Create a fresh password and API token. This will:' : 'Enable authentication with one click. This will:'}
|
||||
size="sm"
|
||||
titleClass="text-gray-900 dark:text-gray-100"
|
||||
descriptionClass="!text-xs text-gray-600 dark:text-gray-400"
|
||||
@@ -171,15 +178,15 @@ Important:
|
||||
<ul class="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-center">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
Generate secure random password
|
||||
{isRotation ? 'Generate a new secure password' : 'Generate secure random password'}
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
Enable basic authentication
|
||||
{isRotation ? 'Replace the stored admin password' : 'Enable basic authentication'}
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
Create API token for automation
|
||||
{isRotation ? 'Create a new API token for automation' : 'Create API token for automation'}
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
@@ -276,6 +283,11 @@ Important:
|
||||
<p>{useCustomPassword()
|
||||
? 'Your password will be hashed before storage'
|
||||
: 'Credentials will be shown only once. Save them immediately!'}</p>
|
||||
<Show when={isRotation}>
|
||||
<p class="mt-1">
|
||||
Existing sessions will be logged out once Pulse restarts with the new credentials.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,10 +303,10 @@ Important:
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Setting up security...
|
||||
{isRotation ? 'Rotating credentials...' : 'Setting up security...'}
|
||||
</span>
|
||||
) : (
|
||||
'Enable Security Now'
|
||||
(isRotation ? 'Rotate credentials' : 'Enable Security Now')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -304,7 +316,7 @@ Important:
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<SectionHeader
|
||||
title="🎉 Security enabled successfully!"
|
||||
title={isRotation ? 'Admin credentials generated' : 'Security enabled successfully'}
|
||||
size="md"
|
||||
class="flex-1"
|
||||
titleClass="text-gray-900 dark:text-gray-100"
|
||||
@@ -313,7 +325,7 @@ Important:
|
||||
onClick={downloadCredentials}
|
||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Download Credentials
|
||||
Download credentials
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -377,14 +389,14 @@ Important:
|
||||
Use this token with the X-API-Token header for automation.
|
||||
</p>
|
||||
<p class="mt-1 text-xs font-semibold text-red-600 dark:text-red-400">
|
||||
⚠️ This token will never be shown again. Save it now!
|
||||
This token is only shown once. Save it now.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<p class="text-sm font-semibold text-green-800 dark:text-green-200 mb-2">
|
||||
✅ Security configured successfully!
|
||||
Credentials saved
|
||||
</p>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
The service needs to be restarted for security settings to take effect.
|
||||
|
||||
@@ -162,6 +162,7 @@ const Settings: Component = () => {
|
||||
const [apiTokenInput, setApiTokenInput] = createSignal('');
|
||||
const [apiTokenModalSource, setApiTokenModalSource] = createSignal<'export' | 'import' | null>(null);
|
||||
const [showQuickSecuritySetup, setShowQuickSecuritySetup] = createSignal(false);
|
||||
const [showQuickSecurityWizard, setShowQuickSecurityWizard] = createSignal(false);
|
||||
|
||||
const formatTimestamp = (timestamp?: string) => {
|
||||
if (!timestamp) {
|
||||
@@ -1589,39 +1590,47 @@ const Settings: Component = () => {
|
||||
{/* System Settings Tab */}
|
||||
<Show when={activeTab() === 'system'}>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<SectionHeader title="System configuration" size="md" class="mb-4" />
|
||||
|
||||
{/* Environment Variable Info */}
|
||||
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p class="font-medium mb-1">Configuration Priority:</p>
|
||||
<p>• Some env vars override settings (API_TOKEN, PORTS, AUTH)</p>
|
||||
<p>• Changes made here are saved to system.json immediately</p>
|
||||
<p>• Settings persist unless overridden by env vars</p>
|
||||
</div>
|
||||
<SectionHeader title="System configuration" size="md" class="mb-2" />
|
||||
|
||||
<Card
|
||||
tone="info"
|
||||
padding="md"
|
||||
border={false}
|
||||
class="border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p class="font-medium mb-1">Configuration Priority</p>
|
||||
<ul class="space-y-1">
|
||||
<li>• Some env vars override settings (API_TOKEN, PORTS, AUTH)</li>
|
||||
<li>• Changes made here are saved to system.json immediately</li>
|
||||
<li>• Settings persist unless overridden by env vars</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
{/* Network Settings */}
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-5">
|
||||
<Card padding="lg" class="space-y-6 lg:col-span-3">
|
||||
<section class="space-y-3">
|
||||
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"></path>
|
||||
</svg>
|
||||
Network Settings
|
||||
</h4>
|
||||
|
||||
<div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">CORS Allowed Origins</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">For reverse proxy setups (* = allow all, empty = same-origin only)</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">For reverse proxy setups (* = allow all, empty = same-origin only)</p>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1635,8 +1644,8 @@ const Settings: Component = () => {
|
||||
disabled={envOverrides().allowedOrigins}
|
||||
placeholder="* or https://example.com"
|
||||
class={`w-full px-3 py-1.5 text-sm border rounded-lg ${
|
||||
envOverrides().allowedOrigins
|
||||
? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
|
||||
envOverrides().allowedOrigins
|
||||
? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
|
||||
}`}
|
||||
/>
|
||||
@@ -1655,67 +1664,71 @@ const Settings: Component = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Iframe Embedding Settings */}
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Iframe Embedding</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="14" rx="2"></rect>
|
||||
<path d="M7 20h10"></path>
|
||||
</svg>
|
||||
Embedding
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)</p>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allowEmbedding"
|
||||
checked={allowEmbedding()}
|
||||
onChange={(e) => {
|
||||
setAllowEmbedding(e.currentTarget.checked);
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label for="allowEmbedding" class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Allow iframe embedding
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={allowEmbedding()}>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">Allowed Embed Origins (optional)</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)</p>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allowEmbedding"
|
||||
checked={allowEmbedding()}
|
||||
type="text"
|
||||
value={allowedEmbedOrigins()}
|
||||
onChange={(e) => {
|
||||
setAllowEmbedding(e.currentTarget.checked);
|
||||
setAllowedEmbedOrigins(e.currentTarget.value);
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
placeholder="https://my.domain, https://dashboard.example.com"
|
||||
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||
/>
|
||||
<label for="allowEmbedding" class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Allow iframe embedding
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Example: If Pulse is at <code>pulse.my.domain</code> and your dashboard is at <code>my.domain</code>, add <code>https://my.domain</code> here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={allowEmbedding()}>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">Allowed Embed Origins (optional)</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)</p>
|
||||
<input
|
||||
type="text"
|
||||
value={allowedEmbedOrigins()}
|
||||
onChange={(e) => {
|
||||
setAllowedEmbedOrigins(e.currentTarget.value);
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
placeholder="https://my.domain, https://dashboard.example.com"
|
||||
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Example: If Pulse is at <code>pulse.my.domain</code> and your dashboard is at <code>my.domain</code>,
|
||||
add <code>https://my.domain</code> here.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p class="text-xs text-amber-800 dark:text-amber-200 mb-2">
|
||||
<strong>Port Configuration:</strong> Use <code class="font-mono bg-amber-100 dark:bg-amber-800 px-1 rounded">systemctl edit pulse</code>
|
||||
</p>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-300 font-mono">
|
||||
[Service]<br/>
|
||||
Environment="FRONTEND_PORT=8080"<br/>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">Then restart: sudo systemctl restart pulse</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Settings */}
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
</section>
|
||||
|
||||
<Card tone="warning" padding="sm" border={false} class="border border-amber-200 dark:border-amber-800">
|
||||
<p class="text-xs text-amber-800 dark:text-amber-200 mb-2">
|
||||
<strong>Port Configuration:</strong> Use <code class="font-mono bg-amber-100 dark:bg-amber-800 px-1 rounded">systemctl edit pulse</code>
|
||||
</p>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-300 font-mono">
|
||||
[Service]<br/>
|
||||
Environment="FRONTEND_PORT=8080"<br/>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">Then restart: sudo systemctl restart pulse</span>
|
||||
</p>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="space-y-6 lg:col-span-2">
|
||||
<section class="space-y-4">
|
||||
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
@@ -1723,10 +1736,9 @@ const Settings: Component = () => {
|
||||
</svg>
|
||||
Updates
|
||||
</h4>
|
||||
|
||||
|
||||
<div class="space-y-4">
|
||||
{/* Version Info */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Current Version</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
@@ -1754,8 +1766,7 @@ const Settings: Component = () => {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Docker Message */}
|
||||
|
||||
<Show when={versionInfo()?.isDocker && !updateInfo()?.available}>
|
||||
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p class="text-xs text-blue-800 dark:text-blue-200">
|
||||
@@ -1763,11 +1774,10 @@ const Settings: Component = () => {
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Update Available */}
|
||||
|
||||
<Show when={updateInfo()?.available}>
|
||||
<div class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div class="mb-2">
|
||||
<div class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg space-y-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Update Available: {updateInfo()?.latestVersion}
|
||||
</p>
|
||||
@@ -1775,10 +1785,9 @@ const Settings: Component = () => {
|
||||
Released: {updateInfo()?.releaseDate ? new Date(updateInfo()!.releaseDate).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Update Instructions based on deployment type */}
|
||||
<div class="mt-3 p-2 bg-green-100 dark:bg-green-900/40 rounded">
|
||||
<p class="text-xs font-medium text-green-800 dark:text-green-200 mb-1">How to update:</p>
|
||||
|
||||
<div class="p-2 bg-green-100 dark:bg-green-900/40 rounded space-y-2">
|
||||
<p class="text-xs font-medium text-green-800 dark:text-green-200">How to update:</p>
|
||||
<Show when={versionInfo()?.deploymentType === 'proxmoxve'}>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
Type <code class="px-1 py-0.5 bg-green-200 dark:bg-green-800 rounded">update</code> in the LXC console
|
||||
@@ -1806,10 +1815,15 @@ const Settings: Component = () => {
|
||||
Pull latest changes and rebuild
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={!versionInfo()?.deploymentType && versionInfo()?.isDocker}>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
Pull the latest Pulse Docker image and recreate your container.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
|
||||
<Show when={updateInfo()?.releaseNotes}>
|
||||
<details class="mt-2">
|
||||
<details class="mt-1">
|
||||
<summary class="text-xs text-green-700 dark:text-green-300 cursor-pointer">Release Notes</summary>
|
||||
<pre class="mt-2 text-xs text-green-600 dark:text-green-400 whitespace-pre-wrap font-mono bg-green-100 dark:bg-green-900/30 p-2 rounded">
|
||||
{updateInfo()?.releaseNotes}
|
||||
@@ -1818,11 +1832,9 @@ const Settings: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
{/* Update Settings */}
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Update Channel</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
@@ -1842,8 +1854,8 @@ const Settings: Component = () => {
|
||||
<option value="rc">Release Candidate</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Update Checks</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
@@ -1864,10 +1876,10 @@ const Settings: Component = () => {
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<Show when={autoUpdateEnabled()}>
|
||||
<div class="pl-4 space-y-4 border-l-2 border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-4 rounded-md border border-gray-200 dark:border-gray-600 p-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Check Interval</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
@@ -1888,8 +1900,8 @@ const Settings: Component = () => {
|
||||
<option value="168">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Check Time</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
@@ -1910,11 +1922,10 @@ const Settings: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Backup & Restore - Moved from Security tab */}
|
||||
<Card padding="lg" border={false} class="border border-gray-200 dark:border-gray-700">
|
||||
<SectionHeader
|
||||
@@ -2128,46 +2139,63 @@ const Settings: Component = () => {
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Change password button clicked, current modal state:', showPasswordModal());
|
||||
setShowPasswordModal(true);
|
||||
console.log('Modal state after setting:', showPasswordModal());
|
||||
}}
|
||||
class="flex items-center gap-3 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-all group cursor-pointer"
|
||||
>
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg group-hover:bg-blue-200 dark:group-hover:bg-blue-900/50 transition-colors">
|
||||
<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 class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Credential controls</p>
|
||||
<p class="mt-1 leading-relaxed">
|
||||
Update the administrator password for routine maintenance, or rotate both the password and API token when you need a full credential refresh.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-start gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowPasswordModal(true);
|
||||
}}
|
||||
class="flex items-center gap-3 px-4 py-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-all"
|
||||
>
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 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>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Change password</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Keep existing API token</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Need to replace both the password and API token?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQuickSecurityWizard(!showQuickSecurityWizard())}
|
||||
class={`inline-flex items-center gap-2 text-sm font-medium text-indigo-600 dark:text-indigo-300 hover:text-indigo-700 dark:hover:text-indigo-200 transition-colors ${showQuickSecurityWizard() ? 'underline' : ''}`}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Generate new credentials</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Change Password</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Update your login credentials</div>
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/rcourtman/Pulse/blob/main/docs/SECURITY.md#first-run-security-setup"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="flex items-center gap-3 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-all group"
|
||||
>
|
||||
<div class="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg group-hover:bg-indigo-200 dark:group-hover:bg-indigo-900/50 transition-colors">
|
||||
<svg class="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Run quick security wizard</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Refresh authentication end-to-end</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 text-xs text-gray-600 dark:text-gray-400 md:grid-cols-2">
|
||||
<Show when={showQuickSecurityWizard()}>
|
||||
<div class="mt-6">
|
||||
<QuickSecuritySetup
|
||||
mode="rotate"
|
||||
defaultUsername={securityStatus()?.authUsername || 'admin'}
|
||||
onConfigured={() => {
|
||||
setShowQuickSecurityWizard(false);
|
||||
loadSecurityStatus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-8 grid gap-3 text-xs text-gray-600 dark:text-gray-400 md:grid-cols-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 mt-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 15c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface AuthConfig {
|
||||
PULSE_AUTH_USER: string; // Admin username
|
||||
PULSE_AUTH_PASS: string; // Bcrypt hashed password
|
||||
API_TOKEN: string; // API authentication token
|
||||
ENABLE_AUDIT_LOG?: boolean; // Enable audit logging
|
||||
ENABLE_AUDIT_LOG?: boolean; // @deprecated - use PULSE_AUDIT_LOG
|
||||
PULSE_AUDIT_LOG?: boolean; // Enable audit logging
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+112
-110
@@ -20,7 +20,7 @@ import (
|
||||
func detectServiceName() string {
|
||||
// Try common service names
|
||||
services := []string{"pulse-backend", "pulse", "pulse.service", "pulse-backend.service"}
|
||||
|
||||
|
||||
for _, service := range services {
|
||||
cmd := exec.Command("systemctl", "status", service)
|
||||
if err := cmd.Run(); err == nil {
|
||||
@@ -31,7 +31,7 @@ func detectServiceName() string {
|
||||
return service
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Default to pulse-backend if no service found
|
||||
return "pulse-backend"
|
||||
}
|
||||
@@ -68,9 +68,22 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if password auth is already configured
|
||||
// Allow adding password auth on top of API-only access
|
||||
if r.config.AuthUser != "" && r.config.AuthPass != "" {
|
||||
// Parse request body
|
||||
var setupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
APIToken string `json:"apiToken"`
|
||||
EnableNotifications bool `json:"enableNotifications"`
|
||||
DarkMode bool `json:"darkMode"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.config.AuthUser != "" && r.config.AuthPass != "" && !setupRequest.Force {
|
||||
log.Info().Msg("Security setup skipped - password auth already configured")
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -82,33 +95,22 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var setupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
APIToken string `json:"apiToken"`
|
||||
EnableNotifications bool `json:"enableNotifications"`
|
||||
DarkMode bool `json:"darkMode"`
|
||||
if setupRequest.Force {
|
||||
log.Info().Msg("Quick security setup invoked with force=true - rotating credentials")
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Validate inputs
|
||||
if setupRequest.Username == "" || setupRequest.Password == "" || setupRequest.APIToken == "" {
|
||||
http.Error(w, "Username, password, and API token are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Validate password complexity
|
||||
if err := internalauth.ValidatePasswordComplexity(setupRequest.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := internalauth.HashPassword(setupRequest.Password)
|
||||
if err != nil {
|
||||
@@ -116,59 +118,59 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
||||
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Validate the bcrypt hash is complete
|
||||
if err := validateBcryptHash(hashedPassword); err != nil {
|
||||
log.Error().Err(err).Msg("Generated invalid bcrypt hash")
|
||||
http.Error(w, fmt.Sprintf("Password hashing error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Store the raw API token for displaying to the user
|
||||
rawAPIToken := setupRequest.APIToken
|
||||
|
||||
|
||||
// Hash the API token for storage
|
||||
hashedAPIToken := internalauth.HashAPIToken(rawAPIToken)
|
||||
|
||||
|
||||
if r.config.APIToken != "" && 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")
|
||||
}
|
||||
|
||||
|
||||
// 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.APITokenEnabled = true
|
||||
log.Info().Msg("Runtime config updated with new security settings - active immediately")
|
||||
|
||||
|
||||
// Save system settings to system.json
|
||||
systemSettings := config.SystemSettings{
|
||||
ConnectionTimeout: 10, // Default
|
||||
AutoUpdateEnabled: false, // Default
|
||||
ConnectionTimeout: 10, // Default
|
||||
AutoUpdateEnabled: false, // Default
|
||||
}
|
||||
if err := r.persistence.SaveSystemSettings(systemSettings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save system settings")
|
||||
// Continue anyway - not critical for auth setup
|
||||
}
|
||||
|
||||
|
||||
// Detect environment
|
||||
isSystemd := os.Getenv("INVOCATION_ID") != ""
|
||||
isDocker := os.Getenv("PULSE_DOCKER") == "true"
|
||||
isRoot := isRunningAsRoot()
|
||||
|
||||
|
||||
// Detect actual service name if systemd
|
||||
serviceName := ""
|
||||
if isSystemd {
|
||||
serviceName = detectServiceName()
|
||||
log.Info().Str("service", serviceName).Msg("Detected systemd service name")
|
||||
}
|
||||
|
||||
|
||||
// Choose appropriate method based on environment
|
||||
if isDocker {
|
||||
// Docker: Save to /data/.env with proper quoting
|
||||
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
||||
|
||||
|
||||
// CRITICAL: Use single quotes to prevent shell expansion of $ in bcrypt hash
|
||||
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
||||
# Generated on %s
|
||||
@@ -176,48 +178,48 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
||||
PULSE_AUTH_USER='%s'
|
||||
PULSE_AUTH_PASS='%s'
|
||||
API_TOKEN=%s
|
||||
ENABLE_AUDIT_LOG=true
|
||||
PULSE_AUDIT_LOG=true
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
|
||||
|
||||
|
||||
// Ensure directory exists
|
||||
os.MkdirAll(r.config.ConfigPath, 0755)
|
||||
|
||||
|
||||
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||
log.Error().Err(err).Str("path", envPath).Msg("Failed to write .env file in Docker")
|
||||
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
log.Info().Str("path", envPath).Msg("Docker security configuration saved")
|
||||
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"method": "docker",
|
||||
"deploymentType": "docker",
|
||||
"success": true,
|
||||
"method": "docker",
|
||||
"deploymentType": "docker",
|
||||
"requiresManualRestart": false,
|
||||
"message": "Security enabled immediately! Your settings are saved and active.",
|
||||
"note": "Configuration saved to /data/.env for persistence across restarts.",
|
||||
"message": "Security enabled immediately! Your settings are saved and active.",
|
||||
"note": "Configuration saved to /data/.env for persistence across restarts.",
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
|
||||
} else if isSystemd && !isRoot {
|
||||
// Systemd but not root (ProxmoxVE script scenario)
|
||||
// Don't attempt sudo, just save config and provide instructions
|
||||
|
||||
|
||||
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
||||
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
||||
# Generated on %s
|
||||
PULSE_AUTH_USER='%s'
|
||||
PULSE_AUTH_PASS='%s'
|
||||
API_TOKEN=%s
|
||||
ENABLE_AUDIT_LOG=true
|
||||
PULSE_AUDIT_LOG=true
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
|
||||
|
||||
|
||||
// Save to config directory (usually /etc/pulse)
|
||||
os.MkdirAll(r.config.ConfigPath, 0755)
|
||||
|
||||
|
||||
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||
// Try data directory as fallback
|
||||
envPath = filepath.Join(r.config.DataPath, ".env")
|
||||
@@ -228,103 +230,103 @@ ENABLE_AUDIT_LOG=true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create response - security is active immediately
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"method": "systemd-nonroot",
|
||||
"serviceName": serviceName,
|
||||
"envFile": envPath,
|
||||
"deploymentType": updates.GetDeploymentType(),
|
||||
"success": true,
|
||||
"method": "systemd-nonroot",
|
||||
"serviceName": serviceName,
|
||||
"envFile": envPath,
|
||||
"deploymentType": updates.GetDeploymentType(),
|
||||
"requiresManualRestart": false,
|
||||
"message": "Security enabled immediately! Your settings are saved and active.",
|
||||
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
|
||||
"message": "Security enabled immediately! Your settings are saved and active.",
|
||||
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
|
||||
} else if isSystemd && isRoot {
|
||||
// Systemd with root - can apply directly
|
||||
|
||||
|
||||
// Create systemd override
|
||||
overridePath := fmt.Sprintf("/etc/systemd/system/%s.service.d/override.conf", serviceName)
|
||||
overrideDir := filepath.Dir(overridePath)
|
||||
|
||||
|
||||
if err := os.MkdirAll(overrideDir, 0755); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create override directory")
|
||||
http.Error(w, "Failed to create systemd override directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
overrideContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
||||
# Generated on %s
|
||||
[Service]
|
||||
Environment="PULSE_AUTH_USER=%s"
|
||||
Environment="PULSE_AUTH_PASS=%s"
|
||||
Environment="API_TOKEN=%s"
|
||||
Environment="ENABLE_AUDIT_LOG=true"
|
||||
Environment="PULSE_AUDIT_LOG=true"
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
|
||||
|
||||
|
||||
if err := os.WriteFile(overridePath, []byte(overrideContent), 0644); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write systemd override")
|
||||
http.Error(w, "Failed to write systemd override", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Reload systemd
|
||||
exec.Command("systemctl", "daemon-reload").Run()
|
||||
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"method": "systemd-root",
|
||||
"serviceName": serviceName,
|
||||
"deploymentType": updates.GetDeploymentType(),
|
||||
"automatic": true,
|
||||
"success": true,
|
||||
"method": "systemd-root",
|
||||
"serviceName": serviceName,
|
||||
"deploymentType": updates.GetDeploymentType(),
|
||||
"automatic": true,
|
||||
"requiresManualRestart": false,
|
||||
"message": "Security enabled immediately! Your settings are saved and active.",
|
||||
"note": "Systemd override created for persistence across restarts.",
|
||||
"message": "Security enabled immediately! Your settings are saved and active.",
|
||||
"note": "Systemd override created for persistence across restarts.",
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
|
||||
} else {
|
||||
// Manual installation or development
|
||||
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
||||
if r.config.ConfigPath == "" {
|
||||
envPath = "/etc/pulse/.env"
|
||||
}
|
||||
|
||||
|
||||
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
||||
# Generated on %s
|
||||
PULSE_AUTH_USER='%s'
|
||||
PULSE_AUTH_PASS='%s'
|
||||
API_TOKEN=%s
|
||||
ENABLE_AUDIT_LOG=true
|
||||
PULSE_AUDIT_LOG=true
|
||||
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
|
||||
|
||||
|
||||
// Try to create directory if needed
|
||||
os.MkdirAll(filepath.Dir(envPath), 0755)
|
||||
|
||||
|
||||
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write .env file")
|
||||
// Still return success with manual instructions
|
||||
}
|
||||
|
||||
|
||||
// Get deployment type for restart instructions
|
||||
deploymentType := updates.GetDeploymentType()
|
||||
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"method": "manual",
|
||||
"envFile": envPath,
|
||||
"deploymentType": deploymentType,
|
||||
"success": true,
|
||||
"method": "manual",
|
||||
"envFile": envPath,
|
||||
"deploymentType": deploymentType,
|
||||
"requiresManualRestart": false,
|
||||
"message": "Security enabled immediately! Your settings are saved and active.",
|
||||
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
|
||||
"message": "Security enabled immediately! Your settings are saved and active.",
|
||||
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -339,7 +341,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
if !r.config.DisableAuth && (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check if using proxy auth and if so, verify admin status
|
||||
if r.config.ProxyAuthSecret != "" {
|
||||
if valid, username, isAdmin := CheckProxyAuth(r.config, rq); valid {
|
||||
@@ -351,7 +353,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
Str("method", rq.Method).
|
||||
Str("username", username).
|
||||
Msg("Non-admin user attempted to regenerate API token")
|
||||
|
||||
|
||||
// Return forbidden error
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
@@ -360,12 +362,12 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if rq.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Apply rate limiting to prevent abuse
|
||||
clientIP := GetClientIP(rq)
|
||||
if !authLimiter.Allow(clientIP) {
|
||||
@@ -373,7 +375,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Generate new token using the auth package
|
||||
rawToken, err := internalauth.GenerateAPIToken()
|
||||
if err != nil {
|
||||
@@ -381,26 +383,26 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Hash the token for storage
|
||||
hashedToken := internalauth.HashAPIToken(rawToken)
|
||||
|
||||
|
||||
// Update runtime config immediately with hashed token - no restart needed!
|
||||
r.config.APIToken = hashedToken
|
||||
r.config.APITokenEnabled = true
|
||||
log.Info().Msg("Runtime config updated with new hashed API token - active immediately")
|
||||
|
||||
|
||||
// Determine env file path
|
||||
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
||||
if r.config.ConfigPath == "" {
|
||||
envPath = "/etc/pulse/.env"
|
||||
}
|
||||
|
||||
|
||||
// Docker uses /data/.env
|
||||
if _, err := os.Stat("/data/.env"); err == nil {
|
||||
envPath = "/data/.env"
|
||||
}
|
||||
|
||||
|
||||
// Read existing .env file
|
||||
content, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
@@ -408,7 +410,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
http.Error(w, "Security configuration not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Update the API_TOKEN line with the hashed token
|
||||
lines := strings.Split(string(content), "\n")
|
||||
var updated bool
|
||||
@@ -419,12 +421,12 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !updated {
|
||||
// API_TOKEN line not found, add it
|
||||
lines = append(lines, fmt.Sprintf("API_TOKEN=%s", hashedToken))
|
||||
}
|
||||
|
||||
|
||||
// Write updated content back
|
||||
newContent := strings.Join(lines, "\n")
|
||||
if err := os.WriteFile(envPath, []byte(newContent), 0600); err != nil {
|
||||
@@ -432,20 +434,20 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
||||
http.Error(w, "Failed to save new token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
log.Info().Msg("API token regenerated successfully")
|
||||
|
||||
|
||||
// Get deployment type for restart instructions
|
||||
deploymentType := updates.GetDeploymentType()
|
||||
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"token": rawToken, // Return the raw token to the user (only shown once!)
|
||||
"deploymentType": deploymentType,
|
||||
"success": true,
|
||||
"token": rawToken, // Return the raw token to the user (only shown once!)
|
||||
"deploymentType": deploymentType,
|
||||
"requiresRestart": false,
|
||||
"message": "New API token generated and active immediately! Save this token - it won't be shown again.",
|
||||
"message": "New API token generated and active immediately! Save this token - it won't be shown again.",
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user