feat: add Quick Security Setup wizard for one-click security hardening

- Created QuickSecuritySetup component with password/token generation
- Added /api/security/quick-setup endpoint to generate config
- Shows credentials once with copy/download functionality
- Generates systemd environment configuration file
- Only shows when authentication is not already enabled
This commit is contained in:
Pulse Monitor 2025-08-12 20:10:21 +00:00
parent 5e6a8357af
commit da6dc52a91
3 changed files with 327 additions and 0 deletions

View file

@ -0,0 +1,251 @@
import { Component, createSignal, Show } from 'solid-js';
import { showSuccess, showError } from '@/utils/toast';
interface SecurityCredentials {
username: string;
password: string;
apiToken?: string;
}
export const QuickSecuritySetup: Component = () => {
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 generatePassword = (length: number = 16): string => {
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = '';
const array = new Uint8Array(length);
crypto.getRandomValues(array);
for (let i = 0; i < length; i++) {
password += charset[array[i] % charset.length];
}
return password;
};
const generateToken = (): string => {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
};
const copyToClipboard = async (text: string, type: 'username' | 'password' | 'token') => {
try {
await navigator.clipboard.writeText(text);
setCopied(type);
setTimeout(() => setCopied(null), 2000);
} catch (err) {
showError('Failed to copy to clipboard');
}
};
const setupSecurity = async () => {
setIsSettingUp(true);
try {
// Generate credentials
const newCredentials: SecurityCredentials = {
username: 'admin',
password: generatePassword(),
apiToken: generateToken()
};
// Call API to enable security
const response = await fetch('/api/security/quick-setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newCredentials)
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to setup security');
}
setCredentials(newCredentials);
setShowCredentials(true);
showSuccess('Security enabled successfully!');
} catch (error) {
showError(`Failed to setup security: ${error}`);
} finally {
setIsSettingUp(false);
}
};
const downloadCredentials = () => {
if (!credentials()) return;
const content = `Pulse Security Credentials
Generated: ${new Date().toISOString()}
Basic Authentication:
Username: ${credentials()!.username}
Password: ${credentials()!.password}
API Token: ${credentials()!.apiToken}
Important:
- Save these credentials securely
- They will not be shown again
- Use the API token for export/import operations
- Basic auth is required to access the web interface
`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pulse-credentials-${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<Show when={!showCredentials()}>
<div class="space-y-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="h-6 w-6 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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Quick Security Setup</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
Enable authentication with one click. This will:
</p>
<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
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
Enable basic authentication
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
Create API token for automation
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
Enable audit logging
</li>
</ul>
</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<div class="flex">
<svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="text-xs text-yellow-700 dark:text-yellow-300">
<p class="font-semibold">Important:</p>
<p>Credentials will be shown only once. Save them immediately!</p>
</div>
</div>
</div>
<button
onClick={setupSecurity}
disabled={isSettingUp()}
class="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSettingUp() ? (
<span class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<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...
</span>
) : (
'Enable Security Now'
)}
</button>
</div>
</Show>
<Show when={showCredentials() && credentials()}>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
🎉 Security Enabled Successfully!
</h4>
<button
onClick={downloadCredentials}
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
Download Credentials
</button>
</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">
Save these credentials now - they won't be shown again!
</p>
</div>
<div class="space-y-3">
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<div class="flex items-center space-x-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700">
{credentials()!.username}
</code>
<button
onClick={() => copyToClipboard(credentials()!.username, 'username')}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() === 'username' ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<div class="flex items-center space-x-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{credentials()!.password}
</code>
<button
onClick={() => copyToClipboard(credentials()!.password, 'password')}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() === 'password' ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">API Token</label>
<div class="flex items-center space-x-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{credentials()!.apiToken}
</code>
<button
onClick={() => copyToClipboard(credentials()!.apiToken!, 'token')}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() === 'token' ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<p class="text-xs text-blue-700 dark:text-blue-300">
<strong>Next steps:</strong> You'll need to restart Pulse with these environment variables set.
See the documentation for systemd or Docker configuration.
</p>
</div>
</div>
</Show>
</div>
);
};

View file

@ -4,6 +4,7 @@ import { showSuccess, showError } from '@/utils/toast';
import { NodeModal } from './NodeModal';
import RegistrationTokens from './RegistrationTokens';
import { APITokenManager } from './APITokenManager';
import { QuickSecuritySetup } from './QuickSecuritySetup';
import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates';
@ -118,6 +119,10 @@ const Settings: Component = () => {
requiresAuth: boolean;
exportProtected: boolean;
unprotectedExportAllowed: boolean;
hasAuthentication: boolean;
hasAuditLogging: boolean;
credentialsEncrypted: boolean;
hasHTTPS: boolean;
} | null>(null);
const [exportPassphrase, setExportPassphrase] = createSignal('');
const [importPassphrase, setImportPassphrase] = createSignal('');
@ -1471,6 +1476,13 @@ const Settings: Component = () => {
{/* Security Tab */}
<Show when={activeTab() === 'security'}>
<div class="space-y-6">
<Show when={!securityStatus()?.hasAuthentication}>
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Quick Security Setup</h3>
<QuickSecuritySetup />
</div>
</Show>
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">API Security</h3>
<APITokenManager />

View file

@ -221,6 +221,70 @@ func (r *Router) setupRoutes() {
}
})
// Quick security setup route
r.mux.HandleFunc("/api/security/quick-setup", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Parse request body
var setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
APIToken string `json:"apiToken"`
}
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
}
// Create config file for systemd environment
configContent := fmt.Sprintf(`# Pulse Security Configuration
# Generated by Quick Security Setup on %s
#
# Add these to your systemd service configuration:
# sudo systemctl edit pulse-backend
#
# [Service]
# Environment="PULSE_AUTH_USER=%s"
# Environment="PULSE_AUTH_PASS=%s"
# Environment="API_TOKEN=%s"
# Environment="ENABLE_AUDIT_LOG=true"
#
# Then restart the service:
# sudo systemctl restart pulse-backend
PULSE_AUTH_USER=%s
PULSE_AUTH_PASS=%s
API_TOKEN=%s
ENABLE_AUDIT_LOG=true
`, time.Now().Format(time.RFC3339), setupRequest.Username, setupRequest.Password, setupRequest.APIToken,
setupRequest.Username, setupRequest.Password, setupRequest.APIToken)
// Save to a temporary config file for user reference
configPath := fmt.Sprintf("%s/security-config-%d.env", r.config.DataPath, time.Now().Unix())
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
log.Error().Err(err).Msg("Failed to write security config file")
}
// Return success with instructions
response := map[string]interface{}{
"success": true,
"configPath": configPath,
"instructions": "Security configuration has been generated. Please update your systemd service configuration with the provided environment variables and restart Pulse.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Config export/import routes (requires API token for security)
r.mux.HandleFunc("/api/config/export", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {