Implement web-based welcome mode configuration
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func startWelcomeModeServer() {
|
func startWelcomeModeServer() {
|
||||||
|
setupHandler := handlers.NewSetupHandler("/app/config")
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
|
||||||
// Add CORS middleware
|
// Add CORS middleware
|
||||||
@@ -28,54 +29,10 @@ func startWelcomeModeServer() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Welcome page with setup instructions
|
// Welcome page with setup instructions
|
||||||
router.GET("/", func(c *gin.Context) {
|
router.GET("/", setupHandler.ShowSetupPage)
|
||||||
html := `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>RedFlag - Setup Required</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
||||||
h1 { color: #333; text-align: center; }
|
|
||||||
.setup-options { margin: 30px 0; }
|
|
||||||
.option { background: #f8f9fa; padding: 20px; margin: 15px 0; border-radius: 5px; border-left: 4px solid #007bff; }
|
|
||||||
.code { background: #f1f1f1; padding: 10px; border-radius: 3px; font-family: monospace; }
|
|
||||||
.status { text-align: center; color: #666; margin: 20px 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🚀 RedFlag Server</h1>
|
|
||||||
<div class="status">
|
|
||||||
<h2>⏳ Server is waiting for configuration</h2>
|
|
||||||
<p>Choose one of the setup methods below:</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setup-options">
|
// Setup endpoint for web configuration
|
||||||
<div class="option">
|
router.POST("/api/v1/setup", setupHandler.ConfigureServer)
|
||||||
<h3>Option 1: Command Line Setup</h3>
|
|
||||||
<p>Run this command in your terminal:</p>
|
|
||||||
<div class="code">docker-compose exec server ./redflag-server --setup</div>
|
|
||||||
<p>Follow the interactive prompts to configure your server.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="option">
|
|
||||||
<h3>Option 2: Web Setup (Coming Soon)</h3>
|
|
||||||
<p>Web-based configuration wizard will be available in a future release.</p>
|
|
||||||
<p>For now, use the command line setup above.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status">
|
|
||||||
<p>After configuration, the server will automatically restart.</p>
|
|
||||||
<p>Refresh this page to see the admin interface.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
c.Data(200, "text/html; charset=utf-8", []byte(html))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Setup endpoint for web configuration (future)
|
// Setup endpoint for web configuration (future)
|
||||||
router.GET("/setup", func(c *gin.Context) {
|
router.GET("/setup", func(c *gin.Context) {
|
||||||
@@ -185,6 +142,7 @@ func main() {
|
|||||||
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
|
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
|
||||||
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
|
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
|
||||||
downloadHandler := handlers.NewDownloadHandler(filepath.Join(".", "redflag-agent"))
|
downloadHandler := handlers.NewDownloadHandler(filepath.Join(".", "redflag-agent"))
|
||||||
|
setupHandler := handlers.NewSetupHandler("/app/config")
|
||||||
|
|
||||||
// Setup router
|
// Setup router
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
|||||||
313
aggregator-server/internal/api/handlers/setup.go
Normal file
313
aggregator-server/internal/api/handlers/setup.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupHandler handles server configuration
|
||||||
|
type SetupHandler struct {
|
||||||
|
configPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSetupHandler(configPath string) *SetupHandler {
|
||||||
|
return &SetupHandler{
|
||||||
|
configPath: configPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowSetupPage displays the web setup interface
|
||||||
|
func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
|
||||||
|
html := `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>RedFlag - Server Configuration</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; }
|
||||||
|
.container { max-width: 800px; margin: 0 auto; padding: 40px 20px; }
|
||||||
|
.card { background: white; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); overflow: hidden; }
|
||||||
|
.header { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; padding: 30px; text-align: center; }
|
||||||
|
.content { padding: 40px; }
|
||||||
|
h1 { margin: 0; font-size: 2.5rem; font-weight: 700; }
|
||||||
|
.subtitle { margin: 10px 0 0 0; opacity: 0.9; font-size: 1.1rem; }
|
||||||
|
.form-section { margin: 30px 0; }
|
||||||
|
.form-section h3 { color: #4f46e5; margin-bottom: 15px; font-size: 1.2rem; }
|
||||||
|
.form-group { margin-bottom: 20px; }
|
||||||
|
label { display: block; margin-bottom: 5px; font-weight: 500; color: #374151; }
|
||||||
|
input, select { width: 100%; padding: 12px; border: 2px solid #e5e7eb; border-radius: 6px; font-size: 1rem; transition: border-color 0.3s; }
|
||||||
|
input:focus, select:focus { outline: none; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); }
|
||||||
|
input[type="password"] { font-family: monospace; }
|
||||||
|
.button { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; border: none; padding: 14px 28px; border-radius: 6px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.2s; }
|
||||||
|
.button:hover { transform: translateY(-1px); }
|
||||||
|
.button:active { transform: translateY(0); }
|
||||||
|
.progress { background: #f3f4f6; border-radius: 6px; height: 8px; overflow: hidden; margin: 20px 0; }
|
||||||
|
.progress-bar { background: linear-gradient(90deg, #4f46e5, #7c3aed); height: 100%; width: 0%; transition: width 0.3s; }
|
||||||
|
.status { text-align: center; padding: 20px; display: none; }
|
||||||
|
.error { background: #fef2f2; color: #dc2626; padding: 15px; border-radius: 6px; margin: 20px 0; border: 1px solid #fecaca; }
|
||||||
|
.success { background: #f0fdf4; color: #16a34a; padding: 15px; border-radius: 6px; margin: 20px 0; border: 1px solid #bbf7d0; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🚀 RedFlag Server Setup</h1>
|
||||||
|
<p class="subtitle">Configure your update management server</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<form id="setupForm">
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>🔐 Admin Account</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminUser">Admin Username</label>
|
||||||
|
<input type="text" id="adminUser" name="adminUser" value="admin" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminPassword">Admin Password</label>
|
||||||
|
<input type="password" id="adminPassword" name="adminPassword" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>💾 Database Configuration</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dbHost">Database Host</label>
|
||||||
|
<input type="text" id="dbHost" name="dbHost" value="postgres" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dbPort">Database Port</label>
|
||||||
|
<input type="number" id="dbPort" name="dbPort" value="5432" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dbName">Database Name</label>
|
||||||
|
<input type="text" id="dbName" name="dbName" value="redflag" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dbUser">Database User</label>
|
||||||
|
<input type="text" id="dbUser" name="dbUser" value="redflag" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dbPassword">Database Password</label>
|
||||||
|
<input type="password" id="dbPassword" name="dbPassword" value="redflag" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>🌐 Server Configuration</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="serverHost">Server Host</label>
|
||||||
|
<input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="serverPort">Server Port</label>
|
||||||
|
<input type="number" id="serverPort" name="serverPort" value="8080" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="maxSeats">Maximum Agent Seats</label>
|
||||||
|
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress" id="progress" style="display: none;">
|
||||||
|
<div class="progress-bar" id="progressBar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
|
||||||
|
<button type="submit" class="button">Configure Server</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('setupForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
|
const progress = document.getElementById('progress');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const submitButton = e.target.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
|
// Show progress and disable button
|
||||||
|
progress.style.display = 'block';
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = 'Configuring...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Success
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
status.innerHTML = '<div class="success">✅ ' + result.message + '</div>';
|
||||||
|
submitButton.textContent = 'Configuration Complete';
|
||||||
|
|
||||||
|
// Redirect to admin interface after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/admin';
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
// Error
|
||||||
|
status.innerHTML = '<div class="error">❌ ' + result.error + '</div>';
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.textContent = 'Configure Server';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
status.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>';
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.textContent = 'Configure Server';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
c.Data(200, "text/html; charset=utf-8", []byte(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureServer handles the configuration submission
|
||||||
|
func (h *SetupHandler) ConfigureServer(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
AdminUser string `json:"adminUser"`
|
||||||
|
AdminPass string `json:"adminPassword"`
|
||||||
|
DBHost string `json:"dbHost"`
|
||||||
|
DBPort string `json:"dbPort"`
|
||||||
|
DBName string `json:"dbName"`
|
||||||
|
DBUser string `json:"dbUser"`
|
||||||
|
DBPassword string `json:"dbPassword"`
|
||||||
|
ServerHost string `json:"serverHost"`
|
||||||
|
ServerPort string `json:"serverPort"`
|
||||||
|
MaxSeats string `json:"maxSeats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if req.AdminUser == "" || req.AdminPass == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Admin username and password are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DBHost == "" || req.DBPort == "" || req.DBName == "" || req.DBUser == "" || req.DBPassword == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "All database fields are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse numeric values
|
||||||
|
dbPort, err := strconv.Atoi(req.DBPort)
|
||||||
|
if err != nil || dbPort <= 0 || dbPort > 65535 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid database port"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPort, err := strconv.Atoi(req.ServerPort)
|
||||||
|
if err != nil || serverPort <= 0 || serverPort > 65535 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server port"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSeats, err := strconv.Atoi(req.MaxSeats)
|
||||||
|
if err != nil || maxSeats <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid maximum agent seats"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create configuration content
|
||||||
|
envContent := fmt.Sprintf(`# RedFlag Server Configuration
|
||||||
|
# Generated by web setup
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
REDFLAG_SERVER_HOST=%s
|
||||||
|
REDFLAG_SERVER_PORT=%d
|
||||||
|
REDFLAG_TLS_ENABLED=false
|
||||||
|
# REDFLAG_TLS_CERT_FILE=
|
||||||
|
# REDFLAG_TLS_KEY_FILE=
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
REDFLAG_DB_HOST=%s
|
||||||
|
REDFLAG_DB_PORT=%d
|
||||||
|
REDFLAG_DB_NAME=%s
|
||||||
|
REDFLAG_DB_USER=%s
|
||||||
|
REDFLAG_DB_PASSWORD=%s
|
||||||
|
|
||||||
|
# Admin Configuration
|
||||||
|
REDFLAG_ADMIN_USER=%s
|
||||||
|
REDFLAG_ADMIN_PASSWORD=%s
|
||||||
|
REDFLAG_JWT_SECRET=%s
|
||||||
|
|
||||||
|
# Agent Registration
|
||||||
|
REDFLAG_TOKEN_EXPIRY=24h
|
||||||
|
REDFLAG_MAX_TOKENS=100
|
||||||
|
REDFLAG_MAX_SEATS=%d
|
||||||
|
|
||||||
|
# Legacy Configuration (for backwards compatibility)
|
||||||
|
SERVER_PORT=%d
|
||||||
|
DATABASE_URL=postgres://%s:%s@%s:%d/%s?sslmode=disable
|
||||||
|
JWT_SECRET=%s
|
||||||
|
CHECK_IN_INTERVAL=300
|
||||||
|
OFFLINE_THRESHOLD=600
|
||||||
|
TIMEZONE=UTC
|
||||||
|
LATEST_AGENT_VERSION=0.1.16`,
|
||||||
|
req.ServerHost, serverPort,
|
||||||
|
req.DBHost, dbPort, req.DBName, req.DBUser, req.DBPassword,
|
||||||
|
req.AdminUser, req.AdminPass, deriveJWTSecret(req.AdminUser, req.AdminPass),
|
||||||
|
maxSeats,
|
||||||
|
serverPort, req.DBUser, req.DBPassword, req.DBHost, dbPort, req.DBName, deriveJWTSecret(req.AdminUser, req.AdminPass))
|
||||||
|
|
||||||
|
// Write configuration to persistent location
|
||||||
|
configDir := "/app/config"
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create config directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
envPath := filepath.Join(configDir, ".env")
|
||||||
|
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Configuration saved successfully! Server will restart automatically.",
|
||||||
|
"configPath": envPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveJWTSecret generates a JWT secret from admin credentials
|
||||||
|
func deriveJWTSecret(username, password string) string {
|
||||||
|
hash := sha256.Sum256([]byte(username + password + "redflag-jwt-2024"))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -50,8 +51,15 @@ type Config struct {
|
|||||||
|
|
||||||
// Load reads configuration from environment variables
|
// Load reads configuration from environment variables
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
// Load .env file if it exists (for development)
|
// Load .env file from persistent config directory
|
||||||
_ = godotenv.Load()
|
configPaths := []string{"/app/config/.env", ".env"}
|
||||||
|
|
||||||
|
for _, path := range configPaths {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
_ = godotenv.Load(path)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
|
|
||||||
@@ -182,8 +190,14 @@ LATEST_AGENT_VERSION=0.1.8
|
|||||||
username, password, jwtSecret, maxSeats,
|
username, password, jwtSecret, maxSeats,
|
||||||
serverPort, dbUser, dbPassword, dbHost, dbPort, dbName, jwtSecret)
|
serverPort, dbUser, dbPassword, dbHost, dbPort, dbName, jwtSecret)
|
||||||
|
|
||||||
// Write .env file
|
// Write .env file to persistent location
|
||||||
if err := os.WriteFile(".env", []byte(envContent), 0600); err != nil {
|
configDir := "/app/config"
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envPath := filepath.Join(configDir, ".env")
|
||||||
|
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
||||||
return fmt.Errorf("failed to write .env file: %w", err)
|
return fmt.Errorf("failed to write .env file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
container_name: redflag-server
|
container_name: redflag-server
|
||||||
volumes:
|
volumes:
|
||||||
- ./aggregator-agent/redflag-agent:/app/redflag-agent:ro
|
- ./aggregator-agent/redflag-agent:/app/redflag-agent:ro
|
||||||
|
- ./aggregator-server/config:/app/config
|
||||||
- server-data:/app/data
|
- server-data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
Reference in New Issue
Block a user