v0.1.17: UI fixes, Linux improvements, documentation overhaul

UI/UX:
- Fix heartbeat auto-refresh and rate-limiting page
- Add navigation breadcrumbs to settings pages
- New screenshots added

Linux Agent v0.1.17:
- Fix disk detection for multiple mount points
- Improve installer idempotency
- Prevent duplicate registrations

Documentation:
- README rewrite: 538→229 lines, homelab-focused
- Split docs: API.md, CONFIGURATION.md, DEVELOPMENT.md
- Add NOTICE for Apache 2.0 attribution
This commit is contained in:
Fimeg
2025-10-30 22:17:48 -04:00
parent 3940877fb2
commit a92ac0ed78
60 changed files with 4301 additions and 1258 deletions

11
.gitignore vendored
View File

@@ -14,9 +14,15 @@
# All documentation goes in docs/ folder (private development)
docs/
*.md
TEST-CLONE.md
!README.md
!LICENSE
!NOTICE
!.env.example
!docs/API.md
!docs/CONFIGURATION.md
!docs/ARCHITECTURE.md
!docs/DEVELOPMENT.md
# Test binary, built with `go test -c`
*.test
@@ -418,6 +424,11 @@ deployment/
# =============================================================================
docs/
*.md
TEST-CLONE.md
!README.md
!LICENSE
!.env.example
!docs/API.md
!docs/CONFIGURATION.md
!docs/ARCHITECTURE.md
!docs/DEVELOPMENT.md

648
README.md
View File

@@ -1,536 +1,228 @@
# RedFlag (Aggregator)
# RedFlag
**ALPHA RELEASE - v0.1.16**
Self-hosted update management platform for homelabs and small teams
> **⚠️ ALPHA SOFTWARE - NOT READY FOR PRODUCTION**
>
> This is experimental software in active development. Features may be broken, bugs are expected, and breaking changes happen frequently. Use at your own risk, preferably on test systems only. Seriously, don't put this in production yet.
## Status
**Self-hosted update management for homelabs**
- **Core Features Working**: Update management, agent registration, web dashboard
- **Alpha Deployment Ready**: Setup wizard and configuration system implemented
- **Cross-Platform Support**: Linux and Windows agents
- **In Development**: Enhanced features and polish
- **Alpha Software**: Expect some rough edges, backup your data
Cross-platform agents • Web dashboard • Single binary deployment • No enterprise BS
## What RedFlag Is
```
v0.1.17 - Alpha Release
```
A self-hosted, cross-platform update management platform built for homelabs and small teams:
---
- Go Server Backend with PostgreSQL database
- React Web Dashboard with real-time updates
- Cross-Platform Agents (Linux APT/DNF/Docker, Windows Updates/Winget)
- Secure Authentication with registration tokens and refresh tokens
- System Monitoring with real-time status and audit trails
- User-Adjustable Rate Limiting with TLS support
## What It Does
RedFlag lets you manage software updates across all your servers from one dashboard. Track pending updates, approve installs, and monitor system health without SSHing into every machine.
**Supported Platforms:**
- Linux (APT, DNF, Docker)
- Windows (Windows Update, Winget)
- Future: Proxmox integration planned
**Built With:**
- Go backend + PostgreSQL
- React dashboard
- Pull-based agents (firewall-friendly)
- JWT auth with refresh tokens
---
## Screenshots
| Dashboard | Agent Details | Update Management |
|-----------|---------------|-------------------|
| ![Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png) | ![Linux Agent](Screenshots/RedFlag%20Linux%20Agent%20Details.png) | ![Updates](Screenshots/RedFlag%20Updates%20Dashboard.png) |
| Windows Support | History Tracking | Docker Integration |
|-----------------|------------------|-------------------|
| ![Windows Agent](Screenshots/RedFlag%20Windows%20Agent%20Details.png) | ![History](Screenshots/RedFlag%20History%20Dashboard.png) | ![Docker](Screenshots/RedFlag%20Docker%20Dashboard.png) |
---
## Quick Start
### Server Deployment (Docker)
```bash
# Clone and start
git clone https://github.com/Fimeg/RedFlag.git
cd RedFlag
docker-compose up -d
# Access web UI
open http://localhost:3000
# Follow setup wizard to create admin account
```
The setup wizard runs automatically on first launch. It'll generate secure secrets and walk you through creating an admin account.
---
### Agent Installation
**Linux (one-liner):**
```bash
curl -sfL https://your-server.com/install | sudo bash -s -- your-registration-token
```
**Windows (PowerShell):**
```powershell
iwr https://your-server.com/install.ps1 | iex
```
**Manual installation:**
```bash
# Download agent binary
wget https://your-server.com/download/linux/amd64/redflag-agent
# Register and install
chmod +x redflag-agent
sudo ./redflag-agent --server https://your-server.com --token your-token --register
```
Get registration tokens from the web dashboard under **Settings → Token Management**.
---
## Key Features
### Alpha Features
- Secure Server Setup: `./redflag-server --setup` with user-provided secrets
- Registration Token System: One-time tokens for secure agent enrollment
- Rate Limiting: User-adjustable API security with sensible defaults
- Cross-Platform Agents: Linux and Windows with unified architecture
- Real-Time Heartbeat: Rapid polling for interactive operations
- Dependency Management: Safe update installation with dry-run checking
- Audit Logging: Complete activity tracking and history
- Proxy Support: HTTP/HTTPS/SOCKS5 proxy support for restricted networks
**Secure by Default** - Registration tokens, JWT auth, rate limiting
**Idempotent Installs** - Re-running installers won't create duplicate agents
**Real-time Heartbeat** - Interactive operations with rapid polling
**Dependency Handling** - Dry-run checks before installing updates
**Multi-seat Tokens** - One token can register multiple agents
**Audit Trails** - Complete history of all operations
**Proxy Support** - HTTP/HTTPS/SOCKS5 for restricted networks
**Native Services** - systemd on Linux, Windows Services on Windows
### Update Management
- Package Managers: APT, DNF, Docker images, Windows Updates, Winget
- Update Discovery: Automatic scanning with severity classification
- Approval Workflow: Controlled update deployment with confirmation
- Bulk Operations: Multi-agent management and batch operations
- Rollback Support: Failed update tracking and retry capabilities
### Deployment
- Configuration Management: CLI flags → environment → config file → defaults
- Service Integration: systemd service management on Linux
- Cross-Platform Installers: One-liner deployment scripts
- Container Support: Docker and Kubernetes deployment options
---
## Architecture
```
┌─────────────────┐
│ Web Dashboard │ React + TypeScript + TailwindCSS
+ Rate Limiting │ + Registration Token Management
│ Web Dashboard │ React + TypeScript
Port: 3000 │
└────────┬────────┘
│ HTTPS with TLS + User Authentication
│ HTTPS + JWT Auth
┌────────▼────────┐
│ Server (Go) │ Alpha with PostgreSQL
+ Rate Limits + Registration Tokens + Setup Wizard
│ + JWT Auth │ + Heartbeat System + Comprehensive API
│ Server (Go) │ PostgreSQL
Port: 8080
└────────┬────────┘
│ Pull-based (agents check in every 5 min) + Rapid Polling
│ Pull-based (agents check in every 5 min)
┌────┴────┬────────┐
│ │ │
┌───▼──┐ ┌──▼──┐ ┌──▼───┐
│Linux │ │Windows│ │Linux │
│Agent │ │Agent │ │Agent │
│+Proxy│ │+Proxy│ │+Proxy│
└──────┘ └───────┘ └──────┘
```
## Prerequisites
---
- **Go 1.21+** (for building from source)
- **Docker & Docker Compose** (for PostgreSQL database)
- **Linux** (server deployment platform)
## Documentation
## Quick Start
- **[API Reference](docs/API.md)** - Complete API documentation
- **[Configuration](docs/CONFIGURATION.md)** - CLI flags, env vars, config files
- **[Architecture](docs/ARCHITECTURE.md)** - System design and database schema
- **[Development](docs/DEVELOPMENT.md)** - Build from source, testing, contributing
### 1. Server Setup (Docker - Recommended)
```bash
# Clone repository
git clone https://github.com/Fimeg/RedFlag.git
cd RedFlag
---
# Build agent (one-time)
cd aggregator-agent && go mod tidy && go build -o redflag-agent cmd/agent/main.go && cd ..
## Security Notes
# Start database and server (auto-configures on first run)
docker-compose up -d
RedFlag uses:
- **Registration tokens** - One-time use tokens for secure agent enrollment
- **Refresh tokens** - 90-day sliding window, auto-renewal for active agents
- **SHA-256 hashing** - All tokens hashed at rest
- **Rate limiting** - Configurable API protection
- **Minimal privileges** - Agents run with least required permissions
# Watch setup progress (optional)
docker-compose logs -f server
For production deployments:
1. Change default admin password
2. Use HTTPS/TLS
3. Generate strong JWT secrets (setup wizard does this)
4. Configure firewall rules
5. Enable rate limiting
# When setup is complete, access: http://localhost:8080
# Admin: http://localhost:8080/admin
```
---
### 2. Manual Setup (Development)
```bash
# Build components
make build-all
## Current Status
# Start database
docker-compose up -d postgres
**What Works:**
- ✅ Cross-platform agent registration and updates
- ✅ Update scanning for all supported package managers
- ✅ Dry-run dependency checking before installation
- ✅ Real-time heartbeat and rapid polling
- ✅ Multi-seat registration tokens
- ✅ Native service integration (systemd, Windows Services)
- ✅ Web dashboard with full agent management
- ✅ Docker integration for container image updates
# Setup server
cd aggregator-server && sudo ./redflag-server --setup
**Known Issues:**
- Windows Winget detection needs debugging
- Some Windows Updates may reappear after installation (known Windows Update quirk)
# Run migrations
./redflag-server --migrate
**Planned Features:**
- Proxmox VM/container integration
- Agent auto-update system
- WebSocket real-time updates
- Mobile-responsive dashboard improvements
# Start server
./redflag-server
```
### 2. Agent Deployment (Linux)
```bash
# Option 1: One-liner with registration token
sudo bash -c 'curl -sfL https://redflag.wiuf.net/install | bash -s -- rf-tok-abc123'
# Option 2: Manual installation
sudo ./install.sh --server https://redflag.wiuf.net:8080 --token rf-tok-abc123
# Option 3: Advanced configuration with proxy
sudo ./redflag-agent --server https://redflag.wiuf.net:8080 \
--token rf-tok-abc123 \
--proxy-http http://proxy.company.com:8080 \
--organization "my-homelab" \
--tags "production,webserver"
```
### 3. Windows Agent Deployment
```powershell
# PowerShell one-liner
iwr https://redflag.wiuf.net/install.ps1 | iex -Arguments '--server https://redflag.wiuf.net:8080 --token rf-tok-abc123'
# Or manual download and install
.\redflag-agent.exe --server https://redflag.wiuf.net:8080 --token rf-tok-abc123
```
## Agent Configuration Options
### CLI Flags (Highest Priority)
```bash
./redflag-agent --server https://redflag.wiuf.net \
--token rf-tok-abc123 \
--proxy-http http://proxy.company.com:8080 \
--proxy-https https://proxy.company.com:8080 \
--log-level debug \
--organization "my-homelab" \
--tags "production,webserver" \
--name "redflag-server-01" \
--insecure-tls
```
### Environment Variables
```bash
export REDFLAG_SERVER_URL="https://redflag.wiuf.net"
export REDFLAG_REGISTRATION_TOKEN="rf-tok-abc123"
export REDFLAG_HTTP_PROXY="http://proxy.company.com:8080"
export REDFLAG_HTTPS_PROXY="https://proxy.company.com:8080"
export REDFLAG_NO_PROXY="localhost,127.0.0.1"
export REDFLAG_LOG_LEVEL="info"
export REDFLAG_ORGANIZATION="my-homelab"
```
### Configuration File
```json
{
"server_url": "https://redflag.wiuf.net",
"registration_token": "rf-tok-abc123",
"proxy": {
"enabled": true,
"http": "http://proxy.company.com:8080",
"https": "https://proxy.company.com:8080",
"no_proxy": "localhost,127.0.0.1"
},
"network": {
"timeout": "30s",
"retry_count": 3,
"retry_delay": "5s"
},
"logging": {
"level": "info",
"max_size": 100,
"max_backups": 3
},
"tags": ["production", "webserver"],
"organization": "my-homelab",
"display_name": "redflag-server-01"
}
```
## Web Dashboard Features
### Agent Management
- Real-time Status: Online/offline with heartbeat indicators
- System Information: CPU, memory, disk usage, OS details
- Version Tracking: Agent versions and update availability
- Metadata Management: Tags, organizations, display names
- Bulk Operations: Multi-agent scanning and updates
### Update Management
- Severity Classification: Critical, high, medium, low priority updates
- Approval Workflow: Controlled update deployment with dependencies
- Dependency Resolution: Safe installation with conflict checking
- Batch Operations: Approve/install multiple updates
- Audit Trail: Complete history of all operations
### Settings & Administration
- Registration Tokens: Generate and manage secure enrollment tokens
- Rate Limiting: User-adjustable API security settings
- Authentication: Secure login with session management
- Audit Logging: Comprehensive activity tracking
- Server Configuration: Admin settings and system controls
## API Reference
### Registration Token Management
```bash
# Generate registration token
curl -X POST https://redflag.wiuf.net/api/v1/admin/registration-tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"label": "Production Servers", "expires_in": "24h"}'
# List tokens
curl -X GET https://redflag.wiuf.net/api/v1/admin/registration-tokens \
-H "Authorization: Bearer $ADMIN_TOKEN"
# Revoke token
curl -X DELETE https://redflag.wiuf.net/api/v1/admin/registration-tokens/rf-tok-abc123 \
-H "Authorization: Bearer $ADMIN_TOKEN"
```
### Rate Limit Management
```bash
# View current settings
curl -X GET https://redflag.wiuf.net/api/v1/admin/rate-limits \
-H "Authorization: Bearer $ADMIN_TOKEN"
# Update settings
curl -X PUT https://redflag.wiuf.net/api/v1/admin/rate-limits \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{
"agent_registration": {"requests": 10, "window": "1m", "enabled": true},
"admin_operations": {"requests": 200, "window": "1m", "enabled": true}
}'
```
## Security
### Authentication & Authorization
- Registration Tokens: One-time use tokens prevent unauthorized agent enrollment
- Refresh Token Authentication: 90-day sliding window with 24h access tokens
- SHA-256 token hashing for secure storage
- Admin authentication for server access and management
### Network Security
- Rate Limiting: Configurable API protection with sensible defaults
- TLS Support: Certificate validation and client certificate support
- Pull-based Model: Agents poll server (firewall-friendly)
- HTTPS Required: Production deployments must use TLS
### System Hardening
- Minimal Privilege Execution: Agents run with least required privileges
- Command Validation: Whitelisted commands only
- Secure Defaults: Hardened configurations out of the box
- Security Hardening: Minimal privilege execution and sudoers management
### Audit & Monitoring
- Audit Trails: Complete logging of all activities
- Token Renewal: `/renew` endpoint prevents daily re-registration
- Activity Tracking: Comprehensive monitoring and alerting
- Access Logs: Full audit trail of user and agent actions
## Docker Deployment
```yaml
# docker-compose.yml
version: '3.8'
services:
redflag-server:
build: ./aggregator-server
ports:
- "8080:8080"
environment:
- REDFLAG_SERVER_HOST=0.0.0.0
- REDFLAG_SERVER_PORT=8080
- REDFLAG_DB_HOST=postgres
- REDFLAG_DB_PORT=5432
- REDFLAG_DB_NAME=redflag
- REDFLAG_DB_USER=redflag
- REDFLAG_DB_PASSWORD=secure-password
depends_on:
- postgres
volumes:
- ./redflag-data:/etc/redflag
- ./logs:/app/logs
postgres:
image: postgres:15
environment:
POSTGRES_DB: redflag
POSTGRES_USER: redflag
POSTGRES_PASSWORD: secure-password
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
```
## Project Structure
```
RedFlag/
├── aggregator-server/ # Go server backend
│ ├── cmd/server/ # Main server entry point
│ ├── internal/
│ │ ├── api/ # REST API handlers and middleware
│ │ │ └── handlers/ # API endpoint implementations
│ │ ├── database/ # Database layer with migrations
│ │ │ ├── migrations/ # Database schema migrations
│ │ │ └── queries/ # Database query functions
│ │ ├── models/ # Data models and structs
│ │ ├── services/ # Business logic services
│ │ └── config/ # Configuration management
│ └── redflag-server # Server binary
├── aggregator-agent/ # Cross-platform Go agent
│ ├── cmd/agent/ # Agent main entry point
│ ├── internal/
│ │ ├── client/ # HTTP client with token renewal
│ │ ├── config/ # Enhanced configuration system
│ │ ├── scanner/ # Update scanners for each platform
│ │ ├── installer/ # Package installers
│ │ └── system/ # System information collection
│ ├── install.sh # Linux installation script
│ └── redflag-agent # Agent binary
├── aggregator-web/ # React dashboard
├── docker-compose.yml # Development environment
├── Makefile # Common tasks
└── README.md # This file
```
## What This Is
A self-hosted, cross-platform update management platform built with:
- Go server backend + PostgreSQL
- React web dashboard with TypeScript
- Cross-platform agents (Linux APT/DNF/Docker, Windows Updates/Winget)
- Local CLI tools for agent management
- Update installation system with dependency management
- Refresh token authentication for stable agent identity
## What This Isn't
- Not ready for public use
- Not documented for external users
- Not supported or maintained for others
- Not stable (active development)
## Current Capabilities
### Working Features
- Server backend with REST API
- Cross-platform agent registration and check-in
- Update discovery for APT, DNF, Docker images, Windows Updates, and Winget packages
- Update approval workflow with dependency confirmation
- Web dashboard with agent management and real-time status
- Local CLI tools (--scan, --status, --list-updates, --export, --export=json/csv)
- Update installation system with dry-run dependency checking
- Beautiful terminal output with colors and severity indicators
- Local cache system for offline viewing of scan results
- Refresh token authentication for stable agent identity
- Event-sourced database architecture for scalability
### Known Limitations
- No real-time WebSocket updates
- Proxmox integration is not implemented in this version (planned for future release)
- Authentication system works but needs security hardening
## Screenshots
| Overview | Updates Management | Agent List |
|----------|-------------------|------------|
| ![Main Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png) | ![Updates Dashboard](Screenshots/RedFlag%20Updates%20Dashboard.png) | ![Agent List](Screenshots/RedFlag%20Agent%20List.png) |
| System overview with metrics | Update approval with dependency workflow | Cross-platform agent management |
| Linux Agent Details | Windows Agent Details |
|-------------------|---------------------|
| ![Linux Agent Details](Screenshots/RedFlag%20Linux%20Agent%20Details.png) | ![Windows Agent Details](Screenshots/RedFlag%20Windows%20Agent%20Details.png) |
| Linux system specs and updates | Windows Updates and Winget support |
| History & Audit | Windows Agent History |
|----------------|----------------------|
| ![History Dashboard](Screenshots/RedFlag%20History%20Dashboard.png) | ![Windows Agent History](Screenshots/RedFlag%20Windows%20Agent%20History%20.png) |
| Complete audit trail of activities | Windows agent activity timeline |
| Live Operations | Docker Management |
|-----------------|------------------|
| ![Live Operations](Screenshots/RedFlag%20Live%20Operations%20-%20Failed%20Dashboard.png) | ![Docker Dashboard](Screenshots/RedFlag%20Docker%20Dashboard.png) |
| Real-time operation tracking | Container image update management |
## For Developers
This repository contains:
- **Server backend code** (`aggregator-server/`)
- **Agent code** (`aggregator-agent/`)
- **Web dashboard** (`aggregator-web/`)
- **Database migrations** and configuration
## Database Schema
Key Tables:
- `agents` - Registered agents with system metadata and version tracking
- `refresh_tokens` - Long-lived refresh tokens for stable agent identity
- `update_events` - Immutable event storage for update discoveries
- `current_package_state` - Optimized view of current update state
- `agent_commands` - Command queue for agents (scan, install, dry-run)
- `update_logs` - Execution logs with detailed results
- `agent_tags` - Agent tagging/grouping
## Configuration
### Server (.env)
```bash
SERVER_PORT=8080
DATABASE_URL=postgres://aggregator:aggregator@localhost:5432/aggregator?sslmode=disable
JWT_SECRET=change-me-in-production
CHECK_IN_INTERVAL=300 # seconds
OFFLINE_THRESHOLD=600 # seconds
```
### Agent (/etc/aggregator/config.json)
Auto-generated on registration:
```json
{
"server_url": "http://localhost:8080",
"agent_id": "uuid",
"token": "jwt-access-token",
"refresh_token": "long-lived-refresh-token",
"check_in_interval": 300
}
```
---
## Development
### Makefile Commands
```bash
make help # Show all commands
make db-up # Start PostgreSQL
make db-down # Stop PostgreSQL
make server # Run server (with auto-reload)
make agent # Run agent
make build-server # Build server binary
make build-agent # Build agent binary
make test # Run tests
make clean # Clean build artifacts
# Start local development environment
make db-up
make server # Terminal 1
make agent # Terminal 2
make web # Terminal 3
```
### Running Tests
```bash
cd aggregator-server && go test ./...
cd aggregator-agent && go test ./...
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for detailed build instructions.
## API Usage
---
### List All Agents
```bash
curl http://localhost:8080/api/v1/agents
```
## Alpha Release Notice
### Trigger Update Scan
```bash
curl -X POST http://localhost:8080/api/v1/agents/{agent-id}/scan
```
This is alpha software built for homelabs and self-hosters. It's functional and actively used, but:
### List All Updates
```bash
# All updates
curl http://localhost:8080/api/v1/updates
- Expect occasional bugs
- Backup your data
- Security model is solid but not audited
- Breaking changes may happen between versions
- Documentation is a work in progress
# Filter by severity
curl http://localhost:8080/api/v1/updates?severity=critical
That said, it works well for its intended use case. Issues and feedback welcome!
# Filter by status
curl http://localhost:8080/api/v1/updates?status=pending
```
### Approve an Update
```bash
curl -X POST http://localhost:8080/api/v1/updates/{update-id}/approve
```
### Token Renewal (Agent Authentication)
```bash
# Exchange refresh token for new access token
curl -X POST http://localhost:8080/api/v1/agents/renew \
-H "Content-Type: application/json" \
-d '{
"agent_id": "uuid",
"refresh_token": "long-lived-token"
}'
```
### Dependency Workflow
```bash
# Dry run to check dependencies (automatically triggered by install)
curl -X POST http://localhost:8080/api/v1/updates/{update-id}/approve
# Confirm dependencies and install
curl -X POST http://localhost:8080/api/v1/updates/{update-id}/confirm-dependencies
```
---
## License
MIT License - see LICENSE file for details.
MIT License - See [LICENSE](LICENSE) for details
This is private development software. Use at your own risk.
**Third-Party Components:**
- Windows Update integration based on [windowsupdate](https://github.com/ceshihao/windowsupdate) (Apache 2.0)
## Third-Party Licenses
---
### Windows Update Package (Apache 2.0)
This project includes a modified version of the `windowsupdate` package from https://github.com/ceshihao/windowsupdate
## Project Goals
Copyright 2022 Zheng Dayu
Licensed under the Apache License, Version 2.0
Original package: https://github.com/ceshihao/windowsupdate
RedFlag aims to be:
- **Simple** - Deploy in 5 minutes, understand in 10
- **Honest** - No enterprise marketing speak, just useful software
- **Homelab-first** - Built for real use cases, not investor pitches
- **Self-hosted** - Your data, your infrastructure
The package is included in `aggregator-agent/pkg/windowsupdate/` and has been modified for integration with RedFlag's update management system.
If you're looking for an enterprise-grade solution with SLAs and support contracts, this isn't it. If you want to manage updates across your homelab without SSH-ing into every server, welcome aboard.
---
**Made with ☕ for homelabbers, by homelabbers**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -1,45 +0,0 @@
# Test Fresh Clone Instructions
## Prerequisites
- Go 1.21+ must be installed
- Docker & Docker Compose must be running
## Quick Test on New Machine/Location
```bash
# Clone fresh
git clone https://github.com/Fimeg/RedFlag.git
cd RedFlag
# Docker deployment (recommended)
docker-compose up -d
# One-time server setup
docker-compose exec server ./redflag-server --setup
# Run database migrations
docker-compose exec server ./redflag-server --migrate
# Restart server with config
docker-compose restart server
# Test server: http://localhost:8080
# Admin: http://localhost:8080/admin
```
## What Should Work
- ✅ Server setup wizard creates .env file
- ✅ Database migrations run without errors
- ✅ Server starts on port 8080
- ✅ Admin interface accessible
- ✅ Can generate registration tokens
- ✅ Agent registers and appears in UI
- ✅ Agent shows system information
- ✅ Agent performs update scan
## Expected Breaking Changes
Old agents won't work - need fresh registration with tokens.
## Version Check
- Agent should report v0.1.16
- Server should show v0.1.16 as latest version

13
aggregator-agent/NOTICE Normal file
View File

@@ -0,0 +1,13 @@
RedFlag Agent
Copyright 2024-2025
This software includes code from the following third-party projects:
---
windowsupdate
Copyright 2022 Zheng Dayu
Licensed under the Apache License, Version 2.0
https://github.com/ceshihao/windowsupdate
Included in: aggregator-agent/pkg/windowsupdate/

View File

@@ -16,12 +16,13 @@ import (
"github.com/Fimeg/RedFlag/aggregator-agent/internal/display"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/installer"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/scanner"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/service"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/system"
"github.com/google/uuid"
)
const (
AgentVersion = "0.1.16" // Enhanced configuration system with proxy support and registration tokens
AgentVersion = "0.1.17" // Fixed Linux disk detection to show all physical mount points (/, /home, etc.)
)
// getConfigPath returns the platform-specific config path
@@ -86,6 +87,13 @@ func main() {
displayName := flag.String("name", "", "Display name for agent")
insecureTLS := flag.Bool("insecure-tls", false, "Skip TLS certificate verification")
exportFormat := flag.String("export", "", "Export format: json, csv")
// Windows service management commands
installServiceCmd := flag.Bool("install-service", false, "Install as Windows service")
removeServiceCmd := flag.Bool("remove-service", false, "Remove Windows service")
startServiceCmd := flag.Bool("start-service", false, "Start Windows service")
stopServiceCmd := flag.Bool("stop-service", false, "Stop Windows service")
serviceStatusCmd := flag.Bool("service-status", false, "Show Windows service status")
flag.Parse()
// Handle version command
@@ -95,6 +103,48 @@ func main() {
os.Exit(0)
}
// Handle Windows service management commands (only on Windows)
if runtime.GOOS == "windows" {
if *installServiceCmd {
if err := service.InstallService(); err != nil {
log.Fatalf("Failed to install service: %v", err)
}
fmt.Println("RedFlag service installed successfully")
os.Exit(0)
}
if *removeServiceCmd {
if err := service.RemoveService(); err != nil {
log.Fatalf("Failed to remove service: %v", err)
}
fmt.Println("RedFlag service removed successfully")
os.Exit(0)
}
if *startServiceCmd {
if err := service.StartService(); err != nil {
log.Fatalf("Failed to start service: %v", err)
}
fmt.Println("RedFlag service started successfully")
os.Exit(0)
}
if *stopServiceCmd {
if err := service.StopService(); err != nil {
log.Fatalf("Failed to stop service: %v", err)
}
fmt.Println("RedFlag service stopped successfully")
os.Exit(0)
}
if *serviceStatusCmd {
if err := service.ServiceStatus(); err != nil {
log.Fatalf("Failed to get service status: %v", err)
}
os.Exit(0)
}
}
// Parse tags from comma-separated string
var tags []string
if *tagsFlag != "" {
@@ -197,7 +247,16 @@ func main() {
log.Fatal("Agent not registered. Run with -register flag first.")
}
// Start agent service
// Check if running as Windows service
if runtime.GOOS == "windows" && service.IsService() {
// Run as Windows service
if err := service.RunService(cfg); err != nil {
log.Fatal("Service failed:", err)
}
return
}
// Start agent service (console mode)
if err := runAgent(cfg); err != nil {
log.Fatal("Agent failed:", err)
}
@@ -221,7 +280,8 @@ func registerAgent(cfg *config.Config, serverURL string) error {
}
}
apiClient := client.NewClient(serverURL, "")
// Use registration token from config if available
apiClient := client.NewClient(serverURL, cfg.RegistrationToken)
// Create metadata with system information
metadata := map[string]string{

View File

@@ -1,10 +1,12 @@
module github.com/Fimeg/RedFlag/aggregator-agent
go 1.21
go 1.23.0
require (
github.com/docker/docker v27.4.1+incompatible
github.com/go-ole/go-ole v1.3.0
github.com/google/uuid v1.6.0
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f
)
require (
@@ -16,7 +18,6 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -24,7 +25,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
@@ -32,6 +32,6 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

View File

@@ -105,8 +105,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=

View File

@@ -56,6 +56,13 @@ install_binary() {
chmod 755 "$AGENT_BINARY"
chown root:root "$AGENT_BINARY"
echo "✓ Agent binary installed"
# Set SELinux context for binary if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "SELinux detected, setting file context for binary..."
restorecon -v "$AGENT_BINARY" 2>/dev/null || true
echo "✓ SELinux context set for binary"
fi
}
# Function to install sudoers configuration
@@ -167,6 +174,13 @@ register_agent() {
# Create config directory
mkdir -p /etc/aggregator
# Set SELinux context for config directory if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "Setting SELinux context for config directory..."
restorecon -Rv /etc/aggregator 2>/dev/null || true
echo "✓ SELinux context set for config directory"
fi
# Register agent (run as regular binary, not as service)
if "$AGENT_BINARY" -register -server "$server_url"; then
echo "✓ Agent registered successfully"

View File

@@ -51,6 +51,7 @@ type RegisterRequest struct {
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
RegistrationToken string `json:"registration_token,omitempty"` // Fallback method
Metadata map[string]string `json:"metadata"`
}
@@ -66,6 +67,12 @@ type RegisterResponse struct {
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
// If we have a registration token, include it in the request
// Registration tokens are longer than regular JWT tokens (usually 64 chars vs JWT ~400 chars)
if c.token != "" && len(c.token) > 40 {
req.RegistrationToken = c.token
}
body, err := json.Marshal(req)
if err != nil {
return nil, err
@@ -77,6 +84,12 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
}
httpReq.Header.Set("Content-Type", "application/json")
// Add Authorization header if we have a registration token (preferred method)
// Registration tokens are longer than regular JWT tokens (usually 64 chars vs JWT ~400 chars)
if c.token != "" && len(c.token) > 40 {
httpReq.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(httpReq)
if err != nil {
return nil, err

View File

@@ -349,6 +349,12 @@ func (c *Config) Save(configPath string) error {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Create parent directory if it doesn't exist
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}

View File

@@ -0,0 +1,52 @@
//go:build !windows
package service
import (
"fmt"
"runtime"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/config"
)
// Stub implementations for non-Windows platforms
// RunService executes the agent as a Windows service (stub for non-Windows)
func RunService(cfg *config.Config) error {
return fmt.Errorf("Windows service mode is only available on Windows, current OS: %s", runtime.GOOS)
}
// IsService returns true if running as Windows service (stub for non-Windows)
func IsService() bool {
return false
}
// InstallService installs the agent as a Windows service (stub for non-Windows)
func InstallService() error {
return fmt.Errorf("Windows service installation is only available on Windows, current OS: %s", runtime.GOOS)
}
// RemoveService removes the Windows service (stub for non-Windows)
func RemoveService() error {
return fmt.Errorf("Windows service removal is only available on Windows, current OS: %s", runtime.GOOS)
}
// StartService starts the Windows service (stub for non-Windows)
func StartService() error {
return fmt.Errorf("Windows service management is only available on Windows, current OS: %s", runtime.GOOS)
}
// StopService stops the Windows service (stub for non-Windows)
func StopService() error {
return fmt.Errorf("Windows service management is only available on Windows, current OS: %s", runtime.GOOS)
}
// ServiceStatus returns the current status of the Windows service (stub for non-Windows)
func ServiceStatus() error {
return fmt.Errorf("Windows service management is only available on Windows, current OS: %s", runtime.GOOS)
}
// RunConsole runs the agent in console mode with signal handling
func RunConsole(cfg *config.Config) error {
// For non-Windows, just run normally
return fmt.Errorf("Console mode is handled by main application logic on %s", runtime.GOOS)
}

File diff suppressed because it is too large Load Diff

View File

@@ -262,9 +262,42 @@ func getDiskInfo() ([]DiskInfo, error) {
fields := strings.Fields(line)
if len(fields) >= 6 {
mountpoint := fields[0]
filesystem := fields[5]
// Filter out pseudo-filesystems and only show physical/important mounts
// Skip tmpfs, devtmpfs, overlay, squashfs, etc.
if strings.HasPrefix(filesystem, "tmpfs") ||
strings.HasPrefix(filesystem, "devtmpfs") ||
strings.HasPrefix(filesystem, "overlay") ||
strings.HasPrefix(filesystem, "squashfs") ||
strings.HasPrefix(filesystem, "udev") ||
strings.HasPrefix(filesystem, "proc") ||
strings.HasPrefix(filesystem, "sysfs") ||
strings.HasPrefix(filesystem, "cgroup") ||
strings.HasPrefix(filesystem, "devpts") ||
strings.HasPrefix(filesystem, "securityfs") ||
strings.HasPrefix(filesystem, "pstore") ||
strings.HasPrefix(filesystem, "bpf") ||
strings.HasPrefix(filesystem, "configfs") ||
strings.HasPrefix(filesystem, "fusectl") ||
strings.HasPrefix(filesystem, "hugetlbfs") ||
strings.HasPrefix(filesystem, "mqueue") ||
strings.HasPrefix(filesystem, "debugfs") ||
strings.HasPrefix(filesystem, "tracefs") {
continue // Skip virtual/pseudo filesystems
}
// Skip container/snap mounts unless they're important
if strings.Contains(mountpoint, "/snap/") ||
strings.Contains(mountpoint, "/var/lib/docker") ||
strings.Contains(mountpoint, "/run") {
continue
}
disk := DiskInfo{
Mountpoint: fields[0],
Filesystem: fields[5],
Mountpoint: mountpoint,
Filesystem: filesystem,
}
// Parse sizes (df outputs in human readable format, we'll parse the numeric part)

View File

@@ -14,19 +14,33 @@ import (
func getWindowsInfo() string {
// Try using wmic for detailed Windows version info
if cmd, err := exec.LookPath("wmic"); err == nil {
if data, err := exec.Command(cmd, "os", "get", "Caption,Version,BuildNumber,SKU").Output(); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.Contains(line, "Microsoft Windows") {
// Clean up the output
line = strings.TrimSpace(line)
// Remove extra spaces
for strings.Contains(line, " ") {
line = strings.ReplaceAll(line, " ", " ")
}
return line
// Get Caption (e.g., "Microsoft Windows 10 Pro")
caption := ""
if data, err := exec.Command(cmd, "os", "get", "Caption", "/value").Output(); err == nil {
output := strings.TrimSpace(string(data))
if strings.HasPrefix(output, "Caption=") {
caption = strings.TrimPrefix(output, "Caption=")
caption = strings.TrimSpace(caption)
}
}
// Get Version and Build Number
version := ""
if data, err := exec.Command(cmd, "os", "get", "Version", "/value").Output(); err == nil {
output := strings.TrimSpace(string(data))
if strings.HasPrefix(output, "Version=") {
version = strings.TrimPrefix(output, "Version=")
version = strings.TrimSpace(version)
}
}
// Combine caption and version for clean output
if caption != "" && version != "" {
return fmt.Sprintf("%s (Build %s)", caption, version)
} else if caption != "" {
return caption
} else if version != "" {
return fmt.Sprintf("Windows %s", version)
}
}
@@ -180,31 +194,50 @@ func getWindowsDiskInfo() ([]DiskInfo, error) {
var disks []DiskInfo
if cmd, err := exec.LookPath("wmic"); err == nil {
// Get logical disk information
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem").Output(); err == nil {
// Get logical disk information - use /value format for reliable parsing
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem", "/format:csv").Output(); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.TrimSpace(line) != "" && !strings.Contains(line, "DeviceID") {
fields := strings.Fields(line)
if len(fields) >= 4 {
for i, line := range lines {
line = strings.TrimSpace(line)
// Skip header and empty lines
if i == 0 || line == "" || !strings.Contains(line, ",") {
continue
}
// CSV format: Node,DeviceID,FileSystem,FreeSpace,Size
fields := strings.Split(line, ",")
if len(fields) >= 5 {
deviceID := strings.TrimSpace(fields[1])
filesystem := strings.TrimSpace(fields[2])
freeSpaceStr := strings.TrimSpace(fields[3])
sizeStr := strings.TrimSpace(fields[4])
// Skip if no size info (e.g., CD-ROM drives)
if sizeStr == "" || freeSpaceStr == "" {
continue
}
disk := DiskInfo{
Mountpoint: strings.TrimSpace(fields[0]),
Filesystem: strings.TrimSpace(fields[3]),
Mountpoint: deviceID,
Filesystem: filesystem,
}
// Parse sizes (wmic outputs in bytes)
if total, err := strconv.ParseUint(strings.TrimSpace(fields[1]), 10, 64); err == nil {
if total, err := strconv.ParseUint(sizeStr, 10, 64); err == nil {
disk.Total = total
}
if available, err := strconv.ParseUint(strings.TrimSpace(fields[2]), 10, 64); err == nil {
if available, err := strconv.ParseUint(freeSpaceStr, 10, 64); err == nil {
disk.Available = available
}
// Calculate used space
if disk.Total > 0 && disk.Available <= disk.Total {
disk.Used = disk.Total - disk.Available
if disk.Total > 0 {
disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100
}
// Only add disks with valid size info
if disk.Total > 0 {
disks = append(disks, disk)
}
}
@@ -238,36 +271,35 @@ func getWindowsProcessCount() (int, error) {
func getWindowsUptime() (string, error) {
// Try PowerShell first for more accurate uptime
if cmd, err := exec.LookPath("powershell"); err == nil {
// Get uptime in seconds for precise calculation
if data, err := exec.Command(cmd, "-Command",
"(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object TotalDays").Output(); err == nil {
// Parse the output to get days
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.Contains(line, "TotalDays") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if days, err := strconv.ParseFloat(fields[len(fields)-1], 64); err == nil {
return formatUptimeFromDays(days), nil
}
}
}
"(New-TimeSpan -Start (Get-CimInstance Win32_OperatingSystem).LastBootUpTime -End (Get-Date)).TotalSeconds").Output(); err == nil {
secondsStr := strings.TrimSpace(string(data))
if seconds, err := strconv.ParseFloat(secondsStr, 64); err == nil {
return formatUptimeFromSeconds(seconds), nil
}
}
}
// Fallback to wmic
// Fallback to wmic with manual parsing
if cmd, err := exec.LookPath("wmic"); err == nil {
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime").Output(); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.TrimSpace(line) != "" && !strings.Contains(line, "LastBootUpTime") {
// Parse WMI datetime format: 20231201123045.123456-300
wmiTime := strings.TrimSpace(line)
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime", "/value").Output(); err == nil {
output := strings.TrimSpace(string(data))
if strings.HasPrefix(output, "LastBootUpTime=") {
wmiTime := strings.TrimPrefix(output, "LastBootUpTime=")
wmiTime = strings.TrimSpace(wmiTime)
// Parse WMI datetime format: 20251025123045.123456-300
if len(wmiTime) >= 14 {
// Extract just the date part for basic calculation
// This is a simplified approach - in production you'd want proper datetime parsing
return fmt.Sprintf("Since %s", wmiTime[:8]), nil
}
// Extract date/time components: YYYYMMDDHHmmss
year := wmiTime[0:4]
month := wmiTime[4:6]
day := wmiTime[6:8]
hour := wmiTime[8:10]
minute := wmiTime[10:12]
second := wmiTime[12:14]
bootTimeStr := fmt.Sprintf("%s-%s-%s %s:%s:%s", year, month, day, hour, minute, second)
return fmt.Sprintf("Since %s", bootTimeStr), nil
}
}
}
@@ -276,6 +308,27 @@ func getWindowsUptime() (string, error) {
return "Unknown", nil
}
// formatUptimeFromSeconds formats uptime from seconds into human readable format
func formatUptimeFromSeconds(seconds float64) string {
days := int(seconds / 86400)
hours := int((seconds - float64(days*86400)) / 3600)
minutes := int((seconds - float64(days*86400) - float64(hours*3600)) / 60)
if days > 0 {
if hours > 0 {
return fmt.Sprintf("%d days, %d hours", days, hours)
}
return fmt.Sprintf("%d days", days)
} else if hours > 0 {
if minutes > 0 {
return fmt.Sprintf("%d hours, %d minutes", hours, minutes)
}
return fmt.Sprintf("%d hours", hours)
} else {
return fmt.Sprintf("%d minutes", minutes)
}
}
// formatUptimeFromDays formats uptime from days into human readable format
func formatUptimeFromDays(days float64) string {
if days < 1 {

View File

@@ -1,18 +1,44 @@
FROM golang:1.23-alpine AS builder
# Stage 1: Build server binary
FROM golang:1.23-alpine AS server-builder
WORKDIR /app
COPY go.mod go.sum ./
COPY aggregator-server/go.mod aggregator-server/go.sum ./
RUN go mod download
COPY . .
COPY aggregator-server/ .
RUN CGO_ENABLED=0 go build -o redflag-server cmd/server/main.go
# Stage 2: Build agent binaries for all platforms
FROM golang:1.23-alpine AS agent-builder
WORKDIR /build
# Copy agent source code
COPY aggregator-agent/ ./
# Build for Linux amd64
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o binaries/linux-amd64/redflag-agent cmd/agent/main.go
# Build for Linux arm64
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o binaries/linux-arm64/redflag-agent cmd/agent/main.go
# Build for Windows amd64
RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o binaries/windows-amd64/redflag-agent.exe cmd/agent/main.go
# Build for Windows arm64
RUN CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o binaries/windows-arm64/redflag-agent.exe cmd/agent/main.go
# Stage 3: Final image with server and all agent binaries
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/redflag-server .
# Copy server binary
COPY --from=server-builder /app/redflag-server .
COPY --from=server-builder /app/internal/database ./internal/database
# Copy all agent binaries
COPY --from=agent-builder /build/binaries ./binaries
EXPOSE 8080

View File

@@ -35,15 +35,10 @@ func startWelcomeModeServer() {
router.GET("/", setupHandler.ShowSetupPage)
// Setup endpoint for web configuration
router.POST("/api/v1/setup", setupHandler.ConfigureServer)
router.POST("/api/setup/configure", setupHandler.ConfigureServer)
// Setup endpoint for web configuration (future)
router.GET("/setup", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "Web setup coming soon",
"instructions": "Use: docker-compose exec server ./redflag-server --setup",
})
})
// Setup endpoint for web configuration
router.GET("/setup", setupHandler.ShowSetupPage)
log.Printf("Welcome mode server started on :8080")
log.Printf("Waiting for configuration...")
@@ -127,6 +122,14 @@ func main() {
commandQueries := queries.NewCommandQueries(db.DB)
refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB)
registrationTokenQueries := queries.NewRegistrationTokenQueries(db.DB)
userQueries := queries.NewUserQueries(db.DB)
// Ensure admin user exists
if err := userQueries.EnsureAdminUser(cfg.Admin.Username, cfg.Admin.Username+"@redflag.local", cfg.Admin.Password); err != nil {
fmt.Printf("Warning: Failed to create admin user: %v\n", err)
} else {
fmt.Println("✅ Admin user ensured")
}
// Initialize services
timezoneService := services.NewTimezoneService(cfg)
@@ -136,15 +139,15 @@ func main() {
rateLimiter := middleware.NewRateLimiter()
// Initialize handlers
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, registrationTokenQueries, cfg.CheckInInterval, cfg.LatestAgentVersion)
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler)
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret)
authHandler := handlers.NewAuthHandler(cfg.Admin.JWTSecret, userQueries)
statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
settingsHandler := handlers.NewSettingsHandler(timezoneService)
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries)
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
downloadHandler := handlers.NewDownloadHandler(filepath.Join(".", "redflag-agent"))
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
// Setup router
router := gin.Default()
@@ -169,6 +172,10 @@ func main() {
api.POST("/agents/register", rateLimiter.RateLimit("agent_registration", middleware.KeyByIP), agentHandler.RegisterAgent)
api.POST("/agents/renew", rateLimiter.RateLimit("public_access", middleware.KeyByIP), agentHandler.RenewToken)
// Public download routes (no authentication - agents need these!)
api.GET("/downloads/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadAgent)
api.GET("/install/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.InstallScript)
// Protected agent routes
agents := api.Group("/agents")
agents.Use(middleware.AuthMiddleware())
@@ -225,10 +232,6 @@ func main() {
dashboard.POST("/docker/containers/:container_id/images/:image_id/reject", dockerHandler.RejectUpdate)
dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate)
// Download routes (authenticated)
dashboard.GET("/downloads/:platform", downloadHandler.DownloadAgent)
dashboard.GET("/install/:platform", downloadHandler.InstallScript)
// Admin/Registration Token routes (for agent enrollment management)
admin := dashboard.Group("/admin")
{
@@ -236,6 +239,7 @@ func main() {
admin.GET("/registration-tokens", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ListRegistrationTokens)
admin.GET("/registration-tokens/active", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetActiveRegistrationTokens)
admin.DELETE("/registration-tokens/:token", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.RevokeRegistrationToken)
admin.DELETE("/registration-tokens/delete/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.DeleteRegistrationToken)
admin.POST("/registration-tokens/cleanup", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.CleanupExpiredTokens)
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)

View File

@@ -18,15 +18,17 @@ type AgentHandler struct {
agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries
refreshTokenQueries *queries.RefreshTokenQueries
registrationTokenQueries *queries.RegistrationTokenQueries
checkInInterval int
latestAgentVersion string
}
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, checkInInterval int, latestAgentVersion string) *AgentHandler {
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, regTokenQueries *queries.RegistrationTokenQueries, checkInInterval int, latestAgentVersion string) *AgentHandler {
return &AgentHandler{
agentQueries: aq,
commandQueries: cq,
refreshTokenQueries: rtq,
registrationTokenQueries: regTokenQueries,
checkInInterval: checkInInterval,
latestAgentVersion: latestAgentVersion,
}
@@ -40,6 +42,35 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
return
}
// Validate registration token (critical security check)
// Extract token from Authorization header or request body
var registrationToken string
// Try Authorization header first (Bearer token)
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
registrationToken = authHeader[7:]
}
}
// If not in header, try request body (fallback)
if registrationToken == "" && req.RegistrationToken != "" {
registrationToken = req.RegistrationToken
}
// Reject if no registration token provided
if registrationToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "registration token required"})
return
}
// Validate the registration token
tokenInfo, err := h.registrationTokenQueries.ValidateRegistrationToken(registrationToken)
if err != nil || tokenInfo == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired registration token"})
return
}
// Create new agent
agent := &models.Agent{
ID: uuid.New(),
@@ -66,6 +97,17 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
return
}
// Mark registration token as used (CRITICAL: must succeed or delete agent)
if err := h.registrationTokenQueries.MarkTokenUsed(registrationToken, agent.ID); err != nil {
// Token marking failed - rollback agent creation to prevent token reuse
log.Printf("ERROR: Failed to mark registration token as used: %v - rolling back agent creation", err)
if deleteErr := h.agentQueries.DeleteAgent(agent.ID); deleteErr != nil {
log.Printf("ERROR: Failed to delete agent during rollback: %v", deleteErr)
}
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed - token may be expired, revoked, or all seats may be used"})
return
}
// Generate JWT access token (short-lived: 24 hours)
token, err := middleware.GenerateAgentToken(agent.ID)
if err != nil {

View File

@@ -5,6 +5,8 @@ import (
"net/http"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
@@ -13,28 +15,34 @@ import (
// AuthHandler handles authentication for the web dashboard
type AuthHandler struct {
jwtSecret string
userQueries *queries.UserQueries
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(jwtSecret string) *AuthHandler {
func NewAuthHandler(jwtSecret string, userQueries *queries.UserQueries) *AuthHandler {
return &AuthHandler{
jwtSecret: jwtSecret,
userQueries: userQueries,
}
}
// LoginRequest represents a login request
type LoginRequest struct {
Token string `json:"token" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse represents a login response
type LoginResponse struct {
Token string `json:"token"`
User *models.User `json:"user"`
}
// UserClaims represents JWT claims for web dashboard users
type UserClaims struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
@@ -46,16 +54,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// For development, accept any non-empty token
// In production, implement proper authentication
if req.Token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
// Validate credentials against database
user, err := h.userQueries.VerifyCredentials(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
}
// Create JWT token for web dashboard
claims := UserClaims{
UserID: uuid.New(), // Generate a user ID for this session
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -69,7 +79,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
c.JSON(http.StatusOK, LoginResponse{Token: tokenString})
c.JSON(http.StatusOK, LoginResponse{
Token: tokenString,
User: user,
})
}
// VerifyToken handles token verification

View File

@@ -1,49 +1,101 @@
package handlers
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/gin-gonic/gin"
)
// DownloadHandler handles agent binary downloads
type DownloadHandler struct {
agentDir string
config *config.Config
}
func NewDownloadHandler(agentDir string) *DownloadHandler {
func NewDownloadHandler(agentDir string, cfg *config.Config) *DownloadHandler {
return &DownloadHandler{
agentDir: agentDir,
config: cfg,
}
}
// getServerURL determines the server URL with proper protocol detection
func (h *DownloadHandler) getServerURL(c *gin.Context) string {
// Priority 1: Use configured public URL if set
if h.config.Server.PublicURL != "" {
return h.config.Server.PublicURL
}
// Priority 2: Detect from request with TLS/proxy awareness
scheme := "http"
// Check if TLS is enabled in config
if h.config.Server.TLS.Enabled {
scheme = "https"
}
// Check if request came through HTTPS (direct or via proxy)
if c.Request.TLS != nil {
scheme = "https"
}
// Check X-Forwarded-Proto for reverse proxy setups
if forwardedProto := c.GetHeader("X-Forwarded-Proto"); forwardedProto == "https" {
scheme = "https"
}
// Use the Host header exactly as received (includes port if present)
host := c.GetHeader("X-Forwarded-Host")
if host == "" {
host = c.Request.Host
}
return fmt.Sprintf("%s://%s", scheme, host)
}
// DownloadAgent serves agent binaries for different platforms
func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
platform := c.Param("platform")
// Validate platform to prevent directory traversal
// Validate platform to prevent directory traversal (removed darwin - no macOS support)
validPlatforms := map[string]bool{
"linux-amd64": true,
"linux-arm64": true,
"windows-amd64": true,
"windows-arm64": true,
"darwin-amd64": true,
"darwin-arm64": true,
}
if !validPlatforms[platform] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid platform"})
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform"})
return
}
// Build filename based on platform
filename := "redflag-agent"
if strings.HasPrefix(platform, "windows") {
filename += ".exe"
}
agentPath := filepath.Join(h.agentDir, filename)
// Serve from platform-specific directory: binaries/{platform}/redflag-agent
agentPath := filepath.Join(h.agentDir, "binaries", platform, filename)
// Check if file exists
if _, err := os.Stat(agentPath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent binary not found"})
return
}
// Handle both GET and HEAD requests
if c.Request.Method == "HEAD" {
c.Status(http.StatusOK)
return
}
c.File(agentPath)
}
@@ -51,75 +103,604 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
func (h *DownloadHandler) InstallScript(c *gin.Context) {
platform := c.Param("platform")
// Validate platform
// Validate platform (removed darwin - no macOS support)
validPlatforms := map[string]bool{
"linux": true,
"darwin": true,
"windows": true,
}
if !validPlatforms[platform] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid platform"})
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform"})
return
}
scriptContent := h.generateInstallScript(platform, c.Request.Host)
serverURL := h.getServerURL(c)
scriptContent := h.generateInstallScript(platform, serverURL)
c.Header("Content-Type", "text/plain")
c.String(http.StatusOK, scriptContent)
}
func (h *DownloadHandler) generateInstallScript(platform, serverHost string) string {
baseURL := "http://" + serverHost
func (h *DownloadHandler) generateInstallScript(platform, baseURL string) string {
switch platform {
case "linux":
return `#!/bin/bash
set -e
# RedFlag Agent Installation Script
# This script installs the RedFlag agent as a systemd service with proper security hardening
REDFLAG_SERVER="` + baseURL + `"
AGENT_DIR="/usr/local/bin"
SERVICE_NAME="redflag-agent"
AGENT_USER="redflag-agent"
AGENT_HOME="/var/lib/redflag-agent"
AGENT_BINARY="/usr/local/bin/redflag-agent"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
CONFIG_DIR="/etc/aggregator"
echo "=== RedFlag Agent Installation ==="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root or with sudo"
echo "ERROR: This script must be run as root (use sudo)"
exit 1
fi
echo "Installing RedFlag agent from ${REDFLAG_SERVER}..."
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
DOWNLOAD_ARCH="amd64"
;;
aarch64|arm64)
DOWNLOAD_ARCH="arm64"
;;
*)
echo "ERROR: Unsupported architecture: $ARCH"
echo "Supported: x86_64 (amd64), aarch64 (arm64)"
exit 1
;;
esac
# Download agent
curl -sfL "${REDFLAG_SERVER}/api/v1/downloads/linux-amd64" -o "${AGENT_DIR}/redflag-agent"
chmod +x "${AGENT_DIR}/redflag-agent"
echo "Detected architecture: $ARCH (using linux-$DOWNLOAD_ARCH)"
echo ""
echo "Agent downloaded. Please visit ${REDFLAG_SERVER}/admin to get a registration token."
echo "Then run: ${AGENT_DIR}/redflag-agent --server ${REDFLAG_SERVER} --token <YOUR_TOKEN>"`
# Step 1: Create system user
echo "Step 1: Creating system user..."
if id "$AGENT_USER" &>/dev/null; then
echo "✓ User $AGENT_USER already exists"
else
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
echo "✓ User $AGENT_USER created"
fi
case "darwin":
return `#!/bin/bash
set -e
# Create home directory if it doesn't exist
if [ ! -d "$AGENT_HOME" ]; then
mkdir -p "$AGENT_HOME"
chown "$AGENT_USER:$AGENT_USER" "$AGENT_HOME"
echo "✓ Home directory created"
fi
REDFLAG_SERVER="` + baseURL + `"
AGENT_DIR="/usr/local/bin"
# Stop existing service if running (to allow binary update)
if systemctl is-active --quiet redflag-agent 2>/dev/null; then
echo ""
echo "Existing service detected - stopping to allow update..."
systemctl stop redflag-agent
sleep 2
echo "✓ Service stopped"
fi
echo "Installing RedFlag agent from ${REDFLAG_SERVER}..."
# Step 2: Download agent binary
echo ""
echo "Step 2: Downloading agent binary..."
echo "Downloading from ${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}..."
# Download agent
curl -sfL "${REDFLAG_SERVER}/api/v1/downloads/darwin-amd64" -o "${AGENT_DIR}/redflag-agent"
chmod +x "${AGENT_DIR}/redflag-agent"
# Download to temporary file first (to avoid root permission issues)
TEMP_FILE="/tmp/redflag-agent-${DOWNLOAD_ARCH}"
echo "Downloading to temporary file: $TEMP_FILE"
echo "Agent downloaded. Please visit ${REDFLAG_SERVER}/admin to get a registration token."
echo "Then run: ${AGENT_DIR}/redflag-agent --server ${REDFLAG_SERVER} --token <YOUR_TOKEN>"`
# Try curl first (most reliable)
if curl -sL "${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}" -o "$TEMP_FILE"; then
echo "✓ Download successful, moving to final location"
mv "$TEMP_FILE" "${AGENT_BINARY}"
chmod 755 "${AGENT_BINARY}"
chown root:root "${AGENT_BINARY}"
echo "✓ Agent binary downloaded and installed"
else
echo "✗ Download with curl failed"
# Fallback to wget if available
if command -v wget >/dev/null 2>&1; then
echo "Trying wget fallback..."
if wget -q "${REDFLAG_SERVER}/api/v1/downloads/linux-${DOWNLOAD_ARCH}" -O "$TEMP_FILE"; then
echo "✓ Download successful with wget, moving to final location"
mv "$TEMP_FILE" "${AGENT_BINARY}"
chmod 755 "${AGENT_BINARY}"
chown root:root "${AGENT_BINARY}"
echo "✓ Agent binary downloaded and installed (using wget fallback)"
else
echo "ERROR: Failed to download agent binary"
echo "Both curl and wget failed"
echo "Please ensure ${REDFLAG_SERVER} is accessible"
# Clean up temp file if it exists
rm -f "$TEMP_FILE"
exit 1
fi
else
echo "ERROR: Failed to download agent binary"
echo "curl failed and wget is not available"
echo "Please ensure ${REDFLAG_SERVER} is accessible"
# Clean up temp file if it exists
rm -f "$TEMP_FILE"
exit 1
fi
fi
# Clean up temp file if it still exists
rm -f "$TEMP_FILE"
# Set SELinux context for binary if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "SELinux detected, setting file context for binary..."
restorecon -v "${AGENT_BINARY}" 2>/dev/null || true
echo "✓ SELinux context set for binary"
fi
# Step 3: Install sudoers configuration
echo ""
echo "Step 3: Installing sudoers configuration..."
cat > "$SUDOERS_FILE" <<'SUDOERS_EOF'
# RedFlag Agent minimal sudo permissions
# This file grants the redflag-agent user limited sudo access for package management
# Generated automatically during RedFlag agent installation
# APT package management commands (Debian/Ubuntu)
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
# DNF package management commands (RHEL/Fedora/Rocky/Alma)
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf makecache
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
# Docker operations
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
SUDOERS_EOF
chmod 440 "$SUDOERS_FILE"
# Validate sudoers file
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then
echo "✓ Sudoers configuration installed and validated"
else
echo "ERROR: Sudoers configuration is invalid"
rm -f "$SUDOERS_FILE"
exit 1
fi
# Step 4: Create configuration directory
echo ""
echo "Step 4: Creating configuration directory..."
mkdir -p "$CONFIG_DIR"
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
echo "✓ Configuration directory created"
# Set SELinux context for config directory if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "Setting SELinux context for config directory..."
restorecon -Rv "$CONFIG_DIR" 2>/dev/null || true
echo "✓ SELinux context set for config directory"
fi
# Step 5: Install systemd service
echo ""
echo "Step 5: Installing systemd service..."
cat > "$SERVICE_FILE" <<SERVICE_EOF
[Unit]
Description=RedFlag Update Agent
After=network.target
Documentation=https://github.com/Fimeg/RedFlag
[Service]
Type=simple
User=$AGENT_USER
Group=$AGENT_USER
WorkingDirectory=$AGENT_HOME
ExecStart=$AGENT_BINARY
Restart=always
RestartSec=30
# Security hardening
# NoNewPrivileges=true - DISABLED: Prevents sudo from working, which agent needs for package management
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$AGENT_HOME /var/log $CONFIG_DIR
PrivateTmp=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=redflag-agent
[Install]
WantedBy=multi-user.target
SERVICE_EOF
chmod 644 "$SERVICE_FILE"
echo "✓ Systemd service installed"
# Step 6: Register agent with server
echo ""
echo "Step 6: Agent registration"
echo "=========================================="
echo ""
# Check if token was provided as parameter (for one-liner support)
if [ -n "$1" ]; then
REGISTRATION_TOKEN="$1"
echo "Using provided registration token"
else
# Check if stdin is a terminal (not being piped)
if [ -t 0 ]; then
echo "Registration token required to enroll this agent with the server."
echo ""
echo "To get a token:"
echo " 1. Visit: ${REDFLAG_SERVER}/settings/tokens"
echo " 2. Copy the active token from the list"
echo ""
echo "Enter registration token (or press Enter to skip):"
read -p "> " REGISTRATION_TOKEN
else
echo ""
echo "IMPORTANT: Registration token required!"
echo ""
echo "Since you're running this via pipe, you need to:"
echo ""
echo "Option 1 - One-liner with token:"
echo " curl -sfL ${REDFLAG_SERVER}/api/v1/install/linux | sudo bash -s -- YOUR_TOKEN"
echo ""
echo "Option 2 - Download and run interactively:"
echo " curl -sfL ${REDFLAG_SERVER}/api/v1/install/linux -o install.sh"
echo " chmod +x install.sh"
echo " sudo ./install.sh"
echo ""
echo "Skipping registration for now."
echo "Please register manually after installation."
fi
fi
# Check if agent is already registered
if [ -f "$CONFIG_DIR/config.json" ]; then
echo ""
echo "[INFO] Agent already registered - configuration file exists"
echo "[INFO] Skipping registration to preserve agent history"
echo "[INFO] If you need to re-register, delete: $CONFIG_DIR/config.json"
echo ""
elif [ -n "$REGISTRATION_TOKEN" ]; then
echo ""
echo "Registering agent..."
# Create config file and register
cat > "$CONFIG_DIR/config.json" <<EOF
{
"server_url": "${REDFLAG_SERVER}",
"registration_token": "${REGISTRATION_TOKEN}"
}
EOF
# Set proper permissions
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR/config.json"
chmod 600 "$CONFIG_DIR/config.json"
# Run agent registration as the agent user with explicit server and token
echo "Running: sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token $REGISTRATION_TOKEN --register"
if sudo -u "$AGENT_USER" "${AGENT_BINARY}" --server "${REDFLAG_SERVER}" --token "$REGISTRATION_TOKEN" --register; then
echo "✓ Agent registered successfully"
# Update config file with the new agent credentials
if [ -f "$CONFIG_DIR/config.json" ]; then
chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR/config.json"
chmod 600 "$CONFIG_DIR/config.json"
echo "✓ Configuration file updated and secured"
fi
else
echo "ERROR: Agent registration failed"
echo "Please check the token and server URL, then try again"
echo ""
echo "To retry manually:"
echo " sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token $REGISTRATION_TOKEN --register"
exit 1
fi
else
echo ""
echo "Skipping registration. You'll need to register manually before starting the service."
echo ""
echo "To register later:"
echo " 1. Visit ${REDFLAG_SERVER}/settings/tokens"
echo " 2. Copy a registration token"
echo " 3. Run: sudo -u $AGENT_USER ${AGENT_BINARY} --server ${REDFLAG_SERVER} --token YOUR_TOKEN"
echo ""
echo "Installation will continue, but the service will not start until registered."
fi
# Step 7: Enable and start service
echo ""
echo "Step 7: Enabling and starting service..."
systemctl daemon-reload
# Check if agent is registered
if [ -f "$CONFIG_DIR/config.json" ]; then
systemctl enable redflag-agent
systemctl restart redflag-agent
# Wait for service to start
sleep 2
if systemctl is-active --quiet redflag-agent; then
echo "✓ Service started successfully"
else
echo "⚠ Service failed to start. Check logs:"
echo " sudo journalctl -u redflag-agent -n 50"
exit 1
fi
else
echo "⚠ Service not started (agent not registered)"
echo " Run registration command above, then:"
echo " sudo systemctl enable redflag-agent"
echo " sudo systemctl start redflag-agent"
fi
# Step 8: Show status
echo ""
echo "=== Installation Complete ==="
echo ""
echo "The RedFlag agent has been installed with the following security features:"
echo " ✓ Dedicated system user (redflag-agent)"
echo " ✓ Limited sudo access via /etc/sudoers.d/redflag-agent"
echo " ✓ Systemd service with security hardening"
echo " ✓ Protected configuration directory"
echo ""
if systemctl is-active --quiet redflag-agent; then
echo "Service Status: ✓ RUNNING"
echo ""
systemctl status redflag-agent --no-pager -l | head -n 15
echo ""
else
echo "Service Status: ⚠ NOT RUNNING (waiting for registration)"
echo ""
fi
echo "Useful commands:"
echo " Check status: sudo systemctl status redflag-agent"
echo " View logs: sudo journalctl -u redflag-agent -f"
echo " Restart: sudo systemctl restart redflag-agent"
echo " Stop: sudo systemctl stop redflag-agent"
echo ""
echo "Configuration:"
echo " Config file: $CONFIG_DIR/config.json"
echo " Binary: $AGENT_BINARY"
echo " Service: $SERVICE_FILE"
echo " Sudoers: $SUDOERS_FILE"
echo ""
`
case "windows":
return `@echo off
REM RedFlag Agent Installation Script for Windows
REM This script downloads the agent and sets up Windows service
REM
REM Usage:
REM install.bat - Interactive mode (prompts for token)
REM install.bat YOUR_TOKEN_HERE - Automatic mode (uses provided token)
set REDFLAG_SERVER=` + baseURL + `
set AGENT_DIR=%ProgramFiles%\RedFlag
set AGENT_BINARY=%AGENT_DIR%\redflag-agent.exe
set CONFIG_DIR=%ProgramData%\RedFlag
echo Downloading RedFlag agent from %REDFLAG_SERVER%...
curl -sfL "%REDFLAG_SERVER%/api/v1/downloads/windows-amd64" -o redflag-agent.exe
echo === RedFlag Agent Installation ===
echo.
echo Agent downloaded. Please visit %REDFLAG_SERVER%/admin to get a registration token.
echo Then run: redflag-agent.exe --server %REDFLAG_SERVER% --token <YOUR_TOKEN%`
REM Check for admin privileges
net session >nul 2>&1
if %errorLevel% neq 0 (
echo ERROR: This script must be run as Administrator
echo Right-click and select "Run as administrator"
pause
exit /b 1
)
REM Detect architecture
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set DOWNLOAD_ARCH=amd64
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set DOWNLOAD_ARCH=arm64
) else (
echo ERROR: Unsupported architecture: %PROCESSOR_ARCHITECTURE%
echo Supported: AMD64, ARM64
pause
exit /b 1
)
echo Detected architecture: %PROCESSOR_ARCHITECTURE% (using windows-%DOWNLOAD_ARCH%)
echo.
REM Create installation directory
echo Creating installation directory...
if not exist "%AGENT_DIR%" mkdir "%AGENT_DIR%"
echo [OK] Installation directory created
REM Create config directory
if not exist "%CONFIG_DIR%" mkdir "%CONFIG_DIR%"
echo [OK] Configuration directory created
REM Grant full permissions to SYSTEM and Administrators on config directory
echo Setting permissions on configuration directory...
icacls "%CONFIG_DIR%" /grant "SYSTEM:(OI)(CI)F"
icacls "%CONFIG_DIR%" /grant "Administrators:(OI)(CI)F"
echo [OK] Permissions set
echo.
REM Stop existing service if running (to allow binary update)
sc query RedFlagAgent >nul 2>&1
if %errorLevel% equ 0 (
echo Existing service detected - stopping to allow update...
sc stop RedFlagAgent >nul 2>&1
timeout /t 3 /nobreak >nul
echo [OK] Service stopped
)
REM Download agent binary
echo Downloading agent binary...
echo From: %REDFLAG_SERVER%/api/v1/downloads/windows-%DOWNLOAD_ARCH%
curl -sfL "%REDFLAG_SERVER%/api/v1/downloads/windows-%DOWNLOAD_ARCH%" -o "%AGENT_BINARY%"
if %errorLevel% neq 0 (
echo ERROR: Failed to download agent binary
echo Please ensure %REDFLAG_SERVER% is accessible
pause
exit /b 1
)
echo [OK] Agent binary downloaded
echo.
REM Agent registration
echo === Agent Registration ===
echo.
REM Check if token was provided as command-line argument
if not "%1"=="" (
set TOKEN=%1
echo Using provided registration token
) else (
echo IMPORTANT: You need a registration token to enroll this agent.
echo.
echo To get a token:
echo 1. Visit: %REDFLAG_SERVER%/settings/tokens
echo 2. Create a new registration token
echo 3. Copy the token
echo.
set /p TOKEN="Enter registration token (or press Enter to skip): "
)
REM Check if agent is already registered
if exist "%CONFIG_DIR%\config.json" (
echo.
echo [INFO] Agent already registered - configuration file exists
echo [INFO] Skipping registration to preserve agent history
echo [INFO] If you need to re-register, delete: %CONFIG_DIR%\config.json
echo.
) else if not "%TOKEN%"=="" (
echo.
echo === Registering Agent ===
echo.
REM Attempt registration
"%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token "%TOKEN%" --register
REM Check exit code
if %errorLevel% equ 0 (
echo [OK] Agent registered successfully
echo [OK] Configuration saved to: %CONFIG_DIR%\config.json
echo.
) else (
echo.
echo [ERROR] Registration failed
echo.
echo Please check:
echo 1. Server is accessible: %REDFLAG_SERVER%
echo 2. Registration token is valid and not expired
echo 3. Token has available seats remaining
echo.
echo To try again:
echo "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token "%TOKEN%" --register
echo.
pause
exit /b 1
)
) else (
echo.
echo [INFO] No registration token provided - skipping registration
echo.
echo To register later:
echo "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token YOUR_TOKEN --register
)
REM Check if service already exists
echo.
echo === Configuring Windows Service ===
echo.
sc query RedFlagAgent >nul 2>&1
if %errorLevel% equ 0 (
echo [INFO] RedFlag Agent service already installed
echo [INFO] Service will be restarted with updated binary
echo.
) else (
echo Installing RedFlag Agent service...
"%AGENT_BINARY%" -install-service
if %errorLevel% equ 0 (
echo [OK] Service installed successfully
echo.
REM Give Windows SCM time to register the service
timeout /t 2 /nobreak >nul
) else (
echo [ERROR] Failed to install service
echo.
pause
exit /b 1
)
)
REM Start the service if agent is registered
if exist "%CONFIG_DIR%\config.json" (
echo Starting RedFlag Agent service...
"%AGENT_BINARY%" -start-service
if %errorLevel% equ 0 (
echo [OK] RedFlag Agent service started
echo.
echo Agent is now running as a Windows service in the background.
echo You can verify it is working by checking the agent status in the web UI.
) else (
echo [WARNING] Failed to start service. You can start it manually:
echo "%AGENT_BINARY%" -start-service
echo Or use Windows Services: services.msc
)
) else (
echo [WARNING] Service not started (agent not registered)
echo To register and start the service:
echo 1. Register: "%AGENT_BINARY%" --server "%REDFLAG_SERVER%" --token YOUR_TOKEN --register
echo 2. Start: "%AGENT_BINARY%" -start-service
)
echo.
echo === Installation Complete ===
echo.
echo The RedFlag agent has been installed as a Windows service.
echo Configuration file: %CONFIG_DIR%\config.json
echo Agent binary: %AGENT_BINARY%
echo.
echo Managing the RedFlag Agent service:
echo Check status: "%AGENT_BINARY%" -service-status
echo Start manually: "%AGENT_BINARY%" -start-service
echo Stop service: "%AGENT_BINARY%" -stop-service
echo Remove service: "%AGENT_BINARY%" -remove-service
echo.
echo Alternative management with Windows Services:
echo Open services.msc and look for "RedFlag Update Agent"
echo.
echo To run the agent directly (for debugging):
echo "%AGENT_BINARY%"
echo.
echo To verify the agent is working:
echo 1. Check the web UI for the agent status
echo 2. Look for recent check-ins from this machine
echo.
pause
`
default:
return "# Unsupported platform"

View File

@@ -8,6 +8,7 @@ import (
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type RegistrationTokenHandler struct {
@@ -29,6 +30,7 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
var request struct {
Label string `json:"label" binding:"required"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
Metadata map[string]interface{} `json:"metadata"`
}
@@ -86,8 +88,14 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
metadata["server_url"] = c.Request.Host
metadata["expires_in"] = expiresIn
// Default max_seats to 1 if not provided or invalid
maxSeats := request.MaxSeats
if maxSeats < 1 {
maxSeats = 1
}
// Store token in database
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, metadata)
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, maxSeats, metadata)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"})
return
@@ -117,6 +125,7 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
status := c.Query("status")
isActive := c.Query("is_active") == "true"
// Validate pagination
if limit > 100 {
@@ -131,10 +140,26 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
var tokens []queries.RegistrationToken
var err error
if status != "" {
// TODO: Add filtered queries by status
tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset)
// Handle filtering by active status
if isActive || status == "active" {
// Get only active tokens (no pagination for active-only queries)
tokens, err = h.tokenQueries.GetActiveRegistrationTokens()
// Apply manual pagination to active tokens if needed
if err == nil && len(tokens) > 0 {
start := offset
end := offset + limit
if start >= len(tokens) {
tokens = []queries.RegistrationToken{}
} else {
if end > len(tokens) {
end = len(tokens)
}
tokens = tokens[start:end]
}
}
} else {
// Get all tokens with database-level pagination
tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset)
}
@@ -213,6 +238,34 @@ func (h *RegistrationTokenHandler) RevokeRegistrationToken(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Token revoked successfully"})
}
// DeleteRegistrationToken permanently deletes a registration token
func (h *RegistrationTokenHandler) DeleteRegistrationToken(c *gin.Context) {
tokenID := c.Param("id")
if tokenID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Token ID is required"})
return
}
// Parse UUID
id, err := uuid.Parse(tokenID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID format"})
return
}
err = h.tokenQueries.DeleteRegistrationToken(id)
if err != nil {
if err.Error() == "token not found" {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete token"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Token deleted successfully"})
}
// ValidateRegistrationToken checks if a token is valid (for testing/debugging)
func (h *RegistrationTokenHandler) ValidateRegistrationToken(c *gin.Context) {
token := c.Query("token")

View File

@@ -2,16 +2,15 @@ package handlers
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
_ "github.com/lib/pq"
)
// SetupHandler handles server configuration
@@ -25,8 +24,81 @@ func NewSetupHandler(configPath string) *SetupHandler {
}
}
// updatePostgresPassword updates the PostgreSQL user password
func updatePostgresPassword(dbHost, dbPort, dbUser, currentPassword, newPassword string) error {
// Connect to PostgreSQL with current credentials
connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/postgres?sslmode=disable", dbUser, currentPassword, dbHost, dbPort)
db, err := sql.Open("postgres", connStr)
if err != nil {
return fmt.Errorf("failed to connect to PostgreSQL: %v", err)
}
defer db.Close()
// Test connection
if err := db.Ping(); err != nil {
return fmt.Errorf("failed to ping PostgreSQL: %v", err)
}
// Update the password
_, err = db.Exec("ALTER USER "+pq.QuoteIdentifier(dbUser)+" PASSWORD '"+newPassword+"'")
if err != nil {
return fmt.Errorf("failed to update PostgreSQL password: %v", err)
}
fmt.Println("PostgreSQL password updated successfully")
return nil
}
// createSharedEnvContentForDisplay generates the .env file content for display
func createSharedEnvContentForDisplay(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"`
}, jwtSecret string) (string, error) {
// Generate .env file content for user to copy
envContent := fmt.Sprintf(`# RedFlag Environment Configuration
# Generated by web setup - Save this content to ./config/.env
# PostgreSQL Configuration (for PostgreSQL container)
POSTGRES_DB=%s
POSTGRES_USER=%s
POSTGRES_PASSWORD=%s
# RedFlag Server Configuration
REDFLAG_SERVER_HOST=%s
REDFLAG_SERVER_PORT=%s
REDFLAG_DB_HOST=%s
REDFLAG_DB_PORT=%s
REDFLAG_DB_NAME=%s
REDFLAG_DB_USER=%s
REDFLAG_DB_PASSWORD=%s
REDFLAG_ADMIN_USER=%s
REDFLAG_ADMIN_PASSWORD=%s
REDFLAG_JWT_SECRET=%s
REDFLAG_TOKEN_EXPIRY=24h
REDFLAG_MAX_TOKENS=100
REDFLAG_MAX_SEATS=%s`,
req.DBName, req.DBUser, req.DBPassword,
req.ServerHost, req.ServerPort,
req.DBHost, req.DBPort, req.DBName, req.DBUser, req.DBPassword,
req.AdminUser, req.AdminPass, jwtSecret, req.MaxSeats)
return envContent, nil
}
// ShowSetupPage displays the web setup interface
func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
// Display setup page - configuration will be generated via web interface
fmt.Println("Showing setup page - configuration will be generated via web interface")
html := `
<!DOCTYPE html>
<html>
@@ -46,19 +118,16 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
.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, 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; } }
.btn { 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; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.success { color: #10b981; background: #ecfdf5; padding: 12px; border-radius: 6px; border: 1px solid #10b981; }
.error { color: #ef4444; background: #fef2f2; padding: 12px; border-radius: 6px; border: 1px solid #ef4444; }
.loading { display: none; text-align: center; margin: 20px 0; }
.spinner { border: 3px solid #f3f3f3; border-top: 3px solid #4f46e5; border-radius: 50%%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto; }
@keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } }
</style>
</head>
<body>
@@ -66,27 +135,24 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
<div class="card">
<div class="header">
<h1>🚀 RedFlag Server Setup</h1>
<p class="subtitle">Configure your update management server</p>
<p class="subtitle">Configure your RedFlag deployment</p>
</div>
<div class="content">
<form id="setupForm">
<div class="form-section">
<h3>🔐 Admin Account</h3>
<div class="grid">
<h3>📊 Server Configuration</h3>
<div class="form-group">
<label for="adminUser">Admin Username</label>
<input type="text" id="adminUser" name="adminUser" value="admin" required>
<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="adminPassword">Admin Password</label>
<input type="password" id="adminPassword" name="adminPassword" required>
</div>
<label for="serverPort">Server Port</label>
<input type="number" id="serverPort" name="serverPort" value="8080" required>
</div>
</div>
<div class="form-section">
<h3>💾 Database Configuration</h3>
<div class="grid">
<h3>🗄️ Database Configuration</h3>
<div class="form-group">
<label for="dbHost">Database Host</label>
<input type="text" id="dbHost" name="dbHost" value="postgres" required>
@@ -105,37 +171,42 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
</div>
<div class="form-group">
<label for="dbPassword">Database Password</label>
<input type="password" id="dbPassword" name="dbPassword" value="redflag" required>
</div>
<input type="password" id="dbPassword" name="dbPassword" placeholder="Enter a secure database password" required>
</div>
</div>
<div class="form-section">
<h3>🌐 Server Configuration</h3>
<div class="grid">
<h3>👤 Administrator Account</h3>
<div class="form-group">
<label for="serverHost">Server Host</label>
<input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
<label for="adminUser">Admin Username</label>
<input type="text" id="adminUser" name="adminUser" value="admin" required>
</div>
<div class="form-group">
<label for="serverPort">Server Port</label>
<input type="number" id="serverPort" name="serverPort" value="8080" required>
<label for="adminPassword">Admin Password</label>
<input type="password" id="adminPassword" name="adminPassword" placeholder="Enter a secure admin password" required>
</div>
</div>
<div class="form-section">
<h3>🔧 Agent Settings</h3>
<div class="form-group">
<label for="maxSeats">Maximum Agent Seats</label>
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000">
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000" required>
<small style="color: #6b7280; font-size: 0.875rem;">Maximum number of agents that can register</small>
</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>
<button type="submit" class="btn" id="submitBtn">
🚀 Configure RedFlag Server
</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Configuring your RedFlag server...</p>
</div>
<div id="result"></div>
</div>
</div>
</div>
@@ -144,56 +215,113 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
document.getElementById('setupForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
const submitBtn = document.getElementById('submitBtn');
const loading = document.getElementById('loading');
const result = document.getElementById('result');
const progress = document.getElementById('progress');
const progressBar = document.getElementById('progressBar');
const status = document.getElementById('status');
const submitButton = e.target.querySelector('button[type="submit"]');
// Get form values
const formData = {
serverHost: document.getElementById('serverHost').value,
serverPort: document.getElementById('serverPort').value,
dbHost: document.getElementById('dbHost').value,
dbPort: document.getElementById('dbPort').value,
dbName: document.getElementById('dbName').value,
dbUser: document.getElementById('dbUser').value,
dbPassword: document.getElementById('dbPassword').value,
adminUser: document.getElementById('adminUser').value,
adminPassword: document.getElementById('adminPassword').value,
maxSeats: document.getElementById('maxSeats').value
};
// Show progress and disable button
progress.style.display = 'block';
submitButton.disabled = true;
submitButton.textContent = 'Configuring...';
// Validate inputs
if (!formData.adminUser || !formData.adminPassword) {
result.innerHTML = '<div class="error">❌ Admin username and password are required</div>';
return;
}
if (!formData.dbHost || !formData.dbPort || !formData.dbName || !formData.dbUser || !formData.dbPassword) {
result.innerHTML = '<div class="error">❌ All database fields are required</div>';
return;
}
// Show loading
submitBtn.disabled = true;
loading.style.display = 'block';
result.innerHTML = '';
try {
const response = await fetch('/api/v1/setup', {
const response = await fetch('/api/setup/configure', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
body: JSON.stringify(formData)
});
const result = await response.json();
const resultData = await response.json();
if (response.ok) {
// Success
progressBar.style.width = '100%';
status.innerHTML = '<div class="success">✅ ' + result.message + '</div>';
submitButton.textContent = 'Configuration Complete';
let resultHtml = '<div class="success">';
resultHtml += '<h3>✅ Configuration Generated Successfully!</h3>';
resultHtml += '<p><strong>Your JWT Secret:</strong> <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">' + resultData.jwtSecret + '</code> ';
resultHtml += '<button onclick="copyJWT(\'' + resultData.jwtSecret + '\')" style="background: #4f46e5; color: white; border: none; padding: 4px 8px; border-radius: 3px; cursor: pointer; font-size: 0.8rem;">📋 Copy</button></p>';
resultHtml += '<p><strong>⚠️ Important Next Steps:</strong></p>';
resultHtml += '<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 15px 0;">';
resultHtml += '<p style="margin: 0; color: #92400e;"><strong>🔧 Complete Setup Required:</strong></p>';
resultHtml += '<ol style="margin: 10px 0 0 0; color: #92400e;">';
resultHtml += '<li>Replace the bootstrap environment variables with the newly generated ones below</li>';
resultHtml += '<li>Run: <code style="background: #fef3c7; padding: 2px 6px; border-radius: 3px;">' + resultData.manualRestartCommand + '</code></li>';
resultHtml += '</ol>';
resultHtml += '<p style="margin: 10px 0 0 0; color: #92400e; font-size: 0.9rem;"><strong>This step is required to apply your configuration and run database migrations.</strong></p>';
resultHtml += '</div>';
resultHtml += '</div>';
resultHtml += '<div style="margin-top: 20px;">';
resultHtml += '<h4>📄 Configuration Content:</h4>';
resultHtml += '<textarea readonly style="width: 100%%; height: 300px; font-family: monospace; font-size: 0.85rem; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; background: #f9fafb;">' + resultData.envContent + '</textarea>';
resultHtml += '<button onclick="copyConfig()" style="background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; margin-top: 10px;">📋 Copy All Configuration</button>';
resultHtml += '</div>';
result.innerHTML = resultHtml;
loading.style.display = 'none';
// Store JWT for copy function
window.jwtSecret = resultData.jwtSecret;
window.envContent = resultData.envContent;
// 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';
result.innerHTML = '<div class="error">❌ Error: ' + resultData.error + '</div>';
submitBtn.disabled = false;
loading.style.display = 'none';
}
} catch (error) {
status.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>';
submitButton.disabled = false;
submitButton.textContent = 'Configure Server';
result.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>';
submitBtn.disabled = false;
loading.style.display = 'none';
}
});
function copyJWT(jwt) {
navigator.clipboard.writeText(jwt).then(() => {
alert('JWT secret copied to clipboard!');
}).catch(() => {
prompt('Copy this JWT secret:', jwt);
});
}
function copyConfig() {
if (window.envContent) {
navigator.clipboard.writeText(window.envContent).then(() => {
alert('Configuration copied to clipboard!');
}).catch(() => {
prompt('Copy this configuration:', window.envContent);
});
}
}
</script>
</body>
</html>`
c.Data(200, "text/html; charset=utf-8", []byte(html))
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}
// ConfigureServer handles the configuration submission
@@ -246,95 +374,36 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
return
}
// Create configuration content
envContent := fmt.Sprintf(`# RedFlag Server Configuration
# Generated by web setup
// Generate JWT secret for display (not logged for security)
jwtSecret := deriveJWTSecret(req.AdminUser, req.AdminPass)
# 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 {
fmt.Printf("Failed to create config directory: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create config directory: %v", err)})
return
// Step 1: Update PostgreSQL password from bootstrap to user password
fmt.Println("Updating PostgreSQL password from bootstrap to user-provided password...")
bootstrapPassword := "redflag_bootstrap" // This matches our bootstrap .env
if err := updatePostgresPassword(req.DBHost, req.DBPort, req.DBUser, bootstrapPassword, req.DBPassword); err != nil {
fmt.Printf("Warning: Failed to update PostgreSQL password: %v\n", err)
fmt.Println("Will proceed with configuration anyway...")
}
envPath := filepath.Join(configDir, ".env")
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
fmt.Printf("Failed to save configuration: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save configuration: %v", err)})
return
}
// Step 2: Generate configuration content for manual update
fmt.Println("Generating configuration content for manual .env file update...")
// Trigger graceful server restart after configuration
go func() {
time.Sleep(2 * time.Second) // Give response time to reach client
// Get the current executable path
execPath, err := os.Executable()
// Generate the complete .env file content for the user to copy
newEnvContent, err := createSharedEnvContentForDisplay(req, jwtSecret)
if err != nil {
fmt.Printf("Failed to get executable path: %v\n", err)
fmt.Printf("Failed to generate .env content: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate configuration content"})
return
}
// Restart the server with the same executable
cmd := exec.Command(execPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// Start the new process
if err := cmd.Start(); err != nil {
fmt.Printf("Failed to start new server process: %v\n", err)
return
}
// Exit the current process gracefully
fmt.Printf("Server restarting... PID: %d\n", cmd.Process.Pid)
os.Exit(0)
}()
c.JSON(http.StatusOK, gin.H{
"message": "Configuration saved successfully! Server will restart automatically.",
"configPath": envPath,
"restart": true,
"message": "Configuration generated successfully!",
"jwtSecret": jwtSecret,
"envContent": newEnvContent,
"restartMessage": "Please replace the bootstrap environment variables with the newly generated ones, then run: docker-compose down && docker-compose up -d",
"manualRestartRequired": true,
"manualRestartCommand": "docker-compose down && docker-compose up -d",
"configFilePath": "./config/.env",
})
}

View File

@@ -6,13 +6,7 @@ import (
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
"golang.org/x/term"
)
// Config holds the application configuration
@@ -20,6 +14,7 @@ type Config struct {
Server struct {
Host string `env:"REDFLAG_SERVER_HOST" default:"0.0.0.0"`
Port int `env:"REDFLAG_SERVER_PORT" default:"8080"`
PublicURL string `env:"REDFLAG_PUBLIC_URL"` // Optional: External URL for reverse proxy/load balancer
TLS struct {
Enabled bool `env:"REDFLAG_TLS_ENABLED" default:"false"`
CertFile string `env:"REDFLAG_TLS_CERT_FILE"`
@@ -49,17 +44,9 @@ type Config struct {
LatestAgentVersion string
}
// Load reads configuration from environment variables
// Load reads configuration from environment variables only (immutable configuration)
func Load() (*Config, error) {
// Load .env file from persistent config directory
configPaths := []string{"/app/config/.env", ".env"}
for _, path := range configPaths {
if _, err := os.Stat(path); err == nil {
_ = godotenv.Load(path)
break
}
}
fmt.Printf("[CONFIG] Loading configuration from environment variables\n")
cfg := &Config{}
@@ -67,6 +54,7 @@ func Load() (*Config, error) {
cfg.Server.Host = getEnv("REDFLAG_SERVER_HOST", "0.0.0.0")
serverPort, _ := strconv.Atoi(getEnv("REDFLAG_SERVER_PORT", "8080"))
cfg.Server.Port = serverPort
cfg.Server.PublicURL = getEnv("REDFLAG_PUBLIC_URL", "") // Optional external URL
cfg.Server.TLS.Enabled = getEnv("REDFLAG_TLS_ENABLED", "false") == "true"
cfg.Server.TLS.CertFile = getEnv("REDFLAG_TLS_CERT_FILE", "")
cfg.Server.TLS.KeyFile = getEnv("REDFLAG_TLS_KEY_FILE", "")
@@ -106,6 +94,13 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("missing required configuration")
}
// Check if we're using bootstrap defaults that need to be replaced
if cfg.Admin.Password == "changeme" || cfg.Admin.JWTSecret == "bootstrap-jwt-secret-replace-in-setup" || cfg.Database.Password == "redflag_bootstrap" {
fmt.Printf("[INFO] Server running with bootstrap configuration - setup required\n")
fmt.Printf("[INFO] Configure via web interface at: http://localhost:8080/setup\n")
return nil, fmt.Errorf("bootstrap configuration detected - setup required")
}
// Validate JWT secret is not the development default
if cfg.Admin.JWTSecret == "test-secret-for-development-only" {
fmt.Printf("[SECURITY WARNING] Using development JWT secret\n")
@@ -115,103 +110,9 @@ func Load() (*Config, error) {
return cfg, nil
}
// RunSetupWizard guides user through initial configuration
// RunSetupWizard is deprecated - configuration is now handled via web interface
func RunSetupWizard() error {
fmt.Printf("RedFlag Server Setup Wizard\n")
fmt.Printf("===========================\n\n")
// Admin credentials
fmt.Printf("Admin Account Setup\n")
fmt.Printf("--------------------\n")
username := promptForInput("Admin username", "admin")
password := promptForPassword("Admin password")
// Database configuration
fmt.Printf("\nDatabase Configuration\n")
fmt.Printf("----------------------\n")
dbHost := promptForInput("Database host", "localhost")
dbPort, _ := strconv.Atoi(promptForInput("Database port", "5432"))
dbName := promptForInput("Database name", "redflag")
dbUser := promptForInput("Database user", "redflag")
dbPassword := promptForPassword("Database password")
// Server configuration
fmt.Printf("\nServer Configuration\n")
fmt.Printf("--------------------\n")
serverHost := promptForInput("Server bind address", "0.0.0.0")
serverPort, _ := strconv.Atoi(promptForInput("Server port", "8080"))
// Agent limits
fmt.Printf("\nAgent Registration\n")
fmt.Printf("------------------\n")
maxSeats, _ := strconv.Atoi(promptForInput("Maximum agent seats (security limit)", "50"))
// Generate JWT secret from admin password
jwtSecret := deriveJWTSecret(username, password)
// Create .env file
envContent := fmt.Sprintf(`# RedFlag Server Configuration
# Generated on %s
# 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.8
`, time.Now().Format("2006-01-02 15:04:05"), serverHost, serverPort,
dbHost, dbPort, dbName, dbUser, dbPassword,
username, password, jwtSecret, maxSeats,
serverPort, dbUser, dbPassword, dbHost, dbPort, dbName, jwtSecret)
// Write .env file to persistent location
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)
}
fmt.Printf("\n[OK] Configuration saved to .env file\n")
fmt.Printf("[SECURITY] File permissions set to 0600 (owner read/write only)\n")
fmt.Printf("\nNext steps:\n")
fmt.Printf(" 1. Start database: %s:%d\n", dbHost, dbPort)
fmt.Printf(" 2. Create database: CREATE DATABASE %s;\n", dbName)
fmt.Printf(" 3. Run migrations: ./redflag-server --migrate\n")
fmt.Printf(" 4. Start server: ./redflag-server\n")
fmt.Printf("\nServer will be available at: http://%s:%d\n", serverHost, serverPort)
fmt.Printf("Admin interface: http://%s:%d/admin\n", serverHost, serverPort)
return nil
return fmt.Errorf("CLI setup wizard is deprecated. Please use the web interface at http://localhost:8080/setup for configuration")
}
func getEnv(key, defaultValue string) string {
@@ -221,28 +122,6 @@ func getEnv(key, defaultValue string) string {
return defaultValue
}
func promptForInput(prompt, defaultValue string) string {
fmt.Printf("%s [%s]: ", prompt, defaultValue)
var input string
fmt.Scanln(&input)
if strings.TrimSpace(input) == "" {
return defaultValue
}
return strings.TrimSpace(input)
}
func promptForPassword(prompt string) string {
fmt.Printf("%s: ", prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
// Fallback to non-hidden input
var input string
fmt.Scanln(&input)
return strings.TrimSpace(input)
}
fmt.Printf("\n")
return strings.TrimSpace(string(password))
}
func deriveJWTSecret(username, password string) string {
// Derive JWT secret from admin credentials

View File

@@ -35,8 +35,18 @@ func Connect(databaseURL string) (*DB, error) {
return &DB{db}, nil
}
// Migrate runs database migrations
// Migrate runs database migrations with proper tracking
func (db *DB) Migrate(migrationsPath string) error {
// Create migrations table if it doesn't exist
createTableSQL := `
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)`
if _, err := db.Exec(createTableSQL); err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Read migration files
files, err := os.ReadDir(migrationsPath)
if err != nil {
@@ -52,18 +62,67 @@ func (db *DB) Migrate(migrationsPath string) error {
}
sort.Strings(migrationFiles)
// Execute migrations
// Execute migrations that haven't been applied yet
for _, filename := range migrationFiles {
// Check if migration has already been applied
var count int
err := db.Get(&count, "SELECT COUNT(*) FROM schema_migrations WHERE version = $1", filename)
if err != nil {
return fmt.Errorf("failed to check migration status for %s: %w", filename, err)
}
if count > 0 {
fmt.Printf("→ Skipping migration (already applied): %s\n", filename)
continue
}
// Read migration file
path := filepath.Join(migrationsPath, filename)
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err)
}
if _, err := db.Exec(string(content)); err != nil {
// Execute migration in a transaction
tx, err := db.Beginx()
if err != nil {
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
}
// Execute the migration SQL
if _, err := tx.Exec(string(content)); err != nil {
// Check if it's a "already exists" error - if so, handle gracefully
if strings.Contains(err.Error(), "already exists") ||
strings.Contains(err.Error(), "duplicate key") ||
strings.Contains(err.Error(), "relation") && strings.Contains(err.Error(), "already exists") {
fmt.Printf("⚠ Migration %s failed (objects already exist), marking as applied: %v\n", filename, err)
// Rollback current transaction and start a new one for tracking
tx.Rollback()
// Start new transaction just for migration tracking
if newTx, newTxErr := db.Beginx(); newTxErr == nil {
if _, insertErr := newTx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", filename); insertErr == nil {
newTx.Commit()
} else {
newTx.Rollback()
}
}
continue
}
tx.Rollback()
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
}
// Record the migration as applied
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", filename); err != nil {
tx.Rollback()
return fmt.Errorf("failed to record migration %s: %w", filename, err)
}
// Commit the transaction
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
}
fmt.Printf("✓ Executed migration: %s\n", filename)
}

View File

@@ -0,0 +1,118 @@
-- Add seat tracking to registration tokens for multi-use support
-- This allows tokens to be used multiple times up to a configured limit
-- Add seats columns
ALTER TABLE registration_tokens
ADD COLUMN max_seats INT NOT NULL DEFAULT 1,
ADD COLUMN seats_used INT NOT NULL DEFAULT 0;
-- Backfill existing tokens
-- Tokens with status='used' should have seats_used=1, max_seats=1
UPDATE registration_tokens
SET seats_used = 1,
max_seats = 1
WHERE status = 'used';
-- Active/expired/revoked tokens get max_seats=1, seats_used=0
UPDATE registration_tokens
SET seats_used = 0,
max_seats = 1
WHERE status IN ('active', 'expired', 'revoked');
-- Add constraint to ensure seats_used doesn't exceed max_seats
ALTER TABLE registration_tokens
ADD CONSTRAINT chk_seats_used_within_max
CHECK (seats_used <= max_seats);
-- Add constraint to ensure positive seat values
ALTER TABLE registration_tokens
ADD CONSTRAINT chk_seats_positive
CHECK (max_seats > 0 AND seats_used >= 0);
-- Create table to track all agents that used a token (for audit trail)
CREATE TABLE IF NOT EXISTS registration_token_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_id UUID NOT NULL REFERENCES registration_tokens(id) ON DELETE CASCADE,
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
used_at TIMESTAMP DEFAULT NOW(),
UNIQUE(token_id, agent_id)
);
CREATE INDEX idx_token_usage_token_id ON registration_token_usage(token_id);
CREATE INDEX idx_token_usage_agent_id ON registration_token_usage(agent_id);
-- Backfill token usage table from existing used_by_agent_id
INSERT INTO registration_token_usage (token_id, agent_id, used_at)
SELECT id, used_by_agent_id, used_at
FROM registration_tokens
WHERE used_by_agent_id IS NOT NULL
ON CONFLICT (token_id, agent_id) DO NOTHING;
-- Update is_registration_token_valid function to check seats
CREATE OR REPLACE FUNCTION is_registration_token_valid(token_input VARCHAR)
RETURNS BOOLEAN AS $$
DECLARE
token_valid BOOLEAN;
BEGIN
SELECT (status = 'active' AND expires_at > NOW() AND seats_used < max_seats) INTO token_valid
FROM registration_tokens
WHERE token = token_input;
RETURN COALESCE(token_valid, FALSE);
END;
$$ LANGUAGE plpgsql;
-- Update mark_registration_token_used function to increment seats
CREATE OR REPLACE FUNCTION mark_registration_token_used(token_input VARCHAR, agent_id_param UUID)
RETURNS BOOLEAN AS $$
DECLARE
rows_updated INTEGER; -- Fixed: Changed from BOOLEAN to INTEGER to match ROW_COUNT type
token_id_val UUID;
new_seats_used INT;
token_max_seats INT;
BEGIN
-- Get token ID and current seat info
SELECT id, seats_used + 1, max_seats INTO token_id_val, new_seats_used, token_max_seats
FROM registration_tokens
WHERE token = token_input
AND status = 'active'
AND expires_at > NOW()
AND seats_used < max_seats;
-- If no token found or already full, return false
IF token_id_val IS NULL THEN
RETURN FALSE;
END IF;
-- Increment seats_used
UPDATE registration_tokens
SET seats_used = new_seats_used,
used_at = CASE
WHEN used_at IS NULL THEN NOW() -- First use
ELSE used_at -- Keep original first use time
END,
-- Only mark as 'used' if all seats are now taken
status = CASE
WHEN new_seats_used >= token_max_seats THEN 'used'
ELSE 'active'
END
WHERE token = token_input
AND status = 'active';
GET DIAGNOSTICS rows_updated = ROW_COUNT;
-- Record this usage in the audit table
IF rows_updated > 0 THEN
INSERT INTO registration_token_usage (token_id, agent_id, used_at)
VALUES (token_id_val, agent_id_param, NOW())
ON CONFLICT (token_id, agent_id) DO NOTHING;
END IF;
RETURN rows_updated > 0;
END;
$$ LANGUAGE plpgsql;
-- Add comment for documentation
COMMENT ON COLUMN registration_tokens.max_seats IS 'Maximum number of agents that can register with this token';
COMMENT ON COLUMN registration_tokens.seats_used IS 'Number of agents that have registered with this token';
COMMENT ON TABLE registration_token_usage IS 'Audit trail of all agents registered with each token';

View File

@@ -0,0 +1,10 @@
-- Create admin user from environment configuration
-- This migration reads the admin credentials from environment variables
-- and creates the initial admin user in the database
-- Note: This is a placeholder migration that will be executed by the application
-- The actual user creation logic is handled in the main application startup
-- to allow for proper password hashing and error handling
-- The admin user creation is handled by the application during startup
-- This migration file exists for version tracking purposes

View File

@@ -27,12 +27,15 @@ type RegistrationToken struct {
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
Status string `json:"status" db:"status"`
CreatedBy string `json:"created_by" db:"created_by"`
Metadata map[string]interface{} `json:"metadata" db:"metadata"`
Metadata json.RawMessage `json:"metadata" db:"metadata"`
MaxSeats int `json:"max_seats" db:"max_seats"`
SeatsUsed int `json:"seats_used" db:"seats_used"`
}
type TokenRequest struct {
Label string `json:"label"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
MaxSeats int `json:"max_seats"` // Number of agents that can use this token (default: 1)
Metadata map[string]interface{} `json:"metadata"`
}
@@ -47,19 +50,24 @@ func NewRegistrationTokenQueries(db *sqlx.DB) *RegistrationTokenQueries {
return &RegistrationTokenQueries{db: db}
}
// CreateRegistrationToken creates a new one-time use registration token
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, metadata map[string]interface{}) error {
// CreateRegistrationToken creates a new registration token with seat tracking
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}) error {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
// Ensure maxSeats is at least 1
if maxSeats < 1 {
maxSeats = 1
}
query := `
INSERT INTO registration_tokens (token, label, expires_at, metadata)
VALUES ($1, $2, $3, $4)
INSERT INTO registration_tokens (token, label, expires_at, max_seats, metadata)
VALUES ($1, $2, $3, $4, $5)
`
_, err = q.db.Exec(query, token, label, expiresAt, metadataJSON)
_, err = q.db.Exec(query, token, label, expiresAt, maxSeats, metadataJSON)
if err != nil {
return fmt.Errorf("failed to create registration token: %w", err)
}
@@ -67,20 +75,21 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
return nil
}
// ValidateRegistrationToken checks if a token is valid and unused
// ValidateRegistrationToken checks if a token is valid and has available seats
func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*RegistrationToken, error) {
var regToken RegistrationToken
query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
revoked, revoked_at, revoked_reason, status, created_by, metadata
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used
FROM registration_tokens
WHERE token = $1 AND status = 'active' AND expires_at > NOW()
WHERE token = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
`
err := q.db.Get(&regToken, query, token)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("invalid or expired token")
return nil, fmt.Errorf("invalid, expired, or seats full")
}
return nil, fmt.Errorf("failed to validate token: %w", err)
}
@@ -89,27 +98,19 @@ func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*Reg
}
// MarkTokenUsed marks a token as used by an agent
// With seat tracking, this increments seats_used and only marks status='used' when all seats are taken
func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID) error {
query := `
UPDATE registration_tokens
SET status = 'used',
used_at = NOW(),
used_by_agent_id = $1
WHERE token = $2 AND status = 'active' AND expires_at > NOW()
`
// Call the PostgreSQL function that handles seat tracking logic
query := `SELECT mark_registration_token_used($1, $2)`
result, err := q.db.Exec(query, agentID, token)
var success bool
err := q.db.QueryRow(query, token, agentID).Scan(&success)
if err != nil {
return fmt.Errorf("failed to mark token as used: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("token not found or already used")
if !success {
return fmt.Errorf("token not found, already used, expired, or seats full")
}
return nil
@@ -120,7 +121,8 @@ func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]Registration
var tokens []RegistrationToken
query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
revoked, revoked_at, revoked_reason, status, created_by, metadata
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used
FROM registration_tokens
WHERE status = 'active'
ORDER BY created_at DESC
@@ -139,7 +141,8 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
var tokens []RegistrationToken
query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
revoked, revoked_at, revoked_reason, status, created_by, metadata
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used
FROM registration_tokens
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
@@ -153,7 +156,7 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
return tokens, nil
}
// RevokeRegistrationToken revokes a token
// RevokeRegistrationToken revokes a token (can revoke tokens in any status)
func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string) error {
query := `
UPDATE registration_tokens
@@ -161,7 +164,7 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
revoked = true,
revoked_at = NOW(),
revoked_reason = $1
WHERE token = $2 AND status = 'active'
WHERE token = $2
`
result, err := q.db.Exec(query, reason, token)
@@ -175,7 +178,28 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
}
if rowsAffected == 0 {
return fmt.Errorf("token not found or already used/revoked")
return fmt.Errorf("token not found")
}
return nil
}
// DeleteRegistrationToken permanently deletes a token from the database
func (q *RegistrationTokenQueries) DeleteRegistrationToken(tokenID uuid.UUID) error {
query := `DELETE FROM registration_tokens WHERE id = $1`
result, err := q.db.Exec(query, tokenID)
if err != nil {
return fmt.Errorf("failed to delete token: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("token not found")
}
return nil

View File

@@ -0,0 +1,123 @@
package queries
import (
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
type UserQueries struct {
db *sqlx.DB
}
func NewUserQueries(db *sqlx.DB) *UserQueries {
return &UserQueries{db: db}
}
// CreateUser inserts a new user into the database with password hashing
func (q *UserQueries) CreateUser(username, email, password, role string) (*models.User, error) {
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &models.User{
ID: uuid.New(),
Username: username,
Email: email,
PasswordHash: string(hashedPassword),
Role: role,
CreatedAt: time.Now().UTC(),
}
query := `
INSERT INTO users (
id, username, email, password_hash, role, created_at
) VALUES (
:id, :username, :email, :password_hash, :role, :created_at
)
RETURNING *
`
rows, err := q.db.NamedQuery(query, user)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
if err := rows.StructScan(user); err != nil {
return nil, err
}
return user, nil
}
return nil, nil
}
// GetUserByUsername retrieves a user by username
func (q *UserQueries) GetUserByUsername(username string) (*models.User, error) {
var user models.User
query := `SELECT * FROM users WHERE username = $1`
err := q.db.Get(&user, query, username)
if err != nil {
return nil, err
}
return &user, nil
}
// VerifyCredentials checks if the provided username and password are valid
func (q *UserQueries) VerifyCredentials(username, password string) (*models.User, error) {
user, err := q.GetUserByUsername(username)
if err != nil {
return nil, err
}
// Compare the provided password with the stored hash
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
if err != nil {
return nil, err // Invalid password
}
// Update last login time
q.UpdateLastLogin(user.ID)
// Don't return password hash
user.PasswordHash = ""
return user, nil
}
// UpdateLastLogin updates the user's last login timestamp
func (q *UserQueries) UpdateLastLogin(id uuid.UUID) error {
query := `UPDATE users SET last_login = $1 WHERE id = $2`
_, err := q.db.Exec(query, time.Now().UTC(), id)
return err
}
// GetUserByID retrieves a user by ID
func (q *UserQueries) GetUserByID(id uuid.UUID) (*models.User, error) {
var user models.User
query := `SELECT id, username, email, role, created_at, last_login FROM users WHERE id = $1`
err := q.db.Get(&user, query, id)
if err != nil {
return nil, err
}
return &user, nil
}
// EnsureAdminUser creates an admin user if one doesn't exist
func (q *UserQueries) EnsureAdminUser(username, email, password string) error {
// Check if admin user already exists
existingUser, err := q.GetUserByUsername(username)
if err == nil && existingUser != nil {
return nil // Admin user already exists
}
// Create admin user
_, err = q.CreateUser(username, email, password, "admin")
return err
}

View File

@@ -68,6 +68,7 @@ type AgentRegistrationRequest struct {
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version" binding:"required"`
RegistrationToken string `json:"registration_token"` // Optional, for fallback method
Metadata map[string]string `json:"metadata"`
}

View File

@@ -0,0 +1,22 @@
package models
import (
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"` // Don't include in JSON
Role string `json:"role" db:"role"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
LastLogin *time.Time `json:"last_login" db:"last_login"`
}
type UserCredentials struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

View File

@@ -12,8 +12,8 @@ RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Build the application (skip TypeScript type checking)
RUN npx vite build
# Production stage
FROM nginx:alpine

View File

@@ -15,7 +15,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -26,7 +26,7 @@ server {
location /health {
proxy_pass http://server:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
}
}

View File

@@ -12,6 +12,7 @@ import History from '@/pages/History';
import Settings from '@/pages/Settings';
import TokenManagement from '@/pages/TokenManagement';
import RateLimiting from '@/pages/RateLimiting';
import AgentManagement from '@/pages/settings/AgentManagement';
import Login from '@/pages/Login';
import Setup from '@/pages/Setup';
import { WelcomeChecker } from '@/components/WelcomeChecker';
@@ -108,6 +109,7 @@ const App: React.FC = () => {
<Route path="/settings" element={<Settings />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>

View File

@@ -16,20 +16,9 @@ export const useHeartbeatStatus = (agentId: string, enabled: boolean = true): Us
queryKey: ['heartbeat', agentId],
queryFn: () => agentApi.getHeartbeatStatus(agentId),
enabled: enabled && !!agentId,
staleTime: 5000, // Consider data stale after 5 seconds
refetchInterval: (query) => {
// Smart polling: only poll when heartbeat is active
const data = query.state.data as HeartbeatStatus | undefined;
// If heartbeat is enabled and still active, poll every 5 seconds
if (data?.enabled && data?.active) {
return 5000; // 5 seconds
}
// If heartbeat is not active, don't poll
return false;
},
refetchOnWindowFocus: false, // Don't refresh when window gains focus
staleTime: 0, // Always consider data stale to force refetch
refetchInterval: 5000, // Poll every 5 seconds regardless of state
refetchOnWindowFocus: true, // Refresh when window gains focus
refetchOnMount: true, // Always refetch when component mounts
});
};

View File

@@ -85,6 +85,24 @@ export const useRevokeRegistrationToken = () => {
});
};
export const useDeleteRegistrationToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.tokens.deleteToken(id),
onSuccess: (_, tokenId) => {
toast.success('Registration token deleted successfully');
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.detail(tokenId) });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
},
onError: (error: any) => {
console.error('Failed to delete registration token:', error);
toast.error(error.response?.data?.message || 'Failed to delete registration token');
},
});
};
export const useCleanupRegistrationTokens = () => {
const queryClient = useQueryClient();

View File

@@ -24,8 +24,8 @@ import {
RateLimitSummary
} from '@/types';
// Base URL for API
export const API_BASE_URL = (import.meta.env?.VITE_API_URL as string) || '/api/v1';
// Base URL for API - use nginx proxy
export const API_BASE_URL = '/api/v1';
// Create axios instance
const api = axios.create({
@@ -237,8 +237,8 @@ export const logApi = {
};
export const authApi = {
// Simple login (using API key or token)
login: async (credentials: { token: string }): Promise<{ token: string }> => {
// Login with username and password
login: async (credentials: { username: string; password: string }): Promise<{ token: string; user: any }> => {
const response = await api.post('/auth/login', credentials);
return response.data;
},
@@ -255,9 +255,9 @@ export const authApi = {
},
};
// Setup API for server configuration (uses base API without auth)
// Setup API for server configuration (uses nginx proxy)
const setupApiInstance = axios.create({
baseURL: API_BASE_URL,
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
@@ -283,8 +283,8 @@ export const setupApi = {
serverHost: string;
serverPort: string;
maxSeats: string;
}): Promise<{ message: string; configPath?: string; restart?: boolean }> => {
const response = await setupApiInstance.post('/setup', config);
}): Promise<{ message: string; jwtSecret?: string; envContent?: string; manualRestartRequired?: boolean; manualRestartCommand?: string; configFilePath?: string }> => {
const response = await setupApiInstance.post('/setup/configure', config);
return response.data;
},
};
@@ -456,11 +456,16 @@ export const adminApi = {
return response.data;
},
// Revoke registration token
// Revoke registration token (soft delete)
revokeToken: async (id: string): Promise<void> => {
await api.delete(`/admin/registration-tokens/${id}`);
},
// Delete registration token (hard delete)
deleteToken: async (id: string): Promise<void> => {
await api.delete(`/admin/registration-tokens/delete/${id}`);
},
// Get registration token statistics
getStats: async (): Promise<RegistrationTokenStats> => {
const response = await api.get('/admin/registration-tokens/stats');
@@ -479,7 +484,17 @@ export const adminApi = {
// Get all rate limit configurations
getConfigs: async (): Promise<RateLimitConfig[]> => {
const response = await api.get('/admin/rate-limits');
return response.data;
// Backend returns { settings: {...}, updated_at: "..." }
// Transform settings object to array format expected by frontend
const settings = response.data.settings || {};
const configs: RateLimitConfig[] = Object.entries(settings).map(([endpoint, config]: [string, any]) => ({
...config,
endpoint,
updated_at: response.data.updated_at, // Preserve update timestamp
}));
return configs;
},
// Update rate limit configuration

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff, Shield } from 'lucide-react';
import { Eye, EyeOff, Shield, User } from 'lucide-react';
import { useAuthStore } from '@/lib/store';
import { authApi } from '@/lib/api';
import { handleApiError } from '@/lib/api';
@@ -9,24 +9,31 @@ import toast from 'react-hot-toast';
const Login: React.FC = () => {
const navigate = useNavigate();
const { setToken } = useAuthStore();
const [token, setTokenInput] = useState('');
const [showToken, setShowToken] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
toast.error('Please enter your authentication token');
if (!username.trim()) {
toast.error('Please enter your username');
return;
}
if (!password.trim()) {
toast.error('Please enter your password');
return;
}
setIsLoading(true);
try {
const response = await authApi.login({ token: token.trim() });
const response = await authApi.login({ username: username.trim(), password: password.trim() });
setToken(response.token);
localStorage.setItem('auth_token', response.token);
toast.success('Login successful');
localStorage.setItem('user', JSON.stringify(response.user));
toast.success(`Welcome back, ${response.user.username}!`);
navigate('/');
} catch (error) {
const apiError = handleApiError(error);
@@ -48,7 +55,7 @@ const Login: React.FC = () => {
Sign in to RedFlag
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter your authentication token to access the dashboard
Enter your username and password to access the dashboard
</p>
</div>
@@ -56,25 +63,45 @@ const Login: React.FC = () => {
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="token" className="block text-sm font-medium text-gray-700">
Authentication Token
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Enter your username"
required
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
id="token"
type={showToken ? 'text' : 'password'}
value={token}
onChange={(e) => setTokenInput(e.target.value)}
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Enter your JWT token"
placeholder="Enter your password"
required
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowToken(!showToken)}
onClick={() => setShowPassword(!showPassword)}
>
{showToken ? (
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
@@ -106,11 +133,11 @@ const Login: React.FC = () => {
<div className="flex items-start space-x-2">
<Shield className="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium">How to get your token:</p>
<p className="font-medium">Login credentials:</p>
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
<li>Check your RedFlag server configuration</li>
<li>Look for the JWT secret in your server settings</li>
<li>Generate a token using the server CLI</li>
<li>Use the admin username you configured during server setup</li>
<li>Enter the password you set during server configuration</li>
<li>If you forgot your credentials, check your server configuration</li>
<li>Contact your administrator if you need access</li>
</ul>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Shield,
RefreshCw,
@@ -25,7 +26,24 @@ import {
} from '../hooks/useRateLimits';
import { RateLimitConfig, RateLimitStats, RateLimitUsage } from '@/types';
// Helper function to format date/time strings
const formatDateTime = (dateString: string): string => {
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch (error) {
return dateString;
}
};
const RateLimiting: React.FC = () => {
const navigate = useNavigate();
const [editingMode, setEditingMode] = useState(false);
const [editingConfigs, setEditingConfigs] = useState<RateLimitConfig[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -46,14 +64,14 @@ const RateLimiting: React.FC = () => {
const cleanupLimits = useCleanupRateLimits();
React.useEffect(() => {
if (configs) {
if (configs && Array.isArray(configs)) {
setEditingConfigs([...configs]);
}
}, [configs]);
// Filtered configurations for display
const filteredConfigs = useMemo(() => {
if (!configs) return [];
if (!configs || !Array.isArray(configs)) return [];
return configs.filter((config) => {
const matchesSearch = searchTerm === '' ||
@@ -122,6 +140,13 @@ const RateLimiting: React.FC = () => {
return (
<div className="max-w-7xl mx-auto px-6 py-8">
<button
onClick={() => navigate('/settings')}
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
>
Back to Settings
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
@@ -234,7 +259,9 @@ const RateLimiting: React.FC = () => {
</button>
<button
onClick={() => {
setEditingConfigs([...configs!]);
if (configs && Array.isArray(configs)) {
setEditingConfigs([...configs]);
}
setEditingMode(false);
}}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
@@ -489,7 +516,7 @@ const RateLimiting: React.FC = () => {
</div>
{/* Rate Limit Statistics */}
{stats && stats.length > 0 && (
{stats && Array.isArray(stats) && stats.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Rate Limit Statistics</h2>
@@ -526,7 +553,7 @@ const RateLimiting: React.FC = () => {
</div>
</div>
{stat.top_clients && stat.top_clients.length > 0 && (
{stat.top_clients && Array.isArray(stat.top_clients) && stat.top_clients.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-200">
<p className="text-xs text-gray-600 mb-2">Top Clients:</p>
<div className="space-y-1">
@@ -547,7 +574,7 @@ const RateLimiting: React.FC = () => {
)}
{/* Usage Monitoring */}
{usage && usage.length > 0 && (
{usage && Array.isArray(usage) && usage.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Usage Monitoring</h2>

View File

@@ -83,11 +83,11 @@ const Settings: React.FC = () => {
},
{
title: 'Agent Management',
description: 'Agent defaults and cleanup policies',
description: 'Deploy and configure agents across platforms',
icon: SettingsIcon,
href: '/settings/agents',
stats: null,
status: 'not-implemented'
status: 'implemented'
}
];
@@ -134,14 +134,17 @@ const Settings: React.FC = () => {
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
</div>
<div className="p-6 bg-gray-50 border border-gray-200 rounded-lg opacity-60">
<Link
to="/settings/agents"
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<SettingsIcon className="w-8 h-8 text-gray-400" />
<ArrowRight className="w-5 h-5 text-gray-300" />
</div>
<h3 className="font-semibold text-gray-500">Agent Management</h3>
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
<SettingsIcon className="w-8 h-8 text-purple-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<h3 className="font-semibold text-gray-900">Agent Management</h3>
<p className="text-sm text-gray-600 mt-1">Deploy and configure agents</p>
</Link>
</div>
{/* Overview Statistics */}
@@ -326,7 +329,6 @@ const Settings: React.FC = () => {
<h3 className="font-medium text-yellow-800 mb-3">🚧 Planned Features</h3>
<ul className="space-y-1 text-sm text-yellow-700">
<li> System configuration management</li>
<li> Agent management settings</li>
<li> Integration with third-party services</li>
<li> Persistent settings storage</li>
</ul>

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { XCircle } from 'lucide-react';
import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { setupApi } from '@/lib/api';
@@ -17,11 +17,15 @@ interface SetupFormData {
maxSeats: string;
}
const Setup: React.FC = () => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [jwtSecret, setJwtSecret] = 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 [formData, setFormData] = useState<SetupFormData>({
adminUser: 'admin',
@@ -108,20 +112,12 @@ const Setup: React.FC = () => {
try {
const result = await setupApi.configure(formData);
// Store JWT secret, env content and show success screen
setJwtSecret(result.jwtSecret || null);
setEnvContent(result.envContent || null);
setShowSuccess(true);
toast.success(result.message || 'Configuration saved successfully!');
if (result.restart) {
// Server is restarting, wait for it to come back online
setTimeout(() => {
navigate('/login');
}, 5000); // Give server time to restart
} else {
// No restart, redirect immediately
setTimeout(() => {
navigate('/login');
}, 2000);
}
} catch (error: any) {
console.error('Setup error:', error);
const errorMessage = error.response?.data?.error || error.message || 'Setup failed';
@@ -132,38 +128,177 @@ const Setup: React.FC = () => {
}
};
// Success screen with credentials display
if (showSuccess && jwtSecret) {
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="py-8">
<h2 className="text-2xl font-bold text-gray-900">Server Setup</h2>
<p className="mt-1 text-sm text-gray-600">
Configure your update management server
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-center mb-4">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 text-center mb-2">
Configuration Complete!
</h1>
<p className="text-gray-600 text-center">
Your RedFlag server is ready to use
</p>
</div>
<div className="bg-white shadow rounded-lg">
<form onSubmit={handleSubmit} className="divide-y divide-gray-200">
{/* Error Display */}
{error && (
<div className="px-6 py-4 bg-red-50">
<div className="flex">
<div className="flex-shrink-0">
<XCircle className="h-5 w-5 text-red-400" />
{/* Success Card */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
{/* Admin Credentials Section */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Administrator Credentials</h3>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<div className="grid grid-cols-1 gap-3">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Username</label>
<div className="mt-1 p-2 bg-white border border-gray-300 rounded text-sm font-mono">{formData.adminUser}</div>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">{error}</h3>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Password</label>
<div className="mt-1 p-2 bg-white border border-gray-300 rounded text-sm font-mono"></div>
</div>
</div>
</div>
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md">
<p className="text-sm text-green-800">
<strong>Important:</strong> Save these credentials securely. You'll use them to login to the RedFlag dashboard.
</p>
</div>
</div>
{/* Configuration Content Section */}
{envContent && (
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Configuration File Content</h3>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<textarea
readOnly
value={envContent}
className="w-full h-64 p-3 text-xs font-mono text-gray-800 bg-white border border-gray-300 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(envContent);
toast.success('Configuration content copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Copy Configuration Content
</button>
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
<strong>Important:</strong> Copy this configuration content and save it to <code className="bg-blue-100 px-1 rounded">./config/.env</code>, then run <code className="bg-blue-100 px-1 rounded">docker-compose down && docker-compose up -d</code> to apply the configuration.
</p>
</div>
</div>
)}
{/* Admin Account */}
<div className="px-6 py-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Admin Account</h3>
{/* JWT Secret Section (Server Configuration) */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Server JWT Secret</h3>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<code className="text-sm text-gray-800 break-all font-mono">{jwtSecret}</code>
</div>
<div className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-md">
<p className="text-sm text-gray-700">
<strong>For your information:</strong> This JWT secret is used internally by the server for session management and agent authentication. It's automatically included in the configuration file above.
</p>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(jwtSecret);
toast.success('JWT secret copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
Copy JWT Secret (Optional)
</button>
</div>
{/* Next Steps */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Next Steps</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>Copy the configuration content using the green button above</li>
<li>Save it to <code className="bg-gray-100 px-1 rounded">./config/.env</code></li>
<li>Run <code className="bg-gray-100 px-1 rounded">docker-compose down && docker-compose up -d</code></li>
<li>Login to the dashboard with your admin username and password</li>
</ol>
</div>
<div className="mt-6 pt-6 border-t border-gray-200 space-y-3">
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> You must restart the containers to apply the configuration before logging in.
</p>
</div>
<button
onClick={() => {
toast.success('Please run: docker-compose down && docker-compose up -d');
}}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500"
>
Show Restart Command
</button>
<button
onClick={() => {
setTimeout(() => navigate('/login'), 500);
}}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Continue to Login (After Restart)
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-center mb-4">
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center">
<span className="text-2xl">🚩</span>
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 text-center mb-2">
Configure RedFlag Server
</h1>
<p className="text-gray-600 text-center">
Set up your update management server configuration
</p>
</div>
{/* Setup Form */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<form onSubmit={handleSubmit} className="space-y-8">
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-800">{error}</div>
</div>
)}
{/* Administrator Account */}
<div>
<div className="flex items-center mb-4">
<User className="h-5 w-5 text-indigo-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Administrator Account</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700">
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700 mb-1">
Admin Username
</label>
<input
@@ -172,33 +307,51 @@ const Setup: React.FC = () => {
name="adminUser"
value={formData.adminUser}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="admin"
required
/>
</div>
<div>
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700">
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700 mb-1">
Admin Password
</label>
<div className="relative">
<input
type="password"
type={showPassword ? 'text' : 'password'}
id="adminPassword"
name="adminPassword"
value={formData.adminPassword}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter secure password"
required
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
</div>
</div>
</div>
{/* Database Configuration */}
<div className="px-6 py-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Database Configuration</h3>
<div>
<div className="flex items-center mb-4">
<Database className="h-5 w-5 text-indigo-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Database Configuration</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700 mb-1">
Database Host
</label>
<input
@@ -207,12 +360,13 @@ const Setup: React.FC = () => {
name="dbHost"
value={formData.dbHost}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="postgres"
required
/>
</div>
<div>
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700 mb-1">
Database Port
</label>
<input
@@ -221,12 +375,12 @@ const Setup: React.FC = () => {
name="dbPort"
value={formData.dbPort}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
required
/>
</div>
<div>
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700 mb-1">
Database Name
</label>
<input
@@ -235,12 +389,13 @@ const Setup: React.FC = () => {
name="dbName"
value={formData.dbName}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="redflag"
required
/>
</div>
<div>
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700 mb-1">
Database User
</label>
<input
@@ -249,33 +404,51 @@ const Setup: React.FC = () => {
name="dbUser"
value={formData.dbUser}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="redflag"
required
/>
</div>
<div>
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700">
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700 mb-1">
Database Password
</label>
<div className="relative">
<input
type="password"
type={showDbPassword ? 'text' : 'password'}
id="dbPassword"
name="dbPassword"
value={formData.dbPassword}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter database password"
required
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowDbPassword(!showDbPassword)}
>
{showDbPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
</div>
</div>
</div>
{/* Server Configuration */}
<div className="px-6 py-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Server Configuration</h3>
<div>
<div className="flex items-center mb-4">
<Settings className="h-5 w-5 text-indigo-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Server Configuration</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700">
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700 mb-1">
Server Host
</label>
<input
@@ -284,12 +457,13 @@ const Setup: React.FC = () => {
name="serverHost"
value={formData.serverHost}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="0.0.0.0"
required
/>
</div>
<div>
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700">
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700 mb-1">
Server Port
</label>
<input
@@ -298,12 +472,13 @@ const Setup: React.FC = () => {
name="serverPort"
value={formData.serverPort}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="8080"
required
/>
</div>
<div>
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700">
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700 mb-1">
Maximum Agent Seats
</label>
<input
@@ -312,9 +487,10 @@ const Setup: React.FC = () => {
name="maxSeats"
value={formData.maxSeats}
onChange={handleInputChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
min="1"
max="1000"
placeholder="50"
required
/>
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
@@ -323,19 +499,22 @@ const Setup: React.FC = () => {
</div>
{/* Submit Button */}
<div className="px-6 py-4 bg-gray-50">
<div className="pt-6 border-t border-gray-200">
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Configuring...
Configuring RedFlag Server...
</div>
) : (
'Configure Server'
<div className="flex items-center">
<Shield className="w-4 h-4 mr-2" />
Configure RedFlag Server
</div>
)}
</button>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Shield,
Plus,
@@ -19,6 +20,7 @@ import {
useRegistrationTokens,
useCreateRegistrationToken,
useRevokeRegistrationToken,
useDeleteRegistrationToken,
useRegistrationTokenStats,
useCleanupRegistrationTokens
} from '../hooks/useRegistrationTokens';
@@ -26,6 +28,8 @@ import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types';
import { formatDateTime } from '@/lib/utils';
const TokenManagement: React.FC = () => {
const navigate = useNavigate();
// Filters and search
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
@@ -47,6 +51,7 @@ const TokenManagement: React.FC = () => {
const { data: stats, isLoading: isLoadingStats } = useRegistrationTokenStats();
const createToken = useCreateRegistrationToken();
const revokeToken = useRevokeRegistrationToken();
const deleteToken = useDeleteRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens();
// Reset page when filters change
@@ -57,15 +62,15 @@ const TokenManagement: React.FC = () => {
// Form state
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '',
max_seats: 10,
expires_at: '',
expires_in: '168h', // Default 7 days
max_seats: 1, // Default 1 seat
});
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: () => {
setFormData({ label: '', max_seats: 10, expires_at: '' });
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreateForm(false);
refetch();
},
@@ -78,44 +83,65 @@ const TokenManagement: React.FC = () => {
}
};
const handleDeleteToken = (tokenId: string, tokenLabel: string) => {
if (confirm(`⚠️ PERMANENTLY DELETE token "${tokenLabel}"? This cannot be undone!`)) {
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
}
};
const handleCleanup = () => {
if (confirm('Clean up all expired tokens? This cannot be undone.')) {
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
}
};
const getServerUrl = () => {
return `${window.location.protocol}//${window.location.host}`;
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
// Show success feedback
};
const copyInstallCommand = async (token: string) => {
const command = `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
const serverUrl = getServerUrl();
const command = `curl -sfL ${serverUrl}/api/v1/install/linux | bash -s -- ${token}`;
await navigator.clipboard.writeText(command);
};
const generateInstallCommand = (token: string) => {
return `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
const serverUrl = getServerUrl();
return `curl -sfL ${serverUrl}/api/v1/install/linux | bash -s -- ${token}`;
};
const getStatusColor = (token: RegistrationToken) => {
if (!token.is_active) return 'text-gray-500';
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'text-red-600';
if (token.max_seats && token.current_seats >= token.max_seats) return 'text-yellow-600';
return 'text-green-600';
if (token.status === 'revoked') return 'text-gray-500';
if (token.status === 'expired') return 'text-red-600';
if (token.status === 'used') return 'text-yellow-600';
if (token.status === 'active') return 'text-green-600';
return 'text-gray-500';
};
const getStatusText = (token: RegistrationToken) => {
if (!token.is_active) return 'Revoked';
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'Expired';
if (token.max_seats && token.current_seats >= token.max_seats) return 'Full';
return 'Active';
if (token.status === 'revoked') return 'Revoked';
if (token.status === 'expired') return 'Expired';
if (token.status === 'used') return 'Used';
if (token.status === 'active') return 'Active';
return token.status.charAt(0).toUpperCase() + token.status.slice(1);
};
const filteredTokens = tokensData?.tokens || [];
return (
<div className="max-w-7xl mx-auto px-6 py-8">
<button
onClick={() => navigate('/settings')}
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
>
Back to Settings
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
@@ -220,32 +246,37 @@ const TokenManagement: React.FC = () => {
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Team"
placeholder="e.g., Production Servers, Development Team"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
<select
value={formData.expires_in}
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="24h">24 hours</option>
<option value="72h">3 days</option>
<option value="168h">7 days (1 week)</option>
</select>
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</label>
<input
type="number"
min="1"
value={formData.max_seats}
onChange={(e) => setFormData({ ...formData, max_seats: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="Leave empty for unlimited"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Expiration Date</label>
<input
type="datetime-local"
value={formData.expires_at}
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
min={new Date().toISOString().slice(0, 16)}
max="100"
value={formData.max_seats || 1}
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
placeholder="1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">Number of agents that can use this token</p>
</div>
</div>
@@ -403,27 +434,24 @@ const TokenManagement: React.FC = () => {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{token.current_seats}
{token.max_seats && ` / ${token.max_seats}`}
</div>
{token.max_seats && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${Math.min((token.current_seats / token.max_seats) * 100, 100)}%` }}
></div>
</div>
<div className="text-sm text-gray-500">
{token.seats_used}/{token.max_seats} used
{token.seats_used >= token.max_seats && (
<span className="ml-2 text-xs text-red-600">(Full)</span>
)}
{token.seats_used < token.max_seats && token.status === 'active' && (
<span className="ml-2 text-xs text-green-600">({token.max_seats - token.seats_used} available)</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.expires_at) || 'Never'}
{formatDateTime(token.expires_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.last_used_at) || 'Never'}
{token.used_at ? formatDateTime(token.used_at) : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
@@ -441,16 +469,24 @@ const TokenManagement: React.FC = () => {
>
<Download className="w-4 h-4" />
</button>
{token.is_active && (
{token.status === 'active' && (
<button
onClick={() => handleRevokeToken(token.id, token.label)}
onClick={() => handleRevokeToken(token.id, token.label || 'this token')}
disabled={revokeToken.isPending}
className="text-orange-600 hover:text-orange-800 disabled:opacity-50"
title="Revoke token (soft delete)"
>
<AlertTriangle className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDeleteToken(token.id, token.label || 'this token')}
disabled={deleteToken.isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
title="Revoke token"
title="Permanently delete token"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>

View File

@@ -0,0 +1,491 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Download,
Terminal,
Copy,
Check,
Shield,
Server,
Monitor,
AlertTriangle,
ExternalLink,
RefreshCw,
Code,
FileText,
Package
} from 'lucide-react';
import { useRegistrationTokens } from '@/hooks/useRegistrationTokens';
import { toast } from 'react-hot-toast';
const AgentManagement: React.FC = () => {
const navigate = useNavigate();
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const [selectedPlatform, setSelectedPlatform] = useState<string>('linux');
const { data: tokens, isLoading: tokensLoading } = useRegistrationTokens({ is_active: true });
const platforms = [
{
id: 'linux',
name: 'Linux',
icon: Server,
description: 'For Ubuntu, Debian, RHEL, CentOS, AlmaLinux, Rocky Linux',
downloadUrl: '/api/v1/downloads/linux-amd64',
installScript: '/api/v1/install/linux',
extensions: ['amd64'],
color: 'orange'
},
{
id: 'windows',
name: 'Windows',
icon: Monitor,
description: 'For Windows 10/11, Windows Server 2019/2022',
downloadUrl: '/api/v1/downloads/windows-amd64',
installScript: '/api/v1/install/windows',
extensions: ['amd64'],
color: 'blue'
}
];
const getServerUrl = () => {
return `${window.location.protocol}//${window.location.host}`;
};
const getActiveToken = () => {
// Defensive null checking to prevent crashes
if (!tokens || !tokens.tokens || !Array.isArray(tokens.tokens) || tokens.tokens.length === 0) {
return 'YOUR_REGISTRATION_TOKEN';
}
return tokens.tokens[0]?.token || 'YOUR_REGISTRATION_TOKEN';
};
const generateInstallCommand = (platform: typeof platforms[0]) => {
const serverUrl = getServerUrl();
const token = getActiveToken();
if (platform.id === 'linux') {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `curl -sfL ${serverUrl}${platform.installScript} | sudo bash -s -- ${token}`;
} else {
return `curl -sfL ${serverUrl}${platform.installScript} | sudo bash`;
}
} else if (platform.id === 'windows') {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `iwr ${serverUrl}${platform.installScript} -OutFile install.bat; .\\install.bat ${token}`;
} else {
return `iwr ${serverUrl}${platform.installScript} -OutFile install.bat; .\\install.bat`;
}
}
return '';
};
const generateManualCommand = (platform: typeof platforms[0]) => {
const serverUrl = getServerUrl();
const token = getActiveToken();
if (platform.id === 'windows') {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `# Download and run as Administrator with token\niwr ${serverUrl}${platform.installScript} -OutFile install.bat\n.\\install.bat ${token}`;
} else {
return `# Download and run as Administrator\niwr ${serverUrl}${platform.installScript} -OutFile install.bat\n.\\install.bat`;
}
} else {
if (token !== 'YOUR_REGISTRATION_TOKEN') {
return `# Download and run as root with token\ncurl -sfL ${serverUrl}${platform.installScript} | sudo bash -s -- ${token}`;
} else {
return `# Download and run as root\ncurl -sfL ${serverUrl}${platform.installScript} | sudo bash`;
}
}
};
const copyToClipboard = async (text: string, commandId: string) => {
try {
if (!text || text.trim() === '') {
toast.error('No command to copy');
return;
}
await navigator.clipboard.writeText(text);
setCopiedCommand(commandId);
toast.success('Command copied to clipboard!');
setTimeout(() => setCopiedCommand(null), 2000);
} catch (error) {
console.error('Copy failed:', error);
toast.error('Failed to copy command. Please copy manually.');
}
};
const handleDownload = (platform: typeof platforms[0]) => {
const link = document.createElement('a');
link.href = `${getServerUrl()}${platform.downloadUrl}`;
link.download = `redflag-agent-${platform.id}-amd64${platform.id === 'windows' ? '.exe' : ''}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(`Download started for ${platform.name} agent`);
};
const selectedPlatformData = platforms.find(p => p.id === selectedPlatform);
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<button
onClick={() => navigate('/settings')}
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
>
Back to Settings
</button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agent Management</h1>
<p className="mt-2 text-gray-600">Deploy and configure RedFlag agents across your infrastructure</p>
</div>
<Link
to="/settings/tokens"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Shield className="w-4 h-4" />
Manage Tokens
</Link>
</div>
</div>
{/* Token Status */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<div className="flex items-start gap-4">
<Shield className="w-6 h-6 text-blue-600 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-blue-900 mb-2">Registration Token Required</h3>
<p className="text-blue-700 mb-4">
Agents need a registration token to enroll with the server. You have {tokens?.tokens?.length || 0} active token(s).
</p>
{!tokens?.tokens || tokens.tokens.length === 0 ? (
<Link
to="/settings/tokens"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Shield className="w-4 h-4" />
Generate Registration Token
</Link>
) : (
<div className="flex items-center gap-4">
<div>
<p className="text-sm text-blue-600 font-medium">Active Token:</p>
<code className="text-xs bg-blue-100 px-2 py-1 rounded">{tokens?.tokens?.[0]?.token || 'N/A'}</code>
</div>
<Link
to="/settings/tokens"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
View all tokens
</Link>
</div>
)}
</div>
</div>
</div>
{/* Platform Selection */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6">1. Select Target Platform</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{platforms.map((platform) => {
const Icon = platform.icon;
return (
<button
key={platform.id}
onClick={() => setSelectedPlatform(platform.id)}
className={`p-6 border-2 rounded-lg transition-all ${
selectedPlatform === platform.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-4">
<Icon className={`w-8 h-8 ${
platform.id === 'linux' ? 'text-orange-600' :
platform.id === 'windows' ? 'text-blue-600' : 'text-gray-600'
}`} />
{selectedPlatform === platform.id && (
<Check className="w-5 h-5 text-blue-600" />
)}
</div>
<h3 className="font-semibold text-gray-900 mb-2">{platform.name}</h3>
<p className="text-sm text-gray-600">{platform.description}</p>
</button>
);
})}
</div>
</div>
{/* Installation Methods */}
{selectedPlatformData && (
<div className="space-y-8">
{/* One-Liner Installation */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">2. One-Liner Installation (Recommended)</h2>
<p className="text-gray-600 mt-1">
Automatically downloads and configures the agent for {selectedPlatformData.name}
</p>
</div>
<Terminal className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Installation Command {selectedPlatformData.id === 'windows' && <span className="text-blue-600">(Run in PowerShell as Administrator)</span>}
</label>
<div className="relative">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{generateInstallCommand(selectedPlatformData)}</code>
</pre>
<button
onClick={() => copyToClipboard(generateInstallCommand(selectedPlatformData), 'one-liner')}
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600 transition-colors"
>
{copiedCommand === 'one-liner' ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<h4 className="font-medium text-yellow-900">Before Running</h4>
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
{selectedPlatformData.id === 'windows' ? (
<>
<li> Open <strong>PowerShell as Administrator</strong></li>
<li> The script will download and install the agent to <code className="bg-yellow-100 px-1 rounded">%ProgramFiles%\RedFlag</code></li>
<li> A Windows service will be created and started automatically</li>
<li> Script is idempotent - safe to re-run for upgrades</li>
</>
) : (
<>
<li> Run this command as <strong>root</strong> (use sudo)</li>
<li> The script will create a dedicated <code className="bg-yellow-100 px-1 rounded">redflag-agent</code> user</li>
<li> Limited sudo access will be configured via <code className="bg-yellow-100 px-1 rounded">/etc/sudoers.d/redflag-agent</code></li>
<li> Systemd service will be installed and enabled automatically</li>
<li> Script is idempotent - safe to re-run for upgrades</li>
</>
)}
</ul>
</div>
</div>
</div>
</div>
</div>
{/* Security Information */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">3. Security Information</h2>
<p className="text-gray-600 mt-1">
Understanding the security model and installation details
</p>
</div>
<Shield className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-6">
<div>
<h4 className="font-medium text-gray-900 mb-3">🛡 Security Model</h4>
<p className="text-sm text-gray-600 mb-4">
The installation script follows the principle of least privilege by creating a dedicated system user with minimal permissions:
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>System User:</strong> <code className="bg-blue-100 px-1 rounded">redflag-agent</code> with no login shell</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Sudo Access:</strong> Limited to package management commands only</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Systemd Service:</strong> Runs with security hardening (ProtectSystem, ProtectHome)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
<span className="text-sm text-blue-800"><strong>Configuration:</strong> Secured in <code className="bg-blue-100 px-1 rounded">/etc/aggregator/config.json</code> with restricted permissions</span>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">📁 Installation Files</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 space-y-1">
{`Binary: /usr/local/bin/redflag-agent
Config: /etc/aggregator/config.json
Service: /etc/systemd/system/redflag-agent.service
Sudoers: /etc/sudoers.d/redflag-agent
Home Dir: /var/lib/redflag-agent
Logs: journalctl -u redflag-agent`}
</pre>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3"> Sudoers Configuration</h4>
<p className="text-sm text-gray-600 mb-2">
The agent gets sudo access only for these specific commands:
</p>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-xs text-gray-700 overflow-x-auto">
{`# APT (Debian/Ubuntu)
/usr/bin/apt-get update
/usr/bin/apt-get install -y *
/usr/bin/apt-get upgrade -y *
/usr/bin/apt-get install --dry-run --yes *
# DNF (RHEL/Fedora/Rocky/Alma)
/usr/bin/dnf makecache
/usr/bin/dnf install -y *
/usr/bin/dnf upgrade -y *
/usr/bin/dnf install --assumeno --downloadonly *
# Docker
/usr/bin/docker pull *
/usr/bin/docker image inspect *
/usr/bin/docker manifest inspect *`}
</pre>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">🔄 Updates and Upgrades</h4>
<p className="text-sm text-gray-600">
The installation script is <strong>idempotent</strong> - it's safe to run multiple times.
RedFlag agents update themselves automatically when new versions are released.
If you need to manually reinstall or upgrade, simply run the same one-liner command.
</p>
</div>
</div>
</div>
{/* Advanced Configuration */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">4. Advanced Configuration</h2>
<p className="text-gray-600 mt-1">
Additional agent configuration options
</p>
</div>
<Code className="w-6 h-6 text-gray-400" />
</div>
<div className="space-y-6">
{/* Configuration Options */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Command Line Options</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700">
{`./redflag-agent [options]
Options:
--server <url> Server URL (default: http://localhost:8080)
--token <token> Registration token
--proxy-http <url> HTTP proxy URL
--proxy-https <url> HTTPS proxy URL
--log-level <level> Log level (debug, info, warn, error)
--organization <name> Organization name
--tags <tags> Comma-separated tags
--name <display> Display name for the agent
--insecure-tls Skip TLS certificate verification`}
</pre>
</div>
</div>
{/* Environment Variables */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Environment Variables</h4>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700">
{`REDFLAG_SERVER_URL="https://your-server.com"
REDFLAG_REGISTRATION_TOKEN="your-token-here"
REDFLAG_HTTP_PROXY="http://proxy.company.com:8080"
REDFLAG_HTTPS_PROXY="https://proxy.company.com:8080"
REDFLAG_NO_PROXY="localhost,127.0.0.1"
REDFLAG_LOG_LEVEL="info"
REDFLAG_ORGANIZATION="IT Department"`}
</pre>
</div>
</div>
{/* Configuration File */}
<div>
<h4 className="font-medium text-gray-900 mb-3">Configuration File</h4>
<p className="text-sm text-gray-600 mb-3">
After installation, the agent configuration is stored at <code>/etc/aggregator/config.json</code> (Linux) or
<code>%ProgramData%\RedFlag\config.json</code> (Windows):
</p>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 overflow-x-auto">
{`{
"server_url": "https://your-server.com",
"registration_token": "your-token-here",
"proxy": {
"enabled": true,
"http": "http://proxy.company.com:8080",
"https": "https://proxy.company.com:8080",
"no_proxy": "localhost,127.0.0.1"
},
"network": {
"timeout": "30s",
"retry_count": 3,
"retry_delay": "5s"
},
"tls": {
"insecure_skip_verify": false
},
"logging": {
"level": "info",
"max_size": 100,
"max_backups": 3
},
"tags": ["production", "webserver"],
"organization": "IT Department",
"display_name": "Web Server 01"
}`}
</pre>
</div>
</div>
</div>
</div>
{/* Next Steps */}
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-start gap-4">
<Check className="w-6 h-6 text-green-600 mt-1" />
<div>
<h3 className="font-semibold text-green-900 mb-2">Next Steps</h3>
<ol className="text-sm text-green-800 space-y-2">
<li>1. Deploy agents to your target machines using the methods above</li>
<li>2. Monitor agent registration in the <Link to="/agents" className="underline">Agents dashboard</Link></li>
<li>3. Configure update policies and scanning schedules</li>
<li>4. Review agent status and system information</li>
</ol>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AgentManagement;

View File

@@ -292,20 +292,24 @@ export interface ApiError {
export interface RegistrationToken {
id: string;
token: string;
label: string;
expires_at: string | null;
max_seats: number | null;
current_seats: number;
is_active: boolean;
label: string | null;
expires_at: string;
created_at: string;
updated_at: string;
last_used_at: string | null;
used_at: string | null;
used_by_agent_id: string | null;
revoked: boolean;
revoked_at: string | null;
revoked_reason: string | null;
status: 'active' | 'used' | 'expired' | 'revoked';
created_by: string;
metadata: Record<string, any>;
max_seats: number;
seats_used: number;
}
export interface CreateRegistrationTokenRequest {
label: string;
expires_at?: string;
expires_in?: string;
max_seats?: number;
metadata?: Record<string, any>;
}

View File

@@ -5,9 +5,11 @@ services:
environment:
POSTGRES_DB: redflag
POSTGRES_USER: redflag
POSTGRES_PASSWORD: redflag
POSTGRES_PASSWORD: redflag_bootstrap
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./config/.env:/shared/.env
ports:
- "5432:5432"
healthcheck:
@@ -16,31 +18,27 @@ services:
timeout: 5s
retries: 5
restart: unless-stopped
env_file:
- ./config/.env
server:
build:
context: ./aggregator-server
dockerfile: Dockerfile
context: .
dockerfile: ./aggregator-server/Dockerfile
container_name: redflag-server
volumes:
- ./aggregator-agent/redflag-agent:/app/redflag-agent:ro
- server-config:/app/config
- server-data:/app/data
- ./config/.env:/shared/.env
depends_on:
postgres:
condition: service_healthy
ports:
- "8080:8080"
environment:
- REDFLAG_DB_HOST=postgres
- REDFLAG_DB_PORT=5432
- REDFLAG_DB_NAME=redflag
- REDFLAG_DB_USER=redflag
- REDFLAG_DB_PASSWORD=redflag
- REDFLAG_SERVER_HOST=0.0.0.0
- REDFLAG_SERVER_PORT=8080
command: ["./redflag-server"]
restart: unless-stopped
env_file:
- ./config/.env
web:
build:

BIN
test-agent Normal file

Binary file not shown.