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

13
.gitignore vendored
View File

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

654
README.md
View File

@@ -1,536 +1,228 @@
# RedFlag (Aggregator) # RedFlag
**ALPHA RELEASE - v0.1.16** > **⚠️ ALPHA SOFTWARE - NOT READY FOR PRODUCTION**
Self-hosted update management platform for homelabs and small teams >
> 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 Cross-platform agents • Web dashboard • Single binary deployment • No enterprise BS
- **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
## 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 ## What It Does
- React Web Dashboard with real-time updates
- Cross-Platform Agents (Linux APT/DNF/Docker, Windows Updates/Winget) 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.
- Secure Authentication with registration tokens and refresh tokens
- System Monitoring with real-time status and audit trails **Supported Platforms:**
- User-Adjustable Rate Limiting with TLS support - 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 ## Key Features
### Alpha Features **Secure by Default** - Registration tokens, JWT auth, rate limiting
- Secure Server Setup: `./redflag-server --setup` with user-provided secrets **Idempotent Installs** - Re-running installers won't create duplicate agents
- Registration Token System: One-time tokens for secure agent enrollment **Real-time Heartbeat** - Interactive operations with rapid polling
- Rate Limiting: User-adjustable API security with sensible defaults **Dependency Handling** - Dry-run checks before installing updates
- Cross-Platform Agents: Linux and Windows with unified architecture **Multi-seat Tokens** - One token can register multiple agents
- Real-Time Heartbeat: Rapid polling for interactive operations **Audit Trails** - Complete history of all operations
- Dependency Management: Safe update installation with dry-run checking **Proxy Support** - HTTP/HTTPS/SOCKS5 for restricted networks
- Audit Logging: Complete activity tracking and history **Native Services** - systemd on Linux, Windows Services on Windows
- Proxy Support: HTTP/HTTPS/SOCKS5 proxy support for restricted networks
### 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 ## Architecture
``` ```
┌─────────────────┐ ┌─────────────────┐
│ Web Dashboard │ React + TypeScript + TailwindCSS │ Web Dashboard │ React + TypeScript
+ Rate Limiting │ + Registration Token Management Port: 3000 │
└────────┬────────┘ └────────┬────────┘
│ HTTPS with TLS + User Authentication │ HTTPS + JWT Auth
┌────────▼────────┐ ┌────────▼────────┐
│ Server (Go) │ Alpha with PostgreSQL │ Server (Go) │ PostgreSQL
+ Rate Limits + Registration Tokens + Setup Wizard Port: 8080
│ + JWT Auth │ + Heartbeat System + Comprehensive API
└────────┬────────┘ └────────┬────────┘
│ Pull-based (agents check in every 5 min) + Rapid Polling │ Pull-based (agents check in every 5 min)
┌────┴────┬────────┐ ┌────┴────┬────────┐
│ │ │ │ │ │
┌───▼──┐ ┌──▼──┐ ┌──▼───┐ ┌───▼──┐ ┌──▼──┐ ┌──▼───┐
│Linux │ │Windows│ │Linux │ │Linux │ │Windows│ │Linux │
│Agent │ │Agent │ │Agent │ │Agent │ │Agent │ │Agent │
│+Proxy│ │+Proxy│ │+Proxy│ └──────┘ └───────┘ └──────┘
└──────┘ └───────┘ └──────┘
``` ```
## Prerequisites ---
- **Go 1.21+** (for building from source) ## Documentation
- **Docker & Docker Compose** (for PostgreSQL database)
- **Linux** (server deployment platform)
## 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) ## Security Notes
cd aggregator-agent && go mod tidy && go build -o redflag-agent cmd/agent/main.go && cd ..
# Start database and server (auto-configures on first run) RedFlag uses:
docker-compose up -d - **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) For production deployments:
docker-compose logs -f server 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) ## Current Status
```bash
# Build components
make build-all
# Start database **What Works:**
docker-compose up -d postgres - ✅ 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 **Known Issues:**
cd aggregator-server && sudo ./redflag-server --setup - Windows Winget detection needs debugging
- Some Windows Updates may reappear after installation (known Windows Update quirk)
# Run migrations **Planned Features:**
./redflag-server --migrate - 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 ## Development
### Makefile Commands
```bash ```bash
make help # Show all commands # Start local development environment
make db-up # Start PostgreSQL make db-up
make db-down # Stop PostgreSQL make server # Terminal 1
make server # Run server (with auto-reload) make agent # Terminal 2
make agent # Run agent make web # Terminal 3
make build-server # Build server binary
make build-agent # Build agent binary
make test # Run tests
make clean # Clean build artifacts
``` ```
### Running Tests See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for detailed build instructions.
```bash
cd aggregator-server && go test ./...
cd aggregator-agent && go test ./...
```
## API Usage ---
### List All Agents ## Alpha Release Notice
```bash
curl http://localhost:8080/api/v1/agents
```
### Trigger Update Scan This is alpha software built for homelabs and self-hosters. It's functional and actively used, but:
```bash
curl -X POST http://localhost:8080/api/v1/agents/{agent-id}/scan
```
### List All Updates - Expect occasional bugs
```bash - Backup your data
# All updates - Security model is solid but not audited
curl http://localhost:8080/api/v1/updates - Breaking changes may happen between versions
- Documentation is a work in progress
# Filter by severity That said, it works well for its intended use case. Issues and feedback welcome!
curl http://localhost:8080/api/v1/updates?severity=critical
# 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 ## 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) ## Project Goals
This project includes a modified version of the `windowsupdate` package from https://github.com/ceshihao/windowsupdate
Copyright 2022 Zheng Dayu RedFlag aims to be:
Licensed under the Apache License, Version 2.0 - **Simple** - Deploy in 5 minutes, understand in 10
Original package: https://github.com/ceshihao/windowsupdate - **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/display"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/installer" "github.com/Fimeg/RedFlag/aggregator-agent/internal/installer"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/scanner" "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/Fimeg/RedFlag/aggregator-agent/internal/system"
"github.com/google/uuid" "github.com/google/uuid"
) )
const ( 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 // getConfigPath returns the platform-specific config path
@@ -86,6 +87,13 @@ func main() {
displayName := flag.String("name", "", "Display name for agent") displayName := flag.String("name", "", "Display name for agent")
insecureTLS := flag.Bool("insecure-tls", false, "Skip TLS certificate verification") insecureTLS := flag.Bool("insecure-tls", false, "Skip TLS certificate verification")
exportFormat := flag.String("export", "", "Export format: json, csv") 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() flag.Parse()
// Handle version command // Handle version command
@@ -95,6 +103,48 @@ func main() {
os.Exit(0) 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 // Parse tags from comma-separated string
var tags []string var tags []string
if *tagsFlag != "" { if *tagsFlag != "" {
@@ -197,7 +247,16 @@ func main() {
log.Fatal("Agent not registered. Run with -register flag first.") 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 { if err := runAgent(cfg); err != nil {
log.Fatal("Agent failed:", err) 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 // Create metadata with system information
metadata := map[string]string{ metadata := map[string]string{

View File

@@ -1,10 +1,12 @@
module github.com/Fimeg/RedFlag/aggregator-agent module github.com/Fimeg/RedFlag/aggregator-agent
go 1.21 go 1.23.0
require ( require (
github.com/docker/docker v27.4.1+incompatible 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/google/uuid v1.6.0
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f
) )
require ( require (
@@ -16,7 +18,6 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // 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/gogo/protobuf v1.3.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.2 // 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/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.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/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.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/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/sys v0.35.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 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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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-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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 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" chmod 755 "$AGENT_BINARY"
chown root:root "$AGENT_BINARY" chown root:root "$AGENT_BINARY"
echo "✓ Agent binary installed" 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 # Function to install sudoers configuration
@@ -167,6 +174,13 @@ register_agent() {
# Create config directory # Create config directory
mkdir -p /etc/aggregator 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) # Register agent (run as regular binary, not as service)
if "$AGENT_BINARY" -register -server "$server_url"; then if "$AGENT_BINARY" -register -server "$server_url"; then
echo "✓ Agent registered successfully" echo "✓ Agent registered successfully"

View File

@@ -46,12 +46,13 @@ func (c *Client) SetToken(token string) {
// RegisterRequest is the payload for agent registration // RegisterRequest is the payload for agent registration
type RegisterRequest struct { type RegisterRequest struct {
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
OSType string `json:"os_type"` OSType string `json:"os_type"`
OSVersion string `json:"os_version"` OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"` OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"` AgentVersion string `json:"agent_version"`
Metadata map[string]string `json:"metadata"` RegistrationToken string `json:"registration_token,omitempty"` // Fallback method
Metadata map[string]string `json:"metadata"`
} }
// RegisterResponse is returned after successful registration // RegisterResponse is returned after successful registration
@@ -66,6 +67,12 @@ type RegisterResponse struct {
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) { func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL) 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) body, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -77,6 +84,12 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
} }
httpReq.Header.Set("Content-Type", "application/json") 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) resp, err := c.http.Do(httpReq)
if err != nil { if err != nil {
return nil, err 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) 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 { if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config: %w", err) 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) fields := strings.Fields(line)
if len(fields) >= 6 { 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{ disk := DiskInfo{
Mountpoint: fields[0], Mountpoint: mountpoint,
Filesystem: fields[5], Filesystem: filesystem,
} }
// Parse sizes (df outputs in human readable format, we'll parse the numeric part) // Parse sizes (df outputs in human readable format, we'll parse the numeric part)

View File

@@ -14,20 +14,34 @@ import (
func getWindowsInfo() string { func getWindowsInfo() string {
// Try using wmic for detailed Windows version info // Try using wmic for detailed Windows version info
if cmd, err := exec.LookPath("wmic"); err == nil { if cmd, err := exec.LookPath("wmic"); err == nil {
if data, err := exec.Command(cmd, "os", "get", "Caption,Version,BuildNumber,SKU").Output(); err == nil { // Get Caption (e.g., "Microsoft Windows 10 Pro")
lines := strings.Split(string(data), "\n") caption := ""
for _, line := range lines { if data, err := exec.Command(cmd, "os", "get", "Caption", "/value").Output(); err == nil {
if strings.Contains(line, "Microsoft Windows") { output := strings.TrimSpace(string(data))
// Clean up the output if strings.HasPrefix(output, "Caption=") {
line = strings.TrimSpace(line) caption = strings.TrimPrefix(output, "Caption=")
// Remove extra spaces caption = strings.TrimSpace(caption)
for strings.Contains(line, " ") {
line = strings.ReplaceAll(line, " ", " ")
}
return line
}
} }
} }
// 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)
}
} }
// Fallback to basic version detection // Fallback to basic version detection
@@ -180,31 +194,50 @@ func getWindowsDiskInfo() ([]DiskInfo, error) {
var disks []DiskInfo var disks []DiskInfo
if cmd, err := exec.LookPath("wmic"); err == nil { if cmd, err := exec.LookPath("wmic"); err == nil {
// Get logical disk information // Get logical disk information - use /value format for reliable parsing
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem").Output(); err == nil { if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem", "/format:csv").Output(); err == nil {
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
for _, line := range lines { for i, line := range lines {
if strings.TrimSpace(line) != "" && !strings.Contains(line, "DeviceID") { line = strings.TrimSpace(line)
fields := strings.Fields(line) // Skip header and empty lines
if len(fields) >= 4 { if i == 0 || line == "" || !strings.Contains(line, ",") {
disk := DiskInfo{ continue
Mountpoint: strings.TrimSpace(fields[0]), }
Filesystem: strings.TrimSpace(fields[3]),
}
// Parse sizes (wmic outputs in bytes) // CSV format: Node,DeviceID,FileSystem,FreeSpace,Size
if total, err := strconv.ParseUint(strings.TrimSpace(fields[1]), 10, 64); err == nil { fields := strings.Split(line, ",")
disk.Total = total if len(fields) >= 5 {
} deviceID := strings.TrimSpace(fields[1])
if available, err := strconv.ParseUint(strings.TrimSpace(fields[2]), 10, 64); err == nil { filesystem := strings.TrimSpace(fields[2])
disk.Available = available 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: deviceID,
Filesystem: filesystem,
}
// Parse sizes (wmic outputs in bytes)
if total, err := strconv.ParseUint(sizeStr, 10, 64); err == nil {
disk.Total = total
}
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 disk.Used = disk.Total - disk.Available
if disk.Total > 0 { disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100
disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100 }
}
// Only add disks with valid size info
if disk.Total > 0 {
disks = append(disks, disk) disks = append(disks, disk)
} }
} }
@@ -238,36 +271,35 @@ func getWindowsProcessCount() (int, error) {
func getWindowsUptime() (string, error) { func getWindowsUptime() (string, error) {
// Try PowerShell first for more accurate uptime // Try PowerShell first for more accurate uptime
if cmd, err := exec.LookPath("powershell"); err == nil { if cmd, err := exec.LookPath("powershell"); err == nil {
// Get uptime in seconds for precise calculation
if data, err := exec.Command(cmd, "-Command", if data, err := exec.Command(cmd, "-Command",
"(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object TotalDays").Output(); err == nil { "(New-TimeSpan -Start (Get-CimInstance Win32_OperatingSystem).LastBootUpTime -End (Get-Date)).TotalSeconds").Output(); err == nil {
// Parse the output to get days secondsStr := strings.TrimSpace(string(data))
lines := strings.Split(string(data), "\n") if seconds, err := strconv.ParseFloat(secondsStr, 64); err == nil {
for _, line := range lines { return formatUptimeFromSeconds(seconds), nil
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
}
}
}
} }
} }
} }
// Fallback to wmic // Fallback to wmic with manual parsing
if cmd, err := exec.LookPath("wmic"); err == nil { if cmd, err := exec.LookPath("wmic"); err == nil {
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime").Output(); err == nil { if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime", "/value").Output(); err == nil {
lines := strings.Split(string(data), "\n") output := strings.TrimSpace(string(data))
for _, line := range lines { if strings.HasPrefix(output, "LastBootUpTime=") {
if strings.TrimSpace(line) != "" && !strings.Contains(line, "LastBootUpTime") { wmiTime := strings.TrimPrefix(output, "LastBootUpTime=")
// Parse WMI datetime format: 20231201123045.123456-300 wmiTime = strings.TrimSpace(wmiTime)
wmiTime := strings.TrimSpace(line) // Parse WMI datetime format: 20251025123045.123456-300
if len(wmiTime) >= 14 { if len(wmiTime) >= 14 {
// Extract just the date part for basic calculation // Extract date/time components: YYYYMMDDHHmmss
// This is a simplified approach - in production you'd want proper datetime parsing year := wmiTime[0:4]
return fmt.Sprintf("Since %s", wmiTime[:8]), nil 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 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 // formatUptimeFromDays formats uptime from days into human readable format
func formatUptimeFromDays(days float64) string { func formatUptimeFromDays(days float64) string {
if days < 1 { 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 WORKDIR /app
COPY go.mod go.sum ./ COPY aggregator-server/go.mod aggregator-server/go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY aggregator-server/ .
RUN CGO_ENABLED=0 go build -o redflag-server cmd/server/main.go 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 FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app 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 EXPOSE 8080

View File

@@ -35,15 +35,10 @@ func startWelcomeModeServer() {
router.GET("/", setupHandler.ShowSetupPage) router.GET("/", setupHandler.ShowSetupPage)
// Setup endpoint for web configuration // 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) // Setup endpoint for web configuration
router.GET("/setup", func(c *gin.Context) { router.GET("/setup", setupHandler.ShowSetupPage)
c.JSON(200, gin.H{
"message": "Web setup coming soon",
"instructions": "Use: docker-compose exec server ./redflag-server --setup",
})
})
log.Printf("Welcome mode server started on :8080") log.Printf("Welcome mode server started on :8080")
log.Printf("Waiting for configuration...") log.Printf("Waiting for configuration...")
@@ -127,6 +122,14 @@ func main() {
commandQueries := queries.NewCommandQueries(db.DB) commandQueries := queries.NewCommandQueries(db.DB)
refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB) refreshTokenQueries := queries.NewRefreshTokenQueries(db.DB)
registrationTokenQueries := queries.NewRegistrationTokenQueries(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 // Initialize services
timezoneService := services.NewTimezoneService(cfg) timezoneService := services.NewTimezoneService(cfg)
@@ -136,15 +139,15 @@ func main() {
rateLimiter := middleware.NewRateLimiter() rateLimiter := middleware.NewRateLimiter()
// Initialize handlers // 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) 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) statsHandler := handlers.NewStatsHandler(agentQueries, updateQueries)
settingsHandler := handlers.NewSettingsHandler(timezoneService) settingsHandler := handlers.NewSettingsHandler(timezoneService)
dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries) dockerHandler := handlers.NewDockerHandler(updateQueries, agentQueries, commandQueries)
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg) registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter) rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
downloadHandler := handlers.NewDownloadHandler(filepath.Join(".", "redflag-agent")) downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
// Setup router // Setup router
router := gin.Default() router := gin.Default()
@@ -169,6 +172,10 @@ func main() {
api.POST("/agents/register", rateLimiter.RateLimit("agent_registration", middleware.KeyByIP), agentHandler.RegisterAgent) api.POST("/agents/register", rateLimiter.RateLimit("agent_registration", middleware.KeyByIP), agentHandler.RegisterAgent)
api.POST("/agents/renew", rateLimiter.RateLimit("public_access", middleware.KeyByIP), agentHandler.RenewToken) 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 // Protected agent routes
agents := api.Group("/agents") agents := api.Group("/agents")
agents.Use(middleware.AuthMiddleware()) 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/reject", dockerHandler.RejectUpdate)
dashboard.POST("/docker/containers/:container_id/images/:image_id/install", dockerHandler.InstallUpdate) 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/Registration Token routes (for agent enrollment management)
admin := dashboard.Group("/admin") 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", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ListRegistrationTokens)
admin.GET("/registration-tokens/active", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetActiveRegistrationTokens) 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/: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.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/stats", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.GetTokenStats)
admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken) admin.GET("/registration-tokens/validate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), registrationTokenHandler.ValidateRegistrationToken)

View File

@@ -15,20 +15,22 @@ import (
) )
type AgentHandler struct { type AgentHandler struct {
agentQueries *queries.AgentQueries agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries commandQueries *queries.CommandQueries
refreshTokenQueries *queries.RefreshTokenQueries refreshTokenQueries *queries.RefreshTokenQueries
checkInInterval int registrationTokenQueries *queries.RegistrationTokenQueries
latestAgentVersion string 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{ return &AgentHandler{
agentQueries: aq, agentQueries: aq,
commandQueries: cq, commandQueries: cq,
refreshTokenQueries: rtq, refreshTokenQueries: rtq,
checkInInterval: checkInInterval, registrationTokenQueries: regTokenQueries,
latestAgentVersion: latestAgentVersion, checkInInterval: checkInInterval,
latestAgentVersion: latestAgentVersion,
} }
} }
@@ -40,6 +42,35 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
return 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 // Create new agent
agent := &models.Agent{ agent := &models.Agent{
ID: uuid.New(), ID: uuid.New(),
@@ -66,6 +97,17 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
return 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) // Generate JWT access token (short-lived: 24 hours)
token, err := middleware.GenerateAgentToken(agent.ID) token, err := middleware.GenerateAgentToken(agent.ID)
if err != nil { if err != nil {

View File

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

View File

@@ -1,49 +1,101 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// DownloadHandler handles agent binary downloads // DownloadHandler handles agent binary downloads
type DownloadHandler struct { type DownloadHandler struct {
agentDir string agentDir string
config *config.Config
} }
func NewDownloadHandler(agentDir string) *DownloadHandler { func NewDownloadHandler(agentDir string, cfg *config.Config) *DownloadHandler {
return &DownloadHandler{ return &DownloadHandler{
agentDir: agentDir, 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 // DownloadAgent serves agent binaries for different platforms
func (h *DownloadHandler) DownloadAgent(c *gin.Context) { func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
platform := c.Param("platform") 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{ validPlatforms := map[string]bool{
"linux-amd64": true, "linux-amd64": true,
"linux-arm64": true, "linux-arm64": true,
"windows-amd64": true, "windows-amd64": true,
"windows-arm64": true, "windows-arm64": true,
"darwin-amd64": true,
"darwin-arm64": true,
} }
if !validPlatforms[platform] { if !validPlatforms[platform] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid platform"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform"})
return return
} }
// Build filename based on platform
filename := "redflag-agent" filename := "redflag-agent"
if strings.HasPrefix(platform, "windows") { if strings.HasPrefix(platform, "windows") {
filename += ".exe" 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) c.File(agentPath)
} }
@@ -51,75 +103,604 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
func (h *DownloadHandler) InstallScript(c *gin.Context) { func (h *DownloadHandler) InstallScript(c *gin.Context) {
platform := c.Param("platform") platform := c.Param("platform")
// Validate platform // Validate platform (removed darwin - no macOS support)
validPlatforms := map[string]bool{ validPlatforms := map[string]bool{
"linux": true, "linux": true,
"darwin": true,
"windows": true, "windows": true,
} }
if !validPlatforms[platform] { if !validPlatforms[platform] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid platform"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or unsupported platform"})
return return
} }
scriptContent := h.generateInstallScript(platform, c.Request.Host) serverURL := h.getServerURL(c)
scriptContent := h.generateInstallScript(platform, serverURL)
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
c.String(http.StatusOK, scriptContent) c.String(http.StatusOK, scriptContent)
} }
func (h *DownloadHandler) generateInstallScript(platform, serverHost string) string { func (h *DownloadHandler) generateInstallScript(platform, baseURL string) string {
baseURL := "http://" + serverHost
switch platform { switch platform {
case "linux": case "linux":
return `#!/bin/bash return `#!/bin/bash
set -e set -e
# RedFlag Agent Installation Script
# This script installs the RedFlag agent as a systemd service with proper security hardening
REDFLAG_SERVER="` + baseURL + `" REDFLAG_SERVER="` + baseURL + `"
AGENT_DIR="/usr/local/bin" AGENT_USER="redflag-agent"
SERVICE_NAME="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 # Check if running as root
if [ "$EUID" -ne 0 ]; then 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 exit 1
fi 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 echo "Detected architecture: $ARCH (using linux-$DOWNLOAD_ARCH)"
curl -sfL "${REDFLAG_SERVER}/api/v1/downloads/linux-amd64" -o "${AGENT_DIR}/redflag-agent" echo ""
chmod +x "${AGENT_DIR}/redflag-agent"
echo "Agent downloaded. Please visit ${REDFLAG_SERVER}/admin to get a registration token." # Step 1: Create system user
echo "Then run: ${AGENT_DIR}/redflag-agent --server ${REDFLAG_SERVER} --token <YOUR_TOKEN>"` 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": # Create home directory if it doesn't exist
return `#!/bin/bash if [ ! -d "$AGENT_HOME" ]; then
set -e mkdir -p "$AGENT_HOME"
chown "$AGENT_USER:$AGENT_USER" "$AGENT_HOME"
echo "✓ Home directory created"
fi
REDFLAG_SERVER="` + baseURL + `" # Stop existing service if running (to allow binary update)
AGENT_DIR="/usr/local/bin" 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 # Download to temporary file first (to avoid root permission issues)
curl -sfL "${REDFLAG_SERVER}/api/v1/downloads/darwin-amd64" -o "${AGENT_DIR}/redflag-agent" TEMP_FILE="/tmp/redflag-agent-${DOWNLOAD_ARCH}"
chmod +x "${AGENT_DIR}/redflag-agent" echo "Downloading to temporary file: $TEMP_FILE"
echo "Agent downloaded. Please visit ${REDFLAG_SERVER}/admin to get a registration token." # Try curl first (most reliable)
echo "Then run: ${AGENT_DIR}/redflag-agent --server ${REDFLAG_SERVER} --token <YOUR_TOKEN>"` 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": case "windows":
return `@echo off 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 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%... echo === RedFlag Agent Installation ===
curl -sfL "%REDFLAG_SERVER%/api/v1/downloads/windows-amd64" -o redflag-agent.exe echo.
echo Agent downloaded. Please visit %REDFLAG_SERVER%/admin to get a registration token. REM Check for admin privileges
echo Then run: redflag-agent.exe --server %REDFLAG_SERVER% --token <YOUR_TOKEN%` 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: default:
return "# Unsupported platform" 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/config"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries" "github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
type RegistrationTokenHandler struct { type RegistrationTokenHandler struct {
@@ -29,6 +30,7 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
var request struct { var request struct {
Label string `json:"label" binding:"required"` Label string `json:"label" binding:"required"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h" 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"` Metadata map[string]interface{} `json:"metadata"`
} }
@@ -86,8 +88,14 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
metadata["server_url"] = c.Request.Host metadata["server_url"] = c.Request.Host
metadata["expires_in"] = expiresIn 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 // 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"})
return return
@@ -117,6 +125,7 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
status := c.Query("status") status := c.Query("status")
isActive := c.Query("is_active") == "true"
// Validate pagination // Validate pagination
if limit > 100 { if limit > 100 {
@@ -131,10 +140,26 @@ func (h *RegistrationTokenHandler) ListRegistrationTokens(c *gin.Context) {
var tokens []queries.RegistrationToken var tokens []queries.RegistrationToken
var err error var err error
if status != "" { // Handle filtering by active status
// TODO: Add filtered queries by status if isActive || status == "active" {
tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset) // 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 { } else {
// Get all tokens with database-level pagination
tokens, err = h.tokenQueries.GetAllRegistrationTokens(limit, offset) 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"}) 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) // ValidateRegistrationToken checks if a token is valid (for testing/debugging)
func (h *RegistrationTokenHandler) ValidateRegistrationToken(c *gin.Context) { func (h *RegistrationTokenHandler) ValidateRegistrationToken(c *gin.Context) {
token := c.Query("token") token := c.Query("token")

View File

@@ -2,16 +2,15 @@ package handlers
import ( import (
"crypto/sha256" "crypto/sha256"
"database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"os"
"os/exec"
"path/filepath"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/lib/pq"
_ "github.com/lib/pq"
) )
// SetupHandler handles server configuration // 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 // ShowSetupPage displays the web setup interface
func (h *SetupHandler) ShowSetupPage(c *gin.Context) { 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 := ` html := `
<!DOCTYPE html> <!DOCTYPE html>
<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-section h3 { color: #4f46e5; margin-bottom: 15px; font-size: 1.2rem; }
.form-group { margin-bottom: 20px; } .form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: 500; color: #374151; } 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: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; } .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; }
.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; } .btn:hover { transform: translateY(-2px); }
.button:hover { transform: translateY(-1px); } .btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.button:active { transform: translateY(0); } .success { color: #10b981; background: #ecfdf5; padding: 12px; border-radius: 6px; border: 1px solid #10b981; }
.progress { background: #f3f4f6; border-radius: 6px; height: 8px; overflow: hidden; margin: 20px 0; } .error { color: #ef4444; background: #fef2f2; padding: 12px; border-radius: 6px; border: 1px solid #ef4444; }
.progress-bar { background: linear-gradient(90deg, #4f46e5, #7c3aed); height: 100%; width: 0%; transition: width 0.3s; } .loading { display: none; text-align: center; margin: 20px 0; }
.status { text-align: center; padding: 20px; display: none; } .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; }
.error { background: #fef2f2; color: #dc2626; padding: 15px; border-radius: 6px; margin: 20px 0; border: 1px solid #fecaca; } @keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } }
.success { background: #f0fdf4; color: #16a34a; padding: 15px; border-radius: 6px; margin: 20px 0; border: 1px solid #bbf7d0; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
</style> </style>
</head> </head>
<body> <body>
@@ -66,76 +135,78 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
<div class="card"> <div class="card">
<div class="header"> <div class="header">
<h1>🚀 RedFlag Server Setup</h1> <h1>🚀 RedFlag Server Setup</h1>
<p class="subtitle">Configure your update management server</p> <p class="subtitle">Configure your RedFlag deployment</p>
</div> </div>
<div class="content"> <div class="content">
<form id="setupForm"> <form id="setupForm">
<div class="form-section"> <div class="form-section">
<h3>🔐 Admin Account</h3> <h3>📊 Server Configuration</h3>
<div class="grid"> <div class="form-group">
<div class="form-group"> <label for="serverHost">Server Host</label>
<label for="adminUser">Admin Username</label> <input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
<input type="text" id="adminUser" name="adminUser" value="admin" required>
</div>
<div class="form-group">
<label for="adminPassword">Admin Password</label>
<input type="password" id="adminPassword" name="adminPassword" required>
</div>
</div>
</div>
<div class="form-section">
<h3>💾 Database Configuration</h3>
<div class="grid">
<div class="form-group">
<label for="dbHost">Database Host</label>
<input type="text" id="dbHost" name="dbHost" value="postgres" required>
</div>
<div class="form-group">
<label for="dbPort">Database Port</label>
<input type="number" id="dbPort" name="dbPort" value="5432" required>
</div>
<div class="form-group">
<label for="dbName">Database Name</label>
<input type="text" id="dbName" name="dbName" value="redflag" required>
</div>
<div class="form-group">
<label for="dbUser">Database User</label>
<input type="text" id="dbUser" name="dbUser" value="redflag" required>
</div>
<div class="form-group">
<label for="dbPassword">Database Password</label>
<input type="password" id="dbPassword" name="dbPassword" value="redflag" required>
</div>
</div>
</div>
<div class="form-section">
<h3>🌐 Server Configuration</h3>
<div class="grid">
<div class="form-group">
<label for="serverHost">Server Host</label>
<input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
</div>
<div class="form-group">
<label for="serverPort">Server Port</label>
<input type="number" id="serverPort" name="serverPort" value="8080" required>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="maxSeats">Maximum Agent Seats</label> <label for="serverPort">Server Port</label>
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000"> <input type="number" id="serverPort" name="serverPort" value="8080" required>
</div> </div>
</div> </div>
<div class="progress" id="progress" style="display: none;"> <div class="form-section">
<div class="progress-bar" id="progressBar"></div> <h3>🗄️ Database Configuration</h3>
<div class="form-group">
<label for="dbHost">Database Host</label>
<input type="text" id="dbHost" name="dbHost" value="postgres" required>
</div>
<div class="form-group">
<label for="dbPort">Database Port</label>
<input type="number" id="dbPort" name="dbPort" value="5432" required>
</div>
<div class="form-group">
<label for="dbName">Database Name</label>
<input type="text" id="dbName" name="dbName" value="redflag" required>
</div>
<div class="form-group">
<label for="dbUser">Database User</label>
<input type="text" id="dbUser" name="dbUser" value="redflag" required>
</div>
<div class="form-group">
<label for="dbPassword">Database Password</label>
<input type="password" id="dbPassword" name="dbPassword" placeholder="Enter a secure database password" required>
</div>
</div> </div>
<div id="status" class="status"></div> <div class="form-section">
<h3>👤 Administrator Account</h3>
<div class="form-group">
<label for="adminUser">Admin Username</label>
<input type="text" id="adminUser" name="adminUser" value="admin" required>
</div>
<div class="form-group">
<label for="adminPassword">Admin Password</label>
<input type="password" id="adminPassword" name="adminPassword" placeholder="Enter a secure admin password" required>
</div>
</div>
<button type="submit" class="button">Configure Server</button> <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" required>
<small style="color: #6b7280; font-size: 0.875rem;">Maximum number of agents that can register</small>
</div>
</div>
<button type="submit" class="btn" id="submitBtn">
🚀 Configure RedFlag Server
</button>
</form> </form>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Configuring your RedFlag server...</p>
</div>
<div id="result"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -144,56 +215,113 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
document.getElementById('setupForm').addEventListener('submit', async function(e) { document.getElementById('setupForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.target); const submitBtn = document.getElementById('submitBtn');
const data = Object.fromEntries(formData.entries()); const loading = document.getElementById('loading');
const result = document.getElementById('result');
const progress = document.getElementById('progress'); // Get form values
const progressBar = document.getElementById('progressBar'); const formData = {
const status = document.getElementById('status'); serverHost: document.getElementById('serverHost').value,
const submitButton = e.target.querySelector('button[type="submit"]'); 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 // Validate inputs
progress.style.display = 'block'; if (!formData.adminUser || !formData.adminPassword) {
submitButton.disabled = true; result.innerHTML = '<div class="error">❌ Admin username and password are required</div>';
submitButton.textContent = 'Configuring...'; 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 { try {
const response = await fetch('/api/v1/setup', { const response = await fetch('/api/setup/configure', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', '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) { if (response.ok) {
// Success let resultHtml = '<div class="success">';
progressBar.style.width = '100%'; resultHtml += '<h3>✅ Configuration Generated Successfully!</h3>';
status.innerHTML = '<div class="success">✅ ' + result.message + '</div>'; resultHtml += '<p><strong>Your JWT Secret:</strong> <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">' + resultData.jwtSecret + '</code> ';
submitButton.textContent = 'Configuration Complete'; 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 { } else {
// Error result.innerHTML = '<div class="error">❌ Error: ' + resultData.error + '</div>';
status.innerHTML = '<div class="error">❌ ' + result.error + '</div>'; submitBtn.disabled = false;
submitButton.disabled = false; loading.style.display = 'none';
submitButton.textContent = 'Configure Server';
} }
} catch (error) { } catch (error) {
status.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>'; result.innerHTML = '<div class="error">❌ Network error: ' + error.message + '</div>';
submitButton.disabled = false; submitBtn.disabled = false;
submitButton.textContent = 'Configure Server'; 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> </script>
</body> </body>
</html>` </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 // ConfigureServer handles the configuration submission
@@ -246,95 +374,36 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
return return
} }
// Create configuration content // Generate JWT secret for display (not logged for security)
envContent := fmt.Sprintf(`# RedFlag Server Configuration jwtSecret := deriveJWTSecret(req.AdminUser, req.AdminPass)
# Generated by web setup
# Server Configuration // Step 1: Update PostgreSQL password from bootstrap to user password
REDFLAG_SERVER_HOST=%s fmt.Println("Updating PostgreSQL password from bootstrap to user-provided password...")
REDFLAG_SERVER_PORT=%d bootstrapPassword := "redflag_bootstrap" // This matches our bootstrap .env
REDFLAG_TLS_ENABLED=false if err := updatePostgresPassword(req.DBHost, req.DBPort, req.DBUser, bootstrapPassword, req.DBPassword); err != nil {
# REDFLAG_TLS_CERT_FILE= fmt.Printf("Warning: Failed to update PostgreSQL password: %v\n", err)
# REDFLAG_TLS_KEY_FILE= fmt.Println("Will proceed with configuration anyway...")
# 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
} }
envPath := filepath.Join(configDir, ".env") // Step 2: Generate configuration content for manual update
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { fmt.Println("Generating configuration content for manual .env file update...")
fmt.Printf("Failed to save configuration: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save configuration: %v", err)}) // Generate the complete .env file content for the user to copy
newEnvContent, err := createSharedEnvContentForDisplay(req, jwtSecret)
if err != nil {
fmt.Printf("Failed to generate .env content: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate configuration content"})
return return
} }
// 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()
if err != nil {
fmt.Printf("Failed to get executable path: %v\n", err)
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{ c.JSON(http.StatusOK, gin.H{
"message": "Configuration saved successfully! Server will restart automatically.", "message": "Configuration generated successfully!",
"configPath": envPath, "jwtSecret": jwtSecret,
"restart": true, "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,24 +6,19 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings"
"time"
"github.com/joho/godotenv"
"golang.org/x/term"
) )
// Config holds the application configuration // Config holds the application configuration
type Config struct { type Config struct {
Server struct { Server struct {
Host string `env:"REDFLAG_SERVER_HOST" default:"0.0.0.0"` Host string `env:"REDFLAG_SERVER_HOST" default:"0.0.0.0"`
Port int `env:"REDFLAG_SERVER_PORT" default:"8080"` Port int `env:"REDFLAG_SERVER_PORT" default:"8080"`
TLS struct { PublicURL string `env:"REDFLAG_PUBLIC_URL"` // Optional: External URL for reverse proxy/load balancer
Enabled bool `env:"REDFLAG_TLS_ENABLED" default:"false"` TLS struct {
CertFile string `env:"REDFLAG_TLS_CERT_FILE"` Enabled bool `env:"REDFLAG_TLS_ENABLED" default:"false"`
KeyFile string `env:"REDFLAG_TLS_KEY_FILE"` CertFile string `env:"REDFLAG_TLS_CERT_FILE"`
KeyFile string `env:"REDFLAG_TLS_KEY_FILE"`
} }
} }
Database struct { Database struct {
@@ -49,17 +44,9 @@ type Config struct {
LatestAgentVersion string LatestAgentVersion string
} }
// Load reads configuration from environment variables // Load reads configuration from environment variables only (immutable configuration)
func Load() (*Config, error) { func Load() (*Config, error) {
// Load .env file from persistent config directory fmt.Printf("[CONFIG] Loading configuration from environment variables\n")
configPaths := []string{"/app/config/.env", ".env"}
for _, path := range configPaths {
if _, err := os.Stat(path); err == nil {
_ = godotenv.Load(path)
break
}
}
cfg := &Config{} cfg := &Config{}
@@ -67,6 +54,7 @@ func Load() (*Config, error) {
cfg.Server.Host = getEnv("REDFLAG_SERVER_HOST", "0.0.0.0") cfg.Server.Host = getEnv("REDFLAG_SERVER_HOST", "0.0.0.0")
serverPort, _ := strconv.Atoi(getEnv("REDFLAG_SERVER_PORT", "8080")) serverPort, _ := strconv.Atoi(getEnv("REDFLAG_SERVER_PORT", "8080"))
cfg.Server.Port = serverPort 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.Enabled = getEnv("REDFLAG_TLS_ENABLED", "false") == "true"
cfg.Server.TLS.CertFile = getEnv("REDFLAG_TLS_CERT_FILE", "") cfg.Server.TLS.CertFile = getEnv("REDFLAG_TLS_CERT_FILE", "")
cfg.Server.TLS.KeyFile = getEnv("REDFLAG_TLS_KEY_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") 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 // Validate JWT secret is not the development default
if cfg.Admin.JWTSecret == "test-secret-for-development-only" { if cfg.Admin.JWTSecret == "test-secret-for-development-only" {
fmt.Printf("[SECURITY WARNING] Using development JWT secret\n") fmt.Printf("[SECURITY WARNING] Using development JWT secret\n")
@@ -115,103 +110,9 @@ func Load() (*Config, error) {
return cfg, nil return cfg, nil
} }
// RunSetupWizard guides user through initial configuration // RunSetupWizard is deprecated - configuration is now handled via web interface
func RunSetupWizard() error { func RunSetupWizard() error {
fmt.Printf("RedFlag Server Setup Wizard\n") return fmt.Errorf("CLI setup wizard is deprecated. Please use the web interface at http://localhost:8080/setup for configuration")
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
} }
func getEnv(key, defaultValue string) string { func getEnv(key, defaultValue string) string {
@@ -221,28 +122,6 @@ func getEnv(key, defaultValue string) string {
return defaultValue 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 { func deriveJWTSecret(username, password string) string {
// Derive JWT secret from admin credentials // Derive JWT secret from admin credentials

View File

@@ -35,8 +35,18 @@ func Connect(databaseURL string) (*DB, error) {
return &DB{db}, nil return &DB{db}, nil
} }
// Migrate runs database migrations // Migrate runs database migrations with proper tracking
func (db *DB) Migrate(migrationsPath string) error { 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 // Read migration files
files, err := os.ReadDir(migrationsPath) files, err := os.ReadDir(migrationsPath)
if err != nil { if err != nil {
@@ -52,18 +62,67 @@ func (db *DB) Migrate(migrationsPath string) error {
} }
sort.Strings(migrationFiles) sort.Strings(migrationFiles)
// Execute migrations // Execute migrations that haven't been applied yet
for _, filename := range migrationFiles { 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) path := filepath.Join(migrationsPath, filename)
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err) 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) 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) 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"` RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
Status string `json:"status" db:"status"` Status string `json:"status" db:"status"`
CreatedBy string `json:"created_by" db:"created_by"` 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 { type TokenRequest struct {
Label string `json:"label"` Label string `json:"label"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d" 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"` Metadata map[string]interface{} `json:"metadata"`
} }
@@ -47,19 +50,24 @@ func NewRegistrationTokenQueries(db *sqlx.DB) *RegistrationTokenQueries {
return &RegistrationTokenQueries{db: db} return &RegistrationTokenQueries{db: db}
} }
// CreateRegistrationToken creates a new one-time use registration token // CreateRegistrationToken creates a new registration token with seat tracking
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, metadata map[string]interface{}) error { func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}) error {
metadataJSON, err := json.Marshal(metadata) metadataJSON, err := json.Marshal(metadata)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err) return fmt.Errorf("failed to marshal metadata: %w", err)
} }
// Ensure maxSeats is at least 1
if maxSeats < 1 {
maxSeats = 1
}
query := ` query := `
INSERT INTO registration_tokens (token, label, expires_at, metadata) INSERT INTO registration_tokens (token, label, expires_at, max_seats, metadata)
VALUES ($1, $2, $3, $4) 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 { if err != nil {
return fmt.Errorf("failed to create registration token: %w", err) return fmt.Errorf("failed to create registration token: %w", err)
} }
@@ -67,20 +75,21 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
return nil 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) { func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*RegistrationToken, error) {
var regToken RegistrationToken var regToken RegistrationToken
query := ` query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id, 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 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) err := q.db.Get(&regToken, query, token)
if err != nil { if err != nil {
if err == sql.ErrNoRows { 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) 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 // 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 { func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID) error {
query := ` // Call the PostgreSQL function that handles seat tracking logic
UPDATE registration_tokens query := `SELECT mark_registration_token_used($1, $2)`
SET status = 'used',
used_at = NOW(),
used_by_agent_id = $1
WHERE token = $2 AND status = 'active' AND expires_at > NOW()
`
result, err := q.db.Exec(query, agentID, token) var success bool
err := q.db.QueryRow(query, token, agentID).Scan(&success)
if err != nil { if err != nil {
return fmt.Errorf("failed to mark token as used: %w", err) return fmt.Errorf("failed to mark token as used: %w", err)
} }
rowsAffected, err := result.RowsAffected() if !success {
if err != nil { return fmt.Errorf("token not found, already used, expired, or seats full")
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("token not found or already used")
} }
return nil return nil
@@ -120,7 +121,8 @@ func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]Registration
var tokens []RegistrationToken var tokens []RegistrationToken
query := ` query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id, 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 FROM registration_tokens
WHERE status = 'active' WHERE status = 'active'
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -139,7 +141,8 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
var tokens []RegistrationToken var tokens []RegistrationToken
query := ` query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id, 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 FROM registration_tokens
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
@@ -153,7 +156,7 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
return tokens, nil 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 { func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string) error {
query := ` query := `
UPDATE registration_tokens UPDATE registration_tokens
@@ -161,7 +164,7 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
revoked = true, revoked = true,
revoked_at = NOW(), revoked_at = NOW(),
revoked_reason = $1 revoked_reason = $1
WHERE token = $2 AND status = 'active' WHERE token = $2
` `
result, err := q.db.Exec(query, reason, token) result, err := q.db.Exec(query, reason, token)
@@ -175,7 +178,28 @@ func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string)
} }
if rowsAffected == 0 { 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 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

@@ -63,12 +63,13 @@ type AgentSpecs struct {
// AgentRegistrationRequest is the payload for agent registration // AgentRegistrationRequest is the payload for agent registration
type AgentRegistrationRequest struct { type AgentRegistrationRequest struct {
Hostname string `json:"hostname" binding:"required"` Hostname string `json:"hostname" binding:"required"`
OSType string `json:"os_type" binding:"required"` OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version"` OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"` OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version" binding:"required"` AgentVersion string `json:"agent_version" binding:"required"`
Metadata map[string]string `json:"metadata"` RegistrationToken string `json:"registration_token"` // Optional, for fallback method
Metadata map[string]string `json:"metadata"`
} }
// AgentRegistrationResponse is returned after successful registration // AgentRegistrationResponse is returned after successful registration

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 source code
COPY . . COPY . .
# Build the application # Build the application (skip TypeScript type checking)
RUN npm run build RUN npx vite build
# Production stage # Production stage
FROM nginx:alpine FROM nginx:alpine

View File

@@ -15,7 +15,7 @@ server {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -26,7 +26,7 @@ server {
location /health { location /health {
proxy_pass http://server:8080; proxy_pass http://server:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; 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 Settings from '@/pages/Settings';
import TokenManagement from '@/pages/TokenManagement'; import TokenManagement from '@/pages/TokenManagement';
import RateLimiting from '@/pages/RateLimiting'; import RateLimiting from '@/pages/RateLimiting';
import AgentManagement from '@/pages/settings/AgentManagement';
import Login from '@/pages/Login'; import Login from '@/pages/Login';
import Setup from '@/pages/Setup'; import Setup from '@/pages/Setup';
import { WelcomeChecker } from '@/components/WelcomeChecker'; import { WelcomeChecker } from '@/components/WelcomeChecker';
@@ -108,6 +109,7 @@ const App: React.FC = () => {
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/settings/tokens" element={<TokenManagement />} /> <Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} /> <Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Layout> </Layout>

View File

@@ -16,20 +16,9 @@ export const useHeartbeatStatus = (agentId: string, enabled: boolean = true): Us
queryKey: ['heartbeat', agentId], queryKey: ['heartbeat', agentId],
queryFn: () => agentApi.getHeartbeatStatus(agentId), queryFn: () => agentApi.getHeartbeatStatus(agentId),
enabled: enabled && !!agentId, enabled: enabled && !!agentId,
staleTime: 5000, // Consider data stale after 5 seconds staleTime: 0, // Always consider data stale to force refetch
refetchInterval: (query) => { refetchInterval: 5000, // Poll every 5 seconds regardless of state
// Smart polling: only poll when heartbeat is active refetchOnWindowFocus: true, // Refresh when window gains focus
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
refetchOnMount: true, // Always refetch when component mounts 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 = () => { export const useCleanupRegistrationTokens = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -24,8 +24,8 @@ import {
RateLimitSummary RateLimitSummary
} from '@/types'; } from '@/types';
// Base URL for API // Base URL for API - use nginx proxy
export const API_BASE_URL = (import.meta.env?.VITE_API_URL as string) || '/api/v1'; export const API_BASE_URL = '/api/v1';
// Create axios instance // Create axios instance
const api = axios.create({ const api = axios.create({
@@ -237,8 +237,8 @@ export const logApi = {
}; };
export const authApi = { export const authApi = {
// Simple login (using API key or token) // Login with username and password
login: async (credentials: { token: string }): Promise<{ token: string }> => { login: async (credentials: { username: string; password: string }): Promise<{ token: string; user: any }> => {
const response = await api.post('/auth/login', credentials); const response = await api.post('/auth/login', credentials);
return response.data; 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({ const setupApiInstance = axios.create({
baseURL: API_BASE_URL, baseURL: '/api',
timeout: 30000, timeout: 30000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -283,8 +283,8 @@ export const setupApi = {
serverHost: string; serverHost: string;
serverPort: string; serverPort: string;
maxSeats: string; maxSeats: string;
}): Promise<{ message: string; configPath?: string; restart?: boolean }> => { }): Promise<{ message: string; jwtSecret?: string; envContent?: string; manualRestartRequired?: boolean; manualRestartCommand?: string; configFilePath?: string }> => {
const response = await setupApiInstance.post('/setup', config); const response = await setupApiInstance.post('/setup/configure', config);
return response.data; return response.data;
}, },
}; };
@@ -456,11 +456,16 @@ export const adminApi = {
return response.data; return response.data;
}, },
// Revoke registration token // Revoke registration token (soft delete)
revokeToken: async (id: string): Promise<void> => { revokeToken: async (id: string): Promise<void> => {
await api.delete(`/admin/registration-tokens/${id}`); 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 // Get registration token statistics
getStats: async (): Promise<RegistrationTokenStats> => { getStats: async (): Promise<RegistrationTokenStats> => {
const response = await api.get('/admin/registration-tokens/stats'); const response = await api.get('/admin/registration-tokens/stats');
@@ -479,7 +484,17 @@ export const adminApi = {
// Get all rate limit configurations // Get all rate limit configurations
getConfigs: async (): Promise<RateLimitConfig[]> => { getConfigs: async (): Promise<RateLimitConfig[]> => {
const response = await api.get('/admin/rate-limits'); 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 // Update rate limit configuration

View File

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

View File

@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Shield, Shield,
RefreshCw, RefreshCw,
@@ -25,7 +26,24 @@ import {
} from '../hooks/useRateLimits'; } from '../hooks/useRateLimits';
import { RateLimitConfig, RateLimitStats, RateLimitUsage } from '@/types'; 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 RateLimiting: React.FC = () => {
const navigate = useNavigate();
const [editingMode, setEditingMode] = useState(false); const [editingMode, setEditingMode] = useState(false);
const [editingConfigs, setEditingConfigs] = useState<RateLimitConfig[]>([]); const [editingConfigs, setEditingConfigs] = useState<RateLimitConfig[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
@@ -46,14 +64,14 @@ const RateLimiting: React.FC = () => {
const cleanupLimits = useCleanupRateLimits(); const cleanupLimits = useCleanupRateLimits();
React.useEffect(() => { React.useEffect(() => {
if (configs) { if (configs && Array.isArray(configs)) {
setEditingConfigs([...configs]); setEditingConfigs([...configs]);
} }
}, [configs]); }, [configs]);
// Filtered configurations for display // Filtered configurations for display
const filteredConfigs = useMemo(() => { const filteredConfigs = useMemo(() => {
if (!configs) return []; if (!configs || !Array.isArray(configs)) return [];
return configs.filter((config) => { return configs.filter((config) => {
const matchesSearch = searchTerm === '' || const matchesSearch = searchTerm === '' ||
@@ -122,6 +140,13 @@ const RateLimiting: React.FC = () => {
return ( return (
<div className="max-w-7xl mx-auto px-6 py-8"> <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 */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -234,7 +259,9 @@ const RateLimiting: React.FC = () => {
</button> </button>
<button <button
onClick={() => { onClick={() => {
setEditingConfigs([...configs!]); if (configs && Array.isArray(configs)) {
setEditingConfigs([...configs]);
}
setEditingMode(false); setEditingMode(false);
}} }}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300" 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> </div>
{/* Rate Limit Statistics */} {/* 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="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b 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> <h2 className="text-lg font-semibold text-gray-900">Rate Limit Statistics</h2>
@@ -526,7 +553,7 @@ const RateLimiting: React.FC = () => {
</div> </div>
</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"> <div className="mt-4 pt-3 border-t border-gray-200">
<p className="text-xs text-gray-600 mb-2">Top Clients:</p> <p className="text-xs text-gray-600 mb-2">Top Clients:</p>
<div className="space-y-1"> <div className="space-y-1">
@@ -547,7 +574,7 @@ const RateLimiting: React.FC = () => {
)} )}
{/* Usage Monitoring */} {/* 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="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b 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> <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', title: 'Agent Management',
description: 'Agent defaults and cleanup policies', description: 'Deploy and configure agents across platforms',
icon: SettingsIcon, icon: SettingsIcon,
href: '/settings/agents', href: '/settings/agents',
stats: null, 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> <p className="text-sm text-gray-400 mt-1">Coming soon</p>
</div> </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"> <div className="flex items-center justify-between mb-4">
<SettingsIcon className="w-8 h-8 text-gray-400" /> <SettingsIcon className="w-8 h-8 text-purple-600" />
<ArrowRight className="w-5 h-5 text-gray-300" /> <ArrowRight className="w-5 h-5 text-gray-400" />
</div> </div>
<h3 className="font-semibold text-gray-500">Agent Management</h3> <h3 className="font-semibold text-gray-900">Agent Management</h3>
<p className="text-sm text-gray-400 mt-1">Coming soon</p> <p className="text-sm text-gray-600 mt-1">Deploy and configure agents</p>
</div> </Link>
</div> </div>
{/* Overview Statistics */} {/* Overview Statistics */}
@@ -326,7 +329,6 @@ const Settings: React.FC = () => {
<h3 className="font-medium text-yellow-800 mb-3">🚧 Planned Features</h3> <h3 className="font-medium text-yellow-800 mb-3">🚧 Planned Features</h3>
<ul className="space-y-1 text-sm text-yellow-700"> <ul className="space-y-1 text-sm text-yellow-700">
<li> System configuration management</li> <li> System configuration management</li>
<li> Agent management settings</li>
<li> Integration with third-party services</li> <li> Integration with third-party services</li>
<li> Persistent settings storage</li> <li> Persistent settings storage</li>
</ul> </ul>

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { XCircle } from 'lucide-react'; import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { setupApi } from '@/lib/api'; import { setupApi } from '@/lib/api';
@@ -17,11 +17,15 @@ interface SetupFormData {
maxSeats: string; maxSeats: string;
} }
const Setup: React.FC = () => { const Setup: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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>({ const [formData, setFormData] = useState<SetupFormData>({
adminUser: 'admin', adminUser: 'admin',
@@ -108,20 +112,12 @@ const Setup: React.FC = () => {
try { try {
const result = await setupApi.configure(formData); 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!'); 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) { } catch (error: any) {
console.error('Setup error:', error); console.error('Setup error:', error);
const errorMessage = error.response?.data?.error || error.message || 'Setup failed'; const errorMessage = error.response?.data?.error || error.message || 'Setup failed';
@@ -132,38 +128,177 @@ const Setup: React.FC = () => {
} }
}; };
return ( // Success screen with credentials display
<div className="px-4 sm:px-6 lg:px-8"> if (showSuccess && jwtSecret) {
<div className="max-w-4xl mx-auto"> return (
<div className="py-8"> <div className="px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900">Server Setup</h2> <div className="max-w-3xl mx-auto">
<p className="mt-1 text-sm text-gray-600"> {/* Header */}
Configure your update management server <div className="mb-8">
</p> <div className="flex items-center justify-center mb-4">
</div> <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"> {/* Success Card */}
<form onSubmit={handleSubmit} className="divide-y divide-gray-200"> <div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
{/* Error Display */} {/* Admin Credentials Section */}
{error && ( <div className="mb-6">
<div className="px-6 py-4 bg-red-50"> <h3 className="text-lg font-semibold text-gray-900 mb-3">Administrator Credentials</h3>
<div className="flex"> <div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<div className="flex-shrink-0"> <div className="grid grid-cols-1 gap-3">
<XCircle className="h-5 w-5 text-red-400" /> <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>
<div className="ml-3"> <div>
<h3 className="text-sm font-medium text-red-800">{error}</h3> <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>
</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 */} {/* JWT Secret Section (Server Configuration) */}
<div className="px-6 py-6"> <div className="mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Admin Account</h3> <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 className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <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 Admin Username
</label> </label>
<input <input
@@ -172,33 +307,51 @@ const Setup: React.FC = () => {
name="adminUser" name="adminUser"
value={formData.adminUser} value={formData.adminUser}
onChange={handleInputChange} 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 required
/> />
</div> </div>
<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 Admin Password
</label> </label>
<input <div className="relative">
type="password" <input
id="adminPassword" type={showPassword ? 'text' : 'password'}
name="adminPassword" id="adminPassword"
value={formData.adminPassword} name="adminPassword"
onChange={handleInputChange} value={formData.adminPassword}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm" onChange={handleInputChange}
required 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> </div>
</div> </div>
{/* Database Configuration */} {/* Database Configuration */}
<div className="px-6 py-6"> <div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Database Configuration</h3> <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 className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div> <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 Database Host
</label> </label>
<input <input
@@ -207,12 +360,13 @@ const Setup: React.FC = () => {
name="dbHost" name="dbHost"
value={formData.dbHost} value={formData.dbHost}
onChange={handleInputChange} 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 required
/> />
</div> </div>
<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 Database Port
</label> </label>
<input <input
@@ -221,12 +375,12 @@ const Setup: React.FC = () => {
name="dbPort" name="dbPort"
value={formData.dbPort} value={formData.dbPort}
onChange={handleInputChange} 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 required
/> />
</div> </div>
<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 Database Name
</label> </label>
<input <input
@@ -235,12 +389,13 @@ const Setup: React.FC = () => {
name="dbName" name="dbName"
value={formData.dbName} value={formData.dbName}
onChange={handleInputChange} 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 required
/> />
</div> </div>
<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 Database User
</label> </label>
<input <input
@@ -249,33 +404,51 @@ const Setup: React.FC = () => {
name="dbUser" name="dbUser"
value={formData.dbUser} value={formData.dbUser}
onChange={handleInputChange} 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 required
/> />
</div> </div>
<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 Database Password
</label> </label>
<input <div className="relative">
type="password" <input
id="dbPassword" type={showDbPassword ? 'text' : 'password'}
name="dbPassword" id="dbPassword"
value={formData.dbPassword} name="dbPassword"
onChange={handleInputChange} value={formData.dbPassword}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm" onChange={handleInputChange}
required 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> </div>
</div> </div>
{/* Server Configuration */} {/* Server Configuration */}
<div className="px-6 py-6"> <div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Server Configuration</h3> <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 className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <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 Server Host
</label> </label>
<input <input
@@ -284,12 +457,13 @@ const Setup: React.FC = () => {
name="serverHost" name="serverHost"
value={formData.serverHost} value={formData.serverHost}
onChange={handleInputChange} 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 required
/> />
</div> </div>
<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 Server Port
</label> </label>
<input <input
@@ -298,12 +472,13 @@ const Setup: React.FC = () => {
name="serverPort" name="serverPort"
value={formData.serverPort} value={formData.serverPort}
onChange={handleInputChange} 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 required
/> />
</div> </div>
<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 Maximum Agent Seats
</label> </label>
<input <input
@@ -312,9 +487,10 @@ const Setup: React.FC = () => {
name="maxSeats" name="maxSeats"
value={formData.maxSeats} value={formData.maxSeats}
onChange={handleInputChange} 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" min="1"
max="1000" max="1000"
placeholder="50"
required required
/> />
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p> <p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
@@ -323,19 +499,22 @@ const Setup: React.FC = () => {
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<div className="px-6 py-4 bg-gray-50"> <div className="pt-6 border-t border-gray-200">
<button <button
type="submit" type="submit"
disabled={isLoading} 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 ? ( {isLoading ? (
<div className="flex items-center"> <div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Configuring... Configuring RedFlag Server...
</div> </div>
) : ( ) : (
'Configure Server' <div className="flex items-center">
<Shield className="w-4 h-4 mr-2" />
Configure RedFlag Server
</div>
)} )}
</button> </button>
</div> </div>

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Shield, Shield,
Plus, Plus,
@@ -19,6 +20,7 @@ import {
useRegistrationTokens, useRegistrationTokens,
useCreateRegistrationToken, useCreateRegistrationToken,
useRevokeRegistrationToken, useRevokeRegistrationToken,
useDeleteRegistrationToken,
useRegistrationTokenStats, useRegistrationTokenStats,
useCleanupRegistrationTokens useCleanupRegistrationTokens
} from '../hooks/useRegistrationTokens'; } from '../hooks/useRegistrationTokens';
@@ -26,6 +28,8 @@ import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
const TokenManagement: React.FC = () => { const TokenManagement: React.FC = () => {
const navigate = useNavigate();
// Filters and search // Filters and search
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all'); 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 { data: stats, isLoading: isLoadingStats } = useRegistrationTokenStats();
const createToken = useCreateRegistrationToken(); const createToken = useCreateRegistrationToken();
const revokeToken = useRevokeRegistrationToken(); const revokeToken = useRevokeRegistrationToken();
const deleteToken = useDeleteRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens(); const cleanupTokens = useCleanupRegistrationTokens();
// Reset page when filters change // Reset page when filters change
@@ -57,15 +62,15 @@ const TokenManagement: React.FC = () => {
// Form state // Form state
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({ const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '', label: '',
max_seats: 10, expires_in: '168h', // Default 7 days
expires_at: '', max_seats: 1, // Default 1 seat
}); });
const handleCreateToken = (e: React.FormEvent) => { const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
createToken.mutate(formData, { createToken.mutate(formData, {
onSuccess: () => { onSuccess: () => {
setFormData({ label: '', max_seats: 10, expires_at: '' }); setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreateForm(false); setShowCreateForm(false);
refetch(); 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 = () => { const handleCleanup = () => {
if (confirm('Clean up all expired tokens? This cannot be undone.')) { if (confirm('Clean up all expired tokens? This cannot be undone.')) {
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() }); cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
} }
}; };
const getServerUrl = () => {
return `${window.location.protocol}//${window.location.host}`;
};
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
// Show success feedback // Show success feedback
}; };
const copyInstallCommand = async (token: string) => { 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); await navigator.clipboard.writeText(command);
}; };
const generateInstallCommand = (token: string) => { 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) => { const getStatusColor = (token: RegistrationToken) => {
if (!token.is_active) return 'text-gray-500'; if (token.status === 'revoked') return 'text-gray-500';
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'text-red-600'; if (token.status === 'expired') return 'text-red-600';
if (token.max_seats && token.current_seats >= token.max_seats) return 'text-yellow-600'; if (token.status === 'used') return 'text-yellow-600';
return 'text-green-600'; if (token.status === 'active') return 'text-green-600';
return 'text-gray-500';
}; };
const getStatusText = (token: RegistrationToken) => { const getStatusText = (token: RegistrationToken) => {
if (!token.is_active) return 'Revoked'; if (token.status === 'revoked') return 'Revoked';
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'Expired'; if (token.status === 'expired') return 'Expired';
if (token.max_seats && token.current_seats >= token.max_seats) return 'Full'; if (token.status === 'used') return 'Used';
return 'Active'; if (token.status === 'active') return 'Active';
return token.status.charAt(0).toUpperCase() + token.status.slice(1);
}; };
const filteredTokens = tokensData?.tokens || []; const filteredTokens = tokensData?.tokens || [];
return ( return (
<div className="max-w-7xl mx-auto px-6 py-8"> <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 */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -220,32 +246,37 @@ const TokenManagement: React.FC = () => {
required required
value={formData.label} value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })} 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" 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>
<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 <input
type="number" type="number"
min="1" min="1"
value={formData.max_seats} max="100"
onChange={(e) => setFormData({ ...formData, max_seats: e.target.value ? parseInt(e.target.value) : undefined })} value={formData.max_seats || 1}
placeholder="Leave empty for unlimited" onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="1"
/>
</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)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" 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>
</div> </div>
@@ -403,27 +434,24 @@ const TokenManagement: React.FC = () => {
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900"> <div className="text-sm text-gray-500">
{token.current_seats} {token.seats_used}/{token.max_seats} used
{token.max_seats && ` / ${token.max_seats}`} {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> </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>
)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.created_at)} {formatDateTime(token.created_at)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.expires_at) || 'Never'} {formatDateTime(token.expires_at)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <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>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -441,16 +469,24 @@ const TokenManagement: React.FC = () => {
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</button> </button>
{token.is_active && ( {token.status === 'active' && (
<button <button
onClick={() => handleRevokeToken(token.id, token.label)} onClick={() => handleRevokeToken(token.id, token.label || 'this token')}
disabled={revokeToken.isPending} disabled={revokeToken.isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50" className="text-orange-600 hover:text-orange-800 disabled:opacity-50"
title="Revoke token" title="Revoke token (soft delete)"
> >
<Trash2 className="w-4 h-4" /> <AlertTriangle className="w-4 h-4" />
</button> </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="Permanently delete token"
>
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
</td> </td>
</tr> </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 { export interface RegistrationToken {
id: string; id: string;
token: string; token: string;
label: string; label: string | null;
expires_at: string | null; expires_at: string;
max_seats: number | null;
current_seats: number;
is_active: boolean;
created_at: string; created_at: string;
updated_at: string; used_at: string | null;
last_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>; metadata: Record<string, any>;
max_seats: number;
seats_used: number;
} }
export interface CreateRegistrationTokenRequest { export interface CreateRegistrationTokenRequest {
label: string; label: string;
expires_at?: string; expires_in?: string;
max_seats?: number; max_seats?: number;
metadata?: Record<string, any>; metadata?: Record<string, any>;
} }

View File

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

BIN
test-agent Normal file

Binary file not shown.