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:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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,15 +18,24 @@ 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();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [envContent, setEnvContent] = useState<string | null>(null);
|
||||
const [envContent, setEnvContent] = useState<string | null>(null);
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user