feat: setup wizard key generation

added ed25519 keypair generation to setup endpoint
wired route for POST /api/setup/generate-keys

existing registration token system handles deployment
This commit is contained in:
Fimeg
2025-11-02 09:47:16 -05:00
parent 822f57bbdc
commit 0062e2acab
4 changed files with 103 additions and 107 deletions

View File

@@ -172,7 +172,6 @@ func main() {
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
subsystemHandler := handlers.NewSubsystemHandler(subsystemQueries, commandQueries)
deploymentHandler := handlers.NewDeploymentHandler(registrationTokenQueries, agentQueries)
// Initialize verification handler
var verificationHandler *handlers.VerificationHandler
@@ -320,12 +319,6 @@ func main() {
admin.GET("/registration-tokens/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)
// Deployment routes (token management with install commands)
admin.GET("/deployment/tokens", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), deploymentHandler.ListTokens)
admin.POST("/deployment/tokens", rateLimiter.RateLimit("admin_token_gen", middleware.KeyByUserID), deploymentHandler.CreateToken)
admin.GET("/deployment/tokens/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), deploymentHandler.GetToken)
admin.DELETE("/deployment/tokens/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), deploymentHandler.RevokeToken)
// Rate Limit Management
admin.GET("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitSettings)
admin.PUT("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.UpdateRateLimitSettings)

View File

@@ -1,96 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/gin-gonic/gin"
)
type DeploymentHandler struct {
registrationTokenQueries *queries.RegistrationTokenQueries
agentQueries *queries.AgentQueries
}
func NewDeploymentHandler(regTokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries) *DeploymentHandler {
return &DeploymentHandler{
registrationTokenQueries: regTokenQueries,
agentQueries: agentQueries,
}
}
// ListTokens returns all registration tokens
func (h *DeploymentHandler) ListTokens(c *gin.Context) {
tokens, err := h.registrationTokenQueries.ListAllTokens()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"})
return
}
c.JSON(http.StatusOK, gin.H{"tokens": tokens})
}
// CreateToken creates a new registration token
func (h *DeploymentHandler) CreateToken(c *gin.Context) {
var req struct {
MaxSeats int `json:"max_seats" binding:"required,min=1"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
Note string `json:"note"` // Optional note/label
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Parse expiration duration
var duration time.Duration
var err error
if req.ExpiresIn != "" {
duration, err = time.ParseDuration(req.ExpiresIn)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid expires_in format - use Go duration (e.g., 24h, 168h)"})
return
}
} else {
duration = 24 * time.Hour // Default 24 hours
}
expiresAt := time.Now().Add(duration)
token, err := h.registrationTokenQueries.CreateToken(req.MaxSeats, expiresAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
return
}
// Build install command
serverURL := c.Request.Host
installCommand := fmt.Sprintf("curl -sSL http://%s/api/v1/install/linux | bash -s -- %s", serverURL, token.Token)
c.JSON(http.StatusOK, gin.H{
"token": token,
"install_command": installCommand,
"message": "registration token created",
})
}
// RevokeToken revokes a registration token
func (h *DeploymentHandler) RevokeToken(c *gin.Context) {
tokenID := c.Param("id")
if err := h.registrationTokenQueries.RevokeToken(tokenID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "token revoked"})
}
// GetToken returns details about a specific token
func (h *DeploymentHandler) GetToken(c *gin.Context) {
tokenID := c.Param("id")
token, err := h.registrationTokenQueries.GetTokenByID(tokenID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}

View File

@@ -74,7 +74,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
name: 'Settings',
href: '/settings',
icon: Settings,
current: location.pathname === '/settings',
current: location.pathname.startsWith('/settings'),
},
];

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle } from 'lucide-react';
import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle, Key } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { setupApi } from '@/lib/api';
import { useAuthStore } from '@/lib/store';
@@ -18,6 +18,13 @@ interface SetupFormData {
maxSeats: string;
}
interface SigningKeys {
public_key: string;
private_key: string;
fingerprint: string;
algorithm: string;
}
const Setup: React.FC = () => {
const navigate = useNavigate();
const { logout } = useAuthStore();
@@ -27,6 +34,8 @@ const Setup: React.FC = () => {
const [showSuccess, setShowSuccess] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showDbPassword, setShowDbPassword] = useState(false);
const [signingKeys, setSigningKeys] = useState<SigningKeys | null>(null);
const [generatingKeys, setGeneratingKeys] = useState(false);
const [formData, setFormData] = useState<SetupFormData>({
adminUser: 'admin',
@@ -49,6 +58,27 @@ const Setup: React.FC = () => {
}));
};
const handleGenerateKeys = async () => {
setGeneratingKeys(true);
try {
const response = await fetch('/api/setup/generate-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to generate keys');
}
const keys: SigningKeys = await response.json();
setSigningKeys(keys);
toast.success('Signing keys generated successfully!');
} catch (error: any) {
toast.error(error.message || 'Failed to generate keys');
setError('Failed to generate signing keys');
} finally {
setGeneratingKeys(false);
}
};
const validateForm = (): boolean => {
if (!formData.adminUser.trim()) {
setError('Admin username is required');
@@ -114,7 +144,13 @@ const Setup: React.FC = () => {
try {
const result = await setupApi.configure(formData);
setEnvContent(result.envContent || null);
// Add signing keys to env content if generated
let finalEnvContent = result.envContent || '';
if (signingKeys && finalEnvContent) {
finalEnvContent += `\n# Ed25519 Signing Keys (for agent updates)\nREDFLAG_SIGNING_PRIVATE_KEY=${signingKeys.private_key}\n`;
}
setEnvContent(finalEnvContent || null);
setShowSuccess(true);
toast.success(result.message || 'Configuration saved successfully!');
@@ -322,6 +358,69 @@ const Setup: React.FC = () => {
</div>
</div>
{/* Security Keys Section */}
<div>
<div className="flex items-center mb-4">
<Key className="h-5 w-5 text-indigo-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Security Keys</h3>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-md p-4 mb-4">
<p className="text-sm text-blue-800">
Generate Ed25519 signing keys for secure agent updates.
<strong> Save the private key securely</strong> - it will be included in your configuration.
</p>
</div>
{!signingKeys ? (
<button
type="button"
onClick={handleGenerateKeys}
disabled={generatingKeys}
className="w-full py-2 px-4 border border-indigo-600 text-indigo-600 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{generatingKeys ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2"></div>
Generating Keys...
</>
) : (
<>
<Key className="h-4 w-4 mr-2" />
Generate Signing Keys
</>
)}
</button>
) : (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Public Key Fingerprint
</label>
<input
readOnly
value={signingKeys.fingerprint}
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Algorithm
</label>
<input
readOnly
value={signingKeys.algorithm.toUpperCase()}
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md text-sm"
/>
</div>
<div className="bg-green-50 border border-green-200 rounded-md p-3">
<p className="text-sm text-green-800">
✓ Keys generated! Private key will be securely included in your configuration file.
</p>
</div>
</div>
)}
</div>
{/* Database Configuration */}
<div>
<div className="flex items-center mb-4">