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)
|
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
|
||||||
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
|
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
|
||||||
subsystemHandler := handlers.NewSubsystemHandler(subsystemQueries, commandQueries)
|
subsystemHandler := handlers.NewSubsystemHandler(subsystemQueries, commandQueries)
|
||||||
deploymentHandler := handlers.NewDeploymentHandler(registrationTokenQueries, agentQueries)
|
|
||||||
|
|
||||||
// Initialize verification handler
|
// Initialize verification handler
|
||||||
var verificationHandler *handlers.VerificationHandler
|
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/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
|
||||||
admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)
|
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
|
// Rate Limit Management
|
||||||
admin.GET("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitSettings)
|
admin.GET("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitSettings)
|
||||||
admin.PUT("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.UpdateRateLimitSettings)
|
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',
|
name: 'Settings',
|
||||||
href: '/settings',
|
href: '/settings',
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
current: location.pathname === '/settings',
|
current: location.pathname.startsWith('/settings'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { toast } from 'react-hot-toast';
|
||||||
import { setupApi } from '@/lib/api';
|
import { setupApi } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
@@ -18,6 +18,13 @@ interface SetupFormData {
|
|||||||
maxSeats: string;
|
maxSeats: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SigningKeys {
|
||||||
|
public_key: string;
|
||||||
|
private_key: string;
|
||||||
|
fingerprint: string;
|
||||||
|
algorithm: string;
|
||||||
|
}
|
||||||
|
|
||||||
const Setup: React.FC = () => {
|
const Setup: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { logout } = useAuthStore();
|
const { logout } = useAuthStore();
|
||||||
@@ -27,6 +34,8 @@ const Setup: React.FC = () => {
|
|||||||
const [showSuccess, setShowSuccess] = useState(false);
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||||
|
const [signingKeys, setSigningKeys] = useState<SigningKeys | null>(null);
|
||||||
|
const [generatingKeys, setGeneratingKeys] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<SetupFormData>({
|
const [formData, setFormData] = useState<SetupFormData>({
|
||||||
adminUser: 'admin',
|
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 => {
|
const validateForm = (): boolean => {
|
||||||
if (!formData.adminUser.trim()) {
|
if (!formData.adminUser.trim()) {
|
||||||
setError('Admin username is required');
|
setError('Admin username is required');
|
||||||
@@ -114,7 +144,13 @@ const Setup: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const result = await setupApi.configure(formData);
|
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);
|
setShowSuccess(true);
|
||||||
toast.success(result.message || 'Configuration saved successfully!');
|
toast.success(result.message || 'Configuration saved successfully!');
|
||||||
|
|
||||||
@@ -322,6 +358,69 @@ const Setup: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Database Configuration */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user